@tuzi-ince/hi-loop 0.1.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.
package/src/runners.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * I/O 경계 어댑터 (NFR-3): 에이전트 호출 / 테스트 실행.
3
+ * 루프 엔진은 이 함수들을 주입받으므로, 테스트에서는 스텁으로 대체된다.
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+
7
+ /**
8
+ * 양의 정수 환경변수를 읽되, 없거나 쓰레기값이면 기본값으로 조용히 되돌린다.
9
+ * 타임아웃을 0 이나 음수로 잘못 주면 즉시 죽는 러너가 되므로 방어한다.
10
+ */
11
+ function envMs(name, fallback) {
12
+ const v = Number(process.env[name]);
13
+ return Number.isFinite(v) && v > 0 ? v : fallback;
14
+ }
15
+
16
+ /** 쉘 명령을 실행하고 exit code 로 성패를 판정한다 (FR-2.3) */
17
+ export function makeTestRunner({ timeoutMs = envMs('HILOOP_TEST_TIMEOUT_MS', 10 * 60 * 1000) } = {}) {
18
+ return ({ command, cwd }) =>
19
+ new Promise((resolve) => {
20
+ const child = spawn(command, { cwd, shell: true, env: process.env });
21
+ let stdout = '';
22
+ let stderr = '';
23
+ let timedOut = false;
24
+ const timer = setTimeout(() => {
25
+ timedOut = true;
26
+ child.kill('SIGKILL');
27
+ }, timeoutMs);
28
+
29
+ child.stdout?.on('data', (d) => {
30
+ stdout += d;
31
+ });
32
+ child.stderr?.on('data', (d) => {
33
+ stderr += d;
34
+ });
35
+ child.on('error', (err) => {
36
+ clearTimeout(timer);
37
+ resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\n${err.message}` });
38
+ });
39
+ child.on('close', (code) => {
40
+ clearTimeout(timer);
41
+ if (timedOut) {
42
+ resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\n[hi-loop] 테스트가 ${timeoutMs}ms 안에 끝나지 않아 강제 종료했습니다.` });
43
+ return;
44
+ }
45
+ resolve({ ok: code === 0, code, stdout, stderr });
46
+ });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * 기본 권한 모드의 `claude -p` 는 비대화형이라 파일 편집 권한 요청을 물어볼 상대가 없고,
52
+ * 그 결과 편집이 거부된다. 그러면 루프는 아무 파일도 못 쓴 채 헛돌기만 한다.
53
+ * 자가 치유 엔진의 전제가 "에이전트가 코드를 고친다"이므로 편집 허용이 기본값이어야 한다.
54
+ * 더 넓히거나(bypassPermissions) 좁히려면 HILOOP_PERMISSION_MODE 로 덮어쓴다.
55
+ */
56
+ export const DEFAULT_PERMISSION_MODE = 'acceptEdits';
57
+
58
+ /** 사용자가 이미 권한 모드를 직접 넘겼으면 우리가 끼어들지 않는다. */
59
+ export function buildAgentArgs({ prompt, sessionId, extraArgs = [], permissionMode }) {
60
+ const args = ['-p', prompt, '--output-format', 'json'];
61
+ if (sessionId) args.push('--resume', sessionId);
62
+ if (permissionMode && !extraArgs.includes('--permission-mode')) {
63
+ args.push('--permission-mode', permissionMode);
64
+ }
65
+ args.push(...extraArgs);
66
+ return args;
67
+ }
68
+
69
+ /**
70
+ * 에이전트 CLI 러너. 기본은
71
+ * `claude -p <prompt> --output-format json --permission-mode acceptEdits`.
72
+ * 세션 유지는 --resume <sessionId>, 핸드오프는 sessionId=null 로 새 세션 시작.
73
+ */
74
+ export function makeAgentRunner({
75
+ command = process.env.HILOOP_AGENT_CMD || 'claude',
76
+ extraArgs = (process.env.HILOOP_AGENT_ARGS || '').split(' ').filter(Boolean),
77
+ permissionMode = process.env.HILOOP_PERMISSION_MODE || DEFAULT_PERMISSION_MODE,
78
+ timeoutMs = envMs('HILOOP_AGENT_TIMEOUT_MS', 30 * 60 * 1000),
79
+ } = {}) {
80
+ return ({ prompt, sessionId, cwd }) =>
81
+ new Promise((resolve, reject) => {
82
+ const args = buildAgentArgs({ prompt, sessionId, extraArgs, permissionMode });
83
+
84
+ const child = spawn(command, args, { cwd, env: process.env });
85
+ let stdout = '';
86
+ let stderr = '';
87
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
88
+
89
+ child.stdout.on('data', (d) => {
90
+ stdout += d;
91
+ });
92
+ child.stderr.on('data', (d) => {
93
+ stderr += d;
94
+ });
95
+ child.on('error', (err) => {
96
+ clearTimeout(timer);
97
+ reject(new Error(`에이전트 실행 실패(${command}): ${err.message}`));
98
+ });
99
+ child.on('close', (code) => {
100
+ clearTimeout(timer);
101
+ if (code !== 0) {
102
+ reject(new Error(`에이전트가 코드 ${code}로 종료했습니다.\n${stderr.slice(-2000)}`));
103
+ return;
104
+ }
105
+ resolve(parseAgentOutput(stdout, sessionId));
106
+ });
107
+ });
108
+ }
109
+
110
+ /**
111
+ * `--output-format json` 응답에서 결과 텍스트와 session_id, 비용을 뽑는다.
112
+ *
113
+ * 비용을 읽는 이유: 실측상 사소한 호출 1회도 새 세션이면 ~$0.43(캐시 생성 42k 토큰),
114
+ * "add 함수를 만들어라" 2회전이 $2.58~$4.67 이었다. 같은 goal·같은 iteration 수인데도
115
+ * 1.8배 차이가 났다 — maxLoops 는 비용 상한이 아니다. 엔진이 직접 세지 않으면
116
+ * 아무도 세지 않는다.
117
+ */
118
+ export function parseAgentOutput(stdout, fallbackSessionId = null) {
119
+ try {
120
+ const json = JSON.parse(stdout);
121
+ return {
122
+ text: typeof json.result === 'string' ? json.result : stdout,
123
+ sessionId: json.session_id ?? fallbackSessionId,
124
+ costUsd: Number.isFinite(json.total_cost_usd) ? json.total_cost_usd : 0,
125
+ };
126
+ } catch {
127
+ return { text: stdout, sessionId: fallbackSessionId, costUsd: 0 };
128
+ }
129
+ }
package/src/state.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * .agent-state.json 상태 저장소 (FR-3, NFR-4)
3
+ * - 원자적 쓰기(tmp -> rename)
4
+ * - 컨텍스트 다이어트: 저장 시 필드 길이를 강제로 자른다.
5
+ */
6
+ import { readFileSync, writeFileSync, renameSync, existsSync, rmSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+
10
+ export const STATE_VERSION = 1;
11
+ export const STATE_FILENAME = '.agent-state.json';
12
+
13
+ export const DEFAULT_SPEC_PATH = 'docs/spec.md';
14
+ export const DEFAULT_TEST_PATH = 'tests/app.test.js';
15
+
16
+ export const LIMITS = {
17
+ specSummary: 1200,
18
+ lastError: 2000,
19
+ historyKeep: 5,
20
+ historySummary: 300,
21
+ checkpointKeep: 10, // 최근 10개 체크포인트만 유지 (L3)
22
+ };
23
+
24
+ export function truncate(text, max) {
25
+ if (typeof text !== 'string') return '';
26
+ if (text.length <= max) return text;
27
+ // 에러는 뒷부분(실제 실패 지점)이 중요하므로 꼬리를 남긴다.
28
+ return `…(앞부분 ${text.length - max}자 생략)\n${text.slice(-max)}`;
29
+ }
30
+
31
+ /**
32
+ * 실패의 **안정적 지문** (L2 정체 감지용).
33
+ *
34
+ * lastError 전체를 해시하면 매번 달라진다 — 소요시간(`duration_ms 47.8`), 임시경로,
35
+ * 줄번호가 실행마다 바뀌기 때문이다. 그러면 같은 오답을 반복해도 "매번 다른 에러"로 보여
36
+ * 정체를 놓친다. 그래서 변하는 것(숫자·경로·16진수)을 지운 뼈대만 남긴다.
37
+ * 남는 것은 "무엇이 어떻게 깨졌는가"의 형태다 — `AssertionError`, `ReferenceError: X`,
38
+ * 무결성 위반 종류 같은 것.
39
+ */
40
+ export function errorSignature(text) {
41
+ return String(text ?? '')
42
+ .replace(/0x[0-9a-f]+/gi, '') // 포인터/해시
43
+ .replace(/\b[0-9a-f]{8,}\b/gi, '') // 긴 16진수(세션id 등)
44
+ .replace(/\d+(?:\.\d+)?\s*ms\b/g, '') // 소요시간
45
+ .replace(/:\d+:\d+/g, '') // :줄:열
46
+ .replace(/\/[^\s'"]+/g, '') // 절대경로
47
+ .replace(/\d+/g, '') // 남은 숫자 전부
48
+ .replace(/\s+/g, ' ')
49
+ .trim()
50
+ .slice(0, 500);
51
+ }
52
+
53
+ export function statePathFor(cwd) {
54
+ return join(cwd, STATE_FILENAME);
55
+ }
56
+
57
+ /**
58
+ * PLAN 이 쓸 spec/테스트 경로를 고른다.
59
+ *
60
+ * 기본 경로가 비어 있으면 그대로 쓴다. 이미 뭔가 있으면 goal 별 경로로 **비켜간다**.
61
+ * 이유: PLAN 프롬프트는 원래 `docs/spec.md` 와 `tests/app.test.js` 를 하드코딩했다.
62
+ * 그래서 (a) 그 파일들이 이미 있는 프로젝트에서 돌리면 남의 스펙·테스트를 덮어썼고,
63
+ * (b) 같은 프로젝트에서 goal 만 바꿔 두 번 돌리면 1회차의 산출물이 2회차에 파괴됐다.
64
+ * 롤백도 없다. 실측으로 확인된 동작이지 가정이 아니다 — 빈 디렉터리에서 돌리면
65
+ * 에이전트는 실제로 그 두 경로에 쓴다.
66
+ *
67
+ * goal 해시를 쓰는 이유: 같은 goal 이면 같은 경로가 나와야 resume 이 이어지고,
68
+ * 다른 goal 끼리는 충돌하지 않는다. 한국어 goal 도 파일명이 깨지지 않는다.
69
+ */
70
+ export function planPathsFor({ cwd, goal }) {
71
+ const slug = createHash('sha1').update(String(goal)).digest('hex').slice(0, 8);
72
+ return {
73
+ specPath: existsSync(join(cwd, DEFAULT_SPEC_PATH)) ? `docs/spec-${slug}.md` : DEFAULT_SPEC_PATH,
74
+ testPath: existsSync(join(cwd, DEFAULT_TEST_PATH)) ? `tests/app-${slug}.test.js` : DEFAULT_TEST_PATH,
75
+ };
76
+ }
77
+
78
+ export function createState({
79
+ goal,
80
+ testCommand,
81
+ maxLoops,
82
+ cwd = process.cwd(),
83
+ now = new Date().toISOString(),
84
+ }) {
85
+ // 경로는 **한 번만** 정하고 상태에 박아둔다. 매번 다시 계산하면 PLAN 이 만든 파일 때문에
86
+ // 2회차부터 경로가 밀려난다.
87
+ const { specPath, testPath } = planPathsFor({ cwd, goal });
88
+ return {
89
+ version: STATE_VERSION,
90
+ goal,
91
+ testCommand,
92
+ status: 'running',
93
+ phase: 'PLAN',
94
+ iteration: 0,
95
+ maxLoops,
96
+ sessionId: null,
97
+ sessionSerial: 1,
98
+ specPath,
99
+ testPath,
100
+ specSummary: '',
101
+ lastError: '',
102
+ costUsd: 0,
103
+ testFingerprint: {}, // L1 가드의 기준선. 빈 값 = 아직 비교할 대상 없음.
104
+ errorSig: '', // L2: 직전 실패의 안정적 지문. 정체 감지용.
105
+ stagnantRuns: 0, // L2: errorSig 가 연속 동일한 횟수.
106
+ checkpoints: [], // L3: [{iteration, sha}] git stash create 스냅샷.
107
+ deletedFiles: [], // L8: 에이전트가 지운 baseline 파일(관측용, 누적).
108
+ history: [],
109
+ startedAt: now,
110
+ updatedAt: now,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * 동일 goal 이면 auto-resume, 아니면 새 상태 (FR-3.5).
116
+ * resume 은 상태 로직이므로 state.js 가 제자리다 — loadState/createState/planPathsFor 를 여기서 쓴다.
117
+ */
118
+ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd }) {
119
+ const prev = existsSync(statePath) ? loadState(statePath) : null;
120
+ if (prev && prev.goal === goal && prev.status !== 'passed') {
121
+ // 필드가 없던 시절의 상태 파일도 이어받는다. 경로는 절대 다시 계산하지 않는다 —
122
+ // PLAN 이 이미 만든 파일 때문에 경로가 밀려나기 때문이다.
123
+ const paths = prev.specPath && prev.testPath ? {} : planPathsFor({ cwd, goal });
124
+ return {
125
+ ...paths,
126
+ ...prev,
127
+ specPath: prev.specPath ?? paths.specPath,
128
+ testPath: prev.testPath ?? paths.testPath,
129
+ costUsd: Number.isFinite(prev.costUsd) ? prev.costUsd : 0,
130
+ // 기준선이 없던 시절의 상태로 재개하면 이번 회차 지문이 곧 기준선이 된다.
131
+ // 그 사이에 벌어진 약화는 못 잡는다 — resume 은 과거를 복원하지 못한다.
132
+ testFingerprint: prev.testFingerprint ?? {},
133
+ errorSig: prev.errorSig ?? '',
134
+ stagnantRuns: Number.isFinite(prev.stagnantRuns) ? prev.stagnantRuns : 0,
135
+ checkpoints: Array.isArray(prev.checkpoints) ? prev.checkpoints : [],
136
+ deletedFiles: Array.isArray(prev.deletedFiles) ? prev.deletedFiles : [],
137
+ testCommand,
138
+ maxLoops,
139
+ status: 'running',
140
+ };
141
+ }
142
+ return createState({ goal, testCommand, maxLoops, cwd });
143
+ }
144
+
145
+ export function loadState(path) {
146
+ if (!existsSync(path)) return null;
147
+ try {
148
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
149
+ if (!parsed || parsed.version !== STATE_VERSION) return null;
150
+ return parsed;
151
+ } catch {
152
+ return null; // 손상된 상태 파일은 무시하고 새로 시작한다.
153
+ }
154
+ }
155
+
156
+ /** 원자적 저장 + 컨텍스트 다이어트 (FR-3.1, FR-3.3) */
157
+ export function saveState(path, state, { now = new Date().toISOString() } = {}) {
158
+ const compact = {
159
+ ...state,
160
+ specSummary: truncate(state.specSummary, LIMITS.specSummary),
161
+ lastError: truncate(state.lastError, LIMITS.lastError),
162
+ history: (state.history ?? []).slice(-LIMITS.historyKeep).map((h) => ({
163
+ ...h,
164
+ summary: truncate(h.summary ?? '', LIMITS.historySummary),
165
+ })),
166
+ updatedAt: now,
167
+ };
168
+ const tmp = join(dirname(path), `.${STATE_FILENAME}.${process.pid}.tmp`);
169
+ writeFileSync(tmp, `${JSON.stringify(compact, null, 2)}\n`, 'utf8');
170
+ renameSync(tmp, path); // 같은 디렉터리 내 rename == 원자적
171
+ return compact;
172
+ }
173
+
174
+ export function resetState(path) {
175
+ if (existsSync(path)) {
176
+ rmSync(path);
177
+ return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /**
183
+ * 완료 보고: 무엇을 관측했고 무엇을 안 했는가 (L10, moai 의 5-섹션 규약 중 Evidence+Gaps).
184
+ *
185
+ * 이 엔진의 `passed` 는 조용한 거짓말이 될 수 있다 — 테스트를 PLAN 단계에서 에이전트
186
+ * 자신이 썼기 때문이다. exit 0 의 실제 의미는 "에이전트가 자기가 쓴 테스트를 자기가
187
+ * 통과시켰다"이고, 스펙의 수용 기준 중 무엇이 테스트로 커버 안 됐는지는 아무도 안 본다.
188
+ *
189
+ * moai: "빈 Gaps 섹션은 '관측하지 않은 것이 없다'는 강한 주장이며, 그 주장 자체가
190
+ * 참이어야 한다." 그래서 숨기는 대신 매 완료에 공개한다. false green 을 disclosed green 으로.
191
+ */
192
+ export function reportGaps(state) {
193
+ const lines = ['', '## 검증 현황 (hi-loop 는 자기 판정의 한계를 숨기지 않는다)'];
194
+
195
+ const deleted = state.deletedFiles ?? [];
196
+
197
+ lines.push('관측한 것(Evidence):');
198
+ lines.push(`- ${state.testCommand} 종료 코드로 판정 (iteration ${state.iteration})`);
199
+ lines.push('- 테스트 파일 무결성 위반 없음 (직전 회차 대비 지문 비교)');
200
+ lines.push(`- 누적 비용 $${(state.costUsd ?? 0).toFixed(2)}`);
201
+ if (state.specVerified) lines.push('- 스펙 대비 구현을 별도 검증자가 심판(2단 판정 통과)');
202
+
203
+ lines.push('관측하지 않은 것(Gaps):');
204
+ lines.push(`- 이 테스트는 PLAN 단계에서 에이전트 자신이 \`${state.testPath || 'tests/app.test.js'}\`에 작성했다. 외부 오라클이 아니다.`);
205
+ if (!state.specVerified) {
206
+ lines.push(`- \`${state.specPath || 'docs/spec.md'}\`의 수용 기준 중 무엇이 테스트로 커버되지 않았는지는 검증하지 않았다(--verify-spec 로 켤 수 있다).`);
207
+ }
208
+ lines.push('- 런타임 동작·성능·보안은 관측 범위 밖이다.');
209
+ if (deleted.length) {
210
+ lines.push(`- ⚠️ 에이전트가 baseline 파일 ${deleted.length}개를 삭제했다(차단 안 함): ${deleted.slice(0, 5).join(', ')}${deleted.length > 5 ? ' …' : ''}. hi-loop rollback 으로 복원 가능.`);
211
+ }
212
+
213
+ return lines.join('\n');
214
+ }
215
+
216
+ export function summarizeState(state) {
217
+ if (!state) return '진행 중인 hi-loop 루프 상태가 없습니다.';
218
+ const lines = [
219
+ `goal: ${state.goal}`,
220
+ `status: ${state.status}${state.stopReason ? ` (${state.stopReason})` : ''} / phase: ${state.phase}`,
221
+ `iteration: ${state.iteration}/${state.maxLoops} (session #${state.sessionSerial})`,
222
+ `cost: $${(state.costUsd ?? 0).toFixed(2)}`,
223
+ ...(state.stagnantRuns > 1 ? [`stagnant: 같은 실패 ${state.stagnantRuns}회 연속`] : []),
224
+ `testCommand: ${state.testCommand}`,
225
+ `updatedAt: ${state.updatedAt}`,
226
+ ];
227
+ if (state.specSummary) lines.push(`spec: ${truncate(state.specSummary, 200)}`);
228
+ if (state.lastError) lines.push(`lastError: ${truncate(state.lastError, 400)}`);
229
+ return lines.join('\n');
230
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 텔레그램 비동기 알림 (FR-5)
3
+ * 설계 원칙: 알림은 절대 루프를 깨뜨리지 않는다. 모든 실패를 삼킨다.
4
+ */
5
+ export const TELEGRAM_MAX = 4096;
6
+
7
+ export function isConfigured(env = process.env) {
8
+ return Boolean(env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID);
9
+ }
10
+
11
+ /**
12
+ * @returns {Promise<{ok:boolean, skipped?:boolean, error?:string, status?:number}>}
13
+ */
14
+ export async function sendMessage(text, { env = process.env, fetchImpl = globalThis.fetch } = {}) {
15
+ if (!isConfigured(env)) return { ok: false, skipped: true };
16
+ if (typeof fetchImpl !== 'function') return { ok: false, error: 'fetch 사용 불가 (Node 18+ 필요)' };
17
+
18
+ const body = {
19
+ chat_id: env.TELEGRAM_CHAT_ID,
20
+ text: truncateForTelegram(text),
21
+ disable_web_page_preview: true,
22
+ };
23
+
24
+ try {
25
+ const res = await fetchImpl(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json' },
28
+ body: JSON.stringify(body),
29
+ });
30
+ if (!res?.ok) return { ok: false, status: res?.status, error: `텔레그램 API ${res?.status}` };
31
+ return { ok: true, status: res.status };
32
+ } catch (err) {
33
+ return { ok: false, error: err?.message ?? String(err) };
34
+ }
35
+ }
36
+
37
+ export function truncateForTelegram(text) {
38
+ const s = typeof text === 'string' ? text : String(text);
39
+ if (s.length <= TELEGRAM_MAX) return s;
40
+ return `${s.slice(0, TELEGRAM_MAX - 1)}…`;
41
+ }
42
+
43
+ const ICON = { start: '🚀', handoff: '♻️', passed: '✅', failed: '❌', check: '🔍' };
44
+
45
+ /** 루프 이벤트를 사람이 읽을 문장으로 바꿔 전송한다 (FR-5.5) */
46
+ export function makeNotifier(options = {}) {
47
+ return async (event) => {
48
+ const icon = ICON[event.type] ?? 'ℹ️';
49
+ const lines = [`${icon} [hi-loop] ${event.type}`, `goal: ${event.goal ?? '-'}`];
50
+ if (event.iteration != null) lines.push(`iteration: ${event.iteration}/${event.maxLoops ?? '?'}`);
51
+ if (event.detail) lines.push(String(event.detail));
52
+ return sendMessage(lines.join('\n'), options);
53
+ };
54
+ }
package/src/verify.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 스펙 오라클 (P6, L9) — bkit 의 중심 명제 이식.
3
+ *
4
+ * bkit: "코드와 스펙이 어긋나면, 틀린 쪽은 코드다." hi-loop 의 판정은 exit code 하나뿐인데,
5
+ * 그 테스트를 에이전트 자신이 썼다(L9). 즉 exit 0 은 "에이전트가 자기 테스트를 통과시켰다"일
6
+ * 뿐, 스펙의 수용 기준을 실제로 달성했는지는 아무도 안 봤다.
7
+ *
8
+ * 2단 판정으로 이걸 닫는다 (분석 §5, moai 의 goal evaluator 구조):
9
+ * - Tier 1 (기계, 최종 권한): exit code + 무결성. 실패면 끝. 어떤 모델도 못 뒤집는다.
10
+ * - Tier 2 (모델, 하향 전용): 스펙 대비 구현 심판. **오직 기각만 가능** — Tier 1 통과를
11
+ * 되돌릴 수는 있어도, Tier 1 실패를 통과로 올릴 권한은 절대 없다.
12
+ *
13
+ * 검증자 격리 (bkit 의 context:fork + disallowedTools):
14
+ * - 쓰기 권한 없음: `--permission-mode plan` — 검증 대상을 물리적으로 못 고친다.
15
+ * - 새 세션: sessionId 없이 호출 — 구현자의 추론에 오염되지 않는다.
16
+ *
17
+ * opt-in 이다(--verify-spec). 검증자 호출이 붙어 비용이 늘기 때문. G1(판정 주체는 엔진)은
18
+ * 유지된다 — 최종 권한은 여전히 Tier 1 이고, Tier 2 는 기각만 하는 심판이다.
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import { execFile } from 'node:child_process';
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { buildAgentArgs, parseAgentOutput } from './runners.js';
25
+ import { verifyPrompt } from './prompts.js';
26
+
27
+ function gitDiff(cwd) {
28
+ return new Promise((resolve) => {
29
+ // 워킹트리 전체 diff (추적 파일). 스펙·테스트 대비 구현이 무엇을 했는지.
30
+ execFile('git', ['diff', 'HEAD'], { cwd, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
31
+ resolve(err ? '' : String(stdout));
32
+ });
33
+ });
34
+ }
35
+
36
+ /** JSON 판정을 관대하게 파싱한다. 형식이 깨지면 "통과"로 흘린다(하향 전용이므로 안전). */
37
+ export function parseVerdict(text) {
38
+ try {
39
+ const m = String(text).match(/\{[\s\S]*"verdict"[\s\S]*\}/);
40
+ const obj = JSON.parse(m ? m[0] : text);
41
+ const verdict = obj.verdict === 'reject' ? 'reject' : 'pass';
42
+ return { verdict, reason: typeof obj.reason === 'string' ? obj.reason : '' };
43
+ } catch {
44
+ // 판정 형식을 못 읽으면 기각하지 않는다 — 검증자가 헛소리를 했다고 통과를
45
+ // 막으면, 잘못된 입력이 방어를 "더 약하게"가 아니라 "더 세게" 만드는 게 아니라
46
+ // 반대가 된다. Tier 2 는 하향 전용이고 확신 없으면 통과가 원칙이다.
47
+ return { verdict: 'pass', reason: '검증자 응답을 파싱하지 못해 통과로 처리(하향 전용)' };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * 주입 가능한 스펙 검증 포트 (NFR-3). `({ specPath, cwd }) => { verdict, reason }`
53
+ * 기본 구현은 별도 claude 를 plan 모드·새 세션으로 spawn 한다.
54
+ */
55
+ export function makeSpecVerifier({
56
+ command = process.env.HILOOP_VERIFY_CMD || process.env.HILOOP_AGENT_CMD || 'claude',
57
+ model = process.env.HILOOP_VERIFY_MODEL || '',
58
+ timeoutMs = 10 * 60 * 1000,
59
+ } = {}) {
60
+ return ({ specPath, cwd }) =>
61
+ new Promise((resolve) => {
62
+ const specFull = specPath ? join(cwd, specPath) : null;
63
+ if (!specFull || !existsSync(specFull)) {
64
+ // 스펙이 없으면 대조할 오라클이 없다 → 기각할 근거 없음 → 통과.
65
+ resolve({ verdict: 'pass', reason: '스펙 파일이 없어 검증 생략' });
66
+ return;
67
+ }
68
+ const specText = readFileSync(specFull, 'utf8');
69
+ gitDiff(cwd).then((diffText) => {
70
+ const prompt = verifyPrompt({ specText, diffText });
71
+ // 새 세션(sessionId 없음) + plan 모드(쓰기 금지). 모델을 따로 지정할 수 있다.
72
+ const extraArgs = model ? ['--model', model] : [];
73
+ const args = buildAgentArgs({ prompt, sessionId: null, extraArgs, permissionMode: 'plan' });
74
+ const child = spawn(command, args, { cwd, env: process.env });
75
+ let stdout = '';
76
+ let stderr = '';
77
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
78
+ child.stdout.on('data', (d) => (stdout += d));
79
+ child.stderr.on('data', (d) => (stderr += d));
80
+ child.on('error', () => {
81
+ clearTimeout(timer);
82
+ // 검증자 실행 자체가 실패하면 통과를 막지 않는다(인프라 장애가 판정을 뒤집으면 안 됨).
83
+ resolve({ verdict: 'pass', reason: `검증자 실행 실패 — 통과로 처리` });
84
+ });
85
+ child.on('close', (code) => {
86
+ clearTimeout(timer);
87
+ if (code !== 0) {
88
+ resolve({ verdict: 'pass', reason: `검증자가 코드 ${code}로 종료 — 통과로 처리` });
89
+ return;
90
+ }
91
+ const { text } = parseAgentOutput(stdout);
92
+ resolve(parseVerdict(text));
93
+ });
94
+ });
95
+ });
96
+ }