@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/README.md +26 -0
- package/dist/cli.js +2079 -221
- package/dist/index.d.ts +170 -1
- package/dist/index.js +615 -170
- package/dist/mcp.js +1351 -105
- package/package.json +2 -1
- package/src/skill/SKILL.md +138 -0
- package/src/skill/reference.md +338 -0
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
|
|
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
|
|
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/
|
|
154
|
+
// src/models/note.ts
|
|
151
155
|
import { z as z4 } from "zod";
|
|
152
|
-
var
|
|
153
|
-
|
|
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:
|
|
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:
|
|
182
|
+
note: z5.string().nullable().optional()
|
|
161
183
|
}).passthrough();
|
|
162
|
-
var PhaseSchema =
|
|
163
|
-
id:
|
|
164
|
-
label:
|
|
165
|
-
name:
|
|
166
|
-
description:
|
|
167
|
-
summary:
|
|
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 =
|
|
170
|
-
title:
|
|
191
|
+
var RoadmapSchema = z5.object({
|
|
192
|
+
title: z5.string(),
|
|
171
193
|
date: DateSchema,
|
|
172
|
-
phases:
|
|
173
|
-
blockers:
|
|
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
|
|
178
|
-
var FeaturesSchema =
|
|
179
|
-
tickets:
|
|
180
|
-
issues:
|
|
181
|
-
handovers:
|
|
182
|
-
roadmap:
|
|
183
|
-
reviews:
|
|
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 =
|
|
186
|
-
version:
|
|
187
|
-
schemaVersion:
|
|
188
|
-
project:
|
|
189
|
-
type:
|
|
190
|
-
language:
|
|
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 {
|
|
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
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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("
|
|
2147
|
+
output: formatError("io_error", `Cannot read handover: ${err.message}`, ctx.format),
|
|
1803
2148
|
exitCode: ExitCode.USER_ERROR,
|
|
1804
|
-
errorCode: "
|
|
2149
|
+
errorCode: "io_error"
|
|
1805
2150
|
};
|
|
1806
2151
|
}
|
|
2152
|
+
}
|
|
2153
|
+
if (parts.length === 0) {
|
|
1807
2154
|
return {
|
|
1808
|
-
output: formatError("
|
|
2155
|
+
output: formatError("not_found", "No handovers found", ctx.format),
|
|
1809
2156
|
exitCode: ExitCode.USER_ERROR,
|
|
1810
|
-
errorCode: "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1879
|
-
|
|
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
|
-
|
|
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
|
|
1980
|
-
import { existsSync as
|
|
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
|
|
1983
|
-
var LoadWarningSchema =
|
|
1984
|
-
type:
|
|
1985
|
-
file:
|
|
1986
|
-
message:
|
|
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 =
|
|
1989
|
-
version:
|
|
1990
|
-
createdAt:
|
|
1991
|
-
project:
|
|
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:
|
|
1995
|
-
issues:
|
|
1996
|
-
|
|
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
|
|
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 (!
|
|
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
|
|
2059
|
-
|
|
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
|
|
2077
|
-
if (
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
2395
|
-
phase:
|
|
2396
|
-
type:
|
|
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:
|
|
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:
|
|
2424
|
-
severity:
|
|
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:
|
|
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:
|
|
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:
|
|
2452
|
-
all:
|
|
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:
|
|
2475
|
-
slug:
|
|
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.
|
|
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 (
|
|
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}
|