@tuzi-ince/hi-loop 0.3.0 → 0.3.2

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
@@ -33,9 +33,12 @@ npm install -g @tuzi-ince/hi-loop
33
33
 
34
34
  # 2) 프로젝트에 주입 (멱등 — 여러 번 돌려도 안전)
35
35
  cd /path/to/my-project
36
- hi-loop-setup # .mcp.json / .gitignore / docs / tests 자동 주입
36
+ hi-loop-setup # .mcp.json / .gitignore / docs·tests / CLAUDE.md / docs/design.md 자동 주입
37
37
  ```
38
38
 
39
+ `hi-loop-setup` 은 **표준 설계 문서 `docs/design.md`** 를 함께 만든다(있으면 보존). 이 문서를 채우면
40
+ 문서↔소스 정합 장치(`--reconcile-spec` / `--spec`)의 오라클이 된다 — 아래 [문서↔소스 정합](#문서를-상시-오라클로--표준-문서-고정---spec) 참조.
41
+
39
42
  소스에서 개발용으로 설치하려면:
40
43
 
41
44
  ```bash
@@ -129,12 +132,15 @@ goal만 바꿔 여러 번 돌려도 앞의 산출물이 살아남는다.
129
132
  ```bash
130
133
  hi-loop run --goal "결제 모듈의 중복 검증 로직을 하나로 합쳐라" \
131
134
  --test "npm test" \
135
+ --spec docs/spec.md \
132
136
  --verify-spec
133
137
  ```
134
138
 
135
139
  - 기존 `npm test` 가 회귀 방지선이다 — 개선하다 무언가 깨면 Tier 1에서 걸린다.
136
140
  - `--verify-spec` 은 테스트 통과 **후** 별도 검증자가 스펙 대비 구현을 심판한다.
137
141
  구조가 아니라 **의도**를 본다("리팩터링했다"는데 동작이 달라졌으면 기각).
