@tuzi-ince/hi-loop 0.2.2 → 0.3.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/bin/hi-loop.js CHANGED
@@ -34,9 +34,10 @@ const USAGE = `hi-loop — 초경량 자율형 자가 치유 엔진
34
34
  [--full] [--discover | --no-discover]
35
35
  [--watch "<헬스체크 명령>"] [--watch-for 5m] [--watch-every 30s]
36
36
  [--watch-tolerate 1] [--on-watch-fail stop|rollback|heal]
37
+ [--branch [name]] [--commit] [--commit-message "..."]
37
38
  [--ask auto|never|always] [--yes]
38
39
  [--start-from <stage>] [--stop-after <stage>]
39
- stage: DISCOVER | PLAN | BUILD | CODE_REVIEW | SHIP | WATCH
40
+ stage: DISCOVER | PLAN | BUILD | CODE_REVIEW | COMMIT | SHIP | WATCH
40
41
 
41
42
  단계별 실행 (전부 같은 상태 머신을 쓴다):
42
43
  hi-loop discover --goal "<요구사항>" 발굴만 하고 멈춤 (가정 확정 + 문서 생성)
@@ -50,6 +51,8 @@ const USAGE = `hi-loop — 초경량 자율형 자가 치유 엔진
50
51
  hi-loop answer <선택> [--note "..."] 대기 중인 질문에 답하고 루프를 재개 가능 상태로 되돌림
51
52
  hi-loop rollback [--to N] [--cwd .] N회차 직전 체크포인트로 파일 복원 (기본: 최신)
52
53
  hi-loop setup [--dry-run] 프로젝트/클로드코드 설정 자동 주입
54
+ hi-loop config [set <k> <v> | add-branch <b> | remove-branch <b>]
55
+ 브랜치·커밋 정책(.hi-loop.json) 조회·변경
53
56
  hi-loop --help | --version
54
57
 
55
58
  환경변수:
@@ -76,7 +79,7 @@ const USAGE = `hi-loop — 초경량 자율형 자가 치유 엔진
76
79
  export async function main(argv = process.argv.slice(2)) {
77
80
  const args = parseArgs(argv, {
78
81
  alias: { g: 'goal', t: 'test', c: 'cwd', h: 'help', v: 'version', m: 'max-loops' },
79
- boolean: ['help', 'version', 'dry-run', 'no-stagnation', 'verify-spec', 'flaky-probe', 'yes', 'review', 'no-review', 'design-review', 'no-design-review', 'full', 'discover', 'no-discover'],
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'],
80
83
  // 개수가 정해지지 않은 입력. `--check A --when X --check B` 처럼 위치로 짝을 맺는다.
81
84
  repeat: ['check', 'when'],
82
85
  });
@@ -133,11 +136,19 @@ export async function main(argv = process.argv.slice(2)) {
133
136
  return 2;
134
137
  }
135
138
 
139
+ // 정책 병합 (FR-16): 플래그가 우선, 없으면 `.hi-loop.json`.
140
+ // 헤드리스라 물어볼 수 없으므로 'always'/'auto' 만 자동 발동한다('ask'/'confirm' 은 스킬 몫).
141
+ const { loadConfig } = await import('../src/config.js');
142
+ const config = loadConfig(cwd);
143
+ const options = { ...parsed.options, protectedBranches: config.protectedBranches, commitStyle: config.commitStyle };
144
+ if (options.branch == null && config.branchPolicy === 'always') options.branch = true;
145
+ if (!options.commit && config.commitPolicy === 'auto') options.commit = true;
146
+
136
147
  const { runLoop } = await import('../src/loop.js');
137
148
  const result = await runLoop({
138
149
  goal,
139
150
  cwd,
140
- ...parsed.options,
151
+ ...options,
141
152
  logger: (line) => process.stdout.write(`${line}\n`),
142
153
  });
143
154
  // awaiting 은 성공도 실패도 아니다. 0 을 주면 `hi-loop run && 배포` 같은 스크립트가
@@ -221,6 +232,38 @@ export async function main(argv = process.argv.slice(2)) {
221
232
  process.stdout.write(`${report.lines.join('\n')}\n`);
222
233
  return 0;
223
234
  }
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
+ }
224
267
  default:
225
268
  process.stderr.write(`알 수 없는 명령: ${command}\n\n${USAGE}\n`);
226
269
  return 2;
package/bin/setup.js CHANGED
@@ -14,6 +14,27 @@ export const GITIGNORE_ENTRY = '.agent-state.json';
14
14
  // 런타임 부산물 — 커밋되면 안 된다. 락(L4)은 pid 마다 다르고, 상태는 캐시다.
15
15
  export const GITIGNORE_ENTRIES = ['.agent-state.json', '.agent-state.lock'];
16
16
 
17
+ // CLAUDE.md 에 심는 상시 지침 — LLM 이 "기획/설계가 필요한 요청"을 hi-loop 으로
18
+ // 라우팅하도록 하는 push 표면이다. MCP 도구는 pull(모델이 먼저 떠올려야 호출)이라
19
+ // 이것만으로는 부족하다(근본원인). 마커로 감싸 멱등 갱신한다.
20
+ export const CLAUDE_MD_BEGIN = '<!-- hi-loop:begin -->';
21
+ export const CLAUDE_MD_END = '<!-- hi-loop:end -->';
22
+ export const CLAUDE_MD_BLOCK = `${CLAUDE_MD_BEGIN}
23
+ ## hi-loop 워크플로
24
+
25
+ 기능 추가·개선·리팩터처럼 **기획/설계가 선행되어야 하는 요청**은 소스만 보고 바로 구현하지 말고,
26
+ \`hi-loop\` 스킬(또는 MCP 도구 \`hiloop_run\`, \`full: true\`)로 PLAN→DESIGN→DO→CHECK→HEAL 루프를 돌린다.
27
+
28
+ - 트리거: 기획, 설계, 구현, 개선, 기능 추가, 리팩터, plan, design, feature, PDCA, TDD
29
+ - 예외: 오타·1줄 수정·문구 변경 등 사소한 작업은 루프 없이 바로 처리한다.
30
+ - MCP 도구가 세션에서 deferred 이면 ToolSearch 로 \`hiloop_run\` 스키마를 먼저 로드한다.
31
+ - 문법·기능 테스트 통과 후 **UI 변경이 있었으면** 사용자에게 e2e 진행 여부와 결과 화면
32
+ 저장 여부를 확인하고, 결과서·스크린샷을 test 폴더에 저장한다. e2e 도구(Playwright MCP·
33
+ 프로젝트 셋업)가 없으면 **임의 설치하지 말고 스킵**한다 — 통과한 루프를 실패로 만들지 않는다.
34
+ - 코드 변경 요청은 보호 브랜치(main/master/develop/dev)면 feature 로 분기하고, 테스트 통과 후
35
+ **로컬 커밋**한다(정책은 커밋되는 \`.hi-loop.json\`; push/PR 은 안 함). "이번만"과 "앞으로"를 구분한다.
36
+ ${CLAUDE_MD_END}`;
37
+
17
38
  /** 이 setup 파일과 나란히 있는 실제 CLI 진입점의 절대 경로 */
