@wooojin/forgen 0.4.1 → 0.4.4

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 (151) hide show
  1. package/.claude-plugin/plugin.json +5 -5
  2. package/CHANGELOG.md +267 -15
  3. package/CONTRIBUTING.md +2 -2
  4. package/README.ja.md +17 -9
  5. package/README.ko.md +34 -12
  6. package/README.md +65 -12
  7. package/README.zh.md +17 -9
  8. package/assets/README.md +86 -0
  9. package/assets/architecture.svg +100 -0
  10. package/assets/banner.png +0 -0
  11. package/assets/banner.svg +53 -0
  12. package/{commands → assets/claude/commands}/calibrate.md +4 -3
  13. package/{commands → assets/claude/commands}/retro.md +2 -2
  14. package/assets/demo/01-install.gif +0 -0
  15. package/assets/demo/01-install.tape +54 -0
  16. package/assets/demo/02-compound-learning.gif +0 -0
  17. package/assets/demo/02-compound-learning.tape +50 -0
  18. package/assets/demo/03-forge-personalization.gif +0 -0
  19. package/assets/demo/03-forge-personalization.tape +64 -0
  20. package/assets/demo/before-after.gif +0 -0
  21. package/assets/demo/before-after.tape +98 -0
  22. package/assets/demo-preview.svg +96 -0
  23. package/assets/icon.png +0 -0
  24. package/{hooks → assets/shared}/hook-registry.json +2 -1
  25. package/dist/checks/_shared/text-sanitizer.d.ts +21 -0
  26. package/dist/checks/_shared/text-sanitizer.js +60 -0
  27. package/dist/checks/dangerous-response-pattern.d.ts +32 -0
  28. package/dist/checks/dangerous-response-pattern.js +65 -0
  29. package/dist/checks/fact-vs-agreement.js +25 -1
  30. package/dist/cli.js +78 -6
  31. package/dist/core/auto-compound-runner.js +90 -39
  32. package/dist/core/behavior-classifier.d.ts +28 -0
  33. package/dist/core/behavior-classifier.js +46 -0
  34. package/dist/core/dashboard.d.ts +7 -0
  35. package/dist/core/dashboard.js +32 -0
  36. package/dist/core/doctor.js +92 -0
  37. package/dist/core/git-stats.d.ts +36 -0
  38. package/dist/core/git-stats.js +79 -0
  39. package/dist/core/harness.d.ts +1 -1
  40. package/dist/core/harness.js +27 -20
  41. package/dist/core/host-detect.d.ts +42 -0
  42. package/dist/core/host-detect.js +68 -0
  43. package/dist/core/installer.js +2 -2
  44. package/dist/core/migrate-cli.d.ts +1 -0
  45. package/dist/core/migrate-cli.js +19 -0
  46. package/dist/core/migrate-evidence-host.d.ts +36 -0
  47. package/dist/core/migrate-evidence-host.js +49 -0
  48. package/dist/core/settings-injector.js +4 -2
  49. package/dist/core/spawn.d.ts +1 -1
  50. package/dist/core/spawn.js +4 -11
  51. package/dist/core/stats-cli.js +12 -0
  52. package/dist/core/trust-layer-intent.d.ts +35 -0
  53. package/dist/core/trust-layer-intent.js +30 -0
  54. package/dist/core/types.d.ts +1 -1
  55. package/dist/engine/compound-extractor.js +7 -9
  56. package/dist/engine/learn-cli.js +4 -2
  57. package/dist/engine/lifecycle/bypass-detector.d.ts +6 -1
  58. package/dist/engine/lifecycle/bypass-detector.js +57 -5
  59. package/dist/fgx.js +2 -1
  60. package/dist/forge/evidence-processor.js +12 -0
  61. package/dist/forge/onboarding.d.ts +3 -2
  62. package/dist/forge/onboarding.js +3 -2
  63. package/dist/hooks/db-guard.js +3 -3
  64. package/dist/hooks/forge-loop-progress.d.ts +9 -0
  65. package/dist/hooks/forge-loop-progress.js +38 -0
  66. package/dist/hooks/hook-registry.js +1 -1
  67. package/dist/hooks/hooks-generator.d.ts +15 -1
  68. package/dist/hooks/hooks-generator.js +18 -16
  69. package/dist/hooks/keyword-detector.js +1 -1
  70. package/dist/hooks/post-tool-use.d.ts +1 -1
  71. package/dist/hooks/post-tool-use.js +13 -4
  72. package/dist/hooks/pre-compact.js +1 -1
  73. package/dist/hooks/pre-tool-use.js +4 -4
  74. package/dist/hooks/rate-limiter.js +2 -2
  75. package/dist/hooks/session-recovery.js +11 -0
  76. package/dist/hooks/shared/blocking-allowlist.d.ts +28 -0
  77. package/dist/hooks/shared/blocking-allowlist.js +38 -0
  78. package/dist/hooks/shared/forge-loop-state.d.ts +36 -0
  79. package/dist/hooks/shared/forge-loop-state.js +116 -0
  80. package/dist/hooks/shared/hook-response.d.ts +18 -0
  81. package/dist/hooks/shared/hook-response.js +31 -0
  82. package/dist/hooks/skill-injector.js +1 -1
  83. package/dist/hooks/stop-guard.js +57 -25
  84. package/dist/host/capabilities-claude.d.ts +8 -0
  85. package/dist/host/capabilities-claude.js +46 -0
  86. package/dist/host/capabilities-codex.d.ts +11 -0
  87. package/dist/host/capabilities-codex.js +50 -0
  88. package/dist/host/capabilities-registry.d.ts +11 -0
  89. package/dist/host/capabilities-registry.js +30 -0
  90. package/dist/host/codex-adapter.d.ts +8 -5
  91. package/dist/host/codex-adapter.js +10 -82
  92. package/dist/host/codex-output-parser.d.ts +39 -0
  93. package/dist/host/codex-output-parser.js +75 -0
  94. package/dist/host/exec-host.d.ts +54 -0
  95. package/dist/host/exec-host.js +92 -0
  96. package/dist/host/host-runtime.d.ts +37 -0
  97. package/dist/host/host-runtime.js +51 -0
  98. package/dist/host/install-claude.d.ts +35 -0
  99. package/dist/host/install-claude.js +238 -0
  100. package/dist/host/install-codex.d.ts +44 -0
  101. package/dist/host/install-codex.js +276 -0
  102. package/dist/host/install-orchestrator.d.ts +34 -0
  103. package/dist/host/install-orchestrator.js +126 -0
  104. package/dist/host/invoke-agent.d.ts +27 -0
  105. package/dist/host/invoke-agent.js +115 -0
  106. package/dist/host/parity-harness.d.ts +62 -0
  107. package/dist/host/parity-harness.js +283 -0
  108. package/dist/host/projection.d.ts +35 -0
  109. package/dist/host/projection.js +126 -0
  110. package/dist/mcp/server.js +11 -0
  111. package/dist/mcp/tools.js +51 -0
  112. package/dist/renderer/rule-renderer.d.ts +1 -1
  113. package/dist/renderer/rule-renderer.js +73 -1
  114. package/dist/services/session.d.ts +6 -3
  115. package/dist/services/session.js +33 -4
  116. package/dist/store/compound-usage-store.d.ts +28 -0
  117. package/dist/store/compound-usage-store.js +59 -0
  118. package/dist/store/evidence-store.d.ts +1 -0
  119. package/dist/store/evidence-store.js +34 -3
  120. package/dist/store/host-mismatch.d.ts +42 -0
  121. package/dist/store/host-mismatch.js +65 -0
  122. package/dist/store/profile-store.d.ts +29 -0
  123. package/dist/store/profile-store.js +53 -0
  124. package/dist/store/types.d.ts +13 -0
  125. package/hooks/hooks.json +6 -1
  126. package/package.json +6 -4
  127. package/plugin.json +4 -4
  128. package/scripts/postinstall.js +100 -25
  129. package/skills/calibrate/SKILL.md +4 -3
  130. package/skills/retro/SKILL.md +2 -2
  131. /package/{agents → assets/claude/agents}/analyst.md +0 -0
  132. /package/{agents → assets/claude/agents}/architect.md +0 -0
  133. /package/{agents → assets/claude/agents}/code-reviewer.md +0 -0
  134. /package/{agents → assets/claude/agents}/critic.md +0 -0
  135. /package/{agents → assets/claude/agents}/debugger.md +0 -0
  136. /package/{agents → assets/claude/agents}/designer.md +0 -0
  137. /package/{agents → assets/claude/agents}/executor.md +0 -0
  138. /package/{agents → assets/claude/agents}/explore.md +0 -0
  139. /package/{agents → assets/claude/agents}/git-master.md +0 -0
  140. /package/{agents → assets/claude/agents}/planner.md +0 -0
  141. /package/{agents → assets/claude/agents}/solution-evolver.md +0 -0
  142. /package/{agents → assets/claude/agents}/test-engineer.md +0 -0
  143. /package/{agents → assets/claude/agents}/verifier.md +0 -0
  144. /package/{commands → assets/claude/commands}/architecture-decision.md +0 -0
  145. /package/{commands → assets/claude/commands}/code-review.md +0 -0
  146. /package/{commands → assets/claude/commands}/compound.md +0 -0
  147. /package/{commands → assets/claude/commands}/deep-interview.md +0 -0
  148. /package/{commands → assets/claude/commands}/docker.md +0 -0
  149. /package/{commands → assets/claude/commands}/forge-loop.md +0 -0
  150. /package/{commands → assets/claude/commands}/learn.md +0 -0
  151. /package/{commands → assets/claude/commands}/ship.md +0 -0
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Forgen v1 — Onboarding
3
3
  *
