@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.
- package/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/bouncer +26 -0
- package/bin/bouncer-root +25 -0
- package/bin/build-stamp.js +78 -0
- package/bin/register-path.js +71 -0
- package/dist/.build-stamp +1 -0
- package/dist/approval-snapshot.js +99 -0
- package/dist/bouncer-root.js +80 -0
- package/dist/canonical-json.js +37 -0
- package/dist/cli-commit-guard-command.js +74 -0
- package/dist/cli-config-command.js +47 -0
- package/dist/cli-context-review-command.js +273 -0
- package/dist/cli-current-command.js +374 -0
- package/dist/cli-dispatch-command.js +119 -0
- package/dist/cli-doc-commands.js +352 -0
- package/dist/cli-epic-command.js +130 -0
- package/dist/cli-flags.js +50 -0
- package/dist/cli-git-commands.js +931 -0
- package/dist/cli-intent-command.js +491 -0
- package/dist/cli-metrics-command.js +134 -0
- package/dist/cli-ontology-command.js +109 -0
- package/dist/cli-project-commands.js +580 -0
- package/dist/cli-retro-command.js +138 -0
- package/dist/cli-review-command.js +251 -0
- package/dist/cli-review-dispatch-command.js +338 -0
- package/dist/cli-usage.js +72 -0
- package/dist/cli.js +70 -0
- package/dist/codex-agents.js +189 -0
- package/dist/command-argv.js +125 -0
- package/dist/commit-guard.js +72 -0
- package/dist/commit-hook.js +470 -0
- package/dist/commit-message.js +77 -0
- package/dist/commit-sha.js +107 -0
- package/dist/commit.js +519 -0
- package/dist/composite-gates.js +129 -0
- package/dist/comprehension.js +127 -0
- package/dist/config.js +298 -0
- package/dist/context-ids.js +288 -0
- package/dist/context-migration-rewrite.js +421 -0
- package/dist/context-ontology.js +746 -0
- package/dist/context-review-contracts.js +2 -0
- package/dist/context-review-projection.js +147 -0
- package/dist/context-review-state.js +824 -0
- package/dist/context-review-store.js +602 -0
- package/dist/context-review-transitions.js +412 -0
- package/dist/coordinate-advance.js +253 -0
- package/dist/coordinate-next.js +998 -0
- package/dist/coordinate-output.js +57 -0
- package/dist/coordinator-command-contracts.js +2 -0
- package/dist/coordinator-commands.js +1908 -0
- package/dist/coordinator-fanin.js +1021 -0
- package/dist/coordinator-git.js +121 -0
- package/dist/coordinator-input.js +37 -0
- package/dist/coordinator-ledger.js +381 -0
- package/dist/coordinator-prepare.js +1047 -0
- package/dist/coordinator-repair.js +1416 -0
- package/dist/coordinator-verification.js +42 -0
- package/dist/coordinator-worker.js +388 -0
- package/dist/coordinator.js +7 -0
- package/dist/cross-drive.js +85 -0
- package/dist/current.js +1148 -0
- package/dist/dag-shape.js +93 -0
- package/dist/doc-shape-contracts.js +241 -0
- package/dist/drive-metrics.js +691 -0
- package/dist/epic-index.js +347 -0
- package/dist/epic-registry.js +453 -0
- package/dist/execute-prepare.js +262 -0
- package/dist/execution-contracts.js +46 -0
- package/dist/execution-dag.js +148 -0
- package/dist/finalize-checks.js +177 -0
- package/dist/finalize-collect.js +343 -0
- package/dist/finalize-comprehension.js +116 -0
- package/dist/finalize-digest-collect.js +388 -0
- package/dist/finalize-digest-model.js +595 -0
- package/dist/finalize-digest.js +164 -0
- package/dist/finalize-explain.js +176 -0
- package/dist/finalize-pr.js +610 -0
- package/dist/finalize-release-main.js +652 -0
- package/dist/finalize.js +714 -0
- package/dist/frontmatter.js +22 -0
- package/dist/git-ops.js +49 -0
- package/dist/graph-exec.js +179 -0
- package/dist/graph-scope.js +268 -0
- package/dist/graphify.js +995 -0
- package/dist/guards.js +82 -0
- package/dist/import-git.js +78 -0
- package/dist/import-history.js +392 -0
- package/dist/import-render.js +59 -0
- package/dist/import-types.js +2 -0
- package/dist/init.js +571 -0
- package/dist/intent-bundle.js +936 -0
- package/dist/intent-provenance.js +927 -0
- package/dist/layout.js +131 -0
- package/dist/lease.js +145 -0
- package/dist/legacy-comments.js +71 -0
- package/dist/migrate-context-slugs.js +262 -0
- package/dist/migrate-merge-legacy-epics.js +392 -0
- package/dist/migrate-review-scope.js +144 -0
- package/dist/migrate-task-layout.js +194 -0
- package/dist/path-overlap.js +38 -0
- package/dist/paths.js +45 -0
- package/dist/plan-approve.js +218 -0
- package/dist/plan-inspect.js +575 -0
- package/dist/plan-prerequisites.js +197 -0
- package/dist/plan-snapshot-contract.js +98 -0
- package/dist/plan-snapshot.js +138 -0
- package/dist/plugin-root.js +237 -0
- package/dist/posix-path.js +14 -0
- package/dist/pre-commit-hook.js +170 -0
- package/dist/print-dispatch.js +285 -0
- package/dist/render.js +8 -0
- package/dist/retention-migration.js +595 -0
- package/dist/retro-read.js +259 -0
- package/dist/retro-sources.js +318 -0
- package/dist/review-dispatch.js +1054 -0
- package/dist/review-policy.js +28 -0
- package/dist/review-record.js +447 -0
- package/dist/review-repair-state.js +52 -0
- package/dist/review-snapshot.js +659 -0
- package/dist/run-detach.js +165 -0
- package/dist/run-preflight.js +368 -0
- package/dist/runtime-state.js +1038 -0
- package/dist/scaffold.js +533 -0
- package/dist/schema.js +294 -0
- package/dist/scope.js +1073 -0
- package/dist/seed-worktree.js +926 -0
- package/dist/session-graph.js +337 -0
- package/dist/subagents.js +86 -0
- package/dist/symbol-index.js +1390 -0
- package/dist/task-brief-hash.js +34 -0
- package/dist/task-commits.js +124 -0
- package/dist/tasks-docs.js +192 -0
- package/dist/templates.js +469 -0
- package/dist/time.js +21 -0
- package/dist/validate-docs.js +302 -0
- package/dist/validate-gates-finalize.js +303 -0
- package/dist/validate-gates-plan.js +720 -0
- package/dist/validate-gates-run.js +420 -0
- package/dist/validate-gates-shared.js +124 -0
- package/dist/validate-gates.js +87 -0
- package/dist/validate-sections.js +654 -0
- package/dist/validate-structural.js +347 -0
- package/dist/validate.js +417 -0
- package/dist/verification-contracts.js +26 -0
- package/dist/verification-evidence.js +233 -0
- package/dist/verification-execution.js +163 -0
- package/dist/verification-identity.js +413 -0
- package/dist/verification-target.js +51 -0
- package/dist/verification.js +383 -0
- package/dist/windows-npm.js +38 -0
- package/package.json +32 -4
- package/resources/graphify-compat.json +9 -0
- package/vendor/README.md +23 -0
- package/vendor/js-yaml.LICENSE +21 -0
- package/vendor/js-yaml.js +3022 -0
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const layout = require("./layout");
|
|
5
|
+
const { CONTEXT_ROOT, normalizeRepoPath, isCanonicalEpicDir, isCanonicalBlueprintDir, isValidBlueprintOrder, listBlueprintOrders, } = layout;
|
|
6
|
+
const contextIds = require("./context-ids");
|
|
7
|
+
const { parseContextPath, validateSlug } = contextIds;
|
|
8
|
+
const paths = require("./paths");
|
|
9
|
+
const { parsePathIds, isNumericContextId } = paths;
|
|
10
|
+
const render = require("./render");
|
|
11
|
+
const { renderDoc } = render;
|
|
12
|
+
const frontmatter = require("./frontmatter");
|
|
13
|
+
const { parseFrontmatter } = frontmatter;
|
|
14
|
+
const templates = require("./templates");
|
|
15
|
+
const { templateBody, TEMPLATES } = templates;
|
|
16
|
+
const epicIndex = require("./epic-index");
|
|
17
|
+
const { ensureEpicIndexEntry, listEpicDirNames } = epicIndex;
|
|
18
|
+
const tasksDocs = require("./tasks-docs");
|
|
19
|
+
const { TASK_UNIT_BASENAMES, expectedTaskDocIds, } = tasksDocs;
|
|
20
|
+
const schema = require("./schema");
|
|
21
|
+
const { DEFAULT_COMMIT_TYPE, DEFAULT_SCALE, SCALE_ENUM, DEFAULT_DEPENDS_ON, DEFAULT_PARALLEL_SAFE, DEFAULT_DEPENDENCY_GATE, EXECUTION_KIND_ENUM, isValidDependsOn, } = schema;
|
|
22
|
+
const epicRegistry = require("./epic-registry");
|
|
23
|
+
const { readEpicRegistry } = epicRegistry;
|
|
24
|
+
const config = require("./config");
|
|
25
|
+
const { readVerifyPolicy } = config;
|
|
26
|
+
function writeRel(repoRoot, rel, data, body) {
|
|
27
|
+
const abs = path.join(repoRoot, rel);
|
|
28
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
29
|
+
fs.writeFileSync(abs, renderDoc(data, body));
|
|
30
|
+
return rel;
|
|
31
|
+
}
|
|
32
|
+
function bouncerDoc(type, title, description, resource, tags, timestamp, bouncer) {
|
|
33
|
+
return { type, title, description, resource, tags, timestamp, bouncer };
|
|
34
|
+
}
|
|
35
|
+
function requireNumericId(id, label) {
|
|
36
|
+
if (!isNumericContextId(id)) {
|
|
37
|
+
throw new Error(`${label} must be a zero-padded three-digit id (\\d{3}), got ${JSON.stringify(id)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 정본 epic 디렉터리의 key(번호 epic은 세 자리 id, slug epic은 slug)와 형식을 꺼낸다.
|
|
42
|
+
* 형식은 경로로만 판정한다 — 플래그나 등록부 버전으로 기존 epic의 형식을 바꾸지 않기 위함이다.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} canonicalEpicDir - isCanonicalEpicDir를 통과한 epic 디렉터리
|
|
45
|
+
* @returns {{ key: string, format: 'numeric' | 'slug' }} epic key와 형식
|
|
46
|
+
* @throws {Error} 경로를 해석하지 못할 때(호출자가 정본 검사를 건너뛴 경우)
|
|
47
|
+
*/
|
|
48
|
+
function epicKeyFromDir(canonicalEpicDir) {
|
|
49
|
+
const ids = parseContextPath(canonicalEpicDir);
|
|
50
|
+
if (!ids) {
|
|
51
|
+
throw new Error(`cannot derive epic id from ${canonicalEpicDir}`);
|
|
52
|
+
}
|
|
53
|
+
return { key: ids.epicKey, format: ids.format };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* blueprint index.md의 bouncer 블록을 읽는다.
|
|
57
|
+
* 판정을 못 하는 경우(파일 없음 / 프론트매터 파싱 실패 / 비객체)는 null —
|
|
58
|
+
* "신호가 없다"이지 "그렇다"가 아니다. 여기서 같이 죽으면 index.md가 아직 없는
|
|
59
|
+
* 저장소나 손상된 문서에서 scaffold 자체가 막힌다.
|
|
60
|
+
*/
|
|
61
|
+
function readBlueprintBouncer(repoRoot, blueprintDirRel) {
|
|
62
|
+
const abs = path.join(repoRoot, blueprintDirRel, 'index.md');
|
|
63
|
+
let data;
|
|
64
|
+
try {
|
|
65
|
+
({ data } = parseFrontmatter(fs.readFileSync(abs, 'utf8')));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// YAML은 unknown이다. falsy·비객체에서 멈추고, 객체일 때만 안을 읽는다.
|
|
71
|
+
if (!data || typeof data !== 'object')
|
|
72
|
+
return null;
|
|
73
|
+
const bouncer = 'bouncer' in data ? data.bouncer : undefined;
|
|
74
|
+
if (!bouncer || typeof bouncer !== 'object')
|
|
75
|
+
return null;
|
|
76
|
+
return bouncer;
|
|
77
|
+
}
|
|
78
|
+
/** blueprint index.md의 bouncer.status가 잠금(`closed`)인지 본다. */
|
|
79
|
+
function isClosedBlueprint(repoRoot, blueprintDirRel) {
|
|
80
|
+
const bouncer = readBlueprintBouncer(repoRoot, blueprintDirRel);
|
|
81
|
+
return Boolean(bouncer && bouncer.status === 'closed');
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* blueprint가 루트 리뷰 모드인지 본다.
|
|
85
|
+
* `review_scope === 'blueprint'`일 때만 true다. 필드 부재·읽기 실패는
|
|
86
|
+
* 구형 계약이므로 false — 여기서 true로 접으면 기존 blueprint에
|
|
87
|
+
* task `review.md`를 안 만들어 G8/S17이 풀린다.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
90
|
+
* @param {string} blueprintDirRel - blueprint 상대 경로
|
|
91
|
+
* @returns {boolean} 루트 리뷰 모드이면 true
|
|
92
|
+
*/
|
|
93
|
+
function isBlueprintReviewScope(repoRoot, blueprintDirRel) {
|
|
94
|
+
const bouncer = readBlueprintBouncer(repoRoot, blueprintDirRel);
|
|
95
|
+
return Boolean(bouncer && bouncer.review_scope === 'blueprint');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* blueprint가 선언한 scale을 돌려준다. 선언은 사람이 쓴 index.md의
|
|
99
|
+
* `bouncer.scale`뿐이다 — 경로 수·diff 크기로 추론하지 않는다.
|
|
100
|
+
* 알 수 없는 값이나 읽기 실패는 기본값(full)으로 떨어뜨린다: 문서 세트를
|
|
101
|
+
* 줄이는 쪽이 아니라 늘리는 쪽이 안전한 실패다.
|
|
102
|
+
*/
|
|
103
|
+
function blueprintScale(repoRoot, blueprintDirRel) {
|
|
104
|
+
const bouncer = readBlueprintBouncer(repoRoot, blueprintDirRel);
|
|
105
|
+
const scale = bouncer ? bouncer.scale : undefined;
|
|
106
|
+
return typeof scale === 'string' && SCALE_ENUM.includes(scale)
|
|
107
|
+
? scale
|
|
108
|
+
: DEFAULT_SCALE;
|
|
109
|
+
}
|
|
110
|
+
// blueprint area slug 형식. epic-registry의 slug 규칙과 같은 모양이다.
|
|
111
|
+
const AREA_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
112
|
+
/** 알 수 없는 scale은 파일을 하나도 쓰기 전에 거절한다. */
|
|
113
|
+
function requireScale(scale) {
|
|
114
|
+
if (scale === undefined)
|
|
115
|
+
return DEFAULT_SCALE;
|
|
116
|
+
if (typeof scale !== 'string' || !SCALE_ENUM.includes(scale)) {
|
|
117
|
+
throw new Error(`scale must be one of ${SCALE_ENUM.join(' | ')}, got ${JSON.stringify(scale)}`);
|
|
118
|
+
}
|
|
119
|
+
return scale;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* scale에 맞는 템플릿 이름. light는 `<base>-light.md`가 실제로 있을 때만
|
|
123
|
+
* 그 이름을 쓰고, 없으면 공용 템플릿으로 떨어진다 — full 본문을 바이트
|
|
124
|
+
* 단위로 유지하면서, light가 full과 같은 본문을 쓰는 문서(verification)는
|
|
125
|
+
* 사본을 두지 않기 위함. 사본을 두면 공용 템플릿만 고칠 때 light가 조용히
|
|
126
|
+
* 뒤처진다.
|
|
127
|
+
*/
|
|
128
|
+
function templateNameFor(base, scale) {
|
|
129
|
+
if (scale !== 'light')
|
|
130
|
+
return base;
|
|
131
|
+
const lightName = base.replace(/\.md$/, '-light.md');
|
|
132
|
+
return lightName in TEMPLATES ? lightName : base;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 새 epic 문서를 만들거나 기존 canonical epic의 파생 색인만 동기화한다.
|
|
136
|
+
* 신규 호출은 유효한 description 없이는 첫 파일도 쓰지 않으며, 재진입 시
|
|
137
|
+
* 전달된 description을 무시하고 기존 frontmatter를 정본으로 사용한다.
|
|
138
|
+
* `epicId`가 있으면 번호 epic(`epics/<ddd>-<name>/`), 없으면 slug epic(`epics/<name>/`)이다.
|
|
139
|
+
* 어느 형식으로 만들지는 호출자(CLI)가 등록부 버전으로 정해 넘긴다 — 이 함수는 입력대로만 쓴다.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} options - 저장소·식별자·저술 description
|
|
142
|
+
* @param {string} options.repoRoot - 저장소 루트 절대 경로
|
|
143
|
+
* @param {string} [options.epicId] - zero-padded epic id. 생략하면 slug epic
|
|
144
|
+
* @param {string} options.name - epic slug
|
|
145
|
+
* @param {string} options.timestamp - 생성 시각
|
|
146
|
+
* @param {unknown} options.description - 신규 epic의 필수 저술 description
|
|
147
|
+
* @returns {string[]} 생성·갱신한 상대 경로; 완전한 no-op이면 빈 배열
|
|
148
|
+
* @throws {Error} epicId 형식 오류, slug epic의 name이 slug 규칙을 어길 때, description 거절, 번호 충돌
|
|
149
|
+
*/
|
|
150
|
+
function scaffoldEpic({ repoRoot, epicId, name, timestamp, description }) {
|
|
151
|
+
const isSlugEpic = epicId === undefined;
|
|
152
|
+
if (isSlugEpic) {
|
|
153
|
+
const violation = validateSlug(name);
|
|
154
|
+
if (violation !== null)
|
|
155
|
+
throw new Error(`epic name must be a valid slug: ${violation}`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
requireNumericId(epicId, 'epicId');
|
|
159
|
+
}
|
|
160
|
+
// 아래 메시지·placeholder 판정이 쓰는 이름표: 번호 epic은 id, slug epic은 slug 자체.
|
|
161
|
+
const epicKey = isSlugEpic ? name : epicId;
|
|
162
|
+
const dirName = isSlugEpic ? name : `${epicId}-${name}`;
|
|
163
|
+
const epicAbs = path.join(repoRoot, CONTEXT_ROOT, 'epics', dirName, 'index.md');
|
|
164
|
+
if (!fs.existsSync(epicAbs)
|
|
165
|
+
&& (typeof description !== 'string' || !description.trim() || description.trim() === `Epic ${epicKey}`)) {
|
|
166
|
+
throw new Error(`epic description must be a non-placeholder string for ${epicKey}`);
|
|
167
|
+
}
|
|
168
|
+
// 규칙 출처는 references/spec-authoring의 "description in English ASCII"다. 문서에만
|
|
169
|
+
// 있으면 한국어로 scaffold한 뒤에야 고치게 되므로 생성 시점에 거부한다. 새 epic 블록
|
|
170
|
+
// 안에만 두어 한국어 description을 가진 기존 epic의 재실행(넘긴 값 무시)은 건드리지
|
|
171
|
+
// 않고, trim 전 원문을 검사해 탭·개행·NBSP 같은 공백류도 잡는다. 위 placeholder
|
|
172
|
+
// 검사 뒤에 두어 기존 거부 문구와 순서를 보존한다.
|
|
173
|
+
if (!fs.existsSync(epicAbs) && /[^\x20-\x7E]/.test(description)) {
|
|
174
|
+
throw new Error(`epic description must be printable ASCII (0x20-0x7E) for ${epicKey}`);
|
|
175
|
+
}
|
|
176
|
+
const dir = `${CONTEXT_ROOT}/epics/${dirName}`;
|
|
177
|
+
// 번호는 다른 slug로 다시 쓰면 새 디렉터리에 그대로 써지고, 경로에서 id를
|
|
178
|
+
// 파생하는 S5도 통과한다. 그래서 같은 번호를 쓰는 epic이 둘 생겨도 아무
|
|
179
|
+
// 단계에서 걸리지 않는다(024가 그랬다). 같은 이름의 재실행은 종전대로 두고,
|
|
180
|
+
// 다른 slug가 번호를 재사용하는 경우만 쓰기 전에 거절한다. slug epic은 디렉터리 이름 자체가
|
|
181
|
+
// 식별자라 같은 이름이면 곧 재진입이고 충돌이 없다.
|
|
182
|
+
if (!isSlugEpic) {
|
|
183
|
+
const conflicting = listEpicDirNames(repoRoot)
|
|
184
|
+
.filter((entry) => entry.slice(0, 3) === epicId && entry !== dirName);
|
|
185
|
+
if (conflicting.length > 0) {
|
|
186
|
+
throw new Error(`epic id ${epicId} is already used by ${conflicting.join(', ')} — pick an unused id`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const rel = `${dir}/index.md`;
|
|
190
|
+
const canonicalEpicAbs = path.join(repoRoot, rel);
|
|
191
|
+
const authoredDescription = typeof description === 'string' ? description.trim() : '';
|
|
192
|
+
const created = [];
|
|
193
|
+
if (!fs.existsSync(canonicalEpicAbs)) {
|
|
194
|
+
const data = bouncerDoc('bouncer.epic', isSlugEpic ? name : `${epicId} ${name}`, authoredDescription, rel, ['bouncer', 'epic'], timestamp,
|
|
195
|
+
// supersedes는 epic·blueprint 전용. 빈 배열은 "자리만" — 값은 사람이 채운다.
|
|
196
|
+
{ id: epicKey, epic_id: epicKey, status: 'draft', supersedes: [] });
|
|
197
|
+
const body = templateBody('epic.md', { epicId: epicKey, name });
|
|
198
|
+
created.push(writeRel(repoRoot, rel, data, body));
|
|
199
|
+
}
|
|
200
|
+
// OKF §6 번들 루트 목록 — scaffold가 소유. 이미 있으면 no-op.
|
|
201
|
+
const indexRel = ensureEpicIndexEntry({
|
|
202
|
+
repoRoot,
|
|
203
|
+
epicId,
|
|
204
|
+
name,
|
|
205
|
+
description: authoredDescription || undefined,
|
|
206
|
+
});
|
|
207
|
+
if (indexRel)
|
|
208
|
+
created.push(indexRel);
|
|
209
|
+
return created;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* 기존 blueprint 에 tasks/<NNN>/ 묶음을 추가한다. commit task는 기본적으로
|
|
213
|
+
* 세 문서, verification task는 review.md 없이 두 문서를 쓰고 tasks.md 본문도
|
|
214
|
+
* 경로 없는 Touch를 가진 `verification-tasks.md`에서 가져온다.
|
|
215
|
+
* blueprint `index.md`의 `bouncer.review_scope`가 `blueprint`이면 commit
|
|
216
|
+
* 묶음도 루트 리뷰가 판정 근거이므로 task `review.md`를 쓰지 않는다.
|
|
217
|
+
* 거절 조건을 모두 검사한 뒤에만 파일을 쓴다 — 일부만 생성된 상태를 남기지 않기 위함.
|
|
218
|
+
* closed blueprint, 잘못된 id·scale·executionKind, verification의 빈 dependsOn·
|
|
219
|
+
* 무효 verify, 이미 있는 task 디렉터리는 파일을 쓰기 전에 Error로 거절한다.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} options - 저장소·식별자·실행 종류 입력
|
|
222
|
+
* @param {string} options.repoRoot - 저장소 루트 절대 경로
|
|
223
|
+
* @param {string} options.blueprintDir - `.bouncer/context/epics/...` 아래 blueprint 상대 경로
|
|
224
|
+
* @param {string} options.taskId - zero-padded task 번호
|
|
225
|
+
* @param {string} options.timestamp - 생성 시각
|
|
226
|
+
* @param {string} [options.scale] - light | full. 생략하면 blueprint가 선언한 값을 따른다
|
|
227
|
+
* @param {unknown} [options.executionKind] - commit | verification. 기본 commit
|
|
228
|
+
* @param {unknown} [options.dependsOn] - verification 선행 TASKS-NNN 배열
|
|
229
|
+
* @param {unknown} [options.verify] - verification 단일 실행 명령
|
|
230
|
+
* @param {readonly string[]} [options.verifyAllowlist] - 테스트용 allowlist. 생략하면 config에서 읽는다
|
|
231
|
+
* @returns {string[]} 생성한 문서의 저장소 상대 경로 (tasks.md, verification.md[, review.md])
|
|
232
|
+
*/
|
|
233
|
+
function scaffoldTask({ repoRoot, blueprintDir, taskId, timestamp, scale, executionKind = 'commit', dependsOn, verify, verifyAllowlist, }) {
|
|
234
|
+
if (!isCanonicalBlueprintDir(blueprintDir)) {
|
|
235
|
+
throw new Error(`blueprintDir must be under ${CONTEXT_ROOT}/epics`);
|
|
236
|
+
}
|
|
237
|
+
requireNumericId(taskId, 'taskId');
|
|
238
|
+
const bp = normalizeRepoPath(blueprintDir);
|
|
239
|
+
const { epicId, blueprintId } = parsePathIds(bp);
|
|
240
|
+
if (!epicId || !blueprintId) {
|
|
241
|
+
throw new Error(`cannot derive epic/blueprint ids from ${bp}`);
|
|
242
|
+
}
|
|
243
|
+
// finalize가 잠근 blueprint는 다시 열지 않는다. 파일을 쓰기 전에 거절해
|
|
244
|
+
// tasks/<NNN>/ 가 남지 않게 한다. cli.ts의 기존 catch가 이 메시지를
|
|
245
|
+
// `scaffold: <메시지>` + exit 2 로 옮긴다.
|
|
246
|
+
if (isClosedBlueprint(repoRoot, bp)) {
|
|
247
|
+
throw new Error(`blueprint is closed (finalized): ${bp} — scaffold a new blueprint instead of adding a task to this one`);
|
|
248
|
+
}
|
|
249
|
+
if (!EXECUTION_KIND_ENUM.includes(executionKind)) {
|
|
250
|
+
throw new Error('executionKind must be commit or verification');
|
|
251
|
+
}
|
|
252
|
+
const isVerification = executionKind === 'verification';
|
|
253
|
+
if (isVerification) {
|
|
254
|
+
if (!isValidDependsOn(dependsOn) || !Array.isArray(dependsOn) || dependsOn.length === 0) {
|
|
255
|
+
throw new Error('verification dependsOn must be a non-empty TASKS-NNN array');
|
|
256
|
+
}
|
|
257
|
+
// 구조 검사와 같은 executable 판정을 재사용해 scaffold가 쓰자마자 S29에
|
|
258
|
+
// 걸리는 반쪽 node를 만들지 않는다. 로드는 호출 시점으로 늦춰 startup 순환을 피한다.
|
|
259
|
+
const { isValidVerifyCommand } = require('./verification');
|
|
260
|
+
const policy = verifyAllowlist === undefined ? readVerifyPolicy(repoRoot) : null;
|
|
261
|
+
if (policy && policy.ok === false) {
|
|
262
|
+
throw new Error('verification config is invalid');
|
|
263
|
+
}
|
|
264
|
+
const allowlist = verifyAllowlist === undefined
|
|
265
|
+
? policy.allowlist
|
|
266
|
+
: verifyAllowlist;
|
|
267
|
+
if (!isValidVerifyCommand(verify, allowlist)) {
|
|
268
|
+
throw new Error('verification verify must be a single executable command');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const taskDir = `${bp}/tasks/${taskId}`;
|
|
272
|
+
const absTaskDir = path.join(repoRoot, taskDir);
|
|
273
|
+
// 덮어쓰기 금지 — 존재하면 즉시 거절 (파일 쓰기 전).
|
|
274
|
+
if (fs.existsSync(absTaskDir)) {
|
|
275
|
+
throw new Error(`task directory already exists: ${taskDir}`);
|
|
276
|
+
}
|
|
277
|
+
const ids = expectedTaskDocIds(taskId);
|
|
278
|
+
// 호출자가 scale을 주지 않으면 blueprint가 선언한 값을 따른다. 나중에 붙는
|
|
279
|
+
// task도 같은 blueprint 계약을 쓰게 하려는 것 — 새 플래그를 만들지 않는다.
|
|
280
|
+
const taskScale = requireScale(scale === undefined ? blueprintScale(repoRoot, bp) : scale);
|
|
281
|
+
const body = (templateName) => templateBody(templateNameFor(templateName, taskScale), { epicId, blueprintId, name: taskId });
|
|
282
|
+
const [tasksBase, verifyBase, reviewBase] = TASK_UNIT_BASENAMES;
|
|
283
|
+
const tasksRel = `${taskDir}/${tasksBase}`;
|
|
284
|
+
const verifyRel = `${taskDir}/${verifyBase}`;
|
|
285
|
+
const reviewRel = `${taskDir}/${reviewBase}`;
|
|
286
|
+
// mkdir 포함 쓰기는 검사 통과 후에만. writeRel 이 dirname 을 만든다.
|
|
287
|
+
const created = [];
|
|
288
|
+
created.push(writeRel(repoRoot, tasksRel, bouncerDoc('bouncer.tasks', `${taskId} tasks`, `Tasks for ${taskId}`, tasksRel, ['bouncer', 'tasks'], timestamp, isVerification ? {
|
|
289
|
+
id: ids.tasks, epic_id: epicId, blueprint_id: blueprintId, status: 'draft',
|
|
290
|
+
execution_kind: 'verification',
|
|
291
|
+
depends_on: dependsOn,
|
|
292
|
+
parallel_safe: false,
|
|
293
|
+
dependency_gate: 'integrated',
|
|
294
|
+
verify,
|
|
295
|
+
affected_paths: [],
|
|
296
|
+
} : {
|
|
297
|
+
id: ids.tasks, epic_id: epicId, blueprint_id: blueprintId, status: 'draft',
|
|
298
|
+
execution_kind: 'commit',
|
|
299
|
+
// DAG 기본값: 의존 없음·순차·선행 integrated 해제.
|
|
300
|
+
// 기존 문서의 필드 부재도 같은 의미로 읽히므로 호환이 유지된다.
|
|
301
|
+
depends_on: [...DEFAULT_DEPENDS_ON],
|
|
302
|
+
parallel_safe: DEFAULT_PARALLEL_SAFE,
|
|
303
|
+
dependency_gate: DEFAULT_DEPENDENCY_GATE,
|
|
304
|
+
affected_paths: [],
|
|
305
|
+
// 빈 YAML 리스트로 자리를 잡아 `commit_intent: 문장` 스칼라를 쓰지 않게 한다.
|
|
306
|
+
// 승인 전에 `- ` 항목 1–2개를 채운다. 커밋은 한국어 종결형을 그때 검사한다.
|
|
307
|
+
commit_intent: [],
|
|
308
|
+
commit_summary: [],
|
|
309
|
+
}),
|
|
310
|
+
// verification은 commit 본문의 백틱 경로 Touch 표를 물려받으면 G20에 걸린다.
|
|
311
|
+
// light에는 `-light` 사본이 없어 templateNameFor가 같은 본문으로 떨어진다.
|
|
312
|
+
body(isVerification ? 'verification-tasks.md' : tasksBase)));
|
|
313
|
+
created.push(writeRel(repoRoot, verifyRel, bouncerDoc('bouncer.verification', `${taskId} verification`, `Verification for ${taskId}`, verifyRel, ['bouncer', 'verification'], timestamp, { id: ids.verification, epic_id: epicId, blueprint_id: blueprintId, status: 'pending' }), body(verifyBase)));
|
|
314
|
+
// 루트 리뷰 모드에서는 task 묶음에 리뷰 leaf를 두면 게이트가 루트와
|
|
315
|
+
// task를 동시에 읽게 된다. 필드 부재(구형)만 세 문서를 유지한다.
|
|
316
|
+
if (!isVerification && !isBlueprintReviewScope(repoRoot, bp)) {
|
|
317
|
+
created.push(writeRel(repoRoot, reviewRel, bouncerDoc('bouncer.review', `${taskId} review`, `Review for ${taskId}`, reviewRel, ['bouncer', 'review'], timestamp, {
|
|
318
|
+
id: ids.review, epic_id: epicId, blueprint_id: blueprintId, status: 'pending',
|
|
319
|
+
review: { required: true },
|
|
320
|
+
}), body(reviewBase)));
|
|
321
|
+
}
|
|
322
|
+
return created;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* 새 blueprint 문서 세트를 만든다. full·light 모두 `review_scope: blueprint`와
|
|
326
|
+
* 루트 `review.md` 하나를 쓰고, 첫 task 묶음은 tasks.md·verification.md만
|
|
327
|
+
* 갖는다. 이미 있는 루트 `review.md`는 덮어쓰지 않는다.
|
|
328
|
+
* 알 수 없는 scale은 첫 파일도 쓰기 전에 거절한다.
|
|
329
|
+
* 형식은 epicDir 경로로 정한다. 번호 epic은 `blueprintId`(세 자리)로 `<ddd>-<name>/`을 만들고,
|
|
330
|
+
* slug epic은 `order`(양의 정수, epic 안에서 유일)로 순서를 정해 `<name>/`을 만든다.
|
|
331
|
+
* 형식에 맞지 않는 입력(`--id`/`--order` 뒤바뀜 포함)은 파일을 쓰기 전에 거절한다.
|
|
332
|
+
*
|
|
333
|
+
* @param {object} options - 저장소·식별자·scale 입력
|
|
334
|
+
* @param {string} options.repoRoot - 저장소 루트 절대 경로
|
|
335
|
+
* @param {string} options.epicDir - canonical epic 상대 경로
|
|
336
|
+
* @param {string} [options.blueprintId] - zero-padded blueprint 번호. 번호 epic에서만 쓴다
|
|
337
|
+
* @param {number} [options.order] - blueprint 순서. slug epic에서만 쓴다
|
|
338
|
+
* @param {string} options.name - blueprint slug
|
|
339
|
+
* @param {string} options.timestamp - 생성 시각
|
|
340
|
+
* @param {string} [options.scale] - light | full. 생략은 full
|
|
341
|
+
* @param {string} [options.area] - 귀속 area slug. 생략하면 frontmatter에 키를 두지 않는다
|
|
342
|
+
* @param {string} [options.attribution] - 귀속 근거 한 줄. 생략하면 키를 두지 않는다
|
|
343
|
+
* @returns {string[]} 생성한 문서의 저장소 상대 경로
|
|
344
|
+
* @throws {Error} 정본이 아닌 epicDir, 형식과 맞지 않는 id/order, 잘못된 order·slug·scale·area·attribution,
|
|
345
|
+
* 이미 쓰인 order·blueprint, legacy epic
|
|
346
|
+
*/
|
|
347
|
+
function scaffoldBlueprint({ repoRoot, epicDir, blueprintId, order, name, timestamp, scale, area, attribution, }) {
|
|
348
|
+
// scripts/가 scale을 읽는 네 곳: scaffoldBlueprint는 문서 집합을 고르고,
|
|
349
|
+
// scaffoldTask는 이후 task에 blueprint가 선언한 scale을 상속하며, plan gate는
|
|
350
|
+
// light 계약(작성 규칙과 문서 집합)을, 구조 검증(S20)은 enum 값을 검사한다. 모두 선언된
|
|
351
|
+
// bouncer.scale만 읽고 크기를 추론하지 않는다.
|
|
352
|
+
if (!isCanonicalEpicDir(epicDir)) {
|
|
353
|
+
throw new Error(`epicDir must be under ${CONTEXT_ROOT}/epics`);
|
|
354
|
+
}
|
|
355
|
+
const canonicalEpicDir = normalizeRepoPath(epicDir);
|
|
356
|
+
const { key: epicId, format } = epicKeyFromDir(canonicalEpicDir);
|
|
357
|
+
// 형식별 식별자 검사. 번호 epic은 --id(세 자리), slug epic은 --order(양의 정수)만 받고 서로를 거절해
|
|
358
|
+
// 한쪽 형식의 입력이 다른 형식의 디렉터리를 조용히 만드는 일이 없게 한다.
|
|
359
|
+
let blueprintKey;
|
|
360
|
+
if (format === 'slug') {
|
|
361
|
+
if (blueprintId !== undefined) {
|
|
362
|
+
throw new Error(`slug epic ${epicId} takes --order <n> instead of --id; got id ${JSON.stringify(blueprintId)}`);
|
|
363
|
+
}
|
|
364
|
+
if (!isValidBlueprintOrder(order)) {
|
|
365
|
+
throw new Error(`order must be a positive integer, got ${JSON.stringify(order)}`);
|
|
366
|
+
}
|
|
367
|
+
const violation = validateSlug(name);
|
|
368
|
+
if (violation !== null)
|
|
369
|
+
throw new Error(`blueprint name must be a valid slug: ${violation}`);
|
|
370
|
+
const taken = listBlueprintOrders(repoRoot, canonicalEpicDir).find((entry) => entry.order === order);
|
|
371
|
+
if (taken) {
|
|
372
|
+
throw new Error(`order ${order} is already used by ${taken.name} in ${epicId}; pick an unused order`);
|
|
373
|
+
}
|
|
374
|
+
if (fs.existsSync(path.join(repoRoot, canonicalEpicDir, 'blueprints', name))) {
|
|
375
|
+
throw new Error(`blueprint already exists: ${canonicalEpicDir}/blueprints/${name}`);
|
|
376
|
+
}
|
|
377
|
+
blueprintKey = name;
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
if (order !== undefined) {
|
|
381
|
+
throw new Error(`numbered epic ${epicId} takes --id <ddd> instead of --order; got order ${JSON.stringify(order)}`);
|
|
382
|
+
}
|
|
383
|
+
requireNumericId(blueprintId, 'blueprintId');
|
|
384
|
+
blueprintKey = blueprintId;
|
|
385
|
+
}
|
|
386
|
+
// 알 수 없는 scale은 첫 파일을 쓰기 전에 거절한다 — 반쯤 만들어진
|
|
387
|
+
// blueprint 디렉터리를 남기지 않기 위함.
|
|
388
|
+
const bpScale = requireScale(scale);
|
|
389
|
+
// area·attribution도 같은 이유로 첫 파일 전에 모양을 거절한다. 두 값은 선택이라
|
|
390
|
+
// 생략은 키 부재(기존 frontmatter와 동일)이고, 있는 값만 S36과 같은 규칙으로 검사한다.
|
|
391
|
+
if (area !== undefined && !AREA_SLUG_RE.test(area)) {
|
|
392
|
+
throw new Error(`area must be a lowercase slug, got ${JSON.stringify(area)}`);
|
|
393
|
+
}
|
|
394
|
+
if (attribution !== undefined && (attribution.trim() === '' || /[\r\n]/.test(attribution))) {
|
|
395
|
+
throw new Error('attribution must be a non-empty single line');
|
|
396
|
+
}
|
|
397
|
+
// legacy epic에는 새 blueprint를 만들지 않는다. 등록부를 한 번 읽고 첫 파일을 쓰기 전에 거절한다.
|
|
398
|
+
// 등록부 부재·invalid(형식 위반은 S35 소관)와 미등록 epic은 기존 동작을 유지한다.
|
|
399
|
+
// 번호 epic은 옛 번호(v1 id, v2 legacy_id)로, slug epic은 slug로 항목을 찾는다.
|
|
400
|
+
const registryRead = readEpicRegistry(repoRoot);
|
|
401
|
+
if (registryRead.status === 'ok'
|
|
402
|
+
&& registryRead.registry.epics.some((e) => e.kind === 'legacy'
|
|
403
|
+
&& (format === 'slug' ? e.slug === epicId : (e.legacy_id ?? e.id) === epicId))) {
|
|
404
|
+
throw new Error(`legacy epic ${canonicalEpicDir.split('/').pop()} takes no new blueprints; plan under a registry epic`);
|
|
405
|
+
}
|
|
406
|
+
const dir = format === 'slug'
|
|
407
|
+
? `${canonicalEpicDir}/blueprints/${name}`
|
|
408
|
+
: `${canonicalEpicDir}/blueprints/${blueprintKey}-${name}`;
|
|
409
|
+
const created = [];
|
|
410
|
+
// 템플릿 자리표시자 <BP-id>는 형식의 blueprint key(번호 또는 slug)로 채운다.
|
|
411
|
+
const body = (templateName) => templateBody(templateNameFor(templateName, bpScale), { epicId, blueprintId: blueprintKey, name });
|
|
412
|
+
const idx = `${dir}/index.md`;
|
|
413
|
+
// commit_type·scale은 blueprint 전용(epic/tasks 등에는 쓰지 않음).
|
|
414
|
+
// supersedes는 epic·blueprint 공용 — 빈 배열로 자리만 잡고 값은 사람이 채운다.
|
|
415
|
+
// status 뒤에 두어 frontmatter 키 순서를 고정한다. 경량 선언은 plan이
|
|
416
|
+
// scale을 light로 바꾸고, 되돌릴 때는 full로 되돌린다(키 삭제 아님).
|
|
417
|
+
created.push(writeRel(repoRoot, idx, bouncerDoc('bouncer.blueprint', format === 'slug' ? name : `${blueprintKey} ${name}`, `Blueprint ${blueprintKey}`, idx, ['bouncer', 'blueprint'], timestamp, {
|
|
418
|
+
id: blueprintKey,
|
|
419
|
+
epic_id: epicId,
|
|
420
|
+
blueprint_id: blueprintKey,
|
|
421
|
+
status: 'draft',
|
|
422
|
+
// slug 형식은 디렉터리에 번호가 없으므로 순서를 frontmatter가 가진다. status 바로 뒤에 둬
|
|
423
|
+
// 사람이 읽을 때 식별자 묶음 다음에 순서가 오게 한다.
|
|
424
|
+
...(format === 'slug' ? { order } : {}),
|
|
425
|
+
commit_type: DEFAULT_COMMIT_TYPE,
|
|
426
|
+
scale: bpScale,
|
|
427
|
+
// 새 blueprint는 task별 리뷰 대신 루트 review.md 하나다.
|
|
428
|
+
// 기존 문서의 필드 부재는 구형 계약으로 남긴다(여기서 소급하지 않음).
|
|
429
|
+
review_scope: 'blueprint',
|
|
430
|
+
// 새 계획은 구현 task별 전용 명령 + 마지막 verification node 계약을 쓴다.
|
|
431
|
+
// 표식이 없는 기존 blueprint는 legacy로 남으므로 소급하지 않는다. 표식의
|
|
432
|
+
// 의미(명령 누락 거절·terminal topology)는 plan gate가 판정한다.
|
|
433
|
+
verification_policy: 'scoped_terminal',
|
|
434
|
+
// 새 계획은 full·light 모두 브리프 계약 표식을 받는다. full은 4관점 plan 리뷰와
|
|
435
|
+
// 브리프 이행 최종 리뷰를, light는 verifiability 1관점 plan 리뷰를 받는다(run
|
|
436
|
+
// 최종 리뷰는 reviewContractOf가 light를 legacy로 읽어 지금과 같다). 기존 문서는 소급하지 않는다.
|
|
437
|
+
review_contract: 'brief',
|
|
438
|
+
// 귀속 기록은 선택 키다. 필수 판정은 plan gate가 맡고 여기서는 값을 쓰기만 한다.
|
|
439
|
+
...(area === undefined ? {} : { area }),
|
|
440
|
+
...(attribution === undefined ? {} : { attribution }),
|
|
441
|
+
supersedes: [],
|
|
442
|
+
}), body('blueprint.md')));
|
|
443
|
+
// index.md 다음, task 묶음보다 앞. explain은 finalize 시점이라 여기 넣지 않는다.
|
|
444
|
+
// 표식 light도 verifiability 관점 context review를 받으므로 light에도 만든다.
|
|
445
|
+
created.push(...scaffoldContextReview({
|
|
446
|
+
repoRoot, blueprintDir: dir, timestamp,
|
|
447
|
+
}));
|
|
448
|
+
const reviewRel = `${dir}/review.md`;
|
|
449
|
+
// 이미 있는 루트 리뷰는 사람이 쓴 findings를 지울 수 있어 재작성하지 않는다.
|
|
450
|
+
if (!fs.existsSync(path.join(repoRoot, reviewRel))) {
|
|
451
|
+
created.push(writeRel(repoRoot, reviewRel, bouncerDoc('bouncer.review', `${blueprintKey} review`, `Review for ${blueprintKey}`, reviewRel, ['bouncer', 'review'], timestamp, {
|
|
452
|
+
id: `REVIEW-${blueprintKey}`,
|
|
453
|
+
epic_id: epicId,
|
|
454
|
+
blueprint_id: blueprintKey,
|
|
455
|
+
status: 'pending',
|
|
456
|
+
review: { required: true },
|
|
457
|
+
}), body('review.md')));
|
|
458
|
+
}
|
|
459
|
+
// 새 blueprint 는 tasks/001/ 묶음부터 시작한다. 루트 tasks-001.md 는 더 이상 만들지 않는다.
|
|
460
|
+
// (기존 문서 인식은 listTasksDocs 가 유지 — 거절은 004.)
|
|
461
|
+
// index에 review_scope를 먼저 썼으므로 아래 scaffoldTask는 task review.md를 생략한다.
|
|
462
|
+
const taskCreated = scaffoldTask({
|
|
463
|
+
repoRoot, blueprintDir: dir, taskId: '001', timestamp, scale: bpScale,
|
|
464
|
+
});
|
|
465
|
+
created.push(...taskCreated);
|
|
466
|
+
// BP explain.md는 plan scaffold가 아니라 finalize 시점(scaffoldExplain)에 생성한다.
|
|
467
|
+
return created;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* 기존 blueprint에 context-review.md를 붙인다.
|
|
471
|
+
* 거절은 쓰기 전에 끝낸다 — 정본 디렉터리 → closed → 이미 존재.
|
|
472
|
+
* scaffoldExplain은 finalize가 반복 호출하므로 이미 있으면 조용히 []를 돌리지만,
|
|
473
|
+
* 이 명령은 plan이 직접 부르므로 덮어쓰기가 사람 손에 닿는다. 존재하면 throw.
|
|
474
|
+
*/
|
|
475
|
+
function scaffoldContextReview({ repoRoot, blueprintDir, timestamp }) {
|
|
476
|
+
if (!isCanonicalBlueprintDir(blueprintDir)) {
|
|
477
|
+
throw new Error(`blueprintDir must be under ${CONTEXT_ROOT}/epics`);
|
|
478
|
+
}
|
|
479
|
+
const bp = normalizeRepoPath(blueprintDir);
|
|
480
|
+
const { epicId, blueprintId } = parsePathIds(bp);
|
|
481
|
+
if (!epicId || !blueprintId) {
|
|
482
|
+
throw new Error(`cannot derive epic/blueprint ids from ${bp}`);
|
|
483
|
+
}
|
|
484
|
+
// finalize가 잠근 blueprint는 다시 열지 않는다. scaffoldTask와 같은 이유 —
|
|
485
|
+
// 마감된 계획에 판정 문서를 붙이지 않고 새 blueprint를 연다.
|
|
486
|
+
if (isClosedBlueprint(repoRoot, bp)) {
|
|
487
|
+
throw new Error('blueprint is closed (finalized): '
|
|
488
|
+
+ `${bp} — scaffold a new blueprint instead of adding a context-review to this one`);
|
|
489
|
+
}
|
|
490
|
+
const rel = `${bp}/context-review.md`;
|
|
491
|
+
if (fs.existsSync(path.join(repoRoot, rel))) {
|
|
492
|
+
throw new Error(`context-review.md already exists: ${rel}`);
|
|
493
|
+
}
|
|
494
|
+
return [writeRel(repoRoot, rel, bouncerDoc('bouncer.context_review', `${blueprintId} context review`, `Context review for ${blueprintId}`, rel, ['bouncer', 'context_review'], timestamp, {
|
|
495
|
+
id: `CTXREVIEW-${blueprintId}`,
|
|
496
|
+
epic_id: epicId,
|
|
497
|
+
blueprint_id: blueprintId,
|
|
498
|
+
status: 'pending',
|
|
499
|
+
context_review: { findings: [] },
|
|
500
|
+
}), templateBody('context-review.md', { epicId, blueprintId, name: blueprintId }))];
|
|
501
|
+
}
|
|
502
|
+
/** BP explain.md가 없으면 생성한다. plan scaffold가 아니라 /bouncer-finalize에서 사용. */
|
|
503
|
+
function scaffoldExplain({ repoRoot, blueprintDir, timestamp }) {
|
|
504
|
+
if (!isCanonicalBlueprintDir(blueprintDir)) {
|
|
505
|
+
throw new Error(`blueprintDir must be under ${CONTEXT_ROOT}/epics`);
|
|
506
|
+
}
|
|
507
|
+
const bp = normalizeRepoPath(blueprintDir);
|
|
508
|
+
const explain = `${bp}/explain.md`;
|
|
509
|
+
if (fs.existsSync(path.join(repoRoot, explain)))
|
|
510
|
+
return [];
|
|
511
|
+
const { epicId, blueprintId } = parsePathIds(bp);
|
|
512
|
+
if (!epicId || !blueprintId) {
|
|
513
|
+
throw new Error(`cannot derive epic/blueprint ids from ${bp}`);
|
|
514
|
+
}
|
|
515
|
+
// split은 문자열에서 항상 한 칸 이상이라 pop()이 런타임에 비지 않는다.
|
|
516
|
+
// 타입만 undefined를 허용하므로 마지막 칸을 직접 집어 동작을 유지한다.
|
|
517
|
+
const segments = bp.split('/');
|
|
518
|
+
const leaf = segments[segments.length - 1];
|
|
519
|
+
const slug = leaf.replace(new RegExp(`^${blueprintId}-`), '') || 'blueprint';
|
|
520
|
+
// 빈 배열: G15는 대상 task 엔트리가 없으면 hash 불일치가 아니라 "기록 없음".
|
|
521
|
+
// 구 단일 객체 기본값은 형식 거절로 바뀌므로 더 이상 쓰지 않는다.
|
|
522
|
+
return [writeRel(repoRoot, explain, bouncerDoc('bouncer.explain', `${blueprintId} explain`, `Explain for ${blueprintId}`, explain, ['bouncer', 'explain'], timestamp, {
|
|
523
|
+
id: `EXPLAIN-${blueprintId}`,
|
|
524
|
+
epic_id: epicId,
|
|
525
|
+
blueprint_id: blueprintId,
|
|
526
|
+
status: 'draft',
|
|
527
|
+
comprehension: [],
|
|
528
|
+
}), templateBody('explain.md', { epicId, blueprintId, name: slug }))];
|
|
529
|
+
}
|
|
530
|
+
module.exports = {
|
|
531
|
+
CONTEXT_ROOT, AREA_SLUG_RE, scaffoldEpic, scaffoldBlueprint, scaffoldTask, scaffoldExplain,
|
|
532
|
+
scaffoldContextReview,
|
|
533
|
+
};
|