@tuzi-ince/hi-loop 0.3.1 → 0.3.3
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 +73 -1
- package/bin/hi-loop.js +29 -92
- package/bin/setup.js +46 -2
- package/docs/guide.md +34 -6
- package/docs/spec.md +60 -1
- package/package.json +1 -1
- package/skills/{hi-loop → flow}/SKILL.md +1 -1
- package/src/cli-commands.js +100 -0
- package/src/cli-options.js +6 -0
- package/src/loop.js +47 -56
- package/src/mcp-server.js +25 -0
- package/src/preflight.js +119 -0
- package/src/prompts.js +10 -3
- package/src/reconcile.js +101 -0
- package/src/resume.js +8 -5
- package/src/runners.js +8 -2
- package/src/stage-context.js +46 -0
- package/src/state.js +11 -4
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI 보조 명령 구현 — answer / rollback / config (bin/hi-loop.js 에서 분리, NFR-5).
|
|
3
|
+
*
|
|
4
|
+
* bin 은 인자 파싱과 라우팅만 맡고, 각 명령의 실제 동작은 여기 있다. 각 함수는 stdout/stderr 로
|
|
5
|
+
* 직접 내고 **exit code 를 반환**한다 — bin 은 그 코드를 그대로 돌려주기만 한다.
|
|
6
|
+
*
|
|
7
|
+
* 정적 import 를 쓴다: 여기서 부르는 모듈(state·ask·checkpoint·config)은 MCP SDK 에 의존하지
|
|
8
|
+
* 않으므로 CLI 경량성(NFR-1)을 해치지 않는다. bin 은 이 파일 자체를 명령별로 lazy import 한다.
|
|
9
|
+
*/
|
|
10
|
+
import { loadState, saveState, statePathFor } from './state.js';
|
|
11
|
+
import { applyAnswer } from './ask.js';
|
|
12
|
+
import { rollbackTo } from './checkpoint.js';
|
|
13
|
+
import * as cfg from './config.js';
|
|
14
|
+
|
|
15
|
+
/** hi-loop answer <선택> [--note "..."] — 대기 중인 질문에 답하고 루프를 재개 가능 상태로 되돌린다. */
|
|
16
|
+
export async function runAnswer({ cwd, args }) {
|
|
17
|
+
const statePath = statePathFor(cwd);
|
|
18
|
+
const state = loadState(statePath);
|
|
19
|
+
if (!state?.ask) {
|
|
20
|
+
process.stderr.write('대기 중인 질문이 없습니다.\n');
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
const note = args.note && args.note !== true ? String(args.note) : '';
|
|
24
|
+
const res = applyAnswer(state, { choice: args._[1], note });
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
process.stderr.write(`${res.reason}\n`);
|
|
27
|
+
return 2;
|
|
28
|
+
}
|
|
29
|
+
saveState(statePath, res.state);
|
|
30
|
+
const last = res.state.assumptions[res.state.assumptions.length - 1];
|
|
31
|
+
process.stdout.write(`✅ 기록했습니다: ${last.text}\n 이어서 진행하려면 같은 goal 로 hi-loop run 을 다시 실행하세요.\n`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** hi-loop rollback [--to N] — N회차 직전 체크포인트로 추적 파일을 복원한다(기본: 최신). */
|
|
36
|
+
export async function runRollback({ cwd, args }) {
|
|
37
|
+
const state = loadState(statePathFor(cwd));
|
|
38
|
+
const checkpoints = state?.checkpoints ?? [];
|
|
39
|
+
if (checkpoints.length === 0) {
|
|
40
|
+
process.stderr.write('되돌릴 체크포인트가 없습니다 (git 저장소가 아니거나 아직 스냅샷이 없음).\n');
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
// --to N 이면 N회차 직전 체크포인트, 없으면 가장 최근.
|
|
44
|
+
const toIter = args.to !== undefined && args.to !== true ? Number(args.to) : null;
|
|
45
|
+
const cp = toIter != null ? checkpoints.find((c) => c.iteration === toIter) : checkpoints[checkpoints.length - 1];
|
|
46
|
+
if (!cp) {
|
|
47
|
+
process.stderr.write(`${toIter}회차 체크포인트가 없습니다. 있는 회차: ${checkpoints.map((c) => c.iteration).join(', ')}\n`);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
const res = await rollbackTo({ cwd, sha: cp.sha });
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
process.stderr.write(`롤백 실패: ${res.reason}\n`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
process.stdout.write(`✅ ${cp.iteration}회차 직전 상태로 추적 파일을 복원했습니다 (${cp.sha.slice(0, 8)}).\n`);
|
|
56
|
+
// 되돌리지 못한 것을 되돌렸다고 말하지 않는다. git 은 추적 밖 파일을 트리 연산으로
|
|
57
|
+
// 지우지 않고, `git clean` 은 사용자의 정상 파일까지 지운다 — 그래서 판단은 사람에게 준다.
|
|
58
|
+
if (res.untrackedRemain?.length) {
|
|
59
|
+
process.stdout.write(
|
|
60
|
+
`⚠️ 추적되지 않는 파일 ${res.untrackedRemain.length}개는 그대로 남아 있습니다(에이전트가 새로 만든 것일 수 있습니다):\n` +
|
|
61
|
+
res.untrackedRemain.slice(0, 10).map((f) => ` - ${f}\n`).join('') +
|
|
62
|
+
(res.untrackedRemain.length > 10 ? ` … 외 ${res.untrackedRemain.length - 10}개\n` : '') +
|
|
63
|
+
' 필요하면 직접 지우세요. hi-loop 은 사용자의 정상 파일을 지울 수 없어 판단하지 않습니다.\n',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (res.safetySha) process.stdout.write(` 되돌리기 전 상태는 ${res.safetySha.slice(0, 8)} 에 스냅샷됨.\n`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* hi-loop config [set <k> <v> | add-branch <b> | remove-branch <b>] — 브랜치·커밋 정책(.hi-loop.json).
|
|
72
|
+
* 인자 없으면 현재 정책을 출력한다.
|
|
73
|
+
*/
|
|
74
|
+
export async function runConfig({ cwd, args }) {
|
|
75
|
+
const sub = args._[1];
|
|
76
|
+
if (!sub) {
|
|
77
|
+
process.stdout.write(`${JSON.stringify(cfg.loadConfig(cwd), null, 2)}\n`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
if (sub === 'set') {
|
|
81
|
+
const r = cfg.setPolicy(cwd, args._[2], args._[3]);
|
|
82
|
+
if (!r.ok) {
|
|
83
|
+
process.stderr.write(`${r.reason}\n`);
|
|
84
|
+
return 2;
|
|
85
|
+
}
|
|
86
|
+
process.stdout.write(`✓ ${args._[2]} = ${args._[3]}\n`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (sub === 'add-branch' || sub === 'remove-branch') {
|
|
90
|
+
const r = sub === 'add-branch' ? cfg.addBranch(cwd, args._[2]) : cfg.removeBranch(cwd, args._[2]);
|
|
91
|
+
if (!r.ok) {
|
|
92
|
+
process.stderr.write(`${r.reason}\n`);
|
|
93
|
+
return 2;
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(`✓ 보호 브랜치: ${r.config.protectedBranches.join(', ')}\n`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
process.stderr.write(`알 수 없는 config 하위명령: ${sub} (가능: set, add-branch, remove-branch)\n`);
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
package/src/cli-options.js
CHANGED
|
@@ -118,6 +118,9 @@ export function parseRunOptions(args, { staged = {} } = {}) {
|
|
|
118
118
|
ok: true,
|
|
119
119
|
options: {
|
|
120
120
|
testCommand: str('test') ?? 'npm test',
|
|
121
|
+
// FR-19: 스펙 오라클을 이 경로로 고정한다. goal 해시 분기를 끄고, 여러 요청이
|
|
122
|
+
// 같은 표준 문서를 오라클로 공유하게 해 문서↔소스 drift 를 막는다.
|
|
123
|
+
specPath: str('spec'),
|
|
121
124
|
budgetUsd,
|
|
122
125
|
stagnationLimit,
|
|
123
126
|
verifySpec: Boolean(args['verify-spec']),
|
|
@@ -141,6 +144,9 @@ export function parseRunOptions(args, { staged = {} } = {}) {
|
|
|
141
144
|
codeReview: triState('review', 'no-review'),
|
|
142
145
|
designReview: triState('design-review', 'no-design-review'),
|
|
143
146
|
discover: triState('discover', 'no-discover'),
|
|
147
|
+
reconcile: triState('reconcile', 'no-reconcile'),
|
|
148
|
+
// FR-21: 정합 게이트가 대조할 표준 문서 경로(기본 docs/spec.md). 지정하면 게이트가 켜진다.
|
|
149
|
+
reconcileSpec: str('reconcile-spec'),
|
|
144
150
|
startFrom,
|
|
145
151
|
stopAfter,
|
|
146
152
|
...durations,
|
package/src/loop.js
CHANGED
|
@@ -26,6 +26,10 @@ import { makeDiscoverer } from './discover.js';
|
|
|
26
26
|
import { formatAsk, createAsk, pauseForAsk, shouldAsk, DEFAULT_ASK_POLICY } from './ask.js';
|
|
27
27
|
import { STAGE_HANDLERS } from './stages.js';
|
|
28
28
|
import { runBuildLoop } from './build.js';
|
|
29
|
+
import { buildStageContext } from './stage-context.js';
|
|
30
|
+
import { makeReconciler, reconcileAsk } from './reconcile.js';
|
|
31
|
+
import { existsSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
29
33
|
|
|
30
34
|
// loop.js 는 이 엔진의 정문이다. 호출부가 내부 파일 배치를 알 필요가 없도록 여기서 모아 낸다.
|
|
31
35
|
export { phaseForIteration } from './build.js';
|
|
@@ -59,6 +63,7 @@ async function runLoopBody({
|
|
|
59
63
|
stagnationLimit = 3,
|
|
60
64
|
verifySpec = false,
|
|
61
65
|
flakyProbe = false,
|
|
66
|
+
specPath = null, // --spec: 스펙 오라클을 표준 문서로 고정(FR-19). null=엔진이 경로 결정.
|
|
62
67
|
checks = null,
|
|
63
68
|
handoffEvery = 4,
|
|
64
69
|
// ---- 라이프사이클 (FR-13, FR-14) ----
|
|
@@ -70,6 +75,8 @@ async function runLoopBody({
|
|
|
70
75
|
designReview = null,
|
|
71
76
|
maxDesignRounds = 2,
|
|
72
77
|
discover = null,
|
|
78
|
+
reconcile = null, // FR-21: 문서 정합 게이트. null=자동(--full 또는 reconcileSpec 지정 시 켜짐).
|
|
79
|
+
reconcileSpec = null, // 대조할 표준 문서 경로. null=기본 docs/spec.md.
|
|
73
80
|
full = false,
|
|
74
81
|
// FR-15: 어느 경로로 들어와도 **같은 상태 머신**을 쓴다. 별도 코드 경로를 만들지 않는다
|
|
75
82
|
// — 분기가 둘이면 반드시 한쪽이 썩는다.
|
|
@@ -99,6 +106,7 @@ async function runLoopBody({
|
|
|
99
106
|
codeReviewer = makeCodeReviewer(),
|
|
100
107
|
designReviewer = makeDesignReviewer(),
|
|
101
108
|
discoverer = makeDiscoverer(),
|
|
109
|
+
reconciler = makeReconciler(),
|
|
102
110
|
shipper = makeShipper(),
|
|
103
111
|
watcher = makeWatcher(),
|
|
104
112
|
brancher = makeBrancher(),
|
|
@@ -117,7 +125,7 @@ async function runLoopBody({
|
|
|
117
125
|
throw new TypeError('budgetUsd 는 0 보다 큰 숫자여야 합니다.');
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
let state = resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd });
|
|
128
|
+
let state = resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd, specPath });
|
|
121
129
|
|
|
122
130
|
// 답 안 받은 질문이 남아 있으면 여기서 끝이다. 재개하려면 `hi-loop answer` 를 거쳐야 한다.
|
|
123
131
|
// 이 관문이 없으면 run 을 다시 부르는 것만으로 사람이 고르라던 분기를 건너뛰게 된다.
|
|
@@ -164,6 +172,8 @@ async function runLoopBody({
|
|
|
164
172
|
const reviewEnabled = codeReview === null ? full || Boolean(shipCommand) : Boolean(codeReview);
|
|
165
173
|
const designReviewEnabled = designReview === null ? full || Boolean(shipCommand) : Boolean(designReview);
|
|
166
174
|
const discoverEnabled = discover === null ? full : Boolean(discover);
|
|
175
|
+
// 표준 문서를 명시(reconcileSpec)하면 게이트를 쓰겠다는 뜻이므로 자동으로 켜진다(--no-reconcile 이 덮는다).
|
|
176
|
+
const reconcileEnabled = reconcile === null ? full || Boolean(reconcileSpec) : Boolean(reconcile);
|
|
167
177
|
|
|
168
178
|
// 라우팅: 켜진 다음 단계로만 넘어간다(기본은 BUILD 하나). 커밋이 켜지면 리뷰된 코드를 먼저 커밋한다.
|
|
169
179
|
const commitEnabled = Boolean(commit);
|
|
@@ -187,6 +197,29 @@ async function runLoopBody({
|
|
|
187
197
|
return { pause: true };
|
|
188
198
|
};
|
|
189
199
|
|
|
200
|
+
// FR-21: 문서 정합 게이트. 기존 표준 문서(docs/spec.md)가 있고 이번 요청이 그것과 별개
|
|
201
|
+
// 스펙으로 포크될 참이면, 조용히 갈라지기 전에 모순 여부를 판정한다. 모순이면 사람에게 묻는다.
|
|
202
|
+
// 대조 대상: 명시(--reconcile-spec)가 최우선. 없으면 사람이 관리하는 표준 문서(docs/design.md)를
|
|
203
|
+
// 우선하고, 그것도 없으면 docs/spec.md 로 폴백한다. spec.md 는 PLAN 이 쓴 에이전트 산출물이라
|
|
204
|
+
// 오라클로는 약하다 — 사람 문서가 있으면 그쪽이 옳다. (게이트 발동 자체는 여전히 opt-in 이다.)
|
|
205
|
+
const STANDING_SPEC = reconcileSpec
|
|
206
|
+
|| (existsSync(join(cwd, 'docs/design.md')) ? 'docs/design.md' : 'docs/spec.md');
|
|
207
|
+
if (reconcileEnabled && !state.reconciled && !state.specPinned
|
|
208
|
+
&& state.specPath !== STANDING_SPEC && existsSync(join(cwd, STANDING_SPEC))) {
|
|
209
|
+
// 이미 답한 재개면 판정기를 다시 부르지 않는다(비용 0). gate 가 저장된 답을 돌려준다.
|
|
210
|
+
const answered = [...(state.answers ?? [])].some((a) => a.stage === 'RECONCILE');
|
|
211
|
+
const v = answered ? { verdict: 'conflict', reason: '' } : await reconciler({ goal, specPath: STANDING_SPEC, cwd });
|
|
212
|
+
if (v.verdict === 'conflict') {
|
|
213
|
+
const g = gate(reconcileAsk({ standingSpec: STANDING_SPEC, reason: v.reason }));
|
|
214
|
+
if (g.pause) return pauseResult();
|
|
215
|
+
if (g.choice === 'a') { state.specPath = STANDING_SPEC; state.specPinned = true; }
|
|
216
|
+
else if (g.choice === 'c') return finish('failed', { stopReason: 'reconcile-aborted', detail: '문서 정리를 위해 중단' });
|
|
217
|
+
// 'b' 또는 null(ask never): 포크 경로를 그대로 쓴다. 미해결은 Gaps 에 남는다.
|
|
218
|
+
}
|
|
219
|
+
state.reconciled = true;
|
|
220
|
+
saveState(statePath, state);
|
|
221
|
+
}
|
|
222
|
+
|
|
190
223
|
/** 방금 끝낸 stage 가 정지 지점인가. 다음 stage 로 넘어가기 직전에만 묻는다. */
|
|
191
224
|
const shouldStopAfter = (stage) => stopAfter === stage;
|
|
192
225
|
|
|
@@ -194,65 +227,24 @@ async function runLoopBody({
|
|
|
194
227
|
* stage 핸들러가 받는 실행 맥락. `state` 는 getter/setter 라 핸들러가 재대입해도
|
|
195
228
|
* 여기 변수에 반영된다(가정 기록은 순수 함수라 새 객체를 만든다).
|
|
196
229
|
*/
|
|
197
|
-
const ctx = {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
},
|
|
201
|
-
set state(v) {
|
|
202
|
-
state = v;
|
|
203
|
-
},
|
|
204
|
-
goal,
|
|
205
|
-
cwd,
|
|
206
|
-
logger,
|
|
207
|
-
notify,
|
|
208
|
-
gate,
|
|
209
|
-
nextAfterReview,
|
|
210
|
-
nextAfterCommit,
|
|
211
|
-
statePath,
|
|
212
|
-
baselineFiles,
|
|
213
|
-
designReviewEnabled,
|
|
230
|
+
const ctx = buildStageContext({
|
|
231
|
+
getState: () => state,
|
|
232
|
+
setState: (v) => { state = v; },
|
|
214
233
|
save: () => saveState(statePath, state),
|
|
234
|
+
goal, cwd, logger, notify, gate,
|
|
235
|
+
nextAfterReview, nextAfterCommit, statePath, baselineFiles, designReviewEnabled,
|
|
215
236
|
ports: {
|
|
216
|
-
discoverer,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
shipper,
|
|
220
|
-
watcher,
|
|
221
|
-
brancher,
|
|
222
|
-
committer,
|
|
223
|
-
agentRunner,
|
|
224
|
-
testRunner,
|
|
225
|
-
integrityChecker,
|
|
226
|
-
checkpointer,
|
|
227
|
-
fileLister,
|
|
228
|
-
changeLister,
|
|
229
|
-
treeKeyReader,
|
|
230
|
-
specVerifier,
|
|
237
|
+
discoverer, codeReviewer, designReviewer, shipper, watcher, brancher, committer,
|
|
238
|
+
agentRunner, testRunner, integrityChecker, checkpointer, fileLister, changeLister,
|
|
239
|
+
treeKeyReader, specVerifier,
|
|
231
240
|
},
|
|
232
241
|
opts: {
|
|
233
|
-
shipCommand,
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
watchForMs,
|
|
238
|
-
watchEveryMs,
|
|
239
|
-
watchTolerate,
|
|
240
|
-
maxReviewRounds,
|
|
241
|
-
maxLoops,
|
|
242
|
-
budgetUsd,
|
|
243
|
-
testCommand,
|
|
244
|
-
stagnationLimit,
|
|
245
|
-
verifySpec,
|
|
246
|
-
flakyProbe,
|
|
247
|
-
checks,
|
|
248
|
-
handoffEvery,
|
|
249
|
-
maxDesignRounds,
|
|
250
|
-
stopAfter,
|
|
251
|
-
commit,
|
|
252
|
-
commitMessage,
|
|
253
|
-
commitStyle,
|
|
242
|
+
shipCommand, watchCommand, onShipFail, onWatchFail, watchForMs, watchEveryMs,
|
|
243
|
+
watchTolerate, maxReviewRounds, maxLoops, budgetUsd, testCommand, stagnationLimit,
|
|
244
|
+
verifySpec, flakyProbe, checks, handoffEvery, maxDesignRounds, stopAfter,
|
|
245
|
+
commit, commitMessage, commitStyle,
|
|
254
246
|
},
|
|
255
|
-
};
|
|
247
|
+
});
|
|
256
248
|
|
|
257
249
|
// ---- stage 머신 ----
|
|
258
250
|
// 다음 stage 를 정하는 권한은 여기 한 곳에만 있다. 핸들러는 지시만 돌려준다.
|
|
@@ -297,4 +289,3 @@ async function runLoopBody({
|
|
|
297
289
|
}
|
|
298
290
|
|
|
299
291
|
export default runLoop;
|
|
300
|
-
|
package/src/mcp-server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { loadState, saveState, statePathFor, summarizeState, resetState } from '
|
|
|
8
8
|
import { applyAnswer, formatAsk } from './ask.js';
|
|
9
9
|
import { rollbackTo } from './checkpoint.js';
|
|
10
10
|
import { runSetup } from '../bin/setup.js';
|
|
11
|
+
import { preflightAgent } from './preflight.js';
|
|
11
12
|
|
|
12
13
|
const log = (line) => process.stderr.write(`[hi-loop-mcp] ${line}\n`);
|
|
13
14
|
|
|
@@ -39,9 +40,16 @@ export const tools = {
|
|
|
39
40
|
ship,
|
|
40
41
|
watch,
|
|
41
42
|
stopAfter,
|
|
43
|
+
spec,
|
|
44
|
+
reconcile,
|
|
45
|
+
reconcileSpec,
|
|
42
46
|
cwd = defaultCwd(),
|
|
43
47
|
}) => {
|
|
44
48
|
if (!goal) return fail('goal 은 필수입니다.');
|
|
49
|
+
// 루프를 돌리기 전에 에이전트가 실행 가능한지 확인한다 — 미설치/실행불가면
|
|
50
|
+
// 회차를 태우지 않고 여기서 행동지침을 돌려준다(호스트 LLM 이 사용자에게 전달).
|
|
51
|
+
const pre = await preflightAgent();
|
|
52
|
+
if (!pre.ok) return fail(pre.message);
|
|
45
53
|
const result = await runLoop({
|
|
46
54
|
goal,
|
|
47
55
|
testCommand,
|
|
@@ -51,6 +59,9 @@ export const tools = {
|
|
|
51
59
|
shipCommand: ship || null,
|
|
52
60
|
watchCommand: watch || null,
|
|
53
61
|
stopAfter: stopAfter ? String(stopAfter).toUpperCase() : null,
|
|
62
|
+
specPath: spec || null,
|
|
63
|
+
reconcile: reconcile === undefined ? null : Boolean(reconcile),
|
|
64
|
+
reconcileSpec: reconcileSpec || null,
|
|
54
65
|
cwd,
|
|
55
66
|
logger: log,
|
|
56
67
|
});
|
|
@@ -136,6 +147,8 @@ export function wrap(handler) {
|
|
|
136
147
|
try {
|
|
137
148
|
return await handler(args ?? {});
|
|
138
149
|
} catch (err) {
|
|
150
|
+
// 환경 오류(에이전트 미설치·인증 만료 등)는 이미 행동지침 메시지다 — 그대로 전달한다.
|
|
151
|
+
if (err?.kind === 'environment') return fail(err.message);
|
|
139
152
|
return fail(`hi-loop 오류: ${err?.message ?? String(err)}`);
|
|
140
153
|
}
|
|
141
154
|
};
|
|
@@ -179,6 +192,18 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
|
|
|
179
192
|
.enum(['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH'])
|
|
180
193
|
.optional()
|
|
181
194
|
.describe('이 단계까지만 진행하고 멈춘다'),
|
|
195
|
+
spec: z
|
|
196
|
+
.string()
|
|
197
|
+
.optional()
|
|
198
|
+
.describe('스펙 오라클을 이 파일로 고정한다(예: docs/spec.md). 사람이 관리하는 표준 문서를 여러 요청이 공유하는 오라클로 삼아 문서↔소스 drift 를 막는다. 이미 내용이 있으면 PLAN 이 덮어쓰지 않는다.'),
|
|
199
|
+
reconcile: z
|
|
200
|
+
.boolean()
|
|
201
|
+
.optional()
|
|
202
|
+
.describe('문서 정합 게이트. 기존 표준 문서와 이 요청이 모순되면 조용히 포크하지 않고 사람에게 묻는다(고정/분기/중단). full=true 또는 reconcileSpec 지정 시 자동 켜짐.'),
|
|
203
|
+
reconcileSpec: z
|
|
204
|
+
.string()
|
|
205
|
+
.optional()
|
|
206
|
+
.describe('정합 게이트가 대조할 표준 문서 경로(기본 docs/spec.md). 예: docs/design.md. 지정하면 게이트가 켜진다.'),
|
|
182
207
|
cwd: cwdSchema,
|
|
183
208
|
},
|
|
184
209
|
},
|
package/src/preflight.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 에이전트 실행 환경 사전 점검(preflight) 및 실패 진단 — CLI/MCP 공용.
|
|
3
|
+
*
|
|
4
|
+
* 목적: "claude 가 없다 / 인증이 끊겼다 / 키가 없다"를 루프 한복판의 스택트레이스가 아니라
|
|
5
|
+
* **실행 전(또는 첫 실패 시) 한 줄의 행동지침**으로 바꾼다. 이 엔진의 전제는 "에이전트가
|
|
6
|
+
* 코드를 고친다"이므로, 에이전트를 못 부르는 것은 버그가 아니라 환경 문제다 — 그렇게 보여줘야 한다.
|
|
7
|
+
*
|
|
8
|
+
* 두 갈래로 나눠 본다(하나로 못 합치는 이유가 있다):
|
|
9
|
+
* - 설치/PATH/권한 문제(ENOENT·EACCES)는 API 를 때리지 않고 `--version` 으로 **공짜로** 잡힌다.
|
|
10
|
+
* 그래서 루프 시작 전에 미리(preflight) 확인한다.
|
|
11
|
+
* - 인증/구독/키 문제는 실제 호출 전에는 알 수 없다(`--version` 은 인증을 요구하지 않는다).
|
|
12
|
+
* 비용을 들여 사전 점검하지 않는다 — 대신 첫 호출의 stderr 를 패턴으로 읽어 힌트로 번역한다.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 환경/설정 오류임을 표시하는 에러. `kind==='environment'` 를 보고 CLI 최상위 핸들러는
|
|
18
|
+
* 스택트레이스 대신 메시지 한 줄만 내고, MCP 래퍼는 "hi-loop 오류:" 접두 없이 그대로 전달한다.
|
|
19
|
+
*/
|
|
20
|
+
export class AgentEnvError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'AgentEnvError';
|
|
24
|
+
this.kind = 'environment';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** spawn 자체가 실패한 원인(ENOENT 등)을 행동지침으로 번역한다. */
|
|
29
|
+
export function describeSpawnError(err, command) {
|
|
30
|
+
if (err?.code === 'ENOENT') {
|
|
31
|
+
return (
|
|
32
|
+
`에이전트 명령 '${command}' 를 찾을 수 없습니다.\n` +
|
|
33
|
+
` - Claude Code CLI 설치를 확인하세요: npm i -g @anthropic-ai/claude-code\n` +
|
|
34
|
+
` - 다른 에이전트/경로를 쓰려면 HILOOP_AGENT_CMD 로 지정하세요 (예: HILOOP_AGENT_CMD=/usr/local/bin/claude)`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (err?.code === 'EACCES') {
|
|
38
|
+
return `에이전트 명령 '${command}' 를 실행할 권한이 없습니다 (EACCES). 실행 권한을 확인하세요: chmod +x "$(command -v ${command})"`;
|
|
39
|
+
}
|
|
40
|
+
return `에이전트 실행 실패(${command}): ${err?.message ?? String(err)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 비정상 종료 stderr 에서 인증/구독/키 문제의 신호를 찾아 힌트를 만든다.
|
|
45
|
+
* 못 찾으면 null — 그때는 원문을 그대로 보여준다(우리가 모르는 실패를 인증 문제로 감추지 않는다).
|
|
46
|
+
* 순서가 곧 우선순위다: 구체적인 신호(키·결제)를 일반적인 신호(로그인)보다 먼저 맞춘다.
|
|
47
|
+
*/
|
|
48
|
+
const AUTH_PATTERNS = [
|
|
49
|
+
[/invalid api key|authentication_error|x-api-key|unauthorized|\b401\b/i, 'API 키가 없거나 유효하지 않습니다.'],
|
|
50
|
+
[/credit balance|insufficient|quota|billing|payment required|402/i, '크레딧/결제 한도 문제로 보입니다.'],
|
|
51
|
+
[/subscription|expired|plan (?:limit|expired)|entitlement/i, '구독/플랜이 만료됐거나 유효하지 않을 수 있습니다.'],
|
|
52
|
+
[/\/login|please log ?in|not logged in|run .*login|re-?authenticate|sign in/i, '로그인이 필요합니다.'],
|
|
53
|
+
[/api key|anthropic_api_key/i, 'API 키 설정을 확인하세요.'],
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export function detectAuthFailure(stderr) {
|
|
57
|
+
const text = typeof stderr === 'string' ? stderr : '';
|
|
58
|
+
for (const [re, hint] of AUTH_PATTERNS) {
|
|
59
|
+
if (re.test(text)) {
|
|
60
|
+
return `${hint} 에이전트 인증 상태를 확인하세요 — 예: 'claude' 로 로그인하거나 ANTHROPIC_API_KEY 를 설정하세요.`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 에이전트 바이너리가 실제로 실행 가능한지 `--version` 으로 확인한다(설치/PATH/권한).
|
|
68
|
+
* 인증을 요구하지 않으므로 비용 0. 인증은 여기서 검증하지 않는다.
|
|
69
|
+
* runner 를 주입할 수 있어 테스트에서 실제 spawn 없이 검증한다.
|
|
70
|
+
*
|
|
71
|
+
* 반환: `{ ok: true, code }` | `{ ok: false, err }` (spawn 실패) | `{ ok: false, code }` (비정상 종료)
|
|
72
|
+
*/
|
|
73
|
+
export function makeVersionProbe({ timeoutMs = 15000 } = {}) {
|
|
74
|
+
return (command) =>
|
|
75
|
+
new Promise((resolve) => {
|
|
76
|
+
let child;
|
|
77
|
+
try {
|
|
78
|
+
child = spawn(command, ['--version'], { env: process.env });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
resolve({ ok: false, err });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
child.kill('SIGKILL');
|
|
85
|
+
resolve({ ok: false, err: new Error(`'${command} --version' 이 ${timeoutMs}ms 안에 응답하지 않았습니다.`) });
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
child.on('error', (err) => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
resolve({ ok: false, err });
|
|
90
|
+
});
|
|
91
|
+
child.on('close', (code) => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
resolve({ ok: code === 0, code });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 루프 시작 전 에이전트 실행 가능성을 확인한다.
|
|
100
|
+
*
|
|
101
|
+
* **오탐 방지 원칙**: 진짜로 못 부르는 경우(spawn 실패=ENOENT·EACCES)에만 막는다.
|
|
102
|
+
* 바이너리가 실행은 됐는데 `--version` 이 0 이 아니면, 커스텀 에이전트가 `--version` 을
|
|
103
|
+
* 지원 안 하는 것일 수 있으므로 **막지 않는다** — 존재는 이미 증명됐다. 멀쩡한 셋업을
|
|
104
|
+
* preflight 가 죽이는 것이 못 잡는 것보다 나쁘다.
|
|
105
|
+
*
|
|
106
|
+
* 반환: `{ ok: true, warning? }` | `{ ok: false, message }`
|
|
107
|
+
*/
|
|
108
|
+
export async function preflightAgent({
|
|
109
|
+
command = process.env.HILOOP_AGENT_CMD || 'claude',
|
|
110
|
+
probe = makeVersionProbe(),
|
|
111
|
+
} = {}) {
|
|
112
|
+
const res = await probe(command);
|
|
113
|
+
if (res.ok) return { ok: true };
|
|
114
|
+
if (res.err) return { ok: false, message: describeSpawnError(res.err, command) };
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
warning: `'${command} --version' 이 code ${res.code} 로 끝났지만 명령은 존재하므로 진행합니다.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
package/src/prompts.js
CHANGED
|
@@ -42,19 +42,26 @@ export function contextBlock(state) {
|
|
|
42
42
|
return parts.join('\n\n');
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** PLAN: 코드보다 spec.md + 테스트 먼저 (FR-2.1) */
|
|
45
|
+
/** PLAN: 코드보다 spec.md + 테스트 먼저 (FR-2.1). --spec 고정 시 표준 문서를 오라클로 삼는다(FR-17). */
|
|
46
46
|
export function planPrompt(state) {
|
|
47
|
+
// 고정된 스펙(--spec)은 사람이 관리하는 표준 문서일 수 있다 — PLAN 이 덮어쓰면 오라클이 오염된다.
|
|
48
|
+
// 그래서 "이미 내용이 있으면 수정하지 말고 그대로 오라클로 삼으라"고 지시한다. 비었을 때만 작성한다.
|
|
49
|
+
const specStep = state.specPinned
|
|
50
|
+
? `1. \`${specPathOf(state)}\` 는 사용자가 **고정한 표준 스펙 문서(오라클)**다.
|
|
51
|
+
- 이미 내용이 있으면 **절대 수정하지 마라.** 그대로 진실로 받아들이고, 이 스펙에 맞춰 아래 테스트만 작성하라.
|
|
52
|
+
- 비어 있거나 없을 때만 이 경로에 스펙을 작성하라.`
|
|
53
|
+
: `1. \`${specPathOf(state)}\` 를 작성하라: 목표 해석, 상세 요구사항, 공개 API 시그니처, 수용 기준 목록.`;
|
|
47
54
|
return `당신은 TDD를 강제하는 수석 아키텍트다. 지금은 PLAN 단계다.
|
|
48
55
|
|
|
49
56
|
${contextBlock(state)}
|
|
50
57
|
|
|
51
58
|
## PLAN 단계에서 할 일 (이것만)
|
|
52
|
-
|
|
59
|
+
${specStep}
|
|
53
60
|
2. \`${testPathOf(state)}\` 를 작성하라: 위 수용 기준을 검증하는 실행 가능한 테스트.
|
|
54
61
|
- Node 내장 \`node:test\` + \`node:assert/strict\` 를 사용하라.
|
|
55
62
|
- 아직 존재하지 않는 구현을 import 해도 된다(이 단계에서 테스트는 실패하는 게 정상이다).
|
|
56
63
|
3. **구현 코드는 절대 작성하지 마라.** 이번 단계 산출물은 spec과 테스트뿐이다.
|
|
57
|
-
4. 위
|
|
64
|
+
4. 위 경로에만 써라. 다른 기존 파일은 건드리지 마라.
|
|
58
65
|
|
|
59
66
|
작업을 마치면 스펙 핵심을 15줄 이내로 요약해 마지막에 출력하라.
|
|
60
67
|
|
package/src/reconcile.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 문서 정합 게이트 (FR-21) — 새 요청이 기존 표준 문서와 모순되는가.
|
|
3
|
+
*
|
|
4
|
+
* 기본 동작은 goal 마다 스펙을 새로 포크한다(state.planPathsFor). 그래서 단말적 요청이
|
|
5
|
+
* 기존 `docs/spec.md` 와 어긋나도 조용히 갈라져 나갈 뿐, 아무도 그 모순을 보지 못한다 —
|
|
6
|
+
* 문서↔소스 갭의 근원이고, 그 갭이 환각의 빌미가 된다.
|
|
7
|
+
*
|
|
8
|
+
* 판정이 아니라 **표면화**다. 모순이면 상호배타 분기(ask, FR-16)로 멈춰 사람이 고른다:
|
|
9
|
+
* 기존 문서를 오라클로 고정(FR-19) / 새 스펙으로 분기 / 중단. `--verify-spec` 처럼 별도
|
|
10
|
+
* 세션·plan 모드(쓰기 금지)로 부르므로 구현자의 추론에 오염되지 않는다. opt-in(비용 증가).
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { buildAgentArgs, parseAgentOutput } from './runners.js';
|
|
16
|
+
|
|
17
|
+
const truncate = (s, n) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}…` : String(s ?? ''));
|
|
18
|
+
|
|
19
|
+
export function reconcilePrompt({ goal, specText }) {
|
|
20
|
+
return `당신은 새 요청과 기존 표준 스펙 문서의 **정합성**만 판정하는 독립 심판자다. 코드를 고치지 마라 — 판정만 한다.
|
|
21
|
+
|
|
22
|
+
## 기존 표준 스펙 문서
|
|
23
|
+
${truncate(specText, 6000)}
|
|
24
|
+
|
|
25
|
+
## 새 요청(goal)
|
|
26
|
+
${truncate(goal, 2000)}
|
|
27
|
+
|
|
28
|
+
## 판정
|
|
29
|
+
- 이 요청이 위 문서와 **모순**되는가? 문서가 정한 동작·제약·범위를 뒤집거나 어기면 모순이다.
|
|
30
|
+
- 문서에 없는 것을 **추가**하는 것은 모순이 아니다(확장은 정상). 확신이 없으면 ok 로 판정하라.
|
|
31
|
+
|
|
32
|
+
JSON 한 줄로만 답하라:
|
|
33
|
+
{"verdict": "ok" 또는 "conflict", "reason": "무엇이 어긋나는가(한국어 1~2문장)"}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 판정을 관대하게 파싱한다. 형식이 깨지면 ok 로 흘린다(애매한 것까지 막으면 정상 작업이 멈춘다). */
|
|
37
|
+
export function parseReconcile(text) {
|
|
38
|
+
try {
|
|
39
|
+
const m = String(text).match(/\{[\s\S]*"verdict"[\s\S]*\}/);
|
|
40
|
+
const obj = JSON.parse(m ? m[0] : text);
|
|
41
|
+
return {
|
|
42
|
+
verdict: obj.verdict === 'conflict' ? 'conflict' : 'ok',
|
|
43
|
+
reason: typeof obj.reason === 'string' ? obj.reason : '',
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
return { verdict: 'ok', reason: '정합 판정을 파싱하지 못해 통과로 처리' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** gate() 에 넘길 ask 스펙 — 상호배타 3지선다(고정 / 분기 / 중단). */
|
|
51
|
+
export function reconcileAsk({ standingSpec, reason }) {
|
|
52
|
+
return {
|
|
53
|
+
stage: 'RECONCILE',
|
|
54
|
+
question: `기존 표준 문서(${standingSpec})와 이 요청이 어긋납니다:\n${reason}\n어떻게 진행할까요?`,
|
|
55
|
+
options: [
|
|
56
|
+
{ key: 'a', label: `${standingSpec} 를 오라클로 고정하고 진행`, impact: '요청을 문서에 맞춘다' },
|
|
57
|
+
{ key: 'b', label: '새 스펙으로 분기해 진행', impact: '요청이 문서를 갱신/대체한다' },
|
|
58
|
+
{ key: 'c', label: '중단 — 문서를 먼저 정리하겠다', impact: '아무것도 실행하지 않는다' },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 주입 가능한 정합 판정 포트 (NFR-3). `({ goal, specPath, cwd }) => { verdict, reason }`.
|
|
65
|
+
* 별도 claude 를 plan 모드·새 세션으로 spawn 한다. 검증 계열과 같은 env 폴백 사슬을 쓴다.
|
|
66
|
+
*/
|
|
67
|
+
export function makeReconciler({
|
|
68
|
+
command = process.env.HILOOP_RECONCILE_CMD || process.env.HILOOP_VERIFY_CMD || process.env.HILOOP_AGENT_CMD || 'claude',
|
|
69
|
+
model = process.env.HILOOP_RECONCILE_MODEL || process.env.HILOOP_VERIFY_MODEL || '',
|
|
70
|
+
timeoutMs = 5 * 60 * 1000,
|
|
71
|
+
} = {}) {
|
|
72
|
+
return ({ goal, specPath, cwd }) =>
|
|
73
|
+
new Promise((resolve) => {
|
|
74
|
+
const full = specPath ? join(cwd, specPath) : null;
|
|
75
|
+
if (!full || !existsSync(full)) {
|
|
76
|
+
resolve({ verdict: 'ok', reason: '표준 문서가 없어 정합 검사 생략' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const prompt = reconcilePrompt({ goal, specText: readFileSync(full, 'utf8') });
|
|
80
|
+
const extraArgs = model ? ['--model', model] : [];
|
|
81
|
+
const args = buildAgentArgs({ prompt, sessionId: null, extraArgs, permissionMode: 'plan' });
|
|
82
|
+
const child = spawn(command, args, { cwd, env: process.env });
|
|
83
|
+
let stdout = '';
|
|
84
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
85
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
86
|
+
child.stderr.on('data', () => {});
|
|
87
|
+
child.on('error', () => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
// 판정기 실행 실패가 정상 작업을 막으면 안 된다(인프라 fail-open).
|
|
90
|
+
resolve({ verdict: 'ok', reason: '정합 판정기 실행 실패 — 통과로 처리' });
|
|
91
|
+
});
|
|
92
|
+
child.on('close', (code) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
if (code !== 0) {
|
|
95
|
+
resolve({ verdict: 'ok', reason: `정합 판정기가 코드 ${code}로 종료 — 통과로 처리` });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
resolve(parseReconcile(parseAgentOutput(stdout).text));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
package/src/resume.js
CHANGED
|
@@ -34,7 +34,7 @@ function carryOver(prev) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd }) {
|
|
37
|
+
export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd, specPath: pinnedSpecPath = null }) {
|
|
38
38
|
const raw = existsSync(statePath) ? loadState(statePath) : null;
|
|
39
39
|
// L16: 디스크의 상태는 신뢰 경계 밖이다. 봉인이 깨졌으면 게이트를 여는 필드만
|
|
40
40
|
// 안전값으로 되돌린다 — goal·회차·비용 같은 나머지는 판정을 열지 않으므로 그대로 둔다.
|
|
@@ -44,16 +44,19 @@ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd })
|
|
|
44
44
|
prev.sealBroken = true;
|
|
45
45
|
}
|
|
46
46
|
if (prev && prev.goal === goal && prev.status === 'passed') {
|
|
47
|
-
return { ...createState({ goal, testCommand, maxLoops, cwd }), ...carryOver(prev) };
|
|
47
|
+
return { ...createState({ goal, testCommand, maxLoops, cwd, specPath: pinnedSpecPath }), ...carryOver(prev) };
|
|
48
48
|
}
|
|
49
49
|
if (prev && prev.goal === goal && prev.status !== 'passed') {
|
|
50
50
|
// 필드가 없던 시절의 상태 파일도 이어받는다. 경로는 절대 다시 계산하지 않는다 —
|
|
51
51
|
// PLAN 이 이미 만든 파일 때문에 경로가 밀려나기 때문이다.
|
|
52
|
-
const paths = prev.specPath && prev.testPath ? {} : planPathsFor({ cwd, goal });
|
|
52
|
+
const paths = prev.specPath && prev.testPath ? {} : planPathsFor({ cwd, goal, specPath: pinnedSpecPath });
|
|
53
53
|
return {
|
|
54
54
|
...paths,
|
|
55
55
|
...prev,
|
|
56
|
-
|
|
56
|
+
// 명시 고정(--spec)은 저장된 경로보다 우선한다 — 사용자가 이번에 오라클을 바꾼 것이다.
|
|
57
|
+
specPath: pinnedSpecPath ?? prev.specPath ?? paths.specPath,
|
|
58
|
+
specPinned: pinnedSpecPath ? true : Boolean(prev.specPinned),
|
|
59
|
+
reconciled: Boolean(prev.reconciled),
|
|
57
60
|
testPath: prev.testPath ?? paths.testPath,
|
|
58
61
|
costUsd: Number.isFinite(prev.costUsd) ? prev.costUsd : 0,
|
|
59
62
|
// 기준선이 없던 시절의 상태로 재개하면 이번 회차 지문이 곧 기준선이 된다.
|
|
@@ -89,5 +92,5 @@ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd })
|
|
|
89
92
|
status: prev.ask ? 'awaiting' : 'running',
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
|
-
return createState({ goal, testCommand, maxLoops, cwd });
|
|
95
|
+
return createState({ goal, testCommand, maxLoops, cwd, specPath: pinnedSpecPath });
|
|
93
96
|
}
|
package/src/runners.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* 루프 엔진은 이 함수들을 주입받으므로, 테스트에서는 스텁으로 대체된다.
|
|
4
4
|
*/
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
+
import { AgentEnvError, describeSpawnError, detectAuthFailure } from './preflight.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* 양의 정수 환경변수를 읽되, 없거나 쓰레기값이면 기본값으로 조용히 되돌린다.
|
|
@@ -94,12 +95,17 @@ export function makeAgentRunner({
|
|
|
94
95
|
});
|
|
95
96
|
child.on('error', (err) => {
|
|
96
97
|
clearTimeout(timer);
|
|
97
|
-
|
|
98
|
+
// spawn 실패(미설치·권한)는 환경 오류다 — 스택 대신 행동지침으로 표시한다.
|
|
99
|
+
reject(new AgentEnvError(describeSpawnError(err, command)));
|
|
98
100
|
});
|
|
99
101
|
child.on('close', (code) => {
|
|
100
102
|
clearTimeout(timer);
|
|
101
103
|
if (code !== 0) {
|
|
102
|
-
|
|
104
|
+
// 비정상 종료의 stderr 에서 인증/구독/키 신호를 찾으면 힌트를 앞에 붙이고
|
|
105
|
+
// 환경 오류로 분류한다. 못 찾으면 우리가 모르는 실패이므로 원문 tail 을 그대로 던진다.
|
|
106
|
+
const hint = detectAuthFailure(stderr);
|
|
107
|
+
const tail = `에이전트가 코드 ${code}로 종료했습니다.\n${stderr.slice(-2000)}`;
|
|
108
|
+
reject(hint ? new AgentEnvError(`${hint}\n\n${tail}`) : new Error(tail));
|
|
103
109
|
return;
|
|
104
110
|
}
|
|
105
111
|
resolve(parseAgentOutput(stdout, sessionId));
|