4
- * 2문항 온보딩, 점수 계산, pack 추천.
5
- * Authoritative spec: docs/plans/2026-04-03-forgen-onboarding-adaptation-spec.md §3-4
4
+ * 4문항 온보딩 (quality / autonomy / judgment / communication 4축), 점수 계산, pack 추천.
5
+ * Authoritative spec: docs/history/2026-04-03-tenetx-onboarding-adaptation-spec.md
6
+ * (spec §3 은 v0.1 시점 2문항 기준 — v0.4 부터 4문항으로 확장됨, src/forge/onboarding-cli.ts:69-75 참조)
6
7
  */
7
8
  import { createRecommendation } from '../store/recommendation-store.js';
8
9
  // 질문 1: 애매한 구현 요청 + 인접 영향 가능성
@@ -9,7 +9,7 @@ import * as path from 'node:path';
9
9
  import { readStdinJSON } from './shared/read-stdin.js';
10
10
  import { atomicWriteJSON } from './shared/atomic-write.js';
11
11
  import { isHookEnabled } from './hook-config.js';
12
- import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
12
+ import { approve, approveWithWarning, denyOrObserve, failOpenWithTracking } from './shared/hook-response.js';
13
13
  import { STATE_DIR } from '../core/paths.js';
14
14
  import { preprocessForMatch } from './shared/command-parser.js';
15
15
  const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'db-guard-fail-counter.json');
