@su-record/vibe 3.2.12 → 3.2.14
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.
- package/CLAUDE.md +15 -14
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/dist/__tests__/engines-contract.test.d.ts +2 -0
- package/dist/__tests__/engines-contract.test.d.ts.map +1 -0
- package/dist/__tests__/engines-contract.test.js +72 -0
- package/dist/__tests__/engines-contract.test.js.map +1 -0
- package/dist/__tests__/instruction-drift.test.d.ts +2 -0
- package/dist/__tests__/instruction-drift.test.d.ts.map +1 -0
- package/dist/__tests__/instruction-drift.test.js +116 -0
- package/dist/__tests__/instruction-drift.test.js.map +1 -0
- package/dist/__tests__/stakes-contract.test.js +7 -0
- package/dist/__tests__/stakes-contract.test.js.map +1 -1
- package/dist/__tests__/stuck-semantics.test.js +52 -0
- package/dist/__tests__/stuck-semantics.test.js.map +1 -1
- package/dist/cli/commands/upgrade.d.ts.map +1 -1
- package/dist/cli/commands/upgrade.js +7 -7
- package/dist/cli/commands/upgrade.js.map +1 -1
- package/dist/cli/postinstall/fs-utils.d.ts.map +1 -1
- package/dist/cli/postinstall/fs-utils.js +53 -4
- package/dist/cli/postinstall/fs-utils.js.map +1 -1
- package/dist/cli/postinstall/fs-utils.test.js +48 -0
- package/dist/cli/postinstall/fs-utils.test.js.map +1 -1
- package/dist/cli/setup/ProjectSetup.d.ts +6 -0
- package/dist/cli/setup/ProjectSetup.d.ts.map +1 -1
- package/dist/cli/setup/ProjectSetup.js +19 -13
- package/dist/cli/setup/ProjectSetup.js.map +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/scripts/__tests__/.vibe/command-log.txt +3 -3
- package/hooks/scripts/__tests__/anchor-inbox.test.js +119 -0
- package/hooks/scripts/__tests__/code-check-false-positive.test.js +74 -0
- package/hooks/scripts/__tests__/fixtures/seq-harness.js +13 -0
- package/hooks/scripts/__tests__/fixtures/seq-step.js +22 -0
- package/hooks/scripts/__tests__/stop-dispatcher-sequential.test.js +74 -0
- package/hooks/scripts/code-check.js +60 -50
- package/hooks/scripts/lib/anchor.js +74 -0
- package/hooks/scripts/lib/console-allow.js +66 -0
- package/hooks/scripts/lib/dispatcher.js +28 -16
- package/hooks/scripts/lib/inbox.js +60 -0
- package/hooks/scripts/loop-ledger.js +24 -1
- package/hooks/scripts/post-edit-dispatcher.js +4 -3
- package/hooks/scripts/post-edit.js +2 -2
- package/package.json +5 -3
- package/skills/vibe/SKILL.md +2 -1
- package/skills/vibe.clone/references/verification-loops.md +6 -3
- package/skills/vibe.loop/SKILL.md +7 -4
- package/skills/vibe.review/SKILL.md +5 -1
- package/skills/vibe.run/references/e2e-and-autofix.md +1 -1
- package/skills/vibe.run/references/process-steps.md +1 -1
- package/vibe/constitution.md +1 -1
- package/vibe/rules/loop-contract.md +40 -3
- package/vibe/rules/quality/checklist.md +3 -3
- package/vibe/templates/constitution-template.md +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dispatch() 를 실제 계약대로 (CLI spawn + stdin) 구동하는 테스트 하네스.
|
|
4
|
+
* 스텝 3개를 dispatch 에 넘기고, 각 스텝은 seq-step.js 가 트레이스를 남긴다.
|
|
5
|
+
*/
|
|
6
|
+
import { dispatch } from '../../lib/dispatcher.js';
|
|
7
|
+
|
|
8
|
+
await dispatch([
|
|
9
|
+
{ name: 'seq-a', script: '__tests__/fixtures/seq-step.js', args: ['a'] },
|
|
10
|
+
{ name: 'seq-b', script: '__tests__/fixtures/seq-step.js', args: ['b'] },
|
|
11
|
+
{ name: 'seq-c', script: '__tests__/fixtures/seq-step.js', args: ['c'] },
|
|
12
|
+
]);
|
|
13
|
+
process.exit(0);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dispatch() 순차 실행 회귀 테스트용 스텝 픽스처.
|
|
4
|
+
*
|
|
5
|
+
* 실행 구간의 시작/끝을 VIBE_SEQ_TRACE 파일에 append 한다.
|
|
6
|
+
* 순차 실행이면 트레이스는 `a:start,a:end,b:start,b:end` 처럼 구간이 겹치지 않고,
|
|
7
|
+
* 병렬 실행이면 `a:start,b:start,...` 로 인터리빙된다.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
|
|
11
|
+
const name = process.argv[2] || '?';
|
|
12
|
+
const tracePath = process.env.VIBE_SEQ_TRACE;
|
|
13
|
+
const holdMs = Number(process.env.VIBE_SEQ_HOLD_MS || 120);
|
|
14
|
+
|
|
15
|
+
fs.appendFileSync(tracePath, `${name}:start\n`);
|
|
16
|
+
|
|
17
|
+
// 동기 홀드 — 병렬 실행 시 다른 스텝의 start 가 이 구간 안에 끼어들도록 만든다.
|
|
18
|
+
const until = Date.now() + holdMs;
|
|
19
|
+
while (Date.now() < until) { /* busy wait */ }
|
|
20
|
+
|
|
21
|
+
fs.appendFileSync(tracePath, `${name}:end\n`);
|
|
22
|
+
process.exit(0);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch() 순차 실행 계약 회귀 테스트
|
|
3
|
+
*
|
|
4
|
+
* stop-dispatcher.js 는 "auto-commit 의 git cascade 와 겹쳐 프로세스 폭주" 를 막기 위해
|
|
5
|
+
* 순차 실행을 계약으로 문서화한다(stop-dispatcher.js 헤더). 그러나 dispatch() 가
|
|
6
|
+
* Promise.all 로 구현돼 있어 실제로는 병렬이었다 — 이 테스트가 그 회귀를 막는다.
|
|
7
|
+
*
|
|
8
|
+
* 검증 대상은 내부 구현이 아니라 관측 가능한 실행 구간 겹침이다.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const FIXTURES = path.resolve(__dirname, 'fixtures');
|
|
19
|
+
const HARNESS = path.join(FIXTURES, 'seq-harness.js');
|
|
20
|
+
|
|
21
|
+
let traceDir;
|
|
22
|
+
let tracePath;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
traceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-seq-'));
|
|
26
|
+
tracePath = path.join(traceDir, 'trace.log');
|
|
27
|
+
fs.writeFileSync(tracePath, '');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(traceDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function runHarness() {
|
|
35
|
+
execFileSync('node', [HARNESS], {
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
input: JSON.stringify({ tool_name: 'Stop' }),
|
|
38
|
+
timeout: 20000,
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
VIBE_SEQ_TRACE: tracePath,
|
|
42
|
+
VIBE_SEQ_HOLD_MS: '120',
|
|
43
|
+
// config.json 이 없는 디렉터리 → 모든 step enabled
|
|
44
|
+
CLAUDE_PROJECT_DIR: traceDir,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return fs.readFileSync(tracePath, 'utf-8').trim().split('\n').filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('dispatch() 순차 실행 계약', () => {
|
|
51
|
+
it('스텝 실행 구간이 겹치지 않는다 (start 직후 항상 같은 스텝의 end)', () => {
|
|
52
|
+
const trace = runHarness();
|
|
53
|
+
|
|
54
|
+
expect(trace).toHaveLength(6);
|
|
55
|
+
for (let i = 0; i < trace.length; i += 2) {
|
|
56
|
+
const [startName, startMark] = trace[i].split(':');
|
|
57
|
+
const [endName, endMark] = trace[i + 1].split(':');
|
|
58
|
+
expect(startMark).toBe('start');
|
|
59
|
+
expect(endMark).toBe('end');
|
|
60
|
+
// 병렬이면 여기서 다른 스텝의 start 가 끼어든다
|
|
61
|
+
expect(endName).toBe(startName);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('선언된 순서(a → b → c)를 그대로 지킨다', () => {
|
|
66
|
+
const trace = runHarness();
|
|
67
|
+
|
|
68
|
+
expect(trace).toEqual([
|
|
69
|
+
'a:start', 'a:end',
|
|
70
|
+
'b:start', 'b:end',
|
|
71
|
+
'c:start', 'c:end',
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
* (run-ledger verifyRequired)를 태우지 않는다 — 그 게이트는 결정론적
|
|
11
11
|
* 검증 흐름(vibe.verify) 전용이다.
|
|
12
12
|
*/
|
|
13
|
-
import { getToolsBaseUrl, PROJECT_DIR
|
|
13
|
+
import { getToolsBaseUrl, PROJECT_DIR } from './utils.js';
|
|
14
14
|
import { readFileSync } from 'fs';
|
|
15
|
-
import path from 'path';
|
|
16
15
|
import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
|
|
17
|
-
import {
|
|
16
|
+
import { CODE_EXT_RE, shouldCheckConsole } from './lib/console-allow.js';
|
|
18
17
|
|
|
19
18
|
const BASE_URL = getToolsBaseUrl();
|
|
20
19
|
|
|
@@ -31,50 +30,6 @@ const P1_DETECTORS = [
|
|
|
31
30
|
];
|
|
32
31
|
|
|
33
32
|
const TS_EXT_RE = /\.(ts|tsx)$/;
|
|
34
|
-
const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
35
|
-
|
|
36
|
-
// console.log 기본 허용 경로 (glob 패턴 → 정규식으로 변환)
|
|
37
|
-
const DEFAULT_CONSOLE_ALLOW_GLOBS = [
|
|
38
|
-
'hooks/scripts/**',
|
|
39
|
-
'scripts/**',
|
|
40
|
-
'**/cli/**',
|
|
41
|
-
'**/*.test.*',
|
|
42
|
-
'**/*.spec.*',
|
|
43
|
-
'**/__tests__/**',
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* .vibe/config.json의 qualityCheck.consoleAllow 글로브 목록 로드.
|
|
48
|
-
* 기본 글로브와 병합하여 반환.
|
|
49
|
-
* @returns {RegExp[]}
|
|
50
|
-
*/
|
|
51
|
-
function loadConsoleAllowPatterns() {
|
|
52
|
-
try {
|
|
53
|
-
const cfg = readProjectConfig();
|
|
54
|
-
const extra = cfg?.qualityCheck?.consoleAllow;
|
|
55
|
-
const globs = Array.isArray(extra)
|
|
56
|
-
? [...DEFAULT_CONSOLE_ALLOW_GLOBS, ...extra]
|
|
57
|
-
: DEFAULT_CONSOLE_ALLOW_GLOBS;
|
|
58
|
-
return globs.map(g => globToRegExp(g));
|
|
59
|
-
} catch {
|
|
60
|
-
return DEFAULT_CONSOLE_ALLOW_GLOBS.map(g => globToRegExp(g));
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* 파일 경로가 console.log 허용 경로인지 판단.
|
|
66
|
-
* @param {string} filePath - 절대 또는 프로젝트 상대 경로
|
|
67
|
-
* @returns {boolean}
|
|
68
|
-
*/
|
|
69
|
-
function isConsoleAllowed(filePath) {
|
|
70
|
-
try {
|
|
71
|
-
const rel = path.relative(PROJECT_DIR, path.resolve(filePath)).replace(/\\/g, '/');
|
|
72
|
-
const patterns = loadConsoleAllowPatterns();
|
|
73
|
-
return patterns.some(re => re.test(rel));
|
|
74
|
-
} catch {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
33
|
|
|
79
34
|
/**
|
|
80
35
|
* hook ctx에서 수정된 파일 경로 추출.
|
|
@@ -99,16 +54,72 @@ function classifyObservation(files) {
|
|
|
99
54
|
return { type: 'feature', title: 'Code modified' };
|
|
100
55
|
}
|
|
101
56
|
|
|
57
|
+
/**
|
|
58
|
+
* 백슬래시로 이스케이프되지 않은 첫 token 위치. 없으면 -1.
|
|
59
|
+
* 템플릿 리터럴 안의 \` 를 종료 백틱으로 오인하지 않기 위해 필요하다.
|
|
60
|
+
* @param {string} str
|
|
61
|
+
* @param {string} token
|
|
62
|
+
* @returns {number}
|
|
63
|
+
*/
|
|
64
|
+
function findUnescaped(str, token) {
|
|
65
|
+
for (let i = 0; i < str.length; i++) {
|
|
66
|
+
if (str[i] === '\\') { i++; continue; }
|
|
67
|
+
if (str.startsWith(token, i)) return i;
|
|
68
|
+
}
|
|
69
|
+
return -1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 한 줄에서 코드가 아닌 구간(주석·문자열·템플릿 리터럴)을 지운다.
|
|
74
|
+
* 여러 줄에 걸친 블록 주석·템플릿 리터럴을 잇기 위해 state를 받고 갱신해 돌려준다.
|
|
75
|
+
* @param {string} line
|
|
76
|
+
* @param {{ inBlock: boolean, inTemplate: boolean }} state - 제자리 갱신
|
|
77
|
+
* @returns {string} 코드 구간만 남은 문자열
|
|
78
|
+
*/
|
|
79
|
+
function stripNonCodeLine(line, state) {
|
|
80
|
+
let rest = line;
|
|
81
|
+
let code = '';
|
|
82
|
+
while (rest.length > 0) {
|
|
83
|
+
if (state.inBlock || state.inTemplate) {
|
|
84
|
+
const closer = state.inBlock ? '*/' : '`';
|
|
85
|
+
const end = findUnescaped(rest, closer);
|
|
86
|
+
if (end === -1) break;
|
|
87
|
+
if (state.inBlock) state.inBlock = false;
|
|
88
|
+
else state.inTemplate = false;
|
|
89
|
+
rest = rest.slice(end + closer.length);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const opener = rest.match(/\/\*|\/\/|`|'|"/);
|
|
93
|
+
if (!opener) return code + rest;
|
|
94
|
+
code += rest.slice(0, opener.index);
|
|
95
|
+
const token = opener[0];
|
|
96
|
+
rest = rest.slice(opener.index + token.length);
|
|
97
|
+
if (token === '//') break;
|
|
98
|
+
if (token === '/*') { state.inBlock = true; continue; }
|
|
99
|
+
if (token === '`') { state.inTemplate = true; continue; }
|
|
100
|
+
const close = findUnescaped(rest, token); // 홑/겹따옴표는 줄을 넘지 않는다
|
|
101
|
+
rest = close === -1 ? '' : rest.slice(close + 1);
|
|
102
|
+
}
|
|
103
|
+
return code;
|
|
104
|
+
}
|
|
105
|
+
|
|
102
106
|
/**
|
|
103
107
|
* P1: any 타입 탐지 — .ts/.tsx 전용, 단어 경계 기반.
|
|
108
|
+
*
|
|
109
|
+
* 주석·문자열·템플릿 리터럴은 제외한다: `any` 를 **금지하는** 문서 문장이
|
|
110
|
+
* 그 자체로 P1 이 되면, 고칠 수도 없는 경고가 그 파일을 편집할 때마다
|
|
111
|
+
* 주입돼 게이트 신뢰도가 떨어진다 (detectConsoleLogs의 확장자 게이트와 같은 이유).
|
|
112
|
+
*
|
|
104
113
|
* @param {string[]} lines
|
|
105
114
|
* @returns {Array<{ line: number, match: string, severity: 'P1' }>}
|
|
106
115
|
*/
|
|
107
116
|
function detectAnyType(lines) {
|
|
108
117
|
const findings = [];
|
|
118
|
+
const state = { inBlock: false, inTemplate: false };
|
|
109
119
|
lines.forEach((line, i) => {
|
|
120
|
+
const code = stripNonCodeLine(line, state);
|
|
110
121
|
for (const re of P1_DETECTORS) {
|
|
111
|
-
if (re.test(
|
|
122
|
+
if (re.test(code)) {
|
|
112
123
|
findings.push({
|
|
113
124
|
line: i + 1,
|
|
114
125
|
match: line.trim(),
|
|
@@ -135,8 +146,7 @@ function detectAnyType(lines) {
|
|
|
135
146
|
* @returns {Array<{ line: number, match: string, severity: 'P1' }>}
|
|
136
147
|
*/
|
|
137
148
|
function detectConsoleLogs(lines, filePath) {
|
|
138
|
-
if (!
|
|
139
|
-
if (isConsoleAllowed(filePath)) return [];
|
|
149
|
+
if (!shouldCheckConsole(filePath)) return [];
|
|
140
150
|
const findings = [];
|
|
141
151
|
lines.forEach((line, i) => {
|
|
142
152
|
if (/console\.log\(/.test(line)) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANCHOR — 루프 회전 시작 시 디스크에서 상태를 재고정한다.
|
|
3
|
+
*
|
|
4
|
+
* loop-contract 는 ANCHOR 를 컨텍스트 오염 방어의 근거로 규정하는데, JUDGE·RECORD·stuck 과
|
|
5
|
+
* 달리 실행 수단이 없어 모델 재량으로 남아 있었다 (감사 2026-07-28 L3). 재고정 대상이
|
|
6
|
+
* 무엇인지 결정론적으로 답하는 것이 이 모듈의 역할이다 — 파일 내용을 해석하지는 않는다.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { readLedger } from './run-ledger.js';
|
|
11
|
+
|
|
12
|
+
/** 우선순위대로 첫 번째로 존재하는 경로를 고른다 */
|
|
13
|
+
function firstExisting(projectDir, candidates) {
|
|
14
|
+
for (const rel of candidates) {
|
|
15
|
+
if (fs.existsSync(path.join(projectDir, rel))) return rel;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `.vibe/.last-feature` 에 기록된 직전 feature 이름 */
|
|
21
|
+
function readLastFeature(projectDir) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = fs.readFileSync(path.join(projectDir, '.vibe', '.last-feature'), 'utf-8').trim();
|
|
24
|
+
return raw.length > 0 ? raw : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** SPEC 경로 — 신규 레이아웃 우선, 레거시 폴백 */
|
|
31
|
+
function findSpec(projectDir, feature) {
|
|
32
|
+
if (!feature) return null;
|
|
33
|
+
return firstExisting(projectDir, [
|
|
34
|
+
path.join('.vibe', 'specs', `${feature}.md`),
|
|
35
|
+
path.join('.vibe', 'specs', feature, '_index.md'),
|
|
36
|
+
path.join('.claude', 'vibe', 'specs', `${feature}.md`),
|
|
37
|
+
path.join('.claude', 'specs', `${feature}.md`),
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 인박스에서 가장 최근 블록(다음 `## ` 직전까지) */
|
|
42
|
+
function readLatestInboxBlock(projectDir) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(path.join(projectDir, '.vibe', 'loops', 'inbox.md'), 'utf-8');
|
|
45
|
+
const start = raw.indexOf('## ');
|
|
46
|
+
if (start === -1) return null;
|
|
47
|
+
const next = raw.indexOf('\n## ', start + 3);
|
|
48
|
+
return (next === -1 ? raw.slice(start) : raw.slice(start, next)).trim() || null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 재고정 번들 — loop-contract ANCHOR 절이 지정한 SPEC + run-ledger + scope.json + 직전 인박스.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} projectDir
|
|
58
|
+
* @param {string} [feature] - 생략 시 `.vibe/.last-feature`
|
|
59
|
+
* @returns {{ feature: string|null, spec: string|null, scope: string|null,
|
|
60
|
+
* ledger: object|null, latestInbox: string|null, missing: string[] }}
|
|
61
|
+
*/
|
|
62
|
+
export function buildAnchor(projectDir, feature) {
|
|
63
|
+
const resolved = feature || readLastFeature(projectDir);
|
|
64
|
+
const spec = findSpec(projectDir, resolved);
|
|
65
|
+
const scope = firstExisting(projectDir, [path.join('.vibe', 'scope.json')]);
|
|
66
|
+
const ledger = readLedger(projectDir);
|
|
67
|
+
|
|
68
|
+
const missing = [];
|
|
69
|
+
if (!resolved) missing.push('feature');
|
|
70
|
+
if (!spec) missing.push('spec');
|
|
71
|
+
if (!ledger) missing.push('run-ledger');
|
|
72
|
+
|
|
73
|
+
return { feature: resolved, spec, scope, ledger, latestInbox: readLatestInboxBlock(projectDir), missing };
|
|
74
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* console.log 탐지의 적용 범위 판단 — code-check.js·post-edit.js 공용 (중복 제거).
|
|
3
|
+
*
|
|
4
|
+
* 두 훅이 같은 검사를 각자 들고 있으면 한쪽의 허용 경로 설계가 다른 쪽 경고에
|
|
5
|
+
* 무력화된다. 범위 규칙은 여기 하나만 둔다.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { PROJECT_DIR, readProjectConfig } from '../utils.js';
|
|
9
|
+
import { globToRegExp } from './glob.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 코드 확장자 — 마크다운·JSON·텍스트에 인용된 `console.log(` 는 커밋되면 안 되는
|
|
13
|
+
* 디버그 코드가 아니라 문서상의 예시다.
|
|
14
|
+
*/
|
|
15
|
+
export const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
16
|
+
|
|
17
|
+
// console.log 기본 허용 경로 (glob 패턴 → 정규식으로 변환)
|
|
18
|
+
const DEFAULT_CONSOLE_ALLOW_GLOBS = [
|
|
19
|
+
'hooks/scripts/**',
|
|
20
|
+
'scripts/**',
|
|
21
|
+
'**/cli/**',
|
|
22
|
+
'**/*.test.*',
|
|
23
|
+
'**/*.spec.*',
|
|
24
|
+
'**/__tests__/**',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* .vibe/config.json의 qualityCheck.consoleAllow 글로브 목록 로드.
|
|
29
|
+
* 기본 글로브와 병합하여 반환.
|
|
30
|
+
* @returns {RegExp[]}
|
|
31
|
+
*/
|
|
32
|
+
function loadConsoleAllowPatterns() {
|
|
33
|
+
try {
|
|
34
|
+
const cfg = readProjectConfig();
|
|
35
|
+
const extra = cfg?.qualityCheck?.consoleAllow;
|
|
36
|
+
const globs = Array.isArray(extra)
|
|
37
|
+
? [...DEFAULT_CONSOLE_ALLOW_GLOBS, ...extra]
|
|
38
|
+
: DEFAULT_CONSOLE_ALLOW_GLOBS;
|
|
39
|
+
return globs.map(g => globToRegExp(g));
|
|
40
|
+
} catch {
|
|
41
|
+
return DEFAULT_CONSOLE_ALLOW_GLOBS.map(g => globToRegExp(g));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 파일 경로가 console.log 허용 경로인지 판단.
|
|
47
|
+
* @param {string} filePath - 절대 또는 프로젝트 상대 경로
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function isConsoleAllowed(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
const rel = path.relative(PROJECT_DIR, path.resolve(filePath)).replace(/\\/g, '/');
|
|
53
|
+
return loadConsoleAllowPatterns().some(re => re.test(rel));
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* console.log 검사 대상 파일인지 — 코드 확장자이면서 허용 경로가 아닌 경우.
|
|
61
|
+
* @param {string} filePath
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function shouldCheckConsole(filePath) {
|
|
65
|
+
return CODE_EXT_RE.test(filePath) && !isConsoleAllowed(filePath);
|
|
66
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hook dispatcher library — 여러 hook script를 단일 이벤트에서
|
|
2
|
+
* Hook dispatcher library — 여러 hook script를 단일 이벤트에서 실행.
|
|
3
3
|
*
|
|
4
4
|
* 목적:
|
|
5
5
|
* - stdin을 한 번만 읽어 각 자식에 동일 버퍼로 pipe (중복 파싱/읽기 방지)
|
|
@@ -7,14 +7,23 @@
|
|
|
7
7
|
* - 한 스크립트 실패가 다른 스크립트를 막지 않도록 cascade 격리
|
|
8
8
|
* - PreToolUse 계열: 자식 중 하나라도 exit 2(deny)면 상위에 전파
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
10
|
+
* 실행 모델이 둘로 갈린다 — 스텝이 서로 독립인지가 기준:
|
|
11
|
+
*
|
|
12
|
+
* dispatch() = 순차 (spawn). 유일한 사용처인 Stop 은 스텝끼리
|
|
13
|
+
* 부작용을 공유한다(auto-commit 의 git 상태를
|
|
14
|
+
* devlog-gen 이 읽는다). 병렬화하면 auto-commit 의
|
|
15
|
+
* git cascade 와 겹쳐 프로세스가 폭주하고,
|
|
16
|
+
* devlog 가 커밋 이전 상태를 관측한다.
|
|
17
|
+
* 회귀 방지: __tests__/stop-dispatcher-sequential.test.js
|
|
18
|
+
*
|
|
19
|
+
* dispatchInProcess() = 병렬 (import). PreToolUse 가드는 모두 독립적
|
|
20
|
+
* 검증자라 순서가 의미 없고, 직렬 실행은 tool당
|
|
21
|
+
* 150~300ms 누적 오버헤드를 유발한다.
|
|
22
|
+
* 트레이드오프:
|
|
23
|
+
* - early-deny 낭비: sentinel-guard가 block이어도
|
|
24
|
+
* pre-tool/scope-guard가 이미 실행됨. 실측 μs 수준.
|
|
25
|
+
* - stderr 인터리빙: 가드 2개가 동시 block 시 경고가
|
|
26
|
+
* 섞일 수 있음. 각 메시지가 완결된 라인이라 무해.
|
|
18
27
|
*/
|
|
19
28
|
import { spawn } from 'child_process';
|
|
20
29
|
import path from 'path';
|
|
@@ -93,7 +102,11 @@ function buildChildEnv(stdinData) {
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
/**
|
|
96
|
-
* 디스패처 실행 — 활성화된 스텝을
|
|
105
|
+
* 디스패처 실행 — 활성화된 스텝을 선언 순서대로 **순차** spawn.
|
|
106
|
+
*
|
|
107
|
+
* 순차인 이유는 파일 상단 주석 참고 — 스텝이 git 상태 같은 부작용을 공유한다.
|
|
108
|
+
* 앞 스텝이 실패해도 다음 스텝은 계속 실행한다(cascade 격리 유지).
|
|
109
|
+
*
|
|
97
110
|
* @param {Array<{name: string, script: string, args?: string[], denyOnExit2?: boolean, timeoutMs?: number}>} steps
|
|
98
111
|
*/
|
|
99
112
|
export async function dispatch(steps) {
|
|
@@ -101,12 +114,11 @@ export async function dispatch(steps) {
|
|
|
101
114
|
const hookConfig = loadHookConfig();
|
|
102
115
|
|
|
103
116
|
const enabledSteps = steps.filter(s => isEnabled(hookConfig, s.name));
|
|
104
|
-
const results =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
);
|
|
117
|
+
const results = [];
|
|
118
|
+
for (const step of enabledSteps) {
|
|
119
|
+
const code = await runScript(step.script, step.args || [], stdinData, step.timeoutMs || 30000);
|
|
120
|
+
results.push({ step, code });
|
|
121
|
+
}
|
|
110
122
|
|
|
111
123
|
// 하나라도 deny(exit 2) 반환 → 상위에 전파
|
|
112
124
|
if (results.some(({ step, code }) => step.denyOnExit2 && code === 2)) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 루프 인박스 — 사람 리뷰 큐(`.vibe/loops/inbox.md`) 기록.
|
|
3
|
+
*
|
|
4
|
+
* loop-history.jsonl 은 결정론적으로 기록되는데 인박스만 모델이 마크다운을 직접
|
|
5
|
+
* prepend 하고 있었다 (감사 2026-07-28 L5). 블록 형식과 최신순 정렬을 코드가 보장한다.
|
|
6
|
+
*
|
|
7
|
+
* fail-open — 기록 실패가 루프를 멈추지 않는다.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
const HEADER = '# Loop Inbox\n\n> 루프가 남긴 사람 리뷰 큐. 최신 항목이 위에 온다.\n';
|
|
13
|
+
|
|
14
|
+
function inboxPath(projectDir) {
|
|
15
|
+
return path.join(projectDir, '.vibe', 'loops', 'inbox.md');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 인박스 블록을 최상단에 prepend 한다.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} projectDir
|
|
22
|
+
* @param {{ loop: string, result: 'ok'|'fail'|'stuck', at: string, lines?: string[] }} entry
|
|
23
|
+
* at 은 호출자가 넘긴다 — 이 모듈은 시각을 직접 읽지 않는다 (테스트 결정성)
|
|
24
|
+
* @returns {boolean} 성공 여부
|
|
25
|
+
*/
|
|
26
|
+
export function prependInboxBlock(projectDir, entry) {
|
|
27
|
+
try {
|
|
28
|
+
if (!entry?.loop || !entry?.result || !entry?.at) return false;
|
|
29
|
+
|
|
30
|
+
const body = (entry.lines ?? []).map(l => `- ${l}`).join('\n');
|
|
31
|
+
const block = `## ${entry.loop} — ${entry.at} — ${entry.result}\n${body}\n`;
|
|
32
|
+
|
|
33
|
+
const target = inboxPath(projectDir);
|
|
34
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
35
|
+
|
|
36
|
+
const existing = fs.existsSync(target) ? fs.readFileSync(target, 'utf-8') : '';
|
|
37
|
+
const blocksStart = existing.indexOf('## ');
|
|
38
|
+
const head = blocksStart === -1 ? HEADER : existing.slice(0, blocksStart);
|
|
39
|
+
const rest = blocksStart === -1 ? '' : existing.slice(blocksStart);
|
|
40
|
+
|
|
41
|
+
fs.writeFileSync(target, `${head}\n${block}\n${rest}`.replace(/\n{3,}/g, '\n\n'), 'utf-8');
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 아직 처리되지 않은 블록 수 — `## ` 로 시작하는 줄의 개수.
|
|
50
|
+
* @param {string} projectDir
|
|
51
|
+
* @returns {number}
|
|
52
|
+
*/
|
|
53
|
+
export function countInboxBlocks(projectDir) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = fs.readFileSync(inboxPath(projectDir), 'utf-8');
|
|
56
|
+
return raw.split('\n').filter(l => l.startsWith('## ')).length;
|
|
57
|
+
} catch {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -6,12 +6,17 @@
|
|
|
6
6
|
* node hooks/scripts/loop-ledger.js start <name>
|
|
7
7
|
* node hooks/scripts/loop-ledger.js end <name> <ok|fail|stuck> [summary]
|
|
8
8
|
* node hooks/scripts/loop-ledger.js check-stuck <name> <discoverHash>
|
|
9
|
+
* node hooks/scripts/loop-ledger.js anchor [feature]
|
|
10
|
+
* node hooks/scripts/loop-ledger.js inbox <name> <ok|fail|stuck> [line...]
|
|
9
11
|
*
|
|
10
12
|
* check-stuck: 'stuck' 또는 'ok'를 stdout에 출력하고 항상 exit 0.
|
|
13
|
+
* anchor: 재고정 번들 JSON을 stdout에 출력한다 (loop-contract ANCHOR 절).
|
|
11
14
|
* 항상 exit 0 (fail-open).
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import { appendLoopEvent, isStuck } from './lib/loop-ledger.js';
|
|
18
|
+
import { buildAnchor } from './lib/anchor.js';
|
|
19
|
+
import { prependInboxBlock } from './lib/inbox.js';
|
|
15
20
|
|
|
16
21
|
const [, , subcommand, ...args] = process.argv;
|
|
17
22
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
@@ -47,9 +52,27 @@ if (subcommand === 'start') {
|
|
|
47
52
|
appendLoopEvent(projectDir, { loop, event: 'discover', discoverHash });
|
|
48
53
|
process.stdout.write(stuck ? 'stuck\n' : 'ok\n');
|
|
49
54
|
|
|
55
|
+
} else if (subcommand === 'anchor') {
|
|
56
|
+
// 회전 시작 시 디스크 재고정 — 모델이 무엇을 다시 읽어야 하는지 결정론적으로 답한다
|
|
57
|
+
process.stdout.write(JSON.stringify(buildAnchor(projectDir, args[0]), null, 2) + '\n');
|
|
58
|
+
|
|
59
|
+
} else if (subcommand === 'inbox') {
|
|
60
|
+
const [loop, result, ...lines] = args;
|
|
61
|
+
if (!loop || !result) {
|
|
62
|
+
process.stdout.write('[loop-ledger] error: inbox 에 루프 이름과 결과(ok|fail|stuck)가 필요합니다\n');
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
const at = new Date().toISOString();
|
|
66
|
+
const ok = prependInboxBlock(projectDir, { loop, result, at, lines });
|
|
67
|
+
process.stdout.write(
|
|
68
|
+
ok ? `[loop-ledger] inbox recorded: loop=${loop} result=${result}\n`
|
|
69
|
+
: '[loop-ledger] WARNING: inbox write failed\n'
|
|
70
|
+
);
|
|
71
|
+
|
|
50
72
|
} else {
|
|
51
73
|
process.stdout.write(
|
|
52
|
-
'[loop-ledger] 사용법: start <name> | end <name> <ok|fail|stuck> [summary] |
|
|
74
|
+
'[loop-ledger] 사용법: start <name> | end <name> <ok|fail|stuck> [summary] | '
|
|
75
|
+
+ 'check-stuck <name> <hash> | anchor [feature] | inbox <name> <ok|fail|stuck> [line...]\n'
|
|
53
76
|
);
|
|
54
77
|
}
|
|
55
78
|
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* auto-format — 코드 스타일 정규화 (변경 시 finding 반환)
|
|
11
11
|
* code-check — 하드룰(any/console.log) 탐지 (additionalContext 주입만, 커밋 게이트 미연동)
|
|
12
12
|
* auto-test — 관련 테스트 실행 (debounce 지원)
|
|
13
|
-
*
|
|
13
|
+
*
|
|
14
|
+
* post-edit.js 는 여기서 돌리지 않는다 — console.log 감지는 code-check 가 같은
|
|
15
|
+
* 허용 경로 규칙(lib/console-allow.js)으로 이미 수행한다. 둘 다 돌리면 허용
|
|
16
|
+
* 경로에서도 경고가 남는다. post-edit.js 는 antigravity-hooks.json 단독 등록용.
|
|
14
17
|
*
|
|
15
18
|
* 출력 계약 (Claude Code PostToolUse):
|
|
16
19
|
* findings 있음 → stdout에 JSON hookSpecificOutput 1개 출력, exit 0
|
|
@@ -33,7 +36,6 @@ import path from 'path';
|
|
|
33
36
|
import { run as autoFormat } from './auto-format.js';
|
|
34
37
|
import { run as codeCheck } from './code-check.js';
|
|
35
38
|
import { run as autoTest } from './auto-test.js';
|
|
36
|
-
import { run as postEdit } from './post-edit.js';
|
|
37
39
|
|
|
38
40
|
// ─── 설정 로딩 ────────────────────────────────────────────────────────
|
|
39
41
|
function loadHookConfig() {
|
|
@@ -59,7 +61,6 @@ const steps = [
|
|
|
59
61
|
{ name: 'auto-format', run: autoFormat },
|
|
60
62
|
{ name: 'code-check', run: codeCheck },
|
|
61
63
|
{ name: 'auto-test', run: autoTest },
|
|
62
|
-
{ name: 'post-edit', run: postEdit },
|
|
63
64
|
];
|
|
64
65
|
|
|
65
66
|
const enabledSteps = steps.filter(s => isEnabled(hookConfig, s.name));
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
import { existsSync, readFileSync } from 'fs';
|
|
11
11
|
import path from 'path';
|
|
12
12
|
import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
|
|
13
|
+
import { shouldCheckConsole } from './lib/console-allow.js';
|
|
13
14
|
|
|
14
15
|
const CONSOLE_LOG_RE = /console\.log\(/;
|
|
15
|
-
const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* in-process 진입점 — console.log 감지만 수행.
|
|
@@ -25,7 +25,7 @@ export async function run(ctx) {
|
|
|
25
25
|
try {
|
|
26
26
|
const filePath = ctx.filePath;
|
|
27
27
|
|
|
28
|
-
if (filePath &&
|
|
28
|
+
if (filePath && shouldCheckConsole(filePath)) {
|
|
29
29
|
const resolved = path.resolve(filePath);
|
|
30
30
|
if (existsSync(resolved)) {
|
|
31
31
|
const lines = readFileSync(resolved, 'utf-8').split('\n');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@su-record/vibe",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.14",
|
|
4
4
|
"description": "AI Coding Framework for Claude Code — 7+ agents, 52 skills, multi-LLM orchestration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/cli/index.js",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"validate:counts": "npx tsx scripts/validate-counts.ts",
|
|
32
32
|
"test": "vitest run",
|
|
33
33
|
"test:watch": "vitest",
|
|
34
|
+
"test:coverage": "vitest run --coverage",
|
|
34
35
|
"prepublishOnly": "pnpm build",
|
|
35
36
|
"postinstall": "node -e \"import('./dist/cli/postinstall/main.js').then(m=>m.main()).catch(()=>{})\"",
|
|
36
37
|
"release": "pnpm version patch && git push origin main --follow-tags"
|
|
@@ -66,7 +67,7 @@
|
|
|
66
67
|
"access": "public"
|
|
67
68
|
},
|
|
68
69
|
"engines": {
|
|
69
|
-
"node": ">=
|
|
70
|
+
"node": ">=20.12.0"
|
|
70
71
|
},
|
|
71
72
|
"optionalDependencies": {
|
|
72
73
|
"@anthropic-ai/claude-agent-sdk": "^0.2.6",
|
|
@@ -86,9 +87,10 @@
|
|
|
86
87
|
"@types/better-sqlite3": "^7.6.13",
|
|
87
88
|
"@types/node": "^22.0.0",
|
|
88
89
|
"@types/papaparse": "^5.5.2",
|
|
90
|
+
"@vitest/coverage-v8": "^4.0.9",
|
|
89
91
|
"ajv": "^8.17.1",
|
|
90
92
|
"typescript": "^5.5.4",
|
|
91
|
-
"vitest": "^4.
|
|
93
|
+
"vitest": "^4.1.10"
|
|
92
94
|
},
|
|
93
95
|
"files": [
|
|
94
96
|
"dist/",
|