prdforge-cli 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.
@@ -0,0 +1,65 @@
1
+ import { Command } from "commander";
2
+ import { writeFileSync, mkdirSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { projects, exports_, isAuthError } from "../api/client.js";
5
+ import { config } from "../utils/config.js";
6
+ import { success, error, info, dim } from "../utils/output.js";
7
+
8
+ const DEFAULT_INTERVAL_SEC = 30;
9
+ const DEFAULT_OUTPUT = "PRD.md";
10
+
11
+ export function watchCommand() {
12
+ const cmd = new Command("watch")
13
+ .description("Watch a PRD project and re-export markdown when it changes (live sync during development)")
14
+ .argument("<projectId>", "Project ID to watch")
15
+ .option("-o, --output <path>", "Output file path (default: PRD.md)", DEFAULT_OUTPUT)
16
+ .option("-i, --interval <seconds>", "Poll interval in seconds", String(DEFAULT_INTERVAL_SEC))
17
+ .action(async (projectId, opts) => {
18
+ const intervalSec = Math.max(5, parseInt(opts.interval, 10) || DEFAULT_INTERVAL_SEC);
19
+ const outputPath = opts.output || DEFAULT_OUTPUT;
20
+
21
+ let lastUpdatedAt = null;
22
+ let firstRun = true;
23
+
24
+ const exportOnce = async () => {
25
+ try {
26
+ const project = await projects.get(projectId);
27
+ const updatedAt = project?.updated_at ?? null;
28
+ if (firstRun) {
29
+ firstRun = false;
30
+ lastUpdatedAt = updatedAt;
31
+ } else if (updatedAt === lastUpdatedAt) {
32
+ return;
33
+ }
34
+ lastUpdatedAt = updatedAt;
35
+
36
+ const result = await exports_.export(projectId, "markdown");
37
+ const content =
38
+ result.content ?? result.data ?? (typeof result === "string" ? result : JSON.stringify(result, null, 2));
39
+ const fullPath = join(process.cwd(), outputPath);
40
+ mkdirSync(dirname(fullPath), { recursive: true });
41
+ writeFileSync(fullPath, content, "utf8");
42
+ success(`Exported to ${fullPath}`);
43
+ dim(` Updated at: ${updatedAt ? new Date(updatedAt).toISOString() : "—"}`);
44
+ } catch (err) {
45
+ error(err.message);
46
+ if (isAuthError(err)) process.exit(2);
47
+ // Don't exit on transient errors; next poll will retry
48
+ }
49
+ };
50
+
51
+ info(`Watching project ${projectId.slice(0, 8)}… (every ${intervalSec}s). Press Ctrl+C to stop.`);
52
+ await exportOnce();
53
+ const timer = setInterval(exportOnce, intervalSec * 1000);
54
+
55
+ const shutdown = () => {
56
+ clearInterval(timer);
57
+ info("Stopped watching.");
58
+ process.exit(0);
59
+ };
60
+ process.on("SIGINT", shutdown);
61
+ process.on("SIGTERM", shutdown);
62
+ });
63
+
64
+ return cmd;
65
+ }
@@ -0,0 +1,24 @@
1
+ import { Command } from "commander";
2
+ import { user, isAuthError } from "../api/client.js";
3
+ import { getEmail, requireApiKey } from "../utils/config.js";
4
+ import { header, dim, error } from "../utils/output.js";
5
+
6
+ export function whoamiCommand() {
7
+ return new Command("whoami")
8
+ .description("Show current authenticated user and usage")
9
+ .action(async () => {
10
+ requireApiKey();
11
+ try {
12
+ const data = await user.validate();
13
+ const email = getEmail() || data.email || "(unknown)";
14
+ header("Current User");
15
+ dim(` Email: ${email}`);
16
+ dim(` User ID: ${data.user_id}`);
17
+ dim(` Subscription: ${data.subscription}`);
18
+ dim(` Credits used: ${data.credits_used_this_month} this month`);
19
+ } catch (err) {
20
+ error(err.message);
21
+ process.exit(isAuthError(err) ? 2 : 1);
22
+ }
23
+ });
24
+ }
package/src/index.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { authCommand } from "./commands/auth.js";
4
+ import { prdCommand } from "./commands/prd.js";
5
+ import { exportCommand } from "./commands/export.js";
6
+ import { generateCommand } from "./commands/generate.js";
7
+ import { configCommand } from "./commands/config.js";
8
+ import { watchCommand } from "./commands/watch.js";
9
+ import { mcpCommand } from "./commands/mcp.js";
10
+ import { bulkCommand } from "./commands/bulk.js";
11
+ import { whoamiCommand } from "./commands/whoami.js";
12
+ import { creditsCommand } from "./commands/credits.js";
13
+ import { dashboardCommand } from "./commands/dashboard.js";
14
+
15
+ const LOGO = `
16
+ \x1b[1m\x1b[36m PRDForge CLI\x1b[0m \x1b[2mv0.1.0\x1b[0m
17
+ \x1b[90mThe planning layer for AI-first development\x1b[0m
18
+ `;
19
+
20
+ const program = new Command();
21
+
22
+ program
23
+ .name("prdforge")
24
+ .description("PRDForge CLI — generate, manage, and export PRDs from your terminal or AI agents")
25
+ .version("0.1.0", "-v, --version")
26
+ .addHelpText("beforeAll", LOGO)
27
+ .hook("preAction", async () => {
28
+ // Load .env from cwd if present
29
+ try {
30
+ const { config: dotenv } = await import("dotenv");
31
+ dotenv();
32
+ } catch {}
33
+ });
34
+
35
+ program.addCommand(authCommand());
36
+ program.addCommand(whoamiCommand());
37
+ program.addCommand(creditsCommand());
38
+ program.addCommand(dashboardCommand());
39
+ program.addCommand(generateCommand());
40
+ program.addCommand(prdCommand());
41
+ program.addCommand(exportCommand(), { isDefault: false });
42
+ program.addCommand(watchCommand());
43
+ program.addCommand(mcpCommand());
44
+ program.addCommand(bulkCommand());
45
+ program.addCommand(configCommand());
46
+
47
+ // Friendly help for unknown commands
48
+ program.on("command:*", (cmds) => {
49
+ console.error(`\x1b[31m✖\x1b[0m Unknown command: ${cmds[0]}`);
50
+ console.log("Run \x1b[36mprdforge --help\x1b[0m for a list of commands.");
51
+ process.exit(1);
52
+ });
53
+
54
+ // When run with no arguments: open dashboard if authenticated, else show help
55
+ if (process.argv.length === 2) {
56
+ const { getApiKey } = await import("./utils/config.js");
57
+ if (getApiKey()) {
58
+ const { dashboardCommand } = await import("./commands/dashboard.js");
59
+ await dashboardCommand().parseAsync([], { from: "user" });
60
+ } else {
61
+ program.help();
62
+ }
63
+ }
64
+
65
+ program.parseAsync(process.argv);
@@ -0,0 +1,263 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput, useApp } from "ink";
3
+ import { PromptBox } from "./PromptBox.js";
4
+ import { theme } from "./theme.js";
5
+
6
+ const TOTAL_STAGES = 8;
7
+
8
+ // ── Stage dots row ────────────────────────────────────────────────────────────
9
+
10
+ function StageDots({ completedStages, stale }) {
11
+ const count = completedStages || 0;
12
+ const dots = theme.stages.map((_, i) => {
13
+ if (stale && i === count - 1) {
14
+ return React.createElement(Text, { key: i, color: theme.warning }, "⚠");
15
+ }
16
+ if (i < count) {
17
+ return React.createElement(Text, { key: i, color: theme.success }, "✓");
18
+ }
19
+ return React.createElement(Text, { key: i, color: theme.dim }, "●");
20
+ });
21
+
22
+ return React.createElement(
23
+ Box,
24
+ { gap: 1 },
25
+ ...dots,
26
+ React.createElement(Text, { color: theme.dim }, ` ${count}/${TOTAL_STAGES}`)
27
+ );
28
+ }
29
+
30
+ // ── Dashboard ─────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Interactive dashboard TUI with two modes:
34
+ * "browse" — arrow-key navigate projects, n to enter compose
35
+ * "compose" — type a prompt, Tab to cycle model, Enter to create
36
+ *
37
+ * On create: exits TUI and calls onCreateNew({ name, prompt, modelId }).
38
+ * On Enter (browse): exits and prints the project workspace URL.
39
+ */
40
+ export function Dashboard({ onCreateNew } = {}) {
41
+ const { exit } = useApp();
42
+
43
+ // Data
44
+ const [projectList, setProjectList] = useState([]);
45
+ const [userInfo, setUserInfo] = useState(null);
46
+ const [modelList, setModelList] = useState([]);
47
+
48
+ // UI state
49
+ const [mode, setMode] = useState("browse"); // "browse" | "compose"
50
+ const [selectedIdx, setSelectedIdx] = useState(0);
51
+ const [loading, setLoading] = useState(true);
52
+ const [statusMsg, setStatusMsg] = useState(null);
53
+
54
+ // Compose state
55
+ const [promptText, setPromptText] = useState("");
56
+ const [modelIdx, setModelIdx] = useState(0);
57
+
58
+ // ── Load data ──────────────────────────────────────────────────────────────
59
+
60
+ async function loadData() {
61
+ setLoading(true);
62
+ setStatusMsg(null);
63
+ try {
64
+ const { projects, user, models } = await import("../api/client.js");
65
+ const [projs, info, mods] = await Promise.all([
66
+ projects.list(),
67
+ user.validate(),
68
+ models.list(),
69
+ ]);
70
+ setProjectList(Array.isArray(projs) ? projs : []);
71
+ setUserInfo(info);
72
+ setModelList(Array.isArray(mods) && mods.length > 0 ? mods : [
73
+ { model_id: null, name: "Default", is_free_tier: true, credit_cost: 4 },
74
+ ]);
75
+ } catch (err) {
76
+ setStatusMsg(`Error: ${err.message}`);
77
+ } finally {
78
+ setLoading(false);
79
+ }
80
+ }
81
+
82
+ useEffect(() => { loadData(); }, []);
83
+
84
+ // ── Keyboard handling ──────────────────────────────────────────────────────
85
+
86
+ useInput((input, key) => {
87
+ if (loading) return;
88
+
89
+ if (mode === "compose") {
90
+ if (key.escape) {
91
+ setMode("browse");
92
+ return;
93
+ }
94
+ if (key.tab) {
95
+ setModelIdx((i) => (i + 1) % modelList.length);
96
+ return;
97
+ }
98
+ if (key.return && promptText.trim()) {
99
+ const chosen = modelList[modelIdx];
100
+ exit();
101
+ if (typeof onCreateNew === "function") {
102
+ onCreateNew({
103
+ prompt: promptText.trim(),
104
+ modelId: chosen?.model_id ?? null,
105
+ });
106
+ }
107
+ return;
108
+ }
109
+ // All other keystrokes go to TextInput via onChange
110
+ return;
111
+ }
112
+
113
+ // browse mode
114
+ if (input === "q" || key.escape) { exit(); return; }
115
+ if (key.upArrow || input === "k") {
116
+ setSelectedIdx((i) => Math.max(0, i - 1));
117
+ }
118
+ if (key.downArrow || input === "j") {
119
+ setSelectedIdx((i) => Math.min(Math.max(0, projectList.length - 1), i + 1));
120
+ }
121
+ if (key.return && projectList[selectedIdx]) {
122
+ const p = projectList[selectedIdx];
123
+ exit();
124
+ process.stdout.write(`\nhttps://prdforge.netlify.app/workspace/${p.id}\n\n`);
125
+ }
126
+ if (input === "n" || input === "c") {
127
+ setMode("compose");
128
+ }
129
+ if (input === "r") {
130
+ loadData();
131
+ }
132
+ });
133
+
134
+ // ── Header ─────────────────────────────────────────────────────────────────
135
+
136
+ const headerRow = React.createElement(
137
+ Box,
138
+ { justifyContent: "space-between", marginBottom: 1 },
139
+ React.createElement(
140
+ Box,
141
+ { gap: 1 },
142
+ React.createElement(Text, { bold: true, color: theme.primary }, "PRDForge")
143
+ ),
144
+ userInfo
145
+ ? React.createElement(
146
+ Text,
147
+ { color: theme.dim },
148
+ [
149
+ userInfo.email,
150
+ userInfo.credits_used_this_month != null
151
+ ? `${userInfo.credits_used_this_month} credits used`
152
+ : null,
153
+ userInfo.subscription,
154
+ ]
155
+ .filter(Boolean)
156
+ .join(" · ")
157
+ )
158
+ : null
159
+ );
160
+
161
+ // ── Prompt box ─────────────────────────────────────────────────────────────
162
+
163
+ const promptBox = React.createElement(PromptBox, {
164
+ focused: mode === "compose",
165
+ value: promptText,
166
+ onChange: setPromptText,
167
+ models: modelList,
168
+ modelIdx,
169
+ onTabModel: () => setModelIdx((i) => (i + 1) % modelList.length),
170
+ onSubmit: ({ prompt, modelId }) => {
171
+ exit();
172
+ if (typeof onCreateNew === "function") onCreateNew({ prompt, modelId });
173
+ },
174
+ onFocus: () => setMode("compose"),
175
+ onCancel: () => setMode("browse"),
176
+ });
177
+
178
+ // ── Project list ───────────────────────────────────────────────────────────
179
+
180
+ let projectSection;
181
+ if (loading) {
182
+ projectSection = React.createElement(
183
+ Box,
184
+ { marginY: 1 },
185
+ React.createElement(Text, { color: theme.dim }, "Loading…")
186
+ );
187
+ } else if (statusMsg) {
188
+ projectSection = React.createElement(
189
+ Box,
190
+ { marginY: 1 },
191
+ React.createElement(Text, { color: theme.error }, statusMsg)
192
+ );
193
+ } else if (projectList.length === 0) {
194
+ projectSection = React.createElement(
195
+ Box,
196
+ { marginY: 1 },
197
+ React.createElement(Text, { color: theme.dim }, "No projects yet. Press "),
198
+ React.createElement(Text, { color: theme.primary, bold: true }, "n"),
199
+ React.createElement(Text, { color: theme.dim }, " above to create one.")
200
+ );
201
+ } else {
202
+ const rows = projectList.map((p, i) => {
203
+ const isSelected = i === selectedIdx && mode === "browse";
204
+ const nameStr = (p.name || "(untitled)").padEnd(34).slice(0, 34);
205
+ return React.createElement(
206
+ Box,
207
+ { key: p.id },
208
+ React.createElement(
209
+ Text,
210
+ { color: isSelected ? theme.primary : theme.dim, bold: isSelected },
211
+ isSelected ? " ❯ " : " "
212
+ ),
213
+ React.createElement(
214
+ Text,
215
+ { color: isSelected ? theme.white : theme.dim, bold: isSelected },
216
+ nameStr
217
+ ),
218
+ React.createElement(Text, { color: theme.dim }, " "),
219
+ React.createElement(StageDots, {
220
+ completedStages: p.completed_stages,
221
+ stale: p.has_stale_stages,
222
+ })
223
+ );
224
+ });
225
+
226
+ projectSection = React.createElement(
227
+ Box,
228
+ { flexDirection: "column" },
229
+ React.createElement(
230
+ Box,
231
+ { marginBottom: 1 },
232
+ React.createElement(Text, { color: theme.dim, bold: true }, " Your Projects"),
233
+ React.createElement(
234
+ Text,
235
+ { color: theme.dim },
236
+ ` (${projectList.length})`
237
+ )
238
+ ),
239
+ ...rows
240
+ );
241
+ }
242
+
243
+ // ── Footer ─────────────────────────────────────────────────────────────────
244
+
245
+ const footerHints = mode === "compose"
246
+ ? "Tab: cycle model Enter: create Esc: cancel"
247
+ : "n compose ↑↓ navigate Enter open r refresh q quit";
248
+
249
+ const footer = React.createElement(
250
+ Box,
251
+ { marginTop: 1, borderStyle: "single", borderColor: theme.dim, borderBottom: false, borderLeft: false, borderRight: false },
252
+ React.createElement(Text, { color: theme.dim }, footerHints)
253
+ );
254
+
255
+ return React.createElement(
256
+ Box,
257
+ { flexDirection: "column", paddingX: 1, paddingTop: 1 },
258
+ headerRow,
259
+ promptBox,
260
+ projectSection,
261
+ footer
262
+ );
263
+ }
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { StageIndicator } from "./StageIndicator.js";
4
+ import { theme } from "./theme.js";
5
+
6
+ /**
7
+ * Animated PRD creation progress view.
8
+ *
9
+ * @param {{ projectName: string, stages: Array<{ label: string, status: string }> }} props
10
+ */
11
+ export function PrdCreation({ projectName, stages }) {
12
+ return React.createElement(
13
+ Box,
14
+ { flexDirection: "column", paddingX: 1, paddingY: 0 },
15
+
16
+ // Header
17
+ React.createElement(
18
+ Box,
19
+ { marginBottom: 1 },
20
+ React.createElement(
21
+ Text,
22
+ { bold: true, color: theme.primary },
23
+ "Creating PRD"
24
+ ),
25
+ React.createElement(Text, { color: theme.dim }, " "),
26
+ React.createElement(Text, { color: theme.white }, projectName)
27
+ ),
28
+
29
+ // Stage rows
30
+ ...stages.map((stage, i) =>
31
+ React.createElement(
32
+ Box,
33
+ { key: i, marginLeft: 2, marginBottom: 0 },
34
+ React.createElement(StageIndicator, { label: stage.label, status: stage.status })
35
+ )
36
+ )
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Build the initial stages array (all pending).
42
+ * @param {string[]} labels
43
+ * @returns {Array<{ label: string, status: string }>}
44
+ */
45
+ export function buildStages(labels) {
46
+ return labels.map((label) => ({ label, status: "pending" }));
47
+ }
48
+
49
+ /**
50
+ * Returns a new stages array with the given index set to the given status.
51
+ * @param {Array<{ label: string, status: string }>} stages
52
+ * @param {number} index
53
+ * @param {string} status
54
+ */
55
+ export function setStageStatus(stages, index, status) {
56
+ return stages.map((s, i) => (i === index ? { ...s, status } : s));
57
+ }
58
+
59
+ /**
60
+ * Mark all remaining pending/active stages as done.
61
+ */
62
+ export function markAllDone(stages) {
63
+ return stages.map((s) =>
64
+ s.status === "pending" || s.status === "active" ? { ...s, status: "done" } : s
65
+ );
66
+ }
@@ -0,0 +1,129 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import { theme } from "./theme.js";
5
+
6
+ /**
7
+ * Styled prompt box matching the main app's overview "Start with AI" section.
8
+ *
9
+ * Props:
10
+ * focused boolean — whether text input is active
11
+ * value string — current prompt text
12
+ * onChange fn — called with new string as user types
13
+ * models Array — [{ model_id, name, credit_cost, is_free_tier }]
14
+ * modelIdx number — index into models array
15
+ * onTabModel fn — called when Tab pressed to cycle model
16
+ * onSubmit fn — called with { prompt, modelId } when Enter pressed
17
+ * onFocus fn — called when user presses key to focus the box
18
+ * onCancel fn — called when Esc pressed while focused
19
+ */
20
+ export function PromptBox({
21
+ focused,
22
+ value,
23
+ onChange,
24
+ models,
25
+ modelIdx,
26
+ onTabModel,
27
+ onSubmit,
28
+ onFocus,
29
+ onCancel,
30
+ }) {
31
+ const currentModel = models?.[modelIdx] ?? { name: "Default", credit_cost: 4 };
32
+ const creditCost = (currentModel.credit_cost ?? 4) * 4; // 4 calls for full generation
33
+ const modelLabel = currentModel.name;
34
+
35
+ // ── Title bar ───────────────────────────────────────────────────
36
+ const titleBar = React.createElement(
37
+ Box,
38
+ { justifyContent: "space-between" },
39
+ React.createElement(
40
+ Text,
41
+ { color: focused ? theme.primary : theme.dim, bold: focused },
42
+ focused ? "✦ Start with AI" : "✦ Start with AI"
43
+ ),
44
+ React.createElement(
45
+ Text,
46
+ { color: theme.dim },
47
+ focused
48
+ ? `~${creditCost} credits`
49
+ : "n to compose"
50
+ )
51
+ );
52
+
53
+ // ── Input area ──────────────────────────────────────────────────
54
+ let inputArea;
55
+ if (focused) {
56
+ inputArea = React.createElement(
57
+ Box,
58
+ { marginTop: 1, marginBottom: 1 },
59
+ React.createElement(TextInput, {
60
+ value,
61
+ onChange,
62
+ placeholder: "Describe your product idea (e.g. 'A Kanban app for remote teams with Slack integration')",
63
+ focus: true,
64
+ })
65
+ );
66
+ } else {
67
+ inputArea = React.createElement(
68
+ Box,
69
+ { marginTop: 1, marginBottom: 1 },
70
+ React.createElement(
71
+ Text,
72
+ { color: theme.dim },
73
+ value.length > 0
74
+ ? value.length > 72
75
+ ? value.slice(0, 69) + "…"
76
+ : value
77
+ : "Describe your product idea…"
78
+ )
79
+ );
80
+ }
81
+
82
+ // ── Model selector row ──────────────────────────────────────────
83
+ const modelRow = React.createElement(
84
+ Box,
85
+ { justifyContent: "space-between" },
86
+ // Model pill
87
+ React.createElement(
88
+ Box,
89
+ { gap: 1 },
90
+ React.createElement(
91
+ Text,
92
+ { color: focused ? theme.primary : theme.dim },
93
+ "◉"
94
+ ),
95
+ React.createElement(
96
+ Text,
97
+ { color: focused ? theme.white : theme.dim, bold: focused },
98
+ modelLabel
99
+ ),
100
+ currentModel.is_free_tier === false
101
+ ? React.createElement(Text, { color: theme.warning }, " ★")
102
+ : null,
103
+ ),
104
+ // Key hints
105
+ React.createElement(
106
+ Text,
107
+ { color: theme.dim },
108
+ focused
109
+ ? "Tab: model Enter: create Esc: cancel"
110
+ : ""
111
+ )
112
+ );
113
+
114
+ const borderColor = focused ? theme.primary : theme.dim;
115
+
116
+ return React.createElement(
117
+ Box,
118
+ {
119
+ flexDirection: "column",
120
+ borderStyle: "round",
121
+ borderColor,
122
+ paddingX: 1,
123
+ marginBottom: 1,
124
+ },
125
+ titleBar,
126
+ inputArea,
127
+ modelRow
128
+ );
129
+ }
@@ -0,0 +1,40 @@
1
+ import React from "react";
2
+ import { Text } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { theme } from "./theme.js";
5
+
6
+ /**
7
+ * Renders a single stage row with a status indicator.
8
+ *
9
+ * @param {{ label: string, status: "pending"|"active"|"done"|"stale" }} props
10
+ */
11
+ export function StageIndicator({ label, status }) {
12
+ if (status === "active") {
13
+ return React.createElement(
14
+ Text,
15
+ null,
16
+ React.createElement(Spinner, { type: "dots" }),
17
+ " ",
18
+ React.createElement(Text, { color: theme.white }, label)
19
+ );
20
+ }
21
+
22
+ const dot = status === "done" ? "✓"
23
+ : status === "stale" ? "⚠"
24
+ : "●";
25
+ const color = status === "done" ? theme.success
26
+ : status === "stale" ? theme.warning
27
+ : theme.dim;
28
+
29
+ return React.createElement(
30
+ Text,
31
+ null,
32
+ React.createElement(Text, { color }, dot),
33
+ " ",
34
+ React.createElement(
35
+ Text,
36
+ { color: status === "pending" ? theme.dim : theme.white },
37
+ label
38
+ )
39
+ );
40
+ }
@@ -0,0 +1,21 @@
1
+ export const theme = {
2
+ // Terminal ANSI-safe hex colors matching the main app's dark-blue palette
3
+ primary: "#5B8DEF", // bright blue
4
+ success: "#22C55E", // green checkmark / done
5
+ warning: "#F97316", // orange stale
6
+ error: "#EF4444", // red
7
+ dim: "#6B7280", // grey inactive
8
+ white: "#F1F5F9", // near-white text
9
+
10
+ // The 8 completion stages (overview + versions not counted)
11
+ stages: [
12
+ "Product Details",
13
+ "PRD Sections",
14
+ "Modules",
15
+ "Architecture",
16
+ "Dependencies",
17
+ "Guardrails",
18
+ "Tasks",
19
+ "Export",
20
+ ],
21
+ };