@anthropologies/claudestory 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 existsSync4 } from "fs";
5
- import { resolve as resolve5, join as join6, isAbsolute } from "path";
4
+ import { realpathSync, existsSync as existsSync5 } from "fs";
5
+ import { resolve as resolve6, join as join7, 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 z6 } from "zod";
37
- import { join as join5 } from "path";
36
+ import { z as z7 } from "zod";
37
+ import { join as join6 } from "path";
38
38
 
39
39
  // src/core/project-loader.ts
40
40
  import {
@@ -502,6 +502,33 @@ async function loadProject(root, options) {
502
502
  });
503
503
  return { state, warnings };
504
504
  }
505
+ async function withProjectLock(root, options, handler) {
506
+ const absRoot = resolve2(root);
507
+ const wrapDir = join3(absRoot, ".story");
508
+ await withLock(wrapDir, async () => {
509
+ await doRecoverTransaction(wrapDir);
510
+ const result = await loadProjectUnlocked(absRoot);
511
+ const config = result.state.config;
512
+ if (config.schemaVersion !== void 0 && config.schemaVersion > CURRENT_SCHEMA_VERSION) {
513
+ throw new ProjectLoaderError(
514
+ "version_mismatch",
515
+ `Config schemaVersion ${config.schemaVersion} exceeds max supported ${CURRENT_SCHEMA_VERSION}.`
516
+ );
517
+ }
518
+ if (options.strict) {
519
+ const integrityWarning = result.warnings.find(
520
+ (w) => INTEGRITY_WARNING_TYPES.includes(w.type)
521
+ );
522
+ if (integrityWarning) {
523
+ throw new ProjectLoaderError(
524
+ "project_corrupt",
525
+ `Strict mode: ${integrityWarning.file}: ${integrityWarning.message}`
526
+ );
527
+ }
528
+ }
529
+ await handler(result);
530
+ });
531
+ }
505
532
  async function doRecoverTransaction(wrapDir) {
506
533
  const journalPath = join3(wrapDir, ".txn.json");
507
534
  let entries;
@@ -570,6 +597,17 @@ async function doRecoverTransaction(wrapDir) {
570
597
  } catch {
571
598
  }
572
599
  }
600
+ async function loadProjectUnlocked(absRoot) {
601
+ const wrapDir = join3(absRoot, ".story");
602
+ const config = await loadSingletonFile("config.json", wrapDir, absRoot, ConfigSchema);
603
+ const roadmap = await loadSingletonFile("roadmap.json", wrapDir, absRoot, RoadmapSchema);
604
+ const warnings = [];
605
+ const tickets = await loadDirectory(join3(wrapDir, "tickets"), absRoot, TicketSchema, warnings);
606
+ const issues = await loadDirectory(join3(wrapDir, "issues"), absRoot, IssueSchema, warnings);
607
+ const handoverFilenames = await listHandovers(join3(wrapDir, "handovers"), absRoot, warnings);
608
+ const state = new ProjectState({ tickets, issues, roadmap, config, handoverFilenames });
609
+ return { state, warnings };
610
+ }
573
611
  async function loadSingletonFile(filename, wrapDir, root, schema) {
574
612
  const filePath = join3(wrapDir, filename);
575
613
  const relPath = relative2(root, filePath);
@@ -648,6 +686,60 @@ async function loadDirectory(dirPath, root, schema, warnings) {
648
686
  }
649
687
  return results;
650
688
  }
