@su-record/vibe 2.13.0 → 2.14.1

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 (69) hide show
  1. package/CLAUDE.md +18 -15
  2. package/README.en.md +7 -5
  3. package/README.md +8 -6
  4. package/dist/cli/detect/matcher.d.ts +15 -0
  5. package/dist/cli/detect/matcher.d.ts.map +1 -0
  6. package/dist/cli/detect/matcher.js +278 -0
  7. package/dist/cli/detect/matcher.js.map +1 -0
  8. package/dist/cli/detect/signatures.d.ts +76 -0
  9. package/dist/cli/detect/signatures.d.ts.map +1 -0
  10. package/dist/cli/detect/signatures.js +175 -0
  11. package/dist/cli/detect/signatures.js.map +1 -0
  12. package/dist/cli/detect/workspace.d.ts +7 -0
  13. package/dist/cli/detect/workspace.d.ts.map +1 -0
  14. package/dist/cli/detect/workspace.js +112 -0
  15. package/dist/cli/detect/workspace.js.map +1 -0
  16. package/dist/cli/detect.characterization.test.d.ts +7 -0
  17. package/dist/cli/detect.characterization.test.d.ts.map +1 -0
  18. package/dist/cli/detect.characterization.test.js +294 -0
  19. package/dist/cli/detect.characterization.test.js.map +1 -0
  20. package/dist/cli/detect.d.ts.map +1 -1
  21. package/dist/cli/detect.js +64 -488
  22. package/dist/cli/detect.js.map +1 -1
  23. package/dist/cli/postinstall/constants.d.ts.map +1 -1
  24. package/dist/cli/postinstall/constants.js +1 -0
  25. package/dist/cli/postinstall/constants.js.map +1 -1
  26. package/dist/cli/setup/ProjectSetup.d.ts.map +1 -1
  27. package/dist/cli/setup/ProjectSetup.js +5 -4
  28. package/dist/cli/setup/ProjectSetup.js.map +1 -1
  29. package/dist/infra/lib/ui-ux/CsvDataLoader.d.ts +10 -1
  30. package/dist/infra/lib/ui-ux/CsvDataLoader.d.ts.map +1 -1
  31. package/dist/infra/lib/ui-ux/CsvDataLoader.js +11 -5
  32. package/dist/infra/lib/ui-ux/CsvDataLoader.js.map +1 -1
  33. package/dist/infra/lib/ui-ux/CsvDataLoader.test.js +8 -8
  34. package/dist/infra/lib/ui-ux/CsvDataLoader.test.js.map +1 -1
  35. package/dist/infra/lib/ui-ux/SearchService.test.js +1 -1
  36. package/dist/infra/lib/ui-ux/SearchService.test.js.map +1 -1
  37. package/dist/tools/index.d.ts +2 -0
  38. package/dist/tools/index.d.ts.map +1 -1
  39. package/dist/tools/index.js +2 -0
  40. package/dist/tools/index.js.map +1 -1
  41. package/dist/tools/loop/index.d.ts +6 -0
  42. package/dist/tools/loop/index.d.ts.map +1 -0
  43. package/dist/tools/loop/index.js +5 -0
  44. package/dist/tools/loop/index.js.map +1 -0
  45. package/dist/tools/loop/validateLoopDefinition.d.ts +38 -0
  46. package/dist/tools/loop/validateLoopDefinition.d.ts.map +1 -0
  47. package/dist/tools/loop/validateLoopDefinition.js +224 -0
  48. package/dist/tools/loop/validateLoopDefinition.js.map +1 -0
  49. package/dist/tools/loop/validateLoopDefinition.test.d.ts +14 -0
  50. package/dist/tools/loop/validateLoopDefinition.test.d.ts.map +1 -0
  51. package/dist/tools/loop/validateLoopDefinition.test.js +229 -0
  52. package/dist/tools/loop/validateLoopDefinition.test.js.map +1 -0
  53. package/hooks/scripts/__tests__/.vibe/command-log.txt +39 -0
  54. package/hooks/scripts/__tests__/.vibe/memories/memories.db-shm +0 -0
  55. package/hooks/scripts/__tests__/.vibe/memories/memories.db-wal +0 -0
  56. package/hooks/scripts/__tests__/keyword-detector.test.js +26 -18
  57. package/hooks/scripts/__tests__/loop-ledger.test.js +321 -0
  58. package/hooks/scripts/keyword-detector.js +22 -22
  59. package/hooks/scripts/lib/hook-context.js +31 -2
  60. package/hooks/scripts/lib/loop-ledger.js +118 -0
  61. package/hooks/scripts/loop-ledger.js +56 -0
  62. package/package.json +3 -2
  63. package/skills/vibe/SKILL.md +40 -23
  64. package/skills/vibe.loop/SKILL.md +116 -0
  65. package/skills/vibe.run/SKILL.md +22 -18
  66. package/skills/vibe.run/references/ralph-loop.md +18 -17
  67. package/skills/vibe.run/references/ultrawork-mode.md +36 -33
  68. package/vibe/rules/loop-contract.md +54 -0
  69. package/vibe/templates/loop-template.md +69 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * loop-ledger 라이브러리 + CLI 테스트
