@wooojin/forgen 0.1.1 → 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 +11 -2
- package/dist/core/auto-compound-runner.js +34 -1
- package/dist/core/dashboard.d.ts +91 -0
- package/dist/core/dashboard.js +385 -0
- package/dist/core/doctor.js +157 -1
- package/dist/core/drift-score.d.ts +49 -0
- package/dist/core/drift-score.js +87 -0
- package/dist/core/inspect-cli.js +54 -1
- 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/core/spawn.d.ts +7 -2
- package/dist/core/spawn.js +45 -7
- package/dist/core/v1-bootstrap.js +9 -2
- package/dist/engine/compound-export.d.ts +41 -0
- package/dist/engine/compound-export.js +169 -0
- package/dist/engine/compound-extractor.js +49 -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/forge/mismatch-detector.js +3 -0
- package/dist/hooks/context-guard.d.ts +10 -0
- package/dist/hooks/context-guard.js +105 -49
- 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.js +29 -4
- package/dist/hooks/keyword-detector.js +114 -106
- 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 +113 -3
- 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 +7 -0
- package/dist/hooks/shared/hook-response.js +20 -0
- 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/mcp/tools.js +114 -0
- package/dist/renderer/rule-renderer.js +9 -11
- package/dist/store/evidence-store.d.ts +8 -0
- package/dist/store/evidence-store.js +51 -0
- package/dist/store/rule-store.d.ts +5 -0
- package/dist/store/rule-store.js +22 -0
- package/package.json +1 -1
- package/skills/deep-interview/SKILL.md +166 -0
- package/skills/specify/SKILL.md +122 -0
|
@@ -15,8 +15,9 @@ const log = createLogger('session-recovery');
|
|
|
15
15
|
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
16
16
|
import { sanitizeId } from './shared/sanitize-id.js';
|
|
17
17
|
import { isHookEnabled } from './hook-config.js';
|
|
18
|
-
import { approve, approveWithContext,
|
|
18
|
+
import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
|
|
19
19
|
import { HANDOFFS_DIR, STATE_DIR } from '../core/paths.js';
|
|
20
|
+
import { recordHookTiming } from './shared/hook-timing.js';
|
|
20
21
|
/** 체크포인트 저장 */
|
|
21
22
|
export function saveCheckpoint(data) {
|
|
22
23
|
try {
|
|
@@ -129,271 +130,290 @@ export function resolveSessionStartContext(rawInput) {
|
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
async function main() {
|
|
132
|
-
|
|
133
|
-
const chunks = [];
|
|
134
|
-
process.stdin.setEncoding('utf-8');
|
|
135
|
-
await new Promise((resolve) => {
|
|
136
|
-
const timeout = setTimeout(() => {
|
|
137
|
-
process.stdin.removeAllListeners('data');
|
|
138
|
-
process.stdin.removeAllListeners('end');
|
|
139
|
-
resolve();
|
|
140
|
-
}, 2000);
|
|
141
|
-
process.stdin.on('data', (chunk) => chunks.push(String(chunk)));
|
|
142
|
-
process.stdin.on('end', () => { clearTimeout(timeout); resolve(); });
|
|
143
|
-
});
|
|
144
|
-
const sessionContext = resolveSessionStartContext(chunks.join(''));
|
|
145
|
-
if (!isHookEnabled('session-recovery')) {
|
|
146
|
-
console.log(approve());
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
if (!fs.existsSync(STATE_DIR)) {
|
|
150
|
-
console.log(approve());
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
// 활성 모드 찾기
|
|
154
|
-
const recoveryMessages = [];
|
|
155
|
-
for (const mode of PERSISTENT_MODES) {
|
|
156
|
-
const statePath = path.join(STATE_DIR, `${mode}-state.json`);
|
|
157
|
-
if (!fs.existsSync(statePath))
|
|
158
|
-
continue;
|
|
159
|
-
try {
|
|
160
|
-
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
161
|
-
if (!isValidModeState(parsed)) {
|
|
162
|
-
log.debug(`상태 파일 구조 검증 실패: ${mode}`);
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
const state = parsed;
|
|
166
|
-
if (!state.active)
|
|
167
|
-
continue;
|
|
168
|
-
// 24시간 이상 경과한 상태는 만료
|
|
169
|
-
const startedAt = new Date(state.startedAt).getTime();
|
|
170
|
-
const elapsed = Date.now() - startedAt;
|
|
171
|
-
if (elapsed > 24 * 60 * 60 * 1000) {
|
|
172
|
-
fs.unlinkSync(statePath);
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
const elapsedMinutes = Math.round(elapsed / 60000);
|
|
176
|
-
// Security: 상태 파일의 사용자 입력을 XML에 삽입하기 전 이스케이프
|
|
177
|
-
const escXml = (s) => s.replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c] ?? c);
|
|
178
|
-
recoveryMessages.push(`<compound-recovery mode="${mode}">` +
|
|
179
|
-
`\n${mode} mode from previous session has been recovered.` +
|
|
180
|
-
`\nStarted: ${state.startedAt} (${elapsedMinutes} minutes ago)` +
|
|
181
|
-
(state.prompt ? `\nOriginal request: ${escXml(state.prompt)}` : '') +
|
|
182
|
-
(state.stage ? `\nCurrent stage: ${escXml(state.stage)}` : '') +
|
|
183
|
-
(state.completedSteps?.length ? `\nCompleted steps: ${state.completedSteps.map((s) => escXml(s)).join(', ')}` : '') +
|
|
184
|
-
`\n\nContinue the previous work. To stop, type "cancelforgen".` +
|
|
185
|
-
`\n</compound-recovery>`);
|
|
186
|
-
}
|
|
187
|
-
catch (e) {
|
|
188
|
-
log.debug(`상태 파일 파싱 실패`, e);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
// Phase 0: endTime이 없는 이전 세션 backfill (file mtime 기반)
|
|
192
|
-
// 최근 7일 파일만 대상 — 오래된 파일은 무시하여 성능 보장
|
|
133
|
+
const _hookStart = Date.now();
|
|
193
134
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const raw = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
|
211
|
-
if (raw.startTime && !raw.endTime) {
|
|
212
|
-
const mtime = fs.statSync(fp).mtime;
|
|
213
|
-
raw.endTime = mtime.toISOString();
|
|
214
|
-
raw.durationMs = mtime.getTime() - new Date(raw.startTime).getTime();
|
|
215
|
-
raw.recoveredEndTime = true;
|
|
216
|
-
atomicWriteJSON(fp, raw);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
catch { /* individual file recovery failure — skip and continue */ }
|
|
220
|
-
}
|
|
135
|
+
// SessionStart 훅은 stdin으로 세션 정보를 받음 (타임아웃 포함)
|
|
136
|
+
const chunks = [];
|
|
137
|
+
process.stdin.setEncoding('utf-8');
|
|
138
|
+
await new Promise((resolve) => {
|
|
139
|
+
const timeout = setTimeout(() => {
|
|
140
|
+
process.stdin.removeAllListeners('data');
|
|
141
|
+
process.stdin.removeAllListeners('end');
|
|
142
|
+
resolve();
|
|
143
|
+
}, 2000);
|
|
144
|
+
process.stdin.on('data', (chunk) => chunks.push(String(chunk)));
|
|
145
|
+
process.stdin.on('end', () => { clearTimeout(timeout); resolve(); });
|
|
146
|
+
});
|
|
147
|
+
const sessionContext = resolveSessionStartContext(chunks.join(''));
|
|
148
|
+
if (!isHookEnabled('session-recovery')) {
|
|
149
|
+
console.log(approve());
|
|
150
|
+
return;
|
|
221
151
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
152
|
+
if (!fs.existsSync(STATE_DIR)) {
|
|
153
|
+
console.log(approve());
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// 활성 모드 찾기
|
|
157
|
+
const recoveryMessages = [];
|
|
158
|
+
for (const mode of PERSISTENT_MODES) {
|
|
159
|
+
const statePath = path.join(STATE_DIR, `${mode}-state.json`);
|
|
160
|
+
if (!fs.existsSync(statePath))
|
|
161
|
+
continue;
|
|
231
162
|
try {
|
|
232
|
-
const
|
|
233
|
-
if (!
|
|
234
|
-
log.debug(
|
|
163
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
164
|
+
if (!isValidModeState(parsed)) {
|
|
165
|
+
log.debug(`상태 파일 구조 검증 실패: ${mode}`);
|
|
235
166
|
continue;
|
|
236
167
|
}
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
168
|
+
const state = parsed;
|
|
169
|
+
if (!state.active)
|
|
170
|
+
continue;
|
|
171
|
+
// 24시간 이상 경과한 상태는 만료
|
|
172
|
+
const startedAt = new Date(state.startedAt).getTime();
|
|
173
|
+
const elapsed = Date.now() - startedAt;
|
|
174
|
+
if (elapsed > 24 * 60 * 60 * 1000) {
|
|
175
|
+
fs.unlinkSync(statePath);
|
|
241
176
|
continue;
|
|
242
177
|
}
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
`\
|
|
249
|
-
`\
|
|
250
|
-
`\
|
|
251
|
-
`\
|
|
252
|
-
`\n
|
|
253
|
-
`\n</compound-
|
|
178
|
+
const elapsedMinutes = Math.round(elapsed / 60000);
|
|
179
|
+
// Security: 상태 파일의 사용자 입력을 XML에 삽입하기 전 이스케이프
|
|
180
|
+
const escXml = (s) => s.replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c] ?? c);
|
|
181
|
+
recoveryMessages.push(`<compound-recovery mode="${mode}">` +
|
|
182
|
+
`\n${mode} mode from previous session has been recovered.` +
|
|
183
|
+
`\nStarted: ${state.startedAt} (${elapsedMinutes} minutes ago)` +
|
|
184
|
+
(state.prompt ? `\nOriginal request: ${escXml(state.prompt)}` : '') +
|
|
185
|
+
(state.stage ? `\nCurrent stage: ${escXml(state.stage)}` : '') +
|
|
186
|
+
(state.completedSteps?.length ? `\nCompleted steps: ${state.completedSteps.map((s) => escXml(s)).join(', ')}` : '') +
|
|
187
|
+
`\n\nContinue the previous work. To stop, type "cancelforgen".` +
|
|
188
|
+
`\n</compound-recovery>`);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
log.debug(`상태 파일 파싱 실패`, e);
|
|
254
192
|
}
|
|
255
|
-
catch { /* 개별 파일 파싱 실패 무시 */ }
|
|
256
193
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
log.debug('체크포인트 스캔 실패', e);
|
|
260
|
-
}
|
|
261
|
-
// pending-compound 마커 확인 (이전 세션에서 compound loop 필요 표시)
|
|
262
|
-
const pendingPath = path.join(STATE_DIR, 'pending-compound.json');
|
|
263
|
-
if (fs.existsSync(pendingPath)) {
|
|
194
|
+
// Phase 0: endTime이 없는 이전 세션 backfill (file mtime 기반)
|
|
195
|
+
// 최근 7일 파일만 대상 — 오래된 파일은 무시하여 성능 보장
|
|
264
196
|
try {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
197
|
+
const { SESSIONS_DIR: sessDir } = await import('../core/paths.js');
|
|
198
|
+
if (fs.existsSync(sessDir)) {
|
|
199
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
200
|
+
const sessionFiles = fs.readdirSync(sessDir)
|
|
201
|
+
.filter(f => f.endsWith('.json'))
|
|
202
|
+
.filter(f => {
|
|
203
|
+
try {
|
|
204
|
+
return fs.statSync(path.join(sessDir, f)).mtimeMs > cutoff;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
for (const file of sessionFiles) {
|
|
211
|
+
const fp = path.join(sessDir, file);
|
|
212
|
+
try {
|
|
213
|
+
const raw = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
|
214
|
+
if (raw.startTime && !raw.endTime) {
|
|
215
|
+
const mtime = fs.statSync(fp).mtime;
|
|
216
|
+
raw.endTime = mtime.toISOString();
|
|
217
|
+
raw.durationMs = mtime.getTime() - new Date(raw.startTime).getTime();
|
|
218
|
+
raw.recoveredEndTime = true;
|
|
219
|
+
atomicWriteJSON(fp, raw);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch { /* individual file recovery failure — skip and continue */ }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
272
225
|
}
|
|
273
226
|
catch (e) {
|
|
274
|
-
log.debug('
|
|
227
|
+
log.debug('세션 endTime backfill 실패', e);
|
|
275
228
|
}
|
|
276
|
-
|
|
277
|
-
// 핸드오프 파일 확인
|
|
278
|
-
const handoffDir = HANDOFFS_DIR;
|
|
279
|
-
if (fs.existsSync(handoffDir)) {
|
|
229
|
+
// 미완료 체크포인트 감지
|
|
280
230
|
try {
|
|
281
|
-
const
|
|
282
|
-
.filter(f => f.endsWith('.
|
|
283
|
-
|
|
284
|
-
if (handoffs.length > 0) {
|
|
285
|
-
const latest = handoffs[handoffs.length - 1];
|
|
286
|
-
const latestPath = path.join(handoffDir, latest);
|
|
287
|
-
// Security: symlink 방지 + XML 이스케이프
|
|
288
|
-
if (fs.lstatSync(latestPath).isSymbolicLink())
|
|
289
|
-
throw new Error('symlink rejected');
|
|
290
|
-
const raw = fs.readFileSync(latestPath, 'utf-8');
|
|
291
|
-
const safeName = latest.replace(/[&"<>]/g, '_');
|
|
292
|
-
const escaped = raw.replace(/<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?\/?>/g, m => m.replace(/</g, '<').replace(/>/g, '>'));
|
|
293
|
-
recoveryMessages.push(`<compound-handoff file="${safeName}">\n${escaped}\n</compound-handoff>`);
|
|
294
|
-
// 마커 삭제 (한 번만 안내 — pending-compound.json과 동일 패턴)
|
|
231
|
+
const checkpointFiles = fs.readdirSync(STATE_DIR)
|
|
232
|
+
.filter(f => f.startsWith('checkpoint-') && f.endsWith('.json'));
|
|
233
|
+
for (const file of checkpointFiles) {
|
|
295
234
|
try {
|
|
296
|
-
fs.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
235
|
+
const parsedCp = JSON.parse(fs.readFileSync(path.join(STATE_DIR, file), 'utf-8'));
|
|
236
|
+
if (!isValidCheckpoint(parsedCp)) {
|
|
237
|
+
log.debug(`체크포인트 파일 구조 검증 실패: ${file}`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const cp = parsedCp;
|
|
241
|
+
const age = Date.now() - new Date(cp.timestamp).getTime();
|
|
242
|
+
if (age > 24 * 60 * 60 * 1000) {
|
|
243
|
+
fs.unlinkSync(path.join(STATE_DIR, file));
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const elapsedMin = Math.round(age / 60000);
|
|
247
|
+
const safeSessionId = String(cp.sessionId).replace(/[&"<>]/g, '_');
|
|
248
|
+
const safeLastTool = String(cp.lastToolCall ?? '').replace(/[<>]/g, '_');
|
|
249
|
+
const safeCwd = String(cp.cwd ?? '').replace(/[<>]/g, '_');
|
|
250
|
+
recoveryMessages.push(`<compound-checkpoint session="${safeSessionId}">` +
|
|
251
|
+
`\nIncomplete checkpoint found (${elapsedMin} minutes ago)` +
|
|
252
|
+
`\n- Modified files: ${cp.modifiedFiles.length}` +
|
|
253
|
+
`\n- Tool calls: ${cp.toolCallCount}` +
|
|
254
|
+
`\n- Last tool: ${safeLastTool}` +
|
|
255
|
+
`\n- Working directory: ${safeCwd}` +
|
|
256
|
+
`\n</compound-checkpoint>`);
|
|
300
257
|
}
|
|
258
|
+
catch { /* 개별 파일 파싱 실패 무시 */ }
|
|
301
259
|
}
|
|
302
260
|
}
|
|
303
261
|
catch (e) {
|
|
304
|
-
log.debug('
|
|
262
|
+
log.debug('체크포인트 스캔 실패', e);
|
|
305
263
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
264
|
+
// pending-compound 마커 확인 (이전 세션에서 compound loop 필요 표시)
|
|
265
|
+
const pendingPath = path.join(STATE_DIR, 'pending-compound.json');
|
|
266
|
+
if (fs.existsSync(pendingPath)) {
|
|
267
|
+
try {
|
|
268
|
+
const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
|
|
269
|
+
recoveryMessages.push(`<compound-pending>` +
|
|
270
|
+
`\nCompound loop was scheduled in the previous session (${pending.promptCount ?? '?'} prompts).` +
|
|
271
|
+
`\nRun \`forgen compound\` to preview, then \`forgen compound --save\` to persist patterns/solutions.` +
|
|
272
|
+
`\n</compound-pending>`);
|
|
273
|
+
// 마커 삭제 (한 번만 안내)
|
|
274
|
+
fs.unlinkSync(pendingPath);
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
log.debug('pending-compound 마커 읽기 실패', e);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// 핸드오프 파일 확인
|
|
281
|
+
const handoffDir = HANDOFFS_DIR;
|
|
282
|
+
if (fs.existsSync(handoffDir)) {
|
|
283
|
+
try {
|
|
284
|
+
const handoffs = fs.readdirSync(handoffDir)
|
|
285
|
+
.filter(f => f.endsWith('.md'))
|
|
286
|
+
.sort();
|
|
287
|
+
if (handoffs.length > 0) {
|
|
288
|
+
const latest = handoffs[handoffs.length - 1];
|
|
289
|
+
const latestPath = path.join(handoffDir, latest);
|
|
290
|
+
// Security: symlink 방지 + XML 이스케이프
|
|
291
|
+
if (fs.lstatSync(latestPath).isSymbolicLink())
|
|
292
|
+
throw new Error('symlink rejected');
|
|
293
|
+
const raw = fs.readFileSync(latestPath, 'utf-8');
|
|
294
|
+
const safeName = latest.replace(/[&"<>]/g, '_');
|
|
295
|
+
const escaped = raw.replace(/<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?\/?>/g, m => m.replace(/</g, '<').replace(/>/g, '>'));
|
|
296
|
+
recoveryMessages.push(`<compound-handoff file="${safeName}">\n${escaped}\n</compound-handoff>`);
|
|
297
|
+
// 마커 삭제 (한 번만 안내 — pending-compound.json과 동일 패턴)
|
|
298
|
+
try {
|
|
299
|
+
fs.unlinkSync(latestPath);
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
log.debug('handoff 파일 삭제 실패', e);
|
|
303
|
+
}
|
|
325
304
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
log.debug('handoff 파일 읽기 실패', e);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Load latest session brief
|
|
311
|
+
try {
|
|
312
|
+
const briefFiles = fs.readdirSync(HANDOFFS_DIR)
|
|
313
|
+
.filter(f => f.endsWith('-session-brief.json'))
|
|
314
|
+
.sort()
|
|
315
|
+
.reverse();
|
|
316
|
+
if (briefFiles.length > 0) {
|
|
317
|
+
const brief = JSON.parse(fs.readFileSync(path.join(HANDOFFS_DIR, briefFiles[0]), 'utf-8'));
|
|
318
|
+
const briefContext = `Previous session: ${brief.mode || 'general'}, ${brief.promptCount || 0} prompts, ${(brief.modifiedFiles || []).length} files modified, ${(brief.solutionsInjected || []).length} solutions used.`;
|
|
319
|
+
recoveryMessages.push(briefContext);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch { /* fail-open */ }
|
|
323
|
+
const sessionId = sessionContext.sessionId;
|
|
324
|
+
// 이전 세션 자동 compound (fire-and-forget)
|
|
325
|
+
// /new로 세션 리셋 시 SessionStart가 다시 호출됨 — 이때 이전 transcript를 compound
|
|
326
|
+
try {
|
|
327
|
+
const cwd = sessionContext.cwd;
|
|
328
|
+
const sanitized = cwd.replace(/\//g, '-');
|
|
329
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', sanitized);
|
|
330
|
+
if (fs.existsSync(projectDir)) {
|
|
331
|
+
const transcripts = fs.readdirSync(projectDir)
|
|
332
|
+
.filter(f => f.endsWith('.jsonl') && f !== `${sessionId}.jsonl`) // 현재 세션 제외
|
|
333
|
+
.map(f => ({ name: f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
|
|
334
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
335
|
+
if (transcripts.length > 0) {
|
|
336
|
+
const prevTranscript = path.join(projectDir, transcripts[0].name);
|
|
337
|
+
const lastCompoundPath = path.join(STATE_DIR, 'last-auto-compound.json');
|
|
338
|
+
let lastCompoundedSession = '';
|
|
339
|
+
try {
|
|
340
|
+
lastCompoundedSession = JSON.parse(fs.readFileSync(lastCompoundPath, 'utf-8')).sessionId ?? '';
|
|
340
341
|
}
|
|
341
|
-
catch {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
342
|
+
catch { /* first time */ }
|
|
343
|
+
const prevSessionId = transcripts[0].name.replace('.jsonl', '');
|
|
344
|
+
if (prevSessionId !== lastCompoundedSession) {
|
|
345
|
+
// 이전 세션이 compound 안 된 상태 — 메시지 수 확인
|
|
346
|
+
// W-D2: 대용량 transcript 보호 — 앞 200KB만 읽어 메시지 수 추정
|
|
347
|
+
const fd = fs.openSync(prevTranscript, 'r');
|
|
348
|
+
const buf = Buffer.alloc(200 * 1024);
|
|
349
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
350
|
+
fs.closeSync(fd);
|
|
351
|
+
const content = buf.toString('utf-8', 0, bytesRead);
|
|
352
|
+
const userMsgCount = content.split('\n')
|
|
353
|
+
.filter(l => { try {
|
|
354
|
+
const t = JSON.parse(l).type;
|
|
355
|
+
return t === 'user' || t === 'queue-operation';
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return false;
|
|
359
|
+
} })
|
|
360
|
+
.length;
|
|
361
|
+
if (userMsgCount >= 10) {
|
|
362
|
+
// background로 auto-compound 실행 (hook timeout과 무관)
|
|
363
|
+
const { spawn: spawnProcess } = await import('node:child_process');
|
|
364
|
+
const autoCompound = spawnProcess('node', [
|
|
365
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core', 'auto-compound-runner.js'),
|
|
366
|
+
cwd, prevTranscript, prevSessionId,
|
|
367
|
+
], { detached: true, stdio: 'ignore' });
|
|
368
|
+
autoCompound.unref();
|
|
369
|
+
log.debug(`이전 세션 auto-compound 시작: ${prevSessionId} (${userMsgCount} messages)`);
|
|
370
|
+
}
|
|
354
371
|
}
|
|
355
372
|
}
|
|
356
373
|
}
|
|
357
374
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// Compound v3: Run lifecycle check once per day
|
|
364
|
-
try {
|
|
365
|
-
const lifecycleModulePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'engine', 'compound-lifecycle.js');
|
|
366
|
-
const lastLifecyclePath = path.join(STATE_DIR, 'last-lifecycle.json');
|
|
367
|
-
let shouldRun = true;
|
|
375
|
+
catch (e) {
|
|
376
|
+
log.debug('이전 세션 auto-compound 체크 실패', e);
|
|
377
|
+
}
|
|
378
|
+
// v1: regex 기반 패턴 학습(prompt-learner) 제거. Evidence 기반으로 전환됨.
|
|
379
|
+
// Compound v3: Run lifecycle check once per day
|
|
368
380
|
try {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
381
|
+
const lifecycleModulePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'engine', 'compound-lifecycle.js');
|
|
382
|
+
const lastLifecyclePath = path.join(STATE_DIR, 'last-lifecycle.json');
|
|
383
|
+
let shouldRun = true;
|
|
384
|
+
try {
|
|
385
|
+
if (fs.existsSync(lastLifecyclePath)) {
|
|
386
|
+
const data = JSON.parse(fs.readFileSync(lastLifecyclePath, 'utf-8'));
|
|
387
|
+
const last = new Date(data.lastRun).getTime();
|
|
388
|
+
shouldRun = Date.now() - last > 24 * 60 * 60 * 1000;
|
|
389
|
+
}
|
|
373
390
|
}
|
|
391
|
+
catch { /* last-lifecycle.json parse failure — run lifecycle check anyway */ }
|
|
392
|
+
if (shouldRun) {
|
|
393
|
+
// B-4: detached background spawn으로 분리 — hook timeout 초과 방지
|
|
394
|
+
const { spawn: spawnLifecycle } = await import('node:child_process');
|
|
395
|
+
const lifecycleRunner = spawnLifecycle('node', [
|
|
396
|
+
'--input-type=module',
|
|
397
|
+
'-e',
|
|
398
|
+
`import('${lifecycleModulePath.replace(/\\/g, '/')}').then(m => m.runLifecycleCheck('${sessionId}'))`,
|
|
399
|
+
], { detached: true, stdio: 'ignore' });
|
|
400
|
+
lifecycleRunner.unref();
|
|
401
|
+
const { atomicWriteJSON: writeJSON } = await import('./shared/atomic-write.js');
|
|
402
|
+
writeJSON(lastLifecyclePath, { lastRun: new Date().toISOString() });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
log.debug('lifecycle check 실패', e);
|
|
374
407
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
'--input-type=module',
|
|
381
|
-
'-e',
|
|
382
|
-
`import('${lifecycleModulePath.replace(/\\/g, '/')}').then(m => m.runLifecycleCheck('${sessionId}'))`,
|
|
383
|
-
], { detached: true, stdio: 'ignore' });
|
|
384
|
-
lifecycleRunner.unref();
|
|
385
|
-
const { atomicWriteJSON: writeJSON } = await import('./shared/atomic-write.js');
|
|
386
|
-
writeJSON(lastLifecyclePath, { lastRun: new Date().toISOString() });
|
|
408
|
+
if (recoveryMessages.length > 0) {
|
|
409
|
+
console.log(approveWithContext(recoveryMessages.join('\n\n'), 'SessionStart'));
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
console.log(approve());
|
|
387
413
|
}
|
|
388
414
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
392
|
-
if (recoveryMessages.length > 0) {
|
|
393
|
-
console.log(approveWithContext(recoveryMessages.join('\n\n'), 'SessionStart'));
|
|
394
|
-
}
|
|
395
|
-
else {
|
|
396
|
-
console.log(approve());
|
|
415
|
+
finally {
|
|
416
|
+
recordHookTiming('session-recovery', Date.now() - _hookStart, 'SessionStart');
|
|
397
417
|
}
|
|
398
418
|
}
|
|
399
419
|
// ESM main guard: 다른 모듈에서 import 시 main() 실행 방지
|
|
@@ -401,6 +421,6 @@ async function main() {
|
|
|
401
421
|
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
402
422
|
main().catch((e) => {
|
|
403
423
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
404
|
-
console.log(
|
|
424
|
+
console.log(failOpenWithTracking('session-recovery'));
|
|
405
425
|
});
|
|
406
426
|
}
|
|
@@ -31,3 +31,10 @@ export declare function deny(reason: string): string;
|
|
|
31
31
|
export declare function ask(reason: string): string;
|
|
32
32
|
/** fail-open: 에러 시 안전하게 통과 */
|
|
33
33
|
export declare function failOpen(): string;
|
|
34
|
+
/**
|
|
35
|
+
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
36
|
+
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
37
|
+
*
|
|
38
|
+
* @fail-open: hook failure must never block the user's workflow
|
|
39
|
+
*/
|
|
40
|
+
export declare function failOpenWithTracking(hookName: string): string;
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* systemMessage 필드는 UI 표시용으로만 사용되며 모델에 전달되지 않음.
|
|
14
14
|
* 모델에 컨텍스트를 주입하려면 반드시 additionalContext를 사용해야 함.
|
|
15
15
|
*/
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
16
19
|
/** 통과 응답 (컨텍스트 없음, 모든 이벤트 공통) */
|
|
17
20
|
export function approve() {
|
|
18
21
|
return JSON.stringify({ continue: true });
|
|
@@ -60,3 +63,20 @@ export function ask(reason) {
|
|
|
60
63
|
export function failOpen() {
|
|
61
64
|
return JSON.stringify({ continue: true });
|
|
62
65
|
}
|
|
66
|
+
/**
|
|
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
|
|
71
|
+
*/
|
|
72
|
+
export function failOpenWithTracking(hookName) {
|
|
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 */ }
|
|
81
|
+
return JSON.stringify({ continue: true });
|
|
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[];
|