@shahmarasy/prodo 0.1.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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/bin/prodo.cjs +6 -0
  4. package/dist/agent-command-installer.d.ts +4 -0
  5. package/dist/agent-command-installer.js +158 -0
  6. package/dist/agents.d.ts +15 -0
  7. package/dist/agents.js +47 -0
  8. package/dist/artifact-registry.d.ts +11 -0
  9. package/dist/artifact-registry.js +49 -0
  10. package/dist/artifacts.d.ts +9 -0
  11. package/dist/artifacts.js +514 -0
  12. package/dist/cli.d.ts +9 -0
  13. package/dist/cli.js +305 -0
  14. package/dist/consistency.d.ts +8 -0
  15. package/dist/consistency.js +268 -0
  16. package/dist/constants.d.ts +7 -0
  17. package/dist/constants.js +64 -0
  18. package/dist/doctor.d.ts +1 -0
  19. package/dist/doctor.js +123 -0
  20. package/dist/errors.d.ts +3 -0
  21. package/dist/errors.js +10 -0
  22. package/dist/hook-executor.d.ts +1 -0
  23. package/dist/hook-executor.js +175 -0
  24. package/dist/init-tui.d.ts +21 -0
  25. package/dist/init-tui.js +161 -0
  26. package/dist/init.d.ts +10 -0
  27. package/dist/init.js +307 -0
  28. package/dist/markdown.d.ts +11 -0
  29. package/dist/markdown.js +66 -0
  30. package/dist/normalize.d.ts +7 -0
  31. package/dist/normalize.js +73 -0
  32. package/dist/normalized-brief.d.ts +39 -0
  33. package/dist/normalized-brief.js +170 -0
  34. package/dist/output-index.d.ts +13 -0
  35. package/dist/output-index.js +55 -0
  36. package/dist/paths.d.ts +16 -0
  37. package/dist/paths.js +76 -0
  38. package/dist/preset-loader.d.ts +4 -0
  39. package/dist/preset-loader.js +210 -0
  40. package/dist/project-config.d.ts +14 -0
  41. package/dist/project-config.js +69 -0
  42. package/dist/providers/index.d.ts +2 -0
  43. package/dist/providers/index.js +12 -0
  44. package/dist/providers/mock-provider.d.ts +7 -0
  45. package/dist/providers/mock-provider.js +168 -0
  46. package/dist/providers/openai-provider.d.ts +11 -0
  47. package/dist/providers/openai-provider.js +69 -0
  48. package/dist/registry.d.ts +13 -0
  49. package/dist/registry.js +115 -0
  50. package/dist/settings.d.ts +6 -0
  51. package/dist/settings.js +34 -0
  52. package/dist/template-resolver.d.ts +11 -0
  53. package/dist/template-resolver.js +28 -0
  54. package/dist/templates.d.ts +33 -0
  55. package/dist/templates.js +428 -0
  56. package/dist/types.d.ts +35 -0
  57. package/dist/types.js +5 -0
  58. package/dist/utils.d.ts +6 -0
  59. package/dist/utils.js +53 -0
  60. package/dist/validate.d.ts +9 -0
  61. package/dist/validate.js +226 -0
  62. package/dist/validator.d.ts +5 -0
  63. package/dist/validator.js +80 -0
  64. package/dist/version.d.ts +1 -0
  65. package/dist/version.js +30 -0
  66. package/dist/workflow-commands.d.ts +7 -0
  67. package/dist/workflow-commands.js +28 -0
  68. package/package.json +45 -0
  69. package/presets/fintech/preset.json +1 -0
  70. package/presets/fintech/prompts/prd.md +3 -0
  71. package/presets/marketplace/preset.json +1 -0
  72. package/presets/marketplace/prompts/prd.md +3 -0
  73. package/presets/saas/preset.json +1 -0
  74. package/presets/saas/prompts/prd.md +3 -0
  75. package/src/agent-command-installer.ts +174 -0
  76. package/src/agents.ts +56 -0
  77. package/src/artifact-registry.ts +69 -0
  78. package/src/artifacts.ts +606 -0
  79. package/src/cli.ts +322 -0
  80. package/src/consistency.ts +303 -0
  81. package/src/constants.ts +72 -0
  82. package/src/doctor.ts +137 -0
  83. package/src/errors.ts +7 -0
  84. package/src/hook-executor.ts +196 -0
  85. package/src/init-tui.ts +193 -0
  86. package/src/init.ts +375 -0
  87. package/src/markdown.ts +73 -0
  88. package/src/normalize.ts +89 -0
  89. package/src/normalized-brief.ts +206 -0
  90. package/src/output-index.ts +59 -0
  91. package/src/paths.ts +72 -0
  92. package/src/preset-loader.ts +237 -0
  93. package/src/project-config.ts +78 -0
  94. package/src/providers/index.ts +12 -0
  95. package/src/providers/mock-provider.ts +188 -0
  96. package/src/providers/openai-provider.ts +87 -0
  97. package/src/registry.ts +119 -0
  98. package/src/settings.ts +34 -0
  99. package/src/template-resolver.ts +33 -0
  100. package/src/templates.ts +440 -0
  101. package/src/types.ts +46 -0
  102. package/src/utils.ts +50 -0
  103. package/src/validate.ts +246 -0
  104. package/src/validator.ts +96 -0
  105. package/src/version.ts +24 -0
  106. package/src/workflow-commands.ts +31 -0
  107. package/templates/artifacts/prd.md +219 -0
  108. package/templates/artifacts/stories.md +49 -0
  109. package/templates/artifacts/techspec.md +42 -0
  110. package/templates/artifacts/wireframe.html +260 -0
  111. package/templates/artifacts/wireframe.md +22 -0
  112. package/templates/artifacts/workflow.md +22 -0
  113. package/templates/artifacts/workflow.mmd +6 -0
  114. package/templates/commands/prodo-normalize.md +24 -0
  115. package/templates/commands/prodo-prd.md +24 -0
  116. package/templates/commands/prodo-stories.md +24 -0
  117. package/templates/commands/prodo-techspec.md +24 -0
  118. package/templates/commands/prodo-validate.md +24 -0
  119. package/templates/commands/prodo-wireframe.md +24 -0
  120. package/templates/commands/prodo-workflow.md +24 -0
