@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,652 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { execFileSync } = require('node:child_process');
5
+ const frontmatter = require("./frontmatter");
6
+ const { parseFrontmatter } = frontmatter;
7
+ const paths = require("./paths");
8
+ const { epicDirOf, toPosix } = paths;
9
+ const layout = require("./layout");
10
+ const { CONTEXT_ROOT } = layout;
11
+ const runtimeState = require("./runtime-state");
12
+ const { runtimePaths, worktreePathFor, coordinatorPathsFor } = runtimeState;
13
+ const driveMetrics = require("./drive-metrics");
14
+ const scope = require("./scope");
15
+ const { readCoordinatorLedger } = scope;
16
+ const current = require("./current");
17
+ const { nextBlueprint } = current;
18
+ const executionContracts = require("./execution-contracts");
19
+ const { validOutsideScope } = executionContracts;
20
+ const seedWorktree = require("./seed-worktree");
21
+ const { releaseSeedManifest } = seedWorktree;
22
+ const BLUEPRINT_DIR_RE = /^\.bouncer\/context\/epics\/[^/]+\/blueprints\/[^/]+$/;
23
+ /**
24
+ * 실제 원장의 worker 집계를 Git common dir snapshot으로 저장한다(release-main 기본 구현).
25
+ * common dir나 원장 경로를 풀 수 없으면 보존을 보장할 수 없으므로 저장 실패로 돌려 정리를 막는다.
26
+ *
27
+ * @param {object} args - 저장 입력
28
+ * @param {string} args.repoRoot - 메인 checkout 루트
29
+ * @param {string} args.blueprint - 정규형 blueprint 경로
30
+ * @returns {{ok: boolean, reason?: string}} 저장 결과. 실패 reason은 driveMetrics의 거절 코드
31
+ */
32
+ function saveMetricsSnapshotDefault({ repoRoot, blueprint }) {
33
+ const failed = { ok: false, reason: 'metrics-snapshot-write-failed' };
34
+ const rt = runtimePaths({ repoRoot });
35
+ if (rt.unavailable || !rt.commonGitDir)
36
+ return failed;
37
+ let ledgerFile;
38
+ try {
39
+ ledgerFile = coordinatorPathsFor({ repoRoot, blueprint }).ledgerFile;
40
+ }
41
+ catch (_error) {
42
+ // blueprint 경로에서 id를 못 뽑는 입력 오류만 온다(이미 정규형 검사를 통과한 값이라 드물다).
43
+ return failed;
44
+ }
45
+ const saved = driveMetrics.saveWorkerSnapshot({ commonGitDir: rt.commonGitDir, ledgerFile, blueprint });
46
+ return saved.ok ? { ok: true } : { ok: false, reason: saved.reason };
47
+ }
48
+ /**
49
+ * 분류된 outside_scope terminal을 뺀 "아직 통합되지 않은" task id를 정렬해 돌려준다.
50
+ * finalize 쪽(finalize-checks)과 같은 validOutsideScope로 판정해, finalize --yes가
51
+ * 닫은 drive를 release-main이 다시 거절하지 않게 한다. 닫힘 판정과 거절 응답의
52
+ * tasks가 같은 계산을 쓰도록 이 함수 하나로 모았다.
53
+ *
54
+ * @param {unknown} ledger - 원장 객체(읽기만 한다)
55
+ * @param {Array<{ id?: string; status?: string }>} tasks - 원장 tasks 배열
56
+ * @returns {string[]} integrated도 유효 분류도 아닌 task id(정렬)
57
+ */
58
+ function unintegratedTaskIds(ledger, tasks) {
59
+ return tasks
60
+ .filter((entry) => entry.status !== 'integrated'
61
+ && !(typeof entry.id === 'string' && validOutsideScope(ledger, entry.id)))
62
+ .map((entry) => String(entry.id))
63
+ .sort();
64
+ }
65
+ /**
66
+ * drive-not-closed 거절 응답을 만든다. 원장 상태별로 cause·next를 고르며 우선순위는
67
+ * partial_closed, awaiting_confirmation, 미통합 task 순이다.
68
+ *
69
+ * @param {string | undefined} status - 원장 status
70
+ * @param {string[]} tasks - unintegratedTaskIds 결과
71
+ * @returns {ReleaseMainErr} cause·next·tasks를 담은 거절 응답
72
+ */
73
+ function driveNotClosed(status, tasks) {
74
+ const rerun = 'rerun `bouncer finalize release-main --blueprint <dir>`.';
75
+ if (status === 'partial_closed') {
76
+ return { ok: false, reason: 'drive-not-closed', tasks,
77
+ cause: 'The drive was partially closed and needs an explicit partial merge.',
78
+ next: 'After the user approves, rerun with `--allow-partial-merge --confirm-partial-merge`.' };
79
+ }
80
+ if (status === 'awaiting_confirmation') {
81
+ return { ok: false, reason: 'drive-not-closed', tasks,
82
+ cause: 'The ledger is waiting for a user decision after the repair wave limit.',
83
+ next: `Resolve it with \`bouncer coordinate next\` (reverify, partial-close, or outside_scope), then ${rerun}` };
84
+ }
85
+ return { ok: false, reason: 'drive-not-closed', tasks,
86
+ cause: 'A coordinator task is not integrated, so the drive is not closed.',
87
+ next: 'From the integration worktree, finish the listed tasks with `bouncer coordinate next` '
88
+ + '(or classify a proven out-of-scope terminal failure with '
89
+ + '`bouncer coordinate repair --disposition outside_scope`), then ' + rerun };
90
+ }
91
+ /**
92
+ * 정규형 blueprint 상대 경로인지 본다. normalize가 입력을 바꾸면 `..` 탈출이
93
+ * 이미 숨어 있는 것이므로, 정규식만 통과시켜도 형제를 지울 수 있다.
94
+ *
95
+ * @param {string} value - 호출자가 넘긴 blueprint 경로
96
+ * @returns {boolean} 자기 자신으로 normalize되고 상대 POSIX 패턴에 맞으면 true
97
+ */
98
+ function isCanonicalBlueprintPath(value) {
99
+ const normalized = path.posix.normalize(value);
100
+ if (normalized !== value)
101
+ return false;
102
+ if (path.posix.isAbsolute(normalized) || normalized.split('/').includes('..'))
103
+ return false;
104
+ return BLUEPRINT_DIR_RE.test(normalized);
105
+ }
106
+ /**
107
+ * 문서의 bouncer.status를 읽는다. 부재·깨진 YAML·frontmatter 없음은 모두
108
+ * "닫힘 근거 없음"이지 권한 오류가 아니므로 null로 접는다.
109
+ *
110
+ * @param {string} abs - 문서 절대 경로
111
+ * @returns {string | null} status 문자열. 파일을 못 읽거나 블록이 없으면 null
112
+ */
113
+ function readBouncerStatus(abs) {
114
+ let source;
115
+ try {
116
+ source = fs.readFileSync(abs, 'utf8');
117
+ }
118
+ catch {
119
+ // ENOENT뿐 아니라 EACCES·EISDIR도 닫힘 근거가 아니다. throw하면
120
+ // releaseMain이 JSON reason 대신 프로세스를 죽인다.
121
+ return null;
122
+ }
123
+ try {
124
+ const data = parseFrontmatter(source).data;
125
+ const bouncer = data && typeof data === 'object'
126
+ ? data.bouncer : null;
127
+ if (!bouncer || typeof bouncer !== 'object')
128
+ return null;
129
+ const status = bouncer.status;
130
+ return typeof status === 'string' ? status : null;
131
+ }
132
+ catch {
133
+ // YAML·frontmatter 부재·기타 파서 실패는 closed가 아니다. 같은 이유로
134
+ // 프로세스 실패로 올리지 않고 거절 분기에 맡긴다.
135
+ return null;
136
+ }
137
+ }
138
+ /**
139
+ * git cat-file이 "HEAD에 그 경로 없음"으로 실패한 경우만 가린다.
140
+ * 권한·저장소 손상까지 같은 분기로 접으면 tracked를 지운다.
141
+ *
142
+ * @param {unknown} error - execFileSync가 던진 값
143
+ * @returns {boolean} 경로가 HEAD에 없다는 메시지일 때만 true
144
+ */
145
+ function isExpectedGitMiss(error) {
146
+ const err = error;
147
+ const text = Buffer.isBuffer(err.stderr)
148
+ ? err.stderr.toString('utf8')
149
+ : typeof err.stderr === 'string' ? err.stderr : String(err.message || '');
150
+ return /Not a valid object name|exists on disk, but not in/.test(text);
151
+ }
152
+ /**
153
+ * child가 parent 디렉터리 트리 안에 있는지 본다. `..`로 올라가면 심볼릭
154
+ * 링크가 blueprint 밖으로 나간 것이다.
155
+ *
156
+ * @param {string} childAbs - 검사할 절대 경로(가능하면 realpath)
157
+ * @param {string} parentAbs - 상한 디렉터리 realpath
158
+ * @returns {boolean} parent 자신이거나 그 아래이면 true
159
+ */
160
+ function isInsideDir(childAbs, parentAbs) {
161
+ const rel = path.relative(parentAbs, childAbs);
162
+ return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
163
+ }
164
+ /**
165
+ * 비어 있는 디렉터리를 `stopRel`(저장소 상대) 직전까지 지운다.
166
+ * 상한을 `blueprints/`로 두면 형제 blueprint와 epic index를 같이 지울 수 없다.
167
+ *
168
+ * @param {string} repoRoot - 메인 checkout 절대 경로
169
+ * @param {string} rel - 방금 지운 파일의 저장소 상대 경로
170
+ * @param {string} stopRel - 지우지 않을 상한 디렉터리
171
+ * @returns {void}
172
+ */
173
+ function pruneEmptyDirs(repoRoot, rel, stopRel) {
174
+ let dir = path.dirname(path.join(repoRoot, rel));
175
+ const stop = path.resolve(repoRoot, stopRel);
176
+ while (path.resolve(dir) !== stop && fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
177
+ fs.rmdirSync(dir);
178
+ dir = path.dirname(dir);
179
+ }
180
+ }
181
+ /**
182
+ * blueprint 트리 아래 남은 빈 디렉터리(파일 없이 껍데기만 있는 경우)를 지운다.
183
+ * 파일 walk는 빈 폴더를 보지 못하므로, 정리 뒤에 한 번 더 쓸어야 상한 계약이 남는다.
184
+ *
185
+ * @param {string} repoRoot - 메인 checkout 절대 경로
186
+ * @param {string} relDir - 쓸 디렉터리(보통 blueprint 경로)
187
+ * @param {string} stopRel - 지우지 않을 상한 (`blueprints/`)
188
+ * @returns {void}
189
+ */
190
+ function sweepEmptyDirs(repoRoot, relDir, stopRel) {
191
+ const abs = path.join(repoRoot, relDir);
192
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory())
193
+ return;
194
+ for (const name of fs.readdirSync(abs)) {
195
+ const childRel = toPosix(path.posix.join(relDir, name));
196
+ if (fs.statSync(path.join(repoRoot, childRel)).isDirectory()) {
197
+ sweepEmptyDirs(repoRoot, childRel, stopRel);
198
+ }
199
+ }
200
+ if (path.resolve(abs) !== path.resolve(repoRoot, stopRel)
201
+ && fs.readdirSync(abs).length === 0) {
202
+ fs.rmdirSync(abs);
203
+ }
204
+ }
205
+ /**
206
+ * 디렉터리 아래 파일의 저장소 상대 POSIX 경로를 모은다.
207
+ * lstat으로만 내려가고, 실제 디렉터리의 realpath가 blueprint 밖이면 멈춘다.
208
+ * 디렉터리 심볼릭 링크를 따라가면 sibling·소스를 지울 수 있다.
209
+ *
210
+ * @param {string} repoRoot - 저장소 루트
211
+ * @param {string} relDir - 시작 상대 경로
212
+ * @returns {string[]} 정렬하지 않은 파일 경로
213
+ */
214
+ function walkFiles(repoRoot, relDir) {
215
+ const abs = path.join(repoRoot, relDir);
216
+ const out = [];
217
+ if (!fs.existsSync(abs))
218
+ return out;
219
+ let bpReal;
220
+ try {
221
+ bpReal = fs.realpathSync(abs);
222
+ }
223
+ catch (_error) {
224
+ return out;
225
+ }
226
+ const visit = (dirRel) => {
227
+ const dirAbs = path.join(repoRoot, dirRel);
228
+ let dirLstat;
229
+ try {
230
+ dirLstat = fs.lstatSync(dirAbs);
231
+ }
232
+ catch (_error) {
233
+ return;
234
+ }
235
+ if (dirLstat.isSymbolicLink() || !dirLstat.isDirectory())
236
+ return;
237
+ let dirReal;
238
+ try {
239
+ dirReal = fs.realpathSync(dirAbs);
240
+ }
241
+ catch (_error) {
242
+ return;
243
+ }
244
+ if (!isInsideDir(dirReal, bpReal))
245
+ return;
246
+ for (const name of fs.readdirSync(dirAbs)) {
247
+ const childRel = toPosix(path.posix.join(dirRel, name));
248
+ const childAbs = path.join(repoRoot, childRel);
249
+ let childLstat;
250
+ try {
251
+ childLstat = fs.lstatSync(childAbs);
252
+ }
253
+ catch (_error) {
254
+ continue;
255
+ }
256
+ if (childLstat.isSymbolicLink()) {
257
+ // 링크 노드만 목록에 넣고 대상 디렉터리로는 들어가지 않는다.
258
+ out.push(childRel);
259
+ continue;
260
+ }
261
+ if (childLstat.isDirectory())
262
+ visit(childRel);
263
+ else
264
+ out.push(childRel);
265
+ }
266
+ };
267
+ visit(relDir);
268
+ return out;
269
+ }
270
+ /**
271
+ * 배열을 POSIX 경로 정렬·중복 제거한다. 세 갈래(bp 정리·manifest)가 같은
272
+ * 경로를 두 번 넣어도 payload가 집합처럼 보이게 하려는 계약이다.
273
+ *
274
+ * @param {string[]} values - 경로 목록
275
+ * @returns {string[]} 정렬된 고유 경로
276
+ */
277
+ function sortedUnique(values) {
278
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
279
+ }
280
+ /**
281
+ * 메인 checkout에서 닫힌 blueprint 계획 사본을 정리한다.
282
+ * 거절은 파일을 바꾸기 전에 JSON으로만 돌아간다. 원장은 읽기만 하고,
283
+ * fence(`--ledger-path`/`--ledger-hash`)는 요구하지 않는다.
284
+ *
285
+ * @param {object} opts - 메인 checkout과 대상 blueprint
286
+ * @param {string} opts.repoRoot - `--repo` 또는 cwd로 고른 저장소 루트
287
+ * @param {string} opts.cwd - 실제 프로세스 cwd. repoRoot와 함께 메인 루트여야 한다
288
+ * @param {string} opts.blueprintDir - 정규형 blueprint 상대 경로
289
+ * @param {object} [opts.deps] - 테스트 seam. 생략하면 runtime/ledger/next/git 기본값
290
+ * @returns {ReleaseMainResult} 성공 payload(분류된 outside_scope terminal이 있으면 `outside_scope` 포함)
291
+ * 또는 `{ ok: false, reason }`. `drive-not-closed`는 `cause`·`next`·`tasks`를 더 싣는다
292
+ */
293
+ function releaseMain({ repoRoot, cwd, blueprintDir, deps, allowPartialMerge = false, confirmPartialMerge = false }) {
294
+ const gitDefault = (args) => execFileSync('git', args, {
295
+ cwd: repoRoot,
296
+ encoding: 'utf8',
297
+ stdio: ['ignore', 'pipe', 'pipe'],
298
+ });
299
+ const d = {
300
+ runtimePaths,
301
+ readCoordinatorLedger,
302
+ nextBlueprint,
303
+ worktreePathFor,
304
+ git: gitDefault,
305
+ saveMetricsSnapshot: saveMetricsSnapshotDefault,
306
+ ...deps,
307
+ };
308
+ // 1. 메인 루트 판정 — 자리부터 틀리면 원장·파일을 읽지 않는다.
309
+ const runtime = d.runtimePaths({ repoRoot });
310
+ let mainRoot;
311
+ try {
312
+ if (runtime.unavailable || typeof runtime.projectRoot !== 'string' || !runtime.projectRoot) {
313
+ return { ok: false, reason: 'release-main-requires-main-checkout' };
314
+ }
315
+ mainRoot = fs.realpathSync(runtime.projectRoot);
316
+ if (fs.realpathSync(repoRoot) !== mainRoot || fs.realpathSync(cwd) !== mainRoot) {
317
+ return { ok: false, reason: 'release-main-requires-main-checkout' };
318
+ }
319
+ }
320
+ catch (_error) {
321
+ // realpath 실패는 "이 cwd를 메인으로 쓸 수 없다"와 같다. 다른 reason으로
322
+ // 접으면 호출자가 checkout을 고치기 전에 원장을 의심한다.
323
+ return { ok: false, reason: 'release-main-requires-main-checkout' };
324
+ }
325
+ // 2. 경로 정규형. 통과한 값만 이후 파일 연산의 접두로 쓴다.
326
+ const bp = toPosix(blueprintDir);
327
+ if (!isCanonicalBlueprintPath(bp)) {
328
+ return { ok: false, reason: 'invalid-blueprint-path' };
329
+ }
330
+ const root = fs.realpathSync(repoRoot);
331
+ const git = (args) => d.git === gitDefault
332
+ ? execFileSync('git', args, {
333
+ cwd: root,
334
+ encoding: 'utf8',
335
+ stdio: ['ignore', 'pipe', 'pipe'],
336
+ })
337
+ : d.git(args);
338
+ const existsInHead = (rel) => {
339
+ try {
340
+ git(['cat-file', '-e', `HEAD:${rel}`]);
341
+ return true;
342
+ }
343
+ catch (error) {
344
+ // HEAD에 경로가 없는 경우만 false. 그 밖의 throw를 접으면 tracked를 지운다.
345
+ if (isExpectedGitMiss(error))
346
+ return false;
347
+ throw error;
348
+ }
349
+ };
350
+ const readHead = (rel) => {
351
+ try {
352
+ // utf8로 디코드하면 비UTF-8 blob이 U+FFFD로 바뀌어 HEAD와 작업 트리
353
+ // 바이트가 같아도 dirty로 오인한다. seed-worktree realGit.readHead와 같이
354
+ // encoding 없이 Buffer를 받는다. deps.git seam은 문자열만 돌려주므로
355
+ // 기본 git일 때만 이 경로를 쓴다.
356
+ if (d.git !== gitDefault) {
357
+ return Buffer.from(git(['cat-file', '--filters', `HEAD:${rel}`]), 'utf8');
358
+ }
359
+ return execFileSync('git', ['cat-file', '--filters', `HEAD:${rel}`], {
360
+ cwd: root,
361
+ stdio: ['ignore', 'pipe', 'pipe'],
362
+ });
363
+ }
364
+ catch (error) {
365
+ if (isExpectedGitMiss(error))
366
+ return null;
367
+ throw error;
368
+ }
369
+ };
370
+ const inIndex = (rel) => {
371
+ try {
372
+ return git(['ls-files', '--', rel]).split('\n').filter(Boolean).length > 0;
373
+ }
374
+ catch (error) {
375
+ if (isExpectedGitMiss(error))
376
+ return false;
377
+ throw error;
378
+ }
379
+ };
380
+ const headTrackedUnderBp = () => {
381
+ try {
382
+ return git(['ls-tree', '-r', '--name-only', 'HEAD', '--', bp])
383
+ .split('\n').filter(Boolean).map((line) => toPosix(line.trim()));
384
+ }
385
+ catch (error) {
386
+ if (isExpectedGitMiss(error))
387
+ return [];
388
+ throw error;
389
+ }
390
+ };
391
+ // 3. 원장. unreadable만 거절이고, 나머지 부재 코드는 "원장 없음"으로 본다.
392
+ const ledgerRead = d.readCoordinatorLedger({ repoRoot: root, blueprint: bp });
393
+ if (!ledgerRead.ok && ledgerRead.reason === 'unreadable-ledger') {
394
+ return { ok: false, reason: 'coordinator-ledger' };
395
+ }
396
+ const hasLedger = ledgerRead.ok === true;
397
+ let outsideScope;
398
+ if (hasLedger) {
399
+ const ledger = ledgerRead.ledger;
400
+ const tasks = Array.isArray(ledger.tasks) ? ledger.tasks : [];
401
+ if (ledger.status === 'partial_closed') {
402
+ if (!allowPartialMerge && !confirmPartialMerge) {
403
+ return driveNotClosed(ledger.status, unintegratedTaskIds(ledger, tasks));
404
+ }
405
+ if (!allowPartialMerge || !confirmPartialMerge) {
406
+ return { ok: false, reason: 'partial-merge-confirmation-required' };
407
+ }
408
+ const handoff = ledger.handoff;
409
+ if (ledger.userConfirmed !== true || !handoff
410
+ || typeof handoff.path !== 'string' || !fs.existsSync(path.join(root, handoff.path))) {
411
+ return { ok: false, reason: 'partial-evidence-missing' };
412
+ }
413
+ // Partial merge는 worker tree를 절대 합치지 않는다. integration HEAD 하나만
414
+ // 병합하고 커밋 메시지에 handoff를 남겨 terminal 성공으로 오인되지 않게 한다.
415
+ const integrationHead = git(['-C', ledgerRead.integrationPath, 'rev-parse', 'HEAD']).trim();
416
+ const currentHead = git(['rev-parse', 'HEAD']).trim();
417
+ if (!integrationHead || integrationHead === currentHead)
418
+ return { ok: false, reason: 'partial-merge-empty' };
419
+ const dirty = git(['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean);
420
+ // partial-close handoff와 integration worktree 디렉터리만 untracked 예외다.
421
+ // 다른 변경은 merge commit에 섞이거나 덮일 수 있으므로 index를 그대로 둔다.
422
+ const integrationRel = toPosix(path.relative(root, ledgerRead.integrationPath));
423
+ if (dirty.some((line) => !(line.startsWith('?? ')
424
+ && [handoff.path, `${integrationRel}/`].includes(line.slice(3))))) {
425
+ return {
426
+ ok: false, reason: 'partial-merge-main-dirty', paths: dirty.map((line) => line.slice(3)),
427
+ };
428
+ }
429
+ // 모든 거절 판정이 끝난 뒤 첫 mutation(merge) 전에 worker 집계를 보존한다.
430
+ const savedPartial = d.saveMetricsSnapshot({ repoRoot: root, blueprint: bp });
431
+ if (!savedPartial.ok)
432
+ return { ok: false, reason: savedPartial.reason ?? 'metrics-snapshot-write-failed' };
433
+ try {
434
+ git(['merge', '--no-commit', '--no-ff', integrationHead]);
435
+ git(['commit', '-m', `[partial] ${bp}`, '-m', `Handoff: ${handoff.path}`]);
436
+ }
437
+ catch (error) {
438
+ let abortError = null;
439
+ try {
440
+ git(['merge', '--abort']);
441
+ }
442
+ catch (error) {
443
+ abortError = error.message;
444
+ }
445
+ return { ok: false, reason: 'partial-merge-failed', cause: error.message,
446
+ ...(abortError ? { recoveryError: abortError } : {}) };
447
+ }
448
+ return { ok: true, blueprint: bp, partial: true, merged: integrationHead,
449
+ handoff: handoff.path, removed: [], restored: [], preserved: [], next: null };
450
+ }
451
+ // 분류된 outside_scope terminal 제외는 닫힘 판정 한 곳에서만 한다. 무효 분류는
452
+ // validOutsideScope가 null이라 미통합으로 남아 거절된다.
453
+ const open = unintegratedTaskIds(ledger, tasks);
454
+ if (ledger.status === 'awaiting_confirmation' || open.length > 0) {
455
+ return driveNotClosed(ledger.status, open);
456
+ }
457
+ const classified = tasks.find((entry) => entry.status !== 'integrated');
458
+ const disposition = classified && typeof classified.id === 'string'
459
+ ? validOutsideScope(ledger, classified.id) : null;
460
+ if (disposition) {
461
+ outsideScope = { task: String(classified?.id), evidence_id: disposition.evidence_id,
462
+ reason: disposition.reason };
463
+ }
464
+ }
465
+ const bpAbs = path.join(root, bp);
466
+ const bpPresent = fs.existsSync(bpAbs);
467
+ // 4. 닫힘 근거. 원장·디렉터리 둘 다 없으면 worktree 제거 뒤 재실행이므로
468
+ // 닫힘을 묻지 않고 빈 정리로 성공한다.
469
+ if (!hasLedger && !bpPresent) {
470
+ return successPayload({ repoRoot: root, bp, removed: [], restored: [], preserved: [], deps: d });
471
+ }
472
+ let closed = false;
473
+ if (hasLedger) {
474
+ closed = readBouncerStatus(path.join(ledgerRead.integrationPath, bp, 'index.md')) === 'closed';
475
+ }
476
+ else {
477
+ // 초기 null은 항상 try/catch에서 덮이므로 선언만 한다(no-useless-assignment).
478
+ let soloStatus;
479
+ try {
480
+ const solo = d.worktreePathFor({ repoRoot: root, blueprint: bp });
481
+ soloStatus = readBouncerStatus(path.join(solo, bp, 'index.md'));
482
+ }
483
+ catch (_error) {
484
+ // GIT_REQUIRED·id 파싱 실패는 단독 worktree가 없다는 뜻. HEAD 근거로 넘긴다.
485
+ soloStatus = null;
486
+ }
487
+ if (soloStatus !== null) {
488
+ closed = soloStatus === 'closed';
489
+ }
490
+ else {
491
+ const headRaw = readHead(`${bp}/index.md`);
492
+ if (headRaw) {
493
+ try {
494
+ const data = parseFrontmatter(headRaw.toString('utf8')).data;
495
+ const bouncer = data && typeof data === 'object'
496
+ ? data.bouncer : null;
497
+ const status = bouncer && typeof bouncer === 'object'
498
+ ? bouncer.status : null;
499
+ closed = status === 'closed';
500
+ }
501
+ catch (_error) {
502
+ closed = false;
503
+ }
504
+ }
505
+ }
506
+ }
507
+ if (!closed)
508
+ return { ok: false, reason: 'blueprint-not-closed' };
509
+ // 5. 판정을 먼저 모은 뒤에만 메인 쓰기다. git 예외를 지우는 쪽으로
510
+ // 접으면 tracked가 사라지고, 쓰던 중에 throw하면 반만 지워진다.
511
+ const removed = [];
512
+ const restored = [];
513
+ const preserved = [];
514
+ const stopRel = path.posix.dirname(bp);
515
+ let actions;
516
+ try {
517
+ const seen = new Set();
518
+ actions = [];
519
+ for (const rel of walkFiles(root, bp)) {
520
+ seen.add(rel);
521
+ if (existsInHead(rel)) {
522
+ const current = fs.readFileSync(path.join(root, rel));
523
+ const head = readHead(rel);
524
+ if (head && current.equals(head))
525
+ actions.push({ kind: 'skip' });
526
+ else
527
+ actions.push({ kind: 'restore', rel });
528
+ }
529
+ else {
530
+ actions.push({ kind: 'remove', rel, unstage: inIndex(rel) });
531
+ }
532
+ }
533
+ for (const rel of headTrackedUnderBp()) {
534
+ if (seen.has(rel))
535
+ continue;
536
+ // 작업 트리에서 지워진 tracked는 walkFiles에 없다. HEAD로 되돌린다.
537
+ actions.push({ kind: 'restore', rel });
538
+ }
539
+ }
540
+ catch (_error) {
541
+ // 예상 miss가 아닌 git·읽기 실패. 파일을 바꾸지 않고 JSON으로만 거절한다.
542
+ return { ok: false, reason: 'blueprint-not-closed' };
543
+ }
544
+ // 6. 기존 거절 판정이 모두 끝났다. 정리(첫 mutation) 전에 worker 토큰 집계를 Git common dir에
545
+ // 보존한다 — 원장은 worktree 제거와 함께 사라지고, 이후 `metrics summary`는 이 snapshot만 본다.
546
+ // 실패하면 아무것도 지우지 않고 거절해 main·원장·worktree를 그대로 둔다. 원장 없는 재실행은
547
+ // 새 값을 만들 수 없으므로 호출하지 않고 기존 snapshot을 보존한다.
548
+ if (hasLedger) {
549
+ const saved = d.saveMetricsSnapshot({ repoRoot: root, blueprint: bp });
550
+ if (!saved.ok)
551
+ return { ok: false, reason: saved.reason ?? 'metrics-snapshot-write-failed' };
552
+ }
553
+ for (const action of actions) {
554
+ if (action.kind === 'skip')
555
+ continue;
556
+ if (action.kind === 'restore') {
557
+ git(['checkout', 'HEAD', '--', action.rel]);
558
+ restored.push(action.rel);
559
+ continue;
560
+ }
561
+ if (action.unstage)
562
+ git(['rm', '--cached', '--quiet', '--', action.rel]);
563
+ const abs = path.join(root, action.rel);
564
+ let st;
565
+ try {
566
+ st = fs.lstatSync(abs);
567
+ }
568
+ catch (error) {
569
+ if (error.code === 'ENOENT') {
570
+ removed.push(action.rel);
571
+ continue;
572
+ }
573
+ throw error;
574
+ }
575
+ // recursive rm은 디렉터리 심볼릭 링크 너머 sibling을 지운다. 링크면
576
+ // unlink만 해서 노드만 없앤다.
577
+ if (st.isSymbolicLink())
578
+ fs.unlinkSync(abs);
579
+ else if (!st.isDirectory())
580
+ fs.rmSync(abs, { force: true });
581
+ pruneEmptyDirs(root, action.rel, stopRel);
582
+ removed.push(action.rel);
583
+ }
584
+ sweepEmptyDirs(root, bp, stopRel);
585
+ const epicIndex = `${epicDirOf(bp)}/index.md`;
586
+ const contextIndex = `${CONTEXT_ROOT}/index.md`;
587
+ if (hasLedger) {
588
+ const manifest = ledgerRead.ledger.seedManifest;
589
+ if (!Array.isArray(manifest)) {
590
+ // 항목을 고를 해시가 없으면 두 index를 손대지 않는다. 지우는 쪽이
591
+ // 기본이면 epic 목록이 메인에서 사라진다.
592
+ preserved.push(contextIndex, epicIndex);
593
+ }
594
+ else {
595
+ const wanted = new Set([epicIndex, contextIndex]);
596
+ const picked = manifest.filter((entry) => {
597
+ if (!entry || typeof entry !== 'object')
598
+ return false;
599
+ const rel = toPosix(entry.path);
600
+ return wanted.has(rel);
601
+ });
602
+ const judged = releaseSeedManifest({
603
+ repoRoot: root, blueprintDir: bp, manifest: picked,
604
+ });
605
+ removed.push(...judged.released);
606
+ restored.push(...judged.restored);
607
+ preserved.push(...judged.preserved);
608
+ }
609
+ }
610
+ return successPayload({
611
+ repoRoot: root, bp,
612
+ removed: sortedUnique(removed),
613
+ restored: sortedUnique(restored),
614
+ preserved: sortedUnique(preserved),
615
+ deps: d,
616
+ outsideScope,
617
+ });
618
+ }
619
+ /**
620
+ * 정리 뒤 메인 checkout을 기준으로 같은 epic의 다음 ready blueprint를 고른다.
621
+ * nextBlueprint의 1순위가 다른 epic이면 remaining에서 같은 epic을 찾는다 —
622
+ * finalize --yes가 integration만 보던 구멍을 메우기 위한 값이다.
623
+ *
624
+ * @param {object} opts - 정리 결과와 next 조회
625
+ * @param {string} opts.repoRoot - 메인 루트
626
+ * @param {string} opts.bp - 방금 정리한 blueprint
627
+ * @param {string[]} opts.removed - 지운 경로
628
+ * @param {string[]} opts.restored - HEAD로 되돌린 경로
629
+ * @param {string[]} opts.preserved - 손대지 않은 index 경로
630
+ * @param {ReleaseMainDeps} opts.deps - nextBlueprint seam
631
+ * @param {ReleaseMainOk['outside_scope']} [opts.outsideScope] - 분류된 terminal. 없으면 키를 싣지 않는다
632
+ * @returns {ReleaseMainOk} 정렬된 경로와 next. 분류된 terminal이 있으면 outside_scope도 담는다
633
+ */
634
+ function successPayload({ repoRoot, bp, removed, restored, preserved, deps, outsideScope }) {
635
+ const listed = deps.nextBlueprint({ repoRoot, blueprintDir: bp });
636
+ const chain = [];
637
+ if (listed.next)
638
+ chain.push(listed.next);
639
+ if (Array.isArray(listed.remaining))
640
+ chain.push(...listed.remaining);
641
+ const same = chain.find((entry) => entry.sameEpic);
642
+ return {
643
+ ok: true,
644
+ blueprint: bp,
645
+ removed,
646
+ restored,
647
+ preserved,
648
+ next: same ? { blueprint: same.blueprint } : null,
649
+ ...(outsideScope ? { outside_scope: outsideScope } : {}),
650
+ };
651
+ }
652
+ module.exports = { releaseMain };