@shahmarasy/prodo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/bin/prodo.cjs +6 -0
  4. package/dist/agent-command-installer.d.ts +4 -0
  5. package/dist/agent-command-installer.js +158 -0
  6. package/dist/agents.d.ts +15 -0
  7. package/dist/agents.js +47 -0
  8. package/dist/artifact-registry.d.ts +11 -0
  9. package/dist/artifact-registry.js +49 -0
  10. package/dist/artifacts.d.ts +9 -0
  11. package/dist/artifacts.js +514 -0
  12. package/dist/cli.d.ts +9 -0
  13. package/dist/cli.js +305 -0
  14. package/dist/consistency.d.ts +8 -0
  15. package/dist/consistency.js +268 -0
  16. package/dist/constants.d.ts +7 -0
  17. package/dist/constants.js +64 -0
  18. package/dist/doctor.d.ts +1 -0
  19. package/dist/doctor.js +123 -0
  20. package/dist/errors.d.ts +3 -0
  21. package/dist/errors.js +10 -0
  22. package/dist/hook-executor.d.ts +1 -0
  23. package/dist/hook-executor.js +175 -0
  24. package/dist/init-tui.d.ts +21 -0
  25. package/dist/init-tui.js +161 -0
  26. package/dist/init.d.ts +10 -0
  27. package/dist/init.js +307 -0
  28. package/dist/markdown.d.ts +11 -0
  29. package/dist/markdown.js +66 -0
  30. package/dist/normalize.d.ts +7 -0
  31. package/dist/normalize.js +73 -0
  32. package/dist/normalized-brief.d.ts +39 -0
  33. package/dist/normalized-brief.js +170 -0
  34. package/dist/output-index.d.ts +13 -0
  35. package/dist/output-index.js +55 -0
  36. package/dist/paths.d.ts +16 -0
  37. package/dist/paths.js +76 -0
  38. package/dist/preset-loader.d.ts +4 -0
  39. package/dist/preset-loader.js +210 -0
  40. package/dist/project-config.d.ts +14 -0
  41. package/dist/project-config.js +69 -0
  42. package/dist/providers/index.d.ts +2 -0
  43. package/dist/providers/index.js +12 -0
  44. package/dist/providers/mock-provider.d.ts +7 -0
  45. package/dist/providers/mock-provider.js +168 -0
  46. package/dist/providers/openai-provider.d.ts +11 -0
  47. package/dist/providers/openai-provider.js +69 -0
  48. package/dist/registry.d.ts +13 -0
  49. package/dist/registry.js +115 -0
  50. package/dist/settings.d.ts +6 -0
  51. package/dist/settings.js +34 -0
  52. package/dist/template-resolver.d.ts +11 -0
  53. package/dist/template-resolver.js +28 -0
  54. package/dist/templates.d.ts +33 -0
  55. package/dist/templates.js +428 -0
  56. package/dist/types.d.ts +35 -0
  57. package/dist/types.js +5 -0
  58. package/dist/utils.d.ts +6 -0
  59. package/dist/utils.js +53 -0
  60. package/dist/validate.d.ts +9 -0
  61. package/dist/validate.js +226 -0
  62. package/dist/validator.d.ts +5 -0
  63. package/dist/validator.js +80 -0
  64. package/dist/version.d.ts +1 -0
  65. package/dist/version.js +30 -0
  66. package/dist/workflow-commands.d.ts +7 -0
  67. package/dist/workflow-commands.js +28 -0
  68. package/package.json +45 -0
  69. package/presets/fintech/preset.json +1 -0
  70. package/presets/fintech/prompts/prd.md +3 -0
  71. package/presets/marketplace/preset.json +1 -0
  72. package/presets/marketplace/prompts/prd.md +3 -0
  73. package/presets/saas/preset.json +1 -0
  74. package/presets/saas/prompts/prd.md +3 -0
  75. package/src/agent-command-installer.ts +174 -0
  76. package/src/agents.ts +56 -0
  77. package/src/artifact-registry.ts +69 -0
  78. package/src/artifacts.ts +606 -0
  79. package/src/cli.ts +322 -0
  80. package/src/consistency.ts +303 -0
  81. package/src/constants.ts +72 -0
  82. package/src/doctor.ts +137 -0
  83. package/src/errors.ts +7 -0
  84. package/src/hook-executor.ts +196 -0
  85. package/src/init-tui.ts +193 -0
  86. package/src/init.ts +375 -0
  87. package/src/markdown.ts +73 -0
  88. package/src/normalize.ts +89 -0
  89. package/src/normalized-brief.ts +206 -0
  90. package/src/output-index.ts +59 -0
  91. package/src/paths.ts +72 -0
  92. package/src/preset-loader.ts +237 -0
  93. package/src/project-config.ts +78 -0
  94. package/src/providers/index.ts +12 -0
  95. package/src/providers/mock-provider.ts +188 -0
  96. package/src/providers/openai-provider.ts +87 -0
  97. package/src/registry.ts +119 -0
  98. package/src/settings.ts +34 -0
  99. package/src/template-resolver.ts +33 -0
  100. package/src/templates.ts +440 -0
  101. package/src/types.ts +46 -0
  102. package/src/utils.ts +50 -0
  103. package/src/validate.ts +246 -0
  104. package/src/validator.ts +96 -0
  105. package/src/version.ts +24 -0
  106. package/src/workflow-commands.ts +31 -0
  107. package/templates/artifacts/prd.md +219 -0
  108. package/templates/artifacts/stories.md +49 -0
  109. package/templates/artifacts/techspec.md +42 -0
  110. package/templates/artifacts/wireframe.html +260 -0
  111. package/templates/artifacts/wireframe.md +22 -0
  112. package/templates/artifacts/workflow.md +22 -0
  113. package/templates/artifacts/workflow.mmd +6 -0
  114. package/templates/commands/prodo-normalize.md +24 -0
  115. package/templates/commands/prodo-prd.md +24 -0
  116. package/templates/commands/prodo-stories.md +24 -0
  117. package/templates/commands/prodo-techspec.md +24 -0
  118. package/templates/commands/prodo-validate.md +24 -0
  119. package/templates/commands/prodo-wireframe.md +24 -0
  120. package/templates/commands/prodo-workflow.md +24 -0
