@tuzi-ince/hi-loop 0.2.1 → 0.3.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/src/build.js CHANGED
@@ -13,10 +13,15 @@
13
13
  * { ok: false, stopReason, detail } 예산·정체·한도 소진
14
14
  * { stopped: true } --stop-after PLAN 으로 멈춤
15
15
  */
16
- import { saveState, truncate, errorSignature, LIMITS } from './state.js';
16
+ import { saveState, truncate, LIMITS } from './state.js';
17
17
  import { promptFor } from './prompts.js';
18
- import { compareFingerprints, violationMessage } from './integrity.js';
19
- import { detectDeletions } from './checkpoint.js';
18
+ import { violationMessage } from './integrity.js';
19
+ import { isNoOp } from './treekey.js';
20
+ import { isImproved, isSameMetrics } from './metrics.js';
21
+ import { composeFailure, stagnationSignature } from './verdict.js';
22
+ import { runCheck } from './check.js';
23
+ import { isGitRepo } from './checkpoint.js';
24
+ import { failingSet } from './checks.js';
20
25
 
21
26
  /** iteration -> PDCA phase (1회차 PLAN, 2회차 DO, 이후 전부 HEAL) */
22
27
  export function phaseForIteration(iteration) {
@@ -42,12 +47,18 @@ function addCost(prev, delta) {
42
47
  export async function runBuildLoop(ctx) {
43
48
  const { goal, cwd, logger, notify, statePath, baselineFiles, designReviewEnabled, ports, opts } = ctx;
44
49
  const { maxLoops, budgetUsd, testCommand, stagnationLimit, verifySpec, handoffEvery, maxDesignRounds, stopAfter } = opts;
45
- const { agentRunner, testRunner, integrityChecker, checkpointer, fileLister, specVerifier, designReviewer } = ports;
50
+ const { agentRunner, testRunner, integrityChecker, checkpointer, fileLister, specVerifier, designReviewer, treeKeyReader } = ports;
46
51
  // state 는 이 루프에서 속성만 바뀌고 재대입되지 않는다 — 참조를 그대로 잡아도 안전하다.
47
52
  const state = ctx.state;
48
53
 
49
54
  state.stage = 'BUILD';
50
55
  state.status = 'running';
56
+ // L21 의 기준점: **루프가 시작될 때** git 저장소였는가. 주입 가능한 포트가 아니라
57
+ // 직접 관측한다 — 이 값이 스텁에서 오면 "복구 경로가 사라졌다"는 판정이 테스트 설정에
58
+ // 좌우된다. 한 번만 재고 상태에 박아둔다(재개해도 기준이 흔들리지 않게).
59
+ if (state.wasGitRepo === undefined || state.wasGitRepo === null) {
60
+ state.wasGitRepo = await isGitRepo(cwd);
61
+ }
51
62
  saveState(statePath, state);
52
63
 
53
64
  while (state.iteration < maxLoops) {
@@ -75,11 +86,21 @@ export async function runBuildLoop(ctx) {
75
86
 
76
87
  logger(`[hi-loop] iteration ${state.iteration}/${maxLoops} — ${state.phase} (session #${state.sessionSerial})`);
77
88
 
89
+ // L12: 에이전트 호출 전후의 트리 키. 같으면 이번 회차는 **증명된 무변경**이다.
90
+ const keyBefore = await treeKeyReader({ cwd });
91
+
78
92
  const prompt = promptFor(state);
79
93
  const result = await agentRunner({ prompt, sessionId: state.sessionId, cwd });
80
94
  state.sessionId = result?.sessionId ?? state.sessionId ?? null;
81
95
  state.costUsd = addCost(state.costUsd, result?.costUsd);
82
96
 
97
+ const keyAfter = await treeKeyReader({ cwd });
98
+ state.treeKey = keyAfter ?? state.treeKey ?? null;
99
+ const noop = isNoOp(keyBefore, keyAfter);
100
+ if (noop) {
101
+ logger('[hi-loop] 🫥 에이전트가 파일을 하나도 바꾸지 않았습니다 — 이 회차는 결과가 같습니다.');
102
+ }
103
+
83
104
  if (state.phase === 'PLAN') {
84
105
  state.specSummary = summarizeSpec(result?.text) || state.specSummary;
85
106
 
@@ -119,50 +140,9 @@ export async function runBuildLoop(ctx) {
119
140
  }
120
141
  }
121
142
 
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
-
143
+ const { passed, integrity, metrics, regressed, specReject, output, fuel, check, suite, recovery } = await runCheck({
144
+ state, cwd, statePath, logger, ports, opts, baselineFiles,
145
+ });
166
146
  state.history.push({
167
147
  iteration: state.iteration,
168
148
  phase: 'CHECK',
@@ -184,7 +164,23 @@ export async function runBuildLoop(ctx) {
184
164
  for (const v of integrity.violations) logger(`[hi-loop] - ${v}`);
185
165
  }
186
166
 
167
+ if (regressed.length) {
168
+ logger(
169
+ `[hi-loop] 📉 비회귀 위반 — 통과 수가 ${state.metricBaseline.passed}개 → ${metrics.passed}개로 줄었습니다. 초록불이지만 통과로 인정하지 않습니다.`,
170
+ );
171
+ }
172
+
173
+ // 복구 경로가 완전히 끊겼으면 더 돌리지 않는다. 되돌릴 수 없는 상태에서 에이전트를
174
+ // 또 부르는 것은 손해를 키우는 것뿐이다.
175
+ if (!recovery.ok) {
176
+ saveState(statePath, state);
177
+ return { ok: false, stopReason: 'recovery-lost', detail: recovery.reason };
178
+ }
179
+
187
180
  if (passed) {
181
+ // L15: 게이트를 통과한 **그 트리**를 박아둔다. 이 값과 최종 판정 시점의 트리가
182
+ // 다르면 passed 를 주장하지 않는다(BUILD 를 건너뛴 경로 차단).
183
+ state.passedTreeKey = (await treeKeyReader({ cwd })) ?? null;
188
184
  state.lastError = '';
189
185
  saveState(statePath, state);
190
186
  logger(`[hi-loop] ✅ ${state.iteration}회차에 통과했습니다. (누적 $${state.costUsd.toFixed(2)})`);
@@ -197,27 +193,43 @@ export async function runBuildLoop(ctx) {
197
193
  // 자기가 뭘 어겼는지 모른 채 같은 짓을 반복한다.
198
194
  // 실패의 정체를 셋 중 하나로 조립한다: 스펙 기각 / 무결성 위반 / 테스트 에러.
199
195
  // 스펙 기각이면 테스트는 통과했으니 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
- }
196
+ const failure = composeFailure({
197
+ specReject,
198
+ output: fuel,
199
+ checkCode: check?.code,
200
+ specPath: state.specPath,
201
+ integrity,
202
+ regressed,
203
+ baseline: state.metricBaseline,
204
+ metrics,
205
+ });
207
206
  state.lastError = truncate(failure, LIMITS.lastError);
208
207
  state.phase = 'ACT';
209
208
 
210
209
  // ---- 정체 감지 (L2) ----
211
- // 판정 신호는 스펙 기각이면 기각 사유, 무결성 위반이면 그 위반, 아니면 에러 지문이다.
212
210
  // 같은 실패가 stagnationLimit 회 연속이면 이 접근으로는 못 고친다는 뜻 —
213
211
  // 같은 값을 태우며 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;
212
+ // 검사가 이상이면 **실패한 명령의 집합**이 출력 텍스트보다 안정적인 신호다.
213
+ // 세 검사의 잡음을 이어붙여 해시하면 매번 달라져 정체를 영영 못 잡는다.
214
+ const sig =
215
+ suite.ran.length > 1 && !suite.ok
216
+ ? `CHECKS:${failingSet(suite.results)}`
217
+ : stagnationSignature({ specReject, integrity, regressed, output });
218
+
219
+ // 에러 지문만으로는 정체를 두 방향으로 오판한다(moai `internal/loop/feedback.go:43,54`):
220
+ // · 위음성 — 진짜 제자리인데 에러 텍스트가 매번 달라지면(줄 번호·파일·타이밍) 지문이
221
+ // 계속 바뀌어 카운터가 리셋되고, maxLoops 와 예산을 전부 태운다.
222
+ // · 위양성 — 실패 12 → 7 → 3 으로 **줄고 있는데** 첫 실패 줄이 그대로면 지문이 같아
223
+ // 수렴 중인 실행을 조기 종료한다.
224
+ // 그래서 판단 근거를 둘로 늘린다: 지표가 나아졌으면 지문과 무관하게 리셋하고,
225
+ // 지표 벡터가 완전히 동일하면 지문이 달라도 정체로 센다.
226
+ const prevMetrics = state.metrics ?? null;
227
+ const improved = isImproved(prevMetrics, metrics);
228
+ // no-op 회차(L12)는 무조건 정체다 — 트리가 그대로면 결과가 같다는 것은 증명이다.
229
+ const stuck = noop || (sig && sig === state.errorSig) || isSameMetrics(prevMetrics, metrics);
230
+ state.stagnantRuns = improved ? 1 : stuck ? state.stagnantRuns + 1 : 1;
220
231
  state.errorSig = sig;
232
+ if (metrics) state.metrics = metrics;
221
233
  saveState(statePath, state);
222
234
 
223
235
  if (integrity.ok) {
package/src/check.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * CHECK 단계 — Tier 1 게이트를 전부 평가하고 하나의 판정으로 합친다.
3
+ *
4
+ * build.js 에서 뺀 이유는 크기(NFR-5)만이 아니다. 게이트는 앞으로도 는다(무결성 → 유령 →
5
+ * 비회귀 → 스펙 → 플레이키…). 그 목록이 루프 본문 안에 있으면 회차 관리·핸드오프·예산 같은
6
+ * **다른 관심사와 섞여** 어떤 게이트가 어떤 순서로 평가되는지 한눈에 안 보인다.
7
+ * 여기 있으면 "이 실행이 무엇을 근거로 통과를 말하는가"가 한 파일에 모인다.
8
+ *
9
+ * 원칙: 게이트를 평가한 **그 자리에서** 원장(gates.js)에 기록한다. 나중에 몰아 쓰면
10
+ * "돌지 않은 게이트가 기록되는" 경로가 다시 열린다 — 그게 감사에서 잡힌 결함이었다.
11
+ */
12
+ import { saveState, truncate } from './state.js';
13
+ import { compareFingerprints, detectUntrackedTests, untrackedMessage } from './integrity.js';
14
+ import { parseMetrics, regressions, nextBaseline } from './metrics.js';
15
+ import { composeIntegrity } from './verdict.js';
16
+ import { recordGate } from './gates.js';
17
+ import { normalizeChecks, runChecks, failureSummary, failingSet } from './checks.js';
18
+ import { detectUnwired } from './wiring.js';
19
+ import { classifyChanges } from './blast.js';
20
+ import { detectDeletions, verifyRecoveryPath } from './checkpoint.js';
21
+
22
+ function combineOutput({ stdout, stderr }) {
23
+ return [stderr, stdout].filter(Boolean).join('\n').trim();
24
+ }
25
+
26
+ /**
27
+ * 반환: `{ passed, integrity, metrics, regressed, specReject, output, check }`
28
+ * state 를 직접 갱신한다(기준선·원장·삭제 관측) — 이것들은 회차를 넘어 누적되는 사실이라
29
+ * 호출부로 돌려보내 다시 쓰게 하면 빠뜨리기 쉽다.
30
+ */
31
+ export async function runCheck({ state, cwd, statePath, logger, ports, opts, baselineFiles }) {
32
+ const { testRunner, integrityChecker, fileLister, specVerifier, changeLister } = ports;
33
+ const { testCommand, verifySpec } = opts;
34
+ // ---- CHECK: 엔진이 직접 검증 (FR-2.3) ----
35
+ // 2단 판정이다. exit code 는 "테스트가 통과했는가"만 답한다. 그 테스트가
36
+ // **그대로인가**는 별도 문제이고, 그걸 안 물으면 에이전트는 테스트를 지워서 통과한다(L1).
37
+ state.phase = 'CHECK';
38
+ saveState(statePath, state);
39
+ // L18: 검사는 배열이다. short-circuit 하지 않고 전부 돌린다 — 그래야 "3개 중 2개가
40
+ // 깨졌다"를 한 회차에 알 수 있다. `--check` 가 없으면 종전대로 testCommand 하나다.
41
+ const checks = normalizeChecks({ testCommand, checks: opts.checks });
42
+ const changedFiles = checks.some((c) => c.when) ? await changeLister({ cwd }) : null;
43
+ const suite = await runChecks({ checks, cwd, runner: testRunner, changedFiles });
44
+ const check = { ok: suite.ok, code: suite.ok ? 0 : 1 };
45
+ // 지표 파싱과 에러 연료는 **실제로 돈 검사들의 출력**에서 나온다.
46
+ const output = suite.ran.map((r) => [r.stderr, r.stdout].filter(Boolean).join('\n')).join('\n').trim();
47
+ for (const s2 of suite.skipped) {
48
+ logger(`[hi-loop] ⏭ \`${s2.cmd}\` 는 건너뜁니다 — 변경 파일이 \`${s2.when}\` 에 맞지 않습니다.`);
49
+ }
50
+
51
+ // 추적 목록은 여기서 한 번만 얻는다 — 아래 삭제 검출(L8)과 유령 테스트(L13)가 같이 쓴다.
52
+ const trackedFiles = await fileLister({ cwd });
53
+
54
+ const fingerprint = integrityChecker({ cwd, testPath: state.testPath });
55
+ const fpCompare = compareFingerprints(state.testFingerprint, fingerprint);
56
+ // L13: 지문은 디스크를 읽으므로 git 이 모르는 테스트도 완벽한 지문을 갖는다.
57
+ // 추적되지 않은 테스트는 CI 에 존재하지 않는 테스트이고, 체크포인트로도 복구되지 않는다.
58
+ const ghosts = detectUntrackedTests(Object.keys(fingerprint), trackedFiles);
59
+ const integrity = composeIntegrity(fpCompare.violations, ghosts.map(untrackedMessage));
60
+ const it = state.iteration;
61
+ state.gates = recordGate(state.gates, 'integrity', fpCompare.violations.length ? 'fail' : 'pass', { iteration: it });
62
+ // 유령 검사는 추적 목록이 있어야만 성립한다. 비-git 이면 통과가 아니라 **미관측**이다.
63
+ state.gates = trackedFiles
64
+ ? recordGate(state.gates, 'ghost', ghosts.length ? 'fail' : 'pass', { iteration: it })
65
+ : recordGate(state.gates, 'ghost', 'unobservable', { iteration: it, detail: 'git 저장소가 아니라 추적 여부를 볼 수 없다' });
66
+ // 위반이 없을 때만 기준선을 갱신한다. 갱신해버리면 약화된 상태가 다음 회차의
67
+ // 기준이 되어 에이전트가 그대로 빠져나간다.
68
+ if (integrity.ok) state.testFingerprint = fingerprint;
69
+
70
+ // L8: baseline 에 있던 파일이 사라졌는가 = 에이전트가 기존 파일을 삭제. 차단하지 않고
71
+ // 경고만 한다(체크포인트로 되돌릴 수 있고 판단은 사람 몫). 누적 관측이라 Gaps 에도 실린다.
72
+ if (baselineFiles) {
73
+ const deleted = detectDeletions(baselineFiles, trackedFiles);
74
+ const fresh = deleted.filter((f) => !(state.deletedFiles ?? []).includes(f));
75
+ if (fresh.length) {
76
+ state.deletedFiles = [...(state.deletedFiles ?? []), ...fresh];
77
+ logger(`[hi-loop] ⚠️ 지정 경로 밖 파일 ${fresh.length}개가 삭제됐습니다 (차단 안 함 — hi-loop rollback 으로 복원 가능):`);
78
+ for (const f of fresh) logger(`[hi-loop] - ${f}`);
79
+ }
80
+ }
81
+
82
+ // L11: 초록불의 **범위**를 본다. exit code 는 "통과했는가"만 답하고 "몇 개가
83
+ // 통과했는가"는 답하지 않는다. 통과 수가 무너져도 exit 0 이다. 지문(integrity)은
84
+ // 파일을 보고 이쪽은 실행 결과를 보므로, 케이스를 지우지 않는 축소를 여기서만 잡는다.
85
+ // 파싱 불가(null)면 의견 없음 — 막지도 통과시키지도 않는다(측정의 부재는 fail-open).
86
+ const metrics = parseMetrics(output);
87
+ const regressed = regressions(state.metricBaseline, metrics);
88
+
89
+ // Tier 1 (기계, 최종 권한): exit code + 무결성 + 비회귀.
90
+ let passed = Boolean(check?.ok) && integrity.ok && regressed.length === 0;
91
+
92
+ state.gates = metrics
93
+ ? recordGate(state.gates, 'nonRegression', regressed.length ? 'fail' : 'pass', { iteration: it })
94
+ : recordGate(state.gates, 'nonRegression', 'unobservable', { iteration: it, detail: '러너 출력에서 통과/실패 수를 읽지 못했다' });
95
+ state.gates = recordGate(state.gates, 'exitCode', check?.ok ? 'pass' : 'fail', { iteration: it });
96
+ state.metricBaseline = nextBaseline(state.metricBaseline, metrics, { integrityOk: integrity.ok, regressed });
97
+
98
+ // L19: 이번 루프가 만든 제품 파일 중 제품 경로에서 안 불리는 것. 차단하지 않고 공개한다 —
99
+ // 다음 회차에 배선할 수도 있어서 차단하면 오탐이 루프를 죽인다. 누적 관측이라 Gaps 에 실린다.
100
+ if (baselineFiles && trackedFiles) {
101
+ const unwired = detectUnwired({ cwd, baselineFiles, currentFiles: trackedFiles });
102
+ const fresh = unwired.filter((f) => !(state.unwiredFiles ?? []).includes(f));
103
+ if (fresh.length) {
104
+ state.unwiredFiles = [...(state.unwiredFiles ?? []), ...fresh];
105
+ logger(`[hi-loop] 🔌 제품 경로에서 안 불리는 새 파일 ${fresh.length}개: ${fresh.join(', ')}`);
106
+ }
107
+ }
108
+
109
+ // L21: 복구 경로가 아직 살아 있는가. 사라졌으면 이 실행은 되돌릴 수 없는 상태다 —
110
+ // 계속 돌리면 에이전트가 더 망가뜨려도 손쓸 방법이 없다. 즉시 멈추는 편이 낫다.
111
+ const recovery = await verifyRecoveryPath({ cwd, checkpoints: state.checkpoints, expectGit: state.wasGitRepo === true });
112
+ if (recovery.reason) {
113
+ state.recoveryWarning = recovery.reason;
114
+ logger(`[hi-loop] 🧨 복구 경로 이상 — ${recovery.reason}`);
115
+ }
116
+
117
+ // L20: 이번 트리가 건드린 위험 분류(의존성·마이그레이션·CI·러너 설정). 차단하지 않고 공개한다.
118
+ const touched = changedFiles ?? (await changeLister({ cwd }));
119
+ if (touched) state.blastClasses = classifyChanges(touched);
120
+
121
+ // 플레이키 프로브 (L17): 통과 회차에서만 같은 명령을 한 번 더 돌린다.
122
+ //
123
+ // Residual-risk 에 "테스트가 플레이키하면 이 판정은 재현되지 않는다"고 **적어만 두는** 것은
124
+ // 이 엔진의 원칙(산문은 요청이고 메커니즘은 사실이다)에 어긋난다. 재실행 한 번이면 그 문장이
125
+ // 측정이 된다. 에이전트 호출은 0 이라 비용은 테스트 한 번뿐이고, 통과했을 때만 낸다.
126
+ //
127
+ // 불일치는 Tier 1 실패다 — 두 번 돌려 결과가 갈리는 초록불은 초록불이 아니다.
128
+ if (passed && opts.flakyProbe) {
129
+ const again = await testRunner({ command: testCommand, cwd });
130
+ if (!again?.ok) {
131
+ passed = false;
132
+ state.gates = recordGate(state.gates, 'flaky', 'fail', { iteration: it });
133
+ logger('[hi-loop] 🎲 플레이키 감지 — 같은 트리에서 두 번째 실행이 실패했습니다. 통과로 인정하지 않습니다.');
134
+ } else {
135
+ state.gates = recordGate(state.gates, 'flaky', 'pass', { iteration: it, detail: '같은 트리에서 2회 연속 통과' });
136
+ }
137
+ } else if (!opts.flakyProbe) {
138
+ state.gates = recordGate(state.gates, 'flaky', 'off', { detail: '--flaky-probe 로 켤 수 있다' });
139
+ }
140
+
141
+ // Tier 2 (모델, 하향 전용): Tier 1 통과일 때만 호출. 스펙 대비 구현을 심판한다.
142
+ // Tier 1 실패를 통과로 올릴 수는 절대 없다 — reject 만 가능하다(L9, bkit 중심 명제).
143
+ let specReject = '';
144
+ if (!verifySpec) state.gates = recordGate(state.gates, 'specVerify', 'off', { detail: '--verify-spec 로 켤 수 있다' });
145
+ if (passed && verifySpec) {
146
+ const verdict = await specVerifier({ specPath: state.specPath, cwd });
147
+ if (verdict?.verdict === 'reject') {
148
+ passed = false;
149
+ specReject = verdict.reason || '스펙과 어긋남';
150
+ logger(`[hi-loop] 🔬 스펙 검증 기각 — 테스트는 통과했으나 스펙 요구를 못 채웠습니다: ${truncate(specReject, 200)}`);
151
+ } else {
152
+ // 검증자는 인프라 실패(스펙 파일 없음·spawn 실패·파싱 실패)에도 pass 를 돌려준다.
153
+ // 게이트 판정으로는 옳지만(2단은 하향 전용) **증거로 쓰면 거짓말**이다 — 심판이
154
+ // 열리지도 않았는데 "심판이 통과시켰다"가 된다. verified 로 그 둘을 가른다.
155
+ const judged = verdict?.verified !== false;
156
+ state.specVerified = judged;
157
+ state.gates = recordGate(state.gates, 'specVerify', judged ? 'pass' : 'unobservable', {
158
+ iteration: it,
159
+ detail: judged ? '' : verdict?.reason || '검증자가 실제로 심판하지 못했다',
160
+ });
161
+ logger(`[hi-loop] 🔬 스펙 검증 통과.`);
162
+ }
163
+ }
164
+
165
+ // 에이전트에게 줄 연료는 검사가 둘 이상일 때 **어느 검사가 실패했는지**를 명시한다.
166
+ // 하나뿐이면 종전대로 원본 출력이다(불필요한 껍데기를 씌우지 않는다).
167
+ const fuel = suite.ran.length > 1 ? failureSummary(suite.results) : output;
168
+ return { passed, integrity, metrics, regressed, specReject, output, fuel, check, suite, recovery };
169
+ }
package/src/checkpoint.js CHANGED
@@ -87,8 +87,92 @@ export async function rollbackTo({ cwd, sha }) {
87
87
  // 되돌리기 전 안전망: 지금 상태를 스냅샷으로 남긴다(롤백을 되돌릴 수 있게).
88
88
  const safety = await git(['stash', 'create', '--include-untracked'], cwd);
89
89
 
90
- // 시점 트리를 워킹트리로 복원. checkout <sha> -- . 커밋 트리의 파일들을 꺼낸다.
91
- const done = await git(['checkout', sha, '--', '.'], cwd);
92
- if (done === null) return { ok: false, reason: 'checkout 실패 — SHA 가 유효하지 않거나 접근 불가.' };
93
- return { ok: true, safetySha: safety || null };
90
+ // `git checkout <sha> -- .` 워킹트리만 건드리고 인덱스를 남긴다. `read-tree -u --reset`
91
+ // 인덱스와 워킹트리를 함께 트리로 맞춘다 — 스테이징된 추가까지 되돌린다.
92
+ //
93
+ // **그래도 되돌리지 못하는 것이 있다: 추적되지 않는 새 파일.** git 의 어떤 트리 연산도
94
+ // 추적 밖 파일을 지우지 않고, `git clean` 은 사용자의 정상 파일(.env, 로컬 스크립트)까지
95
+ // 지운다. 지우는 것보다 **남아 있다고 밝히는 것**이 맞다 — 이 엔진의 규칙은
96
+ // "관측하지 않은 것을 관측 결과로 발행하지 않는다"이고, 여기서는 "되돌리지 못한 것을
97
+ // 되돌렸다고 말하지 않는다"가 같은 규칙이다.
98
+ const reset = await git(['read-tree', '-u', '--reset', sha], cwd);
99
+ if (reset === null) {
100
+ // 인덱스를 못 건드리는 상황(충돌 중 등)에서는 예전 방식으로라도 되돌린다 —
101
+ // 부분 복원이 무복원보다 낫다. 다만 무엇이 덜 됐는지 호출부가 알아야 한다.
102
+ const done = await git(['checkout', sha, '--', '.'], cwd);
103
+ if (done === null) return { ok: false, reason: 'checkout 실패 — SHA 가 유효하지 않거나 접근 불가.' };
104
+ return { ok: true, safetySha: safety || null, partial: true };
105
+ }
106
+ // 되돌린 뒤에도 남아 있는 추적 밖 파일. 호출부가 사람에게 보여줘야 한다.
107
+ const untracked = await git(['ls-files', '--others', '--exclude-standard'], cwd);
108
+ return {
109
+ ok: true,
110
+ safetySha: safety || null,
111
+ partial: false,
112
+ untrackedRemain: untracked ? untracked.split('\n').filter(Boolean) : [],
113
+ };
114
+ }
115
+
116
+ /**
117
+ * 이번 워킹트리에서 **바뀐 파일들** (L18 경로 조건부 검사용).
118
+ *
119
+ * `git status --porcelain` 은 추적 파일의 수정·추가와 추적 안 되는 새 파일을 모두 준다.
120
+ * HEAD 대비가 아니라 워킹트리 대비라, 루프가 시작되기 전부터 더러웠던 파일도 포함된다 —
121
+ * 그게 맞다. 조건부 검사가 답해야 할 질문은 "이 트리에 UI 변경이 있는가"이지
122
+ * "이번 회차가 UI 를 건드렸는가"가 아니다. 후자로 하면 2회차에 e2e 가 사라진다.
123
+ *
124
+ * 주입 가능한 포트 (NFR-3). `({cwd}) => string[] | null` (null = 비-git, 관측 불가)
125
+ */
126
+ export function makeChangeLister() {
127
+ return async ({ cwd }) => {
128
+ if (!(await isGitRepo(cwd))) return null;
129
+ const out = await git(['status', '--porcelain', '--untracked-files=all'], cwd);
130
+ if (out === null) return null;
131
+ return out
132
+ .split('\n')
133
+ .filter(Boolean)
134
+ .map((l) => l.slice(3).trim())
135
+ // 이름이 바뀐 항목은 `old -> new` 로 온다. 새 이름이 판정 대상이다.
136
+ .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
137
+ .filter(Boolean);
138
+ };
139
+ }
140
+
141
+ /**
142
+ * 복구 경로 생존 확인 (L21).
143
+ *
144
+ * L3(체크포인트)는 에이전트가 코드를 망가뜨렸을 때의 **유일한** 복구 경로다. 그런데 그
145
+ * 경로 자체는 무방비였다 — 에이전트는 cwd 에서 `acceptEdits` 로 돌고 Bash 를 갖는다.
146
+ * `git reset --hard`, `rm -rf .git`, `git gc --prune=now` 한 번이면 체크포인트 사슬이
147
+ * 통째로 사라지고, 엔진은 그 사실을 롤백을 시도하는 순간까지 모른다.
148
+ *
149
+ * 자식 에이전트의 Bash 호출을 가로채는 방법(CC 훅 주입)도 있지만, 그건 훅이 실제로
150
+ * 발동하는지 검증하지 못한 채 배송하는 것이 된다. 검증 안 된 방어는 이 엔진이 거부하는
151
+ * 형태다. 대신 **엔진이 직접 소유할 수 있는 측정**으로 바꾼다: 매 회차 복구 경로가
152
+ * 아직 살아 있는지 확인한다. 막지는 못해도 **모른 채 진행하지는 않는다.**
153
+ *
154
+ * 반환: `{ ok, reason }`. 관측 불가(애초에 비-git)면 ok — 인프라는 fail-open.
155
+ */
156
+ export async function verifyRecoveryPath({ cwd, checkpoints, expectGit = false }) {
157
+ const isRepo = await isGitRepo(cwd);
158
+ // **루프 시작 시점에 git 을 관측했는데 지금 아니라면 그건 손실이다.** 이 구분이 없으면
159
+ // 둘 중 하나를 포기해야 한다: 비-git 프로젝트 전체를 오탐으로 막거나(그러면 못 쓴다),
160
+ // `.git` 이 통째로 사라진 최악의 경우를 못 잡거나(그러면 이 함수가 무의미하다).
161
+ if (expectGit && !isRepo) {
162
+ return { ok: false, reason: 'git 저장소가 사라졌다 — 체크포인트를 되돌릴 수 없다.' };
163
+ }
164
+ if (!Array.isArray(checkpoints) || checkpoints.length === 0) return { ok: true, reason: '' };
165
+ if (!isRepo) return { ok: true, reason: '' }; // 애초에 git 이 아니었다 — 관측 불가는 손실이 아니다.
166
+ const dead = [];
167
+ for (const cp of checkpoints) {
168
+ const seen = await git(['cat-file', '-e', `${cp.sha}^{commit}`], cwd);
169
+ if (seen === null) dead.push(cp.iteration);
170
+ }
171
+ if (dead.length === checkpoints.length) {
172
+ return { ok: false, reason: `체크포인트 ${dead.length}개가 모두 접근 불가다(gc 되었거나 저장소가 초기화됐다).` };
173
+ }
174
+ if (dead.length) {
175
+ return { ok: true, reason: `체크포인트 ${dead.length}개가 접근 불가다(회차 ${dead.join(', ')}).` };
176
+ }
177
+ return { ok: true, reason: '' };
94
178
  }
package/src/checks.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * 다중 조건 검사 (L18) — 판정 명령이 하나뿐이던 문제.
3
+ *
4
+ * 지금까지 이 엔진의 Tier 1 입력은 `testCommand` **하나**였다. 유닛 테스트 말고 lint·typecheck·
5
+ * e2e 까지 게이트로 쓰려면 사용자가 `npm test && npm run lint && npx playwright test` 라고
6
+ * 써야 했는데, 그건 셋 다 나쁘다:
7
+ *
8
+ * 1. `&&` 는 short-circuit 이다. 유닛이 깨지면 **e2e 상태를 영원히 모른다.** 고치고 다시
9
+ * 돌려야 비로소 두 번째 실패를 발견하고, 그때 또 한 회차를 태운다.
10
+ * 2. UI 와 무관한 회차에도 e2e 가 매번 돈다. playwright 를 10회 루프에 물리면 비용이
11
+ * 에이전트 호출을 넘어선다.
12
+ * 3. 세 실패가 **하나의 지문으로 뭉개져** 정체 감지(L2)가 오작동한다.
13
+ *
14
+ * 설계(moai `internal/goal/schema.go:29` 의 조건 배열): 검사를 배열로 받고 **short-circuit
15
+ * 하지 않는다.** 전부 돌리고, 조건별로 실패 꼬리를 따로 보관해 에이전트에게 "3개 중 2번이
16
+ * 이렇게 실패했다"를 준다. 그리고 각 검사에 `when` 글롭을 달 수 있다 — 이번 회차가 건드린
17
+ * 파일이 하나도 안 맞으면 **건너뛴다.** 이것이 "UI 를 고쳤을 때만 e2e" 를 가능하게 한다.
18
+ *
19
+ * 건너뛴 검사는 통과가 아니다. 원장에 'skipped' 로 남고 보고서 Gaps 에 나온다 —
20
+ * 안 돌린 것이 통과처럼 보이면 이 엔진의 존재 이유가 무너진다.
21
+ */
22
+
23
+ /** 꼬리 길이. 실패 출력 전체를 물고 다니면 핸드오프 압축이 무의미해진다. */
24
+ export const TAIL_LEN = 800;
25
+
26
+ /**
27
+ * 글롭 → 정규식. `**` 는 경로 구분자를 넘고, `*` 는 안 넘는다.
28
+ * 의존성을 늘리지 않으려고 직접 만든다(NFR-4). 지원하는 것은 `**`, `*`, `?` 뿐이다 —
29
+ * 더 필요해지면 그때 늘린다. 지금 없는 문법을 미리 만들면 검증 안 된 코드가 는다.
30
+ */
31
+ export function globToRegExp(pattern) {
32
+ let out = '';
33
+ for (let i = 0; i < pattern.length; i += 1) {
34
+ const c = pattern[i];
35
+ if (c === '*') {
36
+ if (pattern[i + 1] === '*') {
37
+ // `**/` 는 "0개 이상의 디렉터리"다. 슬래시까지 삼켜야 `src/**/*.ts` 가 `src/a.ts` 에도 맞는다.
38
+ if (pattern[i + 2] === '/') {
39
+ out += '(?:.*/)?';
40
+ i += 2;
41
+ } else {
42
+ out += '.*';
43
+ i += 1;
44
+ }
45
+ } else out += '[^/]*';
46
+ } else if (c === '?') out += '[^/]';
47
+ else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
48
+ }
49
+ return new RegExp(`^${out}$`);
50
+ }
51
+
52
+ /** 변경 파일 중 하나라도 패턴에 맞는가. 패턴이 없으면 항상 참(무조건 실행). */
53
+ export function matchesAny(pattern, files) {
54
+ if (!pattern) return true;
55
+ if (!Array.isArray(files)) return true; // 변경 목록을 못 얻으면 **건너뛰지 않는다** — 미실행보다 과실행이 안전하다.
56
+ const re = globToRegExp(pattern);
57
+ return files.some((f) => re.test(f));
58
+ }
59
+
60
+ /**
61
+ * CLI/MCP 입력을 검사 배열로 정규화한다.
62
+ *
63
+ * `--check` 가 하나도 없으면 종전대로 `testCommand` 하나다 — 기존 사용자에게 아무것도
64
+ * 바뀌지 않는다. 이 하위 호환이 없으면 업데이트만 한 사람의 판정 규칙이 조용히 달라진다.
65
+ */
66
+ export function normalizeChecks({ testCommand, checks }) {
67
+ if (!Array.isArray(checks) || checks.length === 0) return [{ cmd: testCommand, when: null }];
68
+ return checks
69
+ .map((c) => (typeof c === 'string' ? { cmd: c, when: null } : { cmd: c?.cmd, when: c?.when ?? null }))
70
+ .filter((c) => typeof c.cmd === 'string' && c.cmd.trim());
71
+ }
72
+
73
+ /**
74
+ * 검사를 전부 돌린다. **short-circuit 하지 않는다** — 그게 이 모듈의 존재 이유다.
75
+ *
76
+ * 반환: `{ ok, results, ran, skipped }`
77
+ * results: `[{ cmd, when, ok, code, tail, skipped }]`
78
+ */
79
+ export async function runChecks({ checks, cwd, runner, changedFiles }) {
80
+ const results = [];
81
+ for (const c of checks) {
82
+ if (!matchesAny(c.when, changedFiles)) {
83
+ results.push({ ...c, skipped: true, ok: true, code: null, tail: '' });
84
+ continue;
85
+ }
86
+ const r = await runner({ command: c.cmd, cwd });
87
+ const out = [r?.stderr, r?.stdout].filter(Boolean).join('\n').trim();
88
+ results.push({ ...c, skipped: false, ok: Boolean(r?.ok), code: r?.code ?? null, tail: out.slice(-TAIL_LEN), stdout: r?.stdout ?? '', stderr: r?.stderr ?? '' });
89
+ }
90
+ const ran = results.filter((r) => !r.skipped);
91
+ return {
92
+ ok: ran.every((r) => r.ok),
93
+ results,
94
+ ran,
95
+ skipped: results.filter((r) => r.skipped),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * 에이전트에게 줄 실패 요약. **어느 검사가 실패했는지**를 명시한다 —
101
+ * `&&` 로 이어붙였을 때는 알 수 없던 정보다.
102
+ */
103
+ export function failureSummary(results) {
104
+ const failed = results.filter((r) => !r.skipped && !r.ok);
105
+ if (!failed.length) return '';
106
+ const head = `검사 ${results.filter((r) => !r.skipped).length}개 중 ${failed.length}개가 실패했다.`;
107
+ return [head, ...failed.map((r) => `\n### \`${r.cmd}\` (exit ${r.code})\n${r.tail || '(출력 없음)'}`)].join('\n');
108
+ }
109
+
110
+ /**
111
+ * 정체 지문용 키. 실패한 **명령의 집합**으로 만든다.
112
+ *
113
+ * 출력 텍스트를 이어붙여 해시하면 세 검사의 잡음이 섞여 매번 달라진다. 어느 검사가
114
+ * 실패하고 있는가는 그보다 훨씬 안정적인 신호이고, "lint 만 계속 깨진다"를 정확히 짚는다.
115
+ */
116
+ export function failingSet(results) {
117
+ return results
118
+ .filter((r) => !r.skipped && !r.ok)
119
+ .map((r) => r.cmd)
120
+ .sort()
121
+ .join('|');
122
+ }
@@ -12,7 +12,7 @@
12
12
  import { parseDuration, WATCH_DEFAULTS } from './ship.js';
13
13
  import { normalizeAskPolicy } from './ask.js';
14
14
 
15
- export const STAGE_NAMES = ['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'SHIP', 'WATCH'];
15
+ export const STAGE_NAMES = ['DISCOVER', 'PLAN', 'BUILD', 'CODE_REVIEW', 'COMMIT', 'SHIP', 'WATCH'];
16
16
 
17
17
  const bad = (message) => ({ ok: false, message });
18
18
 
@@ -103,6 +103,14 @@ export function parseRunOptions(args, { staged = {} } = {}) {
103
103
  return bad('--start-from 에는 PLAN 을 쓸 수 없습니다. BUILD 로 시작하면 PLAN 부터 돕니다.');
104
104
  }
105
105
 
106
+ // `--check` / `--when` 을 위치로 짝짓는다. when 이 모자라면 나머지는 무조건 실행이다.
107
+ const pairChecks = (check, when) => {
108
+ if (check === undefined) return null;
109
+ const cmds = (Array.isArray(check) ? check : [check]).filter((c) => typeof c === 'string');
110
+ const globs = when === undefined ? [] : Array.isArray(when) ? when : [when];
111
+ return cmds.map((cmd, i) => ({ cmd, when: typeof globs[i] === 'string' ? globs[i] : null }));
112
+ };
113
+
106
114
  // null = 자동(--full 또는 --ship 이면 켜짐). 명시 플래그가 그 자동을 양방향으로 덮는다.
107
115
  const triState = (onKey, offKey) => (args[offKey] ? false : args[onKey] ? true : null);
108
116
 
@@ -113,8 +121,18 @@ export function parseRunOptions(args, { staged = {} } = {}) {
113
121
  budgetUsd,
114
122
  stagnationLimit,
115
123
  verifySpec: Boolean(args['verify-spec']),
124
+ flakyProbe: Boolean(args['flaky-probe']),
125
+ // L18: `--check "<cmd>"` 를 여러 번 줄 수 있고, 각 뒤의 `--when "<glob>"` 이
126
+ // 그 검사에 붙는다. 짝을 이렇게 위치로 맺는 이유는 플래그 이름을 늘리지 않기 위해서다
127
+ // (`--check-e2e`, `--when-e2e` 식으로 가면 검사 개수만큼 플래그가 는다).
128
+ checks: pairChecks(args.check, args.when),
116
129
  shipCommand: str('ship'),
117
130
  watchCommand: str('watch'),
131
+ // git 워크플로 (FR-16). --branch 단독=자동명(true), --branch <name>=지정명.
132
+ // 값 병합(config 의 branchPolicy/commitPolicy)은 bin/hi-loop.js 가 한다 — 여기선 플래그만.
133
+ branch: args.branch === undefined ? null : given(args.branch) ? String(args.branch) : true,
134
+ commit: Boolean(args.commit),
135
+ commitMessage: str('commit-message'),
118
136
  onShipFail,
119
137
  onWatchFail,
120
138
  askPolicy,