@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 +34 -2
- package/bin/hi-loop.js +2 -1
- package/docs/DESIGN.md +1 -1
- package/docs/SPEC.md +24 -2
- package/docs/guide.md +88 -18
- package/package.json +1 -1
- package/skills/flow/SKILL.md +25 -21
- package/src/ask.js +12 -0
- package/src/build.js +28 -4
- package/src/cli-options.js +4 -0
- package/src/loop.js +9 -3
- package/src/mcp-server.js +40 -5
- package/src/resume.js +1 -0
- package/src/state.js +2 -0
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 로그인 폼 만들어줘` (슬래시 스킬) |
|
|
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] [--
|
|
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
|
-
| 단위/수용 테스트
|
|
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
|
-
>
|
|
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 #
|
|
49
|
+
npm test # 전부 통과 확인
|
|
49
50
|
npm link # 전역에 hi-loop / hi-loop-setup 심볼릭 링크
|
|
50
|
-
hi-loop --version # 0.1
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 #
|
|
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
|
-
이 절차는 이론이 아니다. **소스에서는
|
|
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` 통과 (
|
|
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`
|
|
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
|
|
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
|
-
-
|
|
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
package/skills/flow/SKILL.md
CHANGED
|
@@ -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. **
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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 <
|
|
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 <
|
|
273
|
+
if (state.iteration % handoffEvery === 0 && state.iteration < cap()) {
|
|
250
274
|
state.sessionId = null;
|
|
251
275
|
state.sessionSerial += 1;
|
|
252
276
|
saveState(statePath, state);
|
package/src/cli-options.js
CHANGED
|
@@ -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:
|
|
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,
|