@tuzi-ince/hi-loop 0.1.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 +175 -0
- package/bin/hi-loop.js +164 -0
- package/bin/setup.js +115 -0
- package/docs/design.md +631 -0
- package/docs/guide.md +432 -0
- package/docs/spec.md +315 -0
- package/package.json +46 -0
- package/src/args.js +33 -0
- package/src/checkpoint.js +94 -0
- package/src/integrity.js +131 -0
- package/src/is-main.js +28 -0
- package/src/lock.js +83 -0
- package/src/loop.js +296 -0
- package/src/mcp-server.js +159 -0
- package/src/prompts.js +125 -0
- package/src/runners.js +129 -0
- package/src/state.js +230 -0
- package/src/telegram.js +54 -0
- package/src/verify.js +96 -0
package/src/lock.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 동시 실행 락 (L4) — 같은 cwd 에서 두 루프가 상태를 덮어쓰는 것을 막는다.
|
|
3
|
+
*
|
|
4
|
+
* `.agent-state.json` 은 단일 루프를 가정한다(락 없음). 같은 디렉터리에서 두 번째
|
|
5
|
+
* `hi-loop run` 이 뜨면 두 프로세스가 같은 파일을 번갈아 저장해 resume 이 깨진다.
|
|
6
|
+
* 그래서 락 파일 하나로 배타를 강제한다.
|
|
7
|
+
*
|
|
8
|
+
* 설계:
|
|
9
|
+
* - 락은 `wx`(exclusive create)로 만든다 — 이미 있으면 실패한다. 이게 원자성이다.
|
|
10
|
+
* - 락 안에 pid 를 적어둔다. 죽은 프로세스의 락은 stale 로 보고 뺏는다(크래시/SIGKILL
|
|
11
|
+
* 후 남은 락 때문에 영영 못 도는 것이 더 나쁘다).
|
|
12
|
+
* - 락 해제는 finally 에서. 하지만 SIGKILL 은 finally 를 안 태우므로 stale 회수가
|
|
13
|
+
* 최종 안전망이다.
|
|
14
|
+
*/
|
|
15
|
+
import { writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const LOCK_FILENAME = '.agent-state.lock';
|
|
19
|
+
|
|
20
|
+
export function lockPathFor(cwd) {
|
|
21
|
+
return join(cwd, LOCK_FILENAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** pid 가 살아 있는가. 신호 0 은 프로세스를 안 건드리고 존재만 확인한다. */
|
|
25
|
+
function isAlive(pid) {
|
|
26
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
// EPERM = 남의 프로세스지만 살아 있음. ESRCH = 없음.
|
|
32
|
+
return err.code === 'EPERM';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 락을 잡는다. 성공하면 해제 함수를 반환하고, 실패하면 던진다.
|
|
38
|
+
* 호출자는 반환된 함수를 finally 에서 부른다.
|
|
39
|
+
*/
|
|
40
|
+
export function acquireLock(cwd, { pid = process.pid, now = () => new Date().toISOString() } = {}) {
|
|
41
|
+
const path = lockPathFor(cwd);
|
|
42
|
+
const payload = `${JSON.stringify({ pid, at: now() })}\n`;
|
|
43
|
+
|
|
44
|
+
const tryCreate = () => {
|
|
45
|
+
writeFileSync(path, payload, { flag: 'wx' }); // wx: 이미 있으면 EEXIST
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
tryCreate();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err.code !== 'EEXIST') throw err;
|
|
52
|
+
|
|
53
|
+
// 락이 이미 있다. 주인이 살아 있으면 양보하고, 죽었으면 뺏는다.
|
|
54
|
+
const holder = readHolder(path);
|
|
55
|
+
if (holder && isAlive(holder.pid) && holder.pid !== pid) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`다른 hi-loop 루프가 이미 이 디렉터리에서 실행 중입니다 (pid ${holder.pid}). ` +
|
|
58
|
+
`끝나길 기다리거나, 그 프로세스가 죽었다면 ${LOCK_FILENAME} 을 지우세요.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
// stale 이거나 우리 자신의 락 — 뺏어서 다시 만든다.
|
|
62
|
+
rmSync(path, { force: true });
|
|
63
|
+
tryCreate();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let released = false;
|
|
67
|
+
return function release() {
|
|
68
|
+
if (released) return;
|
|
69
|
+
released = true;
|
|
70
|
+
// 우리 락일 때만 지운다. 뺏긴 뒤 남의 락을 지우면 안 된다.
|
|
71
|
+
const holder = readHolder(path);
|
|
72
|
+
if (holder && holder.pid === pid) rmSync(path, { force: true });
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readHolder(path) {
|
|
77
|
+
if (!existsSync(path)) return null;
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
return null; // 손상된 락은 stale 로 본다.
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/loop.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 자가 치유 핵심 오케스트레이션 루프 (FR-2, FR-3)
|
|
3
|
+
*
|
|
4
|
+
* PLAN -> DO -> CHECK -> ACT(HEAL) -> CHECK -> ...
|
|
5
|
+
* CHECK 는 엔진이 직접 testCommand 를 돌려 판정한다. 에이전트의 자기보고는 믿지 않는다.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
saveState,
|
|
9
|
+
statePathFor,
|
|
10
|
+
resumeOrCreate,
|
|
11
|
+
truncate,
|
|
12
|
+
errorSignature,
|
|
13
|
+
reportGaps,
|
|
14
|
+
LIMITS,
|
|
15
|
+
} from './state.js';
|
|
16
|
+
import { promptFor } from './prompts.js';
|
|
17
|
+
import { makeAgentRunner, makeTestRunner } from './runners.js';
|
|
18
|
+
import { makeIntegrityChecker, compareFingerprints, violationMessage } from './integrity.js';
|
|
19
|
+
import { acquireLock } from './lock.js';
|
|
20
|
+
import { makeCheckpointer, makeFileLister, detectDeletions } from './checkpoint.js';
|
|
21
|
+
import { makeSpecVerifier } from './verify.js';
|
|
22
|
+
import { makeNotifier } from './telegram.js';
|
|
23
|
+
|
|
24
|
+
export { createState, loadState, saveState, statePathFor, summarizeState, resetState } from './state.js';
|
|
25
|
+
|
|
26
|
+
/** iteration -> PDCA phase (1회차 PLAN, 2회차 DO, 이후 전부 HEAL) */
|
|
27
|
+
export function phaseForIteration(iteration) {
|
|
28
|
+
if (iteration === 1) return 'PLAN';
|
|
29
|
+
if (iteration === 2) return 'DO';
|
|
30
|
+
return 'ACT';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function summarizeSpec(text) {
|
|
34
|
+
return truncate(String(text ?? '').trim(), LIMITS.specSummary);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function combineOutput({ stdout, stderr }) {
|
|
38
|
+
return [stderr, stdout].filter(Boolean).join('\n').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 부동소수 드리프트를 막으려 6자리에서 끊는다. 러너가 비용을 안 주면 0으로 친다. */
|
|
42
|
+
function addCost(prev, delta) {
|
|
43
|
+
const next = (Number.isFinite(prev) ? prev : 0) + (Number.isFinite(delta) ? delta : 0);
|
|
44
|
+
return Math.round(next * 1e6) / 1e6;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 얇은 락 래퍼 (L4). 같은 cwd 에서 두 루프가 상태를 덮어쓰지 못하게 배타를 강제한다.
|
|
49
|
+
* body 는 return 지점이 여럿이라 여기서 한 번 감싸 finally 로 확실히 푼다.
|
|
50
|
+
* acquireLock 은 테스트를 위해 주입 가능하다.
|
|
51
|
+
*/
|
|
52
|
+
export async function runLoop(opts = {}) {
|
|
53
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
54
|
+
const acquire = opts.acquireLock ?? acquireLock;
|
|
55
|
+
const release = acquire(cwd); // 실패(다른 루프 실행 중)면 여기서 던진다 — release 없음.
|
|
56
|
+
try {
|
|
57
|
+
return await runLoopBody(opts);
|
|
58
|
+
} finally {
|
|
59
|
+
release();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runLoopBody({
|
|
64
|
+
goal,
|
|
65
|
+
testCommand = 'npm test',
|
|
66
|
+
cwd = process.cwd(),
|
|
67
|
+
maxLoops = 10,
|
|
68
|
+
budgetUsd = null,
|
|
69
|
+
stagnationLimit = 3,
|
|
70
|
+
verifySpec = false,
|
|
71
|
+
handoffEvery = 4,
|
|
72
|
+
agentRunner = makeAgentRunner(),
|
|
73
|
+
testRunner = makeTestRunner(),
|
|
74
|
+
integrityChecker = makeIntegrityChecker(),
|
|
75
|
+
checkpointer = makeCheckpointer(),
|
|
76
|
+
fileLister = makeFileLister(),
|
|
77
|
+
specVerifier = makeSpecVerifier(),
|
|
78
|
+
notifier = makeNotifier(),
|
|
79
|
+
logger = (line) => process.stderr.write(`${line}\n`),
|
|
80
|
+
statePath = statePathFor(cwd),
|
|
81
|
+
} = {}) {
|
|
82
|
+
if (!goal || typeof goal !== 'string' || !goal.trim()) {
|
|
83
|
+
throw new TypeError('goal 은 비어 있지 않은 문자열이어야 합니다.');
|
|
84
|
+
}
|
|
85
|
+
if (!Number.isInteger(maxLoops) || maxLoops < 1) {
|
|
86
|
+
throw new TypeError('maxLoops 는 1 이상의 정수여야 합니다.');
|
|
87
|
+
}
|
|
88
|
+
if (budgetUsd != null && !(Number.isFinite(budgetUsd) && budgetUsd > 0)) {
|
|
89
|
+
throw new TypeError('budgetUsd 는 0 보다 큰 숫자여야 합니다.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const state = resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd });
|
|
93
|
+
saveState(statePath, state);
|
|
94
|
+
|
|
95
|
+
const notify = async (event) => {
|
|
96
|
+
try {
|
|
97
|
+
await notifier({ goal, maxLoops, ...event });
|
|
98
|
+
} catch {
|
|
99
|
+
/* 알림 실패는 루프에 영향 없음 (FR-5.3) */
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await notify({ type: 'start', iteration: state.iteration, detail: `testCommand: ${testCommand}` });
|
|
104
|
+
|
|
105
|
+
// L8 baseline: 루프 시작 시점의 추적 파일 목록. 이 뒤로 사라진 파일 = 에이전트가 삭제.
|
|
106
|
+
// null 이면 비-git — 관측 자체가 불가능하니 아래에서 조용히 건너뛴다(인프라 fail-open).
|
|
107
|
+
const baselineFiles = await fileLister({ cwd });
|
|
108
|
+
|
|
109
|
+
while (state.iteration < maxLoops) {
|
|
110
|
+
// 예산은 **다음 호출 앞**에서 막는다. 이미 쓴 돈은 되돌릴 수 없으므로
|
|
111
|
+
// 할 수 있는 일은 "더 쓰지 않는 것"뿐이다.
|
|
112
|
+
if (budgetUsd != null && state.costUsd >= budgetUsd) {
|
|
113
|
+
state.status = 'failed';
|
|
114
|
+
state.phase = 'DONE';
|
|
115
|
+
state.stopReason = 'budget';
|
|
116
|
+
const saved = saveState(statePath, state);
|
|
117
|
+
logger(`[hi-loop] 💸 예산 $${budgetUsd} 소진 (누적 $${state.costUsd.toFixed(2)}) — ${state.iteration}회차에서 중단합니다.`);
|
|
118
|
+
await notify({
|
|
119
|
+
type: 'failed',
|
|
120
|
+
iteration: state.iteration,
|
|
121
|
+
detail: `예산 소진: $${state.costUsd.toFixed(2)} / $${budgetUsd}`,
|
|
122
|
+
});
|
|
123
|
+
return { status: 'failed', iterations: state.iteration, state: saved };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
state.iteration += 1;
|
|
127
|
+
state.phase = phaseForIteration(state.iteration);
|
|
128
|
+
state.status = 'running';
|
|
129
|
+
|
|
130
|
+
// 체크포인트: 에이전트가 손대기 **전** 상태를 스냅샷. 이 호출이 망친 것을 되돌릴 수 있게.
|
|
131
|
+
const sha = await checkpointer({ cwd });
|
|
132
|
+
if (sha) {
|
|
133
|
+
state.checkpoints = [...(state.checkpoints ?? []), { iteration: state.iteration, sha }].slice(-LIMITS.checkpointKeep);
|
|
134
|
+
}
|
|
135
|
+
saveState(statePath, state);
|
|
136
|
+
|
|
137
|
+
logger(`[hi-loop] iteration ${state.iteration}/${maxLoops} — ${state.phase} (session #${state.sessionSerial})`);
|
|
138
|
+
|
|
139
|
+
const prompt = promptFor(state);
|
|
140
|
+
const result = await agentRunner({ prompt, sessionId: state.sessionId, cwd });
|
|
141
|
+
state.sessionId = result?.sessionId ?? state.sessionId ?? null;
|
|
142
|
+
state.costUsd = addCost(state.costUsd, result?.costUsd);
|
|
143
|
+
|
|
144
|
+
if (state.phase === 'PLAN') {
|
|
145
|
+
state.specSummary = summarizeSpec(result?.text) || state.specSummary;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- CHECK: 엔진이 직접 검증 (FR-2.3) ----
|
|
149
|
+
// 2단 판정이다. exit code 는 "테스트가 통과했는가"만 답한다. 그 테스트가
|
|
150
|
+
// **그대로인가**는 별도 문제이고, 그걸 안 물으면 에이전트는 테스트를 지워서 통과한다(L1).
|
|
151
|
+
state.phase = 'CHECK';
|
|
152
|
+
saveState(statePath, state);
|
|
153
|
+
const check = await testRunner({ command: testCommand, cwd });
|
|
154
|
+
const output = combineOutput(check);
|
|
155
|
+
|
|
156
|
+
const fingerprint = integrityChecker({ cwd, testPath: state.testPath });
|
|
157
|
+
const integrity = compareFingerprints(state.testFingerprint, fingerprint);
|
|
158
|
+
// 위반이 없을 때만 기준선을 갱신한다. 갱신해버리면 약화된 상태가 다음 회차의
|
|
159
|
+
// 기준이 되어 에이전트가 그대로 빠져나간다.
|
|
160
|
+
if (integrity.ok) state.testFingerprint = fingerprint;
|
|
161
|
+
|
|
162
|
+
// L8: baseline 에 있던 파일이 사라졌는가 = 에이전트가 기존 파일을 삭제. 차단하지 않고
|
|
163
|
+
// 경고만 한다(체크포인트로 되돌릴 수 있고 판단은 사람 몫). 누적 관측이라 Gaps 에도 실린다.
|
|
164
|
+
if (baselineFiles) {
|
|
165
|
+
const deleted = detectDeletions(baselineFiles, await fileLister({ cwd }));
|
|
166
|
+
const fresh = deleted.filter((f) => !(state.deletedFiles ?? []).includes(f));
|
|
167
|
+
if (fresh.length) {
|
|
168
|
+
state.deletedFiles = [...(state.deletedFiles ?? []), ...fresh];
|
|
169
|
+
logger(`[hi-loop] ⚠️ 지정 경로 밖 파일 ${fresh.length}개가 삭제됐습니다 (차단 안 함 — hi-loop rollback 으로 복원 가능):`);
|
|
170
|
+
for (const f of fresh) logger(`[hi-loop] - ${f}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Tier 1 (기계, 최종 권한): exit code + 무결성.
|
|
175
|
+
let passed = Boolean(check?.ok) && integrity.ok;
|
|
176
|
+
|
|
177
|
+
// Tier 2 (모델, 하향 전용): Tier 1 통과일 때만 호출. 스펙 대비 구현을 심판한다.
|
|
178
|
+
// Tier 1 실패를 통과로 올릴 수는 절대 없다 — reject 만 가능하다(L9, bkit 중심 명제).
|
|
179
|
+
let specReject = '';
|
|
180
|
+
if (passed && verifySpec) {
|
|
181
|
+
const verdict = await specVerifier({ specPath: state.specPath, cwd });
|
|
182
|
+
if (verdict?.verdict === 'reject') {
|
|
183
|
+
passed = false;
|
|
184
|
+
specReject = verdict.reason || '스펙과 어긋남';
|
|
185
|
+
logger(`[hi-loop] 🔬 스펙 검증 기각 — 테스트는 통과했으나 스펙 요구를 못 채웠습니다: ${truncate(specReject, 200)}`);
|
|
186
|
+
} else {
|
|
187
|
+
state.specVerified = true; // Gaps 보고가 "스펙 미검증" 대신 "검증 통과"를 쓰게 한다.
|
|
188
|
+
logger(`[hi-loop] 🔬 스펙 검증 통과.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
state.history.push({
|
|
193
|
+
iteration: state.iteration,
|
|
194
|
+
phase: 'CHECK',
|
|
195
|
+
ok: passed,
|
|
196
|
+
summary: passed
|
|
197
|
+
? 'tests passed + spec verified'
|
|
198
|
+
: truncate(
|
|
199
|
+
specReject
|
|
200
|
+
? `스펙 검증 기각: ${specReject}`
|
|
201
|
+
: integrity.ok
|
|
202
|
+
? output
|
|
203
|
+
: violationMessage(integrity.violations),
|
|
204
|
+
LIMITS.historySummary,
|
|
205
|
+
),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (!integrity.ok) {
|
|
209
|
+
logger(`[hi-loop] 🚨 테스트 무결성 위반 — ${integrity.violations.length}건. 통과로 인정하지 않습니다.`);
|
|
210
|
+
for (const v of integrity.violations) logger(`[hi-loop] - ${v}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (passed) {
|
|
214
|
+
state.status = 'passed';
|
|
215
|
+
state.phase = 'DONE';
|
|
216
|
+
state.lastError = '';
|
|
217
|
+
const saved = saveState(statePath, state);
|
|
218
|
+
logger(`[hi-loop] ✅ ${state.iteration}회차에 통과했습니다. (누적 $${state.costUsd.toFixed(2)})`);
|
|
219
|
+
// passed 야말로 Gaps 를 붙여야 할 자리다 — false green 이 조용히 지나가는 지점이므로.
|
|
220
|
+
const gaps = reportGaps(saved);
|
|
221
|
+
logger(gaps);
|
|
222
|
+
await notify({ type: 'passed', iteration: state.iteration, detail: `누적 비용 $${state.costUsd.toFixed(2)}` });
|
|
223
|
+
return { status: 'passed', iterations: state.iteration, state: saved, report: gaps };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- ACT: 에러를 다음 루프의 연료로 (FR-2.4) ----
|
|
227
|
+
// 무결성 위반은 에러 **뒤**에 붙인다. 절단이 tail-biased 이므로(§5.2) 꼬리가 살아남는다.
|
|
228
|
+
// 앞에 붙이면 여기서든 saveState 에서든 위반 사실이 먼저 잘려나가고, 에이전트는
|
|
229
|
+
// 자기가 뭘 어겼는지 모른 채 같은 짓을 반복한다.
|
|
230
|
+
// 실패의 정체를 셋 중 하나로 조립한다: 스펙 기각 / 무결성 위반 / 테스트 에러.
|
|
231
|
+
// 스펙 기각이면 테스트는 통과했으니 output 대신 스펙 사유가 다음 루프의 연료다.
|
|
232
|
+
let failure;
|
|
233
|
+
if (specReject) {
|
|
234
|
+
failure = `스펙 검증이 이 구현을 기각했다. 테스트는 통과했지만 스펙 요구를 못 채웠다:\n${specReject}\n스펙(${state.specPath})을 다시 읽고 그 요구를 충족하도록 구현을 고쳐라. 테스트만 통과시키는 것으로는 부족하다.`;
|
|
235
|
+
} else {
|
|
236
|
+
const body = output || `테스트가 코드 ${check?.code}로 실패했습니다.`;
|
|
237
|
+
failure = integrity.ok ? body : `${body}\n\n${violationMessage(integrity.violations)}`;
|
|
238
|
+
}
|
|
239
|
+
state.lastError = truncate(failure, LIMITS.lastError);
|
|
240
|
+
state.phase = 'ACT';
|
|
241
|
+
|
|
242
|
+
// ---- 정체 감지 (L2) ----
|
|
243
|
+
// 판정 신호는 스펙 기각이면 기각 사유, 무결성 위반이면 그 위반, 아니면 에러 지문이다.
|
|
244
|
+
// 같은 실패가 stagnationLimit 회 연속이면 이 접근으로는 못 고친다는 뜻 —
|
|
245
|
+
// 같은 값을 태우며 maxLoops 를 다 쓰는 것은 순수한 낭비다(실측: 함정에 $3.07).
|
|
246
|
+
const sig = specReject
|
|
247
|
+
? `SPEC:${errorSignature(specReject)}`
|
|
248
|
+
: integrity.ok
|
|
249
|
+
? errorSignature(output)
|
|
250
|
+
: `INTEGRITY:${integrity.violations.join('|')}`;
|
|
251
|
+
state.stagnantRuns = sig && sig === state.errorSig ? state.stagnantRuns + 1 : 1;
|
|
252
|
+
state.errorSig = sig;
|
|
253
|
+
saveState(statePath, state);
|
|
254
|
+
|
|
255
|
+
if (integrity.ok) {
|
|
256
|
+
logger(`[hi-loop] ❌ 테스트 실패 (code ${check?.code}) — 치유를 시도합니다.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (stagnationLimit != null && state.stagnantRuns >= stagnationLimit && state.iteration < maxLoops) {
|
|
260
|
+
state.status = 'failed';
|
|
261
|
+
state.phase = 'DONE';
|
|
262
|
+
state.stopReason = 'stagnated';
|
|
263
|
+
const saved = saveState(statePath, state);
|
|
264
|
+
logger(`[hi-loop] 🔁 같은 실패가 ${state.stagnantRuns}회 반복 — 접근이 막혔습니다. ${state.iteration}회차에서 중단합니다. (누적 $${state.costUsd.toFixed(2)})`);
|
|
265
|
+
await notify({
|
|
266
|
+
type: 'failed',
|
|
267
|
+
iteration: state.iteration,
|
|
268
|
+
detail: `정체: 같은 실패 ${state.stagnantRuns}회 반복`,
|
|
269
|
+
});
|
|
270
|
+
return { status: 'failed', iterations: state.iteration, state: saved };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- 세션 핸드오프 & 컨텍스트 다이어트 (FR-3.2) ----
|
|
274
|
+
if (state.iteration % handoffEvery === 0 && state.iteration < maxLoops) {
|
|
275
|
+
state.sessionId = null;
|
|
276
|
+
state.sessionSerial += 1;
|
|
277
|
+
saveState(statePath, state);
|
|
278
|
+
logger(`[hi-loop] ♻️ 세션 핸드오프 — 압축 상태만 session #${state.sessionSerial}에 넘깁니다.`);
|
|
279
|
+
await notify({
|
|
280
|
+
type: 'handoff',
|
|
281
|
+
iteration: state.iteration,
|
|
282
|
+
detail: `session #${state.sessionSerial} 로 컨텍스트 다이어트 후 재개`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
state.status = 'failed';
|
|
288
|
+
state.phase = 'DONE';
|
|
289
|
+
state.stopReason = 'maxLoops';
|
|
290
|
+
const saved = saveState(statePath, state);
|
|
291
|
+
logger(`[hi-loop] 한도(${maxLoops}회)를 소진했지만 테스트가 통과하지 못했습니다. (누적 $${state.costUsd.toFixed(2)})`);
|
|
292
|
+
await notify({ type: 'failed', iteration: state.iteration, detail: truncate(state.lastError, 500) });
|
|
293
|
+
return { status: 'failed', iterations: state.iteration, state: saved };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export default runLoop;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP 프로토콜 표준 규격 통신 모듈 (FR-4)
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ stdout 은 MCP 프로토콜 채널이다. 로그는 반드시 stderr 로만 낸다 (FR-4.2).
|
|
5
|
+
*/
|
|
6
|
+
import { runLoop } from './loop.js';
|
|
7
|
+
import { loadState, statePathFor, summarizeState, resetState } from './state.js';
|
|
8
|
+
import { rollbackTo } from './checkpoint.js';
|
|
9
|
+
import { runSetup } from '../bin/setup.js';
|
|
10
|
+
|
|
11
|
+
const log = (line) => process.stderr.write(`[hi-loop-mcp] ${line}\n`);
|
|
12
|
+
|
|
13
|
+
const text = (t) => ({ content: [{ type: 'text', text: t }] });
|
|
14
|
+
const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
|
|
15
|
+
|
|
16
|
+
/** 도구 구현 (SDK 없이도 단위 테스트 가능하도록 분리) */
|
|
17
|
+
export const tools = {
|
|
18
|
+
hiloop_run: {
|
|
19
|
+
description:
|
|
20
|
+
'목표(goal)를 받아 PLAN→DO→CHECK→HEAL 자가 치유 루프를 실행한다. spec/테스트를 먼저 만들고, 테스트가 통과할 때까지 최대 maxLoops회 반복한다.',
|
|
21
|
+
handler: async ({ goal, testCommand = 'npm test', maxLoops = 10, verifySpec = false, cwd = process.cwd() }) => {
|
|
22
|
+
if (!goal) return fail('goal 은 필수입니다.');
|
|
23
|
+
const result = await runLoop({ goal, testCommand, maxLoops, verifySpec, cwd, logger: log });
|
|
24
|
+
const head = result.status === 'passed' ? '✅ 통과' : '❌ 실패';
|
|
25
|
+
// 통과 시 Gaps 를 붙인다 — 에이전트가 도구로 이 결과를 받을 때 false green 을
|
|
26
|
+
// 사실로 착각하지 않도록. summarizeState 는 상태 요약, report 는 검증 한계.
|
|
27
|
+
const gaps = result.report ? `\n\n${result.report}` : '';
|
|
28
|
+
return text(
|
|
29
|
+
`${head} — ${result.iterations}회 반복\n\n${summarizeState(result.state)}${gaps}`,
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
hiloop_status: {
|
|
34
|
+
description: '현재 워크스페이스의 .agent-state.json 루프 상태 요약을 반환한다.',
|
|
35
|
+
handler: async ({ cwd = process.cwd() } = {}) => text(summarizeState(loadState(statePathFor(cwd)))),
|
|
36
|
+
},
|
|
37
|
+
hiloop_reset: {
|
|
38
|
+
description: '.agent-state.json 을 삭제해 루프 상태를 초기화한다.',
|
|
39
|
+
handler: async ({ cwd = process.cwd() } = {}) =>
|
|
40
|
+
text(resetState(statePathFor(cwd)) ? '상태를 초기화했습니다.' : '초기화할 상태가 없습니다.'),
|
|
41
|
+
},
|
|
42
|
+
hiloop_rollback: {
|
|
43
|
+
description:
|
|
44
|
+
'git 체크포인트로 파일을 되돌린다. to(회차)를 주면 그 회차 직전 스냅샷으로, 없으면 가장 최근 체크포인트로 복원한다.',
|
|
45
|
+
handler: async ({ to, cwd = process.cwd() } = {}) => {
|
|
46
|
+
const state = loadState(statePathFor(cwd));
|
|
47
|
+
const checkpoints = state?.checkpoints ?? [];
|
|
48
|
+
if (checkpoints.length === 0)
|
|
49
|
+
return fail('되돌릴 체크포인트가 없습니다 (git 저장소가 아니거나 아직 스냅샷이 없음).');
|
|
50
|
+
const toIter = Number.isInteger(to) ? to : null;
|
|
51
|
+
const cp =
|
|
52
|
+
toIter != null
|
|
53
|
+
? checkpoints.find((c) => c.iteration === toIter)
|
|
54
|
+
: checkpoints[checkpoints.length - 1];
|
|
55
|
+
if (!cp)
|
|
56
|
+
return fail(
|
|
57
|
+
`${toIter}회차 체크포인트가 없습니다. 있는 회차: ${checkpoints.map((c) => c.iteration).join(', ')}`,
|
|
58
|
+
);
|
|
59
|
+
const res = await rollbackTo({ cwd, sha: cp.sha });
|
|
60
|
+
if (!res.ok) return fail(`롤백 실패: ${res.reason}`);
|
|
61
|
+
const safety = res.safetySha ? `\n되돌리기 전 상태는 ${res.safetySha.slice(0, 8)} 에 스냅샷됨.` : '';
|
|
62
|
+
return text(`✅ ${cp.iteration}회차 직전 상태로 파일을 복원했습니다 (${cp.sha.slice(0, 8)}).${safety}`);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
hiloop_setup: {
|
|
66
|
+
description:
|
|
67
|
+
'프로젝트에 hi-loop 설정(.mcp.json MCP 서버 등록, .gitignore 항목, docs/tests 디렉터리)을 자동 주입한다. dryRun 이면 변경 없이 계획만 보여준다.',
|
|
68
|
+
handler: async ({ dryRun = false, cwd = process.cwd() } = {}) => {
|
|
69
|
+
const report = runSetup({ cwd, dryRun: Boolean(dryRun) });
|
|
70
|
+
return text(report.lines.join('\n'));
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** 예외를 isError 응답으로 감싼다 (FR-4.4) */
|
|
76
|
+
export function wrap(handler) {
|
|
77
|
+
return async (args) => {
|
|
78
|
+
try {
|
|
79
|
+
return await handler(args ?? {});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return fail(`hi-loop 오류: ${err?.message ?? String(err)}`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function startMcpServer({ version = '0.1.0' } = {}) {
|
|
87
|
+
let McpServer;
|
|
88
|
+
let StdioServerTransport;
|
|
89
|
+
let z;
|
|
90
|
+
try {
|
|
91
|
+
({ McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js'));
|
|
92
|
+
({ StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'));
|
|
93
|
+
({ z } = await import('zod'));
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// FR-4.5: SDK 없이도 CLI 모드는 살아 있어야 하므로 여기서만 죽는다.
|
|
96
|
+
process.stderr.write(
|
|
97
|
+
`MCP 모드에는 @modelcontextprotocol/sdk 가 필요합니다. \`npm install\` 후 다시 시도하세요.\n${err?.message}\n`,
|
|
98
|
+
);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const server = new McpServer({ name: 'hi-loop', version });
|
|
103
|
+
const cwdSchema = z.string().optional().describe('작업 디렉터리 (기본: 서버 프로세스의 cwd)');
|
|
104
|
+
|
|
105
|
+
server.registerTool(
|
|
106
|
+
'hiloop_run',
|
|
107
|
+
{
|
|
108
|
+
description: tools.hiloop_run.description,
|
|
109
|
+
inputSchema: {
|
|
110
|
+
goal: z.string().describe('달성할 요구사항'),
|
|
111
|
+
testCommand: z.string().optional().describe('검증 명령 (기본 "npm test")'),
|
|
112
|
+
maxLoops: z.number().int().min(1).max(50).optional().describe('최대 루프 횟수 (기본 10)'),
|
|
113
|
+
verifySpec: z.boolean().optional().describe('통과 시 별도 검증자로 스펙 대비 구현을 심판 (하향 전용, 비용 증가)'),
|
|
114
|
+
cwd: cwdSchema,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
wrap(tools.hiloop_run.handler),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
server.registerTool(
|
|
121
|
+
'hiloop_status',
|
|
122
|
+
{ description: tools.hiloop_status.description, inputSchema: { cwd: cwdSchema } },
|
|
123
|
+
wrap(tools.hiloop_status.handler),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
server.registerTool(
|
|
127
|
+
'hiloop_reset',
|
|
128
|
+
{ description: tools.hiloop_reset.description, inputSchema: { cwd: cwdSchema } },
|
|
129
|
+
wrap(tools.hiloop_reset.handler),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
server.registerTool(
|
|
133
|
+
'hiloop_rollback',
|
|
134
|
+
{
|
|
135
|
+
description: tools.hiloop_rollback.description,
|
|
136
|
+
inputSchema: {
|
|
137
|
+
to: z.number().int().min(1).optional().describe('되돌릴 회차 (미지정 시 가장 최근 체크포인트)'),
|
|
138
|
+
cwd: cwdSchema,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
wrap(tools.hiloop_rollback.handler),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
server.registerTool(
|
|
145
|
+
'hiloop_setup',
|
|
146
|
+
{
|
|
147
|
+
description: tools.hiloop_setup.description,
|
|
148
|
+
inputSchema: {
|
|
149
|
+
dryRun: z.boolean().optional().describe('변경 없이 계획만 출력'),
|
|
150
|
+
cwd: cwdSchema,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
wrap(tools.hiloop_setup.handler),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await server.connect(new StdioServerTransport());
|
|
157
|
+
log('stdio 전송으로 MCP 서버가 기동했습니다.');
|
|
158
|
+
return server;
|
|
159
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDCA 각 단계의 프롬프트 빌더 (FR-2.1 ~ FR-2.4, FR-3.3)
|
|
3
|
+
* 전체 대화 로그는 절대 넣지 않는다. 압축본만 넣는다.
|
|
4
|
+
*/
|
|
5
|
+
import { truncate, LIMITS, DEFAULT_SPEC_PATH, DEFAULT_TEST_PATH } from './state.js';
|
|
6
|
+
|
|
7
|
+
const RULES = `## 불변 규칙
|
|
8
|
+
- 작업 디렉터리 바깥을 건드리지 마라.
|
|
9
|
+
- 지정된 산출물 경로 외의 기존 파일을 덮어쓰거나 지우지 마라.
|
|
10
|
+
- 테스트를 통과시키려고 테스트 자체를 무력화(skip/삭제/always-true)하지 마라.
|
|
11
|
+
- 완료 보고 대신 실제 파일을 남겨라. 검증은 엔진이 직접 한다.`;
|
|
12
|
+
|
|
13
|
+
/** 경로는 엔진이 정한다(state.planPathsFor). 프롬프트는 받아 쓰기만 한다. */
|
|
14
|
+
const specPathOf = (state) => state.specPath || DEFAULT_SPEC_PATH;
|
|
15
|
+
const testPathOf = (state) => state.testPath || DEFAULT_TEST_PATH;
|
|
16
|
+
|
|
17
|
+
/** 핸드오프 이후 새 세션에 넘길 압축 컨텍스트 (FR-3.3) */
|
|
18
|
+
export function contextBlock(state) {
|
|
19
|
+
const parts = [`## 목표(goal)\n${state.goal}`];
|
|
20
|
+
if (state.specSummary) {
|
|
21
|
+
parts.push(`## 지금까지의 스펙 요약\n${truncate(state.specSummary, LIMITS.specSummary)}`);
|
|
22
|
+
}
|
|
23
|
+
if (state.history?.length) {
|
|
24
|
+
const recent = state.history
|
|
25
|
+
.slice(-LIMITS.historyKeep)
|
|
26
|
+
.map((h) => `- #${h.iteration} ${h.phase}: ${h.ok ? 'PASS' : 'FAIL'} — ${truncate(h.summary ?? '', 160)}`)
|
|
27
|
+
.join('\n');
|
|
28
|
+
parts.push(`## 최근 이력\n${recent}`);
|
|
29
|
+
}
|
|
30
|
+
if (state.lastError) {
|
|
31
|
+
parts.push(`## 마지막 빌드/테스트 에러\n\`\`\`\n${truncate(state.lastError, LIMITS.lastError)}\n\`\`\``);
|
|
32
|
+
}
|
|
33
|
+
parts.push(`## 현재 위치\niteration ${state.iteration}/${state.maxLoops}, session #${state.sessionSerial}`);
|
|
34
|
+
return parts.join('\n\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PLAN: 코드보다 spec.md + 테스트 먼저 (FR-2.1) */
|
|
38
|
+
export function planPrompt(state) {
|
|
39
|
+
return `당신은 TDD를 강제하는 수석 아키텍트다. 지금은 PLAN 단계다.
|
|
40
|
+
|
|
41
|
+
${contextBlock(state)}
|
|
42
|
+
|
|
43
|
+
## PLAN 단계에서 할 일 (이것만)
|
|
44
|
+
1. \`${specPathOf(state)}\` 를 작성하라: 목표 해석, 상세 요구사항, 공개 API 시그니처, 수용 기준 목록.
|
|
45
|
+
2. \`${testPathOf(state)}\` 를 작성하라: 위 수용 기준을 검증하는 실행 가능한 테스트.
|
|
46
|
+
- Node 내장 \`node:test\` + \`node:assert/strict\` 를 사용하라.
|
|
47
|
+
- 아직 존재하지 않는 구현을 import 해도 된다(이 단계에서 테스트는 실패하는 게 정상이다).
|
|
48
|
+
3. **구현 코드는 절대 작성하지 마라.** 이번 단계 산출물은 spec과 테스트뿐이다.
|
|
49
|
+
4. 위 두 경로에만 써라. 다른 기존 파일은 건드리지 마라.
|
|
50
|
+
|
|
51
|
+
작업을 마치면 스펙 핵심을 15줄 이내로 요약해 마지막에 출력하라.
|
|
52
|
+
|
|
53
|
+
${RULES}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** DO: 테스트를 통과시킬 최소 구현 (FR-2.2) */
|
|
57
|
+
export function doPrompt(state) {
|
|
58
|
+
return `지금은 DO 단계다. 검증 명령: \`${state.testCommand}\`
|
|
59
|
+
|
|
60
|
+
${contextBlock(state)}
|
|
61
|
+
|
|
62
|
+
## DO 단계에서 할 일
|
|
63
|
+
- \`${specPathOf(state)}\` 와 \`${testPathOf(state)}\` 를 만족시키는 **최소한의 실제 구현**을 작성하라.
|
|
64
|
+
- 요구되지 않은 기능/추상화/최적화를 넣지 마라.
|
|
65
|
+
- 테스트를 고치지 말고 구현으로 통과시켜라.
|
|
66
|
+
|
|
67
|
+
${RULES}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** ACT/HEAL: 에러 로그를 들이밀고 고치게 한다 (FR-2.4) */
|
|
71
|
+
export function healPrompt(state) {
|
|
72
|
+
return `지금은 HEAL 단계다. \`${state.testCommand}\` 가 실패했다. 이 에러를 고쳐라.
|
|
73
|
+
|
|
74
|
+
${contextBlock(state)}
|
|
75
|
+
|
|
76
|
+
## HEAL 단계에서 할 일
|
|
77
|
+
- 위 "마지막 빌드/테스트 에러"의 **근본 원인**을 짚고 구현을 수정하라.
|
|
78
|
+
- 테스트를 삭제/skip/무력화해서 통과시키는 것은 실패로 간주한다.
|
|
79
|
+
- 같은 수정을 반복하고 있다면 접근 자체를 바꿔라(이미 ${state.iteration - 1}회 시도했다).${
|
|
80
|
+
state.stagnantRuns >= 2
|
|
81
|
+
? `\n- ⚠️ **같은 실패가 ${state.stagnantRuns}회 연속이다.** 지금 방식은 막혔다. 미봉책을 반복하지 말고,
|
|
82
|
+
이 실패가 애초에 **해결 불가능한 요구(모순된 테스트, 잘못된 환경)** 는 아닌지 먼저 판단하라.
|
|
83
|
+
그렇다면 왜 불가능한지 근거를 명확히 남겨라 — 규칙을 어겨 우회하지 마라.`
|
|
84
|
+
: ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
${RULES}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function promptFor(state) {
|
|
91
|
+
if (state.phase === 'PLAN') return planPrompt(state);
|
|
92
|
+
if (state.phase === 'DO') return doPrompt(state);
|
|
93
|
+
return healPrompt(state);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Tier 2 검증자 프롬프트 (P6, L9). 스펙 대비 구현을 심판한다.
|
|
98
|
+
*
|
|
99
|
+
* 이 프롬프트를 받는 에이전트는 **쓰기 권한이 없고**(--permission-mode plan) **새 세션**이다
|
|
100
|
+
* (구현자 컨텍스트를 상속하지 않는다 — bkit 의 context:fork). 오직 기각만 할 수 있다.
|
|
101
|
+
*
|
|
102
|
+
* bkit 의 가중치 교훈: 구조 일치("파일이 있는가")는 덜 중요하고, **의도 일치**("코드 로직이
|
|
103
|
+
* 스펙이 요구한 동작을 실제로 달성하는가")가 핵심이다. 키워드 grep 이 아니라 로직을 읽어라.
|
|
104
|
+
*/
|
|
105
|
+
export function verifyPrompt({ specText, diffText }) {
|
|
106
|
+
return `당신은 스펙 대비 구현을 심판하는 독립 검증자다. 코드를 고치지 마라 — 판정만 한다.
|
|
107
|
+
|
|
108
|
+
## 스펙 (docs/spec)
|
|
109
|
+
${truncate(String(specText ?? '(스펙 없음)'), 6000)}
|
|
110
|
+
|
|
111
|
+
## 이번 루프의 구현 변경 (git diff)
|
|
112
|
+
\`\`\`diff
|
|
113
|
+
${truncate(String(diffText ?? '(변경 없음)'), 8000)}
|
|
114
|
+
\`\`\`
|
|
115
|
+
|
|
116
|
+
## 판정 기준
|
|
117
|
+
- **구조가 아니라 의도를 봐라.** "함수가 있는가"가 아니라 "그 함수가 스펙이 요구한 동작을
|
|
118
|
+
실제로 하는가"다. 코드 로직을 읽고 추론하라. 키워드 매칭으로 통과시키지 마라.
|
|
119
|
+
- 테스트는 이미 통과했다(그건 엔진이 확인했다). 당신이 볼 것은 **테스트가 놓쳤을 수 있는
|
|
120
|
+
스펙 요구사항** — 커버되지 않은 수용 기준, 스펙과 어긋난 동작, 명백히 빠진 것.
|
|
121
|
+
- 확신이 없으면 통과시켜라. 당신은 하향 전용이다: 명백한 어긋남만 기각하라.
|
|
122
|
+
|
|
123
|
+
## 출력 (반드시 이 JSON 한 줄로만)
|
|
124
|
+
{"verdict": "pass" 또는 "reject", "reason": "한 문장 근거"}`;
|
|
125
|
+
}
|