142
+ - `--spec docs/spec.md` 는 **사람이 관리하는 표준 문서를 오라클로 고정**한다 — 단말적 개선
143
+ 요청이 문서와 어긋나는 것을 잡는다(→ [문서↔소스 정합](#문서를-상시-오라클로--표준-문서-고정---spec)).
138
144
  - 개선 중 락파일·설정·마이그레이션을 건드리면 보고서 Gaps에 ⚠️로 공개된다
139
145
  (→ [영향도 분석](#4-영향도-분석--변경-위험-파악)).
140
146
 
@@ -402,6 +408,67 @@ false green을 disclosed green으로 바꾼다.
402
408
 
403
409
  구조가 아니라 의도를 본다. 비용이 늘어 opt-in이다.
404
410
 
411
+ ### 문서를 상시 오라클로 — 표준 문서 고정 (`--spec`)
412
+
413
+ 구현하다 보면 단말적 요청들이 작성된 문서와 상이하게 들어와 **문서↔소스 갭**이 생기고,
414
+ 그 갭이 환각의 빌미가 된다. 기본 동작은 goal마다 스펙을 새로 만들어(goal 해시 분기) 이 갭을
415
+ **막지 못한다** — 스펙 파일만 여러 개로 늘 뿐이다. `--spec <path>` 가 이 구멍을 닫는다.
416
+
417
+ ```bash
418
+ hi-loop run --goal "..." --spec docs/spec.md --verify-spec
419
+ ```
420
+
421
+ - 스펙 오라클을 이 경로로 **고정**한다. DESIGN_REVIEW·CODE_REVIEW·`--verify-spec` 이 모두
422
+ `state.specPath` 하나에서 읽으므로, 셋 다 이 표준 문서를 기준으로 심판한다.
423
+ - goal이 달라도 **같은 문서를 오라클로 공유**한다 — 새 요청이 문서와 어긋나면 오라클이 잡는다.
424
+ - 고정한 문서에 **이미 내용이 있으면 PLAN이 덮어쓰지 않는다.** 사람이 관리하는 표준 문서를
425
+ 진실로 받아들이고, 그에 맞춰 테스트만 쓴다. 비어 있을 때만 스펙을 작성한다.
426
+
427
+ > 기본(자기서술 스펙)은 "에이전트가 자기가 쓴 테스트를 통과"일 뿐이다(Gaps가 이를 공개한다).
428
+ > `--spec`으로 **외부 오라클**을 주면 그 한계를 좁힌다.
429
+
430
+ ### 문서와 어긋나는 요청을 조용히 넘기지 않는다 (`--reconcile`)
431
+
432
+ `--spec`이 "이 문서를 오라클로 써라"라면, `--reconcile`은 **"이 요청이 기존 문서와 모순되는지
433
+ 먼저 확인하라"**다. 표준 문서를 고정하지 않아도, 요청이 문서를 배신하는 순간을 잡는다.
434
+
435
+ ```bash
436
+ hi-loop run --goal "결제에서 음수 잔액도 허용하라" --reconcile # --full 이면 자동 켜짐
437
+ ```
438
+
439
+ - 기존 표준 문서가 있고 이번 요청이 그것과 별개 스펙으로 **포크될 참이면**, 포크 직전에
440
+ 별도 판정자(plan 모드, 새 세션)가 goal과 표준 문서를 대조한다.
441
+ - **대조 대상 우선순위**: `--reconcile-spec <path>`(명시) → `docs/spec.md`가 아니라 **`docs/design.md`
442
+ (setup이 만드는 사람 관리 표준 문서)** → 없으면 `docs/spec.md` 폴백. 즉 `hi-loop-setup`을 했다면
443
+ `--full`/`--reconcile`만으로 별도 지정 없이 **design.md가 자동으로 오라클이 된다.** (spec.md는
444
+ PLAN이 쓴 에이전트 산출물이라 오라클로 약하다 — 사람 문서가 있으면 그쪽이 옳다.)
445
+ - **모순이면 멈추고 사람에게 묻는다**(상호배타 3지선다): **(a)** 표준 문서를 오라클로 고정하고
446
+ 진행 / **(b)** 새 스펙으로 분기(요청이 문서를 갱신) / **(c)** 중단하고 문서를 먼저 정리.
447
+ - 모순이 없으면(또는 표준 문서가 없으면) 판정자를 부르지도 않는다 — 비용 0. 판정 실패는
448
+ 통과로 흘린다(애매한 것까지 막으면 정상 작업이 멈춘다). opt-in.
449
+
450
+ > 이것이 "단말적 요청이 문서와 상이하게 들어와 갭이 벌어지는" 상황을 **엔진 차원에서** 막는
451
+ > 장치다. `--spec`(고정)과 `--reconcile`(감지)은 짝으로 쓰면 문서↔소스 정합이 가장 단단하다.
452
+
453
+ ### 에이전트를 못 부르면 스택이 아니라 지침을 준다 (preflight)
454
+
455
+ 에이전트(claude) 미설치·인증 만료·API 키 부재는 이 엔진의 버그가 아니라 환경 문제다.
456
+ 루프 한복판의 스택트레이스가 아니라 **행동지침 한 줄**로 바꾼다.
457
+
458
+ - 에이전트를 부르는 명령은 루프 진입 전 `claude --version`(비용 0)으로 실행 가능성을 확인한다.
459
+ 미설치(ENOENT)·권한(EACCES)이면 회차를 태우지 않고 멈추며 설치·`HILOOP_AGENT_CMD` 안내를 준다.
460
+ - 인증/구독/키 문제는 `--version`으론 알 수 없다(비용을 안 들인다). 첫 호출 stderr를 패턴으로
461
+ 읽어 힌트로 번역하고, 못 알아본 실패는 원문을 그대로 보여준다(감추지 않는다).
462
+ - 환경 오류는 CLI에서 **스택 없이 메시지만**, MCP에선 접두 없이 그대로 전달한다. 진짜 버그만
463
+ 스택을 노출한다.
464
+
465
+ ### MCP는 호스트가 정한 프로젝트를 앵커로 삼는다
466
+
467
+ MCP 도구의 `cwd` 기본값은 `CLAUDE_PROJECT_DIR || process.cwd()` 다 — 호스트(클로드코드/커서)가
468
+ "어느 프로젝트인가"로 주입하는 값을 따른다. 호출 시 `cwd`를 명시하면 그것이 최우선. (CLI는
469
+ 반대로 순수 `process.cwd()` — 터미널에서 cd한 곳이 사용자의 명시 의도이고, 통합 터미널에
470
+ 새어든 env가 그것을 덮으면 하위 프로젝트 겨냥이 깨지기 때문이다.)
471
+
405
472
  ### 💸 비용은 엔진이 센다 — 그리고 싸지 않다
406
473
 
407
474
  **`--max-loops` 는 비용 상한이 아니다.** 실패 루프가 10회를 다 쓰면 비용이 크게 뛸 수 있다.
@@ -425,6 +492,7 @@ hi-loop run --goal "..." --max-loops 5 --budget-usd 1 --no-stagnation
425
492
  | `HILOOP_VERIFY_CMD` / `HILOOP_VERIFY_MODEL` | `--verify-spec` 검증자의 실행 명령·모델 (미설정 시 구현자와 동일). 검증에 더 센 모델을 쓸 수 있다 |
426
493
  | `HILOOP_REVIEW_CMD` / `HILOOP_REVIEW_MODEL` | 설계·코드 리뷰어의 실행 명령·모델 (미설정 시 `HILOOP_VERIFY_*` → 구현자 순으로 폴백) |
427
494
  | `HILOOP_DISCOVER_CMD` / `HILOOP_DISCOVER_MODEL` | 발굴 단계의 실행 명령·모델 |
495
+ | `HILOOP_RECONCILE_CMD` / `HILOOP_RECONCILE_MODEL` | 문서 정합 판정자(`--reconcile`)의 실행 명령·모델 (미설정 시 `HILOOP_VERIFY_*` → 구현자 순 폴백) |
428
496
  | `HILOOP_AGENT_TIMEOUT_MS` / `HILOOP_TEST_TIMEOUT_MS` | 에이전트(30분)·테스트(10분) 타임아웃. 쓰레기값은 기본값으로 되돌림 |
429
497
  | `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | 시작/핸드오프/성공/실패 알림. 미설정 시 조용히 생략 |
430
498
 
@@ -454,6 +522,10 @@ src/report.js 완료 보고 — Gaps 공개 + 상태 요약
454
522
  src/cli-options.js CLI 인자 → runLoop 옵션 번역 (잘못된 값은 조용히 흘리지 않는다)
455
523
  src/state.js .agent-state.json (원자적 저장 + 다이어트 + 산출물 경로 + resume + Gaps)
456
524
  src/runners.js 에이전트/테스트 실행 어댑터 (주입 가능) + 비용 파싱 + 타임아웃
525
+ src/preflight.js 에이전트 실행 preflight + 실패 진단 (미설치·인증·키 → 행동지침)
526
+ src/reconcile.js 문서 정합 게이트 — 요청↔표준 문서 모순 판정 + ask 표면화 (FR-21)
527
+ src/cli-commands.js CLI 보조 명령 — answer / rollback / config (bin 은 라우팅만)
528
+ src/stage-context.js stage 핸들러 실행 맥락 조립
457
529
  src/prompts.js PDCA 단계별 프롬프트 + 검증자 프롬프트
458
530
  src/integrity.js 테스트 무결성 지문·비교 — 보상 해킹 방어
459
531
  src/checkpoint.js git stash 체크포인트·롤백 + 삭제 관측
package/bin/hi-loop.js CHANGED
@@ -27,11 +27,12 @@ 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] [--cwd .]
30
+ [--stagnation 3 | --no-stagnation] [--verify-spec] [--spec docs/spec.md] [--cwd .]
31
31
  [--ship "<배포 명령>"] [--on-ship-fail stop|heal]
32
32
  [--review | --no-review] [--max-review-rounds 2]
33
33
  [--design-review | --no-design-review] [--max-design-rounds 2]
34
34
  [--full] [--discover | --no-discover]
35
+ [--reconcile | --no-reconcile] [--reconcile-spec docs/spec.md]
35
36
  [--watch "<헬스체크 명령>"] [--watch-for 5m] [--watch-every 30s]
36
37
  [--watch-tolerate 1] [--on-watch-fail stop|rollback|heal]
37
38
  [--branch [name]] [--commit] [--commit-message "..."]
@@ -79,7 +80,7 @@ const USAGE = `hi-loop — 초경량 자율형 자가 치유 엔진
79
80
  export async function main(argv = process.argv.slice(2)) {
80
81
  const args = parseArgs(argv, {
81
82
  alias: { g: 'goal', t: 'test', c: 'cwd', h: 'help', v: 'version', m: 'max-loops' },
82
- boolean: ['help', 'version', 'dry-run', 'no-stagnation', 'verify-spec', 'flaky-probe', 'yes', 'review', 'no-review', 'design-review', 'no-design-review', 'full', 'discover', 'no-discover', 'commit'],
83
+ boolean: ['help', 'version', 'dry-run', 'no-stagnation', 'verify-spec', 'flaky-probe', 'yes', 'review', 'no-review', 'design-review', 'no-design-review', 'full', 'discover', 'no-discover', 'reconcile', 'no-reconcile', 'commit'],
83
84
  // 개수가 정해지지 않은 입력. `--check A --when X --check B` 처럼 위치로 짝을 맺는다.
84
85
  repeat: ['check', 'when'],
85
86
  });
@@ -144,6 +145,18 @@ export async function main(argv = process.argv.slice(2)) {
144
145
  if (options.branch == null && config.branchPolicy === 'always') options.branch = true;
145
146
  if (!options.commit && config.commitPolicy === 'auto') options.commit = true;
146
147
 
148
+ // 에이전트를 실제로 부르는 명령이면, 루프 진입 전에 실행 가능한지 확인한다.
149
+ // "claude 가 없다/실행 안 된다"를 첫 회차의 스택트레이스가 아니라 여기서 한 줄로 잡는다.
150
+ // ship/watch 는 에이전트를 부르지 않으므로 건너뛴다(불필요한 spawn 방지).
151
+ if (['run', 'discover', 'plan', 'review'].includes(command)) {
152
+ const { preflightAgent } = await import('../src/preflight.js');
153
+ const pre = await preflightAgent();
154
+ if (!pre.ok) {
155
+ process.stderr.write(`[hi-loop] ${pre.message}\n`);
156
+ return 1;
157
+ }
158
+ }
159
+
147
160
  const { runLoop } = await import('../src/loop.js');
148
161
  const result = await runLoop({
149
162
  goal,
@@ -168,63 +181,13 @@ export async function main(argv = process.argv.slice(2)) {
168
181
  process.stdout.write(`${summarizeState(loadState(statePathFor(cwd)))}\n`);
169
182
  return 0;
170
183
  }
171
- case 'answer': {
172
- const { loadState, saveState, statePathFor } = await import('../src/state.js');
173
- const { applyAnswer } = await import('../src/ask.js');
174
- const statePath = statePathFor(cwd);
175
- const state = loadState(statePath);
176
- if (!state?.ask) {
177
- process.stderr.write('대기 중인 질문이 없습니다.\n');
178
- return 1;
179
- }
180
- const note = args.note && args.note !== true ? String(args.note) : '';
181
- const res = applyAnswer(state, { choice: args._[1], note });
182
- if (!res.ok) {
183
- process.stderr.write(`${res.reason}\n`);
184
- return 2;
185
- }
186
- saveState(statePath, res.state);
187
- const last = res.state.assumptions[res.state.assumptions.length - 1];
188
- process.stdout.write(`✅ 기록했습니다: ${last.text}\n 이어서 진행하려면 같은 goal 로 hi-loop run 을 다시 실행하세요.\n`);
189
- return 0;
190
- }
191
- case 'rollback': {
192
- const { loadState, statePathFor } = await import('../src/state.js');
193
- const { rollbackTo } = await import('../src/checkpoint.js');
194
- const state = loadState(statePathFor(cwd));
195
- const checkpoints = state?.checkpoints ?? [];
196
- if (checkpoints.length === 0) {
197
- process.stderr.write('되돌릴 체크포인트가 없습니다 (git 저장소가 아니거나 아직 스냅샷이 없음).\n');
198
- return 1;
199
- }
200
- // --to N 이면 N회차 직전 체크포인트, 없으면 가장 최근.
201
- const toIter = args.to !== undefined && args.to !== true ? Number(args.to) : null;
202
- const cp =
203
- toIter != null
204
- ? checkpoints.find((c) => c.iteration === toIter)
205
- : checkpoints[checkpoints.length - 1];
206
- if (!cp) {
207
- process.stderr.write(`${toIter}회차 체크포인트가 없습니다. 있는 회차: ${checkpoints.map((c) => c.iteration).join(', ')}\n`);
208
- return 1;
209
- }
210
- const res = await rollbackTo({ cwd, sha: cp.sha });
211
- if (!res.ok) {
212
- process.stderr.write(`롤백 실패: ${res.reason}\n`);
213
- return 1;
214
- }
215
- process.stdout.write(`✅ ${cp.iteration}회차 직전 상태로 추적 파일을 복원했습니다 (${cp.sha.slice(0, 8)}).\n`);
216
- // 되돌리지 못한 것을 되돌렸다고 말하지 않는다. git 은 추적 밖 파일을 트리 연산으로
217
- // 지우지 않고, `git clean` 은 사용자의 정상 파일까지 지운다 — 그래서 판단은 사람에게 준다.
218
- if (res.untrackedRemain?.length) {
219
- process.stdout.write(
220
- `⚠️ 추적되지 않는 파일 ${res.untrackedRemain.length}개는 그대로 남아 있습니다(에이전트가 새로 만든 것일 수 있습니다):\n` +
221
- res.untrackedRemain.slice(0, 10).map((f) => ` - ${f}\n`).join('') +
222
- (res.untrackedRemain.length > 10 ? ` … 외 ${res.untrackedRemain.length - 10}개\n` : '') +
223
- ' 필요하면 직접 지우세요. hi-loop 은 사용자의 정상 파일을 지울 수 없어 판단하지 않습니다.\n',
224
- );
225
- }
226
- if (res.safetySha) process.stdout.write(` 되돌리기 전 상태는 ${res.safetySha.slice(0, 8)} 에 스냅샷됨.\n`);
227
- return 0;
184
+ case 'answer':
185
+ case 'rollback':
186
+ case 'config': {
187
+ // 보조 명령의 실제 동작은 src/cli-commands.js 에 있다(NFR-5). bin 은 라우팅만 한다.
188
+ const c = await import('../src/cli-commands.js');
189
+ const fn = { answer: c.runAnswer, rollback: c.runRollback, config: c.runConfig }[command];
190
+ return fn({ cwd, args });
228
191
  }
229
192
  case 'setup': {
230
193
  const { runSetup } = await import('./setup.js');
@@ -232,38 +195,6 @@ export async function main(argv = process.argv.slice(2)) {
232
195
  process.stdout.write(`${report.lines.join('\n')}\n`);
233
196
  return 0;
234
197
  }
235
- case 'config': {
236
- // hi-loop config 현재 정책 출력
237
- // hi-loop config set <key> <value> 정책 변경 (branchPolicy|commitPolicy|commitStyle)
238
- // hi-loop config add-branch <name> 보호 브랜치 추가
239
- // hi-loop config remove-branch <name> 보호 브랜치 제거
240
- const cfg = await import('../src/config.js');
241
- const sub = args._[1];
242
- if (!sub) {
243
- process.stdout.write(`${JSON.stringify(cfg.loadConfig(cwd), null, 2)}\n`);
244
- return 0;
245
- }
246
- if (sub === 'set') {
247
- const r = cfg.setPolicy(cwd, args._[2], args._[3]);
248
- if (!r.ok) {
249
- process.stderr.write(`${r.reason}\n`);
250
- return 2;
251
- }
252
- process.stdout.write(`✓ ${args._[2]} = ${args._[3]}\n`);
253
- return 0;
254
- }
255
- if (sub === 'add-branch' || sub === 'remove-branch') {
256
- const r = sub === 'add-branch' ? cfg.addBranch(cwd, args._[2]) : cfg.removeBranch(cwd, args._[2]);
257
- if (!r.ok) {
258
- process.stderr.write(`${r.reason}\n`);
259
- return 2;
260
- }
261
- process.stdout.write(`✓ 보호 브랜치: ${r.config.protectedBranches.join(', ')}\n`);
262
- return 0;
263
- }
264
- process.stderr.write(`알 수 없는 config 하위명령: ${sub} (가능: set, add-branch, remove-branch)\n`);
265
- return 2;
266
- }
267
198
  default:
268
199
  process.stderr.write(`알 수 없는 명령: ${command}\n\n${USAGE}\n`);
269
200
  return 2;
@@ -276,7 +207,13 @@ if (isMain(import.meta.url)) {
276
207
  if (code !== null && code !== undefined) process.exit(code);
277
208
  })
278
209
  .catch((err) => {
279
- process.stderr.write(`[hi-loop] 치명적 오류: ${err?.stack ?? err}\n`);
210
+ // 환경/설정 오류(에이전트 미설치·인증 만료 등)는 우리 코드의 버그가 아니다.
211
+ // 스택트레이스는 잡음일 뿐이므로 행동지침 한 줄만 낸다. 진짜 버그만 스택을 노출한다.
212
+ if (err?.kind === 'environment') {
213
+ process.stderr.write(`[hi-loop] ${err.message}\n`);
214
+ } else {
215
+ process.stderr.write(`[hi-loop] 치명적 오류: ${err?.stack ?? err}\n`);
216
+ }
280
217
  process.exit(1);
281
218
  });
282
219
  }
package/bin/setup.js CHANGED
@@ -33,8 +33,36 @@ export const CLAUDE_MD_BLOCK = `${CLAUDE_MD_BEGIN}
33
33
  프로젝트 셋업)가 없으면 **임의 설치하지 말고 스킵**한다 — 통과한 루프를 실패로 만들지 않는다.
34
34
  - 코드 변경 요청은 보호 브랜치(main/master/develop/dev)면 feature 로 분기하고, 테스트 통과 후
35
35
  **로컬 커밋**한다(정책은 커밋되는 \`.hi-loop.json\`; push/PR 은 안 함). "이번만"과 "앞으로"를 구분한다.
36
+ - 이 프로젝트의 표준 설계 문서는 \`docs/design.md\` 다. 코드 변경 루프는 이 문서와 정합해야 한다 —
37
+ 요청이 문서와 어긋날 수 있으면 \`--reconcile-spec docs/design.md\`(정합 게이트, 모순이면 사람에게
38
+ 물음)로, 이 문서를 스펙 오라클로 고정하려면 \`--spec docs/design.md\` 로 실행한다.
36
39
  ${CLAUDE_MD_END}`;
37
40
 
41
+ // 표준 설계 문서(FR-21 정합 오라클). setup 이 없으면 만들어 두고, 있으면 절대 덮어쓰지 않는다.
42
+ // PLAN 이 쓰는 spec.md 와 달리 이 문서는 **사람이 관리하는 진실**이라 엔진이 손대지 않는다.
43
+ export const DESIGN_DOC_PATH = 'docs/design.md';
44
+ export const DESIGN_DOC_TEMPLATE = `# 설계 문서 (Design Doc)
45
+
46
+ > 이 프로젝트의 **표준 설계 문서**다. hi-loop 의 문서↔소스 정합 장치가 이 문서를 오라클로 삼는다:
47
+ > - \`hi-loop run --goal "..." --reconcile-spec docs/design.md\` — 새 요청이 이 문서와 모순되면
48
+ > 구현 전에 멈춰 사람에게 묻는다(문서 고정 / 새 스펙 분기 / 중단).
49
+ > - \`hi-loop run --goal "..." --spec docs/design.md\` — 이 문서를 스펙 오라클로 고정한다
50
+ > (설계·코드 리뷰와 검증이 이 문서를 기준으로 판정).
51
+ >
52
+ > 아래를 프로젝트에 맞게 채워라. **비어 있으면 정합 게이트는 사실상 아무것도 막지 못한다.**
53
+
54
+ ## 목적 / 배경
55
+
56
+ ## 핵심 결정 — 제약·불변식
57
+ <!-- 예: 인증은 HS256 JWT, 만료 1시간. 결제 잔액은 음수 불가. 이 줄들이 정합 판정의 근거가 된다. -->
58
+
59
+ ## 범위 — 하는 것 / 안 하는 것
60
+
61
+ ## 공개 인터페이스 / API
62
+
63
+ ## 열린 질문
64
+ `;
65
+
38
66
  /** 이 setup 파일과 나란히 있는 실제 CLI 진입점의 절대 경로 */
39
67
  export function cliEntryPath() {
40
68
  return join(dirname(fileURLToPath(import.meta.url)), 'hi-loop.js');
@@ -146,7 +174,17 @@ export function runSetup({ cwd = process.cwd(), dryRun = false } = {}) {
146
174
  }
147
175
  }
148
176
 
149
- // 4) CLAUDE.md — 기획/설계 요청을 hi-loop 으로 라우팅하는 상시 지침 (FR-6.5)
177
+ // 4) docs/design.md — 표준 설계 문서(FR-21 정합 오라클). 없으면 만들고, 있으면 내용을 보존한다.
178
+ const designPath = join(root, DESIGN_DOC_PATH);
179
+ if (!existsSync(designPath)) {
180
+ write(designPath, DESIGN_DOC_TEMPLATE);
181
+ changes.push(DESIGN_DOC_PATH);
182
+ lines.push(`${dryRun ? '[dry-run] ' : ''}✓ ${DESIGN_DOC_PATH} 표준 설계 문서를 만들었습니다 (--reconcile-spec / --spec 오라클).`);
183
+ } else {
184
+ lines.push(`· ${DESIGN_DOC_PATH} 이미 존재합니다 (내용 보존).`);
185
+ }
186
+
187
+ // 5) CLAUDE.md — 기획/설계 요청을 hi-loop 으로 라우팅하는 상시 지침 (FR-6.5)
150
188
  const cmdPath = join(root, 'CLAUDE.md');
151
189
  const cmd = mergeClaudeMd(existsSync(cmdPath) ? readFileSync(cmdPath, 'utf8') : '');
152
190
  if (cmd.changed) {
@@ -157,7 +195,13 @@ export function runSetup({ cwd = process.cwd(), dryRun = false } = {}) {
157
195
  lines.push('· CLAUDE.md 지침은 이미 최신입니다.');
158
196
  }
159
197
 
160
- lines.push('', '이제 사용하세요:', ' hi-loop run --goal "요구사항" --test "npm test"', ' 또는 에이전트에서 MCP 도구 hiloop_run 호출 (스킬: /hi-loop)');
198
+ lines.push(
199
+ '',
200
+ '이제 사용하세요:',
201
+ ' hi-loop run --goal "요구사항" --test "npm test"',
202
+ ' 또는 에이전트에서 MCP 도구 hiloop_run 호출 (스킬: /hi-loop)',
203
+ ` · ${DESIGN_DOC_PATH} 를 채우면 --reconcile-spec/--spec 오라클로 문서↔소스 정합을 지킵니다.`,
204
+ );
161
205
  return { changes, lines, dryRun };
162
206
  }
163
207
 
package/docs/spec.md CHANGED
@@ -222,11 +222,16 @@ runLoop({
222
222
  - FR-4.3 노출 도구:
223
223
  | tool | input | 동작 |
224
224
  |---|---|---|
225
- | `hiloop_run` | `goal`(필수), `testCommand?`, `maxLoops?`, `verifySpec?`, `cwd?` | 루프 실행 후 결과 텍스트 반환. `budgetUsd`·`stagnation`은 노출 안 함(CLI 전용) |
225
+ | `hiloop_run` | `goal`(필수), `testCommand?`, `maxLoops?`, `verifySpec?`, `spec?`, `reconcile?`, `cwd?` | 루프 실행 후 결과 텍스트 반환. `budgetUsd`·`stagnation`은 노출 안 함(CLI 전용) |
226
226
  | `hiloop_status` | `cwd?` | 현재 `.agent-state.json` 요약 반환 |
227
227
  | `hiloop_reset` | `cwd?` | 상태 파일 삭제 |
228
228
  - FR-4.4 도구 오류는 예외를 던지지 않고 `isError: true` + 메시지로 반환한다.
229
229
  - FR-4.5 SDK 미설치 시 친절한 에러 메시지 후 exit 1 (CLI 모드는 SDK 없이도 동작해야 함).
230
+ - FR-4.6 **cwd 기본값은 `CLAUDE_PROJECT_DIR || process.cwd()`.** MCP 는 호스트가 서버를 띄우는
231
+ 경로라 "어느 프로젝트인가"의 정답은 호스트가 주입하는 `CLAUDE_PROJECT_DIR` 다. 호출자가 `cwd`
232
+ 를 명시하면 그것이 최우선. (CLI 는 이와 달리 순수 `process.cwd()` — 터미널에서 cd 한 곳이
233
+ 사용자의 명시 의도이고, 통합 터미널에 새어든 env 가 그것을 덮으면 하위 프로젝트 겨냥이 깨진다.)
234
+ - FR-4.7 `hiloop_run` 은 루프 진입 전 에이전트 preflight(FR-20)를 거친다.
230
235
 
231
236
  ### FR-5. 텔레그램 알림 (`src/telegram.js`)
232
237
 
@@ -244,6 +249,11 @@ runLoop({
244
249
  - FR-6.3 `docs/`, `tests/` 디렉터리를 보장한다.
245
250
  - FR-6.4 **멱등(idempotent)**: 몇 번 돌려도 결과가 같고 기존 설정을 파괴하지 않는다.
246
251
  - FR-6.5 `--dry-run` 지원: 변경 없이 계획만 출력.
252
+ - FR-6.6 `docs/design.md` **표준 설계 문서**를 없으면 템플릿으로 만들고, 있으면 **절대 덮어쓰지
253
+ 않는다**(사람이 관리하는 진실). FR-21 정합 게이트·FR-19 스펙 고정의 오라클 대상이다. PLAN 이
254
+ 쓰는 `spec.md` 와 달리 엔진은 이 파일을 손대지 않는다.
255
+ - FR-6.7 `CLAUDE.md` 에 hi-loop 워크플로 라우팅 지침을 마커(`<!-- hi-loop:begin/end -->`)로 감싸
256
+ 멱등 주입한다. 코드 변경 루프가 `docs/design.md` 와 정합하도록(`--reconcile-spec`/`--spec`) 안내한다.
247
257
 
248
258
  ---
249
259
 
@@ -383,6 +393,55 @@ DISCOVER → PLAN → DESIGN_REVIEW → DO ⇄ CHECK ⇄ HEAL → CODE_REVIEW
383
393
  - FR-18.5 `assumptions`·`reviewFindings`·`answers` 도 컨텍스트 다이어트 대상이다(FR-3.1).
384
394
  프롬프트에 실려 나가므로 자르지 않으면 핸드오프가 압축한 만큼을 되돌려놓는다.
385
395
 
396
+ ### FR-19. 스펙 오라클 고정 (`--spec`) — 문서↔소스 drift 방지
397
+
398
+ 단말적 요청이 goal 마다 별도 스펙을 만들면(FR-3.5 의 goal 해시 분기) 프로젝트의 표준 문서와
399
+ 소스가 어긋나고, 그 갭이 환각의 빌미가 된다. `--spec <path>` 는 그 갭을 닫는다.
400
+
401
+ - FR-19.1 `--spec <path>` / MCP `hiloop_run(spec)` 로 스펙 오라클을 이 경로로 **고정**한다.
402
+ `state.specPath` 하나에서 DESIGN_REVIEW(FR-11)·CODE_REVIEW(FR-12)·스펙 검증(P6)이 모두
403
+ 읽으므로, 고정하면 세 오라클이 전부 이 문서를 기준으로 심판한다.
404
+ - FR-19.2 고정 경로는 **goal 해시로 비켜가지 않는다**(FR-3.5 의 분기를 끈다). 서로 다른 goal
405
+ 들이 같은 표준 문서를 오라클로 공유해, 요청이 문서와 어긋나면 오라클이 그것을 잡는다.
406
+ - FR-19.3 고정된 스펙에 **이미 내용이 있으면 PLAN 이 덮어쓰지 않는다**(`specPinned` 신호).
407
+ 사람이 관리하는 표준 문서를 진실로 받아들이고, 그에 맞춰 테스트만 작성한다. 비어 있을 때만 쓴다.
408
+ - FR-19.4 명시 고정은 저장된 `state.specPath` 보다 우선한다 — 사용자가 이번에 오라클을 바꾼 것이다.
409
+
410
+ ### FR-20. 에이전트 preflight & 실패 진단 (`src/preflight.js`)
411
+
412
+ 에이전트를 못 부르는 것은 이 엔진의 버그가 아니라 환경 문제다(미설치·인증·키). 루프 한복판의
413
+ 스택트레이스가 아니라 **행동지침 한 줄**로 바꾼다. CLI/MCP 공용.
414
+
415
+ - FR-20.1 에이전트를 부르는 명령(run/discover/plan/review)은 루프 진입 전 `claude --version`
416
+ 으로 실행 가능성을 확인한다(비용 0). 미설치(ENOENT)·권한(EACCES)이면 회차를 태우지 않고 멈춘다.
417
+ - FR-20.2 **오탐 방지**: `--version` 이 비정상 종료해도 막지 않는다(존재는 증명됨). 커스텀
418
+ 에이전트가 `--version` 을 지원 안 할 수 있다 — 멀쩡한 셋업을 죽이는 것이 더 나쁘다.
419
+ - FR-20.3 인증/구독/키 문제는 `--version` 으로 알 수 없다(비용을 들이지 않는다). 첫 실제 호출의
420
+ stderr 를 패턴으로 읽어 힌트로 번역한다. 못 알아본 실패는 원문을 그대로 보여준다(감추지 않는다).
421
+ - FR-20.4 환경 오류는 `kind==='environment'` 로 표시하고, CLI 는 스택 없이 메시지만, MCP 는
422
+ 접두 없이 그대로 전달한다. 진짜 버그만 스택트레이스를 노출한다.
423
+
424
+ ### FR-21. 문서 정합 게이트 (`--reconcile`, `src/reconcile.js`)
425
+
426
+ 단말적 요청이 기존 표준 문서와 상이하게 들어오면, 엔진은 goal 해시로 스펙을 **조용히 포크**해
427
+ (FR-3.5) 문서↔소스 갭을 만든다 — 그 갭이 환각의 빌미다. 이 게이트는 포크 직전에 모순을 잡아
428
+ 사람에게 표면화한다. 판정이 아니라 **표면화**다.
429
+
430
+ - FR-21.1 opt-in. `--reconcile` / `--no-reconcile`, `--full` 이면 자동 켜짐(FR-17 과 같은 삼상).
431
+ 게이트 발동 자체는 `docs/design.md` 존재만으로 켜지지 않는다 — 반드시 위 플래그로 opt-in 한다.
432
+ - FR-21.2 대조 대상 문서의 우선순위: **① `--reconcile-spec <path>`(명시) → ② `docs/design.md`
433
+ (setup 이 만드는 사람 관리 표준 문서, FR-6.6) → ③ `docs/spec.md`(폴백).** spec.md 는 PLAN 이 쓴
434
+ 에이전트 산출물이라 오라클로는 약하므로, 사람 문서가 있으면 그쪽을 기본으로 삼는다.
435
+ - FR-21.3 발동 조건: 켜졌고, 대상 문서가 있고, 이번 실행이 그것을 오라클로 쓰지 않고
436
+ (specPinned 아님) 별도 경로로 포크될 참일 때. 하나라도 아니면 판정기를 부르지 않는다(비용 0).
437
+ - FR-21.4 판정기는 별도 세션·plan 모드(쓰기 금지)로 goal 과 표준 문서를 대조해 `ok|conflict` 를
438
+ 낸다. 인프라 실패·파싱 실패는 `ok` 로 흘린다(하향 관대 — 애매한 것까지 막으면 정상 작업이 멈춘다).
439
+ - FR-21.5 `conflict` 면 상호배타 분기(ask, FR-16)로 멈춘다: (a) 표준 문서를 오라클로 **고정**하고
440
+ 진행(FR-19) / (b) 새 스펙으로 **분기** / (c) **중단**. `--ask never` 면 안 멈추고 포크로 진행하되
441
+ 미해결이 Gaps 에 남는다.
442
+ - FR-21.6 결정은 `state.reconciled` 로 기록해 같은 goal 재개 시 다시 묻지 않는다. 답한 재개는
443
+ 판정기를 재호출하지 않는다(gate 가 저장된 답을 돌려준다).
444
+
386
445
  ---
387
446
 
388
447
  ## 3. 비기능 요구사항
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuzi-ince/hi-loop",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "CLI와 MCP 모드를 지원하는 초경량 자율형 자가 치유 엔진",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,100 @@
1
+ /**
2
+ * CLI 보조 명령 구현 — answer / rollback / config (bin/hi-loop.js 에서 분리, NFR-5).
3
+ *
4
+ * bin 은 인자 파싱과 라우팅만 맡고, 각 명령의 실제 동작은 여기 있다. 각 함수는 stdout/stderr 로
5
+ * 직접 내고 **exit code 를 반환**한다 — bin 은 그 코드를 그대로 돌려주기만 한다.
6
+ *
7
+ * 정적 import 를 쓴다: 여기서 부르는 모듈(state·ask·checkpoint·config)은 MCP SDK 에 의존하지
8
+ * 않으므로 CLI 경량성(NFR-1)을 해치지 않는다. bin 은 이 파일 자체를 명령별로 lazy import 한다.
9
+ */
10
+ import { loadState, saveState, statePathFor } from './state.js';
11
+ import { applyAnswer } from './ask.js';
12
+ import { rollbackTo } from './checkpoint.js';
13
+ import * as cfg from './config.js';
14
+
15
+ /** hi-loop answer <선택> [--note "..."] — 대기 중인 질문에 답하고 루프를 재개 가능 상태로 되돌린다. */
16
+ export async function runAnswer({ cwd, args }) {
17
+ const statePath = statePathFor(cwd);
18
+ const state = loadState(statePath);
19
+ if (!state?.ask) {
20
+ process.stderr.write('대기 중인 질문이 없습니다.\n');
21
+ return 1;
22
+ }
23
+ const note = args.note && args.note !== true ? String(args.note) : '';
24
+ const res = applyAnswer(state, { choice: args._[1], note });
25
+ if (!res.ok) {
26
+ process.stderr.write(`${res.reason}\n`);
27
+ return 2;
28
+ }
29
+ saveState(statePath, res.state);
30
+ const last = res.state.assumptions[res.state.assumptions.length - 1];
31
+ process.stdout.write(`✅ 기록했습니다: ${last.text}\n 이어서 진행하려면 같은 goal 로 hi-loop run 을 다시 실행하세요.\n`);
32
+ return 0;
33
+ }
34
+
35
+ /** hi-loop rollback [--to N] — N회차 직전 체크포인트로 추적 파일을 복원한다(기본: 최신). */
36
+ export async function runRollback({ cwd, args }) {
37
+ const state = loadState(statePathFor(cwd));
38
+ const checkpoints = state?.checkpoints ?? [];
39
+ if (checkpoints.length === 0) {
40
+ process.stderr.write('되돌릴 체크포인트가 없습니다 (git 저장소가 아니거나 아직 스냅샷이 없음).\n');
41
+ return 1;
42
+ }
43
+ // --to N 이면 N회차 직전 체크포인트, 없으면 가장 최근.
44
+ const toIter = args.to !== undefined && args.to !== true ? Number(args.to) : null;
45
+ const cp = toIter != null ? checkpoints.find((c) => c.iteration === toIter) : checkpoints[checkpoints.length - 1];
46
+ if (!cp) {
47
+ process.stderr.write(`${toIter}회차 체크포인트가 없습니다. 있는 회차: ${checkpoints.map((c) => c.iteration).join(', ')}\n`);
48
+ return 1;
49
+ }
50
+ const res = await rollbackTo({ cwd, sha: cp.sha });
51
+ if (!res.ok) {
52
+ process.stderr.write(`롤백 실패: ${res.reason}\n`);
53
+ return 1;
54
+ }
55
+ process.stdout.write(`✅ ${cp.iteration}회차 직전 상태로 추적 파일을 복원했습니다 (${cp.sha.slice(0, 8)}).\n`);
56
+ // 되돌리지 못한 것을 되돌렸다고 말하지 않는다. git 은 추적 밖 파일을 트리 연산으로
57
+ // 지우지 않고, `git clean` 은 사용자의 정상 파일까지 지운다 — 그래서 판단은 사람에게 준다.
58
+ if (res.untrackedRemain?.length) {
59
+ process.stdout.write(
60
+ `⚠️ 추적되지 않는 파일 ${res.untrackedRemain.length}개는 그대로 남아 있습니다(에이전트가 새로 만든 것일 수 있습니다):\n` +
61
+ res.untrackedRemain.slice(0, 10).map((f) => ` - ${f}\n`).join('') +
62
+ (res.untrackedRemain.length > 10 ? ` … 외 ${res.untrackedRemain.length - 10}개\n` : '') +
63
+ ' 필요하면 직접 지우세요. hi-loop 은 사용자의 정상 파일을 지울 수 없어 판단하지 않습니다.\n',
64
+ );
65
+ }
66
+ if (res.safetySha) process.stdout.write(` 되돌리기 전 상태는 ${res.safetySha.slice(0, 8)} 에 스냅샷됨.\n`);
67
+ return 0;
68
+ }
69
+
70
+ /**
71
+ * hi-loop config [set <k> <v> | add-branch <b> | remove-branch <b>] — 브랜치·커밋 정책(.hi-loop.json).
72
+ * 인자 없으면 현재 정책을 출력한다.
73
+ */
74
+ export async function runConfig({ cwd, args }) {
75
+ const sub = args._[1];
76
+ if (!sub) {
77
+ process.stdout.write(`${JSON.stringify(cfg.loadConfig(cwd), null, 2)}\n`);
78
+ return 0;
79
+ }
80
+ if (sub === 'set') {
81
+ const r = cfg.setPolicy(cwd, args._[2], args._[3]);
82
+ if (!r.ok) {
83
+ process.stderr.write(`${r.reason}\n`);
84
+ return 2;
85
+ }
86
+ process.stdout.write(`✓ ${args._[2]} = ${args._[3]}\n`);
87
+ return 0;
88
+ }
89
+ if (sub === 'add-branch' || sub === 'remove-branch') {
90
+ const r = sub === 'add-branch' ? cfg.addBranch(cwd, args._[2]) : cfg.removeBranch(cwd, args._[2]);
91
+ if (!r.ok) {
92
+ process.stderr.write(`${r.reason}\n`);
93
+ return 2;
94
+ }
95
+ process.stdout.write(`✓ 보호 브랜치: ${r.config.protectedBranches.join(', ')}\n`);
96
+ return 0;
97
+ }
98
+ process.stderr.write(`알 수 없는 config 하위명령: ${sub} (가능: set, add-branch, remove-branch)\n`);
99
+ return 2;
100
+ }
@@ -118,6 +118,9 @@ export function parseRunOptions(args, { staged = {} } = {}) {
118
118
  ok: true,
119
119
  options: {
120
120
  testCommand: str('test') ?? 'npm test',
121
+ // FR-19: 스펙 오라클을 이 경로로 고정한다. goal 해시 분기를 끄고, 여러 요청이
122
+ // 같은 표준 문서를 오라클로 공유하게 해 문서↔소스 drift 를 막는다.
123
+ specPath: str('spec'),
121
124
  budgetUsd,
122
125
  stagnationLimit,
123
126
  verifySpec: Boolean(args['verify-spec']),
@@ -141,6 +144,9 @@ export function parseRunOptions(args, { staged = {} } = {}) {
141
144
  codeReview: triState('review', 'no-review'),
142
145
  designReview: triState('design-review', 'no-design-review'),
143
146
  discover: triState('discover', 'no-discover'),
147
+ reconcile: triState('reconcile', 'no-reconcile'),
148
+ // FR-21: 정합 게이트가 대조할 표준 문서 경로(기본 docs/spec.md). 지정하면 게이트가 켜진다.
149
+ reconcileSpec: str('reconcile-spec'),
144
150
  startFrom,
145
151
  stopAfter,
146
152
  ...durations,
package/src/loop.js CHANGED
@@ -26,6 +26,10 @@ import { makeDiscoverer } from './discover.js';
26
26
  import { formatAsk, createAsk, pauseForAsk, shouldAsk, DEFAULT_ASK_POLICY } from './ask.js';
27
27
  import { STAGE_HANDLERS } from './stages.js';
28
28
  import { runBuildLoop } from './build.js';
29
+ import { buildStageContext } from './stage-context.js';
30
+ import { makeReconciler, reconcileAsk } from './reconcile.js';
31
+ import { existsSync } from 'node:fs';
32
+ import { join } from 'node:path';
29
33
 
30
34
  // loop.js 는 이 엔진의 정문이다. 호출부가 내부 파일 배치를 알 필요가 없도록 여기서 모아 낸다.
31
35
  export { phaseForIteration } from './build.js';
@@ -59,6 +63,7 @@ async function runLoopBody({
59
63
  stagnationLimit = 3,
60
64
  verifySpec = false,
61
65
  flakyProbe = false,
66
+ specPath = null, // --spec: 스펙 오라클을 표준 문서로 고정(FR-19). null=엔진이 경로 결정.
62
67
  checks = null,
63
68
  handoffEvery = 4,
64
69
  // ---- 라이프사이클 (FR-13, FR-14) ----
@@ -70,6 +75,8 @@ async function runLoopBody({
70
75
  designReview = null,
71
76
  maxDesignRounds = 2,
72
77
  discover = null,
78
+ reconcile = null, // FR-21: 문서 정합 게이트. null=자동(--full 또는 reconcileSpec 지정 시 켜짐).
79
+ reconcileSpec = null, // 대조할 표준 문서 경로. null=기본 docs/spec.md.
73
80
  full = false,
74
81
  // FR-15: 어느 경로로 들어와도 **같은 상태 머신**을 쓴다. 별도 코드 경로를 만들지 않는다
75
82
  // — 분기가 둘이면 반드시 한쪽이 썩는다.
@@ -99,6 +106,7 @@ async function runLoopBody({
99
106
  codeReviewer = makeCodeReviewer(),
100
107
  designReviewer = makeDesignReviewer(),
101
108
  discoverer = makeDiscoverer(),
109
+ reconciler = makeReconciler(),
102
110
  shipper = makeShipper(),
103
111
  watcher = makeWatcher(),
104
112
  brancher = makeBrancher(),
@@ -117,7 +125,7 @@ async function runLoopBody({
117
125
  throw new TypeError('budgetUsd 는 0 보다 큰 숫자여야 합니다.');
118
126
  }
119
127
 
120
- let state = resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd });
128
+ let state = resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd, specPath });
121
129
 
122
130
  // 답 안 받은 질문이 남아 있으면 여기서 끝이다. 재개하려면 `hi-loop answer` 를 거쳐야 한다.
123
131
  // 이 관문이 없으면 run 을 다시 부르는 것만으로 사람이 고르라던 분기를 건너뛰게 된다.
@@ -164,6 +172,8 @@ async function runLoopBody({
164
172
  const reviewEnabled = codeReview === null ? full || Boolean(shipCommand) : Boolean(codeReview);
165
173
  const designReviewEnabled = designReview === null ? full || Boolean(shipCommand) : Boolean(designReview);
166
174
  const discoverEnabled = discover === null ? full : Boolean(discover);
175
+ // 표준 문서를 명시(reconcileSpec)하면 게이트를 쓰겠다는 뜻이므로 자동으로 켜진다(--no-reconcile 이 덮는다).
176
+ const reconcileEnabled = reconcile === null ? full || Boolean(reconcileSpec) : Boolean(reconcile);
167
177
 
168
178
  // 라우팅: 켜진 다음 단계로만 넘어간다(기본은 BUILD 하나). 커밋이 켜지면 리뷰된 코드를 먼저 커밋한다.
169
179
  const commitEnabled = Boolean(commit);
@@ -187,6 +197,29 @@ async function runLoopBody({
187
197
  return { pause: true };
188
198
  };
189
199
 
200
+ // FR-21: 문서 정합 게이트. 기존 표준 문서(docs/spec.md)가 있고 이번 요청이 그것과 별개
201
+ // 스펙으로 포크될 참이면, 조용히 갈라지기 전에 모순 여부를 판정한다. 모순이면 사람에게 묻는다.
202
+ // 대조 대상: 명시(--reconcile-spec)가 최우선. 없으면 사람이 관리하는 표준 문서(docs/design.md)를
203
+ // 우선하고, 그것도 없으면 docs/spec.md 로 폴백한다. spec.md 는 PLAN 이 쓴 에이전트 산출물이라
204
+ // 오라클로는 약하다 — 사람 문서가 있으면 그쪽이 옳다. (게이트 발동 자체는 여전히 opt-in 이다.)
205
+ const STANDING_SPEC = reconcileSpec
206
+ || (existsSync(join(cwd, 'docs/design.md')) ? 'docs/design.md' : 'docs/spec.md');
207
+ if (reconcileEnabled && !state.reconciled && !state.specPinned
208
+ && state.specPath !== STANDING_SPEC && existsSync(join(cwd, STANDING_SPEC))) {
209
+ // 이미 답한 재개면 판정기를 다시 부르지 않는다(비용 0). gate 가 저장된 답을 돌려준다.
210
+ const answered = [...(state.answers ?? [])].some((a) => a.stage === 'RECONCILE');
211
+ const v = answered ? { verdict: 'conflict', reason: '' } : await reconciler({ goal, specPath: STANDING_SPEC, cwd });
212
+ if (v.verdict === 'conflict') {
213
+ const g = gate(reconcileAsk({ standingSpec: STANDING_SPEC, reason: v.reason }));
214
+ if (g.pause) return pauseResult();
215
+ if (g.choice === 'a') { state.specPath = STANDING_SPEC; state.specPinned = true; }
216
+ else if (g.choice === 'c') return finish('failed', { stopReason: 'reconcile-aborted', detail: '문서 정리를 위해 중단' });
217
+ // 'b' 또는 null(ask never): 포크 경로를 그대로 쓴다. 미해결은 Gaps 에 남는다.
218
+ }
219
+ state.reconciled = true;
220
+ saveState(statePath, state);
221
+ }
222
+
190
223
  /** 방금 끝낸 stage 가 정지 지점인가. 다음 stage 로 넘어가기 직전에만 묻는다. */
191
224
  const shouldStopAfter = (stage) => stopAfter === stage;
192
225
 
@@ -194,65 +227,24 @@ async function runLoopBody({
194
227
  * stage 핸들러가 받는 실행 맥락. `state` 는 getter/setter 라 핸들러가 재대입해도
195
228
  * 여기 변수에 반영된다(가정 기록은 순수 함수라 새 객체를 만든다).
196
229
  */
197
- const ctx = {
198
- get state() {
199
- return state;
200
- },
201
- set state(v) {
202
- state = v;
203
- },
204
- goal,
205
- cwd,
206
- logger,
207
- notify,
208
- gate,
209
- nextAfterReview,
210
- nextAfterCommit,
211
- statePath,
212
- baselineFiles,
213
- designReviewEnabled,
230
+ const ctx = buildStageContext({
231
+ getState: () => state,
232
+ setState: (v) => { state = v; },
214
233
  save: () => saveState(statePath, state),
234
+ goal, cwd, logger, notify, gate,
235
+ nextAfterReview, nextAfterCommit, statePath, baselineFiles, designReviewEnabled,
215
236
  ports: {
216
- discoverer,
217
- codeReviewer,
218
- designReviewer,
219
- shipper,
220
- watcher,
221
- brancher,
222
- committer,
223
- agentRunner,
224
- testRunner,
225
- integrityChecker,
226
- checkpointer,
227
- fileLister,
228
- changeLister,
229
- treeKeyReader,
230
- specVerifier,
237
+ discoverer, codeReviewer, designReviewer, shipper, watcher, brancher, committer,
238
+ agentRunner, testRunner, integrityChecker, checkpointer, fileLister, changeLister,
239
+ treeKeyReader, specVerifier,
231
240
  },
232
241
  opts: {
233
- shipCommand,
234
- watchCommand,
235
- onShipFail,
236
- onWatchFail,
237
- watchForMs,
238
- watchEveryMs,
239
- watchTolerate,
240
- maxReviewRounds,
241
- maxLoops,
242
- budgetUsd,
243
- testCommand,
244
- stagnationLimit,
245
- verifySpec,
246
- flakyProbe,
247
- checks,
248
- handoffEvery,
249
- maxDesignRounds,
250
- stopAfter,
251
- commit,
252
- commitMessage,
253
- commitStyle,
242
+ shipCommand, watchCommand, onShipFail, onWatchFail, watchForMs, watchEveryMs,
243
+ watchTolerate, maxReviewRounds, maxLoops, budgetUsd, testCommand, stagnationLimit,
244
+ verifySpec, flakyProbe, checks, handoffEvery, maxDesignRounds, stopAfter,
245
+ commit, commitMessage, commitStyle,
254
246
  },
255
- };
247
+ });
256
248
 
257
249
  // ---- stage 머신 ----
258
250
  // 다음 stage 를 정하는 권한은 여기 한 곳에만 있다. 핸들러는 지시만 돌려준다.
@@ -297,4 +289,3 @@ async function runLoopBody({
297
289
  }
298
290
 
299
291
  export default runLoop;
300
-
package/src/mcp-server.js CHANGED
@@ -8,12 +8,24 @@ import { loadState, saveState, statePathFor, summarizeState, resetState } from '
8
8
  import { applyAnswer, formatAsk } from './ask.js';
9
9
  import { rollbackTo } from './checkpoint.js';
10
10
  import { runSetup } from '../bin/setup.js';
11
+ import { preflightAgent } from './preflight.js';
11
12
 
12
13
  const log = (line) => process.stderr.write(`[hi-loop-mcp] ${line}\n`);
13
14
 
14
15
  const text = (t) => ({ content: [{ type: 'text', text: t }] });
15
16
  const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
16
17
 
18
+ /**
19
+ * MCP 경로의 기본 작업 디렉터리.
20
+ *
21
+ * CLI 는 `process.cwd()` 만 본다 — 터미널에서 cd 한 곳이 곧 사용자의 명시 의도이기 때문이다.
22
+ * 그러나 MCP 는 호스트(클로드코드/커서)가 서버를 띄우는 경로이고, "어느 프로젝트인가"의
23
+ * 정답은 호스트가 주입하는 `CLAUDE_PROJECT_DIR` 다. 서버 프로세스의 cwd 가 프로젝트 루트와
24
+ * 다른 호스트에서도 올바른 루트를 잡으려면 이 env 를 먼저 본다. 호출자가 cwd 를 명시하면
25
+ * 그것이 최우선이다(각 핸들러의 인자가 이 기본값을 덮는다).
26
+ */
27
+ const defaultCwd = () => process.env.CLAUDE_PROJECT_DIR || process.cwd();
28
+
17
29
  /** 도구 구현 (SDK 없이도 단위 테스트 가능하도록 분리) */
18
30
  export const tools = {
19
31
  hiloop_run: {
@@ -28,9 +40,16 @@ export const tools = {
28
40
  ship,
29
41
  watch,
30
42
  stopAfter,
31
- cwd = process.cwd(),
43
+ spec,
44
+ reconcile,
45
+ reconcileSpec,
46
+ cwd = defaultCwd(),
32
47
  }) => {
33
48
  if (!goal) return fail('goal 은 필수입니다.');
49
+ // 루프를 돌리기 전에 에이전트가 실행 가능한지 확인한다 — 미설치/실행불가면
50
+ // 회차를 태우지 않고 여기서 행동지침을 돌려준다(호스트 LLM 이 사용자에게 전달).
51
+ const pre = await preflightAgent();
52
+ if (!pre.ok) return fail(pre.message);
34
53
  const result = await runLoop({
35
54
  goal,
36
55
  testCommand,
@@ -40,6 +59,9 @@ export const tools = {
40
59
  shipCommand: ship || null,
41
60
  watchCommand: watch || null,
42
61
  stopAfter: stopAfter ? String(stopAfter).toUpperCase() : null,
62
+ specPath: spec || null,
63
+ reconcile: reconcile === undefined ? null : Boolean(reconcile),
64
+ reconcileSpec: reconcileSpec || null,
43
65
  cwd,
44
66
  logger: log,
45
67
  });
@@ -66,7 +88,7 @@ export const tools = {
66
88
  hiloop_answer: {
67
89
  description:
68
90
  'hiloop_run 이 반환한 대기 중인 질문에 답한다. choice 는 제시된 선택지의 키(a, b, ...)이고, note 로 자유 서술을 덧붙이거나 대신할 수 있다. 답한 뒤 hiloop_run 을 같은 goal 로 다시 호출하면 이어서 진행된다.',
69
- handler: async ({ choice, note = '', cwd = process.cwd() } = {}) => {
91
+ handler: async ({ choice, note = '', cwd = defaultCwd() } = {}) => {
70
92
  const statePath = statePathFor(cwd);
71
93
  const state = loadState(statePath);
72
94
  if (!state?.ask) return fail('대기 중인 질문이 없습니다.');
@@ -79,17 +101,17 @@ export const tools = {
79
101
  },
80
102
  hiloop_status: {
81
103
  description: '현재 워크스페이스의 .agent-state.json 루프 상태 요약을 반환한다.',
82
- handler: async ({ cwd = process.cwd() } = {}) => text(summarizeState(loadState(statePathFor(cwd)))),
104
+ handler: async ({ cwd = defaultCwd() } = {}) => text(summarizeState(loadState(statePathFor(cwd)))),
83
105
  },
84
106
  hiloop_reset: {
85
107
  description: '.agent-state.json 을 삭제해 루프 상태를 초기화한다.',
86
- handler: async ({ cwd = process.cwd() } = {}) =>
108
+ handler: async ({ cwd = defaultCwd() } = {}) =>
87
109
  text(resetState(statePathFor(cwd)) ? '상태를 초기화했습니다.' : '초기화할 상태가 없습니다.'),
88
110
  },
89
111
  hiloop_rollback: {
90
112
  description:
91
113
  'git 체크포인트로 파일을 되돌린다. to(회차)를 주면 그 회차 직전 스냅샷으로, 없으면 가장 최근 체크포인트로 복원한다.',
92
- handler: async ({ to, cwd = process.cwd() } = {}) => {
114
+ handler: async ({ to, cwd = defaultCwd() } = {}) => {
93
115
  const state = loadState(statePathFor(cwd));
94
116
  const checkpoints = state?.checkpoints ?? [];
95
117
  if (checkpoints.length === 0)
@@ -112,7 +134,7 @@ export const tools = {
112
134
  hiloop_setup: {
113
135
  description:
114
136
  '프로젝트에 hi-loop 설정(.mcp.json MCP 서버 등록, .gitignore 항목, docs/tests 디렉터리)을 자동 주입한다. dryRun 이면 변경 없이 계획만 보여준다.',
115
- handler: async ({ dryRun = false, cwd = process.cwd() } = {}) => {
137
+ handler: async ({ dryRun = false, cwd = defaultCwd() } = {}) => {
116
138
  const report = runSetup({ cwd, dryRun: Boolean(dryRun) });
117
139
  return text(report.lines.join('\n'));
118
140
  },
@@ -125,6 +147,8 @@ export function wrap(handler) {
125
147
  try {
126
148
  return await handler(args ?? {});
127
149
  } catch (err) {
150
+ // 환경 오류(에이전트 미설치·인증 만료 등)는 이미 행동지침 메시지다 — 그대로 전달한다.
151
+ if (err?.kind === 'environment') return fail(err.message);
128
152
  return fail(`hi-loop 오류: ${err?.message ?? String(err)}`);
129
153
  }
130
154
  };
@@ -147,7 +171,10 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
147
171
  }
148
172
 
149
173
  const server = new McpServer({ name: 'hi-loop', version });
150
- const cwdSchema = z.string().optional().describe('작업 디렉터리 (기본: 서버 프로세스의 cwd)');
174
+ const cwdSchema = z
175
+ .string()
176
+ .optional()
177
+ .describe('작업 디렉터리 (기본: CLAUDE_PROJECT_DIR → 없으면 서버 프로세스의 cwd)');
151
178
 
152
179
  server.registerTool(
153
180
  'hiloop_run',
@@ -165,6 +192,18 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
165
192
  .enum(['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH'])
166
193
  .optional()
167
194
  .describe('이 단계까지만 진행하고 멈춘다'),
195
+ spec: z
196
+ .string()
197
+ .optional()
198
+ .describe('스펙 오라클을 이 파일로 고정한다(예: docs/spec.md). 사람이 관리하는 표준 문서를 여러 요청이 공유하는 오라클로 삼아 문서↔소스 drift 를 막는다. 이미 내용이 있으면 PLAN 이 덮어쓰지 않는다.'),
199
+ reconcile: z
200
+ .boolean()
201
+ .optional()
202
+ .describe('문서 정합 게이트. 기존 표준 문서와 이 요청이 모순되면 조용히 포크하지 않고 사람에게 묻는다(고정/분기/중단). full=true 또는 reconcileSpec 지정 시 자동 켜짐.'),
203
+ reconcileSpec: z
204
+ .string()
205
+ .optional()
206
+ .describe('정합 게이트가 대조할 표준 문서 경로(기본 docs/spec.md). 예: docs/design.md. 지정하면 게이트가 켜진다.'),
168
207
  cwd: cwdSchema,
169
208
  },
170
209
  },
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 에이전트 실행 환경 사전 점검(preflight) 및 실패 진단 — CLI/MCP 공용.
3
+ *
4
+ * 목적: "claude 가 없다 / 인증이 끊겼다 / 키가 없다"를 루프 한복판의 스택트레이스가 아니라
5
+ * **실행 전(또는 첫 실패 시) 한 줄의 행동지침**으로 바꾼다. 이 엔진의 전제는 "에이전트가
6
+ * 코드를 고친다"이므로, 에이전트를 못 부르는 것은 버그가 아니라 환경 문제다 — 그렇게 보여줘야 한다.
7
+ *
8
+ * 두 갈래로 나눠 본다(하나로 못 합치는 이유가 있다):
9
+ * - 설치/PATH/권한 문제(ENOENT·EACCES)는 API 를 때리지 않고 `--version` 으로 **공짜로** 잡힌다.
10
+ * 그래서 루프 시작 전에 미리(preflight) 확인한다.
11
+ * - 인증/구독/키 문제는 실제 호출 전에는 알 수 없다(`--version` 은 인증을 요구하지 않는다).
12
+ * 비용을 들여 사전 점검하지 않는다 — 대신 첫 호출의 stderr 를 패턴으로 읽어 힌트로 번역한다.
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+
16
+ /**
17
+ * 환경/설정 오류임을 표시하는 에러. `kind==='environment'` 를 보고 CLI 최상위 핸들러는
18
+ * 스택트레이스 대신 메시지 한 줄만 내고, MCP 래퍼는 "hi-loop 오류:" 접두 없이 그대로 전달한다.
19
+ */
20
+ export class AgentEnvError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'AgentEnvError';
24
+ this.kind = 'environment';
25
+ }
26
+ }
27
+
28
+ /** spawn 자체가 실패한 원인(ENOENT 등)을 행동지침으로 번역한다. */
29
+ export function describeSpawnError(err, command) {
30
+ if (err?.code === 'ENOENT') {
31
+ return (
32
+ `에이전트 명령 '${command}' 를 찾을 수 없습니다.\n` +
33
+ ` - Claude Code CLI 설치를 확인하세요: npm i -g @anthropic-ai/claude-code\n` +
34
+ ` - 다른 에이전트/경로를 쓰려면 HILOOP_AGENT_CMD 로 지정하세요 (예: HILOOP_AGENT_CMD=/usr/local/bin/claude)`
35
+ );
36
+ }
37
+ if (err?.code === 'EACCES') {
38
+ return `에이전트 명령 '${command}' 를 실행할 권한이 없습니다 (EACCES). 실행 권한을 확인하세요: chmod +x "$(command -v ${command})"`;
39
+ }
40
+ return `에이전트 실행 실패(${command}): ${err?.message ?? String(err)}`;
41
+ }
42
+
43
+ /**
44
+ * 비정상 종료 stderr 에서 인증/구독/키 문제의 신호를 찾아 힌트를 만든다.
45
+ * 못 찾으면 null — 그때는 원문을 그대로 보여준다(우리가 모르는 실패를 인증 문제로 감추지 않는다).
46
+ * 순서가 곧 우선순위다: 구체적인 신호(키·결제)를 일반적인 신호(로그인)보다 먼저 맞춘다.
47
+ */
48
+ const AUTH_PATTERNS = [
49
+ [/invalid api key|authentication_error|x-api-key|unauthorized|\b401\b/i, 'API 키가 없거나 유효하지 않습니다.'],
50
+ [/credit balance|insufficient|quota|billing|payment required|402/i, '크레딧/결제 한도 문제로 보입니다.'],
51
+ [/subscription|expired|plan (?:limit|expired)|entitlement/i, '구독/플랜이 만료됐거나 유효하지 않을 수 있습니다.'],
52
+ [/\/login|please log ?in|not logged in|run .*login|re-?authenticate|sign in/i, '로그인이 필요합니다.'],
53
+ [/api key|anthropic_api_key/i, 'API 키 설정을 확인하세요.'],
54
+ ];
55
+
56
+ export function detectAuthFailure(stderr) {
57
+ const text = typeof stderr === 'string' ? stderr : '';
58
+ for (const [re, hint] of AUTH_PATTERNS) {
59
+ if (re.test(text)) {
60
+ return `${hint} 에이전트 인증 상태를 확인하세요 — 예: 'claude' 로 로그인하거나 ANTHROPIC_API_KEY 를 설정하세요.`;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * 에이전트 바이너리가 실제로 실행 가능한지 `--version` 으로 확인한다(설치/PATH/권한).
68
+ * 인증을 요구하지 않으므로 비용 0. 인증은 여기서 검증하지 않는다.
69
+ * runner 를 주입할 수 있어 테스트에서 실제 spawn 없이 검증한다.
70
+ *
71
+ * 반환: `{ ok: true, code }` | `{ ok: false, err }` (spawn 실패) | `{ ok: false, code }` (비정상 종료)
72
+ */
73
+ export function makeVersionProbe({ timeoutMs = 15000 } = {}) {
74
+ return (command) =>
75
+ new Promise((resolve) => {
76
+ let child;
77
+ try {
78
+ child = spawn(command, ['--version'], { env: process.env });
79
+ } catch (err) {
80
+ resolve({ ok: false, err });
81
+ return;
82
+ }
83
+ const timer = setTimeout(() => {
84
+ child.kill('SIGKILL');
85
+ resolve({ ok: false, err: new Error(`'${command} --version' 이 ${timeoutMs}ms 안에 응답하지 않았습니다.`) });
86
+ }, timeoutMs);
87
+ child.on('error', (err) => {
88
+ clearTimeout(timer);
89
+ resolve({ ok: false, err });
90
+ });
91
+ child.on('close', (code) => {
92
+ clearTimeout(timer);
93
+ resolve({ ok: code === 0, code });
94
+ });
95
+ });
96
+ }
97
+
98
+ /**
99
+ * 루프 시작 전 에이전트 실행 가능성을 확인한다.
100
+ *
101
+ * **오탐 방지 원칙**: 진짜로 못 부르는 경우(spawn 실패=ENOENT·EACCES)에만 막는다.
102
+ * 바이너리가 실행은 됐는데 `--version` 이 0 이 아니면, 커스텀 에이전트가 `--version` 을
103
+ * 지원 안 하는 것일 수 있으므로 **막지 않는다** — 존재는 이미 증명됐다. 멀쩡한 셋업을
104
+ * preflight 가 죽이는 것이 못 잡는 것보다 나쁘다.
105
+ *
106
+ * 반환: `{ ok: true, warning? }` | `{ ok: false, message }`
107
+ */
108
+ export async function preflightAgent({
109
+ command = process.env.HILOOP_AGENT_CMD || 'claude',
110
+ probe = makeVersionProbe(),
111
+ } = {}) {
112
+ const res = await probe(command);
113
+ if (res.ok) return { ok: true };
114
+ if (res.err) return { ok: false, message: describeSpawnError(res.err, command) };
115
+ return {
116
+ ok: true,
117
+ warning: `'${command} --version' 이 code ${res.code} 로 끝났지만 명령은 존재하므로 진행합니다.`,
118
+ };
119
+ }
package/src/prompts.js CHANGED
@@ -42,19 +42,26 @@ export function contextBlock(state) {
42
42
  return parts.join('\n\n');
43
43
  }
44
44
 
45
- /** PLAN: 코드보다 spec.md + 테스트 먼저 (FR-2.1) */
45
+ /** PLAN: 코드보다 spec.md + 테스트 먼저 (FR-2.1). --spec 고정 시 표준 문서를 오라클로 삼는다(FR-17). */
46
46
  export function planPrompt(state) {
47
+ // 고정된 스펙(--spec)은 사람이 관리하는 표준 문서일 수 있다 — PLAN 이 덮어쓰면 오라클이 오염된다.
48
+ // 그래서 "이미 내용이 있으면 수정하지 말고 그대로 오라클로 삼으라"고 지시한다. 비었을 때만 작성한다.
49
+ const specStep = state.specPinned
50
+ ? `1. \`${specPathOf(state)}\` 는 사용자가 **고정한 표준 스펙 문서(오라클)**다.
51
+ - 이미 내용이 있으면 **절대 수정하지 마라.** 그대로 진실로 받아들이고, 이 스펙에 맞춰 아래 테스트만 작성하라.
52
+ - 비어 있거나 없을 때만 이 경로에 스펙을 작성하라.`
53
+ : `1. \`${specPathOf(state)}\` 를 작성하라: 목표 해석, 상세 요구사항, 공개 API 시그니처, 수용 기준 목록.`;
47
54
  return `당신은 TDD를 강제하는 수석 아키텍트다. 지금은 PLAN 단계다.
48
55
 
49
56
  ${contextBlock(state)}
50
57
 
51
58
  ## PLAN 단계에서 할 일 (이것만)
52
- 1. \`${specPathOf(state)}\` 를 작성하라: 목표 해석, 상세 요구사항, 공개 API 시그니처, 수용 기준 목록.
59
+ ${specStep}
53
60
  2. \`${testPathOf(state)}\` 를 작성하라: 위 수용 기준을 검증하는 실행 가능한 테스트.
54
61
  - Node 내장 \`node:test\` + \`node:assert/strict\` 를 사용하라.
55
62
  - 아직 존재하지 않는 구현을 import 해도 된다(이 단계에서 테스트는 실패하는 게 정상이다).
56
63
  3. **구현 코드는 절대 작성하지 마라.** 이번 단계 산출물은 spec과 테스트뿐이다.
57
- 4. 위 경로에만 써라. 다른 기존 파일은 건드리지 마라.
64
+ 4. 위 경로에만 써라. 다른 기존 파일은 건드리지 마라.
58
65
 
59
66
  작업을 마치면 스펙 핵심을 15줄 이내로 요약해 마지막에 출력하라.
60
67
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 문서 정합 게이트 (FR-21) — 새 요청이 기존 표준 문서와 모순되는가.
3
+ *
4
+ * 기본 동작은 goal 마다 스펙을 새로 포크한다(state.planPathsFor). 그래서 단말적 요청이
5
+ * 기존 `docs/spec.md` 와 어긋나도 조용히 갈라져 나갈 뿐, 아무도 그 모순을 보지 못한다 —
6
+ * 문서↔소스 갭의 근원이고, 그 갭이 환각의 빌미가 된다.
7
+ *
8
+ * 판정이 아니라 **표면화**다. 모순이면 상호배타 분기(ask, FR-16)로 멈춰 사람이 고른다:
9
+ * 기존 문서를 오라클로 고정(FR-19) / 새 스펙으로 분기 / 중단. `--verify-spec` 처럼 별도
10
+ * 세션·plan 모드(쓰기 금지)로 부르므로 구현자의 추론에 오염되지 않는다. opt-in(비용 증가).
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { buildAgentArgs, parseAgentOutput } from './runners.js';
16
+
17
+ const truncate = (s, n) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}…` : String(s ?? ''));
18
+
19
+ export function reconcilePrompt({ goal, specText }) {
20
+ return `당신은 새 요청과 기존 표준 스펙 문서의 **정합성**만 판정하는 독립 심판자다. 코드를 고치지 마라 — 판정만 한다.
21
+
22
+ ## 기존 표준 스펙 문서
23
+ ${truncate(specText, 6000)}
24
+
25
+ ## 새 요청(goal)
26
+ ${truncate(goal, 2000)}
27
+
28
+ ## 판정
29
+ - 이 요청이 위 문서와 **모순**되는가? 문서가 정한 동작·제약·범위를 뒤집거나 어기면 모순이다.
30
+ - 문서에 없는 것을 **추가**하는 것은 모순이 아니다(확장은 정상). 확신이 없으면 ok 로 판정하라.
31
+
32
+ JSON 한 줄로만 답하라:
33
+ {"verdict": "ok" 또는 "conflict", "reason": "무엇이 어긋나는가(한국어 1~2문장)"}`;
34
+ }
35
+
36
+ /** 판정을 관대하게 파싱한다. 형식이 깨지면 ok 로 흘린다(애매한 것까지 막으면 정상 작업이 멈춘다). */
37
+ export function parseReconcile(text) {
38
+ try {
39
+ const m = String(text).match(/\{[\s\S]*"verdict"[\s\S]*\}/);
40
+ const obj = JSON.parse(m ? m[0] : text);
41
+ return {
42
+ verdict: obj.verdict === 'conflict' ? 'conflict' : 'ok',
43
+ reason: typeof obj.reason === 'string' ? obj.reason : '',
44
+ };
45
+ } catch {
46
+ return { verdict: 'ok', reason: '정합 판정을 파싱하지 못해 통과로 처리' };
47
+ }
48
+ }
49
+
50
+ /** gate() 에 넘길 ask 스펙 — 상호배타 3지선다(고정 / 분기 / 중단). */
51
+ export function reconcileAsk({ standingSpec, reason }) {
52
+ return {
53
+ stage: 'RECONCILE',
54
+ question: `기존 표준 문서(${standingSpec})와 이 요청이 어긋납니다:\n${reason}\n어떻게 진행할까요?`,
55
+ options: [
56
+ { key: 'a', label: `${standingSpec} 를 오라클로 고정하고 진행`, impact: '요청을 문서에 맞춘다' },
57
+ { key: 'b', label: '새 스펙으로 분기해 진행', impact: '요청이 문서를 갱신/대체한다' },
58
+ { key: 'c', label: '중단 — 문서를 먼저 정리하겠다', impact: '아무것도 실행하지 않는다' },
59
+ ],
60
+ };
61
+ }
62
+
63
+ /**
64
+ * 주입 가능한 정합 판정 포트 (NFR-3). `({ goal, specPath, cwd }) => { verdict, reason }`.
65
+ * 별도 claude 를 plan 모드·새 세션으로 spawn 한다. 검증 계열과 같은 env 폴백 사슬을 쓴다.
66
+ */
67
+ export function makeReconciler({
68
+ command = process.env.HILOOP_RECONCILE_CMD || process.env.HILOOP_VERIFY_CMD || process.env.HILOOP_AGENT_CMD || 'claude',
69
+ model = process.env.HILOOP_RECONCILE_MODEL || process.env.HILOOP_VERIFY_MODEL || '',
70
+ timeoutMs = 5 * 60 * 1000,
71
+ } = {}) {
72
+ return ({ goal, specPath, cwd }) =>
73
+ new Promise((resolve) => {
74
+ const full = specPath ? join(cwd, specPath) : null;
75
+ if (!full || !existsSync(full)) {
76
+ resolve({ verdict: 'ok', reason: '표준 문서가 없어 정합 검사 생략' });
77
+ return;
78
+ }
79
+ const prompt = reconcilePrompt({ goal, specText: readFileSync(full, 'utf8') });
80
+ const extraArgs = model ? ['--model', model] : [];
81
+ const args = buildAgentArgs({ prompt, sessionId: null, extraArgs, permissionMode: 'plan' });
82
+ const child = spawn(command, args, { cwd, env: process.env });
83
+ let stdout = '';
84
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
85
+ child.stdout.on('data', (d) => (stdout += d));
86
+ child.stderr.on('data', () => {});
87
+ child.on('error', () => {
88
+ clearTimeout(timer);
89
+ // 판정기 실행 실패가 정상 작업을 막으면 안 된다(인프라 fail-open).
90
+ resolve({ verdict: 'ok', reason: '정합 판정기 실행 실패 — 통과로 처리' });
91
+ });
92
+ child.on('close', (code) => {
93
+ clearTimeout(timer);
94
+ if (code !== 0) {
95
+ resolve({ verdict: 'ok', reason: `정합 판정기가 코드 ${code}로 종료 — 통과로 처리` });
96
+ return;
97
+ }
98
+ resolve(parseReconcile(parseAgentOutput(stdout).text));
99
+ });
100
+ });
101
+ }
package/src/resume.js CHANGED
@@ -34,7 +34,7 @@ function carryOver(prev) {
34
34
  };
35
35
  }
36
36
 
37
- export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd }) {
37
+ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd, specPath: pinnedSpecPath = null }) {
38
38
  const raw = existsSync(statePath) ? loadState(statePath) : null;
39
39
  // L16: 디스크의 상태는 신뢰 경계 밖이다. 봉인이 깨졌으면 게이트를 여는 필드만
40
40
  // 안전값으로 되돌린다 — goal·회차·비용 같은 나머지는 판정을 열지 않으므로 그대로 둔다.
@@ -44,16 +44,19 @@ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd })
44
44
  prev.sealBroken = true;
45
45
  }
46
46
  if (prev && prev.goal === goal && prev.status === 'passed') {
47
- return { ...createState({ goal, testCommand, maxLoops, cwd }), ...carryOver(prev) };
47
+ return { ...createState({ goal, testCommand, maxLoops, cwd, specPath: pinnedSpecPath }), ...carryOver(prev) };
48
48
  }
49
49
  if (prev && prev.goal === goal && prev.status !== 'passed') {
50
50
  // 필드가 없던 시절의 상태 파일도 이어받는다. 경로는 절대 다시 계산하지 않는다 —
51
51
  // PLAN 이 이미 만든 파일 때문에 경로가 밀려나기 때문이다.
52
- const paths = prev.specPath && prev.testPath ? {} : planPathsFor({ cwd, goal });
52
+ const paths = prev.specPath && prev.testPath ? {} : planPathsFor({ cwd, goal, specPath: pinnedSpecPath });
53
53
  return {
54
54
  ...paths,
55
55
  ...prev,
56
- specPath: prev.specPath ?? paths.specPath,
56
+ // 명시 고정(--spec)은 저장된 경로보다 우선한다 — 사용자가 이번에 오라클을 바꾼 것이다.
57
+ specPath: pinnedSpecPath ?? prev.specPath ?? paths.specPath,
58
+ specPinned: pinnedSpecPath ? true : Boolean(prev.specPinned),
59
+ reconciled: Boolean(prev.reconciled),
57
60
  testPath: prev.testPath ?? paths.testPath,
58
61
  costUsd: Number.isFinite(prev.costUsd) ? prev.costUsd : 0,
59
62
  // 기준선이 없던 시절의 상태로 재개하면 이번 회차 지문이 곧 기준선이 된다.
@@ -89,5 +92,5 @@ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd })
89
92
  status: prev.ask ? 'awaiting' : 'running',
90
93
  };
91
94
  }
92
- return createState({ goal, testCommand, maxLoops, cwd });
95
+ return createState({ goal, testCommand, maxLoops, cwd, specPath: pinnedSpecPath });
93
96
  }
package/src/runners.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * 루프 엔진은 이 함수들을 주입받으므로, 테스트에서는 스텁으로 대체된다.
4
4
  */
5
5
  import { spawn } from 'node:child_process';
6
+ import { AgentEnvError, describeSpawnError, detectAuthFailure } from './preflight.js';
6
7
 
7
8
  /**
8
9
  * 양의 정수 환경변수를 읽되, 없거나 쓰레기값이면 기본값으로 조용히 되돌린다.
@@ -94,12 +95,17 @@ export function makeAgentRunner({
94
95
  });
95
96
  child.on('error', (err) => {
96
97
  clearTimeout(timer);
97
- reject(new Error(`에이전트 실행 실패(${command}): ${err.message}`));
98
+ // spawn 실패(미설치·권한) 환경 오류다 — 스택 대신 행동지침으로 표시한다.
99
+ reject(new AgentEnvError(describeSpawnError(err, command)));
98
100
  });
99
101
  child.on('close', (code) => {
100
102
  clearTimeout(timer);
101
103
  if (code !== 0) {
102
- reject(new Error(`에이전트가 코드 ${code}로 종료했습니다.\n${stderr.slice(-2000)}`));
104
+ // 비정상 종료의 stderr 에서 인증/구독/키 신호를 찾으면 힌트를 앞에 붙이고
105
+ // 환경 오류로 분류한다. 못 찾으면 우리가 모르는 실패이므로 원문 tail 을 그대로 던진다.
106
+ const hint = detectAuthFailure(stderr);
107
+ const tail = `에이전트가 코드 ${code}로 종료했습니다.\n${stderr.slice(-2000)}`;
108
+ reject(hint ? new AgentEnvError(`${hint}\n\n${tail}`) : new Error(tail));
103
109
  return;
104
110
  }
105
111
  resolve(parseAgentOutput(stdout, sessionId));
@@ -0,0 +1,46 @@
1
+ /**
2
+ * stage 핸들러 실행 맥락 조립 (loop.js 에서 분리, NFR-5).
3
+ *
4
+ * loop.js 는 "다음 stage 를 정하는" 책임만 남기고, "무엇을 핸들러에 넘기는가"는 여기 모은다.
5
+ * `state` 는 getter/setter 라 핸들러가 재대입해도 loop.js 의 `let state` 에 반영된다
6
+ * (가정 기록은 순수 함수라 새 객체를 만들기 때문에 이 반영이 필요하다).
7
+ */
8
+ export function buildStageContext({
9
+ getState,
10
+ setState,
11
+ save,
12
+ goal,
13
+ cwd,
14
+ logger,
15
+ notify,
16
+ gate,
17
+ nextAfterReview,
18
+ nextAfterCommit,
19
+ statePath,
20
+ baselineFiles,
21
+ designReviewEnabled,
22
+ ports,
23
+ opts,
24
+ }) {
25
+ return {
26
+ get state() {
27
+ return getState();
28
+ },
29
+ set state(v) {
30
+ setState(v);
31
+ },
32
+ goal,
33
+ cwd,
34
+ logger,
35
+ notify,
36
+ gate,
37
+ nextAfterReview,
38
+ nextAfterCommit,
39
+ statePath,
40
+ baselineFiles,
41
+ designReviewEnabled,
42
+ save,
43
+ ports,
44
+ opts,
45
+ };
46
+ }
package/src/state.js CHANGED
@@ -86,10 +86,12 @@ export function statePathFor(cwd) {
86
86
  * goal 해시를 쓰는 이유: 같은 goal 이면 같은 경로가 나와야 resume 이 이어지고,
87
87
  * 다른 goal 끼리는 충돌하지 않는다. 한국어 goal 도 파일명이 깨지지 않는다.
88
88
  */
89
- export function planPathsFor({ cwd, goal }) {
89
+ export function planPathsFor({ cwd, goal, specPath = null }) {
90
90
  const slug = createHash('sha1').update(String(goal)).digest('hex').slice(0, 8);
91
91
  return {
92
- specPath: existsSync(join(cwd, DEFAULT_SPEC_PATH)) ? `docs/spec-${slug}.md` : DEFAULT_SPEC_PATH,
92
+ // `--spec` 으로 명시 고정하면 goal 해시로 비켜가지 않는다 — 그게 오라클 고정의 핵심이다.
93
+ // 사람이 관리하는 표준 문서를 여러 goal 이 같은 오라클로 공유해 문서↔소스 drift 를 막는다.
94
+ specPath: specPath || (existsSync(join(cwd, DEFAULT_SPEC_PATH)) ? `docs/spec-${slug}.md` : DEFAULT_SPEC_PATH),
93
95
  testPath: existsSync(join(cwd, DEFAULT_TEST_PATH)) ? `tests/app-${slug}.test.js` : DEFAULT_TEST_PATH,
94
96
  };
95
97
  }
@@ -107,11 +109,12 @@ export function createState({
107
109
  testCommand,
108
110
  maxLoops,
109
111
  cwd = process.cwd(),
112
+ specPath: pinnedSpecPath = null,
110
113
  now = new Date().toISOString(),
111
114
  }) {
112
115
  // 경로는 **한 번만** 정하고 상태에 박아둔다. 매번 다시 계산하면 PLAN 이 만든 파일 때문에
113
- // 2회차부터 경로가 밀려난다.
114
- const { specPath, testPath } = planPathsFor({ cwd, goal });
116
+ // 2회차부터 경로가 밀려난다. pinnedSpecPath(--spec)가 있으면 그것으로 고정한다.
117
+ const { specPath, testPath } = planPathsFor({ cwd, goal, specPath: pinnedSpecPath });
115
118
  return {
116
119
  version: STATE_VERSION,
117
120
  goal,
@@ -125,6 +128,10 @@ export function createState({
125
128
  sessionSerial: 1,
126
129
  specPath,
127
130
  testPath,
131
+ // 사용자가 스펙 오라클을 고정했는가(--spec). PLAN 이 이 표준 문서를 덮어쓰지 않도록 신호한다.
132
+ specPinned: Boolean(pinnedSpecPath),
133
+ // FR-21: 문서 정합 게이트를 이미 거쳤는가. 같은 goal 재개 시 다시 묻지 않도록.
134
+ reconciled: false,
128
135
  specSummary: '',
129
136
  lastError: '',
130
137
  costUsd: 0,