specstocode 0.5.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/README.md +218 -0
- package/bin/productbuilders.js +2 -0
- package/bin/specstocode.js +2 -0
- package/dist/chunk-55DTUCLY.js +417 -0
- package/dist/chunk-CYA6I7NV.js +34 -0
- package/dist/chunk-J22FYEMI.js +84 -0
- package/dist/chunk-NAOZWXOF.js +39 -0
- package/dist/chunk-P4M7CVDK.js +249 -0
- package/dist/chunk-QKMZ2SBR.js +39 -0
- package/dist/chunk-WPVDURTJ.js +79 -0
- package/dist/complexity-TUS6F2UI.js +71 -0
- package/dist/import-prd-HP66GKRA.js +114 -0
- package/dist/index.js +1763 -0
- package/dist/log-GSWUQF6Z.js +98 -0
- package/dist/prompt-WAWCRGHN.js +16 -0
- package/dist/research-UGNKVMZ5.js +49 -0
- package/dist/scope-BY5WSTPD.js +10 -0
- package/dist/setup-VBFEFGTK.js +9 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1763 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
setup
|
|
4
|
+
} from "./chunk-55DTUCLY.js";
|
|
5
|
+
import {
|
|
6
|
+
scope
|
|
7
|
+
} from "./chunk-P4M7CVDK.js";
|
|
8
|
+
import {
|
|
9
|
+
API_BASE,
|
|
10
|
+
getAuth,
|
|
11
|
+
requireAuth,
|
|
12
|
+
saveAuth
|
|
13
|
+
} from "./chunk-CYA6I7NV.js";
|
|
14
|
+
import {
|
|
15
|
+
storyContextPath,
|
|
16
|
+
writeStoryContext
|
|
17
|
+
} from "./chunk-J22FYEMI.js";
|
|
18
|
+
import {
|
|
19
|
+
createStories,
|
|
20
|
+
getContext,
|
|
21
|
+
listStories,
|
|
22
|
+
updateStories
|
|
23
|
+
} from "./chunk-QKMZ2SBR.js";
|
|
24
|
+
import {
|
|
25
|
+
ask,
|
|
26
|
+
closePrompt,
|
|
27
|
+
confirm,
|
|
28
|
+
pausePrompt
|
|
29
|
+
} from "./chunk-WPVDURTJ.js";
|
|
30
|
+
import {
|
|
31
|
+
hasConfig,
|
|
32
|
+
readConfig,
|
|
33
|
+
requireConfig,
|
|
34
|
+
writeConfig
|
|
35
|
+
} from "./chunk-NAOZWXOF.js";
|
|
36
|
+
|
|
37
|
+
// src/index.ts
|
|
38
|
+
import { Command } from "commander";
|
|
39
|
+
import { createRequire } from "module";
|
|
40
|
+
|
|
41
|
+
// src/commands/init.ts
|
|
42
|
+
import { writeFileSync, existsSync, readFileSync, appendFileSync } from "fs";
|
|
43
|
+
import { join } from "path";
|
|
44
|
+
import { createInterface } from "readline";
|
|
45
|
+
|
|
46
|
+
// src/commands/login.ts
|
|
47
|
+
import { exec } from "child_process";
|
|
48
|
+
function openBrowser(url) {
|
|
49
|
+
const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "${url}"` : `xdg-open "${url}"`;
|
|
50
|
+
exec(cmd);
|
|
51
|
+
}
|
|
52
|
+
function sleep(ms) {
|
|
53
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
54
|
+
}
|
|
55
|
+
async function login() {
|
|
56
|
+
const existing = getAuth();
|
|
57
|
+
if (existing) {
|
|
58
|
+
console.log("Already logged in. Run `npx specstocode logout` to switch accounts.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.log("Logging in to specstocode...\n");
|
|
62
|
+
const res = await fetch(`${API_BASE}/api/cli/auth`, { method: "POST" });
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
console.error("Failed to start login flow. Try again.");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const { code } = await res.json();
|
|
68
|
+
const authUrl = `${API_BASE}/cli/auth?code=${code}`;
|
|
69
|
+
console.log("Opening browser to authorize...");
|
|
70
|
+
console.log(`If it doesn't open, visit: ${authUrl}
|
|
71
|
+
`);
|
|
72
|
+
openBrowser(authUrl);
|
|
73
|
+
const maxAttempts = 60;
|
|
74
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
75
|
+
await sleep(3e3);
|
|
76
|
+
try {
|
|
77
|
+
const checkRes = await fetch(`${API_BASE}/api/cli/auth?code=${code}`);
|
|
78
|
+
if (!checkRes.ok) continue;
|
|
79
|
+
const data = await checkRes.json();
|
|
80
|
+
if (data.status === "approved" && data.token) {
|
|
81
|
+
saveAuth({ token: data.token, apiBase: API_BASE });
|
|
82
|
+
console.log("\u2705 Logged in to specstocode!\n");
|
|
83
|
+
console.log("You can now run: npx specstocode init");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (data.status === "expired") {
|
|
87
|
+
console.error("Login request expired. Run `npx specstocode login` to try again.");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(".");
|
|
91
|
+
} catch {
|
|
92
|
+
process.stdout.write(".");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
console.error("\nLogin timed out. Run `npx specstocode login` to try again.");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
async function logout() {
|
|
99
|
+
const { existsSync: existsSync5, unlinkSync } = await import("fs");
|
|
100
|
+
const { join: join4 } = await import("path");
|
|
101
|
+
const { homedir } = await import("os");
|
|
102
|
+
const authFile = join4(homedir(), ".specstocode", "auth.json");
|
|
103
|
+
if (existsSync5(authFile)) {
|
|
104
|
+
unlinkSync(authFile);
|
|
105
|
+
console.log("Logged out.");
|
|
106
|
+
} else {
|
|
107
|
+
console.log("Not logged in.");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/commands/init.ts
|
|
112
|
+
function ask2(rl, question) {
|
|
113
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
114
|
+
}
|
|
115
|
+
async function pickFromList(rl, items, label, prompt) {
|
|
116
|
+
items.forEach((item, i) => {
|
|
117
|
+
console.log(` ${i + 1}. ${label(item)}`);
|
|
118
|
+
});
|
|
119
|
+
while (true) {
|
|
120
|
+
const raw = await ask2(rl, `
|
|
121
|
+
${prompt} [1-${items.length}]: `);
|
|
122
|
+
const n = parseInt(raw.trim(), 10);
|
|
123
|
+
if (n >= 1 && n <= items.length) return items[n - 1];
|
|
124
|
+
console.log(` Please enter a number between 1 and ${items.length}.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function authHeaders(token) {
|
|
128
|
+
return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
129
|
+
}
|
|
130
|
+
async function init(tokenArg) {
|
|
131
|
+
if (hasConfig()) {
|
|
132
|
+
console.log("Already initialized. Run `specstocode sync` to refresh.");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (tokenArg) {
|
|
136
|
+
await initWithSyncToken(tokenArg);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
let auth = getAuth();
|
|
140
|
+
if (!auth) {
|
|
141
|
+
console.log("You need to log in first.\n");
|
|
142
|
+
await login();
|
|
143
|
+
auth = getAuth();
|
|
144
|
+
if (!auth) {
|
|
145
|
+
console.error("Login failed.");
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
150
|
+
try {
|
|
151
|
+
console.log("\nFetching your projects...");
|
|
152
|
+
const projectsRes = await fetch(`${auth.apiBase}/api/cli/projects`, {
|
|
153
|
+
headers: authHeaders(auth.token)
|
|
154
|
+
});
|
|
155
|
+
if (!projectsRes.ok) {
|
|
156
|
+
console.error("Failed to fetch projects. Try `specstocode login` first.");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
const { projects } = await projectsRes.json();
|
|
160
|
+
if (projects.length === 0) {
|
|
161
|
+
console.log("\nNo projects found. Create one at specstocode.com first.");
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
console.log("\nYour projects:\n");
|
|
165
|
+
const project = await pickFromList(rl, projects, (p) => p.name, "Select project");
|
|
166
|
+
console.log("\nFetching story maps...");
|
|
167
|
+
const mapsRes = await fetch(
|
|
168
|
+
`${auth.apiBase}/api/cli/projects/${project.id}/maps`,
|
|
169
|
+
{ headers: authHeaders(auth.token) }
|
|
170
|
+
);
|
|
171
|
+
if (!mapsRes.ok) {
|
|
172
|
+
console.error("Failed to fetch story maps.");
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
const { maps } = await mapsRes.json();
|
|
176
|
+
if (maps.length === 0) {
|
|
177
|
+
console.log("\nNo story maps in this project. Create one at specstocode.com first.");
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
console.log(`
|
|
181
|
+
Story maps in "${project.name}":
|
|
182
|
+
`);
|
|
183
|
+
const map = await pickFromList(rl, maps, (m) => m.title, "Select story map");
|
|
184
|
+
const config = {
|
|
185
|
+
syncToken: map.syncToken,
|
|
186
|
+
apiBase: auth.apiBase,
|
|
187
|
+
mapTitle: map.title,
|
|
188
|
+
projectId: project.id,
|
|
189
|
+
mapId: map.id
|
|
190
|
+
};
|
|
191
|
+
console.log(`
|
|
192
|
+
Connecting to "${map.title}"...`);
|
|
193
|
+
try {
|
|
194
|
+
const data = await listStories(config);
|
|
195
|
+
console.log(`\u2713 Connected to "${data.map.title}"`);
|
|
196
|
+
console.log(
|
|
197
|
+
` ${data.summary.total} stories \u2014 ${data.summary.done} done, ${data.summary.todo} todo
|
|
198
|
+
`
|
|
199
|
+
);
|
|
200
|
+
} catch {
|
|
201
|
+
console.error("Failed to validate connection. Please try again.");
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
writeConfig(config);
|
|
205
|
+
await setupFiles(config);
|
|
206
|
+
console.log("\n\u2705 Ready! Try: npx specstocode status");
|
|
207
|
+
} finally {
|
|
208
|
+
rl.close();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function initWithSyncToken(syncToken) {
|
|
212
|
+
if (!syncToken.startsWith("pb_sync_") && !syncToken.startsWith("sc_sync_")) {
|
|
213
|
+
console.error("Invalid token. It should start with pb_sync_");
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
console.log("\nConnecting to specstocode...");
|
|
217
|
+
const config = { syncToken, apiBase: API_BASE };
|
|
218
|
+
try {
|
|
219
|
+
const data = await listStories(config);
|
|
220
|
+
console.log(`\u2713 Connected to "${data.map.title}"`);
|
|
221
|
+
console.log(
|
|
222
|
+
` ${data.summary.total} stories \u2014 ${data.summary.done} done, ${data.summary.todo} todo
|
|
223
|
+
`
|
|
224
|
+
);
|
|
225
|
+
writeConfig({ ...config, mapTitle: data.map.title });
|
|
226
|
+
} catch {
|
|
227
|
+
console.error("Failed to connect. Check your sync token.");
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
await setupFiles(config);
|
|
231
|
+
console.log("\n\u2705 Ready! Try: npx specstocode status");
|
|
232
|
+
}
|
|
233
|
+
async function setupFiles(config) {
|
|
234
|
+
try {
|
|
235
|
+
const md = await getContext(config);
|
|
236
|
+
writeFileSync(join(process.cwd(), "SPECSTOCODE.md"), md);
|
|
237
|
+
console.log("\u2713 Downloaded SPECSTOCODE.md");
|
|
238
|
+
} catch {
|
|
239
|
+
console.warn("\u26A0 Could not download SPECSTOCODE.md");
|
|
240
|
+
}
|
|
241
|
+
const gitignore = join(process.cwd(), ".gitignore");
|
|
242
|
+
if (existsSync(gitignore)) {
|
|
243
|
+
const content = readFileSync(gitignore, "utf-8");
|
|
244
|
+
if (!content.includes(".specstocode/")) {
|
|
245
|
+
appendFileSync(gitignore, "\n# specstocode\n.specstocode/\n");
|
|
246
|
+
console.log("\u2713 Added .specstocode/ to .gitignore");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const cwd = process.cwd();
|
|
250
|
+
if (existsSync(join(cwd, "CLAUDE.md"))) {
|
|
251
|
+
const claudeMd = readFileSync(join(cwd, "CLAUDE.md"), "utf-8");
|
|
252
|
+
if (!claudeMd.includes("SPECSTOCODE.md")) {
|
|
253
|
+
appendFileSync(
|
|
254
|
+
join(cwd, "CLAUDE.md"),
|
|
255
|
+
"\n\n# specstocode\nRead SPECSTOCODE.md for the product story map, personas, and backlog.\n"
|
|
256
|
+
);
|
|
257
|
+
console.log("\u2713 Added SPECSTOCODE.md reference to CLAUDE.md");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (existsSync(join(cwd, ".cursorrules"))) {
|
|
261
|
+
const rules = readFileSync(join(cwd, ".cursorrules"), "utf-8");
|
|
262
|
+
if (!rules.includes("SPECSTOCODE.md")) {
|
|
263
|
+
appendFileSync(
|
|
264
|
+
join(cwd, ".cursorrules"),
|
|
265
|
+
"\n\nRead SPECSTOCODE.md for the product story map, personas, and backlog.\n"
|
|
266
|
+
);
|
|
267
|
+
console.log("\u2713 Added SPECSTOCODE.md reference to .cursorrules");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/commands/sync.ts
|
|
273
|
+
import { writeFileSync as writeFileSync2, readdirSync, existsSync as existsSync2 } from "fs";
|
|
274
|
+
import { join as join2 } from "path";
|
|
275
|
+
async function sync() {
|
|
276
|
+
const config = requireConfig();
|
|
277
|
+
console.log("Syncing with specstocode...");
|
|
278
|
+
const [md, data] = await Promise.all([
|
|
279
|
+
getContext(config),
|
|
280
|
+
listStories(config)
|
|
281
|
+
]);
|
|
282
|
+
writeFileSync2(join2(process.cwd(), "SPECSTOCODE.md"), md);
|
|
283
|
+
const { summary } = data;
|
|
284
|
+
const pct = summary.total > 0 ? Math.round(summary.done / summary.total * 100) : 0;
|
|
285
|
+
console.log(`\u2713 SPECSTOCODE.md updated (${summary.done}/${summary.total} done, ${pct}%)`);
|
|
286
|
+
const contextDir = join2(process.cwd(), ".specstocode", "stories");
|
|
287
|
+
if (!existsSync2(contextDir)) return;
|
|
288
|
+
const localFiles = readdirSync(contextDir).filter((f) => f.endsWith(".md"));
|
|
289
|
+
if (localFiles.length === 0) return;
|
|
290
|
+
const storyMap = new Map(data.stories.map((s) => [s.id, s]));
|
|
291
|
+
let refreshed = 0;
|
|
292
|
+
let stale = 0;
|
|
293
|
+
for (const filename of localFiles) {
|
|
294
|
+
const storyId = filename.replace(".md", "");
|
|
295
|
+
const story = storyMap.get(storyId);
|
|
296
|
+
if (!story) {
|
|
297
|
+
stale++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
writeStoryContext(story);
|
|
301
|
+
refreshed++;
|
|
302
|
+
}
|
|
303
|
+
if (refreshed > 0 || stale > 0) {
|
|
304
|
+
console.log(`\u2713 .specstocode/stories/ refreshed (${refreshed} updated${stale > 0 ? `, ${stale} orphaned` : ""})`);
|
|
305
|
+
if (stale > 0) {
|
|
306
|
+
console.log(` \u26A0 ${stale} context file(s) have no matching story \u2014 they may have been deleted in the UI.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/commands/status.ts
|
|
312
|
+
async function status() {
|
|
313
|
+
const config = requireConfig();
|
|
314
|
+
const data = await listStories(config);
|
|
315
|
+
const { summary } = data;
|
|
316
|
+
const pct = summary.total > 0 ? Math.round(summary.done / summary.total * 100) : 0;
|
|
317
|
+
const barLen = 20;
|
|
318
|
+
const filled = Math.round(pct / 100 * barLen);
|
|
319
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
|
|
320
|
+
console.log(`
|
|
321
|
+
${data.map.title}`);
|
|
322
|
+
console.log(` ${bar} ${pct}%
|
|
323
|
+
`);
|
|
324
|
+
console.log(` \u2705 Done: ${summary.done}`);
|
|
325
|
+
console.log(` \u{1F528} In progress: ${summary.in_progress}`);
|
|
326
|
+
console.log(` \u2B1C Todo: ${summary.todo}`);
|
|
327
|
+
console.log(` \u2500\u2500 Total: ${summary.total}
|
|
328
|
+
`);
|
|
329
|
+
const byActivity = /* @__PURE__ */ new Map();
|
|
330
|
+
for (const s of data.stories) {
|
|
331
|
+
const cur = byActivity.get(s.activity) ?? { done: 0, total: 0 };
|
|
332
|
+
cur.total++;
|
|
333
|
+
if (s.status === "done") cur.done++;
|
|
334
|
+
byActivity.set(s.activity, cur);
|
|
335
|
+
}
|
|
336
|
+
for (const [activity, counts] of byActivity) {
|
|
337
|
+
const actPct = counts.total > 0 ? Math.round(counts.done / counts.total * 100) : 0;
|
|
338
|
+
console.log(` ${activity}: ${counts.done}/${counts.total} (${actPct}%)`);
|
|
339
|
+
}
|
|
340
|
+
console.log();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/commands/stories.ts
|
|
344
|
+
var STATUS_ICON = {
|
|
345
|
+
done: "\u2705",
|
|
346
|
+
in_progress: "\u{1F528}",
|
|
347
|
+
todo: "\u2B1C"
|
|
348
|
+
};
|
|
349
|
+
var STATUS_ALIASES = {
|
|
350
|
+
todo: "todo",
|
|
351
|
+
"in-progress": "in_progress",
|
|
352
|
+
in_progress: "in_progress",
|
|
353
|
+
wip: "in_progress",
|
|
354
|
+
done: "done"
|
|
355
|
+
};
|
|
356
|
+
async function stories(opts) {
|
|
357
|
+
const config = requireConfig();
|
|
358
|
+
const data = await listStories(config);
|
|
359
|
+
let filtered = data.stories;
|
|
360
|
+
if (opts.status) {
|
|
361
|
+
const s = STATUS_ALIASES[opts.status.toLowerCase()];
|
|
362
|
+
if (!s) {
|
|
363
|
+
console.error(`Unknown status "${opts.status}". Use: todo, in-progress, done`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
filtered = filtered.filter((story) => story.status === s);
|
|
367
|
+
} else if (opts.filter) {
|
|
368
|
+
const f = opts.filter.toLowerCase();
|
|
369
|
+
const mappedStatus = STATUS_ALIASES[f];
|
|
370
|
+
if (mappedStatus) {
|
|
371
|
+
filtered = filtered.filter((s) => s.status === mappedStatus);
|
|
372
|
+
} else {
|
|
373
|
+
filtered = filtered.filter(
|
|
374
|
+
(s) => s.activity.toLowerCase().includes(f) || s.title.toLowerCase().includes(f)
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (filtered.length === 0) {
|
|
379
|
+
console.log("No stories found.");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (opts.kanban) {
|
|
383
|
+
kanbanView(filtered);
|
|
384
|
+
} else {
|
|
385
|
+
listView(filtered);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function listView(stories2) {
|
|
389
|
+
const byActivity = /* @__PURE__ */ new Map();
|
|
390
|
+
for (const s of stories2) {
|
|
391
|
+
const list = byActivity.get(s.activity) ?? [];
|
|
392
|
+
list.push(s);
|
|
393
|
+
byActivity.set(s.activity, list);
|
|
394
|
+
}
|
|
395
|
+
for (const [activity, actStories] of byActivity) {
|
|
396
|
+
const done2 = actStories.filter((s) => s.status === "done").length;
|
|
397
|
+
console.log(`
|
|
398
|
+
${activity} (${done2}/${actStories.length})`);
|
|
399
|
+
for (const s of actStories) {
|
|
400
|
+
const icon = STATUS_ICON[s.status] ?? "\u2B1C";
|
|
401
|
+
const id = s.id.slice(0, 8);
|
|
402
|
+
console.log(` ${icon} ${s.title.slice(0, 70)} (${id})`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
console.log();
|
|
406
|
+
}
|
|
407
|
+
function kanbanView(stories2) {
|
|
408
|
+
const termWidth = process.stdout.columns || 100;
|
|
409
|
+
const colW = Math.max(22, Math.floor((termWidth - 4) / 3));
|
|
410
|
+
const innerW = colW - 2;
|
|
411
|
+
const cols = [
|
|
412
|
+
{ label: "TODO", stories: stories2.filter((s) => s.status === "todo") },
|
|
413
|
+
{ label: "IN PROGRESS", stories: stories2.filter((s) => s.status === "in_progress") },
|
|
414
|
+
{ label: "DONE", stories: stories2.filter((s) => s.status === "done") }
|
|
415
|
+
];
|
|
416
|
+
const rule = (l, mid, r) => l + Array(3).fill("\u2500".repeat(colW)).join(mid) + r;
|
|
417
|
+
const cell = (text) => " " + text.slice(0, innerW).padEnd(innerW) + " ";
|
|
418
|
+
const emptyCell = () => " ".repeat(colW);
|
|
419
|
+
const row = (cells) => "\u2502" + cells.join("\u2502") + "\u2502";
|
|
420
|
+
console.log("\n" + rule("\u250C", "\u252C", "\u2510"));
|
|
421
|
+
console.log(
|
|
422
|
+
row(
|
|
423
|
+
cols.map((c) => cell(`${c.label} (${c.stories.length})`))
|
|
424
|
+
)
|
|
425
|
+
);
|
|
426
|
+
console.log(rule("\u251C", "\u253C", "\u2524"));
|
|
427
|
+
const maxRows = Math.max(...cols.map((c) => c.stories.length), 0);
|
|
428
|
+
for (let i = 0; i < maxRows; i++) {
|
|
429
|
+
console.log(
|
|
430
|
+
row(
|
|
431
|
+
cols.map((c) => {
|
|
432
|
+
const s = c.stories[i];
|
|
433
|
+
return s ? cell(s.title) : emptyCell();
|
|
434
|
+
})
|
|
435
|
+
)
|
|
436
|
+
);
|
|
437
|
+
console.log(
|
|
438
|
+
row(
|
|
439
|
+
cols.map((c) => {
|
|
440
|
+
const s = c.stories[i];
|
|
441
|
+
return s ? cell(`${s.activity} \xB7 ${s.priority}`) : emptyCell();
|
|
442
|
+
})
|
|
443
|
+
)
|
|
444
|
+
);
|
|
445
|
+
console.log(
|
|
446
|
+
row(
|
|
447
|
+
cols.map((c) => {
|
|
448
|
+
const s = c.stories[i];
|
|
449
|
+
return s ? cell(s.id.slice(0, 8)) : emptyCell();
|
|
450
|
+
})
|
|
451
|
+
)
|
|
452
|
+
);
|
|
453
|
+
if (i < maxRows - 1) {
|
|
454
|
+
console.log(row(cols.map(() => emptyCell())));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
console.log(rule("\u2514", "\u2534", "\u2518") + "\n");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// src/commands/next.ts
|
|
461
|
+
import { execSync } from "child_process";
|
|
462
|
+
var PRIORITY_ORDER = { must: 0, should: 1, could: 2 };
|
|
463
|
+
var EFFORT_ORDER = { S: 0, M: 1, L: 2, XL: 3 };
|
|
464
|
+
async function next(opts = {}) {
|
|
465
|
+
const config = requireConfig();
|
|
466
|
+
const data = await listStories(config);
|
|
467
|
+
if (!opts.skipWip) {
|
|
468
|
+
const inProgress = data.stories.filter((s2) => s2.status === "in_progress");
|
|
469
|
+
if (inProgress.length > 0) {
|
|
470
|
+
console.log(`
|
|
471
|
+
\u26A0\uFE0F You have ${inProgress.length} story${inProgress.length > 1 ? " stories" : ""} in progress:
|
|
472
|
+
`);
|
|
473
|
+
for (const s2 of inProgress) {
|
|
474
|
+
console.log(` \u{1F528} ${s2.title}`);
|
|
475
|
+
console.log(` ID: ${s2.id.slice(0, 8)} | Mark done: npx specstocode done ${s2.id.slice(0, 8)}`);
|
|
476
|
+
}
|
|
477
|
+
console.log(`
|
|
478
|
+
Finish those first, or run \`specstocode next --skip-wip\` to pick the next todo anyway.
|
|
479
|
+
`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const todos = data.stories.filter((s2) => s2.status === "todo").sort((a, b) => {
|
|
484
|
+
if (a.release !== b.release) return a.release - b.release;
|
|
485
|
+
const pa = PRIORITY_ORDER[a.priority] ?? 9;
|
|
486
|
+
const pb = PRIORITY_ORDER[b.priority] ?? 9;
|
|
487
|
+
if (pa !== pb) return pa - pb;
|
|
488
|
+
return (EFFORT_ORDER[a.effort] ?? 9) - (EFFORT_ORDER[b.effort] ?? 9);
|
|
489
|
+
});
|
|
490
|
+
if (todos.length === 0) {
|
|
491
|
+
console.log("\n \u{1F389} All stories are done! Ship it.\n");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const s = todos[0];
|
|
495
|
+
try {
|
|
496
|
+
await updateStories(config, [{ id: s.id, status: "in_progress" }]);
|
|
497
|
+
} catch {
|
|
498
|
+
}
|
|
499
|
+
const contextPath = writeStoryContext(s);
|
|
500
|
+
const relPath = contextPath.replace(process.cwd() + "/", "");
|
|
501
|
+
const specUrl = `${config.apiBase}/storymapper/${data.map.id}?story=${s.id}`;
|
|
502
|
+
console.log(`
|
|
503
|
+
\u{1F528} Picked up:`);
|
|
504
|
+
console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
505
|
+
console.log(` ${s.title}`);
|
|
506
|
+
console.log(` Activity: ${s.activity}`);
|
|
507
|
+
console.log(` Priority: ${s.priority} | Effort: ${s.effort} | Release: ${s.release}`);
|
|
508
|
+
if (s.acceptance_criteria.length > 0) {
|
|
509
|
+
console.log(` Done when:`);
|
|
510
|
+
for (const ac of s.acceptance_criteria) {
|
|
511
|
+
console.log(` - ${ac.text}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
console.log(`
|
|
515
|
+
ID: ${s.id}`);
|
|
516
|
+
console.log(` Context: ${relPath}`);
|
|
517
|
+
console.log(` Spec: ${specUrl}`);
|
|
518
|
+
console.log(` Mark done: npx specstocode done ${s.id.slice(0, 8)}
|
|
519
|
+
`);
|
|
520
|
+
if (opts.open) {
|
|
521
|
+
openBrowser2(specUrl);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function openBrowser2(url) {
|
|
525
|
+
try {
|
|
526
|
+
const platform = process.platform;
|
|
527
|
+
const cmd = platform === "darwin" ? `open "${url}"` : platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
|
|
528
|
+
execSync(cmd, { stdio: "ignore" });
|
|
529
|
+
} catch {
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/commands/done.ts
|
|
534
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
535
|
+
function extractImplementationContext(storyId) {
|
|
536
|
+
const path = storyContextPath(storyId);
|
|
537
|
+
if (!existsSync3(path)) return { summary: null, files: [] };
|
|
538
|
+
const content = readFileSync2(path, "utf-8");
|
|
539
|
+
const lines = content.split("\n");
|
|
540
|
+
const notesStart = lines.findIndex((l) => l.trim() === "## Implementation notes");
|
|
541
|
+
const decisionsStart = lines.findIndex((l) => l.trim() === "## Decisions");
|
|
542
|
+
const filesStart = lines.findIndex((l) => l.trim() === "## Relevant files");
|
|
543
|
+
let summaryLines = [];
|
|
544
|
+
if (notesStart !== -1) {
|
|
545
|
+
const end = decisionsStart !== -1 && decisionsStart > notesStart ? decisionsStart : filesStart !== -1 && filesStart > notesStart ? filesStart : lines.length;
|
|
546
|
+
summaryLines = lines.slice(notesStart + 1, end).filter((l) => l.trim() && !l.trim().startsWith("_Notes added via"));
|
|
547
|
+
}
|
|
548
|
+
let files = [];
|
|
549
|
+
if (filesStart !== -1) {
|
|
550
|
+
const end = lines.findIndex((l, i) => i > filesStart && l.startsWith("## "));
|
|
551
|
+
const fileLines = lines.slice(filesStart + 1, end === -1 ? void 0 : end);
|
|
552
|
+
files = fileLines.map((l) => l.replace(/^[-*]\s*/, "").trim()).filter((l) => l && !l.startsWith("_") && (l.includes("/") || l.includes(".")));
|
|
553
|
+
}
|
|
554
|
+
const summary = summaryLines.length > 0 ? summaryLines.join("\n").trim() : null;
|
|
555
|
+
return { summary, files };
|
|
556
|
+
}
|
|
557
|
+
async function done(idPrefix) {
|
|
558
|
+
const config = requireConfig();
|
|
559
|
+
const data = await listStories(config);
|
|
560
|
+
const match = data.stories.find((s) => s.id.startsWith(idPrefix));
|
|
561
|
+
if (!match) {
|
|
562
|
+
console.error(`No story found matching "${idPrefix}"`);
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
if (match.status === "done") {
|
|
566
|
+
console.log(`Already done: ${match.title}`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const { summary, files } = extractImplementationContext(match.id);
|
|
570
|
+
const update = { id: match.id, status: "done" };
|
|
571
|
+
if (summary) update.implementation_summary = summary;
|
|
572
|
+
if (files.length > 0) update.implemented_files = files;
|
|
573
|
+
await updateStories(config, [update]);
|
|
574
|
+
console.log(`\u2705 Done: ${match.title}`);
|
|
575
|
+
if (summary) console.log(` \u21B3 Implementation summary posted to story map`);
|
|
576
|
+
if (files.length > 0) console.log(` \u21B3 ${files.length} file(s) recorded`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/commands/add.ts
|
|
580
|
+
async function add(title, opts) {
|
|
581
|
+
const config = requireConfig();
|
|
582
|
+
const result = await createStories(config, [
|
|
583
|
+
{
|
|
584
|
+
title,
|
|
585
|
+
activity: opts.activity,
|
|
586
|
+
priority: opts.priority ?? "should",
|
|
587
|
+
effort: opts.effort ?? "M"
|
|
588
|
+
}
|
|
589
|
+
]);
|
|
590
|
+
if (result.created > 0) {
|
|
591
|
+
const s = result.stories[0];
|
|
592
|
+
console.log(`\u2713 Created: ${s.title}`);
|
|
593
|
+
console.log(` Activity: ${s.activity} | ID: ${s.id.slice(0, 8)}`);
|
|
594
|
+
} else {
|
|
595
|
+
console.error("Failed to create story.");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/lib/banner.ts
|
|
600
|
+
import chalk from "chalk";
|
|
601
|
+
var VERSION = "0.2.2";
|
|
602
|
+
var brand = chalk.hex("#6366F1");
|
|
603
|
+
var muted = chalk.dim;
|
|
604
|
+
var white = chalk.white.bold;
|
|
605
|
+
var PB_LOGO = `
|
|
606
|
+
${brand("\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557")}
|
|
607
|
+
${brand("\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557")} ${white("specstocode")}
|
|
608
|
+
${brand("\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D")} ${muted("Specs \u2192 Code. Fast.")}
|
|
609
|
+
${brand("\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557")}
|
|
610
|
+
${brand("\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551")} ${muted(`v${VERSION}`)}
|
|
611
|
+
${brand("\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D")}
|
|
612
|
+
`;
|
|
613
|
+
function printBanner(subtitle) {
|
|
614
|
+
console.log(PB_LOGO);
|
|
615
|
+
if (subtitle) {
|
|
616
|
+
console.log(` ${white(subtitle)}`);
|
|
617
|
+
console.log(` ${muted("\u2500".repeat(subtitle.length))}
|
|
618
|
+
`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/commands/start.ts
|
|
623
|
+
import ora from "ora";
|
|
624
|
+
async function apiFetch(path, token, opts = {}) {
|
|
625
|
+
return fetch(`${API_BASE}${path}`, {
|
|
626
|
+
...opts,
|
|
627
|
+
headers: {
|
|
628
|
+
"Content-Type": "application/json",
|
|
629
|
+
Authorization: `Bearer ${token}`,
|
|
630
|
+
...opts.headers ?? {}
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
async function streamResponse(path, token, body, options) {
|
|
635
|
+
const res = await apiFetch(path, token, {
|
|
636
|
+
method: "POST",
|
|
637
|
+
body: JSON.stringify(body)
|
|
638
|
+
});
|
|
639
|
+
if (!res.ok) {
|
|
640
|
+
const err = await res.text();
|
|
641
|
+
throw new Error(`API error ${res.status}: ${err}`);
|
|
642
|
+
}
|
|
643
|
+
let full = "";
|
|
644
|
+
const reader = res.body?.getReader();
|
|
645
|
+
const decoder = new TextDecoder();
|
|
646
|
+
if (reader) {
|
|
647
|
+
while (true) {
|
|
648
|
+
const { done: done2, value } = await reader.read();
|
|
649
|
+
if (done2) break;
|
|
650
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
651
|
+
if (!options?.silent) {
|
|
652
|
+
process.stdout.write(chunk);
|
|
653
|
+
}
|
|
654
|
+
full += chunk;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (!options?.silent) {
|
|
658
|
+
console.log();
|
|
659
|
+
}
|
|
660
|
+
return full;
|
|
661
|
+
}
|
|
662
|
+
async function start() {
|
|
663
|
+
const auth = requireAuth();
|
|
664
|
+
const { token } = auth;
|
|
665
|
+
printBanner("Start a new project");
|
|
666
|
+
console.log(" Let's start with the problem you want to solve.\n");
|
|
667
|
+
const problem = await ask(" What problem are you trying to solve?\n > ");
|
|
668
|
+
if (!problem || problem.length < 10) {
|
|
669
|
+
console.log("\n Please describe the problem in more detail (at least 10 characters).");
|
|
670
|
+
closePrompt();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
pausePrompt();
|
|
674
|
+
const spinner = ora(" Thinking about your idea...").start();
|
|
675
|
+
let discoveryText;
|
|
676
|
+
try {
|
|
677
|
+
discoveryText = await streamResponse("/api/blueprint/discovery", token, {
|
|
678
|
+
idea: problem
|
|
679
|
+
}, { silent: true });
|
|
680
|
+
} catch (err) {
|
|
681
|
+
spinner.fail(" Failed to connect");
|
|
682
|
+
console.error(` ${err.message}`);
|
|
683
|
+
console.error(" Make sure you're logged in: npx specstocode login");
|
|
684
|
+
closePrompt();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
spinner.succeed(" Got it \u2014 a few questions to sharpen the idea:\n");
|
|
688
|
+
const questions = discoveryText.split(/\d+\.\s+/).filter((q) => q.trim().length > 10).map((q) => q.trim().replace(/\n/g, " "));
|
|
689
|
+
const qa = [];
|
|
690
|
+
for (const q of questions.slice(0, 3)) {
|
|
691
|
+
console.log();
|
|
692
|
+
const answer = await ask(` ${q}
|
|
693
|
+
> `);
|
|
694
|
+
if (answer) {
|
|
695
|
+
qa.push({ question: q, answer });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
pausePrompt();
|
|
699
|
+
const pitchSpinner = ora(" Synthesising your pitch...").start();
|
|
700
|
+
const pitchRes = await apiFetch("/api/blueprint/synthesize-pitch", token, {
|
|
701
|
+
method: "POST",
|
|
702
|
+
body: JSON.stringify({ problem, qa })
|
|
703
|
+
});
|
|
704
|
+
if (!pitchRes.ok) {
|
|
705
|
+
pitchSpinner.fail(" Failed to synthesise pitch. Try again.");
|
|
706
|
+
closePrompt();
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const { pitch, target_user, names } = await pitchRes.json();
|
|
710
|
+
pitchSpinner.succeed(" Pitch ready!\n");
|
|
711
|
+
console.log(` ${pitch}
|
|
712
|
+
`);
|
|
713
|
+
console.log(` Target user: ${target_user}
|
|
714
|
+
`);
|
|
715
|
+
console.log(" Suggested names:");
|
|
716
|
+
for (let i = 0; i < names.length; i++) {
|
|
717
|
+
console.log(` ${i + 1}. ${names[i]}`);
|
|
718
|
+
}
|
|
719
|
+
console.log(` ${names.length + 1}. Type my own name`);
|
|
720
|
+
const nameChoice = await ask(`
|
|
721
|
+
Pick a name (1-${names.length + 1}): `);
|
|
722
|
+
const nameIdx = parseInt(nameChoice, 10) - 1;
|
|
723
|
+
let productName;
|
|
724
|
+
if (nameIdx >= 0 && nameIdx < names.length) {
|
|
725
|
+
productName = names[nameIdx];
|
|
726
|
+
} else {
|
|
727
|
+
productName = await ask(" Enter your product name: ");
|
|
728
|
+
}
|
|
729
|
+
if (!productName.trim()) {
|
|
730
|
+
productName = names[0] ?? "My Product";
|
|
731
|
+
}
|
|
732
|
+
console.log(`
|
|
733
|
+
Great \u2014 "${productName}" it is.
|
|
734
|
+
`);
|
|
735
|
+
console.log(" Creating project...");
|
|
736
|
+
const createRes = await apiFetch("/api/blueprint/create", token, {
|
|
737
|
+
method: "POST",
|
|
738
|
+
body: JSON.stringify({
|
|
739
|
+
messages: [{ role: "user", content: problem }],
|
|
740
|
+
titleOverride: productName,
|
|
741
|
+
pitchOverride: pitch
|
|
742
|
+
})
|
|
743
|
+
});
|
|
744
|
+
if (!createRes.ok) {
|
|
745
|
+
const contentType = createRes.headers.get("content-type") ?? "";
|
|
746
|
+
if (contentType.includes("application/json")) {
|
|
747
|
+
const data = await createRes.json();
|
|
748
|
+
if (data.error === "PROJECT_LIMIT_REACHED") {
|
|
749
|
+
console.error("\n Project limit reached. Upgrade to Pro at specstocode.com/pricing");
|
|
750
|
+
} else {
|
|
751
|
+
console.error(`
|
|
752
|
+
Failed to create project: ${data.error ?? "unknown error"}`);
|
|
753
|
+
}
|
|
754
|
+
} else {
|
|
755
|
+
console.error("\n Failed to create project. Try logging in again: npx specstocode login");
|
|
756
|
+
}
|
|
757
|
+
closePrompt();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const createContentType = createRes.headers.get("content-type") ?? "";
|
|
761
|
+
if (!createContentType.includes("application/json")) {
|
|
762
|
+
console.error("\n Unexpected response from server. Try logging in again: npx specstocode login");
|
|
763
|
+
closePrompt();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const { projectId } = await createRes.json();
|
|
767
|
+
console.log(` \u2713 Project created (${projectId.slice(0, 8)})
|
|
768
|
+
`);
|
|
769
|
+
const validated = await confirm(
|
|
770
|
+
` Have you validated there is demand for ${productName}?`
|
|
771
|
+
);
|
|
772
|
+
if (validated) {
|
|
773
|
+
const howValidated = await ask(" How did you validate demand?\n > ");
|
|
774
|
+
console.log(`
|
|
775
|
+
Noted${howValidated ? ": " + howValidated : "."}
|
|
776
|
+
`);
|
|
777
|
+
} else {
|
|
778
|
+
const wantPublish = await confirm(
|
|
779
|
+
" Want to publish on specstocode to get feedback and intent signals?"
|
|
780
|
+
);
|
|
781
|
+
if (wantPublish) {
|
|
782
|
+
console.log(`
|
|
783
|
+
Publishing "${productName}"...
|
|
784
|
+
`);
|
|
785
|
+
console.log(" \u2500\u2500 Summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
786
|
+
console.log(` Name: ${productName}`);
|
|
787
|
+
console.log(` Pitch: ${pitch}`);
|
|
788
|
+
console.log(` Target: ${target_user}`);
|
|
789
|
+
console.log(` Problem: ${problem.slice(0, 100)}${problem.length > 100 ? "..." : ""}`);
|
|
790
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
|
|
791
|
+
const confirmPublish = await confirm(" Publish this to Explore?");
|
|
792
|
+
if (confirmPublish) {
|
|
793
|
+
const pubRes = await apiFetch(
|
|
794
|
+
`/api/projects/${projectId}/publish`,
|
|
795
|
+
token,
|
|
796
|
+
{
|
|
797
|
+
method: "POST",
|
|
798
|
+
body: JSON.stringify({
|
|
799
|
+
is_published: true,
|
|
800
|
+
tagline: pitch,
|
|
801
|
+
problem_statement: problem
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
);
|
|
805
|
+
if (pubRes.ok) {
|
|
806
|
+
const pubContentType = pubRes.headers.get("content-type") ?? "";
|
|
807
|
+
if (pubContentType.includes("application/json")) {
|
|
808
|
+
const pubData = await pubRes.json();
|
|
809
|
+
console.log(` \u2713 Published! View at: ${API_BASE}/p/${pubData.public_slug ?? projectId}
|
|
810
|
+
`);
|
|
811
|
+
} else {
|
|
812
|
+
console.log(` \u2713 Published!
|
|
813
|
+
`);
|
|
814
|
+
}
|
|
815
|
+
} else {
|
|
816
|
+
console.log(" \u26A0 Publishing failed \u2014 you can publish later from the UI.\n");
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const readyToScope = await confirm(
|
|
822
|
+
` Ready to start scoping out ${productName}?`
|
|
823
|
+
);
|
|
824
|
+
if (!readyToScope) {
|
|
825
|
+
console.log(`
|
|
826
|
+
No worries! When you're ready:
|
|
827
|
+
\u2022 Run: npx specstocode scope ${projectId}
|
|
828
|
+
\u2022 Or visit: ${API_BASE}
|
|
829
|
+
|
|
830
|
+
Your project is saved and waiting for you.
|
|
831
|
+
`);
|
|
832
|
+
closePrompt();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const { scope: scope2 } = await import("./scope-BY5WSTPD.js");
|
|
836
|
+
await scope2(projectId);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// src/commands/propose.ts
|
|
840
|
+
import { mkdirSync, writeFileSync as writeFileSync3, readFileSync as readFileSync3, existsSync as existsSync4 } from "fs";
|
|
841
|
+
import { join as join3 } from "path";
|
|
842
|
+
async function propose(changeName, opts = {}) {
|
|
843
|
+
requireAuth();
|
|
844
|
+
if (!hasConfig()) {
|
|
845
|
+
console.log(" Not connected to a project. Run `npx specstocode init` first.");
|
|
846
|
+
closePrompt();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const config = requireConfig();
|
|
850
|
+
if (opts.from) {
|
|
851
|
+
await proposeFromFile(opts.from, config, changeName);
|
|
852
|
+
closePrompt();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
console.log("\n \u{1F4CB} New change proposal\n");
|
|
856
|
+
console.log(" This will create stories in your story map and save a local spec.\n");
|
|
857
|
+
if (!changeName?.trim()) {
|
|
858
|
+
changeName = await ask(" Change name (kebab-case, e.g. add-dark-mode): ");
|
|
859
|
+
}
|
|
860
|
+
const slug = (changeName ?? "").toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
861
|
+
if (!slug) {
|
|
862
|
+
console.log(" Change name required.");
|
|
863
|
+
closePrompt();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const problem = await ask(
|
|
867
|
+
"\n What problem does this solve?\n > "
|
|
868
|
+
);
|
|
869
|
+
const solution = await ask(
|
|
870
|
+
"\n What are you proposing to build?\n > "
|
|
871
|
+
);
|
|
872
|
+
const activity = await ask(
|
|
873
|
+
"\n Which activity does this belong to? (e.g. 'Onboarding', 'Core Flow')\n > "
|
|
874
|
+
);
|
|
875
|
+
const priorityInput = await ask(
|
|
876
|
+
"\n Priority \u2014 must / should / could [should]: "
|
|
877
|
+
);
|
|
878
|
+
const priority = ["must", "should", "could"].includes(priorityInput.trim()) ? priorityInput.trim() : "should";
|
|
879
|
+
console.log("\n Now add stories for this change. Enter an empty title when done.\n");
|
|
880
|
+
const stories2 = [];
|
|
881
|
+
let storyNum = 1;
|
|
882
|
+
while (true) {
|
|
883
|
+
const title = await ask(` Story ${storyNum} title (or press Enter to finish): `);
|
|
884
|
+
if (!title.trim()) break;
|
|
885
|
+
const effortInput = await ask(` Effort for "${title}" \u2014 S / M / L / XL [M]: `);
|
|
886
|
+
const effort = ["S", "M", "L", "XL"].includes(effortInput.trim().toUpperCase()) ? effortInput.trim().toUpperCase() : "M";
|
|
887
|
+
console.log(` Acceptance criteria for "${title}" (one per line, empty to finish):`);
|
|
888
|
+
const acs = [];
|
|
889
|
+
while (true) {
|
|
890
|
+
const ac = await ask(` AC ${acs.length + 1}: `);
|
|
891
|
+
if (!ac.trim()) break;
|
|
892
|
+
acs.push(ac.trim());
|
|
893
|
+
}
|
|
894
|
+
stories2.push({ title: title.trim(), activity: activity.trim() || "General", priority, effort, acceptance_criteria: acs });
|
|
895
|
+
storyNum++;
|
|
896
|
+
}
|
|
897
|
+
if (stories2.length === 0) {
|
|
898
|
+
console.log("\n No stories added. Proposal cancelled.");
|
|
899
|
+
closePrompt();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
console.log(`
|
|
903
|
+
Ready to create ${stories2.length} story/stories in your story map:
|
|
904
|
+
`);
|
|
905
|
+
for (const s of stories2) {
|
|
906
|
+
console.log(` [${s.priority.toUpperCase()}] ${s.title} (${s.effort})`);
|
|
907
|
+
for (const ac of s.acceptance_criteria) {
|
|
908
|
+
console.log(` \u2022 ${ac}`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const ok = await confirm("\n Create these stories and save local proposal?");
|
|
912
|
+
if (!ok) {
|
|
913
|
+
console.log(" Cancelled.");
|
|
914
|
+
closePrompt();
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
let created = [];
|
|
918
|
+
try {
|
|
919
|
+
const result = await createStories(config, stories2.map((s) => ({
|
|
920
|
+
title: s.title,
|
|
921
|
+
activity: s.activity,
|
|
922
|
+
priority: s.priority,
|
|
923
|
+
effort: s.effort,
|
|
924
|
+
acceptance_criteria: s.acceptance_criteria
|
|
925
|
+
})));
|
|
926
|
+
created = result.stories;
|
|
927
|
+
console.log(`
|
|
928
|
+
\u2713 Created ${result.created} stories in your story map`);
|
|
929
|
+
} catch (err) {
|
|
930
|
+
console.error(`
|
|
931
|
+
\u2717 Failed to create stories: ${err instanceof Error ? err.message : String(err)}`);
|
|
932
|
+
console.log(" Saving proposal locally anyway...");
|
|
933
|
+
}
|
|
934
|
+
const changesDir = join3(process.cwd(), "pb-changes", slug);
|
|
935
|
+
if (!existsSync4(changesDir)) mkdirSync(changesDir, { recursive: true });
|
|
936
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
937
|
+
const proposalMd = [
|
|
938
|
+
`# Proposal: ${changeName}`,
|
|
939
|
+
``,
|
|
940
|
+
`**Date:** ${date}`,
|
|
941
|
+
`**Status:** proposed`,
|
|
942
|
+
`**Priority:** ${priority}`,
|
|
943
|
+
``,
|
|
944
|
+
`## Problem`,
|
|
945
|
+
``,
|
|
946
|
+
problem,
|
|
947
|
+
``,
|
|
948
|
+
`## Solution`,
|
|
949
|
+
``,
|
|
950
|
+
solution,
|
|
951
|
+
``,
|
|
952
|
+
`## Stories`,
|
|
953
|
+
``,
|
|
954
|
+
...stories2.map((s, i) => {
|
|
955
|
+
const storyId = created[i]?.id ?? null;
|
|
956
|
+
const idNote = storyId ? ` <!-- PB-${storyId} -->` : "";
|
|
957
|
+
const lines = [`### ${s.title}${idNote}`, ``, `**Activity:** ${s.activity} | **Priority:** ${s.priority} | **Effort:** ${s.effort}`, ``];
|
|
958
|
+
if (s.acceptance_criteria.length) {
|
|
959
|
+
lines.push(`**Acceptance criteria:**`, ``);
|
|
960
|
+
for (const ac of s.acceptance_criteria) lines.push(`- [ ] ${ac}`);
|
|
961
|
+
lines.push(``);
|
|
962
|
+
}
|
|
963
|
+
return lines.join("\n");
|
|
964
|
+
}),
|
|
965
|
+
`## Implementation notes`,
|
|
966
|
+
``,
|
|
967
|
+
`_Add notes here as you build._`,
|
|
968
|
+
``
|
|
969
|
+
].join("\n");
|
|
970
|
+
writeFileSync3(join3(changesDir, "proposal.md"), proposalMd);
|
|
971
|
+
console.log(` \u2713 Saved pb-changes/${slug}/proposal.md`);
|
|
972
|
+
const gitignorePath = join3(process.cwd(), ".gitignore");
|
|
973
|
+
if (existsSync4(gitignorePath)) {
|
|
974
|
+
const { readFileSync: readFileSync4, appendFileSync: appendFileSync2 } = await import("fs");
|
|
975
|
+
const gi = readFileSync4(gitignorePath, "utf-8");
|
|
976
|
+
if (!gi.includes("pb-changes")) {
|
|
977
|
+
appendFileSync2(gitignorePath, "\n# specstocode local change proposals\npb-changes/\n");
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
console.log(`
|
|
981
|
+
\u2705 Proposal "${changeName}" ready!
|
|
982
|
+
|
|
983
|
+
Stories created in your story map \u2014 view them at specstocode.com
|
|
984
|
+
Local spec saved at: pb-changes/${slug}/proposal.md
|
|
985
|
+
|
|
986
|
+
Next:
|
|
987
|
+
npx specstocode next \u2014 pick up the first story
|
|
988
|
+
npx specstocode status \u2014 see full progress
|
|
989
|
+
npx specstocode sync \u2014 refresh SPECSTOCODE.md
|
|
990
|
+
`);
|
|
991
|
+
closePrompt();
|
|
992
|
+
}
|
|
993
|
+
function parseProposalFile(filePath) {
|
|
994
|
+
const raw = readFileSync3(filePath, "utf-8");
|
|
995
|
+
const lines = raw.split("\n");
|
|
996
|
+
let changeName = "";
|
|
997
|
+
let problem = "";
|
|
998
|
+
let solution = "";
|
|
999
|
+
let activity = "General";
|
|
1000
|
+
let priority = "should";
|
|
1001
|
+
const stories2 = [];
|
|
1002
|
+
let currentStory = null;
|
|
1003
|
+
for (const raw2 of lines) {
|
|
1004
|
+
const line = raw2.trim();
|
|
1005
|
+
if (line.startsWith("# ")) {
|
|
1006
|
+
changeName = line.replace(/^#\s+/, "").replace(/^Proposal:\s*/i, "").trim();
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (line.startsWith("## ")) {
|
|
1010
|
+
if (currentStory) stories2.push(currentStory);
|
|
1011
|
+
currentStory = {
|
|
1012
|
+
title: line.replace(/^##\s+/, "").trim(),
|
|
1013
|
+
effort: "M",
|
|
1014
|
+
acceptance_criteria: [],
|
|
1015
|
+
activity,
|
|
1016
|
+
priority
|
|
1017
|
+
};
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (currentStory) {
|
|
1021
|
+
const effortMatch = line.match(/^\*\*Effort:\*\*\s*([SMLXLxsml]+)/i);
|
|
1022
|
+
if (effortMatch) {
|
|
1023
|
+
const e = effortMatch[1].toUpperCase();
|
|
1024
|
+
if (["S", "M", "L", "XL"].includes(e)) currentStory.effort = e;
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
const actMatch = line.match(/^\*\*Activity:\*\*\s*(.+)/i);
|
|
1028
|
+
if (actMatch) {
|
|
1029
|
+
currentStory.activity = actMatch[1].trim();
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const priMatch = line.match(/^\*\*Priority:\*\*\s*(must|should|could)/i);
|
|
1033
|
+
if (priMatch) {
|
|
1034
|
+
currentStory.priority = priMatch[1].toLowerCase();
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (line.startsWith("- ")) {
|
|
1038
|
+
currentStory.acceptance_criteria.push(line.slice(2).replace(/^\[ \]\s*/, "").trim());
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
} else {
|
|
1042
|
+
const probMatch = line.match(/^\*\*Problem:\*\*\s*(.+)/i);
|
|
1043
|
+
if (probMatch) {
|
|
1044
|
+
problem = probMatch[1].trim();
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const solMatch = line.match(/^\*\*Solution:\*\*\s*(.+)/i);
|
|
1048
|
+
if (solMatch) {
|
|
1049
|
+
solution = solMatch[1].trim();
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
const actMatch = line.match(/^\*\*Activity:\*\*\s*(.+)/i);
|
|
1053
|
+
if (actMatch) {
|
|
1054
|
+
activity = actMatch[1].trim();
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const priMatch = line.match(/^\*\*Priority:\*\*\s*(must|should|could)/i);
|
|
1058
|
+
if (priMatch) {
|
|
1059
|
+
priority = priMatch[1].toLowerCase();
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (currentStory) stories2.push(currentStory);
|
|
1065
|
+
return { changeName, problem, solution, activity, priority, stories: stories2 };
|
|
1066
|
+
}
|
|
1067
|
+
async function proposeFromFile(filePath, config, nameOverride) {
|
|
1068
|
+
if (!existsSync4(filePath)) {
|
|
1069
|
+
console.error(`
|
|
1070
|
+
File not found: ${filePath}
|
|
1071
|
+
`);
|
|
1072
|
+
process.exit(1);
|
|
1073
|
+
}
|
|
1074
|
+
const parsed = parseProposalFile(filePath);
|
|
1075
|
+
const changeName = nameOverride?.trim() || parsed.changeName || filePath.replace(/.*\//, "").replace(/\.md$/, "");
|
|
1076
|
+
const slug = changeName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
1077
|
+
if (parsed.stories.length === 0) {
|
|
1078
|
+
console.error("\n No stories found in the file. Add ## headings for each story.\n");
|
|
1079
|
+
process.exit(1);
|
|
1080
|
+
}
|
|
1081
|
+
console.log(`
|
|
1082
|
+
\u{1F4CB} Proposing "${changeName}" from ${filePath}
|
|
1083
|
+
`);
|
|
1084
|
+
for (const s of parsed.stories) {
|
|
1085
|
+
console.log(` [${s.priority.toUpperCase()}] ${s.title} (${s.effort}) \u2014 ${s.acceptance_criteria.length} ACs`);
|
|
1086
|
+
}
|
|
1087
|
+
let created = [];
|
|
1088
|
+
try {
|
|
1089
|
+
const result = await createStories(config, parsed.stories.map((s) => ({
|
|
1090
|
+
title: s.title,
|
|
1091
|
+
activity: s.activity,
|
|
1092
|
+
priority: s.priority,
|
|
1093
|
+
effort: s.effort,
|
|
1094
|
+
acceptance_criteria: s.acceptance_criteria
|
|
1095
|
+
})));
|
|
1096
|
+
created = result.stories;
|
|
1097
|
+
console.log(`
|
|
1098
|
+
\u2713 Created ${result.created} stories in your story map`);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
console.error(`
|
|
1101
|
+
\u2717 Failed to create stories: ${err instanceof Error ? err.message : String(err)}`);
|
|
1102
|
+
console.log(" Saving proposal locally anyway...");
|
|
1103
|
+
}
|
|
1104
|
+
const changesDir = join3(process.cwd(), "pb-changes", slug);
|
|
1105
|
+
if (!existsSync4(changesDir)) mkdirSync(changesDir, { recursive: true });
|
|
1106
|
+
const proposalMd = [
|
|
1107
|
+
`# Proposal: ${changeName}`,
|
|
1108
|
+
``,
|
|
1109
|
+
`**Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}`,
|
|
1110
|
+
`**Source:** ${filePath}`,
|
|
1111
|
+
`**Status:** proposed`,
|
|
1112
|
+
``,
|
|
1113
|
+
`## Problem`,
|
|
1114
|
+
``,
|
|
1115
|
+
parsed.problem || "_See source file_",
|
|
1116
|
+
``,
|
|
1117
|
+
`## Solution`,
|
|
1118
|
+
``,
|
|
1119
|
+
parsed.solution || "_See source file_",
|
|
1120
|
+
``,
|
|
1121
|
+
`## Stories`,
|
|
1122
|
+
``,
|
|
1123
|
+
...parsed.stories.map((s, i) => {
|
|
1124
|
+
const storyId = created[i]?.id ?? null;
|
|
1125
|
+
const idNote = storyId ? ` <!-- PB-${storyId} -->` : "";
|
|
1126
|
+
const acLines = s.acceptance_criteria.map((ac) => `- [ ] ${ac}`);
|
|
1127
|
+
return [
|
|
1128
|
+
`### ${s.title}${idNote}`,
|
|
1129
|
+
``,
|
|
1130
|
+
`**Activity:** ${s.activity} | **Priority:** ${s.priority} | **Effort:** ${s.effort}`,
|
|
1131
|
+
``,
|
|
1132
|
+
...acLines.length ? [`**Acceptance criteria:**`, ``, ...acLines, ``] : []
|
|
1133
|
+
].join("\n");
|
|
1134
|
+
})
|
|
1135
|
+
].join("\n");
|
|
1136
|
+
writeFileSync3(join3(changesDir, "proposal.md"), proposalMd);
|
|
1137
|
+
console.log(` \u2713 Saved pb-changes/${slug}/proposal.md`);
|
|
1138
|
+
console.log(`
|
|
1139
|
+
\u2705 Done! Run \`npx specstocode next\` to start building.
|
|
1140
|
+
`);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/commands/show.ts
|
|
1144
|
+
var STATUS_ICON2 = {
|
|
1145
|
+
done: "\u2705",
|
|
1146
|
+
in_progress: "\u{1F528}",
|
|
1147
|
+
todo: "\u2B1C"
|
|
1148
|
+
};
|
|
1149
|
+
async function show(idPrefix) {
|
|
1150
|
+
const config = requireConfig();
|
|
1151
|
+
const data = await listStories(config);
|
|
1152
|
+
const story = data.stories.find((s) => s.id.startsWith(idPrefix));
|
|
1153
|
+
if (!story) {
|
|
1154
|
+
console.error(`
|
|
1155
|
+
No story found matching "${idPrefix}"
|
|
1156
|
+
Run \`productbuilders stories\` to list IDs.
|
|
1157
|
+
`);
|
|
1158
|
+
process.exit(1);
|
|
1159
|
+
}
|
|
1160
|
+
const statusIcon = STATUS_ICON2[story.status ?? "todo"] ?? "\u2B1C";
|
|
1161
|
+
const specUrl = `${config.apiBase}/storymapper/${data.map.id}?story=${story.id}`;
|
|
1162
|
+
console.log(`
|
|
1163
|
+
${statusIcon} ${story.title}`);
|
|
1164
|
+
console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
1165
|
+
console.log(` Activity : ${story.activity}`);
|
|
1166
|
+
console.log(` Type : ${story.story_type ?? "user_story"}`);
|
|
1167
|
+
console.log(` Priority : ${story.priority} | Effort : ${story.effort} | Release : ${story.release}`);
|
|
1168
|
+
console.log(` ID : ${story.id}`);
|
|
1169
|
+
console.log(` Spec : ${specUrl}`);
|
|
1170
|
+
if (story.as_a || story.i_want || story.so_that) {
|
|
1171
|
+
console.log(`
|
|
1172
|
+
User story`);
|
|
1173
|
+
if (story.as_a) console.log(` As a : ${story.as_a}`);
|
|
1174
|
+
if (story.i_want) console.log(` I want : ${story.i_want}`);
|
|
1175
|
+
if (story.so_that) console.log(` So that : ${story.so_that}`);
|
|
1176
|
+
}
|
|
1177
|
+
if (story.acceptance_criteria.length > 0) {
|
|
1178
|
+
console.log(`
|
|
1179
|
+
Done when:`);
|
|
1180
|
+
for (const ac of story.acceptance_criteria) {
|
|
1181
|
+
const check = ac.done ? "\u2611" : "\u2610";
|
|
1182
|
+
console.log(` ${check} ${ac.text}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (story.notes) {
|
|
1186
|
+
console.log(`
|
|
1187
|
+
Notes:`);
|
|
1188
|
+
for (const line of story.notes.split("\n")) {
|
|
1189
|
+
console.log(` ${line}`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (story.evidence && story.evidence.length > 0) {
|
|
1193
|
+
console.log(`
|
|
1194
|
+
Evidence (${story.evidence.length}):`);
|
|
1195
|
+
for (const ev of story.evidence) {
|
|
1196
|
+
console.log(` [${ev.type}] ${ev.content.slice(0, 80)}${ev.content.length > 80 ? "\u2026" : ""}${ev.source ? ` \u2014 ${ev.source}` : ""}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
const contextPath = writeStoryContext(story);
|
|
1200
|
+
const relPath = contextPath.replace(process.cwd() + "/", "");
|
|
1201
|
+
console.log(`
|
|
1202
|
+
Context : ${relPath}`);
|
|
1203
|
+
console.log(` Mark done: npx productbuilders done ${story.id.slice(0, 8)}
|
|
1204
|
+
`);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// src/templates/index.ts
|
|
1208
|
+
var templates = {
|
|
1209
|
+
saas: {
|
|
1210
|
+
name: "SaaS Starter",
|
|
1211
|
+
description: "Multi-tenant SaaS with auth, billing, and dashboards",
|
|
1212
|
+
personas: [
|
|
1213
|
+
{ name: "Admin", role: "Account administrator who manages the team" },
|
|
1214
|
+
{ name: "User", role: "End user who uses the core product daily" }
|
|
1215
|
+
],
|
|
1216
|
+
activities: [
|
|
1217
|
+
{ title: "Sign up & Onboard", steps: ["Registration", "Team setup", "First-run experience"] },
|
|
1218
|
+
{ title: "Core Workflow", steps: ["Dashboard", "Main feature", "Data management"] },
|
|
1219
|
+
{ title: "Team Management", steps: ["Invite members", "Roles & permissions"] },
|
|
1220
|
+
{ title: "Billing & Plans", steps: ["Plan selection", "Checkout", "Usage tracking"] },
|
|
1221
|
+
{ title: "Settings & Account", steps: ["Profile", "Notifications", "Integrations"] }
|
|
1222
|
+
],
|
|
1223
|
+
stories: [
|
|
1224
|
+
{ title: "Sign up with email or OAuth", activity: "Sign up & Onboard", step: "Registration", priority: "must", effort: "M", release: 1 },
|
|
1225
|
+
{ title: "Email verification flow", activity: "Sign up & Onboard", step: "Registration", priority: "must", effort: "S", release: 1 },
|
|
1226
|
+
{ title: "Create workspace / tenant", activity: "Sign up & Onboard", step: "Team setup", priority: "must", effort: "M", release: 1 },
|
|
1227
|
+
{ title: "Onboarding wizard (3 steps)", activity: "Sign up & Onboard", step: "First-run experience", priority: "should", effort: "M", release: 1 },
|
|
1228
|
+
{ title: "Main dashboard with key metrics", activity: "Core Workflow", step: "Dashboard", priority: "must", effort: "L", release: 1 },
|
|
1229
|
+
{ title: "CRUD for primary resource", activity: "Core Workflow", step: "Main feature", priority: "must", effort: "L", release: 1 },
|
|
1230
|
+
{ title: "Search and filter", activity: "Core Workflow", step: "Data management", priority: "must", effort: "M", release: 1 },
|
|
1231
|
+
{ title: "Export data as CSV", activity: "Core Workflow", step: "Data management", priority: "should", effort: "S", release: 2 },
|
|
1232
|
+
{ title: "Invite team members by email", activity: "Team Management", step: "Invite members", priority: "must", effort: "M", release: 1 },
|
|
1233
|
+
{ title: "Role-based access control", activity: "Team Management", step: "Roles & permissions", priority: "must", effort: "L", release: 1 },
|
|
1234
|
+
{ title: "Stripe subscription checkout", activity: "Billing & Plans", step: "Checkout", priority: "must", effort: "L", release: 1 },
|
|
1235
|
+
{ title: "Free trial with upgrade prompt", activity: "Billing & Plans", step: "Plan selection", priority: "should", effort: "M", release: 1 },
|
|
1236
|
+
{ title: "Usage-based billing meter", activity: "Billing & Plans", step: "Usage tracking", priority: "could", effort: "L", release: 3 },
|
|
1237
|
+
{ title: "Edit profile and avatar", activity: "Settings & Account", step: "Profile", priority: "must", effort: "S", release: 1 },
|
|
1238
|
+
{ title: "Email notification preferences", activity: "Settings & Account", step: "Notifications", priority: "should", effort: "M", release: 2 },
|
|
1239
|
+
{ title: "Webhook integrations", activity: "Settings & Account", step: "Integrations", priority: "could", effort: "L", release: 3 }
|
|
1240
|
+
]
|
|
1241
|
+
},
|
|
1242
|
+
marketplace: {
|
|
1243
|
+
name: "Marketplace",
|
|
1244
|
+
description: "Two-sided marketplace connecting buyers and sellers",
|
|
1245
|
+
personas: [
|
|
1246
|
+
{ name: "Buyer", role: "Person searching for and purchasing services/products" },
|
|
1247
|
+
{ name: "Seller", role: "Person listing and selling services/products" }
|
|
1248
|
+
],
|
|
1249
|
+
activities: [
|
|
1250
|
+
{ title: "Discover", steps: ["Browse listings", "Search & filter", "View details"] },
|
|
1251
|
+
{ title: "List & Sell", steps: ["Create listing", "Manage inventory", "Pricing"] },
|
|
1252
|
+
{ title: "Transact", steps: ["Add to cart", "Checkout", "Payment processing"] },
|
|
1253
|
+
{ title: "Communicate", steps: ["Messaging", "Reviews & ratings"] },
|
|
1254
|
+
{ title: "Manage Account", steps: ["Profile", "Order history", "Payouts"] }
|
|
1255
|
+
],
|
|
1256
|
+
stories: [
|
|
1257
|
+
{ title: "Browse listings with categories", activity: "Discover", step: "Browse listings", priority: "must", effort: "L", release: 1 },
|
|
1258
|
+
{ title: "Full-text search with filters", activity: "Discover", step: "Search & filter", priority: "must", effort: "L", release: 1 },
|
|
1259
|
+
{ title: "Listing detail page with images", activity: "Discover", step: "View details", priority: "must", effort: "M", release: 1 },
|
|
1260
|
+
{ title: "Create listing with photos and description", activity: "List & Sell", step: "Create listing", priority: "must", effort: "L", release: 1 },
|
|
1261
|
+
{ title: "Set pricing and availability", activity: "List & Sell", step: "Pricing", priority: "must", effort: "M", release: 1 },
|
|
1262
|
+
{ title: "Manage active listings", activity: "List & Sell", step: "Manage inventory", priority: "must", effort: "M", release: 1 },
|
|
1263
|
+
{ title: "Shopping cart", activity: "Transact", step: "Add to cart", priority: "must", effort: "M", release: 1 },
|
|
1264
|
+
{ title: "Stripe checkout flow", activity: "Transact", step: "Checkout", priority: "must", effort: "L", release: 1 },
|
|
1265
|
+
{ title: "Split payments (platform fee + seller payout)", activity: "Transact", step: "Payment processing", priority: "must", effort: "XL", release: 1 },
|
|
1266
|
+
{ title: "Buyer-seller messaging", activity: "Communicate", step: "Messaging", priority: "should", effort: "L", release: 2 },
|
|
1267
|
+
{ title: "Leave reviews and ratings", activity: "Communicate", step: "Reviews & ratings", priority: "should", effort: "M", release: 2 },
|
|
1268
|
+
{ title: "Seller payout dashboard", activity: "Manage Account", step: "Payouts", priority: "must", effort: "L", release: 1 },
|
|
1269
|
+
{ title: "Order history for buyers", activity: "Manage Account", step: "Order history", priority: "must", effort: "M", release: 1 }
|
|
1270
|
+
]
|
|
1271
|
+
},
|
|
1272
|
+
"mobile-app": {
|
|
1273
|
+
name: "Mobile App",
|
|
1274
|
+
description: "Consumer mobile app with auth, feed, and notifications",
|
|
1275
|
+
personas: [
|
|
1276
|
+
{ name: "New User", role: "First-time user downloading the app" },
|
|
1277
|
+
{ name: "Power User", role: "Daily active user who has customized their experience" }
|
|
1278
|
+
],
|
|
1279
|
+
activities: [
|
|
1280
|
+
{ title: "Onboard", steps: ["App store \u2192 first open", "Sign up", "Permissions"] },
|
|
1281
|
+
{ title: "Core Experience", steps: ["Feed / home", "Create content", "Interact"] },
|
|
1282
|
+
{ title: "Profile & Social", steps: ["View profile", "Follow / connect", "Notifications"] },
|
|
1283
|
+
{ title: "Settings", steps: ["Preferences", "Privacy", "Account"] }
|
|
1284
|
+
],
|
|
1285
|
+
stories: [
|
|
1286
|
+
{ title: "App store listing and screenshots", activity: "Onboard", step: "App store \u2192 first open", priority: "must", effort: "M", release: 1 },
|
|
1287
|
+
{ title: "Social login (Apple, Google)", activity: "Onboard", step: "Sign up", priority: "must", effort: "M", release: 1 },
|
|
1288
|
+
{ title: "Push notification permission prompt", activity: "Onboard", step: "Permissions", priority: "must", effort: "S", release: 1 },
|
|
1289
|
+
{ title: "Main feed with pull-to-refresh", activity: "Core Experience", step: "Feed / home", priority: "must", effort: "L", release: 1 },
|
|
1290
|
+
{ title: "Create and publish content", activity: "Core Experience", step: "Create content", priority: "must", effort: "L", release: 1 },
|
|
1291
|
+
{ title: "Like, comment, share", activity: "Core Experience", step: "Interact", priority: "must", effort: "M", release: 1 },
|
|
1292
|
+
{ title: "User profile page", activity: "Profile & Social", step: "View profile", priority: "must", effort: "M", release: 1 },
|
|
1293
|
+
{ title: "Follow/unfollow users", activity: "Profile & Social", step: "Follow / connect", priority: "must", effort: "M", release: 1 },
|
|
1294
|
+
{ title: "Push notifications for interactions", activity: "Profile & Social", step: "Notifications", priority: "should", effort: "L", release: 2 },
|
|
1295
|
+
{ title: "Dark mode toggle", activity: "Settings", step: "Preferences", priority: "should", effort: "S", release: 2 },
|
|
1296
|
+
{ title: "Block and report users", activity: "Settings", step: "Privacy", priority: "must", effort: "M", release: 1 },
|
|
1297
|
+
{ title: "Delete account", activity: "Settings", step: "Account", priority: "must", effort: "S", release: 1 }
|
|
1298
|
+
]
|
|
1299
|
+
},
|
|
1300
|
+
"ai-tool": {
|
|
1301
|
+
name: "AI Tool",
|
|
1302
|
+
description: "AI-powered tool with prompt input, generation, and history",
|
|
1303
|
+
personas: [
|
|
1304
|
+
{ name: "Creator", role: "Professional using AI to augment their workflow" }
|
|
1305
|
+
],
|
|
1306
|
+
activities: [
|
|
1307
|
+
{ title: "Input & Configure", steps: ["Describe task", "Select model / options", "Upload context"] },
|
|
1308
|
+
{ title: "Generate", steps: ["Streaming output", "Edit & refine", "Regenerate"] },
|
|
1309
|
+
{ title: "Save & Share", steps: ["History", "Export", "Share link"] },
|
|
1310
|
+
{ title: "Account & Billing", steps: ["Usage dashboard", "Plan management", "API keys"] }
|
|
1311
|
+
],
|
|
1312
|
+
stories: [
|
|
1313
|
+
{ title: "Natural language input with prompt templates", activity: "Input & Configure", step: "Describe task", priority: "must", effort: "M", release: 1 },
|
|
1314
|
+
{ title: "Model / style selector", activity: "Input & Configure", step: "Select model / options", priority: "should", effort: "M", release: 1 },
|
|
1315
|
+
{ title: "File upload for context (PDF, images)", activity: "Input & Configure", step: "Upload context", priority: "should", effort: "L", release: 2 },
|
|
1316
|
+
{ title: "Streaming AI response with progress", activity: "Generate", step: "Streaming output", priority: "must", effort: "L", release: 1 },
|
|
1317
|
+
{ title: "Inline editing of generated output", activity: "Generate", step: "Edit & refine", priority: "must", effort: "M", release: 1 },
|
|
1318
|
+
{ title: "Regenerate with tweaked parameters", activity: "Generate", step: "Regenerate", priority: "should", effort: "S", release: 1 },
|
|
1319
|
+
{ title: "Generation history with search", activity: "Save & Share", step: "History", priority: "must", effort: "M", release: 1 },
|
|
1320
|
+
{ title: "Export as markdown / PDF", activity: "Save & Share", step: "Export", priority: "should", effort: "M", release: 2 },
|
|
1321
|
+
{ title: "Shareable public link", activity: "Save & Share", step: "Share link", priority: "could", effort: "M", release: 2 },
|
|
1322
|
+
{ title: "Token usage dashboard", activity: "Account & Billing", step: "Usage dashboard", priority: "must", effort: "M", release: 1 },
|
|
1323
|
+
{ title: "Stripe subscription with usage limits", activity: "Account & Billing", step: "Plan management", priority: "must", effort: "L", release: 1 },
|
|
1324
|
+
{ title: "API key management for developers", activity: "Account & Billing", step: "API keys", priority: "could", effort: "M", release: 3 }
|
|
1325
|
+
]
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
// src/mcp/server.ts
|
|
1330
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1331
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1332
|
+
import {
|
|
1333
|
+
CallToolRequestSchema,
|
|
1334
|
+
ListToolsRequestSchema
|
|
1335
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
1336
|
+
var TOOLS = [
|
|
1337
|
+
// ── Core tools (~5k tokens) ──────────────────────────────────────────────
|
|
1338
|
+
{
|
|
1339
|
+
name: "list_stories",
|
|
1340
|
+
description: "List all stories from the specstocode story map with their status (todo/in_progress/done), priority, effort, and acceptance criteria.",
|
|
1341
|
+
inputSchema: {
|
|
1342
|
+
type: "object",
|
|
1343
|
+
properties: {
|
|
1344
|
+
filter: {
|
|
1345
|
+
type: "string",
|
|
1346
|
+
description: "Optional filter: 'todo', 'in_progress', 'done', or a search term"
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
mode: "core"
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
name: "mark_done",
|
|
1354
|
+
description: "Mark a story as done on the specstocode story map. Use the story ID from list_stories.",
|
|
1355
|
+
inputSchema: {
|
|
1356
|
+
type: "object",
|
|
1357
|
+
properties: {
|
|
1358
|
+
story_id: { type: "string", description: "The story ID (full UUID or prefix)" }
|
|
1359
|
+
},
|
|
1360
|
+
required: ["story_id"]
|
|
1361
|
+
},
|
|
1362
|
+
mode: "core"
|
|
1363
|
+
},
|
|
1364
|
+
{
|
|
1365
|
+
name: "mark_in_progress",
|
|
1366
|
+
description: "Mark a story as in progress on the specstocode story map.",
|
|
1367
|
+
inputSchema: {
|
|
1368
|
+
type: "object",
|
|
1369
|
+
properties: {
|
|
1370
|
+
story_id: { type: "string", description: "The story ID" }
|
|
1371
|
+
},
|
|
1372
|
+
required: ["story_id"]
|
|
1373
|
+
},
|
|
1374
|
+
mode: "core"
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
name: "get_status",
|
|
1378
|
+
description: "Get a summary of story map progress \u2014 total stories, done, in progress, todo counts.",
|
|
1379
|
+
inputSchema: { type: "object", properties: {} },
|
|
1380
|
+
mode: "core"
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
name: "add_note",
|
|
1384
|
+
description: "Add implementation notes to a story. Use this to record how something was built, gotchas, or context for future developers.",
|
|
1385
|
+
inputSchema: {
|
|
1386
|
+
type: "object",
|
|
1387
|
+
properties: {
|
|
1388
|
+
story_id: { type: "string", description: "The story ID" },
|
|
1389
|
+
note: { type: "string", description: "Implementation note" }
|
|
1390
|
+
},
|
|
1391
|
+
required: ["story_id", "note"]
|
|
1392
|
+
},
|
|
1393
|
+
mode: "core"
|
|
1394
|
+
},
|
|
1395
|
+
// ── Standard tools (~10k tokens) ────────────────────────────────────────
|
|
1396
|
+
{
|
|
1397
|
+
name: "create_story",
|
|
1398
|
+
description: "Create a new story on the specstocode story map. Use when you discover a new requirement during development.",
|
|
1399
|
+
inputSchema: {
|
|
1400
|
+
type: "object",
|
|
1401
|
+
properties: {
|
|
1402
|
+
title: { type: "string", description: "Story title" },
|
|
1403
|
+
activity: { type: "string", description: "Activity name to add the story under" },
|
|
1404
|
+
priority: { type: "string", enum: ["must", "should", "could"], description: "Priority level" },
|
|
1405
|
+
effort: { type: "string", enum: ["S", "M", "L", "XL"], description: "Effort estimate" }
|
|
1406
|
+
},
|
|
1407
|
+
required: ["title"]
|
|
1408
|
+
},
|
|
1409
|
+
mode: "standard"
|
|
1410
|
+
},
|
|
1411
|
+
{
|
|
1412
|
+
name: "get_context",
|
|
1413
|
+
description: "Get the full SPECSTOCODE.md context \u2014 the product story map with all personas, stories, and acceptance criteria.",
|
|
1414
|
+
inputSchema: { type: "object", properties: {} },
|
|
1415
|
+
mode: "standard"
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
name: "log_decision",
|
|
1419
|
+
description: "Log an architectural or product decision to the specstocode story map. Use this when you make a significant technical choice during development.",
|
|
1420
|
+
inputSchema: {
|
|
1421
|
+
type: "object",
|
|
1422
|
+
properties: {
|
|
1423
|
+
title: { type: "string", description: "Short decision title" },
|
|
1424
|
+
description: { type: "string", description: "What was decided and why" },
|
|
1425
|
+
category: {
|
|
1426
|
+
type: "string",
|
|
1427
|
+
enum: ["architecture", "product", "technical", "trade-off"],
|
|
1428
|
+
description: "Decision category"
|
|
1429
|
+
},
|
|
1430
|
+
story_id: { type: "string", description: "Optional: link to a story ID" }
|
|
1431
|
+
},
|
|
1432
|
+
required: ["title", "description"]
|
|
1433
|
+
},
|
|
1434
|
+
mode: "standard"
|
|
1435
|
+
},
|
|
1436
|
+
// ── All tools (~15k tokens) ─────────────────────────────────────────────
|
|
1437
|
+
{
|
|
1438
|
+
name: "analyze_complexity",
|
|
1439
|
+
description: "Analyze the complexity of stories using AI. Returns a 1-10 complexity score, reasoning, risks, and suggested effort for each story. Useful before starting work to identify hidden complexity.",
|
|
1440
|
+
inputSchema: {
|
|
1441
|
+
type: "object",
|
|
1442
|
+
properties: {
|
|
1443
|
+
story_ids: {
|
|
1444
|
+
type: "array",
|
|
1445
|
+
items: { type: "string" },
|
|
1446
|
+
description: "Optional list of story IDs to analyze. If omitted, analyzes all non-done stories."
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
},
|
|
1450
|
+
mode: "all"
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
name: "research",
|
|
1454
|
+
description: "Research a topic using AI with your project context. Returns findings, recommendations, risks, and next steps. Use for architectural decisions, library choices, or technical questions.",
|
|
1455
|
+
inputSchema: {
|
|
1456
|
+
type: "object",
|
|
1457
|
+
properties: {
|
|
1458
|
+
query: { type: "string", description: "What to research" }
|
|
1459
|
+
},
|
|
1460
|
+
required: ["query"]
|
|
1461
|
+
},
|
|
1462
|
+
mode: "all"
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
name: "import_prd",
|
|
1466
|
+
description: "Parse a PRD or spec document into user stories. Returns structured stories with titles, activities, priorities, effort estimates, and acceptance criteria.",
|
|
1467
|
+
inputSchema: {
|
|
1468
|
+
type: "object",
|
|
1469
|
+
properties: {
|
|
1470
|
+
prd: { type: "string", description: "The PRD or spec text to parse" },
|
|
1471
|
+
format: { type: "string", description: "Document format: PRD, Markdown, text", enum: ["PRD", "Markdown", "text"] }
|
|
1472
|
+
},
|
|
1473
|
+
required: ["prd"]
|
|
1474
|
+
},
|
|
1475
|
+
mode: "all"
|
|
1476
|
+
}
|
|
1477
|
+
];
|
|
1478
|
+
var MODE_LEVELS = { core: 0, standard: 1, all: 2 };
|
|
1479
|
+
function getToolsForMode(mode) {
|
|
1480
|
+
const level = MODE_LEVELS[mode];
|
|
1481
|
+
return TOOLS.filter((t) => MODE_LEVELS[t.mode] <= level);
|
|
1482
|
+
}
|
|
1483
|
+
async function startMcpServer(mode = "all") {
|
|
1484
|
+
const config = requireConfig();
|
|
1485
|
+
const toolMode = ["core", "standard", "all"].includes(mode) ? mode : "all";
|
|
1486
|
+
const activeTools = getToolsForMode(toolMode);
|
|
1487
|
+
const server = new Server(
|
|
1488
|
+
{ name: "specstocode", version: "0.2.0" },
|
|
1489
|
+
{ capabilities: { tools: {} } }
|
|
1490
|
+
);
|
|
1491
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1492
|
+
tools: activeTools.map(({ mode: _m, ...rest }) => rest)
|
|
1493
|
+
}));
|
|
1494
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1495
|
+
const { name, arguments: args } = request.params;
|
|
1496
|
+
if (!activeTools.find((t) => t.name === name)) {
|
|
1497
|
+
return {
|
|
1498
|
+
content: [{
|
|
1499
|
+
type: "text",
|
|
1500
|
+
text: `Tool "${name}" is not available in "${toolMode}" mode. Use --mode standard or --mode all to enable it.`
|
|
1501
|
+
}]
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
switch (name) {
|
|
1505
|
+
case "list_stories": {
|
|
1506
|
+
const data = await listStories(config);
|
|
1507
|
+
let storyList = data.stories;
|
|
1508
|
+
const filter = args?.filter ?? "";
|
|
1509
|
+
if (filter) {
|
|
1510
|
+
const f = filter.toLowerCase();
|
|
1511
|
+
if (["todo", "in_progress", "done"].includes(f)) {
|
|
1512
|
+
storyList = storyList.filter((s) => s.status === f);
|
|
1513
|
+
} else {
|
|
1514
|
+
storyList = storyList.filter(
|
|
1515
|
+
(s) => s.title.toLowerCase().includes(f) || s.activity.toLowerCase().includes(f)
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return {
|
|
1520
|
+
content: [{
|
|
1521
|
+
type: "text",
|
|
1522
|
+
text: JSON.stringify({ summary: data.summary, stories: storyList }, null, 2)
|
|
1523
|
+
}]
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
case "mark_done":
|
|
1527
|
+
case "mark_in_progress": {
|
|
1528
|
+
const storyId = args?.story_id;
|
|
1529
|
+
if (!storyId) return { content: [{ type: "text", text: "story_id is required" }] };
|
|
1530
|
+
const data = await listStories(config);
|
|
1531
|
+
const match = data.stories.find((s) => s.id.startsWith(storyId));
|
|
1532
|
+
if (!match) return { content: [{ type: "text", text: `No story found matching "${storyId}"` }] };
|
|
1533
|
+
const newStatus = name === "mark_done" ? "done" : "in_progress";
|
|
1534
|
+
await updateStories(config, [{ id: match.id, status: newStatus }]);
|
|
1535
|
+
return {
|
|
1536
|
+
content: [{ type: "text", text: `\u2713 ${match.title} \u2192 ${newStatus}` }]
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
case "create_story": {
|
|
1540
|
+
const result = await createStories(config, [
|
|
1541
|
+
{
|
|
1542
|
+
title: args?.title,
|
|
1543
|
+
activity: args?.activity,
|
|
1544
|
+
priority: args?.priority,
|
|
1545
|
+
effort: args?.effort
|
|
1546
|
+
}
|
|
1547
|
+
]);
|
|
1548
|
+
return {
|
|
1549
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
case "get_context": {
|
|
1553
|
+
const md = await getContext(config);
|
|
1554
|
+
return { content: [{ type: "text", text: md }] };
|
|
1555
|
+
}
|
|
1556
|
+
case "get_status": {
|
|
1557
|
+
const data = await listStories(config);
|
|
1558
|
+
const { summary } = data;
|
|
1559
|
+
const pct = summary.total > 0 ? Math.round(summary.done / summary.total * 100) : 0;
|
|
1560
|
+
return {
|
|
1561
|
+
content: [{
|
|
1562
|
+
type: "text",
|
|
1563
|
+
text: `${data.map.title}: ${summary.done}/${summary.total} done (${pct}%) \u2014 ${summary.in_progress} in progress, ${summary.todo} todo`
|
|
1564
|
+
}]
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
case "log_decision": {
|
|
1568
|
+
const res = await fetch(
|
|
1569
|
+
`${config.apiBase}/api/sync/${config.syncToken}/decisions`,
|
|
1570
|
+
{
|
|
1571
|
+
method: "POST",
|
|
1572
|
+
headers: { "Content-Type": "application/json" },
|
|
1573
|
+
body: JSON.stringify({
|
|
1574
|
+
title: args?.title,
|
|
1575
|
+
description: args?.description,
|
|
1576
|
+
category: args?.category ?? "technical",
|
|
1577
|
+
story_id: args?.story_id
|
|
1578
|
+
})
|
|
1579
|
+
}
|
|
1580
|
+
);
|
|
1581
|
+
if (res.ok) {
|
|
1582
|
+
return { content: [{ type: "text", text: `\u2713 Decision logged: ${args?.title}` }] };
|
|
1583
|
+
}
|
|
1584
|
+
return { content: [{ type: "text", text: "Failed to log decision" }] };
|
|
1585
|
+
}
|
|
1586
|
+
case "add_note": {
|
|
1587
|
+
const storyId = args?.story_id;
|
|
1588
|
+
const note = args?.note;
|
|
1589
|
+
if (!storyId || !note) {
|
|
1590
|
+
return { content: [{ type: "text", text: "story_id and note required" }] };
|
|
1591
|
+
}
|
|
1592
|
+
const data = await listStories(config);
|
|
1593
|
+
const match = data.stories.find((s) => s.id.startsWith(storyId));
|
|
1594
|
+
if (!match) {
|
|
1595
|
+
return { content: [{ type: "text", text: `No story found matching "${storyId}"` }] };
|
|
1596
|
+
}
|
|
1597
|
+
await fetch(`${config.apiBase}/api/sync/${config.syncToken}/stories`, {
|
|
1598
|
+
method: "PATCH",
|
|
1599
|
+
headers: { "Content-Type": "application/json" },
|
|
1600
|
+
body: JSON.stringify({ stories: [{ id: match.id, notes: note }] })
|
|
1601
|
+
});
|
|
1602
|
+
return { content: [{ type: "text", text: `\u2713 Note added to: ${match.title}` }] };
|
|
1603
|
+
}
|
|
1604
|
+
// ── New tools ─────────────────────────────────────────────────────────
|
|
1605
|
+
case "analyze_complexity": {
|
|
1606
|
+
const storyIds = args?.story_ids;
|
|
1607
|
+
const body = {};
|
|
1608
|
+
if (storyIds?.length) body.story_ids = storyIds;
|
|
1609
|
+
else {
|
|
1610
|
+
const data = await listStories(config);
|
|
1611
|
+
body.story_ids = data.stories.filter((s) => s.status !== "done").map((s) => s.id);
|
|
1612
|
+
}
|
|
1613
|
+
const res = await fetch(
|
|
1614
|
+
`${config.apiBase}/api/sync/${config.syncToken}/complexity`,
|
|
1615
|
+
{
|
|
1616
|
+
method: "POST",
|
|
1617
|
+
headers: { "Content-Type": "application/json" },
|
|
1618
|
+
body: JSON.stringify(body)
|
|
1619
|
+
}
|
|
1620
|
+
);
|
|
1621
|
+
if (!res.ok) return { content: [{ type: "text", text: "Complexity analysis failed" }] };
|
|
1622
|
+
const result = await res.json();
|
|
1623
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1624
|
+
}
|
|
1625
|
+
case "research": {
|
|
1626
|
+
const query = args?.query;
|
|
1627
|
+
if (!query) return { content: [{ type: "text", text: "query is required" }] };
|
|
1628
|
+
const res = await fetch(
|
|
1629
|
+
`${config.apiBase}/api/sync/${config.syncToken}/research`,
|
|
1630
|
+
{
|
|
1631
|
+
method: "POST",
|
|
1632
|
+
headers: { "Content-Type": "application/json" },
|
|
1633
|
+
body: JSON.stringify({ query })
|
|
1634
|
+
}
|
|
1635
|
+
);
|
|
1636
|
+
if (!res.ok) return { content: [{ type: "text", text: "Research failed" }] };
|
|
1637
|
+
const result = await res.json();
|
|
1638
|
+
return { content: [{ type: "text", text: result.research }] };
|
|
1639
|
+
}
|
|
1640
|
+
case "import_prd": {
|
|
1641
|
+
const prd = args?.prd;
|
|
1642
|
+
if (!prd) return { content: [{ type: "text", text: "prd text is required" }] };
|
|
1643
|
+
const res = await fetch(
|
|
1644
|
+
`${config.apiBase}/api/sync/${config.syncToken}/import`,
|
|
1645
|
+
{
|
|
1646
|
+
method: "POST",
|
|
1647
|
+
headers: { "Content-Type": "application/json" },
|
|
1648
|
+
body: JSON.stringify({ prd, format: args?.format ?? "PRD" })
|
|
1649
|
+
}
|
|
1650
|
+
);
|
|
1651
|
+
if (!res.ok) return { content: [{ type: "text", text: "PRD import failed" }] };
|
|
1652
|
+
const result = await res.json();
|
|
1653
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1654
|
+
}
|
|
1655
|
+
default:
|
|
1656
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
const transport = new StdioServerTransport();
|
|
1660
|
+
await server.connect(transport);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// src/commands/openclaw-register.ts
|
|
1664
|
+
import { execSync as execSync2 } from "child_process";
|
|
1665
|
+
async function openclawRegister() {
|
|
1666
|
+
if (!hasConfig()) {
|
|
1667
|
+
console.error("Not initialised. Run: npx specstocode init");
|
|
1668
|
+
process.exit(1);
|
|
1669
|
+
}
|
|
1670
|
+
const config = readConfig();
|
|
1671
|
+
const cwd = process.cwd();
|
|
1672
|
+
const mapTitle = config?.mapTitle ?? "this project";
|
|
1673
|
+
const registration = JSON.stringify({
|
|
1674
|
+
command: "npx",
|
|
1675
|
+
args: ["specstocode", "mcp"],
|
|
1676
|
+
cwd
|
|
1677
|
+
});
|
|
1678
|
+
console.log(`
|
|
1679
|
+
Registering specstocode MCP with OpenClaw...`);
|
|
1680
|
+
console.log(` Project : ${mapTitle}`);
|
|
1681
|
+
console.log(` cwd : ${cwd}
|
|
1682
|
+
`);
|
|
1683
|
+
try {
|
|
1684
|
+
execSync2(`openclaw mcp set specstocode '${registration}'`, {
|
|
1685
|
+
stdio: "inherit"
|
|
1686
|
+
});
|
|
1687
|
+
console.log(`
|
|
1688
|
+
\u2705 Done. Any coding agent OpenClaw spawns from this project will have`);
|
|
1689
|
+
console.log(` access to list_stories, mark_done, add_note, create_story, and more.
|
|
1690
|
+
`);
|
|
1691
|
+
console.log(` To register a different project, cd into it and run this command again.
|
|
1692
|
+
`);
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
console.error("\n\u2717 Registration failed. Is OpenClaw installed and on your PATH?");
|
|
1695
|
+
console.error(" You can register manually:\n");
|
|
1696
|
+
console.error(` openclaw mcp set specstocode '${registration}'
|
|
1697
|
+
`);
|
|
1698
|
+
process.exit(1);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/index.ts
|
|
1703
|
+
var require2 = createRequire(import.meta.url);
|
|
1704
|
+
var { version } = require2("../package.json");
|
|
1705
|
+
var program = new Command();
|
|
1706
|
+
program.name("specstocode").description("Write specs, map stories, ship with AI \u2014 from your terminal").version(version);
|
|
1707
|
+
program.command("login").description("Log in to specstocode (opens browser)").action(() => void login());
|
|
1708
|
+
program.command("logout").description("Log out of specstocode").action(() => void logout());
|
|
1709
|
+
program.command("start").description("Start a new project \u2014 guided flow from problem to pitch").action(() => void start());
|
|
1710
|
+
program.command("scope [projectId]").description("Scope a project \u2014 generate blueprint, story map, and mockup").action((id) => void scope(id));
|
|
1711
|
+
program.command("setup").description("Configure your AI tool (Claude Code / Cursor) with specstocode agent workflows").action(() => void setup());
|
|
1712
|
+
program.command("propose [name]").description("Propose a new change \u2014 creates stories in your map and saves a local spec").option("--from <file>", "Parse stories from a markdown proposal file instead of prompting").action((name, opts) => void propose(name, opts));
|
|
1713
|
+
program.command("templates [name]").description("Browse story map templates").action((name) => {
|
|
1714
|
+
if (!name) {
|
|
1715
|
+
console.log("\n Available templates:\n");
|
|
1716
|
+
for (const [key, tmpl] of Object.entries(templates)) {
|
|
1717
|
+
console.log(` ${key.padEnd(20)} ${tmpl.description}`);
|
|
1718
|
+
}
|
|
1719
|
+
console.log(`
|
|
1720
|
+
Usage: npx specstocode templates <name>
|
|
1721
|
+
`);
|
|
1722
|
+
} else {
|
|
1723
|
+
const tmpl = templates[name];
|
|
1724
|
+
if (!tmpl) {
|
|
1725
|
+
console.error(`Unknown template: "${name}". Run \`npx specstocode templates\` to list.`);
|
|
1726
|
+
} else {
|
|
1727
|
+
console.log(`
|
|
1728
|
+
${tmpl.name}: ${tmpl.description}`);
|
|
1729
|
+
console.log(` ${tmpl.stories.length} stories, ${tmpl.activities.length} activities, ${tmpl.personas.length} personas
|
|
1730
|
+
`);
|
|
1731
|
+
for (const act of tmpl.activities) {
|
|
1732
|
+
console.log(` ${act.title}: ${act.steps.join(" \u2192 ")}`);
|
|
1733
|
+
}
|
|
1734
|
+
console.log();
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
program.command("init [token]").description("Connect this project to a specstocode story map").action((token) => void init(token));
|
|
1739
|
+
program.command("sync").description("Refresh SPECSTOCODE.md from the live story map").action(() => void sync());
|
|
1740
|
+
program.command("status").description("Show story map progress").action(() => void status());
|
|
1741
|
+
program.command("stories").description("List all stories").option("-f, --filter <filter>", "Filter by status (todo/in-progress/done) or search term").option("-s, --status <status>", "Filter by exact status: todo, in-progress, wip, done").option("-k, --kanban", "View stories as a kanban board (todo | in-progress | done)").action((opts) => void stories(opts));
|
|
1742
|
+
program.command("next").description("Show the next highest-priority story to work on").option("-o, --open", "Open the spec page in your browser").option("--skip-wip", "Pick next todo even if stories are in progress").action((opts) => void next(opts));
|
|
1743
|
+
program.command("show <id>").description("Show the full spec for a story (accepts ID prefix)").action((id) => void show(id));
|
|
1744
|
+
program.command("done <id>").description("Mark a story as done (accepts ID prefix)").action((id) => void done(id));
|
|
1745
|
+
program.command("add <title>").description("Create a new story").option("-a, --activity <activity>", "Activity to add the story under").option("-p, --priority <priority>", "Priority: must, should, could", "should").option("-e, --effort <effort>", "Effort: S, M, L, XL", "M").action((title, opts) => void add(title, opts));
|
|
1746
|
+
program.command("decide [title]").description("Log an architectural or product decision").option("-d, --description <desc>", "Decision description").option("-c, --category <cat>", "Category: architecture, product, technical, trade-off", "technical").option("-s, --story <id>", "Link to a story ID").action((title, opts) => {
|
|
1747
|
+
import("./log-GSWUQF6Z.js").then((m) => void m.logDecision(title, opts));
|
|
1748
|
+
});
|
|
1749
|
+
program.command("note <storyId> [note]").description("Add implementation notes to a story").action((storyId, note) => {
|
|
1750
|
+
import("./log-GSWUQF6Z.js").then((m) => void m.logNote(storyId, note));
|
|
1751
|
+
});
|
|
1752
|
+
program.command("complexity [storyId]").description("Analyze story complexity with AI \u2014 scores, risks, effort estimates").action((id) => {
|
|
1753
|
+
import("./complexity-TUS6F2UI.js").then((m) => void m.complexity(id));
|
|
1754
|
+
});
|
|
1755
|
+
program.command("research [query]").description("Research a topic with AI using your project context").action((query) => {
|
|
1756
|
+
import("./research-UGNKVMZ5.js").then((m) => void m.research(query));
|
|
1757
|
+
});
|
|
1758
|
+
program.command("import [file]").description("Import a PRD or spec into your story map as stories").action((file) => {
|
|
1759
|
+
import("./import-prd-HP66GKRA.js").then((m) => void m.importPrd(file));
|
|
1760
|
+
});
|
|
1761
|
+
program.command("mcp").description("Start MCP server for Claude Code / Cursor integration").option("-m, --mode <mode>", "Tool mode: core, standard, all", "all").action((opts) => void startMcpServer(opts.mode));
|
|
1762
|
+
program.command("openclaw-register").description("Register this project with OpenClaw MCP \u2014 gives spawned coding agents access to your story map").action(() => void openclawRegister());
|
|
1763
|
+
program.parse();
|