3
+ *
4
+ * 커버리지:
5
+ * - appendLoopEvent round-trip
6
+ * - isStuck: 2회 연속 동일 hash → true
7
+ * - isStuck: 다른 hash → false
8
+ * - isStuck: 이력 없음 → false
9
+ * - fail-open: 손상된 jsonl 줄 무시
10
+ * - hashDiscoverOutput: 공백 정규화
11
+ * - CLI: start / end / check-stuck 서브커맨드
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import os from 'os';
18
+ import { spawnSync } from 'child_process';
19
+ import { fileURLToPath } from 'url';
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+
23
+ let tmpDir;
24
+ beforeEach(() => {
25
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-loop-ledger-test-'));
26
+ });
27
+ afterEach(() => {
28
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
29
+ });
30
+
31
+ async function importLedger() {
32
+ const p = path.resolve(__dirname, '..', 'lib', 'loop-ledger.js');
33
+ return import(p);
34
+ }
35
+
36
+ const CLI = path.resolve(__dirname, '..', 'loop-ledger.js');
37
+
38
+ function historyPath(dir) {
39
+ return path.join(dir, '.vibe', 'metrics', 'loop-history.jsonl');
40
+ }
41
+
42
+ // ─── appendLoopEvent round-trip ───────────────────────────────────────
43
+
44
+ describe('loop-ledger: appendLoopEvent round-trip', () => {
45
+ it('start 이벤트를 append 하면 jsonl 파일에 기록된다', async () => {
46
+ const { appendLoopEvent } = await importLedger();
47
+ appendLoopEvent(tmpDir, { loop: 'nightly', event: 'start' });
48
+
49
+ const p = historyPath(tmpDir);
50
+ expect(fs.existsSync(p)).toBe(true);
51
+ const line = fs.readFileSync(p, 'utf-8').trim();
52
+ const obj = JSON.parse(line);
53
+ expect(obj.loop).toBe('nightly');
54
+ expect(obj.event).toBe('start');
55
+ expect(obj.ts).toBeDefined();
56
+ });
57
+
58
+ it('end 이벤트에 result, summary, discoverHash가 포함된다', async () => {
59
+ const { appendLoopEvent } = await importLedger();
60
+ appendLoopEvent(tmpDir, {
61
+ loop: 'nightly',
62
+ event: 'end',
63
+ result: 'ok',
64
+ summary: '3 items processed',
65
+ discoverHash: 'abc123',
66
+ });
67
+
68
+ const p = historyPath(tmpDir);
69
+ const line = fs.readFileSync(p, 'utf-8').trim();
70
+ const obj = JSON.parse(line);
71
+ expect(obj.event).toBe('end');
72
+ expect(obj.result).toBe('ok');
73
+ expect(obj.summary).toBe('3 items processed');
74
+ expect(obj.discoverHash).toBe('abc123');
75
+ });
76
+
77
+ it('여러 이벤트가 순서대로 append 된다', async () => {
78
+ const { appendLoopEvent } = await importLedger();
79
+ appendLoopEvent(tmpDir, { loop: 'loop-a', event: 'start' });
80
+ appendLoopEvent(tmpDir, { loop: 'loop-a', event: 'end', result: 'ok' });
81
+ appendLoopEvent(tmpDir, { loop: 'loop-b', event: 'start' });
82
+
83
+ const p = historyPath(tmpDir);
84
+ const lines = fs.readFileSync(p, 'utf-8').trim().split('\n');
85
+ expect(lines).toHaveLength(3);
86
+ expect(JSON.parse(lines[0]).loop).toBe('loop-a');
87
+ expect(JSON.parse(lines[1]).event).toBe('end');
88
+ expect(JSON.parse(lines[2]).loop).toBe('loop-b');
89
+ });
90
+
91
+ it('appendLoopEvent는 true를 반환한다', async () => {
92
+ const { appendLoopEvent } = await importLedger();
93
+ const result = appendLoopEvent(tmpDir, { loop: 'x', event: 'start' });
94
+ expect(result).toBe(true);
95
+ });
96
+ });
97
+
98
+ // ─── isStuck ──────────────────────────────────────────────────────────
99
+
100
+ describe('loop-ledger: isStuck', () => {
101
+ it('이력 없으면 false', async () => {
102
+ const { isStuck } = await importLedger();
103
+ expect(isStuck(tmpDir, 'nightly', 'hash-abc')).toBe(false);
104
+ });
105
+
106
+ it('직전 discover 이벤트와 동일 hash → true (2회 연속 성립)', async () => {
107
+ const { appendLoopEvent, isStuck } = await importLedger();
108
+ // 이전 반복의 discover 이벤트 (같은 hash)
109
+ appendLoopEvent(tmpDir, {
110
+ loop: 'nightly',
111
+ event: 'discover',
112
+ discoverHash: 'hash-same',
113
+ });
114
+ // 신규 반복이 같은 hash를 갖고 오면 stuck
115
+ expect(isStuck(tmpDir, 'nightly', 'hash-same')).toBe(true);
116
+ });
117
+
118
+ it('직전 discover 이벤트와 다른 hash → false', async () => {
119
+ const { appendLoopEvent, isStuck } = await importLedger();
120
+ appendLoopEvent(tmpDir, {
121
+ loop: 'nightly',
122
+ event: 'discover',
123
+ discoverHash: 'hash-old',
124
+ });
125
+ expect(isStuck(tmpDir, 'nightly', 'hash-new')).toBe(false);
126
+ });
127
+
128
+ it('end 이벤트의 hash는 비교 대상이 아님 (discover만 본다)', async () => {
129
+ const { appendLoopEvent, isStuck } = await importLedger();
130
+ appendLoopEvent(tmpDir, {
131
+ loop: 'nightly',
132
+ event: 'end',
133
+ result: 'ok',
134
+ discoverHash: 'hash-same',
135
+ });
136
+ expect(isStuck(tmpDir, 'nightly', 'hash-same')).toBe(false);
137
+ });
138
+
139
+ it('다른 루프 이름의 이벤트는 영향 안 줌', async () => {
140
+ const { appendLoopEvent, isStuck } = await importLedger();
141
+ appendLoopEvent(tmpDir, {
142
+ loop: 'other-loop',
143
+ event: 'discover',
144
+ discoverHash: 'hash-same',
145
+ });
146
+ // 'nightly' 에 대한 이력 없음 → false
147
+ expect(isStuck(tmpDir, 'nightly', 'hash-same')).toBe(false);
148
+ });
149
+
150
+ it('start 이벤트만 있고 discover 없으면 false', async () => {
151
+ const { appendLoopEvent, isStuck } = await importLedger();
152
+ appendLoopEvent(tmpDir, { loop: 'nightly', event: 'start' });
153
+ expect(isStuck(tmpDir, 'nightly', 'hash-abc')).toBe(false);
154
+ });
155
+
156
+ it('discoverHash 가 빈 문자열이면 false', async () => {
157
+ const { appendLoopEvent, isStuck } = await importLedger();
158
+ appendLoopEvent(tmpDir, {
159
+ loop: 'nightly',
160
+ event: 'discover',
161
+ discoverHash: '',
162
+ });
163
+ expect(isStuck(tmpDir, 'nightly', '')).toBe(false);
164
+ });
165
+ });
166
+
167
+ // ─── fail-open: 손상된 jsonl 줄 ──────────────────────────────────────
168
+
169
+ describe('loop-ledger: fail-open on corrupt jsonl', () => {
170
+ it('손상된 줄이 있어도 isStuck이 충돌하지 않고 false 반환', async () => {
171
+ const { appendLoopEvent, isStuck } = await importLedger();
172
+
173
+ // 정상 줄 + 손상 줄 + 정상 줄 혼합
174
+ const p = historyPath(tmpDir);
175
+ fs.mkdirSync(path.dirname(p), { recursive: true });
176
+ fs.writeFileSync(
177
+ p,
178
+ [
179
+ JSON.stringify({ ts: '2026-01-01T00:00:00.000Z', loop: 'nightly', event: 'discover', discoverHash: 'abc' }),
180
+ 'CORRUPTED LINE {{{',
181
+ JSON.stringify({ ts: '2026-01-01T01:00:00.000Z', loop: 'nightly', event: 'discover', discoverHash: 'xyz' }),
182
+ ].join('\n') + '\n',
183
+ 'utf-8'
184
+ );
185
+
186
+ // 손상 줄은 건너뛰고, 가장 최근 discover 이벤트(최신)의 hash는 'xyz'
187
+ expect(isStuck(tmpDir, 'nightly', 'xyz')).toBe(true);
188
+ expect(isStuck(tmpDir, 'nightly', 'abc')).toBe(false);
189
+ });
190
+
191
+ it('appendLoopEvent: 전체 파일이 corrupt 여도 추가 후 정상 동작', async () => {
192
+ const { appendLoopEvent } = await importLedger();
193
+ const p = historyPath(tmpDir);
194
+ fs.mkdirSync(path.dirname(p), { recursive: true });
195
+ fs.writeFileSync(p, 'NOT JSON\n', 'utf-8');
196
+
197
+ const ok = appendLoopEvent(tmpDir, { loop: 'x', event: 'start' });
198
+ expect(ok).toBe(true);
199
+ const lines = fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean);
200
+ // 손상 줄 + 새로 추가된 정상 줄
201
+ expect(lines.length).toBeGreaterThanOrEqual(2);
202
+ const last = JSON.parse(lines[lines.length - 1]);
203
+ expect(last.event).toBe('start');
204
+ });
205
+ });
206
+
207
+ // ─── hashDiscoverOutput ───────────────────────────────────────────────
208
+
209
+ describe('loop-ledger: hashDiscoverOutput', () => {
210
+ it('동일 텍스트는 동일 해시를 반환한다', async () => {
211
+ const { hashDiscoverOutput } = await importLedger();
212
+ const h1 = hashDiscoverOutput('hello world');
213
+ const h2 = hashDiscoverOutput('hello world');
214
+ expect(h1).toBe(h2);
215
+ });
216
+
217
+ it('공백 정규화: 여분 공백/줄바꿈이 달라도 동일 해시', async () => {
218
+ const { hashDiscoverOutput } = await importLedger();
219
+ const h1 = hashDiscoverOutput('item a\nitem b');
220
+ const h2 = hashDiscoverOutput('item a item b');
221
+ const h3 = hashDiscoverOutput(' item a item b ');
222
+ expect(h1).toBe(h2);
223
+ expect(h2).toBe(h3);
224
+ });
225
+
226
+ it('다른 텍스트는 다른 해시', async () => {
227
+ const { hashDiscoverOutput } = await importLedger();
228
+ expect(hashDiscoverOutput('abc')).not.toBe(hashDiscoverOutput('xyz'));
229
+ });
230
+
231
+ it('sha256 hex 64자 반환', async () => {
232
+ const { hashDiscoverOutput } = await importLedger();
233
+ expect(hashDiscoverOutput('test')).toMatch(/^[0-9a-f]{64}$/);
234
+ });
235
+ });
236
+
237
+ // ─── CLI: start / end / check-stuck ─────────────────────────────────
238
+
239
+ describe('loop-ledger CLI', () => {
240
+ it('start → exit 0, 확인 메시지 출력, jsonl 생성', () => {
241
+ const result = spawnSync('node', [CLI, 'start', 'nightly'], {
242
+ encoding: 'utf-8',
243
+ timeout: 5000,
244
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
245
+ });
246
+ expect(result.status).toBe(0);
247
+ expect(result.stdout).toContain('start recorded');
248
+ expect(result.stdout).toContain('nightly');
249
+ expect(fs.existsSync(historyPath(tmpDir))).toBe(true);
250
+ });
251
+
252
+ it('end → exit 0, jsonl에 end 이벤트 기록', () => {
253
+ const result = spawnSync('node', [CLI, 'end', 'nightly', 'ok', 'summary text'], {
254
+ encoding: 'utf-8',
255
+ timeout: 5000,
256
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
257
+ });
258
+ expect(result.status).toBe(0);
259
+ expect(result.stdout).toContain('end recorded');
260
+
261
+ const line = fs.readFileSync(historyPath(tmpDir), 'utf-8').trim();
262
+ const obj = JSON.parse(line);
263
+ expect(obj.event).toBe('end');
264
+ expect(obj.result).toBe('ok');
265
+ expect(obj.summary).toBe('summary text');
266
+ });
267
+
268
+ it('check-stuck: 직전 discover hash와 동일 → stdout "stuck"', async () => {
269
+ const { appendLoopEvent } = await importLedger();
270
+ appendLoopEvent(tmpDir, {
271
+ loop: 'nightly',
272
+ event: 'discover',
273
+ discoverHash: 'deadbeef',
274
+ });
275
+
276
+ const result = spawnSync('node', [CLI, 'check-stuck', 'nightly', 'deadbeef'], {
277
+ encoding: 'utf-8',
278
+ timeout: 5000,
279
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
280
+ });
281
+ expect(result.status).toBe(0);
282
+ expect(result.stdout.trim()).toBe('stuck');
283
+ });
284
+
285
+ it('check-stuck: 새 hash → stdout "ok"', async () => {
286
+ const { appendLoopEvent } = await importLedger();
287
+ appendLoopEvent(tmpDir, {
288
+ loop: 'nightly',
289
+ event: 'end',
290
+ result: 'ok',
291
+ discoverHash: 'old-hash',
292
+ });
293
+
294
+ const result = spawnSync('node', [CLI, 'check-stuck', 'nightly', 'new-hash'], {
295
+ encoding: 'utf-8',
296
+ timeout: 5000,
297
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
298
+ });
299
+ expect(result.status).toBe(0);
300
+ expect(result.stdout.trim()).toBe('ok');
301
+ });
302
+
303
+ it('check-stuck: 이력 없으면 "ok"', () => {
304
+ const result = spawnSync('node', [CLI, 'check-stuck', 'nightly', 'anyhash'], {
305
+ encoding: 'utf-8',
306
+ timeout: 5000,
307
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
308
+ });
309
+ expect(result.status).toBe(0);
310
+ expect(result.stdout.trim()).toBe('ok');
311
+ });
312
+
313
+ it('인수 없이 실행해도 exit 0 (fail-open)', () => {
314
+ const result = spawnSync('node', [CLI], {
315
+ encoding: 'utf-8',
316
+ timeout: 5000,
317
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
318
+ });
319
+ expect(result.status).toBe(0);
320
+ });
321
+ });
@@ -8,20 +8,20 @@ import { VIBE_PATH, PROJECT_DIR } from './utils.js';
8
8
 
