@tuzi-ince/hi-loop 0.4.0 → 0.4.3

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/README.md CHANGED
@@ -174,7 +174,9 @@ CI·배포 설정, 빌드·러너 설정, 환경 변수. "테스트는 통과했
174
174
  사람이 놓치지 않게 한다.
175
175
 
176
176
  **(b) 조건부 검사 — 바뀐 파일에 따라 게이트를 켠다.** 무거운 검사(e2e 등)를 매 회차 돌리는
177
- 대신, 특정 경로가 바뀐 회차에만 돌린다.
177
+ 대신, 특정 경로에 변경이 있을 때만 돌린다. `--when` 은 `git status`(HEAD 대비 워킹트리 변경)로
178
+ 매칭한다 — 즉 "그 경로를 건드린 그 회차만"이 아니라 **커밋 전 변경분에 그 경로가 있는 한 이후
179
+ 회차마다** 검사가 돈다(2회차에 e2e 가 사라지지 않게 하려는 의도).
178
180
 
179
181
  ```bash
180
182
  hi-loop run --goal "..." \
@@ -188,6 +190,22 @@ hi-loop run --goal "..." \
188
190
  - `--when` 글롭에 안 맞은 검사는 **건너뜀으로 기록**된다 — 안 돌린 것이 통과처럼 보이지 않게
189
191
  Gaps에 남는다.
190
192
 
193
+ **MCP 에서도 같은 걸 쓴다 — "UI 변경 회차에만 e2e"를 엔진이 강제한다.** `hiloop_run` 이 `checks`
194
+ 인자를 받는다(CLI `--check/--when` 의 MCP 노출):
195
+
196
+ ```json
197
+ "checks": [
198
+ { "cmd": "npm test" },
199
+ { "cmd": "npx playwright test", "when": "src/**/*.tsx" }
200
+ ]
201
+ ```
202
+
203
+ - e2e 를 "스킬이 사람에게 기억해서 실행"(제안)이 아니라 **엔진이 결정론적으로 강제**(메커니즘)하는 길이다.
204
+ UI 파일이 바뀐 회차마다 엔진이 e2e 를 돌리고 통과해야 pass 로 인정한다 — 조용히 빠지지 않는다.
205
+ - e2e 는 **셸 명령**이어야 한다. 엔진은 Playwright **MCP 도구**를 부를 수 없으므로, MCP 만 있는
206
+ 프로젝트는 `flow` 스킬이 루프 통과 후 MCP 로 돌린다(폴백). 셸 e2e 명령도 없으면 스킵.
207
+ - `flow` 스킬이 UI 작업을 감지하면 사용자에게 물은 뒤 이 `checks` 를 자동 구성해 `hiloop_run` 에 넘긴다.
208
+
191
209
  **diff만 리뷰하고 싶다면** — 지금 워킹트리 변경분을 정확성·보안·YAGNI 축으로 심판:
192
210
 
193
211
  ```bash
@@ -275,7 +293,7 @@ hi-loop run --goal "..." --start-from BUILD --stop-after CODE_REVIEW
275
293
 
276
294
  | 사용자가 이렇게 말하면 | LLM이 하는 일 |
277
295
  |---|---|
278
- | `/hi-loop 로그인 폼 만들어줘` (슬래시 스킬) | hi-loop 스킬 기동 → `hiloop_run(full=true)` 실행 |
296
+ | `/hi-loop:flow 로그인 폼 만들어줘` (슬래시 스킬) | `flow` 스킬 기동 → `hiloop_run(full=true)` 실행 (bare `/hi-loop` 은 명령이지 이 스킬이 아님) |
279
297
  | "hiloop_run 도구로 결제 버그 고쳐줘" | 지목된 MCP 도구를 로드해 바로 호출 |
280
298
  | 터미널에서 `hi-loop run --goal "..." --full` | 엔진을 CLI로 직접 구동(LLM 개입 없음) |
281
299
 
@@ -469,6 +487,20 @@ MCP 도구의 `cwd` 기본값은 `CLAUDE_PROJECT_DIR || process.cwd()` 다 —
469
487
  반대로 순수 `process.cwd()` — 터미널에서 cd한 곳이 사용자의 명시 의도이고, 통합 터미널에
470
488
  새어든 env가 그것을 덮으면 하위 프로젝트 겨냥이 깨지기 때문이다.)
471
489
 
490
+ ### 🛑 실패를 어떻게 멈출까 — 카운트 대신 사람 판단 (`--on-fail`)
491
+
492
+ 기본은 `--max-loops`(10)까지 자동 치유다(무인 환경 백스톱). 하지만 대화형·비싼 검사(e2e)에서는
493
+ 카운트보다 **사람 판단**이 낫다 — `--on-fail` 로 정한다:
494
+
495
+ | 값 | 동작 |
496
+ |---|---|
497
+ | `heal` (기본) | `--max-loops` 안에서 자동 치유 (종전) |
498
+ | `stop` | 첫 실패에서 즉시 종료 |
499
+ | **`ask`** | 실패/불능에서 **"재시도할까 / 종료할까"를 사람에게 물음** (pause/resume). MCP 에선 `awaiting` 반환 → `hiloop_answer` 로 답하고 재호출 |
500
+
501
+ `ask` 는 비싼 e2e 루프에 특히 유용하다 — 8.5분·수달러짜리 회차를 무인으로 반복하지 않고,
502
+ **판정 불능(무결성·환경)**까지 사람에게 표면화한다. 무인(`--ask never`)에선 물을 수 없으니 안전하게 종료한다.
503
+
472
504
  ### 💸 비용은 엔진이 센다 — 그리고 싸지 않다
473
505
 
474
506
  **`--max-loops` 는 비용 상한이 아니다.** 실패 루프가 10회를 다 쓰면 비용이 크게 뛸 수 있다.
