@anthropologies/claudestory 0.1.7 → 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/index.js CHANGED
@@ -9,6 +9,9 @@ var TICKET_STATUSES = ["open", "inprogress", "complete"];
9
9
  var TICKET_TYPES = ["task", "feature", "chore"];
10
10
  var ISSUE_STATUSES = ["open", "inprogress", "resolved"];
11
11
  var ISSUE_SEVERITIES = ["critical", "high", "medium", "low"];
12
+ var NOTE_STATUSES = ["active", "archived"];
13
+ var NOTE_ID_REGEX = /^N-\d+$/;
14
+ var NoteIdSchema = z.string().regex(NOTE_ID_REGEX, "Note ID must match N-NNN");
12
15
  var OUTPUT_FORMATS = ["json", "md"];
13
16
  var ERROR_CODES = [
14
17
  "not_found",
@@ -72,47 +75,65 @@ var IssueSchema = z3.object({
72
75
  lastModifiedBy: z3.string().nullable().optional()
73
76
  }).passthrough();
74
77
 
75
- // src/models/roadmap.ts
78
+ // src/models/note.ts
76
79
  import { z as z4 } from "zod";
77
- var BlockerSchema = z4.object({
78
- name: z4.string().min(1),
80
+ var NoteSchema = z4.object({
81
+ id: NoteIdSchema,
82
+ title: z4.preprocess((v) => v ?? null, z4.string().nullable()),
83
+ content: z4.string().refine((v) => v.trim().length > 0, "Content cannot be empty"),
84
+ tags: z4.preprocess(
85
+ (v) => {
86
+ const raw = Array.isArray(v) ? v : [];
87
+ 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);
88
+ },
89
+ z4.array(z4.string())
90
+ ),
91
+ status: z4.enum(NOTE_STATUSES),
92
+ createdDate: DateSchema,
93
+ updatedDate: DateSchema
94
+ }).passthrough();
95
+
96
+ // src/models/roadmap.ts
97
+ import { z as z5 } from "zod";
98
+ var BlockerSchema = z5.object({
99
+ name: z5.string().min(1),
79
100
  // Legacy format (pre-T-082)
80
- cleared: z4.boolean().optional(),
101
+ cleared: z5.boolean().optional(),
81
102
  // New date-based format (T-082 migration)
82
103
  createdDate: DateSchema.optional(),
83
104
  clearedDate: DateSchema.nullable().optional(),
84
105
  // Present in all current data but optional for future minimal blockers
85
- note: z4.string().nullable().optional()
106
+ note: z5.string().nullable().optional()
86
107
  }).passthrough();
87
- var PhaseSchema = z4.object({
88
- id: z4.string().min(1),
89
- label: z4.string(),
90
- name: z4.string(),
91
- description: z4.string(),
92
- summary: z4.string().optional()
108
+ var PhaseSchema = z5.object({
109
+ id: z5.string().min(1),
110
+ label: z5.string(),
111
+ name: z5.string(),
112
+ description: z5.string(),
113
+ summary: z5.string().optional()
93
114
  }).passthrough();
94
- var RoadmapSchema = z4.object({
95
- title: z4.string(),
115
+ var RoadmapSchema = z5.object({
116
+ title: z5.string(),
96
117
  date: DateSchema,
97
- phases: z4.array(PhaseSchema),
98
- blockers: z4.array(BlockerSchema)
118
+ phases: z5.array(PhaseSchema),
119
+ blockers: z5.array(BlockerSchema)
99
120
  }).passthrough();
100
121
 
101
122
  // src/models/config.ts
102
- import { z as z5 } from "zod";
103
- var FeaturesSchema = z5.object({
104
- tickets: z5.boolean(),
105
- issues: z5.boolean(),
106
- handovers: z5.boolean(),
107
- roadmap: z5.boolean(),
108
- reviews: z5.boolean()
123
+ import { z as z6 } from "zod";
124
+ var FeaturesSchema = z6.object({
125
+ tickets: z6.boolean(),
126
+ issues: z6.boolean(),
127
+ handovers: z6.boolean(),
128
+ roadmap: z6.boolean(),
129
+ reviews: z6.boolean()
109
130
  }).passthrough();
110
- var ConfigSchema = z5.object({
111
- version: z5.number().int().min(1),
112
- schemaVersion: z5.number().int().optional(),
113
- project: z5.string().min(1),
114
- type: z5.string(),
115
- language: z5.string(),
131
+ var ConfigSchema = z6.object({
132
+ version: z6.number().int().min(1),
133
+ schemaVersion: z6.number().int().optional(),
134
+ project: z6.string().min(1),
135
+ type: z6.string(),
136
+ language: z6.string(),
116
137
  features: FeaturesSchema
117
138
  }).passthrough();
118
139
 
@@ -121,6 +142,7 @@ var ProjectState = class _ProjectState {
121
142
  // --- Public raw inputs (readonly) ---
122
143
  tickets;
123
144
  issues;
145
+ notes;
124
146
  roadmap;
125
147
  config;
126
148
  handoverFilenames;
@@ -135,15 +157,19 @@ var ProjectState = class _ProjectState {
135
157
  reverseBlocksMap;
136
158
  ticketsByID;
137
159
  issuesByID;
160
+ notesByID;
138
161
  // --- Counts ---
139
162
  totalTicketCount;
140
163
  openTicketCount;
141
164
  completeTicketCount;
142
165
  openIssueCount;
143
166
  issuesBySeverity;
167
+ activeNoteCount;
168
+ archivedNoteCount;
144
169
  constructor(input) {
145
170
  this.tickets = input.tickets;
146
171
  this.issues = input.issues;
172
+ this.notes = input.notes;
147
173
  this.roadmap = input.roadmap;
148
174
  this.config = input.config;
149
175
  this.handoverFilenames = input.handoverFilenames;
@@ -209,6 +235,11 @@ var ProjectState = class _ProjectState {
209
235
  iByID.set(i.id, i);
210
236
  }
211
237
  this.issuesByID = iByID;
238
+ const nByID = /* @__PURE__ */ new Map();
239
+ for (const n of input.notes) {
240
+ nByID.set(n.id, n);
241
+ }
242
+ this.notesByID = nByID;
212
243
  this.totalTicketCount = input.tickets.length;
213
244
  this.openTicketCount = input.tickets.filter(
214
245
  (t) => t.status !== "complete"
@@ -226,6 +257,12 @@ var ProjectState = class _ProjectState {
226
257
  }
227
258
  }
228
259
  this.issuesBySeverity = bySev;
260
+ this.activeNoteCount = input.notes.filter(
261
+ (n) => n.status === "active"
262
+ ).length;
263
+ this.archivedNoteCount = input.notes.filter(
264
+ (n) => n.status === "archived"
265
+ ).length;
229
266
  }
230
267
  // --- Query Methods ---
231
268
  isUmbrella(ticket) {
@@ -272,6 +309,9 @@ var ProjectState = class _ProjectState {
272
309
  issueByID(id) {
273
310
  return this.issuesByID.get(id);
274
311
  }
312
+ noteByID(id) {
313
+ return this.notesByID.get(id);
314
+ }
275
315
  // --- Deletion Safety ---
276
316
  /** IDs of tickets that list `ticketId` in their blockedBy. */
277
317
  ticketsBlocking(ticketId) {
@@ -331,7 +371,8 @@ import {
331
371
  stat,
332
372
  realpath,
333
373
  lstat,
334
- open
374
+ open,
375
+ mkdir
335
376
  } from "fs/promises";
336
377
  import { existsSync as existsSync2 } from "fs";
337
378
  import { join as join2, resolve, relative as relative2, extname as extname2, dirname, basename } from "path";
@@ -462,6 +503,12 @@ async function loadProject(root, options) {
462
503
  IssueSchema,
463
504
  warnings
464
505
  );
506
+ const notes = await loadDirectory(
507
+ join2(wrapDir, "notes"),
508
+ absRoot,
509
+ NoteSchema,
510
+ warnings
511
+ );
465
512
  const handoversDir = join2(wrapDir, "handovers");
466
513
  const handoverFilenames = await listHandovers(
467
514
  handoversDir,
@@ -482,6 +529,7 @@ async function loadProject(root, options) {
482
529
  const state = new ProjectState({
483
530
  tickets,
484
531
  issues,
532
+ notes,
485
533
  roadmap,
486
534
  config,
487
535
  handoverFilenames
@@ -620,6 +668,49 @@ async function deleteIssue(id, root) {
620
668
  await unlink(targetPath);
621
669
  });
622
670
  }
671
+ async function writeNoteUnlocked(note, root) {
672
+ const parsed = NoteSchema.parse(note);
673
+ if (!NOTE_ID_REGEX.test(parsed.id)) {
674
+ throw new ProjectLoaderError(
675
+ "invalid_input",
676
+ `Invalid note ID: ${parsed.id}`
677
+ );
678
+ }
679
+ const wrapDir = resolve(root, ".story");
680
+ const targetPath = join2(wrapDir, "notes", `${parsed.id}.json`);
681
+ await mkdir(dirname(targetPath), { recursive: true });
682
+ await guardPath(targetPath, wrapDir);
683
+ const json = serializeJSON(parsed);
684
+ await atomicWrite(targetPath, json);
685
+ }
686
+ async function writeNote(note, root) {
687
+ const wrapDir = resolve(root, ".story");
688
+ await withLock(wrapDir, async () => {
689
+ await writeNoteUnlocked(note, root);
690
+ });
691
+ }
692
+ async function deleteNote(id, root) {
693
+ if (!NOTE_ID_REGEX.test(id)) {
694
+ throw new ProjectLoaderError(
695
+ "invalid_input",
696
+ `Invalid note ID: ${id}`
697
+ );
698
+ }
699
+ const wrapDir = resolve(root, ".story");
700
+ const targetPath = join2(wrapDir, "notes", `${id}.json`);
701
+ await guardPath(targetPath, wrapDir);
702
+ await withLock(wrapDir, async () => {
703
+ try {
704
+ await stat(targetPath);
705
+ } catch {
706
+ throw new ProjectLoaderError(
707
+ "not_found",
708
+ `Note file not found: notes/${id}.json`
709
+ );
710
+ }
711
+ await unlink(targetPath);
712
+ });
713
+ }
623
714
  async function withProjectLock(root, options, handler) {
624
715
  const absRoot = resolve(root);
625
716
  const wrapDir = join2(absRoot, ".story");
@@ -783,8 +874,9 @@ async function loadProjectUnlocked(absRoot) {
783
874
  const warnings = [];
784
875
  const tickets = await loadDirectory(join2(wrapDir, "tickets"), absRoot, TicketSchema, warnings);
785
876
  const issues = await loadDirectory(join2(wrapDir, "issues"), absRoot, IssueSchema, warnings);
877
+ const notes = await loadDirectory(join2(wrapDir, "notes"), absRoot, NoteSchema, warnings);
786
878
  const handoverFilenames = await listHandovers(join2(wrapDir, "handovers"), absRoot, warnings);
787
- const state = new ProjectState({ tickets, issues, roadmap, config, handoverFilenames });
879
+ const state = new ProjectState({ tickets, issues, notes, roadmap, config, handoverFilenames });
788
880
  return { state, warnings };
789
881
  }
790
882
  async function loadSingletonFile(filename, wrapDir, root, schema) {
@@ -1045,6 +1137,53 @@ function nextTicket(state) {
1045
1137
  }
1046
1138
  return { kind: "empty_project" };
1047
1139
  }
1140
+ function nextTickets(state, count) {
1141
+ const effectiveCount = Math.max(1, count);
1142
+ const phases = state.roadmap.phases;
1143
+ if (phases.length === 0 || state.leafTickets.length === 0) {
1144
+ return { kind: "empty_project" };
1145
+ }
1146
+ const candidates = [];
1147
+ const skippedBlockedPhases = [];
1148
+ let allPhasesComplete = true;
1149
+ for (const phase of phases) {
1150
+ if (candidates.length >= effectiveCount) break;
1151
+ const leaves = state.phaseTickets(phase.id);
1152
+ if (leaves.length === 0) continue;
1153
+ const status = state.phaseStatus(phase.id);
1154
+ if (status === "complete") continue;
1155
+ allPhasesComplete = false;
1156
+ const incompleteLeaves = leaves.filter((t) => t.status !== "complete");
1157
+ const unblocked = incompleteLeaves.filter((t) => !state.isBlocked(t));
1158
+ if (unblocked.length === 0) {
1159
+ skippedBlockedPhases.push({
1160
+ phaseId: phase.id,
1161
+ blockedCount: incompleteLeaves.length
1162
+ });
1163
+ continue;
1164
+ }
1165
+ const remaining = effectiveCount - candidates.length;
1166
+ for (const ticket of unblocked.slice(0, remaining)) {
1167
+ const impact = ticketsUnblockedBy(ticket.id, state);
1168
+ const progress = ticket.parentTicket ? umbrellaProgress(ticket.parentTicket, state) : null;
1169
+ candidates.push({
1170
+ ticket,
1171
+ unblockImpact: { ticketId: ticket.id, wouldUnblock: impact },
1172
+ umbrellaProgress: progress
1173
+ });
1174
+ }
1175
+ }
1176
+ if (candidates.length > 0) {
1177
+ return { kind: "found", candidates, skippedBlockedPhases };
1178
+ }
1179
+ if (skippedBlockedPhases.length > 0) {
1180
+ return { kind: "all_blocked", phases: skippedBlockedPhases };
1181
+ }
1182
+ if (allPhasesComplete) {
1183
+ return { kind: "all_complete" };
1184
+ }
1185
+ return { kind: "empty_project" };
1186
+ }
1048
1187
  function blockedTickets(state) {
1049
1188
  return state.leafTickets.filter(
1050
1189
  (t) => t.status !== "complete" && state.isBlocked(t)
@@ -1092,6 +1231,9 @@ function isBlockerCleared(blocker) {
1092
1231
  if (blocker.clearedDate != null) return true;
1093
1232
  return false;
1094
1233
  }
1234
+ function descendantLeaves(ticketId, state) {
1235
+ return collectDescendantLeaves(ticketId, state, /* @__PURE__ */ new Set());
1236
+ }
1095
1237
  function collectDescendantLeaves(ticketId, state, visited) {
1096
1238
  if (visited.has(ticketId)) return [];
1097
1239
  visited.add(ticketId);
@@ -1107,39 +1249,6 @@ function collectDescendantLeaves(ticketId, state, visited) {
1107
1249
  return leaves;
1108
1250
  }
1109
1251
 
1110
- // src/core/id-allocation.ts
1111
- var TICKET_NUMERIC_REGEX = /^T-(\d+)[a-z]?$/;
1112
- var ISSUE_NUMERIC_REGEX = /^ISS-(\d+)$/;
1113
- function nextTicketID(tickets) {
1114
- let max = 0;
1115
- for (const t of tickets) {
1116
- if (!TICKET_ID_REGEX.test(t.id)) continue;
1117
- const match = t.id.match(TICKET_NUMERIC_REGEX);
1118
- if (match?.[1]) {
1119
- const num = parseInt(match[1], 10);
1120
- if (num > max) max = num;
1121
- }
1122
- }
1123
- return `T-${String(max + 1).padStart(3, "0")}`;
1124
- }
1125
- function nextIssueID(issues) {
1126
- let max = 0;
1127
- for (const i of issues) {
1128
- if (!ISSUE_ID_REGEX.test(i.id)) continue;
1129
- const match = i.id.match(ISSUE_NUMERIC_REGEX);
1130
- if (match?.[1]) {
1131
- const num = parseInt(match[1], 10);
1132
- if (num > max) max = num;
1133
- }
1134
- }
1135
- return `ISS-${String(max + 1).padStart(3, "0")}`;
1136
- }
1137
- function nextOrder(phaseId, state) {
1138
- const tickets = state.phaseTickets(phaseId);
1139
- if (tickets.length === 0) return 10;
1140
- return tickets[tickets.length - 1].order + 10;
1141
- }
1142
-
1143
1252
  // src/core/validation.ts
1144
1253
  function validateProject(state) {
1145
1254
  const findings = [];
@@ -1176,6 +1285,20 @@ function validateProject(state) {
1176
1285
  });
1177
1286
  }
1178
1287
  }
1288
+ const noteIDCounts = /* @__PURE__ */ new Map();
1289
+ for (const n of state.notes) {
1290
+ noteIDCounts.set(n.id, (noteIDCounts.get(n.id) ?? 0) + 1);
1291
+ }
1292
+ for (const [id, count] of noteIDCounts) {
1293
+ if (count > 1) {
1294
+ findings.push({
1295
+ level: "error",
1296
+ code: "duplicate_note_id",
1297
+ message: `Duplicate note ID: ${id} appears ${count} times.`,
1298
+ entity: id
1299
+ });
1300
+ }
1301
+ }
1179
1302
  const phaseIDCounts = /* @__PURE__ */ new Map();
1180
1303
  for (const p of state.roadmap.phases) {
1181
1304
  phaseIDCounts.set(p.id, (phaseIDCounts.get(p.id) ?? 0) + 1);
@@ -1381,8 +1504,285 @@ function dfsBlocked(id, state, visited, inStack, findings) {
1381
1504
  visited.add(id);
1382
1505
  }
1383
1506
 
1507
+ // src/core/recommend.ts
1508
+ var SEVERITY_RANK = {
1509
+ critical: 4,
1510
+ high: 3,
1511
+ medium: 2,
1512
+ low: 1
1513
+ };
1514
+ var PHASE_DISTANCE_PENALTY = 100;
1515
+ var MAX_PHASE_PENALTY = 400;
1516
+ var CATEGORY_PRIORITY = {
1517
+ validation_errors: 1,
1518
+ critical_issue: 2,
1519
+ inprogress_ticket: 3,
1520
+ high_impact_unblock: 4,
1521
+ near_complete_umbrella: 5,
1522
+ phase_momentum: 6,
1523
+ quick_win: 7,
1524
+ open_issue: 8
1525
+ };
1526
+ function recommend(state, count) {
1527
+ const effectiveCount = Math.max(1, Math.min(10, count));
1528
+ const dedup = /* @__PURE__ */ new Map();
1529
+ const phaseIndex = buildPhaseIndex(state);
1530
+ const generators = [
1531
+ () => generateValidationSuggestions(state),
1532
+ () => generateCriticalIssues(state),
1533
+ () => generateInProgressTickets(state, phaseIndex),
1534
+ () => generateHighImpactUnblocks(state),
1535
+ () => generateNearCompleteUmbrellas(state, phaseIndex),
1536
+ () => generatePhaseMomentum(state),
1537
+ () => generateQuickWins(state, phaseIndex),
1538
+ () => generateOpenIssues(state)
1539
+ ];
1540
+ for (const gen of generators) {
1541
+ for (const rec of gen()) {
1542
+ const existing = dedup.get(rec.id);
1543
+ if (!existing || rec.score > existing.score) {
1544
+ dedup.set(rec.id, rec);
1545
+ }
1546
+ }
1547
+ }
1548
+ const curPhase = currentPhase(state);
1549
+ const curPhaseIdx = curPhase ? phaseIndex.get(curPhase.id) ?? 0 : 0;
1550
+ for (const [id, rec] of dedup) {
1551
+ if (rec.kind !== "ticket") continue;
1552
+ const ticket = state.ticketByID(id);
1553
+ if (!ticket || ticket.phase == null) continue;
1554
+ const ticketPhaseIdx = phaseIndex.get(ticket.phase);
1555
+ if (ticketPhaseIdx === void 0) continue;
1556
+ const phasesAhead = ticketPhaseIdx - curPhaseIdx;
1557
+ if (phasesAhead > 0) {
1558
+ const penalty = Math.min(phasesAhead * PHASE_DISTANCE_PENALTY, MAX_PHASE_PENALTY);
1559
+ dedup.set(id, {
1560
+ ...rec,
1561
+ score: rec.score - penalty,
1562
+ reason: rec.reason + " (future phase)"
1563
+ });
1564
+ }
1565
+ }
1566
+ const all = [...dedup.values()].sort((a, b) => {
1567
+ if (b.score !== a.score) return b.score - a.score;
1568
+ const catDiff = CATEGORY_PRIORITY[a.category] - CATEGORY_PRIORITY[b.category];
1569
+ if (catDiff !== 0) return catDiff;
1570
+ return a.id.localeCompare(b.id);
1571
+ });
1572
+ return {
1573
+ recommendations: all.slice(0, effectiveCount),
1574
+ totalCandidates: all.length
1575
+ };
1576
+ }
1577
+ function generateValidationSuggestions(state) {
1578
+ const result = validateProject(state);
1579
+ if (result.errorCount === 0) return [];
1580
+ return [
1581
+ {
1582
+ id: "validate",
1583
+ kind: "action",
1584
+ title: "Run claudestory validate",
1585
+ category: "validation_errors",
1586
+ reason: `${result.errorCount} validation error${result.errorCount === 1 ? "" : "s"} \u2014 fix before other work`,
1587
+ score: 1e3
1588
+ }
1589
+ ];
1590
+ }
1591
+ function generateCriticalIssues(state) {
1592
+ const issues = state.issues.filter(
1593
+ (i) => i.status !== "resolved" && (i.severity === "critical" || i.severity === "high")
1594
+ ).sort((a, b) => {
1595
+ const sevDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
1596
+ if (sevDiff !== 0) return sevDiff;
1597
+ return b.discoveredDate.localeCompare(a.discoveredDate);
1598
+ });
1599
+ return issues.map((issue, index) => ({
1600
+ id: issue.id,
1601
+ kind: "issue",
1602
+ title: issue.title,
1603
+ category: "critical_issue",
1604
+ 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`,
1605
+ score: 900 - Math.min(index, 99)
1606
+ }));
1607
+ }
1608
+ function generateInProgressTickets(state, phaseIndex) {
1609
+ const tickets = state.leafTickets.filter(
1610
+ (t) => t.status === "inprogress"
1611
+ );
1612
+ const sorted = sortByPhaseAndOrder(tickets, phaseIndex);
1613
+ return sorted.map((ticket, index) => ({
1614
+ id: ticket.id,
1615
+ kind: "ticket",
1616
+ title: ticket.title,
1617
+ category: "inprogress_ticket",
1618
+ reason: "In-progress \u2014 finish what's started",
1619
+ score: 800 - Math.min(index, 99)
1620
+ }));
1621
+ }
1622
+ function generateHighImpactUnblocks(state) {
1623
+ const candidates = [];
1624
+ for (const ticket of state.leafTickets) {
1625
+ if (ticket.status === "complete") continue;
1626
+ if (state.isBlocked(ticket)) continue;
1627
+ const wouldUnblock = ticketsUnblockedBy(ticket.id, state);
1628
+ if (wouldUnblock.length >= 2) {
1629
+ candidates.push({ ticket, unblockCount: wouldUnblock.length });
1630
+ }
1631
+ }
1632
+ candidates.sort((a, b) => b.unblockCount - a.unblockCount);
1633
+ return candidates.map(({ ticket, unblockCount }, index) => ({
1634
+ id: ticket.id,
1635
+ kind: "ticket",
1636
+ title: ticket.title,
1637
+ category: "high_impact_unblock",
1638
+ reason: `Completing this unblocks ${unblockCount} other ticket${unblockCount === 1 ? "" : "s"}`,
1639
+ score: 700 - Math.min(index, 99)
1640
+ }));
1641
+ }
1642
+ function generateNearCompleteUmbrellas(state, phaseIndex) {
1643
+ const candidates = [];
1644
+ for (const umbrellaId of state.umbrellaIDs) {
1645
+ const progress = umbrellaProgress(umbrellaId, state);
1646
+ if (!progress) continue;
1647
+ if (progress.total < 2) continue;
1648
+ if (progress.status === "complete") continue;
1649
+ const ratio = progress.complete / progress.total;
1650
+ if (ratio < 0.8) continue;
1651
+ const leaves = descendantLeaves(umbrellaId, state);
1652
+ const incomplete = leaves.filter((t) => t.status !== "complete");
1653
+ const sorted = sortByPhaseAndOrder(incomplete, phaseIndex);
1654
+ if (sorted.length === 0) continue;
1655
+ const umbrella = state.ticketByID(umbrellaId);
1656
+ candidates.push({
1657
+ umbrellaId,
1658
+ umbrellaTitle: umbrella?.title ?? umbrellaId,
1659
+ firstIncompleteLeaf: sorted[0],
1660
+ complete: progress.complete,
1661
+ total: progress.total,
1662
+ ratio
1663
+ });
1664
+ }
1665
+ candidates.sort((a, b) => b.ratio - a.ratio);
1666
+ return candidates.map((c, index) => ({
1667
+ id: c.firstIncompleteLeaf.id,
1668
+ kind: "ticket",
1669
+ title: c.firstIncompleteLeaf.title,
1670
+ category: "near_complete_umbrella",
1671
+ reason: `${c.complete}/${c.total} complete in umbrella ${c.umbrellaId} \u2014 close it out`,
1672
+ score: 600 - Math.min(index, 99)
1673
+ }));
1674
+ }
1675
+ function generatePhaseMomentum(state) {
1676
+ const outcome = nextTicket(state);
1677
+ if (outcome.kind !== "found") return [];
1678
+ const ticket = outcome.ticket;
1679
+ return [
1680
+ {
1681
+ id: ticket.id,
1682
+ kind: "ticket",
1683
+ title: ticket.title,
1684
+ category: "phase_momentum",
1685
+ reason: `Next in phase order (${ticket.phase ?? "none"})`,
1686
+ score: 500
1687
+ }
1688
+ ];
1689
+ }
1690
+ function generateQuickWins(state, phaseIndex) {
1691
+ const tickets = state.leafTickets.filter(
1692
+ (t) => t.status === "open" && t.type === "chore" && !state.isBlocked(t)
1693
+ );
1694
+ const sorted = sortByPhaseAndOrder(tickets, phaseIndex);
1695
+ return sorted.map((ticket, index) => ({
1696
+ id: ticket.id,
1697
+ kind: "ticket",
1698
+ title: ticket.title,
1699
+ category: "quick_win",
1700
+ reason: "Chore \u2014 quick win",
1701
+ score: 400 - Math.min(index, 99)
1702
+ }));
1703
+ }
1704
+ function generateOpenIssues(state) {
1705
+ const issues = state.issues.filter(
1706
+ (i) => i.status !== "resolved" && (i.severity === "medium" || i.severity === "low")
1707
+ ).sort((a, b) => {
1708
+ const sevDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
1709
+ if (sevDiff !== 0) return sevDiff;
1710
+ return b.discoveredDate.localeCompare(a.discoveredDate);
1711
+ });
1712
+ return issues.map((issue, index) => ({
1713
+ id: issue.id,
1714
+ kind: "issue",
1715
+ title: issue.title,
1716
+ category: "open_issue",
1717
+ reason: issue.status === "inprogress" ? `${capitalize(issue.severity)} severity issue \u2014 in-progress` : `${capitalize(issue.severity)} severity issue`,
1718
+ score: 300 - Math.min(index, 99)
1719
+ }));
1720
+ }
1721
+ function capitalize(s) {
1722
+ return s.charAt(0).toUpperCase() + s.slice(1);
1723
+ }
1724
+ function buildPhaseIndex(state) {
1725
+ const index = /* @__PURE__ */ new Map();
1726
+ state.roadmap.phases.forEach((p, i) => index.set(p.id, i));
1727
+ return index;
1728
+ }
1729
+ function sortByPhaseAndOrder(tickets, phaseIndex) {
1730
+ return [...tickets].sort((a, b) => {
1731
+ const aPhase = (a.phase != null ? phaseIndex.get(a.phase) : void 0) ?? Number.MAX_SAFE_INTEGER;
1732
+ const bPhase = (b.phase != null ? phaseIndex.get(b.phase) : void 0) ?? Number.MAX_SAFE_INTEGER;
1733
+ if (aPhase !== bPhase) return aPhase - bPhase;
1734
+ return a.order - b.order;
1735
+ });
1736
+ }
1737
+
1738
+ // src/core/id-allocation.ts
1739
+ var TICKET_NUMERIC_REGEX = /^T-(\d+)[a-z]?$/;
1740
+ var ISSUE_NUMERIC_REGEX = /^ISS-(\d+)$/;
1741
+ var NOTE_NUMERIC_REGEX = /^N-(\d+)$/;
1742
+ function nextTicketID(tickets) {
1743
+ let max = 0;
1744
+ for (const t of tickets) {
1745
+ if (!TICKET_ID_REGEX.test(t.id)) continue;
1746
+ const match = t.id.match(TICKET_NUMERIC_REGEX);
1747
+ if (match?.[1]) {
1748
+ const num = parseInt(match[1], 10);
1749
+ if (num > max) max = num;
1750
+ }
1751
+ }
1752
+ return `T-${String(max + 1).padStart(3, "0")}`;
1753
+ }
1754
+ function nextIssueID(issues) {
1755
+ let max = 0;
1756
+ for (const i of issues) {
1757
+ if (!ISSUE_ID_REGEX.test(i.id)) continue;
1758
+ const match = i.id.match(ISSUE_NUMERIC_REGEX);
1759
+ if (match?.[1]) {
1760
+ const num = parseInt(match[1], 10);
1761
+ if (num > max) max = num;
1762
+ }
1763
+ }
1764
+ return `ISS-${String(max + 1).padStart(3, "0")}`;
1765
+ }
1766
+ function nextNoteID(notes) {
1767
+ let max = 0;
1768
+ for (const n of notes) {
1769
+ if (!NOTE_ID_REGEX.test(n.id)) continue;
1770
+ const match = n.id.match(NOTE_NUMERIC_REGEX);
1771
+ if (match?.[1]) {
1772
+ const num = parseInt(match[1], 10);
1773
+ if (num > max) max = num;
1774
+ }
1775
+ }
1776
+ return `N-${String(max + 1).padStart(3, "0")}`;
1777
+ }
1778
+ function nextOrder(phaseId, state) {
1779
+ const tickets = state.phaseTickets(phaseId);
1780
+ if (tickets.length === 0) return 10;
1781
+ return tickets[tickets.length - 1].order + 10;
1782
+ }
1783
+
1384
1784
  // src/core/init.ts
1385
- import { mkdir, stat as stat2 } from "fs/promises";
1785
+ import { mkdir as mkdir2, stat as stat2 } from "fs/promises";
1386
1786
  import { join as join4, resolve as resolve3 } from "path";
1387
1787
  async function initProject(root, options) {
1388
1788
  const absRoot = resolve3(root);
@@ -1406,9 +1806,18 @@ async function initProject(root, options) {
1406
1806
  ".story/ already exists. Use --force to overwrite config and roadmap."
1407
1807
  );
1408
1808
  }
1409
- await mkdir(join4(wrapDir, "tickets"), { recursive: true });
1410
- await mkdir(join4(wrapDir, "issues"), { recursive: true });
1411
- await mkdir(join4(wrapDir, "handovers"), { recursive: true });
1809
+ await mkdir2(join4(wrapDir, "tickets"), { recursive: true });
1810
+ await mkdir2(join4(wrapDir, "issues"), { recursive: true });
1811
+ await mkdir2(join4(wrapDir, "handovers"), { recursive: true });
1812
+ await mkdir2(join4(wrapDir, "notes"), { recursive: true });
1813
+ const created = [
1814
+ ".story/config.json",
1815
+ ".story/roadmap.json",
1816
+ ".story/tickets/",
1817
+ ".story/issues/",
1818
+ ".story/handovers/",
1819
+ ".story/notes/"
1820
+ ];
1412
1821
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1413
1822
  const config = {
1414
1823
  version: 2,
@@ -1453,42 +1862,37 @@ async function initProject(root, options) {
1453
1862
  }
1454
1863
  return {
1455
1864
  root: absRoot,
1456
- created: [
1457
- ".story/config.json",
1458
- ".story/roadmap.json",
1459
- ".story/tickets/",
1460
- ".story/issues/",
1461
- ".story/handovers/"
1462
- ],
1865
+ created,
1463
1866
  warnings
1464
1867
  };
1465
1868
  }
1466
1869
 
1467
1870
  // src/core/snapshot.ts
1468
- import { readdir as readdir3, readFile as readFile3, mkdir as mkdir2, unlink as unlink2 } from "fs/promises";
1871
+ import { readdir as readdir3, readFile as readFile3, mkdir as mkdir3, unlink as unlink2 } from "fs/promises";
1469
1872
  import { existsSync as existsSync4 } from "fs";
1470
1873
  import { join as join5, resolve as resolve4 } from "path";
1471
- import { z as z6 } from "zod";
1472
- var LoadWarningSchema = z6.object({
1473
- type: z6.string(),
1474
- file: z6.string(),
1475
- message: z6.string()
1874
+ import { z as z7 } from "zod";
1875
+ var LoadWarningSchema = z7.object({
1876
+ type: z7.string(),
1877
+ file: z7.string(),
1878
+ message: z7.string()
1476
1879
  });
1477
- var SnapshotV1Schema = z6.object({
1478
- version: z6.literal(1),
1479
- createdAt: z6.string().datetime({ offset: true }),
1480
- project: z6.string(),
1880
+ var SnapshotV1Schema = z7.object({
1881
+ version: z7.literal(1),
1882
+ createdAt: z7.string().datetime({ offset: true }),
1883
+ project: z7.string(),
1481
1884
  config: ConfigSchema,
1482
1885
  roadmap: RoadmapSchema,
1483
- tickets: z6.array(TicketSchema),
1484
- issues: z6.array(IssueSchema),
1485
- warnings: z6.array(LoadWarningSchema).optional()
1886
+ tickets: z7.array(TicketSchema),
1887
+ issues: z7.array(IssueSchema),
1888
+ notes: z7.array(NoteSchema).optional().default([]),
1889
+ warnings: z7.array(LoadWarningSchema).optional()
1486
1890
  });
1487
1891
  var MAX_SNAPSHOTS = 20;
1488
1892
  async function saveSnapshot(root, loadResult) {
1489
1893
  const absRoot = resolve4(root);
1490
1894
  const snapshotsDir = join5(absRoot, ".story", "snapshots");
1491
- await mkdir2(snapshotsDir, { recursive: true });
1895
+ await mkdir3(snapshotsDir, { recursive: true });
1492
1896
  const { state, warnings } = loadResult;
1493
1897
  const now = /* @__PURE__ */ new Date();
1494
1898
  const filename = formatSnapshotFilename(now);
@@ -1500,6 +1904,7 @@ async function saveSnapshot(root, loadResult) {
1500
1904
  roadmap: state.roadmap,
1501
1905
  tickets: [...state.tickets],
1502
1906
  issues: [...state.issues],
1907
+ notes: [...state.notes],
1503
1908
  ...warnings.length > 0 ? {
1504
1909
  warnings: warnings.map((w) => ({
1505
1910
  type: w.type,
@@ -1540,12 +1945,18 @@ function diffStates(snapshotState, currentState) {
1540
1945
  const ticketsAdded = [];
1541
1946
  const ticketsRemoved = [];
1542
1947
  const ticketsStatusChanged = [];
1948
+ const ticketsDescriptionChanged = [];
1543
1949
  for (const [id, cur] of curTickets) {
1544
1950
  const snap = snapTickets.get(id);
1545
1951
  if (!snap) {
1546
1952
  ticketsAdded.push({ id, title: cur.title });
1547
- } else if (snap.status !== cur.status) {
1548
- ticketsStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
1953
+ } else {
1954
+ if (snap.status !== cur.status) {
1955
+ ticketsStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
1956
+ }
1957
+ if (snap.description !== cur.description) {
1958
+ ticketsDescriptionChanged.push({ id, title: cur.title });
1959
+ }
1549
1960
  }
1550
1961
  }
1551
1962
  for (const [id, snap] of snapTickets) {
@@ -1558,15 +1969,21 @@ function diffStates(snapshotState, currentState) {
1558
1969
  const issuesAdded = [];
1559
1970
  const issuesResolved = [];
1560
1971
  const issuesStatusChanged = [];
1972
+ const issuesImpactChanged = [];
1561
1973
  for (const [id, cur] of curIssues) {
1562
1974
  const snap = snapIssues.get(id);
1563
1975
  if (!snap) {
1564
1976
  issuesAdded.push({ id, title: cur.title });
1565
- } else if (snap.status !== cur.status) {
1566
- if (cur.status === "resolved") {
1567
- issuesResolved.push({ id, title: cur.title });
1568
- } else {
1569
- issuesStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
1977
+ } else {
1978
+ if (snap.status !== cur.status) {
1979
+ if (cur.status === "resolved") {
1980
+ issuesResolved.push({ id, title: cur.title });
1981
+ } else {
1982
+ issuesStatusChanged.push({ id, title: cur.title, from: snap.status, to: cur.status });
1983
+ }
1984
+ }
1985
+ if (snap.impact !== cur.impact) {
1986
+ issuesImpactChanged.push({ id, title: cur.title });
1570
1987
  }
1571
1988
  }
1572
1989
  }
@@ -1615,11 +2032,37 @@ function diffStates(snapshotState, currentState) {
1615
2032
  phasesRemoved.push({ id, name: snapPhase.name });
1616
2033
  }
1617
2034
  }
2035
+ const snapNotes = new Map(snapshotState.notes.map((n) => [n.id, n]));
2036
+ const curNotes = new Map(currentState.notes.map((n) => [n.id, n]));
2037
+ const notesAdded = [];
2038
+ const notesRemoved = [];
2039
+ const notesUpdated = [];
2040
+ for (const [id, cur] of curNotes) {
2041
+ const snap = snapNotes.get(id);
2042
+ if (!snap) {
2043
+ notesAdded.push({ id, title: cur.title });
2044
+ } else {
2045
+ const changedFields = [];
2046
+ if (snap.title !== cur.title) changedFields.push("title");
2047
+ if (snap.content !== cur.content) changedFields.push("content");
2048
+ if (JSON.stringify([...snap.tags].sort()) !== JSON.stringify([...cur.tags].sort())) changedFields.push("tags");
2049
+ if (snap.status !== cur.status) changedFields.push("status");
2050
+ if (changedFields.length > 0) {
2051
+ notesUpdated.push({ id, title: cur.title, changedFields });
2052
+ }
2053
+ }
2054
+ }
2055
+ for (const [id, snap] of snapNotes) {
2056
+ if (!curNotes.has(id)) {
2057
+ notesRemoved.push({ id, title: snap.title });
2058
+ }
2059
+ }
1618
2060
  return {
1619
- tickets: { added: ticketsAdded, removed: ticketsRemoved, statusChanged: ticketsStatusChanged },
1620
- issues: { added: issuesAdded, resolved: issuesResolved, statusChanged: issuesStatusChanged },
2061
+ tickets: { added: ticketsAdded, removed: ticketsRemoved, statusChanged: ticketsStatusChanged, descriptionChanged: ticketsDescriptionChanged },
2062
+ issues: { added: issuesAdded, resolved: issuesResolved, statusChanged: issuesStatusChanged, impactChanged: issuesImpactChanged },
1621
2063
  blockers: { added: blockersAdded, cleared: blockersCleared },
1622
- phases: { added: phasesAdded, removed: phasesRemoved, statusChanged: phasesStatusChanged }
2064
+ phases: { added: phasesAdded, removed: phasesRemoved, statusChanged: phasesStatusChanged },
2065
+ notes: { added: notesAdded, removed: notesRemoved, updated: notesUpdated }
1623
2066
  };
1624
2067
  }
1625
2068
  function buildRecap(currentState, snapshotInfo) {
@@ -1644,6 +2087,7 @@ function buildRecap(currentState, snapshotInfo) {
1644
2087
  const snapshotState = new ProjectState({
1645
2088
  tickets: snapshot.tickets,
1646
2089
  issues: snapshot.issues,
2090
+ notes: snapshot.notes ?? [],
1647
2091
  roadmap: snapshot.roadmap,
1648
2092
  config: snapshot.config,
1649
2093
  handoverFilenames: []
@@ -1742,6 +2186,8 @@ function formatStatus(state, format) {
1742
2186
  openTickets: state.leafTicketCount - state.completeLeafTicketCount,
1743
2187
  blockedTickets: state.blockedCount,
1744
2188
  openIssues: state.openIssueCount,
2189
+ activeNotes: state.activeNoteCount,
2190
+ archivedNotes: state.archivedNoteCount,
1745
2191
  handovers: state.handoverFilenames.length,
1746
2192
  phases: phases.map((p) => ({
1747
2193
  id: p.phase.id,
@@ -1758,6 +2204,7 @@ function formatStatus(state, format) {
1758
2204
  "",
1759
2205
  `Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete, ${state.blockedCount} blocked`,
1760
2206
  `Issues: ${state.openIssueCount} open`,
2207
+ `Notes: ${state.activeNoteCount} active, ${state.archivedNoteCount} archived`,
1761
2208
  `Handovers: ${state.handoverFilenames.length}`,
1762
2209
  "",
1763
2210
  "## Phases",
@@ -1982,6 +2429,7 @@ function formatInitResult(result, format) {
1982
2429
  if (result.warnings.length > 0) {
1983
2430
  lines.push("", `Warning: ${result.warnings.length} corrupt file(s) found. Run \`claudestory validate\` to inspect.`);
1984
2431
  }
2432
+ lines.push("", "Tip: Run `claudestory setup-skill` to install the /story skill for Claude Code.");
1985
2433
  return lines.join("\n");
1986
2434
  }
1987
2435
  function formatHandoverList(filenames, format) {
@@ -2045,7 +2493,7 @@ function formatRecap(recap, state, format) {
2045
2493
  }
2046
2494
  }
2047
2495
  const ticketChanges = changes.tickets;
2048
- if (ticketChanges.added.length > 0 || ticketChanges.removed.length > 0 || ticketChanges.statusChanged.length > 0) {
2496
+ if (ticketChanges.added.length > 0 || ticketChanges.removed.length > 0 || ticketChanges.statusChanged.length > 0 || ticketChanges.descriptionChanged.length > 0) {
2049
2497
  lines.push("");
2050
2498
  lines.push("## Tickets");
2051
2499
  for (const t of ticketChanges.statusChanged) {
@@ -2057,9 +2505,12 @@ function formatRecap(recap, state, format) {
2057
2505
  for (const t of ticketChanges.removed) {
2058
2506
  lines.push(`- ${t.id}: ${escapeMarkdownInline(t.title)} \u2014 **removed**`);
2059
2507
  }
2508
+ for (const t of ticketChanges.descriptionChanged) {
2509
+ lines.push(`- ${t.id}: description updated`);
2510
+ }
2060
2511
  }
2061
2512
  const issueChanges = changes.issues;
2062
- if (issueChanges.added.length > 0 || issueChanges.resolved.length > 0 || issueChanges.statusChanged.length > 0) {
2513
+ if (issueChanges.added.length > 0 || issueChanges.resolved.length > 0 || issueChanges.statusChanged.length > 0 || issueChanges.impactChanged.length > 0) {
2063
2514
  lines.push("");
2064
2515
  lines.push("## Issues");
2065
2516
  for (const i of issueChanges.resolved) {
@@ -2071,6 +2522,9 @@ function formatRecap(recap, state, format) {
2071
2522
  for (const i of issueChanges.added) {
2072
2523
  lines.push(`- ${i.id}: ${escapeMarkdownInline(i.title)} \u2014 **new**`);
2073
2524
  }
2525
+ for (const i of issueChanges.impactChanged) {
2526
+ lines.push(`- ${i.id}: impact updated`);
2527
+ }
2074
2528
  }
2075
2529
  if (changes.blockers.added.length > 0 || changes.blockers.cleared.length > 0) {
2076
2530
  lines.push("");
@@ -2082,6 +2536,19 @@ function formatRecap(recap, state, format) {
2082
2536
  lines.push(`- ${escapeMarkdownInline(name)} \u2014 **new**`);
2083
2537
  }
2084
2538
  }
2539
+ if (changes.notes && (changes.notes.added.length > 0 || changes.notes.removed.length > 0 || changes.notes.updated.length > 0)) {
2540
+ lines.push("");
2541
+ lines.push("## Notes");
2542
+ for (const n of changes.notes.added) {
2543
+ lines.push(`- ${n.id}: added`);
2544
+ }
2545
+ for (const n of changes.notes.removed) {
2546
+ lines.push(`- ${n.id}: removed`);
2547
+ }
2548
+ for (const n of changes.notes.updated) {
2549
+ lines.push(`- ${n.id}: updated (${n.changedFields.join(", ")})`);
2550
+ }
2551
+ }
2085
2552
  }
2086
2553
  }
2087
2554
  const actions = recap.suggestedActions;
@@ -2220,6 +2687,12 @@ function formatFullExport(state, format) {
2220
2687
  severity: i.severity,
2221
2688
  status: i.status
2222
2689
  })),
2690
+ notes: state.notes.map((n) => ({
2691
+ id: n.id,
2692
+ title: n.title,
2693
+ status: n.status,
2694
+ tags: n.tags
2695
+ })),
2223
2696
  blockers: state.roadmap.blockers.map((b) => ({
2224
2697
  name: b.name,
2225
2698
  cleared: isBlockerCleared(b),
@@ -2235,6 +2708,7 @@ function formatFullExport(state, format) {
2235
2708
  lines.push("");
2236
2709
  lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete`);
2237
2710
  lines.push(`Issues: ${state.openIssueCount} open`);
2711
+ lines.push(`Notes: ${state.activeNoteCount} active, ${state.archivedNoteCount} archived`);
2238
2712
  lines.push("");
2239
2713
  lines.push("## Phases");
2240
2714
  for (const p of phases) {
@@ -2261,6 +2735,16 @@ function formatFullExport(state, format) {
2261
2735
  lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}${resolved}`);
2262
2736
  }
2263
2737
  }
2738
+ const activeNotes = state.notes.filter((n) => n.status === "active");
2739
+ if (activeNotes.length > 0) {
2740
+ lines.push("");
2741
+ lines.push("## Notes");
2742
+ for (const n of activeNotes) {
2743
+ const title = n.title ?? n.id;
2744
+ const tagInfo = n.tags.length > 0 ? ` (${n.tags.join(", ")})` : "";
2745
+ lines.push(`- ${n.id}: ${escapeMarkdownInline(title)}${tagInfo}`);
2746
+ }
2747
+ }
2264
2748
  const blockers = state.roadmap.blockers;
2265
2749
  if (blockers.length > 0) {
2266
2750
  lines.push("");
@@ -2273,7 +2757,7 @@ function formatFullExport(state, format) {
2273
2757
  return lines.join("\n");
2274
2758
  }
2275
2759
  function hasAnyChanges(diff) {
2276
- 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;
2760
+ 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;
2277
2761
  }
2278
2762
  function truncate(text, maxLen) {
2279
2763
  if (text.length <= maxLen) return text;
@@ -2284,6 +2768,29 @@ function formatTicketOneLiner(t, state) {
2284
2768
  const blocked = state.isBlocked(t) ? " [BLOCKED]" : "";
2285
2769
  return `${status} ${t.id}: ${escapeMarkdownInline(t.title)}${blocked}`;
2286
2770
  }
2771
+ function formatRecommendations(result, format) {
2772
+ if (format === "json") {
2773
+ return JSON.stringify(successEnvelope(result), null, 2);
2774
+ }
2775
+ if (result.recommendations.length === 0) {
2776
+ return "No recommendations \u2014 all work is complete or blocked.";
2777
+ }
2778
+ const lines = ["# Recommendations", ""];
2779
+ for (let i = 0; i < result.recommendations.length; i++) {
2780
+ const rec = result.recommendations[i];
2781
+ lines.push(
2782
+ `${i + 1}. **${escapeMarkdownInline(rec.id)}** (${rec.kind}) \u2014 ${escapeMarkdownInline(rec.title)}`
2783
+ );
2784
+ lines.push(` _${escapeMarkdownInline(rec.reason)}_`);
2785
+ lines.push("");
2786
+ }
2787
+ if (result.totalCandidates > result.recommendations.length) {
2788
+ lines.push(
2789
+ `Showing ${result.recommendations.length} of ${result.totalCandidates} candidates.`
2790
+ );
2791
+ }
2792
+ return lines.join("\n");
2793
+ }
2287
2794
  export {
2288
2795
  BlockerSchema,
2289
2796
  CURRENT_SCHEMA_VERSION,
@@ -2299,6 +2806,10 @@ export {
2299
2806
  ISSUE_STATUSES,
2300
2807
  IssueIdSchema,
2301
2808
  IssueSchema,
2809
+ NOTE_ID_REGEX,
2810
+ NOTE_STATUSES,
2811
+ NoteIdSchema,
2812
+ NoteSchema,
2302
2813
  OUTPUT_FORMATS,
2303
2814
  PhaseSchema,
2304
2815
  ProjectLoaderError,
@@ -2315,7 +2826,9 @@ export {
2315
2826
  buildRecap,
2316
2827
  currentPhase,
2317
2828
  deleteIssue,
2829
+ deleteNote,
2318
2830
  deleteTicket,
2831
+ descendantLeaves,
2319
2832
  diffStates,
2320
2833
  discoverProjectRoot,
2321
2834
  errorEnvelope,
@@ -2336,6 +2849,7 @@ export {
2336
2849
  formatPhaseList,
2337
2850
  formatPhaseTickets,
2338
2851
  formatRecap,
2852
+ formatRecommendations,
2339
2853
  formatSnapshotResult,
2340
2854
  formatStatus,
2341
2855
  formatTicket,
@@ -2349,12 +2863,15 @@ export {
2349
2863
  loadProject,
2350
2864
  mergeValidation,
2351
2865
  nextIssueID,
2866
+ nextNoteID,
2352
2867
  nextOrder,
2353
2868
  nextTicket,
2354
2869
  nextTicketID,
2870
+ nextTickets,
2355
2871
  partialEnvelope,
2356
2872
  phasesWithStatus,
2357
2873
  readHandover,
2874
+ recommend,
2358
2875
  runTransaction,
2359
2876
  runTransactionUnlocked,
2360
2877
  saveSnapshot,
@@ -2368,6 +2885,8 @@ export {
2368
2885
  writeConfig,
2369
2886
  writeIssue,
2370
2887
  writeIssueUnlocked,
2888
+ writeNote,
2889
+ writeNoteUnlocked,
2371
2890
  writeRoadmap,
2372
2891
  writeRoadmapUnlocked,
2373
2892
  writeTicket,