@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,215 @@
1
+ /**
2
+ * scope-from-spec.js 단위 테스트
3
+ *
4
+ * REQ-harness-remediation-009: scope-guard 기본 활성화
5
+ * - enabled 미설정(undefined) → sync 실행 (기본 ON)
6
+ * - enabled === false → skip (명시적 opt-out)
7
+ * - SPEC 없음 → scope.json 생성 안 함 (노이즈 가드)
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import os from 'os';
13
+ import { isScopeGuardEnabled, syncScopeFile, findActiveSpecs } from '../lib/scope-from-spec.js';
14
+
15
+ // 임시 프로젝트 디렉토리 헬퍼
16
+ function makeTempDir() {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-scope-test-'));
18
+ }
19
+
20
+ function cleanDir(dir) {
21
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
22
+ }
23
+
24
+ // config.json 작성 헬퍼
25
+ function writeConfig(dir, scopeGuard) {
26
+ const vibeDir = path.join(dir, '.vibe');
27
+ fs.mkdirSync(vibeDir, { recursive: true });
28
+ fs.writeFileSync(path.join(vibeDir, 'config.json'), JSON.stringify({ scopeGuard }));
29
+ }
30
+
31
+ // 최소 SPEC 작성 헬퍼
32
+ function writeSpec(dir, filename, content, status = 'in-progress') {
33
+ const specsDir = path.join(dir, '.vibe', 'specs');
34
+ fs.mkdirSync(specsDir, { recursive: true });
35
+ const frontmatter = status ? `---\nstatus: ${status}\n---\n` : '';
36
+ fs.writeFileSync(path.join(specsDir, filename), frontmatter + (content || ''));
37
+ }
38
+
39
+ // ══════════════════════════════════════════════════
40
+ // isScopeGuardEnabled — 기본 ON 동작
41
+ // ══════════════════════════════════════════════════
42
+ describe('isScopeGuardEnabled', () => {
43
+ let tmpDir;
44
+ beforeEach(() => { tmpDir = makeTempDir(); });
45
+ afterEach(() => { cleanDir(tmpDir); });
46
+
47
+ it('config 없음 → true (기본 ON)', () => {
48
+ expect(isScopeGuardEnabled(tmpDir)).toBe(true);
49
+ });
50
+
51
+ it('scopeGuard 키 없는 config → true', () => {
52
+ writeConfig(tmpDir, undefined);
53
+ expect(isScopeGuardEnabled(tmpDir)).toBe(true);
54
+ });
55
+
56
+ it('scopeGuard.enabled 없는 config → true', () => {
57
+ const vibeDir = path.join(tmpDir, '.vibe');
58
+ fs.mkdirSync(vibeDir, { recursive: true });
59
+ fs.writeFileSync(path.join(vibeDir, 'config.json'), JSON.stringify({ scopeGuard: {} }));
60
+ expect(isScopeGuardEnabled(tmpDir)).toBe(true);
61
+ });
62
+
63
+ it('scopeGuard.enabled=true → true', () => {
64
+ writeConfig(tmpDir, { enabled: true });
65
+ expect(isScopeGuardEnabled(tmpDir)).toBe(true);
66
+ });
67
+
68
+ it('scopeGuard.enabled=false → false (명시적 opt-out)', () => {
69
+ writeConfig(tmpDir, { enabled: false });
70
+ expect(isScopeGuardEnabled(tmpDir)).toBe(false);
71
+ });
72
+
73
+ it('legacy .claude/vibe/config.json에서도 enabled=false 인식', () => {
74
+ const legacyDir = path.join(tmpDir, '.claude', 'vibe');
75
+ fs.mkdirSync(legacyDir, { recursive: true });
76
+ fs.writeFileSync(path.join(legacyDir, 'config.json'), JSON.stringify({ scopeGuard: { enabled: false } }));
77
+ expect(isScopeGuardEnabled(tmpDir)).toBe(false);
78
+ });
79
+ });
80
+
81
+ // ══════════════════════════════════════════════════
82
+ // syncScopeFile — 노이즈 가드: SPEC 없으면 생성 안 함
83
+ // ══════════════════════════════════════════════════
84
+ describe('syncScopeFile — no specs → no scope created', () => {
85
+ let tmpDir;
86
+ beforeEach(() => { tmpDir = makeTempDir(); });
87
+ afterEach(() => { cleanDir(tmpDir); });
88
+
89
+ it('SPEC 없음 → skipped-no-specs, scope.json 미생성', () => {
90
+ fs.mkdirSync(path.join(tmpDir, '.vibe'), { recursive: true });
91
+ const result = syncScopeFile(tmpDir);
92
+ expect(result.action).toBe('skipped-no-specs');
93
+ expect(fs.existsSync(path.join(tmpDir, '.vibe', 'scope.json'))).toBe(false);
94
+ });
95
+
96
+ it('specs 디렉토리 없음 → skipped-no-specs', () => {
97
+ const result = syncScopeFile(tmpDir);
98
+ expect(result.action).toBe('skipped-no-specs');
99
+ });
100
+ });
101
+
102
+ // ══════════════════════════════════════════════════
103
+ // syncScopeFile — SPEC 있을 때 sync 실행
104
+ // ══════════════════════════════════════════════════
105
+ describe('syncScopeFile — spec exists → scope created', () => {
106
+ let tmpDir;
107
+ beforeEach(() => { tmpDir = makeTempDir(); });
108
+ afterEach(() => { cleanDir(tmpDir); });
109
+
110
+ // 파생 glob은 실존 디렉토리만 통과하므로, 참조 경로를 실제로 만들어 준다
111
+ function materialize(dir, relFile) {
112
+ const full = path.join(dir, relFile);
113
+ fs.mkdirSync(path.dirname(full), { recursive: true });
114
+ fs.writeFileSync(full, '');
115
+ }
116
+
117
+ it('활성 SPEC 있음 → scope.json 생성', () => {
118
+ writeSpec(tmpDir, 'feature.md', 'edit `src/cli/index.ts`');
119
+ materialize(tmpDir, 'src/cli/index.ts');
120
+ const result = syncScopeFile(tmpDir);
121
+ expect(result.action).toBe('created');
122
+ const scopeJson = path.join(tmpDir, '.vibe', 'scope.json');
123
+ expect(fs.existsSync(scopeJson)).toBe(true);
124
+ const scope = JSON.parse(fs.readFileSync(scopeJson, 'utf-8'));
125
+ expect(scope.auto).toBe(true);
126
+ expect(Array.isArray(scope.allow)).toBe(true);
127
+ });
128
+
129
+ it('status 없는 최근 SPEC은 활성 간주 → scope.json 생성', () => {
130
+ writeSpec(tmpDir, 'no-status.md', 'modify `hooks/scripts/foo.js`', null);
131
+ materialize(tmpDir, 'hooks/scripts/foo.js');
132
+ const result = syncScopeFile(tmpDir);
133
+ expect(result.action).toBe('created');
134
+ });
135
+
136
+ it('status 없는 오래된 SPEC(>14일)은 비활성 → skipped-no-specs', () => {
137
+ writeSpec(tmpDir, 'stale.md', 'modify `hooks/scripts/foo.js`', null);
138
+ materialize(tmpDir, 'hooks/scripts/foo.js');
139
+ const old = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000);
140
+ fs.utimesSync(path.join(tmpDir, '.vibe', 'specs', 'stale.md'), old, old);
141
+ const result = syncScopeFile(tmpDir);
142
+ expect(result.action).toBe('skipped-no-specs');
143
+ });
144
+
145
+ it('활성 SPEC에 산출 가능한 경로 없음 → skipped-no-paths, scope.json 미생성', () => {
146
+ writeSpec(tmpDir, 'no-paths.md', 'just prose, `nonexistent/dir/file` reference only');
147
+ const result = syncScopeFile(tmpDir);
148
+ expect(result.action).toBe('skipped-no-paths');
149
+ expect(fs.existsSync(path.join(tmpDir, '.vibe', 'scope.json'))).toBe(false);
150
+ });
151
+
152
+ it('경로 산출 불가로 전환되면 기존 auto scope.json 제거', () => {
153
+ writeSpec(tmpDir, 'feature.md', 'edit `src/cli/index.ts`');
154
+ materialize(tmpDir, 'src/cli/index.ts');
155
+ syncScopeFile(tmpDir); // 생성
156
+ fs.rmSync(path.join(tmpDir, 'src'), { recursive: true, force: true });
157
+ const result = syncScopeFile(tmpDir);
158
+ expect(result.action).toBe('removed');
159
+ expect(fs.existsSync(path.join(tmpDir, '.vibe', 'scope.json'))).toBe(false);
160
+ });
161
+
162
+ it('completed SPEC만 있을 때 → skipped-no-specs (비활성 SPEC은 제외)', () => {
163
+ writeSpec(tmpDir, 'done.md', 'edit `src/foo.ts`', 'completed');
164
+ const result = syncScopeFile(tmpDir);
165
+ expect(result.action).toBe('skipped-no-specs');
166
+ });
167
+
168
+ it('수동 관리 scope.json(auto:false) → 건드리지 않음', () => {
169
+ writeSpec(tmpDir, 'feature.md', 'edit `src/bar.ts`');
170
+ const scopeDir = path.join(tmpDir, '.vibe');
171
+ fs.mkdirSync(scopeDir, { recursive: true });
172
+ const manual = { auto: false, mode: 'block', allow: ['src/**'], deny: [] };
173
+ fs.writeFileSync(path.join(scopeDir, 'scope.json'), JSON.stringify(manual));
174
+ const result = syncScopeFile(tmpDir);
175
+ expect(result.action).toBe('skipped-manual');
176
+ const existing = JSON.parse(fs.readFileSync(path.join(scopeDir, 'scope.json'), 'utf-8'));
177
+ expect(existing.mode).toBe('block'); // 원본 유지
178
+ });
179
+
180
+ it('활성 SPEC 제거 후 재호출 → auto scope.json 삭제', () => {
181
+ writeSpec(tmpDir, 'feature.md', 'edit `src/cli/index.ts`');
182
+ materialize(tmpDir, 'src/cli/index.ts');
183
+ syncScopeFile(tmpDir); // 생성
184
+ // specs 디렉토리 비우기
185
+ fs.rmSync(path.join(tmpDir, '.vibe', 'specs'), { recursive: true, force: true });
186
+ const result = syncScopeFile(tmpDir);
187
+ expect(result.action).toBe('removed');
188
+ expect(fs.existsSync(path.join(tmpDir, '.vibe', 'scope.json'))).toBe(false);
189
+ });
190
+ });
191
+
192
+ // ══════════════════════════════════════════════════
193
+ // findActiveSpecs — legacy .claude/vibe/specs 폴백
194
+ // ══════════════════════════════════════════════════
195
+ describe('findActiveSpecs — resolution order', () => {
196
+ let tmpDir;
197
+ beforeEach(() => { tmpDir = makeTempDir(); });
198
+ afterEach(() => { cleanDir(tmpDir); });
199
+
200
+ it('.vibe/specs 우선', () => {
201
+ writeSpec(tmpDir, 'main.md', 'content');
202
+ const specs = findActiveSpecs(tmpDir);
203
+ expect(specs.length).toBe(1);
204
+ expect(specs[0]).toContain('.vibe');
205
+ });
206
+
207
+ it('.vibe 없고 .claude/vibe/specs 있으면 legacy 사용', () => {
208
+ const legacySpecsDir = path.join(tmpDir, '.claude', 'vibe', 'specs');
209
+ fs.mkdirSync(legacySpecsDir, { recursive: true });
210
+ fs.writeFileSync(path.join(legacySpecsDir, 'old.md'), '---\nstatus: in-progress\n---\ncontent');
211
+ const specs = findActiveSpecs(tmpDir);
212
+ expect(specs.length).toBe(1);
213
+ expect(specs[0]).toContain('.claude');
214
+ });
215
+ });
@@ -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
  });