@tuzi-ince/hi-loop 0.4.3 → 0.6.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/src/loop.js CHANGED
@@ -79,10 +79,10 @@ async function runLoopBody({
79
79
  reconcile = null, // FR-21: 문서 정합 게이트. null=자동(--full 또는 reconcileSpec 지정 시 켜짐).
80
80
  reconcileSpec = null, // 대조할 표준 문서 경로. null=기본 docs/DESIGN.md.
81
81
  full = false,
82
- // FR-15: 어느 경로로 들어와도 **같은 상태 머신**을 쓴다. 별도 코드 경로를 만들지 않는다
83
- // — 분기가 둘이면 반드시 한쪽이 썩는다.
82
+ // FR-15: 어느 경로로 들어와도 **같은 상태 머신**을 쓴다 분기가 둘이면 한쪽이 썩는다.
84
83
  startFrom = null,
85
84
  stopAfter = null,
85
+ step = false, // FR-24 스텝 모드(opt-in): 매 단위(BUILD 회차·각 stage) 후 호출자에게 양보. 기본 false=완료까지 자동(모델 A).
86
86
  watchCommand = null,
87
87
  watchForMs = WATCH_DEFAULTS.forMs,
88
88
  watchEveryMs = WATCH_DEFAULTS.everyMs,
@@ -156,7 +156,7 @@ async function runLoopBody({
156
156
  // null 이면 비-git — 관측 자체가 불가능하니 아래에서 조용히 건너뛴다(인프라 fail-open).
157
157
  const baselineFiles = await fileLister({ cwd });
158
158
 
159
- const { finish, stopResult, pauseResult } = makeOutcome({
159
+ const { finish, stopResult, pauseResult, stepResult } = makeOutcome({
160
160
  getState: () => state,
161
161
  setState: (v) => { state = v; },
162
162
  statePath, cwd, logger, notify, treeKeyReader,
@@ -198,15 +198,12 @@ async function runLoopBody({
198
198
  return { pause: true };
199
199
  };
200
200
 
201
- // FR-21: 문서 정합 게이트. 기존 표준 문서(docs/DESIGN.md)가 있고 이번 요청이 그것과 별개
202
- // 스펙으로 포크될 참이면, 조용히 갈라지기 전에 모순 여부를 판정한다. 모순이면 사람에게 묻는다.
203
- // 대조 대상: 명시(--reconcile-spec) 최우선. 없으면 사람 관리 표준 문서(docs/DESIGN.md)를
204
- // 우선하고, 그다음 스펙 순으로 폴백한다. 대문자=정본, 소문자=구버전(대소문자 구분 FS 대비).
205
- // spec 은 PLAN 이 쓴 에이전트 산출물이라 오라클로는 약하다 — 사람 문서가 있으면 그쪽이 옳다.
206
- // (게이트 발동 자체는 여전히 opt-in 이다.)
201
+ // FR-21: 문서 정합 게이트. 표준 문서가 있고 이번 요청이 별개 스펙으로 포크될 참이면, 조용히
202
+ // 갈라지기 전에 모순 여부를 판정한다(모순이면 사람에게 물음). 대조 대상: --reconcile-spec 최우선,
203
+ // 없으면 사람 관리 정본(대문자) 구버전(소문자) 폴백. spec 은 PLAN 산출물이라 오라클로는 약하다.
207
204
  const firstExisting = (...ps) => ps.find((p) => existsSync(join(cwd, p)));
208
205
  const STANDING_SPEC = reconcileSpec
209
- || firstExisting('docs/DESIGN.md', 'docs/DESIGN.md', 'docs/SPEC.md', 'docs/DESIGN.md')
206
+ || firstExisting('docs/DESIGN.md', 'docs/design.md', 'docs/SPEC.md', 'docs/spec.md')
210
207
  || 'docs/DESIGN.md';
211
208
  if (reconcileEnabled && !state.reconciled && !state.specPinned
212
209
  && state.specPath !== STANDING_SPEC && existsSync(join(cwd, STANDING_SPEC))) {
@@ -246,14 +243,13 @@ async function runLoopBody({
246
243
  shipCommand, watchCommand, onShipFail, onWatchFail, watchForMs, watchEveryMs,
247
244
  watchTolerate, maxReviewRounds, maxLoops, budgetUsd, testCommand, stagnationLimit,
248
245
  verifySpec, flakyProbe, checks, handoffEvery, maxDesignRounds, stopAfter,
249
- commit, commitMessage, commitStyle, onFail, askPolicy,
246
+ commit, commitMessage, commitStyle, onFail, askPolicy, step,
250
247
  },
251
248
  });
252
249
 
253
250
  // ---- stage 머신 ----
254
251
  // 다음 stage 를 정하는 권한은 여기 한 곳에만 있다. 핸들러는 지시만 돌려준다.
255
- // 재개 시 stage 가 이미 BUILD 이후면 빌드 루프를 다시 돌지 않는다통과한 빌드를
256
- // 다시 돌리면 비용을 두 번 내고, 최악의 경우 이미 배포된 코드를 또 고친다.
252
+ // 재개 시 stage 가 BUILD 이후면 빌드를 다시 돈다비용 이중 지불·배포된 코드 재수정 방지.
257
253
  if (startFrom) state.stage = startFrom;
258
254
  else if (!POST_BUILD_STAGES.includes(state.stage)) {
259
255
  state.stage = discoverEnabled && !state.discovery ? 'DISCOVER' : 'BUILD';
@@ -275,11 +271,14 @@ async function runLoopBody({
275
271
  state.status = 'awaiting';
276
272
  return pauseResult();
277
273
  }
274
+ // FR-24: 스텝 모드 — 한 회차만 돌고 제어를 돌려준다(아직 통과 전). resume 으로 이어간다.
275
+ if (built.continue) return stepResult({ next: 'BUILD', phase: built.phase });
278
276
  if (built.stopped) return stopResult('PLAN');
279
277
  if (!built.ok) return finish('failed', { stopReason: built.stopReason, detail: built.detail });
280
278
  if (shouldStopAfter('BUILD')) return stopResult('BUILD');
281
279
  state.stage = nextAfterBuild();
282
280
  saveState(statePath, state);
281
+ if (step && state.stage !== 'DONE') return stepResult({ next: state.stage }); // FR-24: 다음 stage 앞 양보
283
282
  continue;
284
283
  }
285
284
 
@@ -295,6 +294,7 @@ async function runLoopBody({
295
294
 
296
295
  state.stage = directive.next;
297
296
  saveState(statePath, state);
297
+ if (step && state.stage !== 'DONE') return stepResult({ next: state.stage }); // FR-24: stage 후 양보
298
298
  }
299
299
  }
300
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
- * MCP 경로의 기본 작업 디렉터리.
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
- // MCP progress: 루프 로그가 나올 때마다 클라이언트에 진행 알림을 보낸다. 이게 없으면
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
- // 조건부 검사(--check/--when 의 MCP 노출): 주면 testCommand 대신 이 배열이 판정 기준이 된다.
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
- // 사람이 골라야 하는 지점에서 멈췄다. 호스트 LLM 질문을 사용자에게 그대로
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
- .enum(['heal', 'ask', 'stop'])
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
- .enum(['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH'])
219
- .optional()
220
- .describe(' 단계까지만 진행하고 멈춘다'),
221
- spec: z
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
- return { finish, stopResult, pauseResult, setState };
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 = buildAgentArgs({ prompt, sessionId, extraArgs, permissionMode });
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
+ }