@tuzi-ince/hi-loop 0.1.1 → 0.2.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/src/loop.js CHANGED
@@ -1,48 +1,36 @@
1
1
  /**
2
- * 자가 치유 핵심 오케스트레이션 루프 (FR-2, FR-3)
2
+ * 라이프사이클 stage 머신 (FR-10 ~ FR-15) + 실행 맥락 조립.
3
3
  *
4
- * PLAN -> DO -> CHECK -> ACT(HEAL) -> CHECK -> ...
5
- * CHECK 엔진이 직접 testCommand돌려 판정한다. 에이전트의 자기보고는 믿지 않는다.
4
+ * 안쪽 자가 치유 루프는 build.js 에, stage 핸들러는 stages.js 에 있다.
5
+ * 파일이 하는 일은 **다음 stage 정하는 하나**다 권한이 흩어지면
6
+ * 전체 흐름을 한눈에 볼 수 없게 된다.
7
+ *
8
+ * DISCOVER → BUILD(PLAN⇄DO⇄CHECK⇄HEAL) → CODE_REVIEW → SHIP → WATCH → DONE
9
+ *
10
+ * 새 stage 는 전부 안쪽 루프의 **앞뒤에 붙는 껍질**이다. 안쪽 루프의 판정 규칙
11
+ * (무결성 L1, 정체 L2, 체크포인트 L3, 예산, 핸드오프)은 그대로다.
6
12
  */
7
- import {
8
- saveState,
9
- statePathFor,
10
- resumeOrCreate,
11
- truncate,
12
- errorSignature,
13
- reportGaps,
14
- LIMITS,
15
- } from './state.js';
16
- import { promptFor } from './prompts.js';
13
+ import { saveState, statePathFor, resumeOrCreate, reportGaps } from './state.js';
17
14
  import { makeAgentRunner, makeTestRunner } from './runners.js';
18
- import { makeIntegrityChecker, compareFingerprints, violationMessage } from './integrity.js';
15
+ import { makeIntegrityChecker } from './integrity.js';
19
16
  import { acquireLock } from './lock.js';
20
- import { makeCheckpointer, makeFileLister, detectDeletions } from './checkpoint.js';
17
+ import { makeCheckpointer, makeFileLister } from './checkpoint.js';
21
18
  import { makeSpecVerifier } from './verify.js';
22
19
  import { makeNotifier } from './telegram.js';
20
+ import { makeShipper, makeWatcher, WATCH_DEFAULTS } from './ship.js';
21
+ import { makeCodeReviewer, makeDesignReviewer } from './review.js';
22
+ import { makeDiscoverer } from './discover.js';
23
+ import { formatAsk, createAsk, pauseForAsk, shouldAsk, DEFAULT_ASK_POLICY } from './ask.js';
24
+ import { STAGE_HANDLERS } from './stages.js';
25
+ import { runBuildLoop } from './build.js';
23
26
 
24
- export { createState, loadState, saveState, statePathFor, summarizeState, resetState } from './state.js';
25
-
26
- /** iteration -> PDCA phase (1회차 PLAN, 2회차 DO, 이후 전부 HEAL) */
27
- export function phaseForIteration(iteration) {
28
- if (iteration === 1) return 'PLAN';
29
- if (iteration === 2) return 'DO';
30
- return 'ACT';
31
- }
32
-
33
- function summarizeSpec(text) {
34
- return truncate(String(text ?? '').trim(), LIMITS.specSummary);
35
- }
27
+ // loop.js 엔진의 정문이다. 호출부가 내부 파일 배치를 알 필요가 없도록 여기서 모아 낸다.
28
+ export { phaseForIteration } from './build.js';
29
+ export { createState, loadState, saveState, statePathFor, summarizeState, resetState, migrateState } from './state.js';
30
+ export { applyAnswer, formatAsk, normalizeAskPolicy } from './ask.js';
36
31
 
37
- function combineOutput({ stdout, stderr }) {
38
- return [stderr, stdout].filter(Boolean).join('\n').trim();
39
- }
40
-
41
- /** 부동소수 드리프트를 막으려 6자리에서 끊는다. 러너가 비용을 안 주면 0으로 친다. */
42
- function addCost(prev, delta) {
43
- const next = (Number.isFinite(prev) ? prev : 0) + (Number.isFinite(delta) ? delta : 0);
44
- return Math.round(next * 1e6) / 1e6;
45
- }
32
+ /** BUILD 이후의 stage. 상태로 저장돼 있으면 재개 시 빌드 루프를 다시 돌지 않는다. */
33
+ export const POST_BUILD_STAGES = ['CODE_REVIEW', 'SHIP', 'WATCH'];
46
34
 