18
39
  export function cliEntryPath() {
19
40
  return join(dirname(fileURLToPath(import.meta.url)), 'hi-loop.js');
@@ -60,6 +81,27 @@ export function mergeGitignore(content) {
60
81
  return { changed, content: text };
61
82
  }
62
83
 
84
+ /**
85
+ * `CLAUDE.md` 에 hi-loop 지침 블록을 병합한다 (FR-6.5).
86
+ * 마커가 없으면 파일 끝에 붙이고, 있으면 그 사이를 최신 블록으로 교체한다(버전 갱신).
87
+ * 그 외 사용자 내용은 건드리지 않는다.
88
+ */
89
+ export function mergeClaudeMd(content) {
90
+ const text = typeof content === 'string' ? content : '';
91
+ const start = text.indexOf(CLAUDE_MD_BEGIN);
92
+ if (start !== -1) {
93
+ const endMarker = text.indexOf(CLAUDE_MD_END, start);
94
+ if (endMarker !== -1) {
95
+ const end = endMarker + CLAUDE_MD_END.length;
96
+ const current = text.slice(start, end);
97
+ if (current === CLAUDE_MD_BLOCK) return { changed: false, content: text };
98
+ return { changed: true, content: `${text.slice(0, start)}${CLAUDE_MD_BLOCK}${text.slice(end)}` };
99
+ }
100
+ }
101
+ const prefix = text.length === 0 ? '' : text.endsWith('\n\n') ? '' : text.endsWith('\n') ? '\n' : '\n\n';
102
+ return { changed: true, content: `${text}${prefix}${CLAUDE_MD_BLOCK}\n` };
103
+ }
104
+
63
105
  export function runSetup({ cwd = process.cwd(), dryRun = false } = {}) {
64
106
  const root = resolve(cwd);
65
107
  const lines = [];
@@ -104,7 +146,18 @@ export function runSetup({ cwd = process.cwd(), dryRun = false } = {}) {
104
146
  }
105
147
  }
106
148
 
107
- lines.push('', '이제 사용하세요:', ' hi-loop run --goal "요구사항" --test "npm test"', ' 또는 에이전트에서 MCP 도구 hiloop_run 호출');
149
+ // 4) CLAUDE.md 기획/설계 요청을 hi-loop 으로 라우팅하는 상시 지침 (FR-6.5)
150
+ const cmdPath = join(root, 'CLAUDE.md');
151
+ const cmd = mergeClaudeMd(existsSync(cmdPath) ? readFileSync(cmdPath, 'utf8') : '');
152
+ if (cmd.changed) {
153
+ write(cmdPath, cmd.content);
154
+ changes.push('CLAUDE.md');
155
+ lines.push(`${dryRun ? '[dry-run] ' : ''}✓ CLAUDE.md 에 hi-loop 워크플로 지침을 주입했습니다.`);
156
+ } else {
157
+ lines.push('· CLAUDE.md 지침은 이미 최신입니다.');
158
+ }
159
+
160
+ lines.push('', '이제 사용하세요:', ' hi-loop run --goal "요구사항" --test "npm test"', ' 또는 에이전트에서 MCP 도구 hiloop_run 호출 (스킬: /hi-loop)');
108
161
  return { changes, lines, dryRun };
109
162
  }