package/src/init.ts ADDED
@@ -0,0 +1,375 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import yaml from "js-yaml";
5
+ import { installAgentCommands, type SupportedAi } from "./agent-command-installer";
6
+ import { listArtifactDefinitions } from "./artifact-registry";
7
+ import { ensureDir, fileExists } from "./utils";
8
+ import { briefPath, outputDirPath, outputIndexPath, prodoPath } from "./paths";
9
+ import { applyConfiguredPresets } from "./preset-loader";
10
+ import { syncRegistry } from "./registry";
11
+ import { writeSettings } from "./settings";
12
+ import { buildWorkflowCommands } from "./workflow-commands";
13
+ import {
14
+ NORMALIZED_BRIEF_TEMPLATE,
15
+ NORMALIZE_PROMPT_TEMPLATE,
16
+ START_BRIEF_TEMPLATE,
17
+ HOOKS_TEMPLATE,
18
+ artifactTemplateTemplate,
19
+ commandTemplate,
20
+ promptTemplate,
21
+ schemaTemplate
22
+ } from "./templates";
23
+
24
+ type AssetManifestItem = {
25
+ source: string;
26
+ target: string;
27
+ source_sha256: string;
28
+ target_sha256: string | null;
29
+ status: "match" | "drift" | "missing" | "protected" | "updated" | "unmanaged";
30
+ };
31
+
32
+ type ScaffoldManifest = {
33
+ schema_version: "1.0";
34
+ generated_at: string;
35
+ prodo_version: string;
36
+ copied_asset_count: number;
37
+ copied_assets: Array<{ source: string; target: string; sha256: string }>;
38
+ asset_count: number;
39
+ parity_summary: {
40
+ match_count: number;
41
+ drift_count: number;
42
+ missing_count: number;
43
+ protected_count: number;
44
+ updated_count: number;
45
+ unmanaged_count: number;
46
+ };
47
+ assets: AssetManifestItem[];
48
+ };
49
+
50
+ type SourceTargetPair = { sourceDir: string; targetDir: string };
51
+ type BackupMap = Map<string, Buffer | null>;
52
+
53
+ function templateFileName(artifactType: string): string {
54
+ if (artifactType === "workflow") return `${artifactType}.mmd`;
55
+ if (artifactType === "wireframe") return `${artifactType}.html`;
56
+ return `${artifactType}.md`;
57
+ }
58
+
59
+ async function writeFileIfMissing(filePath: string, content: string): Promise<void> {
60
+ if (await fileExists(filePath)) return;
61
+ await fs.writeFile(filePath, content, "utf8");
62
+ }
63
+
64
+ async function readProdoVersion(cwd: string): Promise<string> {
65
+ const candidates = [
66
+ path.join(cwd, "package.json"),
67
+ path.resolve(__dirname, "..", "package.json")
68
+ ];
69
+ for (const candidate of candidates) {
70
+ if (!(await fileExists(candidate))) continue;
71
+ try {
72
+ const parsed = JSON.parse(await fs.readFile(candidate, "utf8")) as { version?: string };
73
+ if (typeof parsed.version === "string" && parsed.version.trim().length > 0) return parsed.version;
74
+ } catch {
75
+ // ignore and continue
76
+ }
77
+ }
78
+ return "0.0.0-dev";
79
+ }
80
+
81
+ async function fileSha256(filePath: string): Promise<string> {
82
+ const content = await fs.readFile(filePath);
83
+ return createHash("sha256").update(content).digest("hex");
84
+ }
85
+
86
+ async function listFilesRecursive(rootDir: string): Promise<string[]> {
87
+ if (!(await fileExists(rootDir))) return [];
88
+ const out: string[] = [];
89
+ const walk = async (current: string): Promise<void> => {
90
+ const entries = await fs.readdir(current, { withFileTypes: true });
91
+ for (const entry of entries) {
92
+ const full = path.join(current, entry.name);
93
+ if (entry.isDirectory()) {
94
+ await walk(full);
95
+ } else {
96
+ out.push(full);
97
+ }
98
+ }
99
+ };
100
+ await walk(rootDir);
101
+ return out;
102
+ }
103
+
104
+ async function loadPreviousManifest(root: string): Promise<ScaffoldManifest | null> {
105
+ const manifestPath = path.join(root, "scaffold-manifest.json");
106
+ if (!(await fileExists(manifestPath))) return null;
107
+ try {
108
+ const parsed = JSON.parse(await fs.readFile(manifestPath, "utf8")) as Partial<ScaffoldManifest>;
109
+ if (!Array.isArray(parsed.assets)) return null;
110
+ return {
111
+ schema_version: "1.0",
112
+ generated_at: parsed.generated_at ?? "",
113
+ prodo_version: parsed.prodo_version ?? "0.0.0-dev",
114
+ copied_asset_count: Number(parsed.copied_asset_count ?? 0),
115
+ copied_assets: Array.isArray(parsed.copied_assets) ? parsed.copied_assets : [],
116
+ asset_count: Number(parsed.asset_count ?? 0),
117
+ parity_summary: {
118
+ match_count: Number(parsed.parity_summary?.match_count ?? 0),
119
+ drift_count: Number(parsed.parity_summary?.drift_count ?? 0),
120
+ missing_count: Number(parsed.parity_summary?.missing_count ?? 0),
121
+ protected_count: Number(parsed.parity_summary?.protected_count ?? 0),
122
+ updated_count: Number(parsed.parity_summary?.updated_count ?? 0),
123
+ unmanaged_count: Number(parsed.parity_summary?.unmanaged_count ?? 0)
124
+ },
125
+ assets: parsed.assets as AssetManifestItem[]
126
+ };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ async function copyDirIfMissing(
133
+ sourceDir: string,
134
+ targetDir: string,
135
+ copiedAssets: Array<{ source: string; target: string; sha256: string }>
136
+ ): Promise<void> {
137
+ if (!(await fileExists(sourceDir))) return;
138
+ await ensureDir(targetDir);
139
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
140
+ for (const entry of entries) {
141
+ const src = path.join(sourceDir, entry.name);
142
+ const dst = path.join(targetDir, entry.name);
143
+ if (entry.isDirectory()) {
144
+ await copyDirIfMissing(src, dst, copiedAssets);
145
+ continue;
146
+ }
147
+ if (await fileExists(dst)) continue;
148
+ await fs.copyFile(src, dst);
149
+ copiedAssets.push({
150
+ source: src,
151
+ target: dst,
152
+ sha256: await fileSha256(dst)
153
+ });
154
+ }
155
+ }
156
+
157
+ async function buildAssetManifest(
158
+ pairs: SourceTargetPair[],
159
+ previous: ScaffoldManifest | null,
160
+ backup: BackupMap
161
+ ): Promise<AssetManifestItem[]> {
162
+ const previousByTarget = new Map<string, AssetManifestItem>();
163
+ for (const item of previous?.assets ?? []) {
164
+ previousByTarget.set(path.resolve(item.target), item);
165
+ }
166
+
167
+ const items: AssetManifestItem[] = [];
168
+ for (const pair of pairs) {
169
+ const sourceFiles = await listFilesRecursive(pair.sourceDir);
170
+ for (const sourceFile of sourceFiles) {
171
+ const relative = path.relative(pair.sourceDir, sourceFile);
172
+ const targetFile = path.join(pair.targetDir, relative);
173
+ const resolvedTarget = path.resolve(targetFile);
174
+ const sourceHash = await fileSha256(sourceFile);
175
+ const targetExists = await fileExists(targetFile);
176
+
177
+ if (!targetExists) {
178
+ await ensureDir(path.dirname(targetFile));
179
+ backup.set(resolvedTarget, null);
180
+ await fs.copyFile(sourceFile, targetFile);
181
+ items.push({
182
+ source: sourceFile,
183
+ target: targetFile,
184
+ source_sha256: sourceHash,
185
+ target_sha256: await fileSha256(targetFile),
186
+ status: "missing"
187
+ });
188
+ continue;
189
+ }
190
+
191
+ const currentTargetHash = await fileSha256(targetFile);
192
+ const prev = previousByTarget.get(resolvedTarget);
193
+ const prevTargetHash = prev?.target_sha256 ?? null;
194
+ const prevSourceHash = prev?.source_sha256 ?? null;
195
+
196
+ if (currentTargetHash === sourceHash) {
197
+ items.push({
198
+ source: sourceFile,
199
+ target: targetFile,
200
+ source_sha256: sourceHash,
201
+ target_sha256: currentTargetHash,
202
+ status: "match"
203
+ });
204
+ continue;
205
+ }
206
+
207
+ if (prev && prevTargetHash && prevSourceHash && currentTargetHash === prevTargetHash && prevSourceHash !== sourceHash) {
208
+ if (!backup.has(resolvedTarget)) {
209
+ backup.set(resolvedTarget, await fs.readFile(targetFile));
210
+ }
211
+ await fs.copyFile(sourceFile, targetFile);
212
+ items.push({
213
+ source: sourceFile,
214
+ target: targetFile,
215
+ source_sha256: sourceHash,
216
+ target_sha256: await fileSha256(targetFile),
217
+ status: "updated"
218
+ });
219
+ continue;
220
+ }
221
+
222
+ if (prev) {
223
+ items.push({
224
+ source: sourceFile,
225
+ target: targetFile,
226
+ source_sha256: sourceHash,
227
+ target_sha256: currentTargetHash,
228
+ status: "protected"
229
+ });
230
+ continue;
231
+ }
232
+
233
+ items.push({
234
+ source: sourceFile,
235
+ target: targetFile,
236
+ source_sha256: sourceHash,
237
+ target_sha256: currentTargetHash,
238
+ status: "unmanaged"
239
+ });
240
+ }
241
+ }
242
+ return items;
243
+ }
244
+
245
+ async function rollbackFiles(backup: BackupMap): Promise<void> {
246
+ for (const [target, content] of backup.entries()) {
247
+ if (content === null) {
248
+ if (await fileExists(target)) await fs.rm(target, { force: true });
249
+ continue;
250
+ }
251
+ await ensureDir(path.dirname(target));
252
+ await fs.writeFile(target, content);
253
+ }
254
+ }
255
+
256
+ function summarizeParity(items: AssetManifestItem[]): ScaffoldManifest["parity_summary"] {
257
+ const byStatus = (status: AssetManifestItem["status"]) => items.filter((item) => item.status === status).length;
258
+ return {
259
+ match_count: byStatus("match"),
260
+ drift_count: byStatus("drift"),
261
+ missing_count: byStatus("missing"),
262
+ protected_count: byStatus("protected"),
263
+ updated_count: byStatus("updated"),
264
+ unmanaged_count: byStatus("unmanaged")
265
+ };
266
+ }
267
+
268
+ export async function runInit(
269
+ cwd: string,
270
+ options?: { ai?: SupportedAi; lang?: string; preset?: string; script?: "sh" | "ps" }
271
+ ): Promise<{ installedAgentFiles: string[]; settingsPath: string }> {
272
+ const root = prodoPath(cwd);
273
+ const artifactDefs = await listArtifactDefinitions(cwd);
274
+ const artifactTypes = artifactDefs.map((item) => item.name);
275
+ const workflowCommands = buildWorkflowCommands(artifactTypes);
276
+ const prodoVersion = await readProdoVersion(cwd);
277
+ const localRepoTemplates = path.join(cwd, "templates");
278
+ const packagedTemplates = path.resolve(__dirname, "..", "templates");
279
+ const projectScaffoldTemplates = (await fileExists(localRepoTemplates)) ? localRepoTemplates : packagedTemplates;
280
+ const copiedAssets: Array<{ source: string; target: string; sha256: string }> = [];
281
+ const backup: BackupMap = new Map();
282
+ const previousManifest = await loadPreviousManifest(root);
283
+
284
+ await ensureDir(path.join(root, "briefs"));
285
+ await ensureDir(path.join(root, "schemas"));
286
+ await ensureDir(path.join(root, "prompts"));
287
+ await ensureDir(path.join(root, "commands"));
288
+ await ensureDir(path.join(root, "presets"));
289
+ await ensureDir(path.join(root, "templates"));
290
+ await ensureDir(path.join(root, "templates", "overrides"));
291
+ await ensureDir(path.join(root, "state"));
292
+ await ensureDir(path.join(root, "state", "context"));
293
+ for (const def of artifactDefs) {
294
+ await ensureDir(outputDirPath(cwd, def.name, def.output_dir));
295
+ }
296
+ await ensureDir(path.join(cwd, "product-docs", "reports"));
297
+ await writeFileIfMissing(
298
+ outputIndexPath(cwd),
299
+ `${JSON.stringify({ active: {}, history: {}, updated_at: new Date(0).toISOString() }, null, 2)}\n`
300
+ );
301
+
302
+ await writeFileIfMissing(briefPath(cwd), START_BRIEF_TEMPLATE);
303
+ await writeFileIfMissing(
304
+ path.join(root, "briefs", "normalized-brief.json"),
305
+ `${JSON.stringify(NORMALIZED_BRIEF_TEMPLATE, null, 2)}\n`
306
+ );
307
+ await writeFileIfMissing(path.join(root, "hooks.yml"), HOOKS_TEMPLATE);
308
+ await writeFileIfMissing(path.join(root, "prompts", "normalize.md"), `${NORMALIZE_PROMPT_TEMPLATE}\n`);
309
+ const scriptType = options?.script ?? (process.platform === "win32" ? "ps" : "sh");
310
+ await fs.writeFile(
311
+ path.join(root, "init-options.json"),
312
+ `${JSON.stringify({ ai: options?.ai ?? null, lang: options?.lang ?? "en", preset: options?.preset ?? null, script: scriptType }, null, 2)}\n`,
313
+ "utf8"
314
+ );
315
+
316
+ await copyDirIfMissing(path.join(projectScaffoldTemplates, "artifacts"), path.join(root, "templates"), copiedAssets);
317
+ for (const artifact of artifactDefs) {
318
+ const schema = {
319
+ ...schemaTemplate(artifact.name),
320
+ x_required_headings: artifact.required_headings
321
+ };
322
+ await writeFileIfMissing(path.join(root, "schemas", `${artifact.name}.yaml`), yaml.dump(schema));
323
+ await writeFileIfMissing(path.join(root, "prompts", `${artifact.name}.md`), `${promptTemplate(artifact.name, options?.lang ?? "en")}\n`);
324
+ await writeFileIfMissing(
325
+ path.join(root, "templates", templateFileName(artifact.name)),
326
+ `${artifactTemplateTemplate(artifact.name, options?.lang ?? "en")}\n`
327
+ );
328
+ }
329
+
330
+ await copyDirIfMissing(path.join(projectScaffoldTemplates, "commands"), path.join(root, "commands"), copiedAssets);
331
+ for (const command of workflowCommands) {
332
+ await writeFileIfMissing(path.join(root, "commands", `${command.name}.md`), `${commandTemplate(command)}\n`);
333
+ }
334
+
335
+ await applyConfiguredPresets(cwd, root, prodoVersion, options?.preset);
336
+
337
+ const pairs: SourceTargetPair[] = [
338
+ {
339
+ sourceDir: path.join(projectScaffoldTemplates, "commands"),
340
+ targetDir: path.join(root, "commands")
341
+ },
342
+ {
343
+ sourceDir: path.join(projectScaffoldTemplates, "artifacts"),
344
+ targetDir: path.join(root, "templates")
345
+ }
346
+ ];
347
+
348
+ let parity: AssetManifestItem[] = [];
349
+ try {
350
+ parity = await buildAssetManifest(pairs, previousManifest, backup);
351
+ } catch (error) {
352
+ await rollbackFiles(backup);
353
+ throw error;
354
+ }
355
+
356
+ const installedAgentFiles = options?.ai ? await installAgentCommands(cwd, options.ai) : [];
357
+ const manifest: ScaffoldManifest = {
358
+ schema_version: "1.0",
359
+ generated_at: new Date().toISOString(),
360
+ prodo_version: prodoVersion,
361
+ copied_asset_count: copiedAssets.length,
362
+ copied_assets: copiedAssets,
363
+ asset_count: parity.length,
364
+ parity_summary: summarizeParity(parity),
365
+ assets: parity
366
+ };
367
+ await fs.writeFile(path.join(root, "scaffold-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
368
+ await syncRegistry(cwd);
369
+ const settingsPath = await writeSettings(cwd, {
370
+ lang: (options?.lang ?? "en").trim() || "en",
371
+ ai: options?.ai
372
+ });
373
+ return { installedAgentFiles, settingsPath };
374
+ }
375
+
@@ -0,0 +1,73 @@
1
+ export type MarkdownSection = {
2
+ heading: string;
3
+ headingKey: string;
4
+ level: number;
5
+ textLines: string[];
6
+ listItems: string[];
7
+ };
8
+
9
+ function normalizeText(input: string): string {
10
+ return input.trim().replace(/\s+/g, " ");
11
+ }
12
+
13
+ export function normalizeHeadingKey(heading: string): string {
14
+ return normalizeText(heading)
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9\s]/g, " ")
17
+ .replace(/\s+/g, " ")
18
+ .trim();
19
+ }
20
+
21
+ export function parseMarkdownSections(markdown: string): MarkdownSection[] {
22
+ const sections: MarkdownSection[] = [];
23
+ let current: MarkdownSection | null = null;
24
+
25
+ for (const rawLine of markdown.split(/\r?\n/)) {
26
+ const headingMatch = rawLine.match(/^\s*(#{1,6})\s+(.+?)\s*$/);
27
+ if (headingMatch) {
28
+ const title = normalizeText(headingMatch[2]);
29
+ current = {
30
+ heading: title,
31
+ headingKey: normalizeHeadingKey(title),
32
+ level: headingMatch[1].length,
33
+ textLines: [],
34
+ listItems: []
35
+ };
36
+ sections.push(current);
37
+ continue;
38
+ }
39
+
40
+ if (!current) continue;
41
+ const listMatch = rawLine.match(/^\s*(?:[-*+]|\d+\.)\s+(.+?)\s*$/);
42
+ if (listMatch) {
43
+ const item = normalizeText(listMatch[1]);
44
+ if (item.length > 0 && !current.listItems.includes(item)) current.listItems.push(item);
45
+ if (item.length > 0) current.textLines.push(item);
46
+ continue;
47
+ }
48
+
49
+ const text = normalizeText(rawLine);
50
+ if (text.length > 0) current.textLines.push(text);
51
+ }
52
+
53
+ return sections;
54
+ }
55
+
56
+ export function extractRequiredHeadings(content: string): string[] {
57
+ const sections = parseMarkdownSections(content);
58
+ return sections
59
+ .filter((section) => section.level === 2)
60
+ .map((section) => `## ${section.heading}`)
61
+ .filter((heading) => heading.length > 3);
62
+ }
63
+
64
+ export function sectionTextMap(content: string): Map<string, string> {
65
+ const sections = parseMarkdownSections(content);
66
+ const mapped = new Map<string, string>();
67
+ for (const section of sections) {
68
+ const parts = [...section.listItems, ...section.textLines].filter((item) => item.length > 0);
69
+ mapped.set(`## ${section.heading}`, parts.join("\n").trim());
70
+ }
71
+ return mapped;
72
+ }
73
+
@@ -0,0 +1,89 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { UserError } from "./errors";
4
+ import {
5
+ buildContractsFromArrays,
6
+ parseNormalizedBriefOrThrow,
7
+ requireConfidenceOrThrow
8
+ } from "./normalized-brief";
9
+ import { briefPath, normalizedBriefPath, prodoPath } from "./paths";
10
+ import { createProvider } from "./providers";
11
+ import { readSettings } from "./settings";
12
+ import { fileExists, isPathInside } from "./utils";
13
+
14
+ type NormalizeOptions = {
15
+ cwd: string;
16
+ brief?: string;
17
+ out?: string;
18
+ };
19
+
20
+ function extractJsonObject(raw: string): Record<string, unknown> {
21
+ const trimmed = raw.trim();
22
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
23
+ const candidate = fenced ? fenced[1] : trimmed;
24
+ try {
25
+ return JSON.parse(candidate) as Record<string, unknown>;
26
+ } catch {
27
+ throw new UserError("Normalizer provider did not return valid JSON.");
28
+ }
29
+ }
30
+
31
+ export async function runNormalize(options: NormalizeOptions): Promise<string> {
32
+ const { cwd } = options;
33
+ const root = prodoPath(cwd);
34
+ if (!(await fileExists(root))) {
35
+ throw new UserError("Missing .prodo directory. Run `prodo init .` first.");
36
+ }
37
+
38
+ const inPath = options.brief ? path.resolve(cwd, options.brief) : briefPath(cwd);
39
+ if (!(await fileExists(inPath))) {
40
+ throw new UserError(`Brief file not found: ${inPath}`);
41
+ }
42
+
43
+ const rawBrief = await fs.readFile(inPath, "utf8");
44
+ const normalizePromptPath = path.join(root, "prompts", "normalize.md");
45
+ const normalizePrompt = await fs.readFile(normalizePromptPath, "utf8");
46
+ const settings = await readSettings(cwd);
47
+ const provider = createProvider();
48
+
49
+ const generated = await provider.generate(
50
+ normalizePrompt,
51
+ {
52
+ briefMarkdown: rawBrief,
53
+ sourceBriefPath: inPath,
54
+ outputLanguage: settings.lang
55
+ },
56
+ {
57
+ artifactType: "normalize",
58
+ requiredHeadings: [],
59
+ requiredContracts: []
60
+ }
61
+ );
62
+
63
+ const parsed = extractJsonObject(generated.body);
64
+ const withContracts = {
65
+ ...parsed,
66
+ contracts:
67
+ parsed.contracts ??
68
+ buildContractsFromArrays({
69
+ goals: Array.isArray(parsed.goals) ? parsed.goals.filter((x): x is string => typeof x === "string") : [],
70
+ core_features: Array.isArray(parsed.core_features)
71
+ ? parsed.core_features.filter((x): x is string => typeof x === "string")
72
+ : [],
73
+ constraints: Array.isArray(parsed.constraints)
74
+ ? parsed.constraints.filter((x): x is string => typeof x === "string")
75
+ : []
76
+ })
77
+ };
78
+
79
+ const normalized = parseNormalizedBriefOrThrow(withContracts);
80
+ requireConfidenceOrThrow(normalized, ["product_name", "problem", "audience", "goals", "core_features"], 0.7);
81
+
82
+ const outPath = options.out ? path.resolve(cwd, options.out) : normalizedBriefPath(cwd);
83
+ if (!isPathInside(prodoPath(cwd), outPath)) {
84
+ throw new UserError("Normalize output must be inside `.prodo/`.");
85
+ }
86
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
87
+ await fs.writeFile(outPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
88
+ return outPath;
89
+ }