@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,1148 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const frontmatter = require("./frontmatter");
5
+ const { readDoc } = frontmatter;
6
+ const paths = require("./paths");
7
+ const { epicDirOf, toPosix, isNumericContextId } = paths;
8
+ const contextIds = require("./context-ids");
9
+ const { parseContextPath, parsePointerKey } = contextIds;
10
+ const layout = require("./layout");
11
+ const { isValidBlueprintOrder, listBlueprintOrders } = layout;
12
+ const epicRegistry = require("./epic-registry");
13
+ const { readEpicRegistry } = epicRegistry;
14
+ const runtimeState = require("./runtime-state");
15
+ const { readLegacyRuntimeCurrent, writeRuntimeCurrent, clearRuntimeCurrent, listNamespacePointers, removeNamespacePointer, pointerKeyFromBlueprint, worktreePathFor, runtimePaths, } = runtimeState;
16
+ const tasksDocs = require("./tasks-docs");
17
+ const { listTasksDocs } = tasksDocs;
18
+ const scope = require("./scope");
19
+ const { readCoordinatorLedger } = scope;
20
+ const executionDag = require("./execution-dag");
21
+ const { readyWave } = executionDag;
22
+ const configMod = require("./config");
23
+ const { readCoordinatorPolicy } = configMod;
24
+ const READY_TASK_STATUS = ['ready', 'in_progress'];
25
+ // epic `## Blueprints` 링크 대상(예: `blueprints/BP-001-slug/index.md`)과 매칭.
26
+ // blueprint directory 이름만 캡처; title 텍스트와 한 줄 purpose는 무시.
27
+ const BLUEPRINT_LINK_RE = /\]\(blueprints\/([^/)]+)\/index\.md\)/g;
28
+ // --task 는 세 자리 숫자 또는 TASKS-NNN 만 받는다. 그 외 형식은 해석 실패.
29
+ const TASK_DIGITS_RE = /^(\d{3})$/;
30
+ const TASK_ID_RE = /^TASKS-(\d{3})$/;
31
+ class CurrentSelectionError extends Error {
32
+ code;
33
+ candidates;
34
+ issues;
35
+ constructor({ code, message, candidates, issues, }) {
36
+ super(message || code);
37
+ this.name = 'CurrentSelectionError';
38
+ this.code = code;
39
+ if (candidates)
40
+ this.candidates = candidates;
41
+ if (issues)
42
+ this.issues = issues;
43
+ }
44
+ }
45
+ function storedPointer(pointer) {
46
+ return {
47
+ blueprint: pointer.blueprint,
48
+ base: pointer.base,
49
+ task: pointer.task == null ? null : pointer.task,
50
+ };
51
+ }
52
+ function sortPointers(pointers) {
53
+ return [...pointers].sort((a, b) => a.blueprint.localeCompare(b.blueprint));
54
+ }
55
+ function tryPointerKey(blueprint) {
56
+ try {
57
+ return pointerKeyFromBlueprint(blueprint).key;
58
+ }
59
+ catch (_e) {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * cwd가 `.worktrees/<epic>/<bp>` 중첩인지, `.worktrees/<bp>` 평면인지,
65
+ * 기준 checkout인지를 가른다. 키는 세 자리 id만 본다 — worktree 절대 경로를
66
+ * 키로 쓰면 relocated checkout이 다른 슬롯으로 떨어진다.
67
+ *
68
+ * @param {string} repoRoot - 선택 기준이 되는 checkout 절대 경로
69
+ * @param {ReturnType<typeof runtimePaths>} paths - 같은 호출의 runtimePaths
70
+ * @param {string | undefined} platform - win32일 때만 win32 path API
71
+ * @returns {SelectionLocation} nested / flat / base
72
+ */
73
+ function selectionLocation(repoRoot, paths, platform) {
74
+ const pathApi = platform === 'win32' ? path.win32 : path;
75
+ if (paths.unavailable || !paths.worktreeRoot)
76
+ return { kind: 'base' };
77
+ const absRepo = pathApi.resolve(repoRoot);
78
+ const rel = pathApi.relative(paths.worktreeRoot, absRepo);
79
+ if (!rel || rel === '.' || rel.startsWith('..') || pathApi.isAbsolute(rel)) {
80
+ return { kind: 'base' };
81
+ }
82
+ const parts = rel.split(/[\\/]/).filter(Boolean);
83
+ // 중첩 키는 번호 쌍(`119/001`)과 slug 쌍(`plan-workflow/registry-gates`) 둘 다 받는다. 평면(`.worktrees/<bp>`)은
84
+ // 번호만 받는다 — slug 한 조각은 임의 디렉터리 이름과 구분되지 않아 base checkout을 flat으로 오인한다.
85
+ if (parts.length >= 2 && parsePointerKey(`${parts[0]}/${parts[1]}`) !== null) {
86
+ return { kind: 'nested', key: `${parts[0]}/${parts[1]}` };
87
+ }
88
+ if (parts.length >= 1 && isNumericContextId(parts[0])) {
89
+ return { kind: 'flat', worktreeAbs: pathApi.join(paths.worktreeRoot, parts[0]) };
90
+ }
91
+ return { kind: 'base' };
92
+ }
93
+ /**
94
+ * cwd가 coordinator worker 슬롯(`.worktrees/<epic>/<bp>/workers/<NNN>`)의
95
+ * 루트인지 본다. 하위 디렉터리·integration·standalone은 null — lease 우선은
96
+ * worker 루트에서만 적용해 pointer를 덮지 않는다.
97
+ *
98
+ * @param {string} repoRoot - 선택 기준 checkout 절대 경로
99
+ * @param {ReturnType<typeof runtimePaths>} paths - 같은 호출의 runtimePaths
100
+ * @param {string | undefined} platform - win32일 때만 win32 path API
101
+ * @returns {WorkerSlot | null} worker면 epic/bp/task id, 아니면 null
102
+ */
103
+ function workerSlotOf(repoRoot, paths, platform) {
104
+ const pathApi = platform === 'win32' ? path.win32 : path;
105
+ if (paths.unavailable || !paths.worktreeRoot)
106
+ return null;
107
+ const absRepo = pathApi.resolve(repoRoot);
108
+ const rel = pathApi.relative(paths.worktreeRoot, absRepo);
109
+ if (!rel || rel === '.' || rel.startsWith('..') || pathApi.isAbsolute(rel)) {
110
+ return null;
111
+ }
112
+ const parts = rel.split(/[\\/]/).filter(Boolean);
113
+ // 정확히 workers/<NNN> 루트만. 하위 cwd는 worker 소유로 보지 않는다.
114
+ if (parts.length === 4
115
+ && parsePointerKey(`${parts[0]}/${parts[1]}`) !== null
116
+ && parts[2] === 'workers'
117
+ && isNumericContextId(parts[3])) {
118
+ return { epicId: parts[0], blueprintId: parts[1], taskId: parts[3] };
119
+ }
120
+ return null;
121
+ }
122
+ function realPathOf(target, fsApi = fs) {
123
+ const resolved = path.resolve(target);
124
+ try {
125
+ return fsApi.realpathSync(resolved);
126
+ }
127
+ catch (_e) {
128
+ // 아직 없는 경로는 비교만 하므로 resolve 값을 쓴다.
129
+ return resolved;
130
+ }
131
+ }
132
+ /**
133
+ * 원장 task가 이 worker cwd의 active 배정인지 본다. active lease가 있거나,
134
+ * lease 없는 legacy는 prepared·recorded일 때만 동일 취급한다.
135
+ *
136
+ * @param {{ workerPath?: string, status?: string, lease?: { status?: string } }} entry - 원장 task
137
+ * @param {string} cwdReal - cwd realpath
138
+ * @param {typeof fs} fsApi - realpath용 fs
139
+ * @returns {boolean} 이 cwd의 유효 배정이면 true
140
+ */
141
+ function isActiveWorkerAssignment(entry, cwdReal, fsApi) {
142
+ if (typeof entry.workerPath !== 'string' || !entry.workerPath)
143
+ return false;
144
+ if (realPathOf(entry.workerPath, fsApi) !== cwdReal)
145
+ return false;
146
+ if (entry.lease && entry.lease.status === 'active')
147
+ return true;
148
+ // lease 부재 legacy: prepared·recorded만 active와 같다. pending·revoked 경로는
149
+ // pointer로 떨어지지 않고 no-active-lease로 거절해야 한다.
150
+ if (entry.lease == null && (entry.status === 'prepared' || entry.status === 'recorded')) {
151
+ return true;
152
+ }
153
+ return false;
154
+ }
155
+ function namespaceConflict(legacy, ns, legacyPath) {
156
+ if (!legacy || ns.length === 0)
157
+ return null;
158
+ const legacyKey = tryPointerKey(legacy.blueprint);
159
+ const foreign = ns.filter((e) => e.key !== legacyKey);
160
+ if (legacyKey && foreign.length === 0 && ns.length === 1 && ns[0].key === legacyKey) {
161
+ // 같은 키의 레거시·namespace 사본은 이관 미완료일 뿐 충돌이 아니다.
162
+ return null;
163
+ }
164
+ return {
165
+ status: 'invalid',
166
+ issues: [{
167
+ path: legacyPath || 'bouncer/current',
168
+ reason: 'legacy and namespace pointers disagree',
169
+ }],
170
+ candidates: sortPointers([legacy, ...ns.map((e) => e.pointer)]),
171
+ };
172
+ }
173
+ /**
174
+ * cwd와 저장 상태에서 활성 포인터를 고른다. 다중 후보·깨진 파일·레거시 충돌은
175
+ * throw하지 않고 union으로 돌려 호출부가 JSON을 그릴 수 있게 한다.
176
+ *
177
+ * @param {{ repoRoot: string, deps?: RuntimeDeps }} opts - repoRoot는 선택 위치
178
+ * @returns {CurrentResolution} selected / empty / ambiguous / invalid
179
+ */
180
+ function resolveCurrent({ repoRoot, deps }) {
181
+ const listed = listNamespacePointers({ repoRoot, deps });
182
+ const issues = listed
183
+ .filter((e) => e.issue)
184
+ .map((e) => e.issue);
185
+ const ns = listed
186
+ .filter((e) => e.pointer != null)
187
+ .map((e) => ({ key: e.key, pointer: storedPointer(e.pointer) }));
188
+ const candidates = sortPointers(ns.map((e) => e.pointer));
189
+ if (issues.length > 0) {
190
+ return { status: 'invalid', issues, candidates };
191
+ }
192
+ const legacy = readLegacyRuntimeCurrent({ repoRoot, deps });
193
+ const paths = runtimePaths({
194
+ repoRoot,
195
+ execFileSync: deps?.execFileSync,
196
+ env: deps?.env,
197
+ platform: deps?.platform,
198
+ });
199
+ const conflict = namespaceConflict(legacy, ns, paths.currentFile);
200
+ if (conflict)
201
+ return conflict;
202
+ if (paths.currentFile) {
203
+ const d = deps || {};
204
+ const fsApi = d.fs || fs;
205
+ // 레거시 파일이 있는데 파싱이 실패하고 namespace가 있으면 합의를 확인할
206
+ // 수 없다. namespace만 보고 고르면 다른 주기의 레거시를 삼킨다.
207
+ if (ns.length > 0 && !legacy && fsApi.existsSync(paths.currentFile)) {
208
+ return {
209
+ status: 'invalid',
210
+ issues: [{ path: paths.currentFile, reason: 'unreadable legacy pointer' }],
211
+ candidates,
212
+ };
213
+ }
214
+ }
215
+ if (ns.length === 0) {
216
+ if (!legacy)
217
+ return { status: 'empty' };
218
+ return { status: 'selected', current: storedPointer(legacy), source: 'legacy', key: null };
219
+ }
220
+ const loc = selectionLocation(repoRoot, paths, deps?.platform);
221
+ if (loc.kind === 'nested') {
222
+ const match = ns.find((e) => e.key === loc.key);
223
+ if (!match)
224
+ return { status: 'empty' };
225
+ return { status: 'selected', current: match.pointer, source: 'namespace', key: loc.key };
226
+ }
227
+ if (loc.kind === 'flat') {
228
+ const matches = ns.filter((e) => {
229
+ try {
230
+ return worktreePathFor({ repoRoot, blueprint: e.pointer.blueprint, deps }) === loc.worktreeAbs;
231
+ }
232
+ catch (_e) {
233
+ return false;
234
+ }
235
+ });
236
+ if (matches.length === 1) {
237
+ return {
238
+ status: 'selected',
239
+ current: matches[0].pointer,
240
+ source: 'namespace',
241
+ key: matches[0].key,
242
+ };
243
+ }
244
+ if (matches.length === 0)
245
+ return { status: 'empty' };
246
+ return { status: 'ambiguous', candidates: sortPointers(matches.map((e) => e.pointer)) };
247
+ }
248
+ if (ns.length === 1) {
249
+ return { status: 'selected', current: ns[0].pointer, source: 'namespace', key: ns[0].key };
250
+ }
251
+ return { status: 'ambiguous', candidates };
252
+ }
253
+ /**
254
+ * 기준(base) checkout에서 저장된 namespace key 목록을 돌려준다. `--set`이 쓰기 전에
255
+ * "새 key를 더하면 base 선택이 모호해지는가"를 판정하는 입력이다. 위치 판정은
256
+ * resolveCurrent와 같은 selectionLocation을 쓰므로 두 경로가 어긋나지 않는다.
257
+ * 읽기 전용이며 깨진 포인터 파일(issue)이 있는 항목은 목록에서 뺀다 — 그런 상태는
258
+ * 호출부가 resolveCurrent의 invalid로 먼저 걸러야 한다.
259
+ *
260
+ * @param {{ repoRoot: string, deps?: RuntimeDeps }} opts - repoRoot는 선택 위치
261
+ * @returns {string[] | null} base 위치면 `<epic>/<bp>` key 목록, nested·flat worktree면 null
262
+ */
263
+ function baseNamespaceKeys({ repoRoot, deps }) {
264
+ const paths = runtimePaths({
265
+ repoRoot,
266
+ execFileSync: deps?.execFileSync,
267
+ env: deps?.env,
268
+ platform: deps?.platform,
269
+ });
270
+ if (selectionLocation(repoRoot, paths, deps?.platform).kind !== 'base')
271
+ return null;
272
+ return listNamespacePointers({ repoRoot, deps })
273
+ .filter((e) => e.pointer != null)
274
+ .map((e) => e.key);
275
+ }
276
+ /**
277
+ * resolveCurrent의 호환 wrapper. 호출부가 단일 포인터 또는 없음을 기대할 때 쓴다.
278
+ *
279
+ * @param {{ repoRoot: string, deps?: RuntimeDeps }} opts - resolveCurrent와 같다
280
+ * @returns {Pointer | null} selected면 Pointer, empty면 null
281
+ * @throws {CurrentSelectionError} ambiguous → CURRENT_AMBIGUOUS, invalid → CURRENT_INVALID
282
+ */
283
+ function readCurrent({ repoRoot, deps }) {
284
+ const resolved = resolveCurrent({ repoRoot, deps });
285
+ if (resolved.status === 'selected')
286
+ return resolved.current;
287
+ if (resolved.status === 'empty')
288
+ return null;
289
+ if (resolved.status === 'ambiguous') {
290
+ throw new CurrentSelectionError({
291
+ code: 'CURRENT_AMBIGUOUS',
292
+ candidates: resolved.candidates,
293
+ });
294
+ }
295
+ throw new CurrentSelectionError({
296
+ code: 'CURRENT_INVALID',
297
+ issues: resolved.issues,
298
+ candidates: resolved.candidates,
299
+ });
300
+ }
301
+ /**
302
+ * 대상 namespace 키를 추가·갱신하고, 같은 키의 레거시 파일이 있으면 이관한다.
303
+ * `--replace`는 선택된 다른 키를 지운 뒤 대상을 쓴다. 서로 다른
304
+ * legacy·namespace 공존은 쓰지 않고 CURRENT_INVALID다. 레거시 삭제 실패는
305
+ * 이미 쓴 namespace를 남긴 채 CURRENT_MIGRATION_INCOMPLETE다.
306
+ *
307
+ * @param {object} opts - repoRoot, blueprint, base, 선택적 task·replace·replaceKey
308
+ * @returns {string} 쓴 namespace 파일 절대 경로
309
+ */
310
+ function writeCurrent({ repoRoot, blueprint, base, task, deps, replace, replaceKey, }) {
311
+ const paths = runtimePaths({
312
+ repoRoot,
313
+ execFileSync: deps?.execFileSync,
314
+ env: deps?.env,
315
+ platform: deps?.platform,
316
+ });
317
+ // Git 부재는 id 검사보다 앞선다. 비저장소에서 경로 형식 오류로 바꾸면
318
+ // 기존 writeCurrent 거절 메시지와 호출부 분기가 갈라진다.
319
+ if (paths.unavailable) {
320
+ throw new Error('Bouncer requires a Git repository for an active blueprint');
321
+ }
322
+ const ids = pointerKeyFromBlueprint(blueprint);
323
+ const listed = listNamespacePointers({ repoRoot, deps });
324
+ const issues = listed.filter((e) => e.issue).map((e) => e.issue);
325
+ const ns = listed.filter((e) => e.pointer != null);
326
+ if (issues.length > 0) {
327
+ throw new CurrentSelectionError({
328
+ code: 'CURRENT_INVALID',
329
+ issues,
330
+ candidates: sortPointers(ns.map((e) => storedPointer(e.pointer))),
331
+ });
332
+ }
333
+ const legacy = readLegacyRuntimeCurrent({ repoRoot, deps });
334
+ if (legacy) {
335
+ const legacyKey = tryPointerKey(legacy.blueprint);
336
+ const nsKeys = new Set(ns.map((e) => e.key));
337
+ const sameKeyOnly = legacyKey != null
338
+ && nsKeys.size <= 1
339
+ && (nsKeys.size === 0 || nsKeys.has(legacyKey));
340
+ const targetMatchesLegacy = legacyKey === ids.key;
341
+ if (!targetMatchesLegacy && !replace) {
342
+ throw new CurrentSelectionError({
343
+ code: 'CURRENT_INVALID',
344
+ issues: [{
345
+ path: paths.currentFile || 'bouncer/current',
346
+ reason: 'legacy and namespace pointers disagree',
347
+ }],
348
+ candidates: sortPointers([storedPointer(legacy), ...ns.map((e) => storedPointer(e.pointer))]),
349
+ });
350
+ }
351
+ if (nsKeys.size > 0 && !sameKeyOnly && !replace) {
352
+ throw new CurrentSelectionError({
353
+ code: 'CURRENT_INVALID',
354
+ issues: [{
355
+ path: paths.currentFile || 'bouncer/current',
356
+ reason: 'legacy and namespace pointers disagree',
357
+ }],
358
+ candidates: sortPointers([storedPointer(legacy), ...ns.map((e) => storedPointer(e.pointer))]),
359
+ });
360
+ }
361
+ }
362
+ // --replace는 선택된 키를 먼저 지운 뒤 대상을 쓴다. 새 파일을 먼저 쓰면
363
+ // 삭제 실패 시 두 키가 공존하고 어느 쪽이 활성인지 진단이 안 된다.
364
+ if (replace && replaceKey && replaceKey !== ids.key) {
365
+ const pathApi = deps?.platform === 'win32' ? path.win32 : path;
366
+ const keyParts = parsePointerKey(replaceKey);
367
+ const replacePath = keyParts && paths.pointersRoot
368
+ ? pathApi.join(paths.pointersRoot, keyParts.epicKey, `${keyParts.blueprintKey}.json`)
369
+ : replaceKey;
370
+ try {
371
+ removeNamespacePointer({ repoRoot, key: replaceKey, deps });
372
+ }
373
+ catch (error) {
374
+ // rmSync 실패(EACCES 등)만 접는다. 두 키를 남긴 채 raw throw하면 CLI가
375
+ // 모호성 JSON 없이 무너지고, 호출부는 어느 파일이 남았는지 모른다.
376
+ throw new CurrentSelectionError({
377
+ code: 'CURRENT_INVALID',
378
+ issues: [{
379
+ path: replacePath,
380
+ reason: catchSelectionMessage(error),
381
+ }],
382
+ candidates: sortPointers(ns.map((e) => storedPointer(e.pointer))),
383
+ });
384
+ }
385
+ }
386
+ const written = writeRuntimeCurrent({
387
+ repoRoot, blueprint, base, task, deps,
388
+ });
389
+ if (legacy && paths.currentFile) {
390
+ const d = { fs, ...(deps || {}) };
391
+ try {
392
+ if (d.fs.existsSync(paths.currentFile))
393
+ d.fs.rmSync(paths.currentFile);
394
+ }
395
+ catch (error) {
396
+ // 레거시 unlink 실패만 이관 미완료로 접는다. namespace 사본은 이미 같은
397
+ // 본문이라 다음 동일 write가 삭제만 재시도하면 된다. 다른 오류를
398
+ // 성공으로 삼키면 부분 이관이 조용히 남는다.
399
+ throw new CurrentSelectionError({
400
+ code: 'CURRENT_MIGRATION_INCOMPLETE',
401
+ message: catchSelectionMessage(error),
402
+ candidates: sortPointers([
403
+ storedPointer(legacy),
404
+ storedPointer({
405
+ blueprint: toPosix(blueprint),
406
+ base,
407
+ task: typeof task === 'string' ? toPosix(task) : null,
408
+ }),
409
+ ]),
410
+ });
411
+ }
412
+ }
413
+ return written;
414
+ }
415
+ function catchSelectionMessage(error) {
416
+ return error.message || 'CURRENT_MIGRATION_INCOMPLETE';
417
+ }
418
+ /**
419
+ * 현재 위치에서 유일하게 선택된 포인터만 지운다. 다중 후보·깨진 파일은
420
+ * 아무 키도 지우지 않고 throw한다 — 한쪽 clear가 다른 주기를 삼키지 않게.
421
+ *
422
+ * @param {{ repoRoot: string, deps?: RuntimeDeps }} opts - 선택 위치와 주입 의존성
423
+ * @returns {boolean} 지웠으면 true, 선택된 포인터가 없었으면 false
424
+ */
425
+ function clearCurrent({ repoRoot, deps }) {
426
+ const resolved = resolveCurrent({ repoRoot, deps });
427
+ if (resolved.status === 'empty')
428
+ return false;
429
+ if (resolved.status === 'ambiguous') {
430
+ throw new CurrentSelectionError({
431
+ code: 'CURRENT_AMBIGUOUS',
432
+ candidates: resolved.candidates,
433
+ });
434
+ }
435
+ if (resolved.status === 'invalid') {
436
+ throw new CurrentSelectionError({
437
+ code: 'CURRENT_INVALID',
438
+ issues: resolved.issues,
439
+ candidates: resolved.candidates,
440
+ });
441
+ }
442
+ if (resolved.source === 'legacy') {
443
+ return clearRuntimeCurrent({ repoRoot, deps });
444
+ }
445
+ if (!resolved.key)
446
+ return false;
447
+ return removeNamespacePointer({ repoRoot, key: resolved.key, deps });
448
+ }
449
+ function bouncerOf(data) {
450
+ // `data && data.bouncer`와 같다. `'bouncer' in data`로 바꾸면 프로토타입
451
+ // 필드가 생기고, data가 null일 때 예전처럼 단락되지 않을 수 있다.
452
+ return data ? data.bouncer : data;
453
+ }
454
+ function bouncerStatus(data) {
455
+ const bouncer = bouncerOf(data);
456
+ return bouncer ? bouncer.status : undefined;
457
+ }
458
+ function availableTaskEntries(listing) {
459
+ return listing.entries
460
+ .filter((entry) => typeof entry.id === 'string' && !!entry.id)
461
+ .map((entry) => ({ id: entry.id, path: entry.rel }));
462
+ }
463
+ /**
464
+ * blueprint `index.md`의 `bouncer.scale`을 응답 전용으로 읽는다.
465
+ * 포인터 파일에 넣지 않는 이유: 문서 수정 후 stale. 호출 시점마다 다시 계산한다.
466
+ * enum 검사는 S20의 몫 — 알 수 없는 문자열도 그대로 노출하고, 읽기/파싱 실패는 null.
467
+ *
468
+ * @param {string} repoRoot - 저장소 루트 절대 경로
469
+ * @param {string} blueprintDir - 포인터의 blueprint 상대 경로
470
+ * @returns {string | null} 문자열 scale, 없거나 문자열이 아니면 null
471
+ */
472
+ function readBlueprintScale(repoRoot, blueprintDir) {
473
+ try {
474
+ const doc = readDoc(path.join(repoRoot, blueprintDir, 'index.md'));
475
+ const bouncer = bouncerOf(doc.data);
476
+ const scale = bouncer ? bouncer.scale : undefined;
477
+ return typeof scale === 'string' ? scale : null;
478
+ }
479
+ catch (_e) {
480
+ return null;
481
+ }
482
+ }
483
+ /**
484
+ * coordinator 실행 중이면 다음에 열 수 있는 task는 번호 순 하나가 아니라
485
+ * ledger의 ready wave다. 원장이 아예 없으면 null을 돌려주고, 호출자는 응답에
486
+ * `coordinator` 키 자체를 붙이지 않아 기존 sequential payload를 유지한다.
487
+ *
488
+ * 읽을 수는 있으나 깨진 원장은 null이 아니다. 그 상태가 바로 모든 checkout의
489
+ * 커밋을 막는 원인이므로, 운영자가 어느 파일을 고쳐야 하는지 보이도록
490
+ * `status: 'unreadable'`과 경로를 그대로 싣는다.
491
+ *
492
+ * @param {string} repoRoot - 저장소 루트 절대 경로
493
+ * @param {string} blueprint - 포인터의 blueprint 상대 경로
494
+ * @returns {object | null} 원장 스냅샷 또는 null
495
+ */
496
+ function coordinatorSnapshot(repoRoot, blueprint) {
497
+ const found = readCoordinatorLedger({ repoRoot, blueprint });
498
+ if (!found.ok) {
499
+ if (found.reason !== 'unreadable-ledger')
500
+ return null;
501
+ return {
502
+ status: 'unreadable',
503
+ ledgerFile: found.ledgerFile || null,
504
+ integrationPath: found.integrationPath || null,
505
+ revision: null,
506
+ integrationHead: null,
507
+ ready: [],
508
+ tasks: [],
509
+ };
510
+ }
511
+ const ledger = found.ledger;
512
+ const tasks = Array.isArray(ledger.tasks) ? ledger.tasks : [];
513
+ // integration checkout의 config가 정본이다. invalid면 1로 접어 ready 표시가
514
+ // prepare보다 넓은 병렬 wave를 약속하지 않게 한다.
515
+ const policyRoot = typeof found.integrationPath === 'string' && found.integrationPath
516
+ ? found.integrationPath
517
+ : repoRoot;
518
+ const policy = readCoordinatorPolicy(policyRoot);
519
+ const maxParallel = policy.ok ? policy.maxParallel : 1;
520
+ return {
521
+ status: 'ok',
522
+ ledgerFile: found.ledgerFile,
523
+ integrationPath: found.integrationPath,
524
+ revision: typeof ledger.revision === 'string' ? ledger.revision : null,
525
+ integrationHead: typeof ledger.integrationHead === 'string' ? ledger.integrationHead : null,
526
+ terminalStatus: ledger.status === 'partial_closed' || ledger.status === 'awaiting_confirmation'
527
+ ? ledger.status : null,
528
+ terminalFailure: ledger.terminalFailure || null,
529
+ ready: readyWave(tasks, { maxParallel }),
530
+ tasks: tasks.map((task) => ({
531
+ id: task.id,
532
+ executionKind: task.execution_kind || 'commit',
533
+ status: task.status || 'pending',
534
+ revision: task.scope ? task.scope.revision : null,
535
+ scope: task.scope ? task.scope.paths : null,
536
+ })),
537
+ };
538
+ }
539
+ /**
540
+ * cwd와 원장 lease로 소비자가 쓸 effective task를 고른다. worker 슬롯에서는
541
+ * 공유 pointer보다 lease가 우선이고, lease를 확인하지 못하면 pointer로
542
+ * 떨어지지 않는다. worker가 아니면 기존 pointer.task만 본다.
543
+ *
544
+ * 원장은 읽기만 한다. `pointer`를 넘기면 readCurrent를 다시 호출하지 않는다 —
545
+ * presentCurrent가 --set 직후 방금 쓴 키를 보여 줄 때 base의 병렬 포인터로
546
+ * CURRENT_AMBIGUOUS가 나지 않게 하기 위함이다. 생략 시 readCurrent의
547
+ * CURRENT_AMBIGUOUS·CURRENT_INVALID는 그대로 전파한다.
548
+ *
549
+ * @param {{ repoRoot: string, deps?: RuntimeDeps, pointer?: Pointer | null }} opts
550
+ * @returns {EffectiveTask | null} lease·pointer 결과. pointer task가 없으면 null
551
+ */
552
+ function resolveEffectiveTask({ repoRoot, deps, pointer: pointerOpt }) {
553
+ const d = deps || {};
554
+ const fsApi = d.fs || fs;
555
+ // pointer 인자는 presentCurrent 전용 seam. 소비자는 생략해 readCurrent를 탄다.
556
+ const pointer = pointerOpt !== undefined ? pointerOpt : readCurrent({ repoRoot, deps });
557
+ const paths = runtimePaths({
558
+ repoRoot,
559
+ execFileSync: d.execFileSync,
560
+ env: d.env,
561
+ platform: d.platform,
562
+ });
563
+ const slot = workerSlotOf(repoRoot, paths, d.platform);
564
+ if (slot) {
565
+ const pathApi = d.platform === 'win32' ? path.win32 : path;
566
+ const pointerBlueprint = pointer && typeof pointer.blueprint === 'string'
567
+ ? pointer.blueprint
568
+ : '';
569
+ const fail = (reason, blueprint = pointerBlueprint) => ({
570
+ source: null, blueprint, reason, path: null, id: null,
571
+ });
572
+ // pointer가 없어도 worker 슬롯의 integration 원장으로 lease를 판정한다.
573
+ // coordinatorFixture처럼 prepare 직후·--set 전에 worker에 문서를 심는
574
+ // 경로가 pointer 없음을 no-active-lease로 오인하지 않게 한다.
575
+ let found;
576
+ if (pointerBlueprint) {
577
+ found = readCoordinatorLedger({ repoRoot, blueprint: pointerBlueprint });
578
+ }
579
+ else if (paths.worktreeRoot) {
580
+ const ledgerFile = pathApi.join(paths.worktreeRoot, slot.epicId, slot.blueprintId, 'integration', '.bouncer', 'runtime', 'coordinator.json');
581
+ let raw;
582
+ try {
583
+ raw = fsApi.readFileSync(ledgerFile, 'utf8');
584
+ }
585
+ catch (_e) {
586
+ // ENOENT 등 — 원장 부재는 lease 확인 실패와 같다.
587
+ return fail('no-active-lease');
588
+ }
589
+ let ledger;
590
+ try {
591
+ ledger = JSON.parse(raw);
592
+ }
593
+ catch (_e) {
594
+ // JSON 손상만 unreadable. 빈 파일·절단본도 여기로 온다.
595
+ return fail('unreadable-ledger');
596
+ }
597
+ if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)) {
598
+ return fail('unreadable-ledger');
599
+ }
600
+ found = {
601
+ ok: true,
602
+ ledger,
603
+ ledgerFile,
604
+ integrationPath: pathApi.join(paths.worktreeRoot, slot.epicId, slot.blueprintId, 'integration'),
605
+ };
606
+ }
607
+ else {
608
+ return fail('no-active-lease');
609
+ }
610
+ if (!found.ok) {
611
+ // 원장 손상만 unreadable. 부재·경로 없음은 lease를 확인하지 못한 것과 같다.
612
+ if (found.reason === 'unreadable-ledger') {
613
+ return fail('unreadable-ledger', pointerBlueprint);
614
+ }
615
+ return fail('no-active-lease', pointerBlueprint);
616
+ }
617
+ const ledger = found.ledger;
618
+ const blueprint = pointerBlueprint
619
+ || (typeof ledger.blueprint === 'string' ? ledger.blueprint : '');
620
+ if (!blueprint)
621
+ return fail('no-active-lease');
622
+ const tasks = Array.isArray(ledger.tasks) ? ledger.tasks : [];
623
+ const entry = tasks.find((item) => item && item.id === slot.taskId);
624
+ const cwdReal = realPathOf(repoRoot, fsApi);
625
+ if (!entry || !isActiveWorkerAssignment(entry, cwdReal, fsApi)) {
626
+ return fail('no-active-lease', toPosix(blueprint));
627
+ }
628
+ const taskPath = `${toPosix(blueprint)}/tasks/${slot.taskId}/tasks.md`;
629
+ let id = `TASKS-${slot.taskId}`;
630
+ try {
631
+ const listing = listTasksDocs({ repoRoot, blueprintDir: blueprint });
632
+ const listed = listing.entries.find((e) => e.rel === taskPath);
633
+ if (listed && typeof listed.id === 'string')
634
+ id = listed.id;
635
+ }
636
+ catch (_e) {
637
+ // listing 실패 시 경로 기반 TASKS-NNN을 유지한다.
638
+ }
639
+ const lease = entry.lease;
640
+ return {
641
+ source: 'lease',
642
+ blueprint: toPosix(blueprint),
643
+ path: taskPath,
644
+ id,
645
+ lease_id: lease && typeof lease.id === 'string' ? lease.id : null,
646
+ generation: lease && Number.isInteger(lease.generation) ? lease.generation : null,
647
+ };
648
+ }
649
+ // non-worker: 원장 lease로 pointer를 덮지 않는다.
650
+ if (!pointer)
651
+ return null;
652
+ const taskPath = typeof pointer.task === 'string' && pointer.task ? toPosix(pointer.task) : null;
653
+ if (!taskPath)
654
+ return null;
655
+ let id = null;
656
+ try {
657
+ const listing = listTasksDocs({ repoRoot, blueprintDir: pointer.blueprint });
658
+ const entry = listing.entries.find((e) => e.rel === taskPath);
659
+ if (entry && typeof entry.id === 'string')
660
+ id = entry.id;
661
+ }
662
+ catch (_e) {
663
+ // listing 실패 시 id 없이 path만.
664
+ }
665
+ return {
666
+ source: 'pointer',
667
+ blueprint: toPosix(pointer.blueprint),
668
+ path: taskPath,
669
+ id,
670
+ };
671
+ }
672
+ /**
673
+ * CLI 출력용. 포인터 파일의 task 는 rel path 문자열만 보관하고,
674
+ * `bouncer current` 응답에는 경로와 TASKS-NNN id, 그리고 호출 시점의 `scale`
675
+ * 파생값을 함께 실어 Interface 계약을 맞춘다.
676
+ * 문서가 사라져 id 를 못 찾으면 path 만 남기고 id 는 null — 포인터를 지우지 않는다.
677
+ * scale 읽기 실패도 같다: null 로 흡수하고 포인터는 유지한다.
678
+ * `effectiveTask`는 lease 우선 판정이며, 기존 task·base 키는 pointer 그대로다.
679
+ *
680
+ * @param {Pointer | null | undefined} current - 포인터 파일 내용. 없으면 null
681
+ * @param {{ repoRoot: string }} opts - repoRoot 는 blueprint index 절대 경로 계산용
682
+ * @returns {object | null} task 없음: `{ blueprint, base, task: null, scale, effectiveTask }`.
683
+ * task 있음: `{ blueprint, base, task: { path, id }, scale, effectiveTask }`. 포인터 없으면 null.
684
+ */
685
+ function presentCurrent(current, { repoRoot }) {
686
+ if (!current)
687
+ return null;
688
+ const scale = readBlueprintScale(repoRoot, current.blueprint);
689
+ const coordinator = coordinatorSnapshot(repoRoot, current.blueprint);
690
+ // 원장이 없으면 키를 붙이지 않는다 — 기존 4-키 payload 계약을 그대로 둔다.
691
+ const coordinatorField = coordinator ? { coordinator } : {};
692
+ // effectiveTask는 항상 붙인다. 넘긴 current를 재사용해 base 병렬 포인터에서도
693
+ // --set 응답이 CURRENT_AMBIGUOUS로 뒤집히지 않게 한다.
694
+ const effectiveTask = resolveEffectiveTask({ repoRoot, pointer: current });
695
+ const taskPath = typeof current.task === 'string' && current.task ? current.task : null;
696
+ if (!taskPath) {
697
+ return {
698
+ blueprint: current.blueprint, base: current.base, task: null, scale,
699
+ effectiveTask, ...coordinatorField,
700
+ };
701
+ }
702
+ let id = null;
703
+ try {
704
+ const listing = listTasksDocs({ repoRoot, blueprintDir: current.blueprint });
705
+ const entry = listing.entries.find((e) => e.rel === taskPath);
706
+ if (entry && typeof entry.id === 'string')
707
+ id = entry.id;
708
+ }
709
+ catch (_e) {
710
+ // listing 실패 시 id 없이 path 만 노출.
711
+ }
712
+ return {
713
+ blueprint: current.blueprint,
714
+ base: current.base,
715
+ task: { path: taskPath, id },
716
+ scale,
717
+ effectiveTask,
718
+ ...coordinatorField,
719
+ };
720
+ }
721
+ /**
722
+ * --set 시 task 해석.
723
+ * - taskSpec 없음: 번호 순 첫 ready/in_progress. 없으면 task 미지정(ok + null).
724
+ * - NNN / TASKS-NNN: 해당 문서. 없거나 형식이 틀리면 ok:false + available 목록.
725
+ * mixed / 문서 없음: 명시 요청이면 실패, 자동이면 선택 없음.
726
+ */
727
+ function resolvePointerTask({ repoRoot, blueprintDir, task: taskSpec }) {
728
+ const listing = listTasksDocs({ repoRoot, blueprintDir });
729
+ const available = availableTaskEntries(listing);
730
+ const requested = taskSpec !== undefined && taskSpec !== null && taskSpec !== '';
731
+ if (listing.mixed || listing.entries.length === 0) {
732
+ if (requested) {
733
+ return { ok: false, available, reason: 'no matching task document' };
734
+ }
735
+ return { ok: true, task: null, id: null };
736
+ }
737
+ if (requested) {
738
+ const raw = String(taskSpec);
739
+ let wantId = null;
740
+ const digits = TASK_DIGITS_RE.exec(raw);
741
+ const idMatch = TASK_ID_RE.exec(raw);
742
+ if (digits)
743
+ wantId = `TASKS-${digits[1]}`;
744
+ else if (idMatch)
745
+ wantId = `TASKS-${idMatch[1]}`;
746
+ else {
747
+ return { ok: false, available, reason: 'invalid task id' };
748
+ }
749
+ const entry = listing.entries.find((e) => e.id === wantId);
750
+ if (!entry) {
751
+ return { ok: false, available, reason: 'no matching task document' };
752
+ }
753
+ return { ok: true, task: entry.rel, id: entry.id };
754
+ }
755
+ // 자동 선택: listTasksDocs 가 이미 번호 순이므로 첫 열린 문서를 고른다.
756
+ for (const entry of listing.entries) {
757
+ try {
758
+ const doc = readDoc(path.join(repoRoot, entry.rel));
759
+ const st = bouncerStatus(doc.data);
760
+ if (READY_TASK_STATUS.includes(st)) {
761
+ return { ok: true, task: entry.rel, id: entry.id };
762
+ }
763
+ }
764
+ catch (_e) {
765
+ // 깨진 문서는 건너뛰고 다음 후보를 본다.
766
+ }
767
+ }
768
+ return { ok: true, task: null, id: null };
769
+ }
770
+ // approved blueprint 중 execute가 아직 열린 tasks만 side-effect 없이 스캔.
771
+ // 깨지거나 읽을 수 없는 doc은 항목별로 skip하여 corrupt blueprint 하나가
772
+ // ready list 전체를 지우지 않게 함 (pointer가 null일 때 execute가
773
+ // "planned but unset"과 "nothing planned"를 구분하는 데 사용).
774
+ function listReadyBlueprints({ repoRoot }) {
775
+ const list = [];
776
+ const epicsRoot = path.join(repoRoot, '.bouncer', 'context', 'epics');
777
+ if (!fs.existsSync(epicsRoot))
778
+ return list;
779
+ let epicNames;
780
+ try {
781
+ epicNames = fs.readdirSync(epicsRoot);
782
+ }
783
+ catch (_e) {
784
+ return list;
785
+ }
786
+ for (const epicName of epicNames) {
787
+ const blueprintsRoot = path.join(epicsRoot, epicName, 'blueprints');
788
+ if (!fs.existsSync(blueprintsRoot))
789
+ continue;
790
+ let bpNames;
791
+ try {
792
+ bpNames = fs.readdirSync(blueprintsRoot);
793
+ }
794
+ catch (_e) {
795
+ continue;
796
+ }
797
+ for (const bpName of bpNames) {
798
+ const bpAbs = path.join(blueprintsRoot, bpName);
799
+ let st;
800
+ try {
801
+ st = fs.statSync(bpAbs);
802
+ }
803
+ catch (_e) {
804
+ continue;
805
+ }
806
+ if (!st.isDirectory())
807
+ continue;
808
+ const rel = toPosix(path.relative(repoRoot, bpAbs));
809
+ try {
810
+ const indexDoc = readDoc(path.join(bpAbs, 'index.md'));
811
+ const bpStatus = bouncerStatus(indexDoc.data);
812
+ if (bpStatus !== 'approved')
813
+ continue;
814
+ // ready = task 문서 중 하나라도 ready/in_progress.
815
+ // 열린 task 목록은 --set 자동 선택·finalize 다음-task 확인이 같이 쓴다.
816
+ const listing = listTasksDocs({ repoRoot, blueprintDir: rel });
817
+ if (listing.mixed || listing.entries.length === 0)
818
+ continue;
819
+ const openTasks = [];
820
+ for (const entry of listing.entries) {
821
+ const tasksDoc = readDoc(path.join(repoRoot, entry.rel));
822
+ const taskStatus = bouncerStatus(tasksDoc.data);
823
+ if (READY_TASK_STATUS.includes(taskStatus) && entry.id) {
824
+ openTasks.push({ id: entry.id, path: entry.rel, status: taskStatus });
825
+ }
826
+ }
827
+ if (openTasks.length > 0) {
828
+ list.push({
829
+ blueprint: rel,
830
+ status: openTasks[0].status,
831
+ tasks: openTasks,
832
+ });
833
+ }
834
+ }
835
+ catch (_e) {
836
+ // 이 blueprint만 skip — 형제는 계속 스캔.
837
+ }
838
+ }
839
+ }
840
+ list.sort((a, b) => a.blueprint.localeCompare(b.blueprint));
841
+ return list;
842
+ }
843
+ // epic index의 `## Blueprints` section만 읽어 blueprint directory 이름을
844
+ // 링크 등장 순으로 반환. section 없음/읽기 실패 → [] (throw 없음):
845
+ // caller는 path lexicographic order로 fallback.
846
+ function parseEpicBlueprintOrder(epicIndexAbs) {
847
+ let text;
848
+ try {
849
+ text = fs.readFileSync(epicIndexAbs, 'utf8');
850
+ }
851
+ catch (_e) {
852
+ return [];
853
+ }
854
+ // YAML frontmatter를 제거해 그 안의 `## Blueprints` 문자열이 이기지 않게 함.
855
+ const fmEnd = text.indexOf('\n---\n');
856
+ const body = fmEnd >= 0 ? text.slice(fmEnd + 5) : text;
857
+ const sectionMatch = /^## Blueprints\s*$/m.exec(body);
858
+ if (!sectionMatch)
859
+ return [];
860
+ const start = sectionMatch.index + sectionMatch[0].length;
861
+ // 다음 ATX h2가 이 section을 끝냄; 더 깊은 heading은 무시.
862
+ const rest = body.slice(start);
863
+ const nextH2 = /^## /m.exec(rest);
864
+ const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
865
+ const names = [];
866
+ BLUEPRINT_LINK_RE.lastIndex = 0;
867
+ let m;
868
+ while ((m = BLUEPRINT_LINK_RE.exec(section)) !== null) {
869
+ names.push(m[1]);
870
+ }
871
+ return names;
872
+ }
873
+ function readAffectedPaths(repoRoot, blueprintDir) {
874
+ // nextBlueprint sharedPaths 용: blueprint 전체 경로 합집합.
875
+ // 커밋 가드의 좁히기는 commit-hook.readAffectedPaths 가 포인터 task 를 본다.
876
+ try {
877
+ const listing = listTasksDocs({ repoRoot, blueprintDir });
878
+ if (listing.mixed || listing.entries.length === 0)
879
+ return [];
880
+ const out = [];
881
+ const seen = new Set();
882
+ for (const entry of listing.entries) {
883
+ try {
884
+ const doc = readDoc(path.join(repoRoot, entry.rel));
885
+ const bouncer = bouncerOf(doc.data);
886
+ const paths = bouncer
887
+ ? bouncer.affected_paths
888
+ : undefined;
889
+ if (!Array.isArray(paths))
890
+ continue;
891
+ for (const p of paths) {
892
+ if (typeof p === 'string' && !seen.has(p)) {
893
+ seen.add(p);
894
+ out.push(p);
895
+ }
896
+ }
897
+ }
898
+ catch (_e) {
899
+ // 깨진 task 문서 하나는 건너뛰고 나머지 합집합을 유지.
900
+ }
901
+ }
902
+ return out;
903
+ }
904
+ catch (_e) {
905
+ return [];
906
+ }
907
+ }
908
+ // slug epic의 blueprint 순서는 epic 본문 목록이 아니라 각 blueprint index의 frontmatter `order`가 정한다.
909
+ // 번호 epic(과 `E-1` 같은 해석 불가 이름)은 지금 규칙을 그대로 쓰므로 이 판정이 true일 때만 새 규칙을 탄다.
910
+ function isSlugEpicDir(epicRel) {
911
+ const ids = parseContextPath(epicRel);
912
+ return ids !== null && ids.format === 'slug' && ids.blueprintKey === null;
913
+ }
914
+ /**
915
+ * slug epic 아래 blueprint 이름 → 쓸 수 있는 `order`. 정렬 전용 읽기라 실패하면 빈 표로 접는다 —
916
+ * order를 못 읽으면 모두 "order 없음"이 되어 경로순으로 떨어질 뿐, 후보 집합(별도 스캔)은 바뀌지 않으므로
917
+ * 닫힌 blueprint를 고르는 일은 생기지 않는다. 이 모듈의 정렬 계산은 throw하지 않는 계약이다.
918
+ *
919
+ * @param {string} repoRoot - 저장소 루트 절대 경로
920
+ * @param {string} epicRel - 저장소 상대 slug epic 디렉터리
921
+ * @returns {Map<string, number>} 양의 정수 order를 가진 blueprint만 담은 표
922
+ */
923
+ function blueprintOrderTable(repoRoot, epicRel) {
924
+ const table = new Map();
925
+ let entries;
926
+ try {
927
+ entries = listBlueprintOrders(repoRoot, epicRel);
928
+ }
929
+ catch (_e) {
930
+ // EACCES 등 열거 실패만 접는다(위 docstring 사유).
931
+ return table;
932
+ }
933
+ for (const entry of entries) {
934
+ if (isValidBlueprintOrder(entry.order))
935
+ table.set(entry.name, entry.order);
936
+ }
937
+ return table;
938
+ }
939
+ /**
940
+ * 같은 slug epic 안의 두 blueprint를 비교한다: order 오름차순, 같거나 order가 없으면(없는 쪽이 뒤) 경로순.
941
+ *
942
+ * @param {Map<string, number>} table - blueprintOrderTable 결과
943
+ * @param {{ bpName: string, blueprint: string }} a - 비교 대상
944
+ * @param {{ bpName: string, blueprint: string }} b - 비교 대상
945
+ * @returns {number} 정렬 비교 값
946
+ */
947
+ function compareBySlugOrder(table, a, b) {
948
+ const ao = table.get(a.bpName) ?? Number.POSITIVE_INFINITY;
949
+ const bo = table.get(b.bpName) ?? Number.POSITIVE_INFINITY;
950
+ if (ao !== bo)
951
+ return ao < bo ? -1 : 1;
952
+ return a.blueprint.localeCompare(b.blueprint);
953
+ }
954
+ /**
955
+ * finalize 대상과 같은 epic에 남은 미마감 형제를 보고용으로 스캔한다.
956
+ * `--set` 후보 조건은 다시 구현하지 않는다 — `ready`는 같은 호출의
957
+ * `listReadyBlueprints` 결과에 경로가 있는지로만 붙인다. draft처럼 후보가
958
+ * 아닌 형제도 인계 확인에 떠야 하고, 후보 조건을 복제하면 `bouncer current`
959
+ * 의 ready 목록과 어긋날 수 있기 때문.
960
+ *
961
+ * @param {string} repoRoot - 저장소 루트 절대 경로
962
+ * @param {unknown} blueprintDir - finalize 대상 blueprint 상대 경로
963
+ * @param {Array<{ blueprint: string }>} readyBlueprints - 같은 호출의 `listReadyBlueprints` 결과
964
+ * @returns {SameEpicPendingEntry[]} `blueprint` 경로 사전순. 자신·`closed`·다른 epic·깨진 `index.md`는 제외하며 항상 배열
965
+ */
966
+ function listSameEpicPending({ repoRoot, blueprintDir, readyBlueprints, }) {
967
+ const selfRaw = String(blueprintDir);
968
+ const selfPosix = toPosix(selfRaw);
969
+ const selfEpic = epicDirOf(selfPosix);
970
+ const pending = [];
971
+ const blueprintsRoot = path.join(repoRoot, selfEpic, 'blueprints');
972
+ if (!fs.existsSync(blueprintsRoot))
973
+ return pending;
974
+ let bpNames;
975
+ try {
976
+ bpNames = fs.readdirSync(blueprintsRoot);
977
+ }
978
+ catch (_e) {
979
+ return pending;
980
+ }
981
+ // 경로 문자열 equality만 — listReadyBlueprints가 이미 POSIX rel을 씀.
982
+ const readySet = new Set(readyBlueprints.map((entry) => entry.blueprint));
983
+ for (const bpName of bpNames) {
984
+ const bpAbs = path.join(blueprintsRoot, bpName);
985
+ let st;
986
+ try {
987
+ st = fs.statSync(bpAbs);
988
+ }
989
+ catch (_e) {
990
+ continue;
991
+ }
992
+ if (!st.isDirectory())
993
+ continue;
994
+ const rel = toPosix(path.relative(repoRoot, bpAbs));
995
+ // nextBlueprint 자기 제외와 동일: 정규화 전 문자열과 POSIX 경로 모두.
996
+ if (rel === selfRaw || rel === selfPosix)
997
+ continue;
998
+ try {
999
+ const indexDoc = readDoc(path.join(bpAbs, 'index.md'));
1000
+ const bpStatus = bouncerStatus(indexDoc.data);
1001
+ // closed는 터미널 — 잔여 인계에 올리면 이미 끝난 계획을 다시 고르게 됨.
1002
+ // 문자열이 아니면 상태를 보고할 수 없으므로 이 항목만 건너뛴다.
1003
+ if (typeof bpStatus !== 'string' || bpStatus === 'closed' || bpStatus === 'partial_closed')
1004
+ continue;
1005
+ pending.push({
1006
+ blueprint: rel,
1007
+ blueprintStatus: bpStatus,
1008
+ ready: readySet.has(rel),
1009
+ });
1010
+ }
1011
+ catch (_e) {
1012
+ // 깨진 index 하나가 잔여 목록 전체를 지우면 안 됨.
1013
+ }
1014
+ }
1015
+ if (isSlugEpicDir(selfEpic)) {
1016
+ // slug epic의 형제 인계 목록은 order 순서를 따른다. 번호 epic은 지금처럼 경로 사전순.
1017
+ const table = blueprintOrderTable(repoRoot, selfEpic);
1018
+ const withName = (entry) => ({
1019
+ blueprint: entry.blueprint, bpName: entry.blueprint.split('/').pop(),
1020
+ });
1021
+ pending.sort((a, b) => compareBySlugOrder(table, withName(a), withName(b)));
1022
+ }
1023
+ else {
1024
+ pending.sort((a, b) => a.blueprint.localeCompare(b.blueprint));
1025
+ }
1026
+ return pending;
1027
+ }
1028
+ // finalize 대상 이후 다음 ready blueprint 계산 — 순수 계산, write/git/process
1029
+ // 없음. 후보는 listReadyBlueprints에서만; 정렬은 finalized epic 우선, 다음
1030
+ // ## Blueprints link order, epic 내 미등록은 path lexicographic, 그다음
1031
+ // 다른 epic은 epic dir name 순. slug epic은 본문 목록 대신 frontmatter order를
1032
+ // 따르고(같으면 path), finalize 대상이 slug epic이고 등록부가 읽히면 다른 epic도
1033
+ // epics.json 배열 순이다(등록부에 없는 epic은 뒤, 이름순). sameEpicPending은
1034
+ // 같은 epic의 미마감 형제 보고용이며 후보 필터와 독립이다.
1035
+ function nextBlueprint({ repoRoot, blueprintDir }) {
1036
+ const selfRaw = String(blueprintDir);
1037
+ const selfPosix = toPosix(selfRaw);
1038
+ const selfEpic = epicDirOf(selfPosix);
1039
+ const readyAll = listReadyBlueprints({ repoRoot });
1040
+ const sameEpicPending = listSameEpicPending({
1041
+ repoRoot, blueprintDir, readyBlueprints: readyAll,
1042
+ });
1043
+ const ready = readyAll.filter((entry) => {
1044
+ const bp = entry.blueprint;
1045
+ return bp !== selfRaw && bp !== selfPosix;
1046
+ });
1047
+ const ranked = ready.map((entry) => {
1048
+ const bp = entry.blueprint;
1049
+ const epic = epicDirOf(bp);
1050
+ return {
1051
+ blueprint: bp,
1052
+ epic,
1053
+ sameEpic: epic === selfEpic,
1054
+ bpName: bp.split('/').pop() || bp,
1055
+ epicName: epic.split('/').pop() || epic,
1056
+ };
1057
+ });
1058
+ // Epic ## Blueprints order는 epic별; distinct epic dir마다 한 번만 읽음.
1059
+ const orderCache = new Map();
1060
+ function orderOf(epicRel) {
1061
+ if (orderCache.has(epicRel))
1062
+ return orderCache.get(epicRel);
1063
+ const abs = path.join(repoRoot, epicRel, 'index.md');
1064
+ const names = parseEpicBlueprintOrder(abs);
1065
+ orderCache.set(epicRel, names);
1066
+ return names;
1067
+ }
1068
+ // slug epic의 frontmatter order 표도 epic마다 한 번만 읽는다.
1069
+ const slugOrderCache = new Map();
1070
+ function slugOrderOf(epicRel) {
1071
+ const cached = slugOrderCache.get(epicRel);
1072
+ if (cached)
1073
+ return cached;
1074
+ const table = blueprintOrderTable(repoRoot, epicRel);
1075
+ slugOrderCache.set(epicRel, table);
1076
+ return table;
1077
+ }
1078
+ // epic 사이 순서: 현재 blueprint의 epic이 slug이고 등록부가 읽히면 `epics.json` 배열 위치를 따른다.
1079
+ // 등록부에 없는(또는 해석 불가한) epic은 맨 뒤로 보내 이름순을 유지한다. 그 밖에는 지금처럼 epic 이름순이라
1080
+ // 번호 epic 저장소와 등록부가 없는 저장소의 결과는 바뀌지 않는다. 등록부 invalid/missing은 이름순으로 접는다.
1081
+ const registryRead = isSlugEpicDir(selfEpic) ? readEpicRegistry(repoRoot) : null;
1082
+ const registrySlugs = registryRead !== null && registryRead.status === 'ok'
1083
+ ? registryRead.registry.epics.map((entry) => entry.slug)
1084
+ : null;
1085
+ function registryRank(epicRel) {
1086
+ if (registrySlugs === null)
1087
+ return 0;
1088
+ const ids = parseContextPath(epicRel);
1089
+ const index = ids === null ? -1 : registrySlugs.indexOf(ids.epicSlug);
1090
+ return index < 0 ? Number.POSITIVE_INFINITY : index;
1091
+ }
1092
+ ranked.sort((a, b) => {
1093
+ // (1) finalize 대상과 같은 epic을 먼저
1094
+ if (a.sameEpic !== b.sameEpic)
1095
+ return a.sameEpic ? -1 : 1;
1096
+ if (a.sameEpic) {
1097
+ // slug epic: frontmatter order → 경로 (본문 `## Blueprints` 목록은 보지 않는다)
1098
+ if (isSlugEpicDir(a.epic))
1099
+ return compareBySlugOrder(slugOrderOf(a.epic), a, b);
1100
+ // (2)/(3) ## Blueprints 등록 순, 미등록은 path 순
1101
+ const order = orderOf(a.epic);
1102
+ const ai = order.indexOf(a.bpName);
1103
+ const bi = order.indexOf(b.bpName);
1104
+ const aListed = ai >= 0;
1105
+ const bListed = bi >= 0;
1106
+ if (aListed && bListed)
1107
+ return ai - bi;
1108
+ if (aListed !== bListed)
1109
+ return aListed ? -1 : 1;
1110
+ return a.blueprint.localeCompare(b.blueprint);
1111
+ }
1112
+ // (4) 다른 epic: slug 기준이면 등록부 위치, 그다음 epic directory name lexicographic
1113
+ const ar = registryRank(a.epic);
1114
+ const br = registryRank(b.epic);
1115
+ if (ar !== br)
1116
+ return ar < br ? -1 : 1;
1117
+ const byEpic = a.epicName.localeCompare(b.epicName);
1118
+ if (byEpic !== 0)
1119
+ return byEpic;
1120
+ // 같은 다른 epic 안: slug epic이면 order 순, 아니면 blueprint path
1121
+ if (isSlugEpicDir(a.epic))
1122
+ return compareBySlugOrder(slugOrderOf(a.epic), a, b);
1123
+ return a.blueprint.localeCompare(b.blueprint);
1124
+ });
1125
+ if (ranked.length === 0)
1126
+ return { next: null, remaining: [], sameEpicPending };
1127
+ const finalizedPaths = readAffectedPaths(repoRoot, selfPosix);
1128
+ const [head, ...rest] = ranked;
1129
+ const candidatePaths = readAffectedPaths(repoRoot, head.blueprint);
1130
+ // candidate order로 교집합; 문자열 equality만 — directory containment
1131
+ // 추론 없음 (tasks는 정확한 path entry를 선언).
1132
+ const sharedPaths = candidatePaths.filter((p) => finalizedPaths.includes(p));
1133
+ return {
1134
+ next: {
1135
+ blueprint: head.blueprint,
1136
+ epic: head.epic,
1137
+ sameEpic: head.sameEpic,
1138
+ sharedPaths,
1139
+ },
1140
+ remaining: rest.map(({ blueprint, epic, sameEpic }) => ({ blueprint, epic, sameEpic })),
1141
+ sameEpicPending,
1142
+ };
1143
+ }
1144
+ module.exports = {
1145
+ readCurrent, writeCurrent, clearCurrent, listReadyBlueprints, nextBlueprint,
1146
+ resolvePointerTask, presentCurrent, resolveCurrent, resolveEffectiveTask,
1147
+ baseNamespaceKeys, CurrentSelectionError,
1148
+ };