@wooojin/forgen 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.ja.md +79 -14
- package/README.ko.md +100 -14
- package/README.md +124 -17
- package/README.zh.md +79 -14
- package/agents/analyst.md +48 -4
- package/agents/architect.md +39 -4
- package/agents/code-reviewer.md +107 -77
- package/agents/critic.md +47 -4
- package/agents/debugger.md +46 -4
- package/agents/designer.md +40 -4
- package/agents/executor.md +112 -30
- package/agents/explore.md +45 -5
- package/agents/git-master.md +48 -4
- package/agents/planner.md +121 -18
- package/agents/test-engineer.md +58 -4
- package/agents/verifier.md +92 -77
- package/commands/architecture-decision.md +127 -258
- package/commands/calibrate.md +225 -0
- package/commands/code-review.md +163 -178
- package/commands/compound.md +127 -68
- package/commands/deep-interview.md +273 -0
- package/commands/docker.md +68 -178
- package/commands/forge-loop.md +215 -0
- package/commands/learn.md +231 -0
- package/commands/retro.md +215 -0
- package/commands/ship.md +277 -0
- package/dist/cli.js +26 -9
- package/dist/core/auto-compound-runner.js +14 -0
- package/dist/core/config-injector.d.ts +2 -1
- package/dist/core/config-injector.js +2 -1
- package/dist/core/dashboard.d.ts +108 -0
- package/dist/core/dashboard.js +495 -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/harness.d.ts +6 -1
- package/dist/core/harness.js +75 -19
- package/dist/core/mcp-config.d.ts +2 -0
- package/dist/core/mcp-config.js +6 -1
- package/dist/core/paths.d.ts +6 -1
- package/dist/core/paths.js +18 -2
- package/dist/core/spawn.d.ts +3 -2
- package/dist/core/spawn.js +27 -8
- package/dist/core/types.d.ts +34 -0
- package/dist/engine/compound-export.d.ts +41 -0
- package/dist/engine/compound-export.js +169 -0
- package/dist/engine/compound-lifecycle.d.ts +4 -3
- package/dist/engine/compound-lifecycle.js +91 -46
- package/dist/engine/compound-loop.js +18 -0
- package/dist/engine/meta-learning/adaptive-thresholds.d.ts +20 -0
- package/dist/engine/meta-learning/adaptive-thresholds.js +126 -0
- package/dist/engine/meta-learning/extraction-tuner.d.ts +15 -0
- package/dist/engine/meta-learning/extraction-tuner.js +99 -0
- package/dist/engine/meta-learning/matcher-weight-tuner.d.ts +21 -0
- package/dist/engine/meta-learning/matcher-weight-tuner.js +151 -0
- package/dist/engine/meta-learning/runner.d.ts +14 -0
- package/dist/engine/meta-learning/runner.js +90 -0
- package/dist/engine/meta-learning/scope-promoter.d.ts +21 -0
- package/dist/engine/meta-learning/scope-promoter.js +84 -0
- package/dist/engine/meta-learning/session-quality-scorer.d.ts +61 -0
- package/dist/engine/meta-learning/session-quality-scorer.js +166 -0
- package/dist/engine/meta-learning/types.d.ts +114 -0
- package/dist/engine/meta-learning/types.js +43 -0
- package/dist/engine/solution-format.d.ts +2 -2
- package/dist/engine/solution-format.js +249 -34
- package/dist/engine/solution-index.d.ts +1 -1
- package/dist/engine/solution-matcher.d.ts +30 -1
- package/dist/engine/solution-matcher.js +235 -45
- package/dist/fgx.js +12 -8
- package/dist/hooks/context-guard.d.ts +15 -0
- package/dist/hooks/context-guard.js +218 -56
- 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/hooks-generator.d.ts +3 -0
- package/dist/hooks/hooks-generator.js +23 -6
- package/dist/hooks/intent-classifier.d.ts +0 -2
- package/dist/hooks/intent-classifier.js +32 -18
- package/dist/hooks/keyword-detector.js +126 -204
- 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.d.ts +4 -3
- package/dist/hooks/skill-injector.js +47 -16
- 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/host/codex-adapter.d.ts +10 -0
- package/dist/host/codex-adapter.js +154 -0
- package/dist/mcp/solution-reader.d.ts +5 -5
- package/dist/mcp/solution-reader.js +34 -24
- package/dist/renderer/rule-renderer.js +9 -11
- package/dist/services/session.d.ts +19 -0
- package/dist/services/session.js +62 -0
- package/hooks/hooks.json +2 -2
- package/package.json +2 -1
- package/skills/architecture-decision/SKILL.md +113 -257
- package/skills/calibrate/SKILL.md +207 -0
- package/skills/code-review/SKILL.md +151 -178
- package/skills/compound/SKILL.md +126 -68
- package/skills/deep-interview/SKILL.md +266 -0
- package/skills/docker/SKILL.md +57 -179
- package/skills/forge-loop/SKILL.md +198 -0
- package/skills/learn/SKILL.md +216 -0
- package/skills/retro/SKILL.md +199 -0
- package/skills/ship/SKILL.md +259 -0
- package/agents/code-simplifier.md +0 -197
- package/agents/performance-reviewer.md +0 -172
- package/agents/qa-tester.md +0 -158
- package/agents/refactoring-expert.md +0 -168
- package/agents/scientist.md +0 -144
- package/agents/security-reviewer.md +0 -137
- package/agents/writer.md +0 -184
- package/commands/api-design.md +0 -268
- package/commands/ci-cd.md +0 -270
- package/commands/database.md +0 -263
- package/commands/debug-detective.md +0 -99
- package/commands/documentation.md +0 -276
- package/commands/ecomode.md +0 -51
- package/commands/frontend.md +0 -271
- package/commands/git-master.md +0 -90
- package/commands/incident-response.md +0 -292
- package/commands/migrate.md +0 -101
- package/commands/performance.md +0 -288
- package/commands/refactor.md +0 -105
- package/commands/security-review.md +0 -288
- package/commands/tdd.md +0 -183
- package/commands/testing-strategy.md +0 -265
- package/skills/api-design/SKILL.md +0 -262
- package/skills/ci-cd/SKILL.md +0 -264
- package/skills/database/SKILL.md +0 -257
- package/skills/debug-detective/SKILL.md +0 -95
- package/skills/documentation/SKILL.md +0 -270
- package/skills/ecomode/SKILL.md +0 -46
- package/skills/frontend/SKILL.md +0 -265
- package/skills/git-master/SKILL.md +0 -86
- package/skills/incident-response/SKILL.md +0 -286
- package/skills/migrate/SKILL.md +0 -96
- package/skills/performance/SKILL.md +0 -282
- package/skills/refactor/SKILL.md +0 -100
- package/skills/security-review/SKILL.md +0 -282
- package/skills/tdd/SKILL.md +0 -178
- package/skills/testing-strategy/SKILL.md +0 -260
|
@@ -16,8 +16,29 @@ import { saveCheckpoint } from './session-recovery.js';
|
|
|
16
16
|
// v1: recordWriteContent (regex 선호 감지) 제거
|
|
17
17
|
import { incrementFailureCounter, checkCompoundNegative, getCompoundSuccessHint } from './post-tool-handlers.js';
|
|
18
18
|
import { isHookEnabled } from './hook-config.js';
|
|
19
|
-
import { approve, approveWithWarning,
|
|
19
|
+
import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
|
|
20
20
|
import { STATE_DIR } from '../core/paths.js';
|
|
21
|
+
import { recordHookTiming } from './shared/hook-timing.js';
|
|
22
|
+
import { createDriftState, evaluateDrift } from '../core/drift-score.js';
|
|
23
|
+
/** Lightweight hash for content comparison (not cryptographic) */
|
|
24
|
+
function simpleHash(content) {
|
|
25
|
+
let hash = 0;
|
|
26
|
+
for (let i = 0; i < content.length; i++) {
|
|
27
|
+
const char = content.charCodeAt(i);
|
|
28
|
+
hash = ((hash << 5) - hash) + char;
|
|
29
|
+
hash |= 0; // Convert to 32-bit integer
|
|
30
|
+
}
|
|
31
|
+
return hash.toString(36);
|
|
32
|
+
}
|
|
33
|
+
const IMPLICIT_FEEDBACK_LOG = path.join(STATE_DIR, 'implicit-feedback.jsonl');
|
|
34
|
+
/** Record implicit feedback signal to JSONL */
|
|
35
|
+
function recordImplicitFeedback(entry) {
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
38
|
+
fs.appendFileSync(IMPLICIT_FEEDBACK_LOG, JSON.stringify(entry) + '\n');
|
|
39
|
+
}
|
|
40
|
+
catch { /* fail-open: implicit feedback recording must not throw */ }
|
|
41
|
+
}
|
|
21
42
|
// ── State management ──
|
|
22
43
|
function getModifiedFilesPath(sessionId) {
|
|
23
44
|
return path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
|
|
@@ -53,6 +74,28 @@ export function detectErrorPattern(text) {
|
|
|
53
74
|
}
|
|
54
75
|
return null;
|
|
55
76
|
}
|
|
77
|
+
const AGENT_MIN_OUTPUT_LENGTH = 50;
|
|
78
|
+
const AGENT_QUALITY_PATTERNS = [
|
|
79
|
+
{ pattern: /I (?:couldn'?t|could not|was unable to|cannot) (?:find|locate|access|determine)/i, signal: 'agent_unable', severity: 'warning', message: 'Agent reported inability to complete the task' },
|
|
80
|
+
{ pattern: /(?:no (?:files?|results?|matches?) found|returned? (?:no|empty|zero) results?)/i, signal: 'agent_no_results', severity: 'warning', message: 'Agent found no results' },
|
|
81
|
+
{ pattern: /(?:timed? ?out|deadline exceeded|execution expired)/i, signal: 'agent_timeout', severity: 'error', message: 'Agent execution may have timed out' },
|
|
82
|
+
{ pattern: /(?:context (?:window|limit) (?:exceeded|reached)|too (?:large|long) to (?:read|process))/i, signal: 'agent_context_overflow', severity: 'warning', message: 'Agent hit context limits — output may be incomplete' },
|
|
83
|
+
];
|
|
84
|
+
export function validateAgentOutput(toolResponse) {
|
|
85
|
+
if (!toolResponse || toolResponse.trim().length < AGENT_MIN_OUTPUT_LENGTH) {
|
|
86
|
+
return {
|
|
87
|
+
signal: 'agent_empty_output',
|
|
88
|
+
severity: 'warning',
|
|
89
|
+
message: `Agent returned minimal output (${toolResponse?.trim().length ?? 0} chars). Verify the result is usable.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
for (const p of AGENT_QUALITY_PATTERNS) {
|
|
93
|
+
if (p.pattern.test(toolResponse)) {
|
|
94
|
+
return { signal: p.signal, severity: p.severity, message: p.message };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
56
99
|
export function trackModifiedFile(state, filePath, toolName) {
|
|
57
100
|
const existing = state.files[filePath];
|
|
58
101
|
const count = (existing?.count ?? 0) + 1;
|
|
@@ -65,87 +108,157 @@ export function trackModifiedFile(state, filePath, toolName) {
|
|
|
65
108
|
}
|
|
66
109
|
// ── Main flow ──
|
|
67
110
|
async function main() {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
console.log(approve());
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
78
|
-
const toolInput = data.tool_input ?? data.toolInput ?? {};
|
|
79
|
-
const toolResponse = data.tool_response ?? data.toolOutput ?? '';
|
|
80
|
-
const sessionId = data.session_id ?? 'default';
|
|
81
|
-
const modState = loadModifiedFiles(sessionId);
|
|
82
|
-
modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
|
|
83
|
-
const messages = [];
|
|
84
|
-
// 1. Checkpoint (every 5 calls)
|
|
85
|
-
if (modState.toolCallCount % 5 === 0) {
|
|
86
|
-
try {
|
|
87
|
-
saveCheckpoint({
|
|
88
|
-
sessionId, mode: 'active',
|
|
89
|
-
modifiedFiles: Object.keys(modState.files),
|
|
90
|
-
lastToolCall: toolName,
|
|
91
|
-
toolCallCount: modState.toolCallCount,
|
|
92
|
-
timestamp: new Date().toISOString(),
|
|
93
|
-
cwd: data.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(),
|
|
94
|
-
});
|
|
111
|
+
const _hookStart = Date.now();
|
|
112
|
+
try {
|
|
113
|
+
const data = await readStdinJSON();
|
|
114
|
+
if (!data) {
|
|
115
|
+
console.log(approve());
|
|
116
|
+
return;
|
|
95
117
|
}
|
|
96
|
-
|
|
97
|
-
log
|
|
118
|
+
if (!isHookEnabled('post-tool-use')) {
|
|
119
|
+
console.log(approve());
|
|
120
|
+
return;
|
|
98
121
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
122
|
+
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
123
|
+
const toolInput = data.tool_input ?? data.toolInput ?? {};
|
|
124
|
+
const toolResponse = data.tool_response ?? data.toolOutput ?? '';
|
|
125
|
+
const sessionId = data.session_id ?? 'default';
|
|
126
|
+
const modState = loadModifiedFiles(sessionId);
|
|
127
|
+
modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
|
|
128
|
+
const messages = [];
|
|
129
|
+
let revertDetected = false;
|
|
130
|
+
// 1. Checkpoint (every 5 calls)
|
|
131
|
+
if (modState.toolCallCount % 5 === 0) {
|
|
104
132
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
133
|
+
saveCheckpoint({
|
|
134
|
+
sessionId, mode: 'active',
|
|
135
|
+
modifiedFiles: Object.keys(modState.files),
|
|
136
|
+
lastToolCall: toolName,
|
|
137
|
+
toolCallCount: modState.toolCallCount,
|
|
138
|
+
timestamp: new Date().toISOString(),
|
|
139
|
+
cwd: data.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(),
|
|
140
|
+
});
|
|
109
141
|
}
|
|
110
142
|
catch (e) {
|
|
111
|
-
log.debug('
|
|
143
|
+
log.debug('체크포인트 저장 실패', e);
|
|
112
144
|
}
|
|
113
145
|
}
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
146
|
+
// 2. File change tracking (Write, Edit) + implicit feedback detection
|
|
147
|
+
if (toolName === 'Write' || toolName === 'Edit') {
|
|
148
|
+
const filePath = toolInput.file_path ?? toolInput.filePath ?? '';
|
|
149
|
+
if (filePath) {
|
|
150
|
+
try {
|
|
151
|
+
const { count } = trackModifiedFile(modState, filePath, toolName);
|
|
152
|
+
// Implicit feedback: repeated edit detection (5+ edits on same file)
|
|
153
|
+
if (count >= 5) {
|
|
154
|
+
messages.push(`<compound-tool-warning>\n[Forgen] ⚠ ${path.basename(filePath)} has been modified ${count} times.\nConsider redesigning the overall structure and restarting.\n</compound-tool-warning>`);
|
|
155
|
+
recordImplicitFeedback({
|
|
156
|
+
type: 'repeated_edit',
|
|
157
|
+
file: filePath,
|
|
158
|
+
editCount: count,
|
|
159
|
+
at: new Date().toISOString(),
|
|
160
|
+
sessionId,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Implicit feedback: revert detection
|
|
164
|
+
// Track content hashes of recent writes to detect when content is reverted
|
|
165
|
+
const newContent = toolInput.content ?? toolInput.new_string ?? '';
|
|
166
|
+
if (newContent) {
|
|
167
|
+
const hash = simpleHash(newContent);
|
|
168
|
+
if (!modState.recentWrites)
|
|
169
|
+
modState.recentWrites = {};
|
|
170
|
+
const prevHashes = modState.recentWrites[filePath] ?? [];
|
|
171
|
+
// Check if this content hash matches a previous write (revert pattern)
|
|
172
|
+
// Skip the most recent hash (which would be the write being "reverted from")
|
|
173
|
+
if (prevHashes.length >= 2 && prevHashes.slice(0, -1).includes(hash)) {
|
|
174
|
+
revertDetected = true;
|
|
175
|
+
recordImplicitFeedback({
|
|
176
|
+
type: 'revert_detected',
|
|
177
|
+
file: filePath,
|
|
178
|
+
at: new Date().toISOString(),
|
|
179
|
+
sessionId,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Keep last 10 hashes per file
|
|
183
|
+
prevHashes.push(hash);
|
|
184
|
+
if (prevHashes.length > 10)
|
|
185
|
+
prevHashes.splice(0, prevHashes.length - 10);
|
|
186
|
+
modState.recentWrites[filePath] = prevHashes;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
log.debug('파일 변경 추적 실패', e);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// 3. Drift score evaluation
|
|
195
|
+
if (toolName === 'Write' || toolName === 'Edit') {
|
|
196
|
+
if (!modState.drift)
|
|
197
|
+
modState.drift = createDriftState(sessionId);
|
|
198
|
+
const driftResult = evaluateDrift(modState.drift, true, revertDetected);
|
|
199
|
+
if (driftResult.message) {
|
|
200
|
+
messages.push(`<compound-tool-warning>\n${driftResult.message}\n</compound-tool-warning>`);
|
|
201
|
+
recordImplicitFeedback({
|
|
202
|
+
type: driftResult.level === 'critical' || driftResult.level === 'hardcap' ? 'drift_critical' : 'drift_warning',
|
|
203
|
+
score: driftResult.score,
|
|
204
|
+
totalEdits: modState.drift.totalEdits,
|
|
205
|
+
totalReverts: modState.drift.totalReverts,
|
|
206
|
+
at: new Date().toISOString(),
|
|
207
|
+
sessionId,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// 4. Agent output validation (Tier 2-F)
|
|
212
|
+
if (toolName === 'Agent') {
|
|
213
|
+
const agentResult = validateAgentOutput(toolResponse);
|
|
214
|
+
if (agentResult) {
|
|
215
|
+
messages.push(`<compound-agent-validation>\n[Forgen] ${agentResult.severity === 'error' ? '⛔' : '⚠'} ${agentResult.message}\n</compound-agent-validation>`);
|
|
216
|
+
recordImplicitFeedback({
|
|
217
|
+
type: `agent_${agentResult.signal}`,
|
|
218
|
+
severity: agentResult.severity,
|
|
219
|
+
outputLength: toolResponse.trim().length,
|
|
220
|
+
at: new Date().toISOString(),
|
|
221
|
+
sessionId,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// 5. Bash error detection
|
|
226
|
+
if (toolName === 'Bash' && toolResponse) {
|
|
227
|
+
const errorMatch = detectErrorPattern(toolResponse);
|
|
228
|
+
if (errorMatch) {
|
|
229
|
+
incrementFailureCounter(sessionId);
|
|
230
|
+
messages.push(`<compound-tool-info>\n[Forgen] Error pattern detected in execution result: "${errorMatch.description}". Review may be needed.\n</compound-tool-info>`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// 6. Compound negative signal (non-blocking)
|
|
234
|
+
try {
|
|
235
|
+
checkCompoundNegative(toolName, toolResponse, sessionId);
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
log.debug('compound negative check 실패', e);
|
|
239
|
+
}
|
|
240
|
+
// 7. Compound success hint (non-blocking)
|
|
241
|
+
try {
|
|
242
|
+
const successHint = getCompoundSuccessHint(toolName, toolResponse, sessionId);
|
|
243
|
+
if (successHint)
|
|
244
|
+
messages.push(successHint);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
log.debug('success hint generation 실패', e);
|
|
248
|
+
}
|
|
249
|
+
saveModifiedFiles(modState);
|
|
250
|
+
if (messages.length > 0) {
|
|
251
|
+
console.log(approveWithWarning(messages.join('\n')));
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
console.log(approve());
|
|
122
255
|
}
|
|
123
256
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
checkCompoundNegative(toolName, toolResponse, sessionId);
|
|
127
|
-
}
|
|
128
|
-
catch (e) {
|
|
129
|
-
log.debug('compound negative check 실패', e);
|
|
130
|
-
}
|
|
131
|
-
// 6. Compound success hint (non-blocking)
|
|
132
|
-
try {
|
|
133
|
-
const successHint = getCompoundSuccessHint(toolName, toolResponse, sessionId);
|
|
134
|
-
if (successHint)
|
|
135
|
-
messages.push(successHint);
|
|
136
|
-
}
|
|
137
|
-
catch (e) {
|
|
138
|
-
log.debug('success hint generation 실패', e);
|
|
139
|
-
}
|
|
140
|
-
saveModifiedFiles(modState);
|
|
141
|
-
if (messages.length > 0) {
|
|
142
|
-
console.log(approveWithWarning(messages.join('\n')));
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
console.log(approve());
|
|
257
|
+
finally {
|
|
258
|
+
recordHookTiming('post-tool-use', Date.now() - _hookStart, 'PostToolUse');
|
|
146
259
|
}
|
|
147
260
|
}
|
|
148
261
|
main().catch((e) => {
|
|
149
262
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
150
|
-
console.log(
|
|
263
|
+
console.log(failOpenWithTracking('post-tool-use'));
|
|
151
264
|
});
|
|
@@ -7,4 +7,14 @@
|
|
|
7
7
|
* - 진행 중인 작업 요약 저장
|
|
8
8
|
* - handoff 파일 생성 (압축 후 복구용)
|
|
9
9
|
*/
|
|
10
|
-
export {
|
|
10
|
+
export interface SessionBrief {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
mode: string;
|
|
13
|
+
modifiedFiles: string[];
|
|
14
|
+
promptCount: number;
|
|
15
|
+
solutionsInjected: string[];
|
|
16
|
+
correctionCount: number;
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
/** 세션 브리프 JSON 생성 */
|
|
20
|
+
export declare function buildSessionBrief(sessionId: string): SessionBrief;
|
|
@@ -12,8 +12,9 @@ import * as path from 'node:path';
|
|
|
12
12
|
import { createLogger } from '../core/logger.js';
|
|
13
13
|
import { readStdinJSON } from './shared/read-stdin.js';
|
|
14
14
|
import { isHookEnabled } from './hook-config.js';
|
|
15
|
-
import { approve, approveWithWarning,
|
|
16
|
-
import { HANDOFFS_DIR, ME_BEHAVIOR, STATE_DIR } from '../core/paths.js';
|
|
15
|
+
import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
|
|
16
|
+
import { HANDOFFS_DIR, ME_BEHAVIOR, ME_RULES, STATE_DIR } from '../core/paths.js';
|
|
17
|
+
import { sanitizeId } from './shared/sanitize-id.js';
|
|
17
18
|
const log = createLogger('pre-compact');
|
|
18
19
|
/** 활성 모드 상태 수집 */
|
|
19
20
|
function collectActiveStates() {
|
|
@@ -40,6 +41,90 @@ function collectActiveStates() {
|
|
|
40
41
|
}
|
|
41
42
|
return active;
|
|
42
43
|
}
|
|
44
|
+
/** 세션 브리프 JSON 생성 */
|
|
45
|
+
export function buildSessionBrief(sessionId) {
|
|
46
|
+
// modifiedFiles: read modified-files-{sessionId}.json (files field keys)
|
|
47
|
+
let modifiedFiles = [];
|
|
48
|
+
try {
|
|
49
|
+
const modPath = path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
|
|
50
|
+
if (fs.existsSync(modPath)) {
|
|
51
|
+
const modData = JSON.parse(fs.readFileSync(modPath, 'utf-8'));
|
|
52
|
+
if (modData.files && typeof modData.files === 'object') {
|
|
53
|
+
modifiedFiles = Object.keys(modData.files);
|
|
54
|
+
}
|
|
55
|
+
else if (Array.isArray(modData.modifiedFiles)) {
|
|
56
|
+
modifiedFiles = modData.modifiedFiles;
|
|
57
|
+
}
|
|
58
|
+
else if (Array.isArray(modData.fileEdits)) {
|
|
59
|
+
modifiedFiles = modData.fileEdits;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { /* fail-open */ }
|
|
64
|
+
// promptCount: read context-guard.json
|
|
65
|
+
let promptCount = 0;
|
|
66
|
+
try {
|
|
67
|
+
const cgPath = path.join(STATE_DIR, 'context-guard.json');
|
|
68
|
+
if (fs.existsSync(cgPath)) {
|
|
69
|
+
const cgData = JSON.parse(fs.readFileSync(cgPath, 'utf-8'));
|
|
70
|
+
if (typeof cgData.promptCount === 'number') {
|
|
71
|
+
promptCount = cgData.promptCount;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch { /* fail-open */ }
|
|
76
|
+
// solutionsInjected: read injection-cache-*.json files, collect solutions[].name
|
|
77
|
+
let solutionsInjected = [];
|
|
78
|
+
try {
|
|
79
|
+
if (fs.existsSync(STATE_DIR)) {
|
|
80
|
+
for (const f of fs.readdirSync(STATE_DIR)) {
|
|
81
|
+
if (!f.startsWith('injection-cache-') || !f.endsWith('.json'))
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
const cacheData = JSON.parse(fs.readFileSync(path.join(STATE_DIR, f), 'utf-8'));
|
|
85
|
+
if (Array.isArray(cacheData.solutions)) {
|
|
86
|
+
for (const sol of cacheData.solutions) {
|
|
87
|
+
if (typeof sol.name === 'string' && !solutionsInjected.includes(sol.name)) {
|
|
88
|
+
solutionsInjected.push(sol.name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { /* skip */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* fail-open */ }
|
|
98
|
+
// correctionCount: count files in ME_RULES with scope === 'session'
|
|
99
|
+
let correctionCount = 0;
|
|
100
|
+
try {
|
|
101
|
+
if (fs.existsSync(ME_RULES)) {
|
|
102
|
+
for (const f of fs.readdirSync(ME_RULES)) {
|
|
103
|
+
if (!f.endsWith('.json'))
|
|
104
|
+
continue;
|
|
105
|
+
try {
|
|
106
|
+
const rule = JSON.parse(fs.readFileSync(path.join(ME_RULES, f), 'utf-8'));
|
|
107
|
+
if (rule.scope === 'session')
|
|
108
|
+
correctionCount++;
|
|
109
|
+
}
|
|
110
|
+
catch { /* skip */ }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch { /* fail-open */ }
|
|
115
|
+
// mode: from collectActiveStates
|
|
116
|
+
const activeStates = collectActiveStates();
|
|
117
|
+
const mode = activeStates.length > 0 ? activeStates.map(s => s.mode).join('+') : 'general';
|
|
118
|
+
return {
|
|
119
|
+
sessionId,
|
|
120
|
+
mode,
|
|
121
|
+
modifiedFiles,
|
|
122
|
+
promptCount,
|
|
123
|
+
solutionsInjected,
|
|
124
|
+
correctionCount,
|
|
125
|
+
generatedAt: new Date().toISOString(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
43
128
|
/** compaction 전 스냅샷 저장 */
|
|
44
129
|
function saveCompactionSnapshot(sessionId) {
|
|
45
130
|
const activeStates = collectActiveStates();
|
|
@@ -68,35 +153,6 @@ function saveCompactionSnapshot(sessionId) {
|
|
|
68
153
|
fs.writeFileSync(snapshotPath, lines.join('\n'));
|
|
69
154
|
return snapshotPath;
|
|
70
155
|
}
|
|
71
|
-
/** context-guard.json에서 현재 promptCount 읽기 */
|
|
72
|
-
function readPromptCount() {
|
|
73
|
-
try {
|
|
74
|
-
const guardPath = path.join(STATE_DIR, 'context-guard.json');
|
|
75
|
-
if (fs.existsSync(guardPath)) {
|
|
76
|
-
const data = JSON.parse(fs.readFileSync(guardPath, 'utf-8'));
|
|
77
|
-
return typeof data.promptCount === 'number' ? data.promptCount : 0;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
catch { /* fail-open */ }
|
|
81
|
-
return 0;
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* 백그라운드 compound 추출 트리거 (non-blocking).
|
|
85
|
-
* compound extract 서브커맨드가 없으므로 pending-compound.json 마커를 씀.
|
|
86
|
-
* session-recovery가 다음 세션 시작 시 이 마커를 읽고 추출을 트리거함.
|
|
87
|
-
*/
|
|
88
|
-
function triggerBackgroundExtraction(promptCount) {
|
|
89
|
-
try {
|
|
90
|
-
const pendingPath = path.join(STATE_DIR, 'pending-compound.json');
|
|
91
|
-
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
92
|
-
fs.writeFileSync(pendingPath, JSON.stringify({
|
|
93
|
-
reason: 'pre-compact',
|
|
94
|
-
promptCount,
|
|
95
|
-
detectedAt: new Date().toISOString(),
|
|
96
|
-
}, null, 2));
|
|
97
|
-
}
|
|
98
|
-
catch { /* fail-open */ }
|
|
99
|
-
}
|
|
100
156
|
/** 7일 이상 된 handoff 파일 정리 */
|
|
101
157
|
function cleanOldHandoffs() {
|
|
102
158
|
if (!fs.existsSync(HANDOFFS_DIR))
|
|
@@ -175,11 +231,30 @@ Rules:
|
|
|
175
231
|
- Skip patterns that are trivially obvious ("uses TypeScript")
|
|
176
232
|
- Each pattern must be specific enough to change Claude's behavior in future sessions${existingList}
|
|
177
233
|
</forgen-compound-extract>`;
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
234
|
+
// 세션 브리프 저장
|
|
235
|
+
try {
|
|
236
|
+
const brief = buildSessionBrief(sessionId);
|
|
237
|
+
fs.mkdirSync(HANDOFFS_DIR, { recursive: true });
|
|
238
|
+
const briefTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
239
|
+
const briefPath = path.join(HANDOFFS_DIR, `${briefTimestamp}-session-brief.json`);
|
|
240
|
+
let briefJson = JSON.stringify(brief, null, 2);
|
|
241
|
+
// max 1500 chars — truncate modifiedFiles and solutionsInjected if needed
|
|
242
|
+
if (briefJson.length > 1500) {
|
|
243
|
+
let truncBrief = { ...brief };
|
|
244
|
+
while (briefJson.length > 1500 && (truncBrief.modifiedFiles.length > 0 || truncBrief.solutionsInjected.length > 0)) {
|
|
245
|
+
if (truncBrief.solutionsInjected.length > 0) {
|
|
246
|
+
truncBrief = { ...truncBrief, solutionsInjected: truncBrief.solutionsInjected.slice(0, Math.max(0, truncBrief.solutionsInjected.length - 1)) };
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
truncBrief = { ...truncBrief, modifiedFiles: truncBrief.modifiedFiles.slice(0, Math.max(0, truncBrief.modifiedFiles.length - 1)) };
|
|
250
|
+
}
|
|
251
|
+
briefJson = JSON.stringify(truncBrief, null, 2);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
fs.writeFileSync(briefPath, briefJson);
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
log.debug('세션 브리프 저장 실패', e);
|
|
183
258
|
}
|
|
184
259
|
// 스냅샷 저장
|
|
185
260
|
try {
|
|
@@ -196,5 +271,5 @@ Rules:
|
|
|
196
271
|
}
|
|
197
272
|
main().catch((e) => {
|
|
198
273
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
199
|
-
console.log(
|
|
274
|
+
console.log(failOpenWithTracking('pre-compact'));
|
|
200
275
|
});
|
|
@@ -19,8 +19,9 @@ import { sanitizeId } from './shared/sanitize-id.js';
|
|
|
19
19
|
import { incrementEvidence } from '../engine/solution-writer.js';
|
|
20
20
|
import { isReflectionCandidate } from './compound-reflection.js';
|
|
21
21
|
import { isHookEnabled } from './hook-config.js';
|
|
22
|
-
import { approve, approveWithWarning, deny,
|
|
22
|
+
import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
|
|
23
23
|
import { FORGEN_HOME, STATE_DIR } from '../core/paths.js';
|
|
24
|
+
import { recordHookTiming } from './shared/hook-timing.js';
|
|
24
25
|
const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'pre-tool-fail-counter.json');
|
|
25
26
|
const FAIL_CLOSE_THRESHOLD = 3; // 연속 3회 파싱 실패 시에만 reject
|
|
26
27
|
/** RegExp 안전성 검증 (ReDoS 방지) — 매칭/비매칭 양쪽 모두 테스트 */
|
|
@@ -212,15 +213,33 @@ function checkCompoundReflection(toolName, toolInput, sessionId) {
|
|
|
212
213
|
const now = new Date();
|
|
213
214
|
let mutated = false;
|
|
214
215
|
for (const sol of cache.solutions) {
|
|
215
|
-
|
|
216
|
+
const hasIdentifiers = Array.isArray(sol.identifiers) && sol.identifiers.length > 0;
|
|
217
|
+
const hasTags = Array.isArray(sol.tags) && sol.tags.length > 0;
|
|
218
|
+
if (!hasIdentifiers && !hasTags)
|
|
216
219
|
continue;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
220
|
+
let reflected = false;
|
|
221
|
+
if (hasIdentifiers) {
|
|
222
|
+
const result = isReflectionCandidate({
|
|
223
|
+
identifiers: sol.identifiers,
|
|
224
|
+
code,
|
|
225
|
+
injectedAt: sol.injectedAt ?? '',
|
|
226
|
+
now,
|
|
227
|
+
});
|
|
228
|
+
reflected = result.reflected;
|
|
229
|
+
}
|
|
230
|
+
// Tag-based fallback: identifiers 없는 솔루션도 감지
|
|
231
|
+
// 6자 이상 non-generic 태그 2개 이상이 코드에 출현하면 반영으로 인정
|
|
232
|
+
if (!reflected && hasTags) {
|
|
233
|
+
const genericTags = new Set(['pattern', 'solution', 'workflow', 'quality', 'best-practice', 'convention']);
|
|
234
|
+
const eligibleTags = sol.tags.filter((t) => t.length >= 6 && !genericTags.has(t) && /^[a-zA-Z가-힣]/.test(t));
|
|
235
|
+
const matchedTagCount = eligibleTags.filter((t) => code.toLowerCase().includes(t.toLowerCase())).length;
|
|
236
|
+
const injectedTime = new Date(sol.injectedAt ?? '').getTime();
|
|
237
|
+
const elapsed = now.getTime() - injectedTime;
|
|
238
|
+
if (matchedTagCount >= 2 && elapsed <= 15 * 60 * 1000 && elapsed >= 0) {
|
|
239
|
+
reflected = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (reflected) {
|
|
224
243
|
reflectedNames.push(sol.name);
|
|
225
244
|
if (!sol._sessionCounted) {
|
|
226
245
|
sol._sessionCounted = true;
|
|
@@ -262,58 +281,69 @@ export function updateSolutionEvidence(solutionName, field) {
|
|
|
262
281
|
incrementEvidence(solutionName, field);
|
|
263
282
|
}
|
|
264
283
|
async function main() {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
284
|
+
const _hookStart = Date.now();
|
|
285
|
+
try {
|
|
286
|
+
const data = await readStdinJSON();
|
|
287
|
+
if (!data) {
|
|
288
|
+
// graceful fail-close: consecutive failure counter.
|
|
289
|
+
// At threshold, block with a user-visible deny message (the block itself
|
|
290
|
+
// is actionable — the user needs to know why their tool call was
|
|
291
|
+
// rejected). Below threshold, pass SILENTLY via plain approve() so a
|
|
292
|
+
// transient parse glitch doesn't leak `systemMessage` noise to the
|
|
293
|
+
// user's terminal on every tool call. stderr still gets the counter
|
|
294
|
+
// for `forgen doctor` / log inspection. Mirrors `db-guard.ts:85-96`.
|
|
295
|
+
const failCount = getAndIncrementFailCount();
|
|
296
|
+
if (failCount >= FAIL_CLOSE_THRESHOLD) {
|
|
297
|
+
console.log(deny(`[Forgen] PreToolUse: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
process.stderr.write(`[ch-hook] pre-tool-use stdin parse failed (${failCount}/${FAIL_CLOSE_THRESHOLD})\n`);
|
|
301
|
+
console.log(approve());
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
277
304
|
}
|
|
278
|
-
|
|
279
|
-
|
|
305
|
+
// 정상 파싱 성공 시 연속 실패 카운터 리셋
|
|
306
|
+
resetFailCount();
|
|
307
|
+
if (!isHookEnabled('pre-tool-use')) {
|
|
280
308
|
console.log(approve());
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
312
|
+
const toolInput = data.tool_input ?? data.toolInput ?? {};
|
|
313
|
+
const sessionId = data.session_id ?? 'default';
|
|
314
|
+
// Bash 도구: 위험 명령어 감지
|
|
315
|
+
const check = checkDangerousCommand(toolName, toolInput);
|
|
316
|
+
if (check.action === 'block') {
|
|
317
|
+
console.log(deny(`[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (check.action === 'warn') {
|
|
321
|
+
console.log(approveWithWarning(`<compound-tool-warning>\n[Forgen] ⚠ Dangerous command detected: ${check.description}\nProceed with caution.\n</compound-tool-warning>`));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Output size guard: warn when Grep is used without head_limit
|
|
325
|
+
if (toolName === 'Grep' && !toolInput?.head_limit) {
|
|
326
|
+
console.log(approveWithWarning(`<compound-tool-warning>\n[Forgen] Grep without head_limit may produce large output. Set head_limit or pipe through | head -n to limit output size.\n</compound-tool-warning>`));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// Compound v3: Code Reflection check (non-blocking)
|
|
330
|
+
try {
|
|
331
|
+
checkCompoundReflection(toolName, toolInput, sessionId);
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
log.debug('compound reflection check 실패', e);
|
|
335
|
+
}
|
|
336
|
+
// 활성 모드 리마인더 (10회 호출당 1회 — 결정적 카운터 기반)
|
|
337
|
+
const reminders = getActiveReminders();
|
|
338
|
+
if (reminders.length > 0 && shouldShowReminderIO()) {
|
|
339
|
+
console.log(approveWithWarning(`<compound-reminder>\n${reminders.join('\n')}\n</compound-reminder>`));
|
|
340
|
+
return;
|
|
281
341
|
}
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
// 정상 파싱 성공 시 연속 실패 카운터 리셋
|
|
285
|
-
resetFailCount();
|
|
286
|
-
if (!isHookEnabled('pre-tool-use')) {
|
|
287
342
|
console.log(approve());
|
|
288
|
-
return;
|
|
289
343
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const sessionId = data.session_id ?? 'default';
|
|
293
|
-
// Bash 도구: 위험 명령어 감지
|
|
294
|
-
const check = checkDangerousCommand(toolName, toolInput);
|
|
295
|
-
if (check.action === 'block') {
|
|
296
|
-
console.log(deny(`[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
if (check.action === 'warn') {
|
|
300
|
-
console.log(approveWithWarning(`<compound-tool-warning>\n[Forgen] ⚠ Dangerous command detected: ${check.description}\nProceed with caution.\n</compound-tool-warning>`));
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
// Compound v3: Code Reflection check (non-blocking)
|
|
304
|
-
try {
|
|
305
|
-
checkCompoundReflection(toolName, toolInput, sessionId);
|
|
306
|
-
}
|
|
307
|
-
catch (e) {
|
|
308
|
-
log.debug('compound reflection check 실패', e);
|
|
309
|
-
}
|
|
310
|
-
// 활성 모드 리마인더 (10회 호출당 1회 — 결정적 카운터 기반)
|
|
311
|
-
const reminders = getActiveReminders();
|
|
312
|
-
if (reminders.length > 0 && shouldShowReminderIO()) {
|
|
313
|
-
console.log(approveWithWarning(`<compound-reminder>\n${reminders.join('\n')}\n</compound-reminder>`));
|
|
314
|
-
return;
|
|
344
|
+
finally {
|
|
345
|
+
recordHookTiming('pre-tool-use', Date.now() - _hookStart, 'PreToolUse');
|
|
315
346
|
}
|
|
316
|
-
console.log(approve());
|
|
317
347
|
}
|
|
318
348
|
main().catch((e) => {
|
|
319
349
|
const hookErr = new HookError(e instanceof Error ? e.message : String(e), {
|
|
@@ -321,5 +351,5 @@ main().catch((e) => {
|
|
|
321
351
|
});
|
|
322
352
|
process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
|
|
323
353
|
// fail-open: approve on internal error to avoid blocking all tool calls
|
|
324
|
-
console.log(
|
|
354
|
+
console.log(failOpenWithTracking('pre-tool-use'));
|
|
325
355
|
});
|