@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,283 @@
1
+ // Development handoff = a COMPILED deliverable (§6). Gate: the lint set (E+W
2
+ // minus waived) must be empty. The skeleton (scope/reqs/features/screens/
3
+ // non-scope) is deterministic; only the §1 summary is AI-generated and it comes
4
+ // in as a proposal (accept to confirm). Handoff is a `documents` row of a new
5
+ // type 'handoff', so the existing section editor + export/share are reused.
6
+ import * as repo from "../db/repos.js";
7
+ import { resolveProvider } from "../providers/index.js";
8
+ import { getModelConfig } from "./ai.js";
9
+ import { effectiveViolations } from "./lint-service.js";
10
+ export class HandoffGateError extends Error {
11
+ violations;
12
+ constructor(violations) {
13
+ super('정합성 검사 위반이 남아 있어 지시서를 생성할 수 없습니다.');
14
+ this.name = 'HandoffGateError';
15
+ this.violations = violations;
16
+ }
17
+ }
18
+ function meta(i) {
19
+ try {
20
+ return JSON.parse(i.meta || '{}');
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ export function getHandoffDoc(projectId) {
27
+ return repo.listDocuments(projectId).find((d) => d.type === 'handoff') ?? null;
28
+ }
29
+ /** Build the deterministic handoff body from a project's ACCEPTED items. */
30
+ export function compileHandoffBody(projectId) {
31
+ const project = repo.getProject(projectId);
32
+ const items = repo.listProjectItems(projectId).filter((i) => i.status === 'accepted');
33
+ const reqs = repo.reqIdsForProject(projectId);
34
+ const features = items.filter((i) => i.kind === 'feature');
35
+ const groups = items.filter((i) => i.kind === 'feature-group');
36
+ const pages = items.filter((i) => i.kind === 'page');
37
+ const flows = items.filter((i) => i.kind === 'flow');
38
+ const p0 = features.filter((f) => meta(f).priority === 'P0').length;
39
+ const p1 = features.filter((f) => meta(f).priority === 'P1').length;
40
+ const p2 = features.filter((f) => meta(f).priority === 'P2').length;
41
+ const groupRefs = groups.map((g) => g.ref_id);
42
+ const scope = `**${project?.name ?? '프로젝트'}** — 수락된 기능 ${features.length}개를 구현한다.\n\n` +
43
+ `범위: ${groupRefs.join(' · ') || '—'} (P0 ${p0} · P1 ${p1} · P2 ${p2}).`;
44
+ const reqsBody = reqs.length
45
+ ? reqs.map((r) => `- ${r.id} ${r.heading}`).join('\n')
46
+ : '- (PRD 수락 섹션 없음)';
47
+ const featuresBody = features.length
48
+ ? features
49
+ .map((f) => {
50
+ const m = meta(f);
51
+ const crit = f.body.split('\n').map((l) => l.replace(/^[·\-*]\s*/, '').trim()).filter(Boolean)[0];
52
+ const links = [...(m.links?.reqs ?? []), ...(m.links?.flows ?? [])].join(', ');
53
+ return `- ${f.ref_id} ${f.title} (${m.priority ?? 'P?'}${links ? ' · ' + links : ''})` +
54
+ (crit ? `\n - 수용 기준: ${crit}` : '');
55
+ })
56
+ .join('\n')
57
+ : '- (수락된 기능 없음)';
58
+ const screens = pages.length
59
+ ? pages
60
+ .map((pg) => {
61
+ const m = meta(pg);
62
+ return `- ${pg.ref_id} ${pg.title} [${m.page_type ?? 'GENERIC'}] · 기능 ${(m.links?.features ?? []).join(', ') || '—'}`;
63
+ })
64
+ .join('\n') +
65
+ '\n\n플로우:\n' +
66
+ (flows.length ? flows.map((fl) => `- ${fl.ref_id} ${fl.title}`).join('\n') : '- (없음)')
67
+ : '- (화면 없음)';
68
+ const nonScope = 'PRD 비범위 항목은 이번 지시서에서 제외한다. 수락하지 않은 항목은 지시서에 없습니다.';
69
+ const summaryPrompt = `아래 스코프를 한 문단으로 요약하는 개요 문장을 한국어로 써라. 섹션 제목: 개요\n\n${scope}\n\n` +
70
+ `요구 ${reqs.length}개, 기능 ${features.length}개, 화면 ${pages.length}개, 플로우 ${flows.length}개.`;
71
+ return { scope, reqs: reqsBody, features: featuresBody, screens, nonScope, summaryPrompt };
72
+ }
73
+ /**
74
+ * 개발 티켓(GitHub-flavored 체크리스트) — 수락된 기능 하나 = 티켓, 수용 기준 = 체크박스.
75
+ * 게이트와 무관하게 언제든 내보낼 수 있다(현재 수락분의 실행 목록).
76
+ */
77
+ export function handoffTickets(projectId) {
78
+ const project = repo.getProject(projectId);
79
+ const items = repo.listProjectItems(projectId).filter((i) => i.status === 'accepted');
80
+ const groups = items.filter((i) => i.kind === 'feature-group');
81
+ const features = items.filter((i) => i.kind === 'feature');
82
+ const lines = [
83
+ `# ${project?.name ?? '프로젝트'} — 개발 티켓`,
84
+ '',
85
+ `> 수락된 기능 ${features.length}개. 각 기능 = 티켓, 수용 기준 = 체크리스트.`,
86
+ '',
87
+ ];
88
+ const ticketFor = (f) => {
89
+ const m = meta(f);
90
+ const pri = m.priority ?? 'P?';
91
+ const links = [...(m.links?.reqs ?? []), ...(m.links?.pages ?? []), ...(m.links?.flows ?? [])];
92
+ lines.push(`### ${f.ref_id} ${f.title} \`${pri}\``);
93
+ if (links.length)
94
+ lines.push(`연결: ${links.join(' · ')}`);
95
+ const crit = f.body.split('\n').map((l) => l.replace(/^[·\-*]\s*/, '').trim()).filter(Boolean);
96
+ if (crit.length) {
97
+ lines.push('', '수용 기준:');
98
+ for (const c of crit)
99
+ lines.push(`- [ ] ${c}`);
100
+ }
101
+ lines.push('');
102
+ };
103
+ for (const g of groups) {
104
+ const feats = features.filter((f) => f.parent_id === g.id);
105
+ if (!feats.length)
106
+ continue;
107
+ lines.push(`## ${g.ref_id} ${g.title}`, '');
108
+ for (const f of feats)
109
+ ticketFor(f);
110
+ }
111
+ const orphans = features.filter((f) => !groups.some((g) => g.id === f.parent_id));
112
+ if (orphans.length) {
113
+ lines.push('## 기타 기능', '');
114
+ for (const f of orphans)
115
+ ticketFor(f);
116
+ }
117
+ if (features.length === 0)
118
+ lines.push('_(수락된 기능이 없습니다. 기능명세에서 수락 후 다시 내보내세요.)_', '');
119
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
120
+ }
121
+ /**
122
+ * Compile (or recompile) the handoff document for a project. Throws
123
+ * HandoffGateError (→ 409) if the effective lint set is non-empty. The §1 개요
124
+ * section is AI-generated and left 'proposed' with an 'add' suggestion; the rest
125
+ * of the deterministic skeleton is written directly as 'accepted'.
126
+ */
127
+ export async function compileHandoff(projectId) {
128
+ const violations = effectiveViolations(projectId);
129
+ if (violations.length > 0)
130
+ throw new HandoffGateError(violations);
131
+ const body = compileHandoffBody(projectId);
132
+ // one AI summary sentence (proposal). Stub-capable (no network).
133
+ const cfg = getModelConfig('handoff');
134
+ const provider = resolveProvider(cfg.provider);
135
+ let summary = '';
136
+ try {
137
+ for await (const delta of provider.streamChat({
138
+ model: cfg.model,
139
+ maxTokens: cfg.maxTokens,
140
+ messages: [
141
+ { role: 'system', content: '개발 지시서의 개요 문단만 간결히 쓴다. 한국어.' },
142
+ { role: 'user', content: body.summaryPrompt },
143
+ ],
144
+ })) {
145
+ summary += delta;
146
+ }
147
+ }
148
+ catch {
149
+ summary = body.scope;
150
+ }
151
+ let doc = getHandoffDoc(projectId);
152
+ if (!doc)
153
+ doc = repo.createDocument({ projectId, type: 'handoff', title: '개발 지시서' });
154
+ // rebuild sections deterministically; §1 개요 is a fresh proposal
155
+ repo.replaceSections(doc.id, [
156
+ { heading: '§1 개요', body: summary.trim() || body.scope },
157
+ { heading: '§2 스코프', body: body.scope },
158
+ { heading: '§3 요구 (REQ)', body: body.reqs },
159
+ { heading: '§4 기능 목록', body: body.features },
160
+ { heading: '§5 화면 · 플로우', body: body.screens },
161
+ { heading: '§6 비범위', body: body.nonScope },
162
+ ], 'accepted');
163
+ // §1 개요 back to proposed (the only AI-authored part) + a proposal card
164
+ const sections = repo.listSections(doc.id);
165
+ const overview = sections[0];
166
+ repo.setSectionStatus(overview.id, 'proposed');
167
+ repo.createSuggestion({
168
+ documentId: doc.id,
169
+ sectionId: overview.id,
170
+ kind: 'add',
171
+ title: '지시서 초안 v1',
172
+ body: '검사를 통과해 지시서를 컴파일했어요. 개요를 수락하면 지시서가 확정됩니다.',
173
+ quoteAfter: overview.body,
174
+ source: '수락분 컴파일',
175
+ });
176
+ repo.setDocumentStatus(doc.id, 'ready');
177
+ return { documentId: doc.id };
178
+ }
179
+ /**
180
+ * Prompt pack for agent hand-off (§6 출구 ①): the full handoff (accepted
181
+ * sections only) with an "implement only accepted items" header. Markdown.
182
+ */
183
+ /**
184
+ * AI 코딩 에이전트가 바로 실행 가능한 발주서. 지시서 섹션 덤프가 아니라, 수락된 항목에서
185
+ * 역할·요구·기능(수용 기준 체크박스)·화면·플로우·비범위·작업 순서를 구조적으로 조립한다.
186
+ */
187
+ export function promptPack(projectId) {
188
+ const doc = getHandoffDoc(projectId);
189
+ if (!doc)
190
+ return '# 개발 지시서\n\n아직 컴파일되지 않았습니다. 정합성 검사를 통과한 뒤 생성하세요.\n';
191
+ const project = repo.getProject(projectId);
192
+ const items = repo.listProjectItems(projectId).filter((i) => i.status === 'accepted');
193
+ const reqs = repo.reqIdsForProject(projectId);
194
+ const groups = items.filter((i) => i.kind === 'feature-group');
195
+ const features = items.filter((i) => i.kind === 'feature');
196
+ const pages = items.filter((i) => i.kind === 'page');
197
+ const flows = items.filter((i) => i.kind === 'flow');
198
+ const steps = items.filter((i) => i.kind === 'step');
199
+ // 수락된 지시서 §개요(있으면) — AI 생성 요약
200
+ const overview = repo
201
+ .listAcceptedSections(doc.id)
202
+ .find((s) => /개요|overview|배경/i.test(s.heading))?.body.trim();
203
+ const L = [];
204
+ L.push(`# ${project?.name ?? '프로젝트'} — 구현 발주 (AI 에이전트용)`, '');
205
+ L.push('## 역할과 규칙');
206
+ L.push('너는 이 제품의 구현을 맡은 시니어 엔지니어다. 아래를 지켜라:');
207
+ L.push('- **수락된 명세만 구현한다.** 여기 없는 것은 만들지 않는다(범위 확장 금지).');
208
+ L.push('- 각 기능의 **수용 기준을 모두 충족**해야 그 기능이 완료다.');
209
+ L.push('- 화면은 IA 명세를, 화면 전환은 유저 플로우를 따른다.');
210
+ L.push('- 불명확한 점은 임의로 정하지 말고 **질문으로 남겨라**.', '');
211
+ if (overview)
212
+ L.push('## 제품 개요', overview, '');
213
+ L.push('## 요구사항 (REQ)');
214
+ if (reqs.length)
215
+ for (const r of reqs)
216
+ L.push(`- **${r.id}** ${r.heading}`);
217
+ else
218
+ L.push('- (PRD 수락 섹션 없음)');
219
+ L.push('');
220
+ L.push('## 구현할 기능 (수락분)');
221
+ const prioRank = (f) => ({ P0: 0, P1: 1, P2: 2 }[meta(f).priority ?? 'P2'] ?? 3);
222
+ const byGroup = (g) => features.filter((f) => f.parent_id === g.id).sort((a, b) => prioRank(a) - prioRank(b));
223
+ const emit = (f) => {
224
+ const m = meta(f);
225
+ const links = [...(m.links?.reqs ?? []), ...(m.links?.pages ?? []), ...(m.links?.flows ?? [])];
226
+ L.push(`### [${m.priority ?? 'P?'}] ${f.ref_id} ${f.title}`);
227
+ if (links.length)
228
+ L.push(`- 연결: ${links.join(' · ')}`);
229
+ const crit = f.body.split('\n').map((l) => l.replace(/^[·\-*]\s*/, '').trim()).filter(Boolean);
230
+ if (crit.length) {
231
+ L.push('- 수용 기준:');
232
+ for (const c of crit)
233
+ L.push(` - [ ] ${c}`);
234
+ }
235
+ L.push('');
236
+ };
237
+ for (const g of groups.sort((a, b) => a.ref_id.localeCompare(b.ref_id))) {
238
+ const feats = byGroup(g);
239
+ if (!feats.length)
240
+ continue;
241
+ L.push(`#### ${g.ref_id} ${g.title}`, '');
242
+ for (const f of feats)
243
+ emit(f);
244
+ }
245
+ const orphanFeats = features.filter((f) => !groups.some((g) => g.id === f.parent_id)).sort((a, b) => prioRank(a) - prioRank(b));
246
+ if (orphanFeats.length) {
247
+ L.push('#### 기타', '');
248
+ for (const f of orphanFeats)
249
+ emit(f);
250
+ }
251
+ if (pages.length) {
252
+ L.push('## 화면 (IA)');
253
+ for (const pg of pages) {
254
+ const m = meta(pg);
255
+ L.push(`- **${pg.ref_id}** ${pg.title} [${m.page_type ?? 'GENERIC'}]` + (m.links?.features?.length ? ` · 기능 ${m.links.features.join(', ')}` : ''));
256
+ }
257
+ L.push('');
258
+ }
259
+ if (flows.length) {
260
+ L.push('## 유저 플로우');
261
+ for (const fl of flows) {
262
+ const flSteps = steps.filter((s) => s.parent_id === fl.id).sort((a, b) => a.position - b.position);
263
+ const chain = flSteps
264
+ .map((s) => {
265
+ const sm = meta(s);
266
+ const pg = sm.page ? `[${sm.page}] ` : '';
267
+ const br = sm.branch?.label ? ` (${sm.branch.label})` : '';
268
+ return `${pg}${s.title}${br}`;
269
+ })
270
+ .join(' → ');
271
+ L.push(`- **${fl.ref_id}** ${fl.title}: ${chain || '(스텝 없음)'}`);
272
+ }
273
+ L.push('');
274
+ }
275
+ L.push('## 비범위 (구현 금지)');
276
+ L.push('- PRD 비범위 및 수락하지 않은 항목은 이번 구현에서 제외한다.', '');
277
+ L.push('## 작업 순서');
278
+ L.push('1. **P0 기능**부터 순서대로 구현한다. 각 기능은 수용 기준 체크박스를 모두 충족해야 완료.');
279
+ L.push('2. 화면(IA)과 플로우 명세대로 연결한다.');
280
+ L.push('3. 범위 확장·임의 기능 추가 금지. 불명확하면 질문으로 남긴다.');
281
+ L.push('4. 완료 시 각 수용 기준을 어떻게 충족했는지 근거와 함께 요약한다.', '');
282
+ return L.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
283
+ }
@@ -0,0 +1,211 @@
1
+ // AI generation for the three STRUCTURE documents (§2). The LLM returns CONTENT
2
+ // ONLY ({groups|pages|flows}) — never ref_ids (server-numbered §1.2). Output is
3
+ // always 'proposed' with a per-item suggestion carrying its basis (§0.1/§0.3).
4
+ // AI_STUB=1 short-circuits to deterministic fixtures (§2 last bullet).
5
+ import { config } from "./config.js";
6
+ import { resolveProvider } from "../providers/index.js";
7
+ import { getModelConfig } from "./ai.js";
8
+ import { getTemplateForType } from "./templates.js";
9
+ import * as repo from "../db/repos.js";
10
+ import { SPEC_FIXTURE, IA_FIXTURE, FLOW_FIXTURE, } from "./fixtures.js";
11
+ export function isStructureType(t) {
12
+ return t === 'feature-spec' || t === 'ia' || t === 'user-flow';
13
+ }
14
+ // ── lenient JSON parsing (§2) ────────────────────────────────────────────────
15
+ export function extractJson(text) {
16
+ let t = text.trim();
17
+ // drop a leading ```json / ``` fence and a trailing ``` fence
18
+ t = t.replace(/^```[a-zA-Z]*\s*/, '').replace(/\s*```$/, '').trim();
19
+ const first = t.indexOf('{');
20
+ const last = t.lastIndexOf('}');
21
+ if (first >= 0 && last > first)
22
+ return t.slice(first, last + 1);
23
+ return t;
24
+ }
25
+ export function parseItemsJson(text) {
26
+ return JSON.parse(extractJson(text));
27
+ }
28
+ // ── prompt (non-stub path) ───────────────────────────────────────────────────
29
+ function buildItemsMessages(doc) {
30
+ const template = getTemplateForType(doc.type);
31
+ const session = repo.getSessionByDocument(doc.id);
32
+ const answers = session?.answers ?? [];
33
+ const ctx = repo.getParentContext(doc.id);
34
+ const parentBlock = ctx
35
+ ? `상위 문서(${ctx.parentType} "${ctx.parentTitle}"):\n` +
36
+ ctx.sections.map((s) => `### ${s.heading}\n${s.body}`).join('\n\n') +
37
+ '\n\n---\n'
38
+ : '';
39
+ const answerBlock = answers.length
40
+ ? answers.map((a) => `Q: ${a.question}\nA: ${a.answer}`).join('\n\n')
41
+ : '(답변 없음 — 합리적 기본값)';
42
+ const shape = SHAPE_HINT[doc.type];
43
+ const system = `${template?.draftGuidance ?? '구조 문서를 작성한다.'}\n\n` +
44
+ `반드시 JSON 하나만 출력하라. 코드펜스·설명 문장 금지. ` +
45
+ `id·번호(F-/PG-/FLOW-)는 절대 넣지 마라 — 번호는 서버가 매긴다.\n${shape}`;
46
+ const user = `${parentBlock}인터뷰 답변:\n\n${answerBlock}\n\n위 계약대로 JSON 을 출력하라.`;
47
+ return [
48
+ { role: 'system', content: system },
49
+ { role: 'user', content: user },
50
+ ];
51
+ }
52
+ const SHAPE_HINT = {
53
+ 'feature-spec': '형태: {"groups":[{"title":str,"features":[{"title":str,"body":str,"priority":"P0|P1|P2","source":str,"links":{"reqs":[str],"pages":[str],"flows":[str]}}]}]}',
54
+ ia: '형태: {"pages":[{"title":str,"section":str,"page_type":"LIST|DETAIL|FORM|DASH|SETTINGS|GENERIC","links":{"features":[str]}}]}. ' +
55
+ 'section 은 사이트맵 계층 그룹(예: "예약","내 정보","관리자") — 관련 화면끼리 같은 section 으로 묶어라.',
56
+ 'user-flow': '형태: {"flows":[{"title":str,"source":str,"links":{"features":[str]},"steps":[{"title":str,"page":str|null,"node":"start|screen|decision|end","branch":{"label":str,"from_step":str}|null,"note":str}]}]}',
57
+ };
58
+ export function materializeSpec(documentId, data, opts) {
59
+ const status = opts.status ?? 'proposed';
60
+ for (const g of data.groups) {
61
+ const group = repo.createItem({
62
+ documentId,
63
+ kind: 'feature-group',
64
+ title: g.title,
65
+ meta: {},
66
+ status,
67
+ });
68
+ opts.onItem?.(group);
69
+ if (opts.withSuggestions !== false)
70
+ addSuggestion(documentId, group, 'PRD');
71
+ for (const f of g.features) {
72
+ const feat = repo.createItem({
73
+ documentId,
74
+ kind: 'feature',
75
+ parentId: group.id,
76
+ title: f.title,
77
+ body: f.body,
78
+ meta: { priority: f.priority, source: f.source, links: f.links },
79
+ status,
80
+ });
81
+ opts.onItem?.(feat);
82
+ if (opts.withSuggestions !== false)
83
+ addSuggestion(documentId, feat, f.source);
84
+ }
85
+ }
86
+ }
87
+ export function materializeIa(documentId, data, opts) {
88
+ const status = opts.status ?? 'proposed';
89
+ for (const p of data.pages) {
90
+ const page = repo.createItem({
91
+ documentId,
92
+ kind: 'page',
93
+ title: p.title,
94
+ meta: { page_type: p.page_type, section: p.section, source: p.source, links: p.links },
95
+ status,
96
+ });
97
+ opts.onItem?.(page);
98
+ if (opts.withSuggestions !== false)
99
+ addSuggestion(documentId, page, p.source ?? 'IA');
100
+ }
101
+ }
102
+ export function materializeFlow(documentId, data, opts) {
103
+ const status = opts.status ?? 'proposed';
104
+ for (const fl of data.flows) {
105
+ const flow = repo.createItem({
106
+ documentId,
107
+ kind: 'flow',
108
+ title: fl.title,
109
+ meta: { source: fl.source, links: fl.links },
110
+ status,
111
+ });
112
+ opts.onItem?.(flow);
113
+ if (opts.withSuggestions !== false)
114
+ addSuggestion(documentId, flow, fl.source);
115
+ for (const st of fl.steps) {
116
+ const step = repo.createItem({
117
+ documentId,
118
+ kind: 'step',
119
+ parentId: flow.id,
120
+ title: st.title,
121
+ meta: { page: st.page ?? null, branch: st.branch ?? null, note: st.note, node: st.node },
122
+ status,
123
+ });
124
+ opts.onItem?.(step);
125
+ // steps are part of a flow proposal — no separate card (keeps queue readable)
126
+ }
127
+ }
128
+ }
129
+ function addSuggestion(documentId, item, source) {
130
+ repo.createSuggestion({
131
+ documentId,
132
+ targetItemId: item.id,
133
+ kind: 'add',
134
+ title: `${item.ref_id} "${item.title}"`,
135
+ body: '인터뷰·상위 문서를 바탕으로 제안된 항목입니다. 수락하면 문서에 반영됩니다.',
136
+ source: source || item.kind,
137
+ });
138
+ }
139
+ // ── data acquisition (stub vs provider) ──────────────────────────────────────
140
+ async function acquireData(doc) {
141
+ if (config.aiStub || config.managedTier)
142
+ return fixtureFor(doc.type);
143
+ const cfg = getModelConfig(doc.type);
144
+ const provider = resolveProvider(cfg.provider);
145
+ const messages = buildItemsMessages(doc);
146
+ const run = async () => {
147
+ let text = '';
148
+ for await (const delta of provider.streamChat({
149
+ model: cfg.model,
150
+ maxTokens: cfg.maxTokens,
151
+ messages,
152
+ })) {
153
+ text += delta;
154
+ }
155
+ return text;
156
+ };
157
+ // lenient parse with one retry (§2)
158
+ const first = await run();
159
+ try {
160
+ return parseItemsJson(first);
161
+ }
162
+ catch {
163
+ const second = await run();
164
+ return parseItemsJson(second); // throws on second failure → surfaced as error
165
+ }
166
+ }
167
+ export function fixtureFor(type) {
168
+ if (type === 'feature-spec')
169
+ return SPEC_FIXTURE;
170
+ if (type === 'ia')
171
+ return IA_FIXTURE;
172
+ return FLOW_FIXTURE;
173
+ }
174
+ /**
175
+ * Generate items for a structure document, replacing any prior generated set.
176
+ * Streams an event per created item, then 'done'. Items land as 'proposed'.
177
+ */
178
+ export async function* streamItemsGeneration(documentId) {
179
+ const doc = repo.getDocument(documentId);
180
+ if (!doc) {
181
+ yield { type: 'error', message: 'document not found' };
182
+ return;
183
+ }
184
+ if (!isStructureType(doc.type)) {
185
+ yield { type: 'error', message: `document type ${doc.type} has no item generation` };
186
+ return;
187
+ }
188
+ repo.setDocumentStatus(documentId, 'streaming');
189
+ try {
190
+ const data = await acquireData({ id: doc.id, type: doc.type });
191
+ // fresh generation replaces prior generated items for this document
192
+ for (const it of repo.listItems(documentId))
193
+ repo.deleteItem(it.id);
194
+ const created = [];
195
+ const opts = { status: 'proposed', withSuggestions: true, onItem: (i) => created.push(i) };
196
+ if (doc.type === 'feature-spec')
197
+ materializeSpec(documentId, data, opts);
198
+ else if (doc.type === 'ia')
199
+ materializeIa(documentId, data, opts);
200
+ else
201
+ materializeFlow(documentId, data, opts);
202
+ for (const it of created)
203
+ yield { type: 'item', item: it };
204
+ repo.setDocumentStatus(documentId, 'ready');
205
+ yield { type: 'done', documentId, count: created.length };
206
+ }
207
+ catch (e) {
208
+ repo.setDocumentStatus(documentId, 'draft');
209
+ yield { type: 'error', message: e.message };
210
+ }
211
+ }
@@ -0,0 +1,118 @@
1
+ // Project-level lint orchestration over the DB (§4.2/§4.3). The pure rules live
2
+ // in lint.ts; this layer resolves items, turns violations into suggestions
3
+ // (proposal regression), honours waives, and computes the handoff gate set.
4
+ import * as repo from "../db/repos.js";
5
+ import { lintProject, violationKey } from "./lint.js";
6
+ function projectItemsAndReqs(projectId) {
7
+ const items = repo.listProjectItems(projectId);
8
+ const reqIds = repo.reqIdsForProject(projectId).map((r) => r.id);
9
+ return { items, reqIds };
10
+ }
11
+ /** All violations for a project. */
12
+ export function projectViolations(projectId) {
13
+ const { items, reqIds } = projectItemsAndReqs(projectId);
14
+ return lintProject(items, reqIds);
15
+ }
16
+ /** Waived violation keys = lint suggestions rejected by the user (§4.3). */
17
+ export function waivedKeys(projectId) {
18
+ const keys = new Set();
19
+ for (const doc of repo.listDocuments(projectId)) {
20
+ for (const s of repo.listLintSuggestions(doc.id, 'rejected')) {
21
+ if (s.quote_before)
22
+ keys.add(s.quote_before);
23
+ }
24
+ }
25
+ return keys;
26
+ }
27
+ /** Violations that still block the handoff gate (all minus waived). */
28
+ export function effectiveViolations(projectId) {
29
+ const waived = waivedKeys(projectId);
30
+ return projectViolations(projectId).filter((v) => !waived.has(violationKey(v)));
31
+ }
32
+ export function lintReport(projectId) {
33
+ const waived = waivedKeys(projectId);
34
+ const violations = projectViolations(projectId).map((v) => {
35
+ const key = violationKey(v);
36
+ return { ...v, key, waived: waived.has(key) };
37
+ });
38
+ const effectiveCount = violations.filter((v) => !v.waived).length;
39
+ return {
40
+ violations,
41
+ effectiveCount,
42
+ waivedCount: violations.length - effectiveCount,
43
+ gatePasses: effectiveCount === 0,
44
+ };
45
+ }
46
+ /** Which document should own a lint suggestion — the doc of its subject ref. */
47
+ function ownerDocId(projectId, refs) {
48
+ const items = repo.listProjectItems(projectId);
49
+ for (const ref of refs) {
50
+ const it = items.find((i) => i.ref_id === ref);
51
+ if (it)
52
+ return it.document_id;
53
+ }
54
+ // fall back to the feature-spec document
55
+ const spec = repo.listDocuments(projectId).find((d) => d.type === 'feature-spec');
56
+ return spec?.id ?? repo.listDocuments(projectId)[0]?.id ?? null;
57
+ }
58
+ // Code -> the two-choice card copy (초안이 voice, §4.2).
59
+ const FIX_COPY = {
60
+ 'E-BROKEN-REF': '끊긴 참조가 있어요. 대상 항목을 되살리거나 이 항목을 정리하세요.',
61
+ 'E-DUP-REF': '번호가 겹쳤어요. 항목을 정리하면 번호가 다시 정렬돼요.',
62
+ 'W-ORPHAN-SPEC': '이 기능이 어느 요구에도 연결되지 않았어요. 요구를 잇거나 기능을 정리하세요.',
63
+ 'W-UNREACHED-PAGE': '이 화면에 도달하는 플로우가 없어요. 플로우에 넣거나 화면을 정리하세요.',
64
+ 'W-EMPTY-PAGE': '이 화면에 연결된 기능이 없어요. 기능을 잇거나 화면을 정리하세요.',
65
+ 'W-NO-FLOW': '이 P0 기능이 어느 플로우에도 없어요. 플로우에 넣거나 우선순위를 낮추세요.',
66
+ };
67
+ /**
68
+ * Regression (§4.2): create a lint suggestion for each current violation that
69
+ * doesn't already have one (dedupe by code+refs → stored in quote_before). Skips
70
+ * waived ones (their rejected suggestion already exists). Returns created rows.
71
+ */
72
+ export function suggestLint(projectId) {
73
+ const existing = new Set();
74
+ for (const doc of repo.listDocuments(projectId)) {
75
+ for (const s of repo.listLintSuggestions(doc.id)) {
76
+ if (s.quote_before)
77
+ existing.add(s.quote_before);
78
+ }
79
+ }
80
+ let created = 0;
81
+ for (const v of projectViolations(projectId)) {
82
+ const key = violationKey(v);
83
+ if (existing.has(key))
84
+ continue; // dedupe (open or already-resolved)
85
+ const docId = ownerDocId(projectId, v.refs);
86
+ if (!docId)
87
+ continue;
88
+ repo.createSuggestion({
89
+ documentId: docId,
90
+ kind: 'lint',
91
+ title: `${v.refs[0]} · ${v.code}`,
92
+ body: FIX_COPY[v.code] ?? v.message,
93
+ quoteBefore: key, // dedupe / waive key
94
+ quoteAfter: v.message,
95
+ source: v.code,
96
+ });
97
+ existing.add(key);
98
+ created++;
99
+ }
100
+ return created;
101
+ }
102
+ /**
103
+ * Apply the default remediation when a lint suggestion is ACCEPTED (§4.2): the
104
+ * subject item (refs[0]) is rejected, which deterministically clears the
105
+ * violation on the next lint. REQ-only subjects have no item — nothing to do.
106
+ */
107
+ export function applyLintFix(projectId, subjectRef) {
108
+ const items = repo.listProjectItems(projectId);
109
+ const subject = items.find((i) => i.ref_id === subjectRef);
110
+ if (subject)
111
+ repo.setItemStatus(subject.id, 'rejected');
112
+ }
113
+ /** Same, resolved by violation key (code::sortedRefs) — used on suggestion accept. */
114
+ export function applyLintFixByKey(projectId, key) {
115
+ const v = projectViolations(projectId).find((x) => violationKey(x) === key);
116
+ if (v)
117
+ applyLintFix(projectId, v.refs[0]);
118
+ }