@wooojin/forgen 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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 +9 -0
  9. package/dist/core/dashboard.d.ts +91 -0
  10. package/dist/core/dashboard.js +385 -0
  11. package/dist/core/doctor.js +151 -21
  12. package/dist/core/drift-score.d.ts +49 -0
  13. package/dist/core/drift-score.js +87 -0
  14. package/dist/core/mcp-config.d.ts +2 -0
  15. package/dist/core/mcp-config.js +6 -1
  16. package/dist/core/paths.d.ts +1 -1
  17. package/dist/core/paths.js +1 -1
  18. package/dist/engine/compound-export.d.ts +41 -0
  19. package/dist/engine/compound-export.js +169 -0
  20. package/dist/engine/compound-loop.js +18 -0
  21. package/dist/engine/solution-matcher.d.ts +23 -0
  22. package/dist/engine/solution-matcher.js +124 -11
  23. package/dist/hooks/context-guard.d.ts +10 -0
  24. package/dist/hooks/context-guard.js +104 -58
  25. package/dist/hooks/db-guard.js +2 -2
  26. package/dist/hooks/hook-config.d.ts +27 -1
  27. package/dist/hooks/hook-config.js +72 -12
  28. package/dist/hooks/intent-classifier.d.ts +0 -2
  29. package/dist/hooks/intent-classifier.js +32 -18
  30. package/dist/hooks/keyword-detector.js +117 -111
  31. package/dist/hooks/notepad-injector.js +2 -2
  32. package/dist/hooks/permission-handler.js +2 -2
  33. package/dist/hooks/post-tool-failure.js +12 -6
  34. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  35. package/dist/hooks/post-tool-handlers.js +14 -11
  36. package/dist/hooks/post-tool-use.d.ts +11 -0
  37. package/dist/hooks/post-tool-use.js +184 -71
  38. package/dist/hooks/pre-compact.d.ts +11 -1
  39. package/dist/hooks/pre-compact.js +112 -37
  40. package/dist/hooks/pre-tool-use.js +86 -56
  41. package/dist/hooks/rate-limiter.js +3 -3
  42. package/dist/hooks/secret-filter.js +2 -2
  43. package/dist/hooks/session-recovery.js +256 -236
  44. package/dist/hooks/shared/hook-response.d.ts +4 -4
  45. package/dist/hooks/shared/hook-response.js +13 -24
  46. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  47. package/dist/hooks/shared/hook-timing.js +64 -0
  48. package/dist/hooks/skill-injector.js +41 -12
  49. package/dist/hooks/slop-detector.js +3 -3
  50. package/dist/hooks/solution-injector.js +224 -197
  51. package/dist/hooks/subagent-tracker.js +2 -2
  52. package/dist/renderer/rule-renderer.js +9 -11
  53. package/package.json +1 -1
  54. package/skills/deep-interview/SKILL.md +166 -0
  55. package/skills/specify/SKILL.md +122 -0
@@ -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
  });
@@ -6,12 +6,12 @@
6
6
  *
7
7
  * 파이프라인: filter → dedupe(render_key) → group(category) → order → template → budget
8
8
  */
9
- import { initLocaleFromConfig, getLocale, qualityName, autonomyName, judgmentName, communicationName, RULE_RENDERER } from '../i18n/index.js';
9
+ import { initLocaleFromConfig, getLocale, RULE_RENDERER } from '../i18n/index.js';
10
10
  export const DEFAULT_CONTEXT = {
11
11
  surface: 'session_start',
12
12
  max_rules: 30,
13
13
  max_chars: 4000,
14
- include_pack_summary: true,
14
+ include_pack_summary: false,
15
15
  };
16
16
  // ── Output Sections ──
17
17
  const SECTION_ORDER = ['Must Not', 'Working Defaults', 'When To Ask', 'How To Validate', 'How To Report'];
@@ -61,9 +61,11 @@ function dedupeByRenderKey(rules) {
61
61
  }
62
62
  // ── Template ──
63
63
  function ruleToText(rule) {
64
- // policy 필드가 이미 사람이 읽을 수 있는 문장이면 그대로 사용
65
- // render_key로 템플릿을 찾을 수도 있지만 v1은 policy 직접 사용
66
- return rule.policy;
64
+ // AI-optimized: [category|strength] 태그로 축약하여 토큰 절감
65
+ // hard 강도는 태그 생략 (Must Not 섹션이 이미 의미를 전달)
66
+ if (rule.strength === 'hard')
67
+ return rule.policy;
68
+ return `[${rule.category}|${rule.strength}] ${rule.policy}`;
67
69
  }
68
70
  function trustPolicySummary(policy) {
69
71
  const s = RULE_RENDERER[getLocale()];
@@ -121,13 +123,9 @@ export function renderRules(rules, state, _profile, ctx = DEFAULT_CONTEXT) {
121
123
  sections.get('How To Report').push(rule);
122
124
  }
123
125
  }
124
- // 6. 섹션 조립
126
+ // 6. 섹션 조립 (AI-optimized: 간결한 태그 형식)
125
127
  const parts = [];
126
- if (ctx.include_pack_summary) {
127
- const l = getLocale();
128
- parts.push(`[${qualityName(state.quality_pack, l)} quality / ${autonomyName(state.autonomy_pack, l)} autonomy / ${judgmentName(state.judgment_pack, l)} judgment / ${communicationName(state.communication_pack, l)} communication]`);
129
- }
130
- let totalChars = parts.reduce((sum, p) => sum + p.length, 0);
128
+ let totalChars = 0;
131
129
  let totalRules = 0;
132
130
  for (const name of SECTION_ORDER) {
133
131
  const items = sections.get(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooojin/forgen",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "main": "dist/lib.js",
5
5
  "types": "./dist/lib.d.ts",
6
6
  "exports": {