package/bin/hi-loop.js CHANGED
@@ -27,7 +27,8 @@ const USAGE = `hi-loop — 초경량 자율형 자가 치유 엔진
27
27
 
28
28
  사용법:
29
29
  hi-loop run --goal "<요구사항>" [--test "npm test"] [--max-loops 10] [--budget-usd 5]
30
- [--stagnation 3 | --no-stagnation] [--verify-spec] [--spec docs/DESIGN.md] [--cwd .]
30
+ [--stagnation 3 | --no-stagnation] [--on-fail heal|ask|stop] [--verify-spec]
31
+ [--spec docs/DESIGN.md] [--cwd .]
31
32
  [--ship "<배포 명령>"] [--on-ship-fail stop|heal]
32
33
  [--review | --no-review] [--max-review-rounds 2]
33
34
  [--design-review | --no-design-review] [--max-design-rounds 2]
package/docs/DESIGN.md CHANGED
@@ -571,7 +571,7 @@ try/catch 로 감싼다. **텔레그램 장애로 빌드가 죽는 것은 설계
571
571
 
572
572
  | 항목 | 상태 | 근거 |
573
573
  |---|---|---|
574
- | 단위/수용 테스트 104개 | ✅ 통과 | `npm test` (네트워크·실제 LLM 없이) |
574
+ | 단위/수용 테스트 343개 | ✅ 통과 | `npm test` (네트워크·실제 LLM 없이) |
575
575
  | **설치본(tarball) 실행** | ✅ 실측 | `npm pack` → `npm install -g --prefix /tmp/lev-prefix ./tgz` → **심링크 bin 경유** 실행 및 전체 루프 E2E 통과. §4.5 버그를 잡아낸 경로 |
576
576
  | CLI 루프 E2E (가짜 에이전트) | ✅ 실측 | PLAN→실패→DO→통과→exit 0, 2회차 프롬프트에 실제 AssertionError 주입 확인 |
577
577
  | MCP 서버 (도구 왕복) | ✅ 실측 | stdio 로 initialize → tools/list(3종) → tools/call |
package/docs/SPEC.md CHANGED
@@ -100,7 +100,8 @@ runLoop({
100
100
  - **한계**: 이것은 관측이지 강제가 아니다. 엔진은 CLI를 spawn할 뿐 에이전트의 쓰기를
101
101
  가로챌 수 없다(DESIGN.md L8).
102
102
  - FR-2.4 **ACT(HEAL)**: 실패 시 stdout/stderr 마지막 4000자를 잘라 에이전트에 들이밀고
103
- "이 에러를 고쳐라"로 재지시. `maxLoops`(기본 10)까지 반복.
103
+ "이 에러를 고쳐라"로 재지시. `maxLoops`(기본 10)까지 반복. 대화형에선 `--on-fail ask`(FR-22)로
104
+ 자동 반복 대신 사람이 재시도·종료를 정할 수 있다.
104
105
  - FR-2.5 CHECK가 통과하면 즉시 종료하고 `status: 'passed'`.
105
106
  - FR-2.5a **Gaps 공개 (L10).** `passed` 시 결과에 `report`(Evidence + Gaps)를 붙이고
106
107
  로거로 출력한다. moai의 5-섹션 규약 중 핵심인 Gaps를 담는다.
@@ -222,7 +223,7 @@ runLoop({
222
223
  - FR-4.3 노출 도구:
223
224
  | tool | input | 동작 |
224
225
  |---|---|---|
225
- | `hiloop_run` | `goal`(필수), `testCommand?`, `maxLoops?`, `verifySpec?`, `spec?`, `reconcile?`, `cwd?` | 루프 실행 후 결과 텍스트 반환. `budgetUsd`·`stagnation`은 노출 안 함(CLI 전용) |
226
+ | `hiloop_run` | `goal`(필수), `testCommand?`, `maxLoops?`, `verifySpec?`, `spec?`, `reconcile?`, `reconcileSpec?`, `checks?`, `onFail?`, `cwd?` | 루프 실행 후 결과 텍스트 반환. `budgetUsd`·`stagnation`은 노출 안 함(CLI 전용). `checks`=`[{cmd,when?}]` 조건부 검사(UI 변경 회차에만 e2e 등 **엔진 강제**). `onFail`=`heal\|ask\|stop`(FR-22, 실패 시 사람에게 물음) |
226
227
  | `hiloop_status` | `cwd?` | 현재 `.hi-loop/STATE.json` 요약 반환 |
227
228
  | `hiloop_reset` | `cwd?` | 상태 파일 삭제 |
228
229
  - FR-4.4 도구 오류는 예외를 던지지 않고 `isError: true` + 메시지로 반환한다.
@@ -232,6 +233,10 @@ runLoop({
232
233
  를 명시하면 그것이 최우선. (CLI 는 이와 달리 순수 `process.cwd()` — 터미널에서 cd 한 곳이
233
234
  사용자의 명시 의도이고, 통합 터미널에 새어든 env 가 그것을 덮으면 하위 프로젝트 겨냥이 깨진다.)
234
235
  - FR-4.7 `hiloop_run` 은 루프 진입 전 에이전트 preflight(FR-20)를 거친다.
236
+ - FR-4.8 **진행 알림(progress).** `hiloop_run` 은 루프가 도는 동안 회차마다 `notifications/progress`
237
+ 를 보낸다(클라이언트가 progressToken 을 준 경우). 이게 없으면 e2e 같은 긴 루프가 클라이언트
238
+ 유휴 타임아웃(기본 30분)에 조용히 끊긴다. `wrap()` 이 SDK `extra`(progress·signal)를 핸들러에
239
+ 전달하고, 핸들러가 로그 라인마다 알림을 보낸다. 알림 실패는 루프에 영향 없다(fail-open).
235
240
 
236
241
  ### FR-5. 텔레그램 알림 (`src/telegram.js`)
237
242
 
@@ -444,6 +449,23 @@ DISCOVER → PLAN → DESIGN_REVIEW → DO ⇄ CHECK ⇄ HEAL → CODE_REVIEW
444
449
  - FR-21.6 결정은 `state.reconciled` 로 기록해 같은 goal 재개 시 다시 묻지 않는다. 답한 재개는
445
450
  판정기를 재호출하지 않는다(gate 가 저장된 답을 돌려준다).
446
451
 
452
+ ### FR-22. on-fail 게이트 — 카운트 대신 사람 판단 (`--on-fail`, `src/build.js`)
453
+
454
+ 무인 자동 치유는 대화형·비싼 검사(e2e)에서 폭주하거나 헛돈다(실측: git 미추적 무결성 위반을
455
+ 8.5분×회차 자동 재시도). 카운트만으로는 "언제 멈출지"를 모른다. 그래서 대화형에서는 **사람 판단**을
456
+ 멈춤 기준으로 쓸 수 있게 한다(카운트 기본값 `maxLoops`는 종전 10 그대로 — 이건 무인 환경용 백스톱).
457
+
458
+ - FR-22.1 `--on-fail heal|ask|stop` (기본 `heal`). CHECK 실패 시 안쪽 루프 처리:
459
+ - `heal`: 종전대로 `maxLoops` 안에서 자동 치유(기존 동작 불변).
460
+ - `stop`: 첫 실패에서 즉시 종료(`stopReason: on-fail-stop`).
461
+ - `ask`: **사람에게 재시도/종료를 묻는다**(pause/resume, FR-16). `awaiting` 반환 → `hiloop_answer`
462
+ 로 답하고 재호출. 'a'=재시도(회차 +1, `state.extraLoops`), 'b'=종료(`user-stop`).
463
+ - FR-22.2 **PLAN 회차(1)는 게이트 제외** — 구현 전이라 실패가 당연하다(DO 로 넘어간다).
464
+ - FR-22.3 **판정 불능(blocked) 구분**: 무결성 위반·비회귀 같은 메타 문제는 자동 수정이 대개
465
+ 무의미하다. ask 질문이 "실패"가 아니라 **"불능"**으로 표시돼 사람이 알아채게 한다.
466
+ - FR-22.4 **무인 안전**: `--ask never`(물을 사람 없음)에서 `on-fail ask` 는 pause 하지 않고
467
+ 안전하게 종료한다 — 답을 못 받는데 멈춰 있으면 무한/폭주가 된다.
468
+
447
469
  ---
448
470
 
449
471
  ## 3. 비기능 요구사항
package/docs/guide.md CHANGED
@@ -33,8 +33,9 @@ hi-loop run --goal "add 함수를 만들어라" --test "npm test" # 사람이
33
33
  | Git | 선택 (롤백/리뷰용) | `git --version` |
34
34
 
35
35
  > ⚠️ **`npm install -g handoff` 를 하지 마라.** 무스코프 `handoff` 는 이 프로젝트와
36
- > 무관한 **제3자의 redis lua 래퍼 패키지**(v0.1.3)다. 이 프로젝트의 이름은
37
- > **`@tuzi-ince/hi-loop`** 이며 아직 배포 전이다. 설치는 아래 §2 방법을 쓴다.
36
+ > 무관한 **제3자의 redis lua 래퍼 패키지**(v0.1.3)다. 이 프로젝트는 **스코프 이름
37
+ > `@tuzi-ince/hi-loop`** 으로 npm 레지스트리에 게시돼 있다(현재 0.4.0). 스코프를 반드시 붙여라.
38
+ > 최신 소스(0.4.1+)로 개발·검증하려면 아래 §2 의 로컬 설치를 쓴다.
38
39
 
39
40
  ---
40
41
 
@@ -45,9 +46,9 @@ hi-loop run --goal "add 함수를 만들어라" --test "npm test" # 사람이
45
46
  ```bash
