@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/README.md +175 -0
- package/bin/hi-loop.js +164 -0
- package/bin/setup.js +115 -0
- package/docs/design.md +631 -0
- package/docs/guide.md +432 -0
- package/docs/spec.md +315 -0
- package/package.json +46 -0
- package/src/args.js +33 -0
- package/src/checkpoint.js +94 -0
- package/src/integrity.js +131 -0
- package/src/is-main.js +28 -0
- package/src/lock.js +83 -0
- package/src/loop.js +296 -0
- package/src/mcp-server.js +159 -0
- package/src/prompts.js +125 -0
- package/src/runners.js +129 -0
- package/src/state.js +230 -0
- package/src/telegram.js +54 -0
- package/src/verify.js +96 -0
package/docs/spec.md
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# hi-loop — 상세 요구사항 정의서 (Spec v1.0)
|
|
2
|
+
|
|
3
|
+
## 0. 개요
|
|
4
|
+
|
|
5
|
+
`hi-loop`는 개발자가 "날것의 아이디어(goal)"를 던지면, AI 에이전트를 PDCA 루프로
|
|
6
|
+
반복 구동하여 **테스트가 통과할 때까지 스스로 고쳐 나가는** 초경량 자율형 자가 치유 엔진이다.
|
|
7
|
+
|
|
8
|
+
- 런타임: Node.js >= 18 (ESM, `"type": "module"`)
|
|
9
|
+
- 배포 형태: 단일 npm 패키지 (`hi-loop`, `hi-loop-setup` 두 개의 bin)
|
|
10
|
+
- 실행 모드: **CLI 모드**(사람/봇이 직접 구동) + **MCP 서버 모드**(에이전트가 도구로 로드)
|
|
11
|
+
- 외부 의존성: `@modelcontextprotocol/sdk` + `zod`(SDK의 스키마 요구사항) — 둘 다 MCP 모드 전용 lazy import
|
|
12
|
+
- 텔레그램 알림은 Node 내장 `fetch` 사용 → 추가 의존성 없음
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 1. 용어
|
|
17
|
+
|
|
18
|
+
| 용어 | 정의 |
|
|
19
|
+
|---|---|
|
|
20
|
+
| goal | 사용자가 준 요구사항 문자열 |
|
|
21
|
+
| testCommand | 성공/실패를 판정하는 쉘 명령 (기본 `npm test`) |
|
|
22
|
+
| iteration | PDCA 루프 1회전 |
|
|
23
|
+
| session | 에이전트 CLI의 대화 세션 (session id로 식별) |
|
|
24
|
+
| hi-loop | 세션을 폐기하고 압축된 상태만 새 세션에 넘기는 행위 |
|
|
25
|
+
| agentRunner | 에이전트를 실제로 호출하는 주입 가능한 함수 |
|
|
26
|
+
| testRunner | testCommand를 실행하는 주입 가능한 함수 |
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 2. 기능 요구사항
|
|
31
|
+
|
|
32
|
+
### FR-1. Dual Mode CLI (`bin/hi-loop.js`)
|
|
33
|
+
|
|
34
|
+
> 참고: `hi-loop rollback [--to N]` — N회차 직전 체크포인트로 파일 복원 (기본: 최신).
|
|
35
|
+
|
|
36
|
+
| 명령 | 동작 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `hi-loop run --goal "<요구사항>" [--test "npm test"] [--max-loops N] [--budget-usd N] [--stagnation N \| --no-stagnation] [--verify-spec] [--cwd DIR]` | 자가 치유 루프를 전경(foreground)에서 실행 |
|
|
39
|
+
| `hi-loop rollback [--to N] [--cwd DIR]` | N회차 직전 체크포인트로 파일 복원 (기본: 최신). L3 |
|
|
40
|
+
| `hi-loop mcp` | MCP 서버를 stdio 전송으로 기동 |
|
|
41
|
+
| `hi-loop` (인자 없음) | `mcp`와 동일 (에이전트가 그냥 실행해도 서버로 뜬다) |
|
|
42
|
+
| `hi-loop status [--cwd DIR]` | `.agent-state.json` 요약 출력 |
|
|
43
|
+
| `hi-loop setup` / `hi-loop-setup` | 프로젝트/클로드코드 설정 자동 주입 |
|
|
44
|
+
| `hi-loop --help`, `hi-loop --version` | 도움말/버전 |
|
|
45
|
+
|
|
46
|
+
- FR-1.1 `run`에 `--goal`이 없으면 exit code 2 + 사용법 출력.
|
|
47
|
+
- FR-1.2 알 수 없는 서브커맨드는 exit code 2.
|
|
48
|
+
- FR-1.3 루프가 성공(테스트 통과)이면 exit 0, 실패/한도 초과/예산 소진이면 exit 1.
|
|
49
|
+
- FR-1.4 인자 파서는 `--k v`, `--k=v`, `-g v` 형태를 지원한다(외부 의존성 없이 자체 구현).
|
|
50
|
+
- FR-1.5 `--budget-usd`가 0 이하이거나 숫자가 아니면 **exit code 2**. 잘못된 값이
|
|
51
|
+
조용히 "무제한"으로 해석되면 안 된다(비용 통제의 실패는 침묵하면 안 된다).
|
|
52
|
+
|
|
53
|
+
### FR-2. 자가 치유 루프 엔진 (`src/loop.js`)
|
|
54
|
+
|
|
55
|
+
핵심 API:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
runLoop({
|
|
59
|
+
goal, // string (필수)
|
|
60
|
+
testCommand = 'npm test',
|
|
61
|
+
cwd = process.cwd(),
|
|
62
|
+
maxLoops = 10, // 하드 상한 (호출 횟수)
|
|
63
|
+
budgetUsd = null, // 하드 상한 (비용). null 이면 무제한
|
|
64
|
+
handoffEvery = 4, // 이 횟수마다 세션 핸드오프
|
|
65
|
+
agentRunner, // async ({prompt, sessionId, cwd}) => {text, sessionId, costUsd}
|
|
66
|
+
testRunner, // async ({command, cwd}) => {ok, code, stdout, stderr}
|
|
67
|
+
notifier, // async (event) => void
|
|
68
|
+
logger, // (line) => void
|
|
69
|
+
statePath, // 기본 `${cwd}/.agent-state.json`
|
|
70
|
+
}) => Promise<Result>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`Result = { status: 'passed'|'failed', iterations, state }`
|
|
74
|
+
|
|
75
|
+
- FR-2.1 **PLAN**: 첫 iteration에서 spec 문서 + 테스트를 **먼저** 만들도록 에이전트에
|
|
76
|
+
지시한다. 코드 작성 금지를 프롬프트에 명시한다.
|
|
77
|
+
- FR-2.1a **산출물 경로는 엔진이 정한다**(프롬프트에 하드코딩하지 않는다).
|
|
78
|
+
기본값은 `docs/spec.md` / `tests/app.test.js`이며, **그 경로에 이미 파일이 있으면
|
|
79
|
+
goal 해시를 붙여 비켜간다**(`docs/spec-<hash8>.md` / `tests/app-<hash8>.test.js`).
|
|
80
|
+
- 왜: 하드코딩하면 (a) 그 파일이 이미 있는 프로젝트의 스펙·테스트를 덮어쓰고,
|
|
81
|
+
(b) 같은 프로젝트에서 goal만 바꿔 두 번 돌리면 1회차 산출물이 파괴된다. 롤백은 없다.
|
|
82
|
+
- 왜 해시인가: 같은 goal이면 같은 경로여야 resume이 이어지고, 다른 goal끼리는
|
|
83
|
+
충돌하지 않으며, 한국어 goal에서도 파일명이 깨지지 않는다.
|
|
84
|
+
- 경로는 **최초 1회만** 계산해 상태에 저장한다. 매번 재계산하면 PLAN이 만든 파일 때문에
|
|
85
|
+
2회차부터 경로가 밀려난다.
|
|
86
|
+
- FR-2.2 **DO**: 테스트를 통과시킬 **최소 구현**을 작성하게 한다.
|
|
87
|
+
- FR-2.3 **CHECK**: 엔진이 직접 `testCommand`를 실행하고 exit code로 판정한다.
|
|
88
|
+
에이전트의 "다 됐습니다" 자기보고는 신뢰하지 않는다.
|
|
89
|
+
- FR-2.3a **테스트 무결성 (L1 보상 해킹 방어).** CHECK는 2단이다: `check.ok && integrity.ok`여야
|
|
90
|
+
`passed`다. exit code는 "테스트가 통과했는가"만 답하고, 그 테스트가 **그대로인가**는 별도로
|
|
91
|
+
묻는다. 안 물으면 에이전트가 테스트를 지워서 exit 0을 만든다.
|
|
92
|
+
- iteration마다 감시 대상 테스트 파일(`tests/` 재귀 + 지정 `testPath`)의 **지문**을 떠서
|
|
93
|
+
직전 회차와 비교한다. git을 쓰지 않는다(비-git 프로젝트에서도 동작, 기준선이 HEAD가
|
|
94
|
+
아니라 직전 회차라 시작 시점의 더러운 워킹트리가 오탐을 안 만든다).
|
|
95
|
+
- 위반 = 케이스 수 감소 / 파일 삭제 / skip·todo·only 증가 / 항상-참 단언 증가.
|
|
96
|
+
**테스트 추가는 위반이 아니다.** 탐지 패턴은 bkit gap-detector의 가짜 완료 분류학에서.
|
|
97
|
+
- 위반 중에는 기준선을 갱신하지 않는다(약화된 상태가 새 기준이 되면 빠져나간다).
|
|
98
|
+
- 위반 사실을 에러 뒤에 붙여 다음 루프에 먹인다(차단은 대안과 함께 배송 — "되돌리고
|
|
99
|
+
구현을 고쳐라, 테스트가 틀렸으면 약화 말고 더 정확한 단언으로 교체하라").
|
|
100
|
+
- **한계**: 이것은 관측이지 강제가 아니다. 엔진은 CLI를 spawn할 뿐 에이전트의 쓰기를
|
|
101
|
+
가로챌 수 없다(design.md L8).
|
|
102
|
+
- FR-2.4 **ACT(HEAL)**: 실패 시 stdout/stderr 마지막 4000자를 잘라 에이전트에 들이밀고
|
|
103
|
+
"이 에러를 고쳐라"로 재지시. `maxLoops`(기본 10)까지 반복.
|
|
104
|
+
- FR-2.5 CHECK가 통과하면 즉시 종료하고 `status: 'passed'`.
|
|
105
|
+
- FR-2.5a **Gaps 공개 (L10).** `passed` 시 결과에 `report`(Evidence + Gaps)를 붙이고
|
|
106
|
+
로거로 출력한다. moai의 5-섹션 규약 중 핵심인 Gaps를 담는다.
|
|
107
|
+
- 왜 passed 인가: `passed` 야말로 false green 이 조용히 지나가는 지점이다. 이 엔진의
|
|
108
|
+
exit 0 은 "에이전트가 자기가 쓴 테스트를 자기가 통과시켰다"는 뜻이고, 스펙의
|
|
109
|
+
수용 기준 중 무엇이 커버 안 됐는지는 아무도 안 본다. 그 한계를 숨기지 않고 출력한다.
|
|
110
|
+
- Evidence: testCommand 종료 코드, 무결성 위반 없음, 누적 비용.
|
|
111
|
+
- Gaps: 테스트를 에이전트 자신이 썼다(외부 오라클 아님), 스펙 커버리지 미검증,
|
|
112
|
+
런타임·성능·보안은 관측 범위 밖. MCP `hiloop_run` 결과에도 붙는다.
|
|
113
|
+
- 실패 시에는 붙이지 않는다 — 실패는 이미 `stopReason` 으로 정직하다.
|
|
114
|
+
- FR-2.6 `maxLoops` 소진 시 `status: 'failed'`, `stopReason: 'maxLoops'` — 마지막 에러를 state에 남긴다.
|
|
115
|
+
- FR-2.7 goal이 비면 `TypeError`. `budgetUsd`가 0 이하/숫자 아님이면 `TypeError`.
|
|
116
|
+
- FR-2.8 **비용은 엔진이 소유한다.** 에이전트 응답의 `total_cost_usd`를 `state.costUsd`에
|
|
117
|
+
누적한다. `budgetUsd`가 설정되고 누적이 그 값 이상이면 **다음 에이전트 호출 전에**
|
|
118
|
+
`status: 'failed'`, `stopReason: 'budget'`으로 중단한다.
|
|
119
|
+
- 왜 호출 "전"인가: 이미 지출한 비용은 되돌릴 수 없다. 엔진이 할 수 있는 유일한 일은
|
|
120
|
+
더 쓰지 않는 것이다. 따라서 예산은 상한선이지 환불이 아니다.
|
|
121
|
+
- 왜 필요한가: 실측상 새 세션은 사소한 호출도 바닥값 ~$0.43(캐시 생성)이고,
|
|
122
|
+
동일 goal·동일 2회전이 $2.58과 $4.67로 1.8배 갈렸다. **`maxLoops`는 비용 상한이 아니다.**
|
|
123
|
+
- 러너가 비용을 주지 않으면 0으로 취급한다(누적이 `NaN`으로 오염되지 않는다).
|
|
124
|
+
- FR-2.12 **동시 실행 락 (L4).** 같은 cwd 에서 두 루프가 `.agent-state.json`을 서로
|
|
125
|
+
덮어쓰지 못하게 락 파일(`.agent-state.lock`)로 배타를 강제한다.
|
|
126
|
+
- 원자적 생성(`wx` 플래그): 이미 있으면 실패 — 이것이 배타의 원자성.
|
|
127
|
+
- 락에 pid 를 기록하고, 주인이 살아 있으면 거부, 죽었으면(크래시/SIGKILL) stale 로 보고
|
|
128
|
+
회수한다. 남은 락 때문에 영영 못 도는 것이 더 나쁘다. 손상된 락도 stale 로 본다.
|
|
129
|
+
- `runLoop` 은 얇은 래퍼에서 락을 잡고 `finally` 로 확실히 푼다(return 지점이 여럿).
|
|
130
|
+
- `setup` 이 `.gitignore` 에 락 파일도 추가한다(FR-6.2).
|
|
131
|
+
- FR-2.13 **타임아웃 노출 (L6).** 에이전트(30분)·테스트(10분) 타임아웃을
|
|
132
|
+
`HILOOP_AGENT_TIMEOUT_MS` / `HILOOP_TEST_TIMEOUT_MS` 로 덮는다. 0·음수·문자열
|
|
133
|
+
같은 쓰레기값은 조용히 기본값으로 되돌린다(즉시 죽는 러너 방지).
|
|
134
|
+
- FR-2.14 **지정 경로 밖 삭제 관측 (L8).** 경로 회피(FR-2.1a)는 강제가 아니다 — 엔진은
|
|
135
|
+
CLI 를 spawn 할 뿐이라 쓰기를 가로챌 수 없다. 그래서 관측한다: 루프 시작 시점의 추적
|
|
136
|
+
파일을 baseline 으로 잡고, 각 회차 후 사라진 파일을 경고한다. **차단하지 않는다** —
|
|
137
|
+
체크포인트로 되돌릴 수 있고 판단은 사람 몫. 수정은 정당할 수 있으므로 **삭제만** 본다.
|
|
138
|
+
`tests/` 삭제는 무결성 가드 소관이라 제외. 삭제 목록은 Gaps 보고에 실린다. 비-git 이면
|
|
139
|
+
관측 불가라 조용히 건너뛴다(인프라 fail-open).
|
|
140
|
+
- FR-2.11 **스펙 오라클 — 2단 판정 (L9, opt-in `--verify-spec`).** bkit 의 중심 명제
|
|
141
|
+
이식: "코드와 스펙이 어긋나면 코드가 틀렸다." exit 0 은 "에이전트가 자기 테스트를
|
|
142
|
+
통과시켰다"일 뿐 스펙을 달성했다는 뜻이 아니다.
|
|
143
|
+
- **Tier 1 (기계, 최종 권한)**: exit code + 무결성. 실패면 끝. 어떤 모델도 못 뒤집는다.
|
|
144
|
+
- **Tier 2 (모델, 하향 전용)**: Tier 1 통과일 때만 별도 검증자를 호출해 스펙 대비 구현을
|
|
145
|
+
심판한다. **오직 기각만 가능** — Tier 1 실패를 통과로 올릴 권한은 절대 없다. G1 유지.
|
|
146
|
+
- 검증자 격리: `--permission-mode plan`(쓰기 불가) + 새 세션(구현자 컨텍스트 미상속).
|
|
147
|
+
bkit 의 `disallowedTools:[Write,Edit]` + `context:fork` 를 CLI 로 구현.
|
|
148
|
+
- 구조가 아니라 **의도**를 본다: "함수가 있는가"가 아니라 "그 함수가 스펙 동작을
|
|
149
|
+
실제로 하는가". 검증자 응답이 깨지거나 실행 실패면 통과로 흘린다(인프라 fail-open).
|
|
150
|
+
- reject 사유는 다음 루프의 연료가 되고, 반복되면 정체로 잡힌다.
|
|
151
|
+
- FR-2.10 **체크포인트 & 롤백 (L3).** 각 에이전트 호출 **전에** `git stash create`로
|
|
152
|
+
워킹트리 스냅샷을 뜨고 `state.checkpoints[]`(최근 10개)에 `{iteration, sha}`를 기록한다.
|
|
153
|
+
- `git stash create`는 stash 스택에 push하지 않고 commit SHA만 반환 — **워킹트리를
|
|
154
|
+
안 건드린다**(비파괴). 호출 "전"에 찍어야 그 호출이 망친 것을 되돌릴 수 있다.
|
|
155
|
+
- git 저장소가 아니거나 변경이 없으면 `null` → 조용히 skip (fail-open, 루프 안 죽임).
|
|
156
|
+
- `hi-loop rollback [--to N]`이 그 시점 파일을 워킹트리로 복원한다. 되돌리기 전 현재
|
|
157
|
+
상태도 안전망으로 한 번 더 스냅샷한다(롤백을 되돌릴 수 있게).
|
|
158
|
+
- **자동 롤백은 없다.** 전경 실행이 기본이라 사람이 본다. 판단은 사람에게 준다.
|
|
159
|
+
- FR-2.9 **정체 감지.** 실패의 안정적 지문(`errorSignature`)이 연속 `stagnationLimit`회
|
|
160
|
+
(기본 3) 동일하면 `status: 'failed'`, `stopReason: 'stagnated'`로 조기 종료한다.
|
|
161
|
+
- 신호: 무결성 위반이면 위반 목록, 아니면 에러에서 숫자·경로·소요시간을 지운 뼈대.
|
|
162
|
+
전체 에러를 해시하면 매 실행마다 달라져 정체를 놓친다.
|
|
163
|
+
- 진전 중(매번 다른 실패)이면 카운터가 1로 리셋되어 중단하지 않는다.
|
|
164
|
+
- `stagnationLimit: null`이면 감지를 끈다(기존 무제한 동작).
|
|
165
|
+
- **정체와 예산이 겹치면 정체가 이긴다** — 정체는 ACT 처리 시점(회차 끝)에 판정하고
|
|
166
|
+
예산은 다음 회차 진입 전에 판정하므로, 정체가 더 일찍 끊어 낭비를 줄인다.
|
|
167
|
+
- 정체가 2회 이상 쌓이면 HEAL 프롬프트가 "해결 불가능한 요구인지 판단하라"는
|
|
168
|
+
강한 경고를 넣는다(규칙을 어겨 우회하지 말고 근거를 남기게).
|
|
169
|
+
|
|
170
|
+
### FR-3. 세션 핸드오프 & 컨텍스트 다이어트 (`bkit`)
|
|
171
|
+
|
|
172
|
+
- FR-3.1 루프 상태는 `.agent-state.json`에 매 phase 전이마다 **원자적으로**(tmp→rename) 저장한다.
|
|
173
|
+
- FR-3.2 `iteration % handoffEvery === 0`이면 세션을 폐기(`sessionId = null`)하고
|
|
174
|
+
`sessionSerial`을 1 올린다. 다음 호출은 새 세션에서 시작한다.
|
|
175
|
+
- FR-3.3 새 세션에 전달하는 컨텍스트는 **압축본만**:
|
|
176
|
+
goal, specSummary(≤1200자), lastError(≤2000자), 최근 history 5건 요약, 현재 iteration.
|
|
177
|
+
전체 대화 로그는 절대 넘기지 않는다.
|
|
178
|
+
- FR-3.4 `specSummary`는 PLAN 단계 에이전트 출력의 요약(≤1200자)으로 갱신한다.
|
|
179
|
+
- FR-3.5 `.agent-state.json`이 이미 존재하고 goal이 동일하면 **auto-resume**:
|
|
180
|
+
마지막 iteration 다음부터 이어서 진행한다. goal이 다르면 상태를 새로 만든다.
|
|
181
|
+
단 이전 상태가 이미 `passed`면 완료된 작업이므로 새 상태로 시작한다.
|
|
182
|
+
손상된(파싱 불가) 상태 파일도 새 상태로 취급한다.
|
|
183
|
+
|
|
184
|
+
`.agent-state.json` 스키마:
|
|
185
|
+
|
|
186
|
+
```jsonc
|
|
187
|
+
{
|
|
188
|
+
"version": 1,
|
|
189
|
+
"goal": "…",
|
|
190
|
+
"testCommand": "npm test",
|
|
191
|
+
"status": "running|passed|failed",
|
|
192
|
+
"stopReason": "budget|maxLoops|stagnated", // 종료 사유. 진행 중/통과 시에는 없다.
|
|
193
|
+
"phase": "PLAN|DO|CHECK|ACT|DONE",
|
|
194
|
+
"iteration": 3,
|
|
195
|
+
"maxLoops": 10,
|
|
196
|
+
"sessionId": "abc-123 | null",
|
|
197
|
+
"sessionSerial": 1,
|
|
198
|
+
"specPath": "docs/spec.md", // FR-2.1a. 최초 1회 결정 후 고정.
|
|
199
|
+
"testPath": "tests/app.test.js", // FR-2.1a. resume 시 재계산하지 않는다.
|
|
200
|
+
"specSummary": "…(≤1200자)",
|
|
201
|
+
"lastError": "…(≤2000자)",
|
|
202
|
+
"costUsd": 1.606411, // FR-2.8. 누적 지출.
|
|
203
|
+
"testFingerprint": { "tests/app.test.js": { "cases": 3, "skips": 0, "onlys": 0, "fakes": 0 } }, // FR-2.3a(L1)
|
|
204
|
+
"errorSig": "AssertionError expected got", // FR-2.9. 직전 실패의 안정적 지문.
|
|
205
|
+
"stagnantRuns": 2, // FR-2.9. errorSig 연속 동일 횟수.
|
|
206
|
+
"checkpoints": [{ "iteration": 1, "sha": "a1b2c3…" }], // FR-2.10(L3). 최근 10개.
|
|
207
|
+
"deletedFiles": ["src/legacy.js"], // FR-2.14(L8). 에이전트가 지운 baseline 파일(누적).
|
|
208
|
+
"specVerified": true, // FR-2.11(L9). 2단 판정 Tier 2 통과 시. 없으면 미검증.
|
|
209
|
+
"history": [{ "iteration": 1, "phase": "CHECK", "ok": false, "summary": "…" }],
|
|
210
|
+
"startedAt": "ISO8601",
|
|
211
|
+
"updatedAt": "ISO8601"
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- FR-3.6 이 필드들이 없던 시절의 상태 파일도 resume 대상이다. `costUsd`는 0으로,
|
|
216
|
+
`specPath`/`testPath`는 그때 계산해 채운다(버전은 올리지 않는다 — 필드 추가는 하위 호환).
|
|
217
|
+
|
|
218
|
+
### FR-4. MCP 서버 (`src/mcp-server.js`)
|
|
219
|
+
|
|
220
|
+
- FR-4.1 `@modelcontextprotocol/sdk`의 `McpServer` + `StdioServerTransport` 사용.
|
|
221
|
+
- FR-4.2 stdio가 프로토콜 채널이므로 **stdout에 로그 금지**. 모든 로그는 stderr.
|
|
222
|
+
- FR-4.3 노출 도구:
|
|
223
|
+
| tool | input | 동작 |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| `hiloop_run` | `goal`(필수), `testCommand?`, `maxLoops?`, `verifySpec?`, `cwd?` | 루프 실행 후 결과 텍스트 반환. `budgetUsd`·`stagnation`은 노출 안 함(CLI 전용) |
|
|
226
|
+
| `hiloop_status` | `cwd?` | 현재 `.agent-state.json` 요약 반환 |
|
|
227
|
+
| `hiloop_reset` | `cwd?` | 상태 파일 삭제 |
|
|
228
|
+
- FR-4.4 도구 오류는 예외를 던지지 않고 `isError: true` + 메시지로 반환한다.
|
|
229
|
+
- FR-4.5 SDK 미설치 시 친절한 에러 메시지 후 exit 1 (CLI 모드는 SDK 없이도 동작해야 함).
|
|
230
|
+
|
|
231
|
+
### FR-5. 텔레그램 알림 (`src/telegram.js`)
|
|
232
|
+
|
|
233
|
+
- FR-5.1 `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` 환경변수로 설정.
|
|
234
|
+
- FR-5.2 미설정 시 **조용히 no-op** (`{skipped: true}`) — 절대 루프를 깨뜨리지 않는다.
|
|
235
|
+
- FR-5.3 네트워크/API 오류도 삼킨다(`{ok:false, error}` 반환). 알림 실패로 빌드가 죽지 않는다.
|
|
236
|
+
- FR-5.4 4096자 초과 메시지는 잘라낸다.
|
|
237
|
+
- FR-5.5 이벤트 알림: 루프 시작 / 핸드오프 / 성공 / 실패.
|
|
238
|
+
|
|
239
|
+
### FR-6. Setup 스크립트 (`bin/setup.js`)
|
|
240
|
+
|
|
241
|
+
- FR-6.1 대상 프로젝트에 `.mcp.json`을 생성/병합하여 `hi-loop` MCP 서버를 등록한다.
|
|
242
|
+
기존 다른 서버 항목은 보존한다.
|
|
243
|
+
- FR-6.2 `.gitignore`에 `.agent-state.json` 과 `.agent-state.lock`(L4) 항목을 없으면 추가한다.
|
|
244
|
+
- FR-6.3 `docs/`, `tests/` 디렉터리를 보장한다.
|
|
245
|
+
- FR-6.4 **멱등(idempotent)**: 몇 번 돌려도 결과가 같고 기존 설정을 파괴하지 않는다.
|
|
246
|
+
- FR-6.5 `--dry-run` 지원: 변경 없이 계획만 출력.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 3. 비기능 요구사항
|
|
251
|
+
|
|
252
|
+
- NFR-1 런타임 의존성은 `@modelcontextprotocol/sdk`(+ 그 스키마 짝인 `zod`) 뿐.
|
|
253
|
+
CLI 모드는 두 패키지 없이도 동작해야 한다(lazy import).
|
|
254
|
+
- NFR-2 테스트는 Node 내장 `node:test` + `node:assert` 만 사용 (devDependency 0개).
|
|
255
|
+
- NFR-3 모든 I/O 경계(에이전트 호출, 테스트 실행, 알림)는 주입 가능해야 하며
|
|
256
|
+
테스트는 네트워크/실제 에이전트 없이 통과해야 한다.
|
|
257
|
+
- NFR-4 상태 파일 쓰기는 원자적이어야 한다(중단 시 손상 금지).
|
|
258
|
+
- NFR-5 코드 파일당 300줄 이내, 모듈 단일 책임.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 4. 수용 기준 (테스트로 검증)
|
|
263
|
+
|
|
264
|
+
| ID | 기준 |
|
|
265
|
+
|---|---|
|
|
266
|
+
| AC-1 | goal 없이 `runLoop` 호출 시 TypeError |
|
|
267
|
+
| AC-2 | 첫 CHECK가 통과하면 iteration 1에서 `passed` 종료 |
|
|
268
|
+
| AC-3 | 3회 실패 후 4회차 통과 시 `passed`, iterations=4 |
|
|
269
|
+
| AC-4 | 계속 실패 시 `maxLoops` 횟수만큼만 에이전트 호출 후 `failed` |
|
|
270
|
+
| AC-5 | 실패한 CHECK의 stderr가 다음 에이전트 프롬프트에 포함된다 |
|
|
271
|
+
| AC-6 | iteration 4 이후 호출은 sessionId=null(새 세션)로 시작한다 |
|
|
272
|
+
| AC-7 | 핸드오프 프롬프트에 goal/specSummary/lastError가 들어가고 전체 로그는 없다 |
|
|
273
|
+
| AC-8 | `.agent-state.json`이 매 iteration 갱신되고 스키마를 만족한다 |
|
|
274
|
+
| AC-9 | 동일 goal로 재시작 시 이전 iteration 다음부터 resume |
|
|
275
|
+
| AC-10 | 다른 goal로 재시작 시 상태 리셋 |
|
|
276
|
+
| AC-11 | PLAN 프롬프트에 spec.md/tests 강제 문구 포함, 코드 작성 금지 명시 |
|
|
277
|
+
| AC-12 | 텔레그램 env 미설정 시 `{skipped:true}`, 루프 정상 진행 |
|
|
278
|
+
| AC-13 | 텔레그램 fetch가 throw해도 `{ok:false}` 반환하고 전파 안 함 |
|
|
279
|
+
| AC-14 | 4096자 초과 메시지 절단 |
|
|
280
|
+
| AC-15 | CLI 인자 파서가 `--k v`, `--k=v`, `-g v`를 처리 |
|
|
281
|
+
| AC-16 | `run` with no goal → exit code 2 |
|
|
282
|
+
| AC-17 | setup이 `.mcp.json`을 병합하고 기존 서버를 보존 (멱등) |
|
|
283
|
+
| AC-18 | setup이 `.gitignore`에 `.agent-state.json`을 중복 없이 추가 |
|
|
284
|
+
| AC-19 | 상태 저장이 원자적(tmp 파일 잔존 없음) |
|
|
285
|
+
| AC-20 | `hiloop_status` 도구가 상태 요약 텍스트 반환 |
|
|
286
|
+
| AC-21 | `total_cost_usd`를 파싱해 `costUsd`로 노출하고, 없거나 숫자가 아니면 0 |
|
|
287
|
+
| AC-22 | 에이전트가 청구한 비용이 iteration마다 `state.costUsd`에 누적된다 |
|
|
288
|
+
| AC-23 | 예산 초과 시 `maxLoops`를 다 쓰지 않고 중단하며 `stopReason: 'budget'` |
|
|
289
|
+
| AC-24 | 예산 미지정이면 기존 동작(무제한) 유지 |
|
|
290
|
+
| AC-25 | 잘못된 `budgetUsd`(0/음수/문자열)는 `TypeError` — 조용한 무제한 금지 |
|
|
291
|
+
| AC-26 | 비용을 주지 않는 러너와도 동작(`costUsd`가 `NaN`이 되지 않는다) |
|
|
292
|
+
| AC-27 | 기본 경로가 비어 있으면 `docs/spec.md` / `tests/app.test.js` 사용 |
|
|
293
|
+
| AC-28 | 기본 경로에 파일이 있으면 goal 해시 경로로 비켜가고 **원본을 수정하지 않는다** |
|
|
294
|
+
| AC-29 | 같은 goal이면 같은 경로, 다른 goal이면 다른 경로 (한국어 goal 포함) |
|
|
295
|
+
| AC-30 | resume은 저장된 경로를 재사용한다(PLAN 산출물 때문에 밀려나지 않는다) |
|
|
296
|
+
| AC-31 | 테스트를 지우거나 약화시켜 exit 0을 만들면 `passed`가 아니다 (L1) |
|
|
297
|
+
| AC-32 | 테스트 추가는 위반이 아니다 / 위반 사실이 다음 프롬프트에 들어간다 |
|
|
298
|
+
| AC-33 | 위반 중에는 기준선을 갱신하지 않는다 / 되돌리면 통과한다 |
|
|
299
|
+
| AC-34 | 테스트 파일이 없어도 가드가 루프를 죽이지 않는다 |
|
|
300
|
+
| AC-35 | 같은 실패가 `stagnationLimit`회 연속이면 `stopReason: 'stagnated'`로 조기 종료 (L2) |
|
|
301
|
+
| AC-36 | 실패가 매번 다르면(진전 중) 정체로 끊지 않는다 |
|
|
302
|
+
| AC-37 | `stagnationLimit: null`이면 정체 감지를 끈다 / 무결성 위반 반복도 정체로 잡는다 |
|
|
303
|
+
| AC-38 | 통과 시 결과 `report`에 Evidence·Gaps가 들어가고 자기 오라클 한계를 명시 (L10) |
|
|
304
|
+
| AC-39 | Gaps는 실제 산출물 경로를 가리킨다 / 실패 시에는 report를 붙이지 않는다 |
|
|
305
|
+
| AC-40 | 체크포인터 SHA가 iteration별로 상태에 쌓이고 최근 10개만 유지 (L3) |
|
|
306
|
+
| AC-41 | 체크포인터가 null이면(비-git 등) 조용히 skip / 루프를 죽이지 않는다 |
|
|
307
|
+
| AC-42 | 실제 git에서 롤백이 망가진 파일을 체크포인트 시점으로 복원한다 |
|
|
308
|
+
| AC-43 | verifySpec off면 Tier 2 호출 안 함 / Tier 1+Tier 2 pass면 passed (L9) |
|
|
309
|
+
| AC-44 | Tier 1 통과여도 Tier 2 reject면 passed 아님 / reject 사유가 다음 프롬프트에 |
|
|
310
|
+
| AC-45 | Tier 2는 Tier 1 실패 시 호출조차 안 됨 (하향 전용) / parseVerdict는 깨지면 pass |
|
|
311
|
+
| AC-46 | 락: 잡으면 파일 생성·풀면 삭제·release 멱등·산 pid는 거부·죽은 pid는 회수 (L4) |
|
|
312
|
+
| AC-47 | runLoop은 락을 잡고 성공·실패·throw 무관하게 finally로 푼다 (L4) |
|
|
313
|
+
| AC-48 | 타임아웃 env로 덮기 / 쓰레기값(0·음수·문자열)은 기본값으로 되돌림 (L6) |
|
|
314
|
+
| AC-49 | detectDeletions는 사라진 파일을 찾되 tests/·null입력을 제외 (L8) |
|
|
315
|
+
| AC-50 | runLoop이 삭제를 경고·상태 누적·Gaps 반영하되 통과는 유지(차단 안 함) (L8) |
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tuzi-ince/hi-loop",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "CLI와 MCP 모드를 지원하는 초경량 자율형 자가 치유 엔진",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"//name": "스코프 이름을 쓰는 이유: 무스코프 `handoff` 는 제3자의 redis 래퍼 패키지가 이미 점유 중이다(v0.1.3). 배포 시 `npm publish --access public` 필요 — 스코프 패키지의 기본값은 private 이라 이 플래그가 없으면 유료 계정 오류가 난다.",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=18"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"hi-loop": "bin/hi-loop.js",
|
|
16
|
+
"hi-loop-setup": "bin/setup.js"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/loop.js",
|
|
20
|
+
"./loop": "./src/loop.js",
|
|
21
|
+
"./telegram": "./src/telegram.js",
|
|
22
|
+
"./mcp-server": "./src/mcp-server.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"src",
|
|
27
|
+
"docs",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test \"tests/**/*.test.js\"",
|
|
32
|
+
"start": "node bin/hi-loop.js mcp"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
36
|
+
"zod": "^3.25 || ^4.0"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"mcp",
|
|
40
|
+
"cli",
|
|
41
|
+
"agent",
|
|
42
|
+
"self-healing",
|
|
43
|
+
"tdd",
|
|
44
|
+
"pdca"
|
|
45
|
+
]
|
|
46
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** 의존성 없는 최소 인자 파서 (FR-1.4) — `--k v`, `--k=v`, `-g v`, `--flag` 지원 */
|
|
2
|
+
export function parseArgs(argv, { alias = {}, boolean: booleans = [] } = {}) {
|
|
3
|
+
const out = { _: [] };
|
|
4
|
+
const isBool = (k) => booleans.includes(k);
|
|
5
|
+
const norm = (k) => alias[k] ?? k;
|
|
6
|
+
|
|
7
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
8
|
+
const token = argv[i];
|
|
9
|
+
if (token === '--') {
|
|
10
|
+
out._.push(...argv.slice(i + 1));
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
if (token.startsWith('--') || (token.startsWith('-') && token.length > 1 && !/^-\d/.test(token))) {
|
|
14
|
+
const raw = token.replace(/^--?/, '');
|
|
15
|
+
const eq = raw.indexOf('=');
|
|
16
|
+
if (eq !== -1) {
|
|
17
|
+
out[norm(raw.slice(0, eq))] = raw.slice(eq + 1);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const key = norm(raw);
|
|
21
|
+
const next = argv[i + 1];
|
|
22
|
+
if (isBool(key) || next === undefined || next.startsWith('--')) {
|
|
23
|
+
out[key] = true;
|
|
24
|
+
} else {
|
|
25
|
+
out[key] = next;
|
|
26
|
+
i += 1;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
out._.push(token);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 체크포인트 & 롤백 (L3) — 에이전트가 코드를 망가뜨려도 되돌린다.
|
|
3
|
+
*
|
|
4
|
+
* 이 엔진은 에이전트가 파일을 쓰는 것을 전제로 한다(그게 자가 치유다). 그런데 에이전트가
|
|
5
|
+
* 멀쩡한 코드를 망가뜨리면 그대로 남았다 — 롤백이 없었다.
|
|
6
|
+
*
|
|
7
|
+
* 설계:
|
|
8
|
+
* - `git stash create` 로 스냅샷을 뜬다. 이것은 stash 스택에 push 하지 않고 commit 객체
|
|
9
|
+
* SHA 만 반환한다 — **워킹트리를 전혀 건드리지 않는다**(비파괴). 각 에이전트 호출 "전에"
|
|
10
|
+
* 찍어야 그 호출이 망친 것을 되돌릴 수 있다.
|
|
11
|
+
* - git 저장소가 아니면 조용히 skip 한다(fail-open). 롤백이 없다고 루프를 죽이면 안 된다 —
|
|
12
|
+
* 비-git 프로젝트에서도 hi-loop 는 돌아야 한다.
|
|
13
|
+
* - **자동 롤백은 없다.** bkit 도 자동 되돌리기를 L4(아무도 안 보는 상황)에만 건다. hi-loop 는
|
|
14
|
+
* 전경 실행이 기본이라 사람이 본다. 체크포인트만 남기고 판단은 `hi-loop rollback` 으로 준다.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
function git(args, cwd) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile('git', args, { cwd, maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
|
21
|
+
resolve(err ? null : String(stdout).trim());
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** git 워킹트리 안인가? 아니면 체크포인트를 못 만든다. */
|
|
27
|
+
export async function isGitRepo(cwd) {
|
|
28
|
+
return (await git(['rev-parse', '--is-inside-work-tree'], cwd)) === 'true';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 주입 가능한 체크포인트 포트 (NFR-3). `({cwd}) => sha | null`
|
|
33
|
+
*
|
|
34
|
+
* null 을 반환하는 경우:
|
|
35
|
+
* - git 저장소가 아니다
|
|
36
|
+
* - 워킹트리에 변경이 없다(`git stash create` 가 빈 문자열 반환) — 찍을 게 없다
|
|
37
|
+
*/
|
|
38
|
+
export function makeCheckpointer() {
|
|
39
|
+
return async ({ cwd }) => {
|
|
40
|
+
if (!(await isGitRepo(cwd))) return null;
|
|
41
|
+
// -u: 추적 안 되는 파일(에이전트가 새로 만든 것)도 스냅샷에 포함.
|
|
42
|
+
const sha = await git(['stash', 'create', '--include-untracked'], cwd);
|
|
43
|
+
return sha && /^[0-9a-f]{7,40}$/.test(sha) ? sha : null;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 지정 경로 밖 수정의 사후 검출 (L8) — 삭제된 파일 관측.
|
|
49
|
+
*
|
|
50
|
+
* 경로 회피(§4.7)는 spec/test 두 파일만 지킨다. 엔진은 CLI 를 spawn 할 뿐이라 에이전트의
|
|
51
|
+
* 쓰기를 가로챌 수 없으므로, "지정 경로 밖을 건드리지 마라"는 여전히 프롬프트 규칙이다.
|
|
52
|
+
* 그래서 강제 대신 **관측**한다 — L1 무결성 가드와 같은 계열의 사후 검출이다.
|
|
53
|
+
*
|
|
54
|
+
* 왜 삭제만 보는가: 구현이 기존 파일을 **수정**하는 것은 정당하다(기존 index.js 에 함수
|
|
55
|
+
* 추가 등). 하지만 baseline 에 있던 추적 파일을 **삭제**하는 것은 거의 항상 나쁘고,
|
|
56
|
+
* 오탐이 적다. tests/ 삭제는 무결성 가드가 이미 잡으므로 여기선 그 밖을 본다.
|
|
57
|
+
* 차단하지 않고 경고만 한다 — 체크포인트(L3)로 되돌릴 수 있고, 판단은 사람 몫이다.
|
|
58
|
+
*
|
|
59
|
+
* 주입 가능한 포트 (NFR-3). `({cwd}) => string[]` (추적 파일 상대경로 목록)
|
|
60
|
+
*/
|
|
61
|
+
export function makeFileLister() {
|
|
62
|
+
return async ({ cwd }) => {
|
|
63
|
+
if (!(await isGitRepo(cwd))) return null; // 비-git 이면 관측 불가 — null 로 신호
|
|
64
|
+
const out = await git(['ls-files'], cwd);
|
|
65
|
+
return out === null ? null : out.split('\n').filter(Boolean);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** baseline 목록에 있었는데 지금 없어진 파일 = 삭제됨. tests/ 는 무결성 가드 소관이라 뺀다. */
|
|
70
|
+
export function detectDeletions(baseline, current) {
|
|
71
|
+
if (!Array.isArray(baseline) || !Array.isArray(current)) return [];
|
|
72
|
+
const now = new Set(current);
|
|
73
|
+
return baseline.filter((f) => !now.has(f) && !f.startsWith('tests/'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 체크포인트 SHA 시점의 파일들을 워킹트리에 복원한다.
|
|
78
|
+
*
|
|
79
|
+
* `git stash create` 가 만든 commit 의 트리는 "그 시점의 워킹트리"다. 그 트리를 지금
|
|
80
|
+
* 워킹트리로 꺼낸다. 되돌리기 전에 **현재 상태도 안전망으로 한 번 더 스냅샷**을 떠서
|
|
81
|
+
* 반환한다 — 롤백 자체를 잘못 눌렀을 때 다시 앞으로 갈 수 있게.
|
|
82
|
+
*/
|
|
83
|
+
export async function rollbackTo({ cwd, sha }) {
|
|
84
|
+
if (!(await isGitRepo(cwd))) return { ok: false, reason: 'git 저장소가 아닙니다.' };
|
|
85
|
+
if (!/^[0-9a-f]{7,40}$/.test(String(sha ?? ''))) return { ok: false, reason: `잘못된 체크포인트: ${sha}` };
|
|
86
|
+
|
|
87
|
+
// 되돌리기 전 안전망: 지금 상태를 스냅샷으로 남긴다(롤백을 되돌릴 수 있게).
|
|
88
|
+
const safety = await git(['stash', 'create', '--include-untracked'], cwd);
|
|
89
|
+
|
|
90
|
+
// 그 시점 트리를 워킹트리로 복원. checkout <sha> -- . 는 커밋 트리의 파일들을 꺼낸다.
|
|
91
|
+
const done = await git(['checkout', sha, '--', '.'], cwd);
|
|
92
|
+
if (done === null) return { ok: false, reason: 'checkout 실패 — SHA 가 유효하지 않거나 접근 불가.' };
|
|
93
|
+
return { ok: true, safetySha: safety || null };
|
|
94
|
+
}
|
package/src/integrity.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 테스트 무결성 가드 (L1) — 보상 해킹 방어
|
|
3
|
+
*
|
|
4
|
+
* 문제: 이 엔진의 판정은 `testCommand` 의 exit code 하나뿐이다(G1). 그런데 그 테스트를
|
|
5
|
+
* 쓰는 것도, 고치는 것도 같은 에이전트다. 테스트를 지우거나 skip 하면 exit 0 이 나온다.
|
|
6
|
+
* 즉 **자기보고를 안 믿겠다는 엔진이, 정작 "에이전트가 테스트를 안 건드릴 것"은 믿고 있었다.**
|
|
7
|
+
* 지금까지의 방어는 프롬프트 한 줄이었다("테스트를 무력화하지 마라"). 산문은 요청이지
|
|
8
|
+
* 메커니즘이 아니다 — 그리고 실측에서 에이전트가 프롬프트를 어길 수 있음이 확인됐다.
|
|
9
|
+
*
|
|
10
|
+
* 설계: git 을 쓰지 않는다. iteration 마다 테스트 파일의 **지문**을 떠서 직전 회차와 비교한다.
|
|
11
|
+
* - git 저장소가 아니어도 동작한다 (fail-open 예외 처리가 통째로 필요 없다).
|
|
12
|
+
* - 기준선이 HEAD 가 아니라 **직전 iteration** 이라, 실행 시작 시점의 더러운 워킹트리가
|
|
13
|
+
* 오탐을 만들지 않는다. 재는 것은 정확히 "이 루프가 테스트를 약화시켰는가"다.
|
|
14
|
+
*
|
|
15
|
+
* 탐지 패턴은 bkit 의 가짜 완료 분류학(agents/gap-detector.md:274-297)에서 가져왔다 —
|
|
16
|
+
* LLM 이 완료를 위장할 때 남기는 자국의 목록이다.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { join, relative } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/** 테스트 케이스 선언 — 줄어들면 위반 */
|
|
22
|
+
const CASE_RE = /\b(?:test|it)\s*\(/g;
|
|
23
|
+
/** 무력화 마커 — 늘어나면 위반 */
|
|
24
|
+
const SKIP_RE = /\b(?:test|it|describe|suite)\s*\.\s*(?:skip|todo)\s*\(|\bxit\s*\(|\bxdescribe\s*\(/g;
|
|
25
|
+
/** only 는 나머지를 통째로 건너뛰게 만든다 — 늘어나면 위반 */
|
|
26
|
+
const ONLY_RE = /\b(?:test|it|describe|suite)\s*\.\s*only\s*\(/g;
|
|
27
|
+
/** 항상 참인 단언 — 늘어나면 위반 */
|
|
28
|
+
const FAKE_RE =
|
|
29
|
+
/assert\s*\.\s*ok\s*\(\s*true\s*\)|assert\s*\(\s*true\s*\)|expect\s*\(\s*true\s*\)\s*\.\s*to(?:Be|Equal)\s*\(\s*true\s*\)|assert\s*\.\s*(?:equal|strictEqual)\s*\(\s*(\d+)\s*,\s*\1\s*\)/g;
|
|
30
|
+
|
|
31
|
+
const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
32
|
+
|
|
33
|
+
function count(re, text) {
|
|
34
|
+
return (text.match(re) ?? []).length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 디렉터리를 얕게 재귀한다. node_modules 등은 들어가지 않는다. */
|
|
38
|
+
function walk(dir, out = [], depth = 0) {
|
|
39
|
+
if (depth > 6 || !existsSync(dir)) return out;
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = readdirSync(dir);
|
|
43
|
+
} catch {
|
|
44
|
+
return out; // 권한 없는 디렉터리는 그냥 건너뛴다 — 가드가 루프를 죽이면 안 된다.
|
|
45
|
+
}
|
|
46
|
+
for (const name of entries) {
|
|
47
|
+
if (name === 'node_modules' || name.startsWith('.')) continue;
|
|
48
|
+
const full = join(dir, name);
|
|
49
|
+
try {
|
|
50
|
+
if (statSync(full).isDirectory()) walk(full, out, depth + 1);
|
|
51
|
+
else if (TEST_FILE_RE.test(name)) out.push(full);
|
|
52
|
+
} catch {
|
|
53
|
+
/* 읽는 사이에 사라졌으면 무시 */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 감시 대상: 엔진이 지정한 testPath + `tests/` 아래의 테스트 파일들 */
|
|
60
|
+
export function collectTestFiles({ cwd, testPath }) {
|
|
61
|
+
const found = new Set(walk(join(cwd, 'tests')));
|
|
62
|
+
if (testPath && existsSync(join(cwd, testPath))) found.add(join(cwd, testPath));
|
|
63
|
+
return [...found].map((f) => relative(cwd, f)).sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 파일 하나의 지문. 상태에 저장되므로 작아야 한다. */
|
|
67
|
+
export function fingerprintText(text) {
|
|
68
|
+
return {
|
|
69
|
+
cases: count(CASE_RE, text),
|
|
70
|
+
skips: count(SKIP_RE, text),
|
|
71
|
+
onlys: count(ONLY_RE, text),
|
|
72
|
+
fakes: count(FAKE_RE, text),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 주입 가능한 포트 (NFR-3). `({cwd, testPath}) => {상대경로: 지문}` */
|
|
77
|
+
export function makeIntegrityChecker() {
|
|
78
|
+
return ({ cwd, testPath }) => {
|
|
79
|
+
const fp = {};
|
|
80
|
+
for (const rel of collectTestFiles({ cwd, testPath })) {
|
|
81
|
+
try {
|
|
82
|
+
fp[rel] = fingerprintText(readFileSync(join(cwd, rel), 'utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
/* 읽기 실패는 지문 없음으로 — 아래 compare 가 '삭제'로 잡는다 */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return fp;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 직전 지문과 비교해 약화를 찾는다.
|
|
93
|
+
*
|
|
94
|
+
* 늘어나는 것(테스트 추가)은 위반이 아니다. **줄어들거나 무력화되는 것만** 위반이다.
|
|
95
|
+
* 기준선이 비어 있으면(=PLAN 이 테스트를 만들기 전) 위반이 있을 수 없다.
|
|
96
|
+
*/
|
|
97
|
+
export function compareFingerprints(before, after) {
|
|
98
|
+
const violations = [];
|
|
99
|
+
for (const [file, was] of Object.entries(before ?? {})) {
|
|
100
|
+
const now = after?.[file];
|
|
101
|
+
if (!now) {
|
|
102
|
+
violations.push(`${file}: 테스트 파일이 사라졌다`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (now.cases < was.cases) {
|
|
106
|
+
violations.push(`${file}: 테스트 케이스가 ${was.cases}개 → ${now.cases}개로 줄었다`);
|
|
107
|
+
}
|
|
108
|
+
if (now.skips > was.skips) {
|
|
109
|
+
violations.push(`${file}: skip/todo 가 ${was.skips} → ${now.skips}로 늘었다`);
|
|
110
|
+
}
|
|
111
|
+
if (now.onlys > was.onlys) {
|
|
112
|
+
violations.push(`${file}: only 가 ${was.onlys} → ${now.onlys}로 늘었다 (나머지 테스트가 건너뛰어진다)`);
|
|
113
|
+
}
|
|
114
|
+
if (now.fakes > was.fakes) {
|
|
115
|
+
violations.push(`${file}: 항상 참인 단언이 ${was.fakes} → ${now.fakes}로 늘었다`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { ok: violations.length === 0, violations };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 위반을 에이전트에게 들이밀 텍스트로. 차단은 대안과 함께 배송한다(bkit). */
|
|
122
|
+
export function violationMessage(violations) {
|
|
123
|
+
return [
|
|
124
|
+
'[hi-loop] 테스트 무결성 위반 — 통과로 인정하지 않는다.',
|
|
125
|
+
...violations.map((v) => `- ${v}`),
|
|
126
|
+
'',
|
|
127
|
+
'테스트를 약화시켜 통과시키는 것은 실패다. 약화된 테스트를 원래대로 되돌리고,',
|
|
128
|
+
'구현을 고쳐서 통과시켜라. 테스트가 정말 틀렸다고 판단되면 약화시키지 말고',
|
|
129
|
+
'왜 틀렸는지 근거를 남긴 뒤 더 정확한 단언으로 **교체**하라(케이스 수를 줄이지 마라).',
|
|
130
|
+
].join('\n');
|
|
131
|
+
}
|
package/src/is-main.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "이 모듈이 직접 실행됐는가?" 판정.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ 순진한 `resolve(process.argv[1]) === fileURLToPath(import.meta.url)` 비교는
|
|
8
|
+
* 경로에 심볼릭 링크가 끼면 조용히 false 가 된다. Node 는 import.meta.url 을
|
|
9
|
+
* realpath 로 해석하지만 process.argv[1] 은 사용자가 친 경로 그대로이기 때문이다.
|
|
10
|
+
*
|
|
11
|
+
* 이게 터지는 실제 경로:
|
|
12
|
+
* - npm 전역 설치/`npm link` 의 bin 은 **항상 심링크**다
|
|
13
|
+
* (/usr/local/bin/hi-loop -> ../lib/node_modules/@tuzi-ince/hi-loop/bin/hi-loop.js)
|
|
14
|
+
* - macOS 의 /tmp -> /private/tmp
|
|
15
|
+
*
|
|
16
|
+
* 그 결과 CLI 가 main() 을 호출하지 않고 **아무 일도 없이 exit 0** 으로 끝난다.
|
|
17
|
+
* exit 0 은 CI 에서 "성공" 으로 읽히므로, 사용자는 루프가 돌았다고 믿게 된다.
|
|
18
|
+
* 그래서 양쪽 모두 realpath 로 정규화해 비교한다.
|
|
19
|
+
*/
|
|
20
|
+
export function isMain(metaUrl) {
|
|
21
|
+
const entry = process.argv[1];
|
|
22
|
+
if (!entry) return false;
|
|
23
|
+
try {
|
|
24
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(metaUrl));
|
|
25
|
+
} catch {
|
|
26
|
+
return false; // 경로가 사라진 경우 등 — 실행 진입점으로 보지 않는다.
|
|
27
|
+
}
|
|
28
|
+
}
|