@shahmarasy/prodo 0.1.4 → 0.1.5

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 (173) hide show
  1. package/README.md +201 -97
  2. package/bin/prodo.cjs +6 -6
  3. package/dist/agents/agent-registry.d.ts +13 -0
  4. package/dist/agents/agent-registry.js +79 -0
  5. package/dist/agents/anthropic/index.d.ts +9 -0
  6. package/dist/agents/anthropic/index.js +55 -0
  7. package/dist/agents/base.d.ts +25 -0
  8. package/dist/agents/base.js +71 -0
  9. package/dist/agents/google/index.d.ts +9 -0
  10. package/dist/agents/google/index.js +53 -0
  11. package/dist/agents/mock/index.d.ts +11 -0
  12. package/dist/agents/mock/index.js +26 -0
  13. package/dist/agents/openai/index.d.ts +9 -0
  14. package/dist/agents/openai/index.js +57 -0
  15. package/dist/agents/system-prompts.d.ts +3 -0
  16. package/dist/agents/system-prompts.js +32 -0
  17. package/dist/cli/agent-command-installer.d.ts +4 -0
  18. package/dist/cli/agent-command-installer.js +148 -0
  19. package/dist/cli/agent-ids.d.ts +15 -0
  20. package/dist/cli/agent-ids.js +49 -0
  21. package/dist/cli/doctor.d.ts +1 -0
  22. package/dist/cli/doctor.js +144 -0
  23. package/dist/cli/fix-tui.d.ts +4 -0
  24. package/dist/cli/fix-tui.js +79 -0
  25. package/dist/cli/index.d.ts +9 -0
  26. package/dist/cli/index.js +465 -0
  27. package/dist/cli/init-tui.d.ts +23 -0
  28. package/dist/cli/init-tui.js +176 -0
  29. package/dist/cli/init.d.ts +11 -0
  30. package/dist/cli/init.js +334 -0
  31. package/dist/cli/normalize-interactive.d.ts +8 -0
  32. package/dist/cli/normalize-interactive.js +167 -0
  33. package/dist/cli/preset-loader.d.ts +4 -0
  34. package/dist/cli/preset-loader.js +210 -0
  35. package/dist/core/artifact-registry.d.ts +11 -0
  36. package/dist/core/artifact-registry.js +49 -0
  37. package/dist/core/artifacts.d.ts +10 -0
  38. package/dist/core/artifacts.js +892 -0
  39. package/dist/core/clean.d.ts +10 -0
  40. package/dist/core/clean.js +74 -0
  41. package/dist/core/consistency.d.ts +8 -0
  42. package/dist/core/consistency.js +328 -0
  43. package/dist/core/constants.d.ts +7 -0
  44. package/dist/core/constants.js +64 -0
  45. package/dist/core/errors.d.ts +3 -0
  46. package/dist/core/errors.js +10 -0
  47. package/dist/core/fix.d.ts +31 -0
  48. package/dist/core/fix.js +188 -0
  49. package/dist/core/hook-executor.d.ts +1 -0
  50. package/dist/core/hook-executor.js +175 -0
  51. package/dist/core/markdown.d.ts +16 -0
  52. package/dist/core/markdown.js +81 -0
  53. package/dist/core/normalize.d.ts +8 -0
  54. package/dist/core/normalize.js +125 -0
  55. package/dist/core/normalized-brief.d.ts +48 -0
  56. package/dist/core/normalized-brief.js +182 -0
  57. package/dist/core/output-index.d.ts +13 -0
  58. package/dist/core/output-index.js +55 -0
  59. package/dist/core/paths.d.ts +17 -0
  60. package/dist/core/paths.js +80 -0
  61. package/dist/core/project-config.d.ts +14 -0
  62. package/dist/core/project-config.js +69 -0
  63. package/dist/core/registry.d.ts +13 -0
  64. package/dist/core/registry.js +115 -0
  65. package/dist/core/settings.d.ts +7 -0
  66. package/dist/core/settings.js +35 -0
  67. package/dist/core/template-engine.d.ts +3 -0
  68. package/dist/core/template-engine.js +43 -0
  69. package/dist/core/template-resolver.d.ts +15 -0
  70. package/dist/core/template-resolver.js +46 -0
  71. package/dist/core/templates.d.ts +33 -0
  72. package/dist/core/templates.js +440 -0
  73. package/dist/core/terminology.d.ts +21 -0
  74. package/dist/core/terminology.js +143 -0
  75. package/dist/core/tracing.d.ts +21 -0
  76. package/dist/core/tracing.js +74 -0
  77. package/dist/core/types.d.ts +35 -0
  78. package/dist/core/types.js +5 -0
  79. package/dist/core/utils.d.ts +7 -0
  80. package/dist/core/utils.js +66 -0
  81. package/dist/core/validate.d.ts +10 -0
  82. package/dist/core/validate.js +226 -0
  83. package/dist/core/validator.d.ts +5 -0
  84. package/dist/core/validator.js +76 -0
  85. package/dist/core/version.d.ts +1 -0
  86. package/dist/core/version.js +30 -0
  87. package/dist/core/workflow-commands.d.ts +7 -0
  88. package/dist/core/workflow-commands.js +29 -0
  89. package/dist/i18n/en.json +45 -0
  90. package/dist/i18n/index.d.ts +5 -0
  91. package/dist/i18n/index.js +63 -0
  92. package/dist/i18n/tr.json +45 -0
  93. package/dist/providers/index.d.ts +2 -1
  94. package/dist/providers/index.js +20 -6
  95. package/dist/providers/mock-provider.d.ts +1 -1
  96. package/dist/providers/mock-provider.js +7 -6
  97. package/dist/providers/openai-provider.d.ts +1 -1
  98. package/dist/providers/openai-provider.js +1 -1
  99. package/dist/skills/engine.d.ts +10 -0
  100. package/dist/skills/engine.js +75 -0
  101. package/dist/skills/fix-skill.d.ts +2 -0
  102. package/dist/skills/fix-skill.js +38 -0
  103. package/dist/skills/generate-artifact-skill.d.ts +2 -0
  104. package/dist/skills/generate-artifact-skill.js +32 -0
  105. package/dist/skills/generate-pipeline-skill.d.ts +2 -0
  106. package/dist/skills/generate-pipeline-skill.js +45 -0
  107. package/dist/skills/normalize-skill.d.ts +2 -0
  108. package/dist/skills/normalize-skill.js +29 -0
  109. package/dist/skills/types.d.ts +28 -0
  110. package/dist/skills/types.js +2 -0
  111. package/dist/skills/validate-skill.d.ts +2 -0
  112. package/dist/skills/validate-skill.js +29 -0
  113. package/package.json +74 -45
  114. package/src/agents/agent-registry.ts +93 -0
  115. package/src/agents/anthropic/index.ts +86 -0
  116. package/src/agents/anthropic/manifest.json +7 -0
  117. package/src/agents/base.ts +77 -0
  118. package/src/agents/google/index.ts +79 -0
  119. package/src/agents/google/manifest.json +7 -0
  120. package/src/agents/mock/index.ts +32 -0
  121. package/src/agents/mock/manifest.json +7 -0
  122. package/src/agents/openai/index.ts +83 -0
  123. package/src/agents/openai/manifest.json +7 -0
  124. package/src/agents/system-prompts.ts +35 -0
  125. package/src/{agent-command-installer.ts → cli/agent-command-installer.ts} +164 -164
  126. package/src/{agents.ts → cli/agent-ids.ts} +58 -58
  127. package/src/{doctor.ts → cli/doctor.ts} +157 -137
  128. package/src/cli/fix-tui.ts +111 -0
  129. package/src/{cli.ts → cli/index.ts} +459 -410
  130. package/src/{init-tui.ts → cli/init-tui.ts} +208 -208
  131. package/src/{init.ts → cli/init.ts} +398 -398
  132. package/src/cli/normalize-interactive.ts +241 -0
  133. package/src/{preset-loader.ts → cli/preset-loader.ts} +237 -237
  134. package/src/{artifact-registry.ts → core/artifact-registry.ts} +69 -69
  135. package/src/{artifacts.ts → core/artifacts.ts} +1081 -1072
  136. package/src/core/clean.ts +88 -0
  137. package/src/{consistency.ts → core/consistency.ts} +374 -303
  138. package/src/{constants.ts → core/constants.ts} +72 -72
  139. package/src/{errors.ts → core/errors.ts} +7 -7
  140. package/src/core/fix.ts +253 -0
  141. package/src/{hook-executor.ts → core/hook-executor.ts} +196 -196
  142. package/src/{markdown.ts → core/markdown.ts} +93 -73
  143. package/src/{normalize.ts → core/normalize.ts} +145 -137
  144. package/src/{normalized-brief.ts → core/normalized-brief.ts} +227 -206
  145. package/src/{output-index.ts → core/output-index.ts} +59 -59
  146. package/src/{paths.ts → core/paths.ts} +75 -71
  147. package/src/{project-config.ts → core/project-config.ts} +78 -78
  148. package/src/{registry.ts → core/registry.ts} +119 -119
  149. package/src/{settings.ts → core/settings.ts} +35 -35
  150. package/src/core/template-engine.ts +45 -0
  151. package/src/{template-resolver.ts → core/template-resolver.ts} +54 -54
  152. package/src/{templates.ts → core/templates.ts} +452 -452
  153. package/src/core/terminology.ts +177 -0
  154. package/src/core/tracing.ts +110 -0
  155. package/src/{types.ts → core/types.ts} +46 -46
  156. package/src/{utils.ts → core/utils.ts} +64 -64
  157. package/src/{validate.ts → core/validate.ts} +252 -246
  158. package/src/{validator.ts → core/validator.ts} +92 -92
  159. package/src/{version.ts → core/version.ts} +24 -24
  160. package/src/{workflow-commands.ts → core/workflow-commands.ts} +32 -32
  161. package/src/i18n/en.json +45 -0
  162. package/src/i18n/index.ts +58 -0
  163. package/src/i18n/tr.json +45 -0
  164. package/src/providers/index.ts +29 -12
  165. package/src/providers/mock-provider.ts +200 -199
  166. package/src/providers/openai-provider.ts +88 -88
  167. package/src/skills/engine.ts +94 -0
  168. package/src/skills/fix-skill.ts +38 -0
  169. package/src/skills/generate-artifact-skill.ts +32 -0
  170. package/src/skills/generate-pipeline-skill.ts +49 -0
  171. package/src/skills/normalize-skill.ts +29 -0
  172. package/src/skills/types.ts +36 -0
  173. package/src/skills/validate-skill.ts +29 -0
