@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.
Files changed (158) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.ja.md +79 -14
  3. package/README.ko.md +100 -14
  4. package/README.md +124 -17
  5. package/README.zh.md +79 -14
  6. package/agents/analyst.md +48 -4
  7. package/agents/architect.md +39 -4
  8. package/agents/code-reviewer.md +107 -77
  9. package/agents/critic.md +47 -4
  10. package/agents/debugger.md +46 -4
  11. package/agents/designer.md +40 -4
  12. package/agents/executor.md +112 -30
  13. package/agents/explore.md +45 -5
  14. package/agents/git-master.md +48 -4
  15. package/agents/planner.md +121 -18
  16. package/agents/test-engineer.md +58 -4
  17. package/agents/verifier.md +92 -77
  18. package/commands/architecture-decision.md +127 -258
  19. package/commands/calibrate.md +225 -0
  20. package/commands/code-review.md +163 -178
  21. package/commands/compound.md +127 -68
  22. package/commands/deep-interview.md +273 -0
  23. package/commands/docker.md +68 -178
  24. package/commands/forge-loop.md +215 -0
  25. package/commands/learn.md +231 -0
  26. package/commands/retro.md +215 -0
  27. package/commands/ship.md +277 -0
  28. package/dist/cli.js +26 -9
  29. package/dist/core/auto-compound-runner.js +14 -0
  30. package/dist/core/config-injector.d.ts +2 -1
  31. package/dist/core/config-injector.js +2 -1
  32. package/dist/core/dashboard.d.ts +108 -0
  33. package/dist/core/dashboard.js +495 -0
  34. package/dist/core/doctor.js +151 -21
  35. package/dist/core/drift-score.d.ts +49 -0
  36. package/dist/core/drift-score.js +87 -0
  37. package/dist/core/harness.d.ts +6 -1
  38. package/dist/core/harness.js +75 -19
  39. package/dist/core/mcp-config.d.ts +2 -0
  40. package/dist/core/mcp-config.js +6 -1
  41. package/dist/core/paths.d.ts +6 -1
  42. package/dist/core/paths.js +18 -2
  43. package/dist/core/spawn.d.ts +3 -2
  44. package/dist/core/spawn.js +27 -8
  45. package/dist/core/types.d.ts +34 -0
  46. package/dist/engine/compound-export.d.ts +41 -0
  47. package/dist/engine/compound-export.js +169 -0
  48. package/dist/engine/compound-lifecycle.d.ts +4 -3
  49. package/dist/engine/compound-lifecycle.js +91 -46
  50. package/dist/engine/compound-loop.js +18 -0
  51. package/dist/engine/meta-learning/adaptive-thresholds.d.ts +20 -0
  52. package/dist/engine/meta-learning/adaptive-thresholds.js +126 -0
  53. package/dist/engine/meta-learning/extraction-tuner.d.ts +15 -0
  54. package/dist/engine/meta-learning/extraction-tuner.js +99 -0
  55. package/dist/engine/meta-learning/matcher-weight-tuner.d.ts +21 -0
  56. package/dist/engine/meta-learning/matcher-weight-tuner.js +151 -0
  57. package/dist/engine/meta-learning/runner.d.ts +14 -0
  58. package/dist/engine/meta-learning/runner.js +90 -0
  59. package/dist/engine/meta-learning/scope-promoter.d.ts +21 -0
  60. package/dist/engine/meta-learning/scope-promoter.js +84 -0
  61. package/dist/engine/meta-learning/session-quality-scorer.d.ts +61 -0
  62. package/dist/engine/meta-learning/session-quality-scorer.js +166 -0
  63. package/dist/engine/meta-learning/types.d.ts +114 -0
  64. package/dist/engine/meta-learning/types.js +43 -0
  65. package/dist/engine/solution-format.d.ts +2 -2
  66. package/dist/engine/solution-format.js +249 -34
  67. package/dist/engine/solution-index.d.ts +1 -1
  68. package/dist/engine/solution-matcher.d.ts +30 -1
  69. package/dist/engine/solution-matcher.js +235 -45
  70. package/dist/fgx.js +12 -8
  71. package/dist/hooks/context-guard.d.ts +15 -0
  72. package/dist/hooks/context-guard.js +218 -56
  73. package/dist/hooks/db-guard.js +2 -2
  74. package/dist/hooks/hook-config.d.ts +27 -1
  75. package/dist/hooks/hook-config.js +72 -12
  76. package/dist/hooks/hooks-generator.d.ts +3 -0
  77. package/dist/hooks/hooks-generator.js +23 -6
  78. package/dist/hooks/intent-classifier.d.ts +0 -2
  79. package/dist/hooks/intent-classifier.js +32 -18
  80. package/dist/hooks/keyword-detector.js +126 -204
  81. package/dist/hooks/notepad-injector.js +2 -2
  82. package/dist/hooks/permission-handler.js +2 -2
  83. package/dist/hooks/post-tool-failure.js +12 -6
  84. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  85. package/dist/hooks/post-tool-handlers.js +14 -11
  86. package/dist/hooks/post-tool-use.d.ts +11 -0
  87. package/dist/hooks/post-tool-use.js +184 -71
  88. package/dist/hooks/pre-compact.d.ts +11 -1
  89. package/dist/hooks/pre-compact.js +112 -37
  90. package/dist/hooks/pre-tool-use.js +86 -56
  91. package/dist/hooks/rate-limiter.js +3 -3
  92. package/dist/hooks/secret-filter.js +2 -2
  93. package/dist/hooks/session-recovery.js +256 -236
  94. package/dist/hooks/shared/hook-response.d.ts +4 -4
  95. package/dist/hooks/shared/hook-response.js +13 -24
  96. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  97. package/dist/hooks/shared/hook-timing.js +64 -0
  98. package/dist/hooks/skill-injector.d.ts +4 -3
  99. package/dist/hooks/skill-injector.js +47 -16
  100. package/dist/hooks/slop-detector.js +3 -3
  101. package/dist/hooks/solution-injector.js +224 -197
  102. package/dist/hooks/subagent-tracker.js +2 -2
  103. package/dist/host/codex-adapter.d.ts +10 -0
  104. package/dist/host/codex-adapter.js +154 -0
  105. package/dist/mcp/solution-reader.d.ts +5 -5
  106. package/dist/mcp/solution-reader.js +34 -24
  107. package/dist/renderer/rule-renderer.js +9 -11
  108. package/dist/services/session.d.ts +19 -0
  109. package/dist/services/session.js +62 -0
  110. package/hooks/hooks.json +2 -2
  111. package/package.json +2 -1
  112. package/skills/architecture-decision/SKILL.md +113 -257
  113. package/skills/calibrate/SKILL.md +207 -0
  114. package/skills/code-review/SKILL.md +151 -178
  115. package/skills/compound/SKILL.md +126 -68
  116. package/skills/deep-interview/SKILL.md +266 -0
  117. package/skills/docker/SKILL.md +57 -179
  118. package/skills/forge-loop/SKILL.md +198 -0
  119. package/skills/learn/SKILL.md +216 -0
  120. package/skills/retro/SKILL.md +199 -0
  121. package/skills/ship/SKILL.md +259 -0
  122. package/agents/code-simplifier.md +0 -197
  123. package/agents/performance-reviewer.md +0 -172
  124. package/agents/qa-tester.md +0 -158
  125. package/agents/refactoring-expert.md +0 -168
  126. package/agents/scientist.md +0 -144
  127. package/agents/security-reviewer.md +0 -137
  128. package/agents/writer.md +0 -184
  129. package/commands/api-design.md +0 -268
  130. package/commands/ci-cd.md +0 -270
  131. package/commands/database.md +0 -263
  132. package/commands/debug-detective.md +0 -99
  133. package/commands/documentation.md +0 -276
  134. package/commands/ecomode.md +0 -51
  135. package/commands/frontend.md +0 -271
  136. package/commands/git-master.md +0 -90
  137. package/commands/incident-response.md +0 -292
  138. package/commands/migrate.md +0 -101
  139. package/commands/performance.md +0 -288
  140. package/commands/refactor.md +0 -105
  141. package/commands/security-review.md +0 -288
  142. package/commands/tdd.md +0 -183
  143. package/commands/testing-strategy.md +0 -265
  144. package/skills/api-design/SKILL.md +0 -262
  145. package/skills/ci-cd/SKILL.md +0 -264
  146. package/skills/database/SKILL.md +0 -257
  147. package/skills/debug-detective/SKILL.md +0 -95
  148. package/skills/documentation/SKILL.md +0 -270
  149. package/skills/ecomode/SKILL.md +0 -46
  150. package/skills/frontend/SKILL.md +0 -265
  151. package/skills/git-master/SKILL.md +0 -86
  152. package/skills/incident-response/SKILL.md +0 -286
  153. package/skills/migrate/SKILL.md +0 -96
  154. package/skills/performance/SKILL.md +0 -282
  155. package/skills/refactor/SKILL.md +0 -100
  156. package/skills/security-review/SKILL.md +0 -282
  157. package/skills/tdd/SKILL.md +0 -178
  158. package/skills/testing-strategy/SKILL.md +0 -260
