@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
@@ -24,7 +24,6 @@ function runGuard(args = []) {
24
24
 
25
25
  /**
26
26
  * Run sentinel-guard.js with stdin JSON payload.
27
- * 스크립트가 fs.readSync(0, ...)로 stdin을 읽으므로 execFileSync input 옵션이 동작.
28
27
  */
29
28
  function runGuardWithStdin(payload) {
30
29
  const json = typeof payload === 'string' ? payload : JSON.stringify(payload);
@@ -41,24 +40,25 @@ function runGuardWithStdin(payload) {
41
40
  }
42
41
 
43
42
  // ══════════════════════════════════════════════════
44
- // Sentinel path protection
43
+ // Sentinel path protection — evolution machinery
45
44
  // ══════════════════════════════════════════════════
46
45
  describe('sentinel-guard', () => {
47
- describe('Write/Edit to sentinel paths via argv', () => {
48
- it('should block Write to src/infra/lib/autonomy/', () => {
46
+ // ─── 실제 보호 경로: src/infra/lib/evolution/ ───
47
+ describe('Write/Edit to evolution sentinel path via argv', () => {
48
+ it('should block Write to src/infra/lib/evolution/', () => {
49
49
  const result = runGuard([
50
50
  'Write',
51
- JSON.stringify({ file_path: 'src/infra/lib/autonomy/policy.ts' }),
51
+ JSON.stringify({ file_path: 'src/infra/lib/evolution/EvolutionOrchestrator.ts' }),
52
52
  ]);
53
53
  expect(result.exitCode).toBe(2);
54
54
  expect(result.stdout).toContain('block');
55
55
  expect(result.stdout).toContain('Sentinel files are protected');
56
56
  });
57
57
 
58
- it('should block Edit to sentinel path', () => {
58
+ it('should block Edit to evolution sentinel path', () => {
59
59
  const result = runGuard([
60
60
  'Edit',
61
- JSON.stringify({ file_path: 'src/infra/lib/autonomy/config.ts' }),
61
+ JSON.stringify({ file_path: 'src/infra/lib/evolution/GuardAnalyzer.ts' }),
62
62
  ]);
63
63
  expect(result.exitCode).toBe(2);
64
64
  expect(result.stdout).toContain('block');
@@ -67,7 +67,7 @@ describe('sentinel-guard', () => {
67
67
  it('should block Write with backslash path separators', () => {
68
68
  const result = runGuard([
69
69
  'Write',
70
- JSON.stringify({ file_path: 'src\\infra\\lib\\autonomy\\file.ts' }),
70
+ JSON.stringify({ file_path: 'src\\infra\\lib\\evolution\\file.ts' }),
71
71
  ]);
72
72
  expect(result.exitCode).toBe(2);
73
73
  expect(result.stdout).toContain('Sentinel files are protected');
@@ -76,32 +76,62 @@ describe('sentinel-guard', () => {
76
76
  it('should block Write with ./ prefix', () => {
77
77
  const result = runGuard([
78
78
  'Write',
79
- JSON.stringify({ file_path: './src/infra/lib/autonomy/index.ts' }),
79
+ JSON.stringify({ file_path: './src/infra/lib/evolution/index.ts' }),
80
80
  ]);
81
81
  expect(result.exitCode).toBe(2);
82
82
  });
83
83
  });
84
84
 
85
+ // ─── 실제 보호 경로: hooks/scripts/lib/ ───
86
+ describe('Write/Edit to hooks/scripts/lib/ sentinel path', () => {
87
+ it('should block Write to hooks/scripts/lib/', () => {
88
+ const result = runGuard([
89
+ 'Write',
90
+ JSON.stringify({ file_path: 'hooks/scripts/lib/dispatcher.js' }),
91
+ ]);
92
+ expect(result.exitCode).toBe(2);
93
+ expect(result.stdout).toContain('Sentinel files are protected');
94
+ });
95
+
96
+ it('should block Edit to hook-context.js', () => {
97
+ const result = runGuard([
98
+ 'Edit',
99
+ JSON.stringify({ file_path: 'hooks/scripts/lib/hook-context.js' }),
100
+ ]);
101
+ expect(result.exitCode).toBe(2);
102
+ });
103
+ });
104
+
105
+ // ─── stdin 경로 ───
85
106
  describe('Write/Edit to sentinel paths via stdin', () => {
86
- it('should block Edit via stdin payload', () => {
107
+ it('should block Edit evolution path via stdin payload', () => {
87
108
  const result = runGuardWithStdin({
88
109
  tool_name: 'Edit',
89
- tool_input: { file_path: './src/infra/lib/autonomy/config.ts' },
110
+ tool_input: { file_path: './src/infra/lib/evolution/InsightStore.ts' },
90
111
  });
91
112
  expect(result.exitCode).toBe(2);
92
113
  expect(result.stdout).toContain('block');
93
114
  });
94
115
 
95
- it('should block Write via stdin payload', () => {
116
+ it('should block Write evolution path via stdin payload', () => {
96
117
  const result = runGuardWithStdin({
97
118
  tool_name: 'Write',
98
- tool_input: { file_path: 'src/infra/lib/autonomy/policy.ts' },
119
+ tool_input: { file_path: 'src/infra/lib/evolution/CircuitBreaker.ts' },
99
120
  });
100
121
  expect(result.exitCode).toBe(2);
101
122
  expect(result.stdout).toContain('Sentinel files are protected');
102
123
  });
124
+
125
+ it('should block Write hooks/scripts/lib/ path via stdin', () => {
126
+ const result = runGuardWithStdin({
127
+ tool_name: 'Write',
128
+ tool_input: { file_path: 'hooks/scripts/lib/run-ledger.js' },
129
+ });
130
+ expect(result.exitCode).toBe(2);
131
+ });
103
132
  });
104
133
 
134
+ // ─── 허용 경로 ───
105
135
  describe('allowed operations', () => {
106
136
  it('should allow Write to non-sentinel paths', () => {
107
137
  const result = runGuard([
@@ -114,7 +144,7 @@ describe('sentinel-guard', () => {
114
144
  it('should allow Read to sentinel paths (read is not blocked)', () => {
115
145
  const result = runGuard([
116
146
  'Read',
117
- JSON.stringify({ file_path: 'src/infra/lib/autonomy/policy.ts' }),
147
+ JSON.stringify({ file_path: 'src/infra/lib/evolution/GuardAnalyzer.ts' }),
118
148
  ]);
119
149
  expect(result.exitCode).toBe(0);
120
150
  });
@@ -126,23 +156,40 @@ describe('sentinel-guard', () => {
126
156
  ]);
127
157
  expect(result.exitCode).toBe(0);
128
158
  });
159
+
160
+ it('should allow Write to hooks/scripts/ top level (not lib/ subdir)', () => {
161
+ const result = runGuard([
162
+ 'Write',
163
+ JSON.stringify({ file_path: 'hooks/scripts/step-counter.js' }),
164
+ ]);
165
+ expect(result.exitCode).toBe(0);
166
+ });
167
+
168
+ it('should allow Write to src/infra/lib/ parent (not evolution subdir)', () => {
169
+ const result = runGuard([
170
+ 'Write',
171
+ JSON.stringify({ file_path: 'src/infra/lib/constants.ts' }),
172
+ ]);
173
+ expect(result.exitCode).toBe(0);
174
+ });
129
175
  });
130
176
 
177
+ // ─── 위험한 bash + sentinel 경로 ───
131
178
  describe('dangerous bash commands targeting sentinel paths', () => {
132
- it('should block rm -rf targeting sentinel path', () => {
179
+ it('should block rm -rf targeting evolution path', () => {
133
180
  const result = runGuard([
134
181
  'Bash',
135
- JSON.stringify({ command: 'rm -rf src/infra/lib/autonomy/' }),
182
+ JSON.stringify({ command: 'rm -rf src/infra/lib/evolution/' }),
136
183
  ]);
137
184
  expect(result.exitCode).toBe(2);
138
185
  expect(result.stdout).toContain('block');
139
186
  expect(result.stdout).toContain('Dangerous command targeting sentinel path');
140
187
  });
141
188
 
142
- it('should block kill -9 targeting sentinel path', () => {
189
+ it('should block rm -rf targeting hooks/scripts/lib/', () => {
143
190
  const result = runGuard([
144
191
  'Bash',
145
- JSON.stringify({ command: 'kill -9 1234 && rm src/infra/lib/autonomy/x' }),
192
+ JSON.stringify({ command: 'rm -rf hooks/scripts/lib/' }),
146
193
  ]);
147
194
  expect(result.exitCode).toBe(2);
148
195
  });
@@ -164,33 +211,41 @@ describe('sentinel-guard', () => {
164
211
  });
165
212
  });
166
213
 
214
+ // ─── Bash 명령어 문자열이 sentinel 경로로 시작하는 경우 ───
167
215
  describe('Bash command containing sentinel path in command string', () => {
168
- it('should block when command string itself starts with sentinel path', () => {
216
+ it('should block when command string itself starts with evolution sentinel path', () => {
169
217
  const result = runGuard([
170
218
  'Bash',
171
- JSON.stringify({ command: 'src/infra/lib/autonomy/run.sh' }),
219
+ JSON.stringify({ command: 'src/infra/lib/evolution/run.sh' }),
172
220
  ]);
173
221
  expect(result.exitCode).toBe(2);
174
222
  expect(result.stdout).toContain('Sentinel files are protected');
175
223
  });
176
224
 
225
+ it('should block when command string starts with hooks/scripts/lib/', () => {
226
+ const result = runGuard([
227
+ 'Bash',
228
+ JSON.stringify({ command: 'hooks/scripts/lib/dispatcher.js' }),
229
+ ]);
230
+ expect(result.exitCode).toBe(2);
231
+ });
232
+
177
233
  it('should not block non-dangerous commands referencing sentinel path mid-string', () => {
178
- // isSentinelPath only checks startsWith, and the DANGEROUS_BASH_RE +
179
- // includes check requires both a dangerous command and sentinel path
180
234
  const result = runGuard([
181
235
  'Bash',
182
- JSON.stringify({ command: 'cat src/infra/lib/autonomy/policy.ts | wc -l' }),
236
+ JSON.stringify({ command: 'cat src/infra/lib/evolution/GuardAnalyzer.ts | wc -l' }),
183
237
  ]);
184
238
  // 'cat' is not a dangerous command, command does not start with sentinel path
185
239
  expect(result.exitCode).toBe(0);
186
240
  });
187
241
  });
188
242
 
243
+ // ─── stdin vs argv 우선순위 ───
189
244
  describe('stdin vs argv priority', () => {
190
245
  it('should prefer stdin payload over argv', () => {
191
246
  const payload = JSON.stringify({
192
247
  tool_name: 'Write',
193
- tool_input: { file_path: 'src/infra/lib/autonomy/x.ts' },
248
+ tool_input: { file_path: 'src/infra/lib/evolution/x.ts' },
194
249
  });
195
250
  try {
196
251
  execFileSync('node', [SCRIPT, 'Read', '{}'], {
@@ -75,27 +75,31 @@ describe('step-counter PostToolUse hook', () => {
75
75
  expect(data.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
76
76
  });
77
77
 
78
- it('연속 호출 시 steps 누적', () => {
79
- for (let i = 0; i < 3; i++) {
78
+ it('연속 호출 시 steps 누적 (10이벤트 재작성 주기 고려)', () => {
79
+ // 10이벤트마다 재작성 10회 호출 후에는 steps=10 보장됨
80
+ for (let i = 0; i < 10; i++) {
80
81
  runCounter({
81
82
  payload: { tool_name: 'Bash', tool_input: { command: 'echo hi' }, tool_response: {} },
82
83
  projectDir,
83
84
  });
84
85
  }
85
86
  const data = readJson(runJson);
86
- expect(data.steps).toBe(3);
87
+ expect(data.steps).toBe(10);
87
88
  });
88
89
 
89
- it('손상된 JSON 이 있어도 새로 시작', () => {
90
+ it('손상된 JSON 이 있어도 새로 시작 (10회 후 재작성으로 확인)', () => {
90
91
  fs.mkdirSync(path.join(projectDir, '.vibe', 'metrics'), { recursive: true });
91
92
  fs.writeFileSync(runJson, '{ broken json');
92
- const r = runCounter({
93
- payload: { tool_name: 'Bash', tool_input: {}, tool_response: {} },
94
- projectDir,
95
- });
96
- expect(r.status).toBe(0);
93
+ // 10회 호출해야 재작성 주기 도달
94
+ for (let i = 0; i < 10; i++) {
95
+ runCounter({
96
+ payload: { tool_name: 'Bash', tool_input: {}, tool_response: {} },
97
+ projectDir,
98
+ });
99
+ }
97
100
  const data = readJson(runJson);
98
- expect(data.steps).toBe(1);
101
+ expect(data.steps).toBe(10);
102
+ expect(data.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
99
103
  });
100
104
  });
101
105
 
@@ -149,7 +153,8 @@ describe('step-counter PostToolUse hook', () => {
149
153
  expect(readJson(runJson).steps).toBe(1);
150
154
  });
151
155
 
152
- it('연속 호출 시 jsonl 누적', () => {
156
+ it('연속 호출 시 jsonl 누적 (Read는 조기 종료 — 기록 안 됨)', () => {
157
+ // Read 는 READ_ONLY_TOOLS 조기 종료 → jsonl 미기록
153
158
  runCounter({
154
159
  payload: { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, tool_response: {} },
155
160
  projectDir,
@@ -159,8 +164,8 @@ describe('step-counter PostToolUse hook', () => {
159
164
  projectDir,
160
165
  });
161
166
  const lines = readJsonl(runJsonl);
162
- expect(lines).toHaveLength(2);
163
- expect(lines.map((l) => l.tool)).toEqual(['Read', 'Edit']);
167
+ expect(lines).toHaveLength(1);
168
+ expect(lines[0].tool).toBe('Edit');
164
169
  });
165
170
  });
166
171
 
@@ -322,10 +327,10 @@ describe('step-counter PostToolUse hook', () => {
322
327
  // 같은 카테고리 실패 2회
323
328
  failBash('a', err, projectDir);
324
329
  failBash('b', err, projectDir);
325
- // 성공 툴콜로 윈도우 채움 (10줄 이상)
330
+ // 성공 툴콜로 윈도우 채움 (10줄 이상) — Bash 를 사용 (Read 는 조기 종료로 jsonl 미기록)
326
331
  for (let i = 0; i < 10; i++) {
327
332
  runCounter({
328
- payload: { tool_name: 'Read', tool_input: { file_path: `f${i}.ts` }, tool_response: {} },
333
+ payload: { tool_name: 'Bash', tool_input: { command: `echo ${i}` }, tool_response: {} },
329
334
  projectDir,
330
335
  });
331
336
  }
@@ -355,4 +360,79 @@ describe('step-counter PostToolUse hook', () => {
355
360
  expect(r.status).toBe(0);
356
361
  });
357
362
  });
363
+
364
+ // ───────── 읽기 전용 도구 조기 종료 (defense-in-depth) ─────────
365
+ describe('읽기 전용 도구 조기 종료', () => {
366
+ const READ_ONLY = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'ToolSearch', 'ListMcpResourcesTool'];
367
+
368
+ it.each(READ_ONLY)('%s 호출 시 파일 미생성 + exit 0', (toolName) => {
369
+ const r = runCounter({
370
+ payload: { tool_name: toolName, tool_input: { file_path: 'src/foo.ts' }, tool_response: {} },
371
+ projectDir,
372
+ });
373
+ expect(r.status).toBe(0);
374
+ // jsonl에 기록하지 않아야 함
375
+ const jsonlPath = path.join(projectDir, '.vibe', 'metrics', 'current-run.jsonl');
376
+ expect(fs.existsSync(jsonlPath)).toBe(false);
377
+ // current-run.json 도 생성하지 않아야 함
378
+ const jsonPath = path.join(projectDir, '.vibe', 'metrics', 'current-run.json');
379
+ expect(fs.existsSync(jsonPath)).toBe(false);
380
+ });
381
+ });
382
+
383
+ // ───────── 쓰기 스로틀 ─────────
384
+ describe('쓰기 스로틀: jsonl 항상 append, json 조건부 재작성', () => {
385
+ it('첫 번째 이벤트는 current-run.json 생성 (파일 없음 → 스로틀 없음)', () => {
386
+ runCounter({
387
+ payload: { tool_name: 'Bash', tool_input: { command: 'x' }, tool_response: {} },
388
+ projectDir,
389
+ });
390
+ expect(fs.existsSync(runJson)).toBe(true);
391
+ expect(readJson(runJson).steps).toBe(1);
392
+ });
393
+
394
+ it('10이벤트마다 강제 재작성: 10회 후 steps=jsonl라인수=10', () => {
395
+ for (let i = 0; i < 10; i++) {
396
+ runCounter({
397
+ payload: { tool_name: 'Edit', tool_input: { file_path: 'a.ts' }, tool_response: {} },
398
+ projectDir,
399
+ });
400
+ }
401
+ // 10번째 이벤트에서 강제 재작성 (10%10===0) — steps = jsonl 라인 수 = 10
402
+ const data = readJson(runJson);
403
+ expect(data.steps).toBe(10);
404
+ // jsonl 도 10줄
405
+ const lines = readJsonl(runJsonl);
406
+ expect(lines).toHaveLength(10);
407
+ });
408
+
409
+ it('jsonl 은 스로틀 무관 항상 즉시 append', () => {
410
+ for (let i = 0; i < 5; i++) {
411
+ runCounter({
412
+ payload: { tool_name: 'Bash', tool_input: { command: `cmd-${i}` }, tool_response: {} },
413
+ projectDir,
414
+ });
415
+ }
416
+ const lines = readJsonl(runJsonl);
417
+ expect(lines).toHaveLength(5);
418
+ });
419
+
420
+ it('3-fail 감지는 jsonl 기반 — json 스로틀 후에도 동작', () => {
421
+ const err = "TypeError: Cannot read properties of undefined (reading 'x')";
422
+ // 3회 실패 (jsonl에 즉시 기록되므로 감지 가능)
423
+ for (let i = 0; i < 3; i++) {
424
+ runCounter({
425
+ payload: {
426
+ tool_name: 'Bash',
427
+ tool_input: { command: `run-${i}` },
428
+ tool_response: { is_error: true, error: err },
429
+ },
430
+ projectDir,
431
+ });
432
+ }
433
+ const apDir = path.join(projectDir, '.vibe', 'anti-patterns');
434
+ const files = fs.existsSync(apDir) ? fs.readdirSync(apDir).filter(f => f.endsWith('.md')) : [];
435
+ expect(files).toHaveLength(1);
436
+ });
437
+ });
358
438
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * utils.js — getGlobalNpmPath() 파일 캐시 테스트
3
+ *
4
+ * 검증 범위:
5
+ * - L2 파일 캐시 히트 (TTL 내)
6
+ * - L2 파일 캐시 만료 (TTL 초과)
7
+ * - 캐시 파일 손상 시 fail-open (execSync 재실행)
8
+ * - 캐시 파일 없음 시 execSync 실행 후 파일 저장
9
+ *
10
+ * 격리 전략: 각 테스트는 별도 임시 디렉토리를 캐시 경로로 사용한다.
11
+ * NPM_ROOT_CACHE_FILE 환경 변수를 통해 경로를 주입한다.
12
+ */
13
+ import { describe, it, expect } from 'vitest';
14
+ import { spawnSync } from 'child_process';
15
+ import fs from 'fs';
16
+ import os from 'os';
17
+ import path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const UTILS_PATH = path.resolve(__dirname, '..', 'utils.js');
22
+
23
+ function makeTempCacheFile() {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-npm-root-test-'));
25
+ return path.join(dir, 'npm-root.json');
26
+ }
27
+
28
+ /**
29
+ * utils.js 의 getGlobalNpmPath() 를 별도 프로세스에서 실행.
30
+ * VIBE_NPM_ROOT_CACHE_FILE 환경 변수로 캐시 파일 경로를 주입한다.
31
+ */
32
+ function runGetNpmRoot(cacheFilePath) {
33
+ return spawnSync('node', ['--input-type=module', '--eval',
34
+ `import { getGlobalNpmPath } from '${UTILS_PATH}';
35
+ process.stdout.write(getGlobalNpmPath() || '');`
36
+ ], {
37
+ encoding: 'utf-8',
38
+ timeout: 10000,
39
+ env: {
40
+ ...process.env,
41
+ VIBE_NPM_ROOT_CACHE_FILE: cacheFilePath,
42
+ },
43
+ });
44
+ }
45
+
46
+ describe('utils.js — getGlobalNpmPath() npm-root 파일 캐시', () => {
47
+ it('캐시 파일 없으면 execSync 실행 후 캐시 파일 생성', () => {
48
+ const cacheFile = makeTempCacheFile();
49
+ // 캐시 파일이 없는 상태에서 시작
50
+ expect(fs.existsSync(cacheFile)).toBe(false);
51
+
52
+ const result = runGetNpmRoot(cacheFile);
53
+ expect(result.status).toBe(0);
54
+ expect(result.stdout.trim()).toBeTruthy();
55
+
56
+ // 캐시 파일이 생성되어야 함
57
+ expect(fs.existsSync(cacheFile)).toBe(true);
58
+ const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
59
+ expect(cached.npmRoot).toBe(result.stdout.trim());
60
+ expect(typeof cached.savedAt).toBe('number');
61
+ });
62
+
63
+ it('유효한 캐시 파일이 있으면 execSync 없이 캐시값 반환', () => {
64
+ const cacheFile = makeTempCacheFile();
65
+ const fakeRoot = '/fake/npm/root/for/test';
66
+ // 유효한 캐시 미리 작성
67
+ fs.writeFileSync(cacheFile, JSON.stringify({ npmRoot: fakeRoot, savedAt: Date.now() }), { mode: 0o600 });
68
+
69
+ const result = runGetNpmRoot(cacheFile);
70
+ expect(result.status).toBe(0);
71
+ expect(result.stdout.trim()).toBe(fakeRoot);
72
+ });
73
+
74
+ it('TTL 초과 캐시는 무효화 — execSync 재실행', () => {
75
+ const cacheFile = makeTempCacheFile();
76
+ const staleRoot = '/stale/path/should/not/be/used';
77
+ const expiredAt = Date.now() - (25 * 60 * 60 * 1000); // 25시간 전
78
+ fs.writeFileSync(cacheFile, JSON.stringify({ npmRoot: staleRoot, savedAt: expiredAt }), { mode: 0o600 });
79
+
80
+ const result = runGetNpmRoot(cacheFile);
81
+ expect(result.status).toBe(0);
82
+ // stale 값이 아닌 실제 npm root 가 반환되어야 함
83
+ expect(result.stdout.trim()).not.toBe(staleRoot);
84
+ expect(result.stdout.trim()).toBeTruthy();
85
+ });
86
+
87
+ it('손상된 캐시 파일 — fail-open (execSync 실행)', () => {
88
+ const cacheFile = makeTempCacheFile();
89
+ fs.writeFileSync(cacheFile, '{ broken json :::');
90
+
91
+ const result = runGetNpmRoot(cacheFile);
92
+ expect(result.status).toBe(0);
93
+ expect(result.stdout.trim()).toBeTruthy(); // 실제 경로 반환
94
+ // 손상된 파일 때문에 프로세스가 crash 나지 않아야 함
95
+ const stderr = result.stderr || '';
96
+ expect(stderr).not.toMatch(/^Error:/m);
97
+ });
98
+ });
@@ -13,12 +13,38 @@
13
13
  * 으로 롤백 가능. 최근 5개만 유지.
14
14
  */
15
15
  import { execSync } from 'child_process';
16
- import { PROJECT_DIR, readProjectConfig } from './utils.js';
16
+ import { PROJECT_DIR, readProjectConfig, logHookDecision } from './utils.js';
17
+ import { readLedger } from './lib/run-ledger.js';
17
18
 
18
19
  // Opt-in 가드 — 명시적으로 켜지 않았으면 아무것도 하지 않는다.
19
20
  const __autoCommitCfg = readProjectConfig();
20
21
  if (__autoCommitCfg?.hooks?.['auto-commit']?.enabled !== true) process.exit(0);
21
22
 
23
+ // verify 게이트 — vibe.run 세션이 시작됐으면 verifyPassed가 true이고
24
+ // verifyAt > runStarted 인 경우에만 커밋을 허용한다.
25
+ const __ledger = readLedger(PROJECT_DIR);
26
+ if (__ledger && __ledger.runStarted) {
27
+ const verifyOk = __ledger.verifyPassed === true
28
+ && __ledger.verifyAt
29
+ && __ledger.verifyAt > __ledger.runStarted;
30
+ if (!verifyOk) {
31
+ const reason = !__ledger.verifyPassed
32
+ ? 'vibe.verify not passed — run /vibe.verify before committing'
33
+ : 'verifyAt is not after runStarted — re-run /vibe.verify';
34
+ logHookDecision('auto-commit', 'git-commit', 'block', reason);
35
+ process.stderr.write(`[auto-commit] SKIP: ${reason}\n`);
36
+ process.exit(0);
37
+ }
38
+ }
39
+
40
+ // verifyRequired 게이트 — PostToolUse에서 P1 이슈가 발견되어 verify가 요구됨.
41
+ if (__ledger && __ledger.verifyRequired === true) {
42
+ const reason = `P1 issue requires verification: ${__ledger.verifyRequiredReason || 'see code-check findings'}`;
43
+ logHookDecision('auto-commit', 'git-commit', 'block', reason);
44
+ process.stderr.write(`[auto-commit] SKIP: ${reason}\n`);
45
+ process.exit(0);
46
+ }
47
+
22
48
  const PROTECTED_BRANCHES = ['main', 'master', 'develop', 'production'];
23
49
  const MAX_FILES_IN_MSG = 5;
24
50
  const MAX_CHECKPOINTS = 5;
@@ -4,19 +4,33 @@
4
4
  * 프로젝트에 설치된 포매터를 감지하고 수정된 파일에 자동 실행.
5
5
  * Prettier(JS/TS), Black(Python), gofmt(Go) 지원.
6
6
  * 200ms 이내 완료 목표 — 단일 파일만 처리.
7
+ *
8
+ * 변경 감지: mtime 비교로 prettier가 실제 파일을 수정했는지 판단.
9
+ * 수정된 경우 finding을 반환 — 디스패처가 additionalContext에 포함시킨다.
7
10
  */
8
- import { execSync } from 'child_process';
9
- import { existsSync } from 'fs';
11
+ import { execFile } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import { existsSync, statSync } from 'fs';
10
14
  import path from 'path';
11
15
  import { PROJECT_DIR } from './utils.js';
16
+ import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
17
+
18
+ // WHY async execFile (not execSync): in-process 디스패처에서 다른 step과
19
+ // Promise.all로 병렬 실행되므로, 동기 실행은 이벤트 루프를 막아 체인을 직렬화시킨다.
20
+ const execFileAsync = promisify(execFile);
12
21
 
13
22
  const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|css|scss|json|md|html|vue|svelte)$/;
14
23
  const PYTHON_EXT_RE = /\.py$/;
15
24
  const GO_EXT_RE = /\.go$/;
25
+ const FORMAT_TIMEOUT_MS = 5000;
16
26
 
17
- function getFilePath() {
18
- const input = JSON.parse(process.env.TOOL_INPUT || '{}');
19
- return input.file_path || input.path || '';
27
+ function getFilePath(ctx) {
28
+ try {
29
+ const input = JSON.parse(ctx.toolInput || '{}');
30
+ return input.file_path || input.path || '';
31
+ } catch {
32
+ return '';
33
+ }
20
34
  }
21
35
 
22
36
  // PATH 직접 스캔 — `which` execSync는 매 파일 저장마다 자식 프로세스를 동기
@@ -37,33 +51,84 @@ function hasPrettier() {
37
51
  return existsSync(path.join(PROJECT_DIR, 'node_modules', '.bin', 'prettier'));
38
52
  }
39
53
 
40
- function formatFile(filePath) {
54
+ /**
55
+ * mtimeMs 읽기 — stat 실패 시 0 반환 (fail-open).
56
+ * @param {string} resolvedPath
57
+ * @returns {number}
58
+ */
59
+ function getMtime(resolvedPath) {
60
+ try {
61
+ return statSync(resolvedPath).mtimeMs;
62
+ } catch {
63
+ return 0;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 파일 포맷 실행. 실제 변경이 발생했으면 finding 문자열을 반환.
69
+ * @param {string} filePath
70
+ * @returns {Promise<string|null>} finding or null
71
+ */
72
+ async function formatFile(filePath) {
41
73
  const resolved = path.resolve(filePath);
42
- if (!existsSync(resolved)) return;
74
+ if (!existsSync(resolved)) return null;
43
75
 
44
76
  try {
45
77
  if (CODE_EXT_RE.test(filePath) && hasPrettier()) {
46
- execSync(`npx prettier --write "${resolved}"`, {
78
+ const mtimeBefore = getMtime(resolved);
79
+ await execFileAsync('npx', ['prettier', '--write', resolved], {
47
80
  cwd: PROJECT_DIR,
48
- stdio: 'ignore',
49
- timeout: 5000,
81
+ timeout: FORMAT_TIMEOUT_MS,
82
+ // Windows에서 npx는 npx.cmd — shell 없이는 execFile이 찾지 못함
83
+ shell: process.platform === 'win32',
50
84
  });
51
- console.log(`[AUTO-FORMAT] prettier: ${path.basename(resolved)}`);
85
+ const mtimeAfter = getMtime(resolved);
86
+ if (mtimeAfter > mtimeBefore) {
87
+ return `auto-format reformatted ${path.basename(resolved)} — re-read before further edits to avoid stale old_string`;
88
+ }
52
89
  } else if (PYTHON_EXT_RE.test(filePath) && hasBin('black')) {
53
- execSync(`black --quiet "${resolved}"`, { stdio: 'ignore', timeout: 5000 });
54
- console.log(`[AUTO-FORMAT] black: ${path.basename(resolved)}`);
90
+ const mtimeBefore = getMtime(resolved);
91
+ await execFileAsync('black', ['--quiet', resolved], { timeout: FORMAT_TIMEOUT_MS });
92
+ const mtimeAfter = getMtime(resolved);
93
+ if (mtimeAfter > mtimeBefore) {
94
+ return `auto-format reformatted ${path.basename(resolved)} — re-read before further edits to avoid stale old_string`;
95
+ }
55
96
  } else if (GO_EXT_RE.test(filePath) && hasBin('gofmt')) {
56
- execSync(`gofmt -w "${resolved}"`, { stdio: 'ignore', timeout: 5000 });
57
- console.log(`[AUTO-FORMAT] gofmt: ${path.basename(resolved)}`);
97
+ const mtimeBefore = getMtime(resolved);
98
+ await execFileAsync('gofmt', ['-w', resolved], { timeout: FORMAT_TIMEOUT_MS });
99
+ const mtimeAfter = getMtime(resolved);
100
+ if (mtimeAfter > mtimeBefore) {
101
+ return `auto-format reformatted ${path.basename(resolved)} — re-read before further edits to avoid stale old_string`;
102
+ }
58
103
  }
59
104
  } catch {
60
105
  // Format failure should never block — silently continue
61
106
  }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * in-process 진입점 — 포맷 실행. finding 문자열 배열 반환.
112
+ * @param {{ toolInput: string }} ctx
113
+ * @returns {Promise<{ exitCode: number, findings: string[] }>}
114
+ */
115
+ export async function run(ctx) {
116
+ const findings = [];
117
+ try {
118
+ const filePath = getFilePath(ctx);
119
+ if (filePath) {
120
+ const finding = await formatFile(filePath);
121
+ if (finding) findings.push(finding);
122
+ }
123
+ } catch {
124
+ // Silent fail
125
+ }
126
+ return { exitCode: 0, findings };
62
127
  }
63
128
 
64
- try {
65
- const filePath = getFilePath();
66
- if (filePath) formatFile(filePath);
67
- } catch {
68
- // Silent fail
129
+ // standalone CLI 모드
130
+ if (isDirectRun(import.meta.url)) {
131
+ const { exitCode, findings } = await run(buildCliCtx());
132
+ if (findings.length > 0) process.stdout.write(findings.join('\n') + '\n');
133
+ process.exit(exitCode);
69
134
  }