@wooojin/forgen 0.2.0 → 0.2.1
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/CHANGELOG.md +28 -0
- package/README.ja.md +79 -14
- package/README.ko.md +89 -14
- package/README.md +77 -14
- package/README.zh.md +79 -14
- package/commands/deep-interview.md +171 -0
- package/commands/specify.md +128 -0
- package/dist/cli.js +9 -0
- package/dist/core/dashboard.d.ts +91 -0
- package/dist/core/dashboard.js +385 -0
- package/dist/core/doctor.js +151 -21
- package/dist/core/drift-score.d.ts +49 -0
- package/dist/core/drift-score.js +87 -0
- package/dist/core/mcp-config.d.ts +2 -0
- package/dist/core/mcp-config.js +6 -1
- package/dist/core/paths.d.ts +1 -1
- package/dist/core/paths.js +1 -1
- package/dist/engine/compound-export.d.ts +41 -0
- package/dist/engine/compound-export.js +169 -0
- package/dist/engine/compound-loop.js +18 -0
- package/dist/engine/solution-matcher.d.ts +23 -0
- package/dist/engine/solution-matcher.js +124 -11
- package/dist/hooks/context-guard.d.ts +10 -0
- package/dist/hooks/context-guard.js +104 -58
- package/dist/hooks/db-guard.js +2 -2
- package/dist/hooks/hook-config.d.ts +27 -1
- package/dist/hooks/hook-config.js +72 -12
- package/dist/hooks/intent-classifier.d.ts +0 -2
- package/dist/hooks/intent-classifier.js +32 -18
- package/dist/hooks/keyword-detector.js +117 -111
- package/dist/hooks/notepad-injector.js +2 -2
- package/dist/hooks/permission-handler.js +2 -2
- package/dist/hooks/post-tool-failure.js +12 -6
- package/dist/hooks/post-tool-handlers.d.ts +1 -1
- package/dist/hooks/post-tool-handlers.js +14 -11
- package/dist/hooks/post-tool-use.d.ts +11 -0
- package/dist/hooks/post-tool-use.js +184 -71
- package/dist/hooks/pre-compact.d.ts +11 -1
- package/dist/hooks/pre-compact.js +112 -37
- package/dist/hooks/pre-tool-use.js +86 -56
- package/dist/hooks/rate-limiter.js +3 -3
- package/dist/hooks/secret-filter.js +2 -2
- package/dist/hooks/session-recovery.js +256 -236
- package/dist/hooks/shared/hook-response.d.ts +4 -4
- package/dist/hooks/shared/hook-response.js +13 -24
- package/dist/hooks/shared/hook-timing.d.ts +15 -0
- package/dist/hooks/shared/hook-timing.js +64 -0
- package/dist/hooks/skill-injector.js +41 -12
- package/dist/hooks/slop-detector.js +3 -3
- package/dist/hooks/solution-injector.js +224 -197
- package/dist/hooks/subagent-tracker.js +2 -2
- package/dist/renderer/rule-renderer.js +9 -11
- package/package.json +1 -1
- package/skills/deep-interview/SKILL.md +166 -0
- package/skills/specify/SKILL.md +122 -0
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* 모델에 컨텍스트를 주입하려면 반드시 additionalContext를 사용해야 함.
|
|
15
15
|
*/
|
|
16
16
|
import * as fs from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
17
18
|
import * as path from 'node:path';
|
|
18
|
-
import { STATE_DIR } from '../../core/paths.js';
|
|
19
19
|
/** 통과 응답 (컨텍스트 없음, 모든 이벤트 공통) */
|
|
20
20
|
export function approve() {
|
|
21
21
|
return JSON.stringify({ continue: true });
|
|
@@ -63,31 +63,20 @@ export function ask(reason) {
|
|
|
63
63
|
export function failOpen() {
|
|
64
64
|
return JSON.stringify({ continue: true });
|
|
65
65
|
}
|
|
66
|
-
/** 훅별 에러 카운트를 STATE_DIR/hook-errors.json에 누적 */
|
|
67
|
-
export function incrementHookErrorCount(hookName) {
|
|
68
|
-
try {
|
|
69
|
-
const errorPath = path.join(STATE_DIR, 'hook-errors.json');
|
|
70
|
-
let errors = {};
|
|
71
|
-
try {
|
|
72
|
-
if (fs.existsSync(errorPath)) {
|
|
73
|
-
errors = JSON.parse(fs.readFileSync(errorPath, 'utf-8'));
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
catch { /* start fresh */ }
|
|
77
|
-
if (!errors[hookName])
|
|
78
|
-
errors[hookName] = { count: 0, lastAt: '' };
|
|
79
|
-
errors[hookName].count++;
|
|
80
|
-
errors[hookName].lastAt = new Date().toISOString();
|
|
81
|
-
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
82
|
-
fs.writeFileSync(errorPath, JSON.stringify(errors, null, 2));
|
|
83
|
-
}
|
|
84
|
-
catch { /* meta-error in error tracking — ignore */ }
|
|
85
|
-
}
|
|
86
66
|
/**
|
|
87
|
-
* fail-open
|
|
88
|
-
*
|
|
67
|
+
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
68
|
+
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
69
|
+
*
|
|
70
|
+
* @fail-open: hook failure must never block the user's workflow
|
|
89
71
|
*/
|
|
90
72
|
export function failOpenWithTracking(hookName) {
|
|
91
|
-
|
|
73
|
+
try {
|
|
74
|
+
const stateDir = path.join(os.homedir(), '.forgen', 'state');
|
|
75
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
76
|
+
const logPath = path.join(stateDir, 'hook-errors.jsonl');
|
|
77
|
+
const entry = JSON.stringify({ hook: hookName, at: Date.now() });
|
|
78
|
+
fs.appendFileSync(logPath, entry + '\n');
|
|
79
|
+
}
|
|
80
|
+
catch { /* fail-open: tracking itself must not throw */ }
|
|
92
81
|
return JSON.stringify({ continue: true });
|
|
93
82
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Hook Timing Profiler
|
|
3
|
+
*
|
|
4
|
+
* Records hook execution durations and provides timing statistics
|
|
5
|
+
* for visibility into which hooks are slow.
|
|
6
|
+
*/
|
|
7
|
+
export declare function recordHookTiming(hookName: string, durationMs: number, event: string): void;
|
|
8
|
+
export interface TimingStats {
|
|
9
|
+
hook: string;
|
|
10
|
+
count: number;
|
|
11
|
+
p50: number;
|
|
12
|
+
p95: number;
|
|
13
|
+
max: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function getTimingStats(): TimingStats[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Hook Timing Profiler
|
|
3
|
+
*
|
|
4
|
+
* Records hook execution durations and provides timing statistics
|
|
5
|
+
* for visibility into which hooks are slow.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { STATE_DIR } from '../../core/paths.js';
|
|
10
|
+
const TIMING_LOG = path.join(STATE_DIR, 'hook-timing.jsonl');
|
|
11
|
+
const MAX_LINES = 500;
|
|
12
|
+
export function recordHookTiming(hookName, durationMs, event) {
|
|
13
|
+
try {
|
|
14
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
15
|
+
const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now() });
|
|
16
|
+
fs.appendFileSync(TIMING_LOG, entry + '\n');
|
|
17
|
+
// Rotate if too large
|
|
18
|
+
try {
|
|
19
|
+
const content = fs.readFileSync(TIMING_LOG, 'utf-8');
|
|
20
|
+
const lines = content.trim().split('\n');
|
|
21
|
+
if (lines.length > MAX_LINES) {
|
|
22
|
+
fs.writeFileSync(TIMING_LOG, lines.slice(-MAX_LINES).join('\n') + '\n');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch { /* skip rotation on error */ }
|
|
26
|
+
}
|
|
27
|
+
catch { /* fail-open */ }
|
|
28
|
+
}
|
|
29
|
+
export function getTimingStats() {
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(TIMING_LOG))
|
|
32
|
+
return [];
|
|
33
|
+
const content = fs.readFileSync(TIMING_LOG, 'utf-8');
|
|
34
|
+
const entries = content.trim().split('\n')
|
|
35
|
+
.map(line => { try {
|
|
36
|
+
return JSON.parse(line);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
} })
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
const byHook = new Map();
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
if (!byHook.has(e.hook))
|
|
45
|
+
byHook.set(e.hook, []);
|
|
46
|
+
byHook.get(e.hook).push(e.ms);
|
|
47
|
+
}
|
|
48
|
+
const stats = [];
|
|
49
|
+
for (const [hook, times] of byHook) {
|
|
50
|
+
times.sort((a, b) => a - b);
|
|
51
|
+
stats.push({
|
|
52
|
+
hook,
|
|
53
|
+
count: times.length,
|
|
54
|
+
p50: times[Math.floor(times.length * 0.5)] ?? 0,
|
|
55
|
+
p95: times[Math.floor(times.length * 0.95)] ?? 0,
|
|
56
|
+
max: times[times.length - 1] ?? 0,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return stats.sort((a, b) => b.p95 - a.p95);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -34,10 +34,11 @@ function escapeXmlAttr(s) {
|
|
|
34
34
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
35
35
|
}
|
|
36
36
|
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
37
|
+
import { withFileLockSync } from './shared/file-lock.js';
|
|
37
38
|
import { FORGEN_HOME, ME_DIR, STATE_DIR } from '../core/paths.js';
|
|
38
39
|
import { KEYWORD_PATTERNS } from './keyword-detector.js';
|
|
39
40
|
import { isHookEnabled } from './hook-config.js';
|
|
40
|
-
import { approve, approveWithContext,
|
|
41
|
+
import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
|
|
41
42
|
/** keyword-detector가 처리하는 키워드 이름 집합 (skill + inject 모두 포함, 이중 주입 방지) */
|
|
42
43
|
const KEYWORD_DETECTOR_SKILL_NAMES = new Set(KEYWORD_PATTERNS
|
|
43
44
|
.filter(p => p.type === 'skill' || p.type === 'inject')
|
|
@@ -65,11 +66,39 @@ function loadSessionCache(sessionId) {
|
|
|
65
66
|
}
|
|
66
67
|
return new Set();
|
|
67
68
|
}
|
|
68
|
-
function saveSessionCache(sessionId,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
function saveSessionCache(sessionId, newSkillNames) {
|
|
70
|
+
const cachePath = getSessionCachePath(sessionId);
|
|
71
|
+
try {
|
|
72
|
+
withFileLockSync(cachePath, () => {
|
|
73
|
+
// Lock 안에서 fresh re-read → merge → write
|
|
74
|
+
const freshInjected = new Set();
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(cachePath)) {
|
|
77
|
+
const fresh = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
78
|
+
const age = fresh.updatedAt ? Date.now() - new Date(fresh.updatedAt).getTime() : Infinity;
|
|
79
|
+
if (Number.isFinite(age) && age <= 24 * 60 * 60 * 1000) {
|
|
80
|
+
for (const name of fresh.injected ?? [])
|
|
81
|
+
freshInjected.add(name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { /* fresh re-read 실패 시 빈 set으로 진행 */ }
|
|
86
|
+
for (const name of newSkillNames)
|
|
87
|
+
freshInjected.add(name);
|
|
88
|
+
atomicWriteJSON(cachePath, {
|
|
89
|
+
injected: [...freshInjected],
|
|
90
|
+
updatedAt: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
// Lock 실패 시 fail-open: 직접 write (중복 주입 가능성 있지만 기능 차단 안 함)
|
|
96
|
+
log.debug('skill session cache lock 실패 — fallback write', e);
|
|
97
|
+
atomicWriteJSON(cachePath, {
|
|
98
|
+
injected: [...new Set([...loadSessionCache(sessionId), ...newSkillNames])],
|
|
99
|
+
updatedAt: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
73
102
|
}
|
|
74
103
|
/** YAML frontmatter 파싱 (간단한 구현) */
|
|
75
104
|
export function parseFrontmatter(content) {
|
|
@@ -258,11 +287,11 @@ async function main() {
|
|
|
258
287
|
}
|
|
259
288
|
// 최대 제한 적용
|
|
260
289
|
const toInject = matched.slice(0, MAX_SKILLS_PER_SESSION - injected.size);
|
|
261
|
-
// 파일 기반 캐시 업데이트
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
saveSessionCache(sessionId,
|
|
290
|
+
// 파일 기반 캐시 업데이트 (lock 보호)
|
|
291
|
+
const newSkillNames = toInject.map(s => s.name);
|
|
292
|
+
for (const name of newSkillNames)
|
|
293
|
+
injected.add(name);
|
|
294
|
+
saveSessionCache(sessionId, newSkillNames);
|
|
266
295
|
// Adaptive budget: 다른 플러그인 감지 시 스킬 주입량 축소
|
|
267
296
|
let skillCap = 3000; // INJECTION_CAPS.skillContentMax 기본값
|
|
268
297
|
try {
|
|
@@ -281,5 +310,5 @@ async function main() {
|
|
|
281
310
|
}
|
|
282
311
|
main().catch((e) => {
|
|
283
312
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
284
|
-
console.log(
|
|
313
|
+
console.log(failOpenWithTracking('skill-injector'));
|
|
285
314
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { readStdinJSON } from './shared/read-stdin.js';
|
|
10
10
|
import { createLogger } from '../core/logger.js';
|
|
11
11
|
import { isHookEnabled, loadHookConfig } from './hook-config.js';
|
|
12
|
-
import { approve, approveWithWarning,
|
|
12
|
+
import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
|
|
13
13
|
const log = createLogger('slop-detector');
|
|
14
14
|
export const SLOP_PATTERNS = [
|
|
15
15
|
{ pattern: /\/\/\s*TODO:?\s*(implement|add|fix|handle)/i, message: 'Leftover TODO comment', severity: 'warn' },
|
|
@@ -84,10 +84,10 @@ async function main() {
|
|
|
84
84
|
}
|
|
85
85
|
catch (e) {
|
|
86
86
|
log.debug('슬롭 감지 실패', e);
|
|
87
|
-
console.log(
|
|
87
|
+
console.log(failOpenWithTracking('slop-detector'));
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
main().catch((e) => {
|
|
91
91
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
92
|
-
console.log(
|
|
92
|
+
console.log(failOpenWithTracking('slop-detector'));
|
|
93
93
|
});
|