@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.
Files changed (156) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/bouncer +26 -0
  4. package/bin/bouncer-root +25 -0
  5. package/bin/build-stamp.js +78 -0
  6. package/bin/register-path.js +71 -0
  7. package/dist/.build-stamp +1 -0
  8. package/dist/approval-snapshot.js +99 -0
  9. package/dist/bouncer-root.js +80 -0
  10. package/dist/canonical-json.js +37 -0
  11. package/dist/cli-commit-guard-command.js +74 -0
  12. package/dist/cli-config-command.js +47 -0
  13. package/dist/cli-context-review-command.js +273 -0
  14. package/dist/cli-current-command.js +374 -0
  15. package/dist/cli-dispatch-command.js +119 -0
  16. package/dist/cli-doc-commands.js +352 -0
  17. package/dist/cli-epic-command.js +130 -0
  18. package/dist/cli-flags.js +50 -0
  19. package/dist/cli-git-commands.js +931 -0
  20. package/dist/cli-intent-command.js +491 -0
  21. package/dist/cli-metrics-command.js +134 -0
  22. package/dist/cli-ontology-command.js +109 -0
  23. package/dist/cli-project-commands.js +580 -0
  24. package/dist/cli-retro-command.js +138 -0
  25. package/dist/cli-review-command.js +251 -0
  26. package/dist/cli-review-dispatch-command.js +338 -0
  27. package/dist/cli-usage.js +72 -0
  28. package/dist/cli.js +70 -0
  29. package/dist/codex-agents.js +189 -0
  30. package/dist/command-argv.js +125 -0
  31. package/dist/commit-guard.js +72 -0
  32. package/dist/commit-hook.js +470 -0
  33. package/dist/commit-message.js +77 -0
  34. package/dist/commit-sha.js +107 -0
  35. package/dist/commit.js +519 -0
  36. package/dist/composite-gates.js +129 -0
  37. package/dist/comprehension.js +127 -0
  38. package/dist/config.js +298 -0
  39. package/dist/context-ids.js +288 -0
  40. package/dist/context-migration-rewrite.js +421 -0
  41. package/dist/context-ontology.js +746 -0
  42. package/dist/context-review-contracts.js +2 -0
  43. package/dist/context-review-projection.js +147 -0
  44. package/dist/context-review-state.js +824 -0
  45. package/dist/context-review-store.js +602 -0
  46. package/dist/context-review-transitions.js +412 -0
  47. package/dist/coordinate-advance.js +253 -0
  48. package/dist/coordinate-next.js +998 -0
  49. package/dist/coordinate-output.js +57 -0
  50. package/dist/coordinator-command-contracts.js +2 -0
  51. package/dist/coordinator-commands.js +1908 -0
  52. package/dist/coordinator-fanin.js +1021 -0
  53. package/dist/coordinator-git.js +121 -0
  54. package/dist/coordinator-input.js +37 -0
  55. package/dist/coordinator-ledger.js +381 -0
  56. package/dist/coordinator-prepare.js +1047 -0
  57. package/dist/coordinator-repair.js +1416 -0
  58. package/dist/coordinator-verification.js +42 -0
  59. package/dist/coordinator-worker.js +388 -0
  60. package/dist/coordinator.js +7 -0
  61. package/dist/cross-drive.js +85 -0
  62. package/dist/current.js +1148 -0
  63. package/dist/dag-shape.js +93 -0
  64. package/dist/doc-shape-contracts.js +241 -0
  65. package/dist/drive-metrics.js +691 -0
  66. package/dist/epic-index.js +347 -0
  67. package/dist/epic-registry.js +453 -0
  68. package/dist/execute-prepare.js +262 -0
  69. package/dist/execution-contracts.js +46 -0
  70. package/dist/execution-dag.js +148 -0
  71. package/dist/finalize-checks.js +177 -0
  72. package/dist/finalize-collect.js +343 -0
  73. package/dist/finalize-comprehension.js +116 -0
  74. package/dist/finalize-digest-collect.js +388 -0
  75. package/dist/finalize-digest-model.js +595 -0
  76. package/dist/finalize-digest.js +164 -0
  77. package/dist/finalize-explain.js +176 -0
  78. package/dist/finalize-pr.js +610 -0
  79. package/dist/finalize-release-main.js +652 -0
  80. package/dist/finalize.js +714 -0
  81. package/dist/frontmatter.js +22 -0
  82. package/dist/git-ops.js +49 -0
  83. package/dist/graph-exec.js +179 -0
  84. package/dist/graph-scope.js +268 -0
  85. package/dist/graphify.js +995 -0
  86. package/dist/guards.js +82 -0
  87. package/dist/import-git.js +78 -0
  88. package/dist/import-history.js +392 -0
  89. package/dist/import-render.js +59 -0
  90. package/dist/import-types.js +2 -0
  91. package/dist/init.js +571 -0
  92. package/dist/intent-bundle.js +936 -0
  93. package/dist/intent-provenance.js +927 -0
  94. package/dist/layout.js +131 -0
  95. package/dist/lease.js +145 -0
  96. package/dist/legacy-comments.js +71 -0
  97. package/dist/migrate-context-slugs.js +262 -0
  98. package/dist/migrate-merge-legacy-epics.js +392 -0
  99. package/dist/migrate-review-scope.js +144 -0
  100. package/dist/migrate-task-layout.js +194 -0
  101. package/dist/path-overlap.js +38 -0
  102. package/dist/paths.js +45 -0
  103. package/dist/plan-approve.js +218 -0
  104. package/dist/plan-inspect.js +575 -0
  105. package/dist/plan-prerequisites.js +197 -0
  106. package/dist/plan-snapshot-contract.js +98 -0
  107. package/dist/plan-snapshot.js +138 -0
  108. package/dist/plugin-root.js +237 -0
  109. package/dist/posix-path.js +14 -0
  110. package/dist/pre-commit-hook.js +170 -0
  111. package/dist/print-dispatch.js +285 -0
  112. package/dist/render.js +8 -0
  113. package/dist/retention-migration.js +595 -0
  114. package/dist/retro-read.js +259 -0
  115. package/dist/retro-sources.js +318 -0
  116. package/dist/review-dispatch.js +1054 -0
  117. package/dist/review-policy.js +28 -0
  118. package/dist/review-record.js +447 -0
  119. package/dist/review-repair-state.js +52 -0
  120. package/dist/review-snapshot.js +659 -0
  121. package/dist/run-detach.js +165 -0
  122. package/dist/run-preflight.js +368 -0
  123. package/dist/runtime-state.js +1038 -0
  124. package/dist/scaffold.js +533 -0
  125. package/dist/schema.js +294 -0
  126. package/dist/scope.js +1073 -0
  127. package/dist/seed-worktree.js +926 -0
  128. package/dist/session-graph.js +337 -0
  129. package/dist/subagents.js +86 -0
  130. package/dist/symbol-index.js +1390 -0
  131. package/dist/task-brief-hash.js +34 -0
  132. package/dist/task-commits.js +124 -0
  133. package/dist/tasks-docs.js +192 -0
  134. package/dist/templates.js +469 -0
  135. package/dist/time.js +21 -0
  136. package/dist/validate-docs.js +302 -0
  137. package/dist/validate-gates-finalize.js +303 -0
  138. package/dist/validate-gates-plan.js +720 -0
  139. package/dist/validate-gates-run.js +420 -0
  140. package/dist/validate-gates-shared.js +124 -0
  141. package/dist/validate-gates.js +87 -0
  142. package/dist/validate-sections.js +654 -0
  143. package/dist/validate-structural.js +347 -0
  144. package/dist/validate.js +417 -0
  145. package/dist/verification-contracts.js +26 -0
  146. package/dist/verification-evidence.js +233 -0
  147. package/dist/verification-execution.js +163 -0
  148. package/dist/verification-identity.js +413 -0
  149. package/dist/verification-target.js +51 -0
  150. package/dist/verification.js +383 -0
  151. package/dist/windows-npm.js +38 -0
  152. package/package.json +32 -4
  153. package/resources/graphify-compat.json +9 -0
  154. package/vendor/README.md +23 -0
  155. package/vendor/js-yaml.LICENSE +21 -0
  156. package/vendor/js-yaml.js +3022 -0
