@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,1416 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const runtime = require("./runtime-state");
5
+ const frontmatter = require("./frontmatter");
6
+ const { parseFrontmatter, readDoc } = frontmatter;
7
+ const render = require("./render");
8
+ const { renderDoc } = render;
9
+ const pathsMod = require("./paths");
10
+ const { parsePathIds } = pathsMod;
11
+ const contextIdsMod = require("./context-ids");
12
+ const { parseContextPath } = contextIdsMod;
13
+ const validateSections = require("./validate-sections");
14
+ const { REPAIR_WAVE_LIMIT } = validateSections;
15
+ const executionDag = require("./execution-dag");
16
+ const { pathSetOf } = executionDag;
17
+ const scopeMod = require("./scope");
18
+ const { withLedgerLock } = scopeMod;
19
+ const commandArgvMod = require("./command-argv");
20
+ const leaseMod = require("./lease");
21
+ const { checkLease, revokeLease } = leaseMod;
22
+ const coordinatorLedger = require("./coordinator-ledger");
23
+ const { ledgerBytesHash } = coordinatorLedger;
24
+ const coordinatorGit = require("./coordinator-git");
25
+ const { git } = coordinatorGit;
26
+ const coordinatorVerification = require("./coordinator-verification");
27
+ const { prepareVerificationDependencies, selectVerificationRunner, runTargetVerification } = coordinatorVerification;
28
+ const coordinatorWorker = require("./coordinator-worker");
29
+ const { readBouncerBlock, isBlueprintReviewModeAt, reviewContractAt, ensureIntegrationCwd, currentScopePaths, normalizeSupplementPath, porcelainPaths, } = coordinatorWorker;
30
+ const schema = require("./schema");
31
+ const reviewRepairState = require("./review-repair-state");
32
+ const { hasReviewRepairDecision, reviewRoundExists } = reviewRepairState;
33
+ function sourceRepairPaths(paths) {
34
+ return Array.isArray(paths) && paths.length > 0 && paths.every((entry) => {
35
+ if (typeof entry !== 'string')
36
+ return false;
37
+ const candidate = entry.replaceAll('\\', '/').trim();
38
+ if (!candidate || path.posix.isAbsolute(candidate) || /^[A-Za-z]:\//.test(candidate) || candidate.startsWith('~')
39
+ || /[*?[\]{}]/.test(candidate) || candidate.split('/').includes('..'))
40
+ return false;
41
+ const normalized = path.posix.normalize(candidate);
42
+ return normalized !== '.' && normalized !== './' && normalized !== '..' && !normalized.startsWith('../')
43
+ && normalized !== '.git' && !normalized.startsWith('.git/')
44
+ && normalized !== '.bouncer' && !normalized.startsWith('.bouncer/');
45
+ });
46
+ }
47
+ /**
48
+ * repair 생성값(경로·verify)을 ledger 객체 수정과 문서 쓰기 전에 검사한다.
49
+ * scoped_terminal repair가 종단 전체 명령과 추측한 경로를 받아 재배정이 반복되던
50
+ * 문제를 생성 시점에 막는 단일 지점이다. CI·리뷰 원인 두 분기가 같은 검사를 쓴다.
51
+ *
52
+ * 1. `--paths`는 integration HEAD에서 추적돼야 하고, `--new-path`는 부모 디렉터리가
53
+ * 추적 파일의 조상이어야 한다(git 실패는 삼키지 않고 던진다).
54
+ * 2. scoped_terminal은 `--verify`가 필수이며 G20과 같은 규칙(단일 argv, 종단 명령과
55
+ * 다름)을 통과해야 한다. legacy·정책 미상은 `--verify`가 없으면 기존
56
+ * `resolveRepairVerify` 결과를 그대로 쓰도록 verify를 비워 두고, 있으면 단일
57
+ * argv 검사만 적용한다 — 종단 비교와 필수 검사는 scoped_terminal 전용이다.
58
+ *
59
+ * @param {object} opts
60
+ * @param {RepairSupport} opts.support - 정상 경로 helper(verification policy 조회)
61
+ * @param {Exec} opts.exec - git 실행기
62
+ * @param {string} opts.integrationPath - integration worktree 절대 경로
63
+ * @param {string} opts.blueprint - blueprint 상대 경로
64
+ * @param {Ledger} opts.ledger - 종단(verification) task 목록을 읽을 원장
65
+ * @param {string[]} opts.paths - `--paths`
66
+ * @param {string[]} opts.newPaths - `--new-path`
67
+ * @param {string | undefined} opts.verify - `--verify`
68
+ * @returns {RepairIntake} 거절 사유(와 위반 경로) 또는 최종 scope·verify
69
+ */
70
+ function checkRepairIntake({ exec, integrationPath, blueprint, ledger, paths, newPaths, verify, support }) {
71
+ if (newPaths.length > 0 && !sourceRepairPaths(newPaths)) {
72
+ return { ok: false, reason: 'repair-scope-out-of-bounds' };
73
+ }
74
+ const violations = scopeMod.trackedScopeViolations(exec, integrationPath, paths, newPaths);
75
+ if (violations.untracked.length > 0) {
76
+ return { ok: false, reason: 'scope-path-untracked', paths: violations.untracked };
77
+ }
78
+ if (violations.parentMissing.length > 0) {
79
+ return { ok: false, reason: 'scope-new-path-parent-missing', paths: violations.parentMissing };
80
+ }
81
+ const finalPaths = [...new Set([...paths, ...newPaths])];
82
+ const scoped = support.verificationPolicyAt(integrationPath, blueprint) === 'scoped_terminal';
83
+ if (verify === undefined) {
84
+ if (scoped)
85
+ return { ok: false, reason: 'repair-verify-required' };
86
+ return { ok: true, paths: finalPaths };
87
+ }
88
+ if (commandArgvMod.parseVerifyArgv(verify) === null) {
89
+ return { ok: false, reason: 'repair-verify-not-single-argv' };
90
+ }
91
+ if (scoped) {
92
+ const normalized = commandArgvMod.normalizeVerifyCommand(verify);
93
+ for (const terminal of ledger.tasks.filter((entry) => entry.execution_kind === 'verification')) {
94
+ const data = readBouncerBlock(path.join(integrationPath, blueprint, 'tasks', terminal.id, 'tasks.md'));
95
+ const terminalVerify = data && typeof data === 'object'
96
+ ? data.verify : undefined;
97
+ if (typeof terminalVerify === 'string'
98
+ && commandArgvMod.normalizeVerifyCommand(terminalVerify) === normalized) {
99
+ return { ok: false, reason: 'repair-verify-is-terminal' };
100
+ }
101
+ }
102
+ }
103
+ return { ok: true, paths: finalPaths, verify };
104
+ }
105
+ /**
106
+ * 테스트 경로 판정. 첫 조각이 `test`·`tests`이거나 파일명이 `*.test.*`·`*.spec.*`이고
107
+ * 경로 어디에도 `fixtures` 조각이 없는 경로만 true다. fixtures는 테스트가 읽는
108
+ * 데이터라 제품 동작을 바꿀 수 있어 제자리 보완 대상에서 뺀다.
109
+ *
110
+ * @param {string} normalized - normalizeSupplementPath를 거친 경로
111
+ * @returns {boolean} 테스트 경로이면 true
112
+ */
113
+ function isSupplementTestPath(normalized) {
114
+ const parts = normalized.split('/');
115
+ if (parts.includes('fixtures'))
116
+ return false;
117
+ const base = parts[parts.length - 1];
118
+ return parts[0] === 'test' || parts[0] === 'tests' || /\.(test|spec)\./.test(base);
119
+ }
120
+ /**
121
+ * 경로가 허용 경로 목록 중 하나와 같거나 그 아래인지 본다.
122
+ *
123
+ * @param {string} normalized - 비교할 정규형 경로
124
+ * @param {string[]} allowed - 정규형이 아니어도 되는 허용 경로(파일 또는 디렉터리)
125
+ * @returns {boolean} 포함되면 true
126
+ */
127
+ function coveredByPaths(normalized, allowed) {
128
+ return allowed.some((entry) => {
129
+ const base = normalizeSupplementPath(entry);
130
+ return normalized === base || normalized.startsWith(`${base}/`);
131
+ });
132
+ }
133
+ /**
134
+ * 원장에서 가장 최근 supplement 결정을 찾는다. 같은 kind가 여러 번 쌓여도
135
+ * 마지막 하나만 현재 상태를 말한다.
136
+ *
137
+ * @param {Ledger} ledger - 원장
138
+ * @returns {SupplementDecision | undefined} 최신 supplement 결정, 없으면 undefined
139
+ */
140
+ function latestSupplementDecision(ledger) {
141
+ for (let i = ledger.decisions.length - 1; i >= 0; i -= 1) {
142
+ const entry = ledger.decisions[i];
143
+ if (entry && typeof entry === 'object' && entry.kind === 'supplement') {
144
+ return entry;
145
+ }
146
+ }
147
+ return undefined;
148
+ }
149
+ /**
150
+ * blueprint root `review.md`에 주어진 mode의 라운드가 이미 있는지 strict 정책으로 읽는다.
151
+ * 문서 부재(ENOENT)만 "라운드 없음"으로 흡수한다. YAML 파손 같은 다른 실패를 없음으로 접으면
152
+ * 이미 끝난 리뷰에 repair·supplement가 다시 열리므로 오류는 올린다.
153
+ *
154
+ * @param {string} integrationPath - integration worktree
155
+ * @param {string} blueprint - blueprint 상대 경로
156
+ * @param {'discovery' | 'delta'} mode - 찾을 라운드 mode
157
+ * @returns {boolean} 해당 mode 라운드가 있으면 true
158
+ */
159
+ function rootReviewHasRound(integrationPath, blueprint, mode) {
160
+ return reviewRoundExists({ file: path.join(integrationPath, blueprint, 'review.md'), error_policy: 'strict' }, mode);
161
+ }
162
+ const rootReviewHasDelta = (integrationPath, blueprint) => (rootReviewHasRound(integrationPath, blueprint, 'delta'));
163
+ /**
164
+ * blueprint 최종 리뷰가 이미 discovery 한 라운드를 저장했는지 본다. 최종 리뷰는 그 한
165
+ * 라운드로 끝나므로, 저장된 뒤에는 리뷰 원인 repair·supplement가 원장과 task를 쓰기
166
+ * 전에 거절돼야 한다. 리뷰 모드 blueprint의 root `review.md`만 대상으로 한다.
167
+ *
168
+ * @param {string} integrationPath - integration worktree
169
+ * @param {string} blueprint - blueprint 상대 경로
170
+ * @returns {boolean} 리뷰 모드이고 `bouncer.review.rounds[].mode`에 discovery가 있으면 true
171
+ */
172
+ function finalReviewDiscoveryStored(integrationPath, blueprint) {
173
+ if (!isBlueprintReviewModeAt(integrationPath, blueprint))
174
+ return false;
175
+ return rootReviewHasRound(integrationPath, blueprint, 'discovery');
176
+ }
177
+ /**
178
+ * 최종 리뷰 이후 리뷰 원인 repair·supplement 접수 여부를 한 곳에서 판정한다. 표식이 허용 밖의
179
+ * 값이면 구형으로 접지 않고 거절한다. 구형 blueprint는 discovery 뒤 전부 거절하고, `brief`
180
+ * 표식은 discovery 뒤 승인된 경로 안의 리뷰 원인 repair를 한 번(delta 전·이전 repair 없음)만
181
+ * 받는다. 거절은 원장·문서를 쓰기 전에 끝난다.
182
+ *
183
+ * @param {object} opts - 판정 입력
184
+ * @param {string} opts.integrationPath - integration worktree
185
+ * @param {string} opts.blueprint - blueprint 상대 경로
186
+ * @param {Ledger} opts.ledger - 열린 원장(읽기만 한다)
187
+ * @param {boolean} opts.supplement - supplement 접수이면 true, 리뷰 원인 repair이면 false
188
+ * @param {string[]} [opts.newPaths] - 리뷰 원인 repair가 선언한 새 경로
189
+ * @returns {{ ok: false; reason: string } | null} 거절이면 reason, 접수 가능이면 null
190
+ */
191
+ function finalReviewGate(opts) {
192
+ const { integrationPath, blueprint, ledger, supplement, newPaths } = opts;
193
+ const contract = reviewContractAt(integrationPath, blueprint);
194
+ if (contract === null)
195
+ return { ok: false, reason: schema.REVIEW_CONTRACT_INVALID_REASON };
196
+ if (!finalReviewDiscoveryStored(integrationPath, blueprint))
197
+ return null;
198
+ // supplement는 표식과 무관하게 discovery 뒤에 닫힌다. 수정 기회는 repair 한 번뿐이다.
199
+ if (contract !== 'brief' || supplement)
200
+ return { ok: false, reason: 'final-review-single-round' };
201
+ if (hasReviewRepairDecision(ledger.decisions) || rootReviewHasDelta(integrationPath, blueprint)) {
202
+ return { ok: false, reason: 'final-review-single-round' };
203
+ }
204
+ // 새 경로는 승인된 affected_paths 밖이다. 최종 리뷰의 수정 1회는 승인 범위 안에서만 열린다.
205
+ if (newPaths && newPaths.length > 0)
206
+ return { ok: false, reason: 'final-review-repair-scope' };
207
+ return null;
208
+ }
209
+ /**
210
+ * integration worktree에서 커밋되지 않은 변경 경로를 모은다(추적·미추적 모두).
211
+ * `.bouncer/` 아래는 원장·증적이라 보완 범위 판정에서 뺀다. rename은 양쪽 경로를 낸다.
212
+ *
213
+ * @param {Exec} exec - 주입 가능한 Git 실행기
214
+ * @param {string} integrationPath - integration worktree
215
+ * @returns {string[]} 정규형 변경 경로
216
+ */
217
+ function integrationChangedPaths(exec, integrationPath) {
218
+ return porcelainPaths(exec, integrationPath)
219
+ .filter((entry) => entry !== '.bouncer' && !entry.startsWith('.bouncer/'));
220
+ }
221
+ /**
222
+ * supplement 접수 검사. CLI는 경로 조건만 검증하고 finding이 정말 테스트 근거
223
+ * 보완인지는 coordinator가 선언한다 — 그래서 여기서는 선언이 기계적으로 닫을 수
224
+ * 있는 조건(테스트 경로, 승인 범위, 모든 task integrated, delta 미사용)만 본다.
225
+ * 거절 순서는 원인이 겹칠 때 어느 쪽이 먼저 보이는지만 정한다.
226
+ *
227
+ * @param {object} opts - 접수 입력
228
+ * @param {Ledger} opts.ledger - 원장(읽기 전용)
229
+ * @param {string} opts.integrationPath - integration worktree
230
+ * @param {string} opts.blueprint - blueprint 상대 경로
231
+ * @param {string | undefined} opts.failureCommand - CI 원인 플래그(있으면 모호)
232
+ * @param {string[] | undefined} opts.reviewFindings - 최종 리뷰 finding id
233
+ * @param {string | undefined} opts.summary - 보완 필요 사유
234
+ * @param {unknown} opts.paths - `--paths`
235
+ * @returns {{ ok: true, paths: string[], findings: string[] } | { ok: false, reason: string }} 통과 시
236
+ * 정규화된 경로와 finding, 거절 시 reason
237
+ */
238
+ function checkSupplementIntake({ ledger, integrationPath, blueprint, failureCommand, reviewFindings, summary, paths }) {
239
+ if (typeof failureCommand === 'string')
240
+ return { ok: false, reason: 'repair-cause-ambiguous' };
241
+ if (!Array.isArray(reviewFindings) || reviewFindings.length === 0
242
+ || reviewFindings.some((entry) => typeof entry !== 'string' || entry === '')
243
+ || typeof summary !== 'string' || summary === '') {
244
+ return { ok: false, reason: 'failure-evidence-required' };
245
+ }
246
+ if (!sourceRepairPaths(paths))
247
+ return { ok: false, reason: 'repair-scope-out-of-bounds' };
248
+ const normalized = paths.map(normalizeSupplementPath);
249
+ if (!normalized.every(isSupplementTestPath))
250
+ return { ok: false, reason: 'supplement-paths-not-tests' };
251
+ if (ledger.tasks.some((entry) => (entry.status || 'pending') !== 'integrated')) {
252
+ return { ok: false, reason: 'review-repair-requires-integrated' };
253
+ }
254
+ const approved = ledger.tasks.flatMap((entry) => pathSetOf(entry) || []);
255
+ if (!normalized.every((entry) => coveredByPaths(entry, approved))) {
256
+ return { ok: false, reason: 'repair-scope-out-of-bounds' };
257
+ }
258
+ if (rootReviewHasDelta(integrationPath, blueprint))
259
+ return { ok: false, reason: 'supplement-delta-used' };
260
+ return { ok: true, paths: normalized, findings: [...reviewFindings] };
261
+ }
262
+ /**
263
+ * `coordinate repair --kind supplement --done`. implementer가 integration에 남긴
264
+ * 변경이 접수된 경로 안인지 확인하고, 그 경로를 덮는 integrated task의 verify를
265
+ * 명령 중복 없이 기존 러너로 돌린 뒤 결정을 `verified`로 올린다. 검증은 오래 걸릴
266
+ * 수 있어 integrateVerificationTask와 같이 잠금 밖에서 돌리고, 두 번째 잠금에서
267
+ * 원장 bytes가 그대로일 때만 쓴다. 실패하면 결정은 pending에 남아 재시도할 수 있다.
268
+ *
269
+ * @param {object} opts - repair 인자
270
+ * @param {RepairSupport} opts.support - 원장 fence·재확인·checkpoint helper
271
+ * @param {string} opts.blueprint - blueprint 상대 경로
272
+ * @param {string} opts.integrationPath - integration worktree
273
+ * @param {string} opts.ledgerFile - 원장 절대 경로
274
+ * @param {string} [opts.ledgerPath] - fence 원장 상대 경로
275
+ * @param {string} [opts.ledgerHash] - fence sha256
276
+ * @param {Exec} opts.exec - git·npm 주입 실행기
277
+ * @param {Function} opts.writeLedger - 원장 원자 쓰기
278
+ * @param {{ runVerification?: VerificationRunner }} opts.deps - 검증 러너 주입
279
+ * @returns {unknown} 성공 시 ok:true와 결정, 실패 시 ok:false와 reason
280
+ */
281
+ function supplementDone({ blueprint, integrationPath, ledgerFile, ledgerPath, ledgerHash, exec, writeLedger, deps, support, }) {
282
+ let phase1Hash = '';
283
+ let plan = [];
284
+ let approvedPaths = [];
285
+ const phase1 = withLedgerLock(ledgerFile, () => {
286
+ const opened = support.openLedgerFenced({ ledgerFile, ledgerPath, ledgerHash });
287
+ if (!opened.ok)
288
+ return opened;
289
+ const { ledger, bytes } = opened;
290
+ if (ledger.status === 'promotion_stopped')
291
+ return { ok: false, reason: 'promotion-stopped' };
292
+ const pending = latestSupplementDecision(ledger);
293
+ // 거절은 검증 실행과 원장 쓰기보다 앞선다. 최종 discovery 뒤에는 보완 완료도 받지 않는다.
294
+ const gate = finalReviewGate({ integrationPath, blueprint, ledger, supplement: true });
295
+ if (gate)
296
+ return gate;
297
+ if (!pending || pending.outcome !== 'pending')
298
+ return { ok: false, reason: 'supplement-not-pending' };
299
+ // 검증이 만드는 증적 파일이 변경 목록에 섞이지 않게 검증 전에 잰다.
300
+ const changed = integrationChangedPaths(exec, integrationPath);
301
+ if (!changed.every((entry) => coveredByPaths(entry, pending.paths))) {
302
+ return { ok: false, reason: 'supplement-paths-exceeded', paths: changed };
303
+ }
304
+ const seen = new Set();
305
+ const next = [];
306
+ for (const entry of ledger.tasks) {
307
+ const own = pathSetOf(entry) || [];
308
+ if (!pending.paths.some((target) => coveredByPaths(target, own)))
309
+ continue;
310
+ const block = readBouncerBlock(path.join(integrationPath, blueprint, 'tasks', entry.id, 'tasks.md'));
311
+ const verify = block && typeof block.verify === 'string' ? block.verify.trim() : '';
312
+ if (seen.has(verify))
313
+ continue;
314
+ seen.add(verify);
315
+ next.push({ taskId: entry.id, verify });
316
+ }
317
+ plan = next;
318
+ approvedPaths = [...pending.paths];
319
+ phase1Hash = ledgerBytesHash(bytes);
320
+ return { ok: true };
321
+ });
322
+ if (!phase1 || phase1.ok === false)
323
+ return phase1;
324
+ // 잠금 밖: 의존성 준비 후 verify 실행. integration checkout은 추적 파일만 받아
325
+ // node_modules가 비어 있을 수 있다.
326
+ const prepared = prepareVerificationDependencies(integrationPath, exec);
327
+ if (!prepared.ok)
328
+ return { ok: false, reason: 'dependency-install-failed', message: prepared.message };
329
+ const runner = selectVerificationRunner(deps.runVerification);
330
+ const intentId = support.intentIdFor(blueprint);
331
+ const head = git(exec, integrationPath, ['rev-parse', 'HEAD']);
332
+ const evidenceIds = [];
333
+ for (const step of plan) {
334
+ const result = runTargetVerification(runner, {
335
+ repoRoot: integrationPath,
336
+ blueprintDir: blueprint,
337
+ taskId: step.taskId,
338
+ scope: { kind: 'wave', key: `${intentId}:supplement:${head}` },
339
+ });
340
+ if (!result.ok) {
341
+ return { ok: false, reason: 'supplement-verify-failed', verification: result };
342
+ }
343
+ if (typeof result.evidenceId === 'string' && result.evidenceId !== '')
344
+ evidenceIds.push(result.evidenceId);
345
+ }
346
+ return withLedgerLock(ledgerFile, (owns) => {
347
+ const rechecked = support.recheckLedgerCheckpoint({ ledgerFile, expectedHash: phase1Hash });
348
+ if (!rechecked.ok)
349
+ return rechecked;
350
+ const { ledger } = rechecked;
351
+ const decision = latestSupplementDecision(ledger);
352
+ if (!decision)
353
+ return { ok: false, reason: 'supplement-not-pending' };
354
+ decision.outcome = 'verified';
355
+ if (!owns())
356
+ return { ok: false, reason: 'ledger-lock-lost' };
357
+ writeLedger(ledgerFile, ledger);
358
+ return support.withCheckpoint({
359
+ ok: true, command: 'repair', kind: 'supplement', done: true, paths: approvedPaths,
360
+ decision, evidence_ids: evidenceIds,
361
+ }, ledger, ledgerFile);
362
+ });
363
+ }
364
+ /**
365
+ * terminal 검증 실패를 "기존 task 재배정(resume)" 또는 "범위 밖 분류(disposition)"로 해소하기 위한
366
+ * 모든 판정을 한 곳에서 한다. 이 함수는 원장·문서·Git을 바꾸지 않는다 — 호출자가 ok일 때만
367
+ * 변경을 적용하므로, 어떤 거절도 실패 상태(terminalFailure·verifying)를 건드리지 않는다.
368
+ *
369
+ * 공통 거절: terminal이 아닌 task(`terminal-failure-required`), 증거 누락(`failure-evidence-required`),
370
+ * 결정 근거 누락(`decision-reason-required`), 소스 경로가 아닌 `--paths`(`repair-scope-out-of-bounds`),
371
+ * 기록된 실패와 다른 task·command(`failure-evidence-mismatch`).
372
+ * resume 거절: 형식 오류·terminal 자신·미통합 task(`resume-task-invalid`), 이 blueprint에 없는
373
+ * task(`task-outside-blueprint`), stale lease(`stale-lease`), stale failure identity, owner의
374
+ * 승인 범위 밖 경로(`repair-scope-out-of-bounds`), wave 한도(`repair-wave-limit`), salvage 실패.
375
+ * disposition 거절: 알 수 없는 종류(`disposition-invalid`), 비활성 원장(`ledger-not-active`),
376
+ * 원본 evidence/source identity 없음(`disposition-evidence-required`), 소스가 실패 뒤 바뀜
377
+ * (`disposition-stale-source`), 어느 task 범위와 겹치는 경로(`disposition-scope-mixed`).
378
+ *
379
+ * @param {object} opts - 판정 입력
380
+ * @param {RepairSupport} opts.support - worker 보존 helper
381
+ * @param {Ledger} opts.ledger - 읽기 전용으로 쓰는 현재 원장
382
+ * @param {Exec} opts.exec - git 실행기(integration HEAD 조회)
383
+ * @param {string} opts.integrationPath - integration worktree 경로
384
+ * @param {string | undefined} opts.task - 실패한 terminal task id
385
+ * @param {string | undefined} opts.failureCommand - 호출자가 주장하는 실패 command
386
+ * @param {string | undefined} opts.summary - 원인·실패 테스트·필요 변경의 범위 판단
387
+ * @param {unknown} opts.decision - 결정 근거
388
+ * @param {unknown} opts.repairPaths - `--paths`
389
+ * @param {string | undefined} opts.resumeTask - 재배정할 기존 task id
390
+ * @param {string | undefined} opts.disposition - 분류 종류(`outside_scope`만 허용)
391
+ * @param {string | undefined} opts.leaseId - 재개 owner lease fence
392
+ * @param {number | undefined} opts.generation - 재개 owner generation fence
393
+ * @returns {TerminalResolution} 거절 사유 또는 적용할 변경 계획
394
+ */
395
+ function resolveTerminalFailure({ ledger, exec, integrationPath, task, failureCommand, summary, decision, repairPaths, resumeTask, disposition, leaseId, generation, support, }) {
396
+ if (disposition !== undefined && disposition !== 'outside_scope') {
397
+ return { ok: false, reason: 'disposition-invalid' };
398
+ }
399
+ if (!task || !/^\d{3}$/.test(task))
400
+ return { ok: false, reason: 'task-required' };
401
+ const terminal = ledger.tasks.find((entry) => entry.id === task);
402
+ if (!terminal)
403
+ return { ok: false, reason: 'task-outside-blueprint' };
404
+ if (terminal.execution_kind !== 'verification' || terminal.status !== 'verifying') {
405
+ return { ok: false, reason: 'terminal-failure-required' };
406
+ }
407
+ if (disposition !== undefined && ledger.status !== undefined
408
+ && ledger.status !== 'active' && ledger.status !== 'awaiting_confirmation') {
409
+ return { ok: false, reason: 'ledger-not-active' };
410
+ }
411
+ if (typeof failureCommand !== 'string' || failureCommand === ''
412
+ || typeof summary !== 'string' || summary.trim() === '') {
413
+ return { ok: false, reason: 'failure-evidence-required' };
414
+ }
415
+ if (typeof decision !== 'string' || decision.trim() === '') {
416
+ return { ok: false, reason: 'decision-reason-required' };
417
+ }
418
+ if (!sourceRepairPaths(repairPaths))
419
+ return { ok: false, reason: 'repair-scope-out-of-bounds' };
420
+ // 지금 기록된 실패와 같은 task·command일 때만 해소한다. 다른 실패에 대한 근거를 끌어다 쓰면
421
+ // 방금 터진 새 실패가 이전 판단으로 덮인다.
422
+ const recorded = ledger.terminalFailure;
423
+ if (!recorded || recorded.task !== task || recorded.command !== failureCommand) {
424
+ return { ok: false, reason: 'failure-evidence-mismatch' };
425
+ }
426
+ const paths = repairPaths.map((entry) => normalizeSupplementPath(entry));
427
+ if (disposition === 'outside_scope') {
428
+ if (typeof recorded.evidence_id !== 'string' || recorded.evidence_id === ''
429
+ || typeof recorded.source_digest !== 'string' || recorded.source_digest === '') {
430
+ return { ok: false, reason: 'disposition-evidence-required' };
431
+ }
432
+ // 소스가 실패 뒤 바뀌었으면 원본 증거가 지금 코드를 설명하지 못한다. 원장 head와 실제 HEAD를
433
+ // 모두 대조해 어느 한쪽만 움직인 경우도 stale로 막는다.
434
+ const ledgerHead = typeof ledger.integrationHead === 'string' ? ledger.integrationHead : null;
435
+ const actualHead = git(exec, integrationPath, ['rev-parse', 'HEAD']);
436
+ if ((ledgerHead !== null && ledgerHead !== recorded.source_digest)
437
+ || actualHead !== recorded.source_digest) {
438
+ return { ok: false, reason: 'disposition-stale-source' };
439
+ }
440
+ // 범위 밖이려면 어떤 task의 승인 범위(조상·후손 포함)와도 겹치지 않아야 한다. 일부라도 겹치면
441
+ // 범위 안 원인이 섞인 것이라 분류하지 않고 기존 blocked를 유지한다.
442
+ const owned = ledger.tasks.flatMap((entry) => currentScopePaths(entry).map(normalizeSupplementPath));
443
+ const overlaps = (candidate) => coveredByPaths(candidate, owned)
444
+ || owned.some((entry) => entry.startsWith(`${candidate}/`));
445
+ if (paths.some(overlaps))
446
+ return { ok: false, reason: 'disposition-scope-mixed' };
447
+ return {
448
+ ok: true, kind: 'disposition', terminal, failure: recorded,
449
+ record: {
450
+ kind: 'disposition', task, summary: summary.trim(),
451
+ disposition: {
452
+ kind: 'outside_scope', reason: decision.trim(), evidence_id: recorded.evidence_id,
453
+ source_digest: recorded.source_digest, paths,
454
+ },
455
+ },
456
+ };
457
+ }
458
+ if (typeof resumeTask !== 'string' || !/^\d{3}$/.test(resumeTask)) {
459
+ return { ok: false, reason: 'resume-task-invalid' };
460
+ }
461
+ const target = ledger.tasks.find((entry) => entry.id === resumeTask);
462
+ if (!target)
463
+ return { ok: false, reason: 'task-outside-blueprint' };
464
+ if (target.id === terminal.id || target.execution_kind === 'verification' || target.status !== 'integrated') {
465
+ return { ok: false, reason: 'resume-task-invalid' };
466
+ }
467
+ const leaseCheck = checkLease(target, {
468
+ ...(leaseId !== undefined ? { lease_id: leaseId } : {}),
469
+ ...(generation !== undefined ? { generation } : {}),
470
+ });
471
+ if (!leaseCheck.ok)
472
+ return { ok: false, reason: leaseCheck.reason,
473
+ expected: leaseCheck.expected, received: leaseCheck.received };
474
+ // 재작업 경로는 지정 task가 이미 승인받은 범위 안이어야 한다. 새 범위를 넓히는 통로가 아니다.
475
+ const approved = currentScopePaths(target);
476
+ if (approved.length === 0 || !paths.every((entry) => coveredByPaths(entry, approved))) {
477
+ return { ok: false, reason: 'repair-scope-out-of-bounds' };
478
+ }
479
+ const waves = ledger.repairWaves || [];
480
+ if (waves.length >= REPAIR_WAVE_LIMIT) {
481
+ return { ok: false, reason: 'repair-wave-limit', status: 'awaiting_confirmation' };
482
+ }
483
+ const dag = dagSnapshot(ledger.tasks);
484
+ const downstream = downstreamTasks(ledger.tasks, target.id);
485
+ if (!downstream.some((entry) => entry.id === terminal.id)) {
486
+ return { ok: false, reason: 'failure-evidence-mismatch' };
487
+ }
488
+ // Failure identity binds the retry to the exact integration snapshot that produced it.
489
+ // A stale source must not turn an old CI result into authority to invalidate newer work.
490
+ if (typeof recorded.evidence_id !== 'string' || recorded.evidence_id === ''
491
+ || typeof recorded.source_digest !== 'string' || recorded.source_digest === ''
492
+ || ledger.integrationHead !== recorded.source_digest
493
+ || git(exec, integrationPath, ['rev-parse', 'HEAD']) !== recorded.source_digest) {
494
+ return { ok: false, reason: 'failure-evidence-mismatch' };
495
+ }
496
+ // 이미 통합된 하위 commit task는 target 수정과 무관하게 결과가 유효하므로 건드리지 않는다.
497
+ // 되돌릴 대상은 target과, 새 통합 결과를 다시 검증해야 하는 downstream verification task뿐이다
498
+ // (failure-evidence-mismatch 판정은 위의 전체 downstream 폐포를 그대로 쓴다).
499
+ const resetDownstream = downstream.filter((entry) => entry.execution_kind === 'verification');
500
+ const resetTasks = [target];
501
+ const salvage = [];
502
+ let ownerHead = null;
503
+ for (const item of resetTasks) {
504
+ let saved;
505
+ try {
506
+ saved = support.salvageRevokedWorker(exec, integrationPath, item, recorded.source_digest);
507
+ }
508
+ catch (error) {
509
+ const partial = error.salvage;
510
+ return { ok: false, reason: 'worker-salvage-failed', task: item.id,
511
+ message: error.message,
512
+ salvage: [...salvage, ...(partial?.patch ? [{ ...partial, task: item.id }] : [])] };
513
+ }
514
+ if (item.id === target.id)
515
+ ownerHead = saved.head || null;
516
+ if (saved.patch || saved.ref)
517
+ salvage.push({ ...saved, task: item.id });
518
+ }
519
+ const revision = nextLedgerRevision(ledger.revision);
520
+ const failure = {
521
+ ...recorded, summary, paths: [...paths], repairWave: waves.length,
522
+ };
523
+ delete failure.disposition;
524
+ const repairDecision = {
525
+ task: target.id, kind: 'repair', wave: waves.length + 1, reason: decision.trim(), failure,
526
+ previousDag: dag, nextDag: dagSnapshot(ledger.tasks), previousScope: approved, nextScope: [...paths],
527
+ necessity: 'terminal CI failure is owned by an already approved integrated task', revision,
528
+ owner_generation: (target.lease?.generation || 0) + 1,
529
+ salvage_predecessor: {
530
+ head: ownerHead || target.sha || null, lease_id: target.lease?.id || null,
531
+ generation: target.lease?.generation ?? null,
532
+ },
533
+ reset_downstream: resetDownstream.map((entry) => entry.id),
534
+ salvaged: salvage.map(({ task: id, patch, ref, head, generation, dirty_paths }) => ({
535
+ task: id, patch, ref, head, generation, dirty_paths,
536
+ })),
537
+ };
538
+ return {
539
+ ok: true, kind: 'resume', terminal, target, failure, repairDecision, revision,
540
+ previousHead: typeof target.sha === 'string' ? target.sha : null, resetDownstream, salvage,
541
+ };
542
+ }
543
+ /**
544
+ * owner에서 의존 간선을 따라 도달하는 task를 topological 순서대로 찾는다.
545
+ *
546
+ * @param {Task[]} tasks - 원장의 DAG
547
+ * @param {string} ownerId - 재개 owner task id
548
+ * @returns {Task[]} owner의 모든 transitive downstream task
549
+ */
550
+ function downstreamTasks(tasks, ownerId) {
551
+ const reached = new Set([ownerId]);
552
+ const downstream = [];
553
+ let changed = true;
554
+ while (changed) {
555
+ changed = false;
556
+ for (const item of tasks) {
557
+ if (reached.has(item.id) || !(item.depends_on || []).some((id) => reached.has(id)))
558
+ continue;
559
+ reached.add(item.id);
560
+ downstream.push(item);
561
+ changed = true;
562
+ }
563
+ }
564
+ return downstream;
565
+ }
566
+ /**
567
+ * owner부터 terminal까지 downstream DAG의 증적 문서를 재작업 시작 상태로 되돌린다.
568
+ * 새 문서를 만들지 않고 이미 있는 tasks.md·verification.md·review.md의 status만 바꾼다
569
+ * (verification의 harness metadata와 이전 commit_sha도 지운다). 이전 통합 때의 `verified`/`passed`
570
+ * 증적이 남아 있으면 worker가 새 작업 없이도 integrate 증적 검사를 통과시킬 수 있기 때문이다.
571
+ * terminal은 depends_on을 건드리지 않고 `ready`로만 돌린다. 반환한 함수는 쓰기 전 바이트로 되돌린다.
572
+ *
573
+ * @param {object} opts - integrationPath·blueprint·owner·downstream·terminal task
574
+ * @param {Task[]} opts.downstream - owner 이후 도달 가능한 task 목록
575
+ * @returns {() => void} 원장 쓰기 실패 시 호출할 문서 롤백
576
+ */
577
+ function writeResumeDocuments({ integrationPath, blueprint, target, downstream, terminal }) {
578
+ const dir = (id) => path.join(integrationPath, blueprint, 'tasks', id);
579
+ const reset = (file, mutate) => {
580
+ if (!fs.existsSync(file))
581
+ return null;
582
+ const before = fs.readFileSync(file);
583
+ const doc = readDoc(file);
584
+ const bouncer = doc.data.bouncer;
585
+ if (!bouncer || typeof bouncer !== 'object')
586
+ return null;
587
+ mutate(bouncer);
588
+ fs.writeFileSync(file, renderDoc(doc.data, doc.body));
589
+ return { file, before };
590
+ };
591
+ const written = [];
592
+ // terminal 문서는 downstream 순회와 terminal 보정에서 두 번 저장될 수 있어 LIFO로 원본 bytes까지 되돌린다.
593
+ const rollback = () => { for (const entry of [...written].reverse())
594
+ fs.writeFileSync(entry.file, entry.before); };
595
+ const apply = (file, mutate) => {
596
+ const entry = reset(file, mutate);
597
+ if (entry)
598
+ written.push(entry);
599
+ };
600
+ try {
601
+ for (const item of [target, ...downstream]) {
602
+ apply(path.join(dir(item.id), 'tasks.md'), (bouncer) => {
603
+ bouncer.status = 'ready';
604
+ delete bouncer.commit_sha;
605
+ });
606
+ apply(path.join(dir(item.id), 'verification.md'), (bouncer) => {
607
+ bouncer.status = 'pending';
608
+ delete bouncer.verification;
609
+ });
610
+ apply(path.join(dir(item.id), 'review.md'), (bouncer) => { bouncer.status = 'pending'; });
611
+ }
612
+ apply(path.join(dir(terminal.id), 'tasks.md'), (bouncer) => { bouncer.status = 'ready'; });
613
+ }
614
+ catch (error) {
615
+ // 일부만 쓴 문서는 되돌린 뒤 원 예외를 그대로 올린다 — 흡수하지 않는다.
616
+ rollback();
617
+ throw error;
618
+ }
619
+ return rollback;
620
+ }
621
+ function dagSnapshot(tasks) {
622
+ return tasks.map((entry) => ({ id: entry.id, depends_on: [...(entry.depends_on || [])] }));
623
+ }
624
+ function nextLedgerRevision(current) {
625
+ const match = /^r(\d+)$/.exec(typeof current === 'string' ? current : '');
626
+ return `r${match ? Number(match[1]) + 1 : 1}`;
627
+ }
628
+ function integratedLeaves(tasks, terminalId) {
629
+ const integrated = tasks.filter((entry) => entry.id !== terminalId && entry.status === 'integrated');
630
+ const depended = new Set(integrated.flatMap((entry) => entry.depends_on || []));
631
+ return integrated.filter((entry) => !depended.has(entry.id)).map((entry) => entry.id).sort();
632
+ }
633
+ /**
634
+ * repair 문서 frontmatter에 심을 `bouncer.verify`를 고른다. terminal 선언을
635
+ * 우선하고, 없으면 repair가 매달린 integrated leaf(선행 commit) 문서에서
636
+ * 가져온다. `config.verify`는 쓰지 않는다 — repair가 전역 폴백 없이 G13을
637
+ * 통과해야 하고, 없는 config를 만들어 내면 정책 출처가 흐려진다.
638
+ *
639
+ * @param {string} integrationPath - integration checkout 절대 경로
640
+ * @param {string} blueprint - blueprint 상대 경로
641
+ * @param {Task} repair - 새로 쓸 repair task (`depends_on` = integrated leaves)
642
+ * @param {Record<string, unknown> | null} terminalData - terminal tasks.md data
643
+ * @returns {string | undefined} 비어 있지 않은 verify 문자열. 후보가 없으면 undefined
644
+ */
645
+ function resolveRepairVerify(integrationPath, blueprint, repair, terminalData) {
646
+ if (terminalData) {
647
+ const terminalBouncer = terminalData.bouncer;
648
+ if (terminalBouncer && typeof terminalBouncer === 'object') {
649
+ const terminalVerify = terminalBouncer.verify;
650
+ if (typeof terminalVerify === 'string' && terminalVerify.trim() !== '') {
651
+ return terminalVerify;
652
+ }
653
+ }
654
+ }
655
+ // depends_on은 integratedLeaves가 준 leaf다. 번호 순 첫 선언을 취해
656
+ // readVerifyCommand의 earliest-numbered 쪽과 같은 후보를 고른다.
657
+ for (const id of [...(repair.depends_on || [])].sort()) {
658
+ const file = path.join(integrationPath, blueprint, 'tasks', id, 'tasks.md');
659
+ let raw;
660
+ try {
661
+ raw = fs.readFileSync(file, 'utf8');
662
+ }
663
+ catch (error) {
664
+ // 선행 leaf 문서 부재(ENOENT)만 흡수하고 다음 leaf로 간다. EACCES 등을
665
+ // 조용히 넘기면 verify 선택이 비어 G13이 config 부재로 오진한다.
666
+ if (error && typeof error === 'object' && 'code' in error
667
+ && error.code === 'ENOENT') {
668
+ continue;
669
+ }
670
+ throw error;
671
+ }
672
+ let data;
673
+ try {
674
+ data = parseFrontmatter(raw).data;
675
+ }
676
+ catch (_error) {
677
+ // frontmatter 부재·깨진 YAML — 이 leaf는 후보에서 빼고 다음으로.
678
+ // 한 문서 파손이 repair write 전체를 막으면 terminal CI 복구가 끊긴다.
679
+ continue;
680
+ }
681
+ if (!data || typeof data !== 'object')
682
+ continue;
683
+ const bouncer = data.bouncer;
684
+ if (!bouncer || typeof bouncer !== 'object')
685
+ continue;
686
+ const verify = bouncer.verify;
687
+ if (typeof verify === 'string' && verify.trim() !== '') {
688
+ return verify;
689
+ }
690
+ }
691
+ return undefined;
692
+ }
693
+ /**
694
+ * root `review.md`의 finding 목록에서 요청한 id들의 필드를 읽어 repair 문서용
695
+ * 줄 데이터로 만든다. repair는 리뷰 문서 상태와 무관하게 진행해야 하므로
696
+ * 파일 부재·권한 오류·YAML 오류·id 부재를 모두 `found: false`로 접는다 —
697
+ * 이 읽기는 보조 정보이고, 막으면 이미 확정된 repair 결정이 문서 없이 남는다.
698
+ *
699
+ * @param {string} integrationPath - integration worktree 경로
700
+ * @param {string} blueprint - blueprint 디렉터리(repo 상대)
701
+ * @param {string[]} ids - `--review-finding` 순서의 finding id
702
+ * @returns {ReviewFindingLine[]} ids와 같은 순서·길이의 줄 데이터
703
+ */
704
+ function readReviewFindingLines(integrationPath, blueprint, ids) {
705
+ let findings = [];
706
+ try {
707
+ const block = readBouncerBlock(path.join(integrationPath, blueprint, 'review.md'));
708
+ const list = block?.review?.findings;
709
+ if (Array.isArray(list))
710
+ findings = list;
711
+ }
712
+ catch {
713
+ // readBouncerBlock이 던지는 권한 오류·YAML 오류 등 모든 읽기 실패를 흡수한다.
714
+ // 안전한 이유: 결과가 "찾지 못함" 표시일 뿐이라 repair 결정·원장에는 영향이 없다.
715
+ findings = [];
716
+ }
717
+ const text = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
718
+ return ids.map((id) => {
719
+ const hit = findings.find((entry) => entry?.id === id);
720
+ if (!hit)
721
+ return { id, found: false };
722
+ const line = { id, found: true };
723
+ for (const key of ['severity', 'category', 'actionability', 'file', 'symbol', 'brief_clause', 'note']) {
724
+ const value = text(hit[key]);
725
+ if (value !== undefined)
726
+ line[key] = value;
727
+ }
728
+ return line;
729
+ });
730
+ }
731
+ /**
732
+ * finding 한 건을 repair 문서의 한 줄 목록 항목으로 쓴다. 값의 개행은 공백 하나로
733
+ * 접어 한 줄을 유지한다(목록 항목이 다음 줄로 번지면 절 구조를 깬다). file은 backtick으로
734
+ * 감싸지 않는다 — Touch 밖 절의 backtick 경로는 경로 선언으로 읽혀 Touch·Do not touch 경로 해석을 오인시킨다.
735
+ *
736
+ * @param {ReviewFindingLine} line - finding 줄 데이터
737
+ * @returns {string} `- F1 (major, correctness, must_fix) — file#symbol — clause — note: …` 형식의 한 줄
738
+ */
739
+ function formatReviewFindingLine(line) {
740
+ if (!line.found)
741
+ return `- ${line.id} — review.md에서 찾지 못함`;
742
+ const fold = (value) => value.replace(/\s*[\r\n]+\s*/g, ' ');
743
+ const tags = [line.severity, line.category, line.actionability].filter((v) => v !== undefined);
744
+ const parts = [`- ${line.id}${tags.length > 0 ? ` (${fold(tags.join(', '))})` : ''}`];
745
+ if (line.file !== undefined)
746
+ parts.push(fold(line.file) + (line.symbol !== undefined ? `#${fold(line.symbol)}` : ''));
747
+ else if (line.symbol !== undefined)
748
+ parts.push(`#${fold(line.symbol)}`);
749
+ if (line.brief_clause !== undefined)
750
+ parts.push(fold(line.brief_clause));
751
+ if (line.note !== undefined)
752
+ parts.push(`note: ${fold(line.note)}`);
753
+ return parts.join(' — ');
754
+ }
755
+ /**
756
+ * repair task 문서와 (있으면) terminal edge를 한 revision으로 쓴다. 두 write
757
+ * 중 하나가 실패하면 원래 terminal 문서를 복구하고 새 task를 지워, ledger만
758
+ * 다음 graph를 가리키는 반쪽 상태가 생기지 않게 한다. 종단 verification이
759
+ * 없는 blueprint는 terminal을 생략한다 — 없는 tasks.md를 만들어 의존 그래프를
760
+ * 꾸며내지 않기 위해서다. repair frontmatter에는 terminal(또는 선행
761
+ * integrated commit)의 `bouncer.verify`를 심어 config 폴백 없이 G13이
762
+ * 명령을 읽게 한다.
763
+ *
764
+ * `reviewFindings`가 있으면 리뷰 원인 본문(제목·Goal에 finding 목록)을, 없으면
765
+ * CI 원인 본문을 쓴다. CI 본문은 바이트 단위로 바뀌지 않아야 한다.
766
+ *
767
+ * @param {{ integrationPath: string, blueprint: string, repair: Task, terminal?: Task,
768
+ * reviewFindings?: ReviewFindingLine[], verify?: string }} opts
769
+ * integration 경로, blueprint, repair task, 선택적 종단 verification, 선택적 리뷰 finding 줄,
770
+ * 선택적 `--verify`(검사를 통과한 repair 전용 명령)
771
+ * @returns {() => void} ledger write 실패 시 호출할 문서 롤백
772
+ */
773
+ function writeRepairDocuments({ integrationPath, blueprint, repair, terminal, reviewFindings, verify }) {
774
+ // task 문서와 ledger revision은 한 write 단위다.
775
+ const terminalFile = terminal
776
+ ? path.join(integrationPath, blueprint, 'tasks', terminal.id, 'tasks.md')
777
+ : null;
778
+ const terminalBefore = terminalFile ? fs.readFileSync(terminalFile, 'utf8') : null;
779
+ let terminalData = null;
780
+ let terminalBody = '';
781
+ if (terminalFile && terminal) {
782
+ const terminalDoc = readDoc(terminalFile);
783
+ terminalData = terminalDoc.data;
784
+ terminalBody = terminalDoc.body;
785
+ const terminalBouncer = terminalData.bouncer;
786
+ terminalBouncer.depends_on = (terminal.depends_on || []).map((id) => `TASKS-${id}`);
787
+ terminalBouncer.status = 'ready';
788
+ }
789
+ const repairFile = path.join(integrationPath, blueprint, 'tasks', repair.id, 'tasks.md');
790
+ // 번호·slug 두 형식을 context-ids로 읽는다. 해석 불가는 예전처럼 빈 id를 심는다.
791
+ const repairIds = parseContextPath(blueprint);
792
+ const epicId = repairIds && repairIds.blueprintKey !== null ? repairIds.epicKey : '';
793
+ const bpId = repairIds && repairIds.blueprintKey !== null ? repairIds.blueprintKey : '';
794
+ // 호출자가 검사를 통과시킨 --verify가 있으면 그 값이 우선한다. 없을 때만(legacy)
795
+ // 기존 종단·선행 leaf 해석을 쓰므로 --verify 없는 legacy 결과는 바이트 단위로 같다.
796
+ const repairVerify = verify !== undefined
797
+ ? verify : resolveRepairVerify(integrationPath, blueprint, repair, terminalData);
798
+ const repairData = {
799
+ type: 'bouncer.tasks',
800
+ title: reviewFindings ? `Review repair wave ${repair.id}` : `CI repair wave ${repair.id}`,
801
+ description: reviewFindings ? 'Repairs final review findings.' : 'Repairs terminal CI failure.',
802
+ resource: path.relative(integrationPath, repairFile).replaceAll('\\', '/'), tags: ['bouncer', 'repair-wave'],
803
+ timestamp: new Date().toISOString(), bouncer: {
804
+ id: `TASKS-${repair.id}`, epic_id: epicId, blueprint_id: bpId,
805
+ status: 'ready', depends_on: (repair.depends_on || []).map((id) => `TASKS-${id}`),
806
+ parallel_safe: false, dependency_gate: 'integrated', affected_paths: repair.scope?.paths || [],
807
+ scope_revision: repair.scope?.revision,
808
+ // 후보가 있을 때만 심는다. 빈 문자열·날조 config는 S12/G13을 가린다.
809
+ ...(repairVerify !== undefined ? { verify: repairVerify } : {}),
810
+ },
811
+ };
812
+ const timestamp = repairData.timestamp;
813
+ const verificationFile = path.join(path.dirname(repairFile), 'verification.md');
814
+ const repairRel = path.relative(integrationPath, path.dirname(repairFile)).replaceAll('\\', '/');
815
+ const verificationData = {
816
+ type: 'bouncer.verification', title: `TASKS-${repair.id} verification`,
817
+ description: `Verification for TASKS-${repair.id}`, resource: `${repairRel}/verification.md`,
818
+ tags: ['bouncer', 'verification'], timestamp,
819
+ bouncer: { id: `VERIFY-${repair.id}`, epic_id: epicId, blueprint_id: bpId, status: 'pending' },
820
+ };
821
+ // 모드 worker에는 task 리뷰가 없다. 구형 계약만 review.md를 같이 심는다.
822
+ const reviewMode = isBlueprintReviewModeAt(integrationPath, blueprint);
823
+ const reviewFile = path.join(path.dirname(repairFile), 'review.md');
824
+ const reviewData = {
825
+ type: 'bouncer.review', title: `TASKS-${repair.id} review`, description: `Review for TASKS-${repair.id}`,
826
+ resource: `${repairRel}/review.md`, tags: ['bouncer', 'review'], timestamp,
827
+ bouncer: {
828
+ id: `REVIEW-${repair.id}`, epic_id: epicId, blueprint_id: bpId, status: 'pending',
829
+ review: { required: true },
830
+ },
831
+ };
832
+ // 원인별 문구만 갈아 끼운다. 절 구조는 같게 두어야 CI 본문이 바이트 단위로 보존되고,
833
+ // finding 목록은 새 `##` 제목 없이 Goal & intent 안에 둬 intent bundle이 그대로 싣는다.
834
+ const cause = reviewFindings
835
+ ? {
836
+ touch: '기록된 리뷰 finding을 수정한다.',
837
+ goal: `최종 리뷰 finding을 수정한다.\n\n${reviewFindings.map(formatReviewFindingLine).join('\n')}`,
838
+ provide: '기록된 리뷰 finding을 해소하는 최소 수정',
839
+ done: '기록된 리뷰 finding을 모두 수정한다.',
840
+ first: 'finding을 재현하고 관련 source와 regression test를 수정한다.',
841
+ }
842
+ : {
843
+ touch: '기록된 CI 실패를 복구한다.',
844
+ goal: '기록된 terminal CI 실패를 복구한다.',
845
+ provide: '실패 command와 관련 경로를 통과시키는 최소 수정',
846
+ done: '기록된 terminal CI command를 통과시킨다.',
847
+ first: '실패를 재현하고 관련 source와 regression test를 수정한다.',
848
+ };
849
+ const touch = (repair.scope?.paths || []).map((entry) => `- Modify \`${entry}\` — ${cause.touch}`).join('\n');
850
+ const repairBody = `# Tasks
851
+
852
+ ## Goal & intent
853
+
854
+ ${cause.goal}
855
+
856
+ ## Interface
857
+
858
+ - 제공: ${cause.provide}
859
+ - 거부: Blueprint 밖 scope와 새 기능
860
+
861
+ ## Touch
862
+
863
+ ${touch}
864
+
865
+ ## Do not touch
866
+
867
+ - \`.git/\`과 \`.bouncer/\` governance tree
868
+
869
+ ## Constraints
870
+
871
+ - 원장의 실패 증적과 repair 결정 범위만 따른다.
872
+
873
+ ## Checklist
874
+
875
+ - [ ] ${cause.first}
876
+ - [ ] ${cause.done}
877
+ `;
878
+ try {
879
+ fs.mkdirSync(path.dirname(repairFile), { recursive: true });
880
+ fs.writeFileSync(repairFile, renderDoc(repairData, repairBody));
881
+ const verificationBody = '# Verification\n\n## Command\n<command>\n\n## Evidence\n<result>\n';
882
+ fs.writeFileSync(verificationFile, renderDoc(verificationData, verificationBody));
883
+ if (!reviewMode) {
884
+ fs.writeFileSync(reviewFile, renderDoc(reviewData, '# Review\n\n## Findings\n- <finding>\n'));
885
+ }
886
+ if (terminalFile && terminalData && terminalBefore !== null) {
887
+ fs.writeFileSync(terminalFile, renderDoc(terminalData, terminalBody));
888
+ }
889
+ }
890
+ catch (error) {
891
+ if (terminalFile && terminalBefore !== null)
892
+ fs.writeFileSync(terminalFile, terminalBefore);
893
+ fs.rmSync(path.dirname(repairFile), { recursive: true, force: true });
894
+ throw error;
895
+ }
896
+ return () => {
897
+ if (terminalFile && terminalBefore !== null)
898
+ fs.writeFileSync(terminalFile, terminalBefore);
899
+ fs.rmSync(path.dirname(repairFile), { recursive: true, force: true });
900
+ };
901
+ }
902
+ function writePartialCloseBlueprint(integrationPath, blueprint) {
903
+ const file = path.join(integrationPath, blueprint, 'index.md');
904
+ const before = fs.readFileSync(file);
905
+ const doc = readDoc(file);
906
+ const data = doc.data;
907
+ data.bouncer.status = 'partial_closed';
908
+ fs.writeFileSync(file, renderDoc(data, doc.body));
909
+ return () => fs.writeFileSync(file, before);
910
+ }
911
+ /**
912
+ * partial-close용 main checkout handoff를 append하고 원래 bytes를 복구할 함수를 돌려준다.
913
+ * 심볼릭 링크와 directory는 main checkout 밖으로 쓰기가 새지 않도록 거절한다.
914
+ *
915
+ * @param {string} mainRoot - canonical main checkout
916
+ * @param {string} file - main checkout 기준 handoff 경로
917
+ * @param {string} record - 이번 실패의 재현 가능한 record
918
+ * @returns {() => void} 후속 ledger/index 쓰기가 실패했을 때 bytes 복구 함수
919
+ */
920
+ function appendMainHandoff(mainRoot, file, record) {
921
+ const target = path.join(mainRoot, file);
922
+ let before = null;
923
+ let mode = 0o666;
924
+ try {
925
+ const stat = fs.lstatSync(target);
926
+ if (!stat.isFile())
927
+ throw new Error('next-plan-must-be-regular-file');
928
+ mode = stat.mode & 0o777;
929
+ before = fs.readFileSync(target);
930
+ }
931
+ catch (error) {
932
+ if (error.code !== 'ENOENT')
933
+ throw error;
934
+ }
935
+ const separator = before && before.length > 0 && before[before.length - 1] !== 0x0a ? '\n' : '';
936
+ const appended = Buffer.concat([before || Buffer.alloc(0), Buffer.from(`${separator}---\n${record}`)]);
937
+ const temporary = `${target}.partial-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
938
+ try {
939
+ fs.writeFileSync(temporary, appended, { flag: 'wx', mode });
940
+ // 같은 디렉터리의 임시 파일을 rename해 append I/O 실패가 원본을 비우지 않게 한다.
941
+ fs.renameSync(temporary, target);
942
+ }
943
+ catch (error) {
944
+ try {
945
+ fs.rmSync(temporary, { force: true });
946
+ }
947
+ catch (cleanupError) {
948
+ if (cleanupError.code !== 'ENOENT')
949
+ throw cleanupError;
950
+ }
951
+ throw error;
952
+ }
953
+ return () => {
954
+ if (before === null)
955
+ fs.rmSync(target, { force: true });
956
+ else {
957
+ const rollback = `${target}.rollback-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
958
+ try {
959
+ fs.writeFileSync(rollback, before, { flag: 'wx', mode });
960
+ fs.renameSync(rollback, target);
961
+ }
962
+ catch (error) {
963
+ try {
964
+ fs.rmSync(rollback, { force: true });
965
+ }
966
+ catch (cleanupError) {
967
+ if (cleanupError.code !== 'ENOENT')
968
+ throw cleanupError;
969
+ }
970
+ throw error;
971
+ }
972
+ }
973
+ };
974
+ }
975
+ /**
976
+ * `partial-close`: awaiting_confirmation 원장을 사용자 확인 뒤 partial_closed로 닫고 handoff를 남긴다.
977
+ *
978
+ * @param {RepairCtx} ctx - 공통 준비가 끝난 handler 문맥
979
+ * @param {RepairSupport} support - coordinator-commands가 소유한 정상 경로 helper
980
+ * @returns {RepairResult} 명령 결과. 거절은 `ok: false`와 reason
981
+ */
982
+ function coordinatePartialClose(ctx, support) {
983
+ const { cwd, deps, exec, main, integration, ledger, commitWrite } = ctx;
984
+ const { command, repoRoot, blueprint, userConfirmed } = ctx.args;
985
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
986
+ if (ledger.status !== 'awaiting_confirmation') {
987
+ return { ok: false, reason: 'partial-close-awaiting-confirmation-required' };
988
+ }
989
+ if (!main.projectRoot || fs.realpathSync(repoRoot) !== fs.realpathSync(main.projectRoot)) {
990
+ return { ok: false, reason: 'main-checkout-root-required' };
991
+ }
992
+ const { epicId, blueprintId } = parsePathIds(blueprint);
993
+ const handoffPath = `${epicId}_${blueprintId}_NEXT_PLAN.md`;
994
+ const handoffAbs = path.join(main.projectRoot, handoffPath);
995
+ let handoffStat = null;
996
+ try {
997
+ handoffStat = fs.lstatSync(handoffAbs);
998
+ }
999
+ catch (error) {
1000
+ if (error.code !== 'ENOENT')
1001
+ throw error;
1002
+ }
1003
+ if (handoffStat && !handoffStat.isFile())
1004
+ return { ok: false, reason: 'next-plan-must-be-regular-file' };
1005
+ if (git(exec, main.projectRoot, ['ls-files', '--', handoffPath]) !== '') {
1006
+ return { ok: false, reason: 'next-plan-must-be-untracked' };
1007
+ }
1008
+ if (userConfirmed !== true)
1009
+ return { ok: false, reason: 'partial-close-user-confirmation-required' };
1010
+ const timestamp = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().replace('Z', '+09:00');
1011
+ const failure = ledger.terminalFailure;
1012
+ const handoff = { path: handoffPath, timestamp, source_digest: failure.source_digest || '' };
1013
+ const candidate = { ...ledger, status: 'partial_closed', userConfirmed: true, handoff };
1014
+ const validPartialClose = runtime.validateCoordinatorLedger(candidate);
1015
+ if (!validPartialClose.ok)
1016
+ return validPartialClose;
1017
+ Object.assign(ledger, candidate);
1018
+ const remaining = failure.paths.length ? failure.paths.join(', ') : 'none recorded';
1019
+ const record = `# Partial-close handoff\n\n- Timestamp: ${timestamp}\n`
1020
+ + `- Failed command: \`${failure.command}\`\n- Exit code: ${failure.exitCode}\n`
1021
+ + `- Source identity: ${handoff.source_digest}\n- Owner: ${failure.task}\n`
1022
+ + `- Recovery scope: ${remaining}\n- Preserved paths: ${integration.integrationPath}\n`;
1023
+ let rollbackHandoff;
1024
+ const rollbackBlueprint = writePartialCloseBlueprint(integration.integrationPath, blueprint);
1025
+ try {
1026
+ const appendHandoff = typeof deps.appendHandoff === 'function'
1027
+ ? deps.appendHandoff
1028
+ : appendMainHandoff;
1029
+ rollbackHandoff = appendHandoff(main.projectRoot, handoffPath, record);
1030
+ const lost = commitWrite();
1031
+ if (lost) {
1032
+ rollbackHandoff();
1033
+ rollbackBlueprint();
1034
+ return lost;
1035
+ }
1036
+ }
1037
+ catch (error) {
1038
+ rollbackHandoff?.();
1039
+ rollbackBlueprint();
1040
+ throw error;
1041
+ }
1042
+ return support.withCheckpoint({ ok: true, command, status: 'partial_closed', nextPlan: handoffAbs, handoff,
1043
+ message: 'NEXT_PLAN.md를 확인하고 후속 계획 진행 여부를 승인해 주세요.',
1044
+ preserved: [integration.integrationPath, ...ledger.tasks.map((entry) => entry.workerPath).filter(Boolean)] }, ledger, integration.ledgerFile);
1045
+ }
1046
+ /**
1047
+ * `repair`: terminal CI 실패·최종 리뷰 finding·supplement·resume·outside_scope 해소를 처리한다.
1048
+ * 모든 새 검사는 ledger 객체를 만지기 전에 끝내 거절이 바이트 불변이 되게 한다.
1049
+ *
1050
+ * @param {RepairCtx} ctx - 공통 준비가 끝난 handler 문맥
1051
+ * @param {RepairSupport} support - coordinator-commands가 소유한 정상 경로 helper
1052
+ * @returns {RepairResult} 명령 결과. 거절은 `ok: false`와 reason
1053
+ */
1054
+ function coordinateRepair(ctx, support) {
1055
+ const { cwd, exec, integration, ledger, commitWrite } = ctx;
1056
+ const { command, repoRoot, blueprint, task, decision, failureCommand, summary, reviewFindings, kind, resumeTask, disposition, newPaths, verify, leaseId, generation, paths: repairPaths, } = ctx.args;
1057
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
1058
+ // 최종 리뷰 discovery가 저장된 뒤의 리뷰 원인 repair·supplement는 wave 한도나 증거
1059
+ // 검사보다 먼저 거절한다. 원장·task 문서를 쓰기 전이라 거절은 아무 파일도 바꾸지 않는다.
1060
+ // CI 원인 repair(`--failure-command`)는 리뷰 원인이 아니므로 이 거절을 받지 않는다.
1061
+ if (kind === 'supplement' || Array.isArray(reviewFindings)) {
1062
+ const gate = finalReviewGate({
1063
+ integrationPath: integration.integrationPath, blueprint, ledger,
1064
+ supplement: kind === 'supplement', newPaths,
1065
+ });
1066
+ if (gate)
1067
+ return gate;
1068
+ }
1069
+ // supplement는 wave 한도 검사보다 앞에서 처리한다. 새 task·wave를 쓰지 않으므로
1070
+ // 두 wave를 쓴 뒤에도 접수되어야 한다.
1071
+ if (kind === 'supplement') {
1072
+ const intake = checkSupplementIntake({
1073
+ ledger, integrationPath: integration.integrationPath, blueprint,
1074
+ failureCommand, reviewFindings, summary, paths: repairPaths,
1075
+ });
1076
+ if (!intake.ok)
1077
+ return intake;
1078
+ const supplement = {
1079
+ kind: 'supplement', outcome: 'pending', paths: intake.paths, findings: intake.findings,
1080
+ };
1081
+ ledger.decisions.push(supplement);
1082
+ {
1083
+ const lost = commitWrite();
1084
+ if (lost)
1085
+ return lost;
1086
+ }
1087
+ return support.withCheckpoint({
1088
+ ok: true, command, kind: 'supplement', paths: intake.paths,
1089
+ }, ledger, integration.ledgerFile);
1090
+ }
1091
+ // terminal CI 실패의 두 새 해소 경로. 둘은 상호배타이고 리뷰 원인과도 섞을 수 없다.
1092
+ // 어느 쪽이든 새 task를 만들지 않는다 — 기존 task의 승인 범위 안 재작업이거나, 증거에 묶인
1093
+ // 범위 밖 분류 기록일 뿐이다.
1094
+ if (resumeTask !== undefined || disposition !== undefined) {
1095
+ if (resumeTask !== undefined && disposition !== undefined) {
1096
+ return { ok: false, reason: 'repair-resolution-ambiguous' };
1097
+ }
1098
+ if (Array.isArray(reviewFindings))
1099
+ return { ok: false, reason: 'repair-cause-ambiguous' };
1100
+ const resolved = resolveTerminalFailure({
1101
+ support, ledger, exec, integrationPath: integration.integrationPath, task,
1102
+ failureCommand, summary, decision, repairPaths, resumeTask, disposition, leaseId, generation,
1103
+ });
1104
+ if (!resolved.ok)
1105
+ return resolved;
1106
+ if (resolved.kind === 'disposition') {
1107
+ const { terminal, failure, record } = resolved;
1108
+ failure.disposition = record.disposition;
1109
+ ledger.decisions.push(record);
1110
+ // 한도 소진으로 멈춘 drive도 범위 밖 분류로는 이어 갈 수 있다. 분류는 wave를 쓰지 않는다.
1111
+ if (ledger.status === 'awaiting_confirmation')
1112
+ ledger.status = 'active';
1113
+ {
1114
+ const lost = commitWrite();
1115
+ if (lost)
1116
+ return lost;
1117
+ }
1118
+ return support.withCheckpoint({
1119
+ ok: true, command, kind: 'outside_scope', task: terminal, disposition: record.disposition,
1120
+ }, ledger, integration.ledgerFile);
1121
+ }
1122
+ const { terminal, target, repairDecision, failure, revision, resetDownstream, salvage } = resolved;
1123
+ const waves = ledger.repairWaves || [];
1124
+ const rollbackDocuments = writeResumeDocuments({
1125
+ integrationPath: integration.integrationPath, blueprint, target, downstream: resetDownstream, terminal,
1126
+ });
1127
+ // 통합된 하위 commit task는 lease·sha·status를 유지하고 target만 새 integration 결과로 다시 준비한다.
1128
+ revokeLease(ledger, target, {
1129
+ reason: String(decision).trim(), previousHead: typeof target.sha === 'string' ? target.sha : null,
1130
+ });
1131
+ target.resumed = true;
1132
+ target.dependency_gate = 'integrated';
1133
+ target.decisions = [...(target.decisions || []), repairDecision];
1134
+ // downstream verification은 기존 DAG edge를 보존한 채 target 재통합을 기다리게 한다.
1135
+ for (const item of resetDownstream)
1136
+ item.status = 'pending';
1137
+ terminal.status = 'pending';
1138
+ for (const entry of salvage) {
1139
+ ledger.decisions.push({
1140
+ task: entry.task, kind: 'worker-salvage', generation: entry.generation, head: entry.head,
1141
+ patch: entry.patch, ref: entry.ref, dirty_paths: entry.dirty_paths,
1142
+ });
1143
+ }
1144
+ ledger.decisions.push(repairDecision);
1145
+ ledger.repairWaves = [...waves, repairDecision];
1146
+ ledger.revision = revision;
1147
+ ledger.terminalFailure = failure;
1148
+ ledger.status = 'active';
1149
+ try {
1150
+ const lost = commitWrite();
1151
+ if (lost) {
1152
+ rollbackDocuments();
1153
+ return lost;
1154
+ }
1155
+ }
1156
+ catch (error) {
1157
+ rollbackDocuments();
1158
+ throw error;
1159
+ }
1160
+ return support.withCheckpoint({
1161
+ ok: true, command, kind: 'resume', wave: repairDecision.wave, resumedTask: target,
1162
+ terminalTask: terminal, decision: repairDecision,
1163
+ owner_generation: repairDecision.owner_generation,
1164
+ salvage_predecessor: repairDecision.salvage_predecessor,
1165
+ reset_downstream: repairDecision.reset_downstream,
1166
+ salvage: salvage.map(({ task: id, patch, ref }) => ({ task: id, patch, ref })),
1167
+ }, ledger, integration.ledgerFile);
1168
+ }
1169
+ const waves = ledger.repairWaves || [];
1170
+ if (waves.length >= REPAIR_WAVE_LIMIT) {
1171
+ return { ok: false, reason: 'repair-wave-limit', status: 'awaiting_confirmation' };
1172
+ }
1173
+ const hasReviewCause = Array.isArray(reviewFindings);
1174
+ const hasCiCause = typeof failureCommand === 'string';
1175
+ // 두 원인을 한 결정에 넣으면 failure.command가 review와 CI 중 무엇이었는지
1176
+ // 원장이 답할 수 없다. 빈 문자열도 플래그가 온 것으로 본다.
1177
+ if (hasReviewCause && hasCiCause) {
1178
+ return { ok: false, reason: 'repair-cause-ambiguous' };
1179
+ }
1180
+ if (hasReviewCause) {
1181
+ if (reviewFindings.length === 0
1182
+ || reviewFindings.some((entry) => typeof entry !== 'string' || entry === '')
1183
+ || typeof summary !== 'string' || summary === '') {
1184
+ return { ok: false, reason: 'failure-evidence-required' };
1185
+ }
1186
+ if (typeof decision !== 'string' || decision.trim() === '') {
1187
+ return { ok: false, reason: 'decision-reason-required' };
1188
+ }
1189
+ if (!sourceRepairPaths(repairPaths))
1190
+ return { ok: false, reason: 'repair-scope-out-of-bounds' };
1191
+ // 새 검사는 ledger 객체를 만지기 전에 끝내 거절이 바이트 불변이 되게 한다.
1192
+ const intake = checkRepairIntake({
1193
+ support, exec, integrationPath: integration.integrationPath, blueprint, ledger,
1194
+ paths: repairPaths, newPaths: newPaths || [], verify,
1195
+ });
1196
+ if (!intake.ok)
1197
+ return intake;
1198
+ const scopePaths = intake.paths;
1199
+ // 최종 리뷰 수정은 in-flight 작업과 같은 경로를 고칠 수 있다. 모두
1200
+ // integrated일 때만 열어, 미통합 worker HEAD를 리뷰 범위로 착각하지 않게 한다.
1201
+ if (ledger.tasks.some((entry) => (entry.status || 'pending') !== 'integrated')) {
1202
+ return { ok: false, reason: 'review-repair-requires-integrated' };
1203
+ }
1204
+ let terminal;
1205
+ if (task !== undefined && task !== '') {
1206
+ if (!/^\d{3}$/.test(task))
1207
+ return { ok: false, reason: 'task-required' };
1208
+ const found = ledger.tasks.find((x) => x.id === task);
1209
+ if (!found)
1210
+ return { ok: false, reason: 'task-outside-blueprint' };
1211
+ if (found.execution_kind !== 'verification') {
1212
+ return { ok: false, reason: 'terminal-failure-required' };
1213
+ }
1214
+ terminal = found;
1215
+ }
1216
+ const previousDag = dagSnapshot(ledger.tasks);
1217
+ const leaves = integratedLeaves(ledger.tasks, terminal ? terminal.id : '');
1218
+ const repairId = String(Math.max(...ledger.tasks.map((entry) => Number(entry.id)), 0) + 1).padStart(3, '0');
1219
+ const wave = waves.length + 1;
1220
+ const revision = nextLedgerRevision(ledger.revision);
1221
+ const failure = {
1222
+ task: terminal ? terminal.id : repairId,
1223
+ command: 'review',
1224
+ summary,
1225
+ paths: [...repairPaths],
1226
+ exitCode: 1,
1227
+ repairWave: waves.length,
1228
+ findings: [...reviewFindings],
1229
+ };
1230
+ const repair = {
1231
+ id: repairId, depends_on: leaves, dependency_gate: 'integrated', parallel_safe: false,
1232
+ execution_kind: 'commit', status: 'pending', dynamic: true,
1233
+ scope: { revision, paths: [...scopePaths] },
1234
+ };
1235
+ if (terminal) {
1236
+ terminal.depends_on = [repairId];
1237
+ terminal.status = 'pending';
1238
+ }
1239
+ ledger.tasks.push(repair);
1240
+ const repairDecision = {
1241
+ task: repairId, kind: 'repair', wave, reason: decision.trim(), failure,
1242
+ previousDag, nextDag: dagSnapshot(ledger.tasks), previousScope: [],
1243
+ nextScope: [...scopePaths],
1244
+ necessity: 'final review finding requires a Blueprint-scoped source repair',
1245
+ revision,
1246
+ };
1247
+ repair.decisions = [repairDecision];
1248
+ ledger.decisions.push(repairDecision);
1249
+ ledger.repairWaves = [...waves, repairDecision];
1250
+ ledger.revision = revision;
1251
+ ledger.terminalFailure = failure;
1252
+ ledger.status = 'active';
1253
+ const rollbackDocuments = writeRepairDocuments({
1254
+ integrationPath: integration.integrationPath, blueprint, repair, terminal,
1255
+ reviewFindings: readReviewFindingLines(integration.integrationPath, blueprint, reviewFindings),
1256
+ verify: intake.verify,
1257
+ });
1258
+ try {
1259
+ {
1260
+ const lost = commitWrite();
1261
+ if (lost)
1262
+ return lost;
1263
+ }
1264
+ }
1265
+ catch (error) {
1266
+ rollbackDocuments();
1267
+ throw error;
1268
+ }
1269
+ return support.withCheckpoint({
1270
+ ok: true, command, wave, repairTask: repair, terminalTask: terminal, decision: repairDecision,
1271
+ }, ledger, integration.ledgerFile);
1272
+ }
1273
+ if (!task || !/^\d{3}$/.test(task))
1274
+ return { ok: false, reason: 'task-required' };
1275
+ const item = ledger.tasks.find((x) => x.id === task);
1276
+ if (!item)
1277
+ return { ok: false, reason: 'task-outside-blueprint' };
1278
+ if (item.execution_kind !== 'verification' || item.status !== 'verifying') {
1279
+ return { ok: false, reason: 'terminal-failure-required' };
1280
+ }
1281
+ if (typeof failureCommand !== 'string' || failureCommand === ''
1282
+ || typeof summary !== 'string' || summary === '') {
1283
+ return { ok: false, reason: 'failure-evidence-required' };
1284
+ }
1285
+ if (typeof decision !== 'string' || decision.trim() === '') {
1286
+ return { ok: false, reason: 'decision-reason-required' };
1287
+ }
1288
+ if (!sourceRepairPaths(repairPaths))
1289
+ return { ok: false, reason: 'repair-scope-out-of-bounds' };
1290
+ // 새 검사는 ledger 객체를 만지기 전에 끝내 거절이 바이트 불변이 되게 한다.
1291
+ const intake = checkRepairIntake({
1292
+ support, exec, integrationPath: integration.integrationPath, blueprint, ledger,
1293
+ paths: repairPaths, newPaths: newPaths || [], verify,
1294
+ });
1295
+ if (!intake.ok)
1296
+ return intake;
1297
+ const scopePaths = intake.paths;
1298
+ const previousDag = dagSnapshot(ledger.tasks);
1299
+ const leaves = integratedLeaves(ledger.tasks, task);
1300
+ const repairId = String(Math.max(...ledger.tasks.map((entry) => Number(entry.id)), 0) + 1).padStart(3, '0');
1301
+ const wave = waves.length + 1;
1302
+ const revision = nextLedgerRevision(ledger.revision);
1303
+ const failure = {
1304
+ task, command: failureCommand, summary, paths: [...repairPaths], exitCode: 1, repairWave: waves.length,
1305
+ };
1306
+ const repair = {
1307
+ id: repairId, depends_on: leaves, dependency_gate: 'integrated', parallel_safe: false,
1308
+ execution_kind: 'commit', status: 'pending', dynamic: true,
1309
+ scope: { revision, paths: [...scopePaths] },
1310
+ };
1311
+ item.depends_on = [repairId];
1312
+ item.status = 'pending';
1313
+ ledger.tasks.push(repair);
1314
+ const repairDecision = {
1315
+ task: repairId, kind: 'repair', wave, reason: decision.trim(), failure,
1316
+ previousDag, nextDag: dagSnapshot(ledger.tasks), previousScope: [],
1317
+ nextScope: [...scopePaths],
1318
+ necessity: 'terminal CI failure requires a Blueprint-scoped source repair',
1319
+ revision,
1320
+ };
1321
+ repair.decisions = [repairDecision];
1322
+ ledger.decisions.push(repairDecision);
1323
+ ledger.repairWaves = [...waves, repairDecision];
1324
+ ledger.revision = revision;
1325
+ ledger.terminalFailure = failure;
1326
+ ledger.status = 'active';
1327
+ const rollbackDocuments = writeRepairDocuments({
1328
+ integrationPath: integration.integrationPath, blueprint, repair, terminal: item,
1329
+ verify: intake.verify,
1330
+ });
1331
+ try {
1332
+ {
1333
+ const lost = commitWrite();
1334
+ if (lost)
1335
+ return lost;
1336
+ }
1337
+ }
1338
+ catch (error) {
1339
+ rollbackDocuments();
1340
+ throw error;
1341
+ }
1342
+ return support.withCheckpoint({
1343
+ ok: true, command, wave, repairTask: repair, terminalTask: item, decision: repairDecision,
1344
+ }, ledger, integration.ledgerFile);
1345
+ }
1346
+ /**
1347
+ * `critical-recovery` 2단계: task 조회 뒤 결과 기록 또는 1회 recovery 시작을 원장에 쓴다.
1348
+ * lease 검사를 받지 않는다(현재 동작 그대로).
1349
+ *
1350
+ * @param {RepairTaskCtx} ctx - 공통 준비가 끝난 handler 문맥
1351
+ * @param {RepairSupport} support - coordinator-commands가 소유한 정상 경로 helper
1352
+ * @returns {RepairResult} 명령 결과. 거절은 `ok: false`와 reason
1353
+ */
1354
+ function coordinateCriticalRecovery(ctx, support) {
1355
+ const { task, integration, ledger, commitWrite, item } = ctx;
1356
+ const { command, findings, outcome, reason } = ctx.args;
1357
+ if (typeof reason !== 'string' || reason.trim() === '')
1358
+ return { ok: false, reason: 'reason-required' };
1359
+ if (outcome !== undefined) {
1360
+ if (outcome !== 'resolved' && outcome !== 'blocked') {
1361
+ return { ok: false, reason: 'critical-recovery-outcome-invalid' };
1362
+ }
1363
+ if (!item.criticalRecovery)
1364
+ return { ok: false, reason: 'critical-recovery-not-started' };
1365
+ if (item.criticalRecovery.outcome !== null)
1366
+ return { ok: false, reason: 'critical-recovery-closed' };
1367
+ item.criticalRecovery.outcome = outcome;
1368
+ const result = {
1369
+ task, kind: 'critical-recovery', used: 1, findings: [...item.criticalRecovery.findings],
1370
+ reason: reason.trim(), outcome,
1371
+ };
1372
+ item.decisions = [...(item.decisions || []), result];
1373
+ ledger.decisions.push(result);
1374
+ {
1375
+ const lost = commitWrite();
1376
+ if (lost)
1377
+ return lost;
1378
+ }
1379
+ return support.withCheckpoint({
1380
+ ok: true, command, task: item, decision: result, decisions: ledger.decisions,
1381
+ }, ledger, integration.ledgerFile);
1382
+ }
1383
+ if (item.status !== 'prepared')
1384
+ return { ok: false, reason: 'illegal-transition' };
1385
+ if (item.criticalRecovery)
1386
+ return { ok: false, reason: 'critical-recovery-exhausted' };
1387
+ if (!Array.isArray(findings) || findings.length === 0 || findings.some((entry) => entry.trim() === '')) {
1388
+ return { ok: false, reason: 'findings-required' };
1389
+ }
1390
+ item.criticalRecovery = { used: 1, findings: [...findings], reason: reason.trim(), outcome: null };
1391
+ const started = {
1392
+ task, kind: 'critical-recovery', used: 1, findings: [...findings], reason: reason.trim(), outcome: null,
1393
+ };
1394
+ item.decisions = [...(item.decisions || []), started];
1395
+ ledger.decisions.push(started);
1396
+ {
1397
+ const lost = commitWrite();
1398
+ if (lost)
1399
+ return lost;
1400
+ }
1401
+ return support.withCheckpoint({
1402
+ ok: true, command, task: item, decision: started, decisions: ledger.decisions,
1403
+ }, ledger, integration.ledgerFile);
1404
+ }
1405
+ /**
1406
+ * finalReviewGate가 낼 수 있는 거절 reason의 읽기 전용 목록. 문서 lint(check-doc-shape)가
1407
+ * rules/cli.md의 거절 표를 이 목록과 대조한다. 값만 노출하며 repair나 원장 쓰기를 일으키지 않는다.
1408
+ * 새 reason을 finalReviewGate에 추가하면 이 목록에도 넣어야 문서 표 검사가 따라온다.
1409
+ */
1410
+ const FINAL_REVIEW_REJECTION_REASONS = Object.freeze([
1411
+ schema.REVIEW_CONTRACT_INVALID_REASON, 'final-review-single-round', 'final-review-repair-scope',
1412
+ ]);
1413
+ module.exports = {
1414
+ FINAL_REVIEW_REJECTION_REASONS,
1415
+ supplementDone, coordinateRepair, coordinatePartialClose, coordinateCriticalRecovery,
1416
+ };