@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,164 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const paths = require("./paths");
4
+ const { toPosix } = paths;
5
+ const digestCollect = require("./finalize-digest-collect");
6
+ const { collectDigestStart } = digestCollect;
7
+ const finalizePr = require("./finalize-pr");
8
+ const { buildPrDraft } = finalizePr;
9
+ const digestModel = require("./finalize-digest-model");
10
+ const { summarizeDrive, summarizeDriveIssues } = digestModel;
11
+ const { assembleDigestBody } = digestModel;
12
+ const { assessDigestSnapshot } = digestModel;
13
+ const { buildBlueprintSnapshot } = digestModel;
14
+ const { buildTaskSnapshots } = digestModel;
15
+ const { applyTaskTrailers } = digestModel;
16
+ const { collectDigestWarnings } = digestModel;
17
+ const { normalizeChangedPaths, extractSymbols, parseCommitSubjects, summarizeDiffStat } = digestModel;
18
+ const { readReviewEvidence, readReview, resolvePrBase } = digestCollect;
19
+ const { readBlueprintIndex, collectTaskDocuments, readDigestRegistry, readDigestConfig, collectDigestGit, } = digestCollect;
20
+ /**
21
+ * Explain·Quiz·PR에 필요한 finalize 입력을 한 JSON으로 모은다.
22
+ * 문서·Git·원장을 쓰지 않는다. drive면 integration worktree에서 git을 읽고,
23
+ * 깨진 원장은 기존 finalize와 같은 coordinator-ledger로 거절한다.
24
+ * 성공 결과에는 최상위 `coordinator`와 `tasks[].actual_paths`를 싣지 않는다 —
25
+ * 둘 다 drive 실행 기록이고 PR 초안은 읽지 않는다. 대신 최상위 `drive`
26
+ * (`summarizeDrive`: 준비 여부와 task별 status·sha, 원장 없으면 null)를 `pr`과
27
+ * 나란히 싣는다. `drive`는 PR 입력이 아니므로 buildPrDraft 뒤에 붙인다. `git.branch`만 원장
28
+ * `integrationBranch`(없으면 checkout)로 정한다. 성공 시 `pr`는
29
+ * buildPrDraft로 채운다 — agent가 제목·확인 방법을 손으로 조립하지 않게
30
+ * 하기 위함이다. `review_scope: blueprint`이면 루트 리뷰 finding을
31
+ * `blueprint_review`로 싣고, 아니면 그 필드는 null이다.
32
+ * 루트 리뷰의 deferred must_fix는 최상위 `warnings`(blueprint 모드가 아니면 [])로,
33
+ * 원장 event 통계는 최상위 `drive_issues`로 싣는다. 둘 다 buildPrDraft 뒤에 붙여
34
+ * 실행 통계가 PR 입력에 섞이지 않게 한다 — PR의 미해결 절은 같은 판정 함수로
35
+ * `blueprint_review`에서 직접 만든다.
36
+ *
37
+ * @param {object} opts
38
+ * @param {string} opts.repoRoot - 호출 checkout(보통 main). 원장 경로 해석 기준
39
+ * @param {string} opts.blueprintDir - blueprint 상대 경로
40
+ * @param {(args: string[]) => { status: number, stdout: string }} [opts.exec] - `git` 뒤 argv seam
41
+ * @param {Date} [opts.now] - PR 제목 날짜(KST). 기본 `new Date()`
42
+ * @returns {FinalizeDigest | DigestFail} 성공 digest 또는 reason 코드
43
+ */
44
+ function prepareFinalizeDigest({ repoRoot, blueprintDir, exec, now, }) {
45
+ const bp = toPosix(blueprintDir);
46
+ const collected = collectDigestStart({ repoRoot, blueprintDir: bp, exec });
47
+ if ('ok' in collected)
48
+ return collected;
49
+ const { checkoutRoot, listing, ledger, base, head, run } = collected;
50
+ // blueprint index — Intent 파싱 실패는 digest 거절이 아니라 빈 intent로 두지 않는다.
51
+ // Intent는 Interface 필수 필드이므로 읽기 실패 시 빈 배열만 허용(파일이 있을 때).
52
+ const bpIndexRel = `${bp}/index.md`;
53
+ const bpIndexAbs = path.join(checkoutRoot, bpIndexRel);
54
+ const blueprintDoc = readBlueprintIndex(bpIndexAbs);
55
+ const blueprint = buildBlueprintSnapshot({
56
+ data: blueprintDoc?.data,
57
+ body: typeof blueprintDoc?.body === 'string' ? blueprintDoc.body : '',
58
+ });
59
+ if (!blueprint.ok) {
60
+ return {
61
+ ok: false,
62
+ reason: 'validate',
63
+ failures: [{
64
+ code: 'S33',
65
+ message: `blueprint Intent invalid: ${blueprint.message}`,
66
+ file: bpIndexRel,
67
+ }],
68
+ };
69
+ }
70
+ const { title: bpTitle, commit_type: bpCommitType, scale: bpScale, epicId: bpEpicId, blueprintId: bpBlueprintId, stable_id: stableBlueprint, intent, outOfScope, reviewMode: blueprintReviewMode } = blueprint;
71
+ // provenance는 git.branch 결정에만 쓴다. 결과 JSON에 싣지 않는 이유:
72
+ // 원장 색인은 cleanup·finalize payload 자리이고, digest는 PR 입력이다.
73
+ const taskRows = collectTaskDocuments({ checkoutRoot, listing });
74
+ const initialTasks = buildTaskSnapshots({
75
+ rows: taskRows,
76
+ blueprintEpicId: bpEpicId,
77
+ blueprintId: bpBlueprintId,
78
+ ledger,
79
+ });
80
+ const stableIds = initialTasks.map((item) => item.digest.stable_id);
81
+ // 등록부는 이 진입점에서 한 번만 읽는다. 없거나 깨졌으면 null — 정규화 없이 정확 일치로 조회해
82
+ // 이관 전 동작과 같다(깨진 등록부의 거절은 S35 검증 소관이고 digest는 읽기 전용이다).
83
+ const registryRead = readDigestRegistry(checkoutRoot);
84
+ const registry = registryRead.status === 'ok' ? registryRead.registry : null;
85
+ const git = collectDigestGit({
86
+ checkoutRoot,
87
+ base,
88
+ head,
89
+ run,
90
+ stableIds,
91
+ registry,
92
+ integrationBranch: ledger && typeof ledger.integrationBranch === 'string'
93
+ ? ledger.integrationBranch
94
+ : null,
95
+ });
96
+ const built = applyTaskTrailers(initialTasks, git.trailerMap);
97
+ const changedPaths = normalizeChangedPaths(git.nameOnly);
98
+ const symbols = git.unified.status === 0 ? extractSymbols(git.unified.stdout) : [];
99
+ const commits = git.subjectLog.status === 0
100
+ ? parseCommitSubjects(git.subjectLog.stdout)
101
+ : [];
102
+ const diff = git.diff;
103
+ const diffSummary = summarizeDiffStat(git.numstat);
104
+ const rootReview = blueprintReviewMode
105
+ ? readReview(path.join(checkoutRoot, `${bp}/review.md`))
106
+ : null;
107
+ const assessed = assessDigestSnapshot({
108
+ built,
109
+ ledger,
110
+ blueprintReviewMode,
111
+ rootReview,
112
+ changedPaths,
113
+ diffSummaryUnavailable: !diffSummary,
114
+ });
115
+ const unverified = assessed.unverified;
116
+ const blueprintReview = assessed.blueprint_review;
117
+ // branch는 collector가 integrationBranch 우선순위로 수집한다.
118
+ const branch = git.branch;
119
+ // pr는 나머지 digest 필드를 입력으로 쓴다 — 순환 참조를 피하려고 본문을
120
+ // 먼저 조립한 뒤 buildPrDraft에 넘긴다.
121
+ const body = assembleDigestBody({
122
+ blueprint: {
123
+ dir: bp,
124
+ stable_id: stableBlueprint,
125
+ title: bpTitle,
126
+ intent,
127
+ commit_type: bpCommitType,
128
+ scale: bpScale,
129
+ },
130
+ range: {
131
+ base,
132
+ head,
133
+ diff_sha: diff.ok ? diff.sha : null,
134
+ },
135
+ git: {
136
+ branch,
137
+ pr_base: resolvePrBase(checkoutRoot, run),
138
+ },
139
+ tasks: assessed.tasks,
140
+ changed_paths: changedPaths,
141
+ symbols,
142
+ commits,
143
+ diff: diffSummary,
144
+ reviewEvidence: readReviewEvidence(path.join(checkoutRoot, `${bp}/review.md`), `${bp}/review.md`),
145
+ unverified,
146
+ out_of_scope: outOfScope,
147
+ blueprint_review: blueprintReview,
148
+ });
149
+ // task 범위는 경고를 만들지 않는다 — blueprintReview는 blueprint 모드에서만 non-null.
150
+ const warnings = blueprintReview ? collectDigestWarnings(blueprintReview.findings) : [];
151
+ const config = readDigestConfig(checkoutRoot) ?? {};
152
+ return {
153
+ ...body,
154
+ pr: buildPrDraft(body, {
155
+ now: now instanceof Date ? now : new Date(),
156
+ config,
157
+ }),
158
+ // PR draft 입력(body)에 섞이지 않도록 buildPrDraft 호출 뒤 반환 객체에만 붙인다.
159
+ drive: summarizeDrive(ledger),
160
+ warnings,
161
+ drive_issues: summarizeDriveIssues(ledger, warnings.length),
162
+ };
163
+ }
164
+ module.exports = { prepareFinalizeDigest, summarizeDrive, summarizeDriveIssues };
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const paths = require("./paths");
5
+ const { toPosix } = paths;
6
+ const frontmatter = require("./frontmatter");
7
+ const { readDoc } = frontmatter;
8
+ const render = require("./render");
9
+ const { renderDoc } = render;
10
+ const commitSha = require("./commit-sha");
11
+ const { normalizeCommitSha } = commitSha;
12
+ const collect = require("./finalize-collect");
13
+ const { stableIdFromTaskData, taskStableParts } = collect;
14
+ function asRecord(value) { return value; }
15
+ const TASK_SECTIONS = [
16
+ ['goal', /^##\s+(Goal\s*&\s*intent|목적[·・.]?의도)\s*$/i],
17
+ ['currentBehavior', /^##\s+(Current\s+behavior|현재\s*동작)\s*$/i],
18
+ ['targetBehavior', /^##\s+(Target\s+behavior|목표\s*동작)\s*$/i],
19
+ ['interface', /^##\s+(Interface|인터페이스)\s*$/i],
20
+ ['touch', /^##\s+(Touch|수정할\s*부분)\s*$/i],
21
+ ['doNotTouch', /^##\s+(Do\s+not\s+touch|절대\s*수정\s*금지)\s*$/i],
22
+ ['constraints', /^##\s+(Constraints|제약)\s*$/i],
23
+ ['checklist', /^##\s+(Checklist|체크리스트)\s*$/i],
24
+ ];
25
+ /**
26
+ * tasks.md에서 Explain에 보존하는 설계 절의 본문을 읽는다.
27
+ * HTML 주석은 template 안내이므로 본문에서 제외하고, 누락된 절은 null로 둔다.
28
+ *
29
+ * @param {unknown} body - tasks.md 본문
30
+ * @returns {Record<string, string | null>} 절 키에서 잘라낸 본문으로의 매핑
31
+ */
32
+ function parseTasksSections(body) {
33
+ const lines = (typeof body === 'string' ? body : '').replace(/<!--[\s\S]*?-->/g, '').split('\n');
34
+ const starts = [];
35
+ for (let i = 0; i < lines.length; i++) {
36
+ for (const [key, re] of TASK_SECTIONS)
37
+ if (re.test(lines[i].trim())) {
38
+ starts.push({ key: key, line: i });
39
+ }
40
+ }
41
+ const out = Object.fromEntries(TASK_SECTIONS.map(([key]) => [key, null]));
42
+ for (let i = 0; i < starts.length; i++) {
43
+ const start = starts[i];
44
+ const end = i + 1 < starts.length ? starts[i + 1].line : lines.length;
45
+ out[start.key] = lines.slice(start.line + 1, end).join('\n').trim() || null;
46
+ }
47
+ return out;
48
+ }
49
+ /**
50
+ * explain.md frontmatter에 task_commits를 쓴다. 파일이 없으면 false.
51
+ * 기존 배열은 통째로 교체한다 — finalize가 삭제 직전 스냅샷의 정본이다.
52
+ * 직렬화는 `{ task, sha, intent_anchor }`만 남긴다. legacy `{ id, sha }`는 쓰지 않는다.
53
+ * 부모 Epic·Blueprint를 못 읽거나 행의 stable ref가 그 번호와 다르면 그 행은
54
+ * 쓰지 않는다.
55
+ *
56
+ * @param {object} opts
57
+ * @param {string} opts.repoRoot - 저장소 루트
58
+ * @param {string} opts.blueprintDir - blueprint 상대 경로
59
+ * @param {TaskCommitEntry[]} opts.taskCommits - collectTaskCommits 결과
60
+ * @returns {boolean} 썼으면 true, explain 부재·파싱 실패면 false
61
+ */
62
+ function writeExplainTaskCommits({ repoRoot, blueprintDir, taskCommits }) {
63
+ const explainRel = `${toPosix(blueprintDir)}/explain.md`;
64
+ const abs = path.join(repoRoot, explainRel);
65
+ if (!fs.existsSync(abs))
66
+ return false;
67
+ const { data, body } = readDoc(abs);
68
+ if (!data || typeof data !== 'object')
69
+ return false;
70
+ const bouncer = asRecord(asRecord(data).bouncer);
71
+ const epicId = bouncer.epic_id;
72
+ const blueprintId = bouncer.blueprint_id;
73
+ // collectTaskCommits가 걸러도 직접 호출이면 다른 Epic·Blueprint 행이
74
+ // 들어온다. 부모 번호를 못 읽거나 다르면 새 행을 남기지 않는다.
75
+ if (typeof epicId !== 'string' || typeof blueprintId !== 'string') {
76
+ bouncer.task_commits = [];
77
+ }
78
+ else {
79
+ bouncer.task_commits = taskCommits.flatMap((entry) => {
80
+ const parts = taskStableParts(entry.task);
81
+ if (!parts || parts.epicKey !== epicId || parts.blueprintKey !== blueprintId)
82
+ return [];
83
+ return [{
84
+ task: entry.task,
85
+ sha: entry.sha,
86
+ intent_anchor: entry.intent_anchor,
87
+ }];
88
+ });
89
+ }
90
+ fs.writeFileSync(abs, renderDoc(data, body));
91
+ return true;
92
+ }
93
+ /**
94
+ * task 문서에서 장기 보존할 설계 절만 렌더링한다.
95
+ * parseTasksSections가 반환한 본문을 그대로 사용해 작성자가 나눈 줄바꿈을
96
+ * 보존한다. Do not touch·Checklist·verification·review는 실행 시점 범위
97
+ * 통제·절차라서 여기 넣지 않는다 — Git history와 삭제된 task 원문이 정본이다.
98
+ * Current/Target은 값이 있을 때만 넣어 legacy task에 두 절을 요구하지 않는다.
99
+ * 제목은 frontmatter로 stable ID를 만들 수 있으면 `### EPIC-…/BP-…/TASK-…`(번호 경로) 또는
100
+ * `### <epic-slug>/<bp-slug>/NNN`(slug 경로)이고,
101
+ * `commits`에 그 ID의 sha8이 있으면 ` · \`sha8\``을 붙인다. 못 만들면 기존
102
+ * `### Task NNN`으로 폴백한다.
103
+ *
104
+ * @param {TaskUnitLike[] | undefined} taskUnits - Blueprint의 task 묶음
105
+ * @param {Map<string, TrailerCommit>} [commits] - resolveTaskCommits 결과. 선택
106
+ * @returns {string} Explain `## Tasks` 본문. 장기 절이 없으면 빈 문자열
107
+ */
108
+ function buildTaskContext(taskUnits, commits) {
109
+ const units = (Array.isArray(taskUnits) ? taskUnits : [])
110
+ .filter((unit) => unit && unit.tasks && typeof unit.tasks.body === 'string')
111
+ .slice()
112
+ .sort((a, b) => (typeof a.number === 'number' ? a.number : Infinity)
113
+ - (typeof b.number === 'number' ? b.number : Infinity));
114
+ const rendered = [];
115
+ for (const unit of units) {
116
+ const sections = parseTasksSections(unit.tasks && unit.tasks.body);
117
+ // 순서는 Plan/intent 소비자가 기대하는 장기 allowlist와 같다.
118
+ // doNotTouch는 의도적으로 빠진다 — 후속 금지는 Interface·Constraints에 적는다.
119
+ const selected = [
120
+ ['Goal & intent', sections.goal],
121
+ ['Current behavior', sections.currentBehavior],
122
+ ['Target behavior', sections.targetBehavior],
123
+ ['Interface', sections.interface],
124
+ ['Touch', sections.touch],
125
+ ['Constraints', sections.constraints],
126
+ ].filter(([, body]) => typeof body === 'string' && body.trim());
127
+ if (!selected.length)
128
+ continue;
129
+ const stableId = stableIdFromTaskData(unit.tasks && unit.tasks.data);
130
+ let heading;
131
+ if (stableId) {
132
+ const hit = commits && commits.get(stableId);
133
+ const sha8 = hit && normalizeCommitSha(hit.sha8 || hit.sha);
134
+ // SHA가 있으면 통합 commit을 제목에 남긴다. 없으면 ID만 — trailer 해석
135
+ // 실패·미존재 시에도 제목 형식을 깨지 않기 위함.
136
+ heading = sha8 ? `### ${stableId} · \`${sha8}\`` : `### ${stableId}`;
137
+ }
138
+ else {
139
+ const number = typeof unit.number === 'number'
140
+ ? String(unit.number).padStart(3, '0')
141
+ : 'unknown';
142
+ heading = `### Task ${number}`;
143
+ }
144
+ rendered.push([heading, ...selected.flatMap(([h, body]) => [
145
+ `#### ${h}`,
146
+ body,
147
+ ])].join('\n\n'));
148
+ }
149
+ return rendered.length ? `## Tasks\n\n${rendered.join('\n\n')}\n` : '';
150
+ }
151
+ function replaceTaskContext(body, taskContext) {
152
+ if (!taskContext)
153
+ return body;
154
+ const lines = body.split('\n');
155
+ const start = lines.findIndex((line) => /^##\s+Tasks\s*$/i.test(line.trim()));
156
+ if (start < 0)
157
+ return `${body.replace(/\s*$/, '')}\n\n${taskContext}`;
158
+ let end = start + 1;
159
+ while (end < lines.length && !/^##\s+\S/.test(lines[end].trim()))
160
+ end += 1;
161
+ return [...lines.slice(0, start), taskContext.trimEnd(), ...lines.slice(end)].join('\n');
162
+ }
163
+ function writeExplainTaskContext({ repoRoot, blueprintDir, taskContext }) {
164
+ if (!taskContext)
165
+ return false;
166
+ const explainRel = `${toPosix(blueprintDir)}/explain.md`;
167
+ const abs = path.join(repoRoot, explainRel);
168
+ if (!fs.existsSync(abs))
169
+ return false;
170
+ const { data, body } = readDoc(abs);
171
+ if (!data || typeof data !== 'object' || typeof body !== 'string')
172
+ return false;
173
+ fs.writeFileSync(abs, renderDoc(data, replaceTaskContext(body, taskContext)));
174
+ return true;
175
+ }
176
+ module.exports = { writeExplainTaskCommits, buildTaskContext, writeExplainTaskContext };