@tuzi-ince/hi-loop 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -3
- package/bin/hi-loop.js +85 -32
- package/docs/design.md +77 -0
- package/docs/guide.md +73 -2
- package/docs/spec.md +138 -0
- package/package.json +1 -1
- package/src/ask.js +152 -0
- package/src/build.js +252 -0
- package/src/cli-options.js +134 -0
- package/src/discover.js +118 -0
- package/src/loop.js +208 -206
- package/src/mcp-server.js +72 -5
- package/src/prompts.js +138 -0
- package/src/report.js +85 -0
- package/src/review.js +125 -0
- package/src/ship.js +82 -0
- package/src/stages.js +215 -0
- package/src/state.js +98 -54
- package/src/telegram.js +1 -1
- package/src/verify.js +2 -1
package/src/ask.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask 모드 (제안서 §4) — 취사선택이 필연일 때만 사람을 부른다.
|
|
3
|
+
*
|
|
4
|
+
* 설계 제약이 방식을 정한다. 차단형 stdin 을 쓰지 않는 이유:
|
|
5
|
+
* - MCP 는 단일 요청/응답이다. 서버가 stdin 을 붙잡고 사람을 기다릴 자리가 없다.
|
|
6
|
+
* - 백그라운드 데몬·텔레그램 봇 구동에서는 답할 터미널 자체가 없다.
|
|
7
|
+
* 그래서 **상태를 저장하고 종료**한다(pause/resume). 세션이 끊겨도 질문은 파일에 남는다.
|
|
8
|
+
*
|
|
9
|
+
* MCP 에서는 이 구조가 오히려 자연스럽다: `hiloop_run` 이 status='awaiting' + ask 를
|
|
10
|
+
* 반환하면 호스트 LLM 이 그 질문을 사용자에게 그대로 제시하고 `hiloop_answer` 를 부른다.
|
|
11
|
+
*
|
|
12
|
+
* 이 모듈은 state.js 를 import 하지 않는다(순환 방지). 상태를 받아 새 상태를 돌려주는
|
|
13
|
+
* 순수 함수만 둔다.
|
|
14
|
+
*/
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
/** `--ask` 정책. 기본은 auto — 상호배타 분기와 비가역 행위에서만 묻는다. */
|
|
18
|
+
export const ASK_POLICIES = ['auto', 'never', 'always'];
|
|
19
|
+
export const DEFAULT_ASK_POLICY = 'auto';
|
|
20
|
+
|
|
21
|
+
export function normalizeAskPolicy(value) {
|
|
22
|
+
if (value === undefined || value === null || value === true) return DEFAULT_ASK_POLICY;
|
|
23
|
+
const v = String(value).toLowerCase();
|
|
24
|
+
return ASK_POLICIES.includes(v) ? v : null; // null = 잘못된 값. 호출자가 exit 2 로 거른다.
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 이 시점에 사람을 부를 것인가.
|
|
29
|
+
*
|
|
30
|
+
* `required` 는 "엔진이 판단하기에 이건 사람 몫"이라는 뜻이다(상호배타 분기 FR-10.3,
|
|
31
|
+
* 비가역 배포 FR-13.4). auto 는 이때만 묻는다. never 는 그래도 안 묻는다 —
|
|
32
|
+
* CI·봇처럼 답할 사람이 아예 없는 환경에서 멈추는 게 더 나쁘기 때문이다.
|
|
33
|
+
*/
|
|
34
|
+
export function shouldAsk({ policy = DEFAULT_ASK_POLICY, required = false } = {}) {
|
|
35
|
+
if (policy === 'never') return false;
|
|
36
|
+
if (policy === 'always') return true;
|
|
37
|
+
return Boolean(required);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createAsk({ stage, question, options = [], now = new Date().toISOString() }) {
|
|
41
|
+
if (!question || typeof question !== 'string' || !question.trim()) {
|
|
42
|
+
throw new TypeError('ask 에는 비어 있지 않은 question 이 필요합니다.');
|
|
43
|
+
}
|
|
44
|
+
const normalized = options
|
|
45
|
+
.map((o, i) => ({
|
|
46
|
+
// 키가 없으면 a, b, c … 를 붙인다. 사람이 `hi-loop answer a` 로 답하는 손잡이다.
|
|
47
|
+
key: String(o.key ?? String.fromCharCode(97 + i)).toLowerCase(),
|
|
48
|
+
label: String(o.label ?? o),
|
|
49
|
+
impact: o.impact ? String(o.impact) : '',
|
|
50
|
+
}))
|
|
51
|
+
.filter((o) => o.label.trim());
|
|
52
|
+
return {
|
|
53
|
+
id: `ask_${randomBytes(4).toString('hex')}`,
|
|
54
|
+
stage: stage ?? 'BUILD',
|
|
55
|
+
question: question.trim(),
|
|
56
|
+
options: normalized,
|
|
57
|
+
askedAt: now,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 질문을 상태에 걸고 멈춘다. 상태를 직접 바꾸지 않고 새 객체를 돌려준다. */
|
|
62
|
+
export function pauseForAsk(state, ask) {
|
|
63
|
+
return { ...state, status: 'awaiting', ask };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 답을 적용한다. 고른 선택지는 **가정으로 승격**된다 — 사람이 고른 것도 결국 가정이고,
|
|
68
|
+
* 기록되지 않은 가정은 없어야 한다(FR-10.2). 이 기록이 나중 단계 프롬프트에 실린다.
|
|
69
|
+
*
|
|
70
|
+
* 반환: `{ ok, state, reason }`. 실패해도 던지지 않는다 — CLI/MCP 양쪽이 같은 방식으로
|
|
71
|
+
* 사용자에게 사유를 돌려줘야 하기 때문이다.
|
|
72
|
+
*/
|
|
73
|
+
export function applyAnswer(state, { choice, note = '', now = new Date().toISOString() } = {}) {
|
|
74
|
+
const ask = state?.ask;
|
|
75
|
+
if (!ask) return { ok: false, state, reason: '대기 중인 질문이 없습니다.' };
|
|
76
|
+
|
|
77
|
+
const key = choice === undefined || choice === null ? '' : String(choice).toLowerCase().trim();
|
|
78
|
+
const options = ask.options ?? [];
|
|
79
|
+
const picked = options.find((o) => o.key === key || o.label.toLowerCase() === key);
|
|
80
|
+
|
|
81
|
+
// 선택지가 있는 질문인데 못 고르면 진행하지 않는다. 여기서 관대해지면 엔진이 사람의
|
|
82
|
+
// 오타를 임의 해석해 분기를 골라버린다 — ask 로 막으려던 바로 그 일이다.
|
|
83
|
+
if (options.length && !picked) {
|
|
84
|
+
if (!note.trim()) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
state,
|
|
88
|
+
reason: `선택지에 없는 답입니다: "${choice}". 가능한 값: ${options.map((o) => o.key).join(', ')} (또는 --note 로 자유 서술)`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const decision = picked ? picked.label : note.trim();
|
|
94
|
+
// stage 를 남겨야 재개할 때 "이 관문은 이미 답을 받았다"를 알 수 있다.
|
|
95
|
+
// 없으면 같은 질문을 매 재개마다 다시 묻는다.
|
|
96
|
+
const answered = {
|
|
97
|
+
id: ask.id,
|
|
98
|
+
stage: ask.stage,
|
|
99
|
+
choice: picked ? picked.key : '',
|
|
100
|
+
note: note.trim(),
|
|
101
|
+
at: now,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
reason: '',
|
|
107
|
+
state: {
|
|
108
|
+
...state,
|
|
109
|
+
ask: null,
|
|
110
|
+
status: 'running',
|
|
111
|
+
answers: [...(state.answers ?? []), answered],
|
|
112
|
+
assumptions: [
|
|
113
|
+
...(state.assumptions ?? []),
|
|
114
|
+
{
|
|
115
|
+
text: note.trim() && picked ? `${decision} (단서: ${note.trim()})` : decision,
|
|
116
|
+
source: 'ask',
|
|
117
|
+
stage: ask.stage,
|
|
118
|
+
decidedBy: 'human',
|
|
119
|
+
at: now,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 에이전트가 스스로 세운 가정을 기록한다 (policy=never 또는 분기가 아닌 모호성). */
|
|
127
|
+
export function recordAssumption(state, { text, stage, now = new Date().toISOString() }) {
|
|
128
|
+
const clean = String(text ?? '').trim();
|
|
129
|
+
if (!clean) return state;
|
|
130
|
+
return {
|
|
131
|
+
...state,
|
|
132
|
+
assumptions: [
|
|
133
|
+
...(state.assumptions ?? []),
|
|
134
|
+
{ text: clean, source: 'discover', stage: stage ?? state.stage, decidedBy: 'agent', at: now },
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** CLI·MCP·텔레그램이 공유하는 표시 형식. 한 곳에만 둔다. */
|
|
140
|
+
export function formatAsk(ask) {
|
|
141
|
+
if (!ask) return '';
|
|
142
|
+
const lines = [`⏸ 선택이 필요합니다 [${ask.stage}]`, ` ${ask.question}`];
|
|
143
|
+
for (const o of ask.options ?? []) {
|
|
144
|
+
lines.push(` ${o.key}) ${o.label}${o.impact ? ` — ${o.impact}` : ''}`);
|
|
145
|
+
}
|
|
146
|
+
lines.push(
|
|
147
|
+
(ask.options ?? []).length
|
|
148
|
+
? `→ hi-loop answer <${(ask.options ?? []).map((o) => o.key).join('|')}> [--note "..."]`
|
|
149
|
+
: '→ hi-loop answer --note "<답>"',
|
|
150
|
+
);
|
|
151
|
+
return lines.join('\n');
|
|
152
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 안쪽 자가 치유 루프 (FR-2, FR-3) — 이 엔진의 검증된 심장이다.
|
|
3
|
+
*
|
|
4
|
+
* PLAN -> DO -> CHECK -> ACT(HEAL) -> CHECK -> ...
|
|
5
|
+
* CHECK 는 엔진이 직접 testCommand 를 돌려 판정한다. 에이전트의 자기보고는 믿지 않는다.
|
|
6
|
+
*
|
|
7
|
+
* loop.js 의 stage 머신이 이 함수를 부르고, 반환값으로 다음 stage 를 정한다.
|
|
8
|
+
* CODE_REVIEW·SHIP·WATCH 가 기각하면 이 함수를 **다시** 부른다 — 새 치유 경로를
|
|
9
|
+
* 만들지 않고 HEAL 을 재사용하는 것이 확장의 규칙이다.
|
|
10
|
+
*
|
|
11
|
+
* 반환:
|
|
12
|
+
* { ok: true } 테스트 통과
|
|
13
|
+
* { ok: false, stopReason, detail } 예산·정체·한도 소진
|
|
14
|
+
* { stopped: true } --stop-after PLAN 으로 멈춤
|
|
15
|
+
*/
|
|
16
|
+
import { saveState, truncate, errorSignature, LIMITS } from './state.js';
|
|
17
|
+
import { promptFor } from './prompts.js';
|
|
18
|
+
import { compareFingerprints, violationMessage } from './integrity.js';
|
|
19
|
+
import { detectDeletions } from './checkpoint.js';
|
|
20
|
+
|
|
21
|
+
/** iteration -> PDCA phase (1회차 PLAN, 2회차 DO, 이후 전부 HEAL) */
|
|
22
|
+
export function phaseForIteration(iteration) {
|
|
23
|
+
if (iteration === 1) return 'PLAN';
|
|
24
|
+
if (iteration === 2) return 'DO';
|
|
25
|
+
return 'ACT';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function summarizeSpec(text) {
|
|
29
|
+
return truncate(String(text ?? '').trim(), LIMITS.specSummary);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function combineOutput({ stdout, stderr }) {
|
|
33
|
+
return [stderr, stdout].filter(Boolean).join('\n').trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 부동소수 드리프트를 막으려 6자리에서 끊는다. 러너가 비용을 안 주면 0으로 친다. */
|
|
37
|
+
function addCost(prev, delta) {
|
|
38
|
+
const next = (Number.isFinite(prev) ? prev : 0) + (Number.isFinite(delta) ? delta : 0);
|
|
39
|
+
return Math.round(next * 1e6) / 1e6;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runBuildLoop(ctx) {
|
|
43
|
+
const { goal, cwd, logger, notify, statePath, baselineFiles, designReviewEnabled, ports, opts } = ctx;
|
|
44
|
+
const { maxLoops, budgetUsd, testCommand, stagnationLimit, verifySpec, handoffEvery, maxDesignRounds, stopAfter } = opts;
|
|
45
|
+
const { agentRunner, testRunner, integrityChecker, checkpointer, fileLister, specVerifier, designReviewer } = ports;
|
|
46
|
+
// state 는 이 루프에서 속성만 바뀌고 재대입되지 않는다 — 참조를 그대로 잡아도 안전하다.
|
|
47
|
+
const state = ctx.state;
|
|
48
|
+
|
|
49
|
+
state.stage = 'BUILD';
|
|
50
|
+
state.status = 'running';
|
|
51
|
+
saveState(statePath, state);
|
|
52
|
+
|
|
53
|
+
while (state.iteration < maxLoops) {
|
|
54
|
+
// 예산은 **다음 호출 앞**에서 막는다. 이미 쓴 돈은 되돌릴 수 없으므로
|
|
55
|
+
// 할 수 있는 일은 "더 쓰지 않는 것"뿐이다.
|
|
56
|
+
if (budgetUsd != null && state.costUsd >= budgetUsd) {
|
|
57
|
+
logger(`[hi-loop] 💸 예산 $${budgetUsd} 소진 (누적 $${state.costUsd.toFixed(2)}) — ${state.iteration}회차에서 중단합니다.`);
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
stopReason: 'budget',
|
|
61
|
+
detail: `예산 소진: $${state.costUsd.toFixed(2)} / $${budgetUsd}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
state.iteration += 1;
|
|
66
|
+
state.phase = phaseForIteration(state.iteration);
|
|
67
|
+
state.status = 'running';
|
|
68
|
+
|
|
69
|
+
// 체크포인트: 에이전트가 손대기 **전** 상태를 스냅샷. 이 호출이 망친 것을 되돌릴 수 있게.
|
|
70
|
+
const sha = await checkpointer({ cwd });
|
|
71
|
+
if (sha) {
|
|
72
|
+
state.checkpoints = [...(state.checkpoints ?? []), { iteration: state.iteration, sha }].slice(-LIMITS.checkpointKeep);
|
|
73
|
+
}
|
|
74
|
+
saveState(statePath, state);
|
|
75
|
+
|
|
76
|
+
logger(`[hi-loop] iteration ${state.iteration}/${maxLoops} — ${state.phase} (session #${state.sessionSerial})`);
|
|
77
|
+
|
|
78
|
+
const prompt = promptFor(state);
|
|
79
|
+
const result = await agentRunner({ prompt, sessionId: state.sessionId, cwd });
|
|
80
|
+
state.sessionId = result?.sessionId ?? state.sessionId ?? null;
|
|
81
|
+
state.costUsd = addCost(state.costUsd, result?.costUsd);
|
|
82
|
+
|
|
83
|
+
if (state.phase === 'PLAN') {
|
|
84
|
+
state.specSummary = summarizeSpec(result?.text) || state.specSummary;
|
|
85
|
+
|
|
86
|
+
// ---- DESIGN_REVIEW (FR-11): 구현 착수 **전** 가장 값싼 교정 지점 ----
|
|
87
|
+
// 스펙이 goal 을 잘못 읽었으면 그 뒤 전부가 틀린 것을 향해 간다. CHECK 는 이걸
|
|
88
|
+
// 못 잡는다 — 테스트도 같은 스펙에서 나왔으니 사이좋게 통과하기 때문이다.
|
|
89
|
+
if (designReviewEnabled && (state.designRounds ?? 0) < maxDesignRounds) {
|
|
90
|
+
const verdict = await designReviewer({
|
|
91
|
+
goal,
|
|
92
|
+
assumptions: state.assumptions ?? [],
|
|
93
|
+
specPath: state.specPath,
|
|
94
|
+
testPath: state.testPath,
|
|
95
|
+
cwd,
|
|
96
|
+
});
|
|
97
|
+
if (verdict?.verdict === 'reject') {
|
|
98
|
+
state.designRounds = (state.designRounds ?? 0) + 1;
|
|
99
|
+
state.lastError = truncate(
|
|
100
|
+
`설계 리뷰가 이 스펙을 기각했다. 구현으로 넘어가지 말고 스펙과 테스트를 먼저 고쳐라:\n${verdict.reason || '사유 없음'}`,
|
|
101
|
+
LIMITS.lastError,
|
|
102
|
+
);
|
|
103
|
+
// PLAN 을 한 번 더 돌린다. phase 는 iteration 에서 나오므로 회차를 되돌려야
|
|
104
|
+
// 다음 바퀴가 다시 PLAN 이 된다. CHECK 는 건너뛴다 — 아직 구현이 없어서
|
|
105
|
+
// 테스트를 돌려봐야 확정적으로 실패하고, 그 실패는 아무것도 알려주지 않는다.
|
|
106
|
+
state.iteration -= 1;
|
|
107
|
+
saveState(statePath, state);
|
|
108
|
+
logger(`[hi-loop] 📐 설계 리뷰 기각 (${state.designRounds}/${maxDesignRounds}) — 스펙을 다시 씁니다: ${truncate(verdict.reason ?? '', 200)}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
logger('[hi-loop] 📐 설계 리뷰 통과.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `hi-loop plan` — 스펙과 테스트만 만들고 멈춘다. PLAN 은 안쪽 루프의 회차라
|
|
115
|
+
// 정지 지점도 여기다(바깥 stage 머신에는 PLAN 이라는 자리가 없다).
|
|
116
|
+
if (stopAfter === 'PLAN') {
|
|
117
|
+
saveState(statePath, state);
|
|
118
|
+
return { ok: false, stopped: true };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- CHECK: 엔진이 직접 검증 (FR-2.3) ----
|
|
123
|
+
// 2단 판정이다. exit code 는 "테스트가 통과했는가"만 답한다. 그 테스트가
|
|
124
|
+
// **그대로인가**는 별도 문제이고, 그걸 안 물으면 에이전트는 테스트를 지워서 통과한다(L1).
|
|
125
|
+
state.phase = 'CHECK';
|
|
126
|
+
saveState(statePath, state);
|
|
127
|
+
const check = await testRunner({ command: testCommand, cwd });
|
|
128
|
+
const output = combineOutput(check);
|
|
129
|
+
|
|
130
|
+
const fingerprint = integrityChecker({ cwd, testPath: state.testPath });
|
|
131
|
+
const integrity = compareFingerprints(state.testFingerprint, fingerprint);
|
|
132
|
+
// 위반이 없을 때만 기준선을 갱신한다. 갱신해버리면 약화된 상태가 다음 회차의
|
|
133
|
+
// 기준이 되어 에이전트가 그대로 빠져나간다.
|
|
134
|
+
if (integrity.ok) state.testFingerprint = fingerprint;
|
|
135
|
+
|
|
136
|
+
// L8: baseline 에 있던 파일이 사라졌는가 = 에이전트가 기존 파일을 삭제. 차단하지 않고
|
|
137
|
+
// 경고만 한다(체크포인트로 되돌릴 수 있고 판단은 사람 몫). 누적 관측이라 Gaps 에도 실린다.
|
|
138
|
+
if (baselineFiles) {
|
|
139
|
+
const deleted = detectDeletions(baselineFiles, await fileLister({ cwd }));
|
|
140
|
+
const fresh = deleted.filter((f) => !(state.deletedFiles ?? []).includes(f));
|
|
141
|
+
if (fresh.length) {
|
|
142
|
+
state.deletedFiles = [...(state.deletedFiles ?? []), ...fresh];
|
|
143
|
+
logger(`[hi-loop] ⚠️ 지정 경로 밖 파일 ${fresh.length}개가 삭제됐습니다 (차단 안 함 — hi-loop rollback 으로 복원 가능):`);
|
|
144
|
+
for (const f of fresh) logger(`[hi-loop] - ${f}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Tier 1 (기계, 최종 권한): exit code + 무결성.
|
|
149
|
+
let passed = Boolean(check?.ok) && integrity.ok;
|
|
150
|
+
|
|
151
|
+
// Tier 2 (모델, 하향 전용): Tier 1 통과일 때만 호출. 스펙 대비 구현을 심판한다.
|
|
152
|
+
// Tier 1 실패를 통과로 올릴 수는 절대 없다 — reject 만 가능하다(L9, bkit 중심 명제).
|
|
153
|
+
let specReject = '';
|
|
154
|
+
if (passed && verifySpec) {
|
|
155
|
+
const verdict = await specVerifier({ specPath: state.specPath, cwd });
|
|
156
|
+
if (verdict?.verdict === 'reject') {
|
|
157
|
+
passed = false;
|
|
158
|
+
specReject = verdict.reason || '스펙과 어긋남';
|
|
159
|
+
logger(`[hi-loop] 🔬 스펙 검증 기각 — 테스트는 통과했으나 스펙 요구를 못 채웠습니다: ${truncate(specReject, 200)}`);
|
|
160
|
+
} else {
|
|
161
|
+
state.specVerified = true; // Gaps 보고가 "스펙 미검증" 대신 "검증 통과"를 쓰게 한다.
|
|
162
|
+
logger(`[hi-loop] 🔬 스펙 검증 통과.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
state.history.push({
|
|
167
|
+
iteration: state.iteration,
|
|
168
|
+
phase: 'CHECK',
|
|
169
|
+
ok: passed,
|
|
170
|
+
summary: passed
|
|
171
|
+
? 'tests passed + spec verified'
|
|
172
|
+
: truncate(
|
|
173
|
+
specReject
|
|
174
|
+
? `스펙 검증 기각: ${specReject}`
|
|
175
|
+
: integrity.ok
|
|
176
|
+
? output
|
|
177
|
+
: violationMessage(integrity.violations),
|
|
178
|
+
LIMITS.historySummary,
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!integrity.ok) {
|
|
183
|
+
logger(`[hi-loop] 🚨 테스트 무결성 위반 — ${integrity.violations.length}건. 통과로 인정하지 않습니다.`);
|
|
184
|
+
for (const v of integrity.violations) logger(`[hi-loop] - ${v}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (passed) {
|
|
188
|
+
state.lastError = '';
|
|
189
|
+
saveState(statePath, state);
|
|
190
|
+
logger(`[hi-loop] ✅ ${state.iteration}회차에 통과했습니다. (누적 $${state.costUsd.toFixed(2)})`);
|
|
191
|
+
return { ok: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- ACT: 에러를 다음 루프의 연료로 (FR-2.4) ----
|
|
195
|
+
// 무결성 위반은 에러 **뒤**에 붙인다. 절단이 tail-biased 이므로(§5.2) 꼬리가 살아남는다.
|
|
196
|
+
// 앞에 붙이면 여기서든 saveState 에서든 위반 사실이 먼저 잘려나가고, 에이전트는
|
|
197
|
+
// 자기가 뭘 어겼는지 모른 채 같은 짓을 반복한다.
|
|
198
|
+
// 실패의 정체를 셋 중 하나로 조립한다: 스펙 기각 / 무결성 위반 / 테스트 에러.
|
|
199
|
+
// 스펙 기각이면 테스트는 통과했으니 output 대신 스펙 사유가 다음 루프의 연료다.
|
|
200
|
+
let failure;
|
|
201
|
+
if (specReject) {
|
|
202
|
+
failure = `스펙 검증이 이 구현을 기각했다. 테스트는 통과했지만 스펙 요구를 못 채웠다:\n${specReject}\n스펙(${state.specPath})을 다시 읽고 그 요구를 충족하도록 구현을 고쳐라. 테스트만 통과시키는 것으로는 부족하다.`;
|
|
203
|
+
} else {
|
|
204
|
+
const body = output || `테스트가 코드 ${check?.code}로 실패했습니다.`;
|
|
205
|
+
failure = integrity.ok ? body : `${body}\n\n${violationMessage(integrity.violations)}`;
|
|
206
|
+
}
|
|
207
|
+
state.lastError = truncate(failure, LIMITS.lastError);
|
|
208
|
+
state.phase = 'ACT';
|
|
209
|
+
|
|
210
|
+
// ---- 정체 감지 (L2) ----
|
|
211
|
+
// 판정 신호는 스펙 기각이면 기각 사유, 무결성 위반이면 그 위반, 아니면 에러 지문이다.
|
|
212
|
+
// 같은 실패가 stagnationLimit 회 연속이면 이 접근으로는 못 고친다는 뜻 —
|
|
213
|
+
// 같은 값을 태우며 maxLoops 를 다 쓰는 것은 순수한 낭비다(실측: 함정에 $3.07).
|
|
214
|
+
const sig = specReject
|
|
215
|
+
? `SPEC:${errorSignature(specReject)}`
|
|
216
|
+
: integrity.ok
|
|
217
|
+
? errorSignature(output)
|
|
218
|
+
: `INTEGRITY:${integrity.violations.join('|')}`;
|
|
219
|
+
state.stagnantRuns = sig && sig === state.errorSig ? state.stagnantRuns + 1 : 1;
|
|
220
|
+
state.errorSig = sig;
|
|
221
|
+
saveState(statePath, state);
|
|
222
|
+
|
|
223
|
+
if (integrity.ok) {
|
|
224
|
+
logger(`[hi-loop] ❌ 테스트 실패 (code ${check?.code}) — 치유를 시도합니다.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (stagnationLimit != null && state.stagnantRuns >= stagnationLimit && state.iteration < maxLoops) {
|
|
228
|
+
logger(`[hi-loop] 🔁 같은 실패가 ${state.stagnantRuns}회 반복 — 접근이 막혔습니다. ${state.iteration}회차에서 중단합니다. (누적 $${state.costUsd.toFixed(2)})`);
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
stopReason: 'stagnated',
|
|
232
|
+
detail: `정체: 같은 실패 ${state.stagnantRuns}회 반복`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- 세션 핸드오프 & 컨텍스트 다이어트 (FR-3.2) ----
|
|
237
|
+
if (state.iteration % handoffEvery === 0 && state.iteration < maxLoops) {
|
|
238
|
+
state.sessionId = null;
|
|
239
|
+
state.sessionSerial += 1;
|
|
240
|
+
saveState(statePath, state);
|
|
241
|
+
logger(`[hi-loop] ♻️ 세션 핸드오프 — 압축 상태만 session #${state.sessionSerial}에 넘깁니다.`);
|
|
242
|
+
await notify({
|
|
243
|
+
type: 'handoff',
|
|
244
|
+
iteration: state.iteration,
|
|
245
|
+
detail: `session #${state.sessionSerial} 로 컨텍스트 다이어트 후 재개`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
logger(`[hi-loop] 한도(${maxLoops}회)를 소진했지만 테스트가 통과하지 못했습니다. (누적 $${state.costUsd.toFixed(2)})`);
|
|
251
|
+
return { ok: false, stopReason: 'maxLoops', detail: truncate(state.lastError, 500) };
|
|
252
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `run` 계열 명령의 인자 → runLoop 옵션 번역 (FR-1.4, FR-13 ~ FR-15).
|
|
3
|
+
*
|
|
4
|
+
* bin/hi-loop.js 에서 뺀 이유는 크기(NFR-5)만이 아니다. 이 파일의 규칙은 하나로 요약된다:
|
|
5
|
+
* **잘못된 값을 조용히 기본값으로 흘리지 않는다.**
|
|
6
|
+
* "5분"이라 쓴 게 5ms 로 해석되면 감시했다는 착각만 남고, 잘못된 예산이 무제한으로
|
|
7
|
+
* 해석되면 비용 통제가 침묵 속에 실패한다. 그래서 전부 exit 2 로 거른다.
|
|
8
|
+
*
|
|
9
|
+
* 반환: `{ ok: true, options }` 또는 `{ ok: false, message }` — 던지지 않는다.
|
|
10
|
+
* CLI 는 message 를 stderr 로 내고 2를 돌려주기만 하면 된다.
|
|
11
|
+
*/
|
|
12
|
+
import { parseDuration, WATCH_DEFAULTS } from './ship.js';
|
|
13
|
+
import { normalizeAskPolicy } from './ask.js';
|
|
14
|
+
|
|
15
|
+
export const STAGE_NAMES = ['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH'];
|
|
16
|
+
|
|
17
|
+
const bad = (message) => ({ ok: false, message });
|
|
18
|
+
|
|
19
|
+
/** 값이 실제로 주어졌는가. `--flag` 만 쓰면 파서가 true 를 넣으므로 그것도 "없음"이다. */
|
|
20
|
+
const given = (v) => v !== undefined && v !== true && v !== '';
|
|
21
|
+
|
|
22
|
+
export function parseRunOptions(args, { staged = {} } = {}) {
|
|
23
|
+
const str = (k) => (given(args[k]) ? String(args[k]) : null);
|
|
24
|
+
|
|
25
|
+
const intOpt = (k, { min = 1, fallback }) => {
|
|
26
|
+
if (!given(args[k])) return fallback;
|
|
27
|
+
const n = Number(args[k]);
|
|
28
|
+
if (!(Number.isInteger(n) && n >= min)) return { error: `--${k} 는 ${min} 이상의 정수여야 합니다: ${args[k]}` };
|
|
29
|
+
return n;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const enumOpt = (k, allowed, fallback) => {
|
|
33
|
+
const v = str(k);
|
|
34
|
+
if (v === null) return fallback;
|
|
35
|
+
if (!allowed.includes(v)) return { error: `--${k} 는 ${allowed.join('|')} 중 하나여야 합니다: ${v}` };
|
|
36
|
+
return v;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const stageOpt = (k) => {
|
|
40
|
+
const v = str(k);
|
|
41
|
+
if (v === null) return null;
|
|
42
|
+
const upper = v.toUpperCase();
|
|
43
|
+
if (!STAGE_NAMES.includes(upper)) return { error: `--${k} 는 ${STAGE_NAMES.join('|')} 중 하나여야 합니다: ${v}` };
|
|
44
|
+
return upper;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const askPolicy = normalizeAskPolicy(args.ask);
|
|
48
|
+
if (askPolicy === null) return bad(`--ask 는 auto|never|always 중 하나여야 합니다: ${args.ask}`);
|
|
49
|
+
|
|
50
|
+
// 예산은 명시할 때만 건다. 잘못된 값이 조용히 "무제한"이 되면 안 된다.
|
|
51
|
+
let budgetUsd = null;
|
|
52
|
+
if (given(args['budget-usd'])) {
|
|
53
|
+
budgetUsd = Number(args['budget-usd']);
|
|
54
|
+
if (!(Number.isFinite(budgetUsd) && budgetUsd > 0)) {
|
|
55
|
+
return bad(`--budget-usd 는 0 보다 큰 숫자여야 합니다: ${args['budget-usd']}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 정체 상한: --no-stagnation 으로 끄거나 --stagnation N 으로 조절. 기본 3.
|
|
60
|
+
let stagnationLimit = 3;
|
|
61
|
+
if (args['no-stagnation']) stagnationLimit = null;
|
|
62
|
+
else {
|
|
63
|
+
const n = intOpt('stagnation', { fallback: 3 });
|
|
64
|
+
if (n?.error) return bad(n.error);
|
|
65
|
+
stagnationLimit = n;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 기간은 조용히 기본값으로 흘리지 않는다.
|
|
69
|
+
const durations = {};
|
|
70
|
+
for (const [flag, key, fallback] of [
|
|
71
|
+
['watch-for', 'watchForMs', WATCH_DEFAULTS.forMs],
|
|
72
|
+
['watch-every', 'watchEveryMs', WATCH_DEFAULTS.everyMs],
|
|
73
|
+
]) {
|
|
74
|
+
const ms = parseDuration(args[flag], { fallback });
|
|
75
|
+
if (ms === null) return bad(`--${flag} 형식이 잘못됐습니다 (예: 5m, 30s, 1500): ${args[flag]}`);
|
|
76
|
+
durations[key] = ms;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const numbers = {};
|
|
80
|
+
for (const [flag, key, min] of [
|
|
81
|
+
['max-loops', 'maxLoops', 1],
|
|
82
|
+
['max-review-rounds', 'maxReviewRounds', 1],
|
|
83
|
+
['max-design-rounds', 'maxDesignRounds', 1],
|
|
84
|
+
['watch-tolerate', 'watchTolerate', 0],
|
|
85
|
+
]) {
|
|
86
|
+
const v = intOpt(flag, { min, fallback: undefined });
|
|
87
|
+
if (v?.error) return bad(v.error);
|
|
88
|
+
if (v !== undefined) numbers[key] = v;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const onShipFail = enumOpt('on-ship-fail', ['stop', 'heal'], 'stop');
|
|
92
|
+
if (onShipFail?.error) return bad(onShipFail.error);
|
|
93
|
+
const onWatchFail = enumOpt('on-watch-fail', ['stop', 'rollback', 'heal'], 'stop');
|
|
94
|
+
if (onWatchFail?.error) return bad(onWatchFail.error);
|
|
95
|
+
|
|
96
|
+
// 서브커맨드가 정한 구간이 우선한다 — `hi-loop ship` 은 배포 단계를 하라는 명령이다.
|
|
97
|
+
const startFrom = staged.startFrom ?? stageOpt('start-from');
|
|
98
|
+
if (startFrom?.error) return bad(startFrom.error);
|
|
99
|
+
const stopAfter = staged.stopAfter ?? stageOpt('stop-after');
|
|
100
|
+
if (stopAfter?.error) return bad(stopAfter.error);
|
|
101
|
+
// PLAN 은 안쪽 루프의 회차라 시작점이 될 수 없다. BUILD 로 들어가면 자연히 PLAN 부터다.
|
|
102
|
+
if (startFrom === 'PLAN') {
|
|
103
|
+
return bad('--start-from 에는 PLAN 을 쓸 수 없습니다. BUILD 로 시작하면 PLAN 부터 돕니다.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// null = 자동(--full 또는 --ship 이면 켜짐). 명시 플래그가 그 자동을 양방향으로 덮는다.
|
|
107
|
+
const triState = (onKey, offKey) => (args[offKey] ? false : args[onKey] ? true : null);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
options: {
|
|
112
|
+
testCommand: str('test') ?? 'npm test',
|
|
113
|
+
budgetUsd,
|
|
114
|
+
stagnationLimit,
|
|
115
|
+
verifySpec: Boolean(args['verify-spec']),
|
|
116
|
+
shipCommand: str('ship'),
|
|
117
|
+
watchCommand: str('watch'),
|
|
118
|
+
onShipFail,
|
|
119
|
+
onWatchFail,
|
|
120
|
+
askPolicy,
|
|
121
|
+
yes: Boolean(args.yes),
|
|
122
|
+
full: Boolean(args.full),
|
|
123
|
+
codeReview: triState('review', 'no-review'),
|
|
124
|
+
designReview: triState('design-review', 'no-design-review'),
|
|
125
|
+
discover: triState('discover', 'no-discover'),
|
|
126
|
+
startFrom,
|
|
127
|
+
stopAfter,
|
|
128
|
+
...durations,
|
|
129
|
+
...numbers,
|
|
130
|
+
// 서브커맨드는 그 단계를 **하라는 명령**이다. 자동 기본값이 그걸 끄면 안 된다.
|
|
131
|
+
...(staged.force ?? {}),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/discover.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DISCOVER — 요구사항 발굴/기획 (FR-10).
|
|
3
|
+
*
|
|
4
|
+
* goal 은 보통 한 줄이다. 그 한 줄과 실제로 만들어야 할 것 사이의 간극을 메우는 게 이 단계다.
|
|
5
|
+
* 하지만 이 엔진의 기본은 무중단이므로, 발굴의 결론은 **질문 목록이 아니라 가정 목록**이다:
|
|
6
|
+
* 모호한 지점은 에이전트가 가정을 세우고 **기록한 뒤 진행**한다(FR-10.2).
|
|
7
|
+
* 기록되지 않은 가정이 없다는 것 — 그게 이 단계의 진짜 산출물이다.
|
|
8
|
+
*
|
|
9
|
+
* 사람을 부르는 경우는 하나뿐이다(FR-10.3): **상호 배타적이고 스펙을 실질적으로 가르는
|
|
10
|
+
* 분기.** "둘 다 가정으로 적고 진행"이 성립하지 않는 지점. 그마저도 한 번에 하나만 묻는다 —
|
|
11
|
+
* 질문을 쌓으면 무중단이라는 기본이 무너진다.
|
|
12
|
+
*
|
|
13
|
+
* 산출물 문서는 **엔진이 쓴다.** 에이전트에게 쓰게 하면 경로를 못 지키거나 남의 파일을
|
|
14
|
+
* 건드릴 수 있고, 무엇보다 "썼다고 말했지만 안 썼다"를 검증할 방법이 없다.
|
|
15
|
+
* 그래서 이 단계의 에이전트는 쓰기 권한 없이(plan) 돌고, 판정 가능한 JSON 만 돌려준다.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { buildAgentArgs, parseAgentOutput } from './runners.js';
|
|
21
|
+
import { discoverPrompt } from './prompts.js';
|
|
22
|
+
|
|
23
|
+
/** 관대한 파싱. 형식이 깨지면 "가정도 분기도 없음" — 발굴 실패가 루프를 막지는 않는다. */
|
|
24
|
+
export function parseDiscovery(text) {
|
|
25
|
+
try {
|
|
26
|
+
const m = String(text).match(/\{[\s\S]*"assumptions"[\s\S]*\}/);
|
|
27
|
+
const obj = JSON.parse(m ? m[0] : text);
|
|
28
|
+
const assumptions = (Array.isArray(obj.assumptions) ? obj.assumptions : [])
|
|
29
|
+
.map((a) => String(a?.text ?? a ?? '').trim())
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
|
|
32
|
+
// 분기는 **하나만** 받는다. 선택지가 2개 미만이면 분기가 아니다 — 고를 게 없으면
|
|
33
|
+
// 그건 그냥 질문이고, 질문은 가정으로 답해야 할 대상이다.
|
|
34
|
+
const raw = Array.isArray(obj.forks) ? obj.forks : obj.fork ? [obj.fork] : [];
|
|
35
|
+
const fork = raw
|
|
36
|
+
.map((f) => ({
|
|
37
|
+
question: String(f?.question ?? '').trim(),
|
|
38
|
+
options: (Array.isArray(f?.options) ? f.options : [])
|
|
39
|
+
.map((o) => ({ label: String(o?.label ?? o ?? '').trim(), impact: String(o?.impact ?? '').trim() }))
|
|
40
|
+
.filter((o) => o.label),
|
|
41
|
+
}))
|
|
42
|
+
.find((f) => f.question && f.options.length >= 2) ?? null;
|
|
43
|
+
|
|
44
|
+
return { assumptions, fork, summary: String(obj.summary ?? '').trim() };
|
|
45
|
+
} catch {
|
|
46
|
+
return { assumptions: [], fork: null, summary: '' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 발굴 결과를 사람이 읽을 문서로 만든다. 이 문서가 나중 단계들이 참조할 근거가 된다. */
|
|
51
|
+
export function renderDiscoveryDoc({ goal, assumptions, fork, summary, now }) {
|
|
52
|
+
const lines = [
|
|
53
|
+
'# 요구사항 발굴 (DISCOVER)',
|
|
54
|
+
'',
|
|
55
|
+
`> hi-loop 이 자동 생성했습니다. 생성 시각: ${now}`,
|
|
56
|
+
'',
|
|
57
|
+
'## 목표(goal)',
|
|
58
|
+
goal,
|
|
59
|
+
'',
|
|
60
|
+
];
|
|
61
|
+
if (summary) lines.push('## 해석', summary, '');
|
|
62
|
+
lines.push('## 확정된 가정');
|
|
63
|
+
if (assumptions.length) {
|
|
64
|
+
lines.push('goal 만으로는 정해지지 않아 엔진이 확정한 것들이다. 틀렸다면 여기를 고치고 다시 실행하라.', '');
|
|
65
|
+
for (const a of assumptions) lines.push(`- ${a}`);
|
|
66
|
+
} else {
|
|
67
|
+
lines.push('(없음 — goal 이 이미 충분히 정밀하다고 판단했다)');
|
|
68
|
+
}
|
|
69
|
+
lines.push('');
|
|
70
|
+
if (fork) {
|
|
71
|
+
lines.push('## 사람에게 물은 분기', `**${fork.question}**`, '');
|
|
72
|
+
for (const o of fork.options) lines.push(`- ${o.label}${o.impact ? ` — ${o.impact}` : ''}`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
}
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeDiscoveryDoc({ cwd, relPath, content }) {
|
|
79
|
+
const full = join(cwd, relPath);
|
|
80
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
81
|
+
writeFileSync(full, `${content}\n`, 'utf8');
|
|
82
|
+
return relPath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 주입 가능한 발굴 포트 (NFR-3). `({ goal, cwd }) => { assumptions[], fork, summary }`
|
|
87
|
+
* 기본 구현은 claude 를 plan 모드(쓰기 금지)·새 세션으로 spawn 한다.
|
|
88
|
+
*/
|
|
89
|
+
export function makeDiscoverer({
|
|
90
|
+
command = process.env.HILOOP_DISCOVER_CMD || process.env.HILOOP_AGENT_CMD || 'claude',
|
|
91
|
+
model = process.env.HILOOP_DISCOVER_MODEL || '',
|
|
92
|
+
timeoutMs = 10 * 60 * 1000,
|
|
93
|
+
} = {}) {
|
|
94
|
+
return ({ goal, cwd }) =>
|
|
95
|
+
new Promise((resolve) => {
|
|
96
|
+
const extraArgs = model ? ['--model', model] : [];
|
|
97
|
+
const args = buildAgentArgs({
|
|
98
|
+
prompt: discoverPrompt({ goal }),
|
|
99
|
+
sessionId: null,
|
|
100
|
+
extraArgs,
|
|
101
|
+
permissionMode: 'plan',
|
|
102
|
+
});
|
|
103
|
+
const child = spawn(command, args, { cwd, env: process.env });
|
|
104
|
+
let stdout = '';
|
|
105
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
106
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
107
|
+
child.stderr.on('data', () => {});
|
|
108
|
+
// 발굴 실패는 루프를 막지 않는다. 발굴은 정확도를 높이는 단계지 통과 조건이 아니다.
|
|
109
|
+
child.on('error', () => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
resolve({ assumptions: [], fork: null, summary: '' });
|
|
112
|
+
});
|
|
113
|
+
child.on('close', (code) => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
resolve(code === 0 ? parseDiscovery(parseAgentOutput(stdout).text) : { assumptions: [], fork: null, summary: '' });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|