@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,194 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { execFileSync: realExecFileSync } = require('node:child_process');
5
+ const layout = require("./layout");
6
+ const { CONTEXT_ROOT, isCanonicalBlueprintDir } = layout;
7
+ const contextIds = require("./context-ids");
8
+ const { parseContextPath } = contextIds;
9
+ const paths = require("./paths");
10
+ const { toPosix, parsePathIds } = paths;
11
+ const frontmatter = require("./frontmatter");
12
+ const { parseFrontmatter } = frontmatter;
13
+ const render = require("./render");
14
+ const { renderDoc } = render;
15
+ const runtimeState = require("./runtime-state");
16
+ const { readRuntimeCurrent, writeRuntimeCurrent, isWorktreeDirty } = runtimeState;
17
+ const tasksDocs = require("./tasks-docs");
18
+ const { expectedTaskDocIds } = tasksDocs;
19
+ const NUMBERED = /^tasks-(\d{3})\.md$/;
20
+ /** context 트리의 .md 절대 경로를 모은다. migrate-ids 제거 후 이 파일만 쓴다. */
21
+ function walkMarkdownFiles(absDir, out = []) {
22
+ if (!fs.existsSync(absDir))
23
+ return out;
24
+ let entries;
25
+ try {
26
+ entries = fs.readdirSync(absDir);
27
+ }
28
+ catch (_e) {
29
+ return out;
30
+ }
31
+ for (const name of entries) {
32
+ const abs = path.join(absDir, name);
33
+ let st;
34
+ try {
35
+ st = fs.statSync(abs);
36
+ }
37
+ catch (_e) {
38
+ continue;
39
+ }
40
+ if (st.isDirectory())
41
+ walkMarkdownFiles(abs, out);
42
+ else if (name.endsWith('.md'))
43
+ out.push(abs);
44
+ }
45
+ return out;
46
+ }
47
+ function legacyUnits(repoRoot) {
48
+ const units = [];
49
+ for (const abs of walkMarkdownFiles(path.join(repoRoot, CONTEXT_ROOT))) {
50
+ const name = path.basename(abs);
51
+ const matched = NUMBERED.exec(name);
52
+ if (name !== 'tasks.md' && !matched)
53
+ continue;
54
+ const blueprint = toPosix(path.relative(repoRoot, path.dirname(abs)));
55
+ // 이 이관은 번호 형식(`ddd-slug`) blueprint의 구 레이아웃만 대상이다. slug 형식은 처음부터 새 레이아웃이라
56
+ // 이관할 것이 없으므로 범위 밖이다. 형식 판정은 context-ids 한 곳에 맡긴다.
57
+ if (!isCanonicalBlueprintDir(blueprint) || parseContextPath(blueprint)?.format !== 'numeric')
58
+ continue;
59
+ units.push({
60
+ blueprint,
61
+ number: name === 'tasks.md' ? '001' : matched[1],
62
+ tasks: toPosix(path.relative(repoRoot, abs)),
63
+ });
64
+ }
65
+ return units.sort((a, b) => a.tasks.localeCompare(b.tasks));
66
+ }
67
+ function planTaskLayout(repoRoot) {
68
+ const units = legacyUnits(repoRoot);
69
+ const plan = [];
70
+ for (const unit of units) {
71
+ plan.push({ from: unit.tasks, to: `${unit.blueprint}/tasks/${unit.number}/tasks.md` });
72
+ }
73
+ const grouped = new Map();
74
+ for (const unit of units) {
75
+ grouped.set(unit.blueprint, [...(grouped.get(unit.blueprint) || []), unit]);
76
+ }
77
+ for (const [bp, entries] of grouped) {
78
+ for (const leaf of ['verification.md', 'review.md']) {
79
+ const from = `${bp}/${leaf}`;
80
+ if (fs.existsSync(path.join(repoRoot, from))) {
81
+ plan.push({
82
+ from,
83
+ to: `${bp}/tasks/${entries.sort((a, b) => a.number.localeCompare(b.number))[0].number}/${leaf}`,
84
+ });
85
+ }
86
+ }
87
+ }
88
+ return { units, plan };
89
+ }
90
+ function validateTaskLayout(repoRoot, plan, deps) {
91
+ const reasons = [];
92
+ const d = deps || {};
93
+ if (plan.units.length && (d.isWorktreeDirty || isWorktreeDirty)(repoRoot, (d.execFileSync || realExecFileSync))) {
94
+ reasons.push('dirty-worktree: commit or stash changes before apply');
95
+ }
96
+ for (const unit of plan.units) {
97
+ if (fs.existsSync(path.join(repoRoot, unit.blueprint, 'tasks'))) {
98
+ reasons.push(`mixed-layout: ${unit.blueprint}`);
99
+ }
100
+ }
101
+ for (const step of plan.plan) {
102
+ if (fs.existsSync(path.join(repoRoot, step.to))) {
103
+ reasons.push(`collision: destination already exists: ${step.to}`);
104
+ }
105
+ }
106
+ return { ok: reasons.length === 0, reasons };
107
+ }
108
+ function rewrite(rel, repoRoot, number) {
109
+ const abs = path.join(repoRoot, rel);
110
+ const { data, body } = parseFrontmatter(fs.readFileSync(abs, 'utf8'));
111
+ const doc = data;
112
+ doc.resource = rel;
113
+ // 묶음 안의 id는 디렉터리 번호에서 나온다. blueprint id를 쓰던 레거시 문서를
114
+ // 그대로 두면 옮긴 직후 S5로 스스로 거절당한다.
115
+ const ids = expectedTaskDocIds(number);
116
+ const byType = {
117
+ 'bouncer.tasks': ids.tasks,
118
+ 'bouncer.verification': ids.verification,
119
+ 'bouncer.review': ids.review,
120
+ };
121
+ // data.type이 키일 때만 id를 쓴다. bouncer 부재 시 예전처럼 TypeError.
122
+ if (byType[doc.type]) {
123
+ doc.bouncer.id = byType[doc.type];
124
+ }
125
+ fs.writeFileSync(abs, renderDoc(doc, body));
126
+ }
127
+ function pending(repoRoot, bp, number, kind) {
128
+ const { epicId, blueprintId } = parsePathIds(bp);
129
+ const ids = expectedTaskDocIds(number);
130
+ const rel = `${bp}/tasks/${number}/${kind}.md`;
131
+ const data = {
132
+ type: `bouncer.${kind}`,
133
+ title: `${number} ${kind}`,
134
+ description: `${kind} for ${number}`,
135
+ resource: rel,
136
+ tags: ['bouncer', kind],
137
+ timestamp: new Date().toISOString(),
138
+ bouncer: {
139
+ id: kind === 'verification' ? ids.verification : ids.review,
140
+ epic_id: epicId,
141
+ blueprint_id: blueprintId,
142
+ status: 'pending',
143
+ },
144
+ };
145
+ if (kind === 'review')
146
+ data.bouncer.review = { required: true };
147
+ fs.writeFileSync(path.join(repoRoot, rel), renderDoc(data, `# ${kind}\n\n## ${kind === 'review' ? 'Findings' : 'Command'}\n`));
148
+ return rel;
149
+ }
150
+ function migrateTaskLayout({ repoRoot, dryRun = false, deps }) {
151
+ const plan = planTaskLayout(repoRoot);
152
+ const checked = validateTaskLayout(repoRoot, plan, deps);
153
+ if (dryRun || !checked.ok) {
154
+ return {
155
+ ok: checked.ok,
156
+ dryRun,
157
+ plan: plan.plan,
158
+ moved: [],
159
+ rewritten: [],
160
+ pointer: null,
161
+ warnings: checked.reasons,
162
+ };
163
+ }
164
+ const d = deps || {};
165
+ const move = d.move || ((from, to) => (d.execFileSync || realExecFileSync)('git', ['mv', from, to], { cwd: repoRoot, stdio: 'ignore' }));
166
+ for (const step of plan.plan) {
167
+ fs.mkdirSync(path.dirname(path.join(repoRoot, step.to)), { recursive: true });
168
+ move(step.from, step.to);
169
+ }
170
+ const rewritten = [];
171
+ for (const unit of plan.units) {
172
+ for (const kind of ['tasks', 'verification', 'review']) {
173
+ const rel = `${unit.blueprint}/tasks/${unit.number}/${kind}.md`;
174
+ if (fs.existsSync(path.join(repoRoot, rel))) {
175
+ rewrite(rel, repoRoot, unit.number);
176
+ rewritten.push(rel);
177
+ }
178
+ else
179
+ rewritten.push(pending(repoRoot, unit.blueprint, unit.number, kind));
180
+ }
181
+ }
182
+ let pointer = null;
183
+ const current = readRuntimeCurrent({ repoRoot });
184
+ const match = current && plan.units.find((u) => u.blueprint === current.blueprint && u.tasks === current.task);
185
+ if (match) {
186
+ const task = `${match.blueprint}/tasks/${match.number}/tasks.md`;
187
+ writeRuntimeCurrent({
188
+ repoRoot, blueprint: current.blueprint, base: current.base, task,
189
+ });
190
+ pointer = { from: current.task, to: task };
191
+ }
192
+ return { ok: true, plan: plan.plan, moved: plan.plan, rewritten, pointer, warnings: [] };
193
+ }
194
+ module.exports = { migrateTaskLayout, planTaskLayout, validateTaskLayout };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+ // 경로 겹침 규칙의 정본. 문서 검증(validate-sections)이 소유하면 scope·dag-shape·
3
+ // execution-dag 같은 하위 모듈이 규칙 하나 때문에 검증 계층을 가져와야 한다.
4
+ // 그래서 I/O 없는 별도 모듈로 두고 validate-sections는 같은 이름으로 재export한다.
5
+ /**
6
+ * 두 경로가 같거나 한쪽이 다른 쪽의 조상 디렉터리인지 판정한다.
7
+ * 디렉터리 prefix도 교차로 본다. `scripts/` 금지가 `scripts/lib/x.js`를
8
+ * 통과시키면 scope 충돌 판정이 파일 단위 affected_paths를 놓친다.
9
+ *
10
+ * @param {string} a - 한쪽 경로(`./`·trailing `/`는 호출자가 미리 정규화한다)
11
+ * @param {string} b - 다른 쪽 경로
12
+ * @returns {boolean} 같거나 조상·자손 관계이면 true
13
+ */
14
+ function pathsOverlap(a, b) {
15
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
16
+ }
17
+ /**
18
+ * pathsOverlap에 넘기기 전에 경로 표기만 맞추는 정규화의 정본. 비교 알고리즘은 바꾸지 않는다.
19
+ * 문서·Checklist·원장은 `src/`·`./src/a.ts`·Windows식 `src\\a.ts`처럼 표기가 갈리는데,
20
+ * pathsOverlap은 그 형태를 조상으로 보지 못하므로 호출 전에 같은 모양으로 벗겨야 한다.
21
+ * 경로 겹침(execution-dag)과 scope 비교(finalize-digest)가 따로 정규화하면 한쪽만 고쳐
22
+ * 판정이 갈라지므로 이 함수 하나만 쓴다. 이 모듈은 import가 없어야 해서
23
+ * 역슬래시 변환(posix-path의 toPosix와 같은 규칙)도 여기에 직접 둔다.
24
+ *
25
+ * @param {string} raw - affected_paths/scope.paths 항목 또는 변경 경로
26
+ * @returns {string} 역슬래시를 `/`로 바꾸고 선행 `./`·말미 `/`(단독 `/` 제외)를 제거한 경로
27
+ */
28
+ function normalizeOverlapPath(raw) {
29
+ let p = raw.replace(/\\/g, '/');
30
+ // 1. 문서·lease가 상대경로를 `./foo`·`././foo`로 적는 경우 — 동일 경로를 놓치지 않게 접두만 반복 제거
31
+ while (p.startsWith('./'))
32
+ p = p.slice(2);
33
+ // 2. 디렉터리 표기 `src/` → `src`. bare `/`는 루트 의미가 있어 유지한다.
34
+ if (p.length > 1 && p.endsWith('/'))
35
+ p = p.slice(0, -1);
36
+ return p;
37
+ }
38
+ module.exports = { pathsOverlap, normalizeOverlapPath };
package/dist/paths.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+ const tasksDocs = require("./tasks-docs");
3
+ const posixPath = require("./posix-path");
4
+ const contextIds = require("./context-ids");
5
+ const { unitDocKind } = tasksDocs;
6
+ // 기존 31개 import 호환을 위해 posix-path 정본을 그대로 re-export한다.
7
+ const { toPosix } = posixPath;
8
+ const { parseContextPath } = contextIds;
9
+ const FILE_KIND = {
10
+ // verification.md / review.md / tasks.md 는 tasks-docs.unitDocKind 로만 판정.
11
+ // 문자열을 여기 두면 묶음 basename 정본이 갈라진다.
12
+ 'explain.md': 'explain',
13
+ // BP 루트 문서. task 묶음 basename이 아니므로 tasks-docs에 넣지 않는다.
14
+ 'context-review.md': 'context_review',
15
+ };
16
+ function isNumericContextId(id) {
17
+ return typeof id === 'string' && /^\d{3}$/.test(id);
18
+ }
19
+ function parsePathIds(resourcePath) {
20
+ const norm = toPosix(resourcePath);
21
+ // 세그먼트 해석은 context-ids 한 곳에서 한다. 번호 경로는 예전과 같은 key를,
22
+ // slug 경로는 slug key를 epicId/blueprintId 자리에 담는다.
23
+ const ids = parseContextPath(norm);
24
+ const epicId = ids ? ids.epicKey : null;
25
+ const blueprintId = ids ? ids.blueprintKey : null;
26
+ // pop()은 string | undefined라 인덱스에 바로 못 쓴다. split 결과는 항상
27
+ // 한 칸 이상이라 마지막 칸을 쓰면 런타임 값이 같고 타입만 닫힌다.
28
+ const segments = norm.split('/');
29
+ const base = segments[segments.length - 1];
30
+ let kind = FILE_KIND[base] || null;
31
+ // 루트·tasks/<NNN>/ 모두 basename 으로 kind 를 본다.
32
+ // tasks/002/tasks.md → tasks, …/verification.md → verification.
33
+ if (!kind)
34
+ kind = unitDocKind(base);
35
+ if (base === 'index.md') {
36
+ kind = blueprintId ? 'blueprint' : (epicId ? 'epic' : null);
37
+ }
38
+ return { epicId, blueprintId, kind };
39
+ }
40
+ function epicDirOf(blueprintDir) {
41
+ return toPosix(blueprintDir).split('/blueprints/')[0];
42
+ }
43
+ module.exports = {
44
+ parsePathIds, epicDirOf, toPosix, isNumericContextId,
45
+ };
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const crypto = require('node:crypto');
5
+ const layout = require("./layout");
6
+ const { isCanonicalBlueprintDir } = layout;
7
+ const paths = require("./paths");
8
+ const { toPosix, epicDirOf } = paths;
9
+ const frontmatter = require("./frontmatter");
10
+ const { readDoc } = frontmatter;
11
+ const render = require("./render");
12
+ const { renderDoc } = render;
13
+ const epicRegistry = require("./epic-registry");
14
+ const { readEpicRegistry } = epicRegistry;
15
+ const tasksDocs = require("./tasks-docs");
16
+ const { listTasksDocs } = tasksDocs;
17
+ const contextIds = require("./context-ids");
18
+ const schema = require("./schema");
19
+ const validateGatesPlan = require("./validate-gates-plan");
20
+ const { planPrerequisiteFailures, PREREQUISITE_NEXT } = validateGatesPlan;
21
+ const planPrerequisites = require("./plan-prerequisites");
22
+ const { prerequisiteFormatIssues } = planPrerequisites;
23
+ const { parseContextPath } = contextIds;
24
+ function fail(reason, cause, next) {
25
+ return { ok: false, reason, cause, next };
26
+ }
27
+ function isRecord(value) {
28
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
29
+ }
30
+ /**
31
+ * 같은 디렉터리 임시 파일에 쓴 뒤 rename한다. 쓰기 도중 죽어도 대상 파일은
32
+ * 옛 바이트 또는 새 바이트 중 하나로만 남는다. 실패하면 임시 파일을 지우고 재던진다.
33
+ *
34
+ * @param {string} abs - 대상 파일 절대 경로
35
+ * @param {string} text - 새 파일 내용
36
+ * @returns {void}
37
+ */
38
+ function atomicWriteFile(abs, text) {
39
+ // 임시 이름은 무작위 접미사 + 배타 생성('wx')이라 예측해 미리 심어 둔 파일이나
40
+ // 심볼릭 링크를 따라가 엉뚱한 곳에 쓰는 일이 없다(이미 있으면 EEXIST로 실패).
41
+ const tmp = path.join(path.dirname(abs), `.${path.basename(abs)}.${crypto.randomBytes(8).toString('hex')}.tmp`);
42
+ // 대상의 기존 권한을 보존한다. 임시 파일은 umask 기본 모드로 생기므로 rename 전에 맞춘다.
43
+ // stat 실패(대상 부재 등)는 호출자가 이미 readDoc으로 걸러 이쪽이 던지면 그대로 실패시킨다.
44
+ const mode = fs.statSync(abs).mode & 0o7777;
45
+ try {
46
+ fs.writeFileSync(tmp, text, { flag: 'wx', mode });
47
+ // writeFileSync의 mode는 umask에 깎이므로 chmod로 정확한 값을 되돌린다.
48
+ fs.chmodSync(tmp, mode);
49
+ fs.renameSync(tmp, abs);
50
+ }
51
+ catch (error) {
52
+ try {
53
+ fs.unlinkSync(tmp);
54
+ }
55
+ catch (unlinkError) {
56
+ // ENOENT만 흡수: write가 실패했거나 rename이 이미 옮겨 임시 파일이 없다.
57
+ if (!isRecord(unlinkError) || unlinkError.code !== 'ENOENT')
58
+ throw unlinkError;
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+ /**
64
+ * 한 문서를 읽어 `bouncer.status` 전이 계획을 만든다. 파일은 쓰지 않는다.
65
+ *
66
+ * @param {string} repoRoot - 저장소 루트 절대 경로
67
+ * @param {string} rel - 저장소 상대 POSIX 경로
68
+ * @param {string} target - 목표 상태(`approved`/`ready`)
69
+ * @returns {Plan | ApproveFail} 계획, 읽기·형태 실패면 `plan-approve-target`, 전이 불가 상태면 `plan-approve-status`
70
+ */
71
+ function planDoc(repoRoot, rel, target) {
72
+ const abs = path.join(repoRoot, rel);
73
+ let doc;
74
+ try {
75
+ doc = readDoc(abs);
76
+ }
77
+ catch (error) {
78
+ // 부재·권한·YAML 오류를 모두 "대상을 읽을 수 없음"으로 보고하되 원인 문구는 남긴다.
79
+ const detail = error instanceof Error ? error.message : String(error);
80
+ return fail('plan-approve-target', `${rel}: ${detail}`, 'create or repair the document, then retry');
81
+ }
82
+ const data = doc.data;
83
+ const bouncer = isRecord(data) ? data.bouncer : undefined;
84
+ if (!isRecord(data) || !isRecord(bouncer)) {
85
+ return fail('plan-approve-target', `${rel}: missing bouncer frontmatter`, 'repair the frontmatter, then retry');
86
+ }
87
+ const status = bouncer.status;
88
+ if (status !== 'draft' && status !== target) {
89
+ return fail('plan-approve-status', `${rel}: status is ${String(status)}, expected draft or ${target}`, 'only draft documents can be approved; resolve the status manually');
90
+ }
91
+ return { rel, abs, data, body: doc.body, target, needsWrite: status === 'draft' };
92
+ }
93
+ /**
94
+ * plan 승인 뒤 epic·blueprint·task의 `bouncer.status`를 한 번에 전이한다.
95
+ * 모든 대상을 먼저 읽고 판정한 뒤에만 쓰므로, 판정 거절에서는 파일이 바뀌지 않는다.
96
+ * 이미 목표 상태인 문서는 건드리지 않아 재실행이 멱등이다.
97
+ * 판정 뒤 쓰기 도중 I/O 실패는 `plan-approve-write`로 끝나며 이미 쓴 `changed`를 싣는다.
98
+ * 사용자 승인은 추론하지 않는다 — 호출 시점이 곧 plan 스킬의 승인 뒤라는 약속이다.
99
+ *
100
+ * @param {object} args - 인자 묶음
101
+ * @param {string} args.repoRoot - 저장소 루트 절대 경로
102
+ * @param {string} args.blueprintDir - 저장소 상대 blueprint 디렉터리
103
+ * @param {ApproveDeps} [args.deps] - 테스트 seam. `writeFile(abs, text)`는 한 파일의 원자 쓰기를 대신한다
104
+ * @returns {ApproveOk | ApproveFail} 성공이면 정렬된 changed·unchanged, 아니면 reason·cause·next
105
+ */
106
+ function approvePlan({ repoRoot, blueprintDir, deps = {} }) {
107
+ const writeFile = deps.writeFile || atomicWriteFile;
108
+ // 1. 경로 검증: 저장소 안 정본 blueprint 경로만 받는다. `..`/절대 경로는
109
+ // 정본 정규식이 이미 걸러내지만, 의도를 분명히 하려고 먼저 거절한다.
110
+ const bp = toPosix(blueprintDir).replace(/\/+$/, '');
111
+ if (bp === '' || path.isAbsolute(bp) || bp.split('/').includes('..') || !isCanonicalBlueprintDir(bp)) {
112
+ return fail('plan-approve-target', `${blueprintDir}: not a canonical blueprint directory inside the repository`, 'pass --blueprint .bouncer/context/epics/<ddd-name>/blueprints/<ddd-name>');
113
+ }
114
+ // 2. task 열거: 레거시·잘못된 디렉터리·깨진 frontmatter는 승인 대상을 확정할 수 없다.
115
+ const listing = listTasksDocs({ repoRoot, blueprintDir: bp });
116
+ const listingProblems = [];
117
+ if (listing.legacy)
118
+ listingProblems.push(`${bp}: legacy root task files present`);
119
+ for (const dir of listing.invalidDirs)
120
+ listingProblems.push(`${bp}/tasks/${dir}: invalid task directory`);
121
+ for (const entry of listing.entries) {
122
+ if (entry.executionKind === null)
123
+ listingProblems.push(`${entry.tasks.rel}: unreadable tasks.md frontmatter`);
124
+ }
125
+ if (listingProblems.length > 0) {
126
+ return fail('plan-approve-target', listingProblems, 'repair the task layout, then retry');
127
+ }
128
+ if (listing.entries.length === 0) {
129
+ return fail('plan-approve-target', `${bp}: no tasks/<NNN>/tasks.md documents`, 'scaffold tasks before approving');
130
+ }
131
+ // 3. 문서별 계획. blueprint를 먼저 읽어야 scale을 알고 context-review 검사를 가른다.
132
+ const bpPlan = planDoc(repoRoot, `${bp}/index.md`, 'approved');
133
+ if ('ok' in bpPlan)
134
+ return bpPlan;
135
+ // legacy epic의 draft blueprint는 승인하지 않는다(쓰기 전 거절). 이미 approved인 blueprint의
136
+ // 재승인은 진행 중 실행을 막지 않으려고 통과시킨다. 등록부 부재·invalid·미등록 epic은 기존 동작.
137
+ if (bpPlan.needsWrite) {
138
+ const epicLeaf = epicDirOf(bp).split('/').pop() || '';
139
+ // 번호 epic은 옛 번호(v1 id, v2 legacy_id)로, slug epic은 slug로 등록부 항목을 찾는다.
140
+ // 경로가 해석되지 않으면(비정규 이름) 등록부 대조를 건너뛰는 기존 동작을 유지한다.
141
+ const ids = parseContextPath(bp);
142
+ const registryRead = readEpicRegistry(repoRoot);
143
+ if (ids && registryRead.status === 'ok'
144
+ && registryRead.registry.epics.some((e) => e.kind === 'legacy'
145
+ && (ids.format === 'slug' ? e.slug === ids.epicKey : (e.legacy_id ?? e.id) === ids.epicKey))) {
146
+ return fail('plan-approve-legacy-epic', `${epicLeaf}: legacy epic takes no new blueprints`, 'plan the new blueprint under a registry epic (common or domain in .bouncer/epics.json)');
147
+ }
148
+ }
149
+ const epicPlan = planDoc(repoRoot, `${epicDirOf(bp)}/index.md`, 'approved');
150
+ if ('ok' in epicPlan)
151
+ return epicPlan;
152
+ const taskPlans = [];
153
+ for (const entry of listing.entries) {
154
+ const plan = planDoc(repoRoot, entry.tasks.rel, 'ready');
155
+ if ('ok' in plan)
156
+ return plan;
157
+ taskPlans.push(plan);
158
+ }
159
+ // 4. full blueprint는 context review가 accepted여야 한다. 표식 없는 light만 건너뛴다
160
+ // (부재는 full). 표식 light는 verifiability 리뷰를 받으므로 full과 같은 사유·next로 막는다.
161
+ // 잘못된 표식 값(null)은 S38이 이 gate 앞에서 이미 거절하므로 여기서는 legacy로 읽혀도 안전하다.
162
+ const bpBouncer = bpPlan.data.bouncer;
163
+ if (bpBouncer.scale !== 'light' || schema.planReviewContractOf(bpBouncer) === 'brief') {
164
+ const crRel = `${bp}/context-review.md`;
165
+ let crStatus;
166
+ try {
167
+ const crBouncer = readDoc(path.join(repoRoot, crRel)).data.bouncer;
168
+ crStatus = isRecord(crBouncer) ? crBouncer.status : undefined;
169
+ }
170
+ catch (_error) {
171
+ // 부재·파싱 실패는 "accepted 아님"으로만 접는다. 복구는 context-review 명령 소관이라
172
+ // 여기서는 거절 사유와 다음 행동만 알려준다.
173
+ crStatus = undefined;
174
+ }
175
+ if (crStatus !== 'accepted') {
176
+ return fail('plan-approve-context-review', `${crRel}: status is ${crStatus === undefined ? 'missing' : String(crStatus)}, expected accepted`, 'run bouncer context-review close until the review is accepted, then retry');
177
+ }
178
+ }
179
+ // 4b. 선언된 선행 blueprint가 현재 checkout에 병합돼 있어야 한다. draft·정식 plan gate와 같은
180
+ // helper라 세 경로의 G32 답이 같다. 어떤 문서도 쓰기 전에 거절해 거절 뒤 바이트가 그대로다.
181
+ // 형식 오류(HEAD·짧은 SHA·추가 키·비정본 경로·중복·자기참조)는 G32 helper의 entries에서 빠져
182
+ // 통과해 버리므로, 같은 형식 판정(S39)을 여기서 먼저 적용해 쓰기 전에 거절한다.
183
+ const formatIssues = prerequisiteFormatIssues(bpBouncer.prerequisites, bp);
184
+ if (formatIssues.length > 0) {
185
+ return fail('plan-approve-prerequisite', formatIssues.map((m) => `S39 ${m}`), PREREQUISITE_NEXT);
186
+ }
187
+ const prerequisiteFailures = planPrerequisiteFailures({
188
+ repoRoot, blueprintDir: bp, file: `${bp}/index.md`, exec: deps.exec,
189
+ });
190
+ if (prerequisiteFailures.length > 0) {
191
+ return fail('plan-approve-prerequisite', prerequisiteFailures.map((f) => `${f.code} ${f.message}`), PREREQUISITE_NEXT);
192
+ }
193
+ // 5. 판정 통과 — 이제부터만 쓴다. 순서는 epic → blueprint → tasks.
194
+ const all = [epicPlan, bpPlan, ...taskPlans];
195
+ const unchanged = all.filter((p) => !p.needsWrite).map((p) => p.rel).sort();
196
+ const changed = [];
197
+ for (const plan of all) {
198
+ if (!plan.needsWrite)
199
+ continue;
200
+ // 키 순서와 본문을 보존하려고 status 값만 제자리에서 바꾼다.
201
+ plan.data.bouncer.status = plan.target;
202
+ try {
203
+ writeFile(plan.abs, renderDoc(plan.data, plan.body));
204
+ }
205
+ catch (error) {
206
+ // 모든 쓰기 실패를 plan-approve-write로 접는 것은 안전하다: 판정이 이미 끝났고
207
+ // 재실행이 이미 쓴 문서를 unchanged로 넘기며 이어 끝내기 때문이다.
208
+ const detail = error instanceof Error ? error.message : String(error);
209
+ return {
210
+ ...fail('plan-approve-write', `${plan.rel}: ${detail}`, 'fix the I/O problem and rerun the same command'),
211
+ changed: [...changed].sort(),
212
+ };
213
+ }
214
+ changed.push(plan.rel);
215
+ }
216
+ return { ok: true, changed: changed.sort(), unchanged };
217
+ }
218
+ module.exports = { approvePlan };