@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,926 @@
1
+ // scripts/lib/seed-worktree.js
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { execFileSync } = require('node:child_process');
6
+ const { createHash } = require('node:crypto');
7
+ const paths = require("./paths");
8
+ const { epicDirOf, toPosix } = paths;
9
+ const layout = require("./layout");
10
+ const { CONTEXT_ROOT } = layout;
11
+ const scope = require("./scope");
12
+ const { isUnder } = scope;
13
+ const configMod = require("./config");
14
+ const { readConfig, readWorktreeSetup, getVerifyAllowlist } = configMod;
15
+ const windowsNpm = require("./windows-npm");
16
+ const { resolveSpawn } = windowsNpm;
17
+ const commandArgv = require("./command-argv");
18
+ const { parseVerifyArgv, isValidVerifyCommand } = commandArgv;
19
+ // plan target이 아니라 별도 복사 단계의 경로. makeIsTarget에 넣으면
20
+ // phase 2가 base config를 restore하거나 지운다.
21
+ const CONFIG_REL = '.bouncer/config.json';
22
+ // npm이 남기는 marker(`.package-lock.json`)는 lockfile이 바뀌어도 그대로 남으므로
23
+ // "어떤 lockfile로 설치했는지"를 알려주지 못한다. 설치 성공 뒤 lockfile 해시를
24
+ // 이 stamp에 남겨 두 값을 비교한다.
25
+ const LOCK_STAMP_REL = path.join('node_modules', '.bouncer-lock-sha256');
26
+ // git worktree는 ignored 로컬 환경을 가져오지 않는다. Python 가상환경과 `.env`는
27
+ // npm처럼 lockfile로 다시 만들 수 없으므로, main checkout의 것을 symlink로 공유한다.
28
+ const LOCAL_ENV_LINKS = ['.venv', '.env'];
29
+ /**
30
+ * worktreePath가 속한 저장소의 main checkout 경로를 찾는다.
31
+ * `git worktree list --porcelain`의 첫 항목이 main worktree다.
32
+ *
33
+ * @param {string} worktreePath - 조회 cwd
34
+ * @returns {string | null} main checkout 절대 경로. git 저장소가 아니면 null
35
+ */
36
+ function mainCheckoutOf(worktreePath) {
37
+ try {
38
+ const out = execFileSync('git', ['worktree', 'list', '--porcelain'], {
39
+ cwd: worktreePath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
40
+ });
41
+ const first = out.split('\n').find((line) => line.startsWith('worktree '));
42
+ return first ? first.slice('worktree '.length) : null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /**
49
+ * worktree에서 `git check-ignore -q`가 pathspec을 무시하면 true.
50
+ * check-ignore는 ignored면 0, 아니면 1(throw)로 끝난다.
51
+ *
52
+ * @param {string} worktreePath - 조회 cwd
53
+ * @param {string} pathspec - check-ignore에 넘길 경로(또는 `rel/`)
54
+ * @returns {boolean} ignored 여부
55
+ */
56
+ function checkIgnoreQuiet(worktreePath, pathspec) {
57
+ try {
58
+ execFileSync('git', ['check-ignore', '-q', '--', pathspec], {
59
+ cwd: worktreePath, stdio: 'ignore',
60
+ });
61
+ return true;
62
+ }
63
+ catch (error) {
64
+ // exit 1만 "ignored 아님". 128 같은 git 치명 오류를 not-ignored로 접으면
65
+ // 건너뜀 사유가 틀리므로 호출자가 구분하도록 다시 던진다.
66
+ if (error.status === 1)
67
+ return false;
68
+ throw error;
69
+ }
70
+ }
71
+ /**
72
+ * 링크한 로컬 환경 경로를 공통 `info/exclude`에 `/<rel>`로 한 줄 추가한다.
73
+ * symlink는 디렉터리가 아니라서 `.venv/` 같은 패턴에 걸리지 않고 `??`로 새므로,
74
+ * 저장소 공유 exclude에 루트 고정 항목을 넣어 status를 깨끗이 유지한다.
75
+ * 이미 같은 줄이 있으면 쓰지 않는다.
76
+ *
77
+ * @param {string} worktreePath - `git rev-parse --git-path` cwd
78
+ * @param {string} rel - LOCAL_ENV_LINKS 항목(예: `.venv`)
79
+ */
80
+ function ensureLocalEnvExclude(worktreePath, rel) {
81
+ let excludeRel;
82
+ try {
83
+ excludeRel = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], {
84
+ cwd: worktreePath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
85
+ }).trim();
86
+ }
87
+ catch {
88
+ // exclude 경로를 못 얻으면 status 오염만 감수한다. 링크 자체는 이미 성공했다.
89
+ return;
90
+ }
91
+ const excludePath = path.isAbsolute(excludeRel)
92
+ ? excludeRel
93
+ : path.join(worktreePath, excludeRel);
94
+ const entry = `/${rel}`;
95
+ let body = '';
96
+ try {
97
+ body = fs.readFileSync(excludePath, 'utf8');
98
+ }
99
+ catch (error) {
100
+ // 없는 파일만 새로 만든다. 권한 오류는 숨기지 않는다.
101
+ if (error.code !== 'ENOENT')
102
+ throw error;
103
+ fs.mkdirSync(path.dirname(excludePath), { recursive: true });
104
+ }
105
+ if (body.split(/\r?\n/).includes(entry))
106
+ return;
107
+ const prefix = body.length > 0 && !body.endsWith('\n') ? '\n' : '';
108
+ fs.appendFileSync(excludePath, `${prefix}${entry}\n`);
109
+ }
110
+ /**
111
+ * main checkout의 ignored 로컬 환경(LOCAL_ENV_LINKS)을 worktree에 symlink한다.
112
+ * 상대경로 `.venv/bin/pytest` 같은 검증 명령과 `.env`를 읽는 설정 로딩이
113
+ * worker·fan-in candidate·integration checkout에서도 main과 같게 돌게 한다.
114
+ * main에 없거나, worktree에 이미 있거나(깨진 symlink 포함), worktree에서 ignored가
115
+ * 아닌 경로는 건너뛴다 — 추적 파일을 가리지 않기 위해서다. main checkout 자신이면
116
+ * 아무것도 하지 않는다. `.gitignore`의 `.venv/`처럼 디렉터리 전용 패턴은 아직 없는
117
+ * `rel`에 check-ignore가 실패하므로, main 원본이 디렉터리면 `rel/`로도 확인한다.
118
+ * 링크 뒤에는 symlink가 status에 새지 않도록 `info/exclude`에 `/<rel>`을 한 번만 넣고,
119
+ * ignored가 아니거나 링크에 실패한 건너뜀은 stderr에 한 줄로 남긴다(조용한 검증 실패 방지).
120
+ *
121
+ * @param {string} worktreePath - 링크를 만들 worktree 절대 경로
122
+ * @returns {string[]} 새로 만든 링크의 저장소 상대 경로
123
+ */
124
+ function linkLocalEnv(worktreePath) {
125
+ const main = mainCheckoutOf(worktreePath);
126
+ if (!main)
127
+ return [];
128
+ try {
129
+ if (fs.realpathSync(main) === fs.realpathSync(worktreePath))
130
+ return [];
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ const linked = [];
136
+ for (const rel of LOCAL_ENV_LINKS) {
137
+ const src = path.join(main, rel);
138
+ const dst = path.join(worktreePath, rel);
139
+ if (!fs.existsSync(src))
140
+ continue;
141
+ try {
142
+ fs.lstatSync(dst);
143
+ continue;
144
+ }
145
+ catch {
146
+ // ENOENT면 아직 없는 경로라 링크 대상이다.
147
+ }
148
+ // 초기값은 try/catch에서 항상 덮이거나 continue로 버려지므로 선언만 한다(no-useless-assignment).
149
+ let srcIsDir;
150
+ try {
151
+ srcIsDir = fs.statSync(src).isDirectory();
152
+ }
153
+ catch {
154
+ continue;
155
+ }
156
+ // 1. 일반 pathspec. 2. 디렉터리 원본이면 trailing-slash 패턴용 `rel/` 재시도.
157
+ let ignored;
158
+ try {
159
+ ignored = checkIgnoreQuiet(worktreePath, rel)
160
+ || (srcIsDir && checkIgnoreQuiet(worktreePath, `${rel}/`));
161
+ }
162
+ catch (error) {
163
+ process.stderr.write(`linkLocalEnv: skip ${rel} (check-ignore failed: ${error.message || error})\n`);
164
+ continue;
165
+ }
166
+ if (!ignored) {
167
+ process.stderr.write(`linkLocalEnv: skip ${rel} (not ignored in worktree)\n`);
168
+ continue;
169
+ }
170
+ try {
171
+ fs.symlinkSync(src, dst);
172
+ }
173
+ catch (error) {
174
+ // 링크는 편의 장치라 실패를 흡수하되, 사유를 남겨 검증 exit 1이 묻히지 않게 한다.
175
+ process.stderr.write(`linkLocalEnv: skip ${rel} (symlink failed: ${error.message || error})\n`);
176
+ continue;
177
+ }
178
+ // exclude는 status 정리용이다. 링크는 이미 성공했으므로 쓰기가 실패해도
179
+ // 반환에 넣고, 사유만 exclude 실패로 구분해 symlink 실패와 섞지 않는다.
180
+ try {
181
+ ensureLocalEnvExclude(worktreePath, rel);
182
+ }
183
+ catch (error) {
184
+ process.stderr.write(`linkLocalEnv: exclude ${rel} failed: ${error.message || error}\n`);
185
+ }
186
+ linked.push(rel);
187
+ }
188
+ return linked;
189
+ }
190
+ /**
191
+ * root package.json의 직접 패키지(dependencies·devDependencies) 중 설치 metadata
192
+ * (`node_modules/<name>/package.json`)가 없는 이름을 돌려준다. optionalDependencies는
193
+ * 플랫폼에 따라 정상적으로 빠질 수 있어 요구하지 않는다. transitive 완전성은 보장하지 않는다.
194
+ * package.json이 없거나 읽을 수 없거나 JSON이 아니거나 workspaces를 선언하면 이 검사를
195
+ * 적용할 수 없으므로 null을 돌려준다 — 호출자는 현행 경로를 유지한다(추측으로 막지 않는다).
196
+ *
197
+ * @param {string} root - package.json과 node_modules가 있는 디렉터리
198
+ * @returns {string[] | null} 누락 패키지 이름들(없으면 []). 적용 불가는 null
199
+ */
200
+ function missingDirectDependencies(root) {
201
+ let manifest;
202
+ try {
203
+ manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
204
+ }
205
+ catch {
206
+ // 파일 없음·읽기 실패·깨진 JSON은 모두 "검사 불가"다. 여기서 막으면 npm을 쓰지
207
+ // 않거나 manifest가 없는 저장소의 검증까지 멈추므로 적용 대상에서 뺀다.
208
+ return null;
209
+ }
210
+ if (typeof manifest !== 'object' || manifest === null || manifest.workspaces !== undefined)
211
+ return null;
212
+ const names = new Set();
213
+ for (const table of [manifest.dependencies, manifest.devDependencies]) {
214
+ if (typeof table === 'object' && table !== null)
215
+ for (const name of Object.keys(table))
216
+ names.add(name);
217
+ }
218
+ return [...names].filter((name) => !fs.existsSync(path.join(root, 'node_modules', name, 'package.json')));
219
+ }
220
+ /** root package-lock.json bytes의 sha256. 읽을 수 없으면 null이다. */
221
+ function lockHashOf(root) {
222
+ try {
223
+ return createHash('sha256').update(fs.readFileSync(path.join(root, 'package-lock.json'))).digest('hex');
224
+ }
225
+ catch {
226
+ // lockfile 없음·읽기 실패는 "비교 불가"이며 공유 불가로 이어질 뿐 오류가 아니다.
227
+ return null;
228
+ }
229
+ }
230
+ /** 승인 scope가 의존성 manifest/lock을 바꿀 수 있으면 true. 루트 전체를 가리키는 경로도 포함한다. */
231
+ function scopeChangesDependencies(affectedPaths) {
232
+ return affectedPaths.some((entry) => {
233
+ const rel = toPosix(entry).replace(/^\.\//, '').replace(/\/+$/, '');
234
+ if (rel === '' || rel === '.')
235
+ return true;
236
+ return ['package.json', 'package-lock.json', 'npm-shrinkwrap.json'].includes(path.posix.basename(rel));
237
+ });
238
+ }
239
+ /**
240
+ * main checkout의 node_modules를 이 worktree가 안전하게 공유할 수 있는지 판단한다.
241
+ * 모두 만족해야 true다: worktree가 main 자신이 아님, 승인 scope가 manifest/lock을 바꾸지
242
+ * 않음, root lockfile bytes hash가 main과 같음, main에 npm marker와 같은 hash의 stamp가
243
+ * 있음, main에 직접 패키지 metadata가 모두 있음. 하나라도 어긋나면 false이며 그 경우
244
+ * 호출자는 worktree에 독립 설치한다. 읽기만 하고 아무것도 쓰지 않는다.
245
+ *
246
+ * @param {string} worktreePath - 공유 대상 worktree
247
+ * @param {string} mainRoot - main checkout
248
+ * @param {DependencyShare} share - 승인 task scope
249
+ * @returns {boolean} 공유 가능 여부
250
+ */
251
+ function canShareMainDependencies(worktreePath, mainRoot, share) {
252
+ if (scopeChangesDependencies(share.affectedPaths))
253
+ return false;
254
+ try {
255
+ if (fs.realpathSync(mainRoot) === fs.realpathSync(worktreePath))
256
+ return false;
257
+ }
258
+ catch {
259
+ // 경로를 해석할 수 없으면 main과의 관계를 알 수 없으므로 공유하지 않는다.
260
+ return false;
261
+ }
262
+ const lockHash = lockHashOf(worktreePath);
263
+ if (lockHash === null || lockHash !== lockHashOf(mainRoot))
264
+ return false;
265
+ if (!fs.existsSync(path.join(mainRoot, 'node_modules', '.package-lock.json')))
266
+ return false;
267
+ let mainStamp;
268
+ try {
269
+ mainStamp = fs.readFileSync(path.join(mainRoot, LOCK_STAMP_REL), 'utf8').trim();
270
+ }
271
+ catch {
272
+ // stamp를 읽을 수 없으면 main이 이 lock으로 설치됐다는 증거가 없으므로 공유하지 않는다.
273
+ return false;
274
+ }
275
+ if (mainStamp !== lockHash)
276
+ return false;
277
+ const missing = missingDirectDependencies(mainRoot);
278
+ return missing !== null && missing.length === 0;
279
+ }
280
+ /**
281
+ * lockfile이 있고 npm marker와 현재 lockfile에 맞는 stamp가 모두 있을 때만 설치를 건너뛰고,
282
+ * 그 밖에는 `npm ci --include=dev`로 의존성을 채운 뒤 lockfile sha256 stamp를 남긴다.
283
+ * stamp를 읽을 수 없으면(errno 무관) stamp 없음으로 보고 재설치한다.
284
+ * git worktree는 ignored `node_modules`를 가져오지 않고, 브랜치가 lockfile을 바꾸면 남은
285
+ * marker만으로는 오래된 node_modules를 알아채지 못한다. fan-in candidate와 integration
286
+ * checkout, finalize가 같은 계약을 타게 한다. 설치 성공 뒤 stamp 쓰기가 실패해도
287
+ * 결과는 ok:true다(다음 호출이 stamp 부재로 재설치하므로 안전하다).
288
+ * lockfile 확인 전에 linkLocalEnv로 main checkout의 `.venv`·`.env`를 먼저 연결한다.
289
+ * 설치(또는 건너뜀) 뒤에는 main checkout config의 `worktree.setup` 명령을 실행한다.
290
+ *
291
+ * @param {string} worktreePath - 설치 cwd. package-lock.json을 이 경로에서 본다
292
+ * @param {SeedDeps} deps - execFileSync·mainCheckout 주입. 테스트가 호출을 가로채는 용도
293
+ * @param {DependencyShare} [share] - 생략하면 main node_modules 공유를 만들지 않는다
294
+ * @returns {{ ok: true } | { ok: false, reason: string, message: unknown }}
295
+ * 설치 경로와 setup 모두 성공이면 ok:true. npm ci 실패(stamp 미기록)와 setup 거절·실패는
296
+ * ok:false와 dependency-install-failed이며 setup 실패 message는 `worktree.setup`으로 시작한다
297
+ */
298
+ function prepareDependencies(worktreePath, deps, share) {
299
+ const installed = installDependencies(worktreePath, deps, share);
300
+ if (!installed.ok)
301
+ return installed;
302
+ // 설치 실패는 위에서 먼저 반환한다. 설치·건너뜀·lockfile 없음 어느 경로든
303
+ // gitignore 대상 빌드 산출물은 따로 만들어야 하므로 setup은 항상 마지막에 돈다.
304
+ return runWorktreeSetup(worktreePath, deps);
305
+ }
306
+ /**
307
+ * config의 `worktree.setup` 명령을 worktree에서 셸 없이 한 번 실행한다.
308
+ * config는 새 worktree가 아니라 main checkout에서 읽는다 — 이 시점에 worktree에는
309
+ * 아직 config가 복사되지 않았다(seedConfig가 준비 뒤에 돈다). main checkout을
310
+ * 못 찾거나 config가 없거나 키가 없으면 설정 없음으로 보고 아무것도 하지 않는다.
311
+ * 설정이 있는데 형태·argv·허용 목록이 어긋나면 실행하지 않고 실패로 알린다.
312
+ *
313
+ * @param {string} worktreePath - 명령 실행 cwd
314
+ * @param {SeedDeps} deps - execFileSync·mainCheckout 주입(테스트 seam)
315
+ * @returns {{ ok: true } | { ok: false, reason: string, message: unknown }}
316
+ * 미설정·실행 성공은 ok:true. 검사 거절과 실행 실패는 dependency-install-failed이며
317
+ * message는 `worktree.setup`으로 시작한다
318
+ */
319
+ function runWorktreeSetup(worktreePath, deps) {
320
+ const mainRoot = deps.mainCheckout ?? mainCheckoutOf(worktreePath);
321
+ if (!mainRoot)
322
+ return { ok: true };
323
+ // 파일 없음·깨진 JSON은 모두 null이다. 깨진 config를 여기서 막으면 setup을
324
+ // 쓰지 않는 저장소의 준비까지 멈추므로 설정 없음과 같게 둔다.
325
+ const config = readConfig(mainRoot);
326
+ if (config === null)
327
+ return { ok: true };
328
+ const setup = readWorktreeSetup(config);
329
+ if (!setup.ok) {
330
+ return { ok: false, reason: 'dependency-install-failed', message: 'worktree.setup must be a string' };
331
+ }
332
+ if (setup.command === null)
333
+ return { ok: true };
334
+ if (!isValidVerifyCommand(setup.command, getVerifyAllowlist(config))) {
335
+ return {
336
+ ok: false,
337
+ reason: 'dependency-install-failed',
338
+ message: `worktree.setup is not an allowed single command: ${JSON.stringify(setup.command)}`,
339
+ };
340
+ }
341
+ // isValidVerifyCommand가 통과했으므로 argv는 null이 아니다.
342
+ const argv = parseVerifyArgv(setup.command);
343
+ try {
344
+ (deps.execFileSync || execFileSync)(argv[0], argv.slice(1), { cwd: worktreePath, stdio: 'inherit' });
345
+ }
346
+ catch (error) {
347
+ return {
348
+ ok: false,
349
+ reason: 'dependency-install-failed',
350
+ message: `worktree.setup failed: ${error.message}`,
351
+ };
352
+ }
353
+ return { ok: true };
354
+ }
355
+ /**
356
+ * 의존성 설치 단계(`.venv`·`.env` 연결, npm ci, stamp). 계약은 prepareDependencies의
357
+ * 설치 부분과 같다.
358
+ *
359
+ * @param {string} worktreePath - 설치 cwd
360
+ * 공유 계약: `share`가 있고 canShareMainDependencies가 참일 때만 node_modules가 없는 worktree에
361
+ * main의 node_modules symlink를 만든다(실제 node_modules 디렉터리는 덮어쓰지 않는다).
362
+ * node_modules가 symlink이면 npm이 링크 너머 main을 쓰지 못하도록 링크가 여전히 공유
363
+ * 조건을 만족하고 main의 node_modules를 가리킬 때만 그대로 두고, lock 변경·깨진 링크·
364
+ * 알 수 없는 target이면 링크 자체만 unlink한 뒤 worktree에 독립 설치한다(target은 건드리지
365
+ * 않는다). 링크를 풀지 못하면 npm을 실행하지 않고 dependency-install-failed를 돌려준다.
366
+ *
367
+ * @param {string} worktreePath - 설치 cwd
368
+ * @param {SeedDeps} deps - execFileSync·mainCheckout 주입
369
+ * @param {DependencyShare} [share] - 승인 task scope. 없으면 공유를 만들지 않는다
370
+ * @returns {{ ok: true } | { ok: false, reason: string, message: unknown }}
371
+ * lockfile 없음·공유 유지/생성·stamp 일치·설치 성공은 ok:true, 링크 해제·npm ci 실패는 ok:false
372
+ */
373
+ function installDependencies(worktreePath, deps, share) {
374
+ linkLocalEnv(worktreePath);
375
+ const lockFile = path.join(worktreePath, 'package-lock.json');
376
+ if (!fs.existsSync(lockFile))
377
+ return { ok: true };
378
+ const modulesDir = path.join(worktreePath, 'node_modules');
379
+ let modulesStat = null;
380
+ try {
381
+ modulesStat = fs.lstatSync(modulesDir);
382
+ }
383
+ catch (error) {
384
+ // 없는 경로(ENOENT)만 "아직 없음"으로 흡수한다. 권한 같은 다른 오류는 숨기지 않는다.
385
+ if (error.code !== 'ENOENT')
386
+ throw error;
387
+ }
388
+ if (modulesStat?.isSymbolicLink() || (modulesStat === null && share)) {
389
+ const mainRoot = deps.mainCheckout ?? mainCheckoutOf(worktreePath);
390
+ const shareable = Boolean(share && mainRoot && canShareMainDependencies(worktreePath, mainRoot, share));
391
+ if (modulesStat === null) {
392
+ if (shareable) {
393
+ try {
394
+ fs.symlinkSync(path.join(mainRoot, 'node_modules'), modulesDir, 'dir');
395
+ return { ok: true };
396
+ }
397
+ catch {
398
+ // 링크를 못 만들면 공유를 포기하고 독립 설치로 간다. 링크는 최적화일 뿐이다.
399
+ }
400
+ }
401
+ }
402
+ else {
403
+ let intact = false;
404
+ try {
405
+ intact = shareable
406
+ && fs.realpathSync(modulesDir) === fs.realpathSync(path.join(mainRoot, 'node_modules'));
407
+ }
408
+ catch {
409
+ // 깨진 링크(ENOENT/ELOOP)는 intact=false로 흡수해 아래에서 링크를 푼다.
410
+ }
411
+ if (intact)
412
+ return { ok: true };
413
+ try {
414
+ // symlink 자체만 지운다. unlink는 target을 따라가지 않아 main bytes가 보존된다.
415
+ fs.unlinkSync(modulesDir);
416
+ }
417
+ catch (error) {
418
+ return { ok: false, reason: 'dependency-install-failed', message: error.message };
419
+ }
420
+ }
421
+ }
422
+ const lockHash = createHash('sha256').update(fs.readFileSync(lockFile)).digest('hex');
423
+ const stampFile = path.join(worktreePath, LOCK_STAMP_REL);
424
+ if (fs.existsSync(path.join(worktreePath, 'node_modules', '.package-lock.json'))) {
425
+ let stamp = null;
426
+ try {
427
+ stamp = fs.readFileSync(stampFile, 'utf8').trim();
428
+ }
429
+ catch {
430
+ // 읽기 실패(ENOENT·EISDIR·ENOTDIR·EACCES·EIO 등 모든 errno)는 "stamp 알 수 없음"으로
431
+ // 취급한다. stamp는 설치 최적화 장치일 뿐이라 읽지 못해도 재설치하면 안전하고,
432
+ // 여기서 던지면 의존성 준비 전체가 중단되므로 사유를 구분하지 않고 흡수한다.
433
+ }
434
+ if (stamp === lockHash)
435
+ return { ok: true };
436
+ }
437
+ try {
438
+ // 실행 host가 NODE_ENV=production 또는 omit=dev를 설정해도, execute의
439
+ // 검증에는 devDependencies가 필요하다. 명시적으로 포함해 host 설정이
440
+ // 새 worktree의 검증 가능 여부를 바꾸지 못하게 한다.
441
+ const npmArgs = ['ci', '--include=dev', '--ignore-scripts', '--no-audit', '--no-fund'];
442
+ if (deps.execFileSync) {
443
+ deps.execFileSync('npm', npmArgs, { cwd: worktreePath, stdio: 'inherit' });
444
+ }
445
+ else {
446
+ // Windows에서는 npm.cmd를 shell 없이 spawn할 수 없어 node로 npm CLI를 직접 실행한다.
447
+ const call = resolveSpawn('npm', npmArgs);
448
+ execFileSync(call.command, call.args, {
449
+ cwd: worktreePath,
450
+ stdio: 'inherit',
451
+ ...(call.shell ? { shell: true } : {}),
452
+ });
453
+ }
454
+ }
455
+ catch (error) {
456
+ return { ok: false, reason: 'dependency-install-failed', message: error.message };
457
+ }
458
+ try {
459
+ fs.mkdirSync(path.dirname(stampFile), { recursive: true });
460
+ fs.writeFileSync(stampFile, `${lockHash}\n`);
461
+ }
462
+ catch {
463
+ // stamp 쓰기 실패(권한·경로가 디렉터리 등)만 흡수한다. 설치 자체는 성공했고,
464
+ // stamp가 없으면 다음 호출이 재설치하므로 오래된 node_modules로 검증하지 않는다.
465
+ }
466
+ return { ok: true };
467
+ }
468
+ // plan 워크플로에는 commit 단계가 없으므로 epic/blueprint 문서는 base working tree에만
469
+ // 존재합니다. `git worktree add`는 커밋된 HEAD를 checkout하므로 execute worktree는
470
+ // 구현에 필요한 brief 없이 시작합니다. 이 모듈은 plan context 문서만 정확히 옮기고
471
+ // base를 git이 기록한 상태로 되돌립니다.
472
+ //
473
+ // 이동 집합은 scope.makeAllowed가 context 문서에 기본으로 허용하는 경계와
474
+ // 의도적으로 같습니다 — blueprint 트리, epic index, context index.
475
+ // 파생 memory는 필수 입력이 아니며 복사하지 않습니다. affected_paths 아래
476
+ // 코드와 관련 없는 로컬 변경(graph output)은 그대로 둡니다. config는 이
477
+ // 이동 집합 밖이며, dest 보존 복사로만 전달합니다.
478
+ function makeIsTarget({ blueprintDir }) {
479
+ const bp = toPosix(blueprintDir);
480
+ const epicIndex = `${epicDirOf(bp)}/index.md`;
481
+ const contextIndex = `${CONTEXT_ROOT}/index.md`;
482
+ return function isTarget(file) {
483
+ const f = toPosix(file);
484
+ if (isUnder(f, `${bp}/`))
485
+ return true;
486
+ return f === epicIndex || f === contextIndex;
487
+ };
488
+ }
489
+ function realGit(repoRoot) {
490
+ const run = (args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
491
+ const lines = (s) => s.split('\n').filter(Boolean);
492
+ return {
493
+ // `diff HEAD`는 staged 변경도 보고하므로, agent가 `git add`한 문서는
494
+ // untracked가 아니라 여기서 분류됩니다.
495
+ changedFiles: () => lines(run(['diff', '--name-only', 'HEAD'])),
496
+ untrackedFiles: () => lines(run(['ls-files', '--others', '--exclude-standard'])),
497
+ existsInHead: (file) => {
498
+ try {
499
+ // 경로가 없는 것은 모든 새 plan 문서에서 예상되는 답이므로,
500
+ // git의 "디스크에는 있지만 HEAD에는 없음" 메시지는 여기서 오류가 아닙니다.
501
+ execFileSync('git', ['cat-file', '-e', `HEAD:${file}`], {
502
+ cwd: repoRoot,
503
+ stdio: 'ignore',
504
+ });
505
+ return true;
506
+ }
507
+ catch {
508
+ return false;
509
+ }
510
+ },
511
+ readHead: (file) => {
512
+ try {
513
+ // `--filters`는 working-tree filter(autocrlf, .gitattributes text=auto)를
514
+ // 적용하므로 checkout이 쓴 바이트와 동일하게 비교됩니다. raw `git show`는
515
+ // CRLF worktree를 모두 충돌로 읽습니다.
516
+ return execFileSync('git', ['cat-file', '--filters', `HEAD:${file}`], {
517
+ cwd: repoRoot,
518
+ stdio: ['ignore', 'pipe', 'ignore'],
519
+ });
520
+ }
521
+ catch {
522
+ return null;
523
+ }
524
+ },
525
+ // `git checkout -- <path>`는 index에서 복원하는데, agent가 staged하면
526
+ // index에 dirty blob이 남습니다. HEAD를 지정하면 index와 working tree를
527
+ // 함께 되돌려 staged ghost가 남지 않습니다.
528
+ restore: (file) => { run(['checkout', 'HEAD', '--', file]); },
529
+ unstage: (file) => { run(['rm', '--cached', '--quiet', '--', file]); },
530
+ };
531
+ }
532
+ // 파일을 제거하면 scaffold가 만든 디렉터리 트리가 비울 수 있습니다. Git은
533
+ // 디렉터리를 추적하지 않으므로 빈 잔여물은 `git status`에는 보이지 않지만
534
+ // 다음 planning 세션에는 보입니다. 무언가 남을 때까지 위로 prune합니다.
535
+ /**
536
+ * execute worktree에 `.bouncer/config.json`만 보존 복사한다.
537
+ * dest가 있으면 바이트를 비교하지 않는다 — 재사용 worktree의 로컬 정책이
538
+ * 덮이면 이미 통과한 verify와 다른 명령이 돈다. 추적 파일은 HEAD가
539
+ * 정본이다. dirty base를 쓰면 아직 합의되지 않은 수정이 execute 정책이 된다.
540
+ *
541
+ * @param {string} repoRoot - plan 문서가 있는 base checkout
542
+ * @param {string} worktreePath - execute worktree 절대 경로
543
+ * @param {SeedGit} gitApi - HEAD 존재·바이트 조회
544
+ * @returns {'copied' | 'preserved' | 'missing'} dest 유지, HEAD/base 복사, 또는 양쪽 부재
545
+ */
546
+ function seedConfig(repoRoot, worktreePath, gitApi) {
547
+ const dest = path.join(worktreePath, CONFIG_REL);
548
+ if (fs.existsSync(dest))
549
+ return 'preserved';
550
+ const src = path.join(repoRoot, CONFIG_REL);
551
+ if (gitApi.existsInHead(CONFIG_REL)) {
552
+ const head = gitApi.readHead(CONFIG_REL);
553
+ // existsInHead가 참이면 dirty working tree는 정책이 아니다.
554
+ // null을 untracked 분기로 넘기면 로컬 수정본이 execute 검증 명령이 된다.
555
+ if (!head) {
556
+ throw new Error(`failed to read HEAD:${CONFIG_REL}`);
557
+ }
558
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
559
+ fs.writeFileSync(dest, head);
560
+ return 'copied';
561
+ }
562
+ // ignored·untracked는 HEAD에 없다. dest가 비어 있을 때만 base 바이트를
563
+ // 복사하고, base 파일은 그대로 둔다 — 이 경로는 phase 2 대상이 아니다.
564
+ if (!fs.existsSync(src))
565
+ return 'missing';
566
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
567
+ fs.writeFileSync(dest, fs.readFileSync(src));
568
+ return 'copied';
569
+ }
570
+ // stopRel은 지우지 않을 상한 디렉터리(저장소 상대)다. 생략하면 저장소 루트에서 멈춘다.
571
+ // release는 blueprint의 부모 `blueprints/`를 넘겨, 다른 blueprint와 공유하는 epic 트리를
572
+ // 비었다는 이유로 지우지 않는다.
573
+ function pruneEmptyDirs(repoRoot, rel, stopRel = '') {
574
+ let dir = path.dirname(path.join(repoRoot, rel));
575
+ const stop = path.resolve(repoRoot, stopRel);
576
+ while (path.resolve(dir) !== stop && fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
577
+ fs.rmdirSync(dir);
578
+ dir = path.dirname(dir);
579
+ }
580
+ }
581
+ function seedWorktree({ repoRoot, blueprintDir, worktreePath, git, deps, }) {
582
+ const gitApi = git || realGit(repoRoot);
583
+ const isTarget = makeIsTarget({ blueprintDir });
584
+ const changed = gitApi.changedFiles();
585
+ const untracked = gitApi.untrackedFiles();
586
+ const changedSet = new Set(changed);
587
+ const targets = [...new Set([...changed, ...untracked])].filter(isTarget).sort();
588
+ if (!fs.existsSync(worktreePath) || !fs.statSync(worktreePath).isDirectory()) {
589
+ return { ok: false, reason: 'missing-worktree', worktreePath };
590
+ }
591
+ const prepared = prepareDependencies(worktreePath, deps || {});
592
+ if (!prepared.ok)
593
+ return prepared;
594
+ // dependency 준비 다음, plan 이동 전. 충돌로 phase 1이 중단돼도
595
+ // 대상 worktree는 검증 정책을 이미 갖고, 다음 재시도는 preserved가 된다.
596
+ let config;
597
+ try {
598
+ config = seedConfig(repoRoot, worktreePath, gitApi);
599
+ }
600
+ catch (error) {
601
+ return { ok: false, reason: 'copy-failed', message: error.message };
602
+ }
603
+ if (!targets.length)
604
+ return { ok: true, moved: [], restored: [], config };
605
+ // Phase 1 — 복사 후 확인. `git checkout --`와 `git rm --cached`는 되돌릴 수
606
+ // 없으므로, 모든 target이 worktree에 동일한 바이트로 존재하는 것이 확인될 때까지
607
+ // base는 건드리지 않습니다. 거기서 다른 파일이면 이전 실행이 중단됐거나
608
+ // 수동 편집입니다. 덮어쓰지 말고 중단합니다.
609
+ const conflicts = [];
610
+ const pending = [];
611
+ for (const rel of targets) {
612
+ const src = path.join(repoRoot, rel);
613
+ // `git diff HEAD`는 삭제도 나열합니다. 옮길 것은 없지만 base는 git에
614
+ // restore를 빚므로 복사 없이 phase 2용으로 표시합니다.
615
+ if (!fs.existsSync(src)) {
616
+ pending.push({ rel, write: false, absent: true });
617
+ continue;
618
+ }
619
+ const content = fs.readFileSync(src);
620
+ const dst = path.join(worktreePath, rel);
621
+ if (!fs.existsSync(dst)) {
622
+ pending.push({ rel, write: true, dst, content });
623
+ }
624
+ else if (fs.readFileSync(dst).equals(content)) {
625
+ pending.push({ rel, write: false });
626
+ }
627
+ else {
628
+ // worktree는 HEAD에서 checkout됐으므로 추적된 target은 모두 HEAD blob으로
629
+ // 이미 존재합니다. 그것이 pristine checkout이지 다른 사람의 작업이 아닙니다.
630
+ // 덮어쓰는 것이 "dirty 버전을 옮긴다"는 뜻입니다. base도 HEAD도 아닌
631
+ // 내용만 진짜 충돌입니다 — 중단된 이전 실행이나 수동 편집.
632
+ const head = gitApi.readHead(rel);
633
+ if (head && fs.readFileSync(dst).equals(head))
634
+ pending.push({ rel, write: true, dst, content });
635
+ else
636
+ conflicts.push(rel);
637
+ }
638
+ }
639
+ if (conflicts.length)
640
+ return { ok: false, reason: 'conflict', conflicts, config };
641
+ try {
642
+ for (const item of pending) {
643
+ if (!item.write)
644
+ continue;
645
+ fs.mkdirSync(path.dirname(item.dst), { recursive: true });
646
+ fs.writeFileSync(item.dst, item.content);
647
+ }
648
+ }
649
+ catch (error) {
650
+ return { ok: false, reason: 'copy-failed', message: error.message };
651
+ }
652
+ // Phase 2 — base를 git이 기록한 상태로 되돌립니다. HEAD 소속 여부가 동사를
653
+ // 고릅니다 — staged/unstaged 구분만으로는 부족합니다: `git checkout --`는 HEAD가
654
+ // 아는 경로에만 동작하고, staged 새 파일에는 HEAD blob이 없습니다.
655
+ // `moved`는 worktree가 이제 보유하는 모든 target을 나열합니다 — 이미 바이트
656
+ // 동일하게 있던 것도 포함 — 호출자가 brief를 읽을 수 있는지 확인하는 용도이지
657
+ // 파일 쓰기 횟수를 세는 용도가 아닙니다.
658
+ const moved = [];
659
+ const restored = [];
660
+ for (const { rel, absent } of pending) {
661
+ if (gitApi.existsInHead(rel)) {
662
+ gitApi.restore(rel);
663
+ restored.push(rel);
664
+ }
665
+ else {
666
+ if (changedSet.has(rel))
667
+ gitApi.unstage(rel);
668
+ fs.rmSync(path.join(repoRoot, rel), { force: true });
669
+ pruneEmptyDirs(repoRoot, rel);
670
+ }
671
+ // base에서 삭제된 경로는 복사되지 않았으므로 moved가 아니라 restored입니다.
672
+ if (!absent)
673
+ moved.push(rel);
674
+ }
675
+ return { ok: true, moved, restored, config };
676
+ }
677
+ /**
678
+ * coordinator worker·fan-in candidate에 계획 문서와 검증용 의존성을 심는다.
679
+ * execute용 seedWorktree와 달리 base를 복원·삭제하지 않는다. blueprint 트리
680
+ * 다음에 epic·context index를 base에 있을 때만 덮어 복사해, untracked epic이
681
+ * worker execute 게이트에서 S8·S13으로 막히지 않게 한다.
682
+ *
683
+ * @param {object} opts - seed 대상
684
+ * @param {string} opts.repoRoot - 계획 문서가 있는 base(integration) checkout. 읽기만 한다
685
+ * @param {unknown} opts.blueprintDir - 저장소 상대 blueprint 디렉터리
686
+ * @param {string} opts.worktreePath - worker·fan-in worktree 절대 경로
687
+ * @param {SeedDeps} [opts.deps] - execFileSync 주입. 테스트가 npm 호출을 가로채는 용도
688
+ * @param {readonly string[]} [opts.affectedPaths] - 승인 task의 affected_paths. 있을 때만
689
+ * main node_modules 공유를 시도하고, manifest/lock 경로를 포함하면 처음부터 독립 설치한다
690
+ * @returns {{ ok: true, config: ConfigSeedStatus, seeded: string[] }
691
+ * | { ok: false, reason: string, [k: string]: unknown }}
692
+ * 성공 시 seeded는 [blueprintDir, 복사한 index 경로들] 순서. 없는 index는 넣지 않는다.
693
+ * 복사 예외는 copy-failed.
694
+ */
695
+ function seedCoordinatorWorker({ repoRoot, blueprintDir, worktreePath, deps, affectedPaths }) {
696
+ if (!fs.existsSync(worktreePath) || !fs.statSync(worktreePath).isDirectory()) {
697
+ return { ok: false, reason: 'missing-worktree', worktreePath };
698
+ }
699
+ const bp = toPosix(blueprintDir);
700
+ const source = path.join(repoRoot, bp);
701
+ if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) {
702
+ return { ok: false, reason: 'missing-blueprint', blueprintDir };
703
+ }
704
+ // affectedPaths가 없으면 승인 scope를 알 수 없으므로 공유하지 않고 독립 설치한다.
705
+ const prepared = prepareDependencies(worktreePath, deps || {}, affectedPaths ? { affectedPaths } : undefined);
706
+ if (!prepared.ok)
707
+ return prepared;
708
+ try {
709
+ const config = seedConfig(repoRoot, worktreePath, realGit(repoRoot));
710
+ // cpSync는 target tree만 쓰며, base plan은 읽기만 한다. 여러 worker가 같은
711
+ // blueprint seed를 받아도 서로의 brief를 빼앗지 않는 이유다.
712
+ fs.cpSync(source, path.join(worktreePath, bp), { recursive: true, force: true });
713
+ const seeded = [bp];
714
+ // seedIntegration과 같은 경로 계산. worker는 HEAD checkout이라 untracked
715
+ // epic index와 dirty context index가 빠져 execute 구조 게이트가 실패한다.
716
+ for (const rel of [`${epicDirOf(bp)}/index.md`, `${CONTEXT_ROOT}/index.md`]) {
717
+ const src = path.join(repoRoot, rel);
718
+ if (!fs.existsSync(src))
719
+ continue;
720
+ const dst = path.join(worktreePath, rel);
721
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
722
+ fs.copyFileSync(src, dst);
723
+ seeded.push(rel);
724
+ }
725
+ return { ok: true, config, seeded };
726
+ }
727
+ catch (error) {
728
+ return { ok: false, reason: 'copy-failed', message: error.message };
729
+ }
730
+ }
731
+ /**
732
+ * integration checkout에 main의 계획 문서를 seed하고 manifest를 반환한다.
733
+ * 기본 동작은 충돌을 막고, light 승격 호출만 이미 있는 계획 파일을 승인본으로 교체한다.
734
+ *
735
+ * @param {object} opts - source·destination과 승격 전용 overwrite 선택
736
+ * @param {string} opts.repoRoot - 승인된 계획 문서가 있는 main checkout
737
+ * @param {unknown} opts.blueprintDir - blueprint 상대 경로
738
+ * @param {string} opts.integrationPath - 계획 문서를 받을 integration checkout
739
+ * @param {boolean} [opts.allowOverwrite] - true이면 기존 차이를 full 승격 seed가 덮어씀
740
+ * @returns {object} 성공 시 seeded 경로·manifest·config, 실패 시 reason과 대상
741
+ */
742
+ function seedIntegration({ repoRoot, blueprintDir, integrationPath, allowOverwrite = false }) {
743
+ const bp = toPosix(blueprintDir);
744
+ const source = path.join(repoRoot, bp);
745
+ const targets = [];
746
+ if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) {
747
+ return { ok: false, reason: 'missing-blueprint', targets: [bp] };
748
+ }
749
+ const visit = (dir, rel) => {
750
+ for (const name of fs.readdirSync(dir).sort()) {
751
+ const child = path.join(dir, name);
752
+ const childRel = `${rel}/${name}`;
753
+ if (fs.statSync(child).isDirectory())
754
+ visit(child, childRel);
755
+ else
756
+ targets.push(childRel);
757
+ }
758
+ };
759
+ visit(source, bp);
760
+ for (const rel of [`${epicDirOf(bp)}/index.md`, `${CONTEXT_ROOT}/index.md`]) {
761
+ if (fs.existsSync(path.join(repoRoot, rel)))
762
+ targets.push(rel);
763
+ }
764
+ targets.sort();
765
+ const gitApi = realGit(repoRoot);
766
+ const conflicts = [];
767
+ const realIntegration = fs.realpathSync(integrationPath);
768
+ const contained = (candidate) => {
769
+ const rel = path.relative(realIntegration, candidate);
770
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
771
+ };
772
+ // 승격은 충돌 검사를 우회하므로, 쓰기 전에 실제 대상 경로도 integration 안인지 확인한다.
773
+ // symlink가 main이나 외부 파일을 가리키면 승인 문서 복사가 그 파일을 덮어쓸 수 있다.
774
+ for (const rel of targets) {
775
+ const dst = path.resolve(integrationPath, rel);
776
+ if (!contained(dst))
777
+ return { ok: false, reason: 'seed-path-escape', targets: [rel] };
778
+ let probe = dst;
779
+ const suffix = [];
780
+ while (true) {
781
+ try {
782
+ fs.lstatSync(probe);
783
+ break;
784
+ }
785
+ catch (error) {
786
+ if (error.code !== 'ENOENT')
787
+ throw error;
788
+ const parent = path.dirname(probe);
789
+ if (parent === probe)
790
+ return { ok: false, reason: 'seed-path-escape', targets: [rel] };
791
+ suffix.unshift(path.basename(probe));
792
+ probe = parent;
793
+ }
794
+ }
795
+ let resolved;
796
+ try {
797
+ resolved = fs.realpathSync(probe);
798
+ }
799
+ catch (error) {
800
+ const code = error.code;
801
+ // 경로 해석 중 dangling·순환 symlink 또는 비디렉터리 상위 경로는 안전하게 거절한다.
802
+ if (code === 'ENOENT' || code === 'ELOOP' || code === 'ENOTDIR') {
803
+ return { ok: false, reason: 'seed-path-escape', targets: [rel] };
804
+ }
805
+ throw error;
806
+ }
807
+ if (!contained(path.resolve(resolved, ...suffix))) {
808
+ return { ok: false, reason: 'seed-path-escape', targets: [rel] };
809
+ }
810
+ }
811
+ for (const rel of targets) {
812
+ const dst = path.join(integrationPath, rel);
813
+ if (!fs.existsSync(dst))
814
+ continue;
815
+ const current = fs.readFileSync(dst);
816
+ const main = fs.readFileSync(path.join(repoRoot, rel));
817
+ const head = gitApi.readHead(rel);
818
+ // full 재승격은 이미 멈춘 light 문서 상태를 승인된 main 계획으로 바꾸는 경로다.
819
+ if (!allowOverwrite && !current.equals(main) && !(head && current.equals(head)))
820
+ conflicts.push(rel);
821
+ }
822
+ if (conflicts.length)
823
+ return { ok: false, reason: 'seed-conflict', conflicts, targets };
824
+ try {
825
+ for (const rel of targets) {
826
+ const dst = path.resolve(integrationPath, rel);
827
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
828
+ fs.copyFileSync(path.join(repoRoot, rel), dst);
829
+ }
830
+ const config = seedConfig(repoRoot, integrationPath, gitApi);
831
+ const manifest = targets.map((rel) => ({ path: rel,
832
+ sha256: createHash('sha256').update(fs.readFileSync(path.join(integrationPath, rel))).digest('hex') }));
833
+ return { ok: true, seeded: targets, manifest, config };
834
+ }
835
+ catch (error) {
836
+ const code = error.code;
837
+ if (code === 'ELOOP' || code === 'ENOTDIR') {
838
+ return { ok: false, reason: 'seed-path-escape', targets };
839
+ }
840
+ return { ok: false, reason: 'seed-failed', message: error.message, targets };
841
+ }
842
+ }
843
+ // 원장 manifest 경로가 저장소 상대 POSIX 정규형인지. makeIsTarget은 접두어만 보므로
844
+ // `<bp>/../../x` 같은 경로도 통과시킨다. 정규화 결과가 그대로인 경로만 판정에 넣는다.
845
+ function isCanonicalRel(rel) {
846
+ return rel !== '' && !path.posix.isAbsolute(rel) && !/^[A-Za-z]:/.test(rel)
847
+ && path.posix.normalize(rel) === rel && !rel.split('/').includes('..');
848
+ }
849
+ /**
850
+ * drive가 끝난 뒤 main에 남은 계획 사본을 bootstrap manifest와 대조해 git이 기록한
851
+ * 상태로 돌려놓는다. 해시가 맞는 사본만 되돌리므로 drive 도중 main에서 고친 문서는
852
+ * 남는다. seed 집합(makeIsTarget) 밖이거나 정규형이 아닌 manifest 항목은 원장이 손으로
853
+ * 바뀐 경우이므로 판정하지 않고 preserved로만 보고한다. 객체가 아니거나 `path`가 문자열이
854
+ * 아닌 항목도 같다 — 경로 대신 항목의 JSON 텍스트로 보고한다. 되돌린 뒤 다시 부르면 모든
855
+ * 처리 경로가 absent가 되어 같은 상태로 수렴한다. 빈 디렉터리 정리는 blueprint 트리
856
+ * 안에서만 한다.
857
+ *
858
+ * @param {object} opts - main checkout, blueprint 경로, 원장의 seedManifest, 주입용 git
859
+ * @returns {ReleaseResult} 경로별 판정. 순서는 manifest 순서다.
860
+ * @example
861
+ * releaseSeedManifest({ repoRoot: '/repo', blueprintDir: bp, manifest: ledger.seedManifest });
862
+ * // { released: [`${bp}/index.md`], restored: ['.bouncer/context/index.md'], preserved: [], absent: [] }
863
+ * releaseSeedManifest({ repoRoot: '/repo', blueprintDir: bp, manifest: [null] });
864
+ * // { released: [], restored: [], preserved: ['null'], absent: [] }
865
+ */
866
+ function releaseSeedManifest({ repoRoot, blueprintDir, manifest, git }) {
867
+ const gitApi = git || realGit(repoRoot);
868
+ const isTarget = makeIsTarget({ blueprintDir });
869
+ const bp = toPosix(blueprintDir);
870
+ // 쓰기 전에 모든 항목을 먼저 판정 가능한 것과 아닌 것으로 나눈다. 반복 도중에 `null.path`
871
+ // 같은 항목을 만나 throw하면, 앞 항목만 되돌려진 main이 남고 보고도 없다.
872
+ const entries = manifest.map((entry) => {
873
+ const record = entry && typeof entry === 'object' ? entry : null;
874
+ if (!record || typeof record.path !== 'string') {
875
+ // JSON.stringify(undefined)는 문자열이 아니므로 String으로 채운다.
876
+ return { rel: JSON.stringify(entry) ?? String(entry), sha256: undefined, judged: false };
877
+ }
878
+ const rel = toPosix(record.path);
879
+ return { rel, sha256: record.sha256, judged: isCanonicalRel(rel) && isTarget(rel) };
880
+ });
881
+ // `diff HEAD`는 staged 추가도 나열한다. HEAD에 없는 경로가 여기 있으면 index에만
882
+ // 올라간 새 파일이므로 지우기 전에 unstage해야 index에 유령이 남지 않는다.
883
+ const changed = new Set(gitApi.changedFiles());
884
+ const result = { released: [], restored: [], preserved: [], absent: [] };
885
+ for (const { rel, sha256, judged } of entries) {
886
+ if (!judged) {
887
+ result.preserved.push(rel);
888
+ continue;
889
+ }
890
+ const abs = path.join(repoRoot, rel);
891
+ if (!fs.existsSync(abs)) {
892
+ result.absent.push(rel);
893
+ continue;
894
+ }
895
+ const current = fs.readFileSync(abs);
896
+ const inHead = gitApi.existsInHead(rel);
897
+ const head = inHead ? gitApi.readHead(rel) : null;
898
+ if (head && current.equals(head)) {
899
+ result.absent.push(rel);
900
+ continue;
901
+ }
902
+ if (createHash('sha256').update(current).digest('hex') !== sha256) {
903
+ result.preserved.push(rel);
904
+ continue;
905
+ }
906
+ if (inHead) {
907
+ gitApi.restore(rel);
908
+ result.restored.push(rel);
909
+ }
910
+ else {
911
+ if (changed.has(rel))
912
+ gitApi.unstage(rel);
913
+ fs.rmSync(abs, { force: true });
914
+ // blueprint 트리 안의 파일만 정리한다. 상한은 부모 `blueprints/`라 비워진 blueprint
915
+ // 디렉터리까지만 지워진다. epic·context index는 공유 트리의 파일이므로 정리하지 않는다.
916
+ if (isUnder(rel, `${bp}/`))
917
+ pruneEmptyDirs(repoRoot, rel, path.posix.dirname(bp));
918
+ result.released.push(rel);
919
+ }
920
+ }
921
+ return result;
922
+ }
923
+ module.exports = {
924
+ makeIsTarget, realGit, seedWorktree, seedCoordinatorWorker, seedIntegration,
925
+ releaseSeedManifest, prepareDependencies, linkLocalEnv, missingDirectDependencies,
926
+ };