@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/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# hi-loop
|
|
2
|
+
|
|
3
|
+
**CLI와 MCP 모드를 지원하는 초경량 자율형 자가 치유 엔진.**
|
|
4
|
+
|
|
5
|
+
날것의 아이디어(goal)를 던지면, AI 에이전트를 PDCA 루프로 반복 구동해
|
|
6
|
+
**테스트가 실제로 통과할 때까지** 스스로 고쳐 나간다.
|
|
7
|
+
- 요구사항(무엇을): [`docs/spec.md`](docs/spec.md)
|
|
8
|
+
- 설계(어떻게·왜): [`docs/design.md`](docs/design.md)
|
|
9
|
+
- 배포·설치·사용: [`docs/guide.md`](docs/guide.md)
|
|
10
|
+
|
|
11
|
+
> 현재 상태: 실제 `claude`(2.1.212) 와의 통합 **검증 완료** — 편집 권한, 세션 재개,
|
|
12
|
+
> 전체 루프, 예산 상한을 실측했다([`docs/design.md`](docs/design.md) §10).
|
|
13
|
+
> 아직 안 본 것도 그 표에 적어뒀다(핸드오프 실측, MCP 모드 실제 spawn, 10회 장시간 루프).
|
|
14
|
+
|
|
15
|
+
## 설치
|
|
16
|
+
|
|
17
|
+
> ⚠️ `npm install -g handoff` 를 하지 마라. npm 의 `handoff` 는 이 프로젝트와 무관한
|
|
18
|
+
> **제3자의 redis 래퍼 패키지**다. 이름만 같다. 아직 미배포이므로 소스에서 설치한다.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git clone <이 저장소> hi-loop && cd hi-loop
|
|
22
|
+
npm install && npm test # 104개 통과
|
|
23
|
+
npm link # hi-loop / hi-loop-setup 명령 등록
|
|
24
|
+
|
|
25
|
+
cd /path/to/my-project
|
|
26
|
+
hi-loop-setup # .mcp.json / .gitignore / docs / tests 자동 주입 (멱등)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
자세한 절차·문제해결·배포는 [`docs/guide.md`](docs/guide.md).
|
|
30
|
+
|
|
31
|
+
## 두 가지 실행 모드
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 1) CLI 직접 실행 — 터미널, 백그라운드 데몬, 텔레그램 봇에서 구동
|
|
35
|
+
hi-loop run --goal "JWT 인증 미들웨어를 만들어라" --test "npm test" --max-loops 10 --budget-usd 5
|
|
36
|
+
|
|
37
|
+
# 2) MCP 서버 — 클로드코드/커서가 도구로 로드 (인자 없으면 기본 동작)
|
|
38
|
+
hi-loop mcp
|
|
39
|
+
|
|
40
|
+
hi-loop status # 현재 루프 상태 요약
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
MCP 도구: `hiloop_run`, `hiloop_status`, `hiloop_reset`.
|
|
44
|
+
|
|
45
|
+
## 동작 원리
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
PLAN ──► spec.md + tests/app.test.js 를 먼저 강제 (구현 코드 금지)
|
|
49
|
+
DO ──► 테스트를 통과시킬 최소 구현
|
|
50
|
+
CHECK ──► 엔진이 testCommand 를 직접 실행 (에이전트의 "다 됐어요"는 안 믿는다)
|
|
51
|
+
ACT ──► stderr 를 그대로 에이전트에 들이밀고 "이 에러를 고쳐라" (최대 10회)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 세션 핸드오프 & 컨텍스트 다이어트 (bkit 철학)
|
|
55
|
+
|
|
56
|
+
에이전트는 같은 세션에서 오래 굴릴수록 토큰이 쌓여 멍청해진다.
|
|
57
|
+
그래서 4회마다 세션을 **버리고**, `.agent-state.json`에 압축된 상태
|
|
58
|
+
(goal / 스펙 요약 / 마지막 에러 / 최근 이력 5건)만 새 세션에 넘겨 이어서 작업한다(auto-resume).
|
|
59
|
+
같은 goal로 다시 실행하면 중단 지점부터 재개한다.
|
|
60
|
+
|
|
61
|
+
### TDD 자가 치유 (MoAI 철학)
|
|
62
|
+
|
|
63
|
+
성패 판정은 항상 `testCommand`의 exit code다. 에이전트의 자기보고는 판정에 쓰지 않는다.
|
|
64
|
+
|
|
65
|
+
### 💸 비용은 엔진이 센다 — 그리고 싸지 않다
|
|
66
|
+
|
|
67
|
+
**실측**: "두 수를 더하는 add 함수를 만들어라"가 2회차에 통과하는 데 **$2.58~$4.67**
|
|
68
|
+
(claude-opus-4-8). 같은 goal·같은 회차인데 컨텍스트 크기 때문에 1.8배 갈렸다.
|
|
69
|
+
새 세션은 일을 안 시켜도 바닥값이 ~$0.43이다(캐시 생성).
|
|
70
|
+
|
|
71
|
+
**`--max-loops`는 비용 상한이 아니다.** 실패 루프가 10회를 다 쓰면 $20을 넘길 수 있다.
|
|
72
|
+
그래서 `--budget-usd`를 쓴다 — 누적이 상한에 닿으면 **다음 에이전트 호출 전에** 멈춘다
|
|
73
|
+
(이미 쓴 돈은 못 돌리니 할 수 있는 건 더 안 쓰는 것뿐이다).
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
hi-loop run --goal "..." --max-loops 5 --budget-usd 1 --no-stagnation
|
|
77
|
+
# [hi-loop] 💸 예산 $1 소진 (누적 $1.61) — 2회차에서 중단합니다.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`hi-loop status`와 완료 로그에 누적 비용이 찍힌다. 모델은 사용자 기본값을 상속하므로
|
|
81
|
+
`HILOOP_AGENT_ARGS="--model sonnet"`으로 낮출 수 있다.
|
|
82
|
+
|
|
83
|
+
### 기존 파일을 덮어쓰지 않는다
|
|
84
|
+
|
|
85
|
+
PLAN 산출물 경로는 **엔진이 정한다.** `docs/spec.md`/`tests/app.test.js`가 비어 있으면
|
|
86
|
+
그대로 쓰고, 이미 뭔가 있으면 goal 해시로 비켜간다(`docs/spec-5d88cc7f.md`).
|
|
87
|
+
같은 프로젝트에서 goal만 바꿔 두 번 돌려도 앞의 산출물이 살아남는다.
|
|
88
|
+
|
|
89
|
+
### 테스트를 지워서 통과하는 것을 막는다 (bkit 철학)
|
|
90
|
+
|
|
91
|
+
CHECK는 2단이다 — `check.ok && integrity.ok`여야 통과. 판정 기준인 테스트를 에이전트가
|
|
92
|
+
지우거나 skip/always-true로 무력화하면 exit 0이 나와도 **통과로 인정하지 않는다.**
|
|
93
|
+
탐지 패턴(케이스 감소, `.skip`, `.only`, `assert.ok(true)`)은 bkit gap-detector의
|
|
94
|
+
가짜 완료 분류학에서 가져왔다. 테스트 **추가**는 위반이 아니다.
|
|
95
|
+
|
|
96
|
+
### 같은 벽에 열 번 부딪히지 않는다
|
|
97
|
+
|
|
98
|
+
같은 실패가 3회 연속이면 `stopReason: 'stagnated'`로 조기 종료한다 — "이 접근으론 안 된다"를
|
|
99
|
+
`--max-loops` 소진 전에 잡는다. `--stagnation N` 으로 조절, `--no-stagnation` 으로 끈다.
|
|
100
|
+
|
|
101
|
+
### 통과가 무엇을 확인 안 했는지 공개한다 (MoAI 철학)
|
|
102
|
+
|
|
103
|
+
이 엔진의 `passed`는 조용한 거짓말이 될 수 있다 — 테스트를 PLAN 단계에서 **에이전트 자신이**
|
|
104
|
+
썼기 때문이다. 그래서 통과할 때마다 Evidence와 Gaps를 함께 출력한다:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
관측하지 않은 것(Gaps):
|
|
108
|
+
- 이 테스트는 에이전트 자신이 작성했다. 외부 오라클이 아니다.
|
|
109
|
+
- 스펙의 어떤 수용 기준이 커버 안 됐는지는 검증하지 않았다.
|
|
110
|
+
- 런타임·성능·보안은 관측 범위 밖이다.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
moai의 5-섹션 보고 규약에서 왔다. false green을 disclosed green으로 바꾼다.
|
|
114
|
+
|
|
115
|
+
### 망가뜨려도 되돌린다 (bkit 철학)
|
|
116
|
+
|
|
117
|
+
각 에이전트 호출 **전에** `git stash create`로 워킹트리를 스냅샷한다(비파괴 — 워킹트리를
|
|
118
|
+
안 건드리고 SHA만 남긴다). 에이전트가 멀쩡한 코드를 망가뜨리면:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
hi-loop rollback # 최신 체크포인트로 파일 복원
|
|
122
|
+
hi-loop rollback --to 3 # 3회차 직전 상태로
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**자동 롤백은 없다** — 전경 실행이 기본이라 사람이 본다(bkit도 자동 되돌리기를 무인 L4에만
|
|
126
|
+
건다). git 저장소가 아니면 조용히 생략한다.
|
|
127
|
+
|
|
128
|
+
### 스펙을 오라클로 — 2단 판정 (bkit 중심 명제)
|
|
129
|
+
|
|
130
|
+
`--verify-spec`을 켜면 테스트 통과 후 **별도 검증자**가 스펙 대비 구현을 심판한다.
|
|
131
|
+
이 엔진의 exit 0은 "에이전트가 자기가 쓴 테스트를 통과시켰다"일 뿐이므로:
|
|
132
|
+
|
|
133
|
+
- **Tier 1 (기계)**: exit code + 무결성. 최종 권한. 실패면 끝.
|
|
134
|
+
- **Tier 2 (모델)**: 스펙 대조. **오직 기각만 가능** — 통과를 되돌릴 순 있어도
|
|
135
|
+
Tier 1 실패를 통과로 못 올린다. 쓰기 권한 없이(`--permission-mode plan`) 새 세션으로 호출.
|
|
136
|
+
|
|
137
|
+
**실측**: 스펙이 "음수는 TypeError"를 요구하는데 테스트는 양수만 검사하고 구현이
|
|
138
|
+
`(a,b)=>a+b`인 경우 — 테스트는 초록이었지만 검증자가 *"add(-1,2)는 예외 대신 1을
|
|
139
|
+
반환한다"*고 정확히 기각했다. 구조가 아니라 의도를 본다. opt-in(비용 증가).
|
|
140
|
+
|
|
141
|
+
## 환경변수
|
|
142
|
+
|
|
143
|
+
| 변수 | 설명 |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `HILOOP_AGENT_CMD` | 에이전트 실행 명령 (기본 `claude`) |
|
|
146
|
+
| `HILOOP_AGENT_ARGS` | 에이전트에 덧붙일 인자 (공백 구분). 예: `--model sonnet` — 비용에 직결된다 |
|
|
147
|
+
| `HILOOP_PERMISSION_MODE` | 권한 모드 (기본 `acceptEdits`). 편집 허용이 이 엔진의 전제다 |
|
|
148
|
+
| `HILOOP_VERIFY_CMD` / `HILOOP_VERIFY_MODEL` | `--verify-spec` 검증자의 실행 명령·모델 (미설정 시 구현자와 동일). bkit처럼 검증에 더 센 모델을 쓸 수 있다 |
|
|
149
|
+
| `HILOOP_AGENT_TIMEOUT_MS` / `HILOOP_TEST_TIMEOUT_MS` | 에이전트(30분)·테스트(10분) 타임아웃. 쓰레기값은 기본값으로 되돌림 |
|
|
150
|
+
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | 시작/핸드오프/성공/실패 알림. 미설정 시 조용히 생략 |
|
|
151
|
+
|
|
152
|
+
## 개발
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm test # node:test, devDependency 0개
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## 구조
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
bin/hi-loop.js CLI 엔트리포인트 (CLI / MCP 분기 + rollback)
|
|
162
|
+
bin/setup.js 프로젝트·클로드코드 설정 자동 주입
|
|
163
|
+
src/loop.js 자가 치유 오케스트레이션 루프 + 예산·정체·2단 판정·락·삭제 관측 집행
|
|
164
|
+
src/state.js .agent-state.json (원자적 저장 + 다이어트 + 산출물 경로 + resume + Gaps)
|
|
165
|
+
src/runners.js 에이전트/테스트 실행 어댑터 (주입 가능) + 비용 파싱 + 타임아웃 (L6)
|
|
166
|
+
src/prompts.js PDCA 단계별 프롬프트 + 검증자 프롬프트
|
|
167
|
+
src/integrity.js 테스트 무결성 지문·비교 — 보상 해킹 방어 (L1)
|
|
168
|
+
src/checkpoint.js git stash 체크포인트·롤백 (L3) + 삭제 관측 (L8)
|
|
169
|
+
src/verify.js 스펙 오라클 — Tier 2 검증자 (L9)
|
|
170
|
+
src/lock.js 동시 실행 pid 락 (L4)
|
|
171
|
+
src/mcp-server.js MCP 프로토콜 통신
|
|
172
|
+
src/telegram.js 텔레그램 비동기 알림
|
|
173
|
+
src/args.js 의존성 없는 인자 파서
|
|
174
|
+
src/is-main.js "직접 실행인가?" 판정 (심링크 안전)
|
|
175
|
+
```
|
package/bin/hi-loop.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI 엔트리포인트 (FR-1) — CLI Command 와 MCP Server 분기 처리
|
|
4
|
+
*
|
|
5
|
+
* hi-loop run --goal "..." [--test "npm test"] [--max-loops 10] [--cwd .]
|
|
6
|
+
* hi-loop mcp (인자 없으면 기본값)
|
|
7
|
+
* hi-loop status
|
|
8
|
+
* hi-loop setup
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { resolve, dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { parseArgs } from '../src/args.js';
|
|
14
|
+
import { isMain } from '../src/is-main.js';
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
function pkgVersion() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version;
|
|
21
|
+
} catch {
|
|
22
|
+
return '0.0.0';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const USAGE = `hi-loop — 초경량 자율형 자가 치유 엔진
|
|
27
|
+
|
|
28
|
+
사용법:
|
|
29
|
+
hi-loop run --goal "<요구사항>" [--test "npm test"] [--max-loops 10] [--budget-usd 5]
|
|
30
|
+
[--stagnation 3 | --no-stagnation] [--verify-spec] [--cwd .]
|
|
31
|
+
hi-loop mcp MCP 서버(stdio)로 기동. 인자가 없으면 기본 동작.
|
|
32
|
+
hi-loop status [--cwd .] 현재 루프 상태 요약
|
|
33
|
+
hi-loop rollback [--to N] [--cwd .] N회차 직전 체크포인트로 파일 복원 (기본: 최신)
|
|
34
|
+
hi-loop setup [--dry-run] 프로젝트/클로드코드 설정 자동 주입
|
|
35
|
+
hi-loop --help | --version
|
|
36
|
+
|
|
37
|
+
환경변수:
|
|
38
|
+
HILOOP_AGENT_CMD 에이전트 실행 명령 (기본: claude)
|
|
39
|
+
HILOOP_AGENT_ARGS 에이전트에 덧붙일 인자 (공백 구분, 예: --model sonnet)
|
|
40
|
+
HILOOP_PERMISSION_MODE 권한 모드 (기본: acceptEdits)
|
|
41
|
+
HILOOP_VERIFY_CMD/MODEL --verify-spec 검증자의 실행 명령·모델 (기본: 구현자와 동일)
|
|
42
|
+
HILOOP_AGENT_TIMEOUT_MS 에이전트 타임아웃 (기본: 1800000 = 30분)
|
|
43
|
+
HILOOP_TEST_TIMEOUT_MS 테스트 타임아웃 (기본: 600000 = 10분)
|
|
44
|
+
TELEGRAM_BOT_TOKEN/CHAT_ID 텔레그램 알림 (미설정 시 생략)`;
|
|
45
|
+
|
|
46
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
47
|
+
const args = parseArgs(argv, {
|
|
48
|
+
alias: { g: 'goal', t: 'test', c: 'cwd', h: 'help', v: 'version', m: 'max-loops' },
|
|
49
|
+
boolean: ['help', 'version', 'dry-run', 'no-stagnation', 'verify-spec'],
|
|
50
|
+
});
|
|
51
|
+
const command = args._[0] ?? (args.help || args.version ? null : 'mcp');
|
|
52
|
+
|
|
53
|
+
if (args.version) {
|
|
54
|
+
process.stdout.write(`${pkgVersion()}\n`);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (args.help || command === 'help') {
|
|
58
|
+
process.stdout.write(`${USAGE}\n`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cwd = resolve(args.cwd ?? process.cwd());
|
|
63
|
+
|
|
64
|
+
switch (command) {
|
|
65
|
+
case 'run': {
|
|
66
|
+
if (!args.goal || args.goal === true) {
|
|
67
|
+
process.stderr.write(`--goal 이 필요합니다.\n\n${USAGE}\n`);
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
const { runLoop } = await import('../src/loop.js');
|
|
71
|
+
const maxLoops = Number(args['max-loops'] ?? 10);
|
|
72
|
+
// 예산은 명시할 때만 건다. 잘못된 값으로 조용히 무제한이 되지 않도록 exit 2 로 거른다.
|
|
73
|
+
const budgetRaw = args['budget-usd'];
|
|
74
|
+
let budgetUsd = null;
|
|
75
|
+
if (budgetRaw !== undefined && budgetRaw !== true) {
|
|
76
|
+
budgetUsd = Number(budgetRaw);
|
|
77
|
+
if (!(Number.isFinite(budgetUsd) && budgetUsd > 0)) {
|
|
78
|
+
process.stderr.write(`--budget-usd 는 0 보다 큰 숫자여야 합니다: ${budgetRaw}\n`);
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// 정체 상한: --no-stagnation 으로 끄거나 --stagnation N 으로 조절. 기본 3.
|
|
83
|
+
let stagnationLimit = 3;
|
|
84
|
+
if (args['no-stagnation']) stagnationLimit = null;
|
|
85
|
+
else if (args.stagnation !== undefined && args.stagnation !== true) {
|
|
86
|
+
const n = Number(args.stagnation);
|
|
87
|
+
if (!(Number.isInteger(n) && n > 0)) {
|
|
88
|
+
process.stderr.write(`--stagnation 은 1 이상의 정수여야 합니다: ${args.stagnation}\n`);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
stagnationLimit = n;
|
|
92
|
+
}
|
|
93
|
+
const result = await runLoop({
|
|
94
|
+
goal: String(args.goal),
|
|
95
|
+
testCommand: args.test && args.test !== true ? String(args.test) : 'npm test',
|
|
96
|
+
maxLoops: Number.isInteger(maxLoops) && maxLoops > 0 ? maxLoops : 10,
|
|
97
|
+
budgetUsd,
|
|
98
|
+
stagnationLimit,
|
|
99
|
+
verifySpec: Boolean(args['verify-spec']),
|
|
100
|
+
cwd,
|
|
101
|
+
logger: (line) => process.stdout.write(`${line}\n`),
|
|
102
|
+
});
|
|
103
|
+
return result.status === 'passed' ? 0 : 1;
|
|
104
|
+
}
|
|
105
|
+
case 'mcp': {
|
|
106
|
+
const { startMcpServer } = await import('../src/mcp-server.js');
|
|
107
|
+
await startMcpServer({ version: pkgVersion() });
|
|
108
|
+
return null; // 서버는 계속 살아 있는다.
|
|
109
|
+
}
|
|
110
|
+
case 'status': {
|
|
111
|
+
const { loadState, statePathFor, summarizeState } = await import('../src/state.js');
|
|
112
|
+
process.stdout.write(`${summarizeState(loadState(statePathFor(cwd)))}\n`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
case 'rollback': {
|
|
116
|
+
const { loadState, statePathFor } = await import('../src/state.js');
|
|
117
|
+
const { rollbackTo } = await import('../src/checkpoint.js');
|
|
118
|
+
const state = loadState(statePathFor(cwd));
|
|
119
|
+
const checkpoints = state?.checkpoints ?? [];
|
|
120
|
+
if (checkpoints.length === 0) {
|
|
121
|
+
process.stderr.write('되돌릴 체크포인트가 없습니다 (git 저장소가 아니거나 아직 스냅샷이 없음).\n');
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
// --to N 이면 N회차 직전 체크포인트, 없으면 가장 최근.
|
|
125
|
+
const toIter = args.to !== undefined && args.to !== true ? Number(args.to) : null;
|
|
126
|
+
const cp =
|
|
127
|
+
toIter != null
|
|
128
|
+
? checkpoints.find((c) => c.iteration === toIter)
|
|
129
|
+
: checkpoints[checkpoints.length - 1];
|
|
130
|
+
if (!cp) {
|
|
131
|
+
process.stderr.write(`${toIter}회차 체크포인트가 없습니다. 있는 회차: ${checkpoints.map((c) => c.iteration).join(', ')}\n`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const res = await rollbackTo({ cwd, sha: cp.sha });
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
process.stderr.write(`롤백 실패: ${res.reason}\n`);
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
process.stdout.write(`✅ ${cp.iteration}회차 직전 상태로 파일을 복원했습니다 (${cp.sha.slice(0, 8)}).\n`);
|
|
140
|
+
if (res.safetySha) process.stdout.write(` 되돌리기 전 상태는 ${res.safetySha.slice(0, 8)} 에 스냅샷됨.\n`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
case 'setup': {
|
|
144
|
+
const { runSetup } = await import('./setup.js');
|
|
145
|
+
const report = runSetup({ cwd, dryRun: Boolean(args['dry-run']) });
|
|
146
|
+
process.stdout.write(`${report.lines.join('\n')}\n`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
process.stderr.write(`알 수 없는 명령: ${command}\n\n${USAGE}\n`);
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (isMain(import.meta.url)) {
|
|
156
|
+
main()
|
|
157
|
+
.then((code) => {
|
|
158
|
+
if (code !== null && code !== undefined) process.exit(code);
|
|
159
|
+
})
|
|
160
|
+
.catch((err) => {
|
|
161
|
+
process.stderr.write(`[hi-loop] 치명적 오류: ${err?.stack ?? err}\n`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
});
|
|
164
|
+
}
|
package/bin/setup.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 클로드코드 및 프로젝트 설정을 자동 주입하는 스크립트 (FR-6)
|
|
4
|
+
* 멱등하며, 기존 설정을 파괴하지 않고 병합한다.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { join, resolve, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { parseArgs } from '../src/args.js';
|
|
10
|
+
import { isMain } from '../src/is-main.js';
|
|
11
|
+
|
|
12
|
+
export const MCP_SERVER_KEY = 'hi-loop';
|
|
13
|
+
export const GITIGNORE_ENTRY = '.agent-state.json';
|
|
14
|
+
// 런타임 부산물 — 커밋되면 안 된다. 락(L4)은 pid 마다 다르고, 상태는 캐시다.
|
|
15
|
+
export const GITIGNORE_ENTRIES = ['.agent-state.json', '.agent-state.lock'];
|
|
16
|
+
|
|
17
|
+
/** 이 setup 파일과 나란히 있는 실제 CLI 진입점의 절대 경로 */
|
|
18
|
+
export function cliEntryPath() {
|
|
19
|
+
return join(dirname(fileURLToPath(import.meta.url)), 'hi-loop.js');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ⚠️ `npx -y handoff mcp` 를 쓰면 안 된다. 무스코프 `handoff` 는 이 프로젝트와
|
|
24
|
+
* 무관한 제3자의 redis 래퍼 패키지이고, npx 는 그것을 받아 실행해버린다.
|
|
25
|
+
* (이 패키지의 이름은 @tuzi-ince/hi-loop 다.)
|
|
26
|
+
* 그래서 지금 실행 중인 바로 이 설치본의 절대 경로를 박아 넣는다.
|
|
27
|
+
* 배포 후에는 `npx -y @tuzi-ince/hi-loop mcp` 로 바꿀 수 있다 — 스코프를 반드시 붙일 것.
|
|
28
|
+
*/
|
|
29
|
+
export const serverEntry = (entry = cliEntryPath()) => ({
|
|
30
|
+
command: process.execPath,
|
|
31
|
+
args: [entry, 'mcp'],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function readJson(path) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `.mcp.json` 병합 — 기존 서버 항목 보존 (FR-6.1, FR-6.4) */
|
|
43
|
+
export function mergeMcpConfig(existing) {
|
|
44
|
+
const base = existing && typeof existing === 'object' ? existing : {};
|
|
45
|
+
const servers = base.mcpServers && typeof base.mcpServers === 'object' ? base.mcpServers : {};
|
|
46
|
+
return { ...base, mcpServers: { ...servers, [MCP_SERVER_KEY]: serverEntry() } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `.gitignore` 항목 추가 — 중복 없이 (FR-6.2) */
|
|
50
|
+
export function mergeGitignore(content) {
|
|
51
|
+
let text = typeof content === 'string' ? content : '';
|
|
52
|
+
const has = () => text.split('\n').map((l) => l.trim());
|
|
53
|
+
let changed = false;
|
|
54
|
+
for (const entry of GITIGNORE_ENTRIES) {
|
|
55
|
+
if (has().includes(entry)) continue;
|
|
56
|
+
const prefix = text.length && !text.endsWith('\n') ? '\n' : '';
|
|
57
|
+
text = `${text}${prefix}${entry}\n`;
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
return { changed, content: text };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function runSetup({ cwd = process.cwd(), dryRun = false } = {}) {
|
|
64
|
+
const root = resolve(cwd);
|
|
65
|
+
const lines = [];
|
|
66
|
+
const changes = [];
|
|
67
|
+
const write = (path, content) => {
|
|
68
|
+
if (!dryRun) writeFileSync(path, content, 'utf8');
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// 1) .mcp.json
|
|
72
|
+
const mcpPath = join(root, '.mcp.json');
|
|
73
|
+
const before = readJson(mcpPath);
|
|
74
|
+
const merged = mergeMcpConfig(before);
|
|
75
|
+
const mcpChanged = JSON.stringify(before) !== JSON.stringify(merged);
|
|
76
|
+
if (mcpChanged) {
|
|
77
|
+
write(mcpPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
78
|
+
changes.push('.mcp.json');
|
|
79
|
+
lines.push(`${dryRun ? '[dry-run] ' : ''}✓ .mcp.json 에 hi-loop MCP 서버를 등록했습니다.`);
|
|
80
|
+
} else {
|
|
81
|
+
lines.push('· .mcp.json 은 이미 최신입니다.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2) .gitignore
|
|
85
|
+
const giPath = join(root, '.gitignore');
|
|
86
|
+
const gi = mergeGitignore(existsSync(giPath) ? readFileSync(giPath, 'utf8') : '');
|
|
87
|
+
if (gi.changed) {
|
|
88
|
+
write(giPath, gi.content);
|
|
89
|
+
changes.push('.gitignore');
|
|
90
|
+
lines.push(`${dryRun ? '[dry-run] ' : ''}✓ .gitignore 에 ${GITIGNORE_ENTRY} 를 추가했습니다.`);
|
|
91
|
+
} else {
|
|
92
|
+
lines.push(`· .gitignore 에 ${GITIGNORE_ENTRY} 가 이미 있습니다.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3) docs/, tests/ 보장
|
|
96
|
+
for (const dir of ['docs', 'tests']) {
|
|
97
|
+
const p = join(root, dir);
|
|
98
|
+
if (!existsSync(p)) {
|
|
99
|
+
if (!dryRun) mkdirSync(p, { recursive: true });
|
|
100
|
+
changes.push(`${dir}/`);
|
|
101
|
+
lines.push(`${dryRun ? '[dry-run] ' : ''}✓ ${dir}/ 디렉터리를 만들었습니다.`);
|
|
102
|
+
} else {
|
|
103
|
+
lines.push(`· ${dir}/ 이미 존재합니다.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
lines.push('', '이제 사용하세요:', ' hi-loop run --goal "요구사항" --test "npm test"', ' 또는 에이전트에서 MCP 도구 hiloop_run 호출');
|
|
108
|
+
return { changes, lines, dryRun };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isMain(import.meta.url)) {
|
|
112
|
+
const args = parseArgs(process.argv.slice(2), { alias: { c: 'cwd' }, boolean: ['dry-run'] });
|
|
113
|
+
const report = runSetup({ cwd: args.cwd ?? process.cwd(), dryRun: Boolean(args['dry-run']) });
|
|
114
|
+
process.stdout.write(`${report.lines.join('\n')}\n`);
|
|
115
|
+
}
|