@shahmarasy/prodo 0.1.2 → 0.1.4

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 (45) hide show
  1. package/dist/agents.js +4 -2
  2. package/dist/artifacts.d.ts +1 -0
  3. package/dist/artifacts.js +265 -31
  4. package/dist/cli.js +80 -3
  5. package/dist/init-tui.d.ts +3 -0
  6. package/dist/init-tui.js +28 -1
  7. package/dist/init.d.ts +1 -0
  8. package/dist/init.js +9 -3
  9. package/dist/normalize.js +55 -7
  10. package/dist/providers/openai-provider.js +2 -1
  11. package/dist/settings.d.ts +1 -0
  12. package/dist/settings.js +2 -1
  13. package/dist/templates.d.ts +1 -1
  14. package/dist/templates.js +11 -0
  15. package/dist/utils.d.ts +1 -0
  16. package/dist/utils.js +13 -0
  17. package/dist/validator.js +0 -4
  18. package/dist/workflow-commands.js +2 -1
  19. package/package.json +1 -1
  20. package/presets/fintech/preset.json +48 -1
  21. package/presets/fintech/prompts/prd.md +99 -2
  22. package/presets/marketplace/preset.json +51 -1
  23. package/presets/marketplace/prompts/prd.md +140 -2
  24. package/presets/saas/preset.json +53 -1
  25. package/presets/saas/prompts/prd.md +150 -2
  26. package/src/agents.ts +4 -2
  27. package/src/artifacts.ts +323 -28
  28. package/src/cli.ts +97 -6
  29. package/src/init-tui.ts +30 -1
  30. package/src/init.ts +11 -4
  31. package/src/normalize.ts +55 -7
  32. package/src/providers/openai-provider.ts +2 -1
  33. package/src/settings.ts +3 -2
  34. package/src/templates.ts +12 -0
  35. package/src/utils.ts +14 -0
  36. package/src/validator.ts +0 -4
  37. package/src/workflow-commands.ts +2 -1
  38. package/templates/commands/prodo-fix.md +46 -0
  39. package/templates/commands/prodo-normalize.md +116 -14
  40. package/templates/commands/prodo-prd.md +136 -12
  41. package/templates/commands/prodo-stories.md +151 -12
  42. package/templates/commands/prodo-techspec.md +165 -12
  43. package/templates/commands/prodo-validate.md +184 -23
  44. package/templates/commands/prodo-wireframe.md +186 -12
  45. package/templates/commands/prodo-workflow.md +198 -12
