@cheongfish/bouncer 0.0.0-stage → 0.1.13
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/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/bouncer +26 -0
- package/bin/bouncer-root +25 -0
- package/bin/build-stamp.js +78 -0
- package/bin/register-path.js +71 -0
- package/dist/.build-stamp +1 -0
- package/dist/approval-snapshot.js +99 -0
- package/dist/bouncer-root.js +80 -0
- package/dist/canonical-json.js +37 -0
- package/dist/cli-commit-guard-command.js +74 -0
- package/dist/cli-config-command.js +47 -0
- package/dist/cli-context-review-command.js +273 -0
- package/dist/cli-current-command.js +374 -0
- package/dist/cli-dispatch-command.js +119 -0
- package/dist/cli-doc-commands.js +352 -0
- package/dist/cli-epic-command.js +130 -0
- package/dist/cli-flags.js +50 -0
- package/dist/cli-git-commands.js +931 -0
- package/dist/cli-intent-command.js +491 -0
- package/dist/cli-metrics-command.js +134 -0
- package/dist/cli-ontology-command.js +109 -0
- package/dist/cli-project-commands.js +580 -0
- package/dist/cli-retro-command.js +138 -0
- package/dist/cli-review-command.js +251 -0
- package/dist/cli-review-dispatch-command.js +338 -0
- package/dist/cli-usage.js +72 -0
- package/dist/cli.js +70 -0
- package/dist/codex-agents.js +189 -0
- package/dist/command-argv.js +125 -0
- package/dist/commit-guard.js +72 -0
- package/dist/commit-hook.js +470 -0
- package/dist/commit-message.js +77 -0
- package/dist/commit-sha.js +107 -0
- package/dist/commit.js +519 -0
- package/dist/composite-gates.js +129 -0
- package/dist/comprehension.js +127 -0
- package/dist/config.js +298 -0
- package/dist/context-ids.js +288 -0
- package/dist/context-migration-rewrite.js +421 -0
- package/dist/context-ontology.js +746 -0
- package/dist/context-review-contracts.js +2 -0
- package/dist/context-review-projection.js +147 -0
- package/dist/context-review-state.js +824 -0
- package/dist/context-review-store.js +602 -0
- package/dist/context-review-transitions.js +412 -0
- package/dist/coordinate-advance.js +253 -0
- package/dist/coordinate-next.js +998 -0
- package/dist/coordinate-output.js +57 -0
- package/dist/coordinator-command-contracts.js +2 -0
- package/dist/coordinator-commands.js +1908 -0
- package/dist/coordinator-fanin.js +1021 -0
- package/dist/coordinator-git.js +121 -0
- package/dist/coordinator-input.js +37 -0
- package/dist/coordinator-ledger.js +381 -0
- package/dist/coordinator-prepare.js +1047 -0
- package/dist/coordinator-repair.js +1416 -0
- package/dist/coordinator-verification.js +42 -0
- package/dist/coordinator-worker.js +388 -0
- package/dist/coordinator.js +7 -0
- package/dist/cross-drive.js +85 -0
- package/dist/current.js +1148 -0
- package/dist/dag-shape.js +93 -0
- package/dist/doc-shape-contracts.js +241 -0
- package/dist/drive-metrics.js +691 -0
- package/dist/epic-index.js +347 -0
- package/dist/epic-registry.js +453 -0
- package/dist/execute-prepare.js +262 -0
- package/dist/execution-contracts.js +46 -0
- package/dist/execution-dag.js +148 -0
- package/dist/finalize-checks.js +177 -0
- package/dist/finalize-collect.js +343 -0
- package/dist/finalize-comprehension.js +116 -0
- package/dist/finalize-digest-collect.js +388 -0
- package/dist/finalize-digest-model.js +595 -0
- package/dist/finalize-digest.js +164 -0
- package/dist/finalize-explain.js +176 -0
- package/dist/finalize-pr.js +610 -0
- package/dist/finalize-release-main.js +652 -0
- package/dist/finalize.js +714 -0
- package/dist/frontmatter.js +22 -0
- package/dist/git-ops.js +49 -0
- package/dist/graph-exec.js +179 -0
- package/dist/graph-scope.js +268 -0
- package/dist/graphify.js +995 -0
- package/dist/guards.js +82 -0
- package/dist/import-git.js +78 -0
- package/dist/import-history.js +392 -0
- package/dist/import-render.js +59 -0
- package/dist/import-types.js +2 -0
- package/dist/init.js +571 -0
- package/dist/intent-bundle.js +936 -0
- package/dist/intent-provenance.js +927 -0
- package/dist/layout.js +131 -0
- package/dist/lease.js +145 -0
- package/dist/legacy-comments.js +71 -0
- package/dist/migrate-context-slugs.js +262 -0
- package/dist/migrate-merge-legacy-epics.js +392 -0
- package/dist/migrate-review-scope.js +144 -0
- package/dist/migrate-task-layout.js +194 -0
- package/dist/path-overlap.js +38 -0
- package/dist/paths.js +45 -0
- package/dist/plan-approve.js +218 -0
- package/dist/plan-inspect.js +575 -0
- package/dist/plan-prerequisites.js +197 -0
- package/dist/plan-snapshot-contract.js +98 -0
- package/dist/plan-snapshot.js +138 -0
- package/dist/plugin-root.js +237 -0
- package/dist/posix-path.js +14 -0
- package/dist/pre-commit-hook.js +170 -0
- package/dist/print-dispatch.js +285 -0
- package/dist/render.js +8 -0
- package/dist/retention-migration.js +595 -0
- package/dist/retro-read.js +259 -0
- package/dist/retro-sources.js +318 -0
- package/dist/review-dispatch.js +1054 -0
- package/dist/review-policy.js +28 -0
- package/dist/review-record.js +447 -0
- package/dist/review-repair-state.js +52 -0
- package/dist/review-snapshot.js +659 -0
- package/dist/run-detach.js +165 -0
- package/dist/run-preflight.js +368 -0
- package/dist/runtime-state.js +1038 -0
- package/dist/scaffold.js +533 -0
- package/dist/schema.js +294 -0
- package/dist/scope.js +1073 -0
- package/dist/seed-worktree.js +926 -0
- package/dist/session-graph.js +337 -0
- package/dist/subagents.js +86 -0
- package/dist/symbol-index.js +1390 -0
- package/dist/task-brief-hash.js +34 -0
- package/dist/task-commits.js +124 -0
- package/dist/tasks-docs.js +192 -0
- package/dist/templates.js +469 -0
- package/dist/time.js +21 -0
- package/dist/validate-docs.js +302 -0
- package/dist/validate-gates-finalize.js +303 -0
- package/dist/validate-gates-plan.js +720 -0
- package/dist/validate-gates-run.js +420 -0
- package/dist/validate-gates-shared.js +124 -0
- package/dist/validate-gates.js +87 -0
- package/dist/validate-sections.js +654 -0
- package/dist/validate-structural.js +347 -0
- package/dist/validate.js +417 -0
- package/dist/verification-contracts.js +26 -0
- package/dist/verification-evidence.js +233 -0
- package/dist/verification-execution.js +163 -0
- package/dist/verification-identity.js +413 -0
- package/dist/verification-target.js +51 -0
- package/dist/verification.js +383 -0
- package/dist/windows-npm.js +38 -0
- package/package.json +32 -4
- package/resources/graphify-compat.json +9 -0
- package/vendor/README.md +23 -0
- package/vendor/js-yaml.LICENSE +21 -0
- package/vendor/js-yaml.js +3022 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
3
|
+
const guards = require("./guards");
|
|
4
|
+
const { isRecord } = guards;
|
|
5
|
+
const validateSections = require("./validate-sections");
|
|
6
|
+
const { normalizeFinding, normalizeRoundFindings, CONTEXT_REVIEW_STATUS } = validateSections;
|
|
7
|
+
function fail(reason, cause, next) {
|
|
8
|
+
return { ok: false, reason, cause, next };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 병합된 finding 목록에서 둘 이상이 공유하는 fingerprint를 처음 나온 순서로 돌려준다.
|
|
12
|
+
* collectFindingFailures를 통째로 부르면 round·본문 검사 메시지가 섞이므로 중복만 센다.
|
|
13
|
+
* fingerprint가 문자열이 아닌 finding은 건너뛴다(형식 오류는 close 시점 검사가 맡는다).
|
|
14
|
+
*
|
|
15
|
+
* @param {Array<{fingerprint?: unknown}>} findings 이전 round와 합친 finding
|
|
16
|
+
* @returns {string[]} 중복된 fingerprint
|
|
17
|
+
*/
|
|
18
|
+
function duplicateFingerprints(findings) {
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const dup = new Set();
|
|
21
|
+
for (const f of findings) {
|
|
22
|
+
if (typeof f.fingerprint !== 'string')
|
|
23
|
+
continue;
|
|
24
|
+
if (seen.has(f.fingerprint))
|
|
25
|
+
dup.add(f.fingerprint);
|
|
26
|
+
seen.add(f.fingerprint);
|
|
27
|
+
}
|
|
28
|
+
return [...dup];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 이번 입력 finding의 정규화 id를 입력 순서로 중복 없이 돌려준다. noop 응답도 첫 적용과
|
|
32
|
+
* 같은 값을 내야 하므로 fold 없이 finding 하나씩 정규화해 계산한다(id는 결정적이다).
|
|
33
|
+
*
|
|
34
|
+
* @param {Finding[]} findings - 이번 round 입력 findings(readReportInput 통과분)
|
|
35
|
+
* @returns {string[]} 정규화 id 목록
|
|
36
|
+
*/
|
|
37
|
+
function inputFindingIds(findings) {
|
|
38
|
+
const ids = [];
|
|
39
|
+
for (const f of findings) {
|
|
40
|
+
const id = normalizeFinding(f, 'context').id;
|
|
41
|
+
if (!ids.includes(id))
|
|
42
|
+
ids.push(id);
|
|
43
|
+
}
|
|
44
|
+
return ids;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 저장된 projection의 finding을 정규화한 사본을 만든다. 정규화 도입 전에 닫힌 state는
|
|
48
|
+
* 틀린 fingerprint를 projection에 담고 있을 수 있어, noop 재실행이 문서를 다시 쓸 때
|
|
49
|
+
* 그대로 쓰면 형식 검증에서 막힌다. state.json 자체는 고치지 않는다(파생 값만 복구).
|
|
50
|
+
*
|
|
51
|
+
* @param {{ findings: Finding[]; rounds: unknown[] }} projection - state에 저장된 projection
|
|
52
|
+
* @returns {{ findings: Finding[]; rounds: unknown[] }} findings만 정규화한 사본
|
|
53
|
+
*/
|
|
54
|
+
function normalizeProjection(projection) {
|
|
55
|
+
return { ...projection, findings: projection.findings.map((f) => normalizeFinding(f, 'context')) };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* closure 투영을 만든다. resolved 또는 note 있는 accepted만 통과하고, must_fix
|
|
59
|
+
* accepted는 사용자가 명시한 --accept-risk ID와 정확히 일치해야 한다.
|
|
60
|
+
*
|
|
61
|
+
* @param {Finding[]} findings - ID별 최신 처분
|
|
62
|
+
* @param {string[]} acceptRisk - 명시 동의된 finding ID
|
|
63
|
+
* @returns {Finding[] | Fail} 투영된 findings 또는 unresolved 거절
|
|
64
|
+
*/
|
|
65
|
+
function projectClosure(findings, acceptRisk) {
|
|
66
|
+
const bad = [];
|
|
67
|
+
const risky = new Set();
|
|
68
|
+
for (const f of findings) {
|
|
69
|
+
const noted = typeof f.note === 'string' && f.note.trim() !== '';
|
|
70
|
+
if (f.status === 'open')
|
|
71
|
+
bad.push(`${String(f.id)} open`);
|
|
72
|
+
else if (f.status === 'accepted' && !noted)
|
|
73
|
+
bad.push(`${String(f.id)} accepted without note`);
|
|
74
|
+
else if (f.status === 'accepted' && f.actionability === 'must_fix') {
|
|
75
|
+
if (acceptRisk.includes(f.id))
|
|
76
|
+
risky.add(f.id);
|
|
77
|
+
else
|
|
78
|
+
bad.push(`${String(f.id)} must_fix accepted without --accept-risk`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const id of acceptRisk) {
|
|
82
|
+
if (!risky.has(id))
|
|
83
|
+
bad.push(`--accept-risk ${id} is not an accepted must_fix finding`);
|
|
84
|
+
}
|
|
85
|
+
if (bad.length > 0) {
|
|
86
|
+
return fail('context-review-unresolved-findings', bad, 'resolve open findings or record explicit user-approved accepted-risk with note');
|
|
87
|
+
}
|
|
88
|
+
// 통과한 finding 중 사용자 동의가 명시된 accepted must_fix에만 accepted_risk: true 표식을 붙인다.
|
|
89
|
+
// 사람이 손으로 붙인 값은 배제하고 CLI projection에서만 부여해 기록 판정에 넘긴다.
|
|
90
|
+
return findings.map((f) => {
|
|
91
|
+
if (f.status === 'accepted' && f.actionability === 'must_fix' && risky.has(f.id)) {
|
|
92
|
+
return { ...f, accepted_risk: true };
|
|
93
|
+
}
|
|
94
|
+
return { ...f };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 검증된 payload에서 본문 비교에 필요한 값만 취한다. 불완전한 입력은 해결 증거로 쓰지 않는다.
|
|
99
|
+
* @param {Record<string, unknown>} payload - 호출자가 이미 읽은 dispatch
|
|
100
|
+
* @returns {Record<string, { body: string }> | undefined} 경로별 본문 또는 문서 정보 없음; I/O 없음
|
|
101
|
+
*/
|
|
102
|
+
function contentOf(payload) {
|
|
103
|
+
const raw = payload.documents_content;
|
|
104
|
+
if (!isRecord(raw))
|
|
105
|
+
return undefined;
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [file, doc] of Object.entries(raw)) {
|
|
108
|
+
if (isRecord(doc) && typeof doc.body === 'string')
|
|
109
|
+
out[file] = { body: doc.body };
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* finding symbol과 heading 텍스트를 같은 꼴로 줄인다: 소문자로 바꾸고 영숫자(한글 포함)가 아닌 문자를 지운다.
|
|
115
|
+
* 예: `goal-intent`와 `Goal & intent`는 모두 `goalintent`.
|
|
116
|
+
* @param {string} text - 비교할 heading 또는 symbol
|
|
117
|
+
* @returns {string} 구두점을 제거한 비교 키; 부작용 없음
|
|
118
|
+
*/
|
|
119
|
+
function sectionKey(text) {
|
|
120
|
+
return text.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 본문에서 symbol과 일치하는 첫 heading의 섹션 본문을 뽑는다. 섹션은 그 heading 줄부터 같은 단계 이상
|
|
124
|
+
* (# 개수가 같거나 적은)의 다음 heading 직전까지다. 코드 펜스 안의 `#`은 heading이 아니다.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} body - 문서 본문
|
|
127
|
+
* @param {string} symbol - finding symbol(heading slug)
|
|
128
|
+
* @returns {string | undefined} 섹션 본문, heading을 못 찾으면 undefined
|
|
129
|
+
*/
|
|
130
|
+
function extractSection(body, symbol) {
|
|
131
|
+
const want = sectionKey(symbol);
|
|
132
|
+
if (want === '')
|
|
133
|
+
return undefined;
|
|
134
|
+
const lines = body.split('\n');
|
|
135
|
+
let fenced = false;
|
|
136
|
+
let start = -1;
|
|
137
|
+
let level = 0;
|
|
138
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
139
|
+
if (/^\s*(```|~~~)/.test(lines[i])) {
|
|
140
|
+
fenced = !fenced;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (fenced)
|
|
144
|
+
continue;
|
|
145
|
+
const m = /^(#{1,6})\s+(.*)$/.exec(lines[i]);
|
|
146
|
+
if (!m)
|
|
147
|
+
continue;
|
|
148
|
+
if (start >= 0) {
|
|
149
|
+
if (m[1].length <= level)
|
|
150
|
+
return lines.slice(start, i).join('\n');
|
|
151
|
+
}
|
|
152
|
+
else if (sectionKey(m[2]) === want) {
|
|
153
|
+
start = i;
|
|
154
|
+
level = m[1].length;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return start >= 0 ? lines.slice(start).join('\n') : undefined;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 열린 must_fix 모두의 대상이 기준 대비 고쳐졌는지 판정한다. payload의 changed_documents·revision_diff는
|
|
161
|
+
* loadDispatch가 대조하지 않아 위조될 수 있으므로 입력으로 쓰지 않고, 재계산 대조를 통과한
|
|
162
|
+
* documents_content 본문만 비교한다. 파일을 읽지 않는다.
|
|
163
|
+
*
|
|
164
|
+
* @param {Finding[]} findings - 판정할 finding. open must_fix만 본다
|
|
165
|
+
* @param {Record<string, {body: string}> | undefined} baseline - 기준(latest 저장본) 문서 본문. 없으면 false
|
|
166
|
+
* @param {Record<string, {body: string}> | undefined} current - 이번 dispatch의 문서 본문
|
|
167
|
+
* @returns {boolean} 모든 대상이 고쳐졌으면 true. 하나라도 그대로이거나 file/섹션을 한쪽에서
|
|
168
|
+
* 못 찾으면 false
|
|
169
|
+
*/
|
|
170
|
+
function openMustFixesFixed(findings, baseline, current) {
|
|
171
|
+
if (!baseline || !current)
|
|
172
|
+
return false;
|
|
173
|
+
return findings
|
|
174
|
+
.filter((f) => f.status === 'open' && f.actionability === 'must_fix')
|
|
175
|
+
.every((f) => {
|
|
176
|
+
const file = f.file;
|
|
177
|
+
const symbol = f.symbol;
|
|
178
|
+
if (typeof file !== 'string' || typeof symbol !== 'string')
|
|
179
|
+
return false;
|
|
180
|
+
if (!Object.hasOwn(baseline, file) || !Object.hasOwn(current, file))
|
|
181
|
+
return false;
|
|
182
|
+
const before = baseline[file].body;
|
|
183
|
+
const after = current[file].body;
|
|
184
|
+
// symbol `-`는 섹션 지정이 없는 finding이라 file 본문 전체를 비교한다.
|
|
185
|
+
if (symbol === '-')
|
|
186
|
+
return before !== after;
|
|
187
|
+
const a = extractSection(before, symbol);
|
|
188
|
+
const b = extractSection(after, symbol);
|
|
189
|
+
return a !== undefined && b !== undefined && a !== b;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 두 plan payload의 문서 집합·structure_parts(구조)와 scope_parts(Interface·Touch 계약)가 같은지 본다.
|
|
194
|
+
* scope_parts의 해시 공식은 plan-snapshot 소유라 여기서는 값만 비교한다.
|
|
195
|
+
*
|
|
196
|
+
* @param {Record<string, unknown>} a - 기준 payload(저장된 latest dispatch)
|
|
197
|
+
* @param {Record<string, unknown>} b - 비교 payload 또는 현재 계산값
|
|
198
|
+
* @returns {{ structure: boolean, scope: boolean }} 구조 동일 여부와 계약 동일 여부
|
|
199
|
+
*/
|
|
200
|
+
function compareShape(a, b) {
|
|
201
|
+
const docs = (x) => (Array.isArray(x.documents) ? x.documents
|
|
202
|
+
: (isRecord(x.target) ? x.target.documents : undefined));
|
|
203
|
+
return {
|
|
204
|
+
structure: isDeepStrictEqual(docs(a), docs(b)) && isDeepStrictEqual(a.structure_parts, b.structure_parts),
|
|
205
|
+
scope: isDeepStrictEqual(a.scope_parts, b.scope_parts),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* close가 본문 수정만 인증하도록 구조와 계약을 함께 대조한다.
|
|
210
|
+
* @param {Record<string, unknown>} a - 기존 payload
|
|
211
|
+
* @param {Record<string, unknown>} b - 새 payload 또는 이미 계산한 parts
|
|
212
|
+
* @returns {boolean} 두 경계가 모두 같으면 true; 입력 변경과 I/O 없음
|
|
213
|
+
*/
|
|
214
|
+
function sameStructureAndScope(a, b) {
|
|
215
|
+
const shape = compareShape(a, b);
|
|
216
|
+
return shape.structure && shape.scope;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 읽어 둔 처분을 finding 사본에 적용한다. 거절 시 입력과 기존 상태를 바꾸지 않는다.
|
|
220
|
+
* @param {Finding[]} findings - 정규화된 기존 findings
|
|
221
|
+
* @param {unknown[]} parsed - 파싱한 처분 배열
|
|
222
|
+
* @returns {Finding[] | Fail} 처분 사본 또는 기존 reason의 거절
|
|
223
|
+
*/
|
|
224
|
+
function applyDispositions(findings, parsed) {
|
|
225
|
+
const seenIds = new Set();
|
|
226
|
+
const merged = findings.map((f) => ({ ...f }));
|
|
227
|
+
for (const item of parsed) {
|
|
228
|
+
if (!isRecord(item) || typeof item.id !== 'string') {
|
|
229
|
+
return fail('context-review-input-invalid', 'disposition item missing valid id', 'each disposition must be an object with string id');
|
|
230
|
+
}
|
|
231
|
+
if (seenIds.has(item.id)) {
|
|
232
|
+
return fail('context-review-input-invalid', `duplicate disposition id ${item.id}`, 'each finding id must appear at most once in dispositions');
|
|
233
|
+
}
|
|
234
|
+
seenIds.add(item.id);
|
|
235
|
+
const targetIndex = merged.findIndex((f) => f.id === item.id);
|
|
236
|
+
if (targetIndex < 0) {
|
|
237
|
+
return fail('context-review-input-invalid', `finding id ${item.id} not found in recorded findings`, 'only dispose recorded findings');
|
|
238
|
+
}
|
|
239
|
+
if (!CONTEXT_REVIEW_STATUS.includes(item.status)) {
|
|
240
|
+
return fail('context-review-input-invalid', `disposition status ${String(item.status)} invalid`, 'disposition status must be resolved or accepted');
|
|
241
|
+
}
|
|
242
|
+
merged[targetIndex] = {
|
|
243
|
+
...merged[targetIndex],
|
|
244
|
+
status: item.status,
|
|
245
|
+
...(item.note !== undefined ? { note: item.note } : {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return merged;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* 뒤 보고가 같은 ID의 finding 전체를 대체하게 사본을 병합한다.
|
|
252
|
+
* @param {Finding[]} prior - 이전 정규화 findings
|
|
253
|
+
* @param {Finding[]} incoming - 이번 정규화 findings
|
|
254
|
+
* @returns {Finding[]} 입력 배열을 변경하지 않은 최신 ID별 목록
|
|
255
|
+
*/
|
|
256
|
+
function mergeFindings(prior, incoming) {
|
|
257
|
+
const merged = [...prior];
|
|
258
|
+
for (const f of incoming) {
|
|
259
|
+
const at = merged.findIndex((x) => x.id === f.id);
|
|
260
|
+
if (at >= 0)
|
|
261
|
+
merged[at] = f;
|
|
262
|
+
else
|
|
263
|
+
merged.push(f);
|
|
264
|
+
}
|
|
265
|
+
return merged;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* 새 round를 정규화·병합하고 중복 fingerprint를 쓰기 전에 거절한다.
|
|
269
|
+
* @param {Finding[]} findings - 이번 입력 findings
|
|
270
|
+
* @param {Finding[]} prior - 이전 보고를 합친 findings
|
|
271
|
+
* @returns {object | Fail} 정규화 IDs와 병합 결과 또는 거절; 입력 변경과 I/O 없음
|
|
272
|
+
*/
|
|
273
|
+
function prepareFindings(findings, prior) {
|
|
274
|
+
const normalized = normalizeRoundFindings(findings, prior, 'context');
|
|
275
|
+
if (!normalized.ok) {
|
|
276
|
+
return fail('context-review-input-invalid', normalized.messages, 'give each finding a non-empty string id or omit the id key; resolve id collisions, then retry');
|
|
277
|
+
}
|
|
278
|
+
const incoming = normalized.findings;
|
|
279
|
+
const merged = mergeFindings(prior, incoming);
|
|
280
|
+
// 중복 fingerprint는 status와 무관하게 쓰기 전에 거부한다. accepted 때만 막으면 중복이 requested/addressed
|
|
281
|
+
// record를 통과한 뒤 close에서야 막혀, 같은 round를 다시 기록할 수 없고 restart만 남기 때문이다.
|
|
282
|
+
const duplicates = duplicateFingerprints(merged);
|
|
283
|
+
if (duplicates.length > 0) {
|
|
284
|
+
return fail('context-review-input-invalid', duplicates.map((fp) => `context-review duplicate fingerprint ${fp}`), 'merge the findings sharing a fingerprint into one and record the same round again');
|
|
285
|
+
}
|
|
286
|
+
return { normalized, incoming, merged };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* 검증을 마친 전이의 새 상태를 계산한다. 기존 상태와 입력은 변경하지 않는다.
|
|
290
|
+
* @param {State} state - 기존 상태
|
|
291
|
+
* @param {ReportInput} report - 검증된 보고
|
|
292
|
+
* @param {string} status - 요청 상태
|
|
293
|
+
* @param {string[]} acceptRisk - 명시 동의 IDs
|
|
294
|
+
* @param {Dispatch} dispatch - 검증된 dispatch
|
|
295
|
+
* @param {Ref} dispatchRef - 저장 참조
|
|
296
|
+
* @param {string} reportRel - 저장 보고 경로
|
|
297
|
+
* @param {string} reportSha - 보고 digest
|
|
298
|
+
* @param {string} key - 전이 키
|
|
299
|
+
* @param {State['projection']} projection - 검증된 투영
|
|
300
|
+
* @returns {State} 저장할 새 상태; I/O와 추가 검증은 호출부가 기존 시점에 수행한다
|
|
301
|
+
*/
|
|
302
|
+
function recordState(state, report, status, acceptRisk, dispatch, dispatchRef, reportRel, reportSha, key, projection) {
|
|
303
|
+
const cycle = state.cycle;
|
|
304
|
+
const round = report.round.round;
|
|
305
|
+
return {
|
|
306
|
+
...state,
|
|
307
|
+
cycle: {
|
|
308
|
+
...cycle,
|
|
309
|
+
active_reports: [...cycle.active_reports, {
|
|
310
|
+
round: round, mode: report.round.mode, status, path: reportRel,
|
|
311
|
+
sha256: reportSha, dispatch: dispatchRef, key,
|
|
312
|
+
}],
|
|
313
|
+
latest_dispatch: dispatchRef,
|
|
314
|
+
},
|
|
315
|
+
transitions: [...state.transitions, key],
|
|
316
|
+
closed: status === 'accepted',
|
|
317
|
+
...(status === 'accepted'
|
|
318
|
+
? { closure: { accept_risk: acceptRisk, closure_target_digest: dispatch.digest } }
|
|
319
|
+
: {}),
|
|
320
|
+
projection,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 검증을 마친 전이의 새 상태를 계산한다. 기존 상태와 입력은 변경하지 않는다.
|
|
325
|
+
* @param {State} state - 기존 상태
|
|
326
|
+
* @param {string} key - 전이 키
|
|
327
|
+
* @param {Ref} dispRef - 처분 저장 참조
|
|
328
|
+
* @param {string[]} acceptRisk - 명시 동의 IDs
|
|
329
|
+
* @param {string} digest - 인증 digest
|
|
330
|
+
* @param {State['projection']} projection - 검증된 투영
|
|
331
|
+
* @returns {State} 저장할 새 상태; I/O와 추가 검증은 호출부가 기존 시점에 수행한다
|
|
332
|
+
*/
|
|
333
|
+
function closeState(state, key, dispRef, acceptRisk, digest, projection) {
|
|
334
|
+
return {
|
|
335
|
+
...state,
|
|
336
|
+
transitions: [...state.transitions, key],
|
|
337
|
+
closed: true,
|
|
338
|
+
closure: { dispositions: dispRef, accept_risk: acceptRisk, closure_target_digest: digest },
|
|
339
|
+
projection,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* 검증을 마친 전이의 새 상태를 계산한다. 기존 상태와 입력은 변경하지 않는다.
|
|
344
|
+
* @param {State} base - 기존 상태 또는 legacy 틀
|
|
345
|
+
* @param {Record<string, unknown>} archive - 이전 증거
|
|
346
|
+
* @param {Ref} dispatchRef - 새 dispatch 저장 참조
|
|
347
|
+
* @param {string} key - 전이 키
|
|
348
|
+
* @returns {State} 저장할 새 상태; I/O와 추가 검증은 호출부가 기존 시점에 수행한다
|
|
349
|
+
*/
|
|
350
|
+
function restartState(base, archive, dispatchRef, key) {
|
|
351
|
+
return {
|
|
352
|
+
...base,
|
|
353
|
+
cycle: {
|
|
354
|
+
id: base.cycle.id,
|
|
355
|
+
restart_count: 1,
|
|
356
|
+
history: [...base.cycle.history, archive],
|
|
357
|
+
active_reports: [],
|
|
358
|
+
latest_dispatch: dispatchRef,
|
|
359
|
+
},
|
|
360
|
+
transitions: [...base.transitions, key],
|
|
361
|
+
closed: false,
|
|
362
|
+
closure: undefined,
|
|
363
|
+
projection: null,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* 읽어 둔 증거로 restart 가능 여부와 archive를 계산한다. 미해결 finding을 snapshot 동일성보다 먼저 거절한다.
|
|
368
|
+
* @param {State} state - 기존 상태
|
|
369
|
+
* @param {object} folded - 저장 보고 합성 결과
|
|
370
|
+
* @param {Record<string, unknown>} latest - 마지막 저장 dispatch
|
|
371
|
+
* @param {Record<string, unknown>} current - 검증된 새 dispatch
|
|
372
|
+
* @param {string} digest - 새 snapshot digest
|
|
373
|
+
* @param {string} reason - 보관할 재시작 사유
|
|
374
|
+
* @returns {Record<string, unknown> | Fail} archive 또는 기존 reason의 거절; 입력 변경과 I/O 없음
|
|
375
|
+
*/
|
|
376
|
+
function restartArchive(state, folded, latest, current, digest, reason) {
|
|
377
|
+
// 닫힌 cycle은 미해결 검사를 projection 기준으로 판정한다.
|
|
378
|
+
// 열려 있는 cycle은 fold된 active 보고의 open must_fix를 검사한다.
|
|
379
|
+
const findingsToCheck = (state.closed && state.projection)
|
|
380
|
+
? state.projection.findings
|
|
381
|
+
: folded.findings;
|
|
382
|
+
// delta 뒤 열린 must_fix는 대상 섹션이 실제로 고쳐졌을 때만 restart를 받아들인다.
|
|
383
|
+
// 기준은 이미 읽은 latest 저장본이고 새 내용은 loadDispatch가 재계산 대조한 payload다.
|
|
384
|
+
if (state.cycle.active_reports.length >= 2
|
|
385
|
+
&& findingsToCheck.some((f) => f.status === 'open' && f.actionability === 'must_fix')
|
|
386
|
+
&& !openMustFixesFixed(findingsToCheck, contentOf(latest), contentOf(current))) {
|
|
387
|
+
return fail('context-review-unresolved-findings', 'must_fix remains open after the delta round', 'record explicit user-approved accepted-risk with context-review close, or fix the target section '
|
|
388
|
+
+ 'of every open must_fix: route then guides close or restart');
|
|
389
|
+
}
|
|
390
|
+
// 닫힌 cycle은 close가 인증한 digest가 마지막 certified target이다(본문 재close가 latest_dispatch를
|
|
391
|
+
// 바꾸지 않으므로 round digest만 보면 이미 인증된 snapshot으로 restart할 수 있다).
|
|
392
|
+
const previousDigest = state.closure?.closure_target_digest
|
|
393
|
+
?? latest.target.digest;
|
|
394
|
+
if (previousDigest === digest) {
|
|
395
|
+
return fail('context-review-state-conflict', 'plan snapshot is unchanged since the last certified target', 'restart only after the plan documents changed');
|
|
396
|
+
}
|
|
397
|
+
const dispatches = [...state.cycle.active_reports.map((r) => r.dispatch), state.cycle.latest_dispatch];
|
|
398
|
+
return {
|
|
399
|
+
restart_index: 1,
|
|
400
|
+
reason,
|
|
401
|
+
rounds: folded.rounds,
|
|
402
|
+
findings: findingsToCheck,
|
|
403
|
+
reports: state.cycle.active_reports,
|
|
404
|
+
dispatches: dispatches.filter((d, i) => dispatches.findIndex((x) => x.sha256 === d.sha256) === i),
|
|
405
|
+
...(state.closure ? { closure: state.closure } : {}),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
module.exports = {
|
|
409
|
+
inputFindingIds, normalizeProjection, projectClosure, contentOf, openMustFixesFixed,
|
|
410
|
+
compareShape, sameStructureAndScope, applyDispositions, prepareFindings,
|
|
411
|
+
recordState, closeState, restartState, restartArchive,
|
|
412
|
+
};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { spawnSync } = require('node:child_process');
|
|
4
|
+
// judge 없이 argv만으로 끝낼 수 있는 행동. next가 이 집합 밖을 주면 advance는
|
|
5
|
+
// 실행하지 않고 worker/judge 정지로 넘긴다 — commit 순서를 advance가 만들지 않게.
|
|
6
|
+
const AUTO_ACTIONS = new Set([
|
|
7
|
+
'prepare', 'integrate', 'verification_node', 'verify', 'commit',
|
|
8
|
+
]);
|
|
9
|
+
// yield는 세션 경계 신호라 실행할 argv가 없다. worker로 오분류되면 coordinator가 없는
|
|
10
|
+
// worker를 찾아 헤매므로 단말 행동으로 둔다 — 정지만 하고 새 세션 여부는 root가 정한다.
|
|
11
|
+
const TERMINAL_ACTIONS = new Set(['blocked', 'done', 'none', 'yield']);
|
|
12
|
+
// fence가 한 박자 늦은 경우만 재시도한다. stale-lease 등은 원장·lease를
|
|
13
|
+
// 다시 읽어도 같은 argv로는 낫지 않아 즉시 실패로 넘긴다.
|
|
14
|
+
const RETRYABLE_REASONS = new Set([
|
|
15
|
+
'stale-ledger-checkpoint', 'stale-integration-head',
|
|
16
|
+
]);
|
|
17
|
+
const DEFAULT_MAX_STEPS = 20;
|
|
18
|
+
/**
|
|
19
|
+
* `coordinate next` argv를 기본 경로에서 실행한다. 첫 토큰 `bouncer`는
|
|
20
|
+
* 이 패키지 launcher로 치환해 PATH에 bouncer가 없어도 같은 설치본을 쓴다.
|
|
21
|
+
*
|
|
22
|
+
* @param {string[]} argv - next가 준 argv(`bouncer`로 시작)
|
|
23
|
+
* @param {string} cwd - 실행 cwd(next 응답의 cwd)
|
|
24
|
+
* @returns {{ stdout: string, status: number }} 자식 stdout과 종료 코드
|
|
25
|
+
*/
|
|
26
|
+
function defaultRunArgv(argv, cwd) {
|
|
27
|
+
const launcher = path.join(__dirname, '..', 'bin', 'bouncer');
|
|
28
|
+
const result = spawnSync(process.execPath, [launcher, ...argv.slice(1)], {
|
|
29
|
+
cwd,
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
// stderr는 파이프에 모아 호출자 stdout JSON과 섞이지 않게 한다.
|
|
32
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
const status = typeof result.status === 'number' ? result.status : 1;
|
|
35
|
+
return { stdout: result.stdout || '', status };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 기본 next 의존성. advance CLI가 deps를 안 넘길 때 coordinateNext를 부른다.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ repoRoot: string, blueprint: string, task?: string }} opts - advance 옵션
|
|
41
|
+
* @returns {NextShape} coordinateNext 결과
|
|
42
|
+
*/
|
|
43
|
+
function defaultNext(opts) {
|
|
44
|
+
const { coordinateNext } = require('./coordinate-next');
|
|
45
|
+
// next CLI와 같이 호출자 cwd를 넘긴다. integration이 아니면 next가 거절한다.
|
|
46
|
+
return coordinateNext({
|
|
47
|
+
repoRoot: opts.repoRoot,
|
|
48
|
+
blueprint: opts.blueprint,
|
|
49
|
+
cwd: process.cwd(),
|
|
50
|
+
task: opts.task,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* argv 결과 JSON에서 비교·보고용 실패 reason을 고른다.
|
|
55
|
+
* 검증 실패는 reason 키가 없고 failures[0].code만 있는 경우가 있다.
|
|
56
|
+
*
|
|
57
|
+
* @param {Record<string, unknown>} body - 파싱된 stdout JSON
|
|
58
|
+
* @returns {string | undefined} reason 또는 첫 failures[].code
|
|
59
|
+
*/
|
|
60
|
+
function failureReasonOf(body) {
|
|
61
|
+
if (typeof body.reason === 'string' && body.reason !== '')
|
|
62
|
+
return body.reason;
|
|
63
|
+
const failures = body.failures;
|
|
64
|
+
if (Array.isArray(failures) && failures.length > 0) {
|
|
65
|
+
const code = failures[0].code;
|
|
66
|
+
if (typeof code === 'string' && code !== '')
|
|
67
|
+
return code;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* stdout을 JSON 객체로 파싱한다. 배열·원시값은 결과 계약이 아니라 실패로 본다.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} stdout - runArgv stdout
|
|
75
|
+
* @returns {Record<string, unknown> | null} 객체면 본문, 아니면 null
|
|
76
|
+
*/
|
|
77
|
+
function parseResultJson(stdout) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(stdout);
|
|
80
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// JSON이 아니면 unclear-result로 올린다. SyntaxError만 흡수한다.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* runArgv 한 번의 stdout·status를 성공·실패·불명확으로 분류한다.
|
|
92
|
+
*
|
|
93
|
+
* @param {{ stdout: string, status: number }} attempt - runArgv 결과
|
|
94
|
+
* @returns {RunOutcome} 분류 결과
|
|
95
|
+
*/
|
|
96
|
+
function classifyRun(attempt) {
|
|
97
|
+
if (attempt.status !== 0 && attempt.status !== 1) {
|
|
98
|
+
return { kind: 'unclear', detail: `automatic action exited with status ${attempt.status}` };
|
|
99
|
+
}
|
|
100
|
+
const body = parseResultJson(attempt.stdout);
|
|
101
|
+
if (body === null) {
|
|
102
|
+
return { kind: 'unclear', detail: 'automatic action stdout was not a JSON object' };
|
|
103
|
+
}
|
|
104
|
+
const hasFailureCodes = Array.isArray(body.failures)
|
|
105
|
+
&& body.failures.some((f) => f && typeof f.code === 'string');
|
|
106
|
+
if (body.ok === false || hasFailureCodes) {
|
|
107
|
+
return {
|
|
108
|
+
kind: 'failure',
|
|
109
|
+
body,
|
|
110
|
+
reason: failureReasonOf(body) || 'failed',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (body.ok === true && attempt.status === 0) {
|
|
114
|
+
return { kind: 'success', exit: attempt.status };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
kind: 'unclear',
|
|
118
|
+
detail: 'automatic action result was not a clear success or failure',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 자동 행동 실패를 advance 실패 응답으로 옮긴다. 원 응답 필드를 유지하고
|
|
123
|
+
* executed만 붙인다 — 클라이언트가 기존 coordinate 실패와 같이 파싱한다.
|
|
124
|
+
*
|
|
125
|
+
* @param {Record<string, unknown>} body - argv 실패 JSON
|
|
126
|
+
* @param {string} reason - 보고 reason
|
|
127
|
+
* @param {ExecutedStep[]} executed - 지금까지 성공한 단계
|
|
128
|
+
* @returns {AdvanceErr} ok:false 응답
|
|
129
|
+
*/
|
|
130
|
+
function failFromBody(body, reason, executed) {
|
|
131
|
+
// 원 argv 실패 필드(failures 등)를 유지하고 executed만 붙인다.
|
|
132
|
+
return {
|
|
133
|
+
...body,
|
|
134
|
+
ok: false,
|
|
135
|
+
reason,
|
|
136
|
+
cause: body.cause !== undefined ? body.cause : reason,
|
|
137
|
+
next: body.next !== undefined ? body.next : '',
|
|
138
|
+
executed,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* unclear-result / advance-argv-invalid처럼 advance가 만든 실패.
|
|
143
|
+
* 검증기 소스를 읽으라는 안내는 넣지 않고 cause·next만 돌린다.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} reason - advance 실패 reason
|
|
146
|
+
* @param {string} cause - 짧은 원인
|
|
147
|
+
* @param {string} nextHint - 다음에 할 일
|
|
148
|
+
* @param {ExecutedStep[]} executed - 지금까지 성공한 단계
|
|
149
|
+
* @returns {AdvanceErr} ok:false 응답
|
|
150
|
+
*/
|
|
151
|
+
function failAdvance(reason, cause, nextHint, executed) {
|
|
152
|
+
return { ok: false, reason, cause, next: nextHint, executed };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* `coordinate next`가 준 결정적 argv를 연쇄 실행하다 판단·worker·한도·오류에서 멈춘다.
|
|
156
|
+
* 원장·pointer는 직접 쓰지 않고 next가 준 argv만 실행하며, 단계마다 next를
|
|
157
|
+
* 다시 불러 fence를 갱신한다.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} opts
|
|
160
|
+
* @param {string} opts.repoRoot - 저장소 루트
|
|
161
|
+
* @param {string} opts.blueprint - blueprint 상대 경로
|
|
162
|
+
* @param {string} [opts.task] - task 범위 next에 넘길 id
|
|
163
|
+
* @param {number} [opts.maxSteps] - 자동 실행 상한(기본 20). 양의 정수만
|
|
164
|
+
* @param {AdvanceDeps} [opts.deps] - next·runArgv 주입 seam
|
|
165
|
+
* @returns {AdvanceOk | AdvanceErr} 정지(ok:true) 또는 실패(ok:false)
|
|
166
|
+
*/
|
|
167
|
+
function advance(opts) {
|
|
168
|
+
const maxSteps = opts.maxSteps === undefined ? DEFAULT_MAX_STEPS : opts.maxSteps;
|
|
169
|
+
const nextFn = opts.deps?.next ?? defaultNext;
|
|
170
|
+
const runArgv = opts.deps?.runArgv ?? defaultRunArgv;
|
|
171
|
+
const executed = [];
|
|
172
|
+
const nextOpts = {
|
|
173
|
+
repoRoot: opts.repoRoot,
|
|
174
|
+
blueprint: opts.blueprint,
|
|
175
|
+
task: opts.task,
|
|
176
|
+
};
|
|
177
|
+
// 1. 매 단계 next를 새로 부른다. 이전 argv의 fence를 재사용하지 않는다.
|
|
178
|
+
while (true) {
|
|
179
|
+
const step = nextFn(nextOpts);
|
|
180
|
+
if (step.ok !== true) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
reason: typeof step.reason === 'string' ? step.reason : 'next-failed',
|
|
184
|
+
cause: step.cause !== undefined ? step.cause : step.reason,
|
|
185
|
+
next: step.next !== undefined ? step.next : '',
|
|
186
|
+
executed,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const action = typeof step.action === 'string' ? step.action : '';
|
|
190
|
+
// 2. 단말 행동·판단·worker는 실행 없이 정지. stop.next에 이 next 전체를 담는다.
|
|
191
|
+
if (TERMINAL_ACTIONS.has(action)) {
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
executed,
|
|
195
|
+
stop: { reason: action, next: step },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (step.judge !== undefined && step.judge !== null) {
|
|
199
|
+
return { ok: true, executed, stop: { reason: 'judge', next: step } };
|
|
200
|
+
}
|
|
201
|
+
const argv = Array.isArray(step.argv) ? step.argv : undefined;
|
|
202
|
+
const isAuto = AUTO_ACTIONS.has(action) && argv !== undefined && argv.length > 0;
|
|
203
|
+
if (!isAuto) {
|
|
204
|
+
// drive_tasks(argv 없음)·implement(inline 포함)·그 밖 worker 위임.
|
|
205
|
+
return { ok: true, executed, stop: { reason: 'worker', next: step } };
|
|
206
|
+
}
|
|
207
|
+
// 3. 한도에 닿으면 이번 argv는 실행하지 않는다. executed 길이가 곧 단계 수다.
|
|
208
|
+
if (executed.length >= maxSteps) {
|
|
209
|
+
return { ok: true, executed, stop: { reason: 'max-steps', next: step } };
|
|
210
|
+
}
|
|
211
|
+
if (argv[0] !== 'bouncer') {
|
|
212
|
+
return failAdvance('advance-argv-invalid', 'next argv must start with bouncer', 'Inspect coordinate next argv; do not invent a replacement command.', executed);
|
|
213
|
+
}
|
|
214
|
+
const cwd = typeof step.cwd === 'string' && step.cwd !== '' ? step.cwd : process.cwd();
|
|
215
|
+
let outcome = classifyRun(runArgv(argv, cwd));
|
|
216
|
+
// 4. stale fence만 next를 다시 불러 한 번 재실행한다. 같은 reason이면
|
|
217
|
+
// repeated-failure, 다른 실패면 그 응답, 성공이면 executed에 넣고 루프.
|
|
218
|
+
if (outcome.kind === 'failure' && RETRYABLE_REASONS.has(outcome.reason)) {
|
|
219
|
+
const firstReason = outcome.reason;
|
|
220
|
+
const again = nextFn(nextOpts);
|
|
221
|
+
if (again.ok === true
|
|
222
|
+
&& again.action === action
|
|
223
|
+
&& Array.isArray(again.argv)
|
|
224
|
+
&& again.argv.length > 0
|
|
225
|
+
&& again.argv[0] === 'bouncer') {
|
|
226
|
+
const retryCwd = typeof again.cwd === 'string' && again.cwd !== ''
|
|
227
|
+
? again.cwd : process.cwd();
|
|
228
|
+
outcome = classifyRun(runArgv(again.argv, retryCwd));
|
|
229
|
+
if (outcome.kind === 'failure' && outcome.reason === firstReason) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
reason: 'repeated-failure',
|
|
233
|
+
cause: firstReason,
|
|
234
|
+
next: outcome.body.next !== undefined ? outcome.body.next : '',
|
|
235
|
+
executed,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (outcome.kind === 'unclear') {
|
|
241
|
+
return failAdvance('unclear-result', outcome.detail, 'Re-read state with coordinate next or coordinate status and run only the action it names; '
|
|
242
|
+
+ 'do not re-run the same mutation argv first.', executed);
|
|
243
|
+
}
|
|
244
|
+
if (outcome.kind === 'failure') {
|
|
245
|
+
return failFromBody(outcome.body, outcome.reason, executed);
|
|
246
|
+
}
|
|
247
|
+
const entry = { action, exit: outcome.exit };
|
|
248
|
+
if (typeof step.task === 'string' && step.task !== '')
|
|
249
|
+
entry.task = step.task;
|
|
250
|
+
executed.push(entry);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
module.exports = { advance };
|