@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,1047 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { createHash } = require('node:crypto');
5
+ const runtime = require("./runtime-state");
6
+ const { coordinatorPathsFor, branchNamesFor, resolveWorktreeBranch } = runtime;
7
+ const seed = require("./seed-worktree");
8
+ const { seedCoordinatorWorker, seedIntegration } = seed;
9
+ const frontmatter = require("./frontmatter");
10
+ const { parseFrontmatter } = frontmatter;
11
+ const schema = require("./schema");
12
+ const { executionKindOf } = schema;
13
+ const tasksDocs = require("./tasks-docs");
14
+ const { listTasksDocs } = tasksDocs;
15
+ const configMod = require("./config");
16
+ const { readCoordinatorPolicy, readWavesPerSession } = configMod;
17
+ const scopeMod = require("./scope");
18
+ const { withLedgerLock } = scopeMod;
19
+ const executionDag = require("./execution-dag");
20
+ const { transition } = executionDag;
21
+ const crossDrive = require("./cross-drive");
22
+ const { otherDriveTasks, crossDriveConflicts } = crossDrive;
23
+ const leaseMod = require("./lease");
24
+ const { issueLease } = leaseMod;
25
+ const coordinatorGit = require("./coordinator-git");
26
+ const { git, initialWorktreeState, sourceStatus, registeredWorker, registeredIntegration } = coordinatorGit;
27
+ const coordinatorWorker = require("./coordinator-worker");
28
+ const { ensureIntegrationCwd, currentScopePaths, verificationPolicyAt, readBouncerBlock, } = coordinatorWorker;
29
+ const coordinatorLedger = require("./coordinator-ledger");
30
+ const { loadLedger, ledgerBytesHash, writeOwnedLedger, assertLeaseShape, readyWaveGated, maxParallelForRead, withCheckpoint, openLedgerFenced, recheckLedgerCheckpoint, ledgerModeOf, } = coordinatorLedger;
31
+ const coordinatorVerification = require("./coordinator-verification");
32
+ const { withInstallStdioToStderr } = coordinatorVerification;
33
+ const legacyComments = require("./legacy-comments");
34
+ const { scanLegacyScaffoldComments } = legacyComments;
35
+ const LEDGER_REL = coordinatorLedger.LEDGER_REL;
36
+ /**
37
+ * tasks 문서를 원장 task projection으로 바꾼다. 명시적인 병렬 허용만 유지해
38
+ * 오래된 문서가 실수로 같은 wave에서 실행되지 않게 한다.
39
+ *
40
+ * @param {string} repoRoot - task 문서를 읽을 checkout
41
+ * @param {string} blueprint - blueprint 상대 경로
42
+ * @returns {Task[]} 원장 초기 task 목록
43
+ */
44
+ function taskList(repoRoot, blueprint) {
45
+ const listing = listTasksDocs({ repoRoot, blueprintDir: blueprint });
46
+ return listing.entries.map((entry) => {
47
+ const id = String(entry.number).padStart(3, '0');
48
+ const file = path.join(repoRoot, entry.tasks.rel);
49
+ const source = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
50
+ const data = parseFrontmatter(source).data;
51
+ const bouncer = data && typeof data.bouncer === 'object' && data.bouncer ? data.bouncer : {};
52
+ const rawDependencies = Array.isArray(bouncer.depends_on) ? bouncer.depends_on : [];
53
+ const depends_on = rawDependencies.map((value) => String(value).replace(/^TASKS-/, '')).filter((value) => /^\d{3}$/.test(value));
54
+ // task metadata의 정본은 bouncer 아래다. absence를 병렬 허용으로 바꾸면
55
+ // 오래된 문서가 의도치 않게 같은 wave로 열리므로 명시 true만 허용한다.
56
+ const execution_kind = entry.executionKind || executionKindOf(bouncer) || 'commit';
57
+ // 문서에 키가 없어도 []를 심는다. 필드를 빼 두면 readyWave가 legacy 충돌로
58
+ // 단독 실행만 허용해, 경로 없는 신규 bootstrap이 전부 직렬화된다.
59
+ const affected_paths = Array.isArray(bouncer.affected_paths)
60
+ ? bouncer.affected_paths.filter((value) => typeof value === 'string')
61
+ : [];
62
+ const exclusive_resources = Array.isArray(bouncer.exclusive_resources)
63
+ ? bouncer.exclusive_resources.filter((value) => typeof value === 'string')
64
+ : [];
65
+ return { id, depends_on, execution_kind,
66
+ dependency_gate: typeof bouncer.dependency_gate === 'string' ? bouncer.dependency_gate : 'integrated',
67
+ parallel_safe: bouncer.parallel_safe === true,
68
+ affected_paths, exclusive_resources, status: 'pending' };
69
+ });
70
+ }
71
+ /**
72
+ * terminal node를 실행할 bundle이 integration checkout에 있는지 확인한다.
73
+ * 계획 문서와 config는 bootstrap이 이미 integration에 seed했고, 그 뒤로는
74
+ * integration 사본이 정본이다. 여기서 main을 다시 복사하면 `coordinate repair`가
75
+ * integration에서 바꾼 terminal `tasks.md`(depends_on)가 main 바이트로 되돌아간다.
76
+ *
77
+ * @param {string} integrationPath - 검증을 실행할 integration checkout
78
+ * @param {string} blueprint - blueprint 저장소 상대 경로
79
+ * @param {string} taskId - 세 자리 terminal task 번호
80
+ * @returns {{ ok: true } | { ok: false; reason: string }} 확인 결과
81
+ */
82
+ function checkVerificationNode(integrationPath, blueprint, taskId) {
83
+ if (!fs.existsSync(path.join(integrationPath, blueprint, 'tasks', taskId))) {
84
+ return { ok: false, reason: 'missing-verification-bundle' };
85
+ }
86
+ return { ok: true };
87
+ }
88
+ /**
89
+ * blueprint index의 `bouncer.scale`을 읽는다. light 원장 여부의 유일한 입력이다.
90
+ *
91
+ * @param {string} root - 문서를 읽을 checkout (대개 integration)
92
+ * @param {string} blueprint - blueprint 상대 경로
93
+ * @returns {string | undefined} 선언된 scale. 없거나 비문자면 undefined
94
+ */
95
+ function blueprintScaleAt(root, blueprint) {
96
+ const bouncer = readBouncerBlock(path.join(root, blueprint, 'index.md'));
97
+ return bouncer && typeof bouncer.scale === 'string' ? bouncer.scale : undefined;
98
+ }
99
+ /**
100
+ * light 원장 자격. legacy는 현행 의미 그대로 commit task 정확히 1개이고 depends_on이
101
+ * 비어 있어야 하며 verification·두 번째 task는 자격 없음이다. scoped_terminal은
102
+ * 독립 commit task 하나와 그 하나에만 의존하는 verification 하나, 정확히 두 node다.
103
+ * 구현 하나가 끝난 뒤 종단 verification이 한 번 전체 검증을 맡는 최소 계약이다.
104
+ *
105
+ * @param {Task[]} tasks - 원장에 올릴 task 목록
106
+ * @param {boolean} [scoped] - blueprint가 scoped_terminal이면 true. 생략은 legacy
107
+ * @returns {boolean} light 자격이 있으면 true
108
+ */
109
+ function isLightEligible(tasks, scoped = false) {
110
+ const independent = (item) => !Array.isArray(item.depends_on) || item.depends_on.length === 0;
111
+ if (!scoped) {
112
+ if (tasks.length !== 1)
113
+ return false;
114
+ const only = tasks[0];
115
+ if ((only.execution_kind || 'commit') === 'verification')
116
+ return false;
117
+ return independent(only);
118
+ }
119
+ if (tasks.length !== 2)
120
+ return false;
121
+ const commits = tasks.filter((item) => (item.execution_kind || 'commit') !== 'verification');
122
+ const terminals = tasks.filter((item) => item.execution_kind === 'verification');
123
+ if (commits.length !== 1 || terminals.length !== 1)
124
+ return false;
125
+ return independent(commits[0])
126
+ && Array.isArray(terminals[0].depends_on)
127
+ && terminals[0].depends_on.length === 1
128
+ && terminals[0].depends_on[0] === commits[0].id;
129
+ }
130
+ /**
131
+ * integration HEAD 대비 작업 트리(staged+unstaged+porcelain) fingerprint.
132
+ * promote-stop이 worktree를 건드리지 않으므로 읽기만 한다. untracked는 porcelain에
133
+ * 남고 content는 diff HEAD에 없어, 둘을 이어 해 빈 트리와 dirty를 구분한다.
134
+ *
135
+ * @param {Exec} exec - 주입 Git 실행기
136
+ * @param {string} cwd - integration worktree
137
+ * @returns {string} 64자리 소문자 hex
138
+ */
139
+ function worktreeDiffSha(exec, cwd) {
140
+ const porcelain = initialWorktreeState(exec, cwd);
141
+ const diff = String(exec('git', ['diff', 'HEAD'], {
142
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
143
+ }));
144
+ return createHash('sha256').update(porcelain, 'utf8').update('\0').update(diff, 'utf8').digest('hex');
145
+ }
146
+ /**
147
+ * 복원 전에 경로의 노드를 지운다. Node 24의 rmSync는 dangling symlink를 남기므로
148
+ * 링크는 unlinkSync로 노드만 지운다.
149
+ *
150
+ * @param {string} abs - 지울 절대 경로
151
+ */
152
+ function removeNode(abs) {
153
+ const st = fs.lstatSync(abs, { throwIfNoEntry: false });
154
+ if (!st)
155
+ return;
156
+ if (st.isSymbolicLink())
157
+ fs.unlinkSync(abs);
158
+ else
159
+ fs.rmSync(abs, { force: true, recursive: true });
160
+ }
161
+ /**
162
+ * promotion_stopped light 원장과 integration의 미커밋 작업을 원자적으로 full seed로 바꾼다.
163
+ * 중간 쓰기가 실패하면 원문 계획 파일, 원장, 작업 트리를 되돌려 재시도 가능한 상태를 만든다.
164
+ *
165
+ * @param {object} opts - 승격에 필요한 저장소와 injected dependency
166
+ * @param {string} opts.repoRoot - 승인된 계획을 읽을 main checkout
167
+ * @param {string} opts.blueprint - blueprint 상대 경로
168
+ * @param {ReturnType<typeof coordinatorPathsFor>} opts.paths - integration 및 원장 경로
169
+ * @param {string} opts.integrationBranch - full 원장에 기록할 integration branch
170
+ * @param {Exec} opts.exec - Git 실행기
171
+ * @param {(file: string, data: unknown) => void} opts.writeLedger - 원자적 원장 쓰기
172
+ * @returns {unknown} 성공 응답 또는 기존 promotion 거절. 실패 시 변경을 되돌리고 복구 정보를 반환한다
173
+ */
174
+ function promoteLightBootstrap({ repoRoot, blueprint, paths, integrationBranch, exec, writeLedger }) {
175
+ const integration = paths.integrationPath;
176
+ const ledgerFile = paths.ledgerFile;
177
+ const archive = path.join(integration, '.bouncer/runtime/coordinator.light-promoted.json');
178
+ const patchFile = path.join(integration, '.bouncer/runtime/light-promoted.patch');
179
+ return withLedgerLock(ledgerFile, (owns) => {
180
+ const ledger = loadLedger(ledgerFile);
181
+ if (!ledger || ledgerModeOf(ledger) !== 'light' || ledger.status !== 'promotion_stopped') {
182
+ return { ok: false, reason: 'promote-light-requires-stopped' };
183
+ }
184
+ if (blueprintScaleAt(repoRoot, blueprint) === 'light') {
185
+ return { ok: false, reason: 'promote-light-scale-light' };
186
+ }
187
+ if (fs.existsSync(archive) || fs.existsSync(patchFile)) {
188
+ return { ok: false, reason: 'promote-light-archive-exists' };
189
+ }
190
+ if (!ledger.promotion || worktreeDiffSha(exec, integration) !== ledger.promotion.diff_sha) {
191
+ return { ok: false, reason: 'promotion-diff-drift' };
192
+ }
193
+ if (!owns())
194
+ return { ok: false, reason: 'ledger-lock-lost' };
195
+ const status = String(exec('git', ['status', '--porcelain', '--untracked-files=all'], {
196
+ cwd: integration, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
197
+ }));
198
+ // 잠금 파일도 .gitignore 없는 테스트 저장소에서는 untracked로 보인다. 삭제하면
199
+ // 임계 구역의 소유권을 잃으므로 승격 patch와 reset 대상에서 제외한다.
200
+ const untracked = status.split('\n').filter((line) => line && line.startsWith('?? '))
201
+ .map((line) => line.slice(3)).filter((rel) => rel !== `${LEDGER_REL}.lock` && rel !== LEDGER_REL
202
+ // 보존된 worker 산출물은 승격 patch에 섞이지 않고 reset 대상도 아니다.
203
+ && !rel.startsWith('.bouncer/runtime/salvage/'));
204
+ const planPaths = [];
205
+ const collectPlanFiles = (dir, rel) => {
206
+ for (const name of fs.readdirSync(dir).sort()) {
207
+ const child = path.join(dir, name);
208
+ const childRel = `${rel}/${name}`;
209
+ if (fs.statSync(child).isDirectory())
210
+ collectPlanFiles(child, childRel);
211
+ else
212
+ planPaths.push(childRel);
213
+ }
214
+ };
215
+ collectPlanFiles(path.join(repoRoot, blueprint), blueprint);
216
+ for (const rel of [path.posix.dirname(blueprint) + '/index.md', '.bouncer/context/index.md']) {
217
+ if (fs.existsSync(path.join(repoRoot, rel)))
218
+ planPaths.push(rel);
219
+ }
220
+ const planSet = new Set(planPaths);
221
+ const tracked = String(exec('git', ['diff', '--name-only', '-z', 'HEAD'], {
222
+ cwd: integration, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
223
+ })).split('\0').filter(Boolean);
224
+ // 1. 계획 문서는 promotion patch와 독립적으로 보존한다. 승인본으로 seed되므로
225
+ // patch에 섞으면 재적용이 충돌하고, light 원문은 별도 byte snapshot으로 복구한다.
226
+ let patch = String(exec('git', ['diff', '--binary', 'HEAD', '--', '.',
227
+ ...[...planSet].map((rel) => `:(exclude)${rel}`)], {
228
+ cwd: integration, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
229
+ }));
230
+ for (const rel of untracked) {
231
+ if (planSet.has(rel))
232
+ continue;
233
+ try {
234
+ patch += String(exec('git', ['diff', '--no-index', '--binary', '--', '/dev/null', rel], {
235
+ cwd: integration, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
236
+ }));
237
+ }
238
+ catch (error) {
239
+ const failure = error;
240
+ if (failure.status !== 1)
241
+ throw error;
242
+ patch += String(failure.stdout || '');
243
+ }
244
+ }
245
+ const patchPath = patch.length ? patchFile : null;
246
+ // 2. snapshot every dirty path and seed target, plus the raw index. Byte restoration
247
+ // preserves staged/unstaged separation that `git apply` cannot reconstruct safely.
248
+ const originalFiles = new Map();
249
+ for (const rel of new Set([...tracked, ...untracked, ...planPaths, '.bouncer/config.json'])) {
250
+ const abs = path.join(integration, rel);
251
+ if (!fs.existsSync(abs) && !fs.lstatSync(abs, { throwIfNoEntry: false })) {
252
+ originalFiles.set(rel, null);
253
+ }
254
+ else if (fs.lstatSync(abs).isSymbolicLink()) {
255
+ originalFiles.set(rel, { symlink: fs.readlinkSync(abs) });
256
+ }
257
+ else {
258
+ originalFiles.set(rel, { bytes: fs.readFileSync(abs), mode: fs.statSync(abs).mode });
259
+ }
260
+ }
261
+ const gitDir = String(exec('git', ['rev-parse', '--absolute-git-dir'], {
262
+ cwd: integration, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
263
+ })).trim();
264
+ const indexFile = path.join(gitDir, 'index');
265
+ const originalIndex = fs.readFileSync(indexFile);
266
+ let archived = false;
267
+ let patchWritten = false;
268
+ const written = [];
269
+ try {
270
+ if (patchPath) {
271
+ fs.mkdirSync(path.dirname(patchFile), { recursive: true });
272
+ fs.writeFileSync(patchFile, patch);
273
+ patchWritten = true;
274
+ written.push(patchFile);
275
+ }
276
+ fs.renameSync(ledgerFile, archive);
277
+ archived = true;
278
+ written.push(archive);
279
+ // 3. dirty source만 HEAD로 되돌린 뒤 계획 문서는 승인된 main 원본으로 seed한다.
280
+ if (tracked.length) {
281
+ git(exec, integration, ['restore', '--source=HEAD', '--staged', '--worktree', '--', ...tracked]);
282
+ }
283
+ for (const rel of untracked)
284
+ fs.rmSync(path.join(integration, rel), { force: true, recursive: true });
285
+ const seeded = seedIntegration({
286
+ repoRoot, blueprintDir: blueprint, integrationPath: integration, allowOverwrite: true,
287
+ });
288
+ if (!seeded.ok)
289
+ throw Object.assign(new Error(seeded.reason), { code: seeded.reason });
290
+ written.push(...(seeded.seeded || []).map((rel) => path.join(integration, rel)));
291
+ if (patchPath) {
292
+ git(exec, integration, ['apply', '--check', patchFile]);
293
+ // 승인된 patch의 내용이 대상 파일에 정확히 적용 가능한지 먼저 확인한다.
294
+ }
295
+ const fullLedger = {
296
+ version: 1, blueprint, base: ledger.base,
297
+ integrationHead: git(exec, integration, ['rev-parse', 'HEAD']),
298
+ integrationBranch,
299
+ tasks: taskList(integration, blueprint), decisions: [], status: 'active', repairWaves: [],
300
+ seedManifest: seeded.manifest,
301
+ promoted_from: {
302
+ archive, patch: patchPath, diff_sha: ledger.promotion.diff_sha,
303
+ reason: ledger.promotion.reason, task: ledger.promotion.task,
304
+ },
305
+ };
306
+ if (!owns())
307
+ throw Object.assign(new Error('ledger-lock-lost'), { code: 'ledger-lock-lost' });
308
+ written.push(ledgerFile);
309
+ writeLedger(ledgerFile, fullLedger);
310
+ return withCheckpoint({
311
+ ok: true, command: 'bootstrap', integrationPath: integration,
312
+ mode: 'full', promoted_from: fullLedger.promoted_from,
313
+ ready: readyWaveGated(fullLedger.tasks, { maxParallel: maxParallelForRead(integration) }),
314
+ tasks: fullLedger.tasks, decisions: fullLedger.decisions, integrationBranch,
315
+ }, fullLedger, ledgerFile);
316
+ }
317
+ catch (_error) {
318
+ let restored = true;
319
+ try {
320
+ for (const [rel, bytes] of originalFiles) {
321
+ const abs = path.join(integration, rel);
322
+ if (bytes === null)
323
+ removeNode(abs);
324
+ else {
325
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
326
+ removeNode(abs);
327
+ if ('symlink' in bytes)
328
+ fs.symlinkSync(bytes.symlink, abs);
329
+ else {
330
+ fs.writeFileSync(abs, bytes.bytes);
331
+ fs.chmodSync(abs, bytes.mode);
332
+ }
333
+ }
334
+ }
335
+ fs.writeFileSync(indexFile, originalIndex);
336
+ if (worktreeDiffSha(exec, integration) !== ledger.promotion.diff_sha) {
337
+ throw new Error('promotion rollback fingerprint mismatch', { cause: _error });
338
+ }
339
+ // 복구가 fingerprint로 인증된 뒤에만 수동 복구용 사본을 치운다.
340
+ if (archived) {
341
+ fs.rmSync(ledgerFile, { force: true });
342
+ fs.renameSync(archive, ledgerFile);
343
+ archived = false;
344
+ }
345
+ if (patchWritten) {
346
+ fs.rmSync(patchFile, { force: true });
347
+ patchWritten = false;
348
+ }
349
+ }
350
+ catch (_restoreError) {
351
+ restored = false;
352
+ }
353
+ // 승격은 여러 파일과 원장을 함께 바꾸므로 예외를 반환하기 전에 역순 복구를 시도한다.
354
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
355
+ const manual = archived && patchWritten
356
+ ? `cp ${quote(archive)} ${quote(ledgerFile)} && git -C ${quote(integration)} apply --check `
357
+ + `${quote(patchFile)} && git -C ${quote(integration)} apply ${quote(patchFile)}`
358
+ : `cp ${quote(archive)} ${quote(ledgerFile)}`;
359
+ return { ok: false, reason: 'promote-light-incomplete', restored,
360
+ written, next: restored ? 'Promotion rolled back; retry `bouncer coordinate bootstrap --promote-light`.'
361
+ : `Manual recovery: ${manual}. Then inspect written paths: ${written.join(', ')}` };
362
+ }
363
+ });
364
+ }
365
+ /**
366
+ * light 원장에서 promote-stop 스냅샷에 쓸 열린 commit task를 고른다.
367
+ * integrated가 아닌 첫 commit task를 쓰고, 없으면 단일 commit task로 폴백한다.
368
+ *
369
+ * @param {Task[]} tasks - 원장 tasks
370
+ * @returns {Task | null} 대상 task. commit task가 없으면 null
371
+ */
372
+ /**
373
+ * revoke된 worker를 지우기 전에 사용자 작업을 보존한다. 보존 대상은 두 가지다.
374
+ * 1) 미커밋 변경(추적 변경 + 미추적 파일): integration
375
+ * `.bouncer/runtime/salvage/<task>-g<gen>-<short>.patch`에 `git apply --binary`로
376
+ * 재현되는 diff로 쓴다. seed가 복사한 계획 문서처럼 integration에 같은 바이트로
377
+ * 이미 있는 미추적 파일과 `.bouncer/runtime/` 산출물은 사용자 작업이 아니므로 뺀다.
378
+ * 2) 미통합 커밋(`integrationHead..branch`가 1개 이상): `refs/bouncer/salvage/<task>/g<gen>-<short>`.
379
+ * 이 함수는 worktree·branch·원장을 바꾸지 않는다. patch를 먼저 쓰고 ref를 만들며, ref
380
+ * 단계가 실패해도 이미 쓴 patch는 지우지 않는다(`error.salvage`로 알린다).
381
+ * `<gen>`은 revoke된 lease generation(legacy는 0), `<short>`는 worker HEAD 앞 8자다.
382
+ * 같은 이름의 ref가 같은 커밋이면 no-op, 다른 커밋이면 덮어쓰지 않고 throw한다.
383
+ *
384
+ * @param {Exec} exec - git 실행기
385
+ * @param {string} integrationPath - integration checkout (patch·ref의 저장소)
386
+ * @param {Task} item - revoke 후 재준비 대상
387
+ * @param {string} integrationHead - 미통합 커밋 판정 기준
388
+ * @returns {SalvageResult} 보존한 patch·ref와 dirty 경로, 읽은 worker HEAD와 lease generation.
389
+ * 보존할 것이 없으면 dirty_paths는 빈 배열, 미등록 worker는 head가 빈 문자열
390
+ * @throws patch 쓰기나 ref 생성 실패. 이미 쓴 patch 경로는 `error.patch`에 실린다
391
+ */
392
+ function salvageRevokedWorker(exec, integrationPath, item, integrationHead) {
393
+ const generation = item.lease && Number.isInteger(item.lease.generation) ? item.lease.generation : 0;
394
+ const result = { dirty_paths: [], head: '', generation };
395
+ const workerPath = item.workerPath;
396
+ if (!workerPath || !registeredWorker(exec, integrationPath, workerPath))
397
+ return result;
398
+ const head = git(exec, workerPath, ['rev-parse', 'HEAD']);
399
+ result.head = head;
400
+ const short = head.slice(0, 8);
401
+ // git은 `status -z`의 untracked를 디렉터리 단위로 접지 않도록 --untracked-files=all로 읽는다.
402
+ // git()은 출력을 trim해 첫 항목의 앞 공백(` M`)을 깎으므로 직접 읽는다.
403
+ const entries = String(exec('git', ['status', '--porcelain', '-z', '--untracked-files=all'], {
404
+ cwd: workerPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
405
+ })).split('\0').filter(Boolean);
406
+ const trackedPaths = [];
407
+ const untrackedPaths = [];
408
+ for (let i = 0; i < entries.length; i += 1) {
409
+ const entry = entries[i];
410
+ const rel = entry.slice(3);
411
+ // rename/copy 항목은 뒤에 원본 경로가 맨 경로로 한 칸 더 붙는다. 건너뛴다(diff HEAD가 둘 다 담는다).
412
+ if (entry[0] === 'R' || entry[0] === 'C') {
413
+ trackedPaths.push(rel, entries[i + 1]);
414
+ i += 1;
415
+ continue;
416
+ }
417
+ if (rel.startsWith('.bouncer/runtime/'))
418
+ continue;
419
+ if (entry.startsWith('?? ')) {
420
+ const mine = path.join(workerPath, rel);
421
+ const theirs = path.join(integrationPath, rel);
422
+ // seed가 복사한 파일은 integration과 바이트가 같다. 읽기 실패(없음)는 "다르다"로 본다.
423
+ let same = false;
424
+ try {
425
+ same = fs.readFileSync(mine).equals(fs.readFileSync(theirs));
426
+ }
427
+ catch (error) {
428
+ // ENOENT/EISDIR만 흡수한다: integration에 없거나 읽을 수 없으면 사용자 작업으로 보존한다.
429
+ const code = error.code;
430
+ if (code !== 'ENOENT' && code !== 'EISDIR')
431
+ throw error;
432
+ }
433
+ if (!same)
434
+ untrackedPaths.push(rel);
435
+ }
436
+ else if (!entry.startsWith('!! ')) {
437
+ trackedPaths.push(rel);
438
+ }
439
+ }
440
+ result.dirty_paths = [...trackedPaths, ...untrackedPaths];
441
+ const toSalvage = () => {
442
+ const raw = git(exec, workerPath, ['rev-list', `${integrationHead}..${head}`]);
443
+ return raw ? raw.split('\n') : [];
444
+ };
445
+ if (result.dirty_paths.length > 0) {
446
+ let patch = '';
447
+ if (trackedPaths.length > 0) {
448
+ patch += String(exec('git', ['diff', '--binary', 'HEAD', '--', ...trackedPaths], {
449
+ cwd: workerPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
450
+ }));
451
+ }
452
+ for (const rel of untrackedPaths) {
453
+ try {
454
+ patch += String(exec('git', ['diff', '--no-index', '--binary', '--', '/dev/null', rel], {
455
+ cwd: workerPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
456
+ }));
457
+ }
458
+ catch (error) {
459
+ // `--no-index` diff는 차이가 있으면 exit 1이다. 그 경우 stdout이 정상 patch다.
460
+ const failure = error;
461
+ if (failure.status !== 1)
462
+ throw error;
463
+ patch += String(failure.stdout || '');
464
+ }
465
+ }
466
+ const patchFile = path.join(integrationPath, '.bouncer/runtime/salvage', `${item.id}-g${generation}-${short}.patch`);
467
+ fs.mkdirSync(path.dirname(patchFile), { recursive: true });
468
+ fs.writeFileSync(patchFile, patch);
469
+ result.patch = patchFile;
470
+ }
471
+ if (toSalvage().length > 0) {
472
+ const ref = `refs/bouncer/salvage/${item.id}/g${generation}-${short}`;
473
+ try {
474
+ let existing = '';
475
+ try {
476
+ existing = git(exec, integrationPath, ['rev-parse', '--verify', '--quiet', ref]);
477
+ }
478
+ catch (error) {
479
+ // --verify --quiet는 ref가 없을 때만 exit 1이다. 그 외 실패는 올린다.
480
+ if (error.status !== 1)
481
+ throw error;
482
+ }
483
+ if (existing === '') {
484
+ // 빈 old-value로 "없을 때만 생성"을 강제해 동시 생성을 덮어쓰지 않는다.
485
+ git(exec, integrationPath, ['update-ref', ref, head, '']);
486
+ }
487
+ else if (existing !== head) {
488
+ throw new Error(`salvage ref ${ref} already points at ${existing}`);
489
+ }
490
+ result.ref = ref;
491
+ }
492
+ catch (error) {
493
+ error.salvage = result;
494
+ throw error;
495
+ }
496
+ }
497
+ return result;
498
+ }
499
+ /**
500
+ * revoke된 배정의 worker worktree·branch를 지운다. 다음 prepare가 integration
501
+ * HEAD에서 새로 만들 수 있게 등록을 비운다.
502
+ *
503
+ * @param {Exec} exec - git 실행기
504
+ * @param {string} integrationPath - integration checkout
505
+ * @param {Task} item - revoke 후 재준비 대상
506
+ */
507
+ function removeRevokedWorker(exec, integrationPath, item) {
508
+ if (item.workerPath && registeredWorker(exec, integrationPath, item.workerPath)) {
509
+ try {
510
+ git(exec, integrationPath, ['worktree', 'remove', '--force', item.workerPath]);
511
+ }
512
+ catch (_error) {
513
+ // worktree가 이미 없으면 아래 branch 삭제만으로 재준비가 가능하다.
514
+ }
515
+ }
516
+ if (typeof item.branch === 'string' && item.branch !== '') {
517
+ try {
518
+ git(exec, integrationPath, ['branch', '-D', item.branch]);
519
+ }
520
+ catch (_error) {
521
+ // branch가 없으면 새 -b 생성이 이어지면 된다.
522
+ }
523
+ }
524
+ delete item.workerPath;
525
+ delete item.branch;
526
+ }
527
+ /**
528
+ * prepare 재배정 전에 이전 worker를 지울지 본다.
529
+ * lease.status === 'revoked'인 경우뿐 아니라, lease 없이 prepared/recorded에서
530
+ * revoke된 legacy 배정도 workerPath를 남기므로 같은 재작성 규칙에 넣는다.
531
+ *
532
+ * @param {Task} item - ready wave의 commit task
533
+ * @returns {boolean} removeRevokedWorker를 호출해야 하면 true
534
+ */
535
+ function shouldRemoveRevokedWorker(item) {
536
+ if (item.lease?.status === 'revoked')
537
+ return true;
538
+ // legacy: lease 필드가 없으면 status만 pending으로 돌아가므로, 마지막 revoke
539
+ // decision의 previous_status로 prepared/recorded 배정 잔재를 판별한다.
540
+ if (!item.workerPath && !(typeof item.branch === 'string' && item.branch !== '')) {
541
+ return false;
542
+ }
543
+ if (item.lease)
544
+ return false;
545
+ const decisions = Array.isArray(item.decisions) ? item.decisions : [];
546
+ for (let i = decisions.length - 1; i >= 0; i -= 1) {
547
+ const entry = decisions[i];
548
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
549
+ continue;
550
+ const decision = entry;
551
+ if (decision.kind !== 'revoke')
552
+ continue;
553
+ // integrated는 `repair --resume-task`가 재배정한 task다. 이전 통합 시점의 worker도 같은 규칙으로 지운다.
554
+ return decision.previous_status === 'prepared' || decision.previous_status === 'recorded'
555
+ || decision.previous_status === 'integrated';
556
+ }
557
+ return false;
558
+ }
559
+ /**
560
+ * repair resume 단계에서 이미 보존한 동일 generation 산출물이 있는지 확인한다.
561
+ *
562
+ * @param {Ledger} ledger - salvage provenance가 기록된 원장
563
+ * @param {Task} item - prepare가 다시 배정할 task
564
+ * @returns {boolean} 같은 task·generation의 salvage 결정이 있으면 true
565
+ */
566
+ function hasResumeSalvage(ledger, item) {
567
+ const generation = item.lease?.generation ?? 0;
568
+ return ledger.decisions.some((entry) => {
569
+ if (!entry || typeof entry !== 'object')
570
+ return false;
571
+ const decision = entry;
572
+ return decision.kind === 'worker-salvage' && decision.task === item.id
573
+ && decision.generation === generation;
574
+ });
575
+ }
576
+ /**
577
+ * 잠금 소유를 확인한 뒤 원장을 쓴다. owns가 거짓이면 쓰지 않고 ledger-lock-lost.
578
+ *
579
+ * @param {() => boolean} owns - withLedgerLock이 넘긴 소유 확인
580
+ * @param {(file: string, data: unknown) => void} writeLedger - 원자적 쓰기
581
+ * @param {string} file - 원장 경로
582
+ * @param {Ledger} ledger - 기록할 원장
583
+ * @returns {{ok: false, reason: 'ledger-lock-lost'} | null} 실패 시 거절, 성공 시 null
584
+ */
585
+ /**
586
+ * 이번 호출이 `git worktree add`에 성공한 worker만, 지금 원장이 `workerPath`로
587
+ * 가리키지 않을 때 지운다. reuse는 created에 넣지 않아 겹친 prepare가 남긴
588
+ * 배정을 지우지 않는다. Git 등록을 먼저 풀고 디렉터리를 지운 뒤 branch를 지운다 —
589
+ * 빈 디렉터리가 남으면 다음 prepare의 existsSync 검사가 unassigned로 막히고,
590
+ * branch를 먼저 지우면 등록 worktree가 붙은 채로 `-D`가 실패한다.
591
+ * 이미 Git에서 빠진 worktree의 `remove`는 실패하므로 등록된 경우만 부른다.
592
+ *
593
+ * @param {Exec} exec - git 실행기
594
+ * @param {string} integrationPath - integration checkout
595
+ * @param {Ledger | null} ledger - 정리 시점 원장. 없으면 어떤 경로도 참조되지 않은 것으로 본다
596
+ * @param {CreatedWorker[]} created - 이번 호출이 add에 성공한 worker·branch
597
+ * @returns {void}
598
+ */
599
+ function removeUnreferencedCreatedWorkers(exec, integrationPath, ledger, created) {
600
+ const referenced = new Set((ledger?.tasks || [])
601
+ .map((item) => item.workerPath)
602
+ .filter((workerPath) => typeof workerPath === 'string' && workerPath !== ''));
603
+ for (const { worker, branch } of created) {
604
+ if (referenced.has(worker))
605
+ continue;
606
+ if (registeredWorker(exec, integrationPath, worker)) {
607
+ git(exec, integrationPath, ['worktree', 'remove', '--force', worker]);
608
+ }
609
+ fs.rmSync(worker, { recursive: true, force: true });
610
+ git(exec, integrationPath, ['branch', '-D', branch]);
611
+ }
612
+ }
613
+ /**
614
+ * 잠금을 새로 잡아 이번 호출 worker를 정리한다. 정리 잠금을 못 잡거나 도중에
615
+ * 빼앗기면 Git을 건드리지 않는다 — 남은 등록은 다음 prepare가 reuse로 받는다.
616
+ *
617
+ * @param {string} ledgerFile - 원장 경로. 잠금 파일은 `${ledgerFile}.lock`
618
+ * @param {Exec} exec - git 실행기
619
+ * @param {string} integrationPath - integration checkout
620
+ * @param {CreatedWorker[]} created - 이번 호출이 add에 성공한 worker
621
+ * @returns {void}
622
+ */
623
+ function cleanupCreatedWorkersUnderNewLock(ledgerFile, exec, integrationPath, created) {
624
+ if (created.length === 0)
625
+ return;
626
+ withLedgerLock(ledgerFile, (owns) => {
627
+ // 잠금 회수 실패·소유 상실은 원래 실패 reason을 바꾸지 않기 위해 여기서 흡수한다.
628
+ if (!owns())
629
+ return;
630
+ removeUnreferencedCreatedWorkers(exec, integrationPath, loadLedger(ledgerFile), created);
631
+ });
632
+ }
633
+ /**
634
+ * prepare를 원장 잠금 ①판정 / ②worktree·seed / ③재획득·쓰기 로 나눈다.
635
+ * seed의 npm ci가 LOCK_STALE_MS를 넘기면 다른 프로세스가 잠금을 회수하므로,
636
+ * 장기 I/O는 잠금 밖에서 돌리고 hash로 원장이 그대로인지 확인한 뒤에만 쓴다.
637
+ *
638
+ * @param {object} opts - coordinate prepare 인자
639
+ * @param {string} opts.repoRoot - 저장소 루트
640
+ * @param {string} opts.blueprint - blueprint 상대 경로
641
+ * @param {string} opts.cwd - integration cwd
642
+ * @param {string} [opts.ledgerPath] - fence 원장 상대 경로
643
+ * @param {string} [opts.ledgerHash] - fence sha256
644
+ * @param {Exec} opts.exec - git·npm 실행기
645
+ * @param {(file: string, data: unknown) => void} opts.writeLedger - 원자적 원장 쓰기
646
+ * @param {() => string} opts.makeLeaseId - lease id 생성
647
+ * @param {object} opts.integration - coordinatorPathsFor 결과
648
+ * @returns {unknown} 성공 시 기존 prepare 출력, 실패 시 ok:false와 reason
649
+ */
650
+ function prepareCoordinator({ repoRoot, blueprint, cwd, ledgerPath, ledgerHash, exec, writeLedger, makeLeaseId, integration, }) {
651
+ // 1. 잠금 안: 판정·revoke 정리만 하고 원장은 쓰지 않는다. 대조 기준은 이 bytes hash다.
652
+ const phase1 = withLedgerLock(integration.ledgerFile, () => {
653
+ const opened = openLedgerFenced({ ledgerFile: integration.ledgerFile, ledgerPath, ledgerHash });
654
+ if (!opened.ok)
655
+ return opened;
656
+ const { ledger, bytes: ledgerBytes } = opened;
657
+ ensureIntegrationCwd(repoRoot, blueprint, cwd);
658
+ // 승격 정지는 재개 명령이 없다. prepare가 worker를 다시 열면 정지 스냅샷이
659
+ // 의미가 없어지므로 fence 통과 직후 막는다.
660
+ if (ledger.status === 'promotion_stopped') {
661
+ return { ok: false, reason: 'promotion-stopped' };
662
+ }
663
+ let names;
664
+ try {
665
+ // verification-only wave는 worker branch를 만들지 않아 아래 branch 판정에서
666
+ // commit_type을 한 번도 읽지 않는다. task 종류를 보기 전에 blueprint 전체의
667
+ // commit_type을 검증해야, 잘못된 값이 verification node의 ready 전이를 원장에
668
+ // 남긴 뒤 다음 commit wave에서야 드러나는 일을 막는다. 등록 checkout은 실제
669
+ // branch를 재사용해 legacy 원장에만 provenance 필드를 보충하고 rename하지 않는다.
670
+ // commit_type은 integration 사본에서 읽는다. drive 동안 main은 base SHA 출처일 뿐이다.
671
+ names = branchNamesFor({ repoRoot: integration.integrationPath, blueprint });
672
+ const resolved = resolveWorktreeBranch({ repoRoot: integration.integrationPath,
673
+ worktreePath: integration.integrationPath, branch: names.integration, execFileSync: exec });
674
+ if (resolved.action !== 'reuse') {
675
+ return { ok: false, reason: 'unassigned-integration-worktree',
676
+ integrationPath: integration.integrationPath };
677
+ }
678
+ if (!ledger.integrationBranch)
679
+ ledger.integrationBranch = resolved.branch;
680
+ }
681
+ catch (error) {
682
+ const reason = error.code;
683
+ if (reason)
684
+ return { ok: false, reason };
685
+ throw error;
686
+ }
687
+ // prepare만 invalid config를 거절한다. worktree를 만들기 전에 막아 잘못된
688
+ // 한도로 worker가 생기지 않게 한다. status 등 읽기 경로는 1로 폴백한다.
689
+ const policy = readCoordinatorPolicy(integration.integrationPath);
690
+ if (!policy.ok)
691
+ return { ok: false, reason: 'coordinator-config-invalid' };
692
+ // waves_per_session도 같은 이유로 worktree 생성 전에 거절한다. 읽기 경로(next)는 1로 접는다.
693
+ if (!readWavesPerSession(integration.integrationPath).ok) {
694
+ return { ok: false, reason: 'coordinator-config-invalid' };
695
+ }
696
+ const ready = readyWaveGated(ledger.tasks, { maxParallel: policy.maxParallel });
697
+ // 판정 단계의 사전 검사. worker seed 출처는 integration의 blueprint 트리뿐이므로,
698
+ // 그것이 없으면 worktree를 하나도 만들기 전에 멈춰야 ledger와 Git 등록이 갈라지지 않는다.
699
+ const integrationBlueprint = path.join(integration.integrationPath, blueprint);
700
+ if (!fs.existsSync(integrationBlueprint) || !fs.statSync(integrationBlueprint).isDirectory()) {
701
+ return { ok: false, reason: 'missing-blueprint', blueprintDir: blueprint,
702
+ integrationPath: integration.integrationPath };
703
+ }
704
+ // verification bundle 확인도 읽기만 하므로 같은 판정 단계에 둔다. 섞인 wave에서
705
+ // 아래 루프가 commit worker를 먼저 만든 뒤 이 node에서 멈추면, 원장은 쓰이지 않았는데
706
+ // Git에는 worker가 등록되어 재시도 전 둘이 갈라진다.
707
+ for (const id of ready) {
708
+ const item = ledger.tasks.find((x) => x.id === id);
709
+ if (item.execution_kind !== 'verification')
710
+ continue;
711
+ const checked = checkVerificationNode(integration.integrationPath, blueprint, id);
712
+ if (!checked.ok)
713
+ return checked;
714
+ }
715
+ const otherDrives = otherDriveTasks({ repoRoot, ownLedgerFile: integration.ledgerFile });
716
+ if (!otherDrives.ok)
717
+ return otherDrives;
718
+ const conflicts = crossDriveConflicts(ready.map((id) => ledger.tasks.find((x) => x.id === id)), otherDrives.drives);
719
+ if (conflicts.length)
720
+ return { ok: false, reason: 'cross-drive-conflict', conflicts };
721
+ // light는 worker worktree를 만들지 않는다. integration 경로를 workerPath로
722
+ // 쓰므로 branch·worktree 계획 단계 전체를 건너뛴다.
723
+ if (ledgerModeOf(ledger) === 'light') {
724
+ return {
725
+ ok: true,
726
+ ledger,
727
+ ready,
728
+ plannedWorkers: new Map(),
729
+ phase1Hash: ledgerBytesHash(ledgerBytes),
730
+ light: true,
731
+ integrationBranch: names.integration,
732
+ };
733
+ }
734
+ const plannedWorkers = new Map();
735
+ const salvage = [];
736
+ try {
737
+ // revoke된 배정은 등록 worktree·branch를 지운 뒤 새로 만든다. reuse하면
738
+ // 이전 generation의 dirty HEAD가 그대로 남는다. leased-revoked와
739
+ // legacy(no lease) revoke를 같은 헬퍼로 덮는다.
740
+ // 지우기 전에 모든 대상의 보존을 끝낸다. 하나라도 실패하면 아무것도 지우지 않는다.
741
+ const revokedItems = ready.map((id) => ledger.tasks.find((x) => x.id === id))
742
+ .filter((item) => item.execution_kind !== 'verification' && shouldRemoveRevokedWorker(item));
743
+ const salvageItems = revokedItems.filter((item) => !hasResumeSalvage(ledger, item));
744
+ if (revokedItems.length > 0) {
745
+ const liveHead = git(exec, integration.integrationPath, ['rev-parse', 'HEAD']);
746
+ const baseHead = typeof ledger.integrationHead === 'string' ? ledger.integrationHead : liveHead;
747
+ for (const item of salvageItems) {
748
+ let saved;
749
+ try {
750
+ saved = salvageRevokedWorker(exec, integration.integrationPath, item, baseHead);
751
+ }
752
+ catch (error) {
753
+ // 보존 실패는 reason으로 바꾼다. 이미 쓴 patch는 출력에 남겨 수동 확인을 돕는다.
754
+ const partial = error.salvage;
755
+ return { ok: false, reason: 'worker-salvage-failed', task: item.id,
756
+ message: error.message,
757
+ salvage: [...salvage, ...(partial?.patch ? [{ task: item.id, patch: partial.patch }] : [])] };
758
+ }
759
+ if (saved.patch || saved.ref) {
760
+ // head·generation은 salvage가 이미 읽은 값을 쓴다(재계산 없음). 출력 키 순서를
761
+ // 예전과 같게 두려고 구조 분해 뒤 task 다음에 다시 붙인다.
762
+ const { head, generation, ...kept } = saved;
763
+ salvage.push({ ...kept, task: item.id, generation, head });
764
+ }
765
+ }
766
+ for (const item of revokedItems)
767
+ removeRevokedWorker(exec, integration.integrationPath, item);
768
+ }
769
+ // 한 wave의 branch 충돌을 모두 확인한 뒤에만 worktree를 만든다. 앞 task를
770
+ // 먼저 만들고 뒤 task에서 멈추면 재시도 전 ledger와 Git 등록이 갈라지므로,
771
+ // 이 단계는 Git 조회만 하고 seed·mkdir·worktree add를 절대 호출하지 않는다.
772
+ for (const id of ready) {
773
+ const item = ledger.tasks.find((x) => x.id === id);
774
+ if (item.execution_kind === 'verification')
775
+ continue;
776
+ const worker = coordinatorPathsFor({ repoRoot, blueprint, task: id }).workerPath;
777
+ if (fs.existsSync(worker) && !registeredWorker(exec, integration.integrationPath, worker)) {
778
+ return { ok: false, reason: 'unassigned-worker-worktree', workerPath: worker };
779
+ }
780
+ const workerNames = branchNamesFor({ repoRoot: integration.integrationPath, blueprint, task: id });
781
+ const resolved = resolveWorktreeBranch({ repoRoot: integration.integrationPath, worktreePath: worker,
782
+ branch: workerNames.worker, execFileSync: exec });
783
+ plannedWorkers.set(id, { worker, branch: resolved.branch, action: resolved.action });
784
+ }
785
+ // 이전 원장의 prepared task는 ready wave에 없어서 별도로 실제 checkout을 읽는다.
786
+ // 이미 등록된 branch를 rename하지 않고 field만 채워 재개 payload의 provenance를
787
+ // 복원한다. prepared인데 등록이 사라진 경우에는 새 branch를 만들 수 없다.
788
+ for (const item of ledger.tasks) {
789
+ if (item.execution_kind === 'verification' || item.status !== 'prepared' || item.branch)
790
+ continue;
791
+ const worker = coordinatorPathsFor({ repoRoot, blueprint, task: item.id }).workerPath;
792
+ if (!registeredWorker(exec, integration.integrationPath, worker)) {
793
+ return { ok: false, reason: 'unassigned-worker-worktree', workerPath: worker };
794
+ }
795
+ const workerNames = branchNamesFor({ repoRoot: integration.integrationPath, blueprint, task: item.id });
796
+ const resolved = resolveWorktreeBranch({ repoRoot: integration.integrationPath, worktreePath: worker,
797
+ branch: workerNames.worker, execFileSync: exec });
798
+ if (resolved.action !== 'reuse') {
799
+ return { ok: false, reason: 'unassigned-worker-worktree', workerPath: worker };
800
+ }
801
+ item.branch = resolved.branch;
802
+ }
803
+ }
804
+ catch (error) {
805
+ const reason = error.code;
806
+ if (reason)
807
+ return { ok: false, reason };
808
+ throw error;
809
+ }
810
+ return {
811
+ ok: true,
812
+ ledger,
813
+ ready,
814
+ plannedWorkers,
815
+ phase1Hash: ledgerBytesHash(ledgerBytes),
816
+ light: false,
817
+ integrationBranch: names.integration,
818
+ salvage,
819
+ };
820
+ });
821
+ if (!phase1 || typeof phase1 !== 'object' || phase1.ok !== true) {
822
+ return phase1;
823
+ }
824
+ const planned = phase1;
825
+ const { ledger, ready, plannedWorkers, phase1Hash, light, integrationBranch } = planned;
826
+ const salvaged = planned.salvage || [];
827
+ // 보존 산출물은 phase 2·3이 실패해도 남으므로 실패 출력에도 위치를 싣는다.
828
+ const salvageView = salvaged.map((entry) => ({ task: entry.task, patch: entry.patch, ref: entry.ref }));
829
+ const withSalvage = (result) => (salvaged.length > 0 && result && typeof result === 'object'
830
+ ? { ...result, salvage: salvageView } : result);
831
+ const created = [];
832
+ // 2. 잠금 밖: worktree add와 seed. light는 worker를 만들지 않아 이 단계를 건너뛴다.
833
+ // 원장 .lock 파일이 없어야 다른 prepare가 기다리지 않는다.
834
+ if (!light) {
835
+ try {
836
+ for (const id of ready) {
837
+ const item = ledger.tasks.find((x) => x.id === id);
838
+ if (item.execution_kind === 'verification')
839
+ continue;
840
+ const entry = plannedWorkers.get(id);
841
+ if (entry.action === 'create') {
842
+ fs.mkdirSync(path.dirname(entry.worker), { recursive: true });
843
+ git(exec, integration.integrationPath, ['worktree', 'add', '-b', entry.branch, entry.worker, 'HEAD']);
844
+ created.push({ worker: entry.worker, branch: entry.branch });
845
+ }
846
+ if (!registeredWorker(exec, integration.integrationPath, entry.worker)) {
847
+ cleanupCreatedWorkersUnderNewLock(integration.ledgerFile, exec, integration.integrationPath, created);
848
+ return withSalvage({ ok: false, reason: 'unassigned-worker-worktree', workerPath: entry.worker });
849
+ }
850
+ // 모든 worker는 integration 사본을 받는다. bootstrap 뒤 계획 문서의 정본은
851
+ // integration이고(동적 repair 문서는 그곳에만 있다), main은 drive 동안 base SHA
852
+ // 출처로만 남으므로 main 계획이 사라져도 준비가 이어진다. cpSync는 worker마다
853
+ // 독립 사본을 쓰므로 병렬 worker끼리 문서를 공유하지 않는다.
854
+ const seeded = seedCoordinatorWorker({
855
+ repoRoot: integration.integrationPath, blueprintDir: blueprint, worktreePath: entry.worker,
856
+ deps: { execFileSync: withInstallStdioToStderr(exec) },
857
+ affectedPaths: currentScopePaths(item),
858
+ });
859
+ if (!seeded.ok) {
860
+ cleanupCreatedWorkersUnderNewLock(integration.ledgerFile, exec, integration.integrationPath, created);
861
+ return withSalvage(seeded);
862
+ }
863
+ }
864
+ }
865
+ catch (error) {
866
+ cleanupCreatedWorkersUnderNewLock(integration.ledgerFile, exec, integration.integrationPath, created);
867
+ throw error;
868
+ }
869
+ }
870
+ // 3. 잠금 재획득: ① bytes와 같고 계획 worker가 등록돼 있을 때만 메모리 전이를 쓴다.
871
+ // light는 workerPath=integration으로 lease만 발급한다.
872
+ const phase3 = withLedgerLock(integration.ledgerFile, (owns) => {
873
+ // stale이면 만들어 둔 worker 중 현재 원장이 참조하지 않는 것을 거절 직전에 정리한다.
874
+ const rechecked = recheckLedgerCheckpoint({
875
+ ledgerFile: integration.ledgerFile, expectedHash: phase1Hash,
876
+ onStale: (live) => removeUnreferencedCreatedWorkers(exec, integration.integrationPath, live, created),
877
+ });
878
+ if (!rechecked.ok)
879
+ return rechecked;
880
+ const { ledger: live } = rechecked;
881
+ if (!light) {
882
+ for (const id of ready) {
883
+ const item = ledger.tasks.find((x) => x.id === id);
884
+ if (item.execution_kind === 'verification')
885
+ continue;
886
+ const entry = plannedWorkers.get(id);
887
+ if (!registeredWorker(exec, integration.integrationPath, entry.worker)) {
888
+ removeUnreferencedCreatedWorkers(exec, integration.integrationPath, live, created);
889
+ return { ok: false, reason: 'unassigned-worker-worktree', workerPath: entry.worker };
890
+ }
891
+ }
892
+ }
893
+ // 잠금을 빼앗긴 뒤에는 원장도 Git도 건드리지 않는다. 정리는 이 잠금이 끝난 뒤
894
+ // 전용 잠금에서 한다 — 여기서 remove하면 다른 소유자의 임계 구역과 Git이 겹친다.
895
+ if (!owns())
896
+ return { ok: false, reason: 'ledger-lock-lost' };
897
+ for (const id of ready) {
898
+ const item = ledger.tasks.find((x) => x.id === id);
899
+ if (item.execution_kind === 'verification') {
900
+ item.status = transition(item.status || 'pending', 'ready', 'verification');
901
+ continue;
902
+ }
903
+ item.status = transition(item.status || 'pending', 'ready');
904
+ item.status = transition(item.status, 'prepared');
905
+ if (light) {
906
+ item.workerPath = integration.integrationPath;
907
+ item.branch = ledger.integrationBranch || integrationBranch;
908
+ }
909
+ else {
910
+ const entry = plannedWorkers.get(id);
911
+ item.workerPath = entry.worker;
912
+ item.branch = entry.branch;
913
+ }
914
+ issueLease(ledger, item, makeLeaseId);
915
+ }
916
+ // 보존 기록은 phase 1 fence 통과 뒤 이 쓰기에서만 남긴다(phase 1은 원장을 쓰지 않는다).
917
+ for (const entry of salvaged) {
918
+ const decision = {
919
+ task: entry.task, kind: 'worker-salvage', generation: entry.generation, head: entry.head,
920
+ patch: entry.patch, ref: entry.ref, dirty_paths: entry.dirty_paths,
921
+ };
922
+ ledger.decisions.push(decision);
923
+ }
924
+ const lost = writeOwnedLedger(owns, writeLedger, integration.ledgerFile, ledger);
925
+ if (lost)
926
+ return lost;
927
+ return withCheckpoint({ ok: true, command: 'prepare', ready, tasks: ledger.tasks,
928
+ decisions: ledger.decisions }, ledger, integration.ledgerFile);
929
+ });
930
+ if (phase3 && typeof phase3 === 'object'
931
+ && phase3.reason === 'ledger-lock-lost') {
932
+ cleanupCreatedWorkersUnderNewLock(integration.ledgerFile, exec, integration.integrationPath, created);
933
+ }
934
+ return withSalvage(phase3);
935
+ }
936
+ /**
937
+ * `bootstrap`: 메인 체크아웃에서 integration worktree와 원장을 만들거나 재개한다.
938
+ * 자체로 원장을 쓰며 공통 lock 앞에서 끝난다(worktree 생성이 lock 수명보다 길 수 있다).
939
+ *
940
+ * @param {CoordinateCtx} ctx - 공통 준비가 끝난 handler 문맥
941
+ * @returns {CoordinateResult} 명령 결과. 거절은 `ok: false`와 reason
942
+ */
943
+ function coordinateBootstrap(ctx) {
944
+ const { exec, writeLedger, main, base, paths } = ctx;
945
+ const { command, repoRoot, blueprint, promoteLight } = ctx.args;
946
+ if (path.resolve(repoRoot) !== path.resolve(main.projectRoot)) {
947
+ return { ok: false, reason: 'bootstrap-requires-main-checkout' };
948
+ }
949
+ const before = sourceStatus(exec, repoRoot);
950
+ if (fs.existsSync(paths.integrationPath) && !registeredIntegration(exec, repoRoot, paths.integrationPath)) {
951
+ return { ok: false, reason: 'unassigned-integration-worktree', integrationPath: paths.integrationPath };
952
+ }
953
+ if (promoteLight) {
954
+ if (!fs.existsSync(paths.integrationPath) || !registeredIntegration(exec, repoRoot, paths.integrationPath)) {
955
+ return { ok: false, reason: 'promote-light-requires-stopped' };
956
+ }
957
+ const names = branchNamesFor({ repoRoot, blueprint });
958
+ return promoteLightBootstrap({ repoRoot, blueprint, paths,
959
+ integrationBranch: names.integration, exec, writeLedger });
960
+ }
961
+ // 원장이 없는 신규 seed만 검사한다. 재개 bootstrap은 integration 사본이 정본이라
962
+ // 메인에 남은 옛 주석으로 worktree를 만들지 못한 채 멈추면 안 된다.
963
+ // worktree add·main-source-mutated보다 앞에 두어 거절 시 메인을 바꾸지 않는다.
964
+ if (!fs.existsSync(paths.ledgerFile)) {
965
+ const leftover = scanLegacyScaffoldComments({ repoRoot, blueprintDir: blueprint });
966
+ if (leftover.length > 0) {
967
+ return { ok: false, reason: 'scaffold-comment-remaining', paths: leftover };
968
+ }
969
+ }
970
+ let integrationBranch;
971
+ try {
972
+ const names = branchNamesFor({ repoRoot, blueprint });
973
+ const resolved = resolveWorktreeBranch({
974
+ repoRoot, worktreePath: paths.integrationPath, branch: names.integration, execFileSync: exec,
975
+ });
976
+ integrationBranch = resolved.branch;
977
+ if (resolved.action === 'create') {
978
+ fs.mkdirSync(path.dirname(paths.integrationPath), { recursive: true });
979
+ // argv 호출만 사용해 branch 이름과 경로가 shell 해석으로 새지 않게 한다.
980
+ git(exec, repoRoot, ['worktree', 'add', '-b', integrationBranch,
981
+ paths.integrationPath, 'HEAD']);
982
+ }
983
+ }
984
+ catch (error) {
985
+ const reason = error.code;
986
+ if (reason)
987
+ return { ok: false, reason };
988
+ throw error;
989
+ }
990
+ if (sourceStatus(exec, repoRoot) !== before)
991
+ return { ok: false, reason: 'main-source-mutated' };
992
+ if (!registeredIntegration(exec, repoRoot, paths.integrationPath)) {
993
+ return { ok: false, reason: 'unassigned-integration-worktree', integrationPath: paths.integrationPath };
994
+ }
995
+ let ledger = loadLedger(paths.ledgerFile);
996
+ if (!ledger) {
997
+ const seeded = seedIntegration({ repoRoot, blueprintDir: blueprint, integrationPath: paths.integrationPath });
998
+ if (!seeded.ok)
999
+ return seeded;
1000
+ const tasks = taskList(paths.integrationPath, blueprint);
1001
+ // scale:light 이고 단일 commit·빈 depends_on일 때만 mode:light. 그 밖 light는
1002
+ // 거부해 full worktree 경로로 조용히 떨어지지 않게 한다. scale이 light가 아니면
1003
+ // 기존처럼 mode 없는 full 원장이다.
1004
+ const scale = blueprintScaleAt(paths.integrationPath, blueprint);
1005
+ const policy = verificationPolicyAt(paths.integrationPath, blueprint);
1006
+ // 허용 밖 정책 값은 plan gate가 거절했어야 하지만 손으로 고친 문서가 실행까지
1007
+ // 오면 legacy로 읽지 않고 여기서 끊는다. light 자격 판정이 갈라지지 않게 하기 위함.
1008
+ if (policy === null)
1009
+ return { ok: false, reason: 'verification-policy-invalid' };
1010
+ if (scale === 'light' && !isLightEligible(tasks, policy === 'scoped_terminal')) {
1011
+ return { ok: false, reason: 'light-requires-single-task' };
1012
+ }
1013
+ ledger = {
1014
+ version: 1, blueprint, base,
1015
+ integrationHead: git(exec, paths.integrationPath, ['rev-parse', 'HEAD']),
1016
+ tasks, decisions: [], status: 'active', repairWaves: [],
1017
+ seedManifest: seeded.manifest,
1018
+ ...(scale === 'light' ? { mode: 'light' } : {}),
1019
+ };
1020
+ }
1021
+ else {
1022
+ // 기존 원장을 재개할 때도 lease shape를 본다. fenced mutation과 같이
1023
+ // 손상 lease가 bootstrap 응답·이후 명령의 기준이 되지 않게 한다.
1024
+ const leaseShape = assertLeaseShape(ledger);
1025
+ if (!leaseShape.ok)
1026
+ return leaseShape;
1027
+ // 이미 mode가 있는(또는 legacy full로 읽는) 원장에 반대 scale로 재개하면
1028
+ // light/full 계약이 갈라지므로 여기서 끊는다. 자격 검사는 신규 seed만.
1029
+ const scale = blueprintScaleAt(paths.integrationPath, blueprint);
1030
+ const wantMode = scale === 'light' ? 'light' : 'full';
1031
+ if (ledgerModeOf(ledger) !== wantMode) {
1032
+ return { ok: false, reason: 'ledger-mode-mismatch' };
1033
+ }
1034
+ }
1035
+ ledger.integrationBranch = integrationBranch;
1036
+ writeLedger(paths.ledgerFile, ledger);
1037
+ return withCheckpoint({
1038
+ ok: true, command, integrationPath: paths.integrationPath,
1039
+ mode: ledgerModeOf(ledger),
1040
+ ready: readyWaveGated(ledger.tasks, { maxParallel: maxParallelForRead(paths.integrationPath) }),
1041
+ tasks: ledger.tasks, decisions: ledger.decisions, integrationBranch,
1042
+ }, ledger, paths.ledgerFile);
1043
+ }
1044
+ module.exports = {
1045
+ taskList, checkVerificationNode, blueprintScaleAt, isLightEligible, worktreeDiffSha,
1046
+ promoteLightBootstrap, salvageRevokedWorker, prepareCoordinator, coordinateBootstrap,
1047
+ };