@tuzi-ince/hi-loop 0.2.1 → 0.2.2
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/bin/hi-loop.js +25 -3
- package/docs/design.md +12 -1
- package/docs/spec.md +3 -1
- package/package.json +1 -1
- package/src/args.js +18 -5
- package/src/blast.js +42 -0
- package/src/build.js +74 -62
- package/src/check.js +169 -0
- package/src/checkpoint.js +88 -4
- package/src/checks.js +122 -0
- package/src/cli-options.js +13 -0
- package/src/gates.js +104 -0
- package/src/integrity.js +30 -1
- package/src/loop.js +21 -37
- package/src/metrics.js +140 -0
- package/src/outcome.js +76 -0
- package/src/report.js +73 -7
- package/src/resume.js +93 -0
- package/src/seal.js +85 -0
- package/src/stages.js +16 -3
- package/src/state.js +21 -39
- package/src/treekey.js +63 -0
- package/src/verdict.js +54 -0
- package/src/verify.js +6 -5
- package/src/wiring.js +74 -0
package/bin/hi-loop.js
CHANGED
|
@@ -61,12 +61,24 @@ const USAGE = `hi-loop — 초경량 자율형 자가 치유 엔진
|
|
|
61
61
|
HILOOP_DISCOVER_CMD/MODEL 발굴 단계의 실행 명령·모델
|
|
62
62
|
HILOOP_AGENT_TIMEOUT_MS 에이전트 타임아웃 (기본: 1800000 = 30분)
|
|
63
63
|
HILOOP_TEST_TIMEOUT_MS 테스트 타임아웃 (기본: 600000 = 10분)
|
|
64
|
-
TELEGRAM_BOT_TOKEN/CHAT_ID 텔레그램 알림 (미설정 시 생략)
|
|
64
|
+
TELEGRAM_BOT_TOKEN/CHAT_ID 텔레그램 알림 (미설정 시 생략)
|
|
65
|
+
|
|
66
|
+
검사를 여러 개 걸기 (--test 대신):
|
|
67
|
+
--check "<cmd>" 판정 명령. 여러 번 줄 수 있고 short-circuit 하지 않는다.
|
|
68
|
+
--when "<glob>" 바로 앞 --check 를 이 경로가 바뀐 회차에만 실행한다.
|
|
69
|
+
--flaky-probe 통과한 회차에서 같은 명령을 한 번 더 돌려 플레이키를 잡는다.
|
|
70
|
+
|
|
71
|
+
예) UI 를 건드린 회차에만 e2e 까지 돌린다:
|
|
72
|
+
hi-loop run --goal "..." \\
|
|
73
|
+
--check "npm test" \\
|
|
74
|
+
--check "npx playwright test" --when "src/**/*.tsx"`;
|
|
65
75
|
|
|
66
76
|
export async function main(argv = process.argv.slice(2)) {
|
|
67
77
|
const args = parseArgs(argv, {
|
|
68
78
|
alias: { g: 'goal', t: 'test', c: 'cwd', h: 'help', v: 'version', m: 'max-loops' },
|
|
69
|
-
boolean: ['help', 'version', 'dry-run', 'no-stagnation', 'verify-spec', 'yes', 'review', 'no-review', 'design-review', 'no-design-review', 'full', 'discover', 'no-discover'],
|
|
79
|
+
boolean: ['help', 'version', 'dry-run', 'no-stagnation', 'verify-spec', 'flaky-probe', 'yes', 'review', 'no-review', 'design-review', 'no-design-review', 'full', 'discover', 'no-discover'],
|
|
80
|
+
// 개수가 정해지지 않은 입력. `--check A --when X --check B` 처럼 위치로 짝을 맺는다.
|
|
81
|
+
repeat: ['check', 'when'],
|
|
70
82
|
});
|
|
71
83
|
const command = args._[0] ?? (args.help || args.version ? null : 'mcp');
|
|
72
84
|
|
|
@@ -189,7 +201,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
189
201
|
process.stderr.write(`롤백 실패: ${res.reason}\n`);
|
|
190
202
|
return 1;
|
|
191
203
|
}
|
|
192
|
-
process.stdout.write(`✅ ${cp.iteration}회차 직전 상태로 파일을 복원했습니다 (${cp.sha.slice(0, 8)}).\n`);
|
|
204
|
+
process.stdout.write(`✅ ${cp.iteration}회차 직전 상태로 추적 파일을 복원했습니다 (${cp.sha.slice(0, 8)}).\n`);
|
|
205
|
+
// 되돌리지 못한 것을 되돌렸다고 말하지 않는다. git 은 추적 밖 파일을 트리 연산으로
|
|
206
|
+
// 지우지 않고, `git clean` 은 사용자의 정상 파일까지 지운다 — 그래서 판단은 사람에게 준다.
|
|
207
|
+
if (res.untrackedRemain?.length) {
|
|
208
|
+
process.stdout.write(
|
|
209
|
+
`⚠️ 추적되지 않는 파일 ${res.untrackedRemain.length}개는 그대로 남아 있습니다(에이전트가 새로 만든 것일 수 있습니다):\n` +
|
|
210
|
+
res.untrackedRemain.slice(0, 10).map((f) => ` - ${f}\n`).join('') +
|
|
211
|
+
(res.untrackedRemain.length > 10 ? ` … 외 ${res.untrackedRemain.length - 10}개\n` : '') +
|
|
212
|
+
' 필요하면 직접 지우세요. hi-loop 은 사용자의 정상 파일을 지울 수 없어 판단하지 않습니다.\n',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
193
215
|
if (res.safetySha) process.stdout.write(` 되돌리기 전 상태는 ${res.safetySha.slice(0, 8)} 에 스냅샷됨.\n`);
|
|
194
216
|
return 0;
|
|
195
217
|
}
|
package/docs/design.md
CHANGED
|
@@ -552,7 +552,18 @@ try/catch 로 감싼다. **텔레그램 장애로 빌드가 죽는 것은 설계
|
|
|
552
552
|
| ~~L7~~ | ~~실제 `claude` 에이전트와의 통합이 미검증~~ | ✅ **해소** — claude 2.1.212 로 실측. 편집 권한·세션 재개·JSON 파싱·전체 루프 전부 확인(§10). **차단 사유였던 "개발 환경 실행 가드"는 사실이 아니었다** — 막고 있던 것은 기술적 불가능이 아니라 실행되지 않은 절차였다 |
|
|
553
553
|
| ~~L8~~ | ~~경로 회피는 강제가 아니다~~ | ✅ **관측으로 완화**(§4.14) — baseline 대비 삭제된 파일을 경고(차단 안 함, 되돌림 가능). 수정은 정당할 수 있어 삭제만 본다. 실측: 에이전트가 시킨 대로 legacy.js 삭제 → 경고 발동 |
|
|
554
554
|
| ~~L9~~ | ~~스펙이 오라클이 아니다~~ | ✅ **해소**(§4.12, opt-in) — 2단 판정. Tier 1(기계) 최종 권한, Tier 2(모델) 하향 전용. 검증자는 plan 모드·새 세션으로 격리. 실측: 테스트 통과 + 스펙 AC 미충족을 실제 claude 가 41초 만에 기각 |
|
|
555
|
-
| ~~L10~~ | ~~산출물이 미관측을 공개하지 않는다~~ | ✅ **해소**(§4.10) — `reportGaps` 가
|
|
555
|
+
| ~~L10~~ | ~~산출물이 미관측을 공개하지 않는다~~ | ✅ **해소**(§4.10) — `reportGaps` 가 5섹션(Claim/Evidence/Baseline-attribution/Gaps/Residual-risk)을 출력. CLI·MCP 양쪽. **실패에도 붙인다** — 예전 판단("실패는 stopReason 으로 이미 정직")을 뒤집었다. 예산 소진·정체로 멈춘 순간이야말로 워킹트리에 무엇이 반쯤 적용됐는지 알아야 할 때다 |
|
|
556
|
+
| ~~L11~~ | ~~판정이 불리언 하나다 — 통과 수가 무너져도 exit 0~~ | ✅ **해소**(`metrics.js`) — 러너 출력에서 통과/실패 수를 읽어 비회귀 게이트. 지문(L1)은 파일을 보고 이쪽은 실행 결과를 봐서 케이스를 안 지우는 축소를 잡는다. 언어 무관이라 Python·Go 프로젝트의 무결성 사각도 메운다. 정체 판정도 지표 벡터를 함께 본다 |
|
|
557
|
+
| ~~L12~~ | ~~에이전트가 아무것도 안 쓴 회차를 모른다~~ | ✅ **해소**(`treekey.js`) — `git stash create` 트리 SHA 로 앞뒤 비교. 트리가 같으면 결과가 같다는 것은 관측이 아니라 증명이므로 2회차에 정체로 잡는다 |
|
|
558
|
+
| ~~L13~~ | ~~디스크엔 있고 git 엔 없는 테스트~~ | ✅ **해소**(`integrity.js`) — 지문은 디스크를 읽으므로 gitignore 된 테스트도 완벽한 지문을 갖는다. CI 는 그 테스트를 못 보고 체크포인트로도 복구되지 않는다. 측정이라 fail-closed |
|
|
559
|
+
| ~~L14~~ | ~~돌지 않은 게이트가 Evidence 를 만든다~~ | ✅ **해소**(`gates.js`) — 게이트가 평가되는 그 자리에서만 원장에 남고, 보고서는 원장에서 렌더한다. `unobservable`·`off` 는 통과와 구분돼 Gaps 로 간다 |
|
|
560
|
+
| ~~L15~~ | ~~게이트를 하나도 안 거치고 passed 에 도달한다~~ | ✅ **해소**(`outcome.js`) — `--start-from CODE_REVIEW` 가 테스트를 한 번도 안 돌리고 통과를 주장했다. 통과한 트리를 박아두고 최종 판정 시점 트리와 대조한다. 종료 지점을 한 파일에 모아 새 stage 가 이 검사를 우회할 수 없게 했다 |
|
|
561
|
+
| ~~L16~~ | ~~게이트 기준선이 에이전트의 쓰기 범위 안에 있다~~ | ✅ **해소**(`seal.js`) — 상태 파일은 gitignore 되고 cwd 에 있다. 게이트를 여는 필드에 HMAC(키는 cwd 밖). 불일치면 기준선 없음으로 되돌리고 보고서에 밝힌다 |
|
|
562
|
+
| ~~L17~~ | ~~"한 번만 쟀다"가 산문으로만 있다~~ | ✅ **해소**(`--flaky-probe`) — 통과 회차에서만 재실행. 에이전트 호출 0. 두 번 돌려 갈리는 초록불은 초록불이 아니다 |
|
|
563
|
+
| ~~L18~~ | ~~판정 명령이 하나뿐이다~~ | ✅ **해소**(`checks.js`, `--check`/`--when`) — short-circuit 없이 전부 실행. 경로 글롭으로 무거운 검사(e2e)를 해당 회차에만. 건너뛴 것은 통과가 아니라 `skipped` 로 공개 |
|
|
564
|
+
| L19 | 만들었지만 제품 경로에서 안 불리는 파일 | ⚠️ **관측으로 완화**(`wiring.js`) — 테스트만 부르는 새 파일을 Gaps 에 공개. 차단하지 않는다(다음 회차에 배선할 수 있어 오탐이 루프를 죽인다) |
|
|
565
|
+
| L20 | 수정이 실패에 비해 과도한가 | ⚠️ **관측으로 완화**(`blast.js`) — 의존성·마이그레이션·CI·러너 설정 변경을 Gaps 에 공개. bkit 도 경고로만 쓴다 |
|
|
566
|
+
| L21 | 복구 경로 자체가 무방비 | ⚠️ **관측으로 완화**(`checkpoint.js`) — 에이전트는 cwd 에서 Bash 를 갖는다. `rm -rf .git` 한 번이면 L3 가 사라진다. 자식의 Bash 를 가로채는 CC 훅 주입은 **훅 발동을 검증하지 못한 채 배송**하는 것이라 택하지 않았다. 대신 매 회차 복구 경로 생존을 직접 잰다 — 막지는 못해도 모른 채 진행하지 않는다 |
|
|
556
567
|
|
|
557
568
|
---
|
|
558
569
|
|
package/docs/spec.md
CHANGED
|
@@ -393,7 +393,9 @@ DISCOVER → PLAN → DESIGN_REVIEW → DO ⇄ CHECK ⇄ HEAL → CODE_REVIEW
|
|
|
393
393
|
- NFR-3 모든 I/O 경계(에이전트 호출, 테스트 실행, 알림)는 주입 가능해야 하며
|
|
394
394
|
테스트는 네트워크/실제 에이전트 없이 통과해야 한다.
|
|
395
395
|
- NFR-4 상태 파일 쓰기는 원자적이어야 한다(중단 시 손상 금지).
|
|
396
|
-
- NFR-5 코드 파일당 300줄 이내, 모듈 단일 책임.
|
|
396
|
+
- NFR-5 코드 파일당 300줄 이내, 모듈 단일 책임. **`tests/nfr.test.js` 가 이 값을 강제한다** —
|
|
397
|
+
선언만 있고 재는 사람이 없던 동안 `build.js` 가 309줄, `loop.js` 가 303줄로 조용히 넘어갔다.
|
|
398
|
+
같은 파일이 NFR-4(런타임 의존성 2개)와 "아무도 안 읽는 export 금지"도 함께 강제한다.
|
|
397
399
|
|
|
398
400
|
---
|
|
399
401
|
|
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 의존성 없는 최소 인자 파서 (FR-1.4) — `--k v`, `--k=v`, `-g v`, `--flag` 지원.
|
|
3
|
+
*
|
|
4
|
+
* `repeat` 에 든 키는 여러 번 줘도 덮어쓰지 않고 **배열로 쌓는다**. `--check` 처럼 개수가
|
|
5
|
+
* 정해지지 않은 입력에 필요하다. 기본은 덮어쓰기다 — 모든 키를 배열로 만들면 호출부가
|
|
6
|
+
* 전부 `Array.isArray` 를 신경 써야 한다.
|
|
7
|
+
*/
|
|
8
|
+
export function parseArgs(argv, { alias = {}, boolean: booleans = [], repeat = [] } = {}) {
|
|
3
9
|
const out = { _: [] };
|
|
4
10
|
const isBool = (k) => booleans.includes(k);
|
|
5
11
|
const norm = (k) => alias[k] ?? k;
|
|
12
|
+
const put = (k, v) => {
|
|
13
|
+
if (!repeat.includes(k)) {
|
|
14
|
+
out[k] = v;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
out[k] = out[k] === undefined ? [v] : [...(Array.isArray(out[k]) ? out[k] : [out[k]]), v];
|
|
18
|
+
};
|
|
6
19
|
|
|
7
20
|
for (let i = 0; i < argv.length; i += 1) {
|
|
8
21
|
const token = argv[i];
|
|
@@ -14,15 +27,15 @@ export function parseArgs(argv, { alias = {}, boolean: booleans = [] } = {}) {
|
|
|
14
27
|
const raw = token.replace(/^--?/, '');
|
|
15
28
|
const eq = raw.indexOf('=');
|
|
16
29
|
if (eq !== -1) {
|
|
17
|
-
|
|
30
|
+
put(norm(raw.slice(0, eq)), raw.slice(eq + 1));
|
|
18
31
|
continue;
|
|
19
32
|
}
|
|
20
33
|
const key = norm(raw);
|
|
21
34
|
const next = argv[i + 1];
|
|
22
35
|
if (isBool(key) || next === undefined || next.startsWith('--')) {
|
|
23
|
-
|
|
36
|
+
put(key, true);
|
|
24
37
|
} else {
|
|
25
|
-
|
|
38
|
+
put(key, next);
|
|
26
39
|
i += 1;
|
|
27
40
|
}
|
|
28
41
|
continue;
|
package/src/blast.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 블라스트 반경 (L20) — 수정이 실패에 비해 과도한가.
|
|
3
|
+
*
|
|
4
|
+
* 이 엔진의 HEAL 루프에는 "고침의 크기"라는 개념이 없었다. 단언 하나가 깨진 것을 고치려고
|
|
5
|
+
* 에이전트가 `package.json` 을 다시 쓰거나, 마이그레이션을 추가하거나, 러너 설정을 바꿔도
|
|
6
|
+
* 테스트만 통과하면 전부 통과다. L8(삭제 관측)은 **지운 것**만 본다.
|
|
7
|
+
*
|
|
8
|
+
* 판정이 아니라 **공개**다. 큰 변경이 정당한 경우가 흔하고(리팩터링 goal, 초기 스캐폴딩),
|
|
9
|
+
* 차단하면 오탐이 루프를 죽인다. bkit 도 이 분류를 경고로만 쓴다
|
|
10
|
+
* (`lib/control/blast-radius.js:202` — warning 문자열을 돌려줄 뿐 차단하지 않는다).
|
|
11
|
+
* 사람이 보고서에서 "테스트는 통과했는데 왜 락파일이 바뀌었지"를 알아채게 하는 것이 목적이다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 위험 분류. 순서가 곧 우선순위다 — 먼저 맞는 것이 이긴다. */
|
|
15
|
+
const CLASSES = [
|
|
16
|
+
[/(?:^|\/)(?:package|pnpm|yarn)-lock\.(?:json|yaml)$|(?:^|\/)package\.json$|(?:^|\/)go\.(?:mod|sum)$|(?:^|\/)Cargo\.(?:toml|lock)$|requirements\.txt$|pyproject\.toml$/, '의존성'],
|
|
17
|
+
[/(?:^|\/)migrations?\//i, '스키마 마이그레이션'],
|
|
18
|
+
[/(?:^|\/)\.github\/|(?:^|\/)Dockerfile|docker-compose|(?:^|\/)\.gitlab-ci/i, 'CI·배포 설정'],
|
|
19
|
+
[/\.(?:config|conf)\.[cm]?[jt]s$|(?:^|\/)tsconfig|(?:^|\/)vite\.config|(?:^|\/)vitest\.config|(?:^|\/)jest\.config|(?:^|\/)eslint/i, '빌드·러너 설정'],
|
|
20
|
+
[/(?:^|\/)\.env/, '환경 변수'],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** 변경 파일들을 위험 분류로 묶는다. `{분류: [파일…]}` — 해당 없으면 빈 객체. */
|
|
24
|
+
export function classifyChanges(files) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const f of Array.isArray(files) ? files : []) {
|
|
27
|
+
for (const [re, label] of CLASSES) {
|
|
28
|
+
if (!re.test(f)) continue;
|
|
29
|
+
(out[label] ??= []).push(f);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 보고서 Gaps 에 실을 줄. 위험 분류가 없으면 빈 배열이다(소음을 만들지 않는다). */
|
|
37
|
+
export function blastLines(classified) {
|
|
38
|
+
return Object.entries(classified ?? {}).map(
|
|
39
|
+
([label, files]) =>
|
|
40
|
+
`- ⚠️ 이번 실행이 **${label}** 파일을 건드렸다: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` 외 ${files.length - 5}개` : ''}. 테스트 통과와 별개로 사람이 봐야 한다.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
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,
|
|
16
|
+
import { saveState, truncate, LIMITS } from './state.js';
|
|
17
17
|
import { promptFor } from './prompts.js';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
}
|