@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ja.md +79 -14
  3. package/README.ko.md +89 -14
  4. package/README.md +77 -14
  5. package/README.zh.md +79 -14
  6. package/commands/deep-interview.md +171 -0
  7. package/commands/specify.md +128 -0
  8. package/dist/cli.js +11 -2
  9. package/dist/core/auto-compound-runner.js +34 -1
  10. package/dist/core/dashboard.d.ts +91 -0
  11. package/dist/core/dashboard.js +385 -0
  12. package/dist/core/doctor.js +157 -1
  13. package/dist/core/drift-score.d.ts +49 -0
  14. package/dist/core/drift-score.js +87 -0
  15. package/dist/core/inspect-cli.js +54 -1
  16. package/dist/core/mcp-config.d.ts +2 -0
  17. package/dist/core/mcp-config.js +6 -1
  18. package/dist/core/paths.d.ts +1 -1
  19. package/dist/core/paths.js +1 -1
  20. package/dist/core/spawn.d.ts +7 -2
  21. package/dist/core/spawn.js +45 -7
  22. package/dist/core/v1-bootstrap.js +9 -2
  23. package/dist/engine/compound-export.d.ts +41 -0
  24. package/dist/engine/compound-export.js +169 -0
  25. package/dist/engine/compound-extractor.js +49 -0
  26. package/dist/engine/compound-loop.js +18 -0
  27. package/dist/engine/solution-matcher.d.ts +23 -0
  28. package/dist/engine/solution-matcher.js +124 -11
  29. package/dist/forge/mismatch-detector.js +3 -0
  30. package/dist/hooks/context-guard.d.ts +10 -0
  31. package/dist/hooks/context-guard.js +105 -49
  32. package/dist/hooks/db-guard.js +2 -2
  33. package/dist/hooks/hook-config.d.ts +27 -1
  34. package/dist/hooks/hook-config.js +72 -12
  35. package/dist/hooks/intent-classifier.js +29 -4
  36. package/dist/hooks/keyword-detector.js +114 -106
  37. package/dist/hooks/notepad-injector.js +2 -2
  38. package/dist/hooks/permission-handler.js +2 -2
  39. package/dist/hooks/post-tool-failure.js +12 -6
  40. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  41. package/dist/hooks/post-tool-handlers.js +14 -11
  42. package/dist/hooks/post-tool-use.d.ts +11 -0
  43. package/dist/hooks/post-tool-use.js +184 -71
  44. package/dist/hooks/pre-compact.d.ts +11 -1
  45. package/dist/hooks/pre-compact.js +113 -3
  46. package/dist/hooks/pre-tool-use.js +86 -56
  47. package/dist/hooks/rate-limiter.js +3 -3
  48. package/dist/hooks/secret-filter.js +2 -2
  49. package/dist/hooks/session-recovery.js +256 -236
  50. package/dist/hooks/shared/hook-response.d.ts +7 -0
  51. package/dist/hooks/shared/hook-response.js +20 -0
  52. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  53. package/dist/hooks/shared/hook-timing.js +64 -0
  54. package/dist/hooks/skill-injector.js +41 -12
  55. package/dist/hooks/slop-detector.js +3 -3
  56. package/dist/hooks/solution-injector.js +224 -197
  57. package/dist/hooks/subagent-tracker.js +2 -2
  58. package/dist/mcp/tools.js +114 -0
  59. package/dist/renderer/rule-renderer.js +9 -11
  60. package/dist/store/evidence-store.d.ts +8 -0
  61. package/dist/store/evidence-store.js +51 -0
  62. package/dist/store/rule-store.d.ts +5 -0
  63. package/dist/store/rule-store.js +22 -0
  64. package/package.json +1 -1
  65. package/skills/deep-interview/SKILL.md +166 -0
  66. package/skills/specify/SKILL.md +122 -0
@@ -23,7 +23,8 @@ import { ALL_MODES, FORGEN_HOME, ME_DIR, PACKS_DIR, STATE_DIR } from '../core/pa
23
23
  import { atomicWriteJSON } from './shared/atomic-write.js';
24
24
  import { escapeAllXmlTags } from './prompt-injection-filter.js';
