@su-record/vibe 2.12.5 → 2.14.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 (139) hide show
  1. package/CLAUDE.md +25 -16
  2. package/README.en.md +16 -14
  3. package/README.md +13 -11
  4. package/dist/cli/postinstall/constants.d.ts.map +1 -1
  5. package/dist/cli/postinstall/constants.js +1 -0
  6. package/dist/cli/postinstall/constants.js.map +1 -1
  7. package/dist/cli/postinstall/fs-utils.d.ts +23 -0
  8. package/dist/cli/postinstall/fs-utils.d.ts.map +1 -1
  9. package/dist/cli/postinstall/fs-utils.js +71 -0
  10. package/dist/cli/postinstall/fs-utils.js.map +1 -1
  11. package/dist/cli/postinstall/fs-utils.test.js +69 -1
  12. package/dist/cli/postinstall/fs-utils.test.js.map +1 -1
  13. package/dist/cli/postinstall/main.d.ts.map +1 -1
  14. package/dist/cli/postinstall/main.js +12 -2
  15. package/dist/cli/postinstall/main.js.map +1 -1
  16. package/dist/cli/setup/CodexHooks.test.js +27 -0
  17. package/dist/cli/setup/CodexHooks.test.js.map +1 -1
  18. package/dist/cli/setup/ProjectSetup.d.ts.map +1 -1
  19. package/dist/cli/setup/ProjectSetup.js +6 -5
  20. package/dist/cli/setup/ProjectSetup.js.map +1 -1
  21. package/dist/infra/lib/DecisionTracer.d.ts +4 -0
  22. package/dist/infra/lib/DecisionTracer.d.ts.map +1 -1
  23. package/dist/infra/lib/DecisionTracer.js +4 -0
  24. package/dist/infra/lib/DecisionTracer.js.map +1 -1
  25. package/dist/infra/lib/LoopBreaker.d.ts +4 -0
  26. package/dist/infra/lib/LoopBreaker.d.ts.map +1 -1
  27. package/dist/infra/lib/LoopBreaker.js +4 -0
  28. package/dist/infra/lib/LoopBreaker.js.map +1 -1
  29. package/dist/infra/lib/ReviewRace.d.ts +4 -0
  30. package/dist/infra/lib/ReviewRace.d.ts.map +1 -1
  31. package/dist/infra/lib/ReviewRace.js +4 -0
  32. package/dist/infra/lib/ReviewRace.js.map +1 -1
  33. package/dist/infra/lib/SkillQualityGate.d.ts +4 -0
  34. package/dist/infra/lib/SkillQualityGate.d.ts.map +1 -1
  35. package/dist/infra/lib/SkillQualityGate.js +4 -0
  36. package/dist/infra/lib/SkillQualityGate.js.map +1 -1
  37. package/dist/infra/lib/UltraQA.d.ts +4 -0
  38. package/dist/infra/lib/UltraQA.d.ts.map +1 -1
  39. package/dist/infra/lib/UltraQA.js +4 -0
  40. package/dist/infra/lib/UltraQA.js.map +1 -1
  41. package/dist/infra/lib/VerificationLoop.d.ts +4 -0
  42. package/dist/infra/lib/VerificationLoop.d.ts.map +1 -1
  43. package/dist/infra/lib/VerificationLoop.js +4 -0
  44. package/dist/infra/lib/VerificationLoop.js.map +1 -1
  45. package/dist/infra/orchestrator/index.d.ts.map +1 -1
  46. package/dist/infra/orchestrator/index.js +1 -3
  47. package/dist/infra/orchestrator/index.js.map +1 -1
  48. package/dist/infra/orchestrator/parallelResearch.d.ts.map +1 -1
  49. package/dist/infra/orchestrator/parallelResearch.js +1 -4
  50. package/dist/infra/orchestrator/parallelResearch.js.map +1 -1
  51. package/dist/tools/convention/validateCodeQuality.d.ts.map +1 -1
  52. package/dist/tools/convention/validateCodeQuality.js +5 -4
  53. package/dist/tools/convention/validateCodeQuality.js.map +1 -1
  54. package/dist/tools/index.d.ts +2 -0
  55. package/dist/tools/index.d.ts.map +1 -1
  56. package/dist/tools/index.js +2 -0
  57. package/dist/tools/index.js.map +1 -1
  58. package/dist/tools/loop/index.d.ts +6 -0
  59. package/dist/tools/loop/index.d.ts.map +1 -0
  60. package/dist/tools/loop/index.js +5 -0
  61. package/dist/tools/loop/index.js.map +1 -0
  62. package/dist/tools/loop/validateLoopDefinition.d.ts +38 -0
  63. package/dist/tools/loop/validateLoopDefinition.d.ts.map +1 -0
  64. package/dist/tools/loop/validateLoopDefinition.js +224 -0
  65. package/dist/tools/loop/validateLoopDefinition.js.map +1 -0
  66. package/dist/tools/loop/validateLoopDefinition.test.d.ts +14 -0
  67. package/dist/tools/loop/validateLoopDefinition.test.d.ts.map +1 -0
  68. package/dist/tools/loop/validateLoopDefinition.test.js +229 -0
  69. package/dist/tools/loop/validateLoopDefinition.test.js.map +1 -0
  70. package/dist/tools/spec/traceabilityMatrix.d.ts +2 -0
  71. package/dist/tools/spec/traceabilityMatrix.d.ts.map +1 -1
  72. package/dist/tools/spec/traceabilityMatrix.js +50 -1
  73. package/dist/tools/spec/traceabilityMatrix.js.map +1 -1
  74. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts +10 -0
  75. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts.map +1 -0
  76. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js +89 -0
  77. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js.map +1 -0
  78. package/dist/tools/spec/traceabilityMatrix.test.js +19 -0
  79. package/dist/tools/spec/traceabilityMatrix.test.js.map +1 -1
  80. package/hooks/hooks.json +1 -0
  81. package/hooks/scripts/__tests__/.vibe/command-log.txt +60 -0
  82. package/hooks/scripts/__tests__/.vibe/memories/memories.db +0 -0
  83. package/hooks/scripts/__tests__/.vibe/memories/memories.db-shm +0 -0
  84. package/hooks/scripts/__tests__/.vibe/memories/memories.db-wal +0 -0
  85. package/hooks/scripts/__tests__/auto-test-debounce.test.js +145 -0
  86. package/hooks/scripts/__tests__/code-check-detectors.test.js +155 -0
  87. package/hooks/scripts/__tests__/dispatcher-inprocess.test.js +99 -0
  88. package/hooks/scripts/__tests__/keyword-detector.test.js +26 -18
  89. package/hooks/scripts/__tests__/loop-ledger.test.js +321 -0
  90. package/hooks/scripts/__tests__/post-edit-dispatcher.test.js +139 -0
  91. package/hooks/scripts/__tests__/pre-tool-guard.test.js +115 -1
  92. package/hooks/scripts/__tests__/run-ledger-verify-required.test.js +146 -0
  93. package/hooks/scripts/__tests__/run-ledger.test.js +330 -0
  94. package/hooks/scripts/__tests__/scope-from-spec.test.js +215 -0
  95. package/hooks/scripts/__tests__/sentinel-guard.test.js +79 -24
  96. package/hooks/scripts/__tests__/step-counter.test.js +95 -15
  97. package/hooks/scripts/__tests__/utils-npm-root.test.js +98 -0
  98. package/hooks/scripts/auto-commit.js +27 -1
  99. package/hooks/scripts/auto-format.js +85 -20
  100. package/hooks/scripts/auto-test.js +187 -37
  101. package/hooks/scripts/code-check.js +286 -90
  102. package/hooks/scripts/codex-hook-adapter.js +12 -1
  103. package/hooks/scripts/command-log.js +26 -16
  104. package/hooks/scripts/keyword-detector.js +22 -22
  105. package/hooks/scripts/lib/dispatcher.js +38 -0
  106. package/hooks/scripts/lib/hook-context.js +130 -0
  107. package/hooks/scripts/lib/loop-ledger.js +118 -0
  108. package/hooks/scripts/lib/pr-gate-runner.js +62 -0
  109. package/hooks/scripts/lib/run-ledger.js +169 -0
  110. package/hooks/scripts/lib/scope-from-spec.js +40 -7
  111. package/hooks/scripts/loop-ledger.js +56 -0
  112. package/hooks/scripts/post-edit-dispatcher.js +93 -20
  113. package/hooks/scripts/post-edit.js +40 -19
  114. package/hooks/scripts/pr-test-gate.js +8 -37
  115. package/hooks/scripts/pre-tool-dispatcher.js +18 -16
  116. package/hooks/scripts/pre-tool-guard.js +55 -52
  117. package/hooks/scripts/prompt-dispatcher.js +10 -0
  118. package/hooks/scripts/scope-guard.js +40 -39
  119. package/hooks/scripts/sentinel-guard.js +41 -41
  120. package/hooks/scripts/session-start.js +13 -1
  121. package/hooks/scripts/step-counter.js +100 -7
  122. package/hooks/scripts/stop-dispatcher.js +26 -0
  123. package/hooks/scripts/utils.js +63 -21
  124. package/hooks/scripts/verify-ledger.js +22 -0
  125. package/package.json +2 -2
  126. package/skills/spec/references/templates.md +11 -6
  127. package/skills/vibe/SKILL.md +40 -23
  128. package/skills/vibe.loop/SKILL.md +116 -0
  129. package/skills/vibe.run/SKILL.md +153 -1686
  130. package/skills/vibe.run/references/brand-assets.md +59 -0
  131. package/skills/vibe.run/references/parallel-agents.md +326 -0
  132. package/skills/vibe.run/references/race-review.md +272 -0
  133. package/skills/vibe.run/references/ralph-loop.md +173 -0
  134. package/skills/vibe.run/references/ultrawork-mode.md +151 -0
  135. package/skills/vibe.trace/SKILL.md +25 -38
  136. package/skills/vibe.verify/SKILL.md +15 -0
  137. package/vibe/rules/loop-contract.md +54 -0
  138. package/vibe/templates/loop-template.md +69 -0
  139. package/hooks/scripts/figma-guard.js +0 -219
