@qualisoft/ai-skills 1.0.0

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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +234 -0
  3. package/bin/cli.mjs +331 -0
  4. package/package.json +53 -0
  5. package/skills/erd-visual/SKILL.md +294 -0
  6. package/skills/erd-visual/build.mjs +404 -0
  7. package/skills/erd-visual/engine/dbml.mjs +133 -0
  8. package/skills/erd-visual/engine/ingest.mjs +273 -0
  9. package/skills/erd-visual/engine/layout.mjs +165 -0
  10. package/skills/erd-visual/engine/overview.mjs +314 -0
  11. package/skills/erd-visual/engine/render.mjs +144 -0
  12. package/skills/erd-visual/engine/router.mjs +401 -0
  13. package/skills/erd-visual/engine/verify.mjs +138 -0
  14. package/skills/erd-visual/engine/wire.mjs +115 -0
  15. package/skills/erd-visual/fixtures/crm-large.dbml +1337 -0
  16. package/skills/erd-visual/fixtures/edge-cases.dbml +43 -0
  17. package/skills/erd-visual/fixtures/map-dynamics.json +32 -0
  18. package/skills/erd-visual/fixtures/shop-basic.dbml +78 -0
  19. package/skills/erd-visual/fixtures/src-json/api.json +38 -0
  20. package/skills/erd-visual/fixtures/src-prisma/schema.prisma +44 -0
  21. package/skills/erd-visual/fixtures/src-sql/shop.sql +62 -0
  22. package/skills/erd-visual/readers/csv.mjs +81 -0
  23. package/skills/erd-visual/readers/index.mjs +55 -0
  24. package/skills/erd-visual/readers/jsonschema.mjs +86 -0
  25. package/skills/erd-visual/readers/prisma.mjs +102 -0
  26. package/skills/erd-visual/readers/sql.mjs +205 -0
  27. package/skills/erd-visual/readers/xlsx.mjs +220 -0
  28. package/skills/erd-visual/readers/xml.mjs +130 -0
  29. package/skills/erd-visual/readers/zip.mjs +74 -0
  30. package/skills/erd-visual/viewer/index.html +539 -0
  31. package/skills/project-build/SKILL.md +171 -0
  32. package/skills/project-build/templates/build-rules.md +88 -0
  33. package/skills/project-build/templates/change-log.md +29 -0
  34. package/skills/project-build/templates/coverage.md +49 -0
  35. package/skills/project-build/templates/parity.html +375 -0
  36. package/skills/project-build/templates/theme.css +45 -0
  37. package/skills/project-design/MEDIUMS.md +104 -0
  38. package/skills/project-design/QUESTIONS.md +126 -0
  39. package/skills/project-design/SKILL.md +365 -0
  40. package/skills/project-design/templates/audit.html +689 -0
  41. package/skills/project-design/templates/change-log.md +53 -0
  42. package/skills/project-design/templates/design-rules.md +118 -0
  43. package/skills/project-design/templates/mockup-app.html +96 -0
  44. package/skills/project-design/templates/mockup-slides.html +82 -0
  45. package/skills/project-design/templates/mockup-web.html +45 -0
  46. package/skills/project-design/templates/styleguide.html +436 -0
  47. package/skills/project-design/templates/tokens.css +69 -0
  48. package/skills/project-design/templates/tone-options.html +138 -0
  49. package/skills/project-init/GUIDE.md +370 -0
  50. package/skills/project-init/RUNBOOK.md +192 -0
  51. package/skills/project-init/SKILL.md +382 -0
  52. package/skills/project-init/build.mjs +2898 -0
  53. package/skills/project-init/evals/RUBRIC.md +81 -0
  54. package/skills/project-init/evals/cases/conflicting.expect.json +17 -0
  55. package/skills/project-init/evals/cases/conflicting.md +9 -0
  56. package/skills/project-init/evals/cases/vague-idea.expect.json +11 -0
  57. package/skills/project-init/evals/cases/vague-idea.md +7 -0
  58. package/skills/project-init/evals/cases/well-formed.expect.json +18 -0
  59. package/skills/project-init/evals/cases/well-formed.md +28 -0
  60. package/skills/project-init/markdown.mjs +0 -0
  61. package/skills/project-init/modules/a11y.json +46 -0
  62. package/skills/project-init/modules/ai.json +77 -0
  63. package/skills/project-init/modules/audience.json +48 -0
  64. package/skills/project-init/modules/backend.json +129 -0
  65. package/skills/project-init/modules/brand.json +78 -0
  66. package/skills/project-init/modules/core.json +132 -0
  67. package/skills/project-init/modules/design.json +72 -0
  68. package/skills/project-init/modules/engineering.json +86 -0
  69. package/skills/project-init/modules/mobile.json +22 -0
  70. package/skills/project-init/modules/ops.json +122 -0
  71. package/skills/project-init/modules/process.json +104 -0
  72. package/skills/project-init/modules/product.json +37 -0
  73. package/skills/project-init/modules/ux.json +52 -0
  74. package/skills/project-init/modules/web.json +129 -0
  75. package/skills/project-init/presets/ai-product.json +12 -0
  76. package/skills/project-init/presets/internal-system.json +15 -0
  77. package/skills/project-init/presets/mobile-app.json +20 -0
  78. package/skills/project-init/presets/web-corporate.json +116 -0
  79. package/skills/project-init/schema.json +80 -0
  80. package/skills/project-init/templates/log.md +54 -0
  81. package/skills/project-init/templates/readme.md +62 -0
  82. package/skills/project-init/templates/reference.md +34 -0
  83. package/skills/project-init/templates/register.md +63 -0
  84. package/skills/project-init/templates/spec.md +46 -0
  85. package/skills/project-interview/INTERVIEW.md +221 -0
  86. package/skills/project-interview/SKILL.md +156 -0
  87. package/skills/project-interview/fixtures/brief.md +49 -0
  88. package/skills/project-interview/fixtures/decisions.md +13 -0
  89. package/skills/project-interview/fixtures/open-questions.md +9 -0
  90. package/skills/project-interview/templates/brief.md +179 -0
  91. package/skills/project-interview/templates/decisions.md +55 -0
  92. package/skills/project-interview/templates/open-questions.md +40 -0
  93. package/skills/project-interview/validate.mjs +49 -0