@@ -0,0 +1,606 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import { DEFAULT_STATUS, defaultRequiredHeadings } from "./constants";
5
+ import { getArtifactDefinition } from "./artifact-registry";
6
+ import { UserError } from "./errors";
7
+ import { contractIds, parseNormalizedBriefOrThrow, type NormalizedBrief } from "./normalized-brief";
8
+ import { getActiveArtifactPath, setActiveArtifact } from "./output-index";
9
+ import {
10
+ briefPath,
11
+ normalizedBriefPath,
12
+ outputContextDirPath,
13
+ outputDirPath,
14
+ promptPath,
15
+ prodoPath
16
+ } from "./paths";
17
+ import { createProvider } from "./providers";
18
+ import { extractRequiredHeadingsFromTemplate, resolveTemplate } from "./template-resolver";
19
+ import { readSettings } from "./settings";
20
+ import { sectionTextMap } from "./markdown";
21
+ import type { ArtifactDoc, ArtifactType, ContractCoverage } from "./types";
22
+ import { fileExists, isPathInside, listFilesSortedByMtime, readJsonFile, timestampSlug } from "./utils";
23
+ import { validateSchema } from "./validator";
24
+
25
+ export type GenerateOptions = {
26
+ artifactType: ArtifactType;
27
+ cwd: string;
28
+ normalizedBriefOverride?: string;
29
+ outPath?: string;
30
+ agent?: string;
31
+ };
32
+
33
+ function defaultFilename(type: ArtifactType): string {
34
+ if (type === "workflow") return `${type}-${timestampSlug()}.md`;
35
+ if (type === "wireframe") return `${type}-${timestampSlug()}.md`;
36
+ return `${type}-${timestampSlug()}.md`;
37
+ }
38
+
39
+ function sidecarPath(filePath: string): string {
40
+ const parsed = path.parse(filePath);
41
+ return path.join(parsed.dir, `${parsed.name}.artifact.json`);
42
+ }
43
+
44
+ async function writeSidecar(filePath: string, doc: ArtifactDoc): Promise<void> {
45
+ const payload = {
46
+ frontmatter: doc.frontmatter,
47
+ body: doc.body
48
+ };
49
+ await fs.writeFile(sidecarPath(filePath), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
50
+ }
51
+
52
+ async function loadArtifactDoc(filePath: string): Promise<ArtifactDoc> {
53
+ const sidecar = sidecarPath(filePath);
54
+ if (await fileExists(sidecar)) {
55
+ const loaded = await readJsonFile<Record<string, unknown>>(sidecar);
56
+ return {
57
+ frontmatter: (loaded.frontmatter as Record<string, unknown>) ?? {},
58
+ body: typeof loaded.body === "string" ? loaded.body : ""
59
+ };
60
+ }
61
+ const raw = await fs.readFile(filePath, "utf8");
62
+ const parsed = matter(raw);
63
+ return {
64
+ frontmatter: parsed.data as Record<string, unknown>,
65
+ body: parsed.content
66
+ };
67
+ }
68
+
69
+ function hasEnglishLeak(body: string): boolean {
70
+ const englishMarkers = [" the ", " and ", " with ", " user ", " should ", " must ", " requirement ", " flow "];
71
+ const normalized = ` ${body.toLowerCase().replace(/\s+/g, " ")} `;
72
+ return englishMarkers.filter((m) => normalized.includes(m)).length >= 2;
73
+ }
74
+
75
+ function enforceLanguage(body: string, lang: string, artifactType: ArtifactType): void {
76
+ const normalized = (lang || "en").toLowerCase();
77
+ if (!normalized.startsWith("tr")) return;
78
+ if (hasEnglishLeak(body)) {
79
+ throw new UserError(
80
+ `Language enforcement failed for ${artifactType}: output contains English fragments while language is Turkish.`
81
+ );
82
+ }
83
+ }
84
+
85
+ function toSlug(value: string): string {
86
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "screen";
87
+ }
88
+
89
+ function extractTurkishTitle(featureText: string): string {
90
+ const base = featureText.replace(/^\[[A-Z][0-9]+\]\s*/, "").trim();
91
+ if (!base) return "Ekran";
92
+ return base;
93
+ }
94
+
95
+ async function resolvePrompt(
96
+ cwd: string,
97
+ artifactType: ArtifactType,
98
+ templateContent: string,
99
+ requiredHeadings: string[],
100
+ agent?: string
101
+ ): Promise<string> {
102
+ const base = await fs.readFile(promptPath(cwd, artifactType), "utf8");
103
+ const authority = `Template authority (STRICT):
104
+ - Treat this template as the single output structure source.
105
+ - Keep heading order and names exactly as listed.
106
+ - Do not invent new primary sections.
107
+
108
+ Required headings (from template):
109
+ ${requiredHeadings.map((heading) => `- ${heading}`).join("\n")}
110
+
111
+ Resolved template:
112
+ \`\`\`md
113
+ ${templateContent.trim()}
114
+ \`\`\``;
115
+ const workflowPairing =
116
+ artifactType === "workflow"
117
+ ? `
118
+ Workflow paired output contract (STRICT):
119
+ - Output markdown explanation first (template headings).
120
+ - Then append a mermaid block for the same flow:
121
+ \`\`\`mermaid
122
+ flowchart TD
123
+ ...
124
+ \`\`\`
125
+ - Mermaid block is mandatory.`
126
+ : "";
127
+ const withTemplate = `${base}
128
+
129
+ ${authority}${workflowPairing}`;
130
+ if (!agent) return withTemplate;
131
+ return `${withTemplate}
132
+
133
+ Agent execution profile: ${agent}
134
+ - Keep output deterministic and actionable.`;
135
+ }
136
+
137
+ async function loadLatestArtifactPath(cwd: string, type: ArtifactType): Promise<string | undefined> {
138
+ const def = await getArtifactDefinition(cwd, type);
139
+ const active = await getActiveArtifactPath(cwd, type);
140
+ if (active) return active;
141
+ const files = await listFilesSortedByMtime(outputDirPath(cwd, type, def.output_dir));
142
+ return files[0];
143
+ }
144
+
145
+ function contextFilePath(cwd: string, artifactFile: string): string {
146
+ const base = path.parse(artifactFile).name;
147
+ return path.join(outputContextDirPath(cwd), `${base}.json`);
148
+ }
149
+
150
+ function toLineItems(value: string | undefined): string[] {
151
+ if (!value) return [];
152
+ return value
153
+ .split(/\r?\n/)
154
+ .map((line) => line.replace(/^\s*[-*0-9.]+\s*/, "").trim())
155
+ .filter((line) => line.length > 0);
156
+ }
157
+
158
+ function parseHeadingTitle(fullHeading: string): string {
159
+ return fullHeading.replace(/^##\s+/, "").trim();
160
+ }
161
+
162
+ function deriveStructuredContext(
163
+ artifactType: ArtifactType,
164
+ body: string,
165
+ requiredHeadings: string[]
166
+ ): Record<string, unknown> {
167
+ const sections = sectionTextMap(body);
168
+ const ordered = requiredHeadings
169
+ .map((heading) => ({ heading, items: toLineItems(sections.get(heading)) }))
170
+ .filter((item) => item.items.length > 0);
171
+ const section_map = Object.fromEntries(
172
+ Array.from(sections.entries()).map(([heading, text]) => [parseHeadingTitle(heading), toLineItems(text)])
173
+ );
174
+
175
+ if (artifactType === "workflow") {
176
+ return {
177
+ section_map,
178
+ actor_map: ordered[0]?.items ?? [],
179
+ step_map: ordered[1]?.items ?? [],
180
+ edge_case_map: ordered[2]?.items ?? []
181
+ };
182
+ }
183
+ if (artifactType === "wireframe") {
184
+ return {
185
+ section_map,
186
+ screen_map: ordered[0]?.items ?? [],
187
+ interaction_map: ordered[1]?.items ?? []
188
+ };
189
+ }
190
+ if (artifactType === "techspec") {
191
+ return {
192
+ section_map,
193
+ architecture_map: ordered[0]?.items ?? [],
194
+ integration_map: ordered[1]?.items ?? []
195
+ };
196
+ }
197
+ if (artifactType === "stories") {
198
+ return {
199
+ section_map,
200
+ story_map: ordered[0]?.items ?? [],
201
+ acceptance_map: ordered[1]?.items ?? []
202
+ };
203
+ }
204
+ return {
205
+ section_map,
206
+ goal_map: ordered[0]?.items ?? [],
207
+ requirement_map: ordered[1]?.items ?? []
208
+ };
209
+ }
210
+
211
+ async function buildUpstreamArtifacts(
212
+ cwd: string,
213
+ artifactType: ArtifactType,
214
+ upstreamTypes: ArtifactType[]
215
+ ): Promise<
216
+ Array<{
217
+ type: ArtifactType;
218
+ file: string;
219
+ contractCoverage: ContractCoverage;
220
+ structuredContext?: Record<string, unknown>;
221
+ }>
222
+ > {
223
+ const refs: Array<{
224
+ type: ArtifactType;
225
+ file: string;
226
+ contractCoverage: ContractCoverage;
227
+ structuredContext?: Record<string, unknown>;
228
+ }> = [];
229
+ for (const type of upstreamTypes) {
230
+ const latest = await loadLatestArtifactPath(cwd, type);
231
+ if (!latest) continue;
232
+ const parsed = await loadArtifactDoc(latest);
233
+ const frontmatter = parsed.frontmatter;
234
+ const coverageRaw = frontmatter.contract_coverage as Partial<ContractCoverage> | undefined;
235
+ const contextPath = contextFilePath(cwd, latest);
236
+ const structuredContext = (await fileExists(contextPath))
237
+ ? await readJsonFile<Record<string, unknown>>(contextPath)
238
+ : {};
239
+ refs.push({
240
+ type,
241
+ file: latest,
242
+ contractCoverage: {
243
+ goals: Array.isArray(coverageRaw?.goals)
244
+ ? coverageRaw.goals.filter((item): item is string => typeof item === "string")
245
+ : [],
246
+ core_features: Array.isArray(coverageRaw?.core_features)
247
+ ? coverageRaw.core_features.filter((item): item is string => typeof item === "string")
248
+ : [],
249
+ constraints: Array.isArray(coverageRaw?.constraints)
250
+ ? coverageRaw.constraints.filter((item): item is string => typeof item === "string")
251
+ : []
252
+ },
253
+ ...(Object.keys(structuredContext).length > 0 ? { structuredContext } : {})
254
+ });
255
+ }
256
+ return refs;
257
+ }
258
+
259
+ function extractCoverageFromBody(body: string): ContractCoverage {
260
+ const tagged = {
261
+ goals: Array.from(new Set(body.match(/\[(G[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? [])),
262
+ core_features: Array.from(new Set(body.match(/\[(F[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? [])),
263
+ constraints: Array.from(new Set(body.match(/\[(C[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? []))
264
+ };
265
+ return tagged;
266
+ }
267
+
268
+ function missingCoverage(
269
+ requiredContracts: Array<keyof ContractCoverage>,
270
+ normalized: NormalizedBrief,
271
+ coverage: ContractCoverage
272
+ ): Array<{ key: keyof ContractCoverage; ids: string[] }> {
273
+ const ids = contractIds(normalized.contracts);
274
+ const missing: Array<{ key: keyof ContractCoverage; ids: string[] }> = [];
275
+
276
+ for (const key of requiredContracts) {
277
+ const expected = ids[key];
278
+ if (expected.length === 0) continue;
279
+ const missingIds = expected.filter((id) => !coverage[key].includes(id));
280
+ if (missingIds.length > 0) {
281
+ missing.push({ key, ids: missingIds });
282
+ }
283
+ }
284
+ return missing;
285
+ }
286
+
287
+ async function ensurePipelinePrereqs(cwd: string, normalizedPath: string): Promise<void> {
288
+ const prodoRoot = prodoPath(cwd);
289
+ if (!(await fileExists(prodoRoot))) {
290
+ throw new UserError("Missing .prodo directory. Run `prodo-init` first.");
291
+ }
292
+
293
+ if (!(await fileExists(briefPath(cwd)))) {
294
+ throw new UserError(
295
+ "Missing brief at `brief.md`. Run `prodo-init` or create the file."
296
+ );
297
+ }
298
+
299
+ if (!(await fileExists(normalizedPath))) {
300
+ throw new UserError(
301
+ "Missing normalized brief at `.prodo/briefs/normalized-brief.json`. Create it before generating artifacts."
302
+ );
303
+ }
304
+ }
305
+
306
+ function splitWorkflowPair(raw: string): { markdown: string; mermaid: string } {
307
+ const match = raw.match(/```mermaid\s*([\s\S]*?)```/i);
308
+ if (!match) {
309
+ throw new UserError(
310
+ "Workflow output is missing a Mermaid block. Regenerate with template-compliant paired output."
311
+ );
312
+ }
313
+ const mermaid = match[1].trim();
314
+ const markdown = raw.replace(match[0], "").trim();
315
+ if (!markdown) {
316
+ throw new UserError("Workflow markdown explanation is empty.");
317
+ }
318
+ if (!/(^|\n)\s*(flowchart|graph)\s+/i.test(mermaid)) {
319
+ throw new UserError("Workflow Mermaid block is invalid.");
320
+ }
321
+ return { markdown, mermaid };
322
+ }
323
+
324
+ async function writeWireframeScreens(
325
+ targetDir: string,
326
+ baseName: string,
327
+ normalized: NormalizedBrief,
328
+ coverage: ContractCoverage,
329
+ lang: string,
330
+ headings: string[]
331
+ ): Promise<{ primaryPath: string; summaryBody: string }> {
332
+ const tr = lang.toLowerCase().startsWith("tr");
333
+ const screenContracts = normalized.contracts.core_features
334
+ .filter((item) => coverage.core_features.includes(item.id))
335
+ .slice(0, 6);
336
+ const screens = screenContracts.length > 0 ? screenContracts : normalized.contracts.core_features.slice(0, 3);
337
+ const summaryBodies: string[] = [];
338
+ let primaryMdPath = "";
339
+ for (const [index, screen] of screens.entries()) {
340
+ const title = extractTurkishTitle(screen.text);
341
+ const screenBase = `${baseName}-${index + 1}-${toSlug(title)}`;
342
+ const htmlPath = path.join(targetDir, `${screenBase}.html`);
343
+ const mdPath = path.join(targetDir, `${screenBase}.md`);
344
+ const html = `<!doctype html>
345
+ <html lang="${lang}">
346
+ <head>
347
+ <meta charset="utf-8" />
348
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
349
+ <title>${title}</title>
350
+ </head>
351
+ <body>
352
+ <!-- [${screen.id}] -->
353
+ <header>
354
+ <h1>${title}</h1>
355
+ <nav><button type="button">${tr ? "Geri" : "Back"}</button><button type="button">${tr ? "Devam" : "Next"}</button></nav>
356
+ </header>
357
+ <main>
358
+ <section>
359
+ <h2>${tr ? "Icerik" : "Content"}</h2>
360
+ <ul>
361
+ <li>${tr ? "Birincil bilgi alani" : "Primary information area"}</li>
362
+ <li>${tr ? "Durum gostergesi" : "Status indicator"}</li>
363
+ </ul>
364
+ </section>
365
+ <section>
366
+ <h2>${tr ? "Form" : "Form"}</h2>
367
+ <form>
368
+ <label>${tr ? "Alan" : "Field"}
369
+ <input type="text" name="field_${index + 1}" />
370
+ </label>
371
+ <button type="submit">${tr ? "Kaydet" : "Save"}</button>
372
+ </form>
373
+ </section>
374
+ </main>
375
+ </body>
376
+ </html>
377
+ `;
378
+ await fs.writeFile(htmlPath, html, "utf8");
379
+ const defaultMap = {
380
+ purpose: [`- [${screen.id}] ${screen.text}`],
381
+ actor: [`- ${(normalized.audience[0] ?? (tr ? "Birincil kullanici" : "Primary user"))}`],
382
+ sections: [
383
+ `- ${tr ? "Baslik ve gezinme" : "Header and navigation"}`,
384
+ `- ${tr ? "Icerik bolumu" : "Content section"}`,
385
+ `- ${tr ? "Form bolumu" : "Form section"}`
386
+ ],
387
+ fields: [`- ${tr ? "Metin alani (field_" : "Text input (field_"}${index + 1})`],
388
+ actions: [`- ${tr ? "Geri" : "Back"}`, `- ${tr ? "Devam" : "Next"}`, `- ${tr ? "Kaydet" : "Save"}`],
389
+ states: [`- ${tr ? "Bos durum, yukleniyor, hata, basari" : "Empty, loading, error, success states"}`],
390
+ notes: [`- ${tr ? "Dusuk sadakatli tel kafes taslaktir." : "Low-fidelity black-and-white wireframe mock."}`]
391
+ };
392
+ const fallbackQueue = [
393
+ ...defaultMap.purpose,
394
+ ...defaultMap.actor,
395
+ ...defaultMap.sections,
396
+ ...defaultMap.fields,
397
+ ...defaultMap.actions,
398
+ ...defaultMap.states,
399
+ ...defaultMap.notes
400
+ ];
401
+ const consumeFallback = () => (fallbackQueue.shift() ?? `- ${tr ? "Detay bekleniyor." : "Detail pending."}`);
402
+ const contentForHeading = (heading: string): string[] => {
403
+ const key = heading.toLowerCase();
404
+ if (/(screen purpose|purpose|amac|hedef)/.test(key)) return defaultMap.purpose;
405
+ if (/(primary actor|actor|user|kullanici|rol)/.test(key)) return defaultMap.actor;
406
+ if (/(main section|section|bolum|layout)/.test(key)) return defaultMap.sections;
407
+ if (/(field|input|form|alan)/.test(key)) return defaultMap.fields;
408
+ if (/(action|button|cta|aksiyon)/.test(key)) return defaultMap.actions;
409
+ if (/(state|message|durum|mesaj)/.test(key)) return defaultMap.states;
410
+ if (/(note|not|aciklama)/.test(key)) return defaultMap.notes;
411
+ return [consumeFallback()];
412
+ };
413
+ const targetHeadings = headings.length > 0 ? headings : defaultRequiredHeadings("wireframe");
414
+ const mdLines = [`# ${title}`, ""];
415
+ for (const heading of targetHeadings) {
416
+ mdLines.push(heading);
417
+ mdLines.push(...contentForHeading(heading));
418
+ mdLines.push("");
419
+ }
420
+ const mdBody = mdLines.join("\n").trim();
421
+ await fs.writeFile(mdPath, `${mdBody}\n`, "utf8");
422
+ if (!primaryMdPath) primaryMdPath = mdPath;
423
+ summaryBodies.push(mdBody);
424
+ }
425
+ return {
426
+ primaryPath: primaryMdPath,
427
+ summaryBody: summaryBodies.join("\n\n")
428
+ };
429
+ }
430
+
431
+ export async function generateArtifact(options: GenerateOptions): Promise<string> {
432
+ const { cwd, artifactType, outPath, agent } = options;
433
+ const def = await getArtifactDefinition(cwd, artifactType);
434
+ const normalizedPath = options.normalizedBriefOverride ?? normalizedBriefPath(cwd);
435
+ await ensurePipelinePrereqs(cwd, normalizedPath);
436
+
437
+ const settings = await readSettings(cwd);
438
+ const normalizedBriefRaw = await readJsonFile<Record<string, unknown>>(normalizedPath);
439
+ const normalizedBrief = parseNormalizedBriefOrThrow(normalizedBriefRaw);
440
+ const template = await resolveTemplate({ cwd, artifactType });
441
+ if (!template || template.content.trim().length === 0) {
442
+ throw new UserError(
443
+ `Missing ${artifactType} template. Create \`.prodo/templates/${artifactType}.md\` before running \`prodo-${artifactType}\`.`
444
+ );
445
+ }
446
+ const templateHeadings =
447
+ template && template.content.trim().length > 0 ? extractRequiredHeadingsFromTemplate(template.content) : [];
448
+ if (templateHeadings.length === 0) {
449
+ throw new UserError(
450
+ `${artifactType} template has no extractable headings. Add markdown headings to \`${template.path}\`.`
451
+ );
452
+ }
453
+ const computedHeadings = templateHeadings.length > 0
454
+ ? templateHeadings
455
+ : (def.required_headings.length > 0 ? def.required_headings : defaultRequiredHeadings(artifactType));
456
+ const prompt = await resolvePrompt(cwd, artifactType, template?.content ?? "", computedHeadings, agent);
457
+ const provider = createProvider();
458
+ const upstreamArtifacts = await buildUpstreamArtifacts(cwd, artifactType, def.upstream);
459
+ const schemaHint = {
460
+ artifactType,
461
+ requiredHeadings: computedHeadings,
462
+ requiredContracts: def.required_contracts
463
+ };
464
+
465
+ const generated = await provider.generate(
466
+ prompt,
467
+ {
468
+ normalizedBrief,
469
+ upstreamArtifacts,
470
+ contractCatalog: normalizedBrief.contracts,
471
+ templateContent: template?.content ?? "",
472
+ templatePath: template?.path ?? "",
473
+ outputLanguage: settings.lang
474
+ },
475
+ schemaHint
476
+ );
477
+
478
+ let generatedBody = generated.body.trim();
479
+ let workflowMermaidBody: string | null = null;
480
+ if (artifactType === "workflow") {
481
+ const paired = splitWorkflowPair(generatedBody);
482
+ generatedBody = paired.markdown;
483
+ workflowMermaidBody = paired.mermaid;
484
+ }
485
+ let contractCoverage = extractCoverageFromBody(generatedBody);
486
+
487
+ if (artifactType === "workflow") {
488
+ if (contractCoverage.core_features.length === 0) {
489
+ contractCoverage = {
490
+ ...contractCoverage,
491
+ core_features: normalizedBrief.contracts.core_features.map((item) => item.id)
492
+ };
493
+ }
494
+ }
495
+
496
+ if (artifactType === "wireframe") {
497
+ if (contractCoverage.core_features.length === 0) {
498
+ contractCoverage = {
499
+ ...contractCoverage,
500
+ core_features: normalizedBrief.contracts.core_features.map((item) => item.id)
501
+ };
502
+ }
503
+ }
504
+
505
+ enforceLanguage(generatedBody, settings.lang, artifactType);
506
+ const uncovered = missingCoverage(def.required_contracts, normalizedBrief, contractCoverage);
507
+ if (uncovered.length > 0) {
508
+ const lines = uncovered
509
+ .map((item) => `- ${item.key}: missing ${item.ids.join(", ")}`)
510
+ .join("\n");
511
+ throw new UserError(
512
+ `Artifact is missing required contract references. Add ID tags to body:\n${lines}\nExample tags: [G1], [F2], [C1].`
513
+ );
514
+ }
515
+
516
+ const frontmatter = {
517
+ artifact_type: artifactType,
518
+ version: timestampSlug(),
519
+ source_brief: path.resolve(normalizedPath),
520
+ generated_at: new Date().toISOString(),
521
+ status: DEFAULT_STATUS,
522
+ upstream_artifacts: upstreamArtifacts.map((item) => item.file),
523
+ contract_coverage: contractCoverage,
524
+ language: settings.lang
525
+ } as Record<string, unknown>;
526
+
527
+ const mergedFrontmatter = { ...frontmatter, ...(generated.frontmatter ?? {}) };
528
+ let doc: ArtifactDoc = {
529
+ frontmatter: mergedFrontmatter,
530
+ body: generatedBody
531
+ };
532
+
533
+ const validation = await validateSchema(cwd, artifactType, doc, schemaHint.requiredHeadings);
534
+ const schemaErrors = validation.issues.filter((issue) => issue.level === "error");
535
+ if (schemaErrors.length > 0) {
536
+ const details = schemaErrors.map((issue) => `- ${issue.message}`).join("\n");
537
+ throw new UserError(`Artifact failed schema checks:\n${details}`);
538
+ }
539
+
540
+ const targetDir = outputDirPath(cwd, artifactType, def.output_dir);
541
+ const finalPath = outPath ? path.resolve(cwd, outPath) : path.join(targetDir, defaultFilename(artifactType));
542
+ if (!isPathInside(path.join(cwd, "product-docs"), finalPath)) {
543
+ throw new UserError("Artifact output must be inside `product-docs/`.");
544
+ }
545
+ await fs.mkdir(path.dirname(finalPath), { recursive: true });
546
+ if (artifactType === "workflow") {
547
+ const basePath = path.join(path.dirname(finalPath), path.parse(finalPath).name);
548
+ const mdPath = `${basePath}.md`;
549
+ const mmdPath = `${basePath}.mmd`;
550
+ await fs.writeFile(mdPath, matter.stringify(doc.body, doc.frontmatter), "utf8");
551
+ await fs.writeFile(mmdPath, `${(workflowMermaidBody ?? "").trim()}\n`, "utf8");
552
+ await writeSidecar(mdPath, doc);
553
+ const derivedContext = {
554
+ artifact_type: artifactType,
555
+ artifact_file: mdPath,
556
+ generated_at: new Date().toISOString(),
557
+ contract_coverage: contractCoverage,
558
+ ...deriveStructuredContext(artifactType, doc.body, schemaHint.requiredHeadings)
559
+ };
560
+ await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
561
+ await fs.writeFile(contextFilePath(cwd, mdPath), `${JSON.stringify(derivedContext, null, 2)}\n`, "utf8");
562
+ await setActiveArtifact(cwd, artifactType, mdPath);
563
+ return mdPath;
564
+ } else if (artifactType === "wireframe") {
565
+ const base = path.parse(finalPath).name;
566
+ const wireframe = await writeWireframeScreens(
567
+ path.dirname(finalPath),
568
+ base,
569
+ normalizedBrief,
570
+ contractCoverage,
571
+ settings.lang,
572
+ schemaHint.requiredHeadings
573
+ );
574
+ doc = {
575
+ frontmatter: doc.frontmatter,
576
+ body: wireframe.summaryBody
577
+ };
578
+ await writeSidecar(wireframe.primaryPath, doc);
579
+ const derivedContext = {
580
+ artifact_type: artifactType,
581
+ artifact_file: wireframe.primaryPath,
582
+ generated_at: new Date().toISOString(),
583
+ contract_coverage: contractCoverage,
584
+ ...deriveStructuredContext(artifactType, doc.body, schemaHint.requiredHeadings)
585
+ };
586
+ await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
587
+ await fs.writeFile(contextFilePath(cwd, wireframe.primaryPath), `${JSON.stringify(derivedContext, null, 2)}\n`, "utf8");
588
+ await setActiveArtifact(cwd, artifactType, wireframe.primaryPath);
589
+ return wireframe.primaryPath;
590
+ } else {
591
+ const content = matter.stringify(doc.body, doc.frontmatter);
592
+ await fs.writeFile(finalPath, content, "utf8");
593
+ }
594
+ await writeSidecar(finalPath, doc);
595
+ const derivedContext = {
596
+ artifact_type: artifactType,
597
+ artifact_file: finalPath,
598
+ generated_at: new Date().toISOString(),
599
+ contract_coverage: contractCoverage,
600
+ ...deriveStructuredContext(artifactType, doc.body, schemaHint.requiredHeadings)
601
+ };
602
+ await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
603
+ await fs.writeFile(contextFilePath(cwd, finalPath), `${JSON.stringify(derivedContext, null, 2)}\n`, "utf8");
604
+ await setActiveArtifact(cwd, artifactType, finalPath);
605
+ return finalPath;
606
+ }