@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,1908 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { execFileSync: realExecFileSync } = require('node:child_process');
5
+ const runtime = require("./runtime-state");
6
+ const compositeGates = require("./composite-gates");
7
+ const { coordinatorPathsFor, runtimePaths, } = runtime;
8
+ const frontmatter = require("./frontmatter");
9
+ const { readDoc } = frontmatter;
10
+ const render = require("./render");
11
+ const { renderDoc } = render;
12
+ const validateSections = require("./validate-sections");
13
+ const { parseSections } = validateSections;
14
+ const executionDag = require("./execution-dag");
15
+ const { transition } = executionDag;
16
+ const configMod = require("./config");
17
+ const { readWavesPerSession } = configMod;
18
+ const scopeMod = require("./scope");
19
+ const { withLedgerLock } = scopeMod;
20
+ const leaseMod = require("./lease");
21
+ const reviewPolicy = require("./review-policy");
22
+ // worker-model 기록의 관점 소속 검사와 `next` 문구가 validate-sections와 같은 정본을 쓴다.
23
+ const { REVIEW_PERSPECTIVE } = reviewPolicy;
24
+ const coordinatorLedger = require("./coordinator-ledger");
25
+ const coordinatorGit = require("./coordinator-git");
26
+ const coordinatorVerification = require("./coordinator-verification");
27
+ const driveMetrics = require("./drive-metrics");
28
+ const { revokeLease, checkLease } = leaseMod;
29
+ const { randomUUID } = require('node:crypto');
30
+ // 경로 상수와 원장 I/O는 한 모듈에서 공유해 fence와 저장 동작이 갈라지지 않게 한다.
31
+ const { atomicWrite, loadLedgerBytes, loadLedger, ledgerBytesHash, writeOwnedLedger, projectCheckpoint, assertLeaseShape, readyWaveGated, maxParallelForRead, withCheckpoint, openLedgerFenced, recheckLedgerCheckpoint, ledgerModeOf, } = coordinatorLedger;
32
+ const coordinatorRepair = require("./coordinator-repair");
33
+ const coordinatorWorker = require("./coordinator-worker");
34
+ const coordinatorFanin = require("./coordinator-fanin");
35
+ const { integrateCommitWave, lightWorkerAssigned, rejectLeaseMismatch } = coordinatorFanin;
36
+ const { reportPathFor, taskBriefHashOf, readBouncerBlock, workerCommitEvidence, ensureIntegrationCwd, currentScopePaths, intentIdFor, verificationPolicyAt, } = coordinatorWorker;
37
+ const { git, initialWorktreeState, directParent, registeredWorker, registeredIntegration, } = coordinatorGit;
38
+ const { prepareVerificationDependencies, selectVerificationRunner, runTargetVerification, } = coordinatorVerification;
39
+ const coordinatorPrepare = require("./coordinator-prepare");
40
+ const { worktreeDiffSha, prepareCoordinator, coordinateBootstrap, salvageRevokedWorker, } = coordinatorPrepare;
41
+ /**
42
+ * status/bootstrap을 제외한 원장 쓰기 명령. path/hash 쌍이 없거나 어긋나면
43
+ * Git·파일 mutation보다 먼저 거절해야 stale checkpoint로 상태가 갈라지지 않는다.
44
+ */
45
+ const LEDGER_FENCED_COMMANDS = new Set([
46
+ 'prepare', 'dispatch', 'report', 'record', 'rerecord', 'critical-recovery',
47
+ 'repair', 'integrate', 'partial-close', 'revoke', 'promote-stop', 'session-open', 'worker-model', 'worker-tokens',
48
+ 'reverify', 'note',
49
+ ]);
50
+ /** report outcome 열거. CLI·ledger 검증과 같은 집합을 써야 stale/accepted 판정이 갈라지지 않는다. */
51
+ // 타입 합집합의 정본은 execution-contracts다. satisfies로 이 배열이 합집합을 벗어나지 않게 묶는다.
52
+ const REPORT_OUTCOMES = [
53
+ 'accepted', 'rework', 'scope_revision', 'task_change', 'blocked',
54
+ ];
55
+ /** light 승격 정지 사유. CLI·원장·힌트가 같은 다섯 값만 쓴다. */
56
+ const PROMOTE_STOP_REASONS = [
57
+ 'security-risk', 'out-of-scope', 'task-split', 'interface-semantics', 'reviewer-wider-scope',
58
+ ];
59
+ // reviewer·debugger dispatch에 넘긴 모델 인자를 남기는 기록 전용 decision.
60
+ // dispatch·lease·status는 건드리지 않는다.
61
+ // worker 기록 role. implementer는 dispatch --model로도 모델이 남지만, 토큰 합산이 세 role을
62
+ // 같은 키 체계로 다루도록 worker-model에도 허용한다.
63
+ const WORKER_RECORD_ROLES = ['implementer', 'reviewer', 'debugger'];
64
+ /**
65
+ * `--tokens` 값을 안전한 0 이상 정수로 검증한다. CLI는 문자열을, 직접 호출은 숫자를 줄 수 있어
66
+ * 둘 다 받는다. 소수·음수·비숫자·안전 정수 밖의 값은 거절해 합산이 오염되지 않게 한다.
67
+ *
68
+ * @param {unknown} raw - 호스트가 준 토큰 값
69
+ * @returns {number | null} 검증된 정수, 유효하지 않으면 null
70
+ */
71
+ function parseWorkerTokens(raw) {
72
+ if (typeof raw === 'string') {
73
+ if (!/^\d+$/.test(raw))
74
+ return null;
75
+ const n = Number(raw);
76
+ return Number.isSafeInteger(n) ? n : null;
77
+ }
78
+ return typeof raw === 'number' && Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
79
+ }
80
+ /** note 본문 길이 상한(trim 뒤). checkpoint가 매번 싣는 값이라 무한정 키우지 않는다. */
81
+ const NOTE_TEXT_MAX = 2000;
82
+ /**
83
+ * worker 보고 파일 본문에서 Brief revision·Open choices·Known weaknesses를 읽는다.
84
+ * 제목이 없거나 본문이 비면 해당 값은 null이다(빈 섹션도 누락으로 본다 — 값이
85
+ * 없으면 `- none`을 쓰게 해 "작성 안 함"과 "없음"을 구분한다). revision은 두
86
+ * 값 줄이 모두 형식에 맞을 때만 채우고, 하나라도 틀리면 null이다.
87
+ *
88
+ * @param {string} body - 보고 파일 원문
89
+ * @returns {ParsedReportFile} 제목 부재는 null, 항목 목록은 `none` 한 줄이면 빈 배열
90
+ */
91
+ function parseReportFile(body) {
92
+ const sections = parseSections(body, [
93
+ { key: 'revision', re: /^##\s+Brief revision\s*$/i },
94
+ { key: 'open', re: /^##\s+Open choices\s*$/i },
95
+ { key: 'weak', re: /^##\s+Known weaknesses\s*$/i },
96
+ // 다른 ## 제목은 앞 섹션의 끝을 알리는 경계 구실만 한다.
97
+ { key: 'other', re: /^##\s+(?!Brief revision\s*$|Open choices\s*$|Known weaknesses\s*$)\S/i },
98
+ ]);
99
+ let revision = null;
100
+ if (sections.revision) {
101
+ const attemptMatch = /^attempt:\s*(\d+)\s*$/m.exec(sections.revision);
102
+ const hashMatch = /^task_brief_hash:\s*([a-f0-9]{64})\s*$/m.exec(sections.revision);
103
+ if (attemptMatch && hashMatch) {
104
+ revision = { attempt: Number(attemptMatch[1]), task_brief_hash: hashMatch[1] };
105
+ }
106
+ }
107
+ const items = (text) => {
108
+ if (text === null)
109
+ return null;
110
+ const list = text.split('\n').map((line) => line.replace(/^\s*[-*]\s+/, '').trim()).filter(Boolean);
111
+ return list.length === 1 && list[0].toLowerCase() === 'none' ? [] : list;
112
+ };
113
+ return { revision, open_choices: items(sections.open), known_weaknesses: items(sections.weak) };
114
+ }
115
+ /**
116
+ * full 모드 report가 활성 dispatch의 보고 파일을 읽어 revision 출처를 증명한다.
117
+ * 파일 값으로 CLI 인자를 채우거나 누락을 통과시키지 않는다 — 출처 증명이 목적이다.
118
+ * ENOENT만 report-revision-missing으로 흡수하고(아직 안 쓴 보고), 그 밖의 읽기
119
+ * 오류(권한 등)는 다른 실패와 뭉개지 않도록 그대로 던진다.
120
+ *
121
+ * @param {string} reportPath - 활성 attempt의 보고 파일 절대 경로
122
+ * @param {{attempt: number, task_brief_hash: string}} args - CLI로 받은 revision
123
+ * @returns {ReportFileCheck} 거절 본문 또는 통과 시 저장할 항목 목록
124
+ */
125
+ function checkReportFile(reportPath, args) {
126
+ let body;
127
+ try {
128
+ body = fs.readFileSync(reportPath, 'utf8');
129
+ }
130
+ catch (error) {
131
+ if (error.code === 'ENOENT') {
132
+ return { ok: false, failure: { ok: false, reason: 'report-revision-missing', report_path: reportPath } };
133
+ }
134
+ throw error;
135
+ }
136
+ const parsed = parseReportFile(body);
137
+ if (!parsed.revision) {
138
+ return { ok: false, failure: { ok: false, reason: 'report-revision-missing', report_path: reportPath } };
139
+ }
140
+ if (parsed.revision.attempt !== args.attempt
141
+ || parsed.revision.task_brief_hash !== args.task_brief_hash) {
142
+ return { ok: false, failure: { ok: false, reason: 'report-revision-conflict', file: parsed.revision, args } };
143
+ }
144
+ const missing = [
145
+ ...(parsed.open_choices === null ? ['Open choices'] : []),
146
+ ...(parsed.known_weaknesses === null ? ['Known weaknesses'] : []),
147
+ ];
148
+ if (missing.length > 0) {
149
+ return { ok: false, failure: { ok: false, reason: 'report-section-missing', missing, report_path: reportPath } };
150
+ }
151
+ return {
152
+ ok: true, open_choices: parsed.open_choices, known_weaknesses: parsed.known_weaknesses,
153
+ };
154
+ }
155
+ /**
156
+ * verification task 문서 상태를 runner 증적과 같은 checkout에 기록한다.
157
+ * 실패 실행은 verifying을 유지하고 성공만 integrated로 올린다.
158
+ *
159
+ * @param {string} integrationPath - integration checkout 절대 경로
160
+ * @param {string} blueprint - blueprint 상대 경로
161
+ * @param {string} taskId - 세 자리 task 번호
162
+ * @param {'verifying' | 'integrated'} status - 기록할 lifecycle 상태
163
+ * @returns {void}
164
+ */
165
+ function writeVerificationTaskStatus(integrationPath, blueprint, taskId, status) {
166
+ // 실패한 실행은 integrated로 전이하지 않는다.
167
+ const file = path.join(integrationPath, blueprint, 'tasks', taskId, 'tasks.md');
168
+ const doc = readDoc(file);
169
+ const data = doc.data;
170
+ const bouncer = data.bouncer;
171
+ bouncer.status = status;
172
+ fs.writeFileSync(file, renderDoc(data, doc.body));
173
+ }
174
+ function openPromoteTask(tasks) {
175
+ const commits = tasks.filter((item) => (item.execution_kind || 'commit') !== 'verification');
176
+ if (commits.length === 0)
177
+ return null;
178
+ return commits.find((item) => item.status !== 'integrated') || commits[0];
179
+ }
180
+ /**
181
+ * light 원장의 `report accepted`가 요구하는 루트 review.md 라운드·status를 본다.
182
+ * 라운드는 `bouncer review record`만 쓰므로 여기서는 존재와 status만 판정한다.
183
+ *
184
+ * @param {string} integrationPath - integration checkout
185
+ * @param {string} blueprint - blueprint 상대 경로
186
+ * @returns {boolean} rounds가 비어 있지 않고 status가 accepted이면 true
187
+ */
188
+ function lightReviewAccepted(integrationPath, blueprint) {
189
+ const bouncer = readBouncerBlock(path.join(integrationPath, blueprint, 'review.md'));
190
+ if (!bouncer || bouncer.status !== 'accepted')
191
+ return false;
192
+ const review = bouncer.review;
193
+ if (!review || typeof review !== 'object' || Array.isArray(review))
194
+ return false;
195
+ const rounds = review.rounds;
196
+ return Array.isArray(rounds) && rounds.length > 0;
197
+ }
198
+ /**
199
+ * integrate. verification node는 verifying 전이와 결과 기록을 잠금으로 나누고,
200
+ * commit wave는 candidate worktree에서 cherry-pick·검증한 뒤 CAS ff만 canonical에
201
+ * 반영한다. runVerification 동안 원장 잠금 파일이 없게 한다.
202
+ */
203
+ function integrateTask({
204
+ // sha는 CLI 호환으로 타입에만 남기고 구조분해하지 않는다 — fan-in은 원장·lease로 판정.
205
+ repoRoot, blueprint, cwd, task, leaseId, generation, ledgerPath, ledgerHash, exec, writeLedger, deps, integration, }) {
206
+ // wave 형태에 lease 플래그가 오면 어느 task의 lease인지 모호하므로 거절한다.
207
+ if (!task && (leaseId !== undefined || generation !== undefined)) {
208
+ return { ok: false, reason: 'lease-flags-require-task' };
209
+ }
210
+ if (task !== undefined && !/^\d{3}$/.test(task)) {
211
+ return { ok: false, reason: 'task-required' };
212
+ }
213
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
214
+ // fan-in·verification 경로보다 앞에서 막아 후보 worktree를 만들지 않는다.
215
+ {
216
+ const peekStopped = loadLedger(integration.ledgerFile);
217
+ if (peekStopped?.status === 'promotion_stopped') {
218
+ return { ok: false, reason: 'promotion-stopped' };
219
+ }
220
+ }
221
+ // verification --task는 기존 두 단계 경로. commit/wave는 candidate fan-in.
222
+ if (task) {
223
+ const peek = loadLedger(integration.ledgerFile);
224
+ const peekItem = peek?.tasks.find((x) => x.id === task);
225
+ if (peekItem?.execution_kind === 'verification') {
226
+ return integrateVerificationTask({
227
+ repoRoot, blueprint, task, leaseId, generation,
228
+ ledgerPath, ledgerHash, exec, writeLedger, deps, integration,
229
+ });
230
+ }
231
+ }
232
+ return integrateCommitWave({
233
+ repoRoot, blueprint, task, leaseId, generation,
234
+ ledgerPath, ledgerHash, exec, writeLedger, deps, integration,
235
+ });
236
+ }
237
+ /**
238
+ * verification node integrate. terminal scope·repair 한도 2를 보존하고, 명시
239
+ * taskId로 pointer 없이도 그 task의 verify를 고른다. 잠금 해제 뒤·runVerification
240
+ * 앞에서 integration checkout에 prepareDependencies를 돌려, fan-in candidate만
241
+ * npm ci 된 채로 terminal CI가 도는 불일치를 막는다.
242
+ *
243
+ * @param {object} opts - integrateVerificationTask 인자
244
+ * @param {string} opts.blueprint - blueprint 상대 경로
245
+ * @param {string} opts.task - verification task id
246
+ * @param {string} [opts.leaseId] - 공개 시그니처 호환. 이 경로에서는 쓰지 않는다
247
+ * @param {number} [opts.generation] - 공개 시그니처 호환. 이 경로에서는 쓰지 않는다
248
+ * @param {string} [opts.ledgerPath] - fence 원장 상대 경로
249
+ * @param {string} [opts.ledgerHash] - fence sha256
250
+ * @param {Exec} opts.exec - git·npm 주입 execFileSync
251
+ * @param {Function} opts.writeLedger - 원장 원자 쓰기
252
+ * @param {{ runVerification?: VerificationRunner }} opts.deps - 검증 러너 주입
253
+ * @param {object} opts.integration - coordinatorPathsFor 결과. integrationPath가 검증 cwd
254
+ * @param {boolean} [opts.reverify] - true이면 `terminalFailure`가 기록된 verifying terminal을 같은
255
+ * HEAD에서 다시 돌리는 reverify 모드다. 조건을 어기면 상태를 바꾸지 않고 거절하고, 실패해도
256
+ * wave 한도 전이를 하지 않으며, 통과하면 `awaiting_confirmation`을 `active`로 되돌린다
257
+ * @returns {unknown} 성공 시 ok:true와 task·verification. 실패 시 ok:false와 reason
258
+ */
259
+ function integrateVerificationTask({
260
+ // repoRoot는 공개 시그니처 호환용 — 검증 cwd는 integration.integrationPath만 쓴다.
261
+ blueprint, task, leaseId, generation, ledgerPath, ledgerHash, exec, writeLedger, deps, integration, reverify = false, }) {
262
+ let phase1CheckpointHash = null;
263
+ let verificationRetry = false;
264
+ const phase1 = withLedgerLock(integration.ledgerFile, (owns) => {
265
+ const opened = openLedgerFenced({ ledgerFile: integration.ledgerFile, ledgerPath, ledgerHash });
266
+ if (!opened.ok)
267
+ return opened;
268
+ const { ledger } = opened;
269
+ const item = ledger.tasks.find((x) => x.id === task);
270
+ if (!item)
271
+ return { ok: false, reason: 'task-outside-blueprint' };
272
+ const status = item.status || 'pending';
273
+ if (reverify) {
274
+ // 재실행 조건을 전부 통과한 경우에만 아래 잠금 밖 단계로 넘어간다. 이 단계는 원장·문서를
275
+ // 쓰지 않으므로 거절은 항상 상태 불변이다.
276
+ const rejected = reverifyRejection({
277
+ ledger, item, task, blueprint, exec, integrationPath: integration.integrationPath,
278
+ });
279
+ if (rejected)
280
+ return rejected;
281
+ phase1CheckpointHash = ledgerBytesHash(fs.readFileSync(integration.ledgerFile));
282
+ return { ok: true, mode: 'verification', ledger };
283
+ }
284
+ // terminalFailure가 있는 verifying는 repair만 받는다.
285
+ if (status === 'verifying' && ledger.terminalFailure
286
+ && ledger.terminalFailure.task === task) {
287
+ return { ok: false, reason: 'not-ready' };
288
+ }
289
+ if (status === 'verifying' && !(ledger.terminalFailure && ledger.terminalFailure.task === task)) {
290
+ // 전이 없이 검증만 재실행 — repair wave를 소모하지 않는다.
291
+ verificationRetry = true;
292
+ phase1CheckpointHash = ledgerBytesHash(fs.readFileSync(integration.ledgerFile));
293
+ return { ok: true, mode: 'verification', ledger };
294
+ }
295
+ if (status !== 'ready')
296
+ return { ok: false, reason: 'not-ready' };
297
+ item.status = transition('ready', 'verifying', 'verification');
298
+ writeVerificationTaskStatus(integration.integrationPath, blueprint, task, 'verifying');
299
+ if (!owns())
300
+ return { ok: false, reason: 'ledger-lock-lost' };
301
+ writeLedger(integration.ledgerFile, ledger);
302
+ phase1CheckpointHash = ledgerBytesHash(fs.readFileSync(integration.ledgerFile));
303
+ return { ok: true, mode: 'verification', ledger };
304
+ });
305
+ if (!phase1 || typeof phase1 !== 'object')
306
+ return phase1;
307
+ if (phase1.ok === false)
308
+ return phase1;
309
+ // --- 잠금 밖에서 의존성 준비 후 검증 실행 ---
310
+ // seedCoordinatorWorker는 candidate에 npm ci를 하지만 integration worktree는
311
+ // git이 추적한 파일만 받아 node_modules가 비어 있다. 잠금을 잡은 채 설치하면
312
+ // 원장이 오래 막히므로, 이미 푼 검증 phase에서만 같은 계약을 적용한다.
313
+ const prepared = prepareVerificationDependencies(integration.integrationPath, exec);
314
+ if (!prepared.ok) {
315
+ return { ok: false, reason: 'dependency-install-failed', message: prepared.message };
316
+ }
317
+ const runner = selectVerificationRunner(deps.runVerification);
318
+ const intentId = intentIdFor(blueprint);
319
+ const headForKey = (() => {
320
+ const live = loadLedger(integration.ledgerFile);
321
+ if (live && typeof live.integrationHead === 'string')
322
+ return live.integrationHead;
323
+ return git(exec, integration.integrationPath, ['rev-parse', 'HEAD']);
324
+ })();
325
+ const result = runTargetVerification(runner, {
326
+ repoRoot: integration.integrationPath,
327
+ blueprintDir: blueprint,
328
+ // pointer를 옮기지 않으므로 자기 task id로 verify·문서를 고른다.
329
+ taskId: task,
330
+ scope: {
331
+ kind: 'terminal',
332
+ key: `${intentId}:${headForKey}`,
333
+ },
334
+ });
335
+ return withLedgerLock(integration.ledgerFile, (owns) => {
336
+ const rechecked = recheckLedgerCheckpoint({
337
+ ledgerFile: integration.ledgerFile, expectedHash: phase1CheckpointHash,
338
+ });
339
+ if (!rechecked.ok)
340
+ return rechecked;
341
+ const { ledger } = rechecked;
342
+ const item = ledger.tasks.find((x) => x.id === task);
343
+ if (!item)
344
+ return { ok: false, reason: 'task-outside-blueprint' };
345
+ if (!result.ok) {
346
+ const lastRepair = (ledger.repairWaves || []).at(-1);
347
+ // nextScope는 repair 생성 시점(r1) 값이라 scope revision 뒤에는 낡는다.
348
+ // 복구 task의 현재 scope가 정본이므로 그것을 우선하고, task를 못 찾을 때만 기록값으로 물러난다.
349
+ const lastRepairTask = lastRepair ? ledger.tasks.find((x) => x.id === lastRepair.task) : undefined;
350
+ const evidence = {
351
+ task, command: result.command, summary: `exit code ${result.exitCode}`,
352
+ paths: lastRepairTask ? currentScopePaths(lastRepairTask)
353
+ : lastRepair ? [...lastRepair.nextScope] : [...(ledger.terminalFailure?.paths || [])],
354
+ exitCode: result.exitCode, repairWave: (ledger.repairWaves || []).length,
355
+ // 범위 밖 분류가 묶일 원본 identity. 증거 id는 러너가 줄 때만, 소스 identity는 이 검증이
356
+ // 실행된 integration HEAD다. 둘 중 하나라도 없으면 분류를 받을 수 없어 blocked 의미가 유지된다.
357
+ ...(typeof result.evidenceId === 'string' && result.evidenceId !== ''
358
+ ? { evidence_id: result.evidenceId } : {}),
359
+ source_digest: headForKey,
360
+ };
361
+ ledger.terminalFailure = evidence;
362
+ // reverify는 wave를 쓰지 않으므로 한도 상태 전이(awaiting_confirmation)를 건드리지 않는다.
363
+ // 이미 awaiting_confirmation이면 그대로 두어 사용자 확인 대기가 풀리지 않게 한다.
364
+ const exhausted = !reverify && (ledger.repairWaves || []).length >= 2;
365
+ if (exhausted) {
366
+ ledger.status = 'awaiting_confirmation';
367
+ }
368
+ if (!owns())
369
+ return { ok: false, reason: 'ledger-lock-lost' };
370
+ writeLedger(integration.ledgerFile, ledger);
371
+ if (reverify) {
372
+ return { ok: false, reason: 'verification-failed', command: 'reverify', task: item,
373
+ verification: result, repairWaves: (ledger.repairWaves || []).length, stopped: false };
374
+ }
375
+ return { ok: false, reason: 'verification-failed', task: item, verification: result,
376
+ repairWaves: (ledger.repairWaves || []).length, stopped: exhausted };
377
+ }
378
+ item.status = transition('verifying', 'integrated', 'verification');
379
+ if (typeof result.evidenceId === 'string' && result.evidenceId !== '') {
380
+ item.verify_evidence_id = result.evidenceId;
381
+ }
382
+ writeVerificationTaskStatus(integration.integrationPath, blueprint, task, 'integrated');
383
+ // wave 한도로 멈췄던 drive도 환경 수정으로 통과하면 다시 진행할 수 있어야 한다.
384
+ const restored = reverify && ledger.status === 'awaiting_confirmation';
385
+ if (restored)
386
+ ledger.status = 'active';
387
+ if (!owns())
388
+ return { ok: false, reason: 'ledger-lock-lost' };
389
+ writeLedger(integration.ledgerFile, ledger);
390
+ void verificationRetry;
391
+ void leaseId;
392
+ void generation;
393
+ void exec;
394
+ return withCheckpoint({ ok: true, command: reverify ? 'reverify' : 'integrate', task: item,
395
+ verification: result,
396
+ ready: readyWaveGated(ledger.tasks, {
397
+ maxParallel: maxParallelForRead(integration.integrationPath),
398
+ }), decisions: ledger.decisions, ...(restored ? { status_restored: 'active' } : {}) }, ledger, integration.ledgerFile);
399
+ });
400
+ }
401
+ /**
402
+ * reverify 거절 조건을 순서대로 검사한다. 읽기 전용이라 거절해도 원장·문서·작업 트리가 그대로다.
403
+ *
404
+ * @param {object} opts - 검사 입력
405
+ * @param {Ledger} opts.ledger - fence를 통과한 원장
406
+ * @param {Task} opts.item - 대상 task 항목
407
+ * @param {string} opts.task - task id
408
+ * @param {string} opts.blueprint - blueprint 상대 경로(허용 증적 문서 경로 계산용)
409
+ * @param {Exec} opts.exec - git 실행기
410
+ * @param {string} opts.integrationPath - integration checkout
411
+ * @returns {{ ok: false; reason: string } | null} 거절 응답, 통과하면 null
412
+ */
413
+ function reverifyRejection({ ledger, item, task, blueprint, exec, integrationPath, }) {
414
+ const failure = ledger.terminalFailure;
415
+ if (item.execution_kind !== 'verification' || (item.status || 'pending') !== 'verifying'
416
+ || !failure || failure.task !== task) {
417
+ return { ok: false, reason: 'terminal-failure-required' };
418
+ }
419
+ // 실패가 기록된 바로 그 소스에서만 재실행한다. HEAD가 움직였으면 환경 문제가 아니라 코드
420
+ // 변경이므로 repair 경로로 보낸다(coordinator-repair의 source_digest 대조와 같은 기준).
421
+ const digest = failure.source_digest;
422
+ if (typeof digest !== 'string' || digest === ''
423
+ || ledger.integrationHead !== digest || git(exec, integrationPath, ['rev-parse', 'HEAD']) !== digest) {
424
+ return { ok: false, reason: 'reverify-source-moved' };
425
+ }
426
+ // 허용 목록은 coordinator-fanin의 disallowedDirtyPaths와 같은 규칙을 따른다(그 함수는 비공개라
427
+ // 여기서 미러링한다): 원장 seedManifest 경로(bootstrap이 seed한 계획 문서; 추적 파일이면 수정으로,
428
+ // 아니면 미추적으로 보인다) + 통합된 task의 증적 문서 + 이번 terminal이 스스로 쓰는 증적 문서.
429
+ const base = blueprint.replaceAll('\\', '/');
430
+ const allowed = new Set((ledger.seedManifest || []).map((entry) => entry.path));
431
+ allowed.add(`${base}/tasks/${task}/tasks.md`);
432
+ allowed.add(`${base}/tasks/${task}/verification.md`);
433
+ allowed.add(`${base}/verification.md`);
434
+ const reviewMode = coordinatorWorker.isBlueprintReviewModeAt(integrationPath, blueprint);
435
+ for (const other of ledger.tasks) {
436
+ if (other.status !== 'integrated' && other.dynamic !== true)
437
+ continue;
438
+ for (const [name] of coordinatorWorker.evidenceFiles(reviewMode)) {
439
+ allowed.add(`${base}/tasks/${other.id}/${name}`);
440
+ }
441
+ }
442
+ const porcelain = String(exec('git', ['status', '--porcelain', '-z', '--untracked-files=all'], {
443
+ cwd: integrationPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
444
+ }));
445
+ const dirty = [];
446
+ // -z는 경로를 C-quote하지 않고 NUL로 끝맺으므로 비ASCII·따옴표 경로도 허용 목록과 원문 비교된다.
447
+ const tokens = porcelain.split('\0');
448
+ for (let i = 0; i < tokens.length; i += 1) {
449
+ const token = tokens[i];
450
+ if (token.length < 4)
451
+ continue;
452
+ const entries = [token.slice(3)];
453
+ // rename·copy는 다음 토큰이 원래 경로이고 그 토큰에는 상태 코드가 없다.
454
+ if (/[RC]/.test(token.slice(0, 2)) && i + 1 < tokens.length) {
455
+ i += 1;
456
+ entries.push(tokens[i]);
457
+ }
458
+ for (const entry of entries) {
459
+ // worktree 등록·CLI 런타임 파일은 source가 아니다(fanin과 같은 제외).
460
+ if (entry.startsWith('.worktrees/') || entry.startsWith('.bouncer/runtime/')
461
+ || entry === '.bouncer/config.json')
462
+ continue;
463
+ if (!allowed.has(entry))
464
+ dirty.push(entry);
465
+ }
466
+ }
467
+ if (dirty.length > 0)
468
+ return { ok: false, reason: 'reverify-worktree-dirty' };
469
+ return null;
470
+ }
471
+ /** `coordinate`가 받는 명령 인자. 공개 경계(`coordinateWithHints`)도 이 타입을 그대로 쓴다. */
472
+ /**
473
+ * `status`: 잠금 없이 읽기 전용으로 checkpoint와 세션 지표를 돌려준다.
474
+ *
475
+ * @param {CoordinateCtx} ctx - 공통 준비가 끝난 handler 문맥
476
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
477
+ */
478
+ function coordinateStatus(ctx) {
479
+ const { cwd, main, integration } = ctx;
480
+ const { command, repoRoot, blueprint } = ctx.args;
481
+ const loaded = loadLedgerBytes(integration.ledgerFile);
482
+ if (!loaded)
483
+ return { ok: false, reason: 'missing-ledger' };
484
+ const leaseShape = assertLeaseShape(loaded.ledger);
485
+ if (!leaseShape.ok)
486
+ return leaseShape;
487
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
488
+ // drive-metrics.json은 main worktree에 있다(integration이 아니다). 읽기 전용 관측이라
489
+ // 기록이 없거나 깨져도 status 판정에 영향을 주지 않는다.
490
+ const sessions = driveMetrics.listCoordinatorSessions({
491
+ file: driveMetrics.metricsFilePath(main.projectRoot), blueprint,
492
+ });
493
+ return {
494
+ ok: true,
495
+ command,
496
+ checkpoint: {
497
+ ...projectCheckpoint(loaded.ledger, integration.ledgerFile, loaded.bytes),
498
+ sessions,
499
+ },
500
+ };
501
+ }
502
+ /**
503
+ * `integrate`: 검증 실행을 잠금 밖에 두려고 `integrateTask`가 내부에서 두 단계 lock을 잡는다.
504
+ *
505
+ * @param {CoordinateCtx} ctx - 공통 준비가 끝난 handler 문맥
506
+ * @returns {CoordinateResult} `integrateTask` 결과
507
+ */
508
+ function coordinateIntegrate(ctx) {
509
+ const { repoRoot, blueprint, task, sha, leaseId, generation, ledgerPath, ledgerHash } = ctx.args;
510
+ const { cwd, exec, writeLedger, deps, integration } = ctx;
511
+ return integrateTask({
512
+ repoRoot, blueprint, cwd, task, sha, leaseId, generation,
513
+ ledgerPath, ledgerHash, exec, writeLedger, deps,
514
+ integration,
515
+ });
516
+ }
517
+ /**
518
+ * `reverify`: 환경만 고치면 통과할 terminal 검증 실패를 repair wave 없이 같은 HEAD에서 다시 돌린다.
519
+ * 조건 검사와 2단계 실행은 `integrateVerificationTask`의 reverify 모드가 맡는다.
520
+ *
521
+ * @param {CoordinateCtx} ctx - 공통 준비가 끝난 handler 문맥
522
+ * @returns {CoordinateResult} 통과·실패·거절 결과
523
+ */
524
+ function coordinateReverify(ctx) {
525
+ const { repoRoot, blueprint, task, ledgerPath, ledgerHash } = ctx.args;
526
+ const { cwd, exec, writeLedger, deps, integration } = ctx;
527
+ if (task === undefined || !/^\d{3}$/.test(task))
528
+ return { ok: false, reason: 'task-required' };
529
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
530
+ // integrate와 같이 승격이 멈춘 drive에서는 검증을 시작하지 않는다.
531
+ if (loadLedger(integration.ledgerFile)?.status === 'promotion_stopped') {
532
+ return { ok: false, reason: 'promotion-stopped' };
533
+ }
534
+ return integrateVerificationTask({
535
+ repoRoot, blueprint, task, ledgerPath, ledgerHash, exec, writeLedger, deps, integration, reverify: true,
536
+ });
537
+ }
538
+ /**
539
+ * `prepare`: seed(npm ci)가 LOCK_STALE_MS를 넘길 수 있어 공통 fenced 잠금 앞에서 처리하고,
540
+ * 잠금은 `prepareCoordinator`가 내부에서 잡는다.
541
+ *
542
+ * @param {CoordinateCtx} ctx - 공통 준비가 끝난 handler 문맥
543
+ * @returns {CoordinateResult} `prepareCoordinator` 결과
544
+ */
545
+ function coordinatePrepare(ctx) {
546
+ const { repoRoot, blueprint, ledgerPath, ledgerHash } = ctx.args;
547
+ const { cwd, exec, writeLedger, makeLeaseId, integration } = ctx;
548
+ return prepareCoordinator({
549
+ repoRoot, blueprint, cwd, ledgerPath, ledgerHash, exec, writeLedger, makeLeaseId, integration,
550
+ });
551
+ }
552
+ /**
553
+ * `repair --kind supplement --done`: 검증을 잠금 밖에서 돌려야 해서 공통 잠금 앞에서 갈라지며,
554
+ * 두 단계 lock은 `supplementDone`이 잡는다.
555
+ *
556
+ * @param {CoordinateCtx} ctx - 공통 준비가 끝난 handler 문맥
557
+ * @returns {CoordinateResult} `supplementDone` 결과
558
+ */
559
+ function coordinateSupplementDone(ctx) {
560
+ const { repoRoot, blueprint, ledgerPath, ledgerHash } = ctx.args;
561
+ const { cwd, exec, writeLedger, deps, integration } = ctx;
562
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
563
+ return coordinatorRepair.supplementDone({
564
+ support: REPAIR_SUPPORT,
565
+ blueprint, integrationPath: integration.integrationPath, ledgerFile: integration.ledgerFile,
566
+ ledgerPath, ledgerHash, exec, writeLedger, deps,
567
+ });
568
+ }
569
+ /**
570
+ * `promote-stop`: light 원장을 promotion_stopped로 멈춘다. 멱등 재실행만 허용하는 유일한 fence mutation이라
571
+ * promotion_stopped 거절보다 먼저 호출된다.
572
+ *
573
+ * @param {LockedCtx} ctx - 공통 준비가 끝난 handler 문맥
574
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
575
+ */
576
+ function coordinatePromoteStop(ctx) {
577
+ const { cwd, exec, integration, ledger, commitWrite } = ctx;
578
+ const { command, repoRoot, blueprint, summary, reason } = ctx.args;
579
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
580
+ if (ledgerModeOf(ledger) !== 'light') {
581
+ return { ok: false, reason: 'promote-stop-requires-light' };
582
+ }
583
+ if (typeof reason !== 'string'
584
+ || !PROMOTE_STOP_REASONS.includes(reason)) {
585
+ return { ok: false, reason: 'promote-reason-invalid' };
586
+ }
587
+ if (typeof summary !== 'string' || summary.trim() === '') {
588
+ return { ok: false, reason: 'summary-required' };
589
+ }
590
+ const promoteReason = reason;
591
+ const promoteSummary = summary.trim();
592
+ if (ledger.status === 'promotion_stopped') {
593
+ const existing = ledger.promotion;
594
+ if (existing
595
+ && existing.reason === promoteReason
596
+ && existing.summary === promoteSummary) {
597
+ return withCheckpoint({
598
+ ok: true, command, status: 'promotion_stopped',
599
+ promotion: existing,
600
+ }, ledger, integration.ledgerFile);
601
+ }
602
+ return { ok: false, reason: 'promotion-already-stopped' };
603
+ }
604
+ const openTask = openPromoteTask(ledger.tasks);
605
+ if (!openTask)
606
+ return { ok: false, reason: 'task-outside-blueprint' };
607
+ // fanin·terminalFailure는 건드리지 않는다. 정지는 promotion 필드만으로 기록한다.
608
+ const promotion = {
609
+ reason: promoteReason,
610
+ summary: promoteSummary,
611
+ task: openTask.id,
612
+ diff_sha: worktreeDiffSha(exec, integration.integrationPath),
613
+ };
614
+ if (typeof openTask.verify_evidence_id === 'string' && openTask.verify_evidence_id !== '') {
615
+ promotion.verify_evidence_id = openTask.verify_evidence_id;
616
+ }
617
+ if (typeof openTask.review_evidence_id === 'string' && openTask.review_evidence_id !== '') {
618
+ promotion.review_evidence_id = openTask.review_evidence_id;
619
+ }
620
+ ledger.promotion = promotion;
621
+ ledger.status = 'promotion_stopped';
622
+ {
623
+ const lost = commitWrite();
624
+ if (lost)
625
+ return lost;
626
+ }
627
+ return withCheckpoint({
628
+ ok: true, command, status: 'promotion_stopped', promotion,
629
+ }, ledger, integration.ledgerFile);
630
+ }
631
+ /**
632
+ * `session-open`: 세션 경계 표지를 원장에 덧붙인다. 다른 fenced mutation과 같이 fence·잠금 안에서 쓰고
633
+ * 새 checkpoint를 돌려줘 root가 곧바로 `coordinate next`를 이어 부를 수 있게 한다.
634
+ *
635
+ * @param {LockedCtx} ctx - 공통 준비가 끝난 handler 문맥
636
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
637
+ */
638
+ function coordinateSessionOpen(ctx) {
639
+ const { cwd, exec, integration, ledger, commitWrite } = ctx;
640
+ const { command, repoRoot, blueprint } = ctx.args;
641
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
642
+ // light 원장은 wave·fanin이 없어 세션 경계가 의미 없다. 거절 코드는 기존 것을 쓴다.
643
+ if (ledgerModeOf(ledger) !== 'full')
644
+ return { ok: false, reason: 'ledger-mode-mismatch' };
645
+ // promotion_stopped는 위에서 이미 거절됐다. 남은 비활성 상태(부분 종료·사용자 확인 대기)에서
646
+ // 새 세션을 열면 닫힌 drive를 되살리는 것처럼 보이므로 원장을 바꾸지 않고 막는다.
647
+ if (ledger.status !== undefined && ledger.status !== 'active') {
648
+ return { ok: false, reason: 'ledger-not-active' };
649
+ }
650
+ // 설정이 invalid여도 여기서 거절하지 않는다(거절은 prepare 몫). next가 같은 값을 1로
651
+ // 접으므로 표지에도 1을 남겨 기록과 판정이 어긋나지 않게 한다.
652
+ const wavesPolicy = readWavesPerSession(integration.integrationPath);
653
+ const session = {
654
+ kind: 'session',
655
+ opened_at: new Date().toISOString(),
656
+ integration_head: git(exec, integration.integrationPath, ['rev-parse', 'HEAD']),
657
+ waves_cap: wavesPolicy.ok ? wavesPolicy.cap : 1,
658
+ };
659
+ ledger.decisions.push(session);
660
+ {
661
+ const lost = commitWrite();
662
+ if (lost)
663
+ return lost;
664
+ }
665
+ return withCheckpoint({
666
+ ok: true, command, decision: session,
667
+ }, ledger, integration.ledgerFile);
668
+ }
669
+ /**
670
+ * `note`: 다음 세션에 넘길 판단 메모를 원장 decisions 끝에 덧붙인다. task의 decisions·dispatch·lease·
671
+ * status는 건드리지 않고(원장 전역 기록만), 검증을 모두 통과한 뒤에만 commitWrite()를 부른다.
672
+ *
673
+ * @param {LockedCtx} ctx - 공통 준비가 끝난 handler 문맥
674
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
675
+ */
676
+ function coordinateNote(ctx) {
677
+ const { cwd, integration, ledger, commitWrite } = ctx;
678
+ const { command, repoRoot, blueprint, task, text } = ctx.args;
679
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
680
+ // light 원장은 세션 경계가 없어 인계 대상이 아니다. 거절 reason은 session-open과 같다.
681
+ if (ledgerModeOf(ledger) !== 'full')
682
+ return { ok: false, reason: 'ledger-mode-mismatch' };
683
+ if (ledger.status !== undefined && ledger.status !== 'active') {
684
+ return { ok: false, reason: 'ledger-not-active' };
685
+ }
686
+ // 값 없는 플래그(true)나 비문자열은 빈 값과 같이 취급한다. 길이는 trim 뒤 기준이라 공백 패딩이 상한을 깎지 않는다.
687
+ const body = typeof text === 'string' ? text.trim() : '';
688
+ if (body === '')
689
+ return { ok: false, reason: 'note-text-required' };
690
+ if (body.length > NOTE_TEXT_MAX)
691
+ return { ok: false, reason: 'note-too-long' };
692
+ if (task !== undefined && !ledger.tasks.some((x) => x.id === task)) {
693
+ return { ok: false, reason: 'task-outside-blueprint' };
694
+ }
695
+ const note = {
696
+ kind: 'note', text: body, written_at: new Date().toISOString(), ...(task !== undefined ? { task } : {}),
697
+ };
698
+ ledger.decisions.push(note);
699
+ {
700
+ const lost = commitWrite();
701
+ if (lost)
702
+ return lost;
703
+ }
704
+ return withCheckpoint({
705
+ ok: true, command, decision: note,
706
+ }, ledger, integration.ledgerFile);
707
+ }
708
+ /**
709
+ * `worker-model`·`worker-tokens`: 기록 전용 명령. dispatch·lease·status·attempt를 바꾸지 않고
710
+ * decision만 덧붙이므로 검증을 모두 통과한 뒤에만 commitWrite()를 부른다.
711
+ *
712
+ * @param {LockedCtx} ctx - 공통 준비가 끝난 handler 문맥
713
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
714
+ */
715
+ function coordinateWorkerRecord(ctx) {
716
+ const { cwd, integration, ledger, commitWrite } = ctx;
717
+ const { command, repoRoot, blueprint, task, model, role, perspective, tokens } = ctx.args;
718
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
719
+ if (role === undefined || !WORKER_RECORD_ROLES.includes(role)) {
720
+ return { ok: false, reason: 'worker-model-role-invalid' };
721
+ }
722
+ const recordRole = role;
723
+ // 값 없는 플래그(true)와 공백 문자열은 모두 빈 값이다. 누락(undefined)은 CLI가 usage로 막는다.
724
+ if (command === 'worker-model'
725
+ && (typeof model !== 'string' || model.trim() === '')) {
726
+ return { ok: false, reason: 'model-invalid' };
727
+ }
728
+ // 관점은 reviewer 전용이고 execute review 관점 열거 안이어야 한다.
729
+ if (perspective !== undefined
730
+ && (recordRole !== 'reviewer'
731
+ || !REVIEW_PERSPECTIVE.includes(perspective))) {
732
+ return { ok: false, reason: 'worker-model-perspective-invalid' };
733
+ }
734
+ // worker-model은 tokens가 선택이라 없으면 키를 쓰지 않고, worker-tokens는 필수다.
735
+ const parsedTokens = tokens === undefined ? null : parseWorkerTokens(tokens);
736
+ if ((tokens !== undefined || command === 'worker-tokens') && parsedTokens === null) {
737
+ return { ok: false, reason: 'tokens-invalid' };
738
+ }
739
+ let owner;
740
+ if (task !== undefined) {
741
+ owner = ledger.tasks.find((x) => x.id === task);
742
+ if (!owner)
743
+ return { ok: false, reason: 'task-outside-blueprint' };
744
+ }
745
+ const scope = {
746
+ ...(task !== undefined ? { task } : {}),
747
+ ...(perspective !== undefined ? { perspective } : {}),
748
+ };
749
+ const recorded = command === 'worker-model'
750
+ ? {
751
+ kind: 'worker-model', role: recordRole, model: model.trim(), ...scope,
752
+ ...(parsedTokens !== null ? { tokens: parsedTokens } : {}),
753
+ }
754
+ : { kind: 'worker-tokens', role: recordRole, tokens: parsedTokens, ...scope };
755
+ if (owner)
756
+ owner.decisions = [...(owner.decisions || []), recorded];
757
+ ledger.decisions.push(recorded);
758
+ {
759
+ const lost = commitWrite();
760
+ if (lost)
761
+ return lost;
762
+ }
763
+ return withCheckpoint({
764
+ ok: true, command, decision: recorded,
765
+ }, ledger, integration.ledgerFile);
766
+ }
767
+ /**
768
+ * `critical-recovery` 1단계: task 조회 전에 ledger 전체 검증만 한다. 통과(null)하면 호출부가
769
+ * task 조회와 2단계(`coordinateCriticalRecovery`)로 진행한다.
770
+ *
771
+ * @param {LockedCtx} ctx - 공통 준비가 끝난 handler 문맥
772
+ * @returns {CoordinateResult | null} 명령 결과. 거절은 `ok: false`와 reason
773
+ */
774
+ function criticalRecoveryPrecheck(ctx) {
775
+ const { cwd, ledger } = ctx;
776
+ const { repoRoot, blueprint } = ctx.args;
777
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
778
+ const checked = runtime.validateCoordinatorLedger(ledger);
779
+ if (!checked.ok)
780
+ return { ok: false, reason: checked.reason };
781
+ return null;
782
+ }
783
+ /**
784
+ * `revoke`: prepared·recorded task의 lease를 회수한다.
785
+ *
786
+ * @param {LockedCtx} ctx - 공통 준비가 끝난 handler 문맥
787
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
788
+ */
789
+ function coordinateRevoke(ctx) {
790
+ const { cwd, integration, ledger, commitWrite } = ctx;
791
+ const { command, repoRoot, blueprint, task, reason } = ctx.args;
792
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
793
+ if (!task || !/^\d{3}$/.test(task))
794
+ return { ok: false, reason: 'task-required' };
795
+ const revokeItem = ledger.tasks.find((x) => x.id === task);
796
+ if (!revokeItem)
797
+ return { ok: false, reason: 'task-outside-blueprint' };
798
+ if (typeof reason !== 'string' || reason.trim() === '') {
799
+ return { ok: false, reason: 'decision-reason-required' };
800
+ }
801
+ const status = revokeItem.status || 'pending';
802
+ if (status !== 'prepared' && status !== 'recorded') {
803
+ return { ok: false, reason: 'illegal-transition' };
804
+ }
805
+ const previousHead = typeof revokeItem.sha === 'string' ? revokeItem.sha : null;
806
+ const revoked = revokeLease(ledger, revokeItem, {
807
+ reason: reason.trim(), previousHead,
808
+ });
809
+ {
810
+ const lost = commitWrite();
811
+ if (lost)
812
+ return lost;
813
+ }
814
+ return withCheckpoint({
815
+ ok: true, command, task: revokeItem, decision: revoked,
816
+ }, ledger, integration.ledgerFile);
817
+ }
818
+ /**
819
+ * `dispatch`·`report`·`record`의 lease 검사. attempt·brief hash·상태 검사보다 앞에 둔다. revoke 뒤
820
+ * 늦은 플래그 호출이 illegal-transition으로 가려지지 않게 한다. stale-lease 거절은 증적을 원장에 남긴다.
821
+ *
822
+ * @param {TaskCtx} ctx - 공통 준비가 끝난 handler 문맥
823
+ * @returns {CoordinateResult | null} 명령 결과. 거절은 `ok: false`와 reason
824
+ */
825
+ function checkTaskLease(ctx) {
826
+ const { task, ledger, commitWrite, item } = ctx;
827
+ const { leaseId, generation } = ctx.args;
828
+ const leaseChecked = checkLease(item, {
829
+ ...(leaseId !== undefined ? { lease_id: leaseId } : {}),
830
+ ...(generation !== undefined ? { generation } : {}),
831
+ });
832
+ if (!leaseChecked.ok) {
833
+ const rejected = rejectLeaseMismatch(ledger, item, task, leaseChecked);
834
+ if (leaseChecked.reason === 'stale-lease') {
835
+ const lost = commitWrite();
836
+ if (lost)
837
+ return lost;
838
+ }
839
+ return rejected;
840
+ }
841
+ return null;
842
+ }
843
+ /**
844
+ * `dispatch`·`report` 공통 가드. implementer 경계라 할당 worker에서만 열리며 task 상태(prepared)는
845
+ * 유지하고 attempt 상태만 바꾼다. light는 workerPath===integration만 허용한다.
846
+ *
847
+ * @param {TaskCtx} ctx - 공통 준비가 끝난 handler 문맥
848
+ * @returns {CoordinateResult | null} 명령 결과. 거절은 `ok: false`와 reason
849
+ */
850
+ function guardWorkerDispatch(ctx) {
851
+ const { task, cwd, exec, integration, ledger, item } = ctx;
852
+ const { repoRoot, blueprint } = ctx.args;
853
+ if (ledgerModeOf(ledger) === 'light') {
854
+ if (!lightWorkerAssigned(item, integration.integrationPath)) {
855
+ return {
856
+ ok: false, reason: 'unassigned-worker-worktree',
857
+ workerPath: item.workerPath || integration.integrationPath,
858
+ };
859
+ }
860
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
861
+ }
862
+ else {
863
+ const worker = coordinatorPathsFor({ repoRoot, blueprint, task }).workerPath;
864
+ if (item.workerPath !== worker || !registeredWorker(exec, integration.integrationPath, worker)) {
865
+ return { ok: false, reason: 'unassigned-worker-worktree', workerPath: worker };
866
+ }
867
+ ensureIntegrationCwd(repoRoot, blueprint, cwd, task);
868
+ }
869
+ if (item.execution_kind === 'verification') {
870
+ return { ok: false, reason: 'dispatch-commit-task-required' };
871
+ }
872
+ if ((item.status || 'pending') !== 'prepared')
873
+ return { ok: false, reason: 'illegal-transition' };
874
+ return null;
875
+ }
876
+ /**
877
+ * `dispatch`: 새 attempt를 열고 brief hash·base HEAD를 원장에 고정한다.
878
+ *
879
+ * @param {TaskCtx} ctx - 공통 준비가 끝난 handler 문맥
880
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
881
+ */
882
+ function coordinateDispatch(ctx) {
883
+ const { task, cwd, exec, integration, ledger, commitWrite, item } = ctx;
884
+ const { command, blueprint, model } = ctx.args;
885
+ // --model은 선택이지만 줬다면 비어 있으면 안 된다. attempt를 열기 전에 거절해
886
+ // 잘못된 값이 원장에 반쯤 남지 않게 한다.
887
+ if (model !== undefined && (typeof model !== 'string' || model.trim() === '')) {
888
+ return { ok: false, reason: 'model-invalid' };
889
+ }
890
+ if (item.dispatch?.status === 'active') {
891
+ return { ok: false, reason: 'dispatch-already-active' };
892
+ }
893
+ // 직전 reported attempt가 있을 때만 previous_outcome을 싣는다. 최초 1회는 필드를
894
+ // 생략해 "없음"과 빈 객체를 구분한다.
895
+ const previousOutcome = item.dispatch?.status === 'reported'
896
+ && item.dispatch.outcome && item.dispatch.summary
897
+ ? { outcome: item.dispatch.outcome, summary: item.dispatch.summary }
898
+ : undefined;
899
+ let baseHead;
900
+ let porcelain;
901
+ try {
902
+ baseHead = git(exec, cwd, ['rev-parse', 'HEAD']);
903
+ porcelain = initialWorktreeState(exec, cwd);
904
+ }
905
+ catch (_error) {
906
+ // HEAD·porcelain 조회 실패는 attempt를 열지 않는다. 부분 ledger를 남기면 재개가
907
+ // 깨진 baseline을 정본으로 삼는다.
908
+ return { ok: false, reason: 'dispatch-git-read-failed' };
909
+ }
910
+ const hash = taskBriefHashOf(cwd, blueprint, task);
911
+ const nextAttempt = (item.dispatch?.attempt || 0) + 1;
912
+ item.dispatch = {
913
+ attempt: nextAttempt, task_brief_hash: hash, base_head: baseHead,
914
+ initial_worktree_state: porcelain, status: 'active',
915
+ ...(model !== undefined ? { model: model.trim() } : {}),
916
+ };
917
+ const dispatchDecision = {
918
+ task, kind: 'dispatch', attempt: nextAttempt, task_brief_hash: hash,
919
+ base_head: baseHead, initial_worktree_state: porcelain,
920
+ ...(model !== undefined ? { model: model.trim() } : {}),
921
+ };
922
+ item.decisions = [...(item.decisions || []), dispatchDecision];
923
+ ledger.decisions.push(dispatchDecision);
924
+ {
925
+ const lost = commitWrite();
926
+ if (lost)
927
+ return lost;
928
+ }
929
+ const metadata = {
930
+ attempt: nextAttempt, task_brief_hash: hash, base_head: baseHead,
931
+ initial_worktree_state: porcelain,
932
+ };
933
+ if (previousOutcome)
934
+ metadata.previous_outcome = previousOutcome;
935
+ const report_path = reportPathFor(item.workerPath, task, nextAttempt);
936
+ return withCheckpoint({
937
+ ok: true, command, metadata, report_path, task: item, decisions: ledger.decisions,
938
+ }, ledger, integration.ledgerFile);
939
+ }
940
+ /**
941
+ * `report`: 활성 attempt의 worker 보고를 검증해 reported로 올린다. mismatch는 stale 증적만 남긴다.
942
+ *
943
+ * @param {TaskCtx} ctx - 공통 준비가 끝난 handler 문맥
944
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
945
+ */
946
+ function coordinateReport(ctx) {
947
+ const { task, exec, integration, ledger, commitWrite, item } = ctx;
948
+ const { command, blueprint, summary, outcome, attempt, taskBriefHash } = ctx.args;
949
+ if (!item.dispatch || item.dispatch.status !== 'active') {
950
+ return { ok: false, reason: 'no-active-dispatch' };
951
+ }
952
+ if (!Number.isInteger(attempt) || attempt < 1) {
953
+ return { ok: false, reason: 'invalid-attempt' };
954
+ }
955
+ if (typeof taskBriefHash !== 'string' || !/^[a-f0-9]{64}$/.test(taskBriefHash)) {
956
+ return { ok: false, reason: 'invalid-task-brief-hash' };
957
+ }
958
+ if (!REPORT_OUTCOMES.includes(outcome || '')) {
959
+ return { ok: false, reason: 'invalid-report-outcome' };
960
+ }
961
+ if (typeof summary !== 'string' || summary.trim() === '') {
962
+ return { ok: false, reason: 'summary-required' };
963
+ }
964
+ // light는 루트 review.md에 review record 라운드와 accepted status가 있을 때만
965
+ // accepted를 받는다. 독립 reviewer 여부는 CLI가 아니라 run 절차 몫이다.
966
+ if (ledgerModeOf(ledger) === 'light' && outcome === 'accepted'
967
+ && !lightReviewAccepted(integration.integrationPath, blueprint)) {
968
+ return { ok: false, reason: 'light-review-required' };
969
+ }
970
+ // full 모드는 stale 비교보다 먼저 보고 파일로 revision 출처를 증명한다. 파일 값이
971
+ // CLI 인자와 같을 때만 아래 stale 비교가 의미를 갖는다. 거절은 원장을 쓰지 않는다.
972
+ let fileItems = null;
973
+ if (ledgerModeOf(ledger) !== 'light') {
974
+ const checked = checkReportFile(reportPathFor(item.workerPath, task, item.dispatch.attempt), { attempt: attempt, task_brief_hash: taskBriefHash });
975
+ if (!checked.ok)
976
+ return checked.failure;
977
+ fileItems = checked;
978
+ }
979
+ const expected = {
980
+ attempt: item.dispatch.attempt, task_brief_hash: item.dispatch.task_brief_hash,
981
+ };
982
+ const received = { attempt: attempt, task_brief_hash: taskBriefHash };
983
+ // mismatch는 stale 증적만 남기고 활성 attempt를 유지한다. recorded로 올리면
984
+ // 늦은 보고가 현재 dispatch를 닫아 재시도를 막는다.
985
+ if (received.attempt !== expected.attempt
986
+ || received.task_brief_hash !== expected.task_brief_hash) {
987
+ const stale = {
988
+ task, kind: 'stale-report', expected, received,
989
+ };
990
+ item.decisions = [...(item.decisions || []), stale];
991
+ ledger.decisions.push(stale);
992
+ {
993
+ const lost = commitWrite();
994
+ if (lost)
995
+ return lost;
996
+ }
997
+ return { ok: false, reason: 'stale-report', expected, received };
998
+ }
999
+ // accepted만 커밋 확정을 요구한다. rework·blocked 등은 커밋 없이도 보고돼야 하므로
1000
+ // 건드리지 않는다. stale 판정 뒤·report decision 쓰기 전에 둬서 거절이 원장을 바꾸지 않는다.
1001
+ if (outcome === 'accepted' && (item.execution_kind || 'commit') !== 'verification') {
1002
+ const evidence = workerCommitEvidence(exec, item.workerPath || integration.integrationPath, blueprint, item, ledger);
1003
+ if (!evidence.ok)
1004
+ return evidence;
1005
+ }
1006
+ item.dispatch.status = 'reported';
1007
+ item.dispatch.outcome = outcome;
1008
+ item.dispatch.summary = summary;
1009
+ const reportDecision = {
1010
+ task, kind: 'report', attempt: attempt, task_brief_hash: taskBriefHash,
1011
+ outcome: outcome, summary,
1012
+ ...(fileItems
1013
+ ? { open_choices: fileItems.open_choices, known_weaknesses: fileItems.known_weaknesses } : {}),
1014
+ };
1015
+ item.decisions = [...(item.decisions || []), reportDecision];
1016
+ ledger.decisions.push(reportDecision);
1017
+ {
1018
+ const lost = commitWrite();
1019
+ if (lost)
1020
+ return lost;
1021
+ }
1022
+ return withCheckpoint({
1023
+ ok: true, command, attempt: attempt, decision: reportDecision,
1024
+ task: item, decisions: ledger.decisions,
1025
+ }, ledger, integration.ledgerFile);
1026
+ }
1027
+ /**
1028
+ * `record`: worker HEAD와 provenance를 ledger로 올린다. accepted report 뒤 상태를 다시 본다.
1029
+ *
1030
+ * @param {TaskCtx} ctx - 공통 준비가 끝난 handler 문맥
1031
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
1032
+ */
1033
+ function coordinateRecord(ctx) {
1034
+ const { task, cwd, exec, integration, ledger, commitWrite, item } = ctx;
1035
+ const { command, repoRoot, blueprint, sha, decision } = ctx.args;
1036
+ // record는 worker가 만든 SHA와 provenance를 ledger로 올리는 경계다. integration
1037
+ // checkout에서 다시 worker 경계를 요구하면 어떤 정상 worker도 기록할 수 없다.
1038
+ // light는 integration 자체가 workerPath이므로 등록 workers/ 검사가 아니라
1039
+ // integration 일치만 본다.
1040
+ if (ledgerModeOf(ledger) === 'light') {
1041
+ if (!lightWorkerAssigned(item, integration.integrationPath)) {
1042
+ return {
1043
+ ok: false, reason: 'unassigned-worker-worktree',
1044
+ workerPath: item.workerPath || integration.integrationPath,
1045
+ };
1046
+ }
1047
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
1048
+ }
1049
+ else {
1050
+ const worker = coordinatorPathsFor({ repoRoot, blueprint, task }).workerPath;
1051
+ // prepare 뒤에도 경로 치환은 가능하다. ledger에 저장한 문자열과 Git 등록을 둘 다
1052
+ // 다시 확인해야 symlink가 외부 checkout의 HEAD를 provenance로 기록할 수 없다.
1053
+ if (item.workerPath !== worker || !registeredWorker(exec, integration.integrationPath, worker)) {
1054
+ return { ok: false, reason: 'unassigned-worker-worktree', workerPath: worker };
1055
+ }
1056
+ ensureIntegrationCwd(repoRoot, blueprint, cwd, task);
1057
+ }
1058
+ if ((item.status || 'pending') !== 'prepared')
1059
+ return { ok: false, reason: 'illegal-transition' };
1060
+ // accepted report가 없는 HEAD는 어느 brief·attempt의 결과인지 알 수 없다.
1061
+ // dispatch 없이 record하던 경로를 여기서 끊는다.
1062
+ if (!item.dispatch || item.dispatch.status !== 'reported'
1063
+ || item.dispatch.outcome !== 'accepted') {
1064
+ return { ok: false, reason: 'accepted-report-required' };
1065
+ }
1066
+ // report 이후 brief bytes가 바뀌면 수락한 보고와 다른 문서다. attempt를 닫지
1067
+ // 않은 채 stale-worker-report로만 거절해 재디스패치 여지를 남긴다.
1068
+ if (taskBriefHashOf(cwd, blueprint, task) !== item.dispatch.task_brief_hash) {
1069
+ return { ok: false, reason: 'stale-worker-report' };
1070
+ }
1071
+ // accepted report 뒤 worker HEAD·stamp·작업 트리가 바뀌었을 수 있어 record에서 다시 본다.
1072
+ // item.sha에 HEAD를 쓰기 전이어야 기준 HEAD가 worker 결과로 기록되지 않는다.
1073
+ if ((item.execution_kind || 'commit') !== 'verification') {
1074
+ const evidence = workerCommitEvidence(exec, cwd, blueprint, item, ledger);
1075
+ if (!evidence.ok)
1076
+ return evidence;
1077
+ }
1078
+ const workerHead = git(exec, cwd, ['rev-parse', 'HEAD']);
1079
+ // record 시점의 HEAD만 허용한다. caller가 임의 SHA를 주장하거나 worker가
1080
+ // 다른 commit을 향한 뒤의 값을 기록하면 coordinator provenance가 무너진다.
1081
+ if (sha && sha !== workerHead)
1082
+ return { ok: false, reason: 'sha-not-worker-head' };
1083
+ item.status = transition('prepared', 'recorded');
1084
+ item.sha = workerHead;
1085
+ if (decision !== undefined) {
1086
+ item.decisions = [...(item.decisions || []), decision];
1087
+ ledger.decisions.push({ task, decision });
1088
+ }
1089
+ {
1090
+ const lost = commitWrite();
1091
+ if (lost)
1092
+ return lost;
1093
+ }
1094
+ return withCheckpoint({ ok: true, command, task: item, decisions: ledger.decisions }, ledger, integration.ledgerFile);
1095
+ }
1096
+ /**
1097
+ * `rerecord`: integration HEAD가 바뀐 뒤 recorded task의 SHA를 새 직접 자식으로 교체한다.
1098
+ *
1099
+ * @param {TaskCtx} ctx - 공통 준비가 끝난 handler 문맥
1100
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
1101
+ */
1102
+ function coordinateRerecord(ctx) {
1103
+ const { task, cwd, exec, integration, ledger, commitWrite, item } = ctx;
1104
+ const { command, repoRoot, blueprint, sha, decision } = ctx.args;
1105
+ const worker = coordinatorPathsFor({ repoRoot, blueprint, task }).workerPath;
1106
+ if (item.workerPath !== worker || !registeredWorker(exec, integration.integrationPath, worker)) {
1107
+ return { ok: false, reason: 'unassigned-worker-worktree', workerPath: worker };
1108
+ }
1109
+ ensureIntegrationCwd(repoRoot, blueprint, cwd, task);
1110
+ if (item.status !== 'recorded' || !item.sha)
1111
+ return { ok: false, reason: 'not-recorded' };
1112
+ if (typeof decision !== 'string' || decision.trim() === '') {
1113
+ return { ok: false, reason: 'decision-reason-required' };
1114
+ }
1115
+ const integrationHead = git(exec, integration.integrationPath, ['rev-parse', 'HEAD']);
1116
+ if (ledger.integrationHead !== integrationHead)
1117
+ return { ok: false, reason: 'stale-integration-head' };
1118
+ const workerHead = git(exec, cwd, ['rev-parse', 'HEAD']);
1119
+ if (sha && sha !== workerHead)
1120
+ return { ok: false, reason: 'sha-not-worker-head' };
1121
+ if (workerHead === item.sha)
1122
+ return { ok: false, reason: 'sha-unchanged' };
1123
+ if (directParent(exec, cwd, workerHead) !== integrationHead) {
1124
+ return { ok: false, reason: 'sha-not-direct-integration-child' };
1125
+ }
1126
+ const rerecordDecision = {
1127
+ task, kind: 'rerecord', reason: decision.trim(), previousSha: item.sha,
1128
+ nextSha: workerHead, integrationHead,
1129
+ };
1130
+ item.sha = workerHead;
1131
+ item.decisions = [...(item.decisions || []), rerecordDecision];
1132
+ ledger.decisions.push(rerecordDecision);
1133
+ {
1134
+ const lost = commitWrite();
1135
+ if (lost)
1136
+ return lost;
1137
+ }
1138
+ return withCheckpoint({
1139
+ ok: true, command, task: item, decision: rerecordDecision, decisions: ledger.decisions,
1140
+ }, ledger, integration.ledgerFile);
1141
+ }
1142
+ /**
1143
+ * 복구 계열(coordinator-repair)이 정상 경로와 함께 쓰는 helper. 정의가 이 모듈에 남아 있어
1144
+ * coordinator-repair가 이 모듈을 가져오면 순환이 생기므로 handler 표 연결 지점에서 넘긴다.
1145
+ */
1146
+ const REPAIR_SUPPORT = {
1147
+ openLedgerFenced, recheckLedgerCheckpoint, intentIdFor, verificationPolicyAt, salvageRevokedWorker, withCheckpoint,
1148
+ };
1149
+ /** lock 전, integration 등록 확인 뒤에 끝까지 처리하는 handler. 내부 lock은 각 기존 함수가 잡는다. */
1150
+ const PRE_LOCK_HANDLERS = new Map([
1151
+ ['status', coordinateStatus],
1152
+ ['integrate', coordinateIntegrate],
1153
+ ['reverify', coordinateReverify],
1154
+ ['prepare', coordinatePrepare],
1155
+ ]);
1156
+ /** lock 안·task 조회 전에 끝까지 처리하는 handler. `promote-stop`은 별도로 promotion_stopped 거절 앞에서 돈다. */
1157
+ const LOCKED_HANDLERS = new Map([
1158
+ ['session-open', coordinateSessionOpen],
1159
+ ['note', coordinateNote],
1160
+ ['worker-model', coordinateWorkerRecord],
1161
+ ['worker-tokens', coordinateWorkerRecord],
1162
+ ['partial-close', (ctx) => coordinatorRepair.coordinatePartialClose(ctx, REPAIR_SUPPORT)],
1163
+ ['revoke', coordinateRevoke],
1164
+ ['repair', (ctx) => coordinatorRepair.coordinateRepair(ctx, REPAIR_SUPPORT)],
1165
+ ]);
1166
+ /** task 조회 뒤에 끝까지 처리하는 handler. */
1167
+ const TASK_HANDLERS = new Map([
1168
+ ['critical-recovery', (ctx) => coordinatorRepair.coordinateCriticalRecovery(ctx, REPAIR_SUPPORT)],
1169
+ ['dispatch', coordinateDispatch],
1170
+ ['report', coordinateReport],
1171
+ ['record', coordinateRecord],
1172
+ ['rerecord', coordinateRerecord],
1173
+ ]);
1174
+ /** lease 검사를 받는 명령. `rerecord`와 `critical-recovery`는 받지 않는다. */
1175
+ const LEASE_CHECKED_COMMANDS = new Set(['dispatch', 'report', 'record']);
1176
+ /**
1177
+ * coordinator 원장 명령을 실행한다. 실패는 `ok: false`와 reason만 내고,
1178
+ * 공개 경계(`coordinateWithHints`)가 cause·next를 붙인다.
1179
+ * `repair`는 `--failure-command`(CI)와 `reviewFindings`(최종 리뷰)를 한
1180
+ * 결정에 섞지 않는다 — 원장 `failure.command`가 원인을 하나만 갖게 하기 위해서다.
1181
+ *
1182
+ * @param {object} opts - coordinate 명령 인자
1183
+ * @param {string} opts.command - bootstrap·prepare·repair 등 서브커맨드
1184
+ * @param {string} opts.repoRoot - 저장소 루트
1185
+ * @param {string} opts.blueprint - blueprint 상대 경로
1186
+ * @param {string} [opts.cwd] - 실제 write cwd. 기본은 repoRoot
1187
+ * @param {string} [opts.task] - 세 자리 task id. review repair는 생략 가능
1188
+ * @param {string} [opts.sha] - record/rerecord worker SHA
1189
+ * @param {unknown} [opts.decision] - repair·rerecord 결정 사유 또는 record 부가 결정
1190
+ * @param {string} [opts.failureCommand] - CI repair 실패 명령
1191
+ * @param {string} [opts.summary] - 실패·report 요약
1192
+ * @param {string[]} [opts.paths] - repair/revise 경로
1193
+ * @param {string[]} [opts.findings] - critical-recovery finding id
1194
+ * @param {string[]} [opts.reviewFindings] - 최종 리뷰 must_fix id. 없으면 CI 원인
1195
+ * @param {string} [opts.kind] - repair 종류. `supplement`는 범위 내 테스트 보완, 그 밖은 기존 경로
1196
+ * @param {boolean} [opts.done] - supplement 전용. 보완 완료를 알리고 검증을 돌린다
1197
+ * @param {string} [opts.outcome] - report 또는 critical-recovery 결과
1198
+ * @param {string} [opts.reason] - revoke·critical-recovery 사유
1199
+ * @param {string | true} [opts.model] - dispatch·worker-model 모델 인자. true는 값 없는 플래그
1200
+ * @param {string} [opts.role] - worker-model·worker-tokens 대상 role(implementer|reviewer|debugger)
1201
+ * @param {number | string} [opts.tokens] - worker-model 선택, worker-tokens 필수 토큰 수(0 이상 안전 정수)
1202
+ * @param {string} [opts.perspective] - worker-model reviewer 관점
1203
+ * @param {number} [opts.attempt] - dispatch report attempt
1204
+ * @param {string} [opts.taskBriefHash] - dispatch report brief hash
1205
+ * @param {string} [opts.leaseId] - task lease id
1206
+ * @param {number} [opts.generation] - task lease generation
1207
+ * @param {string} [opts.ledgerPath] - fence 원장 상대 경로
1208
+ * @param {string} [opts.ledgerHash] - fence sha256
1209
+ * @param {boolean} [opts.userConfirmed] - partial-close 사용자 확인
1210
+ * @param {boolean} [opts.promoteLight] - promotion_stopped light 원장을 full로 전환
1211
+ * @param {object} [opts.deps] - exec·verify·원장·handoff 쓰기 주입
1212
+ * @returns {object} 성공 시 ok:true와 명령별 필드, 실패 시 ok:false와 reason
1213
+ * prepare는 worktree 생성·seed를 원장 잠금 밖에서 돌리고, 실패하면 이번 호출이
1214
+ * 만든 worker만 되돌린다.
1215
+ */
1216
+ function coordinate(args) {
1217
+ const { command, repoRoot, blueprint, cwd = repoRoot, task, kind, done, ledgerPath, ledgerHash, deps = {} } = args;
1218
+ const exec = deps.execFileSync || realExecFileSync;
1219
+ const main = runtimePaths({ repoRoot, execFileSync: exec });
1220
+ if (main.unavailable)
1221
+ return { ok: false, reason: 'non-git-root' };
1222
+ const ctx = {
1223
+ args, cwd, deps, exec,
1224
+ writeLedger: deps.writeLedger || atomicWrite,
1225
+ makeLeaseId: deps.makeLeaseId || randomUUID,
1226
+ main,
1227
+ base: git(exec, repoRoot, ['rev-parse', 'HEAD']),
1228
+ paths: coordinatorPathsFor({ repoRoot, blueprint, task }),
1229
+ integration: coordinatorPathsFor({ repoRoot, blueprint }),
1230
+ };
1231
+ if (command === 'bootstrap')
1232
+ return coordinateBootstrap(ctx);
1233
+ const { integration } = ctx;
1234
+ if (!registeredIntegration(exec, repoRoot, integration.integrationPath)) {
1235
+ return { ok: false, reason: 'unassigned-integration-worktree', integrationPath: integration.integrationPath };
1236
+ }
1237
+ const preLock = PRE_LOCK_HANDLERS.get(command);
1238
+ if (preLock)
1239
+ return preLock(ctx);
1240
+ if (!LEDGER_FENCED_COMMANDS.has(command)) {
1241
+ return { ok: false, reason: 'unknown-coordinate-command' };
1242
+ }
1243
+ if (command === 'repair' && kind === 'supplement' && done === true)
1244
+ return coordinateSupplementDone(ctx);
1245
+ return withLedgerLock(integration.ledgerFile, (owns) => {
1246
+ // status는 읽기 전용, bootstrap은 위에서 이미 반환. 그 외 쓰기는 path/hash가
1247
+ // 방금 로드한 원장 bytes와 같을 때만 진행해 stale checkpoint로 Git·원장이 갈라지지 않게 한다.
1248
+ const opened = openLedgerFenced({ ledgerFile: integration.ledgerFile, ledgerPath, ledgerHash });
1249
+ if (!opened.ok)
1250
+ return opened;
1251
+ const { ledger, bytes: ledgerBytes } = opened;
1252
+ const commitWrite = () => (writeOwnedLedger(owns, ctx.writeLedger, integration.ledgerFile, ledger));
1253
+ const locked = { ...ctx, ledger, ledgerBytes, commitWrite };
1254
+ // promote-stop만 멱등 재실행을 허용한다. 그 외 fence mutation은 정지 뒤 거절해
1255
+ // 스냅샷(reason·diff·증적)이 바뀌지 않게 한다. 해제는 없다 — full 재계획은
1256
+ // 새 bootstrap/current --set이다.
1257
+ if (command === 'promote-stop')
1258
+ return coordinatePromoteStop(locked);
1259
+ if (ledger.status === 'promotion_stopped') {
1260
+ return { ok: false, reason: 'promotion-stopped' };
1261
+ }
1262
+ const lockedHandler = LOCKED_HANDLERS.get(command);
1263
+ if (lockedHandler)
1264
+ return lockedHandler(locked);
1265
+ if (command === 'critical-recovery') {
1266
+ const rejected = criticalRecoveryPrecheck(locked);
1267
+ if (rejected)
1268
+ return rejected;
1269
+ }
1270
+ if (!task || !/^\d{3}$/.test(task))
1271
+ return { ok: false, reason: 'task-required' };
1272
+ const item = ledger.tasks.find((x) => x.id === task);
1273
+ if (!item)
1274
+ return { ok: false, reason: 'task-outside-blueprint' };
1275
+ const taskCtx = { ...locked, item, task };
1276
+ if (LEASE_CHECKED_COMMANDS.has(command)) {
1277
+ const leaseRejected = checkTaskLease(taskCtx);
1278
+ if (leaseRejected)
1279
+ return leaseRejected;
1280
+ }
1281
+ if (command === 'dispatch' || command === 'report') {
1282
+ const guardRejected = guardWorkerDispatch(taskCtx);
1283
+ if (guardRejected)
1284
+ return guardRejected;
1285
+ }
1286
+ const taskHandler = TASK_HANDLERS.get(command);
1287
+ return taskHandler ? taskHandler(taskCtx) : { ok: false, reason: 'unknown-coordinate-command' };
1288
+ });
1289
+ }
1290
+ const FALLBACK_COORDINATE_NEXT = 'Run `bouncer coordinate status` and continue from its checkpoint.';
1291
+ // 힌트는 여기 한 표만 본다. 개별 return { ok: false }에 cause/next를 흩어 쓰면
1292
+ // 새 reason이 표 없이 새어 나가고 coordinator가 다시 coordinator.ts를 읽게 된다.
1293
+ const COORDINATE_FAILURE_HINTS = {
1294
+ 'cross-drive-conflict': {
1295
+ cause: 'A task in this wave overlaps a started task in another active coordinator drive.',
1296
+ next: 'Wait for the other drive to finish or detach, or revise the plan to remove the overlap.',
1297
+ },
1298
+ 'cross-drive-ledger-unreadable': {
1299
+ cause: 'Another coordinator ledger could not be read, so its task scope cannot be checked.',
1300
+ next: 'Repair or detach the named drive ledger, then retry `bouncer coordinate prepare`.',
1301
+ },
1302
+ 'accepted-report-required': {
1303
+ cause: 'Record requires an accepted dispatch report for this task, and none is stored.',
1304
+ next: 'Call `bouncer coordinate report` with outcome accepted, then retry `bouncer coordinate record`.',
1305
+ },
1306
+ 'bootstrap-requires-main-checkout': {
1307
+ cause: 'Bootstrap must run from the main checkout, not an integration or worker worktree.',
1308
+ next: 'Change cwd to the main checkout and rerun `bouncer coordinate bootstrap`.',
1309
+ },
1310
+ 'cherry-pick-failed': {
1311
+ cause: 'Cherry-pick of the recorded worker commit into the fan-in candidate failed.',
1312
+ next: 'Inspect the named task, then `bouncer coordinate revoke` it or retry '
1313
+ + '`bouncer coordinate integrate` after fixing the commit.',
1314
+ },
1315
+ 'coordinator-config-invalid': {
1316
+ cause: '`.bouncer/config.json` coordinator policy is missing or invalid.',
1317
+ next: 'Fix coordinator config in the integration worktree, then rerun `bouncer coordinate prepare`.',
1318
+ },
1319
+ 'critical-recovery-closed': {
1320
+ cause: 'This task already has a closed critical-recovery outcome.',
1321
+ next: 'Do not retry `bouncer coordinate critical-recovery`; continue from `bouncer coordinate status`.',
1322
+ },
1323
+ 'critical-recovery-exhausted': {
1324
+ cause: 'This task already used its one critical-recovery attempt.',
1325
+ next: 'Report the block to the user; do not retry `bouncer coordinate critical-recovery`.',
1326
+ },
1327
+ 'critical-recovery-not-started': {
1328
+ cause: 'No open critical-recovery record exists on this task to close.',
1329
+ next: 'Start recovery with `bouncer coordinate critical-recovery` and findings before sending an outcome.',
1330
+ },
1331
+ 'critical-recovery-outcome-invalid': {
1332
+ cause: 'Critical-recovery outcome must be resolved or blocked.',
1333
+ next: 'Retry `bouncer coordinate critical-recovery` with outcome resolved or blocked.',
1334
+ },
1335
+ 'decision-reason-required': {
1336
+ cause: 'This mutation requires a non-empty decision reason.',
1337
+ next: 'Retry the same `bouncer coordinate` command with a reason that names why the change is required.',
1338
+ },
1339
+ 'dependency-install-failed': {
1340
+ cause: 'Locked development dependencies could not be installed in an integration '
1341
+ + 'or worker worktree.',
1342
+ next: 'Inspect npm ci in that worktree, then retry `bouncer coordinate prepare` '
1343
+ + 'or `bouncer coordinate integrate` for the verification task.',
1344
+ },
1345
+ 'dispatch-already-active': {
1346
+ cause: 'This task already has an active dispatch, so a second dispatch is refused.',
1347
+ next: 'Call `bouncer coordinate report` for the open attempt, or `bouncer coordinate status` to see it.',
1348
+ },
1349
+ 'dispatch-commit-task-required': {
1350
+ cause: 'Dispatch applies only to commit tasks, not verification tasks.',
1351
+ next: 'Run `bouncer coordinate status` and dispatch a commit task id instead.',
1352
+ },
1353
+ 'dispatch-git-read-failed': {
1354
+ cause: 'Git could not read the worker HEAD or worktree state needed to open dispatch.',
1355
+ next: 'Repair the assigned worker worktree, then retry `bouncer coordinate dispatch`.',
1356
+ },
1357
+ 'failure-evidence-required': {
1358
+ cause: 'Repair needs terminal failure evidence (command, summary, and paths).',
1359
+ next: 'Pass failure evidence into `bouncer coordinate repair` from the failed verification.',
1360
+ },
1361
+ 'fanin-conflict': {
1362
+ cause: 'Fan-in hit a conflict while applying a recorded worker commit.',
1363
+ next: 'Call `bouncer coordinate revoke` for the conflicting task, then retry `bouncer coordinate integrate`.',
1364
+ },
1365
+ 'findings-required': {
1366
+ cause: 'Starting critical recovery requires a non-empty findings list.',
1367
+ next: 'Retry `bouncer coordinate critical-recovery` with at least one finding id.',
1368
+ },
1369
+ 'illegal-transition': {
1370
+ cause: 'The task is not in a status that allows this coordinate command.',
1371
+ next: 'Run `bouncer coordinate status` and issue the command that matches the task status.',
1372
+ },
1373
+ 'invalid-attempt': {
1374
+ cause: 'The report attempt number does not match the active dispatch.',
1375
+ next: 'Call `bouncer coordinate report` with the attempt from the last `bouncer coordinate dispatch`.',
1376
+ },
1377
+ 'invalid-report-outcome': {
1378
+ cause: 'The report outcome is not one of the accepted dispatch outcomes.',
1379
+ next: 'Retry `bouncer coordinate report` with accepted, rework, scope_revision, task_change, or blocked.',
1380
+ },
1381
+ 'invalid-task-brief-hash': {
1382
+ cause: 'The report task_brief_hash does not match the active dispatch.',
1383
+ next: 'Call `bouncer coordinate report` with the task_brief_hash from the last `bouncer coordinate dispatch`.',
1384
+ },
1385
+ 'lease-flags-require-task': {
1386
+ cause: 'Lease flags were supplied without a task id to bind them to.',
1387
+ next: 'Retry the same `bouncer coordinate` command with `--task` plus the lease flags.',
1388
+ },
1389
+ 'lease-invalid': {
1390
+ cause: 'The ledger lease shape or leaseSeq is invalid.',
1391
+ next: 'Run `bouncer coordinate status`; if the ledger is corrupt, report it to the user and do not invent a lease.',
1392
+ },
1393
+ 'lease-required': {
1394
+ cause: 'This mutation requires the task lease id and generation.',
1395
+ next: 'Retry with `--lease-id` and `--generation` from the last `bouncer coordinate prepare` or status checkpoint.',
1396
+ },
1397
+ 'ledger-checkpoint-invalid': {
1398
+ cause: 'The ledger path/hash fence is missing or not a valid integration-relative checkpoint.',
1399
+ next: 'Run `bouncer coordinate status` and pass its checkpoint.ledger path and sha256.',
1400
+ },
1401
+ 'ledger-lock-lost': {
1402
+ cause: 'The process lost the ledger lock before it could write.',
1403
+ next: 'Run `bouncer coordinate status` and retry the same command from that '
1404
+ + 'checkpoint; do not write the ledger by hand.',
1405
+ },
1406
+ 'ledger-mode-mismatch': {
1407
+ cause: 'The existing ledger mode does not match the blueprint scale for this bootstrap.',
1408
+ next: 'Use a matching scale (light ledger with scale light, full with non-light), or start a new blueprint drive.',
1409
+ },
1410
+ 'ledger-not-active': {
1411
+ cause: 'The ledger is partial_closed or awaiting_confirmation, so a new coordinator session cannot open.',
1412
+ next: 'Report the ledger status to the user; do not retry `bouncer coordinate session-open` on this blueprint.',
1413
+ },
1414
+ 'light-requires-single-task': {
1415
+ cause: 'Scale light requires one independent commit task; a blueprint with verification_policy scoped_terminal '
1416
+ + 'also requires exactly one terminal verification task that depends only on it. A legacy light blueprint '
1417
+ + 'allows no verification task.',
1418
+ next: 'Fix the blueprint to that shape, or set scale to full and bootstrap again.',
1419
+ },
1420
+ 'verification-policy-invalid': {
1421
+ cause: 'The blueprint index declares a verification_policy value other than scoped_terminal.',
1422
+ next: 'Set verification_policy to scoped_terminal or remove the key (legacy), then bootstrap again.',
1423
+ },
1424
+ 'worker-test-report-mismatch': {
1425
+ cause: 'A scoped_terminal worker report does not show the task-assigned command with a passing result '
1426
+ + 'in its Tests section (problem names which check failed).',
1427
+ next: 'Reject the fan-in: send the worker back to run exactly the assigned bouncer.verify command and '
1428
+ + 'report its command and result under Tests; never hand the worker the full-suite command.',
1429
+ },
1430
+ 'light-review-required': {
1431
+ cause: 'Light-mode accepted report requires blueprint-root review.md rounds and status accepted.',
1432
+ next: 'Record a review round with `bouncer review record` on the root review.md, '
1433
+ + 'set status accepted, then retry report.',
1434
+ },
1435
+ 'main-source-mutated': {
1436
+ cause: 'Bootstrap changed tracked files in the main checkout, which is forbidden.',
1437
+ next: 'Restore the main checkout, then retry `bouncer coordinate bootstrap`.',
1438
+ },
1439
+ 'missing-blueprint': {
1440
+ cause: 'The integration worktree has no blueprint directory for this drive.',
1441
+ next: 'Restore the blueprint on the integration worktree, then retry `bouncer coordinate prepare`.',
1442
+ },
1443
+ 'missing-ledger': {
1444
+ cause: 'The coordinator ledger file is missing for this blueprint.',
1445
+ next: 'Run `bouncer coordinate bootstrap` from the main checkout, then continue from `bouncer coordinate status`.',
1446
+ },
1447
+ 'missing-verification-bundle': {
1448
+ cause: 'The worker is missing the verification task bundle required for fan-in.',
1449
+ next: 'Restore the task bundle on the worker, then retry `bouncer coordinate integrate`.',
1450
+ },
1451
+ 'main-checkout-root-required': {
1452
+ cause: 'Partial-close can write handoff only when repoRoot resolves to the canonical main checkout.',
1453
+ next: 'Run `bouncer coordinate partial-close` from the main checkout while keeping the assigned integration cwd.',
1454
+ },
1455
+ 'next-plan-must-be-regular-file': {
1456
+ cause: 'NEXT_PLAN.md exists but is not a regular file.',
1457
+ next: 'Replace it with a regular NEXT_PLAN.md on the integration worktree, then '
1458
+ + 'retry `bouncer coordinate partial-close`.',
1459
+ },
1460
+ 'next-plan-must-be-untracked': {
1461
+ cause: 'NEXT_PLAN.md is tracked, but partial-close requires it untracked.',
1462
+ next: 'Untrack NEXT_PLAN.md without deleting the plan, then retry `bouncer coordinate partial-close`.',
1463
+ },
1464
+ 'next-plan-required': {
1465
+ cause: 'Partial-close in awaiting_confirmation requires NEXT_PLAN.md.',
1466
+ next: 'Write NEXT_PLAN.md on the integration worktree, then retry `bouncer coordinate partial-close`.',
1467
+ },
1468
+ 'no-active-dispatch': {
1469
+ cause: 'Report was called with no active dispatch for this task.',
1470
+ next: 'Open `bouncer coordinate dispatch` first, then call `bouncer coordinate report`.',
1471
+ },
1472
+ 'non-git-root': {
1473
+ cause: 'The repo root is not an available git checkout.',
1474
+ next: 'Run `bouncer coordinate` from a git repository root that Bouncer can resolve.',
1475
+ },
1476
+ 'not-ready': {
1477
+ cause: 'The task is not in the ready wave for this command.',
1478
+ next: 'Run `bouncer coordinate status` and act on a task listed in checkpoint.ready.',
1479
+ },
1480
+ 'not-recorded': {
1481
+ cause: 'Rerecord requires a recorded task with a stored SHA.',
1482
+ next: 'Call `bouncer coordinate record` first, or pick a recorded task from `bouncer coordinate status`.',
1483
+ },
1484
+ 'nothing-to-integrate': {
1485
+ cause: 'No recorded commit tasks remain to fan in.',
1486
+ next: 'Run `bouncer coordinate status` and record remaining commit tasks before `bouncer coordinate integrate`.',
1487
+ },
1488
+ 'partial-close-awaiting-confirmation-required': {
1489
+ cause: 'Partial-close is allowed only while the drive is awaiting confirmation.',
1490
+ next: 'Run `bouncer coordinate status` and wait for awaiting_confirmation before '
1491
+ + '`bouncer coordinate partial-close`.',
1492
+ },
1493
+ 'partial-close-user-confirmation-required': {
1494
+ cause: 'Partial-close records a terminal failure and requires explicit user confirmation.',
1495
+ next: 'Review the main-checkout handoff, then retry `bouncer coordinate partial-close --user-confirmed`.',
1496
+ },
1497
+ 'promote-reason-invalid': {
1498
+ cause: 'promote-stop reason must be one of the five light promotion enums.',
1499
+ next: 'Retry with --reason security-risk, out-of-scope, task-split, '
1500
+ + 'interface-semantics, or reviewer-wider-scope.',
1501
+ },
1502
+ 'promote-stop-requires-light': {
1503
+ cause: 'promote-stop applies only to a light ledger (mode: light).',
1504
+ next: 'Do not call promote-stop on a full ledger; continue with the full coordinate path.',
1505
+ },
1506
+ 'promote-light-requires-stopped': {
1507
+ cause: 'The existing coordinator ledger is not a promotion_stopped light ledger.',
1508
+ next: 'Stop the light drive with `bouncer coordinate promote-stop`, then rerun bootstrap with `--promote-light`.',
1509
+ },
1510
+ 'promote-light-scale-light': {
1511
+ cause: 'The main checkout blueprint still has light scale.',
1512
+ next: 'Approve the full replan in the main checkout, then rerun `coordinate bootstrap --promote-light`.',
1513
+ },
1514
+ 'promote-light-archive-exists': {
1515
+ cause: 'A light promotion archive or patch already exists for this integration worktree.',
1516
+ next: 'Inspect the existing promotion artifacts and recover them before retrying.',
1517
+ },
1518
+ 'promotion-diff-drift': {
1519
+ cause: 'The integration worktree no longer matches the diff recorded by promote-stop.',
1520
+ next: 'Restore the stopped worktree to its recorded state or inspect it before retrying promotion.',
1521
+ },
1522
+ 'promote-light-incomplete': {
1523
+ cause: 'Promotion failed partway through; the response reports whether rollback restored the original state.',
1524
+ next: 'Follow the returned recovery instructions before retrying bootstrap.',
1525
+ },
1526
+ 'promotion-already-stopped': {
1527
+ cause: 'This light ledger is already promotion_stopped with a different reason or summary.',
1528
+ next: 'Keep the recorded promotion snapshot; replan as full with a new bootstrap and '
1529
+ + '`bouncer current --set` after the user approves.',
1530
+ },
1531
+ 'run-detached': {
1532
+ cause: 'This run was detached with `bouncer run detach`; its ledger is preserved but no longer driven.',
1533
+ next: 'Finalize with `bouncer current --set <blueprint>` then /bouncer-finalize, or plan new work; '
1534
+ + 'do not retry coordinate mutations on this blueprint.',
1535
+ },
1536
+ 'promotion-stopped': {
1537
+ cause: 'This light ledger was stopped for promotion; light mutations are refused.',
1538
+ next: 'After the full replan is approved in the main checkout, run `bouncer coordinate bootstrap --promote-light`.',
1539
+ },
1540
+ 'reason-required': {
1541
+ cause: 'Critical recovery requires a non-empty reason string.',
1542
+ next: 'Retry `bouncer coordinate critical-recovery` with a reason.',
1543
+ },
1544
+ 'repair-cause-ambiguous': {
1545
+ cause: 'Repair cannot take both a review finding and a verification failure command.',
1546
+ next: 'Retry `bouncer coordinate repair` with either `--review-finding` or `--failure-command`, not both.',
1547
+ },
1548
+ 'repair-resolution-ambiguous': {
1549
+ cause: 'Repair cannot take both `--resume-task` and `--disposition`.',
1550
+ next: 'Retry `bouncer coordinate repair` with exactly one of `--resume-task <ddd>` or '
1551
+ + '`--disposition outside_scope`.',
1552
+ },
1553
+ 'resume-task-invalid': {
1554
+ cause: 'The resume task must be a different, already integrated implementation task of this blueprint.',
1555
+ next: 'Pick the integrated implementation task that owns the failing code from `bouncer coordinate status`.',
1556
+ },
1557
+ 'failure-evidence-mismatch': {
1558
+ cause: 'The given task or failure command is not the terminal failure currently recorded in the ledger.',
1559
+ next: 'Copy `--task` and `--failure-command` from the ledger `recent_failure` and retry.',
1560
+ },
1561
+ 'disposition-invalid': {
1562
+ cause: 'The only supported failure disposition is `outside_scope`.',
1563
+ next: 'Retry with `--disposition outside_scope`, or use `--resume-task` to rework an approved task.',
1564
+ },
1565
+ 'disposition-evidence-required': {
1566
+ cause: 'The recorded terminal failure carries no evidence id or source identity to classify against.',
1567
+ next: 'Rerun the terminal verification so the failure stores its evidence, or keep the failure blocked.',
1568
+ },
1569
+ 'disposition-stale-source': {
1570
+ cause: 'The integration source changed after the terminal failure was recorded.',
1571
+ next: 'Rerun the terminal verification on the current source, then classify the new failure.',
1572
+ },
1573
+ 'disposition-scope-mixed': {
1574
+ cause: 'A given path belongs to a task scope, so the failure cannot be classified as outside every approved scope.',
1575
+ next: 'Use `--resume-task` for the owning task, or pass only paths no task owns.',
1576
+ },
1577
+ 'scope-path-untracked': {
1578
+ cause: 'A given scope path is not tracked at the integration HEAD, so no worker can edit it in place.',
1579
+ next: 'Take paths from tracked files named in the failure evidence or debugger report, '
1580
+ + 'or pass a file to create with `--new-path` when its parent directory is tracked.',
1581
+ },
1582
+ // 아래 12종은 `coordinate revise`(reviseTaskScope)만 내는 거절이다. 표에 없으면
1583
+ // fallback 힌트가 붙어 coordinator가 거절 원인과 다음 행동을 알 수 없다.
1584
+ 'scope-paths-required': {
1585
+ cause: '`coordinate revise` was called without any `--paths` entry.',
1586
+ next: 'Pass the full replacement scope as `--paths <path>...` and rerun `bouncer coordinate revise`.',
1587
+ },
1588
+ 'scope-path-glob': {
1589
+ cause: 'A given scope path contains a glob character, but scope paths must be literal files or directories.',
1590
+ next: 'Replace each glob with literal tracked paths (or a directory ending in `/`) and rerun `bouncer ' +
1591
+ 'coordinate revise`.',
1592
+ },
1593
+ 'scope-path-out-of-bounds': {
1594
+ cause: 'A given scope path points outside the repository or into a path scope may not cover (such as ' +
1595
+ '`.bouncer/context/`).',
1596
+ next: 'Use repo-relative source or test paths only, then rerun `bouncer coordinate revise`.',
1597
+ },
1598
+ 'scope-do-not-touch-conflict': {
1599
+ cause: 'A `Do not touch` entry in the task brief overlaps a path this revise adds to the scope, so a worker ' +
1600
+ 'would hit a forbidden path.',
1601
+ next: 'Pass each conflicting entry from `paths` as `--release-do-not-touch <path>` (repeat the flag) and ' +
1602
+ 'rerun `bouncer coordinate revise`, or drop that path from the scope.',
1603
+ },
1604
+ 'release-do-not-touch-unknown': {
1605
+ cause: 'A `--release-do-not-touch` value is not a `Do not touch` entry that overlaps a newly added scope ' +
1606
+ 'path.',
1607
+ next: 'Pass `--release-do-not-touch` only for the conflicting entries listed in `paths` of a ' +
1608
+ '`scope-do-not-touch-conflict` rejection, spelled exactly as in the brief, then rerun ' +
1609
+ '`bouncer coordinate revise`.',
1610
+ },
1611
+ 'task-document-missing': {
1612
+ cause: 'The task document for the named task cannot be found or read in this worktree.',
1613
+ next: 'Check `--task` and `--blueprint` against `bouncer coordinate status`, then rerun the same ' +
1614
+ '`bouncer coordinate` command.',
1615
+ },
1616
+ 'no-coordinator-paths': {
1617
+ cause: 'No coordinator runtime paths could be derived for this blueprint.',
1618
+ next: 'Run from the worker or integration worktree of the driven blueprint and pass a valid `--blueprint`.',
1619
+ },
1620
+ 'no-git': {
1621
+ cause: 'The current directory is not inside a git work tree, so scope cannot be judged.',
1622
+ next: 'Change cwd to the assigned worker worktree and rerun the same `bouncer coordinate` command.',
1623
+ },
1624
+ 'main-worktree-source-write': {
1625
+ cause: 'The current directory is the main checkout, which a coordinator drive must not write.',
1626
+ next: 'Change cwd to the assigned worker worktree and rerun the same `bouncer coordinate` command.',
1627
+ },
1628
+ 'unassigned-worktree': {
1629
+ cause: 'The current worktree is not a worker worktree registered in the coordinator ledger.',
1630
+ next: 'Change cwd to the worker worktree assigned to the task and rerun the same `bouncer coordinate` command.',
1631
+ },
1632
+ 'unreadable-ledger': {
1633
+ cause: 'The coordinator ledger exists but cannot be read or parsed.',
1634
+ next: 'Do not edit the ledger by hand; restore it from the integration worktree and rerun after `bouncer ' +
1635
+ 'coordinate status` succeeds.',
1636
+ },
1637
+ 'ledger-locked': {
1638
+ cause: 'Another coordinator command holds the ledger lock.',
1639
+ next: 'Wait for the other command to finish, then retry the same `bouncer coordinate` command.',
1640
+ },
1641
+ 'scope-new-path-parent-missing': {
1642
+ cause: 'The parent directory of a `--new-path` entry holds no tracked file at the integration HEAD.',
1643
+ next: 'Create the file under an existing tracked directory, or revise the plan to add the directory first.',
1644
+ },
1645
+ 'repair-verify-required': {
1646
+ cause: 'A scoped_terminal blueprint needs the repair task to carry its own verify command.',
1647
+ next: 'Retry `bouncer coordinate repair` with `--verify` set to one command that checks only the failing files.',
1648
+ },
1649
+ 'repair-verify-not-single-argv': {
1650
+ cause: '`--verify` is empty or chains shell operators, so it is not one executable argv.',
1651
+ next: 'Pass a single command such as `node --test test/a.test.js` without `&&`, `|`, `;`, or redirection.',
1652
+ },
1653
+ 'repair-verify-is-terminal': {
1654
+ cause: '`--verify` equals the terminal full-suite command, which scoped_terminal forbids on commit tasks (G20).',
1655
+ next: 'Pass a command that checks only the failing files; the terminal task re-runs the full suite.',
1656
+ },
1657
+ 'repair-scope-out-of-bounds': {
1658
+ cause: 'Repair paths are outside the blueprint-scoped source bound.',
1659
+ next: 'Retry `bouncer coordinate repair` with paths inside the blueprint source scope.',
1660
+ },
1661
+ 'final-review-single-round': {
1662
+ cause: 'The blueprint final review already recorded its one discovery round, so no review-caused '
1663
+ + 'repair, supplement, or delta round may open. A `review_contract: brief` blueprint gets one '
1664
+ + 'review-caused repair after discovery; a second repair, any supplement, or a repair after the delta '
1665
+ + 'round is refused here.',
1666
+ next: 'Defer the remaining must_fix findings with a note and accept the review, or stop with '
1667
+ + '`final-review-blocked` for the user decision.',
1668
+ },
1669
+ 'final-review-repair-scope': {
1670
+ cause: 'The review-caused repair declares new paths outside the approved `affected_paths`, which the one '
1671
+ + 'final-review repair may not add.',
1672
+ next: 'Report the block to the user and plan the out-of-scope fix with `/bouncer-plan`; retry only with '
1673
+ + 'paths inside the approved scope.',
1674
+ },
1675
+ 'review-contract-invalid': {
1676
+ cause: 'The blueprint index `review_contract` value is not an allowed marker, so no review-caused repair '
1677
+ + 'or supplement may open.',
1678
+ next: 'Report to the user and fix `review_contract` through `/bouncer-plan`.',
1679
+ },
1680
+ 'supplement-delta-used': {
1681
+ cause: 'The blueprint root review already recorded its one delta round, so no in-place supplement may open.',
1682
+ next: 'Use the existing `bouncer coordinate repair --review-finding` path or report the open finding as blocked.',
1683
+ },
1684
+ 'supplement-not-pending': {
1685
+ cause: 'There is no pending supplement decision to complete.',
1686
+ next: 'Open one with `bouncer coordinate repair --kind supplement --review-finding <id> ...` first, '
1687
+ + 'or use the existing repair path.',
1688
+ },
1689
+ 'supplement-paths-exceeded': {
1690
+ cause: 'The integration worktree changed paths outside the accepted supplement paths.',
1691
+ next: 'Restore the extra changes; if product code must change, report it as blocked or use '
1692
+ + '`bouncer coordinate repair --review-finding` instead.',
1693
+ },
1694
+ 'supplement-paths-not-tests': {
1695
+ cause: 'A supplement may change test paths only; a path is not a test path (or is under fixtures).',
1696
+ next: 'Retry with test paths only, or use the existing `bouncer coordinate repair --review-finding` path.',
1697
+ },
1698
+ 'supplement-verify-failed': {
1699
+ cause: 'The relevant verify command failed after the supplement; the decision stays pending.',
1700
+ next: 'Fix the supplement and retry `--done`; if the failure shows a product defect, report it as blocked '
1701
+ + 'or use the existing repair path.',
1702
+ },
1703
+ 'repair-wave-limit': {
1704
+ cause: 'This drive already used the maximum of two repair waves.',
1705
+ next: 'If the failure was environmental, fix it and run `bouncer coordinate reverify --task <terminal>` first; '
1706
+ + 'otherwise report the terminal failure to the user. Do not retry `bouncer coordinate repair`.',
1707
+ },
1708
+ 'reverify-source-moved': {
1709
+ cause: 'The integration HEAD is not the source the terminal failure was recorded on.',
1710
+ next: 'The code changed since the failure, so reverify cannot vouch for it; handle it with '
1711
+ + '`bouncer coordinate repair`.',
1712
+ },
1713
+ 'reverify-worktree-dirty': {
1714
+ cause: 'The integration worktree has uncommitted changes beyond the terminal evidence documents.',
1715
+ next: 'Revert the uncommitted source changes and run `bouncer coordinate reverify` again; if the change is '
1716
+ + 'needed, handle it with `bouncer coordinate repair`.',
1717
+ },
1718
+ 'review-repair-requires-integrated': {
1719
+ cause: 'A final-review repair can open only after every task on the blueprint is integrated.',
1720
+ next: 'Finish remaining tasks, then retry `bouncer coordinate repair` with `--review-finding`.',
1721
+ },
1722
+ 'scaffold-comment-remaining': {
1723
+ cause: '계획 문서에 옛 스캐폴드 안내 주석이 남음',
1724
+ next: '주석을 지우고 plan gate를 다시 통과한 뒤 bootstrap',
1725
+ },
1726
+ 'sha-not-direct-integration-child': {
1727
+ cause: 'The new worker SHA is not a direct child of the recorded integration-base commit.',
1728
+ next: 'Reset the worker onto the recorded SHA, then retry `bouncer coordinate rerecord`.',
1729
+ },
1730
+ 'sha-not-owned-by-worker': {
1731
+ cause: 'The recorded SHA is not owned by the assigned worker worktree.',
1732
+ next: 'Restore the assigned worker worktree, then retry `bouncer coordinate integrate`.',
1733
+ },
1734
+ 'sha-not-worker-head': {
1735
+ cause: 'The supplied SHA is not the assigned worker HEAD.',
1736
+ next: 'Omit sha or pass the worker HEAD, then retry `bouncer coordinate record`.',
1737
+ },
1738
+ 'sha-unchanged': {
1739
+ cause: 'Rerecord requires a worker HEAD that differs from the stored SHA.',
1740
+ next: 'Commit the new worker work, then retry `bouncer coordinate rerecord`.',
1741
+ },
1742
+ 'integration-source-mutated': {
1743
+ cause: 'The integration checkout has uncommitted changes outside the seed manifest '
1744
+ + 'and integrated or repair task evidence, so the root or coordinator edited it directly.',
1745
+ next: 'Commit governance changes (an epic migration, `.bouncer/epics.json`) on main before the drive '
1746
+ + 'and prepare the drive again. During a drive, move source changes into a worker task or '
1747
+ + '`bouncer coordinate repair`. Revert the direct edits in the integration checkout, '
1748
+ + 'then call integrate again.',
1749
+ },
1750
+ 'stale-integration-head': {
1751
+ cause: 'The integration worktree HEAD does not match the ledger integrationHead.',
1752
+ next: 'Reset the integration worktree to the ledger head from `bouncer coordinate status`, then retry.',
1753
+ },
1754
+ 'stale-ledger-checkpoint': {
1755
+ cause: 'The supplied ledger hash does not match the bytes loaded for this mutation.',
1756
+ next: 'Run `bouncer coordinate status` and retry with the new checkpoint.ledger sha256.',
1757
+ },
1758
+ 'stale-lease': {
1759
+ cause: 'The supplied lease id or generation does not match the task lease.',
1760
+ next: 'Run `bouncer coordinate status` and retry with the current `--lease-id` and `--generation`.',
1761
+ },
1762
+ 'report-revision-missing': {
1763
+ cause: 'The worker report file is missing or has no valid `## Brief revision` (attempt and task_brief_hash).',
1764
+ next: 'Keep the attempt open; ask the worker to write the report file at `report_path`, '
1765
+ + 'or redispatch the same attempt, then retry `bouncer coordinate report`.',
1766
+ },
1767
+ 'report-revision-conflict': {
1768
+ cause: 'The report file Brief revision differs from the `--attempt` / `--task-brief-hash` arguments.',
1769
+ next: 'Keep the attempt open; fix the report file or retry with the file values, do not edit the ledger.',
1770
+ },
1771
+ 'report-section-missing': {
1772
+ cause: 'The worker report file lacks `## Open choices` or `## Known weaknesses`.',
1773
+ next: 'Keep the attempt open; ask the worker to add the missing sections (`- none` when empty), then retry.',
1774
+ },
1775
+ 'stale-report': {
1776
+ cause: 'The report attempt or task_brief_hash does not match the active dispatch.',
1777
+ next: 'Keep the attempt open and call `bouncer coordinate report` with the received '
1778
+ + 'attempt and task_brief_hash; do not call record.',
1779
+ },
1780
+ 'stale-worker-report': {
1781
+ cause: 'The task brief changed after the accepted report, so the report no longer matches the brief.',
1782
+ next: 'Open a new `bouncer coordinate dispatch` for this task and rerun the implementer; do not retry record.',
1783
+ },
1784
+ 'model-invalid': {
1785
+ cause: 'The model argument is empty or was given without a value.',
1786
+ next: 'Retry with the one-line output of `bouncer subagent-model --agent <name>` as `--model <value>`.',
1787
+ },
1788
+ 'note-text-required': {
1789
+ cause: 'The note text is missing, not a string, or empty after trimming.',
1790
+ next: 'Retry `bouncer coordinate note --text "<handoff note>"` with a non-empty text.',
1791
+ },
1792
+ 'note-too-long': {
1793
+ cause: 'The note text is longer than 2000 characters after trimming.',
1794
+ next: 'Shorten the note to 2000 characters or fewer and retry `bouncer coordinate note`.',
1795
+ },
1796
+ 'worker-model-role-invalid': {
1797
+ cause: 'worker-model and worker-tokens record only the implementer, reviewer or debugger role.',
1798
+ next: 'Retry with `--role implementer`, `--role reviewer` or `--role debugger`.',
1799
+ },
1800
+ 'tokens-invalid': {
1801
+ cause: 'The tokens value is negative, fractional, not a number, or missing for worker-tokens.',
1802
+ next: 'Retry with `--tokens <n>` set to the non-negative integer the host reported, or omit it for worker-model.',
1803
+ },
1804
+ 'worker-model-perspective-invalid': {
1805
+ cause: 'The perspective is not a reviewer perspective, or it was given with a non-reviewer role.',
1806
+ // 열거를 하드코딩하면 REVIEW_PERSPECTIVE(review-policy 정본)와 어긋난다 — 상수에서 만든다.
1807
+ next: `Retry with a reviewer perspective (${REVIEW_PERSPECTIVE.join(', ')}), `
1808
+ + 'or drop `--perspective` for the debugger.',
1809
+ },
1810
+ 'summary-required': {
1811
+ cause: 'Report requires a non-empty summary.',
1812
+ next: 'Retry `bouncer coordinate report` with a summary of the worker outcome.',
1813
+ },
1814
+ 'task-outside-blueprint': {
1815
+ cause: 'The task id is not a commit task on this blueprint ledger.',
1816
+ next: 'Run `bouncer coordinate status` and use a task id listed on this blueprint.',
1817
+ },
1818
+ 'task-required': {
1819
+ cause: 'This command requires a three-digit task id.',
1820
+ next: 'Retry the same `bouncer coordinate` command with `--task NNN`.',
1821
+ },
1822
+ 'terminal-failure-required': {
1823
+ cause: 'Repair requires a stored terminal verification failure.',
1824
+ next: 'Run `bouncer coordinate status` and issue `bouncer coordinate repair` only '
1825
+ + 'after a terminal verification failure.',
1826
+ },
1827
+ 'unassigned-integration-worktree': {
1828
+ cause: 'The integration path exists but is not a git worktree registered for this drive.',
1829
+ next: 'Remove or replace the stray path, then retry `bouncer coordinate bootstrap`.',
1830
+ },
1831
+ 'unassigned-worker-worktree': {
1832
+ cause: 'The worker path is not the git worktree assigned to this task.',
1833
+ next: 'Do not seed into that path; run `bouncer coordinate prepare` from the '
1834
+ + 'integration worktree so it can assign workers.',
1835
+ },
1836
+ 'unknown-coordinate-command': {
1837
+ cause: 'The coordinate command is not a supported subcommand.',
1838
+ next: 'Run `bouncer coordinate status` or a documented subcommand from `bouncer help`.',
1839
+ },
1840
+ 'verification-failed': {
1841
+ cause: 'The verification task command failed on the integration worktree.',
1842
+ next: 'Inspect the verification payload, then `bouncer coordinate repair` or report the failure to the user.',
1843
+ },
1844
+ 'wave-verification-failed': {
1845
+ cause: 'Wave verification failed after fan-in applied the recorded commits.',
1846
+ next: 'Call `bouncer coordinate revoke` or `bouncer coordinate repair` from the '
1847
+ + 'failure evidence, then retry integrate.',
1848
+ },
1849
+ 'worker-salvage-failed': {
1850
+ cause: 'Preserving the revoked worker changes (patch or refs/bouncer/salvage ref) failed, so nothing was removed.',
1851
+ next: 'Check the reported salvage patch and ref manually and report; the worker is left as it was.',
1852
+ },
1853
+ 'worker-evidence-not-terminal': {
1854
+ cause: 'Worker verification or review evidence files are still open, so fan-in is refused.',
1855
+ next: 'Close those evidence files on the worker, then retry `bouncer coordinate integrate`.',
1856
+ },
1857
+ 'worker-evidence-sha-mismatch': {
1858
+ cause: 'The commit_sha stamp in the worker tasks.md differs from the SHA recorded in the ledger '
1859
+ + '(recorded before the commit, or the worker was recommitted after recording).',
1860
+ next: 'Confirm the commit and stamp on the worker, then call `bouncer coordinate rerecord`; '
1861
+ + 'if the worker has no commit, call `bouncer coordinate revoke` and `prepare` again. '
1862
+ + 'In light mode, report and stop.',
1863
+ },
1864
+ 'worker-commit-missing': {
1865
+ cause: 'The worker HEAD is the integration head or one of its ancestors, so no task commit exists yet.',
1866
+ next: 'Run `bouncer coordinate advance` to create the task commit, then rerun report/record.',
1867
+ },
1868
+ 'worker-commit-stamp-mismatch': {
1869
+ cause: 'The commit_sha stamp in the worker tasks.md is missing or differs from the first 8 chars of HEAD.',
1870
+ next: 'Run `bouncer coordinate advance` to create the task commit, then rerun report/record.',
1871
+ },
1872
+ 'worker-scope-dirty': {
1873
+ cause: 'The worker has uncommitted changes inside the approved scope (dirty_paths).',
1874
+ next: 'Run `bouncer coordinate advance` to create the task commit, then rerun report/record.',
1875
+ },
1876
+ };
1877
+ /**
1878
+ * coordinate 본문이 돌려준 실패 객체에 cause/next를 붙인다.
1879
+ * 개별 return 지점을 고치지 않기 위해 공개 경계에서만 병합한다.
1880
+ *
1881
+ * @param {unknown} result - coordinate 본문 반환값
1882
+ * @returns {unknown} 실패면 cause·next가 붙은 사본, 아니면 원본
1883
+ */
1884
+ function withCoordinateFailureHints(result) {
1885
+ if (!result || typeof result !== 'object')
1886
+ return result;
1887
+ const body = result;
1888
+ if (body.ok === false && typeof body.reason === 'string') {
1889
+ const hint = COORDINATE_FAILURE_HINTS[body.reason] || {
1890
+ cause: body.reason,
1891
+ next: FALLBACK_COORDINATE_NEXT,
1892
+ };
1893
+ // 매핑된 경계 reason에만 gate를 붙인다. 나머지는 키 자체가 없다.
1894
+ const gate = compositeGates.compositeGateForReason(body.reason);
1895
+ return { ...body, cause: hint.cause, next: hint.next, ...(gate ? { gate } : {}) };
1896
+ }
1897
+ return result;
1898
+ }
1899
+ /**
1900
+ * 공개 coordinate 진입점. 본문 실패에 힌트를 붙인 뒤에만 호출자에게 돌려준다.
1901
+ *
1902
+ * @param {Parameters<typeof coordinate>[0]} opts - `coordinate`와 동일한 인자 객체
1903
+ * @returns {ReturnType<typeof withCoordinateFailureHints>} 힌트가 병합된 coordinate 결과
1904
+ */
1905
+ function coordinateWithHints(opts) {
1906
+ return withCoordinateFailureHints(coordinate(opts));
1907
+ }
1908
+ module.exports = { coordinate: coordinateWithHints, COORDINATE_FAILURE_HINTS, withCoordinateFailureHints };