@@ -0,0 +1,2898 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * project-init — 기획 문서 표준 검증 + 단일 HTML 뷰어 빌더.
4
+ *
5
+ * node build.mjs check 검증만 (실패 시 exit 1)
6
+ * node build.mjs check --deep + 품질 점검 (경고만, 빌드는 막지 않음)
7
+ * node build.mjs build 검증 후 index.html 생성
8
+ * node build.mjs init --preset=<이름> --name="<프로젝트>"
9
+ * node build.mjs init --modules=<a,b,c> --name="<프로젝트>"
10
+ * node build.mjs intake 미처리 기획안 확인 (있으면 exit 0, 없으면 1)
11
+ * node build.mjs intake --archive=<파일> 처리 완료분 보관
12
+ * node build.mjs options [--json] 범위 옵션 카탈로그와 현재 선택
13
+ * node build.mjs presets 프리셋 목록
14
+ * node build.mjs modules 모듈 목록 (프리셋 없이 조합 가능)
15
+ * node build.mjs eval [--case=<이름>] 스킬 평가 (evals/RUBRIC.md 와 함께)
16
+ * node build.mjs selftest 파서·검증기 자체 점검
17
+ *
18
+ * 공통 옵션: --docs=<디렉터리> (기본 ./Docs)
19
+ *
20
+ * 의존성 없음 (Node 18+).
21
+ */
22
+
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ readdirSync,
28
+ renameSync,
29
+ statSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import { dirname, join, resolve } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ import {
36
+ escapeHtml,
37
+ mdToHtml,
38
+ normalizeSectionTitle,
39
+ parseFrontmatter,
40
+ scanReferences,
41
+ } from "./markdown.mjs";
42
+
43
+ const SKILL_DIR = dirname(fileURLToPath(import.meta.url));
44
+ const schema = JSON.parse(
45
+ readFileSync(join(SKILL_DIR, "schema.json"), "utf8"),
46
+ );
47
+
48
+ const STATUS_LABEL = { draft: "초안", review: "검토중", approved: "확정" };
49
+ const TODO_LABEL = {
50
+ client: "클라이언트",
51
+ design: "디자인",
52
+ copy: "카피",
53
+ legal: "법무",
54
+ dev: "개발",
55
+ };
56
+
57
+ /** 인라인 SVG 아이콘. 외부 폰트·라이브러리를 쓰지 않는다. */
58
+ const ICON = {
59
+ panel:
60
+ '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">' +
61
+ '<rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2" fill="none" stroke="currentColor" stroke-width="1.3"/>' +
62
+ '<line x1="6.25" y1="2.75" x2="6.25" y2="13.25" stroke="currentColor" stroke-width="1.3"/></svg>',
63
+ wide:
64
+ '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">' +
65
+ '<path d="M6.5 4.5 3 8l3.5 3.5M9.5 4.5 13 8l-3.5 3.5" fill="none" stroke="currentColor" ' +
66
+ 'stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
67
+ search:
68
+ '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">' +
69
+ '<circle cx="7" cy="7" r="4.25" fill="none" stroke="currentColor" stroke-width="1.4"/>' +
70
+ '<line x1="10.2" y1="10.2" x2="13.5" y2="13.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
71
+ };
72
+
73
+ const today = () => new Date().toISOString().slice(0, 10);
74
+ const nowMinute = () => {
75
+ const d = new Date();
76
+ const p = (n) => String(n).padStart(2, "0");
77
+ return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes());
78
+ };
79
+
80
+ const arg = (name, fallback = null) => {
81
+ const hit = process.argv.find((a) => a.startsWith("--" + name + "="));
82
+ return hit ? hit.slice(name.length + 3) : fallback;
83
+ };
84
+
85
+ /* ================================================================== *
86
+ * 로드
87
+ * ================================================================== */
88
+
89
+ function loadConfig(docsDir) {
90
+ const path = join(docsDir, "docs.config.json");
91
+ if (!existsSync(path)) return { project: "Project", preset: null };
92
+ return JSON.parse(readFileSync(path, "utf8"));
93
+ }
94
+
95
+ function loadDocs(docsDir) {
96
+ const files = readdirSync(docsDir)
97
+ .filter((f) => f.endsWith(".md") && !f.startsWith("_"))
98
+ .sort();
99
+
100
+ return files
101
+ .map((file) => {
102
+ const raw = readFileSync(join(docsDir, file), "utf8");
103
+ const { meta, body, found } = parseFrontmatter(raw);
104
+ const sections = [];
105
+ for (const line of body.split("\n")) {
106
+ const h = line.match(/^##\s+(.*)$/);
107
+ if (h) sections.push(normalizeSectionTitle(h[1]));
108
+ }
109
+ return {
110
+ file,
111
+ meta,
112
+ body,
113
+ hasFrontmatter: found,
114
+ sections,
115
+ refs: scanReferences(body),
116
+ id: meta.id || file.replace(/\.md$/, ""),
117
+ };
118
+ })
119
+ .sort((a, b) => a.id.localeCompare(b.id));
120
+ }
121
+
122
+ /**
123
+ * 1단계(project-interview) 산출물을 읽는다.
124
+ *
125
+ * `Docs/_discovery/` 에 기획안과 인터뷰 기록이 쌓인다. 2단계 문서와 성격이 달라
126
+ * 스키마 검증(필수 섹션·template·날짜 형식)을 적용하지 않는다. 대신 id 중복과
127
+ * 위키링크만 검사하고, 추적 ID는 2단계와 같은 풀에서 관리한다.
128
+ *
129
+ * 메뉴는 분리된다 — 어느 단계에서 나온 기록인지 섞이면 안 된다.
130
+ */
131
+ function loadStageDocs(docsDir, dirName, stage, prefix) {
132
+ const dir = join(docsDir, dirName);
133
+ if (!existsSync(dir)) return [];
134
+ return readdirSync(dir)
135
+ .filter((f) => f.endsWith(".md") && !f.startsWith("."))
136
+ .sort()
137
+ .map((file) => {
138
+ const raw = readFileSync(join(dir, file), "utf8");
139
+ const { meta, body } = parseFrontmatter(raw);
140
+ return {
141
+ file: dirName + "/" + file,
142
+ meta,
143
+ body,
144
+ stage,
145
+ refs: scanReferences(body),
146
+ id: meta.id || prefix + file.replace(/\.md$/, ""),
147
+ };
148
+ });
149
+ }
150
+
151
+ const loadDiscovery = (d) => loadStageDocs(d, DISCOVERY_DIR, 1, "discovery-");
152
+ const loadDesign = (d) => loadStageDocs(d, DESIGN_DIR, 3, "design-");
153
+ const loadBuild = (d) => loadStageDocs(d, BUILD_DIR, 4, "build-");
154
+
155
+ /** 3단계 스타일 가이드가 있으면 경로를 돌려준다. */
156
+ function findStyleguide(docsDir) {
157
+ const rel = DESIGN_DIR + "/styleguide.html";
158
+ return existsSync(join(docsDir, rel)) ? rel : null;
159
+ }
160
+
161
+ /** 3단계가 만든 시안 HTML 목록. 문서가 아니라 링크로 다룬다. */
162
+ function loadMockups(docsDir) {
163
+ const dir = join(docsDir, DESIGN_DIR, MOCKUP_DIR);
164
+ if (!existsSync(dir)) return [];
165
+ return readdirSync(dir)
166
+ .filter((f) => /\.html?$/i.test(f))
167
+ .sort()
168
+ .map((f) => ({ file: f, href: DESIGN_DIR + "/" + MOCKUP_DIR + "/" + f }));
169
+ }
170
+
171
+ /* ================================================================== *
172
+ * 3단계 — 시안 스냅샷과 규칙 검증
173
+ *
174
+ * 이 단계의 실패 모드는 "사람이 잊는 것"이다. 수정 이력을 안 적고 시안만
175
+ * 고치면 문서와 산출물이 조용히 어긋난다. 실제로 그렇게 어긋난 적이 있다.
176
+ * 그래서 여기 있는 검사는 --deep 없이 **항상** 돈다.
177
+ * ================================================================== */
178
+
179
+ /**
180
+ * 시안이 바뀌었으면 이전 내용을 .history/ 에 남긴다.
181
+ *
182
+ * "바뀌기 전 값도 적는다"는 규칙만으로는 되돌릴 수 없다. 값은 적어도
183
+ * 파일은 사라진다. 실제로 한 회차를 통째로 잃은 적이 있다.
184
+ * HTML 텍스트라 용량이 사실상 문제되지 않는다.
185
+ */
186
+ function snapshotMockups(docsDir) {
187
+ const dir = join(docsDir, DESIGN_DIR, MOCKUP_DIR);
188
+ if (!existsSync(dir)) return [];
189
+ const hist = join(dir, HISTORY_DIR);
190
+ const saved = [];
191
+
192
+ for (const m of loadMockups(docsDir)) {
193
+ const cur = readFileSync(join(dir, m.file), "utf8");
194
+ const base = m.file.replace(/\.html?$/i, "");
195
+ if (!existsSync(hist)) mkdirSync(hist, { recursive: true });
196
+
197
+ const prior = readdirSync(hist)
198
+ .filter((f) => f.startsWith(base + "-") && /\.html$/.test(f))
199
+ .sort();
200
+
201
+ // 직전 스냅샷과 같으면 남기지 않는다 — 빌드마다 쌓이면 쓸모없어진다
202
+ if (prior.length) {
203
+ const last = readFileSync(join(hist, prior[prior.length - 1]), "utf8");
204
+ if (last === cur) continue;
205
+ }
206
+ const seq = String(prior.length + 1).padStart(3, "0");
207
+ const out = base + "-" + seq + ".html";
208
+ writeFileSync(join(hist, out), cur);
209
+ saved.push(out);
210
+ }
211
+ return saved;
212
+ }
213
+
214
+ /** tokens.css 에 선언된 커스텀 속성 이름. */
215
+ function tokenNames(docsDir) {
216
+ const f = join(docsDir, DESIGN_DIR, "tokens.css");
217
+ if (!existsSync(f)) return [];
218
+ const css = readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
219
+ const root = css.match(/:root\s*\{([\s\S]*?)\}/);
220
+ if (!root) return [];
221
+ return [...root[1].matchAll(/(--[a-z0-9-]+)\s*:/gi)].map((m) => m[1]);
222
+ }
223
+
224
+ /**
225
+ * 화면 정의서·인터랙션 문서가 정의한 상태 중 시안이 보여주지 않는 것.
226
+ *
227
+ * 스타일 가이드의 버튼 8상태만으로는 부족하다. 폼의 submitting·error,
228
+ * 목록의 빈 상태는 화면에서 따로 그려야 하고, 안 그리면 구현 단계에서
229
+ * 각자 지어낸다. 시안에 `data-state="error"` 처럼 표시해 두면 여기서 대조한다.
230
+ */
231
+ const STATE_ALIASES = {
232
+ loading: ["loading", "로딩", "처리 중", "처리중", "submitting"],
233
+ success: ["success", "성공", "완료"],
234
+ error: ["error", "오류", "에러"],
235
+ empty: ["empty", "빈 상태", "빈상태"],
236
+ disabled: ["disabled", "비활성"],
237
+ };
238
+
239
+ function stateCoverage(docs, docsDir, mockups) {
240
+ const specs = docs.filter((d) => /screen-spec|interaction-motion/.test(d.id));
241
+ if (!specs.length) return [];
242
+ const text = specs.map((d) => d.body).join("\n").toLowerCase();
243
+
244
+ const required = Object.keys(STATE_ALIASES).filter((k) =>
245
+ STATE_ALIASES[k].some((a) => text.includes(a.toLowerCase())),
246
+ );
247
+ if (!required.length) return [];
248
+
249
+ let shown = "";
250
+ const sg = join(docsDir, DESIGN_DIR, "styleguide.html");
251
+ if (existsSync(sg)) shown += readFileSync(sg, "utf8");
252
+ for (const m of mockups) shown += readFileSync(join(docsDir, m.href), "utf8");
253
+
254
+ const declared = new Set(
255
+ [...shown.matchAll(/data-state\s*=\s*["']([a-z-]+)["']/gi)].map((m) => m[1].toLowerCase()),
256
+ );
257
+ return required.filter((k) => !declared.has(k));
258
+ }
259
+
260
+ /**
261
+ * 3단계 규칙 검증. 경고로 낸다 — 빌드는 막지 않되 눈에는 보이게.
262
+ */
263
+ function designReview(docsDir, design, mockups, docs) {
264
+ const warnings = [];
265
+ const add = (file, msg) => warnings.push({ file, msg });
266
+ if (!mockups.length) return warnings;
267
+
268
+ const logPath = join(docsDir, DESIGN_DIR, "change-log.md");
269
+ const hasLog = existsSync(logPath);
270
+ const log = hasLog ? readFileSync(logPath, "utf8") : "";
271
+ if (!hasLog) add(DESIGN_DIR + "/", "시안이 있는데 change-log.md 가 없습니다");
272
+
273
+ const logTime = hasLog ? statSync(logPath).mtimeMs : 0;
274
+
275
+ for (const m of mockups) {
276
+ const abs = join(docsDir, m.href);
277
+ const html = readFileSync(abs, "utf8");
278
+
279
+ if (!/href="\.\.\/tokens\.css"/.test(html)) {
280
+ add(m.href, "tokens.css 를 링크하지 않습니다 — 토큰이 갈라집니다");
281
+ }
282
+ if (/lorem ipsum/i.test(html.replace(/<!--[\s\S]*?-->/g, ""))) {
283
+ add(m.href, "Lorem ipsum 이 남아 있습니다");
284
+ }
285
+
286
+ // 시안이 수정 이력보다 최신이면 이력이 빠졌을 가능성이 크다
287
+ if (hasLog && statSync(abs).mtimeMs > logTime + 60000) {
288
+ add(m.href, "change-log.md 보다 최신입니다 — 수정 이력을 빠뜨렸을 수 있습니다");
289
+ }
290
+
291
+ // 시안에 적힌 차수가 이력에 없으면 둘이 어긋난 것이다
292
+ const rev = html.match(/(\d+)\s*차\s*시안/);
293
+ if (hasLog && rev && !log.includes(rev[0])) {
294
+ add(m.href, '"' + rev[0] + '" 이 change-log.md 에 없습니다 — 차수와 이력이 어긋납니다');
295
+ }
296
+ }
297
+
298
+ // 토큰이 규칙 문서에 하나도 언급되지 않으면 규칙과 값이 갈라진 상태다
299
+ const rules = design.find((d) => /design-rules/.test(d.id));
300
+ if (rules) {
301
+ const missing = tokenNames(docsDir).filter((t) => !rules.body.includes(t));
302
+ if (missing.length) {
303
+ add(
304
+ DESIGN_DIR + "/design-rules.md",
305
+ "규칙에 언급되지 않은 토큰 " + missing.length + "개: " + missing.slice(0, 8).join(", "),
306
+ );
307
+ }
308
+ }
309
+
310
+ if (!existsSync(join(docsDir, DESIGN_DIR, "audit.html"))) {
311
+ add(DESIGN_DIR + "/", "audit.html 이 없습니다 — 시안 검수를 손으로 하고 있다는 뜻입니다");
312
+ }
313
+ if (!existsSync(join(docsDir, DESIGN_DIR, "tone-options.html"))) {
314
+ add(DESIGN_DIR + "/", "tone-options.html 이 없습니다 — 톤 방향을 확인받지 않고 시안을 만들었을 수 있습니다");
315
+ }
316
+
317
+ // 문서가 정의한 상태 중 시안에 없는 것
318
+ const missing = stateCoverage(docs || [], docsDir, mockups);
319
+ if (missing.length) {
320
+ add(
321
+ DESIGN_DIR + "/mockups/",
322
+ "문서가 정의했으나 시안에 없는 상태: " + missing.join(", ") +
323
+ ' — 해당 상태를 그리고 data-state="이름" 을 붙이세요',
324
+ );
325
+ }
326
+ return warnings;
327
+ }
328
+
329
+ /**
330
+ * 4단계 구현 검증.
331
+ *
332
+ * 이 단계의 위험은 3단계와 다르다 — **시안을 코드로 옮기면서 값이 흐트러지는 것**이다.
333
+ * 클래스 이름이 바뀌면 눈으로 대조할 방법이 없으므로 표식과 대조기가 필요하다.
334
+ */
335
+ function buildReview(docsDir, build, docs) {
336
+ const warnings = [];
337
+ const add = (file, msg) => warnings.push({ file, msg });
338
+ const srcDir = join(docsDir, "..", "src");
339
+ const hasSrc = existsSync(srcDir);
340
+ if (!build.length && !hasSrc) return warnings;
341
+
342
+ const has = (f) => existsSync(join(docsDir, BUILD_DIR, f));
343
+
344
+ if (hasSrc && !has("parity.html")) {
345
+ add(BUILD_DIR + "/", "parity.html 이 없습니다 — 구현이 시안과 어긋나도 알 방법이 없습니다");
346
+ }
347
+ if (hasSrc && !has("coverage.md")) {
348
+ add(BUILD_DIR + "/", "coverage.md 가 없습니다 — 화면 정의서 대비 무엇을 만들었는지 기록이 없습니다");
349
+ }
350
+ if (hasSrc && !has("change-log.md")) {
351
+ add(BUILD_DIR + "/", "구현이 있는데 change-log.md 가 없습니다");
352
+ }
353
+
354
+ // 토큰을 복사해 넣었는지 — 값이 두 곳이 되면 반드시 갈라진다
355
+ if (hasSrc && existsSync(join(docsDir, DESIGN_DIR, "tokens.css"))) {
356
+ const names = tokenNames(docsDir);
357
+ const copied = [];
358
+ const walk = (dir) => {
359
+ let entries;
360
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
361
+ for (const e of entries) {
362
+ if (e.name === "node_modules" || e.name.startsWith(".")) continue;
363
+ const full = join(dir, e.name);
364
+ if (e.isDirectory()) { walk(full); continue; }
365
+ if (!/\.(css|ts|tsx|js|jsx)$/.test(e.name)) continue;
366
+ const body = readFileSync(full, "utf8");
367
+ for (const n of names) {
368
+ const m = body.match(new RegExp(n + "\\s*:\\s*([^;]*);"));
369
+ // var(...) 로 잇는 것은 정상. 값을 그대로 적었으면 복사다.
370
+ if (m && !/var\(/.test(m[1])) { copied.push(e.name + " · " + n); break; }
371
+ }
372
+ }
373
+ };
374
+ walk(srcDir);
375
+ if (copied.length) {
376
+ add("src/", "tokens.css 값을 복사한 곳이 있습니다 — 링크하세요: " + copied.slice(0, 5).join(", "));
377
+ }
378
+ }
379
+
380
+ // 수용 기준을 실제로 대고 있는가
381
+ const prd = docs.find((d) => /prd/.test(d.id));
382
+ const log = has("change-log.md")
383
+ ? readFileSync(join(docsDir, BUILD_DIR, "change-log.md"), "utf8") : "";
384
+ if (hasSrc && prd) {
385
+ const unique = [...new Set([...prd.body.matchAll(/\bF-\d{2}\b/g)].map((m) => m[0]))];
386
+ const untouched = unique.filter((id) => !log.includes(id));
387
+ if (untouched.length) {
388
+ add(
389
+ BUILD_DIR + "/change-log.md",
390
+ "구현 이력에 한 번도 언급되지 않은 수용 기준 " + untouched.length + "개: " +
391
+ untouched.slice(0, 8).join(", "),
392
+ );
393
+ }
394
+ }
395
+ return warnings;
396
+ }
397
+
398
+ /**
399
+ * 1·3단계 기록 검증.
400
+ *
401
+ * 스키마(필수 섹션·template·날짜 형식)는 면제하지만 **참조 무결성은 면제하지 않는다.**
402
+ * 없는 문서로 링크하거나 정의되지 않은 추적 ID를 언급하는 것은
403
+ * 어느 단계에서 일어나든 똑같이 깨진 것이다.
404
+ */
405
+ function validateDiscovery(records, knownIds, definedIds) {
406
+ const errors = [];
407
+ const seen = new Set();
408
+ for (const d of records) {
409
+ if (seen.has(d.id)) {
410
+ errors.push({ file: d.file, msg: "id 중복: " + d.id });
411
+ }
412
+ seen.add(d.id);
413
+ for (const target of d.refs.wikiLinks) {
414
+ if (!knownIds.has(target)) {
415
+ errors.push({ file: d.file, msg: "깨진 참조: [[" + target + "]]" });
416
+ }
417
+ }
418
+ if (definedIds) {
419
+ for (const id of d.refs.mentioned) {
420
+ if (!definedIds.has(id)) {
421
+ errors.push({ file: d.file, msg: "정의되지 않은 추적 ID: " + id });
422
+ }
423
+ }
424
+ }
425
+ }
426
+ return errors;
427
+ }
428
+
429
+ /* ================================================================== *
430
+ * 검증
431
+ * ================================================================== */
432
+
433
+ function validate(docs, extraIds = new Set()) {
434
+ const errors = [];
435
+ const warnings = [];
436
+ const fail = (doc, msg) => errors.push({ file: doc.file, msg });
437
+ const warn = (doc, msg) => warnings.push({ file: doc.file, msg });
438
+
439
+ const byId = new Map();
440
+
441
+ for (const doc of docs) {
442
+ if (!doc.hasFrontmatter) {
443
+ fail(doc, "프론트매터가 없습니다");
444
+ continue;
445
+ }
446
+
447
+ for (const key of schema.frontmatter.required) {
448
+ if (!doc.meta[key]) fail(doc, "프론트매터 누락: " + key);
449
+ }
450
+
451
+ for (const [key, allowed] of Object.entries(schema.frontmatter.enums)) {
452
+ const v = doc.meta[key];
453
+ if (v && !allowed.includes(v)) {
454
+ fail(doc, key + " 값 오류: '" + v + "' (허용: " + allowed.join(", ") + ")");
455
+ }
456
+ }
457
+
458
+ for (const [key, pattern] of Object.entries(schema.frontmatter.patterns)) {
459
+ const v = doc.meta[key];
460
+ if (v && !new RegExp(pattern).test(v)) {
461
+ fail(doc, key + " 형식 오류: '" + v + "'");
462
+ }
463
+ }
464
+
465
+ for (const [key, max] of Object.entries(schema.frontmatter.maxLength)) {
466
+ const v = doc.meta[key];
467
+ if (v && v.length > max) {
468
+ fail(doc, key + " 길이 초과: " + v.length + "자 (최대 " + max + ")");
469
+ }
470
+ }
471
+
472
+ if (schema.rules.filenameMatchesId && doc.meta.id) {
473
+ const ok = doc.file === doc.meta.id + ".md" || doc.file === "README.md";
474
+ if (!ok) fail(doc, "파일명이 id와 다릅니다: id=" + doc.meta.id);
475
+ }
476
+
477
+ if (schema.rules.uniqueIds && doc.meta.id) {
478
+ if (byId.has(doc.meta.id)) {
479
+ fail(doc, "id 중복: " + doc.meta.id + " (" + byId.get(doc.meta.id) + ")");
480
+ } else {
481
+ byId.set(doc.meta.id, doc.file);
482
+ }
483
+ }
484
+
485
+ // 문서 종류별 필수 섹션
486
+ const tmpl = schema.sectionsByTemplate[doc.meta.template];
487
+ if (doc.meta.template && !tmpl) {
488
+ fail(doc, "알 수 없는 template: " + doc.meta.template);
489
+ } else if (tmpl) {
490
+ for (const required of tmpl.required) {
491
+ if (!doc.sections.includes(required)) {
492
+ fail(doc, "필수 섹션 누락: " + required);
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ const ids = new Set([...docs.map((d) => d.id), ...extraIds]);
499
+
500
+ // 위키링크 해석
501
+ if (schema.rules.wikiLinksResolve) {
502
+ for (const doc of docs) {
503
+ for (const target of doc.refs.wikiLinks) {
504
+ if (!ids.has(target)) fail(doc, "깨진 참조: [[" + target + "]]");
505
+ }
506
+ }
507
+ }
508
+
509
+ // 추적 ID — 언급됐지만 어디에도 정의되지 않은 것
510
+ const definedAnywhere = new Set();
511
+ for (const doc of docs) for (const id of doc.refs.defined) definedAnywhere.add(id);
512
+
513
+ if (schema.rules.referencedIdsMustBeDefined) {
514
+ for (const doc of docs) {
515
+ for (const id of doc.refs.mentioned) {
516
+ if (!definedAnywhere.has(id)) {
517
+ fail(doc, "정의되지 않은 추적 ID: " + id);
518
+ }
519
+ }
520
+ }
521
+ }
522
+
523
+ // 문서 id를 맨텍스트로 참조 — 위키링크로 바꾸라는 경고
524
+ if (schema.rules.warnOnPlainDocReference) {
525
+ for (const doc of docs) {
526
+ for (const other of ids) {
527
+ if (other === doc.id) continue;
528
+ const plain = new RegExp(
529
+ "(?<!\\[\\[)(?<!/)\\b" + other + "\\b(?!\\]\\])(?!\\.md)",
530
+ );
531
+ if (plain.test(doc.body) && !doc.refs.wikiLinks.has(other)) {
532
+ warn(doc, other + " 를 맨텍스트로 참조 — [[" + other + "]] 권장");
533
+ }
534
+ }
535
+ }
536
+ }
537
+
538
+ return { errors, warnings };
539
+ }
540
+
541
+ /* ================================================================== *
542
+ * 품질 점검 (check --deep)
543
+ *
544
+ * 스키마 검증이 "형식이 맞는가"를 본다면 이쪽은 "내용이 방치되지 않았는가"를 본다.
545
+ * 기계가 확정할 수 없는 것은 **경고로만** 낸다 — 빌드를 막지 않는다.
546
+ * 판단이 필요한 항목까지 실패로 처리하면 우회하려고 규칙을 느슨하게 만들게 된다.
547
+ * ================================================================== */
548
+
549
+ /**
550
+ * 사업 성과로 읽히는 수치만 잡는다.
551
+ *
552
+ * `200% 확대`(접근성 기준)나 `90% 스크롤`(이벤트 임계값)처럼 스펙 수치는
553
+ * 잡지 않는다. 오탐이 쌓이면 점검 자체를 무시하게 된다.
554
+ * 성과 문맥 낱말이 가까이 있을 때만 후보로 본다.
555
+ */
556
+ const CLAIM_RE = /\d[\d,.]*\s*(?:만|천|억|조)?\s*(?:%|명|원|배|퍼센트)(?![a-zA-Z])/g;
557
+ const OUTCOME_WORDS =
558
+ /(증가|감소|절감|개선|향상|성장|달성|상승|단축|절약|매출|수익|고객|사용자|가입|전환율|만족도|점유율|ROI|성공률|재구매|이탈률)/;
559
+
560
+ function outcomeClaims(text) {
561
+ const out = new Set();
562
+ for (const m of text.matchAll(CLAIM_RE)) {
563
+ const from = Math.max(0, m.index - 24);
564
+ const around = text.slice(from, m.index + m[0].length + 24);
565
+ if (OUTCOME_WORDS.test(around)) out.add(m[0].trim());
566
+ }
567
+ return [...out];
568
+ }
569
+
570
+ function stripCode(md) {
571
+ return md.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, " ");
572
+ }
573
+
574
+ function deepReview(docs, discovery, design, mockups, docsDir) {
575
+ const notes = [];
576
+ const add = (file, msg) => notes.push({ file, msg });
577
+ const all = [...docs, ...discovery, ...design];
578
+
579
+ for (const d of docs) {
580
+ const body = stripCode(d.body);
581
+
582
+ // 확정 문서에 대기 항목이 남아 있으면 확정이 아니다
583
+ if (d.meta.status === "approved" && /TODO\([a-z]+\)/.test(body)) {
584
+ add(d.file, "status가 approved인데 TODO가 남아 있습니다");
585
+ }
586
+
587
+ // 표만 있고 내용이 없는 섹션 — 템플릿 그대로인 상태
588
+ const emptyTable = /\|\s*\|\s*\|\s*\|?\s*\n(?!\s*\|\s*-)/.test(d.body);
589
+ if (emptyTable) add(d.file, "비어 있는 표가 있습니다 (템플릿 그대로일 수 있음)");
590
+
591
+ // 작성 지침이 남아 있으면 아직 안 채운 섹션이다
592
+ const hints = (d.body.match(/> \*\*작성 지침\*\*/g) || []).length;
593
+ if (hints) add(d.file, "작성 지침이 " + hints + "곳 남아 있습니다 (미작성 섹션)");
594
+
595
+ // 남은 자리표시자
596
+ if (/\{\{[^}]+\}\}/.test(d.body)) add(d.file, "치환되지 않은 자리표시자가 있습니다");
597
+
598
+ // 성과 주장으로 읽히는 수치 — 근거가 있는지 사람이 확인해야 한다
599
+ const claims = outcomeClaims(body);
600
+ if (claims.length) {
601
+ add(d.file, "근거 확인 필요한 성과 수치: " + claims.slice(0, 6).join(", "));
602
+ }
603
+
604
+ // 아무도 참조하지 않고 아무 데도 링크하지 않는 문서
605
+ if (!d.refs.wikiLinks.size) add(d.file, "다른 문서를 하나도 링크하지 않습니다");
606
+ }
607
+
608
+ // 안티패턴 목록이 비어 있으면 시안을 검증할 기준이 없다
609
+ const ds = docs.find((d) => /design-system/.test(d.id));
610
+ if (ds) {
611
+ const sec = ds.body.split(/^##\s+/m).find((x) => /^\d*\.?\s*안티패턴/.test(x));
612
+ if (sec && !/^\s*[-*]\s/m.test(sec)) {
613
+ add(ds.file, "안티패턴 목록이 비어 있습니다 — 시안을 판정할 기준이 없습니다");
614
+ }
615
+ }
616
+
617
+ // 3단계 시안 검사는 designReview 로 옮겼다 — --deep 없이 항상 돌아야 하는 것들이라서.
618
+ if (mockups.length && !existsSync(join(docsDir, DESIGN_DIR, "tokens.css"))) {
619
+ add(DESIGN_DIR + "/", "시안은 있는데 tokens.css가 없습니다");
620
+ }
621
+
622
+ // 오래 묵은 문서
623
+ const today0 = today();
624
+ for (const d of all) {
625
+ const u = d.meta.updated || d.meta.date;
626
+ if (!u || !/^\d{4}-\d{2}-\d{2}$/.test(u)) continue;
627
+ const days = Math.round((Date.parse(today0) - Date.parse(u)) / 86400000);
628
+ if (days > 90) add(d.file, days + "일간 갱신되지 않았습니다");
629
+ }
630
+
631
+ return notes;
632
+ }
633
+
634
+ /* ================================================================== *
635
+ * 추적성 / 대시보드
636
+ * ================================================================== */
637
+
638
+ function buildTraceability(docs) {
639
+ const owners = new Map(); // id -> {doc, label}
640
+ const refs = new Map(); // id -> Set<docId>
641
+ const backlinks = new Map(); // docId -> Set<docId>
642
+
643
+ for (const doc of docs) {
644
+ for (const id of doc.refs.defined) {
645
+ if (!owners.has(id)) owners.set(id, { doc: doc.id, label: labelFor(doc, id) });
646
+ }
647
+ for (const target of doc.refs.wikiLinks) {
648
+ if (!backlinks.has(target)) backlinks.set(target, new Set());
649
+ backlinks.get(target).add(doc.id);
650
+ }
651
+ }
652
+
653
+ for (const doc of docs) {
654
+ for (const id of doc.refs.mentioned) {
655
+ if (doc.refs.defined.has(id)) continue;
656
+ if (!refs.has(id)) refs.set(id, new Set());
657
+ refs.get(id).add(doc.id);
658
+ }
659
+ }
660
+
661
+ return { owners, refs, backlinks };
662
+ }
663
+
664
+ /** 추적 ID가 정의된 줄에서 사람이 읽을 라벨을 뽑는다. */
665
+ function labelFor(doc, id) {
666
+ for (const line of doc.body.split("\n")) {
667
+ if (!line.includes(id)) continue;
668
+ const heading = line.match(/^#{2,6}\s+\**([A-Z]{1,3}-\d{1,3})\**\.?\s*(.*)$/);
669
+ if (heading && heading[1] === id) {
670
+ return heading[2].replace(/[*`]/g, "").trim();
671
+ }
672
+ const cells = line.split("|").map((c) => c.trim());
673
+ if (cells.length > 2 && cells[1].replace(/[*`]/g, "") === id) {
674
+ return cells[2].replace(/[*`]/g, "").trim();
675
+ }
676
+ }
677
+ return "";
678
+ }
679
+
680
+ function collectTodos(docs) {
681
+ const pattern = new RegExp(schema.traceability.todoPattern, "g");
682
+ const groups = new Map();
683
+ for (const doc of docs) {
684
+ for (const m of doc.body.matchAll(pattern)) {
685
+ const who = m[1];
686
+ if (!groups.has(who)) groups.set(who, new Map());
687
+ const g = groups.get(who);
688
+ g.set(doc.id, (g.get(doc.id) || 0) + 1);
689
+ }
690
+ }
691
+ return groups;
692
+ }
693
+
694
+ /**
695
+ * 4단계 파이프라인의 현재 상태를 계산한다.
696
+ * 3·4단계는 아직 스킬이 없으므로 자리만 잡아둔다 — GUIDE.md 를 고쳐 채운다.
697
+ */
698
+ function stageStatus(docs, discovery, trace, design = [], mockups = [], styleguide = null,
699
+ build = [], docsDir = "") {
700
+ const buildHas = (f) => !!docsDir && existsSync(join(docsDir, BUILD_DIR, f));
701
+ const buildStarted = build.length > 0 || (!!docsDir && existsSync(join(docsDir, "..", "src")));
702
+ const count = (st) => docs.filter((d) => d.meta.status === st).length;
703
+ const blockers = [...trace.owners.keys()].filter((id) =>
704
+ id.startsWith(schema.traceability.blockerPrefix + "-"),
705
+ ).length;
706
+
707
+ const s1 = discovery.length
708
+ ? { mark: "완료", detail: "기록 " + discovery.length + "개" }
709
+ : { mark: "미실행", detail: "기획안을 직접 넣었거나 아직 시작 전" };
710
+
711
+ let s2;
712
+ if (!docs.length) s2 = { mark: "미실행", detail: "" };
713
+ else if (count("approved") === docs.length)
714
+ s2 = { mark: "완료", detail: "문서 " + docs.length + "개 전부 확정" };
715
+ else
716
+ s2 = {
717
+ mark: "진행 중",
718
+ detail:
719
+ "문서 " + docs.length + "개 — 확정 " + count("approved") +
720
+ " · 검토중 " + count("review") + " · 초안 " + count("draft") +
721
+ (blockers ? " · Blocker " + blockers + "건" : ""),
722
+ };
723
+
724
+ const rows = [
725
+ ["1", "인터뷰", "`/project-interview`", s1],
726
+ ["2", "구조화", "`/project-init`", s2],
727
+ [
728
+ "3", "디자인", "`/project-design`",
729
+ design.length || mockups.length || styleguide
730
+ ? {
731
+ mark: mockups.length ? "진행 중" : "규칙 수립",
732
+ detail:
733
+ [
734
+ design.length ? "기록 " + design.length + "개" : "",
735
+ styleguide ? "스타일 가이드" : "",
736
+ mockups.length ? "시안 " + mockups.length + "개" : "",
737
+ ]
738
+ .filter(Boolean)
739
+ .join(" · "),
740
+ }
741
+ : { mark: "미실행", detail: "디자인 규칙과 시안이 아직 없음" },
742
+ ],
743
+ [
744
+ "4", "구현", "`/project-build`",
745
+ buildStarted
746
+ ? {
747
+ mark: "진행 중",
748
+ detail: [
749
+ buildHas("parity.html") ? "대조기" : "",
750
+ buildHas("coverage.md") ? "커버리지" : "",
751
+ build.length ? "기록 " + build.length + "개" : "",
752
+ ].filter(Boolean).join(" · ") || "src 생성됨",
753
+ }
754
+ : { mark: "미실행", detail: "설계도와 시안을 코드로 옮기기 전" },
755
+ ],
756
+ ];
757
+
758
+ const table =
759
+ "| 단계 | 스킬 | 상태 | |\n| --- | --- | --- | --- |\n" +
760
+ rows
761
+ .map(
762
+ ([n, name, cmd, st]) =>
763
+ "| " + n + ". " + name + " | " + cmd +
764
+ " | **" + st.mark + "** | " + st.detail + " |",
765
+ )
766
+ .join("\n");
767
+
768
+ // 다음에 할 일 — 상태에서 유도한다
769
+ let next;
770
+ if (!docs.length) next = "먼저 `/project-interview` 로 기획안을 만드세요.";
771
+ else if (count("approved") === docs.length)
772
+ next = "문서가 모두 확정되었습니다. 3단계 디자인으로 넘어갈 수 있습니다.";
773
+ else if (blockers)
774
+ next =
775
+ "**Blocker " + blockers + "건이 등록되어 있습니다.** 홈에서 목록을 확인하고, " +
776
+ "해소되지 않은 것이 있다면 디자인·구현 단계로 넘어가기 전에 먼저 닫으세요.";
777
+ else if (count("draft") > count("review") + count("approved"))
778
+ next =
779
+ "문서 대부분이 초안입니다. 기획안을 흡수하려면 `/project-init ingest`, " +
780
+ "직접 채웠다면 `/project-init build` 로 갱신하세요.";
781
+ else next = "검토가 끝난 문서는 `status` 를 `approved` 로 올리세요.";
782
+
783
+ return { table, next: "> **다음에 할 일** — " + next };
784
+ }
785
+
786
+ /**
787
+ * 4단계 진행률.
788
+ *
789
+ * 3단계와 같은 원칙 — 파일이 있으면 몇 점이 아니라, **수용 기준을 얼마나 통과했는가**로 센다.
790
+ * 코드가 있다고 끝난 게 아니다. QA 체크리스트의 마지막 줄은 사용자 승인이고
791
+ * 그건 AI 가 대신 체크하지 않는다.
792
+ */
793
+ function stage4Progress(build, docs, docsDir) {
794
+ if (!docsDir) return 0;
795
+ const has = (f) => existsSync(join(docsDir, BUILD_DIR, f));
796
+ const hasSrc = existsSync(join(docsDir, "..", "src"));
797
+ if (!hasSrc && !build.length) return 0;
798
+
799
+ let p = 0;
800
+ if (hasSrc) p += 15;
801
+ if (has("parity.html")) p += 10;
802
+ if (has("coverage.md")) p += 10;
803
+ const rules = build.find((d) => /build-rules/.test(d.id));
804
+ if (rules) p += 10;
805
+
806
+ // 수용 기준 **통과 비율**이 절반을 차지한다.
807
+ // 이력에 이름이 나왔는지가 아니라 coverage.md 가 뭐라고 판정했는지를 센다 —
808
+ // "언급됨"과 "통과함"은 다르다. 그 둘을 섞으면 진행률이 다시 거짓말을 한다.
809
+ const prd = docs.find((d) => /prd/.test(d.id));
810
+ const cov = has("coverage.md")
811
+ ? readFileSync(join(docsDir, BUILD_DIR, "coverage.md"), "utf8") : "";
812
+ if (prd && cov) {
813
+ const ids = [...new Set([...prd.body.matchAll(/\bF-\d{2}\b/g)].map((m) => m[0]))];
814
+ if (ids.length) {
815
+ // 표의 **판정 칸만** 읽는다.
816
+ // · 줄 아무 데나 있는 ID 를 세면 "(F-03 준수)" 같은 언급이 잡힌다
817
+ // · 줄 아무 데나 있는 ✅ 를 세면 판정 근거 문장의 ✅ 가 🟡 을 이긴다
818
+ // 둘 다 실제로 겪었다. 그래서 행 형식(| ID | 요구사항 | 판정 | 근거 |)을 고정해 읽는다.
819
+ const verdict = (id) => {
820
+ for (const line of cov.split("\n")) {
821
+ if (!line.trim().startsWith("|")) continue;
822
+ const cells = line.split("|");
823
+ if (cells.length < 4) continue;
824
+ if (!new RegExp("\\b" + id + "\\b").test(cells[1])) continue;
825
+ const v = cells[3];
826
+ if (v.includes("✅")) return 1;
827
+ if (v.includes("🟡")) return 0.5;
828
+ return 0;
829
+ }
830
+ return null;
831
+ };
832
+ let score = 0;
833
+ for (const id of ids) {
834
+ const v = verdict(id);
835
+ if (v !== null) score += v;
836
+ }
837
+ p += Math.round((score / ids.length) * 45);
838
+ }
839
+ }
840
+ // 나머지 10 은 사용자 최종 승인. build-rules 의 status 로 받는다.
841
+ if (rules && rules.meta.status === "approved") p += 10;
842
+ return Math.min(p, 100);
843
+ }
844
+
845
+ /**
846
+ * 3단계 진행률.
847
+ *
848
+ * 예전에는 "파일이 있으면 몇 점" 이었다. 그래서 첫 시안을 만든 순간 100% 가 됐고,
849
+ * 그 뒤로 열세 번을 더 고쳤다. 진행률이 아무것도 뜻하지 않았다.
850
+ * 승인 전에는 100% 가 되지 않게 한다 — 남은 20% 가 곧 "사용자 확인" 이다.
851
+ */
852
+ function stage3Progress(design, mockups, styleguide, docsDir) {
853
+ const rules = design.find((d) => /design-rules/.test(d.id));
854
+ const has = (f) => existsSync(join(docsDir, DESIGN_DIR, f));
855
+ let p = 0;
856
+ if (rules) p += 20;
857
+ if (styleguide) p += 15;
858
+ if (has("tone-options.html")) p += 10;
859
+ if (has("audit.html")) p += 10;
860
+ if (mockups.length) p += 25;
861
+ // 승인은 사람만 줄 수 있다. design-rules.md 프론트매터의 status 로 받는다.
862
+ if (rules && rules.meta.status === "approved") p += 20;
863
+ else if (rules && rules.meta.status === "review") p += 8;
864
+ return p;
865
+ }
866
+
867
+ function stageProgress(docs, discovery, design, mockups, styleguide, config, docsDir = "", build = []) {
868
+ const clamp = (n) => Math.max(0, Math.min(100, Math.round(n)));
869
+ const s2 = docs.length
870
+ ? docs.reduce((sum, d) => sum + ({ draft: 25, review: 65, approved: 100 }[d.meta.status] || 0), 0) / docs.length
871
+ : 0;
872
+ const automatic = {
873
+ 1: discovery.length ? 100 : 0,
874
+ 2: s2,
875
+ 3: stage3Progress(design, mockups, styleguide, docsDir),
876
+ 4: stage4Progress(build, docs, docsDir),
877
+ };
878
+ const configured = config.stageProgress || {};
879
+ return Object.fromEntries([1, 2, 3, 4].map((n) => [n, clamp(configured[n] ?? automatic[n])]));
880
+ }
881
+
882
+ function progressClass(percent) {
883
+ if (percent <= 0) return "p-gray";
884
+ if (percent < 25) return "p-red";
885
+ if (percent < 50) return "p-yellow";
886
+ if (percent < 100) return "p-orange";
887
+ return "p-green";
888
+ }
889
+
890
+ /* ================================================================== *
891
+ * 렌더
892
+ * ================================================================== */
893
+
894
+ function render(docs, config, trace, discovery = [], design = [], mockups = [], styleguide = null, docsDir = "", build = []) {
895
+ const byId = new Map([...discovery, ...design, ...docs].map((d) => [d.id, d]));
896
+ const resolveLink = (target) => {
897
+ const d = byId.get(target);
898
+ return d ? { id: d.id, title: d.meta.title || d.id } : null;
899
+ };
900
+
901
+ const phases = [];
902
+ const progress = stageProgress(docs, discovery, design, mockups, styleguide, config, docsDir, build);
903
+ const stageTitle = (n, label) =>
904
+ '<div class="nav-group-title"><span>' + n + '단계 · ' + label + '</span><span class="nav-progress ' +
905
+ progressClass(progress[n]) + '">' + progress[n] + '%</span></div>';
906
+ for (const d of docs) {
907
+ const phase = d.meta.phase || "기타";
908
+ let g = phases.find((p) => p.name === phase);
909
+ if (!g) phases.push((g = { name: phase, docs: [] }));
910
+ g.docs.push(d);
911
+ }
912
+
913
+ const navLink = (id, label, status) =>
914
+ '<a class="nav-item" data-id="' +
915
+ id +
916
+ '" href="#/' +
917
+ id +
918
+ '"><span class="nav-dot s-' +
919
+ status +
920
+ '"></span><span>' +
921
+ escapeHtml(label) +
922
+ "</span></a>";
923
+
924
+ const guidePath = join(SKILL_DIR, "GUIDE.md");
925
+ let guideArticle = "";
926
+ let guideNav = "";
927
+ if (existsSync(guidePath)) {
928
+ const st = stageStatus(docs, discovery, trace, design, mockups, styleguide, build, docsDir);
929
+ const md = readFileSync(guidePath, "utf8")
930
+ .replace("{{stage_status}}", st.table)
931
+ .replace("{{next_action}}", st.next);
932
+ const { html, headings } = mdToHtml(md, { resolveLink });
933
+ const gh2 = headings.filter((h) => h.level === 2);
934
+ const toc = gh2.length
935
+ ? '<aside class="toc"><div class="toc-title">이 문서에서</div>' +
936
+ gh2
937
+ .map(
938
+ (h) =>
939
+ '<a href="#' + h.id + '" data-anchor="' + h.id + '">' +
940
+ escapeHtml(h.text) + "</a>",
941
+ )
942
+ .join("") +
943
+ "</aside>"
944
+ : "";
945
+ guideArticle =
946
+ '<article class="doc doc-guide" data-id="__guide" data-title="가이드">' +
947
+ '<div class="doc-inner"><div class="doc-body">' + html + "</div>" + toc +
948
+ "</div></article>";
949
+ guideNav = navLink("__guide", "가이드", "utility");
950
+ }
951
+
952
+ const mockupArticle = mockups.length
953
+ ? '<article class="doc" data-id="__mockups" data-title="디자인 시안">' +
954
+ '<div class="doc-inner"><div class="doc-body"><h1>디자인 시안</h1>' +
955
+ '<p class="home-lead">3단계에서 만든 화면 시안입니다. 새 탭에서 열립니다. ' +
956
+ "규칙과 수정 이력은 왼쪽 <strong>3단계 · 디자인</strong> 메뉴에 있습니다." +
957
+ (styleguide
958
+ ? ' 토큰 견본과 명도비는 <a href="' + styleguide + '" target="_blank" rel="noreferrer">스타일 가이드</a>에서 볼 수 있습니다.'
959
+ : "") +
960
+ "</p>" +
961
+ '<div class="mockups">' +
962
+ mockups
963
+ .map(
964
+ (m) =>
965
+ '<a class="mockup" href="' + m.href + '" target="_blank" rel="noreferrer">' +
966
+ '<span class="mockup-name">' + escapeHtml(m.file.replace(/\.html?$/i, "")) + "</span>" +
967
+ '<span class="mockup-go">열기 ↗</span></a>',
968
+ )
969
+ .join("") +
970
+ "</div></div></div></article>"
971
+ : "";
972
+
973
+ const sgLink = styleguide
974
+ ? '<a class="nav-item nav-ext" href="' + styleguide + '" target="_blank" rel="noreferrer">' +
975
+ '<span class="nav-dot s-stage3"></span><span>스타일 가이드 ↗</span></a>'
976
+ : "";
977
+
978
+ const designNav =
979
+ '<div class="nav-group nav-stage3 ' + progressClass(progress[3]) + '">' + stageTitle(3, "디자인") +
980
+ (design.length || mockups.length || styleguide ?
981
+ sgLink +
982
+ (mockups.length ? navLink("__mockups", "디자인 시안 " + mockups.length + "개", "stage3") : "") +
983
+ design.map((d) => navLink(d.id, d.meta.title || d.id, "stage3")).join("") :
984
+ '<div class="nav-empty">아직 산출물 없음</div>') + "</div>";
985
+
986
+ const discoveryNav =
987
+ '<div class="nav-group nav-stage1 ' + progressClass(progress[1]) + '">' + stageTitle(1, "인터뷰") +
988
+ (discovery.length ?
989
+ discovery
990
+ .map((d) => navLink(d.id, d.meta.title || d.id, "stage1"))
991
+ .join("") : '<div class="nav-empty">아직 산출물 없음</div>') + "</div>";
992
+
993
+ const sidebar = '<div class="nav-group nav-stage2 ' + progressClass(progress[2]) + '">' + stageTitle(2, "구조화") + phases
994
+ .map(
995
+ (p) =>
996
+ '<div class="nav-subgroup"><div class="nav-subgroup-title">' +
997
+ escapeHtml(p.name) +
998
+ "</div>" +
999
+ p.docs
1000
+ .map((d) => navLink(d.id, d.meta.title || d.id, d.meta.status || "draft"))
1001
+ .join("") +
1002
+ "</div>",
1003
+ )
1004
+ .join("") + "</div>";
1005
+ const implementationNav = '<div class="nav-group nav-stage4 ' + progressClass(progress[4]) + '">' +
1006
+ stageTitle(4, "구현") + '<div class="nav-empty">' +
1007
+ (progress[4] ? "구현 진행 중" : "아직 산출물 없음") + "</div></div>";
1008
+
1009
+ /* --- 문서 본문 (1단계 기록 + 2단계 문서) --- */
1010
+ const searchIndex = [];
1011
+ const articles = [...discovery, ...design, ...docs]
1012
+ .map((d) => {
1013
+ const { html, headings, blocks } = mdToHtml(d.body, { resolveLink, dateToggles: true });
1014
+ for (const b of blocks) {
1015
+ searchIndex.push({
1016
+ d: d.id,
1017
+ n: d.meta.title || d.id,
1018
+ a: b.anchor,
1019
+ h: b.heading,
1020
+ t: b.text,
1021
+ });
1022
+ }
1023
+ const h2s = headings.filter((h) => h.level === 2);
1024
+ const toc = h2s.length
1025
+ ? '<aside class="toc"><div class="toc-title">이 문서에서</div>' +
1026
+ headings
1027
+ .map(
1028
+ (h) =>
1029
+ '<a href="#' +
1030
+ h.id +
1031
+ '" data-anchor="' +
1032
+ h.id +
1033
+ '">' +
1034
+ escapeHtml(h.text) +
1035
+ "</a>",
1036
+ )
1037
+ .join("") +
1038
+ "</aside>"
1039
+ : "";
1040
+
1041
+ const back = trace.backlinks.get(d.id);
1042
+ const backHtml =
1043
+ back && back.size
1044
+ ? '<div class="backlinks"><div class="backlinks-title">이 문서를 참조하는 문서</div>' +
1045
+ [...back]
1046
+ .sort()
1047
+ .map(
1048
+ (src) =>
1049
+ '<a href="#/' +
1050
+ src +
1051
+ '">' +
1052
+ escapeHtml(byId.get(src)?.meta.title || src) +
1053
+ "</a>",
1054
+ )
1055
+ .join("") +
1056
+ "</div>"
1057
+ : "";
1058
+
1059
+ const defined = [...d.refs.defined].sort();
1060
+ const definedHtml = defined.length
1061
+ ? '<div class="idchips"><span class="idchips-title">이 문서가 정의한 ID</span>' +
1062
+ defined.map((id) => '<span class="chip">' + id + "</span>").join("") +
1063
+ "</div>"
1064
+ : "";
1065
+
1066
+ const st = d.stage;
1067
+ const status = st ? "stage" + st : d.meta.status || "draft";
1068
+ const statusText = st ? st + "단계" : STATUS_LABEL[status] || status;
1069
+ const stage1 = !!st;
1070
+ return (
1071
+ '<article class="doc" data-id="' +
1072
+ d.id +
1073
+ '" data-title="' +
1074
+ escapeHtml(d.meta.title || d.id) +
1075
+ '"><div class="doc-inner"><div class="doc-body">' +
1076
+ '<div class="doc-meta"><span class="pill s-' +
1077
+ status +
1078
+ '">' +
1079
+ statusText +
1080
+ "</span>" +
1081
+ (stage1
1082
+ ? ""
1083
+ : '<span class="meta-sep"></span><span>담당 ' +
1084
+ escapeHtml(d.meta.owner || "—") +
1085
+ "</span>") +
1086
+ '<span class="meta-sep"></span><span>수정 ' +
1087
+ escapeHtml(d.meta.updated || d.meta.date || "—") +
1088
+ '</span><span class="meta-sep"></span><span class="mono">' +
1089
+ escapeHtml(d.file) +
1090
+ "</span></div>" +
1091
+ html +
1092
+ definedHtml +
1093
+ backHtml +
1094
+ "</div>" +
1095
+ toc +
1096
+ "</div></article>"
1097
+ );
1098
+ })
1099
+ .join("\n");
1100
+
1101
+ /* --- 홈 대시보드 --- */
1102
+ const blockerPrefix = schema.traceability.blockerPrefix;
1103
+ const blockers = [...trace.owners.entries()]
1104
+ .filter(([id]) => id.startsWith(blockerPrefix + "-"))
1105
+ .sort();
1106
+
1107
+ const blockerPanel = blockers.length
1108
+ ? '<div class="panel panel-alert"><div class="panel-title">Blocker ' +
1109
+ blockers.length +
1110
+ "건 — 해소 전 다음 단계 진입 불가</div>" +
1111
+ blockers
1112
+ .map(
1113
+ ([id, info]) =>
1114
+ '<a class="panel-row" href="#/' +
1115
+ info.doc +
1116
+ '"><span class="chip chip-alert">' +
1117
+ id +
1118
+ "</span><span>" +
1119
+ escapeHtml(info.label || "") +
1120
+ "</span></a>",
1121
+ )
1122
+ .join("") +
1123
+ "</div>"
1124
+ : "";
1125
+
1126
+ const stage1Panel = discovery.length
1127
+ ? '<div class="panel panel-stage1"><div class="panel-title">1단계 · 기획 기록</div>' +
1128
+ discovery
1129
+ .map(
1130
+ (d) =>
1131
+ '<a class="panel-row" href="#/' +
1132
+ d.id +
1133
+ '"><span class="chip chip-stage1">1단계</span><span>' +
1134
+ escapeHtml(d.meta.title || d.id) +
1135
+ (d.meta.summary ? " — " + escapeHtml(d.meta.summary) : "") +
1136
+ "</span></a>",
1137
+ )
1138
+ .join("") +
1139
+ "</div>"
1140
+ : '<div class="panel"><div class="panel-title">1단계 · 기획 기록</div>' +
1141
+ '<div class="panel-row"><span class="muted">아직 없습니다. ' +
1142
+ "인터뷰로 기획안을 만들려면 project-interview 스킬을 실행하세요.</span></div></div>";
1143
+
1144
+ const stage3Panel = design.length || mockups.length || styleguide
1145
+ ? '<div class="panel panel-stage3"><div class="panel-title">3단계 · 디자인</div>' +
1146
+ (styleguide
1147
+ ? '<a class="panel-row" href="' + styleguide + '" target="_blank" rel="noreferrer">' +
1148
+ '<span class="chip chip-stage3">가이드</span>' +
1149
+ "<span>스타일 가이드 — 토큰·명도비·상태를 실제로 렌더해 봅니다 ↗</span></a>"
1150
+ : "") +
1151
+ (mockups.length
1152
+ ? '<a class="panel-row" href="#/__mockups"><span class="chip chip-stage3">시안</span>' +
1153
+ "<span>" + mockups.length + "개 — 새 탭에서 열어볼 수 있습니다</span></a>"
1154
+ : "") +
1155
+ design
1156
+ .map(
1157
+ (d) =>
1158
+ '<a class="panel-row" href="#/' + d.id +
1159
+ '"><span class="chip chip-stage3">3단계</span><span>' +
1160
+ escapeHtml(d.meta.title || d.id) + "</span></a>",
1161
+ )
1162
+ .join("") +
1163
+ "</div>"
1164
+ : "";
1165
+
1166
+ const scopePanel = config.options
1167
+ ? '<div class="panel"><div class="panel-title">이 프로젝트의 범위</div>' +
1168
+ '<div class="scope">' +
1169
+ Object.entries(config.options)
1170
+ .map(
1171
+ ([id, on]) =>
1172
+ '<span class="scope-item ' +
1173
+ (on ? "on" : "off") +
1174
+ '">' +
1175
+ escapeHtml(id) +
1176
+ "</span>",
1177
+ )
1178
+ .join("") +
1179
+ "</div></div>"
1180
+ : "";
1181
+
1182
+ const todos = collectTodos(docs);
1183
+ const todoPanel = todos.size
1184
+ ? '<div class="panel"><div class="panel-title">대기 중인 정보 제공</div>' +
1185
+ [...todos.entries()]
1186
+ .sort()
1187
+ .map(([who, docsMap]) => {
1188
+ const total = [...docsMap.values()].reduce((a, b) => a + b, 0);
1189
+ return (
1190
+ '<div class="panel-row"><span class="chip">' +
1191
+ escapeHtml(TODO_LABEL[who] || who) +
1192
+ '</span><span>' +
1193
+ total +
1194
+ "건 — " +
1195
+ [...docsMap.keys()]
1196
+ .sort()
1197
+ .map(
1198
+ (id) =>
1199
+ '<a href="#/' + id + '">' + escapeHtml(byId.get(id)?.meta.title || id) + "</a>",
1200
+ )
1201
+ .join(", ") +
1202
+ "</span></div>"
1203
+ );
1204
+ })
1205
+ .join("") +
1206
+ "</div>"
1207
+ : "";
1208
+
1209
+ const cards = phases
1210
+ .map(
1211
+ (p) =>
1212
+ '<div class="card-group"><h3 class="card-group-title">' +
1213
+ escapeHtml(p.name) +
1214
+ '</h3><div class="cards">' +
1215
+ p.docs
1216
+ .map(
1217
+ (d) =>
1218
+ '<a class="card" href="#/' +
1219
+ d.id +
1220
+ '"><div class="card-head"><span class="card-title">' +
1221
+ escapeHtml(d.meta.title || d.id) +
1222
+ '</span><span class="pill s-' +
1223
+ (d.meta.status || "draft") +
1224
+ '">' +
1225
+ (STATUS_LABEL[d.meta.status] || d.meta.status || "초안") +
1226
+ '</span></div><p class="card-summary">' +
1227
+ escapeHtml(d.meta.summary || "") +
1228
+ "</p></a>",
1229
+ )
1230
+ .join("") +
1231
+ "</div></div>",
1232
+ )
1233
+ .join("");
1234
+
1235
+ const count = (s) => docs.filter((d) => d.meta.status === s).length;
1236
+ const home =
1237
+ '<article class="doc doc-home" data-id="__home" data-title="홈"><div class="doc-inner"><div class="doc-body">' +
1238
+ '<div class="home-eyebrow">Project Documentation</div><h1>' +
1239
+ escapeHtml(config.project) +
1240
+ '</h1><p class="home-lead">구현 이전에 정립되어야 하는 정의를 모아둔 곳입니다. ' +
1241
+ "왼쪽에서 문서를 고르거나 아래 목록에서 시작하세요. " +
1242
+ (guideNav ? '처음이라면 <a href="#/__guide">가이드</a>부터 보세요.' : "") +
1243
+ "</p>" +
1244
+ '<div class="home-stats"><span><b>' +
1245
+ docs.length +
1246
+ "</b>문서</span><span><b>" +
1247
+ count("approved") +
1248
+ "</b>확정</span><span><b>" +
1249
+ count("review") +
1250
+ "</b>검토중</span><span><b>" +
1251
+ count("draft") +
1252
+ "</b>초안</span></div>" +
1253
+ stage1Panel +
1254
+ stage3Panel +
1255
+ blockerPanel +
1256
+ scopePanel +
1257
+ todoPanel +
1258
+ cards +
1259
+ "</div></div></article>";
1260
+
1261
+ /* --- 추적성 매트릭스 --- */
1262
+ const traceRows = [...trace.owners.entries()]
1263
+ .sort()
1264
+ .map(([id, info]) => {
1265
+ const refDocs = [...(trace.refs.get(id) || [])].sort();
1266
+ return (
1267
+ "<tr><td><span class=\"chip\">" +
1268
+ id +
1269
+ '</span></td><td>' +
1270
+ escapeHtml(info.label || "") +
1271
+ '</td><td><a href="#/' +
1272
+ info.doc +
1273
+ '">' +
1274
+ escapeHtml(byId.get(info.doc)?.meta.title || info.doc) +
1275
+ "</a></td><td>" +
1276
+ (refDocs.length
1277
+ ? refDocs
1278
+ .map(
1279
+ (r) =>
1280
+ '<a href="#/' + r + '">' + escapeHtml(byId.get(r)?.meta.title || r) + "</a>",
1281
+ )
1282
+ .join("<br>")
1283
+ : '<span class="muted">참조 없음</span>') +
1284
+ "</td></tr>"
1285
+ );
1286
+ })
1287
+ .join("");
1288
+
1289
+ const traceArticle =
1290
+ '<article class="doc" data-id="__trace" data-title="추적성 매트릭스"><div class="doc-inner"><div class="doc-body">' +
1291
+ "<h1>추적성 매트릭스</h1>" +
1292
+ '<p class="home-lead">요구사항·리스크·결정 ID가 어디서 정의되고 어디서 참조되는지 자동 수집한 표입니다. ' +
1293
+ "참조가 없는 항목은 고아이거나, 아직 후속 문서에 반영되지 않은 것입니다.</p>" +
1294
+ '<div class="table-wrap"><table><thead><tr><th>ID</th><th>내용</th><th>정의</th><th>참조</th></tr></thead><tbody>' +
1295
+ (traceRows || '<tr><td colspan="4" class="muted">수집된 ID가 없습니다</td></tr>') +
1296
+ "</tbody></table></div></div></div></article>";
1297
+
1298
+ return (
1299
+ "<!doctype html>\n" +
1300
+ '<html lang="ko"><head><meta charset="utf-8">' +
1301
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' +
1302
+ "<title>" +
1303
+ escapeHtml(config.project) +
1304
+ " — Docs</title><style>" +
1305
+ CSS +
1306
+ "</style></head><body>" +
1307
+ '<button class="menu-btn" id="menuBtn" aria-label="문서 목록 열기">' + ICON.panel + "</button>" +
1308
+ '<nav class="sidebar" id="sidebar">' +
1309
+ '<div class="brand-row">' +
1310
+ '<a class="brand" href="#/__home"><span class="brand-mark"></span><span>' +
1311
+ escapeHtml(config.project) +
1312
+ "</span></a>" +
1313
+ '<button class="icon-btn" id="collapseBtn" title="사이드바 접기" aria-label="사이드바 접기">' +
1314
+ ICON.panel +
1315
+ "</button></div>" +
1316
+ '<div class="search-wrap"><div class="search-field">' +
1317
+ '<span class="search-icon">' + ICON.search + "</span>" +
1318
+ '<input id="search" type="search" placeholder="문서 검색" autocomplete="off" spellcheck="false">' +
1319
+ '<kbd class="search-kbd">/</kbd></div></div>' +
1320
+ '<div class="nav" id="nav">' +
1321
+ guideNav +
1322
+ navLink("__home", "홈", "home") +
1323
+ navLink("__trace", "추적성 매트릭스", "utility") +
1324
+ discoveryNav +
1325
+ sidebar +
1326
+ designNav +
1327
+ implementationNav +
1328
+ "</div>" +
1329
+ '<button class="theme-btn" id="themeBtn">테마 전환</button></nav>' +
1330
+ '<div class="scrim" id="scrim"></div>' +
1331
+ '<main id="main">' +
1332
+ '<header class="topbar" id="topbar">' +
1333
+ '<button class="icon-btn topbar-reveal" id="revealBtn" title="사이드바 열기" aria-label="사이드바 열기">' +
1334
+ ICON.panel +
1335
+ "</button>" +
1336
+ '<span class="topbar-title" id="topbarTitle"></span>' +
1337
+ '<button class="icon-btn" id="widthBtn" title="본문 너비" aria-label="본문 너비 전환" aria-pressed="false">' +
1338
+ ICON.wide +
1339
+ "</button></header>" +
1340
+ '<article class="doc" data-id="__search" data-title="검색"><div class="doc-inner">' +
1341
+ '<div class="doc-body"><h1>검색</h1><div id="results"></div></div></div></article>' +
1342
+ guideArticle +
1343
+ home +
1344
+ mockupArticle +
1345
+ traceArticle +
1346
+ articles +
1347
+ "</main>" +
1348
+ '<script id="searchIndex" type="application/json">' +
1349
+ JSON.stringify(searchIndex).replace(/</g, "\\u003c") +
1350
+ "<\/script>" +
1351
+ "<script>" +
1352
+ JS +
1353
+ "<\/script></body></html>"
1354
+ );
1355
+ }
1356
+
1357
+ /* ================================================================== *
1358
+ * 스타일 / 클라이언트 스크립트
1359
+ * ================================================================== */
1360
+
1361
+ const CSS = `
1362
+ *,*::before,*::after{box-sizing:border-box}
1363
+ :root{
1364
+ --bg:#fff; --bg-side:#f7f7f5; --bg-code:#f1f1ef; --bg-hl:#fdf3d9;
1365
+ --tx:#37352f; --tx-dim:rgba(55,53,47,.65); --tx-faint:rgba(55,53,47,.45);
1366
+ --line:rgba(55,53,47,.09); --line-strong:rgba(55,53,47,.16);
1367
+ --hover:rgba(55,53,47,.055); --active:rgba(55,53,47,.085);
1368
+ --accent:#2383e2; --red:#eb5757; --yellow:#d9a300; --orange:#d9730d; --green:#0f7b6c; --stage1:#6941c6; --stage3:#0f7b6c;
1369
+ --s-draft:#9b9a97; --s-review:#d9730d; --s-approved:#0f7b6c;
1370
+ --alert-bg:#fdf2f0; --alert-line:#f0c8c0; --alert-tx:#b02f1c;
1371
+ --side-w:264px; --content:768px; --content-wide:1180px;
1372
+ --ease:cubic-bezier(.32,.72,0,1);
1373
+ }
1374
+ [data-theme=dark]{
1375
+ --bg:#191919; --bg-side:#202020; --bg-code:#2b2b2b; --bg-hl:#4a3b1a;
1376
+ --tx:rgba(255,255,255,.85); --tx-dim:rgba(255,255,255,.55); --tx-faint:rgba(255,255,255,.38);
1377
+ --line:rgba(255,255,255,.09); --line-strong:rgba(255,255,255,.16);
1378
+ --hover:rgba(255,255,255,.055); --active:rgba(255,255,255,.09);
1379
+ --accent:#529cca; --stage1:#9e77ed; --stage3:#4dab9a;
1380
+ --s-draft:#979a9b; --s-review:#d9730d; --s-approved:#4dab9a;
1381
+ --alert-bg:#2d1f1c; --alert-line:#5c332a; --alert-tx:#ff9b8a;
1382
+ }
1383
+ html{-webkit-text-size-adjust:100%}
1384
+ body{
1385
+ margin:0; background:var(--bg); color:var(--tx);
1386
+ font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",
1387
+ "Pretendard Variable",Pretendard,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;
1388
+ font-size:16px; line-height:1.6; word-break:keep-all; overflow-wrap:break-word;
1389
+ -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;
1390
+ font-variant-numeric:tabular-nums;
1391
+ }
1392
+ a{color:inherit}
1393
+ .muted{color:var(--tx-faint)}
1394
+ :focus-visible{outline:2px solid var(--accent); outline-offset:2px; border-radius:3px}
1395
+
1396
+ .icon-btn{
1397
+ display:inline-flex; align-items:center; justify-content:center; flex:none;
1398
+ width:28px; height:28px; padding:0; border:none; border-radius:6px; cursor:pointer;
1399
+ background:transparent; color:var(--tx-faint); line-height:0;
1400
+ transition:background .12s, color .12s;
1401
+ }
1402
+ .icon-btn svg,.brand-mark,.search-icon svg{display:block}
1403
+ .icon-btn:hover{background:var(--hover); color:var(--tx)}
1404
+ .icon-btn[aria-pressed=true]{color:var(--accent)}
1405
+
1406
+ /* ── Sidebar ─────────────────────────────────────────── */
1407
+ .sidebar{
1408
+ position:fixed; inset:0 auto 0 0; width:var(--side-w); background:var(--bg-side);
1409
+ border-right:1px solid var(--line); display:flex; flex-direction:column; z-index:20;
1410
+ transition:transform .28s var(--ease);
1411
+ }
1412
+ .brand-row{display:flex; align-items:center; gap:6px; padding:10px 10px 8px; flex:none}
1413
+ .brand{
1414
+ display:flex; align-items:center; gap:8px; flex:1; min-width:0;
1415
+ font-weight:600; font-size:14px; line-height:1.2; text-decoration:none;
1416
+ letter-spacing:-.01em; padding-left:2px;
1417
+ }
1418
+ .brand>span:last-child{overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
1419
+ .brand-mark{width:17px; height:17px; border-radius:4px; flex:none;
1420
+ background:linear-gradient(135deg,#0a2240,#00a3e0)}
1421
+ .search-wrap{padding:0 10px 8px; flex:none}
1422
+ .search-field{position:relative; display:block}
1423
+ .search-icon{position:absolute; left:9px; top:50%; transform:translateY(-50%);
1424
+ color:var(--tx-faint); pointer-events:none; display:flex; line-height:0}
1425
+ .search-kbd{position:absolute; right:9px; top:50%; transform:translateY(-50%);
1426
+ display:flex; align-items:center; height:16px;
1427
+ font:inherit; font-size:11px; line-height:1; color:var(--tx-faint); background:var(--bg);
1428
+ border:1px solid var(--line-strong); border-radius:3px; padding:0 5px;
1429
+ pointer-events:none; transition:opacity .12s}
1430
+ #search{
1431
+ display:block; height:30px;
1432
+ width:100%; padding:0 30px 0 28px; font:inherit; font-size:13.5px; color:var(--tx);
1433
+ background:var(--bg); border:1px solid var(--line-strong); border-radius:6px; outline:none;
1434
+ transition:border-color .12s, box-shadow .12s;
1435
+ }
1436
+ #search:focus{border-color:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent)}
1437
+ #search:focus + .search-kbd{opacity:0}
1438
+ #search::-webkit-search-cancel-button{display:none}
1439
+ .nav{flex:1; overflow-y:auto; overscroll-behavior:contain; padding:2px 8px 16px}
1440
+ .nav-group{margin-top:16px}
1441
+ .nav-group-title{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 8px;font-size:11px;font-weight:650;letter-spacing:.05em;color:var(--tx-faint);text-transform:uppercase}
1442
+ .nav-progress{font-weight:700;letter-spacing:0}.nav-progress.p-gray{color:var(--s-draft)}.nav-progress.p-red{color:var(--red)}.nav-progress.p-yellow{color:var(--yellow)}.nav-progress.p-orange{color:var(--orange)}.nav-progress.p-green{color:var(--green)}
1443
+ .nav-subgroup{margin-top:9px}.nav-subgroup-title{padding:3px 8px;font-size:10.5px;color:var(--tx-faint)}
1444
+ .nav-empty{padding:4px 8px;font-size:12px;color:var(--tx-faint)}
1445
+ .nav-item{
1446
+ display:flex; align-items:center; gap:8px; padding:5px 8px; border-radius:6px;
1447
+ font-size:13.5px; color:var(--tx-dim); text-decoration:none; line-height:1.4;
1448
+ transition:background .1s, color .1s;
1449
+ }
1450
+ .nav-item:hover{background:var(--hover); color:var(--tx)}
1451
+ .nav-item.active{background:var(--active); color:var(--tx); font-weight:500}
1452
+ .nav-dot{width:5px; height:5px; border-radius:50%; flex:none; background:var(--s-draft)}
1453
+ .nav-dot.s-review{background:var(--orange)}
1454
+ .nav-dot.s-approved{background:var(--s-approved)}
1455
+ .nav-dot.s-utility{background:var(--s-draft)}
1456
+ .nav-dot.s-home{background:var(--accent)}
1457
+ .nav-group.p-gray .nav-dot{background:var(--s-draft)}.nav-group.p-red .nav-dot{background:var(--red)}.nav-group.p-yellow .nav-dot{background:var(--yellow)}.nav-group.p-orange .nav-dot{background:var(--orange)}.nav-group.p-green .nav-dot{background:var(--green)}
1458
+ .pill.s-stage3{background:var(--stage3)}
1459
+ .chip-stage3{background:var(--stage3); color:#fff}
1460
+ .nav-group.nav-stage3{padding-bottom:12px; border-bottom:1px solid var(--line)}
1461
+ .panel-stage3{border-color:var(--stage3)}
1462
+ .panel-stage3 .panel-title{color:var(--stage3)}
1463
+ .mockups{display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:10px; margin-top:20px}
1464
+ .mockup{display:flex; align-items:center; justify-content:space-between; gap:10px;
1465
+ padding:14px 16px; border:1px solid var(--line-strong); border-radius:8px;
1466
+ text-decoration:none; background:var(--bg); transition:background .12s, border-color .12s}
1467
+ .mockup:hover{background:var(--hover); border-color:var(--stage3)}
1468
+ .mockup-name{font-weight:600; font-size:14px}
1469
+ .mockup-go{font-size:12px; color:var(--tx-faint); white-space:nowrap}
1470
+ .nav-item[data-id="__guide"]{font-weight:500; color:var(--tx)}
1471
+ .nav-item.nav-ext{text-decoration:none}
1472
+ .nav-group.nav-stage1{padding-bottom:12px; border-bottom:1px solid var(--line)}
1473
+ .theme-btn{flex:none; padding:9px 14px; font:inherit; font-size:12.5px; text-align:left;
1474
+ color:var(--tx-faint); background:none; border:none; border-top:1px solid var(--line); cursor:pointer}
1475
+ .theme-btn:hover{color:var(--tx)}
1476
+
1477
+ /* ── Collapse ────────────────────────────────────────── */
1478
+ body.sb-collapsed .sidebar{transform:translateX(-100%)}
1479
+ body.sb-collapsed main{margin-left:0}
1480
+ .topbar-reveal{display:none}
1481
+ body.sb-collapsed .topbar-reveal{display:inline-flex}
1482
+
1483
+ /* ── Main ────────────────────────────────────────────── */
1484
+ main{margin-left:var(--side-w); min-height:100vh; transition:margin-left .28s var(--ease)}
1485
+ .topbar{
1486
+ position:sticky; top:0; z-index:10; display:flex; align-items:center; gap:8px;
1487
+ height:44px; padding:0 16px; background:color-mix(in srgb, var(--bg) 82%, transparent);
1488
+ backdrop-filter:saturate(180%) blur(12px); border-bottom:1px solid transparent;
1489
+ transition:border-color .15s;
1490
+ }
1491
+ body.scrolled .topbar{border-bottom-color:var(--line)}
1492
+ .topbar-title{flex:1; min-width:0; font-size:13.5px; font-weight:500; color:var(--tx-dim);
1493
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
1494
+
1495
+ .doc{display:none}
1496
+ .doc.active{display:block}
1497
+ .doc-inner{display:flex; gap:44px; align-items:flex-start; padding:40px 60px 160px; justify-content:center}
1498
+ .doc-body{width:100%; max-width:var(--content); min-width:0; transition:max-width .32s var(--ease)}
1499
+ body.wide .doc-body{max-width:var(--content-wide)}
1500
+ body.wide .toc{opacity:.55}
1501
+ .doc-meta{display:flex; align-items:center; gap:9px; flex-wrap:wrap;
1502
+ font-size:12.5px; color:var(--tx-faint); margin-bottom:28px}
1503
+ .meta-sep{width:3px; height:3px; border-radius:50%; background:var(--tx-faint)}
1504
+ .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
1505
+
1506
+ .toc{position:sticky; top:64px; width:186px; flex:none; font-size:12.5px; padding-top:2px;
1507
+ transition:opacity .3s}
1508
+ .toc-title{color:var(--tx-faint); font-size:11px; font-weight:600; letter-spacing:.05em;
1509
+ text-transform:uppercase; margin-bottom:8px}
1510
+ .toc a{display:block; padding:4px 0 4px 11px; color:var(--tx-dim); text-decoration:none;
1511
+ border-left:2px solid var(--line); line-height:1.45; transition:color .12s, border-color .12s}
1512
+ .toc a:hover{color:var(--tx)}
1513
+ .toc a.active{color:var(--tx); border-left-color:var(--tx); font-weight:500}
1514
+
1515
+ /* ── Typography ──────────────────────────────────────── */
1516
+ .doc-body h1{font-size:40px; line-height:1.15; letter-spacing:-.022em; font-weight:700; margin:0 0 4px}
1517
+ .doc-body h2{font-size:25px; line-height:1.3; letter-spacing:-.012em; font-weight:600;
1518
+ margin:1.9em 0 .35em; padding-top:.15em}
1519
+ .doc-body h3{font-size:19px; line-height:1.35; letter-spacing:-.008em; font-weight:600; margin:1.5em 0 .25em}
1520
+ .doc-body h4{font-size:16px; font-weight:600; margin:1.3em 0 .15em}
1521
+ .doc-body p{margin:.5em 0; line-height:1.75}
1522
+ .doc-body ul,.doc-body ol{margin:.45em 0; padding-left:1.5em}
1523
+ .doc-body li{margin:.2em 0; line-height:1.7}
1524
+ .doc-body li::marker{color:var(--tx-faint)}
1525
+ .doc-body ul.task-list{list-style:none; padding-left:.15em}
1526
+ .doc-body ul.task-list li{display:flex; align-items:flex-start; gap:8px}
1527
+ .doc-body ul.task-list input{margin-top:.45em; flex:none; accent-color:var(--accent)}
1528
+ .doc-body a{color:var(--tx); text-decoration:underline;
1529
+ text-decoration-color:var(--line-strong); text-underline-offset:3px; text-decoration-thickness:1px}
1530
+ .doc-body a:hover{text-decoration-color:var(--tx)}
1531
+ .doc-body strong{font-weight:600}
1532
+ .doc-body code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.85em;
1533
+ background:var(--bg-code); color:var(--red); padding:.15em .4em; border-radius:4px}
1534
+ .swatch{display:inline-block; width:.85em; height:.85em; border-radius:3px;
1535
+ border:1px solid var(--line-strong); margin-right:.35em; vertical-align:-.08em;
1536
+ box-shadow:inset 0 0 0 1px rgba(255,255,255,.35)}
1537
+ .doc-body pre{background:var(--bg-side); border:1px solid var(--line); border-radius:6px;
1538
+ padding:16px 18px; overflow-x:auto; margin:.9em 0; line-height:1.55}
1539
+ .doc-body pre code{background:none; color:var(--tx); padding:0; font-size:12.5px}
1540
+ .doc-body blockquote{margin:1em 0; padding:12px 16px; background:var(--bg-side);
1541
+ border-left:3px solid var(--tx-faint); border-radius:0 4px 4px 0}
1542
+ .doc-body blockquote p{margin:.2em 0}
1543
+ .doc-body hr{border:none; border-top:1px solid var(--line); margin:2.4em 0}
1544
+ .table-wrap{overflow-x:auto; margin:1em 0; border-radius:6px}
1545
+ .doc-body table{border-collapse:collapse; font-size:13.5px; min-width:100%}
1546
+ .doc-body th,.doc-body td{border:1px solid var(--line-strong); padding:8px 12px;
1547
+ text-align:left; vertical-align:top; line-height:1.6}
1548
+ .doc-body th{background:var(--bg-side); font-weight:600; white-space:nowrap; font-size:13px}
1549
+ .doc-body tbody tr:hover{background:var(--hover)}
1550
+ .log-date{margin:1.1em 0;border:1px solid var(--line);border-radius:8px;background:var(--bg)}
1551
+ .log-date>summary{cursor:pointer;padding:12px 14px;font-weight:650;list-style-position:inside}
1552
+ .log-date-body{padding:0 14px 14px}.log-date-body>h3:first-child{margin-top:.5em}
1553
+ .doc-body h2[id]:target,.flash{animation:flash 1.6s var(--ease)}
1554
+ @keyframes flash{0%,35%{background:var(--bg-hl)}100%{background:transparent}}
1555
+
1556
+ a.wikilink{text-decoration-color:var(--accent)}
1557
+ a.wikilink::before{content:"→ "; color:var(--accent); text-decoration:none}
1558
+ a.wikilink.broken{color:var(--red); text-decoration-style:wavy}
1559
+
1560
+ .pill{display:inline-block; padding:1.5px 8px; border-radius:4px; font-size:11.5px;
1561
+ font-weight:500; white-space:nowrap; color:#fff; background:var(--s-draft)}
1562
+ .pill.s-review{background:var(--s-review)}
1563
+ .pill.s-approved{background:var(--s-approved)}
1564
+ .pill.s-stage1{background:var(--stage1)}
1565
+ .chip{display:inline-block; padding:1.5px 7px; border-radius:4px; font-size:12px; font-weight:600;
1566
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg-code); color:var(--tx-dim)}
1567
+ .chip-alert{background:var(--alert-line); color:var(--alert-tx)}
1568
+ .chip-stage1{background:var(--stage1); color:#fff}
1569
+
1570
+ .idchips{margin-top:3em; padding-top:14px; border-top:1px solid var(--line); font-size:13px}
1571
+ .idchips-title{color:var(--tx-faint); margin-right:8px}
1572
+ .idchips .chip{margin:0 4px 4px 0}
1573
+ .backlinks{margin-top:1.6em; padding:14px 16px; background:var(--bg-side); border-radius:6px; font-size:13.5px}
1574
+ .backlinks-title{color:var(--tx-faint); font-size:11px; font-weight:600; letter-spacing:.05em;
1575
+ text-transform:uppercase; margin-bottom:7px}
1576
+ .backlinks a{display:block; padding:2px 0; color:var(--tx-dim); text-decoration:none}
1577
+ .backlinks a:hover{color:var(--tx)}
1578
+
1579
+ .panel{margin:24px 0; border:1px solid var(--line-strong); border-radius:8px; padding:14px 16px; font-size:13.5px}
1580
+ .panel-alert{background:var(--alert-bg); border-color:var(--alert-line)}
1581
+ .panel-stage1{border-color:var(--stage1)}
1582
+ .panel-title{font-size:11px; font-weight:600; letter-spacing:.05em; text-transform:uppercase;
1583
+ color:var(--tx-faint); margin-bottom:9px}
1584
+ .panel-alert .panel-title{color:var(--alert-tx)}
1585
+ .panel-stage1 .panel-title{color:var(--stage1)}
1586
+ .panel-row{display:flex; align-items:baseline; gap:9px; padding:3px 0; text-decoration:none; color:var(--tx-dim)}
1587
+ a.panel-row:hover{color:var(--tx)}
1588
+ .panel-row .chip{flex:none}
1589
+ .scope{display:flex; flex-wrap:wrap; gap:6px}
1590
+ .scope-item{padding:2px 9px; border-radius:4px; font-size:12px;
1591
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
1592
+ .scope-item.on{background:var(--s-approved); color:#fff}
1593
+ .scope-item.off{background:var(--bg-code); color:var(--tx-faint); text-decoration:line-through}
1594
+
1595
+ .home-eyebrow{font-size:11.5px; font-weight:600; letter-spacing:.09em; text-transform:uppercase;
1596
+ color:var(--tx-faint); margin-bottom:10px}
1597
+ .home-lead{font-size:16px; color:var(--tx-dim); max-width:62ch; margin:.6em 0 0; line-height:1.7}
1598
+ .home-stats{display:flex; gap:24px; margin:26px 0 8px; font-size:13px; color:var(--tx-dim)}
1599
+ .home-stats b{color:var(--tx); font-size:18px; font-weight:600; margin-right:5px}
1600
+ .card-group{margin-top:36px}
1601
+ .card-group-title{font-size:11.5px; font-weight:600; letter-spacing:.06em; text-transform:uppercase;
1602
+ color:var(--tx-faint); margin:0 0 12px}
1603
+ .cards{display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:10px}
1604
+ .card{display:block; padding:14px 16px; border:1px solid var(--line-strong); border-radius:8px;
1605
+ text-decoration:none; background:var(--bg); transition:background .12s, border-color .12s, transform .12s}
1606
+ .card:hover{background:var(--hover); border-color:var(--tx-faint)}
1607
+ .card-head{display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:5px}
1608
+ .card-title{font-weight:600; font-size:14px}
1609
+ .card-summary{margin:0; font-size:12.5px; color:var(--tx-dim); line-height:1.55}
1610
+
1611
+ /* ── Search results ──────────────────────────────────── */
1612
+ .res-meta{font-size:13px; color:var(--tx-faint); margin:-4px 0 18px}
1613
+ .res{display:block; padding:12px 14px; margin-bottom:8px; border:1px solid var(--line);
1614
+ border-radius:8px; text-decoration:none; transition:background .12s, border-color .12s}
1615
+ .res:hover{background:var(--hover); border-color:var(--line-strong)}
1616
+ .res-head{display:flex; align-items:baseline; gap:8px; margin-bottom:4px; flex-wrap:wrap}
1617
+ .res-doc{font-size:13.5px; font-weight:600; color:var(--tx)}
1618
+ .res-sec{font-size:12.5px; color:var(--tx-faint)}
1619
+ .res-snip{font-size:13px; color:var(--tx-dim); line-height:1.6; margin:0}
1620
+ .res mark{background:var(--bg-hl); color:inherit; border-radius:2px; padding:0 1px}
1621
+ .res-empty{display:flex; flex-direction:column; align-items:center; justify-content:center;
1622
+ text-align:center; padding:72px 24px; color:var(--tx-faint); gap:2px}
1623
+ .res-empty svg{color:var(--line-strong); margin-bottom:12px}
1624
+ .res-empty-title{margin:0; font-size:15px; font-weight:500; color:var(--tx-dim)}
1625
+ .res-empty-hint{margin:0; font-size:13px; line-height:1.6; max-width:38ch}
1626
+
1627
+ .menu-btn,.scrim{display:none}
1628
+
1629
+ @media (max-width:1240px){ .toc{display:none} .doc-inner{padding:36px 44px 120px} }
1630
+ @media (max-width:860px){
1631
+ .menu-btn{display:inline-flex; position:fixed; top:8px; left:10px; z-index:40;
1632
+ width:30px; height:30px; padding:0; cursor:pointer; background:var(--bg); color:var(--tx);
1633
+ border:1px solid var(--line-strong); border-radius:6px;
1634
+ align-items:center; justify-content:center; line-height:0}
1635
+ .menu-btn svg{display:block}
1636
+ .sidebar{transform:translateX(-100%)}
1637
+ body.nav-open .sidebar{transform:none}
1638
+ body.nav-open .scrim{display:block; position:fixed; inset:0; background:rgba(0,0,0,.35); z-index:15}
1639
+ main{margin-left:0}
1640
+ .topbar{padding-left:50px}
1641
+ .topbar-reveal{display:none !important}
1642
+ .doc-inner{padding:28px 20px 100px}
1643
+ .doc-body h1{font-size:30px}
1644
+ .doc-body h2{font-size:21px}
1645
+ .cards{grid-template-columns:1fr}
1646
+ }
1647
+ @media (prefers-reduced-motion:reduce){ *{transition-duration:.01ms !important; animation-duration:.01ms !important} }
1648
+ `;
1649
+
1650
+ const JS = `
1651
+ (function(){
1652
+ var docs = [].slice.call(document.querySelectorAll('.doc'));
1653
+ var items = [].slice.call(document.querySelectorAll('.nav-item'));
1654
+ var main = document.getElementById('main');
1655
+ var title = document.getElementById('topbarTitle');
1656
+ var body = document.body;
1657
+ var INDEX = JSON.parse(document.getElementById('searchIndex').textContent || '[]');
1658
+ var ICON_EMPTY =
1659
+ '<svg viewBox="0 0 48 48" width="38" height="38" aria-hidden="true" fill="none" ' +
1660
+ 'stroke="currentColor" stroke-width="2.4" stroke-linecap="round">' +
1661
+ '<circle cx="21" cy="21" r="13"/><line x1="30.5" y1="30.5" x2="41" y2="41"/>' +
1662
+ '<line x1="16.5" y1="21" x2="25.5" y2="21"/></svg>';
1663
+
1664
+ function byId(id){ return docs.filter(function(d){ return d.dataset.id === id; })[0]; }
1665
+
1666
+ function show(id){
1667
+ var found = false;
1668
+ docs.forEach(function(d){
1669
+ var on = d.dataset.id === id;
1670
+ d.classList.toggle('active', on);
1671
+ if(on) found = true;
1672
+ });
1673
+ if(!found && docs.length){ docs[0].classList.add('active'); id = docs[0].dataset.id; }
1674
+ items.forEach(function(a){ a.classList.toggle('active', a.dataset.id === id); });
1675
+ var cur = byId(id);
1676
+ title.textContent = cur ? (cur.dataset.title || '') : '';
1677
+ body.classList.remove('nav-open');
1678
+ spy();
1679
+ }
1680
+
1681
+ function fromHash(){
1682
+ var raw = location.hash.replace(/^#[/]/, '');
1683
+ if(!raw || raw.charAt(0) === '#'){ show('__home'); return; }
1684
+ var parts = decodeURIComponent(raw).split('@');
1685
+ show(parts[0]);
1686
+ window.scrollTo(0,0);
1687
+ if(parts[1]) jump(parts[1]);
1688
+ }
1689
+ window.addEventListener('hashchange', fromHash);
1690
+
1691
+ function jump(anchor){
1692
+ setTimeout(function(){
1693
+ var el = document.getElementById(anchor);
1694
+ if(!el) return;
1695
+ el.scrollIntoView({behavior:'smooth', block:'start'});
1696
+ el.classList.remove('flash');
1697
+ void el.offsetWidth;
1698
+ el.classList.add('flash');
1699
+ }, 40);
1700
+ }
1701
+
1702
+ // 문서 내 앵커는 페이지 전환이 아니라 스크롤
1703
+ document.addEventListener('click', function(e){
1704
+ var a = e.target.closest && e.target.closest('a[href^="#"]');
1705
+ if(!a) return;
1706
+ var href = a.getAttribute('href');
1707
+ if(href.indexOf('#/') === 0) return;
1708
+ var el = document.getElementById(href.slice(1));
1709
+ if(el){ e.preventDefault(); el.scrollIntoView({behavior:'smooth', block:'start'}); }
1710
+ });
1711
+
1712
+ /* ── 전문 검색 ───────────────────────────────────── */
1713
+ var search = document.getElementById('search');
1714
+ var results = document.getElementById('results');
1715
+ var lastView = '__home';
1716
+
1717
+ function esc(t){
1718
+ return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
1719
+ .replace(/"/g,'&quot;');
1720
+ }
1721
+
1722
+ // 공백으로 끊어 키워드 단위로 본다. 순서·연속 여부와 무관하게 모두 포함돼야 일치.
1723
+ function tokenize(q){
1724
+ return q.toLowerCase().split(/\s+/).filter(function(t){ return t.length > 0; });
1725
+ }
1726
+
1727
+ // 여러 키워드의 일치 구간을 모아 겹치는 것끼리 합친다
1728
+ function spansOf(lower, tokens){
1729
+ var spans = [];
1730
+ tokens.forEach(function(tk){
1731
+ var from = 0, i;
1732
+ while((i = lower.indexOf(tk, from)) > -1){
1733
+ spans.push([i, i + tk.length]);
1734
+ from = i + tk.length;
1735
+ }
1736
+ });
1737
+ if(!spans.length) return spans;
1738
+ spans.sort(function(a,b){ return a[0] - b[0]; });
1739
+ var merged = [], cur = spans[0].slice();
1740
+ for(var k = 1; k < spans.length; k++){
1741
+ if(spans[k][0] <= cur[1]) cur[1] = Math.max(cur[1], spans[k][1]);
1742
+ else { merged.push(cur); cur = spans[k].slice(); }
1743
+ }
1744
+ merged.push(cur);
1745
+ return merged;
1746
+ }
1747
+
1748
+ function markAll(text, tokens){
1749
+ var merged = spansOf(text.toLowerCase(), tokens);
1750
+ if(!merged.length) return esc(text);
1751
+ var out = '', pos = 0;
1752
+ merged.forEach(function(m){
1753
+ out += esc(text.slice(pos, m[0])) + '<mark>' + esc(text.slice(m[0], m[1])) + '</mark>';
1754
+ pos = m[1];
1755
+ });
1756
+ return out + esc(text.slice(pos));
1757
+ }
1758
+
1759
+ function snippet(text, tokens){
1760
+ var merged = spansOf(text.toLowerCase(), tokens);
1761
+ if(!merged.length) return esc(text.slice(0, 150)) + (text.length > 150 ? '…' : '');
1762
+ var start = Math.max(0, merged[0][0] - 55);
1763
+ var end = Math.min(text.length, Math.max(merged[0][1] + 110, start + 165));
1764
+ return (start > 0 ? '…' : '') + markAll(text.slice(start, end), tokens) +
1765
+ (end < text.length ? '…' : '');
1766
+ }
1767
+
1768
+ // 제목 > 섹션명 > 본문 순으로 가중치를 준다
1769
+ function score(entry, tokens){
1770
+ var name = (entry.n || '').toLowerCase();
1771
+ var head = (entry.h || '').toLowerCase();
1772
+ var text = entry.t.toLowerCase();
1773
+ var total = 0;
1774
+ for(var i = 0; i < tokens.length; i++){
1775
+ var tk = tokens[i], got = 0;
1776
+ if(name.indexOf(tk) > -1){ total += 6; got = 1; }
1777
+ if(head.indexOf(tk) > -1){ total += 4; got = 1; }
1778
+ var at = text.indexOf(tk);
1779
+ if(at > -1){
1780
+ got = 1;
1781
+ total += 2;
1782
+ total += Math.min(text.split(tk).length - 1, 4) * 0.5; // 반복 등장 가산
1783
+ total += at < 60 ? 1 : 0; // 앞쪽 등장 가산
1784
+ }
1785
+ if(!got) return -1; // 키워드 하나라도 없으면 탈락 (AND)
1786
+ }
1787
+ return total;
1788
+ }
1789
+
1790
+ function runSearch(q){
1791
+ var tokens = tokenize(q);
1792
+ if(!tokens.length){ results.innerHTML = ''; return; }
1793
+
1794
+ var hits = [];
1795
+ for(var i = 0; i < INDEX.length; i++){
1796
+ var sc = score(INDEX[i], tokens);
1797
+ if(sc >= 0) hits.push({ e: INDEX[i], s: sc });
1798
+ }
1799
+ hits.sort(function(a,b){ return b.s - a.s; });
1800
+ hits = hits.slice(0, 60);
1801
+
1802
+ if(!hits.length){
1803
+ results.innerHTML =
1804
+ '<div class="res-empty">' + ICON_EMPTY +
1805
+ '<p class="res-empty-title">일치하는 내용이 없습니다</p>' +
1806
+ '<p class="res-empty-hint">키워드를 줄이거나 다른 낱말로 찾아보세요. ' +
1807
+ '여러 낱말을 넣으면 모두 포함된 곳만 찾습니다.</p></div>';
1808
+ return;
1809
+ }
1810
+
1811
+ results.innerHTML =
1812
+ '<p class="res-meta">' + hits.length + '개 결과' +
1813
+ (tokens.length > 1 ? ' · 키워드 ' + tokens.length + '개 모두 포함' : '') + '</p>' +
1814
+ hits.map(function(x){
1815
+ var e = x.e;
1816
+ var href = '#/' + e.d + (e.a ? '@' + e.a : '');
1817
+ return '<a class="res" href="' + href + '">' +
1818
+ '<div class="res-head"><span class="res-doc">' + markAll(e.n, tokens) + '</span>' +
1819
+ (e.h ? '<span class="res-sec">› ' + markAll(e.h, tokens) + '</span>' : '') + '</div>' +
1820
+ '<p class="res-snip">' + snippet(e.t, tokens) + '</p></a>';
1821
+ }).join('');
1822
+ }
1823
+
1824
+ search.addEventListener('input', function(){
1825
+ var q = search.value.trim().toLowerCase();
1826
+ if(!q){
1827
+ if(document.querySelector('.doc.active') === byId('__search')) show(lastView);
1828
+ return;
1829
+ }
1830
+ var active = document.querySelector('.doc.active');
1831
+ if(active && active.dataset.id !== '__search') lastView = active.dataset.id;
1832
+ runSearch(q);
1833
+ show('__search');
1834
+ });
1835
+ search.addEventListener('keydown', function(e){
1836
+ if(e.key === 'Escape'){ search.value=''; search.blur(); show(lastView); }
1837
+ });
1838
+ document.addEventListener('keydown', function(e){
1839
+ if(e.key === '/' && document.activeElement !== search &&
1840
+ !/^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)){
1841
+ e.preventDefault(); search.focus();
1842
+ }
1843
+ if((e.metaKey || e.ctrlKey) && e.key === 'k'){ e.preventDefault(); search.focus(); search.select(); }
1844
+ });
1845
+
1846
+ /* ── 목차 하이라이트 ─────────────────────────────── */
1847
+ var spyTargets = [];
1848
+ function spy(){
1849
+ var active = document.querySelector('.doc.active');
1850
+ spyTargets = active ? [].slice.call(active.querySelectorAll('h2[id]')) : [];
1851
+ onScroll();
1852
+ }
1853
+ function onScroll(){
1854
+ body.classList.toggle('scrolled', window.scrollY > 4);
1855
+ if(!spyTargets.length) return;
1856
+ var cur = spyTargets[0];
1857
+ for(var i=0;i<spyTargets.length;i++){
1858
+ if(spyTargets[i].getBoundingClientRect().top <= 120) cur = spyTargets[i];
1859
+ }
1860
+ document.querySelectorAll('.toc a').forEach(function(a){
1861
+ a.classList.toggle('active', a.dataset.anchor === cur.id);
1862
+ });
1863
+ }
1864
+ window.addEventListener('scroll', onScroll, {passive:true});
1865
+
1866
+ /* ── 사이드바 접기 · 본문 너비 ───────────────────── */
1867
+ function persist(key, on, cls){
1868
+ body.classList.toggle(cls, on);
1869
+ try { localStorage.setItem(key, on ? '1' : '0'); } catch(err){}
1870
+ }
1871
+ var collapsed = false, wide = false;
1872
+ try {
1873
+ collapsed = localStorage.getItem('docs-sb') === '1';
1874
+ wide = localStorage.getItem('docs-wide') === '1';
1875
+ } catch(err){}
1876
+ body.classList.toggle('sb-collapsed', collapsed);
1877
+ body.classList.toggle('wide', wide);
1878
+
1879
+ var widthBtn = document.getElementById('widthBtn');
1880
+ widthBtn.setAttribute('aria-pressed', wide ? 'true' : 'false');
1881
+ widthBtn.addEventListener('click', function(){
1882
+ wide = !wide;
1883
+ persist('docs-wide', wide, 'wide');
1884
+ widthBtn.setAttribute('aria-pressed', wide ? 'true' : 'false');
1885
+ });
1886
+
1887
+ function setCollapsed(v){
1888
+ collapsed = v;
1889
+ persist('docs-sb', collapsed, 'sb-collapsed');
1890
+ }
1891
+ document.getElementById('collapseBtn').addEventListener('click', function(){ setCollapsed(true); });
1892
+ document.getElementById('revealBtn').addEventListener('click', function(){ setCollapsed(false); });
1893
+
1894
+ document.getElementById('menuBtn').addEventListener('click', function(){
1895
+ body.classList.toggle('nav-open');
1896
+ });
1897
+ document.getElementById('scrim').addEventListener('click', function(){
1898
+ body.classList.remove('nav-open');
1899
+ });
1900
+
1901
+ /* ── 테마 ────────────────────────────────────────── */
1902
+ var KEY = 'docs-theme';
1903
+ var saved = null;
1904
+ try { saved = localStorage.getItem(KEY); } catch(err){}
1905
+ if(saved) document.documentElement.dataset.theme = saved;
1906
+ else if(window.matchMedia('(prefers-color-scheme: dark)').matches)
1907
+ document.documentElement.dataset.theme = 'dark';
1908
+ document.getElementById('themeBtn').addEventListener('click', function(){
1909
+ var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
1910
+ document.documentElement.dataset.theme = next;
1911
+ try { localStorage.setItem(KEY, next); } catch(err){}
1912
+ });
1913
+
1914
+ fromHash();
1915
+ })();
1916
+ `;
1917
+
1918
+ /* ================================================================== *
1919
+ * 커맨드
1920
+ * ================================================================== */
1921
+
1922
+ function report({ errors, warnings }) {
1923
+ const byFile = new Map();
1924
+ for (const e of errors) {
1925
+ if (!byFile.has(e.file)) byFile.set(e.file, []);
1926
+ byFile.get(e.file).push(e.msg);
1927
+ }
1928
+ for (const [file, msgs] of byFile) {
1929
+ console.error("✗ " + file);
1930
+ for (const m of msgs) console.error(" " + m);
1931
+ }
1932
+ if (warnings.length) {
1933
+ const wByFile = new Map();
1934
+ for (const w of warnings) {
1935
+ if (!wByFile.has(w.file)) wByFile.set(w.file, []);
1936
+ wByFile.get(w.file).push(w.msg);
1937
+ }
1938
+ for (const [file, msgs] of wByFile) {
1939
+ console.warn("⚠ " + file);
1940
+ for (const m of msgs.slice(0, 6)) console.warn(" " + m);
1941
+ if (msgs.length > 6) console.warn(" … 외 " + (msgs.length - 6) + "건");
1942
+ }
1943
+ }
1944
+ }
1945
+
1946
+ function cmdCheck(docsDir, quiet = false) {
1947
+ const docs = loadDocs(docsDir);
1948
+ const discovery = loadDiscovery(docsDir);
1949
+ const design = loadDesign(docsDir);
1950
+ const build = loadBuild(docsDir);
1951
+ const extra = [...discovery, ...design, ...build];
1952
+ const extraIds = new Set(extra.map((d) => d.id));
1953
+
1954
+ const result = validate(docs, extraIds);
1955
+ const knownIds = new Set([...docs.map((d) => d.id), ...extraIds]);
1956
+
1957
+ // 추적 ID 는 단계와 무관하게 하나의 풀로 본다
1958
+ const definedIds = new Set();
1959
+ for (const d of [...docs, ...extra]) {
1960
+ for (const id of d.refs.defined) definedIds.add(id);
1961
+ }
1962
+ result.errors.push(...validateDiscovery(extra, knownIds, definedIds));
1963
+
1964
+ // 3단계 검증은 --deep 과 무관하게 항상 돈다. 잊는 것이 이 단계의 실패 모드다.
1965
+ const mockupsForReview = loadMockups(docsDir);
1966
+ result.warnings.push(...designReview(docsDir, design, mockupsForReview, docs));
1967
+ result.warnings.push(...buildReview(docsDir, build, docs));
1968
+
1969
+ // 2단계 검증이 1·3단계에서 정의된 ID를 모르고 낸 오류는 걷어낸다
1970
+ for (const d of extra) {
1971
+ for (const id of d.refs.defined) {
1972
+ result.errors = result.errors.filter(
1973
+ (e) => e.msg !== "정의되지 않은 추적 ID: " + id,
1974
+ );
1975
+ }
1976
+ }
1977
+ report(result);
1978
+ if (result.errors.length) {
1979
+ console.error("\n" + result.errors.length + "건 실패 — 빌드 중단");
1980
+ return { ok: false, docs, discovery, design, build };
1981
+ }
1982
+ if (process.argv.includes("--deep")) {
1983
+ const notes = deepReview(docs, discovery, design, mockupsForReview, docsDir);
1984
+ if (notes.length) {
1985
+ const byFile = new Map();
1986
+ for (const n of notes) {
1987
+ if (!byFile.has(n.file)) byFile.set(n.file, []);
1988
+ byFile.get(n.file).push(n.msg);
1989
+ }
1990
+ console.log("\n품질 점검 — " + notes.length + "건 (빌드를 막지 않습니다)");
1991
+ for (const [file, msgs] of byFile) {
1992
+ console.log(" " + file);
1993
+ for (const m of msgs) console.log(" " + m);
1994
+ }
1995
+ console.log("");
1996
+ } else {
1997
+ console.log("품질 점검 — 지적사항 없음");
1998
+ }
1999
+ }
2000
+
2001
+ if (!quiet) {
2002
+ console.log(
2003
+ "검증 통과 — 문서 " +
2004
+ docs.length +
2005
+ "개" +
2006
+ (discovery.length ? ", 1단계 기록 " + discovery.length + "개" : "") +
2007
+ (design.length ? ", 3단계 기록 " + design.length + "개" : "") +
2008
+ (result.warnings.length ? ", 경고 " + result.warnings.length + "건" : ""),
2009
+ );
2010
+ }
2011
+ return { ok: true, docs, discovery, design, build };
2012
+ }
2013
+
2014
+ function cmdBuild(docsDir) {
2015
+ const { ok, docs, discovery, design, build } = cmdCheck(docsDir, true);
2016
+ if (!ok) process.exit(1);
2017
+
2018
+ // 시안이 바뀌었으면 이전 판을 남긴다. 되돌릴 수 없는 손실을 막는 가장 싼 방법.
2019
+ const snapped = snapshotMockups(docsDir);
2020
+ if (snapped.length) {
2021
+ console.log("시안 스냅샷 " + snapped.length + "건: " + snapped.join(", "));
2022
+ }
2023
+
2024
+ // 인라인 스크립트는 템플릿 리터럴 안에 있어서 백슬래시가 조용히 먹힌다.
2025
+ // 파싱만 해보면(실행하지 않는다) 그런 사고를 빌드에서 잡을 수 있다.
2026
+ try {
2027
+ new Function(JS);
2028
+ } catch (e) {
2029
+ console.error("인라인 스크립트 문법 오류: " + e.message);
2030
+ console.error("JS 상수 안의 이스케이프를 확인하세요 (\\/ 는 템플릿 리터럴에서 / 로 바뀝니다)");
2031
+ process.exit(1);
2032
+ }
2033
+
2034
+ const config = loadConfig(docsDir);
2035
+ const mockups = loadMockups(docsDir);
2036
+ const styleguide = findStyleguide(docsDir);
2037
+ const trace = buildTraceability([...discovery, ...design, ...docs]);
2038
+ const out = join(docsDir, "index.html");
2039
+ writeFileSync(out, render(docs, config, trace, discovery, design, mockups, styleguide, docsDir, build), "utf8");
2040
+ console.log(
2041
+ "생성됨: " +
2042
+ out +
2043
+ " (문서 " +
2044
+ docs.length +
2045
+ "개" +
2046
+ (discovery.length ? ", 1단계 기록 " + discovery.length + "개" : "") +
2047
+ (design.length ? ", 3단계 기록 " + design.length + "개" : "") +
2048
+ (mockups.length ? ", 시안 " + mockups.length + "개" : "") +
2049
+ (styleguide ? ", 스타일 가이드" : "") +
2050
+ ", 추적 ID " +
2051
+ trace.owners.size +
2052
+ "개)",
2053
+ );
2054
+ }
2055
+
2056
+ function cmdInit(docsDir) {
2057
+ const presetName = arg("preset");
2058
+ const moduleArg = arg("modules");
2059
+ const projectName = arg("name", "Project");
2060
+ const ai = arg("ai", process.env.PROJECT_AI || "미확인");
2061
+ const account = arg("account", process.env.PROJECT_ACCOUNT || "미확인");
2062
+ const existingCfg = existsSync(join(docsDir, "docs.config.json"))
2063
+ ? JSON.parse(readFileSync(join(docsDir, "docs.config.json"), "utf8"))
2064
+ : null;
2065
+ const profile = normalizeProfile({
2066
+ scale: arg("scale", existingCfg?.profile?.scale),
2067
+ risk: arg("risk", existingCfg?.profile?.risk),
2068
+ delivery: arg("delivery", existingCfg?.profile?.delivery),
2069
+ });
2070
+
2071
+ let preset;
2072
+ if (moduleArg) {
2073
+ const names = moduleArg.split(",").map((x) => x.trim()).filter(Boolean);
2074
+ if (!names.includes("core")) names.unshift("core"); // core 는 항상 필요하다
2075
+ preset = { name: "custom", modules: names, exclude: [], options: [] };
2076
+ } else if (presetName) {
2077
+ const presetPath = join(SKILL_DIR, "presets", presetName + ".json");
2078
+ if (!existsSync(presetPath)) {
2079
+ console.error("없는 프리셋: " + presetName);
2080
+ process.exit(1);
2081
+ }
2082
+ preset = JSON.parse(readFileSync(presetPath, "utf8"));
2083
+ } else {
2084
+ console.error(
2085
+ "--preset=<이름> 또는 --modules=<a,b,c> 가 필요합니다.\n" +
2086
+ " 프리셋 목록: build.mjs presets\n 모듈 목록 : build.mjs modules",
2087
+ );
2088
+ process.exit(1);
2089
+ }
2090
+
2091
+ preset.docs = resolveDocs(preset.modules, preset.exclude, profile);
2092
+ preset.refs = resolveRefs(preset.docs);
2093
+ if (!preset.refs.risk) {
2094
+ console.error("모듈 조합에 role:risk 문서가 없습니다 — core 모듈이 필요합니다");
2095
+ process.exit(1);
2096
+ }
2097
+
2098
+ mkdirSync(docsDir, { recursive: true });
2099
+ mkdirSync(join(docsDir, INTAKE_DIR, PROCESSED_DIR), { recursive: true });
2100
+
2101
+ // 기존 docs.config.json 의 옵션 선택을 존중한다. 없으면 프리셋 기본값.
2102
+ const existing = existsSync(join(docsDir, "docs.config.json"))
2103
+ ? loadConfig(docsDir)
2104
+ : null;
2105
+ const options = existing?.options || defaultOptions(preset);
2106
+
2107
+ let created = 0;
2108
+ let skipped = 0;
2109
+ let omitted = 0;
2110
+ for (const entry of preset.docs) {
2111
+ const file = entry.file || entry.id + ".md";
2112
+ const path = join(docsDir, file);
2113
+ if (existsSync(path)) {
2114
+ skipped += 1;
2115
+ continue;
2116
+ }
2117
+ // 논점이 전부 꺼졌으면 그 문서는 이번 프로젝트에 필요 없다
2118
+ if (entry.outline && !activeOutline(entry.outline, options).length) {
2119
+ omitted += 1;
2120
+ continue;
2121
+ }
2122
+ const tmpl = readFileSync(
2123
+ join(SKILL_DIR, "templates", entry.template + ".md"),
2124
+ "utf8",
2125
+ );
2126
+ let filled = tmpl
2127
+ .replace(/\{\{id\}\}/g, entry.id)
2128
+ .replace(/\{\{title\}\}/g, entry.title)
2129
+ .replace(/\{\{phase\}\}/g, entry.phase)
2130
+ .replace(/\{\{summary\}\}/g, entry.summary)
2131
+ .replace(/\{\{updated\}\}/g, today())
2132
+ .replace(/\{\{timestamp\}\}/g, nowMinute())
2133
+ .replace(/\{\{ai\}\}/g, ai)
2134
+ .replace(/\{\{account\}\}/g, account)
2135
+ .replace(/\{\{riskDoc\}\}/g, preset.refs?.risk || entry.id)
2136
+ .replace(/\{\{decisionDoc\}\}/g, preset.refs?.decision || entry.id)
2137
+ .replace(/\{\{outline\}\}/g, renderOutline(entry.outline, options));
2138
+
2139
+ if (entry.template === "spec") filled = numberSections(filled);
2140
+ writeFileSync(path, filled, "utf8");
2141
+ created += 1;
2142
+ }
2143
+
2144
+ const configPath = join(docsDir, "docs.config.json");
2145
+ if (!existsSync(configPath)) {
2146
+ writeFileSync(
2147
+ configPath,
2148
+ JSON.stringify(
2149
+ { project: projectName, preset: presetName, options, profile },
2150
+ null,
2151
+ 2,
2152
+ ) + "\n",
2153
+ "utf8",
2154
+ );
2155
+ }
2156
+
2157
+ console.log(
2158
+ "초기화 완료: " +
2159
+ docsDir +
2160
+ " (생성 " +
2161
+ created +
2162
+ "개" +
2163
+ (skipped ? ", 기존 유지 " + skipped + "개" : "") +
2164
+ (omitted ? ", 옵션 제외 " + omitted + "개" : "") +
2165
+ ")",
2166
+ );
2167
+ console.log("다음: node " + join(SKILL_DIR, "build.mjs") + " build");
2168
+ }
2169
+
2170
+ /* ================================================================== *
2171
+ * 기획안 흡수 (intake)
2172
+ *
2173
+ * 원본 기획안을 Docs/_intake/ 에 넣으면 미처리 대기 상태가 된다.
2174
+ * 분석과 문서 작성은 에이전트가 한다 (SKILL.md 절차). 이 명령은
2175
+ * 대기 목록 확인과 처리 완료 보관만 담당한다.
2176
+ *
2177
+ * 종료 코드가 곧 게이트다 — 대기 파일이 있으면 0, 없으면 1.
2178
+ * Orca automation 의 --precheck 로 그대로 쓸 수 있다.
2179
+ * ================================================================== */
2180
+
2181
+ const INTAKE_DIR = "_intake";
2182
+ const PROCESSED_DIR = "processed";
2183
+ const DISCOVERY_DIR = "_discovery";
2184
+ const DESIGN_DIR = "_design";
2185
+ const MOCKUP_DIR = "mockups";
2186
+ const BUILD_DIR = "_build";
2187
+ const HISTORY_DIR = ".history";
2188
+ const DESIGN_IMG_DIR = "images";
2189
+
2190
+ function intakePaths(docsDir) {
2191
+ const root = join(docsDir, INTAKE_DIR);
2192
+ return { root, processed: join(root, PROCESSED_DIR) };
2193
+ }
2194
+
2195
+ function pendingIntake(docsDir) {
2196
+ const { root } = intakePaths(docsDir);
2197
+ if (!existsSync(root)) return [];
2198
+ return readdirSync(root, { withFileTypes: true })
2199
+ .filter((e) => e.isFile() && /\.(md|markdown|txt)$/i.test(e.name))
2200
+ .map((e) => e.name)
2201
+ .sort();
2202
+ }
2203
+
2204
+ function cmdIntake(docsDir) {
2205
+ const { root, processed } = intakePaths(docsDir);
2206
+ const archive = arg("archive");
2207
+ const quiet = process.argv.includes("--quiet");
2208
+
2209
+ if (archive) {
2210
+ const from = join(root, archive);
2211
+ if (!existsSync(from)) {
2212
+ console.error("없는 파일: " + from);
2213
+ process.exit(1);
2214
+ }
2215
+ // 처리된 기획안은 1단계 기록 폴더로 옮겨 HTML에서 계속 볼 수 있게 한다
2216
+ const dest = join(docsDir, DISCOVERY_DIR);
2217
+ mkdirSync(dest, { recursive: true });
2218
+ const to = join(dest, archive);
2219
+ renameSync(from, to);
2220
+ console.log("1단계 기록으로 이동: " + to);
2221
+ return;
2222
+ }
2223
+
2224
+ const pending = pendingIntake(docsDir);
2225
+ if (!pending.length) {
2226
+ if (!quiet) console.log("대기 중인 기획안 없음 (" + root + ")");
2227
+ process.exit(1);
2228
+ }
2229
+ if (!quiet) {
2230
+ console.log("미처리 기획안 " + pending.length + "건:");
2231
+ for (const f of pending) console.log(" " + join(root, f));
2232
+ } else {
2233
+ console.log(pending.length);
2234
+ }
2235
+ process.exit(0);
2236
+ }
2237
+
2238
+ /**
2239
+ * 프리셋의 outline(문서별 논점)을 빈 섹션으로 펼친다.
2240
+ *
2241
+ * 이것이 플랫폼이 도메인 지식을 축적하는 방식이다. 구조만 주면 새 프로젝트마다
2242
+ * "이 문서에 뭘 써야 하지"를 매번 다시 떠올려야 하고, 기획안에 없는 주제는
2243
+ * 영원히 누락된다. 빈 섹션이 있으면 안 채운 것이 눈에 보인다.
2244
+ *
2245
+ * outline 항목은 [제목, 작성지침] 또는 "제목" 형식.
2246
+ */
2247
+ /* ================================================================== *
2248
+ * 범위 옵션
2249
+ *
2250
+ * 프로젝트마다 켜고 끄는 것이 달라지는 항목(SEO, GEO, 애널리틱스, 다국어 …)을
2251
+ * 프리셋이 카탈로그로 들고 있는다. 에이전트가 흡수 단계에서 사용자에게 묻고,
2252
+ * 답을 docs.config.json 에 적으면 init 이 그 선택대로 문서를 만든다.
2253
+ *
2254
+ * 목적은 "나중에 왜 이게 없지"를 없애는 것이다. 선택은 기록으로 남는다.
2255
+ * ================================================================== */
2256
+
2257
+ /* ================================================================== *
2258
+ * 모듈 조합
2259
+ *
2260
+ * 프리셋은 문서 목록을 직접 들지 않고 **모듈 조합**만 선언한다.
2261
+ * 새 프로젝트 유형이 생겨도 문서 정의를 복제하지 않고 모듈만 고르면 된다.
2262
+ * 프리셋이 없는 유형은 `--modules=core,product,ops` 처럼 직접 조합할 수 있다.
2263
+ * ================================================================== */
2264
+
2265
+ function loadModule(name) {
2266
+ const path = join(SKILL_DIR, "modules", name + ".json");
2267
+ if (!existsSync(path)) {
2268
+ console.error("없는 모듈: " + name + " (목록: build.mjs modules)");
2269
+ process.exit(1);
2270
+ }
2271
+ return JSON.parse(readFileSync(path, "utf8"));
2272
+ }
2273
+
2274
+ /**
2275
+ * 모듈들을 펼쳐 문서 목록을 만든다.
2276
+ * 번호는 여기서 매긴다 — 단계(phase) 순서를 먼저 따르므로 조합이 달라져도
2277
+ * 사이드바가 01 전략 → 06 관리 흐름을 유지한다.
2278
+ */
2279
+ /* 프로젝트 프로필 축 — 낮은 쪽이 작은 프로젝트다.
2280
+ * 문서의 min* 값이 프로젝트 값보다 높으면 그 문서는 만들지 않는다.
2281
+ * 축을 늘리려면 여기와 modules/*.json 만 고치면 된다. */
2282
+ const PROFILE_AXES = {
2283
+ scale: ["solo", "small", "standard", "large"],
2284
+ risk: ["low", "standard", "regulated"],
2285
+ delivery: ["prototype", "mvp", "production"],
2286
+ };
2287
+ const PROFILE_DEFAULT = { scale: "large", risk: "regulated", delivery: "production" };
2288
+
2289
+ /** 프로필 값을 정규화한다. 모르는 값은 기본값(가장 넓은 범위)으로 둔다. */
2290
+ function normalizeProfile(profile = {}) {
2291
+ const out = {};
2292
+ for (const [axis, levels] of Object.entries(PROFILE_AXES)) {
2293
+ const v = profile[axis];
2294
+ out[axis] = levels.includes(v) ? v : PROFILE_DEFAULT[axis];
2295
+ }
2296
+ return out;
2297
+ }
2298
+
2299
+ /** 문서가 이 프로필에서 필요한가. min* 이 없으면 항상 포함한다. */
2300
+ function docInProfile(doc, profile) {
2301
+ for (const [axis, levels] of Object.entries(PROFILE_AXES)) {
2302
+ const need = doc["min" + axis[0].toUpperCase() + axis.slice(1)];
2303
+ if (!need) continue;
2304
+ if (!levels.includes(need)) continue; // 오타는 무시한다 — 문서를 잃는 것보다 낫다
2305
+ if (levels.indexOf(profile[axis]) < levels.indexOf(need)) return false;
2306
+ }
2307
+ return true;
2308
+ }
2309
+
2310
+ function resolveDocs(moduleNames, exclude = [], profile) {
2311
+ const p = normalizeProfile(profile);
2312
+ const skip = new Set(exclude);
2313
+ const collected = [];
2314
+ moduleNames.forEach((m, mi) => {
2315
+ loadModule(m).docs.forEach((d, di) => {
2316
+ if (skip.has(d.name)) return;
2317
+ if (!docInProfile(d, p)) return;
2318
+ if (collected.some((c) => c.name === d.name)) return; // 모듈 간 중복 제거
2319
+ collected.push({ ...d, _m: mi, _d: di });
2320
+ });
2321
+ });
2322
+
2323
+ collected.sort((a, b) =>
2324
+ (a.phase || "").localeCompare(b.phase || "") ||
2325
+ a._m - b._m ||
2326
+ a._d - b._d,
2327
+ );
2328
+
2329
+ return collected.map((d, i) => {
2330
+ const num = String(i).padStart(2, "0");
2331
+ const { _m, _d, ...rest } = d;
2332
+ return { ...rest, id: num + "-" + d.name };
2333
+ });
2334
+ }
2335
+
2336
+ /** role 로 표시된 문서에서 템플릿 참조를 계산한다. */
2337
+ function resolveRefs(docs) {
2338
+ const find = (role) => docs.find((d) => d.role === role);
2339
+ return {
2340
+ risk: find("risk")?.id || "",
2341
+ decision: find("decision")?.id || "",
2342
+ };
2343
+ }
2344
+
2345
+ function cmdModules() {
2346
+ const dir = join(SKILL_DIR, "modules");
2347
+ for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
2348
+ const m = JSON.parse(readFileSync(join(dir, f), "utf8"));
2349
+ console.log(m.name.padEnd(13) + m.label);
2350
+ console.log(" ".repeat(13) + m.docs.map((d) => d.name).join(", "));
2351
+ }
2352
+ console.log(
2353
+ "\n프리셋 없이 조합: build.mjs init --modules=core,product,ops --name=\"이름\"",
2354
+ );
2355
+ }
2356
+
2357
+ function defaultOptions(preset) {
2358
+ const out = {};
2359
+ for (const o of preset.options || []) out[o.id] = o.default !== false;
2360
+ return out;
2361
+ }
2362
+
2363
+ function csvArg(name) {
2364
+ return (arg(name, "") || "").split(",").map((x) => x.trim()).filter(Boolean);
2365
+ }
2366
+
2367
+ function cmdConfigure(docsDir) {
2368
+ const currentPath = join(docsDir, "docs.config.json");
2369
+ const current = existsSync(currentPath) ? JSON.parse(readFileSync(currentPath, "utf8")) : {};
2370
+ const presetName = arg("preset", current.preset || "web-corporate");
2371
+ const presetPath = join(SKILL_DIR, "presets", presetName + ".json");
2372
+ if (!existsSync(presetPath)) throw new Error("없는 프리셋: " + presetName);
2373
+ const preset = JSON.parse(readFileSync(presetPath, "utf8"));
2374
+ const options = { ...defaultOptions(preset), ...(current.options || {}) };
2375
+ const known = new Set((preset.options || []).map((o) => o.id));
2376
+ for (const id of csvArg("enable")) {
2377
+ if (!known.has(id)) throw new Error("없는 옵션: " + id);
2378
+ options[id] = true;
2379
+ }
2380
+ for (const id of csvArg("disable")) {
2381
+ if (!known.has(id)) throw new Error("없는 옵션: " + id);
2382
+ options[id] = false;
2383
+ }
2384
+ if (options.adtracking && !options.consent) throw new Error("adtracking을 켜려면 consent도 켜야 합니다");
2385
+ mkdirSync(docsDir, { recursive: true });
2386
+ const modes = { ...(current.modes || {}) };
2387
+ if (arg("contact-mode")) modes.contactform = arg("contact-mode");
2388
+ const profile = normalizeProfile({
2389
+ scale: arg("scale", current.profile?.scale),
2390
+ risk: arg("risk", current.profile?.risk),
2391
+ delivery: arg("delivery", current.profile?.delivery),
2392
+ });
2393
+ writeFileSync(currentPath, JSON.stringify({ ...current, project: arg("name", current.project || "Project"), preset: presetName, options, modes, profile }, null, 2) + "\n");
2394
+ console.log(" 프로필: scale=" + profile.scale + " risk=" + profile.risk + " delivery=" + profile.delivery);
2395
+ console.log("설정됨: " + currentPath);
2396
+ }
2397
+
2398
+ function cmdReady(docsDir) {
2399
+ const phase = arg("phase");
2400
+ if (!phase) throw new Error("--phase=design|implementation|release 가 필요합니다");
2401
+ const risks = loadDocs(docsDir).filter((d) => d.meta.template === "register");
2402
+ const blocked = risks.flatMap((d) => [...d.body.matchAll(/^###\s+([BHM]-\d+).*?[\s\S]*?\|\s*\*\*차단 단계\*\*\s*\|\s*([^|\n]+).*?[\s\S]*?\|\s*\*\*상태\*\*\s*\|\s*([^|\n]+)/gm)])
2403
+ .filter((m) => m[2].trim() === phase && !/^(해소|완료)(?:\s|$)/.test(m[3].trim()))
2404
+ .map((m) => m[1]);
2405
+ if (blocked.length) {
2406
+ console.error("진입 불가 — " + phase + " 차단 리스크: " + blocked.join(", "));
2407
+ process.exit(1);
2408
+ }
2409
+ console.log("진입 가능: " + phase);
2410
+ }
2411
+
2412
+ function cmdOptions(docsDir) {
2413
+ const presetName =
2414
+ arg("preset") || (existsSync(docsDir) ? loadConfig(docsDir).preset : null);
2415
+ if (!presetName) {
2416
+ console.error("--preset=<이름> 이 필요합니다. 목록: build.mjs presets");
2417
+ process.exit(1);
2418
+ }
2419
+ const path = join(SKILL_DIR, "presets", presetName + ".json");
2420
+ if (!existsSync(path)) {
2421
+ console.error("없는 프리셋: " + presetName);
2422
+ process.exit(1);
2423
+ }
2424
+ const preset = JSON.parse(readFileSync(path, "utf8"));
2425
+ const current = existsSync(docsDir) ? loadConfig(docsDir).options : null;
2426
+
2427
+ if (process.argv.includes("--json")) {
2428
+ console.log(
2429
+ JSON.stringify(
2430
+ { preset: presetName, options: preset.options || [], current },
2431
+ null,
2432
+ 2,
2433
+ ),
2434
+ );
2435
+ return;
2436
+ }
2437
+
2438
+ console.log("프리셋: " + presetName + "\n");
2439
+ for (const o of preset.options || []) {
2440
+ const state = current ? (current[o.id] === false ? "끔" : "켬") : "-";
2441
+ const def = o.default === false ? "기본 끔" : "기본 켬";
2442
+ console.log(" [" + state + "] " + o.id.padEnd(13) + o.label + " (" + def + ")");
2443
+ if (o.detail) console.log(" " + o.detail);
2444
+ }
2445
+ }
2446
+
2447
+ /**
2448
+ * outline 항목은 [제목, 작성지침, 옵션id] 또는 "제목".
2449
+ * 옵션 id가 붙은 항목은 그 옵션이 켜져 있을 때만 살아남는다.
2450
+ * 태그가 없는 항목은 항상 포함된다.
2451
+ */
2452
+ function activeOutline(outline, options) {
2453
+ if (!outline) return [];
2454
+ return outline.filter((entry) => {
2455
+ const opt = Array.isArray(entry) ? entry[2] : null;
2456
+ return !opt || options[opt] !== false;
2457
+ });
2458
+ }
2459
+
2460
+ function renderOutline(outline, options = {}) {
2461
+ const active = activeOutline(outline, options);
2462
+ if (!active.length) return "## 정의\n\n본문.";
2463
+ return active
2464
+ .map((entry) => {
2465
+ const [title, hint] = Array.isArray(entry) ? entry : [entry, null];
2466
+ return (
2467
+ "## " + title + "\n\n" + (hint ? "> **작성 지침** — " + hint + "\n" : "")
2468
+ );
2469
+ })
2470
+ .join("\n");
2471
+ }
2472
+
2473
+ /** `## ` 헤딩에 순번을 매긴다. 코드펜스 안은 건드리지 않는다. */
2474
+ function numberSections(md) {
2475
+ let n = 0;
2476
+ return md
2477
+ .split(/(```[\s\S]*?```)/g)
2478
+ .map((chunk, i) =>
2479
+ i % 2 === 1
2480
+ ? chunk
2481
+ : chunk.replace(/^## (?!\d+\.\s)(.+)$/gm, (_, t) => "## " + ++n + ". " + t),
2482
+ )
2483
+ .join("");
2484
+ }
2485
+
2486
+ /* ================================================================== *
2487
+ * 스킬 평가 (eval)
2488
+ *
2489
+ * selftest 가 빌더를 검증한다면 이쪽은 **스킬 지시가 지켜졌는지**를 본다.
2490
+ * 기계가 확정할 수 있는 것만 여기서 판정하고, 판단이 필요한 항목은
2491
+ * evals/RUBRIC.md 로 넘겨 사람·에이전트가 채점하게 한다.
2492
+ * 애매한 것까지 자동 통과 처리하면 평가가 거짓 안심을 준다.
2493
+ * ================================================================== */
2494
+
2495
+ function cmdEval(docsDir) {
2496
+ const name = arg("case");
2497
+ const dir = join(SKILL_DIR, "evals", "cases");
2498
+ if (!name) {
2499
+ console.log("케이스:");
2500
+ for (const f of readdirSync(dir).filter((x) => x.endsWith(".expect.json")).sort()) {
2501
+ const e = JSON.parse(readFileSync(join(dir, f), "utf8"));
2502
+ console.log(" " + e.case.padEnd(14) + (e.note || ""));
2503
+ }
2504
+ console.log("\n사용: build.mjs eval --case=<이름> [--docs=<경로>]");
2505
+ return;
2506
+ }
2507
+ const path = join(dir, name + ".expect.json");
2508
+ if (!existsSync(path)) {
2509
+ console.error("없는 케이스: " + name);
2510
+ process.exit(1);
2511
+ }
2512
+ const exp = JSON.parse(readFileSync(path, "utf8"));
2513
+ const docs = loadDocs(docsDir);
2514
+ const discovery = loadDiscovery(docsDir);
2515
+ const design = loadDesign(docsDir);
2516
+ const config = loadConfig(docsDir);
2517
+ const all = [...docs, ...discovery, ...design];
2518
+ const blob = all.map((d) => d.body).join("\n");
2519
+
2520
+ const results = [];
2521
+ const check = (label, pass, detail) => results.push({ label, pass, detail });
2522
+
2523
+ if (exp.preset) {
2524
+ check("프리셋 = " + exp.preset, config.preset === exp.preset, "실제: " + config.preset);
2525
+ }
2526
+
2527
+ const defined = new Set();
2528
+ for (const d of all) for (const id of d.refs.defined) defined.add(id);
2529
+ const blockers = [...defined].filter((i) => i.startsWith("B-")).length;
2530
+ if (exp.minBlockers != null) {
2531
+ check("Blocker ≥ " + exp.minBlockers, blockers >= exp.minBlockers, "실제: " + blockers + "건");
2532
+ }
2533
+
2534
+ const todos = (blob.match(/TODO\([a-z]+\)/g) || []).length;
2535
+ if (exp.minTodos != null) {
2536
+ check("TODO ≥ " + exp.minTodos, todos >= exp.minTodos, "실제: " + todos + "건");
2537
+ }
2538
+
2539
+ if (exp.noLeftoverPlaceholders) {
2540
+ const left = all.filter((d) => /\{\{[^}]+\}\}/.test(d.body)).map((d) => d.file);
2541
+ check("자리표시자 없음", left.length === 0, left.join(", "));
2542
+ }
2543
+
2544
+ if (exp.noOutcomeClaims) {
2545
+ const found = [];
2546
+ for (const d of all) {
2547
+ for (const c of outcomeClaims(stripCode(d.body))) found.push(d.file + ":" + c);
2548
+ }
2549
+ check("근거 없는 성과 수치 없음", found.length === 0, found.slice(0, 4).join(", "));
2550
+ }
2551
+
2552
+ if (exp.requireOptionsRecorded) {
2553
+ const n = config.options ? Object.keys(config.options).length : 0;
2554
+ check("범위 옵션 기록됨", n > 0, n + "개");
2555
+ }
2556
+
2557
+ for (const opt of exp.expectOptionsOff || []) {
2558
+ const v = config.options ? config.options[opt] : undefined;
2559
+ check("옵션 " + opt + " = 끔", v === false, "실제: " + String(v));
2560
+ }
2561
+
2562
+ for (const word of exp.mustMentionUndecided || []) {
2563
+ const hit = all.some(
2564
+ (d) => d.body.includes(word) && /미결정|TODO\(|미정|확정 필요/.test(d.body),
2565
+ );
2566
+ check("'" + word + "' 를 미결정으로 남김", hit, "");
2567
+ }
2568
+
2569
+ const pass = results.filter((r) => r.pass).length;
2570
+ console.log("케이스: " + exp.case + (exp.note ? " — " + exp.note : "") + "\n");
2571
+ for (const r of results) {
2572
+ console.log(" " + (r.pass ? "✓" : "✗") + " " + r.label + (r.detail ? " " + r.detail : ""));
2573
+ }
2574
+ console.log("\n기계 판정: " + pass + "/" + results.length);
2575
+
2576
+ const human = exp.humanCheck || [];
2577
+ console.log("\n사람이 채점할 항목" + (human.length ? "" : " — RUBRIC.md 참조"));
2578
+ for (const h of human) console.log(" □ " + h);
2579
+ console.log(" □ RUBRIC.md 의 공통 무결성 C1~C4");
2580
+ console.log("\n※ 기계 판정 통과가 곧 합격이 아니다. RUBRIC.md 를 함께 채점한다.");
2581
+
2582
+ if (pass < results.length) process.exitCode = 1;
2583
+ }
2584
+
2585
+ function cmdPresets() {
2586
+ const dir = join(SKILL_DIR, "presets");
2587
+ for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
2588
+ const p = JSON.parse(readFileSync(join(dir, f), "utf8"));
2589
+ console.log(
2590
+ p.name.padEnd(18) + p.label + " (문서 " + resolveDocs(p.modules, p.exclude).length + "개)",
2591
+ );
2592
+ console.log(" ".repeat(18) + p.description);
2593
+ }
2594
+ }
2595
+
2596
+ /* ================================================================== *
2597
+ * 자체 점검
2598
+ * ================================================================== */
2599
+
2600
+ function selftest() {
2601
+ let failed = 0;
2602
+ const assert = (cond, msg) => {
2603
+ if (!cond) {
2604
+ console.error("FAIL: " + msg);
2605
+ failed += 1;
2606
+ }
2607
+ };
2608
+ const h = (md, o) => mdToHtml(md, o || {}).html;
2609
+
2610
+ // 마크다운
2611
+ assert(h("# 제목").startsWith('<h1 id="제목">제목</h1>'), "heading + id");
2612
+ assert(mdToHtml("## 섹션").headings.length === 1, "h2 collected");
2613
+ assert(h("본문") === "<p>본문</p>", "paragraph");
2614
+ assert(h("- a\n- b") === "<ul><li>a</li><li>b</li></ul>", "list");
2615
+ assert(h("- [x] 완료").includes("disabled checked>"), "task checked");
2616
+ assert(h("---") === "<hr>", "hr");
2617
+ assert(h("> 인용").includes("<blockquote><p>인용</p></blockquote>"), "quote");
2618
+ assert(h("```js\nvar a=1;\n```").includes("<pre><code"), "code fence");
2619
+ assert(h("`a<b`").includes("<code>a&lt;b</code>"), "inline code escaped");
2620
+ assert(h("**굵게**").includes("<strong>굵게</strong>"), "bold");
2621
+ assert(!h("텍스트 <script>").includes("<script>"), "html escaped");
2622
+ assert(!h('그는 "use client"라고 썼다').includes('"'), "double quote escaped");
2623
+ assert(!h("don't").includes("'"), "single quote escaped");
2624
+
2625
+ const table = h("| a | b |\n| --- | --- |\n| 1 | 2 |");
2626
+ assert(table.includes("<th>a</th>") && table.includes("<td>2</td>"), "table");
2627
+ const escaped = h("| k | v |\n| --- | --- |\n| t | `x \\| y` |");
2628
+ assert(
2629
+ escaped.includes("<code>x | y</code>") &&
2630
+ (escaped.match(/<td>/g) || []).length === 2,
2631
+ "escaped pipe stays in one cell",
2632
+ );
2633
+ assert(!h("| | |\n| --- | --- |\n| k | v |").includes("<thead>"), "empty header omitted");
2634
+ assert(
2635
+ mdToHtml("## 같은 제목\n\n## 같은 제목").html.includes('id="같은-제목-2"'),
2636
+ "unique heading ids",
2637
+ );
2638
+ const toggled = h("## 2026-08-24\n\n### D-001. 결정\n\n본문", { dateToggles: true });
2639
+ assert(toggled.includes('<details class="log-date">') && toggled.endsWith("</div></details>"), "date headings become toggles");
2640
+
2641
+ // 위키링크
2642
+ const resolveLink = (t) => (t === "04-prd" ? { id: "04-prd", title: "PRD" } : null);
2643
+ const wl = h("[[04-prd]] 참조", { resolveLink });
2644
+ assert(wl.includes('href="#/04-prd"') && wl.includes(">PRD<"), "wikilink resolves to title");
2645
+ assert(h("[[04-prd|요구사항]]", { resolveLink }).includes(">요구사항<"), "wikilink label");
2646
+ assert(h("[[없는문서]]", { resolveLink }).includes("broken"), "broken wikilink marked");
2647
+
2648
+ // 참조 수집
2649
+ const refs = scanReferences(
2650
+ "### B-01. 로고 없음\n\n| F-01 | 헤더 | M |\n\n본문에서 D-002 를 언급한다. B2B 는 ID가 아니다.",
2651
+ );
2652
+ assert(refs.defined.has("B-01"), "heading defines id");
2653
+ assert(refs.defined.has("F-01"), "table first cell defines id");
2654
+ assert(refs.mentioned.has("D-002"), "mention collected");
2655
+ assert(!refs.defined.has("D-002"), "mention is not a definition");
2656
+ assert(![...refs.mentioned].some((x) => x.startsWith("B2")), "B2B not treated as id");
2657
+
2658
+ // 코드 안의 표기는 예시이지 참조가 아니다 (표기법을 설명하는 문서가 스스로 깨지지 않도록)
2659
+ const inCode = scanReferences(
2660
+ "표기: `[[문서-id]]` 와 `B-01` 을 쓴다.\n\n```\n[[또다른문서]]\nF-99\n```",
2661
+ );
2662
+ assert(inCode.wikiLinks.size === 0, "wikilink inside code is not a reference");
2663
+ assert(inCode.mentioned.size === 0, "id inside code is not a reference");
2664
+
2665
+ // outline — 프리셋의 도메인 논점이 빈 섹션으로 펼쳐지는가
2666
+ const ol = renderOutline([["네이버 대응", "서치어드바이저 등록"], "GEO"]);
2667
+ assert(ol.includes("## 네이버 대응"), "outline renders heading");
2668
+ assert(ol.includes("**작성 지침** — 서치어드바이저 등록"), "outline renders hint");
2669
+ assert(ol.includes("## GEO"), "outline accepts bare string");
2670
+ assert(renderOutline([]).includes("## 정의"), "empty outline falls back");
2671
+
2672
+ // 성과 수치 탐지 — 스펙 수치를 잡으면 안 된다
2673
+ assert(outcomeClaims("텍스트 200% 확대 시 손실 없음").length === 0, "spec threshold not flagged");
2674
+ assert(outcomeClaims("본문 90% 스크롤 시 이벤트").length === 0, "event threshold not flagged");
2675
+ assert(outcomeClaims("문의 전환율이 30% 증가했습니다").length === 1, "outcome claim flagged");
2676
+ assert(outcomeClaims("사용자 5만 명 달성").length >= 1, "user-count claim flagged");
2677
+
2678
+ // 모듈 조합 — 프리셋 없이도 문서 목록이 만들어지는가
2679
+ const only = resolveDocs(["core"]);
2680
+ assert(only.length >= 4, "core module resolves docs");
2681
+ assert(
2682
+ only.every((d, i) => d.id.startsWith(String(i).padStart(2, "0") + "-")),
2683
+ "ids are numbered sequentially",
2684
+ );
2685
+ const phases = only.map((d) => d.phase);
2686
+ assert(
2687
+ phases.every((v, i) => i === 0 || phases[i - 1] <= v),
2688
+ "docs are ordered by phase",
2689
+ );
2690
+ const coreRefs = resolveRefs(only);
2691
+ assert(coreRefs.risk && coreRefs.decision, "refs resolved from role markers");
2692
+ assert(
2693
+ only.some((d) => d.id === coreRefs.risk),
2694
+ "risk ref points at a doc that exists",
2695
+ );
2696
+ // 프로필 축 — 작은 프로젝트는 문서가 줄어야 한다
2697
+ const large = resolveDocs(["core", "backend"], [], { scale: "large", risk: "regulated" });
2698
+ const solo = resolveDocs(["core", "backend"], [], { scale: "solo", risk: "low" });
2699
+ assert(solo.length < large.length, "작은 프로필이 문서를 줄인다");
2700
+ assert(
2701
+ !solo.some((d) => d.name === "api-spec"),
2702
+ "minScale=standard 문서는 solo 에서 빠진다",
2703
+ );
2704
+ assert(
2705
+ solo.some((d) => d.name === "project-brief"),
2706
+ "min* 없는 문서는 어떤 프로필에서도 남는다",
2707
+ );
2708
+ assert(
2709
+ !resolveDocs(["backend"], [], { risk: "low" }).some((d) => d.name === "security-audit"),
2710
+ "minRisk=regulated 문서는 low 에서 빠진다",
2711
+ );
2712
+ assert(
2713
+ resolveDocs(["core"], [], { scale: "존재하지않음" }).length ===
2714
+ resolveDocs(["core"], [], {}).length,
2715
+ "모르는 프로필 값은 기본값으로 넘어간다",
2716
+ );
2717
+
2718
+ const combined = resolveDocs(["core", "core", "ops"]);
2719
+ assert(
2720
+ new Set(combined.map((d) => d.name)).size === combined.length,
2721
+ "duplicate modules are deduped",
2722
+ );
2723
+ assert(
2724
+ !resolveDocs(["ops"], ["roadmap"]).some((d) => d.name === "roadmap"),
2725
+ "exclude removes a doc",
2726
+ );
2727
+
2728
+ // 1단계 기록 — 가벼운 검증만 적용되는가
2729
+ const disc = [
2730
+ { file: "_discovery/a.md", id: "discovery-brief",
2731
+ refs: { wikiLinks: new Set(["04-prd"]), defined: new Set(["DI-001"]), mentioned: new Set() } },
2732
+ { file: "_discovery/b.md", id: "discovery-brief",
2733
+ refs: { wikiLinks: new Set(["없는문서"]), defined: new Set(), mentioned: new Set() } },
2734
+ ];
2735
+ const dErr = validateDiscovery(
2736
+ disc,
2737
+ new Set(["04-prd", "discovery-brief"]),
2738
+ new Set(["DI-001"]),
2739
+ )
2740
+ .map((e) => e.msg)
2741
+ .join(" | ");
2742
+ assert(dErr.includes("id 중복"), "discovery id 중복 catches");
2743
+ assert(dErr.includes("깨진 참조"), "discovery broken wikilink catches");
2744
+ assert(
2745
+ !dErr.includes("필수 섹션") && !dErr.includes("프론트매터 누락"),
2746
+ "discovery is exempt from spec-doc schema",
2747
+ );
2748
+ const refErr = validateDiscovery(
2749
+ [{ file: "_design/x.md", id: "design-x",
2750
+ refs: { wikiLinks: new Set(), defined: new Set(), mentioned: new Set(["ZZ-99"]) } }],
2751
+ new Set(["design-x"]),
2752
+ new Set(["DS-001"]),
2753
+ ).map((e) => e.msg).join(" | ");
2754
+ assert(
2755
+ refErr.includes("정의되지 않은 추적 ID: ZZ-99"),
2756
+ "stage records are NOT exempt from referential integrity",
2757
+ );
2758
+
2759
+ // 범위 옵션 — 꺼진 항목의 섹션이 사라지는가
2760
+ const tagged = [
2761
+ ["항상", "언제나 포함", null],
2762
+ ["GEO", "생성형 AI 노출", "geo"],
2763
+ ["네이버", "서치어드바이저", "naver"],
2764
+ ];
2765
+ assert(activeOutline(tagged, {}).length === 3, "no config = all sections on");
2766
+ assert(
2767
+ activeOutline(tagged, { geo: false }).length === 2,
2768
+ "disabled option removes its section",
2769
+ );
2770
+ assert(
2771
+ activeOutline(tagged, { geo: false }).every((e) => e[2] !== "geo"),
2772
+ "the removed section is the tagged one",
2773
+ );
2774
+ assert(
2775
+ activeOutline(tagged, { geo: false, naver: false })[0][0] === "항상",
2776
+ "untagged sections always survive",
2777
+ );
2778
+ assert(
2779
+ activeOutline([["a", "h", "x"]], { x: false }).length === 0,
2780
+ "fully gated outline becomes empty (doc is skipped at init)",
2781
+ );
2782
+
2783
+ const gated = renderOutline(tagged, { geo: false });
2784
+ assert(!gated.includes("## GEO"), "renderOutline honours options");
2785
+ assert(gated.includes("## 네이버"), "renderOutline keeps enabled sections");
2786
+ const progressSample = stageProgress(
2787
+ [{ meta: { status: "draft" } }, { meta: { status: "approved" } }],
2788
+ [{}], [], [], null, {},
2789
+ );
2790
+ assert(progressSample[1] === 100 && progressSample[2] === 63 && progressSample[3] === 0, "stage progress is derived from artifacts");
2791
+
2792
+ // 3단계는 산출물이 있어도 승인 전에는 100 이 되지 않는다.
2793
+ // 예전 계산식은 시안 하나만 있으면 100 이었고, 그 뒤로 열세 번을 더 고쳤다.
2794
+ const rulesDraft = [{ id: "design-rules", meta: { status: "draft" } }];
2795
+ const rulesOk = [{ id: "design-rules", meta: { status: "approved" } }];
2796
+ assert(stage3Progress([], [], null, "") === 0, "stage3 starts at 0");
2797
+ assert(stage3Progress(rulesDraft, [{}], "sg.html", "") === 60, "stage3 without approval stays below 100");
2798
+ assert(stage3Progress(rulesOk, [{}], "sg.html", "") === 80, "stage3 approval adds weight");
2799
+ assert(
2800
+ stage3Progress(rulesOk, [{}], "sg.html", "") < 100,
2801
+ "stage3 cannot reach 100 without tone-options and audit",
2802
+ );
2803
+ assert(progressClass(0) === "p-gray" && progressClass(24) === "p-red" && progressClass(25) === "p-yellow" && progressClass(50) === "p-orange" && progressClass(100) === "p-green", "progress color thresholds");
2804
+
2805
+ // 섹션 번호 자동 부여
2806
+ const numbered = numberSections("## 목적\n\n본문\n\n## 범위\n\n## 3. 이미번호");
2807
+ assert(numbered.includes("## 1. 목적"), "sections numbered from 1");
2808
+ assert(numbered.includes("## 2. 범위"), "sections numbered sequentially");
2809
+ assert(numbered.includes("## 3. 이미번호"), "already-numbered heading untouched");
2810
+ assert(
2811
+ !numberSections("```\n## 코드 안 헤딩\n```").includes("## 1."),
2812
+ "headings inside code fence are not numbered",
2813
+ );
2814
+
2815
+ // 섹션 제목 정규화
2816
+ assert(normalizeSectionTitle("7. 미결정") === "미결정", "numbered section normalized");
2817
+ assert(normalizeSectionTitle("범위 제외 (`W`)") === "범위 제외", "trailing paren stripped");
2818
+
2819
+ // 프론트매터
2820
+ const fm = parseFrontmatter("---\nid: x\ntitle: T\n---\n# H");
2821
+ assert(fm.found && fm.meta.id === "x" && fm.body.trim() === "# H", "frontmatter");
2822
+ assert(!parseFrontmatter("# H").found, "missing frontmatter detected");
2823
+
2824
+ // 검증기 — 위반이 실제로 잡히는가
2825
+ const bad = [
2826
+ {
2827
+ file: "x.md",
2828
+ meta: {
2829
+ id: "01-x",
2830
+ title: "T",
2831
+ phase: "01. 전략",
2832
+ status: "aproved",
2833
+ owner: "o",
2834
+ summary: "s",
2835
+ template: "spec",
2836
+ updated: "2026/08/13",
2837
+ },
2838
+ body: "",
2839
+ hasFrontmatter: true,
2840
+ sections: [],
2841
+ refs: { wikiLinks: new Set(["없는문서"]), defined: new Set(), mentioned: new Set() },
2842
+ id: "01-x",
2843
+ },
2844
+ ];
2845
+ const r = validate(bad);
2846
+ const msgs = r.errors.map((e) => e.msg).join(" | ");
2847
+ assert(msgs.includes("status 값 오류"), "enum violation caught");
2848
+ assert(msgs.includes("updated 형식 오류"), "date pattern violation caught");
2849
+ assert(msgs.includes("필수 섹션 누락: 미결정"), "required section caught");
2850
+ assert(msgs.includes("필수 섹션 누락: 변경 이력"), "required section caught (2)");
2851
+ assert(msgs.includes("깨진 참조"), "broken wikilink caught");
2852
+ assert(msgs.includes("파일명이 id와 다릅니다"), "filename mismatch caught");
2853
+
2854
+ if (failed) {
2855
+ console.error("\nselftest: " + failed + "건 실패");
2856
+ process.exit(1);
2857
+ }
2858
+ console.log("selftest: 모든 검사 통과");
2859
+ }
2860
+
2861
+ /* ================================================================== *
2862
+ * 진입점
2863
+ * ================================================================== */
2864
+
2865
+ const command = process.argv[2] || "build";
2866
+ const docsDir = resolve(arg("docs", "Docs"));
2867
+
2868
+ if (command === "selftest") {
2869
+ selftest();
2870
+ } else if (command === "modules") {
2871
+ cmdModules();
2872
+ } else if (command === "presets") {
2873
+ cmdPresets();
2874
+ } else if (command === "eval") {
2875
+ cmdEval(docsDir);
2876
+ } else if (command === "options") {
2877
+ cmdOptions(docsDir);
2878
+ } else if (command === "configure") {
2879
+ cmdConfigure(docsDir);
2880
+ } else if (command === "init") {
2881
+ cmdInit(docsDir);
2882
+ } else if (!existsSync(docsDir)) {
2883
+ console.error("문서 디렉터리가 없습니다: " + docsDir);
2884
+ console.error("먼저 init 하세요: node build.mjs init --preset=web-corporate --name=\"프로젝트명\"");
2885
+ process.exit(1);
2886
+ } else if (command === "intake") {
2887
+ cmdIntake(docsDir);
2888
+ } else if (command === "check") {
2889
+ process.exit(cmdCheck(docsDir).ok ? 0 : 1);
2890
+ } else if (command === "ready") {
2891
+ cmdReady(docsDir);
2892
+ } else if (command === "build") {
2893
+ cmdBuild(docsDir);
2894
+ } else {
2895
+ console.error("알 수 없는 명령: " + command);
2896
+ console.error("사용 가능: check | build | intake | options | configure | ready | eval | init | presets | modules | selftest");
2897
+ process.exit(1);
2898
+ }