@wooojin/forgen 0.4.8 → 0.4.9
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/assets/dev-guide/be/README.md +226 -0
- package/assets/dev-guide/be/adapters/build-agents-md.sh +63 -0
- package/assets/dev-guide/be/principles/common.md +433 -0
- package/assets/dev-guide/be/principles/go.md +469 -0
- package/assets/dev-guide/be/principles/node.md +388 -0
- package/assets/dev-guide/be/skills/go/be-build/SKILL.md +262 -0
- package/assets/dev-guide/be/skills/go/be-perf/SKILL.md +308 -0
- package/assets/dev-guide/be/skills/go/be-review/SKILL.md +119 -0
- package/assets/dev-guide/be/skills/go/be-security/SKILL.md +362 -0
- package/assets/dev-guide/be/skills/node/be-build/SKILL.md +239 -0
- package/assets/dev-guide/be/skills/node/be-perf/SKILL.md +272 -0
- package/assets/dev-guide/be/skills/node/be-review/SKILL.md +118 -0
- package/assets/dev-guide/be/skills/node/be-security/SKILL.md +355 -0
- package/assets/dev-guide/be/sources/12factor/INDEX.md +53 -0
- package/assets/dev-guide/be/sources/api-design/INDEX.md +56 -0
- package/assets/dev-guide/be/sources/ddia/INDEX.md +55 -0
- package/assets/dev-guide/be/sources/go-runtime/INDEX.md +62 -0
- package/assets/dev-guide/be/sources/node-runtime/INDEX.md +60 -0
- package/assets/dev-guide/be/sources/otel/INDEX.md +53 -0
- package/assets/dev-guide/be/sources/owasp-api/INDEX.md +52 -0
- package/assets/dev-guide/be/sources/postgres/INDEX.md +55 -0
- package/assets/dev-guide/be/sources/sre-book/INDEX.md +48 -0
- package/assets/dev-guide/fe/README.md +197 -0
- package/assets/dev-guide/fe/adapters/build-agents-md.sh +63 -0
- package/assets/dev-guide/fe/adapters/refresh.sh +68 -0
- package/assets/dev-guide/fe/principles/common.md +160 -0
- package/assets/dev-guide/fe/principles/react.md +183 -0
- package/assets/dev-guide/fe/principles/vue.md +196 -0
- package/assets/dev-guide/fe/skills/react/fe-build/SKILL.md +139 -0
- package/assets/dev-guide/fe/skills/react/fe-perf/SKILL.md +179 -0
- package/assets/dev-guide/fe/skills/react/fe-review/SKILL.md +141 -0
- package/assets/dev-guide/fe/skills/vue/fe-build/SKILL.md +148 -0
- package/assets/dev-guide/fe/skills/vue/fe-perf/SKILL.md +163 -0
- package/assets/dev-guide/fe/skills/vue/fe-review/SKILL.md +136 -0
- package/assets/dev-guide/fe/sources/a11y-dx/INDEX.md +41 -0
- package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-memory.md +150 -0
- package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-performance.md +99 -0
- package/assets/dev-guide/fe/sources/a11y-dx/lighthouse-audits.md +146 -0
- package/assets/dev-guide/fe/sources/a11y-dx/react-devtools-profiler.md +128 -0
- package/assets/dev-guide/fe/sources/a11y-dx/wcag22-new-criteria.md +174 -0
- package/assets/dev-guide/fe/sources/perf/01-core-web-vitals.md +58 -0
- package/assets/dev-guide/fe/sources/perf/02-inp.md +83 -0
- package/assets/dev-guide/fe/sources/perf/03-lcp-cls.md +130 -0
- package/assets/dev-guide/fe/sources/perf/04-speculation-rules.md +148 -0
- package/assets/dev-guide/fe/sources/perf/05-view-transitions.md +153 -0
- package/assets/dev-guide/fe/sources/perf/06-nextjs-caching.md +188 -0
- package/assets/dev-guide/fe/sources/perf/07-server-components.md +181 -0
- package/assets/dev-guide/fe/sources/perf/08-ppr.md +133 -0
- package/assets/dev-guide/fe/sources/perf/09-nextjs-image.md +200 -0
- package/assets/dev-guide/fe/sources/perf/10-optimize-lcp.md +201 -0
- package/assets/dev-guide/fe/sources/perf/INDEX.md +88 -0
- package/assets/dev-guide/fe/sources/react/INDEX.md +41 -0
- package/assets/dev-guide/fe/sources/react/keeping-components-pure.md +135 -0
- package/assets/dev-guide/fe/sources/react/no-effect-patterns.md +183 -0
- package/assets/dev-guide/fe/sources/react/react-compiler.md +182 -0
- package/assets/dev-guide/fe/sources/react/server-components.md +194 -0
- package/assets/dev-guide/fe/sources/react/server-functions.md +192 -0
- package/assets/dev-guide/fe/sources/react/suspense.md +218 -0
- package/assets/dev-guide/fe/sources/react/use-action-state.md +123 -0
- package/assets/dev-guide/fe/sources/react/use-form-status.md +158 -0
- package/assets/dev-guide/fe/sources/react/use-hook.md +153 -0
- package/assets/dev-guide/fe/sources/react/use-optimistic.md +194 -0
- package/assets/dev-guide/fe/sources/toss-ff/INDEX.md +58 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-code-directory.md +79 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-form-fields.md +110 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-magic-number.md +47 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-item-edit-modal.md +124 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-use-bottom-sheet.md +57 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-use-page-state.md +71 -0
- package/assets/dev-guide/fe/sources/toss-ff/overview-4-principles.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-hidden-logic.md +59 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-http.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-use-user.md +110 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-comparison-order.md +52 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-condition-name.md +64 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-login-start-page.md +183 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-magic-number.md +53 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-submit-button.md +73 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-ternary-operator.md +38 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-use-page-state.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-user-policy.md +98 -0
- package/assets/dev-guide/fe/sources/vue/INDEX.md +17 -0
- package/assets/dev-guide/fe/sources/vue/composition-api.md +251 -0
- package/assets/dev-guide/fe/sources/vue/nuxt-data-fetching.md +232 -0
- package/assets/dev-guide/fe/sources/vue/pinia-state-management.md +134 -0
- package/assets/dev-guide/fe/sources/vue/reactivity-pitfalls.md +261 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-a.md +117 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-b.md +231 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-c.md +86 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-d.md +72 -0
- package/dist/cli.js +42 -0
- package/dist/core/dashboard-cli.d.ts +12 -0
- package/dist/core/dashboard-cli.js +226 -0
- package/dist/core/dev-guide-injector.d.ts +26 -0
- package/dist/core/dev-guide-injector.js +137 -0
- package/dist/core/init.js +53 -0
- package/dist/core/lifecycle-classifier.d.ts +23 -0
- package/dist/core/lifecycle-classifier.js +104 -0
- package/dist/core/observability-backfill.d.ts +31 -0
- package/dist/core/observability-backfill.js +178 -0
- package/dist/core/observability-store.d.ts +58 -0
- package/dist/core/observability-store.js +195 -0
- package/dist/core/session-store.js +4 -0
- package/dist/core/spawn.d.ts +17 -0
- package/dist/core/spawn.js +179 -2
- package/dist/core/statusline-cli.js +34 -1
- package/dist/engine/compound-extractor.js +39 -0
- package/dist/engine/compound-loop.js +6 -0
- package/dist/engine/compound-retire.d.ts +20 -0
- package/dist/engine/compound-retire.js +85 -0
- package/dist/hooks/context-guard.js +25 -1
- package/dist/hooks/post-tool-use.js +48 -0
- package/dist/hooks/solution-injector.js +93 -0
- package/dist/host/install-claude.d.ts +6 -2
- package/dist/host/install-claude.js +74 -2
- package/dist/host/install-codex.d.ts +4 -0
- package/dist/host/install-codex.js +71 -0
- package/dist/host/install-orchestrator.js +1 -0
- package/package.json +6 -6
- package/plugin.json +1 -1
- package/scripts/postinstall.js +134 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Dashboard CLI (P3)
|
|
3
|
+
*
|
|
4
|
+
* `fgx status [--watch] [--json] [--interval N]`
|
|
5
|
+
* ANSI box-drawing 기반 상태 대시보드. TUI 라이브러리 없음.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { getUsageStats } from './usage-telemetry.js';
|
|
10
|
+
import { classifySolutions } from './lifecycle-classifier.js';
|
|
11
|
+
import { STATE_DIR } from './paths.js';
|
|
12
|
+
import { FORGEN_HOME } from './paths.js';
|
|
13
|
+
// ── ANSI ──────────────────────────────────────────────────────────────────────
|
|
14
|
+
const isTTY = process.stdout.isTTY;
|
|
15
|
+
const C = {
|
|
16
|
+
reset: isTTY ? '\x1b[0m' : '',
|
|
17
|
+
bold: isTTY ? '\x1b[1m' : '',
|
|
18
|
+
dim: isTTY ? '\x1b[2m' : '',
|
|
19
|
+
cyan: isTTY ? '\x1b[36m' : '',
|
|
20
|
+
yellow: isTTY ? '\x1b[33m' : '',
|
|
21
|
+
green: isTTY ? '\x1b[32m' : '',
|
|
22
|
+
red: isTTY ? '\x1b[31m' : '',
|
|
23
|
+
};
|
|
24
|
+
const BOX_WIDTH = 66;
|
|
25
|
+
function bar(pct, width = 10) {
|
|
26
|
+
const filled = Math.round(pct * width);
|
|
27
|
+
const empty = width - filled;
|
|
28
|
+
return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, empty));
|
|
29
|
+
}
|
|
30
|
+
// 런타임에 정규식 구성 — biome 의 noControlCharactersInRegex 우회.
|
|
31
|
+
// ESC (0x1B) 는 ANSI escape 의 시작이지만 literal regex 에는 못 박힘.
|
|
32
|
+
const ANSI_STRIP_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
|
33
|
+
function boxLine(content) {
|
|
34
|
+
const plain = content.replace(ANSI_STRIP_RE, '');
|
|
35
|
+
const padding = BOX_WIDTH - 2 - plain.length;
|
|
36
|
+
return `│ ${content}${' '.repeat(Math.max(0, padding))} │`;
|
|
37
|
+
}
|
|
38
|
+
function boxEmpty() {
|
|
39
|
+
return `│${' '.repeat(BOX_WIDTH - 2)}│`;
|
|
40
|
+
}
|
|
41
|
+
function boxTop() {
|
|
42
|
+
return `┌─ ${C.bold}${C.cyan}forgen status${C.reset}` +
|
|
43
|
+
' ' + '─'.repeat(BOX_WIDTH - 17) + '┐';
|
|
44
|
+
}
|
|
45
|
+
function boxBottom() {
|
|
46
|
+
return `└${'─'.repeat(BOX_WIDTH - 2)}┘`;
|
|
47
|
+
}
|
|
48
|
+
function collectData() {
|
|
49
|
+
const now = new Date();
|
|
50
|
+
// usage
|
|
51
|
+
const usage = (() => {
|
|
52
|
+
try {
|
|
53
|
+
return getUsageStats();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { hour5: { claude: 0, codex: 0, total: 0 }, week: { claude: 0, codex: 0, total: 0 } };
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
// today extracted: me/solutions/*.md mtime이 오늘인 것 (또는 last-extraction.json)
|
|
60
|
+
const todayExtracted = (() => {
|
|
61
|
+
try {
|
|
62
|
+
const lastExtPath = path.join(STATE_DIR, 'last-extraction.json');
|
|
63
|
+
if (fs.existsSync(lastExtPath)) {
|
|
64
|
+
const data = JSON.parse(fs.readFileSync(lastExtPath, 'utf-8'));
|
|
65
|
+
const ts = data.ts ?? 0;
|
|
66
|
+
const isToday = new Date(ts).toDateString() === now.toDateString();
|
|
67
|
+
if (isToday && typeof data.count === 'number')
|
|
68
|
+
return data.count;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* ignore */ }
|
|
72
|
+
// fallback: count solutions modified today
|
|
73
|
+
try {
|
|
74
|
+
const solutionsDir = path.join(FORGEN_HOME, 'me', 'solutions');
|
|
75
|
+
if (!fs.existsSync(solutionsDir))
|
|
76
|
+
return 0;
|
|
77
|
+
const files = fs.readdirSync(solutionsDir).filter(f => f.endsWith('.md'));
|
|
78
|
+
const todayStr = now.toDateString();
|
|
79
|
+
return files.filter(f => {
|
|
80
|
+
try {
|
|
81
|
+
const stat = fs.statSync(path.join(solutionsDir, f));
|
|
82
|
+
return new Date(stat.mtimeMs).toDateString() === todayStr;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}).length;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
// lifecycle
|
|
94
|
+
const classified = (() => {
|
|
95
|
+
try {
|
|
96
|
+
return classifySolutions();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
const counts = { hot: 0, warm: 0, cold: 0, dead: 0, new: 0 };
|
|
103
|
+
for (const c of classified)
|
|
104
|
+
counts[c.lifecycle]++;
|
|
105
|
+
const topHot = classified
|
|
106
|
+
.filter(c => c.lifecycle === 'hot')
|
|
107
|
+
.sort((a, b) => b.acted_90d - a.acted_90d)
|
|
108
|
+
.slice(0, 5)
|
|
109
|
+
.map(c => ({
|
|
110
|
+
id: c.solutionId,
|
|
111
|
+
surfaced: c.surfaced_90d,
|
|
112
|
+
acted: c.acted_90d,
|
|
113
|
+
rate: c.hitRate ?? 0,
|
|
114
|
+
}));
|
|
115
|
+
// rate-limit misses (7d)
|
|
116
|
+
const rateLimitMisses7d = (() => {
|
|
117
|
+
try {
|
|
118
|
+
const missPath = path.join(STATE_DIR, 'rate-limit-misses.jsonl');
|
|
119
|
+
if (!fs.existsSync(missPath))
|
|
120
|
+
return 0;
|
|
121
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
122
|
+
const lines = fs.readFileSync(missPath, 'utf-8').split('\n').filter(Boolean);
|
|
123
|
+
return lines.filter(l => {
|
|
124
|
+
try {
|
|
125
|
+
const obj = JSON.parse(l);
|
|
126
|
+
return typeof obj.ts === 'number' && obj.ts >= cutoff;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}).length;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
return {
|
|
138
|
+
timestamp: now.toISOString(),
|
|
139
|
+
usage,
|
|
140
|
+
todayExtracted,
|
|
141
|
+
solutions: {
|
|
142
|
+
total: classified.length,
|
|
143
|
+
...counts,
|
|
144
|
+
topHot,
|
|
145
|
+
classified,
|
|
146
|
+
},
|
|
147
|
+
rateLimitMisses7d,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// ── Render ─────────────────────────────────────────────────────────────────────
|
|
151
|
+
function renderTTY(data) {
|
|
152
|
+
const lines = [];
|
|
153
|
+
lines.push(boxTop());
|
|
154
|
+
lines.push(boxEmpty());
|
|
155
|
+
// Usage
|
|
156
|
+
lines.push(boxLine(`${C.bold}Usage${C.reset}`));
|
|
157
|
+
const h5total = data.usage.hour5.total;
|
|
158
|
+
const h5pct = Math.min(1, h5total / Math.max(h5total + 10, 50));
|
|
159
|
+
const h5bar = bar(h5pct);
|
|
160
|
+
lines.push(boxLine(` 5h window: ${C.yellow}${h5bar}${C.reset} (${h5total} tool calls)`));
|
|
161
|
+
const wktotal = data.usage.week.total;
|
|
162
|
+
const wkpct = Math.min(1, wktotal / Math.max(wktotal + 10, 100));
|
|
163
|
+
const wkbar = bar(wkpct);
|
|
164
|
+
lines.push(boxLine(` weekly: ${C.yellow}${wkbar}${C.reset} (${wktotal} tool calls)`));
|
|
165
|
+
lines.push(boxEmpty());
|
|
166
|
+
// Today compound
|
|
167
|
+
lines.push(boxLine(`${C.bold}Today's compound${C.reset}`));
|
|
168
|
+
lines.push(boxLine(` extracted: ${data.todayExtracted} solutions`));
|
|
169
|
+
lines.push(boxEmpty());
|
|
170
|
+
// Solutions
|
|
171
|
+
lines.push(boxLine(`${C.bold}Solutions${C.reset} (${data.solutions.total} total)`));
|
|
172
|
+
const lifecycleLine = ` ${C.red}🔥 hot:${C.reset} ${data.solutions.hot}` +
|
|
173
|
+
` ${C.yellow}🟡 warm:${C.reset} ${data.solutions.warm}` +
|
|
174
|
+
` ${C.cyan}🥶 cold:${C.reset} ${data.solutions.cold}` +
|
|
175
|
+
` ${C.dim}💀 dead:${C.reset} ${data.solutions.dead}` +
|
|
176
|
+
` ${C.green}🌱 new:${C.reset} ${data.solutions.new}`;
|
|
177
|
+
lines.push(boxLine(lifecycleLine));
|
|
178
|
+
lines.push(boxEmpty());
|
|
179
|
+
// Top 5 hot
|
|
180
|
+
if (data.solutions.topHot.length > 0) {
|
|
181
|
+
lines.push(boxLine(`${C.bold}Top ${data.solutions.topHot.length} hot (90d)${C.reset}`));
|
|
182
|
+
for (const h of data.solutions.topHot) {
|
|
183
|
+
const pctStr = `${Math.round(h.rate * 100)}%`;
|
|
184
|
+
const idShort = h.id.length > 36 ? h.id.slice(0, 33) + '...' : h.id;
|
|
185
|
+
lines.push(boxLine(` · ${C.yellow}${idShort}${C.reset} surf=${h.surfaced} acted=${h.acted} (${pctStr})`));
|
|
186
|
+
}
|
|
187
|
+
lines.push(boxEmpty());
|
|
188
|
+
}
|
|
189
|
+
// Rate-limit
|
|
190
|
+
lines.push(boxLine(`${C.bold}Rate-limit${C.reset}`));
|
|
191
|
+
lines.push(boxLine(` misses (7d): ${data.rateLimitMisses7d}`));
|
|
192
|
+
lines.push(boxEmpty());
|
|
193
|
+
// Last update
|
|
194
|
+
lines.push(boxLine(`${C.dim}Last update: ${data.timestamp}${C.reset}`));
|
|
195
|
+
lines.push(boxBottom());
|
|
196
|
+
return lines.join('\n');
|
|
197
|
+
}
|
|
198
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
199
|
+
export async function runDashboard(opts) {
|
|
200
|
+
const intervalSec = opts.intervalSec ?? 5;
|
|
201
|
+
const render = () => {
|
|
202
|
+
const data = collectData();
|
|
203
|
+
if (opts.json) {
|
|
204
|
+
// Strip classified array (verbose) from JSON output for cleaner schema
|
|
205
|
+
const { solutions: { classified: _c, ...solutionStats }, ...rest } = data;
|
|
206
|
+
process.stdout.write(JSON.stringify({ ...rest, solutions: solutionStats }, null, 2) + '\n');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const output = renderTTY(data);
|
|
210
|
+
if (opts.watch) {
|
|
211
|
+
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
212
|
+
}
|
|
213
|
+
process.stdout.write(output + '\n');
|
|
214
|
+
};
|
|
215
|
+
render();
|
|
216
|
+
if (opts.watch) {
|
|
217
|
+
const interval = setInterval(render, intervalSec * 1000);
|
|
218
|
+
process.on('SIGINT', () => {
|
|
219
|
+
clearInterval(interval);
|
|
220
|
+
process.stdout.write('\n');
|
|
221
|
+
process.exit(0);
|
|
222
|
+
});
|
|
223
|
+
// Keep process alive
|
|
224
|
+
await new Promise(() => { });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-guide-injector — fgx init 시 dev-guide principles 를
|
|
3
|
+
* Claude (.claude/rules/dev-guide-principles.md) + Codex (AGENTS.md managed block)
|
|
4
|
+
* 양쪽에 stack-aware 자동 inject.
|
|
5
|
+
*/
|
|
6
|
+
export interface DetectedStack {
|
|
7
|
+
side: 'fe' | 'be';
|
|
8
|
+
stack: 'react' | 'vue' | 'node' | 'go';
|
|
9
|
+
principlesFiles: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface InjectResult {
|
|
12
|
+
stack: DetectedStack | null;
|
|
13
|
+
claudeRulePath: string;
|
|
14
|
+
claudeRuleWritten: boolean;
|
|
15
|
+
agentsMdPath: string;
|
|
16
|
+
agentsMdInjected: boolean;
|
|
17
|
+
bytesWritten: number;
|
|
18
|
+
}
|
|
19
|
+
export interface InjectOptions {
|
|
20
|
+
cwd: string;
|
|
21
|
+
pkgRoot: string;
|
|
22
|
+
dryRun?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function detectStack(cwd: string): DetectedStack | null;
|
|
25
|
+
export declare function readPrinciples(pkgRoot: string, side: 'fe' | 'be', stack: string): string;
|
|
26
|
+
export declare function injectDevGuidePrinciples(opts: InjectOptions): InjectResult;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-guide-injector — fgx init 시 dev-guide principles 를
|
|
3
|
+
* Claude (.claude/rules/dev-guide-principles.md) + Codex (AGENTS.md managed block)
|
|
4
|
+
* 양쪽에 stack-aware 자동 inject.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
// ── 상수 ──────────────────────────────────────────────────────────────────────
|
|
9
|
+
const AGENTS_MD_BEGIN = '<!-- >>> forgen-managed-rules -->';
|
|
10
|
+
const AGENTS_MD_END = '<!-- <<< forgen-managed-rules -->';
|
|
11
|
+
// ── detectStack ───────────────────────────────────────────────────────────────
|
|
12
|
+
export function detectStack(cwd) {
|
|
13
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
14
|
+
if (fs.existsSync(pkgPath)) {
|
|
15
|
+
try {
|
|
16
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
17
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies };
|
|
18
|
+
if ('react' in deps || 'next' in deps) {
|
|
19
|
+
return { side: 'fe', stack: 'react', principlesFiles: ['common.md', 'react.md'] };
|
|
20
|
+
}
|
|
21
|
+
if ('vue' in deps || 'nuxt' in deps) {
|
|
22
|
+
return { side: 'fe', stack: 'vue', principlesFiles: ['common.md', 'vue.md'] };
|
|
23
|
+
}
|
|
24
|
+
return { side: 'be', stack: 'node', principlesFiles: ['common.md', 'node.md'] };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// JSON parse 실패 — fall through to go.mod check
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (fs.existsSync(path.join(cwd, 'go.mod'))) {
|
|
31
|
+
return { side: 'be', stack: 'go', principlesFiles: ['common.md', 'go.md'] };
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
// ── readPrinciples ────────────────────────────────────────────────────────────
|
|
36
|
+
export function readPrinciples(pkgRoot, side, stack) {
|
|
37
|
+
const principlesDir = path.join(pkgRoot, 'assets', 'dev-guide', side, 'principles');
|
|
38
|
+
const commonContent = fs.readFileSync(path.join(principlesDir, 'common.md'), 'utf-8');
|
|
39
|
+
const stackContent = fs.readFileSync(path.join(principlesDir, `${stack}.md`), 'utf-8');
|
|
40
|
+
return [
|
|
41
|
+
`<!-- forgen dev-guide principles (auto-generated, do not edit) -->`,
|
|
42
|
+
`<!-- source: assets/dev-guide/${side}/principles/common.md + ${stack}.md -->`,
|
|
43
|
+
``,
|
|
44
|
+
`# common (${side})`,
|
|
45
|
+
commonContent.trimEnd(),
|
|
46
|
+
``,
|
|
47
|
+
`---`,
|
|
48
|
+
``,
|
|
49
|
+
`# ${stack}`,
|
|
50
|
+
stackContent.trimEnd(),
|
|
51
|
+
``,
|
|
52
|
+
].join('\n');
|
|
53
|
+
}
|
|
54
|
+
// ── injectDevGuidePrinciples ──────────────────────────────────────────────────
|
|
55
|
+
export function injectDevGuidePrinciples(opts) {
|
|
56
|
+
const { cwd, pkgRoot, dryRun = false } = opts;
|
|
57
|
+
const claudeRulePath = path.join(cwd, '.claude', 'rules', 'dev-guide-principles.md');
|
|
58
|
+
const agentsMdPath = path.join(cwd, 'AGENTS.md');
|
|
59
|
+
const emptyResult = {
|
|
60
|
+
stack: null,
|
|
61
|
+
claudeRulePath,
|
|
62
|
+
claudeRuleWritten: false,
|
|
63
|
+
agentsMdPath,
|
|
64
|
+
agentsMdInjected: false,
|
|
65
|
+
bytesWritten: 0,
|
|
66
|
+
};
|
|
67
|
+
const detected = detectStack(cwd);
|
|
68
|
+
if (!detected)
|
|
69
|
+
return emptyResult;
|
|
70
|
+
const body = readPrinciples(pkgRoot, detected.side, detected.stack);
|
|
71
|
+
// ── Claude side ──
|
|
72
|
+
const claudeRuleWritten = writeClaudeRule({ claudeRulePath, body, dryRun });
|
|
73
|
+
// ── Codex side ──
|
|
74
|
+
const agentsMdInjected = upsertAgentsMd({ agentsMdPath, body, dryRun });
|
|
75
|
+
return {
|
|
76
|
+
stack: detected,
|
|
77
|
+
claudeRulePath,
|
|
78
|
+
claudeRuleWritten,
|
|
79
|
+
agentsMdPath,
|
|
80
|
+
agentsMdInjected,
|
|
81
|
+
bytesWritten: dryRun ? 0 : body.length,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
85
|
+
function writeClaudeRule(opts) {
|
|
86
|
+
const { claudeRulePath, body, dryRun } = opts;
|
|
87
|
+
const existing = fs.existsSync(claudeRulePath)
|
|
88
|
+
? fs.readFileSync(claudeRulePath, 'utf-8')
|
|
89
|
+
: null;
|
|
90
|
+
if (existing === body)
|
|
91
|
+
return false;
|
|
92
|
+
if (!dryRun) {
|
|
93
|
+
fs.mkdirSync(path.dirname(claudeRulePath), { recursive: true });
|
|
94
|
+
fs.writeFileSync(claudeRulePath, body, 'utf-8');
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
function escapeRegex(s) {
|
|
99
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
100
|
+
}
|
|
101
|
+
function buildDevGuideBlock(body) {
|
|
102
|
+
return [AGENTS_MD_BEGIN, body.trimEnd(), AGENTS_MD_END].join('\n');
|
|
103
|
+
}
|
|
104
|
+
function upsertAgentsMd(opts) {
|
|
105
|
+
const { agentsMdPath, body, dryRun } = opts;
|
|
106
|
+
const block = buildDevGuideBlock(body);
|
|
107
|
+
const current = fs.existsSync(agentsMdPath) ? fs.readFileSync(agentsMdPath, 'utf-8') : '';
|
|
108
|
+
const reMarker = new RegExp(`${escapeRegex(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRegex(AGENTS_MD_END)}`);
|
|
109
|
+
const hasBlock = reMarker.test(current);
|
|
110
|
+
let newContent;
|
|
111
|
+
if (hasBlock) {
|
|
112
|
+
newContent = current.replace(reMarker, block);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const beginIdx = current.indexOf(AGENTS_MD_BEGIN);
|
|
116
|
+
const endIdx = current.indexOf(AGENTS_MD_END);
|
|
117
|
+
if (beginIdx !== -1 && endIdx === -1) {
|
|
118
|
+
// self-heal: begin 만 있고 end 손상
|
|
119
|
+
newContent = `${current.slice(0, beginIdx).replace(/\s+$/, '')}\n\n${block}\n`;
|
|
120
|
+
}
|
|
121
|
+
else if (current.length === 0) {
|
|
122
|
+
// 신규 파일
|
|
123
|
+
newContent = `# Agent Instructions (forgen-managed)\n\n${block}\n`;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
// 기존 파일에 append
|
|
127
|
+
newContent = `${current.replace(/\s+$/, '')}\n\n${block}\n`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (newContent === current)
|
|
131
|
+
return false;
|
|
132
|
+
if (!dryRun) {
|
|
133
|
+
fs.mkdirSync(path.dirname(agentsMdPath), { recursive: true });
|
|
134
|
+
fs.writeFileSync(agentsMdPath, newContent, 'utf-8');
|
|
135
|
+
}
|
|
136
|
+
return true;
|
|
137
|
+
}
|
package/dist/core/init.js
CHANGED
|
@@ -9,6 +9,7 @@ import * as path from 'node:path';
|
|
|
9
9
|
import { profileExists } from '../store/profile-store.js';
|
|
10
10
|
import { ensureV1Directories } from './v1-bootstrap.js';
|
|
11
11
|
import { initializeForgenHome } from './init-cli.js';
|
|
12
|
+
import { injectDevGuidePrinciples } from './dev-guide-injector.js';
|
|
12
13
|
// ── CLI 핸들러 ──
|
|
13
14
|
export async function handleInit(_args) {
|
|
14
15
|
const cwd = process.cwd();
|
|
@@ -19,6 +20,21 @@ export async function handleInit(_args) {
|
|
|
19
20
|
// 프로젝트 .claude/rules 디렉토리 생성
|
|
20
21
|
const rulesDir = path.join(cwd, '.claude', 'rules');
|
|
21
22
|
fs.mkdirSync(rulesDir, { recursive: true });
|
|
23
|
+
// dev-guide principles 자동 inject — profile 존재 여부와 무관한 stateless 작업.
|
|
24
|
+
// profileExists() early-return / onboarding 흐름 어디든 동일하게 통과해야 하므로
|
|
25
|
+
// 두 분기 앞쪽에 배치 (기존 위치는 unreachable 경로였음).
|
|
26
|
+
const pkgRoot = path.resolve(import.meta.dirname ?? __dirname, '..', '..');
|
|
27
|
+
const injectResult = injectDevGuidePrinciples({ cwd, pkgRoot });
|
|
28
|
+
if (injectResult.stack) {
|
|
29
|
+
const { side, stack } = injectResult.stack;
|
|
30
|
+
const stackLabel = side === 'fe'
|
|
31
|
+
? (stack === 'react' ? 'React/Next' : 'Vue/Nuxt')
|
|
32
|
+
: (stack === 'go' ? 'Go' : 'Node.js');
|
|
33
|
+
console.log(` 💡 ${stackLabel} 프로젝트 감지됨. dev-guide principles 자동 inject:`);
|
|
34
|
+
console.log(` ${injectResult.claudeRulePath} (Claude)`);
|
|
35
|
+
console.log(` ${injectResult.agentsMdPath} (Codex, managed block)`);
|
|
36
|
+
suggestStackSkills(cwd);
|
|
37
|
+
}
|
|
22
38
|
// v0.4.1 (2026-04-24): starter-pack 프로비저닝 — 격리 홈 / 신규 FORGEN_HOME
|
|
23
39
|
// 에서 "신규 사용자 첫날 가치" 가 0이 되는 결함 해소. npm install-g 시의
|
|
24
40
|
// postinstall 이 하던 starter 배포를 런타임에서도 보장.
|
|
@@ -52,3 +68,40 @@ export async function handleInit(_args) {
|
|
|
52
68
|
console.log(' forgen inspect profile View your profile');
|
|
53
69
|
console.log(' forgen doctor Check system health\n');
|
|
54
70
|
}
|
|
71
|
+
// 감지된 스택을 알리고, fgx 번들에 포함된 dev-guide 스킬을 안내.
|
|
72
|
+
// 실제 설치는 install-claude.ts 가 ~/.claude/skills/ 에 처리.
|
|
73
|
+
function suggestStackSkills(cwd) {
|
|
74
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
75
|
+
const goModPath = path.join(cwd, 'go.mod');
|
|
76
|
+
let stack = null;
|
|
77
|
+
let skills = [];
|
|
78
|
+
if (fs.existsSync(pkgPath)) {
|
|
79
|
+
try {
|
|
80
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
81
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies };
|
|
82
|
+
if ('react' in deps || 'next' in deps) {
|
|
83
|
+
stack = 'React/Next';
|
|
84
|
+
skills = ['fe-build-react', 'fe-review-react', 'fe-perf-react'];
|
|
85
|
+
}
|
|
86
|
+
else if ('vue' in deps || 'nuxt' in deps) {
|
|
87
|
+
stack = 'Vue/Nuxt';
|
|
88
|
+
skills = ['fe-build-vue', 'fe-review-vue', 'fe-perf-vue'];
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
stack = 'Node.js';
|
|
92
|
+
skills = ['be-build-node', 'be-review-node', 'be-perf-node', 'be-security-node'];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch { /* ignore */ }
|
|
96
|
+
}
|
|
97
|
+
else if (fs.existsSync(goModPath)) {
|
|
98
|
+
stack = 'Go';
|
|
99
|
+
skills = ['be-build-go', 'be-review-go', 'be-perf-go', 'be-security-go'];
|
|
100
|
+
}
|
|
101
|
+
if (!stack)
|
|
102
|
+
return;
|
|
103
|
+
console.log(` 💡 ${stack} 프로젝트로 감지됨. fgx 번들 dev-guide 스킬 사용 가능:`);
|
|
104
|
+
for (const s of skills)
|
|
105
|
+
console.log(` /${s}`);
|
|
106
|
+
console.log('');
|
|
107
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Lifecycle Classifier (P3)
|
|
3
|
+
*
|
|
4
|
+
* 솔루션 catalog (~/.forgen/me/solutions/*.md) 를 읽어 각 솔루션의
|
|
5
|
+
* lifecycle 을 hot/warm/cold/dead/new 로 분류한다.
|
|
6
|
+
*/
|
|
7
|
+
import type { HitRateRow } from './observability-store.js';
|
|
8
|
+
export type Lifecycle = 'hot' | 'warm' | 'cold' | 'dead' | 'new';
|
|
9
|
+
export interface LifecycleClass {
|
|
10
|
+
solutionId: string;
|
|
11
|
+
lifecycle: Lifecycle;
|
|
12
|
+
/** acted_90d / max(surfaced_90d, 1). surfaced_90d == 0 이면 null */
|
|
13
|
+
hitRate: number | null;
|
|
14
|
+
matched_90d: number;
|
|
15
|
+
surfaced_90d: number;
|
|
16
|
+
acted_90d: number;
|
|
17
|
+
matched_180d: number;
|
|
18
|
+
ageDays: number;
|
|
19
|
+
}
|
|
20
|
+
/** 분류 로직 — §5.2 */
|
|
21
|
+
export declare function classifyOne(_solutionId: string, ageDays: number, rates: HitRateRow): Lifecycle;
|
|
22
|
+
/** ~/.forgen/me/solutions/*.md 전체를 분류하여 반환 */
|
|
23
|
+
export declare function classifySolutions(): LifecycleClass[];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Lifecycle Classifier (P3)
|
|
3
|
+
*
|
|
4
|
+
* 솔루션 catalog (~/.forgen/me/solutions/*.md) 를 읽어 각 솔루션의
|
|
5
|
+
* lifecycle 을 hot/warm/cold/dead/new 로 분류한다.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { ME_SOLUTIONS } from './paths.js';
|
|
10
|
+
import { parseFrontmatterOnly } from '../engine/solution-format.js';
|
|
11
|
+
import { queryHitRate } from './observability-store.js';
|
|
12
|
+
/** 분류 로직 — §5.2 */
|
|
13
|
+
export function classifyOne(_solutionId, ageDays, rates) {
|
|
14
|
+
// new: age ≤ 30d
|
|
15
|
+
if (ageDays <= 30)
|
|
16
|
+
return 'new';
|
|
17
|
+
// dead: matched_180d == 0 AND age > 30d
|
|
18
|
+
if (rates.matched_180d === 0)
|
|
19
|
+
return 'dead';
|
|
20
|
+
// hot: acted_90d >= 3 AND (acted_90d / max(surfaced_90d, 1)) >= 0.4
|
|
21
|
+
const hitRate = rates.acted_90d / Math.max(rates.surfaced_90d, 1);
|
|
22
|
+
if (rates.acted_90d >= 3 && hitRate >= 0.4)
|
|
23
|
+
return 'hot';
|
|
24
|
+
// warm: surfaced_90d >= 3 AND acted_90d >= 1
|
|
25
|
+
if (rates.surfaced_90d >= 3 && rates.acted_90d >= 1)
|
|
26
|
+
return 'warm';
|
|
27
|
+
// cold: matched_90d >= 1 AND surfaced_90d == 0
|
|
28
|
+
if (rates.matched_90d >= 1 && rates.surfaced_90d === 0)
|
|
29
|
+
return 'cold';
|
|
30
|
+
// fallback
|
|
31
|
+
return 'cold';
|
|
32
|
+
}
|
|
33
|
+
/** ~/.forgen/me/solutions/*.md 전체를 분류하여 반환 */
|
|
34
|
+
export function classifySolutions() {
|
|
35
|
+
const results = [];
|
|
36
|
+
let files;
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(ME_SOLUTIONS))
|
|
39
|
+
return [];
|
|
40
|
+
files = fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
// queryHitRate() 는 전체 결과를 한 번에 가져옴
|
|
46
|
+
const rateMap = new Map();
|
|
47
|
+
try {
|
|
48
|
+
const rows = queryHitRate();
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
rateMap.set(row.solutionId, row);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// fail-open: DB 없으면 빈 map
|
|
55
|
+
}
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const filePath = path.join(ME_SOLUTIONS, file);
|
|
59
|
+
let content;
|
|
60
|
+
try {
|
|
61
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const fm = parseFrontmatterOnly(content);
|
|
67
|
+
if (!fm)
|
|
68
|
+
continue;
|
|
69
|
+
const solutionId = fm.name;
|
|
70
|
+
// ageDays: frontmatter created date 에서 계산
|
|
71
|
+
let ageDays = 999;
|
|
72
|
+
try {
|
|
73
|
+
const createdMs = new Date(fm.created).getTime();
|
|
74
|
+
if (!isNaN(createdMs)) {
|
|
75
|
+
ageDays = Math.floor((now - createdMs) / (24 * 60 * 60 * 1000));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// keep 999 (treat as old)
|
|
80
|
+
}
|
|
81
|
+
const rates = rateMap.get(solutionId) ?? {
|
|
82
|
+
solutionId,
|
|
83
|
+
matched_30d: 0, surfaced_30d: 0, acted_30d: 0,
|
|
84
|
+
matched_90d: 0, surfaced_90d: 0, acted_90d: 0,
|
|
85
|
+
matched_180d: 0, surfaced_180d: 0, acted_180d: 0,
|
|
86
|
+
last_event_ts: 0,
|
|
87
|
+
};
|
|
88
|
+
const lifecycle = classifyOne(solutionId, ageDays, rates);
|
|
89
|
+
const hitRate = rates.surfaced_90d > 0
|
|
90
|
+
? rates.acted_90d / rates.surfaced_90d
|
|
91
|
+
: null;
|
|
92
|
+
results.push({
|
|
93
|
+
solutionId,
|
|
94
|
+
lifecycle,
|
|
95
|
+
hitRate,
|
|
96
|
+
matched_90d: rates.matched_90d,
|
|
97
|
+
surfaced_90d: rates.surfaced_90d,
|
|
98
|
+
acted_90d: rates.acted_90d,
|
|
99
|
+
matched_180d: rates.matched_180d,
|
|
100
|
+
ageDays,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Observability Backfill (Phase 2)
|
|
3
|
+
*
|
|
4
|
+
* 기존 JSONL 상태 파일에서 solution_events 를 소급 생성한다.
|
|
5
|
+
* Phase A (결정론적): match-eval-log, implicit-feedback, compound-usage, outcomes
|
|
6
|
+
* Phase B (휴리스틱): transcript 스캔 — CLI --phase B|all 로만 활성
|
|
7
|
+
*
|
|
8
|
+
* 안전성:
|
|
9
|
+
* - 기본: events 가 이미 있으면 reject (--force 필요)
|
|
10
|
+
* - signal_source 에 '-backfill' prefix 로 실시간 emit 과 구분
|
|
11
|
+
* - BEGIN/COMMIT 단위 트랜잭션
|
|
12
|
+
* - fail-open: 파일 누락은 조용히 skip
|
|
13
|
+
*/
|
|
14
|
+
export interface BackfillOptions {
|
|
15
|
+
force?: boolean;
|
|
16
|
+
phase?: 'A' | 'B' | 'all';
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface BackfillResult {
|
|
20
|
+
phaseA: {
|
|
21
|
+
matched: number;
|
|
22
|
+
surfaced: number;
|
|
23
|
+
acted_on: number;
|
|
24
|
+
};
|
|
25
|
+
phaseB: {
|
|
26
|
+
acted_on: number;
|
|
27
|
+
};
|
|
28
|
+
total: number;
|
|
29
|
+
durationMs: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function runBackfill(opts?: BackfillOptions): Promise<BackfillResult>;
|