@tuzi-ince/hi-loop 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -3
- package/bin/hi-loop.js +85 -32
- package/docs/design.md +77 -0
- package/docs/guide.md +73 -2
- package/docs/spec.md +138 -0
- package/package.json +1 -1
- package/src/ask.js +152 -0
- package/src/build.js +252 -0
- package/src/cli-options.js +134 -0
- package/src/discover.js +118 -0
- package/src/loop.js +208 -206
- package/src/mcp-server.js +72 -5
- package/src/prompts.js +138 -0
- package/src/report.js +85 -0
- package/src/review.js +125 -0
- package/src/ship.js +82 -0
- package/src/stages.js +215 -0
- package/src/state.js +98 -54
- package/src/telegram.js +1 -1
- package/src/verify.js +2 -1
package/src/loop.js
CHANGED
|
@@ -1,48 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 라이프사이클 stage 머신 (FR-10 ~ FR-15) + 실행 맥락 조립.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
15
|
+
import { makeIntegrityChecker } from './integrity.js';
|
|
19
16
|
import { acquireLock } from './lock.js';
|
|
20
|
-
import { makeCheckpointer, makeFileLister
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
const
|
|
141
|
-
state.
|
|
142
|
-
state.
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
193
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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 ({
|
|
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({
|
|
24
|
-
|
|
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 } },
|