@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/docs/design.md ADDED
@@ -0,0 +1,631 @@
1
+ # hi-loop — 설계 문서 (Design v1.0)
2
+
3
+ > 대상: `docs/spec.md` (요구사항 정의서) 를 무엇으로/어떻게 구현했는가.
4
+ > 요구사항이 **무엇을(What)** 이라면 이 문서는 **어떻게(How)와 왜(Why)** 다.
5
+ > 구현 기준 커밋: `auto/handoff-engine`
6
+
7
+ ---
8
+
9
+ ## 1. 설계 목표와 제약
10
+
11
+ | # | 목표 | 이 목표가 강제하는 설계 |
12
+ |---|---|---|
13
+ | G1 | 에이전트의 자기보고를 신뢰하지 않는다 | 성패 판정은 오직 `testCommand` 의 exit code. 판정 주체는 엔진(§4.3) |
14
+ | G2 | 에이전트가 길어질수록 멍청해지는 것을 막는다 | 세션 핸드오프 + 컨텍스트 다이어트(§5) |
15
+ | G3 | 사람도 에이전트도 같은 엔진을 쓴다 | CLI/MCP 는 **껍데기**, 로직은 `runLoop` 하나(§3) |
16
+ | G4 | 테스트가 네트워크·실제 LLM 없이 돈다 | 모든 I/O 경계를 주입(§6) |
17
+ | G5 | 초경량 | 런타임 의존성 2개(SDK+zod), devDependency 0개 |
18
+ | G6 | 중단되어도 이어서 한다 | 상태를 파일에 원자적으로 저장(§4.4) |
19
+ | G7 | 에이전트가 잊는 것과 자기에게 유리하게 다룰 것을 엔진이 소유한다 | 비용 상한(§4.6), 산출물 경로(§4.7) |
20
+
21
+ **설계 상수**: `maxLoops=10`, `budgetUsd=null`(무제한), `handoffEvery=4`, `specSummary≤1200자`, `lastError≤2000자`, `history` 최근 5건, goal 해시 8자.
22
+
23
+ ---
24
+
25
+ ## 2. 모듈 구조 (의존 방향)
26
+
27
+ ```
28
+ bin/hi-loop.js bin/setup.js
29
+ (CLI 파싱/분기) (설정 주입)
30
+ │ │ │
31
+ ┌───────┘ └────────┐ │
32
+ ▼ ▼ ▼
33
+ src/mcp-server.js src/loop.js src/args.js
34
+ (MCP 프로토콜) ┌───►(오케스트레이션)◄──┐
35
+ │ │ │ │ │
36
+ └───────────┘ │ │ │
37
+ ┌──────┘ └───────┐ │
38
+ ▼ ▼ │
39
+ src/prompts.js src/runners.js
40
+ │ (agent/test spawn)
41
+ ▼ │
42
+ src/state.js ◄─────────────┘
43
+ (영속 + 다이어트) src/telegram.js
44
+ ```
45
+
46
+ 의존 규칙(단방향, 순환 없음):
47
+
48
+ - `state.js` 는 아무것도 의존하지 않는다 — 가장 안쪽 코어.
49
+ - `loop.js` 는 어댑터(`runners`, `telegram`)를 **기본값으로만** 참조하고, 호출자가 주입하면 그것을 쓴다.
50
+ - `bin/*` 는 진입점일 뿐 로직이 없다. 파싱 → `runLoop` 위임 → exit code 변환.
51
+ - `mcp-server.js` 의 도구 핸들러는 SDK와 분리(`export const tools`)되어 SDK 없이 단위 테스트된다.
52
+
53
+ | 파일 | 단일 책임 | 줄 수 |
54
+ |---|---|---|
55
+ | `src/loop.js` | PDCA 사이클 주행, 상태 전이, 예산·정체·체크포인트·2단 판정·락·삭제 관측 집행 | 296 |
56
+ | `src/state.js` | 상태 스키마·저장·절단·산출물 경로·resume·에러 지문·Gaps 보고 | 230 |
57
+ | `bin/hi-loop.js` | CLI 진입점 (+rollback) | 161 |
58
+ | `src/integrity.js` | 테스트 무결성 지문·비교 (L1) | 131 |
59
+ | `src/runners.js` | 프로세스 spawn (+타임아웃 env), 응답·비용 파싱 (L6) | 129 |
60
+ | `src/prompts.js` | 단계별 프롬프트 + 검증자 프롬프트 조립 | 125 |
61
+ | `bin/setup.js` | 설정 주입 진입점 | 115 |
62
+ | `src/mcp-server.js` | MCP 도구 등록/응답 포맷 | 102 |
63
+ | `src/verify.js` | 스펙 오라클 — Tier 2 검증자 (L9) | 96 |
64
+ | `src/checkpoint.js` | git stash 체크포인트·롤백 (L3) + 삭제 관측 (L8) | 94 |
65
+ | `src/lock.js` | 동시 실행 pid 락 (L4) | 83 |
66
+ | `src/telegram.js` | 알림 전송 + 실패 흡수 | 54 |
67
+ | `src/args.js` | 의존성 없는 인자 파서 | 33 |
68
+ | `src/is-main.js` | "직접 실행인가?" 판정 (심링크 안전, §4.5) | 28 |
69
+
70
+ 전 모듈 300줄 이내(NFR-5) — 실측 최대 296(loop.js). **합계 1,677줄** (`wc -l` 실측).
71
+
72
+ > ⚠️ 이 표는 손으로 관리된다. 커밋 `e0a3871`·`2156730` 이후 실측 865줄일 때도 "803줄"로
73
+ > 남아 있었다 — **설계 문서가 자기 자신에 대해 드리프트한 사례다.** 코드가 늘면 여기도 재라.
74
+
75
+ ---
76
+
77
+ ## 3. 듀얼 모드: 두 입구, 하나의 엔진 (G3)
78
+
79
+ ```
80
+ 사람/봇 ──► hi-loop run ──┐
81
+ ├──► runLoop({goal, testCommand, cwd, maxLoops}) ──► {status, iterations, state}
82
+ 에이전트 ─► MCP hiloop_run┘
83
+ ```
84
+
85
+ - 분기는 `bin/hi-loop.js` 의 `switch(command)` 한 곳뿐. `command` 기본값이 `mcp` 라서
86
+ **인자 없이 실행하면 서버**가 된다 — 에이전트가 아무 설정 없이 붙일 수 있게 하기 위한 의도된 기본값.
87
+ - 반환 계약도 통일: CLI 는 `passed→0 / failed→1 / 오용→2`, MCP 는 같은 결과를 텍스트로 포맷.
88
+ - **결정**: MCP 모드에서 SDK 를 top-level import 하지 않고 `startMcpServer` 안에서 `await import` 한다.
89
+ 이유: SDK 설치가 깨져도 `hi-loop run` 은 살아 있어야 한다(NFR-1). 실패 시 stderr 안내 후 exit 1.
90
+
91
+ ---
92
+
93
+ ## 4. 루프 엔진 설계
94
+
95
+ ### 4.1 상태 기계
96
+
97
+ ```
98
+ ┌──────────────────────── 실패(테스트 fail) ───────────────────────┐
99
+ │ │
100
+ 시작 ──► PLAN ──► CHECK ──► DO ──► CHECK ──► ACT ──► CHECK ──► ACT ──► CHECK ──►…┘
101
+ │ (i=1) (i=2) (i=3) (i=4)
102
+ │ │ │ │ │
103
+ │ └── 통과 ────────┴───────────────┴──────────────┴──► DONE(passed) ──► exit 0
104
+
105
+ └── i > maxLoops ────────────────────────────────────────────► DONE(failed) ──► exit 1
106
+ ```
107
+
108
+ `phaseForIteration(i)`: `1→PLAN`, `2→DO`, `3+→ACT`. 순수 함수라 단독 테스트된다.
109
+
110
+ **설계 결정 — 왜 매 iteration 마다 CHECK 를 도는가?**
111
+ PLAN 직후에도 테스트를 돌린다. 낭비처럼 보이지만:
112
+ (a) 이미 통과하는 목표(no-op 요구)를 1회차에 즉시 종료시키고,
113
+ (b) "테스트가 실패하는 것을 확인한 뒤 구현한다"는 TDD Red 단계를 엔진이 **관측**해 상태에 남긴다.
114
+
115
+ **설계 결정 — 왜 DO 는 한 번뿐이고 이후는 전부 ACT 인가?**
116
+ 2회차 이후의 모든 작업은 "실패한 테스트를 고치는 일"로 동형이다. 단계를 늘리면
117
+ 프롬프트만 갈라지고 동작은 같아진다. ACT 프롬프트가 `state.iteration - 1` 을 넣어
118
+ "이미 N회 시도했다, 접근을 바꿔라"로 압박하는 것으로 반복 수렴을 유도한다.
119
+
120
+ ### 4.2 iteration 회계
121
+
122
+ `state.iteration` 은 **에이전트 호출 횟수**와 1:1 이다(= CHECK 횟수). 이 불변식 덕분에:
123
+
124
+ - `maxLoops` 는 비용 상한으로 직결된다(AC-4: 10회 실패 시 에이전트 호출 정확히 10회).
125
+ - resume 은 `while (state.iteration < maxLoops)` 조건만으로 자연히 이어진다 — 별도 재개 로직 없음.
126
+
127
+ ### 4.3 CHECK: 판정 주체의 분리 (G1)
128
+
129
+ ```js
130
+ const check = await testRunner({ command: testCommand, cwd }); // 엔진이 직접 spawn
131
+ if (check?.ok) { /* passed */ } // 판정은 exit code === 0
132
+ ```
133
+
134
+ 에이전트 출력 텍스트는 **판정에 일절 쓰이지 않는다.** PLAN 단계의 출력만 `specSummary` 로
135
+ 받아 적을 뿐이다. "완료했습니다! 모든 테스트 통과!" 를 뱉는 에이전트를 failed 로 판정하는
136
+ 테스트가 이 계약을 고정한다.
137
+
138
+ 에러 조립은 `stderr` 우선 + `stdout` 후행. 대부분의 러너가 실패 원인을 stderr 에 쓰지만,
139
+ 일부(jest 등)는 stdout 에 쓰므로 둘 다 넣고 꼬리를 남긴다(§5.2).
140
+
141
+ ### 4.4 영속화: 언제·어떻게 쓰는가
142
+
143
+ phase 전이마다 저장한다(iteration 당 3~4회). 쓰기는 `tmp → rename`:
144
+
145
+ ```js
146
+ writeFileSync(`.${STATE_FILENAME}.${process.pid}.tmp`, json);
147
+ renameSync(tmp, path); // 동일 디렉터리 rename = POSIX 원자적
148
+ ```
149
+
150
+ - **왜 원자적인가**: 루프는 몇 시간 돌 수 있고 사용자는 아무 때나 Ctrl-C 한다.
151
+ 부분 기록된 JSON 은 다음 실행의 resume 을 깨뜨린다.
152
+ - **왜 pid 를 tmp 이름에 넣나**: 같은 디렉터리에서 두 루프가 돌 때 tmp 충돌 방지.
153
+ - **손상 시**: `loadState` 는 파싱/버전 실패를 `null` 로 흡수하고 새 상태로 시작한다.
154
+ 상태 파일은 캐시지 진실의 원천이 아니다 — 진실은 리포지토리의 코드와 테스트다.
155
+
156
+ ### 4.5 진입점 판정: 심링크가 삼킨 CLI
157
+
158
+ `bin/*.js` 는 "라이브러리로 import 된 것인지, 직접 실행된 것인지"를 구분해야 한다
159
+ (테스트가 `main()` 을 import 하기 때문). 순진한 구현은 이렇게 쓴다:
160
+
161
+ ```js
162
+ resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) // ❌ 심링크에서 깨진다
163
+ ```
164
+
165
+ Node 는 `import.meta.url` 을 **realpath 로** 주는데 `process.argv[1]` 은 사용자가 친 경로
166
+ 그대로다. **npm 의 전역 bin 은 언제나 심링크**이므로(그리고 macOS 의 `/tmp`→`/private/tmp`),
167
+ 두 값이 갈라져 판정이 `false` 가 되고 `main()` 이 호출되지 않는다.
168
+
169
+ 결과가 특히 악질적이다: 에러도, 출력도 없이 **exit 0**. CI 는 그것을 성공으로 읽는다.
170
+ `hi-loop run` 이 루프를 한 번도 안 돌리고 "통과"를 반환하는 것 —
171
+ **자기보고를 믿지 않겠다(G1)는 엔진이 정작 자기 자신에 대해 거짓말하는 상태**였다.
172
+
173
+ 그래서 `src/is-main.js` 로 분리하고 **양쪽 모두 realpath 로 정규화**해 비교한다.
174
+ 소스 트리에서는 재현되지 않는 부류라, 회귀 테스트는 tmp 에 **실제 심링크를 만들어
175
+ 자식 프로세스로 실행**해 출력과 exit code 를 검사한다.
176
+
177
+ ### 4.6 예산: 엔진이 지갑을 든다 (G7)
178
+
179
+ `maxLoops`는 **호출 횟수** 상한이지 **비용** 상한이 아니다. 실측이 그것을 증명했다:
180
+
181
+ | 실행 | 위치 | iteration | 턴 | 비용 |
182
+ |---|---|---:|---:|---:|
183
+ | A | hi-loop 저장소 | 2 | 18 | **$4.67** |
184
+ | B | 빈 `/tmp` 디렉터리 | 2 | 13 | **$2.58** |
185
+
186
+ **같은 goal, 같은 회차, 1.8배 차이.** 컨텍스트 크기가 비용을 정하는데 엔진은 그걸 모른다.
187
+ 바닥값도 만만치 않다 — "SMOKE_OK 라고만 답해라"는 사소한 호출이 **$0.434**였다
188
+ (캐시 생성 42,441토큰 × 1시간 TTL). **새 세션은 일을 안 시켜도 ~$0.43이다.**
189
+
190
+ 그래서 §5.1의 핸드오프는 공짜가 아니다. 4회마다 세션을 버리는 것은 G2(세션 부패 방지)를
191
+ 사려고 캐시 재사용을 파는 거래이고, 이제 그 가격표가 상태에 찍힌다.
192
+
193
+ ```js
194
+ if (budgetUsd != null && state.costUsd >= budgetUsd) { /* 중단 */ }
195
+ state.iteration += 1;
196
+ ```
197
+
198
+ **설계 결정 — 왜 호출 "전"에 막는가?** 이미 지출한 비용은 되돌릴 수 없다. 엔진이 할 수 있는
199
+ 유일한 일은 **더 쓰지 않는 것**이다. 그래서 예산은 상한선이지 환불이 아니고, 검사 지점은
200
+ 루프 최상단(다음 호출 직전)이다. 초과분은 마지막 호출 하나만큼 발생한다 —
201
+ 실측 검증에서 예산 $1에 실제 종료 시점은 $1.61이었다.
202
+
203
+ **설계 결정 — 왜 데이터는 이미 있었나?** `claude -p --output-format json` 응답에
204
+ `total_cost_usd`가 들어 있고, `parseAgentOutput`은 그 JSON을 **이미 파싱하면서**
205
+ `result`와 `session_id`만 꺼내고 나머지를 버리고 있었다. L5는 "향후 과제"가 아니라
206
+ 한 줄 더 읽는 일이었다.
207
+
208
+ **잘못된 값은 침묵하지 않는다.** `--budget-usd 0` 이나 `--budget-usd abc`는 exit 2다.
209
+ 비용 통제가 오타 하나로 조용히 "무제한"이 되는 것은 통제가 아니다.
210
+
211
+ ### 4.7 산출물 경로: 프롬프트에서 빼앗아 엔진으로 (G7)
212
+
213
+ 원래 PLAN 프롬프트는 `docs/spec.md`와 `tests/app.test.js`를 **문자열로 박아** 지시했다.
214
+ 그 결과 두 가지가 깨진다:
215
+
216
+ 1. 그 파일들이 이미 있는 프로젝트에서 돌리면 **남의 스펙·테스트를 덮어쓴다.**
217
+ 2. 같은 프로젝트에서 goal만 바꿔 두 번 돌리면 **1회차 산출물이 2회차에 파괴된다.**
218
+ 그리고 롤백이 없다(L3).
219
+
220
+ 이건 가정이 아니라 관측이다. 빈 디렉터리에서 실제 에이전트로 돌리면 그 두 경로에 정확히 쓴다.
221
+ 반대로 이 저장소에서 돌렸을 때는 **에이전트가 프롬프트를 어기고** 파일명을 바꿔 살아남았다 —
222
+ "지시대로 썼다면 엔진 자신의 스펙(AC-1~AC-20)과 440줄 테스트를 날렸을 것"이라며.
223
+
224
+ > **여기서 hi-loop의 논지가 한 번 뒤집혔다.** 엔진의 프롬프트가 공격이었고 에이전트의 판단이
225
+ > 방어였다. 자기보고를 안 믿겠다는 엔진이, 정작 "에이전트가 규칙을 지킬 것"은 믿고 있었다.
226
+ > 산문은 요청이고 메커니즘은 사실이다 — 그래서 경로 결정을 프롬프트에서 회수했다.
227
+
228
+ ```js
229
+ planPathsFor({ cwd, goal })
230
+ // docs/spec.md 가 비었으면 → 'docs/spec.md'
231
+ // 이미 있으면 → 'docs/spec-<sha1(goal).slice(0,8)>.md'
232
+ ```
233
+
234
+ - **왜 해시인가**: 같은 goal이면 같은 경로여야 resume이 이어진다. 다른 goal끼리는 충돌하지
235
+ 않는다. 한국어 goal에서도 파일명이 깨지지 않는다(슬러그화하면 깨진다).
236
+ - **왜 최초 1회만 계산하나**: PLAN이 `docs/spec.md`를 만든 뒤 재계산하면 "이미 있음"으로
237
+ 판정돼 2회차부터 경로가 밀려난다. 그래서 경로는 상태에 박아두고 resume은 그걸 재사용한다.
238
+ - **한계**: 이것은 **회피**지 강제가 아니다. 엔진은 에이전트의 쓰기를 가로챌 수 없다(CLI를
239
+ spawn할 뿐이다). 지정 경로 밖을 건드리지 말라는 것은 여전히 프롬프트 규칙이다(L1과 동류).
240
+
241
+ ### 4.8 테스트 무결성: exit 0 이 거짓말할 때 (L1 → G1 회복)
242
+
243
+ G1 은 "판정 주체는 엔진"이라 말한다. 그런데 판정 기준(테스트)을 쓰는 것도 같은 에이전트다.
244
+ 테스트를 지우면 exit 0 이 나온다. **자기보고를 안 믿겠다는 엔진이, 정작 "에이전트가
245
+ 테스트를 안 건드릴 것"은 믿고 있었다.** 지금까지의 방어는 프롬프트 한 줄이었다.
246
+
247
+ CHECK 를 2단으로 만든다:
248
+
249
+ ```js
250
+ const passed = Boolean(check?.ok) && integrity.ok;
251
+ ```
252
+
253
+ **설계 결정 — 왜 git 이 아니라 지문인가?** 분석 초안은 `git diff --numstat tests/` 를
254
+ 쓰려 했다. 대신 iteration 마다 테스트 파일의 지문(케이스/skip/only/가짜단언 개수)을 떠서
255
+ 직전 회차와 비교한다.
256
+
257
+ - git 저장소가 아니어도 동작한다 — "비-git 이면 fail-open" 예외 처리가 통째로 사라진다.
258
+ - 기준선이 HEAD 가 아니라 **직전 iteration** 이다. 실행 시작 시점의 더러운 워킹트리가
259
+ 오탐을 만들지 않는다. 재는 것은 정확히 "이 루프가 테스트를 약화시켰는가"다.
260
+
261
+ 탐지 패턴은 bkit 의 가짜 완료 분류학(`gap-detector.md:274-297`)에서 왔다 — LLM 이 완료를
262
+ 위장할 때 남기는 자국의 목록이다. **늘어나는 것(테스트 추가)은 위반이 아니다. 줄거나
263
+ 무력화되는 것만** 위반이다.
264
+
265
+ **설계 결정 — 왜 위반 중엔 기준선을 안 갱신하나?** 갱신하면 약화된 상태가 다음 회차의
266
+ 기준이 되어 에이전트가 그대로 빠져나간다. 되돌릴 때까지 위반이 유지된다.
267
+
268
+ **설계 결정 — 위반을 에러 "뒤"에 붙인다.** 절단이 tail-biased 이므로(§5.2) 앞에 붙이면
269
+ `truncate` 나 `saveState` 에서 위반 사실이 먼저 잘려나가고, 에이전트는 자기가 뭘 어겼는지
270
+ 모른 채 같은 짓을 반복한다. (초안에서 앞에 붙였다가 자기 코드에 당할 뻔했다.)
271
+
272
+ **실측**: 만족 불가능한 미끼 테스트로 실제 claude 를 두 번 압박했으나 둘 다 미끼를 지우지
273
+ 않았다 — 한 번은 경로 회피(§4.7) 때문에 미끼가 에이전트 소관이 아니었고, 한 번은
274
+ **에이전트가 프롬프트 규칙을 근거로 제거를 명시적으로 거부**했다("그 권한은 나에게 없다").
275
+ 즉 1차 방어(프롬프트)가 이 모델·이 상황에선 버텼다. 가드의 발동 자체는 유닛테스트로 고정했다.
276
+ bkit 의 말대로 **프롬프트는 1차 방어선일 뿐** — 규칙을 어기는 모델이 올 때를 위해 가드가 있다.
277
+
278
+ ### 4.9 정체 감지: 같은 벽에 열 번 부딪히지 않는다 (L2)
279
+
280
+ 같은 오답을 반복해도 엔진은 몰랐다. 실측에서 에이전트가 3·4·5회차에 **같은 결론**("함정이라
281
+ 못 고침")을 반복하며 $3.07 을 태웠다. 정체 판정을 엔진이 한다.
282
+
283
+ ```js
284
+ const sig = integrity.ok ? errorSignature(output) : `INTEGRITY:${violations.join('|')}`;
285
+ state.stagnantRuns = sig === state.errorSig ? state.stagnantRuns + 1 : 1;
286
+ ```
287
+
288
+ **설계 결정 — 왜 전체 에러를 해시하지 않나?** 소요시간(`47.8ms`), 임시경로, 줄번호가 실행마다
289
+ 바뀐다. 전체를 해시하면 같은 오답도 "매번 다른 에러"로 보여 정체를 놓친다. 그래서
290
+ `errorSignature` 가 숫자·경로·16진수를 지운 **뼈대**만 남긴다. 남는 것은 "무엇이 어떻게
291
+ 깨졌는가"의 형태(`AssertionError`, `ReferenceError: X`, 무결성 위반 종류)다.
292
+
293
+ **설계 결정 — 정체와 예산이 겹치면 정체가 이긴다.** 정체는 ACT 처리 시점(회차 끝)에 판정하고
294
+ 예산은 다음 회차 진입 전에 판정한다. 따라서 매번 같은 실패 + 예산이 있으면 정체가 더 일찍
295
+ 끊는다. 실측: 넉넉한 예산에서 같은 실패가 3회차에 `stagnated` 로 끊겼다. 이건 의도다 —
296
+ "이 접근으론 안 된다"를 예산 소진보다 빨리 잡는 게 더 싸다.
297
+
298
+ 정체가 2회 쌓이면 HEAL 프롬프트가 "미봉책 반복 말고 애초에 해결 불가능한 요구인지
299
+ 판단하라, 그렇다면 규칙을 어겨 우회하지 말고 근거를 남겨라"로 압박한다 — 실측에서
300
+ 에이전트가 실제로 한 정직한 행동이다.
301
+
302
+ ### 4.12 스펙 오라클: 코드와 스펙이 어긋나면 코드가 틀렸다 (L9, bkit 중심 명제)
303
+
304
+ 지금까지의 개선(L1·L2·L3·L5·L10)은 전부 **약점을 막는** 것이었다. 이건 hi-loop 에
305
+ **없던 능력**을 넣는다 — bkit 의 중심 명제 이식.
306
+
307
+ 문제(§4.1 의 닫힌 루프): PLAN 에서 에이전트가 스펙과 테스트를 **스스로 쓰고**, 같은
308
+ 에이전트가 통과시킨다. `exit 0` 의 실제 의미는 "에이전트가 자기가 쓴 테스트를 자기가
309
+ 통과시켰다"이며, 스펙의 수용 기준을 실제로 달성했는지는 아무도 안 봤다.
310
+
311
+ **2단 판정** (분석 §5, moai 의 goal evaluator 구조):
312
+
313
+ ```
314
+ Tier 1 (기계) passed?
315
+ no → 계속 (기존)
316
+ yes + verifySpec off → passed
317
+ yes + verifySpec on → Tier 2 (모델) 호출
318
+ pass → passed
319
+ reject → 통과 취소, 사유를 다음 루프 연료로
320
+ ```
321
+
322
+ **설계 결정 — 왜 하향 전용인가?** G1 은 "판정 주체는 엔진"이다. bkit 은 판정에 LLM 을
323
+ 넣는다. 정면 충돌처럼 보이지만, **최종 권한을 Tier 1(기계)에 두고 Tier 2 는 기각만
324
+ 하게** 하면 둘 다 만족한다. Tier 1 실패를 Tier 2 가 통과로 올릴 수 없고(호출조차 안 됨),
325
+ Tier 2 는 Tier 1 통과를 되돌릴 수만 있다. bkit 의 *"`fail` 은 모든 레벨에서 차단된다 —
326
+ 신뢰가 사는 건 더 적은 멈춤이지 더 낮은 기준이 아니다"* 와, moai 의 *"stop-goal 은 모델
327
+ 호출을 하지 않는다"* 를 동시에 만족한다.
328
+
329
+ **설계 결정 — 검증자 격리.** bkit 의 gap-detector 는 `disallowedTools:[Write,Edit]` +
330
+ `context:fork`. hi-loop 는 CLI 를 spawn 할 뿐이라 이걸 두 가지로 구현한다:
331
+ - `--permission-mode plan`: 검증자는 **쓰기 권한이 없다** — 검증 대상을 물리적으로 못 고친다.
332
+ - `sessionId: null`(새 세션): 구현자의 추론에 오염되지 않는다.
333
+
334
+ **설계 결정 — 인프라는 fail-open.** 검증자 응답이 깨지거나 실행이 실패하면 **통과로
335
+ 흘린다**(`parseVerdict` 기본값 pass). 인프라 장애가 판정을 뒤집으면 안 된다 — Tier 2 는
336
+ 하향 전용이고, "기각할 근거를 못 읽음 = 기각 안 함"이 원칙이다. 측정은 fail-closed,
337
+ 인프라는 fail-open(§7).
338
+
339
+ **설계 결정 — 왜 opt-in 인가?** 검증자 호출이 매 통과마다 붙어 비용이 는다. 그래서
340
+ `--verify-spec` 이 있을 때만. 검증자 모델은 `HILOOP_VERIFY_MODEL` 로 따로 지정 가능
341
+ (bkit 은 검증자를 Opus, 구현자를 Sonnet 으로 돌린다 — 확인에 만드는 것보다 더 쓴다).
342
+
343
+ **실측**: 스펙이 "음수 인자는 TypeError"(AC-3)를 요구하는데 테스트는 양수만 검사하고
344
+ 구현은 `(a,b)=>a+b`인 미끼를 실제 claude 검증자에게 줬다. 41초 만에:
345
+
346
+ > `{"verdict":"reject","reason":"add()는 단순히 a+b를 반환하여 AC-3(음수 인자 시 TypeError
347
+ > 발생)을 전혀 구현하지 않았고, add(-1, 2)는 예외 대신 1을 반환한다."}`
348
+
349
+ **테스트는 초록이었는데 검증자가 스펙 미충족을 정확히 짚었다.** 구조가 아니라 의도를
350
+ 봤다 — "add(-1,2)가 예외 대신 1을 반환한다"고 코드 로직을 읽고 추론했다. 이것이 bkit 의
351
+ Intent Match 이고, hi-loop 가 이제껏 못 잡던 false green 이다.
352
+
353
+ ### 4.11 체크포인트: 되돌릴 수 없으면 자율이 아니다 (L3)
354
+
355
+ 에이전트가 파일을 쓰는 것이 이 엔진의 전제인데, 멀쩡한 코드를 망가뜨리면 그대로 남았다.
356
+ 롤백이 없었다.
357
+
358
+ ```js
359
+ const sha = await checkpointer({ cwd }); // 에이전트 호출 "전"
360
+ ```
361
+
362
+ **설계 결정 — 왜 `git stash create` 인가?** 이것은 stash 스택에 push 하지 않고 commit
363
+ 객체 SHA 만 반환한다 — **워킹트리를 전혀 안 건드린다.** 매 iteration 마다 `git stash push`
364
+ 했다면 에이전트가 볼 워킹트리를 계속 흔들었을 것이다. create 는 스냅샷만 뜬다.
365
+
366
+ **설계 결정 — 왜 호출 "전"에 찍나?** 그 호출이 망친 것을 되돌리려면 망치기 전 상태가
367
+ 필요하다. 그래서 iteration 증가 직후, `promptFor` 앞에서 찍는다.
368
+
369
+ **설계 결정 — 왜 자동 롤백이 없나?** bkit 도 자동 되돌리기를 critical + L4(아무도 안 보는
370
+ 상황)에만 건다. hi-loop 는 전경 실행이 기본이라 사람이 본다. 체크포인트만 남기고
371
+ `hi-loop rollback [--to N]` 로 판단을 사람에게 준다. 롤백 자체도 되돌리기 전 상태를
372
+ 안전망으로 한 번 더 스냅샷한다 — 롤백을 잘못 눌러도 앞으로 갈 수 있게.
373
+
374
+ **fail-open**: git 저장소가 아니거나 변경이 없으면 `null` → 조용히 skip. 롤백이 없다고
375
+ 루프를 죽이는 것은, 비-git 프로젝트에서 hi-loop 를 못 쓰게 만드는 것이다(측정은
376
+ fail-closed, 인프라는 fail-open — §7 의 원칙 그대로).
377
+
378
+ **실측**: 실제 git 저장소에서 `BROKEN GARBAGE` 로 망가뜨린 뒤 `hi-loop rollback` →
379
+ 체크포인트 시점 파일로 복원 확인. 되돌리기 전 상태도 별도 SHA 로 스냅샷됨.
380
+
381
+ ### 4.10 Gaps 공개: passed 가 거짓말하지 않게 (L10)
382
+
383
+ 이 문서의 §10(검증 현황)을 사람이 손으로 쓰고 있다 — moai 의 5-섹션 규약(Claim / Evidence /
384
+ Baseline / **Gaps** / Residual-risk) 그 자체다. 그런데 `runLoop` 이 `passed` 를 반환할 때는
385
+ 그런 말을 안 했다. 그냥 `passed` 였다.
386
+
387
+ `passed` 야말로 위험한 자리다. 이 엔진의 exit 0 은 **"에이전트가 자기가 쓴 테스트를 자기가
388
+ 통과시켰다"**는 뜻이다. 스펙의 수용 기준 중 무엇이 테스트로 커버 안 됐는지는 아무도 안 본다.
389
+ 그게 §4.1(닫힌 루프)의 false green 이다.
390
+
391
+ `reportGaps(state)` 가 완료마다 이걸 출력한다:
392
+
393
+ ```
394
+ 관측한 것(Evidence): testCommand 종료 코드 / 무결성 위반 없음 / 누적 비용
395
+ 관측하지 않은 것(Gaps):
396
+ - 이 테스트는 에이전트 자신이 <testPath>에 작성했다. 외부 오라클이 아니다.
397
+ - <specPath>의 수용 기준 중 무엇이 커버 안 됐는지 검증하지 않았다.
398
+ - 런타임·성능·보안은 관측 범위 밖.
399
+ ```
400
+
401
+ **설계 결정 — 왜 실패엔 안 붙이나?** 실패는 이미 `stopReason`(budget/maxLoops/stagnated)과
402
+ `lastError` 로 왜 실패했는지 정직하다. Gaps 는 **성공이 조용히 지나가는 것**을 막는 장치다.
403
+ moai: "빈 Gaps 는 '관측하지 않은 것이 없다'는 강한 주장이며, 그 주장 자체가 참이어야 한다."
404
+ 그래서 false green 을 disclosed green 으로 바꾼다 — 문서를 사람이 쓰던 것을 엔진이 쓰게 했다.
405
+
406
+ MCP `hiloop_run` 결과에도 붙는다. 에이전트가 이 도구를 호출해 "통과"를 받을 때,
407
+ 그 통과를 사실로 착각하지 않도록.
408
+
409
+ ### 4.13 동시 실행 락: 배타는 원자적 생성으로 (L4)
410
+
411
+ `.agent-state.json`은 단일 루프를 가정한다. 같은 디렉터리에서 두 번째 `hi-loop run`이
412
+ 뜨면 두 프로세스가 같은 파일을 번갈아 저장해 resume이 깨진다. 락 파일 하나로 막는다.
413
+
414
+ **설계 결정 — 왜 `wx` 인가?** 락 획득은 `writeFileSync(path, ..., { flag: 'wx' })` —
415
+ 파일이 **이미 있으면 실패**하는 배타적 생성이다. "존재 확인 후 생성"의 두 단계는 그 사이에
416
+ 경합이 낀다. `wx`는 한 시스템콜이라 원자적이다. 이게 락의 심장이다.
417
+
418
+ **설계 결정 — stale 회수.** 락에 pid를 적고, 주인이 살아 있으면(`process.kill(pid, 0)`)
419
+ 거부, 죽었으면 뺏는다. 크래시나 SIGKILL 후 남은 락 때문에 **영영 못 도는 것이 더 나쁘다**.
420
+ 손상된 락(파싱 불가)도 stale로 본다 — 신뢰할 수 없는 락은 없는 락이다.
421
+
422
+ **설계 결정 — 왜 얇은 래퍼인가?** `runLoop`은 return 지점이 5개(passed/budget/
423
+ stagnated/maxLoops/throw)다. 각 지점에서 락을 풀면 하나만 빠뜨려도 데드락이다. 그래서
424
+ `runLoop`을 락 잡는 래퍼 + `runLoopBody`로 나누고, 래퍼가 `try/finally`로 **무조건** 푼다.
425
+ `acquireLock`은 테스트를 위해 주입 가능하다.
426
+
427
+ ### 4.14 삭제 관측: 회피를 못 하면 최소한 본다 (L8)
428
+
429
+ 경로 회피(§4.7)는 spec/test 두 파일만 지킨다. 엔진은 CLI를 spawn할 뿐이라 에이전트의
430
+ 쓰기를 **가로챌 수 없다**. 그래서 강제 대신 관측한다 — L1 무결성 가드와 같은 계열.
431
+
432
+ **설계 결정 — 왜 삭제만 보는가?** 구현이 기존 파일을 **수정**하는 것은 정당하다(기존
433
+ `index.js`에 함수 추가 등). 그걸 위반으로 잡으면 오탐 천지다. 하지만 baseline에 있던
434
+ 파일을 **삭제**하는 것은 거의 항상 나쁘고 오탐이 적다. `tests/` 삭제는 무결성 가드가 이미
435
+ 잡으므로 여기선 그 밖을 본다.
436
+
437
+ **설계 결정 — 왜 차단하지 않는가?** 체크포인트(L3)로 되돌릴 수 있고, 삭제가 정당한 경우도
438
+ 있다(goal이 "레거시를 치워라"일 수 있다). 그래서 경고 + Gaps 기록만 하고 판단은 사람에게
439
+ 준다. 관측한 것은 숨기지 않는다(L10). 비-git이면 관측 자체가 불가능하니 조용히 건너뛴다.
440
+
441
+ ### 4.15 타임아웃: 하드코딩을 열되, 쓰레기값에 죽지 않게 (L6)
442
+
443
+ 에이전트 30분·테스트 10분이 하드코딩이었다. 환경변수로 열되 방어한다:
444
+ `Number(env)`가 유한한 양수일 때만 쓰고, 0·음수·문자열·빈값이면 **기본값으로 되돌린다**.
445
+ 타임아웃을 0으로 잘못 주면 즉시 죽는 러너가 되므로, 잘못된 설정은 무설정으로 취급한다.
446
+
447
+ ---
448
+
449
+ ## 5. 컨텍스트 다이어트 & 세션 핸드오프 (G2)
450
+
451
+ ### 5.1 왜 세션을 버리는가
452
+
453
+ 에이전트 세션은 turn 이 쌓일수록 (a) 토큰 비용이 선형 증가하고 (b) 초기의 잘못된 시도가
454
+ 컨텍스트에 남아 같은 실패를 반복 유도한다. hi-loop 는 **주기적 기억상실**을 처방한다.
455
+
456
+ ```
457
+ session #1 session #2
458
+ i=1 PLAN ─┐ i=5 ACT ─┐
459
+ i=2 DO ├─ 대화 누적(sessionId 유지) i=6 ACT ├─ 새 대화
460
+ i=3 ACT │ i=7 ACT │
461
+ i=4 ACT ──┘ ← 핸드오프: sessionId=null, serial++
462
+ 넘기는 것: goal + specSummary + lastError + 최근 5건 요약
463
+ 버리는 것: 대화 전문, 중간 시도, 도구 호출 로그
464
+ ```
465
+
466
+ 구현은 한 줄의 산술이다: `if (state.iteration % handoffEvery === 0 && state.iteration < maxLoops)`.
467
+ 뒤 조건이 없으면 마지막 iteration 직후 무의미한 핸드오프가 발생한다.
468
+
469
+ ### 5.2 절단 정책: 왜 꼬리를 남기는가
470
+
471
+ ```js
472
+ truncate(text, max) → `…(앞부분 N자 생략)\n${text.slice(-max)}`
473
+ ```
474
+
475
+ 스택트레이스와 assertion diff 는 **끝**에 온다. 앞을 남기면 `npm WARN`, 컴파일 진행 로그 같은
476
+ 쓰레기가 살고 정작 실패 지점이 잘린다. 그래서 전 구간에서 tail-biased 절단을 쓴다.
477
+ (예외: 텔레그램 4096자 제한은 head 절단 — 사람이 읽는 알림은 앞부분이 요지다.)
478
+
479
+ ### 5.3 프롬프트 조립
480
+
481
+ `contextBlock(state)` 하나가 모든 단계 프롬프트의 공통 헤더를 만든다 → 세션이 바뀌어도
482
+ 에이전트가 보는 컨텍스트 형식은 동일. 실측 핸드오프 프롬프트는 8KB 미만(테스트로 상한 고정).
483
+
484
+ 각 프롬프트에 공통 `RULES` 를 박아 넣는다 — 특히
485
+ **"테스트를 skip/삭제/always-true 로 무력화하지 마라"**. 이것은 자가 치유 엔진의 가장
486
+ 명백한 보상 해킹(reward hacking) 경로이고, 프롬프트 규칙은 1차 방어선일 뿐이다(§9 참조).
487
+
488
+ ---
489
+
490
+ ## 6. 주입 가능한 I/O 경계 (G4)
491
+
492
+ ```js
493
+ runLoop({ agentRunner, testRunner, notifier, logger, statePath })
494
+ ```
495
+
496
+ | 포트 | 계약 | 프로덕션 어댑터 | 테스트 스텁 |
497
+ |---|---|---|---|
498
+ | `agentRunner` | `({prompt, sessionId, cwd}) => {text, sessionId}` | `claude -p … --output-format json [--resume]` | 호출 기록 배열 |
499
+ | `testRunner` | `({command, cwd}) => {ok, code, stdout, stderr}` | `spawn(cmd, {shell:true})` | 정해둔 성패 시퀀스 |
500
+ | `notifier` | `(event) => void` | 텔레그램 | 이벤트 타입 수집 |
501
+
502
+ - **세션 왕복**: 러너가 `sessionId` 를 돌려주고 엔진이 그것을 다음 호출에 넣는다.
503
+ 엔진은 세션이 CLI 플래그인지 HTTP 헤더인지 모른다 → `claude` 외 다른 에이전트로 교체 가능
504
+ (`HILOOP_AGENT_CMD`).
505
+ - **에이전트 응답 파싱**: `--output-format json` 의 `{result, session_id}` 를 읽되,
506
+ JSON 이 아니면 원문을 텍스트로 쓰고 기존 세션을 유지한다(관대한 파싱).
507
+ - **실패 처리 비대칭**: `testRunner` 의 실패는 **정상 입력**(치유할 재료)이라 resolve 로 흘리고,
508
+ `agentRunner` 의 실패는 **엔진 오류**라 reject 로 루프를 세운다. 에이전트가 죽었는데
509
+ 10회 재시도하는 것은 돈만 태우는 짓이다.
510
+
511
+ ---
512
+
513
+ ## 7. MCP 서버 설계
514
+
515
+ - **stdout 은 프로토콜 채널이다.** 모든 로그는 stderr. `runLoop` 에 `logger: log(stderr)` 를
516
+ 명시적으로 주입해 루프의 진행 로그가 JSON-RPC 프레임을 오염시키지 못하게 막는다.
517
+ (CLI 모드에서는 반대로 stdout 로거를 주입한다 — 같은 엔진, 다른 배선.)
518
+ - **도구/전송 분리**: `tools` 객체(순수 핸들러) ↔ `startMcpServer`(SDK 등록). 덕분에
519
+ 도구 로직은 SDK·전송 없이 테스트되고, `wrap()` 이 예외를 `isError:true` 로 획일 변환한다.
520
+ - **도구 3종**: `hiloop_run`(실행) / `hiloop_status`(관측) / `hiloop_reset`(초기화).
521
+ status 를 둔 이유: 루프는 장시간 실행이라 외부에서 상태를 들여다볼 창이 필요하다.
522
+ - `cwd` 를 모든 도구의 선택 인자로 노출 — MCP 서버 프로세스의 cwd 와 실제 작업 대상이
523
+ 다를 수 있다(에이전트가 서버를 어디서 띄웠는지 엔진은 모른다).
524
+
525
+ ---
526
+
527
+ ## 8. 알림 설계 (실패를 삼키는 것이 기능이다)
528
+
529
+ ```js
530
+ try { await notifier(event) } catch { /* 무시 */ } // loop.js
531
+ ```
532
+
533
+ 이중 방어: `telegram.js` 자체가 `{ok:false, error}` 로 실패를 반환하고, 루프도 한 번 더
534
+ try/catch 로 감싼다. **텔레그램 장애로 빌드가 죽는 것은 설계 실패**라는 판단.
535
+ 미설정 시 `{skipped:true}` 로 조용히 no-op — 알림은 부가 기능이지 전제 조건이 아니다.
536
+
537
+ 알림 시점은 4개: `start`(시작), `hi-loop`(♻️ 세션 교체), `passed`(✅), `failed`(❌).
538
+ 매 iteration 알림은 폰을 울려대므로 의도적으로 제외했다.
539
+
540
+ ---
541
+
542
+ ## 9. 알려진 한계 / 향후 과제
543
+
544
+ | # | 한계 | 완화책 / 계획 |
545
+ |---|---|---|
546
+ | ~~L1~~ | ~~에이전트가 테스트를 무력화해 통과시킬 수 있다~~ | ✅ **해소**(§4.8) — CHECK 2단 판정 + 지문 비교. 가드 발동은 유닛테스트로 고정, 1차 방어(프롬프트)는 실제 에이전트로 버팀 확인. 다만 관측이지 강제는 아니다(L8) |
547
+ | ~~L2~~ | ~~같은 오답을 10회 반복해도 엔진은 모른다~~ | ✅ **해소**(§4.9) — `errorSignature` 연속 동일 3회 시 `stopReason: 'stagnated'`. 정체 > 예산 우선순위 |
548
+ | ~~L3~~ | ~~롤백이 없다 — 에이전트가 코드를 망가뜨리면 그대로 남는다~~ | ✅ **해소**(§4.11) — 호출 전 `git stash create` 비파괴 체크포인트 + `hi-loop rollback [--to N]`. 자동 롤백은 없음(전경 실행). 실제 git 으로 복원 실측 |
549
+ | ~~L4~~ | ~~단일 루프 가정(락 없음)~~ | ✅ **해소**(§4.13) — `.agent-state.lock` pid 락. 산 pid는 거부, 죽은 pid는 stale 회수. runLoop 이 finally 로 확실히 푼다 |
550
+ | ~~L5~~ | ~~비용/토큰 추적 없음~~ | ✅ **해소**(§4.6) — `total_cost_usd` 누적 + `--budget-usd`. 실제 에이전트로 검증됨(§10) |
551
+ | ~~L6~~ | ~~타임아웃 하드코딩~~ | ✅ **해소** — `HILOOP_AGENT_TIMEOUT_MS` / `HILOOP_TEST_TIMEOUT_MS`. 쓰레기값은 기본값으로 되돌림 |
552
+ | ~~L7~~ | ~~실제 `claude` 에이전트와의 통합이 미검증~~ | ✅ **해소** — claude 2.1.212 로 실측. 편집 권한·세션 재개·JSON 파싱·전체 루프 전부 확인(§10). **차단 사유였던 "개발 환경 실행 가드"는 사실이 아니었다** — 막고 있던 것은 기술적 불가능이 아니라 실행되지 않은 절차였다 |
553
+ | ~~L8~~ | ~~경로 회피는 강제가 아니다~~ | ✅ **관측으로 완화**(§4.14) — baseline 대비 삭제된 파일을 경고(차단 안 함, 되돌림 가능). 수정은 정당할 수 있어 삭제만 본다. 실측: 에이전트가 시킨 대로 legacy.js 삭제 → 경고 발동 |
554
+ | ~~L9~~ | ~~스펙이 오라클이 아니다~~ | ✅ **해소**(§4.12, opt-in) — 2단 판정. Tier 1(기계) 최종 권한, Tier 2(모델) 하향 전용. 검증자는 plan 모드·새 세션으로 격리. 실측: 테스트 통과 + 스펙 AC 미충족을 실제 claude 가 41초 만에 기각 |
555
+ | ~~L10~~ | ~~산출물이 미관측을 공개하지 않는다~~ | ✅ **해소**(§4.10) — `reportGaps` 가 passed 마다 Evidence+Gaps 를 출력. CLI·MCP 양쪽. 실패엔 안 붙임(이미 stopReason 으로 정직) |
556
+
557
+ ---
558
+
559
+ ## 10. 검증 현황 — 무엇을 실제로 확인했고 무엇을 안 했는가
560
+
561
+ | 항목 | 상태 | 근거 |
562
+ |---|---|---|
563
+ | 단위/수용 테스트 104개 | ✅ 통과 | `npm test` (네트워크·실제 LLM 없이) |
564
+ | **설치본(tarball) 실행** | ✅ 실측 | `npm pack` → `npm install -g --prefix /tmp/lev-prefix ./tgz` → **심링크 bin 경유** 실행 및 전체 루프 E2E 통과. §4.5 버그를 잡아낸 경로 |
565
+ | CLI 루프 E2E (가짜 에이전트) | ✅ 실측 | PLAN→실패→DO→통과→exit 0, 2회차 프롬프트에 실제 AssertionError 주입 확인 |
566
+ | MCP 서버 (도구 왕복) | ✅ 실측 | stdio 로 initialize → tools/list(3종) → tools/call |
567
+ | setup 멱등성 | ✅ 테스트 | 2회 실행 시 changes=[] |
568
+ | **실제 `claude` 에이전트 왕복** | ✅ **실측** | claude 2.1.212. PLAN→CHECK(fail)→DO→CHECK(pass)→exit 0, 2회차 통과 |
569
+ | **`--permission-mode acceptEdits` 실효성** | ✅ **실측** | 에이전트가 실제로 `docs/spec.md`(81줄) + `tests/app.test.js`(105줄) + `src/add.js`를 썼다. 이전의 "⚠️ 추론"이 사실로 확인됨 |
570
+ | **세션 재개(`--resume`)** | ✅ **실측** | 세션 파일 하나(`~/.claude/projects/…/<id>.jsonl`)에 PLAN 프롬프트와 DO 프롬프트가 **둘 다** 존재. 재개 실패 시 2회차는 새 세션 ID를 받았을 것 |
571
+ | **비용 관측 / 예산 상한** | ✅ **실측** | 예산 $1 지정 → `maxLoops 5` 중 **2회차에 중단**, `stopReason: 'budget'`, `costUsd: 1.606411` |
572
+ | **경로 회피 (덮어쓰기 방지)** | ✅ **실측** | 보호 대상 `docs/spec.md`·`tests/app.test.js` 가 있는 디렉터리에서 실행 → 원본 무수정(`git status` 비어 있음), 에이전트는 `docs/spec-5d88cc7f.md`·`tests/app-5d88cc7f.test.js` 에 씀 |
573
+ | CLI 플래그 4종 존재 | ✅ 실측 | claude 2.1.212 `--help`: `-p`, `--output-format`, `-r/--resume`, `--permission-mode`(choices 에 `acceptEdits`·`plan` 포함) |
574
+ | **핸드오프(4회차 세션 폐기) — 실제 에이전트** | ❌ **미검증** | 가짜 에이전트로만 확인(AC-6). 관측하려면 `--max-loops 5` 이상 + 계속 실패해야 한다 — `iteration % 4 === 0 && iteration < maxLoops` 이므로 `maxLoops 3` 으로는 **산술적으로 불가능** |
575
+ | **MCP 모드에서 실제 에이전트 spawn** | ❌ 미검증 | 도구 왕복만 확인. `hiloop_run` 이 실제 claude 를 띄우는 경로는 미확인 |
576
+ | 장시간(10회) 실제 루프 | ❌ 미검증 | 최대 2회차까지만 실행 |
577
+ | 텔레그램 실제 발송 | ❌ 미검증 | 스텁으로만 |
578
+
579
+ ### §10.1 관측된 비용 (실측)
580
+
581
+ | 실행 | 모델 | iteration | 턴 | 캐시 쓰기 | 캐시 읽기 | 출력 | 비용 |
582
+ |---|---|---:|---:|---:|---:|---:|---:|
583
+ | 사소한 프로브("SMOKE_OK 라고 답해라") | opus-4-8 | — | 1 | 42,441 | 18,689 | 10 | **$0.434** |
584
+ | A: 저장소에서 add 함수 | opus-4-8 | 2 | 18 | 357,630 | 982,089 | 23,936 | **$4.67** |
585
+ | B: 빈 `/tmp` 에서 add 함수 | opus-4-8 | 2 | 13 | 198,098 | 660,363 | 10,816 | **$2.58** |
586
+ | C: 예산 $1 상한 검증 | opus-4-8 | 2 | — | — | — | — | **$1.61**(중단) |
587
+
588
+ 읽어야 할 것: (1) **새 세션 바닥값 ~$0.43** — 일을 안 시켜도 그렇다. (2) **A와 B는 같은 goal·같은
589
+ 회차인데 1.8배 차이** — `maxLoops` 는 비용 상한이 아니다. (3) 모델을 안 넘기므로 사용자 기본값을
590
+ 상속한다(여기선 `claude-opus-4-8`) — **환경마다 비용이 달라진다.**
591
+
592
+ ### §11. 스모크 절차 (실행 완료 — 재현용으로 남김)
593
+
594
+ ```bash
595
+ mkdir -p /tmp/hi-loop-smoke/s1 && cd /tmp/hi-loop-smoke/s1
596
+ npm init -y && npm pkg set scripts.test="node --test" # ⚠️ 기본 test 스크립트는 영구 실패한다
597
+ git init -q && git add -A && git commit -qm baseline # 에이전트가 뭘 썼는지 보는 기계적 증거
598
+ hi-loop run --goal "두 수를 더하는 add 함수를 만들어라" --test "npm test" --max-loops 3 --budget-usd 5
599
+ ```
600
+
601
+ - **`npm pkg set scripts.test` 를 빼지 마라.** `npm init -y` 의 기본 test 스크립트는
602
+ `echo "Error: no test specified" && exit 1` 이라 에이전트가 무엇을 하든 실패한다.
603
+ 그러면 "권한 모드가 되는가"가 아니라 "에이전트가 함정을 눈치채는가"를 측정하게 된다.
604
+ - **일회용 디렉터리에서 돌려라.** 경로 회피(§4.7)가 있어도 작업 디렉터리 이탈 방지는
605
+ 프롬프트 규칙뿐이다(L8).
606
+ - 확인할 것: 파일이 실제로 쓰였는가 / `.agent-state.json` 의 `sessionId`·`costUsd` /
607
+ 최종 exit code. 핸드오프까지 보려면 `--max-loops 5` 이상 + 계속 실패하는 testCommand
608
+ (예: `--test "node -e 'process.exit(1)'"` — 에이전트가 수정할 수 없다).
609
+
610
+ ## 12. 요구사항 ↔ 설계 ↔ 검증 추적표
611
+
612
+ | 요구 | 설계 섹션 | 구현 | 검증(AC) |
613
+ |---|---|---|---|
614
+ | FR-1 듀얼 모드 CLI | §3 | `bin/hi-loop.js`, `src/args.js` | AC-15, 16 |
615
+ | FR-2 루프 엔진 | §4.1~4.3 | `src/loop.js`, `src/prompts.js` | AC-1~5, 11 |
616
+ | FR-2.1a 산출물 경로 | §4.7 | `src/state.js` (`planPathsFor`), `src/prompts.js` | AC-27~30 |
617
+ | FR-2.3a 테스트 무결성 | §4.8 | `src/integrity.js`, `src/loop.js` | AC-31~34 |
618
+ | FR-2.8 예산 상한 | §4.6 | `src/loop.js`, `src/runners.js` | AC-21~26 |
619
+ | FR-2.9 정체 감지 | §4.9 | `src/loop.js`, `src/state.js` (`errorSignature`) | AC-35~37 |
620
+ | FR-2.5a Gaps 공개 | §4.10 | `src/state.js` (`reportGaps`), `src/loop.js`, `src/mcp-server.js` | AC-38~39 |
621
+ | FR-2.10 체크포인트/롤백 | §4.11 | `src/checkpoint.js`, `src/loop.js`, `bin/hi-loop.js` | AC-40~42 |
622
+ | FR-2.11 스펙 오라클 (2단 판정) | §4.12 | `src/verify.js`, `src/prompts.js`, `src/loop.js` | AC-43~45 |
623
+ | FR-2.12 동시 실행 락 | §4.13 | `src/lock.js`, `src/loop.js`, `bin/setup.js` | AC-46, 47 |
624
+ | FR-2.13 타임아웃 노출 | §4.15 | `src/runners.js` | AC-48 |
625
+ | FR-2.14 삭제 관측 | §4.14 | `src/checkpoint.js`, `src/loop.js`, `src/state.js` | AC-49, 50 |
626
+ | FR-3 핸드오프/다이어트 | §5 | `src/loop.js`, `src/state.js` | AC-6~10, 19 |
627
+ | FR-4 MCP 서버 | §7 | `src/mcp-server.js` | AC-20 |
628
+ | FR-5 텔레그램 | §8 | `src/telegram.js` | AC-12~14 |
629
+ | FR-6 setup | — | `bin/setup.js` | AC-17, 18 |
630
+ | NFR-3 주입 가능 | §6 | `src/runners.js` | 전 테스트가 네트워크 없이 동작 |
631
+ | NFR-4 원자적 저장 | §4.4 | `src/state.js` | AC-19 |