47
35
  /**
48
36
  * 얇은 락 래퍼 (L4). 같은 cwd 에서 두 루프가 상태를 덮어쓰지 못하게 배타를 강제한다.
@@ -69,12 +57,38 @@ async function runLoopBody({
69
57
  stagnationLimit = 3,
70
58
  verifySpec = false,
71
59
  handoffEvery = 4,
60
+ // ---- 라이프사이클 (FR-13, FR-14) ----
61
+ shipCommand = null,
62
+ onShipFail = 'stop',
63
+ // 기본값이 "배포할 때만 켜짐"인 이유는 §CODE_REVIEW 주석 참조.
64
+ codeReview = null,
65
+ maxReviewRounds = 2,
66
+ designReview = null,
67
+ maxDesignRounds = 2,
68
+ discover = null,
69
+ full = false,
70
+ // FR-15: 어느 경로로 들어와도 **같은 상태 머신**을 쓴다. 별도 코드 경로를 만들지 않는다
71
+ // — 분기가 둘이면 반드시 한쪽이 썩는다.
72
+ startFrom = null,
73
+ stopAfter = null,
74
+ watchCommand = null,
75
+ watchForMs = WATCH_DEFAULTS.forMs,
76
+ watchEveryMs = WATCH_DEFAULTS.everyMs,
77
+ watchTolerate = WATCH_DEFAULTS.tolerate,
78
+ onWatchFail = 'stop',
79
+ askPolicy = DEFAULT_ASK_POLICY,
80
+ yes = false,
72
81
  agentRunner = makeAgentRunner(),
73
82
  testRunner = makeTestRunner(),
74
83
  integrityChecker = makeIntegrityChecker(),
75
84
  checkpointer = makeCheckpointer(),
76
85
  fileLister = makeFileLister(),
77
86
  specVerifier = makeSpecVerifier(),
87
+ codeReviewer = makeCodeReviewer(),
88
+ designReviewer = makeDesignReviewer(),
89
+ discoverer = makeDiscoverer(),
90
+ shipper = makeShipper(),
91
+ watcher = makeWatcher(),
78
92
  notifier = makeNotifier(),
79
93
  logger = (line) => process.stderr.write(`${line}\n`),
80
94
  statePath = statePathFor(cwd),
@@ -89,7 +103,16 @@ async function runLoopBody({
89
103
  throw new TypeError('budgetUsd 는 0 보다 큰 숫자여야 합니다.');
90
104
  }
91
105
 
92
- const state = resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd });
106
+ let state = resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd });
107
+
108
+ // 답 안 받은 질문이 남아 있으면 여기서 끝이다. 재개하려면 `hi-loop answer` 를 거쳐야 한다.
109
+ // 이 관문이 없으면 run 을 다시 부르는 것만으로 사람이 고르라던 분기를 건너뛰게 된다.
110
+ if (state.ask) {
111
+ const saved = saveState(statePath, state);
112
+ logger(formatAsk(state.ask));
113
+ return { status: 'awaiting', iterations: state.iteration, state: saved, ask: state.ask };
114
+ }
115
+
93
116
  saveState(statePath, state);
94
117
 
95
118
  const notify = async (event) => {
@@ -106,191 +129,170 @@ async function runLoopBody({
106
129
  // null 이면 비-git — 관측 자체가 불가능하니 아래에서 조용히 건너뛴다(인프라 fail-open).
107
130
  const baselineFiles = await fileLister({ cwd });
108
131
 
109
- while (state.iteration < maxLoops) {
110
- // 예산은 **다음 호출 앞**에서 막는다. 이미 돈은 되돌릴 없으므로
111
- // 수 있는 일은 "더 쓰지 않는 것"뿐이다.
112
- if (budgetUsd != null && state.costUsd >= budgetUsd) {
113
- state.status = 'failed';
114
- state.phase = 'DONE';
115
- state.stopReason = 'budget';
116
- const saved = saveState(statePath, state);
117
- logger(`[hi-loop] 💸 예산 $${budgetUsd} 소진 (누적 $${state.costUsd.toFixed(2)}) ${state.iteration}회차에서 중단합니다.`);
118
- await notify({
119
- type: 'failed',
120
- iteration: state.iteration,
121
- detail: `예산 소진: $${state.costUsd.toFixed(2)} / $${budgetUsd}`,
122
- });
123
- return { status: 'failed', iterations: state.iteration, state: saved };
124
- }
125
-
126
- state.iteration += 1;
127
- state.phase = phaseForIteration(state.iteration);
128
- state.status = 'running';
132
+ /** 완료 보고는 곳에서만 만든다. stage 가 늘어도 Gaps 공개 지점은 하나여야 한다. */
133
+ const finish = async (status, { stopReason = null, detail = '' } = {}) => {
134
+ state.status = status;
135
+ state.phase = 'DONE';
136
+ state.stage = 'DONE';
137
+ if (stopReason) state.stopReason = stopReason;
138
+ const saved = saveState(statePath, state);
139
+ await notify({ type: status === 'passed' ? 'passed' : 'failed', iteration: state.iteration, detail });
140
+ // Gaps passed 에만 붙인다. false green 조용히 지나가는 지점이 거기이기 때문이다.
141
+ // 실패는 stopReason 만으로 이미 정직하다 — 거기에 Gaps 를 얹으면 소음이다.
142
+ if (status !== 'passed') return { status, iterations: state.iteration, state: saved };
143
+ const gaps = reportGaps(saved);
144
+ logger(gaps);
145
+ return { status, iterations: state.iteration, state: saved, report: gaps };
146
+ };
129
147
 
