@sprintdock/backend 0.4.2

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.
Files changed (107) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +252 -0
  3. package/SERVER.md +25 -0
  4. package/dist/index.d.ts +1536 -0
  5. package/dist/index.js +4103 -0
  6. package/drizzle/0000_fresh_roxanne_simpson.sql +51 -0
  7. package/drizzle/0001_sprint_markdown_content.sql +1 -0
  8. package/drizzle/0002_task_touched_files.sql +8 -0
  9. package/drizzle/meta/0000_snapshot.json +372 -0
  10. package/drizzle/meta/0001_snapshot.json +379 -0
  11. package/drizzle/meta/_journal.json +27 -0
  12. package/drizzle.config.ts +14 -0
  13. package/package.json +40 -0
  14. package/src/application/container.ts +44 -0
  15. package/src/application/dto/plan-sprint-analytics.dto.ts +30 -0
  16. package/src/application/plan.service.ts +123 -0
  17. package/src/application/sprint.service.ts +118 -0
  18. package/src/application/task.service.ts +389 -0
  19. package/src/db/connection.ts +25 -0
  20. package/src/db/migrator.ts +46 -0
  21. package/src/db/schema/index.ts +14 -0
  22. package/src/db/schema/plans.ts +18 -0
  23. package/src/db/schema/relations.ts +36 -0
  24. package/src/db/schema/sprints.ts +33 -0
  25. package/src/db/schema/tasks.ts +62 -0
  26. package/src/domain/entities/index.ts +30 -0
  27. package/src/domain/entities/plan.entity.ts +33 -0
  28. package/src/domain/entities/sprint.entity.ts +44 -0
  29. package/src/domain/entities/task.entity.ts +80 -0
  30. package/src/domain/repositories/index.ts +9 -0
  31. package/src/domain/repositories/plan.repository.ts +21 -0
  32. package/src/domain/repositories/sprint.repository.ts +19 -0
  33. package/src/domain/repositories/task.repository.ts +35 -0
  34. package/src/domain/services/index.ts +9 -0
  35. package/src/domain/services/plan-domain.service.ts +44 -0
  36. package/src/domain/services/sprint-domain.service.ts +44 -0
  37. package/src/domain/services/task-domain.service.ts +136 -0
  38. package/src/errors/backend-errors.ts +75 -0
  39. package/src/http/app-factory.ts +55 -0
  40. package/src/http/controllers/health.controller.ts +33 -0
  41. package/src/http/controllers/plan.controller.ts +153 -0
  42. package/src/http/controllers/sprint.controller.ts +111 -0
  43. package/src/http/controllers/task.controller.ts +158 -0
  44. package/src/http/express-augmentation.d.ts +20 -0
  45. package/src/http/middleware/cors.ts +41 -0
  46. package/src/http/middleware/error-handler.ts +50 -0
  47. package/src/http/middleware/request-id.ts +28 -0
  48. package/src/http/middleware/validate.ts +54 -0
  49. package/src/http/routes/v1/index.ts +39 -0
  50. package/src/http/routes/v1/plan.routes.ts +51 -0
  51. package/src/http/routes/v1/schemas.ts +175 -0
  52. package/src/http/routes/v1/sprint.routes.ts +49 -0
  53. package/src/http/routes/v1/task.routes.ts +64 -0
  54. package/src/index.ts +34 -0
  55. package/src/infrastructure/observability/audit-log.ts +34 -0
  56. package/src/infrastructure/observability/request-correlation.ts +20 -0
  57. package/src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts +138 -0
  58. package/src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts +137 -0
  59. package/src/infrastructure/repositories/drizzle/drizzle-task.repository.ts +403 -0
  60. package/src/infrastructure/repositories/drizzle/index.ts +16 -0
  61. package/src/infrastructure/repositories/drizzle/row-mappers.ts +106 -0
  62. package/src/infrastructure/repositories/drizzle/sqlite-db.ts +13 -0
  63. package/src/infrastructure/repositories/repository-factory.ts +54 -0
  64. package/src/infrastructure/security/auth-context.ts +35 -0
  65. package/src/infrastructure/security/input-guard.ts +21 -0
  66. package/src/infrastructure/security/rate-limiter.ts +65 -0
  67. package/src/mcp/bootstrap-sprintdock-sqlite.ts +45 -0
  68. package/src/mcp/mcp-query-helpers.ts +89 -0
  69. package/src/mcp/mcp-text-formatters.ts +204 -0
  70. package/src/mcp/mcp-tool-error.ts +24 -0
  71. package/src/mcp/plugins/context-tools.plugin.ts +107 -0
  72. package/src/mcp/plugins/default-plugins.ts +23 -0
  73. package/src/mcp/plugins/index.ts +21 -0
  74. package/src/mcp/plugins/mcp-tool-kit.ts +90 -0
  75. package/src/mcp/plugins/plan-tools.plugin.ts +426 -0
  76. package/src/mcp/plugins/sprint-tools.plugin.ts +396 -0
  77. package/src/mcp/plugins/task-tools.plugin.ts +528 -0
  78. package/src/mcp/plugins/task-workflow.plugin.ts +275 -0
  79. package/src/mcp/plugins/types.ts +45 -0
  80. package/src/mcp/register-sprintdock-mcp-tools.ts +50 -0
  81. package/src/mcp/sprintdock-mcp-capabilities.ts +14 -0
  82. package/src/mcp/sprintdock-mcp-runtime.ts +119 -0
  83. package/src/mcp/tool-guard.ts +58 -0
  84. package/src/mcp/transports/http-app-factory.ts +31 -0
  85. package/src/mcp/transports/http-entry.ts +27 -0
  86. package/src/mcp/transports/stdio-entry.ts +17 -0
  87. package/tests/application/container.test.ts +36 -0
  88. package/tests/application/plan.service.test.ts +114 -0
  89. package/tests/application/sprint.service.test.ts +138 -0
  90. package/tests/application/task.service.test.ts +325 -0
  91. package/tests/db/test-db.test.ts +112 -0
  92. package/tests/domain/plan-domain.service.test.ts +44 -0
  93. package/tests/domain/sprint-domain.service.test.ts +38 -0
  94. package/tests/domain/task-domain.service.test.ts +105 -0
  95. package/tests/errors/backend-errors.test.ts +44 -0
  96. package/tests/helpers/test-db.ts +43 -0
  97. package/tests/http/error-handler.test.ts +37 -0
  98. package/tests/http/plan.routes.test.ts +128 -0
  99. package/tests/http/sprint.routes.test.ts +72 -0
  100. package/tests/http/task.routes.test.ts +130 -0
  101. package/tests/http/test-app.ts +17 -0
  102. package/tests/infrastructure/drizzle-plan.repository.test.ts +62 -0
  103. package/tests/infrastructure/drizzle-sprint.repository.test.ts +49 -0
  104. package/tests/infrastructure/drizzle-task.repository.test.ts +132 -0
  105. package/tests/mcp/mcp-text-formatters.test.ts +246 -0
  106. package/tests/mcp/register-sprintdock-mcp-tools.test.ts +207 -0
  107. package/tsconfig.json +9 -0
