@tuzi-ince/hi-loop 0.4.1 → 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
@@ -487,6 +487,20 @@ MCP 도구의 `cwd` 기본값은 `CLAUDE_PROJECT_DIR || process.cwd()` 다 —
487
487
  반대로 순수 `process.cwd()` — 터미널에서 cd한 곳이 사용자의 명시 의도이고, 통합 터미널에
488
488
  새어든 env가 그것을 덮으면 하위 프로젝트 겨냥이 깨지기 때문이다.)
489
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
+
490
504
  ### 💸 비용은 엔진이 센다 — 그리고 싸지 않다
491
505
 
492
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/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?`, `reconcileSpec?`, `checks?`, `cwd?` | 루프 실행 후 결과 텍스트 반환. `budgetUsd`·`stagnation`은 노출 안 함(CLI 전용). `checks`=`[{cmd,when?}]` 조건부 검사(CLI `--check/--when` MCP 노출) UI 변경 회차에만 e2e 같은 게이트를 **엔진이 강제**한다 |
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
@@ -122,7 +122,11 @@ hi-loop-setup # 실제 적용
122
122
 
123
123
  ## 4. 사용법
124
124
 
125
- ### 4-1. CLI 직접 실행 (사람 / 데몬 / 텔레그램 봇)
125
+ > **주 경로는 MCP(§4-2)다.** 대부분은 코드 어시스턴트(클로드코드·커서)가 `hiloop_run` 같은
126
+ > **MCP 도구로 hi-loop 을 부른다.** CLI(§4-1)는 사람이 터미널에서·데몬·봇·CI 로 직접 구동할 때 쓴다.
127
+ > 두 경로는 **같은 엔진·같은 상태 머신**을 쓰므로 동작은 동일하다 — 인터페이스만 다르다.
128
+
129
+ ### 4-1. CLI 직접 실행 (사람 / 데몬 / 텔레그램 봇 / CI)
126
130
 
127
131
  ```bash
128
132
  hi-loop run --goal "JWT 인증 미들웨어를 만들어라" \
@@ -135,7 +139,8 @@ hi-loop run --goal "JWT 인증 미들웨어를 만들어라" \
135
139
  |---|---|---|---|
136
140
  | `--goal` | `-g` | (필수) | 달성할 요구사항 |
137
141
  | `--test` | `-t` | `npm test` | 성패를 판정할 명령 |
138
- | `--max-loops` | `-m` | `10` | 최대 루프(=에이전트 호출) **횟수** 상한 |
142
+ | `--max-loops` | `-m` | `10` | 최대 루프(=에이전트 호출) **횟수** 상한 (무인 환경 백스톱) |
143
+ | `--on-fail` | — | `heal` | CHECK 실패/불능 시: `heal`(자동 치유) / `ask`(사람에게 재시도·종료 물음) / `stop`(즉시 종료). 대화형·비싼 e2e 에선 `ask` 권장 |
139
144
  | `--budget-usd` | — | (없음=무제한) | 누적 **비용** 상한(USD). 넘으면 다음 호출 전에 중단 |
140
145
  | `--stagnation` | — | `3` | 같은 실패가 이 횟수 연속이면 조기 종료. `--no-stagnation` 으로 끔 |
141
146
  | `--verify-spec` | — | (꺼짐) | 통과 후 별도 검증자로 스펙 대비 구현을 심판 (하향 전용, 비용 증가) |
@@ -163,6 +168,21 @@ hi-loop status # 현재 루프 상태 요약 (누적 비용·정체
163
168
  hi-loop --help