@@ -1,27 +1,100 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PostToolUse dispatcher — Write/Edit 이후 순차 실행.
3
+ * PostToolUse dispatcher — Write/Edit 이후 후처리.
4
4
  *
5
- * 기존: PostToolUse.Write|Edit 배열에 3개 스크립트가 병렬 spawn (프로세스 피크 3배)
6
- * + PostToolUse.Edit 추가로 post-edit.js 1개
7
- * 현재: 단일 디스패처에서 순차 실행. config.hooks.{name}.enabled로 개별 토글.
5
+ * in-process 평탄화 (2026-06): 자식 node spawn 없이 import 실행.
6
+ * 실작업(prettier/vitest 등)은 step이 자체 timeout을 가진 비동기 자식으로
7
+ * 실행하므로 step 병렬성(Promise.all)은 spawn 시절과 동일하게 유지된다.
8
8
  *
9
- * 실행 순서:
10
- * 1. auto-format — 코드 스타일 정규화
11
- * 2. code-check — 린트/품질 검사
12
- * 3. auto-test — 관련 테스트 실행
13
- * 4. post-edit Edit 전용 후처리 (Write에서는 스크립트 내부에서 스킵)
9
+ * 실행 step (모두 비차단, config.hooks.{name}.enabled로 개별 토글):
10
+ * auto-format — 코드 스타일 정규화 (변경 시 finding 반환)
11
+ * code-check — 린트/품질 검사 + P1 이슈 verifyRequired 기록
12
+ * auto-test — 관련 테스트 실행 (debounce 지원)
13
+ * post-edit console.log 감지
14
14
  *