package/dist/index.js ADDED
@@ -0,0 +1,4103 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ // src/errors/backend-errors.ts
8
+ var BackendError = class extends Error {
9
+ statusCode;
10
+ /**
11
+ * @param message Human-readable error message.
12
+ * @param statusCode HTTP status code for mapping in REST/MCP layers.
13
+ */
14
+ constructor(message, statusCode) {
15
+ super(message);
16
+ this.name = "BackendError";
17
+ this.statusCode = statusCode;
18
+ }
19
+ };
20
+ var NotFoundError = class extends BackendError {
21
+ /**
22
+ * @param entityName Domain entity label (for example, Plan or Task).
23
+ * @param id Identifier that was not found.
24
+ */
25
+ constructor(entityName, id) {
26
+ super(`${entityName} not found: ${id}`, 404);
27
+ this.name = "NotFoundError";
28
+ }
29
+ };
30
+ var ValidationError = class extends BackendError {
31
+ /**
32
+ * @param message Human-readable validation message.
33
+ */
34
+ constructor(message) {
35
+ super(message, 400);
36
+ this.name = "ValidationError";
37
+ }
38
+ };
39
+ var StorageError = class extends BackendError {
40
+ /**
41
+ * @param message Human-readable storage failure message.
42
+ */
43
+ constructor(message) {
44
+ super(message, 500);
45
+ this.name = "StorageError";
46
+ }
47
+ };
48
+
49
+ // src/domain/services/plan-domain.service.ts
50
+ var PlanDomainService = class {
51
+ transitions = {
52
+ draft: ["active"],
53
+ active: ["completed", "archived"],
54
+ completed: ["archived"],
55
+ archived: []
56
+ };
57
+ /**
58
+ * @param from Current plan status.
59
+ * @param to Requested plan status.
60
+ * @returns True when the transition is allowed (including no-op same status).
61
+ */
62
+ canTransitionStatus(from, to) {
63
+ if (from === to) {
64
+ return true;
65
+ }
66
+ return this.transitions[from].includes(to);
67
+ }
68
+ /**
69
+ * @param from Current plan status.
70
+ * @param to Requested plan status.
71
+ * @throws ValidationError when the transition is not allowed.
72
+ */
73
+ validateTransition(from, to) {
74
+ if (!this.canTransitionStatus(from, to)) {
75
+ throw new ValidationError(
76
+ `Illegal plan status transition: ${from} -> ${to}`
77
+ );
78
+ }
79
+ }
80
+ };
81
+
82
+ // src/domain/services/sprint-domain.service.ts
83
+ var SprintDomainService = class {
84
+ transitions = {
85
+ planned: ["active"],
86
+ active: ["completed", "archived"],
87
+ completed: ["archived"],
88
+ archived: []
89
+ };
90
+ /**
91
+ * @param from Current sprint status.
92
+ * @param to Requested sprint status.
93
+ * @returns True when the transition is allowed (including no-op same status).
94
+ */
95
+ canTransitionStatus(from, to) {
96
+ if (from === to) {
97
+ return true;
98
+ }
99
+ return this.transitions[from].includes(to);
100
+ }
101
+ /**
102
+ * @param from Current sprint status.
103
+ * @param to Requested sprint status.
104
+ * @throws ValidationError when the transition is not allowed.
105
+ */
106
+ validateTransition(from, to) {
107
+ if (!this.canTransitionStatus(from, to)) {
108
+ throw new ValidationError(
109
+ `Illegal sprint status transition: ${from} -> ${to}`
110
+ );
111
+ }
112
+ }
113
+ };
114
+
115
+ // src/domain/services/task-domain.service.ts
116
+ var TaskDomainService = class {
117
+ transitions = {
118
+ todo: ["in_progress", "blocked", "done"],
119
+ in_progress: ["blocked", "done", "todo"],
120
+ blocked: ["in_progress", "todo"],
121
+ /** Allow reopening from Done on the Kanban (done → any non-terminal workflow state). */
122
+ done: ["todo", "in_progress", "blocked"]
123
+ };
124
+ /**
125
+ * @param from Current task status.
126
+ * @param to Requested task status.
127
+ * @returns True when the transition is allowed (including no-op same status).
128
+ */
129
+ canTransitionStatus(from, to) {
130
+ if (from === to) {
131
+ return true;
132
+ }
133
+ return this.transitions[from].includes(to);
134
+ }
135
+ /**
136
+ * @param from Current task status.
137
+ * @param to Requested task status.
138
+ * @throws ValidationError when the transition is not allowed.
139
+ */
140
+ validateTransition(from, to) {
141
+ if (!this.canTransitionStatus(from, to)) {
142
+ throw new ValidationError(
143
+ `Illegal task status transition: ${from} -> ${to}`
144
+ );
145
+ }
146
+ }
147
+ /**
148
+ * @param tasks Tasks whose {@link Task.order} values must be unique within this set.
149
+ * @throws ValidationError when two tasks share the same order value.
150
+ */
151
+ validateOrderUniqueness(tasks2) {
152
+ const seen = /* @__PURE__ */ new Set();
153
+ for (const t of tasks2) {
154
+ if (seen.has(t.order)) {
155
+ throw new ValidationError(`Duplicate task order: ${t.order}`);
156
+ }
157
+ seen.add(t.order);
158
+ }
159
+ }
160
+ /**
161
+ * Ensures dependencies only reference known tasks and form a directed acyclic graph.
162
+ *
163
+ * @param tasks Tasks participating in the graph.
164
+ * @param dependencies Directed edges: taskId depends on dependsOnTaskId.
165
+ * @throws ValidationError when an endpoint is missing or a cycle exists.
166
+ */
167
+ validateDependencyGraph(tasks2, dependencies) {
168
+ const ids = new Set(tasks2.map((t) => t.id));
169
+ for (const d of dependencies) {
170
+ if (!ids.has(d.taskId) || !ids.has(d.dependsOnTaskId)) {
171
+ throw new ValidationError(
172
+ `Dependency references unknown task (${d.taskId} -> ${d.dependsOnTaskId})`
173
+ );
174
+ }
175
+ }
176
+ const adj = /* @__PURE__ */ new Map();
177
+ for (const id of ids) {
178
+ adj.set(id, []);
179
+ }
180
+ for (const d of dependencies) {
181
+ adj.get(d.taskId).push(d.dependsOnTaskId);
182
+ }
183
+ const color = /* @__PURE__ */ new Map();
184
+ for (const id of ids) {
185
+ color.set(id, 0);
186
+ }
187
+ const dfs = (u, stack) => {
188
+ color.set(u, 1);
189
+ stack.push(u);
190
+ for (const v of adj.get(u) ?? []) {
191
+ const c = color.get(v) ?? 0;
192
+ if (c === 1) {
193
+ const i = stack.indexOf(v);
194
+ const segment = stack.slice(i);
195
+ throw new ValidationError(
196
+ `Dependency cycle: ${segment.join(" -> ")} -> ${v}`
197
+ );
198
+ }
199
+ if (c === 0) {
200
+ dfs(v, stack);
201
+ }
202
+ }
203
+ stack.pop();
204
+ color.set(u, 2);
205
+ };
206
+ for (const id of ids) {
207
+ if (color.get(id) === 0) {
208
+ dfs(id, []);
209
+ }
210
+ }
211
+ }
212
+ /**
213
+ * @param dependsOnIds Task ids that must exist as prerequisites.
214
+ * @param existingTaskIds Task ids that are allowed as dependency targets.
215
+ * @throws ValidationError when any prerequisite id is not in the allowed set.
216
+ */
217
+ validateDependencyReferences(dependsOnIds, existingTaskIds) {
218
+ const allowed = new Set(existingTaskIds);
219
+ for (const id of dependsOnIds) {
220
+ if (!allowed.has(id)) {
221
+ throw new ValidationError(`Unknown dependency target task: ${id}`);
222
+ }
223
+ }
224
+ }
225
+ };
226
+
227
+ // src/application/plan.service.ts
228
+ var PlanService = class {
229
+ constructor(planRepo, planDomain) {
230
+ this.planRepo = planRepo;
231
+ this.planDomain = planDomain;
232
+ }
233
+ /** @returns All plans in creation order. */
234
+ async listPlans() {
235
+ return this.planRepo.list();
236
+ }
237
+ /**
238
+ * Creates a plan and activates it when it is the only plan in storage.
239
+ */
240
+ async createPlan(input) {
241
+ const beforeCount = (await this.planRepo.list()).length;
242
+ const plan = await this.planRepo.create(input);
243
+ if (beforeCount === 0) {
244
+ await this.planRepo.setActive(plan.id);
245
+ const refreshed = await this.planRepo.findById(plan.id);
246
+ if (!refreshed) {
247
+ throw new NotFoundError("Plan", plan.id);
248
+ }
249
+ return refreshed;
250
+ }
251
+ return plan;
252
+ }
253
+ /**
254
+ * Loads a plan by id, then by slug.
255
+ *
256
+ * @throws NotFoundError when neither matches.
257
+ */
258
+ async getPlan(idOrSlug) {
259
+ const byId = await this.planRepo.findById(idOrSlug);
260
+ if (byId) {
261
+ return byId;
262
+ }
263
+ const bySlug = await this.planRepo.findBySlug(idOrSlug);
264
+ if (bySlug) {
265
+ return bySlug;
266
+ }
267
+ throw new NotFoundError("Plan", idOrSlug);
268
+ }
269
+ /**
270
+ * @throws NotFoundError when no plan is marked active.
271
+ */
272
+ async getActivePlan() {
273
+ const active = await this.planRepo.findActive();
274
+ if (!active) {
275
+ throw new NotFoundError("Active plan", "none");
276
+ }
277
+ return active;
278
+ }
279
+ /**
280
+ * Marks the resolved plan as the sole active plan.
281
+ */
282
+ async setActivePlan(idOrSlug) {
283
+ const plan = await this.getPlan(idOrSlug);
284
+ await this.planRepo.setActive(plan.id);
285
+ }
286
+ /**
287
+ * Updates fields; validates plan status transitions through the domain service.
288
+ */
289
+ async updatePlan(idOrSlug, input) {
290
+ const existing = await this.getPlan(idOrSlug);
291
+ if (input.status !== void 0 && input.status !== existing.status) {
292
+ this.planDomain.validateTransition(existing.status, input.status);
293
+ }
294
+ const updated = await this.planRepo.update(existing.id, input);
295
+ if (!updated) {
296
+ throw new NotFoundError("Plan", existing.id);
297
+ }
298
+ return updated;
299
+ }
300
+ /**
301
+ * Deletes a plan by id.
302
+ *
303
+ * @throws NotFoundError when the plan does not exist.
304
+ */
305
+ async deletePlan(id) {
306
+ const deleted = await this.planRepo.delete(id);
307
+ if (!deleted) {
308
+ throw new NotFoundError("Plan", id);
309
+ }
310
+ }
311
+ /**
312
+ * Resolves the current plan from an optional id or slug, otherwise the active plan.
313
+ *
314
+ * @throws NotFoundError when lookup fails or no active plan exists.
315
+ */
316
+ async resolvePlan(idOrSlug) {
317
+ if (idOrSlug === void 0 || idOrSlug === "") {
318
+ return this.getActivePlan();
319
+ }
320
+ return this.getPlan(idOrSlug);
321
+ }
322
+ };
323
+
324
+ // src/application/sprint.service.ts
325
+ var SprintService = class {
326
+ constructor(sprintRepo, planRepo, planService, sprintDomain) {
327
+ this.sprintRepo = sprintRepo;
328
+ this.planRepo = planRepo;
329
+ this.planService = planService;
330
+ this.sprintDomain = sprintDomain;
331
+ }
332
+ /** @param planIdOrSlug When omitted, the active plan is used. */
333
+ async listSprints(planIdOrSlug) {
334
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
335
+ return this.sprintRepo.listByPlanId(plan.id);
336
+ }
337
+ /**
338
+ * Creates a sprint under the resolved plan; {@link CreateSprintInput.planId} is overwritten.
339
+ */
340
+ async createSprint(planIdOrSlug, input) {
341
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
342
+ return this.sprintRepo.create({ ...input, planId: plan.id });
343
+ }
344
+ /**
345
+ * @throws NotFoundError when the sprint does not exist.
346
+ */
347
+ async getSprint(id) {
348
+ const sprint = await this.sprintRepo.findById(id);
349
+ if (!sprint) {
350
+ throw new NotFoundError("Sprint", id);
351
+ }
352
+ return sprint;
353
+ }
354
+ /**
355
+ * Finds a sprint by plan scope and slug.
356
+ *
357
+ * @throws NotFoundError when the sprint is missing.
358
+ */
359
+ async getSprintBySlug(planIdOrSlug, sprintSlug) {
360
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
361
+ const sprint = await this.sprintRepo.findBySlug(plan.id, sprintSlug);
362
+ if (!sprint) {
363
+ throw new NotFoundError("Sprint", sprintSlug);
364
+ }
365
+ return sprint;
366
+ }
367
+ /**
368
+ * Updates sprint fields (name, goal, dates, order) without changing status.
369
+ * Use {@link updateSprintStatus} for lifecycle transitions.
370
+ */
371
+ async updateSprint(id, input) {
372
+ const existing = await this.getSprint(id);
373
+ const updated = await this.sprintRepo.update(existing.id, input);
374
+ if (!updated) {
375
+ throw new NotFoundError("Sprint", id);
376
+ }
377
+ return updated;
378
+ }
379
+ /**
380
+ * Updates sprint status after validating the transition.
381
+ */
382
+ async updateSprintStatus(id, status) {
383
+ const existing = await this.getSprint(id);
384
+ this.sprintDomain.validateTransition(existing.status, status);
385
+ const updated = await this.sprintRepo.update(existing.id, { status });
386
+ if (!updated) {
387
+ throw new NotFoundError("Sprint", id);
388
+ }
389
+ return updated;
390
+ }
391
+ /**
392
+ * @throws NotFoundError when the sprint does not exist.
393
+ */
394
+ async deleteSprint(id) {
395
+ const removed = await this.sprintRepo.delete(id);
396
+ if (!removed) {
397
+ throw new NotFoundError("Sprint", id);
398
+ }
399
+ }
400
+ };
401
+
402
+ // src/application/task.service.ts
403
+ var PENDING_TASK_ID = "__pending__";
404
+ function emptyStatusCounts() {
405
+ return { todo: 0, in_progress: 0, blocked: 0, done: 0 };
406
+ }
407
+ var TaskService = class {
408
+ constructor(taskRepo, sprintRepo, planService, taskDomain) {
409
+ this.taskRepo = taskRepo;
410
+ this.sprintRepo = sprintRepo;
411
+ this.planService = planService;
412
+ this.taskDomain = taskDomain;
413
+ }
414
+ /**
415
+ * Lists tasks for a sprint, or all tasks in a resolved plan when {@link sprintId} is omitted.
416
+ */
417
+ async listTasks(sprintId, planIdOrSlug, filter) {
418
+ if (sprintId !== void 0 && sprintId !== "") {
419
+ return this.taskRepo.listBySprintId(sprintId, filter);
420
+ }
421
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
422
+ return this.taskRepo.listByPlanId(plan.id, filter);
423
+ }
424
+ /**
425
+ * Creates a task after validating sprint ownership, order uniqueness, and dependencies.
426
+ */
427
+ async createTask(input, planIdOrSlug) {
428
+ const sprint = await this.sprintRepo.findById(input.sprintId);
429
+ if (!sprint) {
430
+ throw new NotFoundError("Sprint", input.sprintId);
431
+ }
432
+ let plan;
433
+ if (planIdOrSlug !== void 0 && planIdOrSlug !== "") {
434
+ plan = await this.planService.resolvePlan(planIdOrSlug);
435
+ if (sprint.planId !== plan.id) {
436
+ throw new ValidationError(
437
+ "Sprint does not belong to the resolved plan"
438
+ );
439
+ }
440
+ } else {
441
+ plan = await this.planService.getPlan(sprint.planId);
442
+ }
443
+ const sprintTasks = await this.taskRepo.listBySprintId(input.sprintId);
444
+ let order;
445
+ if (input.order === void 0) {
446
+ order = sprintTasks.length === 0 ? 0 : Math.max(...sprintTasks.map((t) => t.order)) + 1;
447
+ } else {
448
+ order = input.order;
449
+ }
450
+ const pendingTask = {
451
+ id: PENDING_TASK_ID,
452
+ sprintId: input.sprintId,
453
+ title: input.title,
454
+ description: input.description ?? null,
455
+ status: "todo",
456
+ priority: input.priority,
457
+ order,
458
+ assignee: input.assignee ?? null,
459
+ tags: input.tags ?? null,
460
+ touchedFiles: [],
461
+ createdAt: "",
462
+ updatedAt: ""
463
+ };
464
+ this.taskDomain.validateOrderUniqueness([...sprintTasks, pendingTask]);
465
+ const planTasks = await this.taskRepo.listByPlanId(plan.id);
466
+ const prereqs = input.dependsOnTaskIds ?? [];
467
+ if (prereqs.length > 0) {
468
+ this.taskDomain.validateDependencyReferences(
469
+ prereqs,
470
+ planTasks.map((t) => t.id)
471
+ );
472
+ const existingDeps = [];
473
+ for (const t of planTasks) {
474
+ existingDeps.push(...await this.taskRepo.getDependencies(t.id));
475
+ }
476
+ const newEdges = prereqs.map((dependsOnTaskId) => ({
477
+ taskId: PENDING_TASK_ID,
478
+ dependsOnTaskId
479
+ }));
480
+ this.taskDomain.validateDependencyGraph(
481
+ [...planTasks, pendingTask],
482
+ [...existingDeps, ...newEdges]
483
+ );
484
+ }
485
+ return this.taskRepo.create({
486
+ ...input,
487
+ order
488
+ });
489
+ }
490
+ /**
491
+ * @throws NotFoundError when the task does not exist.
492
+ */
493
+ async getTask(id) {
494
+ const task = await this.taskRepo.findById(id);
495
+ if (!task) {
496
+ throw new NotFoundError("Task", id);
497
+ }
498
+ return task;
499
+ }
500
+ /**
501
+ * Updates title, description, priority, tags, or order (not status).
502
+ */
503
+ async updateTask(id, input) {
504
+ const existing = await this.getTask(id);
505
+ const merged = {
506
+ ...existing,
507
+ title: input.title ?? existing.title,
508
+ description: input.description !== void 0 ? input.description : existing.description,
509
+ priority: input.priority ?? existing.priority,
510
+ order: input.order ?? existing.order,
511
+ assignee: input.assignee !== void 0 ? input.assignee : existing.assignee,
512
+ tags: input.tags !== void 0 ? input.tags : existing.tags,
513
+ touchedFiles: existing.touchedFiles
514
+ };
515
+ if (input.order !== void 0 && input.order !== existing.order) {
516
+ const sprintTasks = await this.taskRepo.listBySprintId(existing.sprintId);
517
+ this.taskDomain.validateOrderUniqueness(
518
+ sprintTasks.map((t) => t.id === id ? merged : t)
519
+ );
520
+ }
521
+ const updated = await this.taskRepo.update(existing.id, input);
522
+ if (!updated) {
523
+ throw new NotFoundError("Task", id);
524
+ }
525
+ return updated;
526
+ }
527
+ /**
528
+ * Replaces prerequisite edges for a task after validating the dependency DAG.
529
+ */
530
+ async setTaskDependencies(taskId, dependsOnTaskIds, planIdOrSlug) {
531
+ const task = await this.getTask(taskId);
532
+ const sprint = await this.sprintRepo.findById(task.sprintId);
533
+ if (!sprint) {
534
+ throw new NotFoundError("Sprint", task.sprintId);
535
+ }
536
+ const plan = await this.planService.getPlan(sprint.planId);
537
+ if (planIdOrSlug !== void 0 && planIdOrSlug !== "") {
538
+ const scoped = await this.planService.resolvePlan(planIdOrSlug);
539
+ if (scoped.id !== plan.id) {
540
+ throw new ValidationError(
541
+ "Task does not belong to the resolved plan"
542
+ );
543
+ }
544
+ }
545
+ const planTasks = await this.taskRepo.listByPlanId(plan.id);
546
+ this.taskDomain.validateDependencyReferences(
547
+ dependsOnTaskIds,
548
+ planTasks.map((t) => t.id)
549
+ );
550
+ const existingDeps = [];
551
+ for (const t of planTasks) {
552
+ if (t.id === taskId) {
553
+ continue;
554
+ }
555
+ existingDeps.push(...await this.taskRepo.getDependencies(t.id));
556
+ }
557
+ const newEdges = dependsOnTaskIds.map((dependsOnTaskId) => ({
558
+ taskId,
559
+ dependsOnTaskId
560
+ }));
561
+ this.taskDomain.validateDependencyGraph(planTasks, [
562
+ ...existingDeps,
563
+ ...newEdges
564
+ ]);
565
+ await this.taskRepo.setDependencies(taskId, dependsOnTaskIds);
566
+ }
567
+ /**
568
+ * Loads prerequisite tasks and tasks that depend on this task.
569
+ */
570
+ async getTaskDependencyInfo(taskId, planIdOrSlug) {
571
+ const task = await this.getTask(taskId);
572
+ if (planIdOrSlug !== void 0 && planIdOrSlug !== "") {
573
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
574
+ const sprint = await this.sprintRepo.findById(task.sprintId);
575
+ if (!sprint || sprint.planId !== plan.id) {
576
+ throw new ValidationError(
577
+ "Task does not belong to the resolved plan"
578
+ );
579
+ }
580
+ }
581
+ const depRows = await this.taskRepo.getDependencies(taskId);
582
+ const dependsOn = [];
583
+ for (const d of depRows) {
584
+ dependsOn.push(await this.getTask(d.dependsOnTaskId));
585
+ }
586
+ const dependentIds = await this.taskRepo.listDependentTaskIds(taskId);
587
+ const dependedOnBy = [];
588
+ for (const id of dependentIds) {
589
+ dependedOnBy.push(await this.getTask(id));
590
+ }
591
+ return { dependsOn, dependedOnBy };
592
+ }
593
+ /**
594
+ * Updates task status after validating the transition.
595
+ */
596
+ async updateTaskStatus(id, status) {
597
+ const existing = await this.getTask(id);
598
+ this.taskDomain.validateTransition(existing.status, status);
599
+ const updated = await this.taskRepo.update(existing.id, { status });
600
+ if (!updated) {
601
+ throw new NotFoundError("Task", id);
602
+ }
603
+ return updated;
604
+ }
605
+ /**
606
+ * Sets or clears the task assignee.
607
+ */
608
+ async assignTask(id, assignee) {
609
+ const existing = await this.getTask(id);
610
+ const updated = await this.taskRepo.update(existing.id, { assignee });
611
+ if (!updated) {
612
+ throw new NotFoundError("Task", id);
613
+ }
614
+ return updated;
615
+ }
616
+ /**
617
+ * Moves a task to another sprint in the same plan.
618
+ */
619
+ async moveTask(taskId, targetSprintId) {
620
+ const task = await this.getTask(taskId);
621
+ const sourceSprint = await this.sprintRepo.findById(task.sprintId);
622
+ const targetSprint = await this.sprintRepo.findById(targetSprintId);
623
+ if (!sourceSprint) {
624
+ throw new NotFoundError("Sprint", task.sprintId);
625
+ }
626
+ if (!targetSprint) {
627
+ throw new NotFoundError("Sprint", targetSprintId);
628
+ }
629
+ if (sourceSprint.planId !== targetSprint.planId) {
630
+ throw new ValidationError(
631
+ "Cannot move a task to a sprint in a different plan"
632
+ );
633
+ }
634
+ const moved = await this.taskRepo.move(taskId, targetSprintId);
635
+ if (!moved) {
636
+ throw new NotFoundError("Task", taskId);
637
+ }
638
+ return moved;
639
+ }
640
+ /**
641
+ * @throws NotFoundError when the task does not exist.
642
+ */
643
+ async deleteTask(id) {
644
+ const removed = await this.taskRepo.delete(id);
645
+ if (!removed) {
646
+ throw new NotFoundError("Task", id);
647
+ }
648
+ }
649
+ /**
650
+ * Loads all sprints for the plan (ordered) and task counts by status per sprint, plus plan rollup.
651
+ */
652
+ async getPlanSprintTaskAnalytics(planIdOrSlug) {
653
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
654
+ const [sprintList, tasks2] = await Promise.all([
655
+ this.sprintRepo.listByPlanId(plan.id),
656
+ this.taskRepo.listByPlanId(plan.id)
657
+ ]);
658
+ const perSprint = /* @__PURE__ */ new Map();
659
+ for (const s of sprintList) {
660
+ perSprint.set(s.id, emptyStatusCounts());
661
+ }
662
+ const rollup = emptyStatusCounts();
663
+ for (const t of tasks2) {
664
+ rollup[t.status] += 1;
665
+ const row = perSprint.get(t.sprintId);
666
+ if (row !== void 0) {
667
+ row[t.status] += 1;
668
+ }
669
+ }
670
+ const sprints2 = sprintList.map(
671
+ (sprint) => {
672
+ const c = perSprint.get(sprint.id) ?? emptyStatusCounts();
673
+ const totalTasks2 = c.todo + c.in_progress + c.blocked + c.done;
674
+ return {
675
+ sprint,
676
+ tasksByStatus: { ...c },
677
+ totalTasks: totalTasks2
678
+ };
679
+ }
680
+ );
681
+ const totalTasks = rollup.todo + rollup.in_progress + rollup.blocked + rollup.done;
682
+ return {
683
+ planId: plan.id,
684
+ sprints: sprints2,
685
+ rollup: {
686
+ tasksByStatus: { ...rollup },
687
+ totalTasks,
688
+ sprintCount: sprintList.length
689
+ }
690
+ };
691
+ }
692
+ /**
693
+ * Fleet Command throughput: counts across all tasks (all plans). Blocked tasks are omitted from the trio.
694
+ */
695
+ async getGlobalTaskThroughput() {
696
+ const c = await this.taskRepo.countByStatusGlobally();
697
+ return {
698
+ todo: c.todo,
699
+ inProgress: c.in_progress,
700
+ done: c.done
701
+ };
702
+ }
703
+ };
704
+
705
+ // src/application/container.ts
706
+ function createApplicationServices(repos) {
707
+ const planDomain = new PlanDomainService();
708
+ const sprintDomain = new SprintDomainService();
709
+ const taskDomain = new TaskDomainService();
710
+ const planService = new PlanService(repos.plans, planDomain);
711
+ const sprintService = new SprintService(
712
+ repos.sprints,
713
+ repos.plans,
714
+ planService,
715
+ sprintDomain
716
+ );
717
+ const taskService = new TaskService(
718
+ repos.tasks,
719
+ repos.sprints,
720
+ planService,
721
+ taskDomain
722
+ );
723
+ return { planService, sprintService, taskService };
724
+ }
725
+
726
+ // src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts
727
+ import { asc, eq } from "drizzle-orm";
728
+ import { randomUUID } from "crypto";
729
+
730
+ // src/db/schema/plans.ts
731
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
732
+ var plans = sqliteTable("plans", {
733
+ id: text("id").primaryKey(),
734
+ slug: text("slug").notNull().unique(),
735
+ title: text("title").notNull(),
736
+ description: text("description"),
737
+ markdownContent: text("markdown_content"),
738
+ status: text("status").notNull(),
739
+ isActive: integer("is_active", { mode: "boolean" }).notNull().default(false),
740
+ createdAt: text("created_at").notNull(),
741
+ updatedAt: text("updated_at").notNull()
742
+ });
743
+
744
+ // src/infrastructure/repositories/drizzle/row-mappers.ts
745
+ function toPlanEntity(row) {
746
+ return {
747
+ id: row.id,
748
+ slug: row.slug,
749
+ title: row.title,
750
+ description: row.description ?? null,
751
+ markdownContent: row.markdownContent ?? null,
752
+ status: row.status,
753
+ isActive: row.isActive,
754
+ createdAt: row.createdAt,
755
+ updatedAt: row.updatedAt
756
+ };
757
+ }
758
+ function toSprintEntity(row) {
759
+ return {
760
+ id: row.id,
761
+ slug: row.slug,
762
+ planId: row.planId,
763
+ name: row.name,
764
+ goal: row.goal,
765
+ markdownContent: row.markdownContent ?? null,
766
+ status: row.status,
767
+ order: row.sprintOrder,
768
+ startDate: row.startDate ?? null,
769
+ endDate: row.endDate ?? null,
770
+ createdAt: row.createdAt,
771
+ updatedAt: row.updatedAt
772
+ };
773
+ }
774
+ function toTaskEntity(row) {
775
+ return {
776
+ id: row.id,
777
+ sprintId: row.sprintId,
778
+ title: row.title,
779
+ description: row.description ?? null,
780
+ status: row.status,
781
+ priority: row.priority,
782
+ order: row.taskOrder,
783
+ assignee: row.assignee ?? null,
784
+ tags: row.tags ?? null,
785
+ touchedFiles: [],
786
+ createdAt: row.createdAt,
787
+ updatedAt: row.updatedAt
788
+ };
789
+ }
790
+ function toTaskTouchedFileEntity(row) {
791
+ return {
792
+ id: row.id,
793
+ taskId: row.taskId,
794
+ path: row.path,
795
+ fileType: row.fileType
796
+ };
797
+ }
798
+ function toTaskDependencyEntity(row) {
799
+ return {
800
+ taskId: row.taskId,
801
+ dependsOnTaskId: row.dependsOnTaskId
802
+ };
803
+ }
804
+
805
+ // src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts
806
+ var DrizzlePlanRepository = class {
807
+ constructor(db) {
808
+ this.db = db;
809
+ }
810
+ /** @inheritdoc */
811
+ async create(input) {
812
+ const id = randomUUID();
813
+ const now = (/* @__PURE__ */ new Date()).toISOString();
814
+ this.db.insert(plans).values({
815
+ id,
816
+ slug: input.slug,
817
+ title: input.title,
818
+ description: input.description ?? null,
819
+ markdownContent: input.markdownContent ?? null,
820
+ status: "draft",
821
+ isActive: false,
822
+ createdAt: now,
823
+ updatedAt: now
824
+ }).run();
825
+ const created = this.db.select().from(plans).where(eq(plans.id, id)).get();
826
+ if (!created) {
827
+ throw new StorageError("Failed to read plan after insert");
828
+ }
829
+ return toPlanEntity(created);
830
+ }
831
+ /** @inheritdoc */
832
+ async findById(id) {
833
+ const row = this.db.select().from(plans).where(eq(plans.id, id)).get();
834
+ return row ? toPlanEntity(row) : null;
835
+ }
836
+ /** @inheritdoc */
837
+ async findBySlug(slug) {
838
+ const row = this.db.select().from(plans).where(eq(plans.slug, slug)).get();
839
+ return row ? toPlanEntity(row) : null;
840
+ }
841
+ /** @inheritdoc */
842
+ async findActive() {
843
+ const row = this.db.select().from(plans).where(eq(plans.isActive, true)).orderBy(asc(plans.createdAt)).limit(1).get();
844
+ return row ? toPlanEntity(row) : null;
845
+ }
846
+ /** @inheritdoc */
847
+ async list() {
848
+ const rows = this.db.select().from(plans).orderBy(asc(plans.createdAt)).all();
849
+ return rows.map(toPlanEntity);
850
+ }
851
+ /** @inheritdoc */
852
+ async update(id, input) {
853
+ const existing = await this.findById(id);
854
+ if (!existing) {
855
+ return null;
856
+ }
857
+ const now = (/* @__PURE__ */ new Date()).toISOString();
858
+ this.db.update(plans).set({
859
+ title: input.title ?? existing.title,
860
+ description: input.description !== void 0 ? input.description : existing.description,
861
+ markdownContent: input.markdownContent !== void 0 ? input.markdownContent : existing.markdownContent,
862
+ status: input.status ?? existing.status,
863
+ updatedAt: now
864
+ }).where(eq(plans.id, id)).run();
865
+ return this.findById(id);
866
+ }
867
+ /** @inheritdoc */
868
+ async setActive(id) {
869
+ this.db.transaction((tx) => {
870
+ tx.update(plans).set({ isActive: false }).run();
871
+ tx.update(plans).set({
872
+ isActive: true,
873
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
874
+ }).where(eq(plans.id, id)).run();
875
+ });
876
+ }
877
+ /** @inheritdoc */
878
+ async delete(id) {
879
+ const before = await this.findById(id);
880
+ if (!before) {
881
+ return false;
882
+ }
883
+ this.db.delete(plans).where(eq(plans.id, id)).run();
884
+ return await this.findById(id) === null;
885
+ }
886
+ };
887
+
888
+ // src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts
889
+ import { and, asc as asc2, eq as eq2 } from "drizzle-orm";
890
+ import { randomUUID as randomUUID2 } from "crypto";
891
+
892
+ // src/db/schema/sprints.ts
893
+ import { sqliteTable as sqliteTable2, text as text2, integer as integer2, uniqueIndex } from "drizzle-orm/sqlite-core";
894
+ var sprints = sqliteTable2(
895
+ "sprints",
896
+ {
897
+ id: text2("id").primaryKey(),
898
+ slug: text2("slug").notNull(),
899
+ planId: text2("plan_id").notNull().references(() => plans.id, { onDelete: "cascade" }),
900
+ name: text2("name").notNull(),
901
+ goal: text2("goal").notNull(),
902
+ markdownContent: text2("markdown_content"),
903
+ status: text2("status").notNull(),
904
+ sprintOrder: integer2("order").notNull().default(0),
905
+ startDate: text2("start_date"),
906
+ endDate: text2("end_date"),
907
+ createdAt: text2("created_at").notNull(),
908
+ updatedAt: text2("updated_at").notNull()
909
+ },
910
+ (table) => ({
911
+ planSlugUnique: uniqueIndex("sprints_plan_id_slug_unique").on(
912
+ table.planId,
913
+ table.slug
914
+ )
915
+ })
916
+ );
917
+
918
+ // src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts
919
+ var DrizzleSprintRepository = class {
920
+ constructor(db) {
921
+ this.db = db;
922
+ }
923
+ /** @inheritdoc */
924
+ async create(input) {
925
+ const id = randomUUID2();
926
+ const now = (/* @__PURE__ */ new Date()).toISOString();
927
+ let order;
928
+ if (input.order === void 0) {
929
+ const rows = this.db.select().from(sprints).where(eq2(sprints.planId, input.planId)).all();
930
+ order = rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.sprintOrder)) + 1;
931
+ } else {
932
+ order = input.order;
933
+ }
934
+ this.db.insert(sprints).values({
935
+ id,
936
+ slug: input.slug,
937
+ planId: input.planId,
938
+ name: input.name,
939
+ goal: input.goal,
940
+ markdownContent: input.markdownContent ?? null,
941
+ status: "planned",
942
+ sprintOrder: order,
943
+ startDate: input.startDate ?? null,
944
+ endDate: input.endDate ?? null,
945
+ createdAt: now,
946
+ updatedAt: now
947
+ }).run();
948
+ const created = this.db.select().from(sprints).where(eq2(sprints.id, id)).get();
949
+ if (!created) {
950
+ throw new StorageError("Failed to read sprint after insert");
951
+ }
952
+ return toSprintEntity(created);
953
+ }
954
+ /** @inheritdoc */
955
+ async findById(id) {
956
+ const row = this.db.select().from(sprints).where(eq2(sprints.id, id)).get();
957
+ return row ? toSprintEntity(row) : null;
958
+ }
959
+ /** @inheritdoc */
960
+ async findBySlug(planId, slug) {
961
+ const row = this.db.select().from(sprints).where(and(eq2(sprints.planId, planId), eq2(sprints.slug, slug))).get();
962
+ return row ? toSprintEntity(row) : null;
963
+ }
964
+ /** @inheritdoc */
965
+ async listByPlanId(planId) {
966
+ const rows = this.db.select().from(sprints).where(eq2(sprints.planId, planId)).orderBy(asc2(sprints.sprintOrder)).all();
967
+ return rows.map(toSprintEntity);
968
+ }
969
+ /** @inheritdoc */
970
+ async update(id, input) {
971
+ const existing = await this.findById(id);
972
+ if (!existing) {
973
+ return null;
974
+ }
975
+ const now = (/* @__PURE__ */ new Date()).toISOString();
976
+ this.db.update(sprints).set({
977
+ name: input.name ?? existing.name,
978
+ goal: input.goal ?? existing.goal,
979
+ markdownContent: input.markdownContent !== void 0 ? input.markdownContent : existing.markdownContent,
980
+ status: input.status ?? existing.status,
981
+ sprintOrder: input.order ?? existing.order,
982
+ startDate: input.startDate !== void 0 ? input.startDate : existing.startDate,
983
+ endDate: input.endDate !== void 0 ? input.endDate : existing.endDate,
984
+ updatedAt: now
985
+ }).where(eq2(sprints.id, id)).run();
986
+ return this.findById(id);
987
+ }
988
+ /** @inheritdoc */
989
+ async delete(id) {
990
+ const before = await this.findById(id);
991
+ if (!before) {
992
+ return false;
993
+ }
994
+ this.db.delete(sprints).where(eq2(sprints.id, id)).run();
995
+ return await this.findById(id) === null;
996
+ }
997
+ };
998
+
999
+ // src/infrastructure/repositories/drizzle/drizzle-task.repository.ts
1000
+ import { and as and2, asc as asc3, eq as eq3, inArray } from "drizzle-orm";
1001
+ import { randomUUID as randomUUID3 } from "crypto";
1002
+
1003
+ // src/db/schema/tasks.ts
1004
+ import {
1005
+ sqliteTable as sqliteTable3,
1006
+ text as text3,
1007
+ integer as integer3,
1008
+ primaryKey,
1009
+ uniqueIndex as uniqueIndex2
1010
+ } from "drizzle-orm/sqlite-core";
1011
+ var tasks = sqliteTable3("tasks", {
1012
+ id: text3("id").primaryKey(),
1013
+ sprintId: text3("sprint_id").notNull().references(() => sprints.id, { onDelete: "cascade" }),
1014
+ title: text3("title").notNull(),
1015
+ description: text3("description"),
1016
+ status: text3("status").notNull(),
1017
+ priority: text3("priority").notNull(),
1018
+ taskOrder: integer3("order").notNull().default(0),
1019
+ assignee: text3("assignee"),
1020
+ tags: text3("tags", { mode: "json" }).$type(),
1021
+ createdAt: text3("created_at").notNull(),
1022
+ updatedAt: text3("updated_at").notNull()
1023
+ });
1024
+ var taskDependencies = sqliteTable3(
1025
+ "task_dependencies",
1026
+ {
1027
+ taskId: text3("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
1028
+ dependsOnTaskId: text3("depends_on_task_id").notNull().references(() => tasks.id, { onDelete: "cascade" })
1029
+ },
1030
+ (t) => ({
1031
+ pk: primaryKey({ columns: [t.taskId, t.dependsOnTaskId] })
1032
+ })
1033
+ );
1034
+ var taskTouchedFiles = sqliteTable3(
1035
+ "task_touched_files",
1036
+ {
1037
+ id: text3("id").primaryKey(),
1038
+ taskId: text3("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
1039
+ path: text3("path").notNull(),
1040
+ fileType: text3("file_type").notNull()
1041
+ },
1042
+ (t) => ({
1043
+ taskPathUnique: uniqueIndex2("task_touched_files_task_id_path_unique").on(
1044
+ t.taskId,
1045
+ t.path
1046
+ )
1047
+ })
1048
+ );
1049
+
1050
+ // src/infrastructure/repositories/drizzle/drizzle-task.repository.ts
1051
+ var TOUCHED_FILE_TYPES = /* @__PURE__ */ new Set([
1052
+ "test",
1053
+ "implementation",
1054
+ "doc",
1055
+ "config",
1056
+ "other"
1057
+ ]);
1058
+ function validateTouchedFileInputs(items) {
1059
+ const seenPaths = /* @__PURE__ */ new Set();
1060
+ const out = [];
1061
+ for (const raw of items) {
1062
+ const path = raw.path.trim();
1063
+ if (path.length === 0) {
1064
+ throw new ValidationError("Touched file path cannot be empty");
1065
+ }
1066
+ const norm = path.replace(/\\/g, "/");
1067
+ if (norm.split("/").some((s) => s === "..")) {
1068
+ throw new ValidationError("Touched file path cannot contain '..'");
1069
+ }
1070
+ if (!TOUCHED_FILE_TYPES.has(raw.fileType)) {
1071
+ throw new ValidationError(`Invalid touched file type: ${raw.fileType}`);
1072
+ }
1073
+ if (seenPaths.has(norm)) {
1074
+ throw new ValidationError(`Duplicate touched path: ${path}`);
1075
+ }
1076
+ seenPaths.add(norm);
1077
+ out.push({ path, fileType: raw.fileType });
1078
+ }
1079
+ return out;
1080
+ }
1081
+ var DrizzleTaskRepository = class {
1082
+ constructor(db) {
1083
+ this.db = db;
1084
+ }
1085
+ replaceTouchedFiles(taskId, items) {
1086
+ this.db.transaction((tx) => {
1087
+ tx.delete(taskTouchedFiles).where(eq3(taskTouchedFiles.taskId, taskId)).run();
1088
+ for (const item of items) {
1089
+ tx.insert(taskTouchedFiles).values({
1090
+ id: randomUUID3(),
1091
+ taskId,
1092
+ path: item.path,
1093
+ fileType: item.fileType
1094
+ }).run();
1095
+ }
1096
+ });
1097
+ }
1098
+ touchedFilesByTaskIds(taskIds) {
1099
+ const map = /* @__PURE__ */ new Map();
1100
+ for (const id of taskIds) {
1101
+ map.set(id, []);
1102
+ }
1103
+ if (taskIds.length === 0) {
1104
+ return map;
1105
+ }
1106
+ const rows = this.db.select().from(taskTouchedFiles).where(inArray(taskTouchedFiles.taskId, taskIds)).all();
1107
+ for (const r of rows) {
1108
+ const e = toTaskTouchedFileEntity(r);
1109
+ const list = map.get(e.taskId);
1110
+ if (list !== void 0) {
1111
+ list.push(e);
1112
+ }
1113
+ }
1114
+ for (const list of map.values()) {
1115
+ list.sort(
1116
+ (a, b) => a.path.localeCompare(b.path, void 0, { sensitivity: "base" })
1117
+ );
1118
+ }
1119
+ return map;
1120
+ }
1121
+ attachTouchedFiles(taskList) {
1122
+ if (taskList.length === 0) {
1123
+ return taskList;
1124
+ }
1125
+ const byId = this.touchedFilesByTaskIds(taskList.map((t) => t.id));
1126
+ return taskList.map((t) => ({
1127
+ ...t,
1128
+ touchedFiles: byId.get(t.id) ?? []
1129
+ }));
1130
+ }
1131
+ /** @inheritdoc */
1132
+ async create(input) {
1133
+ const id = randomUUID3();
1134
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1135
+ let order;
1136
+ if (input.order === void 0) {
1137
+ const rows = this.db.select().from(tasks).where(eq3(tasks.sprintId, input.sprintId)).all();
1138
+ order = rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.taskOrder)) + 1;
1139
+ } else {
1140
+ order = input.order;
1141
+ }
1142
+ const clash = this.db.select().from(tasks).where(
1143
+ and2(eq3(tasks.sprintId, input.sprintId), eq3(tasks.taskOrder, order))
1144
+ ).get();
1145
+ if (clash) {
1146
+ throw new ValidationError(`Duplicate task order in sprint: ${order}`);
1147
+ }
1148
+ this.db.insert(tasks).values({
1149
+ id,
1150
+ sprintId: input.sprintId,
1151
+ title: input.title,
1152
+ description: input.description ?? null,
1153
+ status: "todo",
1154
+ priority: input.priority,
1155
+ taskOrder: order,
1156
+ assignee: input.assignee ?? null,
1157
+ tags: input.tags ?? null,
1158
+ createdAt: now,
1159
+ updatedAt: now
1160
+ }).run();
1161
+ const created = this.db.select().from(tasks).where(eq3(tasks.id, id)).get();
1162
+ if (!created) {
1163
+ throw new StorageError("Failed to read task after insert");
1164
+ }
1165
+ if (input.dependsOnTaskIds?.length) {
1166
+ await this.setDependencies(id, [...new Set(input.dependsOnTaskIds)]);
1167
+ }
1168
+ const finalRow = this.db.select().from(tasks).where(eq3(tasks.id, id)).get();
1169
+ if (!finalRow) {
1170
+ throw new StorageError("Failed to read task after dependency write");
1171
+ }
1172
+ if (input.touchedFiles !== void 0) {
1173
+ this.replaceTouchedFiles(id, validateTouchedFileInputs(input.touchedFiles));
1174
+ }
1175
+ const hydrated = this.attachTouchedFiles([toTaskEntity(finalRow)])[0];
1176
+ if (!hydrated) {
1177
+ throw new StorageError("Failed to hydrate task after create");
1178
+ }
1179
+ return hydrated;
1180
+ }
1181
+ /** @inheritdoc */
1182
+ async findById(id) {
1183
+ const row = this.db.select().from(tasks).where(eq3(tasks.id, id)).get();
1184
+ if (!row) {
1185
+ return null;
1186
+ }
1187
+ return this.attachTouchedFiles([toTaskEntity(row)])[0] ?? null;
1188
+ }
1189
+ /** @inheritdoc */
1190
+ async listBySprintId(sprintId, filter) {
1191
+ const conditions = [eq3(tasks.sprintId, sprintId)];
1192
+ if (filter?.status) {
1193
+ conditions.push(eq3(tasks.status, filter.status));
1194
+ }
1195
+ if (filter?.priority) {
1196
+ conditions.push(eq3(tasks.priority, filter.priority));
1197
+ }
1198
+ if (filter?.assignee !== void 0) {
1199
+ conditions.push(eq3(tasks.assignee, filter.assignee));
1200
+ }
1201
+ const rows = this.db.select().from(tasks).where(and2(...conditions)).orderBy(asc3(tasks.taskOrder)).all();
1202
+ return this.attachTouchedFiles(rows.map(toTaskEntity));
1203
+ }
1204
+ /** @inheritdoc */
1205
+ async listByPlanId(planId, filter) {
1206
+ const conditions = [eq3(sprints.planId, planId)];
1207
+ if (filter?.status) {
1208
+ conditions.push(eq3(tasks.status, filter.status));
1209
+ }
1210
+ if (filter?.priority) {
1211
+ conditions.push(eq3(tasks.priority, filter.priority));
1212
+ }
1213
+ if (filter?.assignee !== void 0) {
1214
+ conditions.push(eq3(tasks.assignee, filter.assignee));
1215
+ }
1216
+ const rows = this.db.select().from(tasks).innerJoin(sprints, eq3(tasks.sprintId, sprints.id)).where(and2(...conditions)).orderBy(asc3(tasks.taskOrder)).all();
1217
+ return this.attachTouchedFiles(rows.map((row) => toTaskEntity(row.tasks)));
1218
+ }
1219
+ /** @inheritdoc */
1220
+ async update(id, input) {
1221
+ const existing = await this.findById(id);
1222
+ if (!existing) {
1223
+ return null;
1224
+ }
1225
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1226
+ if (input.order !== void 0 && input.order !== existing.order) {
1227
+ const clash = this.db.select().from(tasks).where(
1228
+ and2(
1229
+ eq3(tasks.sprintId, existing.sprintId),
1230
+ eq3(tasks.taskOrder, input.order)
1231
+ )
1232
+ ).get();
1233
+ if (clash && clash.id !== id) {
1234
+ throw new ValidationError(
1235
+ `Duplicate task order in sprint: ${input.order}`
1236
+ );
1237
+ }
1238
+ }
1239
+ this.db.update(tasks).set({
1240
+ title: input.title ?? existing.title,
1241
+ description: input.description !== void 0 ? input.description : existing.description,
1242
+ status: input.status ?? existing.status,
1243
+ priority: input.priority ?? existing.priority,
1244
+ taskOrder: input.order ?? existing.order,
1245
+ assignee: input.assignee !== void 0 ? input.assignee : existing.assignee,
1246
+ tags: input.tags !== void 0 ? input.tags : existing.tags,
1247
+ updatedAt: now
1248
+ }).where(eq3(tasks.id, id)).run();
1249
+ if (input.touchedFiles !== void 0) {
1250
+ this.replaceTouchedFiles(id, validateTouchedFileInputs(input.touchedFiles));
1251
+ }
1252
+ return this.findById(id);
1253
+ }
1254
+ /** @inheritdoc */
1255
+ async move(taskId, targetSprintId) {
1256
+ const existing = await this.findById(taskId);
1257
+ if (!existing) {
1258
+ return null;
1259
+ }
1260
+ this.db.transaction((tx) => {
1261
+ const rows = tx.select().from(tasks).where(eq3(tasks.sprintId, targetSprintId)).all();
1262
+ const nextOrder = rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.taskOrder)) + 1;
1263
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1264
+ tx.update(tasks).set({
1265
+ sprintId: targetSprintId,
1266
+ taskOrder: nextOrder,
1267
+ updatedAt: now
1268
+ }).where(eq3(tasks.id, taskId)).run();
1269
+ });
1270
+ return this.findById(taskId);
1271
+ }
1272
+ /** @inheritdoc */
1273
+ async delete(id) {
1274
+ const before = await this.findById(id);
1275
+ if (!before) {
1276
+ return false;
1277
+ }
1278
+ this.db.delete(tasks).where(eq3(tasks.id, id)).run();
1279
+ return await this.findById(id) === null;
1280
+ }
1281
+ /** @inheritdoc */
1282
+ async getDependencies(taskId) {
1283
+ const rows = this.db.select().from(taskDependencies).where(eq3(taskDependencies.taskId, taskId)).all();
1284
+ return rows.map(toTaskDependencyEntity);
1285
+ }
1286
+ /** @inheritdoc */
1287
+ async listDependentTaskIds(dependsOnTaskId) {
1288
+ const rows = this.db.select().from(taskDependencies).where(eq3(taskDependencies.dependsOnTaskId, dependsOnTaskId)).all();
1289
+ return rows.map((r) => r.taskId);
1290
+ }
1291
+ /** @inheritdoc */
1292
+ async setDependencies(taskId, dependsOnTaskIds) {
1293
+ const unique = [...new Set(dependsOnTaskIds)];
1294
+ this.db.transaction((tx) => {
1295
+ tx.delete(taskDependencies).where(eq3(taskDependencies.taskId, taskId)).run();
1296
+ for (const dependsOnTaskId of unique) {
1297
+ tx.insert(taskDependencies).values({ taskId, dependsOnTaskId }).run();
1298
+ }
1299
+ });
1300
+ }
1301
+ /** @inheritdoc */
1302
+ async countByStatusGlobally() {
1303
+ const rows = this.db.select().from(tasks).all();
1304
+ const out = {
1305
+ todo: 0,
1306
+ in_progress: 0,
1307
+ blocked: 0,
1308
+ done: 0
1309
+ };
1310
+ for (const r of rows) {
1311
+ const s = r.status;
1312
+ if (s in out) {
1313
+ out[s] += 1;
1314
+ }
1315
+ }
1316
+ return out;
1317
+ }
1318
+ };
1319
+
1320
+ // src/infrastructure/repositories/repository-factory.ts
1321
+ function createRepositories(adapter, config) {
1322
+ if (adapter !== "sqlite") {
1323
+ throw new Error(`Unsupported storage adapter: ${adapter}`);
1324
+ }
1325
+ const db = config.sqlite?.db;
1326
+ if (!db) {
1327
+ throw new Error('createRepositories("sqlite") requires config.sqlite.db');
1328
+ }
1329
+ return {
1330
+ plans: new DrizzlePlanRepository(db),
1331
+ sprints: new DrizzleSprintRepository(db),
1332
+ tasks: new DrizzleTaskRepository(db)
1333
+ };
1334
+ }
1335
+
1336
+ // src/http/app-factory.ts
1337
+ import express from "express";
1338
+
1339
+ // src/http/routes/v1/index.ts
1340
+ import { Router } from "express";
1341
+
1342
+ // src/http/controllers/plan.controller.ts
1343
+ var PlanController = class {
1344
+ constructor(planService, sprintService, taskService) {
1345
+ this.planService = planService;
1346
+ this.sprintService = sprintService;
1347
+ this.taskService = taskService;
1348
+ }
1349
+ list = async (_req, res, next) => {
1350
+ try {
1351
+ const plans2 = await this.planService.listPlans();
1352
+ res.json({ plans: plans2 });
1353
+ } catch (e) {
1354
+ next(e);
1355
+ }
1356
+ };
1357
+ create = async (req, res, next) => {
1358
+ try {
1359
+ const plan = await this.planService.createPlan(req.body);
1360
+ res.status(201).json({ plan });
1361
+ } catch (e) {
1362
+ next(e);
1363
+ }
1364
+ };
1365
+ getPlanAnalytics = async (req, res, next) => {
1366
+ try {
1367
+ const { idOrSlug } = req.params;
1368
+ const analytics = await this.taskService.getPlanSprintTaskAnalytics(
1369
+ idOrSlug
1370
+ );
1371
+ res.json({ analytics });
1372
+ } catch (e) {
1373
+ next(e);
1374
+ }
1375
+ };
1376
+ getOne = async (req, res, next) => {
1377
+ try {
1378
+ const { idOrSlug } = req.params;
1379
+ const plan = await this.planService.getPlan(idOrSlug);
1380
+ res.json({ plan });
1381
+ } catch (e) {
1382
+ next(e);
1383
+ }
1384
+ };
1385
+ update = async (req, res, next) => {
1386
+ try {
1387
+ const { idOrSlug } = req.params;
1388
+ const plan = await this.planService.updatePlan(idOrSlug, req.body);
1389
+ res.json({ plan });
1390
+ } catch (e) {
1391
+ next(e);
1392
+ }
1393
+ };
1394
+ activate = async (req, res, next) => {
1395
+ try {
1396
+ const { idOrSlug } = req.params;
1397
+ await this.planService.setActivePlan(idOrSlug);
1398
+ res.json({ ok: true });
1399
+ } catch (e) {
1400
+ next(e);
1401
+ }
1402
+ };
1403
+ getExecutionContext = async (_req, res, next) => {
1404
+ try {
1405
+ const plan = await this.planService.getActivePlan();
1406
+ const sprints2 = await this.sprintService.listSprints(plan.id);
1407
+ const activeSprints = sprints2.filter((s) => s.status === "active");
1408
+ res.json({ plan, activeSprints });
1409
+ } catch (e) {
1410
+ next(e);
1411
+ }
1412
+ };
1413
+ /** Composes execution context with global task throughput for the dashboard. */
1414
+ getDashboardOverview = async (_req, res, next) => {
1415
+ try {
1416
+ const throughput = await this.taskService.getGlobalTaskThroughput();
1417
+ let plan;
1418
+ try {
1419
+ plan = await this.planService.getActivePlan();
1420
+ } catch (e) {
1421
+ if (e instanceof NotFoundError) {
1422
+ res.json({
1423
+ execution: { plan: null, activeSprints: [] },
1424
+ throughput
1425
+ });
1426
+ return;
1427
+ }
1428
+ throw e;
1429
+ }
1430
+ const sprints2 = await this.sprintService.listSprints(plan.id);
1431
+ const activeSprints = sprints2.filter((s) => s.status === "active");
1432
+ res.json({
1433
+ execution: { plan, activeSprints },
1434
+ throughput
1435
+ });
1436
+ } catch (e) {
1437
+ next(e);
1438
+ }
1439
+ };
1440
+ };
1441
+
1442
+ // src/http/controllers/sprint.controller.ts
1443
+ var SprintController = class {
1444
+ constructor(sprintService, taskService) {
1445
+ this.sprintService = sprintService;
1446
+ this.taskService = taskService;
1447
+ }
1448
+ listByPlan = async (req, res, next) => {
1449
+ try {
1450
+ const { planId } = req.params;
1451
+ const sprints2 = await this.sprintService.listSprints(planId);
1452
+ res.json({ sprints: sprints2 });
1453
+ } catch (e) {
1454
+ next(e);
1455
+ }
1456
+ };
1457
+ create = async (req, res, next) => {
1458
+ try {
1459
+ const { planId } = req.params;
1460
+ const sprint = await this.sprintService.createSprint(planId, {
1461
+ ...req.body,
1462
+ planId
1463
+ });
1464
+ res.status(201).json({ sprint });
1465
+ } catch (e) {
1466
+ next(e);
1467
+ }
1468
+ };
1469
+ getOne = async (req, res, next) => {
1470
+ try {
1471
+ const { id } = req.params;
1472
+ const sprint = await this.sprintService.getSprint(id);
1473
+ const tasks2 = await this.taskService.listTasks(sprint.id);
1474
+ res.json({ sprint, tasks: tasks2 });
1475
+ } catch (e) {
1476
+ next(e);
1477
+ }
1478
+ };
1479
+ patch = async (req, res, next) => {
1480
+ try {
1481
+ const { id } = req.params;
1482
+ const b = req.body;
1483
+ const sprint = await this.sprintService.updateSprint(id, {
1484
+ ...b.name !== void 0 ? { name: b.name } : {},
1485
+ ...b.goal !== void 0 ? { goal: b.goal } : {},
1486
+ ...b.markdownContent !== void 0 ? { markdownContent: b.markdownContent } : {},
1487
+ ...b.startDate !== void 0 ? { startDate: b.startDate } : {},
1488
+ ...b.endDate !== void 0 ? { endDate: b.endDate } : {},
1489
+ ...b.order !== void 0 ? { order: b.order } : {}
1490
+ });
1491
+ res.json({ sprint });
1492
+ } catch (e) {
1493
+ next(e);
1494
+ }
1495
+ };
1496
+ updateStatus = async (req, res, next) => {
1497
+ try {
1498
+ const { id } = req.params;
1499
+ const { status } = req.body;
1500
+ const sprint = await this.sprintService.updateSprintStatus(id, status);
1501
+ res.json({ sprint });
1502
+ } catch (e) {
1503
+ next(e);
1504
+ }
1505
+ };
1506
+ };
1507
+
1508
+ // src/http/controllers/task.controller.ts
1509
+ var TaskController = class {
1510
+ constructor(taskService) {
1511
+ this.taskService = taskService;
1512
+ }
1513
+ listBySprint = async (req, res, next) => {
1514
+ try {
1515
+ const { sprintId } = req.params;
1516
+ const q = req.validatedQuery ?? {};
1517
+ const filter = q.status || q.priority || q.assignee !== void 0 ? {
1518
+ ...q.status ? { status: q.status } : {},
1519
+ ...q.priority ? { priority: q.priority } : {},
1520
+ ...q.assignee !== void 0 ? { assignee: q.assignee } : {}
1521
+ } : void 0;
1522
+ const tasks2 = await this.taskService.listTasks(sprintId, void 0, filter);
1523
+ res.json({ tasks: tasks2 });
1524
+ } catch (e) {
1525
+ next(e);
1526
+ }
1527
+ };
1528
+ create = async (req, res, next) => {
1529
+ try {
1530
+ const { sprintId } = req.params;
1531
+ const task = await this.taskService.createTask(
1532
+ { ...req.body, sprintId },
1533
+ void 0
1534
+ );
1535
+ res.status(201).json({ task });
1536
+ } catch (e) {
1537
+ next(e);
1538
+ }
1539
+ };
1540
+ getOne = async (req, res, next) => {
1541
+ try {
1542
+ const { id } = req.params;
1543
+ const task = await this.taskService.getTask(id);
1544
+ res.json({ task });
1545
+ } catch (e) {
1546
+ next(e);
1547
+ }
1548
+ };
1549
+ patch = async (req, res, next) => {
1550
+ try {
1551
+ const { id } = req.params;
1552
+ const b = req.body;
1553
+ const task = await this.taskService.updateTask(id, {
1554
+ ...b.title !== void 0 ? { title: b.title } : {},
1555
+ ...b.description !== void 0 ? { description: b.description } : {},
1556
+ ...b.priority !== void 0 ? { priority: b.priority } : {},
1557
+ ...b.assignee !== void 0 ? { assignee: b.assignee } : {},
1558
+ ...b.tags !== void 0 ? { tags: b.tags } : {},
1559
+ ...b.order !== void 0 ? { order: b.order } : {},
1560
+ ...b.touchedFiles !== void 0 ? { touchedFiles: b.touchedFiles } : {}
1561
+ });
1562
+ res.json({ task });
1563
+ } catch (e) {
1564
+ next(e);
1565
+ }
1566
+ };
1567
+ updateStatus = async (req, res, next) => {
1568
+ try {
1569
+ const { id } = req.params;
1570
+ const { status } = req.body;
1571
+ const task = await this.taskService.updateTaskStatus(id, status);
1572
+ res.json({ task });
1573
+ } catch (e) {
1574
+ next(e);
1575
+ }
1576
+ };
1577
+ assign = async (req, res, next) => {
1578
+ try {
1579
+ const { id } = req.params;
1580
+ const { assignee } = req.body;
1581
+ const task = await this.taskService.assignTask(id, assignee);
1582
+ res.json({ task });
1583
+ } catch (e) {
1584
+ next(e);
1585
+ }
1586
+ };
1587
+ move = async (req, res, next) => {
1588
+ try {
1589
+ const { id } = req.params;
1590
+ const { targetSprintId } = req.body;
1591
+ const task = await this.taskService.moveTask(id, targetSprintId);
1592
+ res.json({ task });
1593
+ } catch (e) {
1594
+ next(e);
1595
+ }
1596
+ };
1597
+ remove = async (req, res, next) => {
1598
+ try {
1599
+ const { id } = req.params;
1600
+ await this.taskService.deleteTask(id);
1601
+ res.json({ deleted: true });
1602
+ } catch (e) {
1603
+ next(e);
1604
+ }
1605
+ };
1606
+ };
1607
+
1608
+ // src/http/controllers/health.controller.ts
1609
+ import { readFileSync } from "fs";
1610
+ function readPackageVersion() {
1611
+ const path = new URL("../../../package.json", import.meta.url);
1612
+ const raw = readFileSync(path, "utf8");
1613
+ const pkg = JSON.parse(raw);
1614
+ return pkg.version ?? "0.0.0";
1615
+ }
1616
+ var HealthController = class {
1617
+ version = readPackageVersion();
1618
+ check = (_req, res, _next) => {
1619
+ res.json({
1620
+ status: "ok",
1621
+ version: this.version,
1622
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1623
+ });
1624
+ };
1625
+ };
1626
+
1627
+ // src/http/middleware/validate.ts
1628
+ import { z } from "zod";
1629
+ function validate(schemas) {
1630
+ return (req, res, next) => {
1631
+ try {
1632
+ if (schemas.body) {
1633
+ req.body = schemas.body.parse(req.body);
1634
+ }
1635
+ if (schemas.query) {
1636
+ const rawQuery = req.query && typeof req.query === "object" ? { ...req.query } : {};
1637
+ req.validatedQuery = schemas.query.parse(rawQuery);
1638
+ }
1639
+ if (schemas.params) {
1640
+ req.params = schemas.params.parse({
1641
+ ...req.params
1642
+ });
1643
+ }
1644
+ next();
1645
+ } catch (e) {
1646
+ if (e instanceof z.ZodError) {
1647
+ res.status(400).json({
1648
+ error: {
1649
+ message: "Validation failed",
1650
+ details: e.issues
1651
+ }
1652
+ });
1653
+ return;
1654
+ }
1655
+ next(e);
1656
+ }
1657
+ };
1658
+ }
1659
+
1660
+ // src/http/routes/v1/schemas.ts
1661
+ import { z as z2 } from "zod";
1662
+ var planStatusSchema = z2.enum([
1663
+ "draft",
1664
+ "active",
1665
+ "completed",
1666
+ "archived"
1667
+ ]);
1668
+ var sprintStatusSchema = z2.enum([
1669
+ "planned",
1670
+ "active",
1671
+ "completed",
1672
+ "archived"
1673
+ ]);
1674
+ var taskStatusSchema = z2.enum([
1675
+ "todo",
1676
+ "in_progress",
1677
+ "blocked",
1678
+ "done"
1679
+ ]);
1680
+ var taskPrioritySchema = z2.enum([
1681
+ "low",
1682
+ "medium",
1683
+ "high",
1684
+ "critical"
1685
+ ]);
1686
+ var idOrSlugParams = z2.object({
1687
+ idOrSlug: z2.string().min(1)
1688
+ });
1689
+ var planIdParams = z2.object({
1690
+ planId: z2.string().min(1)
1691
+ });
1692
+ var sprintIdParams = z2.object({
1693
+ sprintId: z2.string().min(1)
1694
+ });
1695
+ var sprintIdPathParams = z2.object({
1696
+ id: z2.string().min(1)
1697
+ });
1698
+ var taskIdPathParams = z2.object({
1699
+ id: z2.string().min(1)
1700
+ });
1701
+ var createPlanBody = z2.object({
1702
+ slug: z2.string().min(1),
1703
+ title: z2.string().min(1),
1704
+ description: z2.string().nullable().optional(),
1705
+ markdownContent: z2.string().nullable().optional()
1706
+ });
1707
+ var updatePlanBody = z2.object({
1708
+ title: z2.string().min(1).optional(),
1709
+ description: z2.string().nullable().optional(),
1710
+ markdownContent: z2.string().nullable().optional(),
1711
+ status: planStatusSchema.optional()
1712
+ }).refine(
1713
+ (v) => v.title !== void 0 || v.description !== void 0 || v.markdownContent !== void 0 || v.status !== void 0,
1714
+ { message: "At least one field is required" }
1715
+ );
1716
+ var createSprintBody = z2.object({
1717
+ slug: z2.string().min(1),
1718
+ name: z2.string().min(1),
1719
+ goal: z2.string(),
1720
+ markdownContent: z2.string().nullable().optional(),
1721
+ order: z2.number().int().optional(),
1722
+ startDate: z2.string().nullable().optional(),
1723
+ endDate: z2.string().nullable().optional()
1724
+ });
1725
+ var updateSprintBody = z2.object({
1726
+ name: z2.string().min(1).optional(),
1727
+ goal: z2.string().optional(),
1728
+ markdownContent: z2.string().nullable().optional(),
1729
+ startDate: z2.string().nullable().optional(),
1730
+ endDate: z2.string().nullable().optional(),
1731
+ order: z2.number().int().optional()
1732
+ }).refine(
1733
+ (v) => v.name !== void 0 || v.goal !== void 0 || v.markdownContent !== void 0 || v.startDate !== void 0 || v.endDate !== void 0 || v.order !== void 0,
1734
+ { message: "At least one field is required" }
1735
+ );
1736
+ var updateSprintStatusBody = z2.object({
1737
+ status: sprintStatusSchema
1738
+ });
1739
+ var taskListQuery = z2.object({
1740
+ status: taskStatusSchema.optional(),
1741
+ priority: taskPrioritySchema.optional(),
1742
+ assignee: z2.string().optional()
1743
+ });
1744
+ var taskTouchedFileTypeSchema = z2.enum([
1745
+ "test",
1746
+ "implementation",
1747
+ "doc",
1748
+ "config",
1749
+ "other"
1750
+ ]);
1751
+ var taskTouchedFileItemSchema = z2.object({
1752
+ path: z2.string().min(1).max(2048),
1753
+ fileType: taskTouchedFileTypeSchema
1754
+ });
1755
+ var createTaskBody = z2.object({
1756
+ title: z2.string().min(1),
1757
+ priority: taskPrioritySchema,
1758
+ description: z2.string().nullable().optional(),
1759
+ order: z2.number().int().optional(),
1760
+ assignee: z2.string().nullable().optional(),
1761
+ tags: z2.array(z2.string()).nullable().optional(),
1762
+ dependsOnTaskIds: z2.array(z2.string().min(1)).optional(),
1763
+ touchedFiles: z2.array(taskTouchedFileItemSchema).optional()
1764
+ });
1765
+ var updateTaskBody = z2.object({
1766
+ title: z2.string().min(1).optional(),
1767
+ description: z2.string().nullable().optional(),
1768
+ priority: taskPrioritySchema.optional(),
1769
+ assignee: z2.string().nullable().optional(),
1770
+ tags: z2.array(z2.string()).nullable().optional(),
1771
+ order: z2.number().int().optional(),
1772
+ touchedFiles: z2.array(taskTouchedFileItemSchema).optional()
1773
+ }).refine(
1774
+ (v) => v.title !== void 0 || v.description !== void 0 || v.priority !== void 0 || v.assignee !== void 0 || v.tags !== void 0 || v.order !== void 0 || v.touchedFiles !== void 0,
1775
+ { message: "At least one field is required" }
1776
+ );
1777
+ var updateTaskStatusBody = z2.object({
1778
+ status: taskStatusSchema
1779
+ });
1780
+ var assignTaskBody = z2.object({
1781
+ assignee: z2.union([z2.string().min(1), z2.null()])
1782
+ });
1783
+ var moveTaskBody = z2.object({
1784
+ targetSprintId: z2.string().min(1)
1785
+ });
1786
+
1787
+ // src/http/routes/v1/plan.routes.ts
1788
+ function registerPlanRoutes(router, c) {
1789
+ router.get(
1790
+ "/dashboard-overview",
1791
+ (req, res, next) => void c.getDashboardOverview(req, res, next)
1792
+ );
1793
+ router.get("/plans", (req, res, next) => void c.list(req, res, next));
1794
+ router.post(
1795
+ "/plans",
1796
+ validate({ body: createPlanBody }),
1797
+ (req, res, next) => void c.create(req, res, next)
1798
+ );
1799
+ router.get(
1800
+ "/plans/:idOrSlug/analytics",
1801
+ validate({ params: idOrSlugParams }),
1802
+ (req, res, next) => void c.getPlanAnalytics(req, res, next)
1803
+ );
1804
+ router.get(
1805
+ "/plans/:idOrSlug",
1806
+ validate({ params: idOrSlugParams }),
1807
+ (req, res, next) => void c.getOne(req, res, next)
1808
+ );
1809
+ router.patch(
1810
+ "/plans/:idOrSlug",
1811
+ validate({ params: idOrSlugParams, body: updatePlanBody }),
1812
+ (req, res, next) => void c.update(req, res, next)
1813
+ );
1814
+ router.post(
1815
+ "/plans/:idOrSlug/activate",
1816
+ validate({ params: idOrSlugParams }),
1817
+ (req, res, next) => void c.activate(req, res, next)
1818
+ );
1819
+ router.get(
1820
+ "/execution-context",
1821
+ (req, res, next) => void c.getExecutionContext(req, res, next)
1822
+ );
1823
+ }
1824
+
1825
+ // src/http/routes/v1/sprint.routes.ts
1826
+ function registerSprintRoutes(router, c) {
1827
+ router.get(
1828
+ "/plans/:planId/sprints",
1829
+ validate({ params: planIdParams }),
1830
+ (req, res, next) => void c.listByPlan(req, res, next)
1831
+ );
1832
+ router.post(
1833
+ "/plans/:planId/sprints",
1834
+ validate({ params: planIdParams, body: createSprintBody }),
1835
+ (req, res, next) => void c.create(req, res, next)
1836
+ );
1837
+ router.get(
1838
+ "/sprints/:id",
1839
+ validate({ params: sprintIdPathParams }),
1840
+ (req, res, next) => void c.getOne(req, res, next)
1841
+ );
1842
+ router.patch(
1843
+ "/sprints/:id",
1844
+ validate({ params: sprintIdPathParams, body: updateSprintBody }),
1845
+ (req, res, next) => void c.patch(req, res, next)
1846
+ );
1847
+ router.patch(
1848
+ "/sprints/:id/status",
1849
+ validate({ params: sprintIdPathParams, body: updateSprintStatusBody }),
1850
+ (req, res, next) => void c.updateStatus(req, res, next)
1851
+ );
1852
+ }
1853
+
1854
+ // src/http/routes/v1/task.routes.ts
1855
+ function registerTaskRoutes(router, c) {
1856
+ router.get(
1857
+ "/sprints/:sprintId/tasks",
1858
+ validate({ params: sprintIdParams, query: taskListQuery }),
1859
+ (req, res, next) => void c.listBySprint(req, res, next)
1860
+ );
1861
+ router.post(
1862
+ "/sprints/:sprintId/tasks",
1863
+ validate({ params: sprintIdParams, body: createTaskBody }),
1864
+ (req, res, next) => void c.create(req, res, next)
1865
+ );
1866
+ router.get(
1867
+ "/tasks/:id",
1868
+ validate({ params: taskIdPathParams }),
1869
+ (req, res, next) => void c.getOne(req, res, next)
1870
+ );
1871
+ router.patch(
1872
+ "/tasks/:id",
1873
+ validate({ params: taskIdPathParams, body: updateTaskBody }),
1874
+ (req, res, next) => void c.patch(req, res, next)
1875
+ );
1876
+ router.patch(
1877
+ "/tasks/:id/status",
1878
+ validate({ params: taskIdPathParams, body: updateTaskStatusBody }),
1879
+ (req, res, next) => void c.updateStatus(req, res, next)
1880
+ );
1881
+ router.patch(
1882
+ "/tasks/:id/assign",
1883
+ validate({ params: taskIdPathParams, body: assignTaskBody }),
1884
+ (req, res, next) => void c.assign(req, res, next)
1885
+ );
1886
+ router.post(
1887
+ "/tasks/:id/move",
1888
+ validate({ params: taskIdPathParams, body: moveTaskBody }),
1889
+ (req, res, next) => void c.move(req, res, next)
1890
+ );
1891
+ router.delete(
1892
+ "/tasks/:id",
1893
+ validate({ params: taskIdPathParams }),
1894
+ (req, res, next) => void c.remove(req, res, next)
1895
+ );
1896
+ }
1897
+
1898
+ // src/http/routes/v1/index.ts
1899
+ function createV1Router(services) {
1900
+ const router = Router();
1901
+ const health = new HealthController();
1902
+ const planController = new PlanController(
1903
+ services.planService,
1904
+ services.sprintService,
1905
+ services.taskService
1906
+ );
1907
+ const sprintController = new SprintController(
1908
+ services.sprintService,
1909
+ services.taskService
1910
+ );
1911
+ const taskController = new TaskController(services.taskService);
1912
+ router.get("/health", health.check);
1913
+ registerPlanRoutes(router, planController);
1914
+ registerSprintRoutes(router, sprintController);
1915
+ registerTaskRoutes(router, taskController);
1916
+ return router;
1917
+ }
1918
+
1919
+ // src/http/middleware/cors.ts
1920
+ var DEFAULT_METHODS = "GET,POST,PATCH,DELETE,OPTIONS,HEAD";
1921
+ var DEFAULT_ALLOWED_HEADERS = "Content-Type, Authorization, X-Request-Id";
1922
+ function corsMiddleware(options) {
1923
+ const origin = options?.origin ?? "*";
1924
+ const methods = options?.methods ?? DEFAULT_METHODS;
1925
+ const allowedHeaders = options?.allowedHeaders ?? DEFAULT_ALLOWED_HEADERS;
1926
+ return (req, res, next) => {
1927
+ res.setHeader("Access-Control-Allow-Origin", origin);
1928
+ res.setHeader("Access-Control-Allow-Methods", methods);
1929
+ res.setHeader("Access-Control-Allow-Headers", allowedHeaders);
1930
+ if (req.method === "OPTIONS") {
1931
+ res.status(204).end();
1932
+ return;
1933
+ }
1934
+ next();
1935
+ };
1936
+ }
1937
+
1938
+ // src/http/middleware/error-handler.ts
1939
+ function errorHandler(err, _req, res, next) {
1940
+ if (res.headersSent) {
1941
+ next(err);
1942
+ return;
1943
+ }
1944
+ if (err instanceof BackendError) {
1945
+ res.status(err.statusCode).json({
1946
+ error: {
1947
+ message: err.message,
1948
+ statusCode: err.statusCode
1949
+ }
1950
+ });
1951
+ return;
1952
+ }
1953
+ const message = err instanceof Error ? err.message : "Internal server error";
1954
+ const payload = {
1955
+ error: {
1956
+ message,
1957
+ statusCode: 500
1958
+ }
1959
+ };
1960
+ if (process.env.NODE_ENV !== "production" && err instanceof Error && err.stack) {
1961
+ payload.error.stack = err.stack;
1962
+ }
1963
+ res.status(500).json(payload);
1964
+ }
1965
+
1966
+ // src/http/middleware/request-id.ts
1967
+ import { randomUUID as randomUUID4 } from "crypto";
1968
+ var HEADER = "x-request-id";
1969
+ function requestIdMiddleware(req, res, next) {
1970
+ const incoming = req.get(HEADER);
1971
+ const id = typeof incoming === "string" && incoming.trim().length > 0 ? incoming.trim() : randomUUID4();
1972
+ req.id = id;
1973
+ res.setHeader("X-Request-Id", id);
1974
+ next();
1975
+ }
1976
+
1977
+ // src/http/app-factory.ts
1978
+ function createHttpApp(services, options) {
1979
+ const app = express();
1980
+ if (options?.trustProxy) {
1981
+ app.set("trust proxy", true);
1982
+ }
1983
+ app.use(express.json());
1984
+ if (options?.cors !== false) {
1985
+ app.use(corsMiddleware());
1986
+ }
1987
+ app.use(requestIdMiddleware);
1988
+ app.get("/", (_req, res) => {
1989
+ res.type("application/json").status(200).send({
1990
+ service: "sprintdock-rest",
1991
+ message: "JSON API lives under /api/v1. Open /api/v1/health to verify the server.",
1992
+ apiBase: "/api/v1",
1993
+ health: "/api/v1/health",
1994
+ webUi: "The browser UI is a separate Vite app (@sprintdock/webui); run pnpm dev:web with SPRINTDOCK_API_BASE_URL pointing at this origin."
1995
+ });
1996
+ });
1997
+ app.use("/api/v1", createV1Router(services));
1998
+ app.use(errorHandler);
1999
+ return app;
2000
+ }
2001
+
2002
+ // src/mcp/sprintdock-mcp-runtime.ts
2003
+ import { getEnv } from "@sprintdock/env-manager";
2004
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2005
+ import { resolveWorkspaceRoot as resolveWorkspaceRoot2 } from "@sprintdock/shared";
2006
+
2007
+ // src/infrastructure/observability/audit-log.ts
2008
+ var ConsoleAuditLog = class {
2009
+ /** @inheritdoc */
2010
+ write(event) {
2011
+ process.stderr.write(`[audit] ${JSON.stringify(event)}
2012
+ `);
2013
+ }
2014
+ };
2015
+
2016
+ // src/infrastructure/observability/request-correlation.ts
2017
+ import { randomUUID as randomUUID5 } from "crypto";
2018
+ var RequestCorrelation = class {
2019
+ /**
2020
+ * Creates a new unique correlation identifier.
2021
+ *
2022
+ * @returns Correlation id string.
2023
+ */
2024
+ create() {
2025
+ return randomUUID5();
2026
+ }
2027
+ };
2028
+
2029
+ // src/infrastructure/security/auth-context.ts
2030
+ var LocalAuthContextResolver = class {
2031
+ /** @inheritdoc */
2032
+ async resolve(transport) {
2033
+ return {
2034
+ principalId: "local-dev-principal",
2035
+ scopes: ["sprintdock:read", "sprintdock:write"],
2036
+ transport
2037
+ };
2038
+ }
2039
+ };
2040
+
2041
+ // src/infrastructure/security/rate-limiter.ts
2042
+ var InMemoryBackendRateLimiter = class {
2043
+ maxRequests;
2044
+ windowMs;
2045
+ requestWindows;
2046
+ /**
2047
+ * @param maxRequests Maximum allowed requests per window.
2048
+ * @param windowMs Window size in milliseconds.
2049
+ */
2050
+ constructor(maxRequests = 120, windowMs = 6e4) {
2051
+ this.maxRequests = maxRequests;
2052
+ this.windowMs = windowMs;
2053
+ this.requestWindows = /* @__PURE__ */ new Map();
2054
+ }
2055
+ /** @inheritdoc */
2056
+ check(key) {
2057
+ const now = Date.now();
2058
+ const threshold = now - this.windowMs;
2059
+ const previous = this.requestWindows.get(key) ?? [];
2060
+ const currentWindow = previous.filter((timestamp) => timestamp > threshold);
2061
+ if (currentWindow.length >= this.maxRequests) {
2062
+ const oldestRequestTimestamp = currentWindow[0] ?? now;
2063
+ const retryAfterSeconds = Math.max(
2064
+ 1,
2065
+ Math.ceil((oldestRequestTimestamp + this.windowMs - now) / 1e3)
2066
+ );
2067
+ this.requestWindows.set(key, currentWindow);
2068
+ return {
2069
+ allowed: false,
2070
+ retryAfterSeconds
2071
+ };
2072
+ }
2073
+ currentWindow.push(now);
2074
+ this.requestWindows.set(key, currentWindow);
2075
+ return {
2076
+ allowed: true,
2077
+ retryAfterSeconds: 0
2078
+ };
2079
+ }
2080
+ };
2081
+
2082
+ // src/mcp/bootstrap-sprintdock-sqlite.ts
2083
+ import { mkdirSync } from "fs";
2084
+ import { dirname as dirname2, isAbsolute, join as join2 } from "path";
2085
+ import {
2086
+ ensureSprintdockDirectory,
2087
+ resolveSprintdockDbPath,
2088
+ resolveWorkspaceRoot
2089
+ } from "@sprintdock/shared";
2090
+
2091
+ // src/db/connection.ts
2092
+ import Database from "better-sqlite3";
2093
+ import { drizzle } from "drizzle-orm/better-sqlite3";
2094
+
2095
+ // src/db/schema/index.ts
2096
+ var schema_exports = {};
2097
+ __export(schema_exports, {
2098
+ plans: () => plans,
2099
+ plansRelations: () => plansRelations,
2100
+ sprints: () => sprints,
2101
+ sprintsRelations: () => sprintsRelations,
2102
+ taskDependencies: () => taskDependencies,
2103
+ taskTouchedFiles: () => taskTouchedFiles,
2104
+ taskTouchedFilesRelations: () => taskTouchedFilesRelations,
2105
+ tasks: () => tasks,
2106
+ tasksRelations: () => tasksRelations
2107
+ });
2108
+
2109
+ // src/db/schema/relations.ts
2110
+ import { relations } from "drizzle-orm";
2111
+ var plansRelations = relations(plans, ({ many }) => ({
2112
+ sprints: many(sprints)
2113
+ }));
2114
+ var sprintsRelations = relations(sprints, ({ one, many }) => ({
2115
+ plan: one(plans, {
2116
+ fields: [sprints.planId],
2117
+ references: [plans.id]
2118
+ }),
2119
+ tasks: many(tasks)
2120
+ }));
2121
+ var tasksRelations = relations(tasks, ({ one, many }) => ({
2122
+ sprint: one(sprints, {
2123
+ fields: [tasks.sprintId],
2124
+ references: [sprints.id]
2125
+ }),
2126
+ touchedFiles: many(taskTouchedFiles)
2127
+ }));
2128
+ var taskTouchedFilesRelations = relations(taskTouchedFiles, ({ one }) => ({
2129
+ task: one(tasks, {
2130
+ fields: [taskTouchedFiles.taskId],
2131
+ references: [tasks.id]
2132
+ })
2133
+ }));
2134
+
2135
+ // src/db/connection.ts
2136
+ function createSqliteConnection(dbPath) {
2137
+ const sqlite = new Database(dbPath);
2138
+ sqlite.pragma("journal_mode = WAL");
2139
+ sqlite.pragma("foreign_keys = ON");
2140
+ sqlite.pragma("busy_timeout = 5000");
2141
+ return drizzle(sqlite, { schema: schema_exports });
2142
+ }
2143
+
2144
+ // src/db/migrator.ts
2145
+ import { existsSync } from "fs";
2146
+ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
2147
+ import { dirname, join } from "path";
2148
+ import { fileURLToPath } from "url";
2149
+ function resolveMigrationsFolder() {
2150
+ let dir = dirname(fileURLToPath(import.meta.url));
2151
+ for (let i = 0; i < 8; i++) {
2152
+ const candidate = join(dir, "drizzle");
2153
+ if (existsSync(join(candidate, "meta", "_journal.json"))) {
2154
+ return candidate;
2155
+ }
2156
+ const parent = dirname(dir);
2157
+ if (parent === dir) {
2158
+ break;
2159
+ }
2160
+ dir = parent;
2161
+ }
2162
+ throw new Error(
2163
+ "Sprintdock: could not find drizzle migrations (expected drizzle/meta/_journal.json next to @sprintdock/backend)."
2164
+ );
2165
+ }
2166
+ var migrationsFolder = resolveMigrationsFolder();
2167
+ function runMigrations(db) {
2168
+ migrate(db, { migrationsFolder });
2169
+ }
2170
+
2171
+ // src/mcp/bootstrap-sprintdock-sqlite.ts
2172
+ async function bootstrapSprintdockSqlite() {
2173
+ const workspaceRoot = resolveWorkspaceRoot();
2174
+ await ensureSprintdockDirectory({ workspaceRoot });
2175
+ const envPath = process.env.SPRINTDOCK_DB_PATH?.trim();
2176
+ const dbPath = envPath && envPath.length > 0 ? isAbsolute(envPath) ? envPath : join2(workspaceRoot, envPath) : resolveSprintdockDbPath({ workspaceRoot });
2177
+ mkdirSync(dirname2(dbPath), { recursive: true });
2178
+ const db = createSqliteConnection(dbPath);
2179
+ runMigrations(db);
2180
+ const repos = createRepositories("sqlite", { sqlite: { db } });
2181
+ const services = createApplicationServices(repos);
2182
+ return { db, services, dbPath };
2183
+ }
2184
+
2185
+ // src/mcp/plugins/context-tools.plugin.ts
2186
+ import { z as z3 } from "zod";
2187
+
2188
+ // src/mcp/mcp-query-helpers.ts
2189
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2190
+ function looksLikeUuid(value) {
2191
+ return UUID_RE.test(value);
2192
+ }
2193
+ async function resolvePlanOrActive(planService, planSlug) {
2194
+ if (planSlug !== void 0 && planSlug !== "") {
2195
+ return planService.resolvePlan(planSlug);
2196
+ }
2197
+ return planService.getActivePlan();
2198
+ }
2199
+ function countByTaskStatus(tasks2) {
2200
+ const base = {
2201
+ todo: 0,
2202
+ in_progress: 0,
2203
+ blocked: 0,
2204
+ done: 0
2205
+ };
2206
+ for (const t of tasks2) {
2207
+ base[t.status] += 1;
2208
+ }
2209
+ return base;
2210
+ }
2211
+ async function getSprintForPlan(sprintService, plan, sprintIdOrSlug) {
2212
+ if (looksLikeUuid(sprintIdOrSlug)) {
2213
+ const sp = await sprintService.getSprint(sprintIdOrSlug);
2214
+ if (sp.planId !== plan.id) {
2215
+ throw new ValidationError("Sprint does not belong to the resolved plan");
2216
+ }
2217
+ return sp;
2218
+ }
2219
+ return sprintService.getSprintBySlug(plan.id, sprintIdOrSlug);
2220
+ }
2221
+ async function ensureSprintInPlan(sprintService, plan, sprintId) {
2222
+ const sp = await sprintService.getSprint(sprintId);
2223
+ if (sp.planId !== plan.id) {
2224
+ throw new ValidationError("Sprint does not belong to the resolved plan");
2225
+ }
2226
+ return sp;
2227
+ }
2228
+ async function ensureTaskInPlan(taskService, sprintService, plan, taskId) {
2229
+ const task = await taskService.getTask(taskId);
2230
+ await ensureSprintInPlan(sprintService, plan, task.sprintId);
2231
+ return task;
2232
+ }
2233
+
2234
+ // src/mcp/mcp-text-formatters.ts
2235
+ var STATUS_ORDER = [
2236
+ "todo",
2237
+ "in_progress",
2238
+ "blocked",
2239
+ "done"
2240
+ ];
2241
+ function shortId(uuid) {
2242
+ return uuid.replace(/-/g, "").slice(0, 8);
2243
+ }
2244
+ function formatTaskCounts(counts) {
2245
+ const parts = [];
2246
+ for (const s of STATUS_ORDER) {
2247
+ const n = counts[s];
2248
+ if (n > 0) {
2249
+ parts.push(`${n} ${s}`);
2250
+ }
2251
+ }
2252
+ return parts.join(", ");
2253
+ }
2254
+ function formatTaskLine(task, indent = "") {
2255
+ const assignee = task.assignee !== null && task.assignee !== "" ? `, assignee: ${task.assignee}` : "";
2256
+ return `${indent}- [${task.status}] "${task.title}" (priority: ${task.priority}${assignee}) id:${shortId(task.id)}`;
2257
+ }
2258
+ function formatPlanList(plans2, activePlanId) {
2259
+ const header = `Plans (${plans2.length}):`;
2260
+ if (plans2.length === 0) {
2261
+ return header;
2262
+ }
2263
+ const lines = plans2.map((p) => {
2264
+ const activeSuffix = p.isActive || p.id === activePlanId ? " (active plan)" : "";
2265
+ return `- ${p.slug} [${p.status}] "${p.title}"${activeSuffix}`;
2266
+ });
2267
+ return `${header}
2268
+ ${lines.join("\n")}`;
2269
+ }
2270
+ function formatSprintList(planSlug, sprints2) {
2271
+ const header = `Sprints for '${planSlug}' (${sprints2.length}):`;
2272
+ if (sprints2.length === 0) {
2273
+ return header;
2274
+ }
2275
+ const lines = sprints2.map((s) => {
2276
+ const tc = formatTaskCounts(s.taskCounts);
2277
+ const countsPart = tc.length > 0 ? ` | ${tc}` : "";
2278
+ return `- ${s.slug} [${s.status}] "${s.name}"${countsPart} | sprintId:${s.id}`;
2279
+ });
2280
+ return `${header}
2281
+ ${lines.join("\n")}`;
2282
+ }
2283
+ function formatTaskList(tasks2, headerLabel = "Tasks") {
2284
+ const header = `${headerLabel} (${tasks2.length}):`;
2285
+ if (tasks2.length === 0) {
2286
+ return header;
2287
+ }
2288
+ const lines = tasks2.map((t) => formatTaskLine(t));
2289
+ return `${header}
2290
+ ${lines.join("\n")}`;
2291
+ }
2292
+ function formatPlanSummary(plan, sprintsOut) {
2293
+ let totalTasks = 0;
2294
+ for (const s of sprintsOut) {
2295
+ totalTasks += Object.values(s.taskCounts).reduce((a, b) => a + b, 0);
2296
+ }
2297
+ const sprintWord = sprintsOut.length === 1 ? "sprint" : "sprints";
2298
+ const taskWord = totalTasks === 1 ? "task" : "tasks";
2299
+ const head = `Plan '${plan.slug}' ("${plan.title}") -- ${sprintsOut.length} ${sprintWord}, ${totalTasks} ${taskWord} total`;
2300
+ if (sprintsOut.length === 0) {
2301
+ return head;
2302
+ }
2303
+ const breakdown = sprintsOut.map((s) => {
2304
+ const tc = formatTaskCounts(s.taskCounts);
2305
+ const countsPart = tc.length > 0 ? `: ${tc}` : ":";
2306
+ return `- ${s.sprintSlug} [${s.status}]${countsPart} (id: ${shortId(s.sprintId)})`;
2307
+ });
2308
+ return `${head}
2309
+
2310
+ Sprint breakdown:
2311
+ ${breakdown.join("\n")}`;
2312
+ }
2313
+ function formatExecutionContext(planSlug, activeSprints) {
2314
+ const head = `Execution context for '${planSlug}':
2315
+ Active sprints (${activeSprints.length}):`;
2316
+ if (activeSprints.length === 0) {
2317
+ return head;
2318
+ }
2319
+ const blocks = activeSprints.map((block) => {
2320
+ const sp = block.sprint;
2321
+ const title = `Sprint '${sp.slug}' "${sp.name}" (id: ${shortId(sp.id)}):`;
2322
+ const taskLines = block.tasks.length === 0 ? " (no tasks)" : block.tasks.map((t) => formatTaskLine(t, " ")).join("\n");
2323
+ const summary = ` Summary: ${formatTaskCounts(block.taskCounts)}`;
2324
+ return `${title}
2325
+ ${taskLines}
2326
+ ${summary}`;
2327
+ });
2328
+ return `${head}
2329
+
2330
+ ${blocks.join("\n\n")}`;
2331
+ }
2332
+ function formatPlanProgress(planSlug, done, totalTasks, sprints2) {
2333
+ const pct = totalTasks === 0 ? 0 : Math.round(done / totalTasks * 100);
2334
+ const head = `Plan '${planSlug}' progress: ${pct}% complete (${done}/${totalTasks} tasks done)`;
2335
+ if (sprints2.length === 0) {
2336
+ return head;
2337
+ }
2338
+ const lines = sprints2.map((s) => {
2339
+ const spct = s.total === 0 ? 0 : Math.round(s.done / s.total * 100);
2340
+ return `- ${s.name} (${s.slug}) [${s.status}]: ${s.done}/${s.total} done (${spct}%)`;
2341
+ });
2342
+ return `${head}
2343
+ ${lines.join("\n")}`;
2344
+ }
2345
+ function formatSprintDetail(sprint, tasks2) {
2346
+ const dates = sprint.startDate || sprint.endDate ? `
2347
+ Dates: start=${sprint.startDate ?? "\u2014"}, end=${sprint.endDate ?? "\u2014"}` : "";
2348
+ const md = sprint.markdownContent != null && sprint.markdownContent.trim().length > 0 ? `
2349
+
2350
+ --- Markdown ---
2351
+ ${sprint.markdownContent}` : "";
2352
+ const header = `Sprint '${sprint.name}' (${sprint.slug}) [${sprint.status}]
2353
+ Goal: ${sprint.goal}${dates}${md}`;
2354
+ const body = formatTaskList(tasks2, "Tasks");
2355
+ return `${header}
2356
+
2357
+ ${body}`;
2358
+ }
2359
+
2360
+ // src/mcp/mcp-tool-error.ts
2361
+ function toMcpToolError(error) {
2362
+ if (error instanceof BackendError) {
2363
+ return { content: [{ type: "text", text: error.message }], isError: true };
2364
+ }
2365
+ if (error instanceof Error) {
2366
+ return { content: [{ type: "text", text: error.message }], isError: true };
2367
+ }
2368
+ throw error;
2369
+ }
2370
+
2371
+ // src/mcp/plugins/context-tools.plugin.ts
2372
+ var contextToolsPlugin = {
2373
+ id: "sprintdock/context-tools",
2374
+ register(server, ctx) {
2375
+ const { deps, kit } = ctx;
2376
+ const { jsonStructured: jsonStructured2, optionalPlanSlug: optionalPlanSlug2 } = kit;
2377
+ const { planService, sprintService, taskService } = deps.services;
2378
+ server.registerTool(
2379
+ "get_sprint_detail",
2380
+ {
2381
+ description: "Loads one sprint by UUID or sprint slug within the resolved plan, including goal, dates, status, and a full task list with metadata. Prefer this over get_sprint when you need tasks.",
2382
+ inputSchema: z3.object({
2383
+ sprintIdOrSlug: z3.string().min(1),
2384
+ planSlug: optionalPlanSlug2
2385
+ }).strict()
2386
+ },
2387
+ async (input) => {
2388
+ try {
2389
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2390
+ const sprint = await getSprintForPlan(
2391
+ sprintService,
2392
+ plan,
2393
+ input.sprintIdOrSlug
2394
+ );
2395
+ const tasks2 = await taskService.listTasks(sprint.id);
2396
+ return {
2397
+ content: [
2398
+ {
2399
+ type: "text",
2400
+ text: formatSprintDetail(sprint, tasks2)
2401
+ }
2402
+ ],
2403
+ structuredContent: jsonStructured2({
2404
+ sprintSlug: sprint.slug,
2405
+ sprint,
2406
+ tasks: tasks2
2407
+ })
2408
+ };
2409
+ } catch (e) {
2410
+ return toMcpToolError(e);
2411
+ }
2412
+ }
2413
+ );
2414
+ server.registerTool(
2415
+ "get_execution_context",
2416
+ {
2417
+ description: "Returns the working plan plus every active sprint, each with its full task list and per-status counts. This is the main tool to see what to work on next.",
2418
+ inputSchema: z3.object({ planSlug: optionalPlanSlug2 }).strict()
2419
+ },
2420
+ async (input) => {
2421
+ try {
2422
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2423
+ const all = await sprintService.listSprints(plan.id);
2424
+ const activeSprints = [];
2425
+ for (const sp of all) {
2426
+ if (sp.status !== "active") continue;
2427
+ const tasks2 = await taskService.listTasks(sp.id);
2428
+ activeSprints.push({
2429
+ sprintSlug: sp.slug,
2430
+ sprint: sp,
2431
+ tasks: tasks2,
2432
+ taskCounts: countByTaskStatus(tasks2)
2433
+ });
2434
+ }
2435
+ return {
2436
+ content: [
2437
+ {
2438
+ type: "text",
2439
+ text: formatExecutionContext(plan.slug, activeSprints)
2440
+ }
2441
+ ],
2442
+ structuredContent: jsonStructured2({
2443
+ planSlug: plan.slug,
2444
+ planTitle: plan.title,
2445
+ activeSprints
2446
+ })
2447
+ };
2448
+ } catch (e) {
2449
+ return toMcpToolError(e);
2450
+ }
2451
+ }
2452
+ );
2453
+ }
2454
+ };
2455
+
2456
+ // src/mcp/plugins/plan-tools.plugin.ts
2457
+ import { z as z4 } from "zod";
2458
+
2459
+ // src/mcp/tool-guard.ts
2460
+ async function guardToolExecution(authContextResolver, requestCorrelation, rateLimiter, toolName) {
2461
+ const authContext = await authContextResolver.resolve("stdio");
2462
+ const correlationId = requestCorrelation.create();
2463
+ const rateLimit = rateLimiter.check(`${authContext.principalId}:${toolName}`);
2464
+ if (!rateLimit.allowed) {
2465
+ throw new Error(
2466
+ `Rate limit exceeded for tool '${toolName}'. Retry after ${rateLimit.retryAfterSeconds}s.`
2467
+ );
2468
+ }
2469
+ return {
2470
+ principalId: authContext.principalId,
2471
+ correlationId
2472
+ };
2473
+ }
2474
+ function writeToolAudit(auditLog, action, principalId, correlationId, resourceId, metadata) {
2475
+ auditLog.write({
2476
+ action,
2477
+ principalId,
2478
+ transport: "stdio",
2479
+ resourceId,
2480
+ correlationId,
2481
+ metadata
2482
+ });
2483
+ }
2484
+
2485
+ // src/mcp/plugins/plan-tools.plugin.ts
2486
+ var planToolsPlugin = {
2487
+ id: "sprintdock/plan-tools",
2488
+ register(server, ctx) {
2489
+ const { deps, kit } = ctx;
2490
+ const {
2491
+ jsonStructured: jsonStructured2,
2492
+ applyPagination: applyPagination2,
2493
+ paginationNote: paginationNote2,
2494
+ optionalPlanSlug: optionalPlanSlug2,
2495
+ paginationSchema: paginationSchema2
2496
+ } = kit;
2497
+ const { planService, sprintService, taskService } = deps.services;
2498
+ server.registerTool(
2499
+ "list_plans",
2500
+ {
2501
+ description: "Lists every plan in the SQLite database with slug, title, status, and which plan is active. Use this first to discover planSlug values. Optional limit/offset paginate results; omit both to return the full list.",
2502
+ inputSchema: z4.object({ ...paginationSchema2 }).strict()
2503
+ },
2504
+ async (input) => {
2505
+ try {
2506
+ const plansAll = await planService.listPlans();
2507
+ const { page, total } = applyPagination2(
2508
+ plansAll,
2509
+ input.limit,
2510
+ input.offset
2511
+ );
2512
+ let activePlanId = null;
2513
+ try {
2514
+ activePlanId = (await planService.getActivePlan()).id;
2515
+ } catch {
2516
+ activePlanId = null;
2517
+ }
2518
+ const text4 = formatPlanList(page, activePlanId) + paginationNote2(total, input.limit, input.offset);
2519
+ return {
2520
+ content: [{ type: "text", text: text4 }],
2521
+ structuredContent: jsonStructured2({ plans: page, total })
2522
+ };
2523
+ } catch (e) {
2524
+ return toMcpToolError(e);
2525
+ }
2526
+ }
2527
+ );
2528
+ server.registerTool(
2529
+ "create_plan",
2530
+ {
2531
+ description: "Creates a new plan with a kebab-case slug. The first plan in an empty database becomes the active plan automatically. Returns the new plan record.",
2532
+ inputSchema: z4.object({
2533
+ planSlug: z4.string().min(1),
2534
+ title: z4.string().min(1).optional(),
2535
+ initialBody: z4.string().optional()
2536
+ }).strict()
2537
+ },
2538
+ async (input) => {
2539
+ try {
2540
+ const guard = await guardToolExecution(
2541
+ deps.authContextResolver,
2542
+ deps.requestCorrelation,
2543
+ deps.rateLimiter,
2544
+ "create_plan"
2545
+ );
2546
+ const plan = await planService.createPlan({
2547
+ slug: input.planSlug,
2548
+ title: input.title ?? input.planSlug,
2549
+ markdownContent: input.initialBody ?? null
2550
+ });
2551
+ writeToolAudit(
2552
+ deps.auditLog,
2553
+ "create_plan",
2554
+ guard.principalId,
2555
+ guard.correlationId,
2556
+ plan.id
2557
+ );
2558
+ return {
2559
+ content: [
2560
+ {
2561
+ type: "text",
2562
+ text: `Created plan '${plan.slug}' (${plan.id}).`
2563
+ }
2564
+ ],
2565
+ structuredContent: jsonStructured2({ planSlug: plan.slug, plan })
2566
+ };
2567
+ } catch (e) {
2568
+ return toMcpToolError(e);
2569
+ }
2570
+ }
2571
+ );
2572
+ server.registerTool(
2573
+ "set_active_plan",
2574
+ {
2575
+ description: "Switches the active plan used when tools omit planSlug. Pass the plan slug or UUID. Only one plan can be active at a time.",
2576
+ inputSchema: z4.object({ planSlug: z4.string().min(1) }).strict()
2577
+ },
2578
+ async (input) => {
2579
+ try {
2580
+ const guard = await guardToolExecution(
2581
+ deps.authContextResolver,
2582
+ deps.requestCorrelation,
2583
+ deps.rateLimiter,
2584
+ "set_active_plan"
2585
+ );
2586
+ await planService.setActivePlan(input.planSlug);
2587
+ writeToolAudit(
2588
+ deps.auditLog,
2589
+ "set_active_plan",
2590
+ guard.principalId,
2591
+ guard.correlationId,
2592
+ input.planSlug
2593
+ );
2594
+ return {
2595
+ content: [
2596
+ { type: "text", text: `Active plan set to '${input.planSlug}'.` }
2597
+ ],
2598
+ structuredContent: jsonStructured2({ planSlug: input.planSlug })
2599
+ };
2600
+ } catch (e) {
2601
+ return toMcpToolError(e);
2602
+ }
2603
+ }
2604
+ );
2605
+ server.registerTool(
2606
+ "get_active_plan",
2607
+ {
2608
+ description: "Returns the active plan (or the plan matching optional planSlug) with title, id, and the on-disk plan markdown path.",
2609
+ inputSchema: z4.object({ planSlug: optionalPlanSlug2 }).strict()
2610
+ },
2611
+ async (input) => {
2612
+ try {
2613
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2614
+ return {
2615
+ content: [
2616
+ {
2617
+ type: "text",
2618
+ text: `Plan '${plan.slug}' (${plan.title}).`
2619
+ }
2620
+ ],
2621
+ structuredContent: jsonStructured2({
2622
+ planSlug: plan.slug,
2623
+ title: plan.title,
2624
+ planId: plan.id,
2625
+ planMarkdownPath: `.sprintdock/plans/${plan.slug}/plan.md`
2626
+ })
2627
+ };
2628
+ } catch (e) {
2629
+ return toMcpToolError(e);
2630
+ }
2631
+ }
2632
+ );
2633
+ server.registerTool(
2634
+ "get_plan_summary",
2635
+ {
2636
+ description: "Returns plan title, updated time, and every sprint with slug, status, id, and task counts by status (todo, in_progress, blocked, done). Use for dashboards before drilling into sprints.",
2637
+ inputSchema: z4.object({ planSlug: optionalPlanSlug2 }).strict()
2638
+ },
2639
+ async (input) => {
2640
+ try {
2641
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2642
+ const sprints2 = await sprintService.listSprints(plan.id);
2643
+ const sprintsOut = [];
2644
+ for (const sp of sprints2) {
2645
+ const tasks2 = await taskService.listTasks(sp.id);
2646
+ sprintsOut.push({
2647
+ sprintSlug: sp.slug,
2648
+ sprintId: sp.id,
2649
+ name: sp.name,
2650
+ status: sp.status,
2651
+ taskCounts: countByTaskStatus(tasks2)
2652
+ });
2653
+ }
2654
+ return {
2655
+ content: [
2656
+ {
2657
+ type: "text",
2658
+ text: formatPlanSummary(plan, sprintsOut)
2659
+ }
2660
+ ],
2661
+ structuredContent: jsonStructured2({
2662
+ planSlug: plan.slug,
2663
+ title: plan.title,
2664
+ updatedAt: plan.updatedAt,
2665
+ sprints: sprintsOut
2666
+ })
2667
+ };
2668
+ } catch (e) {
2669
+ return toMcpToolError(e);
2670
+ }
2671
+ }
2672
+ );
2673
+ server.registerTool(
2674
+ "update_plan_markdown",
2675
+ {
2676
+ description: "Overwrites the plan body markdown stored in SQLite for the resolved plan. Requires auth/rate-limit guard; use for syncing plan docs from agents.",
2677
+ inputSchema: z4.object({
2678
+ content: z4.string(),
2679
+ planSlug: optionalPlanSlug2
2680
+ }).strict()
2681
+ },
2682
+ async (input) => {
2683
+ try {
2684
+ const guard = await guardToolExecution(
2685
+ deps.authContextResolver,
2686
+ deps.requestCorrelation,
2687
+ deps.rateLimiter,
2688
+ "update_plan_markdown"
2689
+ );
2690
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2691
+ const updated = await planService.updatePlan(plan.id, {
2692
+ markdownContent: input.content
2693
+ });
2694
+ writeToolAudit(
2695
+ deps.auditLog,
2696
+ "update_plan_markdown",
2697
+ guard.principalId,
2698
+ guard.correlationId,
2699
+ updated.id
2700
+ );
2701
+ return {
2702
+ content: [{ type: "text", text: "Plan markdown updated." }],
2703
+ structuredContent: jsonStructured2({ ok: true, plan: updated })
2704
+ };
2705
+ } catch (e) {
2706
+ return toMcpToolError(e);
2707
+ }
2708
+ }
2709
+ );
2710
+ server.registerTool(
2711
+ "delete_plan",
2712
+ {
2713
+ description: "Deletes a plan and cascades all sprints and tasks. Cannot delete the currently active plan; switch active plan first. Guarded mutation.",
2714
+ inputSchema: z4.object({ planSlug: z4.string().min(1) }).strict()
2715
+ },
2716
+ async (input) => {
2717
+ try {
2718
+ const guard = await guardToolExecution(
2719
+ deps.authContextResolver,
2720
+ deps.requestCorrelation,
2721
+ deps.rateLimiter,
2722
+ "delete_plan"
2723
+ );
2724
+ const plan = await planService.getPlan(input.planSlug);
2725
+ let active;
2726
+ try {
2727
+ active = await planService.getActivePlan();
2728
+ } catch {
2729
+ active = { id: "" };
2730
+ }
2731
+ if (active.id === plan.id) {
2732
+ throw new ValidationError(
2733
+ "Cannot delete the active plan; set another plan active first."
2734
+ );
2735
+ }
2736
+ await planService.deletePlan(plan.id);
2737
+ writeToolAudit(
2738
+ deps.auditLog,
2739
+ "delete_plan",
2740
+ guard.principalId,
2741
+ guard.correlationId,
2742
+ plan.id,
2743
+ { slug: plan.slug }
2744
+ );
2745
+ return {
2746
+ content: [
2747
+ {
2748
+ type: "text",
2749
+ text: `Plan '${plan.slug}' ("${plan.title}") deleted.`
2750
+ }
2751
+ ],
2752
+ structuredContent: jsonStructured2({
2753
+ deleted: true,
2754
+ planSlug: plan.slug
2755
+ })
2756
+ };
2757
+ } catch (e) {
2758
+ return toMcpToolError(e);
2759
+ }
2760
+ }
2761
+ );
2762
+ server.registerTool(
2763
+ "update_plan",
2764
+ {
2765
+ description: "Updates plan title, description, or status with domain validation on status transitions. Resolves the plan from optional planSlug or the active plan; at least one field must be provided.",
2766
+ inputSchema: z4.object({
2767
+ planSlug: optionalPlanSlug2,
2768
+ title: z4.string().min(1).optional(),
2769
+ description: z4.string().nullable().optional(),
2770
+ status: z4.enum(["draft", "active", "completed", "archived"]).optional()
2771
+ }).strict().refine(
2772
+ (v) => v.title !== void 0 || v.description !== void 0 || v.status !== void 0,
2773
+ {
2774
+ message: "Provide at least one of title, description, or status."
2775
+ }
2776
+ )
2777
+ },
2778
+ async (input) => {
2779
+ try {
2780
+ const guard = await guardToolExecution(
2781
+ deps.authContextResolver,
2782
+ deps.requestCorrelation,
2783
+ deps.rateLimiter,
2784
+ "update_plan"
2785
+ );
2786
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2787
+ const updated = await planService.updatePlan(plan.id, {
2788
+ ...input.title !== void 0 ? { title: input.title } : {},
2789
+ ...input.description !== void 0 ? { description: input.description } : {},
2790
+ ...input.status !== void 0 ? { status: input.status } : {}
2791
+ });
2792
+ writeToolAudit(
2793
+ deps.auditLog,
2794
+ "update_plan",
2795
+ guard.principalId,
2796
+ guard.correlationId,
2797
+ updated.id
2798
+ );
2799
+ return {
2800
+ content: [
2801
+ {
2802
+ type: "text",
2803
+ text: `Plan '${updated.slug}' ("${updated.title}") updated [${updated.status}].`
2804
+ }
2805
+ ],
2806
+ structuredContent: jsonStructured2({ plan: updated })
2807
+ };
2808
+ } catch (e) {
2809
+ return toMcpToolError(e);
2810
+ }
2811
+ }
2812
+ );
2813
+ server.registerTool(
2814
+ "get_plan_progress",
2815
+ {
2816
+ description: "Computes done vs total tasks for the plan and each sprint, with completion percentages. Use for burndown-style snapshots.",
2817
+ inputSchema: z4.object({ planSlug: optionalPlanSlug2 }).strict()
2818
+ },
2819
+ async (input) => {
2820
+ try {
2821
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2822
+ const sprints2 = await sprintService.listSprints(plan.id);
2823
+ let doneAll = 0;
2824
+ let totalAll = 0;
2825
+ const rows = [];
2826
+ for (const sp of sprints2) {
2827
+ const tasks2 = await taskService.listTasks(sp.id);
2828
+ const done = tasks2.filter((t) => t.status === "done").length;
2829
+ const total = tasks2.length;
2830
+ doneAll += done;
2831
+ totalAll += total;
2832
+ rows.push({
2833
+ slug: sp.slug,
2834
+ name: sp.name,
2835
+ status: sp.status,
2836
+ done,
2837
+ total
2838
+ });
2839
+ }
2840
+ const text4 = formatPlanProgress(plan.slug, doneAll, totalAll, rows);
2841
+ return {
2842
+ content: [{ type: "text", text: text4 }],
2843
+ structuredContent: jsonStructured2({
2844
+ planSlug: plan.slug,
2845
+ done: doneAll,
2846
+ total: totalAll,
2847
+ sprints: rows
2848
+ })
2849
+ };
2850
+ } catch (e) {
2851
+ return toMcpToolError(e);
2852
+ }
2853
+ }
2854
+ );
2855
+ }
2856
+ };
2857
+
2858
+ // src/mcp/plugins/sprint-tools.plugin.ts
2859
+ import { z as z5 } from "zod";
2860
+ var sprintToolsPlugin = {
2861
+ id: "sprintdock/sprint-tools",
2862
+ register(server, ctx) {
2863
+ const { deps, kit } = ctx;
2864
+ const {
2865
+ jsonStructured: jsonStructured2,
2866
+ applyPagination: applyPagination2,
2867
+ paginationNote: paginationNote2,
2868
+ optionalPlanSlug: optionalPlanSlug2,
2869
+ paginationSchema: paginationSchema2
2870
+ } = kit;
2871
+ const { planService, sprintService, taskService } = deps.services;
2872
+ server.registerTool(
2873
+ "create_sprint",
2874
+ {
2875
+ description: "Creates a sprint under the active or named plan with slug, name, goal, and optional start/end dates. New sprints start in planned status.",
2876
+ inputSchema: z5.object({
2877
+ sprintSlug: z5.string().min(1),
2878
+ name: z5.string().min(1),
2879
+ goal: z5.string().min(1),
2880
+ markdownContent: z5.string().nullable().optional(),
2881
+ startDate: z5.string().datetime().nullable().optional(),
2882
+ endDate: z5.string().datetime().nullable().optional(),
2883
+ planSlug: optionalPlanSlug2
2884
+ }).strict()
2885
+ },
2886
+ async (input) => {
2887
+ try {
2888
+ const guard = await guardToolExecution(
2889
+ deps.authContextResolver,
2890
+ deps.requestCorrelation,
2891
+ deps.rateLimiter,
2892
+ "create_sprint"
2893
+ );
2894
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2895
+ const sprint = await sprintService.createSprint(plan.slug, {
2896
+ slug: input.sprintSlug,
2897
+ planId: plan.id,
2898
+ name: input.name,
2899
+ goal: input.goal,
2900
+ markdownContent: input.markdownContent ?? null,
2901
+ startDate: input.startDate ?? null,
2902
+ endDate: input.endDate ?? null
2903
+ });
2904
+ writeToolAudit(
2905
+ deps.auditLog,
2906
+ "create_sprint",
2907
+ guard.principalId,
2908
+ guard.correlationId,
2909
+ sprint.id
2910
+ );
2911
+ return {
2912
+ content: [
2913
+ {
2914
+ type: "text",
2915
+ text: `Created sprint '${sprint.name}' (${sprint.slug}, id: ${shortId(sprint.id)}).`
2916
+ }
2917
+ ],
2918
+ structuredContent: jsonStructured2(sprint)
2919
+ };
2920
+ } catch (e) {
2921
+ return toMcpToolError(e);
2922
+ }
2923
+ }
2924
+ );
2925
+ server.registerTool(
2926
+ "list_sprints",
2927
+ {
2928
+ description: "Lists sprints for the resolved plan with slug, status, name, and live task counts by status. Optional limit/offset paginate; omit both for the full list.",
2929
+ inputSchema: z5.object({ planSlug: optionalPlanSlug2, ...paginationSchema2 }).strict()
2930
+ },
2931
+ async (input) => {
2932
+ try {
2933
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2934
+ const sprints2 = await sprintService.listSprints(plan.id);
2935
+ const withCounts = [];
2936
+ for (const s of sprints2) {
2937
+ const tasks2 = await taskService.listTasks(s.id);
2938
+ withCounts.push({
2939
+ ...s,
2940
+ sprintSlug: s.slug,
2941
+ taskCounts: countByTaskStatus(tasks2)
2942
+ });
2943
+ }
2944
+ const { page, total } = applyPagination2(
2945
+ withCounts,
2946
+ input.limit,
2947
+ input.offset
2948
+ );
2949
+ const text4 = formatSprintList(plan.slug, page) + paginationNote2(total, input.limit, input.offset) + "\nPass each line's slug or `sprintId:<uuid>` as create_task / bulk_create_tasks **sprintIdOrSlug** (with the same planSlug). Full UUIDs are in structuredContent.sprints[].id.";
2950
+ return {
2951
+ content: [{ type: "text", text: text4 }],
2952
+ structuredContent: jsonStructured2({ sprints: page, total })
2953
+ };
2954
+ } catch (e) {
2955
+ return toMcpToolError(e);
2956
+ }
2957
+ }
2958
+ );
2959
+ server.registerTool(
2960
+ "get_sprint",
2961
+ {
2962
+ description: "Loads sprint metadata (name, goal, dates, status) by UUID or sprint slug under the resolved plan. Does not include tasks; use get_sprint_detail for tasks.",
2963
+ inputSchema: z5.object({
2964
+ sprintIdOrSlug: z5.string().min(1),
2965
+ planSlug: optionalPlanSlug2
2966
+ }).strict()
2967
+ },
2968
+ async (input) => {
2969
+ try {
2970
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
2971
+ const sprint = await getSprintForPlan(
2972
+ sprintService,
2973
+ plan,
2974
+ input.sprintIdOrSlug
2975
+ );
2976
+ return {
2977
+ content: [
2978
+ {
2979
+ type: "text",
2980
+ text: `Sprint '${sprint.name}' (${sprint.slug}) is in status '${sprint.status}' (id: ${shortId(sprint.id)}).`
2981
+ }
2982
+ ],
2983
+ structuredContent: jsonStructured2({
2984
+ ...sprint,
2985
+ sprintSlug: sprint.slug
2986
+ })
2987
+ };
2988
+ } catch (e) {
2989
+ return toMcpToolError(e);
2990
+ }
2991
+ }
2992
+ );
2993
+ server.registerTool(
2994
+ "update_sprint_status",
2995
+ {
2996
+ description: "Moves a sprint through its lifecycle (planned, active, completed, archived) with domain validation. Accepts sprint UUID or slug scoped to the plan.",
2997
+ inputSchema: z5.object({
2998
+ sprintIdOrSlug: z5.string().min(1),
2999
+ status: z5.enum(["planned", "active", "completed", "archived"]),
3000
+ planSlug: optionalPlanSlug2
3001
+ }).strict()
3002
+ },
3003
+ async (input) => {
3004
+ try {
3005
+ const guard = await guardToolExecution(
3006
+ deps.authContextResolver,
3007
+ deps.requestCorrelation,
3008
+ deps.rateLimiter,
3009
+ "update_sprint_status"
3010
+ );
3011
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
3012
+ const resolved = await getSprintForPlan(
3013
+ sprintService,
3014
+ plan,
3015
+ input.sprintIdOrSlug
3016
+ );
3017
+ const sprint = await sprintService.updateSprintStatus(
3018
+ resolved.id,
3019
+ input.status
3020
+ );
3021
+ writeToolAudit(
3022
+ deps.auditLog,
3023
+ "update_sprint_status",
3024
+ guard.principalId,
3025
+ guard.correlationId,
3026
+ sprint.id,
3027
+ { status: sprint.status }
3028
+ );
3029
+ return {
3030
+ content: [
3031
+ {
3032
+ type: "text",
3033
+ text: `Sprint '${sprint.name}' (${sprint.slug}) updated to '${sprint.status}'.`
3034
+ }
3035
+ ],
3036
+ structuredContent: jsonStructured2(sprint)
3037
+ };
3038
+ } catch (e) {
3039
+ return toMcpToolError(e);
3040
+ }
3041
+ }
3042
+ );
3043
+ server.registerTool(
3044
+ "delete_sprint",
3045
+ {
3046
+ description: "Deletes a sprint and all of its tasks (cascade). Accepts sprint UUID or slug under the resolved plan. Guarded mutation.",
3047
+ inputSchema: z5.object({
3048
+ sprintIdOrSlug: z5.string().min(1),
3049
+ planSlug: optionalPlanSlug2
3050
+ }).strict()
3051
+ },
3052
+ async (input) => {
3053
+ try {
3054
+ const guard = await guardToolExecution(
3055
+ deps.authContextResolver,
3056
+ deps.requestCorrelation,
3057
+ deps.rateLimiter,
3058
+ "delete_sprint"
3059
+ );
3060
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
3061
+ const sprint = await getSprintForPlan(
3062
+ sprintService,
3063
+ plan,
3064
+ input.sprintIdOrSlug
3065
+ );
3066
+ await sprintService.deleteSprint(sprint.id);
3067
+ writeToolAudit(
3068
+ deps.auditLog,
3069
+ "delete_sprint",
3070
+ guard.principalId,
3071
+ guard.correlationId,
3072
+ sprint.id,
3073
+ { slug: sprint.slug }
3074
+ );
3075
+ return {
3076
+ content: [
3077
+ {
3078
+ type: "text",
3079
+ text: `Sprint '${sprint.name}' (${sprint.slug}, id: ${shortId(sprint.id)}) deleted.`
3080
+ }
3081
+ ],
3082
+ structuredContent: jsonStructured2({
3083
+ deleted: true,
3084
+ sprintId: sprint.id
3085
+ })
3086
+ };
3087
+ } catch (e) {
3088
+ return toMcpToolError(e);
3089
+ }
3090
+ }
3091
+ );
3092
+ server.registerTool(
3093
+ "update_sprint",
3094
+ {
3095
+ description: "Updates sprint name, goal, start/end dates, or order without changing lifecycle status (use update_sprint_status for status). Accepts sprint UUID or slug. Guarded mutation.",
3096
+ inputSchema: z5.object({
3097
+ sprintIdOrSlug: z5.string().min(1),
3098
+ name: z5.string().min(1).optional(),
3099
+ goal: z5.string().min(1).optional(),
3100
+ markdownContent: z5.string().nullable().optional(),
3101
+ startDate: z5.string().datetime().nullable().optional(),
3102
+ endDate: z5.string().datetime().nullable().optional(),
3103
+ order: z5.number().int().optional(),
3104
+ planSlug: optionalPlanSlug2
3105
+ }).strict().refine(
3106
+ (v) => v.name !== void 0 || v.goal !== void 0 || v.markdownContent !== void 0 || v.startDate !== void 0 || v.endDate !== void 0 || v.order !== void 0,
3107
+ { message: "Provide at least one field to update." }
3108
+ )
3109
+ },
3110
+ async (input) => {
3111
+ try {
3112
+ const guard = await guardToolExecution(
3113
+ deps.authContextResolver,
3114
+ deps.requestCorrelation,
3115
+ deps.rateLimiter,
3116
+ "update_sprint"
3117
+ );
3118
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
3119
+ const sprint = await getSprintForPlan(
3120
+ sprintService,
3121
+ plan,
3122
+ input.sprintIdOrSlug
3123
+ );
3124
+ const updated = await sprintService.updateSprint(sprint.id, {
3125
+ ...input.name !== void 0 ? { name: input.name } : {},
3126
+ ...input.goal !== void 0 ? { goal: input.goal } : {},
3127
+ ...input.markdownContent !== void 0 ? { markdownContent: input.markdownContent } : {},
3128
+ ...input.startDate !== void 0 ? { startDate: input.startDate } : {},
3129
+ ...input.endDate !== void 0 ? { endDate: input.endDate } : {},
3130
+ ...input.order !== void 0 ? { order: input.order } : {}
3131
+ });
3132
+ writeToolAudit(
3133
+ deps.auditLog,
3134
+ "update_sprint",
3135
+ guard.principalId,
3136
+ guard.correlationId,
3137
+ updated.id
3138
+ );
3139
+ return {
3140
+ content: [
3141
+ {
3142
+ type: "text",
3143
+ text: `Sprint '${updated.name}' (${updated.slug}) updated. (id: ${shortId(updated.id)})`
3144
+ }
3145
+ ],
3146
+ structuredContent: jsonStructured2(updated)
3147
+ };
3148
+ } catch (e) {
3149
+ return toMcpToolError(e);
3150
+ }
3151
+ }
3152
+ );
3153
+ server.registerTool(
3154
+ "update_sprint_markdown",
3155
+ {
3156
+ description: "Overwrites sprint markdown notes (GFM) in SQLite for a sprint under the active or named plan. Use list_sprints / get_sprint_detail to resolve slugs. Guarded mutation.",
3157
+ inputSchema: z5.object({
3158
+ sprintIdOrSlug: z5.string().min(1),
3159
+ content: z5.string(),
3160
+ planSlug: optionalPlanSlug2
3161
+ }).strict()
3162
+ },
3163
+ async (input) => {
3164
+ try {
3165
+ const guard = await guardToolExecution(
3166
+ deps.authContextResolver,
3167
+ deps.requestCorrelation,
3168
+ deps.rateLimiter,
3169
+ "update_sprint_markdown"
3170
+ );
3171
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
3172
+ const sprint = await getSprintForPlan(
3173
+ sprintService,
3174
+ plan,
3175
+ input.sprintIdOrSlug
3176
+ );
3177
+ const updated = await sprintService.updateSprint(sprint.id, {
3178
+ markdownContent: input.content
3179
+ });
3180
+ writeToolAudit(
3181
+ deps.auditLog,
3182
+ "update_sprint_markdown",
3183
+ guard.principalId,
3184
+ guard.correlationId,
3185
+ updated.id
3186
+ );
3187
+ return {
3188
+ content: [{ type: "text", text: "Sprint markdown updated." }],
3189
+ structuredContent: jsonStructured2({ ok: true, sprint: updated })
3190
+ };
3191
+ } catch (e) {
3192
+ return toMcpToolError(e);
3193
+ }
3194
+ }
3195
+ );
3196
+ }
3197
+ };
3198
+
3199
+ // src/mcp/plugins/task-tools.plugin.ts
3200
+ import { z as z6 } from "zod";
3201
+ var TOUCHED_FILE_TYPE = z6.enum([
3202
+ "test",
3203
+ "implementation",
3204
+ "doc",
3205
+ "config",
3206
+ "other"
3207
+ ]);
3208
+ var touchedFilesSchema = z6.array(
3209
+ z6.object({
3210
+ path: z6.string().min(1).max(2048),
3211
+ fileType: TOUCHED_FILE_TYPE
3212
+ })
3213
+ ).optional();
3214
+ var taskToolsPlugin = {
3215
+ id: "sprintdock/task-tools",
3216
+ register(server, ctx) {
3217
+ const { deps, kit } = ctx;
3218
+ const {
3219
+ jsonStructured: jsonStructured2,
3220
+ applyPagination: applyPagination2,
3221
+ paginationNote: paginationNote2,
3222
+ optionalPlanSlug: optionalPlanSlug2,
3223
+ paginationSchema: paginationSchema2,
3224
+ TASK_STATUS_SCHEMA: TASK_STATUS_SCHEMA2,
3225
+ TASK_PRIORITY_SCHEMA: TASK_PRIORITY_SCHEMA2
3226
+ } = kit;
3227
+ const { planService, sprintService, taskService } = deps.services;
3228
+ server.registerTool(
3229
+ "create_task",
3230
+ {
3231
+ description: "Creates a task in a sprint. Pass sprintIdOrSlug: full sprint UUID or sprint slug under the resolved plan (same pattern as get_sprint). Do not read SQLite directly. Order defaults to the end of the sprint. Optional dependsOnTaskIds must reference tasks in the same plan. Optional touchedFiles lists repo paths with file types (test, implementation, doc, config, other). Guarded mutation.",
3232
+ inputSchema: z6.object({
3233
+ title: z6.string().min(1),
3234
+ description: z6.string().default(""),
3235
+ priority: TASK_PRIORITY_SCHEMA2.default("medium"),
3236
+ sprintIdOrSlug: z6.string().min(1),
3237
+ assignee: z6.string().nullable().optional(),
3238
+ tags: z6.array(z6.string()).default([]),
3239
+ order: z6.number().int().optional(),
3240
+ dependsOnTaskIds: z6.array(z6.string()).optional(),
3241
+ touchedFiles: touchedFilesSchema,
3242
+ planSlug: optionalPlanSlug2
3243
+ }).strict()
3244
+ },
3245
+ async (input) => {
3246
+ try {
3247
+ const guard = await guardToolExecution(
3248
+ deps.authContextResolver,
3249
+ deps.requestCorrelation,
3250
+ deps.rateLimiter,
3251
+ "create_task"
3252
+ );
3253
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
3254
+ const sprint = await getSprintForPlan(
3255
+ sprintService,
3256
+ plan,
3257
+ input.sprintIdOrSlug
3258
+ );
3259
+ const task = await taskService.createTask(
3260
+ {
3261
+ title: input.title,
3262
+ description: input.description || null,
3263
+ priority: input.priority ?? "medium",
3264
+ sprintId: sprint.id,
3265
+ assignee: input.assignee ?? null,
3266
+ tags: input.tags ?? [],
3267
+ order: input.order,
3268
+ dependsOnTaskIds: input.dependsOnTaskIds,
3269
+ touchedFiles: input.touchedFiles
3270
+ },
3271
+ input.planSlug
3272
+ );
3273
+ writeToolAudit(
3274
+ deps.auditLog,
3275
+ "create_task",
3276
+ guard.principalId,
3277
+ guard.correlationId,
3278
+ task.id
3279
+ );
3280
+ return {
3281
+ content: [
3282
+ {
3283
+ type: "text",
3284
+ text: `Created task '${task.title}' (id: ${shortId(task.id)}).`
3285
+ }
3286
+ ],
3287
+ structuredContent: jsonStructured2(task)
3288
+ };
3289
+ } catch (e) {
3290
+ return toMcpToolError(e);
3291
+ }
3292
+ }
3293
+ );
3294
+ server.registerTool(
3295
+ "list_tasks",
3296
+ {
3297
+ description: "Lists tasks for an optional sprint UUID or across the whole plan when sprintId is omitted, with optional filters (status, priority, assignee). Optional limit/offset paginate; omit both for the full filtered list.",
3298
+ inputSchema: z6.object({
3299
+ sprintId: z6.string().optional(),
3300
+ status: TASK_STATUS_SCHEMA2.optional(),
3301
+ priority: TASK_PRIORITY_SCHEMA2.optional(),
3302
+ assignee: z6.string().optional(),
3303
+ planSlug: optionalPlanSlug2,
3304
+ ...paginationSchema2
3305
+ }).strict()
3306
+ },
3307
+ async (input) => {
3308
+ try {
3309
+ const filter = input.status || input.priority || input.assignee !== void 0 ? {
3310
+ ...input.status ? { status: input.status } : {},
3311
+ ...input.priority ? { priority: input.priority } : {},
3312
+ ...input.assignee !== void 0 ? { assignee: input.assignee } : {}
3313
+ } : void 0;
3314
+ const tasksAll = await taskService.listTasks(
3315
+ input.sprintId,
3316
+ input.planSlug,
3317
+ filter
3318
+ );
3319
+ const { page, total } = applyPagination2(
3320
+ tasksAll,
3321
+ input.limit,
3322
+ input.offset
3323
+ );
3324
+ const text4 = formatTaskList(page) + paginationNote2(total, input.limit, input.offset);
3325
+ return {
3326
+ content: [{ type: "text", text: text4 }],
3327
+ structuredContent: jsonStructured2({ tasks: page, total })
3328
+ };
3329
+ } catch (e) {
3330
+ return toMcpToolError(e);
3331
+ }
3332
+ }
3333
+ );
3334
+ server.registerTool(
3335
+ "get_task",
3336
+ {
3337
+ description: "Returns one task by UUID with title, status, priority, assignee, tags, order, and sprint. When planSlug is set, verifies the task belongs to that plan.",
3338
+ inputSchema: z6.object({
3339
+ taskId: z6.string().min(1),
3340
+ planSlug: optionalPlanSlug2
3341
+ }).strict()
3342
+ },
3343
+ async (input) => {
3344
+ try {
3345
+ if (input.planSlug !== void 0 && input.planSlug !== "") {
3346
+ const plan = await planService.resolvePlan(input.planSlug);
3347
+ await ensureTaskInPlan(
3348
+ taskService,
3349
+ sprintService,
3350
+ plan,
3351
+ input.taskId
3352
+ );
3353
+ }
3354
+ const task = await taskService.getTask(input.taskId);
3355
+ return {
3356
+ content: [
3357
+ {
3358
+ type: "text",
3359
+ text: `Task '${task.title}' currently has status '${task.status}'.`
3360
+ }
3361
+ ],
3362
+ structuredContent: jsonStructured2(task)
3363
+ };
3364
+ } catch (e) {
3365
+ return toMcpToolError(e);
3366
+ }
3367
+ }
3368
+ );
3369
+ server.registerTool(
3370
+ "update_task_status",
3371
+ {
3372
+ description: "Updates a task status with domain transition rules (e.g. done cannot jump back arbitrarily). Guarded mutation; returns the updated task.",
3373
+ inputSchema: z6.object({
3374
+ taskId: z6.string().min(1),
3375
+ status: TASK_STATUS_SCHEMA2,
3376
+ planSlug: optionalPlanSlug2
3377
+ }).strict()
3378
+ },
3379
+ async (input) => {
3380
+ try {
3381
+ const guard = await guardToolExecution(
3382
+ deps.authContextResolver,
3383
+ deps.requestCorrelation,
3384
+ deps.rateLimiter,
3385
+ "update_task_status"
3386
+ );
3387
+ if (input.planSlug !== void 0 && input.planSlug !== "") {
3388
+ const plan = await planService.resolvePlan(input.planSlug);
3389
+ await ensureTaskInPlan(
3390
+ taskService,
3391
+ sprintService,
3392
+ plan,
3393
+ input.taskId
3394
+ );
3395
+ }
3396
+ const task = await taskService.updateTaskStatus(
3397
+ input.taskId,
3398
+ input.status
3399
+ );
3400
+ writeToolAudit(
3401
+ deps.auditLog,
3402
+ "update_task_status",
3403
+ guard.principalId,
3404
+ guard.correlationId,
3405
+ task.id,
3406
+ { status: task.status }
3407
+ );
3408
+ return {
3409
+ content: [
3410
+ {
3411
+ type: "text",
3412
+ text: `Task '${task.title}' updated to '${task.status}'. (id: ${shortId(task.id)})`
3413
+ }
3414
+ ],
3415
+ structuredContent: jsonStructured2(task)
3416
+ };
3417
+ } catch (e) {
3418
+ return toMcpToolError(e);
3419
+ }
3420
+ }
3421
+ );
3422
+ server.registerTool(
3423
+ "assign_task",
3424
+ {
3425
+ description: "Sets or clears the assignee string on a task (null clears). Guarded mutation; scoped with planSlug when provided.",
3426
+ inputSchema: z6.object({
3427
+ taskId: z6.string().min(1),
3428
+ assignee: z6.string().nullable(),
3429
+ planSlug: optionalPlanSlug2
3430
+ }).strict()
3431
+ },
3432
+ async (input) => {
3433
+ try {
3434
+ const guard = await guardToolExecution(
3435
+ deps.authContextResolver,
3436
+ deps.requestCorrelation,
3437
+ deps.rateLimiter,
3438
+ "assign_task"
3439
+ );
3440
+ if (input.planSlug !== void 0 && input.planSlug !== "") {
3441
+ const plan = await planService.resolvePlan(input.planSlug);
3442
+ await ensureTaskInPlan(
3443
+ taskService,
3444
+ sprintService,
3445
+ plan,
3446
+ input.taskId
3447
+ );
3448
+ }
3449
+ const task = await taskService.assignTask(
3450
+ input.taskId,
3451
+ input.assignee
3452
+ );
3453
+ writeToolAudit(
3454
+ deps.auditLog,
3455
+ "assign_task",
3456
+ guard.principalId,
3457
+ guard.correlationId,
3458
+ task.id,
3459
+ { assignee: task.assignee }
3460
+ );
3461
+ const who = task.assignee ?? "unassigned";
3462
+ return {
3463
+ content: [
3464
+ {
3465
+ type: "text",
3466
+ text: `Task '${task.title}' assigned to '${who}'. (id: ${shortId(task.id)})`
3467
+ }
3468
+ ],
3469
+ structuredContent: jsonStructured2(task)
3470
+ };
3471
+ } catch (e) {
3472
+ return toMcpToolError(e);
3473
+ }
3474
+ }
3475
+ );
3476
+ server.registerTool(
3477
+ "move_task",
3478
+ {
3479
+ description: "Moves a task to another sprint in the same plan (target sprint UUID). Guarded mutation; confirms the task and target sprint share a plan when planSlug is provided.",
3480
+ inputSchema: z6.object({
3481
+ taskId: z6.string().min(1),
3482
+ targetSprintId: z6.string().min(1),
3483
+ planSlug: optionalPlanSlug2
3484
+ }).strict()
3485
+ },
3486
+ async (input) => {
3487
+ try {
3488
+ const guard = await guardToolExecution(
3489
+ deps.authContextResolver,
3490
+ deps.requestCorrelation,
3491
+ deps.rateLimiter,
3492
+ "move_task"
3493
+ );
3494
+ if (input.planSlug !== void 0 && input.planSlug !== "") {
3495
+ const plan = await planService.resolvePlan(input.planSlug);
3496
+ await ensureTaskInPlan(
3497
+ taskService,
3498
+ sprintService,
3499
+ plan,
3500
+ input.taskId
3501
+ );
3502
+ await ensureSprintInPlan(
3503
+ sprintService,
3504
+ plan,
3505
+ input.targetSprintId
3506
+ );
3507
+ }
3508
+ const targetSprint = await sprintService.getSprint(
3509
+ input.targetSprintId
3510
+ );
3511
+ const task = await taskService.moveTask(
3512
+ input.taskId,
3513
+ input.targetSprintId
3514
+ );
3515
+ writeToolAudit(
3516
+ deps.auditLog,
3517
+ "move_task",
3518
+ guard.principalId,
3519
+ guard.correlationId,
3520
+ task.id,
3521
+ { targetSprintId: task.sprintId }
3522
+ );
3523
+ return {
3524
+ content: [
3525
+ {
3526
+ type: "text",
3527
+ text: `Task '${task.title}' moved to sprint '${targetSprint.name}' (${targetSprint.slug}). (id: ${shortId(task.id)})`
3528
+ }
3529
+ ],
3530
+ structuredContent: jsonStructured2(task)
3531
+ };
3532
+ } catch (e) {
3533
+ return toMcpToolError(e);
3534
+ }
3535
+ }
3536
+ );
3537
+ server.registerTool(
3538
+ "delete_task",
3539
+ {
3540
+ description: "Permanently deletes a task by UUID and removes its dependency edges. Guarded mutation. Use delete_sprint to remove a whole sprint. Returns confirmation with the task title and short id.",
3541
+ inputSchema: z6.object({
3542
+ taskId: z6.string().min(1),
3543
+ planSlug: optionalPlanSlug2
3544
+ }).strict()
3545
+ },
3546
+ async (input) => {
3547
+ try {
3548
+ const guard = await guardToolExecution(
3549
+ deps.authContextResolver,
3550
+ deps.requestCorrelation,
3551
+ deps.rateLimiter,
3552
+ "delete_task"
3553
+ );
3554
+ if (input.planSlug !== void 0 && input.planSlug !== "") {
3555
+ const plan = await planService.resolvePlan(input.planSlug);
3556
+ await ensureTaskInPlan(
3557
+ taskService,
3558
+ sprintService,
3559
+ plan,
3560
+ input.taskId
3561
+ );
3562
+ }
3563
+ const before = await taskService.getTask(input.taskId);
3564
+ await taskService.deleteTask(input.taskId);
3565
+ writeToolAudit(
3566
+ deps.auditLog,
3567
+ "delete_task",
3568
+ guard.principalId,
3569
+ guard.correlationId,
3570
+ input.taskId,
3571
+ { title: before.title }
3572
+ );
3573
+ return {
3574
+ content: [
3575
+ {
3576
+ type: "text",
3577
+ text: `Task '${before.title}' (${shortId(before.id)}) deleted.`
3578
+ }
3579
+ ],
3580
+ structuredContent: jsonStructured2({
3581
+ deleted: true,
3582
+ taskId: input.taskId
3583
+ })
3584
+ };
3585
+ } catch (e) {
3586
+ return toMcpToolError(e);
3587
+ }
3588
+ }
3589
+ );
3590
+ server.registerTool(
3591
+ "update_task",
3592
+ {
3593
+ description: "Updates task title, description, priority, tags, sort order, or touchedFiles (replaces all file rows when set). Does not change status (use update_task_status). Guarded mutation.",
3594
+ inputSchema: z6.object({
3595
+ taskId: z6.string().min(1),
3596
+ title: z6.string().min(1).optional(),
3597
+ description: z6.string().nullable().optional(),
3598
+ priority: TASK_PRIORITY_SCHEMA2.optional(),
3599
+ tags: z6.array(z6.string()).optional(),
3600
+ order: z6.number().int().optional(),
3601
+ touchedFiles: touchedFilesSchema,
3602
+ planSlug: optionalPlanSlug2
3603
+ }).strict().refine(
3604
+ (v) => v.title !== void 0 || v.description !== void 0 || v.priority !== void 0 || v.tags !== void 0 || v.order !== void 0 || v.touchedFiles !== void 0,
3605
+ { message: "Provide at least one field to update." }
3606
+ )
3607
+ },
3608
+ async (input) => {
3609
+ try {
3610
+ const guard = await guardToolExecution(
3611
+ deps.authContextResolver,
3612
+ deps.requestCorrelation,
3613
+ deps.rateLimiter,
3614
+ "update_task"
3615
+ );
3616
+ if (input.planSlug !== void 0 && input.planSlug !== "") {
3617
+ const plan = await planService.resolvePlan(input.planSlug);
3618
+ await ensureTaskInPlan(
3619
+ taskService,
3620
+ sprintService,
3621
+ plan,
3622
+ input.taskId
3623
+ );
3624
+ }
3625
+ const updated = await taskService.updateTask(input.taskId, {
3626
+ ...input.title !== void 0 ? { title: input.title } : {},
3627
+ ...input.description !== void 0 ? { description: input.description } : {},
3628
+ ...input.priority !== void 0 ? { priority: input.priority } : {},
3629
+ ...input.tags !== void 0 ? { tags: input.tags } : {},
3630
+ ...input.order !== void 0 ? { order: input.order } : {},
3631
+ ...input.touchedFiles !== void 0 ? { touchedFiles: input.touchedFiles } : {}
3632
+ });
3633
+ writeToolAudit(
3634
+ deps.auditLog,
3635
+ "update_task",
3636
+ guard.principalId,
3637
+ guard.correlationId,
3638
+ updated.id
3639
+ );
3640
+ return {
3641
+ content: [
3642
+ {
3643
+ type: "text",
3644
+ text: `Task '${updated.title}' updated. (id: ${shortId(updated.id)})`
3645
+ }
3646
+ ],
3647
+ structuredContent: jsonStructured2(updated)
3648
+ };
3649
+ } catch (e) {
3650
+ return toMcpToolError(e);
3651
+ }
3652
+ }
3653
+ );
3654
+ }
3655
+ };
3656
+
3657
+ // src/mcp/plugins/task-workflow.plugin.ts
3658
+ import { z as z7 } from "zod";
3659
+ var taskWorkflowPlugin = {
3660
+ id: "sprintdock/task-workflow",
3661
+ register(server, ctx) {
3662
+ const { deps, kit } = ctx;
3663
+ const { jsonStructured: jsonStructured2, optionalPlanSlug: optionalPlanSlug2, TASK_STATUS_SCHEMA: TASK_STATUS_SCHEMA2, TASK_PRIORITY_SCHEMA: TASK_PRIORITY_SCHEMA2 } = kit;
3664
+ const { planService, sprintService, taskService } = deps.services;
3665
+ server.registerTool(
3666
+ "bulk_create_tasks",
3667
+ {
3668
+ description: "Creates up to 50 tasks in one sprint in a single call. Pass sprintIdOrSlug (full UUID or sprint slug under the resolved plan). Each entry supports title, description, priority, assignee, and tags. Guarded mutation.",
3669
+ inputSchema: z7.object({
3670
+ sprintIdOrSlug: z7.string().min(1),
3671
+ tasks: z7.array(
3672
+ z7.object({
3673
+ title: z7.string().min(1),
3674
+ description: z7.string().default(""),
3675
+ priority: TASK_PRIORITY_SCHEMA2.default("medium"),
3676
+ assignee: z7.string().nullable().optional(),
3677
+ tags: z7.array(z7.string()).default([])
3678
+ }).strict()
3679
+ ).min(1).max(50),
3680
+ planSlug: optionalPlanSlug2
3681
+ }).strict()
3682
+ },
3683
+ async (input) => {
3684
+ try {
3685
+ const guard = await guardToolExecution(
3686
+ deps.authContextResolver,
3687
+ deps.requestCorrelation,
3688
+ deps.rateLimiter,
3689
+ "bulk_create_tasks"
3690
+ );
3691
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
3692
+ const sprint = await getSprintForPlan(
3693
+ sprintService,
3694
+ plan,
3695
+ input.sprintIdOrSlug
3696
+ );
3697
+ const created = [];
3698
+ for (const t of input.tasks) {
3699
+ const task = await taskService.createTask(
3700
+ {
3701
+ title: t.title,
3702
+ description: t.description || null,
3703
+ priority: t.priority ?? "medium",
3704
+ sprintId: sprint.id,
3705
+ assignee: t.assignee ?? null,
3706
+ tags: t.tags ?? []
3707
+ },
3708
+ input.planSlug
3709
+ );
3710
+ created.push(task);
3711
+ }
3712
+ writeToolAudit(
3713
+ deps.auditLog,
3714
+ "bulk_create_tasks",
3715
+ guard.principalId,
3716
+ guard.correlationId,
3717
+ sprint.id,
3718
+ { count: created.length }
3719
+ );
3720
+ const lines = created.map(
3721
+ (x) => `- '${x.title}' (id: ${shortId(x.id)})`
3722
+ );
3723
+ return {
3724
+ content: [
3725
+ {
3726
+ type: "text",
3727
+ text: `Created ${created.length} task(s):
3728
+ ${lines.join("\n")}`
3729
+ }
3730
+ ],
3731
+ structuredContent: jsonStructured2({ tasks: created })
3732
+ };
3733
+ } catch (e) {
3734
+ return toMcpToolError(e);
3735
+ }
3736
+ }
3737
+ );
3738
+ server.registerTool(
3739
+ "bulk_update_task_status",
3740
+ {
3741
+ description: "Updates status on up to 50 tasks in one call. Each pair must include taskId and target status. Guarded mutation.",
3742
+ inputSchema: z7.object({
3743
+ updates: z7.array(
3744
+ z7.object({
3745
+ taskId: z7.string().min(1),
3746
+ status: TASK_STATUS_SCHEMA2
3747
+ }).strict()
3748
+ ).min(1).max(50),
3749
+ planSlug: optionalPlanSlug2
3750
+ }).strict()
3751
+ },
3752
+ async (input) => {
3753
+ try {
3754
+ const guard = await guardToolExecution(
3755
+ deps.authContextResolver,
3756
+ deps.requestCorrelation,
3757
+ deps.rateLimiter,
3758
+ "bulk_update_task_status"
3759
+ );
3760
+ const done = [];
3761
+ for (const u of input.updates) {
3762
+ if (input.planSlug !== void 0 && input.planSlug !== "") {
3763
+ const plan = await planService.resolvePlan(input.planSlug);
3764
+ await ensureTaskInPlan(
3765
+ taskService,
3766
+ sprintService,
3767
+ plan,
3768
+ u.taskId
3769
+ );
3770
+ }
3771
+ const task = await taskService.updateTaskStatus(
3772
+ u.taskId,
3773
+ u.status
3774
+ );
3775
+ done.push(task);
3776
+ }
3777
+ writeToolAudit(
3778
+ deps.auditLog,
3779
+ "bulk_update_task_status",
3780
+ guard.principalId,
3781
+ guard.correlationId,
3782
+ "bulk",
3783
+ { count: done.length }
3784
+ );
3785
+ const lines = done.map(
3786
+ (x) => `- '${x.title}' -> ${x.status} (id: ${shortId(x.id)})`
3787
+ );
3788
+ return {
3789
+ content: [
3790
+ {
3791
+ type: "text",
3792
+ text: `Updated ${done.length} task(s):
3793
+ ${lines.join("\n")}`
3794
+ }
3795
+ ],
3796
+ structuredContent: jsonStructured2({ tasks: done })
3797
+ };
3798
+ } catch (e) {
3799
+ return toMcpToolError(e);
3800
+ }
3801
+ }
3802
+ );
3803
+ server.registerTool(
3804
+ "get_task_dependencies",
3805
+ {
3806
+ description: "Returns tasks this task depends on (prerequisites) and tasks that depend on it (dependents), scoped to the plan when planSlug is set.",
3807
+ inputSchema: z7.object({
3808
+ taskId: z7.string().min(1),
3809
+ planSlug: optionalPlanSlug2
3810
+ }).strict()
3811
+ },
3812
+ async (input) => {
3813
+ try {
3814
+ const { dependsOn, dependedOnBy } = await taskService.getTaskDependencyInfo(
3815
+ input.taskId,
3816
+ input.planSlug
3817
+ );
3818
+ const task = await taskService.getTask(input.taskId);
3819
+ const preLines = dependsOn.map(
3820
+ (t) => `- '${t.title}' (id: ${shortId(t.id)})`
3821
+ );
3822
+ const postLines = dependedOnBy.map(
3823
+ (t) => `- '${t.title}' (id: ${shortId(t.id)})`
3824
+ );
3825
+ const text4 = [
3826
+ `Dependencies for '${task.title}' (id: ${shortId(task.id)}):`,
3827
+ "Depends on:",
3828
+ preLines.length ? preLines.join("\n") : "(none)",
3829
+ "Depended on by:",
3830
+ postLines.length ? postLines.join("\n") : "(none)"
3831
+ ].join("\n");
3832
+ return {
3833
+ content: [{ type: "text", text: text4 }],
3834
+ structuredContent: jsonStructured2({
3835
+ task,
3836
+ dependsOn,
3837
+ dependedOnBy
3838
+ })
3839
+ };
3840
+ } catch (e) {
3841
+ return toMcpToolError(e);
3842
+ }
3843
+ }
3844
+ );
3845
+ server.registerTool(
3846
+ "update_task_dependencies",
3847
+ {
3848
+ description: "Replaces the full prerequisite list for a task (depends-on edges). Validates acyclic graph within the plan. Guarded mutation.",
3849
+ inputSchema: z7.object({
3850
+ taskId: z7.string().min(1),
3851
+ dependsOnTaskIds: z7.array(z7.string()),
3852
+ planSlug: optionalPlanSlug2
3853
+ }).strict()
3854
+ },
3855
+ async (input) => {
3856
+ try {
3857
+ const guard = await guardToolExecution(
3858
+ deps.authContextResolver,
3859
+ deps.requestCorrelation,
3860
+ deps.rateLimiter,
3861
+ "update_task_dependencies"
3862
+ );
3863
+ await taskService.setTaskDependencies(
3864
+ input.taskId,
3865
+ input.dependsOnTaskIds,
3866
+ input.planSlug
3867
+ );
3868
+ const task = await taskService.getTask(input.taskId);
3869
+ writeToolAudit(
3870
+ deps.auditLog,
3871
+ "update_task_dependencies",
3872
+ guard.principalId,
3873
+ guard.correlationId,
3874
+ input.taskId,
3875
+ { count: input.dependsOnTaskIds.length }
3876
+ );
3877
+ return {
3878
+ content: [
3879
+ {
3880
+ type: "text",
3881
+ text: `Task '${task.title}' dependencies set (${input.dependsOnTaskIds.length} edge(s)). (id: ${shortId(task.id)})`
3882
+ }
3883
+ ],
3884
+ structuredContent: jsonStructured2({
3885
+ taskId: input.taskId,
3886
+ dependsOnTaskIds: input.dependsOnTaskIds
3887
+ })
3888
+ };
3889
+ } catch (e) {
3890
+ return toMcpToolError(e);
3891
+ }
3892
+ }
3893
+ );
3894
+ }
3895
+ };
3896
+
3897
+ // src/mcp/plugins/default-plugins.ts
3898
+ var defaultSprintdockMcpPlugins = [
3899
+ planToolsPlugin,
3900
+ contextToolsPlugin,
3901
+ sprintToolsPlugin,
3902
+ taskToolsPlugin,
3903
+ taskWorkflowPlugin
3904
+ ];
3905
+
3906
+ // src/mcp/plugins/mcp-tool-kit.ts
3907
+ import { z as z8 } from "zod";
3908
+ var optionalPlanSlug = z8.string().min(1).optional();
3909
+ var paginationSchema = {
3910
+ limit: z8.number().int().min(1).max(100).optional(),
3911
+ offset: z8.number().int().min(0).optional()
3912
+ };
3913
+ var TASK_STATUS_SCHEMA = z8.enum([
3914
+ "todo",
3915
+ "in_progress",
3916
+ "blocked",
3917
+ "done"
3918
+ ]);
3919
+ var TASK_PRIORITY_SCHEMA = z8.enum([
3920
+ "low",
3921
+ "medium",
3922
+ "high",
3923
+ "critical"
3924
+ ]);
3925
+ function applyPagination(items, limit, offset) {
3926
+ const total = items.length;
3927
+ if (limit === void 0 && offset === void 0) {
3928
+ return { page: items, total };
3929
+ }
3930
+ const off = offset ?? 0;
3931
+ const lim = limit ?? 50;
3932
+ return { page: items.slice(off, off + lim), total };
3933
+ }
3934
+ function paginationNote(total, limit, offset) {
3935
+ if (limit === void 0 && offset === void 0) {
3936
+ return "";
3937
+ }
3938
+ const off = offset ?? 0;
3939
+ const lim = limit ?? 50;
3940
+ if (total === 0) {
3941
+ return " (showing 0 of 0)";
3942
+ }
3943
+ const end = Math.min(off + lim, total);
3944
+ return ` (showing ${off + 1}-${end} of ${total})`;
3945
+ }
3946
+ function jsonStructured(data) {
3947
+ return JSON.parse(JSON.stringify(data));
3948
+ }
3949
+ var kitSingleton = {
3950
+ jsonStructured,
3951
+ applyPagination,
3952
+ paginationNote,
3953
+ optionalPlanSlug,
3954
+ paginationSchema,
3955
+ TASK_STATUS_SCHEMA,
3956
+ TASK_PRIORITY_SCHEMA
3957
+ };
3958
+ function createSprintdockMcpToolKit() {
3959
+ return kitSingleton;
3960
+ }
3961
+
3962
+ // src/mcp/register-sprintdock-mcp-tools.ts
3963
+ function registerSprintdockMcpTools(server, d, options) {
3964
+ const kit = createSprintdockMcpToolKit();
3965
+ const ctx = { deps: d, kit };
3966
+ const plugins = options?.plugins ?? [...defaultSprintdockMcpPlugins];
3967
+ for (const plugin of plugins) {
3968
+ plugin.register(server, ctx);
3969
+ }
3970
+ }
3971
+
3972
+ // src/mcp/sprintdock-mcp-capabilities.ts
3973
+ var SPRINTDOCK_MCP_CAPABILITIES = {
3974
+ tools: {
3975
+ listChanged: true
3976
+ },
3977
+ logging: {}
3978
+ };
3979
+
3980
+ // src/mcp/transports/http-app-factory.ts
3981
+ import { randomUUID as randomUUID6 } from "crypto";
3982
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
3983
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3984
+ function createSprintdockMcpHttpApp(serverFactory, host) {
3985
+ const app = createMcpExpressApp({ host });
3986
+ app.use("/mcp", async (request, response) => {
3987
+ const server = serverFactory();
3988
+ const transport = new StreamableHTTPServerTransport({
3989
+ sessionIdGenerator: () => randomUUID6()
3990
+ });
3991
+ await server.connect(transport);
3992
+ await transport.handleRequest(request, response);
3993
+ });
3994
+ return app;
3995
+ }
3996
+
3997
+ // src/mcp/transports/http-entry.ts
3998
+ async function runHttpTransport(serverFactory, host, port) {
3999
+ const app = createSprintdockMcpHttpApp(serverFactory, host);
4000
+ await new Promise((resolve) => {
4001
+ app.listen(port, host, () => {
4002
+ process.stderr.write(
4003
+ `[sprintdock:backend:mcp] streamable-http listening at http://${host}:${port}/mcp
4004
+ `
4005
+ );
4006
+ resolve();
4007
+ });
4008
+ });
4009
+ return 0;
4010
+ }
4011
+
4012
+ // src/mcp/transports/stdio-entry.ts
4013
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4014
+ async function runStdioTransport(server) {
4015
+ const transport = new StdioServerTransport();
4016
+ await server.connect(transport);
4017
+ process.stderr.write("[sprintdock:backend:mcp] stdio transport connected\n");
4018
+ return 0;
4019
+ }
4020
+
4021
+ // src/mcp/sprintdock-mcp-runtime.ts
4022
+ var SprintdockMcpRuntime = class {
4023
+ options;
4024
+ workspaceRoot;
4025
+ toolDeps = null;
4026
+ constructor(options) {
4027
+ this.workspaceRoot = resolveWorkspaceRoot2();
4028
+ const transportEnv = getEnv("SPRINTDOCK_MCP_TRANSPORT", "stdio", {
4029
+ projectRoot: this.workspaceRoot
4030
+ });
4031
+ this.options = {
4032
+ transport: options?.transport ?? transportEnv,
4033
+ httpHost: options?.httpHost,
4034
+ httpPort: options?.httpPort
4035
+ };
4036
+ }
4037
+ /**
4038
+ * Boots SQLite, registers tools, and starts the selected transport.
4039
+ *
4040
+ * @returns Process-style exit code (0 on success).
4041
+ */
4042
+ async run() {
4043
+ const { services, dbPath } = await bootstrapSprintdockSqlite();
4044
+ process.stderr.write(
4045
+ `[sprintdock:backend:mcp] sqlite database: ${dbPath}
4046
+ `
4047
+ );
4048
+ this.toolDeps = {
4049
+ services,
4050
+ auditLog: new ConsoleAuditLog(),
4051
+ rateLimiter: new InMemoryBackendRateLimiter(),
4052
+ requestCorrelation: new RequestCorrelation(),
4053
+ authContextResolver: new LocalAuthContextResolver()
4054
+ };
4055
+ if (this.options.transport === "http") {
4056
+ const host = this.options.httpHost ?? getEnv("SPRINTDOCK_MCP_HTTP_HOST", "127.0.0.1", {
4057
+ projectRoot: this.workspaceRoot
4058
+ });
4059
+ const portRaw = this.options.httpPort ?? Number(
4060
+ getEnv("SPRINTDOCK_MCP_HTTP_PORT", "3030", {
4061
+ projectRoot: this.workspaceRoot
4062
+ })
4063
+ );
4064
+ if (!Number.isFinite(portRaw)) {
4065
+ throw new Error("SPRINTDOCK_MCP_HTTP_PORT must be a valid number.");
4066
+ }
4067
+ return runHttpTransport(() => this.createMcpServer(), host, portRaw);
4068
+ }
4069
+ return runStdioTransport(this.createMcpServer());
4070
+ }
4071
+ createMcpServer() {
4072
+ if (!this.toolDeps) {
4073
+ throw new Error("SprintdockMcpRuntime tool dependencies not initialized");
4074
+ }
4075
+ const server = new McpServer(
4076
+ {
4077
+ name: "sprintdock-backend",
4078
+ version: "0.2.0"
4079
+ },
4080
+ {
4081
+ capabilities: SPRINTDOCK_MCP_CAPABILITIES,
4082
+ instructions: "Sprintdock MCP (SQLite): data lives in .sprintdock/sprintdock.db. Use list_plans / create_plan / set_active_plan. Optional planSlug on sprint/task tools scopes to that plan. Plan/sprint/task tools include update_plan_markdown and update_sprint_markdown for long-form notes."
4083
+ }
4084
+ );
4085
+ registerSprintdockMcpTools(server, this.toolDeps);
4086
+ return server;
4087
+ }
4088
+ };
4089
+ async function runSprintdockMcpServer(options) {
4090
+ const runtime = new SprintdockMcpRuntime(options);
4091
+ return runtime.run();
4092
+ }
4093
+ export {
4094
+ SprintdockMcpRuntime,
4095
+ bootstrapSprintdockSqlite,
4096
+ createApplicationServices,
4097
+ createHttpApp,
4098
+ createRepositories,
4099
+ createSprintdockMcpToolKit,
4100
+ defaultSprintdockMcpPlugins,
4101
+ registerSprintdockMcpTools,
4102
+ runSprintdockMcpServer
4103
+ };