@anthropologies/claudestory 0.1.3 → 0.1.5
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 +25 -4
- package/dist/cli.js +779 -15
- package/dist/index.d.ts +605 -1
- package/dist/index.js +528 -1
- package/dist/mcp.js +787 -19
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/mcp/index.ts
|
|
4
|
-
import { realpathSync, existsSync as
|
|
5
|
-
import { resolve as
|
|
4
|
+
import { realpathSync, existsSync as existsSync5 } from "fs";
|
|
5
|
+
import { resolve as resolve7, join as join8, isAbsolute } from "path";
|
|
6
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
8
|
|
|
@@ -33,8 +33,8 @@ function discoverProjectRoot(startDir) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// src/mcp/tools.ts
|
|
36
|
-
import { z as
|
|
37
|
-
import { join as
|
|
36
|
+
import { z as z7 } from "zod";
|
|
37
|
+
import { join as join7 } from "path";
|
|
38
38
|
|
|
39
39
|
// src/core/project-loader.ts
|
|
40
40
|
import {
|
|
@@ -386,6 +386,7 @@ import { readdir, readFile } from "fs/promises";
|
|
|
386
386
|
import { existsSync as existsSync2 } from "fs";
|
|
387
387
|
import { join as join2, relative, extname } from "path";
|
|
388
388
|
var HANDOVER_DATE_REGEX = /^\d{4}-\d{2}-\d{2}/;
|
|
389
|
+
var HANDOVER_SEQ_REGEX = /^(\d{4}-\d{2}-\d{2})-(\d{2})-/;
|
|
389
390
|
async function listHandovers(handoversDir, root, warnings) {
|
|
390
391
|
if (!existsSync2(handoversDir)) return [];
|
|
391
392
|
let entries;
|
|
@@ -415,7 +416,16 @@ async function listHandovers(handoversDir, root, warnings) {
|
|
|
415
416
|
});
|
|
416
417
|
}
|
|
417
418
|
}
|
|
418
|
-
conforming.sort((a, b) =>
|
|
419
|
+
conforming.sort((a, b) => {
|
|
420
|
+
const dateA = a.slice(0, 10);
|
|
421
|
+
const dateB = b.slice(0, 10);
|
|
422
|
+
if (dateA !== dateB) return dateB.localeCompare(dateA);
|
|
423
|
+
const seqA = a.match(HANDOVER_SEQ_REGEX);
|
|
424
|
+
const seqB = b.match(HANDOVER_SEQ_REGEX);
|
|
425
|
+
if (seqA && !seqB) return -1;
|
|
426
|
+
if (!seqA && seqB) return 1;
|
|
427
|
+
return b.localeCompare(a);
|
|
428
|
+
});
|
|
419
429
|
return [...conforming, ...nonConforming];
|
|
420
430
|
}
|
|
421
431
|
async function readHandover(handoversDir, filename) {
|
|
@@ -502,6 +512,33 @@ async function loadProject(root, options) {
|
|
|
502
512
|
});
|
|
503
513
|
return { state, warnings };
|
|
504
514
|
}
|
|
515
|
+
async function withProjectLock(root, options, handler) {
|
|
516
|
+
const absRoot = resolve2(root);
|
|
517
|
+
const wrapDir = join3(absRoot, ".story");
|
|
518
|
+
await withLock(wrapDir, async () => {
|
|
519
|
+
await doRecoverTransaction(wrapDir);
|
|
520
|
+
const result = await loadProjectUnlocked(absRoot);
|
|
521
|
+
const config = result.state.config;
|
|
522
|
+
if (config.schemaVersion !== void 0 && config.schemaVersion > CURRENT_SCHEMA_VERSION) {
|
|
523
|
+
throw new ProjectLoaderError(
|
|
524
|
+
"version_mismatch",
|
|
525
|
+
`Config schemaVersion ${config.schemaVersion} exceeds max supported ${CURRENT_SCHEMA_VERSION}.`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (options.strict) {
|
|
529
|
+
const integrityWarning = result.warnings.find(
|
|
530
|
+
(w) => INTEGRITY_WARNING_TYPES.includes(w.type)
|
|
531
|
+
);
|
|
532
|
+
if (integrityWarning) {
|
|
533
|
+
throw new ProjectLoaderError(
|
|
534
|
+
"project_corrupt",
|
|
535
|
+
`Strict mode: ${integrityWarning.file}: ${integrityWarning.message}`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
await handler(result);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
505
542
|
async function doRecoverTransaction(wrapDir) {
|
|
506
543
|
const journalPath = join3(wrapDir, ".txn.json");
|
|
507
544
|
let entries;
|
|
@@ -570,6 +607,17 @@ async function doRecoverTransaction(wrapDir) {
|
|
|
570
607
|
} catch {
|
|
571
608
|
}
|
|
572
609
|
}
|
|
610
|
+
async function loadProjectUnlocked(absRoot) {
|
|
611
|
+
const wrapDir = join3(absRoot, ".story");
|
|
612
|
+
const config = await loadSingletonFile("config.json", wrapDir, absRoot, ConfigSchema);
|
|
613
|
+
const roadmap = await loadSingletonFile("roadmap.json", wrapDir, absRoot, RoadmapSchema);
|
|
614
|
+
const warnings = [];
|
|
615
|
+
const tickets = await loadDirectory(join3(wrapDir, "tickets"), absRoot, TicketSchema, warnings);
|
|
616
|
+
const issues = await loadDirectory(join3(wrapDir, "issues"), absRoot, IssueSchema, warnings);
|
|
617
|
+
const handoverFilenames = await listHandovers(join3(wrapDir, "handovers"), absRoot, warnings);
|
|
618
|
+
const state = new ProjectState({ tickets, issues, roadmap, config, handoverFilenames });
|
|
619
|
+
return { state, warnings };
|
|
620
|
+
}
|
|
573
621
|
async function loadSingletonFile(filename, wrapDir, root, schema) {
|
|
574
622
|
const filePath = join3(wrapDir, filename);
|
|
575
623
|
const relPath = relative2(root, filePath);
|
|
@@ -648,6 +696,60 @@ async function loadDirectory(dirPath, root, schema, warnings) {
|
|
|
648
696
|
}
|
|
649
697
|
return results;
|
|
650
698
|
}
|
|
699
|
+
async function atomicWrite(targetPath, content) {
|
|
700
|
+
const tempPath = `${targetPath}.${process.pid}.tmp`;
|
|
701
|
+
try {
|
|
702
|
+
await writeFile(tempPath, content, "utf-8");
|
|
703
|
+
await rename(tempPath, targetPath);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
try {
|
|
706
|
+
await unlink(tempPath);
|
|
707
|
+
} catch {
|
|
708
|
+
}
|
|
709
|
+
throw new ProjectLoaderError(
|
|
710
|
+
"io_error",
|
|
711
|
+
`Failed to write ${basename(targetPath)}`,
|
|
712
|
+
err
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async function guardPath(target, root) {
|
|
717
|
+
let resolvedRoot;
|
|
718
|
+
try {
|
|
719
|
+
resolvedRoot = await realpath(root);
|
|
720
|
+
} catch {
|
|
721
|
+
throw new ProjectLoaderError(
|
|
722
|
+
"invalid_input",
|
|
723
|
+
`Cannot resolve project root: ${root}`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
const targetDir = dirname2(target);
|
|
727
|
+
let resolvedDir;
|
|
728
|
+
try {
|
|
729
|
+
resolvedDir = await realpath(targetDir);
|
|
730
|
+
} catch {
|
|
731
|
+
resolvedDir = targetDir;
|
|
732
|
+
}
|
|
733
|
+
if (!resolvedDir.startsWith(resolvedRoot)) {
|
|
734
|
+
throw new ProjectLoaderError(
|
|
735
|
+
"invalid_input",
|
|
736
|
+
`Path ${target} resolves outside project root`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
if (existsSync3(target)) {
|
|
740
|
+
try {
|
|
741
|
+
const stats = await lstat(target);
|
|
742
|
+
if (stats.isSymbolicLink()) {
|
|
743
|
+
throw new ProjectLoaderError(
|
|
744
|
+
"invalid_input",
|
|
745
|
+
`Symlink target rejected: ${target}`
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
} catch (err) {
|
|
749
|
+
if (err instanceof ProjectLoaderError) throw err;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
651
753
|
async function withLock(wrapDir, fn) {
|
|
652
754
|
let release;
|
|
653
755
|
try {
|
|
@@ -686,6 +788,13 @@ var CliValidationError = class extends Error {
|
|
|
686
788
|
this.name = "CliValidationError";
|
|
687
789
|
}
|
|
688
790
|
};
|
|
791
|
+
function todayISO() {
|
|
792
|
+
const d = /* @__PURE__ */ new Date();
|
|
793
|
+
const y = d.getFullYear();
|
|
794
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
795
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
796
|
+
return `${y}-${m}-${day}`;
|
|
797
|
+
}
|
|
689
798
|
async function parseHandoverFilename(raw, handoversDir) {
|
|
690
799
|
if (raw.includes("/") || raw.includes("\\") || raw.includes("..") || raw.includes("\0")) {
|
|
691
800
|
throw new CliValidationError(
|
|
@@ -1109,6 +1218,284 @@ function formatHandoverContent(filename, content, format) {
|
|
|
1109
1218
|
}
|
|
1110
1219
|
return content;
|
|
1111
1220
|
}
|
|
1221
|
+
function formatHandoverCreateResult(filename, format) {
|
|
1222
|
+
if (format === "json") {
|
|
1223
|
+
return JSON.stringify(successEnvelope({ filename }), null, 2);
|
|
1224
|
+
}
|
|
1225
|
+
return `Created handover: ${filename}`;
|
|
1226
|
+
}
|
|
1227
|
+
function formatSnapshotResult(result, format) {
|
|
1228
|
+
if (format === "json") {
|
|
1229
|
+
return JSON.stringify(successEnvelope(result), null, 2);
|
|
1230
|
+
}
|
|
1231
|
+
let line = `Snapshot saved: ${result.filename} (${result.retained} retained`;
|
|
1232
|
+
if (result.pruned > 0) line += `, ${result.pruned} pruned`;
|
|
1233
|
+
line += ")";
|
|
1234
|
+
return line;
|
|
1235
|
+
}
|
|
1236
|
+
function formatRecap(recap, state, format) {
|
|
1237
|
+
if (format === "json") {
|
|
1238
|
+
return JSON.stringify(successEnvelope(recap), null, 2);
|
|
1239
|
+
}
|
|
1240
|
+
const lines = [];
|
|
1241
|
+
if (!recap.snapshot) {
|
|
1242
|
+
lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Recap`);
|
|
1243
|
+
lines.push("");
|
|
1244
|
+
lines.push("No snapshot found. Run `claudestory snapshot` to enable session diffs.");
|
|
1245
|
+
lines.push("");
|
|
1246
|
+
lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete, ${state.blockedCount} blocked`);
|
|
1247
|
+
lines.push(`Issues: ${state.openIssueCount} open`);
|
|
1248
|
+
} else {
|
|
1249
|
+
lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Recap`);
|
|
1250
|
+
lines.push("");
|
|
1251
|
+
lines.push(`Since snapshot: ${recap.snapshot.createdAt}`);
|
|
1252
|
+
if (recap.partial) {
|
|
1253
|
+
lines.push("**Note:** Snapshot was taken from a project with integrity warnings. Diff may be incomplete.");
|
|
1254
|
+
}
|
|
1255
|
+
const changes = recap.changes;
|
|
1256
|
+
const hasChanges = hasAnyChanges(changes);
|
|
1257
|
+
if (!hasChanges) {
|
|
1258
|
+
lines.push("");
|
|
1259
|
+
lines.push("No changes since last snapshot.");
|
|
1260
|
+
} else {
|
|
1261
|
+
if (changes.phases.statusChanged.length > 0) {
|
|
1262
|
+
lines.push("");
|
|
1263
|
+
lines.push("## Phase Transitions");
|
|
1264
|
+
for (const p of changes.phases.statusChanged) {
|
|
1265
|
+
lines.push(`- **${escapeMarkdownInline(p.name)}** (${p.id}): ${p.from} \u2192 ${p.to}`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const ticketChanges = changes.tickets;
|
|
1269
|
+
if (ticketChanges.added.length > 0 || ticketChanges.removed.length > 0 || ticketChanges.statusChanged.length > 0) {
|
|
1270
|
+
lines.push("");
|
|
1271
|
+
lines.push("## Tickets");
|
|
1272
|
+
for (const t of ticketChanges.statusChanged) {
|
|
1273
|
+
lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 ${t.from} \u2192 ${t.to}`);
|
|
1274
|
+
}
|
|
1275
|
+
for (const t of ticketChanges.added) {
|
|
1276
|
+
lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 **new**`);
|
|
1277
|
+
}
|
|
1278
|
+
for (const t of ticketChanges.removed) {
|
|
1279
|
+
lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 **removed**`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const issueChanges = changes.issues;
|
|
1283
|
+
if (issueChanges.added.length > 0 || issueChanges.resolved.length > 0 || issueChanges.statusChanged.length > 0) {
|
|
1284
|
+
lines.push("");
|
|
1285
|
+
lines.push("## Issues");
|
|
1286
|
+
for (const i of issueChanges.resolved) {
|
|
1287
|
+
lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 **resolved**`);
|
|
1288
|
+
}
|
|
1289
|
+
for (const i of issueChanges.statusChanged) {
|
|
1290
|
+
lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 ${i.from} \u2192 ${i.to}`);
|
|
1291
|
+
}
|
|
1292
|
+
for (const i of issueChanges.added) {
|
|
1293
|
+
lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 **new**`);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (changes.blockers.added.length > 0 || changes.blockers.cleared.length > 0) {
|
|
1297
|
+
lines.push("");
|
|
1298
|
+
lines.push("## Blockers");
|
|
1299
|
+
for (const name of changes.blockers.cleared) {
|
|
1300
|
+
lines.push(`- ${escapeMarkdownInline(name)} \u2014 **cleared**`);
|
|
1301
|
+
}
|
|
1302
|
+
for (const name of changes.blockers.added) {
|
|
1303
|
+
lines.push(`- ${escapeMarkdownInline(name)} \u2014 **new**`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const actions = recap.suggestedActions;
|
|
1309
|
+
lines.push("");
|
|
1310
|
+
lines.push("## Suggested Actions");
|
|
1311
|
+
if (actions.nextTicket) {
|
|
1312
|
+
lines.push(`- **Next:** ${actions.nextTicket.id} \u2014 ${escapeMarkdownInline(actions.nextTicket.title)}${actions.nextTicket.phase ? ` (${actions.nextTicket.phase})` : ""}`);
|
|
1313
|
+
}
|
|
1314
|
+
if (actions.highSeverityIssues.length > 0) {
|
|
1315
|
+
for (const i of actions.highSeverityIssues) {
|
|
1316
|
+
lines.push(`- **${i.severity} issue:** ${i.id} \u2014 ${escapeMarkdownInline(i.title)}`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (actions.recentlyClearedBlockers.length > 0) {
|
|
1320
|
+
lines.push(`- **Recently cleared:** ${actions.recentlyClearedBlockers.map(escapeMarkdownInline).join(", ")}`);
|
|
1321
|
+
}
|
|
1322
|
+
if (!actions.nextTicket && actions.highSeverityIssues.length === 0 && actions.recentlyClearedBlockers.length === 0) {
|
|
1323
|
+
lines.push("- No urgent actions.");
|
|
1324
|
+
}
|
|
1325
|
+
return lines.join("\n");
|
|
1326
|
+
}
|
|
1327
|
+
function formatExport(state, mode, phaseId, format) {
|
|
1328
|
+
if (mode === "phase" && phaseId) {
|
|
1329
|
+
return formatPhaseExport(state, phaseId, format);
|
|
1330
|
+
}
|
|
1331
|
+
return formatFullExport(state, format);
|
|
1332
|
+
}
|
|
1333
|
+
function formatPhaseExport(state, phaseId, format) {
|
|
1334
|
+
const phase = state.roadmap.phases.find((p) => p.id === phaseId);
|
|
1335
|
+
if (!phase) {
|
|
1336
|
+
return formatError("not_found", `Phase "${phaseId}" not found`, format);
|
|
1337
|
+
}
|
|
1338
|
+
const phaseStatus = state.phaseStatus(phaseId);
|
|
1339
|
+
const leaves = state.phaseTickets(phaseId);
|
|
1340
|
+
const umbrellaAncestors = /* @__PURE__ */ new Map();
|
|
1341
|
+
for (const leaf of leaves) {
|
|
1342
|
+
if (leaf.parentTicket) {
|
|
1343
|
+
const parent = state.ticketByID(leaf.parentTicket);
|
|
1344
|
+
if (parent && !umbrellaAncestors.has(parent.id)) {
|
|
1345
|
+
umbrellaAncestors.set(parent.id, parent);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
const crossPhaseDeps = /* @__PURE__ */ new Map();
|
|
1350
|
+
for (const leaf of leaves) {
|
|
1351
|
+
for (const blockerId of leaf.blockedBy) {
|
|
1352
|
+
const blocker = state.ticketByID(blockerId);
|
|
1353
|
+
if (blocker && blocker.phase !== phaseId && !crossPhaseDeps.has(blocker.id)) {
|
|
1354
|
+
crossPhaseDeps.set(blocker.id, blocker);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const relatedIssues = state.issues.filter(
|
|
1359
|
+
(i) => i.status !== "resolved" && (i.phase === phaseId || i.relatedTickets.some((tid) => {
|
|
1360
|
+
const t = state.ticketByID(tid);
|
|
1361
|
+
return t && t.phase === phaseId;
|
|
1362
|
+
}))
|
|
1363
|
+
);
|
|
1364
|
+
const activeBlockers = state.roadmap.blockers.filter(
|
|
1365
|
+
(b) => !isBlockerCleared(b)
|
|
1366
|
+
);
|
|
1367
|
+
if (format === "json") {
|
|
1368
|
+
return JSON.stringify(
|
|
1369
|
+
successEnvelope({
|
|
1370
|
+
phase: { id: phase.id, name: phase.name, description: phase.description, status: phaseStatus },
|
|
1371
|
+
tickets: leaves.map((t) => ({ id: t.id, title: t.title, status: t.status, type: t.type, order: t.order })),
|
|
1372
|
+
umbrellaAncestors: [...umbrellaAncestors.values()].map((t) => ({ id: t.id, title: t.title })),
|
|
1373
|
+
crossPhaseDependencies: [...crossPhaseDeps.values()].map((t) => ({ id: t.id, title: t.title, status: t.status, phase: t.phase })),
|
|
1374
|
+
issues: relatedIssues.map((i) => ({ id: i.id, title: i.title, severity: i.severity, status: i.status })),
|
|
1375
|
+
blockers: activeBlockers.map((b) => ({ name: b.name, note: b.note ?? null }))
|
|
1376
|
+
}),
|
|
1377
|
+
null,
|
|
1378
|
+
2
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
const lines = [];
|
|
1382
|
+
lines.push(`# ${escapeMarkdownInline(phase.name)} (${phase.id})`);
|
|
1383
|
+
lines.push("");
|
|
1384
|
+
lines.push(`Status: ${phaseStatus}`);
|
|
1385
|
+
if (phase.description) {
|
|
1386
|
+
lines.push(`Description: ${escapeMarkdownInline(phase.description)}`);
|
|
1387
|
+
}
|
|
1388
|
+
if (leaves.length > 0) {
|
|
1389
|
+
lines.push("");
|
|
1390
|
+
lines.push("## Tickets");
|
|
1391
|
+
for (const t of leaves) {
|
|
1392
|
+
const indicator = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
|
|
1393
|
+
const parentNote = t.parentTicket && umbrellaAncestors.has(t.parentTicket) ? ` (under ${t.parentTicket})` : "";
|
|
1394
|
+
lines.push(`${indicator} ${t.id}: ${escapeMarkdownInline(t.title)}${parentNote}`);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (crossPhaseDeps.size > 0) {
|
|
1398
|
+
lines.push("");
|
|
1399
|
+
lines.push("## Cross-Phase Dependencies");
|
|
1400
|
+
for (const [, dep] of crossPhaseDeps) {
|
|
1401
|
+
lines.push(`- ${dep.id}: ${escapeMarkdownInline(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (relatedIssues.length > 0) {
|
|
1405
|
+
lines.push("");
|
|
1406
|
+
lines.push("## Open Issues");
|
|
1407
|
+
for (const i of relatedIssues) {
|
|
1408
|
+
lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
if (activeBlockers.length > 0) {
|
|
1412
|
+
lines.push("");
|
|
1413
|
+
lines.push("## Active Blockers");
|
|
1414
|
+
for (const b of activeBlockers) {
|
|
1415
|
+
lines.push(`- ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return lines.join("\n");
|
|
1419
|
+
}
|
|
1420
|
+
function formatFullExport(state, format) {
|
|
1421
|
+
const phases = phasesWithStatus(state);
|
|
1422
|
+
if (format === "json") {
|
|
1423
|
+
return JSON.stringify(
|
|
1424
|
+
successEnvelope({
|
|
1425
|
+
project: state.config.project,
|
|
1426
|
+
phases: phases.map((p) => ({
|
|
1427
|
+
id: p.phase.id,
|
|
1428
|
+
name: p.phase.name,
|
|
1429
|
+
description: p.phase.description,
|
|
1430
|
+
status: p.status,
|
|
1431
|
+
tickets: state.phaseTickets(p.phase.id).map((t) => ({
|
|
1432
|
+
id: t.id,
|
|
1433
|
+
title: t.title,
|
|
1434
|
+
status: t.status,
|
|
1435
|
+
type: t.type
|
|
1436
|
+
}))
|
|
1437
|
+
})),
|
|
1438
|
+
issues: state.issues.map((i) => ({
|
|
1439
|
+
id: i.id,
|
|
1440
|
+
title: i.title,
|
|
1441
|
+
severity: i.severity,
|
|
1442
|
+
status: i.status
|
|
1443
|
+
})),
|
|
1444
|
+
blockers: state.roadmap.blockers.map((b) => ({
|
|
1445
|
+
name: b.name,
|
|
1446
|
+
cleared: isBlockerCleared(b),
|
|
1447
|
+
note: b.note ?? null
|
|
1448
|
+
}))
|
|
1449
|
+
}),
|
|
1450
|
+
null,
|
|
1451
|
+
2
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
const lines = [];
|
|
1455
|
+
lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Full Export`);
|
|
1456
|
+
lines.push("");
|
|
1457
|
+
lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete`);
|
|
1458
|
+
lines.push(`Issues: ${state.openIssueCount} open`);
|
|
1459
|
+
lines.push("");
|
|
1460
|
+
lines.push("## Phases");
|
|
1461
|
+
for (const p of phases) {
|
|
1462
|
+
const indicator = p.status === "complete" ? "[x]" : p.status === "inprogress" ? "[~]" : "[ ]";
|
|
1463
|
+
lines.push("");
|
|
1464
|
+
lines.push(`### ${indicator} ${escapeMarkdownInline(p.phase.name)} (${p.phase.id})`);
|
|
1465
|
+
if (p.phase.description) {
|
|
1466
|
+
lines.push(escapeMarkdownInline(p.phase.description));
|
|
1467
|
+
}
|
|
1468
|
+
const tickets = state.phaseTickets(p.phase.id);
|
|
1469
|
+
if (tickets.length > 0) {
|
|
1470
|
+
lines.push("");
|
|
1471
|
+
for (const t of tickets) {
|
|
1472
|
+
const ti = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
|
|
1473
|
+
lines.push(`${ti} ${t.id}: ${escapeMarkdownInline(t.title)}`);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
if (state.issues.length > 0) {
|
|
1478
|
+
lines.push("");
|
|
1479
|
+
lines.push("## Issues");
|
|
1480
|
+
for (const i of state.issues) {
|
|
1481
|
+
const resolved = i.status === "resolved" ? " \u2713" : "";
|
|
1482
|
+
lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}${resolved}`);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
const blockers = state.roadmap.blockers;
|
|
1486
|
+
if (blockers.length > 0) {
|
|
1487
|
+
lines.push("");
|
|
1488
|
+
lines.push("## Blockers");
|
|
1489
|
+
for (const b of blockers) {
|
|
1490
|
+
const cleared = isBlockerCleared(b) ? "[x]" : "[ ]";
|
|
1491
|
+
lines.push(`${cleared} ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return lines.join("\n");
|
|
1495
|
+
}
|
|
1496
|
+
function hasAnyChanges(diff) {
|
|
1497
|
+
return diff.tickets.added.length > 0 || diff.tickets.removed.length > 0 || diff.tickets.statusChanged.length > 0 || diff.issues.added.length > 0 || diff.issues.resolved.length > 0 || diff.issues.statusChanged.length > 0 || diff.blockers.added.length > 0 || diff.blockers.cleared.length > 0 || diff.phases.added.length > 0 || diff.phases.removed.length > 0 || diff.phases.statusChanged.length > 0;
|
|
1498
|
+
}
|
|
1112
1499
|
function truncate(text, maxLen) {
|
|
1113
1500
|
if (text.length <= maxLen) return text;
|
|
1114
1501
|
return text.slice(0, maxLen - 3) + "...";
|
|
@@ -1376,6 +1763,8 @@ function handleValidate(ctx) {
|
|
|
1376
1763
|
}
|
|
1377
1764
|
|
|
1378
1765
|
// src/cli/commands/handover.ts
|
|
1766
|
+
import { mkdir } from "fs/promises";
|
|
1767
|
+
import { join as join4, resolve as resolve4 } from "path";
|
|
1379
1768
|
function handleHandoverList(ctx) {
|
|
1380
1769
|
return { output: formatHandoverList(ctx.state.handoverFilenames, ctx.format) };
|
|
1381
1770
|
}
|
|
@@ -1427,6 +1816,59 @@ async function handleHandoverGet(filename, ctx) {
|
|
|
1427
1816
|
};
|
|
1428
1817
|
}
|
|
1429
1818
|
}
|
|
1819
|
+
function normalizeSlug(raw) {
|
|
1820
|
+
let slug = raw.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1821
|
+
if (slug.length > 60) slug = slug.slice(0, 60).replace(/-$/, "");
|
|
1822
|
+
if (!slug) {
|
|
1823
|
+
throw new CliValidationError(
|
|
1824
|
+
"invalid_input",
|
|
1825
|
+
`Slug is empty after normalization: "${raw}"`
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
return slug;
|
|
1829
|
+
}
|
|
1830
|
+
async function handleHandoverCreate(content, slugRaw, format, root) {
|
|
1831
|
+
if (!content.trim()) {
|
|
1832
|
+
throw new CliValidationError("invalid_input", "Handover content is empty");
|
|
1833
|
+
}
|
|
1834
|
+
const slug = normalizeSlug(slugRaw);
|
|
1835
|
+
const date = todayISO();
|
|
1836
|
+
let filename;
|
|
1837
|
+
await withProjectLock(root, { strict: false }, async () => {
|
|
1838
|
+
const absRoot = resolve4(root);
|
|
1839
|
+
const handoversDir = join4(absRoot, ".story", "handovers");
|
|
1840
|
+
await mkdir(handoversDir, { recursive: true });
|
|
1841
|
+
const wrapDir = join4(absRoot, ".story");
|
|
1842
|
+
const datePrefix = `${date}-`;
|
|
1843
|
+
const seqRegex = new RegExp(`^${date}-(\\d{2})-`);
|
|
1844
|
+
let maxSeq = 0;
|
|
1845
|
+
const { readdirSync } = await import("fs");
|
|
1846
|
+
try {
|
|
1847
|
+
for (const f of readdirSync(handoversDir)) {
|
|
1848
|
+
const m = f.match(seqRegex);
|
|
1849
|
+
if (m) {
|
|
1850
|
+
const n = parseInt(m[1], 10);
|
|
1851
|
+
if (n > maxSeq) maxSeq = n;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
} catch {
|
|
1855
|
+
}
|
|
1856
|
+
const nextSeq = maxSeq + 1;
|
|
1857
|
+
if (nextSeq > 99) {
|
|
1858
|
+
throw new CliValidationError(
|
|
1859
|
+
"conflict",
|
|
1860
|
+
`Too many handovers for ${date}; limit is 99 per day`
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
const candidate = `${date}-${String(nextSeq).padStart(2, "0")}-${slug}.md`;
|
|
1864
|
+
const candidatePath = join4(handoversDir, candidate);
|
|
1865
|
+
await parseHandoverFilename(candidate, handoversDir);
|
|
1866
|
+
await guardPath(candidatePath, wrapDir);
|
|
1867
|
+
await atomicWrite(candidatePath, content);
|
|
1868
|
+
filename = candidate;
|
|
1869
|
+
});
|
|
1870
|
+
return { output: formatHandoverCreateResult(filename, format) };
|
|
1871
|
+
}
|
|
1430
1872
|
|
|
1431
1873
|
// src/cli/commands/blocker.ts
|
|
1432
1874
|
function handleBlockerList(ctx) {
|
|
@@ -1518,8 +1960,266 @@ function handleIssueGet(id, ctx) {
|
|
|
1518
1960
|
return { output: formatIssue(issue, ctx.format) };
|
|
1519
1961
|
}
|
|
1520
1962
|
|
|
1963
|
+
// src/core/snapshot.ts
|
|
1964
|
+
import { readdir as readdir3, readFile as readFile3, mkdir as mkdir2, unlink as unlink2 } from "fs/promises";
|
|
1965
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1966
|
+
import { join as join5, resolve as resolve5 } from "path";
|
|
1967
|
+
import { z as z6 } from "zod";
|
|
1968
|
+
var LoadWarningSchema = z6.object({
|
|
1969
|
+
type: z6.string(),
|
|
1970
|
+
file: z6.string(),
|
|
1971
|
+
message: z6.string()
|
|
1972
|
+
});
|
|
1973
|
+
var SnapshotV1Schema = z6.object({
|
|
1974
|
+
version: z6.literal(1),
|
|
1975
|
+
createdAt: z6.string().datetime({ offset: true }),
|
|
1976
|
+
project: z6.string(),
|
|
1977
|
+
config: ConfigSchema,
|
|
1978
|
+
roadmap: RoadmapSchema,
|
|
1979
|
+
tickets: z6.array(TicketSchema),
|
|
1980
|
+
issues: z6.array(IssueSchema),
|
|
1981
|
+
warnings: z6.array(LoadWarningSchema).optional()
|
|
1982
|
+
});
|
|
1983
|
+
var MAX_SNAPSHOTS = 20;
|
|
1984
|
+
async function saveSnapshot(root, loadResult) {
|
|
1985
|
+
const absRoot = resolve5(root);
|
|
1986
|
+
const snapshotsDir = join5(absRoot, ".story", "snapshots");
|
|
1987
|
+
await mkdir2(snapshotsDir, { recursive: true });
|
|
1988
|
+
const { state, warnings } = loadResult;
|
|
1989
|
+
const now = /* @__PURE__ */ new Date();
|
|
1990
|
+
const filename = formatSnapshotFilename(now);
|
|
1991
|
+
const snapshot = {
|
|
1992
|
+
version: 1,
|
|
1993
|
+
createdAt: now.toISOString(),
|
|
1994
|
+
project: state.config.project,
|
|
1995
|
+
config: state.config,
|
|
1996
|
+
roadmap: state.roadmap,
|
|
1997
|
+
tickets: [...state.tickets],
|
|
1998
|
+
issues: [...state.issues],
|
|
1999
|
+
...warnings.length > 0 ? {
|
|
2000
|
+
warnings: warnings.map((w) => ({
|
|
2001
|
+
type: w.type,
|
|
2002
|
+
file: w.file,
|
|
2003
|
+
message: w.message
|
|
2004
|
+
}))
|
|
2005
|
+
} : {}
|
|
2006
|
+
};
|
|
2007
|
+
const json = JSON.stringify(snapshot, null, 2) + "\n";
|
|
2008
|
+
const targetPath = join5(snapshotsDir, filename);
|
|
2009
|
+
const wrapDir = join5(absRoot, ".story");
|
|
2010
|
+
await guardPath(targetPath, wrapDir);
|
|
2011
|
+
await atomicWrite(targetPath, json);
|
|
2012
|
+
const pruned = await pruneSnapshots(snapshotsDir);
|
|
2013
|
+
const entries = await listSnapshotFiles(snapshotsDir);
|
|
2014
|
+
return { filename, retained: entries.length, pruned };
|
|
2015
|
+
}
|
|
2016
|
+
async function loadLatestSnapshot(root) {
|
|
2017
|
+
const snapshotsDir = join5(resolve5(root), ".story", "snapshots");
|
|
2018
|
+
if (!existsSync4(snapshotsDir)) return null;
|
|
2019
|
+
const files = await listSnapshotFiles(snapshotsDir);
|
|
2020
|
+
if (files.length === 0) return null;
|
|
2021
|
+
for (const filename of files) {
|
|
2022
|
+
try {
|
|
2023
|
+
const content = await readFile3(join5(snapshotsDir, filename), "utf-8");
|
|
2024
|
+
const parsed = JSON.parse(content);
|
|
2025
|
+
const snapshot = SnapshotV1Schema.parse(parsed);
|
|
2026
|
+
return { snapshot, filename };
|
|
2027
|
+
} catch {
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
function diffStates(snapshotState, currentState) {
|
|
2034
|
+
const snapTickets = new Map(snapshotState.tickets.map((t) => [t.id, t]));
|
|
2035
|
+
const curTickets = new Map(currentState.tickets.map((t) => [t.id, t]));
|
|
2036
|
+
const ticketsAdded = [];
|
|
2037
|
+
const ticketsRemoved = [];
|
|
2038
|
+
const ticketsStatusChanged = [];
|
|
2039
|
+
for (const [id, cur] of curTickets) {
|
|
2040
|
+
const snap = snapTickets.get(id);
|
|
2041
|
+
if (!snap) {
|
|
2042
|
+
ticketsAdded.push({ id, title: cur.title });
|
|
2043
|
+
} else if (snap.status !== cur.status) {
|
|
2044
|
+
ticketsStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
for (const [id, snap] of snapTickets) {
|
|
2048
|
+
if (!curTickets.has(id)) {
|
|
2049
|
+
ticketsRemoved.push({ id, title: snap.title });
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
const snapIssues = new Map(snapshotState.issues.map((i) => [i.id, i]));
|
|
2053
|
+
const curIssues = new Map(currentState.issues.map((i) => [i.id, i]));
|
|
2054
|
+
const issuesAdded = [];
|
|
2055
|
+
const issuesResolved = [];
|
|
2056
|
+
const issuesStatusChanged = [];
|
|
2057
|
+
for (const [id, cur] of curIssues) {
|
|
2058
|
+
const snap = snapIssues.get(id);
|
|
2059
|
+
if (!snap) {
|
|
2060
|
+
issuesAdded.push({ id, title: cur.title });
|
|
2061
|
+
} else if (snap.status !== cur.status) {
|
|
2062
|
+
if (cur.status === "resolved") {
|
|
2063
|
+
issuesResolved.push({ id, title: cur.title });
|
|
2064
|
+
} else {
|
|
2065
|
+
issuesStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
const snapBlockers = new Map(
|
|
2070
|
+
snapshotState.roadmap.blockers.map((b) => [b.name, b])
|
|
2071
|
+
);
|
|
2072
|
+
const curBlockers = new Map(
|
|
2073
|
+
currentState.roadmap.blockers.map((b) => [b.name, b])
|
|
2074
|
+
);
|
|
2075
|
+
const blockersAdded = [];
|
|
2076
|
+
const blockersCleared = [];
|
|
2077
|
+
for (const [name, cur] of curBlockers) {
|
|
2078
|
+
const snap = snapBlockers.get(name);
|
|
2079
|
+
if (!snap) {
|
|
2080
|
+
blockersAdded.push(name);
|
|
2081
|
+
} else if (!isBlockerCleared(snap) && isBlockerCleared(cur)) {
|
|
2082
|
+
blockersCleared.push(name);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
const snapPhases = snapshotState.roadmap.phases;
|
|
2086
|
+
const curPhases = currentState.roadmap.phases;
|
|
2087
|
+
const snapPhaseMap = new Map(snapPhases.map((p) => [p.id, p]));
|
|
2088
|
+
const curPhaseMap = new Map(curPhases.map((p) => [p.id, p]));
|
|
2089
|
+
const phasesAdded = [];
|
|
2090
|
+
const phasesRemoved = [];
|
|
2091
|
+
const phasesStatusChanged = [];
|
|
2092
|
+
for (const [id, curPhase] of curPhaseMap) {
|
|
2093
|
+
const snapPhase = snapPhaseMap.get(id);
|
|
2094
|
+
if (!snapPhase) {
|
|
2095
|
+
phasesAdded.push({ id, name: curPhase.name });
|
|
2096
|
+
} else {
|
|
2097
|
+
const snapStatus = snapshotState.phaseStatus(id);
|
|
2098
|
+
const curStatus = currentState.phaseStatus(id);
|
|
2099
|
+
if (snapStatus !== curStatus) {
|
|
2100
|
+
phasesStatusChanged.push({
|
|
2101
|
+
id,
|
|
2102
|
+
name: curPhase.name,
|
|
2103
|
+
from: snapStatus,
|
|
2104
|
+
to: curStatus
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
for (const [id, snapPhase] of snapPhaseMap) {
|
|
2110
|
+
if (!curPhaseMap.has(id)) {
|
|
2111
|
+
phasesRemoved.push({ id, name: snapPhase.name });
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
return {
|
|
2115
|
+
tickets: { added: ticketsAdded, removed: ticketsRemoved, statusChanged: ticketsStatusChanged },
|
|
2116
|
+
issues: { added: issuesAdded, resolved: issuesResolved, statusChanged: issuesStatusChanged },
|
|
2117
|
+
blockers: { added: blockersAdded, cleared: blockersCleared },
|
|
2118
|
+
phases: { added: phasesAdded, removed: phasesRemoved, statusChanged: phasesStatusChanged }
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
function buildRecap(currentState, snapshotInfo) {
|
|
2122
|
+
const next = nextTicket(currentState);
|
|
2123
|
+
const nextTicketAction = next.kind === "found" ? { id: next.ticket.id, title: next.ticket.title, phase: next.ticket.phase } : null;
|
|
2124
|
+
const highSeverityIssues = currentState.issues.filter(
|
|
2125
|
+
(i) => i.status !== "resolved" && (i.severity === "critical" || i.severity === "high")
|
|
2126
|
+
).map((i) => ({ id: i.id, title: i.title, severity: i.severity }));
|
|
2127
|
+
if (!snapshotInfo) {
|
|
2128
|
+
return {
|
|
2129
|
+
snapshot: null,
|
|
2130
|
+
changes: null,
|
|
2131
|
+
suggestedActions: {
|
|
2132
|
+
nextTicket: nextTicketAction,
|
|
2133
|
+
highSeverityIssues,
|
|
2134
|
+
recentlyClearedBlockers: []
|
|
2135
|
+
},
|
|
2136
|
+
partial: false
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
const { snapshot, filename } = snapshotInfo;
|
|
2140
|
+
const snapshotState = new ProjectState({
|
|
2141
|
+
tickets: snapshot.tickets,
|
|
2142
|
+
issues: snapshot.issues,
|
|
2143
|
+
roadmap: snapshot.roadmap,
|
|
2144
|
+
config: snapshot.config,
|
|
2145
|
+
handoverFilenames: []
|
|
2146
|
+
});
|
|
2147
|
+
const changes = diffStates(snapshotState, currentState);
|
|
2148
|
+
const recentlyClearedBlockers = changes.blockers.cleared;
|
|
2149
|
+
return {
|
|
2150
|
+
snapshot: { filename, createdAt: snapshot.createdAt },
|
|
2151
|
+
changes,
|
|
2152
|
+
suggestedActions: {
|
|
2153
|
+
nextTicket: nextTicketAction,
|
|
2154
|
+
highSeverityIssues,
|
|
2155
|
+
recentlyClearedBlockers
|
|
2156
|
+
},
|
|
2157
|
+
partial: (snapshot.warnings ?? []).length > 0
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
function formatSnapshotFilename(date) {
|
|
2161
|
+
const y = date.getUTCFullYear();
|
|
2162
|
+
const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
2163
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
2164
|
+
const h = String(date.getUTCHours()).padStart(2, "0");
|
|
2165
|
+
const mi = String(date.getUTCMinutes()).padStart(2, "0");
|
|
2166
|
+
const s = String(date.getUTCSeconds()).padStart(2, "0");
|
|
2167
|
+
const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
|
|
2168
|
+
return `${y}-${mo}-${d}T${h}-${mi}-${s}-${ms}.json`;
|
|
2169
|
+
}
|
|
2170
|
+
async function listSnapshotFiles(dir) {
|
|
2171
|
+
try {
|
|
2172
|
+
const entries = await readdir3(dir);
|
|
2173
|
+
return entries.filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort().reverse();
|
|
2174
|
+
} catch {
|
|
2175
|
+
return [];
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function pruneSnapshots(dir) {
|
|
2179
|
+
const files = await listSnapshotFiles(dir);
|
|
2180
|
+
if (files.length <= MAX_SNAPSHOTS) return 0;
|
|
2181
|
+
const toRemove = files.slice(MAX_SNAPSHOTS);
|
|
2182
|
+
for (const f of toRemove) {
|
|
2183
|
+
try {
|
|
2184
|
+
await unlink2(join5(dir, f));
|
|
2185
|
+
} catch {
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
return toRemove.length;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// src/cli/commands/recap.ts
|
|
2192
|
+
async function handleRecap(ctx) {
|
|
2193
|
+
const snapshotInfo = await loadLatestSnapshot(ctx.root);
|
|
2194
|
+
const recap = buildRecap(ctx.state, snapshotInfo);
|
|
2195
|
+
return { output: formatRecap(recap, ctx.state, ctx.format) };
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// src/cli/commands/snapshot.ts
|
|
2199
|
+
async function handleSnapshot(root, format) {
|
|
2200
|
+
let result;
|
|
2201
|
+
await withProjectLock(root, { strict: false }, async (loadResult) => {
|
|
2202
|
+
result = await saveSnapshot(root, loadResult);
|
|
2203
|
+
});
|
|
2204
|
+
return { output: formatSnapshotResult(result, format) };
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// src/cli/commands/export.ts
|
|
2208
|
+
function handleExport(ctx, mode, phaseId) {
|
|
2209
|
+
if (mode === "phase") {
|
|
2210
|
+
if (!phaseId) {
|
|
2211
|
+
throw new CliValidationError("invalid_input", "Missing --phase value");
|
|
2212
|
+
}
|
|
2213
|
+
const phase = ctx.state.roadmap.phases.find((p) => p.id === phaseId);
|
|
2214
|
+
if (!phase) {
|
|
2215
|
+
throw new CliValidationError("not_found", `Phase "${phaseId}" not found in roadmap`);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
return { output: formatExport(ctx.state, mode, phaseId, ctx.format) };
|
|
2219
|
+
}
|
|
2220
|
+
|
|
1521
2221
|
// src/cli/commands/phase.ts
|
|
1522
|
-
import { join as
|
|
2222
|
+
import { join as join6, resolve as resolve6 } from "path";
|
|
1523
2223
|
function handlePhaseList(ctx) {
|
|
1524
2224
|
return { output: formatPhaseList(ctx.state, ctx.format) };
|
|
1525
2225
|
}
|
|
@@ -1578,7 +2278,7 @@ function formatMcpError(code, message) {
|
|
|
1578
2278
|
async function runMcpReadTool(pinnedRoot, handler) {
|
|
1579
2279
|
try {
|
|
1580
2280
|
const { state, warnings } = await loadProject(pinnedRoot);
|
|
1581
|
-
const handoversDir =
|
|
2281
|
+
const handoversDir = join7(pinnedRoot, ".story", "handovers");
|
|
1582
2282
|
const ctx = { state, warnings, root: pinnedRoot, handoversDir, format: "md" };
|
|
1583
2283
|
const result = await handler(ctx);
|
|
1584
2284
|
if (result.errorCode && INFRASTRUCTURE_ERROR_CODES.includes(result.errorCode)) {
|
|
@@ -1608,6 +2308,27 @@ ${text}`;
|
|
|
1608
2308
|
return { content: [{ type: "text", text: formatMcpError("io_error", message) }], isError: true };
|
|
1609
2309
|
}
|
|
1610
2310
|
}
|
|
2311
|
+
async function runMcpWriteTool(pinnedRoot, handler) {
|
|
2312
|
+
try {
|
|
2313
|
+
const result = await handler(pinnedRoot, "md");
|
|
2314
|
+
if (result.errorCode && INFRASTRUCTURE_ERROR_CODES.includes(result.errorCode)) {
|
|
2315
|
+
return {
|
|
2316
|
+
content: [{ type: "text", text: formatMcpError(result.errorCode, result.output) }],
|
|
2317
|
+
isError: true
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
return { content: [{ type: "text", text: result.output }] };
|
|
2321
|
+
} catch (err) {
|
|
2322
|
+
if (err instanceof ProjectLoaderError) {
|
|
2323
|
+
return { content: [{ type: "text", text: formatMcpError(err.code, err.message) }], isError: true };
|
|
2324
|
+
}
|
|
2325
|
+
if (err instanceof CliValidationError) {
|
|
2326
|
+
return { content: [{ type: "text", text: formatMcpError(err.code, err.message) }], isError: true };
|
|
2327
|
+
}
|
|
2328
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2329
|
+
return { content: [{ type: "text", text: formatMcpError("io_error", message) }], isError: true };
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
1611
2332
|
function registerAllTools(server, pinnedRoot) {
|
|
1612
2333
|
server.registerTool("claudestory_status", {
|
|
1613
2334
|
description: "Project summary: phase statuses, ticket/issue counts, blockers, current phase"
|
|
@@ -1639,7 +2360,7 @@ function registerAllTools(server, pinnedRoot) {
|
|
|
1639
2360
|
server.registerTool("claudestory_phase_tickets", {
|
|
1640
2361
|
description: "Leaf tickets for a specific phase, sorted by order",
|
|
1641
2362
|
inputSchema: {
|
|
1642
|
-
phaseId:
|
|
2363
|
+
phaseId: z7.string().describe("Phase ID (e.g. p5b, dogfood)")
|
|
1643
2364
|
}
|
|
1644
2365
|
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => {
|
|
1645
2366
|
const phaseExists = ctx.state.roadmap.phases.some((p) => p.id === args.phaseId);
|
|
@@ -1655,9 +2376,9 @@ function registerAllTools(server, pinnedRoot) {
|
|
|
1655
2376
|
server.registerTool("claudestory_ticket_list", {
|
|
1656
2377
|
description: "List leaf tickets with optional filters",
|
|
1657
2378
|
inputSchema: {
|
|
1658
|
-
status:
|
|
1659
|
-
phase:
|
|
1660
|
-
type:
|
|
2379
|
+
status: z7.enum(TICKET_STATUSES).optional().describe("Filter by status: open, inprogress, complete"),
|
|
2380
|
+
phase: z7.string().optional().describe("Filter by phase ID"),
|
|
2381
|
+
type: z7.enum(TICKET_TYPES).optional().describe("Filter by type: task, feature, chore")
|
|
1661
2382
|
}
|
|
1662
2383
|
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => {
|
|
1663
2384
|
if (args.phase) {
|
|
@@ -1678,14 +2399,14 @@ function registerAllTools(server, pinnedRoot) {
|
|
|
1678
2399
|
server.registerTool("claudestory_ticket_get", {
|
|
1679
2400
|
description: "Get a ticket by ID (includes umbrella tickets)",
|
|
1680
2401
|
inputSchema: {
|
|
1681
|
-
id:
|
|
2402
|
+
id: z7.string().regex(TICKET_ID_REGEX).describe("Ticket ID (e.g. T-001, T-079b)")
|
|
1682
2403
|
}
|
|
1683
2404
|
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleTicketGet(args.id, ctx)));
|
|
1684
2405
|
server.registerTool("claudestory_issue_list", {
|
|
1685
2406
|
description: "List issues with optional filters",
|
|
1686
2407
|
inputSchema: {
|
|
1687
|
-
status:
|
|
1688
|
-
severity:
|
|
2408
|
+
status: z7.enum(ISSUE_STATUSES).optional().describe("Filter by status: open, inprogress, resolved"),
|
|
2409
|
+
severity: z7.enum(ISSUE_SEVERITIES).optional().describe("Filter by severity: critical, high, medium, low")
|
|
1689
2410
|
}
|
|
1690
2411
|
}, (args) => runMcpReadTool(
|
|
1691
2412
|
pinnedRoot,
|
|
@@ -1694,21 +2415,68 @@ function registerAllTools(server, pinnedRoot) {
|
|
|
1694
2415
|
server.registerTool("claudestory_issue_get", {
|
|
1695
2416
|
description: "Get an issue by ID",
|
|
1696
2417
|
inputSchema: {
|
|
1697
|
-
id:
|
|
2418
|
+
id: z7.string().regex(ISSUE_ID_REGEX).describe("Issue ID (e.g. ISS-001)")
|
|
1698
2419
|
}
|
|
1699
2420
|
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleIssueGet(args.id, ctx)));
|
|
1700
2421
|
server.registerTool("claudestory_handover_get", {
|
|
1701
2422
|
description: "Content of a specific handover document by filename",
|
|
1702
2423
|
inputSchema: {
|
|
1703
|
-
filename:
|
|
2424
|
+
filename: z7.string().describe("Handover filename (e.g. 2026-03-20-session.md)")
|
|
1704
2425
|
}
|
|
1705
2426
|
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleHandoverGet(args.filename, ctx)));
|
|
2427
|
+
server.registerTool("claudestory_recap", {
|
|
2428
|
+
description: "Session diff \u2014 changes since last snapshot + suggested next actions. Shows what changed and what to work on."
|
|
2429
|
+
}, () => runMcpReadTool(pinnedRoot, handleRecap));
|
|
2430
|
+
server.registerTool("claudestory_snapshot", {
|
|
2431
|
+
description: "Save current project state for session diffs. Creates a snapshot in .story/snapshots/."
|
|
2432
|
+
}, () => runMcpWriteTool(pinnedRoot, handleSnapshot));
|
|
2433
|
+
server.registerTool("claudestory_export", {
|
|
2434
|
+
description: "Self-contained project document for sharing",
|
|
2435
|
+
inputSchema: {
|
|
2436
|
+
phase: z7.string().optional().describe("Export a single phase by ID"),
|
|
2437
|
+
all: z7.boolean().optional().describe("Export entire project")
|
|
2438
|
+
}
|
|
2439
|
+
}, (args) => {
|
|
2440
|
+
if (!args.phase && !args.all) {
|
|
2441
|
+
return Promise.resolve({
|
|
2442
|
+
content: [{ type: "text", text: formatMcpError("invalid_input", "Specify either phase or all") }],
|
|
2443
|
+
isError: true
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
if (args.phase && args.all) {
|
|
2447
|
+
return Promise.resolve({
|
|
2448
|
+
content: [{ type: "text", text: formatMcpError("invalid_input", "Arguments phase and all are mutually exclusive") }],
|
|
2449
|
+
isError: true
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
const mode = args.all ? "all" : "phase";
|
|
2453
|
+
const phaseId = args.phase ?? null;
|
|
2454
|
+
return runMcpReadTool(pinnedRoot, (ctx) => handleExport(ctx, mode, phaseId));
|
|
2455
|
+
});
|
|
2456
|
+
server.registerTool("claudestory_handover_create", {
|
|
2457
|
+
description: "Create a handover document from markdown content",
|
|
2458
|
+
inputSchema: {
|
|
2459
|
+
content: z7.string().describe("Markdown content of the handover"),
|
|
2460
|
+
slug: z7.string().optional().describe("Slug for filename (e.g. phase5b-wrapup). Default: session")
|
|
2461
|
+
}
|
|
2462
|
+
}, (args) => {
|
|
2463
|
+
if (!args.content?.trim()) {
|
|
2464
|
+
return Promise.resolve({
|
|
2465
|
+
content: [{ type: "text", text: formatMcpError("invalid_input", "Handover content is empty") }],
|
|
2466
|
+
isError: true
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
return runMcpWriteTool(
|
|
2470
|
+
pinnedRoot,
|
|
2471
|
+
(root) => handleHandoverCreate(args.content, args.slug ?? "session", "md", root)
|
|
2472
|
+
);
|
|
2473
|
+
});
|
|
1706
2474
|
}
|
|
1707
2475
|
|
|
1708
2476
|
// src/mcp/index.ts
|
|
1709
2477
|
var ENV_VAR2 = "CLAUDESTORY_PROJECT_ROOT";
|
|
1710
2478
|
var CONFIG_PATH2 = ".story/config.json";
|
|
1711
|
-
var version = "0.1.
|
|
2479
|
+
var version = "0.1.5";
|
|
1712
2480
|
function pinProjectRoot() {
|
|
1713
2481
|
const envRoot = process.env[ENV_VAR2];
|
|
1714
2482
|
if (envRoot) {
|
|
@@ -1717,7 +2485,7 @@ function pinProjectRoot() {
|
|
|
1717
2485
|
`);
|
|
1718
2486
|
process.exit(1);
|
|
1719
2487
|
}
|
|
1720
|
-
const resolved =
|
|
2488
|
+
const resolved = resolve7(envRoot);
|
|
1721
2489
|
let canonical;
|
|
1722
2490
|
try {
|
|
1723
2491
|
canonical = realpathSync(resolved);
|
|
@@ -1726,7 +2494,7 @@ function pinProjectRoot() {
|
|
|
1726
2494
|
`);
|
|
1727
2495
|
process.exit(1);
|
|
1728
2496
|
}
|
|
1729
|
-
if (!
|
|
2497
|
+
if (!existsSync5(join8(canonical, CONFIG_PATH2))) {
|
|
1730
2498
|
process.stderr.write(`Error: No .story/config.json at ${canonical}
|
|
1731
2499
|
`);
|
|
1732
2500
|
process.exit(1);
|