@anthropologies/claudestory 0.1.0
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 +162 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3792 -0
- package/dist/index.d.ts +769 -0
- package/dist/index.js +1812 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +1755 -0
- package/package.json +41 -0
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,1755 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/mcp/index.ts
|
|
4
|
+
import { realpathSync, existsSync as existsSync4 } from "fs";
|
|
5
|
+
import { resolve as resolve5, join as join6, isAbsolute } from "path";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
|
|
9
|
+
// src/core/project-root-discovery.ts
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import { resolve, dirname, join } from "path";
|
|
12
|
+
var ENV_VAR = "CLAUDESTORY_PROJECT_ROOT";
|
|
13
|
+
var CONFIG_PATH = ".story/config.json";
|
|
14
|
+
function discoverProjectRoot(startDir) {
|
|
15
|
+
const envRoot = process.env[ENV_VAR];
|
|
16
|
+
if (envRoot) {
|
|
17
|
+
const resolved = resolve(envRoot);
|
|
18
|
+
if (existsSync(join(resolved, CONFIG_PATH))) {
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
let current = resolve(startDir ?? process.cwd());
|
|
24
|
+
for (; ; ) {
|
|
25
|
+
if (existsSync(join(current, CONFIG_PATH))) {
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
const parent = dirname(current);
|
|
29
|
+
if (parent === current) break;
|
|
30
|
+
current = parent;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/mcp/tools.ts
|
|
36
|
+
import { z as z6 } from "zod";
|
|
37
|
+
import { join as join5 } from "path";
|
|
38
|
+
|
|
39
|
+
// src/core/project-loader.ts
|
|
40
|
+
import {
|
|
41
|
+
readdir as readdir2,
|
|
42
|
+
readFile as readFile2,
|
|
43
|
+
writeFile,
|
|
44
|
+
rename,
|
|
45
|
+
unlink,
|
|
46
|
+
stat,
|
|
47
|
+
realpath,
|
|
48
|
+
lstat,
|
|
49
|
+
open
|
|
50
|
+
} from "fs/promises";
|
|
51
|
+
import { existsSync as existsSync3 } from "fs";
|
|
52
|
+
import { join as join3, resolve as resolve2, relative as relative2, extname as extname2, dirname as dirname2, basename } from "path";
|
|
53
|
+
import lockfile from "proper-lockfile";
|
|
54
|
+
|
|
55
|
+
// src/models/ticket.ts
|
|
56
|
+
import { z as z2 } from "zod";
|
|
57
|
+
|
|
58
|
+
// src/models/types.ts
|
|
59
|
+
import { z } from "zod";
|
|
60
|
+
var TICKET_ID_REGEX = /^T-\d+[a-z]?$/;
|
|
61
|
+
var ISSUE_ID_REGEX = /^ISS-\d+$/;
|
|
62
|
+
var TICKET_STATUSES = ["open", "inprogress", "complete"];
|
|
63
|
+
var TICKET_TYPES = ["task", "feature", "chore"];
|
|
64
|
+
var ISSUE_STATUSES = ["open", "inprogress", "resolved"];
|
|
65
|
+
var ISSUE_SEVERITIES = ["critical", "high", "medium", "low"];
|
|
66
|
+
var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
67
|
+
var DateSchema = z.string().regex(DATE_REGEX, "Date must be YYYY-MM-DD").refine(
|
|
68
|
+
(val) => {
|
|
69
|
+
const d = /* @__PURE__ */ new Date(val + "T00:00:00Z");
|
|
70
|
+
return !isNaN(d.getTime()) && d.toISOString().startsWith(val);
|
|
71
|
+
},
|
|
72
|
+
{ message: "Invalid calendar date" }
|
|
73
|
+
);
|
|
74
|
+
var TicketIdSchema = z.string().regex(TICKET_ID_REGEX, "Ticket ID must match T-NNN or T-NNNx");
|
|
75
|
+
var IssueIdSchema = z.string().regex(ISSUE_ID_REGEX, "Issue ID must match ISS-NNN");
|
|
76
|
+
|
|
77
|
+
// src/models/ticket.ts
|
|
78
|
+
var TicketSchema = z2.object({
|
|
79
|
+
id: TicketIdSchema,
|
|
80
|
+
title: z2.string().min(1),
|
|
81
|
+
description: z2.string(),
|
|
82
|
+
type: z2.enum(TICKET_TYPES),
|
|
83
|
+
status: z2.enum(TICKET_STATUSES),
|
|
84
|
+
phase: z2.string().nullable(),
|
|
85
|
+
order: z2.number().int(),
|
|
86
|
+
createdDate: DateSchema,
|
|
87
|
+
completedDate: DateSchema.nullable(),
|
|
88
|
+
blockedBy: z2.array(TicketIdSchema),
|
|
89
|
+
parentTicket: TicketIdSchema.nullable().optional(),
|
|
90
|
+
// Attribution fields — unused in v1, baked in to avoid future migration
|
|
91
|
+
createdBy: z2.string().nullable().optional(),
|
|
92
|
+
assignedTo: z2.string().nullable().optional(),
|
|
93
|
+
lastModifiedBy: z2.string().nullable().optional()
|
|
94
|
+
}).passthrough();
|
|
95
|
+
|
|
96
|
+
// src/models/issue.ts
|
|
97
|
+
import { z as z3 } from "zod";
|
|
98
|
+
var IssueSchema = z3.object({
|
|
99
|
+
id: IssueIdSchema,
|
|
100
|
+
title: z3.string().min(1),
|
|
101
|
+
status: z3.enum(ISSUE_STATUSES),
|
|
102
|
+
severity: z3.enum(ISSUE_SEVERITIES),
|
|
103
|
+
components: z3.array(z3.string()),
|
|
104
|
+
impact: z3.string(),
|
|
105
|
+
resolution: z3.string().nullable(),
|
|
106
|
+
location: z3.array(z3.string()),
|
|
107
|
+
discoveredDate: DateSchema,
|
|
108
|
+
resolvedDate: DateSchema.nullable(),
|
|
109
|
+
relatedTickets: z3.array(TicketIdSchema),
|
|
110
|
+
// Optional fields — older issues may omit these
|
|
111
|
+
order: z3.number().int().optional(),
|
|
112
|
+
phase: z3.string().nullable().optional(),
|
|
113
|
+
// Attribution fields — unused in v1
|
|
114
|
+
createdBy: z3.string().nullable().optional(),
|
|
115
|
+
assignedTo: z3.string().nullable().optional(),
|
|
116
|
+
lastModifiedBy: z3.string().nullable().optional()
|
|
117
|
+
}).passthrough();
|
|
118
|
+
|
|
119
|
+
// src/models/roadmap.ts
|
|
120
|
+
import { z as z4 } from "zod";
|
|
121
|
+
var BlockerSchema = z4.object({
|
|
122
|
+
name: z4.string().min(1),
|
|
123
|
+
// Legacy format (pre-T-082)
|
|
124
|
+
cleared: z4.boolean().optional(),
|
|
125
|
+
// New date-based format (T-082 migration)
|
|
126
|
+
createdDate: DateSchema.optional(),
|
|
127
|
+
clearedDate: DateSchema.nullable().optional(),
|
|
128
|
+
// Present in all current data but optional for future minimal blockers
|
|
129
|
+
note: z4.string().nullable().optional()
|
|
130
|
+
}).passthrough();
|
|
131
|
+
var PhaseSchema = z4.object({
|
|
132
|
+
id: z4.string().min(1),
|
|
133
|
+
label: z4.string(),
|
|
134
|
+
name: z4.string(),
|
|
135
|
+
description: z4.string(),
|
|
136
|
+
summary: z4.string().optional()
|
|
137
|
+
}).passthrough();
|
|
138
|
+
var RoadmapSchema = z4.object({
|
|
139
|
+
title: z4.string(),
|
|
140
|
+
date: DateSchema,
|
|
141
|
+
phases: z4.array(PhaseSchema),
|
|
142
|
+
blockers: z4.array(BlockerSchema)
|
|
143
|
+
}).passthrough();
|
|
144
|
+
|
|
145
|
+
// src/models/config.ts
|
|
146
|
+
import { z as z5 } from "zod";
|
|
147
|
+
var FeaturesSchema = z5.object({
|
|
148
|
+
tickets: z5.boolean(),
|
|
149
|
+
issues: z5.boolean(),
|
|
150
|
+
handovers: z5.boolean(),
|
|
151
|
+
roadmap: z5.boolean(),
|
|
152
|
+
reviews: z5.boolean()
|
|
153
|
+
}).passthrough();
|
|
154
|
+
var ConfigSchema = z5.object({
|
|
155
|
+
version: z5.number().int().min(1),
|
|
156
|
+
schemaVersion: z5.number().int().optional(),
|
|
157
|
+
project: z5.string().min(1),
|
|
158
|
+
type: z5.string(),
|
|
159
|
+
language: z5.string(),
|
|
160
|
+
features: FeaturesSchema
|
|
161
|
+
}).passthrough();
|
|
162
|
+
|
|
163
|
+
// src/core/project-state.ts
|
|
164
|
+
var ProjectState = class _ProjectState {
|
|
165
|
+
// --- Public raw inputs (readonly) ---
|
|
166
|
+
tickets;
|
|
167
|
+
issues;
|
|
168
|
+
roadmap;
|
|
169
|
+
config;
|
|
170
|
+
handoverFilenames;
|
|
171
|
+
// --- Derived (public readonly) ---
|
|
172
|
+
umbrellaIDs;
|
|
173
|
+
leafTickets;
|
|
174
|
+
// --- Derived (private) ---
|
|
175
|
+
leafTicketsByPhase;
|
|
176
|
+
childrenByParent;
|
|
177
|
+
reverseBlocksMap;
|
|
178
|
+
ticketsByID;
|
|
179
|
+
issuesByID;
|
|
180
|
+
// --- Counts ---
|
|
181
|
+
totalTicketCount;
|
|
182
|
+
openTicketCount;
|
|
183
|
+
completeTicketCount;
|
|
184
|
+
openIssueCount;
|
|
185
|
+
issuesBySeverity;
|
|
186
|
+
constructor(input) {
|
|
187
|
+
this.tickets = input.tickets;
|
|
188
|
+
this.issues = input.issues;
|
|
189
|
+
this.roadmap = input.roadmap;
|
|
190
|
+
this.config = input.config;
|
|
191
|
+
this.handoverFilenames = input.handoverFilenames;
|
|
192
|
+
const parentIDs = /* @__PURE__ */ new Set();
|
|
193
|
+
for (const t of input.tickets) {
|
|
194
|
+
if (t.parentTicket != null) {
|
|
195
|
+
parentIDs.add(t.parentTicket);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.umbrellaIDs = parentIDs;
|
|
199
|
+
this.leafTickets = input.tickets.filter((t) => !parentIDs.has(t.id));
|
|
200
|
+
const byPhase = /* @__PURE__ */ new Map();
|
|
201
|
+
for (const t of this.leafTickets) {
|
|
202
|
+
const phase = t.phase;
|
|
203
|
+
const arr = byPhase.get(phase);
|
|
204
|
+
if (arr) {
|
|
205
|
+
arr.push(t);
|
|
206
|
+
} else {
|
|
207
|
+
byPhase.set(phase, [t]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const [, arr] of byPhase) {
|
|
211
|
+
arr.sort((a, b) => a.order - b.order);
|
|
212
|
+
}
|
|
213
|
+
this.leafTicketsByPhase = byPhase;
|
|
214
|
+
const children = /* @__PURE__ */ new Map();
|
|
215
|
+
for (const t of input.tickets) {
|
|
216
|
+
if (t.parentTicket != null) {
|
|
217
|
+
const arr = children.get(t.parentTicket);
|
|
218
|
+
if (arr) {
|
|
219
|
+
arr.push(t);
|
|
220
|
+
} else {
|
|
221
|
+
children.set(t.parentTicket, [t]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.childrenByParent = children;
|
|
226
|
+
const reverseBlocks = /* @__PURE__ */ new Map();
|
|
227
|
+
for (const t of input.tickets) {
|
|
228
|
+
for (const blockerID of t.blockedBy) {
|
|
229
|
+
const arr = reverseBlocks.get(blockerID);
|
|
230
|
+
if (arr) {
|
|
231
|
+
arr.push(t);
|
|
232
|
+
} else {
|
|
233
|
+
reverseBlocks.set(blockerID, [t]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.reverseBlocksMap = reverseBlocks;
|
|
238
|
+
const tByID = /* @__PURE__ */ new Map();
|
|
239
|
+
for (const t of input.tickets) {
|
|
240
|
+
if (!tByID.has(t.id)) {
|
|
241
|
+
tByID.set(t.id, t);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
this.ticketsByID = tByID;
|
|
245
|
+
const iByID = /* @__PURE__ */ new Map();
|
|
246
|
+
for (const i of input.issues) {
|
|
247
|
+
iByID.set(i.id, i);
|
|
248
|
+
}
|
|
249
|
+
this.issuesByID = iByID;
|
|
250
|
+
this.totalTicketCount = input.tickets.length;
|
|
251
|
+
this.openTicketCount = input.tickets.filter(
|
|
252
|
+
(t) => t.status !== "complete"
|
|
253
|
+
).length;
|
|
254
|
+
this.completeTicketCount = input.tickets.filter(
|
|
255
|
+
(t) => t.status === "complete"
|
|
256
|
+
).length;
|
|
257
|
+
this.openIssueCount = input.issues.filter(
|
|
258
|
+
(i) => i.status === "open"
|
|
259
|
+
).length;
|
|
260
|
+
const bySev = /* @__PURE__ */ new Map();
|
|
261
|
+
for (const i of input.issues) {
|
|
262
|
+
if (i.status === "open") {
|
|
263
|
+
bySev.set(i.severity, (bySev.get(i.severity) ?? 0) + 1);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
this.issuesBySeverity = bySev;
|
|
267
|
+
}
|
|
268
|
+
// --- Query Methods ---
|
|
269
|
+
isUmbrella(ticket) {
|
|
270
|
+
return this.umbrellaIDs.has(ticket.id);
|
|
271
|
+
}
|
|
272
|
+
phaseTickets(phaseId) {
|
|
273
|
+
return this.leafTicketsByPhase.get(phaseId) ?? [];
|
|
274
|
+
}
|
|
275
|
+
/** Phase status derived from leaf tickets only. Umbrella stored status is ignored. */
|
|
276
|
+
phaseStatus(phaseId) {
|
|
277
|
+
const leaves = this.phaseTickets(phaseId);
|
|
278
|
+
return _ProjectState.aggregateStatus(leaves);
|
|
279
|
+
}
|
|
280
|
+
umbrellaChildren(ticketId) {
|
|
281
|
+
return this.childrenByParent.get(ticketId) ?? [];
|
|
282
|
+
}
|
|
283
|
+
/** Umbrella status derived from descendant leaf tickets (recursive traversal). */
|
|
284
|
+
umbrellaStatus(ticketId) {
|
|
285
|
+
const visited = /* @__PURE__ */ new Set();
|
|
286
|
+
const leaves = this.descendantLeaves(ticketId, visited);
|
|
287
|
+
return _ProjectState.aggregateStatus(leaves);
|
|
288
|
+
}
|
|
289
|
+
reverseBlocks(ticketId) {
|
|
290
|
+
return this.reverseBlocksMap.get(ticketId) ?? [];
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* A ticket is blocked if any blockedBy reference points to a non-complete ticket.
|
|
294
|
+
* Unknown blocker IDs treated as blocked (conservative — unknown dependency = assume not cleared).
|
|
295
|
+
*/
|
|
296
|
+
isBlocked(ticket) {
|
|
297
|
+
if (ticket.blockedBy.length === 0) return false;
|
|
298
|
+
return ticket.blockedBy.some((blockerID) => {
|
|
299
|
+
const blocker = this.ticketsByID.get(blockerID);
|
|
300
|
+
if (!blocker) return true;
|
|
301
|
+
return blocker.status !== "complete";
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
get blockedCount() {
|
|
305
|
+
return this.tickets.filter((t) => this.isBlocked(t)).length;
|
|
306
|
+
}
|
|
307
|
+
ticketByID(id) {
|
|
308
|
+
return this.ticketsByID.get(id);
|
|
309
|
+
}
|
|
310
|
+
issueByID(id) {
|
|
311
|
+
return this.issuesByID.get(id);
|
|
312
|
+
}
|
|
313
|
+
// --- Deletion Safety ---
|
|
314
|
+
/** IDs of tickets that list `ticketId` in their blockedBy. */
|
|
315
|
+
ticketsBlocking(ticketId) {
|
|
316
|
+
return (this.reverseBlocksMap.get(ticketId) ?? []).map((t) => t.id);
|
|
317
|
+
}
|
|
318
|
+
/** IDs of tickets that have `ticketId` as their parentTicket. */
|
|
319
|
+
childrenOf(ticketId) {
|
|
320
|
+
return (this.childrenByParent.get(ticketId) ?? []).map((t) => t.id);
|
|
321
|
+
}
|
|
322
|
+
/** IDs of issues that reference `ticketId` in relatedTickets. */
|
|
323
|
+
issuesReferencing(ticketId) {
|
|
324
|
+
return this.issues.filter((i) => i.relatedTickets.includes(ticketId)).map((i) => i.id);
|
|
325
|
+
}
|
|
326
|
+
// --- Private ---
|
|
327
|
+
/**
|
|
328
|
+
* Recursively collects all descendant leaf tickets of an umbrella.
|
|
329
|
+
* Uses a visited set to guard against cycles in malformed data.
|
|
330
|
+
*/
|
|
331
|
+
descendantLeaves(ticketId, visited) {
|
|
332
|
+
if (visited.has(ticketId)) return [];
|
|
333
|
+
visited.add(ticketId);
|
|
334
|
+
const directChildren = this.childrenByParent.get(ticketId) ?? [];
|
|
335
|
+
const leaves = [];
|
|
336
|
+
for (const child of directChildren) {
|
|
337
|
+
if (this.umbrellaIDs.has(child.id)) {
|
|
338
|
+
leaves.push(...this.descendantLeaves(child.id, visited));
|
|
339
|
+
} else {
|
|
340
|
+
leaves.push(child);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return leaves;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Shared aggregation logic for phase and umbrella status.
|
|
347
|
+
* - all complete → complete
|
|
348
|
+
* - any inprogress OR any complete (but not all) → inprogress
|
|
349
|
+
* - else → notstarted (nothing started)
|
|
350
|
+
*/
|
|
351
|
+
static aggregateStatus(tickets) {
|
|
352
|
+
if (tickets.length === 0) return "notstarted";
|
|
353
|
+
const allComplete = tickets.every((t) => t.status === "complete");
|
|
354
|
+
if (allComplete) return "complete";
|
|
355
|
+
const anyProgress = tickets.some((t) => t.status === "inprogress");
|
|
356
|
+
const anyComplete = tickets.some((t) => t.status === "complete");
|
|
357
|
+
if (anyProgress || anyComplete) return "inprogress";
|
|
358
|
+
return "notstarted";
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// src/core/errors.ts
|
|
363
|
+
var CURRENT_SCHEMA_VERSION = 1;
|
|
364
|
+
var ProjectLoaderError = class extends Error {
|
|
365
|
+
constructor(code, message, cause) {
|
|
366
|
+
super(message);
|
|
367
|
+
this.code = code;
|
|
368
|
+
this.cause = cause;
|
|
369
|
+
}
|
|
370
|
+
name = "ProjectLoaderError";
|
|
371
|
+
};
|
|
372
|
+
var INTEGRITY_WARNING_TYPES = [
|
|
373
|
+
"parse_error",
|
|
374
|
+
"schema_error",
|
|
375
|
+
"duplicate_id"
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
// src/core/handover-parser.ts
|
|
379
|
+
import { readdir, readFile } from "fs/promises";
|
|
380
|
+
import { existsSync as existsSync2 } from "fs";
|
|
381
|
+
import { join as join2, relative, extname } from "path";
|
|
382
|
+
var HANDOVER_DATE_REGEX = /^\d{4}-\d{2}-\d{2}/;
|
|
383
|
+
async function listHandovers(handoversDir, root, warnings) {
|
|
384
|
+
if (!existsSync2(handoversDir)) return [];
|
|
385
|
+
let entries;
|
|
386
|
+
try {
|
|
387
|
+
entries = await readdir(handoversDir);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
warnings.push({
|
|
390
|
+
file: relative(root, handoversDir),
|
|
391
|
+
message: `Cannot enumerate handovers: ${err instanceof Error ? err.message : String(err)}`,
|
|
392
|
+
type: "parse_error"
|
|
393
|
+
});
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
const conforming = [];
|
|
397
|
+
const nonConforming = [];
|
|
398
|
+
for (const entry of entries.sort()) {
|
|
399
|
+
if (entry.startsWith(".")) continue;
|
|
400
|
+
if (extname(entry) !== ".md") continue;
|
|
401
|
+
if (HANDOVER_DATE_REGEX.test(entry)) {
|
|
402
|
+
conforming.push(entry);
|
|
403
|
+
} else {
|
|
404
|
+
nonConforming.push(entry);
|
|
405
|
+
warnings.push({
|
|
406
|
+
file: relative(root, join2(handoversDir, entry)),
|
|
407
|
+
message: "Handover filename does not start with YYYY-MM-DD date prefix.",
|
|
408
|
+
type: "naming_convention"
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
conforming.sort((a, b) => b.localeCompare(a));
|
|
413
|
+
return [...conforming, ...nonConforming];
|
|
414
|
+
}
|
|
415
|
+
async function readHandover(handoversDir, filename) {
|
|
416
|
+
return readFile(join2(handoversDir, filename), "utf-8");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/core/project-loader.ts
|
|
420
|
+
async function loadProject(root, options) {
|
|
421
|
+
const absRoot = resolve2(root);
|
|
422
|
+
const wrapDir = join3(absRoot, ".story");
|
|
423
|
+
try {
|
|
424
|
+
const wrapStat = await stat(wrapDir);
|
|
425
|
+
if (!wrapStat.isDirectory()) {
|
|
426
|
+
throw new ProjectLoaderError(
|
|
427
|
+
"not_found",
|
|
428
|
+
"Missing .story/ directory."
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
} catch (err) {
|
|
432
|
+
if (err instanceof ProjectLoaderError) throw err;
|
|
433
|
+
throw new ProjectLoaderError(
|
|
434
|
+
"not_found",
|
|
435
|
+
"Missing .story/ directory."
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (existsSync3(join3(wrapDir, ".txn.json"))) {
|
|
439
|
+
await withLock(wrapDir, () => doRecoverTransaction(wrapDir));
|
|
440
|
+
}
|
|
441
|
+
const config = await loadSingletonFile(
|
|
442
|
+
"config.json",
|
|
443
|
+
wrapDir,
|
|
444
|
+
absRoot,
|
|
445
|
+
ConfigSchema
|
|
446
|
+
);
|
|
447
|
+
const maxVersion = options?.maxSchemaVersion ?? CURRENT_SCHEMA_VERSION;
|
|
448
|
+
if (config.schemaVersion !== void 0 && config.schemaVersion > maxVersion) {
|
|
449
|
+
throw new ProjectLoaderError(
|
|
450
|
+
"version_mismatch",
|
|
451
|
+
`Config schemaVersion ${config.schemaVersion} exceeds max supported ${maxVersion}.`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const roadmap = await loadSingletonFile(
|
|
455
|
+
"roadmap.json",
|
|
456
|
+
wrapDir,
|
|
457
|
+
absRoot,
|
|
458
|
+
RoadmapSchema
|
|
459
|
+
);
|
|
460
|
+
const warnings = [];
|
|
461
|
+
const tickets = await loadDirectory(
|
|
462
|
+
join3(wrapDir, "tickets"),
|
|
463
|
+
absRoot,
|
|
464
|
+
TicketSchema,
|
|
465
|
+
warnings
|
|
466
|
+
);
|
|
467
|
+
const issues = await loadDirectory(
|
|
468
|
+
join3(wrapDir, "issues"),
|
|
469
|
+
absRoot,
|
|
470
|
+
IssueSchema,
|
|
471
|
+
warnings
|
|
472
|
+
);
|
|
473
|
+
const handoversDir = join3(wrapDir, "handovers");
|
|
474
|
+
const handoverFilenames = await listHandovers(
|
|
475
|
+
handoversDir,
|
|
476
|
+
absRoot,
|
|
477
|
+
warnings
|
|
478
|
+
);
|
|
479
|
+
if (options?.strict) {
|
|
480
|
+
const integrityWarning = warnings.find(
|
|
481
|
+
(w) => INTEGRITY_WARNING_TYPES.includes(w.type)
|
|
482
|
+
);
|
|
483
|
+
if (integrityWarning) {
|
|
484
|
+
throw new ProjectLoaderError(
|
|
485
|
+
"project_corrupt",
|
|
486
|
+
`Strict mode: ${integrityWarning.file}: ${integrityWarning.message}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const state = new ProjectState({
|
|
491
|
+
tickets,
|
|
492
|
+
issues,
|
|
493
|
+
roadmap,
|
|
494
|
+
config,
|
|
495
|
+
handoverFilenames
|
|
496
|
+
});
|
|
497
|
+
return { state, warnings };
|
|
498
|
+
}
|
|
499
|
+
async function doRecoverTransaction(wrapDir) {
|
|
500
|
+
const journalPath = join3(wrapDir, ".txn.json");
|
|
501
|
+
let entries;
|
|
502
|
+
let commitStarted = false;
|
|
503
|
+
try {
|
|
504
|
+
const raw = await readFile2(journalPath, "utf-8");
|
|
505
|
+
const parsed = JSON.parse(raw);
|
|
506
|
+
if (Array.isArray(parsed)) {
|
|
507
|
+
entries = parsed;
|
|
508
|
+
commitStarted = true;
|
|
509
|
+
} else if (parsed != null && typeof parsed === "object" && Array.isArray(parsed.entries) && typeof parsed.commitStarted === "boolean") {
|
|
510
|
+
const journal = parsed;
|
|
511
|
+
entries = journal.entries;
|
|
512
|
+
commitStarted = journal.commitStarted;
|
|
513
|
+
} else {
|
|
514
|
+
try {
|
|
515
|
+
await unlink(journalPath);
|
|
516
|
+
} catch {
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
try {
|
|
522
|
+
await unlink(journalPath);
|
|
523
|
+
} catch {
|
|
524
|
+
}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!commitStarted) {
|
|
528
|
+
for (const entry of entries) {
|
|
529
|
+
if (entry.op === "write" && entry.tempPath && existsSync3(entry.tempPath)) {
|
|
530
|
+
try {
|
|
531
|
+
await unlink(entry.tempPath);
|
|
532
|
+
} catch {
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
await unlink(journalPath);
|
|
538
|
+
} catch {
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
for (const entry of entries) {
|
|
543
|
+
if (entry.op === "write" && entry.tempPath) {
|
|
544
|
+
const tempExists = existsSync3(entry.tempPath);
|
|
545
|
+
if (tempExists) {
|
|
546
|
+
try {
|
|
547
|
+
await rename(entry.tempPath, entry.target);
|
|
548
|
+
} catch {
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
await unlink(entry.tempPath);
|
|
552
|
+
} catch {
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} else if (entry.op === "delete") {
|
|
556
|
+
try {
|
|
557
|
+
await unlink(entry.target);
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
await unlink(journalPath);
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function loadSingletonFile(filename, wrapDir, root, schema) {
|
|
568
|
+
const filePath = join3(wrapDir, filename);
|
|
569
|
+
const relPath = relative2(root, filePath);
|
|
570
|
+
let raw;
|
|
571
|
+
try {
|
|
572
|
+
raw = await readFile2(filePath, "utf-8");
|
|
573
|
+
} catch (err) {
|
|
574
|
+
if (err.code === "ENOENT") {
|
|
575
|
+
throw new ProjectLoaderError("not_found", `File not found: ${relPath}`);
|
|
576
|
+
}
|
|
577
|
+
throw new ProjectLoaderError(
|
|
578
|
+
"io_error",
|
|
579
|
+
`Cannot read file: ${relPath}`,
|
|
580
|
+
err
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
let parsed;
|
|
584
|
+
try {
|
|
585
|
+
parsed = JSON.parse(raw);
|
|
586
|
+
} catch (err) {
|
|
587
|
+
throw new ProjectLoaderError(
|
|
588
|
+
"validation_failed",
|
|
589
|
+
`Invalid JSON in ${relPath}`,
|
|
590
|
+
err
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
const result = schema.safeParse(parsed);
|
|
594
|
+
if (!result.success) {
|
|
595
|
+
throw new ProjectLoaderError(
|
|
596
|
+
"validation_failed",
|
|
597
|
+
`Validation failed for ${relPath}: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
598
|
+
result.error
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
return result.data;
|
|
602
|
+
}
|
|
603
|
+
async function loadDirectory(dirPath, root, schema, warnings) {
|
|
604
|
+
if (!existsSync3(dirPath)) return [];
|
|
605
|
+
let entries;
|
|
606
|
+
try {
|
|
607
|
+
entries = await readdir2(dirPath);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
throw new ProjectLoaderError(
|
|
610
|
+
"io_error",
|
|
611
|
+
`Cannot enumerate ${relative2(root, dirPath)}`,
|
|
612
|
+
err
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
entries.sort();
|
|
616
|
+
const results = [];
|
|
617
|
+
for (const entry of entries) {
|
|
618
|
+
if (entry.startsWith(".")) continue;
|
|
619
|
+
if (extname2(entry) !== ".json") continue;
|
|
620
|
+
const filePath = join3(dirPath, entry);
|
|
621
|
+
const relPath = relative2(root, filePath);
|
|
622
|
+
try {
|
|
623
|
+
const raw = await readFile2(filePath, "utf-8");
|
|
624
|
+
const parsed = JSON.parse(raw);
|
|
625
|
+
const result = schema.safeParse(parsed);
|
|
626
|
+
if (!result.success) {
|
|
627
|
+
warnings.push({
|
|
628
|
+
file: relPath,
|
|
629
|
+
message: result.error.issues.map((i) => i.message).join("; "),
|
|
630
|
+
type: "schema_error"
|
|
631
|
+
});
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
results.push(result.data);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
warnings.push({
|
|
637
|
+
file: relPath,
|
|
638
|
+
message: err instanceof Error ? err.message : String(err),
|
|
639
|
+
type: "parse_error"
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return results;
|
|
644
|
+
}
|
|
645
|
+
async function withLock(wrapDir, fn) {
|
|
646
|
+
let release;
|
|
647
|
+
try {
|
|
648
|
+
release = await lockfile.lock(wrapDir, {
|
|
649
|
+
retries: { retries: 3, minTimeout: 100, maxTimeout: 1e3 },
|
|
650
|
+
stale: 1e4,
|
|
651
|
+
lockfilePath: join3(wrapDir, ".lock")
|
|
652
|
+
});
|
|
653
|
+
} catch (err) {
|
|
654
|
+
if (err instanceof ProjectLoaderError) throw err;
|
|
655
|
+
throw new ProjectLoaderError(
|
|
656
|
+
"io_error",
|
|
657
|
+
`Lock acquisition failed for ${wrapDir}`,
|
|
658
|
+
err
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
return await fn();
|
|
663
|
+
} finally {
|
|
664
|
+
if (release) {
|
|
665
|
+
try {
|
|
666
|
+
await release();
|
|
667
|
+
} catch {
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/cli/helpers.ts
|
|
674
|
+
import { resolve as resolve3, relative as relative3, extname as extname3 } from "path";
|
|
675
|
+
import { lstat as lstat2 } from "fs/promises";
|
|
676
|
+
var CliValidationError = class extends Error {
|
|
677
|
+
constructor(code, message) {
|
|
678
|
+
super(message);
|
|
679
|
+
this.code = code;
|
|
680
|
+
this.name = "CliValidationError";
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
async function parseHandoverFilename(raw, handoversDir) {
|
|
684
|
+
if (raw.includes("/") || raw.includes("\\") || raw.includes("..") || raw.includes("\0")) {
|
|
685
|
+
throw new CliValidationError(
|
|
686
|
+
"invalid_input",
|
|
687
|
+
`Invalid handover filename "${raw}": contains path traversal characters`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
if (extname3(raw) !== ".md") {
|
|
691
|
+
throw new CliValidationError(
|
|
692
|
+
"invalid_input",
|
|
693
|
+
`Invalid handover filename "${raw}": must have .md extension`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const resolvedDir = resolve3(handoversDir);
|
|
697
|
+
const resolvedCandidate = resolve3(handoversDir, raw);
|
|
698
|
+
const rel = relative3(resolvedDir, resolvedCandidate);
|
|
699
|
+
if (!rel || rel.startsWith("..") || resolve3(resolvedDir, rel) !== resolvedCandidate) {
|
|
700
|
+
throw new CliValidationError(
|
|
701
|
+
"invalid_input",
|
|
702
|
+
`Invalid handover filename "${raw}": resolves outside handovers directory`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
const stats = await lstat2(resolvedCandidate);
|
|
707
|
+
if (stats.isSymbolicLink()) {
|
|
708
|
+
throw new CliValidationError(
|
|
709
|
+
"invalid_input",
|
|
710
|
+
`Invalid handover filename "${raw}": symlinks not allowed`
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
} catch (err) {
|
|
714
|
+
if (err instanceof CliValidationError) throw err;
|
|
715
|
+
if (err.code !== "ENOENT") {
|
|
716
|
+
throw new CliValidationError(
|
|
717
|
+
"io_error",
|
|
718
|
+
`Cannot check handover file "${raw}": ${err.message}`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return raw;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// src/core/queries.ts
|
|
726
|
+
function nextTicket(state) {
|
|
727
|
+
const phases = state.roadmap.phases;
|
|
728
|
+
if (phases.length === 0 || state.leafTickets.length === 0) {
|
|
729
|
+
return { kind: "empty_project" };
|
|
730
|
+
}
|
|
731
|
+
let allPhasesComplete = true;
|
|
732
|
+
for (const phase of phases) {
|
|
733
|
+
const leaves = state.phaseTickets(phase.id);
|
|
734
|
+
if (leaves.length === 0) continue;
|
|
735
|
+
const status = state.phaseStatus(phase.id);
|
|
736
|
+
if (status === "complete") continue;
|
|
737
|
+
allPhasesComplete = false;
|
|
738
|
+
const incompleteLeaves = leaves.filter((t) => t.status !== "complete");
|
|
739
|
+
const candidate = incompleteLeaves.find((t) => !state.isBlocked(t));
|
|
740
|
+
if (candidate) {
|
|
741
|
+
const impact = ticketsUnblockedBy(candidate.id, state);
|
|
742
|
+
const progress = candidate.parentTicket ? umbrellaProgress(candidate.parentTicket, state) : null;
|
|
743
|
+
return {
|
|
744
|
+
kind: "found",
|
|
745
|
+
ticket: candidate,
|
|
746
|
+
unblockImpact: { ticketId: candidate.id, wouldUnblock: impact },
|
|
747
|
+
umbrellaProgress: progress
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
kind: "all_blocked",
|
|
752
|
+
phaseId: phase.id,
|
|
753
|
+
blockedCount: incompleteLeaves.length
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (allPhasesComplete) {
|
|
757
|
+
return { kind: "all_complete" };
|
|
758
|
+
}
|
|
759
|
+
return { kind: "empty_project" };
|
|
760
|
+
}
|
|
761
|
+
function blockedTickets(state) {
|
|
762
|
+
return state.leafTickets.filter(
|
|
763
|
+
(t) => t.status !== "complete" && state.isBlocked(t)
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
function ticketsUnblockedBy(ticketId, state) {
|
|
767
|
+
const blocked = state.reverseBlocks(ticketId);
|
|
768
|
+
return blocked.filter((t) => {
|
|
769
|
+
if (t.status === "complete") return false;
|
|
770
|
+
return t.blockedBy.every((bid) => {
|
|
771
|
+
if (bid === ticketId) return true;
|
|
772
|
+
const blocker = state.ticketByID(bid);
|
|
773
|
+
if (!blocker) return false;
|
|
774
|
+
return blocker.status === "complete";
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
function umbrellaProgress(ticketId, state) {
|
|
779
|
+
if (!state.umbrellaIDs.has(ticketId)) return null;
|
|
780
|
+
const leaves = collectDescendantLeaves(ticketId, state, /* @__PURE__ */ new Set());
|
|
781
|
+
const complete = leaves.filter((t) => t.status === "complete").length;
|
|
782
|
+
return {
|
|
783
|
+
total: leaves.length,
|
|
784
|
+
complete,
|
|
785
|
+
status: state.umbrellaStatus(ticketId)
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
function currentPhase(state) {
|
|
789
|
+
for (const phase of state.roadmap.phases) {
|
|
790
|
+
const leaves = state.phaseTickets(phase.id);
|
|
791
|
+
if (leaves.length === 0) continue;
|
|
792
|
+
if (state.phaseStatus(phase.id) !== "complete") return phase;
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
function phasesWithStatus(state) {
|
|
797
|
+
return state.roadmap.phases.map((phase) => ({
|
|
798
|
+
phase,
|
|
799
|
+
status: state.phaseStatus(phase.id),
|
|
800
|
+
leafCount: state.phaseTickets(phase.id).length
|
|
801
|
+
}));
|
|
802
|
+
}
|
|
803
|
+
function isBlockerCleared(blocker) {
|
|
804
|
+
if (blocker.cleared === true) return true;
|
|
805
|
+
if (blocker.clearedDate != null) return true;
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
function collectDescendantLeaves(ticketId, state, visited) {
|
|
809
|
+
if (visited.has(ticketId)) return [];
|
|
810
|
+
visited.add(ticketId);
|
|
811
|
+
const children = state.umbrellaChildren(ticketId);
|
|
812
|
+
const leaves = [];
|
|
813
|
+
for (const child of children) {
|
|
814
|
+
if (state.umbrellaIDs.has(child.id)) {
|
|
815
|
+
leaves.push(...collectDescendantLeaves(child.id, state, visited));
|
|
816
|
+
} else {
|
|
817
|
+
leaves.push(child);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return leaves;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/core/output-formatter.ts
|
|
824
|
+
var ExitCode = {
|
|
825
|
+
OK: 0,
|
|
826
|
+
USER_ERROR: 1,
|
|
827
|
+
VALIDATION_ERROR: 2,
|
|
828
|
+
PARTIAL: 3
|
|
829
|
+
};
|
|
830
|
+
function successEnvelope(data) {
|
|
831
|
+
return { version: 1, data };
|
|
832
|
+
}
|
|
833
|
+
function errorEnvelope(code, message) {
|
|
834
|
+
return { version: 1, error: { code, message } };
|
|
835
|
+
}
|
|
836
|
+
function escapeMarkdownInline(text) {
|
|
837
|
+
return text.replace(/\\/g, "\\\\").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/([`*_~\[\]()|])/g, "\\$1").replace(/(^|\n)([#\-+*])/g, "$1\\$2").replace(/(^|\n)(\d+)\./g, "$1$2\\.");
|
|
838
|
+
}
|
|
839
|
+
function fencedBlock(content, lang) {
|
|
840
|
+
let maxTicks = 2;
|
|
841
|
+
const matches = content.match(/`+/g);
|
|
842
|
+
if (matches) {
|
|
843
|
+
for (const m of matches) {
|
|
844
|
+
if (m.length > maxTicks) maxTicks = m.length;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const fence = "`".repeat(maxTicks + 1);
|
|
848
|
+
return `${fence}${lang ?? ""}
|
|
849
|
+
${content}
|
|
850
|
+
${fence}`;
|
|
851
|
+
}
|
|
852
|
+
function formatStatus(state, format) {
|
|
853
|
+
const phases = phasesWithStatus(state);
|
|
854
|
+
const data = {
|
|
855
|
+
project: state.config.project,
|
|
856
|
+
totalTickets: state.totalTicketCount,
|
|
857
|
+
completeTickets: state.completeTicketCount,
|
|
858
|
+
openTickets: state.openTicketCount,
|
|
859
|
+
blockedTickets: state.blockedCount,
|
|
860
|
+
openIssues: state.openIssueCount,
|
|
861
|
+
handovers: state.handoverFilenames.length,
|
|
862
|
+
phases: phases.map((p) => ({
|
|
863
|
+
id: p.phase.id,
|
|
864
|
+
name: p.phase.name,
|
|
865
|
+
status: p.status,
|
|
866
|
+
leafCount: p.leafCount
|
|
867
|
+
}))
|
|
868
|
+
};
|
|
869
|
+
if (format === "json") {
|
|
870
|
+
return JSON.stringify(successEnvelope(data), null, 2);
|
|
871
|
+
}
|
|
872
|
+
const lines = [
|
|
873
|
+
`# ${escapeMarkdownInline(state.config.project)}`,
|
|
874
|
+
"",
|
|
875
|
+
`Tickets: ${state.completeTicketCount}/${state.totalTicketCount} complete, ${state.blockedCount} blocked`,
|
|
876
|
+
`Issues: ${state.openIssueCount} open`,
|
|
877
|
+
`Handovers: ${state.handoverFilenames.length}`,
|
|
878
|
+
"",
|
|
879
|
+
"## Phases",
|
|
880
|
+
""
|
|
881
|
+
];
|
|
882
|
+
for (const p of phases) {
|
|
883
|
+
const indicator = p.status === "complete" ? "[x]" : p.status === "inprogress" ? "[~]" : "[ ]";
|
|
884
|
+
const summary = p.phase.summary ?? truncate(p.phase.description, 80);
|
|
885
|
+
lines.push(`${indicator} **${escapeMarkdownInline(p.phase.name)}** (${p.leafCount} tickets) \u2014 ${escapeMarkdownInline(summary)}`);
|
|
886
|
+
}
|
|
887
|
+
return lines.join("\n");
|
|
888
|
+
}
|
|
889
|
+
function formatPhaseList(state, format) {
|
|
890
|
+
const phases = phasesWithStatus(state);
|
|
891
|
+
const data = phases.map((p) => ({
|
|
892
|
+
id: p.phase.id,
|
|
893
|
+
label: p.phase.label,
|
|
894
|
+
name: p.phase.name,
|
|
895
|
+
description: p.phase.summary ?? p.phase.description,
|
|
896
|
+
status: p.status,
|
|
897
|
+
leafCount: p.leafCount
|
|
898
|
+
}));
|
|
899
|
+
if (format === "json") {
|
|
900
|
+
return JSON.stringify(successEnvelope(data), null, 2);
|
|
901
|
+
}
|
|
902
|
+
const lines = [];
|
|
903
|
+
for (const p of data) {
|
|
904
|
+
const indicator = p.status === "complete" ? "[x]" : p.status === "inprogress" ? "[~]" : "[ ]";
|
|
905
|
+
lines.push(`${indicator} **${escapeMarkdownInline(p.name)}** (${p.id}) \u2014 ${p.leafCount} tickets \u2014 ${escapeMarkdownInline(truncate(p.description, 80))}`);
|
|
906
|
+
}
|
|
907
|
+
return lines.join("\n");
|
|
908
|
+
}
|
|
909
|
+
function formatPhaseTickets(phaseId, state, format) {
|
|
910
|
+
const tickets = state.phaseTickets(phaseId);
|
|
911
|
+
if (format === "json") {
|
|
912
|
+
return JSON.stringify(successEnvelope(tickets), null, 2);
|
|
913
|
+
}
|
|
914
|
+
if (tickets.length === 0) return "No tickets in this phase.";
|
|
915
|
+
return tickets.map((t) => formatTicketOneLiner(t, state)).join("\n");
|
|
916
|
+
}
|
|
917
|
+
function formatTicket(ticket, state, format) {
|
|
918
|
+
if (format === "json") {
|
|
919
|
+
return JSON.stringify(successEnvelope(ticket), null, 2);
|
|
920
|
+
}
|
|
921
|
+
const blocked = state.isBlocked(ticket) ? " [BLOCKED]" : "";
|
|
922
|
+
const lines = [
|
|
923
|
+
`# ${escapeMarkdownInline(ticket.id)}: ${escapeMarkdownInline(ticket.title)}${blocked}`,
|
|
924
|
+
"",
|
|
925
|
+
`Status: ${ticket.status} | Type: ${ticket.type} | Phase: ${ticket.phase ?? "none"} | Order: ${ticket.order}`,
|
|
926
|
+
`Created: ${ticket.createdDate}${ticket.completedDate ? ` | Completed: ${ticket.completedDate}` : ""}`
|
|
927
|
+
];
|
|
928
|
+
if (ticket.blockedBy.length > 0) {
|
|
929
|
+
lines.push(`Blocked by: ${ticket.blockedBy.join(", ")}`);
|
|
930
|
+
}
|
|
931
|
+
if (ticket.parentTicket) {
|
|
932
|
+
lines.push(`Parent: ${ticket.parentTicket}`);
|
|
933
|
+
}
|
|
934
|
+
if (ticket.description) {
|
|
935
|
+
lines.push("", "## Description", "", fencedBlock(ticket.description));
|
|
936
|
+
}
|
|
937
|
+
return lines.join("\n");
|
|
938
|
+
}
|
|
939
|
+
function formatNextTicketOutcome(outcome, state, format) {
|
|
940
|
+
if (format === "json") {
|
|
941
|
+
return JSON.stringify(successEnvelope(outcome), null, 2);
|
|
942
|
+
}
|
|
943
|
+
switch (outcome.kind) {
|
|
944
|
+
case "empty_project":
|
|
945
|
+
return "No phased tickets found.";
|
|
946
|
+
case "all_complete":
|
|
947
|
+
return "All phases complete.";
|
|
948
|
+
case "all_blocked": {
|
|
949
|
+
return `All ${outcome.blockedCount} incomplete tickets in phase "${escapeMarkdownInline(outcome.phaseId)}" are blocked.`;
|
|
950
|
+
}
|
|
951
|
+
case "found": {
|
|
952
|
+
const t = outcome.ticket;
|
|
953
|
+
const lines = [
|
|
954
|
+
`# Next: ${escapeMarkdownInline(t.id)} \u2014 ${escapeMarkdownInline(t.title)}`,
|
|
955
|
+
"",
|
|
956
|
+
`Phase: ${t.phase ?? "none"} | Order: ${t.order} | Type: ${t.type}`
|
|
957
|
+
];
|
|
958
|
+
if (outcome.unblockImpact.wouldUnblock.length > 0) {
|
|
959
|
+
const ids = outcome.unblockImpact.wouldUnblock.map((u) => u.id).join(", ");
|
|
960
|
+
lines.push(`Completing this unblocks: ${ids}`);
|
|
961
|
+
}
|
|
962
|
+
if (outcome.umbrellaProgress) {
|
|
963
|
+
const p = outcome.umbrellaProgress;
|
|
964
|
+
lines.push(`Parent progress: ${p.complete}/${p.total} complete (${p.status})`);
|
|
965
|
+
}
|
|
966
|
+
if (t.description) {
|
|
967
|
+
lines.push("", fencedBlock(t.description));
|
|
968
|
+
}
|
|
969
|
+
return lines.join("\n");
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
function formatTicketList(tickets, format) {
|
|
974
|
+
if (format === "json") {
|
|
975
|
+
return JSON.stringify(successEnvelope(tickets), null, 2);
|
|
976
|
+
}
|
|
977
|
+
if (tickets.length === 0) return "No tickets found.";
|
|
978
|
+
const lines = [];
|
|
979
|
+
for (const t of tickets) {
|
|
980
|
+
const status = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
|
|
981
|
+
lines.push(`${status} ${t.id}: ${escapeMarkdownInline(t.title)} (${t.phase ?? "none"})`);
|
|
982
|
+
}
|
|
983
|
+
return lines.join("\n");
|
|
984
|
+
}
|
|
985
|
+
function formatIssue(issue, format) {
|
|
986
|
+
if (format === "json") {
|
|
987
|
+
return JSON.stringify(successEnvelope(issue), null, 2);
|
|
988
|
+
}
|
|
989
|
+
const lines = [
|
|
990
|
+
`# ${escapeMarkdownInline(issue.id)}: ${escapeMarkdownInline(issue.title)}`,
|
|
991
|
+
"",
|
|
992
|
+
`Status: ${issue.status} | Severity: ${issue.severity}`,
|
|
993
|
+
`Components: ${issue.components.join(", ") || "none"}`,
|
|
994
|
+
`Discovered: ${issue.discoveredDate}${issue.resolvedDate ? ` | Resolved: ${issue.resolvedDate}` : ""}`
|
|
995
|
+
];
|
|
996
|
+
if (issue.relatedTickets.length > 0) {
|
|
997
|
+
lines.push(`Related: ${issue.relatedTickets.join(", ")}`);
|
|
998
|
+
}
|
|
999
|
+
lines.push("", "## Impact", "", fencedBlock(issue.impact));
|
|
1000
|
+
if (issue.resolution) {
|
|
1001
|
+
lines.push("", "## Resolution", "", fencedBlock(issue.resolution));
|
|
1002
|
+
}
|
|
1003
|
+
return lines.join("\n");
|
|
1004
|
+
}
|
|
1005
|
+
function formatIssueList(issues, format) {
|
|
1006
|
+
if (format === "json") {
|
|
1007
|
+
return JSON.stringify(successEnvelope(issues), null, 2);
|
|
1008
|
+
}
|
|
1009
|
+
if (issues.length === 0) return "No issues found.";
|
|
1010
|
+
const lines = [];
|
|
1011
|
+
for (const i of issues) {
|
|
1012
|
+
const status = i.status === "resolved" ? "[x]" : "[ ]";
|
|
1013
|
+
lines.push(`${status} ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}`);
|
|
1014
|
+
}
|
|
1015
|
+
return lines.join("\n");
|
|
1016
|
+
}
|
|
1017
|
+
function formatBlockedTickets(tickets, state, format) {
|
|
1018
|
+
if (format === "json") {
|
|
1019
|
+
return JSON.stringify(
|
|
1020
|
+
successEnvelope(
|
|
1021
|
+
tickets.map((t) => ({
|
|
1022
|
+
...t,
|
|
1023
|
+
blockers: t.blockedBy.map((bid) => ({
|
|
1024
|
+
id: bid,
|
|
1025
|
+
status: state.ticketByID(bid)?.status ?? "unknown"
|
|
1026
|
+
}))
|
|
1027
|
+
}))
|
|
1028
|
+
),
|
|
1029
|
+
null,
|
|
1030
|
+
2
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
if (tickets.length === 0) return "No blocked tickets.";
|
|
1034
|
+
const lines = [];
|
|
1035
|
+
for (const t of tickets) {
|
|
1036
|
+
const blockerInfo = t.blockedBy.map((bid) => {
|
|
1037
|
+
const b = state.ticketByID(bid);
|
|
1038
|
+
return b ? `${bid} (${b.status})` : `${bid} (unknown)`;
|
|
1039
|
+
}).join(", ");
|
|
1040
|
+
lines.push(`${t.id}: ${escapeMarkdownInline(t.title)} \u2014 blocked by: ${blockerInfo}`);
|
|
1041
|
+
}
|
|
1042
|
+
return lines.join("\n");
|
|
1043
|
+
}
|
|
1044
|
+
function formatValidation(result, format) {
|
|
1045
|
+
if (format === "json") {
|
|
1046
|
+
return JSON.stringify(successEnvelope(result), null, 2);
|
|
1047
|
+
}
|
|
1048
|
+
const lines = [
|
|
1049
|
+
result.valid ? "Validation passed." : "Validation failed.",
|
|
1050
|
+
`Errors: ${result.errorCount} | Warnings: ${result.warningCount} | Info: ${result.infoCount}`
|
|
1051
|
+
];
|
|
1052
|
+
if (result.findings.length > 0) {
|
|
1053
|
+
lines.push("");
|
|
1054
|
+
for (const f of result.findings) {
|
|
1055
|
+
const prefix = f.level === "error" ? "ERROR" : f.level === "warning" ? "WARN" : "INFO";
|
|
1056
|
+
const entity = f.entity ? `[${escapeMarkdownInline(f.entity)}] ` : "";
|
|
1057
|
+
lines.push(`${prefix}: ${entity}${escapeMarkdownInline(f.message)}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return lines.join("\n");
|
|
1061
|
+
}
|
|
1062
|
+
function formatBlockerList(roadmap, format) {
|
|
1063
|
+
if (format === "json") {
|
|
1064
|
+
return JSON.stringify(
|
|
1065
|
+
successEnvelope(
|
|
1066
|
+
roadmap.blockers.map((b) => ({
|
|
1067
|
+
name: b.name,
|
|
1068
|
+
cleared: isBlockerCleared(b),
|
|
1069
|
+
note: b.note ?? null,
|
|
1070
|
+
createdDate: b.createdDate ?? null,
|
|
1071
|
+
clearedDate: b.clearedDate ?? null
|
|
1072
|
+
}))
|
|
1073
|
+
),
|
|
1074
|
+
null,
|
|
1075
|
+
2
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
if (roadmap.blockers.length === 0) return "No blockers.";
|
|
1079
|
+
const lines = [];
|
|
1080
|
+
for (const b of roadmap.blockers) {
|
|
1081
|
+
const status = isBlockerCleared(b) ? "[x]" : "[ ]";
|
|
1082
|
+
const note = b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : "";
|
|
1083
|
+
lines.push(`${status} ${escapeMarkdownInline(b.name)}${note}`);
|
|
1084
|
+
}
|
|
1085
|
+
return lines.join("\n");
|
|
1086
|
+
}
|
|
1087
|
+
function formatError(code, message, format) {
|
|
1088
|
+
if (format === "json") {
|
|
1089
|
+
return JSON.stringify(errorEnvelope(code, message), null, 2);
|
|
1090
|
+
}
|
|
1091
|
+
return `Error [${code}]: ${escapeMarkdownInline(message)}`;
|
|
1092
|
+
}
|
|
1093
|
+
function formatHandoverList(filenames, format) {
|
|
1094
|
+
if (format === "json") {
|
|
1095
|
+
return JSON.stringify(successEnvelope(filenames), null, 2);
|
|
1096
|
+
}
|
|
1097
|
+
if (filenames.length === 0) return "No handovers found.";
|
|
1098
|
+
return filenames.join("\n");
|
|
1099
|
+
}
|
|
1100
|
+
function formatHandoverContent(filename, content, format) {
|
|
1101
|
+
if (format === "json") {
|
|
1102
|
+
return JSON.stringify(successEnvelope({ filename, content }), null, 2);
|
|
1103
|
+
}
|
|
1104
|
+
return content;
|
|
1105
|
+
}
|
|
1106
|
+
function truncate(text, maxLen) {
|
|
1107
|
+
if (text.length <= maxLen) return text;
|
|
1108
|
+
return text.slice(0, maxLen - 3) + "...";
|
|
1109
|
+
}
|
|
1110
|
+
function formatTicketOneLiner(t, state) {
|
|
1111
|
+
const status = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
|
|
1112
|
+
const blocked = state.isBlocked(t) ? " [BLOCKED]" : "";
|
|
1113
|
+
return `${status} ${t.id}: ${escapeMarkdownInline(t.title)}${blocked}`;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// src/cli/commands/status.ts
|
|
1117
|
+
function handleStatus(ctx) {
|
|
1118
|
+
return { output: formatStatus(ctx.state, ctx.format) };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/core/validation.ts
|
|
1122
|
+
function validateProject(state) {
|
|
1123
|
+
const findings = [];
|
|
1124
|
+
const phaseIDs = new Set(state.roadmap.phases.map((p) => p.id));
|
|
1125
|
+
const ticketIDs = /* @__PURE__ */ new Set();
|
|
1126
|
+
const issueIDs = /* @__PURE__ */ new Set();
|
|
1127
|
+
const ticketIDCounts = /* @__PURE__ */ new Map();
|
|
1128
|
+
for (const t of state.tickets) {
|
|
1129
|
+
ticketIDCounts.set(t.id, (ticketIDCounts.get(t.id) ?? 0) + 1);
|
|
1130
|
+
ticketIDs.add(t.id);
|
|
1131
|
+
}
|
|
1132
|
+
for (const [id, count] of ticketIDCounts) {
|
|
1133
|
+
if (count > 1) {
|
|
1134
|
+
findings.push({
|
|
1135
|
+
level: "error",
|
|
1136
|
+
code: "duplicate_ticket_id",
|
|
1137
|
+
message: `Duplicate ticket ID: ${id} appears ${count} times.`,
|
|
1138
|
+
entity: id
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const issueIDCounts = /* @__PURE__ */ new Map();
|
|
1143
|
+
for (const i of state.issues) {
|
|
1144
|
+
issueIDCounts.set(i.id, (issueIDCounts.get(i.id) ?? 0) + 1);
|
|
1145
|
+
issueIDs.add(i.id);
|
|
1146
|
+
}
|
|
1147
|
+
for (const [id, count] of issueIDCounts) {
|
|
1148
|
+
if (count > 1) {
|
|
1149
|
+
findings.push({
|
|
1150
|
+
level: "error",
|
|
1151
|
+
code: "duplicate_issue_id",
|
|
1152
|
+
message: `Duplicate issue ID: ${id} appears ${count} times.`,
|
|
1153
|
+
entity: id
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
const phaseIDCounts = /* @__PURE__ */ new Map();
|
|
1158
|
+
for (const p of state.roadmap.phases) {
|
|
1159
|
+
phaseIDCounts.set(p.id, (phaseIDCounts.get(p.id) ?? 0) + 1);
|
|
1160
|
+
}
|
|
1161
|
+
for (const [id, count] of phaseIDCounts) {
|
|
1162
|
+
if (count > 1) {
|
|
1163
|
+
findings.push({
|
|
1164
|
+
level: "error",
|
|
1165
|
+
code: "duplicate_phase_id",
|
|
1166
|
+
message: `Duplicate phase ID: ${id} appears ${count} times.`,
|
|
1167
|
+
entity: id
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
for (const t of state.tickets) {
|
|
1172
|
+
if (t.phase !== null && !phaseIDs.has(t.phase)) {
|
|
1173
|
+
findings.push({
|
|
1174
|
+
level: "error",
|
|
1175
|
+
code: "invalid_phase_ref",
|
|
1176
|
+
message: `Ticket ${t.id} references unknown phase "${t.phase}".`,
|
|
1177
|
+
entity: t.id
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
for (const bid of t.blockedBy) {
|
|
1181
|
+
if (bid === t.id) {
|
|
1182
|
+
findings.push({
|
|
1183
|
+
level: "error",
|
|
1184
|
+
code: "self_ref_blocked_by",
|
|
1185
|
+
message: `Ticket ${t.id} references itself in blockedBy.`,
|
|
1186
|
+
entity: t.id
|
|
1187
|
+
});
|
|
1188
|
+
} else if (!ticketIDs.has(bid)) {
|
|
1189
|
+
findings.push({
|
|
1190
|
+
level: "error",
|
|
1191
|
+
code: "invalid_blocked_by_ref",
|
|
1192
|
+
message: `Ticket ${t.id} blockedBy references nonexistent ticket ${bid}.`,
|
|
1193
|
+
entity: t.id
|
|
1194
|
+
});
|
|
1195
|
+
} else if (state.umbrellaIDs.has(bid)) {
|
|
1196
|
+
findings.push({
|
|
1197
|
+
level: "error",
|
|
1198
|
+
code: "blocked_by_umbrella",
|
|
1199
|
+
message: `Ticket ${t.id} blockedBy references umbrella ticket ${bid}. Use leaf tickets instead.`,
|
|
1200
|
+
entity: t.id
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (t.parentTicket != null) {
|
|
1205
|
+
if (t.parentTicket === t.id) {
|
|
1206
|
+
findings.push({
|
|
1207
|
+
level: "error",
|
|
1208
|
+
code: "self_ref_parent",
|
|
1209
|
+
message: `Ticket ${t.id} references itself as parentTicket.`,
|
|
1210
|
+
entity: t.id
|
|
1211
|
+
});
|
|
1212
|
+
} else if (!ticketIDs.has(t.parentTicket)) {
|
|
1213
|
+
findings.push({
|
|
1214
|
+
level: "error",
|
|
1215
|
+
code: "invalid_parent_ref",
|
|
1216
|
+
message: `Ticket ${t.id} parentTicket references nonexistent ticket ${t.parentTicket}.`,
|
|
1217
|
+
entity: t.id
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
detectParentCycles(state, findings);
|
|
1223
|
+
detectBlockedByCycles(state, findings);
|
|
1224
|
+
for (const i of state.issues) {
|
|
1225
|
+
for (const tref of i.relatedTickets) {
|
|
1226
|
+
if (!ticketIDs.has(tref)) {
|
|
1227
|
+
findings.push({
|
|
1228
|
+
level: "error",
|
|
1229
|
+
code: "invalid_related_ticket_ref",
|
|
1230
|
+
message: `Issue ${i.id} relatedTickets references nonexistent ticket ${tref}.`,
|
|
1231
|
+
entity: i.id
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (i.phase != null && !phaseIDs.has(i.phase)) {
|
|
1236
|
+
findings.push({
|
|
1237
|
+
level: "error",
|
|
1238
|
+
code: "invalid_phase_ref",
|
|
1239
|
+
message: `Issue ${i.id} references unknown phase "${i.phase}".`,
|
|
1240
|
+
entity: i.id
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
if (i.relatedTickets.length === 0 && i.status === "open") {
|
|
1244
|
+
findings.push({
|
|
1245
|
+
level: "warning",
|
|
1246
|
+
code: "orphan_issue",
|
|
1247
|
+
message: `Issue ${i.id} is open with no related tickets.`,
|
|
1248
|
+
entity: i.id
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const orderByPhase = /* @__PURE__ */ new Map();
|
|
1253
|
+
for (const t of state.leafTickets) {
|
|
1254
|
+
const phase = t.phase;
|
|
1255
|
+
if (!orderByPhase.has(phase)) orderByPhase.set(phase, /* @__PURE__ */ new Map());
|
|
1256
|
+
const orders = orderByPhase.get(phase);
|
|
1257
|
+
if (!orders.has(t.order)) orders.set(t.order, []);
|
|
1258
|
+
orders.get(t.order).push(t.id);
|
|
1259
|
+
}
|
|
1260
|
+
for (const [phase, orders] of orderByPhase) {
|
|
1261
|
+
for (const [order, ids] of orders) {
|
|
1262
|
+
if (ids.length > 1) {
|
|
1263
|
+
findings.push({
|
|
1264
|
+
level: "info",
|
|
1265
|
+
code: "duplicate_order",
|
|
1266
|
+
message: `Phase "${phase ?? "null"}": tickets ${ids.join(", ")} share order ${order}.`,
|
|
1267
|
+
entity: null
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
const errorCount = findings.filter((f) => f.level === "error").length;
|
|
1273
|
+
const warningCount = findings.filter((f) => f.level === "warning").length;
|
|
1274
|
+
const infoCount = findings.filter((f) => f.level === "info").length;
|
|
1275
|
+
return {
|
|
1276
|
+
valid: errorCount === 0,
|
|
1277
|
+
errorCount,
|
|
1278
|
+
warningCount,
|
|
1279
|
+
infoCount,
|
|
1280
|
+
findings
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
function mergeValidation(result, loaderWarnings) {
|
|
1284
|
+
if (loaderWarnings.length === 0) return result;
|
|
1285
|
+
const extra = loaderWarnings.map((w) => ({
|
|
1286
|
+
level: w.type === "naming_convention" ? "info" : "error",
|
|
1287
|
+
code: `loader_${w.type}`,
|
|
1288
|
+
message: `${w.file}: ${w.message}`,
|
|
1289
|
+
entity: null
|
|
1290
|
+
}));
|
|
1291
|
+
const allFindings = [...result.findings, ...extra];
|
|
1292
|
+
const errorCount = allFindings.filter((f) => f.level === "error").length;
|
|
1293
|
+
const warningCount = allFindings.filter((f) => f.level === "warning").length;
|
|
1294
|
+
const infoCount = allFindings.filter((f) => f.level === "info").length;
|
|
1295
|
+
return {
|
|
1296
|
+
valid: errorCount === 0,
|
|
1297
|
+
errorCount,
|
|
1298
|
+
warningCount,
|
|
1299
|
+
infoCount,
|
|
1300
|
+
findings: allFindings
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
function detectParentCycles(state, findings) {
|
|
1304
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1305
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
1306
|
+
for (const t of state.tickets) {
|
|
1307
|
+
if (t.parentTicket == null || visited.has(t.id)) continue;
|
|
1308
|
+
dfsParent(t.id, state, visited, inStack, findings);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
function dfsParent(id, state, visited, inStack, findings) {
|
|
1312
|
+
if (inStack.has(id)) {
|
|
1313
|
+
findings.push({
|
|
1314
|
+
level: "error",
|
|
1315
|
+
code: "parent_cycle",
|
|
1316
|
+
message: `Cycle detected in parentTicket chain involving ${id}.`,
|
|
1317
|
+
entity: id
|
|
1318
|
+
});
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if (visited.has(id)) return;
|
|
1322
|
+
inStack.add(id);
|
|
1323
|
+
const ticket = state.ticketByID(id);
|
|
1324
|
+
if (ticket?.parentTicket && ticket.parentTicket !== id) {
|
|
1325
|
+
dfsParent(ticket.parentTicket, state, visited, inStack, findings);
|
|
1326
|
+
}
|
|
1327
|
+
inStack.delete(id);
|
|
1328
|
+
visited.add(id);
|
|
1329
|
+
}
|
|
1330
|
+
function detectBlockedByCycles(state, findings) {
|
|
1331
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1332
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
1333
|
+
for (const t of state.tickets) {
|
|
1334
|
+
if (t.blockedBy.length === 0 || visited.has(t.id)) continue;
|
|
1335
|
+
dfsBlocked(t.id, state, visited, inStack, findings);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
function dfsBlocked(id, state, visited, inStack, findings) {
|
|
1339
|
+
if (inStack.has(id)) {
|
|
1340
|
+
findings.push({
|
|
1341
|
+
level: "error",
|
|
1342
|
+
code: "blocked_by_cycle",
|
|
1343
|
+
message: `Cycle detected in blockedBy chain involving ${id}.`,
|
|
1344
|
+
entity: id
|
|
1345
|
+
});
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (visited.has(id)) return;
|
|
1349
|
+
inStack.add(id);
|
|
1350
|
+
const ticket = state.ticketByID(id);
|
|
1351
|
+
if (ticket) {
|
|
1352
|
+
for (const bid of ticket.blockedBy) {
|
|
1353
|
+
if (bid !== id) {
|
|
1354
|
+
dfsBlocked(bid, state, visited, inStack, findings);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
inStack.delete(id);
|
|
1359
|
+
visited.add(id);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// src/cli/commands/validate.ts
|
|
1363
|
+
function handleValidate(ctx) {
|
|
1364
|
+
const baseResult = validateProject(ctx.state);
|
|
1365
|
+
const merged = mergeValidation(baseResult, ctx.warnings);
|
|
1366
|
+
return {
|
|
1367
|
+
output: formatValidation(merged, ctx.format),
|
|
1368
|
+
exitCode: merged.valid ? ExitCode.OK : ExitCode.VALIDATION_ERROR
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// src/cli/commands/handover.ts
|
|
1373
|
+
function handleHandoverList(ctx) {
|
|
1374
|
+
return { output: formatHandoverList(ctx.state.handoverFilenames, ctx.format) };
|
|
1375
|
+
}
|
|
1376
|
+
async function handleHandoverLatest(ctx) {
|
|
1377
|
+
if (ctx.state.handoverFilenames.length === 0) {
|
|
1378
|
+
return {
|
|
1379
|
+
output: formatError("not_found", "No handovers found", ctx.format),
|
|
1380
|
+
exitCode: ExitCode.USER_ERROR,
|
|
1381
|
+
errorCode: "not_found"
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
const filename = ctx.state.handoverFilenames[0];
|
|
1385
|
+
await parseHandoverFilename(filename, ctx.handoversDir);
|
|
1386
|
+
try {
|
|
1387
|
+
const content = await readHandover(ctx.handoversDir, filename);
|
|
1388
|
+
return { output: formatHandoverContent(filename, content, ctx.format) };
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
if (err.code === "ENOENT") {
|
|
1391
|
+
return {
|
|
1392
|
+
output: formatError("not_found", `Handover file not found: ${filename}`, ctx.format),
|
|
1393
|
+
exitCode: ExitCode.USER_ERROR,
|
|
1394
|
+
errorCode: "not_found"
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
return {
|
|
1398
|
+
output: formatError("io_error", `Cannot read handover: ${err.message}`, ctx.format),
|
|
1399
|
+
exitCode: ExitCode.USER_ERROR,
|
|
1400
|
+
errorCode: "io_error"
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
async function handleHandoverGet(filename, ctx) {
|
|
1405
|
+
await parseHandoverFilename(filename, ctx.handoversDir);
|
|
1406
|
+
try {
|
|
1407
|
+
const content = await readHandover(ctx.handoversDir, filename);
|
|
1408
|
+
return { output: formatHandoverContent(filename, content, ctx.format) };
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
if (err.code === "ENOENT") {
|
|
1411
|
+
return {
|
|
1412
|
+
output: formatError("not_found", `Handover not found: ${filename}`, ctx.format),
|
|
1413
|
+
exitCode: ExitCode.USER_ERROR,
|
|
1414
|
+
errorCode: "not_found"
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
output: formatError("io_error", `Cannot read handover: ${err.message}`, ctx.format),
|
|
1419
|
+
exitCode: ExitCode.USER_ERROR,
|
|
1420
|
+
errorCode: "io_error"
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// src/cli/commands/blocker.ts
|
|
1426
|
+
function handleBlockerList(ctx) {
|
|
1427
|
+
return { output: formatBlockerList(ctx.state.roadmap, ctx.format) };
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// src/cli/commands/ticket.ts
|
|
1431
|
+
function handleTicketList(filters, ctx) {
|
|
1432
|
+
let tickets = [...ctx.state.leafTickets];
|
|
1433
|
+
if (filters.status) {
|
|
1434
|
+
if (!TICKET_STATUSES.includes(filters.status)) {
|
|
1435
|
+
throw new CliValidationError(
|
|
1436
|
+
"invalid_input",
|
|
1437
|
+
`Unknown ticket status "${filters.status}": must be one of ${TICKET_STATUSES.join(", ")}`
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
tickets = tickets.filter((t) => t.status === filters.status);
|
|
1441
|
+
}
|
|
1442
|
+
if (filters.phase) {
|
|
1443
|
+
tickets = tickets.filter((t) => t.phase === filters.phase);
|
|
1444
|
+
}
|
|
1445
|
+
if (filters.type) {
|
|
1446
|
+
if (!TICKET_TYPES.includes(filters.type)) {
|
|
1447
|
+
throw new CliValidationError(
|
|
1448
|
+
"invalid_input",
|
|
1449
|
+
`Unknown ticket type "${filters.type}": must be one of ${TICKET_TYPES.join(", ")}`
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
tickets = tickets.filter((t) => t.type === filters.type);
|
|
1453
|
+
}
|
|
1454
|
+
return { output: formatTicketList(tickets, ctx.format) };
|
|
1455
|
+
}
|
|
1456
|
+
function handleTicketGet(id, ctx) {
|
|
1457
|
+
const ticket = ctx.state.ticketByID(id);
|
|
1458
|
+
if (!ticket) {
|
|
1459
|
+
return {
|
|
1460
|
+
output: formatError("not_found", `Ticket ${id} not found`, ctx.format),
|
|
1461
|
+
exitCode: ExitCode.USER_ERROR,
|
|
1462
|
+
errorCode: "not_found"
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
return { output: formatTicket(ticket, ctx.state, ctx.format) };
|
|
1466
|
+
}
|
|
1467
|
+
function handleTicketNext(ctx) {
|
|
1468
|
+
const outcome = nextTicket(ctx.state);
|
|
1469
|
+
const exitCode = outcome.kind === "found" ? ExitCode.OK : ExitCode.USER_ERROR;
|
|
1470
|
+
return {
|
|
1471
|
+
output: formatNextTicketOutcome(outcome, ctx.state, ctx.format),
|
|
1472
|
+
exitCode
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
function handleTicketBlocked(ctx) {
|
|
1476
|
+
const blocked = blockedTickets(ctx.state);
|
|
1477
|
+
return { output: formatBlockedTickets(blocked, ctx.state, ctx.format) };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// src/cli/commands/issue.ts
|
|
1481
|
+
function handleIssueList(filters, ctx) {
|
|
1482
|
+
let issues = [...ctx.state.issues];
|
|
1483
|
+
if (filters.status) {
|
|
1484
|
+
if (!ISSUE_STATUSES.includes(filters.status)) {
|
|
1485
|
+
throw new CliValidationError(
|
|
1486
|
+
"invalid_input",
|
|
1487
|
+
`Unknown issue status "${filters.status}": must be one of ${ISSUE_STATUSES.join(", ")}`
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
issues = issues.filter((i) => i.status === filters.status);
|
|
1491
|
+
}
|
|
1492
|
+
if (filters.severity) {
|
|
1493
|
+
if (!ISSUE_SEVERITIES.includes(filters.severity)) {
|
|
1494
|
+
throw new CliValidationError(
|
|
1495
|
+
"invalid_input",
|
|
1496
|
+
`Unknown issue severity "${filters.severity}": must be one of ${ISSUE_SEVERITIES.join(", ")}`
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
issues = issues.filter((i) => i.severity === filters.severity);
|
|
1500
|
+
}
|
|
1501
|
+
return { output: formatIssueList(issues, ctx.format) };
|
|
1502
|
+
}
|
|
1503
|
+
function handleIssueGet(id, ctx) {
|
|
1504
|
+
const issue = ctx.state.issueByID(id);
|
|
1505
|
+
if (!issue) {
|
|
1506
|
+
return {
|
|
1507
|
+
output: formatError("not_found", `Issue ${id} not found`, ctx.format),
|
|
1508
|
+
exitCode: ExitCode.USER_ERROR,
|
|
1509
|
+
errorCode: "not_found"
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
return { output: formatIssue(issue, ctx.format) };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// src/cli/commands/phase.ts
|
|
1516
|
+
import { join as join4, resolve as resolve4 } from "path";
|
|
1517
|
+
function handlePhaseList(ctx) {
|
|
1518
|
+
return { output: formatPhaseList(ctx.state, ctx.format) };
|
|
1519
|
+
}
|
|
1520
|
+
function handlePhaseCurrent(ctx) {
|
|
1521
|
+
const phase = currentPhase(ctx.state);
|
|
1522
|
+
if (phase) {
|
|
1523
|
+
if (ctx.format === "json") {
|
|
1524
|
+
return { output: JSON.stringify(successEnvelope(phase), null, 2) };
|
|
1525
|
+
}
|
|
1526
|
+
const summary = phase.summary ?? phase.description;
|
|
1527
|
+
return { output: `${phase.name} (${phase.id}) \u2014 ${summary}` };
|
|
1528
|
+
}
|
|
1529
|
+
const hasLeavesInAnyPhase = ctx.state.roadmap.phases.some(
|
|
1530
|
+
(p) => ctx.state.phaseTickets(p.id).length > 0
|
|
1531
|
+
);
|
|
1532
|
+
if (!hasLeavesInAnyPhase) {
|
|
1533
|
+
if (ctx.format === "json") {
|
|
1534
|
+
return {
|
|
1535
|
+
output: JSON.stringify(successEnvelope({ current: null, reason: "no_phases" }), null, 2),
|
|
1536
|
+
exitCode: ExitCode.USER_ERROR
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
return {
|
|
1540
|
+
output: "No phases with tickets defined.",
|
|
1541
|
+
exitCode: ExitCode.USER_ERROR
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
if (ctx.format === "json") {
|
|
1545
|
+
return {
|
|
1546
|
+
output: JSON.stringify(successEnvelope({ current: null, reason: "all_complete" }), null, 2)
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
return { output: "All phases complete." };
|
|
1550
|
+
}
|
|
1551
|
+
function handlePhaseTickets(phaseId, ctx) {
|
|
1552
|
+
const phaseExists = ctx.state.roadmap.phases.some((p) => p.id === phaseId);
|
|
1553
|
+
if (!phaseExists) {
|
|
1554
|
+
return {
|
|
1555
|
+
output: formatError("not_found", `Phase "${phaseId}" not found`, ctx.format),
|
|
1556
|
+
exitCode: ExitCode.USER_ERROR,
|
|
1557
|
+
errorCode: "not_found"
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
return { output: formatPhaseTickets(phaseId, ctx.state, ctx.format) };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/mcp/tools.ts
|
|
1564
|
+
var INFRASTRUCTURE_ERROR_CODES = [
|
|
1565
|
+
"io_error",
|
|
1566
|
+
"project_corrupt",
|
|
1567
|
+
"version_mismatch"
|
|
1568
|
+
];
|
|
1569
|
+
function formatMcpError(code, message) {
|
|
1570
|
+
return `[${code}] ${message}`;
|
|
1571
|
+
}
|
|
1572
|
+
async function runMcpReadTool(pinnedRoot, handler) {
|
|
1573
|
+
try {
|
|
1574
|
+
const { state, warnings } = await loadProject(pinnedRoot);
|
|
1575
|
+
const handoversDir = join5(pinnedRoot, ".story", "handovers");
|
|
1576
|
+
const ctx = { state, warnings, root: pinnedRoot, handoversDir, format: "md" };
|
|
1577
|
+
const result = await handler(ctx);
|
|
1578
|
+
if (result.errorCode && INFRASTRUCTURE_ERROR_CODES.includes(result.errorCode)) {
|
|
1579
|
+
return {
|
|
1580
|
+
content: [{ type: "text", text: formatMcpError(result.errorCode, result.output) }],
|
|
1581
|
+
isError: true
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
let text = result.output;
|
|
1585
|
+
const integrityWarnings = warnings.filter(
|
|
1586
|
+
(w) => INTEGRITY_WARNING_TYPES.includes(w.type)
|
|
1587
|
+
);
|
|
1588
|
+
if (integrityWarnings.length > 0) {
|
|
1589
|
+
text = `Warning: ${integrityWarnings.length} item(s) skipped due to data integrity issues. Run claudestory_validate for details.
|
|
1590
|
+
|
|
1591
|
+
${text}`;
|
|
1592
|
+
}
|
|
1593
|
+
return { content: [{ type: "text", text }] };
|
|
1594
|
+
} catch (err) {
|
|
1595
|
+
if (err instanceof ProjectLoaderError) {
|
|
1596
|
+
return { content: [{ type: "text", text: formatMcpError(err.code, err.message) }], isError: true };
|
|
1597
|
+
}
|
|
1598
|
+
if (err instanceof CliValidationError) {
|
|
1599
|
+
return { content: [{ type: "text", text: formatMcpError(err.code, err.message) }], isError: true };
|
|
1600
|
+
}
|
|
1601
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1602
|
+
return { content: [{ type: "text", text: formatMcpError("io_error", message) }], isError: true };
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
function registerAllTools(server, pinnedRoot) {
|
|
1606
|
+
server.registerTool("claudestory_status", {
|
|
1607
|
+
description: "Project summary: phase statuses, ticket/issue counts, blockers, current phase"
|
|
1608
|
+
}, () => runMcpReadTool(pinnedRoot, handleStatus));
|
|
1609
|
+
server.registerTool("claudestory_phase_list", {
|
|
1610
|
+
description: "All phases with derived status (complete/inprogress/notstarted)"
|
|
1611
|
+
}, () => runMcpReadTool(pinnedRoot, handlePhaseList));
|
|
1612
|
+
server.registerTool("claudestory_phase_current", {
|
|
1613
|
+
description: "First non-complete phase with its description"
|
|
1614
|
+
}, () => runMcpReadTool(pinnedRoot, handlePhaseCurrent));
|
|
1615
|
+
server.registerTool("claudestory_ticket_next", {
|
|
1616
|
+
description: "Highest-priority unblocked ticket with unblock impact and umbrella progress"
|
|
1617
|
+
}, () => runMcpReadTool(pinnedRoot, handleTicketNext));
|
|
1618
|
+
server.registerTool("claudestory_ticket_blocked", {
|
|
1619
|
+
description: "All blocked tickets with their blocking dependencies"
|
|
1620
|
+
}, () => runMcpReadTool(pinnedRoot, handleTicketBlocked));
|
|
1621
|
+
server.registerTool("claudestory_handover_list", {
|
|
1622
|
+
description: "List handover filenames (newest first)"
|
|
1623
|
+
}, () => runMcpReadTool(pinnedRoot, handleHandoverList));
|
|
1624
|
+
server.registerTool("claudestory_handover_latest", {
|
|
1625
|
+
description: "Content of the most recent handover document"
|
|
1626
|
+
}, () => runMcpReadTool(pinnedRoot, handleHandoverLatest));
|
|
1627
|
+
server.registerTool("claudestory_blocker_list", {
|
|
1628
|
+
description: "All roadmap blockers with dates and status"
|
|
1629
|
+
}, () => runMcpReadTool(pinnedRoot, handleBlockerList));
|
|
1630
|
+
server.registerTool("claudestory_validate", {
|
|
1631
|
+
description: "Reference integrity + schema checks on all .story/ files"
|
|
1632
|
+
}, () => runMcpReadTool(pinnedRoot, handleValidate));
|
|
1633
|
+
server.registerTool("claudestory_phase_tickets", {
|
|
1634
|
+
description: "Leaf tickets for a specific phase, sorted by order",
|
|
1635
|
+
inputSchema: {
|
|
1636
|
+
phaseId: z6.string().describe("Phase ID (e.g. p5b, dogfood)")
|
|
1637
|
+
}
|
|
1638
|
+
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => {
|
|
1639
|
+
const phaseExists = ctx.state.roadmap.phases.some((p) => p.id === args.phaseId);
|
|
1640
|
+
if (!phaseExists) {
|
|
1641
|
+
return {
|
|
1642
|
+
output: `Phase "${args.phaseId}" not found in roadmap.`,
|
|
1643
|
+
exitCode: 1,
|
|
1644
|
+
errorCode: "not_found"
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
return handlePhaseTickets(args.phaseId, ctx);
|
|
1648
|
+
}));
|
|
1649
|
+
server.registerTool("claudestory_ticket_list", {
|
|
1650
|
+
description: "List leaf tickets with optional filters",
|
|
1651
|
+
inputSchema: {
|
|
1652
|
+
status: z6.enum(TICKET_STATUSES).optional().describe("Filter by status: open, inprogress, complete"),
|
|
1653
|
+
phase: z6.string().optional().describe("Filter by phase ID"),
|
|
1654
|
+
type: z6.enum(TICKET_TYPES).optional().describe("Filter by type: task, feature, chore")
|
|
1655
|
+
}
|
|
1656
|
+
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => {
|
|
1657
|
+
if (args.phase) {
|
|
1658
|
+
const phaseExists = ctx.state.roadmap.phases.some((p) => p.id === args.phase);
|
|
1659
|
+
if (!phaseExists) {
|
|
1660
|
+
return {
|
|
1661
|
+
output: `Phase "${args.phase}" not found in roadmap.`,
|
|
1662
|
+
exitCode: 1,
|
|
1663
|
+
errorCode: "not_found"
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return handleTicketList(
|
|
1668
|
+
{ status: args.status, phase: args.phase, type: args.type },
|
|
1669
|
+
ctx
|
|
1670
|
+
);
|
|
1671
|
+
}));
|
|
1672
|
+
server.registerTool("claudestory_ticket_get", {
|
|
1673
|
+
description: "Get a ticket by ID (includes umbrella tickets)",
|
|
1674
|
+
inputSchema: {
|
|
1675
|
+
id: z6.string().regex(TICKET_ID_REGEX).describe("Ticket ID (e.g. T-001, T-079b)")
|
|
1676
|
+
}
|
|
1677
|
+
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleTicketGet(args.id, ctx)));
|
|
1678
|
+
server.registerTool("claudestory_issue_list", {
|
|
1679
|
+
description: "List issues with optional filters",
|
|
1680
|
+
inputSchema: {
|
|
1681
|
+
status: z6.enum(ISSUE_STATUSES).optional().describe("Filter by status: open, inprogress, resolved"),
|
|
1682
|
+
severity: z6.enum(ISSUE_SEVERITIES).optional().describe("Filter by severity: critical, high, medium, low")
|
|
1683
|
+
}
|
|
1684
|
+
}, (args) => runMcpReadTool(
|
|
1685
|
+
pinnedRoot,
|
|
1686
|
+
(ctx) => handleIssueList({ status: args.status, severity: args.severity }, ctx)
|
|
1687
|
+
));
|
|
1688
|
+
server.registerTool("claudestory_issue_get", {
|
|
1689
|
+
description: "Get an issue by ID",
|
|
1690
|
+
inputSchema: {
|
|
1691
|
+
id: z6.string().regex(ISSUE_ID_REGEX).describe("Issue ID (e.g. ISS-001)")
|
|
1692
|
+
}
|
|
1693
|
+
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleIssueGet(args.id, ctx)));
|
|
1694
|
+
server.registerTool("claudestory_handover_get", {
|
|
1695
|
+
description: "Content of a specific handover document by filename",
|
|
1696
|
+
inputSchema: {
|
|
1697
|
+
filename: z6.string().describe("Handover filename (e.g. 2026-03-20-session.md)")
|
|
1698
|
+
}
|
|
1699
|
+
}, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleHandoverGet(args.filename, ctx)));
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/mcp/index.ts
|
|
1703
|
+
var ENV_VAR2 = "CLAUDESTORY_PROJECT_ROOT";
|
|
1704
|
+
var CONFIG_PATH2 = ".story/config.json";
|
|
1705
|
+
var version = "0.1.0";
|
|
1706
|
+
function pinProjectRoot() {
|
|
1707
|
+
const envRoot = process.env[ENV_VAR2];
|
|
1708
|
+
if (envRoot) {
|
|
1709
|
+
if (!isAbsolute(envRoot)) {
|
|
1710
|
+
process.stderr.write(`Error: ${ENV_VAR2} must be an absolute path, got: ${envRoot}
|
|
1711
|
+
`);
|
|
1712
|
+
process.exit(1);
|
|
1713
|
+
}
|
|
1714
|
+
const resolved = resolve5(envRoot);
|
|
1715
|
+
let canonical;
|
|
1716
|
+
try {
|
|
1717
|
+
canonical = realpathSync(resolved);
|
|
1718
|
+
} catch {
|
|
1719
|
+
process.stderr.write(`Error: ${ENV_VAR2} path does not exist: ${resolved}
|
|
1720
|
+
`);
|
|
1721
|
+
process.exit(1);
|
|
1722
|
+
}
|
|
1723
|
+
if (!existsSync4(join6(canonical, CONFIG_PATH2))) {
|
|
1724
|
+
process.stderr.write(`Error: No .story/config.json at ${canonical}
|
|
1725
|
+
`);
|
|
1726
|
+
process.exit(1);
|
|
1727
|
+
}
|
|
1728
|
+
return canonical;
|
|
1729
|
+
}
|
|
1730
|
+
const root = discoverProjectRoot();
|
|
1731
|
+
if (!root) {
|
|
1732
|
+
process.stderr.write("Error: No .story/ project found. Set CLAUDESTORY_PROJECT_ROOT or run from a project directory.\n");
|
|
1733
|
+
process.exit(1);
|
|
1734
|
+
}
|
|
1735
|
+
return realpathSync(root);
|
|
1736
|
+
}
|
|
1737
|
+
async function main() {
|
|
1738
|
+
const root = pinProjectRoot();
|
|
1739
|
+
const server = new McpServer(
|
|
1740
|
+
{ name: "claudestory", version },
|
|
1741
|
+
{
|
|
1742
|
+
instructions: "Start with claudestory_status for a project overview, then claudestory_ticket_next for the highest-priority work, then claudestory_handover_latest for session context."
|
|
1743
|
+
}
|
|
1744
|
+
);
|
|
1745
|
+
registerAllTools(server, root);
|
|
1746
|
+
const transport = new StdioServerTransport();
|
|
1747
|
+
await server.connect(transport);
|
|
1748
|
+
process.stderr.write(`claudestory MCP server running (root: ${root})
|
|
1749
|
+
`);
|
|
1750
|
+
}
|
|
1751
|
+
main().catch((err) => {
|
|
1752
|
+
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}
|
|
1753
|
+
`);
|
|
1754
|
+
process.exit(1);
|
|
1755
|
+
});
|