@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
package/dist/scope.js ADDED
@@ -0,0 +1,1073 @@
1
+ // scripts/lib/scope.js
2
+ 'use strict';
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { randomUUID } = require('node:crypto');
6
+ const { execFileSync: realExecFileSync } = require('node:child_process');
7
+ const paths = require("./paths");
8
+ const { epicDirOf, toPosix } = paths;
9
+ const layout = require("./layout");
10
+ const { CONTEXT_ROOT } = layout;
11
+ const runtimeState = require("./runtime-state");
12
+ const { coordinatorPathsFor, runtimePaths } = runtimeState;
13
+ const frontmatter = require("./frontmatter");
14
+ const { readDoc } = frontmatter;
15
+ const render = require("./render");
16
+ const { renderDoc } = render;
17
+ const pathOverlapMod = require("./path-overlap");
18
+ const { pathsOverlap } = pathOverlapMod;
19
+ const leaseMod = require("./lease");
20
+ const { revokeLease, isValidLease, isValidLeaseSeq } = leaseMod;
21
+ const validateSections = require("./validate-sections");
22
+ const { SECTION_DEFS, extractPathCandidates } = validateSections;
23
+ function isUnder(file, entry) {
24
+ const f = toPosix(file);
25
+ const e = toPosix(entry);
26
+ if (f === e)
27
+ return true;
28
+ const pref = e.endsWith('/') ? e : `${e}/`;
29
+ return f.startsWith(pref);
30
+ }
31
+ // 빌드 산출물과 runtime state는 Bouncer 관리 scope가 아님: stage하지도
32
+ // 위반으로 보고하지도 않아 .gitignore가 없는 repo도 finalize를 막지 않음.
33
+ // `bouncer init`은 프로젝트가 무시해야 할 항목을 알려 주며, `--write-gitignore`
34
+ // 동의 신호가 있을 때만 마커 블록 안에서 쓴다(기본은 제안만).
35
+ // Execute checkout은 `<repo>/.worktrees/<BP-id>` 아래에 있음. 트리 전체를
36
+ // ignore하여 finalize가 중첩 worktree 파일을 scope 밖으로 보지 않게 함.
37
+ // `.bouncer/.venv/`는 init 설치 산출물 — 범위 위반으로 보고하지 않는다.
38
+ // `.bouncer/runtime/`은 coordinator 원장이 사는 자리 — 실행 상태이지 컨텍스트
39
+ // 문서가 아니다. integration worktree 자신의 커밋 범위에서도 위반이 아니어야
40
+ // 하므로 `.worktrees/` 접두만으로는 부족하다. `.bouncer/` 전체가 아니라 이
41
+ // 한 갈래만 넣는다 — context 문서는 커밋 대상이다.
42
+ const RUNTIME_ARTIFACTS = ['node_modules/', 'graphify-out/', '.worktrees/', '.bouncer/.venv/', '.bouncer/runtime/'];
43
+ function isRuntimeArtifact(file) {
44
+ const f = toPosix(file);
45
+ return RUNTIME_ARTIFACTS.some((entry) => isUnder(f, entry));
46
+ }
47
+ // task 커밋은 범위 권한을 먼저 확인한 뒤 이 경계에서 workflow 문서를 뺀다.
48
+ // makeAllowed를 좁히면 문서 변경이 out-of-scope로 오인되고, 넓히면 task가
49
+ // 다른 문서까지 커밋할 수 있으므로 권한과 후보 필터를 별도 함수로 둔다.
50
+ function isTaskWorkflowArtifact(file) {
51
+ const f = toPosix(file);
52
+ return isUnder(f, CONTEXT_ROOT);
53
+ }
54
+ function filterTaskCommitCandidates({ repoRoot, changedFiles, untrackedFiles, }) {
55
+ const changed = Array.isArray(changedFiles) ? changedFiles : [];
56
+ const untracked = (Array.isArray(untrackedFiles) ? untrackedFiles : [])
57
+ .filter((file) => (typeof file === 'string'
58
+ && typeof repoRoot === 'string'
59
+ && fs.existsSync(path.resolve(repoRoot, file))));
60
+ return [...new Set([...changed, ...untracked])]
61
+ .filter((file) => !isRuntimeArtifact(file))
62
+ .filter((file) => !isTaskWorkflowArtifact(file));
63
+ }
64
+ function makeAllowed({ affectedPaths, blueprintDir }) {
65
+ const bp = toPosix(blueprintDir);
66
+ const epicDir = epicDirOf(bp);
67
+ const paths = Array.isArray(affectedPaths) ? affectedPaths : [];
68
+ return function allowed(file) {
69
+ const f = toPosix(file);
70
+ if (isUnder(f, `${bp}/`))
71
+ return true;
72
+ if (f === `${epicDir}/index.md`)
73
+ return true;
74
+ if (f === `${CONTEXT_ROOT}/index.md`)
75
+ return true;
76
+ return paths.some((p) => isUnder(f, p));
77
+ };
78
+ }
79
+ // finalize remainder도 execute와 같은 권한이다. 파생 memory 승격 예외는 두지 않아
80
+ // 일반 task가 샤드를 몰래 커밋하는 회귀를 막는다. repoRoot는 호출 계약을 유지한다.
81
+ function makeFinalizeAllowed({ repoRoot, affectedPaths, blueprintDir }) {
82
+ void repoRoot;
83
+ return makeAllowed({ affectedPaths, blueprintDir });
84
+ }
85
+ const TASK_DOC_RE = /(?:^|\/)tasks\/(\d{3})\/tasks\.md$/;
86
+ // 부재와 판독 불가는 다른 상태다. 깨진 원장을 "없음"으로 접으면 main worktree
87
+ // 거절이 조용히 풀리므로, 읽기 실패는 별도 상태로 올려 어디서든 막게 한다.
88
+ function readLedger(file) {
89
+ if (!fs.existsSync(file))
90
+ return { status: 'absent' };
91
+ try {
92
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
93
+ if (!parsed || typeof parsed !== 'object')
94
+ return { status: 'unreadable' };
95
+ return { status: 'ok', ledger: parsed };
96
+ }
97
+ catch (_e) {
98
+ // JSON 파싱·읽기 실패만 흡수한다. 내용을 신뢰할 수 없다는 사실 자체가
99
+ // 결과이고, 호출자는 이를 거절 사유로 쓴다.
100
+ return { status: 'unreadable' };
101
+ }
102
+ }
103
+ const LOCK_STALE_MS = 30000;
104
+ const LOCK_WAIT_MS = 2000;
105
+ const LOCK_RETRY_MS = 25;
106
+ function sleepSync(ms) {
107
+ // 동기 경로에서 재시도 간격을 만드는 표준 방법. busy loop로 돌면 잠금을 쥔
108
+ // 다른 프로세스의 진행까지 늦춘다.
109
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
110
+ }
111
+ // 토큰을 쓰기 전에 죽은 잠금만 이 값으로 판단한다 — 그 파일은 생성 이후 한 번도
112
+ // 손대지 않으므로 mtime이 곧 생성 시각이다.
113
+ function lockAgeMs(lockFile) {
114
+ try {
115
+ return Date.now() - fs.statSync(lockFile).mtimeMs;
116
+ }
117
+ catch (_e) {
118
+ // 사이에 잠금이 풀렸다 — 즉시 재시도하면 된다.
119
+ return null;
120
+ }
121
+ }
122
+ // 잠금의 주인은 파일 존재가 아니라 파일 안의 토큰이다. mtime만 보면 빈 채로
123
+ // 생성돼 한 번도 touch되지 않는 잠금이 실제보다 늙어 보이고, 소유 확인 없는
124
+ // 삭제는 두 대기자가 서로의 새 잠금을 지우며 동시에 임계 구역에 들어간다.
125
+ function readLockRecord(lockFile) {
126
+ try {
127
+ const parsed = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
128
+ if (!parsed || typeof parsed.token !== 'string' || typeof parsed.at !== 'number')
129
+ return null;
130
+ return parsed;
131
+ }
132
+ catch (_e) {
133
+ // 없거나, 아직 내용을 쓰는 중이거나, 깨졌다 — 어느 쪽이든 주인을 알 수 없다.
134
+ return null;
135
+ }
136
+ }
137
+ /**
138
+ * 방치된 잠금을 회수한다. 관측한 토큰과 같은 파일일 때만 지운다.
139
+ *
140
+ * 경로에 대고 지우면 그사이 새로 생긴 잠금을 뺏는다. 그래서 먼저 고유 이름으로
141
+ * rename하고(성공한 하나만 그 파일을 갖는다), 옮긴 내용이 관측한 토큰과 다르면
142
+ * 새 주인의 잠금이므로 되돌려 놓는다.
143
+ *
144
+ * 복원이 EEXIST도, 하드링크 불가도 아닌 이유로 실패하면 그 오류는 삼키지 않고
145
+ * 이 함수 밖으로 — 나아가 획득 루프 밖으로 — 전파된다.
146
+ *
147
+ * @returns {boolean} 회수했으면 true. 경합에 졌으면 false — 호출자는 다시 기다린다.
148
+ */
149
+ function reclaimStaleLock(lockFile, observedToken) {
150
+ const parked = `${lockFile}.stale.${process.pid}.${randomUUID()}`;
151
+ try {
152
+ fs.renameSync(lockFile, parked);
153
+ }
154
+ catch (_e) {
155
+ // 다른 대기자가 먼저 가져갔거나 주인이 풀었다.
156
+ return false;
157
+ }
158
+ const taken = readLockRecord(parked);
159
+ // 토큰이 관측값과 다르면(토큰 없던 자리에 새 주인이 생긴 경우 포함) 남의 잠금이다.
160
+ if ((taken ? taken.token : null) !== observedToken) {
161
+ try {
162
+ // 복원은 절대 덮어쓰면 안 된다. park와 복원 사이에 다른 프로세스가 `wx`로
163
+ // 빈자리를 가져갔을 수 있고, rename은 그 새 주인의 잠금을 말없이 지워
164
+ // 두 writer를 함께 임계 구역에 넣는다. link(2)는 대상이 있으면 EEXIST로
165
+ // 실패하므로 그 자리를 뺏지 않는다.
166
+ fs.linkSync(parked, lockFile);
167
+ }
168
+ catch (error) {
169
+ // 복원 실패는 세 갈래다.
170
+ // 1. EEXIST — 자리가 점유됐다. 덮어쓰지 않고 회수 실패로 물러난다.
171
+ // 2. 하드링크를 걸 수 없는 오류 — 이름 바꾸기 복원으로 물러난다.
172
+ // 이 갈래에서는 비덮어쓰기 보장이 성립하지 않는다.
173
+ // 3. 그 밖의 실패 — 삼키지 않고 올리되 park 사본을 남긴다.
174
+ const code = error.code;
175
+ // parked는 lockFile과 같은 디렉터리이므로 device를 넘지 않는다 — EXDEV는 여기서
176
+ // 나올 수 없고, 났다 해도 아래 renameSync 폴백이 같은 이유로 실패하므로 목록에 없다.
177
+ if (code === 'EPERM' || code === 'ENOTSUP') {
178
+ // 갈래 2. 이 자리에 하드링크를 걸 수 없다. 파일시스템이 하드링크를 지원하지 않는
179
+ // 경우만이 아니라, 리눅스 `fs.protected_hardlinks=1`(대부분의 배포판 기본값)
180
+ // 아래에서 남의 소유 파일에 링크할 때도 EPERM이 난다 — 원인은 단정하지 않는다.
181
+ // link 이전 구현이 쓰던 이름 바꾸기 복원으로 물러난다. rename은 자리를 덮어쓰므로
182
+ // 이 갈래에서는 비덮어쓰기 보장이 성립하지 않는다 — 그사이 빈자리를 가져간
183
+ // 새 주인의 잠금 레코드는 여기서 지워진다. 그 밀려난 획득자는 임계 구역 안에서
184
+ // 조용히 진행하지 않는다: 원장에 쓰기 직전의 소유 확인(`owns()`)이 자기 토큰이
185
+ // 사라진 것을 보고 `ledger-lock-lost`로 걸러 준다. 정상 동작하던 경로를 예외로
186
+ // 바꾸고 원 소유자의 레코드까지 잃는 것보다 이 트레이드오프가 낫다.
187
+ fs.renameSync(parked, lockFile);
188
+ return false;
189
+ }
190
+ // 갈래 3. 자리가 이미 채워진 EEXIST(갈래 1)만 흡수한다 — 복원을 포기하고 다시
191
+ // 기다리면 되는 정상 경합 결과다. 권한·경로 오류까지 삼키면 잠금 상태를 잘못 보고한다.
192
+ if (code !== 'EEXIST') {
193
+ // park 사본은 지우지 않는다. 이 시점에 lockFile은 비어 있고 복원도 실패했으므로,
194
+ // 지우면 살아 있을 수 있는 원 소유자의 잠금 레코드가 영구히 사라진다.
195
+ // 남겨 두면 사람이 그 파일로 복구할 수 있다.
196
+ throw error;
197
+ }
198
+ }
199
+ // 복원했으면 link로 남은 사본을, 실패했으면 아무도 읽지 않을 park 파일을 지운다.
200
+ // readLockRecord는 lockFile만 열므로 남겨 두면 runtime 디렉터리에 쌓이기만 하고,
201
+ // 밀려난 원 소유자는 release 시점에 소유 상실을 감지한다.
202
+ fs.rmSync(parked, { force: true });
203
+ return false;
204
+ }
205
+ fs.rmSync(parked, { force: true });
206
+ return true;
207
+ }
208
+ // 내 토큰일 때만 잠금을 푼다. 뺏긴 뒤에도 지우면 새 주인의 임계 구역을 연다.
209
+ function releaseLock(lockFile, token) {
210
+ const held = readLockRecord(lockFile);
211
+ if (!held || held.token !== token)
212
+ return false;
213
+ fs.rmSync(lockFile, { force: true });
214
+ return true;
215
+ }
216
+ /**
217
+ * ledger의 read-modify-write를 직렬화한다.
218
+ *
219
+ * 여러 worker worktree가 같은 파일 하나를 고치므로, 원자적 쓰기만으로는
220
+ * 한 task의 actualPaths가 사라지거나 두 revision이 같은 번호를 발급한다.
221
+ * `wx` 생성으로 잠금을 잡고, 잠금 레코드가 `LOCK_STALE_MS`보다 오래됐을 때만
222
+ * 소유권을 확인하며 회수한다.
223
+ *
224
+ * @param {string} ledgerFile - 대상 원장 절대 경로
225
+ * @param {Function} run - 잠금 안에서 읽고 쓰는 작업. 인자로 받은 `owns()`를
226
+ * 원장에 쓰기 직전에 호출해 아직 내가 주인인지 확인한다.
227
+ * @returns run의 결과. 잠금을 못 잡으면 `{ ok: false, reason: 'ledger-locked' }`,
228
+ * 임계 구역 도중 잠금을 뺏겼으면 `{ ok: false, reason: 'ledger-lock-lost' }` —
229
+ * 그 경우 쓰기가 다른 writer와 겹쳤을 수 있으므로 성공으로 보고하지 않는다.
230
+ */
231
+ function withLedgerLock(ledgerFile, run) {
232
+ const lockFile = `${ledgerFile}.lock`;
233
+ fs.mkdirSync(path.dirname(ledgerFile), { recursive: true });
234
+ const deadline = Date.now() + LOCK_WAIT_MS;
235
+ const record = { pid: process.pid, token: randomUUID(), at: Date.now() };
236
+ for (;;) {
237
+ try {
238
+ // 생성과 토큰 쓰기를 한 호출로 합친다. 따로 열고 쓰면 그사이 다른 대기자가
239
+ // 토큰 없는 잠금을 보고 주인을 알 수 없다고 판단하는 창이 길어진다.
240
+ // (O_CREAT|O_EXCL 뒤 write이므로 0바이트 잠금 자체가 사라지지는 않는다.)
241
+ fs.writeFileSync(lockFile, JSON.stringify(record), { flag: 'wx' });
242
+ }
243
+ catch (error) {
244
+ // 이미 잠긴 경우만 재시도한다. 권한·경로 오류는 삼키지 않고 올린다.
245
+ if (error.code !== 'EEXIST')
246
+ throw error;
247
+ const held = readLockRecord(lockFile);
248
+ // 토큰이 있으면 그 레코드의 시각으로 판단한다. 토큰이 없는 잠금은 `wx`
249
+ // 생성과 토큰 쓰기 사이에 죽은 흔적이므로, 그때만 파일 mtime(=생성 시각)을
250
+ // 쓴다 — 살아 있는 주인의 잠금을 mtime으로 뺏는 경로는 남기지 않는다.
251
+ const age = held ? Date.now() - held.at : lockAgeMs(lockFile);
252
+ if (age !== null && age > LOCK_STALE_MS) {
253
+ reclaimStaleLock(lockFile, held ? held.token : null);
254
+ }
255
+ else if (Date.now() >= deadline) {
256
+ return { ok: false, reason: 'ledger-locked' };
257
+ }
258
+ else {
259
+ sleepSync(LOCK_RETRY_MS);
260
+ }
261
+ continue;
262
+ }
263
+ break;
264
+ }
265
+ // 지금 이 잠금 파일의 주인이 나인지만 보고한다. 실제로 물러나는 판단은 호출자가
266
+ // 쓰기 직전에 이 함수를 불러 내린다 — 아래 `run(owns)`에 넘긴다.
267
+ const owns = () => {
268
+ const held = readLockRecord(lockFile);
269
+ return held !== null && held.token === record.token;
270
+ };
271
+ let result;
272
+ try {
273
+ result = run(owns);
274
+ }
275
+ catch (error) {
276
+ // 임계 구역이 던져도 잠금은 반드시 푼다 — 그 뒤 예외는 그대로 올린다.
277
+ releaseLock(lockFile, record.token);
278
+ throw error;
279
+ }
280
+ // 풀 때 내 토큰이 아니면 도중에 잠금을 뺏긴 것이다. 다른 writer와 겹쳤을 수
281
+ // 있으므로 성공으로 보고하지 않는다.
282
+ if (!releaseLock(lockFile, record.token))
283
+ return { ok: false, reason: 'ledger-lock-lost' };
284
+ return result;
285
+ }
286
+ function writeLedger(file, data) {
287
+ fs.mkdirSync(path.dirname(file), { recursive: true });
288
+ const temporary = `${file}.${process.pid}.tmp`;
289
+ fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`);
290
+ fs.renameSync(temporary, file);
291
+ }
292
+ /**
293
+ * `bouncer run detach`가 남긴 `detached` 상태인지 본다.
294
+ *
295
+ * @param {Ledger} ledger - 읽은 원장
296
+ * @returns {boolean} 분리된 실행이면 true
297
+ */
298
+ function isDetached(ledger) {
299
+ return ledger.status === 'detached';
300
+ }
301
+ /**
302
+ * `main-worktree-source-write` 거절에 붙일 다음 행동 안내를 고른다.
303
+ * commit hook과 CLI commit이 같은 문구를 쓰도록 한 곳에 둔다.
304
+ *
305
+ * 모든 commit task가 `integrated`이면 구현이 끝난 실행이 남은 것이므로(Git 병합 뒤 흔한
306
+ * 경우) 사용자 승인 뒤 `run detach`를 안내한다. 병합 여부는 여기서 판정하지 않는다 —
307
+ * 안내일 뿐이고 detach 자체도 병합을 조건으로 보지 않는다.
308
+ *
309
+ * @param {{ tasks?: Array<{ status?: string, execution_kind?: string }> }} coordinator - 거절된 컨텍스트
310
+ * @param {unknown} blueprint - blueprint 상대 경로
311
+ * @returns {string} 사람이 따라 할 한 줄 안내
312
+ */
313
+ function mainWorktreeNext(coordinator, blueprint) {
314
+ const commitTasks = ((coordinator && coordinator.tasks) || [])
315
+ .filter((item) => item && item.execution_kind !== 'verification');
316
+ const allIntegrated = commitTasks.length > 0 && commitTasks.every((item) => item.status === 'integrated');
317
+ if (allIntegrated) {
318
+ return 'If the Git merge is done, after the user approves run '
319
+ + `\`bouncer run detach --blueprint ${blueprint} --yes\`; otherwise run /bouncer-finalize.`;
320
+ }
321
+ return 'Work in the execution worktree, or continue the drive with /bouncer-run.';
322
+ }
323
+ function realOf(target) {
324
+ const resolved = path.resolve(String(target));
325
+ try {
326
+ return fs.realpathSync(resolved);
327
+ }
328
+ catch (_e) {
329
+ // 아직 없는 경로는 비교 대상이 아니므로 resolve 값만 돌려준다.
330
+ return resolved;
331
+ }
332
+ }
333
+ function coordinatorTaskId(task) {
334
+ if (typeof task !== 'string' || !task)
335
+ return null;
336
+ if (/^\d{3}$/.test(task))
337
+ return task;
338
+ const match = TASK_DOC_RE.exec(toPosix(task));
339
+ return match ? match[1] : null;
340
+ }
341
+ function taskDocPath(repoRoot, blueprint, taskId) {
342
+ return path.join(String(repoRoot), toPosix(blueprint), 'tasks', taskId, 'tasks.md');
343
+ }
344
+ function bouncerOf(data) {
345
+ if (!data || typeof data !== 'object')
346
+ return null;
347
+ const bouncer = data.bouncer;
348
+ return bouncer && typeof bouncer === 'object' ? bouncer : null;
349
+ }
350
+ // revision의 정본은 그 task를 맡은 worker의 문서다. 커밋 중인 checkout만 보면,
351
+ // 아직 cherry-pick 전인 integration 사본에 `scope_revision`이 없어 정상 상태가
352
+ // stale로 막힌다. worker 경로가 사라졌으면 현재 checkout으로 되돌아간다.
353
+ function docScopeRevision(repoRoot, blueprint, taskId, workerPath) {
354
+ const base = workerPath && fs.existsSync(workerPath) ? workerPath : repoRoot;
355
+ try {
356
+ const bouncer = bouncerOf(readDoc(taskDocPath(base, blueprint, taskId)).data);
357
+ const revision = bouncer ? bouncer.scope_revision : undefined;
358
+ return typeof revision === 'string' ? revision : null;
359
+ }
360
+ catch (_e) {
361
+ // 문서를 못 읽으면 revision을 확인할 수 없다. null을 돌려주면 원장에 기록된
362
+ // revision과 어긋나 stale로 막힌다 — 열어 주는 쪽으로 흡수하지 않는다.
363
+ return null;
364
+ }
365
+ }
366
+ // coordinator가 넓힐 수 있는 것은 이 저장소 안의 소스 경로뿐이다. 규칙과
367
+ // 원장 자신이 사는 `.bouncer/`, Git 내부 `.git/`, 저장소 밖(절대 경로·`..`),
368
+ // 그리고 트리 전체를 뜻하는 표기는 scope로 받지 않는다. 새로 발견한 소스
369
+ // 경로는 그대로 통과하므로 실행 중 확장 능력 자체는 줄지 않는다.
370
+ const SCOPE_WHOLE_TREE = new Set(['.', './', '/']);
371
+ // scope는 glob 어휘가 아니다. `makeAllowed`는 `isUnder`로 리터럴 접두만 비교하므로
372
+ // `src/**` 같은 표기는 어떤 파일과도 맞지 않는다. 그대로 통과시키면 원장과 문서에
373
+ // 정본으로 적힌 뒤 다음 커밋이 "허용하려던 그 경로"를 out-of-scope로 거절한다.
374
+ function globScopePaths(entries) {
375
+ return entries.filter((entry) => toPosix(entry).includes('*'));
376
+ }
377
+ // 범위 밖 경로를 골라낸다. `approved`는 직전 승인 scope의 경로다: plan이 승인한
378
+ // `.bouncer/epics.json` 같은 경로를 가진 task도 scope를 개정할 수 있도록, 정규화 값
379
+ // (앞 `./`·뒤 `/` 제거, POSIX 구분자)이 승인 경로와 정확히 같은 항목만 면제한다.
380
+ // `isUnder` 접두 비교로 넓히지 않는다 — 승인 경로의 하위 경로나 새 `.bouncer/`·`.git/`
381
+ // 경로까지 열면 coordinator가 규칙·원장 영역으로 scope를 넓히는 길이 된다.
382
+ function outOfBoundsScopePaths(entries, approved = []) {
383
+ const canonical = (raw) => {
384
+ let p = toPosix(raw).trim();
385
+ while (p.startsWith('./'))
386
+ p = p.slice(2);
387
+ if (p.length > 1 && p.endsWith('/'))
388
+ p = p.slice(0, -1);
389
+ return p;
390
+ };
391
+ const exempt = new Set(approved.map(canonical));
392
+ return entries.filter((entry) => {
393
+ if (exempt.has(canonical(entry)))
394
+ return false;
395
+ const p = toPosix(entry).trim();
396
+ if (!p || SCOPE_WHOLE_TREE.has(p))
397
+ return true;
398
+ if (path.isAbsolute(p) || p.startsWith('~'))
399
+ return true;
400
+ if (/(^|\/)\.\.(\/|$)/.test(p))
401
+ return true;
402
+ return isUnder(p, '.bouncer') || isUnder(p, '.git');
403
+ });
404
+ }
405
+ function nextRevision(current) {
406
+ // ledger lock과 revision 모델: read-modify-write를 직렬화해 동시 worker가
407
+ // 같은 revision을 두 번 발행하거나 로그 항목을 잃지 않게 한다. lock을 제때
408
+ // 못 잡으면 즉시 거절하고, task 문서 쓰기 전과 ledger 쓰기 전에 소유를 다시
409
+ // 확인해 잃은 쪽에서 쓰지 않고 물러난다. 두 검사 사이에 lock을 잃으면 문서는
410
+ // 쓰고 ledger는 안 써서 문서의 scope_revision이 ledger에 없는 상태가 된다.
411
+ // 손수 고칠 필요는 없다 — nextRevision은 ledger revision만의 함수이고 문서
412
+ // scope_revision을 사전 검사하지 않아 다음 개정이 양쪽을 한 번호로 맞춘다.
413
+ // 그 전까지 불일치 문서는 stale이며, 읽지 못하는 ledger·ledger에 없는 pointer task와 같이 commit safety가 어느 쪽이 최신인지 추측하지 않고 거절한다.
414
+ const match = /^r(\d+)$/.exec(typeof current === 'string' ? current : '');
415
+ return `r${match ? Number(match[1]) + 1 : 1}`;
416
+ }
417
+ function coordinatorPaths(repoRoot, blueprint, taskId) {
418
+ const runtime = runtimePaths({ repoRoot: String(repoRoot) });
419
+ if (runtime.unavailable)
420
+ return null;
421
+ const paths = coordinatorPathsFor({
422
+ repoRoot: String(repoRoot), blueprint, task: taskId || undefined,
423
+ });
424
+ return { runtime, paths };
425
+ }
426
+ function readCoordinatorLedger({ repoRoot, blueprint }) {
427
+ let resolved;
428
+ try {
429
+ resolved = coordinatorPaths(repoRoot, blueprint, null);
430
+ }
431
+ catch (_e) {
432
+ // epic/blueprint id를 못 뽑는 레거시 경로는 coordinator 대상이 아니다.
433
+ return { ok: false, reason: 'no-coordinator-paths' };
434
+ }
435
+ if (!resolved)
436
+ return { ok: false, reason: 'no-git' };
437
+ const read = readLedger(resolved.paths.ledgerFile);
438
+ // 실패에도 경로를 실어 보낸다. 어느 원장이 문제인지 모르면 손상 상태를
439
+ // 보고받은 쪽이 고칠 파일을 찾을 수 없다.
440
+ if (read.status !== 'ok') {
441
+ return {
442
+ ok: false,
443
+ reason: read.status === 'absent' ? 'no-ledger' : 'unreadable-ledger',
444
+ ledgerFile: resolved.paths.ledgerFile,
445
+ integrationPath: resolved.paths.integrationPath,
446
+ };
447
+ }
448
+ return {
449
+ ok: true,
450
+ ledger: read.ledger,
451
+ ledgerFile: resolved.paths.ledgerFile,
452
+ integrationPath: resolved.paths.integrationPath,
453
+ };
454
+ }
455
+ function inactiveContext(reason) {
456
+ return {
457
+ active: false,
458
+ reason,
459
+ taskId: null,
460
+ revision: null,
461
+ scope: null,
462
+ actualPaths: [],
463
+ resources: [],
464
+ workerSha: null,
465
+ workerPath: null,
466
+ integrationPath: null,
467
+ integrationHead: null,
468
+ ledgerFile: null,
469
+ tasks: [],
470
+ };
471
+ }
472
+ /**
473
+ * 지금 커밋을 판정할 coordinator 권한을 읽는다.
474
+ *
475
+ * `active: false`면 coordinator 실행이 아니다 — 호출자는 승인된
476
+ * `affected_paths`로 예전처럼 판정한다. `active: true`인데 `reason`이 있으면
477
+ * 그 자체가 거절 사유이고, `scope`는 신뢰할 수 없다.
478
+ * 활성 task의 `exclusive_resources`도 함께 반환해 커밋 시 다른 drive와 비교한다.
479
+ *
480
+ * @param {object} opts
481
+ * @param {unknown} opts.repoRoot - 커밋이 일어나는 checkout(= cwd)
482
+ * @param {unknown} opts.blueprint - 활성 blueprint 상대 경로
483
+ * @param {unknown} [opts.task] - 포인터 task 경로 또는 `NNN`
484
+ * @returns {CoordinatorContext}
485
+ */
486
+ function coordinatorContext({ repoRoot, blueprint, task }) {
487
+ const taskId = coordinatorTaskId(task);
488
+ let resolved;
489
+ try {
490
+ resolved = coordinatorPaths(repoRoot, blueprint, taskId);
491
+ }
492
+ catch (_e) {
493
+ return inactiveContext('no-coordinator-paths');
494
+ }
495
+ if (!resolved)
496
+ return inactiveContext('no-git');
497
+ const { runtime, paths } = resolved;
498
+ const here = realOf(repoRoot);
499
+ const workerPath = paths.workerPath || null;
500
+ const inIntegration = here === realOf(paths.integrationPath);
501
+ const inWorker = Boolean(workerPath) && here === realOf(workerPath);
502
+ const read = readLedger(paths.ledgerFile);
503
+ // 원장도 없고 coordinator worktree도 아니면 이 저장소는 위임 실행 중이 아니다.
504
+ if (read.status === 'absent' && !inIntegration && !inWorker)
505
+ return inactiveContext('no-ledger');
506
+ const base = {
507
+ ...inactiveContext('no-ledger'),
508
+ active: true,
509
+ reason: null,
510
+ taskId,
511
+ workerPath,
512
+ integrationPath: paths.integrationPath,
513
+ ledgerFile: paths.ledgerFile,
514
+ };
515
+ // 읽을 수 없는 원장은 어느 checkout에서도 판정 불가다 — main worktree 거절이
516
+ // 파일 손상만으로 풀리지 않게, 위치를 보기 전에 먼저 막는다.
517
+ if (read.status === 'unreadable')
518
+ return { ...base, reason: 'unreadable-ledger' };
519
+ // 할당된 worktree에서 원장이 사라진 상태를 "범위 없음"으로 통과시키지 않는다.
520
+ if (read.status === 'absent')
521
+ return { ...base, reason: 'missing-coordinator-ledger' };
522
+ const { ledger } = read;
523
+ // 분리된(run detach) 실행은 더 이상 활성 drive가 아니다. 병합 뒤 메인 문서 커밋이
524
+ // main-worktree-source-write로 영원히 막히지 않게 비활성으로 돌려 일반 scope 판정에 맡긴다.
525
+ // 위치(main/worker) 판정보다 앞서야 integration·worker cwd도 같은 답을 얻는다 —
526
+ // 그 cwd의 task 쓰기는 taskWriteBoundary·assignedLedgerTask가 run-detached로 거절한다.
527
+ if (isDetached(ledger))
528
+ return inactiveContext('run-detached');
529
+ if (here === realOf(runtime.projectRoot)) {
530
+ // tasks를 함께 실어 거절 안내(mainWorktreeNext)가 병합 뒤 상태인지 판별하게 한다.
531
+ // 이 거절 경로는 scope를 쓰지 않으므로 tasks 추가가 판정을 바꾸지 않는다.
532
+ return {
533
+ ...base,
534
+ tasks: Array.isArray(ledger.tasks) ? ledger.tasks : [],
535
+ reason: 'main-worktree-source-write',
536
+ };
537
+ }
538
+ if (!inIntegration && !inWorker)
539
+ return { ...base, reason: 'unassigned-worktree' };
540
+ const tasks = Array.isArray(ledger.tasks) ? ledger.tasks : [];
541
+ const entry = taskId ? tasks.find((item) => item && item.id === taskId) : undefined;
542
+ const ledgerRevision = entry && entry.scope ? entry.scope.revision : null;
543
+ const withLedger = {
544
+ ...base,
545
+ tasks,
546
+ integrationHead: typeof ledger.integrationHead === 'string' ? ledger.integrationHead : null,
547
+ revision: ledgerRevision,
548
+ scope: entry && entry.scope ? entry.scope.paths : null,
549
+ actualPaths: entry && Array.isArray(entry.actualPaths) ? entry.actualPaths : [],
550
+ resources: entry && Array.isArray(entry.exclusive_resources) ? entry.exclusive_resources : [],
551
+ workerSha: entry && typeof entry.sha === 'string' ? entry.sha : null,
552
+ };
553
+ // 포인터가 가리키는 task가 원장에 없으면 이 실행이 맡은 task가 아니다.
554
+ // reviseTaskScope가 같은 상황을 거절하므로 여기서도 같은 코드로 막는다.
555
+ if (taskId && !entry)
556
+ return { ...withLedger, reason: 'task-outside-blueprint' };
557
+ // task 문서와 원장이 같은 revision일 때만 scope가 정본이다.
558
+ if (taskId && docScopeRevision(repoRoot, blueprint, taskId, entry && entry.workerPath) !== ledgerRevision) {
559
+ return { ...withLedger, reason: 'stale-revision' };
560
+ }
561
+ return withLedger;
562
+ }
563
+ /**
564
+ * 원장을 읽지 않고도 판단할 수 있는 쓰기 경계만 먼저 본다.
565
+ *
566
+ * 권한 없는 호출이 잠금 파일과 runtime 디렉터리를 먼저 만들고, 그동안 정당한
567
+ * worker를 막는 일이 없도록 순서만 앞당긴 검사다. 잠금 안의 전체 검사
568
+ * (`assignedLedgerTask`)는 그대로 남는다 — 두 번 보는 것이 안전의 근거다.
569
+ */
570
+ function taskWriteBoundary({ repoRoot, blueprint, taskId }) {
571
+ let resolved;
572
+ try {
573
+ resolved = coordinatorPaths(repoRoot, blueprint, taskId);
574
+ }
575
+ catch (_e) {
576
+ return { ok: false, reason: 'no-coordinator-paths' };
577
+ }
578
+ if (!resolved)
579
+ return { ok: false, reason: 'no-git' };
580
+ const { runtime, paths } = resolved;
581
+ const here = realOf(repoRoot);
582
+ if (here === realOf(runtime.projectRoot)) {
583
+ return { ok: false, reason: 'main-worktree-source-write' };
584
+ }
585
+ if (!paths.workerPath || here !== realOf(paths.workerPath)) {
586
+ return { ok: false, reason: 'unassigned-worktree' };
587
+ }
588
+ // 경계 검사에서 원장 내용은 보지 않는다는 원칙의 유일한 예외: 분리된 실행의 worktree는
589
+ // 더 이상 task를 쓸 자리가 아니다. 읽기만 하고 잠금·디렉터리는 만들지 않는다.
590
+ const read = readLedger(paths.ledgerFile);
591
+ if (read.status === 'ok' && isDetached(read.ledger)) {
592
+ return { ok: false, reason: 'run-detached' };
593
+ }
594
+ return { ok: true, ledgerFile: paths.ledgerFile };
595
+ }
596
+ function assignedLedgerTask({ repoRoot, blueprint, taskId }) {
597
+ let resolved;
598
+ try {
599
+ resolved = coordinatorPaths(repoRoot, blueprint, taskId);
600
+ }
601
+ catch (_e) {
602
+ return { ok: false, reason: 'no-coordinator-paths' };
603
+ }
604
+ if (!resolved)
605
+ return { ok: false, reason: 'no-git' };
606
+ const { runtime, paths } = resolved;
607
+ const here = realOf(repoRoot);
608
+ // 문서와 원장 쓰기는 할당된 worktree에서만. main checkout은 읽기 전용 출처다.
609
+ if (here === realOf(runtime.projectRoot))
610
+ return { ok: false, reason: 'main-worktree-source-write' };
611
+ const read = readLedger(paths.ledgerFile);
612
+ if (read.status !== 'ok') {
613
+ return { ok: false, reason: read.status === 'absent' ? 'missing-ledger' : 'unreadable-ledger' };
614
+ }
615
+ const { ledger } = read;
616
+ if (isDetached(ledger))
617
+ return { ok: false, reason: 'run-detached' };
618
+ const tasks = Array.isArray(ledger.tasks) ? ledger.tasks : [];
619
+ const entry = tasks.find((item) => item && item.id === taskId);
620
+ if (!entry)
621
+ return { ok: false, reason: 'task-outside-blueprint' };
622
+ const worker = paths.workerPath;
623
+ if (!worker || !entry.workerPath || realOf(entry.workerPath) !== realOf(worker) || here !== realOf(worker)) {
624
+ return { ok: false, reason: 'unassigned-worktree' };
625
+ }
626
+ return { ok: true, ledger, entry, ledgerFile: paths.ledgerFile };
627
+ }
628
+ /**
629
+ * 잠금 안에서 원장을 읽고 → 고치고 → 쓴다.
630
+ *
631
+ * 읽기를 잠금 밖에 두면 두 worker가 같은 revision 번호를 발급하거나 서로의
632
+ * actualPaths를 덮어쓴다. 그래서 경계 검사(assignedLedgerTask)까지 잠금
633
+ * 안에서 다시 수행한다.
634
+ */
635
+ function lockedTaskWrite(repoRoot, blueprint, taskId, mutate) {
636
+ const boundary = taskWriteBoundary({ repoRoot, blueprint, taskId });
637
+ if (!boundary.ok)
638
+ return { ok: false, reason: boundary.reason };
639
+ return withLedgerLock(boundary.ledgerFile, (owns) => {
640
+ const assigned = assignedLedgerTask({ repoRoot, blueprint, taskId });
641
+ if (!assigned.ok)
642
+ return { ok: false, reason: assigned.reason };
643
+ return mutate({ ...assigned, owns });
644
+ });
645
+ }
646
+ /**
647
+ * scope 경로가 integration HEAD에서 실제로 추적되는지 검사한다. coordinator가
648
+ * 존재하지 않는 파일을 추측해 `--paths`로 넘기면 worker가 그 경로를 만들 수
649
+ * 없어 재배정이 반복되므로, 생성 시점에 `git ls-files`로 걸러 낸다.
650
+ *
651
+ * - `paths`: 이미 있어야 하는 경로. `git ls-files -- <p>`가 0줄이면 untracked.
652
+ * - `newPaths`: 새로 만들 파일. 파일 자신은 없어도 되지만 부모 디렉터리는 추적
653
+ * 파일의 조상이어야 한다(저장소 루트 직하 파일은 부모가 루트라 통과).
654
+ * git 실행 실패는 삼키지 않고 그대로 던진다 — "추적 안 됨"으로 접으면 저장소
655
+ * 오류가 경로 오류로 오진된다.
656
+ *
657
+ * @param {TrackedExec} exec - execFileSync 호환 실행기
658
+ * @param {string} integrationPath - integration worktree 절대 경로
659
+ * @param {string[]} paths - 추적돼야 하는 경로
660
+ * @param {string[]} newPaths - 새로 만들 경로
661
+ * @returns {{ untracked: string[], parentMissing: string[] }} 위반 항목(입력 표기 그대로)
662
+ */
663
+ function trackedScopeViolations(exec, integrationPath, paths, newPaths) {
664
+ // --literal-pathspecs: `:!x`·`:(icase)`·`:/` 같은 pathspec magic을 해석하면 이름이 다른
665
+ // 추적 파일과 맞아 untracked 사전 거절을 우회한다. 입력을 글자 그대로 비교한다
666
+ // (review-snapshot.ts와 같은 이유). TrackedExec에 env가 없어 argv로 넘긴다.
667
+ const tracked = (entry) => String(exec('git', ['--literal-pathspecs', 'ls-files', '--', entry], { cwd: integrationPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).trim() !== '';
668
+ const untracked = paths.filter((entry) => !tracked(entry));
669
+ const parentMissing = newPaths.filter((entry) => {
670
+ const parent = path.posix.dirname(path.posix.normalize(toPosix(entry).replace(/\/+$/, '')));
671
+ return parent !== '.' && !tracked(`${parent}/`);
672
+ });
673
+ return { untracked, parentMissing };
674
+ }
675
+ /**
676
+ * scope 경로 한 항목을 비교용으로 정규화한다(앞 `./` 제거, 끝 `/` 제거).
677
+ * revise의 "새 경로" 판정, 겹침 판정, 축소 경고가 같은 규칙을 써야 서로 다른
678
+ * 표기가 다른 경로로 갈리지 않는다.
679
+ *
680
+ * @param {string} raw - 정규화 전 경로
681
+ * @returns {string} 정규화한 경로
682
+ */
683
+ function normalizeScopeEntry(raw) {
684
+ let p = raw;
685
+ while (p.startsWith('./'))
686
+ p = p.slice(2);
687
+ if (p.length > 1 && p.endsWith('/'))
688
+ p = p.slice(0, -1);
689
+ return p;
690
+ }
691
+ // Do not touch 제목 정규식은 validate-sections의 SECTION_DEFS가 정본이다(영문·한국어 제목).
692
+ // 여기서 복제하면 제목 규칙이 갈라져 G/S 검사와 revise가 다른 절을 보게 된다.
693
+ const DO_NOT_TOUCH_HEADING_RE = SECTION_DEFS.find((def) => def.key === 'doNotTouch').re;
694
+ /**
695
+ * 한 줄에서 HTML 주석 구간(`<!-- … -->`, 줄을 넘는 주석 포함)을 같은 길이의 공백으로 가린다.
696
+ * 주석 상태는 문자 단위로 추적해, 한 줄에서 주석이 닫혔다 다시 열려도(`--> <!--`) 주석 안쪽만
697
+ * 가려진다. 길이를 보존하므로 가린 줄에서 얻은 인덱스를 원본 줄에 그대로 쓸 수 있다.
698
+ *
699
+ * @param {string} line - 원본 줄
700
+ * @param {boolean} inComment - 이 줄이 주석 안에서 시작하는지
701
+ * @returns {{masked: string, inComment: boolean}} 가린 줄과 줄 끝의 주석 상태
702
+ */
703
+ function maskHtmlComments(line, inComment) {
704
+ let masked = '';
705
+ let open = inComment;
706
+ let i = 0;
707
+ while (i < line.length) {
708
+ if (open) {
709
+ const end = line.indexOf('-->', i);
710
+ const stop = end === -1 ? line.length : end + 3;
711
+ masked += ' '.repeat(stop - i);
712
+ if (end !== -1)
713
+ open = false;
714
+ i = stop;
715
+ }
716
+ else {
717
+ const start = line.indexOf('<!--', i);
718
+ const stop = start === -1 ? line.length : start;
719
+ masked += line.slice(i, stop);
720
+ if (start !== -1)
721
+ open = true;
722
+ i = stop;
723
+ }
724
+ }
725
+ return { masked, inComment: open };
726
+ }
727
+ /**
728
+ * task 본문에서 Do not touch 절(제목 다음 줄부터 다음 `## ` 제목 앞까지)의 줄을 모은다.
729
+ * 절이 여러 개면 모두 대상이다. 제목 줄과 주석 구간은 판정·편집 대상이 아니다: 토큰은 주석을
730
+ * 가린 줄에서만 뽑으므로, 주석이 붙은 항목도 충돌로 잡히고 주석 안쪽 경로는 잡히지 않는다.
731
+ *
732
+ * @param {string[]} lines - 본문을 `\n`으로 나눈 줄
733
+ * @returns {DoNotTouchLine[]} 절 안 줄의 인덱스, 주석을 가린 줄, 줄 시작 시점의 주석 상태, `extractPathCandidates` 토큰
734
+ */
735
+ function doNotTouchLines(lines) {
736
+ const out = [];
737
+ let inSection = false;
738
+ let inComment = false;
739
+ for (let i = 0; i < lines.length; i += 1) {
740
+ const startsInComment = inComment;
741
+ const state = maskHtmlComments(lines[i], startsInComment);
742
+ inComment = state.inComment;
743
+ const { masked } = state;
744
+ if (/^##\s/.test(masked)) {
745
+ inSection = DO_NOT_TOUCH_HEADING_RE.test(masked.trim());
746
+ continue;
747
+ }
748
+ if (inSection)
749
+ out.push({ index: i, tokens: extractPathCandidates(masked), masked, startsInComment });
750
+ }
751
+ return out;
752
+ }
753
+ /**
754
+ * 한 줄에서 해제 토큰의 출현을 지운다. 백틱으로 감싼 출현과 앞뒤가 공백·`,`·`;`·줄 경계인
755
+ * 민 단어 출현만 대상이다. 지운 출현 뒤에 `, `가 있으면 함께 지우고, 없고 앞에 `, `가
756
+ * 있으면 그것을 지워 목록 구분자가 남지 않게 한다. 출현은 주석을 가린 줄에서 찾으므로
757
+ * 주석 안쪽 바이트는 건드리지 않는다.
758
+ *
759
+ * @param {string} line - 편집할 원본 줄
760
+ * @param {string} masked - 주석 구간을 같은 길이의 공백으로 가린 줄
761
+ * @param {Set<string>} released - 정규화한 해제 토큰 집합
762
+ * @returns {string} 편집한 원본 줄(출현이 없으면 입력과 같은 문자열)
763
+ */
764
+ function removeReleasedTokens(line, masked, released) {
765
+ const ranges = [];
766
+ for (const m of masked.matchAll(/`[^`]+`|[^\s,;]+/g)) {
767
+ const raw = m[0];
768
+ const isSpan = raw.startsWith('`') && raw.endsWith('`') && raw.length > 2;
769
+ // 백틱이 섞인 민 단어(`a`b 등)는 extractPathCandidates도 후보로 보지 않는다.
770
+ if (!isSpan && raw.includes('`'))
771
+ continue;
772
+ const value = toPosix((isSpan ? raw.slice(1, -1) : raw).trim()).replace(/^\.\//, '');
773
+ if (released.has(value))
774
+ ranges.push([m.index, m.index + raw.length]);
775
+ }
776
+ // 뒤에서부터 지워야 앞 출현의 인덱스가 유효하다. 구분자 판정은 가린 줄로 해서 주석 안쪽 `, `를 지우지 않는다.
777
+ let edited = line;
778
+ let editedMask = masked;
779
+ const cut = (start, end) => {
780
+ edited = edited.slice(0, start) + edited.slice(end);
781
+ editedMask = editedMask.slice(0, start) + editedMask.slice(end);
782
+ };
783
+ for (const [start, end] of ranges.reverse()) {
784
+ if (editedMask.startsWith(', ', end))
785
+ cut(start, end + 2);
786
+ else if (editedMask.slice(0, start).endsWith(', '))
787
+ cut(start - 2, end);
788
+ else
789
+ cut(start, end);
790
+ }
791
+ return edited;
792
+ }
793
+ /**
794
+ * Do not touch 충돌을 판정하고 해제 편집을 계산한다. 파일은 쓰지 않는다 — 판정이 끝나기 전에
795
+ * 바이트를 바꾸면 거절된 revise가 흔적을 남긴다.
796
+ *
797
+ * 충돌 토큰 = 절의 토큰 중 새 경로 하나 이상과 겹치는 것. 해제 값이 충돌 토큰이 아니면
798
+ * `release-do-not-touch-unknown`을 먼저, 해제되지 않은 충돌이 남으면
799
+ * `scope-do-not-touch-conflict`를 낸다. 해제는 scope를 넓히지 않고 본문 편집만 허용한다.
800
+ *
801
+ * @param {string} body - task 문서 본문
802
+ * @param {string[]} fresh - 정규화한 새 경로(직전 scope에 정확히 같은 값이 없는 것)
803
+ * @param {string[]} released - 정규화·중복 제거한 해제 값(입력 순서)
804
+ * @returns 거절이면 `{ ok: false, reason, paths }`, 아니면 편집한 본문
805
+ */
806
+ function planDoNotTouchRelease(body, fresh, released) {
807
+ const lines = body.split('\n');
808
+ const sectionLines = doNotTouchLines(lines);
809
+ const conflicts = [];
810
+ for (const { tokens } of sectionLines) {
811
+ for (const token of tokens) {
812
+ if (conflicts.includes(token))
813
+ continue;
814
+ const base = normalizeScopeEntry(token);
815
+ if (fresh.some((p) => pathsOverlap(base, p)))
816
+ conflicts.push(token);
817
+ }
818
+ }
819
+ const unknown = released.filter((value) => !conflicts.includes(value));
820
+ if (unknown.length > 0)
821
+ return { ok: false, reason: 'release-do-not-touch-unknown', paths: unknown };
822
+ const remaining = conflicts.filter((token) => !released.includes(token));
823
+ if (remaining.length > 0)
824
+ return { ok: false, reason: 'scope-do-not-touch-conflict', paths: remaining };
825
+ if (released.length === 0)
826
+ return { ok: true, body };
827
+ const releasedSet = new Set(released);
828
+ const drop = new Set();
829
+ for (const { index, tokens, masked, startsInComment } of sectionLines) {
830
+ if (!tokens.some((token) => releasedSet.has(token)))
831
+ continue;
832
+ const edited = removeReleasedTokens(lines[index], masked, releasedSet);
833
+ const editedMasked = maskHtmlComments(edited, startsInComment).masked;
834
+ // 경로가 하나도 남지 않은 줄은 빈 껍데기라 지운다. 단 주석 바이트가 남은 줄은 주석을 지우지
835
+ // 않으려고 남긴다. 해제 토큰이 없던 줄은 위에서 걸러져 보존된다.
836
+ if (extractPathCandidates(editedMasked).length === 0 && editedMasked === edited)
837
+ drop.add(index);
838
+ else
839
+ lines[index] = edited;
840
+ }
841
+ return { ok: true, body: lines.filter((_line, i) => !drop.has(i)).join('\n') };
842
+ }
843
+ /**
844
+ * coordinator가 실행 중 발견한 필수 경로로 task scope를 갱신한다.
845
+ * task 문서와 ledger를 같은 revision으로 옮기고, 이유 없는 변경은 거절한다.
846
+ * 저장소 밖·`.bouncer/`·`.git/`·트리 전체 표기는 scope로 받지 않는다. 단 직전 승인
847
+ * scope에 정확히 같은 경로로 이미 있던 항목은 유지할 수 있다(하위·새 경로는 아니다).
848
+ *
849
+ * `paths`는 task scope를 전달한 집합 전체로 교체한다(추가가 아니다). 이전 범위에서
850
+ * 빠졌는데 새 집합이 덮지 못한 경로가 있으면 성공 결과에 `warnings`로 알린다.
851
+ *
852
+ * task 문서의 Do not touch 절이 새 경로(직전 scope에 정확히 같은 값이 없는 경로)와 겹치면
853
+ * `scope-do-not-touch-conflict`로 거절한다. `releaseDoNotTouch`로 겹치는 항목을 하나씩
854
+ * 명시하면 frontmatter 갱신과 같은 파일 쓰기에서 그 토큰을 본문에서 지운다. 충돌 항목이
855
+ * 아닌 해제 값은 `release-do-not-touch-unknown`이다. 거절은 문서·원장을 쓰지 않는다.
856
+ * `releaseDoNotTouch`는 unknown으로 받아 비어 있지 않은 문자열만 쓴다.
857
+ *
858
+ * @returns {{ok: true, revision: string, previous: string[], paths: string[],
859
+ * revoked?: string[], releasedDoNotTouch?: string[],
860
+ * warnings?: Array<{code: 'scope-paths-dropped', paths: string[]}>}
861
+ * | {ok: false, reason: string}}
862
+ */
863
+ function reviseTaskScope({ repoRoot, blueprint, task, paths: nextPaths, newPaths, reason, exec, releaseDoNotTouch, }) {
864
+ const taskId = coordinatorTaskId(task);
865
+ if (!taskId)
866
+ return { ok: false, reason: 'task-required' };
867
+ const why = typeof reason === 'string' ? reason.trim() : '';
868
+ // 이유 없는 scope 변경은 감사할 수 없다 — 기록이 곧 권한이다.
869
+ if (!why)
870
+ return { ok: false, reason: 'decision-reason-required' };
871
+ const strings = (value) => (Array.isArray(value) ? value : [])
872
+ .filter((entry) => typeof entry === 'string' && entry !== '');
873
+ const existing = strings(nextPaths);
874
+ const created = strings(newPaths);
875
+ // 최종 scope는 --paths ∪ --new-path다(중복은 한 번만).
876
+ const next = [...new Set([...existing, ...created])];
877
+ if (next.length === 0)
878
+ return { ok: false, reason: 'scope-paths-required' };
879
+ // 해제 값은 Do not touch 토큰과 같은 정규화(toPosix, 앞 `./` 제거)로 맞춘다. 순서를 지키고 중복은 지운다.
880
+ const released = [...new Set(strings(releaseDoNotTouch).map((v) => toPosix(v.trim()).replace(/^\.\//, '')))]
881
+ .filter((v) => v !== '');
882
+ const globs = globScopePaths(next);
883
+ if (globs.length > 0) {
884
+ return { ok: false, reason: 'scope-path-glob', paths: globs };
885
+ }
886
+ return lockedTaskWrite(repoRoot, blueprint, taskId, ({ ledger, entry, ledgerFile, owns, }) => {
887
+ // coordinator assertLeaseShape / validateCoordinatorLedger와 같은 규칙.
888
+ // revise는 CLI 우회로 coordinate 잠금 경로를 안 타므로 여기서 막는다.
889
+ if (!isValidLeaseSeq(ledger.leaseSeq))
890
+ return { ok: false, reason: 'lease-invalid' };
891
+ const peersForLease = Array.isArray(ledger.tasks) ? ledger.tasks : [];
892
+ for (const peer of peersForLease) {
893
+ if (peer && peer.lease !== undefined && !isValidLease(peer.lease)) {
894
+ return { ok: false, reason: 'lease-invalid' };
895
+ }
896
+ }
897
+ const docAbs = taskDocPath(repoRoot, blueprint, taskId);
898
+ let doc;
899
+ try {
900
+ doc = readDoc(docAbs);
901
+ }
902
+ catch (_e) {
903
+ return { ok: false, reason: 'task-document-missing' };
904
+ }
905
+ const bouncer = bouncerOf(doc.data);
906
+ if (!bouncer)
907
+ return { ok: false, reason: 'task-document-missing' };
908
+ const declared = Array.isArray(bouncer.affected_paths)
909
+ ? bouncer.affected_paths.filter((p) => typeof p === 'string')
910
+ : [];
911
+ const previous = entry.scope ? entry.scope.paths : declared;
912
+ const revision = nextRevision(ledger.revision);
913
+ // 범위 밖 검사는 직전 승인 scope(`previous`)를 알아야 하므로 lock 안에서, 추적 검사보다
914
+ // 앞서 한다. 그래야 새 `.bouncer/` 경로가 scope-path-untracked로 위장되지 않고
915
+ // scope-path-out-of-bounds로 거절된다. 쓰기 전이라 거절 시 바이트는 그대로다.
916
+ const outOfBounds = outOfBoundsScopePaths(next, previous);
917
+ if (outOfBounds.length > 0) {
918
+ return { ok: false, reason: 'scope-path-out-of-bounds', paths: outOfBounds };
919
+ }
920
+ // 추적 검사는 task 문서·ledger 쓰기보다 앞선다: 거절된 revise가 바이트를
921
+ // 바꾸면 안 된다. 기준은 worker가 아니라 integration HEAD다 — worker
922
+ // worktree는 아직 통합되지 않은 파일을 가질 수 있어 근거가 되지 못한다.
923
+ const integrationPath = coordinatorPaths(repoRoot, blueprint, null)?.paths.integrationPath;
924
+ if (integrationPath) {
925
+ const violations = trackedScopeViolations(exec || realExecFileSync, integrationPath, existing, created);
926
+ if (violations.untracked.length > 0) {
927
+ return { ok: false, reason: 'scope-path-untracked', paths: violations.untracked };
928
+ }
929
+ if (violations.parentMissing.length > 0) {
930
+ return { ok: false, reason: 'scope-new-path-parent-missing', paths: violations.parentMissing };
931
+ }
932
+ }
933
+ // Do not touch 판정은 추적 검사 뒤, 잠금 재확인 앞이다. 이 절은 런타임이 강제하지 않아
934
+ // 승인 scope를 넓혀도 worker가 brief의 금지 항목과 부딪쳐 blocked를 보고했다. 새 경로(직전
935
+ // scope에 정확히 같은 값이 없는 것)와 겹치는 항목은 해제 플래그로 하나씩 명시해야 하고,
936
+ // 해제 편집은 아래 frontmatter 갱신과 같은 writeFileSync에 실린다. 거절은 아무것도 쓰지 않는다.
937
+ const previousNormalized = previous.map(normalizeScopeEntry);
938
+ const fresh = [...new Set(next.map(normalizeScopeEntry))].filter((p) => !previousNormalized.includes(p));
939
+ const release = planDoNotTouchRelease(doc.body, fresh, released);
940
+ if (!release.ok)
941
+ return release;
942
+ // 문서를 먼저 쓰면 안 된다. 여기서 잠금을 잃은 writer는 ledger에 결코 기록되지
943
+ // 않을 revision을 문서에만 남기고, 그 task는 ledger와 어긋난 stale 상태가 된다.
944
+ // 손으로 고칠 일은 아니다 — nextRevision은 원장 revision만의 함수이고 여기서
945
+ // 문서의 scope_revision을 사전 검사하지 않으므로, 다음 scope revision이 양쪽을
946
+ // 한 번호로 다시 써서 화해시킨다. 다만 저절로 낫지도 않는다: 그 revision이
947
+ // 나올 때까지 commit safety는 계속 거절한다. 그래서 문서 쓰기 앞에서 한 번 물러난다.
948
+ if (!owns())
949
+ return { ok: false, reason: 'ledger-lock-lost' };
950
+ bouncer.affected_paths = next;
951
+ bouncer.scope_revision = revision;
952
+ fs.writeFileSync(docAbs, renderDoc(doc.data, release.body));
953
+ // 해제가 없으면 키를 두지 않아 기존 decision·결과와 바이트가 같다.
954
+ const releasedKey = released.length > 0 ? { releasedDoNotTouch: released } : {};
955
+ const decision = {
956
+ task: taskId, kind: 'scope', reason: why, previous, next, revision, ...releasedKey,
957
+ };
958
+ entry.scope = { revision, paths: next };
959
+ entry.decisions = [...(entry.decisions || []), decision];
960
+ ledger.revision = revision;
961
+ // append-only: 이전 판단을 지우지 않고 뒤에 붙인다.
962
+ ledger.decisions = [...(Array.isArray(ledger.decisions) ? ledger.decisions : []), decision];
963
+ // 원장 쓰기 직전에도 다시 본다. 잃었다면 이 판단은 다른 writer가 읽은 원장 위에
964
+ // 얹히므로, 쓰지 않고 거절한다. (두 확인 사이의 창은 남는다 — 회수는 여전히
965
+ // take-then-check다.)
966
+ // scope 충돌: 새 경로가 다른 prepared·recorded active lease와 겹치면
967
+ // seq가 큰 쪽(후발)만 같은 잠금 안에서 revoke한다. revision 기록은 유지한다.
968
+ const revoked = [];
969
+ const normalize = normalizeScopeEntry;
970
+ const pathSetOf = (task) => {
971
+ if (task.scope && Array.isArray(task.scope.paths))
972
+ return task.scope.paths;
973
+ if (Array.isArray(task.affected_paths))
974
+ return task.affected_paths;
975
+ return null;
976
+ };
977
+ const overlaps = (left, right) => {
978
+ for (const a of left) {
979
+ for (const b of right) {
980
+ if (pathsOverlap(normalize(a), normalize(b)))
981
+ return true;
982
+ }
983
+ }
984
+ return false;
985
+ };
986
+ const peers = Array.isArray(ledger.tasks) ? ledger.tasks : [];
987
+ for (const other of peers) {
988
+ if (!other || other.id === taskId)
989
+ continue;
990
+ const status = other.status || 'pending';
991
+ if (status !== 'prepared' && status !== 'recorded')
992
+ continue;
993
+ if (!other.lease || other.lease.status !== 'active')
994
+ continue;
995
+ const otherPaths = pathSetOf(other);
996
+ if (!otherPaths || !overlaps(next, otherPaths))
997
+ continue;
998
+ // 개정 대상에 active lease가 없으면 겹친 peer만 후발로 본다(seq 비교 불가 시 peer revoke).
999
+ const entrySeq = entry.lease && entry.lease.status === 'active' ? entry.lease.seq : -1;
1000
+ const later = other.lease.seq > entrySeq ? other : entry;
1001
+ if (!later.lease || later.lease.status !== 'active')
1002
+ continue;
1003
+ if (revoked.includes(later.id))
1004
+ continue;
1005
+ revokeLease(ledger, later, {
1006
+ reason: 'scope-conflict',
1007
+ previousHead: typeof later.sha === 'string' ? later.sha : null,
1008
+ });
1009
+ revoked.push(later.id);
1010
+ }
1011
+ if (!owns())
1012
+ return { ok: false, reason: 'ledger-lock-lost' };
1013
+ writeLedger(ledgerFile, ledger);
1014
+ // --paths는 전체 교체인데 경로 추가로 착각해 새 경로만 넘기면 범위가 조용히
1015
+ // 줄었다. 거절하면 의도적 축소(재작업 되돌림)를 막으므로 경고만 붙인다.
1016
+ // "덮는다" = 정규화한 두 경로가 같거나 새 경로가 이전 경로의 상위 디렉터리.
1017
+ // 이 함수의 잠금 안에서 쓴 previous를 그대로 쓰므로 추가 읽기는 없다.
1018
+ const nextNormalized = next.map(normalize);
1019
+ const dropped = previous.filter((p) => {
1020
+ const prev = normalize(p);
1021
+ return !nextNormalized.some((n) => isUnder(prev, n));
1022
+ });
1023
+ return {
1024
+ ok: true,
1025
+ revision,
1026
+ previous,
1027
+ paths: next,
1028
+ ...releasedKey,
1029
+ ...(revoked.length > 0 ? { revoked } : {}),
1030
+ // 경고가 없으면 키를 두지 않아 기존 성공 결과와 바이트가 같다.
1031
+ ...(dropped.length > 0
1032
+ ? { warnings: [{ code: 'scope-paths-dropped', paths: dropped }] }
1033
+ : {}),
1034
+ };
1035
+ });
1036
+ }
1037
+ /**
1038
+ * 커밋이 실제로 담은 경로를 ledger에 남긴다. 초기 예상치와 실제 변경을 함께
1039
+ * 두어야 coordinator가 다음 wave에서 감사를 할 수 있다.
1040
+ */
1041
+ function recordActualPaths({ repoRoot, blueprint, task, paths: actual }) {
1042
+ const taskId = coordinatorTaskId(task);
1043
+ if (!taskId)
1044
+ return { ok: false, reason: 'task-required' };
1045
+ const list = [...new Set((Array.isArray(actual) ? actual : [])
1046
+ .filter((entryPath) => typeof entryPath === 'string' && entryPath !== ''))];
1047
+ return lockedTaskWrite(repoRoot, blueprint, taskId, ({ ledger, entry, ledgerFile, owns, }) => {
1048
+ entry.actualPaths = list;
1049
+ if (!owns())
1050
+ return { ok: false, reason: 'ledger-lock-lost' };
1051
+ writeLedger(ledgerFile, ledger);
1052
+ return { ok: true, paths: list };
1053
+ });
1054
+ }
1055
+ module.exports = {
1056
+ isUnder,
1057
+ isRuntimeArtifact,
1058
+ isTaskWorkflowArtifact,
1059
+ filterTaskCommitCandidates,
1060
+ RUNTIME_ARTIFACTS,
1061
+ makeAllowed,
1062
+ makeFinalizeAllowed,
1063
+ coordinatorTaskId,
1064
+ readCoordinatorLedger,
1065
+ coordinatorContext,
1066
+ mainWorktreeNext,
1067
+ reviseTaskScope,
1068
+ trackedScopeViolations,
1069
+ recordActualPaths,
1070
+ withLedgerLock,
1071
+ // run detach가 status 'detached'를 쓴다. 정본 Ledger 타입이 그 값을 받으므로 인자 타입을 좁힌다.
1072
+ writeLedgerFile: (file, data) => writeLedger(file, data),
1073
+ };