130
- // 체크포인트: 에이전트가 손대기 **전** 상태를 스냅샷. 이 호출이 망친 것을 되돌릴 수 있게.
131
- const sha = await checkpointer({ cwd });
132
- if (sha) {
133
- state.checkpoints = [...(state.checkpoints ?? []), { iteration: state.iteration, sha }].slice(-LIMITS.checkpointKeep);
134
- }
135
- saveState(statePath, state);
136
148
 
137
- logger(`[hi-loop] iteration ${state.iteration}/${maxLoops} — ${state.phase} (session #${state.sessionSerial})`);
149
+ /**
150
+ * 단계 기본값 (FR-17) — 조용히 비용을 얹지 않는다.
151
+ *
152
+ * 새 단계는 전부 에이전트 호출 1회씩을 더한다(SHIP/WATCH 는 예외로 0). 전역 기본 ON 으로
153
+ * 하면 업데이트만 한 기존 사용자의 실행 비용이 조용히 는다. 그래서: 플래그가 없으면 종전대로
154
+ * BUILD 하나, `--full` 이면 셋 다, `--ship` 이면 리뷰만("리뷰 없는 자동 배포가 위험하다"는
155
+ * 근거가 거기서만 성립한다). 개별 플래그가 이 자동을 양방향으로 덮는다.
156
+ */
157
+ const reviewEnabled = codeReview === null ? full || Boolean(shipCommand) : Boolean(codeReview);
158
+ const designReviewEnabled = designReview === null ? full || Boolean(shipCommand) : Boolean(designReview);
159
+ const discoverEnabled = discover === null ? full : Boolean(discover);
160
+
161
+ /** BUILD 다음에 갈 곳. 켜지 않은 단계는 건너뛴다 — 기본은 종전대로 BUILD 하나뿐이다. */
162
+ const nextAfterBuild = () =>
163
+ reviewEnabled ? 'CODE_REVIEW' : shipCommand ? 'SHIP' : watchCommand ? 'WATCH' : 'DONE';
164
+
165
+ /** 리뷰를 통과했거나 상한을 소진한 뒤 갈 곳. */
166
+ const nextAfterReview = () => (shipCommand ? 'SHIP' : watchCommand ? 'WATCH' : 'DONE');
167
+
168
+ /**
169
+ * 사람이 골라야 하는 지점에서 멈춘다. 이미 답한 질문이면 그 답을 돌려준다.
170
+ * 반환: `{ pause }` 면 호출자는 즉시 종료해야 한다.
171
+ */
172
+ const gate = ({ stage, question, options, required = true }) => {
173
+ const answered = [...(state.answers ?? [])].reverse().find((a) => a.stage === stage);
174
+ if (answered) return { pause: false, choice: answered.choice, note: answered.note };
175
+ if (!shouldAsk({ policy: askPolicy, required }) || yes) return { pause: false, choice: null };
176
+ state = pauseForAsk(state, createAsk({ stage, question, options }));
177
+ return { pause: true };
178
+ };
138
179
 
