@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/outcome.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 실행의 끝 — `passed` / `failed` / `stopped` / `awaiting` 을 만드는 한 곳.
|
|
3
|
+
*
|
|
4
|
+
* loop.js 에서 뺀 이유는 크기(NFR-5)만이 아니다. **"통과했다"고 말할 권한**이 이 파일에만
|
|
5
|
+
* 있어야 한다. stage 가 늘 때마다 종료 지점이 늘고, 그중 하나라도 결속 검사(L15)를 빠뜨리면
|
|
6
|
+
* 그 경로가 곧 false green 의 문이 된다 — 실제로 `--start-from` 이 그랬다.
|
|
7
|
+
* 종료를 한 파일에 모아두면 새 stage 를 추가한 사람이 이 검사를 우회할 방법이 없다.
|
|
8
|
+
*/
|
|
9
|
+
import { saveState, reportGaps } from './state.js';
|
|
10
|
+
import { formatAsk } from './ask.js';
|
|
11
|
+
import { checkVerdictBinding } from './gates.js';
|
|
12
|
+
|
|
13
|
+
export function makeOutcome({ getState, setState, statePath, cwd, logger, notify, treeKeyReader }) {
|
|
14
|
+
/** 보고서를 찍고 결과 객체를 만든다. 모든 종료가 이 한 줄을 지난다. */
|
|
15
|
+
const emit = (status, saved) => {
|
|
16
|
+
const report = reportGaps(saved, { status });
|
|
17
|
+
logger(report);
|
|
18
|
+
return { status, iterations: saved.iteration, state: saved, report };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 완료 보고 — Gaps 공개 지점은 stage 가 늘어도 하나여야 한다.
|
|
23
|
+
*
|
|
24
|
+
* `passed` 만 특별 취급한다: **게이트를 통과한 그 트리**에 대해서만 통과를 주장할 수 있다.
|
|
25
|
+
* 이 검사가 없으면 BUILD 를 건너뛴 경로(`--start-from CODE_REVIEW`)가 테스트를 한 번도
|
|
26
|
+
* 돌리지 않고 passed 에 도달하고, 보고서는 "게이트를 모두 넘었다"고 인쇄한다 —
|
|
27
|
+
* false green 을 막으려 만든 공개 메커니즘이 false green 을 생산하게 된다.
|
|
28
|
+
*/
|
|
29
|
+
const finish = async (status, { stopReason = null, detail = '' } = {}) => {
|
|
30
|
+
const state = getState();
|
|
31
|
+
if (status === 'passed') {
|
|
32
|
+
const bound = checkVerdictBinding({
|
|
33
|
+
ledger: state.gates,
|
|
34
|
+
passedTreeKey: state.passedTreeKey,
|
|
35
|
+
currentTreeKey: await treeKeyReader({ cwd }),
|
|
36
|
+
});
|
|
37
|
+
if (!bound.ok) {
|
|
38
|
+
state.status = 'stopped';
|
|
39
|
+
state.stopReason = 'unverified';
|
|
40
|
+
logger(`[hi-loop] ⛔ 통과를 주장할 수 없습니다 — ${bound.reason}`);
|
|
41
|
+
return emit('stopped', saveState(statePath, state));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
state.status = status;
|
|
45
|
+
state.phase = 'DONE';
|
|
46
|
+
state.stage = 'DONE';
|
|
47
|
+
// 통과하면 이전 실행의 중단 사유를 지운다 — 안 지우면 `passed (budget)` 가 찍힌다.
|
|
48
|
+
state.stopReason = stopReason ?? (status === 'passed' ? null : (state.stopReason ?? null));
|
|
49
|
+
const saved = saveState(statePath, state);
|
|
50
|
+
await notify({ type: status === 'passed' ? 'passed' : 'failed', iteration: state.iteration, detail });
|
|
51
|
+
return emit(status, saved);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `--stop-after` 로 멈춘 결과. 실패가 아니다 — 시킨 것을 다 했을 뿐이다.
|
|
56
|
+
* status 를 passed 로 하면 "테스트가 통과했다"는 거짓말이 되므로 별도 값을 쓴다.
|
|
57
|
+
*/
|
|
58
|
+
const stopResult = (stage) => {
|
|
59
|
+
const state = getState();
|
|
60
|
+
state.status = 'stopped';
|
|
61
|
+
state.stopReason = `stop-after:${stage}`;
|
|
62
|
+
const saved = saveState(statePath, state);
|
|
63
|
+
logger(`[hi-loop] ⏹ ${stage} 까지 진행하고 멈췄습니다 (--stop-after).`);
|
|
64
|
+
return { status: 'stopped', iterations: state.iteration, state: saved, stoppedAt: stage };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** 사람의 답을 기다리며 멈춘 결과. */
|
|
68
|
+
const pauseResult = () => {
|
|
69
|
+
const state = getState();
|
|
70
|
+
const saved = saveState(statePath, state);
|
|
71
|
+
logger(formatAsk(state.ask));
|
|
72
|
+
return { status: 'awaiting', iterations: state.iteration, state: saved, ask: state.ask };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return { finish, stopResult, pauseResult, setState };
|
|
76
|
+
}
|
package/src/report.js
CHANGED
|
@@ -6,10 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { truncate } from './state.js';
|
|
8
8
|
import { formatAsk } from './ask.js';
|
|
9
|
+
import { shortKey } from './treekey.js';
|
|
10
|
+
import { evidenceLines, gapLines } from './gates.js';
|
|
11
|
+
import { blastLines } from './blast.js';
|
|
9
12
|
|
|
13
|
+
/** 실패 사유 코드를 사람이 읽는 Claim 으로. 모르는 코드는 그대로 노출한다(숨기지 않는다). */
|
|
14
|
+
const CLAIM_BY_REASON = {
|
|
15
|
+
maxLoops: '반복 상한을 소진했다. goal 은 달성되지 않았다.',
|
|
16
|
+
budget: '예산을 소진해 중단했다. goal 은 달성되지 않았다.',
|
|
17
|
+
stagnated: '같은 실패가 반복돼 중단했다. goal 은 달성되지 않았다.',
|
|
18
|
+
'recovery-lost': '복구 경로(체크포인트)가 사라져 중단했다. 되돌릴 수 없는 상태다.',
|
|
19
|
+
'review-exhausted': '코드 리뷰 지적을 상한 안에 해소하지 못했다. goal 은 달성되지 않았다.',
|
|
20
|
+
ship: '배포 명령이 실패했다. 테스트는 통과했으나 배포되지 않았다.',
|
|
21
|
+
watch: '배포 후 헬스체크가 실패했다.',
|
|
22
|
+
};
|
|
10
23
|
|
|
11
24
|
/**
|
|
12
|
-
* 완료 보고: 무엇을 관측했고 무엇을 안 했는가 (L10, moai 의 5-섹션 규약
|
|
25
|
+
* 완료 보고: 무엇을 관측했고 무엇을 안 했는가 (L10, moai 의 5-섹션 규약).
|
|
13
26
|
*
|
|
14
27
|
* 이 엔진의 `passed` 는 조용한 거짓말이 될 수 있다 — 테스트를 PLAN 단계에서 에이전트
|
|
15
28
|
* 자신이 썼기 때문이다. exit 0 의 실제 의미는 "에이전트가 자기가 쓴 테스트를 자기가
|
|
@@ -18,16 +31,32 @@ import { formatAsk } from './ask.js';
|
|
|
18
31
|
* moai: "빈 Gaps 섹션은 '관측하지 않은 것이 없다'는 강한 주장이며, 그 주장 자체가
|
|
19
32
|
* 참이어야 한다." 그래서 숨기는 대신 매 완료에 공개한다. false green 을 disclosed green 으로.
|
|
20
33
|
*/
|
|
21
|
-
export function reportGaps(state) {
|
|
34
|
+
export function reportGaps(state, { status = 'passed' } = {}) {
|
|
22
35
|
const lines = ['', '## 검증 현황 (hi-loop 는 자기 판정의 한계를 숨기지 않는다)'];
|
|
23
36
|
|
|
24
37
|
const deleted = state.deletedFiles ?? [];
|
|
38
|
+
const ok = status === 'passed';
|
|
39
|
+
|
|
40
|
+
// Claim — 이 실행이 실제로 주장하는 것. moai `evaluate.go:38-46` 의 5섹션 중 첫 칸이다.
|
|
41
|
+
// 실패 보고에서 이 줄이 없으면 나머지 네 섹션이 무엇에 대한 것인지 알 수 없다.
|
|
42
|
+
lines.push('주장(Claim):');
|
|
43
|
+
lines.push(
|
|
44
|
+
ok
|
|
45
|
+
? `- \`${state.testCommand}\` 가 ${state.iteration}회차에 통과했고 무결성·비회귀 게이트를 모두 넘었다.`
|
|
46
|
+
: `- ${CLAIM_BY_REASON[state.stopReason] ?? `중단됨(${state.stopReason ?? '사유 미기록'}). goal 은 달성되지 않았다.`}`,
|
|
47
|
+
);
|
|
25
48
|
|
|
26
49
|
lines.push('관측한 것(Evidence):');
|
|
27
|
-
|
|
28
|
-
|
|
50
|
+
// Evidence 는 **원장에서만** 나온다(L14). 문자열을 여기서 지으면 돌지 않은 게이트가
|
|
51
|
+
// 관측 결과로 발행되는 길이 다시 열린다 — 비-git 에서 "유령 테스트 없음"을 주장하던
|
|
52
|
+
// 것이 정확히 그 사고였다. 원장에 없는 게이트는 Evidence 를 만들 수 없다.
|
|
53
|
+
const evidence = evidenceLines(state.gates);
|
|
54
|
+
if (evidence.length) lines.push(...evidence);
|
|
55
|
+
else lines.push('- (평가된 게이트가 없다)');
|
|
56
|
+
if (state.metricBaseline) {
|
|
57
|
+
lines.push(`- 기준선: 통과 ${state.metricBaseline.passed}건 / 실패 ${state.metricBaseline.failed}건`);
|
|
58
|
+
}
|
|
29
59
|
lines.push(`- 누적 비용 $${(state.costUsd ?? 0).toFixed(2)}`);
|
|
30
|
-
if (state.specVerified) lines.push('- 스펙 대비 구현을 별도 검증자가 심판(2단 판정 통과)');
|
|
31
60
|
if (state.ship) lines.push(`- 배포 명령 \`${state.ship.command}\` 종료 코드 ${state.ship.code}`);
|
|
32
61
|
if (state.watch) {
|
|
33
62
|
lines.push(`- 배포 후 헬스체크 ${state.watch.checks}회 (실패 ${state.watch.failures}회)`);
|
|
@@ -39,10 +68,21 @@ export function reportGaps(state) {
|
|
|
39
68
|
}
|
|
40
69
|
}
|
|
41
70
|
|
|
71
|
+
// Baseline-attribution — 위 Evidence 가 **무엇을 측정한 것인지** 못 박는다.
|
|
72
|
+
// 이게 없으면 "테스트가 통과했다"는 어느 시점 어느 트리에 대한 주장인지 알 수 없고,
|
|
73
|
+
// 보고서를 읽는 시점에 워킹트리가 이미 달라져 있어도 아무도 모른다.
|
|
74
|
+
lines.push('무엇을 기준으로 쟀는가(Baseline-attribution):');
|
|
75
|
+
lines.push(`- 워킹트리 ${shortKey(state.treeKey)} (git 트리 SHA — 이 내용에 대한 판정이다)`);
|
|
76
|
+
lines.push(`- 명령 \`${state.testCommand}\` / iteration ${state.iteration}/${state.maxLoops} / 시각 ${state.updatedAt ?? '기록 없음'}`);
|
|
77
|
+
|
|
42
78
|
lines.push('관측하지 않은 것(Gaps):');
|
|
79
|
+
// 돌지 않은 게이트·관측 불가한 게이트를 여기서 전부 밝힌다. "검사 안 함"과 "통과"가
|
|
80
|
+
// 같은 침묵으로 보이면 안 된다.
|
|
81
|
+
lines.push(...gapLines(state.gates));
|
|
43
82
|
lines.push(`- 이 테스트는 PLAN 단계에서 에이전트 자신이 \`${state.testPath || 'tests/app.test.js'}\`에 작성했다. 외부 오라클이 아니다.`);
|
|
44
|
-
if (!
|
|
45
|
-
|
|
83
|
+
if (!ok) {
|
|
84
|
+
// 실패 보고에서 가장 알아야 할 것 — 워킹트리에 무엇이 남아 있는가.
|
|
85
|
+
lines.push('- 중단 시점의 워킹트리에는 에이전트가 적용한 수정이 그대로 남아 있다. 어디까지 맞는지는 검증하지 않았다.');
|
|
46
86
|
}
|
|
47
87
|
if (!state.watch) lines.push('- 런타임 동작·성능·보안은 관측 범위 밖이다.');
|
|
48
88
|
// 리뷰 상한을 소진하고 남은 지적은 숨기지 않는다. 이게 disclosed green 의 핵심이다 —
|
|
@@ -56,10 +96,36 @@ export function reportGaps(state) {
|
|
|
56
96
|
if (state.assumptions?.some((a) => a.decidedBy === 'agent')) {
|
|
57
97
|
lines.push('- 위 가정 중 에이전트가 스스로 세운 것은 사람이 확인하지 않았다.');
|
|
58
98
|
}
|
|
99
|
+
lines.push(...blastLines(state.blastClasses));
|
|
100
|
+
if (state.unwiredFiles?.length) {
|
|
101
|
+
lines.push(
|
|
102
|
+
`- ⚠️ 이번 루프가 만든 파일 ${state.unwiredFiles.length}개가 제품 경로에서 호출되지 않는다(테스트만 부른다): ${state.unwiredFiles.slice(0, 5).join(', ')}. 테스트는 통과했지만 기능이 배선되지 않았을 수 있다.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
59
105
|
if (deleted.length) {
|
|
60
106
|
lines.push(`- ⚠️ 에이전트가 baseline 파일 ${deleted.length}개를 삭제했다(차단 안 함): ${deleted.slice(0, 5).join(', ')}${deleted.length > 5 ? ' …' : ''}. hi-loop rollback 으로 복원 가능.`);
|
|
61
107
|
}
|
|
62
108
|
|
|
109
|
+
// Residual-risk — Gaps 와 다르다. Gaps 는 "관측하지 않은 것", 이쪽은
|
|
110
|
+
// **관측했는데도 여전히 틀릴 수 있는 것**이다(moai `verification-claim-integrity.md:62`).
|
|
111
|
+
// 둘을 한 섹션에 합치면 "봤지만 못 믿는다"가 "안 봤다"에 묻혀 사라진다.
|
|
112
|
+
if (state.recoveryWarning) {
|
|
113
|
+
lines.push(`- 🧨 복구 경로 이상: ${state.recoveryWarning}`);
|
|
114
|
+
}
|
|
115
|
+
if (state.sealBroken) {
|
|
116
|
+
lines.push('- ⚠️ 상태 파일의 봉인이 깨져 있었다(L16). 무결성·비회귀 기준선과 체크포인트를 초기화하고 다시 세웠다 — 이번 실행 이전의 관측은 신뢰할 수 없다.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push('그럼에도 틀릴 수 있는 것(Residual-risk):');
|
|
120
|
+
lines.push('- 테스트가 플레이키하면 이 판정은 재현되지 않는다. 같은 트리에서 한 번만 쟀다.');
|
|
121
|
+
if ((state.checkpoints ?? []).length) {
|
|
122
|
+
lines.push('- 롤백은 **추적 파일만** 되돌린다. 에이전트가 새로 만든 추적 밖 파일은 남는다(지우면 사용자의 정상 파일까지 지울 위험이 있어 판단하지 않는다).');
|
|
123
|
+
lines.push(`- 체크포인트 ${state.checkpoints.length}개가 stash 객체로만 남아 있다. GC 되면 rollback 대상이 사라진다.`);
|
|
124
|
+
}
|
|
125
|
+
if (state.sessionSerial > 1) {
|
|
126
|
+
lines.push(`- 세션이 ${state.sessionSerial}번 교체됐다. 이전 세션의 맥락 중 요약에 실리지 않은 것은 판단에 반영되지 않았다.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
63
129
|
return lines.join('\n');
|
|
64
130
|
}
|
|
65
131
|
|
package/src/resume.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 재개 규칙 (FR-3.5) — 디스크의 과거를 이번 실행에 이어 붙일 때 **무엇까지 믿을 것인가**.
|
|
3
|
+
*
|
|
4
|
+
* state.js 에서 뺀 이유는 크기(NFR-5)만이 아니다. 이 파일이 다루는 것은 신뢰 경계다.
|
|
5
|
+
* `.agent-state.json` 은 gitignore 돼 있고 에이전트의 쓰기 범위 안에 있다 — 게이트를 여는
|
|
6
|
+
* 값들이 에이전트가 만질 수 있는 파일에서 온다. 그 값을 어디까지 믿을지 정하는 규칙을
|
|
7
|
+
* 저장·직렬화와 섞어두면 필드가 하나 늘 때 검증을 빠뜨리기 쉽다.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { STAGES, createState, planPathsFor, loadState, V2_DEFAULTS } from './state.js';
|
|
11
|
+
import { loadOrCreateKey, verifySeal } from './seal.js';
|
|
12
|
+
|
|
13
|
+
/** 게이트를 여는 값은 모양이 맞을 때만 살린다. 아니면 "기준선 없음"으로 되돌린다. */
|
|
14
|
+
function validBaseline(v) {
|
|
15
|
+
return v && Number.isFinite(v.passed) && Number.isFinite(v.failed) ? { passed: v.passed, failed: v.failed } : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 통과한 실행에서 **다음 실행으로 이월할 것**.
|
|
20
|
+
*
|
|
21
|
+
* `hi-loop run` 뒤에 `hi-loop ship` 을 하면 `prev.status === 'passed'` 라 아래 분기가
|
|
22
|
+
* 새 상태를 만들어 덮어썼다. 그 순간 체크포인트(L3)·삭제 관측(L8)·통과 트리(L15)가 전부
|
|
23
|
+
* 사라진다 — **롤백이 가장 필요한 배포 직후에 되돌릴 대상이 없어진다.** 그래서 회차 카운터나
|
|
24
|
+
* 에러 지문 같은 "이번 실행의 것"은 버리되, 복구·귀속에 필요한 것만 넘긴다.
|
|
25
|
+
*/
|
|
26
|
+
function carryOver(prev) {
|
|
27
|
+
return {
|
|
28
|
+
checkpoints: Array.isArray(prev.checkpoints) ? prev.checkpoints : [],
|
|
29
|
+
deletedFiles: Array.isArray(prev.deletedFiles) ? prev.deletedFiles : [],
|
|
30
|
+
treeKey: typeof prev.treeKey === 'string' ? prev.treeKey : null,
|
|
31
|
+
passedTreeKey: typeof prev.passedTreeKey === 'string' ? prev.passedTreeKey : null,
|
|
32
|
+
gates: prev.gates && typeof prev.gates === 'object' ? prev.gates : {},
|
|
33
|
+
costUsd: Number.isFinite(prev.costUsd) ? prev.costUsd : 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd }) {
|
|
38
|
+
const raw = existsSync(statePath) ? loadState(statePath) : null;
|
|
39
|
+
// L16: 디스크의 상태는 신뢰 경계 밖이다. 봉인이 깨졌으면 게이트를 여는 필드만
|
|
40
|
+
// 안전값으로 되돌린다 — goal·회차·비용 같은 나머지는 판정을 열지 않으므로 그대로 둔다.
|
|
41
|
+
const { state: prev, broken } = verifySeal(raw, loadOrCreateKey());
|
|
42
|
+
if (broken) {
|
|
43
|
+
// 조용히 넘어가면 "기준선이 왜 사라졌지"가 미스터리가 된다. 상태에 남겨 보고서가 밝힌다.
|
|
44
|
+
prev.sealBroken = true;
|
|
45
|
+
}
|
|
46
|
+
if (prev && prev.goal === goal && prev.status === 'passed') {
|
|
47
|
+
return { ...createState({ goal, testCommand, maxLoops, cwd }), ...carryOver(prev) };
|
|
48
|
+
}
|
|
49
|
+
if (prev && prev.goal === goal && prev.status !== 'passed') {
|
|
50
|
+
// 필드가 없던 시절의 상태 파일도 이어받는다. 경로는 절대 다시 계산하지 않는다 —
|
|
51
|
+
// PLAN 이 이미 만든 파일 때문에 경로가 밀려나기 때문이다.
|
|
52
|
+
const paths = prev.specPath && prev.testPath ? {} : planPathsFor({ cwd, goal });
|
|
53
|
+
return {
|
|
54
|
+
...paths,
|
|
55
|
+
...prev,
|
|
56
|
+
specPath: prev.specPath ?? paths.specPath,
|
|
57
|
+
testPath: prev.testPath ?? paths.testPath,
|
|
58
|
+
costUsd: Number.isFinite(prev.costUsd) ? prev.costUsd : 0,
|
|
59
|
+
// 기준선이 없던 시절의 상태로 재개하면 이번 회차 지문이 곧 기준선이 된다.
|
|
60
|
+
// 그 사이에 벌어진 약화는 못 잡는다 — resume 은 과거를 복원하지 못한다.
|
|
61
|
+
testFingerprint: prev.testFingerprint ?? {},
|
|
62
|
+
// 게이트를 여는 필드는 **모양까지 검증**한다. 상태 파일은 에이전트의 쓰기 범위 안에
|
|
63
|
+
// 있고 gitignore 돼 있어서, `{"passed": null}` 한 줄이면 `candidate.passed < null` 이
|
|
64
|
+
// 영원히 false 가 되어 비회귀 게이트가 조용히 죽는다. 모양이 어긋나면 기준선 없음으로
|
|
65
|
+
// 되돌린다 — fail-safe 1(기준선 없으면 절대 차단 안 함)이 안전한 쪽이다.
|
|
66
|
+
metricBaseline: validBaseline(prev.metricBaseline),
|
|
67
|
+
metrics: validBaseline(prev.metrics),
|
|
68
|
+
treeKey: typeof prev.treeKey === 'string' ? prev.treeKey : null,
|
|
69
|
+
passedTreeKey: typeof prev.passedTreeKey === 'string' ? prev.passedTreeKey : null,
|
|
70
|
+
gates: prev.gates && typeof prev.gates === 'object' ? prev.gates : {},
|
|
71
|
+
errorSig: prev.errorSig ?? '',
|
|
72
|
+
stagnantRuns: Number.isFinite(prev.stagnantRuns) ? prev.stagnantRuns : 0,
|
|
73
|
+
checkpoints: Array.isArray(prev.checkpoints) ? prev.checkpoints : [],
|
|
74
|
+
deletedFiles: Array.isArray(prev.deletedFiles) ? prev.deletedFiles : [],
|
|
75
|
+
stage: STAGES.includes(prev.stage) ? prev.stage : V2_DEFAULTS.stage,
|
|
76
|
+
assumptions: Array.isArray(prev.assumptions) ? prev.assumptions : [],
|
|
77
|
+
ask: prev.ask ?? null,
|
|
78
|
+
answers: Array.isArray(prev.answers) ? prev.answers : [],
|
|
79
|
+
designRounds: Number.isFinite(prev.designRounds) ? prev.designRounds : 0,
|
|
80
|
+
reviewRounds: Number.isFinite(prev.reviewRounds) ? prev.reviewRounds : 0,
|
|
81
|
+
reviewFindings: Array.isArray(prev.reviewFindings) ? prev.reviewFindings : [],
|
|
82
|
+
discovery: prev.discovery ?? null,
|
|
83
|
+
ship: prev.ship ?? null,
|
|
84
|
+
watch: prev.watch ?? null,
|
|
85
|
+
testCommand,
|
|
86
|
+
maxLoops,
|
|
87
|
+
// 답을 안 받은 질문이 남아 있으면 재개해선 안 된다. 그냥 running 으로 덮으면
|
|
88
|
+
// 사람이 고르라던 분기를 엔진이 조용히 스스로 골라 지나간다 — ask 의 존재 이유가 사라진다.
|
|
89
|
+
status: prev.ask ? 'awaiting' : 'running',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return createState({ goal, testCommand, maxLoops, cwd });
|
|
93
|
+
}
|
package/src/seal.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상태 봉인 (L16) — 게이트를 여는 값이 에이전트의 쓰기 범위 안에 있는 문제.
|
|
3
|
+
*
|
|
4
|
+
* `.agent-state.json` 은 cwd 에 있고 gitignore 돼 있으며, 에이전트는 그 cwd 에서
|
|
5
|
+
* `acceptEdits` 권한으로 돈다. 즉 **게이트의 기준선이 피고인이 편집할 수 있는 파일에 산다.**
|
|
6
|
+
* 한 줄이면 된다:
|
|
7
|
+
*
|
|
8
|
+
* "metricBaseline": {"passed": null, "failed": 0}
|
|
9
|
+
*
|
|
10
|
+
* `candidate.passed < null` 은 영원히 false 라 비회귀 게이트(L11)가 조용히 죽는다. 모양 검증
|
|
11
|
+
* (resume.js)이 이 특정 형태는 막지만, `{"passed": 0}` 처럼 **모양이 멀쩡한 거짓말**은 못 막는다.
|
|
12
|
+
* 기준선을 0 으로 낮춰두면 어떤 축소도 회귀가 아니게 된다.
|
|
13
|
+
*
|
|
14
|
+
* 설계: 게이트를 여는 필드만 골라 HMAC 을 뜬다. 키는 **cwd 밖**(홈 디렉터리)에 둔다 — 그래야
|
|
15
|
+
* 에이전트가 값을 고치고 봉인을 다시 계산하는 짓을 할 수 없다. 불일치면 그 필드들을
|
|
16
|
+
* "기준선 없음"으로 되돌린다(fail-safe 1). 지우는 게 아니라 **되돌리는** 이유는, 무결성이
|
|
17
|
+
* 깨진 기준선을 그대로 믿느니 처음부터 다시 세우는 쪽이 항상 안전하기 때문이다.
|
|
18
|
+
*
|
|
19
|
+
* 인프라는 fail-open 이다: 키를 만들 수도 읽을 수도 없으면 봉인을 건너뛰고 그 사실을 보고서에
|
|
20
|
+
* 싣는다. 홈 디렉터리가 읽기 전용이라고 루프가 죽으면 안 된다.
|
|
21
|
+
*/
|
|
22
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
/** 봉인 대상 — 이 값들이 판정을 연다. 여기 없는 필드는 봉인하지 않는다. */
|
|
28
|
+
export const SEALED_FIELDS = ['testFingerprint', 'metricBaseline', 'passedTreeKey', 'gates', 'checkpoints'];
|
|
29
|
+
|
|
30
|
+
/** 봉인이 깨졌을 때 되돌릴 값. fail-safe 1(기준선 없음 = 절대 차단 안 함)과 같은 방향이다. */
|
|
31
|
+
const SAFE_DEFAULTS = {
|
|
32
|
+
testFingerprint: {},
|
|
33
|
+
metricBaseline: null,
|
|
34
|
+
passedTreeKey: null,
|
|
35
|
+
gates: {},
|
|
36
|
+
checkpoints: [],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function keyPathFor(home = homedir()) {
|
|
40
|
+
return join(home, '.hi-loop', 'state-key');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 봉인 키. 없으면 만든다. 만들 수 없으면 null — 호출부는 봉인을 건너뛴다.
|
|
45
|
+
* cwd 밖에 두는 것이 이 함수의 존재 이유다. cwd 안에 두면 봉인이 자물쇠와 열쇠를
|
|
46
|
+
* 같은 서랍에 넣는 꼴이 된다.
|
|
47
|
+
*/
|
|
48
|
+
export function loadOrCreateKey(path = keyPathFor()) {
|
|
49
|
+
try {
|
|
50
|
+
if (existsSync(path)) {
|
|
51
|
+
const k = readFileSync(path, 'utf8').trim();
|
|
52
|
+
if (k.length >= 32) return k;
|
|
53
|
+
}
|
|
54
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
55
|
+
const k = randomBytes(32).toString('hex');
|
|
56
|
+
writeFileSync(path, `${k}\n`, { mode: 0o600 });
|
|
57
|
+
return k;
|
|
58
|
+
} catch {
|
|
59
|
+
return null; // 인프라 실패 — 봉인 없이 진행하고 보고서에 밝힌다.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 봉인 값. 필드 순서를 고정해서 같은 내용이면 항상 같은 값이 나오게 한다. */
|
|
64
|
+
export function sealOf(state, key) {
|
|
65
|
+
if (!key) return null;
|
|
66
|
+
const payload = JSON.stringify(SEALED_FIELDS.map((f) => [f, state?.[f] ?? null]));
|
|
67
|
+
return createHmac('sha256', key).update(payload).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 디스크에서 읽은 상태를 검사한다.
|
|
72
|
+
*
|
|
73
|
+
* 반환: `{ state, broken, checked }`
|
|
74
|
+
* · checked=false — 키가 없어 검사 자체를 못 했다(관측 불가). 되돌리지 않는다.
|
|
75
|
+
* · broken=true — 봉인 불일치. 봉인 대상 필드를 안전값으로 되돌린 state 를 돌려준다.
|
|
76
|
+
*
|
|
77
|
+
* 봉인이 **아예 없는** 상태(구버전 파일, 첫 실행)는 깨진 것으로 치지 않는다. 그러면
|
|
78
|
+
* 업그레이드한 사용자 전원의 기준선이 한 번씩 날아간다 — 그건 방어가 아니라 고장이다.
|
|
79
|
+
*/
|
|
80
|
+
export function verifySeal(state, key) {
|
|
81
|
+
if (!key || !state) return { state, broken: false, checked: false };
|
|
82
|
+
if (typeof state.seal !== 'string') return { state, broken: false, checked: false };
|
|
83
|
+
if (sealOf(state, key) === state.seal) return { state, broken: false, checked: true };
|
|
84
|
+
return { state: { ...state, ...SAFE_DEFAULTS, sealBroken: true }, broken: true, checked: true };
|
|
85
|
+
}
|
package/src/stages.js
CHANGED
|
@@ -95,6 +95,9 @@ export async function runCodeReviewStage(ctx) {
|
|
|
95
95
|
// 상한 소진: 막지 않고 **공개**한다. 여기서 실패로 처리하면 통과한 테스트를
|
|
96
96
|
// 모델 심판이 뒤집는 셈이라 하향 전용 원칙(L9)을 넘어선다.
|
|
97
97
|
logger(`[hi-loop] ⚠️ 리뷰 상한(${opts.maxReviewRounds}회) 소진 — 미해결 지적 ${findings.length}건을 Gaps 에 공개합니다.`);
|
|
98
|
+
// 상한 소진은 "리뷰가 통과했다"가 아니다. 사유를 남기지 않으면 뒤이어 DONE 으로 가서
|
|
99
|
+
// 보고서 Claim 이 "게이트를 모두 넘었다"고 인쇄한다 — 미해결 지적을 안고 통과라고 말하는 셈.
|
|
100
|
+
ctx.state.stopReason = 'review-exhausted';
|
|
98
101
|
} else {
|
|
99
102
|
ctx.state.reviewFindings = [];
|
|
100
103
|
logger('[hi-loop] ✅ 코드 리뷰 통과.');
|
|
@@ -102,9 +105,43 @@ export async function runCodeReviewStage(ctx) {
|
|
|
102
105
|
return { next: ctx.nextAfterReview() };
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
/**
|
|
109
|
+
* COMMIT (FR-16) — 테스트·리뷰를 통과한 코드를 로컬에 커밋한다. 에이전트를 부르지 않는다(비용 0).
|
|
110
|
+
*
|
|
111
|
+
* 헤드리스 opt-in(`--commit`)이므로 여기서 게이트를 걸지 않는다 — 플래그 자체가 동의다.
|
|
112
|
+
* 대화형 확인(diff 보고 승인)은 스킬 층이 LLM 레벨에서 담당한다(역할 분리).
|
|
113
|
+
* 커밋은 되돌릴 수 있으므로(로컬), 변경이 없거나 비-git 이면 실패로 만들지 않고 조용히 지나간다.
|
|
114
|
+
*/
|
|
115
|
+
export async function runCommitStage(ctx) {
|
|
116
|
+
const { goal, cwd, logger, ports, opts } = ctx;
|
|
117
|
+
ctx.state.status = 'running';
|
|
118
|
+
ctx.save();
|
|
119
|
+
|
|
120
|
+
const res = await ports.committer({
|
|
121
|
+
cwd,
|
|
122
|
+
goal,
|
|
123
|
+
message: opts.commitMessage ?? null,
|
|
124
|
+
styleConfig: opts.commitStyle ?? 'match-log',
|
|
125
|
+
logger,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (res.committed) {
|
|
129
|
+
ctx.state.commit = { sha: res.sha, message: res.message, at: new Date().toISOString() };
|
|
130
|
+
ctx.save();
|
|
131
|
+
} else if (res.reason && res.reason !== 'non-git') {
|
|
132
|
+
// 변경 없음 등은 실패가 아니다 — 이유만 남기고 진행한다.
|
|
133
|
+
logger(`[hi-loop] · 커밋하지 않았습니다 — ${res.reason}`);
|
|
134
|
+
}
|
|
135
|
+
return { next: ctx.nextAfterCommit() };
|
|
136
|
+
}
|
|
137
|
+
|
|
105
138
|
/** SHIP (FR-13) — 판정은 exit code 뿐. 에이전트를 부르지 않으므로 비용은 0이다. */
|
|
106
139
|
export async function runShipStage(ctx) {
|
|
107
140
|
const { cwd, logger, ports, opts, gate, notify } = ctx;
|
|
141
|
+
// 이전 실행이 SHIP 에서 멈춘 상태로 저장돼 있으면, --ship 없이 재개해도 여기로 들어온다.
|
|
142
|
+
// 그때 command 는 null 이고 spawn(null) 은 동기 예외를 던져 루프 전체가 죽었다.
|
|
143
|
+
// 명령이 없는 stage 는 크래시가 아니라 **무작동**이다.
|
|
144
|
+
if (!opts.shipCommand) return { next: 'DONE' };
|
|
108
145
|
|
|
109
146
|
// FR-13.4: 비가역 외부 행위다. 기본 무중단의 유일한 예외로 항상 확인한다.
|
|
110
147
|
const g = gate({
|
|
@@ -116,9 +153,15 @@ export async function runShipStage(ctx) {
|
|
|
116
153
|
],
|
|
117
154
|
});
|
|
118
155
|
if (g.pause) return { pause: true };
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
156
|
+
// **명시적 승인만 진행으로 친다.** 예전에는 `choice === 'b'` 만 중단이라 두 경로가
|
|
157
|
+
// 확인 없이 배포로 샜다: (a) `--ask never` 는 choice=null 을 돌려주고, (b) 답변을
|
|
158
|
+
// note 만 달고 보내면 choice='' 다. 즉 "배포하지 마"라고 적어 보내도 배포됐다.
|
|
159
|
+
// 비가역 행위에서 확인의 **부재**를 승인으로 읽는 것은 fail-open 의 오용이다 —
|
|
160
|
+
// 인프라가 아니라 사람의 결정을 대신 내리는 것이기 때문이다.
|
|
161
|
+
if (g.choice !== 'a') {
|
|
162
|
+
const why = g.choice ? `사용자 선택(${g.choice})` : '명시적 승인이 없었습니다(--yes 또는 answer 로 진행)';
|
|
163
|
+
logger(`[hi-loop] 🛑 배포하지 않았습니다 — ${why}.`);
|
|
164
|
+
return { finish: { stopReason: 'ship-declined', detail: `배포 미실행: ${why}` } };
|
|
122
165
|
}
|
|
123
166
|
|
|
124
167
|
ctx.state.status = 'running';
|
|
@@ -210,6 +253,7 @@ export async function runWatchStage(ctx) {
|
|
|
210
253
|
export const STAGE_HANDLERS = {
|
|
211
254
|
DISCOVER: runDiscoverStage,
|
|
212
255
|
CODE_REVIEW: runCodeReviewStage,
|
|
256
|
+
COMMIT: runCommitStage,
|
|
213
257
|
SHIP: runShipStage,
|
|
214
258
|
WATCH: runWatchStage,
|
|
215
259
|
};
|
package/src/state.js
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
import { readFileSync, writeFileSync, renameSync, existsSync, rmSync } from 'node:fs';
|
|
7
7
|
import { join, dirname } from 'node:path';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
+
import { loadOrCreateKey, sealOf } from './seal.js';
|
|
9
10
|
|
|
10
11
|
// 사람이 읽는 보고(Gaps 공개·상태 요약)는 report.js 로 뺐다. 기존 import 경로를
|
|
11
12
|
// 깨지 않도록 여기서 다시 내보낸다 — 호출부가 어느 파일에서 오는지 알 필요는 없다.
|
|
12
13
|
export { reportGaps, summarizeState } from './report.js';
|
|
14
|
+
// 재개 규칙은 resume.js 로 뺐다. 호출부가 파일 배치를 알 필요는 없으므로 여기서 다시 내보낸다.
|
|
15
|
+
export { resumeOrCreate } from './resume.js';
|
|
13
16
|
|
|
14
17
|
export const STATE_VERSION = 2;
|
|
15
18
|
export const STATE_FILENAME = '.agent-state.json';
|
|
@@ -126,10 +129,17 @@ export function createState({
|
|
|
126
129
|
lastError: '',
|
|
127
130
|
costUsd: 0,
|
|
128
131
|
testFingerprint: {}, // L1 가드의 기준선. 빈 값 = 아직 비교할 대상 없음.
|
|
132
|
+
metricBaseline: null, // L11 비회귀 기준선 {passed, failed}. null = 기준선 없음(절대 차단 안 함).
|
|
133
|
+
metrics: null, // L11 직전 회차 지표 벡터. 정체 판정의 두 번째 신호.
|
|
134
|
+
treeKey: null, // L12 마지막으로 관측한 워킹트리 트리 SHA. null = 비-git.
|
|
135
|
+
passedTreeKey: null, // L15 게이트를 통과한 그 트리. 이것과 현재 트리가 다르면 passed 를 주장하지 않는다.
|
|
136
|
+
gates: {}, // L14 게이트 원장. 보고서 Evidence 는 여기서만 나온다.
|
|
129
137
|
errorSig: '', // L2: 직전 실패의 안정적 지문. 정체 감지용.
|
|
130
138
|
stagnantRuns: 0, // L2: errorSig 가 연속 동일한 횟수.
|
|
131
139
|
checkpoints: [], // L3: [{iteration, sha}] git stash create 스냅샷.
|
|
132
140
|
deletedFiles: [], // L8: 에이전트가 지운 baseline 파일(관측용, 누적).
|
|
141
|
+
wasGitRepo: null, // L21: 루프 시작 시점의 git 관측. 이후 사라지면 복구 경로 손실이다.
|
|
142
|
+
unwiredFiles: [], // L19: 만들었지만 제품 경로에서 안 불리는 파일(관측용, 누적).
|
|
133
143
|
assumptions: [], // FR-10.2: DISCOVER/ask 가 확정한 가정. 기록 안 된 가정은 없어야 한다.
|
|
134
144
|
ask: null, // FR-10.3/13.4: 대기 중인 질문. 있으면 status 는 'awaiting'.
|
|
135
145
|
answers: [], // 사람이 고른 이력 [{id, choice, note, at}].
|
|
@@ -146,8 +156,13 @@ export function createState({
|
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
/** v1 상태의 신규 필드 기본값. 한 곳에만 두어 createState/migrate/resume 이 갈라지지 않게 한다. */
|
|
149
|
-
const V2_DEFAULTS = {
|
|
159
|
+
export const V2_DEFAULTS = {
|
|
150
160
|
stage: 'BUILD',
|
|
161
|
+
metricBaseline: null,
|
|
162
|
+
metrics: null,
|
|
163
|
+
treeKey: null,
|
|
164
|
+
passedTreeKey: null,
|
|
165
|
+
gates: {},
|
|
151
166
|
assumptions: [],
|
|
152
167
|
ask: null,
|
|
153
168
|
answers: [],
|
|
@@ -186,44 +201,6 @@ export function migrateState(prev) {
|
|
|
186
201
|
* 동일 goal 이면 auto-resume, 아니면 새 상태 (FR-3.5).
|
|
187
202
|
* resume 은 상태 로직이므로 state.js 가 제자리다 — loadState/createState/planPathsFor 를 여기서 쓴다.
|
|
188
203
|
*/
|
|
189
|
-
export function resumeOrCreate({ statePath, goal, testCommand, maxLoops, cwd }) {
|
|
190
|
-
const prev = existsSync(statePath) ? loadState(statePath) : null;
|
|
191
|
-
if (prev && prev.goal === goal && prev.status !== 'passed') {
|
|
192
|
-
// 필드가 없던 시절의 상태 파일도 이어받는다. 경로는 절대 다시 계산하지 않는다 —
|
|
193
|
-
// PLAN 이 이미 만든 파일 때문에 경로가 밀려나기 때문이다.
|
|
194
|
-
const paths = prev.specPath && prev.testPath ? {} : planPathsFor({ cwd, goal });
|
|
195
|
-
return {
|
|
196
|
-
...paths,
|
|
197
|
-
...prev,
|
|
198
|
-
specPath: prev.specPath ?? paths.specPath,
|
|
199
|
-
testPath: prev.testPath ?? paths.testPath,
|
|
200
|
-
costUsd: Number.isFinite(prev.costUsd) ? prev.costUsd : 0,
|
|
201
|
-
// 기준선이 없던 시절의 상태로 재개하면 이번 회차 지문이 곧 기준선이 된다.
|
|
202
|
-
// 그 사이에 벌어진 약화는 못 잡는다 — resume 은 과거를 복원하지 못한다.
|
|
203
|
-
testFingerprint: prev.testFingerprint ?? {},
|
|
204
|
-
errorSig: prev.errorSig ?? '',
|
|
205
|
-
stagnantRuns: Number.isFinite(prev.stagnantRuns) ? prev.stagnantRuns : 0,
|
|
206
|
-
checkpoints: Array.isArray(prev.checkpoints) ? prev.checkpoints : [],
|
|
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,
|
|
218
|
-
testCommand,
|
|
219
|
-
maxLoops,
|
|
220
|
-
// 답을 안 받은 질문이 남아 있으면 재개해선 안 된다. 그냥 running 으로 덮으면
|
|
221
|
-
// 사람이 고르라던 분기를 엔진이 조용히 스스로 골라 지나간다 — ask 의 존재 이유가 사라진다.
|
|
222
|
-
status: prev.ask ? 'awaiting' : 'running',
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
return createState({ goal, testCommand, maxLoops, cwd });
|
|
226
|
-
}
|
|
227
204
|
|
|
228
205
|
export function loadState(path) {
|
|
229
206
|
if (!existsSync(path)) return null;
|
|
@@ -259,6 +236,11 @@ export function saveState(path, state, { now = new Date().toISOString() } = {})
|
|
|
259
236
|
answers: (state.answers ?? []).slice(-LIMITS.answersKeep),
|
|
260
237
|
updatedAt: now,
|
|
261
238
|
};
|
|
239
|
+
// L16: 게이트를 여는 필드에 봉인을 찍는다. 키는 cwd 밖에 있어서 에이전트가 값을 고치고
|
|
240
|
+
// 봉인을 다시 계산할 수 없다. 키를 못 얻으면(인프라 실패) 봉인 없이 저장한다 — 그 사실은
|
|
241
|
+
// 다음 로드에서 checked=false 로 나타나고 보고서가 밝힌다.
|
|
242
|
+
const seal = sealOf(compact, loadOrCreateKey());
|
|
243
|
+
if (seal) compact.seal = seal;
|
|
262
244
|
const tmp = join(dirname(path), `.${STATE_FILENAME}.${process.pid}.tmp`);
|
|
263
245
|
writeFileSync(tmp, `${JSON.stringify(compact, null, 2)}\n`, 'utf8');
|
|
264
246
|
renameSync(tmp, path); // 같은 디렉터리 내 rename == 원자적
|
package/src/treekey.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 워킹트리 상태 키 (L12) — "이번 회차가 아무것도 바꾸지 않았다"를 증명한다.
|
|
3
|
+
*
|
|
4
|
+
* 문제: 에이전트가 아무것도 쓰지 않고 돌아오는 회차가 있다(레이트 리밋, 거부, "이미 다 했다"고
|
|
5
|
+
* 판단, 도구 호출 실패). 엔진은 그걸 모르고 testCommand 를 또 돌려 **똑같은 실패를 다시
|
|
6
|
+
* 발견**하고, 그 다음 회차에 또 에이전트를 부른다. 지금은 stagnationLimit 회를 다 태워야
|
|
7
|
+
* 알아채는데, 트리가 그대로라면 결과가 같다는 것은 **관측이 아니라 증명**이다 — 2회차에 안다.
|
|
8
|
+
*
|
|
9
|
+
* 설계: moai 는 `internal/verify/key.go:32` 에서 `rev-parse HEAD` + `status --porcelain=v2` +
|
|
10
|
+
* `diff HEAD` 세 다리를 해시한다. 세 번째 다리가 필요한 이유를 자기 주석이 밝힌다 —
|
|
11
|
+
* *"porcelain-v2 출력은 이미 더러운 추적 파일을 다시 고쳐도 바이트 단위로 동일하다."*
|
|
12
|
+
*
|
|
13
|
+
* hi-loop 는 그보다 나은 원시 도구를 이미 쓰고 있다. `git stash create --include-untracked`
|
|
14
|
+
* 가 만든 커밋의 **트리 SHA** 는 내용 주소(content-addressed)라, 워킹트리가 같으면 항상 같고
|
|
15
|
+
* 다르면 항상 다르다. 추적 안 되는 새 파일까지 포함한다. 커밋 SHA 는 타임스탬프 때문에 매번
|
|
16
|
+
* 달라지지만 **트리 SHA 는 그렇지 않다** — 그래서 커밋이 아니라 트리를 본다.
|
|
17
|
+
* 해시 조립도, 세 다리를 맞추는 일도 필요 없다.
|
|
18
|
+
*
|
|
19
|
+
* fail-open: git 이 아니거나 커밋이 하나도 없으면 null 을 돌려주고 호출부는 조용히 건너뛴다.
|
|
20
|
+
* 이건 인프라지 측정이 아니다 — 비-git 프로젝트에서 루프가 죽는 게 훨씬 나쁘다.
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from 'node:child_process';
|
|
23
|
+
|
|
24
|
+
function git(args, cwd) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
execFile('git', args, { cwd, maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
|
27
|
+
resolve(err ? null : String(stdout).trim());
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TREE_RE = /^[0-9a-f]{40}$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 주입 가능한 포트 (NFR-3). `({cwd}) => 트리 SHA | null`
|
|
36
|
+
*
|
|
37
|
+
* 변경이 없으면 `stash create` 가 빈 문자열을 준다 — 그때는 HEAD 의 트리가 곧 현재 트리다.
|
|
38
|
+
*/
|
|
39
|
+
export function makeTreeKeyReader() {
|
|
40
|
+
return async ({ cwd }) => {
|
|
41
|
+
const inside = await git(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
42
|
+
if (inside !== 'true') return null;
|
|
43
|
+
const snapshot = await git(['stash', 'create', '--include-untracked'], cwd);
|
|
44
|
+
const ref = snapshot && /^[0-9a-f]{7,40}$/.test(snapshot) ? snapshot : 'HEAD';
|
|
45
|
+
const tree = await git(['rev-parse', `${ref}^{tree}`], cwd);
|
|
46
|
+
return tree && TREE_RE.test(tree) ? tree : null;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 에이전트가 워킹트리를 전혀 건드리지 않았는가.
|
|
52
|
+
*
|
|
53
|
+
* 한쪽이라도 null 이면 **모른다** — false 를 돌려준다. 관측 불가를 "안 바뀌었다"로
|
|
54
|
+
* 읽으면 비-git 프로젝트의 모든 회차가 no-op 으로 오판돼 즉시 정체 판정을 맞는다.
|
|
55
|
+
*/
|
|
56
|
+
export function isNoOp(before, after) {
|
|
57
|
+
return Boolean(before) && Boolean(after) && before === after;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 보고서의 Baseline-attribution 용 짧은 표기. 40자 SHA 를 그대로 싣지 않는다. */
|
|
61
|
+
export function shortKey(key) {
|
|
62
|
+
return typeof key === 'string' && TREE_RE.test(key) ? key.slice(0, 12) : '(관측 불가)';
|
|
63
|
+
}
|