@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.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/bin/prodo.cjs +6 -0
- package/dist/agent-command-installer.d.ts +4 -0
- package/dist/agent-command-installer.js +158 -0
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +47 -0
- package/dist/artifact-registry.d.ts +11 -0
- package/dist/artifact-registry.js +49 -0
- package/dist/artifacts.d.ts +9 -0
- package/dist/artifacts.js +514 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +305 -0
- package/dist/consistency.d.ts +8 -0
- package/dist/consistency.js +268 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +64 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +123 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +10 -0
- package/dist/hook-executor.d.ts +1 -0
- package/dist/hook-executor.js +175 -0
- package/dist/init-tui.d.ts +21 -0
- package/dist/init-tui.js +161 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +307 -0
- package/dist/markdown.d.ts +11 -0
- package/dist/markdown.js +66 -0
- package/dist/normalize.d.ts +7 -0
- package/dist/normalize.js +73 -0
- package/dist/normalized-brief.d.ts +39 -0
- package/dist/normalized-brief.js +170 -0
- package/dist/output-index.d.ts +13 -0
- package/dist/output-index.js +55 -0
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +76 -0
- package/dist/preset-loader.d.ts +4 -0
- package/dist/preset-loader.js +210 -0
- package/dist/project-config.d.ts +14 -0
- package/dist/project-config.js +69 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.js +12 -0
- package/dist/providers/mock-provider.d.ts +7 -0
- package/dist/providers/mock-provider.js +168 -0
- package/dist/providers/openai-provider.d.ts +11 -0
- package/dist/providers/openai-provider.js +69 -0
- package/dist/registry.d.ts +13 -0
- package/dist/registry.js +115 -0
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +34 -0
- package/dist/template-resolver.d.ts +11 -0
- package/dist/template-resolver.js +28 -0
- package/dist/templates.d.ts +33 -0
- package/dist/templates.js +428 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.js +5 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +53 -0
- package/dist/validate.d.ts +9 -0
- package/dist/validate.js +226 -0
- package/dist/validator.d.ts +5 -0
- package/dist/validator.js +80 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +30 -0
- package/dist/workflow-commands.d.ts +7 -0
- package/dist/workflow-commands.js +28 -0
- package/package.json +45 -0
- package/presets/fintech/preset.json +1 -0
- package/presets/fintech/prompts/prd.md +3 -0
- package/presets/marketplace/preset.json +1 -0
- package/presets/marketplace/prompts/prd.md +3 -0
- package/presets/saas/preset.json +1 -0
- package/presets/saas/prompts/prd.md +3 -0
- package/src/agent-command-installer.ts +174 -0
- package/src/agents.ts +56 -0
- package/src/artifact-registry.ts +69 -0
- package/src/artifacts.ts +606 -0
- package/src/cli.ts +322 -0
- package/src/consistency.ts +303 -0
- package/src/constants.ts +72 -0
- package/src/doctor.ts +137 -0
- package/src/errors.ts +7 -0
- package/src/hook-executor.ts +196 -0
- package/src/init-tui.ts +193 -0
- package/src/init.ts +375 -0
- package/src/markdown.ts +73 -0
- package/src/normalize.ts +89 -0
- package/src/normalized-brief.ts +206 -0
- package/src/output-index.ts +59 -0
- package/src/paths.ts +72 -0
- package/src/preset-loader.ts +237 -0
- package/src/project-config.ts +78 -0
- package/src/providers/index.ts +12 -0
- package/src/providers/mock-provider.ts +188 -0
- package/src/providers/openai-provider.ts +87 -0
- package/src/registry.ts +119 -0
- package/src/settings.ts +34 -0
- package/src/template-resolver.ts +33 -0
- package/src/templates.ts +440 -0
- package/src/types.ts +46 -0
- package/src/utils.ts +50 -0
- package/src/validate.ts +246 -0
- package/src/validator.ts +96 -0
- package/src/version.ts +24 -0
- package/src/workflow-commands.ts +31 -0
- package/templates/artifacts/prd.md +219 -0
- package/templates/artifacts/stories.md +49 -0
- package/templates/artifacts/techspec.md +42 -0
- package/templates/artifacts/wireframe.html +260 -0
- package/templates/artifacts/wireframe.md +22 -0
- package/templates/artifacts/workflow.md +22 -0
- package/templates/artifacts/workflow.mmd +6 -0
- package/templates/commands/prodo-normalize.md +24 -0
- package/templates/commands/prodo-prd.md +24 -0
- package/templates/commands/prodo-stories.md +24 -0
- package/templates/commands/prodo-techspec.md +24 -0
- package/templates/commands/prodo-validate.md +24 -0
- package/templates/commands/prodo-wireframe.md +24 -0
- package/templates/commands/prodo-workflow.md +24 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
2
|
+
import { UserError } from "./errors";
|
|
3
|
+
import type { ValidationIssue } from "./types";
|
|
4
|
+
|
|
5
|
+
export type BriefContractItem = {
|
|
6
|
+
id: string;
|
|
7
|
+
text: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type NormalizedBrief = {
|
|
11
|
+
schema_version: string;
|
|
12
|
+
product_name: string;
|
|
13
|
+
problem: string;
|
|
14
|
+
audience: string[];
|
|
15
|
+
goals: string[];
|
|
16
|
+
core_features: string[];
|
|
17
|
+
constraints: string[];
|
|
18
|
+
assumptions: string[];
|
|
19
|
+
contracts: {
|
|
20
|
+
goals: BriefContractItem[];
|
|
21
|
+
core_features: BriefContractItem[];
|
|
22
|
+
constraints: BriefContractItem[];
|
|
23
|
+
};
|
|
24
|
+
confidence?: Record<string, number>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type BriefContracts = NormalizedBrief["contracts"];
|
|
28
|
+
|
|
29
|
+
const schema: Record<string, unknown> = {
|
|
30
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
31
|
+
type: "object",
|
|
32
|
+
required: [
|
|
33
|
+
"schema_version",
|
|
34
|
+
"product_name",
|
|
35
|
+
"problem",
|
|
36
|
+
"audience",
|
|
37
|
+
"goals",
|
|
38
|
+
"core_features",
|
|
39
|
+
"constraints",
|
|
40
|
+
"assumptions",
|
|
41
|
+
"contracts"
|
|
42
|
+
],
|
|
43
|
+
properties: {
|
|
44
|
+
schema_version: { type: "string", minLength: 1 },
|
|
45
|
+
product_name: { type: "string", minLength: 2 },
|
|
46
|
+
problem: { type: "string", minLength: 10 },
|
|
47
|
+
audience: { type: "array", minItems: 1, items: { type: "string", minLength: 2 } },
|
|
48
|
+
goals: { type: "array", minItems: 1, items: { type: "string", minLength: 2 } },
|
|
49
|
+
core_features: { type: "array", minItems: 1, items: { type: "string", minLength: 2 } },
|
|
50
|
+
constraints: { type: "array", items: { type: "string", minLength: 2 } },
|
|
51
|
+
assumptions: { type: "array", items: { type: "string", minLength: 2 } },
|
|
52
|
+
contracts: {
|
|
53
|
+
type: "object",
|
|
54
|
+
required: ["goals", "core_features", "constraints"],
|
|
55
|
+
properties: {
|
|
56
|
+
goals: { $ref: "#/$defs/contractArray" },
|
|
57
|
+
core_features: { $ref: "#/$defs/contractArray" },
|
|
58
|
+
constraints: { $ref: "#/$defs/contractArray" }
|
|
59
|
+
},
|
|
60
|
+
additionalProperties: false
|
|
61
|
+
},
|
|
62
|
+
confidence: {
|
|
63
|
+
type: "object",
|
|
64
|
+
additionalProperties: { type: "number", minimum: 0, maximum: 1 }
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
$defs: {
|
|
68
|
+
contractItem: {
|
|
69
|
+
type: "object",
|
|
70
|
+
required: ["id", "text"],
|
|
71
|
+
properties: {
|
|
72
|
+
id: { type: "string", pattern: "^[A-Z]+[0-9]+$" },
|
|
73
|
+
text: { type: "string", minLength: 2 }
|
|
74
|
+
},
|
|
75
|
+
additionalProperties: false
|
|
76
|
+
},
|
|
77
|
+
contractArray: { type: "array", items: { $ref: "#/$defs/contractItem" } }
|
|
78
|
+
},
|
|
79
|
+
additionalProperties: false
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
83
|
+
const validateFn = ajv.compile(schema);
|
|
84
|
+
|
|
85
|
+
function asString(value: unknown): string | undefined {
|
|
86
|
+
if (typeof value !== "string") return undefined;
|
|
87
|
+
const trimmed = value.trim();
|
|
88
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function asStringArray(value: unknown): string[] {
|
|
92
|
+
if (!Array.isArray(value)) return [];
|
|
93
|
+
return value
|
|
94
|
+
.map((item) => asString(item))
|
|
95
|
+
.filter((item): item is string => typeof item === "string");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function asContracts(value: unknown): BriefContractItem[] {
|
|
99
|
+
if (!Array.isArray(value)) return [];
|
|
100
|
+
return value
|
|
101
|
+
.map((item) => {
|
|
102
|
+
if (!item || typeof item !== "object") return null;
|
|
103
|
+
const record = item as Record<string, unknown>;
|
|
104
|
+
const id = asString(record.id);
|
|
105
|
+
const text = asString(record.text);
|
|
106
|
+
if (!id || !text) return null;
|
|
107
|
+
return { id, text };
|
|
108
|
+
})
|
|
109
|
+
.filter((item): item is BriefContractItem => item !== null);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function asConfidence(value: unknown): Record<string, number> | undefined {
|
|
113
|
+
if (!value || typeof value !== "object") return undefined;
|
|
114
|
+
const result: Record<string, number> = {};
|
|
115
|
+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
116
|
+
if (typeof raw !== "number" || Number.isNaN(raw)) continue;
|
|
117
|
+
result[key] = raw;
|
|
118
|
+
}
|
|
119
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeInput(input: Record<string, unknown>): NormalizedBrief {
|
|
123
|
+
const rawContracts = (input.contracts as Record<string, unknown> | undefined) ?? {};
|
|
124
|
+
return {
|
|
125
|
+
schema_version: asString(input.schema_version) ?? "1.0",
|
|
126
|
+
product_name: asString(input.product_name) ?? "",
|
|
127
|
+
problem: asString(input.problem) ?? "",
|
|
128
|
+
audience: asStringArray(input.audience),
|
|
129
|
+
goals: asStringArray(input.goals),
|
|
130
|
+
core_features: asStringArray(input.core_features),
|
|
131
|
+
constraints: asStringArray(input.constraints),
|
|
132
|
+
assumptions: asStringArray(input.assumptions),
|
|
133
|
+
contracts: {
|
|
134
|
+
goals: asContracts(rawContracts.goals),
|
|
135
|
+
core_features: asContracts(rawContracts.core_features),
|
|
136
|
+
constraints: asContracts(rawContracts.constraints)
|
|
137
|
+
},
|
|
138
|
+
confidence: asConfidence(input.confidence)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function buildContractsFromArrays(input: {
|
|
143
|
+
goals: string[];
|
|
144
|
+
core_features: string[];
|
|
145
|
+
constraints: string[];
|
|
146
|
+
}): BriefContracts {
|
|
147
|
+
return {
|
|
148
|
+
goals: input.goals.map((text, index) => ({ id: `G${index + 1}`, text })),
|
|
149
|
+
core_features: input.core_features.map((text, index) => ({ id: `F${index + 1}`, text })),
|
|
150
|
+
constraints: input.constraints.map((text, index) => ({ id: `C${index + 1}`, text }))
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function parseNormalizedBrief(input: Record<string, unknown>): {
|
|
155
|
+
brief: NormalizedBrief;
|
|
156
|
+
issues: ValidationIssue[];
|
|
157
|
+
} {
|
|
158
|
+
const normalized = normalizeInput(input);
|
|
159
|
+
const valid = validateFn(normalized);
|
|
160
|
+
if (valid) return { brief: normalized, issues: [] };
|
|
161
|
+
const errors = validateFn.errors ?? [];
|
|
162
|
+
const issues = errors.map((error) => ({
|
|
163
|
+
level: "error" as const,
|
|
164
|
+
code: "normalized_brief_invalid",
|
|
165
|
+
check: "schema" as const,
|
|
166
|
+
field: error.instancePath || error.schemaPath,
|
|
167
|
+
message: `Normalized brief schema error: ${error.message ?? "unknown error"}`,
|
|
168
|
+
suggestion: "Fix missing content in start brief and rerun `prodo normalize`."
|
|
169
|
+
}));
|
|
170
|
+
return { brief: normalized, issues };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function parseNormalizedBriefOrThrow(input: Record<string, unknown>): NormalizedBrief {
|
|
174
|
+
const { brief, issues } = parseNormalizedBrief(input);
|
|
175
|
+
if (issues.length > 0) {
|
|
176
|
+
const detail = issues.map((issue) => `- ${issue.message}`).join("\n");
|
|
177
|
+
throw new UserError(`Normalized brief is invalid:\n${detail}`);
|
|
178
|
+
}
|
|
179
|
+
return brief;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function requireConfidenceOrThrow(
|
|
183
|
+
brief: NormalizedBrief,
|
|
184
|
+
fields: Array<keyof Pick<NormalizedBrief, "product_name" | "problem" | "audience" | "goals" | "core_features">>,
|
|
185
|
+
threshold = 0.7
|
|
186
|
+
): void {
|
|
187
|
+
const confidence = brief.confidence ?? {};
|
|
188
|
+
const missing = fields.filter((field) => (confidence[field] ?? 0) < threshold);
|
|
189
|
+
if (missing.length > 0) {
|
|
190
|
+
throw new UserError(
|
|
191
|
+
`Normalization confidence too low for: ${missing.join(", ")}. Improve brief clarity and rerun \`prodo normalize\`.`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function contractIds(contracts: BriefContracts): {
|
|
197
|
+
goals: string[];
|
|
198
|
+
core_features: string[];
|
|
199
|
+
constraints: string[];
|
|
200
|
+
} {
|
|
201
|
+
return {
|
|
202
|
+
goals: contracts.goals.map((item) => item.id),
|
|
203
|
+
core_features: contracts.core_features.map((item) => item.id),
|
|
204
|
+
constraints: contracts.constraints.map((item) => item.id)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { outputIndexPath } from "./paths";
|
|
4
|
+
import type { ArtifactType } from "./types";
|
|
5
|
+
import { ensureDir, fileExists } from "./utils";
|
|
6
|
+
|
|
7
|
+
type ArtifactMap = Partial<Record<ArtifactType, string>>;
|
|
8
|
+
type ArtifactHistoryMap = Partial<Record<ArtifactType, string[]>>;
|
|
9
|
+
|
|
10
|
+
export type OutputIndex = {
|
|
11
|
+
active: ArtifactMap;
|
|
12
|
+
history: ArtifactHistoryMap;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function defaultIndex(): OutputIndex {
|
|
17
|
+
return {
|
|
18
|
+
active: {},
|
|
19
|
+
history: {},
|
|
20
|
+
updated_at: new Date(0).toISOString()
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function loadOutputIndex(cwd: string): Promise<OutputIndex> {
|
|
25
|
+
const indexPath = outputIndexPath(cwd);
|
|
26
|
+
if (!(await fileExists(indexPath))) return defaultIndex();
|
|
27
|
+
const raw = await fs.readFile(indexPath, "utf8");
|
|
28
|
+
const parsed = JSON.parse(raw) as Partial<OutputIndex>;
|
|
29
|
+
return {
|
|
30
|
+
active: parsed.active ?? {},
|
|
31
|
+
history: parsed.history ?? {},
|
|
32
|
+
updated_at: parsed.updated_at ?? new Date(0).toISOString()
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function saveOutputIndex(cwd: string, index: OutputIndex): Promise<void> {
|
|
37
|
+
const indexPath = outputIndexPath(cwd);
|
|
38
|
+
await ensureDir(path.dirname(indexPath));
|
|
39
|
+
await fs.writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`, "utf8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function setActiveArtifact(cwd: string, type: ArtifactType, filePath: string): Promise<void> {
|
|
43
|
+
const index = await loadOutputIndex(cwd);
|
|
44
|
+
const normalizedPath = path.resolve(filePath);
|
|
45
|
+
const existing = index.history[type] ?? [];
|
|
46
|
+
index.active[type] = normalizedPath;
|
|
47
|
+
index.history[type] = [normalizedPath, ...existing.filter((item) => item !== normalizedPath)].slice(0, 100);
|
|
48
|
+
index.updated_at = new Date().toISOString();
|
|
49
|
+
await saveOutputIndex(cwd, index);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getActiveArtifactPath(cwd: string, type: ArtifactType): Promise<string | undefined> {
|
|
53
|
+
const index = await loadOutputIndex(cwd);
|
|
54
|
+
const candidate = index.active[type];
|
|
55
|
+
if (!candidate) return undefined;
|
|
56
|
+
if (await fileExists(candidate)) return candidate;
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { defaultOutputDir, PRODO_DIR } from "./constants";
|
|
3
|
+
import type { ArtifactType } from "./types";
|
|
4
|
+
|
|
5
|
+
export function prodoPath(cwd: string): string {
|
|
6
|
+
return path.join(cwd, PRODO_DIR);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function briefPath(cwd: string): string {
|
|
10
|
+
return path.join(cwd, "brief.md");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizedBriefPath(cwd: string): string {
|
|
14
|
+
return path.join(prodoPath(cwd), "briefs", "normalized-brief.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function settingsPath(cwd: string): string {
|
|
18
|
+
return path.join(prodoPath(cwd), "settings.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registryPath(cwd: string): string {
|
|
22
|
+
return path.join(prodoPath(cwd), "registry.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function promptPath(cwd: string, artifactType: ArtifactType): string {
|
|
26
|
+
return path.join(prodoPath(cwd), "prompts", `${artifactType}.md`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function templatePath(cwd: string, artifactType: ArtifactType): string {
|
|
30
|
+
return path.join(prodoPath(cwd), "templates", `${artifactType}.md`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function overrideTemplatePath(cwd: string, artifactType: ArtifactType): string {
|
|
34
|
+
return path.join(prodoPath(cwd), "templates", "overrides", `${artifactType}.md`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function templateExtensionsForArtifact(artifactType: ArtifactType): string[] {
|
|
38
|
+
if (artifactType === "workflow") return ["md", "mmd"];
|
|
39
|
+
if (artifactType === "wireframe") return ["md", "html"];
|
|
40
|
+
return ["md"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function templateCandidatePaths(cwd: string, artifactType: ArtifactType): string[] {
|
|
44
|
+
const root = path.join(prodoPath(cwd), "templates");
|
|
45
|
+
return templateExtensionsForArtifact(artifactType).map((ext) => path.join(root, `${artifactType}.${ext}`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function overrideTemplateCandidatePaths(cwd: string, artifactType: ArtifactType): string[] {
|
|
49
|
+
const root = path.join(prodoPath(cwd), "templates", "overrides");
|
|
50
|
+
return templateExtensionsForArtifact(artifactType).map((ext) => path.join(root, `${artifactType}.${ext}`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function schemaPath(cwd: string, artifactType: ArtifactType): string {
|
|
54
|
+
return path.join(prodoPath(cwd), "schemas", `${artifactType}.yaml`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function outputDirPath(cwd: string, artifactType: ArtifactType, outputDirOverride?: string): string {
|
|
58
|
+
return path.join(cwd, "product-docs", outputDirOverride ?? defaultOutputDir(artifactType));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function reportPath(cwd: string): string {
|
|
62
|
+
return path.join(cwd, "product-docs", "reports", "latest-validation.md");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function outputIndexPath(cwd: string): string {
|
|
66
|
+
return path.join(prodoPath(cwd), "state", "index.json");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function outputContextDirPath(cwd: string): string {
|
|
70
|
+
return path.join(prodoPath(cwd), "state", "context");
|
|
71
|
+
}
|
|
72
|
+
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { UserError } from "./errors";
|
|
5
|
+
import { readProjectConfig } from "./project-config";
|
|
6
|
+
import { ensureDir, fileExists } from "./utils";
|
|
7
|
+
|
|
8
|
+
type PresetManifest = {
|
|
9
|
+
name: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
priority?: number;
|
|
12
|
+
min_prodo_version?: string;
|
|
13
|
+
max_prodo_version?: string;
|
|
14
|
+
command_packs?: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type CopyOp = {
|
|
18
|
+
source: string;
|
|
19
|
+
target: string;
|
|
20
|
+
priority: number;
|
|
21
|
+
order: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function parseVersion(version: string): number[] {
|
|
25
|
+
return version.split(".").map((part) => Number(part.replace(/[^0-9]/g, "")) || 0).slice(0, 3);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cmpVersion(a: string, b: string): number {
|
|
29
|
+
const left = parseVersion(a);
|
|
30
|
+
const right = parseVersion(b);
|
|
31
|
+
for (let i = 0; i < 3; i++) {
|
|
32
|
+
if ((left[i] ?? 0) > (right[i] ?? 0)) return 1;
|
|
33
|
+
if ((left[i] ?? 0) < (right[i] ?? 0)) return -1;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readPresetManifest(presetDir: string): Promise<PresetManifest> {
|
|
39
|
+
const candidates = ["preset.yaml", "preset.yml", "preset.json"];
|
|
40
|
+
for (const name of candidates) {
|
|
41
|
+
const file = path.join(presetDir, name);
|
|
42
|
+
if (!(await fileExists(file))) continue;
|
|
43
|
+
if (name.endsWith(".json")) {
|
|
44
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
|
|
45
|
+
const presetName = typeof parsed.name === "string" ? parsed.name.trim() : path.basename(presetDir);
|
|
46
|
+
return {
|
|
47
|
+
name: presetName,
|
|
48
|
+
version: typeof parsed.version === "string" ? parsed.version : undefined,
|
|
49
|
+
priority: typeof parsed.priority === "number" ? parsed.priority : 0,
|
|
50
|
+
min_prodo_version: typeof parsed.min_prodo_version === "string" ? parsed.min_prodo_version : undefined,
|
|
51
|
+
max_prodo_version: typeof parsed.max_prodo_version === "string" ? parsed.max_prodo_version : undefined,
|
|
52
|
+
command_packs: Array.isArray(parsed.command_packs)
|
|
53
|
+
? parsed.command_packs.filter((item): item is string => typeof item === "string")
|
|
54
|
+
: []
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const parsed = (yaml.load(await fs.readFile(file, "utf8")) as Record<string, unknown>) ?? {};
|
|
58
|
+
const presetName = typeof parsed.name === "string" ? parsed.name.trim() : path.basename(presetDir);
|
|
59
|
+
return {
|
|
60
|
+
name: presetName,
|
|
61
|
+
version: typeof parsed.version === "string" ? parsed.version : undefined,
|
|
62
|
+
priority: typeof parsed.priority === "number" ? parsed.priority : 0,
|
|
63
|
+
min_prodo_version: typeof parsed.min_prodo_version === "string" ? parsed.min_prodo_version : undefined,
|
|
64
|
+
max_prodo_version: typeof parsed.max_prodo_version === "string" ? parsed.max_prodo_version : undefined,
|
|
65
|
+
command_packs: Array.isArray(parsed.command_packs)
|
|
66
|
+
? parsed.command_packs.filter((item): item is string => typeof item === "string")
|
|
67
|
+
: []
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
throw new UserError(`Preset manifest is missing in ${presetDir} (expected preset.yaml or preset.json).`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function collectFilesRecursive(rootDir: string): Promise<string[]> {
|
|
74
|
+
if (!(await fileExists(rootDir))) return [];
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
const walk = async (current: string): Promise<void> => {
|
|
77
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const full = path.join(current, entry.name);
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
await walk(full);
|
|
82
|
+
} else {
|
|
83
|
+
out.push(full);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
await walk(rootDir);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function collectPresetOps(
|
|
92
|
+
presetDir: string,
|
|
93
|
+
prodoRoot: string,
|
|
94
|
+
priority: number,
|
|
95
|
+
order: number
|
|
96
|
+
): Promise<CopyOp[]> {
|
|
97
|
+
const lanes = ["prompts", "schemas", "templates", "commands"];
|
|
98
|
+
const ops: CopyOp[] = [];
|
|
99
|
+
for (const lane of lanes) {
|
|
100
|
+
const sourceBase = path.join(presetDir, lane);
|
|
101
|
+
const files = await collectFilesRecursive(sourceBase);
|
|
102
|
+
for (const source of files) {
|
|
103
|
+
const relative = path.relative(sourceBase, source);
|
|
104
|
+
ops.push({
|
|
105
|
+
source,
|
|
106
|
+
target: path.join(prodoRoot, lane, relative),
|
|
107
|
+
priority,
|
|
108
|
+
order
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return ops;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function resolvePresetDir(projectRoot: string, presetName: string): Promise<string> {
|
|
116
|
+
const candidates = [
|
|
117
|
+
path.join(projectRoot, "presets", presetName),
|
|
118
|
+
path.resolve(__dirname, "..", "presets", presetName)
|
|
119
|
+
];
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
if (await fileExists(candidate)) return candidate;
|
|
122
|
+
}
|
|
123
|
+
throw new UserError(`Preset not found: ${presetName}. Create presets/${presetName} with a preset manifest.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function writeInstalledPresets(prodoRoot: string, names: string[]): Promise<void> {
|
|
127
|
+
const file = path.join(prodoRoot, "presets", "installed.json");
|
|
128
|
+
await ensureDir(path.dirname(file));
|
|
129
|
+
await fs.writeFile(file, `${JSON.stringify(Array.from(new Set(names)).sort(), null, 2)}\n`, "utf8");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readInstalledPresets(prodoRoot: string): Promise<string[]> {
|
|
133
|
+
const file = path.join(prodoRoot, "presets", "installed.json");
|
|
134
|
+
if (!(await fileExists(file))) return [];
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8")) as unknown;
|
|
137
|
+
if (!Array.isArray(parsed)) return [];
|
|
138
|
+
return parsed
|
|
139
|
+
.filter((item): item is string => typeof item === "string")
|
|
140
|
+
.map((item) => item.trim())
|
|
141
|
+
.filter((item) => item.length > 0);
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function applyCopyOps(ops: CopyOp[]): Promise<void> {
|
|
148
|
+
const selected = new Map<string, CopyOp>();
|
|
149
|
+
for (const op of ops) {
|
|
150
|
+
const current = selected.get(op.target);
|
|
151
|
+
if (!current) {
|
|
152
|
+
selected.set(op.target, op);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (op.priority > current.priority || (op.priority === current.priority && op.order > current.order)) {
|
|
156
|
+
selected.set(op.target, op);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const op of selected.values()) {
|
|
160
|
+
await ensureDir(path.dirname(op.target));
|
|
161
|
+
await fs.copyFile(op.source, op.target);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function collectCommandPackOps(projectRoot: string, prodoRoot: string, names: string[]): Promise<CopyOp[]> {
|
|
166
|
+
const ops: CopyOp[] = [];
|
|
167
|
+
for (const [index, name] of names.entries()) {
|
|
168
|
+
const base = path.join(projectRoot, "command-packs", name);
|
|
169
|
+
if (!(await fileExists(base))) {
|
|
170
|
+
throw new UserError(`Command pack not found: command-packs/${name}`);
|
|
171
|
+
}
|
|
172
|
+
const laneMap: Record<string, string> = {
|
|
173
|
+
commands: "commands"
|
|
174
|
+
};
|
|
175
|
+
for (const [sourceLane, targetLane] of Object.entries(laneMap)) {
|
|
176
|
+
const sourceBase = path.join(base, sourceLane);
|
|
177
|
+
const files = await collectFilesRecursive(sourceBase);
|
|
178
|
+
for (const source of files) {
|
|
179
|
+
const relative = path.relative(sourceBase, source);
|
|
180
|
+
ops.push({
|
|
181
|
+
source,
|
|
182
|
+
target: path.join(prodoRoot, targetLane, relative),
|
|
183
|
+
priority: 100,
|
|
184
|
+
order: index
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return ops;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function applyConfiguredPresets(
|
|
193
|
+
projectRoot: string,
|
|
194
|
+
prodoRoot: string,
|
|
195
|
+
prodoVersion: string,
|
|
196
|
+
presetOverride?: string
|
|
197
|
+
): Promise<{ installedPresets: string[]; appliedFiles: string[] }> {
|
|
198
|
+
const config = await readProjectConfig(projectRoot);
|
|
199
|
+
const presets = Array.from(new Set([...(config.presets ?? []), ...(presetOverride ? [presetOverride] : [])]));
|
|
200
|
+
const existingInstalled = await readInstalledPresets(prodoRoot);
|
|
201
|
+
const allOps: CopyOp[] = [];
|
|
202
|
+
const installedNames: string[] = [...existingInstalled];
|
|
203
|
+
const commandPacks = new Set<string>(config.command_packs ?? []);
|
|
204
|
+
|
|
205
|
+
for (const [order, presetName] of presets.entries()) {
|
|
206
|
+
const presetDir = await resolvePresetDir(projectRoot, presetName);
|
|
207
|
+
const manifest = await readPresetManifest(presetDir);
|
|
208
|
+
if (manifest.min_prodo_version && cmpVersion(prodoVersion, manifest.min_prodo_version) < 0) {
|
|
209
|
+
throw new UserError(
|
|
210
|
+
`Preset ${presetName} requires prodo >= ${manifest.min_prodo_version}, current is ${prodoVersion}.`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (manifest.max_prodo_version && cmpVersion(prodoVersion, manifest.max_prodo_version) > 0) {
|
|
214
|
+
throw new UserError(
|
|
215
|
+
`Preset ${presetName} supports prodo <= ${manifest.max_prodo_version}, current is ${prodoVersion}.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
for (const pack of manifest.command_packs ?? []) {
|
|
219
|
+
if (pack.trim()) commandPacks.add(pack.trim());
|
|
220
|
+
}
|
|
221
|
+
installedNames.push(manifest.name || presetName);
|
|
222
|
+
allOps.push(...(await collectPresetOps(presetDir, prodoRoot, manifest.priority ?? 0, order)));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const commandPackList = Array.from(commandPacks);
|
|
226
|
+
if (commandPackList.length > 0) {
|
|
227
|
+
const commandPackOps = await collectCommandPackOps(projectRoot, prodoRoot, commandPackList);
|
|
228
|
+
allOps.push(...commandPackOps);
|
|
229
|
+
}
|
|
230
|
+
if (allOps.length > 0) await applyCopyOps(allOps);
|
|
231
|
+
await writeInstalledPresets(prodoRoot, installedNames);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
installedPresets: Array.from(new Set(installedNames)),
|
|
235
|
+
appliedFiles: Array.from(new Set(allOps.map((item) => item.target)))
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ContractCoverage } from "./types";
|
|
4
|
+
import { UserError } from "./errors";
|
|
5
|
+
import { fileExists } from "./utils";
|
|
6
|
+
|
|
7
|
+
export type ArtifactConfig = {
|
|
8
|
+
name: string;
|
|
9
|
+
output_dir?: string;
|
|
10
|
+
required_headings?: string[];
|
|
11
|
+
upstream?: string[];
|
|
12
|
+
required_contracts?: Array<keyof ContractCoverage>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ProdoProjectConfig = {
|
|
16
|
+
presets?: string[];
|
|
17
|
+
artifacts?: ArtifactConfig[];
|
|
18
|
+
command_packs?: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function sanitizeStringArray(value: unknown): string[] {
|
|
22
|
+
if (!Array.isArray(value)) return [];
|
|
23
|
+
return value
|
|
24
|
+
.filter((item): item is string => typeof item === "string")
|
|
25
|
+
.map((item) => item.trim())
|
|
26
|
+
.filter((item) => item.length > 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sanitizeArtifact(raw: unknown): ArtifactConfig | null {
|
|
30
|
+
if (!raw || typeof raw !== "object") return null;
|
|
31
|
+
const rec = raw as Record<string, unknown>;
|
|
32
|
+
const name = typeof rec.name === "string" ? rec.name.trim() : "";
|
|
33
|
+
if (!name) return null;
|
|
34
|
+
const outputDir = typeof rec.output_dir === "string" ? rec.output_dir.trim() : undefined;
|
|
35
|
+
const requiredHeadings = sanitizeStringArray(rec.required_headings);
|
|
36
|
+
const upstream = sanitizeStringArray(rec.upstream);
|
|
37
|
+
const requiredContracts = sanitizeStringArray(rec.required_contracts)
|
|
38
|
+
.filter((value): value is keyof ContractCoverage =>
|
|
39
|
+
value === "goals" || value === "core_features" || value === "constraints");
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
...(outputDir ? { output_dir: outputDir } : {}),
|
|
43
|
+
...(requiredHeadings.length > 0 ? { required_headings: requiredHeadings } : {}),
|
|
44
|
+
...(upstream.length > 0 ? { upstream } : {}),
|
|
45
|
+
...(requiredContracts.length > 0 ? { required_contracts: requiredContracts } : {})
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sanitizeConfig(raw: unknown): ProdoProjectConfig {
|
|
50
|
+
if (!raw || typeof raw !== "object") return {};
|
|
51
|
+
const rec = raw as Record<string, unknown>;
|
|
52
|
+
const artifacts = Array.isArray(rec.artifacts)
|
|
53
|
+
? rec.artifacts.map(sanitizeArtifact).filter((item): item is ArtifactConfig => item !== null)
|
|
54
|
+
: [];
|
|
55
|
+
return {
|
|
56
|
+
presets: sanitizeStringArray(rec.presets),
|
|
57
|
+
command_packs: sanitizeStringArray(rec.command_packs),
|
|
58
|
+
...(artifacts.length > 0 ? { artifacts } : {})
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readProjectConfig(cwd: string): Promise<ProdoProjectConfig> {
|
|
63
|
+
const candidates = [
|
|
64
|
+
path.join(cwd, ".prodo", "config.json"),
|
|
65
|
+
path.join(cwd, "prodo.config.json")
|
|
66
|
+
];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
if (!(await fileExists(candidate))) continue;
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(await fs.readFile(candidate, "utf8")) as unknown;
|
|
71
|
+
return sanitizeConfig(parsed);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new UserError(`Invalid project config JSON: ${candidate}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LLMProvider } from "../types";
|
|
2
|
+
import { MockProvider } from "./mock-provider";
|
|
3
|
+
import { OpenAIProvider } from "./openai-provider";
|
|
4
|
+
|
|
5
|
+
export function createProvider(): LLMProvider {
|
|
6
|
+
const provider = (process.env.PRODO_LLM_PROVIDER ?? "mock").toLowerCase();
|
|
7
|
+
if (provider === "openai") {
|
|
8
|
+
return new OpenAIProvider();
|
|
9
|
+
}
|
|
10
|
+
return new MockProvider();
|
|
11
|
+
}
|
|
12
|
+
|