@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/cli.js CHANGED
@@ -484,6 +484,278 @@ function formatHandoverContent(filename, content, format) {
484
484
  }
485
485
  return content;
486
486
  }
487
+ function formatSnapshotResult(result, format) {
488
+ if (format === "json") {
489
+ return JSON.stringify(successEnvelope(result), null, 2);
490
+ }
491
+ let line = `Snapshot saved: ${result.filename} (${result.retained} retained`;
492
+ if (result.pruned > 0) line += `, ${result.pruned} pruned`;
493
+ line += ")";
494
+ return line;
495
+ }
496
+ function formatRecap(recap, state, format) {
497
+ if (format === "json") {
498
+ return JSON.stringify(successEnvelope(recap), null, 2);
499
+ }
500
+ const lines = [];
501
+ if (!recap.snapshot) {
502
+ lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Recap`);
503
+ lines.push("");
504
+ lines.push("No snapshot found. Run `claudestory snapshot` to enable session diffs.");
505
+ lines.push("");
506
+ lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete, ${state.blockedCount} blocked`);
507
+ lines.push(`Issues: ${state.openIssueCount} open`);
508
+ } else {
509
+ lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Recap`);
510
+ lines.push("");
511
+ lines.push(`Since snapshot: ${recap.snapshot.createdAt}`);
512
+ if (recap.partial) {
513
+ lines.push("**Note:** Snapshot was taken from a project with integrity warnings. Diff may be incomplete.");
514
+ }
515
+ const changes = recap.changes;
516
+ const hasChanges = hasAnyChanges(changes);
517
+ if (!hasChanges) {
518
+ lines.push("");
519
+ lines.push("No changes since last snapshot.");
520
+ } else {
521
+ if (changes.phases.statusChanged.length > 0) {
522
+ lines.push("");
523
+ lines.push("## Phase Transitions");
524
+ for (const p of changes.phases.statusChanged) {
525
+ lines.push(`- **${escapeMarkdownInline(p.name)}** (${p.id}): ${p.from} \u2192 ${p.to}`);
526
+ }
527
+ }
528
+ const ticketChanges = changes.tickets;
529
+ if (ticketChanges.added.length > 0 || ticketChanges.removed.length > 0 || ticketChanges.statusChanged.length > 0) {
530
+ lines.push("");
531
+ lines.push("## Tickets");
532
+ for (const t of ticketChanges.statusChanged) {
533
+ lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 ${t.from} \u2192 ${t.to}`);
534
+ }
535
+ for (const t of ticketChanges.added) {
536
+ lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 **new**`);
537
+ }
538
+ for (const t of ticketChanges.removed) {
539
+ lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 **removed**`);
540
+ }
541
+ }
542
+ const issueChanges = changes.issues;
543
+ if (issueChanges.added.length > 0 || issueChanges.resolved.length > 0 || issueChanges.statusChanged.length > 0) {
544
+ lines.push("");
545
+ lines.push("## Issues");
546
+ for (const i of issueChanges.resolved) {
547
+ lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 **resolved**`);
548
+ }
549
+ for (const i of issueChanges.statusChanged) {
550
+ lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 ${i.from} \u2192 ${i.to}`);
551
+ }
552
+ for (const i of issueChanges.added) {
553
+ lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 **new**`);
554
+ }
555
+ }
556
+ if (changes.blockers.added.length > 0 || changes.blockers.cleared.length > 0) {
557
+ lines.push("");
558
+ lines.push("## Blockers");
559
+ for (const name of changes.blockers.cleared) {
560
+ lines.push(`- ${escapeMarkdownInline(name)} \u2014 **cleared**`);
561
+ }
562
+ for (const name of changes.blockers.added) {
563
+ lines.push(`- ${escapeMarkdownInline(name)} \u2014 **new**`);
564
+ }
565
+ }
566
+ }
567
+ }
568
+ const actions = recap.suggestedActions;
569
+ lines.push("");
570
+ lines.push("## Suggested Actions");
571
+ if (actions.nextTicket) {
572
+ lines.push(`- **Next:** ${actions.nextTicket.id} \u2014 ${escapeMarkdownInline(actions.nextTicket.title)}${actions.nextTicket.phase ? ` (${actions.nextTicket.phase})` : ""}`);
573
+ }
574
+ if (actions.highSeverityIssues.length > 0) {
575
+ for (const i of actions.highSeverityIssues) {
576
+ lines.push(`- **${i.severity} issue:** ${i.id} \u2014 ${escapeMarkdownInline(i.title)}`);
577
+ }
578
+ }
579
+ if (actions.recentlyClearedBlockers.length > 0) {
580
+ lines.push(`- **Recently cleared:** ${actions.recentlyClearedBlockers.map(escapeMarkdownInline).join(", ")}`);
581
+ }
582
+ if (!actions.nextTicket && actions.highSeverityIssues.length === 0 && actions.recentlyClearedBlockers.length === 0) {
583
+ lines.push("- No urgent actions.");
584
+ }
585
+ return lines.join("\n");
586
+ }
587
+ function formatExport(state, mode, phaseId, format) {
588
+ if (mode === "phase" && phaseId) {
589
+ return formatPhaseExport(state, phaseId, format);
590
+ }
591
+ return formatFullExport(state, format);
592
+ }
593
+ function formatPhaseExport(state, phaseId, format) {
594
+ const phase = state.roadmap.phases.find((p) => p.id === phaseId);
595
+ if (!phase) {
596
+ return formatError("not_found", `Phase "${phaseId}" not found`, format);
597
+ }
598
+ const phaseStatus = state.phaseStatus(phaseId);
599
+ const leaves = state.phaseTickets(phaseId);
600
+ const umbrellaAncestors = /* @__PURE__ */ new Map();
601
+ for (const leaf of leaves) {
602
+ if (leaf.parentTicket) {
603
+ const parent = state.ticketByID(leaf.parentTicket);
604
+ if (parent && !umbrellaAncestors.has(parent.id)) {
605
+ umbrellaAncestors.set(parent.id, parent);
606
+ }
607
+ }
608
+ }
609
+ const crossPhaseDeps = /* @__PURE__ */ new Map();
610
+ for (const leaf of leaves) {
611
+ for (const blockerId of leaf.blockedBy) {
612
+ const blocker = state.ticketByID(blockerId);
613
+ if (blocker && blocker.phase !== phaseId && !crossPhaseDeps.has(blocker.id)) {
614
+ crossPhaseDeps.set(blocker.id, blocker);
615
+ }
616
+ }
617
+ }
618
+ const relatedIssues = state.issues.filter(
619
+ (i) => i.status !== "resolved" && (i.phase === phaseId || i.relatedTickets.some((tid) => {
620
+ const t = state.ticketByID(tid);
621
+ return t && t.phase === phaseId;
622
+ }))
623
+ );
624
+ const activeBlockers = state.roadmap.blockers.filter(
625
+ (b) => !isBlockerCleared(b)
626
+ );
627
+ if (format === "json") {
628
+ return JSON.stringify(
629
+ successEnvelope({
630
+ phase: { id: phase.id, name: phase.name, description: phase.description, status: phaseStatus },
631
+ tickets: leaves.map((t) => ({ id: t.id, title: t.title, status: t.status, type: t.type, order: t.order })),
632
+ umbrellaAncestors: [...umbrellaAncestors.values()].map((t) => ({ id: t.id, title: t.title })),
633
+ crossPhaseDependencies: [...crossPhaseDeps.values()].map((t) => ({ id: t.id, title: t.title, status: t.status, phase: t.phase })),
634
+ issues: relatedIssues.map((i) => ({ id: i.id, title: i.title, severity: i.severity, status: i.status })),
635
+ blockers: activeBlockers.map((b) => ({ name: b.name, note: b.note ?? null }))
636
+ }),
637
+ null,
638
+ 2
639
+ );
640
+ }
641
+ const lines = [];
642
+ lines.push(`# ${escapeMarkdownInline(phase.name)} (${phase.id})`);
643
+ lines.push("");
644
+ lines.push(`Status: ${phaseStatus}`);
645
+ if (phase.description) {
646
+ lines.push(`Description: ${escapeMarkdownInline(phase.description)}`);
647
+ }
648
+ if (leaves.length > 0) {
649
+ lines.push("");
650
+ lines.push("## Tickets");
651
+ for (const t of leaves) {
652
+ const indicator = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
653
+ const parentNote = t.parentTicket && umbrellaAncestors.has(t.parentTicket) ? ` (under ${t.parentTicket})` : "";
654
+ lines.push(`${indicator} ${t.id}: ${escapeMarkdownInline(t.title)}${parentNote}`);
655
+ }
656
+ }
657
+ if (crossPhaseDeps.size > 0) {
658
+ lines.push("");
659
+ lines.push("## Cross-Phase Dependencies");
660
+ for (const [, dep] of crossPhaseDeps) {
661
+ lines.push(`- ${dep.id}: ${escapeMarkdownInline(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
662
+ }
663
+ }
664
+ if (relatedIssues.length > 0) {
665
+ lines.push("");
666
+ lines.push("## Open Issues");
667
+ for (const i of relatedIssues) {
668
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}`);
669
+ }
670
+ }
671
+ if (activeBlockers.length > 0) {
672
+ lines.push("");
673
+ lines.push("## Active Blockers");
674
+ for (const b of activeBlockers) {
675
+ lines.push(`- ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
676
+ }
677
+ }
678
+ return lines.join("\n");
679
+ }
680
+ function formatFullExport(state, format) {
681
+ const phases = phasesWithStatus(state);
682
+ if (format === "json") {
683
+ return JSON.stringify(
684
+ successEnvelope({
685
+ project: state.config.project,
686
+ phases: phases.map((p) => ({
687
+ id: p.phase.id,
688
+ name: p.phase.name,
689
+ description: p.phase.description,
690
+ status: p.status,
691
+ tickets: state.phaseTickets(p.phase.id).map((t) => ({
692
+ id: t.id,
693
+ title: t.title,
694
+ status: t.status,
695
+ type: t.type
696
+ }))
697
+ })),
698
+ issues: state.issues.map((i) => ({
699
+ id: i.id,
700
+ title: i.title,
701
+ severity: i.severity,
702
+ status: i.status
703
+ })),
704
+ blockers: state.roadmap.blockers.map((b) => ({
705
+ name: b.name,
706
+ cleared: isBlockerCleared(b),
707
+ note: b.note ?? null
708
+ }))
709
+ }),
710
+ null,
711
+ 2
712
+ );
713
+ }
714
+ const lines = [];
715
+ lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Full Export`);
716
+ lines.push("");
717
+ lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete`);
718
+ lines.push(`Issues: ${state.openIssueCount} open`);
719
+ lines.push("");
720
+ lines.push("## Phases");
721
+ for (const p of phases) {
722
+ const indicator = p.status === "complete" ? "[x]" : p.status === "inprogress" ? "[~]" : "[ ]";
723
+ lines.push("");
724
+ lines.push(`### ${indicator} ${escapeMarkdownInline(p.phase.name)} (${p.phase.id})`);
725
+ if (p.phase.description) {
726
+ lines.push(escapeMarkdownInline(p.phase.description));
727
+ }
728
+ const tickets = state.phaseTickets(p.phase.id);
729
+ if (tickets.length > 0) {
730
+ lines.push("");
731
+ for (const t of tickets) {
732
+ const ti = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
733
+ lines.push(`${ti} ${t.id}: ${escapeMarkdownInline(t.title)}`);
734
+ }
735
+ }
736
+ }
737
+ if (state.issues.length > 0) {
738
+ lines.push("");
739
+ lines.push("## Issues");
740
+ for (const i of state.issues) {
741
+ const resolved = i.status === "resolved" ? " \u2713" : "";
742
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}${resolved}`);
743
+ }
744
+ }
745
+ const blockers = state.roadmap.blockers;
746
+ if (blockers.length > 0) {
747
+ lines.push("");
748
+ lines.push("## Blockers");
749
+ for (const b of blockers) {
750
+ const cleared = isBlockerCleared(b) ? "[x]" : "[ ]";
751
+ lines.push(`${cleared} ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
752
+ }
753
+ }
754
+ return lines.join("\n");
755
+ }
756
+ function hasAnyChanges(diff) {
757
+ 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;
758
+ }
487
759
  function truncate(text, maxLen) {
488
760
  if (text.length <= maxLen) return text;
489
761
  return text.slice(0, maxLen - 3) + "...";
@@ -496,7 +768,7 @@ function formatTicketOneLiner(t, state) {
496
768
 
497
769
  // src/cli/run.ts
498
770
  init_esm_shims();
499
- import { join as join5 } from "path";
771
+ import { join as join6 } from "path";
500
772
 
501
773
  // src/core/index.ts
502
774
  init_esm_shims();
@@ -1792,12 +2064,241 @@ async function initProject(root, options) {
1792
2064
  };
1793
2065
  }
1794
2066
 
2067
+ // src/core/snapshot.ts
2068
+ init_esm_shims();
2069
+ import { readdir as readdir3, readFile as readFile3, mkdir as mkdir2, unlink as unlink2 } from "fs/promises";
2070
+ import { existsSync as existsSync4 } from "fs";
2071
+ import { join as join5, resolve as resolve4 } from "path";
2072
+ import { z as z6 } from "zod";
2073
+ var LoadWarningSchema = z6.object({
2074
+ type: z6.string(),
2075
+ file: z6.string(),
2076
+ message: z6.string()
2077
+ });
2078
+ var SnapshotV1Schema = z6.object({
2079
+ version: z6.literal(1),
2080
+ createdAt: z6.string().datetime({ offset: true }),
2081
+ project: z6.string(),
2082
+ config: ConfigSchema,
2083
+ roadmap: RoadmapSchema,
2084
+ tickets: z6.array(TicketSchema),
2085
+ issues: z6.array(IssueSchema),
2086
+ warnings: z6.array(LoadWarningSchema).optional()
2087
+ });
2088
+ var MAX_SNAPSHOTS = 20;
2089
+ async function saveSnapshot(root, loadResult) {
2090
+ const absRoot = resolve4(root);
2091
+ const snapshotsDir = join5(absRoot, ".story", "snapshots");
2092
+ await mkdir2(snapshotsDir, { recursive: true });
2093
+ const { state, warnings } = loadResult;
2094
+ const now = /* @__PURE__ */ new Date();
2095
+ const filename = formatSnapshotFilename(now);
2096
+ const snapshot = {
2097
+ version: 1,
2098
+ createdAt: now.toISOString(),
2099
+ project: state.config.project,
2100
+ config: state.config,
2101
+ roadmap: state.roadmap,
2102
+ tickets: [...state.tickets],
2103
+ issues: [...state.issues],
2104
+ ...warnings.length > 0 ? {
2105
+ warnings: warnings.map((w) => ({
2106
+ type: w.type,
2107
+ file: w.file,
2108
+ message: w.message
2109
+ }))
2110
+ } : {}
2111
+ };
2112
+ const json = JSON.stringify(snapshot, null, 2) + "\n";
2113
+ const targetPath = join5(snapshotsDir, filename);
2114
+ const wrapDir = join5(absRoot, ".story");
2115
+ await guardPath(targetPath, wrapDir);
2116
+ await atomicWrite(targetPath, json);
2117
+ const pruned = await pruneSnapshots(snapshotsDir);
2118
+ const entries = await listSnapshotFiles(snapshotsDir);
2119
+ return { filename, retained: entries.length, pruned };
2120
+ }
2121
+ async function loadLatestSnapshot(root) {
2122
+ const snapshotsDir = join5(resolve4(root), ".story", "snapshots");
2123
+ if (!existsSync4(snapshotsDir)) return null;
2124
+ const files = await listSnapshotFiles(snapshotsDir);
2125
+ if (files.length === 0) return null;
2126
+ for (const filename of files) {
2127
+ try {
2128
+ const content = await readFile3(join5(snapshotsDir, filename), "utf-8");
2129
+ const parsed = JSON.parse(content);
2130
+ const snapshot = SnapshotV1Schema.parse(parsed);
2131
+ return { snapshot, filename };
2132
+ } catch {
2133
+ continue;
2134
+ }
2135
+ }
2136
+ return null;
2137
+ }
2138
+ function diffStates(snapshotState, currentState) {
2139
+ const snapTickets = new Map(snapshotState.tickets.map((t) => [t.id, t]));
2140
+ const curTickets = new Map(currentState.tickets.map((t) => [t.id, t]));
2141
+ const ticketsAdded = [];
2142
+ const ticketsRemoved = [];
2143
+ const ticketsStatusChanged = [];
2144
+ for (const [id, cur] of curTickets) {
2145
+ const snap = snapTickets.get(id);
2146
+ if (!snap) {
2147
+ ticketsAdded.push({ id, title: cur.title });
2148
+ } else if (snap.status !== cur.status) {
2149
+ ticketsStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
2150
+ }
2151
+ }
2152
+ for (const [id, snap] of snapTickets) {
2153
+ if (!curTickets.has(id)) {
2154
+ ticketsRemoved.push({ id, title: snap.title });
2155
+ }
2156
+ }
2157
+ const snapIssues = new Map(snapshotState.issues.map((i) => [i.id, i]));
2158
+ const curIssues = new Map(currentState.issues.map((i) => [i.id, i]));
2159
+ const issuesAdded = [];
2160
+ const issuesResolved = [];
2161
+ const issuesStatusChanged = [];
2162
+ for (const [id, cur] of curIssues) {
2163
+ const snap = snapIssues.get(id);
2164
+ if (!snap) {
2165
+ issuesAdded.push({ id, title: cur.title });
2166
+ } else if (snap.status !== cur.status) {
2167
+ if (cur.status === "resolved") {
2168
+ issuesResolved.push({ id, title: cur.title });
2169
+ } else {
2170
+ issuesStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
2171
+ }
2172
+ }
2173
+ }
2174
+ const snapBlockers = new Map(
2175
+ snapshotState.roadmap.blockers.map((b) => [b.name, b])
2176
+ );
2177
+ const curBlockers = new Map(
2178
+ currentState.roadmap.blockers.map((b) => [b.name, b])
2179
+ );
2180
+ const blockersAdded = [];
2181
+ const blockersCleared = [];
2182
+ for (const [name, cur] of curBlockers) {
2183
+ const snap = snapBlockers.get(name);
2184
+ if (!snap) {
2185
+ blockersAdded.push(name);
2186
+ } else if (!isBlockerCleared(snap) && isBlockerCleared(cur)) {
2187
+ blockersCleared.push(name);
2188
+ }
2189
+ }
2190
+ const snapPhases = snapshotState.roadmap.phases;
2191
+ const curPhases = currentState.roadmap.phases;
2192
+ const snapPhaseMap = new Map(snapPhases.map((p) => [p.id, p]));
2193
+ const curPhaseMap = new Map(curPhases.map((p) => [p.id, p]));
2194
+ const phasesAdded = [];
2195
+ const phasesRemoved = [];
2196
+ const phasesStatusChanged = [];
2197
+ for (const [id, curPhase] of curPhaseMap) {
2198
+ const snapPhase = snapPhaseMap.get(id);
2199
+ if (!snapPhase) {
2200
+ phasesAdded.push({ id, name: curPhase.name });
2201
+ } else {
2202
+ const snapStatus = snapshotState.phaseStatus(id);
2203
+ const curStatus = currentState.phaseStatus(id);
2204
+ if (snapStatus !== curStatus) {
2205
+ phasesStatusChanged.push({
2206
+ id,
2207
+ name: curPhase.name,
2208
+ from: snapStatus,
2209
+ to: curStatus
2210
+ });
2211
+ }
2212
+ }
2213
+ }
2214
+ for (const [id, snapPhase] of snapPhaseMap) {
2215
+ if (!curPhaseMap.has(id)) {
2216
+ phasesRemoved.push({ id, name: snapPhase.name });
2217
+ }
2218
+ }
2219
+ return {
2220
+ tickets: { added: ticketsAdded, removed: ticketsRemoved, statusChanged: ticketsStatusChanged },
2221
+ issues: { added: issuesAdded, resolved: issuesResolved, statusChanged: issuesStatusChanged },
2222
+ blockers: { added: blockersAdded, cleared: blockersCleared },
2223
+ phases: { added: phasesAdded, removed: phasesRemoved, statusChanged: phasesStatusChanged }
2224
+ };
2225
+ }
2226
+ function buildRecap(currentState, snapshotInfo) {
2227
+ const next = nextTicket(currentState);
2228
+ const nextTicketAction = next.kind === "found" ? { id: next.ticket.id, title: next.ticket.title, phase: next.ticket.phase } : null;
2229
+ const highSeverityIssues = currentState.issues.filter(
2230
+ (i) => i.status !== "resolved" && (i.severity === "critical" || i.severity === "high")
2231
+ ).map((i) => ({ id: i.id, title: i.title, severity: i.severity }));
2232
+ if (!snapshotInfo) {
2233
+ return {
2234
+ snapshot: null,
2235
+ changes: null,
2236
+ suggestedActions: {
2237
+ nextTicket: nextTicketAction,
2238
+ highSeverityIssues,
2239
+ recentlyClearedBlockers: []
2240
+ },
2241
+ partial: false
2242
+ };
2243
+ }
2244
+ const { snapshot, filename } = snapshotInfo;
2245
+ const snapshotState = new ProjectState({
2246
+ tickets: snapshot.tickets,
2247
+ issues: snapshot.issues,
2248
+ roadmap: snapshot.roadmap,
2249
+ config: snapshot.config,
2250
+ handoverFilenames: []
2251
+ });
2252
+ const changes = diffStates(snapshotState, currentState);
2253
+ const recentlyClearedBlockers = changes.blockers.cleared;
2254
+ return {
2255
+ snapshot: { filename, createdAt: snapshot.createdAt },
2256
+ changes,
2257
+ suggestedActions: {
2258
+ nextTicket: nextTicketAction,
2259
+ highSeverityIssues,
2260
+ recentlyClearedBlockers
2261
+ },
2262
+ partial: (snapshot.warnings ?? []).length > 0
2263
+ };
2264
+ }
2265
+ function formatSnapshotFilename(date) {
2266
+ const y = date.getUTCFullYear();
2267
+ const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
2268
+ const d = String(date.getUTCDate()).padStart(2, "0");
2269
+ const h = String(date.getUTCHours()).padStart(2, "0");
2270
+ const mi = String(date.getUTCMinutes()).padStart(2, "0");
2271
+ const s = String(date.getUTCSeconds()).padStart(2, "0");
2272
+ const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
2273
+ return `${y}-${mo}-${d}T${h}-${mi}-${s}-${ms}.json`;
2274
+ }
2275
+ async function listSnapshotFiles(dir) {
2276
+ try {
2277
+ const entries = await readdir3(dir);
2278
+ return entries.filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort().reverse();
2279
+ } catch {
2280
+ return [];
2281
+ }
2282
+ }
2283
+ async function pruneSnapshots(dir) {
2284
+ const files = await listSnapshotFiles(dir);
2285
+ if (files.length <= MAX_SNAPSHOTS) return 0;
2286
+ const toRemove = files.slice(MAX_SNAPSHOTS);
2287
+ for (const f of toRemove) {
2288
+ try {
2289
+ await unlink2(join5(dir, f));
2290
+ } catch {
2291
+ }
2292
+ }
2293
+ return toRemove.length;
2294
+ }
2295
+
1795
2296
  // src/cli/run.ts
1796
2297
  init_errors();
1797
2298
 
1798
2299
  // src/cli/helpers.ts
1799
2300
  init_esm_shims();
1800
- import { resolve as resolve4, relative as relative3, extname as extname3 } from "path";
2301
+ import { resolve as resolve5, relative as relative3, extname as extname3 } from "path";
1801
2302
  import { lstat as lstat2 } from "fs/promises";
1802
2303
  var CliValidationError = class extends Error {
1803
2304
  constructor(code, message) {
@@ -1870,10 +2371,10 @@ async function parseHandoverFilename(raw, handoversDir) {
1870
2371
  `Invalid handover filename "${raw}": must have .md extension`
1871
2372
  );
1872
2373
  }
1873
- const resolvedDir = resolve4(handoversDir);
1874
- const resolvedCandidate = resolve4(handoversDir, raw);
2374
+ const resolvedDir = resolve5(handoversDir);
2375
+ const resolvedCandidate = resolve5(handoversDir, raw);
1875
2376
  const rel = relative3(resolvedDir, resolvedCandidate);
1876
- if (!rel || rel.startsWith("..") || resolve4(resolvedDir, rel) !== resolvedCandidate) {
2377
+ if (!rel || rel.startsWith("..") || resolve5(resolvedDir, rel) !== resolvedCandidate) {
1877
2378
  throw new CliValidationError(
1878
2379
  "invalid_input",
1879
2380
  `Invalid handover filename "${raw}": resolves outside handovers directory`
@@ -1934,7 +2435,7 @@ async function runReadCommand(format, handler) {
1934
2435
  return;
1935
2436
  }
1936
2437
  const { state, warnings } = await loadProject(root);
1937
- const handoversDir = join5(root, ".story", "handovers");
2438
+ const handoversDir = join6(root, ".story", "handovers");
1938
2439
  const result = await handler({ state, warnings, root, handoversDir, format });
1939
2440
  writeOutput(result.output);
1940
2441
  let exitCode = result.exitCode ?? ExitCode.OK;
@@ -1969,7 +2470,7 @@ async function runDeleteCommand(format, force, handler) {
1969
2470
  return;
1970
2471
  }
1971
2472
  const { state, warnings } = await loadProject(root);
1972
- const handoversDir = join5(root, ".story", "handovers");
2473
+ const handoversDir = join6(root, ".story", "handovers");
1973
2474
  if (!force && hasIntegrityWarnings(warnings)) {
1974
2475
  writeOutput(
1975
2476
  formatError(
@@ -2499,7 +3000,7 @@ async function handleIssueDelete(id, format, root) {
2499
3000
 
2500
3001
  // src/cli/commands/phase.ts
2501
3002
  init_esm_shims();
2502
- import { join as join6, resolve as resolve5 } from "path";
3003
+ import { join as join7, resolve as resolve6 } from "path";
2503
3004
  var PHASE_ID_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
2504
3005
  var PHASE_ID_MAX_LENGTH = 40;
2505
3006
  function validatePhaseId(id) {
@@ -2682,7 +3183,7 @@ async function handlePhaseDelete(id, reassign, format, root) {
2682
3183
  }
2683
3184
  const targetLeaves = state.phaseTickets(reassign);
2684
3185
  let maxOrder = targetLeaves.length > 0 ? targetLeaves[targetLeaves.length - 1].order : 0;
2685
- const wrapDir = resolve5(root, ".story");
3186
+ const wrapDir = resolve6(root, ".story");
2686
3187
  const operations = [];
2687
3188
  const sortedTickets = [...affectedTickets].sort((a, b) => a.order - b.order);
2688
3189
  for (const ticket of sortedTickets) {
@@ -2690,21 +3191,21 @@ async function handlePhaseDelete(id, reassign, format, root) {
2690
3191
  const updated = { ...ticket, phase: reassign, order: maxOrder };
2691
3192
  const parsed = TicketSchema.parse(updated);
2692
3193
  const content = serializeJSON(parsed);
2693
- const target = join6(wrapDir, "tickets", `${parsed.id}.json`);
3194
+ const target = join7(wrapDir, "tickets", `${parsed.id}.json`);
2694
3195
  operations.push({ op: "write", target, content });
2695
3196
  }
2696
3197
  for (const issue of affectedIssues) {
2697
3198
  const updated = { ...issue, phase: reassign };
2698
3199
  const parsed = IssueSchema.parse(updated);
2699
3200
  const content = serializeJSON(parsed);
2700
- const target = join6(wrapDir, "issues", `${parsed.id}.json`);
3201
+ const target = join7(wrapDir, "issues", `${parsed.id}.json`);
2701
3202
  operations.push({ op: "write", target, content });
2702
3203
  }
2703
3204
  const newPhases = state.roadmap.phases.filter((p) => p.id !== id);
2704
3205
  const newRoadmap = { ...state.roadmap, phases: newPhases };
2705
3206
  const parsedRoadmap = RoadmapSchema.parse(newRoadmap);
2706
3207
  const roadmapContent = serializeJSON(parsedRoadmap);
2707
- const roadmapTarget = join6(wrapDir, "roadmap.json");
3208
+ const roadmapTarget = join7(wrapDir, "roadmap.json");
2708
3209
  operations.push({ op: "write", target: roadmapTarget, content: roadmapContent });
2709
3210
  await runTransactionUnlocked(root, operations);
2710
3211
  } else {
@@ -2788,6 +3289,39 @@ function registerInitCommand(yargs2) {
2788
3289
  );
2789
3290
  }
2790
3291
 
3292
+ // src/cli/commands/recap.ts
3293
+ init_esm_shims();
3294
+ async function handleRecap(ctx) {
3295
+ const snapshotInfo = await loadLatestSnapshot(ctx.root);
3296
+ const recap = buildRecap(ctx.state, snapshotInfo);
3297
+ return { output: formatRecap(recap, ctx.state, ctx.format) };
3298
+ }
3299
+
3300
+ // src/cli/commands/export.ts
3301
+ init_esm_shims();
3302
+ function handleExport(ctx, mode, phaseId) {
3303
+ if (mode === "phase") {
3304
+ if (!phaseId) {
3305
+ throw new CliValidationError("invalid_input", "Missing --phase value");
3306
+ }
3307
+ const phase = ctx.state.roadmap.phases.find((p) => p.id === phaseId);
3308
+ if (!phase) {
3309
+ throw new CliValidationError("not_found", `Phase "${phaseId}" not found in roadmap`);
3310
+ }
3311
+ }
3312
+ return { output: formatExport(ctx.state, mode, phaseId, ctx.format) };
3313
+ }
3314
+
3315
+ // src/cli/commands/snapshot.ts
3316
+ init_esm_shims();
3317
+ async function handleSnapshot(root, format) {
3318
+ let result;
3319
+ await withProjectLock(root, { strict: false }, async (loadResult) => {
3320
+ result = await saveSnapshot(root, loadResult);
3321
+ });
3322
+ return { output: formatSnapshotResult(result, format) };
3323
+ }
3324
+
2791
3325
  // src/cli/register.ts
2792
3326
  function registerStatusCommand(yargs2) {
2793
3327
  return yargs2.command(
@@ -3780,9 +4314,89 @@ function registerPhaseCommand(yargs2) {
3780
4314
  }
3781
4315
  );
3782
4316
  }
4317
+ function registerSnapshotCommand(yargs2) {
4318
+ return yargs2.command(
4319
+ "snapshot",
4320
+ "Save current project state for session diffs",
4321
+ (y) => addFormatOption(y),
4322
+ async (argv) => {
4323
+ const format = parseOutputFormat(argv.format);
4324
+ const root = (await Promise.resolve().then(() => (init_project_root_discovery(), project_root_discovery_exports))).discoverProjectRoot();
4325
+ if (!root) {
4326
+ writeOutput(
4327
+ formatError("not_found", "No .story/ project found.", format)
4328
+ );
4329
+ process.exitCode = ExitCode.USER_ERROR;
4330
+ return;
4331
+ }
4332
+ try {
4333
+ const result = await handleSnapshot(root, format);
4334
+ writeOutput(result.output);
4335
+ process.exitCode = result.exitCode ?? ExitCode.OK;
4336
+ } catch (err) {
4337
+ if (err instanceof CliValidationError) {
4338
+ writeOutput(formatError(err.code, err.message, format));
4339
+ process.exitCode = ExitCode.USER_ERROR;
4340
+ return;
4341
+ }
4342
+ const { ProjectLoaderError: ProjectLoaderError2 } = await Promise.resolve().then(() => (init_errors(), errors_exports));
4343
+ if (err instanceof ProjectLoaderError2) {
4344
+ writeOutput(formatError(err.code, err.message, format));
4345
+ process.exitCode = ExitCode.USER_ERROR;
4346
+ return;
4347
+ }
4348
+ const message = err instanceof Error ? err.message : String(err);
4349
+ writeOutput(formatError("io_error", message, format));
4350
+ process.exitCode = ExitCode.USER_ERROR;
4351
+ }
4352
+ }
4353
+ );
4354
+ }
4355
+ function registerRecapCommand(yargs2) {
4356
+ return yargs2.command(
4357
+ "recap",
4358
+ "Session diff \u2014 changes since last snapshot + suggested actions",
4359
+ (y) => addFormatOption(y),
4360
+ async (argv) => {
4361
+ const format = parseOutputFormat(argv.format);
4362
+ await runReadCommand(format, handleRecap);
4363
+ }
4364
+ );
4365
+ }
4366
+ function registerExportCommand(yargs2) {
4367
+ return yargs2.command(
4368
+ "export",
4369
+ "Self-contained project document for sharing",
4370
+ (y) => addFormatOption(
4371
+ y.option("phase", {
4372
+ type: "string",
4373
+ describe: "Export a single phase by ID"
4374
+ }).option("all", {
4375
+ type: "boolean",
4376
+ describe: "Export entire project"
4377
+ }).conflicts("phase", "all").check((argv) => {
4378
+ if (!argv.phase && !argv.all) {
4379
+ throw new Error(
4380
+ "Specify either --phase <id> or --all"
4381
+ );
4382
+ }
4383
+ return true;
4384
+ })
4385
+ ),
4386
+ async (argv) => {
4387
+ const format = parseOutputFormat(argv.format);
4388
+ const mode = argv.all ? "all" : "phase";
4389
+ const phaseId = argv.phase ?? null;
4390
+ await runReadCommand(
4391
+ format,
4392
+ (ctx) => handleExport(ctx, mode, phaseId)
4393
+ );
4394
+ }
4395
+ );
4396
+ }
3783
4397
 
3784
4398
  // src/cli/index.ts
3785
- var version = "0.1.3";
4399
+ var version = "0.1.4";
3786
4400
  var HandledError = class extends Error {
3787
4401
  constructor() {
3788
4402
  super("HANDLED_ERROR");
@@ -3812,6 +4426,9 @@ cli = registerIssueCommand(cli);
3812
4426
  cli = registerHandoverCommand(cli);
3813
4427
  cli = registerBlockerCommand(cli);
3814
4428
  cli = registerValidateCommand(cli);
4429
+ cli = registerSnapshotCommand(cli);
4430
+ cli = registerRecapCommand(cli);
4431
+ cli = registerExportCommand(cli);
3815
4432
  function handleUnexpectedError(err) {
3816
4433
  if (err instanceof HandledError) return;
3817
4434
  const message = err instanceof Error ? err.message : String(err);