@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,927 @@
1
+ 'use strict';
2
+ const guards = require("./guards");
3
+ const { isSkippableFsError } = guards;
4
+ const fs = require("node:fs");
5
+ const path = require('node:path');
6
+ const { createHash } = require('node:crypto');
7
+ const { spawnSync } = require('node:child_process');
8
+ const symbolIndex = require("./symbol-index");
9
+ const { resolveSymbol } = symbolIndex;
10
+ const frontmatter = require("./frontmatter");
11
+ const { parseFrontmatter } = frontmatter;
12
+ const commitSha = require("./commit-sha");
13
+ const { normalizeCommitSha, buildStableProvenance } = commitSha;
14
+ const validateSections = require("./validate-sections");
15
+ const { parseExplainSections } = validateSections;
16
+ const contextIds = require("./context-ids");
17
+ const { parseStableId, parsePointerKey, parseContextPath, formatStableIntentId, formatStableTaskId, normalizeStableId, } = contextIds;
18
+ const epicRegistryMod = require("./epic-registry");
19
+ const { readEpicRegistry } = epicRegistryMod;
20
+ const paths = require("./paths");
21
+ const { toPosix } = paths;
22
+ const DEFAULT_LIMIT = 3;
23
+ const MAX_LIMIT = 5;
24
+ const BODY_BUDGET_BYTES = 2000;
25
+ const INTENT_ANCHOR_RE = /^task-(\d{3})$/;
26
+ const TASK_DIR_RE = /^(\d{3})$/;
27
+ const TRAILER_RE = /^(Bouncer-Task|Bouncer-Intent)\s*:\s*(.*?)\s*$/;
28
+ // loadExplainDocs가 걷는 경로와 같은 canonical Explain만 projection 입력이 된다.
29
+ // 번호(`NNN-slug`)와 slug 두 경로 형식을 모두 받되 세그먼트 해석은 parseContextPath에 맡긴다.
30
+ const EXPLAIN_SHAPE_RE = /^\.bouncer\/context\/epics\/[^/]+\/blueprints\/[^/]+\/explain\.md$/;
31
+ /** loadExplainDocs가 걷는 canonical Explain 경로(번호·slug 두 형식)인지 판정한다. */
32
+ function isCanonicalExplainPath(rel) {
33
+ if (!EXPLAIN_SHAPE_RE.test(rel))
34
+ return false;
35
+ const ids = parseContextPath(rel);
36
+ return ids !== null && ids.blueprintKey !== null;
37
+ }
38
+ function readRegistryOrNull(repoRoot) {
39
+ const read = readEpicRegistry(repoRoot);
40
+ return read.status === 'ok' ? read.registry : null;
41
+ }
42
+ const SKIP_DIR_NAMES = new Set(['.git', 'node_modules', '.worktrees', 'graphify-out']);
43
+ const FRESHNESS_RANK = {
44
+ current: 0,
45
+ related: 1,
46
+ 'possibly-superseded': 2,
47
+ historical: 3,
48
+ };
49
+ /**
50
+ * 현재 checkout의 함수 정의에 Git blame/log와 Explain을 연결한다.
51
+ * trailer와 Explain 본문은 데이터일 뿐이라서 limit·status·workflow를 바꾸지 않는다.
52
+ * Git이 없거나 명령이 실패하면 unlinked로 접지 않고 던진다.
53
+ *
54
+ * @param {object} input - 조회 입력
55
+ * @param {string} input.repoRoot - 저장소 루트. 이 경계 밖 Explain은 증거가 되지 않는다
56
+ * @param {string} input.symbol - 함수명. TASKS-001과 같은 식별자
57
+ * @param {string} [input.candidateRef] - TASKS-001이 발급한 opaque ref
58
+ * @param {number} [input.limit] - 반환 candidate 수. 생략 시 3, 허용 1..5
59
+ * @returns {IntentResult} resolved / unlinked / ambiguous / unresolved
60
+ */
61
+ function resolveIntentProvenance(input) {
62
+ // 1. limit는 조회 전에 거절한다. 잘못된 예산을 들고 Git을 치지 않는다.
63
+ const limit = requireLimit(input.limit);
64
+ const symbol = typeof input.symbol === 'string' ? input.symbol : '';
65
+ const selected = resolveSymbol({
66
+ repoRoot: input.repoRoot,
67
+ symbol: input.symbol,
68
+ candidateRef: input.candidateRef,
69
+ });
70
+ // 2. 정의가 없거나 동명이면 provenance를 만들지 않는다. TASKS-001 상태를 보존한다.
71
+ if (selected.status !== 'resolved') {
72
+ return {
73
+ status: selected.status,
74
+ symbol,
75
+ candidates: selected.candidates,
76
+ truncated: false,
77
+ };
78
+ }
79
+ const repoReal = fs.realpathSync(input.repoRoot);
80
+ requireGitRepo(repoReal);
81
+ const symbolRef = selected.symbol_ref;
82
+ const tracked = runGit(repoReal, ['ls-files', '--', symbolRef.path]);
83
+ if (tracked.status !== 0) {
84
+ throw gitFailure('git ls-files failed', tracked);
85
+ }
86
+ // 추적되지 않은 파일은 이력이 없는 정상 비연결이다. 명령 실패로 취급하지 않는다.
87
+ if (tracked.stdout.trim().length === 0) {
88
+ return unlinkedResult(symbol, symbolRef);
89
+ }
90
+ const metas = collectCommitMetas(repoReal, symbolRef, symbol);
91
+ const registry = readRegistryOrNull(repoReal);
92
+ const explains = loadExplainDocs(repoReal);
93
+ const shaIndex = buildShaIndex(repoReal, explains);
94
+ const linked = [];
95
+ for (const meta of metas) {
96
+ const hit = linkCommit(repoReal, meta.sha, explains, shaIndex, registry);
97
+ if (!hit)
98
+ continue;
99
+ const doc = explains.find((item) => item.rel === hit.explain);
100
+ const taskDigits = taskDigitsOf(hit.task);
101
+ linked.push({
102
+ ...meta,
103
+ task: hit.task,
104
+ explain: hit.explain,
105
+ sections: doc ? selectSections(doc, taskDigits) : [],
106
+ });
107
+ }
108
+ if (linked.length === 0) {
109
+ return unlinkedResult(symbol, symbolRef);
110
+ }
111
+ const currentSha = pickCurrentSha(metas);
112
+ const drafts = linked.map((item) => {
113
+ const freshness = freshnessOf(item, linked, currentSha);
114
+ return {
115
+ relation: item.relation,
116
+ commit: item.sha,
117
+ task: item.task,
118
+ explain: item.explain,
119
+ freshness,
120
+ sections: freshness === 'historical' ? [] : item.sections.map((part) => part.name),
121
+ body: freshness === 'historical' ? '' : serializeSections(item.sections),
122
+ ct: item.ct,
123
+ };
124
+ });
125
+ drafts.sort((left, right) => {
126
+ const rank = FRESHNESS_RANK[left.freshness] - FRESHNESS_RANK[right.freshness];
127
+ if (rank !== 0)
128
+ return rank;
129
+ if (right.ct !== left.ct)
130
+ return right.ct - left.ct;
131
+ if (left.commit !== right.commit)
132
+ return left.commit < right.commit ? -1 : 1;
133
+ return left.explain < right.explain ? -1 : 1;
134
+ });
135
+ const sliced = drafts.slice(0, limit);
136
+ const truncated = applyBodyBudget(sliced);
137
+ const candidates = sliced.map((item) => ({
138
+ relation: item.relation,
139
+ commit: item.commit,
140
+ task: item.task,
141
+ explain: item.explain,
142
+ freshness: item.freshness,
143
+ sections: item.sections,
144
+ body: item.body,
145
+ }));
146
+ return {
147
+ status: 'resolved',
148
+ symbol,
149
+ symbol_ref: symbolRef,
150
+ candidates,
151
+ truncated,
152
+ };
153
+ }
154
+ /**
155
+ * limit를 1..5 정수로만 받는다. 문자열 숫자나 기본값 보정은 하지 않는다 —
156
+ * CLI가 이미 거절해야 할 값을 resolver가 조용히 3으로 바꾸면 계약이 갈라진다.
157
+ *
158
+ * @param {unknown} limit - 호출자가 넘긴 상한
159
+ * @returns {number} 사용할 candidate 수
160
+ */
161
+ function requireLimit(limit) {
162
+ if (limit === undefined || limit === null)
163
+ return DEFAULT_LIMIT;
164
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
165
+ throw new Error('limit must be an integer between 1 and 5');
166
+ }
167
+ return limit;
168
+ }
169
+ function unlinkedResult(symbol, symbolRef) {
170
+ return {
171
+ status: 'unlinked',
172
+ symbol,
173
+ symbol_ref: symbolRef,
174
+ candidates: [],
175
+ truncated: false,
176
+ };
177
+ }
178
+ /**
179
+ * cwd를 저장소 루트로 두고 argv 배열만 넘긴다. 셸 문자열을 만들지 않아
180
+ * path·SHA가 플래그로 해석되거나 보간되지 않게 한다.
181
+ *
182
+ * @param {string} repoRoot - git cwd
183
+ * @param {string[]} args - `git` 다음 argv
184
+ * @returns {GitResult} status·stdout·stderr. 비0도 throw하지 않는다
185
+ */
186
+ function runGit(repoRoot, args) {
187
+ const result = spawnSync('git', args, {
188
+ cwd: repoRoot,
189
+ encoding: 'utf8',
190
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
191
+ stdio: ['ignore', 'pipe', 'pipe'],
192
+ });
193
+ if (result.error) {
194
+ // ENOENT는 git 바이너리 부재다. unlinked로 접으면 설치 문제가 연결 부재로 보인다.
195
+ throw new Error('git command is not executable', { cause: result.error });
196
+ }
197
+ return {
198
+ status: typeof result.status === 'number' ? result.status : 1,
199
+ stdout: result.stdout || '',
200
+ stderr: result.stderr || '',
201
+ };
202
+ }
203
+ function gitFailure(message, result) {
204
+ const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join('\n');
205
+ return new Error(detail ? `${message}: ${detail}` : message);
206
+ }
207
+ /**
208
+ * work tree가 아니면 명시적으로 거절한다. rev-parse 실패를 unlinked로 바꾸지 않는다.
209
+ *
210
+ * @param {string} repoRoot - realpath 저장소 루트
211
+ */
212
+ function requireGitRepo(repoRoot) {
213
+ const inside = runGit(repoRoot, ['rev-parse', '--is-inside-work-tree']);
214
+ if (inside.status !== 0 || inside.stdout.trim() !== 'true') {
215
+ throw gitFailure('not a git repository', inside);
216
+ }
217
+ }
218
+ /**
219
+ * blame의 현재 line 소유 commit과 `--follow` 파일 이력을 모은다.
220
+ * rename은 follow가 담당하고, 같은 함수를 만진 commit은 `-G`로 표시한다.
221
+ *
222
+ * @param {string} repoRoot - git cwd
223
+ * @param {SymbolRef} symbolRef - 현재 함수 좌표
224
+ * @param {string} symbol - `-G`에 쓸 함수명
225
+ * @returns {CommitMeta[]} 중복 없는 commit 목록
226
+ */
227
+ function collectCommitMetas(repoRoot, symbolRef, symbol) {
228
+ const blame = runGit(repoRoot, [
229
+ 'blame', '--line-porcelain', '-L', `${symbolRef.start_line},${symbolRef.end_line}`,
230
+ '--', symbolRef.path,
231
+ ]);
232
+ if (blame.status !== 0) {
233
+ throw gitFailure('git blame failed', blame);
234
+ }
235
+ const blameShas = parseBlameShas(blame.stdout);
236
+ const follow = runGit(repoRoot, [
237
+ 'log', '--follow', '--format=%H%x1f%ct', '--', symbolRef.path,
238
+ ]);
239
+ if (follow.status !== 0) {
240
+ throw gitFailure('git log --follow failed', follow);
241
+ }
242
+ const functionLog = runGit(repoRoot, [
243
+ 'log', '--follow', '-G', escapeRegExp(symbol), '--format=%H', '--', symbolRef.path,
244
+ ]);
245
+ if (functionLog.status !== 0) {
246
+ throw gitFailure('git log -G failed', functionLog);
247
+ }
248
+ const functionShas = new Set(functionLog.stdout.split('\n').map((line) => line.trim()).filter((line) => /^[0-9a-f]{40}$/.test(line)));
249
+ const bySha = new Map();
250
+ for (const line of follow.stdout.split('\n')) {
251
+ if (!line.trim())
252
+ continue;
253
+ const [sha, ctRaw] = line.split('\x1f');
254
+ if (!/^[0-9a-f]{40}$/.test(sha))
255
+ continue;
256
+ const ct = Number.parseInt(ctRaw, 10);
257
+ bySha.set(sha, {
258
+ sha,
259
+ ct: Number.isFinite(ct) ? ct : 0,
260
+ relation: blameShas.has(sha) ? 'blame' : 'follow',
261
+ functionTouch: functionShas.has(sha) || blameShas.has(sha),
262
+ });
263
+ }
264
+ for (const sha of blameShas) {
265
+ if (bySha.has(sha))
266
+ continue;
267
+ bySha.set(sha, {
268
+ sha,
269
+ ct: committerUnix(repoRoot, sha),
270
+ relation: 'blame',
271
+ functionTouch: true,
272
+ });
273
+ }
274
+ return [...bySha.values()];
275
+ }
276
+ /**
277
+ * blame porcelain에서 줄을 소유한 commit SHA를 모은다.
278
+ *
279
+ * 커밋되지 않은 줄은 git이 null SHA(`0` 40개)로 표시한다. 실제 commit이 아니므로
280
+ * provenance 후보에서 빼야 `git log`가 bad object로 실패하지 않고, 수정 중이거나
281
+ * 새로 만든 함수가 bundle 전체를 깨뜨리지 않는다.
282
+ *
283
+ * @param {string} stdout - `git blame --line-porcelain` 원문
284
+ * @returns {Set<string>} 커밋된 줄의 SHA 집합
285
+ */
286
+ function parseBlameShas(stdout) {
287
+ const shas = new Set();
288
+ for (const line of stdout.split('\n')) {
289
+ const match = /^([0-9a-f]{40}) \d+ \d+/.exec(line);
290
+ if (match && !/^0{40}$/.test(match[1]))
291
+ shas.add(match[1]);
292
+ }
293
+ return shas;
294
+ }
295
+ function committerUnix(repoRoot, sha) {
296
+ const result = runGit(repoRoot, ['log', '-1', '--format=%ct', sha]);
297
+ if (result.status !== 0) {
298
+ throw gitFailure('git log %ct failed', result);
299
+ }
300
+ const ct = Number.parseInt(result.stdout.trim(), 10);
301
+ return Number.isFinite(ct) ? ct : 0;
302
+ }
303
+ function escapeRegExp(value) {
304
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
305
+ }
306
+ function pickCurrentSha(metas) {
307
+ const blame = metas.filter((item) => item.relation === 'blame');
308
+ if (blame.length === 0)
309
+ return null;
310
+ return [...blame].sort((left, right) => {
311
+ if (right.ct !== left.ct)
312
+ return right.ct - left.ct;
313
+ return left.sha < right.sha ? -1 : 1;
314
+ })[0].sha;
315
+ }
316
+ /**
317
+ * 현재 line owner는 current. 같은 함수를 나중에 연결 커밋이 고치면 앞선 함수
318
+ * 커밋은 possibly-superseded. 함수를 만지지 않은 과거 연결은 historical이라서
319
+ * 현재 동작의 근거처럼 본문을 실지 않는다.
320
+ *
321
+ * @param {LinkedDraft} item - 연결이 끝난 candidate
322
+ * @param {LinkedDraft[]} linked - 같은 조회에서 연결된 전체
323
+ * @param {string | null} currentSha - blame 최신 SHA
324
+ * @returns {Freshness} freshness 값
325
+ */
326
+ function freshnessOf(item, linked, currentSha) {
327
+ if (currentSha && item.sha === currentSha)
328
+ return 'current';
329
+ const laterLinkedFn = linked.some((other) => (other.sha !== item.sha
330
+ && other.ct > item.ct
331
+ && other.functionTouch));
332
+ if (laterLinkedFn && item.functionTouch)
333
+ return 'possibly-superseded';
334
+ if (laterLinkedFn && !item.functionTouch)
335
+ return 'historical';
336
+ if (item.functionTouch || item.relation === 'follow')
337
+ return 'related';
338
+ return 'historical';
339
+ }
340
+ /**
341
+ * trailer를 우선하고, trailer가 없을 때만 Explain SHA 역색인으로 연결한다.
342
+ * 상충·malformed trailer는 SHA fallback의 입력이 아니며, 모호 SHA·저장소 밖
343
+ * 경로와 같이 증거가 되지 않는다.
344
+ *
345
+ * @param {string} repoRoot - git cwd
346
+ * @param {string} sha - 40자리 commit
347
+ * @param {ExplainDoc[]} explains - 저장소 안 canonical Explain
348
+ * @param {Map<string, LinkHit[]>} shaIndex - full SHA → task·explain
349
+ * @param {object | null} registry - 등록부(구형 trailer 정규화용). null이면 정확 일치
350
+ * @returns {LinkHit | null} 하나면 hit, 없거나 모호·거절이면 null
351
+ */
352
+ function linkCommit(repoRoot, sha, explains, shaIndex, registry) {
353
+ const message = runGit(repoRoot, ['log', '-1', '--format=%B', sha]);
354
+ if (message.status !== 0) {
355
+ throw gitFailure('git log %B failed', message);
356
+ }
357
+ const trailer = parseTrailers(message.stdout);
358
+ if (trailer.status === 'ok') {
359
+ return uniqueHit(explainsForTask(explains, trailer.task, registry));
360
+ }
361
+ // reject는 연결 증거가 아니다. SHA 역색인은 trailer가 전혀 없을 때만 쓴다 —
362
+ // 상충 Task 값을 두고 Explain이 그 SHA를 유일하게 찍으면 잘못된 쪽을 승격한다.
363
+ if (trailer.status === 'reject') {
364
+ return null;
365
+ }
366
+ return uniqueHit(shaIndex.get(sha) || []);
367
+ }
368
+ /**
369
+ * 줄 앞의 Bouncer-Task/Intent만 trailer로 본다. 본문 한가운데 언급은 키가 아니다.
370
+ * 값이 둘 이상이거나 Task·Intent가 어긋나면 reject다. SHA fallback으로 넘기지 않는다.
371
+ *
372
+ * @param {string} message - `%B` 본문
373
+ * @returns {TrailerParse} ok / absent / reject
374
+ */
375
+ function parseTrailers(message) {
376
+ const tasks = new Set();
377
+ const intents = new Set();
378
+ for (const raw of message.split('\n')) {
379
+ const match = TRAILER_RE.exec(raw);
380
+ if (!match)
381
+ continue;
382
+ if (match[1] === 'Bouncer-Task')
383
+ tasks.add(match[2]);
384
+ else
385
+ intents.add(match[2]);
386
+ }
387
+ if (tasks.size === 0 && intents.size === 0)
388
+ return { status: 'absent' };
389
+ if (tasks.size !== 1)
390
+ return { status: 'reject' };
391
+ const task = [...tasks][0];
392
+ const parsed = parseStableId(task);
393
+ if (!parsed || parsed.task === null)
394
+ return { status: 'reject' };
395
+ if (intents.size > 1)
396
+ return { status: 'reject' };
397
+ if (intents.size === 1) {
398
+ // intent는 task와 같은 형식·같은 key여야 한다. 형식이 다른 쌍은 한 커밋이 두 체계를 주장하므로 reject.
399
+ const intent = parseStableId([...intents][0]);
400
+ if (!intent || intent.task !== null || intent.format !== parsed.format
401
+ || intent.epicKey !== parsed.epicKey || intent.blueprintKey !== parsed.blueprintKey) {
402
+ return { status: 'reject' };
403
+ }
404
+ }
405
+ return { status: 'ok', task };
406
+ }
407
+ /**
408
+ * trailer의 task가 속한 blueprint의 Explain을 찾는다. trailer와 Explain 경로의 intent를 각각
409
+ * 등록부 별칭으로 정규화해 비교하므로, 구형 trailer 커밋도 이관 뒤의 slug Explain에 연결된다.
410
+ * 별칭이 없거나 등록부가 null이면 원래 문자열끼리 비교한다(이관 전 동작).
411
+ *
412
+ * @param {ExplainDoc[]} explains - 저장소 안 canonical Explain
413
+ * @param {string} task - trailer의 stable Task ID(번호 또는 slug 형식)
414
+ * @param {object | null} registry - 등록부 또는 null
415
+ * @returns {LinkHit[]} 같은 blueprint의 Explain hit
416
+ */
417
+ function explainsForTask(explains, task, registry) {
418
+ const parsed = parseStableId(task);
419
+ if (!parsed || parsed.task === null)
420
+ return [];
421
+ const intent = formatStableIntentId(parsed);
422
+ const wanted = normalizeStableId(intent, registry) ?? intent;
423
+ const hits = [];
424
+ const seen = new Set();
425
+ for (const doc of explains) {
426
+ if ((normalizeStableId(doc.intentId, registry) ?? doc.intentId) !== wanted)
427
+ continue;
428
+ const key = `${task}\0${doc.rel}`;
429
+ if (seen.has(key))
430
+ continue;
431
+ seen.add(key);
432
+ hits.push({ task, explain: doc.rel });
433
+ }
434
+ return hits;
435
+ }
436
+ function uniqueHit(hits) {
437
+ if (hits.length === 0)
438
+ return null;
439
+ const keys = new Set(hits.map((item) => `${item.task}\0${item.explain}`));
440
+ if (keys.size !== 1)
441
+ return null;
442
+ return hits[0];
443
+ }
444
+ /**
445
+ * 8자리 SHA를 Git 객체로 펼친다. 둘 이상의 객체에 매기면 하나를 고르지 않는다.
446
+ *
447
+ * @param {string} repoRoot - git cwd
448
+ * @param {string} shortSha - Explain에 적힌 짧은 hex
449
+ * @returns {ShaExpand} unique / ambiguous / missing
450
+ */
451
+ function expandShortSha(repoRoot, shortSha) {
452
+ const parsed = runGit(repoRoot, ['rev-parse', '--verify', shortSha]);
453
+ const text = `${parsed.stdout}\n${parsed.stderr}`;
454
+ if (/ambiguous/i.test(text))
455
+ return { status: 'ambiguous' };
456
+ if (parsed.status !== 0)
457
+ return { status: 'missing' };
458
+ const full = parsed.stdout.trim();
459
+ if (!/^[0-9a-f]{40}$/.test(full))
460
+ return { status: 'missing' };
461
+ const kind = runGit(repoRoot, ['cat-file', '-t', full]);
462
+ if (kind.status !== 0 || kind.stdout.trim() !== 'commit')
463
+ return { status: 'missing' };
464
+ return { status: 'unique', full };
465
+ }
466
+ function buildShaIndex(repoRoot, explains) {
467
+ const index = new Map();
468
+ for (const doc of explains) {
469
+ for (const row of doc.rows) {
470
+ const expanded = expandShortSha(repoRoot, row.sha8);
471
+ // 모호한 짧은 SHA는 어느 task에도 붙이지 않는다. 한 쪽을 찍으면 다른 객체의
472
+ // 의도가 현재 함수 근거로 승격된다.
473
+ if (expanded.status !== 'unique')
474
+ continue;
475
+ const list = index.get(expanded.full) || [];
476
+ list.push({ task: row.task, explain: doc.rel });
477
+ index.set(expanded.full, list);
478
+ }
479
+ }
480
+ return index;
481
+ }
482
+ /**
483
+ * canonical Explain만 읽는다. context graph 역색인은 쓰지 않고, symlink가
484
+ * 저장소 밖으로 나가면 그 파일만 버린다.
485
+ *
486
+ * @param {string} repoReal - realpath 루트
487
+ * @returns {ExplainDoc[]} 파싱에 성공한 Explain
488
+ */
489
+ function loadExplainDocs(repoReal) {
490
+ const root = path.join(repoReal, '.bouncer', 'context');
491
+ const files = [];
492
+ walkExplain(root, repoReal, files);
493
+ files.sort();
494
+ const docs = [];
495
+ for (const abs of files) {
496
+ const parsed = parseExplainDoc(abs, repoReal);
497
+ if (parsed)
498
+ docs.push(parsed);
499
+ }
500
+ return docs;
501
+ }
502
+ function walkExplain(absDir, repoReal, files) {
503
+ let entries;
504
+ try {
505
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
506
+ }
507
+ catch (error) {
508
+ // 한 디렉터리의 부재·권한·루프만 건너뛴다. 그 외는 색인 전체를 공집합으로 위장하지 않는다.
509
+ if (isSkippableFsError(error))
510
+ return;
511
+ throw error;
512
+ }
513
+ for (const ent of entries) {
514
+ if (ent.name === '.' || ent.name === '..')
515
+ continue;
516
+ const abs = path.join(absDir, ent.name);
517
+ if (ent.isDirectory()) {
518
+ if (SKIP_DIR_NAMES.has(ent.name))
519
+ continue;
520
+ if (!isInsideRepo(abs, repoReal))
521
+ continue;
522
+ walkExplain(abs, repoReal, files);
523
+ continue;
524
+ }
525
+ if (ent.name !== 'explain.md')
526
+ continue;
527
+ let real;
528
+ try {
529
+ real = fs.realpathSync(abs);
530
+ }
531
+ catch (error) {
532
+ if (isSkippableFsError(error))
533
+ continue;
534
+ throw error;
535
+ }
536
+ if (!isInsideRepo(real, repoReal))
537
+ continue;
538
+ files.push(abs);
539
+ }
540
+ }
541
+ function parseExplainDoc(abs, repoReal) {
542
+ let raw;
543
+ try {
544
+ raw = fs.readFileSync(abs, 'utf8');
545
+ }
546
+ catch (error) {
547
+ if (isSkippableFsError(error))
548
+ return null;
549
+ throw error;
550
+ }
551
+ let parsed;
552
+ try {
553
+ parsed = parseFrontmatter(raw);
554
+ }
555
+ catch (_e) {
556
+ // 깨진 frontmatter는 이 파일만 증거가 아니다. 다른 Explain을 지우지 않는다.
557
+ return null;
558
+ }
559
+ const data = parsed.data;
560
+ if (!data || typeof data !== 'object' || Array.isArray(data))
561
+ return null;
562
+ const bouncer = data.bouncer;
563
+ if (!bouncer || typeof bouncer !== 'object' || Array.isArray(bouncer))
564
+ return null;
565
+ const rec = bouncer;
566
+ const epicId = String(rec.epic_id || '');
567
+ const bpId = String(rec.blueprint_id || '');
568
+ // 번호 쌍이거나 slug 쌍일 때만 증거다. 형식이 섞이거나 어느 쪽도 아니면 이 파일은 버린다.
569
+ const keys = parsePointerKey(`${epicId}/${bpId}`);
570
+ if (!keys)
571
+ return null;
572
+ const rel = toPosix(path.relative(repoReal, abs));
573
+ const rows = parseTaskCommitRows(rec.task_commits, epicId, bpId, keys.format);
574
+ const explainSections = parseExplainSections(parsed.body);
575
+ const sections = [];
576
+ pushSection(sections, 'Background', stripExcludedHeadings(explainSections.background));
577
+ pushSection(sections, 'Intuition', stripExcludedHeadings(explainSections.intuition));
578
+ pushSection(sections, 'Code', stripExcludedHeadings(explainSections.code));
579
+ return { rel, epicId, bpId, intentId: formatStableIntentId(keys), body: parsed.body, rows, sections };
580
+ }
581
+ /**
582
+ * 새 `{ task, sha, intent_anchor }`를 먼저 읽고, 키가 없으면 legacy `{ id, sha }`만
583
+ * 받는다. 깨진 새 행을 legacy id로 구하지 않는다.
584
+ *
585
+ * @param {unknown} value - task_commits
586
+ * @param {string} epicId - Explain 부모 epic
587
+ * @param {string} bpId - Explain 부모 blueprint
588
+ * @param {'numeric' | 'slug'} format - 부모 key 형식(legacy `{id}` 행의 task ID 조립에 쓴다)
589
+ * @returns {ExplainRow[]} 검증된 행
590
+ */
591
+ function parseTaskCommitRows(value, epicId, bpId, format) {
592
+ if (!Array.isArray(value))
593
+ return [];
594
+ const rows = [];
595
+ for (const row of value) {
596
+ if (!row || typeof row !== 'object' || Array.isArray(row))
597
+ continue;
598
+ const record = row;
599
+ const looksNew = Object.prototype.hasOwnProperty.call(record, 'task')
600
+ || Object.prototype.hasOwnProperty.call(record, 'intent_anchor');
601
+ if (looksNew) {
602
+ const parsed = parseNewRow(record, epicId, bpId);
603
+ if (parsed)
604
+ rows.push(parsed);
605
+ continue;
606
+ }
607
+ const id = String(record.id || '');
608
+ if (!TASK_DIR_RE.test(id))
609
+ continue;
610
+ const sha8 = normalizeCommitSha(record.sha);
611
+ if (!sha8)
612
+ continue;
613
+ rows.push({ task: formatStableTaskId({ format, epicKey: epicId, blueprintKey: bpId, task: id }), sha8 });
614
+ }
615
+ return rows;
616
+ }
617
+ function parseNewRow(row, epicId, bpId) {
618
+ const sha8 = normalizeCommitSha(row.sha);
619
+ if (!sha8)
620
+ return null;
621
+ const anchor = INTENT_ANCHOR_RE.exec(String(row.intent_anchor || ''));
622
+ if (!anchor)
623
+ return null;
624
+ let expected;
625
+ try {
626
+ expected = buildStableProvenance({
627
+ epicId,
628
+ blueprintId: bpId,
629
+ taskId: `TASKS-${anchor[1]}`,
630
+ });
631
+ }
632
+ catch (_e) {
633
+ return null;
634
+ }
635
+ if (String(row.task || '') !== expected.task)
636
+ return null;
637
+ return { task: expected.task, sha8 };
638
+ }
639
+ function pushSection(sections, name, body) {
640
+ if (typeof body !== 'string')
641
+ return;
642
+ const trimmed = body.trim();
643
+ if (!trimmed)
644
+ return;
645
+ sections.push({ name, body: trimmed });
646
+ }
647
+ /**
648
+ * parseExplainSections가 모르는 `## Checklist` 같은 제목은 직전 절에 흡수된다.
649
+ * 허용 절을 고른 뒤에도 verification·review·Quiz·Checklist가 본문에 남지 않게 자른다.
650
+ *
651
+ * @param {string | null | undefined} body - 한 절의 원문
652
+ * @returns {string | null} 제외 제목 앞까지만
653
+ */
654
+ function stripExcludedHeadings(body) {
655
+ if (typeof body !== 'string')
656
+ return null;
657
+ const lines = body.split('\n');
658
+ const kept = [];
659
+ for (const line of lines) {
660
+ if (/^##\s+(Quiz|Checklist|이해\s*상태|verification|review)\b/i.test(line.trim()))
661
+ break;
662
+ kept.push(line);
663
+ }
664
+ return kept.join('\n');
665
+ }
666
+ function taskDigitsOf(task) {
667
+ return parseStableId(task)?.task ?? null;
668
+ }
669
+ /**
670
+ * Background·Intuition·Code와 해당 Task의 장기 설계 절만 고른다.
671
+ * Quiz·Checklist·Do not touch·verification·review는 입력 예산에 넣지 않는다.
672
+ *
673
+ * @param {ExplainDoc} doc - 이미 읽은 Explain
674
+ * @param {string | null} taskDigits - TASK-NNN의 숫자
675
+ * @returns {SelectedSection[]} 허용된 절
676
+ */
677
+ function selectSections(doc, taskDigits) {
678
+ const selected = [...doc.sections];
679
+ if (!taskDigits)
680
+ return selected;
681
+ return selected.concat(parseTaskDesign(doc.body, taskDigits));
682
+ }
683
+ /**
684
+ * selectSections와 같은 allowlist로 절을 고른 뒤 각 절 본문의 SHA-256을 만든다.
685
+ * 공개 resolver JSON은 이름 목록과 예산 본문만 노출하므로, bundle fast path가
686
+ * 같은 절을 재해시할 수 있게 별도 projection으로 둔다 — allowlist가 갈라지면
687
+ * stale hit가 생긴다.
688
+ *
689
+ * @param {SelectedSection[]} sections - selectSections 결과
690
+ * @returns {SectionHash[]} 이름 순서 유지, hash는 lowercase 64자리 hex
691
+ */
692
+ function projectSectionHashes(sections) {
693
+ return sections.map((part) => ({
694
+ name: part.name,
695
+ hash: createHash('sha256').update(part.body, 'utf8').digest('hex'),
696
+ }));
697
+ }
698
+ /**
699
+ * hash·본문 projection이 같은 canonical 경로·symlink 경계를 쓰게 한다.
700
+ * 검사가 갈라지면 bundle drift 판정과 역할 본문이 다른 파일을 보게 된다.
701
+ *
702
+ * @param {object} input - 조회 입력
703
+ * @param {string} input.repoRoot - 저장소 루트
704
+ * @param {string} input.explainRel - repo-relative Explain 경로
705
+ * @returns {ExplainDoc | null} 검증된 Explain 또는 거절 시 null
706
+ */
707
+ function loadCanonicalExplainDoc(input) {
708
+ if (typeof input.explainRel !== 'string' || input.explainRel.length === 0)
709
+ return null;
710
+ const explainRel = toPosix(input.explainRel);
711
+ // cache body가 지시하는 경로는 live walkExplain과 같은 canonical만 연다.
712
+ if (path.isAbsolute(explainRel) || explainRel.includes('..'))
713
+ return null;
714
+ if (!isCanonicalExplainPath(explainRel))
715
+ return null;
716
+ let repoReal;
717
+ try {
718
+ repoReal = fs.realpathSync(input.repoRoot);
719
+ }
720
+ catch (error) {
721
+ if (isSkippableFsError(error))
722
+ return null;
723
+ throw error;
724
+ }
725
+ const abs = path.resolve(repoReal, explainRel);
726
+ let real;
727
+ try {
728
+ real = fs.realpathSync(abs);
729
+ }
730
+ catch (error) {
731
+ // 부재·권한·루프는 hit 검증 실패로만 접는다. 다른 I/O는 위장하지 않는다.
732
+ if (isSkippableFsError(error))
733
+ return null;
734
+ throw error;
735
+ }
736
+ if (!isInsideRepo(real, repoReal))
737
+ return null;
738
+ return parseExplainDoc(abs, repoReal);
739
+ }
740
+ /**
741
+ * 저장소 안 canonical Explain 실경로만 읽어 selectSections → projectSectionHashes를 적용한다.
742
+ * cache가 임의 상대경로를 읽기 대상으로 넘기면 allowlist에서 거절하고, symlink가
743
+ * 루트 밖으로 나가거나 파일이 없으면 null — cache hit로 승격하지 않는다.
744
+ *
745
+ * @param {object} input - 조회 입력
746
+ * @param {string} input.repoRoot - 저장소 루트
747
+ * @param {string} input.explainRel - repo-relative Explain 경로
748
+ * @param {string} input.task - `EPIC-ddd/BP-ddd/TASK-ddd` stable Task ID
749
+ * @returns {SectionHash[] | null} 절 hash 목록 또는 검증 실패 시 null
750
+ */
751
+ function projectExplainSectionHashes(input) {
752
+ const doc = loadCanonicalExplainDoc(input);
753
+ if (!doc)
754
+ return null;
755
+ return projectSectionHashes(selectSections(doc, taskDigitsOf(input.task)));
756
+ }
757
+ /**
758
+ * projectExplainSectionHashes와 같은 경로 검사·selectSections allowlist로 절 본문을 돌려준다.
759
+ * hash와 본문이 다른 파일을 읽으면 drift 대조가 의미를 잃는다.
760
+ *
761
+ * @param {object} input - 조회 입력
762
+ * @param {string} input.repoRoot - 저장소 루트
763
+ * @param {string} input.explainRel - repo-relative Explain 경로
764
+ * @param {string} input.task - `EPIC-ddd/BP-ddd/TASK-ddd` stable Task ID
765
+ * @returns {SelectedSection[] | null} 허용된 절 본문 또는 검증 실패 시 null
766
+ */
767
+ function projectExplainSectionBodies(input) {
768
+ const doc = loadCanonicalExplainDoc(input);
769
+ if (!doc)
770
+ return null;
771
+ return selectSections(doc, taskDigitsOf(input.task));
772
+ }
773
+ /**
774
+ * Explain 본문에서 해당 Task의 장기 설계 절만 덧붙인다.
775
+ * finalize `buildTaskContext`와 같은 allowlist·순서를 유지해 Plan 입력이
776
+ * 보존 계약과 갈라지지 않게 한다. Do not touch·Checklist는 같은 ### 아래
777
+ * 있어도 고르지 않는다.
778
+ *
779
+ * @param {string} body - frontmatter를 벗긴 Explain 본문
780
+ * @param {string} taskDigits - `001`
781
+ * @returns {SelectedSection[]} 값이 있는 장기 절만
782
+ */
783
+ function parseTaskDesign(body, taskDigits) {
784
+ const explainSections = parseExplainSections(body);
785
+ const tasks = explainSections.tasks;
786
+ if (typeof tasks !== 'string' || !tasks.trim())
787
+ return [];
788
+ const chunks = splitTaskChunks(tasks);
789
+ const chunk = chunks.get(taskDigits);
790
+ if (!chunk)
791
+ return [];
792
+ const parts = splitSubheadings(chunk);
793
+ const selected = [];
794
+ pushSection(selected, 'Goal & intent', parts.get('goal'));
795
+ pushSection(selected, 'Current behavior', parts.get('currentBehavior'));
796
+ pushSection(selected, 'Target behavior', parts.get('targetBehavior'));
797
+ pushSection(selected, 'Interface', parts.get('interface'));
798
+ pushSection(selected, 'Touch', parts.get('touch'));
799
+ pushSection(selected, 'Constraints', parts.get('constraints'));
800
+ return selected;
801
+ }
802
+ /**
803
+ * Explain `## Tasks` 본문을 task 번호 → 절 본문 맵으로 나눈다.
804
+ * 새 제목(`### EPIC-ddd/BP-ddd/TASK-ddd · \`sha8\`` 또는 `### <epic>/<bp>/ddd · \`sha8\``)과 기존 `### Task NNN`을
805
+ * 같은 NNN 키로 묶는다. 세 자리가 아니거나 SHA가 대문자·비-8자리면 인식하지
806
+ * 않는다 — 잘못된 제목을 추측해 올리면 다른 task 절이 섞인다.
807
+ *
808
+ * @param {string} tasksBody - `## Tasks` 아래 본문
809
+ * @returns {Map<string, string>} 키는 `001` 형태
810
+ */
811
+ function splitTaskChunks(tasksBody) {
812
+ const lines = tasksBody.split('\n');
813
+ const starts = [];
814
+ for (let i = 0; i < lines.length; i += 1) {
815
+ const trimmed = lines[i].trim();
816
+ const legacy = /^###\s+Task\s+(\d{3})\s*$/i.exec(trimmed);
817
+ if (legacy) {
818
+ starts.push({ id: legacy[1], line: i });
819
+ continue;
820
+ }
821
+ // SHA는 소문자 8자리만. 대문자·짧은 hex는 Interface 거절 계약과 같다.
822
+ // 번호(`EPIC-…/TASK-NNN`)와 slug(`<epic>/<bp>/NNN`) 제목을 같은 파서로 읽는다.
823
+ const stable = /^###\s+(\S+)(?:\s+·\s+`[0-9a-f]{8}`)?\s*$/.exec(trimmed);
824
+ const stableTask = stable ? parseStableId(stable[1])?.task : null;
825
+ if (stableTask)
826
+ starts.push({ id: stableTask, line: i });
827
+ }
828
+ const out = new Map();
829
+ for (let i = 0; i < starts.length; i += 1) {
830
+ const end = i + 1 < starts.length ? starts[i + 1].line : lines.length;
831
+ out.set(starts[i].id, lines.slice(starts[i].line + 1, end).join('\n'));
832
+ }
833
+ return out;
834
+ }
835
+ function splitSubheadings(chunk) {
836
+ const lines = chunk.split('\n');
837
+ const starts = [];
838
+ for (let i = 0; i < lines.length; i += 1) {
839
+ const line = lines[i].trim();
840
+ // Touch는 제목 전체 일치(`^####\s+Touch\s*$`)로만 맞춘다. Do not touch를
841
+ // Touch로 승격하면 실행용 범위 통제가 Plan 입력에 장기 금지로 남는다.
842
+ if (/^####\s+Goal\s*&\s*intent\s*$/i.test(line))
843
+ starts.push({ key: 'goal', line: i });
844
+ else if (/^####\s+Current\s+behavior\s*$/i.test(line)) {
845
+ starts.push({ key: 'currentBehavior', line: i });
846
+ }
847
+ else if (/^####\s+Target\s+behavior\s*$/i.test(line)) {
848
+ starts.push({ key: 'targetBehavior', line: i });
849
+ }
850
+ else if (/^####\s+Interface\s*$/i.test(line))
851
+ starts.push({ key: 'interface', line: i });
852
+ else if (/^####\s+Touch\s*$/i.test(line))
853
+ starts.push({ key: 'touch', line: i });
854
+ else if (/^####\s+Constraints\s*$/i.test(line))
855
+ starts.push({ key: 'constraints', line: i });
856
+ else if (/^####\s+/.test(line))
857
+ starts.push({ key: 'other', line: i });
858
+ }
859
+ const out = new Map();
860
+ for (let i = 0; i < starts.length; i += 1) {
861
+ const end = i + 1 < starts.length ? starts[i + 1].line : lines.length;
862
+ const key = starts[i].key;
863
+ if (key === 'other')
864
+ continue;
865
+ out.set(key, lines.slice(starts[i].line + 1, end).join('\n'));
866
+ }
867
+ return out;
868
+ }
869
+ function serializeSections(sections) {
870
+ return sections
871
+ .map((item) => `## ${item.name}\n\n${item.body}`)
872
+ .join('\n\n');
873
+ }
874
+ /**
875
+ * 허용된 절의 UTF-8 합계가 2,000 byte를 넘기기 전에 자른다.
876
+ * historical 본문은 이미 비어 있어 예산에 넣지 않는다.
877
+ *
878
+ * @param {Array<{ body: string, freshness: Freshness, sections: string[] }>} candidates - 정렬·limit 이후
879
+ * @returns {boolean} 잘렸으면 true
880
+ */
881
+ function applyBodyBudget(candidates) {
882
+ let used = 0;
883
+ let truncated = false;
884
+ for (const item of candidates) {
885
+ if (item.freshness === 'historical' || item.body.length === 0)
886
+ continue;
887
+ const remaining = BODY_BUDGET_BYTES - used;
888
+ if (remaining <= 0) {
889
+ item.body = '';
890
+ truncated = true;
891
+ continue;
892
+ }
893
+ const bytes = Buffer.byteLength(item.body, 'utf8');
894
+ if (bytes <= remaining) {
895
+ used += bytes;
896
+ continue;
897
+ }
898
+ item.body = clipUtf8(item.body, remaining);
899
+ used = BODY_BUDGET_BYTES;
900
+ truncated = true;
901
+ }
902
+ return truncated;
903
+ }
904
+ function clipUtf8(text, maxBytes) {
905
+ const buf = Buffer.from(text, 'utf8');
906
+ if (buf.length <= maxBytes)
907
+ return text;
908
+ let end = maxBytes;
909
+ // 연속 바이트 한가운데를 자르면 깨진 한글이 본문으로 나간다.
910
+ while (end > 0 && (buf[end] & 0xc0) === 0x80)
911
+ end -= 1;
912
+ return buf.subarray(0, end).toString('utf8');
913
+ }
914
+ function isInsideRepo(absPath, repoReal) {
915
+ if (absPath === repoReal)
916
+ return true;
917
+ const prefix = repoReal.endsWith(path.sep) ? repoReal : `${repoReal}${path.sep}`;
918
+ return absPath.startsWith(prefix);
919
+ }
920
+ module.exports = {
921
+ isCanonicalExplainPath,
922
+ resolveIntentProvenance,
923
+ projectSectionHashes,
924
+ projectExplainSectionHashes,
925
+ projectExplainSectionBodies,
926
+ splitTaskChunks,
927
+ };