@su-record/vibe 2.12.5 → 2.13.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 (110) hide show
  1. package/CLAUDE.md +8 -2
  2. package/README.en.md +11 -11
  3. package/README.md +7 -7
  4. package/dist/cli/postinstall/fs-utils.d.ts +23 -0
  5. package/dist/cli/postinstall/fs-utils.d.ts.map +1 -1
  6. package/dist/cli/postinstall/fs-utils.js +71 -0
  7. package/dist/cli/postinstall/fs-utils.js.map +1 -1
  8. package/dist/cli/postinstall/fs-utils.test.js +69 -1
  9. package/dist/cli/postinstall/fs-utils.test.js.map +1 -1
  10. package/dist/cli/postinstall/main.d.ts.map +1 -1
  11. package/dist/cli/postinstall/main.js +12 -2
  12. package/dist/cli/postinstall/main.js.map +1 -1
  13. package/dist/cli/setup/CodexHooks.test.js +27 -0
  14. package/dist/cli/setup/CodexHooks.test.js.map +1 -1
  15. package/dist/cli/setup/ProjectSetup.js +2 -2
  16. package/dist/cli/setup/ProjectSetup.js.map +1 -1
  17. package/dist/infra/lib/DecisionTracer.d.ts +4 -0
  18. package/dist/infra/lib/DecisionTracer.d.ts.map +1 -1
  19. package/dist/infra/lib/DecisionTracer.js +4 -0
  20. package/dist/infra/lib/DecisionTracer.js.map +1 -1
  21. package/dist/infra/lib/LoopBreaker.d.ts +4 -0
  22. package/dist/infra/lib/LoopBreaker.d.ts.map +1 -1
  23. package/dist/infra/lib/LoopBreaker.js +4 -0
  24. package/dist/infra/lib/LoopBreaker.js.map +1 -1
  25. package/dist/infra/lib/ReviewRace.d.ts +4 -0
  26. package/dist/infra/lib/ReviewRace.d.ts.map +1 -1
  27. package/dist/infra/lib/ReviewRace.js +4 -0
  28. package/dist/infra/lib/ReviewRace.js.map +1 -1
  29. package/dist/infra/lib/SkillQualityGate.d.ts +4 -0
  30. package/dist/infra/lib/SkillQualityGate.d.ts.map +1 -1
  31. package/dist/infra/lib/SkillQualityGate.js +4 -0
  32. package/dist/infra/lib/SkillQualityGate.js.map +1 -1
  33. package/dist/infra/lib/UltraQA.d.ts +4 -0
  34. package/dist/infra/lib/UltraQA.d.ts.map +1 -1
  35. package/dist/infra/lib/UltraQA.js +4 -0
  36. package/dist/infra/lib/UltraQA.js.map +1 -1
  37. package/dist/infra/lib/VerificationLoop.d.ts +4 -0
  38. package/dist/infra/lib/VerificationLoop.d.ts.map +1 -1
  39. package/dist/infra/lib/VerificationLoop.js +4 -0
  40. package/dist/infra/lib/VerificationLoop.js.map +1 -1
  41. package/dist/infra/orchestrator/index.d.ts.map +1 -1
  42. package/dist/infra/orchestrator/index.js +1 -3
  43. package/dist/infra/orchestrator/index.js.map +1 -1
  44. package/dist/infra/orchestrator/parallelResearch.d.ts.map +1 -1
  45. package/dist/infra/orchestrator/parallelResearch.js +1 -4
  46. package/dist/infra/orchestrator/parallelResearch.js.map +1 -1
  47. package/dist/tools/convention/validateCodeQuality.d.ts.map +1 -1
  48. package/dist/tools/convention/validateCodeQuality.js +5 -4
  49. package/dist/tools/convention/validateCodeQuality.js.map +1 -1
  50. package/dist/tools/spec/traceabilityMatrix.d.ts +2 -0
  51. package/dist/tools/spec/traceabilityMatrix.d.ts.map +1 -1
  52. package/dist/tools/spec/traceabilityMatrix.js +50 -1
  53. package/dist/tools/spec/traceabilityMatrix.js.map +1 -1
  54. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts +10 -0
  55. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts.map +1 -0
  56. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js +89 -0
  57. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js.map +1 -0
  58. package/dist/tools/spec/traceabilityMatrix.test.js +19 -0
  59. package/dist/tools/spec/traceabilityMatrix.test.js.map +1 -1
  60. package/hooks/hooks.json +1 -0
  61. package/hooks/scripts/__tests__/.vibe/command-log.txt +39 -0
  62. package/hooks/scripts/__tests__/.vibe/memories/memories.db +0 -0
  63. package/hooks/scripts/__tests__/.vibe/memories/memories.db-shm +0 -0
  64. package/hooks/scripts/__tests__/.vibe/memories/memories.db-wal +0 -0
  65. package/hooks/scripts/__tests__/auto-test-debounce.test.js +145 -0
  66. package/hooks/scripts/__tests__/code-check-detectors.test.js +155 -0
  67. package/hooks/scripts/__tests__/dispatcher-inprocess.test.js +99 -0
  68. package/hooks/scripts/__tests__/post-edit-dispatcher.test.js +139 -0
  69. package/hooks/scripts/__tests__/pre-tool-guard.test.js +115 -1
  70. package/hooks/scripts/__tests__/run-ledger-verify-required.test.js +146 -0
  71. package/hooks/scripts/__tests__/run-ledger.test.js +330 -0
  72. package/hooks/scripts/__tests__/scope-from-spec.test.js +215 -0
  73. package/hooks/scripts/__tests__/sentinel-guard.test.js +79 -24
  74. package/hooks/scripts/__tests__/step-counter.test.js +95 -15
  75. package/hooks/scripts/__tests__/utils-npm-root.test.js +98 -0
  76. package/hooks/scripts/auto-commit.js +27 -1
  77. package/hooks/scripts/auto-format.js +85 -20
  78. package/hooks/scripts/auto-test.js +187 -37
  79. package/hooks/scripts/code-check.js +286 -90
  80. package/hooks/scripts/codex-hook-adapter.js +12 -1
  81. package/hooks/scripts/command-log.js +26 -16
  82. package/hooks/scripts/lib/dispatcher.js +38 -0
  83. package/hooks/scripts/lib/hook-context.js +101 -0
  84. package/hooks/scripts/lib/pr-gate-runner.js +62 -0
  85. package/hooks/scripts/lib/run-ledger.js +169 -0
  86. package/hooks/scripts/lib/scope-from-spec.js +40 -7
  87. package/hooks/scripts/post-edit-dispatcher.js +93 -20
  88. package/hooks/scripts/post-edit.js +40 -19
  89. package/hooks/scripts/pr-test-gate.js +8 -37
  90. package/hooks/scripts/pre-tool-dispatcher.js +18 -16
  91. package/hooks/scripts/pre-tool-guard.js +55 -52
  92. package/hooks/scripts/prompt-dispatcher.js +10 -0
  93. package/hooks/scripts/scope-guard.js +40 -39
  94. package/hooks/scripts/sentinel-guard.js +41 -41
  95. package/hooks/scripts/session-start.js +13 -1
  96. package/hooks/scripts/step-counter.js +100 -7
  97. package/hooks/scripts/stop-dispatcher.js +26 -0
  98. package/hooks/scripts/utils.js +63 -21
  99. package/hooks/scripts/verify-ledger.js +22 -0
  100. package/package.json +2 -2
  101. package/skills/spec/references/templates.md +11 -6
  102. package/skills/vibe.run/SKILL.md +144 -1681
  103. package/skills/vibe.run/references/brand-assets.md +59 -0
  104. package/skills/vibe.run/references/parallel-agents.md +326 -0
  105. package/skills/vibe.run/references/race-review.md +272 -0
  106. package/skills/vibe.run/references/ralph-loop.md +172 -0
  107. package/skills/vibe.run/references/ultrawork-mode.md +148 -0
  108. package/skills/vibe.trace/SKILL.md +25 -38
  109. package/skills/vibe.verify/SKILL.md +15 -0
  110. package/hooks/scripts/figma-guard.js +0 -219
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Hook 실행 컨텍스트 공용 헬퍼 — in-process 평탄화의 기반.
3
+ *
4
+ * 훅 스크립트는 두 모드로 실행된다:
5
+ * 1. in-process: 디스패처가 stdin을 1회 읽고 buildCtx()로 만든 ctx를
6
+ * 각 스크립트의 run(ctx)에 직접 전달 (자식 spawn 없음)
7
+ * 2. standalone CLI: antigravity-hooks.json / 기존 테스트가 스크립트를
8
+ * 직접 실행 — isDirectRun()으로 감지해 stdin/argv/env에서 ctx를 구성
9
+ *
10
+ * ctx 형태: { toolName, toolInput, payload, hookInput }
11
+ * - toolName: payload.tool_name 우선, argv 폴백 (현행 각 스크립트와 동일 우선순위)
12
+ * - toolInput: 문자열 정규화된 tool_input (payload → argv[3] → env.TOOL_INPUT)
13
+ * - payload: 파싱된 stdin JSON 또는 null
14
+ * - hookInput: stdin 원문 문자열 (code-check의 HOOK_INPUT 사용처 대체)
15
+ */
16
+ import fs from 'fs';
17
+ import { pathToFileURL } from 'url';
18
+
19
+ /** stdin을 EOF까지 읽을 때 허용하는 최대 바이트 수 (10MB). */
20
+ const STDIN_MAX_BYTES = 10 * 1024 * 1024;
21
+
22
+ /**
23
+ * stdin에서 JSON 페이로드 동기 읽기 (Claude Code 하네스 호환).
24
+ * fd 0 직접 사용 — Windows는 '/dev/stdin'이 없음.
25
+ * 64KB 단일 버퍼 대신 EOF까지 청크 반복 읽기 (STDIN_MAX_BYTES 상한).
26
+ *
27
+ * @returns {{ raw: string|null, parsed: object|null, truncated: boolean }}
28
+ * truncated=true: 페이로드가 STDIN_MAX_BYTES를 초과해 잘림
29
+ */
30
+ export function readStdinSync() {
31
+ try {
32
+ if (process.stdin.isTTY) return { raw: null, parsed: null, truncated: false };
33
+
34
+ const chunks = [];
35
+ let totalBytes = 0;
36
+ let truncated = false;
37
+ const chunkSize = 65536;
38
+ const chunkBuf = Buffer.alloc(chunkSize);
39
+
40
+ while (true) {
41
+ let bytesRead;
42
+ try {
43
+ bytesRead = fs.readSync(0, chunkBuf, 0, chunkSize, null);
44
+ } catch (err) {
45
+ // EAGAIN: non-blocking stdin에 데이터 없음 → 루프 종료
46
+ if (err.code === 'EAGAIN') break;
47
+ throw err;
48
+ }
49
+ if (bytesRead === 0) break;
50
+ totalBytes += bytesRead;
51
+ if (totalBytes > STDIN_MAX_BYTES) {
52
+ truncated = true;
53
+ break;
54
+ }
55
+ chunks.push(Buffer.from(chunkBuf.subarray(0, bytesRead)));
56
+ }
57
+
58
+ if (chunks.length === 0) return { raw: null, parsed: null, truncated: false };
59
+
60
+ const raw = Buffer.concat(chunks).toString('utf-8');
61
+ try {
62
+ return { raw, parsed: JSON.parse(raw), truncated };
63
+ } catch {
64
+ return { raw, parsed: null, truncated };
65
+ }
66
+ } catch { /* stdin 없음 → 폴백 */ }
67
+ return { raw: null, parsed: null, truncated: false };
68
+ }
69
+
70
+ /**
71
+ * 실행 컨텍스트 구성. 우선순위는 현행 스크립트들과 동일:
72
+ * toolName: payload.tool_name → argvToolName
73
+ * toolInput: payload.tool_input(문자열화) → argv[3] → env.TOOL_INPUT
74
+ */
75
+ export function buildCtx({ rawInput = null, payload = null, argvToolName = '', argvToolInput = '' } = {}) {
76
+ const toolName = payload?.tool_name || argvToolName || '';
77
+ const toolInput = payload?.tool_input !== undefined && payload?.tool_input !== null
78
+ ? (typeof payload.tool_input === 'string' ? payload.tool_input : JSON.stringify(payload.tool_input))
79
+ : (argvToolInput || process.env.TOOL_INPUT || '');
80
+ return { toolName, toolInput, payload, hookInput: rawInput };
81
+ }
82
+
83
+ /** standalone CLI 모드용 ctx — stdin/argv/env에서 구성 */
84
+ export function buildCliCtx() {
85
+ const { raw, parsed } = readStdinSync();
86
+ return buildCtx({
87
+ rawInput: raw ?? process.env.HOOK_INPUT ?? null,
88
+ payload: parsed,
89
+ argvToolName: process.argv[2] || '',
90
+ argvToolInput: process.argv[3] || '',
91
+ });
92
+ }
93
+
94
+ /** 모듈이 `node <file>`로 직접 실행됐는지 감지 (import 시 false) */
95
+ export function isDirectRun(importMetaUrl) {
96
+ try {
97
+ return Boolean(process.argv[1]) && importMetaUrl === pathToFileURL(process.argv[1]).href;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * PR 테스트 게이트 공유 헬퍼 — pr-test-gate.js와 pre-tool-guard.js가 공용.
3
+ *
4
+ * 테스트 커맨드 감지 → 실행 → 결과 반환.
5
+ * exit code를 직접 호출하지 않고 결과 객체를 반환한다 (호출자가 판단).
6
+ */
7
+ import { execSync } from 'child_process';
8
+ import { existsSync, readFileSync } from 'fs';
9
+ import path from 'path';
10
+
11
+ /**
12
+ * 프로젝트에서 실행할 테스트 커맨드를 감지한다.
13
+ *
14
+ * @param {string} projectDir
15
+ * @returns {string|null}
16
+ */
17
+ export function detectTestCommand(projectDir) {
18
+ const pkgPath = path.join(projectDir, 'package.json');
19
+ if (existsSync(pkgPath)) {
20
+ try {
21
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
22
+ if (pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1') {
23
+ return 'npm test';
24
+ }
25
+ } catch { /* ignore */ }
26
+ }
27
+ // Python
28
+ if (existsSync(path.join(projectDir, 'pytest.ini')) || existsSync(path.join(projectDir, 'pyproject.toml'))) {
29
+ return 'python -m pytest --tb=short -q';
30
+ }
31
+ // Go
32
+ if (existsSync(path.join(projectDir, 'go.mod'))) {
33
+ return 'go test ./...';
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * 테스트 게이트를 실행한다.
40
+ *
41
+ * @param {string} projectDir
42
+ * @returns {{ passed: boolean, testCmd: string|null, output: string }}
43
+ * testCmd=null 이면 테스트 없음 → passed=true (통과)
44
+ */
45
+ export function runPrTestGate(projectDir) {
46
+ const testCmd = detectTestCommand(projectDir);
47
+ if (!testCmd) {
48
+ return { passed: true, testCmd: null, output: '' };
49
+ }
50
+
51
+ try {
52
+ execSync(testCmd, {
53
+ cwd: projectDir,
54
+ stdio: ['ignore', 'pipe', 'pipe'],
55
+ timeout: 120000,
56
+ });
57
+ return { passed: true, testCmd, output: '' };
58
+ } catch (err) {
59
+ const output = err.stdout ? err.stdout.toString().split('\n').slice(-5).join('\n') : '';
60
+ return { passed: false, testCmd, output };
61
+ }
62
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Run Ledger — vibe.run 실행 및 vibe.verify 결과 추적.
3
+ *
4
+ * 파일 위치: <projectDir>/.vibe/metrics/run-ledger.json
5
+ * 형식: { runStarted, runFeature, verifyPassed, verifyAt, stopWarned,
6
+ * verifyRequired, verifyRequiredReason }
7
+ *
8
+ * 모든 함수는 fail-open (try/catch, 오류 시 null/false 반환).
9
+ * 원자적 쓰기: 임시 파일 → rename 방식 사용.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import os from 'os';
14
+ import path from 'path';
15
+
16
+ /** 레저 파일 경로 */
17
+ function ledgerPath(projectDir) {
18
+ return path.join(projectDir, '.vibe', 'metrics', 'run-ledger.json');
19
+ }
20
+
21
+ /**
22
+ * 레저 파일 읽기.
23
+ * @param {string} projectDir
24
+ * @returns {{ runStarted: string|null, runFeature: string|null, verifyPassed: boolean, verifyAt: string|null, stopWarned: boolean, verifyRequired: boolean, verifyRequiredReason: string|null }|null}
25
+ */
26
+ export function readLedger(projectDir) {
27
+ try {
28
+ const p = ledgerPath(projectDir);
29
+ if (!fs.existsSync(p)) return null;
30
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 레저 파일 원자적 쓰기 (임시 파일 write → rename).
38
+ * @param {string} projectDir
39
+ * @param {object} data
40
+ * @returns {boolean} 성공 여부
41
+ */
42
+ function writeLedger(projectDir, data) {
43
+ try {
44
+ const p = ledgerPath(projectDir);
45
+ fs.mkdirSync(path.dirname(p), { recursive: true });
46
+ const tmp = path.join(os.tmpdir(), `run-ledger-${process.pid}-${Date.now()}.tmp`);
47
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
48
+ fs.renameSync(tmp, p);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * vibe.run 시작 기록 — runStarted를 현재 시각으로 세팅하고 verifyPassed를 리셋.
57
+ * @param {string} projectDir
58
+ * @param {string|null} feature - 프롬프트에서 추출된 기능명 (없으면 null)
59
+ * @returns {boolean} 성공 여부
60
+ */
61
+ export function recordRunStart(projectDir, feature) {
62
+ try {
63
+ const existing = readLedger(projectDir) || {};
64
+ const next = {
65
+ ...existing,
66
+ runStarted: new Date().toISOString(),
67
+ runFeature: feature || null,
68
+ verifyPassed: false,
69
+ verifyAt: null,
70
+ stopWarned: false,
71
+ };
72
+ return writeLedger(projectDir, next);
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * vibe.verify 결과 기록.
80
+ * pass 시 verifyRequired 상태를 클리어한다.
81
+ * @param {string} projectDir
82
+ * @param {boolean} passed - 검증 통과 여부
83
+ * @returns {boolean} 성공 여부
84
+ */
85
+ export function recordVerify(projectDir, passed) {
86
+ try {
87
+ const existing = readLedger(projectDir) || {};
88
+ const next = {
89
+ ...existing,
90
+ verifyPassed: Boolean(passed),
91
+ verifyAt: new Date().toISOString(),
92
+ };
93
+ // pass 시 verifyRequired 클리어
94
+ if (passed) {
95
+ next.verifyRequired = false;
96
+ next.verifyRequiredReason = null;
97
+ }
98
+ return writeLedger(projectDir, next);
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * P1 이슈 발견 시 verify-required 상태 기록.
106
+ * @param {string} projectDir
107
+ * @param {string} reason - 이슈 사유
108
+ * @returns {boolean} 성공 여부
109
+ */
110
+ export function recordVerifyRequired(projectDir, reason) {
111
+ try {
112
+ const existing = readLedger(projectDir) || {};
113
+ const next = {
114
+ ...existing,
115
+ verifyRequired: true,
116
+ verifyRequiredReason: reason || 'P1 issue detected',
117
+ };
118
+ return writeLedger(projectDir, next);
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * stop 경고 플래그 세팅 (루프 방지).
126
+ * @param {string} projectDir
127
+ * @returns {boolean} 성공 여부
128
+ */
129
+ export function markStopWarned(projectDir) {
130
+ try {
131
+ const existing = readLedger(projectDir) || {};
132
+ const next = { ...existing, stopWarned: true };
133
+ return writeLedger(projectDir, next);
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 프롬프트에서 vibe.run 기능명 추출.
141
+ * "/vibe.run some-feature ..." 또는 "$vibe.run some-feature ..." 형태에서 첫 토큰 추출.
142
+ * @param {string} prompt
143
+ * @returns {string|null}
144
+ */
145
+ export function extractRunFeature(prompt) {
146
+ try {
147
+ const m = prompt.match(/(?:\/|\$)vibe\.run\s+([^\s]+)/i);
148
+ if (!m) return null;
149
+ const token = m[1];
150
+ // 플래그(-- 시작)나 키워드는 기능명이 아님
151
+ if (token.startsWith('-')) return null;
152
+ return token;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * 프롬프트에 vibe.run 호출이 포함되는지 확인 (단어 경계 매칭, 대소문자 무관).
160
+ * @param {string} prompt
161
+ * @returns {boolean}
162
+ */
163
+ export function isVibeRunPrompt(prompt) {
164
+ try {
165
+ return /(?:^|[\s,;|&(])[$\/]vibe\.run(?:\b|$)/i.test(prompt);
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
@@ -12,12 +12,26 @@ import path from 'path';
12
12
 
13
13
  const ACTIVE_STATUSES = new Set(['pending', 'in-progress', 'in_progress', 'active', 'running']);
14
14
 
15
+ // status frontmatter 없는 SPEC을 활성으로 간주하는 최대 나이.
16
+ // 오래된 status-없는 SPEC이 영구 활성으로 남아 stale scope를 만드는 것을 방지한다.
17
+ const NO_STATUS_ACTIVE_MAX_AGE_DAYS = 14;
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+
20
+ function isRecentlyModified(file) {
21
+ try {
22
+ return Date.now() - fs.statSync(file).mtimeMs <= NO_STATUS_ACTIVE_MAX_AGE_DAYS * DAY_MS;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
15
28
  /**
16
29
  * scope-guard auto-derive 활성 여부.
17
30
  *
18
- * 기본값은 **off**. 사용자가 `.vibe/config.json` 의 `scopeGuard.enabled = true` 로
19
- * 명시적으로 때만 SPEC → scope.json 자동 합성을 수행한다. 이전 동작(자동 ON)은
20
- * SPEC 외부 파일 편집에 매번 경고를 띄워 다수 사용자에게 노이즈가 됐기 때문.
31
+ * 기본값은 **on**. `.vibe/config.json` 의 `scopeGuard.enabled = false` 로
32
+ * 명시적으로 때만 SPEC → scope.json 자동 합성을 건너뛴다.
33
+ * 노이즈 가드: SPEC 파일이 존재할 때만 scope.json 생성하며,
34
+ * SPEC 없이 scope.json 생성이 필요한 경우 수동 작성(auto:false)을 사용한다.
21
35
  *
22
36
  * 이미 `auto: false` scope.json 을 직접 만들어 둔 사용자는 영향 없음 — scope-guard.js
23
37
  * 자체는 scope.json 존재 여부만 본다.
@@ -31,10 +45,10 @@ export function isScopeGuardEnabled(projectDir) {
31
45
  for (const p of candidates) {
32
46
  if (!fs.existsSync(p)) continue;
33
47
  const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
34
- if (cfg && cfg.scopeGuard && cfg.scopeGuard.enabled === true) return true;
48
+ if (cfg && cfg.scopeGuard && cfg.scopeGuard.enabled === false) return false;
35
49
  }
36
50
  } catch { /* ignore */ }
37
- return false;
51
+ return true;
38
52
  }
39
53
 
40
54
  /**
@@ -181,7 +195,7 @@ export function findActiveSpecs(projectDir) {
181
195
  const content = fs.readFileSync(f, 'utf-8');
182
196
  const fm = readFrontmatter(content);
183
197
  const status = (fm.status || '').toLowerCase();
184
- if (!fm.status) return true; // status 없으면 활성 간주
198
+ if (!fm.status) return isRecentlyModified(f); // status 없으면 최근 수정분만 활성 간주
185
199
  return ACTIVE_STATUSES.has(status);
186
200
  } catch { return false; }
187
201
  });
@@ -215,6 +229,7 @@ export function synthesizeScope(specFiles, { projectDir } = {}) {
215
229
  return {
216
230
  auto: true,
217
231
  mode: 'warn',
232
+ derivedCount: verified.length,
218
233
  allow,
219
234
  deny: [],
220
235
  reason: sourceNames.length > 0
@@ -262,6 +277,23 @@ export function syncScopeFile(projectDir) {
262
277
  }
263
278
 
264
279
  const next = synthesizeScope(specs, { projectDir });
280
+
281
+ // SPEC은 있으나 경로를 산출하지 못함 → defaultAllow만 남은 scope는
282
+ // 모든 소스 편집에 경고를 띄우는 노이즈가 된다. 생성하지 않고, 기존
283
+ // 자동 생성 파일이 있으면 제거한다.
284
+ if (next.derivedCount === 0) {
285
+ if (fs.existsSync(scopePath)) {
286
+ try {
287
+ const existing = JSON.parse(fs.readFileSync(scopePath, 'utf-8'));
288
+ if (existing.auto === true) {
289
+ fs.unlinkSync(scopePath);
290
+ return { action: 'removed', path: scopePath };
291
+ }
292
+ } catch { /* ignore */ }
293
+ }
294
+ return { action: 'skipped-no-paths', path: scopePath };
295
+ }
296
+
265
297
  const nextStr = JSON.stringify(next, null, 2);
266
298
 
267
299
  // 변경 없음 체크 (generatedAt 제외)
@@ -288,7 +320,7 @@ import { fileURLToPath } from 'url';
288
320
  if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
289
321
  const dir = process.argv[2] || process.env.CLAUDE_PROJECT_DIR || process.cwd();
290
322
  if (!isScopeGuardEnabled(dir)) {
291
- console.log('[scope-sync] · scopeGuard.enabled !== true — skipping (set in .vibe/config.json to opt in)');
323
+ console.log('[scope-sync] · scopeGuard.enabled=false — skipping (explicitly opted out in .vibe/config.json)');
292
324
  process.exit(0);
293
325
  }
294
326
  const result = syncScopeFile(dir);
@@ -299,6 +331,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.a
299
331
  removed: '✓ scope.json removed (no active SPECs)',
300
332
  'skipped-manual': '· scope.json is manually managed (auto=false)',
301
333
  'skipped-no-specs': '· no active SPECs — scope.json not generated',
334
+ 'skipped-no-paths': '· active SPECs contain no derivable paths — scope.json not generated',
302
335
  }[result.action] || result.action;
303
336
  console.log(`[scope-sync] ${label}`);
304
337
  }
@@ -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
  }