110
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuzi-ince/hi-loop",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "CLI와 MCP 모드를 지원하는 초경량 자율형 자가 치유 엔진",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@
24
24
  "files": [
25
25
  "bin",
26
26
  "src",
27
+ "skills",
27
28
  "docs",
28
29
  "README.md"
29
30
  ],
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: hi-loop
3
+ description: >-
4
+ 기획·설계가 필요한 기능 개발/개선 요청을 소스만 보고 바로 구현하지 말고, PLAN→DESIGN→DO→CHECK→HEAL
5
+ 자율 루프(hiloop_run)로 처리한다. spec/설계를 먼저 세우고 테스트가 통과할 때까지 반복하며, full=true면
6
+ 발굴·설계리뷰·코드리뷰까지 전체 라이프사이클을 돈다. Use this whenever the user asks to build or improve
7
+ a feature that benefits from planning/design before coding, or mentions the hi-loop/PDCA/TDD lifecycle.
8
+ Triggers: 기획, 설계, 구현, 개선, 기능 추가, 리팩터, plan, design, implement, feature, improve, refactor,
9
+ PDCA, TDD, lifecycle, 자가치유, self-healing, hi-loop, hiloop.
10
+ user-invocable: true
11
+ ---
12
+
13
+ # hi-loop 라이프사이클
14
+
15
+ 기능 추가·개선처럼 **기획/설계가 선행되어야 하는 작업**을 자율 자가치유 루프로 몰고 간다.
16
+ 소스만 훑고 바로 코드부터 짜는 대신, spec→설계→구현→검증→치유를 hi-loop 엔진이 돌게 한다.
17
+
18
+ ## 언제 이 스킬을 쓰나
19
+ - 사용자가 새 기능/개선/리팩터를 요청했고, 곧장 구현하기보다 **기획·설계 단계가 필요할 때**.
20
+ - 사용자가 hi-loop / PDCA / TDD 라이프사이클을 명시했을 때.
21
+ - 예외(루프 없이 바로 처리): 오타·1줄 수정·문구 변경 같은 사소한 작업.
22
+
23
+ ## 실행 절차
24
+
25
+ 1. **목표(goal) 확정.** 사용자의 요청을 한 문장의 goal로 정리한다.
26
+ 모호하면 먼저 짧게 확인한다(무엇을·어디서·성공 기준).
27
+
28
+ 2. **정책 확인 & 브랜치 게이트 (git 워크플로).** 코드를 바꾸는 요청이면 루프 전에 정책을 본다.
29
+ git 저장소가 아니면 이 단계를 건너뛴다.
30
+ - `.hi-loop.json` 을 읽는다. **없으면** 아래 "정책 관리 → 최초 1회"를 먼저 수행해 만든다.
31
+ - 현재 브랜치: `git rev-parse --abbrev-ref HEAD`.
32
+ - 현재 브랜치가 `protectedBranches`(기본 main/master/develop/dev)에 **속하면** `branchPolicy` 대로:
33
+ - `always` → `feature/<slug>` 생성·전환(`git switch -c`) 후 알린다.
34
+ - `ask` → "지금 `<브랜치>`입니다. feature 브랜치로 분기할까요? (제안: `feature/<slug>`)" 물어 승인 시 분기.
35
+ - `never` → 분기하지 않는다.
36
+ - 이미 feature 브랜치(보호 대상 아님)면 조용히 진행한다.
37
+
38
+ 3. **MCP 도구 로드.** hi-loop MCP 도구는 세션에서 *deferred*(이름만)일 수 있다.
39
+ 먼저 스키마를 불러온다:
40
+ `ToolSearch` → `select:mcp__plugin_hi-loop_hi-loop__hiloop_run,mcp__plugin_hi-loop_hi-loop__hiloop_answer`
41
+
42
+ 4. **루프 실행.** `hiloop_run` 을 호출한다:
43
+ - `goal`: 위에서 정한 목표
44
+ - `full: true` — 발굴·설계리뷰·코드리뷰를 포함한 전체 라이프사이클
45
+ - `testCommand`: 프로젝트의 테스트 명령(기본 `npm test`)
46
+ - 필요 시 `maxLoops`, `ship`, `watch`
47
+
48
+ 5. **사용자 선택 처리.** 응답이 `⏸ 사용자의 선택이 필요합니다` 로 오면,
49
+ 그 질문을 **그대로 사용자에게 제시**하고 답을 받는다. 답을 `hiloop_answer`
50
+ (`choice`, 필요하면 `note`)로 전달한 뒤, **같은 goal 로 `hiloop_run` 을 다시 호출**해 이어간다.
51
+
52
+ 6. **결과 보고.** `✅ 통과` / `❌ 실패` 와 반복 횟수, 그리고 함께 온 Gaps(검증 한계)를
53
+ 사용자에게 전한다. false green(테스트만 초록이고 실제 미완)을 통과로 포장하지 않는다.
54
+
55
+ 7. **UI 변경 시 e2e 게이트.** 문법·기능 테스트가 통과(✅)한 뒤, 이번 변경이
56
+ **UI(프론트엔드 화면·컴포넌트·라우팅·스타일)** 를 건드렸는지 판단한다.
57
+ - UI 변경이 **없으면** 이 단계를 건너뛴다(그대로 종료).
58
+ - UI 변경이 **있으면** 사용자에게 **먼저 묻는다**: "UI 변경이 있었습니다. e2e 테스트를
59
+ 진행할까요?" — 임의로 실행하지 않는다.
60
+
61
+ **e2e 실행 능력을 순서대로 탐지한다(강등 사다리).** e2e는 이미 통과한 루프의 *부가* 게이트다 —
62
+ 없다고 루프를 실패로 만들지 않는다.
63
+ 1. **Playwright MCP 도구가 세션에 있으면** 그걸로 실행한다(브라우저 구동·스크린샷 네이티브 지원).
64
+ 2. 없지만 **프로젝트에 e2e 셋업이 있으면**(`@playwright/test` devDep + `test:e2e` 스크립트,
65
+ 또는 `playwright.config.*`, 또는 `e2e` 스킬) 그 경로로 실행한다.
66
+ 3. **둘 다 없으면** e2e 도구를 **임의로 설치하지 않는다** — Playwright + 브라우저 바이너리는
67
+ 수백 MB다. 사용자에게 상황을 알리고 선택지를 준다: (a) 설치 후 진행, (b) 이미 있는 다른
68
+ 도구 사용, (c) 건너뛰기. 어느 쪽도 강요하지 않고, **이 게이트를 "스킵"으로 기록**한다.
69
+
70
+ - e2e를 실제로 실행하기로 했으면 **결과 화면(스크린샷) 저장 여부도 사용자에게 묻는다**.
71
+ 스크린샷은 **브라우저를 구동하는 도구(1·2)가 있을 때만** 가능하다 — 없으면 저장할 화면이
72
+ 없음을 밝힌다.
73
+ - 완료되면 **테스트 결과서와 스크린샷을 프로젝트의 test 폴더에 저장**한다
74
+ (예: 리포트 `tests/e2e/report-<날짜시각>.md`, 스크린샷 `tests/e2e/screenshots/`).
75
+ 날짜·시각은 실제 값으로 채운다. 저장 경로를 사용자에게 알린다.
76
+
77
+ 8. **커밋 게이트 (git 워크플로).** 테스트가 통과(✅)하고 e2e 게이트까지 끝나면 `commitPolicy` 대로
78
+ **로컬** 커밋한다. **push/PR 은 하지 않는다.** git 저장소가 아니면 건너뛴다.
79
+ - `off` → 커밋하지 않는다(사용자가 직접).
80
+ - 메시지는 **기존 `git log` 스타일에 맞춘다**(`git log --oneline -20` 로 언어·규칙 파악).
81
+ `commitStyle` 이 `conventional`/`korean` 이면 그 형식.
82
+ - `confirm` → `git status` 와 diff 요약을 제시하고 승인받는다. **미관련 변경이 섞였으면 반드시
83
+ 드러낸다**(몰래 `git add -A` 하지 않는다). 승인 시 커밋.
84
+ - `auto` → 확인 없이 커밋.
85
+ - 커밋 후 SHA·메시지를 알린다.
86
+
87
+ ## 정책 관리 (.hi-loop.json)
88
+
89
+ 브랜치·커밋 정책은 프로젝트 루트의 **커밋되는** `.hi-loop.json` 하나에 산다(팀 공유, 스킬·엔진 공용).
90
+ `.agent-state.json`(임시·gitignore)과 다르다.
91
+
92
+ ### 최초 1회 (지연 발동)
93
+ 코드변경 요청인데 `.hi-loop.json` 이 **없으면**, 진행 전에 딱 한 번 묻고 저장한다:
94
+ > "보호 브랜치(main/master/develop/dev)일 때 매 요청을 feature 브랜치로 분기·관리할까요?
95
+ > (always/ask/never) 그리고 테스트 통과 후 커밋은? (auto/confirm/off)"
96
+
97
+ 답을 `branchPolicy`·`commitPolicy` 로 파일에 쓴다. 파일을 직접 작성하거나
98
+ `hi-loop config set <key> <value>` 를 쓴다(둘 다 같은 파일). 이후엔 조용히 적용한다(다시 안 묻는다).
99
+
100
+ ### 정책 변경 요청 (평문)
101
+ 사용자가 말로 정책을 바꾸면 `.hi-loop.json` 을 갱신하고 확인한다:
102
+ - "release 도 보호 대상에 넣어줘" → `protectedBranches` 에 추가 (`hi-loop config add-branch release`)
103
+ - "dev는 보호에서 빼줘" → 제거 (`hi-loop config remove-branch dev`)
104
+ - "앞으로 커밋 자동으로" → `commitPolicy = auto` · "auto commit 꺼줘" → `commitPolicy = off`
105
+
106
+ ### 🔴 "이번만" vs "앞으로" — 반드시 구분
107
+ - **"이번엔 커밋/분기 하지 마"** → 저장된 정책은 **그대로 두고** 이번 요청만 건너뛴다(일회성).
108
+ - **"앞으로 커밋/분기 하지 마"** → `.hi-loop.json` 을 **영구 변경**한다.
109
+
110
+ 일회성 지시가 팀 공유 정책을 실수로 바꾸지 않게 한다.
111
+ 우선순위: **이번 요청 지시 > `.hi-loop.json` > 기본값**.
112
+
113
+ ## 보조 도구
114
+ - `hiloop_status` — 현재 `.agent-state.json` 루프 상태 요약
115
+ - `hiloop_rollback` — git 체크포인트로 파일 복원(회차 지정 가능)
116
+ - `hiloop_reset` — 루프 상태 초기화
117
+ - `hiloop_setup` — 프로젝트에 `.mcp.json`·`.gitignore`·`docs/`·`tests/`·CLAUDE.md 규칙 주입
@@ -12,7 +12,7 @@
12
12
  import { parseDuration, WATCH_DEFAULTS } from './ship.js';
