@anthropologies/claudestory 0.1.8 → 0.1.9

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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/mcp/index.ts
4
- import { realpathSync, existsSync as existsSync5 } from "fs";
4
+ import { realpathSync, existsSync as existsSync6 } from "fs";
5
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";
@@ -64,7 +64,7 @@ function checkRoot(candidate) {
64
64
  }
65
65
 
66
66
  // src/mcp/tools.ts
67
- import { z as z7 } from "zod";
67
+ import { z as z8 } from "zod";
68
68
  import { join as join7 } from "path";
69
69
 
70
70
  // src/core/project-loader.ts
@@ -77,7 +77,8 @@ import {
77
77
  stat,
78
78
  realpath,
79
79
  lstat,
80
- open
80
+ open,
81
+ mkdir
81
82
  } from "fs/promises";
82
83
  import { existsSync as existsSync3 } from "fs";
83
84
  import { join as join3, resolve as resolve2, relative as relative2, extname as extname2, dirname as dirname2, basename } from "path";
@@ -94,6 +95,9 @@ var TICKET_STATUSES = ["open", "inprogress", "complete"];
94
95
  var TICKET_TYPES = ["task", "feature", "chore"];
95
96
  var ISSUE_STATUSES = ["open", "inprogress", "resolved"];
96
97
  var ISSUE_SEVERITIES = ["critical", "high", "medium", "low"];
