@hanmariyang/drafting 1.6.2

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/api/dist/db/index.js +107 -0
  4. package/api/dist/db/repos.js +670 -0
  5. package/api/dist/index.js +89 -0
  6. package/api/dist/lib/ai.js +314 -0
  7. package/api/dist/lib/config.js +57 -0
  8. package/api/dist/lib/crypto.js +71 -0
  9. package/api/dist/lib/design-system-gen.js +332 -0
  10. package/api/dist/lib/fixtures.js +150 -0
  11. package/api/dist/lib/gateway.js +55 -0
  12. package/api/dist/lib/handoff.js +283 -0
  13. package/api/dist/lib/items-gen.js +211 -0
  14. package/api/dist/lib/lint-service.js +118 -0
  15. package/api/dist/lib/lint.js +141 -0
  16. package/api/dist/lib/mockup-gen.js +136 -0
  17. package/api/dist/lib/numbering.js +75 -0
  18. package/api/dist/lib/provider-errors.js +31 -0
  19. package/api/dist/lib/render.js +154 -0
  20. package/api/dist/lib/style-guide.js +47 -0
  21. package/api/dist/lib/templates.js +85 -0
  22. package/api/dist/lib/types.js +1 -0
  23. package/api/dist/lib/wireframes.js +137 -0
  24. package/api/dist/providers/byok/anthropic.js +75 -0
  25. package/api/dist/providers/byok/openai-compat.js +95 -0
  26. package/api/dist/providers/cli.js +391 -0
  27. package/api/dist/providers/index.js +68 -0
  28. package/api/dist/providers/managed.js +22 -0
  29. package/api/dist/providers/sse.js +37 -0
  30. package/api/dist/providers/stub.js +55 -0
  31. package/api/dist/providers/types.js +1 -0
  32. package/api/dist/routes/backup.js +24 -0
  33. package/api/dist/routes/deliverables.js +588 -0
  34. package/api/dist/routes/documents.js +205 -0
  35. package/api/dist/routes/helpers.js +43 -0
  36. package/api/dist/routes/interview.js +141 -0
  37. package/api/dist/routes/keys.js +70 -0
  38. package/api/dist/routes/projects.js +103 -0
  39. package/api/dist/routes/settings.js +134 -0
  40. package/api/dist/routes/share.js +39 -0
  41. package/api/dist/routes/suggestions.js +144 -0
  42. package/api/templates/design-system.json +17 -0
  43. package/api/templates/feature-spec.json +73 -0
  44. package/api/templates/ia.json +52 -0
  45. package/api/templates/prd.json +60 -0
  46. package/api/templates/user-flow.json +61 -0
  47. package/bin/drafting.mjs +79 -0
  48. package/db/schema.sql +154 -0
  49. package/package.json +62 -0
  50. package/web/dist/assets/index-CS06cWP3.js +125 -0
  51. package/web/dist/assets/index-DWoYeaZU.css +1 -0
  52. package/web/dist/index.html +14 -0