15
- * 실패 격리: 스크립트 실패해도 다음은 계속 진행.
15
+ * 출력 계약 (Claude Code PostToolUse):
16
+ * findings 있음 → stdout에 JSON hookSpecificOutput 1개 출력, exit 0
17
+ * findings 없음 → stdout 없음, exit 0
18
+ * 절대 exit 2 없음 — 차단은 downstream 게이트에서 (SPEC 설계 원칙)
19
+ *
20
+ * Codex 경로: codex-hook-adapter.js가 이 스크립트를 spawn하고
21
+ * combinedOutput(stdout+stderr)을 writeAdditionalContext로 래핑함.
22
+ * → stdout이 JSON hookSpecificOutput이면 어댑터가 이중 래핑함.
23
+ * → 어댑터에서 JSON hookSpecificOutput 형식 감지 후 bypass 처리.
24
+ *
25
+ * 실패 격리: step별 try/catch — 한 step이 throw해도 나머지는 계속 진행.
16
26
  */
17
- import { dispatch } from './lib/dispatcher.js';
18
-
19
- try {
20
- await dispatch([
21
- { name: 'auto-format', script: 'auto-format.js' },
22
- { name: 'code-check', script: 'code-check.js' },
23
- { name: 'auto-test', script: 'auto-test.js' },
24
- { name: 'post-edit', script: 'post-edit.js' },
25
- ]);
26
- } catch { /* noise suppression */ }
27
+ import { readStdinSync, buildCtx } from './lib/hook-context.js';
28
+ import { readProjectConfig } from './utils.js';
29
+ import fs from 'fs';
30
+ import path from 'path';
31
+
32
+ // ─── step 임포트 ─────────────────────────────────────────────────────
33
+ import { run as autoFormat } from './auto-format.js';
34
+ import { run as codeCheck } from './code-check.js';
35
+ import { run as autoTest } from './auto-test.js';
36
+ import { run as postEdit } from './post-edit.js';
37
+
38
+ // ─── 설정 로딩 ────────────────────────────────────────────────────────
39
+ function loadHookConfig() {
40
+ try {
41
+ return readProjectConfig()?.hooks || {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ function isEnabled(hookConfig, name) {
48
+ const entry = hookConfig[name];
49
+ if (entry && typeof entry === 'object' && entry.enabled === false) return false;
50
+ return true;
51
+ }
52
+
53
+ // ─── 메인 ─────────────────────────────────────────────────────────────
54
+ const { raw, parsed } = readStdinSync();
55
+ const ctx = buildCtx({ rawInput: raw, payload: parsed });
56
+ const hookConfig = loadHookConfig();
57
+
58
+ const steps = [
59
+ { name: 'auto-format', run: autoFormat },
60
+ { name: 'code-check', run: codeCheck },
61
+ { name: 'auto-test', run: autoTest },
62
+ { name: 'post-edit', run: postEdit },
63
+ ];
64
+
65
+ const enabledSteps = steps.filter(s => isEnabled(hookConfig, s.name));
66
+
67
+ // 모든 step을 병렬 실행해 findings 수집
68
+ const results = await Promise.all(
69
+ enabledSteps.map(async (step) => {
70
+ try {
71
+ const result = await step.run(ctx);
72
+ // findings 배열을 반환하는 새 구조 지원 + 구형 숫자 반환 폴백
73
+ if (result && typeof result === 'object' && Array.isArray(result.findings)) {
74
+ return result.findings;
75
+ }
76
+ return [];
77
+ } catch {
78
+ // 크래시 격리 — 해당 step만 건너뜀
79
+ return [];
80
+ }
81
+ })
82
+ );
83
+
84
+ const allFindings = results.flat().filter(f => typeof f === 'string' && f.trim().length > 0);
85
+
86
+ // findings가 있을 때만 stdout 출력
87
+ // Claude Code: JSON hookSpecificOutput → 모델이 additionalContext로 인식
88
+ // findings 없음: 조용히 종료
89
+ if (allFindings.length > 0) {
90
+ const summary = allFindings.join('\n');
91
+ const output = JSON.stringify({
92
+ hookSpecificOutput: {
93
+ hookEventName: 'PostToolUse',
94
+ additionalContext: summary,
95
+ },
96
+ });
97
+ process.stdout.write(output + '\n');
98
+ }
99
+
27
100
  process.exit(0);
@@ -3,33 +3,54 @@
3
3
  *
4
4
  * NOTE: tsc, prettier 제거 — 빌드/커밋 시점에 실행하므로 Edit마다 불필요
5
5
  * grep spawn 대신 fs.readFileSync + regex로 프로세스 오버헤드 제거
6
+ *
7
+ * findings를 console.log가 아닌 반환값으로 전달.
8
+ * (console.log 출력은 CC PostToolUse에서 transcript-only — 모델이 보지 못함)
6
9
  */
7
10
  import { existsSync, readFileSync } from 'fs';
8
11
  import path from 'path';
12
+ import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
9
13
 
10
- process.on('uncaughtException', () => {});
11
- process.on('unhandledRejection', () => {});
12
-
13
- const CONSOLE_LOG_RE = /console\.log/;
14
+ const CONSOLE_LOG_RE = /console\.log\(/;
14
15
  const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
15
16
 
16
- try {
17
- const input = JSON.parse(process.env.TOOL_INPUT || '{}');
18
- const filePath = input.file_path || input.path || '';
17
+ /**
18
+ * in-process 진입점 — console.log 감지만 수행.
19
+ * findings 배열 반환 (디스패처가 수집해 additionalContext에 주입).
20
+ * @param {{ toolInput: string }} ctx
21
+ * @returns {Promise<{ exitCode: number, findings: string[] }>}
22
+ */
23
+ export async function run(ctx) {
24
+ const findings = [];
25
+ try {
26
+ const input = JSON.parse(ctx.toolInput || '{}');
27
+ const filePath = input.file_path || input.path || '';
19
28
 
20
- if (filePath && CODE_EXT_RE.test(filePath)) {
21
- const resolved = path.resolve(filePath);
22
- if (existsSync(resolved)) {
23
- const lines = readFileSync(resolved, 'utf-8').split('\n');
24
- const hits = [];
25
- for (let i = 0; i < lines.length && hits.length < 3; i++) {
26
- if (CONSOLE_LOG_RE.test(lines[i])) hits.push(i + 1);
27
- }
28
- if (hits.length > 0) {
29
- console.log(`[POST-EDIT] ${path.basename(resolved)}: console.log at line ${hits.join(',')}`);
29
+ if (filePath && CODE_EXT_RE.test(filePath)) {
30
+ const resolved = path.resolve(filePath);
31
+ if (existsSync(resolved)) {
32
+ const lines = readFileSync(resolved, 'utf-8').split('\n');
33
+ const hits = [];
34
+ for (let i = 0; i < lines.length && hits.length < 3; i++) {
35
+ if (CONSOLE_LOG_RE.test(lines[i])) hits.push(i + 1);
36
+ }
37
+ if (hits.length > 0) {
38
+ findings.push(`[POST-EDIT] ${path.basename(resolved)}: console.log at line ${hits.join(',')}`);
39
+ }
30
40
  }
31
41
  }
42
+ } catch {
43
+ // 조용히 실패
32
44
  }
33
- } catch {
34
- // 조용히 실패
45
+ return { exitCode: 0, findings };
46
+ }
47
+
48
+ // standalone CLI 모드 — 전역 예외 흡수는 단독 프로세스일 때만 등록
49
+ // (in-process import 시 디스패처의 전역 핸들러를 오염시키지 않도록)
50
+ if (isDirectRun(import.meta.url)) {
51
+ process.on('uncaughtException', () => {});
52
+ process.on('unhandledRejection', () => {});
53
+ const { exitCode, findings } = await run(buildCliCtx());
54
+ if (findings.length > 0) process.stdout.write(findings.join('\n') + '\n');
55
+ process.exit(exitCode);
35
56
  }
@@ -4,49 +4,20 @@
4
4
  * mcp__github__create_pull_request 호출 시 테스트가 통과해야만 PR 생성 허용.
5
5
  * exit 2 = 차단, exit 0 = 통과
6
6
  */
7
- import { execSync } from 'child_process';
8
7
  import { PROJECT_DIR } from './utils.js';
9
- import { existsSync, readFileSync } from 'fs';
10
- import path from 'path';
8
+ import { runPrTestGate } from './lib/pr-gate-runner.js';
11
9
 
12
- function detectTestCommand() {
13
- const pkgPath = path.join(PROJECT_DIR, 'package.json');
14
- if (existsSync(pkgPath)) {
15
- try {
16
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
17
- if (pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1') {
18
- return 'npm test';
19
- }
20
- } catch { /* ignore */ }
21
- }
22
- // Python
23
- if (existsSync(path.join(PROJECT_DIR, 'pytest.ini')) || existsSync(path.join(PROJECT_DIR, 'pyproject.toml'))) {
24
- return 'python -m pytest --tb=short -q';
25
- }
26
- // Go
27
- if (existsSync(path.join(PROJECT_DIR, 'go.mod'))) {
28
- return 'go test ./...';
29
- }
30
- return null;
31
- }
10
+ const { passed, testCmd, output } = runPrTestGate(PROJECT_DIR);
32
11
 
33
- try {
34
- const testCmd = detectTestCommand();
35
- if (!testCmd) {
36
- // No test command detected — allow PR
37
- process.exit(0);
38
- }
12
+ if (!testCmd) {
13
+ // 테스트 커맨드 없음 → PR 허용
14
+ process.exit(0);
15
+ }
39
16
 
40
- console.log(`[PR-GATE] Running tests before PR creation: ${testCmd}`);
41
- execSync(testCmd, {
42
- cwd: PROJECT_DIR,
43
- stdio: ['ignore', 'pipe', 'pipe'],
44
- timeout: 120000,
45
- });
17
+ if (passed) {
46
18
  console.log('[PR-GATE] Tests passed — PR creation allowed');
47
19
  process.exit(0);
48
- } catch (err) {
49
- const output = err.stdout ? err.stdout.toString().split('\n').slice(-5).join('\n') : '';
20
+ } else {
50
21
  console.log(`[PR-GATE] Tests failed — PR creation blocked\n${output}`);
51
22
  process.exit(2);
52
23
  }
@@ -2,43 +2,45 @@
2
2
  /**
3
3
  * PreToolUse dispatcher — Bash/Edit/Write 공용.
4
4
  *
5
- * 기존: matcher별로 2~3개 스크립트가 병렬 spawn.
6
- * - Bash: sentinel-guard + pre-tool-guard + command-log
7
- * - Edit: sentinel-guard + pre-tool-guard
8
- * - Write: sentinel-guard + pre-tool-guard
9
- * 현재: 단일 디스패처가 tool name을 인자로 받아 순차 실행.
5
+ * in-process 평탄화 (2026-06): 가드를 자식 node로 spawn하지 않고 import해서
6
+ * 같은 프로세스에서 실행한다. 자식 VM 기동(~20ms × N)과 stdin 재읽기 제거.
7
+ * daemon/IPC는 금지 (CLAUDE.md Gotchas) — 디스패처 프로세스 자체는 유지.
10
8
  *
11
9
  * Deny 시맨틱 보존:
12
- * sentinel-guard / pre-tool-guard exit 2(deny)를 반환하면 dispatcher도
13
- * 즉시 exit 2로 상위에 전파 → Claude Code가 도구 실행을 차단.
10
+ * sentinel-guard / pre-tool-guard / scope-guard의 run(ctx)이 2를 반환하면
11
+ * dispatchInProcess가 process.exit(2)로 상위에 전파 → Claude Code가 도구 실행 차단.
14
12
  *
15
13
  * 사용법: node pre-tool-dispatcher.js <Bash|Edit|Write>
16
14
  */
17
- import { dispatch } from './lib/dispatcher.js';
15
+ import { dispatchInProcess } from './lib/dispatcher.js';
16
+ import { run as sentinelGuard } from './sentinel-guard.js';
17
+ import { run as preToolGuard } from './pre-tool-guard.js';
18
+ import { run as scopeGuard } from './scope-guard.js';
19
+ import { run as commandLog } from './command-log.js';
18
20
 
19
21
  const toolName = process.argv[2] || '';
20
22
 
21
23
  const steps = [
22
- { name: 'sentinel-guard', script: 'sentinel-guard.js', args: [toolName], denyOnExit2: true },
23
- { name: 'pre-tool-guard', script: 'pre-tool-guard.js', args: [toolName], denyOnExit2: true },
24
+ { name: 'sentinel-guard', run: sentinelGuard, denyOnExit2: true },
25
+ { name: 'pre-tool-guard', run: preToolGuard, denyOnExit2: true },
24
26
  ];
25
27
 
26
- // scope-guard는 Edit/Write에만 의미 있음 — 불필요한 spawn 회피
28
+ // scope-guard는 Edit/Write에만 의미 있음
27
29
  if (toolName === 'Edit' || toolName === 'Write') {
28
- steps.push({ name: 'scope-guard', script: 'scope-guard.js', args: [toolName], denyOnExit2: true });
30
+ steps.push({ name: 'scope-guard', run: scopeGuard, denyOnExit2: true });
29
31
  }
30
32
 
31
33
  // command-log은 Bash 전용
32
34
  if (toolName === 'Bash') {
33
- steps.push({ name: 'command-log', script: 'command-log.js' });
35
+ steps.push({ name: 'command-log', run: commandLog });
34
36
  }
35
37
 
36
38
  // 하네스에 노이즈를 주지 않도록 디스패처 자체의 예외는 모두 흡수.
37
- // exit 2 (deny 전파)는 dispatch() 내부에서 process.exit(2)로 처리되므로
39
+ // exit 2 (deny 전파)는 dispatchInProcess 내부에서 process.exit(2)로 처리되므로
38
40
  // 여기까지 오면 "deny 아님" → 항상 exit 0.
39
41
  try {
40
- await dispatch(steps);
42
+ await dispatchInProcess(steps, { argvToolName: toolName });
41
43
  } catch {
42
- // ignore — 하위 스크립트 크래시가 상위 훅 실패로 표시되지 않도록
44
+ // ignore — step 크래시가 상위 훅 실패로 표시되지 않도록
43
45
  }
44
46
  process.exit(0);
@@ -4,8 +4,6 @@
4
4
  * 위험한 도구 사용 전 검증 및 경고
5
5
  */
6
6
 
7
- import { VIBE_PATH, PROJECT_DIR } from './utils.js';
8
-
9
7
  // 위험한 명령어 패턴
10
8
  //
11
9
  // 각 엔트리의 `target`은 매칭 대상 필드:
@@ -197,63 +195,68 @@ function formatOutput(toolName, validation) {
197
195
  return lines.join('\n');
198
196
  }
199
197
 
198
+ import { logHookDecision, PROJECT_DIR } from './utils.js';
199
+ import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
200
+ import { runPrTestGate } from './lib/pr-gate-runner.js';
201
+
202
+ /** gh pr create 감지 정규식 (단어 경계 기준, 플래그 허용). */
203
+ const GH_PR_CREATE_RE = /\bgh\s+pr\s+create\b/;
204
+
200
205
  /**
201
- * stdin에서 JSON 페이로드 읽기 (Claude Code 하네스 호환)
202
- * stdin이 없거나 파싱 실패 argv/env 폴백
206
+ * in-process 진입점 디스패처가 ctx를 전달해 직접 호출.
207
+ * @param {{ toolName: string, toolInput: string, payload: object|null }} ctx
208
+ * @returns {Promise<number>} exit code (0 = allowed, 2 = denied)
203
209
  */
204
- function readStdinSync() {
205
- try {
206
- if (process.stdin.isTTY) return null;
207
- // fd 0을 직접 사용 (Windows는 '/dev/stdin'이 없음)
208
- const buf = Buffer.alloc(65536);
209
- const bytesRead = fs.readSync(0, buf, 0, buf.length, null);
210
- if (bytesRead > 0) {
211
- return JSON.parse(buf.toString('utf-8', 0, bytesRead));
210
+ export async function run(ctx) {
211
+ const toolName = ctx.toolName || 'Bash';
212
+ const toolInput = ctx.toolInput;
213
+
214
+ // 1단계: 입력 스키마 검증 (구조적 오류 탐지)
215
+ const schemaResult = validateInputSchema(toolName, ctx.payload?.tool_input || toolInput);
216
+ if (!schemaResult.valid) {
217
+ // stderr: Claude Code surfaces stderr in hook-error notifications; stdout is injected
218
+ // into the assistant's context and never shown to the user. Guard messages target the user.
219
+ console.error(`⚠️ INPUT VALIDATION: ${toolName}`);
220
+ for (const err of schemaResult.errors) {
221
+ console.error(` [SCHEMA] ${err}`);
212
222
  }
213
- } catch { /* 파싱 실패 시 폴백 */ }
214
- return null;
215
- }
223
+ logHookDecision('pre-tool-guard', toolName, 'warn', `schema: ${schemaResult.errors.join('; ')}`);
224
+ // 스키마 오류는 경고만 (차단하지 않음 — 레거시 호환)
225
+ }
216
226
 
217
- import fs from 'fs';
218
-
219
- // 메인 실행: stdin JSON 우선, argv 폴백
220
- const stdinPayload = readStdinSync();
221
- const toolName = stdinPayload?.tool_name || process.argv[2] || 'Bash';
222
- const toolInput = stdinPayload?.tool_input
223
- ? (typeof stdinPayload.tool_input === 'string'
224
- ? stdinPayload.tool_input
225
- : JSON.stringify(stdinPayload.tool_input))
226
- : (process.argv[3] || process.env.TOOL_INPUT || '');
227
-
228
- import { logHookDecision } from './utils.js';
229
-
230
- // 1단계: 입력 스키마 검증 (구조적 오류 탐지)
231
- const schemaResult = validateInputSchema(toolName, stdinPayload?.tool_input || toolInput);
232
- if (!schemaResult.valid) {
233
- // stderr: Claude Code surfaces stderr in hook-error notifications; stdout is injected
234
- // into the assistant's context and never shown to the user. Guard messages target the user.
235
- console.error(`⚠️ INPUT VALIDATION: ${toolName}`);
236
- for (const err of schemaResult.errors) {
237
- console.error(` [SCHEMA] ${err}`);
227
+ // 2단계: gh pr create Bash 명령 → PR 테스트 게이트
228
+ if (toolName === 'Bash' || toolName === 'bash') {
229
+ const command = extractTarget(toolInput, 'command') || toolInput;
230
+ if (GH_PR_CREATE_RE.test(command)) {
231
+ const gateResult = runPrTestGate(PROJECT_DIR);
232
+ if (!gateResult.passed) {
233
+ console.error(`[PR-GATE] Tests failed — gh pr create blocked\n${gateResult.output}`);
234
+ logHookDecision('pre-tool-guard', 'Bash', 'block', 'gh pr create: test gate failed');
235
+ return 2;
236
+ }
237
+ }
238
238
  }
239
- logHookDecision('pre-tool-guard', toolName, 'warn', `schema: ${schemaResult.errors.join('; ')}`);
240
- // 스키마 오류는 경고만 (차단하지 않음 — 레거시 호환)
241
- }
242
239
 
243
- // 2단계: 위험 패턴 검증 (보안 탐지)
244
- const validation = validateCommand(toolName, toolInput);
245
- const output = formatOutput(toolName, validation);
240
+ // 3단계: 위험 패턴 검증 (보안 탐지)
241
+ const validation = validateCommand(toolName, toolInput);
242
+ const output = formatOutput(toolName, validation);
246
243
 
247
- if (output) {
248
- console.error(output);
249
- }
244
+ if (output) {
245
+ console.error(output);
246
+ }
250
247
 
251
- // Hook trace logging
252
- if (!validation.allowed) {
253
- logHookDecision('pre-tool-guard', toolName, 'block', validation.warnings.join('; '));
254
- } else if (validation.warnings.length > 0) {
255
- logHookDecision('pre-tool-guard', toolName, 'warn', validation.warnings.join('; '));
248
+ // Hook trace logging
249
+ if (!validation.allowed) {
250
+ logHookDecision('pre-tool-guard', toolName, 'block', validation.warnings.join('; '));
251
+ } else if (validation.warnings.length > 0) {
252
+ logHookDecision('pre-tool-guard', toolName, 'warn', validation.warnings.join('; '));
253
+ }
254
+
255
+ // Exit code: 0 = allowed, 2 = denied (claw-code 규약)
256
+ return validation.allowed ? 0 : 2;
256
257
  }
257
258
 
258
- // Exit code: 0 = allowed, 2 = denied (claw-code 규약), 1 = 레거시 호환
259
- process.exit(validation.allowed ? 0 : 2);
259
+ // standalone CLI 모드: stdin JSON 우선, argv 폴백
260
+ if (isDirectRun(import.meta.url)) {
261
+ process.exit(await run(buildCliCtx()));
262
+ }
@@ -42,6 +42,16 @@ try {
42
42
 
43
43
  if (!prompt) process.exit(0);
44
44
 
45
+ // vibe.run 감지 — runStarted 기록 및 verifyPassed 리셋 (in-process, stdout 없음).
46
+ {
47
+ const { isVibeRunPrompt, extractRunFeature, recordRunStart } = await import('./lib/run-ledger.js');
48
+ if (isVibeRunPrompt(prompt)) {
49
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
50
+ const feature = extractRunFeature(prompt);
51
+ recordRunStart(projectDir, feature);
52
+ }
53
+ }
54
+
45
55
  // 레거시 SSOT 통합 — `/vibe.*` 진입 시 `.claude/vibe/` → `.vibe/` 자동 이동.
46
56
  // `vibe init`/`update` 와 동일한 `consolidateLegacyVibe` (dist/cli/setup/LegacyMigration.js) 를 직접 재사용. Idempotent.
47
57
  if (/^\s*\/vibe\b/i.test(prompt)) {
@@ -22,6 +22,7 @@
22
22
  import fs from 'fs';
23
23
  import path from 'path';
24
24
  import { PROJECT_DIR, logHookDecision, projectVibePath, projectVibeRoot } from './utils.js';
25
+ import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
25
26
 
26
27
  const SCOPE_PATH = projectVibePath(PROJECT_DIR, 'scope.json');
27
28
 
@@ -86,16 +87,6 @@ function toRelative(filePath) {
86
87
  return rel || path.basename(filePath).replace(/\\/g, '/');
87
88
  }
88
89
 
89
- function readStdinSync() {
90
- try {
91
- if (process.stdin.isTTY) return null;
92
- const buf = Buffer.alloc(65536);
93
- const bytesRead = fs.readSync(0, buf, 0, buf.length, null);
94
- if (bytesRead > 0) return JSON.parse(buf.toString('utf-8', 0, bytesRead));
95
- } catch { /* ignore */ }
96
- return null;
97
- }
98
-
99
90
  function extractFilePath(toolInput) {
100
91
  if (!toolInput) return '';
101
92
  if (typeof toolInput === 'string') {
@@ -105,41 +96,51 @@ function extractFilePath(toolInput) {
105
96
  return typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
106
97
  }
107
98
 
108
- const scope = readScope();
109
- if (!scope) process.exit(0); // no scope declared no-op
99
+ /**
100
+ * in-process 진입점 디스패처가 ctx를 전달해 직접 호출.
101
+ * @param {{ toolName: string, toolInput: string }} ctx
102
+ * @returns {Promise<number>} exit code (0 = allow/no-op, 2 = block)
103
+ */
104
+ export async function run(ctx) {
105
+ const scope = readScope();
106
+ if (!scope) return 0; // no scope declared → no-op
107
+
108
+ const toolName = ctx.toolName;
109
+ if (toolName !== 'Edit' && toolName !== 'Write') return 0;
110
110
 
111
- const stdinPayload = readStdinSync();
112
- const toolName = stdinPayload?.tool_name || process.argv[2] || '';
113
- if (toolName !== 'Edit' && toolName !== 'Write') process.exit(0);
111
+ const filePath = extractFilePath(ctx.toolInput);
112
+ if (!filePath) return 0;
114
113
 
115
- const rawInput = stdinPayload?.tool_input ?? process.argv[3] ?? process.env.TOOL_INPUT ?? '';
116
- const filePath = extractFilePath(rawInput);
117
- if (!filePath) process.exit(0);
114
+ const rel = toRelative(filePath);
118
115
 
119
- const rel = toRelative(filePath);
116
+ // 평가 순서: deny 우선 → allow 검증
117
+ const denied = scope.deny.length > 0 && matchesAny(rel, scope.deny);
118
+ const allowed = scope.allow.length === 0 || matchesAny(rel, scope.allow);
120
119
 
121
- // 평가 순서: deny 우선 → allow 검증
122
- const denied = scope.deny.length > 0 && matchesAny(rel, scope.deny);
123
- const allowed = scope.allow.length === 0 || matchesAny(rel, scope.allow);
120
+ const violated = denied || !allowed;
121
+ if (!violated) return 0;
124
122
 
125
- const violated = denied || !allowed;
126
- if (!violated) process.exit(0);
123
+ const lines = [];
124
+ lines.push(`🚧 SCOPE GUARD: ${toolName} — out of declared scope`);
125
+ lines.push(` file: ${rel}`);
126
+ if (denied) lines.push(` reason: matches deny pattern`);
127
+ else if (!allowed) lines.push(` reason: not in allow list`);
128
+ if (scope.reason) lines.push(` declared scope: ${scope.reason}`);
129
+ lines.push(` declared in: ${path.relative(PROJECT_DIR, SCOPE_PATH)} (mode=${scope.mode})`);
127
130
 
128
- const lines = [];
129
- lines.push(`🚧 SCOPE GUARD: ${toolName} — out of declared scope`);
130
- lines.push(` file: ${rel}`);
131
- if (denied) lines.push(` reason: matches deny pattern`);
132
- else if (!allowed) lines.push(` reason: not in allow list`);
133
- if (scope.reason) lines.push(` declared scope: ${scope.reason}`);
134
- lines.push(` declared in: ${path.relative(PROJECT_DIR, SCOPE_PATH)} (mode=${scope.mode})`);
131
+ const blocking = scope.mode === 'block';
132
+ if (blocking) {
133
+ lines.push('');
134
+ lines.push('🚫 BLOCKED. Edit scope.json or justify to the user before proceeding.');
135
+ }
135
136
 
136
- const blocking = scope.mode === 'block';
137
- if (blocking) {
138
- lines.push('');
139
- lines.push('🚫 BLOCKED. Edit scope.json or justify to the user before proceeding.');
140
- }
137
+ process.stderr.write(lines.join('\n') + '\n');
138
+ logHookDecision('scope-guard', toolName, blocking ? 'block' : 'warn', `${rel} ${denied ? '(deny)' : '(out-of-allow)'}`);
141
139
 
142
- console.log(lines.join('\n'));
143
- logHookDecision('scope-guard', toolName, blocking ? 'block' : 'warn', `${rel} ${denied ? '(deny)' : '(out-of-allow)'}`);
140
+ return blocking ? 2 : 0;
141
+ }
144
142
 
145
- process.exit(blocking ? 2 : 0);
143
+ // standalone CLI 모드: stdin JSON 우선, argv 폴백
144
+ if (isDirectRun(import.meta.url)) {
145
+ process.exit(await run(buildCliCtx()));
146
+ }