@@ -1,1072 +1,1081 @@
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, resolveCompanionTemplate, resolveTemplate } from "./template-resolver";
19
- import { readSettings } from "./settings";
20
- import { sectionTextMap } from "./markdown";
21
- import type { ArtifactDoc, ArtifactType, ContractCoverage } from "./types";
22
- import { artifactFileStamp, 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
- revisionType?: "default" | "fix";
32
- };
33
-
34
- function defaultFilename(type: ArtifactType): string {
35
- return `${type}-${artifactFileStamp()}.md`;
36
- }
37
-
38
- function sidecarPath(filePath: string): string {
39
- const parsed = path.parse(filePath);
40
- return path.join(parsed.dir, `${parsed.name}.artifact.json`);
41
- }
42
-
43
- async function writeSidecar(filePath: string, doc: ArtifactDoc): Promise<void> {
44
- const payload = {
45
- frontmatter: doc.frontmatter,
46
- body: doc.body
47
- };
48
- await fs.writeFile(sidecarPath(filePath), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
49
- }
50
-
51
- async function loadArtifactDoc(filePath: string): Promise<ArtifactDoc> {
52
- const sidecar = sidecarPath(filePath);
53
- if (await fileExists(sidecar)) {
54
- const loaded = await readJsonFile<Record<string, unknown>>(sidecar);
55
- return {
56
- frontmatter: (loaded.frontmatter as Record<string, unknown>) ?? {},
57
- body: typeof loaded.body === "string" ? loaded.body : ""
58
- };
59
- }
60
- const raw = await fs.readFile(filePath, "utf8");
61
- const parsed = matter(raw);
62
- return {
63
- frontmatter: parsed.data as Record<string, unknown>,
64
- body: parsed.content
65
- };
66
- }
67
-
68
- function languageProbe(body: string): string {
69
- const stripped = body
70
- .replace(/```[\s\S]*?```/g, " ")
71
- .replace(/^\s*#{1,6}\s+.*$/gm, " ")
72
- .replace(/<[^>]+>/g, " ")
73
- .replace(/\|/g, " ")
74
- .replace(/\s+/g, " ")
75
- .trim()
76
- .toLowerCase();
77
- return ` ${stripped} `;
78
- }
79
-
80
- function hasEnglishLeak(body: string): boolean {
81
- const englishMarkers = [" the ", " and ", " with ", " user ", " should ", " must ", " requirement ", " flow ", " error ", " success "];
82
- const normalized = languageProbe(body);
83
- return englishMarkers.filter((m) => normalized.includes(m)).length >= 2;
84
- }
85
-
86
- function hasTurkishLeak(body: string): boolean {
87
- const turkishMarkers = [
88
- " ve ",
89
- " ile ",
90
- " kullanici ",
91
- " kullanıcı ",
92
- " akis ",
93
- " akış ",
94
- " hata ",
95
- " basari ",
96
- " başarı ",
97
- " ekran ",
98
- " islem ",
99
- " işlem ",
100
- " gerekli "
101
- ];
102
- const normalized = languageProbe(body);
103
- return turkishMarkers.filter((m) => normalized.includes(m)).length >= 2;
104
- }
105
-
106
- function enforceLanguage(body: string, lang: string, artifactType: ArtifactType): void {
107
- const normalized = (lang || "en").toLowerCase();
108
- if (normalized.startsWith("tr")) {
109
- if (!hasEnglishLeak(body)) return;
110
- throw new UserError(
111
- `Language enforcement failed for ${artifactType}: output contains English fragments while language is Turkish.`
112
- );
113
- }
114
- if (normalized.startsWith("en")) {
115
- if (!hasTurkishLeak(body)) return;
116
- throw new UserError(
117
- `Language enforcement failed for ${artifactType}: output contains Turkish fragments while language is English.`
118
- );
119
- }
120
- }
121
-
122
- function toSlug(value: string): string {
123
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "screen";
124
- }
125
-
126
- function extractTurkishTitle(featureText: string): string {
127
- const base = featureText.replace(/^\[[A-Z][0-9]+\]\s*/, "").trim();
128
- if (!base) return "Ekran";
129
- return base;
130
- }
131
-
132
- function replaceTemplateTokens(
133
- template: string,
134
- replacements: Record<string, string>,
135
- fallbackFromToken: (token: string) => string
136
- ): string {
137
- let out = template;
138
- for (const [key, value] of Object.entries(replacements)) {
139
- out = out.replace(new RegExp(`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`, "g"), value);
140
- }
141
- return out.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_match, tokenRaw: string) => {
142
- const token = String(tokenRaw).trim();
143
- return fallbackFromToken(token);
144
- });
145
- }
146
-
147
- function renderWorkflowMermaidTemplate(
148
- templateContent: string,
149
- normalized: NormalizedBrief,
150
- coverage: ContractCoverage,
151
- lang: string
152
- ): string {
153
- const tr = lang.toLowerCase().startsWith("tr");
154
- const primaryFeatureId = coverage.core_features[0] ?? normalized.contracts.core_features[0]?.id ?? "F1";
155
- const primaryFeatureText =
156
- normalized.contracts.core_features.find((item) => item.id === primaryFeatureId)?.text ??
157
- normalized.contracts.core_features[0]?.text ??
158
- (tr ? "Kullanici islemi" : "User action");
159
-
160
- return replaceTemplateTokens(
161
- templateContent,
162
- {
163
- "Flow Name": tr ? "Ana Akis" : "Main Flow",
164
- "Primary Actor": normalized.audience[0] ?? (tr ? "Kullanici" : "User"),
165
- "Primary Action": `[${primaryFeatureId}] ${primaryFeatureText}`,
166
- "Success State": tr ? "Basari" : "Success",
167
- "Error State": tr ? "Hata" : "Error"
168
- },
169
- (token) => {
170
- const key = token.toLowerCase();
171
- if (key.includes("actor") || key.includes("user")) return normalized.audience[0] ?? (tr ? "Kullanici" : "User");
172
- if (key.includes("action") || key.includes("feature")) return `[${primaryFeatureId}] ${primaryFeatureText}`;
173
- if (key.includes("success")) return tr ? "Basari" : "Success";
174
- if (key.includes("error") || key.includes("fail")) return tr ? "Hata" : "Error";
175
- if (key.includes("flow")) return tr ? "Ana Akis" : "Main Flow";
176
- return token;
177
- }
178
- );
179
- }
180
-
181
- function normalizeAuthor(author: string | undefined): string | undefined {
182
- if (!author) return undefined;
183
- const normalized = author.trim();
184
- return normalized.length > 0 ? normalized : undefined;
185
- }
186
-
187
- function replaceAuthorPlaceholders(body: string, author: string | undefined): string {
188
- const safeAuthor = normalizeAuthor(author);
189
- if (!safeAuthor) return body;
190
- return body.replace(/\{\{\s*author\s*\}\}/gi, safeAuthor);
191
- }
192
-
193
- function todayYmd(): string {
194
- const now = new Date();
195
- const y = now.getFullYear();
196
- const m = String(now.getMonth() + 1).padStart(2, "0");
197
- const d = String(now.getDate()).padStart(2, "0");
198
- return `${y}-${m}-${d}`;
199
- }
200
-
201
- function headingKey(value: string): string {
202
- return value
203
- .toLowerCase()
204
- .replace(/[^a-z0-9]+/g, " ")
205
- .trim();
206
- }
207
-
208
- function defaultDocumentControlValues(
209
- lang: string,
210
- revisionType: "default" | "fix",
211
- version: string,
212
- author?: string
213
- ): { version: string; date: string; author: string; description: string } {
214
- const tr = lang.toLowerCase().startsWith("tr");
215
- const safeAuthor = normalizeAuthor(author) ?? (tr ? "Prodo" : "Prodo");
216
- const description = revisionType === "fix"
217
- ? (tr ? "Dogrulama sonrasi duzeltme revizyonu" : "Post-validation fix revision")
218
- : (tr ? "Ilk surum" : "Initial version");
219
- return {
220
- version,
221
- date: todayYmd(),
222
- author: safeAuthor,
223
- description
224
- };
225
- }
226
-
227
- function applyDocumentControlDefaults(
228
- body: string,
229
- options: { lang: string; revisionType: "default" | "fix"; version: string; author?: string }
230
- ): string {
231
- const defaults = defaultDocumentControlValues(options.lang, options.revisionType, options.version, options.author);
232
- let out = body
233
- .replace(/\{\{\s*date\s*\}\}/gi, defaults.date)
234
- .replace(/\{\{\s*description\s*\}\}/gi, defaults.description)
235
- .replace(/\{\{\s*version\s*\}\}/gi, defaults.version);
236
-
237
- const lines = out.split(/\r?\n/);
238
- const headingIndex = lines.findIndex((line) => {
239
- const match = line.match(/^\s*##+\s+(.+?)\s*$/);
240
- if (!match) return false;
241
- const key = headingKey(match[1]);
242
- return key.includes("document control") || key.includes("belge kontrol");
243
- });
244
- if (headingIndex === -1) return out;
245
-
246
- const row = `| ${defaults.version} | ${defaults.date} | ${defaults.author} | ${defaults.description} |`;
247
- let tableSeparatorIndex = -1;
248
- let tableDataIndex = -1;
249
-
250
- for (let i = headingIndex + 1; i < lines.length; i += 1) {
251
- if (/^\s*##+\s+/.test(lines[i])) break;
252
- if (tableSeparatorIndex === -1 && /\|/.test(lines[i]) && /-/.test(lines[i])) {
253
- tableSeparatorIndex = i;
254
- continue;
255
- }
256
- if (tableSeparatorIndex !== -1 && /^\s*\|/.test(lines[i])) {
257
- tableDataIndex = i;
258
- break;
259
- }
260
- }
261
-
262
- if (tableDataIndex !== -1) {
263
- lines[tableDataIndex] = row;
264
- } else if (tableSeparatorIndex !== -1) {
265
- lines.splice(tableSeparatorIndex + 1, 0, row);
266
- } else {
267
- lines.splice(headingIndex + 1, 0, "", "| Version | Date | Author | Description |", "|--------|------|--------|-------------|", row, "");
268
- }
269
-
270
- out = lines.join("\n");
271
- return out;
272
- }
273
-
274
- function parseVersionToken(input: string): { major: number; minor: number } | null {
275
- const match = input.match(/v?\s*(\d+)(?:\.(\d+))?/i);
276
- if (!match) return null;
277
- const major = Number(match[1]);
278
- const minor = Number(match[2] ?? "0");
279
- if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
280
- return { major, minor };
281
- }
282
-
283
- function extractDocumentControlVersion(body: string): string | undefined {
284
- const tableMatch = body.match(/\|\s*(v?\d+(?:\.\d+)?)\s*\|/i);
285
- if (tableMatch?.[1]) return tableMatch[1].trim().startsWith("v") ? tableMatch[1].trim() : `v${tableMatch[1].trim()}`;
286
- const looseMatch = body.match(/\bv?\d+\.\d+\b/i);
287
- if (looseMatch?.[0]) return looseMatch[0].startsWith("v") ? looseMatch[0] : `v${looseMatch[0]}`;
288
- return undefined;
289
- }
290
-
291
- async function resolveDocumentControlVersion(
292
- cwd: string,
293
- artifactType: ArtifactType,
294
- revisionType: "default" | "fix"
295
- ): Promise<string> {
296
- if (revisionType !== "fix") return "v1.0";
297
-
298
- const activePath = await getActiveArtifactPath(cwd, artifactType);
299
- const fallbackPath = activePath ?? (await loadLatestArtifactPath(cwd, artifactType));
300
- if (!fallbackPath || !(await fileExists(fallbackPath))) {
301
- return "v1.1";
302
- }
303
-
304
- try {
305
- const previous = await loadArtifactDoc(fallbackPath);
306
- const previousVersion = extractDocumentControlVersion(previous.body) ?? String(previous.frontmatter.version ?? "");
307
- const parsed = parseVersionToken(previousVersion);
308
- if (!parsed) return "v1.1";
309
- return `v${parsed.major}.${parsed.minor + 1}`;
310
- } catch {
311
- return "v1.1";
312
- }
313
- }
314
-
315
- function enforceAuthorInControlTables(body: string, author: string | undefined): string {
316
- const safeAuthor = normalizeAuthor(author);
317
- if (!safeAuthor) return body;
318
- return body.replace(
319
- /(\|\s*v?[0-9.]+\s*\|\s*[^|]*\|\s*)([^|]*)(\|\s*[^|]*\|)/gi,
320
- (_match, left: string, _current: string, right: string) => `${left}${safeAuthor} ${right}`
321
- );
322
- }
323
-
324
- async function resolveUniqueOutputPath(targetDir: string, fileName: string): Promise<string> {
325
- const parsed = path.parse(fileName);
326
- let candidate = path.join(targetDir, fileName);
327
- let index = 2;
328
- while (await fileExists(candidate)) {
329
- candidate = path.join(targetDir, `${parsed.name}-${String(index).padStart(2, "0")}${parsed.ext}`);
330
- index += 1;
331
- }
332
- return candidate;
333
- }
334
-
335
- function workflowFeatureTargets(
336
- normalized: NormalizedBrief,
337
- coverage: ContractCoverage
338
- ): Array<{ id: string; text: string }> {
339
- const byId = new Map(normalized.contracts.core_features.map((item) => [item.id, item]));
340
- const explicit = coverage.core_features
341
- .map((id) => byId.get(id))
342
- .filter((item): item is { id: string; text: string } => Boolean(item));
343
-
344
- if (explicit.length > 1) return explicit;
345
- if (normalized.contracts.core_features.length > 1) return normalized.contracts.core_features.slice(0, 6);
346
- if (explicit.length === 1) return explicit;
347
- return normalized.contracts.core_features.slice(0, 1);
348
- }
349
-
350
- function renderWorkflowMarkdownForFeature(
351
- markdown: string,
352
- feature: { id: string; text: string },
353
- lang: string
354
- ): string {
355
- const tr = lang.toLowerCase().startsWith("tr");
356
- const noteHeading = tr ? "## Akis Odagi" : "## Flow Focus";
357
- const noteLine = tr
358
- ? `- [${feature.id}] Bu akis ${feature.text} ihtiyacina odaklanir.`
359
- : `- [${feature.id}] This flow focuses on ${feature.text}.`;
360
- if (markdown.includes(noteHeading)) return markdown;
361
- return `${markdown.trim()}\n\n${noteHeading}\n${noteLine}`.trim();
362
- }
363
-
364
- async function resolvePrompt(
365
- cwd: string,
366
- artifactType: ArtifactType,
367
- templateContent: string,
368
- requiredHeadings: string[],
369
- companionTemplate: { path: string; content: string } | null,
370
- outputAuthor: string | undefined,
371
- agent?: string
372
- ): Promise<string> {
373
- const base = await fs.readFile(promptPath(cwd, artifactType), "utf8");
374
- const authority = `Template authority (STRICT):
375
- - Treat this template as the single output structure source.
376
- - Keep heading order and names exactly as listed.
377
- - Do not invent new primary sections.
378
-
379
- Required headings (from template):
380
- ${requiredHeadings.map((heading) => `- ${heading}`).join("\n")}
381
-
382
- Resolved template:
383
- \`\`\`md
384
- ${templateContent.trim()}
385
- \`\`\``;
386
- const companionAuthority = companionTemplate
387
- ? `Native companion template (STRICT reference):
388
- - Path: ${companionTemplate.path}
389
- - Preserve this native format and structure when generating companion artifact.
390
- \`\`\`${artifactType === "workflow" ? "mermaid" : "html"}
391
- ${companionTemplate.content.trim()}
392
- \`\`\``
393
- : "";
394
- const workflowPairing =
395
- artifactType === "workflow"
396
- ? `
397
- Workflow paired output contract (STRICT):
398
- - Output markdown explanation first (template headings).
399
- - Then append a mermaid block for the same flow:
400
- \`\`\`mermaid
401
- flowchart TD
402
- ...
403
- \`\`\`
404
- - Mermaid block is mandatory.`
405
- : "";
406
- const wireframePairing =
407
- artifactType === "wireframe"
408
- ? `
409
- Wireframe paired output contract (STRICT):
410
- - Output markdown explanation first (template headings).
411
- - Generate companion HTML screens based on native wireframe template.
412
- - HTML must stay low-fidelity and structure-first.`
413
- : "";
414
- const authorPolicy = outputAuthor && outputAuthor.trim().length > 0
415
- ? `
416
- Author policy (STRICT):
417
- - Use this exact author name wherever author is required: ${outputAuthor.trim()}
418
- - Do not invent random author names.`
419
- : "";
420
- const withTemplate = `${base}
421
-
422
- ${authority}
423
- ${companionAuthority}
424
- ${workflowPairing}
425
- ${wireframePairing}
426
- ${authorPolicy}`;
427
- if (!agent) return withTemplate;
428
- return `${withTemplate}
429
-
430
- Agent execution profile: ${agent}
431
- - Keep output deterministic and actionable.`;
432
- }
433
-
434
- async function loadLatestArtifactPath(cwd: string, type: ArtifactType): Promise<string | undefined> {
435
- const def = await getArtifactDefinition(cwd, type);
436
- const active = await getActiveArtifactPath(cwd, type);
437
- if (active) return active;
438
- const files = await listFilesSortedByMtime(outputDirPath(cwd, type, def.output_dir));
439
- return files[0];
440
- }
441
-
442
- function contextFilePath(cwd: string, artifactFile: string): string {
443
- const base = path.parse(artifactFile).name;
444
- return path.join(outputContextDirPath(cwd), `${base}.json`);
445
- }
446
-
447
- function toLineItems(value: string | undefined): string[] {
448
- if (!value) return [];
449
- return value
450
- .split(/\r?\n/)
451
- .map((line) => line.replace(/^\s*[-*0-9.]+\s*/, "").trim())
452
- .filter((line) => line.length > 0);
453
- }
454
-
455
- function parseHeadingTitle(fullHeading: string): string {
456
- return fullHeading.replace(/^##\s+/, "").trim();
457
- }
458
-
459
- function deriveStructuredContext(
460
- artifactType: ArtifactType,
461
- body: string,
462
- requiredHeadings: string[]
463
- ): Record<string, unknown> {
464
- const sections = sectionTextMap(body);
465
- const ordered = requiredHeadings
466
- .map((heading) => ({ heading, items: toLineItems(sections.get(heading)) }))
467
- .filter((item) => item.items.length > 0);
468
- const section_map = Object.fromEntries(
469
- Array.from(sections.entries()).map(([heading, text]) => [parseHeadingTitle(heading), toLineItems(text)])
470
- );
471
-
472
- if (artifactType === "workflow") {
473
- return {
474
- section_map,
475
- actor_map: ordered[0]?.items ?? [],
476
- step_map: ordered[1]?.items ?? [],
477
- edge_case_map: ordered[2]?.items ?? []
478
- };
479
- }
480
- if (artifactType === "wireframe") {
481
- return {
482
- section_map,
483
- screen_map: ordered[0]?.items ?? [],
484
- interaction_map: ordered[1]?.items ?? []
485
- };
486
- }
487
- if (artifactType === "techspec") {
488
- return {
489
- section_map,
490
- architecture_map: ordered[0]?.items ?? [],
491
- integration_map: ordered[1]?.items ?? []
492
- };
493
- }
494
- if (artifactType === "stories") {
495
- return {
496
- section_map,
497
- story_map: ordered[0]?.items ?? [],
498
- acceptance_map: ordered[1]?.items ?? []
499
- };
500
- }
501
- return {
502
- section_map,
503
- goal_map: ordered[0]?.items ?? [],
504
- requirement_map: ordered[1]?.items ?? []
505
- };
506
- }
507
-
508
- async function buildUpstreamArtifacts(
509
- cwd: string,
510
- artifactType: ArtifactType,
511
- upstreamTypes: ArtifactType[]
512
- ): Promise<
513
- Array<{
514
- type: ArtifactType;
515
- file: string;
516
- contractCoverage: ContractCoverage;
517
- structuredContext?: Record<string, unknown>;
518
- }>
519
- > {
520
- const refs: Array<{
521
- type: ArtifactType;
522
- file: string;
523
- contractCoverage: ContractCoverage;
524
- structuredContext?: Record<string, unknown>;
525
- }> = [];
526
- for (const type of upstreamTypes) {
527
- const latest = await loadLatestArtifactPath(cwd, type);
528
- if (!latest) continue;
529
- const parsed = await loadArtifactDoc(latest);
530
- const frontmatter = parsed.frontmatter;
531
- const coverageRaw = frontmatter.contract_coverage as Partial<ContractCoverage> | undefined;
532
- const contextPath = contextFilePath(cwd, latest);
533
- const structuredContext = (await fileExists(contextPath))
534
- ? await readJsonFile<Record<string, unknown>>(contextPath)
535
- : {};
536
- refs.push({
537
- type,
538
- file: latest,
539
- contractCoverage: {
540
- goals: Array.isArray(coverageRaw?.goals)
541
- ? coverageRaw.goals.filter((item): item is string => typeof item === "string")
542
- : [],
543
- core_features: Array.isArray(coverageRaw?.core_features)
544
- ? coverageRaw.core_features.filter((item): item is string => typeof item === "string")
545
- : [],
546
- constraints: Array.isArray(coverageRaw?.constraints)
547
- ? coverageRaw.constraints.filter((item): item is string => typeof item === "string")
548
- : []
549
- },
550
- ...(Object.keys(structuredContext).length > 0 ? { structuredContext } : {})
551
- });
552
- }
553
- return refs;
554
- }
555
-
556
- function extractCoverageFromBody(body: string): ContractCoverage {
557
- const tagged = {
558
- goals: Array.from(new Set(body.match(/\[(G[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? [])),
559
- core_features: Array.from(new Set(body.match(/\[(F[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? [])),
560
- constraints: Array.from(new Set(body.match(/\[(C[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? []))
561
- };
562
- return tagged;
563
- }
564
-
565
- function missingCoverage(
566
- requiredContracts: Array<keyof ContractCoverage>,
567
- normalized: NormalizedBrief,
568
- coverage: ContractCoverage
569
- ): Array<{ key: keyof ContractCoverage; ids: string[] }> {
570
- const ids = contractIds(normalized.contracts);
571
- const missing: Array<{ key: keyof ContractCoverage; ids: string[] }> = [];
572
-
573
- for (const key of requiredContracts) {
574
- const expected = ids[key];
575
- if (expected.length === 0) continue;
576
- const missingIds = expected.filter((id) => !coverage[key].includes(id));
577
- if (missingIds.length > 0) {
578
- missing.push({ key, ids: missingIds });
579
- }
580
- }
581
- return missing;
582
- }
583
-
584
- async function ensurePipelinePrereqs(cwd: string, normalizedPath: string): Promise<void> {
585
- const prodoRoot = prodoPath(cwd);
586
- if (!(await fileExists(prodoRoot))) {
587
- throw new UserError("Missing .prodo directory. Run `prodo-init` first.");
588
- }
589
-
590
- if (!(await fileExists(briefPath(cwd)))) {
591
- throw new UserError(
592
- "Missing brief at `brief.md`. Run `prodo-init` or create the file."
593
- );
594
- }
595
-
596
- if (!(await fileExists(normalizedPath))) {
597
- throw new UserError(
598
- "Missing normalized brief at `.prodo/briefs/normalized-brief.json`. Create it before generating artifacts."
599
- );
600
- }
601
- }
602
-
603
- function splitWorkflowPair(raw: string): { markdown: string; mermaid: string } {
604
- const match = raw.match(/```mermaid\s*([\s\S]*?)```/i);
605
- if (!match) {
606
- throw new UserError(
607
- "Workflow output is missing a Mermaid block. Regenerate with template-compliant paired output."
608
- );
609
- }
610
- const mermaid = match[1].trim();
611
- const markdown = raw.replace(match[0], "").trim();
612
- if (!markdown) {
613
- throw new UserError("Workflow markdown explanation is empty.");
614
- }
615
- if (!/(^|\n)\s*(flowchart|graph)\s+/i.test(mermaid)) {
616
- throw new UserError("Workflow Mermaid block is invalid.");
617
- }
618
- return { markdown, mermaid };
619
- }
620
-
621
- async function writeWorkflowFlows(
622
- targetDir: string,
623
- baseName: string,
624
- normalized: NormalizedBrief,
625
- coverage: ContractCoverage,
626
- lang: string,
627
- markdownBody: string,
628
- mermaidBody: string | null,
629
- mermaidTemplateContent: string | null
630
- ): Promise<{ primaryPath: string; summaryBody: string; rendered: Array<{ mdPath: string; body: string }> }> {
631
- const targets = workflowFeatureTargets(normalized, coverage);
632
- const fallbackFeature = normalized.contracts.core_features[0] ?? { id: "F1", text: "Primary flow" };
633
- const flows = targets.length > 0 ? targets : [fallbackFeature];
634
- const summaryBodies: string[] = [];
635
- const renderedArtifacts: Array<{ mdPath: string; body: string }> = [];
636
- let primaryMdPath = "";
637
-
638
- for (const [index, flowFeature] of flows.entries()) {
639
- const flowBase =
640
- flows.length === 1
641
- ? baseName
642
- : (index === 0
643
- ? baseName
644
- : `${baseName}-${index + 1}-${toSlug(extractTurkishTitle(flowFeature.text))}`);
645
- const mdPath = path.join(targetDir, `${flowBase}.md`);
646
- const mmdPath = path.join(targetDir, `${flowBase}.mmd`);
647
- const featureCoverage: ContractCoverage = {
648
- ...coverage,
649
- core_features: [flowFeature.id]
650
- };
651
- const renderedMarkdown = renderWorkflowMarkdownForFeature(markdownBody, flowFeature, lang);
652
- const renderedMermaid = (mermaidTemplateContent && mermaidTemplateContent.trim().length > 0)
653
- ? renderWorkflowMermaidTemplate(mermaidTemplateContent, normalized, featureCoverage, lang).trim()
654
- : (mermaidBody ?? "").trim();
655
-
656
- if (!/(^|\n)\s*(flowchart|graph)\s+/i.test(renderedMermaid)) {
657
- throw new UserError("Workflow Mermaid output is invalid.");
658
- }
659
-
660
- enforceLanguage(renderedMarkdown, lang, "workflow");
661
- enforceLanguage(renderedMermaid, lang, "workflow");
662
- await fs.writeFile(mdPath, `${renderedMarkdown}\n`, "utf8");
663
- await fs.writeFile(mmdPath, `${renderedMermaid}\n`, "utf8");
664
-
665
- if (!primaryMdPath) primaryMdPath = mdPath;
666
- summaryBodies.push(renderedMarkdown);
667
- renderedArtifacts.push({ mdPath, body: renderedMarkdown });
668
- }
669
-
670
- return {
671
- primaryPath: primaryMdPath,
672
- summaryBody: summaryBodies.join("\n\n"),
673
- rendered: renderedArtifacts
674
- };
675
- }
676
-
677
- async function writeWireframeScreens(
678
- targetDir: string,
679
- baseName: string,
680
- normalized: NormalizedBrief,
681
- coverage: ContractCoverage,
682
- lang: string,
683
- headings: string[],
684
- htmlTemplateContent: string | null
685
- ): Promise<{ primaryPath: string; summaryBody: string }> {
686
- const tr = lang.toLowerCase().startsWith("tr");
687
- const explicitScreens = normalized.contracts.core_features
688
- .filter((item) => coverage.core_features.includes(item.id))
689
- .slice(0, 6);
690
- const screens =
691
- explicitScreens.length > 1
692
- ? explicitScreens
693
- : (normalized.contracts.core_features.length > 1
694
- ? normalized.contracts.core_features.slice(0, 6)
695
- : (explicitScreens.length === 1
696
- ? explicitScreens
697
- : normalized.contracts.core_features.slice(0, 1)));
698
- const summaryBodies: string[] = [];
699
- let primaryMdPath = "";
700
- for (const [index, screen] of screens.entries()) {
701
- const title = extractTurkishTitle(screen.text);
702
- const screenBase = `${baseName}-${index + 1}-${toSlug(title)}`;
703
- const htmlPath = path.join(targetDir, `${screenBase}.html`);
704
- const mdPath = path.join(targetDir, `${screenBase}.md`);
705
- const fallbackHtml = `<!doctype html>
706
- <html lang="${lang}">
707
- <head>
708
- <meta charset="utf-8" />
709
- <meta name="viewport" content="width=device-width, initial-scale=1" />
710
- <title>${title}</title>
711
- </head>
712
- <body>
713
- <!-- [${screen.id}] -->
714
- <header>
715
- <h1>${title}</h1>
716
- <nav><button type="button">${tr ? "Geri" : "Back"}</button><button type="button">${tr ? "Devam" : "Next"}</button></nav>
717
- </header>
718
- <main>
719
- <section>
720
- <h2>${tr ? "Icerik" : "Content"}</h2>
721
- <ul>
722
- <li>${tr ? "Birincil bilgi alani" : "Primary information area"}</li>
723
- <li>${tr ? "Durum gostergesi" : "Status indicator"}</li>
724
- </ul>
725
- </section>
726
- <section>
727
- <h2>${tr ? "Form" : "Form"}</h2>
728
- <form>
729
- <label>${tr ? "Alan" : "Field"}
730
- <input type="text" name="field_${index + 1}" />
731
- </label>
732
- <button type="submit">${tr ? "Kaydet" : "Save"}</button>
733
- </form>
734
- </section>
735
- </main>
736
- </body>
737
- </html>`;
738
- const htmlTemplate = htmlTemplateContent && htmlTemplateContent.trim().length > 0 ? htmlTemplateContent : fallbackHtml;
739
- const html = replaceTemplateTokens(
740
- htmlTemplate,
741
- {
742
- "Screen Title": title,
743
- "Primary Action": tr ? "Kaydet" : "Save",
744
- "Description Label": tr ? "Aciklama" : "Description",
745
- "Description Placeholder": `[${screen.id}] ${screen.text}`,
746
- "Meta Label 1": tr ? "Kontrat" : "Contract",
747
- "Meta Value 1": screen.id,
748
- "Meta Label 2": tr ? "Aktor" : "Actor",
749
- "Meta Value 2": normalized.audience[0] ?? (tr ? "Kullanici" : "User"),
750
- "Field Label": tr ? "Alan" : "Field",
751
- "Detailed Input Area": tr ? "Detayli Giris Alani" : "Detailed Input Area",
752
- "Upload / Attachment Area": tr ? "Dosya Alani" : "Upload Area",
753
- "Allowed file types / notes": tr ? "Dusuk sadakatli wireframe." : "Low-fidelity wireframe.",
754
- "Consent / confirmation text": tr ? "Onay metni" : "Confirmation text"
755
- },
756
- (token) => {
757
- const key = token.toLowerCase();
758
- if (key.includes("screen") || key.includes("title")) return title;
759
- if (key.includes("action") || key.includes("button")) return tr ? "Kaydet" : "Save";
760
- if (key.includes("field")) return tr ? "Alan" : "Field";
761
- if (key.includes("description") || key.includes("summary")) return `[${screen.id}] ${screen.text}`;
762
- if (key.includes("actor") || key.includes("user")) return normalized.audience[0] ?? (tr ? "Kullanici" : "User");
763
- if (key.includes("logo")) return "[ LOGO ]";
764
- return token;
765
- }
766
- );
767
- enforceLanguage(html, lang, "wireframe");
768
- await fs.writeFile(htmlPath, html, "utf8");
769
- const defaultMap = {
770
- purpose: [`- [${screen.id}] ${screen.text}`],
771
- actor: [`- ${(normalized.audience[0] ?? (tr ? "Birincil kullanici" : "Primary user"))}`],
772
- sections: [
773
- `- ${tr ? "Baslik ve gezinme" : "Header and navigation"}`,
774
- `- ${tr ? "Icerik bolumu" : "Content section"}`,
775
- `- ${tr ? "Form bolumu" : "Form section"}`
776
- ],
777
- fields: [`- ${tr ? "Metin alani (field_" : "Text input (field_"}${index + 1})`],
778
- actions: [`- ${tr ? "Geri" : "Back"}`, `- ${tr ? "Devam" : "Next"}`, `- ${tr ? "Kaydet" : "Save"}`],
779
- states: [`- ${tr ? "Bos durum, yukleniyor, hata, basari" : "Empty, loading, error, success states"}`],
780
- notes: [`- ${tr ? "Dusuk sadakatli tel kafes taslaktir." : "Low-fidelity black-and-white wireframe mock."}`]
781
- };
782
- const fallbackQueue = [
783
- ...defaultMap.purpose,
784
- ...defaultMap.actor,
785
- ...defaultMap.sections,
786
- ...defaultMap.fields,
787
- ...defaultMap.actions,
788
- ...defaultMap.states,
789
- ...defaultMap.notes
790
- ];
791
- const consumeFallback = () => (fallbackQueue.shift() ?? `- ${tr ? "Detay bekleniyor." : "Detail pending."}`);
792
- const contentForHeading = (heading: string): string[] => {
793
- const key = heading.toLowerCase();
794
- if (/(screen purpose|purpose|amac|hedef)/.test(key)) return defaultMap.purpose;
795
- if (/(primary actor|actor|user|kullanici|rol)/.test(key)) return defaultMap.actor;
796
- if (/(main section|section|bolum|layout)/.test(key)) return defaultMap.sections;
797
- if (/(field|input|form|alan)/.test(key)) return defaultMap.fields;
798
- if (/(action|button|cta|aksiyon)/.test(key)) return defaultMap.actions;
799
- if (/(state|message|durum|mesaj)/.test(key)) return defaultMap.states;
800
- if (/(note|not|aciklama)/.test(key)) return defaultMap.notes;
801
- return [consumeFallback()];
802
- };
803
- const targetHeadings = headings.length > 0 ? headings : defaultRequiredHeadings("wireframe");
804
- const mdLines = [`# ${title}`, ""];
805
- for (const heading of targetHeadings) {
806
- mdLines.push(heading);
807
- mdLines.push(...contentForHeading(heading));
808
- mdLines.push("");
809
- }
810
- const mdBody = mdLines.join("\n").trim();
811
- enforceLanguage(mdBody, lang, "wireframe");
812
- await fs.writeFile(mdPath, `${mdBody}\n`, "utf8");
813
- if (!primaryMdPath) primaryMdPath = mdPath;
814
- summaryBodies.push(mdBody);
815
- }
816
- return {
817
- primaryPath: primaryMdPath,
818
- summaryBody: summaryBodies.join("\n\n")
819
- };
820
- }
821
-
822
- export async function generateArtifact(options: GenerateOptions): Promise<string> {
823
- const { cwd, artifactType, outPath, agent } = options;
824
- const revisionType = options.revisionType ?? "default";
825
- const def = await getArtifactDefinition(cwd, artifactType);
826
- const normalizedPath = options.normalizedBriefOverride ?? normalizedBriefPath(cwd);
827
- await ensurePipelinePrereqs(cwd, normalizedPath);
828
- const documentControlVersion = await resolveDocumentControlVersion(cwd, artifactType, revisionType);
829
-
830
- const settings = await readSettings(cwd);
831
- const normalizedBriefRaw = await readJsonFile<Record<string, unknown>>(normalizedPath);
832
- const normalizedBrief = parseNormalizedBriefOrThrow(normalizedBriefRaw);
833
- const template = await resolveTemplate({ cwd, artifactType });
834
- const companionTemplate = await resolveCompanionTemplate({ cwd, artifactType });
835
- if (!template || template.content.trim().length === 0) {
836
- throw new UserError(
837
- `Missing ${artifactType} template. Create \`.prodo/templates/${artifactType}.md\` before running \`prodo-${artifactType}\`.`
838
- );
839
- }
840
- if (artifactType === "workflow" && !companionTemplate) {
841
- throw new UserError(
842
- "Missing workflow companion template. Create `.prodo/templates/workflow.mmd` before running `prodo-workflow`."
843
- );
844
- }
845
- if (artifactType === "wireframe" && !companionTemplate) {
846
- throw new UserError(
847
- "Missing wireframe companion template. Create `.prodo/templates/wireframe.html` before running `prodo-wireframe`."
848
- );
849
- }
850
- const templateHeadings =
851
- template && template.content.trim().length > 0 ? extractRequiredHeadingsFromTemplate(template.content) : [];
852
- if (templateHeadings.length === 0) {
853
- throw new UserError(
854
- `${artifactType} template has no extractable headings. Add markdown headings to \`${template.path}\`.`
855
- );
856
- }
857
- const computedHeadings = templateHeadings.length > 0
858
- ? templateHeadings
859
- : (def.required_headings.length > 0 ? def.required_headings : defaultRequiredHeadings(artifactType));
860
- const prompt = await resolvePrompt(
861
- cwd,
862
- artifactType,
863
- template?.content ?? "",
864
- computedHeadings,
865
- companionTemplate,
866
- settings.author,
867
- agent
868
- );
869
- const provider = createProvider();
870
- const upstreamArtifacts = await buildUpstreamArtifacts(cwd, artifactType, def.upstream);
871
- const schemaHint = {
872
- artifactType,
873
- requiredHeadings: computedHeadings,
874
- requiredContracts: def.required_contracts
875
- };
876
-
877
- const generated = await provider.generate(
878
- prompt,
879
- {
880
- normalizedBrief,
881
- upstreamArtifacts,
882
- contractCatalog: normalizedBrief.contracts,
883
- templateContent: template?.content ?? "",
884
- templatePath: template?.path ?? "",
885
- companionTemplateContent: companionTemplate?.content ?? "",
886
- companionTemplatePath: companionTemplate?.path ?? "",
887
- outputLanguage: settings.lang,
888
- outputAuthor: settings.author
889
- },
890
- schemaHint
891
- );
892
-
893
- let generatedBody = enforceAuthorInControlTables(
894
- replaceAuthorPlaceholders(generated.body.trim(), settings.author),
895
- settings.author
896
- );
897
- let workflowMermaidBody: string | null = null;
898
- if (artifactType === "workflow") {
899
- const paired = splitWorkflowPair(generatedBody);
900
- generatedBody = enforceAuthorInControlTables(
901
- replaceAuthorPlaceholders(paired.markdown, settings.author),
902
- settings.author
903
- );
904
- workflowMermaidBody = replaceAuthorPlaceholders(paired.mermaid, settings.author);
905
- }
906
- let contractCoverage = extractCoverageFromBody(generatedBody);
907
-
908
- if (artifactType === "workflow") {
909
- if (contractCoverage.core_features.length === 0) {
910
- contractCoverage = {
911
- ...contractCoverage,
912
- core_features: normalizedBrief.contracts.core_features.map((item) => item.id)
913
- };
914
- }
915
- }
916
-
917
- if (artifactType === "wireframe") {
918
- if (contractCoverage.core_features.length === 0) {
919
- contractCoverage = {
920
- ...contractCoverage,
921
- core_features: normalizedBrief.contracts.core_features.map((item) => item.id)
922
- };
923
- }
924
- }
925
-
926
- generatedBody = applyDocumentControlDefaults(generatedBody, {
927
- lang: settings.lang,
928
- revisionType,
929
- version: documentControlVersion,
930
- author: settings.author
931
- });
932
-
933
- if (artifactType === "workflow" && companionTemplate?.content) {
934
- workflowMermaidBody = renderWorkflowMermaidTemplate(
935
- companionTemplate.content,
936
- normalizedBrief,
937
- contractCoverage,
938
- settings.lang
939
- ).trim();
940
- workflowMermaidBody = replaceAuthorPlaceholders(workflowMermaidBody, settings.author);
941
- }
942
-
943
- enforceLanguage(generatedBody, settings.lang, artifactType);
944
- if (artifactType === "workflow" && workflowMermaidBody) {
945
- enforceLanguage(workflowMermaidBody, settings.lang, artifactType);
946
- }
947
- const uncovered = missingCoverage(def.required_contracts, normalizedBrief, contractCoverage);
948
- if (uncovered.length > 0) {
949
- const lines = uncovered
950
- .map((item) => `- ${item.key}: missing ${item.ids.join(", ")}`)
951
- .join("\n");
952
- throw new UserError(
953
- `Artifact is missing required contract references. Add ID tags to body:\n${lines}\nExample tags: [G1], [F2], [C1].`
954
- );
955
- }
956
-
957
- const frontmatter = {
958
- artifact_type: artifactType,
959
- version: timestampSlug(),
960
- source_brief: path.resolve(normalizedPath),
961
- generated_at: new Date().toISOString(),
962
- status: DEFAULT_STATUS,
963
- upstream_artifacts: upstreamArtifacts.map((item) => item.file),
964
- contract_coverage: contractCoverage,
965
- language: settings.lang,
966
- ...(normalizeAuthor(settings.author) ? { author: normalizeAuthor(settings.author) } : {})
967
- } as Record<string, unknown>;
968
-
969
- const mergedFrontmatter = { ...frontmatter, ...(generated.frontmatter ?? {}) };
970
- if (normalizeAuthor(settings.author)) {
971
- mergedFrontmatter.author = normalizeAuthor(settings.author);
972
- }
973
- let doc: ArtifactDoc = {
974
- frontmatter: mergedFrontmatter,
975
- body: generatedBody
976
- };
977
-
978
- const validation = await validateSchema(cwd, artifactType, doc, schemaHint.requiredHeadings);
979
- const schemaErrors = validation.issues.filter((issue) => issue.level === "error");
980
- if (schemaErrors.length > 0) {
981
- const details = schemaErrors.map((issue) => `- ${issue.message}`).join("\n");
982
- throw new UserError(`Artifact failed schema checks:\n${details}`);
983
- }
984
-
985
- const targetDir = outputDirPath(cwd, artifactType, def.output_dir);
986
- const finalPath = outPath
987
- ? path.resolve(cwd, outPath)
988
- : await resolveUniqueOutputPath(targetDir, defaultFilename(artifactType));
989
- if (!isPathInside(path.join(cwd, "product-docs"), finalPath)) {
990
- throw new UserError("Artifact output must be inside `product-docs/`.");
991
- }
992
- await fs.mkdir(path.dirname(finalPath), { recursive: true });
993
- if (artifactType === "workflow") {
994
- const basePath = path.join(path.dirname(finalPath), path.parse(finalPath).name);
995
- const workflow = await writeWorkflowFlows(
996
- path.dirname(basePath),
997
- path.parse(basePath).name,
998
- normalizedBrief,
999
- contractCoverage,
1000
- settings.lang,
1001
- doc.body,
1002
- workflowMermaidBody,
1003
- companionTemplate?.content ?? null
1004
- );
1005
- await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
1006
- for (const rendered of workflow.rendered) {
1007
- const renderedDoc: ArtifactDoc = {
1008
- frontmatter: doc.frontmatter,
1009
- body: rendered.body
1010
- };
1011
- await fs.writeFile(rendered.mdPath, matter.stringify(renderedDoc.body, renderedDoc.frontmatter), "utf8");
1012
- await writeSidecar(rendered.mdPath, renderedDoc);
1013
- const renderedContext = {
1014
- artifact_type: artifactType,
1015
- artifact_file: rendered.mdPath,
1016
- generated_at: new Date().toISOString(),
1017
- contract_coverage: contractCoverage,
1018
- ...deriveStructuredContext(artifactType, renderedDoc.body, schemaHint.requiredHeadings)
1019
- };
1020
- await fs.writeFile(contextFilePath(cwd, rendered.mdPath), `${JSON.stringify(renderedContext, null, 2)}\n`, "utf8");
1021
- }
1022
- const primaryRendered = workflow.rendered.find((item) => item.mdPath === workflow.primaryPath) ?? workflow.rendered[0];
1023
- doc = {
1024
- frontmatter: doc.frontmatter,
1025
- body: primaryRendered?.body ?? doc.body
1026
- };
1027
- await setActiveArtifact(cwd, artifactType, workflow.primaryPath);
1028
- return workflow.primaryPath;
1029
- } else if (artifactType === "wireframe") {
1030
- const base = path.parse(finalPath).name;
1031
- const wireframe = await writeWireframeScreens(
1032
- path.dirname(finalPath),
1033
- base,
1034
- normalizedBrief,
1035
- contractCoverage,
1036
- settings.lang,
1037
- schemaHint.requiredHeadings,
1038
- companionTemplate?.content ?? null
1039
- );
1040
- doc = {
1041
- frontmatter: doc.frontmatter,
1042
- body: wireframe.summaryBody
1043
- };
1044
- await writeSidecar(wireframe.primaryPath, doc);
1045
- const derivedContext = {
1046
- artifact_type: artifactType,
1047
- artifact_file: wireframe.primaryPath,
1048
- generated_at: new Date().toISOString(),
1049
- contract_coverage: contractCoverage,
1050
- ...deriveStructuredContext(artifactType, doc.body, schemaHint.requiredHeadings)
1051
- };
1052
- await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
1053
- await fs.writeFile(contextFilePath(cwd, wireframe.primaryPath), `${JSON.stringify(derivedContext, null, 2)}\n`, "utf8");
1054
- await setActiveArtifact(cwd, artifactType, wireframe.primaryPath);
1055
- return wireframe.primaryPath;
1056
- } else {
1057
- const content = matter.stringify(doc.body, doc.frontmatter);
1058
- await fs.writeFile(finalPath, content, "utf8");
1059
- }
1060
- await writeSidecar(finalPath, doc);
1061
- const derivedContext = {
1062
- artifact_type: artifactType,
1063
- artifact_file: finalPath,
1064
- generated_at: new Date().toISOString(),
1065
- contract_coverage: contractCoverage,
1066
- ...deriveStructuredContext(artifactType, doc.body, schemaHint.requiredHeadings)
1067
- };
1068
- await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
1069
- await fs.writeFile(contextFilePath(cwd, finalPath), `${JSON.stringify(derivedContext, null, 2)}\n`, "utf8");
1070
- await setActiveArtifact(cwd, artifactType, finalPath);
1071
- return finalPath;
1072
- }
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import { t } from "../i18n";
5
+ import { DEFAULT_STATUS, defaultRequiredHeadings } from "./constants";
6
+ import { getArtifactDefinition } from "./artifact-registry";
7
+ import { UserError } from "./errors";
8
+ import { contractIds, parseNormalizedBriefOrThrow, type NormalizedBrief } from "./normalized-brief";
9
+ import { getActiveArtifactPath, setActiveArtifact } from "./output-index";
10
+ import {
11
+ briefPath,
12
+ normalizedBriefPath,
13
+ outputContextDirPath,
14
+ outputDirPath,
15
+ promptPath,
16
+ prodoPath
17
+ } from "./paths";
18
+ import { createProvider } from "../providers";
19
+ import { extractRequiredHeadingsFromTemplate, resolveCompanionTemplate, resolveTemplate } from "./template-resolver";
20
+ import { readSettings } from "./settings";
21
+ import { sectionTextMap } from "./markdown";
22
+ import type { ArtifactDoc, ArtifactType, ContractCoverage } from "./types";
23
+ import { artifactFileStamp, fileExists, isPathInside, listFilesSortedByMtime, readJsonFile, timestampSlug } from "./utils";
24
+ import { validateSchema } from "./validator";
25
+ import { renderTemplate } from "./template-engine";
26
+
27
+ export type GenerateOptions = {
28
+ artifactType: ArtifactType;
29
+ cwd: string;
30
+ normalizedBriefOverride?: string;
31
+ outPath?: string;
32
+ agent?: string;
33
+ revisionType?: "default" | "fix";
34
+ };
35
+
36
+ function defaultFilename(type: ArtifactType): string {
37
+ return `${type}-${artifactFileStamp()}.md`;
38
+ }
39
+
40
+ function sidecarPath(filePath: string): string {
41
+ const parsed = path.parse(filePath);
42
+ return path.join(parsed.dir, `${parsed.name}.artifact.json`);
43
+ }
44
+
45
+ async function writeSidecar(filePath: string, doc: ArtifactDoc): Promise<void> {
46
+ const payload = {
47
+ frontmatter: doc.frontmatter,
48
+ body: doc.body
49
+ };
50
+ await fs.writeFile(sidecarPath(filePath), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
51
+ }
52
+
53
+ async function loadArtifactDoc(filePath: string): Promise<ArtifactDoc> {
54
+ const sidecar = sidecarPath(filePath);
55
+ if (await fileExists(sidecar)) {
56
+ const loaded = await readJsonFile<Record<string, unknown>>(sidecar);
57
+ return {
58
+ frontmatter: (loaded.frontmatter as Record<string, unknown>) ?? {},
59
+ body: typeof loaded.body === "string" ? loaded.body : ""
60
+ };
61
+ }
62
+ const raw = await fs.readFile(filePath, "utf8");
63
+ const parsed = matter(raw);
64
+ return {
65
+ frontmatter: parsed.data as Record<string, unknown>,
66
+ body: parsed.content
67
+ };
68
+ }
69
+
70
+ function languageProbe(body: string): string {
71
+ const stripped = body
72
+ .replace(/```[\s\S]*?```/g, " ")
73
+ .replace(/^\s*#{1,6}\s+.*$/gm, " ")
74
+ .replace(/<[^>]+>/g, " ")
75
+ .replace(/\|/g, " ")
76
+ .replace(/\s+/g, " ")
77
+ .trim()
78
+ .toLowerCase();
79
+ return ` ${stripped} `;
80
+ }
81
+
82
+ function hasEnglishLeak(body: string): boolean {
83
+ const englishMarkers = [" the ", " and ", " with ", " user ", " should ", " must ", " requirement ", " flow ", " error ", " success "];
84
+ const normalized = languageProbe(body);
85
+ return englishMarkers.filter((m) => normalized.includes(m)).length >= 2;
86
+ }
87
+
88
+ function hasTurkishLeak(body: string): boolean {
89
+ const turkishMarkers = [
90
+ " ve ",
91
+ " ile ",
92
+ " kullanici ",
93
+ " kullanıcı ",
94
+ " akis ",
95
+ " akış ",
96
+ " hata ",
97
+ " basari ",
98
+ " başarı ",
99
+ " ekran ",
100
+ " islem ",
101
+ " işlem ",
102
+ " gerekli "
103
+ ];
104
+ const normalized = languageProbe(body);
105
+ return turkishMarkers.filter((m) => normalized.includes(m)).length >= 2;
106
+ }
107
+
108
+ function enforceLanguage(body: string, lang: string, artifactType: ArtifactType): void {
109
+ const normalized = (lang || "en").toLowerCase();
110
+ if (normalized.startsWith("tr")) {
111
+ if (!hasEnglishLeak(body)) return;
112
+ throw new UserError(
113
+ `Language enforcement failed for ${artifactType}: output contains English fragments while language is Turkish.`
114
+ );
115
+ }
116
+ if (normalized.startsWith("en")) {
117
+ if (!hasTurkishLeak(body)) return;
118
+ throw new UserError(
119
+ `Language enforcement failed for ${artifactType}: output contains Turkish fragments while language is English.`
120
+ );
121
+ }
122
+ }
123
+
124
+ function toSlug(value: string): string {
125
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "screen";
126
+ }
127
+
128
+ function extractTurkishTitle(featureText: string): string {
129
+ const base = featureText.replace(/^\[[A-Z][0-9]+\]\s*/, "").trim();
130
+ if (!base) return "Ekran";
131
+ return base;
132
+ }
133
+
134
+ function replaceTemplateTokens(
135
+ template: string,
136
+ replacements: Record<string, string>,
137
+ fallbackFromToken: (token: string) => string
138
+ ): string {
139
+ const context: Record<string, string> = {};
140
+ for (const [key, value] of Object.entries(replacements)) {
141
+ const nunjucksKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
142
+ context[nunjucksKey] = value;
143
+ }
144
+
145
+ let prepared = template;
146
+ for (const [key, value] of Object.entries(replacements)) {
147
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
+ prepared = prepared.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, "g"), value);
149
+ }
150
+
151
+ return prepared.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_match, tokenRaw: string) => {
152
+ const token = String(tokenRaw).trim();
153
+ if (token.includes("|") || token.includes("%")) return _match;
154
+ return fallbackFromToken(token);
155
+ });
156
+ }
157
+
158
+ function renderWorkflowMermaidTemplate(
159
+ templateContent: string,
160
+ normalized: NormalizedBrief,
161
+ coverage: ContractCoverage,
162
+ lang: string
163
+ ): string {
164
+ const primaryFeatureId = coverage.core_features[0] ?? normalized.contracts.core_features[0]?.id ?? "F1";
165
+ const primaryFeatureText =
166
+ normalized.contracts.core_features.find((item) => item.id === primaryFeatureId)?.text ??
167
+ normalized.contracts.core_features[0]?.text ??
168
+ t("user_action", lang);
169
+
170
+ return replaceTemplateTokens(
171
+ templateContent,
172
+ {
173
+ "Flow Name": t("main_flow", lang),
174
+ "Primary Actor": normalized.audience[0] ?? t("user", lang),
175
+ "Primary Action": `[${primaryFeatureId}] ${primaryFeatureText}`,
176
+ "Success State": t("success", lang),
177
+ "Error State": t("error", lang)
178
+ },
179
+ (token) => {
180
+ const key = token.toLowerCase();
181
+ if (key.includes("actor") || key.includes("user")) return normalized.audience[0] ?? t("user", lang);
182
+ if (key.includes("action") || key.includes("feature")) return `[${primaryFeatureId}] ${primaryFeatureText}`;
183
+ if (key.includes("success")) return t("success", lang);
184
+ if (key.includes("error") || key.includes("fail")) return t("error", lang);
185
+ if (key.includes("flow")) return t("main_flow", lang);
186
+ return token;
187
+ }
188
+ );
189
+ }
190
+
191
+ function normalizeAuthor(author: string | undefined): string | undefined {
192
+ if (!author) return undefined;
193
+ const normalized = author.trim();
194
+ return normalized.length > 0 ? normalized : undefined;
195
+ }
196
+
197
+ function replaceAuthorPlaceholders(body: string, author: string | undefined): string {
198
+ const safeAuthor = normalizeAuthor(author);
199
+ if (!safeAuthor) return body;
200
+ return body.replace(/\{\{\s*author\s*\}\}/gi, safeAuthor);
201
+ }
202
+
203
+ function todayYmd(): string {
204
+ const now = new Date();
205
+ const y = now.getFullYear();
206
+ const m = String(now.getMonth() + 1).padStart(2, "0");
207
+ const d = String(now.getDate()).padStart(2, "0");
208
+ return `${y}-${m}-${d}`;
209
+ }
210
+
211
+ function headingKey(value: string): string {
212
+ return value
213
+ .toLowerCase()
214
+ .replace(/[^a-z0-9]+/g, " ")
215
+ .trim();
216
+ }
217
+
218
+ function defaultDocumentControlValues(
219
+ lang: string,
220
+ revisionType: "default" | "fix",
221
+ version: string,
222
+ author?: string
223
+ ): { version: string; date: string; author: string; description: string } {
224
+ const safeAuthor = normalizeAuthor(author) ?? "Prodo";
225
+ const description = revisionType === "fix"
226
+ ? t("fix_revision", lang)
227
+ : t("initial_version", lang);
228
+ return {
229
+ version,
230
+ date: todayYmd(),
231
+ author: safeAuthor,
232
+ description
233
+ };
234
+ }
235
+
236
+ function applyDocumentControlDefaults(
237
+ body: string,
238
+ options: { lang: string; revisionType: "default" | "fix"; version: string; author?: string }
239
+ ): string {
240
+ const defaults = defaultDocumentControlValues(options.lang, options.revisionType, options.version, options.author);
241
+ let out = body
242
+ .replace(/\{\{\s*date\s*\}\}/gi, defaults.date)
243
+ .replace(/\{\{\s*description\s*\}\}/gi, defaults.description)
244
+ .replace(/\{\{\s*version\s*\}\}/gi, defaults.version);
245
+
246
+ const lines = out.split(/\r?\n/);
247
+ const headingIndex = lines.findIndex((line) => {
248
+ const match = line.match(/^\s*##+\s+(.+?)\s*$/);
249
+ if (!match) return false;
250
+ const key = headingKey(match[1]);
251
+ return key.includes("document control") || key.includes("belge kontrol");
252
+ });
253
+ if (headingIndex === -1) return out;
254
+
255
+ const row = `| ${defaults.version} | ${defaults.date} | ${defaults.author} | ${defaults.description} |`;
256
+ let tableSeparatorIndex = -1;
257
+ let tableDataIndex = -1;
258
+
259
+ for (let i = headingIndex + 1; i < lines.length; i += 1) {
260
+ if (/^\s*##+\s+/.test(lines[i])) break;
261
+ if (tableSeparatorIndex === -1 && /\|/.test(lines[i]) && /-/.test(lines[i])) {
262
+ tableSeparatorIndex = i;
263
+ continue;
264
+ }
265
+ if (tableSeparatorIndex !== -1 && /^\s*\|/.test(lines[i])) {
266
+ tableDataIndex = i;
267
+ break;
268
+ }
269
+ }
270
+
271
+ if (tableDataIndex !== -1) {
272
+ lines[tableDataIndex] = row;
273
+ } else if (tableSeparatorIndex !== -1) {
274
+ lines.splice(tableSeparatorIndex + 1, 0, row);
275
+ } else {
276
+ lines.splice(headingIndex + 1, 0, "", "| Version | Date | Author | Description |", "|--------|------|--------|-------------|", row, "");
277
+ }
278
+
279
+ out = lines.join("\n");
280
+ return out;
281
+ }
282
+
283
+ function parseVersionToken(input: string): { major: number; minor: number } | null {
284
+ const match = input.match(/v?\s*(\d+)(?:\.(\d+))?/i);
285
+ if (!match) return null;
286
+ const major = Number(match[1]);
287
+ const minor = Number(match[2] ?? "0");
288
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
289
+ return { major, minor };
290
+ }
291
+
292
+ function extractDocumentControlVersion(body: string): string | undefined {
293
+ const tableMatch = body.match(/\|\s*(v?\d+(?:\.\d+)?)\s*\|/i);
294
+ if (tableMatch?.[1]) return tableMatch[1].trim().startsWith("v") ? tableMatch[1].trim() : `v${tableMatch[1].trim()}`;
295
+ const looseMatch = body.match(/\bv?\d+\.\d+\b/i);
296
+ if (looseMatch?.[0]) return looseMatch[0].startsWith("v") ? looseMatch[0] : `v${looseMatch[0]}`;
297
+ return undefined;
298
+ }
299
+
300
+ async function resolveDocumentControlVersion(
301
+ cwd: string,
302
+ artifactType: ArtifactType,
303
+ revisionType: "default" | "fix"
304
+ ): Promise<string> {
305
+ if (revisionType !== "fix") return "v1.0";
306
+
307
+ const activePath = await getActiveArtifactPath(cwd, artifactType);
308
+ const fallbackPath = activePath ?? (await loadLatestArtifactPath(cwd, artifactType));
309
+ if (!fallbackPath || !(await fileExists(fallbackPath))) {
310
+ return "v1.1";
311
+ }
312
+
313
+ try {
314
+ const previous = await loadArtifactDoc(fallbackPath);
315
+ const previousVersion = extractDocumentControlVersion(previous.body) ?? String(previous.frontmatter.version ?? "");
316
+ const parsed = parseVersionToken(previousVersion);
317
+ if (!parsed) return "v1.1";
318
+ return `v${parsed.major}.${parsed.minor + 1}`;
319
+ } catch {
320
+ return "v1.1";
321
+ }
322
+ }
323
+
324
+ function enforceAuthorInControlTables(body: string, author: string | undefined): string {
325
+ const safeAuthor = normalizeAuthor(author);
326
+ if (!safeAuthor) return body;
327
+ return body.replace(
328
+ /(\|\s*v?[0-9.]+\s*\|\s*[^|]*\|\s*)([^|]*)(\|\s*[^|]*\|)/gi,
329
+ (_match, left: string, _current: string, right: string) => `${left}${safeAuthor} ${right}`
330
+ );
331
+ }
332
+
333
+ async function resolveUniqueOutputPath(targetDir: string, fileName: string): Promise<string> {
334
+ const parsed = path.parse(fileName);
335
+ let candidate = path.join(targetDir, fileName);
336
+ let index = 2;
337
+ while (await fileExists(candidate)) {
338
+ candidate = path.join(targetDir, `${parsed.name}-${String(index).padStart(2, "0")}${parsed.ext}`);
339
+ index += 1;
340
+ }
341
+ return candidate;
342
+ }
343
+
344
+ function workflowFeatureTargets(
345
+ normalized: NormalizedBrief,
346
+ coverage: ContractCoverage
347
+ ): Array<{ id: string; text: string }> {
348
+ const byId = new Map(normalized.contracts.core_features.map((item) => [item.id, item]));
349
+ const explicit = coverage.core_features
350
+ .map((id) => byId.get(id))
351
+ .filter((item): item is { id: string; text: string } => Boolean(item));
352
+
353
+ if (explicit.length > 1) return explicit;
354
+ if (normalized.contracts.core_features.length > 1) return normalized.contracts.core_features.slice(0, 6);
355
+ if (explicit.length === 1) return explicit;
356
+ return normalized.contracts.core_features.slice(0, 1);
357
+ }
358
+
359
+ function renderWorkflowMarkdownForFeature(
360
+ markdown: string,
361
+ feature: { id: string; text: string },
362
+ lang: string
363
+ ): string {
364
+ const tr = lang.toLowerCase().startsWith("tr");
365
+ const noteHeading = "## " + t("flow_focus", lang);
366
+ const noteLine = tr
367
+ ? `- [${feature.id}] Bu akis ${feature.text} ihtiyacina odaklanir.`
368
+ : `- [${feature.id}] This flow focuses on ${feature.text}.`;
369
+ if (markdown.includes(noteHeading)) return markdown;
370
+ return `${markdown.trim()}\n\n${noteHeading}\n${noteLine}`.trim();
371
+ }
372
+
373
+ async function resolvePrompt(
374
+ cwd: string,
375
+ artifactType: ArtifactType,
376
+ templateContent: string,
377
+ requiredHeadings: string[],
378
+ companionTemplate: { path: string; content: string } | null,
379
+ outputAuthor: string | undefined,
380
+ agent?: string
381
+ ): Promise<string> {
382
+ const base = await fs.readFile(promptPath(cwd, artifactType), "utf8");
383
+ const authority = `Template authority (STRICT):
384
+ - Treat this template as the single output structure source.
385
+ - Keep heading order and names exactly as listed.
386
+ - Do not invent new primary sections.
387
+
388
+ Required headings (from template):
389
+ ${requiredHeadings.map((heading) => `- ${heading}`).join("\n")}
390
+
391
+ Resolved template:
392
+ \`\`\`md
393
+ ${templateContent.trim()}
394
+ \`\`\``;
395
+ const companionAuthority = companionTemplate
396
+ ? `Native companion template (STRICT reference):
397
+ - Path: ${companionTemplate.path}
398
+ - Preserve this native format and structure when generating companion artifact.
399
+ \`\`\`${artifactType === "workflow" ? "mermaid" : "html"}
400
+ ${companionTemplate.content.trim()}
401
+ \`\`\``
402
+ : "";
403
+ const workflowPairing =
404
+ artifactType === "workflow"
405
+ ? `
406
+ Workflow paired output contract (STRICT):
407
+ - Output markdown explanation first (template headings).
408
+ - Then append a mermaid block for the same flow:
409
+ \`\`\`mermaid
410
+ flowchart TD
411
+ ...
412
+ \`\`\`
413
+ - Mermaid block is mandatory.`
414
+ : "";
415
+ const wireframePairing =
416
+ artifactType === "wireframe"
417
+ ? `
418
+ Wireframe paired output contract (STRICT):
419
+ - Output markdown explanation first (template headings).
420
+ - Generate companion HTML screens based on native wireframe template.
421
+ - HTML must stay low-fidelity and structure-first.`
422
+ : "";
423
+ const authorPolicy = outputAuthor && outputAuthor.trim().length > 0
424
+ ? `
425
+ Author policy (STRICT):
426
+ - Use this exact author name wherever author is required: ${outputAuthor.trim()}
427
+ - Do not invent random author names.`
428
+ : "";
429
+ const withTemplate = `${base}
430
+
431
+ ${authority}
432
+ ${companionAuthority}
433
+ ${workflowPairing}
434
+ ${wireframePairing}
435
+ ${authorPolicy}`;
436
+ if (!agent) return withTemplate;
437
+ return `${withTemplate}
438
+
439
+ Agent execution profile: ${agent}
440
+ - Keep output deterministic and actionable.`;
441
+ }
442
+
443
+ async function loadLatestArtifactPath(cwd: string, type: ArtifactType): Promise<string | undefined> {
444
+ const def = await getArtifactDefinition(cwd, type);
445
+ const active = await getActiveArtifactPath(cwd, type);
446
+ if (active) return active;
447
+ const files = await listFilesSortedByMtime(outputDirPath(cwd, type, def.output_dir));
448
+ return files[0];
449
+ }
450
+
451
+ function contextFilePath(cwd: string, artifactFile: string): string {
452
+ const base = path.parse(artifactFile).name;
453
+ return path.join(outputContextDirPath(cwd), `${base}.json`);
454
+ }
455
+
456
+ function toLineItems(value: string | undefined): string[] {
457
+ if (!value) return [];
458
+ return value
459
+ .split(/\r?\n/)
460
+ .map((line) => line.replace(/^\s*[-*0-9.]+\s*/, "").trim())
461
+ .filter((line) => line.length > 0);
462
+ }
463
+
464
+ function parseHeadingTitle(fullHeading: string): string {
465
+ return fullHeading.replace(/^##\s+/, "").trim();
466
+ }
467
+
468
+ function deriveStructuredContext(
469
+ artifactType: ArtifactType,
470
+ body: string,
471
+ requiredHeadings: string[]
472
+ ): Record<string, unknown> {
473
+ const sections = sectionTextMap(body);
474
+ const ordered = requiredHeadings
475
+ .map((heading) => ({ heading, items: toLineItems(sections.get(heading)) }))
476
+ .filter((item) => item.items.length > 0);
477
+ const section_map = Object.fromEntries(
478
+ Array.from(sections.entries()).map(([heading, text]) => [parseHeadingTitle(heading), toLineItems(text)])
479
+ );
480
+
481
+ if (artifactType === "workflow") {
482
+ return {
483
+ section_map,
484
+ actor_map: ordered[0]?.items ?? [],
485
+ step_map: ordered[1]?.items ?? [],
486
+ edge_case_map: ordered[2]?.items ?? []
487
+ };
488
+ }
489
+ if (artifactType === "wireframe") {
490
+ return {
491
+ section_map,
492
+ screen_map: ordered[0]?.items ?? [],
493
+ interaction_map: ordered[1]?.items ?? []
494
+ };
495
+ }
496
+ if (artifactType === "techspec") {
497
+ return {
498
+ section_map,
499
+ architecture_map: ordered[0]?.items ?? [],
500
+ integration_map: ordered[1]?.items ?? []
501
+ };
502
+ }
503
+ if (artifactType === "stories") {
504
+ return {
505
+ section_map,
506
+ story_map: ordered[0]?.items ?? [],
507
+ acceptance_map: ordered[1]?.items ?? []
508
+ };
509
+ }
510
+ return {
511
+ section_map,
512
+ goal_map: ordered[0]?.items ?? [],
513
+ requirement_map: ordered[1]?.items ?? []
514
+ };
515
+ }
516
+
517
+ async function buildUpstreamArtifacts(
518
+ cwd: string,
519
+ artifactType: ArtifactType,
520
+ upstreamTypes: ArtifactType[]
521
+ ): Promise<
522
+ Array<{
523
+ type: ArtifactType;
524
+ file: string;
525
+ contractCoverage: ContractCoverage;
526
+ structuredContext?: Record<string, unknown>;
527
+ }>
528
+ > {
529
+ const refs: Array<{
530
+ type: ArtifactType;
531
+ file: string;
532
+ contractCoverage: ContractCoverage;
533
+ structuredContext?: Record<string, unknown>;
534
+ }> = [];
535
+ for (const type of upstreamTypes) {
536
+ const latest = await loadLatestArtifactPath(cwd, type);
537
+ if (!latest) continue;
538
+ const parsed = await loadArtifactDoc(latest);
539
+ const frontmatter = parsed.frontmatter;
540
+ const coverageRaw = frontmatter.contract_coverage as Partial<ContractCoverage> | undefined;
541
+ const contextPath = contextFilePath(cwd, latest);
542
+ const structuredContext = (await fileExists(contextPath))
543
+ ? await readJsonFile<Record<string, unknown>>(contextPath)
544
+ : {};
545
+ refs.push({
546
+ type,
547
+ file: latest,
548
+ contractCoverage: {
549
+ goals: Array.isArray(coverageRaw?.goals)
550
+ ? coverageRaw.goals.filter((item): item is string => typeof item === "string")
551
+ : [],
552
+ core_features: Array.isArray(coverageRaw?.core_features)
553
+ ? coverageRaw.core_features.filter((item): item is string => typeof item === "string")
554
+ : [],
555
+ constraints: Array.isArray(coverageRaw?.constraints)
556
+ ? coverageRaw.constraints.filter((item): item is string => typeof item === "string")
557
+ : []
558
+ },
559
+ ...(Object.keys(structuredContext).length > 0 ? { structuredContext } : {})
560
+ });
561
+ }
562
+ return refs;
563
+ }
564
+
565
+ function extractCoverageFromBody(body: string): ContractCoverage {
566
+ const tagged = {
567
+ goals: Array.from(new Set(body.match(/\[(G[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? [])),
568
+ core_features: Array.from(new Set(body.match(/\[(F[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? [])),
569
+ constraints: Array.from(new Set(body.match(/\[(C[0-9]+)\]/g)?.map((item) => item.slice(1, -1)) ?? []))
570
+ };
571
+ return tagged;
572
+ }
573
+
574
+ function missingCoverage(
575
+ requiredContracts: Array<keyof ContractCoverage>,
576
+ normalized: NormalizedBrief,
577
+ coverage: ContractCoverage
578
+ ): Array<{ key: keyof ContractCoverage; ids: string[] }> {
579
+ const ids = contractIds(normalized.contracts);
580
+ const missing: Array<{ key: keyof ContractCoverage; ids: string[] }> = [];
581
+
582
+ for (const key of requiredContracts) {
583
+ const expected = ids[key];
584
+ if (expected.length === 0) continue;
585
+ const missingIds = expected.filter((id) => !coverage[key].includes(id));
586
+ if (missingIds.length > 0) {
587
+ missing.push({ key, ids: missingIds });
588
+ }
589
+ }
590
+ return missing;
591
+ }
592
+
593
+ async function ensurePipelinePrereqs(cwd: string, normalizedPath: string): Promise<void> {
594
+ const prodoRoot = prodoPath(cwd);
595
+ if (!(await fileExists(prodoRoot))) {
596
+ throw new UserError("Missing .prodo directory. Run `prodo-init` first.");
597
+ }
598
+
599
+ if (!(await fileExists(briefPath(cwd)))) {
600
+ throw new UserError(
601
+ "Missing brief at `brief.md`. Run `prodo-init` or create the file."
602
+ );
603
+ }
604
+
605
+ if (!(await fileExists(normalizedPath))) {
606
+ throw new UserError(
607
+ "Missing normalized brief at `.prodo/briefs/normalized-brief.json`. Create it before generating artifacts."
608
+ );
609
+ }
610
+ }
611
+
612
+ function splitWorkflowPair(raw: string): { markdown: string; mermaid: string } {
613
+ const match = raw.match(/```mermaid\s*([\s\S]*?)```/i);
614
+ if (!match) {
615
+ throw new UserError(
616
+ "Workflow output is missing a Mermaid block. Regenerate with template-compliant paired output."
617
+ );
618
+ }
619
+ const mermaid = match[1].trim();
620
+ const markdown = raw.replace(match[0], "").trim();
621
+ if (!markdown) {
622
+ throw new UserError("Workflow markdown explanation is empty.");
623
+ }
624
+ if (!/(^|\n)\s*(flowchart|graph)\s+/i.test(mermaid)) {
625
+ throw new UserError("Workflow Mermaid block is invalid.");
626
+ }
627
+ return { markdown, mermaid };
628
+ }
629
+
630
+ async function writeWorkflowFlows(
631
+ targetDir: string,
632
+ baseName: string,
633
+ normalized: NormalizedBrief,
634
+ coverage: ContractCoverage,
635
+ lang: string,
636
+ markdownBody: string,
637
+ mermaidBody: string | null,
638
+ mermaidTemplateContent: string | null
639
+ ): Promise<{ primaryPath: string; summaryBody: string; rendered: Array<{ mdPath: string; body: string }> }> {
640
+ const targets = workflowFeatureTargets(normalized, coverage);
641
+ const fallbackFeature = normalized.contracts.core_features[0] ?? { id: "F1", text: "Primary flow" };
642
+ const flows = targets.length > 0 ? targets : [fallbackFeature];
643
+ const summaryBodies: string[] = [];
644
+ const renderedArtifacts: Array<{ mdPath: string; body: string }> = [];
645
+ let primaryMdPath = "";
646
+
647
+ for (const [index, flowFeature] of flows.entries()) {
648
+ const flowBase =
649
+ flows.length === 1
650
+ ? baseName
651
+ : (index === 0
652
+ ? baseName
653
+ : `${baseName}-${index + 1}-${toSlug(extractTurkishTitle(flowFeature.text))}`);
654
+ const mdPath = path.join(targetDir, `${flowBase}.md`);
655
+ const mmdPath = path.join(targetDir, `${flowBase}.mmd`);
656
+ const featureCoverage: ContractCoverage = {
657
+ ...coverage,
658
+ core_features: [flowFeature.id]
659
+ };
660
+ const renderedMarkdown = renderWorkflowMarkdownForFeature(markdownBody, flowFeature, lang);
661
+ const renderedMermaid = (mermaidTemplateContent && mermaidTemplateContent.trim().length > 0)
662
+ ? renderWorkflowMermaidTemplate(mermaidTemplateContent, normalized, featureCoverage, lang).trim()
663
+ : (mermaidBody ?? "").trim();
664
+
665
+ if (!/(^|\n)\s*(flowchart|graph)\s+/i.test(renderedMermaid)) {
666
+ throw new UserError("Workflow Mermaid output is invalid.");
667
+ }
668
+
669
+ enforceLanguage(renderedMarkdown, lang, "workflow");
670
+ enforceLanguage(renderedMermaid, lang, "workflow");
671
+ await fs.writeFile(mdPath, `${renderedMarkdown}\n`, "utf8");
672
+ await fs.writeFile(mmdPath, `${renderedMermaid}\n`, "utf8");
673
+
674
+ if (!primaryMdPath) primaryMdPath = mdPath;
675
+ summaryBodies.push(renderedMarkdown);
676
+ renderedArtifacts.push({ mdPath, body: renderedMarkdown });
677
+ }
678
+
679
+ return {
680
+ primaryPath: primaryMdPath,
681
+ summaryBody: summaryBodies.join("\n\n"),
682
+ rendered: renderedArtifacts
683
+ };
684
+ }
685
+
686
+ async function writeWireframeScreens(
687
+ targetDir: string,
688
+ baseName: string,
689
+ normalized: NormalizedBrief,
690
+ coverage: ContractCoverage,
691
+ lang: string,
692
+ headings: string[],
693
+ htmlTemplateContent: string | null
694
+ ): Promise<{ primaryPath: string; summaryBody: string }> {
695
+ const tr = lang.toLowerCase().startsWith("tr");
696
+ const explicitScreens = normalized.contracts.core_features
697
+ .filter((item) => coverage.core_features.includes(item.id))
698
+ .slice(0, 6);
699
+ const screens =
700
+ explicitScreens.length > 1
701
+ ? explicitScreens
702
+ : (normalized.contracts.core_features.length > 1
703
+ ? normalized.contracts.core_features.slice(0, 6)
704
+ : (explicitScreens.length === 1
705
+ ? explicitScreens
706
+ : normalized.contracts.core_features.slice(0, 1)));
707
+ const summaryBodies: string[] = [];
708
+ let primaryMdPath = "";
709
+ for (const [index, screen] of screens.entries()) {
710
+ const title = extractTurkishTitle(screen.text);
711
+ const screenBase = `${baseName}-${index + 1}-${toSlug(title)}`;
712
+ const htmlPath = path.join(targetDir, `${screenBase}.html`);
713
+ const mdPath = path.join(targetDir, `${screenBase}.md`);
714
+ const fallbackHtml = `<!doctype html>
715
+ <html lang="${lang}">
716
+ <head>
717
+ <meta charset="utf-8" />
718
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
719
+ <title>${title}</title>
720
+ </head>
721
+ <body>
722
+ <!-- [${screen.id}] -->
723
+ <header>
724
+ <h1>${title}</h1>
725
+ <nav><button type="button">${t("back", lang)}</button><button type="button">${t("next", lang)}</button></nav>
726
+ </header>
727
+ <main>
728
+ <section>
729
+ <h2>${t("content", lang)}</h2>
730
+ <ul>
731
+ <li>${t("primary_info_area", lang)}</li>
732
+ <li>${t("status_indicator", lang)}</li>
733
+ </ul>
734
+ </section>
735
+ <section>
736
+ <h2>${t("form", lang)}</h2>
737
+ <form>
738
+ <label>${t("field", lang)}
739
+ <input type="text" name="field_${index + 1}" />
740
+ </label>
741
+ <button type="submit">${t("save", lang)}</button>
742
+ </form>
743
+ </section>
744
+ </main>
745
+ </body>
746
+ </html>`;
747
+ const htmlTemplate = htmlTemplateContent && htmlTemplateContent.trim().length > 0 ? htmlTemplateContent : fallbackHtml;
748
+ const html = replaceTemplateTokens(
749
+ htmlTemplate,
750
+ {
751
+ "Screen Title": title,
752
+ "Primary Action": t("save", lang),
753
+ "Description Label": t("description", lang),
754
+ "Description Placeholder": `[${screen.id}] ${screen.text}`,
755
+ "Meta Label 1": t("contract", lang),
756
+ "Meta Value 1": screen.id,
757
+ "Meta Label 2": t("actor", lang),
758
+ "Meta Value 2": normalized.audience[0] ?? t("user", lang),
759
+ "Field Label": t("field", lang),
760
+ "Detailed Input Area": t("detailed_input_area", lang),
761
+ "Upload / Attachment Area": t("upload_area", lang),
762
+ "Allowed file types / notes": t("low_fidelity_wireframe", lang),
763
+ "Consent / confirmation text": t("confirmation_text", lang)
764
+ },
765
+ (token) => {
766
+ const key = token.toLowerCase();
767
+ if (key.includes("screen") || key.includes("title")) return title;
768
+ if (key.includes("action") || key.includes("button")) return t("save", lang);
769
+ if (key.includes("field")) return t("field", lang);
770
+ if (key.includes("description") || key.includes("summary")) return `[${screen.id}] ${screen.text}`;
771
+ if (key.includes("actor") || key.includes("user")) return normalized.audience[0] ?? t("user", lang);
772
+ if (key.includes("logo")) return "[ LOGO ]";
773
+ return token;
774
+ }
775
+ );
776
+ enforceLanguage(html, lang, "wireframe");
777
+ await fs.writeFile(htmlPath, html, "utf8");
778
+ const defaultMap = {
779
+ purpose: [`- [${screen.id}] ${screen.text}`],
780
+ actor: [`- ${(normalized.audience[0] ?? t("primary_user", lang))}`],
781
+ sections: [
782
+ `- ${t("header_and_navigation", lang)}`,
783
+ `- ${t("content_section", lang)}`,
784
+ `- ${t("form_section", lang)}`
785
+ ],
786
+ fields: [`- ${t("text_input", lang)} (field_${index + 1})`],
787
+ actions: [`- ${t("back", lang)}`, `- ${t("next", lang)}`, `- ${t("save", lang)}`],
788
+ states: [`- ${tr ? "Bos durum, yukleniyor, hata, basari" : "Empty, loading, error, success states"}`],
789
+ notes: [`- ${tr ? "Dusuk sadakatli tel kafes taslaktir." : "Low-fidelity black-and-white wireframe mock."}`]
790
+ };
791
+ const fallbackQueue = [
792
+ ...defaultMap.purpose,
793
+ ...defaultMap.actor,
794
+ ...defaultMap.sections,
795
+ ...defaultMap.fields,
796
+ ...defaultMap.actions,
797
+ ...defaultMap.states,
798
+ ...defaultMap.notes
799
+ ];
800
+ const consumeFallback = () => (fallbackQueue.shift() ?? `- ${tr ? "Detay bekleniyor." : "Detail pending."}`);
801
+ const contentForHeading = (heading: string): string[] => {
802
+ const key = heading.toLowerCase();
803
+ if (/(screen purpose|purpose|amac|hedef)/.test(key)) return defaultMap.purpose;
804
+ if (/(primary actor|actor|user|kullanici|rol)/.test(key)) return defaultMap.actor;
805
+ if (/(main section|section|bolum|layout)/.test(key)) return defaultMap.sections;
806
+ if (/(field|input|form|alan)/.test(key)) return defaultMap.fields;
807
+ if (/(action|button|cta|aksiyon)/.test(key)) return defaultMap.actions;
808
+ if (/(state|message|durum|mesaj)/.test(key)) return defaultMap.states;
809
+ if (/(note|not|aciklama)/.test(key)) return defaultMap.notes;
810
+ return [consumeFallback()];
811
+ };
812
+ const targetHeadings = headings.length > 0 ? headings : defaultRequiredHeadings("wireframe");
813
+ const mdLines = [`# ${title}`, ""];
814
+ for (const heading of targetHeadings) {
815
+ mdLines.push(heading);
816
+ mdLines.push(...contentForHeading(heading));
817
+ mdLines.push("");
818
+ }
819
+ const mdBody = mdLines.join("\n").trim();
820
+ enforceLanguage(mdBody, lang, "wireframe");
821
+ await fs.writeFile(mdPath, `${mdBody}\n`, "utf8");
822
+ if (!primaryMdPath) primaryMdPath = mdPath;
823
+ summaryBodies.push(mdBody);
824
+ }
825
+ return {
826
+ primaryPath: primaryMdPath,
827
+ summaryBody: summaryBodies.join("\n\n")
828
+ };
829
+ }
830
+
831
+ export async function generateArtifact(options: GenerateOptions): Promise<string> {
832
+ const { cwd, artifactType, outPath, agent } = options;
833
+ const revisionType = options.revisionType ?? "default";
834
+ const def = await getArtifactDefinition(cwd, artifactType);
835
+ const normalizedPath = options.normalizedBriefOverride ?? normalizedBriefPath(cwd);
836
+ await ensurePipelinePrereqs(cwd, normalizedPath);
837
+ const documentControlVersion = await resolveDocumentControlVersion(cwd, artifactType, revisionType);
838
+
839
+ const settings = await readSettings(cwd);
840
+ const normalizedBriefRaw = await readJsonFile<Record<string, unknown>>(normalizedPath);
841
+ const normalizedBrief = parseNormalizedBriefOrThrow(normalizedBriefRaw);
842
+ const template = await resolveTemplate({ cwd, artifactType });
843
+ const companionTemplate = await resolveCompanionTemplate({ cwd, artifactType });
844
+ if (!template || template.content.trim().length === 0) {
845
+ throw new UserError(
846
+ `Missing ${artifactType} template. Create \`.prodo/templates/${artifactType}.md\` before running \`prodo-${artifactType}\`.`
847
+ );
848
+ }
849
+ if (artifactType === "workflow" && !companionTemplate) {
850
+ throw new UserError(
851
+ "Missing workflow companion template. Create `.prodo/templates/workflow.mmd` before running `prodo-workflow`."
852
+ );
853
+ }
854
+ if (artifactType === "wireframe" && !companionTemplate) {
855
+ throw new UserError(
856
+ "Missing wireframe companion template. Create `.prodo/templates/wireframe.html` before running `prodo-wireframe`."
857
+ );
858
+ }
859
+ const templateHeadings =
860
+ template && template.content.trim().length > 0 ? extractRequiredHeadingsFromTemplate(template.content) : [];
861
+ if (templateHeadings.length === 0) {
862
+ throw new UserError(
863
+ `${artifactType} template has no extractable headings. Add markdown headings to \`${template.path}\`.`
864
+ );
865
+ }
866
+ const computedHeadings = templateHeadings.length > 0
867
+ ? templateHeadings
868
+ : (def.required_headings.length > 0 ? def.required_headings : defaultRequiredHeadings(artifactType));
869
+ const prompt = await resolvePrompt(
870
+ cwd,
871
+ artifactType,
872
+ template?.content ?? "",
873
+ computedHeadings,
874
+ companionTemplate,
875
+ settings.author,
876
+ agent
877
+ );
878
+ const provider = createProvider();
879
+ const upstreamArtifacts = await buildUpstreamArtifacts(cwd, artifactType, def.upstream);
880
+ const schemaHint = {
881
+ artifactType,
882
+ requiredHeadings: computedHeadings,
883
+ requiredContracts: def.required_contracts
884
+ };
885
+
886
+ const generated = await provider.generate(
887
+ prompt,
888
+ {
889
+ normalizedBrief,
890
+ upstreamArtifacts,
891
+ contractCatalog: normalizedBrief.contracts,
892
+ templateContent: template?.content ?? "",
893
+ templatePath: template?.path ?? "",
894
+ companionTemplateContent: companionTemplate?.content ?? "",
895
+ companionTemplatePath: companionTemplate?.path ?? "",
896
+ outputLanguage: settings.lang,
897
+ outputAuthor: settings.author
898
+ },
899
+ schemaHint
900
+ );
901
+
902
+ let generatedBody = enforceAuthorInControlTables(
903
+ replaceAuthorPlaceholders(generated.body.trim(), settings.author),
904
+ settings.author
905
+ );
906
+ let workflowMermaidBody: string | null = null;
907
+ if (artifactType === "workflow") {
908
+ const paired = splitWorkflowPair(generatedBody);
909
+ generatedBody = enforceAuthorInControlTables(
910
+ replaceAuthorPlaceholders(paired.markdown, settings.author),
911
+ settings.author
912
+ );
913
+ workflowMermaidBody = replaceAuthorPlaceholders(paired.mermaid, settings.author);
914
+ }
915
+ let contractCoverage = extractCoverageFromBody(generatedBody);
916
+
917
+ if (artifactType === "workflow") {
918
+ if (contractCoverage.core_features.length === 0) {
919
+ contractCoverage = {
920
+ ...contractCoverage,
921
+ core_features: normalizedBrief.contracts.core_features.map((item) => item.id)
922
+ };
923
+ }
924
+ }
925
+
926
+ if (artifactType === "wireframe") {
927
+ if (contractCoverage.core_features.length === 0) {
928
+ contractCoverage = {
929
+ ...contractCoverage,
930
+ core_features: normalizedBrief.contracts.core_features.map((item) => item.id)
931
+ };
932
+ }
933
+ }
934
+
935
+ generatedBody = applyDocumentControlDefaults(generatedBody, {
936
+ lang: settings.lang,
937
+ revisionType,
938
+ version: documentControlVersion,
939
+ author: settings.author
940
+ });
941
+
942
+ if (artifactType === "workflow" && companionTemplate?.content) {
943
+ workflowMermaidBody = renderWorkflowMermaidTemplate(
944
+ companionTemplate.content,
945
+ normalizedBrief,
946
+ contractCoverage,
947
+ settings.lang
948
+ ).trim();
949
+ workflowMermaidBody = replaceAuthorPlaceholders(workflowMermaidBody, settings.author);
950
+ }
951
+
952
+ enforceLanguage(generatedBody, settings.lang, artifactType);
953
+ if (artifactType === "workflow" && workflowMermaidBody) {
954
+ enforceLanguage(workflowMermaidBody, settings.lang, artifactType);
955
+ }
956
+ const uncovered = missingCoverage(def.required_contracts, normalizedBrief, contractCoverage);
957
+ if (uncovered.length > 0) {
958
+ const lines = uncovered
959
+ .map((item) => `- ${item.key}: missing ${item.ids.join(", ")}`)
960
+ .join("\n");
961
+ throw new UserError(
962
+ `Artifact is missing required contract references. Add ID tags to body:\n${lines}\nExample tags: [G1], [F2], [C1].`
963
+ );
964
+ }
965
+
966
+ const frontmatter = {
967
+ artifact_type: artifactType,
968
+ version: timestampSlug(),
969
+ source_brief: path.resolve(normalizedPath),
970
+ generated_at: new Date().toISOString(),
971
+ status: DEFAULT_STATUS,
972
+ upstream_artifacts: upstreamArtifacts.map((item) => item.file),
973
+ contract_coverage: contractCoverage,
974
+ language: settings.lang,
975
+ ...(normalizeAuthor(settings.author) ? { author: normalizeAuthor(settings.author) } : {})
976
+ } as Record<string, unknown>;
977
+
978
+ const mergedFrontmatter = { ...frontmatter, ...(generated.frontmatter ?? {}) };
979
+ if (normalizeAuthor(settings.author)) {
980
+ mergedFrontmatter.author = normalizeAuthor(settings.author);
981
+ }
982
+ let doc: ArtifactDoc = {
983
+ frontmatter: mergedFrontmatter,
984
+ body: generatedBody
985
+ };
986
+
987
+ const validation = await validateSchema(cwd, artifactType, doc, schemaHint.requiredHeadings);
988
+ const schemaErrors = validation.issues.filter((issue) => issue.level === "error");
989
+ if (schemaErrors.length > 0) {
990
+ const details = schemaErrors.map((issue) => `- ${issue.message}`).join("\n");
991
+ throw new UserError(`Artifact failed schema checks:\n${details}`);
992
+ }
993
+
994
+ const targetDir = outputDirPath(cwd, artifactType, def.output_dir);
995
+ const finalPath = outPath
996
+ ? path.resolve(cwd, outPath)
997
+ : await resolveUniqueOutputPath(targetDir, defaultFilename(artifactType));
998
+ if (!isPathInside(path.join(cwd, "product-docs"), finalPath)) {
999
+ throw new UserError("Artifact output must be inside `product-docs/`.");
1000
+ }
1001
+ await fs.mkdir(path.dirname(finalPath), { recursive: true });
1002
+ if (artifactType === "workflow") {
1003
+ const basePath = path.join(path.dirname(finalPath), path.parse(finalPath).name);
1004
+ const workflow = await writeWorkflowFlows(
1005
+ path.dirname(basePath),
1006
+ path.parse(basePath).name,
1007
+ normalizedBrief,
1008
+ contractCoverage,
1009
+ settings.lang,
1010
+ doc.body,
1011
+ workflowMermaidBody,
1012
+ companionTemplate?.content ?? null
1013
+ );
1014
+ await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
1015
+ for (const rendered of workflow.rendered) {
1016
+ const renderedDoc: ArtifactDoc = {
1017
+ frontmatter: doc.frontmatter,
1018
+ body: rendered.body
1019
+ };
1020
+ await fs.writeFile(rendered.mdPath, matter.stringify(renderedDoc.body, renderedDoc.frontmatter), "utf8");
1021
+ await writeSidecar(rendered.mdPath, renderedDoc);
1022
+ const renderedContext = {
1023
+ artifact_type: artifactType,
1024
+ artifact_file: rendered.mdPath,
1025
+ generated_at: new Date().toISOString(),
1026
+ contract_coverage: contractCoverage,
1027
+ ...deriveStructuredContext(artifactType, renderedDoc.body, schemaHint.requiredHeadings)
1028
+ };
1029
+ await fs.writeFile(contextFilePath(cwd, rendered.mdPath), `${JSON.stringify(renderedContext, null, 2)}\n`, "utf8");
1030
+ }
1031
+ const primaryRendered = workflow.rendered.find((item) => item.mdPath === workflow.primaryPath) ?? workflow.rendered[0];
1032
+ doc = {
1033
+ frontmatter: doc.frontmatter,
1034
+ body: primaryRendered?.body ?? doc.body
1035
+ };
1036
+ await setActiveArtifact(cwd, artifactType, workflow.primaryPath);
1037
+ return workflow.primaryPath;
1038
+ } else if (artifactType === "wireframe") {
1039
+ const base = path.parse(finalPath).name;
1040
+ const wireframe = await writeWireframeScreens(
1041
+ path.dirname(finalPath),
1042
+ base,
1043
+ normalizedBrief,
1044
+ contractCoverage,
1045
+ settings.lang,
1046
+ schemaHint.requiredHeadings,
1047
+ companionTemplate?.content ?? null
1048
+ );
1049
+ doc = {
1050
+ frontmatter: doc.frontmatter,
1051
+ body: wireframe.summaryBody
1052
+ };
1053
+ await writeSidecar(wireframe.primaryPath, doc);
1054
+ const derivedContext = {
1055
+ artifact_type: artifactType,
1056
+ artifact_file: wireframe.primaryPath,
1057
+ generated_at: new Date().toISOString(),
1058
+ contract_coverage: contractCoverage,
1059
+ ...deriveStructuredContext(artifactType, doc.body, schemaHint.requiredHeadings)
1060
+ };
1061
+ await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
1062
+ await fs.writeFile(contextFilePath(cwd, wireframe.primaryPath), `${JSON.stringify(derivedContext, null, 2)}\n`, "utf8");
1063
+ await setActiveArtifact(cwd, artifactType, wireframe.primaryPath);
1064
+ return wireframe.primaryPath;
1065
+ } else {
1066
+ const content = matter.stringify(doc.body, doc.frontmatter);
1067
+ await fs.writeFile(finalPath, content, "utf8");
1068
+ }
1069
+ await writeSidecar(finalPath, doc);
1070
+ const derivedContext = {
1071
+ artifact_type: artifactType,
1072
+ artifact_file: finalPath,
1073
+ generated_at: new Date().toISOString(),
1074
+ contract_coverage: contractCoverage,
1075
+ ...deriveStructuredContext(artifactType, doc.body, schemaHint.requiredHeadings)
1076
+ };
1077
+ await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
1078
+ await fs.writeFile(contextFilePath(cwd, finalPath), `${JSON.stringify(derivedContext, null, 2)}\n`, "utf8");
1079
+ await setActiveArtifact(cwd, artifactType, finalPath);
1080
+ return finalPath;
1081
+ }