139
- const prompt = promptFor(state);
140
- const result = await agentRunner({ prompt, sessionId: state.sessionId, cwd });
141
- state.sessionId = result?.sessionId ?? state.sessionId ?? null;
142
- state.costUsd = addCost(state.costUsd, result?.costUsd);
180
+ const pauseResult = () => {
181
+ const saved = saveState(statePath, state);
182
+ logger(formatAsk(state.ask));
183
+ return { status: 'awaiting', iterations: state.iteration, state: saved, ask: state.ask };
184
+ };
143
185
 
144
- if (state.phase === 'PLAN') {
145
- state.specSummary = summarizeSpec(result?.text) || state.specSummary;
146
- }
186
+ /**
187
+ * `--stop-after` 멈춘 결과. 실패가 아니다 — 시킨 것을 다 했을 뿐이다.
188
+ * status 를 passed 로 하면 "테스트가 통과했다"는 거짓말이 되므로 별도 값을 쓴다.
189
+ */
190
+ const stopResult = (stage) => {
191
+ state.status = 'stopped';
192
+ state.stopReason = `stop-after:${stage}`;
193
+ const saved = saveState(statePath, state);
194
+ logger(`[hi-loop] ⏹ ${stage} 까지 진행하고 멈췄습니다 (--stop-after).`);
195
+ return { status: 'stopped', iterations: state.iteration, state: saved, stoppedAt: stage };
196
+ };
147
197
 
148
- // ---- CHECK: 엔진이 직접 검증 (FR-2.3) ----
149
- // 2단 판정이다. exit code "테스트가 통과했는가"만 답한다. 그 테스트가
150
- // **그대로인가**는 별도 문제이고, 그걸 안 물으면 에이전트는 테스트를 지워서 통과한다(L1).
151
- state.phase = 'CHECK';
152
- saveState(statePath, state);
153
- const check = await testRunner({ command: testCommand, cwd });
154
- const output = combineOutput(check);
198
+ /** 방금 끝낸 stage 정지 지점인가. 다음 stage 로 넘어가기 직전에만 묻는다. */
199
+ const shouldStopAfter = (stage) => stopAfter === stage;
200
+
201
+ /**
202
+ * stage 핸들러가 받는 실행 맥락. `state` 는 getter/setter 라 핸들러가 재대입해도
203
+ * 여기 변수에 반영된다(가정 기록은 순수 함수라 새 객체를 만든다).
204
+ */
205
+ const ctx = {
206
+ get state() {
207
+ return state;
208
+ },
209
+ set state(v) {
210
+ state = v;
211
+ },
212
+ goal,
213
+ cwd,
214
+ logger,
215
+ notify,
216
+ gate,
217
+ nextAfterReview,
218
+ statePath,
219
+ baselineFiles,
220
+ designReviewEnabled,
221
+ save: () => saveState(statePath, state),
222
+ ports: {
223
+ discoverer,
224
+ codeReviewer,
225
+ designReviewer,
226
+ shipper,
227
+ watcher,
228
+ agentRunner,
229
+ testRunner,
230
+ integrityChecker,
231
+ checkpointer,
232
+ fileLister,
233
+ specVerifier,
234
+ },
235
+ opts: {
236
+ shipCommand,
237
+ watchCommand,
238
+ onShipFail,
239
+ onWatchFail,
240
+ watchForMs,
241
+ watchEveryMs,
242
+ watchTolerate,
243
+ maxReviewRounds,
244
+ maxLoops,
245
+ budgetUsd,
246
+ testCommand,
247
+ stagnationLimit,
248
+ verifySpec,
249
+ handoffEvery,
250
+ maxDesignRounds,
251
+ stopAfter,
252
+ },
253
+ };
155
254
 
