@tuzi-ince/hi-loop 0.4.3 → 0.6.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 +44 -4
- package/bin/hi-loop.js +18 -5
- package/bin/setup.js +60 -20
- package/docs/DESIGN.md +11 -3
- package/docs/SPEC.md +65 -3
- package/docs/guide.md +122 -17
- package/package.json +1 -1
- package/skills/flow/SKILL.md +54 -10
- package/src/build.js +8 -1
- package/src/cli-options.js +2 -0
- package/src/docsync.js +100 -0
- package/src/loop.js +13 -13
- package/src/mcp-server.js +61 -62
- package/src/outcome.js +15 -1
- package/src/prompts.js +27 -0
- package/src/runners.js +29 -1
package/src/mcp-server.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* ⚠️ stdout 은 MCP 프로토콜 채널이다. 로그는 반드시 stderr 로만 낸다 (FR-4.2).
|
|
5
5
|
*/
|
|
6
6
|
import { runLoop } from './loop.js';
|
|
7
|
+
import { runDocSync } from './docsync.js';
|
|
7
8
|
import { loadState, saveState, statePathFor, summarizeState, resetState } from './state.js';
|
|
8
9
|
import { applyAnswer, formatAsk } from './ask.js';
|
|
9
10
|
import { rollbackTo } from './checkpoint.js';
|
|
@@ -15,22 +16,32 @@ const log = (line) => process.stderr.write(`[hi-loop-mcp] ${line}\n`);
|
|
|
15
16
|
const text = (t) => ({ content: [{ type: 'text', text: t }] });
|
|
16
17
|
const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
* CLI 는 `process.cwd()` 만 본다 — 터미널에서 cd 한 곳이 곧 사용자의 명시 의도이기 때문이다.
|
|
22
|
-
* 그러나 MCP 는 호스트(클로드코드/커서)가 서버를 띄우는 경로이고, "어느 프로젝트인가"의
|
|
23
|
-
* 정답은 호스트가 주입하는 `CLAUDE_PROJECT_DIR` 다. 서버 프로세스의 cwd 가 프로젝트 루트와
|
|
24
|
-
* 다른 호스트에서도 올바른 루트를 잡으려면 이 env 를 먼저 본다. 호출자가 cwd 를 명시하면
|
|
25
|
-
* 그것이 최우선이다(각 핸들러의 인자가 이 기본값을 덮는다).
|
|
26
|
-
*/
|
|
19
|
+
// MCP 경로의 기본 cwd: 호스트가 주입하는 CLAUDE_PROJECT_DIR 가 "어느 프로젝트인가"의 정답이다
|
|
20
|
+
// (서버 cwd 가 프로젝트 루트와 다를 수 있으므로). 호출자가 cwd 를 명시하면 그것이 최우선.
|
|
27
21
|
const defaultCwd = () => process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
28
22
|
|
|
23
|
+
// MCP progress: 로그 라인마다 진행 알림 전송(progressToken 준 경우만; 없으면 stderr 로만).
|
|
24
|
+
function makeProgressLogger(extra) {
|
|
25
|
+
const token = extra?._meta?.progressToken;
|
|
26
|
+
let ticks = 0;
|
|
27
|
+
return (line) => {
|
|
28
|
+
log(line);
|
|
29
|
+
if (token != null && typeof extra?.sendNotification === 'function') {
|
|
30
|
+
extra
|
|
31
|
+
.sendNotification({
|
|
32
|
+
method: 'notifications/progress',
|
|
33
|
+
params: { progressToken: token, progress: (ticks += 1), message: String(line).replace(/^\[hi-loop[^\]]*\]\s*/, '').slice(0, 200) },
|
|
34
|
+
})
|
|
35
|
+
.catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/** 도구 구현 (SDK 없이도 단위 테스트 가능하도록 분리) */
|
|
30
41
|
export const tools = {
|
|
31
42
|
hiloop_run: {
|
|
32
43
|
description:
|
|
33
|
-
'목표(goal)를 받아 PLAN→DO→CHECK→HEAL 자가 치유 루프를 실행한다. spec/테스트를 먼저 만들고, 테스트가 통과할 때까지 최대 maxLoops회 반복한다. full=true 면 발굴·설계리뷰·코드리뷰까지 도는 전체 라이프사이클로 실행한다. ship/watch 를 주면 배포와 헬스체크까지 진행한다.',
|
|
44
|
+
'목표(goal)를 받아 PLAN→DO→CHECK→HEAL 자가 치유 루프를 실행한다. spec/테스트를 먼저 만들고, 테스트가 통과할 때까지 최대 maxLoops회 반복한다. full=true 면 발굴·설계리뷰·코드리뷰까지 도는 전체 라이프사이클로 실행한다. ship/watch 를 주면 배포와 헬스체크까지 진행한다. step=true 면 매 단위 작업 후 제어를 돌려줘(status:continue) LLM 이 진행을 보이며 이어 호출한다 — 대화형·긴 작업 권장.',
|
|
34
45
|
handler: async ({
|
|
35
46
|
goal,
|
|
36
47
|
testCommand = 'npm test',
|
|
@@ -40,6 +51,7 @@ export const tools = {
|
|
|
40
51
|
ship,
|
|
41
52
|
watch,
|
|
42
53
|
stopAfter,
|
|
54
|
+
step,
|
|
43
55
|
spec,
|
|
44
56
|
reconcile,
|
|
45
57
|
reconcileSpec,
|
|
@@ -48,26 +60,10 @@ export const tools = {
|
|
|
48
60
|
cwd = defaultCwd(),
|
|
49
61
|
}, extra) => {
|
|
50
62
|
if (!goal) return fail('goal 은 필수입니다.');
|
|
51
|
-
//
|
|
52
|
-
// 회차를 태우지 않고 여기서 행동지침을 돌려준다(호스트 LLM 이 사용자에게 전달).
|
|
63
|
+
// 루프 전에 에이전트 실행 가능 여부 확인 — 미설치/불가면 회차를 안 태우고 행동지침을 돌려준다.
|
|
53
64
|
const pre = await preflightAgent();
|
|
54
65
|
if (!pre.ok) return fail(pre.message);
|
|
55
|
-
|
|
56
|
-
// 긴 루프(e2e 등)가 조용히 유휴 타임아웃(기본 30분)에 걸려 클라이언트 호출만 끊긴다.
|
|
57
|
-
// 클라이언트가 progressToken 을 준 경우에만 보낸다(안 주면 조용히 stderr 로만 로그).
|
|
58
|
-
const progressToken = extra?._meta?.progressToken;
|
|
59
|
-
let ticks = 0;
|
|
60
|
-
const runLogger = (line) => {
|
|
61
|
-
log(line);
|
|
62
|
-
if (progressToken != null && typeof extra?.sendNotification === 'function') {
|
|
63
|
-
extra
|
|
64
|
-
.sendNotification({
|
|
65
|
-
method: 'notifications/progress',
|
|
66
|
-
params: { progressToken, progress: (ticks += 1), message: String(line).replace(/^\[hi-loop[^\]]*\]\s*/, '').slice(0, 200) },
|
|
67
|
-
})
|
|
68
|
-
.catch(() => {}); // 알림 실패는 루프에 영향 없음
|
|
69
|
-
}
|
|
70
|
-
};
|
|
66
|
+
const runLogger = makeProgressLogger(extra);
|
|
71
67
|
const result = await runLoop({
|
|
72
68
|
goal,
|
|
73
69
|
testCommand,
|
|
@@ -77,26 +73,31 @@ export const tools = {
|
|
|
77
73
|
shipCommand: ship || null,
|
|
78
74
|
watchCommand: watch || null,
|
|
79
75
|
stopAfter: stopAfter ? String(stopAfter).toUpperCase() : null,
|
|
76
|
+
step: Boolean(step),
|
|
80
77
|
specPath: spec || null,
|
|
81
78
|
reconcile: reconcile === undefined ? null : Boolean(reconcile),
|
|
82
79
|
reconcileSpec: reconcileSpec || null,
|
|
83
|
-
// 조건부 검사(
|
|
84
|
-
// when 글롭에 맞는 파일이 바뀐 회차에만 그 검사를 돌린다(UI 변경 회차에만 e2e 등).
|
|
80
|
+
// 조건부 검사(MCP 노출): 주면 testCommand 대신 이 배열이 기준. when 글롭에 맞는 파일이 바뀐 회차만.
|
|
85
81
|
checks: Array.isArray(checks) && checks.length ? checks : null,
|
|
86
82
|
onFail: onFail || 'heal',
|
|
87
83
|
cwd,
|
|
88
84
|
logger: runLogger,
|
|
89
85
|
});
|
|
90
86
|
|
|
91
|
-
// 사람이 골라야 하는 지점에서 멈췄다.
|
|
92
|
-
// 제시하고 hiloop_answer 로 답을 돌려주면 재개된다 — MCP 의 단일 요청/응답
|
|
93
|
-
// 제약과 충돌하지 않는 유일한 방법이다.
|
|
87
|
+
// 사람이 골라야 하는 지점에서 멈췄다. 질문을 사용자에게 제시하고 hiloop_answer 로 답하면 재개.
|
|
94
88
|
if (result.status === 'awaiting') {
|
|
95
89
|
return text(
|
|
96
90
|
`⏸ 사용자의 선택이 필요합니다. 아래 질문을 그대로 사용자에게 제시하고, 답을 받아 hiloop_answer 를 호출하세요.\n\n${formatAsk(result.ask)}\n\n(hiloop_answer 인자: choice="${(result.ask.options ?? []).map((o) => o.key).join('" 또는 "')}", 필요하면 note)`,
|
|
97
91
|
);
|
|
98
92
|
}
|
|
99
93
|
|
|
94
|
+
// FR-24 스텝 모드: 한 단위만 돌고 돌아왔다. 진행을 알리고 같은 goal 로 재호출해 이어간다.
|
|
95
|
+
if (result.status === 'continue') {
|
|
96
|
+
return text(
|
|
97
|
+
`⏭ 스텝 완료 (${result.phase ?? result.next}) — ${result.iterations}회차까지 진행. 아직 미완이니 같은 goal 로 hiloop_run(step:true)을 다시 호출해 이어가세요.\n\n${summarizeState(result.state)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
100
101
|
const head =
|
|
101
102
|
result.status === 'passed' ? '✅ 통과' : result.status === 'stopped' ? `⏹ ${result.stoppedAt} 까지 진행 후 정지` : '❌ 실패';
|
|
102
103
|
// 통과 시 Gaps 를 붙인다 — 에이전트가 도구로 이 결과를 받을 때 false green 을
|
|
@@ -161,6 +162,20 @@ export const tools = {
|
|
|
161
162
|
return text(report.lines.join('\n'));
|
|
162
163
|
},
|
|
163
164
|
},
|
|
165
|
+
hiloop_docsync: {
|
|
166
|
+
description:
|
|
167
|
+
'소스 변경(git diff)을 읽어 관련 문서(README·docs/*, docs/DESIGN.md 제외)를 소스에 맞춰 갱신하는 단발 패스. 테스트 루프가 아니라 짧게 끝난다(진행 알림 지원). 문서 외 파일이 바뀌면 stray 로 보고한다. 소스 변경 후 문서↔소스 일치를 맞출 때 쓴다.',
|
|
168
|
+
handler: async ({ cwd = defaultCwd() } = {}, extra) => {
|
|
169
|
+
const pre = await preflightAgent();
|
|
170
|
+
if (!pre.ok) return fail(pre.message);
|
|
171
|
+
const res = await runDocSync({ cwd, logger: makeProgressLogger(extra) });
|
|
172
|
+
if (!res.ok) return fail(res.reason);
|
|
173
|
+
const head = res.changed.length ? `✅ 문서 ${res.changed.length}개 갱신` : `· ${res.note || '갱신할 문서 변경 없음'}`;
|
|
174
|
+
const list = res.changed.length ? `\n갱신: ${res.changed.join(', ')}` : '';
|
|
175
|
+
const stray = res.stray?.length ? `\n⚠️ 문서 외 파일 변경(검토 요망): ${res.stray.join(', ')}` : '';
|
|
176
|
+
return text(`${head}${list}${stray}${res.note ? `\n\n${res.note}` : ''}`);
|
|
177
|
+
},
|
|
178
|
+
},
|
|
164
179
|
};
|
|
165
180
|
|
|
166
181
|
/** 예외를 isError 응답으로 감싼다 (FR-4.4). extra(progress·signal)를 핸들러에 그대로 넘긴다. */
|
|
@@ -206,39 +221,17 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
|
|
|
206
221
|
goal: z.string().describe('달성할 요구사항'),
|
|
207
222
|
testCommand: z.string().optional().describe('검증 명령 (기본 "npm test")'),
|
|
208
223
|
maxLoops: z.number().int().min(1).max(50).optional().describe('최대 루프 횟수 (기본 10)'),
|
|
209
|
-
onFail: z
|
|
210
|
-
|
|
211
|
-
.optional()
|
|
212
|
-
.describe('CHECK 실패/불능 시 처리. heal(기본)=maxLoops 안에서 자동 치유 / ask=사람에게 재시도·종료를 물음(awaiting 반환 → hiloop_answer 로 답하고 재호출) / stop=즉시 종료. 대화형에선 ask 권장'),
|
|
224
|
+
onFail: z.enum(['heal', 'ask', 'stop']).optional().describe('CHECK 실패/불능 시. heal(기본)=maxLoops 안 자동 치유 / ask=사람에게 재시도·종료를 물음(awaiting→hiloop_answer 로 답하고 재호출) / stop=즉시 종료. 대화형엔 ask 권장'),
|
|
225
|
+
step: z.boolean().optional().describe('스텝 모드. 매 단위 작업(BUILD 회차·각 stage) 후 status:"continue" 로 돌려준다 — 진행이 콘솔에 보이고 콜당 시간이 짧아 타임아웃이 없다. "⏭ 스텝 완료" 응답이 오면 같은 goal 로 다시 호출해 이어간다(✅/❌/⏸ 까지). 대화형·긴 작업에 권장'),
|
|
213
226
|
verifySpec: z.boolean().optional().describe('통과 시 별도 검증자로 스펙 대비 구현을 심판 (하향 전용, 비용 증가)'),
|
|
214
227
|
full: z.boolean().optional().describe('전체 라이프사이클(발굴·설계리뷰·코드리뷰)까지 실행 (비용 증가)'),
|
|
215
228
|
ship: z.string().optional().describe('배포 명령. 주면 테스트 통과 후 실행하고 exit code 로 판정한다 (실행 전 사용자 확인)'),
|
|
216
229
|
watch: z.string().optional().describe('배포 후 헬스체크 명령. 일정 시간 연속 통과해야 성공이다'),
|
|
217
|
-
stopAfter: z
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
.string()
|
|
223
|
-
.optional()
|
|
224
|
-
.describe('스펙 오라클을 이 파일로 고정한다(예: docs/DESIGN.md). 사람이 관리하는 표준 문서를 여러 요청이 공유하는 오라클로 삼아 문서↔소스 drift 를 막는다. 이미 내용이 있으면 PLAN 이 덮어쓰지 않는다.'),
|
|
225
|
-
reconcile: z
|
|
226
|
-
.boolean()
|
|
227
|
-
.optional()
|
|
228
|
-
.describe('문서 정합 게이트. 기존 표준 문서와 이 요청이 모순되면 조용히 포크하지 않고 사람에게 묻는다(고정/분기/중단). full=true 또는 reconcileSpec 지정 시 자동 켜짐.'),
|
|
229
|
-
reconcileSpec: z
|
|
230
|
-
.string()
|
|
231
|
-
.optional()
|
|
232
|
-
.describe('정합 게이트가 대조할 표준 문서 경로(기본 docs/DESIGN.md). 예: docs/DESIGN.md. 지정하면 게이트가 켜진다.'),
|
|
233
|
-
checks: z
|
|
234
|
-
.array(z.object({ cmd: z.string(), when: z.string().optional() }))
|
|
235
|
-
.optional()
|
|
236
|
-
.describe(
|
|
237
|
-
'조건부 판정 검사. 주면 testCommand 대신 이 배열이 판정 기준이 된다(첫 항목에 기본 테스트를 넣을 것). ' +
|
|
238
|
-
'when 글롭(예: "src/**/*.tsx")이 있으면 그 경로가 바뀐 회차에만 그 검사를 돌린다 — ' +
|
|
239
|
-
'"UI 를 고친 회차에만 e2e" 를 엔진이 강제하는 용도. e2e 는 셸 명령이어야 한다(예: {cmd:"npx playwright test", when:"src/**/*.tsx"}). ' +
|
|
240
|
-
'short-circuit 하지 않아 유닛이 깨져도 e2e 결과를 같은 회차에 함께 본다.',
|
|
241
|
-
),
|
|
230
|
+
stopAfter: z.enum(['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH']).optional().describe('이 단계까지만 진행하고 멈춘다'),
|
|
231
|
+
spec: z.string().optional().describe('스펙 오라클을 이 파일로 고정(예: docs/DESIGN.md). 사람이 관리하는 표준 문서를 여러 요청이 공유하는 오라클로 삼아 drift 를 막는다. 내용이 있으면 PLAN 이 덮어쓰지 않는다.'),
|
|
232
|
+
reconcile: z.boolean().optional().describe('문서 정합 게이트. 기존 표준 문서와 이 요청이 모순되면 포크 전에 사람에게 묻는다(고정/분기/중단). full=true 또는 reconcileSpec 지정 시 자동 켜짐.'),
|
|
233
|
+
reconcileSpec: z.string().optional().describe('정합 게이트가 대조할 표준 문서 경로(기본 docs/DESIGN.md). 지정하면 게이트가 켜진다.'),
|
|
234
|
+
checks: z.array(z.object({ cmd: z.string(), when: z.string().optional() })).optional().describe('조건부 판정 검사. 주면 testCommand 대신 이 배열이 기준(첫 항목에 기본 테스트). when 글롭(예: "src/**/*.tsx")이 있으면 그 경로가 바뀐 회차에만 그 검사를 돌린다 — "UI 고친 회차에만 e2e" 를 엔진이 강제. e2e 는 셸 명령(예: {cmd:"npx playwright test", when:"src/**/*.tsx"}). short-circuit 안 함.'),
|
|
242
235
|
cwd: cwdSchema,
|
|
243
236
|
},
|
|
244
237
|
},
|
|
@@ -294,6 +287,12 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
|
|
|
294
287
|
wrap(tools.hiloop_setup.handler),
|
|
295
288
|
);
|
|
296
289
|
|
|
290
|
+
server.registerTool(
|
|
291
|
+
'hiloop_docsync',
|
|
292
|
+
{ description: tools.hiloop_docsync.description, inputSchema: { cwd: cwdSchema } },
|
|
293
|
+
wrap(tools.hiloop_docsync.handler),
|
|
294
|
+
);
|
|
295
|
+
|
|
297
296
|
await server.connect(new StdioServerTransport());
|
|
298
297
|
log('stdio 전송으로 MCP 서버가 기동했습니다.');
|
|
299
298
|
return server;
|
package/src/outcome.js
CHANGED
|
@@ -72,5 +72,19 @@ export function makeOutcome({ getState, setState, statePath, cwd, logger, notify
|
|
|
72
72
|
return { status: 'awaiting', iterations: state.iteration, state: saved, ask: state.ask };
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* 스텝 모드(FR-24) — 한 단위 작업을 끝내고 제어를 호출자(LLM)에게 돌려준다. 실패가 아니다.
|
|
77
|
+
* 상태는 그대로 저장되어 다음 hiloop_run 호출이 resume 경로로 정확히 이어간다.
|
|
78
|
+
* 통과 판정을 여기서 하지 않으므로 checkVerdictBinding(false green 게이트)을 우회하지 않는다
|
|
79
|
+
* — 진짜 통과는 여전히 finish('passed') 한 곳만 지난다.
|
|
80
|
+
*/
|
|
81
|
+
const stepResult = ({ next, phase } = {}) => {
|
|
82
|
+
const state = getState();
|
|
83
|
+
state.status = 'stepping';
|
|
84
|
+
const saved = saveState(statePath, state);
|
|
85
|
+
logger(`[hi-loop] ⏭ 한 스텝 진행 (${phase ?? saved.phase ?? saved.stage}). 이어가려면 hiloop_run 을 다시 호출하세요.`);
|
|
86
|
+
return { status: 'continue', iterations: saved.iteration, state: saved, next: next ?? saved.stage, phase: phase ?? saved.phase };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return { finish, stopResult, pauseResult, stepResult, setState };
|
|
76
90
|
}
|
package/src/prompts.js
CHANGED
|
@@ -268,3 +268,30 @@ ${truncate(String(diffText ?? '(변경 없음)'), 10000)}
|
|
|
268
268
|
## 출력 (반드시 이 JSON 한 줄로만)
|
|
269
269
|
{"verdict": "pass" 또는 "reject", "findings": [{"severity": "high|medium|low", "text": "무엇이 어떤 입력에서 어떻게 깨지는가"}]}`;
|
|
270
270
|
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* DOC-SYNC (FR-25). 소스 변경(diff)을 문서에 반영하는 단발 패스. 테스트 루프가 아니다.
|
|
274
|
+
* 사람이 관리하는 표준 문서(docs/DESIGN.md)는 오라클이라 **절대 건드리지 않는다**.
|
|
275
|
+
* 소스에 없는 내용을 지어내면 그게 곧 환각이므로, diff 로 확인되는 것만 반영한다.
|
|
276
|
+
*/
|
|
277
|
+
export function docSyncPrompt({ diffText, docTargets } = {}) {
|
|
278
|
+
const targets = Array.isArray(docTargets) ? docTargets : [];
|
|
279
|
+
return `지금은 DOC-SYNC 단계다. 아래 **소스 변경(git diff)**을 읽고, 그 변경에 맞춰 **문서만** 갱신하라.
|
|
280
|
+
|
|
281
|
+
## 소스 변경 (git diff)
|
|
282
|
+
\`\`\`diff
|
|
283
|
+
${truncate(String(diffText ?? '(변경 없음)'), 12000)}
|
|
284
|
+
\`\`\`
|
|
285
|
+
|
|
286
|
+
## 갱신 대상 문서 (이 파일들만 수정)
|
|
287
|
+
${targets.length ? targets.map((p) => `- ${p}`).join('\n') : '- README.md\n- docs/*.md (docs/DESIGN.md 제외)'}
|
|
288
|
+
|
|
289
|
+
## 규칙
|
|
290
|
+
- **문서만 고쳐라.** 소스 코드·테스트(\`src/\`, \`bin/\`, \`tests/\`, \`*.js\`)는 절대 건드리지 마라.
|
|
291
|
+
- **\`docs/DESIGN.md\`(및 소문자 design.md)는 절대 수정하지 마라** — 사람이 관리하는 표준 오라클이다.
|
|
292
|
+
- diff 로 **확인되는 변경만** 반영하라(새 플래그·인자·동작·API·기본값). diff 에 없는 내용을 지어내지 마라.
|
|
293
|
+
- 새 기능이면 해당 문서 관례대로 반영하라(예: SPEC 의 FR 번호 규칙, README 의 사용법·표, guide 의 절).
|
|
294
|
+
- 변경이 문서와 무관하면(내부 리팩터 등) 아무것도 바꾸지 말고 그 사실을 밝혀라.
|
|
295
|
+
|
|
296
|
+
작업을 마치면 **어떤 문서의 무엇을 왜 고쳤는지** 5줄 이내로 요약해 출력하라.`;
|
|
297
|
+
}
|
package/src/runners.js
CHANGED
|
@@ -74,13 +74,15 @@ export function buildAgentArgs({ prompt, sessionId, extraArgs = [], permissionMo
|
|
|
74
74
|
*/
|
|
75
75
|
export function makeAgentRunner({
|
|
76
76
|
command = process.env.HILOOP_AGENT_CMD || 'claude',
|
|
77
|
+
provider = process.env.HILOOP_AGENT_PROVIDER,
|
|
77
78
|
extraArgs = (process.env.HILOOP_AGENT_ARGS || '').split(' ').filter(Boolean),
|
|
78
79
|
permissionMode = process.env.HILOOP_PERMISSION_MODE || DEFAULT_PERMISSION_MODE,
|
|
79
80
|
timeoutMs = envMs('HILOOP_AGENT_TIMEOUT_MS', 30 * 60 * 1000),
|
|
80
81
|
} = {}) {
|
|
82
|
+
const adapter = getAgentAdapter(provider);
|
|
81
83
|
return ({ prompt, sessionId, cwd }) =>
|
|
82
84
|
new Promise((resolve, reject) => {
|
|
83
|
-
const args =
|
|
85
|
+
const args = adapter.buildArgs({ prompt, sessionId, extraArgs, permissionMode });
|
|
84
86
|
|
|
85
87
|
const child = spawn(command, args, { cwd, env: process.env });
|
|
86
88
|
let stdout = '';
|
|
@@ -133,3 +135,29 @@ export function parseAgentOutput(stdout, fallbackSessionId = null) {
|
|
|
133
135
|
return { text: stdout, sessionId: fallbackSessionId, costUsd: 0 };
|
|
134
136
|
}
|
|
135
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 에이전트 프로바이더 어댑터 (FR-23): claude 외의 CLI 도 루프의 일꾼으로 쓰게,
|
|
141
|
+
* 인자 구성과 출력 파싱을 프로바이더별로 분리한다. HILOOP_AGENT_PROVIDER 로 고른다.
|
|
142
|
+
* - claude(기본): `-p <prompt> --output-format json [--resume sid] [--permission-mode]`,
|
|
143
|
+
* result/session_id/total_cost_usd 를 파싱 — 세션 재개·비용 집계 모두 지원.
|
|
144
|
+
* - generic: 프롬프트를 마지막 위치인자로 넘기고 stdout 을 그대로 텍스트로 본다.
|
|
145
|
+
* 세션 재개·비용 집계는 없다(매 호출이 새 세션). opencode 등 단순 CLI 용 최소 계약.
|
|
146
|
+
* 예: HILOOP_AGENT_CMD=opencode, HILOOP_AGENT_ARGS=run → `opencode run "<prompt>"`.
|
|
147
|
+
*/
|
|
148
|
+
export const AGENT_ADAPTERS = {
|
|
149
|
+
claude: { buildArgs: buildAgentArgs, parseOutput: parseAgentOutput },
|
|
150
|
+
generic: {
|
|
151
|
+
buildArgs: ({ prompt, extraArgs = [] }) => [...extraArgs, prompt],
|
|
152
|
+
parseOutput: (stdout, fallbackSessionId = null) => ({
|
|
153
|
+
text: String(stdout),
|
|
154
|
+
sessionId: fallbackSessionId,
|
|
155
|
+
costUsd: 0,
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** 이름이 없거나 모르는 값이면 claude 로 조용히 되돌린다(기본 경로 보존). */
|
|
161
|
+
export function getAgentAdapter(name = process.env.HILOOP_AGENT_PROVIDER) {
|
|
162
|
+
return AGENT_ADAPTERS[String(name || 'claude').toLowerCase()] || AGENT_ADAPTERS.claude;
|
|
163
|
+
}
|