@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/README.md +319 -119
- package/bin/hi-loop.js +70 -5
- package/bin/setup.js +54 -1
- package/docs/design.md +12 -1
- package/docs/spec.md +3 -1
- package/package.json +2 -1
- package/skills/hi-loop/SKILL.md +117 -0
- 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 +19 -1
- package/src/config.js +112 -0
- package/src/gates.js +104 -0
- package/src/integrity.js +30 -1
- package/src/loop.js +46 -44
- 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 +47 -3
- package/src/state.js +21 -39
- package/src/treekey.js +63 -0
- package/src/vcs.js +167 -0
- package/src/verdict.js +54 -0
- package/src/verify.js +6 -5
- package/src/wiring.js +74 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 프로젝트 정책 저장소 `.hi-loop.json` (FR-16) — 브랜치·커밋 워크플로의 단일 진실 원천.
|
|
3
|
+
*
|
|
4
|
+
* `.agent-state.json`(임시·gitignore, goal 마다 리셋)과 **다르다**: 이 파일은 팀이 공유하는
|
|
5
|
+
* **영속 정책**이라 커밋 대상이다(그래서 .gitignore 에 넣지 않는다).
|
|
6
|
+
*
|
|
7
|
+
* 스킬 층(대화형)과 엔진 층(헤드리스)이 같은 파일을 읽어 정책을 일관되게 적용한다.
|
|
8
|
+
* 손상된 JSON 에도 죽지 않고 기본값으로 폴백한다 — 정책 파일 하나가 루프를 막으면 안 된다.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const CONFIG_FILE = '.hi-loop.json';
|
|
14
|
+
|
|
15
|
+
/** 보호 브랜치일 때: always=자동 분기 | ask=매번 확인 | never=분기 안 함 */
|
|
16
|
+
export const BRANCH_POLICIES = ['ask', 'always', 'never'];
|
|
17
|
+
/** 테스트 통과 후: confirm=메시지+diff 확인 후 | auto=자동 | off=커밋 안 함 */
|
|
18
|
+
export const COMMIT_POLICIES = ['confirm', 'auto', 'off'];
|
|
19
|
+
/** 커밋 메시지 형식: match-log=git log 스타일 미러 | conventional | korean */
|
|
20
|
+
export const COMMIT_STYLES = ['match-log', 'conventional', 'korean'];
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
23
|
+
version: 1,
|
|
24
|
+
protectedBranches: ['main', 'master', 'develop', 'dev'],
|
|
25
|
+
branchPolicy: 'ask',
|
|
26
|
+
commitPolicy: 'confirm',
|
|
27
|
+
commitStyle: 'match-log',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function configPathFor(cwd) {
|
|
31
|
+
return join(cwd, CONFIG_FILE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 파일이 있으면 기본값 위에 병합, 없거나 손상되면 기본값. 알 수 없는 값은 기본값으로 교정. */
|
|
35
|
+
export function loadConfig(cwd) {
|
|
36
|
+
const path = configPathFor(cwd);
|
|
37
|
+
let raw = null;
|
|
38
|
+
if (existsSync(path)) {
|
|
39
|
+
try {
|
|
40
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
raw = null; // 손상된 정책 파일은 무시하고 기본값 (루프를 막지 않는다)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return normalizeConfig(raw);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 신뢰할 수 없는 입력을 스키마에 맞춰 교정한다 — 잘못된 값이 조용히 통과하지 않게. */
|
|
49
|
+
export function normalizeConfig(raw) {
|
|
50
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
51
|
+
const branches = Array.isArray(r.protectedBranches)
|
|
52
|
+
? [...new Set(r.protectedBranches.filter((b) => typeof b === 'string' && b.trim()).map((b) => b.trim()))]
|
|
53
|
+
: [...DEFAULT_CONFIG.protectedBranches];
|
|
54
|
+
const pick = (v, allowed, def) => (allowed.includes(v) ? v : def);
|
|
55
|
+
return {
|
|
56
|
+
version: 1,
|
|
57
|
+
protectedBranches: branches,
|
|
58
|
+
branchPolicy: pick(r.branchPolicy, BRANCH_POLICIES, DEFAULT_CONFIG.branchPolicy),
|
|
59
|
+
commitPolicy: pick(r.commitPolicy, COMMIT_POLICIES, DEFAULT_CONFIG.commitPolicy),
|
|
60
|
+
commitStyle: pick(r.commitStyle, COMMIT_STYLES, DEFAULT_CONFIG.commitStyle),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 병합된(정규화된) 정책을 파일에 쓴다. 항상 정규화 후 저장해 파일이 스키마를 벗어나지 않게. */
|
|
65
|
+
export function saveConfig(cwd, config) {
|
|
66
|
+
const normalized = normalizeConfig(config);
|
|
67
|
+
writeFileSync(configPathFor(cwd), `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 정책 한 항목 변경 (`hi-loop config set <key> <value>` / 평문 요청).
|
|
73
|
+
* 반환: `{ ok, config }` 또는 `{ ok:false, reason }` — 던지지 않는다.
|
|
74
|
+
*/
|
|
75
|
+
export function setPolicy(cwd, key, value) {
|
|
76
|
+
const allowed = {
|
|
77
|
+
branchPolicy: BRANCH_POLICIES,
|
|
78
|
+
commitPolicy: COMMIT_POLICIES,
|
|
79
|
+
commitStyle: COMMIT_STYLES,
|
|
80
|
+
};
|
|
81
|
+
if (!(key in allowed)) {
|
|
82
|
+
return { ok: false, reason: `알 수 없는 정책 키: ${key} (가능: ${Object.keys(allowed).join(', ')})` };
|
|
83
|
+
}
|
|
84
|
+
if (!allowed[key].includes(value)) {
|
|
85
|
+
return { ok: false, reason: `--${key} 는 ${allowed[key].join('|')} 중 하나여야 합니다: ${value}` };
|
|
86
|
+
}
|
|
87
|
+
const config = { ...loadConfig(cwd), [key]: value };
|
|
88
|
+
return { ok: true, config: saveConfig(cwd, config) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 보호 브랜치 추가 (중복 무시). */
|
|
92
|
+
export function addBranch(cwd, name) {
|
|
93
|
+
const branch = String(name ?? '').trim();
|
|
94
|
+
if (!branch) return { ok: false, reason: '브랜치 이름이 비어 있습니다.' };
|
|
95
|
+
const config = loadConfig(cwd);
|
|
96
|
+
if (config.protectedBranches.includes(branch)) {
|
|
97
|
+
return { ok: true, config, changed: false };
|
|
98
|
+
}
|
|
99
|
+
config.protectedBranches.push(branch);
|
|
100
|
+
return { ok: true, config: saveConfig(cwd, config), changed: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 보호 브랜치 제거. */
|
|
104
|
+
export function removeBranch(cwd, name) {
|
|
105
|
+
const branch = String(name ?? '').trim();
|
|
106
|
+
const config = loadConfig(cwd);
|
|
107
|
+
if (!config.protectedBranches.includes(branch)) {
|
|
108
|
+
return { ok: true, config, changed: false };
|
|
109
|
+
}
|
|
110
|
+
config.protectedBranches = config.protectedBranches.filter((b) => b !== branch);
|
|
111
|
+
return { ok: true, config: saveConfig(cwd, config), changed: true };
|
|
112
|
+
}
|
package/src/gates.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 게이트 원장 (L14) — 어떤 게이트가 **실제로** 평가됐는지 기록한다.
|
|
3
|
+
*
|
|
4
|
+
* 문제: `reportGaps` 의 Evidence 는 하드코딩 문자열이었다. "무결성 위반 없음", "유령 테스트
|
|
5
|
+
* 없음" 같은 줄이 `status === 'passed'` 하나만 보고 인쇄됐다. 그런데 비-git 프로젝트에서는
|
|
6
|
+
* 유령 테스트 검사가 **수행 자체가 불가능**하고, 스펙 검증자는 스펙 파일이 없으면 검사 없이
|
|
7
|
+
* pass 를 돌려준다. 그 둘 다 보고서에는 "검사했고 깨끗했다"로 찍혔다.
|
|
8
|
+
*
|
|
9
|
+
* 즉 **관측하지 않은 것이 관측 결과로 발행됐다** — 이 엔진이 존재하는 이유인 바로 그 병이,
|
|
10
|
+
* 그 병을 막으려고 만든 보고 메커니즘 안에 있었다.
|
|
11
|
+
*
|
|
12
|
+
* 설계: 게이트가 평가되는 그 순간에만 원장에 한 줄이 남는다. 보고서는 문자열을 짓지 않고
|
|
13
|
+
* **원장을 읽어서** 렌더한다. 그러면 돌지 않은 게이트가 Evidence 를 만드는 일이 구조적으로
|
|
14
|
+
* 불가능해진다. 판정 근거를 지어내려면 원장에 없는 키를 읽어야 하는데, 그건 undefined 다.
|
|
15
|
+
*
|
|
16
|
+
* 결과값의 뜻:
|
|
17
|
+
* 'pass' — 평가했고 통과
|
|
18
|
+
* 'fail' — 평가했고 위반
|
|
19
|
+
* 'unobservable' — **평가할 수 없었다**(비-git 등). 통과가 아니다. Gaps 로 간다.
|
|
20
|
+
* 'off' — 사용자가 켜지 않았다(예: --verify-spec 없음). Gaps 로 간다.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 원장에 실릴 수 있는 게이트. 여기 없는 이름을 쓰면 보고서가 조용히 빠뜨린다. */
|
|
24
|
+
export const GATE_NAMES = ['exitCode', 'integrity', 'ghost', 'nonRegression', 'specVerify', 'flaky'];
|
|
25
|
+
|
|
26
|
+
/** 사람이 읽는 이름. 보고서 Evidence/Gaps 양쪽에서 같은 문구를 쓴다. */
|
|
27
|
+
const LABEL = {
|
|
28
|
+
exitCode: '테스트 명령 종료 코드',
|
|
29
|
+
integrity: '테스트 파일 무결성(직전 회차 대비 지문)',
|
|
30
|
+
ghost: '유령 테스트(git 추적 여부)',
|
|
31
|
+
nonRegression: '비회귀(통과 수 축소)',
|
|
32
|
+
specVerify: '스펙 대비 구현 심판(2단 판정)',
|
|
33
|
+
flaky: '플레이키 프로브(통과 회차 재실행)',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 게이트 평가 결과를 기록한다. **평가한 그 자리에서만** 부른다 —
|
|
38
|
+
* 나중에 몰아서 쓰면 "돌지 않은 게이트가 기록되는" 경로가 다시 열린다.
|
|
39
|
+
*/
|
|
40
|
+
export function recordGate(ledger, name, result, { iteration = null, detail = '' } = {}) {
|
|
41
|
+
if (!GATE_NAMES.includes(name)) throw new Error(`알 수 없는 게이트: ${name}`);
|
|
42
|
+
return { ...(ledger ?? {}), [name]: { result, iteration, detail } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 원장에서 Evidence 줄들을 만든다. 평가된 것만 나온다. */
|
|
46
|
+
export function evidenceLines(ledger) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const name of GATE_NAMES) {
|
|
49
|
+
const e = ledger?.[name];
|
|
50
|
+
if (!e || e.result !== 'pass') continue;
|
|
51
|
+
out.push(`- ${LABEL[name]} 통과${e.iteration ? ` (iteration ${e.iteration})` : ''}${e.detail ? ` — ${e.detail}` : ''}`);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 원장에서 Gaps 줄들을 만든다.
|
|
58
|
+
*
|
|
59
|
+
* **관측 불가와 미사용을 구분해서 밝힌다.** 둘 다 "검사 안 함"이지만 사용자가 할 일이
|
|
60
|
+
* 다르다 — 전자는 환경을 고쳐야 하고(git 저장소가 아님), 후자는 플래그를 켜면 된다.
|
|
61
|
+
*/
|
|
62
|
+
export function gapLines(ledger) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const name of GATE_NAMES) {
|
|
65
|
+
const e = ledger?.[name];
|
|
66
|
+
if (!e) {
|
|
67
|
+
out.push(`- ${LABEL[name]}: 이번 실행에서 평가되지 않았다.`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (e.result === 'unobservable') {
|
|
71
|
+
out.push(`- ${LABEL[name]}: **관측 불가**라 검사하지 못했다${e.detail ? ` (${e.detail})` : ''}. 통과가 아니라 미관측이다.`);
|
|
72
|
+
} else if (e.result === 'off') {
|
|
73
|
+
out.push(`- ${LABEL[name]}: 켜지 않아 검사하지 않았다${e.detail ? ` (${e.detail})` : ''}.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 판정↔트리 결속 (L15) — "통과한 트리"와 "지금 트리"가 같은가.
|
|
81
|
+
*
|
|
82
|
+
* 이게 없으면 `--start-from CODE_REVIEW` 처럼 BUILD 를 건너뛴 경로가 게이트를 **하나도**
|
|
83
|
+
* 통과하지 않고 `passed` 에 도달했다. 그리고 보고서는 "무결성·비회귀 게이트를 모두 넘었다"고
|
|
84
|
+
* 인쇄했다 — false green 을 막으려 만든 공개 메커니즘이 false green 을 생산한 것이다.
|
|
85
|
+
*
|
|
86
|
+
* 반환: `{ ok, reason }`. ok=false 면 호출부는 `passed` 를 주장해선 안 된다.
|
|
87
|
+
*
|
|
88
|
+
* 관측 불가(비-git)일 때는 결속을 **강제하지 못한다**. 그때도 "빌드가 통과했다"는 사실
|
|
89
|
+
* 자체는 원장에 있으므로 그것만 요구하고, 트리 일치는 건너뛴다(인프라 fail-open).
|
|
90
|
+
* 하지만 빌드를 아예 안 돈 것은 인프라 문제가 아니라 **측정의 부재**이므로 fail-closed 다.
|
|
91
|
+
*/
|
|
92
|
+
export function checkVerdictBinding({ ledger, passedTreeKey, currentTreeKey }) {
|
|
93
|
+
if (ledger?.exitCode?.result !== 'pass') {
|
|
94
|
+
return { ok: false, reason: '이번 실행에서 테스트 게이트를 통과한 적이 없다(BUILD 를 건너뛰었다).' };
|
|
95
|
+
}
|
|
96
|
+
if (!passedTreeKey || !currentTreeKey) return { ok: true, reason: '' }; // 트리 관측 불가 — 강제 불가
|
|
97
|
+
if (passedTreeKey !== currentTreeKey) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: `게이트를 통과한 트리(${passedTreeKey.slice(0, 12)})와 지금 트리(${currentTreeKey.slice(0, 12)})가 다르다.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, reason: '' };
|
|
104
|
+
}
|
package/src/integrity.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* 지금까지의 방어는 프롬프트 한 줄이었다("테스트를 무력화하지 마라"). 산문은 요청이지
|
|
8
8
|
* 메커니즘이 아니다 — 그리고 실측에서 에이전트가 프롬프트를 어길 수 있음이 확인됐다.
|
|
9
9
|
*
|
|
10
|
-
* 설계: git 을 쓰지 않는다. iteration 마다 테스트 파일의 **지문**을 떠서 직전 회차와
|
|
10
|
+
* 설계: 지문 비교는 git 을 쓰지 않는다. iteration 마다 테스트 파일의 **지문**을 떠서 직전 회차와
|
|
11
|
+
* 비교한다. (아래 유령 테스트 검출만 추적 목록을 필요로 하는데, 그것도 이 모듈이 git 을 부르지
|
|
12
|
+
* 않고 호출부가 이미 갖고 있는 목록을 인자로 받는다 — 이 파일은 여전히 순수하다.)
|
|
11
13
|
* - git 저장소가 아니어도 동작한다 (fail-open 예외 처리가 통째로 필요 없다).
|
|
12
14
|
* - 기준선이 HEAD 가 아니라 **직전 iteration** 이라, 실행 시작 시점의 더러운 워킹트리가
|
|
13
15
|
* 오탐을 만들지 않는다. 재는 것은 정확히 "이 루프가 테스트를 약화시켰는가"다.
|
|
@@ -118,6 +120,33 @@ export function compareFingerprints(before, after) {
|
|
|
118
120
|
return { ok: violations.length === 0, violations };
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
/**
|
|
124
|
+
* 유령 테스트 (L13) — 디스크에는 있는데 git 이 모르는 테스트 파일.
|
|
125
|
+
*
|
|
126
|
+
* 위 지문 비교는 파일을 **디스크에서** 읽는다. 그래서 에이전트가 테스트를 `.gitignore` 된
|
|
127
|
+
* 경로에 쓰거나 `tests/` 를 `.gitignore` 에 추가해버리면, 지문은 완벽하고 exit code 는
|
|
128
|
+
* 초록불인데 **사용자의 CI 에는 테스트가 하나도 없다.** 체크포인트(L3)로도 복구할 수 없다 —
|
|
129
|
+
* `git stash create` 가 담는 것은 git 이 아는 파일뿐이기 때문이다.
|
|
130
|
+
* L1 이 지키려던 바로 그 실패를, L1 이 보지 않는 문으로 통과시킨다.
|
|
131
|
+
*
|
|
132
|
+
* bkit 은 이걸 CI 하드 게이트로 갖고 있다(`scripts/check-test-tracking.js:92-112`,
|
|
133
|
+
* `git ls-files` 집합과의 차집합 → exit 1). 실제로 v2.1.14~v2.1.16 릴리스에서 CI 가
|
|
134
|
+
* 참조하는 테스트 파일이 추적되지 않은 채 나간 사고의 재발 방지책이다.
|
|
135
|
+
*
|
|
136
|
+
* 이건 **측정**이므로 fail-closed 다(통과로 인정하지 않는다). 다만 추적 목록 자체를
|
|
137
|
+
* 못 얻으면(비-git) 관측 불가이므로 조용히 빈 배열 — 인프라는 fail-open.
|
|
138
|
+
*/
|
|
139
|
+
export function detectUntrackedTests(testFiles, trackedFiles) {
|
|
140
|
+
if (!Array.isArray(trackedFiles) || !Array.isArray(testFiles)) return [];
|
|
141
|
+
const tracked = new Set(trackedFiles);
|
|
142
|
+
return testFiles.filter((f) => !tracked.has(f));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 유령 테스트를 위반 문장으로. 차단은 대안과 함께 배송한다(bkit). */
|
|
146
|
+
export function untrackedMessage(file) {
|
|
147
|
+
return `${file}: 테스트 파일이 git 에 추적되지 않는다 — CI 는 이 테스트를 보지 못하고 롤백으로도 복구되지 않는다. \`git add ${file}\` 로 추적에 넣어라(.gitignore 에 걸려 있다면 그 규칙을 고쳐라).`;
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
/** 위반을 에이전트에게 들이밀 텍스트로. 차단은 대안과 함께 배송한다(bkit). */
|
|
122
151
|
export function violationMessage(violations) {
|
|
123
152
|
return [
|
package/src/loop.js
CHANGED
|
@@ -14,7 +14,10 @@ import { saveState, statePathFor, resumeOrCreate, reportGaps } from './state.js'
|
|
|
14
14
|
import { makeAgentRunner, makeTestRunner } from './runners.js';
|
|
15
15
|
import { makeIntegrityChecker } from './integrity.js';
|
|
16
16
|
import { acquireLock } from './lock.js';
|
|
17
|
-
import { makeCheckpointer, makeFileLister } from './checkpoint.js';
|
|
17
|
+
import { makeCheckpointer, makeFileLister, makeChangeLister } from './checkpoint.js';
|
|
18
|
+
import { makeBrancher, makeCommitter, branchPreStep } from './vcs.js';
|
|
19
|
+
import { makeTreeKeyReader } from './treekey.js';
|
|
20
|
+
import { makeOutcome } from './outcome.js';
|
|
18
21
|
import { makeSpecVerifier } from './verify.js';
|
|
19
22
|
import { makeNotifier } from './telegram.js';
|
|
20
23
|
import { makeShipper, makeWatcher, WATCH_DEFAULTS } from './ship.js';
|
|
@@ -30,12 +33,11 @@ export { createState, loadState, saveState, statePathFor, summarizeState, resetS
|
|
|
30
33
|
export { applyAnswer, formatAsk, normalizeAskPolicy } from './ask.js';
|
|
31
34
|
|
|
32
35
|
/** BUILD 이후의 stage. 이 상태로 저장돼 있으면 재개 시 빌드 루프를 다시 돌지 않는다. */
|
|
33
|
-
export const POST_BUILD_STAGES = ['CODE_REVIEW', 'SHIP', 'WATCH'];
|
|
36
|
+
export const POST_BUILD_STAGES = ['CODE_REVIEW', 'COMMIT', 'SHIP', 'WATCH'];
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* 얇은 락 래퍼 (L4). 같은 cwd 에서 두 루프가 상태를 덮어쓰지 못하게 배타를 강제한다.
|
|
37
40
|
* body 는 return 지점이 여럿이라 여기서 한 번 감싸 finally 로 확실히 푼다.
|
|
38
|
-
* acquireLock 은 테스트를 위해 주입 가능하다.
|
|
39
41
|
*/
|
|
40
42
|
export async function runLoop(opts = {}) {
|
|
41
43
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -56,6 +58,8 @@ async function runLoopBody({
|
|
|
56
58
|
budgetUsd = null,
|
|
57
59
|
stagnationLimit = 3,
|
|
58
60
|
verifySpec = false,
|
|
61
|
+
flakyProbe = false,
|
|
62
|
+
checks = null,
|
|
59
63
|
handoffEvery = 4,
|
|
60
64
|
// ---- 라이프사이클 (FR-13, FR-14) ----
|
|
61
65
|
shipCommand = null,
|
|
@@ -76,6 +80,12 @@ async function runLoopBody({
|
|
|
76
80
|
watchEveryMs = WATCH_DEFAULTS.everyMs,
|
|
77
81
|
watchTolerate = WATCH_DEFAULTS.tolerate,
|
|
78
82
|
onWatchFail = 'stop',
|
|
83
|
+
// git 워크플로 (FR-16) — opt-in. branch: null=안 함 | true=자동명 | '<name>'=지정명.
|
|
84
|
+
branch = null,
|
|
85
|
+
protectedBranches = null,
|
|
86
|
+
commit = false,
|
|
87
|
+
commitMessage = null,
|
|
88
|
+
commitStyle = 'match-log',
|
|
79
89
|
askPolicy = DEFAULT_ASK_POLICY,
|
|
80
90
|
yes = false,
|
|
81
91
|
agentRunner = makeAgentRunner(),
|
|
@@ -83,12 +93,16 @@ async function runLoopBody({
|
|
|
83
93
|
integrityChecker = makeIntegrityChecker(),
|
|
84
94
|
checkpointer = makeCheckpointer(),
|
|
85
95
|
fileLister = makeFileLister(),
|
|
96
|
+
changeLister = makeChangeLister(),
|
|
97
|
+
treeKeyReader = makeTreeKeyReader(),
|
|
86
98
|
specVerifier = makeSpecVerifier(),
|
|
87
99
|
codeReviewer = makeCodeReviewer(),
|
|
88
100
|
designReviewer = makeDesignReviewer(),
|
|
89
101
|
discoverer = makeDiscoverer(),
|
|
90
102
|
shipper = makeShipper(),
|
|
91
103
|
watcher = makeWatcher(),
|
|
104
|
+
brancher = makeBrancher(),
|
|
105
|
+
committer = makeCommitter(),
|
|
92
106
|
notifier = makeNotifier(),
|
|
93
107
|
logger = (line) => process.stderr.write(`${line}\n`),
|
|
94
108
|
statePath = statePathFor(cwd),
|
|
@@ -115,6 +129,10 @@ async function runLoopBody({
|
|
|
115
129
|
|
|
116
130
|
saveState(statePath, state);
|
|
117
131
|
|
|
132
|
+
// 브랜치 프리스텝 (FR-16, opt-in): 보호 브랜치일 때만 분기. 비-git·feature·재개엔 no-op.
|
|
133
|
+
const branchedTo = await branchPreStep({ brancher, cwd, branch, goal, protectedBranches, logger });
|
|
134
|
+
if (branchedTo) { state.branch = branchedTo; saveState(statePath, state); }
|
|
135
|
+
|
|
118
136
|
const notify = async (event) => {
|
|
119
137
|
try {
|
|
120
138
|
await notifier({ goal, maxLoops, ...event });
|
|
@@ -129,22 +147,11 @@ async function runLoopBody({
|
|
|
129
147
|
// null 이면 비-git — 관측 자체가 불가능하니 아래에서 조용히 건너뛴다(인프라 fail-open).
|
|
130
148
|
const baselineFiles = await fileLister({ cwd });
|
|
131
149
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
state
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (stopReason) state.stopReason = stopReason;
|
|
138
|
-
const saved = saveState(statePath, state);
|
|
139
|
-
await notify({ type: status === 'passed' ? 'passed' : 'failed', iteration: state.iteration, detail });
|
|
140
|
-
// Gaps 는 passed 에만 붙인다. false green 이 조용히 지나가는 지점이 거기이기 때문이다.
|
|
141
|
-
// 실패는 stopReason 만으로 이미 정직하다 — 거기에 Gaps 를 얹으면 소음이다.
|
|
142
|
-
if (status !== 'passed') return { status, iterations: state.iteration, state: saved };
|
|
143
|
-
const gaps = reportGaps(saved);
|
|
144
|
-
logger(gaps);
|
|
145
|
-
return { status, iterations: state.iteration, state: saved, report: gaps };
|
|
146
|
-
};
|
|
147
|
-
|
|
150
|
+
const { finish, stopResult, pauseResult } = makeOutcome({
|
|
151
|
+
getState: () => state,
|
|
152
|
+
setState: (v) => { state = v; },
|
|
153
|
+
statePath, cwd, logger, notify, treeKeyReader,
|
|
154
|
+
});
|
|
148
155
|
|
|
149
156
|
/**
|
|
150
157
|
* 단계 기본값 (FR-17) — 조용히 비용을 얹지 않는다.
|
|
@@ -158,12 +165,11 @@ async function runLoopBody({
|
|
|
158
165
|
const designReviewEnabled = designReview === null ? full || Boolean(shipCommand) : Boolean(designReview);
|
|
159
166
|
const discoverEnabled = discover === null ? full : Boolean(discover);
|
|
160
167
|
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const nextAfterReview = () => (shipCommand ? 'SHIP' : watchCommand ? 'WATCH' : 'DONE');
|
|
168
|
+
// 라우팅: 켜진 다음 단계로만 넘어간다(기본은 BUILD 하나). 커밋이 켜지면 리뷰된 코드를 먼저 커밋한다.
|
|
169
|
+
const commitEnabled = Boolean(commit);
|
|
170
|
+
const nextAfterCommit = () => (shipCommand ? 'SHIP' : watchCommand ? 'WATCH' : 'DONE');
|
|
171
|
+
const nextAfterBuild = () => (reviewEnabled ? 'CODE_REVIEW' : commitEnabled ? 'COMMIT' : nextAfterCommit());
|
|
172
|
+
const nextAfterReview = () => (commitEnabled ? 'COMMIT' : nextAfterCommit());
|
|
167
173
|
|
|
168
174
|
/**
|
|
169
175
|
* 사람이 골라야 하는 지점에서 멈춘다. 이미 답한 질문이면 그 답을 돌려준다.
|
|
@@ -172,29 +178,15 @@ async function runLoopBody({
|
|
|
172
178
|
const gate = ({ stage, question, options, required = true }) => {
|
|
173
179
|
const answered = [...(state.answers ?? [])].reverse().find((a) => a.stage === stage);
|
|
174
180
|
if (answered) return { pause: false, choice: answered.choice, note: answered.note };
|
|
175
|
-
|
|
181
|
+
// `--yes` 는 **명시적 포괄 승인**이므로 승인('a')으로 친다.
|
|
182
|
+
// `--ask never` 는 "답할 사람이 없다"일 뿐 승인이 아니다 — null 을 돌려주고,
|
|
183
|
+
// 비가역 행위 쪽에서 그걸 거절로 해석한다. 되돌릴 수 있는 지점은 종전대로 진행한다.
|
|
184
|
+
if (yes) return { pause: false, choice: 'a' };
|
|
185
|
+
if (!shouldAsk({ policy: askPolicy, required })) return { pause: false, choice: null };
|
|
176
186
|
state = pauseForAsk(state, createAsk({ stage, question, options }));
|
|
177
187
|
return { pause: true };
|
|
178
188
|
};
|
|
179
189
|
|
|
180
|
-
const pauseResult = () => {
|
|
181
|
-
const saved = saveState(statePath, state);
|
|
182
|
-
logger(formatAsk(state.ask));
|
|
183
|
-
return { status: 'awaiting', iterations: state.iteration, state: saved, ask: state.ask };
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* `--stop-after` 로 멈춘 결과. 실패가 아니다 — 시킨 것을 다 했을 뿐이다.
|
|
188
|
-
* status 를 passed 로 하면 "테스트가 통과했다"는 거짓말이 되므로 별도 값을 쓴다.
|
|
189
|
-
*/
|
|
190
|
-
const stopResult = (stage) => {
|
|
191
|
-
state.status = 'stopped';
|
|
192
|
-
state.stopReason = `stop-after:${stage}`;
|
|
193
|
-
const saved = saveState(statePath, state);
|
|
194
|
-
logger(`[hi-loop] ⏹ ${stage} 까지 진행하고 멈췄습니다 (--stop-after).`);
|
|
195
|
-
return { status: 'stopped', iterations: state.iteration, state: saved, stoppedAt: stage };
|
|
196
|
-
};
|
|
197
|
-
|
|
198
190
|
/** 방금 끝낸 stage 가 정지 지점인가. 다음 stage 로 넘어가기 직전에만 묻는다. */
|
|
199
191
|
const shouldStopAfter = (stage) => stopAfter === stage;
|
|
200
192
|
|
|
@@ -215,6 +207,7 @@ async function runLoopBody({
|
|
|
215
207
|
notify,
|
|
216
208
|
gate,
|
|
217
209
|
nextAfterReview,
|
|
210
|
+
nextAfterCommit,
|
|
218
211
|
statePath,
|
|
219
212
|
baselineFiles,
|
|
220
213
|
designReviewEnabled,
|
|
@@ -225,11 +218,15 @@ async function runLoopBody({
|
|
|
225
218
|
designReviewer,
|
|
226
219
|
shipper,
|
|
227
220
|
watcher,
|
|
221
|
+
brancher,
|
|
222
|
+
committer,
|
|
228
223
|
agentRunner,
|
|
229
224
|
testRunner,
|
|
230
225
|
integrityChecker,
|
|
231
226
|
checkpointer,
|
|
232
227
|
fileLister,
|
|
228
|
+
changeLister,
|
|
229
|
+
treeKeyReader,
|
|
233
230
|
specVerifier,
|
|
234
231
|
},
|
|
235
232
|
opts: {
|
|
@@ -246,9 +243,14 @@ async function runLoopBody({
|
|
|
246
243
|
testCommand,
|
|
247
244
|
stagnationLimit,
|
|
248
245
|
verifySpec,
|
|
246
|
+
flakyProbe,
|
|
247
|
+
checks,
|
|
249
248
|
handoffEvery,
|
|
250
249
|
maxDesignRounds,
|
|
251
250
|
stopAfter,
|
|
251
|
+
commit,
|
|
252
|
+
commitMessage,
|
|
253
|
+
commitStyle,
|
|
252
254
|
},
|
|
253
255
|
};
|
|
254
256
|
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 테스트 지표 파싱과 비회귀 게이트 (L11) — 판정을 불리언에서 벡터로.
|
|
3
|
+
*
|
|
4
|
+
* 문제: 이 엔진의 Tier 1 판정은 exit code 하나다. 그런데 exit 0 은 "몇 개가 통과했는가"를
|
|
5
|
+
* 말하지 않는다. 테스트 120개 통과 → 1개 통과 / 0개 실패로 붕괴해도 **exit 0 이고, passed 다.**
|
|
6
|
+
* `integrity.js` 가 이걸 막을 것 같지만 막지 못한다 — 그쪽은 파일의 지문(케이스 선언 수)을
|
|
7
|
+
* 보고, 이쪽은 **실행 결과**를 본다. 케이스를 지우지 않고도 통과 수는 무너질 수 있다
|
|
8
|
+
* (조건부 스킵, 파라미터 축소, 러너 설정 변경, import 실패로 스위트 통째 미수집).
|
|
9
|
+
*
|
|
10
|
+
* 그리고 더 중요한 사각: `integrity.js` 의 TEST_FILE_RE 는 `.test.js|.spec.ts` 계열만 안다.
|
|
11
|
+
* **Python·Go·Rust 프로젝트에서 무결성 가드는 0 이다.** 이 모듈은 파일이 아니라 러너의
|
|
12
|
+
* 표준출력을 읽으므로 언어에 무관하다 — 그 사각을 메우는 것이 이 파일의 두 번째 목적이다.
|
|
13
|
+
*
|
|
14
|
+
* 출처: moai 의 비회귀 게이트(`internal/harness/regression_gate.go:50` MetricTriple.Regressions
|
|
15
|
+
* → `applier.go:544` 회귀 시 RestoreSnapshot)와 지표 벡터 정체 판정(`internal/loop/feedback.go:43`
|
|
16
|
+
* IsImproved / `:54` IsStagnant). 둘 다 moai 에서 실제 배선된 경로다.
|
|
17
|
+
*
|
|
18
|
+
* 두 fail-safe 규칙은 moai 에서 그대로 가져온다. 이게 없으면 게이트가 오탐 기계가 된다:
|
|
19
|
+
* 1. 기준선이 없으면 절대 막지 않는다 (regression_gate.go:124-127).
|
|
20
|
+
* 2. 파싱 불가면 "의견 없음"이다 — 막지도, 통과시키지도 않는다.
|
|
21
|
+
* 즉 **측정은 fail-closed 이되, 측정의 부재는 fail-open** 이다. 지표를 못 읽는다는 이유로
|
|
22
|
+
* 루프가 죽으면 러너를 바꾼 사용자 전원이 피해자가 된다.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 러너별 추출기. 위에서부터 시도하고 **먼저 걸리는 것이 이긴다.**
|
|
27
|
+
*
|
|
28
|
+
* 순서가 중요하다: 구체적인 포맷을 먼저 두고 일반 패턴을 뒤에 둔다. 반대로 하면
|
|
29
|
+
* jest 의 "Tests: 1 failed, 205 passed" 를 느슨한 패턴이 먼저 반쪽만 집어간다.
|
|
30
|
+
*/
|
|
31
|
+
const EXTRACTORS = [
|
|
32
|
+
// node:test — `ℹ pass 206` / `ℹ fail 0`
|
|
33
|
+
(t) => {
|
|
34
|
+
const p = /^\s*(?:ℹ\s*)?pass\s+(\d+)\s*$/m.exec(t);
|
|
35
|
+
const f = /^\s*(?:ℹ\s*)?fail\s+(\d+)\s*$/m.exec(t);
|
|
36
|
+
return p ? { passed: +p[1], failed: f ? +f[1] : 0 } : null;
|
|
37
|
+
},
|
|
38
|
+
// jest — `Tests: 1 failed, 205 passed, 206 total`
|
|
39
|
+
(t) => {
|
|
40
|
+
const m = /Tests:\s+(?:(\d+)\s+failed,\s*)?(?:\d+\s+skipped,\s*)?(\d+)\s+passed/.exec(t);
|
|
41
|
+
return m ? { passed: +m[2], failed: m[1] ? +m[1] : 0 } : null;
|
|
42
|
+
},
|
|
43
|
+
// vitest — `Tests 1 failed | 205 passed (206)`
|
|
44
|
+
(t) => {
|
|
45
|
+
const m = /Tests\s+(?:(\d+)\s+failed\s*\|\s*)?(\d+)\s+passed/.exec(t);
|
|
46
|
+
return m ? { passed: +m[2], failed: m[1] ? +m[1] : 0 } : null;
|
|
47
|
+
},
|
|
48
|
+
// pytest — `===== 3 failed, 12 passed in 1.20s =====`
|
|
49
|
+
(t) => {
|
|
50
|
+
const p = /(\d+)\s+passed/.exec(t);
|
|
51
|
+
const f = /(\d+)\s+failed/.exec(t);
|
|
52
|
+
return p || f ? { passed: p ? +p[1] : 0, failed: f ? +f[1] : 0 } : null;
|
|
53
|
+
},
|
|
54
|
+
// go test — 개별 결과 줄을 센다. 요약 줄이 없는 러너의 마지막 보루.
|
|
55
|
+
(t) => {
|
|
56
|
+
const p = (t.match(/^\s*--- PASS:/gm) ?? []).length;
|
|
57
|
+
const f = (t.match(/^\s*--- FAIL:/gm) ?? []).length;
|
|
58
|
+
return p + f > 0 ? { passed: p, failed: f } : null;
|
|
59
|
+
},
|
|
60
|
+
// TAP — `# pass 12` / `# fail 0`
|
|
61
|
+
(t) => {
|
|
62
|
+
const p = /^#\s*pass\s+(\d+)/m.exec(t);
|
|
63
|
+
const f = /^#\s*fail\s+(\d+)/m.exec(t);
|
|
64
|
+
return p ? { passed: +p[1], failed: f ? +f[1] : 0 } : null;
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 러너 출력에서 `{passed, failed}` 를 뽑는다. 못 읽으면 **null** — 0 이 아니다.
|
|
70
|
+
* 이 구분이 게이트 전체를 떠받친다. 0 을 돌려주면 "전부 사라졌다"는 회귀로 오독된다.
|
|
71
|
+
*/
|
|
72
|
+
export function parseMetrics(text) {
|
|
73
|
+
if (typeof text !== 'string' || !text) return null;
|
|
74
|
+
for (const extract of EXTRACTORS) {
|
|
75
|
+
let got;
|
|
76
|
+
try {
|
|
77
|
+
got = extract(text);
|
|
78
|
+
} catch {
|
|
79
|
+
continue; // 추출기 하나가 죽어도 다음으로 넘어간다 — 인프라는 fail-open.
|
|
80
|
+
}
|
|
81
|
+
if (got && Number.isFinite(got.passed) && Number.isFinite(got.failed)) return got;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 기준선 대비 후퇴한 차원의 이름들. 빈 배열이면 회귀 없음.
|
|
88
|
+
*
|
|
89
|
+
* **통과 수 감소만 회귀로 친다.** 실패 수 증가는 어차피 exit code 가 잡으므로 여기서
|
|
90
|
+
* 또 세면 같은 사실을 두 번 벌하는 셈이고, 무엇보다 "고치는 중이라 실패가 잠깐 는다"는
|
|
91
|
+
* 정상 경로를 막아버린다. 이 게이트가 겨냥하는 것은 정확히 하나다 —
|
|
92
|
+
* **초록불인데 측정 범위가 쪼그라든 경우.**
|
|
93
|
+
*/
|
|
94
|
+
export function regressions(base, candidate) {
|
|
95
|
+
if (!base || !candidate) return []; // 기준선 없음 = 절대 막지 않는다 (fail-safe 1)
|
|
96
|
+
const out = [];
|
|
97
|
+
if (candidate.passed < base.passed) out.push('passed');
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 다음 기준선. **무결성이 깨끗하고** 회귀가 없을 때만 올린다.
|
|
103
|
+
*
|
|
104
|
+
* `integrityOk` 조건이 빠지면 두 가드가 서로를 무력화한다(실측으로 재현했다):
|
|
105
|
+
* · 가짜 단언 60개를 주입한 회차는 무결성 위반으로 차단되지만, 출력은 `pass 260` 이고
|
|
106
|
+
* 회귀도 아니므로 기준선이 260 으로 오염된다.
|
|
107
|
+
* · 다음 회차에 에이전트가 지시대로 가짜를 걷어내면 `pass 200` 이 되고, 이번엔
|
|
108
|
+
* **비회귀 게이트가 그 정직한 수정을 차단**한다.
|
|
109
|
+
* 그러면 엔진이 받아들이는 유일한 상태가 "가짜를 유지한 상태"다 — 무결성 가드가 벌하는
|
|
110
|
+
* 바로 그 보상 해킹을 비회귀 가드가 보상하게 된다. 정직한 수정을 막는 게이트는 게이트가 아니다.
|
|
111
|
+
*/
|
|
112
|
+
export function nextBaseline(current, metrics, { integrityOk, regressed }) {
|
|
113
|
+
return metrics && integrityOk && !(regressed ?? []).length ? metrics : current;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 이번 회차가 직전보다 나아졌는가. 하나라도 좋아지면 참이다. */
|
|
117
|
+
export function isImproved(prev, curr) {
|
|
118
|
+
if (!prev || !curr) return false;
|
|
119
|
+
return curr.failed < prev.failed || curr.passed > prev.passed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 지표 벡터가 완전히 동일한가 = 이번 회차가 아무것도 바꾸지 못했다. */
|
|
123
|
+
export function isSameMetrics(prev, curr) {
|
|
124
|
+
if (!prev || !curr) return false;
|
|
125
|
+
return prev.passed === curr.passed && prev.failed === curr.failed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 회귀를 에이전트에게 들이밀 텍스트로. 차단은 대안과 함께 배송한다(bkit). */
|
|
129
|
+
export function regressionMessage(base, candidate, regressed) {
|
|
130
|
+
return [
|
|
131
|
+
'[hi-loop] 비회귀 게이트 위반 — 테스트는 초록불이지만 통과로 인정하지 않는다.',
|
|
132
|
+
`- 통과 수가 ${base.passed}개 → ${candidate.passed}개로 줄었다 (실패 ${base.failed} → ${candidate.failed}).`,
|
|
133
|
+
`- 후퇴한 차원: ${regressed.join(', ')}`,
|
|
134
|
+
'',
|
|
135
|
+
'초록불은 "테스트가 통과했다"는 뜻이지 "측정 범위가 그대로다"라는 뜻이 아니다.',
|
|
136
|
+
'테스트가 수집되지 않거나 조건부로 건너뛰어져 통과 수가 줄면 exit 0 이 나온다.',
|
|
137
|
+
'사라진 테스트가 다시 실행되도록 되돌린 뒤, 구현을 고쳐서 통과시켜라.',
|
|
138
|
+
'테스트를 정말 지워야 한다면 왜인지 근거를 남기고 같은 범위를 덮는 것으로 교체하라.',
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|