13
13
  import { normalizeAskPolicy } from './ask.js';
14
14
 
15
- export const STAGE_NAMES = ['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH'];
15
+ export const STAGE_NAMES = ['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'COMMIT', 'SHIP', 'WATCH'];
16
16
 
17
17
  const bad = (message) => ({ ok: false, message });
18
18
 
@@ -128,6 +128,11 @@ export function parseRunOptions(args, { staged = {} } = {}) {
128
128
  checks: pairChecks(args.check, args.when),
129
129
  shipCommand: str('ship'),
130
130
  watchCommand: str('watch'),
131
+ // git 워크플로 (FR-16). --branch 단독=자동명(true), --branch <name>=지정명.
132
+ // 값 병합(config 의 branchPolicy/commitPolicy)은 bin/hi-loop.js 가 한다 — 여기선 플래그만.
133
+ branch: args.branch === undefined ? null : given(args.branch) ? String(args.branch) : true,
134
+ commit: Boolean(args.commit),
135
+ commitMessage: str('commit-message'),
131
136
  onShipFail,
132
137
  onWatchFail,
133
138
  askPolicy,
package/src/config.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 프로젝트 정책 저장소 `.hi-loop.json` (FR-16) — 브랜치·커밋 워크플로의 단일 진실 원천.
3
+ *
4
+ * `.agent-state.json`(임시·gitignore, goal 마다 리셋)과 **다르다**: 이 파일은 팀이 공유하는
5
+ * **영속 정책**이라 커밋 대상이다(그래서 .gitignore 에 넣지 않는다).
6
+ *
7
+ * 스킬 층(대화형)과 엔진 층(헤드리스)이 같은 파일을 읽어 정책을 일관되게 적용한다.
8
+ * 손상된 JSON 에도 죽지 않고 기본값으로 폴백한다 — 정책 파일 하나가 루프를 막으면 안 된다.
9
+ */
10
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ export const CONFIG_FILE = '.hi-loop.json';
14
+
15
+ /** 보호 브랜치일 때: always=자동 분기 | ask=매번 확인 | never=분기 안 함 */
16
+ export const BRANCH_POLICIES = ['ask', 'always', 'never'];
17
+ /** 테스트 통과 후: confirm=메시지+diff 확인 후 | auto=자동 | off=커밋 안 함 */
18
+ export const COMMIT_POLICIES = ['confirm', 'auto', 'off'];
19
+ /** 커밋 메시지 형식: match-log=git log 스타일 미러 | conventional | korean */
20
+ export const COMMIT_STYLES = ['match-log', 'conventional', 'korean'];
21
+
22
+ export const DEFAULT_CONFIG = Object.freeze({
23
+ version: 1,
24
+ protectedBranches: ['main', 'master', 'develop', 'dev'],
25
+ branchPolicy: 'ask',
26
+ commitPolicy: 'confirm',
27
+ commitStyle: 'match-log',
28
+ });
29
+
30
+ export function configPathFor(cwd) {
31
+ return join(cwd, CONFIG_FILE);
32
+ }
33
+
34
+ /** 파일이 있으면 기본값 위에 병합, 없거나 손상되면 기본값. 알 수 없는 값은 기본값으로 교정. */
35
+ export function loadConfig(cwd) {
36
+ const path = configPathFor(cwd);
37
+ let raw = null;
38
+ if (existsSync(path)) {
39
+ try {
40
+ raw = JSON.parse(readFileSync(path, 'utf8'));
41
+ } catch {
42
+ raw = null; // 손상된 정책 파일은 무시하고 기본값 (루프를 막지 않는다)
43
+ }
44
+ }
45
+ return normalizeConfig(raw);
46
+ }
47
+
48
+ /** 신뢰할 수 없는 입력을 스키마에 맞춰 교정한다 — 잘못된 값이 조용히 통과하지 않게. */
49
+ export function normalizeConfig(raw) {
50
+ const r = raw && typeof raw === 'object' ? raw : {};
51
+ const branches = Array.isArray(r.protectedBranches)
52
+ ? [...new Set(r.protectedBranches.filter((b) => typeof b === 'string' && b.trim()).map((b) => b.trim()))]
53
+ : [...DEFAULT_CONFIG.protectedBranches];
54
+ const pick = (v, allowed, def) => (allowed.includes(v) ? v : def);
55
+ return {
56
+ version: 1,
57
+ protectedBranches: branches,
58
+ branchPolicy: pick(r.branchPolicy, BRANCH_POLICIES, DEFAULT_CONFIG.branchPolicy),
59
+ commitPolicy: pick(r.commitPolicy, COMMIT_POLICIES, DEFAULT_CONFIG.commitPolicy),
60
+ commitStyle: pick(r.commitStyle, COMMIT_STYLES, DEFAULT_CONFIG.commitStyle),
61
+ };
62
+ }
63
+
64
+ /** 병합된(정규화된) 정책을 파일에 쓴다. 항상 정규화 후 저장해 파일이 스키마를 벗어나지 않게. */
65
+ export function saveConfig(cwd, config) {
66
+ const normalized = normalizeConfig(config);
67
+ writeFileSync(configPathFor(cwd), `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
68
+ return normalized;
69
+ }
70
+
71
+ /**
72
+ * 정책 한 항목 변경 (`hi-loop config set <key> <value>` / 평문 요청).
73
+ * 반환: `{ ok, config }` 또는 `{ ok:false, reason }` — 던지지 않는다.
74
+ */
75
+ export function setPolicy(cwd, key, value) {
76
+ const allowed = {
77
+ branchPolicy: BRANCH_POLICIES,
78
+ commitPolicy: COMMIT_POLICIES,
79
+ commitStyle: COMMIT_STYLES,
80
+ };
81
+ if (!(key in allowed)) {
82
+ return { ok: false, reason: `알 수 없는 정책 키: ${key} (가능: ${Object.keys(allowed).join(', ')})` };
83
+ }
84
+ if (!allowed[key].includes(value)) {
85
+ return { ok: false, reason: `--${key} 는 ${allowed[key].join('|')} 중 하나여야 합니다: ${value}` };
86
+ }
87
+ const config = { ...loadConfig(cwd), [key]: value };
88
+ return { ok: true, config: saveConfig(cwd, config) };
89
+ }
90
+
91
+ /** 보호 브랜치 추가 (중복 무시). */
92
+ export function addBranch(cwd, name) {
93
+ const branch = String(name ?? '').trim();
94
+ if (!branch) return { ok: false, reason: '브랜치 이름이 비어 있습니다.' };
95
+ const config = loadConfig(cwd);
96
+ if (config.protectedBranches.includes(branch)) {
97
+ return { ok: true, config, changed: false };
98
+ }
99
+ config.protectedBranches.push(branch);
100
+ return { ok: true, config: saveConfig(cwd, config), changed: true };
101
+ }
102
+
103
+ /** 보호 브랜치 제거. */
104
+ export function removeBranch(cwd, name) {
105
+ const branch = String(name ?? '').trim();
106
+ const config = loadConfig(cwd);
107
+ if (!config.protectedBranches.includes(branch)) {
108
+ return { ok: true, config, changed: false };
109
+ }
110
+ config.protectedBranches = config.protectedBranches.filter((b) => b !== branch);
111
+ return { ok: true, config: saveConfig(cwd, config), changed: true };
112
+ }
package/src/loop.js CHANGED
@@ -15,6 +15,7 @@ import { makeAgentRunner, makeTestRunner } from './runners.js';
15
15
  import { makeIntegrityChecker } from './integrity.js';
16
16
  import { acquireLock } from './lock.js';
17
17
  import { makeCheckpointer, makeFileLister, makeChangeLister } from './checkpoint.js';
18
+ import { makeBrancher, makeCommitter, branchPreStep } from './vcs.js';
18
19
  import { makeTreeKeyReader } from './treekey.js';
19
20
  import { makeOutcome } from './outcome.js';
20
21
  import { makeSpecVerifier } from './verify.js';
@@ -32,7 +33,7 @@ export { createState, loadState, saveState, statePathFor, summarizeState, resetS
32
33
  export { applyAnswer, formatAsk, normalizeAskPolicy } from './ask.js';
33
34
 
34
35
  /** BUILD 이후의 stage. 이 상태로 저장돼 있으면 재개 시 빌드 루프를 다시 돌지 않는다. */
35
- export const POST_BUILD_STAGES = ['CODE_REVIEW', 'SHIP', 'WATCH'];
36
+ export const POST_BUILD_STAGES = ['CODE_REVIEW', 'COMMIT', 'SHIP', 'WATCH'];
36
37
 
37
38
  /**
38
39
  * 얇은 락 래퍼 (L4). 같은 cwd 에서 두 루프가 상태를 덮어쓰지 못하게 배타를 강제한다.
@@ -79,6 +80,12 @@ async function runLoopBody({
79
80
  watchEveryMs = WATCH_DEFAULTS.everyMs,
80
81
  watchTolerate = WATCH_DEFAULTS.tolerate,
81
82
  onWatchFail = 'stop',
83
+ // git 워크플로 (FR-16) — opt-in. branch: null=안 함 | true=자동명 | '<name>'=지정명.
84
+ branch = null,
85
+ protectedBranches = null,
86
+ commit = false,
87
+ commitMessage = null,
88
+ commitStyle = 'match-log',
82
89
  askPolicy = DEFAULT_ASK_POLICY,
83
90
  yes = false,
84
91
  agentRunner = makeAgentRunner(),
@@ -94,6 +101,8 @@ async function runLoopBody({
94
101
  discoverer = makeDiscoverer(),
95
102
  shipper = makeShipper(),
96
103
  watcher = makeWatcher(),
104
+ brancher = makeBrancher(),
105
+ committer = makeCommitter(),
97
106
  notifier = makeNotifier(),
98
107
  logger = (line) => process.stderr.write(`${line}\n`),
99
108
  statePath = statePathFor(cwd),
@@ -120,6 +129,10 @@ async function runLoopBody({
120
129
 
121
130
  saveState(statePath, state);
122
131
 
132
+ // 브랜치 프리스텝 (FR-16, opt-in): 보호 브랜치일 때만 분기. 비-git·feature·재개엔 no-op.
133
+ const branchedTo = await branchPreStep({ brancher, cwd, branch, goal, protectedBranches, logger });
134
+ if (branchedTo) { state.branch = branchedTo; saveState(statePath, state); }
135
+
123
136
  const notify = async (event) => {
124
137
  try {
125
138
  await notifier({ goal, maxLoops, ...event });
@@ -152,12 +165,11 @@ async function runLoopBody({
152
165
  const designReviewEnabled = designReview === null ? full || Boolean(shipCommand) : Boolean(designReview);
153
166
  const discoverEnabled = discover === null ? full : Boolean(discover);
154
167
 
155
- /** BUILD 다음에 곳. 켜지 않은 단계는 건너뛴다 기본은 종전대로 BUILD 하나뿐이다. */
156
- const nextAfterBuild = () =>
157
- reviewEnabled ? 'CODE_REVIEW' : shipCommand ? 'SHIP' : watchCommand ? 'WATCH' : 'DONE';
158
-
159
- /** 리뷰를 통과했거나 상한을 소진한 곳. */
160
- const nextAfterReview = () => (shipCommand ? 'SHIP' : watchCommand ? 'WATCH' : 'DONE');
168
+ // 라우팅: 켜진 다음 단계로만 넘어간다(기본은 BUILD 하나). 커밋이 켜지면 리뷰된 코드를 먼저 커밋한다.
169
+ const commitEnabled = Boolean(commit);
170
+ const nextAfterCommit = () => (shipCommand ? 'SHIP' : watchCommand ? 'WATCH' : 'DONE');
171
+ const nextAfterBuild = () => (reviewEnabled ? 'CODE_REVIEW' : commitEnabled ? 'COMMIT' : nextAfterCommit());
172
+ const nextAfterReview = () => (commitEnabled ? 'COMMIT' : nextAfterCommit());
161
173
 
162
174
  /**
163
175
  * 사람이 골라야 하는 지점에서 멈춘다. 이미 답한 질문이면 그 답을 돌려준다.
@@ -195,6 +207,7 @@ async function runLoopBody({
195
207
  notify,
196
208
  gate,
197
209
  nextAfterReview,
210
+ nextAfterCommit,
198
211
  statePath,
199
212
  baselineFiles,
200
213
  designReviewEnabled,
@@ -205,6 +218,8 @@ async function runLoopBody({
205
218
  designReviewer,
206
219
  shipper,
207
220
  watcher,
221
+ brancher,
222
+ committer,
208
223
  agentRunner,
209
224
  testRunner,
210
225
  integrityChecker,
@@ -233,6 +248,9 @@ async function runLoopBody({
233
248
  handoffEvery,
234
249
  maxDesignRounds,
235
250
  stopAfter,
251
+ commit,
252
+ commitMessage,
253
+ commitStyle,
236
254
  },
237
255
  };
238
256
 
package/src/mcp-server.js CHANGED
@@ -14,6 +14,17 @@ const log = (line) => process.stderr.write(`[hi-loop-mcp] ${line}\n`);
14
14
  const text = (t) => ({ content: [{ type: 'text', text: t }] });
15
15
  const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
16
16
 
17
+ /**
18
+ * MCP 경로의 기본 작업 디렉터리.
19
+ *
20
+ * CLI 는 `process.cwd()` 만 본다 — 터미널에서 cd 한 곳이 곧 사용자의 명시 의도이기 때문이다.
21
+ * 그러나 MCP 는 호스트(클로드코드/커서)가 서버를 띄우는 경로이고, "어느 프로젝트인가"의
22
+ * 정답은 호스트가 주입하는 `CLAUDE_PROJECT_DIR` 다. 서버 프로세스의 cwd 가 프로젝트 루트와
23
+ * 다른 호스트에서도 올바른 루트를 잡으려면 이 env 를 먼저 본다. 호출자가 cwd 를 명시하면
24
+ * 그것이 최우선이다(각 핸들러의 인자가 이 기본값을 덮는다).
25
+ */
26
+ const defaultCwd = () => process.env.CLAUDE_PROJECT_DIR || process.cwd();
27
+
17
28
  /** 도구 구현 (SDK 없이도 단위 테스트 가능하도록 분리) */
18
29
  export const tools = {
19
30
  hiloop_run: {
@@ -28,7 +39,7 @@ export const tools = {
28
39
  ship,
29
40
  watch,
30
41
  stopAfter,
31
- cwd = process.cwd(),
42
+ cwd = defaultCwd(),
32
43
  }) => {
33
44
  if (!goal) return fail('goal 은 필수입니다.');
34
45
  const result = await runLoop({
@@ -66,7 +77,7 @@ export const tools = {
66
77
  hiloop_answer: {
67
78
  description:
68
79
  'hiloop_run 이 반환한 대기 중인 질문에 답한다. choice 는 제시된 선택지의 키(a, b, ...)이고, note 로 자유 서술을 덧붙이거나 대신할 수 있다. 답한 뒤 hiloop_run 을 같은 goal 로 다시 호출하면 이어서 진행된다.',
69
- handler: async ({ choice, note = '', cwd = process.cwd() } = {}) => {
80
+ handler: async ({ choice, note = '', cwd = defaultCwd() } = {}) => {
70
81
  const statePath = statePathFor(cwd);
71
82
  const state = loadState(statePath);
72
83
  if (!state?.ask) return fail('대기 중인 질문이 없습니다.');
@@ -79,17 +90,17 @@ export const tools = {
79
90
  },
80
91
  hiloop_status: {
81
92
  description: '현재 워크스페이스의 .agent-state.json 루프 상태 요약을 반환한다.',
82
- handler: async ({ cwd = process.cwd() } = {}) => text(summarizeState(loadState(statePathFor(cwd)))),
93
+ handler: async ({ cwd = defaultCwd() } = {}) => text(summarizeState(loadState(statePathFor(cwd)))),
83
94
  },
84
95
  hiloop_reset: {
85
96
  description: '.agent-state.json 을 삭제해 루프 상태를 초기화한다.',
86
- handler: async ({ cwd = process.cwd() } = {}) =>
97
+ handler: async ({ cwd = defaultCwd() } = {}) =>
87
98
  text(resetState(statePathFor(cwd)) ? '상태를 초기화했습니다.' : '초기화할 상태가 없습니다.'),
88
99
  },
89
100
  hiloop_rollback: {
90
101
  description:
91
102
  'git 체크포인트로 파일을 되돌린다. to(회차)를 주면 그 회차 직전 스냅샷으로, 없으면 가장 최근 체크포인트로 복원한다.',
92
- handler: async ({ to, cwd = process.cwd() } = {}) => {
103
+ handler: async ({ to, cwd = defaultCwd() } = {}) => {
93
104
  const state = loadState(statePathFor(cwd));
94
105
  const checkpoints = state?.checkpoints ?? [];
95
106
  if (checkpoints.length === 0)
@@ -112,7 +123,7 @@ export const tools = {
112
123
  hiloop_setup: {
113
124
  description:
114
125
  '프로젝트에 hi-loop 설정(.mcp.json MCP 서버 등록, .gitignore 항목, docs/tests 디렉터리)을 자동 주입한다. dryRun 이면 변경 없이 계획만 보여준다.',
115
- handler: async ({ dryRun = false, cwd = process.cwd() } = {}) => {
126
+ handler: async ({ dryRun = false, cwd = defaultCwd() } = {}) => {
116
127
  const report = runSetup({ cwd, dryRun: Boolean(dryRun) });
117
128
  return text(report.lines.join('\n'));
118
129
  },
@@ -147,7 +158,10 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
147
158
  }
148
159
 
149
160
  const server = new McpServer({ name: 'hi-loop', version });
150
- const cwdSchema = z.string().optional().describe('작업 디렉터리 (기본: 서버 프로세스의 cwd)');
161
+ const cwdSchema = z
162
+ .string()
163
+ .optional()
164
+ .describe('작업 디렉터리 (기본: CLAUDE_PROJECT_DIR → 없으면 서버 프로세스의 cwd)');
151
165
 
152
166
  server.registerTool(
153
167
  'hiloop_run',
package/src/stages.js CHANGED
@@ -105,6 +105,36 @@ export async function runCodeReviewStage(ctx) {
105
105
  return { next: ctx.nextAfterReview() };
106
106
  }
107
107
 
108
+ /**
109
+ * COMMIT (FR-16) — 테스트·리뷰를 통과한 코드를 로컬에 커밋한다. 에이전트를 부르지 않는다(비용 0).
110
+ *
111
+ * 헤드리스 opt-in(`--commit`)이므로 여기서 게이트를 걸지 않는다 — 플래그 자체가 동의다.
112
+ * 대화형 확인(diff 보고 승인)은 스킬 층이 LLM 레벨에서 담당한다(역할 분리).
113
+ * 커밋은 되돌릴 수 있으므로(로컬), 변경이 없거나 비-git 이면 실패로 만들지 않고 조용히 지나간다.
114
+ */
115
+ export async function runCommitStage(ctx) {
116
+ const { goal, cwd, logger, ports, opts } = ctx;
117
+ ctx.state.status = 'running';
118
+ ctx.save();
119
+
120
+ const res = await ports.committer({
121
+ cwd,
122
+ goal,
123
+ message: opts.commitMessage ?? null,
124
+ styleConfig: opts.commitStyle ?? 'match-log',
125
+ logger,
126
+ });
127
+
128
+ if (res.committed) {
129
+ ctx.state.commit = { sha: res.sha, message: res.message, at: new Date().toISOString() };
130
+ ctx.save();
131
+ } else if (res.reason && res.reason !== 'non-git') {
132
+ // 변경 없음 등은 실패가 아니다 — 이유만 남기고 진행한다.
133
+ logger(`[hi-loop] · 커밋하지 않았습니다 — ${res.reason}`);
134
+ }
135
+ return { next: ctx.nextAfterCommit() };
136
+ }
137
+
108
138
  /** SHIP (FR-13) — 판정은 exit code 뿐. 에이전트를 부르지 않으므로 비용은 0이다. */
109
139
  export async function runShipStage(ctx) {
110
140
  const { cwd, logger, ports, opts, gate, notify } = ctx;
@@ -223,6 +253,7 @@ export async function runWatchStage(ctx) {
223
253
  export const STAGE_HANDLERS = {
224
254
  DISCOVER: runDiscoverStage,
225
255
  CODE_REVIEW: runCodeReviewStage,
256
+ COMMIT: runCommitStage,
226
257
  SHIP: runShipStage,
227
258
  WATCH: runWatchStage,
228
259
  };