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