@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,42 @@
1
+ 'use strict';
2
+ const seed = require("./seed-worktree");
3
+ const verification = require("./verification");
4
+ /**
5
+ * npm 설치 출력이 coordinator의 stdout 계약을 오염시키지 않도록 stderr로 보낸다.
6
+ *
7
+ * @param {Exec} exec - execFileSync 호환 함수
8
+ * @returns {Exec} 설치 출력을 stderr로 보내는 실행기
9
+ */
10
+ function withInstallStdioToStderr(exec) {
11
+ return (file, args, options) => exec(file, args, { ...options, stdio: ['ignore', 2, 2] });
12
+ }
13
+ /**
14
+ * 검증 직전 의존성을 준비한다. 호출자는 준비 결과를 중간 판정 뒤에 해석한다.
15
+ *
16
+ * @param {string} repoRoot - 준비할 checkout
17
+ * @param {Exec} exec - 주입 가능한 실행기
18
+ * @returns {ReturnType<typeof seed.prepareDependencies>} 성공 여부와 설치 오류 정보
19
+ */
20
+ function prepareVerificationDependencies(repoRoot, exec) {
21
+ return seed.prepareDependencies(repoRoot, { execFileSync: withInstallStdioToStderr(exec) });
22
+ }
23
+ /**
24
+ * 주입된 runner를 사용하고, 없으면 기본 verification runner를 고른다.
25
+ *
26
+ * @param {VerificationRunner | undefined} injected - 테스트 seam 또는 명시 주입
27
+ * @returns {VerificationRunner} 선택한 runner
28
+ */
29
+ function selectVerificationRunner(injected) {
30
+ return injected || verification.runVerification;
31
+ }
32
+ /**
33
+ * 명시된 task·wave·terminal 대상에 기존 runner를 호출한다.
34
+ *
35
+ * @param {VerificationRunner} runner - 선택된 verification runner
36
+ * @param {Parameters<VerificationRunner>[0]} target - repo, blueprint, task 및 scope
37
+ * @returns {ReturnType<VerificationRunner>} 기존 evidence shape를 포함한 결과
38
+ */
39
+ function runTargetVerification(runner, target) {
40
+ return runner(target);
41
+ }
42
+ module.exports = { withInstallStdioToStderr, prepareVerificationDependencies, selectVerificationRunner, runTargetVerification };
@@ -0,0 +1,388 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const runtime = require("./runtime-state");
5
+ const { coordinatorPathsFor } = runtime;
6
+ const frontmatter = require("./frontmatter");
7
+ const { parseFrontmatter, readDoc } = frontmatter;
8
+ const commitShaMod = require("./commit-sha");
9
+ const { normalizeCommitSha } = commitShaMod;
10
+ const taskBriefHashMod = require("./task-brief-hash");
11
+ const { taskBriefHash } = taskBriefHashMod;
12
+ const executionDag = require("./execution-dag");
13
+ const { normalizeOverlapPath } = executionDag;
14
+ const coordinatorGit = require("./coordinator-git");
15
+ const { git } = coordinatorGit;
16
+ const contextIdsMod = require("./context-ids");
17
+ const { parseContextPath, formatStableIntentId } = contextIdsMod;
18
+ const schema = require("./schema");
19
+ // worker·dispatch 조회 helper의 정본이다. 원장 I/O(coordinator-ledger)와 명령 구현
20
+ // (coordinator-commands)이 모두 이 모듈을 가져오고, 이 모듈은 둘 다 가져오지 않아 순환이 없다.
21
+ /**
22
+ * blueprint 경로를 canonical stable intent ID로 바꾼다.
23
+ *
24
+ * @param {string} blueprint - blueprint 상대 경로
25
+ * @returns {string} stable intent ID
26
+ * @throws epic 또는 blueprint 식별자를 해석할 수 없으면 Error
27
+ */
28
+ function intentIdFor(blueprint) {
29
+ const ids = parseContextPath(blueprint);
30
+ if (!ids || ids.blueprintKey === null) {
31
+ throw new Error(`Cannot derive epic/blueprint ids from blueprint path: ${blueprint}`);
32
+ }
33
+ return formatStableIntentId({ format: ids.format, epicKey: ids.epicKey, blueprintKey: ids.blueprintKey });
34
+ }
35
+ /**
36
+ * blueprint index의 verification 정책을 읽는다. 알 수 없는 값은 legacy로 접지 않는다.
37
+ *
38
+ * @param {string} root - 문서를 읽을 checkout
39
+ * @param {string} blueprint - blueprint 상대 경로
40
+ * @returns {'scoped_terminal' | 'legacy' | null} 허용 정책 또는 invalid 표시
41
+ */
42
+ function verificationPolicyAt(root, blueprint) {
43
+ const bouncer = readBouncerBlock(path.join(root, blueprint, 'index.md'));
44
+ return schema.verificationPolicyOf(bouncer);
45
+ }
46
+ /**
47
+ * implementer attempt의 최종 보고 파일 경로를 계산한다.
48
+ * dispatch 반환과 checkpoint 투영이 같은 경로를 쓰도록 계산을 이 한 곳에 둔다.
49
+ * `.bouncer/runtime/`은 gitignore라서 worker HEAD·porcelain 비교에 보이지 않는다.
50
+ *
51
+ * @param {string} workerPath - attempt를 수행하는 worker checkout 절대 경로
52
+ * @param {string} task - task 번호(예: '002')
53
+ * @param {number} attempt - dispatch attempt 번호
54
+ * @returns {string} `<workerPath>/.bouncer/runtime/reports/<task>-<attempt>.md` 절대 경로
55
+ */
56
+ function reportPathFor(workerPath, task, attempt) {
57
+ return path.join(workerPath, '.bouncer', 'runtime', 'reports', `${task}-${attempt}.md`);
58
+ }
59
+ /**
60
+ * canonical tasks.md의 SHA-256. 파일을 읽은 뒤 taskBriefHash에 맡겨 dispatch·record가
61
+ * 같은 정규형 계약을 쓰게 한다 — intent-bundle의 task_brief_hash와 맞춘다.
62
+ *
63
+ * @param {string} workerRoot - 할당된 worker worktree
64
+ * @param {string} blueprint - blueprint 상대 경로
65
+ * @param {string} taskId - 세 자리 task id
66
+ * @returns {string} 64자리 소문자 hex
67
+ */
68
+ function taskBriefHashOf(workerRoot, blueprint, taskId) {
69
+ const markdown = fs.readFileSync(path.join(workerRoot, blueprint, 'tasks', taskId, 'tasks.md'), 'utf8');
70
+ return taskBriefHash(markdown);
71
+ }
72
+ /**
73
+ * checkout의 blueprint index가 리뷰 모드인지 읽는다. 인덱스가 없거나 YAML이
74
+ * 깨지면 구형 계약(task 리뷰 요구)을 유지한다.
75
+ *
76
+ * @param {string} root - 문서를 읽을 checkout
77
+ * @param {string} blueprint - blueprint 상대 경로
78
+ * @returns {boolean} 모드이면 true
79
+ */
80
+ function isBlueprintReviewModeAt(root, blueprint) {
81
+ const abs = path.join(root, blueprint, 'index.md');
82
+ try {
83
+ const { data } = readDoc(abs);
84
+ const rec = data;
85
+ const bouncer = rec && rec.bouncer;
86
+ // validate-docs.isBlueprintReviewMode와 같은 조건식이다. coordinator가
87
+ // validate-docs를 require하면 current→coordinator 순환이 생긴다.
88
+ return Boolean(bouncer && bouncer.review_scope === 'blueprint');
89
+ }
90
+ catch (error) {
91
+ // 인덱스 부재와 YAML 파싱 실패만 흡수한다. 모드가 아닌 쪽으로 접어야
92
+ // 구형 drive의 증적 요구가 풀리지 않는다.
93
+ if (error.code === 'ENOENT')
94
+ return false;
95
+ if (error.name === 'YAMLException')
96
+ return false;
97
+ throw error;
98
+ }
99
+ }
100
+ /**
101
+ * checkout의 blueprint index에서 리뷰 계약 표식을 읽는다. `isBlueprintReviewModeAt`과
102
+ * 같은 이유로 index 부재·YAML 파손은 구형(`legacy`)으로 접고, 리뷰 모드가 아니면
103
+ * 표식이 있어도 적용하지 않는다(S38이 `review_scope: blueprint` 없는 표식을 plan gate에서
104
+ * 거절하므로 dispatch와 coordinator가 같은 blueprint를 다르게 읽는 경우는 없다).
105
+ *
106
+ * @param {string} root - 문서를 읽을 checkout
107
+ * @param {string} blueprint - blueprint 상대 경로
108
+ * @returns {'brief' | 'legacy' | null} 허용 밖의 표식 값이면 null — 호출자가 거절한다
109
+ */
110
+ function reviewContractAt(root, blueprint) {
111
+ const abs = path.join(root, blueprint, 'index.md');
112
+ let data;
113
+ try {
114
+ data = readDoc(abs).data;
115
+ }
116
+ catch (error) {
117
+ // 인덱스 부재와 YAML 파싱 실패만 흡수한다(isBlueprintReviewModeAt과 같은 기준).
118
+ if (error.code === 'ENOENT')
119
+ return 'legacy';
120
+ if (error.name === 'YAMLException')
121
+ return 'legacy';
122
+ throw error;
123
+ }
124
+ const bouncer = data?.bouncer;
125
+ if (!bouncer || bouncer.review_scope !== 'blueprint')
126
+ return 'legacy';
127
+ return schema.reviewContractOf(bouncer);
128
+ }
129
+ // frontmatter.ts의 FRONTMATTER_RE에서 뒤쪽 `\n?([\s\S]*)$`(항상 맞는 부분)를 뺀
130
+ // 앞부분과 같다. 이 검사를 통과한 문서는 parseFrontmatter가 블록 부재로 throw할 수
131
+ // 없으므로, 블록 부재를 오류 문구 비교 없이 구조로 판정한다.
132
+ const FRONTMATTER_FENCE_RE = /^---\n[\s\S]*?\n---/;
133
+ /**
134
+ * 문서의 bouncer 블록을 읽는다. 문서가 없거나 frontmatter 블록이 없거나 YAML이
135
+ * 깨졌으면 null — 호출자는 셋 다 "상태를 읽을 수 없다"로 판정한다. integrate의
136
+ * worker 증적 판정과 finalize의 원장·문서 대조가 이 한 구현을 같이 쓴다.
137
+ *
138
+ * @param {string} file - 문서 절대 경로
139
+ * @returns {Record<string, unknown> | null} bouncer 블록 또는 null
140
+ * @example
141
+ * readBouncerBlock('/wt/.bouncer/.../tasks/001/tasks.md'); // { id: 'TASKS-001', status: 'verified', ... }
142
+ * readBouncerBlock('/wt/.bouncer/.../tasks/001/missing.md'); // null
143
+ */
144
+ function readBouncerBlock(file) {
145
+ let source;
146
+ try {
147
+ source = fs.readFileSync(file, 'utf8');
148
+ }
149
+ catch (error) {
150
+ // 파일 부재(ENOENT)만 흡수한다. 아직 쓰이지 않은 문서는 상태를 읽을 수 없는 것으로
151
+ // 판정하면 되지만, 권한 오류 같은 다른 실패를 "문서 없음"으로 접으면 원인이 가려진다.
152
+ if (error.code === 'ENOENT')
153
+ return null;
154
+ throw error;
155
+ }
156
+ if (!FRONTMATTER_FENCE_RE.test(source))
157
+ return null;
158
+ let data;
159
+ try {
160
+ data = parseFrontmatter(source).data;
161
+ }
162
+ catch (error) {
163
+ // YAML 파싱 오류만 흡수한다. 블록 부재는 위 구조 검사가 이미 걸렀으므로 여기 오는
164
+ // 것은 깨진 YAML뿐이고, 상태를 읽을 수 없는 문서라 null로 접어도 판정이 안전하다.
165
+ if (error.name === 'YAMLException')
166
+ return null;
167
+ throw error;
168
+ }
169
+ const bouncer = data && typeof data === 'object' ? data.bouncer : null;
170
+ return bouncer && typeof bouncer === 'object' ? bouncer : null;
171
+ }
172
+ const EVIDENCE_FILES = [
173
+ ['tasks.md', 'verified'], ['verification.md', 'passed'], ['review.md', 'accepted'],
174
+ ];
175
+ /**
176
+ * fan-in이 요구하는 worker 증적 파일과 terminal 상태 쌍을 고른다.
177
+ * blueprint review 모드에서는 루트 리뷰가 정본이므로 task `review.md`를 뺀다.
178
+ *
179
+ * @param {boolean} reviewMode - integration blueprint index의 리뷰 모드
180
+ * @returns {ReadonlyArray<readonly [string, string]>} 파일명과 기대 status
181
+ */
182
+ function evidenceFiles(reviewMode) {
183
+ return reviewMode ? EVIDENCE_FILES.filter(([name]) => name !== 'review.md') : EVIDENCE_FILES;
184
+ }
185
+ /**
186
+ * worker task bundle이 fan-in할 terminal 증적인지 판정한다. 요구 모드는 integration
187
+ * index만 읽어 worker가 review_scope를 바꾸어 검사 항목을 줄이지 못하게 한다.
188
+ *
189
+ * @param {string} workerPath - 배정된 worker worktree
190
+ * @param {string} integrationPath - review 모드의 정본 checkout
191
+ * @param {string} blueprint - blueprint 상대 경로
192
+ * @param {string} taskId - 세 자리 task 번호
193
+ * @param {string} sha - 원장에 기록된 worker SHA
194
+ * @returns {{ ok: true } | { ok: false; reason: string; files: string[] }} 판정
195
+ */
196
+ function checkWorkerEvidence(workerPath, integrationPath, blueprint, taskId, sha) {
197
+ const relOf = (name) => `${blueprint.replaceAll('\\', '/')}/tasks/${taskId}/${name}`;
198
+ const open = [];
199
+ let stamped;
200
+ const reviewMode = isBlueprintReviewModeAt(integrationPath, blueprint);
201
+ for (const [name, terminal] of evidenceFiles(reviewMode)) {
202
+ const bouncer = readBouncerBlock(path.join(workerPath, blueprint, 'tasks', taskId, name));
203
+ if (!bouncer || bouncer.status !== terminal)
204
+ open.push(relOf(name));
205
+ if (name === 'tasks.md' && bouncer)
206
+ stamped = bouncer.commit_sha;
207
+ }
208
+ if (open.length > 0)
209
+ return { ok: false, reason: 'worker-evidence-not-terminal', files: open };
210
+ if (normalizeCommitSha(stamped) !== sha.slice(0, 8).toLowerCase()) {
211
+ return { ok: false, reason: 'worker-evidence-sha-mismatch', files: [relOf('tasks.md')] };
212
+ }
213
+ return { ok: true };
214
+ }
215
+ /**
216
+ * worker terminal bundle을 integration에 복사하고, 실패 시 복사 전 바이트를 복원한다.
217
+ *
218
+ * @param {string} workerPath - 배정된 worker worktree
219
+ * @param {string} integrationPath - integration checkout 및 모드 정본
220
+ * @param {string} blueprint - blueprint 상대 경로
221
+ * @param {string} taskId - 세 자리 task 번호
222
+ * @returns {() => void} 성공 후 rollback 함수
223
+ */
224
+ function copyEvidenceBundle(workerPath, integrationPath, blueprint, taskId) {
225
+ const targetDir = path.join(integrationPath, blueprint, 'tasks', taskId);
226
+ const dirExisted = fs.existsSync(targetDir);
227
+ const entries = evidenceFiles(isBlueprintReviewModeAt(integrationPath, blueprint)).map(([name]) => {
228
+ const target = path.join(targetDir, name);
229
+ return {
230
+ source: path.join(workerPath, blueprint, 'tasks', taskId, name), target,
231
+ before: fs.existsSync(target) ? fs.readFileSync(target) : null,
232
+ };
233
+ });
234
+ const restore = () => {
235
+ if (!dirExisted) {
236
+ fs.rmSync(targetDir, { recursive: true, force: true });
237
+ return;
238
+ }
239
+ for (const entry of entries) {
240
+ if (entry.before === null)
241
+ fs.rmSync(entry.target, { force: true });
242
+ else
243
+ fs.writeFileSync(entry.target, entry.before);
244
+ }
245
+ };
246
+ try {
247
+ fs.mkdirSync(targetDir, { recursive: true });
248
+ for (const entry of entries)
249
+ fs.copyFileSync(entry.source, entry.target);
250
+ }
251
+ catch (error) {
252
+ // 일부만 복사된 bundle은 fan-in 기준이 될 수 없으므로 되돌린 뒤 같은 오류를 전달한다.
253
+ restore();
254
+ throw error;
255
+ }
256
+ return restore;
257
+ }
258
+ /**
259
+ * supplement 경로 표기를 맞춘다. 역슬래시·`./` 접두를 정리해 포함 비교가 표기
260
+ * 차이로 어긋나지 않게 한다.
261
+ *
262
+ * @param {string} raw - `--paths` 항목 또는 task 경로
263
+ * @returns {string} posix 정규형(말미 `/` 제거)
264
+ */
265
+ function normalizeSupplementPath(raw) {
266
+ return normalizeOverlapPath(path.posix.normalize(raw.replaceAll('\\', '/').trim()));
267
+ }
268
+ /**
269
+ * worktree의 커밋되지 않은 변경 경로를 필터 없이 모은다(추적·미추적 모두, rename은 양쪽).
270
+ * `-z`를 쓰므로 한국어·따옴표가 든 이름도 C-quote 없이 원문 그대로 나온다.
271
+ *
272
+ * @param {Exec} exec - 주입 가능한 Git 실행기
273
+ * @param {string} cwd - 검사할 worktree
274
+ * @returns {string[]} 정규형 변경 경로
275
+ */
276
+ function porcelainPaths(exec, cwd) {
277
+ const raw = String(exec('git', ['status', '--porcelain', '-z', '-uall'], {
278
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
279
+ }));
280
+ const tokens = raw.split('\0').filter((token) => token !== '');
281
+ const out = [];
282
+ for (let i = 0; i < tokens.length; i += 1) {
283
+ const status = tokens[i].slice(0, 2);
284
+ out.push(normalizeSupplementPath(tokens[i].slice(3)));
285
+ // rename·copy는 다음 토큰이 원래 경로다.
286
+ if (/[RC]/.test(status) && i + 1 < tokens.length) {
287
+ i += 1;
288
+ out.push(normalizeSupplementPath(tokens[i]));
289
+ }
290
+ }
291
+ return out;
292
+ }
293
+ /**
294
+ * task의 현재 승인 scope. scope_revision으로 갱신된 ledger scope가 있으면 그것이 정본이고,
295
+ * 없으면 계획의 affected_paths다. 의존성 공유 여부를 가르는 seed가 이 값을 받는다.
296
+ *
297
+ * @param {Task} task - ledger task
298
+ * @returns {string[]} 현재 승인 경로
299
+ */
300
+ function currentScopePaths(task) {
301
+ return task.scope && Array.isArray(task.scope.paths) ? [...task.scope.paths] : [...(task.affected_paths || [])];
302
+ }
303
+ /**
304
+ * worker porcelain 경로 가운데 task 현재 scope 안에 있고 tasks.md가 아닌 "커밋할 scope 변경"을 모은다.
305
+ * tasks.md는 `bouncer commit`이 stamp를 쓰는 파일이라 정상 잔여물이므로 뺀다. dispatch 기준선은
306
+ * 면제하지 않는다. `workerCommitEvidence`의 scope dirty 판정과 `coordinate next`의 commit 선택이
307
+ * 같은 집합을 보게 하려고 한 곳에 둔다. 읽기만 한다.
308
+ *
309
+ * @param {Exec} exec - 주입 가능한 Git 실행기
310
+ * @param {string} workerPath - worker(light는 integration) checkout
311
+ * @param {string} blueprint - blueprint 상대 경로
312
+ * @param {Task} task - ledger task(현재 scope 경로의 출처)
313
+ * @returns {string[]} 중복을 없애 정렬한 repo 상대 경로. 없으면 빈 배열
314
+ */
315
+ function workerScopeDirtyPaths(exec, workerPath, blueprint, task) {
316
+ const briefRel = `${blueprint.replaceAll('\\', '/')}/tasks/${task.id}/tasks.md`;
317
+ const scope = currentScopePaths(task).map((entry) => normalizeSupplementPath(entry).replace(/\/+$/, ''));
318
+ const dirty = porcelainPaths(exec, workerPath).filter((rel) => rel !== briefRel
319
+ && scope.some((entry) => entry !== '' && (rel === entry || rel.startsWith(`${entry}/`))));
320
+ return [...new Set(dirty)].sort();
321
+ }
322
+ /**
323
+ * worker가 report(accepted)·record 전에 task commit을 확정했는지 읽기 전용으로 판정한다.
324
+ * 검사 순서는 원인이 겹칠 때 보이는 코드를 정한다: 커밋 부재 → stamp 불일치 → scope 안 dirty.
325
+ * 비교 기준은 `ledger.integrationHead`다. wave에서 다른 worker가 먼저 통합돼 live HEAD가
326
+ * 움직여도 남은 worker를 오판하지 않기 위해서다. `coordinate-next`의 report 선택과 같은
327
+ * stamp 규칙(`commit_sha` 앞 8자 == HEAD 앞 8자)을 쓰도록 이 함수가 단일 정본이다.
328
+ *
329
+ * @param {Exec} exec - 주입 가능한 Git 실행기
330
+ * @param {string} workerPath - worker(light는 integration) checkout
331
+ * @param {string} blueprint - blueprint 상대 경로
332
+ * @param {Task} task - ledger task(현재 scope 경로의 출처)
333
+ * @param {{ integrationHead?: unknown }} ledger - integrationHead를 읽을 원장
334
+ * @returns {{ ok: true } | { ok: false; reason: string; head: string; integration_head: string | null;
335
+ * stamp: string | null; dirty_paths: string[] }} 판정
336
+ */
337
+ function workerCommitEvidence(exec, workerPath, blueprint, task, ledger) {
338
+ const head = git(exec, workerPath, ['rev-parse', 'HEAD']);
339
+ const integrationHead = typeof ledger.integrationHead === 'string' ? ledger.integrationHead : null;
340
+ const block = readBouncerBlock(path.join(workerPath, blueprint, 'tasks', task.id, 'tasks.md'));
341
+ const stampRaw = block ? normalizeCommitSha(block.commit_sha) : '';
342
+ const stamp = stampRaw === '' ? null : stampRaw;
343
+ const refuse = (reason, dirty = []) => ({
344
+ ok: false, reason, head, integration_head: integrationHead, stamp, dirty_paths: dirty,
345
+ });
346
+ // 1. HEAD가 integrationHead와 같거나 그 조상이면 worker가 만든 커밋이 없다.
347
+ // is-ancestor는 같은 커밋도 성공(exit 0)으로 답한다. exit 1(비조상)만 "커밋 있음"이고,
348
+ // object 조회 실패 같은 다른 오류는 추측하지 않고 그대로 올린다.
349
+ if (integrationHead !== null) {
350
+ let ancestor;
351
+ try {
352
+ git(exec, workerPath, ['merge-base', '--is-ancestor', head, integrationHead]);
353
+ ancestor = true;
354
+ }
355
+ catch (error) {
356
+ if (error.status !== 1)
357
+ throw error;
358
+ ancestor = false;
359
+ }
360
+ if (ancestor)
361
+ return refuse('worker-commit-missing');
362
+ }
363
+ // 2. stamp는 `bouncer commit`이 찍는 HEAD 앞 8자와 같아야 한다.
364
+ if (stamp !== head.slice(0, 8).toLowerCase())
365
+ return refuse('worker-commit-stamp-mismatch');
366
+ // 3. scope 안 dirty는 dispatch 기준선에 있어도 거절한다 — 재dispatch 때 앞 attempt의
367
+ // 미커밋 수정이 기준선에 흡수되므로 기준선 면제는 커밋 누락을 숨긴다.
368
+ // tasks.md 제외 규칙은 workerScopeDirtyPaths가 갖는다(next와 같은 집합을 쓰기 위해 추출).
369
+ const dirty = workerScopeDirtyPaths(exec, workerPath, blueprint, task);
370
+ if (dirty.length > 0)
371
+ return refuse('worker-scope-dirty', dirty);
372
+ return { ok: true };
373
+ }
374
+ function ensureIntegrationCwd(repoRoot, blueprint, cwd, task) {
375
+ const paths = coordinatorPathsFor({ repoRoot, blueprint, task });
376
+ const actual = fs.realpathSync(cwd);
377
+ const allowed = fs.realpathSync(task ? paths.workerPath : paths.integrationPath);
378
+ if (actual !== allowed)
379
+ throw new Error('coordinate command must run in its assigned worktree');
380
+ return paths;
381
+ }
382
+ module.exports = {
383
+ reportPathFor, taskBriefHashOf, isBlueprintReviewModeAt, reviewContractAt, readBouncerBlock,
384
+ evidenceFiles, checkWorkerEvidence, copyEvidenceBundle,
385
+ workerCommitEvidence, workerScopeDirtyPaths, ensureIntegrationCwd,
386
+ // coordinator-commands가 같은 경로 정규형과 scope 규칙을 쓰도록 함께 내보낸다.
387
+ currentScopePaths, normalizeSupplementPath, porcelainPaths, intentIdFor, verificationPolicyAt,
388
+ };
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+ const commands = require("./coordinator-commands");
3
+ module.exports = {
4
+ coordinate: commands.coordinate,
5
+ COORDINATE_FAILURE_HINTS: commands.COORDINATE_FAILURE_HINTS,
6
+ withCoordinateFailureHints: commands.withCoordinateFailureHints,
7
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const runtime = require("./runtime-state");
5
+ const { runtimePaths, COORDINATOR_LEDGER_REL } = runtime;
6
+ const executionDag = require("./execution-dag");
7
+ const { taskConflictDetail } = executionDag;
8
+ /**
9
+ * 저장소의 worktree 원장을 열거해 다른 활성 drive의 시작된 task만 돌려준다.
10
+ * 원장 경로가 존재하는데 읽을 수 없으면 충돌을 놓치지 않도록 해당 경로로 실패한다.
11
+ *
12
+ * @param {object} opts - 저장소와 현재 drive 원장
13
+ * @param {string} opts.repoRoot - main checkout 루트
14
+ * @param {string} opts.ownLedgerFile - 제외할 현재 원장 절대 경로
15
+ * @returns {object} 성공 시 drives, 실패 시 읽을 수 없는 ledgerFile이 포함된 거절
16
+ */
17
+ function otherDriveTasks({ repoRoot, ownLedgerFile }) {
18
+ const roots = runtimePaths({ repoRoot });
19
+ if ('unavailable' in roots)
20
+ return { ok: true, drives: [] };
21
+ const worktreeRoot = roots.worktreeRoot;
22
+ const drives = [];
23
+ let epics;
24
+ try {
25
+ epics = fs.readdirSync(worktreeRoot);
26
+ }
27
+ catch (error) {
28
+ if (error.code === 'ENOENT')
29
+ return { ok: true, drives };
30
+ throw error;
31
+ }
32
+ for (const epic of epics) {
33
+ const epicPath = path.join(worktreeRoot, epic);
34
+ if (!fs.statSync(epicPath).isDirectory())
35
+ continue;
36
+ for (const blueprint of fs.readdirSync(epicPath)) {
37
+ const integration = path.join(epicPath, blueprint, 'integration');
38
+ if (!fs.existsSync(integration) || !fs.statSync(integration).isDirectory())
39
+ continue;
40
+ const ledgerFile = path.join(integration, ...COORDINATOR_LEDGER_REL.split('/'));
41
+ if (path.resolve(ledgerFile) === path.resolve(ownLedgerFile) || !fs.existsSync(ledgerFile))
42
+ continue;
43
+ let ledger;
44
+ try {
45
+ ledger = JSON.parse(fs.readFileSync(ledgerFile, 'utf8'));
46
+ }
47
+ catch (_error) {
48
+ // JSON 파싱 오류만 원장 불가로 흡수한다. 충돌 가능성을 누락하지 않게 prepare를 막는다.
49
+ return { ok: false, reason: 'cross-drive-ledger-unreadable', ledgerFile };
50
+ }
51
+ if (!ledger || typeof ledger !== 'object') {
52
+ return { ok: false, reason: 'cross-drive-ledger-unreadable', ledgerFile };
53
+ }
54
+ if (ledger.status === 'detached')
55
+ continue;
56
+ const tasks = Array.isArray(ledger.tasks)
57
+ ? ledger.tasks.filter((task) => task && task.status !== 'pending') : [];
58
+ drives.push({ blueprint: typeof ledger.blueprint === 'string'
59
+ ? ledger.blueprint : `${epic}/${blueprint}`, ledgerFile, tasks });
60
+ }
61
+ }
62
+ return { ok: true, drives };
63
+ }
64
+ /**
65
+ * 후보 task 각각을 모든 활성 drive task와 비교해 상대 원장 기준 충돌을 만든다.
66
+ *
67
+ * @param {Task[]} candidates - 이번 prepare wave 후보
68
+ * @param {DriveTasks[]} drives - 다른 원장에서 읽은 task 집합
69
+ * @returns {DriveConflict[]} 충돌 상대별 상세 목록
70
+ */
71
+ function crossDriveConflicts(candidates, drives) {
72
+ const conflicts = [];
73
+ for (const candidate of candidates) {
74
+ for (const drive of drives) {
75
+ for (const task of drive.tasks) {
76
+ const detail = taskConflictDetail(candidate, task);
77
+ if (detail)
78
+ conflicts.push({ blueprint: drive.blueprint, task: task.id,
79
+ paths: detail.paths, resources: detail.resources, legacy: detail.legacy });
80
+ }
81
+ }
82
+ }
83
+ return conflicts;
84
+ }
85
+ module.exports = { otherDriveTasks, crossDriveConflicts };