@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,1054 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { isDeepStrictEqual } = require('node:util');
5
+ const { execFileSync: realExecFileSync } = require('node:child_process');
6
+ const validateMod = require("./validate");
7
+ const { validateBlueprint } = validateMod;
8
+ const planSnapshot = require("./plan-snapshot");
9
+ const snapshotContract = require("./plan-snapshot-contract");
10
+ const reviewPolicy = require("./review-policy");
11
+ const { computePlanSnapshot, computePlanParts } = planSnapshot;
12
+ const { SNAPSHOT_VERSION, digestPlanContent, normalizeStructure, structureKindOf, hashBody, hashStructure, } = snapshotContract;
13
+ const tasksDocs = require("./tasks-docs");
14
+ const { listTasksDocs, taskExecutionKind } = tasksDocs;
15
+ const frontmatter = require("./frontmatter");
16
+ const { readDoc } = frontmatter;
17
+ const validateSections = require("./validate-sections");
18
+ const { parseTasksSections, extractPathCandidates, pathsOverlap } = validateSections;
19
+ const paths = require("./paths");
20
+ const { toPosix, epicDirOf } = paths;
21
+ const layout = require("./layout");
22
+ const { CONTEXT_ROOT, isCanonicalBlueprintDir } = layout;
23
+ const guards = require("./guards");
24
+ const contextReviewState = require("./context-review-state");
25
+ const { isRecord } = guards;
26
+ const schema = require("./schema");
27
+ const { executionKindOf, reviewContractOf, planReviewContractOf, PLAN_FULL_PERSPECTIVES, PLAN_LIGHT_PERSPECTIVES, } = schema;
28
+ const reviewSnapshot = require("./review-snapshot");
29
+ const { captureReviewSnapshot } = reviewSnapshot;
30
+ // Execute 경계값은 한 모듈에만 둔다. CLI와 workflow가 서로 다른 상수를
31
+ // 복제하면 single/parallel 판정이 갈라진다. Plan은 항상 single이라 경계값이 없다.
32
+ const EXECUTE_SMALL_MAX_FILES = 3;
33
+ const EXECUTE_SMALL_MAX_LINES = 200;
34
+ // S30과 같은 허용 목록(review-policy 정본). validate-structural이 먼저 거절하지만
35
+ // Execute 경로는 구조 게이트 없이 이 필드를 읽으므로 여기서도 동일 enum으로 막는다.
36
+ const { REVIEW_RISK_ENUM } = reviewPolicy;
37
+ // 브리프 이행 최종 리뷰가 security 관점을 붙이는 위험 집합. public_interface는 브리프
38
+ // 이행 판정(spec_scope·correctness_tests)이 이미 다루므로 여기에 넣지 않는다.
39
+ const BRIEF_SECURITY_RISKS = ['authentication', 'authorization', 'credential'];
40
+ // 표식이 허용 밖 값일 때 dispatch가 legacy로 접지 않고 내는 거절 메시지. CLI도 같은 문구를 쓴다.
41
+ const REVIEW_CONTRACT_INVALID = schema.REVIEW_CONTRACT_INVALID_MESSAGE;
42
+ const LEGACY_REASON = 'legacy-payload-no-content';
43
+ const TASKS_REL_RE = /\/tasks\/(\d{3})\/tasks\.md$/;
44
+ /**
45
+ * 한 tasks.md 본문에서 cluster 입력(Interface 키·Touch 경로)을 뽑는다.
46
+ * 현재 문서 적재와 이전 payload 본문 재구성이 같은 규칙을 쓰도록 한 곳에 둔다.
47
+ * 비어 있는 절은 거절하지 않고 빈 신호로 돌려준다 — 거절은 현재 문서 적재의 몫이고,
48
+ * 이전 본문은 이미 지난 상태라 cluster 이웃을 넓히는 용도로만 쓰인다.
49
+ *
50
+ * @param {string} number - 세 자리 task 번호
51
+ * @param {string} body - tasks.md 본문
52
+ * @returns {CommitTaskSignals} 번호 기반 id와 신호
53
+ */
54
+ function signalsFromBody(number, body) {
55
+ const sections = parseTasksSections(body);
56
+ const interfaceText = typeof sections.interface === 'string' ? sections.interface.trim() : '';
57
+ const touchText = typeof sections.touch === 'string' ? sections.touch.trim() : '';
58
+ return {
59
+ id: `TASKS-${number}`,
60
+ number,
61
+ interfaceKeys: extractBacktickKeys(interfaceText),
62
+ touchPaths: extractPathCandidates(touchText),
63
+ };
64
+ }
65
+ /**
66
+ * 문서 내용으로부터 commit task의 cluster 구성원 관계를 rel 집합으로 만든다.
67
+ *
68
+ * @param {string} bp - blueprint 상대 경로
69
+ * @param {Record<string, PlanDocContent>} content - 경로별 본문·구조
70
+ * @returns {string[][]} cluster마다 tasks.md 경로 목록
71
+ */
72
+ function clusterRels(bp, content) {
73
+ const signals = [];
74
+ for (const rel of Object.keys(content).sort()) {
75
+ const m = TASKS_REL_RE.exec(rel);
76
+ if (!m || content[rel].structure.execution_kind === 'verification')
77
+ continue;
78
+ signals.push(signalsFromBody(m[1], content[rel].body));
79
+ }
80
+ return buildClusters(signals).map((c) => c.tasks.map((id) => (`${bp}/tasks/${id.replace(/^TASKS-/, '')}/tasks.md`)));
81
+ }
82
+ /**
83
+ * depends_on 간선을 현재·이전 구조의 합집합으로 모아, 변경 task에서 선행·후행으로
84
+ * 닫힌 transitive closure를 만든다. 형제(같은 선행을 공유하는 task)는 포함하지 않는다.
85
+ *
86
+ * @param {string} bp - blueprint 상대 경로
87
+ * @param {string[]} seeds - 변경된 tasks.md 경로
88
+ * @param {Array<Record<string, PlanDocContent>>} contents - 간선을 읽을 내용들(현재, 이전)
89
+ * @returns {Set<string>} seeds를 제외한 선행·후행 tasks.md 경로
90
+ */
91
+ function dagClosure(bp, seeds, contents) {
92
+ const up = new Map();
93
+ const down = new Map();
94
+ const link = (map, from, to) => {
95
+ const set = map.get(from) || new Set();
96
+ set.add(to);
97
+ map.set(from, set);
98
+ };
99
+ for (const content of contents) {
100
+ for (const rel of Object.keys(content)) {
101
+ if (!TASKS_REL_RE.test(rel))
102
+ continue;
103
+ const deps = content[rel].structure.depends_on;
104
+ if (!Array.isArray(deps))
105
+ continue;
106
+ for (const dep of deps) {
107
+ const m = /^TASKS-(\d{3})$/.exec(String(dep));
108
+ if (!m)
109
+ continue;
110
+ const depRel = `${bp}/tasks/${m[1]}/tasks.md`;
111
+ link(up, rel, depRel);
112
+ link(down, depRel, rel);
113
+ }
114
+ }
115
+ }
116
+ const out = new Set();
117
+ for (const map of [up, down]) {
118
+ const queue = [...seeds];
119
+ const seen = new Set(seeds);
120
+ while (queue.length > 0) {
121
+ const cur = queue.shift();
122
+ for (const next of map.get(cur) || []) {
123
+ if (seen.has(next))
124
+ continue;
125
+ seen.add(next);
126
+ out.add(next);
127
+ queue.push(next);
128
+ }
129
+ }
130
+ }
131
+ for (const seed of seeds)
132
+ out.delete(seed);
133
+ return out;
134
+ }
135
+ /**
136
+ * 이전 snapshot과 현재 내용을 비교해 follow_up·변경 목록·관련 문서·before/after diff를 만든다.
137
+ * 문서 집합 또는 어느 문서의 정규화 구조가 다르면 full이고, 구조가 같으면 본문 보완은
138
+ * (epic/blueprint·Interface·Touch 포함) 모두 partial이다. scope_parts는 full 판단에 쓰지 않는다.
139
+ * 이전 내용을 모르는 경우(최초·legacy)는 이전 본문을 추정하지 않고 before를 null로 둔다.
140
+ *
141
+ * @param {{
142
+ * bp: string, documents: string[], content: Record<string, PlanDocContent>,
143
+ * structure_parts: Record<string, string>, parts: Record<string, string>,
144
+ * previous: PreviousSnapshot
145
+ * }} opts - blueprint 경로, 현재 문서 순서·내용·digest 맵, 이전 snapshot
146
+ * @returns {PlanDecision} follow_up과 정렬된 changed/related 목록, 문서별 revision_diff
147
+ */
148
+ function decidePlanFollowUp({ bp, documents, content, structure_parts, parts, previous }) {
149
+ const currentSet = [...documents].sort();
150
+ // 1. 이전 내용이 없으면(최초·legacy) 전체 현재 문서를 변경으로 표기하고 full을 고른다.
151
+ // legacy digest는 본문을 되돌릴 수 없어 delta 근거가 아니다 — 추정하지 않는다.
152
+ if (previous === null || previous.kind === 'legacy') {
153
+ const reason = previous === null ? undefined : LEGACY_REASON;
154
+ return {
155
+ follow_up: 'full',
156
+ changed_documents: currentSet,
157
+ related_documents: currentSet,
158
+ revision_diff: currentSet.map((file) => ({
159
+ file, before: null, after: content[file], ...(reason ? { reason } : {}),
160
+ })),
161
+ };
162
+ }
163
+ // 2. v2: 추가/삭제/본문/구조 변경의 합집합.
164
+ const prevSet = [...previous.documents].sort();
165
+ const universe = [...new Set([...currentSet, ...prevSet])].sort();
166
+ const changed = [];
167
+ let structural = currentSet.length !== prevSet.length || currentSet.some((rel, i) => rel !== prevSet[i]);
168
+ for (const rel of universe) {
169
+ const inCur = rel in content;
170
+ const inPrev = rel in previous.content;
171
+ if (inCur !== inPrev) {
172
+ changed.push(rel);
173
+ continue;
174
+ }
175
+ const bodyChanged = parts[rel] !== previous.parts[rel];
176
+ const structureChanged = structure_parts[rel] !== previous.structure_parts[rel];
177
+ if (structureChanged)
178
+ structural = true;
179
+ if (bodyChanged || structureChanged)
180
+ changed.push(rel);
181
+ }
182
+ const revision_diff = changed.map((file) => ({
183
+ file,
184
+ before: previous.content[file] ?? null,
185
+ after: content[file] ?? null,
186
+ }));
187
+ if (structural) {
188
+ return { follow_up: 'full', changed_documents: changed, related_documents: currentSet, revision_diff };
189
+ }
190
+ if (changed.length === 0) {
191
+ return { follow_up: 'partial', changed_documents: [], related_documents: [], revision_diff: [] };
192
+ }
193
+ // 3. partial의 관련 문서: 변경 task의 이전·현재 cluster 이웃 + DAG closure + epic/blueprint.
194
+ // epic/blueprint 자체가 바뀌면 모든 task 계약이 관련된다. 변경 문서는 delta 입력으로 따로
195
+ // 전달되므로 related에서는 뺀다(한 번만 전달).
196
+ const epicRel = `${epicDirOf(bp)}/index.md`;
197
+ const bpRel = `${bp}/index.md`;
198
+ const related = new Set([epicRel, bpRel]);
199
+ const changedTasks = changed.filter((rel) => TASKS_REL_RE.test(rel));
200
+ if (changed.includes(epicRel) || changed.includes(bpRel)) {
201
+ for (const rel of currentSet)
202
+ related.add(rel);
203
+ }
204
+ for (const group of [...clusterRels(bp, content), ...clusterRels(bp, previous.content)]) {
205
+ if (group.some((rel) => changedTasks.includes(rel)))
206
+ for (const rel of group)
207
+ related.add(rel);
208
+ }
209
+ for (const rel of dagClosure(bp, changedTasks, [content, previous.content]))
210
+ related.add(rel);
211
+ const related_documents = [...related]
212
+ .filter((rel) => rel in content && !changed.includes(rel))
213
+ .sort();
214
+ return { follow_up: 'partial', changed_documents: changed, related_documents, revision_diff };
215
+ }
216
+ function previousFail(reason, cause, next) {
217
+ return { ok: false, reason, cause, next };
218
+ }
219
+ /**
220
+ * 후속 리뷰가 기대하는 previous의 정상 payload 모양을 검사한다. review-dispatch plan이
221
+ * 내는 single/clustered/perspectives 성공 결과만 통과하며, 일부 필드만 골라 만든 축소본은 거절한다.
222
+ *
223
+ * @param {unknown} previous - --previous로 받은 JSON
224
+ * 표식 없는(legacy) blueprint는 perspectives 전략이 없던 시절의 계약이므로, 잘못된 전략에
225
+ * 구형 cause를 그대로 돌려준다. 복구 안내를 읽는 기존 호출자가 문구에 의존하기 때문이다.
226
+ *
227
+ * @param {unknown} previous - --previous로 받은 JSON
228
+ * @param {string} bp - 현재 blueprint 상대 POSIX 경로
229
+ * @param {'brief' | 'legacy'} contract - blueprint의 plan 리뷰 계약 표식 해석 결과
230
+ * @returns {PreviousFail | null} 형식 위반이면 invalid 실패, 정상이면 null
231
+ */
232
+ function checkPreviousShape(previous, bp, contract) {
233
+ const next = 'pass the unmodified JSON that review-dispatch plan printed for this blueprint';
234
+ const bad = (cause) => previousFail('previous-payload-invalid', cause, next);
235
+ if (!isRecord(previous) || previous.ok !== true || previous.phase !== 'plan') {
236
+ return bad('previous is not a successful plan payload');
237
+ }
238
+ if (contract === 'legacy') {
239
+ if (previous.strategy !== 'single' && previous.strategy !== 'clustered') {
240
+ return bad('previous strategy must be single or clustered');
241
+ }
242
+ }
243
+ else if (previous.strategy !== 'single' && previous.strategy !== 'clustered'
244
+ && previous.strategy !== 'perspectives') {
245
+ return bad('previous strategy must be single, clustered or perspectives');
246
+ }
247
+ const target = previous.target;
248
+ if (!isRecord(target) || typeof target.digest !== 'string' || !Array.isArray(target.documents)) {
249
+ return bad('previous target must carry digest and documents');
250
+ }
251
+ for (const key of ['parts', 'scope_parts']) {
252
+ const map = previous[key];
253
+ if (!isRecord(map) || Array.isArray(map))
254
+ return bad(`previous ${key} must be an object`);
255
+ }
256
+ // 다른 blueprint의 payload를 이 blueprint의 이전 상태로 쓰지 못하게 한다.
257
+ // payload에 blueprint가 없으면 문서 목록이 이 blueprint index를 품고 있어야 한다.
258
+ if (previous.blueprint !== undefined && previous.blueprint !== bp) {
259
+ return bad('previous belongs to another blueprint');
260
+ }
261
+ if (!target.documents.includes(`${bp}/index.md`)) {
262
+ return bad('previous documents do not include this blueprint');
263
+ }
264
+ // snapshot_version이 없는 payload는 digest-only legacy다: 읽기 호환만 하고 delta 근거로 쓰지 않는다.
265
+ if (previous.snapshot_version === undefined)
266
+ return null;
267
+ if (previous.snapshot_version !== SNAPSHOT_VERSION) {
268
+ return bad(`previous snapshot_version ${String(previous.snapshot_version)} is unsupported`);
269
+ }
270
+ return checkPreviousContent(previous, target, bp, bad);
271
+ }
272
+ /**
273
+ * v2 previous의 내용·hash·정규화 구조·키 집합·target identity가 서로 맞는지 검증한다.
274
+ * documents_content를 믿고 delta를 만들기 때문에, 본문이나 구조가 손으로 바뀐 payload는
275
+ * 여기서 모두 걸러야 한다 — 검증 없이 쓰면 위조된 before로 partial 판정이 흔들린다.
276
+ *
277
+ * @param {Record<string, unknown>} previous - snapshot_version 2 payload
278
+ * @param {{ digest: string, documents: unknown[] }} target - previous.target
279
+ * @param {string} bp - 현재 blueprint 상대 경로
280
+ * @param {(cause: string) => PreviousFail} bad - previous-payload-invalid 생성기
281
+ * @returns {PreviousFail | null} 불일치 사유, 통과하면 null
282
+ */
283
+ function checkPreviousContent(previous, target, bp, bad) {
284
+ for (const key of ['structure_parts', 'documents_content']) {
285
+ const map = previous[key];
286
+ if (!isRecord(map) || Array.isArray(map))
287
+ return bad(`previous ${key} must be an object`);
288
+ }
289
+ if (previous.follow_up !== 'partial' && previous.follow_up !== 'full') {
290
+ return bad('previous follow_up must be partial or full');
291
+ }
292
+ for (const key of ['changed_documents', 'related_documents', 'revision_diff']) {
293
+ if (!Array.isArray(previous[key]))
294
+ return bad(`previous ${key} must be an array`);
295
+ }
296
+ const documents = target.documents;
297
+ if (!documents.every((d) => typeof d === 'string')) {
298
+ return bad('previous target documents must be strings');
299
+ }
300
+ if (documents[0] !== `${epicDirOf(bp)}/index.md` || documents[1] !== `${bp}/index.md`) {
301
+ return bad('previous documents do not start with this epic and blueprint');
302
+ }
303
+ const sortedDocs = [...documents].sort();
304
+ // scope_parts는 관찰용이고 full 판정에 쓰지 않으므로 키 집합 대조에서 뺀다.
305
+ for (const key of ['parts', 'structure_parts', 'documents_content']) {
306
+ const keys = Object.keys(previous[key]).sort();
307
+ if (keys.length !== sortedDocs.length || keys.some((k, i) => k !== sortedDocs[i])) {
308
+ return bad(`previous ${key} keys differ from target documents`);
309
+ }
310
+ }
311
+ const parts = previous.parts;
312
+ const structureParts = previous.structure_parts;
313
+ const contentMap = previous.documents_content;
314
+ const checked = {};
315
+ for (const rel of documents) {
316
+ const entry = contentMap[rel];
317
+ if (!isRecord(entry) || typeof entry.body !== 'string'
318
+ || !isRecord(entry.structure) || Array.isArray(entry.structure)) {
319
+ return bad(`previous content for ${rel} must carry body and structure`);
320
+ }
321
+ const structure = entry.structure;
322
+ // 같은 정규화를 다시 거쳐도 변하지 않아야 한다: 기본값을 빠뜨리거나 정렬이 틀린 구조 거절.
323
+ const normalized = normalizeStructure(structureKindOf(rel, bp), structure);
324
+ if (!isDeepStrictEqual(normalized, structure))
325
+ return bad(`previous structure for ${rel} is not normalized`);
326
+ if (hashBody(entry.body) !== parts[rel])
327
+ return bad(`previous parts hash differs from content for ${rel}`);
328
+ if (hashStructure(normalized) !== structureParts[rel]) {
329
+ return bad(`previous structure_parts hash differs from content for ${rel}`);
330
+ }
331
+ checked[rel] = { body: entry.body, structure: normalized };
332
+ }
333
+ if (digestPlanContent(documents, checked) !== target.digest) {
334
+ return bad('previous target digest differs from its documents_content');
335
+ }
336
+ return null;
337
+ }
338
+ /**
339
+ * previous를 이 blueprint의 저장된 리뷰 증거와 대조한다. 읽기 전용이다.
340
+ * cycle state(.bouncer/runtime/context-review/<bp>/state.json)가 있으면 latest_dispatch
341
+ * 참조 파일과 payload가 같아야 하고, state 없이 context-review.md에 기존 증거만 있으면
342
+ * 마지막 round의 digest와 previous digest가 같아야 한다. 증거가 전혀 없을 때만 previous는
343
+ * 선택이다. 읽기 실패를 "증거 없음"으로 접으면 반복 초기화 경로가 열리므로, ENOENT만
344
+ * 없음으로 흡수하고 나머지는 invalid로 돌린다.
345
+ *
346
+ * @param {string} repoRoot - 저장소 루트
347
+ * @param {string} bp - blueprint 상대 POSIX 경로
348
+ * @param {unknown} previous - --previous로 받은 JSON(없으면 undefined)
349
+ * @param {'brief' | 'legacy'} contract - blueprint의 plan 리뷰 계약 표식 해석 결과
350
+ * @returns {PreviousFail | null} 거절 사유, 통과하면 null
351
+ */
352
+ function checkPreviousBinding(repoRoot, bp, previous, contract) {
353
+ const recover = 'restore the stored payload from the cycle (latest_dispatch) and pass it as --previous';
354
+ // 1. cycle state와 latest_dispatch 저장본은 context-review-state의 읽기 전용 helper로 읽는다.
355
+ // 쓰기 쪽과 같은 검증을 공유해 경로·id 규칙이 두 곳에서 어긋나지 않게 한다.
356
+ const stored = contextReviewState.readStoredLatest(repoRoot, bp);
357
+ if (!stored.ok) {
358
+ return previousFail('previous-payload-invalid', `cycle state invalid: ${stored.cause}`, recover);
359
+ }
360
+ // 2. state가 없으면 context-review.md의 증거(rounds/findings/restart_count 표식)를 본다.
361
+ // 표식은 restart가 남기는 값이라 runtime state가 지워져도 증거로 남는다.
362
+ let legacyDigest = null;
363
+ let hasEvidence = stored.state !== null;
364
+ if (stored.state === null) {
365
+ const legacy = contextReviewState.readLegacyEvidence(repoRoot, bp);
366
+ if (!legacy.ok) {
367
+ // 파싱 실패 문서를 "증거 없음"으로 보면 후속 리뷰가 최초처럼 전체 재리뷰된다.
368
+ return previousFail('previous-payload-invalid', `context-review.md unreadable: ${legacy.cause}`, 'fix the context-review.md frontmatter, then retry');
369
+ }
370
+ hasEvidence = legacy.present || legacy.marker >= 1;
371
+ legacyDigest = legacy.digest;
372
+ }
373
+ if (!hasEvidence)
374
+ return null;
375
+ if (previous === undefined) {
376
+ return previousFail('previous-payload-required', 'this blueprint already has review evidence, so a follow-up needs the recorded previous payload', recover);
377
+ }
378
+ const shape = checkPreviousShape(previous, bp, contract);
379
+ if (shape)
380
+ return shape;
381
+ // 3a. legacy: 마지막 round가 인증한 digest와 previous digest가 같아야 한다.
382
+ if (stored.state === null) {
383
+ const digest = previous.target.digest;
384
+ if (legacyDigest !== null && legacyDigest !== digest) {
385
+ return previousFail('previous-payload-stale', `previous digest ${digest} != last recorded round digest ${legacyDigest}`, recover);
386
+ }
387
+ return null;
388
+ }
389
+ // 3b. cycle: latest_dispatch 저장본(sha256 확인됨)과 payload가 같아야 한다.
390
+ if (!isDeepStrictEqual(stored.payload, previous)) {
391
+ return previousFail('previous-payload-stale', 'previous differs from the cycle latest_dispatch', recover);
392
+ }
393
+ return null;
394
+ }
395
+ /**
396
+ * blueprint index.md를 한 번만 읽어 plan 리뷰 계약 표식과 scale을 함께 얻는다.
397
+ * 읽기·파싱 실패는 계약을 legacy로 두고 사유만 `readError`에 담는다 — 구조 검사(S 코드)가
398
+ * 같은 파일의 실패를 먼저 드러내야 표식 없는 blueprint의 거절 문구가 바뀌지 않기 때문이다.
399
+ * 호출자는 structural 통과 뒤에도 readError가 남아 있으면 unreadable로 거절한다.
400
+ *
401
+ * @param {string} repoRoot - 저장소 루트
402
+ * @param {string} blueprintDir - blueprint 상대 경로
403
+ * @param {(file: string) => { data: unknown }} readIndex - index 읽기기(파일 I/O만 대체하는 seam)
404
+ * @returns {PlanIndexRead} contract는 'brief'|'legacy'|null(null은 허용 밖의 표식), scale은 기본 full
405
+ */
406
+ function readPlanIndex(repoRoot, blueprintDir, readIndex) {
407
+ let data;
408
+ try {
409
+ data = readIndex(path.join(repoRoot, toPosix(blueprintDir), 'index.md')).data;
410
+ }
411
+ catch (error) {
412
+ // 읽기·파싱 실패만 흡수한다. 사유는 보존해 structural 통과 뒤 호출자가 그대로 드러낸다.
413
+ return {
414
+ contract: 'legacy',
415
+ scale: 'full',
416
+ readError: error instanceof Error ? error.message : String(error),
417
+ };
418
+ }
419
+ const bouncer = data?.bouncer;
420
+ return {
421
+ contract: planReviewContractOf(bouncer),
422
+ scale: bouncer && typeof bouncer.scale === 'string' ? bouncer.scale : 'full',
423
+ readError: null,
424
+ };
425
+ }
426
+ /**
427
+ * Plan 리뷰 전략을 문서의 구조 신호만으로 분류한다.
428
+ * reviewer 호출 전에 structural 검사와 plan draft 검사(G5·G19·G20)를
429
+ * 통과해야 한다 — gate가 나중에 거절할 문서로 context review snapshot을 얼리지 않기 위해서다.
430
+ * working tree와 review 문서는 쓰지 않으며, 실패 시 perspectives를 만들지 않는다.
431
+ * `previous`가 있으면 문서별 digest로 follow_up을 판정한다. 저장된 리뷰 증거가 있는
432
+ * blueprint는 previous가 필수이며 cycle latest_dispatch와 일치해야 한다.
433
+ *
434
+ * @param {{ repoRoot: string, blueprintDir: string, previous?: unknown }} opts
435
+ * - repoRoot: 저장소 루트
436
+ * - blueprintDir: blueprint 상대 경로
437
+ * - previous: 이전 plan payload. 증거 없는 최초 분류에서만 생략 가능(follow_up=full)
438
+ * - deps.readIndex: blueprint index 읽기기. 생략하면 readDoc. 분류당 정확히 한 번 호출된다
439
+ * @returns {PlanDispatchResult} 성공 시 strategy·cluster·근거와(single/clustered면)
440
+ * parts·scope_parts·follow_up. 실패 시 ok:false —
441
+ * S 코드가 있으면 'structural validation failed', draft 검사 실패면
442
+ * 'plan draft validation failed'(failures는 plan gate와 같은 항목),
443
+ * previous 누락은 'previous-payload-required', 형식·blueprint 불일치는
444
+ * 'previous-payload-invalid', 저장 최신 payload와 다르면 'previous-payload-stale'
445
+ * (모두 cause·next 포함)
446
+ */
447
+ function classifyPlanReview({ repoRoot, blueprintDir, previous, deps }) {
448
+ // 0. 표식 해석 실패는 light 여부와 무관하게 거절한다. 깨진 표식을 legacy로 접으면 리뷰
449
+ // 전략이 조용히 바뀐다(fallback 없음). 표식은 선언된 값만 보고 크기·task 수로 추론하지
450
+ // 않는다. 구조 검사(S38)보다 앞에 두어 값 오류가 항상 같은 사유로 돌아오게 한다.
451
+ // index는 여기서 한 번만 읽고 계약과 scale을 모두 이 값에서 얻는다.
452
+ const index = readPlanIndex(repoRoot, blueprintDir, deps?.readIndex ?? readDoc);
453
+ const contract = index.contract;
454
+ if (contract === null)
455
+ return { ok: false, error: REVIEW_CONTRACT_INVALID };
456
+ // 1. 구조·draft 실패를 축소 판정으로 덮지 않는다 — malformed review_risk·문서 부재·
457
+ // G19/G20 위반은 그대로 돌려 controller가 reviewer를 호출하지 않게 한다.
458
+ // validateBlueprint는 S 실패가 있으면 draft 검사를 건너뛰므로 한 결과에
459
+ // S와 G가 섞이지 않는다. 그래서 S 하나만 있어도 structural로 분류해도 된다.
460
+ const checked = validateBlueprint({ repoRoot, blueprintDir, planDraft: true });
461
+ if (!checked.ok) {
462
+ const structural = checked.failures.some((f) => f.code.startsWith('S'));
463
+ return {
464
+ ok: false,
465
+ error: structural ? 'structural validation failed' : 'plan draft validation failed',
466
+ failures: checked.failures,
467
+ };
468
+ }
469
+ const bp = toPosix(blueprintDir);
470
+ // index 파싱 실패는 validateBlueprint가 이미 잡았어야 한다. 여기까지 오면
471
+ // 레이스나 직접 호출이므로 축소 skip으로 위장하지 않는다.
472
+ if (index.readError !== null) {
473
+ return { ok: false, error: `blueprint index unreadable: ${index.readError}` };
474
+ }
475
+ const scale = index.scale;
476
+ const marked = contract === 'brief';
477
+ const snapshot = computePlanSnapshot({ repoRoot, blueprintDir: bp });
478
+ if (!snapshot.ok)
479
+ return snapshot;
480
+ // 2. 표식 없는 light는 context review를 건너뛰는 신호(선언된 scale)만 본다. task 수로 skip을
481
+ // 추론하면 full 소규모 Plan이 context review를 건너뛴다. 표식 light는 skip하지 않고
482
+ // 아래 경로로 내려가 verifiability 한 관점 리뷰를 받는다.
483
+ if (scale === 'light' && !marked) {
484
+ return {
485
+ ok: true,
486
+ phase: 'plan',
487
+ target: { digest: snapshot.digest, documents: snapshot.documents },
488
+ strategy: 'skip',
489
+ task_count: countCommitTasks(repoRoot, bp),
490
+ clusters: [],
491
+ perspectives: [],
492
+ reasons: ['blueprint scale is light — context review is skipped'],
493
+ };
494
+ }
495
+ // previous는 light skip 전에 검사하지 않는다 — skip 응답에 follow_up 자체가
496
+ // 없으므로 깨진 previous로 skip 경로를 막지 않는다. single/clustered만 본다.
497
+ // 저장된 리뷰 증거가 있으면 previous가 필수이고 latest_dispatch와 같아야 한다:
498
+ // 누락을 최초(full)로 해석하면 후속 리뷰가 전체 리뷰로 되돌아 반복 한도가 무력해진다.
499
+ const bound = checkPreviousBinding(repoRoot, bp, previous, contract);
500
+ if (bound)
501
+ return bound;
502
+ let previousSnap = null;
503
+ if (previous !== undefined) {
504
+ // 증거 없는 최초 상태에서 준 previous도 같은 엄격한 모양이어야 한다.
505
+ const shape = checkPreviousShape(previous, bp, contract);
506
+ if (shape)
507
+ return shape;
508
+ const prev = previous;
509
+ previousSnap = prev.snapshot_version === undefined
510
+ ? { kind: 'legacy' }
511
+ : {
512
+ kind: 'v2',
513
+ documents: prev.target.documents,
514
+ content: prev.documents_content,
515
+ structure_parts: prev.structure_parts,
516
+ parts: prev.parts,
517
+ };
518
+ }
519
+ const partSnap = computePlanParts({ repoRoot, blueprintDir: bp });
520
+ if (!partSnap.ok)
521
+ return partSnap;
522
+ const follow = decidePlanFollowUp({
523
+ bp,
524
+ documents: snapshot.documents,
525
+ content: partSnap.documents_content,
526
+ structure_parts: partSnap.structure_parts,
527
+ parts: partSnap.parts,
528
+ previous: previousSnap,
529
+ });
530
+ // legacy migration은 reviewer가 사유를 알 수 있도록 reasons에도 남긴다(restart 한도는 우회하지 않는다).
531
+ const migrationReasons = previousSnap?.kind === 'legacy'
532
+ ? [`${LEGACY_REASON}: previous payload has no document content — full migration review`]
533
+ : [];
534
+ const reviewFields = {
535
+ snapshot_version: SNAPSHOT_VERSION,
536
+ parts: partSnap.parts,
537
+ scope_parts: partSnap.scope_parts,
538
+ structure_parts: partSnap.structure_parts,
539
+ documents_content: partSnap.documents_content,
540
+ follow_up: follow.follow_up,
541
+ changed_documents: follow.changed_documents,
542
+ related_documents: follow.related_documents,
543
+ revision_diff: follow.revision_diff,
544
+ };
545
+ const commitTasks = loadCommitTaskSignals({ repoRoot, blueprintDir: bp });
546
+ if (!commitTasks.ok)
547
+ return commitTasks;
548
+ const taskCount = commitTasks.tasks.length;
549
+ // 모든 full 최초 계획 리뷰는 task 수와 무관하게 single combined reviewer 한 명이 읽는다.
550
+ // clustered(local+global) 다중 호출은 비용 대비 효과가 낮아 신규 dispatch에서 제거했다 —
551
+ // 과거 clustered payload는 checkPreviousShape·loadDispatch가 읽기 호환으로 계속 받는다.
552
+ // cluster를 첫 task만으로 만들면 나머지 task의 Interface·Touch가 reviewer 입력에서
553
+ // 빠지므로, 모든 commit task를 한 cluster에 담는다. task가 0개면 cluster도 없다.
554
+ const sortedUnique = (values) => [...new Set(values)].sort();
555
+ const clusters = taskCount > 0
556
+ ? [{
557
+ id: 'c1',
558
+ tasks: commitTasks.tasks.map((task) => task.id).sort(),
559
+ interface_keys: sortedUnique(commitTasks.tasks.flatMap((task) => [...task.interfaceKeys])),
560
+ touch_paths: sortedUnique(commitTasks.tasks.flatMap((task) => [...task.touchPaths])),
561
+ }]
562
+ : [];
563
+ if (marked) {
564
+ // 표식 blueprint는 같은 cluster·reviewFields에 관점만 바꿔 싣는다: full은 4관점 병렬,
565
+ // light는 verifiability 하나. light용 별도 strategy 이름은 만들지 않는다.
566
+ const perspectives = scale === 'light' ? PLAN_LIGHT_PERSPECTIVES : PLAN_FULL_PERSPECTIVES;
567
+ return {
568
+ ok: true,
569
+ phase: 'plan',
570
+ target: { digest: snapshot.digest, documents: snapshot.documents },
571
+ strategy: 'perspectives',
572
+ task_count: taskCount,
573
+ clusters,
574
+ perspectives: [...perspectives],
575
+ reasons: [
576
+ `review_contract brief — ${perspectives.join(', ')} plan review perspectives`,
577
+ ...migrationReasons,
578
+ ],
579
+ ...reviewFields,
580
+ };
581
+ }
582
+ return {
583
+ ok: true,
584
+ phase: 'plan',
585
+ target: { digest: snapshot.digest, documents: snapshot.documents },
586
+ strategy: 'single',
587
+ task_count: taskCount,
588
+ clusters,
589
+ perspectives: ['combined'],
590
+ reasons: [
591
+ `commit task count ${taskCount} — single combined reviewer (every full plan review is single)`,
592
+ ...migrationReasons,
593
+ ],
594
+ ...reviewFields,
595
+ };
596
+ }
597
+ /**
598
+ * base 이후 최종 작업 트리의 동결 스냅샷 통계와 승인된 review_risk로 Execute 리뷰 전략을 고른다.
599
+ * 스냅샷 artifact는 `.bouncer/runtime/review-snapshots/`에 저장되며(그 밖에는 쓰지 않는다),
600
+ * target에 snapshot_id·snapshot_path가 실린다. head가 현재 HEAD와 다르거나 수집이 실패하면
601
+ * 줄 수 0으로 접지 않고 ok:false(reason·cause·next)를 돌려준다.
602
+ * 경로명·diff 본문 키워드로 위험을 추측하지 않는다.
603
+ * blueprint `bouncer.scale`이 `light`이면 diff 크기와 관계없이 `single`/
604
+ * `combined`다 — light run의 단일 reviewer 계약이 임계값에 갈리지 않게 하기
605
+ * 위함이다. `taskId`가 없으면 같은 numstat을 blueprint 전체로 보고, commit
606
+ * task의 `review_risk` 합집합을 쓴다 — 최종 리뷰가 한 task 문서에 묶이면
607
+ * 빠진 위험을 보안 관점에서 닫아버리기 때문이다. `reviewMode: 'delta'`면
608
+ * discovery가 parallel이어도 reviewer를 `combined` 하나로 접는다 — repair 뒤
609
+ * 같은 관점 목록을 다시 열면 토큰이 중복되기 때문이다.
610
+ *
611
+ * @param {{
612
+ * repoRoot: string, blueprintDir: string, taskId?: string,
613
+ * base: string, head: string, reviewMode?: 'discovery' | 'delta',
614
+ * exec?: ExecFileSyncFn
615
+ * }} opts - repoRoot는 저장소 루트, blueprintDir는 canonical blueprint 상대 경로,
616
+ * taskId는 선택적 commit task 번호, base/head는 frozen git ref,
617
+ * reviewMode는 discovery(기본)|delta, exec는 git 호출기
618
+ * @returns {ExecuteDispatchResult} 성공 시 strategy·통계·위험·snapshot target, 실패 시 ok:false.
619
+ * blueprint 범위 성공의 `target.task`는 null. S30 위반이면 perspectives 없음.
620
+ * delta 성공은 항상 `single`/`['combined']`이고 reasons에 한 줄이 더 있다.
621
+ */
622
+ function classifyExecuteReview({ repoRoot, blueprintDir, taskId, base, head, reviewMode = 'discovery', exec = realExecFileSync, }) {
623
+ // 1. Plan과 같은 S10 경계: isCanonicalBlueprintDir을 거치지 않으면
624
+ // `../`·절대경로가 path.join으로 repo 밖 tasks.md를 읽어 review_risk로
625
+ // security 관점을 열 수 있다. 전체 validateBlueprint는 다른 task의
626
+ // structural 실패까지 Execute를 막으므로 여기선 canonical만 맞춘다.
627
+ if (!isCanonicalBlueprintDir(blueprintDir)) {
628
+ return {
629
+ ok: false,
630
+ error: 'structural validation failed',
631
+ failures: [{
632
+ code: 'S10',
633
+ message: `blueprintDir must be under ${CONTEXT_ROOT}/epics`,
634
+ file: toPosix(blueprintDir),
635
+ }],
636
+ };
637
+ }
638
+ const bp = toPosix(blueprintDir);
639
+ // light 분기는 선언된 `bouncer.scale`만 본다. diff 크기·경로 수로 light를
640
+ // 추론하면 full 대규모 변경이 축소 리뷰로 빠지고, light 큰 diff는 parallel로
641
+ // 갈라져 단일 reviewer 계약이 깨진다.
642
+ let scale = 'full';
643
+ let reviewContract;
644
+ try {
645
+ const bpDoc = readDoc(path.join(repoRoot, bp, 'index.md'));
646
+ const bouncer = bpDoc.data?.bouncer;
647
+ if (bouncer && typeof bouncer.scale === 'string')
648
+ scale = bouncer.scale;
649
+ reviewContract = reviewContractOf(bouncer);
650
+ }
651
+ catch (error) {
652
+ // index 부재·파싱 실패를 기본 full로 접으면 light 선언을 놓친 채 parallel을
653
+ // 고른다. Plan 분류기와 같이 읽기 실패를 그대로 드러낸다.
654
+ const message = error instanceof Error ? error.message : String(error);
655
+ return { ok: false, error: `blueprint index unreadable: ${message}` };
656
+ }
657
+ // 표식 해석 실패는 light 여부와 무관하게 거절한다. 깨진 표식을 legacy로 접으면
658
+ // 리뷰 관점이 조용히 바뀐다(fallback 없음).
659
+ if (reviewContract === null)
660
+ return { ok: false, error: REVIEW_CONTRACT_INVALID };
661
+ // 브리프 관점은 task 없는 최종 리뷰의 full blueprint에만 적용한다. task 범위 리뷰는
662
+ // 표식과 무관하게 현행 규칙이고, light 표식 조합은 plan gate(S38)가 막으므로
663
+ // 분류기에서는 light 분기를 그대로 둔다.
664
+ const briefReview = reviewContract === 'brief' && scale !== 'light' && (taskId == null || taskId === '');
665
+ let targetTask = null;
666
+ let risk;
667
+ if (taskId == null || taskId === '') {
668
+ // 최종 리뷰는 한 commit task의 필드만 보면 다른 task의 credential 위험을
669
+ // 놓친다. 합집합을 여기서 만들고, 한 문서라도 S30이면 축소 성공으로
670
+ // 위장하지 않는다.
671
+ risk = unionCommitReviewRisk({ repoRoot, blueprintDir: bp });
672
+ }
673
+ else {
674
+ const digits = normalizeTaskDigits(taskId);
675
+ if (!digits) {
676
+ return { ok: false, error: `invalid task id: ${taskId}` };
677
+ }
678
+ const tasksRel = `${bp}/tasks/${digits}/tasks.md`;
679
+ const tasksAbs = path.join(repoRoot, tasksRel);
680
+ if (!fs.existsSync(tasksAbs)) {
681
+ return { ok: false, error: `unknown task: ${digits}` };
682
+ }
683
+ let taskData;
684
+ try {
685
+ taskData = readDoc(tasksAbs).data;
686
+ }
687
+ catch (error) {
688
+ // YAML/IO 실패를 missing task로 접으면 controller가 존재하지 않는 id로
689
+ // 재시도한다. 파싱 오류는 그대로 드러낸다.
690
+ const message = error instanceof Error ? error.message : String(error);
691
+ return { ok: false, error: `task unreadable: ${message}` };
692
+ }
693
+ const kind = taskExecutionKind(taskData);
694
+ if (kind !== 'commit') {
695
+ return { ok: false, error: `task ${digits} is not a commit task` };
696
+ }
697
+ risk = readReviewRisk(taskData.bouncer);
698
+ targetTask = digits;
699
+ }
700
+ if (!risk.ok)
701
+ return risk;
702
+ let resolvedBase;
703
+ let resolvedHead;
704
+ try {
705
+ resolvedBase = gitRevParse(exec, repoRoot, base);
706
+ resolvedHead = gitRevParse(exec, repoRoot, head);
707
+ }
708
+ catch (error) {
709
+ // 잘못된 ref·Git 부재는 single로 가장하지 않는다. frozen target이 없으면
710
+ // review round를 열 수 없다.
711
+ const message = error instanceof Error ? error.message : String(error);
712
+ return { ok: false, error: `git ref resolve failed: ${message}` };
713
+ }
714
+ // 별도 live diff로 분류한 뒤 다시 수집하면 분류와 리뷰 입력이 갈라진다. 최종 작업
715
+ // 트리를 한 번만 동결하고 그 artifact의 통계로 임계값을 판정한다. base 이후의
716
+ // 미커밋(staged·unstaged·untracked) 변경도 이 경로로만 센다.
717
+ const captured = captureReviewSnapshot({
718
+ repoRoot,
719
+ blueprintDir: bp,
720
+ taskId: targetTask,
721
+ base: resolvedBase,
722
+ head: resolvedHead,
723
+ deps: {
724
+ // 기존 exec 주입 호환: 모든 git 호출이 같은 exec를 지나가 테스트가 한 곳에서 대체한다.
725
+ runGit: (args) => Buffer.from(exec('git', args, { cwd: repoRoot, maxBuffer: 512 * 1024 * 1024 })),
726
+ },
727
+ });
728
+ if (!captured.ok)
729
+ return captured;
730
+ const stats = captured.snapshot;
731
+ const frozenTarget = {
732
+ base: captured.snapshot.target.base,
733
+ head: captured.snapshot.target.head,
734
+ task: targetTask,
735
+ snapshot_id: captured.snapshot_id,
736
+ snapshot_path: captured.snapshot_path,
737
+ };
738
+ // 계약별 계산 분리: snapshot·stats·risk는 위에서 공통으로 만들었고, 아래는 선택된 계약의
739
+ // 전략·관점·reasons만 계산한다. legacy 값을 먼저 만들고 brief로 덮어쓰면 두 계약의 규칙이
740
+ // 한 변수에 섞여 어느 쪽이 최종인지 읽기 어렵다.
741
+ let strategy;
742
+ let perspectives;
743
+ const reasons = [];
744
+ if (briefReview) {
745
+ // 브리프 관점은 규모와 무관하게 두 관점 고정, security는 인증·인가·자격 위험일 때만 붙는다.
746
+ const briefSecurity = risk.flags.some((flag) => BRIEF_SECURITY_RISKS.includes(flag));
747
+ strategy = 'parallel';
748
+ perspectives = ['spec_scope', 'correctness_tests', ...(briefSecurity ? ['security'] : [])];
749
+ reasons.push('review_contract brief — brief-compliance perspectives', briefSecurity
750
+ ? `review_risk ${risk.flags.join(',')} adds security reviewer`
751
+ : 'review_risk has no authentication/authorization/credential — no security reviewer');
752
+ }
753
+ else {
754
+ const small = stats.changed_files <= EXECUTE_SMALL_MAX_FILES
755
+ && stats.changed_lines <= EXECUTE_SMALL_MAX_LINES;
756
+ const lightScale = scale === 'light';
757
+ // light는 크기 임계를 건너뛴다. full만 EXECUTE_SMALL_*로 single/parallel을 고른다.
758
+ const single = lightScale || small;
759
+ strategy = single ? 'single' : 'parallel';
760
+ perspectives = single
761
+ ? ['combined']
762
+ : ['spec_scope', 'correctness_tests', 'minimality_maintainability'];
763
+ if (risk.flags.length > 0)
764
+ perspectives.push('security');
765
+ reasons.push(lightScale
766
+ ? 'blueprint scale is light — single combined reviewer regardless of diff size'
767
+ : small
768
+ ? `changed_files ${stats.changed_files} <= ${EXECUTE_SMALL_MAX_FILES}`
769
+ + ` and changed_lines ${stats.changed_lines} <= ${EXECUTE_SMALL_MAX_LINES}`
770
+ : `diff exceeds small thresholds (files=${stats.changed_files}, lines=${stats.changed_lines})`, risk.flags.length > 0
771
+ ? `review_risk ${risk.flags.join(',')} adds security reviewer`
772
+ : 'review_risk empty — no security reviewer');
773
+ }
774
+ // delta는 risk_flags·diff 통계를 discovery와 같게 두고 reviewer만 combined로
775
+ // 접는다. security를 다시 붙이면 repair 뒤 토큰 절감 목적이 무너진다.
776
+ if (reviewMode === 'delta') {
777
+ reasons.push('delta review — single combined reviewer after discovery');
778
+ return {
779
+ ok: true,
780
+ phase: 'execute',
781
+ target: frozenTarget,
782
+ strategy: 'single',
783
+ changed_files: stats.changed_files,
784
+ changed_lines: stats.changed_lines,
785
+ risk_flags: risk.flags,
786
+ perspectives: ['combined'],
787
+ reasons,
788
+ };
789
+ }
790
+ return {
791
+ ok: true,
792
+ phase: 'execute',
793
+ target: frozenTarget,
794
+ strategy,
795
+ changed_files: stats.changed_files,
796
+ changed_lines: stats.changed_lines,
797
+ risk_flags: risk.flags,
798
+ perspectives,
799
+ reasons,
800
+ };
801
+ }
802
+ /**
803
+ * commit task의 `review_risk`를 번호 순으로 합친다.
804
+ * 한 문서의 S30 위반을 다른 문서의 정상 값으로 덮으면 최종 리뷰가 깨진
805
+ * 필드를 보안 관점 없이 통과하므로, 첫 실패에서 멈춘다.
806
+ *
807
+ * @param {{ repoRoot: string, blueprintDir: string }} opts - 저장소와 blueprint 상대 경로
808
+ * @returns {{ ok: true, flags: string[] } | DispatchFail} 중복 없는 합집합, 또는 S30/읽기 실패
809
+ */
810
+ function unionCommitReviewRisk({ repoRoot, blueprintDir }) {
811
+ const listing = listTasksDocs({ repoRoot, blueprintDir });
812
+ const flags = [];
813
+ const seen = new Set();
814
+ for (const entry of listing.entries) {
815
+ if (entry.executionKind === 'verification')
816
+ continue;
817
+ if (entry.executionKind !== 'commit' && entry.executionKind !== null) {
818
+ return { ok: false, error: `task ${entry.id} has invalid execution_kind` };
819
+ }
820
+ let data;
821
+ try {
822
+ data = readDoc(path.join(repoRoot, entry.tasks.rel)).data;
823
+ }
824
+ catch (error) {
825
+ const message = error instanceof Error ? error.message : String(error);
826
+ return { ok: false, error: `task unreadable: ${entry.tasks.rel}: ${message}` };
827
+ }
828
+ if (executionKindOf(data.bouncer) === 'verification') {
829
+ continue;
830
+ }
831
+ const risk = readReviewRisk(data.bouncer);
832
+ if (!risk.ok)
833
+ return risk;
834
+ for (const flag of risk.flags) {
835
+ if (seen.has(flag))
836
+ continue;
837
+ seen.add(flag);
838
+ flags.push(flag);
839
+ }
840
+ }
841
+ return { ok: true, flags };
842
+ }
843
+ /**
844
+ * commit task만 Interface backtick·Touch 경로 신호를 모은다.
845
+ * verification node는 task_count/cluster에서 제외한다.
846
+ *
847
+ * @param {{ repoRoot: string, blueprintDir: string }} opts
848
+ * @returns {{ ok: true, tasks: CommitTaskSignals[] } | DispatchFail}
849
+ */
850
+ function loadCommitTaskSignals({ repoRoot, blueprintDir }) {
851
+ const listing = listTasksDocs({ repoRoot, blueprintDir });
852
+ const tasks = [];
853
+ for (const entry of listing.entries) {
854
+ if (entry.executionKind === 'verification')
855
+ continue;
856
+ if (entry.executionKind !== 'commit' && entry.executionKind !== null) {
857
+ return { ok: false, error: `task ${entry.id} has invalid execution_kind` };
858
+ }
859
+ // 부재=commit(executionKind null)은 schema 계약과 같다. listing이 null을
860
+ // 주면 레거시 문서를 commit으로 읽고, 잘못된 enum은 위에서 거절했다.
861
+ let body;
862
+ let data;
863
+ try {
864
+ const doc = readDoc(path.join(repoRoot, entry.tasks.rel));
865
+ body = doc.body;
866
+ data = doc.data;
867
+ }
868
+ catch (error) {
869
+ const message = error instanceof Error ? error.message : String(error);
870
+ return { ok: false, error: `task unreadable: ${entry.tasks.rel}: ${message}` };
871
+ }
872
+ if (executionKindOf(data.bouncer) === 'verification') {
873
+ continue;
874
+ }
875
+ const sections = parseTasksSections(body);
876
+ const interfaceText = typeof sections.interface === 'string' ? sections.interface.trim() : '';
877
+ const touchText = typeof sections.touch === 'string' ? sections.touch.trim() : '';
878
+ // 비어 있거나 절 자체가 없으면 cluster 입력을 꾸며내지 않는다. full Plan의
879
+ // Interface/Touch는 authoring 계약이므로 축소 single로 폴백하지 않는다.
880
+ if (!interfaceText || !touchText) {
881
+ return {
882
+ ok: false,
883
+ error: `empty or unreadable Interface/Touch in ${entry.tasks.rel}`,
884
+ };
885
+ }
886
+ const number = entry.number != null
887
+ ? String(entry.number).padStart(3, '0')
888
+ : (entry.id || '').replace(/^TASKS-/, '');
889
+ tasks.push({
890
+ ...signalsFromBody(number, body),
891
+ id: entry.id || `TASKS-${number}`,
892
+ });
893
+ }
894
+ return { ok: true, tasks };
895
+ }
896
+ /**
897
+ * Interface backtick 식별자를 모은다. 경로형·함수형 모두 cluster 키로 쓴다.
898
+ *
899
+ * @param {string} text - Interface 절 본문
900
+ * @returns {string[]} 중복 없는 backtick 내용
901
+ */
902
+ function extractBacktickKeys(text) {
903
+ const found = new Set();
904
+ for (const match of text.matchAll(/`([^`]+)`/g)) {
905
+ const key = match[1].trim();
906
+ if (key)
907
+ found.add(key);
908
+ }
909
+ return [...found];
910
+ }
911
+ /**
912
+ * Interface 키 공유 또는 Touch 경로 중첩의 연결 요소를 cluster로 만든다.
913
+ * 중첩이 없어도 각 task가 단독 cluster가 되어 global과 짝을 이룬다.
914
+ *
915
+ * @param {CommitTaskSignals[]} tasks - 번호 순 commit task 신호
916
+ * @returns {PlanCluster[]} id 순 cluster 목록
917
+ */
918
+ function buildClusters(tasks) {
919
+ const n = tasks.length;
920
+ const parent = Array.from({ length: n }, (_, i) => i);
921
+ const find = (i) => {
922
+ let root = i;
923
+ while (parent[root] !== root)
924
+ root = parent[root];
925
+ let cur = i;
926
+ while (parent[cur] !== cur) {
927
+ const next = parent[cur];
928
+ parent[cur] = root;
929
+ cur = next;
930
+ }
931
+ return root;
932
+ };
933
+ const union = (a, b) => {
934
+ const ra = find(a);
935
+ const rb = find(b);
936
+ if (ra !== rb)
937
+ parent[rb] = ra;
938
+ };
939
+ for (let i = 0; i < n; i += 1) {
940
+ for (let j = i + 1; j < n; j += 1) {
941
+ const sharedKey = tasks[i].interfaceKeys.some((k) => tasks[j].interfaceKeys.includes(k));
942
+ const sharedPath = tasks[i].touchPaths.some((a) => (tasks[j].touchPaths.some((b) => pathsOverlap(a, b))));
943
+ if (sharedKey || sharedPath)
944
+ union(i, j);
945
+ }
946
+ }
947
+ const groups = new Map();
948
+ for (let i = 0; i < n; i += 1) {
949
+ const root = find(i);
950
+ const list = groups.get(root) || [];
951
+ list.push(tasks[i]);
952
+ groups.set(root, list);
953
+ }
954
+ const ordered = [...groups.values()].sort((a, b) => {
955
+ const an = Math.min(...a.map((t) => Number(t.number)));
956
+ const bn = Math.min(...b.map((t) => Number(t.number)));
957
+ return an - bn;
958
+ });
959
+ return ordered.map((group, index) => {
960
+ const interfaceKeys = new Set();
961
+ const touchPaths = new Set();
962
+ for (const task of group) {
963
+ for (const key of task.interfaceKeys)
964
+ interfaceKeys.add(key);
965
+ for (const p of task.touchPaths)
966
+ touchPaths.add(p);
967
+ }
968
+ return {
969
+ id: `c${index + 1}`,
970
+ tasks: group.map((t) => t.id).sort(),
971
+ interface_keys: [...interfaceKeys].sort(),
972
+ touch_paths: [...touchPaths].sort(),
973
+ };
974
+ });
975
+ }
976
+ /**
977
+ * commit task 개수만 센다. light skip 응답의 task_count용.
978
+ *
979
+ * @param {string} repoRoot - 저장소 루트
980
+ * @param {string} blueprintDir - blueprint 상대 경로
981
+ * @returns {number} verification을 제외한 commit task 수
982
+ */
983
+ function countCommitTasks(repoRoot, blueprintDir) {
984
+ const listing = listTasksDocs({ repoRoot, blueprintDir });
985
+ let count = 0;
986
+ for (const entry of listing.entries) {
987
+ if (entry.executionKind === 'verification')
988
+ continue;
989
+ count += 1;
990
+ }
991
+ return count;
992
+ }
993
+ /**
994
+ * `--task` 값을 세 자리 숫자로 정규화한다.
995
+ *
996
+ * @param {string} taskId - `001` 또는 `TASKS-001`
997
+ * @returns {string | null} 세 자리 숫자, 형식이 아니면 null
998
+ */
999
+ function normalizeTaskDigits(taskId) {
1000
+ if (/^\d{3}$/.test(taskId))
1001
+ return taskId;
1002
+ const match = /^TASKS-(\d{3})$/.exec(taskId);
1003
+ return match ? match[1] : null;
1004
+ }
1005
+ /**
1006
+ * bouncer.review_risk를 읽는다. 부재는 legacy []로 읽고, 형태 오류만 거절한다.
1007
+ *
1008
+ * @param {Record<string, unknown> | undefined} bouncer - tasks frontmatter bouncer
1009
+ * @returns {{ ok: true, flags: string[] } | DispatchFail}
1010
+ */
1011
+ function readReviewRisk(bouncer) {
1012
+ if (!bouncer || bouncer.review_risk === undefined) {
1013
+ return { ok: true, flags: [] };
1014
+ }
1015
+ const value = bouncer.review_risk;
1016
+ if (!Array.isArray(value)) {
1017
+ return { ok: false, error: 'S30 review_risk must be an array' };
1018
+ }
1019
+ const flags = [];
1020
+ const seen = new Set();
1021
+ for (const entry of value) {
1022
+ if (typeof entry !== 'string' || !REVIEW_RISK_ENUM.includes(entry)) {
1023
+ return { ok: false, error: `S30 review_risk value invalid: ${String(entry)}` };
1024
+ }
1025
+ if (seen.has(entry)) {
1026
+ return { ok: false, error: `S30 review_risk duplicate: ${entry}` };
1027
+ }
1028
+ seen.add(entry);
1029
+ flags.push(entry);
1030
+ }
1031
+ return { ok: true, flags };
1032
+ }
1033
+ /**
1034
+ * Git 객체를 해석한다. 잘못된 ref는 throw한다.
1035
+ *
1036
+ * @param {ExecFileSyncFn} exec - 주입 가능한 execFileSync
1037
+ * @param {string} repoRoot - cwd
1038
+ * @param {string} ref - base/head 후보
1039
+ * @returns {string} 40자 또는 short SHA
1040
+ */
1041
+ function gitRevParse(exec, repoRoot, ref) {
1042
+ const out = exec('git', ['rev-parse', '--verify', `${ref}^{commit}`], {
1043
+ cwd: repoRoot,
1044
+ encoding: 'utf8',
1045
+ });
1046
+ return String(out).trim();
1047
+ }
1048
+ module.exports = {
1049
+ EXECUTE_SMALL_MAX_FILES,
1050
+ EXECUTE_SMALL_MAX_LINES,
1051
+ REVIEW_RISK_ENUM,
1052
+ classifyPlanReview,
1053
+ classifyExecuteReview,
1054
+ };