@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,720 @@
1
+ 'use strict';
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SECURITY_PATH_TOKENS = exports.PREREQUISITE_NEXT = void 0;
4
+ exports.checkPlanGate = checkPlanGate;
5
+ exports.checkPlanDraft = checkPlanDraft;
6
+ exports.planPrerequisiteFailures = planPrerequisiteFailures;
7
+ const path = require('node:path');
8
+ const schema = require("./schema");
9
+ const { executionKindOf } = schema;
10
+ const commandArgv = require("./command-argv");
11
+ const dagShape = require("./dag-shape");
12
+ const { pathOverlapRatio } = dagShape;
13
+ const validateDocs = require("./validate-docs");
14
+ const { statusOf } = validateDocs;
15
+ const validateSections = require("./validate-sections");
16
+ const { parseTasksSections, extractPathCandidates } = validateSections;
17
+ const validate_gates_shared_1 = require("./validate-gates-shared");
18
+ const docShapeContracts = require("./doc-shape-contracts");
19
+ const { classifyDocPath, pathContractFor } = docShapeContracts;
20
+ const planPrerequisites = require("./plan-prerequisites");
21
+ const { checkPrerequisites } = planPrerequisites;
22
+ // 체인 경고 기준: 인접 edge의 경로 중첩률이 이 값 이상이면 "같은 파일을 이어서
23
+ // 만지는" 직렬 구간으로 본다. 0.5는 한쪽 경로의 절반 이상이 겹친다는 뜻이다.
24
+ const DAG_SERIAL_OVERLAP_MIN = 0.5;
25
+ // 체인 경고는 task 3개 이상(edge 2개 이상)부터 낸다. 두 task 직렬은 흔하다.
26
+ const DAG_SERIAL_CHAIN_MIN_TASKS = 3;
27
+ /**
28
+ * plan 게이트와 plan draft가 공유하는 DAG 모양 경고(권고)를 ctx.warnings에 싣는다.
29
+ * commit task만 노드로 보고 verification 노드와 그 edge는 뺀다 — 노드는 drive
30
+ * 대상이 아니라 마지막 확인 단계라 직렬이어도 병렬화 대상이 아니다.
31
+ * 중첩률은 TASKS-001 `pathOverlapRatio` 하나만 쓴다. 경고는 failures를 건드리지
32
+ * 않으며, `next`도 만들지 않는다(권고 문구는 message에 둔다).
33
+ * G19 실패가 있으면 호출자가 부르지 않는다: 깨진 graph 위의 모양 경고는 소음이다.
34
+ *
35
+ * @param {DocLeaf[]} tasksList - blueprint에서 수집한 tasks.md 문서들
36
+ * @param {GateContext} ctx - warnings 배열을 담은 gate 문맥. 배열이 없으면 아무것도 하지 않는다
37
+ * @param {string} file - 경고에 붙일 blueprint index 상대 경로
38
+ * @returns {void} 결과는 ctx.warnings에 push로만 남긴다
39
+ */
40
+ function checkTaskDagShape(tasksList, ctx, file) {
41
+ if (!Array.isArray(ctx.warnings))
42
+ return;
43
+ const commit = new Map();
44
+ for (const tasksDoc of tasksList) {
45
+ const data = (0, validate_gates_shared_1.asData)(tasksDoc);
46
+ const bouncer = data && data.bouncer && typeof data.bouncer === 'object'
47
+ ? data.bouncer
48
+ : undefined;
49
+ const id = bouncer && typeof bouncer.id === 'string' ? bouncer.id : '';
50
+ if (!bouncer || !id || executionKindOf(bouncer) !== 'commit')
51
+ continue;
52
+ // affected_paths가 비었거나 배열이 아니면 중첩률 0으로 센다(G5가 따로 실패시킨다).
53
+ const ap = Array.isArray(bouncer.affected_paths)
54
+ ? bouncer.affected_paths.filter((p) => typeof p === 'string')
55
+ : [];
56
+ const deps = Array.isArray(bouncer.depends_on)
57
+ ? bouncer.depends_on.filter((d) => typeof d === 'string')
58
+ : [];
59
+ commit.set(id, { paths: ap, deps });
60
+ }
61
+ // commit→commit edge만 (선행, 후행) id 순으로 모은다. verification 쪽 edge는
62
+ // commit 맵에 없는 id라 여기서 자연히 빠진다.
63
+ const edges = [];
64
+ for (const [to, node] of commit) {
65
+ for (const from of node.deps) {
66
+ const pred = commit.get(from);
67
+ if (!pred)
68
+ continue;
69
+ edges.push({ from, to, ratio: pathOverlapRatio(pred.paths, node.paths) });
70
+ }
71
+ }
72
+ edges.sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : a.to < b.to ? -1 : a.to > b.to ? 1 : 0));
73
+ // 1. 체인: 중첩률 기준을 넘는 edge만 이은 부분 그래프에서 시작점(들어오는 edge 없음)
74
+ // 부터 끝점까지의 최대 경로를 모은다. G19가 통과했으므로 순환은 없어 DFS가 끝난다.
75
+ const strong = edges.filter((e) => e.ratio >= DAG_SERIAL_OVERLAP_MIN);
76
+ const outs = new Map();
77
+ const hasIn = new Set();
78
+ for (const e of strong) {
79
+ if (!outs.has(e.from))
80
+ outs.set(e.from, []);
81
+ outs.get(e.from).push({ to: e.to, ratio: e.ratio });
82
+ hasIn.add(e.to);
83
+ }
84
+ const chains = [];
85
+ const walk = (ids, min) => {
86
+ const next = outs.get(ids[ids.length - 1]) || [];
87
+ if (next.length === 0) {
88
+ if (ids.length >= DAG_SERIAL_CHAIN_MIN_TASKS)
89
+ chains.push({ ids, min });
90
+ return;
91
+ }
92
+ for (const n of next)
93
+ walk([...ids, n.to], Math.min(min, n.ratio));
94
+ };
95
+ for (const start of [...outs.keys()].sort()) {
96
+ if (!hasIn.has(start))
97
+ walk([start], 1);
98
+ }
99
+ chains.sort((a, b) => {
100
+ const x = a.ids.join(' ');
101
+ const y = b.ids.join(' ');
102
+ return x < y ? -1 : x > y ? 1 : 0;
103
+ });
104
+ for (const chain of chains) {
105
+ ctx.warnings.push({
106
+ code: 'dag-serial-overlap',
107
+ message: `serial chain ${chain.ids.join(' → ')} shares paths (min overlap ${chain.min.toFixed(2)}); `
108
+ + 'consider merging the tasks or splitting shared paths so they can run in parallel',
109
+ file,
110
+ });
111
+ }
112
+ // 2. 경로가 하나도 겹치지 않는 edge: 실제 의존이 아닐 수 있는 직렬화다.
113
+ const disjoint = edges.filter((e) => e.ratio === 0);
114
+ if (disjoint.length > 0) {
115
+ ctx.warnings.push({
116
+ code: 'dag-disjoint-edge',
117
+ message: `serial edges with no shared paths: ${disjoint.map((e) => `${e.from} → ${e.to}`).join(', ')}; `
118
+ + 'if the dependency is not real, drop it or make the dependent task fixture-first '
119
+ + 'so it can run in parallel',
120
+ file,
121
+ });
122
+ }
123
+ }
124
+ /**
125
+ * plan 게이트 G19 — task `depends_on` graph의 참조 무결성과 순환을 판정한다.
126
+ * 부재 depends_on은 빈 배열로 읽어 기존 plan을 통과시킨다.
127
+ * 불변조건: 같은 blueprint 안의 TASKS-NNN id만 edge로 허용하고, 자기 참조·
128
+ * 한 문서 안 중복 edge·방향 그래프 순환은 모두 거절한다. 순환 판정은
129
+ * DFS 재귀 경로(visiting)에 다시 들어온 정점만 cycle로 보고, 이미 끝난
130
+ * 정점(visited)은 재탐색하지 않아 보고가 결정적이 된다.
131
+ *
132
+ * @param {DocLeaf[]} tasksList - blueprint에서 수집한 tasks.md 문서들
133
+ * @param {FailureEntry[]} failures - G19를 누적할 배열
134
+ * @returns {void}
135
+ */
136
+ function checkTaskDependencyGraph(tasksList, failures) {
137
+ const byId = new Map();
138
+ for (const tasksDoc of tasksList) {
139
+ const data = (0, validate_gates_shared_1.asData)(tasksDoc);
140
+ const bouncer = data && data.bouncer && typeof data.bouncer === 'object'
141
+ ? data.bouncer
142
+ : undefined;
143
+ const id = bouncer && typeof bouncer.id === 'string' ? bouncer.id : '';
144
+ if (id)
145
+ byId.set(id, tasksDoc);
146
+ }
147
+ const edges = new Map();
148
+ for (const tasksDoc of tasksList) {
149
+ const file = tasksDoc.rel || '';
150
+ const data = (0, validate_gates_shared_1.asData)(tasksDoc);
151
+ const bouncer = data && data.bouncer && typeof data.bouncer === 'object'
152
+ ? data.bouncer
153
+ : undefined;
154
+ const id = bouncer && typeof bouncer.id === 'string' ? bouncer.id : '';
155
+ // 부재는 빈 배열 — 기존 DAG 필드 없는 fixture와 단일 task를 통과시킨다.
156
+ const rawDeps = bouncer ? bouncer.depends_on : undefined;
157
+ const deps = rawDeps === undefined ? [] : rawDeps;
158
+ if (!Array.isArray(deps)) {
159
+ // shape는 S28이 거절한다. 여기선 graph 판정을 건너뛴다.
160
+ continue;
161
+ }
162
+ const seen = new Set();
163
+ const normalized = [];
164
+ for (const entry of deps) {
165
+ if (typeof entry !== 'string')
166
+ continue;
167
+ if (entry === id) {
168
+ failures.push({
169
+ code: 'G19',
170
+ message: `depends_on self-reference: ${entry}`,
171
+ file,
172
+ });
173
+ continue;
174
+ }
175
+ if (seen.has(entry)) {
176
+ failures.push({
177
+ code: 'G19',
178
+ message: `depends_on duplicate: ${entry}`,
179
+ file,
180
+ });
181
+ continue;
182
+ }
183
+ seen.add(entry);
184
+ if (!byId.has(entry)) {
185
+ failures.push({
186
+ code: 'G19',
187
+ message: `depends_on missing task: ${entry}`,
188
+ file,
189
+ });
190
+ continue;
191
+ }
192
+ normalized.push(entry);
193
+ }
194
+ if (id)
195
+ edges.set(id, normalized);
196
+ }
197
+ // 순환: visiting에 다시 들어오면 cycle. visited는 이미 DAG로 끝난 부분이라
198
+ // 재방문해도 새 cycle이 아니므로 한 번만 보고한다.
199
+ const visiting = new Set();
200
+ const visited = new Set();
201
+ const stack = [];
202
+ const reported = new Set();
203
+ function visit(nodeId) {
204
+ if (visiting.has(nodeId)) {
205
+ const start = stack.indexOf(nodeId);
206
+ const cycle = stack.slice(start).concat(nodeId);
207
+ const key = [...cycle].sort().join('>');
208
+ if (!reported.has(key)) {
209
+ reported.add(key);
210
+ const doc = byId.get(nodeId);
211
+ failures.push({
212
+ code: 'G19',
213
+ message: `depends_on cycle: ${cycle.join(' -> ')}`,
214
+ file: (doc && doc.rel) || '',
215
+ });
216
+ }
217
+ return;
218
+ }
219
+ if (visited.has(nodeId))
220
+ return;
221
+ visiting.add(nodeId);
222
+ stack.push(nodeId);
223
+ for (const next of edges.get(nodeId) || [])
224
+ visit(next);
225
+ stack.pop();
226
+ visiting.delete(nodeId);
227
+ visited.add(nodeId);
228
+ }
229
+ // id 정렬로 시작 순서를 고정해 같은 graph에서 같은 cycle 메시지를 낸다.
230
+ for (const nodeId of [...edges.keys()].sort())
231
+ visit(nodeId);
232
+ }
233
+ /**
234
+ * verification node가 구현 범위를 우회하는 중간 node가 되지 않는지 판정한다.
235
+ * predecessor를 갖고 source Touch가 없으며, successor가 있다면 그 successor도
236
+ * verification이어야 한다. shape와 argv 유효성은 S29가 먼저 맡는다.
237
+ * Touch 위반 메시지에는 경로로 추출된 후보를 `, `로 이어 붙인다 — 백틱 명령
238
+ * (`npm run ci`)도 후보가 되므로, 작성자가 validator를 읽지 않고 원인을 알게 한다.
239
+ *
240
+ * @param {DocLeaf[]} tasksList - blueprint의 모든 tasks.md
241
+ * @param {FailureEntry[]} failures - G20 결과 누적 배열
242
+ * @returns {void} 결과는 failures에 push로만 남긴다
243
+ */
244
+ function checkVerificationTaskGraph(tasksList, failures) {
245
+ const dependents = new Map();
246
+ for (const doc of tasksList) {
247
+ const data = (0, validate_gates_shared_1.asData)(doc);
248
+ const bouncer = data && data.bouncer;
249
+ if (bouncer && Array.isArray(bouncer.depends_on)) {
250
+ for (const predecessor of bouncer.depends_on) {
251
+ if (typeof predecessor !== 'string')
252
+ continue;
253
+ dependents.set(predecessor, [...(dependents.get(predecessor) || []), doc]);
254
+ }
255
+ }
256
+ }
257
+ for (const doc of tasksList) {
258
+ const data = (0, validate_gates_shared_1.asData)(doc);
259
+ const bouncer = data && data.bouncer;
260
+ if (executionKindOf(bouncer) !== 'verification')
261
+ continue;
262
+ const id = bouncer && typeof bouncer.id === 'string' ? bouncer.id : '';
263
+ const sections = parseTasksSections(doc.body || '');
264
+ const touchCandidates = extractPathCandidates(sections.touch || '');
265
+ if (touchCandidates.length > 0) {
266
+ failures.push({
267
+ code: 'G20',
268
+ message: `verification task Touch must not declare source changes: ${touchCandidates.join(', ')}`,
269
+ file: doc.rel,
270
+ });
271
+ }
272
+ for (const successor of dependents.get(id) || []) {
273
+ const successorData = (0, validate_gates_shared_1.asData)(successor);
274
+ const successorBouncer = successorData && successorData.bouncer;
275
+ if (executionKindOf(successorBouncer) !== 'verification') {
276
+ failures.push({
277
+ code: 'G20',
278
+ message: `verification task cannot precede commit task: ${id}`,
279
+ file: successor.rel,
280
+ });
281
+ }
282
+ }
283
+ }
284
+ }
285
+ // check-doc-shape 직접 호출로 인정하는 저장소 안 script 경로(repoRoot 상대 POSIX).
286
+ const DOC_SHAPE_SCRIPT = 'scripts/check-doc-shape.js';
287
+ /**
288
+ * verify 명령이 `node scripts/check-doc-shape.js <문서...>` 직접 호출이면 종류/경로 계약이 없는
289
+ * 문서 인자를 돌려준다. checker CLI는 계약 없는 인자에 exit 1이므로, 실행 전에 같은 표
290
+ * (doc-shape-contracts)로 미리 판정해 plan gate와 draft가 같은 답을 내게 한다.
291
+ * 파일을 읽거나 실행하지 않는다: 인자 문자열과 정적 표만 본다. npm wrapper·node 옵션·셸은
292
+ * 해석하지 않으며(첫 인자가 script가 아니면 인식 밖), 명시 문서가 0개면 기본 스캔이라 통과다.
293
+ * repoRoot가 없는 단위 호출은 절대 경로를 저장소 상대로 바꿀 수 없어 그 인자를 판정하지 않는다.
294
+ *
295
+ * @param {string} verify - 이미 parseVerifyArgv를 통과한 task `bouncer.verify`
296
+ * @param {string | undefined} repoRoot - 저장소 루트 절대 경로. 없으면 상대 인자만 판정한다
297
+ * @returns {string[]} 계약 없는 문서 인자(repoRoot 상대 POSIX). 인식 밖이거나 모두 계약이 있으면 빈 배열
298
+ */
299
+ function docShapeTargetsWithoutContract(verify, repoRoot) {
300
+ const argv = commandArgv.parseVerifyArgv(verify);
301
+ if (!argv || argv.length < 2 || commandArgv.verifyExecutableName(argv[0]) !== 'node')
302
+ return [];
303
+ // checker는 cwd(저장소 루트) 기준으로 인자를 풀어 path.relative로 정규화한다. 같은 규칙을 따른다.
304
+ const toRepoRel = (arg) => {
305
+ if (!repoRoot) {
306
+ return path.isAbsolute(arg) ? null : path.posix.normalize(arg.replace(/\\/g, '/'));
307
+ }
308
+ return path.relative(repoRoot, path.resolve(repoRoot, arg)).replace(/\\/g, '/');
309
+ };
310
+ if (toRepoRel(argv[1]) !== DOC_SHAPE_SCRIPT)
311
+ return [];
312
+ const missing = [];
313
+ for (const arg of argv.slice(2)) {
314
+ const rel = toRepoRel(arg);
315
+ if (rel === null)
316
+ continue;
317
+ if (classifyDocPath(rel) === null && pathContractFor(rel) === null)
318
+ missing.push(rel);
319
+ }
320
+ return missing;
321
+ }
322
+ /**
323
+ * `verification_policy: scoped_terminal` blueprint의 검증 책임 분리를 판정한다(G20).
324
+ * 구현 task는 task 전용 `bouncer.verify`를 가져야 하고, 전체 검증은 구현 task를
325
+ * 모두 기다리는 단 하나의 마지막 verification node가 맡는다. 표식이 없는 legacy
326
+ * blueprint는 소급하지 않고 그대로 통과시킨다. 허용 밖의 값은 legacy로 접지 않고
327
+ * 거절한다 — 오타가 조용히 옛 계약으로 읽히면 새 계획의 보호가 풀리기 때문이다.
328
+ * 거절 사유: 정책 값 오류, 구현 task 명령 누락·단일 argv 위반, 구현 task에 terminal과
329
+ * 같은 전체 명령 배정, terminal 개수가 1이 아님, terminal이 구현 task를 (간접 포함)
330
+ * 기다리지 않음, 새 light에서 구현이 하나가 아니거나 독립이 아님. 검증 뒤 구현
331
+ * node는 checkVerificationTaskGraph가 이미 거절하므로 중복 보고하지 않는다.
332
+ * 정책과 무관하게 모든 task verify가 check-doc-shape에 계약 없는 문서를 명시하면 실행 전에 거절한다.
333
+ *
334
+ * @param {DocLeaf | undefined} blueprintIndex - blueprint index.md 문서
335
+ * @param {DocLeaf[]} tasksList - blueprint의 모든 tasks.md
336
+ * @param {boolean} isLight - blueprint scale이 light인지
337
+ *
338
+ * @param {FailureEntry[]} failures - G20 결과 누적 배열
339
+ * @param {string | undefined} repoRoot - verify의 절대 경로 인자를 저장소 상대로 바꿀 루트(선택)
340
+ * @returns {void} 결과는 failures에 push로만 남긴다
341
+ */
342
+ function checkScopedTerminalPolicy(blueprintIndex, tasksList, isLight, failures, repoRoot) {
343
+ const bpData = (0, validate_gates_shared_1.asData)(blueprintIndex);
344
+ const policy = schema.verificationPolicyOf(bpData ? bpData.bouncer : undefined);
345
+ const bpFile = blueprintIndex ? blueprintIndex.rel : '';
346
+ if (policy === null) {
347
+ failures.push({
348
+ code: 'G20',
349
+ message: `verification_policy must be one of ${schema.VERIFICATION_POLICY_ENUM.join(' | ')} or absent`,
350
+ file: bpFile,
351
+ });
352
+ return;
353
+ }
354
+ // check-doc-shape 대상 사전 검사는 정책과 무관하게 모든 task verify에 적용한다. legacy 계획도
355
+ // 계약 없는 문서를 실행 시점에야 exit 1로 알게 되면 안 되기 때문이다. verify가 없거나 파싱 불가한
356
+ // 명령은 docShapeTargetsWithoutContract가 빈 배열을 돌려주므로 선택 verify와 argv 위반은 건드리지 않는다.
357
+ for (const doc of tasksList) {
358
+ const data = (0, validate_gates_shared_1.asData)(doc);
359
+ const verify = data && data.bouncer ? data.bouncer.verify : undefined;
360
+ if (typeof verify !== 'string')
361
+ continue;
362
+ const uncontracted = docShapeTargetsWithoutContract(verify, repoRoot);
363
+ if (uncontracted.length > 0) {
364
+ failures.push({
365
+ code: 'G20',
366
+ message: `check-doc-shape verify target has no kind or path contract: ${uncontracted.join(', ')}`,
367
+ file: doc.rel,
368
+ });
369
+ }
370
+ }
371
+ if (policy === 'legacy')
372
+ return;
373
+ const bouncerOf = (doc) => {
374
+ const data = (0, validate_gates_shared_1.asData)(doc);
375
+ return (data && data.bouncer ? data.bouncer : {});
376
+ };
377
+ const commits = [];
378
+ const terminals = [];
379
+ for (const doc of tasksList) {
380
+ const kind = executionKindOf(bouncerOf(doc));
381
+ if (kind === 'verification')
382
+ terminals.push(doc);
383
+ else if (kind === 'commit')
384
+ commits.push(doc);
385
+ }
386
+ const normalize = (value) => commandArgv.normalizeVerifyCommand(value);
387
+ const terminalCommands = new Set(terminals.map((doc) => normalize(bouncerOf(doc).verify)));
388
+ for (const doc of commits) {
389
+ const verify = bouncerOf(doc).verify;
390
+ if (typeof verify !== 'string' || verify.trim() === '') {
391
+ failures.push({
392
+ code: 'G20',
393
+ message: 'scoped_terminal commit task requires its own non-empty bouncer.verify',
394
+ file: doc.rel,
395
+ });
396
+ }
397
+ else if (commandArgv.parseVerifyArgv(verify) === null) {
398
+ // 셸 연산자·미종료 인용은 실행 시점의 VERIFY_COMMAND_INVALID와 같은 파서로 미리 막는다.
399
+ failures.push({
400
+ code: 'G20',
401
+ message: 'scoped_terminal commit task verify must be a single executable command',
402
+ file: doc.rel,
403
+ });
404
+ }
405
+ else if (terminalCommands.has(normalize(verify))) {
406
+ failures.push({
407
+ code: 'G20',
408
+ message: `scoped_terminal commit task must not run the terminal full-suite command: ${normalize(verify)}`,
409
+ file: doc.rel,
410
+ });
411
+ }
412
+ }
413
+ if (terminals.length !== 1) {
414
+ failures.push({
415
+ code: 'G20',
416
+ message: terminals.length === 0
417
+ ? 'scoped_terminal requires exactly one terminal verification task, found none'
418
+ : `scoped_terminal requires exactly one terminal verification task, found ${terminals.length}`,
419
+ file: bpFile,
420
+ });
421
+ return;
422
+ }
423
+ if (commits.length === 0) {
424
+ failures.push({
425
+ code: 'G20',
426
+ message: 'scoped_terminal requires at least one commit task before the terminal verification',
427
+ file: terminals[0].rel,
428
+ });
429
+ return;
430
+ }
431
+ // terminal이 모든 구현 task를 (간접 포함) 기다려야 "구현 통합 뒤 한 번"이 성립한다.
432
+ // 선행 id를 거슬러 올라가며 도달 집합을 만든다. 순환은 G19가 이미 거절했으므로
433
+ // visited 집합으로 한 번씩만 방문해 끝나게 한다.
434
+ const dependsById = new Map();
435
+ for (const doc of tasksList) {
436
+ const b = bouncerOf(doc);
437
+ if (typeof b.id === 'string') {
438
+ const declared = Array.isArray(b.depends_on) ? b.depends_on : [];
439
+ dependsById.set(b.id, declared.filter((x) => typeof x === 'string'));
440
+ }
441
+ }
442
+ const reached = new Set();
443
+ const queue = [...(dependsById.get(String(bouncerOf(terminals[0]).id)) || [])];
444
+ while (queue.length > 0) {
445
+ const id = queue.pop();
446
+ if (reached.has(id))
447
+ continue;
448
+ reached.add(id);
449
+ queue.push(...(dependsById.get(id) || []));
450
+ }
451
+ for (const doc of commits) {
452
+ const id = String(bouncerOf(doc).id);
453
+ if (!reached.has(id)) {
454
+ failures.push({
455
+ code: 'G20',
456
+ message: `terminal verification must wait for commit task: ${id}`,
457
+ file: terminals[0].rel,
458
+ });
459
+ }
460
+ }
461
+ if (isLight) {
462
+ const only = commits[0];
463
+ const dependsOn = bouncerOf(only).depends_on;
464
+ if (commits.length !== 1 || (Array.isArray(dependsOn) && dependsOn.length > 0)) {
465
+ failures.push({
466
+ code: 'G20',
467
+ message: 'scoped_terminal light requires one independent commit task plus the terminal verification',
468
+ file: bpFile,
469
+ });
470
+ }
471
+ }
472
+ }
473
+ // G32 복구 안내. gate hint(validate.ts)와 approvePlan이 같은 문장을 써야 두 경로의 next가 갈라지지 않는다.
474
+ const PREREQUISITE_NEXT = 'Fix the missing blueprint reference or the merge result SHA in `bouncer.prerequisites`, '
475
+ + 'or bring the prerequisite merge into this checkout (merge or rebase onto it), then rerun the check.';
476
+ exports.PREREQUISITE_NEXT = PREREQUISITE_NEXT;
477
+ /**
478
+ * 선언된 선행 blueprint가 준비되지 않은 항목을 G32 실패로 바꾼다.
479
+ * draft·정식 plan gate·approvePlan이 같은 함수를 불러야 세 경로가 서로 다른 답을 내지 않는다.
480
+ * 형식 오류(S39)는 구조 검사 몫이라 여기서 다시 내지 않는다. 선언이 없으면 Git을 부르지 않고 빈 배열이다.
481
+ * Git 조회 오류·object 부재는 통과로 접지 않고 원인을 구분해 실패로 보고한다.
482
+ *
483
+ * @param {object} args - 인자 묶음
484
+ * @param {string} args.repoRoot - 저장소 루트 절대 경로
485
+ * @param {string} args.blueprintDir - 저장소 상대 blueprint 디렉터리
486
+ * @param {string} args.file - 실패에 붙일 blueprint index 상대 경로
487
+ * @param {GitExec} [args.exec] - Git 실행 seam. 없으면 plan-prerequisites의 기본 실행기
488
+ * @returns {FailureEntry[]} 준비되지 않은 선행마다 한 건의 G32. 모두 준비됐거나 선언이 없으면 빈 배열
489
+ */
490
+ function planPrerequisiteFailures({ repoRoot, blueprintDir, file, exec }) {
491
+ const report = checkPrerequisites({ repoRoot, blueprintDir, exec });
492
+ const failures = [];
493
+ const add = (detail) => {
494
+ failures.push({ code: 'G32', message: `declared prerequisite is not ready: ${detail}`, file });
495
+ };
496
+ for (const entry of report.entries) {
497
+ if (!entry.blueprintFound)
498
+ add(`${entry.blueprint} has no index.md`);
499
+ if (entry.commitState === 'object-missing') {
500
+ add(`${entry.blueprint} merge result ${entry.merged_commit} is not in this repository`);
501
+ }
502
+ else if (entry.commitState === 'unmerged') {
503
+ add(`${entry.blueprint} merge result ${entry.merged_commit} is not an ancestor of the current checkout`);
504
+ }
505
+ else if (entry.commitState === 'lookup-error') {
506
+ add(`${entry.blueprint} merge result ${entry.merged_commit} could not be checked (git lookup failed)`);
507
+ }
508
+ }
509
+ for (const cycle of report.cycles)
510
+ add(`prerequisite cycle ${cycle.join(' -> ')}`);
511
+ return failures;
512
+ }
513
+ /**
514
+ * plan gate 판정. G 코드·메시지·순서는 분리 전과 같다. 단계 선택만 진입점에 남긴다.
515
+ *
516
+ * @param {GateStageCtx} stage - add 클로저와 문서 묶음
517
+ * @returns {void}
518
+ */
519
+ function checkPlanGate(stage) {
520
+ const { add, docs, rels, failures } = stage;
521
+ const ctx = (0, validate_gates_shared_1.toGateContext)(stage);
522
+ if (statusOf(docs.epicIndex) !== 'approved')
523
+ add('G1', 'epic.status != approved', 'epicIndex');
524
+ // closed는 finalize --yes가 마감한 blueprint의 잠금 signal(hard rule/schema 참고).
525
+ // 미승인 draft와 같은 코드(G2)로 걸지만, 사용자가 "왜 막혔는지" draft와
526
+ // 헷갈리지 않도록 문구를 분기한다 — 재승인 경로가 없다는 점도 여기서 안내.
527
+ const bpStatus = statusOf(docs.blueprintIndex);
528
+ if (bpStatus === 'closed') {
529
+ add('G2', 'blueprint is closed (finalized) — open a new blueprint instead of resuming this one', 'blueprintIndex');
530
+ }
531
+ else if (bpStatus === 'partial_closed') {
532
+ add('G2', 'blueprint is partial_closed with unresolved CI evidence — approve NEXT_PLAN.md before new work', 'blueprintIndex');
533
+ }
534
+ else if (bpStatus !== 'approved') {
535
+ add('G2', 'blueprint.status != approved', 'blueprintIndex');
536
+ }
537
+ const { isLight } = planScaleOf(docs);
538
+ const tasksList = planTasksOf(docs);
539
+ if (tasksList.length === 0) {
540
+ add('G3', 'tasks.status != ready', 'tasks');
541
+ add('G5', 'tasks.affected_paths missing or empty', 'tasks');
542
+ return;
543
+ }
544
+ for (const tasksDoc of tasksList) {
545
+ const file = tasksDoc.rel || rels.tasks;
546
+ // ready = plan 직후. in_progress = execute 중. verified = 같은 BP의
547
+ // 앞 task를 이미 끝낸 뒤 next-task --set. draft만 G3.
548
+ const taskStatus = statusOf(tasksDoc);
549
+ if (!['ready', 'in_progress', 'verified'].includes(taskStatus)) {
550
+ failures.push({ code: 'G3', message: 'tasks.status != ready', file });
551
+ }
552
+ // G3 바로 뒤에 task 검사를 붙여야 실패 순서가 분리 전과 같다
553
+ // (task1 G3 → task1 G5 → task2 G3 → …).
554
+ checkTaskScope(tasksDoc, file, failures, ctx);
555
+ }
556
+ // G19: blueprint 안 모든 task를 한 번 모아 depends_on 참조·중복·순환을
557
+ // 결정적으로 판정한다. shape/enum은 S28; 여기는 graph 무결성만.
558
+ const beforeG19 = failures.length;
559
+ checkTaskDependencyGraph(tasksList, failures);
560
+ // G19가 실패한 graph는 모양 경고를 계산하지 않는다(순환이면 체인 탐색도 무의미).
561
+ if (failures.length === beforeG19) {
562
+ checkTaskDagShape(tasksList, ctx, rels.blueprintIndex);
563
+ }
564
+ checkVerificationTaskGraph(tasksList, failures);
565
+ checkScopedTerminalPolicy(docs.blueprintIndex, tasksList, isLight, failures, stage.repoRoot);
566
+ // repoRoot 없이 부른 단위 호출은 선행 선언을 읽을 위치가 없어 건너뛴다(선언 없는 기존 경로와 같다).
567
+ if (stage.repoRoot && stage.blueprintDir) {
568
+ failures.push(...planPrerequisiteFailures({
569
+ repoRoot: stage.repoRoot, blueprintDir: stage.blueprintDir, file: rels.blueprintIndex,
570
+ exec: stage.deps && stage.deps.exec,
571
+ }));
572
+ }
573
+ }
574
+ /**
575
+ * blueprint `scale`로 plan 검사의 light 여부를 고른다.
576
+ * plan gate와 plan draft 검사가 같은 헬퍼를 써야 light 문서에 대한 G20 답이 갈라지지 않는다.
577
+ *
578
+ * @param {BlueprintDocs} docs - 로드된 blueprint 문서 묶음
579
+ * @returns {{ isLight: boolean }} blueprint `scale`이 light이면 isLight가 true
580
+ */
581
+ function planScaleOf(docs) {
582
+ // 축약 계약의 유일한 발동 신호는 blueprint index.md의 `bouncer.scale`이다.
583
+ // 사용자 선언(그리고 그것을 쓰는 scaffold --scale)만이 여기 도달한다 —
584
+ // 게이트는 경로 수·diff 크기로 light를 추론하지 않는다.
585
+ const bpData = (0, validate_gates_shared_1.asData)(docs.blueprintIndex);
586
+ const bpBouncer = bpData && bpData.bouncer && typeof bpData.bouncer === 'object'
587
+ ? bpData.bouncer
588
+ : undefined;
589
+ const isLight = Boolean(bpBouncer && bpBouncer.scale === 'light');
590
+ return { isLight };
591
+ }
592
+ /**
593
+ * plan 검사 대상 task 문서 목록을 고른다.
594
+ * tasksDocs가 없으면 단위 테스트용 단일 docs.tasks로 폴백한다.
595
+ *
596
+ * @param {BlueprintDocs} docs - 로드된 blueprint 문서 묶음
597
+ * @returns {DocLeaf[]} 검사할 tasks.md 문서들. 하나도 없으면 빈 배열
598
+ */
599
+ function planTasksOf(docs) {
600
+ return Array.isArray(docs.tasksDocs) && docs.tasksDocs.length > 0
601
+ ? docs.tasksDocs
602
+ : (docs.tasks ? [docs.tasks] : []);
603
+ }
604
+ /**
605
+ * Touch 경로의 segment·basename 조각과 정확히 비교할 보안 토큰. 부분 문자열이 아니라
606
+ * 완전 일치만 보므로 `tokenizer`·`sessions` 같은 이름은 걸리지 않는다. 위험도 enum
607
+ * (`review-policy.ts`의 REVIEW_RISK_ENUM)과는 다른 어휘다 — 그쪽은 review_risk 값이고 이 목록은
608
+ * Touch 경로에서 보안 민감 파일을 가려내는 토큰이라 enum으로 대체할 수 없어 여기서 독립으로 둔다.
609
+ */
610
+ const SECURITY_PATH_TOKENS = [
611
+ 'auth', 'login', 'session', 'token', 'credential', 'secret',
612
+ 'password', 'permission', 'acl', 'oauth', 'jwt',
613
+ ];
614
+ exports.SECURITY_PATH_TOKENS = SECURITY_PATH_TOKENS;
615
+ /**
616
+ * 경로 하나가 보안 토큰과 일치하는지 판정한다. `.bouncer/`로 시작하는 계획 문서는
617
+ * 코드 경로가 아니므로 epic 제목에 auth가 들어 있어도 검사하지 않는다.
618
+ * 조각 = `/` segment 전체 + 각 segment를 `.`·`_`·`-`로 나눈 조각(basename 포함).
619
+ *
620
+ * @param {string} p - 저장소 상대 posix 경로 후보
621
+ * @returns {boolean} 소문자 기준으로 토큰과 정확히 같은 조각이 있으면 true
622
+ */
623
+ function isSecurityPath(p) {
624
+ if (p === '.bouncer' || p.startsWith('.bouncer/'))
625
+ return false;
626
+ return p.toLowerCase().split('/').some((seg) => SECURITY_PATH_TOKENS.includes(seg) || seg.split(/[._-]/).some((x) => SECURITY_PATH_TOKENS.includes(x)));
627
+ }
628
+ /**
629
+ * task 문서 하나에 status와 무관한 plan 검사(G5)와 경고를 현재 순서대로 적용한다.
630
+ * plan gate는 G3 다음에, plan draft 검사는 G3 없이 이 함수를 부른다 — 두 경로가
631
+ * 한 구현을 공유해야 draft에서 통과한 문서가 승인 뒤 gate에서 다른 답을 받지 않는다.
632
+ * affected_paths가 20개를 넘으면 실패가 아니라 ctx.warnings에 task-split 경고만 싣고,
633
+ * 보안 경로를 건드리는데 review_risk가 비면 review-risk-missing 경고를 싣는다.
634
+ *
635
+ * @param {DocLeaf} tasksDoc - 검사할 tasks.md 문서
636
+ * @param {string} file - 실패 항목에 실을 task 문서 경로
637
+ * @param {FailureEntry[]} failures - 실패를 누적할 배열
638
+ * @param {GateContext} ctx - warnings 배열을 담은 gate 문맥
639
+ * @returns {void} 결과는 failures·ctx.warnings에 push로만 남긴다
640
+ */
641
+ function checkTaskScope(tasksDoc, file, failures, ctx) {
642
+ const addTask = (code, message) => failures.push({ code, message, file });
643
+ // YAML data가 null/undefined면 `.bouncer`에서 터지는 게 기존 실패 형태다.
644
+ // `data &&`로 막으면 G5가 missing 메시지로 fail-open 한다.
645
+ const taskBouncer = tasksDoc.data.bouncer;
646
+ const executionKind = executionKindOf(taskBouncer);
647
+ const ap = taskBouncer ? taskBouncer.affected_paths : undefined;
648
+ if (executionKind !== 'verification' && (!Array.isArray(ap) || ap.length === 0)) {
649
+ addTask('G5', 'tasks.affected_paths missing or empty');
650
+ }
651
+ // 20 초과는 한-커밋 리뷰 판단을 돕는 보조 신호일 뿐 — G/S 실패로 올리지 않는다.
652
+ // 정당한 넓은 task(대량 리네임·이관)도 통과해야 하므로 failures에 넣지 않는다.
653
+ if (Array.isArray(ap) && ap.length > 20 && Array.isArray(ctx.warnings)) {
654
+ ctx.warnings.push({
655
+ // G/S 코드가 아니다. FailureEntry 형태만 맞춰 구조화 경고로 싣는다.
656
+ code: 'task-split',
657
+ message: `affected_paths has ${ap.length} entries; `
658
+ + 'if it cannot be reviewed as one commit, split the task',
659
+ file,
660
+ });
661
+ }
662
+ // 보안 경로를 건드리는데 review_risk가 비면 execute가 security reviewer를 빼므로
663
+ // 승인 전에 알린다. 실패가 아닌 경고이고, 절 누락·TODO 여부와 무관하게 Touch 절 본문이
664
+ // 있을 때만 판정한다(없으면 후보 경로가 없다). task-split 뒤에 push해 같은 task의 경고
665
+ // 순서를 고정한다.
666
+ const tasksBody = tasksDoc && typeof tasksDoc.body === 'string' ? tasksDoc.body : '';
667
+ const touchText = parseTasksSections(tasksBody).touch;
668
+ if (!touchText)
669
+ return;
670
+ const risk = taskBouncer ? taskBouncer.review_risk : undefined;
671
+ if (executionKind === 'commit' && (!Array.isArray(risk) || risk.length === 0)
672
+ && Array.isArray(ctx.warnings)) {
673
+ const hits = extractPathCandidates(touchText).filter(isSecurityPath).sort();
674
+ if (hits.length) {
675
+ ctx.warnings.push({
676
+ code: 'review-risk-missing',
677
+ message: `Touch paths look security-sensitive but review_risk is empty: ${hits.join(', ')}; `
678
+ + 'record public_interface | authentication | authorization | credential if they apply',
679
+ file,
680
+ });
681
+ }
682
+ }
683
+ }
684
+ /**
685
+ * context review 전 계획 draft에 status 무관 plan 검사만 돌린다.
686
+ * task마다 G5를 적용한 뒤 G19·G20 graph 검사를 이어 붙이고, ctx에 repoRoot·blueprintDir가 있으면 G32도 낸다.
687
+ * G1·G2·G3은 approved/ready status를 요구해 draft에서 항상 실패하므로 넣지 않는다.
688
+ *
689
+ * @param {BlueprintDocs} docs - 로드된 blueprint 문서 묶음
690
+ * @param {BlueprintRels} rels - 문서 키별 상대 경로(빈 tasks 목록의 file 값)
691
+ * @param {FailureEntry[]} failures - 실패를 누적할 배열
692
+ * @param {GateContext} ctx - warnings 배열을 담은 gate 문맥
693
+ * @returns {void} 결과는 failures·ctx.warnings에 push로만 남긴다
694
+ */
695
+ function checkPlanDraft(docs, rels, failures, ctx = {}) {
696
+ const { isLight } = planScaleOf(docs);
697
+ const tasksList = planTasksOf(docs);
698
+ if (tasksList.length === 0) {
699
+ // plan gate의 빈 목록 분기에서 status 코드(G3)만 뺀 것이다.
700
+ failures.push({ code: 'G5', message: 'tasks.affected_paths missing or empty', file: rels.tasks });
701
+ return;
702
+ }
703
+ for (const tasksDoc of tasksList) {
704
+ checkTaskScope(tasksDoc, tasksDoc.rel || rels.tasks, failures, ctx);
705
+ }
706
+ const beforeG19 = failures.length;
707
+ checkTaskDependencyGraph(tasksList, failures);
708
+ // plan gate와 같은 조건: G19가 실패하면 모양 경고를 건너뛴다.
709
+ if (failures.length === beforeG19)
710
+ checkTaskDagShape(tasksList, ctx, rels.blueprintIndex);
711
+ checkVerificationTaskGraph(tasksList, failures);
712
+ checkScopedTerminalPolicy(docs.blueprintIndex, tasksList, isLight, failures, ctx.repoRoot);
713
+ // 정식 gate·approvePlan과 같은 helper를 쓴다. repoRoot가 없으면 선언을 읽을 수 없어 건너뛴다.
714
+ if (ctx.repoRoot && ctx.blueprintDir) {
715
+ failures.push(...planPrerequisiteFailures({
716
+ repoRoot: ctx.repoRoot, blueprintDir: ctx.blueprintDir, file: rels.blueprintIndex,
717
+ exec: ctx.deps && ctx.deps.exec,
718
+ }));
719
+ }
720
+ }