@@ -89,7 +89,7 @@ async function main() {
89
89
  if (!data) {
90
90
  const failCount = getAndIncrementFailCount();
91
91
  if (failCount >= FAIL_CLOSE_THRESHOLD) {
92
- console.log(deny(`[Forgen] DB Guard: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
92
+ console.log(denyOrObserve('db-guard', `[Forgen] DB Guard: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
93
93
  }
94
94
  else {
95
95
  process.stderr.write(`[ch-hook] db-guard stdin parse failed (${failCount}/${FAIL_CLOSE_THRESHOLD})\n`);
@@ -106,7 +106,7 @@ async function main() {
106
106
  const toolInput = data.tool_input ?? data.toolInput ?? {};
107
107
  const check = checkDangerousSql(toolName, toolInput);
108
108
  if (check.action === 'block') {
109
- console.log(deny(`[Forgen] Dangerous SQL blocked: ${check.description}`));
109
+ console.log(denyOrObserve('db-guard', `[Forgen] Dangerous SQL blocked: ${check.description}`));
110
110
  return;
111
111
  }
112
112
  if (check.action === 'warn') {
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forgen — Forge Loop Progress Injector
4
+ *
5
+ * Claude Code UserPromptSubmit 훅. forge-loop active=true 인 동안 매 프롬프트
6
+ * 마다 진행 상황(N/M, next story)을 컨텍스트에 inject 한다. RC6 가드의 두 번째
7
+ * 축 — 세션 도중에도 forge-loop 가 컨텍스트에서 사라지지 않게 함.
8
+ */
9
+ export {};
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forgen — Forge Loop Progress Injector
4
+ *
5
+ * Claude Code UserPromptSubmit 훅. forge-loop active=true 인 동안 매 프롬프트
6
+ * 마다 진행 상황(N/M, next story)을 컨텍스트에 inject 한다. RC6 가드의 두 번째
7
+ * 축 — 세션 도중에도 forge-loop 가 컨텍스트에서 사라지지 않게 함.
8
+ */
9
+ import { readStdinJSON } from './shared/read-stdin.js';
10
+ import { isHookEnabled } from './hook-config.js';
11
+ import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
12
+ import { recordHookTiming } from './shared/hook-timing.js';
13
+ import { readForgeLoopState, renderForgeLoopForPrompt } from './shared/forge-loop-state.js';
14
+ import { createLogger } from '../core/logger.js';
15
+ const log = createLogger('forge-loop-progress');
16
+ async function main() {
17
+ const _hookStart = Date.now();
18
+ try {
19
+ await readStdinJSON().catch((e) => { log.debug('stdin read failed', e); return null; });
20
+ if (!isHookEnabled('forge-loop-progress')) {
21
+ console.log(approve());
22
+ return;
23
+ }
24
+ const block = renderForgeLoopForPrompt(readForgeLoopState());
25
+ if (!block) {
26
+ console.log(approve());
27
+ return;
28
+ }
29
+ console.log(approveWithContext(block, 'UserPromptSubmit'));
30
+ }
31
+ finally {
32
+ recordHookTiming('forge-loop-progress', Date.now() - _hookStart, 'UserPromptSubmit');
33
+ }
34
+ }
35
+ main().catch((e) => {
36
+ process.stderr.write(`[ch-hook] forge-loop-progress: ${e instanceof Error ? e.message : String(e)}\n`);
37
+ console.log(failOpenWithTracking('forge-loop-progress', e));
38
+ });
@@ -20,7 +20,7 @@ const require = createRequire(import.meta.url);
20
20
  * (Code Reflection + permission hints 주입 타이밍)
21
21
  * - 같은 이벤트 내 훅은 배열 순서대로 실행됨
22
22
  */
23
- import registryData from '../../hooks/hook-registry.json' with { type: 'json' };
23
+ import registryData from '../../assets/shared/hook-registry.json' with { type: 'json' };
24
24
  export const HOOK_REGISTRY = registryData;
25
25
  /** 티어별 훅 목록 조회 */
26
26
  export function getHooksByTier(tier) {
@@ -9,7 +9,7 @@
9
9
  * - forgen config hooks (사용자 설정 변경 후)
10
10
  * - forgen install (플러그인 설치 후)
11
11
  */
12
- import { type RuntimeHost } from '../core/types.js';
12
+ import type { RuntimeHost } from '../core/types.js';
13
13
  interface HookCommand {
14
14
  type: 'command';
15
15
  command: string;
@@ -30,6 +30,12 @@ interface GenerateOptions {
30
30
  pluginRoot?: string;
31
31
  /** 런타임 (claude|codex) */
32
32
  runtime?: RuntimeHost;
33
+ /**
34
+ * 환경 독립 산출물 모드 (W4, 2026-04-27).
35
+ * true 시 plugin 감지 + hook-config 비활성화 모두 건너뛰어 모든 hook 이 active.
36
+ * 배포(prepack), 테스트 결정론, runtime 환경 분리에 사용.
37
+ */
38
+ releaseMode?: boolean;
33
39
  }
34
40
  /**
35
41
  * 활성 훅만 포함한 hooks.json 객체를 생성합니다.
@@ -39,6 +45,14 @@ interface GenerateOptions {
39
45
  * 2. 충돌 훅 식별
40
46
  * 3. hook-config.json 설정 적용
41
47
  * 4. 활성 훅만 hooks.json 구조로 변환
48
+ *
49
+ * releaseMode: 환경 독립 산출물 모드 (W4, 2026-04-27).
50
+ * - true 시 plugin 감지를 건너뛰고, hook-config.json 의 사용자 비활성화도 무시한다.
51
+ * - 결과는 항상 모든 hook active — 배포 산출물 결정론화 + 테스트 안정화.
52
+ * - prepack-hooks.cjs 는 이미 HOME swap 으로 같은 효과를 내지만, 본 옵션은
53
+ * 명시적 API 로 동일 보장을 제공해 테스트가 환경 독립 검증 가능.
54
+ * - 자기증거: 본 세션이 사용자 HOME 에서 19/21 active 산출물을 받아 우회한
55
+ * 사례 — docs/issues/W4-W5-self-evidence.md 박제.
42
56
  */
43
57
  export declare function generateHooksJson(options?: GenerateOptions): HooksJson;
44
58
  /**
@@ -14,6 +14,7 @@ import * as path from 'node:path';
14
14
  import { HOOK_REGISTRY } from './hook-registry.js';
15
15
  import { isHookEnabled } from './hook-config.js';
16
16
  import { detectInstalledPlugins, getHookConflicts } from '../core/plugin-detector.js';
17
+ import { getHostRuntime } from '../host/host-runtime.js';
17
18
  function splitCommand(raw) {
18
19
  const tokens = raw.match(/"([^"]+)"|\S+/g) ?? [];
19
20
  const unquoted = tokens.map(token => token.replace(/^"/, '').replace(/"$/, ''));
@@ -24,16 +25,9 @@ function quoteArg(raw) {
24
25
  }
25
26
  function buildHookCommand(pluginRoot, rawScript, runtime) {
26
27
  const { script, args } = splitCommand(rawScript);
27
- const scriptPath = `${pluginRoot}/${script}`;
28
28
  const quotedArgs = args.map(quoteArg).join(' ');
29
- if (runtime === 'codex') {
30
- const adapterPath = `${pluginRoot}/host/codex-adapter.js`;
31
- const baseCommand = `node ${quoteArg(adapterPath)} ${quoteArg(scriptPath)}`;
32
- return `${baseCommand}${quotedArgs ? ` ${quotedArgs}` : ''}`;
33
- }
34
- return quotedArgs
35
- ? `node ${quoteArg(scriptPath)} ${quotedArgs}`
36
- : `node ${quoteArg(scriptPath)}`;
29
+ // Phase 2: host-runtime 위임 — Codex 표면 (codex-adapter 경유) 을 core 가 모르도록.
30
+ return getHostRuntime(runtime).wrapHookCommand(pluginRoot, script, quotedArgs);
37
31
  }
38
32
  /**
39
33
  * 활성 훅만 포함한 hooks.json 객체를 생성합니다.
@@ -43,23 +37,31 @@ function buildHookCommand(pluginRoot, rawScript, runtime) {
43
37
  * 2. 충돌 훅 식별
44
38
  * 3. hook-config.json 설정 적용
45
39
  * 4. 활성 훅만 hooks.json 구조로 변환
40
+ *
41
+ * releaseMode: 환경 독립 산출물 모드 (W4, 2026-04-27).
42
+ * - true 시 plugin 감지를 건너뛰고, hook-config.json 의 사용자 비활성화도 무시한다.
43
+ * - 결과는 항상 모든 hook active — 배포 산출물 결정론화 + 테스트 안정화.
44
+ * - prepack-hooks.cjs 는 이미 HOME swap 으로 같은 효과를 내지만, 본 옵션은
45
+ * 명시적 API 로 동일 보장을 제공해 테스트가 환경 독립 검증 가능.
46
+ * - 자기증거: 본 세션이 사용자 HOME 에서 19/21 active 산출물을 받아 우회한
47
+ * 사례 — docs/issues/W4-W5-self-evidence.md 박제.
46
48
  */
47
49
  export function generateHooksJson(options) {
48
50
  const cwd = options?.cwd;
51
+ const releaseMode = options?.releaseMode ?? false;
49
52
  // biome-ignore lint/suspicious/noTemplateCurlyInString: CLAUDE_PLUGIN_ROOT is a Claude Code Plugin SDK variable resolved at runtime
50
53
  const pluginRoot = options?.pluginRoot ?? '${CLAUDE_PLUGIN_ROOT}/dist';
51
54
  const runtime = options?.runtime ?? 'claude';
52
- // 다른 플러그인의 충돌 훅 감지
53
- const hookConflicts = getHookConflicts(cwd);
54
- const detectedPlugins = detectInstalledPlugins(cwd);
55
- const hasOtherPlugins = detectedPlugins.length > 0;
55
+ // 다른 플러그인의 충돌 훅 감지 — releaseMode 시 건너뜀
56
+ const hookConflicts = releaseMode ? new Set() : getHookConflicts(cwd);
57
+ const hasOtherPlugins = !releaseMode && detectInstalledPlugins(cwd).length > 0;
56
58
  // 활성 훅 필터링
57
59
  const activeHooks = HOOK_REGISTRY.filter(hook => {
58
- // 1) hook-config.json에서 명시적 비활성화
59
- if (!isHookEnabled(hook.name))
60
+ // 1) hook-config.json에서 명시적 비활성화 (releaseMode 시 무시)
61
+ if (!releaseMode && !isHookEnabled(hook.name))
60
62
  return false;
61
63
  // 2) 다른 플러그인과 충돌하는 workflow 훅은 자동 비활성
62
- // (단, compound-critical 훅은 항상 유지)
64
+ // (단, compound-critical 훅은 항상 유지. releaseMode 면 분기 조건이 false)
63
65
  if (hasOtherPlugins && hook.tier === 'workflow' && hookConflicts.has(hook.name) && !hook.compoundCritical) {
64
66
  return false;
65
67
  }
@@ -106,7 +106,7 @@ function loadSkillContent(skillName) {
106
106
  // 글로벌 스킬 경로
107
107
  searchPaths.push(path.join(FORGEN_HOME, 'skills', `${skillName}.md`));
108
108
  // forgen 패키지 내장 스킬
109
- const pkgSkillPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'commands', `${skillName}.md`);
109
+ const pkgSkillPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'claude', 'commands', `${skillName}.md`);
110
110
  searchPaths.push(pkgSkillPath);
111
111
  for (const p of searchPaths) {
112
112
  if (fs.existsSync(p)) {
@@ -38,7 +38,7 @@ export interface AgentValidationResult {
38
38
  severity: 'info' | 'warning' | 'error';
39
39
  message: string;
40
40
  }
41
- export declare function validateAgentOutput(toolResponse: string): AgentValidationResult | null;
41
+ export declare function validateAgentOutput(toolResponse: unknown): AgentValidationResult | null;
42
42
  export declare function trackModifiedFile(state: ModifiedFilesState, filePath: string, toolName: string): {
43
43
  state: ModifiedFilesState;
44
44
  count: number;
@@ -75,15 +75,21 @@ const AGENT_QUALITY_PATTERNS = [
75
75
  { pattern: /(?:context (?:window|limit) (?:exceeded|reached)|too (?:large|long) to (?:read|process))/i, signal: 'agent_context_overflow', severity: 'warning', message: 'Agent hit context limits — output may be incomplete' },
76
76
  ];
77
77
  export function validateAgentOutput(toolResponse) {
78
- if (!toolResponse || toolResponse.trim().length < AGENT_MIN_OUTPUT_LENGTH) {
78
+ // tool_response string / object / array 모두 가능. main() 측에서 stringify 를 한 번 더
79
+ // 하지만 직접 호출 보호 (defense in depth).
80
+ if (typeof toolResponse !== 'string') {
81
+ toolResponse = toolResponse == null ? '' : JSON.stringify(toolResponse);
82
+ }
83
+ const r = toolResponse;
84
+ if (!r || r.trim().length < AGENT_MIN_OUTPUT_LENGTH) {
79
85
  return {
80
86
  signal: 'agent_empty_output',
81
87
  severity: 'warning',
82
- message: `Agent returned minimal output (${toolResponse?.trim().length ?? 0} chars). Verify the result is usable.`,
88
+ message: `Agent returned minimal output (${r.trim().length} chars). Verify the result is usable.`,
83
89
  };
84
90
  }
85
91
  for (const p of AGENT_QUALITY_PATTERNS) {
86
- if (p.pattern.test(toolResponse)) {
92
+ if (p.pattern.test(r)) {
87
93
  return { signal: p.signal, severity: p.severity, message: p.message };
88
94
  }
89
95
  }
@@ -114,7 +120,10 @@ async function main() {
114
120
  }
115
121
  const toolName = data.tool_name ?? data.toolName ?? '';
116
122
  const toolInput = data.tool_input ?? data.toolInput ?? {};
117
- const toolResponse = data.tool_response ?? data.toolOutput ?? '';
123
+ // tool_response string / object / array 모두 가능 (sub-agent 결과는 object 가 흔함).
124
+ // 모든 downstream 이 string 가정이라 stringify 로 normalize. 회귀 박제: tests/hooks/post-tool-use.test.ts
125
+ const rawResponse = data.tool_response ?? data.toolOutput ?? '';
126
+ const toolResponse = typeof rawResponse === 'string' ? rawResponse : JSON.stringify(rawResponse);
118
127
  const sessionId = data.session_id ?? 'default';
119
128
  const modState = loadModifiedFiles(sessionId);
120
129
  modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
@@ -74,7 +74,7 @@ export function buildSessionBrief(sessionId) {
74
74
  }
75
75
  catch { /* fail-open */ }
76
76
  // solutionsInjected: read injection-cache-*.json files, collect solutions[].name
77
- let solutionsInjected = [];
77
+ const solutionsInjected = [];
78
78
  try {
79
79
  if (fs.existsSync(STATE_DIR)) {
80
80
  for (const f of fs.readdirSync(STATE_DIR)) {
@@ -19,7 +19,7 @@ import { sanitizeId } from './shared/sanitize-id.js';
19
19
  import { incrementEvidence } from '../engine/solution-writer.js';
20
20
  import { isReflectionCandidate } from './compound-reflection.js';
21
21
  import { isHookEnabled } from './hook-config.js';
22
- import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
22
+ import { approve, approveWithWarning, denyOrObserve, failOpenWithTracking } from './shared/hook-response.js';
23
23
  import { FORGEN_HOME, STATE_DIR } from '../core/paths.js';
24
24
  import { recordHookTiming } from './shared/hook-timing.js';
25
25
  import { maskQuotedContent } from './shared/command-parser.js';
@@ -305,7 +305,7 @@ async function main() {
305
305
  // for `forgen doctor` / log inspection. Mirrors `db-guard.ts:85-96`.
306
306
  const failCount = getAndIncrementFailCount();
307
307
  if (failCount >= FAIL_CLOSE_THRESHOLD) {
308
- console.log(deny(`[Forgen] PreToolUse: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
308
+ console.log(denyOrObserve('pre-tool-use', `[Forgen] PreToolUse: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
309
309
  }
310
310
  else {
311
311
  process.stderr.write(`[ch-hook] pre-tool-use stdin parse failed (${failCount}/${FAIL_CLOSE_THRESHOLD})\n`);
@@ -366,7 +366,7 @@ async function main() {
366
366
  const baseMsg = spec.block_message ?? `[${rule.rule_id}] policy violation: ${rule.policy.slice(0, 120)}`;
367
367
  // G8: override 힌트 — FORGEN_USER_CONFIRMED=1 으로 사용자 명시 승인 가능, 감사 로그 기록됨.
368
368
  const msgWithHint = `${baseMsg}\n\n(override: set FORGEN_USER_CONFIRMED=1 (bypass will be audited in violations.jsonl))`;
369
- console.log(deny(msgWithHint));
369
+ console.log(denyOrObserve('pre-tool-use', msgWithHint));
370
370
  return;
371
371
  }
372
372
  if (requiresFlag && confirmed) {
@@ -388,7 +388,7 @@ async function main() {
388
388
  // Bash 도구: 위험 명령어 감지 (빌트인 safety net)
389
389
  const check = checkDangerousCommand(toolName, toolInput);
390
390
  if (check.action === 'block') {
391
- console.log(deny(`[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
391
+ console.log(denyOrObserve('pre-tool-use', `[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
392
392
  return;
393
393
  }
394
394
  if (check.action === 'warn') {
@@ -10,7 +10,7 @@ import * as path from 'node:path';
10
10
  import { readStdinJSON } from './shared/read-stdin.js';
11
11
  import { atomicWriteJSON } from './shared/atomic-write.js';
12
12
  import { isHookEnabled } from './hook-config.js';
13
- import { approve, deny, failOpenWithTracking } from './shared/hook-response.js';
13
+ import { approve, denyOrObserve, failOpenWithTracking } from './shared/hook-response.js';
14
14
  import { STATE_DIR } from '../core/paths.js';
15
15
  const RATE_LIMIT_PATH = path.join(STATE_DIR, 'rate-limit.json');
16
16
  const DEFAULT_LIMIT = 30; // calls per minute
@@ -75,7 +75,7 @@ async function main() {
75
75
  saveRateLimitState(updatedState);
76
76
  }
77
77
  if (exceeded) {
78
- console.log(deny(`[Forgen] Rate limit exceeded (${count}/${DEFAULT_LIMIT}/min). Wait before retrying.`));
78
+ console.log(denyOrObserve('rate-limiter', `[Forgen] Rate limit exceeded (${count}/${DEFAULT_LIMIT}/min). Wait before retrying.`));
79
79
  return;
80
80
  }
81
81
  console.log(approve());
@@ -320,6 +320,17 @@ async function main() {
320
320
  }
321
321
  }
322
322
  catch { /* fail-open */ }
323
+ // US-M1 (RC6 가드): 직전 forge-loop findings 또는 진행 중 stories 자동 inject.
324
+ // 본 세션 자기증거 — head -80 truncation 으로 findings 누락 → 같은 가설 재발.
325
+ try {
326
+ const { readForgeLoopState, renderForgeLoopForSession } = await import('./shared/forge-loop-state.js');
327
+ const block = renderForgeLoopForSession(readForgeLoopState());
328
+ if (block)
329
+ recoveryMessages.push(block);
330
+ }
331
+ catch (e) {
332
+ log.debug('forge-loop findings inject 실패', e);
333
+ }
323
334
  const sessionId = sessionContext.sessionId;
324
335
  // 이전 세션 자동 compound (fire-and-forget)
325
336
  // /new로 세션 리셋 시 SessionStart가 다시 호출됨 — 이때 이전 transcript를 compound
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Blocking ALLOW-LIST — P3' (2026-04-27)
3
+ *
4
+ * 사용자 작업을 차단(block)할 권한을 가진 hook 의 명시적 화이트리스트.
5
+ * 목록 외 hook 의 부정적 판정은 "관찰 신호"(log only) 로만 처리되어야 한다.
6
+ *
7
+ * RC5 (retro-v040): 분산된 detector 가 각자 block 결정을 내리면서 false-positive
8
+ * 가 메인 로직 흐름까지 차단하는 회귀 패턴 발생. ALLOW-LIST 명시화로 차단 권한
9
+ * 의 source-of-truth 를 단일화.
10
+ *
11
+ * v0.4.2 정책:
12
+ * - 본 모듈은 ALLOW-LIST 정의 + 검증 helper. 기존 deny() 직접 호출 hook 들은
13
+ * v0.4.2 에서 denyOrObserve(name, reason) 로 마이그레이션 완료.
14
+ * - 신규 hook 추가 시 차단 권한이 필요하면 본 ALLOW-LIST 에 추가 + 본 파일의
15
+ * 사유 문서화 의무. 본 commit diff 가 review 필수 항목.
16
+ *
17
+ * 멤버 사유:
18
+ * - stop-guard: Stop hook — false-completion 메타 가드 (자가 검증 강제)
19
+ * - pre-tool-use: Bash dangerous-pattern + 수동 confirm 가드
20
+ * - secret-filter: Write/Edit 결과의 .env / API key 노출 차단
21
+ * - db-guard: Bash 의 destructive DB 명령 (DROP/TRUNCATE/DELETE) 차단
22
+ * - rate-limiter: 사용자 작업 빈도 임계 초과 시 cool-down 차단 (resource abuse 방어)
23
+ */
24
+ export declare const BLOCKING_ALLOWLIST: ReadonlySet<string>;
25
+ /** hook 이 block 결정을 출력할 권한이 있는지. */
26
+ export declare function canBlock(hookName: string): boolean;
27
+ /** ALLOW-LIST 에 추가하려는 hook 이 정책 문서화를 요구하는지 (lint helper). */
28
+ export declare function requiresPolicyDoc(hookName: string): boolean;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Blocking ALLOW-LIST — P3' (2026-04-27)
3
+ *
4
+ * 사용자 작업을 차단(block)할 권한을 가진 hook 의 명시적 화이트리스트.
5
+ * 목록 외 hook 의 부정적 판정은 "관찰 신호"(log only) 로만 처리되어야 한다.
6
+ *
7
+ * RC5 (retro-v040): 분산된 detector 가 각자 block 결정을 내리면서 false-positive
8
+ * 가 메인 로직 흐름까지 차단하는 회귀 패턴 발생. ALLOW-LIST 명시화로 차단 권한
9
+ * 의 source-of-truth 를 단일화.
10
+ *
11
+ * v0.4.2 정책:
12
+ * - 본 모듈은 ALLOW-LIST 정의 + 검증 helper. 기존 deny() 직접 호출 hook 들은
13
+ * v0.4.2 에서 denyOrObserve(name, reason) 로 마이그레이션 완료.
14
+ * - 신규 hook 추가 시 차단 권한이 필요하면 본 ALLOW-LIST 에 추가 + 본 파일의
15
+ * 사유 문서화 의무. 본 commit diff 가 review 필수 항목.
16
+ *
17
+ * 멤버 사유:
18
+ * - stop-guard: Stop hook — false-completion 메타 가드 (자가 검증 강제)
19
+ * - pre-tool-use: Bash dangerous-pattern + 수동 confirm 가드
20
+ * - secret-filter: Write/Edit 결과의 .env / API key 노출 차단
21
+ * - db-guard: Bash 의 destructive DB 명령 (DROP/TRUNCATE/DELETE) 차단
22
+ * - rate-limiter: 사용자 작업 빈도 임계 초과 시 cool-down 차단 (resource abuse 방어)
23
+ */
24
+ export const BLOCKING_ALLOWLIST = new Set([
25
+ 'stop-guard',
26
+ 'pre-tool-use',
27
+ 'secret-filter',
28
+ 'db-guard',
29
+ 'rate-limiter',
30
+ ]);
31
+ /** hook 이 block 결정을 출력할 권한이 있는지. */
32
+ export function canBlock(hookName) {
33
+ return BLOCKING_ALLOWLIST.has(hookName);
34
+ }
35
+ /** ALLOW-LIST 에 추가하려는 hook 이 정책 문서화를 요구하는지 (lint helper). */
36
+ export function requiresPolicyDoc(hookName) {
37
+ return !BLOCKING_ALLOWLIST.has(hookName);
38
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Forge Loop State — RC6 가드 (US-M1)
3
+ *
4
+ * 직전 forge-loop 의 findings 또는 진행 중 stories 를 ≤1KB 요약으로 렌더한다.
5
+ * SessionStart 와 UserPromptSubmit 두 hook 이 공유하는 단일 진입점.
6
+ *
7
+ * RC6 자기증거: 본 세션 R1 에서 head -80 으로 forge-loop.json 을 읽어 findings
8
+ * 8줄(line 92~99)이 잘렸음. 결과적으로 직전 결론을 컨텍스트에 못 가져 같은
9
+ * 가설을 재발. 이 모듈은 그 회귀를 시스템 레벨에서 차단한다.
10
+ */
11
+ interface ForgeLoopStory {
12
+ id: string;
13
+ title: string;
14
+ passes?: boolean;
15
+ }
16
+ export interface ForgeLoopState {
17
+ active?: boolean;
18
+ task?: string;
19
+ startedAt?: string;
20
+ completedAt?: string;
21
+ stories?: ForgeLoopStory[];
22
+ findings?: Record<string, string>;
23
+ }
24
+ export declare function readForgeLoopState(filePath?: string): ForgeLoopState | null;
25
+ /** SessionStart 용 — 완료된 forge-loop 의 findings 또는 진행 중 stories 요약. */
26
+ export declare function renderForgeLoopForSession(state: ForgeLoopState | null, now?: number): string | null;
27
+ /** UserPromptSubmit 용 — active=true 시에만 짧은 진행 상황 1~2줄. */
28
+ export declare function renderForgeLoopForPrompt(state: ForgeLoopState | null, now?: number): string | null;
29
+ /** 테스트 노출용 상수 — 회귀 시 임계값 변경 즉시 감지. */
30
+ export declare const FORGE_LOOP_LIMITS: {
31
+ readonly SOFT_STALE_MS: number;
32
+ readonly HARD_STALE_MS: number;
33
+ readonly MAX_INJECT_BYTES: 1024;
34
+ readonly MAX_PENDING: 5;
35
+ };
36
+ export {};
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Forge Loop State — RC6 가드 (US-M1)
3
+ *
4
+ * 직전 forge-loop 의 findings 또는 진행 중 stories 를 ≤1KB 요약으로 렌더한다.
5
+ * SessionStart 와 UserPromptSubmit 두 hook 이 공유하는 단일 진입점.
6
+ *
7
+ * RC6 자기증거: 본 세션 R1 에서 head -80 으로 forge-loop.json 을 읽어 findings
8
+ * 8줄(line 92~99)이 잘렸음. 결과적으로 직전 결론을 컨텍스트에 못 가져 같은
9
+ * 가설을 재발. 이 모듈은 그 회귀를 시스템 레벨에서 차단한다.
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import { STATE_DIR } from '../../core/paths.js';
14
+ const FORGE_LOOP_PATH = path.join(STATE_DIR, 'forge-loop.json');
15
+ const SOFT_STALE_MS = 24 * 60 * 60 * 1000;
16
+ const HARD_STALE_MS = 7 * 24 * 60 * 60 * 1000;
17
+ const MAX_INJECT_BYTES = 1024;
18
+ const MAX_TASK_CHARS = 240;
19
+ const MAX_FINDING_CHARS = 240;
20
+ const MAX_PENDING = 5;
21
+ export function readForgeLoopState(filePath = FORGE_LOOP_PATH) {
22
+ try {
23
+ if (!fs.existsSync(filePath))
24
+ return null;
25
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
26
+ if (typeof raw !== 'object' || raw === null)
27
+ return null;
28
+ return raw;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function ageMs(state, now = Date.now()) {
35
+ const ts = state.completedAt ?? state.startedAt;
36
+ if (!ts)
37
+ return Number.POSITIVE_INFINITY;
38
+ const t = new Date(ts).getTime();
39
+ if (Number.isNaN(t))
40
+ return Number.POSITIVE_INFINITY;
41
+ return now - t;
42
+ }
43
+ function clipBlock(block) {
44
+ if (block.length <= MAX_INJECT_BYTES)
45
+ return block;
46
+ return `${block.slice(0, MAX_INJECT_BYTES - 3)}...`;
47
+ }
48
+ function escXml(s) {
49
+ return s.replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c] ?? c);
50
+ }
51
+ /** SessionStart 용 — 완료된 forge-loop 의 findings 또는 진행 중 stories 요약. */
52
+ export function renderForgeLoopForSession(state, now = Date.now()) {
53
+ if (!state)
54
+ return null;
55
+ const age = ageMs(state, now);
56
+ if (age > HARD_STALE_MS)
57
+ return null;
58
+ const stale = age > SOFT_STALE_MS;
59
+ const lines = [];
60
+ const task = String(state.task ?? '').trim();
61
+ if (task)
62
+ lines.push(`Task: ${escXml(task.slice(0, MAX_TASK_CHARS))}`);
63
+ if (state.active && Array.isArray(state.stories)) {
64
+ const total = state.stories.length;
65
+ const done = state.stories.filter(s => s?.passes).length;
66
+ lines.push(`Status: in-progress ${done}/${total}${stale ? ' (stale)' : ''}`);
67
+ const pending = state.stories
68
+ .filter(s => !s?.passes)
69
+ .slice(0, MAX_PENDING)
70
+ .map(s => `- ${escXml(String(s.id))}: ${escXml(String(s.title))}`);
71
+ if (pending.length) {
72
+ lines.push('Pending:');
73
+ lines.push(...pending);
74
+ }
75
+ }
76
+ else if (state.findings && typeof state.findings === 'object') {
77
+ lines.push(`Status: completed${stale ? ' (stale)' : ''}`);
78
+ for (const [id, val] of Object.entries(state.findings)) {
79
+ const text = String(val ?? '').slice(0, MAX_FINDING_CHARS);
80
+ if (text)
81
+ lines.push(`- ${escXml(id)}: ${escXml(text)}`);
82
+ }
83
+ }
84
+ else {
85
+ return null;
86
+ }
87
+ if (lines.length === 0)
88
+ return null;
89
+ const tag = stale ? '<forge-loop-state stale="true">' : '<forge-loop-state>';
90
+ const body = lines.join('\n');
91
+ return clipBlock(`${tag}\n${body}\n</forge-loop-state>`);
92
+ }
93
+ /** UserPromptSubmit 용 — active=true 시에만 짧은 진행 상황 1~2줄. */
94
+ export function renderForgeLoopForPrompt(state, now = Date.now()) {
95
+ if (!state || !state.active || !Array.isArray(state.stories))
96
+ return null;
97
+ const age = ageMs(state, now);
98
+ if (age > HARD_STALE_MS)
99
+ return null;
100
+ const total = state.stories.length;
101
+ const done = state.stories.filter(s => s?.passes).length;
102
+ const next = state.stories.find(s => !s?.passes);
103
+ if (!next)
104
+ return null;
105
+ const stale = age > SOFT_STALE_MS;
106
+ const tag = stale ? '<forge-loop-active stale="true">' : '<forge-loop-active>';
107
+ const body = `Progress: ${done}/${total} | next: ${escXml(String(next.id))} ${escXml(String(next.title))}`;
108
+ return clipBlock(`${tag}\n${body}\n</forge-loop-active>`);
109
+ }
110
+ /** 테스트 노출용 상수 — 회귀 시 임계값 변경 즉시 감지. */
111
+ export const FORGE_LOOP_LIMITS = {
112
+ SOFT_STALE_MS,
113
+ HARD_STALE_MS,
114
+ MAX_INJECT_BYTES,
115
+ MAX_PENDING,
116
+ };
@@ -34,6 +34,24 @@ export declare function approveWithWarning(warning: string): string;
34
34
  export declare function deny(reason: string): string;
35
35
  /** 사용자 확인 요청 (PreToolUse 전용) */
36
36
  export declare function ask(reason: string): string;
37
+ /**
38
+ * P3' enforcement helper (2026-04-27)
39
+ *
40
+ * ALLOW-LIST 에 있는 hook 만 진짜 deny — 아닌 경우 approve + 관찰 신호로 강등.
41
+ *
42
+ * 사용:
43
+ * ```ts
44
+ * import { canBlock } from './blocking-allowlist.js';
45
+ * if (someCondition) {
46
+ * console.log(blockOrObserve(hookName, 'reason', logCallback));
47
+ * return;
48
+ * }
49
+ * ```
50
+ *
51
+ * 점진 마이그레이션 — 본 helper 를 새로 사용하는 hook 은 ALLOW-LIST 외라면
52
+ * 자동 관찰 모드로 작동. 기존 hook 들은 별도 PR 에서 마이그레이션.
53
+ */
54
+ export declare function denyOrObserve(hookName: string, reason: string, observer?: (msg: string) => void): string;
37
55
  /**
38
56
  * Stop hook only — block the agent from stopping and feed a self-check
39
57
  * question back to Claude so the current session resumes with new guidance.
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
  import { STATE_DIR } from '../../core/paths.js';
19
+ import { canBlock } from './blocking-allowlist.js';
19
20
  /** 통과 응답 (컨텍스트 없음, 모든 이벤트 공통) */
20
21
  export function approve() {
21
22
  return JSON.stringify({ continue: true });
@@ -65,6 +66,36 @@ export function ask(reason) {
65
66
  },
66
67
  });
67
68
  }
69
+ /**
70
+ * P3' enforcement helper (2026-04-27)
71
+ *
72
+ * ALLOW-LIST 에 있는 hook 만 진짜 deny — 아닌 경우 approve + 관찰 신호로 강등.
73
+ *
74
+ * 사용:
75
+ * ```ts
76
+ * import { canBlock } from './blocking-allowlist.js';
77
+ * if (someCondition) {
78
+ * console.log(blockOrObserve(hookName, 'reason', logCallback));
79
+ * return;
80
+ * }
81
+ * ```
82
+ *
83
+ * 점진 마이그레이션 — 본 helper 를 새로 사용하는 hook 은 ALLOW-LIST 외라면
84
+ * 자동 관찰 모드로 작동. 기존 hook 들은 별도 PR 에서 마이그레이션.
85
+ */
86
+ export function denyOrObserve(hookName, reason, observer) {
87
+ if (canBlock(hookName)) {
88
+ return deny(reason);
89
+ }
90
+ // ALLOW-LIST 외 — log only, approve
91
+ if (observer) {
92
+ try {
93
+ observer(`[${hookName}] would-deny (observe-only): ${reason}`);
94
+ }
95
+ catch { /* fail-open */ }
96
+ }
97
+ return approve();
98
+ }
68
99
  /**
69
100
  * Stop hook only — block the agent from stopping and feed a self-check
70
101
  * question back to Claude so the current session resumes with new guidance.
@@ -181,7 +181,7 @@ function collectSkills() {
181
181
  const skills = [];
182
182
  const seen = new Map(); // name → source dir
183
183
  // 패키지 내장 스킬 경로 (dist/../skills/)
184
- const pkgSkillsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'commands');
184
+ const pkgSkillsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'claude', 'commands');
185
185
  // 프로젝트 .forgen > 프로젝트 .compound > 개인 > 글로벌 > 패키지 내장
186
186
  const dirs = [
187
187
  path.join(process.cwd(), '.forgen', 'skills'),