25
25
  import { getSkillConflicts } from '../core/plugin-detector.js';
26
- import { approve, approveWithContext, failOpen } from './shared/hook-response.js';
26
+ import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
27
+ import { recordHookTiming } from './shared/hook-timing.js';
27
28
  /** Escape a string for safe use in XML attribute values */
28
29
  function escapeXmlAttr(s) {
29
30
  return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -50,8 +51,9 @@ export const KEYWORD_PATTERNS = [
50
51
  { pattern: /\bccg\b/i, keyword: 'ccg', type: 'skill', skill: 'ccg' },
51
52
  { pattern: /\bralplan\b/i, keyword: 'ralplan', type: 'skill', skill: 'ralplan' },
52
53
  { pattern: /\bdeep[- ]?interview\b/i, keyword: 'deep-interview', type: 'skill', skill: 'deep-interview' },
54
+ { pattern: /\bspecify\b|(?:^|\s)(명세|요구사항\s*정리|스펙\s*정리)(?:\s|$)/im, keyword: 'specify', type: 'skill', skill: 'specify' },
53
55
  { pattern: /\bpipeline\b/i, keyword: 'pipeline', type: 'skill', skill: 'pipeline' },
54
- { pattern: /\b(ecomode|에코\s*모드|토큰\s*절약)\b/i, keyword: 'ecomode', type: 'skill', skill: 'ecomode' },
56
+ { pattern: /\becomode\b|(?:^|\s)(에코\s*모드|토큰\s*절약)(?:\s|$)/im, keyword: 'ecomode', type: 'skill', skill: 'ecomode' },
55
57
  // 인젝션 모드
56
58
  { pattern: /\bultrathink\b/i, keyword: 'ultrathink', type: 'inject' },
57
59
  { pattern: /\bdeepsearch\b/i, keyword: 'deepsearch', type: 'inject' },
@@ -60,10 +62,10 @@ export const KEYWORD_PATTERNS = [
60
62
  { pattern: /(?:security[- ]?review|보안\s*리뷰|보안\s*검토)\s*(?:해|해줘|시작|해봐|부탁|mode|모드)/i, keyword: 'security-review', type: 'skill', skill: 'security-review' },
61
63
  // 실용 스킬 — 명시적 모드 호출만 매칭 (일상 단어 false positive 방지)
62
64
  { pattern: /\bgit[- ]?master\b/i, keyword: 'git-master', type: 'skill', skill: 'git-master' },
63
- { pattern: /\b(benchmark|벤치마크)\s*(?:mode|모드|해|해줘|시작|실행|돌려)|성능\s*측정/i, keyword: 'benchmark', type: 'inject' },
64
- { pattern: /\b(migrate|마이그레이션)\s*(?:mode|모드|해|해줘|시작|실행|진행)/i, keyword: 'migrate', type: 'skill', skill: 'migrate' },
65
- { pattern: /\b(debug[- ]?detective|디버그\s*탐정|체계적\s*디버깅)\b/i, keyword: 'debug-detective', type: 'skill', skill: 'debug-detective' },
66
- { pattern: /\b(refactor|리팩토링|리팩터)\s*(?:mode|모드|해|해줘|시작|실행|진행)/i, keyword: 'refactor', type: 'skill', skill: 'refactor' },
65
+ { pattern: /\b(benchmark)\s*(?:mode|모드|해|해줘|시작|실행|돌려)|(?:^|\s)(벤치마크)\s*(?:mode|모드|해|해줘|시작|실행|돌려)|(?:^|\s)성능\s*측정/im, keyword: 'benchmark', type: 'inject' },
66
+ { pattern: /\b(migrate)\s*(?:mode|모드|해|해줘|시작|실행|진행)|(?:^|\s)(마이그레이션)\s*(?:mode|모드|해|해줘|시작|실행|진행)/im, keyword: 'migrate', type: 'skill', skill: 'migrate' },
67
+ { pattern: /\b(debug[- ]?detective)\b|(?:^|\s)(디버그\s*탐정|체계적\s*디버깅)(?:\s|$)/im, keyword: 'debug-detective', type: 'skill', skill: 'debug-detective' },
68
+ { pattern: /\b(refactor)\s*(?:mode|모드|해|해줘|시작|실행|진행)|(?:^|\s)(리팩토링|리팩터)\s*(?:mode|모드|해|해줘|시작|실행|진행)/im, keyword: 'refactor', type: 'skill', skill: 'refactor' },
67
69
  ];
68
70
  // ── 인젝션 메시지 ──
69
71
  const INJECT_MESSAGES = {
@@ -268,122 +270,128 @@ function cleanSkillCaches() {
268
270
  }
269
271
  // ── 메인 ──
270
272
  async function main() {
271
- const input = await readStdinJSON();
272
- if (!isHookEnabled('keyword-detector')) {
273
- console.log(approve());
274
- return;
275
- }
276
- if (!input?.prompt) {
277
- console.log(approve());
278
- return;
279
- }
280
- const match = detectKeyword(input.prompt);
281
- const sessionId = input.session_id ?? 'unknown';
282
- // v1: regex 기반 prompt 학습 제거. Evidence 기반으로 전환됨.
283
- if (!match) {
284
- console.log(approve());
285
- return;
286
- }
287
- // Cache conflict map once for the duration of this hook execution
288
- const skillConflicts = getSkillConflicts(input.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd());
289
- if (match.type === 'cancel') {
290
- const cancelCwd = input.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
291
- if (match.keyword === 'cancel-ralph') {
292
- // ralph만 취소
293
- clearState('ralph-state');
294
- const ralphLoopState = path.join(cancelCwd, '.claude', 'ralph-loop.local.md');
295
- try {
296
- fs.unlinkSync(ralphLoopState);
297
- }
298
- catch { /* 파일 없으면 무시 */ }
299
- }
300
- else {
301
- // 모든 모드 상태 초기화 (ralplan, deep-interview 포함)
302
- for (const mode of ALL_MODES) {
303
- clearState(`${mode}-state`);
304
- }
305
- const ralphLoopState = path.join(cancelCwd, '.claude', 'ralph-loop.local.md');
306
- try {
307
- fs.unlinkSync(ralphLoopState);
308
- }
309
- catch { /* 파일 없으면 무시 */ }
310
- }
311
- // skill-cache 파일도 정리 (재주입 가능하도록)
312
- cleanSkillCaches();
313
- console.log(approveWithContext(match.message ?? '[Forgen] Mode cancelled.', 'UserPromptSubmit'));
314
- return;
315
- }
316
- if (match.type === 'inject') {
317
- // Plugin conflict check: inject 타입도 다른 플러그인과 충돌하면 스킵
318
- // (tdd, code-review 등이 OMC/superpowers와 이중 실행되는 것을 방지)
319
- const conflictPlugin = skillConflicts.get(match.keyword);
320
- if (conflictPlugin) {
321
- log.debug(`Skipping inject "${match.keyword}" — provided by ${conflictPlugin}`);
273
+ const _hookStart = Date.now();
274
+ try {
275
+ const input = await readStdinJSON();
276
+ if (!isHookEnabled('keyword-detector')) {
322
277
  console.log(approve());
323
278
  return;
324
279
  }
325
- if (shouldTrackWorkflowActivation(match)) {
326
- try { /* v1: recordModeUsage 제거 */ }
327
- catch { /* noop */ }
280
+ if (!input?.prompt) {
281
+ console.log(approve());
282
+ return;
328
283
  }
329
- console.log(approveWithContext(match.message ?? `[Forgen] ${match.keyword} mode activated.`, 'UserPromptSubmit'));
330
- return;
331
- }
332
- // 스킬 주입
333
- if (match.skill) {
334
- // Plugin conflict check: if a plugin already provides this skill, skip injection
335
- const conflictPlugin = skillConflicts.get(match.skill);
336
- if (conflictPlugin) {
337
- log.debug(`Skipping keyword "${match.keyword}" — skill provided by ${conflictPlugin}`);
284
+ const match = detectKeyword(input.prompt);
285
+ const sessionId = input.session_id ?? 'unknown';
286
+ // v1: regex 기반 prompt 학습 제거. Evidence 기반으로 전환됨.
287
+ if (!match) {
338
288
  console.log(approve());
339
289
  return;
340
290
  }
341
- // Compound: mode usage 기록
342
- // v1: recordModeUsage 제거
343
- const skillContent = loadSkillContent(match.skill);
344
- const effectiveCwd = input.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
345
- // 상태 저장
346
- saveState(`${match.skill}-state`, {
347
- active: true,
348
- startedAt: new Date().toISOString(),
349
- prompt: match.prompt,
350
- sessionId: input.session_id,
351
- });
352
- // ralph 스킬 활성화 ralph-loop 플러그인 상태 파일도 생성
353
- if (match.skill === 'ralph') {
354
- const ralphLoopDir = path.join(effectiveCwd, '.claude');
355
- const ralphLoopState = path.join(ralphLoopDir, 'ralph-loop.local.md');
356
- fs.mkdirSync(ralphLoopDir, { recursive: true });
357
- const frontmatter = [
358
- '---',
359
- 'active: true',
360
- 'iteration: 1',
361
- `session_id: ${input.session_id ?? ''}`,
362
- 'max_iterations: 0',
363
- 'completion_promise: "TASK COMPLETE"',
364
- `started_at: "${new Date().toISOString()}"`,
365
- '---',
366
- '',
367
- match.prompt ?? input.prompt,
368
- ].join('\n');
369
- fs.writeFileSync(ralphLoopState, frontmatter);
291
+ // Cache conflict map once for the duration of this hook execution
292
+ const skillConflicts = getSkillConflicts(input.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd());
293
+ if (match.type === 'cancel') {
294
+ const cancelCwd = input.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
295
+ if (match.keyword === 'cancel-ralph') {
296
+ // ralph만 취소
297
+ clearState('ralph-state');
298
+ const ralphLoopState = path.join(cancelCwd, '.claude', 'ralph-loop.local.md');
299
+ try {
300
+ fs.unlinkSync(ralphLoopState);
301
+ }
302
+ catch { /* 파일 없으면 무시 */ }
303
+ }
304
+ else {
305
+ // 모든 모드 상태 초기화 (ralplan, deep-interview 포함)
306
+ for (const mode of ALL_MODES) {
307
+ clearState(`${mode}-state`);
308
+ }
309
+ const ralphLoopState = path.join(cancelCwd, '.claude', 'ralph-loop.local.md');
310
+ try {
311
+ fs.unlinkSync(ralphLoopState);
312
+ }
313
+ catch { /* 파일 없으면 무시 */ }
314
+ }
315
+ // skill-cache 파일도 정리 (재주입 가능하도록)
316
+ cleanSkillCaches();
317
+ console.log(approveWithContext(match.message ?? '[Forgen] Mode cancelled.', 'UserPromptSubmit'));
318
+ return;
370
319
  }
371
- if (skillContent) {
372
- const truncatedContent = truncateContent(skillContent, INJECTION_CAPS.skillContentMax);
373
- console.log(approveWithContext(`<compound-skill name="${escapeXmlAttr(match.skill)}">\n${escapeAllXmlTags(truncatedContent)}\n</compound-skill>\n\nUser request: ${match.prompt}`, 'UserPromptSubmit'));
320
+ if (match.type === 'inject') {
321
+ // Plugin conflict check: inject 타입도 다른 플러그인과 충돌하면 스킵
322
+ // (tdd, code-review 등이 OMC/superpowers와 이중 실행되는 것을 방지)
323
+ const conflictPlugin = skillConflicts.get(match.keyword);
324
+ if (conflictPlugin) {
325
+ log.debug(`Skipping inject "${match.keyword}" — provided by ${conflictPlugin}`);
326
+ console.log(approve());
327
+ return;
328
+ }
329
+ if (shouldTrackWorkflowActivation(match)) {
330
+ try { /* v1: recordModeUsage 제거 */ }
331
+ catch { /* noop */ }
332
+ }
333
+ console.log(approveWithContext(match.message ?? `[Forgen] ${match.keyword} mode activated.`, 'UserPromptSubmit'));
334
+ return;
374
335
  }
375
- else {
376
- console.log(approveWithContext(`[Forgen] ${match.keyword} mode activated.\n\nUser request: ${match.prompt}`, 'UserPromptSubmit'));
336
+ // 스킬 주입
337
+ if (match.skill) {
338
+ // Plugin conflict check: if a plugin already provides this skill, skip injection
339
+ const conflictPlugin = skillConflicts.get(match.skill);
340
+ if (conflictPlugin) {
341
+ log.debug(`Skipping keyword "${match.keyword}" — skill provided by ${conflictPlugin}`);
342
+ console.log(approve());
343
+ return;
344
+ }
345
+ // Compound: mode usage 기록
346
+ // v1: recordModeUsage 제거
347
+ const skillContent = loadSkillContent(match.skill);
348
+ const effectiveCwd = input.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
349
+ // 상태 저장
350
+ saveState(`${match.skill}-state`, {
351
+ active: true,
352
+ startedAt: new Date().toISOString(),
353
+ prompt: match.prompt,
354
+ sessionId: input.session_id,
355
+ });
356
+ // ralph 스킬 활성화 시 ralph-loop 플러그인 상태 파일도 생성
357
+ if (match.skill === 'ralph') {
358
+ const ralphLoopDir = path.join(effectiveCwd, '.claude');
359
+ const ralphLoopState = path.join(ralphLoopDir, 'ralph-loop.local.md');
360
+ fs.mkdirSync(ralphLoopDir, { recursive: true });
361
+ const frontmatter = [
362
+ '---',
363
+ 'active: true',
364
+ 'iteration: 1',
365
+ `session_id: ${input.session_id ?? ''}`,
366
+ 'max_iterations: 0',
367
+ 'completion_promise: "TASK COMPLETE"',
368
+ `started_at: "${new Date().toISOString()}"`,
369
+ '---',
370
+ '',
371
+ match.prompt ?? input.prompt,
372
+ ].join('\n');
373
+ fs.writeFileSync(ralphLoopState, frontmatter);
374
+ }
375
+ if (skillContent) {
376
+ const truncatedContent = truncateContent(skillContent, INJECTION_CAPS.skillContentMax);
377
+ console.log(approveWithContext(`<compound-skill name="${escapeXmlAttr(match.skill)}">\n${escapeAllXmlTags(truncatedContent)}\n</compound-skill>\n\nUser request: ${match.prompt}`, 'UserPromptSubmit'));
378
+ }
379
+ else {
380
+ console.log(approveWithContext(`[Forgen] ${match.keyword} mode activated.\n\nUser request: ${match.prompt}`, 'UserPromptSubmit'));
381
+ }
382
+ return;
377
383
  }
378
- return;
384
+ console.log(approve());
385
+ }
386
+ finally {
387
+ recordHookTiming('keyword-detector', Date.now() - _hookStart, 'UserPromptSubmit');
379
388
  }
380
- console.log(approve());
381
389
  }
382
390
  // ESM main guard: 다른 모듈에서 import 시 main() 실행 방지
383
391
  // realpathSync로 symlink 해석 (플러그인 캐시가 symlink일 때 경로 불일치 방지)
384
392
  if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
385
393
  main().catch((e) => {
386
394
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
387
- console.log(failOpen());
395
+ console.log(failOpenWithTracking('keyword-detector'));
388
396
  });
389
397
  }
@@ -20,7 +20,7 @@ import { readNotepad } from '../core/notepad.js';
20
20
  import { isHookEnabled } from './hook-config.js';
21
21
  import { truncateContent } from './shared/injection-caps.js';
22
22
  import { calculateBudget } from './shared/context-budget.js';
23
- import { approve, approveWithContext, failOpen } from './shared/hook-response.js';
23
+ import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
24
24
  // ── 메인 ──
25
25
  async function main() {
26
26
  const input = await readStdinJSON();
@@ -47,5 +47,5 @@ async function main() {
47
47
  }
48
48
  main().catch((e) => {
49
49
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
50
- console.log(failOpen());
50
+ console.log(failOpenWithTracking('notepad-injector'));
51
51
  });
@@ -13,7 +13,7 @@ const log = createLogger('permission-handler');
13
13
  import { readStdinJSON } from './shared/read-stdin.js';
14
14
  import { sanitizeId } from './shared/sanitize-id.js';
15
15
  import { isHookEnabled } from './hook-config.js';
16
- import { approve, approveWithWarning, failOpen } from './shared/hook-response.js';
16
+ import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
17
17
  import { STATE_DIR } from '../core/paths.js';
18
18
  /** 자동 승인 가능한 안전 도구 목록 */
19
19
  export const SAFE_TOOLS = new Set([
@@ -110,5 +110,5 @@ async function main() {
110
110
  }
111
111
  main().catch((e) => {
112
112
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
113
- console.log(failOpen());
113
+ console.log(failOpenWithTracking('permission-handler'));
114
114
  });
@@ -15,7 +15,7 @@ import { readStdinJSON } from './shared/read-stdin.js';
15
15
  import { isHookEnabled } from './hook-config.js';
16
16
  import { sanitizeId } from './shared/sanitize-id.js';
17
17
  import { atomicWriteJSON } from './shared/atomic-write.js';
18
- import { approve, approveWithWarning, failOpen } from './shared/hook-response.js';
18
+ import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
19
19
  import { STATE_DIR } from '../core/paths.js';
20
20
  function getFailureStatePath(sessionId) {
21
21
  return path.join(STATE_DIR, `tool-failures-${sanitizeId(sessionId)}.json`);
@@ -58,11 +58,14 @@ export function getRecoverySuggestion(error, toolName) {
58
58
  if (/timeout|timed out/.test(lower)) {
59
59
  return 'Timeout occurred. Split into smaller units and retry.';
60
60
  }
61
+ if (/old_string.*not found|not found in file|not unique|multiple matches/i.test(lower)) {
62
+ return 'The old_string matched multiple locations. Include more surrounding context to make it unique, or use replace_all: true if all occurrences should change.';
63
+ }
61
64
  if (/enoent|no such file|not found/.test(lower)) {
62
- return 'File/path does not exist. Check the path.';
65
+ return 'File/path does not exist. Use Glob to search for similar file names, then retry with the correct path.';
63
66
  }
64
67
  if (/eacces|permission denied/.test(lower)) {
65
- return 'Permission denied. Check file permissions.';
68
+ return 'Permission denied. Check file permissions with `ls -la` and fix with `chmod` if needed.';
66
69
  }
67
70
  if (/syntax error|syntaxerror/.test(lower)) {
68
71
  return 'Syntax error. Review the code again.';
@@ -70,8 +73,11 @@ export function getRecoverySuggestion(error, toolName) {
70
73
  if (/enospc|no space/.test(lower)) {
71
74
  return 'Disk space is insufficient.';
72
75
  }
73
- if (/old_string.*not found|not unique/i.test(lower)) {
74
- return 'Edit tool old_string not found in file. Use Read to check the current file content and retry.';
76
+ if (/stale|file has been modified|changed since/i.test(lower)) {
77
+ return 'File content has changed since last read. Use Read to get the current content, then retry the edit with updated old_string.';
78
+ }
79
+ if (/binary|encoding|invalid utf|ucs-2/i.test(lower)) {
80
+ return "File may be binary or use non-UTF-8 encoding. Verify encoding with 'file <path>' command.";
75
81
  }
76
82
  return `${toolName} tool failed. Try a different approach.`;
77
83
  }
@@ -114,5 +120,5 @@ main().catch((e) => {
114
120
  hookName: 'post-tool-failure', eventType: 'PostToolUseFailure', cause: e,
115
121
  });
116
122
  process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
117
- console.log(failOpen());
123
+ console.log(failOpenWithTracking('post-tool-failure'));
118
124
  });
@@ -4,7 +4,7 @@
4
4
  * Compound negative/success 신호 감지, 컨텍스트 실패 카운터,
5
5
  * 솔루션 negative evidence 업데이트 등 post-tool 분석 핸들러.
6
6
  */
7
- /** 세션의 실패 카운터 증가 (컨텍스트 신호 수집) */
7
+ /** 세션의 실패 카운터 증가 (컨텍스트 신호 수집, lock 보호) */
8
8
  export declare function incrementFailureCounter(sessionId: string): void;
9
9
  /** Compound v3: detect negative signals after tool execution */
10
10
  export declare function checkCompoundNegative(toolName: string, toolResponse: string, sessionId: string): void;
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import { createLogger } from '../core/logger.js';
10
10
  import { atomicWriteJSON } from './shared/atomic-write.js';
11
+ import { withFileLockSync } from './shared/file-lock.js';
11
12
  import { sanitizeId } from './shared/sanitize-id.js';
12
13
  import { incrementEvidence } from '../engine/solution-writer.js';
13
14
  import { classifyMatch, shouldAttribute } from '../engine/term-matcher.js';
@@ -15,19 +16,21 @@ import { detectErrorPattern } from './post-tool-use.js';
15
16
  import { STATE_DIR } from '../core/paths.js';
16
17
  const log = createLogger('post-tool-handlers');
17
18
  const CONTEXT_SIGNALS_PATH = path.join(STATE_DIR, 'context-signals.json');
18
- /** 세션의 실패 카운터 증가 (컨텍스트 신호 수집) */
19
+ /** 세션의 실패 카운터 증가 (컨텍스트 신호 수집, lock 보호) */
19
20
  export function incrementFailureCounter(sessionId) {
20
21
  try {
21
- let signals = {};
22
- if (fs.existsSync(CONTEXT_SIGNALS_PATH)) {
23
- signals = JSON.parse(fs.readFileSync(CONTEXT_SIGNALS_PATH, 'utf-8'));
24
- if (signals.sessionId !== sessionId)
25
- signals = {};
26
- }
27
- signals.sessionId = sessionId;
28
- signals.previousFailures = (signals.previousFailures ?? 0) + 1;
29
- signals.updatedAt = new Date().toISOString();
30
- atomicWriteJSON(CONTEXT_SIGNALS_PATH, signals);
22
+ withFileLockSync(CONTEXT_SIGNALS_PATH, () => {
23
+ let signals = {};
24
+ if (fs.existsSync(CONTEXT_SIGNALS_PATH)) {
25
+ signals = JSON.parse(fs.readFileSync(CONTEXT_SIGNALS_PATH, 'utf-8'));
26
+ if (signals.sessionId !== sessionId)
27
+ signals = {};
28
+ }
29
+ signals.sessionId = sessionId;
30
+ signals.previousFailures = (signals.previousFailures ?? 0) + 1;
31
+ signals.updatedAt = new Date().toISOString();
32
+ atomicWriteJSON(CONTEXT_SIGNALS_PATH, signals);
33
+ });
31
34
  }
32
35
  catch (e) {
33
36
  log.debug('context signals write failed — failure count may be lost', e);
@@ -5,6 +5,7 @@
5
5
  * 도구 실행 후 결과 검증 + 파일 변경 추적.
6
6
  * Compound/workflow 핸들러는 ./post-tool-handlers.ts에 분리.
7
7
  */
8
+ import { type DriftState } from '../core/drift-score.js';
8
9
  interface ModifiedFilesState {
9
10
  sessionId: string;
10
11
  files: Record<string, {
@@ -13,6 +14,10 @@ interface ModifiedFilesState {
13
14
  tool: string;
14
15
  }>;
15
16
  toolCallCount: number;
17
+ /** Track recent write content hashes for revert detection */
18
+ recentWrites?: Record<string, string[]>;
19
+ /** Drift detection state */
20
+ drift?: DriftState;
16
21
  }
17
22
  export declare const ERROR_PATTERNS: Array<{
18
23
  pattern: RegExp;
@@ -22,6 +27,12 @@ export declare function detectErrorPattern(text: string): {
22
27
  pattern: RegExp;
23
28
  description: string;
24
29
  } | null;
30
+ export interface AgentValidationResult {
31
+ signal: string;
32
+ severity: 'info' | 'warning' | 'error';
33
+ message: string;
34
+ }
35
+ export declare function validateAgentOutput(toolResponse: string): AgentValidationResult | null;
25
36
  export declare function trackModifiedFile(state: ModifiedFilesState, filePath: string, toolName: string): {
26
37
  state: ModifiedFilesState;
27
38
  count: number;