@tuzi-ince/hi-loop 0.4.1 → 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 +58 -4
- package/bin/hi-loop.js +19 -5
- package/bin/setup.js +60 -20
- package/docs/DESIGN.md +11 -3
- package/docs/SPEC.md +89 -5
- package/docs/guide.md +182 -21
- package/package.json +1 -1
- package/skills/flow/SKILL.md +54 -10
- package/src/ask.js +12 -0
- package/src/build.js +35 -4
- package/src/cli-options.js +6 -0
- package/src/docsync.js +100 -0
- package/src/loop.js +21 -15
- package/src/mcp-server.js +68 -47
- package/src/outcome.js +15 -1
- package/src/prompts.js +27 -0
- package/src/resume.js +1 -0
- package/src/runners.js +29 -1
- package/src/state.js +2 -0
package/src/docsync.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOC-SYNC (FR-25) — 소스 변경을 문서에 반영하는 단발 패스.
|
|
3
|
+
*
|
|
4
|
+
* 자가 치유 루프가 아니다(통과시킬 테스트가 없다). git diff 로 소스 변경을 모아 일꾼
|
|
5
|
+
* 에이전트에게 주고, **문서만** 소스에 맞춰 갱신하게 한다. 사람이 관리하는 표준 오라클
|
|
6
|
+
* (docs/DESIGN.md)은 절대 건드리지 않는다. 짧은 단발이라 긴 루프의 블랙박스/타임아웃이 없다.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { makeAgentRunner } from './runners.js';
|
|
10
|
+
import { makeCheckpointer, makeChangeLister, isGitRepo } from './checkpoint.js';
|
|
11
|
+
import { docSyncPrompt } from './prompts.js';
|
|
12
|
+
|
|
13
|
+
function git(args, cwd) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
execFile('git', args, { cwd, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
|
16
|
+
resolve(err ? null : String(stdout));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isMd = (f) => /\.md$/i.test(f);
|
|
22
|
+
/** 사람이 관리하는 표준 오라클 — doc-sync 도 손대면 안 된다. */
|
|
23
|
+
export const isOracleDoc = (f) => /(^|\/)(DESIGN|design)\.md$/.test(f);
|
|
24
|
+
/** doc-sync 가 갱신해도 되는 문서인가: 마크다운이되 오라클은 제외. */
|
|
25
|
+
export const isEditableDoc = (f) => isMd(f) && !isOracleDoc(f);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 반영할 **소스(비-문서) 변경**의 diff 를 읽는다. 커밋 안 된 변경(HEAD 대비)을 우선하고,
|
|
29
|
+
* 없으면 마지막 커밋을 본다. 문서(.md)만 바뀌었으면 트리거가 없으므로 빈 문자열.
|
|
30
|
+
*/
|
|
31
|
+
export function makeSourceDiffReader() {
|
|
32
|
+
return async ({ cwd }) => {
|
|
33
|
+
const uncommitted = (await git(['diff', 'HEAD', '--name-only'], cwd)) || '';
|
|
34
|
+
let range = ['HEAD'];
|
|
35
|
+
let files = uncommitted.split('\n').filter(Boolean);
|
|
36
|
+
if (!files.length) {
|
|
37
|
+
const last = (await git(['diff', 'HEAD~1', 'HEAD', '--name-only'], cwd)) || '';
|
|
38
|
+
files = last.split('\n').filter(Boolean);
|
|
39
|
+
range = ['HEAD~1', 'HEAD'];
|
|
40
|
+
}
|
|
41
|
+
const sourceFiles = files.filter((f) => !isMd(f));
|
|
42
|
+
if (!sourceFiles.length) return '';
|
|
43
|
+
return (await git(['diff', ...range, '--', ...sourceFiles], cwd)) || '';
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* doc-sync 한 번 실행. 반환:
|
|
49
|
+
* { ok:true, changed:[문서], stray:[문서 외 편집], note, costUsd }
|
|
50
|
+
* { ok:false, reason } — 비-git 등 실행 불가
|
|
51
|
+
*/
|
|
52
|
+
export async function runDocSync({
|
|
53
|
+
cwd = process.cwd(),
|
|
54
|
+
logger = () => {},
|
|
55
|
+
agentRunner = makeAgentRunner(),
|
|
56
|
+
checkpointer = makeCheckpointer(),
|
|
57
|
+
changeLister = makeChangeLister(),
|
|
58
|
+
diffReader = makeSourceDiffReader(),
|
|
59
|
+
isRepo = isGitRepo,
|
|
60
|
+
docTargets = null,
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (!(await isRepo(cwd))) {
|
|
63
|
+
return { ok: false, reason: 'git 저장소가 아니라 소스 변경(diff)을 읽을 수 없습니다.' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
logger('[hi-loop] 🔎 소스 변경 대조 중…');
|
|
67
|
+
const diffText = await diffReader({ cwd });
|
|
68
|
+
if (!diffText.trim()) {
|
|
69
|
+
return { ok: true, changed: [], stray: [], note: '반영할 소스 변경이 없습니다(문서만 바뀌었거나 변경 없음).' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 안전망: 에이전트가 손대기 전 스냅샷. 잘못 고치면 rollback 으로 되돌린다.
|
|
73
|
+
await checkpointer({ cwd });
|
|
74
|
+
const before = new Set((await changeLister({ cwd })) ?? []);
|
|
75
|
+
|
|
76
|
+
logger('[hi-loop] 📝 문서 갱신 중…');
|
|
77
|
+
const result = await agentRunner({ prompt: docSyncPrompt({ diffText, docTargets }), cwd });
|
|
78
|
+
|
|
79
|
+
const after = (await changeLister({ cwd })) ?? [];
|
|
80
|
+
const touched = after.filter((f) => !before.has(f));
|
|
81
|
+
const changed = touched.filter(isEditableDoc);
|
|
82
|
+
const stray = touched.filter((f) => !isEditableDoc(f));
|
|
83
|
+
|
|
84
|
+
logger(
|
|
85
|
+
changed.length
|
|
86
|
+
? `[hi-loop] ✅ 문서 ${changed.length}개 갱신: ${changed.join(', ')}`
|
|
87
|
+
: '[hi-loop] ✅ 갱신할 문서 변경이 없었습니다.',
|
|
88
|
+
);
|
|
89
|
+
if (stray.length) logger(`[hi-loop] ⚠️ 문서 외 파일이 변경됨(검토 요망): ${stray.join(', ')}`);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
changed,
|
|
94
|
+
stray,
|
|
95
|
+
note: typeof result?.text === 'string' ? result.text.slice(0, 800) : '',
|
|
96
|
+
costUsd: Number.isFinite(result?.costUsd) ? result.costUsd : 0,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default runDocSync;
|
package/src/loop.js
CHANGED
|
@@ -23,7 +23,7 @@ import { makeNotifier } from './telegram.js';
|
|
|
23
23
|
import { makeShipper, makeWatcher, WATCH_DEFAULTS } from './ship.js';
|
|
24
24
|
import { makeCodeReviewer, makeDesignReviewer } from './review.js';
|
|
25
25
|
import { makeDiscoverer } from './discover.js';
|
|
26
|
-
import { formatAsk, createAsk, pauseForAsk, shouldAsk, DEFAULT_ASK_POLICY } from './ask.js';
|
|
26
|
+
import { formatAsk, createAsk, pauseForAsk, shouldAsk, onFailAsk, DEFAULT_ASK_POLICY } from './ask.js';
|
|
27
27
|
import { STAGE_HANDLERS } from './stages.js';
|
|
28
28
|
import { runBuildLoop } from './build.js';
|
|
29
29
|
import { buildStageContext } from './stage-context.js';
|
|
@@ -65,6 +65,7 @@ async function runLoopBody({
|
|
|
65
65
|
flakyProbe = false,
|
|
66
66
|
specPath = null, // --spec: 스펙 오라클을 표준 문서로 고정(FR-19). null=엔진이 경로 결정.
|
|
67
67
|
checks = null,
|
|
68
|
+
onFail = 'heal', // FR-22: CHECK 실패/불능 시 안쪽 루프 처리. heal(자동) | ask(사람에게) | stop(중단).
|
|
68
69
|
handoffEvery = 4,
|
|
69
70
|
// ---- 라이프사이클 (FR-13, FR-14) ----
|
|
70
71
|
shipCommand = null,
|
|
@@ -78,10 +79,10 @@ async function runLoopBody({
|
|
|
78
79
|
reconcile = null, // FR-21: 문서 정합 게이트. null=자동(--full 또는 reconcileSpec 지정 시 켜짐).
|
|
79
80
|
reconcileSpec = null, // 대조할 표준 문서 경로. null=기본 docs/DESIGN.md.
|
|
80
81
|
full = false,
|
|
81
|
-
// FR-15: 어느 경로로 들어와도 **같은 상태 머신**을
|
|
82
|
-
// — 분기가 둘이면 반드시 한쪽이 썩는다.
|
|
82
|
+
// FR-15: 어느 경로로 들어와도 **같은 상태 머신**을 쓴다 — 분기가 둘이면 한쪽이 썩는다.
|
|
83
83
|
startFrom = null,
|
|
84
84
|
stopAfter = null,
|
|
85
|
+
step = false, // FR-24 스텝 모드(opt-in): 매 단위(BUILD 회차·각 stage) 후 호출자에게 양보. 기본 false=완료까지 자동(모델 A).
|
|
85
86
|
watchCommand = null,
|
|
86
87
|
watchForMs = WATCH_DEFAULTS.forMs,
|
|
87
88
|
watchEveryMs = WATCH_DEFAULTS.everyMs,
|
|
@@ -155,7 +156,7 @@ async function runLoopBody({
|
|
|
155
156
|
// null 이면 비-git — 관측 자체가 불가능하니 아래에서 조용히 건너뛴다(인프라 fail-open).
|
|
156
157
|
const baselineFiles = await fileLister({ cwd });
|
|
157
158
|
|
|
158
|
-
const { finish, stopResult, pauseResult } = makeOutcome({
|
|
159
|
+
const { finish, stopResult, pauseResult, stepResult } = makeOutcome({
|
|
159
160
|
getState: () => state,
|
|
160
161
|
setState: (v) => { state = v; },
|
|
161
162
|
statePath, cwd, logger, notify, treeKeyReader,
|
|
@@ -197,15 +198,12 @@ async function runLoopBody({
|
|
|
197
198
|
return { pause: true };
|
|
198
199
|
};
|
|
199
200
|
|
|
200
|
-
// FR-21: 문서 정합 게이트.
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// 우선하고, 그다음 스펙 순으로 폴백한다. 대문자=정본, 소문자=구버전(대소문자 구분 FS 대비).
|
|
204
|
-
// spec 은 PLAN 이 쓴 에이전트 산출물이라 오라클로는 약하다 — 사람 문서가 있으면 그쪽이 옳다.
|
|
205
|
-
// (게이트 발동 자체는 여전히 opt-in 이다.)
|
|
201
|
+
// FR-21: 문서 정합 게이트. 표준 문서가 있고 이번 요청이 별개 스펙으로 포크될 참이면, 조용히
|
|
202
|
+
// 갈라지기 전에 모순 여부를 판정한다(모순이면 사람에게 물음). 대조 대상: --reconcile-spec 최우선,
|
|
203
|
+
// 없으면 사람 관리 정본(대문자) → 구버전(소문자) 순 폴백. spec 은 PLAN 산출물이라 오라클로는 약하다.
|
|
206
204
|
const firstExisting = (...ps) => ps.find((p) => existsSync(join(cwd, p)));
|
|
207
205
|
const STANDING_SPEC = reconcileSpec
|
|
208
|
-
|| firstExisting('docs/DESIGN.md', 'docs/
|
|
206
|
+
|| firstExisting('docs/DESIGN.md', 'docs/design.md', 'docs/SPEC.md', 'docs/spec.md')
|
|
209
207
|
|| 'docs/DESIGN.md';
|
|
210
208
|
if (reconcileEnabled && !state.reconciled && !state.specPinned
|
|
211
209
|
&& state.specPath !== STANDING_SPEC && existsSync(join(cwd, STANDING_SPEC))) {
|
|
@@ -245,14 +243,13 @@ async function runLoopBody({
|
|
|
245
243
|
shipCommand, watchCommand, onShipFail, onWatchFail, watchForMs, watchEveryMs,
|
|
246
244
|
watchTolerate, maxReviewRounds, maxLoops, budgetUsd, testCommand, stagnationLimit,
|
|
247
245
|
verifySpec, flakyProbe, checks, handoffEvery, maxDesignRounds, stopAfter,
|
|
248
|
-
commit, commitMessage, commitStyle,
|
|
246
|
+
commit, commitMessage, commitStyle, onFail, askPolicy, step,
|
|
249
247
|
},
|
|
250
248
|
});
|
|
251
249
|
|
|
252
250
|
// ---- stage 머신 ----
|
|
253
251
|
// 다음 stage 를 정하는 권한은 여기 한 곳에만 있다. 핸들러는 지시만 돌려준다.
|
|
254
|
-
// 재개 시 stage 가
|
|
255
|
-
// 다시 돌리면 비용을 두 번 내고, 최악의 경우 이미 배포된 코드를 또 고친다.
|
|
252
|
+
// 재개 시 stage 가 BUILD 이후면 빌드를 다시 안 돈다 — 비용 이중 지불·배포된 코드 재수정 방지.
|
|
256
253
|
if (startFrom) state.stage = startFrom;
|
|
257
254
|
else if (!POST_BUILD_STAGES.includes(state.stage)) {
|
|
258
255
|
state.stage = discoverEnabled && !state.discovery ? 'DISCOVER' : 'BUILD';
|
|
@@ -268,11 +265,20 @@ async function runLoopBody({
|
|
|
268
265
|
state.status = 'running';
|
|
269
266
|
saveState(statePath, state);
|
|
270
267
|
const built = await runBuildLoop(ctx);
|
|
268
|
+
// FR-22: on-fail ask — 빌드 루프가 실패/불능에서 사람 판단을 요청하며 멈췄다.
|
|
269
|
+
if (built.paused) {
|
|
270
|
+
state.ask = createAsk(onFailAsk(built.onFail));
|
|
271
|
+
state.status = 'awaiting';
|
|
272
|
+
return pauseResult();
|
|
273
|
+
}
|
|
274
|
+
// FR-24: 스텝 모드 — 한 회차만 돌고 제어를 돌려준다(아직 통과 전). resume 으로 이어간다.
|
|
275
|
+
if (built.continue) return stepResult({ next: 'BUILD', phase: built.phase });
|
|
271
276
|
if (built.stopped) return stopResult('PLAN');
|
|
272
277
|
if (!built.ok) return finish('failed', { stopReason: built.stopReason, detail: built.detail });
|
|
273
278
|
if (shouldStopAfter('BUILD')) return stopResult('BUILD');
|
|
274
279
|
state.stage = nextAfterBuild();
|
|
275
280
|
saveState(statePath, state);
|
|
281
|
+
if (step && state.stage !== 'DONE') return stepResult({ next: state.stage }); // FR-24: 다음 stage 앞 양보
|
|
276
282
|
continue;
|
|
277
283
|
}
|
|
278
284
|
|
|
@@ -288,7 +294,7 @@ async function runLoopBody({
|
|
|
288
294
|
|
|
289
295
|
state.stage = directive.next;
|
|
290
296
|
saveState(statePath, state);
|
|
297
|
+
if (step && state.stage !== 'DONE') return stepResult({ next: state.stage }); // FR-24: stage 후 양보
|
|
291
298
|
}
|
|
292
299
|
}
|
|
293
|
-
|
|
294
300
|
export default runLoop;
|
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,17 +51,19 @@ export const tools = {
|
|
|
40
51
|
ship,
|
|
41
52
|
watch,
|
|
42
53
|
stopAfter,
|
|
54
|
+
step,
|
|
43
55
|
spec,
|
|
44
56
|
reconcile,
|
|
45
57
|
reconcileSpec,
|
|
46
58
|
checks,
|
|
59
|
+
onFail,
|
|
47
60
|
cwd = defaultCwd(),
|
|
48
|
-
}) => {
|
|
61
|
+
}, extra) => {
|
|
49
62
|
if (!goal) return fail('goal 은 필수입니다.');
|
|
50
|
-
//
|
|
51
|
-
// 회차를 태우지 않고 여기서 행동지침을 돌려준다(호스트 LLM 이 사용자에게 전달).
|
|
63
|
+
// 루프 전에 에이전트 실행 가능 여부 확인 — 미설치/불가면 회차를 안 태우고 행동지침을 돌려준다.
|
|
52
64
|
const pre = await preflightAgent();
|
|
53
65
|
if (!pre.ok) return fail(pre.message);
|
|
66
|
+
const runLogger = makeProgressLogger(extra);
|
|
54
67
|
const result = await runLoop({
|
|
55
68
|
goal,
|
|
56
69
|
testCommand,
|
|
@@ -60,25 +73,31 @@ export const tools = {
|
|
|
60
73
|
shipCommand: ship || null,
|
|
61
74
|
watchCommand: watch || null,
|
|
62
75
|
stopAfter: stopAfter ? String(stopAfter).toUpperCase() : null,
|
|
76
|
+
step: Boolean(step),
|
|
63
77
|
specPath: spec || null,
|
|
64
78
|
reconcile: reconcile === undefined ? null : Boolean(reconcile),
|
|
65
79
|
reconcileSpec: reconcileSpec || null,
|
|
66
|
-
// 조건부 검사(
|
|
67
|
-
// when 글롭에 맞는 파일이 바뀐 회차에만 그 검사를 돌린다(UI 변경 회차에만 e2e 등).
|
|
80
|
+
// 조건부 검사(MCP 노출): 주면 testCommand 대신 이 배열이 기준. when 글롭에 맞는 파일이 바뀐 회차만.
|
|
68
81
|
checks: Array.isArray(checks) && checks.length ? checks : null,
|
|
82
|
+
onFail: onFail || 'heal',
|
|
69
83
|
cwd,
|
|
70
|
-
logger:
|
|
84
|
+
logger: runLogger,
|
|
71
85
|
});
|
|
72
86
|
|
|
73
|
-
// 사람이 골라야 하는 지점에서 멈췄다.
|
|
74
|
-
// 제시하고 hiloop_answer 로 답을 돌려주면 재개된다 — MCP 의 단일 요청/응답
|
|
75
|
-
// 제약과 충돌하지 않는 유일한 방법이다.
|
|
87
|
+
// 사람이 골라야 하는 지점에서 멈췄다. 질문을 사용자에게 제시하고 hiloop_answer 로 답하면 재개.
|
|
76
88
|
if (result.status === 'awaiting') {
|
|
77
89
|
return text(
|
|
78
90
|
`⏸ 사용자의 선택이 필요합니다. 아래 질문을 그대로 사용자에게 제시하고, 답을 받아 hiloop_answer 를 호출하세요.\n\n${formatAsk(result.ask)}\n\n(hiloop_answer 인자: choice="${(result.ask.options ?? []).map((o) => o.key).join('" 또는 "')}", 필요하면 note)`,
|
|
79
91
|
);
|
|
80
92
|
}
|
|
81
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
|
+
|
|
82
101
|
const head =
|
|
83
102
|
result.status === 'passed' ? '✅ 통과' : result.status === 'stopped' ? `⏹ ${result.stoppedAt} 까지 진행 후 정지` : '❌ 실패';
|
|
84
103
|
// 통과 시 Gaps 를 붙인다 — 에이전트가 도구로 이 결과를 받을 때 false green 을
|
|
@@ -143,13 +162,27 @@ export const tools = {
|
|
|
143
162
|
return text(report.lines.join('\n'));
|
|
144
163
|
},
|
|
145
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
|
+
},
|
|
146
179
|
};
|
|
147
180
|
|
|
148
|
-
/** 예외를 isError 응답으로 감싼다 (FR-4.4) */
|
|
181
|
+
/** 예외를 isError 응답으로 감싼다 (FR-4.4). extra(progress·signal)를 핸들러에 그대로 넘긴다. */
|
|
149
182
|
export function wrap(handler) {
|
|
150
|
-
return async (args) => {
|
|
183
|
+
return async (args, extra) => {
|
|
151
184
|
try {
|
|
152
|
-
return await handler(args ?? {});
|
|
185
|
+
return await handler(args ?? {}, extra);
|
|
153
186
|
} catch (err) {
|
|
154
187
|
// 환경 오류(에이전트 미설치·인증 만료 등)는 이미 행동지침 메시지다 — 그대로 전달한다.
|
|
155
188
|
if (err?.kind === 'environment') return fail(err.message);
|
|
@@ -188,35 +221,17 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
|
|
|
188
221
|
goal: z.string().describe('달성할 요구사항'),
|
|
189
222
|
testCommand: z.string().optional().describe('검증 명령 (기본 "npm test")'),
|
|
190
223
|
maxLoops: z.number().int().min(1).max(50).optional().describe('최대 루프 횟수 (기본 10)'),
|
|
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 로 다시 호출해 이어간다(✅/❌/⏸ 까지). 대화형·긴 작업에 권장'),
|
|
191
226
|
verifySpec: z.boolean().optional().describe('통과 시 별도 검증자로 스펙 대비 구현을 심판 (하향 전용, 비용 증가)'),
|
|
192
227
|
full: z.boolean().optional().describe('전체 라이프사이클(발굴·설계리뷰·코드리뷰)까지 실행 (비용 증가)'),
|
|
193
228
|
ship: z.string().optional().describe('배포 명령. 주면 테스트 통과 후 실행하고 exit code 로 판정한다 (실행 전 사용자 확인)'),
|
|
194
229
|
watch: z.string().optional().describe('배포 후 헬스체크 명령. 일정 시간 연속 통과해야 성공이다'),
|
|
195
|
-
stopAfter: z
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.string()
|
|
201
|
-
.optional()
|
|
202
|
-
.describe('스펙 오라클을 이 파일로 고정한다(예: docs/DESIGN.md). 사람이 관리하는 표준 문서를 여러 요청이 공유하는 오라클로 삼아 문서↔소스 drift 를 막는다. 이미 내용이 있으면 PLAN 이 덮어쓰지 않는다.'),
|
|
203
|
-
reconcile: z
|
|
204
|
-
.boolean()
|
|
205
|
-
.optional()
|
|
206
|
-
.describe('문서 정합 게이트. 기존 표준 문서와 이 요청이 모순되면 조용히 포크하지 않고 사람에게 묻는다(고정/분기/중단). full=true 또는 reconcileSpec 지정 시 자동 켜짐.'),
|
|
207
|
-
reconcileSpec: z
|
|
208
|
-
.string()
|
|
209
|
-
.optional()
|
|
210
|
-
.describe('정합 게이트가 대조할 표준 문서 경로(기본 docs/DESIGN.md). 예: docs/DESIGN.md. 지정하면 게이트가 켜진다.'),
|
|
211
|
-
checks: z
|
|
212
|
-
.array(z.object({ cmd: z.string(), when: z.string().optional() }))
|
|
213
|
-
.optional()
|
|
214
|
-
.describe(
|
|
215
|
-
'조건부 판정 검사. 주면 testCommand 대신 이 배열이 판정 기준이 된다(첫 항목에 기본 테스트를 넣을 것). ' +
|
|
216
|
-
'when 글롭(예: "src/**/*.tsx")이 있으면 그 경로가 바뀐 회차에만 그 검사를 돌린다 — ' +
|
|
217
|
-
'"UI 를 고친 회차에만 e2e" 를 엔진이 강제하는 용도. e2e 는 셸 명령이어야 한다(예: {cmd:"npx playwright test", when:"src/**/*.tsx"}). ' +
|
|
218
|
-
'short-circuit 하지 않아 유닛이 깨져도 e2e 결과를 같은 회차에 함께 본다.',
|
|
219
|
-
),
|
|
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 안 함.'),
|
|
220
235
|
cwd: cwdSchema,
|
|
221
236
|
},
|
|
222
237
|
},
|
|
@@ -272,6 +287,12 @@ export async function startMcpServer({ version = '0.1.0' } = {}) {
|
|
|
272
287
|
wrap(tools.hiloop_setup.handler),
|
|
273
288
|
);
|
|
274
289
|
|
|
290
|
+
server.registerTool(
|
|
291
|
+
'hiloop_docsync',
|
|
292
|
+
{ description: tools.hiloop_docsync.description, inputSchema: { cwd: cwdSchema } },
|
|
293
|
+
wrap(tools.hiloop_docsync.handler),
|
|
294
|
+
);
|
|
295
|
+
|
|
275
296
|
await server.connect(new StdioServerTransport());
|
|
276
297
|
log('stdio 전송으로 MCP 서버가 기동했습니다.');
|
|
277
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/resume.js
CHANGED
|
@@ -57,6 +57,7 @@ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd, sp
|
|
|
57
57
|
specPath: pinnedSpecPath ?? prev.specPath ?? paths.specPath,
|
|
58
58
|
specPinned: pinnedSpecPath ? true : Boolean(prev.specPinned),
|
|
59
59
|
reconciled: Boolean(prev.reconciled),
|
|
60
|
+
extraLoops: Number.isFinite(prev.extraLoops) ? prev.extraLoops : 0,
|
|
60
61
|
testPath: prev.testPath ?? paths.testPath,
|
|
61
62
|
costUsd: Number.isFinite(prev.costUsd) ? prev.costUsd : 0,
|
|
62
63
|
// 기준선이 없던 시절의 상태로 재개하면 이번 회차 지문이 곧 기준선이 된다.
|
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
|
+
}
|
package/src/state.js
CHANGED
|
@@ -146,6 +146,8 @@ export function createState({
|
|
|
146
146
|
specPinned: Boolean(pinnedSpecPath),
|
|
147
147
|
// FR-21: 문서 정합 게이트를 이미 거쳤는가. 같은 goal 재개 시 다시 묻지 않도록.
|
|
148
148
|
reconciled: false,
|
|
149
|
+
// FR-22: on-fail ask 에서 사용자가 "재시도"를 고를 때마다 +1. maxLoops 위에 얹혀 회차를 늘린다.
|
|
150
|
+
extraLoops: 0,
|
|
149
151
|
specSummary: '',
|
|
150
152
|
lastError: '',
|
|
151
153
|
costUsd: 0,
|