package/src/artifacts.ts CHANGED
@@ -19,7 +19,7 @@ import { extractRequiredHeadingsFromTemplate, resolveCompanionTemplate, resolveT
19
19
  import { readSettings } from "./settings";
20
20
  import { sectionTextMap } from "./markdown";
21
21
  import type { ArtifactDoc, ArtifactType, ContractCoverage } from "./types";
22
- import { fileExists, isPathInside, listFilesSortedByMtime, readJsonFile, timestampSlug } from "./utils";
22
+ import { artifactFileStamp, fileExists, isPathInside, listFilesSortedByMtime, readJsonFile, timestampSlug } from "./utils";
23
23
  import { validateSchema } from "./validator";
24
24
 
25
25
  export type GenerateOptions = {
@@ -28,12 +28,11 @@ export type GenerateOptions = {
28
28
  normalizedBriefOverride?: string;
29
29
  outPath?: string;
30
30
  agent?: string;
31
+ revisionType?: "default" | "fix";
31
32
  };
32
33
 
33
34
  function defaultFilename(type: ArtifactType): string {
34
- if (type === "workflow") return `${type}-${timestampSlug()}.md`;
35
- if (type === "wireframe") return `${type}-${timestampSlug()}.md`;
36
- return `${type}-${timestampSlug()}.md`;
35
+ return `${type}-${artifactFileStamp()}.md`;
37
36
  }
38
37
 
39
38
  function sidecarPath(filePath: string): string {
@@ -179,12 +178,196 @@ function renderWorkflowMermaidTemplate(
179
178
  );
180
179
  }
181
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
+
182
364
  async function resolvePrompt(
183
365
  cwd: string,
184
366
  artifactType: ArtifactType,
185
367
  templateContent: string,
186
368
  requiredHeadings: string[],
187
369
  companionTemplate: { path: string; content: string } | null,
370
+ outputAuthor: string | undefined,
188
371
  agent?: string
189
372
  ): Promise<string> {
190
373
  const base = await fs.readFile(promptPath(cwd, artifactType), "utf8");
@@ -228,12 +411,19 @@ Wireframe paired output contract (STRICT):
228
411
  - Generate companion HTML screens based on native wireframe template.
229
412
  - HTML must stay low-fidelity and structure-first.`
230
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
+ : "";
231
420
  const withTemplate = `${base}
232
421
 
233
422
  ${authority}
234
423
  ${companionAuthority}
235
424
  ${workflowPairing}
236
- ${wireframePairing}`;
425
+ ${wireframePairing}
426
+ ${authorPolicy}`;
237
427
  if (!agent) return withTemplate;
238
428
  return `${withTemplate}
239
429
 
@@ -428,6 +618,62 @@ function splitWorkflowPair(raw: string): { markdown: string; mermaid: string } {
428
618
  return { markdown, mermaid };
429
619
  }
430
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
+
431
677
  async function writeWireframeScreens(
432
678
  targetDir: string,
433
679
  baseName: string,
@@ -438,10 +684,17 @@ async function writeWireframeScreens(
438
684
  htmlTemplateContent: string | null
439
685
  ): Promise<{ primaryPath: string; summaryBody: string }> {
440
686
  const tr = lang.toLowerCase().startsWith("tr");
441
- const screenContracts = normalized.contracts.core_features
687
+ const explicitScreens = normalized.contracts.core_features
442
688
  .filter((item) => coverage.core_features.includes(item.id))
443
689
  .slice(0, 6);
444
- const screens = screenContracts.length > 0 ? screenContracts : normalized.contracts.core_features.slice(0, 3);
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)));
445
698
  const summaryBodies: string[] = [];
446
699
  let primaryMdPath = "";
447
700
  for (const [index, screen] of screens.entries()) {
@@ -568,9 +821,11 @@ async function writeWireframeScreens(
568
821
 
569
822
  export async function generateArtifact(options: GenerateOptions): Promise<string> {
570
823
  const { cwd, artifactType, outPath, agent } = options;
824
+ const revisionType = options.revisionType ?? "default";
571
825
  const def = await getArtifactDefinition(cwd, artifactType);
572
826
  const normalizedPath = options.normalizedBriefOverride ?? normalizedBriefPath(cwd);
573
827
  await ensurePipelinePrereqs(cwd, normalizedPath);
828
+ const documentControlVersion = await resolveDocumentControlVersion(cwd, artifactType, revisionType);
574
829
 
575
830
  const settings = await readSettings(cwd);
576
831
  const normalizedBriefRaw = await readJsonFile<Record<string, unknown>>(normalizedPath);
@@ -608,6 +863,7 @@ export async function generateArtifact(options: GenerateOptions): Promise<string
608
863
  template?.content ?? "",
609
864
  computedHeadings,
610
865
  companionTemplate,
866
+ settings.author,
611
867
  agent
612
868
  );
613
869
  const provider = createProvider();
@@ -628,17 +884,24 @@ export async function generateArtifact(options: GenerateOptions): Promise<string
628
884
  templatePath: template?.path ?? "",
629
885
  companionTemplateContent: companionTemplate?.content ?? "",
630
886
  companionTemplatePath: companionTemplate?.path ?? "",
631
- outputLanguage: settings.lang
887
+ outputLanguage: settings.lang,
888
+ outputAuthor: settings.author
632
889
  },
633
890
  schemaHint
634
891
  );
635
892
 
636
- let generatedBody = generated.body.trim();
893
+ let generatedBody = enforceAuthorInControlTables(
894
+ replaceAuthorPlaceholders(generated.body.trim(), settings.author),
895
+ settings.author
896
+ );
637
897
  let workflowMermaidBody: string | null = null;
638
898
  if (artifactType === "workflow") {
639
899
  const paired = splitWorkflowPair(generatedBody);
640
- generatedBody = paired.markdown;
641
- workflowMermaidBody = paired.mermaid;
900
+ generatedBody = enforceAuthorInControlTables(
901
+ replaceAuthorPlaceholders(paired.markdown, settings.author),
902
+ settings.author
903
+ );
904
+ workflowMermaidBody = replaceAuthorPlaceholders(paired.mermaid, settings.author);
642
905
  }
643
906
  let contractCoverage = extractCoverageFromBody(generatedBody);
644
907
 
@@ -660,6 +923,13 @@ export async function generateArtifact(options: GenerateOptions): Promise<string
660
923
  }
661
924
  }
662
925
 
926
+ generatedBody = applyDocumentControlDefaults(generatedBody, {
927
+ lang: settings.lang,
928
+ revisionType,
929
+ version: documentControlVersion,
930
+ author: settings.author
931
+ });
932
+
663
933
  if (artifactType === "workflow" && companionTemplate?.content) {
664
934
  workflowMermaidBody = renderWorkflowMermaidTemplate(
665
935
  companionTemplate.content,
@@ -667,6 +937,7 @@ export async function generateArtifact(options: GenerateOptions): Promise<string
667
937
  contractCoverage,
668
938
  settings.lang
669
939
  ).trim();
940
+ workflowMermaidBody = replaceAuthorPlaceholders(workflowMermaidBody, settings.author);
670
941
  }
671
942
 
672
943
  enforceLanguage(generatedBody, settings.lang, artifactType);
@@ -691,10 +962,14 @@ export async function generateArtifact(options: GenerateOptions): Promise<string
691
962
  status: DEFAULT_STATUS,
692
963
  upstream_artifacts: upstreamArtifacts.map((item) => item.file),
693
964
  contract_coverage: contractCoverage,
694
- language: settings.lang
965
+ language: settings.lang,
966
+ ...(normalizeAuthor(settings.author) ? { author: normalizeAuthor(settings.author) } : {})
695
967
  } as Record<string, unknown>;
696
968
 
697
969
  const mergedFrontmatter = { ...frontmatter, ...(generated.frontmatter ?? {}) };
970
+ if (normalizeAuthor(settings.author)) {
971
+ mergedFrontmatter.author = normalizeAuthor(settings.author);
972
+ }
698
973
  let doc: ArtifactDoc = {
699
974
  frontmatter: mergedFrontmatter,
700
975
  body: generatedBody
@@ -708,29 +983,49 @@ export async function generateArtifact(options: GenerateOptions): Promise<string
708
983
  }
709
984
 
710
985
  const targetDir = outputDirPath(cwd, artifactType, def.output_dir);
711
- const finalPath = outPath ? path.resolve(cwd, outPath) : path.join(targetDir, defaultFilename(artifactType));
986
+ const finalPath = outPath
987
+ ? path.resolve(cwd, outPath)
988
+ : await resolveUniqueOutputPath(targetDir, defaultFilename(artifactType));
712
989
  if (!isPathInside(path.join(cwd, "product-docs"), finalPath)) {
713
990
  throw new UserError("Artifact output must be inside `product-docs/`.");
714
991
  }
715
992
  await fs.mkdir(path.dirname(finalPath), { recursive: true });
716
993
  if (artifactType === "workflow") {
717
994
  const basePath = path.join(path.dirname(finalPath), path.parse(finalPath).name);
718
- const mdPath = `${basePath}.md`;
719
- const mmdPath = `${basePath}.mmd`;
720
- await fs.writeFile(mdPath, matter.stringify(doc.body, doc.frontmatter), "utf8");
721
- await fs.writeFile(mmdPath, `${(workflowMermaidBody ?? "").trim()}\n`, "utf8");
722
- await writeSidecar(mdPath, doc);
723
- const derivedContext = {
724
- artifact_type: artifactType,
725
- artifact_file: mdPath,
726
- generated_at: new Date().toISOString(),
727
- contract_coverage: contractCoverage,
728
- ...deriveStructuredContext(artifactType, doc.body, schemaHint.requiredHeadings)
729
- };
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
+ );
730
1005
  await fs.mkdir(outputContextDirPath(cwd), { recursive: true });
731
- await fs.writeFile(contextFilePath(cwd, mdPath), `${JSON.stringify(derivedContext, null, 2)}\n`, "utf8");
732
- await setActiveArtifact(cwd, artifactType, mdPath);
733
- return mdPath;
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;
734
1029
  } else if (artifactType === "wireframe") {
735
1030
  const base = path.parse(finalPath).name;
736
1031
  const wireframe = await writeWireframeScreens(
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { loadAgentCommandSet, resolveAgent } from "./agents";
6
6
  import { resolveAi, type SupportedAi } from "./agent-command-installer";
7
- import { listArtifactTypes } from "./artifact-registry";
7
+ import { listArtifactDefinitions, listArtifactTypes } from "./artifact-registry";
8
8
  import { generateArtifact } from "./artifacts";
9
9
  import { runDoctor } from "./doctor";
10
10
  import { UserError } from "./errors";
@@ -30,10 +30,11 @@ const dynamicImport = new Function("specifier", "return import(specifier)") as (
30
30
  specifier: string
31
31
  ) => Promise<unknown>;
32
32
 
33
- function mapForcedCommand(forcedCommand: string): ArtifactType | "init" | "validate" | "normalize" | undefined {
33
+ function mapForcedCommand(forcedCommand: string): ArtifactType | "init" | "validate" | "normalize" | "fix" | undefined {
34
34
  if (forcedCommand === "prodo-init") return "init";
35
35
  if (forcedCommand === "prodo-validate") return "validate";
36
36
  if (forcedCommand === "prodo-normalize") return "normalize";
37
+ if (forcedCommand === "prodo-fix") return "fix";
37
38
  if (forcedCommand === "prodo-prd") return "prd";
38
39
  if (forcedCommand === "prodo-workflow") return "workflow";
39
40
  if (forcedCommand === "prodo-wireframe") return "wireframe";
@@ -44,7 +45,7 @@ function mapForcedCommand(forcedCommand: string): ArtifactType | "init" | "valid
44
45
 
45
46
  async function runArtifactCommand(
46
47
  type: ArtifactType,
47
- opts: { from?: string; out?: string; agent?: string },
48
+ opts: { from?: string; out?: string; agent?: string; revisionType?: "default" | "fix" },
48
49
  cwd: string,
49
50
  log: (message: string) => void,
50
51
  options?: { suggestValidate?: boolean }
@@ -56,7 +57,8 @@ async function runArtifactCommand(
56
57
  cwd,
57
58
  normalizedBriefOverride: opts.from,
58
59
  outPath: opts.out,
59
- agent
60
+ agent,
61
+ revisionType: opts.revisionType
60
62
  });
61
63
  const agentMsg = agent ? ` [agent=${agent}]` : "";
62
64
  log(`${type.toUpperCase()} generated${agentMsg}: ${file}`);
@@ -116,13 +118,15 @@ export async function runCli(options: RunOptions = {}): Promise<number> {
116
118
  .command("init [target]")
117
119
  .option("--ai <name>", "agent integration: codex | gemini-cli | claude-cli")
118
120
  .option("--lang <code>", "document language (e.g. en, tr)")
121
+ .option("--author <name>", "document author name")
119
122
  .option("--preset <name>", "preset to install during initialization")
120
123
  .action(async (target, opts) => {
121
124
  const projectRoot = path.resolve(cwd, target ?? ".");
122
125
  const selected = await gatherInitSelections({
123
126
  projectRoot,
124
127
  aiInput: opts.ai,
125
- langInput: opts.lang
128
+ langInput: opts.lang,
129
+ authorInput: opts.author
126
130
  });
127
131
  const selectedAi = selected.ai as SupportedAi | undefined;
128
132
 
@@ -133,6 +137,7 @@ export async function runCli(options: RunOptions = {}): Promise<number> {
133
137
  const result = await runInit(projectRoot, {
134
138
  ai: selectedAi,
135
139
  lang: selected.lang,
140
+ author: selected.author,
136
141
  preset: opts.preset,
137
142
  script: selected.script
138
143
  });
@@ -141,7 +146,8 @@ export async function runCli(options: RunOptions = {}): Promise<number> {
141
146
  projectRoot,
142
147
  settingsPath: result.settingsPath,
143
148
  ai: selectedAi,
144
- lang: selected.lang
149
+ lang: selected.lang,
150
+ author: selected.author
145
151
  });
146
152
  return;
147
153
  }
@@ -149,6 +155,7 @@ export async function runCli(options: RunOptions = {}): Promise<number> {
149
155
  const result = await runInit(projectRoot, {
150
156
  ai: selectedAi,
151
157
  lang: selected.lang,
158
+ author: selected.author,
152
159
  preset: opts.preset,
153
160
  script: selected.script
154
161
  });
@@ -161,6 +168,7 @@ export async function runCli(options: RunOptions = {}): Promise<number> {
161
168
  out("No agent selected. Use `prodo generate` for end-to-end generation.");
162
169
  }
163
170
  out(`Settings file: ${result.settingsPath}`);
171
+ out(`Author: ${selected.author}`);
164
172
  out("Next: edit brief.md.");
165
173
  });
166
174
 
@@ -198,6 +206,56 @@ export async function runCli(options: RunOptions = {}): Promise<number> {
198
206
  });
199
207
  });
200
208
 
209
+ program
210
+ .command("fix", { hidden: true })
211
+ .description("Advanced: auto-regenerate affected artifacts from validation findings")
212
+ .option("--agent <name>", "agent profile: codex | gemini-cli | claude-cli")
213
+ .option("--strict", "treat validation warnings as errors")
214
+ .option("--report <path>", "validation report output path")
215
+ .action(async (opts) => {
216
+ if (opts.agent) resolveAgent(opts.agent);
217
+ await withBriefReadOnlyGuard(cwd, async () => {
218
+ await runHookPhase(cwd, "before_validate", out);
219
+ const initial = await runValidate(cwd, {
220
+ strict: Boolean(opts.strict),
221
+ report: opts.report
222
+ });
223
+ out(`Validation report written to: ${initial.reportPath}`);
224
+ await runHookPhase(cwd, "after_validate", out);
225
+
226
+ if (initial.pass) {
227
+ out("No blocking issues found. Nothing to fix.");
228
+ return;
229
+ }
230
+
231
+ const targets = await resolveFixTargets(cwd, artifactTypes, initial.issues);
232
+ out(`Validation failed. Regenerating impacted artifacts: ${targets.join(", ")}`);
233
+
234
+ await runHookPhase(cwd, "before_normalize", out);
235
+ const normalizedPath = await runNormalize({ cwd });
236
+ out(`Normalized brief refreshed: ${normalizedPath}`);
237
+ await runHookPhase(cwd, "after_normalize", out);
238
+
239
+ for (const type of targets) {
240
+ await runArtifactCommand(type, { from: normalizedPath, agent: opts.agent, revisionType: "fix" }, cwd, out, {
241
+ suggestValidate: false
242
+ });
243
+ }
244
+
245
+ await runHookPhase(cwd, "before_validate", out);
246
+ const final = await runValidate(cwd, {
247
+ strict: Boolean(opts.strict),
248
+ report: opts.report
249
+ });
250
+ out(`Validation report written to: ${final.reportPath}`);
251
+ if (!final.pass) {
252
+ throw new UserError("Fix completed but validation is still failing. Review report and retry.");
253
+ }
254
+ out("Fix pipeline completed. Validation passed.");
255
+ await runHookPhase(cwd, "after_validate", out);
256
+ });
257
+ });
258
+
201
259
  program
202
260
  .command("normalize", { hidden: true })
203
261
  .description("Advanced: normalize brief without full pipeline")
@@ -294,6 +352,8 @@ export async function runCli(options: RunOptions = {}): Promise<number> {
294
352
  await program.parseAsync(["node", "prodo", "normalize", ...argv.slice(2)]);
295
353
  } else if (forced === "validate") {
296
354
  await program.parseAsync(["node", "prodo", "validate", ...argv.slice(2)]);
355
+ } else if (forced === "fix") {
356
+ await program.parseAsync(["node", "prodo", "fix", ...argv.slice(2)]);
297
357
  } else {
298
358
  await program.parseAsync(["node", "prodo", forced, ...argv.slice(2)]);
299
359
  }
@@ -317,3 +377,34 @@ if (require.main === module) {
317
377
  process.exitCode = code;
318
378
  });
319
379
  }
380
+
381
+ async function resolveFixTargets(
382
+ cwd: string,
383
+ artifactTypes: ArtifactType[],
384
+ issues: Array<{ artifactType?: ArtifactType }>
385
+ ): Promise<ArtifactType[]> {
386
+ const direct = new Set<ArtifactType>(
387
+ issues
388
+ .map((issue) => issue.artifactType)
389
+ .filter(
390
+ (artifactType): artifactType is ArtifactType =>
391
+ typeof artifactType === "string" && artifactTypes.includes(artifactType)
392
+ )
393
+ );
394
+ if (direct.size === 0) return artifactTypes;
395
+
396
+ const defs = await listArtifactDefinitions(cwd);
397
+ let changed = true;
398
+ while (changed) {
399
+ changed = false;
400
+ for (const def of defs) {
401
+ const needsRefresh = def.upstream.some((upstream) => direct.has(upstream));
402
+ if (needsRefresh && !direct.has(def.name)) {
403
+ direct.add(def.name);
404
+ changed = true;
405
+ }
406
+ }
407
+ }
408
+
409
+ return artifactTypes.filter((artifactType) => direct.has(artifactType));
410
+ }