46
47
  git clone <이 저장소> hi-loop && cd hi-loop
47
48
  npm install # @modelcontextprotocol/sdk, zod
48
- npm test # 104개 통과 확인
49
+ npm test # 전부 통과 확인
49
50
  npm link # 전역에 hi-loop / hi-loop-setup 심볼릭 링크
50
- hi-loop --version # 0.1.0
51
+ hi-loop --version # 0.4.1
51
52
  ```
52
53
 
53
54
  해제: `npm unlink -g @tuzi-ince/hi-loop`
@@ -62,10 +63,10 @@ npm install -g /absolute/path/to/hi-loop
62
63
 
63
64
  ```bash
64
65
  # 보내는 쪽
65
- npm pack # tuzi-ince-hi-loop-0.1.0.tgz 생성 (~26kB)
66
+ npm pack # tuzi-ince-hi-loop-0.4.1.tgz 생성 (~26kB)
66
67
 
67
68
  # 받는 쪽
68
- npm install -g ./tuzi-ince-hi-loop-0.1.0.tgz
69
+ npm install -g ./tuzi-ince-hi-loop-0.4.1.tgz
69
70
  ```
70
71
 
71
72
  ### 2-D. 설치 없이 직접 실행
@@ -121,7 +122,11 @@ hi-loop-setup # 실제 적용
121
122
 
122
123
  ## 4. 사용법
123
124
 
124
- ### 4-1. CLI 직접 실행 (사람 / 데몬 / 텔레그램 봇)
125
+ > **주 경로는 MCP(§4-2)다.** 대부분은 코드 어시스턴트(클로드코드·커서)가 `hiloop_run` 같은
126
+ > **MCP 도구로 hi-loop 을 부른다.** CLI(§4-1)는 사람이 터미널에서·데몬·봇·CI 로 직접 구동할 때 쓴다.
127
+ > 두 경로는 **같은 엔진·같은 상태 머신**을 쓰므로 동작은 동일하다 — 인터페이스만 다르다.
128
+
129
+ ### 4-1. CLI 직접 실행 (사람 / 데몬 / 텔레그램 봇 / CI)
125
130
 
126
131
  ```bash
127
132
  hi-loop run --goal "JWT 인증 미들웨어를 만들어라" \
@@ -134,7 +139,8 @@ hi-loop run --goal "JWT 인증 미들웨어를 만들어라" \
134
139
  |---|---|---|---|
135
140
  | `--goal` | `-g` | (필수) | 달성할 요구사항 |
136
141
  | `--test` | `-t` | `npm test` | 성패를 판정할 명령 |
137
- | `--max-loops` | `-m` | `10` | 최대 루프(=에이전트 호출) **횟수** 상한 |
142
+ | `--max-loops` | `-m` | `10` | 최대 루프(=에이전트 호출) **횟수** 상한 (무인 환경 백스톱) |
143
+ | `--on-fail` | — | `heal` | CHECK 실패/불능 시: `heal`(자동 치유) / `ask`(사람에게 재시도·종료 물음) / `stop`(즉시 종료). 대화형·비싼 e2e 에선 `ask` 권장 |
138
144
  | `--budget-usd` | — | (없음=무제한) | 누적 **비용** 상한(USD). 넘으면 다음 호출 전에 중단 |
139
145
  | `--stagnation` | — | `3` | 같은 실패가 이 횟수 연속이면 조기 종료. `--no-stagnation` 으로 끔 |
140
146
  | `--verify-spec` | — | (꺼짐) | 통과 후 별도 검증자로 스펙 대비 구현을 심판 (하향 전용, 비용 증가) |
@@ -162,6 +168,21 @@ hi-loop status # 현재 루프 상태 요약 (누적 비용·정체
162
168
  hi-loop --help