689
+ async function atomicWrite(targetPath, content) {
690
+ const tempPath = `${targetPath}.${process.pid}.tmp`;
691
+ try {
692
+ await writeFile(tempPath, content, "utf-8");
693
+ await rename(tempPath, targetPath);
694
+ } catch (err) {
695
+ try {
696
+ await unlink(tempPath);
697
+ } catch {
698
+ }
699
+ throw new ProjectLoaderError(
700
+ "io_error",
701
+ `Failed to write ${basename(targetPath)}`,
702
+ err
703
+ );
704
+ }
705
+ }
706
+ async function guardPath(target, root) {
707
+ let resolvedRoot;
708
+ try {
709
+ resolvedRoot = await realpath(root);
710
+ } catch {
711
+ throw new ProjectLoaderError(
712
+ "invalid_input",
713
+ `Cannot resolve project root: ${root}`
714
+ );
715
+ }
716
+ const targetDir = dirname2(target);
717
+ let resolvedDir;
718
+ try {
719
+ resolvedDir = await realpath(targetDir);
720
+ } catch {
721
+ resolvedDir = targetDir;
722
+ }
723
+ if (!resolvedDir.startsWith(resolvedRoot)) {
724
+ throw new ProjectLoaderError(
725
+ "invalid_input",
726
+ `Path ${target} resolves outside project root`
727
+ );
728
+ }
729
+ if (existsSync3(target)) {
730
+ try {
731
+ const stats = await lstat(target);
732
+ if (stats.isSymbolicLink()) {
733
+ throw new ProjectLoaderError(
734
+ "invalid_input",
735
+ `Symlink target rejected: ${target}`
736
+ );
737
+ }
738
+ } catch (err) {
739
+ if (err instanceof ProjectLoaderError) throw err;
740
+ }
741
+ }
742
+ }
651
743
  async function withLock(wrapDir, fn) {
652
744
  let release;
653
745
  try {
@@ -1109,6 +1201,278 @@ function formatHandoverContent(filename, content, format) {
1109
1201
  }
1110
1202
  return content;
1111
1203
  }
1204
+ function formatSnapshotResult(result, format) {
1205
+ if (format === "json") {
1206
+ return JSON.stringify(successEnvelope(result), null, 2);
1207
+ }
1208
+ let line = `Snapshot saved: ${result.filename} (${result.retained} retained`;
1209
+ if (result.pruned > 0) line += `, ${result.pruned} pruned`;
1210
+ line += ")";
1211
+ return line;
1212
+ }
1213
+ function formatRecap(recap, state, format) {
1214
+ if (format === "json") {
1215
+ return JSON.stringify(successEnvelope(recap), null, 2);
1216
+ }
1217
+ const lines = [];
1218
+ if (!recap.snapshot) {
1219
+ lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Recap`);
1220
+ lines.push("");
1221
+ lines.push("No snapshot found. Run `claudestory snapshot` to enable session diffs.");
1222
+ lines.push("");
1223
+ lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete, ${state.blockedCount} blocked`);
1224
+ lines.push(`Issues: ${state.openIssueCount} open`);
1225
+ } else {
1226
+ lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Recap`);
1227
+ lines.push("");
1228
+ lines.push(`Since snapshot: ${recap.snapshot.createdAt}`);
1229
+ if (recap.partial) {
1230
+ lines.push("**Note:** Snapshot was taken from a project with integrity warnings. Diff may be incomplete.");
1231
+ }
1232
+ const changes = recap.changes;
1233
+ const hasChanges = hasAnyChanges(changes);
1234
+ if (!hasChanges) {
1235
+ lines.push("");
1236
+ lines.push("No changes since last snapshot.");
1237
+ } else {
1238
+ if (changes.phases.statusChanged.length > 0) {
1239
+ lines.push("");
1240
+ lines.push("## Phase Transitions");
1241
+ for (const p of changes.phases.statusChanged) {
1242
+ lines.push(`- **${escapeMarkdownInline(p.name)}** (${p.id}): ${p.from} \u2192 ${p.to}`);
1243
+ }
1244
+ }
1245
+ const ticketChanges = changes.tickets;
1246
+ if (ticketChanges.added.length > 0 || ticketChanges.removed.length > 0 || ticketChanges.statusChanged.length > 0) {
1247
+ lines.push("");
1248
+ lines.push("## Tickets");
1249
+ for (const t of ticketChanges.statusChanged) {
1250
+ lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 ${t.from} \u2192 ${t.to}`);
1251
+ }
1252
+ for (const t of ticketChanges.added) {
1253
+ lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 **new**`);
1254
+ }
1255
+ for (const t of ticketChanges.removed) {
1256
+ lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 **removed**`);
1257
+ }
1258
+ }
1259
+ const issueChanges = changes.issues;
1260
+ if (issueChanges.added.length > 0 || issueChanges.resolved.length > 0 || issueChanges.statusChanged.length > 0) {
1261
+ lines.push("");
1262
+ lines.push("## Issues");
1263
+ for (const i of issueChanges.resolved) {
1264
+ lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 **resolved**`);
1265
+ }
1266
+ for (const i of issueChanges.statusChanged) {
1267
+ lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 ${i.from} \u2192 ${i.to}`);
1268
+ }
1269
+ for (const i of issueChanges.added) {
1270
+ lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 **new**`);
1271
+ }
1272
+ }
1273
+ if (changes.blockers.added.length > 0 || changes.blockers.cleared.length > 0) {
1274
+ lines.push("");
1275
+ lines.push("## Blockers");
1276
+ for (const name of changes.blockers.cleared) {
1277
+ lines.push(`- ${escapeMarkdownInline(name)} \u2014 **cleared**`);
1278
+ }
1279
+ for (const name of changes.blockers.added) {
1280
+ lines.push(`- ${escapeMarkdownInline(name)} \u2014 **new**`);
1281
+ }
1282
+ }
1283
+ }
1284
+ }
1285
+ const actions = recap.suggestedActions;
1286
+ lines.push("");
1287
+ lines.push("## Suggested Actions");
1288
+ if (actions.nextTicket) {
1289
+ lines.push(`- **Next:** ${actions.nextTicket.id} \u2014 ${escapeMarkdownInline(actions.nextTicket.title)}${actions.nextTicket.phase ? ` (${actions.nextTicket.phase})` : ""}`);
1290
+ }
1291
+ if (actions.highSeverityIssues.length > 0) {
1292
+ for (const i of actions.highSeverityIssues) {
1293
+ lines.push(`- **${i.severity} issue:** ${i.id} \u2014 ${escapeMarkdownInline(i.title)}`);
1294
+ }
1295
+ }
1296
+ if (actions.recentlyClearedBlockers.length > 0) {
1297
+ lines.push(`- **Recently cleared:** ${actions.recentlyClearedBlockers.map(escapeMarkdownInline).join(", ")}`);
1298
+ }
1299
+ if (!actions.nextTicket && actions.highSeverityIssues.length === 0 && actions.recentlyClearedBlockers.length === 0) {
1300
+ lines.push("- No urgent actions.");
1301
+ }
1302
+ return lines.join("\n");
1303
+ }
1304
+ function formatExport(state, mode, phaseId, format) {
1305
+ if (mode === "phase" && phaseId) {
1306
+ return formatPhaseExport(state, phaseId, format);
1307
+ }
1308
+ return formatFullExport(state, format);
1309
+ }
1310
+ function formatPhaseExport(state, phaseId, format) {
1311
+ const phase = state.roadmap.phases.find((p) => p.id === phaseId);
1312
+ if (!phase) {
1313
+ return formatError("not_found", `Phase "${phaseId}" not found`, format);
1314
+ }
1315
+ const phaseStatus = state.phaseStatus(phaseId);
1316
+ const leaves = state.phaseTickets(phaseId);
1317
+ const umbrellaAncestors = /* @__PURE__ */ new Map();
1318
+ for (const leaf of leaves) {
1319
+ if (leaf.parentTicket) {
1320
+ const parent = state.ticketByID(leaf.parentTicket);
1321
+ if (parent && !umbrellaAncestors.has(parent.id)) {
1322
+ umbrellaAncestors.set(parent.id, parent);
1323
+ }
1324
+ }
1325
+ }
1326
+ const crossPhaseDeps = /* @__PURE__ */ new Map();
1327
+ for (const leaf of leaves) {
1328
+ for (const blockerId of leaf.blockedBy) {
1329
+ const blocker = state.ticketByID(blockerId);
1330
+ if (blocker && blocker.phase !== phaseId && !crossPhaseDeps.has(blocker.id)) {
1331
+ crossPhaseDeps.set(blocker.id, blocker);
1332
+ }
1333
+ }
1334
+ }
1335
+ const relatedIssues = state.issues.filter(
1336
+ (i) => i.status !== "resolved" && (i.phase === phaseId || i.relatedTickets.some((tid) => {
1337
+ const t = state.ticketByID(tid);
1338
+ return t && t.phase === phaseId;
1339
+ }))
1340
+ );
1341
+ const activeBlockers = state.roadmap.blockers.filter(
1342
+ (b) => !isBlockerCleared(b)
1343
+ );
1344
+ if (format === "json") {
1345
+ return JSON.stringify(
1346
+ successEnvelope({
1347
+ phase: { id: phase.id, name: phase.name, description: phase.description, status: phaseStatus },
1348
+ tickets: leaves.map((t) => ({ id: t.id, title: t.title, status: t.status, type: t.type, order: t.order })),
1349
+ umbrellaAncestors: [...umbrellaAncestors.values()].map((t) => ({ id: t.id, title: t.title })),
1350
+ crossPhaseDependencies: [...crossPhaseDeps.values()].map((t) => ({ id: t.id, title: t.title, status: t.status, phase: t.phase })),
1351
+ issues: relatedIssues.map((i) => ({ id: i.id, title: i.title, severity: i.severity, status: i.status })),
1352
+ blockers: activeBlockers.map((b) => ({ name: b.name, note: b.note ?? null }))
1353
+ }),
1354
+ null,
1355
+ 2
1356
+ );
1357
+ }
1358
+ const lines = [];
1359
+ lines.push(`# ${escapeMarkdownInline(phase.name)} (${phase.id})`);
1360
+ lines.push("");
1361
+ lines.push(`Status: ${phaseStatus}`);
1362
+ if (phase.description) {
1363
+ lines.push(`Description: ${escapeMarkdownInline(phase.description)}`);
1364
+ }
1365
+ if (leaves.length > 0) {
1366
+ lines.push("");
1367
+ lines.push("## Tickets");
1368
+ for (const t of leaves) {
1369
+ const indicator = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
1370
+ const parentNote = t.parentTicket && umbrellaAncestors.has(t.parentTicket) ? ` (under ${t.parentTicket})` : "";
1371
+ lines.push(`${indicator} ${t.id}: ${escapeMarkdownInline(t.title)}${parentNote}`);
1372
+ }
1373
+ }
1374
+ if (crossPhaseDeps.size > 0) {
1375
+ lines.push("");
1376
+ lines.push("## Cross-Phase Dependencies");
1377
+ for (const [, dep] of crossPhaseDeps) {
1378
+ lines.push(`- ${dep.id}: ${escapeMarkdownInline(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
1379
+ }
1380
+ }
1381
+ if (relatedIssues.length > 0) {
1382
+ lines.push("");
1383
+ lines.push("## Open Issues");
1384
+ for (const i of relatedIssues) {
1385
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}`);
1386
+ }
1387
+ }
1388
+ if (activeBlockers.length > 0) {
1389
+ lines.push("");
1390
+ lines.push("## Active Blockers");
1391
+ for (const b of activeBlockers) {
1392
+ lines.push(`- ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
1393
+ }
1394
+ }
1395
+ return lines.join("\n");
1396
+ }
1397
+ function formatFullExport(state, format) {
1398
+ const phases = phasesWithStatus(state);
1399
+ if (format === "json") {
1400
+ return JSON.stringify(
1401
+ successEnvelope({
1402
+ project: state.config.project,
1403
+ phases: phases.map((p) => ({
1404
+ id: p.phase.id,
1405
+ name: p.phase.name,
1406
+ description: p.phase.description,
1407
+ status: p.status,
1408
+ tickets: state.phaseTickets(p.phase.id).map((t) => ({
1409
+ id: t.id,
1410
+ title: t.title,
1411
+ status: t.status,
1412
+ type: t.type
1413
+ }))
1414
+ })),
1415
+ issues: state.issues.map((i) => ({
1416
+ id: i.id,
1417
+ title: i.title,
1418
+ severity: i.severity,
1419
+ status: i.status
1420
+ })),
1421
+ blockers: state.roadmap.blockers.map((b) => ({
1422
+ name: b.name,
1423
+ cleared: isBlockerCleared(b),
1424
+ note: b.note ?? null
1425
+ }))
1426
+ }),
1427
+ null,
1428
+ 2
1429
+ );
1430
+ }
1431
+ const lines = [];
1432
+ lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Full Export`);
1433
+ lines.push("");
1434
+ lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete`);
1435
+ lines.push(`Issues: ${state.openIssueCount} open`);
1436
+ lines.push("");
1437
+ lines.push("## Phases");
1438
+ for (const p of phases) {
1439
+ const indicator = p.status === "complete" ? "[x]" : p.status === "inprogress" ? "[~]" : "[ ]";
1440
+ lines.push("");
1441
+ lines.push(`### ${indicator} ${escapeMarkdownInline(p.phase.name)} (${p.phase.id})`);
1442
+ if (p.phase.description) {
1443
+ lines.push(escapeMarkdownInline(p.phase.description));
1444
+ }
1445
+ const tickets = state.phaseTickets(p.phase.id);
1446
+ if (tickets.length > 0) {
1447
+ lines.push("");
1448
+ for (const t of tickets) {
1449
+ const ti = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
1450
+ lines.push(`${ti} ${t.id}: ${escapeMarkdownInline(t.title)}`);
1451
+ }
1452
+ }
1453
+ }
1454
+ if (state.issues.length > 0) {
1455
+ lines.push("");
1456
+ lines.push("## Issues");
1457
+ for (const i of state.issues) {
1458
+ const resolved = i.status === "resolved" ? " \u2713" : "";
1459
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}${resolved}`);
1460
+ }
1461
+ }
1462
+ const blockers = state.roadmap.blockers;
1463
+ if (blockers.length > 0) {
1464
+ lines.push("");
1465
+ lines.push("## Blockers");
1466
+ for (const b of blockers) {
1467
+ const cleared = isBlockerCleared(b) ? "[x]" : "[ ]";
1468
+ lines.push(`${cleared} ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
1469
+ }
1470
+ }
1471
+ return lines.join("\n");
1472
+ }
1473
+ function hasAnyChanges(diff) {
1474
+ 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;
1475
+ }
1112
1476
  function truncate(text, maxLen) {
1113
1477
  if (text.length <= maxLen) return text;
1114
1478
  return text.slice(0, maxLen - 3) + "...";
@@ -1518,8 +1882,266 @@ function handleIssueGet(id, ctx) {
1518
1882
  return { output: formatIssue(issue, ctx.format) };
1519
1883
  }
1520
1884
 
1521
- // src/cli/commands/phase.ts
1885
+ // src/core/snapshot.ts
1886
+ import { readdir as readdir3, readFile as readFile3, mkdir, unlink as unlink2 } from "fs/promises";
1887
+ import { existsSync as existsSync4 } from "fs";
1522
1888
  import { join as join4, resolve as resolve4 } from "path";
1889
+ import { z as z6 } from "zod";
1890
+ var LoadWarningSchema = z6.object({
1891
+ type: z6.string(),
1892
+ file: z6.string(),
1893
+ message: z6.string()
1894
+ });
1895
+ var SnapshotV1Schema = z6.object({
1896
+ version: z6.literal(1),
1897
+ createdAt: z6.string().datetime({ offset: true }),
1898
+ project: z6.string(),
1899
+ config: ConfigSchema,
1900
+ roadmap: RoadmapSchema,
1901
+ tickets: z6.array(TicketSchema),
1902
+ issues: z6.array(IssueSchema),
1903
+ warnings: z6.array(LoadWarningSchema).optional()
1904
+ });
1905
+ var MAX_SNAPSHOTS = 20;
1906
+ async function saveSnapshot(root, loadResult) {
1907
+ const absRoot = resolve4(root);
1908
+ const snapshotsDir = join4(absRoot, ".story", "snapshots");
1909
+ await mkdir(snapshotsDir, { recursive: true });
1910
+ const { state, warnings } = loadResult;
1911
+ const now = /* @__PURE__ */ new Date();
1912
+ const filename = formatSnapshotFilename(now);
1913
+ const snapshot = {
1914
+ version: 1,
1915
+ createdAt: now.toISOString(),
1916
+ project: state.config.project,
1917
+ config: state.config,
1918
+ roadmap: state.roadmap,
1919
+ tickets: [...state.tickets],
1920
+ issues: [...state.issues],
1921
+ ...warnings.length > 0 ? {
1922
+ warnings: warnings.map((w) => ({
1923
+ type: w.type,
1924
+ file: w.file,
1925
+ message: w.message
1926
+ }))
1927
+ } : {}
1928
+ };
1929
+ const json = JSON.stringify(snapshot, null, 2) + "\n";
1930
+ const targetPath = join4(snapshotsDir, filename);
1931
+ const wrapDir = join4(absRoot, ".story");
1932
+ await guardPath(targetPath, wrapDir);
1933
+ await atomicWrite(targetPath, json);
1934
+ const pruned = await pruneSnapshots(snapshotsDir);
1935
+ const entries = await listSnapshotFiles(snapshotsDir);
1936
+ return { filename, retained: entries.length, pruned };
1937
+ }
1938
+ async function loadLatestSnapshot(root) {
1939
+ const snapshotsDir = join4(resolve4(root), ".story", "snapshots");
1940
+ if (!existsSync4(snapshotsDir)) return null;
1941
+ const files = await listSnapshotFiles(snapshotsDir);
1942
+ if (files.length === 0) return null;
1943
+ for (const filename of files) {
1944
+ try {
1945
+ const content = await readFile3(join4(snapshotsDir, filename), "utf-8");
1946
+ const parsed = JSON.parse(content);
1947
+ const snapshot = SnapshotV1Schema.parse(parsed);
1948
+ return { snapshot, filename };
1949
+ } catch {
1950
+ continue;
1951
+ }
1952
+ }
1953
+ return null;
1954
+ }
1955
+ function diffStates(snapshotState, currentState) {
1956
+ const snapTickets = new Map(snapshotState.tickets.map((t) => [t.id, t]));
1957
+ const curTickets = new Map(currentState.tickets.map((t) => [t.id, t]));
1958
+ const ticketsAdded = [];
1959
+ const ticketsRemoved = [];
1960
+ const ticketsStatusChanged = [];
1961
+ for (const [id, cur] of curTickets) {
1962
+ const snap = snapTickets.get(id);
1963
+ if (!snap) {
1964
+ ticketsAdded.push({ id, title: cur.title });
1965
+ } else if (snap.status !== cur.status) {
1966
+ ticketsStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
1967
+ }
1968
+ }
1969
+ for (const [id, snap] of snapTickets) {
1970
+ if (!curTickets.has(id)) {
1971
+ ticketsRemoved.push({ id, title: snap.title });
1972
+ }
1973
+ }
1974
+ const snapIssues = new Map(snapshotState.issues.map((i) => [i.id, i]));
1975
+ const curIssues = new Map(currentState.issues.map((i) => [i.id, i]));
1976
+ const issuesAdded = [];
1977
+ const issuesResolved = [];
1978
+ const issuesStatusChanged = [];
1979
+ for (const [id, cur] of curIssues) {
1980
+ const snap = snapIssues.get(id);
1981
+ if (!snap) {
1982
+ issuesAdded.push({ id, title: cur.title });
1983
+ } else if (snap.status !== cur.status) {
1984
+ if (cur.status === "resolved") {
1985
+ issuesResolved.push({ id, title: cur.title });
1986
+ } else {
1987
+ issuesStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
1988
+ }
1989
+ }
1990
+ }
1991
+ const snapBlockers = new Map(
1992
+ snapshotState.roadmap.blockers.map((b) => [b.name, b])
1993
+ );
1994
+ const curBlockers = new Map(
1995
+ currentState.roadmap.blockers.map((b) => [b.name, b])
1996
+ );
1997
+ const blockersAdded = [];
1998
+ const blockersCleared = [];
1999
+ for (const [name, cur] of curBlockers) {
2000
+ const snap = snapBlockers.get(name);
2001
+ if (!snap) {
2002
+ blockersAdded.push(name);
2003
+ } else if (!isBlockerCleared(snap) && isBlockerCleared(cur)) {
2004
+ blockersCleared.push(name);
2005
+ }
2006
+ }
2007
+ const snapPhases = snapshotState.roadmap.phases;
2008
+ const curPhases = currentState.roadmap.phases;
2009
+ const snapPhaseMap = new Map(snapPhases.map((p) => [p.id, p]));
2010
+ const curPhaseMap = new Map(curPhases.map((p) => [p.id, p]));
2011
+ const phasesAdded = [];
2012
+ const phasesRemoved = [];
2013
+ const phasesStatusChanged = [];
2014
+ for (const [id, curPhase] of curPhaseMap) {
2015
+ const snapPhase = snapPhaseMap.get(id);
2016
+ if (!snapPhase) {
2017
+ phasesAdded.push({ id, name: curPhase.name });
2018
+ } else {
2019
+ const snapStatus = snapshotState.phaseStatus(id);
2020
+ const curStatus = currentState.phaseStatus(id);
2021
+ if (snapStatus !== curStatus) {
2022
+ phasesStatusChanged.push({
2023
+ id,
2024
+ name: curPhase.name,
2025
+ from: snapStatus,
2026
+ to: curStatus
2027
+ });
2028
+ }
2029
+ }
2030
+ }
2031
+ for (const [id, snapPhase] of snapPhaseMap) {
2032
+ if (!curPhaseMap.has(id)) {
2033
+ phasesRemoved.push({ id, name: snapPhase.name });
2034
+ }
2035
+ }
2036
+ return {
2037
+ tickets: { added: ticketsAdded, removed: ticketsRemoved, statusChanged: ticketsStatusChanged },
2038
+ issues: { added: issuesAdded, resolved: issuesResolved, statusChanged: issuesStatusChanged },
2039
+ blockers: { added: blockersAdded, cleared: blockersCleared },
2040
+ phases: { added: phasesAdded, removed: phasesRemoved, statusChanged: phasesStatusChanged }
2041
+ };
2042
+ }
2043
+ function buildRecap(currentState, snapshotInfo) {
2044
+ const next = nextTicket(currentState);
2045
+ const nextTicketAction = next.kind === "found" ? { id: next.ticket.id, title: next.ticket.title, phase: next.ticket.phase } : null;
2046
+ const highSeverityIssues = currentState.issues.filter(
2047
+ (i) => i.status !== "resolved" && (i.severity === "critical" || i.severity === "high")
2048
+ ).map((i) => ({ id: i.id, title: i.title, severity: i.severity }));
2049
+ if (!snapshotInfo) {
2050
+ return {
2051
+ snapshot: null,
2052
+ changes: null,
2053
+ suggestedActions: {
2054
+ nextTicket: nextTicketAction,
2055
+ highSeverityIssues,
2056
+ recentlyClearedBlockers: []
2057
+ },
2058
+ partial: false
2059
+ };
2060
+ }
2061
+ const { snapshot, filename } = snapshotInfo;
2062
+ const snapshotState = new ProjectState({
2063
+ tickets: snapshot.tickets,
2064
+ issues: snapshot.issues,
2065
+ roadmap: snapshot.roadmap,
2066
+ config: snapshot.config,
2067
+ handoverFilenames: []
2068
+ });
2069
+ const changes = diffStates(snapshotState, currentState);
2070
+ const recentlyClearedBlockers = changes.blockers.cleared;
2071
+ return {
2072
+ snapshot: { filename, createdAt: snapshot.createdAt },
2073
+ changes,
2074
+ suggestedActions: {
2075
+ nextTicket: nextTicketAction,
2076
+ highSeverityIssues,
2077
+ recentlyClearedBlockers
2078
+ },
2079
+ partial: (snapshot.warnings ?? []).length > 0
2080
+ };
2081
+ }
2082
+ function formatSnapshotFilename(date) {
2083
+ const y = date.getUTCFullYear();
2084
+ const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
2085
+ const d = String(date.getUTCDate()).padStart(2, "0");
2086
+ const h = String(date.getUTCHours()).padStart(2, "0");
2087
+ const mi = String(date.getUTCMinutes()).padStart(2, "0");
2088
+ const s = String(date.getUTCSeconds()).padStart(2, "0");
2089
+ const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
2090
+ return `${y}-${mo}-${d}T${h}-${mi}-${s}-${ms}.json`;
2091
+ }
2092
+ async function listSnapshotFiles(dir) {
2093
+ try {
2094
+ const entries = await readdir3(dir);
2095
+ return entries.filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort().reverse();
2096
+ } catch {
2097
+ return [];
2098
+ }
2099
+ }
2100
+ async function pruneSnapshots(dir) {
2101
+ const files = await listSnapshotFiles(dir);
2102
+ if (files.length <= MAX_SNAPSHOTS) return 0;
2103
+ const toRemove = files.slice(MAX_SNAPSHOTS);
2104
+ for (const f of toRemove) {
2105
+ try {
2106
+ await unlink2(join4(dir, f));
2107
+ } catch {
2108
+ }
2109
+ }
2110
+ return toRemove.length;
2111
+ }
2112
+
2113
+ // src/cli/commands/recap.ts
2114
+ async function handleRecap(ctx) {
2115
+ const snapshotInfo = await loadLatestSnapshot(ctx.root);
2116
+ const recap = buildRecap(ctx.state, snapshotInfo);
2117
+ return { output: formatRecap(recap, ctx.state, ctx.format) };
2118
+ }
2119
+
2120
+ // src/cli/commands/snapshot.ts
2121
+ async function handleSnapshot(root, format) {
2122
+ let result;
2123
+ await withProjectLock(root, { strict: false }, async (loadResult) => {
2124
+ result = await saveSnapshot(root, loadResult);
2125
+ });
2126
+ return { output: formatSnapshotResult(result, format) };
2127
+ }
2128
+
2129
+ // src/cli/commands/export.ts
2130
+ function handleExport(ctx, mode, phaseId) {
2131
+ if (mode === "phase") {
2132
+ if (!phaseId) {
2133
+ throw new CliValidationError("invalid_input", "Missing --phase value");
2134
+ }
2135
+ const phase = ctx.state.roadmap.phases.find((p) => p.id === phaseId);
2136
+ if (!phase) {
2137
+ throw new CliValidationError("not_found", `Phase "${phaseId}" not found in roadmap`);
2138
+ }
2139
+ }
2140
+ return { output: formatExport(ctx.state, mode, phaseId, ctx.format) };
2141
+ }
2142
+
2143
+ // src/cli/commands/phase.ts
2144
+ import { join as join5, resolve as resolve5 } from "path";
1523
2145
  function handlePhaseList(ctx) {
1524
2146
  return { output: formatPhaseList(ctx.state, ctx.format) };
1525
2147
  }
@@ -1578,7 +2200,7 @@ function formatMcpError(code, message) {
1578
2200
  async function runMcpReadTool(pinnedRoot, handler) {
1579
2201
  try {
1580
2202
  const { state, warnings } = await loadProject(pinnedRoot);
1581
- const handoversDir = join5(pinnedRoot, ".story", "handovers");
2203
+ const handoversDir = join6(pinnedRoot, ".story", "handovers");
1582
2204
  const ctx = { state, warnings, root: pinnedRoot, handoversDir, format: "md" };
1583
2205
  const result = await handler(ctx);
1584
2206
  if (result.errorCode && INFRASTRUCTURE_ERROR_CODES.includes(result.errorCode)) {
@@ -1608,6 +2230,27 @@ ${text}`;
1608
2230
  return { content: [{ type: "text", text: formatMcpError("io_error", message) }], isError: true };
1609
2231
  }
1610
2232
  }
2233
+ async function runMcpWriteTool(pinnedRoot, handler) {
2234
+ try {
2235
+ const result = await handler(pinnedRoot, "md");
2236
+ if (result.errorCode && INFRASTRUCTURE_ERROR_CODES.includes(result.errorCode)) {
2237
+ return {
2238
+ content: [{ type: "text", text: formatMcpError(result.errorCode, result.output) }],
2239
+ isError: true
2240
+ };
2241
+ }
2242
+ return { content: [{ type: "text", text: result.output }] };
2243
+ } catch (err) {
2244
+ if (err instanceof ProjectLoaderError) {
2245
+ return { content: [{ type: "text", text: formatMcpError(err.code, err.message) }], isError: true };
2246
+ }
2247
+ if (err instanceof CliValidationError) {
2248
+ return { content: [{ type: "text", text: formatMcpError(err.code, err.message) }], isError: true };
2249
+ }
2250
+ const message = err instanceof Error ? err.message : String(err);
2251
+ return { content: [{ type: "text", text: formatMcpError("io_error", message) }], isError: true };
2252
+ }
2253
+ }
1611
2254
  function registerAllTools(server, pinnedRoot) {
1612
2255
  server.registerTool("claudestory_status", {
1613
2256
  description: "Project summary: phase statuses, ticket/issue counts, blockers, current phase"
@@ -1639,7 +2282,7 @@ function registerAllTools(server, pinnedRoot) {
1639
2282
  server.registerTool("claudestory_phase_tickets", {
1640
2283
  description: "Leaf tickets for a specific phase, sorted by order",
1641
2284
  inputSchema: {
1642
- phaseId: z6.string().describe("Phase ID (e.g. p5b, dogfood)")
2285
+ phaseId: z7.string().describe("Phase ID (e.g. p5b, dogfood)")
1643
2286
  }
1644
2287
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => {
1645
2288
  const phaseExists = ctx.state.roadmap.phases.some((p) => p.id === args.phaseId);
@@ -1655,9 +2298,9 @@ function registerAllTools(server, pinnedRoot) {
1655
2298
  server.registerTool("claudestory_ticket_list", {
1656
2299
  description: "List leaf tickets with optional filters",
1657
2300
  inputSchema: {
1658
- status: z6.enum(TICKET_STATUSES).optional().describe("Filter by status: open, inprogress, complete"),
1659
- phase: z6.string().optional().describe("Filter by phase ID"),
1660
- type: z6.enum(TICKET_TYPES).optional().describe("Filter by type: task, feature, chore")
2301
+ status: z7.enum(TICKET_STATUSES).optional().describe("Filter by status: open, inprogress, complete"),
2302
+ phase: z7.string().optional().describe("Filter by phase ID"),
2303
+ type: z7.enum(TICKET_TYPES).optional().describe("Filter by type: task, feature, chore")
1661
2304
  }
1662
2305
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => {
1663
2306
  if (args.phase) {
@@ -1678,14 +2321,14 @@ function registerAllTools(server, pinnedRoot) {
1678
2321
  server.registerTool("claudestory_ticket_get", {
1679
2322
  description: "Get a ticket by ID (includes umbrella tickets)",
1680
2323
  inputSchema: {
1681
- id: z6.string().regex(TICKET_ID_REGEX).describe("Ticket ID (e.g. T-001, T-079b)")
2324
+ id: z7.string().regex(TICKET_ID_REGEX).describe("Ticket ID (e.g. T-001, T-079b)")
1682
2325
  }
1683
2326
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleTicketGet(args.id, ctx)));
1684
2327
  server.registerTool("claudestory_issue_list", {
1685
2328
  description: "List issues with optional filters",
1686
2329
  inputSchema: {
1687
- status: z6.enum(ISSUE_STATUSES).optional().describe("Filter by status: open, inprogress, resolved"),
1688
- severity: z6.enum(ISSUE_SEVERITIES).optional().describe("Filter by severity: critical, high, medium, low")
2330
+ status: z7.enum(ISSUE_STATUSES).optional().describe("Filter by status: open, inprogress, resolved"),
2331
+ severity: z7.enum(ISSUE_SEVERITIES).optional().describe("Filter by severity: critical, high, medium, low")
1689
2332
  }
1690
2333
  }, (args) => runMcpReadTool(
1691
2334
  pinnedRoot,
@@ -1694,21 +2337,50 @@ function registerAllTools(server, pinnedRoot) {
1694
2337
  server.registerTool("claudestory_issue_get", {
1695
2338
  description: "Get an issue by ID",
1696
2339
  inputSchema: {
1697
- id: z6.string().regex(ISSUE_ID_REGEX).describe("Issue ID (e.g. ISS-001)")
2340
+ id: z7.string().regex(ISSUE_ID_REGEX).describe("Issue ID (e.g. ISS-001)")
1698
2341
  }
1699
2342
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleIssueGet(args.id, ctx)));
1700
2343
  server.registerTool("claudestory_handover_get", {
1701
2344
  description: "Content of a specific handover document by filename",
1702
2345
  inputSchema: {
1703
- filename: z6.string().describe("Handover filename (e.g. 2026-03-20-session.md)")
2346
+ filename: z7.string().describe("Handover filename (e.g. 2026-03-20-session.md)")
1704
2347
  }
1705
2348
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleHandoverGet(args.filename, ctx)));
2349
+ server.registerTool("claudestory_recap", {
2350
+ description: "Session diff \u2014 changes since last snapshot + suggested next actions. Shows what changed and what to work on."
2351
+ }, () => runMcpReadTool(pinnedRoot, handleRecap));
2352
+ server.registerTool("claudestory_snapshot", {
2353
+ description: "Save current project state for session diffs. Creates a snapshot in .story/snapshots/."
2354
+ }, () => runMcpWriteTool(pinnedRoot, handleSnapshot));
2355
+ server.registerTool("claudestory_export", {
2356
+ description: "Self-contained project document for sharing",
2357
+ inputSchema: {
2358
+ phase: z7.string().optional().describe("Export a single phase by ID"),
2359
+ all: z7.boolean().optional().describe("Export entire project")
2360
+ }
2361
+ }, (args) => {
2362
+ if (!args.phase && !args.all) {
2363
+ return Promise.resolve({
2364
+ content: [{ type: "text", text: formatMcpError("invalid_input", "Specify either phase or all") }],
2365
+ isError: true
2366
+ });
2367
+ }
2368
+ if (args.phase && args.all) {
2369
+ return Promise.resolve({
2370
+ content: [{ type: "text", text: formatMcpError("invalid_input", "Arguments phase and all are mutually exclusive") }],
2371
+ isError: true
2372
+ });
2373
+ }
2374
+ const mode = args.all ? "all" : "phase";
2375
+ const phaseId = args.phase ?? null;
2376
+ return runMcpReadTool(pinnedRoot, (ctx) => handleExport(ctx, mode, phaseId));
2377
+ });
1706
2378
  }
1707
2379
 
1708
2380
  // src/mcp/index.ts
1709
2381
  var ENV_VAR2 = "CLAUDESTORY_PROJECT_ROOT";
1710
2382
  var CONFIG_PATH2 = ".story/config.json";
1711
- var version = "0.1.3";
2383
+ var version = "0.1.4";
1712
2384
  function pinProjectRoot() {
1713
2385
  const envRoot = process.env[ENV_VAR2];
1714
2386
  if (envRoot) {
@@ -1717,7 +2389,7 @@ function pinProjectRoot() {
1717
2389
  `);
1718
2390
  process.exit(1);
1719
2391
  }
1720
- const resolved = resolve5(envRoot);
2392
+ const resolved = resolve6(envRoot);
1721
2393
  let canonical;
1722
2394
  try {
1723
2395
  canonical = realpathSync(resolved);
@@ -1726,7 +2398,7 @@ function pinProjectRoot() {
1726
2398
  `);
1727
2399
  process.exit(1);
1728
2400
  }
1729
- if (!existsSync4(join6(canonical, CONFIG_PATH2))) {
2401
+ if (!existsSync5(join7(canonical, CONFIG_PATH2))) {
1730
2402
  process.stderr.write(`Error: No .story/config.json at ${canonical}
1731
2403
  `);
1732
2404
  process.exit(1);