@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,417 @@
1
+ 'use strict';
2
+ const guards = require("./guards");
3
+ const { catchMessage } = guards;
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const schema = require("./schema");
7
+ const { detectLegacyFormat, isValidAuthoredLineList } = schema;
8
+ const layout = require("./layout");
9
+ const { CONTEXT_ROOT, isCanonicalBlueprintDir, blueprintPathProblem } = layout;
10
+ const contextIds = require("./context-ids");
11
+ const { validateSlug } = contextIds;
12
+ const paths = require("./paths");
13
+ const { toPosix } = paths;
14
+ const verification = require("./verification");
15
+ const { runVerification } = verification;
16
+ const verificationTarget = require("./verification-target");
17
+ const { resolveVerificationTarget } = verificationTarget;
18
+ const epicIndex = require("./epic-index");
19
+ const epicRegistry = require("./epic-registry");
20
+ const { checkEpicIndexConsistency } = epicIndex;
21
+ const validateDocs = require("./validate-docs");
22
+ const { loadBlueprintDocs, resolveTaskUnit, blueprintDocsExist, statusOf, requiredTaskLeaves, isBlueprintReviewMode, } = validateDocs;
23
+ const validateStructural = require("./validate-structural");
24
+ const { checkStructural, checkSlugBlueprint } = validateStructural;
25
+ const config = require("./config");
26
+ const { readVerifyPolicy } = config;
27
+ const validateGates = require("./validate-gates");
28
+ const { checkGate, checkPlanDraft } = validateGates;
29
+ const validateGatesPlan = require("./validate-gates-plan");
30
+ const { PREREQUISITE_NEXT } = validateGatesPlan;
31
+ const validateSections = require("./validate-sections");
32
+ const { parseTasksSections, parseSections, extractPathCandidates, } = validateSections;
33
+ const templates = require("./templates");
34
+ const compositeGates = require("./composite-gates");
35
+ const { summarizeCompositeGates } = compositeGates;
36
+ const { parseIntentBody, normalizeAuthoredLines } = templates;
37
+ // 복구가 메시지에 없는 코드만 여기를 본다. 같은 G 코드라도 이미 다음 행동을
38
+ // 말하는 메시지(G13 body sections 등)에는 next를 붙이지
39
+ // 않아야 에이전트가 표 밖의 경로를 발명하지 않는다. match는 메시지 문자열을
40
+ // 바꾸지 않고 고르는 필터다.
41
+ const GATE_FAILURE_HINTS = Object.freeze([
42
+ {
43
+ code: 'G13',
44
+ match: /missing harness verify ledger record|does not match verify ledger/,
45
+ next: 'Rerun this checkout\'s active-task `bouncer verify --blueprint <dir>`, then rerun the gate.',
46
+ },
47
+ {
48
+ code: 'G20',
49
+ match: /Touch must not declare source changes/,
50
+ next: 'Set Touch to `Source 변경 경로 없음.` and keep the command only in frontmatter `verify`.',
51
+ },
52
+ {
53
+ code: 'G23',
54
+ match: /verification evidence is stale/,
55
+ next: 'Rerun this checkout\'s active-task `bouncer verify --blueprint <dir>`, then rerun the commit gate.',
56
+ },
57
+ {
58
+ code: 'G24',
59
+ match: /approved scope changed after activation/,
60
+ next: 'If the change is intended, get the user\'s explicit approval, then run '
61
+ + '`bouncer current --set <dir> --reapprove` and rerun the gate.',
62
+ },
63
+ {
64
+ code: 'G32',
65
+ next: PREREQUISITE_NEXT,
66
+ },
67
+ {
68
+ code: 'S32',
69
+ match: /must be a YAML list/,
70
+ next: 'Rewrite the field as a YAML list of 1-2 Korean terminal sentences '
71
+ + '(each item on its own `- ` line), then rerun the plan gate.',
72
+ },
73
+ {
74
+ code: 'S33',
75
+ match: /blueprint Intent invalid/,
76
+ // Intent에 다이어그램·코드 펜스를 두면 문장 검사에 걸려 finalize가
77
+ // 스택으로 터지던 경로다. 문장만 남기고 펜스는 다른 ## 절로 옮기라고 안내한다.
78
+ next: 'Keep only 1-2 Korean terminal sentences under `## Intent`, move code '
79
+ + 'fences and diagrams under a separate `##` heading, then rerun the gate.',
80
+ },
81
+ {
82
+ code: 'S34',
83
+ match: /^(commit_intent|commit_summary) invalid: /,
84
+ // hint 표는 정적이라 필드 이름을 못 끼운다. 어느 필드가 틀렸는지는 메시지가
85
+ // 말하므로 next는 두 필드 공통 문구로 둔다.
86
+ next: 'Rewrite each failing commit_intent/commit_summary item as one Korean '
87
+ + 'terminal sentence (e.g. …함, …임, …됨, …다), then rerun the plan gate.',
88
+ },
89
+ {
90
+ code: 'S35',
91
+ next: 'Fix the `.bouncer/epics.json` format, or recreate it with `bouncer init`.',
92
+ },
93
+ {
94
+ code: 'S36',
95
+ next: 'Set blueprint `area` to a lowercase slug and `attribution` to a non-empty single line, '
96
+ + 'or remove the key, then rerun the gate.',
97
+ },
98
+ {
99
+ code: 'S37',
100
+ next: 'Give a slug blueprint a unique positive integer `order`, keep its slug unique in the epic, and use '
101
+ + 'the same format (numbered or slug) for the epic and blueprint directories, then rerun the gate.',
102
+ },
103
+ ]);
104
+ /**
105
+ * validate 실패 항목에 hint 표의 next를 붙인 사본을 만든다.
106
+ * 판정(ok·순서·warnings)은 바꾸지 않고, match가 맞는 항목에만 next 키를 넣는다.
107
+ * G 실패가 하나 이상이면 복합 gate 요약 `gates`를 함께 싣는다.
108
+ *
109
+ * @param {ValidateResult} result - validateBlueprint가 만들 원본 결과
110
+ * @returns {ValidateResult} failures에 next가 병합된 사본. 맞는 hint가 없으면 그 항목은 그대로다
111
+ */
112
+ function withGateFailureHints(result) {
113
+ const failures = result.failures.map((entry) => {
114
+ const hint = GATE_FAILURE_HINTS.find((row) => (row.code === entry.code && (!row.match || row.match.test(entry.message))));
115
+ if (!hint)
116
+ return entry;
117
+ return { ...entry, next: hint.next };
118
+ });
119
+ // gates는 hint 병합 뒤에 요약해야 gate의 next가 첫 실패 항목의 hint를 따른다.
120
+ // G 실패가 없으면 키 자체를 싣지 않는다(warnings와 같은 방식) — 성공 결과 전체를
121
+ // deepStrictEqual로 고정한 기존 테스트가 깨지지 않게 하기 위함이다.
122
+ const gates = summarizeCompositeGates(failures);
123
+ return gates.length > 0 ? { ...result, failures, gates } : { ...result, failures };
124
+ }
125
+ /**
126
+ * blueprint 문서를 로드해 structural(S) 검사와 선택한 gate 또는 plan draft 검사를 돌린다.
127
+ * `gate: 'execute'`는 검사 전에 verify 명령을 실행해 증적을 새로 쓰고,
128
+ * 통과하면 lease·pointer commit task를 `ready`에서 `verified`로 올린다(부작용).
129
+ * `planDraft: true`는 structural·S18 실패가 없을 때만 checkPlanDraft를 돌린다 —
130
+ * S 실패 위에 G 코드를 쌓으면 review-dispatch가 원인을 structural로 분류하지 못한다.
131
+ *
132
+ * @param {object} opts - 검증 옵션
133
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
134
+ * @param {string} opts.blueprintDir - `.bouncer/context/epics/...` 아래 blueprint 상대 경로
135
+ * @param {string} [opts.gate] - plan | execute | commit | finalize | partial-close. 없으면 S 검사만
136
+ * @param {boolean} [opts.planDraft] - true면 gate 대신 status 무관 plan draft 검사를 돌린다
137
+ * @param {GateDeps} [opts.deps] - gate 검사에 주입할 의존성(테스트용)
138
+ * @returns {{ ok: boolean, failures: FailureEntry[], warnings?: FailureEntry[] }}
139
+ * failures가 비면 ok:true. 경고(task-split·dag-*·review-risk-missing 등)가 있을 때만 warnings 키를 싣는다
140
+ * @throws {Error} planDraft와 gate를 함께 주면 'planDraft cannot be combined with gate'
141
+ */
142
+ function validateBlueprint({ repoRoot, blueprintDir, gate, planDraft, deps, }) {
143
+ // 둘 다 주면 어느 판정이 ok를 결정하는지 모호하다. 호출 계약 오류이므로
144
+ // 문서 결과로 접지 않고 파일을 읽기 전에 던진다.
145
+ if (planDraft && gate) {
146
+ throw new Error('planDraft cannot be combined with gate');
147
+ }
148
+ if (!isCanonicalBlueprintDir(blueprintDir)) {
149
+ // slug 체계를 쓰는 epic 아래의 경로 오류(형식 혼용·slug 규칙 위반)는 일반 경로 오류 S10 대신
150
+ // 어디를 고쳐야 하는지 알려 주는 S37로 보고한다. 그 밖의 비정본 경로는 S10 그대로다.
151
+ const problem = blueprintPathProblem(blueprintDir);
152
+ if (problem !== null) {
153
+ const leaf = toPosix(blueprintDir).split('/').pop();
154
+ const detail = problem === 'format-mixed'
155
+ ? 'the epic and blueprint directories must both be numbered (ddd-name) or both be slugs'
156
+ : `blueprint directory name is not a valid slug: ${validateSlug(leaf)}`;
157
+ return withGateFailureHints({
158
+ ok: false,
159
+ failures: [{ code: 'S37', message: `${problem}: ${detail}`, file: toPosix(blueprintDir) }],
160
+ });
161
+ }
162
+ return withGateFailureHints({
163
+ ok: false,
164
+ failures: [{
165
+ code: 'S10',
166
+ message: `blueprintDir must be under ${CONTEXT_ROOT}/epics`,
167
+ file: toPosix(blueprintDir),
168
+ }],
169
+ });
170
+ }
171
+ const legacyRepo = detectLegacyFormat({ repoRoot });
172
+ if (legacyRepo.legacy) {
173
+ return withGateFailureHints({
174
+ ok: false,
175
+ failures: [{ code: 'S2', message: legacyRepo.reason, file: '.sdd' }],
176
+ });
177
+ }
178
+ // blueprint 문서가 하나도 없으면 문서 문제가 아니라 잘못된 경로를 의미함.
179
+ // 이를 먼저 보고하면 빈 문서 집합이 만드는 gate 실패 연쇄를 쫓지 않게 하고,
180
+ // 존재하지 않는 경로에 대해 `execute`가 verify 명령을 실행하는 것을 막음.
181
+ // epic index는 의도적으로 제외: 해당 epic 아래 모든 blueprint에 존재하므로,
182
+ // 오타 난 blueprint 이름이 이 검사를 통과해 버릴 수 있음.
183
+ if (!blueprintDocsExist({ repoRoot, blueprintDir })) {
184
+ return withGateFailureHints({
185
+ ok: false,
186
+ failures: [{
187
+ code: 'S11',
188
+ message: 'blueprint documents not found — check the blueprint path',
189
+ file: toPosix(blueprintDir),
190
+ }],
191
+ });
192
+ }
193
+ const executionFailures = [];
194
+ if (gate === 'execute') {
195
+ // G13 file은 포인터 대상 묶음의 verification 경로. 루트 고정 경로를 쓰면
196
+ // tasks/<NNN>/ 레이아웃에서 실패 위치가 엉킨다.
197
+ let verificationFile = `${toPosix(blueprintDir)}/verification.md`;
198
+ try {
199
+ const { effectiveTask, entries } = resolveVerificationTarget({ repoRoot, blueprintDir });
200
+ if (entries[0] && entries[0].verification && entries[0].verification.rel) {
201
+ verificationFile = entries[0].verification.rel;
202
+ }
203
+ const verification = runVerification({
204
+ repoRoot, blueprintDir, effectiveTask, markTaskVerified: true,
205
+ });
206
+ if (!verification.ok) {
207
+ executionFailures.push({
208
+ code: 'G13',
209
+ message: `configured verify command failed with exit code ${verification.exitCode}`,
210
+ file: verificationFile,
211
+ });
212
+ }
213
+ }
214
+ catch (error) {
215
+ executionFailures.push({
216
+ code: 'G13',
217
+ message: catchMessage(error),
218
+ file: verificationFile,
219
+ });
220
+ }
221
+ }
222
+ // execute gate가 방금 기록한 증적을 읽도록 verification 이후에 로드.
223
+ const { docs, rels, parseErrors, tasksListing } = loadBlueprintDocs({ repoRoot, blueprintDir });
224
+ const failures = [...executionFailures, ...parseErrors];
225
+ // 한 blueprint에 레거시 tasks.md와 번호 문서가 섞이면 어느 규칙을
226
+ // 적용할지 모호해지므로 구조 단계에서 거절한다.
227
+ if (tasksListing && tasksListing.legacyFiles && tasksListing.legacyFiles.length) {
228
+ failures.push({
229
+ code: 'S15',
230
+ message: `legacy task layout remains: ${tasksListing.legacyFiles.join(', ')}; run bouncer migrate task-layout`,
231
+ file: toPosix(blueprintDir),
232
+ });
233
+ }
234
+ for (const name of (tasksListing && tasksListing.invalidDirs) || []) {
235
+ failures.push({
236
+ code: 'S16',
237
+ message: `non-canonical task directory: tasks/${name}`,
238
+ file: `${toPosix(blueprintDir)}/tasks/${name}`,
239
+ });
240
+ }
241
+ // closed는 finalize가 남긴 축약 레이아웃(task leaf 없음)을 허용하고,
242
+ // 열린 blueprint는 기존처럼 세 leaf를 모두 요구한다. 모드에서는 review
243
+ // leaf를 빼므로 구형 루트 review.md 존재만으로 S17이 바뀌지 않는다.
244
+ const requiredLeaves = requiredTaskLeaves(statusOf(docs.blueprintIndex), undefined, isBlueprintReviewMode(docs.blueprintIndex));
245
+ for (const entry of (tasksListing && tasksListing.entries) || []) {
246
+ for (const leaf of requiredLeaves) {
247
+ const rel = entry[leaf].rel;
248
+ if (!fs.existsSync(path.join(repoRoot, rel))) {
249
+ failures.push({
250
+ code: 'S17',
251
+ message: `task unit ${entry.number} missing ${path.posix.basename(rel)}`,
252
+ file: rel,
253
+ });
254
+ }
255
+ }
256
+ }
257
+ const anyLeaf = (docs.tasksDocs && docs.tasksDocs.length > 0)
258
+ || (docs.taskUnits && docs.taskUnits.length > 0)
259
+ || ['verification', 'review', 'explain'].some((k) => docs[k]);
260
+ if (anyLeaf && !docs.blueprintIndex) {
261
+ failures.push({ code: 'S8', message: 'blueprint index.md absent', file: rels.blueprintIndex });
262
+ }
263
+ if (docs.blueprintIndex && !docs.epicIndex) {
264
+ failures.push({ code: 'S8', message: 'epic index.md absent', file: rels.epicIndex });
265
+ }
266
+ // S33: plan·finalize만. draft와 Intent 헤딩 부재는 기존 fixture·scaffold를
267
+ // 보존하려고 건너뛴다 — 헤딩이 없으면 finalize prepare도 intent:[]로 통과한다.
268
+ // 오류 메시지 원문은 parseIntentBody throw를 그대로 붙인다(계약 문자열 유지).
269
+ if ((gate === 'plan' || gate === 'finalize') && docs.blueprintIndex
270
+ && statusOf(docs.blueprintIndex) !== 'draft') {
271
+ const bpBody = typeof docs.blueprintIndex.body === 'string'
272
+ ? docs.blueprintIndex.body
273
+ : '';
274
+ const hasIntentHeading = /^##\s+Intent\s*$/im.test(bpBody.replace(/<!--[\s\S]*?-->/g, ''));
275
+ if (hasIntentHeading) {
276
+ try {
277
+ parseIntentBody(bpBody);
278
+ }
279
+ catch (error) {
280
+ failures.push({
281
+ code: 'S33',
282
+ message: `blueprint Intent invalid: ${catchMessage(error)}`,
283
+ file: rels.blueprintIndex || docs.blueprintIndex.rel,
284
+ });
285
+ }
286
+ }
287
+ }
288
+ // S34: plan gate만. S32(목록 형태)는 checkStructural이 gate를 몰라 그대로 두고,
289
+ // 종결어미 판정은 `bouncer commit`이 쓰는 normalizeAuthoredLines를 그대로 불러
290
+ // 두 시점의 답이 갈라지지 않게 한다. 형태 오류는 S32 소관이라 건너뛰고, 부재·[]는
291
+ // 작성 전 scaffold라 통과시킨다. 필드당 한 번만 보고한다.
292
+ if (gate === 'plan') {
293
+ const hasTaskUnitsForS34 = Array.isArray(docs.taskUnits) && docs.taskUnits.length > 0;
294
+ const taskLeaves = hasTaskUnitsForS34
295
+ ? (docs.taskUnits || []).flatMap((unit) => (unit.tasks ? [unit.tasks] : []))
296
+ : (docs.tasksDocs || (docs.tasks ? [docs.tasks] : []));
297
+ for (const leaf of taskLeaves) {
298
+ const data = leaf.data;
299
+ const bouncerFm = (data && data.bouncer) || {};
300
+ for (const field of ['commit_intent', 'commit_summary']) {
301
+ const value = bouncerFm[field];
302
+ if (!isValidAuthoredLineList(value) || value === undefined)
303
+ continue;
304
+ if (Array.isArray(value) && value.length === 0)
305
+ continue;
306
+ try {
307
+ normalizeAuthoredLines(value, field);
308
+ }
309
+ catch (error) {
310
+ failures.push({
311
+ code: 'S34',
312
+ message: `${field} invalid: ${catchMessage(error)}`,
313
+ file: leaf.rel,
314
+ });
315
+ }
316
+ }
317
+ }
318
+ }
319
+ // 구조 검사를 돌기 전에 정책을 한 번만 읽는다. 문서마다 config를 다시
320
+ // 열면 같은 blueprint에서 S12 답이 갈라질 수 있고, 파손된 파일을
321
+ // 기본 목록으로 접으면 runtime VERIFY_CONFIG_INVALID와 어긋난다.
322
+ const verifyPolicy = readVerifyPolicy(repoRoot);
323
+ if (verifyPolicy.ok === false) {
324
+ failures.push({
325
+ code: 'S12',
326
+ message: `verification config is invalid: ${path.join(repoRoot, '.bouncer', 'config.json')}`,
327
+ file: '.bouncer/config.json',
328
+ });
329
+ }
330
+ const verifyAllowlist = verifyPolicy.ok === true
331
+ ? verifyPolicy.allowlist
332
+ : [];
333
+ const hasTaskUnits = Array.isArray(docs.taskUnits) && docs.taskUnits.length > 0;
334
+ // 번호 tasks가 루트 verification/review를 공유하면 같은 rel을 두 번
335
+ // 검사하지 않는다. unit leaf에서 못 본 루트 파일은 그대로 검사한다
336
+ // (task-dir 레이아웃에 남은 고아 루트 증적/리뷰).
337
+ const unitSeenRels = new Set();
338
+ for (const key of Object.keys(docs)) {
339
+ if (key === 'taskUnits') {
340
+ for (const unit of docs.taskUnits || []) {
341
+ for (const leaf of ['tasks', 'verification', 'review']) {
342
+ const leafDoc = unit[leaf];
343
+ if (!leafDoc || unitSeenRels.has(leafDoc.rel))
344
+ continue;
345
+ unitSeenRels.add(leafDoc.rel);
346
+ checkStructural(leafDoc, failures, verifyAllowlist);
347
+ }
348
+ }
349
+ continue;
350
+ }
351
+ if (key === 'tasksDocs') {
352
+ // taskUnits가 있으면 tasks leaf는 그쪽에서 이미 검사함.
353
+ if (hasTaskUnits)
354
+ continue;
355
+ for (const td of docs.tasksDocs || [])
356
+ checkStructural(td, failures, verifyAllowlist);
357
+ continue;
358
+ }
359
+ // tasksDocs/taskUnits가 있으면 docs.tasks는 그 첫 항목이라 중복 검사하지 않는다.
360
+ if (key === 'tasks' && (docs.tasksDocs || hasTaskUnits))
361
+ continue;
362
+ if ((key === 'verification' || key === 'review')
363
+ && docs[key]
364
+ && unitSeenRels.has(docs[key].rel)) {
365
+ continue;
366
+ }
367
+ checkStructural(docs[key], failures, verifyAllowlist);
368
+ }
369
+ // S37: slug 형식 blueprint의 order·slug 판정은 형제 문서를 읽어야 해서 문서 하나만 보는 checkStructural
370
+ // 밖에서, blueprint index 하나에 대해 한 번만 돈다. 번호 형식 경로는 아무것도 내지 않는다.
371
+ failures.push(...checkSlugBlueprint({ repoRoot, blueprintDir, blueprintIndex: docs.blueprintIndex }));
372
+ failures.push(...checkEpicIndexConsistency({ repoRoot }));
373
+ // 등록부가 있을 때만 형식을 본다. 부재는 이 BP에서 차단하지 않아 기존 결과가 그대로다.
374
+ failures.push(...epicRegistry.checkEpicRegistry({ repoRoot }));
375
+ // imported blueprint는 게이트·작업 대상이 아니다. 구조·에픽목록 검사는 유지하되
376
+ // checkGate를 건너뛰고 S18 하나로 거절한다 — ok:true/gateSkipped로 통과시키면
377
+ // cmdCurrent --set이 plan 통과로 포인터를 잡아버린다. epic-only imported는
378
+ // 판정하지 않는다(기준은 blueprint status). S14는 결번이므로 재사용하지 않는다.
379
+ if (statusOf(docs.blueprintIndex) === 'imported') {
380
+ failures.push({
381
+ code: 'S18',
382
+ message: 'imported document is out of gate scope',
383
+ file: rels.blueprintIndex,
384
+ });
385
+ return withGateFailureHints({ ok: false, failures });
386
+ }
387
+ // plan task 분해 경고는 failures와 분리한다. ok는 실패만 본다.
388
+ const warnings = [];
389
+ if (planDraft) {
390
+ // 이 시점 failures는 S 코드뿐이다(S18은 위에서 이미 반환). S가 있으면 draft
391
+ // 검사를 건너뛰어 한 결과에 S와 G가 섞이지 않게 한다.
392
+ if (failures.length > 0)
393
+ return withGateFailureHints({ ok: false, failures });
394
+ checkPlanDraft(docs, rels, failures, { warnings, repoRoot, blueprintDir, deps });
395
+ }
396
+ else if (gate) {
397
+ // execute·commit 모두 포인터 task 단위만 본다(G6–G8).
398
+ const taskUnit = (gate === 'execute' || gate === 'commit')
399
+ ? resolveTaskUnit(docs, { repoRoot, blueprintDir })
400
+ : undefined;
401
+ // parseErrors는 이미 failures에 합쳐졌지만, gate 문맥에는 문서별 파싱 실패를
402
+ // 구분할 수 있도록 원본 목록을 그대로 넘긴다.
403
+ checkGate(gate, docs, rels, failures, {
404
+ repoRoot, blueprintDir, deps, taskUnit, parseErrors, warnings,
405
+ });
406
+ }
407
+ // 기존 소비자는 warnings 부재를 허용한다 — 비어 있으면 키를 넣지 않는다.
408
+ if (warnings.length > 0) {
409
+ return withGateFailureHints({ ok: failures.length === 0, failures, warnings });
410
+ }
411
+ return withGateFailureHints({ ok: failures.length === 0, failures });
412
+ }
413
+ module.exports = {
414
+ loadBlueprintDocs, resolveTaskUnit, checkStructural, checkGate, validateBlueprint,
415
+ parseTasksSections, parseSections, extractPathCandidates,
416
+ GATE_FAILURE_HINTS, withGateFailureHints,
417
+ };
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verificationError = verificationError;
4
+ exports.errorCode = errorCode;
5
+ /**
6
+ * 기존 검증 오류 code를 유지하여 호출자가 실행 실패와 사전 거절을 구분하게 한다.
7
+ * @param {string} code - 기계 판독 오류 코드
8
+ * @param {string} message - 오류 설명
9
+ * @returns {CodedError} code를 보유한 오류 객체
10
+ */
11
+ function verificationError(code, message) {
12
+ const error = new Error(message);
13
+ error.code = code;
14
+ return error;
15
+ }
16
+ /**
17
+ * 알 수 없는 예외에서 code만 안전하게 읽는다.
18
+ * @param {unknown} error - 외부 의존성이 던진 값
19
+ * @returns {unknown} code가 없으면 undefined
20
+ */
21
+ function errorCode(error) {
22
+ if (typeof error === 'object' && error !== null && 'code' in error) {
23
+ return error.code;
24
+ }
25
+ return undefined;
26
+ }
@@ -0,0 +1,233 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { createHash } = require('node:crypto');
5
+ const canonicalJsonMod = require("./canonical-json");
6
+ const { canonicalJson } = canonicalJsonMod;
7
+ const guards = require("./guards");
8
+ const { isRecord } = guards;
9
+ const frontmatter = require("./frontmatter");
10
+ const { readDoc } = frontmatter;
11
+ const render = require("./render");
12
+ const { renderDoc } = render;
13
+ const paths = require("./paths");
14
+ const { toPosix } = paths;
15
+ const runtimeState = require("./runtime-state");
16
+ const { verifyLedgerPathFor } = runtimeState;
17
+ const tasksDocs = require("./tasks-docs");
18
+ const { listTasksDocs } = tasksDocs;
19
+ const identityModule = require("./verification-identity");
20
+ const { resolveDefaultTaskScope, computeEvidenceIdentity, computeSourceDigest } = identityModule;
21
+ const verification_contracts_1 = require("./verification-contracts");
22
+ /**
23
+ * v2 성공 원장만 reuse hit로 인정한다. v1·실패·필드 누락·identity/scope 불일치는 miss.
24
+ *
25
+ * @param {unknown} record - 디스크 원장
26
+ * @param {EvidenceIdentity} identity - 현재 입력 identity
27
+ * @param {string} evidenceId - 현재 evidence_id
28
+ * @returns {record is VerifyLedgerRecordV2} hit이면 true
29
+ */
30
+ function isReuseHit(record, identity, evidenceId) {
31
+ if (!isRecord(record))
32
+ return false;
33
+ // v1은 evidence_id가 없다. 읽을 수는 있어도 hit로 승격하지 않는다.
34
+ if (typeof record.evidence_id !== 'string' || !record.evidence_id)
35
+ return false;
36
+ if (record.evidence_id !== evidenceId)
37
+ return false;
38
+ if (record.exit_code !== 0)
39
+ return false;
40
+ if (typeof record.ran_at !== 'string' || !record.ran_at)
41
+ return false;
42
+ if (typeof record.output_sha !== 'string' || !record.output_sha)
43
+ return false;
44
+ if (typeof record.command !== 'string')
45
+ return false;
46
+ // reused는 성공 hit의 필수 boolean. 누락·문자열·숫자는 손상으로 miss.
47
+ if (typeof record.reused !== 'boolean')
48
+ return false;
49
+ if (!isRecord(record.identity) || !isRecord(record.scope))
50
+ return false;
51
+ if (canonicalJson(record.identity) !== canonicalJson(identity))
52
+ return false;
53
+ if (canonicalJson(record.scope) !== canonicalJson(identity.scope))
54
+ return false;
55
+ return true;
56
+ }
57
+ /**
58
+ * 원장 파일을 읽어 파싱한다. 부재·파손은 null(정상 miss).
59
+ *
60
+ * @param {string} ledgerFile - 절대 경로
61
+ * @returns {unknown | null}
62
+ */
63
+ function readLedgerFile(ledgerFile) {
64
+ if (!fs.existsSync(ledgerFile))
65
+ return null;
66
+ try {
67
+ const parsed = JSON.parse(fs.readFileSync(ledgerFile, 'utf8'));
68
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
69
+ return null;
70
+ return parsed;
71
+ }
72
+ catch (_error) {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * verification.md와 common-dir 원장에 한 번 실행(또는 reuse)의 증적을 쓴다.
78
+ * source_digest는 기록 시각의 checkout에서 다시 계산한다 — reuse hit여도
79
+ * 그 시점 값을 남겨 commit G23이 verify 이후 소스 변경을 거절할 수 있다.
80
+ *
81
+ * @param {object} opts - 기록 입력
82
+ * @param {string} opts.repoRoot - 저장소 루트
83
+ * @param {string} [opts.verificationRel] - verification.md 상대 경로
84
+ * @param {string} [opts.blueprintDir] - 구 호출 호환용 blueprint 상대 경로
85
+ * @param {string} opts.command - 실행한 검증 명령
86
+ * @param {string} opts.ranAt - 실행 시각(KST ISO)
87
+ * @param {number} opts.exitCode - 프로세스 종료 코드
88
+ * @param {string} opts.output - output_tail 원본
89
+ * @param {string} [opts.evidenceId] - 생략 시 현재 identity에서 계산
90
+ * @param {EvidenceIdentity} [opts.identity] - 생략 시 현재 checkout에서 계산
91
+ * @param {VerificationScope} [opts.scope] - 생략 시 포인터/경로에서 유도
92
+ * @param {boolean} [opts.reused] - reuse hit이면 true
93
+ * @param {string} [opts.reusedFrom] - hit의 원본 evidence_id
94
+ * @param {object} [opts.deps] - git/원장 경로 주입
95
+ * @returns {void}
96
+ */
97
+ function recordVerificationResult({ repoRoot, verificationRel, blueprintDir, command, ranAt, exitCode, output, evidenceId, identity, scope, reused = false, reusedFrom, deps, }) {
98
+ // verificationRel이 정식 인자. blueprintDir은 구 호출 호환(루트 verification.md).
99
+ const rel = verificationRel
100
+ || (blueprintDir ? `${toPosix(blueprintDir)}/verification.md` : null);
101
+ if (!rel) {
102
+ throw (0, verification_contracts_1.verificationError)('VERIFY_DOCUMENT_MISSING', 'verification document path missing');
103
+ }
104
+ // 직접 호출 테스트는 evidence를 생략한다. 그 경우 현재 checkout identity를
105
+ // 채워 v2 원장·G13 계약과 맞춘다 — runVerification 경로와 키 공간을 공유한다.
106
+ let resolvedScope = scope;
107
+ let resolvedIdentity = identity;
108
+ let resolvedEvidenceId = evidenceId;
109
+ if (!resolvedScope || !resolvedIdentity || !resolvedEvidenceId) {
110
+ const blueprintGuess = blueprintDir
111
+ || rel.replace(/\/tasks\/\d{3}\/verification\.md$/, '')
112
+ || rel.replace(/\/verification\.md$/, '');
113
+ resolvedScope = resolvedScope || resolveDefaultTaskScope(repoRoot, blueprintGuess, null, listTasksDocs({ repoRoot, blueprintDir: blueprintGuess }).entries);
114
+ const computed = computeEvidenceIdentity({
115
+ repoRoot,
116
+ command,
117
+ scope: resolvedScope,
118
+ deps,
119
+ });
120
+ resolvedIdentity = resolvedIdentity || computed.identity;
121
+ resolvedEvidenceId = resolvedEvidenceId || computed.evidenceId;
122
+ }
123
+ const verificationPath = path.join(repoRoot, rel);
124
+ let document;
125
+ try {
126
+ document = readDoc(verificationPath);
127
+ }
128
+ catch (error) {
129
+ if ((0, verification_contracts_1.errorCode)(error) === 'ENOENT') {
130
+ throw (0, verification_contracts_1.verificationError)('VERIFY_DOCUMENT_MISSING', `verification document missing: ${verificationPath}`);
131
+ }
132
+ throw error;
133
+ }
134
+ const data = document.data;
135
+ // 증적은 파싱 객체에 in-place로 붙인다. 빈 객체로 바꾸면 스칼라 YAML의
136
+ // TypeError가 사라져 실패 형태가 바뀐다.
137
+ const bouncer = (data.bouncer || {});
138
+ data.bouncer = bouncer;
139
+ bouncer.status = exitCode === 0 ? 'passed' : 'failed';
140
+ // identity와 별도로 둔다. dirty_digest는 .bouncer를 포함해 G13 재사용 키를
141
+ // 바꾸지 못하게 하고, 이 필드는 commit 신선도만 본다.
142
+ const sourceDigest = computeSourceDigest(repoRoot, deps);
143
+ const verificationMeta = {
144
+ command,
145
+ ran_at: ranAt,
146
+ exit_code: exitCode,
147
+ output_tail: output,
148
+ evidence_id: resolvedEvidenceId,
149
+ identity: resolvedIdentity,
150
+ scope: resolvedScope,
151
+ reused,
152
+ source_digest: sourceDigest,
153
+ };
154
+ // reused_from은 hit일 때만 기록한다. false인데 키가 있으면 G13이 손기록을 통과시킨다.
155
+ if (reused && reusedFrom) {
156
+ verificationMeta.reused_from = reusedFrom;
157
+ }
158
+ bouncer.verification = verificationMeta;
159
+ const evidence = exitCode === 0
160
+ ? ''
161
+ : `\n\`\`\`\n${output}\n\`\`\`\n`;
162
+ const body = `# Verification
163
+
164
+ ## Command
165
+ \`${command}\`
166
+
167
+ ## Evidence
168
+ Ran at: ${ranAt}
169
+ Exit code: ${exitCode}
170
+ ${evidence}`;
171
+ fs.writeFileSync(verificationPath, renderDoc(data, body));
172
+ // 게이트는 디스크에서 다시 읽은 output_tail을 해싱한다. js-yaml dump/load가
173
+ // 개행·후행 공백을 정규화해도, 기록 쪽이 같은 왕복을 거치면 해시가 갈라지지 않는다.
174
+ const reread = readDoc(verificationPath);
175
+ const rereadBouncer = reread.data.bouncer;
176
+ const rereadEvidence = rereadBouncer && rereadBouncer.verification
177
+ ? rereadBouncer.verification
178
+ : {};
179
+ const outputTail = typeof rereadEvidence.output_tail === 'string' ? rereadEvidence.output_tail : '';
180
+ const outputSha = createHash('sha256').update(outputTail, 'utf8').digest('hex');
181
+ const ledgerPaths = verifyLedgerPathFor({
182
+ repoRoot,
183
+ verificationRel: rel,
184
+ evidenceId: resolvedEvidenceId,
185
+ deps,
186
+ });
187
+ if (ledgerPaths.unavailable || !ledgerPaths.ledgerFile) {
188
+ // 원장 없이 문서만 남기면 에이전트 Write와 구분이 안 된다. Git을 못 쓰면
189
+ // verify 자체를 실패시켜 복구 경로(저장소에서 재실행)만 남긴다.
190
+ throw new Error(ledgerPaths.reason || 'Bouncer requires a Git repository for an active blueprint');
191
+ }
192
+ fs.mkdirSync(path.dirname(ledgerPaths.ledgerFile), { recursive: true });
193
+ const record = {
194
+ rel: toPosix(rel),
195
+ command,
196
+ ran_at: ranAt,
197
+ exit_code: exitCode,
198
+ output_sha: outputSha,
199
+ evidence_id: resolvedEvidenceId,
200
+ identity: resolvedIdentity,
201
+ scope: resolvedScope,
202
+ reused,
203
+ source_digest: sourceDigest,
204
+ };
205
+ if (reused && reusedFrom) {
206
+ record.reused_from = reusedFrom;
207
+ }
208
+ // reuse hit가 문서 output_tail을 다시 쓸 수 있게 성공 원장에만 본문을 남긴다.
209
+ if (exitCode === 0) {
210
+ record.output_tail = outputTail;
211
+ }
212
+ fs.writeFileSync(ledgerPaths.ledgerFile, `${JSON.stringify(record, null, 2)}\n`);
213
+ }
214
+ /**
215
+ * runner와 recorder의 경로 주입 비대칭을 유지하며 성공 증적만 반환한다.
216
+ * 출력 SHA가 맞지 않거나 원장이 없고 손상된 경우는 실행할 정상 miss다.
217
+ * @param {string} repoRoot - 저장소 루트
218
+ * @param {string} verificationRel - 증적 문서 상대 경로
219
+ * @param {EvidenceIdentity} identity - 현재 검증 identity
220
+ * @param {string} evidenceId - 현재 identity의 해시
221
+ * @returns {VerifyLedgerRecordV2 | null} 재사용 가능한 원장 또는 정상 miss
222
+ */
223
+ function findReusableEvidence(repoRoot, verificationRel, identity, evidenceId) {
224
+ const paths = verifyLedgerPathFor({ repoRoot, verificationRel, evidenceId });
225
+ if (paths.unavailable || !paths.ledgerFile)
226
+ return null;
227
+ const record = readLedgerFile(paths.ledgerFile);
228
+ if (!isReuseHit(record, identity, evidenceId))
229
+ return null;
230
+ const output = typeof record.output_tail === 'string' ? record.output_tail : '';
231
+ return createHash('sha256').update(output, 'utf8').digest('hex') === record.output_sha ? record : null;
232
+ }
233
+ module.exports = { recordVerificationResult, findReusableEvidence };