@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,610 @@
1
+ 'use strict';
2
+ const { spawnSync } = require('node:child_process');
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const paths = require("./paths");
6
+ const { toPosix } = paths;
7
+ const scope = require("./scope");
8
+ const { readCoordinatorLedger } = scope;
9
+ const gitOps = require("./git-ops");
10
+ const { resolveCheckoutBranch } = gitOps;
11
+ const finalizeChecks = require("./finalize-checks");
12
+ const { collectMustFixWarnings, isDeferredMustFix } = finalizeChecks;
13
+ const COMMIT_TYPE_RE = /^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?!?:/;
14
+ /**
15
+ * 기본 git 실행기. 주입 exec와 같은 `{ status, stdout }`만 돌려
16
+ * stderr 유무로 seam이 갈라지지 않게 한다.
17
+ *
18
+ * @param {string} cwd - git cwd
19
+ * @param {string[]} args - `git` 뒤 argv
20
+ * @returns {{ status: number, stdout: string }}
21
+ */
22
+ function defaultExec(cwd, args) {
23
+ const r = spawnSync('git', args, {
24
+ cwd,
25
+ encoding: 'utf8',
26
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
27
+ });
28
+ return {
29
+ status: typeof r.status === 'number' ? r.status : 1,
30
+ stdout: r.stdout || '',
31
+ };
32
+ }
33
+ function asRecord(value) {
34
+ return value && typeof value === 'object' && !Array.isArray(value)
35
+ ? value
36
+ : {};
37
+ }
38
+ /**
39
+ * 문자열 첫 글자만 대문자로 바꾼다. PR 제목의 MergeTarget·Type에 쓴다.
40
+ *
41
+ * @param {string} raw - 원문
42
+ * @returns {string}
43
+ */
44
+ function capitalizeFirst(raw) {
45
+ if (!raw)
46
+ return raw;
47
+ return raw.charAt(0).toUpperCase() + raw.slice(1);
48
+ }
49
+ /**
50
+ * instant를 KST 달력의 YYMMDD로 접는다. 제목 접두 날짜는 UTC가 아니라
51
+ * Asia/Seoul이어야 draft-pr 계약과 같다.
52
+ *
53
+ * @param {Date} now - 기준 시각
54
+ * @returns {string} 여섯 자리 YYMMDD
55
+ */
56
+ function yymmddKst(now) {
57
+ const parts = new Intl.DateTimeFormat('en-CA', {
58
+ timeZone: 'Asia/Seoul',
59
+ year: 'numeric',
60
+ month: '2-digit',
61
+ day: '2-digit',
62
+ }).formatToParts(now);
63
+ const get = (type) => {
64
+ const part = parts.find((p) => p.type === type);
65
+ return part ? part.value : '00';
66
+ };
67
+ return `${get('year').slice(-2)}${get('month')}${get('day')}`;
68
+ }
69
+ /**
70
+ * GitHub owner/repo 한 칸이 blob URL에 안전한 identity인지 본다.
71
+ * 빈 값·`.`·`..`·`/`·`\`·기타 경호 문자는 경로 탈출·호스트 혼동에 쓰이므로 거절한다.
72
+ *
73
+ * @param {string} segment - owner 또는 repo 후보
74
+ * @returns {boolean}
75
+ */
76
+ function isGithubIdentitySegment(segment) {
77
+ if (!segment || segment === '.' || segment === '..')
78
+ return false;
79
+ if (segment.includes('/') || segment.includes('\\'))
80
+ return false;
81
+ // GitHub login/repo에 허용되는 문자만. `%2f` 같은 인코딩 우회도 여기서 막는다.
82
+ return /^[A-Za-z0-9._-]+$/.test(segment);
83
+ }
84
+ /**
85
+ * GitHub remote URL에서 owner/repo만 뽑는다. GitLab·repo 없는 SSH·빈 문자열·
86
+ * `.`/`..`/비 identity 세그먼트는 null — links가 unsupported-host로 거절할 수
87
+ * 있게 URL을 추측하지 않는다.
88
+ *
89
+ * @param {string} url - `git remote get-url origin` 결과
90
+ * @returns {{ owner: string, repo: string } | null}
91
+ */
92
+ function parseGithubRemote(url) {
93
+ if (typeof url !== 'string' || !url.trim())
94
+ return null;
95
+ const raw = url.trim();
96
+ const patterns = [
97
+ /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i,
98
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i,
99
+ /^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i,
100
+ ];
101
+ for (const re of patterns) {
102
+ const m = re.exec(raw);
103
+ if (!m)
104
+ continue;
105
+ const owner = m[1];
106
+ const repo = m[2];
107
+ if (!isGithubIdentitySegment(owner) || !isGithubIdentitySegment(repo)) {
108
+ return null;
109
+ }
110
+ return { owner, repo };
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * conventional commit subject에서 type만 고른다. scope·breaking 표기는 버리고
116
+ * 제목 접두의 Feat/Fix 집합에 넣는다.
117
+ *
118
+ * @param {string} subject - 커밋 제목 한 줄
119
+ * @returns {string | null} 소문자 type 또는 매칭 실패 null
120
+ */
121
+ function commitTypeOf(subject) {
122
+ const m = COMMIT_TYPE_RE.exec(String(subject || '').trim());
123
+ return m ? m[1].toLowerCase() : null;
124
+ }
125
+ /**
126
+ * digest 커밋 제목에서 type을 첫 등장 순으로 모은다. 없으면 blueprint
127
+ * commit_type 하나로 폴백 — 제목 접두의 `[Type]`이 비지 않게 한다.
128
+ *
129
+ * @param {Array<{ subject?: string }>} commits - digest.commits
130
+ * @param {string} fallback - blueprint.commit_type
131
+ * @returns {string[]} 소문자 type 목록(중복 제거, 등장 순)
132
+ */
133
+ function collectCommitTypes(commits, fallback) {
134
+ const seen = new Set();
135
+ const ordered = [];
136
+ for (const entry of Array.isArray(commits) ? commits : []) {
137
+ const type = commitTypeOf(typeof entry.subject === 'string' ? entry.subject : '');
138
+ if (!type || seen.has(type))
139
+ continue;
140
+ seen.add(type);
141
+ ordered.push(type);
142
+ }
143
+ if (ordered.length === 0) {
144
+ const fb = typeof fallback === 'string' && fallback.trim()
145
+ ? fallback.trim().toLowerCase()
146
+ : 'feat';
147
+ return [fb];
148
+ }
149
+ return ordered;
150
+ }
151
+ /**
152
+ * task verification을 `command — passed|failed` 행으로 접는다.
153
+ * 같은 문자열은 첫 등장만 남긴다 — 동일 명령·동일 결과가 task마다 반복되면
154
+ * PR 확인 방법이 불필요하게 길어진다. 명령이 같아도 passed/failed가 다르면
155
+ * 문자열이 달라 두 줄 모두 남는다.
156
+ *
157
+ * @param {unknown[]} tasks - digest.tasks
158
+ * @returns {string[]}
159
+ */
160
+ function buildVerificationLines(tasks) {
161
+ const lines = [];
162
+ const seen = new Set();
163
+ for (const entry of Array.isArray(tasks) ? tasks : []) {
164
+ const task = asRecord(entry);
165
+ const verification = asRecord(task.verification);
166
+ if (!task.verification || typeof verification.command !== 'string' || !verification.command) {
167
+ continue;
168
+ }
169
+ const passed = verification.status === 'passed';
170
+ // 범위 밖으로 분류된 terminal 실패는 failed 그대로 두고 원본 증거 id만 덧붙인다 — 분류가
171
+ // 통과처럼 읽히지 않게 하고, 로컬 경로 대신 id로만 참조한다.
172
+ const outside = asRecord(verification.outside_scope);
173
+ const suffix = !passed && typeof outside.evidence_id === 'string' && outside.evidence_id
174
+ ? ` (outside_scope, evidence ${outside.evidence_id})` : '';
175
+ const line = `${verification.command} — ${passed ? 'passed' : 'failed'}${suffix}`;
176
+ if (seen.has(line))
177
+ continue;
178
+ seen.add(line);
179
+ lines.push(line);
180
+ }
181
+ return lines;
182
+ }
183
+ /**
184
+ * accepted/deferred finding만 리뷰 포인트로 접는다.
185
+ * task.review와 blueprint_review가 같은 규칙을 쓰게 한 헬퍼 —
186
+ * 한쪽만 note 폴백을 바꾸면 PR 본문이 갈라진다.
187
+ * `seen`에 이미 있는 문자열은 건너뛴다 — finding 메모끼리만 첫 등장을 남기기 위함.
188
+ * `exclude`가 참인 finding은 다른 절이 이미 싣는 항목이라 건너뛰고 `seen`에도
189
+ * 넣지 않는다 — 같은 메모를 가진 다른 finding이 그 때문에 사라지지 않게 한다.
190
+ *
191
+ * @param {string[]} points - 누적 배열
192
+ * @param {Set<string>} seen - finding 메모 중복 집합(out_of_scope와 공유하지 않음)
193
+ * @param {unknown} rawFindings - findings 원본
194
+ * @param {(raw: unknown) => boolean} [exclude] - 이 절에서 뺄 finding 판정. 없으면 모두 대상
195
+ * @returns {void}
196
+ */
197
+ function pushFindingPoints(points, seen, rawFindings, exclude) {
198
+ const findings = Array.isArray(rawFindings) ? rawFindings : [];
199
+ for (const raw of findings) {
200
+ const finding = asRecord(raw);
201
+ if (finding.status !== 'accepted' && finding.status !== 'deferred')
202
+ continue;
203
+ if (exclude && exclude(raw))
204
+ continue;
205
+ let text = null;
206
+ if (typeof finding.note === 'string' && finding.note) {
207
+ text = finding.note;
208
+ }
209
+ else if (typeof finding.id === 'string' && finding.id) {
210
+ // F3 accepted: note 없을 때 finding.id 폴백은 digest 진단값이라 유지한다.
211
+ text = finding.id;
212
+ }
213
+ if (!text || seen.has(text))
214
+ continue;
215
+ seen.add(text);
216
+ points.push(text);
217
+ }
218
+ }
219
+ /**
220
+ * out_of_scope·accepted/deferred finding note·unverified를 리뷰 포인트 문자열로
221
+ * 모은다. Epic/Quiz/원문 로그는 넣지 않는다 — Constraints의 PR 본문 금지 목록.
222
+ * unverified.task는 digest stable_id(EPIC-…/BP-…/TASK-…)라 본문에 넣지 않는다.
223
+ * blueprint_review.findings는 루트 리뷰 경로이며 task.review와 같은 규칙으로
224
+ * accepted/deferred note를 싣는다.
225
+ * finding 메모는 서로만 첫 등장 순서로 한 번 — out_of_scope와 같은 문자열이어도
226
+ * 둘 다 남긴다. digest가 같은 finding을 unverified `finding-*`로도 내므로 그 행은
227
+ * 재게시하지 않는다(path-outside-scope 등 다른 kind는 유지).
228
+ * blueprint 루트의 deferred must_fix는 `unresolved_must_fix` 절이 싣으므로 여기서
229
+ * 뺀다. task 범위 finding은 같은 id·메모여도 경고 대상이 아니라 그대로 남긴다.
230
+ *
231
+ * @param {object} digest
232
+ * @param {string[]} digest.out_of_scope
233
+ * @param {unknown[]} digest.tasks
234
+ * @param {unknown[]} digest.unverified
235
+ * @param {{ findings?: unknown } | null} [digest.blueprint_review]
236
+ * @returns {string[]}
237
+ */
238
+ function buildReviewPoints(digest) {
239
+ const points = [];
240
+ const outOfScope = Array.isArray(digest.out_of_scope) ? digest.out_of_scope : [];
241
+ for (const item of outOfScope) {
242
+ if (typeof item === 'string' && item)
243
+ points.push(item);
244
+ }
245
+ // finding 메모 중복 집합은 out_of_scope와 분리 — 같은 문자열도 출처가 다르면 둘 다.
246
+ const seenFindings = new Set();
247
+ const tasks = Array.isArray(digest.tasks) ? digest.tasks : [];
248
+ for (const entry of tasks) {
249
+ const review = asRecord(asRecord(entry).review);
250
+ pushFindingPoints(points, seenFindings, review.findings);
251
+ }
252
+ pushFindingPoints(points, seenFindings, asRecord(digest.blueprint_review).findings, isDeferredMustFix);
253
+ // worker가 보고한 사양 해석·약점은 finding 뒤, unverified 앞에 둔다. 값은 digest
254
+ // task 항목(worker_notes)으로만 들어온다 — 원장을 직접 읽지 않는다.
255
+ for (const entry of tasks) {
256
+ const task = asRecord(entry);
257
+ const notes = asRecord(task.worker_notes);
258
+ const id = typeof task.id === 'string' ? task.id : '';
259
+ for (const item of Array.isArray(notes.open_choices) ? notes.open_choices : []) {
260
+ if (typeof item === 'string' && item)
261
+ points.push(`구현 선택(${id}): ${item}`);
262
+ }
263
+ for (const item of Array.isArray(notes.known_weaknesses) ? notes.known_weaknesses : []) {
264
+ if (typeof item === 'string' && item)
265
+ points.push(`알려진 약점(${id}): ${item}`);
266
+ }
267
+ }
268
+ const unverified = Array.isArray(digest.unverified) ? digest.unverified : [];
269
+ for (const raw of unverified) {
270
+ const row = asRecord(raw);
271
+ const kind = typeof row.kind === 'string' ? row.kind : '';
272
+ if (!kind)
273
+ continue;
274
+ // review.findings 경로와 같은 메모를 다시 쓰지 않는다 — digest unverified 보존은 그대로.
275
+ if (kind === 'finding-accepted' || kind === 'finding-deferred')
276
+ continue;
277
+ if (typeof row.detail === 'string' && row.detail) {
278
+ points.push(`${kind}: ${row.detail}`);
279
+ }
280
+ else if (typeof row.path === 'string' && row.path) {
281
+ points.push(`${kind}: ${row.path}`);
282
+ }
283
+ else {
284
+ // task stable_id는 고의로 생략 — EPIC-/BP- 형태가 review_points로 새지 않게.
285
+ points.push(kind);
286
+ }
287
+ }
288
+ return points;
289
+ }
290
+ /**
291
+ * finalize digest에서 PR 제목 접두·base/head·결정적 본문 절을 만든다.
292
+ * background/changes/flow는 Explain 작성 전이므로 null로 두고, related는
293
+ * push 뒤 links가 채우도록 빈 배열로 둔다. git.pr_base가 비어 있으면 base와
294
+ * title_prefix를 null로 두고 template만 채운다 — `main`으로 닫으면 origin/HEAD가
295
+ * 없는 저장소에서 잘못된 draft PR이 열린다.
296
+ *
297
+ * @param {object} digest - prepareFinalizeDigest 성공 payload(또는 동형 fixture)
298
+ * @param {object} opts
299
+ * @param {Date} opts.now - 제목 날짜(KST). 테스트가 고정한다
300
+ * @param {unknown} opts.config - `.bouncer/config.json`. `pr.draft`만 읽는다
301
+ * @returns {PrDraft}
302
+ */
303
+ function buildPrDraft(digest, opts) {
304
+ const d = asRecord(digest);
305
+ const git = asRecord(d.git);
306
+ const blueprint = asRecord(d.blueprint);
307
+ const config = asRecord(opts.config);
308
+ const prCfg = asRecord(config.pr);
309
+ const prBase = typeof git.pr_base === 'string' && git.pr_base.trim()
310
+ ? git.pr_base.trim()
311
+ : null;
312
+ const head = typeof git.branch === 'string' ? git.branch : null;
313
+ const commitType = typeof blueprint.commit_type === 'string' ? blueprint.commit_type : 'feat';
314
+ const commits = Array.isArray(d.commits) ? d.commits : [];
315
+ const types = collectCommitTypes(commits, commitType)
316
+ .map((t) => capitalizeFirst(t))
317
+ .join('/');
318
+ const titlePrefixTemplate = `[${yymmddKst(opts.now)}] (→ {base}) [${types}]`;
319
+ // draft 기본 true — 키가 없거나 비불리언이면 skill이 실수로 공개 PR을 만들지 않게.
320
+ const draft = typeof prCfg.draft === 'boolean' ? prCfg.draft : true;
321
+ return {
322
+ title_prefix: prBase
323
+ ? titlePrefixTemplate.replace('{base}', capitalizeFirst(prBase))
324
+ : null,
325
+ title_prefix_template: titlePrefixTemplate,
326
+ base: prBase,
327
+ head,
328
+ draft,
329
+ sections: {
330
+ related: [],
331
+ background: null,
332
+ changes: null,
333
+ flow: null,
334
+ review_points: buildReviewPoints({
335
+ out_of_scope: d.out_of_scope,
336
+ tasks: d.tasks,
337
+ unverified: d.unverified,
338
+ // 루트 리뷰 finding은 task.review에 없어서 같이 넘기지 않으면 PR에서 빠진다.
339
+ blueprint_review: d.blueprint_review,
340
+ }),
341
+ verification: buildVerificationLines(Array.isArray(d.tasks) ? d.tasks : []),
342
+ // digest warnings와 같은 함수로 만든다 — PR이 별도 입력 필드에 기대지 않고
343
+ // blueprint_review(blueprint 모드에서만 non-null)에서 같은 id/note를 얻는다.
344
+ unresolved_must_fix: collectMustFixWarnings(asRecord(d.blueprint_review).findings)
345
+ .map((w) => (w.note ? `${w.id}: ${w.note}` : w.id)),
346
+ },
347
+ };
348
+ }
349
+ const SUMMARY_BACKGROUND = '배경 · 변경 의도';
350
+ const SUMMARY_CHANGES = '주요 변경 내용';
351
+ const DRIVE_ISSUE_KEYS = [
352
+ 'rework_reports', 'debugger_calls', 'repair_waves',
353
+ 'fanin_verification_failures', 'blocked', 'deferred_must_fix',
354
+ ];
355
+ /**
356
+ * 한국어 요약 Markdown에서 `## <heading>` 절의 본문을 꺼낸다. 다음 `## ` 헤딩 전까지가 본문이며
357
+ * 앞뒤 공백은 잘라 결정적 출력을 보장한다. 헤딩이 없거나 본문이 비면 null이다.
358
+ *
359
+ * @param {string} summary - 요약 파일 전문
360
+ * @param {string} heading - `## ` 뒤의 정확한 헤딩 문자열
361
+ * @returns {string | null}
362
+ */
363
+ function extractSummarySection(summary, heading) {
364
+ const lines = summary.replace(/\r\n/g, '\n').split('\n');
365
+ const start = lines.findIndex((line) => line.trimEnd() === `## ${heading}`);
366
+ if (start < 0)
367
+ return null;
368
+ const rest = lines.slice(start + 1);
369
+ const end = rest.findIndex((line) => line.startsWith('## '));
370
+ const body = (end < 0 ? rest : rest.slice(0, end)).join('\n').trim();
371
+ return body || null;
372
+ }
373
+ /**
374
+ * renderPrBody가 읽는 v2 digest의 최소 shape를 검사한다. 보존 digest는 파일에서 오므로
375
+ * 손상된 JSON이 빈 초안으로 조용히 통과하지 않게 range와 task verification만 확인한다.
376
+ *
377
+ * @param {Record<string, unknown>} digest - 파싱된 digest
378
+ * @returns {void} 위반하면 Error를 던진다
379
+ */
380
+ function assertPrBodyDigest(digest) {
381
+ if (digest.ok !== true || digest.version !== 2) {
382
+ throw new Error('digest must be a prepare digest with ok: true and version: 2');
383
+ }
384
+ const range = asRecord(digest.range);
385
+ if (typeof range.base !== 'string' || !range.base || typeof range.head !== 'string' || !range.head) {
386
+ throw new Error('digest.range.base and digest.range.head are required');
387
+ }
388
+ if (!Array.isArray(digest.tasks))
389
+ throw new Error('digest.tasks must be an array');
390
+ for (const entry of digest.tasks) {
391
+ const task = asRecord(entry);
392
+ if (task.verification === null || task.verification === undefined)
393
+ continue;
394
+ const v = asRecord(task.verification);
395
+ const commandOk = v.command === null || v.command === undefined || typeof v.command === 'string';
396
+ if (typeof v.status !== 'string' || !commandOk) {
397
+ throw new Error('digest task verification needs a string status and a string or null command');
398
+ }
399
+ }
400
+ }
401
+ /**
402
+ * 보존한 finalize digest와 한국어 요약으로 PR 본문 Markdown 초안을 만든다. 순수 함수 —
403
+ * 파일·Git·원장·현재 task를 읽지 않으므로 정리 뒤에도 같은 입력이 같은 바이트를 낸다.
404
+ * 절 순서는 관련 이슈, 배경, 변경, 로직 흐름, 리뷰 포인트, 확인 방법이며 근거 없는 절은 생략한다.
405
+ * 실패한 검증은 `failed`로, 미해결 must_fix·실행 이슈는 리뷰 포인트에 그대로 남긴다.
406
+ *
407
+ * @param {object} input
408
+ * @param {unknown} input.digest - 파싱된 v2 digest
409
+ * @param {string} input.summary - `## 배경 · 변경 의도`, `## 주요 변경 내용` 절을 가진 요약 Markdown
410
+ * @param {string} [input.flow] - Mermaid 소스. 비었거나 없으면 로직 흐름 절을 생략한다
411
+ * @returns {string} 끝에 개행 하나를 가진 Markdown
412
+ * @throws 입력 shape 위반 또는 요약 절 누락 시 Error
413
+ */
414
+ function renderPrBody(input) {
415
+ const d = asRecord(input.digest);
416
+ assertPrBodyDigest(d);
417
+ const background = extractSummarySection(String(input.summary ?? ''), SUMMARY_BACKGROUND);
418
+ const changes = extractSummarySection(String(input.summary ?? ''), SUMMARY_CHANGES);
419
+ if (!background)
420
+ throw new Error(`summary needs a non-empty "## ${SUMMARY_BACKGROUND}" section`);
421
+ if (!changes)
422
+ throw new Error(`summary needs a non-empty "## ${SUMMARY_CHANGES}" section`);
423
+ const blocks = [];
424
+ const bullets = (items) => items.map((item) => `- ${item}`).join('\n');
425
+ // 링크는 digest가 가진 것만 쓴다 — 경로에서 URL을 만들지 않는다.
426
+ const related = (Array.isArray(asRecord(asRecord(d.pr).sections).related)
427
+ ? asRecord(asRecord(d.pr).sections).related : [])
428
+ .filter((item) => typeof item === 'string' && item !== '');
429
+ if (related.length)
430
+ blocks.push(`## 관련 이슈\n\n${bullets(related)}`);
431
+ blocks.push(`## ${SUMMARY_BACKGROUND}\n\n${background}`);
432
+ blocks.push(`## ${SUMMARY_CHANGES}\n\n${changes}`);
433
+ const flow = typeof input.flow === 'string' ? input.flow.replace(/\r\n/g, '\n').trim() : '';
434
+ if (flow)
435
+ blocks.push(`## 로직 흐름\n\n\`\`\`mermaid\n${flow}\n\`\`\``);
436
+ // 리뷰 포인트: 저장된 warnings와 blueprint_review 재계산을 합쳐 deferred must_fix를 빠뜨리지 않고
437
+ // (같은 문자열은 한 번), 나머지는 buildReviewPoints가 이미 must_fix 중복을 뺀 목록을 쓴다.
438
+ const mustFix = [];
439
+ const pushMustFix = (id, note) => {
440
+ if (typeof id !== 'string' || !id)
441
+ return;
442
+ const text = typeof note === 'string' && note ? `${id}: ${note}` : id;
443
+ if (!mustFix.includes(text))
444
+ mustFix.push(text);
445
+ };
446
+ for (const w of Array.isArray(d.warnings) ? d.warnings : []) {
447
+ pushMustFix(asRecord(w).id, asRecord(w).note);
448
+ }
449
+ for (const w of collectMustFixWarnings(asRecord(d.blueprint_review).findings))
450
+ pushMustFix(w.id, w.note);
451
+ const points = [
452
+ ...mustFix.map((text) => `미해결 must_fix: ${text}`),
453
+ ...buildReviewPoints({
454
+ out_of_scope: d.out_of_scope,
455
+ tasks: d.tasks,
456
+ unverified: d.unverified,
457
+ blueprint_review: d.blueprint_review,
458
+ }),
459
+ ];
460
+ const issues = asRecord(d.drive_issues);
461
+ const issueParts = DRIVE_ISSUE_KEYS
462
+ .filter((key) => typeof issues[key] === 'number' && issues[key] > 0)
463
+ .map((key) => `${key} ${issues[key]}`);
464
+ if (issueParts.length)
465
+ points.push(`실행 이슈: ${issueParts.join(', ')}`);
466
+ if (points.length)
467
+ blocks.push(`## 리뷰 포인트\n\n${bullets(points)}`);
468
+ const verification = buildVerificationLines(d.tasks);
469
+ if (verification.length)
470
+ blocks.push(`## 확인 방법\n\n${bullets(verification)}`);
471
+ return `${blocks.join('\n\n')}\n`;
472
+ }
473
+ /**
474
+ * links가 쓸 branch를 고른다. 원장 integrationBranch가 있으면 그것이 정본이고,
475
+ * 없으면 호출 checkout의 실제 branch다(brief: resolveCheckoutBranch(repoRoot)).
476
+ *
477
+ * @param {string} repoRoot - 호출 checkout
478
+ * @param {string} blueprintDir - blueprint 상대 경로
479
+ * @returns {{ branch: string | null, checkoutRoot: string }}
480
+ */
481
+ function resolveLinksBranch(repoRoot, blueprintDir) {
482
+ const bp = toPosix(blueprintDir);
483
+ const ledgerRead = readCoordinatorLedger({ repoRoot, blueprint: bp });
484
+ // drive면 integration worktree의 HEAD·remote tracking이 finalize checkout이다.
485
+ const checkoutRoot = ledgerRead.ok
486
+ && typeof ledgerRead.integrationPath === 'string'
487
+ && ledgerRead.integrationPath
488
+ && fs.existsSync(ledgerRead.integrationPath)
489
+ ? ledgerRead.integrationPath
490
+ : repoRoot;
491
+ if (ledgerRead.ok && ledgerRead.ledger) {
492
+ const ledger = asRecord(ledgerRead.ledger);
493
+ if (typeof ledger.integrationBranch === 'string' && ledger.integrationBranch.trim()) {
494
+ return { branch: ledger.integrationBranch.trim(), checkoutRoot };
495
+ }
496
+ }
497
+ return { branch: resolveCheckoutBranch(repoRoot), checkoutRoot };
498
+ }
499
+ /**
500
+ * links용 blueprintDir이 저장소 상대이며 `..`로 탈출하지 않는지 본다.
501
+ * absolute/`..`를 blob URL에 넣기 전에 막아 404·경로 혼동 URL을 만들지 않는다.
502
+ *
503
+ * @param {unknown} blueprintDir - 호출자 `--blueprint` 값
504
+ * @returns {boolean} true면 거절 대상
505
+ */
506
+ function blueprintDirEscapesRepo(blueprintDir) {
507
+ if (typeof blueprintDir !== 'string' || !blueprintDir.trim())
508
+ return true;
509
+ const raw = blueprintDir.trim();
510
+ const posix = toPosix(raw);
511
+ // path.isAbsolute는 플랫폼만 본다. POSIX 절대·드라이브 문자도 저장소 밖으로 취급.
512
+ if (path.isAbsolute(raw) || path.posix.isAbsolute(posix) || /^[A-Za-z]:\//.test(posix)) {
513
+ return true;
514
+ }
515
+ if (posix.split('/').includes('..'))
516
+ return true;
517
+ return false;
518
+ }
519
+ /**
520
+ * GitHub blob URL을 만든다. owner/repo/ref/파일 경로의 각 세그먼트를
521
+ * percent-encode해 `#`·공백 등이 fragment·쿼리로 잘리지 않게 한다.
522
+ *
523
+ * @param {string} owner - GitHub owner
524
+ * @param {string} repo - GitHub repo
525
+ * @param {string} ref - branch 이름 또는 commit SHA
526
+ * @param {string} fileRel - repo-relative posix 경로
527
+ * @returns {string}
528
+ */
529
+ function githubBlobUrl(owner, repo, ref, fileRel) {
530
+ const encodeSegs = (value) => value
531
+ .split('/')
532
+ .filter((seg) => seg.length > 0)
533
+ .map((seg) => encodeURIComponent(seg))
534
+ .join('/');
535
+ return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/blob/${encodeSegs(ref)}/${encodeSegs(fileRel)}`;
536
+ }
537
+ /**
538
+ * push된 GitHub head에서만 Explain URL 후보를 반환한다.
539
+ * 네트워크(ls-remote/fetch)를 쓰지 않고 로컬 remote URL·tracking ref만 본다.
540
+ * 실패해도 ok:true — skill이 reason만으로 링크를 생략한다.
541
+ *
542
+ * @param {object} opts
543
+ * @param {string} opts.repoRoot - 호출 checkout(보통 main)
544
+ * @param {string} opts.blueprintDir - blueprint 상대 경로
545
+ * @param {(args: string[]) => { status: number, stdout: string }} [opts.exec] - git seam
546
+ * @returns {ExplainLinksResult}
547
+ */
548
+ function resolveExplainLinks({ repoRoot, blueprintDir, exec, }) {
549
+ const empty = (branch, reason) => ({
550
+ ok: true,
551
+ branch,
552
+ links: [],
553
+ reason,
554
+ });
555
+ // 0. blueprintDir 탈출 거절 — explainRel을 만들기 전에 막아 URL에 `..`가 안 실리게.
556
+ if (blueprintDirEscapesRepo(blueprintDir)) {
557
+ return empty(null, 'invalid-blueprint');
558
+ }
559
+ // 1. branch — task 문서 유무와 무관. 원장 또는 checkout.
560
+ const { branch, checkoutRoot } = resolveLinksBranch(repoRoot, blueprintDir);
561
+ if (!branch)
562
+ return empty(null, 'branch-unresolved');
563
+ const run = typeof exec === 'function'
564
+ ? exec
565
+ : (args) => defaultExec(checkoutRoot, args);
566
+ // 2. origin URL — 없으면 추측하지 않는다.
567
+ const remote = run(['remote', 'get-url', 'origin']);
568
+ if (remote.status !== 0 || !remote.stdout.trim()) {
569
+ return empty(branch, 'no-remote');
570
+ }
571
+ // 3. GitHub만. 그 외 host는 URL을 만들지 않는다.
572
+ const parsed = parseGithubRemote(remote.stdout.trim());
573
+ if (!parsed)
574
+ return empty(branch, 'unsupported-host');
575
+ // 4. tracking ref가 있고 HEAD가 그 ref의 조상(또는 같음)이어야 push된 것으로 본다.
576
+ const remoteRef = `refs/remotes/origin/${branch}`;
577
+ const verified = run(['rev-parse', '--verify', remoteRef]);
578
+ if (verified.status !== 0 || !verified.stdout.trim()) {
579
+ return empty(branch, 'head-not-pushed');
580
+ }
581
+ const ancestor = run(['merge-base', '--is-ancestor', 'HEAD', remoteRef]);
582
+ if (ancestor.status !== 0) {
583
+ return empty(branch, 'head-not-pushed');
584
+ }
585
+ // 5. HEAD 트리에 explain.md가 추적돼 있어야 404 링크를 만들지 않는다.
586
+ const explainRel = toPosix(path.posix.join(toPosix(blueprintDir), 'explain.md'));
587
+ // join 후에도 `..`가 남으면(정규화 전 입력 우회) URL을 만들지 않는다.
588
+ if (explainRel.split('/').includes('..') || path.posix.isAbsolute(explainRel)) {
589
+ return empty(branch, 'invalid-blueprint');
590
+ }
591
+ const tracked = run(['cat-file', '-e', `HEAD:${explainRel}`]);
592
+ if (tracked.status !== 0) {
593
+ return empty(branch, 'explain-missing');
594
+ }
595
+ const headResult = run(['rev-parse', 'HEAD']);
596
+ if (headResult.status !== 0 || !headResult.stdout.trim()) {
597
+ return empty(branch, 'head-not-pushed');
598
+ }
599
+ const headSha = headResult.stdout.trim();
600
+ return {
601
+ ok: true,
602
+ branch,
603
+ links: [
604
+ { kind: 'branch', url: githubBlobUrl(parsed.owner, parsed.repo, branch, explainRel) },
605
+ { kind: 'commit', url: githubBlobUrl(parsed.owner, parsed.repo, headSha, explainRel) },
606
+ ],
607
+ reason: null,
608
+ };
609
+ }
610
+ module.exports = { buildPrDraft, renderPrBody, resolveExplainLinks, parseGithubRemote };