@tuzi-ince/hi-loop 0.1.2 → 0.2.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 +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/state.js
CHANGED
|
@@ -7,18 +7,34 @@ import { readFileSync, writeFileSync, renameSync, existsSync, rmSync } from 'nod
|
|
|
7
7
|
import { join, dirname } from 'node:path';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// 사람이 읽는 보고(Gaps 공개·상태 요약)는 report.js 로 뺐다. 기존 import 경로를
|
|
11
|
+
// 깨지 않도록 여기서 다시 내보낸다 — 호출부가 어느 파일에서 오는지 알 필요는 없다.
|
|
12
|
+
export { reportGaps, summarizeState } from './report.js';
|
|
13
|
+
|
|
14
|
+
export const STATE_VERSION = 2;
|
|
11
15
|
export const STATE_FILENAME = '.agent-state.json';
|
|
12
16
|
|
|
13
17
|
export const DEFAULT_SPEC_PATH = 'docs/spec.md';
|
|
14
18
|
export const DEFAULT_TEST_PATH = 'tests/app.test.js';
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 라이프사이클 위치 (FR-10~14). `phase` 와 다르다:
|
|
22
|
+
* - `phase` 는 **안쪽 루프**의 회차별 위치 (PLAN/DO/CHECK/ACT/DONE) — 의미 불변.
|
|
23
|
+
* - `stage` 는 **바깥 껍질**의 위치. BUILD 가 안쪽 루프 전체를 감싼다.
|
|
24
|
+
*/
|
|
25
|
+
export const STAGES = ['DISCOVER', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH', 'DONE'];
|
|
26
|
+
|
|
16
27
|
export const LIMITS = {
|
|
17
28
|
specSummary: 1200,
|
|
18
29
|
lastError: 2000,
|
|
19
30
|
historyKeep: 5,
|
|
20
31
|
historySummary: 300,
|
|
21
32
|
checkpointKeep: 10, // 최근 10개 체크포인트만 유지 (L3)
|
|
33
|
+
assumptionsKeep: 20,
|
|
34
|
+
assumptionText: 300,
|
|
35
|
+
findingsKeep: 10,
|
|
36
|
+
findingText: 300,
|
|
37
|
+
answersKeep: 10,
|
|
22
38
|
};
|
|
23
39
|
|
|
24
40
|
export function truncate(text, max) {
|
|
@@ -75,6 +91,14 @@ export function planPathsFor({ cwd, goal }) {
|
|
|
75
91
|
};
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
/**
|
|
95
|
+
* DISCOVER 산출물 경로 (FR-10.1). 항상 goal 해시를 붙인다 — 기본 경로를 쓰면
|
|
96
|
+
* 남의 파일을 덮어쓸 수 있고, 같은 프로젝트에서 goal 만 바꿔 두 번 돌릴 때 서로를 파괴한다.
|
|
97
|
+
*/
|
|
98
|
+
export function discoveryPathFor(goal) {
|
|
99
|
+
return `docs/discovery-${createHash('sha1').update(String(goal)).digest('hex').slice(0, 8)}.md`;
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
export function createState({
|
|
79
103
|
goal,
|
|
80
104
|
testCommand,
|
|
@@ -90,6 +114,7 @@ export function createState({
|
|
|
90
114
|
goal,
|
|
91
115
|
testCommand,
|
|
92
116
|
status: 'running',
|
|
117
|
+
stage: 'DISCOVER', // 라이프사이클 시작점 (FR-10). --no-discover 면 엔진이 BUILD 로 건너뛴다.
|
|
93
118
|
phase: 'PLAN',
|
|
94
119
|
iteration: 0,
|
|
95
120
|
maxLoops,
|
|
@@ -105,12 +130,58 @@ export function createState({
|
|
|
105
130
|
stagnantRuns: 0, // L2: errorSig 가 연속 동일한 횟수.
|
|
106
131
|
checkpoints: [], // L3: [{iteration, sha}] git stash create 스냅샷.
|
|
107
132
|
deletedFiles: [], // L8: 에이전트가 지운 baseline 파일(관측용, 누적).
|
|
133
|
+
assumptions: [], // FR-10.2: DISCOVER/ask 가 확정한 가정. 기록 안 된 가정은 없어야 한다.
|
|
134
|
+
ask: null, // FR-10.3/13.4: 대기 중인 질문. 있으면 status 는 'awaiting'.
|
|
135
|
+
answers: [], // 사람이 고른 이력 [{id, choice, note, at}].
|
|
136
|
+
designRounds: 0, // FR-11.3 상한 카운터.
|
|
137
|
+
reviewRounds: 0, // FR-12.4 상한 카운터.
|
|
138
|
+
reviewFindings: [], // FR-12.4: 미해결 findings. Gaps 에 공개된다.
|
|
139
|
+
discovery: null, // FR-10: {doc, at} — 있으면 발굴이 끝났다는 표시(재개 시 재실행 방지).
|
|
140
|
+
ship: null, // FR-13: {command, code, at}
|
|
141
|
+
watch: null, // FR-14: {command, checks, failures, at}
|
|
108
142
|
history: [],
|
|
109
143
|
startedAt: now,
|
|
110
144
|
updatedAt: now,
|
|
111
145
|
};
|
|
112
146
|
}
|
|
113
147
|
|
|
148
|
+
/** v1 상태의 신규 필드 기본값. 한 곳에만 두어 createState/migrate/resume 이 갈라지지 않게 한다. */
|
|
149
|
+
const V2_DEFAULTS = {
|
|
150
|
+
stage: 'BUILD',
|
|
151
|
+
assumptions: [],
|
|
152
|
+
ask: null,
|
|
153
|
+
answers: [],
|
|
154
|
+
designRounds: 0,
|
|
155
|
+
reviewRounds: 0,
|
|
156
|
+
reviewFindings: [],
|
|
157
|
+
discovery: null,
|
|
158
|
+
ship: null,
|
|
159
|
+
watch: null,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* v1 → v2 승격 (제안서 §5).
|
|
164
|
+
*
|
|
165
|
+
* 그냥 STATE_VERSION 만 올리면 `loadState` 가 버전 불일치로 **null 을 반환**해서
|
|
166
|
+
* 진행 중이던 v1 루프가 조용히 버려진다. 사용자에겐 "업데이트했더니 작업이 사라졌다"다.
|
|
167
|
+
* 그래서 버리지 않고 신규 필드만 채워 올린다.
|
|
168
|
+
*
|
|
169
|
+
* stage 를 BUILD 로 두는 이유: v1 상태는 이미 PLAN(1회차)을 지났다. DISCOVER 로 되돌리면
|
|
170
|
+
* 이미 만든 스펙을 두고 발굴을 다시 하는 낭비가 된다. 통과한 상태는 DONE 이다.
|
|
171
|
+
* 미래 버전(>2)은 읽지 않는다 — 구버전이 신버전 상태를 해석하면 조용히 망가뜨린다.
|
|
172
|
+
*/
|
|
173
|
+
export function migrateState(prev) {
|
|
174
|
+
if (!prev || typeof prev !== 'object') return null;
|
|
175
|
+
if (prev.version === STATE_VERSION) return prev;
|
|
176
|
+
if (prev.version !== 1) return null;
|
|
177
|
+
return {
|
|
178
|
+
...V2_DEFAULTS,
|
|
179
|
+
...prev,
|
|
180
|
+
version: STATE_VERSION,
|
|
181
|
+
stage: prev.status === 'passed' ? 'DONE' : 'BUILD',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
114
185
|
/**
|
|
115
186
|
* 동일 goal 이면 auto-resume, 아니면 새 상태 (FR-3.5).
|
|
116
187
|
* resume 은 상태 로직이므로 state.js 가 제자리다 — loadState/createState/planPathsFor 를 여기서 쓴다.
|
|
@@ -134,9 +205,21 @@ export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd })
|
|
|
134
205
|
stagnantRuns: Number.isFinite(prev.stagnantRuns) ? prev.stagnantRuns : 0,
|
|
135
206
|
checkpoints: Array.isArray(prev.checkpoints) ? prev.checkpoints : [],
|
|
136
207
|
deletedFiles: Array.isArray(prev.deletedFiles) ? prev.deletedFiles : [],
|
|
208
|
+
stage: STAGES.includes(prev.stage) ? prev.stage : V2_DEFAULTS.stage,
|
|
209
|
+
assumptions: Array.isArray(prev.assumptions) ? prev.assumptions : [],
|
|
210
|
+
ask: prev.ask ?? null,
|
|
211
|
+
answers: Array.isArray(prev.answers) ? prev.answers : [],
|
|
212
|
+
designRounds: Number.isFinite(prev.designRounds) ? prev.designRounds : 0,
|
|
213
|
+
reviewRounds: Number.isFinite(prev.reviewRounds) ? prev.reviewRounds : 0,
|
|
214
|
+
reviewFindings: Array.isArray(prev.reviewFindings) ? prev.reviewFindings : [],
|
|
215
|
+
discovery: prev.discovery ?? null,
|
|
216
|
+
ship: prev.ship ?? null,
|
|
217
|
+
watch: prev.watch ?? null,
|
|
137
218
|
testCommand,
|
|
138
219
|
maxLoops,
|
|
139
|
-
|
|
220
|
+
// 답을 안 받은 질문이 남아 있으면 재개해선 안 된다. 그냥 running 으로 덮으면
|
|
221
|
+
// 사람이 고르라던 분기를 엔진이 조용히 스스로 골라 지나간다 — ask 의 존재 이유가 사라진다.
|
|
222
|
+
status: prev.ask ? 'awaiting' : 'running',
|
|
140
223
|
};
|
|
141
224
|
}
|
|
142
225
|
return createState({ goal, testCommand, maxLoops, cwd });
|
|
@@ -146,8 +229,9 @@ export function loadState(path) {
|
|
|
146
229
|
if (!existsSync(path)) return null;
|
|
147
230
|
try {
|
|
148
231
|
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
149
|
-
if (!parsed
|
|
150
|
-
|
|
232
|
+
if (!parsed) return null;
|
|
233
|
+
// 구버전은 버리지 않고 승격한다. 못 읽는 버전만 null 이다.
|
|
234
|
+
return parsed.version === STATE_VERSION ? parsed : migrateState(parsed);
|
|
151
235
|
} catch {
|
|
152
236
|
return null; // 손상된 상태 파일은 무시하고 새로 시작한다.
|
|
153
237
|
}
|
|
@@ -163,6 +247,16 @@ export function saveState(path, state, { now = new Date().toISOString() } = {})
|
|
|
163
247
|
...h,
|
|
164
248
|
summary: truncate(h.summary ?? '', LIMITS.historySummary),
|
|
165
249
|
})),
|
|
250
|
+
// 가정·findings 도 컨텍스트 다이어트 대상이다. 이것들은 프롬프트에 실려 나가므로
|
|
251
|
+
// 자르지 않으면 핸드오프가 압축한 만큼을 그대로 되돌려놓는다.
|
|
252
|
+
assumptions: (state.assumptions ?? []).slice(-LIMITS.assumptionsKeep).map((a) => ({
|
|
253
|
+
...a,
|
|
254
|
+
text: truncate(a.text ?? '', LIMITS.assumptionText),
|
|
255
|
+
})),
|
|
256
|
+
reviewFindings: (state.reviewFindings ?? []).slice(-LIMITS.findingsKeep).map((f) =>
|
|
257
|
+
typeof f === 'string' ? truncate(f, LIMITS.findingText) : { ...f, text: truncate(f.text ?? '', LIMITS.findingText) },
|
|
258
|
+
),
|
|
259
|
+
answers: (state.answers ?? []).slice(-LIMITS.answersKeep),
|
|
166
260
|
updatedAt: now,
|
|
167
261
|
};
|
|
168
262
|
const tmp = join(dirname(path), `.${STATE_FILENAME}.${process.pid}.tmp`);
|
|
@@ -178,53 +272,3 @@ export function resetState(path) {
|
|
|
178
272
|
}
|
|
179
273
|
return false;
|
|
180
274
|
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* 완료 보고: 무엇을 관측했고 무엇을 안 했는가 (L10, moai 의 5-섹션 규약 중 Evidence+Gaps).
|
|
184
|
-
*
|
|
185
|
-
* 이 엔진의 `passed` 는 조용한 거짓말이 될 수 있다 — 테스트를 PLAN 단계에서 에이전트
|
|
186
|
-
* 자신이 썼기 때문이다. exit 0 의 실제 의미는 "에이전트가 자기가 쓴 테스트를 자기가
|
|
187
|
-
* 통과시켰다"이고, 스펙의 수용 기준 중 무엇이 테스트로 커버 안 됐는지는 아무도 안 본다.
|
|
188
|
-
*
|
|
189
|
-
* moai: "빈 Gaps 섹션은 '관측하지 않은 것이 없다'는 강한 주장이며, 그 주장 자체가
|
|
190
|
-
* 참이어야 한다." 그래서 숨기는 대신 매 완료에 공개한다. false green 을 disclosed green 으로.
|
|
191
|
-
*/
|
|
192
|
-
export function reportGaps(state) {
|
|
193
|
-
const lines = ['', '## 검증 현황 (hi-loop 는 자기 판정의 한계를 숨기지 않는다)'];
|
|
194
|
-
|
|
195
|
-
const deleted = state.deletedFiles ?? [];
|
|
196
|
-
|
|
197
|
-
lines.push('관측한 것(Evidence):');
|
|
198
|
-
lines.push(`- ${state.testCommand} 종료 코드로 판정 (iteration ${state.iteration})`);
|
|
199
|
-
lines.push('- 테스트 파일 무결성 위반 없음 (직전 회차 대비 지문 비교)');
|
|
200
|
-
lines.push(`- 누적 비용 $${(state.costUsd ?? 0).toFixed(2)}`);
|
|
201
|
-
if (state.specVerified) lines.push('- 스펙 대비 구현을 별도 검증자가 심판(2단 판정 통과)');
|
|
202
|
-
|
|
203
|
-
lines.push('관측하지 않은 것(Gaps):');
|
|
204
|
-
lines.push(`- 이 테스트는 PLAN 단계에서 에이전트 자신이 \`${state.testPath || 'tests/app.test.js'}\`에 작성했다. 외부 오라클이 아니다.`);
|
|
205
|
-
if (!state.specVerified) {
|
|
206
|
-
lines.push(`- \`${state.specPath || 'docs/spec.md'}\`의 수용 기준 중 무엇이 테스트로 커버되지 않았는지는 검증하지 않았다(--verify-spec 로 켤 수 있다).`);
|
|
207
|
-
}
|
|
208
|
-
lines.push('- 런타임 동작·성능·보안은 관측 범위 밖이다.');
|
|
209
|
-
if (deleted.length) {
|
|
210
|
-
lines.push(`- ⚠️ 에이전트가 baseline 파일 ${deleted.length}개를 삭제했다(차단 안 함): ${deleted.slice(0, 5).join(', ')}${deleted.length > 5 ? ' …' : ''}. hi-loop rollback 으로 복원 가능.`);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return lines.join('\n');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function summarizeState(state) {
|
|
217
|
-
if (!state) return '진행 중인 hi-loop 루프 상태가 없습니다.';
|
|
218
|
-
const lines = [
|
|
219
|
-
`goal: ${state.goal}`,
|
|
220
|
-
`status: ${state.status}${state.stopReason ? ` (${state.stopReason})` : ''} / phase: ${state.phase}`,
|
|
221
|
-
`iteration: ${state.iteration}/${state.maxLoops} (session #${state.sessionSerial})`,
|
|
222
|
-
`cost: $${(state.costUsd ?? 0).toFixed(2)}`,
|
|
223
|
-
...(state.stagnantRuns > 1 ? [`stagnant: 같은 실패 ${state.stagnantRuns}회 연속`] : []),
|
|
224
|
-
`testCommand: ${state.testCommand}`,
|
|
225
|
-
`updatedAt: ${state.updatedAt}`,
|
|
226
|
-
];
|
|
227
|
-
if (state.specSummary) lines.push(`spec: ${truncate(state.specSummary, 200)}`);
|
|
228
|
-
if (state.lastError) lines.push(`lastError: ${truncate(state.lastError, 400)}`);
|
|
229
|
-
return lines.join('\n');
|
|
230
|
-
}
|
package/src/telegram.js
CHANGED
|
@@ -40,7 +40,7 @@ export function truncateForTelegram(text) {
|
|
|
40
40
|
return `${s.slice(0, TELEGRAM_MAX - 1)}…`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const ICON = { start: '🚀', handoff: '♻️', passed: '✅', failed: '❌', check: '🔍' };
|
|
43
|
+
const ICON = { start: '🚀', handoff: '♻️', passed: '✅', failed: '❌', check: '🔍', ship: '📦', watch: '🩺' };
|
|
44
44
|
|
|
45
45
|
/** 루프 이벤트를 사람이 읽을 문장으로 바꿔 전송한다 (FR-5.5) */
|
|
46
46
|
export function makeNotifier(options = {}) {
|
package/src/verify.js
CHANGED
|
@@ -24,7 +24,8 @@ import { join } from 'node:path';
|
|
|
24
24
|
import { buildAgentArgs, parseAgentOutput } from './runners.js';
|
|
25
25
|
import { verifyPrompt } from './prompts.js';
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/** 워킹트리 전체 diff. 검증자·리뷰어가 공유하는 "이번에 무엇을 했는가"의 원본. */
|
|
28
|
+
export function gitDiff(cwd) {
|
|
28
29
|
return new Promise((resolve) => {
|
|
29
30
|
// 워킹트리 전체 diff (추적 파일). 스펙·테스트 대비 구현이 무엇을 했는지.
|
|
30
31
|
execFile('git', ['diff', 'HEAD'], { cwd, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|