@tuzi-ince/hi-loop 0.1.2 → 0.2.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.
package/src/prompts.js CHANGED
@@ -17,6 +17,14 @@ const testPathOf = (state) => state.testPath || DEFAULT_TEST_PATH;
17
17
  /** 핸드오프 이후 새 세션에 넘길 압축 컨텍스트 (FR-3.3) */
18
18
  export function contextBlock(state) {
19
19
  const parts = [`## 목표(goal)\n${state.goal}`];
20
+ // 확정된 가정은 goal 다음으로 중요하다. 이게 안 실리면 DISCOVER 가 정한 것들이
21
+ // 구현자에게 전달되지 않고, 에이전트는 같은 모호함을 매번 자기 마음대로 다시 정한다.
22
+ if (state.assumptions?.length) {
23
+ const listed = state.assumptions
24
+ .map((a) => `- ${truncate(a.text ?? '', 300)}${a.decidedBy === 'human' ? ' (사람이 선택 — 반드시 지켜라)' : ''}`)
25
+ .join('\n');
26
+ parts.push(`## 확정된 가정 (이미 정해진 것들이다. 다시 정하지 마라)\n${listed}`);
27
+ }
20
28
  if (state.specSummary) {
21
29
  parts.push(`## 지금까지의 스펙 요약\n${truncate(state.specSummary, LIMITS.specSummary)}`);
22
30
  }
@@ -123,3 +131,133 @@ ${truncate(String(diffText ?? '(변경 없음)'), 8000)}
123
131
  ## 출력 (반드시 이 JSON 한 줄로만)
124
132
  {"verdict": "pass" 또는 "reject", "reason": "한 문장 근거"}`;
125
133
  }
134
+
135
+ /**
136
+ * 발굴 프롬프트 (FR-10). 이 단계의 어려운 부분은 "질문을 많이 만들지 않는 것"이다.
137
+ *
138
+ * 모호한 지점마다 사람을 부르면 이 엔진은 자율성을 잃는다. 그래서 기본 지시는
139
+ * **가정을 세우고 기록한 뒤 진행하라**이고, 사람을 부르는 건 "가정으로 답할 수 없는
140
+ * 상호배타 분기" 하나뿐이다. 그 판단 기준을 프롬프트에 명시적으로 못 박는다 —
141
+ * 안 그러면 모델은 친절하게 굴려고 질문을 잔뜩 만든다.
142
+ */
143
+ export function discoverPrompt({ goal }) {
144
+ return `당신은 요구사항을 발굴하는 분석가다. 파일을 만들지 마라 — 분석 결과만 JSON 으로 낸다.
145
+
146
+ ## 목표(goal)
147
+ ${truncate(String(goal ?? ''), 2000)}
148
+
149
+ ## 할 일
150
+ 이 goal 로 구현을 시작하기 전에, goal 만으로는 정해지지 않는 것들을 찾아 **결정하라.**
151
+
152
+ 1. **가정(assumptions)**: 모호한 지점마다 가장 합리적인 선택을 하고 그것을 문장으로 기록하라.
153
+ - 기술 선택, 범위 경계, 에러 처리 방침, 기본값, 성능·보안 기준선 같은 것.
154
+ - "무엇을 어떻게 정했다"까지 써라. "인증 방식을 정해야 한다"는 가정이 아니다.
155
+ "인증은 HS256 JWT 를 쓰고 만료는 1시간으로 한다"가 가정이다.
156
+ - 사람에게 묻지 말고 **결정하라.** 이 목록이 이 단계의 산출물이다.
157
+
158
+ 2. **분기(forks)**: 위 방식으로 **결정할 수 없는 것**만 골라라. 조건은 셋 다 만족해야 한다:
159
+ - 선택지가 상호 배타적이다(둘 다 취할 수 없다).
160
+ - 어느 쪽이냐에 따라 **스펙 전체가 달라진다**(구현 세부가 아니라 방향이 갈린다).
161
+ - 근거만으로는 한쪽이 명백히 낫다고 말할 수 없다.
162
+ 해당하는 게 없으면 **빈 배열로 두라. 그게 정상이다.**
163
+ 해당하는 게 여럿이면 **가장 중요한 것 하나만** 내라.
164
+
165
+ ## 출력 (반드시 이 JSON 한 줄로만)
166
+ {"summary": "goal 을 한 문단으로 해석한 것",
167
+ "assumptions": [{"text": "무엇을 어떻게 정했다"}],
168
+ "forks": [{"question": "무엇을 골라야 하는가", "options": [{"label": "선택지", "impact": "이걸 고르면 무엇이 달라지는가"}]}]}`;
169
+ }
170
+
171
+ /**
172
+ * 설계 리뷰어 프롬프트 (FR-11). PLAN 직후, 구현이 시작되기 **전**에 부른다.
173
+ *
174
+ * 여기가 가장 값싼 교정 지점이다. 스펙이 goal 을 잘못 읽었으면 그 뒤의 모든 것 —
175
+ * 구현, 치유, 리뷰, 배포 — 이 통째로 틀린 것을 향해 간다. 그리고 CHECK 는 그걸 못 잡는다:
176
+ * 테스트도 같은 잘못된 스펙에서 나왔기 때문에 사이좋게 통과한다(L9 의 근원).
177
+ *
178
+ * 그래서 심사의 두 축이 이렇게 정해진다:
179
+ * 1. 스펙이 goal 을 충실히 해석했는가 (누락·왜곡·범위 초과)
180
+ * 2. 수용 기준이 **테스트로 검증 가능한가** — 이게 핵심이다. 검증 불가능한 수용 기준은
181
+ * 나중에 반드시 false green 을 만든다. "사용자 친화적이어야 한다" 같은 것.
182
+ */
183
+ export function designReviewPrompt({ goal, assumptions = [], specText, testText }) {
184
+ const assumed = assumptions.length
185
+ ? assumptions.map((a) => `- ${a.text}${a.decidedBy === 'human' ? ' (사람이 선택)' : ''}`).join('\n')
186
+ : '(없음)';
187
+ return `당신은 구현 착수 전 설계를 심판하는 독립 리뷰어다. 파일을 고치지 마라 — 판정만 한다.
188
+
189
+ ## 목표(goal)
190
+ ${truncate(String(goal ?? ''), 1500)}
191
+
192
+ ## 확정된 가정
193
+ ${truncate(assumed, 1500)}
194
+
195
+ ## 스펙 (심사 대상)
196
+ ${truncate(String(specText ?? '(스펙 없음)'), 6000)}
197
+
198
+ ## 테스트 (심사 대상)
199
+ \`\`\`
200
+ ${truncate(String(testText ?? '(테스트 없음)'), 5000)}
201
+ \`\`\`
202
+
203
+ ## 심사 축 (이 둘만)
204
+ 1. **해석 정합**: 스펙이 goal 을 충실히 옮겼는가. goal 이 요구한 것 중 빠진 것,
205
+ goal 이 요구하지 않은 것 중 들어온 것(범위 초과), 뜻이 뒤집힌 것.
206
+ 2. **검증 가능성**: 각 수용 기준이 위 테스트로 실제 판정되는가.
207
+ - 테스트가 없는 수용 기준 → 지적하라.
208
+ - "빠르게", "안전하게", "사용자 친화적" 처럼 **기계가 판정할 수 없는 기준** → 지적하라.
209
+ 이런 기준은 나중에 통과 판정을 거짓말로 만든다.
210
+
211
+ ## 심사에서 제외
212
+ - 구현 방식·아키텍처 취향. 아직 구현은 없다.
213
+ - 문서 형식·분량.
214
+
215
+ ## 판정 기준
216
+ - 확신이 없으면 통과시켜라. 당신은 **하향 전용**이다.
217
+ - 기각하려면 무엇을 어떻게 고쳐야 하는지가 지적에 담겨야 한다.
218
+
219
+ ## 출력 (반드시 이 JSON 한 줄로만)
220
+ {"verdict": "pass" 또는 "reject", "reason": "무엇이 어긋났고 어떻게 고쳐야 하는가"}`;
221
+ }
222
+
223
+ /**
224
+ * 코드 리뷰어 프롬프트 (FR-12). verifyPrompt 와 축이 다르다:
225
+ * - verifyPrompt: **스펙 대비** — 요구한 걸 했는가.
226
+ * - 이 프롬프트: **품질 대비** — 한 것이 안전하고 군더더기 없는가.
227
+ * 둘 다 켜도 중복이 아니다. 다만 둘 다 하향 전용이고, Tier1(exit code)을 뒤집지 못한다.
228
+ *
229
+ * 스타일을 심사 대상에서 뺀 이유(FR-12.2): 들여쓰기·네이밍은 린터의 일이다. 모델에게
230
+ * 시키면 진짜 결함이 스타일 지적 20건에 묻힌다. 리뷰의 값어치는 신호 대 잡음비다.
231
+ */
232
+ export function reviewPrompt({ goal, specText, diffText }) {
233
+ return `당신은 독립 코드 리뷰어다. 코드를 고치지 마라 — 지적만 한다.
234
+
235
+ ## 목표(goal)
236
+ ${truncate(String(goal ?? ''), 1000)}
237
+
238
+ ## 스펙
239
+ ${truncate(String(specText ?? '(스펙 없음)'), 5000)}
240
+
241
+ ## 리뷰 대상 (git diff)
242
+ \`\`\`diff
243
+ ${truncate(String(diffText ?? '(변경 없음)'), 10000)}
244
+ \`\`\`
245
+
246
+ ## 심사 축 (이 넷만)
247
+ 1. **정확성**: 경계값·에러 경로·비동기 경합에서 깨지는가. 테스트가 놓친 실패 시나리오.
248
+ 2. **보안**: injection, 검증 없는 입력, 노출된 비밀값, 안전하지 않은 기본값.
249
+ 3. **과잉구현(YAGNI)**: 스펙이 요구하지 않은 기능·추상화·설정. 이건 결함이다.
250
+ 4. **명백한 성능 함정**: 루프 안의 O(n²), 불필요한 동기 I/O 같은 것.
251
+
252
+ ## 심사에서 제외 (지적하지 마라)
253
+ - 코드 스타일, 들여쓰기, 네이밍 취향, 주석 분량 — 린터의 일이다.
254
+ - "이렇게 하면 더 좋다" 류의 선호. 결함만 말하라.
255
+
256
+ ## 판정 기준
257
+ - 확신이 없으면 통과시켜라. 당신은 **하향 전용**이다 — 명백한 결함만 기각하라.
258
+ - 기각하려면 각 지적이 **구체적 실패 시나리오**를 가져야 한다. "견고하지 않다"는 지적이 아니다.
259
+ - 지적이 없으면 findings 는 빈 배열이고 verdict 는 pass 다.
260
+
261
+ ## 출력 (반드시 이 JSON 한 줄로만)
262
+ {"verdict": "pass" 또는 "reject", "findings": [{"severity": "high|medium|low", "text": "무엇이 어떤 입력에서 어떻게 깨지는가"}]}`;
263
+ }
package/src/report.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 사람이 읽는 보고 — 완료 시 Gaps 공개(L10)와 상태 요약.
3
+ *
4
+ * state.js 에서 뺀 이유는 크기(NFR-5)만이 아니다. 이 둘은 **상태를 바꾸지 않고**
5
+ * 상태를 읽어 문장으로 만들 뿐이라, 저장·마이그레이션 로직과 섞일 이유가 없다.
6
+ */
7
+ import { truncate } from './state.js';
8
+ import { formatAsk } from './ask.js';
9
+
10
+
11
+ /**
12
+ * 완료 보고: 무엇을 관측했고 무엇을 안 했는가 (L10, moai 의 5-섹션 규약 중 Evidence+Gaps).
13
+ *
14
+ * 이 엔진의 `passed` 는 조용한 거짓말이 될 수 있다 — 테스트를 PLAN 단계에서 에이전트
15
+ * 자신이 썼기 때문이다. exit 0 의 실제 의미는 "에이전트가 자기가 쓴 테스트를 자기가
16
+ * 통과시켰다"이고, 스펙의 수용 기준 중 무엇이 테스트로 커버 안 됐는지는 아무도 안 본다.
17
+ *
18
+ * moai: "빈 Gaps 섹션은 '관측하지 않은 것이 없다'는 강한 주장이며, 그 주장 자체가
19
+ * 참이어야 한다." 그래서 숨기는 대신 매 완료에 공개한다. false green 을 disclosed green 으로.
20
+ */
21
+ export function reportGaps(state) {
22
+ const lines = ['', '## 검증 현황 (hi-loop 는 자기 판정의 한계를 숨기지 않는다)'];
23
+
24
+ const deleted = state.deletedFiles ?? [];
25
+
26
+ lines.push('관측한 것(Evidence):');
27
+ lines.push(`- ${state.testCommand} 종료 코드로 판정 (iteration ${state.iteration})`);
28
+ lines.push('- 테스트 파일 무결성 위반 없음 (직전 회차 대비 지문 비교)');
29
+ lines.push(`- 누적 비용 $${(state.costUsd ?? 0).toFixed(2)}`);
30
+ if (state.specVerified) lines.push('- 스펙 대비 구현을 별도 검증자가 심판(2단 판정 통과)');
31
+ if (state.ship) lines.push(`- 배포 명령 \`${state.ship.command}\` 종료 코드 ${state.ship.code}`);
32
+ if (state.watch) {
33
+ lines.push(`- 배포 후 헬스체크 ${state.watch.checks}회 (실패 ${state.watch.failures}회)`);
34
+ }
35
+ if (state.assumptions?.length) {
36
+ lines.push(`- 확정된 가정 ${state.assumptions.length}건:`);
37
+ for (const a of state.assumptions.slice(-5)) {
38
+ lines.push(` · ${truncate(a.text ?? '', 160)} (${a.decidedBy === 'human' ? '사람이 선택' : '에이전트 가정'})`);
39
+ }
40
+ }
41
+
42
+ lines.push('관측하지 않은 것(Gaps):');
43
+ lines.push(`- 이 테스트는 PLAN 단계에서 에이전트 자신이 \`${state.testPath || 'tests/app.test.js'}\`에 작성했다. 외부 오라클이 아니다.`);
44
+ if (!state.specVerified) {
45
+ lines.push(`- \`${state.specPath || 'docs/spec.md'}\`의 수용 기준 중 무엇이 테스트로 커버되지 않았는지는 검증하지 않았다(--verify-spec 로 켤 수 있다).`);
46
+ }
47
+ if (!state.watch) lines.push('- 런타임 동작·성능·보안은 관측 범위 밖이다.');
48
+ // 리뷰 상한을 소진하고 남은 지적은 숨기지 않는다. 이게 disclosed green 의 핵심이다 —
49
+ // "리뷰를 돌렸다"가 아니라 "리뷰가 지적했는데 못 고친 게 이것"을 밝히는 것.
50
+ if (state.reviewFindings?.length) {
51
+ lines.push(`- ⚠️ 코드 리뷰가 지적했으나 해소되지 않은 항목 ${state.reviewFindings.length}건:`);
52
+ for (const f of state.reviewFindings.slice(0, 5)) {
53
+ lines.push(` · ${truncate(typeof f === 'string' ? f : (f.text ?? ''), 160)}`);
54
+ }
55
+ }
56
+ if (state.assumptions?.some((a) => a.decidedBy === 'agent')) {
57
+ lines.push('- 위 가정 중 에이전트가 스스로 세운 것은 사람이 확인하지 않았다.');
58
+ }
59
+ if (deleted.length) {
60
+ lines.push(`- ⚠️ 에이전트가 baseline 파일 ${deleted.length}개를 삭제했다(차단 안 함): ${deleted.slice(0, 5).join(', ')}${deleted.length > 5 ? ' …' : ''}. hi-loop rollback 으로 복원 가능.`);
61
+ }
62
+
63
+ return lines.join('\n');
64
+ }
65
+
66
+ export function summarizeState(state) {
67
+ if (!state) return '진행 중인 hi-loop 루프 상태가 없습니다.';
68
+ const lines = [
69
+ `goal: ${state.goal}`,
70
+ `status: ${state.status}${state.stopReason ? ` (${state.stopReason})` : ''} / stage: ${state.stage ?? 'BUILD'} / phase: ${state.phase}`,
71
+ `iteration: ${state.iteration}/${state.maxLoops} (session #${state.sessionSerial})`,
72
+ `cost: $${(state.costUsd ?? 0).toFixed(2)}`,
73
+ ...(state.stagnantRuns > 1 ? [`stagnant: 같은 실패 ${state.stagnantRuns}회 연속`] : []),
74
+ `testCommand: ${state.testCommand}`,
75
+ `updatedAt: ${state.updatedAt}`,
76
+ ];
77
+ if (state.assumptions?.length) lines.push(`assumptions: ${state.assumptions.length}건`);
78
+ if (state.ship) lines.push(`ship: ${state.ship.command} → code ${state.ship.code}`);
79
+ if (state.watch) lines.push(`watch: ${state.watch.checks}회 검사 / 실패 ${state.watch.failures}회`);
80
+ if (state.specSummary) lines.push(`spec: ${truncate(state.specSummary, 200)}`);
81
+ if (state.lastError) lines.push(`lastError: ${truncate(state.lastError, 400)}`);
82
+ // 대기 중인 질문은 상태 요약의 결론이다 — 이걸 안 보여주면 사용자는 루프가 왜 멈췄는지 모른다.
83
+ if (state.ask) lines.push('', formatAsk(state.ask));
84
+ return lines.join('\n');
85
+ }
package/src/review.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 코드 리뷰어 (FR-12) — 품질 축의 Tier 2 심판.
3
+ *
4
+ * verify.js 와 같은 격리를 쓴다:
5
+ * - 쓰기 권한 없음(`--permission-mode plan`): 심사 대상을 물리적으로 못 고친다.
6
+ * - 새 세션(sessionId 없음): 구현자의 추론에 오염되지 않는다.
7
+ *
8
+ * 그리고 같은 권한 제약을 진다 — **하향 전용**이다. Tier1(exit code + 무결성) 실패를
9
+ * 통과로 올릴 수는 없고, 통과를 기각할 수만 있다(L9).
10
+ *
11
+ * 기각의 결과는 "실패"가 아니라 **HEAL 로의 복귀**다. 지적을 다음 루프의 연료로 삼아
12
+ * 안쪽 루프가 고치게 한다 — 새 치유 경로를 만들지 않고 이미 검증된 것을 재사용한다.
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { buildAgentArgs, parseAgentOutput } from './runners.js';
18
+ import { reviewPrompt, designReviewPrompt } from './prompts.js';
19
+ import { gitDiff } from './verify.js';
20
+ import { parseVerdict } from './verify.js';
21
+
22
+ const SEVERITIES = ['high', 'medium', 'low'];
23
+
24
+ /**
25
+ * 리뷰 판정을 관대하게 파싱한다. 형식이 깨지면 **통과**로 흘린다.
26
+ * 리뷰어가 헛소리를 했다고 빌드를 막으면, 잘못된 입력이 방어를 더 세게 만드는 게 아니라
27
+ * 그냥 진행을 막는 고장이 된다. 하향 전용의 원칙은 "확신 없으면 통과"다.
28
+ */
29
+ export function parseReview(text) {
30
+ try {
31
+ const m = String(text).match(/\{[\s\S]*"verdict"[\s\S]*\}/);
32
+ const obj = JSON.parse(m ? m[0] : text);
33
+ const findings = (Array.isArray(obj.findings) ? obj.findings : [])
34
+ .map((f) => ({
35
+ severity: SEVERITIES.includes(f?.severity) ? f.severity : 'medium',
36
+ text: String(f?.text ?? f ?? '').trim(),
37
+ }))
38
+ .filter((f) => f.text);
39
+ // 지적이 하나도 없는데 reject 면 고칠 대상이 없다는 뜻이다. 그런 기각은 무한 루프만 만든다.
40
+ const verdict = obj.verdict === 'reject' && findings.length ? 'reject' : 'pass';
41
+ return { verdict, findings };
42
+ } catch {
43
+ return { verdict: 'pass', findings: [] };
44
+ }
45
+ }
46
+
47
+ /** findings 를 다음 루프의 연료로 조립한다. 심각도 순으로 세워야 앞부분이 안 잘린다. */
48
+ export function findingsToFuel(findings, { specPath } = {}) {
49
+ const ordered = [...findings].sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
50
+ const lines = [
51
+ '코드 리뷰가 이 구현을 기각했다. 테스트는 통과했지만 아래 결함이 남아 있다:',
52
+ ...ordered.map((f, i) => `${i + 1}. [${f.severity}] ${f.text}`),
53
+ '',
54
+ '각 지적의 근본 원인을 고쳐라. 테스트를 고쳐서 지적을 회피하지 마라.',
55
+ ];
56
+ if (specPath) lines.push(`스펙(${specPath})의 범위를 벗어나는 기능을 새로 넣지도 마라.`);
57
+ return lines.join('\n');
58
+ }
59
+
60
+ /** 격리된 심판자를 한 번 돌리고 원문 텍스트를 돌려준다. 격리 규칙은 여기 한 곳에만 둔다. */
61
+ function judge({ command, model, timeoutMs, prompt, cwd }) {
62
+ return new Promise((resolve) => {
63
+ const extraArgs = model ? ['--model', model] : [];
64
+ // 쓰기 금지(plan) + 새 세션(sessionId 없음). 심판자는 대상을 못 고치고, 구현자의
65
+ // 추론을 물려받지도 않는다 — bkit 의 context:fork 와 같은 격리다.
66
+ const args = buildAgentArgs({ prompt, sessionId: null, extraArgs, permissionMode: 'plan' });
67
+ const child = spawn(command, args, { cwd, env: process.env });
68
+ let stdout = '';
69
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
70
+ child.stdout.on('data', (d) => (stdout += d));
71
+ child.stderr.on('data', () => {});
72
+ // 인프라 장애(실행 실패·비정상 종료)는 판정을 뒤집지 못한다. 빈 문자열을 돌려주면
73
+ // 각 파서가 "확신 없음 → 통과"로 흘린다.
74
+ child.on('error', () => {
75
+ clearTimeout(timer);
76
+ resolve('');
77
+ });
78
+ child.on('close', (code) => {
79
+ clearTimeout(timer);
80
+ resolve(code === 0 ? parseAgentOutput(stdout).text : '');
81
+ });
82
+ });
83
+ }
84
+
85
+ function readIfExists(cwd, relPath) {
86
+ const full = relPath ? join(cwd, relPath) : null;
87
+ return full && existsSync(full) ? readFileSync(full, 'utf8') : '';
88
+ }
89
+
90
+ /**
91
+ * 주입 가능한 설계 리뷰 포트 (FR-11).
92
+ * `({ goal, assumptions, specPath, testPath, cwd }) => { verdict, reason }`
93
+ */
94
+ export function makeDesignReviewer({
95
+ command = process.env.HILOOP_REVIEW_CMD || process.env.HILOOP_AGENT_CMD || 'claude',
96
+ model = process.env.HILOOP_REVIEW_MODEL || process.env.HILOOP_VERIFY_MODEL || '',
97
+ timeoutMs = 10 * 60 * 1000,
98
+ } = {}) {
99
+ return async ({ goal, assumptions = [], specPath, testPath, cwd }) => {
100
+ const specText = readIfExists(cwd, specPath);
101
+ // 스펙이 없으면 심사할 대상이 없다 → 기각할 근거도 없다. PLAN 이 아무것도 안 만든
102
+ // 경우인데, 그건 CHECK 가 어차피 잡는다.
103
+ if (!specText.trim()) return { verdict: 'pass', reason: '스펙 파일이 없어 설계 리뷰 생략' };
104
+ const prompt = designReviewPrompt({ goal, assumptions, specText, testText: readIfExists(cwd, testPath) });
105
+ return parseVerdict(await judge({ command, model, timeoutMs, prompt, cwd }));
106
+ };
107
+ }
108
+
109
+ /**
110
+ * 주입 가능한 코드 리뷰 포트 (NFR-3). `({ goal, specPath, cwd }) => { verdict, findings }`
111
+ * 기본 구현은 별도 claude 를 plan 모드·새 세션으로 spawn 한다.
112
+ */
113
+ export function makeCodeReviewer({
114
+ command = process.env.HILOOP_REVIEW_CMD || process.env.HILOOP_AGENT_CMD || 'claude',
115
+ model = process.env.HILOOP_REVIEW_MODEL || process.env.HILOOP_VERIFY_MODEL || '',
116
+ timeoutMs = 10 * 60 * 1000,
117
+ } = {}) {
118
+ return async ({ goal, specPath, cwd }) => {
119
+ const diffText = await gitDiff(cwd);
120
+ // 변경이 없으면 심사할 대상이 없다 → 기각할 근거도 없다.
121
+ if (!String(diffText ?? '').trim()) return { verdict: 'pass', findings: [] };
122
+ const prompt = reviewPrompt({ goal, specText: readIfExists(cwd, specPath), diffText });
123
+ return parseReview(await judge({ command, model, timeoutMs, prompt, cwd }));
124
+ };
125
+ }
package/src/ship.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * SHIP / WATCH 어댑터 (FR-13, FR-14).
3
+ *
4
+ * 이 두 단계는 **에이전트를 부르지 않는다.** 배포와 헬스체크는 결정론적 작업이고,
5
+ * 판정은 exit code 하나로 끝난다. 모델을 끼워 넣으면 "배포됐다고 모델이 말했다"가
6
+ * 판정이 되는데, 그건 이 엔진이 CHECK 에서 이미 거부한 방식이다(G1).
7
+ *
8
+ * 부작용으로 비용도 0 이다 — budgetUsd 를 건드리지 않는다.
9
+ */
10
+ import { makeTestRunner } from './runners.js';
11
+
12
+ /**
13
+ * 배포 러너. 시그니처가 testRunner 와 같은 건 우연이 아니다 —
14
+ * 둘 다 "명령을 돌리고 exit code 로 판정"이다. 같은 구현을 재사용한다.
15
+ */
16
+ export function makeShipper({ timeoutMs } = {}) {
17
+ return makeTestRunner(timeoutMs ? { timeoutMs } : {});
18
+ }
19
+
20
+ /** "5m", "30s", "1500" → ms. 잘못된 값이면 null (호출자가 exit 2 로 거른다). */
21
+ export function parseDuration(value, { fallback = null } = {}) {
22
+ if (value === undefined || value === null || value === true || value === '') return fallback;
23
+ const m = String(value).trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i);
24
+ if (!m) return null;
25
+ const n = Number(m[1]);
26
+ if (!Number.isFinite(n) || n <= 0) return null;
27
+ const unit = (m[2] ?? 'ms').toLowerCase();
28
+ const mult = { ms: 1, s: 1000, m: 60 * 1000, h: 60 * 60 * 1000 }[unit];
29
+ return Math.round(n * mult);
30
+ }
31
+
32
+ export const WATCH_DEFAULTS = {
33
+ forMs: 5 * 60 * 1000,
34
+ everyMs: 30 * 1000,
35
+ tolerate: 1,
36
+ };
37
+
38
+ /**
39
+ * 헬스체크 감시자 (FR-14).
40
+ *
41
+ * 1회 실패로 즉시 실패 판정하지 않는 이유: 배포 직후에는 워밍업·롤링 교체 구간이 있어
42
+ * 정상 배포에서도 한두 번은 깨진다. 그걸 실패로 치면 감시자가 멀쩡한 배포를 되돌린다.
43
+ * 그래서 **누적 실패가 tolerate 를 넘을 때만** 실패다.
44
+ *
45
+ * 성공 판정은 "forMs 동안 버텼는가"다. 마지막 상태만 보면 안 된다 — 죽었다 살아나는
46
+ * 서비스도 마지막 한 번은 성공하기 때문이다.
47
+ *
48
+ * sleep/now 는 주입 가능하다. 안 그러면 이 함수의 테스트가 실제로 5분을 기다려야 한다.
49
+ */
50
+ export function makeWatcher({
51
+ runner = makeTestRunner(),
52
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
53
+ now = () => Date.now(),
54
+ } = {}) {
55
+ return async ({ command, cwd, forMs = WATCH_DEFAULTS.forMs, everyMs = WATCH_DEFAULTS.everyMs, tolerate = WATCH_DEFAULTS.tolerate, logger = () => {} }) => {
56
+ const startedAt = now();
57
+ let checks = 0;
58
+ let failures = 0;
59
+ const errors = [];
60
+
61
+ // do-while: forMs 가 아무리 짧아도 **최소 1회는** 검사한다. 0회 검사하고 "통과"라고
62
+ // 하면 감시하지 않은 것을 감시했다고 말하는 셈이다.
63
+ do {
64
+ const res = await runner({ command, cwd });
65
+ checks += 1;
66
+ if (!res?.ok) {
67
+ failures += 1;
68
+ const detail = [res?.stderr, res?.stdout].filter(Boolean).join('\n').trim();
69
+ errors.push(`#${checks} exit ${res?.code}: ${detail.slice(-400)}`);
70
+ logger(`[hi-loop] 🩺 헬스체크 실패 ${failures}/${tolerate + 1} (검사 ${checks}회) — code ${res?.code}`);
71
+ if (failures > tolerate) {
72
+ return { ok: false, checks, failures, errors, elapsedMs: now() - startedAt };
73
+ }
74
+ }
75
+ const remaining = forMs - (now() - startedAt);
76
+ if (remaining <= 0) break;
77
+ await sleep(Math.min(everyMs, remaining));
78
+ } while (now() - startedAt < forMs);
79
+
80
+ return { ok: true, checks, failures, errors, elapsedMs: now() - startedAt };
81
+ };
82
+ }
package/src/stages.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * 라이프사이클 stage 핸들러 (FR-10, FR-12 ~ FR-14).
3
+ *
4
+ * 안쪽 루프(BUILD)는 loop.js 에 그대로 두고, **그 앞뒤에 붙는 껍질**만 여기로 뺐다.
5
+ * loop.js 가 653줄까지 불어 NFR-5(파일당 300줄)를 어겼기 때문이다.
6
+ *
7
+ * 각 핸들러는 상태를 직접 진행시키지 않고 **지시(directive)** 를 돌려준다. 다음 stage 를
8
+ * 정하는 권한은 loop.js 의 stage 머신 한 곳에만 둔다 — 여러 곳이 stage 를 옮기기 시작하면
9
+ * 흐름을 한눈에 볼 수 없게 된다.
10
+ *
11
+ * { next: '<stage>' } 다음 stage 로
12
+ * { pause: true } 사람의 답을 기다리며 종료
13
+ * { finish: { stopReason, detail } } 실패로 종료
14
+ *
15
+ * ctx 는 loop.js 가 만든다. `state` 는 getter/setter 라 여기서 재대입해도 loop.js 쪽
16
+ * 변수에 그대로 반영된다(ask/가정 기록은 순수 함수라 새 객체를 만든다).
17
+ */
18
+ import { truncate, LIMITS, discoveryPathFor } from './state.js';
19
+ import { recordAssumption } from './ask.js';
20
+ import { renderDiscoveryDoc, writeDiscoveryDoc } from './discover.js';
21
+ import { findingsToFuel } from './review.js';
22
+ import { rollbackTo } from './checkpoint.js';
23
+
24
+ const combineOutput = ({ stdout, stderr } = {}) => [stderr, stdout].filter(Boolean).join('\n').trim();
25
+
26
+ /** DISCOVER (FR-10) — 모호함을 가정으로 확정하고, 가정으로 답할 수 없는 분기에서만 멈춘다. */
27
+ export async function runDiscoverStage(ctx) {
28
+ const { goal, cwd, logger, ports, gate } = ctx;
29
+ ctx.state.status = 'running';
30
+ ctx.save();
31
+ logger('[hi-loop] 🔍 DISCOVER — goal 만으로 정해지지 않는 것들을 확정합니다.');
32
+
33
+ const res = await ports.discoverer({ goal, cwd });
34
+ for (const text of res?.assumptions ?? []) {
35
+ ctx.state = recordAssumption(ctx.state, { text, stage: 'DISCOVER' });
36
+ }
37
+
38
+ // 문서는 엔진이 쓴다. 에이전트에게 맡기면 "썼다고 말했지만 안 썼다"를 확인할 수 없다.
39
+ const relPath = discoveryPathFor(goal);
40
+ const now = new Date().toISOString();
41
+ writeDiscoveryDoc({
42
+ cwd,
43
+ relPath,
44
+ content: renderDiscoveryDoc({
45
+ goal,
46
+ assumptions: (ctx.state.assumptions ?? []).filter((a) => a.stage === 'DISCOVER').map((a) => a.text),
47
+ fork: res?.fork ?? null,
48
+ summary: res?.summary ?? '',
49
+ now,
50
+ }),
51
+ });
52
+ ctx.state.discovery = { doc: relPath, at: now };
53
+ ctx.save();
54
+ logger(`[hi-loop] 🔍 가정 ${(res?.assumptions ?? []).length}건 확정 → ${relPath}`);
55
+
56
+ // 상호배타 분기일 때만 사람을 부른다(FR-10.3). 기본 무중단의 예외 둘 중 하나다.
57
+ if (res?.fork) {
58
+ const g = gate({ stage: 'DISCOVER', question: res.fork.question, options: res.fork.options });
59
+ if (g.pause) return { pause: true };
60
+ // --ask never 로 물음을 껐다면 분기는 미해결로 남는다. 조용히 넘기지 않고 가정으로
61
+ // 기록해 Gaps 에 드러나게 한다 — 정하지 않은 것을 정했다고 하면 안 된다.
62
+ if (g.choice == null) {
63
+ ctx.state = recordAssumption(ctx.state, {
64
+ text: `분기 "${res.fork.question}" 는 사람 확인 없이 구현 판단에 맡겼다.`,
65
+ stage: 'DISCOVER',
66
+ });
67
+ }
68
+ }
69
+ return { next: 'BUILD' };
70
+ }
71
+
72
+ /** CODE_REVIEW (FR-12) — 기각하면 지적을 연료로 안쪽 루프(HEAL)로 되돌린다. */
73
+ export async function runCodeReviewStage(ctx) {
74
+ const { goal, cwd, logger, ports, opts } = ctx;
75
+ ctx.state.status = 'running';
76
+ ctx.state.reviewRounds = (ctx.state.reviewRounds ?? 0) + 1;
77
+ ctx.save();
78
+ logger(`[hi-loop] 🔎 CODE_REVIEW ${ctx.state.reviewRounds}/${opts.maxReviewRounds}`);
79
+
80
+ const res = await ports.codeReviewer({ goal, specPath: ctx.state.specPath, cwd });
81
+ const findings = res?.findings ?? [];
82
+
83
+ if (res?.verdict === 'reject') {
84
+ for (const f of findings) logger(`[hi-loop] - [${f.severity}] ${truncate(f.text, 200)}`);
85
+ ctx.state.reviewFindings = findings;
86
+
87
+ // 상한이 남았으면 안쪽 루프로 되돌린다. 새 치유 경로를 만들지 않고 이미 검증된
88
+ // HEAL 을 재사용한다 — 지적이 다음 루프의 연료가 될 뿐이다.
89
+ if (ctx.state.reviewRounds < opts.maxReviewRounds) {
90
+ ctx.state.lastError = truncate(findingsToFuel(findings, { specPath: ctx.state.specPath }), LIMITS.lastError);
91
+ logger('[hi-loop] ↩️ 리뷰 지적을 연료로 치유를 재시도합니다.');
92
+ return { next: 'BUILD' };
93
+ }
94
+
95
+ // 상한 소진: 막지 않고 **공개**한다. 여기서 실패로 처리하면 통과한 테스트를
96
+ // 모델 심판이 뒤집는 셈이라 하향 전용 원칙(L9)을 넘어선다.
97
+ logger(`[hi-loop] ⚠️ 리뷰 상한(${opts.maxReviewRounds}회) 소진 — 미해결 지적 ${findings.length}건을 Gaps 에 공개합니다.`);
98
+ } else {
99
+ ctx.state.reviewFindings = [];
100
+ logger('[hi-loop] ✅ 코드 리뷰 통과.');
101
+ }
102
+ return { next: ctx.nextAfterReview() };
103
+ }
104
+
105
+ /** SHIP (FR-13) — 판정은 exit code 뿐. 에이전트를 부르지 않으므로 비용은 0이다. */
106
+ export async function runShipStage(ctx) {
107
+ const { cwd, logger, ports, opts, gate, notify } = ctx;
108
+
109
+ // FR-13.4: 비가역 외부 행위다. 기본 무중단의 유일한 예외로 항상 확인한다.
110
+ const g = gate({
111
+ stage: 'SHIP',
112
+ question: `배포 명령을 실행할까요? ${opts.shipCommand}`,
113
+ options: [
114
+ { key: 'a', label: '진행', impact: '외부에 나가면 되돌릴 수 없습니다' },
115
+ { key: 'b', label: '중단', impact: '배포 없이 여기서 종료' },
116
+ ],
117
+ });
118
+ if (g.pause) return { pause: true };
119
+ if (g.choice === 'b') {
120
+ logger('[hi-loop] 🛑 배포를 중단했습니다(사용자 선택).');
121
+ return { finish: { stopReason: 'ship-declined', detail: '사용자가 배포를 중단했습니다.' } };
122
+ }
123
+
124
+ ctx.state.status = 'running';
125
+ ctx.save();
126
+ logger(`[hi-loop] 🚀 SHIP — ${opts.shipCommand}`);
127
+ const res = await ports.shipper({ command: opts.shipCommand, cwd });
128
+ ctx.state.ship = { command: opts.shipCommand, code: res?.code ?? -1, at: new Date().toISOString() };
129
+
130
+ if (!res?.ok) {
131
+ const detail = combineOutput(res) || `배포 명령이 코드 ${res?.code}로 실패했습니다.`;
132
+ logger(`[hi-loop] ❌ 배포 실패 (code ${res?.code}).`);
133
+ // 기본이 rollback 이 아닌 이유(FR-13.3): 이미 외부에 나간 것을 로컬에서 되돌려도
134
+ // 돌아오지 않는다. 오히려 코드와 배포본이 어긋난 상태를 만든다.
135
+ if (opts.onShipFail === 'heal') {
136
+ ctx.state.lastError = truncate(
137
+ `배포 명령 \`${opts.shipCommand}\` 가 실패했다. 원인을 고쳐라:\n${detail}`,
138
+ LIMITS.lastError,
139
+ );
140
+ return { next: 'BUILD' };
141
+ }
142
+ return { finish: { stopReason: 'ship', detail: truncate(detail, 500) } };
143
+ }
144
+
145
+ logger('[hi-loop] ✅ 배포 성공.');
146
+ await notify({ type: 'ship', iteration: ctx.state.iteration, detail: opts.shipCommand });
147
+ return { next: opts.watchCommand ? 'WATCH' : 'DONE' };
148
+ }
149
+
150
+ /** WATCH (FR-14) — "지금 살아 있는가"가 아니라 "일정 시간 버텼는가"가 판정이다. */
151
+ export async function runWatchStage(ctx) {
152
+ const { cwd, logger, ports, opts, notify } = ctx;
153
+ ctx.state.status = 'running';
154
+ ctx.save();
155
+ logger(
156
+ `[hi-loop] 🩺 WATCH — ${opts.watchCommand} (${Math.round(opts.watchForMs / 1000)}초간 ${Math.round(opts.watchEveryMs / 1000)}초 간격, ${opts.watchTolerate}회까지 허용)`,
157
+ );
158
+
159
+ const res = await ports.watcher({
160
+ command: opts.watchCommand,
161
+ cwd,
162
+ forMs: opts.watchForMs,
163
+ everyMs: opts.watchEveryMs,
164
+ tolerate: opts.watchTolerate,
165
+ logger,
166
+ });
167
+ ctx.state.watch = {
168
+ command: opts.watchCommand,
169
+ checks: res?.checks ?? 0,
170
+ failures: res?.failures ?? 0,
171
+ at: new Date().toISOString(),
172
+ };
173
+
174
+ if (res?.ok) {
175
+ logger(`[hi-loop] ✅ 헬스체크 통과 (${res.checks}회 검사, 실패 ${res.failures}회).`);
176
+ return { next: 'DONE' };
177
+ }
178
+
179
+ const detail = (res?.errors ?? []).join('\n') || '헬스체크 실패';
180
+ logger(`[hi-loop] ❌ 헬스체크가 무너졌습니다 (${res?.failures}회 실패 / ${res?.checks}회 검사).`);
181
+ await notify({ type: 'watch', iteration: ctx.state.iteration, detail: truncate(detail, 500) });
182
+
183
+ if (opts.onWatchFail === 'rollback') {
184
+ const cps = ctx.state.checkpoints ?? [];
185
+ const cp = cps[cps.length - 1];
186
+ if (!cp) {
187
+ logger('[hi-loop] ⚠️ 되돌릴 체크포인트가 없습니다 (git 저장소가 아니거나 스냅샷 없음).');
188
+ } else {
189
+ const rb = await rollbackTo({ cwd, sha: cp.sha });
190
+ logger(
191
+ rb?.ok
192
+ ? `[hi-loop] ↩️ ${cp.iteration}회차 직전 상태로 복원했습니다 (${String(cp.sha).slice(0, 8)}).`
193
+ : `[hi-loop] ⚠️ 롤백 실패: ${rb?.reason}`,
194
+ );
195
+ }
196
+ return { finish: { stopReason: 'watch', detail: truncate(detail, 500) } };
197
+ }
198
+
199
+ if (opts.onWatchFail === 'heal') {
200
+ ctx.state.lastError = truncate(
201
+ `배포 후 헬스체크 \`${opts.watchCommand}\` 가 실패했다. 원인을 고쳐라:\n${detail}`,
202
+ LIMITS.lastError,
203
+ );
204
+ return { next: 'BUILD' };
205
+ }
206
+
207
+ return { finish: { stopReason: 'watch', detail: truncate(detail, 500) } };
208
+ }
209
+
210
+ export const STAGE_HANDLERS = {
211
+ DISCOVER: runDiscoverStage,
212
+ CODE_REVIEW: runCodeReviewStage,
213
+ SHIP: runShipStage,
214
+ WATCH: runWatchStage,
215
+ };