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