@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.
- package/CLAUDE.md +25 -16
- package/README.en.md +16 -14
- package/README.md +13 -11
- package/dist/cli/postinstall/constants.d.ts.map +1 -1
- package/dist/cli/postinstall/constants.js +1 -0
- package/dist/cli/postinstall/constants.js.map +1 -1
- package/dist/cli/postinstall/fs-utils.d.ts +23 -0
- package/dist/cli/postinstall/fs-utils.d.ts.map +1 -1
- package/dist/cli/postinstall/fs-utils.js +71 -0
- package/dist/cli/postinstall/fs-utils.js.map +1 -1
- package/dist/cli/postinstall/fs-utils.test.js +69 -1
- package/dist/cli/postinstall/fs-utils.test.js.map +1 -1
- package/dist/cli/postinstall/main.d.ts.map +1 -1
- package/dist/cli/postinstall/main.js +12 -2
- package/dist/cli/postinstall/main.js.map +1 -1
- package/dist/cli/setup/CodexHooks.test.js +27 -0
- package/dist/cli/setup/CodexHooks.test.js.map +1 -1
- package/dist/cli/setup/ProjectSetup.d.ts.map +1 -1
- package/dist/cli/setup/ProjectSetup.js +6 -5
- package/dist/cli/setup/ProjectSetup.js.map +1 -1
- package/dist/infra/lib/DecisionTracer.d.ts +4 -0
- package/dist/infra/lib/DecisionTracer.d.ts.map +1 -1
- package/dist/infra/lib/DecisionTracer.js +4 -0
- package/dist/infra/lib/DecisionTracer.js.map +1 -1
- package/dist/infra/lib/LoopBreaker.d.ts +4 -0
- package/dist/infra/lib/LoopBreaker.d.ts.map +1 -1
- package/dist/infra/lib/LoopBreaker.js +4 -0
- package/dist/infra/lib/LoopBreaker.js.map +1 -1
- package/dist/infra/lib/ReviewRace.d.ts +4 -0
- package/dist/infra/lib/ReviewRace.d.ts.map +1 -1
- package/dist/infra/lib/ReviewRace.js +4 -0
- package/dist/infra/lib/ReviewRace.js.map +1 -1
- package/dist/infra/lib/SkillQualityGate.d.ts +4 -0
- package/dist/infra/lib/SkillQualityGate.d.ts.map +1 -1
- package/dist/infra/lib/SkillQualityGate.js +4 -0
- package/dist/infra/lib/SkillQualityGate.js.map +1 -1
- package/dist/infra/lib/UltraQA.d.ts +4 -0
- package/dist/infra/lib/UltraQA.d.ts.map +1 -1
- package/dist/infra/lib/UltraQA.js +4 -0
- package/dist/infra/lib/UltraQA.js.map +1 -1
- package/dist/infra/lib/VerificationLoop.d.ts +4 -0
- package/dist/infra/lib/VerificationLoop.d.ts.map +1 -1
- package/dist/infra/lib/VerificationLoop.js +4 -0
- package/dist/infra/lib/VerificationLoop.js.map +1 -1
- package/dist/infra/orchestrator/index.d.ts.map +1 -1
- package/dist/infra/orchestrator/index.js +1 -3
- package/dist/infra/orchestrator/index.js.map +1 -1
- package/dist/infra/orchestrator/parallelResearch.d.ts.map +1 -1
- package/dist/infra/orchestrator/parallelResearch.js +1 -4
- package/dist/infra/orchestrator/parallelResearch.js.map +1 -1
- package/dist/tools/convention/validateCodeQuality.d.ts.map +1 -1
- package/dist/tools/convention/validateCodeQuality.js +5 -4
- package/dist/tools/convention/validateCodeQuality.js.map +1 -1
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/loop/index.d.ts +6 -0
- package/dist/tools/loop/index.d.ts.map +1 -0
- package/dist/tools/loop/index.js +5 -0
- package/dist/tools/loop/index.js.map +1 -0
- package/dist/tools/loop/validateLoopDefinition.d.ts +38 -0
- package/dist/tools/loop/validateLoopDefinition.d.ts.map +1 -0
- package/dist/tools/loop/validateLoopDefinition.js +224 -0
- package/dist/tools/loop/validateLoopDefinition.js.map +1 -0
- package/dist/tools/loop/validateLoopDefinition.test.d.ts +14 -0
- package/dist/tools/loop/validateLoopDefinition.test.d.ts.map +1 -0
- package/dist/tools/loop/validateLoopDefinition.test.js +229 -0
- package/dist/tools/loop/validateLoopDefinition.test.js.map +1 -0
- package/dist/tools/spec/traceabilityMatrix.d.ts +2 -0
- package/dist/tools/spec/traceabilityMatrix.d.ts.map +1 -1
- package/dist/tools/spec/traceabilityMatrix.js +50 -1
- package/dist/tools/spec/traceabilityMatrix.js.map +1 -1
- package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts +10 -0
- package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts.map +1 -0
- package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js +89 -0
- package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js.map +1 -0
- package/dist/tools/spec/traceabilityMatrix.test.js +19 -0
- package/dist/tools/spec/traceabilityMatrix.test.js.map +1 -1
- package/hooks/hooks.json +1 -0
- package/hooks/scripts/__tests__/.vibe/command-log.txt +60 -0
- package/hooks/scripts/__tests__/.vibe/memories/memories.db +0 -0
- package/hooks/scripts/__tests__/.vibe/memories/memories.db-shm +0 -0
- package/hooks/scripts/__tests__/.vibe/memories/memories.db-wal +0 -0
- package/hooks/scripts/__tests__/auto-test-debounce.test.js +145 -0
- package/hooks/scripts/__tests__/code-check-detectors.test.js +155 -0
- package/hooks/scripts/__tests__/dispatcher-inprocess.test.js +99 -0
- package/hooks/scripts/__tests__/keyword-detector.test.js +26 -18
- package/hooks/scripts/__tests__/loop-ledger.test.js +321 -0
- package/hooks/scripts/__tests__/post-edit-dispatcher.test.js +139 -0
- package/hooks/scripts/__tests__/pre-tool-guard.test.js +115 -1
- package/hooks/scripts/__tests__/run-ledger-verify-required.test.js +146 -0
- package/hooks/scripts/__tests__/run-ledger.test.js +330 -0
- package/hooks/scripts/__tests__/scope-from-spec.test.js +215 -0
- package/hooks/scripts/__tests__/sentinel-guard.test.js +79 -24
- package/hooks/scripts/__tests__/step-counter.test.js +95 -15
- package/hooks/scripts/__tests__/utils-npm-root.test.js +98 -0
- package/hooks/scripts/auto-commit.js +27 -1
- package/hooks/scripts/auto-format.js +85 -20
- package/hooks/scripts/auto-test.js +187 -37
- package/hooks/scripts/code-check.js +286 -90
- package/hooks/scripts/codex-hook-adapter.js +12 -1
- package/hooks/scripts/command-log.js +26 -16
- package/hooks/scripts/keyword-detector.js +22 -22
- package/hooks/scripts/lib/dispatcher.js +38 -0
- package/hooks/scripts/lib/hook-context.js +130 -0
- package/hooks/scripts/lib/loop-ledger.js +118 -0
- package/hooks/scripts/lib/pr-gate-runner.js +62 -0
- package/hooks/scripts/lib/run-ledger.js +169 -0
- package/hooks/scripts/lib/scope-from-spec.js +40 -7
- package/hooks/scripts/loop-ledger.js +56 -0
- package/hooks/scripts/post-edit-dispatcher.js +93 -20
- package/hooks/scripts/post-edit.js +40 -19
- package/hooks/scripts/pr-test-gate.js +8 -37
- package/hooks/scripts/pre-tool-dispatcher.js +18 -16
- package/hooks/scripts/pre-tool-guard.js +55 -52
- package/hooks/scripts/prompt-dispatcher.js +10 -0
- package/hooks/scripts/scope-guard.js +40 -39
- package/hooks/scripts/sentinel-guard.js +41 -41
- package/hooks/scripts/session-start.js +13 -1
- package/hooks/scripts/step-counter.js +100 -7
- package/hooks/scripts/stop-dispatcher.js +26 -0
- package/hooks/scripts/utils.js +63 -21
- package/hooks/scripts/verify-ledger.js +22 -0
- package/package.json +2 -2
- package/skills/spec/references/templates.md +11 -6
- package/skills/vibe/SKILL.md +40 -23
- package/skills/vibe.loop/SKILL.md +116 -0
- package/skills/vibe.run/SKILL.md +153 -1686
- package/skills/vibe.run/references/brand-assets.md +59 -0
- package/skills/vibe.run/references/parallel-agents.md +326 -0
- package/skills/vibe.run/references/race-review.md +272 -0
- package/skills/vibe.run/references/ralph-loop.md +173 -0
- package/skills/vibe.run/references/ultrawork-mode.md +151 -0
- package/skills/vibe.trace/SKILL.md +25 -38
- package/skills/vibe.verify/SKILL.md +15 -0
- package/vibe/rules/loop-contract.md +54 -0
- package/vibe/templates/loop-template.md +69 -0
- 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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* in-process 평탄화 (2026-06): 자식 node spawn 없이 import 실행.
|
|
6
|
+
* 실작업(prettier/vitest 등)은 각 step이 자체 timeout을 가진 비동기 자식으로
|
|
7
|
+
* 실행하므로 step 간 병렬성(Promise.all)은 spawn 시절과 동일하게 유지된다.
|
|
8
8
|
*
|
|
9
|
-
* 실행
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 {
|
|
10
|
-
import path from 'path';
|
|
8
|
+
import { runPrTestGate } from './lib/pr-gate-runner.js';
|
|
11
9
|
|
|
12
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
process.exit(0);
|
|
38
|
-
}
|
|
12
|
+
if (!testCmd) {
|
|
13
|
+
// 테스트 커맨드 없음 → PR 허용
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
39
16
|
|
|
40
|
-
|
|
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
|
-
}
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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 {
|
|
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',
|
|
23
|
-
{ name: 'pre-tool-guard',
|
|
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에만 의미 있음
|
|
28
|
+
// scope-guard는 Edit/Write에만 의미 있음
|
|
27
29
|
if (toolName === 'Edit' || toolName === 'Write') {
|
|
28
|
-
steps.push({ name: 'scope-guard',
|
|
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',
|
|
35
|
+
steps.push({ name: 'command-log', run: commandLog });
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
// 하네스에 노이즈를 주지 않도록 디스패처 자체의 예외는 모두 흡수.
|
|
37
|
-
// exit 2 (deny 전파)는
|
|
39
|
+
// exit 2 (deny 전파)는 dispatchInProcess 내부에서 process.exit(2)로 처리되므로
|
|
38
40
|
// 여기까지 오면 "deny 아님" → 항상 exit 0.
|
|
39
41
|
try {
|
|
40
|
-
await
|
|
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
|
-
*
|
|
202
|
-
*
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
223
|
+
logHookDecision('pre-tool-guard', toolName, 'warn', `schema: ${schemaResult.errors.join('; ')}`);
|
|
224
|
+
// 스키마 오류는 경고만 (차단하지 않음 — 레거시 호환)
|
|
225
|
+
}
|
|
216
226
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
249
|
-
}
|
|
244
|
+
if (output) {
|
|
245
|
+
console.error(output);
|
|
246
|
+
}
|
|
250
247
|
|
|
251
|
-
// Hook trace logging
|
|
252
|
-
if (!validation.allowed) {
|
|
253
|
-
|
|
254
|
-
} else if (validation.warnings.length > 0) {
|
|
255
|
-
|
|
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
|
-
//
|
|
259
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
if (toolName !== 'Edit' && toolName !== 'Write') process.exit(0);
|
|
111
|
+
const filePath = extractFilePath(ctx.toolInput);
|
|
112
|
+
if (!filePath) return 0;
|
|
114
113
|
|
|
115
|
-
const
|
|
116
|
-
const filePath = extractFilePath(rawInput);
|
|
117
|
-
if (!filePath) process.exit(0);
|
|
114
|
+
const rel = toRelative(filePath);
|
|
118
115
|
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
126
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
lines.push(
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
140
|
+
return blocking ? 2 : 0;
|
|
141
|
+
}
|
|
144
142
|
|
|
145
|
-
|
|
143
|
+
// standalone CLI 모드: stdin JSON 우선, argv 폴백
|
|
144
|
+
if (isDirectRun(import.meta.url)) {
|
|
145
|
+
process.exit(await run(buildCliCtx()));
|
|
146
|
+
}
|