156
- const fingerprint = integrityChecker({ cwd, testPath: state.testPath });
157
- const integrity = compareFingerprints(state.testFingerprint, fingerprint);
158
- // 위반이 없을 때만 기준선을 갱신한다. 갱신해버리면 약화된 상태가 다음 회차의
159
- // 기준이 되어 에이전트가 그대로 빠져나간다.
160
- if (integrity.ok) state.testFingerprint = fingerprint;
255
+ // ---- stage 머신 ----
256
+ // 다음 stage 정하는 권한은 여기 한 곳에만 있다. 핸들러는 지시만 돌려준다.
257
+ // 재개 stage 이미 BUILD 이후면 빌드 루프를 다시 돌지 않는다 — 통과한 빌드를
258
+ // 다시 돌리면 비용을 번 내고, 최악의 경우 이미 배포된 코드를 또 고친다.
259
+ if (startFrom) state.stage = startFrom;
260
+ else if (!POST_BUILD_STAGES.includes(state.stage)) {
261
+ state.stage = discoverEnabled && !state.discovery ? 'DISCOVER' : 'BUILD';
262
+ }
161
263
 
162
- // L8: baseline 에 있던 파일이 사라졌는가 = 에이전트가 기존 파일을 삭제. 차단하지 않고
163
- // 경고만 한다(체크포인트로 되돌릴 수 있고 판단은 사람 몫). 누적 관측이라 Gaps 에도 실린다.
164
- if (baselineFiles) {
165
- const deleted = detectDeletions(baselineFiles, await fileLister({ cwd }));
166
- const fresh = deleted.filter((f) => !(state.deletedFiles ?? []).includes(f));
167
- if (fresh.length) {
168
- state.deletedFiles = [...(state.deletedFiles ?? []), ...fresh];
169
- logger(`[hi-loop] ⚠️ 지정 경로 밖 파일 ${fresh.length}개가 삭제됐습니다 (차단 안 함 — hi-loop rollback 으로 복원 가능):`);
170
- for (const f of fresh) logger(`[hi-loop] - ${f}`);
171
- }
264
+ while (true) {
265
+ if (state.stage === 'DONE') {
266
+ return finish('passed', { detail: `누적 비용 $${state.costUsd.toFixed(2)}` });
172
267
  }
173
268
 
174
- // Tier 1 (기계, 최종 권한): exit code + 무결성.
175
- let passed = Boolean(check?.ok) && integrity.ok;
176
-
177
- // Tier 2 (모델, 하향 전용): Tier 1 통과일 때만 호출. 스펙 대비 구현을 심판한다.
178
- // Tier 1 실패를 통과로 올릴 수는 절대 없다 — reject 만 가능하다(L9, bkit 중심 명제).
179
- let specReject = '';
180
- if (passed && verifySpec) {
181
- const verdict = await specVerifier({ specPath: state.specPath, cwd });
182
- if (verdict?.verdict === 'reject') {
183
- passed = false;
184
- specReject = verdict.reason || '스펙과 어긋남';
185
- logger(`[hi-loop] 🔬 스펙 검증 기각 — 테스트는 통과했으나 스펙 요구를 못 채웠습니다: ${truncate(specReject, 200)}`);
186
- } else {
187
- state.specVerified = true; // Gaps 보고가 "스펙 미검증" 대신 "검증 통과"를 쓰게 한다.
188
- logger(`[hi-loop] 🔬 스펙 검증 통과.`);
189
- }
269
+ if (state.stage === 'BUILD') {
270
+ state.stage = 'BUILD';
271
+ state.status = 'running';
272
+ saveState(statePath, state);
273
+ const built = await runBuildLoop(ctx);
274
+ if (built.stopped) return stopResult('PLAN');
275
+ if (!built.ok) return finish('failed', { stopReason: built.stopReason, detail: built.detail });
276
+ if (shouldStopAfter('BUILD')) return stopResult('BUILD');
277
+ state.stage = nextAfterBuild();
278
+ saveState(statePath, state);
279
+ continue;
190
280
  }
191
281
 
192
- state.history.push({
193
- iteration: state.iteration,
194
- phase: 'CHECK',
195
- ok: passed,
196
- summary: passed
197
- ? 'tests passed + spec verified'
198
- : truncate(
199
- specReject
200
- ? `스펙 검증 기각: ${specReject}`
201
- : integrity.ok
202
- ? output
203
- : violationMessage(integrity.violations),
204
- LIMITS.historySummary,
205
- ),
206
- });
282
+ const handler = STAGE_HANDLERS[state.stage];
283
+ if (!handler) throw new Error(`알 수 없는 stage: ${state.stage}`);
207
284
 
208
- if (!integrity.ok) {
209
- logger(`[hi-loop] 🚨 테스트 무결성 위반 — ${integrity.violations.length}건. 통과로 인정하지 않습니다.`);
210
- for (const v of integrity.violations) logger(`[hi-loop] - ${v}`);
211
- }
212
-
213
- if (passed) {
214
- state.status = 'passed';
215
- state.phase = 'DONE';
216
- state.lastError = '';
217
- const saved = saveState(statePath, state);
218
- logger(`[hi-loop] ✅ ${state.iteration}회차에 통과했습니다. (누적 $${state.costUsd.toFixed(2)})`);
219
- // passed 야말로 Gaps 를 붙여야 할 자리다 — false green 이 조용히 지나가는 지점이므로.
220
- const gaps = reportGaps(saved);
221
- logger(gaps);
222
- await notify({ type: 'passed', iteration: state.iteration, detail: `누적 비용 $${state.costUsd.toFixed(2)}` });
223
- return { status: 'passed', iterations: state.iteration, state: saved, report: gaps };
224
- }
285
+ const done = state.stage;
286
+ const directive = await handler(ctx);
225
287
 
226
- // ---- ACT: 에러를 다음 루프의 연료로 (FR-2.4) ----
227
- // 무결성 위반은 에러 **뒤**에 붙인다. 절단이 tail-biased 이므로(§5.2) 꼬리가 살아남는다.
228
- // 앞에 붙이면 여기서든 saveState 에서든 위반 사실이 먼저 잘려나가고, 에이전트는
229
- // 자기가 뭘 어겼는지 모른 채 같은 짓을 반복한다.
230
- // 실패의 정체를 셋 중 하나로 조립한다: 스펙 기각 / 무결성 위반 / 테스트 에러.
231
- // 스펙 기각이면 테스트는 통과했으니 output 대신 스펙 사유가 다음 루프의 연료다.
232
- let failure;
233
- if (specReject) {
234
- failure = `스펙 검증이 이 구현을 기각했다. 테스트는 통과했지만 스펙 요구를 못 채웠다:\n${specReject}\n스펙(${state.specPath})을 다시 읽고 그 요구를 충족하도록 구현을 고쳐라. 테스트만 통과시키는 것으로는 부족하다.`;
235
- } else {
236
- const body = output || `테스트가 코드 ${check?.code}로 실패했습니다.`;
237
- failure = integrity.ok ? body : `${body}\n\n${violationMessage(integrity.violations)}`;
238
- }
239
- state.lastError = truncate(failure, LIMITS.lastError);
240
- state.phase = 'ACT';
288
+ if (directive.pause) return pauseResult();
289
+ if (directive.finish) return finish('failed', directive.finish);
290
+ if (shouldStopAfter(done)) return stopResult(done);
241
291
 
242
- // ---- 정체 감지 (L2) ----
243
- // 판정 신호는 스펙 기각이면 기각 사유, 무결성 위반이면 그 위반, 아니면 에러 지문이다.
244
- // 같은 실패가 stagnationLimit 회 연속이면 이 접근으로는 못 고친다는 뜻 —
245
- // 같은 값을 태우며 maxLoops 를 다 쓰는 것은 순수한 낭비다(실측: 함정에 $3.07).
246
- const sig = specReject
247
- ? `SPEC:${errorSignature(specReject)}`
248
- : integrity.ok
249
- ? errorSignature(output)
250
- : `INTEGRITY:${integrity.violations.join('|')}`;
251
- state.stagnantRuns = sig && sig === state.errorSig ? state.stagnantRuns + 1 : 1;
252
- state.errorSig = sig;
292
+ state.stage = directive.next;
253
293
  saveState(statePath, state);
254
-
255
- if (integrity.ok) {
256
- logger(`[hi-loop] ❌ 테스트 실패 (code ${check?.code}) — 치유를 시도합니다.`);
257
- }
258
-
259
- if (stagnationLimit != null && state.stagnantRuns >= stagnationLimit && state.iteration < maxLoops) {
260
- state.status = 'failed';
261
- state.phase = 'DONE';
262
- state.stopReason = 'stagnated';
263
- const saved = saveState(statePath, state);
264
- logger(`[hi-loop] 🔁 같은 실패가 ${state.stagnantRuns}회 반복 — 접근이 막혔습니다. ${state.iteration}회차에서 중단합니다. (누적 $${state.costUsd.toFixed(2)})`);
265
- await notify({
266
- type: 'failed',
267
- iteration: state.iteration,
268
- detail: `정체: 같은 실패 ${state.stagnantRuns}회 반복`,
269
- });
270
- return { status: 'failed', iterations: state.iteration, state: saved };
271
- }
272
-
273
- // ---- 세션 핸드오프 & 컨텍스트 다이어트 (FR-3.2) ----
274
- if (state.iteration % handoffEvery === 0 && state.iteration < maxLoops) {
275
- state.sessionId = null;
276
- state.sessionSerial += 1;
277
- saveState(statePath, state);
278
- logger(`[hi-loop] ♻️ 세션 핸드오프 — 압축 상태만 session #${state.sessionSerial}에 넘깁니다.`);
279
- await notify({
280
- type: 'handoff',
281
- iteration: state.iteration,
282
- detail: `session #${state.sessionSerial} 로 컨텍스트 다이어트 후 재개`,
283
- });
284
- }
285
294
  }
286
-
287
- state.status = 'failed';
288
- state.phase = 'DONE';
289
- state.stopReason = 'maxLoops';
290
- const saved = saveState(statePath, state);
291
- logger(`[hi-loop] 한도(${maxLoops}회)를 소진했지만 테스트가 통과하지 못했습니다. (누적 $${state.costUsd.toFixed(2)})`);
292
- await notify({ type: 'failed', iteration: state.iteration, detail: truncate(state.lastError, 500) });
293
- return { status: 'failed', iterations: state.iteration, state: saved };
294
295
  }
295
296
 
296
297
  export default runLoop;
298
+
package/src/mcp-server.js CHANGED
@@ -4,7 +4,8 @@
4
4
  * ⚠️ stdout 은 MCP 프로토콜 채널이다. 로그는 반드시 stderr 로만 낸다 (FR-4.2).
5
5
  */
6
6
  import { runLoop } from './loop.js';
7
- import { loadState, statePathFor, summarizeState, resetState } from './state.js';
7
+ import { loadState, saveState, statePathFor, summarizeState, resetState } from './state.js';
8
+ import { applyAnswer, formatAsk } from './ask.js';
8
9
  import { rollbackTo } from './checkpoint.js';
9
10
  import { runSetup } from '../bin/setup.js';
10
11
 
@@ -17,11 +18,43 @@ const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
17
18
  export const tools = {
18
19
  hiloop_run: {
19
20
  description:
20
- '목표(goal)를 받아 PLAN→DO→CHECK→HEAL 자가 치유 루프를 실행한다. spec/테스트를 먼저 만들고, 테스트가 통과할 때까지 최대 maxLoops회 반복한다.',
21
- handler: async ({ goal, testCommand = 'npm test', maxLoops = 10, verifySpec = false, cwd = process.cwd() }) => {
21
+ '목표(goal)를 받아 PLAN→DO→CHECK→HEAL 자가 치유 루프를 실행한다. spec/테스트를 먼저 만들고, 테스트가 통과할 때까지 최대 maxLoops회 반복한다. full=true 면 발굴·설계리뷰·코드리뷰까지 도는 전체 라이프사이클로 실행한다. ship/watch 를 주면 배포와 헬스체크까지 진행한다.',
22
+ handler: async ({
23
+ goal,
24
+ testCommand = 'npm test',
25
+ maxLoops = 10,
26
+ verifySpec = false,
27
+ full = false,
28
+ ship,
29
+ watch,
30
+ stopAfter,
31
+ cwd = process.cwd(),
32
+ }) => {
22
33
  if (!goal) return fail('goal 은 필수입니다.');
23
- const result = await runLoop({ goal, testCommand, maxLoops, verifySpec, cwd, logger: log });
24
- const head = result.status === 'passed' ? '✅ 통과' : '❌ 실패';
34
+ const result = await runLoop({
35
+ goal,
36
+ testCommand,
37
+ maxLoops,
38
+ verifySpec,
39
+ full: Boolean(full),
40
+ shipCommand: ship || null,
41
+ watchCommand: watch || null,
42
+ stopAfter: stopAfter ? String(stopAfter).toUpperCase() : null,
43
+ cwd,
44
+ logger: log,
45
+ });
46
+
47
+ // 사람이 골라야 하는 지점에서 멈췄다. 호스트 LLM 이 이 질문을 사용자에게 그대로
48
+ // 제시하고 hiloop_answer 로 답을 돌려주면 재개된다 — MCP 의 단일 요청/응답
49
+ // 제약과 충돌하지 않는 유일한 방법이다.
50
+ if (result.status === 'awaiting') {
51
+ return text(
52
+ `⏸ 사용자의 선택이 필요합니다. 아래 질문을 그대로 사용자에게 제시하고, 답을 받아 hiloop_answer 를 호출하세요.\n\n${formatAsk(result.ask)}\n\n(hiloop_answer 인자: choice="${(result.ask.options ?? []).map((o) => o.key).join('" 또는 "')}", 필요하면 note)`,
53
+ );
54
+ }
55
+
56
+ const head =
57
+ result.status === 'passed' ? '✅ 통과' : result.status === 'stopped' ? `⏹ ${result.stoppedAt} 까지 진행 후 정지` : '❌ 실패';
25
58
  // 통과 시 Gaps 를 붙인다 — 에이전트가 도구로 이 결과를 받을 때 false green 을
26
59
  // 사실로 착각하지 않도록. summarizeState 는 상태 요약, report 는 검증 한계.
27
60
  const gaps = result.report ? `\n\n${result.report}` : '';
@@ -30,6 +63,20 @@ export const tools = {
30
63
  );
31
64
  },
32
65
  },
66
+ hiloop_answer: {
67
+ description:
68
+ 'hiloop_run 이 반환한 대기 중인 질문에 답한다. choice 는 제시된 선택지의 키(a, b, ...)이고, note 로 자유 서술을 덧붙이거나 대신할 수 있다. 답한 뒤 hiloop_run 을 같은 goal 로 다시 호출하면 이어서 진행된다.',
69
+ handler: async ({ choice, note = '', cwd = process.cwd() } = {}) => {
70
+ const statePath = statePathFor(cwd);
71
+ const state = loadState(statePath);
72
+ if (!state?.ask) return fail('대기 중인 질문이 없습니다.');
73
+ const res = applyAnswer(state, { choice, note });
74
+ if (!res.ok) return fail(res.reason);
75
+ saveState(statePath, res.state);
76
+ const last = res.state.assumptions[res.state.assumptions.length - 1];
77
+ return text(`✅ 기록했습니다: ${last.text}\n같은 goal 로 hiloop_run 을 다시 호출하면 이어서 진행합니다.`);
78
+ },
79
+ },
33
80
  hiloop_status: {
34
81
  description: '현재 워크스페이스의 .agent-state.json 루프 상태 요약을 반환한다.',
35
82
  handler: async ({ cwd = process.cwd() } = {}) => text(summarizeState(loadState(statePathFor(cwd)))),
@@ -111,12 +158,32 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
111
158
  testCommand: z.string().optional().describe('검증 명령 (기본 "npm test")'),
112
159
  maxLoops: z.number().int().min(1).max(50).optional().describe('최대 루프 횟수 (기본 10)'),
113
160
  verifySpec: z.boolean().optional().describe('통과 시 별도 검증자로 스펙 대비 구현을 심판 (하향 전용, 비용 증가)'),
161
+ full: z.boolean().optional().describe('전체 라이프사이클(발굴·설계리뷰·코드리뷰)까지 실행 (비용 증가)'),
162
+ ship: z.string().optional().describe('배포 명령. 주면 테스트 통과 후 실행하고 exit code 로 판정한다 (실행 전 사용자 확인)'),
163
+ watch: z.string().optional().describe('배포 후 헬스체크 명령. 일정 시간 연속 통과해야 성공이다'),
164
+ stopAfter: z
165
+ .enum(['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH'])
166
+ .optional()
167
+ .describe('이 단계까지만 진행하고 멈춘다'),
114
168
  cwd: cwdSchema,
115
169
  },
116
170
  },
117
171
  wrap(tools.hiloop_run.handler),
118
172
  );
119
173
 
174
+ server.registerTool(
175
+ 'hiloop_answer',
176
+ {
177
+ description: tools.hiloop_answer.description,
178
+ inputSchema: {
179
+ choice: z.string().optional().describe('선택지 키 (a, b, ...) 또는 선택지 라벨'),
180
+ note: z.string().optional().describe('자유 서술. 선택지에 없는 답을 줄 때는 이것만으로도 된다'),
181
+ cwd: cwdSchema,
182
+ },
183
+ },
184
+ wrap(tools.hiloop_answer.handler),
185
+ );
186
+
120
187
  server.registerTool(
121
188
  'hiloop_status',
122
189
  { description: tools.hiloop_status.description, inputSchema: { cwd: cwdSchema } },