@tuzi-ince/hi-loop 0.2.1 → 0.3.0
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 +319 -119
- package/bin/hi-loop.js +70 -5
- package/bin/setup.js +54 -1
- package/docs/design.md +12 -1
- package/docs/spec.md +3 -1
- package/package.json +2 -1
- package/skills/hi-loop/SKILL.md +117 -0
- package/src/args.js +18 -5
- package/src/blast.js +42 -0
- package/src/build.js +74 -62
- package/src/check.js +169 -0
- package/src/checkpoint.js +88 -4
- package/src/checks.js +122 -0
- package/src/cli-options.js +19 -1
- package/src/config.js +112 -0
- package/src/gates.js +104 -0
- package/src/integrity.js +30 -1
- package/src/loop.js +46 -44
- package/src/metrics.js +140 -0
- package/src/outcome.js +76 -0
- package/src/report.js +73 -7
- package/src/resume.js +93 -0
- package/src/seal.js +85 -0
- package/src/stages.js +47 -3
- package/src/state.js +21 -39
- package/src/treekey.js +63 -0
- package/src/vcs.js +167 -0
- package/src/verdict.js +54 -0
- package/src/verify.js +6 -5
- package/src/wiring.js +74 -0
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
|
환경변수:
|
|
@@ -61,12 +64,24 @@ const USAGE = `hi-loop — 초경량 자율형 자가 치유 엔진
|
|
|
61
64
|
HILOOP_DISCOVER_CMD/MODEL 발굴 단계의 실행 명령·모델
|
|
62
65
|
HILOOP_AGENT_TIMEOUT_MS 에이전트 타임아웃 (기본: 1800000 = 30분)
|
|
63
66
|
HILOOP_TEST_TIMEOUT_MS 테스트 타임아웃 (기본: 600000 = 10분)
|
|
64
|
-
TELEGRAM_BOT_TOKEN/CHAT_ID 텔레그램 알림 (미설정 시 생략)
|
|
67
|
+
TELEGRAM_BOT_TOKEN/CHAT_ID 텔레그램 알림 (미설정 시 생략)
|
|
68
|
+
|
|
69
|
+
검사를 여러 개 걸기 (--test 대신):
|
|
70
|
+
--check "<cmd>" 판정 명령. 여러 번 줄 수 있고 short-circuit 하지 않는다.
|
|
71
|
+
--when "<glob>" 바로 앞 --check 를 이 경로가 바뀐 회차에만 실행한다.
|
|
72
|
+
--flaky-probe 통과한 회차에서 같은 명령을 한 번 더 돌려 플레이키를 잡는다.
|
|
73
|
+
|
|
74
|
+
예) UI 를 건드린 회차에만 e2e 까지 돌린다:
|
|
75
|
+
hi-loop run --goal "..." \\
|
|
76
|
+
--check "npm test" \\
|
|
77
|
+
--check "npx playwright test" --when "src/**/*.tsx"`;
|
|
65
78
|
|
|
66
79
|
export async function main(argv = process.argv.slice(2)) {
|
|
67
80
|
const args = parseArgs(argv, {
|
|
68
81
|
alias: { g: 'goal', t: 'test', c: 'cwd', h: 'help', v: 'version', m: 'max-loops' },
|
|
69
|
-
boolean: ['help', 'version', 'dry-run', 'no-stagnation', 'verify-spec', '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'],
|
|
83
|
+
// 개수가 정해지지 않은 입력. `--check A --when X --check B` 처럼 위치로 짝을 맺는다.
|
|
84
|
+
repeat: ['check', 'when'],
|
|
70
85
|
});
|
|
71
86
|
const command = args._[0] ?? (args.help || args.version ? null : 'mcp');
|
|
72
87
|
|
|
@@ -121,11 +136,19 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
121
136
|
return 2;
|
|
122
137
|
}
|
|
123
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
|
+
|
|
124
147
|
const { runLoop } = await import('../src/loop.js');
|
|
125
148
|
const result = await runLoop({
|
|
126
149
|
goal,
|
|
127
150
|
cwd,
|
|
128
|
-
...
|
|
151
|
+
...options,
|
|
129
152
|
logger: (line) => process.stdout.write(`${line}\n`),
|
|
130
153
|
});
|
|
131
154
|
// awaiting 은 성공도 실패도 아니다. 0 을 주면 `hi-loop run && 배포` 같은 스크립트가
|
|
@@ -189,7 +212,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
189
212
|
process.stderr.write(`롤백 실패: ${res.reason}\n`);
|
|
190
213
|
return 1;
|
|
191
214
|
}
|
|
192
|
-
process.stdout.write(`✅ ${cp.iteration}회차 직전 상태로 파일을 복원했습니다 (${cp.sha.slice(0, 8)}).\n`);
|
|
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
|
+
}
|
|
193
226
|
if (res.safetySha) process.stdout.write(` 되돌리기 전 상태는 ${res.safetySha.slice(0, 8)} 에 스냅샷됨.\n`);
|
|
194
227
|
return 0;
|
|
195
228
|
}
|
|
@@ -199,6 +232,38 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
199
232
|
process.stdout.write(`${report.lines.join('\n')}\n`);
|
|
200
233
|
return 0;
|
|
201
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
|
+
}
|
|
202
267
|
default:
|
|
203
268
|
process.stderr.write(`알 수 없는 명령: ${command}\n\n${USAGE}\n`);
|
|
204
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
|
-
|
|
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/docs/design.md
CHANGED
|
@@ -552,7 +552,18 @@ try/catch 로 감싼다. **텔레그램 장애로 빌드가 죽는 것은 설계
|
|
|
552
552
|
| ~~L7~~ | ~~실제 `claude` 에이전트와의 통합이 미검증~~ | ✅ **해소** — claude 2.1.212 로 실측. 편집 권한·세션 재개·JSON 파싱·전체 루프 전부 확인(§10). **차단 사유였던 "개발 환경 실행 가드"는 사실이 아니었다** — 막고 있던 것은 기술적 불가능이 아니라 실행되지 않은 절차였다 |
|
|
553
553
|
| ~~L8~~ | ~~경로 회피는 강제가 아니다~~ | ✅ **관측으로 완화**(§4.14) — baseline 대비 삭제된 파일을 경고(차단 안 함, 되돌림 가능). 수정은 정당할 수 있어 삭제만 본다. 실측: 에이전트가 시킨 대로 legacy.js 삭제 → 경고 발동 |
|
|
554
554
|
| ~~L9~~ | ~~스펙이 오라클이 아니다~~ | ✅ **해소**(§4.12, opt-in) — 2단 판정. Tier 1(기계) 최종 권한, Tier 2(모델) 하향 전용. 검증자는 plan 모드·새 세션으로 격리. 실측: 테스트 통과 + 스펙 AC 미충족을 실제 claude 가 41초 만에 기각 |
|
|
555
|
-
| ~~L10~~ | ~~산출물이 미관측을 공개하지 않는다~~ | ✅ **해소**(§4.10) — `reportGaps` 가
|
|
555
|
+
| ~~L10~~ | ~~산출물이 미관측을 공개하지 않는다~~ | ✅ **해소**(§4.10) — `reportGaps` 가 5섹션(Claim/Evidence/Baseline-attribution/Gaps/Residual-risk)을 출력. CLI·MCP 양쪽. **실패에도 붙인다** — 예전 판단("실패는 stopReason 으로 이미 정직")을 뒤집었다. 예산 소진·정체로 멈춘 순간이야말로 워킹트리에 무엇이 반쯤 적용됐는지 알아야 할 때다 |
|
|
556
|
+
| ~~L11~~ | ~~판정이 불리언 하나다 — 통과 수가 무너져도 exit 0~~ | ✅ **해소**(`metrics.js`) — 러너 출력에서 통과/실패 수를 읽어 비회귀 게이트. 지문(L1)은 파일을 보고 이쪽은 실행 결과를 봐서 케이스를 안 지우는 축소를 잡는다. 언어 무관이라 Python·Go 프로젝트의 무결성 사각도 메운다. 정체 판정도 지표 벡터를 함께 본다 |
|
|
557
|
+
| ~~L12~~ | ~~에이전트가 아무것도 안 쓴 회차를 모른다~~ | ✅ **해소**(`treekey.js`) — `git stash create` 트리 SHA 로 앞뒤 비교. 트리가 같으면 결과가 같다는 것은 관측이 아니라 증명이므로 2회차에 정체로 잡는다 |
|
|
558
|
+
| ~~L13~~ | ~~디스크엔 있고 git 엔 없는 테스트~~ | ✅ **해소**(`integrity.js`) — 지문은 디스크를 읽으므로 gitignore 된 테스트도 완벽한 지문을 갖는다. CI 는 그 테스트를 못 보고 체크포인트로도 복구되지 않는다. 측정이라 fail-closed |
|
|
559
|
+
| ~~L14~~ | ~~돌지 않은 게이트가 Evidence 를 만든다~~ | ✅ **해소**(`gates.js`) — 게이트가 평가되는 그 자리에서만 원장에 남고, 보고서는 원장에서 렌더한다. `unobservable`·`off` 는 통과와 구분돼 Gaps 로 간다 |
|
|
560
|
+
| ~~L15~~ | ~~게이트를 하나도 안 거치고 passed 에 도달한다~~ | ✅ **해소**(`outcome.js`) — `--start-from CODE_REVIEW` 가 테스트를 한 번도 안 돌리고 통과를 주장했다. 통과한 트리를 박아두고 최종 판정 시점 트리와 대조한다. 종료 지점을 한 파일에 모아 새 stage 가 이 검사를 우회할 수 없게 했다 |
|
|
561
|
+
| ~~L16~~ | ~~게이트 기준선이 에이전트의 쓰기 범위 안에 있다~~ | ✅ **해소**(`seal.js`) — 상태 파일은 gitignore 되고 cwd 에 있다. 게이트를 여는 필드에 HMAC(키는 cwd 밖). 불일치면 기준선 없음으로 되돌리고 보고서에 밝힌다 |
|
|
562
|
+
| ~~L17~~ | ~~"한 번만 쟀다"가 산문으로만 있다~~ | ✅ **해소**(`--flaky-probe`) — 통과 회차에서만 재실행. 에이전트 호출 0. 두 번 돌려 갈리는 초록불은 초록불이 아니다 |
|
|
563
|
+
| ~~L18~~ | ~~판정 명령이 하나뿐이다~~ | ✅ **해소**(`checks.js`, `--check`/`--when`) — short-circuit 없이 전부 실행. 경로 글롭으로 무거운 검사(e2e)를 해당 회차에만. 건너뛴 것은 통과가 아니라 `skipped` 로 공개 |
|
|
564
|
+
| L19 | 만들었지만 제품 경로에서 안 불리는 파일 | ⚠️ **관측으로 완화**(`wiring.js`) — 테스트만 부르는 새 파일을 Gaps 에 공개. 차단하지 않는다(다음 회차에 배선할 수 있어 오탐이 루프를 죽인다) |
|
|
565
|
+
| L20 | 수정이 실패에 비해 과도한가 | ⚠️ **관측으로 완화**(`blast.js`) — 의존성·마이그레이션·CI·러너 설정 변경을 Gaps 에 공개. bkit 도 경고로만 쓴다 |
|
|
566
|
+
| L21 | 복구 경로 자체가 무방비 | ⚠️ **관측으로 완화**(`checkpoint.js`) — 에이전트는 cwd 에서 Bash 를 갖는다. `rm -rf .git` 한 번이면 L3 가 사라진다. 자식의 Bash 를 가로채는 CC 훅 주입은 **훅 발동을 검증하지 못한 채 배송**하는 것이라 택하지 않았다. 대신 매 회차 복구 경로 생존을 직접 잰다 — 막지는 못해도 모른 채 진행하지 않는다 |
|
|
556
567
|
|
|
557
568
|
---
|
|
558
569
|
|
package/docs/spec.md
CHANGED
|
@@ -393,7 +393,9 @@ DISCOVER → PLAN → DESIGN_REVIEW → DO ⇄ CHECK ⇄ HEAL → CODE_REVIEW
|
|
|
393
393
|
- NFR-3 모든 I/O 경계(에이전트 호출, 테스트 실행, 알림)는 주입 가능해야 하며
|
|
394
394
|
테스트는 네트워크/실제 에이전트 없이 통과해야 한다.
|
|
395
395
|
- NFR-4 상태 파일 쓰기는 원자적이어야 한다(중단 시 손상 금지).
|
|
396
|
-
- NFR-5 코드 파일당 300줄 이내, 모듈 단일 책임.
|
|
396
|
+
- NFR-5 코드 파일당 300줄 이내, 모듈 단일 책임. **`tests/nfr.test.js` 가 이 값을 강제한다** —
|
|
397
|
+
선언만 있고 재는 사람이 없던 동안 `build.js` 가 309줄, `loop.js` 가 303줄로 조용히 넘어갔다.
|
|
398
|
+
같은 파일이 NFR-4(런타임 의존성 2개)와 "아무도 안 읽는 export 금지"도 함께 강제한다.
|
|
397
399
|
|
|
398
400
|
---
|
|
399
401
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuzi-ince/hi-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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 규칙 주입
|
package/src/args.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 의존성 없는 최소 인자 파서 (FR-1.4) — `--k v`, `--k=v`, `-g v`, `--flag` 지원.
|
|
3
|
+
*
|
|
4
|
+
* `repeat` 에 든 키는 여러 번 줘도 덮어쓰지 않고 **배열로 쌓는다**. `--check` 처럼 개수가
|
|
5
|
+
* 정해지지 않은 입력에 필요하다. 기본은 덮어쓰기다 — 모든 키를 배열로 만들면 호출부가
|
|
6
|
+
* 전부 `Array.isArray` 를 신경 써야 한다.
|
|
7
|
+
*/
|
|
8
|
+
export function parseArgs(argv, { alias = {}, boolean: booleans = [], repeat = [] } = {}) {
|
|
3
9
|
const out = { _: [] };
|
|
4
10
|
const isBool = (k) => booleans.includes(k);
|
|
5
11
|
const norm = (k) => alias[k] ?? k;
|
|
12
|
+
const put = (k, v) => {
|
|
13
|
+
if (!repeat.includes(k)) {
|
|
14
|
+
out[k] = v;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
out[k] = out[k] === undefined ? [v] : [...(Array.isArray(out[k]) ? out[k] : [out[k]]), v];
|
|
18
|
+
};
|
|
6
19
|
|
|
7
20
|
for (let i = 0; i < argv.length; i += 1) {
|
|
8
21
|
const token = argv[i];
|
|
@@ -14,15 +27,15 @@ export function parseArgs(argv, { alias = {}, boolean: booleans = [] } = {}) {
|
|
|
14
27
|
const raw = token.replace(/^--?/, '');
|
|
15
28
|
const eq = raw.indexOf('=');
|
|
16
29
|
if (eq !== -1) {
|
|
17
|
-
|
|
30
|
+
put(norm(raw.slice(0, eq)), raw.slice(eq + 1));
|
|
18
31
|
continue;
|
|
19
32
|
}
|
|
20
33
|
const key = norm(raw);
|
|
21
34
|
const next = argv[i + 1];
|
|
22
35
|
if (isBool(key) || next === undefined || next.startsWith('--')) {
|
|
23
|
-
|
|
36
|
+
put(key, true);
|
|
24
37
|
} else {
|
|
25
|
-
|
|
38
|
+
put(key, next);
|
|
26
39
|
i += 1;
|
|
27
40
|
}
|
|
28
41
|
continue;
|
package/src/blast.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 블라스트 반경 (L20) — 수정이 실패에 비해 과도한가.
|
|
3
|
+
*
|
|
4
|
+
* 이 엔진의 HEAL 루프에는 "고침의 크기"라는 개념이 없었다. 단언 하나가 깨진 것을 고치려고
|
|
5
|
+
* 에이전트가 `package.json` 을 다시 쓰거나, 마이그레이션을 추가하거나, 러너 설정을 바꿔도
|
|
6
|
+
* 테스트만 통과하면 전부 통과다. L8(삭제 관측)은 **지운 것**만 본다.
|
|
7
|
+
*
|
|
8
|
+
* 판정이 아니라 **공개**다. 큰 변경이 정당한 경우가 흔하고(리팩터링 goal, 초기 스캐폴딩),
|
|
9
|
+
* 차단하면 오탐이 루프를 죽인다. bkit 도 이 분류를 경고로만 쓴다
|
|
10
|
+
* (`lib/control/blast-radius.js:202` — warning 문자열을 돌려줄 뿐 차단하지 않는다).
|
|
11
|
+
* 사람이 보고서에서 "테스트는 통과했는데 왜 락파일이 바뀌었지"를 알아채게 하는 것이 목적이다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 위험 분류. 순서가 곧 우선순위다 — 먼저 맞는 것이 이긴다. */
|
|
15
|
+
const CLASSES = [
|
|
16
|
+
[/(?:^|\/)(?:package|pnpm|yarn)-lock\.(?:json|yaml)$|(?:^|\/)package\.json$|(?:^|\/)go\.(?:mod|sum)$|(?:^|\/)Cargo\.(?:toml|lock)$|requirements\.txt$|pyproject\.toml$/, '의존성'],
|
|
17
|
+
[/(?:^|\/)migrations?\//i, '스키마 마이그레이션'],
|
|
18
|
+
[/(?:^|\/)\.github\/|(?:^|\/)Dockerfile|docker-compose|(?:^|\/)\.gitlab-ci/i, 'CI·배포 설정'],
|
|
19
|
+
[/\.(?:config|conf)\.[cm]?[jt]s$|(?:^|\/)tsconfig|(?:^|\/)vite\.config|(?:^|\/)vitest\.config|(?:^|\/)jest\.config|(?:^|\/)eslint/i, '빌드·러너 설정'],
|
|
20
|
+
[/(?:^|\/)\.env/, '환경 변수'],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** 변경 파일들을 위험 분류로 묶는다. `{분류: [파일…]}` — 해당 없으면 빈 객체. */
|
|
24
|
+
export function classifyChanges(files) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const f of Array.isArray(files) ? files : []) {
|
|
27
|
+
for (const [re, label] of CLASSES) {
|
|
28
|
+
if (!re.test(f)) continue;
|
|
29
|
+
(out[label] ??= []).push(f);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 보고서 Gaps 에 실을 줄. 위험 분류가 없으면 빈 배열이다(소음을 만들지 않는다). */
|
|
37
|
+
export function blastLines(classified) {
|
|
38
|
+
return Object.entries(classified ?? {}).map(
|
|
39
|
+
([label, files]) =>
|
|
40
|
+
`- ⚠️ 이번 실행이 **${label}** 파일을 건드렸다: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` 외 ${files.length - 5}개` : ''}. 테스트 통과와 별개로 사람이 봐야 한다.`,
|
|
41
|
+
);
|
|
42
|
+
}
|