@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
package/dist/core/stats-cli.js
CHANGED
|
@@ -198,8 +198,125 @@ export function computeStats() {
|
|
|
198
198
|
lastExtraction: readLastExtraction(),
|
|
199
199
|
assistToday: computeAssistToday(),
|
|
200
200
|
philosophy: computePhilosophy(),
|
|
201
|
+
solutionHealth: computeSolutionHealth(),
|
|
202
|
+
topRules7d: computeTopRules7d(realBlocks),
|
|
203
|
+
weeklyTrend: computeWeeklyTrend(realBlocks),
|
|
201
204
|
};
|
|
202
205
|
}
|
|
206
|
+
function computeSolutionHealth() {
|
|
207
|
+
const byStatus = {};
|
|
208
|
+
let total = 0;
|
|
209
|
+
let confidenceSum = 0;
|
|
210
|
+
const localNames = new Set();
|
|
211
|
+
try {
|
|
212
|
+
if (!fs.existsSync(SOLUTIONS_DIR))
|
|
213
|
+
return { total: 0, byStatus: {}, avgConfidence: 0, utilization7d: 0 };
|
|
214
|
+
const files = fs.readdirSync(SOLUTIONS_DIR).filter(f => f.endsWith('.md'));
|
|
215
|
+
for (const f of files) {
|
|
216
|
+
try {
|
|
217
|
+
const content = fs.readFileSync(path.join(SOLUTIONS_DIR, f), 'utf-8');
|
|
218
|
+
const statusMatch = content.match(/status:\s*"?([a-z]+)"?/);
|
|
219
|
+
const confMatch = content.match(/confidence:\s*([0-9.]+)/);
|
|
220
|
+
const st = statusMatch?.[1] ?? 'unknown';
|
|
221
|
+
byStatus[st] = (byStatus[st] ?? 0) + 1;
|
|
222
|
+
confidenceSum += parseFloat(confMatch?.[1] ?? '0');
|
|
223
|
+
localNames.add(f.replace(/\.md$/, ''));
|
|
224
|
+
total++;
|
|
225
|
+
}
|
|
226
|
+
catch { /* skip */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch { /* fail-open */ }
|
|
230
|
+
const cutoff7d = Date.now() - 7 * MS_PER_DAY;
|
|
231
|
+
const matchLog = readJsonl(path.join(STATE_DIR, 'match-eval-log.jsonl'));
|
|
232
|
+
const matchedLocalNames = new Set();
|
|
233
|
+
for (const e of matchLog) {
|
|
234
|
+
const ts = typeof e.ts === 'string' ? Date.parse(e.ts) : NaN;
|
|
235
|
+
if (!Number.isFinite(ts) || ts < cutoff7d)
|
|
236
|
+
continue;
|
|
237
|
+
const ranked = e.rankedTopN;
|
|
238
|
+
if (Array.isArray(ranked)) {
|
|
239
|
+
for (const r of ranked) {
|
|
240
|
+
let name = null;
|
|
241
|
+
if (typeof r === 'string')
|
|
242
|
+
name = r;
|
|
243
|
+
else if (typeof r === 'object' && r !== null && typeof r.name === 'string') {
|
|
244
|
+
name = r.name;
|
|
245
|
+
}
|
|
246
|
+
// Only count matches against LOCAL solutions to avoid skew from
|
|
247
|
+
// starter-pack/team-pack matches that aren't in this user's me/solutions/.
|
|
248
|
+
if (name && localNames.has(name)) {
|
|
249
|
+
matchedLocalNames.add(name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
total,
|
|
256
|
+
byStatus,
|
|
257
|
+
avgConfidence: total > 0 ? confidenceSum / total : 0,
|
|
258
|
+
utilization7d: total > 0 ? matchedLocalNames.size / total : 0,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function computeTopRules7d(violations) {
|
|
262
|
+
const cutoff = Date.now() - 7 * MS_PER_DAY;
|
|
263
|
+
const counts = new Map();
|
|
264
|
+
for (const v of violations) {
|
|
265
|
+
const ts = typeof v.at === 'string' ? Date.parse(v.at) : NaN;
|
|
266
|
+
if (!Number.isFinite(ts) || ts < cutoff)
|
|
267
|
+
continue;
|
|
268
|
+
const rule = typeof v.rule === 'string' ? v.rule
|
|
269
|
+
: typeof v.guard === 'string' ? v.guard
|
|
270
|
+
: typeof v.source === 'string' ? v.source
|
|
271
|
+
: 'unknown';
|
|
272
|
+
counts.set(rule, (counts.get(rule) ?? 0) + 1);
|
|
273
|
+
}
|
|
274
|
+
return [...counts.entries()]
|
|
275
|
+
.sort((a, b) => b[1] - a[1])
|
|
276
|
+
.slice(0, 3)
|
|
277
|
+
.map(([name, count]) => ({ name, count }));
|
|
278
|
+
}
|
|
279
|
+
function computeWeeklyTrend(violations) {
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
const thisWeekStart = now - 7 * MS_PER_DAY;
|
|
282
|
+
const lastWeekStart = now - 14 * MS_PER_DAY;
|
|
283
|
+
const blocksThisWeek = countWithin(violations, 7);
|
|
284
|
+
let blocksLastWeek = 0;
|
|
285
|
+
for (const v of violations) {
|
|
286
|
+
const ts = typeof v.at === 'string' ? Date.parse(v.at) : NaN;
|
|
287
|
+
if (Number.isFinite(ts) && ts >= lastWeekStart && ts < thisWeekStart)
|
|
288
|
+
blocksLastWeek++;
|
|
289
|
+
}
|
|
290
|
+
const matchLog = readJsonl(path.join(STATE_DIR, 'match-eval-log.jsonl'));
|
|
291
|
+
let recallsThisWeek = 0;
|
|
292
|
+
let recallsLastWeek = 0;
|
|
293
|
+
for (const e of matchLog) {
|
|
294
|
+
const ts = typeof e.ts === 'string' ? Date.parse(e.ts) : NaN;
|
|
295
|
+
if (!Number.isFinite(ts))
|
|
296
|
+
continue;
|
|
297
|
+
if (ts >= thisWeekStart)
|
|
298
|
+
recallsThisWeek++;
|
|
299
|
+
else if (ts >= lastWeekStart)
|
|
300
|
+
recallsLastWeek++;
|
|
301
|
+
}
|
|
302
|
+
let extractionsThisWeek = 0;
|
|
303
|
+
let extractionsLastWeek = 0;
|
|
304
|
+
try {
|
|
305
|
+
if (fs.existsSync(SOLUTIONS_DIR)) {
|
|
306
|
+
for (const f of fs.readdirSync(SOLUTIONS_DIR)) {
|
|
307
|
+
if (!f.endsWith('.md'))
|
|
308
|
+
continue;
|
|
309
|
+
const mtime = fs.statSync(path.join(SOLUTIONS_DIR, f)).mtimeMs;
|
|
310
|
+
if (mtime >= thisWeekStart)
|
|
311
|
+
extractionsThisWeek++;
|
|
312
|
+
else if (mtime >= lastWeekStart)
|
|
313
|
+
extractionsLastWeek++;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch { /* fail-open */ }
|
|
318
|
+
return { blocksThisWeek, blocksLastWeek, recallsThisWeek, recallsLastWeek, extractionsThisWeek, extractionsLastWeek };
|
|
319
|
+
}
|
|
203
320
|
function padNum(n, width = 4) {
|
|
204
321
|
return String(n).padStart(width);
|
|
205
322
|
}
|
|
@@ -256,6 +373,38 @@ export function renderStats(s) {
|
|
|
256
373
|
}
|
|
257
374
|
}
|
|
258
375
|
catch { /* fail-open: git 없거나 비-repo 환경 */ }
|
|
376
|
+
// v0.5.0: Solution health
|
|
377
|
+
if (s.solutionHealth.total > 0) {
|
|
378
|
+
const sh = s.solutionHealth;
|
|
379
|
+
const statusParts = Object.entries(sh.byStatus).map(([k, v]) => `${k}:${v}`).join(' ');
|
|
380
|
+
lines.push(' Solutions');
|
|
381
|
+
lines.push(` Total ${padNum(sh.total)} ${statusParts}`);
|
|
382
|
+
lines.push(` Avg confidence ${sh.avgConfidence.toFixed(2)}`);
|
|
383
|
+
lines.push(` Utilization (7d) ${Math.round(sh.utilization7d * 100)}% — matched at least once`);
|
|
384
|
+
lines.push('');
|
|
385
|
+
}
|
|
386
|
+
// v0.5.0: Top rules (7d)
|
|
387
|
+
if (s.topRules7d.length > 0) {
|
|
388
|
+
lines.push(' Top rules (7d)');
|
|
389
|
+
for (const r of s.topRules7d) {
|
|
390
|
+
lines.push(` ${padNum(r.count)}x ${r.name}`);
|
|
391
|
+
}
|
|
392
|
+
lines.push('');
|
|
393
|
+
}
|
|
394
|
+
// v0.5.0: Weekly trend
|
|
395
|
+
const wt = s.weeklyTrend;
|
|
396
|
+
const trendArrow = (curr, prev) => {
|
|
397
|
+
if (curr > prev)
|
|
398
|
+
return `+${curr - prev}`;
|
|
399
|
+
if (curr < prev)
|
|
400
|
+
return `${curr - prev}`;
|
|
401
|
+
return '=';
|
|
402
|
+
};
|
|
403
|
+
lines.push(' Weekly trend (this vs last)');
|
|
404
|
+
lines.push(` Blocks ${padNum(wt.blocksThisWeek)} → ${padNum(wt.blocksLastWeek, 2)} prev (${trendArrow(wt.blocksThisWeek, wt.blocksLastWeek)})`);
|
|
405
|
+
lines.push(` Recalls ${padNum(wt.recallsThisWeek)} → ${padNum(wt.recallsLastWeek, 2)} prev (${trendArrow(wt.recallsThisWeek, wt.recallsLastWeek)})`);
|
|
406
|
+
lines.push(` Extractions ${padNum(wt.extractionsThisWeek)} → ${padNum(wt.extractionsLastWeek, 2)} prev (${trendArrow(wt.extractionsThisWeek, wt.extractionsLastWeek)})`);
|
|
407
|
+
lines.push('');
|
|
259
408
|
lines.push(` Last extraction: ${s.lastExtraction}`);
|
|
260
409
|
lines.push('');
|
|
261
410
|
return lines.join('\n');
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen watch — real-time hook event stream.
|
|
3
|
+
*
|
|
4
|
+
* Tails hook-timing.jsonl, enforcement/violations.jsonl, and
|
|
5
|
+
* match-eval-log.jsonl to show live forgen activity in a terminal pane.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { STATE_DIR } from './paths.js';
|
|
10
|
+
const isTTY = process.stdout.isTTY;
|
|
11
|
+
const C = {
|
|
12
|
+
reset: isTTY ? '\x1b[0m' : '',
|
|
13
|
+
dim: isTTY ? '\x1b[2m' : '',
|
|
14
|
+
cyan: isTTY ? '\x1b[36m' : '',
|
|
15
|
+
yellow: isTTY ? '\x1b[33m' : '',
|
|
16
|
+
green: isTTY ? '\x1b[32m' : '',
|
|
17
|
+
red: isTTY ? '\x1b[31m' : '',
|
|
18
|
+
magenta: isTTY ? '\x1b[35m' : '',
|
|
19
|
+
};
|
|
20
|
+
function formatTimestamp(isoOrMs) {
|
|
21
|
+
try {
|
|
22
|
+
const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
|
|
23
|
+
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '??:??:??';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function formatHookTiming(e) {
|
|
30
|
+
const hook = String(e.hook ?? '');
|
|
31
|
+
const ms = Number(e.ms ?? 0);
|
|
32
|
+
const event = String(e.event ?? '');
|
|
33
|
+
const ts = formatTimestamp(e.at);
|
|
34
|
+
const speedColor = ms > 500 ? C.yellow : ms > 1000 ? C.red : C.dim;
|
|
35
|
+
return `${C.dim}${ts}${C.reset} ${C.cyan}hook${C.reset} ${hook} ${C.dim}(${event})${C.reset} ${speedColor}${ms}ms${C.reset}`;
|
|
36
|
+
}
|
|
37
|
+
function formatViolation(e) {
|
|
38
|
+
const rule = String(e.rule ?? e.guard ?? e.source ?? 'unknown');
|
|
39
|
+
const kind = String(e.kind ?? 'block');
|
|
40
|
+
const ts = formatTimestamp(String(e.at ?? ''));
|
|
41
|
+
const icon = kind === 'block' ? `${C.red}BLOCK${C.reset}` : `${C.yellow}${kind}${C.reset}`;
|
|
42
|
+
return `${C.dim}${ts}${C.reset} ${icon} ${C.magenta}${rule}${C.reset}`;
|
|
43
|
+
}
|
|
44
|
+
function formatMatchEval(e) {
|
|
45
|
+
const source = String(e.source ?? '');
|
|
46
|
+
const ranked = e.rankedTopN;
|
|
47
|
+
const ts = formatTimestamp(String(e.ts ?? ''));
|
|
48
|
+
if (!ranked || !Array.isArray(ranked) || ranked.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
// rankedTopN entries can be strings (names) or objects {name: ...}
|
|
51
|
+
const names = ranked.slice(0, 3).map(r => {
|
|
52
|
+
if (typeof r === 'string')
|
|
53
|
+
return r;
|
|
54
|
+
if (typeof r === 'object' && r !== null && 'name' in r) {
|
|
55
|
+
return String(r.name);
|
|
56
|
+
}
|
|
57
|
+
return '?';
|
|
58
|
+
}).filter(Boolean).join(', ');
|
|
59
|
+
if (!names)
|
|
60
|
+
return null;
|
|
61
|
+
return `${C.dim}${ts}${C.reset} ${C.green}match${C.reset} ${C.dim}(${source})${C.reset} ${names}`;
|
|
62
|
+
}
|
|
63
|
+
function tailFile(filePath, fromEnd) {
|
|
64
|
+
try {
|
|
65
|
+
if (!fs.existsSync(filePath))
|
|
66
|
+
return [];
|
|
67
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
68
|
+
const lines = content.trim().split('\n');
|
|
69
|
+
const tail = lines.slice(-fromEnd);
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const line of tail) {
|
|
72
|
+
try {
|
|
73
|
+
out.push(JSON.parse(line));
|
|
74
|
+
}
|
|
75
|
+
catch { /* skip */ }
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function watchFile(filePath, onLine) {
|
|
84
|
+
let lastSize = 0;
|
|
85
|
+
try {
|
|
86
|
+
if (fs.existsSync(filePath)) {
|
|
87
|
+
lastSize = fs.statSync(filePath).size;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch { /* ok */ }
|
|
91
|
+
try {
|
|
92
|
+
return fs.watch(filePath, () => {
|
|
93
|
+
try {
|
|
94
|
+
const stat = fs.statSync(filePath);
|
|
95
|
+
if (stat.size <= lastSize) {
|
|
96
|
+
lastSize = stat.size;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const fd = fs.openSync(filePath, 'r');
|
|
100
|
+
const buf = Buffer.alloc(stat.size - lastSize);
|
|
101
|
+
fs.readSync(fd, buf, 0, buf.length, lastSize);
|
|
102
|
+
fs.closeSync(fd);
|
|
103
|
+
lastSize = stat.size;
|
|
104
|
+
const chunk = buf.toString('utf-8');
|
|
105
|
+
for (const line of chunk.split('\n')) {
|
|
106
|
+
if (!line.trim())
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
onLine(JSON.parse(line));
|
|
110
|
+
}
|
|
111
|
+
catch { /* skip */ }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch { /* fail-open */ }
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export async function handleWatch() {
|
|
122
|
+
const sources = [
|
|
123
|
+
{
|
|
124
|
+
label: 'hooks',
|
|
125
|
+
path: path.join(STATE_DIR, 'hook-timing.jsonl'),
|
|
126
|
+
format: formatHookTiming,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
label: 'enforcement',
|
|
130
|
+
path: path.join(STATE_DIR, 'enforcement', 'violations.jsonl'),
|
|
131
|
+
format: formatViolation,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
label: 'matches',
|
|
135
|
+
path: path.join(STATE_DIR, 'match-eval-log.jsonl'),
|
|
136
|
+
format: formatMatchEval,
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
console.log(`\n ${C.cyan}forgen watch${C.reset} — real-time event stream`);
|
|
140
|
+
console.log(` ${C.dim}Watching: hook-timing, violations, match-eval-log${C.reset}`);
|
|
141
|
+
console.log(` ${C.dim}Press Ctrl+C to stop${C.reset}\n`);
|
|
142
|
+
// Show recent events (last 10 per source)
|
|
143
|
+
const recent = [];
|
|
144
|
+
for (const src of sources) {
|
|
145
|
+
const entries = tailFile(src.path, 10);
|
|
146
|
+
for (const e of entries) {
|
|
147
|
+
const line = src.format(e);
|
|
148
|
+
if (!line)
|
|
149
|
+
continue;
|
|
150
|
+
const ts = typeof e.at === 'number' ? e.at
|
|
151
|
+
: typeof e.at === 'string' ? Date.parse(e.at)
|
|
152
|
+
: typeof e.ts === 'string' ? Date.parse(e.ts)
|
|
153
|
+
: 0;
|
|
154
|
+
recent.push({ ts, line });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
recent.sort((a, b) => a.ts - b.ts);
|
|
158
|
+
if (recent.length > 0) {
|
|
159
|
+
console.log(` ${C.dim}── recent ──${C.reset}`);
|
|
160
|
+
for (const r of recent.slice(-15)) {
|
|
161
|
+
console.log(` ${r.line}`);
|
|
162
|
+
}
|
|
163
|
+
console.log(` ${C.dim}── live ──${C.reset}\n`);
|
|
164
|
+
}
|
|
165
|
+
// Watch for new events
|
|
166
|
+
const watchers = [];
|
|
167
|
+
for (const src of sources) {
|
|
168
|
+
const watcher = watchFile(src.path, (entry) => {
|
|
169
|
+
const line = src.format(entry);
|
|
170
|
+
if (line)
|
|
171
|
+
console.log(` ${line}`);
|
|
172
|
+
});
|
|
173
|
+
if (watcher)
|
|
174
|
+
watchers.push(watcher);
|
|
175
|
+
}
|
|
176
|
+
// Keep process alive
|
|
177
|
+
await new Promise((resolve) => {
|
|
178
|
+
process.on('SIGINT', () => {
|
|
179
|
+
for (const w of watchers)
|
|
180
|
+
w.close();
|
|
181
|
+
console.log(`\n ${C.dim}watch stopped${C.reset}\n`);
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen workflows — dynamic-workflow 템플릿 설치/조회 (ADR-009 §3).
|
|
3
|
+
*
|
|
4
|
+
* dynamic workflows 는 플러그인 매니페스트로 번들할 수 없고(research preview,
|
|
5
|
+
* `.claude/workflows/` 에만 저장됨), forgen 은 철학을 인코딩한 canonical 템플릿을
|
|
6
|
+
* `assets/claude/workflows/*.js` 로 동봉한다. 이 명령이 그것을 사용자의
|
|
7
|
+
* `.claude/workflows/` 로 복사한다 → `/<name>` 으로 실행 가능.
|
|
8
|
+
*
|
|
9
|
+
* forgen-verify 에이전트(verify 스테이지용)는 플러그인 `agents` 키로 자동 배포되므로
|
|
10
|
+
* 별도 설치가 필요 없다.
|
|
11
|
+
*
|
|
12
|
+
* forgen workflows install ~/.claude/workflows/ (개인, 모든 프로젝트)
|
|
13
|
+
* forgen workflows install --project <cwd>/.claude/workflows/ (저장소 공유)
|
|
14
|
+
* forgen workflows list 동봉 템플릿 + 설치 여부
|
|
15
|
+
*/
|
|
16
|
+
/** forgen pkg root 의 assets/claude/workflows 디렉토리. invoke-agent 와 동일 walk-up. */
|
|
17
|
+
export declare function findTemplatesDir(): string;
|
|
18
|
+
/** 템플릿 파일명(.js) 목록. 실패 시 []. */
|
|
19
|
+
export declare function listTemplates(templatesDir: string): string[];
|
|
20
|
+
export interface InstallResult {
|
|
21
|
+
installed: string[];
|
|
22
|
+
targetDir: string;
|
|
23
|
+
}
|
|
24
|
+
/** 템플릿을 targetDir 로 복사 (DI — 테스트 주입용). 기존 파일은 덮어쓴다. */
|
|
25
|
+
export declare function installWorkflows(templatesDir: string, targetDir: string): InstallResult;
|
|
26
|
+
export declare function handleWorkflows(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forgen workflows — dynamic-workflow 템플릿 설치/조회 (ADR-009 §3).
|
|
3
|
+
*
|
|
4
|
+
* dynamic workflows 는 플러그인 매니페스트로 번들할 수 없고(research preview,
|
|
5
|
+
* `.claude/workflows/` 에만 저장됨), forgen 은 철학을 인코딩한 canonical 템플릿을
|
|
6
|
+
* `assets/claude/workflows/*.js` 로 동봉한다. 이 명령이 그것을 사용자의
|
|
7
|
+
* `.claude/workflows/` 로 복사한다 → `/<name>` 으로 실행 가능.
|
|
8
|
+
*
|
|
9
|
+
* forgen-verify 에이전트(verify 스테이지용)는 플러그인 `agents` 키로 자동 배포되므로
|
|
10
|
+
* 별도 설치가 필요 없다.
|
|
11
|
+
*
|
|
12
|
+
* forgen workflows install ~/.claude/workflows/ (개인, 모든 프로젝트)
|
|
13
|
+
* forgen workflows install --project <cwd>/.claude/workflows/ (저장소 공유)
|
|
14
|
+
* forgen workflows list 동봉 템플릿 + 설치 여부
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
const isTTY = process.stdout.isTTY;
|
|
21
|
+
const C = {
|
|
22
|
+
reset: isTTY ? '\x1b[0m' : '',
|
|
23
|
+
bold: isTTY ? '\x1b[1m' : '',
|
|
24
|
+
dim: isTTY ? '\x1b[2m' : '',
|
|
25
|
+
green: isTTY ? '\x1b[32m' : '',
|
|
26
|
+
cyan: isTTY ? '\x1b[36m' : '',
|
|
27
|
+
};
|
|
28
|
+
/** forgen pkg root 의 assets/claude/workflows 디렉토리. invoke-agent 와 동일 walk-up. */
|
|
29
|
+
export function findTemplatesDir() {
|
|
30
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
32
|
+
const pkgJson = path.join(dir, 'package.json');
|
|
33
|
+
if (fs.existsSync(pkgJson)) {
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
|
|
36
|
+
if (pkg.name === '@wooojin/forgen') {
|
|
37
|
+
const candidate = path.join(dir, 'assets', 'claude', 'workflows');
|
|
38
|
+
if (fs.existsSync(candidate))
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { /* fallthrough */ }
|
|
43
|
+
}
|
|
44
|
+
const parent = path.dirname(dir);
|
|
45
|
+
if (parent === dir)
|
|
46
|
+
break;
|
|
47
|
+
dir = parent;
|
|
48
|
+
}
|
|
49
|
+
throw new Error('forgen workflows: pkg root + assets/claude/workflows/ not found');
|
|
50
|
+
}
|
|
51
|
+
/** 템플릿 파일명(.js) 목록. 실패 시 []. */
|
|
52
|
+
export function listTemplates(templatesDir) {
|
|
53
|
+
try {
|
|
54
|
+
return fs.readdirSync(templatesDir).filter((f) => f.endsWith('.js')).sort();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** 템플릿을 targetDir 로 복사 (DI — 테스트 주입용). 기존 파일은 덮어쓴다. */
|
|
61
|
+
export function installWorkflows(templatesDir, targetDir) {
|
|
62
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
63
|
+
const installed = [];
|
|
64
|
+
for (const file of listTemplates(templatesDir)) {
|
|
65
|
+
fs.copyFileSync(path.join(templatesDir, file), path.join(targetDir, file));
|
|
66
|
+
installed.push(file);
|
|
67
|
+
}
|
|
68
|
+
return { installed, targetDir };
|
|
69
|
+
}
|
|
70
|
+
function resolveTargetBase(projectScope) {
|
|
71
|
+
return projectScope ? process.cwd() : os.homedir();
|
|
72
|
+
}
|
|
73
|
+
export async function handleWorkflows(args) {
|
|
74
|
+
const sub = args[0] ?? 'list';
|
|
75
|
+
const projectScope = args.includes('--project');
|
|
76
|
+
let templatesDir;
|
|
77
|
+
try {
|
|
78
|
+
templatesDir = findTemplatesDir();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
console.log(`\n ${C.dim}워크플로우 템플릿을 찾을 수 없습니다 (forgen 설치 확인).${C.reset}\n`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (sub === 'install') {
|
|
86
|
+
const targetDir = path.join(resolveTargetBase(projectScope), '.claude', 'workflows');
|
|
87
|
+
const { installed } = installWorkflows(templatesDir, targetDir);
|
|
88
|
+
if (installed.length === 0) {
|
|
89
|
+
console.log(`\n ${C.dim}동봉된 템플릿이 없습니다.${C.reset}\n`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
console.log(`\n ${C.green}✓${C.reset} ${installed.length} workflow templates → ${C.cyan}${targetDir}${C.reset}`);
|
|
93
|
+
for (const f of installed)
|
|
94
|
+
console.log(` /${f.replace(/\.js$/, '')}`);
|
|
95
|
+
console.log(`\n ${C.dim}Claude Code 에서 /<name> 으로 실행. verify 스테이지는 forgen-verify 에이전트 사용.${C.reset}\n`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (sub === 'list') {
|
|
99
|
+
const templates = listTemplates(templatesDir);
|
|
100
|
+
const homeDir = path.join(os.homedir(), '.claude', 'workflows');
|
|
101
|
+
const projDir = path.join(process.cwd(), '.claude', 'workflows');
|
|
102
|
+
console.log(`\n ${C.bold}forgen workflow templates${C.reset}\n`);
|
|
103
|
+
for (const f of templates) {
|
|
104
|
+
const name = f.replace(/\.js$/, '');
|
|
105
|
+
const inHome = fs.existsSync(path.join(homeDir, f));
|
|
106
|
+
const inProj = fs.existsSync(path.join(projDir, f));
|
|
107
|
+
const where = [inHome && 'user', inProj && 'project'].filter(Boolean).join('+') || 'not installed';
|
|
108
|
+
console.log(` ${inHome || inProj ? C.green + '✓' + C.reset : C.dim + '·' + C.reset} /${name} ${C.dim}(${where})${C.reset}`);
|
|
109
|
+
}
|
|
110
|
+
console.log(`\n ${C.dim}install: forgen workflows install [--project]${C.reset}\n`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
console.log(`
|
|
114
|
+
${C.bold}forgen workflows${C.reset} — dynamic-workflow 템플릿 (ADR-009 §3)
|
|
115
|
+
|
|
116
|
+
Usage:
|
|
117
|
+
forgen workflows install [--project] 템플릿을 .claude/workflows/ 로 복사
|
|
118
|
+
forgen workflows list 동봉 템플릿 + 설치 여부
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
@@ -50,6 +50,18 @@ export declare function exportKnowledge(outputPath?: string): ExportResult;
|
|
|
50
50
|
* added.
|
|
51
51
|
*/
|
|
52
52
|
export declare function importKnowledge(archivePath: string): ImportResult;
|
|
53
|
+
/**
|
|
54
|
+
* Selective export — filter to specific categories.
|
|
55
|
+
* `--only solutions,rules` exports only those two directories.
|
|
56
|
+
*/
|
|
57
|
+
export declare function exportKnowledgeSelective(categories: string[], outputPath?: string): ExportResult;
|
|
58
|
+
/**
|
|
59
|
+
* Import with merge mode — updates existing files instead of skipping them.
|
|
60
|
+
* Newer files in the archive overwrite older local files.
|
|
61
|
+
*/
|
|
62
|
+
export declare function importKnowledgeMerge(archivePath: string): ImportResult & {
|
|
63
|
+
merged: number;
|
|
64
|
+
};
|
|
53
65
|
/** CLI handler: forgen compound export */
|
|
54
66
|
export declare function handleExport(args: string[]): Promise<void>;
|
|
55
67
|
/** CLI handler: forgen compound import */
|