163
169
  ```
164
170
 
171
+ > **실패 시 자동 반복 대신 사람에게 물음 (`--on-fail ask`)**: 대화형·비싼 e2e 에서 무인 자동
172
+ > 치유가 폭주하는 걸 막는다. CHECK 실패/불능에서 루프가 **멈추고 재시도/종료를 묻는다**(exit 3).
173
+ > `heal`(기본, 자동 치유) 동작은 그대로다. 무인(`--ask never`)에선 물을 수 없으니 안전하게 종료한다.
174
+ >
175
+ > ```bash
176
+ > hi-loop run --goal "..." --test "cd frontend && npx playwright test" --on-fail ask
177
+ > # ❌ 테스트 실패 → ⏸ 선택이 필요합니다 [ON_FAIL:2]
178
+ > # 계속 진행할까요? a) 재시도 b) 종료
179
+ > # → hi-loop answer <a|b> (exit 3)
180
+ >
181
+ > hi-loop answer a # 재시도 → 같은 goal 로 run 을 다시 호출하면 이어서 진행
182
+ > ```
183
+ > MCP 에선 `hiloop_run({ goal, onFail: "ask" })` → `awaiting` 반환 → `hiloop_answer` 로 답하고 재호출.
184
+ > 판정 불능(무결성·환경)일 땐 질문이 "실패"가 아니라 **"불능"**으로 표시돼 사람이 알아챈다.
185
+
165
186
  > **문서↔소스 정합 (`--spec` / `--reconcile`)**: 기본적으로 이 엔진의 스펙·테스트는 에이전트
166
187
  > **자신이** 쓴 것이라, 통과가 "자기 테스트를 통과"에 그칠 수 있다(완료 보고의 Gaps 가 이를 공개한다).
167
188
  > 사람이 관리하는 표준 문서(`docs/DESIGN.md`, `hi-loop-setup` 이 만든다)를 오라클로 세우면 그 한계를 좁힌다:
@@ -253,21 +274,69 @@ hi-loop rollback --cwd . # 작업 대상 지정
253
274
  안전망 스냅샷으로 한 번 더 떠두므로, 롤백 자체를 잘못 눌러도 다시 앞으로 갈 수 있다.
254
275
  - git 저장소가 아니면 체크포인트가 없으니 롤백도 조용히 no-op이다.
255
276
 
256
- ### 4-2. MCP 서버 (클로드코드 / 커서)
277
+ ### 4-2. MCP 서버 (클로드코드 / 커서) — **주 사용 경로 (권장)**
257
278
 
258
- `hi-loop-setup` 에이전트를 재시작하면 도구 6개가 뜬다.
279
+ 대부분의 사용은 CLI 아니라 **코드 어시스턴트(클로드코드·커서)가 MCP 도구로 hi-loop 을 부르는**
280
+ 방식이다. `hi-loop-setup` 후 에이전트를 재시작하면 도구 6개가 뜬다.
259
281
 
260
282
  | 도구 | 인자 | 용도 |
261
283
  |---|---|---|
262
- | `hiloop_run` | `goal`(필수), `testCommand`, `maxLoops`, `verifySpec`, `full`, `ship`, `watch`, `stopAfter`, `spec`, `reconcile`, `reconcileSpec`, `cwd` | 자가 치유(+전과정) 루프 실행 |
284
+ | `hiloop_run` | `goal`(필수), `testCommand`, `maxLoops`, `verifySpec`, `full`, `ship`, `watch`, `stopAfter`, `spec`, `reconcile`, `reconcileSpec`, `checks`, `onFail`, `cwd` | 자가 치유(+전과정) 루프 실행 |
263
285
  | `hiloop_answer` | `choice`, `note`, `cwd` | 대기 중인 질문에 답하고 재개 가능 상태로 되돌림 |
264
286
  | `hiloop_status` | `cwd` | 진행 상태 조회 (장시간 루프를 들여다보는 창) |
265
287
  | `hiloop_rollback` | `to`, `cwd` | 체크포인트로 파일 복원 |
266
288
  | `hiloop_reset` | `cwd` | `.hi-loop/STATE.json` 초기화 |
267
289
  | `hiloop_setup` | `dryRun`, `cwd` | 프로젝트 설정 주입 |
268
290
 
291
+ #### 기본 흐름 — 실행 → 진행 → (필요 시) 멈춤·재개
292
+
293
+ ```
294
+ hiloop_run({ goal, testCommand, cwd })
295
+ │ 루프가 도는 동안 회차마다 progress 알림을 보낸다(아래 참조) → 클라이언트가 안 끊긴다
296
+ ├─▶ ✅ 통과 → 결과 텍스트 + Gaps(검증 한계) 반환. 끝.
297
+ ├─▶ ❌ 실패 → 실패 요약 반환(heal 기본은 maxLoops 까지 자동 치유 후).
298
+ └─▶ ⏸ awaiting → **사람의 선택이 필요**. 질문을 사용자에게 그대로 제시하고,
299
+ hiloop_answer({ choice }) 로 답한 뒤 **같은 goal 로 hiloop_run 을 다시 호출**해 이어간다.
300
+ ```
301
+
302
+ **멈춤·재개(ask)는 MCP 의 핵심 패턴이다.** MCP 는 단일 요청/응답이라 서버가 사람을 기다릴 수 없다.
303
+ 그래서 hi-loop 은 **상태를 저장하고 `awaiting` 으로 반환**한다(차단 안 함). 멈추는 지점은 셋:
304
+ - **DISCOVER 분기**(상호배타 선택), **SHIP 직전**(비가역 배포), **on-fail ask**(테스트 실패/불능).
305
+ - 어느 경우든 `hiloop_run` 이 `⏸ 사용자의 선택이 필요합니다 …` 를 반환한다 → 그 질문을 사용자에게
306
+ 보여주고 → `hiloop_answer({ choice: "a" })` → **같은 goal 로 `hiloop_run` 재호출**하면 그 지점부터 잇는다.
307
+
308
+ #### 실패를 사람이 통제하기 — `onFail: "ask"` (대화형·비싼 e2e 권장)
309
+
310
+ 기본(`heal`)은 `maxLoops` 까지 무인 자동 치유다. 대화형에서 비싼 e2e 를 무인으로 반복시키지 않으려면:
311
+
312
+ ```js
313
+ hiloop_run({ goal: "...", testCommand: "cd frontend && npx playwright test", onFail: "ask" })
314
+ // ❌ 실패/불능 → ⏸ awaiting "재시도할까요 / 종료할까요?"
315
+ hiloop_answer({ choice: "a" }) // a=재시도, b=종료
316
+ // → 같은 goal 로 hiloop_run 재호출하면 한 회차 더 돌린다
317
+ ```
318
+ 판정 불능(무결성·환경)일 땐 질문이 "실패"가 아니라 **"불능"**으로 표시된다.
319
+
320
+ #### 긴 루프가 안 끊긴다 — progress 알림
321
+
322
+ `hiloop_run` 은 루프가 도는 동안 **회차마다 진행 알림(`notifications/progress`)을 보낸다.** 그래서
323
+ e2e 처럼 오래 걸리는 루프도 클라이언트 유휴 타임아웃(기본 30분)에 조용히 끊기지 않는다. 그럼에도
324
+ 매우 긴 무인 루프를 돌릴 땐, `hiloop_run` 대신 한 번 던지고 **`hiloop_status` 로 폴링**하는 편이 안전하다.
325
+
269
326
  > MCP `hiloop_run`은 CLI와 달리 `--budget-usd`·`--stagnation`을 노출하지 않는다.
270
- > 예산 상한이 필요하면 CLI(`hi-loop run`)를 쓴다. 정체 감지는 기본값(3회)으로 동작한다.
327
+ > 예산 상한이 필요하면 CLI(`hi-loop run`)를 쓴다. 정체 감지는 기본값(3회)으로 동작한다.
328
+
329
+ > **UI 변경 회차에만 e2e 를 엔진이 강제하기 (`checks`).** `checks: [{cmd, when?}]` 를 주면
330
+ > `testCommand` 대신 이 배열이 판정 기준이 되고, `when` 글롭에 맞는 파일이 바뀐 회차에만 그 검사를
331
+ > 돌린다. e2e 를 스킬의 자연어 지시(사람이 기억해서 실행)가 아니라 **엔진이 결정론적으로 강제**하는 길이다:
332
+ > ```json
333
+ > "checks": [
334
+ > { "cmd": "npm test" },
335
+ > { "cmd": "npx playwright test", "when": "src/**/*.tsx" }
336
+ > ]
337
+ > ```
338
+ > e2e 는 **셸 명령**이어야 한다(엔진은 Playwright MCP 도구를 부를 수 없다 — 그 경우는 스킬이 루프 후
339
+ > MCP 로 돌린다). `flow` 스킬이 UI 작업을 감지하면 이 `checks` 를 자동으로 구성해 넘긴다.
271
340
 
272
341
  > **cwd 기본값은 `CLAUDE_PROJECT_DIR || process.cwd()`.** MCP 는 호스트(클로드코드/커서)가 서버를
273
342
  > 띄우는 경로라, "어느 프로젝트인가"의 정답은 호스트가 주입하는 `CLAUDE_PROJECT_DIR` 다. 각 도구에
@@ -346,7 +415,7 @@ hi-loop 의 CHECK 단계는 **에이전트가 "다 됐다"고 말해도 믿지
346
415
  npm version patch --no-git-tag-version # 0.1.0 -> 0.1.1
347
416
 
348
417
  # ── CHECK 1: 소스에서 테스트
349
- npm test # 104
418
+ npm test # 343
350
419
 
351
420
  # ── CHECK 2: 아티팩트로 만든다 (레지스트리 안 건드림)
352
421
  npm pack # tuzi-ince-hi-loop-0.1.1.tgz
@@ -372,7 +441,7 @@ rm -rf /tmp/lev-prefix # 정리
372
441
 
373
442
  ### 이 루프가 실제로 잡아낸 버그
374
443
 
375
- 이 절차는 이론이 아니다. **소스에서는 104개 테스트가 다 통과하는데 설치본에서는
444
+ 이 절차는 이론이 아니다. **소스에서는 343개 테스트가 다 통과하는데 설치본에서는
376
445
  모든 명령이 아무 일도 안 하고 조용히 `exit 0` 으로 끝나는** 버그를 이 루프가 잡았다.
377
446
 
378
447
  원인: `bin/*.js` 의 "직접 실행인가?" 판정이
@@ -431,7 +500,7 @@ npm install -g @tuzi-ince/hi-loop # 명령 이름은 그대로 hi-loop / hi-l
431
500
 
432
501
  배포 전 체크리스트:
433
502
 
434
- - [ ] `npm test` 통과 (104개)
503
+ - [ ] `npm test` 통과 (343개)
435
504
  - [ ] **§7 의 실제 에이전트 스모크 테스트 통과** ← 아직 안 된 항목. **이게 통과하기 전엔 배포하지 마라**
436
505
  - [ ] `npm pack --dry-run` 으로 포함 파일 확인 (bin, src, docs, README)
437
506
  - [ ] `version` 갱신
@@ -468,11 +537,11 @@ cat .hi-loop/STATE.json | jq '{status, phase, iteration, sessionId, sessionSeria
468
537
 
469
538
  **실제로 실행해 확인함**
470
539
 
471
- - `npm test` 104개 통과
540
+ - `npm test` 전부 통과
472
541
  - `npm pack` → tarball 생성 (bin/src/docs/README 포함)
473
542
  - **`npm install -g --prefix /tmp/lev-prefix ./tarball` 로 진짜 설치 → 설치본 실행 확인**
474
543
  - `bin/hi-loop` 가 심링크로 생성됨을 `ls -l` 로 확인
475
- - **심링크 bin 경유** `hi-loop --version` → `0.1.0`, `run`(goal 없음) → exit 2
544
+ - **심링크 bin 경유** `hi-loop --version` → `0.4.1`, `run`(goal 없음) → exit 2
476
545
  - 설치본으로 전체 루프 E2E: PLAN→실패→DO→통과→exit 0
477
546
  - 설치본 `hi-loop-setup` → `.mcp.json` 이 설치 위치를 정확히 가리킴
478
547
  - `hi-loop-setup` 멱등, `--dry-run` 무기록
@@ -507,7 +576,8 @@ cat .hi-loop/STATE.json | jq '{status, phase, iteration, sessionId, sessionSeria
507
576
  - **MCP 모드에서 실제 에이전트 spawn** — 도구 왕복(initialize/tools/call)만 확인
508
577
  - 장시간(10회) 실제 루프
509
578
  - 텔레그램 실제 발송
510
- - 레지스트리 경유 설치(`npm install -g @tuzi-ince/hi-loop`) 배포 후에만 가능
579
+ - 레지스트리에는 **0.4.0** 게시돼 있다 — 이번 세션의 변경(0.4.1: `.hi-loop/` 상태·대문자 규약·
580
+ MCP `checks`)을 레지스트리로 받으려면 **0.4.1 재게시**가 필요하다. 그전까지 최신은 로컬 설치(§2)로만.
511
581
 
512
582
  **재현 절차**
513
583
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuzi-ince/hi-loop",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "CLI와 MCP 모드를 지원하는 초경량 자율형 자가 치유 엔진",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,6 +46,19 @@ user-invocable: true
46
46
  - `full: true` — 발굴·설계리뷰·코드리뷰를 포함한 전체 라이프사이클
47
47
  - `testCommand`: 프로젝트의 테스트 명령(기본 `npm test`)
48
48
  - 필요 시 `maxLoops`, `ship`, `watch`
49
+ - **UI 작업이면 e2e 를 엔진 게이트로 넣는다(권장 — LLM 기억에 의존하지 않는 결정론적 강제).**
50
+ goal 이 UI(화면·컴포넌트·라우팅·스타일)를 건드릴 것 같고 프로젝트에 **셸 e2e 명령**
51
+ (package.json `test:e2e` 스크립트, 또는 `playwright.config.*` → `npx playwright test`)이 있으면,
52
+ **먼저 사용자에게 e2e 게이트 여부를 묻고**, 켜기로 하면 `checks` 로 넘긴다:
53
+ ```json
54
+ "checks": [
55
+ { "cmd": "<기본 테스트, 예: npm test>" },
56
+ { "cmd": "<e2e 셸 명령>", "when": "<UI 글롭, 예: src/**/*.tsx>" }
57
+ ]
58
+ ```
59
+ → 엔진이 **UI 변경이 (커밋 전) 워킹트리에 있는 회차마다** e2e 를 돌리고, 통과해야 pass 로 인정한다(조용히 빠지지 않는다).
60
+ - 셸 e2e 명령이 없고 **Playwright MCP 만** 있으면 `checks` 를 쓰지 않는다(엔진은 MCP 도구를 못 부른다)
61
+ — 루프 통과 후 7단계에서 MCP 로 돌린다. **둘 다 없으면** e2e 는 스킵한다(도구를 임의 설치하지 않는다).
49
62
 
50
63
  5. **사용자 선택 처리.** 응답이 `⏸ 사용자의 선택이 필요합니다` 로 오면,
51
64
  그 질문을 **그대로 사용자에게 제시**하고 답을 받는다. 답을 `hiloop_answer`
@@ -54,27 +67,18 @@ user-invocable: true
54
67
  6. **결과 보고.** `✅ 통과` / `❌ 실패` 와 반복 횟수, 그리고 함께 온 Gaps(검증 한계)를
55
68
  사용자에게 전한다. false green(테스트만 초록이고 실제 미완)을 통과로 포장하지 않는다.
56
69
 
57
- 7. **UI 변경 e2e 게이트.** 문법·기능 테스트가 통과(✅)한 뒤, 이번 변경이
58
- **UI(프론트엔드 화면·컴포넌트·라우팅·스타일)** 건드렸는지 판단한다.
59
- - UI 변경이 **없으면** 단계를 건너뛴다(그대로 종료).
60
- - UI 변경이 **있으면** 사용자에게 **먼저 묻는다**: "UI 변경이 있었습니다. e2e 테스트를
61
- 진행할까요?" 임의로 실행하지 않는다.
62
-
63
- **e2e 실행 능력을 순서대로 탐지한다(강등 사다리).** e2e는 이미 통과한 루프의 *부가* 게이트다 —
64
- 없다고 루프를 실패로 만들지 않는다.
65
- 1. **Playwright MCP 도구가 세션에 있으면** 그걸로 실행한다(브라우저 구동·스크린샷 네이티브 지원).
66
- 2. 없지만 **프로젝트에 e2e 셋업이 있으면**(`@playwright/test` devDep + `test:e2e` 스크립트,
67
- 또는 `playwright.config.*`, 또는 `e2e` 스킬) 경로로 실행한다.
68
- 3. **둘 없으면** e2e 도구를 **임의로 설치하지 않는다** Playwright + 브라우저 바이너리는
69
- 수백 MB다. 사용자에게 상황을 알리고 선택지를 준다: (a) 설치 후 진행, (b) 이미 있는 다른
70
- 도구 사용, (c) 건너뛰기. 어느 쪽도 강요하지 않고, **이 게이트를 "스킵"으로 기록**한다.
71
-
72
- - e2e를 실제로 실행하기로 했으면 **결과 화면(스크린샷) 저장 여부도 사용자에게 묻는다**.
73
- 스크린샷은 **브라우저를 구동하는 도구(1·2)가 있을 때만** 가능하다 — 없으면 저장할 화면이
74
- 없음을 밝힌다.
75
- - 완료되면 **테스트 결과서와 스크린샷을 프로젝트의 test 폴더에 저장**한다
76
- (예: 리포트 `tests/e2e/report-<날짜시각>.md`, 스크린샷 `tests/e2e/screenshots/`).
77
- 날짜·시각은 실제 값으로 채운다. 저장 경로를 사용자에게 알린다.
70
+ 7. **e2e 후처리 (스크린샷·리포트, MCP 폴백).** 루프가 통과(✅)한 뒤, 4단계에서 정한 e2e 전략에 따라:
71
+ - **엔진 `checks` 로 돌린 경우**: e2e 는 이미 게이트로 통과했다(엔진이 강제). 결과 화면(스크린샷)
72
+ 저장이 필요하면 사용자에게 물어 저장한다. **여기서 다시 e2e 를 돌릴 필요는 없다.**
73
+ - **Playwright MCP 폴백 경우**(셸 e2e 명령이 없어 4단계에서 checks 못 건 경우): 사용자에게
74
+ "UI 변경이 있었습니다. 지금 Playwright MCP 로 e2e 를 돌릴까요?" 물어, 승인 시 MCP 로 실행한다.
75
+ - **스킵된 경우**(도구 없음): e2e 도구가 없어 스킵했음을 밝힌다 — 통과한 루프를 실패로 만들지 않는다.
76
+ - 실행한 경우 **결과서·스크린샷을 test 폴더에 저장**한다(예: 리포트 `tests/e2e/report-<날짜시각>.md`,
77
+ 스크린샷 `tests/e2e/screenshots/`). 날짜·시각은 실제 값으로 채우고 저장 경로를 알린다.
78
+ 스크린샷은 브라우저를 구동하는 도구(Playwright MCP/셸)가 있을 때만 가능하다.
79
+
80
+ > 이렇게 나누나: e2e "실행"은 결정론적 게이트라 **엔진(`checks`)**에 맡기는 최신 정설이다
81
+ > (프롬프트 지시는 "제안"이지 "강제"가 아니다). 엔진이 부르는 Playwright MCP LLM 이 후처리한다.
78
82
 
79
83
  8. **커밋 게이트 (git 워크플로).** 테스트가 통과(✅)하고 e2e 게이트까지 끝나면 `commitPolicy` 대로
80
84
  **로컬** 커밋한다. **push/PR 은 하지 않는다.** git 저장소가 아니면 건너뛴다.
package/src/ask.js CHANGED
@@ -123,6 +123,18 @@ export function applyAnswer(state, { choice, note = '', now = new Date().toISOSt
123
123
  };
124
124
  }
125
125
 
126
+ /** FR-22 on-fail 게이트 질문 스펙 (재시도/종료). createAsk 에 그대로 넘긴다. */
127
+ export function onFailAsk({ stage, blocked, summary }) {
128
+ return {
129
+ stage,
130
+ question: `${blocked ? '⚠️ 테스트를 판정할 수 없습니다(불능).' : '테스트가 실패했습니다.'}\n${summary}\n계속 진행할까요?`,
131
+ options: [
132
+ { key: 'a', label: '재시도 — 수정하고 한 번 더 돌린다', impact: '회차·비용 추가' },
133
+ { key: 'b', label: '종료 — 여기서 멈춘다', impact: '지금까지의 산출물은 남는다' },
134
+ ],
135
+ };
136
+ }
137
+
126
138
  /** 에이전트가 스스로 세운 가정을 기록한다 (policy=never 또는 분기가 아닌 모호성). */
127
139
  export function recordAssumption(state, { text, stage, now = new Date().toISOString() }) {
128
140
  const clean = String(text ?? '').trim();
package/src/build.js CHANGED
@@ -46,10 +46,12 @@ function addCost(prev, delta) {
46
46
 
47
47
  export async function runBuildLoop(ctx) {
48
48
  const { goal, cwd, logger, notify, statePath, baselineFiles, designReviewEnabled, ports, opts } = ctx;
49
- const { maxLoops, budgetUsd, testCommand, stagnationLimit, verifySpec, handoffEvery, maxDesignRounds, stopAfter } = opts;
49
+ const { maxLoops, budgetUsd, testCommand, stagnationLimit, verifySpec, handoffEvery, maxDesignRounds, stopAfter, onFail, askPolicy } = opts;
50
50
  const { agentRunner, testRunner, integrityChecker, checkpointer, fileLister, specVerifier, designReviewer, treeKeyReader } = ports;
51
51
  // state 는 이 루프에서 속성만 바뀌고 재대입되지 않는다 — 참조를 그대로 잡아도 안전하다.
52
52
  const state = ctx.state;
53
+ // FR-22: on-fail ask 로 사용자가 "재시도"를 고른 만큼 회차 상한이 늘어난다(사람이 곧 상한).
54
+ const cap = () => maxLoops + (state.extraLoops || 0);
53
55
 
54
56
  state.stage = 'BUILD';
55
57
  state.status = 'running';
@@ -61,7 +63,14 @@ export async function runBuildLoop(ctx) {
61
63
  }
62
64
  saveState(statePath, state);
63
65
 
64
- while (state.iteration < maxLoops) {
66
+ // FR-22 재개: 직전 실패에서 on-fail ask 로 물었던 답을 처리한다('a'=재시도→회차 +1, 'b'=종료).
67
+ const pendingFail = [...(state.answers ?? [])].find((a) => a.stage === `ON_FAIL:${state.iteration}`);
68
+ if (pendingFail) {
69
+ if (pendingFail.choice === 'a') state.extraLoops = (state.extraLoops || 0) + 1;
70
+ else return { ok: false, stopReason: 'user-stop', detail: '사용자가 종료를 선택했습니다.' };
71
+ }
72
+
73
+ while (state.iteration < cap()) {
65
74
  // 예산은 **다음 호출 앞**에서 막는다. 이미 쓴 돈은 되돌릴 수 없으므로
66
75
  // 할 수 있는 일은 "더 쓰지 않는 것"뿐이다.
67
76
  if (budgetUsd != null && state.costUsd >= budgetUsd) {
@@ -206,6 +215,21 @@ export async function runBuildLoop(ctx) {
206
215
  state.lastError = truncate(failure, LIMITS.lastError);
207
216
  state.phase = 'ACT';
208
217
 
218
+ // ---- FR-22 on-fail 게이트 ----
219
+ // 실패/불능을 사람에게 물을지·즉시 멈출지. PLAN 회차(1)는 제외한다(구현 전이라 실패가 당연).
220
+ // heal(기본)은 이 블록을 건너뛰어 종전대로 자동 치유한다 — 기존 동작 불변.
221
+ if (state.iteration >= 2 && onFail !== 'heal') {
222
+ const blocked = !integrity.ok || regressed.length > 0; // 판정 불능/메타 문제(무결성·비회귀)
223
+ // stop, 또는 물을 수 없는 환경(ask never)은 안전하게 종료 — 무인 폭주 방지.
224
+ if (onFail === 'stop' || askPolicy === 'never') {
225
+ saveState(statePath, state);
226
+ return { ok: false, stopReason: 'on-fail-stop', detail: truncate(state.lastError, 500) };
227
+ }
228
+ // ask: 사람에게 재시도/종료를 묻는다. pause 신호만 반환하고 loop.js 가 ask 를 세팅한다.
229
+ saveState(statePath, state);
230
+ return { paused: true, onFail: { stage: `ON_FAIL:${state.iteration}`, blocked, summary: truncate(state.lastError, 400) } };
231
+ }
232
+
209
233
  // ---- 정체 감지 (L2) ----
210
234
  // 같은 실패가 stagnationLimit 회 연속이면 이 접근으로는 못 고친다는 뜻 —
211
235
  // 같은 값을 태우며 maxLoops 를 다 쓰는 것은 순수한 낭비다(실측: 함정에 $3.07).
@@ -236,7 +260,7 @@ export async function runBuildLoop(ctx) {
236
260
  logger(`[hi-loop] ❌ 테스트 실패 (code ${check?.code}) — 치유를 시도합니다.`);
237
261
  }
238
262
 
239
- if (stagnationLimit != null && state.stagnantRuns >= stagnationLimit && state.iteration < maxLoops) {
263
+ if (stagnationLimit != null && state.stagnantRuns >= stagnationLimit && state.iteration < cap()) {
240
264
  logger(`[hi-loop] 🔁 같은 실패가 ${state.stagnantRuns}회 반복 — 접근이 막혔습니다. ${state.iteration}회차에서 중단합니다. (누적 $${state.costUsd.toFixed(2)})`);
241
265
  return {
242
266
  ok: false,
@@ -246,7 +270,7 @@ export async function runBuildLoop(ctx) {
246
270
  }
247
271
 
248
272
  // ---- 세션 핸드오프 & 컨텍스트 다이어트 (FR-3.2) ----
249
- if (state.iteration % handoffEvery === 0 && state.iteration < maxLoops) {
273
+ if (state.iteration % handoffEvery === 0 && state.iteration < cap()) {
250
274
  state.sessionId = null;
251
275
  state.sessionSerial += 1;
252
276
  saveState(statePath, state);
@@ -90,6 +90,9 @@ export function parseRunOptions(args, { staged = {} } = {}) {
90
90
 
91
91
  const onShipFail = enumOpt('on-ship-fail', ['stop', 'heal'], 'stop');
92
92
  if (onShipFail?.error) return bad(onShipFail.error);
93
+ // FR-22: CHECK 실패/불능 시 안쪽 루프를 어떻게 다룰까. heal=자동수정(기본) / ask=사람에게 물음 / stop=즉시 중단.
94
+ const onFail = enumOpt('on-fail', ['heal', 'ask', 'stop'], 'heal');
95
+ if (onFail?.error) return bad(onFail.error);
93
96
  const onWatchFail = enumOpt('on-watch-fail', ['stop', 'rollback', 'heal'], 'stop');
94
97
  if (onWatchFail?.error) return bad(onWatchFail.error);
95
98
 
@@ -138,6 +141,7 @@ export function parseRunOptions(args, { staged = {} } = {}) {
138
141
  commitMessage: str('commit-message'),
139
142
  onShipFail,
140
143
  onWatchFail,
144
+ onFail,
141
145
  askPolicy,
142
146
  yes: Boolean(args.yes),
143
147
  full: Boolean(args.full),
package/src/loop.js CHANGED
@@ -23,7 +23,7 @@ import { makeNotifier } from './telegram.js';
23
23
  import { makeShipper, makeWatcher, WATCH_DEFAULTS } from './ship.js';
24
24
  import { makeCodeReviewer, makeDesignReviewer } from './review.js';
25
25
  import { makeDiscoverer } from './discover.js';
26
- import { formatAsk, createAsk, pauseForAsk, shouldAsk, DEFAULT_ASK_POLICY } from './ask.js';
26
+ import { formatAsk, createAsk, pauseForAsk, shouldAsk, onFailAsk, DEFAULT_ASK_POLICY } from './ask.js';
27
27
  import { STAGE_HANDLERS } from './stages.js';
28
28
  import { runBuildLoop } from './build.js';
29
29
  import { buildStageContext } from './stage-context.js';
@@ -65,6 +65,7 @@ async function runLoopBody({
65
65
  flakyProbe = false,
66
66
  specPath = null, // --spec: 스펙 오라클을 표준 문서로 고정(FR-19). null=엔진이 경로 결정.
67
67
  checks = null,
68
+ onFail = 'heal', // FR-22: CHECK 실패/불능 시 안쪽 루프 처리. heal(자동) | ask(사람에게) | stop(중단).
68
69
  handoffEvery = 4,
69
70
  // ---- 라이프사이클 (FR-13, FR-14) ----
70
71
  shipCommand = null,
@@ -245,7 +246,7 @@ async function runLoopBody({
245
246
  shipCommand, watchCommand, onShipFail, onWatchFail, watchForMs, watchEveryMs,
246
247
  watchTolerate, maxReviewRounds, maxLoops, budgetUsd, testCommand, stagnationLimit,
247
248
  verifySpec, flakyProbe, checks, handoffEvery, maxDesignRounds, stopAfter,
248
- commit, commitMessage, commitStyle,
249
+ commit, commitMessage, commitStyle, onFail, askPolicy,
249
250
  },
250
251
  });
251
252
 
@@ -268,6 +269,12 @@ async function runLoopBody({
268
269
  state.status = 'running';
269
270
  saveState(statePath, state);
270
271
  const built = await runBuildLoop(ctx);
272
+ // FR-22: on-fail ask — 빌드 루프가 실패/불능에서 사람 판단을 요청하며 멈췄다.
273
+ if (built.paused) {
274
+ state.ask = createAsk(onFailAsk(built.onFail));
275
+ state.status = 'awaiting';
276
+ return pauseResult();
277
+ }
271
278
  if (built.stopped) return stopResult('PLAN');
272
279
  if (!built.ok) return finish('failed', { stopReason: built.stopReason, detail: built.detail });
273
280
  if (shouldStopAfter('BUILD')) return stopResult('BUILD');
@@ -290,5 +297,4 @@ async function runLoopBody({
290
297
  saveState(statePath, state);
291
298
  }
292
299
  }
293
-
294
300
  export default runLoop;
package/src/mcp-server.js CHANGED
@@ -43,13 +43,31 @@ export const tools = {
43
43
  spec,
44
44
  reconcile,
45
45
  reconcileSpec,
46
+ checks,
47
+ onFail,
46
48
  cwd = defaultCwd(),
47
- }) => {
49
+ }, extra) => {
48
50
  if (!goal) return fail('goal 은 필수입니다.');
49
51
  // 루프를 돌리기 전에 에이전트가 실행 가능한지 확인한다 — 미설치/실행불가면
50
52
  // 회차를 태우지 않고 여기서 행동지침을 돌려준다(호스트 LLM 이 사용자에게 전달).
51
53
  const pre = await preflightAgent();
52
54
  if (!pre.ok) return fail(pre.message);
55
+ // MCP progress: 루프 로그가 나올 때마다 클라이언트에 진행 알림을 보낸다. 이게 없으면
56
+ // 긴 루프(e2e 등)가 조용히 유휴 타임아웃(기본 30분)에 걸려 클라이언트 호출만 끊긴다.
57
+ // 클라이언트가 progressToken 을 준 경우에만 보낸다(안 주면 조용히 stderr 로만 로그).
58
+ const progressToken = extra?._meta?.progressToken;
59
+ let ticks = 0;
60
+ const runLogger = (line) => {
61
+ log(line);
62
+ if (progressToken != null && typeof extra?.sendNotification === 'function') {
63
+ extra
64
+ .sendNotification({
65
+ method: 'notifications/progress',
66
+ params: { progressToken, progress: (ticks += 1), message: String(line).replace(/^\[hi-loop[^\]]*\]\s*/, '').slice(0, 200) },
67
+ })
68
+ .catch(() => {}); // 알림 실패는 루프에 영향 없음
69
+ }
70
+ };
53
71
  const result = await runLoop({
54
72
  goal,
55
73
  testCommand,
@@ -62,8 +80,12 @@ export const tools = {
62
80
  specPath: spec || null,
63
81
  reconcile: reconcile === undefined ? null : Boolean(reconcile),
64
82
  reconcileSpec: reconcileSpec || null,
83
+ // 조건부 검사(--check/--when 의 MCP 노출): 주면 testCommand 대신 이 배열이 판정 기준이 된다.
84
+ // when 글롭에 맞는 파일이 바뀐 회차에만 그 검사를 돌린다(UI 변경 회차에만 e2e 등).
85
+ checks: Array.isArray(checks) && checks.length ? checks : null,
86
+ onFail: onFail || 'heal',
65
87
  cwd,
66
- logger: log,
88
+ logger: runLogger,
67
89
  });
68
90
 
69
91
  // 사람이 골라야 하는 지점에서 멈췄다. 호스트 LLM 이 이 질문을 사용자에게 그대로
@@ -141,11 +163,11 @@ export const tools = {
141
163
  },
142
164
  };
143
165
 
144
- /** 예외를 isError 응답으로 감싼다 (FR-4.4) */
166
+ /** 예외를 isError 응답으로 감싼다 (FR-4.4). extra(progress·signal)를 핸들러에 그대로 넘긴다. */
145
167
  export function wrap(handler) {
146
- return async (args) => {
168
+ return async (args, extra) => {
147
169
  try {
148
- return await handler(args ?? {});
170
+ return await handler(args ?? {}, extra);
149
171
  } catch (err) {
150
172
  // 환경 오류(에이전트 미설치·인증 만료 등)는 이미 행동지침 메시지다 — 그대로 전달한다.
151
173
  if (err?.kind === 'environment') return fail(err.message);
@@ -184,6 +206,10 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
184
206
  goal: z.string().describe('달성할 요구사항'),
185
207
  testCommand: z.string().optional().describe('검증 명령 (기본 "npm test")'),
186
208
  maxLoops: z.number().int().min(1).max(50).optional().describe('최대 루프 횟수 (기본 10)'),
209
+ onFail: z
210
+ .enum(['heal', 'ask', 'stop'])
211
+ .optional()
212
+ .describe('CHECK 실패/불능 시 처리. heal(기본)=maxLoops 안에서 자동 치유 / ask=사람에게 재시도·종료를 물음(awaiting 반환 → hiloop_answer 로 답하고 재호출) / stop=즉시 종료. 대화형에선 ask 권장'),
187
213
  verifySpec: z.boolean().optional().describe('통과 시 별도 검증자로 스펙 대비 구현을 심판 (하향 전용, 비용 증가)'),
188
214
  full: z.boolean().optional().describe('전체 라이프사이클(발굴·설계리뷰·코드리뷰)까지 실행 (비용 증가)'),
189
215
  ship: z.string().optional().describe('배포 명령. 주면 테스트 통과 후 실행하고 exit code 로 판정한다 (실행 전 사용자 확인)'),
@@ -204,6 +230,15 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
204
230
  .string()
205
231
  .optional()
206
232
  .describe('정합 게이트가 대조할 표준 문서 경로(기본 docs/DESIGN.md). 예: docs/DESIGN.md. 지정하면 게이트가 켜진다.'),
233
+ checks: z
234
+ .array(z.object({ cmd: z.string(), when: z.string().optional() }))
235
+ .optional()
236
+ .describe(
237
+ '조건부 판정 검사. 주면 testCommand 대신 이 배열이 판정 기준이 된다(첫 항목에 기본 테스트를 넣을 것). ' +
238
+ 'when 글롭(예: "src/**/*.tsx")이 있으면 그 경로가 바뀐 회차에만 그 검사를 돌린다 — ' +
239
+ '"UI 를 고친 회차에만 e2e" 를 엔진이 강제하는 용도. e2e 는 셸 명령이어야 한다(예: {cmd:"npx playwright test", when:"src/**/*.tsx"}). ' +
240
+ 'short-circuit 하지 않아 유닛이 깨져도 e2e 결과를 같은 회차에 함께 본다.',
241
+ ),
207
242
  cwd: cwdSchema,
208
243
  },
209
244
  },
package/src/resume.js CHANGED
@@ -57,6 +57,7 @@ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd, sp
57
57
  specPath: pinnedSpecPath ?? prev.specPath ?? paths.specPath,
58
58
  specPinned: pinnedSpecPath ? true : Boolean(prev.specPinned),
59
59
  reconciled: Boolean(prev.reconciled),
60
+ extraLoops: Number.isFinite(prev.extraLoops) ? prev.extraLoops : 0,
60
61
  testPath: prev.testPath ?? paths.testPath,
61
62
  costUsd: Number.isFinite(prev.costUsd) ? prev.costUsd : 0,
62
63
  // 기준선이 없던 시절의 상태로 재개하면 이번 회차 지문이 곧 기준선이 된다.
package/src/state.js CHANGED
@@ -146,6 +146,8 @@ export function createState({
146
146
  specPinned: Boolean(pinnedSpecPath),
147
147
  // FR-21: 문서 정합 게이트를 이미 거쳤는가. 같은 goal 재개 시 다시 묻지 않도록.
148
148
  reconciled: false,
149
+ // FR-22: on-fail ask 에서 사용자가 "재시도"를 고를 때마다 +1. maxLoops 위에 얹혀 회차를 늘린다.
150
+ extraLoops: 0,
149
151
  specSummary: '',
150
152
  lastError: '',
151
153
  costUsd: 0,