98
+ var NOTE_STATUSES = ["active", "archived"];
99
+ var NOTE_ID_REGEX = /^N-\d+$/;
100
+ var NoteIdSchema = z.string().regex(NOTE_ID_REGEX, "Note ID must match N-NNN");
97
101
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
98
102
  var DateSchema = z.string().regex(DATE_REGEX, "Date must be YYYY-MM-DD").refine(
99
103
  (val) => {
@@ -147,47 +151,65 @@ var IssueSchema = z3.object({
147
151
  lastModifiedBy: z3.string().nullable().optional()
148
152
  }).passthrough();
149
153
 
150
- // src/models/roadmap.ts
154
+ // src/models/note.ts
151
155
  import { z as z4 } from "zod";
152
- var BlockerSchema = z4.object({
153
- name: z4.string().min(1),
156
+ var NoteSchema = z4.object({
157
+ id: NoteIdSchema,
158
+ title: z4.preprocess((v) => v ?? null, z4.string().nullable()),
159
+ content: z4.string().refine((v) => v.trim().length > 0, "Content cannot be empty"),
160
+ tags: z4.preprocess(
161
+ (v) => {
162
+ const raw = Array.isArray(v) ? v : [];
163
+ return raw.filter((t) => typeof t === "string").map((t) => t.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "")).filter((t) => t.length > 0).filter((t, i, a) => a.indexOf(t) === i);
164
+ },
165
+ z4.array(z4.string())
166
+ ),
167
+ status: z4.enum(NOTE_STATUSES),
168
+ createdDate: DateSchema,
169
+ updatedDate: DateSchema
170
+ }).passthrough();
171
+
172
+ // src/models/roadmap.ts
173
+ import { z as z5 } from "zod";
174
+ var BlockerSchema = z5.object({
175
+ name: z5.string().min(1),
154
176
  // Legacy format (pre-T-082)
155
- cleared: z4.boolean().optional(),
177
+ cleared: z5.boolean().optional(),
156
178
  // New date-based format (T-082 migration)
157
179
  createdDate: DateSchema.optional(),
158
180
  clearedDate: DateSchema.nullable().optional(),
159
181
  // Present in all current data but optional for future minimal blockers
160
- note: z4.string().nullable().optional()
182
+ note: z5.string().nullable().optional()
161
183
  }).passthrough();
162
- var PhaseSchema = z4.object({
163
- id: z4.string().min(1),
164
- label: z4.string(),
165
- name: z4.string(),
166
- description: z4.string(),
167
- summary: z4.string().optional()
184
+ var PhaseSchema = z5.object({
185
+ id: z5.string().min(1),
186
+ label: z5.string(),
187
+ name: z5.string(),
188
+ description: z5.string(),
189
+ summary: z5.string().optional()
168
190
  }).passthrough();
169
- var RoadmapSchema = z4.object({
170
- title: z4.string(),
191
+ var RoadmapSchema = z5.object({
192
+ title: z5.string(),
171
193
  date: DateSchema,
172
- phases: z4.array(PhaseSchema),
173
- blockers: z4.array(BlockerSchema)
194
+ phases: z5.array(PhaseSchema),
195
+ blockers: z5.array(BlockerSchema)
174
196
  }).passthrough();
175
197
 
176
198
  // src/models/config.ts
177
- import { z as z5 } from "zod";
178
- var FeaturesSchema = z5.object({
179
- tickets: z5.boolean(),
180
- issues: z5.boolean(),
181
- handovers: z5.boolean(),
182
- roadmap: z5.boolean(),
183
- reviews: z5.boolean()
199
+ import { z as z6 } from "zod";
200
+ var FeaturesSchema = z6.object({
201
+ tickets: z6.boolean(),
202
+ issues: z6.boolean(),
203
+ handovers: z6.boolean(),
204
+ roadmap: z6.boolean(),
205
+ reviews: z6.boolean()
184
206
  }).passthrough();
185
- var ConfigSchema = z5.object({
186
- version: z5.number().int().min(1),
187
- schemaVersion: z5.number().int().optional(),
188
- project: z5.string().min(1),
189
- type: z5.string(),
190
- language: z5.string(),
207
+ var ConfigSchema = z6.object({
208
+ version: z6.number().int().min(1),
209
+ schemaVersion: z6.number().int().optional(),
210
+ project: z6.string().min(1),
211
+ type: z6.string(),
212
+ language: z6.string(),
191
213
  features: FeaturesSchema
192
214
  }).passthrough();
193
215
 
@@ -196,6 +218,7 @@ var ProjectState = class _ProjectState {
196
218
  // --- Public raw inputs (readonly) ---
197
219
  tickets;
198
220
  issues;
221
+ notes;
199
222
  roadmap;
200
223
  config;
201
224
  handoverFilenames;
@@ -210,15 +233,19 @@ var ProjectState = class _ProjectState {
210
233
  reverseBlocksMap;
211
234
  ticketsByID;
212
235
  issuesByID;
236
+ notesByID;
213
237
  // --- Counts ---
214
238
  totalTicketCount;
215
239
  openTicketCount;
216
240
  completeTicketCount;
217
241
  openIssueCount;
218
242
  issuesBySeverity;
243
+ activeNoteCount;
244
+ archivedNoteCount;
219
245
  constructor(input) {
220
246
  this.tickets = input.tickets;
221
247
  this.issues = input.issues;
248
+ this.notes = input.notes;
222
249
  this.roadmap = input.roadmap;
223
250
  this.config = input.config;
224
251
  this.handoverFilenames = input.handoverFilenames;
@@ -284,6 +311,11 @@ var ProjectState = class _ProjectState {
284
311
  iByID.set(i.id, i);
285
312
  }
286
313
  this.issuesByID = iByID;
314
+ const nByID = /* @__PURE__ */ new Map();
315
+ for (const n of input.notes) {
316
+ nByID.set(n.id, n);
317
+ }
318
+ this.notesByID = nByID;
287
319
  this.totalTicketCount = input.tickets.length;
288
320
  this.openTicketCount = input.tickets.filter(
289
321
  (t) => t.status !== "complete"
@@ -301,6 +333,12 @@ var ProjectState = class _ProjectState {
301
333
  }
302
334
  }
303
335
  this.issuesBySeverity = bySev;
336
+ this.activeNoteCount = input.notes.filter(
337
+ (n) => n.status === "active"
338
+ ).length;
339
+ this.archivedNoteCount = input.notes.filter(
340
+ (n) => n.status === "archived"
341
+ ).length;
304
342
  }
305
343
  // --- Query Methods ---
306
344
  isUmbrella(ticket) {
@@ -347,6 +385,9 @@ var ProjectState = class _ProjectState {
347
385
  issueByID(id) {
348
386
  return this.issuesByID.get(id);
349
387
  }
388
+ noteByID(id) {
389
+ return this.notesByID.get(id);
390
+ }
350
391
  // --- Deletion Safety ---
351
392
  /** IDs of tickets that list `ticketId` in their blockedBy. */
352
393
  ticketsBlocking(ticketId) {
@@ -501,6 +542,12 @@ async function loadProject(root, options) {
501
542
  IssueSchema,
502
543
  warnings
503
544
  );
545
+ const notes = await loadDirectory(
546
+ join3(wrapDir, "notes"),
547
+ absRoot,
548
+ NoteSchema,
549
+ warnings
550
+ );
504
551
  const handoversDir = join3(wrapDir, "handovers");
505
552
  const handoverFilenames = await listHandovers(
506
553
  handoversDir,
@@ -521,12 +568,56 @@ async function loadProject(root, options) {
521
568
  const state = new ProjectState({
522
569
  tickets,
523
570
  issues,
571
+ notes,
524
572
  roadmap,
525
573
  config,
526
574
  handoverFilenames
527
575
  });
528
576
  return { state, warnings };
529
577
  }
578
+ async function writeTicketUnlocked(ticket, root) {
579
+ const parsed = TicketSchema.parse(ticket);
580
+ if (!TICKET_ID_REGEX.test(parsed.id)) {
581
+ throw new ProjectLoaderError(
582
+ "invalid_input",
583
+ `Invalid ticket ID: ${parsed.id}`
584
+ );
585
+ }
586
+ const wrapDir = resolve2(root, ".story");
587
+ const targetPath = join3(wrapDir, "tickets", `${parsed.id}.json`);
588
+ await guardPath(targetPath, wrapDir);
589
+ const json = serializeJSON(parsed);
590
+ await atomicWrite(targetPath, json);
591
+ }
592
+ async function writeIssueUnlocked(issue, root) {
593
+ const parsed = IssueSchema.parse(issue);
594
+ if (!ISSUE_ID_REGEX.test(parsed.id)) {
595
+ throw new ProjectLoaderError(
596
+ "invalid_input",
597
+ `Invalid issue ID: ${parsed.id}`
598
+ );
599
+ }
600
+ const wrapDir = resolve2(root, ".story");
601
+ const targetPath = join3(wrapDir, "issues", `${parsed.id}.json`);
602
+ await guardPath(targetPath, wrapDir);
603
+ const json = serializeJSON(parsed);
604
+ await atomicWrite(targetPath, json);
605
+ }
606
+ async function writeNoteUnlocked(note, root) {
607
+ const parsed = NoteSchema.parse(note);
608
+ if (!NOTE_ID_REGEX.test(parsed.id)) {
609
+ throw new ProjectLoaderError(
610
+ "invalid_input",
611
+ `Invalid note ID: ${parsed.id}`
612
+ );
613
+ }
614
+ const wrapDir = resolve2(root, ".story");
615
+ const targetPath = join3(wrapDir, "notes", `${parsed.id}.json`);
616
+ await mkdir(dirname2(targetPath), { recursive: true });
617
+ await guardPath(targetPath, wrapDir);
618
+ const json = serializeJSON(parsed);
619
+ await atomicWrite(targetPath, json);
620
+ }
530
621
  async function withProjectLock(root, options, handler) {
531
622
  const absRoot = resolve2(root);
532
623
  const wrapDir = join3(absRoot, ".story");
@@ -629,8 +720,9 @@ async function loadProjectUnlocked(absRoot) {
629
720
  const warnings = [];
630
721
  const tickets = await loadDirectory(join3(wrapDir, "tickets"), absRoot, TicketSchema, warnings);
631
722
  const issues = await loadDirectory(join3(wrapDir, "issues"), absRoot, IssueSchema, warnings);
723
+ const notes = await loadDirectory(join3(wrapDir, "notes"), absRoot, NoteSchema, warnings);
632
724
  const handoverFilenames = await listHandovers(join3(wrapDir, "handovers"), absRoot, warnings);
633
- const state = new ProjectState({ tickets, issues, roadmap, config, handoverFilenames });
725
+ const state = new ProjectState({ tickets, issues, notes, roadmap, config, handoverFilenames });
634
726
  return { state, warnings };
635
727
  }
636
728
  async function loadSingletonFile(filename, wrapDir, root, schema) {
@@ -711,6 +803,20 @@ async function loadDirectory(dirPath, root, schema, warnings) {
711
803
  }
712
804
  return results;
713
805
  }
806
+ function sortKeysDeep(value) {
807
+ if (value === null || value === void 0) return value;
808
+ if (typeof value !== "object") return value;
809
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
810
+ const obj = value;
811
+ const sorted = {};
812
+ for (const key of Object.keys(obj).sort()) {
813
+ sorted[key] = sortKeysDeep(obj[key]);
814
+ }
815
+ return sorted;
816
+ }
817
+ function serializeJSON(obj) {
818
+ return JSON.stringify(sortKeysDeep(obj), null, 2) + "\n";
819
+ }
714
820
  async function atomicWrite(targetPath, content) {
715
821
  const tempPath = `${targetPath}.${process.pid}.tmp`;
716
822
  try {
@@ -851,6 +957,19 @@ async function parseHandoverFilename(raw, handoversDir) {
851
957
  }
852
958
  return raw;
853
959
  }
960
+ function normalizeTags(raw) {
961
+ const seen = /* @__PURE__ */ new Set();
962
+ const result = [];
963
+ for (const item of raw) {
964
+ if (typeof item !== "string") continue;
965
+ const normalized = item.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
966
+ if (normalized && !seen.has(normalized)) {
967
+ seen.add(normalized);
968
+ result.push(normalized);
969
+ }
970
+ }
971
+ return result;
972
+ }
854
973
 
855
974
  // src/core/queries.ts
856
975
  function nextTicket(state) {
@@ -888,6 +1007,53 @@ function nextTicket(state) {
888
1007
  }
889
1008
  return { kind: "empty_project" };
890
1009
  }
1010
+ function nextTickets(state, count) {
1011
+ const effectiveCount = Math.max(1, count);
1012
+ const phases = state.roadmap.phases;
1013
+ if (phases.length === 0 || state.leafTickets.length === 0) {
1014
+ return { kind: "empty_project" };
1015
+ }
1016
+ const candidates = [];
1017
+ const skippedBlockedPhases = [];
1018
+ let allPhasesComplete = true;
1019
+ for (const phase of phases) {
1020
+ if (candidates.length >= effectiveCount) break;
1021
+ const leaves = state.phaseTickets(phase.id);
1022
+ if (leaves.length === 0) continue;
1023
+ const status = state.phaseStatus(phase.id);
1024
+ if (status === "complete") continue;
1025
+ allPhasesComplete = false;
1026
+ const incompleteLeaves = leaves.filter((t) => t.status !== "complete");
1027
+ const unblocked = incompleteLeaves.filter((t) => !state.isBlocked(t));
1028
+ if (unblocked.length === 0) {
1029
+ skippedBlockedPhases.push({
1030
+ phaseId: phase.id,
1031
+ blockedCount: incompleteLeaves.length
1032
+ });
1033
+ continue;
1034
+ }
1035
+ const remaining = effectiveCount - candidates.length;
1036
+ for (const ticket of unblocked.slice(0, remaining)) {
1037
+ const impact = ticketsUnblockedBy(ticket.id, state);
1038
+ const progress = ticket.parentTicket ? umbrellaProgress(ticket.parentTicket, state) : null;
1039
+ candidates.push({
1040
+ ticket,
1041
+ unblockImpact: { ticketId: ticket.id, wouldUnblock: impact },
1042
+ umbrellaProgress: progress
1043
+ });
1044
+ }
1045
+ }
1046
+ if (candidates.length > 0) {
1047
+ return { kind: "found", candidates, skippedBlockedPhases };
1048
+ }
1049
+ if (skippedBlockedPhases.length > 0) {
1050
+ return { kind: "all_blocked", phases: skippedBlockedPhases };
1051
+ }
1052
+ if (allPhasesComplete) {
1053
+ return { kind: "all_complete" };
1054
+ }
1055
+ return { kind: "empty_project" };
1056
+ }
891
1057
  function blockedTickets(state) {
892
1058
  return state.leafTickets.filter(
893
1059
  (t) => t.status !== "complete" && state.isBlocked(t)
@@ -935,6 +1101,9 @@ function isBlockerCleared(blocker) {
935
1101
  if (blocker.clearedDate != null) return true;
936
1102
  return false;
937
1103
  }
1104
+ function descendantLeaves(ticketId, state) {
1105
+ return collectDescendantLeaves(ticketId, state, /* @__PURE__ */ new Set());
1106
+ }
938
1107
  function collectDescendantLeaves(ticketId, state, visited) {
939
1108
  if (visited.has(ticketId)) return [];
940
1109
  visited.add(ticketId);
@@ -988,6 +1157,8 @@ function formatStatus(state, format) {
988
1157
  openTickets: state.leafTicketCount - state.completeLeafTicketCount,
989
1158
  blockedTickets: state.blockedCount,
990
1159
  openIssues: state.openIssueCount,
1160
+ activeNotes: state.activeNoteCount,
1161
+ archivedNotes: state.archivedNoteCount,
991
1162
  handovers: state.handoverFilenames.length,
992
1163
  phases: phases.map((p) => ({
993
1164
  id: p.phase.id,
@@ -1004,6 +1175,7 @@ function formatStatus(state, format) {
1004
1175
  "",
1005
1176
  `Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete, ${state.blockedCount} blocked`,
1006
1177
  `Issues: ${state.openIssueCount} open`,
1178
+ `Notes: ${state.activeNoteCount} active, ${state.archivedNoteCount} archived`,
1007
1179
  `Handovers: ${state.handoverFilenames.length}`,
1008
1180
  "",
1009
1181
  "## Phases",
@@ -1100,6 +1272,52 @@ function formatNextTicketOutcome(outcome, state, format) {
1100
1272
  }
1101
1273
  }
1102
1274
  }
1275
+ function formatNextTicketsOutcome(outcome, state, format) {
1276
+ if (format === "json") {
1277
+ return JSON.stringify(successEnvelope(outcome), null, 2);
1278
+ }
1279
+ switch (outcome.kind) {
1280
+ case "empty_project":
1281
+ return "No phased tickets found.";
1282
+ case "all_complete":
1283
+ return "All phases complete.";
1284
+ case "all_blocked": {
1285
+ const details = outcome.phases.map((p) => `${escapeMarkdownInline(p.phaseId)} (${p.blockedCount} blocked)`).join(", ");
1286
+ return `All incomplete tickets are blocked across ${outcome.phases.length} phase${outcome.phases.length === 1 ? "" : "s"}: ${details}`;
1287
+ }
1288
+ case "found": {
1289
+ const { candidates, skippedBlockedPhases } = outcome;
1290
+ const lines = [];
1291
+ for (let i = 0; i < candidates.length; i++) {
1292
+ const c = candidates[i];
1293
+ const t = c.ticket;
1294
+ if (i > 0) lines.push("", "---", "");
1295
+ if (candidates.length === 1) {
1296
+ lines.push(`# Next: ${escapeMarkdownInline(t.id)} \u2014 ${escapeMarkdownInline(t.title)}`);
1297
+ } else {
1298
+ lines.push(`# ${i + 1}. ${escapeMarkdownInline(t.id)} \u2014 ${escapeMarkdownInline(t.title)}`);
1299
+ }
1300
+ lines.push("", `Phase: ${t.phase ?? "none"} | Order: ${t.order} | Type: ${t.type}`);
1301
+ if (c.unblockImpact.wouldUnblock.length > 0) {
1302
+ const ids = c.unblockImpact.wouldUnblock.map((u) => u.id).join(", ");
1303
+ lines.push(`Completing this unblocks: ${ids}`);
1304
+ }
1305
+ if (c.umbrellaProgress) {
1306
+ const p = c.umbrellaProgress;
1307
+ lines.push(`Parent progress: ${p.complete}/${p.total} complete (${p.status})`);
1308
+ }
1309
+ if (t.description) {
1310
+ lines.push("", fencedBlock(t.description));
1311
+ }
1312
+ }
1313
+ if (skippedBlockedPhases.length > 0) {
1314
+ const details = skippedBlockedPhases.map((p) => `${escapeMarkdownInline(p.phaseId)} (${p.blockedCount} blocked)`).join(", ");
1315
+ lines.push("", "---", "", `Skipped blocked phases: ${details}`);
1316
+ }
1317
+ return lines.join("\n");
1318
+ }
1319
+ }
1320
+ }
1103
1321
  function formatTicketList(tickets, format) {
1104
1322
  if (format === "json") {
1105
1323
  return JSON.stringify(successEnvelope(tickets), null, 2);
@@ -1214,6 +1432,50 @@ function formatBlockerList(roadmap, format) {
1214
1432
  }
1215
1433
  return lines.join("\n");
1216
1434
  }
1435
+ function formatNote(note, format) {
1436
+ if (format === "json") {
1437
+ return JSON.stringify(successEnvelope(note), null, 2);
1438
+ }
1439
+ const title = note.title ?? `${note.createdDate} \u2014 ${note.id}`;
1440
+ const statusBadge = note.status === "archived" ? " (archived)" : "";
1441
+ const lines = [
1442
+ `# ${escapeMarkdownInline(title)}${statusBadge}`,
1443
+ "",
1444
+ `Status: ${note.status}`
1445
+ ];
1446
+ if (note.tags.length > 0) {
1447
+ lines.push(`Tags: ${note.tags.join(", ")}`);
1448
+ }
1449
+ lines.push(`Created: ${note.createdDate} | Updated: ${note.updatedDate}`);
1450
+ lines.push("", fencedBlock(note.content));
1451
+ return lines.join("\n");
1452
+ }
1453
+ function formatNoteList(notes, format) {
1454
+ if (format === "json") {
1455
+ return JSON.stringify(successEnvelope(notes), null, 2);
1456
+ }
1457
+ if (notes.length === 0) return "No notes found.";
1458
+ const lines = [];
1459
+ for (const n of notes) {
1460
+ const title = n.title ?? n.id;
1461
+ const status = n.status === "archived" ? "[x]" : "[ ]";
1462
+ const tagInfo = n.status === "archived" ? " (archived)" : n.tags.length > 0 ? ` (${n.tags.join(", ")})` : "";
1463
+ lines.push(`${status} ${n.id}: ${escapeMarkdownInline(title)}${tagInfo}`);
1464
+ }
1465
+ return lines.join("\n");
1466
+ }
1467
+ function formatNoteCreateResult(note, format) {
1468
+ if (format === "json") {
1469
+ return JSON.stringify(successEnvelope(note), null, 2);
1470
+ }
1471
+ return `Created note ${note.id}: ${note.title ?? note.id}`;
1472
+ }
1473
+ function formatNoteUpdateResult(note, format) {
1474
+ if (format === "json") {
1475
+ return JSON.stringify(successEnvelope(note), null, 2);
1476
+ }
1477
+ return `Updated note ${note.id}: ${note.title ?? note.id}`;
1478
+ }
1217
1479
  function formatError(code, message, format) {
1218
1480
  if (format === "json") {
1219
1481
  return JSON.stringify(errorEnvelope(code, message), null, 2);
@@ -1281,7 +1543,7 @@ function formatRecap(recap, state, format) {
1281
1543
  }
1282
1544
  }
1283
1545
  const ticketChanges = changes.tickets;
1284
- if (ticketChanges.added.length > 0 || ticketChanges.removed.length > 0 || ticketChanges.statusChanged.length > 0) {
1546
+ if (ticketChanges.added.length > 0 || ticketChanges.removed.length > 0 || ticketChanges.statusChanged.length > 0 || ticketChanges.descriptionChanged.length > 0) {
1285
1547
  lines.push("");
1286
1548
  lines.push("## Tickets");
1287
1549
  for (const t of ticketChanges.statusChanged) {
@@ -1293,9 +1555,12 @@ function formatRecap(recap, state, format) {
1293
1555
  for (const t of ticketChanges.removed) {
1294
1556
  lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 **removed**`);
1295
1557
  }
1558
+ for (const t of ticketChanges.descriptionChanged) {
1559
+ lines.push(`- ${t.id}: description updated`);
1560
+ }
1296
1561
  }
1297
1562
  const issueChanges = changes.issues;
1298
- if (issueChanges.added.length > 0 || issueChanges.resolved.length > 0 || issueChanges.statusChanged.length > 0) {
1563
+ if (issueChanges.added.length > 0 || issueChanges.resolved.length > 0 || issueChanges.statusChanged.length > 0 || issueChanges.impactChanged.length > 0) {
1299
1564
  lines.push("");
1300
1565
  lines.push("## Issues");
1301
1566
  for (const i of issueChanges.resolved) {
@@ -1307,6 +1572,9 @@ function formatRecap(recap, state, format) {
1307
1572
  for (const i of issueChanges.added) {
1308
1573
  lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 **new**`);
1309
1574
  }
1575
+ for (const i of issueChanges.impactChanged) {
1576
+ lines.push(`- ${i.id}: impact updated`);
1577
+ }
1310
1578
  }
1311
1579
  if (changes.blockers.added.length > 0 || changes.blockers.cleared.length > 0) {
1312
1580
  lines.push("");
@@ -1318,6 +1586,19 @@ function formatRecap(recap, state, format) {
1318
1586
  lines.push(`- ${escapeMarkdownInline(name)} \u2014 **new**`);
1319
1587
  }
1320
1588
  }
1589
+ if (changes.notes && (changes.notes.added.length > 0 || changes.notes.removed.length > 0 || changes.notes.updated.length > 0)) {
1590
+ lines.push("");
1591
+ lines.push("## Notes");
1592
+ for (const n of changes.notes.added) {
1593
+ lines.push(`- ${n.id}: added`);
1594
+ }
1595
+ for (const n of changes.notes.removed) {
1596
+ lines.push(`- ${n.id}: removed`);
1597
+ }
1598
+ for (const n of changes.notes.updated) {
1599
+ lines.push(`- ${n.id}: updated (${n.changedFields.join(", ")})`);
1600
+ }
1601
+ }
1321
1602
  }
1322
1603
  }
1323
1604
  const actions = recap.suggestedActions;
@@ -1456,6 +1737,12 @@ function formatFullExport(state, format) {
1456
1737
  severity: i.severity,
1457
1738
  status: i.status
1458
1739
  })),
1740
+ notes: state.notes.map((n) => ({
1741
+ id: n.id,
1742
+ title: n.title,
1743
+ status: n.status,
1744
+ tags: n.tags
1745
+ })),
1459
1746
  blockers: state.roadmap.blockers.map((b) => ({
1460
1747
  name: b.name,
1461
1748
  cleared: isBlockerCleared(b),
@@ -1471,6 +1758,7 @@ function formatFullExport(state, format) {
1471
1758
  lines.push("");
1472
1759
  lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete`);
1473
1760
  lines.push(`Issues: ${state.openIssueCount} open`);
1761
+ lines.push(`Notes: ${state.activeNoteCount} active, ${state.archivedNoteCount} archived`);
1474
1762
  lines.push("");
1475
1763
  lines.push("## Phases");
1476
1764
  for (const p of phases) {
@@ -1497,6 +1785,16 @@ function formatFullExport(state, format) {
1497
1785
  lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}${resolved}`);
1498
1786
  }
1499
1787
  }
1788
+ const activeNotes = state.notes.filter((n) => n.status === "active");
1789
+ if (activeNotes.length > 0) {
1790
+ lines.push("");
1791
+ lines.push("## Notes");
1792
+ for (const n of activeNotes) {
1793
+ const title = n.title ?? n.id;
1794
+ const tagInfo = n.tags.length > 0 ? ` (${n.tags.join(", ")})` : "";
1795
+ lines.push(`- ${n.id}: ${escapeMarkdownInline(title)}${tagInfo}`);
1796
+ }
1797
+ }
1500
1798
  const blockers = state.roadmap.blockers;
1501
1799
  if (blockers.length > 0) {
1502
1800
  lines.push("");
@@ -1509,7 +1807,7 @@ function formatFullExport(state, format) {
1509
1807
  return lines.join("\n");
1510
1808
  }
1511
1809
  function hasAnyChanges(diff) {
1512
- 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;
1810
+ return diff.tickets.added.length > 0 || diff.tickets.removed.length > 0 || diff.tickets.statusChanged.length > 0 || diff.tickets.descriptionChanged.length > 0 || diff.issues.added.length > 0 || diff.issues.resolved.length > 0 || diff.issues.statusChanged.length > 0 || diff.issues.impactChanged.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 || (diff.notes?.added.length ?? 0) > 0 || (diff.notes?.removed.length ?? 0) > 0 || (diff.notes?.updated.length ?? 0) > 0;
1513
1811
  }
1514
1812
  function truncate(text, maxLen) {
1515
1813
  if (text.length <= maxLen) return text;
@@ -1520,6 +1818,29 @@ function formatTicketOneLiner(t, state) {
1520
1818
  const blocked = state.isBlocked(t) ? " [BLOCKED]" : "";
1521
1819
  return `${status} ${t.id}: ${escapeMarkdownInline(t.title)}${blocked}`;
1522
1820
  }
1821
+ function formatRecommendations(result, format) {
1822
+ if (format === "json") {
1823
+ return JSON.stringify(successEnvelope(result), null, 2);
1824
+ }
1825
+ if (result.recommendations.length === 0) {
1826
+ return "No recommendations \u2014 all work is complete or blocked.";
1827
+ }
1828
+ const lines = ["# Recommendations", ""];
1829
+ for (let i = 0; i < result.recommendations.length; i++) {
1830
+ const rec = result.recommendations[i];
1831
+ lines.push(
1832
+ `${i + 1}. **${escapeMarkdownInline(rec.id)}** (${rec.kind}) \u2014 ${escapeMarkdownInline(rec.title)}`
1833
+ );
1834
+ lines.push(` _${escapeMarkdownInline(rec.reason)}_`);
1835
+ lines.push("");
1836
+ }
1837
+ if (result.totalCandidates > result.recommendations.length) {
1838
+ lines.push(
1839
+ `Showing ${result.recommendations.length} of ${result.totalCandidates} candidates.`
1840
+ );
1841
+ }
1842
+ return lines.join("\n");
1843
+ }
1523
1844
 
1524
1845
  // src/cli/commands/status.ts
1525
1846
  function handleStatus(ctx) {
@@ -1562,6 +1883,20 @@ function validateProject(state) {
1562
1883
  });
1563
1884
  }
1564
1885
  }
1886
+ const noteIDCounts = /* @__PURE__ */ new Map();
1887
+ for (const n of state.notes) {
1888
+ noteIDCounts.set(n.id, (noteIDCounts.get(n.id) ?? 0) + 1);
1889
+ }
1890
+ for (const [id, count] of noteIDCounts) {
1891
+ if (count > 1) {
1892
+ findings.push({
1893
+ level: "error",
1894
+ code: "duplicate_note_id",
1895
+ message: `Duplicate note ID: ${id} appears ${count} times.`,
1896
+ entity: id
1897
+ });
1898
+ }
1899
+ }
1565
1900
  const phaseIDCounts = /* @__PURE__ */ new Map();
1566
1901
  for (const p of state.roadmap.phases) {
1567
1902
  phaseIDCounts.set(p.id, (phaseIDCounts.get(p.id) ?? 0) + 1);
@@ -1778,12 +2113,13 @@ function handleValidate(ctx) {
1778
2113
  }
1779
2114
 
1780
2115
  // src/cli/commands/handover.ts
1781
- import { mkdir } from "fs/promises";
2116
+ import { existsSync as existsSync4 } from "fs";
2117
+ import { mkdir as mkdir2 } from "fs/promises";
1782
2118
  import { join as join4, resolve as resolve4 } from "path";
1783
2119
  function handleHandoverList(ctx) {
1784
2120
  return { output: formatHandoverList(ctx.state.handoverFilenames, ctx.format) };
1785
2121
  }
1786
- async function handleHandoverLatest(ctx) {
2122
+ async function handleHandoverLatest(ctx, count = 1) {
1787
2123
  if (ctx.state.handoverFilenames.length === 0) {
1788
2124
  return {
1789
2125
  output: formatError("not_found", "No handovers found", ctx.format),
@@ -1791,25 +2127,38 @@ async function handleHandoverLatest(ctx) {
1791
2127
  errorCode: "not_found"
1792
2128
  };
1793
2129
  }
1794
- const filename = ctx.state.handoverFilenames[0];
1795
- await parseHandoverFilename(filename, ctx.handoversDir);
1796
- try {
1797
- const content = await readHandover(ctx.handoversDir, filename);
1798
- return { output: formatHandoverContent(filename, content, ctx.format) };
1799
- } catch (err) {
1800
- if (err.code === "ENOENT") {
2130
+ const filenames = ctx.state.handoverFilenames.slice(0, count);
2131
+ const parts = [];
2132
+ for (const filename of filenames) {
2133
+ await parseHandoverFilename(filename, ctx.handoversDir);
2134
+ try {
2135
+ const content = await readHandover(ctx.handoversDir, filename);
2136
+ parts.push(formatHandoverContent(filename, content, ctx.format));
2137
+ } catch (err) {
2138
+ if (err.code === "ENOENT") {
2139
+ if (count > 1) continue;
2140
+ return {
2141
+ output: formatError("not_found", `Handover file not found: ${filename}`, ctx.format),
2142
+ exitCode: ExitCode.USER_ERROR,
2143
+ errorCode: "not_found"
2144
+ };
2145
+ }
1801
2146
  return {
1802
- output: formatError("not_found", `Handover file not found: ${filename}`, ctx.format),
2147
+ output: formatError("io_error", `Cannot read handover: ${err.message}`, ctx.format),
1803
2148
  exitCode: ExitCode.USER_ERROR,
1804
- errorCode: "not_found"
2149
+ errorCode: "io_error"
1805
2150
  };
1806
2151
  }
2152
+ }
2153
+ if (parts.length === 0) {
1807
2154
  return {
1808
- output: formatError("io_error", `Cannot read handover: ${err.message}`, ctx.format),
2155
+ output: formatError("not_found", "No handovers found", ctx.format),
1809
2156
  exitCode: ExitCode.USER_ERROR,
1810
- errorCode: "io_error"
2157
+ errorCode: "not_found"
1811
2158
  };
1812
2159
  }
2160
+ const separator = ctx.format === "json" ? "\n" : "\n\n---\n\n";
2161
+ return { output: parts.join(separator) };
1813
2162
  }
1814
2163
  async function handleHandoverGet(filename, ctx) {
1815
2164
  await parseHandoverFilename(filename, ctx.handoversDir);
@@ -1852,7 +2201,7 @@ async function handleHandoverCreate(content, slugRaw, format, root) {
1852
2201
  await withProjectLock(root, { strict: false }, async () => {
1853
2202
  const absRoot = resolve4(root);
1854
2203
  const handoversDir = join4(absRoot, ".story", "handovers");
1855
- await mkdir(handoversDir, { recursive: true });
2204
+ await mkdir2(handoversDir, { recursive: true });
1856
2205
  const wrapDir = join4(absRoot, ".story");
1857
2206
  const datePrefix = `${date}-`;
1858
2207
  const seqRegex = new RegExp(`^${date}-(\\d{2})-`);
@@ -1868,15 +2217,26 @@ async function handleHandoverCreate(content, slugRaw, format, root) {
1868
2217
  }
1869
2218
  } catch {
1870
2219
  }
1871
- const nextSeq = maxSeq + 1;
2220
+ let nextSeq = maxSeq + 1;
1872
2221
  if (nextSeq > 99) {
1873
2222
  throw new CliValidationError(
1874
2223
  "conflict",
1875
2224
  `Too many handovers for ${date}; limit is 99 per day`
1876
2225
  );
1877
2226
  }
1878
- const candidate = `${date}-${String(nextSeq).padStart(2, "0")}-${slug}.md`;
1879
- const candidatePath = join4(handoversDir, candidate);
2227
+ let candidate = `${date}-${String(nextSeq).padStart(2, "0")}-${slug}.md`;
2228
+ let candidatePath = join4(handoversDir, candidate);
2229
+ while (existsSync4(candidatePath)) {
2230
+ nextSeq++;
2231
+ if (nextSeq > 99) {
2232
+ throw new CliValidationError(
2233
+ "conflict",
2234
+ `Too many handovers for ${date}; limit is 99 per day`
2235
+ );
2236
+ }
2237
+ candidate = `${date}-${String(nextSeq).padStart(2, "0")}-${slug}.md`;
2238
+ candidatePath = join4(handoversDir, candidate);
2239
+ }
1880
2240
  await parseHandoverFilename(candidate, handoversDir);
1881
2241
  await guardPath(candidatePath, wrapDir);
1882
2242
  await atomicWrite(candidatePath, content);
@@ -1890,6 +2250,52 @@ function handleBlockerList(ctx) {
1890
2250
  return { output: formatBlockerList(ctx.state.roadmap, ctx.format) };
1891
2251
  }
1892
2252
 
2253
+ // src/core/id-allocation.ts
2254
+ var TICKET_NUMERIC_REGEX = /^T-(\d+)[a-z]?$/;
2255
+ var ISSUE_NUMERIC_REGEX = /^ISS-(\d+)$/;
2256
+ var NOTE_NUMERIC_REGEX = /^N-(\d+)$/;
2257
+ function nextTicketID(tickets) {
2258
+ let max = 0;
2259
+ for (const t of tickets) {
2260
+ if (!TICKET_ID_REGEX.test(t.id)) continue;
2261
+ const match = t.id.match(TICKET_NUMERIC_REGEX);
2262
+ if (match?.[1]) {
2263
+ const num = parseInt(match[1], 10);
2264
+ if (num > max) max = num;
2265
+ }
2266
+ }
2267
+ return `T-${String(max + 1).padStart(3, "0")}`;
2268
+ }
2269
+ function nextIssueID(issues) {
2270
+ let max = 0;
2271
+ for (const i of issues) {
2272
+ if (!ISSUE_ID_REGEX.test(i.id)) continue;
2273
+ const match = i.id.match(ISSUE_NUMERIC_REGEX);
2274
+ if (match?.[1]) {
2275
+ const num = parseInt(match[1], 10);
2276
+ if (num > max) max = num;
2277
+ }
2278
+ }
2279
+ return `ISS-${String(max + 1).padStart(3, "0")}`;
2280
+ }
2281
+ function nextNoteID(notes) {
2282
+ let max = 0;
2283
+ for (const n of notes) {
2284
+ if (!NOTE_ID_REGEX.test(n.id)) continue;
2285
+ const match = n.id.match(NOTE_NUMERIC_REGEX);
2286
+ if (match?.[1]) {
2287
+ const num = parseInt(match[1], 10);
2288
+ if (num > max) max = num;
2289
+ }
2290
+ }
2291
+ return `N-${String(max + 1).padStart(3, "0")}`;
2292
+ }
2293
+ function nextOrder(phaseId, state) {
2294
+ const tickets = state.phaseTickets(phaseId);
2295
+ if (tickets.length === 0) return 10;
2296
+ return tickets[tickets.length - 1].order + 10;
2297
+ }
2298
+
1893
2299
  // src/cli/commands/ticket.ts
1894
2300
  function handleTicketList(filters, ctx) {
1895
2301
  let tickets = [...ctx.state.leafTickets];
@@ -1927,18 +2333,159 @@ function handleTicketGet(id, ctx) {
1927
2333
  }
1928
2334
  return { output: formatTicket(ticket, ctx.state, ctx.format) };
1929
2335
  }
1930
- function handleTicketNext(ctx) {
1931
- const outcome = nextTicket(ctx.state);
2336
+ function handleTicketNext(ctx, count = 1) {
2337
+ if (count <= 1) {
2338
+ const outcome2 = nextTicket(ctx.state);
2339
+ const exitCode2 = outcome2.kind === "found" ? ExitCode.OK : ExitCode.USER_ERROR;
2340
+ return { output: formatNextTicketOutcome(outcome2, ctx.state, ctx.format), exitCode: exitCode2 };
2341
+ }
2342
+ const outcome = nextTickets(ctx.state, count);
1932
2343
  const exitCode = outcome.kind === "found" ? ExitCode.OK : ExitCode.USER_ERROR;
1933
- return {
1934
- output: formatNextTicketOutcome(outcome, ctx.state, ctx.format),
1935
- exitCode
1936
- };
2344
+ return { output: formatNextTicketsOutcome(outcome, ctx.state, ctx.format), exitCode };
1937
2345
  }
1938
2346
  function handleTicketBlocked(ctx) {
1939
2347
  const blocked = blockedTickets(ctx.state);
1940
2348
  return { output: formatBlockedTickets(blocked, ctx.state, ctx.format) };
1941
2349
  }
2350
+ function validatePhase(phase, ctx) {
2351
+ if (phase !== null && !ctx.state.roadmap.phases.some((p) => p.id === phase)) {
2352
+ throw new CliValidationError("invalid_input", `Phase "${phase}" not found in roadmap`);
2353
+ }
2354
+ }
2355
+ function validateBlockedBy(ids, ticketId, state) {
2356
+ for (const bid of ids) {
2357
+ if (bid === ticketId) {
2358
+ throw new CliValidationError("invalid_input", `Ticket cannot block itself: ${bid}`);
2359
+ }
2360
+ const blocker = state.ticketByID(bid);
2361
+ if (!blocker) {
2362
+ throw new CliValidationError("invalid_input", `Blocked-by ticket ${bid} not found`);
2363
+ }
2364
+ if (state.umbrellaIDs.has(bid)) {
2365
+ throw new CliValidationError("invalid_input", `Cannot block on umbrella ticket ${bid}. Use leaf tickets instead.`);
2366
+ }
2367
+ }
2368
+ }
2369
+ function validateParentTicket(parentId, ticketId, state) {
2370
+ if (parentId === ticketId) {
2371
+ throw new CliValidationError("invalid_input", `Ticket cannot be its own parent`);
2372
+ }
2373
+ if (!state.ticketByID(parentId)) {
2374
+ throw new CliValidationError("invalid_input", `Parent ticket ${parentId} not found`);
2375
+ }
2376
+ }
2377
+ function validatePostWriteState(candidate, state, isCreate) {
2378
+ const existingTickets = [...state.tickets];
2379
+ if (isCreate) {
2380
+ existingTickets.push(candidate);
2381
+ } else {
2382
+ const idx = existingTickets.findIndex((t) => t.id === candidate.id);
2383
+ if (idx >= 0) existingTickets[idx] = candidate;
2384
+ else existingTickets.push(candidate);
2385
+ }
2386
+ const postState = new ProjectState({
2387
+ tickets: existingTickets,
2388
+ issues: [...state.issues],
2389
+ notes: [...state.notes],
2390
+ roadmap: state.roadmap,
2391
+ config: state.config,
2392
+ handoverFilenames: [...state.handoverFilenames]
2393
+ });
2394
+ const result = validateProject(postState);
2395
+ if (!result.valid) {
2396
+ const errors = result.findings.filter((f) => f.level === "error");
2397
+ const msg = errors.map((f) => f.message).join("; ");
2398
+ throw new CliValidationError("validation_failed", `Write would create invalid state: ${msg}`);
2399
+ }
2400
+ }
2401
+ async function handleTicketCreate(args, format, root) {
2402
+ if (!TICKET_TYPES.includes(args.type)) {
2403
+ throw new CliValidationError(
2404
+ "invalid_input",
2405
+ `Unknown ticket type "${args.type}": must be one of ${TICKET_TYPES.join(", ")}`
2406
+ );
2407
+ }
2408
+ let createdTicket;
2409
+ await withProjectLock(root, { strict: true }, async ({ state }) => {
2410
+ validatePhase(args.phase, { state });
2411
+ if (args.blockedBy.length > 0) {
2412
+ validateBlockedBy(args.blockedBy, "", state);
2413
+ }
2414
+ if (args.parentTicket) {
2415
+ validateParentTicket(args.parentTicket, "", state);
2416
+ }
2417
+ const id = nextTicketID(state.tickets);
2418
+ const order = nextOrder(args.phase, state);
2419
+ const ticket = {
2420
+ id,
2421
+ title: args.title,
2422
+ description: args.description,
2423
+ type: args.type,
2424
+ status: "open",
2425
+ phase: args.phase,
2426
+ order,
2427
+ createdDate: todayISO(),
2428
+ completedDate: null,
2429
+ blockedBy: args.blockedBy,
2430
+ parentTicket: args.parentTicket ?? void 0
2431
+ };
2432
+ validatePostWriteState(ticket, state, true);
2433
+ await writeTicketUnlocked(ticket, root);
2434
+ createdTicket = ticket;
2435
+ });
2436
+ if (!createdTicket) throw new Error("Ticket not created");
2437
+ if (format === "json") {
2438
+ return { output: JSON.stringify(successEnvelope(createdTicket), null, 2) };
2439
+ }
2440
+ return { output: `Created ticket ${createdTicket.id}: ${createdTicket.title}` };
2441
+ }
2442
+ async function handleTicketUpdate(id, updates, format, root) {
2443
+ if (updates.status && !TICKET_STATUSES.includes(updates.status)) {
2444
+ throw new CliValidationError(
2445
+ "invalid_input",
2446
+ `Unknown ticket status "${updates.status}": must be one of ${TICKET_STATUSES.join(", ")}`
2447
+ );
2448
+ }
2449
+ let updatedTicket;
2450
+ await withProjectLock(root, { strict: true }, async ({ state }) => {
2451
+ const existing = state.ticketByID(id);
2452
+ if (!existing) {
2453
+ throw new CliValidationError("not_found", `Ticket ${id} not found`);
2454
+ }
2455
+ if (updates.phase !== void 0) {
2456
+ validatePhase(updates.phase, { state });
2457
+ }
2458
+ if (updates.blockedBy) {
2459
+ validateBlockedBy(updates.blockedBy, id, state);
2460
+ }
2461
+ if (updates.parentTicket) {
2462
+ validateParentTicket(updates.parentTicket, id, state);
2463
+ }
2464
+ const ticket = { ...existing };
2465
+ if (updates.title !== void 0) ticket.title = updates.title;
2466
+ if (updates.description !== void 0) ticket.description = updates.description;
2467
+ if (updates.phase !== void 0) ticket.phase = updates.phase;
2468
+ if (updates.order !== void 0) ticket.order = updates.order;
2469
+ if (updates.blockedBy !== void 0) ticket.blockedBy = updates.blockedBy;
2470
+ if (updates.parentTicket !== void 0) ticket.parentTicket = updates.parentTicket === null ? void 0 : updates.parentTicket;
2471
+ if (updates.status !== void 0 && updates.status !== existing.status) {
2472
+ ticket.status = updates.status;
2473
+ if (updates.status === "complete" && existing.status !== "complete") {
2474
+ ticket.completedDate = todayISO();
2475
+ } else if (updates.status !== "complete" && existing.status === "complete") {
2476
+ ticket.completedDate = null;
2477
+ }
2478
+ }
2479
+ validatePostWriteState(ticket, state, false);
2480
+ await writeTicketUnlocked(ticket, root);
2481
+ updatedTicket = ticket;
2482
+ });
2483
+ if (!updatedTicket) throw new Error("Ticket not updated");
2484
+ if (format === "json") {
2485
+ return { output: JSON.stringify(successEnvelope(updatedTicket), null, 2) };
2486
+ }
2487
+ return { output: `Updated ticket ${updatedTicket.id}: ${updatedTicket.title}` };
2488
+ }
1942
2489
 
1943
2490
  // src/cli/commands/issue.ts
1944
2491
  function handleIssueList(filters, ctx) {
@@ -1961,6 +2508,9 @@ function handleIssueList(filters, ctx) {
1961
2508
  }
1962
2509
  issues = issues.filter((i) => i.severity === filters.severity);
1963
2510
  }
2511
+ if (filters.component) {
2512
+ issues = issues.filter((i) => i.components.includes(filters.component));
2513
+ }
1964
2514
  return { output: formatIssueList(issues, ctx.format) };
1965
2515
  }
1966
2516
  function handleIssueGet(id, ctx) {
@@ -1974,32 +2524,149 @@ function handleIssueGet(id, ctx) {
1974
2524
  }
1975
2525
  return { output: formatIssue(issue, ctx.format) };
1976
2526
  }
2527
+ function validateRelatedTickets(ids, state) {
2528
+ for (const tid of ids) {
2529
+ if (!state.ticketByID(tid)) {
2530
+ throw new CliValidationError("invalid_input", `Related ticket ${tid} not found`);
2531
+ }
2532
+ }
2533
+ }
2534
+ function validatePostWriteIssueState(candidate, state, isCreate) {
2535
+ const existingIssues = [...state.issues];
2536
+ if (isCreate) {
2537
+ existingIssues.push(candidate);
2538
+ } else {
2539
+ const idx = existingIssues.findIndex((i) => i.id === candidate.id);
2540
+ if (idx >= 0) existingIssues[idx] = candidate;
2541
+ else existingIssues.push(candidate);
2542
+ }
2543
+ const postState = new ProjectState({
2544
+ tickets: [...state.tickets],
2545
+ issues: existingIssues,
2546
+ notes: [...state.notes],
2547
+ roadmap: state.roadmap,
2548
+ config: state.config,
2549
+ handoverFilenames: [...state.handoverFilenames]
2550
+ });
2551
+ const result = validateProject(postState);
2552
+ if (!result.valid) {
2553
+ const errors = result.findings.filter((f) => f.level === "error");
2554
+ const msg = errors.map((f) => f.message).join("; ");
2555
+ throw new CliValidationError("validation_failed", `Write would create invalid state: ${msg}`);
2556
+ }
2557
+ }
2558
+ async function handleIssueCreate(args, format, root) {
2559
+ if (!ISSUE_SEVERITIES.includes(args.severity)) {
2560
+ throw new CliValidationError(
2561
+ "invalid_input",
2562
+ `Unknown issue severity "${args.severity}": must be one of ${ISSUE_SEVERITIES.join(", ")}`
2563
+ );
2564
+ }
2565
+ let createdIssue;
2566
+ await withProjectLock(root, { strict: true }, async ({ state }) => {
2567
+ if (args.relatedTickets.length > 0) {
2568
+ validateRelatedTickets(args.relatedTickets, state);
2569
+ }
2570
+ const id = nextIssueID(state.issues);
2571
+ const issue = {
2572
+ id,
2573
+ title: args.title,
2574
+ status: "open",
2575
+ severity: args.severity,
2576
+ components: args.components,
2577
+ impact: args.impact,
2578
+ resolution: null,
2579
+ location: args.location,
2580
+ discoveredDate: todayISO(),
2581
+ resolvedDate: null,
2582
+ relatedTickets: args.relatedTickets,
2583
+ phase: args.phase ?? null
2584
+ };
2585
+ validatePostWriteIssueState(issue, state, true);
2586
+ await writeIssueUnlocked(issue, root);
2587
+ createdIssue = issue;
2588
+ });
2589
+ if (!createdIssue) throw new Error("Issue not created");
2590
+ if (format === "json") {
2591
+ return { output: JSON.stringify(successEnvelope(createdIssue), null, 2) };
2592
+ }
2593
+ return { output: `Created issue ${createdIssue.id}: ${createdIssue.title}` };
2594
+ }
2595
+ async function handleIssueUpdate(id, updates, format, root) {
2596
+ if (updates.status && !ISSUE_STATUSES.includes(updates.status)) {
2597
+ throw new CliValidationError(
2598
+ "invalid_input",
2599
+ `Unknown issue status "${updates.status}": must be one of ${ISSUE_STATUSES.join(", ")}`
2600
+ );
2601
+ }
2602
+ if (updates.severity && !ISSUE_SEVERITIES.includes(updates.severity)) {
2603
+ throw new CliValidationError(
2604
+ "invalid_input",
2605
+ `Unknown issue severity "${updates.severity}": must be one of ${ISSUE_SEVERITIES.join(", ")}`
2606
+ );
2607
+ }
2608
+ let updatedIssue;
2609
+ await withProjectLock(root, { strict: true }, async ({ state }) => {
2610
+ const existing = state.issueByID(id);
2611
+ if (!existing) {
2612
+ throw new CliValidationError("not_found", `Issue ${id} not found`);
2613
+ }
2614
+ if (updates.relatedTickets) {
2615
+ validateRelatedTickets(updates.relatedTickets, state);
2616
+ }
2617
+ const issue = { ...existing };
2618
+ if (updates.title !== void 0) issue.title = updates.title;
2619
+ if (updates.severity !== void 0) issue.severity = updates.severity;
2620
+ if (updates.impact !== void 0) issue.impact = updates.impact;
2621
+ if (updates.resolution !== void 0) issue.resolution = updates.resolution;
2622
+ if (updates.components !== void 0) issue.components = updates.components;
2623
+ if (updates.relatedTickets !== void 0) issue.relatedTickets = updates.relatedTickets;
2624
+ if (updates.location !== void 0) issue.location = updates.location;
2625
+ if (updates.status !== void 0 && updates.status !== existing.status) {
2626
+ issue.status = updates.status;
2627
+ if (updates.status === "resolved" && existing.status !== "resolved") {
2628
+ issue.resolvedDate = todayISO();
2629
+ } else if (updates.status !== "resolved" && existing.status === "resolved") {
2630
+ issue.resolvedDate = null;
2631
+ }
2632
+ }
2633
+ validatePostWriteIssueState(issue, state, false);
2634
+ await writeIssueUnlocked(issue, root);
2635
+ updatedIssue = issue;
2636
+ });
2637
+ if (!updatedIssue) throw new Error("Issue not updated");
2638
+ if (format === "json") {
2639
+ return { output: JSON.stringify(successEnvelope(updatedIssue), null, 2) };
2640
+ }
2641
+ return { output: `Updated issue ${updatedIssue.id}: ${updatedIssue.title}` };
2642
+ }
1977
2643
 
1978
2644
  // src/core/snapshot.ts
1979
- import { readdir as readdir3, readFile as readFile3, mkdir as mkdir2, unlink as unlink2 } from "fs/promises";
1980
- import { existsSync as existsSync4 } from "fs";
2645
+ import { readdir as readdir3, readFile as readFile3, mkdir as mkdir3, unlink as unlink2 } from "fs/promises";
2646
+ import { existsSync as existsSync5 } from "fs";
1981
2647
  import { join as join5, resolve as resolve5 } from "path";
1982
- import { z as z6 } from "zod";
1983
- var LoadWarningSchema = z6.object({
1984
- type: z6.string(),
1985
- file: z6.string(),
1986
- message: z6.string()
2648
+ import { z as z7 } from "zod";
2649
+ var LoadWarningSchema = z7.object({
2650
+ type: z7.string(),
2651
+ file: z7.string(),
2652
+ message: z7.string()
1987
2653
  });
1988
- var SnapshotV1Schema = z6.object({
1989
- version: z6.literal(1),
1990
- createdAt: z6.string().datetime({ offset: true }),
1991
- project: z6.string(),
2654
+ var SnapshotV1Schema = z7.object({
2655
+ version: z7.literal(1),
2656
+ createdAt: z7.string().datetime({ offset: true }),
2657
+ project: z7.string(),
1992
2658
  config: ConfigSchema,
1993
2659
  roadmap: RoadmapSchema,
1994
- tickets: z6.array(TicketSchema),
1995
- issues: z6.array(IssueSchema),
1996
- warnings: z6.array(LoadWarningSchema).optional()
2660
+ tickets: z7.array(TicketSchema),
2661
+ issues: z7.array(IssueSchema),
2662
+ notes: z7.array(NoteSchema).optional().default([]),
2663
+ warnings: z7.array(LoadWarningSchema).optional()
1997
2664
  });
1998
2665
  var MAX_SNAPSHOTS = 20;
1999
2666
  async function saveSnapshot(root, loadResult) {
2000
2667
  const absRoot = resolve5(root);
2001
2668
  const snapshotsDir = join5(absRoot, ".story", "snapshots");
2002
- await mkdir2(snapshotsDir, { recursive: true });
2669
+ await mkdir3(snapshotsDir, { recursive: true });
2003
2670
  const { state, warnings } = loadResult;
2004
2671
  const now = /* @__PURE__ */ new Date();
2005
2672
  const filename = formatSnapshotFilename(now);
@@ -2011,6 +2678,7 @@ async function saveSnapshot(root, loadResult) {
2011
2678
  roadmap: state.roadmap,
2012
2679
  tickets: [...state.tickets],
2013
2680
  issues: [...state.issues],
2681
+ notes: [...state.notes],
2014
2682
  ...warnings.length > 0 ? {
2015
2683
  warnings: warnings.map((w) => ({
2016
2684
  type: w.type,
@@ -2030,7 +2698,7 @@ async function saveSnapshot(root, loadResult) {
2030
2698
  }
2031
2699
  async function loadLatestSnapshot(root) {
2032
2700
  const snapshotsDir = join5(resolve5(root), ".story", "snapshots");
2033
- if (!existsSync4(snapshotsDir)) return null;
2701
+ if (!existsSync5(snapshotsDir)) return null;
2034
2702
  const files = await listSnapshotFiles(snapshotsDir);
2035
2703
  if (files.length === 0) return null;
2036
2704
  for (const filename of files) {
@@ -2051,12 +2719,18 @@ function diffStates(snapshotState, currentState) {
2051
2719
  const ticketsAdded = [];
2052
2720
  const ticketsRemoved = [];
2053
2721
  const ticketsStatusChanged = [];
2722
+ const ticketsDescriptionChanged = [];
2054
2723
  for (const [id, cur] of curTickets) {
2055
2724
  const snap = snapTickets.get(id);
2056
2725
  if (!snap) {
2057
2726
  ticketsAdded.push({ id, title: cur.title });
2058
- } else if (snap.status !== cur.status) {
2059
- ticketsStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
2727
+ } else {
2728
+ if (snap.status !== cur.status) {
2729
+ ticketsStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
2730
+ }
2731
+ if (snap.description !== cur.description) {
2732
+ ticketsDescriptionChanged.push({ id, title: cur.title });
2733
+ }
2060
2734
  }
2061
2735
  }
2062
2736
  for (const [id, snap] of snapTickets) {
@@ -2069,15 +2743,21 @@ function diffStates(snapshotState, currentState) {
2069
2743
  const issuesAdded = [];
2070
2744
  const issuesResolved = [];
2071
2745
  const issuesStatusChanged = [];
2746
+ const issuesImpactChanged = [];
2072
2747
  for (const [id, cur] of curIssues) {
2073
2748
  const snap = snapIssues.get(id);
2074
2749
  if (!snap) {
2075
2750
  issuesAdded.push({ id, title: cur.title });
2076
- } else if (snap.status !== cur.status) {
2077
- if (cur.status === "resolved") {
2078
- issuesResolved.push({ id, title: cur.title });
2079
- } else {
2080
- issuesStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
2751
+ } else {
2752
+ if (snap.status !== cur.status) {
2753
+ if (cur.status === "resolved") {
2754
+ issuesResolved.push({ id, title: cur.title });
2755
+ } else {
2756
+ issuesStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
2757
+ }
2758
+ }
2759
+ if (snap.impact !== cur.impact) {
2760
+ issuesImpactChanged.push({ id, title: cur.title });
2081
2761
  }
2082
2762
  }
2083
2763
  }
@@ -2126,11 +2806,37 @@ function diffStates(snapshotState, currentState) {
2126
2806
  phasesRemoved.push({ id, name: snapPhase.name });
2127
2807
  }
2128
2808
  }
2809
+ const snapNotes = new Map(snapshotState.notes.map((n) => [n.id, n]));
2810
+ const curNotes = new Map(currentState.notes.map((n) => [n.id, n]));
2811
+ const notesAdded = [];
2812
+ const notesRemoved = [];
2813
+ const notesUpdated = [];
2814
+ for (const [id, cur] of curNotes) {
2815
+ const snap = snapNotes.get(id);
2816
+ if (!snap) {
2817
+ notesAdded.push({ id, title: cur.title });
2818
+ } else {
2819
+ const changedFields = [];
2820
+ if (snap.title !== cur.title) changedFields.push("title");
2821
+ if (snap.content !== cur.content) changedFields.push("content");
2822
+ if (JSON.stringify([...snap.tags].sort()) !== JSON.stringify([...cur.tags].sort())) changedFields.push("tags");
2823
+ if (snap.status !== cur.status) changedFields.push("status");
2824
+ if (changedFields.length > 0) {
2825
+ notesUpdated.push({ id, title: cur.title, changedFields });
2826
+ }
2827
+ }
2828
+ }
2829
+ for (const [id, snap] of snapNotes) {
2830
+ if (!curNotes.has(id)) {
2831
+ notesRemoved.push({ id, title: snap.title });
2832
+ }
2833
+ }
2129
2834
  return {
2130
- tickets: { added: ticketsAdded, removed: ticketsRemoved, statusChanged: ticketsStatusChanged },
2131
- issues: { added: issuesAdded, resolved: issuesResolved, statusChanged: issuesStatusChanged },
2835
+ tickets: { added: ticketsAdded, removed: ticketsRemoved, statusChanged: ticketsStatusChanged, descriptionChanged: ticketsDescriptionChanged },
2836
+ issues: { added: issuesAdded, resolved: issuesResolved, statusChanged: issuesStatusChanged, impactChanged: issuesImpactChanged },
2132
2837
  blockers: { added: blockersAdded, cleared: blockersCleared },
2133
- phases: { added: phasesAdded, removed: phasesRemoved, statusChanged: phasesStatusChanged }
2838
+ phases: { added: phasesAdded, removed: phasesRemoved, statusChanged: phasesStatusChanged },
2839
+ notes: { added: notesAdded, removed: notesRemoved, updated: notesUpdated }
2134
2840
  };
2135
2841
  }
2136
2842
  function buildRecap(currentState, snapshotInfo) {
@@ -2155,6 +2861,7 @@ function buildRecap(currentState, snapshotInfo) {
2155
2861
  const snapshotState = new ProjectState({
2156
2862
  tickets: snapshot.tickets,
2157
2863
  issues: snapshot.issues,
2864
+ notes: snapshot.notes ?? [],
2158
2865
  roadmap: snapshot.roadmap,
2159
2866
  config: snapshot.config,
2160
2867
  handoverFilenames: []
@@ -2210,12 +2917,358 @@ async function handleRecap(ctx) {
2210
2917
  return { output: formatRecap(recap, ctx.state, ctx.format) };
2211
2918
  }
2212
2919
 
2920
+ // src/cli/commands/note.ts
2921
+ function handleNoteList(filters, ctx) {
2922
+ let notes = [...ctx.state.notes];
2923
+ if (filters.status) {
2924
+ if (!NOTE_STATUSES.includes(filters.status)) {
2925
+ throw new CliValidationError(
2926
+ "invalid_input",
2927
+ `Unknown note status "${filters.status}": must be one of ${NOTE_STATUSES.join(", ")}`
2928
+ );
2929
+ }
2930
+ notes = notes.filter((n) => n.status === filters.status);
2931
+ }
2932
+ if (filters.tag) {
2933
+ const normalized = normalizeTags([filters.tag]);
2934
+ if (normalized.length === 0) {
2935
+ notes = [];
2936
+ } else {
2937
+ const tag = normalized[0];
2938
+ notes = notes.filter((n) => n.tags.includes(tag));
2939
+ }
2940
+ }
2941
+ notes.sort((a, b) => {
2942
+ const dateCmp = b.updatedDate.localeCompare(a.updatedDate);
2943
+ if (dateCmp !== 0) return dateCmp;
2944
+ return a.id.localeCompare(b.id);
2945
+ });
2946
+ return { output: formatNoteList(notes, ctx.format) };
2947
+ }
2948
+ function handleNoteGet(id, ctx) {
2949
+ const note = ctx.state.noteByID(id);
2950
+ if (!note) {
2951
+ return {
2952
+ output: formatError("not_found", `Note ${id} not found`, ctx.format),
2953
+ exitCode: ExitCode.USER_ERROR,
2954
+ errorCode: "not_found"
2955
+ };
2956
+ }
2957
+ return { output: formatNote(note, ctx.format) };
2958
+ }
2959
+ async function handleNoteCreate(args, format, root) {
2960
+ if (!args.content.trim()) {
2961
+ throw new CliValidationError("invalid_input", "Note content cannot be empty");
2962
+ }
2963
+ let createdNote;
2964
+ await withProjectLock(root, { strict: true }, async ({ state }) => {
2965
+ const id = nextNoteID(state.notes);
2966
+ const today = todayISO();
2967
+ const tags = args.tags ? normalizeTags(args.tags) : [];
2968
+ const note = {
2969
+ id,
2970
+ title: args.title && args.title.trim() !== "" ? args.title : null,
2971
+ content: args.content,
2972
+ tags,
2973
+ status: "active",
2974
+ createdDate: today,
2975
+ updatedDate: today
2976
+ };
2977
+ await writeNoteUnlocked(note, root);
2978
+ createdNote = note;
2979
+ });
2980
+ if (!createdNote) throw new Error("Note not created");
2981
+ return { output: formatNoteCreateResult(createdNote, format) };
2982
+ }
2983
+ async function handleNoteUpdate(id, updates, format, root) {
2984
+ if (updates.content !== void 0 && !updates.content.trim()) {
2985
+ throw new CliValidationError("invalid_input", "Note content cannot be empty");
2986
+ }
2987
+ if (updates.status && !NOTE_STATUSES.includes(updates.status)) {
2988
+ throw new CliValidationError(
2989
+ "invalid_input",
2990
+ `Unknown note status "${updates.status}": must be one of ${NOTE_STATUSES.join(", ")}`
2991
+ );
2992
+ }
2993
+ let updatedNote;
2994
+ await withProjectLock(root, { strict: true }, async ({ state }) => {
2995
+ const existing = state.noteByID(id);
2996
+ if (!existing) {
2997
+ throw new CliValidationError("not_found", `Note ${id} not found`);
2998
+ }
2999
+ const note = { ...existing };
3000
+ if (updates.content !== void 0) {
3001
+ note.content = updates.content;
3002
+ }
3003
+ if (updates.title !== void 0) {
3004
+ const trimmed = updates.title?.trim();
3005
+ note.title = !trimmed ? null : updates.title;
3006
+ }
3007
+ if (updates.clearTags) {
3008
+ note.tags = [];
3009
+ } else if (updates.tags !== void 0) {
3010
+ note.tags = normalizeTags(updates.tags);
3011
+ }
3012
+ if (updates.status !== void 0) {
3013
+ note.status = updates.status;
3014
+ }
3015
+ note.updatedDate = todayISO();
3016
+ await writeNoteUnlocked(note, root);
3017
+ updatedNote = note;
3018
+ });
3019
+ if (!updatedNote) throw new Error("Note not updated");
3020
+ return { output: formatNoteUpdateResult(updatedNote, format) };
3021
+ }
3022
+
3023
+ // src/core/recommend.ts
3024
+ var SEVERITY_RANK = {
3025
+ critical: 4,
3026
+ high: 3,
3027
+ medium: 2,
3028
+ low: 1
3029
+ };
3030
+ var PHASE_DISTANCE_PENALTY = 100;
3031
+ var MAX_PHASE_PENALTY = 400;
3032
+ var CATEGORY_PRIORITY = {
3033
+ validation_errors: 1,
3034
+ critical_issue: 2,
3035
+ inprogress_ticket: 3,
3036
+ high_impact_unblock: 4,
3037
+ near_complete_umbrella: 5,
3038
+ phase_momentum: 6,
3039
+ quick_win: 7,
3040
+ open_issue: 8
3041
+ };
3042
+ function recommend(state, count) {
3043
+ const effectiveCount = Math.max(1, Math.min(10, count));
3044
+ const dedup = /* @__PURE__ */ new Map();
3045
+ const phaseIndex = buildPhaseIndex(state);
3046
+ const generators = [
3047
+ () => generateValidationSuggestions(state),
3048
+ () => generateCriticalIssues(state),
3049
+ () => generateInProgressTickets(state, phaseIndex),
3050
+ () => generateHighImpactUnblocks(state),
3051
+ () => generateNearCompleteUmbrellas(state, phaseIndex),
3052
+ () => generatePhaseMomentum(state),
3053
+ () => generateQuickWins(state, phaseIndex),
3054
+ () => generateOpenIssues(state)
3055
+ ];
3056
+ for (const gen of generators) {
3057
+ for (const rec of gen()) {
3058
+ const existing = dedup.get(rec.id);
3059
+ if (!existing || rec.score > existing.score) {
3060
+ dedup.set(rec.id, rec);
3061
+ }
3062
+ }
3063
+ }
3064
+ const curPhase = currentPhase(state);
3065
+ const curPhaseIdx = curPhase ? phaseIndex.get(curPhase.id) ?? 0 : 0;
3066
+ for (const [id, rec] of dedup) {
3067
+ if (rec.kind !== "ticket") continue;
3068
+ const ticket = state.ticketByID(id);
3069
+ if (!ticket || ticket.phase == null) continue;
3070
+ const ticketPhaseIdx = phaseIndex.get(ticket.phase);
3071
+ if (ticketPhaseIdx === void 0) continue;
3072
+ const phasesAhead = ticketPhaseIdx - curPhaseIdx;
3073
+ if (phasesAhead > 0) {
3074
+ const penalty = Math.min(phasesAhead * PHASE_DISTANCE_PENALTY, MAX_PHASE_PENALTY);
3075
+ dedup.set(id, {
3076
+ ...rec,
3077
+ score: rec.score - penalty,
3078
+ reason: rec.reason + " (future phase)"
3079
+ });
3080
+ }
3081
+ }
3082
+ const all = [...dedup.values()].sort((a, b) => {
3083
+ if (b.score !== a.score) return b.score - a.score;
3084
+ const catDiff = CATEGORY_PRIORITY[a.category] - CATEGORY_PRIORITY[b.category];
3085
+ if (catDiff !== 0) return catDiff;
3086
+ return a.id.localeCompare(b.id);
3087
+ });
3088
+ return {
3089
+ recommendations: all.slice(0, effectiveCount),
3090
+ totalCandidates: all.length
3091
+ };
3092
+ }
3093
+ function generateValidationSuggestions(state) {
3094
+ const result = validateProject(state);
3095
+ if (result.errorCount === 0) return [];
3096
+ return [
3097
+ {
3098
+ id: "validate",
3099
+ kind: "action",
3100
+ title: "Run claudestory validate",
3101
+ category: "validation_errors",
3102
+ reason: `${result.errorCount} validation error${result.errorCount === 1 ? "" : "s"} \u2014 fix before other work`,
3103
+ score: 1e3
3104
+ }
3105
+ ];
3106
+ }
3107
+ function generateCriticalIssues(state) {
3108
+ const issues = state.issues.filter(
3109
+ (i) => i.status !== "resolved" && (i.severity === "critical" || i.severity === "high")
3110
+ ).sort((a, b) => {
3111
+ const sevDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
3112
+ if (sevDiff !== 0) return sevDiff;
3113
+ return b.discoveredDate.localeCompare(a.discoveredDate);
3114
+ });
3115
+ return issues.map((issue, index) => ({
3116
+ id: issue.id,
3117
+ kind: "issue",
3118
+ title: issue.title,
3119
+ category: "critical_issue",
3120
+ reason: issue.status === "inprogress" ? `${capitalize(issue.severity)} severity issue \u2014 in-progress, ensure it's being addressed` : `${capitalize(issue.severity)} severity issue \u2014 address before new features`,
3121
+ score: 900 - Math.min(index, 99)
3122
+ }));
3123
+ }
3124
+ function generateInProgressTickets(state, phaseIndex) {
3125
+ const tickets = state.leafTickets.filter(
3126
+ (t) => t.status === "inprogress"
3127
+ );
3128
+ const sorted = sortByPhaseAndOrder(tickets, phaseIndex);
3129
+ return sorted.map((ticket, index) => ({
3130
+ id: ticket.id,
3131
+ kind: "ticket",
3132
+ title: ticket.title,
3133
+ category: "inprogress_ticket",
3134
+ reason: "In-progress \u2014 finish what's started",
3135
+ score: 800 - Math.min(index, 99)
3136
+ }));
3137
+ }
3138
+ function generateHighImpactUnblocks(state) {
3139
+ const candidates = [];
3140
+ for (const ticket of state.leafTickets) {
3141
+ if (ticket.status === "complete") continue;
3142
+ if (state.isBlocked(ticket)) continue;
3143
+ const wouldUnblock = ticketsUnblockedBy(ticket.id, state);
3144
+ if (wouldUnblock.length >= 2) {
3145
+ candidates.push({ ticket, unblockCount: wouldUnblock.length });
3146
+ }
3147
+ }
3148
+ candidates.sort((a, b) => b.unblockCount - a.unblockCount);
3149
+ return candidates.map(({ ticket, unblockCount }, index) => ({
3150
+ id: ticket.id,
3151
+ kind: "ticket",
3152
+ title: ticket.title,
3153
+ category: "high_impact_unblock",
3154
+ reason: `Completing this unblocks ${unblockCount} other ticket${unblockCount === 1 ? "" : "s"}`,
3155
+ score: 700 - Math.min(index, 99)
3156
+ }));
3157
+ }
3158
+ function generateNearCompleteUmbrellas(state, phaseIndex) {
3159
+ const candidates = [];
3160
+ for (const umbrellaId of state.umbrellaIDs) {
3161
+ const progress = umbrellaProgress(umbrellaId, state);
3162
+ if (!progress) continue;
3163
+ if (progress.total < 2) continue;
3164
+ if (progress.status === "complete") continue;
3165
+ const ratio = progress.complete / progress.total;
3166
+ if (ratio < 0.8) continue;
3167
+ const leaves = descendantLeaves(umbrellaId, state);
3168
+ const incomplete = leaves.filter((t) => t.status !== "complete");
3169
+ const sorted = sortByPhaseAndOrder(incomplete, phaseIndex);
3170
+ if (sorted.length === 0) continue;
3171
+ const umbrella = state.ticketByID(umbrellaId);
3172
+ candidates.push({
3173
+ umbrellaId,
3174
+ umbrellaTitle: umbrella?.title ?? umbrellaId,
3175
+ firstIncompleteLeaf: sorted[0],
3176
+ complete: progress.complete,
3177
+ total: progress.total,
3178
+ ratio
3179
+ });
3180
+ }
3181
+ candidates.sort((a, b) => b.ratio - a.ratio);
3182
+ return candidates.map((c, index) => ({
3183
+ id: c.firstIncompleteLeaf.id,
3184
+ kind: "ticket",
3185
+ title: c.firstIncompleteLeaf.title,
3186
+ category: "near_complete_umbrella",
3187
+ reason: `${c.complete}/${c.total} complete in umbrella ${c.umbrellaId} \u2014 close it out`,
3188
+ score: 600 - Math.min(index, 99)
3189
+ }));
3190
+ }
3191
+ function generatePhaseMomentum(state) {
3192
+ const outcome = nextTicket(state);
3193
+ if (outcome.kind !== "found") return [];
3194
+ const ticket = outcome.ticket;
3195
+ return [
3196
+ {
3197
+ id: ticket.id,
3198
+ kind: "ticket",
3199
+ title: ticket.title,
3200
+ category: "phase_momentum",
3201
+ reason: `Next in phase order (${ticket.phase ?? "none"})`,
3202
+ score: 500
3203
+ }
3204
+ ];
3205
+ }
3206
+ function generateQuickWins(state, phaseIndex) {
3207
+ const tickets = state.leafTickets.filter(
3208
+ (t) => t.status === "open" && t.type === "chore" && !state.isBlocked(t)
3209
+ );
3210
+ const sorted = sortByPhaseAndOrder(tickets, phaseIndex);
3211
+ return sorted.map((ticket, index) => ({
3212
+ id: ticket.id,
3213
+ kind: "ticket",
3214
+ title: ticket.title,
3215
+ category: "quick_win",
3216
+ reason: "Chore \u2014 quick win",
3217
+ score: 400 - Math.min(index, 99)
3218
+ }));
3219
+ }
3220
+ function generateOpenIssues(state) {
3221
+ const issues = state.issues.filter(
3222
+ (i) => i.status !== "resolved" && (i.severity === "medium" || i.severity === "low")
3223
+ ).sort((a, b) => {
3224
+ const sevDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
3225
+ if (sevDiff !== 0) return sevDiff;
3226
+ return b.discoveredDate.localeCompare(a.discoveredDate);
3227
+ });
3228
+ return issues.map((issue, index) => ({
3229
+ id: issue.id,
3230
+ kind: "issue",
3231
+ title: issue.title,
3232
+ category: "open_issue",
3233
+ reason: issue.status === "inprogress" ? `${capitalize(issue.severity)} severity issue \u2014 in-progress` : `${capitalize(issue.severity)} severity issue`,
3234
+ score: 300 - Math.min(index, 99)
3235
+ }));
3236
+ }
3237
+ function capitalize(s) {
3238
+ return s.charAt(0).toUpperCase() + s.slice(1);
3239
+ }
3240
+ function buildPhaseIndex(state) {
3241
+ const index = /* @__PURE__ */ new Map();
3242
+ state.roadmap.phases.forEach((p, i) => index.set(p.id, i));
3243
+ return index;
3244
+ }
3245
+ function sortByPhaseAndOrder(tickets, phaseIndex) {
3246
+ return [...tickets].sort((a, b) => {
3247
+ const aPhase = (a.phase != null ? phaseIndex.get(a.phase) : void 0) ?? Number.MAX_SAFE_INTEGER;
3248
+ const bPhase = (b.phase != null ? phaseIndex.get(b.phase) : void 0) ?? Number.MAX_SAFE_INTEGER;
3249
+ if (aPhase !== bPhase) return aPhase - bPhase;
3250
+ return a.order - b.order;
3251
+ });
3252
+ }
3253
+
3254
+ // src/cli/commands/recommend.ts
3255
+ function handleRecommend(ctx, count) {
3256
+ const result = recommend(ctx.state, count);
3257
+ return { output: formatRecommendations(result, ctx.format) };
3258
+ }
3259
+
2213
3260
  // src/cli/commands/snapshot.ts
2214
- async function handleSnapshot(root, format) {
3261
+ async function handleSnapshot(root, format, options) {
2215
3262
  let result;
2216
3263
  await withProjectLock(root, { strict: false }, async (loadResult) => {
2217
3264
  result = await saveSnapshot(root, loadResult);
2218
3265
  });
3266
+ if (!result) {
3267
+ throw new Error("snapshot: withProjectLock completed without setting result");
3268
+ }
3269
+ if (options?.quiet) {
3270
+ return { output: "" };
3271
+ }
2219
3272
  return { output: formatSnapshotResult(result, format) };
2220
3273
  }
2221
3274
 
@@ -2355,8 +3408,14 @@ function registerAllTools(server, pinnedRoot) {
2355
3408
  description: "First non-complete phase with its description"
2356
3409
  }, () => runMcpReadTool(pinnedRoot, handlePhaseCurrent));
2357
3410
  server.registerTool("claudestory_ticket_next", {
2358
- description: "Highest-priority unblocked ticket with unblock impact and umbrella progress"
2359
- }, () => runMcpReadTool(pinnedRoot, handleTicketNext));
3411
+ description: "Highest-priority unblocked ticket(s) with unblock impact and umbrella progress",
3412
+ inputSchema: {
3413
+ count: z8.number().int().min(1).max(10).optional().describe("Number of candidates to return (default: 1)")
3414
+ }
3415
+ }, (args) => runMcpReadTool(
3416
+ pinnedRoot,
3417
+ (ctx) => handleTicketNext(ctx, args.count ?? 1)
3418
+ ));
2360
3419
  server.registerTool("claudestory_ticket_blocked", {
2361
3420
  description: "All blocked tickets with their blocking dependencies"
2362
3421
  }, () => runMcpReadTool(pinnedRoot, handleTicketBlocked));
@@ -2364,8 +3423,14 @@ function registerAllTools(server, pinnedRoot) {
2364
3423
  description: "List handover filenames (newest first)"
2365
3424
  }, () => runMcpReadTool(pinnedRoot, handleHandoverList));
2366
3425
  server.registerTool("claudestory_handover_latest", {
2367
- description: "Content of the most recent handover document"
2368
- }, () => runMcpReadTool(pinnedRoot, handleHandoverLatest));
3426
+ description: "Content of the most recent handover document(s)",
3427
+ inputSchema: {
3428
+ count: z8.number().int().min(1).max(10).optional().describe("Number of recent handovers to return (default: 1)")
3429
+ }
3430
+ }, (args) => runMcpReadTool(
3431
+ pinnedRoot,
3432
+ (ctx) => handleHandoverLatest(ctx, args.count ?? 1)
3433
+ ));
2369
3434
  server.registerTool("claudestory_blocker_list", {
2370
3435
  description: "All roadmap blockers with dates and status"
2371
3436
  }, () => runMcpReadTool(pinnedRoot, handleBlockerList));
@@ -2375,7 +3440,7 @@ function registerAllTools(server, pinnedRoot) {
2375
3440
  server.registerTool("claudestory_phase_tickets", {
2376
3441
  description: "Leaf tickets for a specific phase, sorted by order",
2377
3442
  inputSchema: {
2378
- phaseId: z7.string().describe("Phase ID (e.g. p5b, dogfood)")
3443
+ phaseId: z8.string().describe("Phase ID (e.g. p5b, dogfood)")
2379
3444
  }
2380
3445
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => {
2381
3446
  const phaseExists = ctx.state.roadmap.phases.some((p) => p.id === args.phaseId);
@@ -2391,9 +3456,9 @@ function registerAllTools(server, pinnedRoot) {
2391
3456
  server.registerTool("claudestory_ticket_list", {
2392
3457
  description: "List leaf tickets with optional filters",
2393
3458
  inputSchema: {
2394
- status: z7.enum(TICKET_STATUSES).optional().describe("Filter by status: open, inprogress, complete"),
2395
- phase: z7.string().optional().describe("Filter by phase ID"),
2396
- type: z7.enum(TICKET_TYPES).optional().describe("Filter by type: task, feature, chore")
3459
+ status: z8.enum(TICKET_STATUSES).optional().describe("Filter by status: open, inprogress, complete"),
3460
+ phase: z8.string().optional().describe("Filter by phase ID"),
3461
+ type: z8.enum(TICKET_TYPES).optional().describe("Filter by type: task, feature, chore")
2397
3462
  }
2398
3463
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => {
2399
3464
  if (args.phase) {
@@ -2414,42 +3479,52 @@ function registerAllTools(server, pinnedRoot) {
2414
3479
  server.registerTool("claudestory_ticket_get", {
2415
3480
  description: "Get a ticket by ID (includes umbrella tickets)",
2416
3481
  inputSchema: {
2417
- id: z7.string().regex(TICKET_ID_REGEX).describe("Ticket ID (e.g. T-001, T-079b)")
3482
+ id: z8.string().regex(TICKET_ID_REGEX).describe("Ticket ID (e.g. T-001, T-079b)")
2418
3483
  }
2419
3484
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleTicketGet(args.id, ctx)));
2420
3485
  server.registerTool("claudestory_issue_list", {
2421
3486
  description: "List issues with optional filters",
2422
3487
  inputSchema: {
2423
- status: z7.enum(ISSUE_STATUSES).optional().describe("Filter by status: open, inprogress, resolved"),
2424
- severity: z7.enum(ISSUE_SEVERITIES).optional().describe("Filter by severity: critical, high, medium, low")
3488
+ status: z8.enum(ISSUE_STATUSES).optional().describe("Filter by status: open, inprogress, resolved"),
3489
+ severity: z8.enum(ISSUE_SEVERITIES).optional().describe("Filter by severity: critical, high, medium, low"),
3490
+ component: z8.string().optional().describe("Filter by component name")
2425
3491
  }
2426
3492
  }, (args) => runMcpReadTool(
2427
3493
  pinnedRoot,
2428
- (ctx) => handleIssueList({ status: args.status, severity: args.severity }, ctx)
3494
+ (ctx) => handleIssueList({ status: args.status, severity: args.severity, component: args.component }, ctx)
2429
3495
  ));
2430
3496
  server.registerTool("claudestory_issue_get", {
2431
3497
  description: "Get an issue by ID",
2432
3498
  inputSchema: {
2433
- id: z7.string().regex(ISSUE_ID_REGEX).describe("Issue ID (e.g. ISS-001)")
3499
+ id: z8.string().regex(ISSUE_ID_REGEX).describe("Issue ID (e.g. ISS-001)")
2434
3500
  }
2435
3501
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleIssueGet(args.id, ctx)));
2436
3502
  server.registerTool("claudestory_handover_get", {
2437
3503
  description: "Content of a specific handover document by filename",
2438
3504
  inputSchema: {
2439
- filename: z7.string().describe("Handover filename (e.g. 2026-03-20-session.md)")
3505
+ filename: z8.string().describe("Handover filename (e.g. 2026-03-20-session.md)")
2440
3506
  }
2441
3507
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleHandoverGet(args.filename, ctx)));
2442
3508
  server.registerTool("claudestory_recap", {
2443
3509
  description: "Session diff \u2014 changes since last snapshot + suggested next actions. Shows what changed and what to work on."
2444
3510
  }, () => runMcpReadTool(pinnedRoot, handleRecap));
3511
+ server.registerTool("claudestory_recommend", {
3512
+ description: "Context-aware ranked work suggestions mixing tickets and issues",
3513
+ inputSchema: {
3514
+ count: z8.number().int().min(1).max(10).optional().describe("Number of recommendations (default: 5)")
3515
+ }
3516
+ }, (args) => runMcpReadTool(
3517
+ pinnedRoot,
3518
+ (ctx) => handleRecommend(ctx, args.count ?? 5)
3519
+ ));
2445
3520
  server.registerTool("claudestory_snapshot", {
2446
3521
  description: "Save current project state for session diffs. Creates a snapshot in .story/snapshots/."
2447
3522
  }, () => runMcpWriteTool(pinnedRoot, handleSnapshot));
2448
3523
  server.registerTool("claudestory_export", {
2449
3524
  description: "Self-contained project document for sharing",
2450
3525
  inputSchema: {
2451
- phase: z7.string().optional().describe("Export a single phase by ID"),
2452
- all: z7.boolean().optional().describe("Export entire project")
3526
+ phase: z8.string().optional().describe("Export a single phase by ID"),
3527
+ all: z8.boolean().optional().describe("Export entire project")
2453
3528
  }
2454
3529
  }, (args) => {
2455
3530
  if (!args.phase && !args.all) {
@@ -2471,8 +3546,8 @@ function registerAllTools(server, pinnedRoot) {
2471
3546
  server.registerTool("claudestory_handover_create", {
2472
3547
  description: "Create a handover document from markdown content",
2473
3548
  inputSchema: {
2474
- content: z7.string().describe("Markdown content of the handover"),
2475
- slug: z7.string().optional().describe("Slug for filename (e.g. phase5b-wrapup). Default: session")
3549
+ content: z8.string().describe("Markdown content of the handover"),
3550
+ slug: z8.string().optional().describe("Slug for filename (e.g. phase5b-wrapup). Default: session")
2476
3551
  }
2477
3552
  }, (args) => {
2478
3553
  if (!args.content?.trim()) {
@@ -2486,12 +3561,183 @@ function registerAllTools(server, pinnedRoot) {
2486
3561
  (root) => handleHandoverCreate(args.content, args.slug ?? "session", "md", root)
2487
3562
  );
2488
3563
  });
3564
+ server.registerTool("claudestory_ticket_create", {
3565
+ description: "Create a new ticket",
3566
+ inputSchema: {
3567
+ title: z8.string().describe("Ticket title"),
3568
+ type: z8.enum(TICKET_TYPES).describe("Ticket type: task, feature, chore"),
3569
+ phase: z8.string().optional().describe("Phase ID"),
3570
+ description: z8.string().optional().describe("Ticket description"),
3571
+ blockedBy: z8.array(z8.string().regex(TICKET_ID_REGEX)).optional().describe("IDs of blocking tickets"),
3572
+ parentTicket: z8.string().regex(TICKET_ID_REGEX).optional().describe("Parent ticket ID (makes this a sub-ticket)")
3573
+ }
3574
+ }, (args) => runMcpWriteTool(
3575
+ pinnedRoot,
3576
+ (root, format) => handleTicketCreate(
3577
+ {
3578
+ title: args.title,
3579
+ type: args.type,
3580
+ phase: args.phase ?? null,
3581
+ description: args.description ?? "",
3582
+ blockedBy: args.blockedBy ?? [],
3583
+ parentTicket: args.parentTicket ?? null
3584
+ },
3585
+ format,
3586
+ root
3587
+ )
3588
+ ));
3589
+ server.registerTool("claudestory_ticket_update", {
3590
+ description: "Update an existing ticket",
3591
+ inputSchema: {
3592
+ id: z8.string().regex(TICKET_ID_REGEX).describe("Ticket ID (e.g. T-001)"),
3593
+ status: z8.enum(TICKET_STATUSES).optional().describe("New status: open, inprogress, complete"),
3594
+ title: z8.string().optional().describe("New title"),
3595
+ order: z8.number().optional().describe("New sort order"),
3596
+ description: z8.string().optional().describe("New description"),
3597
+ phase: z8.string().nullable().optional().describe("New phase ID (null to clear)"),
3598
+ parentTicket: z8.string().regex(TICKET_ID_REGEX).nullable().optional().describe("Parent ticket ID (null to clear)"),
3599
+ blockedBy: z8.array(z8.string().regex(TICKET_ID_REGEX)).optional().describe("IDs of blocking tickets")
3600
+ }
3601
+ }, (args) => runMcpWriteTool(
3602
+ pinnedRoot,
3603
+ (root, format) => handleTicketUpdate(
3604
+ args.id,
3605
+ {
3606
+ status: args.status,
3607
+ title: args.title,
3608
+ order: args.order,
3609
+ description: args.description,
3610
+ phase: args.phase,
3611
+ parentTicket: args.parentTicket,
3612
+ blockedBy: args.blockedBy
3613
+ },
3614
+ format,
3615
+ root
3616
+ )
3617
+ ));
3618
+ server.registerTool("claudestory_issue_create", {
3619
+ description: "Create a new issue",
3620
+ inputSchema: {
3621
+ title: z8.string().describe("Issue title"),
3622
+ severity: z8.enum(ISSUE_SEVERITIES).describe("Issue severity: critical, high, medium, low"),
3623
+ impact: z8.string().describe("Impact description"),
3624
+ components: z8.array(z8.string()).optional().describe("Affected components"),
3625
+ relatedTickets: z8.array(z8.string().regex(TICKET_ID_REGEX)).optional().describe("Related ticket IDs"),
3626
+ location: z8.array(z8.string()).optional().describe("File locations"),
3627
+ phase: z8.string().optional().describe("Phase ID")
3628
+ }
3629
+ }, (args) => runMcpWriteTool(
3630
+ pinnedRoot,
3631
+ (root, format) => handleIssueCreate(
3632
+ {
3633
+ title: args.title,
3634
+ severity: args.severity,
3635
+ impact: args.impact,
3636
+ components: args.components ?? [],
3637
+ relatedTickets: args.relatedTickets ?? [],
3638
+ location: args.location ?? [],
3639
+ phase: args.phase
3640
+ },
3641
+ format,
3642
+ root
3643
+ )
3644
+ ));
3645
+ server.registerTool("claudestory_issue_update", {
3646
+ description: "Update an existing issue",
3647
+ inputSchema: {
3648
+ id: z8.string().regex(ISSUE_ID_REGEX).describe("Issue ID (e.g. ISS-001)"),
3649
+ status: z8.enum(ISSUE_STATUSES).optional().describe("New status: open, inprogress, resolved"),
3650
+ title: z8.string().optional().describe("New title"),
3651
+ severity: z8.enum(ISSUE_SEVERITIES).optional().describe("New severity"),
3652
+ impact: z8.string().optional().describe("New impact description"),
3653
+ resolution: z8.string().nullable().optional().describe("Resolution description (null to clear)"),
3654
+ components: z8.array(z8.string()).optional().describe("Affected components"),
3655
+ relatedTickets: z8.array(z8.string().regex(TICKET_ID_REGEX)).optional().describe("Related ticket IDs"),
3656
+ location: z8.array(z8.string()).optional().describe("File locations")
3657
+ }
3658
+ }, (args) => runMcpWriteTool(
3659
+ pinnedRoot,
3660
+ (root, format) => handleIssueUpdate(
3661
+ args.id,
3662
+ {
3663
+ status: args.status,
3664
+ title: args.title,
3665
+ severity: args.severity,
3666
+ impact: args.impact,
3667
+ resolution: args.resolution,
3668
+ components: args.components,
3669
+ relatedTickets: args.relatedTickets,
3670
+ location: args.location
3671
+ },
3672
+ format,
3673
+ root
3674
+ )
3675
+ ));
3676
+ server.registerTool("claudestory_note_list", {
3677
+ description: "List notes with optional status/tag filters",
3678
+ inputSchema: {
3679
+ status: z8.enum(NOTE_STATUSES).optional().describe("Filter by status: active, archived"),
3680
+ tag: z8.string().optional().describe("Filter by tag")
3681
+ }
3682
+ }, (args) => runMcpReadTool(
3683
+ pinnedRoot,
3684
+ (ctx) => handleNoteList({ status: args.status, tag: args.tag }, ctx)
3685
+ ));
3686
+ server.registerTool("claudestory_note_get", {
3687
+ description: "Get a note by ID",
3688
+ inputSchema: {
3689
+ id: z8.string().regex(NOTE_ID_REGEX).describe("Note ID (e.g. N-001)")
3690
+ }
3691
+ }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleNoteGet(args.id, ctx)));
3692
+ server.registerTool("claudestory_note_create", {
3693
+ description: "Create a new note",
3694
+ inputSchema: {
3695
+ content: z8.string().describe("Note content"),
3696
+ title: z8.string().optional().describe("Note title"),
3697
+ tags: z8.array(z8.string()).optional().describe("Tags for the note")
3698
+ }
3699
+ }, (args) => runMcpWriteTool(
3700
+ pinnedRoot,
3701
+ (root, format) => handleNoteCreate(
3702
+ {
3703
+ content: args.content,
3704
+ title: args.title ?? null,
3705
+ tags: args.tags ?? []
3706
+ },
3707
+ format,
3708
+ root
3709
+ )
3710
+ ));
3711
+ server.registerTool("claudestory_note_update", {
3712
+ description: "Update an existing note",
3713
+ inputSchema: {
3714
+ id: z8.string().regex(NOTE_ID_REGEX).describe("Note ID (e.g. N-001)"),
3715
+ content: z8.string().optional().describe("New content"),
3716
+ title: z8.string().nullable().optional().describe("New title (null to clear)"),
3717
+ tags: z8.array(z8.string()).optional().describe("New tags (replaces existing)"),
3718
+ status: z8.enum(NOTE_STATUSES).optional().describe("New status: active, archived")
3719
+ }
3720
+ }, (args) => runMcpWriteTool(
3721
+ pinnedRoot,
3722
+ (root, format) => handleNoteUpdate(
3723
+ args.id,
3724
+ {
3725
+ content: args.content,
3726
+ title: args.title,
3727
+ tags: args.tags,
3728
+ clearTags: args.tags !== void 0 && args.tags.length === 0,
3729
+ status: args.status
3730
+ },
3731
+ format,
3732
+ root
3733
+ )
3734
+ ));
2489
3735
  }
2490
3736
 
2491
3737
  // src/mcp/index.ts
2492
3738
  var ENV_VAR2 = "CLAUDESTORY_PROJECT_ROOT";
2493
3739
  var CONFIG_PATH2 = ".story/config.json";
2494
- var version = "0.1.8";
3740
+ var version = "0.1.9";
2495
3741
  function tryDiscoverRoot() {
2496
3742
  const envRoot = process.env[ENV_VAR2];
2497
3743
  if (envRoot) {
@@ -2503,7 +3749,7 @@ function tryDiscoverRoot() {
2503
3749
  const resolved = resolve7(envRoot);
2504
3750
  try {
2505
3751
  const canonical = realpathSync(resolved);
2506
- if (existsSync5(join8(canonical, CONFIG_PATH2))) {
3752
+ if (existsSync6(join8(canonical, CONFIG_PATH2))) {
2507
3753
  return canonical;
2508
3754
  }
2509
3755
  process.stderr.write(`Warning: No .story/config.json at ${canonical}