164
169
  ```
165
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
+
166
186
  > **문서↔소스 정합 (`--spec` / `--reconcile`)**: 기본적으로 이 엔진의 스펙·테스트는 에이전트
167
187
  > **자신이** 쓴 것이라, 통과가 "자기 테스트를 통과"에 그칠 수 있다(완료 보고의 Gaps 가 이를 공개한다).
168
188
  > 사람이 관리하는 표준 문서(`docs/DESIGN.md`, `hi-loop-setup` 이 만든다)를 오라클로 세우면 그 한계를 좁힌다:
@@ -254,21 +274,57 @@ hi-loop rollback --cwd . # 작업 대상 지정
254
274
  안전망 스냅샷으로 한 번 더 떠두므로, 롤백 자체를 잘못 눌러도 다시 앞으로 갈 수 있다.
255
275
  - git 저장소가 아니면 체크포인트가 없으니 롤백도 조용히 no-op이다.
256
276
 
257
- ### 4-2. MCP 서버 (클로드코드 / 커서)
277
+ ### 4-2. MCP 서버 (클로드코드 / 커서) — **주 사용 경로 (권장)**
258
278
 
259
- `hi-loop-setup` 에이전트를 재시작하면 도구 6개가 뜬다.
279
+ 대부분의 사용은 CLI 아니라 **코드 어시스턴트(클로드코드·커서)가 MCP 도구로 hi-loop 을 부르는**
280
+ 방식이다. `hi-loop-setup` 후 에이전트를 재시작하면 도구 6개가 뜬다.
260
281
 
261
282
  | 도구 | 인자 | 용도 |
262
283
  |---|---|---|
263
- | `hiloop_run` | `goal`(필수), `testCommand`, `maxLoops`, `verifySpec`, `full`, `ship`, `watch`, `stopAfter`, `spec`, `reconcile`, `reconcileSpec`, `checks`, `cwd` | 자가 치유(+전과정) 루프 실행 |
284
+ | `hiloop_run` | `goal`(필수), `testCommand`, `maxLoops`, `verifySpec`, `full`, `ship`, `watch`, `stopAfter`, `spec`, `reconcile`, `reconcileSpec`, `checks`, `onFail`, `cwd` | 자가 치유(+전과정) 루프 실행 |
264
285
  | `hiloop_answer` | `choice`, `note`, `cwd` | 대기 중인 질문에 답하고 재개 가능 상태로 되돌림 |
265
286
  | `hiloop_status` | `cwd` | 진행 상태 조회 (장시간 루프를 들여다보는 창) |
266
287
  | `hiloop_rollback` | `to`, `cwd` | 체크포인트로 파일 복원 |
267
288
  | `hiloop_reset` | `cwd` | `.hi-loop/STATE.json` 초기화 |
268
289
  | `hiloop_setup` | `dryRun`, `cwd` | 프로젝트 설정 주입 |
269
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
+
270
326
  > MCP `hiloop_run`은 CLI와 달리 `--budget-usd`·`--stagnation`을 노출하지 않는다.
271
- > 예산 상한이 필요하면 CLI(`hi-loop run`)를 쓴다. 정체 감지는 기본값(3회)으로 동작한다.
327
+ > 예산 상한이 필요하면 CLI(`hi-loop run`)를 쓴다. 정체 감지는 기본값(3회)으로 동작한다.
272
328
 
273
329
  > **UI 변경 회차에만 e2e 를 엔진이 강제하기 (`checks`).** `checks: [{cmd, when?}]` 를 주면
274
330
  > `testCommand` 대신 이 배열이 판정 기준이 되고, `when` 글롭에 맞는 파일이 바뀐 회차에만 그 검사를
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuzi-ince/hi-loop",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "CLI와 MCP 모드를 지원하는 초경량 자율형 자가 치유 엔진",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
@@ -44,13 +44,30 @@ export const tools = {
44
44
  reconcile,
45
45
  reconcileSpec,
46
46
  checks,
47
+ onFail,
47
48
  cwd = defaultCwd(),
48
- }) => {
49
+ }, extra) => {
49
50
  if (!goal) return fail('goal 은 필수입니다.');
50
51
  // 루프를 돌리기 전에 에이전트가 실행 가능한지 확인한다 — 미설치/실행불가면
51
52
  // 회차를 태우지 않고 여기서 행동지침을 돌려준다(호스트 LLM 이 사용자에게 전달).
52
53
  const pre = await preflightAgent();
53
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
+ };
54
71
  const result = await runLoop({
55
72
  goal,
56
73
  testCommand,
@@ -66,8 +83,9 @@ export const tools = {
66
83
  // 조건부 검사(--check/--when 의 MCP 노출): 주면 testCommand 대신 이 배열이 판정 기준이 된다.
67
84
  // when 글롭에 맞는 파일이 바뀐 회차에만 그 검사를 돌린다(UI 변경 회차에만 e2e 등).
68
85
  checks: Array.isArray(checks) && checks.length ? checks : null,
86
+ onFail: onFail || 'heal',
69
87
  cwd,
70
- logger: log,
88
+ logger: runLogger,
71
89
  });
72
90
 
73
91
  // 사람이 골라야 하는 지점에서 멈췄다. 호스트 LLM 이 이 질문을 사용자에게 그대로
@@ -145,11 +163,11 @@ export const tools = {
145
163
  },
146
164
  };
147
165
 
148
- /** 예외를 isError 응답으로 감싼다 (FR-4.4) */
166
+ /** 예외를 isError 응답으로 감싼다 (FR-4.4). extra(progress·signal)를 핸들러에 그대로 넘긴다. */
149
167
  export function wrap(handler) {
150
- return async (args) => {
168
+ return async (args, extra) => {
151
169
  try {
152
- return await handler(args ?? {});
170
+ return await handler(args ?? {}, extra);
153
171
  } catch (err) {
154
172
  // 환경 오류(에이전트 미설치·인증 만료 등)는 이미 행동지침 메시지다 — 그대로 전달한다.
155
173
  if (err?.kind === 'environment') return fail(err.message);
@@ -188,6 +206,10 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
188
206
  goal: z.string().describe('달성할 요구사항'),
189
207
  testCommand: z.string().optional().describe('검증 명령 (기본 "npm test")'),
190
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 권장'),
191
213
  verifySpec: z.boolean().optional().describe('통과 시 별도 검증자로 스펙 대비 구현을 심판 (하향 전용, 비용 증가)'),
192
214
  full: z.boolean().optional().describe('전체 라이프사이클(발굴·설계리뷰·코드리뷰)까지 실행 (비용 증가)'),
193
215
  ship: z.string().optional().describe('배포 명령. 주면 테스트 통과 후 실행하고 exit code 로 판정한다 (실행 전 사용자 확인)'),
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,