@@ -0,0 +1,1021 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const runtime = require("./runtime-state");
5
+ const { coordinatorPathsFor } = runtime;
6
+ const seed = require("./seed-worktree");
7
+ const { seedCoordinatorWorker } = seed;
8
+ const sections = require("./validate-sections");
9
+ const { parseSections } = sections;
10
+ const dag = require("./execution-dag");
11
+ const { transition } = dag;
12
+ const scope = require("./scope");
13
+ const { withLedgerLock } = scope;
14
+ const lease = require("./lease");
15
+ const { checkLease, revokeLease } = lease;
16
+ const coordinatorLedger = require("./coordinator-ledger");
17
+ const { loadLedger, loadLedgerBytes, ledgerBytesHash, openLedgerFenced, validateFaninLedger, ledgerModeOf, withCheckpoint, readyWaveGated, maxParallelForRead, recheckLedgerCheckpoint, } = coordinatorLedger;
18
+ const coordinatorGit = require("./coordinator-git");
19
+ const { git, registeredWorker, workerOwnsSha } = coordinatorGit;
20
+ const coordinatorWorker = require("./coordinator-worker");
21
+ const { reportPathFor, readBouncerBlock, checkWorkerEvidence, verificationPolicyAt, isBlueprintReviewModeAt, evidenceFiles, currentScopePaths, copyEvidenceBundle, intentIdFor, } = coordinatorWorker;
22
+ const verification = require("./coordinator-verification");
23
+ const { withInstallStdioToStderr, prepareVerificationDependencies, selectVerificationRunner, runTargetVerification, } = verification;
24
+ /**
25
+ * 커밋 메시지 끝에 `Bouncer-Worker-Commit` trailer 한 줄을 붙인 새 메시지를 만든다.
26
+ * 마지막 문단이 전부 `Key: value` 줄이고 제목 문단이 아니면 기존 trailer 블록의 마지막 줄
27
+ * 바로 뒤에 붙이고, 아니면 빈 줄 하나를 두고 새 블록으로 붙인다. 제목만 있는 메시지의
28
+ * `feat: x`가 trailer로 오인되지 않도록 문단이 둘 이상일 때만 블록으로 본다.
29
+ *
30
+ * @param {string} message - cherry-pick된 커밋의 원본 메시지(`%B`)
31
+ * @param {string} workerSha - 원본 worker 커밋 전체 SHA
32
+ * @returns {string} trailer가 추가된 메시지
33
+ */
34
+ function appendWorkerCommitTrailer(message, workerSha) {
35
+ const trimmed = message.replace(/\s+$/, '');
36
+ const paragraphs = trimmed.split(/\n\s*\n/);
37
+ const last = paragraphs[paragraphs.length - 1];
38
+ const isTrailerBlock = paragraphs.length > 1
39
+ && last.split('\n').every((line) => /^[A-Za-z][A-Za-z0-9-]*\s*:\s*\S/.test(line));
40
+ const line = `Bouncer-Worker-Commit: ${workerSha}`;
41
+ return `${trimmed}${isTrailerBlock ? '\n' : '\n\n'}${line}\n`;
42
+ }
43
+ /**
44
+ * light 원장에서 task cwd가 integration인지 본다. full의 registeredWorker와
45
+ * 달리 integration 자체 배정만 허용하고 workers/ 경로는 거절한다.
46
+ *
47
+ * @param {Task} item - 원장 task
48
+ * @param {string} integrationPath - integration checkout 절대 경로
49
+ * @returns {boolean} workerPath가 integration과 같으면 true
50
+ */
51
+ function lightWorkerAssigned(item, integrationPath) {
52
+ if (typeof item.workerPath !== 'string' || item.workerPath === '')
53
+ return false;
54
+ try {
55
+ return fs.realpathSync(item.workerPath) === fs.realpathSync(integrationPath);
56
+ }
57
+ catch (_error) {
58
+ // 경로 부재·권한 오류는 배정 실패로만 접는다. throw하면 dispatch가 사용법 오류가 된다.
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * 보고의 `## Tests` 절이 task에 지정된 명령과 통과 결과를 담았는지 판정한다.
64
+ * 사람이 쓴 보고라 형식이 정확히 고정되어 있지 않으므로 의도적으로 관대한 휴리스틱이다:
65
+ * (1) Tests 절이 있어야 하고, (2) 지정 명령이 인수 확장 없이 경계에 맞춰 등장해야
66
+ * 하며(`cmd extra.test.js`는 다른 명령), (3) 통과 표지(pass·ok·exit 0·통과 등)가
67
+ * 있어야 하고, (4) `0 fail`·`fail 0` 같은 무결과를 지운 뒤에도 실패 표지(fail·error·
68
+ * 실패·exit 비0)가 남지 않아야 한다. 실제 실행 여부는 증명하지 못한다 — 그 증거는
69
+ * execute gate가 쓴다. 이 검사는 worker가 엉뚱한 명령(전체 suite 포함)을 돌렸거나
70
+ * 결과를 빠뜨린 보고를 fan-in 전에 걸러내는 용도다.
71
+ *
72
+ * @param {string} body - 보고 파일 원문
73
+ * @param {string} command - task가 지정한 `bouncer.verify` 명령
74
+ * @returns {string | null} 통과면 null, 아니면 사유
75
+ * (`tests-section-missing`·`command-missing`·`result-missing`·`result-failed`)
76
+ */
77
+ function testsReportProblem(body, command) {
78
+ const sections = parseSections(body, [
79
+ { key: 'tests', re: /^##\s+Tests\s*$/i },
80
+ { key: 'other', re: /^##\s+(?!Tests\s*$)\S/i },
81
+ ]);
82
+ if (sections.tests === null)
83
+ return 'tests-section-missing';
84
+ const collapse = (value) => value.replace(/\s+/g, ' ').trim();
85
+ const text = collapse(sections.tests);
86
+ const wanted = collapse(command);
87
+ const escaped = wanted.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
88
+ // 명령 앞은 문장 시작·공백·열기 문자, 뒤는 끝·닫기/구두점·공백이어야 한다.
89
+ const boundary = new RegExp(`(?:^|[\\s\`'"(])${escaped}(?=$|[\\s\`'")\\].,;:])`, 'g');
90
+ let matched = false;
91
+ for (const hit of text.matchAll(boundary)) {
92
+ const rest = text.slice(hit.index + hit[0].length)
93
+ .replace(/^[`'")\].,;:]+/, '');
94
+ // 공백 뒤에 옵션·경로·테스트 파일이 이어지면 인수가 더 붙은 다른 명령이다.
95
+ const extended = /^\s+(?:--?[A-Za-z]|\S*\/\S*|\S+\.(?:js|ts|mjs|cjs|json)\b)/.test(rest);
96
+ if (!extended) {
97
+ matched = true;
98
+ break;
99
+ }
100
+ }
101
+ if (!matched)
102
+ return 'command-missing';
103
+ const passMark = /\b(?:pass(?:ed|es)?|ok|success(?:ful(?:ly)?)?)\b|통과|성공|exit(?:\s+code)?\s*[:=]?\s*0\b/i;
104
+ if (!passMark.test(text))
105
+ return 'result-missing';
106
+ const noZero = text
107
+ .replace(/\b0\s+(?:fail\w*|errors?)\b/gi, ' ')
108
+ .replace(/\b(?:fail\w*|errors?)\s*[:=]?\s*0\b/gi, ' ')
109
+ .replace(/\bno\s+(?:fail\w*|errors?)\b/gi, ' ')
110
+ .replace(/(?:실패|오류)\s*0|0\s*(?:실패|오류)/g, ' ');
111
+ if (/\b(?:fail\w*|errors?)\b|실패|오류|exit(?:\s+code)?\s*[:=]?\s*[1-9]/i.test(noZero)) {
112
+ return 'result-failed';
113
+ }
114
+ return null;
115
+ }
116
+ /**
117
+ * scoped_terminal fan-in 전에 각 task의 worker 보고가 지정 명령·통과 결과를 담았는지
118
+ * 확인한다. 명령은 integration의 tasks.md `bouncer.verify`가 정본이다(worker 사본은
119
+ * worker가 바꿀 수 있어 믿지 않는다). 보고 파일이 없으면 full은 거절한다. light는
120
+ * 세션이 같은 cwd에서 inline 보고를 하므로 파일이 없을 때만(ENOENT) 이 검사를 건너뛴다.
121
+ * 그 밖의 읽기 오류는 삼키지 않고 던진다.
122
+ *
123
+ * @param {object} args - 검사 입력
124
+ * @param {string} args.integrationPath - 계획 정본을 읽을 integration checkout
125
+ * @param {string} args.blueprint - blueprint 상대 경로
126
+ * @param {Task[]} args.items - fan-in 대상 task
127
+ * @param {boolean} args.light - light 원장이면 true (보고 파일 부재 허용)
128
+ * @returns {{ task: string; expected_command: string; problem: string } | null} 첫 위반. 없으면 null
129
+ */
130
+ function scopedTestReportFailure({ integrationPath, blueprint, items, light }) {
131
+ for (const item of items) {
132
+ const declared = readBouncerBlock(path.join(integrationPath, blueprint, 'tasks', item.id, 'tasks.md'));
133
+ const expected = declared && typeof declared.verify === 'string' ? declared.verify.trim() : '';
134
+ if (expected === '') {
135
+ return { task: item.id, expected_command: '', problem: 'task-command-missing' };
136
+ }
137
+ if (!item.dispatch || typeof item.workerPath !== 'string') {
138
+ if (light)
139
+ continue;
140
+ return { task: item.id, expected_command: expected, problem: 'report-missing' };
141
+ }
142
+ let body;
143
+ try {
144
+ body = fs.readFileSync(reportPathFor(item.workerPath, item.id, item.dispatch.attempt), 'utf8');
145
+ }
146
+ catch (error) {
147
+ if (error.code === 'ENOENT') {
148
+ if (light)
149
+ continue;
150
+ return { task: item.id, expected_command: expected, problem: 'report-missing' };
151
+ }
152
+ throw error;
153
+ }
154
+ const problem = testsReportProblem(body, expected);
155
+ if (problem !== null)
156
+ return { task: item.id, expected_command: expected, problem };
157
+ }
158
+ return null;
159
+ }
160
+ /**
161
+ * lease 검사 실패를 decision으로만 남긴다. status·dispatch·sha는 바꾸지 않는다.
162
+ *
163
+ * @param {Ledger} ledger - 원장
164
+ * @param {Task} item - 대상 task
165
+ * @param {string} taskId - task id
166
+ * @param {ReturnType<typeof checkLease> & {ok: false}} checked - 거절 결과
167
+ * @returns {{ok: false, reason: string, expected: unknown, received: unknown}}
168
+ */
169
+ function rejectLeaseMismatch(ledger, item, taskId,
170
+ // 호출부 checkLease 거절 결과 — expected/received 유니온이 길어 줄 분리.
171
+ checked) {
172
+ if (checked.reason === 'stale-lease') {
173
+ const stale = {
174
+ task: taskId, kind: 'stale-lease',
175
+ expected: checked.expected, received: checked.received,
176
+ };
177
+ item.decisions = [...(item.decisions || []), stale];
178
+ ledger.decisions.push(stale);
179
+ }
180
+ return {
181
+ ok: false,
182
+ reason: checked.reason,
183
+ expected: checked.expected,
184
+ received: checked.received,
185
+ };
186
+ }
187
+ /**
188
+ * recorded commit task를 depends_on 우선·ID순으로 정렬한다. wave 안 의존만
189
+ * 본다 — 이미 integrated인 선행은 edge가 없어 병렬 순서가 ID로만 정해진다.
190
+ *
191
+ * @param {Task[]} tasks - 원장 전체
192
+ * @param {string[]} ids - wave 대상 id
193
+ * @returns {string[]} cherry-pick 순서
194
+ */
195
+ function sortFaninTasks(tasks, ids) {
196
+ const idSet = new Set(ids);
197
+ const byId = new Map(tasks.filter((entry) => idSet.has(entry.id)).map((entry) => [entry.id, entry]));
198
+ const indegree = new Map(ids.map((id) => [id, 0]));
199
+ const children = new Map(ids.map((id) => [id, []]));
200
+ for (const id of ids) {
201
+ const deps = (byId.get(id)?.depends_on || []).filter((dep) => idSet.has(dep));
202
+ indegree.set(id, deps.length);
203
+ for (const dep of deps) {
204
+ children.get(dep).push(id);
205
+ }
206
+ }
207
+ const ready = ids.filter((id) => indegree.get(id) === 0).sort((a, b) => a.localeCompare(b));
208
+ const ordered = [];
209
+ while (ready.length > 0) {
210
+ const next = ready.shift();
211
+ ordered.push(next);
212
+ for (const child of (children.get(next) || []).slice().sort((a, b) => a.localeCompare(b))) {
213
+ const nextDegree = indegree.get(child) - 1;
214
+ indegree.set(child, nextDegree);
215
+ if (nextDegree === 0) {
216
+ ready.push(child);
217
+ ready.sort((a, b) => a.localeCompare(b));
218
+ }
219
+ }
220
+ }
221
+ // 순환이 남으면 ID순으로 붙여 cherry-pick이 멈추지 않게 한다.
222
+ if (ordered.length < ids.length) {
223
+ for (const id of [...ids].sort((a, b) => a.localeCompare(b))) {
224
+ if (!ordered.includes(id))
225
+ ordered.push(id);
226
+ }
227
+ }
228
+ return ordered;
229
+ }
230
+ /**
231
+ * recorded + (active lease | lease 부재) commit만 wave 후보다. revoked는 빼고,
232
+ * `--task`면 그 하나만 검사한다.
233
+ *
234
+ * @param {Ledger} ledger - 원장
235
+ * @param {string | undefined} task - 선택적 단일 task
236
+ * @returns {string[]} 정렬 전 후보 id
237
+ */
238
+ function selectRecordedCommitIds(ledger, task) {
239
+ const candidates = ledger.tasks.filter((entry) => {
240
+ if ((entry.execution_kind || 'commit') === 'verification')
241
+ return false;
242
+ if (entry.status !== 'recorded' || !entry.sha)
243
+ return false;
244
+ // revoked lease는 플래그 없는 wave에서 제외. legacy(lease 없음)는 포함한다.
245
+ if (entry.lease && entry.lease.status !== 'active')
246
+ return false;
247
+ if (task)
248
+ return entry.id === task;
249
+ return true;
250
+ });
251
+ return candidates.map((entry) => entry.id);
252
+ }
253
+ /**
254
+ * fanin candidate worktree를 지운다. 등록이 없거나 경로만 남은 잔해도 제거한다.
255
+ *
256
+ * @param {Exec} exec - git 실행기
257
+ * @param {string} repoRoot - 저장소 루트
258
+ * @param {string} faninPath - candidate 경로
259
+ * @returns {void} candidate 등록과 잔여 디렉터리를 제거하며 파일 삭제 오류는 전파한다
260
+ */
261
+ function removeFaninWorktree(exec, repoRoot, faninPath) {
262
+ try {
263
+ git(exec, repoRoot, ['worktree', 'remove', '--force', faninPath]);
264
+ }
265
+ catch (_error) {
266
+ // 등록이 이미 없으면 디렉터리만 남아 있을 수 있다 — 강제 삭제로 재시드를 연다.
267
+ }
268
+ fs.rmSync(faninPath, { recursive: true, force: true });
269
+ }
270
+ /**
271
+ * cherry-pick 예외·stderr에서 사람이 읽을 수 있는 진단 문자열을 모은다.
272
+ * status/signal은 분류에 쓰지 않는다 — CONFLICT 없는 exit 1도 흔하다.
273
+ *
274
+ * @param {unknown} error - execFileSync 또는 주입 예외
275
+ * @returns {string} stdout·stderr·message를 이어 붙인 원문
276
+ */
277
+ function cherryPickErrorText(error) {
278
+ if (error == null || typeof error !== 'object')
279
+ return String(error ?? '');
280
+ const err = error;
281
+ return [err.stdout, err.stderr, err.message]
282
+ .filter((part) => part != null && part !== '')
283
+ .map((part) => String(part))
284
+ .join('\n');
285
+ }
286
+ /**
287
+ * cherry-pick 실패가 내용 충돌인지 판별한다. fanin-conflict+revoke는 충돌만
288
+ * 해당하므로, bad object·empty commit 같은 다른 실패와 섞이면 안 된다.
289
+ *
290
+ * @param {unknown} error - cherry-pick이 던진 예외
291
+ * @param {Exec} exec - candidate에서 인덱스 조사용
292
+ * @param {string} faninPath - abort 전 candidate cwd
293
+ * @returns {boolean} unmerged 경로 또는 CONFLICT 표식이 있으면 true
294
+ */
295
+ function isCherryPickConflict(error, exec, faninPath) {
296
+ // 1. abort 전에 unmerged index가 있으면 실제 merge conflict다.
297
+ try {
298
+ if (git(exec, faninPath, ['ls-files', '-u']).length > 0)
299
+ return true;
300
+ }
301
+ catch (_probeError) {
302
+ // candidate가 이미 깨졌거나 ls-files가 실패하면 메시지 휴리스틱으로 간다.
303
+ }
304
+ // 2. git은 충돌 시 stderr에 `CONFLICT`를 찍는다. 테스트 seam 주입도 같은 표식을 쓴다.
305
+ const text = cherryPickErrorText(error);
306
+ return /\bCONFLICT\b/.test(text) || /Merge conflict/i.test(text);
307
+ }
308
+ /**
309
+ * light commit wave: cherry-pick 없이 integration HEAD를 fanin verified로 기록한다.
310
+ * 커밋은 이미 integration에서 이뤄졌으므로 HEAD≠integrationHead여도 stale로
311
+ * 보지 않고, 기록된 sha 소유와 증적만 확인한 뒤 integrated로 올린다.
312
+ *
313
+ * @param {object} opts - integrateCommitWave와 동일 인자(deps.runVerification 미사용)
314
+ * @returns {unknown} 성공 시 integrate checkpoint, 실패 시 ok:false
315
+ */
316
+ function integrateLightCommitWave({
317
+ // repoRoot는 공개 시그니처 호환용 — light commit wave는 integration 경로만 쓴다.
318
+ blueprint, task, leaseId, generation, ledgerPath, ledgerHash, exec, writeLedger, integration, }) {
319
+ return withLedgerLock(integration.ledgerFile, (owns) => {
320
+ const opened = openLedgerFenced({
321
+ ledgerFile: integration.ledgerFile, ledgerPath, ledgerHash, validate: validateFaninLedger,
322
+ });
323
+ if (!opened.ok)
324
+ return opened;
325
+ const { ledger } = opened;
326
+ if (ledgerModeOf(ledger) !== 'light') {
327
+ return { ok: false, reason: 'ledger-mode-mismatch' };
328
+ }
329
+ if (task) {
330
+ const item = ledger.tasks.find((x) => x.id === task);
331
+ if (!item)
332
+ return { ok: false, reason: 'task-outside-blueprint' };
333
+ if ((item.execution_kind || 'commit') === 'verification') {
334
+ return { ok: false, reason: 'task-outside-blueprint' };
335
+ }
336
+ const leaseChecked = checkLease(item, {
337
+ ...(leaseId !== undefined ? { lease_id: leaseId } : {}),
338
+ ...(generation !== undefined ? { generation } : {}),
339
+ });
340
+ if (!leaseChecked.ok) {
341
+ const rejected = rejectLeaseMismatch(ledger, item, task, leaseChecked);
342
+ if (leaseChecked.reason === 'stale-lease') {
343
+ if (!owns())
344
+ return { ok: false, reason: 'ledger-lock-lost' };
345
+ writeLedger(integration.ledgerFile, ledger);
346
+ }
347
+ return rejected;
348
+ }
349
+ }
350
+ const selected = selectRecordedCommitIds(ledger, task);
351
+ if (selected.length === 0)
352
+ return { ok: false, reason: 'nothing-to-integrate' };
353
+ const ordered = sortFaninTasks(ledger.tasks, selected);
354
+ const head = git(exec, integration.integrationPath, ['rev-parse', 'HEAD']);
355
+ const baseHead = typeof ledger.integrationHead === 'string' ? ledger.integrationHead : head;
356
+ for (const id of ordered) {
357
+ const item = ledger.tasks.find((x) => x.id === id);
358
+ if (!lightWorkerAssigned(item, integration.integrationPath)
359
+ || !workerOwnsSha(exec, integration.integrationPath, item.sha)) {
360
+ return { ok: false, reason: 'sha-not-owned-by-worker' };
361
+ }
362
+ const evidence = checkWorkerEvidence(integration.integrationPath, integration.integrationPath, blueprint, id, item.sha);
363
+ if (!evidence.ok)
364
+ return evidence;
365
+ }
366
+ // light inline 보고는 파일이 없을 수 있어 부재만 허용하고, 파일이 있으면 full과 같은 검사를 한다.
367
+ if (verificationPolicyAt(integration.integrationPath, blueprint) === 'scoped_terminal') {
368
+ const mismatch = scopedTestReportFailure({
369
+ integrationPath: integration.integrationPath, blueprint, light: true,
370
+ items: ordered.map((id) => ledger.tasks.find((x) => x.id === id)),
371
+ });
372
+ if (mismatch)
373
+ return { ok: false, reason: 'worker-test-report-mismatch', ...mismatch };
374
+ }
375
+ // fanin verified를 남긴 뒤 바로 비운다. candidate worktree·ff는 없다 —
376
+ // HEAD가 이미 기록된 커밋이다. copyEvidenceBundle는 동일 경로라 건너뛴다.
377
+ ledger.fanin = {
378
+ base_head: baseHead,
379
+ candidate_head: head,
380
+ tasks: ordered,
381
+ status: 'verified',
382
+ };
383
+ for (const id of ordered) {
384
+ const item = ledger.tasks.find((x) => x.id === id);
385
+ if (!item)
386
+ continue;
387
+ item.status = transition('recorded', 'integrated');
388
+ }
389
+ ledger.integrationHead = head;
390
+ const decision = {
391
+ kind: 'fanin',
392
+ tasks: [...ordered],
393
+ base_head: baseHead,
394
+ candidate_head: head,
395
+ evidence_id: null,
396
+ };
397
+ ledger.decisions.push(decision);
398
+ ledger.fanin = null;
399
+ if (!owns())
400
+ return { ok: false, reason: 'ledger-lock-lost' };
401
+ writeLedger(integration.ledgerFile, ledger);
402
+ return withCheckpoint({
403
+ ok: true,
404
+ command: 'integrate',
405
+ integrated: [...ordered],
406
+ integrationHead: head,
407
+ verification: { ok: true, command: '', exitCode: 0 },
408
+ ready: readyWaveGated(ledger.tasks, {
409
+ maxParallel: maxParallelForRead(integration.integrationPath),
410
+ }),
411
+ }, ledger, integration.ledgerFile);
412
+ });
413
+ }
414
+ /**
415
+ * integration checkout에서 허용 경로 밖에 있는 미커밋 경로를 모은다.
416
+ * 허용 경로는 seed manifest 경로, 이미 integrated된 task의 evidence 문서, 그리고
417
+ * `coordinate repair`가 만든 dynamic task의 evidence 문서뿐이다.
418
+ * dynamic task 문서는 repair 시점에 integration에 untracked로 쓰이고 integrate 때
419
+ * 비로소 worker 증적으로 덮어써지므로, status가 integrated가 아니어도 허용해야 한다.
420
+ * 넓은 prefix를 허용하면 coordinator가 직접 고친 소스가 섞여 들어와도 통과하므로
421
+ * 파일 단위로만 비교한다. rename·copy(`-z`에서는 새 경로 토큰 뒤에 원래 경로 토큰)는 양쪽 경로를 모두 본다.
422
+ * `git()`은 출력을 trim해 첫 줄의 선행 공백 상태 코드를 깨뜨리므로 exec 원문을 쓴다.
423
+ *
424
+ * @param {Exec} exec - git 호출 seam
425
+ * @param {string} integrationPath - integration checkout 절대 경로
426
+ * @param {string} blueprint - blueprint 상대 경로
427
+ * @param {Ledger} ledger - seedManifest와 task 상태를 읽을 원장
428
+ * @returns {string[]} 허용되지 않은 dirty 경로(정렬, 중복 없음)
429
+ */
430
+ function disallowedDirtyPaths(exec, integrationPath, blueprint, ledger) {
431
+ const raw = String(exec('git', ['status', '--porcelain', '-z', '--untracked-files=all'], {
432
+ cwd: integrationPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
433
+ }));
434
+ const reviewMode = isBlueprintReviewModeAt(integrationPath, blueprint);
435
+ const allowed = new Set((ledger.seedManifest || []).map((entry) => entry.path));
436
+ for (const task of ledger.tasks) {
437
+ if (task.status !== 'integrated' && task.dynamic !== true)
438
+ continue;
439
+ for (const [name] of evidenceFiles(reviewMode)) {
440
+ allowed.add(`${blueprint.replaceAll('\\', '/')}/tasks/${task.id}/${name}`);
441
+ }
442
+ }
443
+ const dirty = new Set();
444
+ // -z 출력은 경로를 C-quote하지 않고 NUL로 끝맺는다. 한국어·따옴표·" -> " 이름도
445
+ // 허용 목록과 같은 원래 문자열로 비교하려면 줄 단위 파싱을 쓸 수 없다.
446
+ const tokens = raw.split('\0');
447
+ for (let i = 0; i < tokens.length; i += 1) {
448
+ const token = tokens[i];
449
+ if (token.length < 4)
450
+ continue;
451
+ const entries = [token.slice(3)];
452
+ // rename·copy는 다음 토큰이 원래 경로이고 그 토큰에는 상태 코드가 없다.
453
+ if (/[RC]/.test(token.slice(0, 2)) && i + 1 < tokens.length) {
454
+ i += 1;
455
+ entries.push(tokens[i]);
456
+ }
457
+ for (const entry of entries) {
458
+ // worktree 등록·CLI 런타임 파일(.bouncer/runtime, config.json)은 source가 아니다.
459
+ // 실제 프로젝트에서는 gitignore라 안 보이지만, .gitignore가 없는 bootstrap fixture에서는
460
+ // ledger 자체가 dirty로 보여 모든 integrate가 거절되므로 이 경로만 제외한다.
461
+ if (entry.startsWith('.worktrees/') || entry.startsWith('.bouncer/runtime/')
462
+ || entry === '.bouncer/config.json')
463
+ continue;
464
+ dirty.add(entry);
465
+ }
466
+ }
467
+ return [...dirty].filter((entry) => !allowed.has(entry)).sort();
468
+ }
469
+ /**
470
+ * 보고의 변경 경로를 읽어 경고 비교에 사용한다. 제목 부재는 빈 보고와 구별한다.
471
+ *
472
+ * @param {string} body - worker 보고 원문
473
+ * @returns {string[] | null} 경로 목록, 제목이 없으면 null
474
+ */
475
+ function reportChangedFiles(body) {
476
+ const sections = parseSections(body, [
477
+ { key: 'changed', re: /^##\s+Changed files\s*$/i },
478
+ { key: 'other', re: /^##\s+(?!Changed files\s*$)\S/i },
479
+ ]);
480
+ if (sections.changed === null)
481
+ return null;
482
+ const out = [];
483
+ for (const line of sections.changed.split('\n')) {
484
+ const m = /^\s*[-*]\s+[^`]*`([^`]+)`/.exec(line);
485
+ if (m)
486
+ out.push(m[1]);
487
+ }
488
+ return out;
489
+ }
490
+ /**
491
+ * worker 커밋의 변경 경로(task evidence 디렉터리 제외)와 보고 `## Changed files`를 대조한다.
492
+ * 불일치는 실패가 아니라 경고다 — 보고 정확도를 드러낼 뿐 통합 판정에는 쓰지 않는다.
493
+ * 보고 파일이 없는 경우(ENOENT)만 unavailable로 흡수하고 다른 읽기 오류는 던진다.
494
+ *
495
+ * @param {Exec} exec - git 호출 seam
496
+ * @param {string} cwd - sha를 읽을 checkout
497
+ * @param {string} blueprint - blueprint 상대 경로
498
+ * @param {Task} item - 대조할 task(workerPath·dispatch·sha 사용)
499
+ * @returns {IntegrateWarning | null} 일치하면 null
500
+ */
501
+ function reportPathsWarning(exec, cwd, blueprint, item) {
502
+ const unavailable = { task: item.id, code: 'report-changed-files-unavailable' };
503
+ if (!item.workerPath || !item.dispatch)
504
+ return unavailable;
505
+ let body;
506
+ try {
507
+ body = fs.readFileSync(reportPathFor(item.workerPath, item.id, item.dispatch.attempt), 'utf8');
508
+ }
509
+ catch (error) {
510
+ if (error.code === 'ENOENT')
511
+ return unavailable;
512
+ throw error;
513
+ }
514
+ const reported = reportChangedFiles(body);
515
+ if (reported === null)
516
+ return unavailable;
517
+ const evidenceDir = `${blueprint.replaceAll('\\', '/')}/tasks/${item.id}/`;
518
+ const committed = git(exec, cwd, ['diff-tree', '--no-commit-id', '--name-only', '-r', item.sha])
519
+ .split('\n').filter((entry) => entry && !entry.startsWith(evidenceDir));
520
+ const reportedSet = new Set(reported);
521
+ const committedSet = new Set(committed);
522
+ const missing = [...reportedSet].filter((entry) => !committedSet.has(entry)).sort();
523
+ const extra = [...committedSet].filter((entry) => !reportedSet.has(entry)).sort();
524
+ if (missing.length === 0 && extra.length === 0)
525
+ return null;
526
+ return { task: item.id, code: 'report-paths-mismatch', missing, extra };
527
+ }
528
+ /**
529
+ * commit wave fan-in: candidate에서 cherry-pick·검증 후 CAS ff만 canonical에 쓴다.
530
+ * task마다 `<baseHead>..<item.sha>` 범위의 worker 커밋을 모두 적용하고 통합 커밋 1개로
531
+ * 합친다(rework로 쌓인 앞 attempt 커밋이 빠지지 않게). 범위 안 내용 충돌은 `fanin-conflict`
532
+ * (그 task만 revoke), 그 밖의 git 실패는 `cherry-pick-failed`, 빈 범위는 `nothing-to-integrate`.
533
+ * light 원장은 cherry-pick 없이 `integrateLightCommitWave`로 보낸다.
534
+ *
535
+ * @param {object} opts - 저장소·blueprint·task·lease·fence와 주입 Exec·writer·runner·integration 경로
536
+ * @returns {unknown} 통합 배열과 checkpoint 또는 기존 reason의 거절. Git·파일 실행 오류는 전파한다
537
+ */
538
+ function integrateCommitWave({ repoRoot, blueprint, task, leaseId, generation, ledgerPath, ledgerHash, exec, writeLedger, deps, integration, }) {
539
+ // light는 full fan-in 복구·candidate 경로와 섞지 않는다. 원장 mode만 본다.
540
+ const peek = loadLedger(integration.ledgerFile);
541
+ if (peek && ledgerModeOf(peek) === 'light') {
542
+ return integrateLightCommitWave({
543
+ repoRoot, blueprint, task, leaseId, generation,
544
+ ledgerPath, ledgerHash, exec, writeLedger, integration,
545
+ });
546
+ }
547
+ const faninPath = integration.faninPath;
548
+ const runner = selectVerificationRunner(deps.runVerification);
549
+ // scoped_terminal이면 구현 wave fan-in은 전체 검증을 돌리지 않는다. 검증은 task별
550
+ // 지정 명령(worker, 보고로 확인)과 마지막 terminal node가 나눠 맡는다. 정책은
551
+ // integration 사본에서 읽는다 — worker 사본은 worker가 바꿀 수 있다.
552
+ const scopedTerminal = verificationPolicyAt(integration.integrationPath, blueprint) === 'scoped_terminal';
553
+ // --- 재시작 복구: 이전 fanin 상태가 있으면 먼저 처리 ---
554
+ const recovery = withLedgerLock(integration.ledgerFile, (owns) => {
555
+ const opened = openLedgerFenced({
556
+ ledgerFile: integration.ledgerFile, ledgerPath, ledgerHash, validate: validateFaninLedger,
557
+ });
558
+ if (!opened.ok)
559
+ return opened;
560
+ const { ledger } = opened;
561
+ if (!ledger.fanin)
562
+ return { ok: true, resume: null };
563
+ const head = git(exec, integration.integrationPath, ['rev-parse', 'HEAD']);
564
+ const fanin = ledger.fanin;
565
+ // verified + HEAD가 candidate면 cherry-pick 없이 번들 복사부터 완결.
566
+ if (fanin.status === 'verified' && fanin.candidate_head && head === fanin.candidate_head) {
567
+ return {
568
+ ok: true,
569
+ resume: {
570
+ mode: 'finish',
571
+ tasks: [...fanin.tasks],
572
+ baseHead: fanin.base_head,
573
+ candidateHead: fanin.candidate_head,
574
+ evidenceId: null,
575
+ },
576
+ };
577
+ }
578
+ // HEAD가 base면 building이거나 ff 전 중단 — candidate만 지우고 원장은
579
+ // phase 1이 덮어쓴다. 여기서 쓰면 fence hash가 깨져 같은 호출의 1단계가 거절된다.
580
+ if (head === fanin.base_head) {
581
+ removeFaninWorktree(exec, repoRoot, faninPath);
582
+ return { ok: true, resume: null };
583
+ }
584
+ // base도 candidate도 아니면 원장·Git이 갈라진 상태.
585
+ removeFaninWorktree(exec, repoRoot, faninPath);
586
+ ledger.fanin = null;
587
+ if (!owns())
588
+ return { ok: false, reason: 'ledger-lock-lost' };
589
+ writeLedger(integration.ledgerFile, ledger);
590
+ return { ok: false, reason: 'stale-integration-head' };
591
+ });
592
+ if (!recovery || typeof recovery !== 'object')
593
+ return recovery;
594
+ if (recovery.ok === false)
595
+ return recovery;
596
+ const resume = recovery.resume;
597
+ if (resume && resume.mode === 'finish') {
598
+ return finishFaninAfterFf({
599
+ repoRoot, blueprint, exec, writeLedger, integration,
600
+ ledgerPath, ledgerHash,
601
+ tasks: resume.tasks,
602
+ baseHead: resume.baseHead,
603
+ candidateHead: resume.candidateHead,
604
+ evidenceId: resume.evidenceId,
605
+ alreadyFastForwarded: true,
606
+ phase1CheckpointHash: null,
607
+ });
608
+ }
609
+ // --- phase 1: 잠금 안에서 대상 확정·fanin building 기록 ---
610
+ let phase1CheckpointHash = null;
611
+ let waveTasks = [];
612
+ let waveScopePaths = [];
613
+ let waveWarnings = [];
614
+ let baseHead = '';
615
+ const phase1 = withLedgerLock(integration.ledgerFile, (owns) => {
616
+ const opened = openLedgerFenced({
617
+ ledgerFile: integration.ledgerFile, ledgerPath, ledgerHash, validate: validateFaninLedger,
618
+ });
619
+ if (!opened.ok)
620
+ return opened;
621
+ const { ledger } = opened;
622
+ // --task + lease 플래그는 대상 확정 전에 checkLease.
623
+ if (task) {
624
+ const item = ledger.tasks.find((x) => x.id === task);
625
+ if (!item)
626
+ return { ok: false, reason: 'task-outside-blueprint' };
627
+ if ((item.execution_kind || 'commit') === 'verification') {
628
+ return { ok: false, reason: 'task-outside-blueprint' };
629
+ }
630
+ const leaseChecked = checkLease(item, {
631
+ ...(leaseId !== undefined ? { lease_id: leaseId } : {}),
632
+ ...(generation !== undefined ? { generation } : {}),
633
+ });
634
+ if (!leaseChecked.ok) {
635
+ const rejected = rejectLeaseMismatch(ledger, item, task, leaseChecked);
636
+ if (leaseChecked.reason === 'stale-lease') {
637
+ if (!owns())
638
+ return { ok: false, reason: 'ledger-lock-lost' };
639
+ writeLedger(integration.ledgerFile, ledger);
640
+ }
641
+ return rejected;
642
+ }
643
+ }
644
+ const selected = selectRecordedCommitIds(ledger, task);
645
+ if (selected.length === 0)
646
+ return { ok: false, reason: 'nothing-to-integrate' };
647
+ const ordered = sortFaninTasks(ledger.tasks, selected);
648
+ const head = git(exec, integration.integrationPath, ['rev-parse', 'HEAD']);
649
+ if (ledger.integrationHead !== head) {
650
+ return { ok: false, reason: 'stale-integration-head' };
651
+ }
652
+ for (const id of ordered) {
653
+ const item = ledger.tasks.find((x) => x.id === id);
654
+ const worker = coordinatorPathsFor({ repoRoot, blueprint, task: id }).workerPath;
655
+ if (item.workerPath !== worker || !registeredWorker(exec, integration.integrationPath, worker)
656
+ || !workerOwnsSha(exec, worker, item.sha)) {
657
+ return { ok: false, reason: 'sha-not-owned-by-worker' };
658
+ }
659
+ const evidence = checkWorkerEvidence(worker, integration.integrationPath, blueprint, id, item.sha);
660
+ if (!evidence.ok)
661
+ return evidence;
662
+ }
663
+ // scoped_terminal은 worker가 자기 지정 명령만 돌렸다는 보고가 있어야 통합한다.
664
+ // wave 전체 검증을 fan-in에서 다시 돌리지 않는 대신 거는 입구 검사다.
665
+ if (scopedTerminal) {
666
+ const mismatch = scopedTestReportFailure({
667
+ integrationPath: integration.integrationPath, blueprint, light: false,
668
+ items: ordered.map((id) => ledger.tasks.find((x) => x.id === id)),
669
+ });
670
+ if (mismatch)
671
+ return { ok: false, reason: 'worker-test-report-mismatch', ...mismatch };
672
+ }
673
+ // coordinator가 integration checkout을 직접 고치면 worker 보고 없이 통합된다.
674
+ // fanin 기록 전에 끝내 거절 시 ledger 바이트가 호출 전과 같다.
675
+ const mutated = disallowedDirtyPaths(exec, integration.integrationPath, blueprint, ledger);
676
+ if (mutated.length > 0)
677
+ return { ok: false, reason: 'integration-source-mutated', paths: mutated };
678
+ // 경고는 결과 JSON에만 싣고 ledger에는 쓰지 않는다.
679
+ waveWarnings = [];
680
+ for (const id of ordered) {
681
+ const warning = reportPathsWarning(exec, integration.integrationPath, blueprint, ledger.tasks.find((x) => x.id === id));
682
+ if (warning)
683
+ waveWarnings.push(warning);
684
+ }
685
+ baseHead = head;
686
+ waveTasks = ordered;
687
+ ledger.fanin = {
688
+ base_head: baseHead,
689
+ candidate_head: null,
690
+ tasks: ordered,
691
+ status: 'building',
692
+ };
693
+ if (!owns())
694
+ return { ok: false, reason: 'ledger-lock-lost' };
695
+ writeLedger(integration.ledgerFile, ledger);
696
+ phase1CheckpointHash = ledgerBytesHash(fs.readFileSync(integration.ledgerFile));
697
+ waveScopePaths = ordered.flatMap((id) => currentScopePaths(ledger.tasks.find((x) => x.id === id)));
698
+ return { ok: true, tasks: ordered, baseHead };
699
+ });
700
+ if (!phase1 || typeof phase1 !== 'object')
701
+ return phase1;
702
+ if (phase1.ok === false)
703
+ return phase1;
704
+ waveTasks = phase1.tasks;
705
+ baseHead = phase1.baseHead;
706
+ // --- phase 2: 잠금 밖 — candidate 생성·cherry-pick·wave 검증 ---
707
+ removeFaninWorktree(exec, repoRoot, faninPath);
708
+ try {
709
+ fs.mkdirSync(path.dirname(faninPath), { recursive: true });
710
+ git(exec, repoRoot, ['worktree', 'add', '--detach', faninPath, baseHead]);
711
+ }
712
+ catch (error) {
713
+ removeFaninWorktree(exec, repoRoot, faninPath);
714
+ throw error;
715
+ }
716
+ const seeded = seedCoordinatorWorker({
717
+ repoRoot: integration.integrationPath, blueprintDir: blueprint, worktreePath: faninPath,
718
+ // fan-in seed도 같은 stdio 고정 wrapper를 받아야 npm 출력이 CLI stdout을 오염시키지 않는다.
719
+ deps: { execFileSync: withInstallStdioToStderr(exec) },
720
+ // candidate는 wave 전체를 합치므로 어느 task든 manifest/lock을 바꿀 수 있으면 공유하지 않는다.
721
+ affectedPaths: waveScopePaths,
722
+ });
723
+ if (!seeded.ok) {
724
+ removeFaninWorktree(exec, repoRoot, faninPath);
725
+ return withLedgerLock(integration.ledgerFile, (owns) => {
726
+ const loaded = loadLedgerBytes(integration.ledgerFile);
727
+ if (!loaded)
728
+ return { ok: false, reason: 'missing-ledger' };
729
+ loaded.ledger.fanin = null;
730
+ if (!owns())
731
+ return { ok: false, reason: 'ledger-lock-lost' };
732
+ writeLedger(integration.ledgerFile, loaded.ledger);
733
+ return seeded;
734
+ });
735
+ }
736
+ // 충돌이 아닌 fan-in 실패: candidate를 지우고 fanin 표식을 풀되 task는 revoke하지 않는다.
737
+ const stopFaninFailed = (id) => {
738
+ removeFaninWorktree(exec, repoRoot, faninPath);
739
+ return withLedgerLock(integration.ledgerFile, (owns) => {
740
+ const loaded = loadLedgerBytes(integration.ledgerFile);
741
+ if (!loaded)
742
+ return { ok: false, reason: 'missing-ledger' };
743
+ loaded.ledger.fanin = null;
744
+ if (!owns())
745
+ return { ok: false, reason: 'ledger-lock-lost' };
746
+ writeLedger(integration.ledgerFile, loaded.ledger);
747
+ return { ok: false, reason: 'cherry-pick-failed', task: id };
748
+ });
749
+ };
750
+ const liveForSha = loadLedger(integration.ledgerFile);
751
+ for (const id of waveTasks) {
752
+ const item = liveForSha?.tasks.find((x) => x.id === id);
753
+ const pickSha = item?.sha;
754
+ if (!pickSha) {
755
+ removeFaninWorktree(exec, repoRoot, faninPath);
756
+ return { ok: false, reason: 'nothing-to-integrate' };
757
+ }
758
+ // 1. 범위 계산: rework로 worker 브랜치에 attempt별 커밋이 쌓이면 item.sha 하나만 pick할 때
759
+ // 앞 커밋의 변경이 통합에서 빠진다. baseHead(phase 1이 고정한 값) 위의 전체 범위를 구한다.
760
+ let range;
761
+ try {
762
+ range = git(exec, faninPath, ['rev-list', '--reverse', `${baseHead}..${pickSha}`])
763
+ .split('\n').filter(Boolean);
764
+ }
765
+ catch (_rangeError) {
766
+ // rev-list 실패(bad object 등)는 충돌이 아니므로 revoke 없이 cherry-pick-failed로 멈춘다.
767
+ return stopFaninFailed(id);
768
+ }
769
+ // 빈 범위: item.sha가 이미 baseHead의 조상이라 통합할 변경이 없다.
770
+ if (range.length === 0) {
771
+ removeFaninWorktree(exec, repoRoot, faninPath);
772
+ return { ok: false, reason: 'nothing-to-integrate' };
773
+ }
774
+ // squash 시 되돌아갈 지점. 단일 커밋 경로는 git 호출 순서를 보존하려고 읽지 않는다.
775
+ let preHead = null;
776
+ try {
777
+ if (range.length > 1)
778
+ preHead = git(exec, faninPath, ['rev-parse', 'HEAD']);
779
+ }
780
+ catch (_headError) {
781
+ return stopFaninFailed(id);
782
+ }
783
+ try {
784
+ for (const commit of range)
785
+ git(exec, faninPath, ['cherry-pick', commit]);
786
+ }
787
+ catch (error) {
788
+ // abort 전에 충돌 여부를 본다 — abort가 unmerged를 지우면 분류가 무너진다.
789
+ const conflict = isCherryPickConflict(error, exec, faninPath);
790
+ try {
791
+ git(exec, faninPath, ['cherry-pick', '--abort']);
792
+ }
793
+ catch (_abortError) {
794
+ // abort 실패도 candidate 삭제로 충분하다 — 진행 중 상태가 fanin에만 있다.
795
+ }
796
+ removeFaninWorktree(exec, repoRoot, faninPath);
797
+ return withLedgerLock(integration.ledgerFile, (owns) => {
798
+ const loaded = loadLedgerBytes(integration.ledgerFile);
799
+ if (!loaded)
800
+ return { ok: false, reason: 'missing-ledger' };
801
+ const { ledger } = loaded;
802
+ if (conflict) {
803
+ // Target behavior: 충돌 task만 revoke. 나머지는 recorded로 남긴다.
804
+ const conflicted = ledger.tasks.find((x) => x.id === id);
805
+ if (conflicted) {
806
+ revokeLease(ledger, conflicted, {
807
+ reason: 'fanin-conflict',
808
+ previousHead: typeof conflicted.sha === 'string' ? conflicted.sha : null,
809
+ });
810
+ }
811
+ ledger.fanin = null;
812
+ if (!owns())
813
+ return { ok: false, reason: 'ledger-lock-lost' };
814
+ writeLedger(integration.ledgerFile, ledger);
815
+ return { ok: false, reason: 'fanin-conflict', task: id };
816
+ }
817
+ // CONFLICT가 아니면 revoke하지 않는다 — 잘못된 fanin-conflict는 requeue를 속인다.
818
+ ledger.fanin = null;
819
+ if (!owns())
820
+ return { ok: false, reason: 'ledger-lock-lost' };
821
+ writeLedger(integration.ledgerFile, ledger);
822
+ return { ok: false, reason: 'cherry-pick-failed', task: id };
823
+ });
824
+ }
825
+ // integration 커밋에 worker 출처를 남긴다. candidateHead를 읽기 전에 끝내야 ledger의
826
+ // fanin.candidate_head가 amend 뒤 SHA를 가리킨다. hook이 fan-in worktree에서 lint를
827
+ // 다시 돌리지 않도록 --no-verify, author/date는 cherry-pick 결과를 그대로 둔다.
828
+ try {
829
+ // 범위가 둘 이상이면 pick 결과를 한 커밋으로 합친다. -C item.sha가 마지막 worker
830
+ // 커밋의 메시지·author·date를 그대로 가져와 아래 log/amend가 trailer만 덧붙이게 한다.
831
+ if (preHead !== null) {
832
+ git(exec, faninPath, ['reset', '--soft', preHead]);
833
+ git(exec, faninPath, ['commit', '--no-verify', '-C', pickSha]);
834
+ }
835
+ const picked = git(exec, faninPath, ['log', '-1', '--format=%B']);
836
+ git(exec, faninPath, [
837
+ 'commit', '--amend', '--no-verify', '-m', appendWorkerCommitTrailer(picked, pickSha),
838
+ ]);
839
+ }
840
+ catch (_amendError) {
841
+ // cherry-pick은 이미 끝나 진행 중 상태가 없으므로 --abort를 부르지 않는다.
842
+ // 합치기·amend 어느 git 실패든 candidate를 지우고 cherry-pick 실패와 같은 reason으로
843
+ // 멈춘다 — trailer 없는 integration 커밋이 verified fanin으로 남으면 출처 연결이 끊긴다.
844
+ return stopFaninFailed(id);
845
+ }
846
+ }
847
+ // gitignore 대상 빌드 산출물은 cherry-pick한 소스로 만들어야 한다. seed 시점의 setup은
848
+ // baseHead 소스를 빌드하므로, 검증 직전에 한 번 더 돌려 wave 변경이 산출물에 반영되게 한다.
849
+ const rebuilt = prepareVerificationDependencies(faninPath, exec);
850
+ if (!rebuilt.ok) {
851
+ removeFaninWorktree(exec, repoRoot, faninPath);
852
+ return withLedgerLock(integration.ledgerFile, (owns) => {
853
+ const loaded = loadLedgerBytes(integration.ledgerFile);
854
+ if (!loaded)
855
+ return { ok: false, reason: 'missing-ledger' };
856
+ loaded.ledger.fanin = null;
857
+ if (!owns())
858
+ return { ok: false, reason: 'ledger-lock-lost' };
859
+ writeLedger(integration.ledgerFile, loaded.ledger);
860
+ return rebuilt;
861
+ });
862
+ }
863
+ const candidateHead = git(exec, faninPath, ['rev-parse', 'HEAD']);
864
+ const intentId = intentIdFor(blueprint);
865
+ const lastTask = waveTasks[waveTasks.length - 1];
866
+ // scoped_terminal은 wave 전체 검증을 생략한다(null). 실패 복구 재검증과 finalize
867
+ // remainder는 각자 별도 경로라 이 생략의 영향을 받지 않는다.
868
+ const verification = scopedTerminal ? null : runTargetVerification(runner, {
869
+ repoRoot: faninPath,
870
+ blueprintDir: blueprint,
871
+ taskId: lastTask,
872
+ scope: {
873
+ kind: 'wave',
874
+ key: `${intentId}:wave:${candidateHead}`,
875
+ },
876
+ });
877
+ if (verification && !verification.ok) {
878
+ removeFaninWorktree(exec, repoRoot, faninPath);
879
+ return withLedgerLock(integration.ledgerFile, (owns) => {
880
+ const loaded = loadLedgerBytes(integration.ledgerFile);
881
+ if (!loaded)
882
+ return { ok: false, reason: 'missing-ledger' };
883
+ const { ledger } = loaded;
884
+ const failed = {
885
+ kind: 'fanin-verification-failed',
886
+ tasks: [...waveTasks],
887
+ command: verification.command,
888
+ exitCode: verification.exitCode,
889
+ evidence_id: typeof verification.evidenceId === 'string' ? verification.evidenceId : null,
890
+ };
891
+ ledger.decisions.push(failed);
892
+ ledger.fanin = null;
893
+ if (!owns())
894
+ return { ok: false, reason: 'ledger-lock-lost' };
895
+ writeLedger(integration.ledgerFile, ledger);
896
+ return {
897
+ ok: false,
898
+ reason: 'wave-verification-failed',
899
+ tasks: [...waveTasks],
900
+ verification,
901
+ };
902
+ });
903
+ }
904
+ // --- phase 3: 잠금 — checkpoint·CAS·verified 기록·ff·번들·integrated ---
905
+ const finished = finishFaninAfterFf({
906
+ repoRoot, blueprint, exec, writeLedger, integration,
907
+ ledgerPath, ledgerHash,
908
+ tasks: waveTasks,
909
+ baseHead,
910
+ candidateHead,
911
+ evidenceId: verification && typeof verification.evidenceId === 'string'
912
+ ? verification.evidenceId : null,
913
+ alreadyFastForwarded: false,
914
+ phase1CheckpointHash,
915
+ ...(verification ? { verification } : {}),
916
+ });
917
+ // 경고가 없으면 키를 생략한다(다른 명령의 warnings 규칙과 같다).
918
+ if (waveWarnings.length > 0 && finished.ok === true) {
919
+ return { ...finished, warnings: waveWarnings };
920
+ }
921
+ return finished;
922
+ }
923
+ /**
924
+ * CAS 확인 후 ff(또는 이미 ff된 복구)와 번들 복사·integrated 전이를 마친다.
925
+ * verified 기록을 먼저 남겨 ff 후 증적 복사가 중단되어도 다음 호출이 복구할 수 있게 한다.
926
+ *
927
+ * @param {object} opts - 저장소·blueprint·Exec·writer·integration과 fence·대상·base/candidate HEAD·검증 증적
928
+ * @returns {unknown} 성공 checkpoint 또는 stale/lock 거절. 증적 복사 오류는 전파하며 verified를 보존한다
929
+ */
930
+ function finishFaninAfterFf({ repoRoot, blueprint, exec, writeLedger, integration, ledgerPath, ledgerHash, tasks, baseHead, candidateHead, evidenceId, alreadyFastForwarded, phase1CheckpointHash, verification, }) {
931
+ const faninPath = integration.faninPath;
932
+ return withLedgerLock(integration.ledgerFile, (owns) => {
933
+ // 정상 wave는 phase1CheckpointHash로만 끼어든 쓰기를 본다. 호출자 fence는
934
+ // 1단계 쓰기 전 값이라 여기서 다시 검사하면 항상 stale가 된다.
935
+ // 재시작 finish(이미 ff됨)만 호출자 fence를 다시 검사한다.
936
+ const rechecked = recheckLedgerCheckpoint({
937
+ ledgerFile: integration.ledgerFile, expectedHash: phase1CheckpointHash,
938
+ fence: { ledgerPath, ledgerHash },
939
+ onStale: () => removeFaninWorktree(exec, repoRoot, faninPath),
940
+ });
941
+ if (!rechecked.ok)
942
+ return rechecked;
943
+ const { ledger } = rechecked;
944
+ const head = git(exec, integration.integrationPath, ['rev-parse', 'HEAD']);
945
+ if (!alreadyFastForwarded) {
946
+ // CAS: canonical이 여전히 base일 때만 ff.
947
+ if (head !== baseHead) {
948
+ removeFaninWorktree(exec, repoRoot, faninPath);
949
+ ledger.fanin = null;
950
+ if (!owns())
951
+ return { ok: false, reason: 'ledger-lock-lost' };
952
+ writeLedger(integration.ledgerFile, ledger);
953
+ return { ok: false, reason: 'stale-integration-head' };
954
+ }
955
+ // ff 전 첫 쓰기: verified + candidate_head. 중단 시 재실행이 finish로 복구.
956
+ ledger.fanin = {
957
+ base_head: baseHead,
958
+ candidate_head: candidateHead,
959
+ tasks: [...tasks],
960
+ status: 'verified',
961
+ };
962
+ if (!owns())
963
+ return { ok: false, reason: 'ledger-lock-lost' };
964
+ writeLedger(integration.ledgerFile, ledger);
965
+ try {
966
+ git(exec, integration.integrationPath, ['merge', '--ff-only', candidateHead]);
967
+ }
968
+ catch (error) {
969
+ // ff 실패는 CAS를 다시 깨뜨린 것과 같다 — candidate만 지우고 원장을 비운다.
970
+ removeFaninWorktree(exec, repoRoot, faninPath);
971
+ const again = loadLedger(integration.ledgerFile);
972
+ if (again) {
973
+ again.fanin = null;
974
+ writeLedger(integration.ledgerFile, again);
975
+ }
976
+ throw error;
977
+ }
978
+ }
979
+ else if (head !== candidateHead) {
980
+ removeFaninWorktree(exec, repoRoot, faninPath);
981
+ ledger.fanin = null;
982
+ if (!owns())
983
+ return { ok: false, reason: 'ledger-lock-lost' };
984
+ writeLedger(integration.ledgerFile, ledger);
985
+ return { ok: false, reason: 'stale-integration-head' };
986
+ }
987
+ for (const id of tasks) {
988
+ const item = ledger.tasks.find((x) => x.id === id);
989
+ if (!item)
990
+ continue;
991
+ const worker = coordinatorPathsFor({ repoRoot, blueprint, task: id }).workerPath;
992
+ copyEvidenceBundle(worker, integration.integrationPath, blueprint, id);
993
+ item.status = transition('recorded', 'integrated');
994
+ }
995
+ ledger.integrationHead = candidateHead;
996
+ const decision = {
997
+ kind: 'fanin',
998
+ tasks: [...tasks],
999
+ base_head: baseHead,
1000
+ candidate_head: candidateHead,
1001
+ evidence_id: evidenceId,
1002
+ };
1003
+ ledger.decisions.push(decision);
1004
+ ledger.fanin = null;
1005
+ if (!owns())
1006
+ return { ok: false, reason: 'ledger-lock-lost' };
1007
+ writeLedger(integration.ledgerFile, ledger);
1008
+ removeFaninWorktree(exec, repoRoot, faninPath);
1009
+ return withCheckpoint({
1010
+ ok: true,
1011
+ command: 'integrate',
1012
+ integrated: [...tasks],
1013
+ integrationHead: candidateHead,
1014
+ verification: verification || { ok: true, command: '', exitCode: 0 },
1015
+ ready: readyWaveGated(ledger.tasks, {
1016
+ maxParallel: maxParallelForRead(integration.integrationPath),
1017
+ }),
1018
+ }, ledger, integration.ledgerFile);
1019
+ });
1020
+ }
1021
+ module.exports = { integrateCommitWave, lightWorkerAssigned, rejectLeaseMismatch };