@@ -25,8 +25,9 @@ import { withFileLock, withFileLockSync, FileLockError } from './shared/file-loc
25
25
  // v1: recordPrompt (regex 선호 감지) 제거
26
26
  import { calculateBudget } from './shared/context-budget.js';
27
27
  import { writeSignal } from './shared/plugin-signal.js';
28
- import { approve, approveWithContext, failOpen } from './shared/hook-response.js';
28
+ import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
29
29
  import { STATE_DIR } from '../core/paths.js';
30
+ import { recordHookTiming } from './shared/hook-timing.js';
30
31
  const MAX_SOLUTIONS_PER_SESSION = 10;
31
32
  /** 세션별 이미 주입된 솔루션 추적 (중복 방지) */
32
33
  function getSessionCachePath(sessionId) {
@@ -220,217 +221,243 @@ function backfillCacheTagsOnDisk(cachePath, allMatched) {
220
221
  }
221
222
  }
222
223
  async function main() {
223
- const input = await readStdinJSON();
224
- if (!isHookEnabled('solution-injector')) {
225
- console.log(approve());
226
- return;
227
- }
228
- if (!input?.prompt) {
229
- console.log(approve());
230
- return;
231
- }
232
- const sessionId = input.session_id ?? 'default';
233
- // v1: 교정 감지 → correction-record 호출 유도 hint
234
- const correctionPatterns = /하지\s*마|그렇게\s*말고|앞으로는|이렇게\s*해|stop\s+doing|don'?t\s+do|always\s+do|never\s+do|아니\s*그게\s*아니라/i;
235
- if (correctionPatterns.test(input.prompt)) {
224
+ const _hookStart = Date.now();
225
+ try {
226
+ const input = await readStdinJSON();
227
+ if (!isHookEnabled('solution-injector')) {
228
+ console.log(approve());
229
+ return;
230
+ }
231
+ if (!input?.prompt) {
232
+ console.log(approve());
233
+ return;
234
+ }
235
+ const sessionId = input.session_id ?? 'default';
236
+ // v1: 교정 감지 → correction-record 호출 유도 hint
237
+ const correctionPatterns = /하지\s*마|그렇게\s*말고|앞으로는|이렇게\s*해|stop\s+doing|don'?t\s+do|always\s+do|never\s+do|아니\s*그게\s*아니라/i;
238
+ if (correctionPatterns.test(input.prompt)) {
239
+ try {
240
+ writeSignal(sessionId, 'correction-detected', 0);
241
+ }
242
+ catch { /* non-critical */ }
243
+ }
244
+ // 어댑티브 버짓: 다른 플러그인 감지 시 주입��� ���동 축소
245
+ const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
246
+ const budget = calculateBudget(cwd);
247
+ const cache = loadSessionCache(sessionId);
248
+ const injected = cache.injected;
249
+ // H-1 fix: `let`으로 재할당을 허락하되, commit 이후 fresh total로 갱신된다.
250
+ // 이전엔 dead variable이었음 (선언 후 재할당 없음).
251
+ let totalInjectedChars = cache.totalInjectedChars;
252
+ if (injected.size >= MAX_SOLUTIONS_PER_SESSION || totalInjectedChars >= budget.solutionSessionMax) {
253
+ if (totalInjectedChars >= budget.solutionSessionMax) {
254
+ log.debug(`세션 토큰 상한 도달: ${totalInjectedChars}/${budget.solutionSessionMax} chars (factor=${budget.factor})`);
255
+ }
256
+ console.log(approve());
257
+ return;
258
+ }
259
+ const scope = resolveScope(cwd);
260
+ // 프롬프트와 관련된 솔루션 매칭
261
+ // allMatched는 backfill 용도로 보존: 이미 injected된 entry라도 같은 솔루션이
262
+ // 다시 매칭되면 그 정보로 cache의 missing tags를 채울 수 있다.
263
+ // matches는 새 주입 후보 (이미 injected는 제외).
264
+ const allMatched = matchSolutions(input.prompt, scope, cwd);
265
+ const matches = allMatched.filter(m => !injected.has(m.name));
266
+ // T3: emit a ranking-decision record for offline analysis. Fail-open —
267
+ // the logger swallows any error so this never blocks hook approval.
268
+ // Runs AFTER ranking (plan: "Add the logging call in solution-injector
269
+ // after ranking, not before."). `rankedTopN` records what the matcher
270
+ // returned at log time; subsequent caller-side filtering (budget cap,
271
+ // experiment cap, session-cache disjoint) is intentionally NOT captured
272
+ // here — the field's contract is "matcher's top, not final injection set".
236
273
  try {
237
- writeSignal(sessionId, 'correction-detected', 0);
274
+ const promptTags = extractTags(input.prompt);
275
+ const normalizedQuery = defaultNormalizer.normalizeTerms(promptTags);
276
+ logMatchDecision({
277
+ source: 'hook',
278
+ rawQuery: input.prompt,
279
+ normalizedQuery,
280
+ candidates: allMatched.map(m => ({
281
+ name: m.name,
282
+ relevance: m.relevance,
283
+ matchedTerms: m.matchedTags,
284
+ })),
285
+ rankedTopN: allMatched.slice(0, 5).map(m => m.name),
286
+ });
238
287
  }
239
- catch { /* non-critical */ }
240
- }
241
- // 어댑티브 버짓: 다른 플러그인 감지 시 주입��� ���동 축소
242
- const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
243
- const budget = calculateBudget(cwd);
244
- const cache = loadSessionCache(sessionId);
245
- const injected = cache.injected;
246
- // H-1 fix: `let`으로 재할당을 허락하되, commit 이후 fresh total로 갱신된다.
247
- // 이전엔 dead variable이었음 (선언 후 재할당 없음).
248
- let totalInjectedChars = cache.totalInjectedChars;
249
- if (injected.size >= MAX_SOLUTIONS_PER_SESSION || totalInjectedChars >= budget.solutionSessionMax) {
250
- if (totalInjectedChars >= budget.solutionSessionMax) {
251
- log.debug(`세션 토큰 상한 도달: ${totalInjectedChars}/${budget.solutionSessionMax} chars (factor=${budget.factor})`);
288
+ catch (e) {
289
+ log.debug('match-eval-log emit failed', e);
252
290
  }
253
- console.log(approve());
254
- return;
255
- }
256
- const scope = resolveScope(cwd);
257
- // 프롬프트와 관련된 솔루션 매칭
258
- // allMatched는 backfill 용도로 보존: 이미 injected된 entry라도 같은 솔루션이
259
- // 다시 매칭되면 그 정보로 cache의 missing tags를 채울 수 있다.
260
- // matches는 새 주입 후보 (이미 injected는 제외).
261
- const allMatched = matchSolutions(input.prompt, scope, cwd);
262
- const matches = allMatched.filter(m => !injected.has(m.name));
263
- // T3: emit a ranking-decision record for offline analysis. Fail-open —
264
- // the logger swallows any error so this never blocks hook approval.
265
- // Runs AFTER ranking (plan: "Add the logging call in solution-injector
266
- // after ranking, not before."). `rankedTopN` records what the matcher
267
- // returned at log time; subsequent caller-side filtering (budget cap,
268
- // experiment cap, session-cache disjoint) is intentionally NOT captured
269
- // here — the field's contract is "matcher's top, not final injection set".
270
- try {
271
- const promptTags = extractTags(input.prompt);
272
- const normalizedQuery = defaultNormalizer.normalizeTerms(promptTags);
273
- logMatchDecision({
274
- source: 'hook',
275
- rawQuery: input.prompt,
276
- normalizedQuery,
277
- candidates: allMatched.map(m => ({
278
- name: m.name,
279
- relevance: m.relevance,
280
- matchedTerms: m.matchedTags,
281
- })),
282
- rankedTopN: allMatched.slice(0, 5).map(m => m.name),
283
- });
284
- }
285
- catch (e) {
286
- log.debug('match-eval-log emit failed', e);
287
- }
288
- // 신규 주입할 게 없어도 backfill은 수행한다.
289
- // R2 fix: matches.length === 0인 경우에도 allMatched에 정보가 있으면
290
- // 기존 cache의 missing tags를 채울 수 있다. 이전엔 이 경로를 놓쳐서
291
- // backfill fix가 절반만 적용된 상태였다 (Codex/code-reviewer 발견).
292
- if (matches.length === 0) {
293
- const earlyCachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
294
- backfillCacheTagsOnDisk(earlyCachePath, allMatched);
295
- console.log(approve());
296
- return;
297
- }
298
- // 어댑티브 프롬프트당 솔루션 수 제한, experiment는 1개 제한
299
- let experimentCount = 0;
300
- const toInject = [];
301
- for (const sol of matches) {
302
- if (injected.has(sol.name))
303
- continue;
304
- if (sol.status === 'experiment') {
305
- if (experimentCount >= 1)
291
+ // 신규 주입할 게 없어도 backfill은 수행한다.
292
+ // R2 fix: matches.length === 0인 경우에도 allMatched에 정보가 있으면
293
+ // 기존 cache의 missing tags를 채울 수 있다. 이전엔 이 경로를 놓쳐서
294
+ // backfill fix가 절반만 적용된 상태였다 (Codex/code-reviewer 발견).
295
+ if (matches.length === 0) {
296
+ const earlyCachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
297
+ backfillCacheTagsOnDisk(earlyCachePath, allMatched);
298
+ console.log(approve());
299
+ return;
300
+ }
301
+ // 어댑티브 프롬프트당 솔루션 제한, experiment는 1개 제한
302
+ let experimentCount = 0;
303
+ const toInject = [];
304
+ for (const sol of matches) {
305
+ if (injected.has(sol.name))
306
306
  continue;
307
- experimentCount++;
307
+ if (sol.status === 'experiment') {
308
+ if (experimentCount >= 1)
309
+ continue;
310
+ experimentCount++;
311
+ }
312
+ toInject.push(sol);
313
+ if (toInject.length >= Math.min(budget.solutionsPerPrompt, MAX_SOLUTIONS_PER_SESSION - injected.size))
314
+ break;
308
315
  }
309
- toInject.push(sol);
310
- if (toInject.length >= Math.min(budget.solutionsPerPrompt, MAX_SOLUTIONS_PER_SESSION - injected.size))
311
- break;
312
- }
313
- // Progressive Disclosure Tier 2: 요약만 push, 전문은 MCP compound-read로 pull
314
- // 근거: Anthropic "smallest set of high-signal tokens" + Cursor 46.9% 토큰 절감
315
- const summaries = new Map();
316
- const candidateEntries = [];
317
- for (const sol of toInject) {
318
- // Tier 2: 한 줄 요약만 생성 (전문 읽기 없음 → 토큰 대폭 절감)
319
- const summary = `${sol.name} [${sol.type}|${sol.confidence.toFixed(2)}]: ${sol.matchedTags.slice(0, 5).join(', ')}`;
320
- summaries.set(sol.name, summary);
321
- candidateEntries.push({ name: sol.name, chars: summary.length });
322
- }
323
- // H-1 + M-3 fix: lock 안 disjoint 검증으로 새로 추가된 entry만 반환받는다.
324
- // 다른 hook이 같은 sessionId로 동시에 같은 솔루션을 inject했다면 이 hook의
325
- // commit에서는 newlyAdded에 포함되지 않아 evidence 중복 카운트가 차단된다.
326
- const commitResult = commitSessionCacheEntries(sessionId, candidateEntries);
327
- // M-1 fix: lock 실패와 정상 0건을 구분.
328
- // lock-failed / error: disk 상태 불명 → fail-open으로 approve 하되 warn으로 가시화
329
- if (commitResult.status !== 'committed') {
330
- log.warn(`session cache commit ${commitResult.status} — hook approving without injection`);
331
- console.log(approve());
332
- return;
333
- }
334
- // H-1 fix: commit 이후 fresh disk total로 caller 변수 갱신.
335
- // 이전엔 dead variable이라 budget cap이 caller-side stale 값에 의존했다.
336
- totalInjectedChars = commitResult.totalInjectedChars;
337
- // toInject은 commit 결과의 newlyAdded만 의미 있음 — evidence/cache 갱신은 이 list 기준
338
- const newlyAddedNames = new Set(commitResult.newlyAdded.map(e => e.name));
339
- const effectiveToInject = toInject.filter(sol => newlyAddedNames.has(sol.name));
340
- // 다른 hook이 모두 먼저 inject했다면 effectiveToInject가 0 — 출력할 게 없음
341
- if (effectiveToInject.length === 0) {
342
- console.log(approve());
343
- return;
344
- }
345
- // Save injection cache for Code Reflection (Phase 2) — cumulative merge
346
- // PR2c-1: withFileLock으로 read-modify-write 보호. 동시 hook이 같은 cache를
347
- // 만지면 last-writer-wins로 _sessionCounted 등 비트가 사라질 수 있었음.
348
- const injectionCachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
349
- try {
350
- await withFileLock(injectionCachePath, () => {
351
- // Lock 안에서 fresh re-read
352
- let existingSolutions = [];
316
+ // Progressive Disclosure Tier 2.5: 핵심 요약 push (이름+태그+본문 핵심 3줄)
317
+ // 이전 Tier 2(이름+태그만)는 반영률 0% → Claude가 행동 가능한 정보 부족
318
+ // 토큰 예산: 솔루션당 최대 300자, 3개 제한 → 최대 ~900자
319
+ const SUMMARY_MAX_CHARS = 300;
320
+ const summaries = new Map();
321
+ const candidateEntries = [];
322
+ for (const sol of toInject) {
323
+ let contentSnippet = '';
353
324
  try {
354
- if (fs.existsSync(injectionCachePath)) {
355
- const existing = JSON.parse(fs.readFileSync(injectionCachePath, 'utf-8'));
356
- if (Array.isArray(existing.solutions))
357
- existingSolutions = existing.solutions;
325
+ const raw = fs.readFileSync(sol.path, 'utf-8');
326
+ const contentMatch = raw.match(/## Content\n([\s\S]*?)(?:\n## |\n---|$)/);
327
+ if (contentMatch) {
328
+ // 코드 블록 제거 후 핵심 텍스트만 추출, 최대 3줄
329
+ const lines = contentMatch[1]
330
+ .replace(/```[\s\S]*?```/g, '')
331
+ .split('\n')
332
+ .map(l => l.trim())
333
+ .filter(l => l.length > 0);
334
+ contentSnippet = lines.slice(0, 3).join('\n');
335
+ if (contentSnippet.length > SUMMARY_MAX_CHARS) {
336
+ contentSnippet = contentSnippet.slice(0, SUMMARY_MAX_CHARS - 3) + '...';
337
+ }
358
338
  }
359
339
  }
360
- catch (e) {
361
- log.debug('injection cache 읽기 실패 기존 캐시 없이 새로 시작', e);
340
+ catch { /* fail-open: 파일 읽기 실패 시 이름+태그만 사용 */ }
341
+ const header = `${sol.name} [${sol.type}|${sol.confidence.toFixed(2)}]: ${sol.matchedTags.slice(0, 5).join(', ')}`;
342
+ const summary = contentSnippet ? `${header}\n ${contentSnippet.replace(/\n/g, '\n ')}` : header;
343
+ summaries.set(sol.name, summary);
344
+ candidateEntries.push({ name: sol.name, chars: summary.length });
345
+ }
346
+ // H-1 + M-3 fix: lock 안 disjoint 검증으로 새로 추가된 entry만 반환받는다.
347
+ // 다른 hook이 같은 sessionId로 동시에 같은 솔루션을 inject했다면 이 hook의
348
+ // commit에서는 newlyAdded에 포함되지 않아 evidence 중복 카운트가 차단된다.
349
+ const commitResult = commitSessionCacheEntries(sessionId, candidateEntries);
350
+ // M-1 fix: lock 실패와 정상 0건을 구분.
351
+ // lock-failed / error: disk 상태 불명 → fail-open으로 approve 하되 warn으로 가시화
352
+ if (commitResult.status !== 'committed') {
353
+ log.warn(`session cache commit ${commitResult.status} — hook approving without injection`);
354
+ console.log(approve());
355
+ return;
356
+ }
357
+ // H-1 fix: commit 이후 fresh disk total로 caller 변수 갱신.
358
+ // 이전엔 dead variable이라 budget cap이 caller-side stale 값에 의존했다.
359
+ totalInjectedChars = commitResult.totalInjectedChars;
360
+ // toInject은 commit 결과의 newlyAdded만 의미 있음 — evidence/cache 갱신은 이 list 기준
361
+ const newlyAddedNames = new Set(commitResult.newlyAdded.map(e => e.name));
362
+ const effectiveToInject = toInject.filter(sol => newlyAddedNames.has(sol.name));
363
+ // 다른 hook이 모두 먼저 inject했다면 effectiveToInject가 0 — 출력할 게 없음
364
+ if (effectiveToInject.length === 0) {
365
+ console.log(approve());
366
+ return;
367
+ }
368
+ // Save injection cache for Code Reflection (Phase 2) — cumulative merge
369
+ // PR2c-1: withFileLock으로 read-modify-write 보호. 동시 hook이 같은 cache를
370
+ // 만지면 last-writer-wins로 _sessionCounted 등 비트가 사라질 수 있었음.
371
+ const injectionCachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
372
+ try {
373
+ await withFileLock(injectionCachePath, () => {
374
+ // Lock 안에서 fresh re-read
375
+ let existingSolutions = [];
376
+ try {
377
+ if (fs.existsSync(injectionCachePath)) {
378
+ const existing = JSON.parse(fs.readFileSync(injectionCachePath, 'utf-8'));
379
+ if (Array.isArray(existing.solutions))
380
+ existingSolutions = existing.solutions;
381
+ }
382
+ }
383
+ catch (e) {
384
+ log.debug('injection cache 읽기 실패 — 기존 캐시 없이 새로 시작', e);
385
+ }
386
+ // R5: defensive copy로 SolutionMatch.tags / .identifiers reference 공유 차단.
387
+ // M-3 fix: effectiveToInject는 commitSessionCacheEntries가 검증한 disjoint set만 포함.
388
+ const newSolutions = effectiveToInject.map(sol => ({
389
+ name: sol.name,
390
+ identifiers: [...sol.identifiers],
391
+ tags: [...sol.tags],
392
+ status: sol.status,
393
+ injectedAt: new Date().toISOString(),
394
+ }));
395
+ // BACKFILL: existing entry에 tags 키 자체가 없으면 fresh로 채움.
396
+ const matchedByName = new Map(allMatched.map(m => [m.name, m]));
397
+ const existingNames = new Set(existingSolutions.map(s => s.name));
398
+ const merged = [
399
+ ...existingSolutions.map(existing => {
400
+ if (existing.tags !== undefined)
401
+ return existing;
402
+ const fresh = matchedByName.get(existing.name);
403
+ if (!fresh)
404
+ return existing;
405
+ return { ...existing, tags: [...fresh.tags] };
406
+ }),
407
+ ...newSolutions.filter(s => !existingNames.has(s.name)),
408
+ ];
409
+ const injectionData = {
410
+ solutions: merged,
411
+ updatedAt: new Date().toISOString(),
412
+ };
413
+ // mode 0o600 + dirMode 0o700 — STATE_DIR auto-detect 의존성을 명시화
414
+ atomicWriteJSON(injectionCachePath, injectionData, { mode: 0o600, dirMode: 0o700 });
415
+ });
416
+ }
417
+ catch (e) {
418
+ if (e instanceof FileLockError) {
419
+ log.warn(`injection cache lock 실패 — write skipped`, e);
420
+ }
421
+ else {
422
+ log.debug('injection cache 저장 실패', e);
362
423
  }
363
- // R5: defensive copy로 SolutionMatch.tags / .identifiers reference 공유 차단.
364
- // M-3 fix: effectiveToInject는 commitSessionCacheEntries가 검증한 disjoint set만 포함.
365
- const newSolutions = effectiveToInject.map(sol => ({
366
- name: sol.name,
367
- identifiers: [...sol.identifiers],
368
- tags: [...sol.tags],
369
- status: sol.status,
370
- injectedAt: new Date().toISOString(),
371
- }));
372
- // BACKFILL: existing entry에 tags 키 자체가 없으면 fresh로 채움.
373
- const matchedByName = new Map(allMatched.map(m => [m.name, m]));
374
- const existingNames = new Set(existingSolutions.map(s => s.name));
375
- const merged = [
376
- ...existingSolutions.map(existing => {
377
- if (existing.tags !== undefined)
378
- return existing;
379
- const fresh = matchedByName.get(existing.name);
380
- if (!fresh)
381
- return existing;
382
- return { ...existing, tags: [...fresh.tags] };
383
- }),
384
- ...newSolutions.filter(s => !existingNames.has(s.name)),
385
- ];
386
- const injectionData = {
387
- solutions: merged,
388
- updatedAt: new Date().toISOString(),
389
- };
390
- // mode 0o600 + dirMode 0o700 — STATE_DIR auto-detect 의존성을 명시화
391
- atomicWriteJSON(injectionCachePath, injectionData, { mode: 0o600, dirMode: 0o700 });
392
- });
393
- }
394
- catch (e) {
395
- if (e instanceof FileLockError) {
396
- log.warn(`injection cache lock 실패 — write skipped`, e);
397
424
  }
398
- else {
399
- log.debug('injection cache 저장 실패', e);
425
+ // Update evidence.injected counters on solution files.
426
+ // M-3 fix: effectiveToInject(commit이 검증한 disjoint set)만 evidence 갱신 →
427
+ // 동시 hook이 같은 솔루션을 inject해도 한 번만 카운트됨.
428
+ try {
429
+ const { updateSolutionEvidence } = await import('./pre-tool-use.js');
430
+ for (const sol of effectiveToInject) {
431
+ updateSolutionEvidence(sol.name, 'injected');
432
+ }
400
433
  }
401
- }
402
- // Update evidence.injected counters on solution files.
403
- // M-3 fix: effectiveToInject(commit이 검증한 disjoint set)만 evidence 갱신 →
404
- // 동시 hook이 같은 솔루션을 inject해도 한 번만 카운트됨.
405
- try {
406
- const { updateSolutionEvidence } = await import('./pre-tool-use.js');
407
- for (const sol of effectiveToInject) {
408
- updateSolutionEvidence(sol.name, 'injected');
434
+ catch (e) {
435
+ log.debug('evidence.injected counter 업데이트 실패', e);
409
436
  }
437
+ // Progressive Disclosure: Tier 1(인덱스) + Tier 2(매칭 요약) push
438
+ // Tier 3(전문)은 compound-read MCP tool로 pull
439
+ // effectiveToInject 사용 — 다른 hook이 이미 inject한 솔루션은 사용자에게 다시 push 안 함
440
+ const injections = effectiveToInject.map(sol => {
441
+ const summary = summaries.get(sol.name) ?? sol.name;
442
+ return `- ${summary}`;
443
+ }).join('\n');
444
+ const header = `Matched solutions (apply these patterns to your response):\n`;
445
+ const footer = `\n\nAPPLY the patterns above to your response. If a pattern is directly relevant, follow its guidance. Use compound-read MCP tool for full details if needed.\nWhen using Grep or Bash, always set head_limit or pipe through | head -n to limit output size.`;
446
+ const fullInjection = header + injections + footer;
447
+ // 플러그인 시그널 기록 (다른 플러그인이 참고할 수 있도록)
448
+ try {
449
+ writeSignal(sessionId, 'UserPromptSubmit', fullInjection.length);
450
+ }
451
+ catch (e) {
452
+ log.debug('plugin signal 기록 실패', e);
453
+ }
454
+ console.log(approveWithContext(fullInjection, 'UserPromptSubmit'));
410
455
  }
411
- catch (e) {
412
- log.debug('evidence.injected counter 업데이트 실패', e);
413
- }
414
- // Progressive Disclosure: Tier 1(인덱스) + Tier 2(매칭 요약) push
415
- // Tier 3(전문)은 compound-read MCP tool로 pull
416
- // effectiveToInject 사용 — 다른 hook이 이미 inject한 솔루션은 사용자에게 다시 push 안 함
417
- const injections = effectiveToInject.map(sol => {
418
- const summary = summaries.get(sol.name) ?? sol.name;
419
- return `- ${summary}`;
420
- }).join('\n');
421
- const header = `Matched solutions (compound-read로 전문 확인 시 더 정확한 구현 가능):\n`;
422
- const footer = `\n\nIMPORTANT: When you use compound knowledge above, briefly mention it naturally (e.g., "Based on accumulated patterns..." or "From past experience..."). This helps the user see compound learning in action.`;
423
- const fullInjection = header + injections + footer;
424
- // 플러그인 시그널 기록 (다른 플러그인이 참고할 수 있도록)
425
- try {
426
- writeSignal(sessionId, 'UserPromptSubmit', fullInjection.length);
427
- }
428
- catch (e) {
429
- log.debug('plugin signal 기록 실패', e);
456
+ finally {
457
+ recordHookTiming('solution-injector', Date.now() - _hookStart, 'UserPromptSubmit');
430
458
  }
431
- console.log(approveWithContext(fullInjection, 'UserPromptSubmit'));
432
459
  }
433
460
  main().catch((e) => {
434
461
  process.stderr.write(`[ch-hook] solution-injector: ${e instanceof Error ? e.message : String(e)}\n`);
435
- console.log(failOpen());
462
+ console.log(failOpenWithTracking('solution-injector'));
436
463
  });
@@ -13,7 +13,7 @@ import { readStdinJSON } from './shared/read-stdin.js';
13
13
  import { isHookEnabled } from './hook-config.js';
14
14
  import { sanitizeId } from './shared/sanitize-id.js';
15
15
  import { atomicWriteJSON } from './shared/atomic-write.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
  const MAX_CONCURRENT_AGENTS = 10;
19
19
  const AGENT_GC_AGE_MS = 60 * 60 * 1000; // 1시간 이상 종료된 에이전트는 GC
@@ -86,5 +86,5 @@ async function main() {
86
86
  }
87
87
  main().catch((e) => {
88
88
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
89
- console.log(failOpen());
89
+ console.log(failOpenWithTracking('subagent-tracker'));
90
90
  });
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codex 훅 어댑터
4
+ *
5
+ * 목적:
6
+ * - codex 런타임에서 실행되는 훅 스크립트 출력을 Claude Hook schema로 정규화
7
+ * - continue 누락 또는 codex 특화 판정 필드(approved/decision) 대응
8
+ * - 파싱 실패/실행 실패 시 fail-open(continue: true)
9
+ */
10
+ export {};
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codex 훅 어댑터
4
+ *
5
+ * 목적:
6
+ * - codex 런타임에서 실행되는 훅 스크립트 출력을 Claude Hook schema로 정규화
7
+ * - continue 누락 또는 codex 특화 판정 필드(approved/decision) 대응
8
+ * - 파싱 실패/실행 실패 시 fail-open(continue: true)
9
+ */
10
+ import { spawnSync } from 'node:child_process';
11
+ function parseDecision(raw) {
12
+ if (typeof raw === 'boolean') {
13
+ return { continueFlag: raw };
14
+ }
15
+ if (typeof raw === 'string') {
16
+ const normalized = raw.toLowerCase();
17
+ if (normalized === 'continue')
18
+ return { continueFlag: true };
19
+ if (normalized === 'stop' || normalized === 'deny' || normalized === 'reject' || normalized === 'block') {
20
+ return { continueFlag: false, permissionDecision: normalized };
21
+ }
22
+ return { continueFlag: true };
23
+ }
24
+ if (typeof raw !== 'object' || raw === null)
25
+ return { continueFlag: true };
26
+ const value = raw.decision;
27
+ if (typeof value === 'string') {
28
+ const normalized = value.toLowerCase();
29
+ if (normalized === 'deny' || normalized === 'reject' || normalized === 'block') {
30
+ return { continueFlag: false, permissionDecision: normalized };
31
+ }
32
+ if (normalized === 'ask' || normalized === 'prompt' || normalized === 'confirm') {
33
+ return { continueFlag: true, permissionDecision: normalized };
34
+ }
35
+ }
36
+ if (typeof raw.approved === 'boolean') {
37
+ const approved = raw.approved;
38
+ return approved
39
+ ? { continueFlag: true, permissionDecision: raw.decision || 'approve' }
40
+ : { continueFlag: false, permissionDecision: 'deny' };
41
+ }
42
+ if (typeof raw.continue === 'boolean') {
43
+ return { continueFlag: raw.continue };
44
+ }
45
+ return { continueFlag: true };
46
+ }
47
+ function lastJSONObjectFromText(raw) {
48
+ const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
49
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
50
+ try {
51
+ return JSON.parse(lines[i]);
52
+ }
53
+ catch {
54
+ // continue
55
+ }
56
+ }
57
+ try {
58
+ return JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ function normalizeOutput(raw, input) {
65
+ const result = { continue: true };
66
+ const decision = parseDecision(raw);
67
+ result.continue = decision.continueFlag;
68
+ if (typeof raw === 'object' && raw !== null) {
69
+ const payload = raw;
70
+ if (typeof payload.continue === 'boolean')
71
+ result.continue = payload.continue;
72
+ if (typeof payload.systemMessage === 'string')
73
+ result.systemMessage = payload.systemMessage;
74
+ if (typeof payload.suppressOutput === 'boolean')
75
+ result.suppressOutput = payload.suppressOutput;
76
+ if (typeof payload.hookSpecificOutput === 'object' && payload.hookSpecificOutput !== null) {
77
+ result.hookSpecificOutput = { ...payload.hookSpecificOutput };
78
+ }
79
+ if (typeof payload.decision === 'string') {
80
+ result.hookSpecificOutput = {
81
+ ...(result.hookSpecificOutput ?? {}),
82
+ permissionDecision: payload.decision,
83
+ };
84
+ }
85
+ }
86
+ const eventName = result.hookSpecificOutput?.hookEventName ?? input.hookEventName ?? input.event;
87
+ if (eventName) {
88
+ result.hookSpecificOutput = {
89
+ hookEventName: eventName,
90
+ ...(result.hookSpecificOutput ?? {}),
91
+ };
92
+ }
93
+ if (!result.continue && !result.hookSpecificOutput?.permissionDecision) {
94
+ if (decision.permissionDecision)
95
+ result.hookSpecificOutput = {
96
+ ...(result.hookSpecificOutput ?? {}),
97
+ permissionDecision: decision.permissionDecision,
98
+ };
99
+ else
100
+ result.hookSpecificOutput = { ...(result.hookSpecificOutput ?? {}), permissionDecision: 'deny' };
101
+ }
102
+ return result;
103
+ }
104
+ async function main() {
105
+ const [delegatePath, ...restArgs] = process.argv.slice(2);
106
+ if (!delegatePath) {
107
+ console.log(JSON.stringify({ continue: true }));
108
+ return;
109
+ }
110
+ const input = await (async () => {
111
+ const chunks = [];
112
+ let totalBytes = 0;
113
+ for await (const chunk of process.stdin) {
114
+ chunks.push(chunk);
115
+ totalBytes += chunk.length;
116
+ if (totalBytes > 10 * 1024 * 1024)
117
+ break;
118
+ }
119
+ const raw = Buffer.concat(chunks.map(c => typeof c === 'string' ? Buffer.from(c) : c)).toString('utf-8').trim();
120
+ if (!raw)
121
+ return {};
122
+ try {
123
+ return JSON.parse(raw);
124
+ }
125
+ catch {
126
+ return {};
127
+ }
128
+ })();
129
+ try {
130
+ const result = spawnSync(process.execPath, [delegatePath, ...restArgs], {
131
+ encoding: 'utf-8',
132
+ input: JSON.stringify(input),
133
+ cwd: process.cwd(),
134
+ stdio: ['pipe', 'pipe', 'pipe'],
135
+ });
136
+ if (result.error) {
137
+ console.log(JSON.stringify({ continue: true }));
138
+ return;
139
+ }
140
+ const parsed = lastJSONObjectFromText(result.stdout ?? '');
141
+ if (!parsed) {
142
+ console.log(JSON.stringify({ continue: true }));
143
+ return;
144
+ }
145
+ const output = normalizeOutput(parsed, input);
146
+ console.log(JSON.stringify(output));
147
+ }
148
+ catch {
149
+ console.log(JSON.stringify({ continue: true }));
150
+ }
151
+ }
152
+ main().catch(() => {
153
+ console.log(JSON.stringify({ continue: true }));
154
+ });