@@ -0,0 +1,588 @@
1
+ import { z } from 'zod';
2
+ import * as repo from "../db/repos.js";
3
+ import { HttpError, parse, sseStream } from "./helpers.js";
4
+ import { streamItemsGeneration, materializeSpec, materializeIa, materializeFlow, } from "../lib/items-gen.js";
5
+ import { lintReport, suggestLint } from "../lib/lint-service.js";
6
+ import { deriveWireframes } from "../lib/wireframes.js";
7
+ import { getStyleGuide, saveStyleGuide, guideRender, PRESETS } from "../lib/style-guide.js";
8
+ import { generateMockupHtml } from "../lib/mockup-gen.js";
9
+ import { generateDesignSystem, acceptDesignSystem, getDesignSystem, exploreDesignSystems, selectDesignSystemCandidate } from "../lib/design-system-gen.js";
10
+ import { compileHandoff, promptPack, handoffTickets, getHandoffDoc, HandoffGateError } from "../lib/handoff.js";
11
+ import { PRD_SECTIONS, SPEC_FIXTURE, IA_FIXTURE, FLOW_FIXTURE } from "../lib/fixtures.js";
12
+ const ITEM_KINDS = ['feature-group', 'feature', 'page', 'flow', 'step'];
13
+ export async function deliverableRoutes(app) {
14
+ // ── plan items (structure docs) ─────────────────────────────────────────────
15
+ app.get('/api/documents/:id/items', async (req) => {
16
+ const { id } = req.params;
17
+ if (!repo.getDocument(id))
18
+ throw new HttpError(404, 'document not found');
19
+ return { items: repo.listItems(id) };
20
+ });
21
+ app.post('/api/documents/:id/items', async (req) => {
22
+ const { id } = req.params;
23
+ if (!repo.getDocument(id))
24
+ throw new HttpError(404, 'document not found');
25
+ const body = parse(z.object({
26
+ kind: z.enum(ITEM_KINDS),
27
+ title: z.string().min(1),
28
+ body: z.string().optional(),
29
+ meta: z.record(z.unknown()).optional(),
30
+ parentId: z.string().nullable().optional(),
31
+ status: z.enum(['proposed', 'accepted', 'rejected']).optional(),
32
+ }), req.body);
33
+ return repo.createItem({
34
+ documentId: id,
35
+ kind: body.kind,
36
+ title: body.title,
37
+ body: body.body,
38
+ meta: body.meta,
39
+ parentId: body.parentId ?? null,
40
+ status: body.status ?? 'accepted', // manual add is the editor's own text
41
+ });
42
+ });
43
+ app.patch('/api/items/:id', async (req) => {
44
+ const { id } = req.params;
45
+ if (!repo.getItem(id))
46
+ throw new HttpError(404, 'item not found');
47
+ const body = parse(z.object({
48
+ title: z.string().optional(),
49
+ body: z.string().optional(),
50
+ meta: z.record(z.unknown()).optional(),
51
+ position: z.number().int().optional(),
52
+ }), req.body ?? {});
53
+ return repo.updateItem(id, body);
54
+ });
55
+ app.delete('/api/items/:id', async (req) => {
56
+ const { id } = req.params;
57
+ if (!repo.getItem(id))
58
+ throw new HttpError(404, 'item not found');
59
+ repo.deleteItem(id);
60
+ return { ok: true };
61
+ });
62
+ app.post('/api/items/:id/accept', async (req) => {
63
+ const { id } = req.params;
64
+ const item = repo.getItem(id);
65
+ if (!item)
66
+ throw new HttpError(404, 'item not found');
67
+ repo.setItemStatus(id, 'accepted');
68
+ for (const s of repo.listItemSuggestions(id))
69
+ repo.resolveSuggestion(s.id, 'accepted');
70
+ return repo.getItem(id);
71
+ });
72
+ app.post('/api/items/:id/reject', async (req) => {
73
+ const { id } = req.params;
74
+ const item = repo.getItem(id);
75
+ if (!item)
76
+ throw new HttpError(404, 'item not found');
77
+ repo.setItemStatus(id, 'rejected');
78
+ for (const s of repo.listItemSuggestions(id))
79
+ repo.resolveSuggestion(s.id, 'rejected');
80
+ return repo.getItem(id);
81
+ });
82
+ // 범용 링크 편집 — 항목(:id)의 meta.links.<field> 배열에서 ref 를 추가/제거한다.
83
+ // 링크 위반 근본 해소에 공용: 기능→요구(reqs)·화면→기능(features)·플로우→기능(features).
84
+ // lint 가 검사하는 배열이 subject 쪽에 있으므로 편집도 subject 항목에서 한다.
85
+ app.post('/api/items/:id/link', async (req) => {
86
+ const { id } = req.params;
87
+ const item = repo.getItem(id);
88
+ if (!item)
89
+ throw new HttpError(404, 'item not found');
90
+ const { field, ref, op } = parse(z.object({
91
+ field: z.enum(['reqs', 'pages', 'flows', 'features']),
92
+ ref: z.string().min(1),
93
+ op: z.enum(['add', 'remove']),
94
+ }), req.body);
95
+ const meta = repo.parsePlanItemMeta(item);
96
+ const links = meta.links ?? {};
97
+ const cur = links[field] ?? [];
98
+ const next = op === 'add' ? Array.from(new Set([...cur, ref])) : cur.filter((r) => r !== ref);
99
+ return repo.updateItem(id, { meta: { ...meta, links: { ...links, [field]: next } } });
100
+ });
101
+ // 스텝(:id)의 meta.page 를 지정/해제한다 — W-UNREACHED-PAGE 근본 해소.
102
+ // page 는 배열이 아니라 스칼라(스텝이 한 화면에 도달)이므로 /link 와 별도.
103
+ app.post('/api/items/:id/step-page', async (req) => {
104
+ const { id } = req.params;
105
+ const step = repo.getItem(id);
106
+ if (!step)
107
+ throw new HttpError(404, 'item not found');
108
+ if (step.kind !== 'step')
109
+ throw new HttpError(400, 'page can only be set on a step');
110
+ const { page } = parse(z.object({ page: z.string().nullable() }), req.body);
111
+ const meta = repo.parsePlanItemMeta(step);
112
+ return repo.updateItem(id, { meta: { ...meta, page: page || null } });
113
+ });
114
+ // 페이지(:id)의 meta.section 을 지정/해제한다 — 사이트맵 계층(섹션>페이지) 편집.
115
+ app.post('/api/items/:id/section', async (req) => {
116
+ const { id } = req.params;
117
+ const page = repo.getItem(id);
118
+ if (!page)
119
+ throw new HttpError(404, 'item not found');
120
+ if (page.kind !== 'page')
121
+ throw new HttpError(400, 'section can only be set on a page');
122
+ const { section } = parse(z.object({ section: z.string() }), req.body);
123
+ const meta = repo.parsePlanItemMeta(page);
124
+ return repo.updateItem(id, { meta: { ...meta, section: section.trim() || undefined } });
125
+ });
126
+ // 이 프로젝트의 유효 REQ id 목록 (PRD 수락 섹션에서 파생) — 기능→요구 연결 드롭다운용.
127
+ app.get('/api/projects/:id/reqs', async (req) => {
128
+ const { id } = req.params;
129
+ if (!repo.getProject(id))
130
+ throw new HttpError(404, 'project not found');
131
+ return { reqs: repo.reqIdsForProject(id) };
132
+ });
133
+ // 하위호환: 기존 link-feature/unlink-feature (플로우→기능) 유지
134
+ app.post('/api/items/:id/link-feature', async (req) => {
135
+ const { id } = req.params;
136
+ const flow = repo.getItem(id);
137
+ if (!flow)
138
+ throw new HttpError(404, 'item not found');
139
+ if (flow.kind !== 'flow')
140
+ throw new HttpError(400, 'link target must be a flow');
141
+ const { featureRef } = parse(z.object({ featureRef: z.string().min(1) }), req.body);
142
+ const meta = repo.parsePlanItemMeta(flow);
143
+ const links = meta.links ?? {};
144
+ const features = Array.from(new Set([...(links.features ?? []), featureRef]));
145
+ return repo.updateItem(id, { meta: { ...meta, links: { ...links, features } } });
146
+ });
147
+ app.post('/api/items/:id/unlink-feature', async (req) => {
148
+ const { id } = req.params;
149
+ const flow = repo.getItem(id);
150
+ if (!flow)
151
+ throw new HttpError(404, 'item not found');
152
+ const { featureRef } = parse(z.object({ featureRef: z.string().min(1) }), req.body);
153
+ const meta = repo.parsePlanItemMeta(flow);
154
+ const links = meta.links ?? {};
155
+ const features = (links.features ?? []).filter((f) => f !== featureRef);
156
+ return repo.updateItem(id, { meta: { ...meta, links: { ...links, features } } });
157
+ });
158
+ // 제외(rejected)된 항목을 되살린다 — 정합성 '모두 수락' 등으로 통째로 제외돼
159
+ // 화면에서 사라진 항목을 재검토(proposed)로 복귀. 본문은 보존돼 있어 손실 없음.
160
+ app.post('/api/items/:id/restore', async (req) => {
161
+ const { id } = req.params;
162
+ const item = repo.getItem(id);
163
+ if (!item)
164
+ throw new HttpError(404, 'item not found');
165
+ repo.setItemStatus(id, 'proposed');
166
+ return repo.getItem(id);
167
+ });
168
+ // SSE generation of a structure document's items (EventSource)
169
+ app.get('/api/documents/:id/items/generate/stream', async (req, reply) => {
170
+ const { id } = req.params;
171
+ if (!repo.getDocument(id))
172
+ throw new HttpError(404, 'document not found');
173
+ await pipeItems(streamItemsGeneration(id), req, reply);
174
+ });
175
+ // ── project-level deliverables ──────────────────────────────────────────────
176
+ app.get('/api/projects/:id/lint', async (req) => {
177
+ const { id } = req.params;
178
+ if (!repo.getProject(id))
179
+ throw new HttpError(404, 'project not found');
180
+ return lintReport(id);
181
+ });
182
+ app.post('/api/projects/:id/lint/suggest', async (req) => {
183
+ const { id } = req.params;
184
+ if (!repo.getProject(id))
185
+ throw new HttpError(404, 'project not found');
186
+ const created = suggestLint(id);
187
+ return { created, report: lintReport(id) };
188
+ });
189
+ // 위반 하나를 키로 무시(waive)한다 — 인라인 배지에서 개별 처리. 비파괴적.
190
+ app.post('/api/projects/:id/lint/waive', async (req) => {
191
+ const { id } = req.params;
192
+ if (!repo.getProject(id))
193
+ throw new HttpError(404, 'project not found');
194
+ const { key } = parse(z.object({ key: z.string().min(1) }), req.body);
195
+ suggestLint(id); // 해당 위반의 lint 제안이 없으면 생성
196
+ let waived = false;
197
+ for (const doc of repo.listDocuments(id)) {
198
+ for (const s of repo.listLintSuggestions(doc.id, 'open')) {
199
+ if (s.quote_before === key) {
200
+ repo.resolveSuggestion(s.id, 'rejected');
201
+ waived = true;
202
+ }
203
+ }
204
+ }
205
+ return { waived, report: lintReport(id) };
206
+ });
207
+ // 모든 현재 위반을 비파괴적으로 무시(waive)한다 — 항목·본문은 그대로 두고
208
+ // 게이트만 통과시킨다('모두 수락'의 항목 제외와 다름). §4.3 waive 를 일괄 적용.
209
+ app.post('/api/projects/:id/lint/waive-all', async (req) => {
210
+ const { id } = req.params;
211
+ if (!repo.getProject(id))
212
+ throw new HttpError(404, 'project not found');
213
+ suggestLint(id); // 위반마다 lint 제안 생성(없는 것만)
214
+ let waived = 0;
215
+ for (const doc of repo.listDocuments(id)) {
216
+ for (const s of repo.listLintSuggestions(doc.id, 'open')) {
217
+ repo.resolveSuggestion(s.id, 'rejected'); // waive — 항목 상태는 건드리지 않음
218
+ waived++;
219
+ }
220
+ }
221
+ return { waived, report: lintReport(id) };
222
+ });
223
+ app.get('/api/projects/:id/wireframes', async (req) => {
224
+ const { id } = req.params;
225
+ if (!repo.getProject(id))
226
+ throw new HttpError(404, 'project not found');
227
+ return { wireframes: deriveWireframes(id) };
228
+ });
229
+ // ── 프로젝트 내보내기·가져오기 (전체 상태 스냅샷, 기기 간 이동) ──────────────
230
+ app.get('/api/projects/:id/export', async (req, reply) => {
231
+ const { id } = req.params;
232
+ const bundle = repo.exportProjectBundle(id);
233
+ if (!bundle)
234
+ throw new HttpError(404, 'project not found');
235
+ // 헤더는 ASCII 만 허용 → filename 은 ASCII 로, 한글 원본은 RFC5987 filename* 로.
236
+ const ascii = bundle.project.name.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'project';
237
+ const utf8 = encodeURIComponent(`${bundle.project.name}.drafting`);
238
+ reply.header('content-disposition', `attachment; filename="${ascii}.drafting"; filename*=UTF-8''${utf8}`);
239
+ return bundle;
240
+ });
241
+ app.post('/api/projects/import', async (req) => {
242
+ const bundle = req.body;
243
+ const pid = repo.importProjectBundle(bundle);
244
+ return { projectId: pid };
245
+ });
246
+ // ── 디자인 시스템 — 인터뷰 답변 → StyleGuide + 근거 + 스타일 타일(제안→수락) ──
247
+ app.get('/api/projects/:id/design-system', async (req) => {
248
+ const { id } = req.params;
249
+ if (!repo.getProject(id))
250
+ throw new HttpError(404, 'project not found');
251
+ return { record: getDesignSystem(id) };
252
+ });
253
+ app.post('/api/documents/:id/design-system/generate', async (req) => {
254
+ const { id } = req.params;
255
+ const doc = repo.getDocument(id);
256
+ if (!doc)
257
+ throw new HttpError(404, 'document not found');
258
+ if (doc.type !== 'design-system')
259
+ throw new HttpError(400, 'not a design-system document');
260
+ return { record: await generateDesignSystem(id) };
261
+ });
262
+ app.post('/api/documents/:id/design-system/explore', async (req) => {
263
+ const { id } = req.params;
264
+ const doc = repo.getDocument(id);
265
+ if (!doc)
266
+ throw new HttpError(404, 'document not found');
267
+ if (doc.type !== 'design-system')
268
+ throw new HttpError(400, 'not a design-system document');
269
+ return { candidates: exploreDesignSystems(id) };
270
+ });
271
+ app.post('/api/documents/:id/design-system/select', async (req) => {
272
+ const { id } = req.params;
273
+ const doc = repo.getDocument(id);
274
+ if (!doc)
275
+ throw new HttpError(404, 'document not found');
276
+ const { index } = parse(z.object({ index: z.number().int().min(0) }), req.body);
277
+ return { record: selectDesignSystemCandidate(id, index) };
278
+ });
279
+ app.post('/api/documents/:id/design-system/accept', async (req) => {
280
+ const { id } = req.params;
281
+ const doc = repo.getDocument(id);
282
+ if (!doc)
283
+ throw new HttpError(404, 'document not found');
284
+ return { record: acceptDesignSystem(id) };
285
+ });
286
+ // ── StyleGuide(테마) — C: 와이어프레임/시안 공용 스타일 ────────────────────
287
+ app.get('/api/projects/:id/style-guide', async (req) => {
288
+ const { id } = req.params;
289
+ if (!repo.getProject(id))
290
+ throw new HttpError(404, 'project not found');
291
+ const guide = getStyleGuide(id);
292
+ return { guide, render: guideRender(guide), presets: Object.keys(PRESETS) };
293
+ });
294
+ app.put('/api/projects/:id/style-guide', async (req) => {
295
+ const { id } = req.params;
296
+ if (!repo.getProject(id))
297
+ throw new HttpError(404, 'project not found');
298
+ const patch = parse(z.object({
299
+ preset: z.string().optional(),
300
+ accent: z.string().optional(),
301
+ density: z.enum(['compact', 'cozy', 'spacious']).optional(),
302
+ font: z.enum(['sans', 'serif', 'rounded', 'mono']).optional(),
303
+ mode: z.enum(['light', 'dark']).optional(),
304
+ }), req.body);
305
+ const guide = saveStyleGuide(id, patch);
306
+ return { guide, render: guideRender(guide) };
307
+ });
308
+ // ── AI 시안(mockup) — A: 페이지당 자기완결 HTML, 제안 문법 ─────────────────
309
+ app.get('/api/projects/:id/mockups', async (req) => {
310
+ const { id } = req.params;
311
+ if (!repo.getProject(id))
312
+ throw new HttpError(404, 'project not found');
313
+ // 목록엔 상태만(html 제외로 가볍게)
314
+ return {
315
+ mockups: repo.listMockups(id).map((m) => ({ pageRef: m.page_ref, status: m.status, styleKey: m.style_key })),
316
+ };
317
+ });
318
+ app.get('/api/projects/:id/mockups/:ref', async (req) => {
319
+ const { id, ref } = req.params;
320
+ const m = repo.getMockup(id, ref);
321
+ if (!m)
322
+ throw new HttpError(404, 'mockup not found');
323
+ return { pageRef: m.page_ref, status: m.status, styleKey: m.style_key, html: m.html };
324
+ });
325
+ // 생성/재생성 — item(:id)=IA 페이지. 결과는 proposed.
326
+ app.post('/api/items/:id/mockup', async (req) => {
327
+ const { id } = req.params;
328
+ const page = repo.getItem(id);
329
+ if (!page)
330
+ throw new HttpError(404, 'item not found');
331
+ if (page.kind !== 'page')
332
+ throw new HttpError(400, 'mockup can only be generated for a page');
333
+ const doc = repo.getDocument(page.document_id);
334
+ if (!doc)
335
+ throw new HttpError(404, 'document not found');
336
+ const html = await generateMockupHtml(doc.project_id, page);
337
+ const guide = getStyleGuide(doc.project_id);
338
+ const saved = repo.upsertMockup(doc.project_id, page.ref_id, html, guide.preset);
339
+ return { pageRef: saved.page_ref, status: saved.status, styleKey: saved.style_key, html: saved.html };
340
+ });
341
+ app.post('/api/items/:id/mockup/accept', async (req) => {
342
+ const { id } = req.params;
343
+ const page = repo.getItem(id);
344
+ if (!page)
345
+ throw new HttpError(404, 'item not found');
346
+ const doc = repo.getDocument(page.document_id);
347
+ if (!doc)
348
+ throw new HttpError(404, 'document not found');
349
+ const m = repo.setMockupStatus(doc.project_id, page.ref_id, 'accepted');
350
+ if (!m)
351
+ throw new HttpError(404, 'mockup not found');
352
+ return { pageRef: m.page_ref, status: m.status };
353
+ });
354
+ app.post('/api/items/:id/mockup/reject', async (req) => {
355
+ const { id } = req.params;
356
+ const page = repo.getItem(id);
357
+ if (!page)
358
+ throw new HttpError(404, 'item not found');
359
+ const doc = repo.getDocument(page.document_id);
360
+ if (!doc)
361
+ throw new HttpError(404, 'document not found');
362
+ repo.deleteMockup(doc.project_id, page.ref_id);
363
+ return { ok: true };
364
+ });
365
+ app.post('/api/projects/:id/handoff', async (req, reply) => {
366
+ const { id } = req.params;
367
+ if (!repo.getProject(id))
368
+ throw new HttpError(404, 'project not found');
369
+ try {
370
+ const { documentId } = await compileHandoff(id);
371
+ return { documentId, report: lintReport(id) };
372
+ }
373
+ catch (e) {
374
+ if (e instanceof HandoffGateError) {
375
+ reply.code(409);
376
+ return { error: e.message, violations: e.violations };
377
+ }
378
+ throw e;
379
+ }
380
+ });
381
+ app.get('/api/projects/:id/handoff/prompt-pack', async (req, reply) => {
382
+ const { id } = req.params;
383
+ if (!repo.getProject(id))
384
+ throw new HttpError(404, 'project not found');
385
+ reply
386
+ .header('Content-Type', 'text/markdown; charset=utf-8')
387
+ .header('Content-Disposition', `attachment; filename="handoff-${id}.md"`);
388
+ return promptPack(id);
389
+ });
390
+ // 개발 티켓(체크리스트 MD) — 게이트 무관, 현재 수락분 실행 목록.
391
+ app.get('/api/projects/:id/handoff/tickets.md', async (req, reply) => {
392
+ const { id } = req.params;
393
+ if (!repo.getProject(id))
394
+ throw new HttpError(404, 'project not found');
395
+ reply
396
+ .header('Content-Type', 'text/markdown; charset=utf-8')
397
+ .header('Content-Disposition', `attachment; filename="tickets-${id}.md"`);
398
+ return handoffTickets(id);
399
+ });
400
+ app.get('/api/projects/:id/hub', async (req) => {
401
+ const { id } = req.params;
402
+ if (!repo.getProject(id))
403
+ throw new HttpError(404, 'project not found');
404
+ return hubSnapshot(id);
405
+ });
406
+ // Seed a fully-populated demo project (the 회의실 예약 example) so the hub /
407
+ // wireframes / handoff / lint have real cross-linked data with no AI/keys.
408
+ app.post('/api/sample/deliverables', async () => {
409
+ const existing = repo.listProjects().find((p) => p.name === DELIVERABLES_SAMPLE);
410
+ if (existing)
411
+ return { projectId: existing.id, created: false };
412
+ const projectId = seedDeliverables();
413
+ return { projectId, created: true };
414
+ });
415
+ }
416
+ async function pipeItems(gen, req, reply) {
417
+ const sse = sseStream(req, reply);
418
+ try {
419
+ for await (const evt of gen) {
420
+ const { type, ...rest } = evt;
421
+ sse.send(type, rest);
422
+ if (evt.type === 'done' || evt.type === 'error')
423
+ break;
424
+ }
425
+ }
426
+ catch (e) {
427
+ sse.send('error', { message: e.message });
428
+ }
429
+ finally {
430
+ sse.end();
431
+ }
432
+ }
433
+ function rollupItems(documentId) {
434
+ const items = repo.listItems(documentId).filter((i) => i.status !== 'rejected');
435
+ return {
436
+ accepted: items.filter((i) => i.status === 'accepted').length,
437
+ proposed: items.filter((i) => i.status === 'proposed').length,
438
+ total: items.length,
439
+ };
440
+ }
441
+ function rollupSections(documentId) {
442
+ const secs = repo.listSections(documentId).filter((s) => s.status !== 'rejected');
443
+ return {
444
+ accepted: secs.filter((s) => s.status === 'accepted').length,
445
+ proposed: secs.filter((s) => s.status === 'proposed').length,
446
+ total: secs.length,
447
+ };
448
+ }
449
+ const CHAIN = ['prd', 'feature-spec', 'ia', 'user-flow', 'design-system'];
450
+ const CHAIN_LABEL = {
451
+ prd: 'PRD',
452
+ 'feature-spec': '기능명세서',
453
+ ia: '정보 구조',
454
+ 'user-flow': '유저 플로우',
455
+ 'design-system': '디자인 시스템',
456
+ };
457
+ /** 6-deliverable roll-up for the hub (§3) + per-doc stale/open + 다음 할 일. */
458
+ export function hubSnapshot(projectId) {
459
+ const docs = repo.listDocuments(projectId);
460
+ const perDoc = {};
461
+ for (const type of CHAIN) {
462
+ const doc = docs.find((d) => d.type === type);
463
+ if (!doc) {
464
+ perDoc[type] = { accepted: 0, proposed: 0, total: 0, documentId: null, stale: false, openSuggestions: 0, status: null };
465
+ continue;
466
+ }
467
+ const roll = type === 'prd' ? rollupSections(doc.id) : rollupItems(doc.id);
468
+ perDoc[type] = {
469
+ ...roll,
470
+ documentId: doc.id,
471
+ stale: doc.context_stale === 1,
472
+ openSuggestions: repo.countOpenSuggestions(doc.id),
473
+ status: doc.status,
474
+ };
475
+ }
476
+ const report = lintReport(projectId);
477
+ const wireframes = deriveWireframes(projectId);
478
+ const handoffDoc = getHandoffDoc(projectId);
479
+ // ── 다음 할 일: 체인 순서 + 위반/제안/stale 을 종합해 가장 중요한 한 걸음을 고른다 ──
480
+ const nextAction = computeNextAction(perDoc, report, !!handoffDoc);
481
+ return {
482
+ perDoc,
483
+ lint: report,
484
+ nextAction,
485
+ derived: {
486
+ wireframes: { count: wireframes.length },
487
+ handoff: {
488
+ compiled: !!handoffDoc,
489
+ documentId: handoffDoc?.id ?? null,
490
+ locked: !report.gatePasses,
491
+ blocking: report.effectiveCount,
492
+ },
493
+ },
494
+ };
495
+ }
496
+ function computeNextAction(perDoc, report, handoffCompiled) {
497
+ // 1) 아직 없는/빈 문서 — 체인 순서대로 첫 번째
498
+ for (const type of CHAIN) {
499
+ const d = perDoc[type];
500
+ if (!d.documentId || d.total === 0) {
501
+ return {
502
+ kind: 'create',
503
+ label: `${CHAIN_LABEL[type]} 생성`,
504
+ detail: `${CHAIN_LABEL[type]} 가 아직 없습니다. 여기서 체인을 이어가세요.`,
505
+ documentId: d.documentId,
506
+ target: d.documentId ? 'document' : 'none',
507
+ };
508
+ }
509
+ }
510
+ // 2) stale — 상위 변경으로 재검토 필요한 문서
511
+ for (const type of CHAIN) {
512
+ const d = perDoc[type];
513
+ if (d.stale) {
514
+ return {
515
+ kind: 'stale',
516
+ label: `${CHAIN_LABEL[type]} 재검토`,
517
+ detail: `상위 문서 변경으로 ${CHAIN_LABEL[type]} 가 재검토 대기 상태입니다.`,
518
+ documentId: d.documentId,
519
+ target: 'document',
520
+ };
521
+ }
522
+ }
523
+ // 3) 열린 제안 — 검토 대기
524
+ for (const type of CHAIN) {
525
+ const d = perDoc[type];
526
+ if (d.openSuggestions > 0) {
527
+ return {
528
+ kind: 'review',
529
+ label: `${CHAIN_LABEL[type]} 제안 ${d.openSuggestions}건 검토`,
530
+ detail: '수락·거절로 제안을 정리하면 문서가 확정됩니다.',
531
+ documentId: d.documentId,
532
+ target: 'document',
533
+ };
534
+ }
535
+ }
536
+ // 4) 정합성 위반
537
+ if (report.effectiveCount > 0) {
538
+ const specId = perDoc['feature-spec']?.documentId ?? null;
539
+ return {
540
+ kind: 'lint',
541
+ label: `정합성 위반 ${report.effectiveCount}건 해소`,
542
+ detail: '연결 편집·우선순위·무시로 위반을 정리하세요.',
543
+ documentId: specId,
544
+ target: specId ? 'document' : 'none',
545
+ };
546
+ }
547
+ // 5) 게이트 통과 — 개발 지시서
548
+ return {
549
+ kind: handoffCompiled ? 'done' : 'handoff',
550
+ label: handoffCompiled ? '완성 · 개발 지시서 내보내기' : '개발 지시서 생성',
551
+ detail: handoffCompiled ? '체인이 완성됐습니다. 지시서를 공유하거나 내보내세요.' : '정합성 검사를 통과했습니다. 지시서를 생성할 수 있어요.',
552
+ documentId: null,
553
+ target: 'handoff',
554
+ };
555
+ }
556
+ const DELIVERABLES_SAMPLE = '예시: 회의실 예약 정리';
557
+ /** Build the full demo chain (PRD accepted + SPEC/IA/FLOW items accepted). */
558
+ export function seedDeliverables() {
559
+ const project = repo.createProject(DELIVERABLES_SAMPLE, '겹침 없는 예약과 자동 반납·노쇼 처리');
560
+ const prd = repo.createDocument({ projectId: project.id, type: 'prd', title: '제품 요구사항' });
561
+ for (const s of PRD_SECTIONS)
562
+ repo.createSection(prd.id, s.heading, s.body, undefined, 'accepted');
563
+ const spec = repo.createDocument({
564
+ projectId: project.id,
565
+ type: 'feature-spec',
566
+ title: '기능명세서',
567
+ parentDocumentId: prd.id,
568
+ });
569
+ materializeSpec(spec.id, SPEC_FIXTURE, { status: 'accepted', withSuggestions: false });
570
+ repo.setDocumentStatus(spec.id, 'ready');
571
+ const ia = repo.createDocument({
572
+ projectId: project.id,
573
+ type: 'ia',
574
+ title: '정보 구조',
575
+ parentDocumentId: spec.id,
576
+ });
577
+ materializeIa(ia.id, IA_FIXTURE, { status: 'accepted', withSuggestions: false });
578
+ repo.setDocumentStatus(ia.id, 'ready');
579
+ const flow = repo.createDocument({
580
+ projectId: project.id,
581
+ type: 'user-flow',
582
+ title: '유저 플로우',
583
+ parentDocumentId: ia.id,
584
+ });
585
+ materializeFlow(flow.id, FLOW_FIXTURE, { status: 'accepted', withSuggestions: false });
586
+ repo.setDocumentStatus(flow.id, 'ready');
587
+ return project.id;
588
+ }