9
9
  // 매직 키워드 정의
10
10
  const MAGIC_KEYWORDS = {
11
- // 지속성 모드 (완료까지 계속)
11
+ // Deprecated: 기본 루프 동작과 동일 (no-op). exit=coverage-100으로 해석.
12
12
  ralph: {
13
- name: 'Ralph Loop',
14
- description: 'Continue until task is verified complete',
13
+ name: 'Ralph (deprecated alias)',
14
+ description: '[deprecated] Looping to convergence is the default; alias mapped',
15
15
  flags: ['persistence', 'verification'],
16
- output: '[RALPH MODE] Self-referential completion loop activated. Will continue until ALL tasks verified complete. NO early stopping.',
16
+ output: '[vibe] \'ralph\' is deprecated looping to convergence is the default; alias mapped.',
17
17
  },
18
18
 
19
- // 울트라워크 모드 (병렬 + 자동 계속)
19
+ // 울트라워크 모드 (automationLevel: autonomous + 병렬 ACT)
20
20
  ultrawork: {
21
21
  name: 'Ultrawork',
22
- description: 'Maximum parallel execution, no pause',
22
+ description: 'automationLevel: autonomous + parallel ACT (deprecated alias)',
23
23
  flags: ['parallel', 'auto_continue', 'no_confirmation'],
24
- output: '[ULTRAWORK MODE] Use PARALLEL Task calls. Auto-continue through ALL phases. Auto-retry on errors up to 3 times. Do NOT ask for confirmation between phases.',
24
+ output: '[ULTRAWORK] automationLevel: autonomous + parallel ACT. Loop runs to convergence; stuck auto-TODO (no confirmation).',
25
25
  },
26
26
  ulw: {
27
27
  alias: 'ultrawork',
@@ -47,12 +47,12 @@ const MAGIC_KEYWORDS = {
47
47
  output: '[RALPLAN MODE] Iterative planning with consensus. Will refine plan until approved, then execute with Ralph persistence.',
48
48
  },
49
49
 
50
- // 검증 모드
50
+ // Deprecated: 기본 JUDGE는 항상 결정론 검증 (no-op)
51
51
  verify: {
52
- name: 'Verify Mode',
53
- description: 'Strict verification after each step',
52
+ name: 'Verify (deprecated alias)',
53
+ description: '[deprecated] Deterministic verification is the default; alias mapped',
54
54
  flags: ['verification', 'strict'],
55
- output: '[VERIFY MODE] Strict verification enabled. Every change must be verified before proceeding.',
55
+ output: '[vibe] \'verify\' is deprecated deterministic JUDGE is the default; alias mapped.',
56
56
  strict: true, // 일상어 ("please verify the fix" 오탐 방지)
57
57
  },
58
58
 
@@ -65,29 +65,29 @@ const MAGIC_KEYWORDS = {
65
65
  strict: true, // 일상어 ("let me explore the options" 오탐 방지)
66
66
  },
67
67
 
68
- // 빠른 모드
68
+ // Deprecated: --max-iter 1 매핑
69
69
  quick: {
70
- name: 'Quick Mode',
71
- description: 'Fast execution, minimal verification',
70
+ name: 'Quick (deprecated alias)',
71
+ description: '[deprecated] Maps to --max-iter 1; use --max-iter 1 explicitly',
72
72
  flags: ['fast', 'minimal_verification'],
73
- output: '[QUICK MODE] Fast execution mode. Minimal verification, single round reviews.',
73
+ output: '[vibe] \'quick\' maps to --max-iter 1 (single-pass, minimal JUDGE).',
74
74
  strict: true, // 일상어 ("quick question on auth" 오탐 방지)
75
75
  },
76
76
  };
77
77
 
78
- // 키워드 조합 시너지
78
+ // 키워드 조합 시너지 (deprecated alias 조합도 매핑 유지)
79
79
  const KEYWORD_SYNERGIES = {
80
80
  'ralph+ultrawork': {
81
- name: 'Ralph Ultrawork',
82
- output: '[RALPH+ULTRAWORK] Maximum persistence AND parallel execution. Will NOT stop until ALL phases complete with verification.',
81
+ name: 'Ralph+Ultrawork (deprecated)',
82
+ output: '[vibe] \'ralph\'+\'ultrawork\' deprecated: automationLevel: autonomous + parallel ACT, exit=coverage-100.',
83
83
  },
84
84
  'ralph+verify': {
85
- name: 'Ralph Verify',
86
- output: '[RALPH+VERIFY] Persistent completion with strict verification at each step.',
85
+ name: 'Ralph+Verify (deprecated)',
86
+ output: '[vibe] \'ralph\'+\'verify\' deprecated: both are default behavior; alias mapped.',
87
87
  },
88
88
  'ultrawork+explore': {
89
- name: 'Ultrawork Explore',
90
- output: '[ULTRAWORK+EXPLORE] Parallel exploration agents for maximum coverage.',
89
+ name: 'Ultrawork+Explore',
90
+ output: '[ULTRAWORK+EXPLORE] automationLevel: autonomous + parallel exploration agents.',
91
91
  },
92
92
  };
93
93
 
@@ -19,6 +19,26 @@ import { pathToFileURL } from 'url';
19
19
  /** stdin을 EOF까지 읽을 때 허용하는 최대 바이트 수 (10MB). */
20
20
  const STDIN_MAX_BYTES = 10 * 1024 * 1024;
21
21
 
22
+ /** EAGAIN 재시도 간격/데드라인 — non-blocking pipe에서 writer가 아직 flush 전일 수 있다. */
23
+ const STDIN_EAGAIN_RETRY_MS = 5;
24
+ const STDIN_EAGAIN_DEADLINE_MS = 500;
25
+
26
+ /** 동기 sleep (Atomics.wait — 이벤트 루프 없이 대기). */
27
+ const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
28
+ function sleepSync(ms) {
29
+ Atomics.wait(sleepBuf, 0, 0, ms);
30
+ }
31
+
32
+ /** 누적 청크가 완전한 JSON인지 검사 — EAGAIN 시 조기 종료 판단용. */
33
+ function isCompleteJson(chunks) {
34
+ try {
35
+ JSON.parse(Buffer.concat(chunks).toString('utf-8'));
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
22
42
  /**
23
43
  * stdin에서 JSON 페이로드 동기 읽기 (Claude Code 하네스 호환).
24
44
  * fd 0 직접 사용 — Windows는 '/dev/stdin'이 없음.
@@ -37,16 +57,25 @@ export function readStdinSync() {
37
57
  const chunkSize = 65536;
38
58
  const chunkBuf = Buffer.alloc(chunkSize);
39
59
 
60
+ let lastProgress = Date.now();
40
61
  while (true) {
41
62
  let bytesRead;
42
63
  try {
43
64
  bytesRead = fs.readSync(0, chunkBuf, 0, chunkSize, null);
44
65
  } catch (err) {
45
- // EAGAIN: non-blocking stdin에 데이터 없음 루프 종료
46
- if (err.code === 'EAGAIN') break;
66
+ // EAGAIN: 파이프가 *지금* 비었을 EOF가 아닐 수 있다 (writer가 flush 전).
67
+ // EOF로 취급하면 대용량 페이로드가 중간에 끊겨 가드가 fail-open된다 —
68
+ // 완전한 JSON이 모였거나 데드라인이 지나기 전까지 재시도한다.
69
+ if (err.code === 'EAGAIN') {
70
+ if (chunks.length > 0 && isCompleteJson(chunks)) break;
71
+ if (Date.now() - lastProgress > STDIN_EAGAIN_DEADLINE_MS) break;
72
+ sleepSync(STDIN_EAGAIN_RETRY_MS);
73
+ continue;
74
+ }
47
75
  throw err;
48
76
  }
49
77
  if (bytesRead === 0) break;
78
+ lastProgress = Date.now();
50
79
  totalBytes += bytesRead;
51
80
  if (totalBytes > STDIN_MAX_BYTES) {
52
81
  truncated = true;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Loop Ledger 라이브러리 — 루프 실행 이력 추적 및 stuck 감지.
3
+ *
4
+ * 파일 위치: <projectDir>/.vibe/metrics/loop-history.jsonl
5
+ * 형식: JSON Lines — 각 줄이 독립적인 루프 이벤트 JSON 객체
6
+ *
7
+ * 모든 함수는 fail-open (try/catch, 오류 시 무시하거나 안전한 기본값 반환).
8
+ * isStuck: 같은 루프의 가장 최근 discover 이벤트의 discoverHash가
9
+ * 신규 hash와 같으면 stuck으로 판정한다 (2회 연속 동일 발견).
10
+ * discover 이벤트는 CLI check-stuck이 판정 직후 스스로 기록한다 —
11
+ * 기록 없는 판정은 다음 실행의 비교 기준을 잃는다.
12
+ */
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import crypto from 'crypto';
17
+
18
+ /** 루프 이력 파일 경로 */
19
+ function historyPath(projectDir) {
20
+ return path.join(projectDir, '.vibe', 'metrics', 'loop-history.jsonl');
21
+ }
22
+
23
+ /**
24
+ * discover 산출물 텍스트를 sha256 hex 해시로 변환한다.
25
+ * 공백/줄바꿈을 정규화해 동등한 출력이 동일 해시를 갖도록 한다.
26
+ *
27
+ * @param {string} text
28
+ * @returns {string} sha256 hex
29
+ */
30
+ export function hashDiscoverOutput(text) {
31
+ const normalized = text.replace(/\s+/g, ' ').trim();
32
+ return crypto.createHash('sha256').update(normalized, 'utf-8').digest('hex');
33
+ }
34
+
35
+ /**
36
+ * 루프 이벤트를 jsonl 파일에 append한다.
37
+ *
38
+ * @param {string} projectDir
39
+ * @param {{ loop: string, event: 'start'|'discover'|'end', result?: 'ok'|'fail'|'stuck', summary?: string, discoverHash?: string }} opts
40
+ * @returns {boolean} 성공 여부
41
+ */
42
+ export function appendLoopEvent(projectDir, opts) {
43
+ try {
44
+ const p = historyPath(projectDir);
45
+ fs.mkdirSync(path.dirname(p), { recursive: true });
46
+ const entry = {
47
+ ts: new Date().toISOString(),
48
+ loop: opts.loop,
49
+ event: opts.event,
50
+ ...(opts.result !== undefined ? { result: opts.result } : {}),
51
+ ...(opts.summary !== undefined ? { summary: opts.summary } : {}),
52
+ ...(opts.discoverHash !== undefined ? { discoverHash: opts.discoverHash } : {}),
53
+ };
54
+ fs.appendFileSync(p, JSON.stringify(entry) + '\n', 'utf-8');
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 지정 루프의 특정 이벤트 목록을 최신순으로 읽는다.
63
+ * 손상된 줄은 건너뛴다 (fail-open).
64
+ *
65
+ * @param {string} projectDir
66
+ * @param {string} loop
67
+ * @param {string} eventType
68
+ * @returns {{ ts: string, discoverHash?: string }[]}
69
+ */
70
+ function readEventsOfType(projectDir, loop, eventType) {
71
+ try {
72
+ const p = historyPath(projectDir);
73
+ if (!fs.existsSync(p)) return [];
74
+ const lines = fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean);
75
+ const events = [];
76
+ for (const line of lines) {
77
+ try {
78
+ const obj = JSON.parse(line);
79
+ if (obj.loop === loop && obj.event === eventType) {
80
+ events.push(obj);
81
+ }
82
+ } catch {
83
+ // 손상된 줄 무시
84
+ }
85
+ }
86
+ // 최신순 정렬 (ts 기준)
87
+ return events.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 신규 discoverHash가 stuck 조건을 충족하는지 판정한다.
95
+ *
96
+ * stuck 조건: 신규 hash가 non-empty이고, 해당 루프의 가장 최근
97
+ * discover 이벤트에 동일한 discoverHash가 있을 때.
98
+ * (직전 실행의 발견 + 이번 발견 = 2회 연속 동일이 되는 시점)
99
+ *
100
+ * 주의: 판정만 하고 기록하지 않으면 다음 실행이 비교할 기준이 없다 —
101
+ * 호출자는 판정 직후 event:'discover'로 해시를 기록해야 한다 (CLI check-stuck이 수행).
102
+ *
103
+ * @param {string} projectDir
104
+ * @param {string} loop
105
+ * @param {string} discoverHash
106
+ * @returns {boolean}
107
+ */
108
+ export function isStuck(projectDir, loop, discoverHash) {
109
+ try {
110
+ if (!discoverHash) return false;
111
+ const discoverEvents = readEventsOfType(projectDir, loop, 'discover');
112
+ if (discoverEvents.length === 0) return false;
113
+ const lastHash = discoverEvents[0].discoverHash;
114
+ return Boolean(lastHash && lastHash === discoverHash);
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * loop-ledger CLI — 루프 실행 이력 기록 및 stuck 감지.
4
+ *
5
+ * 사용법:
6
+ * node hooks/scripts/loop-ledger.js start <name>
7
+ * node hooks/scripts/loop-ledger.js end <name> <ok|fail|stuck> [summary]
8
+ * node hooks/scripts/loop-ledger.js check-stuck <name> <discoverHash>
9
+ *
10
+ * check-stuck: 'stuck' 또는 'ok'를 stdout에 출력하고 항상 exit 0.
11
+ * 항상 exit 0 (fail-open).
12
+ */
13
+
14
+ import { appendLoopEvent, isStuck } from './lib/loop-ledger.js';
15
+
16
+ const [, , subcommand, ...args] = process.argv;
17
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
18
+
19
+ if (subcommand === 'start') {
20
+ const loop = args[0];
21
+ if (!loop) {
22
+ process.stdout.write('[loop-ledger] error: start 에 루프 이름이 필요합니다\n');
23
+ process.exit(0);
24
+ }
25
+ appendLoopEvent(projectDir, { loop, event: 'start' });
26
+ process.stdout.write(`[loop-ledger] start recorded: loop=${loop}\n`);
27
+
28
+ } else if (subcommand === 'end') {
29
+ const [loop, result, ...summaryParts] = args;
30
+ if (!loop || !result) {
31
+ process.stdout.write('[loop-ledger] error: end 에 루프 이름과 결과(ok|fail|stuck)가 필요합니다\n');
32
+ process.exit(0);
33
+ }
34
+ const summary = summaryParts.length > 0 ? summaryParts.join(' ') : undefined;
35
+ appendLoopEvent(projectDir, { loop, event: 'end', result, summary });
36
+ process.stdout.write(`[loop-ledger] end recorded: loop=${loop} result=${result}\n`);
37
+
38
+ } else if (subcommand === 'check-stuck') {
39
+ const [loop, discoverHash] = args;
40
+ if (!loop || !discoverHash) {
41
+ process.stdout.write('[loop-ledger] error: check-stuck 에 루프 이름과 discoverHash가 필요합니다\n');
42
+ process.stdout.write('ok\n');
43
+ process.exit(0);
44
+ }
45
+ const stuck = isStuck(projectDir, loop, discoverHash);
46
+ // 판정 직후 이번 발견 해시를 기록 — 다음 실행의 비교 기준이 된다
47
+ appendLoopEvent(projectDir, { loop, event: 'discover', discoverHash });
48
+ process.stdout.write(stuck ? 'stuck\n' : 'ok\n');
49
+
50
+ } else {
51
+ process.stdout.write(
52
+ '[loop-ledger] 사용법: start <name> | end <name> <ok|fail|stuck> [summary] | check-stuck <name> <hash>\n'
53
+ );
54
+ }
55
+
56
+ process.exit(0);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@su-record/vibe",
3
- "version": "2.13.0",
4
- "description": "AI Coding Framework for Claude Code — 60+ agents, 69 skills, multi-LLM orchestration",
3
+ "version": "2.14.1",
4
+ "description": "AI Coding Framework for Claude Code — 42+ agents, 70 skills, multi-LLM orchestration",
5
5
  "type": "module",
6
6
  "main": "dist/cli/index.js",
7
7
  "exports": {
@@ -28,6 +28,7 @@
28
28
  "gen:skill-docs": "npx tsx scripts/gen-skill-docs.ts",
29
29
  "gen:skill-docs:check": "npx tsx scripts/gen-skill-docs.ts --check",
30
30
  "validate:skill-invocation": "npx tsx scripts/validate-skill-invocation.ts",
31
+ "validate:counts": "npx tsx scripts/validate-counts.ts",
31
32
  "test": "vitest run",
32
33
  "test:watch": "vitest",
33
34
  "prepublishOnly": "pnpm build",