@mcoda/core 0.1.4

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 (142) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +9 -0
  4. package/dist/api/AgentsApi.d.ts +36 -0
  5. package/dist/api/AgentsApi.d.ts.map +1 -0
  6. package/dist/api/AgentsApi.js +176 -0
  7. package/dist/api/QaTasksApi.d.ts +8 -0
  8. package/dist/api/QaTasksApi.d.ts.map +1 -0
  9. package/dist/api/QaTasksApi.js +36 -0
  10. package/dist/api/TasksApi.d.ts +7 -0
  11. package/dist/api/TasksApi.d.ts.map +1 -0
  12. package/dist/api/TasksApi.js +34 -0
  13. package/dist/config/ConfigService.d.ts +3 -0
  14. package/dist/config/ConfigService.d.ts.map +1 -0
  15. package/dist/config/ConfigService.js +2 -0
  16. package/dist/domain/dependencies/Dependency.d.ts +3 -0
  17. package/dist/domain/dependencies/Dependency.d.ts.map +1 -0
  18. package/dist/domain/dependencies/Dependency.js +2 -0
  19. package/dist/domain/epics/Epic.d.ts +3 -0
  20. package/dist/domain/epics/Epic.d.ts.map +1 -0
  21. package/dist/domain/epics/Epic.js +2 -0
  22. package/dist/domain/projects/Project.d.ts +3 -0
  23. package/dist/domain/projects/Project.d.ts.map +1 -0
  24. package/dist/domain/projects/Project.js +2 -0
  25. package/dist/domain/tasks/Task.d.ts +3 -0
  26. package/dist/domain/tasks/Task.d.ts.map +1 -0
  27. package/dist/domain/tasks/Task.js +2 -0
  28. package/dist/domain/userStories/UserStory.d.ts +3 -0
  29. package/dist/domain/userStories/UserStory.d.ts.map +1 -0
  30. package/dist/domain/userStories/UserStory.js +2 -0
  31. package/dist/index.d.ts +28 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +27 -0
  34. package/dist/prompts/PdrPrompts.d.ts +4 -0
  35. package/dist/prompts/PdrPrompts.d.ts.map +1 -0
  36. package/dist/prompts/PdrPrompts.js +21 -0
  37. package/dist/prompts/PromptLoader.d.ts +3 -0
  38. package/dist/prompts/PromptLoader.d.ts.map +1 -0
  39. package/dist/prompts/PromptLoader.js +2 -0
  40. package/dist/prompts/SdsPrompts.d.ts +5 -0
  41. package/dist/prompts/SdsPrompts.d.ts.map +1 -0
  42. package/dist/prompts/SdsPrompts.js +44 -0
  43. package/dist/services/agents/AgentManagementService.d.ts +3 -0
  44. package/dist/services/agents/AgentManagementService.d.ts.map +1 -0
  45. package/dist/services/agents/AgentManagementService.js +2 -0
  46. package/dist/services/agents/GatewayAgentService.d.ts +92 -0
  47. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -0
  48. package/dist/services/agents/GatewayAgentService.js +870 -0
  49. package/dist/services/agents/RoutingApiClient.d.ts +23 -0
  50. package/dist/services/agents/RoutingApiClient.d.ts.map +1 -0
  51. package/dist/services/agents/RoutingApiClient.js +62 -0
  52. package/dist/services/agents/RoutingService.d.ts +50 -0
  53. package/dist/services/agents/RoutingService.d.ts.map +1 -0
  54. package/dist/services/agents/RoutingService.js +386 -0
  55. package/dist/services/agents/generated/RoutingApiClient.d.ts +21 -0
  56. package/dist/services/agents/generated/RoutingApiClient.d.ts.map +1 -0
  57. package/dist/services/agents/generated/RoutingApiClient.js +68 -0
  58. package/dist/services/backlog/BacklogService.d.ts +98 -0
  59. package/dist/services/backlog/BacklogService.d.ts.map +1 -0
  60. package/dist/services/backlog/BacklogService.js +453 -0
  61. package/dist/services/backlog/TaskOrderingService.d.ts +88 -0
  62. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -0
  63. package/dist/services/backlog/TaskOrderingService.js +675 -0
  64. package/dist/services/docs/DocsService.d.ts +82 -0
  65. package/dist/services/docs/DocsService.d.ts.map +1 -0
  66. package/dist/services/docs/DocsService.js +1631 -0
  67. package/dist/services/estimate/EstimateService.d.ts +12 -0
  68. package/dist/services/estimate/EstimateService.d.ts.map +1 -0
  69. package/dist/services/estimate/EstimateService.js +103 -0
  70. package/dist/services/estimate/VelocityService.d.ts +19 -0
  71. package/dist/services/estimate/VelocityService.d.ts.map +1 -0
  72. package/dist/services/estimate/VelocityService.js +237 -0
  73. package/dist/services/estimate/types.d.ts +30 -0
  74. package/dist/services/estimate/types.d.ts.map +1 -0
  75. package/dist/services/estimate/types.js +1 -0
  76. package/dist/services/execution/ExecutionService.d.ts +3 -0
  77. package/dist/services/execution/ExecutionService.d.ts.map +1 -0
  78. package/dist/services/execution/ExecutionService.js +2 -0
  79. package/dist/services/execution/QaFollowupService.d.ts +38 -0
  80. package/dist/services/execution/QaFollowupService.d.ts.map +1 -0
  81. package/dist/services/execution/QaFollowupService.js +236 -0
  82. package/dist/services/execution/QaProfileService.d.ts +22 -0
  83. package/dist/services/execution/QaProfileService.d.ts.map +1 -0
  84. package/dist/services/execution/QaProfileService.js +142 -0
  85. package/dist/services/execution/QaTasksService.d.ts +101 -0
  86. package/dist/services/execution/QaTasksService.d.ts.map +1 -0
  87. package/dist/services/execution/QaTasksService.js +1117 -0
  88. package/dist/services/execution/TaskSelectionService.d.ts +50 -0
  89. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -0
  90. package/dist/services/execution/TaskSelectionService.js +281 -0
  91. package/dist/services/execution/TaskStateService.d.ts +19 -0
  92. package/dist/services/execution/TaskStateService.d.ts.map +1 -0
  93. package/dist/services/execution/TaskStateService.js +59 -0
  94. package/dist/services/execution/WorkOnTasksService.d.ts +80 -0
  95. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -0
  96. package/dist/services/execution/WorkOnTasksService.js +1833 -0
  97. package/dist/services/jobs/JobInsightsService.d.ts +97 -0
  98. package/dist/services/jobs/JobInsightsService.d.ts.map +1 -0
  99. package/dist/services/jobs/JobInsightsService.js +263 -0
  100. package/dist/services/jobs/JobResumeService.d.ts +16 -0
  101. package/dist/services/jobs/JobResumeService.d.ts.map +1 -0
  102. package/dist/services/jobs/JobResumeService.js +113 -0
  103. package/dist/services/jobs/JobService.d.ts +149 -0
  104. package/dist/services/jobs/JobService.d.ts.map +1 -0
  105. package/dist/services/jobs/JobService.js +490 -0
  106. package/dist/services/jobs/JobsApiClient.d.ts +73 -0
  107. package/dist/services/jobs/JobsApiClient.d.ts.map +1 -0
  108. package/dist/services/jobs/JobsApiClient.js +67 -0
  109. package/dist/services/openapi/OpenApiService.d.ts +54 -0
  110. package/dist/services/openapi/OpenApiService.d.ts.map +1 -0
  111. package/dist/services/openapi/OpenApiService.js +503 -0
  112. package/dist/services/planning/CreateTasksService.d.ts +68 -0
  113. package/dist/services/planning/CreateTasksService.d.ts.map +1 -0
  114. package/dist/services/planning/CreateTasksService.js +989 -0
  115. package/dist/services/planning/KeyHelpers.d.ts +5 -0
  116. package/dist/services/planning/KeyHelpers.d.ts.map +1 -0
  117. package/dist/services/planning/KeyHelpers.js +62 -0
  118. package/dist/services/planning/PlanningService.d.ts +3 -0
  119. package/dist/services/planning/PlanningService.d.ts.map +1 -0
  120. package/dist/services/planning/PlanningService.js +2 -0
  121. package/dist/services/planning/RefineTasksService.d.ts +56 -0
  122. package/dist/services/planning/RefineTasksService.d.ts.map +1 -0
  123. package/dist/services/planning/RefineTasksService.js +1328 -0
  124. package/dist/services/review/CodeReviewService.d.ts +103 -0
  125. package/dist/services/review/CodeReviewService.d.ts.map +1 -0
  126. package/dist/services/review/CodeReviewService.js +1187 -0
  127. package/dist/services/system/SystemUpdateService.d.ts +55 -0
  128. package/dist/services/system/SystemUpdateService.d.ts.map +1 -0
  129. package/dist/services/system/SystemUpdateService.js +136 -0
  130. package/dist/services/tasks/TaskApiResolver.d.ts +7 -0
  131. package/dist/services/tasks/TaskApiResolver.d.ts.map +1 -0
  132. package/dist/services/tasks/TaskApiResolver.js +41 -0
  133. package/dist/services/tasks/TaskDetailService.d.ts +106 -0
  134. package/dist/services/tasks/TaskDetailService.d.ts.map +1 -0
  135. package/dist/services/tasks/TaskDetailService.js +332 -0
  136. package/dist/services/telemetry/TelemetryService.d.ts +53 -0
  137. package/dist/services/telemetry/TelemetryService.d.ts.map +1 -0
  138. package/dist/services/telemetry/TelemetryService.js +434 -0
  139. package/dist/workspace/WorkspaceManager.d.ts +35 -0
  140. package/dist/workspace/WorkspaceManager.d.ts.map +1 -0
  141. package/dist/workspace/WorkspaceManager.js +201 -0
  142. package/package.json +45 -0
@@ -0,0 +1,1328 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { AgentService } from "@mcoda/agents";
4
+ import { DocdexClient } from "@mcoda/integrations";
5
+ import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
6
+ import { JobService } from "../jobs/JobService.js";
7
+ import { RoutingService } from "../agents/RoutingService.js";
8
+ import { createTaskKeyGenerator } from "./KeyHelpers.js";
9
+ const DEFAULT_STRATEGY = "auto";
10
+ const FORBIDDEN_TARGET_STATUSES = new Set(["ready_to_review", "ready_to_qa", "completed"]);
11
+ const DEFAULT_MAX_TASKS = 250;
12
+ const MAX_AGENT_OUTPUT_CHARS = 10000000;
13
+ const estimateTokens = (text) => Math.max(1, Math.ceil(text.length / 4));
14
+ const extractJson = (raw) => {
15
+ try {
16
+ const fenced = raw.match(/```json([\s\S]*?)```/);
17
+ const candidate = fenced ? fenced[1] : raw;
18
+ const start = candidate.indexOf("{");
19
+ const end = candidate.lastIndexOf("}");
20
+ if (start === -1 || end === -1 || end <= start)
21
+ return JSON.parse(raw);
22
+ return JSON.parse(candidate.slice(start, end + 1));
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ };
28
+ const normalizeOperation = (op) => {
29
+ if (!op || typeof op !== "object")
30
+ return op;
31
+ if (op.op !== "update_task")
32
+ return op;
33
+ const taskKey = op.taskKey ?? op.key ?? op.task ?? op.targetTaskKey ?? null;
34
+ const updates = { ...op.updates };
35
+ const inlineFields = ["title", "description", "acceptanceCriteria", "type", "status", "storyPoints", "priority", "dependsOn", "metadata"];
36
+ for (const field of inlineFields) {
37
+ if (op[field] !== undefined && updates[field] === undefined) {
38
+ updates[field] = op[field];
39
+ }
40
+ }
41
+ return {
42
+ ...op,
43
+ taskKey,
44
+ updates,
45
+ };
46
+ };
47
+ const safeParsePlan = (content) => {
48
+ try {
49
+ const parsed = JSON.parse(content);
50
+ if (parsed && Array.isArray(parsed.operations))
51
+ return parsed;
52
+ }
53
+ catch {
54
+ /* ignore */
55
+ }
56
+ return undefined;
57
+ };
58
+ const formatTaskSummary = (task) => {
59
+ return [
60
+ `- ${task.key}: ${task.title} [${task.status}${task.type ? `/${task.type}` : ""}]`,
61
+ task.storyPoints !== null && task.storyPoints !== undefined ? ` SP: ${task.storyPoints}` : "",
62
+ task.dependencies.length ? ` Depends on: ${task.dependencies.join(", ")}` : "",
63
+ ]
64
+ .filter(Boolean)
65
+ .join("\n");
66
+ };
67
+ export class RefineTasksService {
68
+ constructor(workspace, deps) {
69
+ this.workspace = workspace;
70
+ this.docdex = deps.docdex;
71
+ this.jobService = deps.jobService;
72
+ this.agentService = deps.agentService;
73
+ this.repo = deps.repo;
74
+ this.workspaceRepo = deps.workspaceRepo;
75
+ this.routingService = deps.routingService;
76
+ }
77
+ static async create(workspace) {
78
+ const repo = await GlobalRepository.create();
79
+ const agentService = new AgentService(repo);
80
+ const routingService = await RoutingService.create();
81
+ const docdex = new DocdexClient({
82
+ workspaceRoot: workspace.workspaceRoot,
83
+ baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
84
+ });
85
+ const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
86
+ const jobService = new JobService(workspace, workspaceRepo);
87
+ return new RefineTasksService(workspace, {
88
+ docdex,
89
+ jobService,
90
+ agentService,
91
+ repo,
92
+ workspaceRepo,
93
+ routingService,
94
+ });
95
+ }
96
+ async close() {
97
+ const tryClose = async (target) => {
98
+ try {
99
+ if (target?.close) {
100
+ await target.close();
101
+ }
102
+ }
103
+ catch {
104
+ // ignore close errors
105
+ }
106
+ };
107
+ await tryClose(this.agentService);
108
+ await tryClose(this.repo);
109
+ await tryClose(this.jobService);
110
+ await tryClose(this.workspaceRepo);
111
+ await tryClose(this.routingService);
112
+ await tryClose(this.docdex);
113
+ }
114
+ async resolveAgent(agentName) {
115
+ const resolved = await this.routingService.resolveAgentForCommand({
116
+ workspace: this.workspace,
117
+ commandName: "refine-tasks",
118
+ overrideAgentSlug: agentName,
119
+ });
120
+ return resolved.agent;
121
+ }
122
+ async selectTasks(projectKey, filters) {
123
+ const db = this.workspaceRepo.getDb();
124
+ const warnings = [];
125
+ const project = await this.workspaceRepo.getProjectByKey(projectKey);
126
+ if (!project) {
127
+ throw new Error(`Unknown project key: ${projectKey}`);
128
+ }
129
+ const epicRow = filters.epicKey
130
+ ? await db.get(`SELECT id, key, title, description FROM epics WHERE key = ? AND project_id = ?`, filters.epicKey, project.id)
131
+ : undefined;
132
+ if (filters.epicKey && !epicRow) {
133
+ throw new Error(`Unknown epic key ${filters.epicKey} under project ${projectKey}`);
134
+ }
135
+ const storyRow = filters.storyKey
136
+ ? await db.get(`SELECT id, key, epic_id, title, description, acceptance_criteria FROM user_stories WHERE key = ?`, filters.storyKey)
137
+ : undefined;
138
+ if (filters.storyKey && !storyRow) {
139
+ throw new Error(`Unknown user story key ${filters.storyKey}`);
140
+ }
141
+ if (filters.storyKey && epicRow && storyRow && storyRow.epic_id !== epicRow.id) {
142
+ throw new Error(`Story ${filters.storyKey} is not under epic ${filters.epicKey}`);
143
+ }
144
+ const clauses = ["t.project_id = ?"];
145
+ const params = [project.id];
146
+ if (epicRow) {
147
+ clauses.push("t.epic_id = ?");
148
+ params.push(epicRow.id);
149
+ }
150
+ if (storyRow) {
151
+ clauses.push("t.user_story_id = ?");
152
+ params.push(storyRow.id);
153
+ }
154
+ if (filters.taskKeys && filters.taskKeys.length > 0) {
155
+ clauses.push(`t.key IN (${filters.taskKeys.map(() => "?").join(", ")})`);
156
+ params.push(...filters.taskKeys);
157
+ }
158
+ if (filters.statusFilter && filters.statusFilter.length > 0) {
159
+ clauses.push(`LOWER(t.status) IN (${filters.statusFilter.map(() => "?").join(", ")})`);
160
+ params.push(...filters.statusFilter.map((s) => s.toLowerCase()));
161
+ }
162
+ if (filters.excludeAlreadyRefined) {
163
+ clauses.push(`NOT EXISTS (
164
+ SELECT 1
165
+ FROM task_runs tr
166
+ WHERE tr.task_id = t.id
167
+ AND tr.command = ?
168
+ AND LOWER(tr.status) = 'succeeded'
169
+ )`);
170
+ params.push("refine-tasks");
171
+ }
172
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
173
+ const limit = filters.maxTasks ? `LIMIT ${filters.maxTasks}` : "";
174
+ const rows = await db.all(`
175
+ SELECT
176
+ t.id AS task_id,
177
+ t.key AS task_key,
178
+ t.project_id AS project_id,
179
+ t.epic_id AS epic_id,
180
+ t.user_story_id AS story_id,
181
+ t.title AS task_title,
182
+ t.description AS task_description,
183
+ t.type AS task_type,
184
+ t.status AS task_status,
185
+ t.story_points AS task_story_points,
186
+ t.priority AS task_priority,
187
+ t.metadata_json AS task_metadata,
188
+ e.key AS epic_key,
189
+ e.title AS epic_title,
190
+ e.description AS epic_description,
191
+ s.key AS story_key,
192
+ s.title AS story_title,
193
+ s.description AS story_description,
194
+ s.acceptance_criteria AS story_acceptance
195
+ FROM tasks t
196
+ INNER JOIN epics e ON e.id = t.epic_id
197
+ INNER JOIN user_stories s ON s.id = t.user_story_id
198
+ ${where}
199
+ ORDER BY s.priority IS NULL, s.priority, t.priority IS NULL, t.priority, t.created_at
200
+ ${limit}
201
+ `, params);
202
+ const taskIds = rows.map((r) => r.task_id);
203
+ const depMap = new Map();
204
+ if (taskIds.length > 0) {
205
+ const depRows = await db.all(`
206
+ SELECT td.task_id, dep.key AS dep_key
207
+ FROM task_dependencies td
208
+ INNER JOIN tasks dep ON dep.id = td.depends_on_task_id
209
+ WHERE td.task_id IN (${taskIds.map(() => "?").join(", ")})
210
+ `, taskIds);
211
+ for (const dep of depRows) {
212
+ const list = depMap.get(dep.task_id) ?? [];
213
+ list.push(dep.dep_key);
214
+ depMap.set(dep.task_id, list);
215
+ }
216
+ }
217
+ const groups = new Map();
218
+ for (const row of rows) {
219
+ const acceptance = row.story_acceptance ? String(row.story_acceptance).split(/\r?\n/).filter(Boolean) : [];
220
+ const groupKey = row.story_id;
221
+ if (!groups.has(groupKey)) {
222
+ groups.set(groupKey, {
223
+ epic: { id: row.epic_id, key: row.epic_key, title: row.epic_title, description: row.epic_description ?? undefined },
224
+ story: { id: row.story_id, key: row.story_key, title: row.story_title, description: row.story_description ?? undefined, acceptance },
225
+ tasks: [],
226
+ });
227
+ }
228
+ const group = groups.get(groupKey);
229
+ const task = {
230
+ id: row.task_id,
231
+ projectId: row.project_id,
232
+ epicId: row.epic_id,
233
+ userStoryId: row.story_id,
234
+ key: row.task_key,
235
+ title: row.task_title,
236
+ description: row.task_description ?? undefined,
237
+ type: row.task_type ?? undefined,
238
+ status: row.task_status,
239
+ storyPoints: row.task_story_points ?? null,
240
+ priority: row.task_priority ?? null,
241
+ assignedAgentId: null,
242
+ assigneeHuman: null,
243
+ vcsBranch: null,
244
+ vcsBaseBranch: null,
245
+ vcsLastCommitSha: null,
246
+ metadata: row.task_metadata ? JSON.parse(row.task_metadata) : undefined,
247
+ openapiVersionAtCreation: null,
248
+ createdAt: "",
249
+ updatedAt: "",
250
+ storyKey: row.story_key,
251
+ epicKey: row.epic_key,
252
+ dependencies: depMap.get(row.task_id) ?? [],
253
+ };
254
+ group.tasks.push(task);
255
+ }
256
+ if (filters.maxTasks && rows.length > filters.maxTasks) {
257
+ warnings.push(`max-tasks=${filters.maxTasks} truncated selection to ${filters.maxTasks} tasks.`);
258
+ }
259
+ return { projectId: project.id, groups: Array.from(groups.values()), warnings };
260
+ }
261
+ parseTaskKeyParts(taskKey) {
262
+ const match = taskKey.match(/^(.*-us-\d+)-t\d+$/);
263
+ if (!match)
264
+ return null;
265
+ const storyKey = match[1];
266
+ const epicMatch = storyKey.match(/^(.*)-us-\d+$/);
267
+ const epicKey = epicMatch ? epicMatch[1] : storyKey.split("-us-")[0];
268
+ return { storyKey, epicKey };
269
+ }
270
+ async ensureTaskExists(projectId, projectKey, taskKey, createIfMissing, seed) {
271
+ const parts = this.parseTaskKeyParts(taskKey);
272
+ if (!parts)
273
+ return undefined;
274
+ const db = this.workspaceRepo.getDb();
275
+ const existing = await this.workspaceRepo.getTaskByKey(taskKey);
276
+ const loadDeps = async (taskId) => {
277
+ const depRows = await db.all(`SELECT dep.key AS dep_key
278
+ FROM task_dependencies td
279
+ INNER JOIN tasks dep ON dep.id = td.depends_on_task_id
280
+ WHERE td.task_id = ?`, taskId);
281
+ return depRows.map((d) => d.dep_key);
282
+ };
283
+ const ensureEpic = async () => {
284
+ const row = await db.get(`SELECT id, key, title, description FROM epics WHERE key = ? AND project_id = ?`, parts.epicKey, projectId);
285
+ if (row)
286
+ return { id: row.id, key: row.key, title: row.title, description: row.description ?? undefined };
287
+ const [inserted] = await this.workspaceRepo.insertEpics([
288
+ {
289
+ projectId,
290
+ key: parts.epicKey,
291
+ title: `Epic ${parts.epicKey}`,
292
+ description: `Auto-created while applying refine plan for ${projectKey}`,
293
+ storyPointsTotal: null,
294
+ priority: null,
295
+ },
296
+ ], false);
297
+ return { id: inserted.id, key: inserted.key, title: inserted.title, description: inserted.description ?? undefined };
298
+ };
299
+ const ensureStory = async (epicId) => {
300
+ const row = await db.get(`SELECT id, key, title, description, acceptance_criteria FROM user_stories WHERE key = ?`, parts.storyKey);
301
+ if (row) {
302
+ const acceptance = row.acceptance_criteria ? String(row.acceptance_criteria).split(/\r?\n/).filter(Boolean) : [];
303
+ return {
304
+ id: row.id,
305
+ key: row.key,
306
+ title: row.title,
307
+ description: row.description ?? undefined,
308
+ acceptance,
309
+ };
310
+ }
311
+ const [inserted] = await this.workspaceRepo.insertStories([
312
+ {
313
+ projectId,
314
+ epicId,
315
+ key: parts.storyKey,
316
+ title: `Story ${parts.storyKey}`,
317
+ description: `Auto-created while applying refine plan for ${projectKey}`,
318
+ acceptanceCriteria: undefined,
319
+ storyPointsTotal: null,
320
+ priority: null,
321
+ },
322
+ ], false);
323
+ return {
324
+ id: inserted.id,
325
+ key: inserted.key,
326
+ title: inserted.title,
327
+ description: inserted.description ?? undefined,
328
+ acceptance: [],
329
+ };
330
+ };
331
+ if (existing) {
332
+ const epicRow = await db.get(`SELECT id, key, title, description FROM epics WHERE id = ?`, existing.epicId);
333
+ const storyRow = await db.get(`SELECT id, key, title, description, acceptance_criteria FROM user_stories WHERE id = ?`, existing.userStoryId);
334
+ const acceptance = storyRow?.acceptance_criteria
335
+ ? String(storyRow.acceptance_criteria).split(/\r?\n/).filter(Boolean)
336
+ : [];
337
+ return {
338
+ task: {
339
+ ...existing,
340
+ storyKey: storyRow?.key ?? parts.storyKey,
341
+ epicKey: epicRow?.key ?? parts.epicKey,
342
+ dependencies: await loadDeps(existing.id),
343
+ },
344
+ epic: {
345
+ id: epicRow?.id ?? existing.epicId,
346
+ key: epicRow?.key ?? parts.epicKey,
347
+ title: epicRow?.title ?? `Epic ${parts.epicKey}`,
348
+ description: epicRow?.description ?? undefined,
349
+ },
350
+ story: {
351
+ id: storyRow?.id ?? existing.userStoryId,
352
+ key: storyRow?.key ?? parts.storyKey,
353
+ title: storyRow?.title ?? `Story ${parts.storyKey}`,
354
+ description: storyRow?.description ?? undefined,
355
+ acceptance,
356
+ },
357
+ };
358
+ }
359
+ if (!createIfMissing)
360
+ return undefined;
361
+ const updates = seed?.updates ?? seed?.fields ?? {};
362
+ const epic = await ensureEpic();
363
+ const story = await ensureStory(epic.id);
364
+ const [task] = await this.workspaceRepo.insertTasks([
365
+ {
366
+ projectId,
367
+ epicId: epic.id,
368
+ userStoryId: story.id,
369
+ key: taskKey,
370
+ title: updates.title ?? `Task ${taskKey}`,
371
+ description: updates.description ?? "",
372
+ type: updates.type ?? "feature",
373
+ status: updates.status ?? "not_started",
374
+ storyPoints: updates.storyPoints ?? null,
375
+ priority: updates.priority ?? null,
376
+ metadata: updates.metadata ?? undefined,
377
+ },
378
+ ], false);
379
+ return {
380
+ task: {
381
+ ...task,
382
+ storyKey: story.key,
383
+ epicKey: epic.key,
384
+ dependencies: [],
385
+ },
386
+ epic,
387
+ story,
388
+ };
389
+ }
390
+ buildStoryPrompt(group, strategy, docSummary) {
391
+ const taskList = group.tasks.map((t) => formatTaskSummary(t)).join("\n");
392
+ const constraints = [
393
+ "- Immutable: project_id, epic_id, user_story_id, task keys.",
394
+ "- Allowed edits: title, description, acceptanceCriteria, metadata/labels, type, priority, storyPoints, status (but NOT ready_to_review/qa/completed).",
395
+ "- Splits: children stay under same story; keep parent unless keepParent=false; child dependsOn must reference existing tasks or siblings.",
396
+ "- Merges: target and sources must be in same story; prefer cancelling redundant sources (status=cancelled) and preserve useful details in target updates.",
397
+ "- Dependencies: maintain DAG; do not introduce cycles or cross-story edges.",
398
+ "- Story points: non-negative, keep within typical agile range (0-13).",
399
+ "- Do not invent new epics/stories or change parentage.",
400
+ ].join("\n");
401
+ return [
402
+ `You are refining tasks for epic ${group.epic.key} "${group.epic.title}" and story ${group.story.key} "${group.story.title}".`,
403
+ `Strategy: ${strategy}`,
404
+ "Story acceptance criteria:",
405
+ group.story.acceptance?.length ? group.story.acceptance.map((c) => `- ${c}`).join("\n") : "- (none provided)",
406
+ "Current tasks:",
407
+ taskList || "- (no tasks selected)",
408
+ "Doc context (summaries only):",
409
+ docSummary || "(none)",
410
+ "Recent task history (logs/comments):",
411
+ group.historySummary || "(none)",
412
+ "Constraints:",
413
+ constraints,
414
+ "Return JSON ONLY matching: { \"operations\": [UpdateTaskOp | SplitTaskOp | MergeTasksOp | UpdateEstimateOp] } where each item has an `op` discriminator (update_task|split_task|merge_tasks|update_estimate).",
415
+ ].join("\n\n");
416
+ }
417
+ async summarizeDocs(projectKey, epicKey, storyKey) {
418
+ const warnings = [];
419
+ const startedAt = Date.now();
420
+ try {
421
+ const docs = await this.docdex.search({
422
+ projectKey,
423
+ profile: "sds",
424
+ query: [epicKey, storyKey].filter(Boolean).join(" "),
425
+ });
426
+ if (!docs || docs.length === 0) {
427
+ return { summary: "(no relevant docdex entries)", warnings: [] };
428
+ }
429
+ const top = docs
430
+ .filter((doc) => {
431
+ const type = (doc.docType ?? "").toLowerCase();
432
+ return type.includes("sds") || type.includes("pdr") || type.includes("rfp");
433
+ })
434
+ .slice(0, 5);
435
+ const summary = top
436
+ .map((doc) => {
437
+ const segments = (doc.segments ?? []).slice(0, 3);
438
+ const segText = segments
439
+ .map((seg, idx) => {
440
+ const snippet = seg.content.length > 180 ? `${seg.content.slice(0, 180)}...` : seg.content;
441
+ return ` (${idx + 1}) ${seg.heading ? `${seg.heading}: ` : ""}${snippet}`;
442
+ })
443
+ .join("\n");
444
+ const head = doc.content ? doc.content.split(/\r?\n/).slice(0, 2).join(" ").slice(0, 160) : "";
445
+ return [`- [${doc.docType}] ${doc.title ?? doc.path ?? doc.id}${head ? ` — ${head}` : ""}`, segText].filter(Boolean).join("\n");
446
+ })
447
+ .join("\n");
448
+ const durationSeconds = (Date.now() - startedAt) / 1000;
449
+ await this.jobService.recordTokenUsage({
450
+ workspaceId: this.workspace.workspaceId,
451
+ jobId: undefined,
452
+ commandRunId: undefined,
453
+ agentId: undefined,
454
+ modelName: "docdex",
455
+ tokensPrompt: null,
456
+ tokensCompletion: null,
457
+ tokensTotal: null,
458
+ durationSeconds,
459
+ timestamp: new Date().toISOString(),
460
+ metadata: { command: "refine-tasks", action: "docdex_search", projectKey, epicKey, storyKey },
461
+ });
462
+ return { summary: summary || "(no doc segments found)", warnings };
463
+ }
464
+ catch (error) {
465
+ warnings.push(`Docdex lookup failed: ${error.message}`);
466
+ return { summary: "(docdex unavailable)", warnings };
467
+ }
468
+ }
469
+ async summarizeHistory(taskIds) {
470
+ if (taskIds.length === 0)
471
+ return "(none)";
472
+ const db = this.workspaceRepo.getDb();
473
+ const placeholders = taskIds.map(() => "?").join(", ");
474
+ try {
475
+ const rows = await db.all(`
476
+ SELECT r.task_id, l.timestamp, l.level, l.message, l.source
477
+ FROM task_logs l
478
+ INNER JOIN task_runs r ON r.id = l.task_run_id
479
+ WHERE r.task_id IN (${placeholders})
480
+ ORDER BY l.timestamp DESC
481
+ LIMIT 15
482
+ `, taskIds);
483
+ if (!rows || rows.length === 0)
484
+ return "(none)";
485
+ return rows
486
+ .map((row) => {
487
+ const level = row.level ? row.level.toUpperCase() : "INFO";
488
+ const msg = row.message ?? "";
489
+ return `- ${row.task_id}: [${level}] ${msg} (${row.source ?? "run"})`;
490
+ })
491
+ .join("\n");
492
+ }
493
+ catch {
494
+ return "(unavailable)";
495
+ }
496
+ }
497
+ async logWarningsToTasks(taskIds, jobId, commandRunId, message) {
498
+ const now = new Date().toISOString();
499
+ for (const taskId of taskIds) {
500
+ try {
501
+ const run = await this.workspaceRepo.createTaskRun({
502
+ taskId,
503
+ command: "refine-tasks",
504
+ status: "succeeded",
505
+ jobId,
506
+ commandRunId,
507
+ startedAt: now,
508
+ finishedAt: now,
509
+ runContext: { warning: true },
510
+ });
511
+ await this.workspaceRepo.insertTaskLog({
512
+ taskRunId: run.id,
513
+ sequence: 0,
514
+ timestamp: now,
515
+ level: "warn",
516
+ source: "refine-tasks",
517
+ message,
518
+ details: { warning: true },
519
+ });
520
+ }
521
+ catch {
522
+ // Best-effort logging only.
523
+ }
524
+ }
525
+ }
526
+ mergeMetadata(existing, updates) {
527
+ if (!updates)
528
+ return existing;
529
+ return { ...(existing ?? {}), ...updates };
530
+ }
531
+ validateOperation(group, op) {
532
+ const allowedOps = new Set(["update_task", "split_task", "merge_tasks", "update_estimate"]);
533
+ if (!op || typeof op.op !== "string" || !allowedOps.has(op.op)) {
534
+ return { valid: false, reason: "Unknown op type" };
535
+ }
536
+ if (op.op === "update_task") {
537
+ if (!op.taskKey || typeof op.updates !== "object") {
538
+ return { valid: false, reason: "update_task missing taskKey or updates" };
539
+ }
540
+ }
541
+ if (op.op === "split_task") {
542
+ const split = op;
543
+ if (!split.taskKey || !Array.isArray(split.children) || split.children.length === 0) {
544
+ return { valid: false, reason: "split_task missing taskKey or children" };
545
+ }
546
+ }
547
+ if (op.op === "merge_tasks") {
548
+ if (!op.targetTaskKey || !Array.isArray(op.sourceTaskKeys) || op.sourceTaskKeys.length === 0) {
549
+ return { valid: false, reason: "merge_tasks missing targets" };
550
+ }
551
+ }
552
+ if (op.op === "update_estimate") {
553
+ if (!op.taskKey)
554
+ return { valid: false, reason: "update_estimate missing taskKey" };
555
+ }
556
+ const keySet = new Set(group.tasks.map((t) => t.key));
557
+ if (op.taskKey && !keySet.has(op.taskKey)) {
558
+ return { valid: false, reason: `Unknown task key ${op.taskKey} for story ${group.story.key}` };
559
+ }
560
+ if (op.targetTaskKey && !keySet.has(op.targetTaskKey)) {
561
+ return { valid: false, reason: `Unknown merge target ${op.targetTaskKey}` };
562
+ }
563
+ if (op.sourceTaskKeys) {
564
+ const missing = op.sourceTaskKeys.filter((k) => !keySet.has(k));
565
+ if (missing.length) {
566
+ return { valid: false, reason: `Merge sources not in story ${group.story.key}: ${missing.join(", ")}` };
567
+ }
568
+ }
569
+ if (op.op === "update_task" && op.updates.status && FORBIDDEN_TARGET_STATUSES.has(op.updates.status.toLowerCase())) {
570
+ return { valid: false, reason: `Status ${op.updates.status} not allowed in refine-tasks` };
571
+ }
572
+ if (op.op === "update_task" && op.updates.storyPoints !== undefined) {
573
+ const sp = op.updates.storyPoints;
574
+ if (sp !== null && (typeof sp !== "number" || sp < 0 || sp > 13)) {
575
+ return { valid: false, reason: `Story points out of bounds for ${op.taskKey}` };
576
+ }
577
+ }
578
+ if (op.op === "split_task") {
579
+ const split = op;
580
+ const invalidDep = split.children.some((child) => child.dependsOn?.some((dep) => !keySet.has(dep)));
581
+ if (invalidDep) {
582
+ return { valid: false, reason: "Split child references unknown dependency" };
583
+ }
584
+ if (split.children.some((child) => child.storyPoints !== undefined && child.storyPoints !== null && (child.storyPoints < 0 || child.storyPoints > 13))) {
585
+ return { valid: false, reason: "Child story points out of bounds" };
586
+ }
587
+ const crossStory = split.children.some((child) => child.storyKey && child.storyKey !== group.story.key);
588
+ if (crossStory) {
589
+ return { valid: false, reason: "Split children must stay within the same story" };
590
+ }
591
+ }
592
+ if (op.op === "merge_tasks") {
593
+ const crossStory = op.sourceTaskKeys.some((k) => !keySet.has(k)) ||
594
+ (op.targetTaskKey && !keySet.has(op.targetTaskKey));
595
+ if (crossStory) {
596
+ return { valid: false, reason: "Merge must stay within the same story" };
597
+ }
598
+ const uniqueSources = new Set(op.sourceTaskKeys.filter(Boolean));
599
+ if (uniqueSources.size !== op.sourceTaskKeys.length) {
600
+ return { valid: false, reason: "Duplicate source task keys in merge" };
601
+ }
602
+ if (uniqueSources.has(op.targetTaskKey)) {
603
+ return { valid: false, reason: "Merge sources cannot include target" };
604
+ }
605
+ }
606
+ return { valid: true };
607
+ }
608
+ detectCycle(edges) {
609
+ const adj = new Map();
610
+ for (const edge of edges) {
611
+ const list = adj.get(edge.from) ?? [];
612
+ list.push(edge.to);
613
+ adj.set(edge.from, list);
614
+ }
615
+ const visiting = new Set();
616
+ const visited = new Set();
617
+ const dfs = (node) => {
618
+ if (visiting.has(node))
619
+ return true;
620
+ if (visited.has(node))
621
+ return false;
622
+ visiting.add(node);
623
+ for (const nxt of adj.get(node) ?? []) {
624
+ if (dfs(nxt))
625
+ return true;
626
+ }
627
+ visiting.delete(node);
628
+ visited.add(node);
629
+ return false;
630
+ };
631
+ for (const node of adj.keys()) {
632
+ if (dfs(node))
633
+ return true;
634
+ }
635
+ return false;
636
+ }
637
+ async applyOperations(projectId, jobId, commandRunId, group, operations) {
638
+ const created = [];
639
+ const updated = [];
640
+ const cancelled = [];
641
+ let storyPointsDelta = 0;
642
+ const warnings = [];
643
+ const taskByKey = new Map(group.tasks.map((t) => [t.key, t]));
644
+ await this.workspaceRepo.withTransaction(async () => {
645
+ let stage = "start";
646
+ const newTasks = [];
647
+ const pendingDeps = [];
648
+ const dependencyEdges = [];
649
+ try {
650
+ stage = "load:storyKeys";
651
+ const storyKeyRows = await this.workspaceRepo.getDb().all(`SELECT key FROM tasks WHERE user_story_id = ?`, group.story.id);
652
+ const existingKeys = storyKeyRows.map((r) => r.key);
653
+ const keyGen = createTaskKeyGenerator(group.story.key, existingKeys);
654
+ for (const op of operations) {
655
+ stage = `op:${op.op}`;
656
+ if (op.op === "update_task") {
657
+ const target = taskByKey.get(op.taskKey);
658
+ if (!target)
659
+ continue;
660
+ const before = { ...target };
661
+ const metadata = this.mergeMetadata(target.metadata, op.updates.metadata);
662
+ const beforeSp = target.storyPoints ?? 0;
663
+ const afterSp = op.updates.storyPoints ?? target.storyPoints ?? null;
664
+ storyPointsDelta += (afterSp ?? 0) - (beforeSp ?? 0);
665
+ await this.workspaceRepo.updateTask(target.id, {
666
+ title: op.updates.title ?? target.title,
667
+ description: op.updates.description ?? target.description ?? null,
668
+ type: op.updates.type ?? target.type ?? null,
669
+ storyPoints: afterSp,
670
+ priority: op.updates.priority ?? target.priority ?? null,
671
+ status: op.updates.status ?? target.status,
672
+ metadata,
673
+ });
674
+ updated.push(target.key);
675
+ await this.workspaceRepo.insertTaskRevision({
676
+ taskId: target.id,
677
+ jobId,
678
+ commandRunId,
679
+ snapshotBefore: before,
680
+ snapshotAfter: { ...before, ...op.updates, storyPoints: afterSp, metadata },
681
+ createdAt: new Date().toISOString(),
682
+ });
683
+ }
684
+ else if (op.op === "split_task") {
685
+ const target = taskByKey.get(op.taskKey);
686
+ if (!target)
687
+ continue;
688
+ if (op.parentUpdates) {
689
+ const before = { ...target };
690
+ await this.workspaceRepo.updateTask(target.id, {
691
+ title: op.parentUpdates.title ?? target.title,
692
+ description: op.parentUpdates.description ?? target.description ?? null,
693
+ type: op.parentUpdates.type ?? target.type ?? null,
694
+ storyPoints: op.parentUpdates.storyPoints ?? target.storyPoints ?? null,
695
+ priority: op.parentUpdates.priority ?? target.priority ?? null,
696
+ metadata: this.mergeMetadata(target.metadata, op.parentUpdates.metadata),
697
+ });
698
+ updated.push(target.key);
699
+ await this.workspaceRepo.insertTaskRevision({
700
+ taskId: target.id,
701
+ jobId,
702
+ commandRunId,
703
+ snapshotBefore: before,
704
+ snapshotAfter: {
705
+ ...before,
706
+ ...op.parentUpdates,
707
+ storyPoints: op.parentUpdates.storyPoints ?? before.storyPoints,
708
+ metadata: this.mergeMetadata(before.metadata, op.parentUpdates.metadata),
709
+ },
710
+ createdAt: new Date().toISOString(),
711
+ });
712
+ }
713
+ for (const child of op.children) {
714
+ const childKey = keyGen();
715
+ const childSp = child.storyPoints ?? null;
716
+ if (childSp) {
717
+ storyPointsDelta += childSp;
718
+ }
719
+ const childInsert = {
720
+ projectId,
721
+ epicId: target.epicId,
722
+ userStoryId: target.userStoryId,
723
+ key: childKey,
724
+ title: child.title,
725
+ description: child.description ?? target.description ?? "",
726
+ type: child.type ?? target.type ?? "feature",
727
+ status: "not_started",
728
+ storyPoints: childSp,
729
+ priority: child.priority ?? target.priority ?? null,
730
+ metadata: this.mergeMetadata({}, child.metadata),
731
+ assignedAgentId: target.assignedAgentId ?? null,
732
+ assigneeHuman: target.assigneeHuman ?? null,
733
+ vcsBranch: null,
734
+ vcsBaseBranch: null,
735
+ vcsLastCommitSha: null,
736
+ openapiVersionAtCreation: target.openapiVersionAtCreation ?? null,
737
+ };
738
+ newTasks.push(childInsert);
739
+ const dependsOn = child.dependsOn ?? [];
740
+ for (const depKey of dependsOn) {
741
+ const depTask = taskByKey.get(depKey);
742
+ if (depTask) {
743
+ pendingDeps.push({ childKey, dependsOnId: depTask.id, relationType: "blocks" });
744
+ dependencyEdges.push({ from: childKey, to: depTask.key });
745
+ }
746
+ }
747
+ taskByKey.set(childKey, {
748
+ ...childInsert,
749
+ id: "",
750
+ createdAt: "",
751
+ updatedAt: "",
752
+ storyKey: group.story.key,
753
+ epicKey: group.epic.key,
754
+ dependencies: child.dependsOn ?? [],
755
+ });
756
+ created.push(childKey);
757
+ }
758
+ }
759
+ else if (op.op === "merge_tasks") {
760
+ const target = taskByKey.get(op.targetTaskKey);
761
+ if (!target)
762
+ continue;
763
+ if (op.updates) {
764
+ const before = { ...target };
765
+ await this.workspaceRepo.updateTask(target.id, {
766
+ title: op.updates.title ?? target.title,
767
+ description: op.updates.description ?? target.description ?? null,
768
+ type: op.updates.type ?? target.type ?? null,
769
+ storyPoints: op.updates.storyPoints ?? target.storyPoints ?? null,
770
+ priority: op.updates.priority ?? target.priority ?? null,
771
+ metadata: this.mergeMetadata(target.metadata, op.updates.metadata),
772
+ });
773
+ updated.push(target.key);
774
+ await this.workspaceRepo.insertTaskRevision({
775
+ taskId: target.id,
776
+ jobId,
777
+ commandRunId,
778
+ snapshotBefore: before,
779
+ snapshotAfter: {
780
+ ...before,
781
+ ...op.updates,
782
+ storyPoints: op.updates.storyPoints ?? before.storyPoints,
783
+ metadata: this.mergeMetadata(before.metadata, op.updates.metadata),
784
+ },
785
+ createdAt: new Date().toISOString(),
786
+ });
787
+ }
788
+ for (const sourceKey of op.sourceTaskKeys) {
789
+ const source = taskByKey.get(sourceKey);
790
+ if (!source || source.key === target.key)
791
+ continue;
792
+ const before = { ...source };
793
+ const mergedMetadata = this.mergeMetadata(source.metadata, { merged_into: target.key });
794
+ await this.workspaceRepo.updateTask(source.id, {
795
+ status: source.status, // do not cancel; requirement: no deletes/cancels
796
+ metadata: mergedMetadata,
797
+ });
798
+ updated.push(source.key);
799
+ await this.workspaceRepo.insertTaskRevision({
800
+ taskId: source.id,
801
+ jobId,
802
+ commandRunId,
803
+ snapshotBefore: before,
804
+ snapshotAfter: { ...before, metadata: mergedMetadata },
805
+ createdAt: new Date().toISOString(),
806
+ });
807
+ }
808
+ }
809
+ else if (op.op === "update_estimate") {
810
+ const target = taskByKey.get(op.taskKey);
811
+ if (!target)
812
+ continue;
813
+ const beforeSp = target.storyPoints ?? 0;
814
+ const afterSp = op.storyPoints ?? target.storyPoints ?? null;
815
+ storyPointsDelta += (afterSp ?? 0) - (beforeSp ?? 0);
816
+ await this.workspaceRepo.updateTask(target.id, {
817
+ storyPoints: afterSp,
818
+ type: op.type ?? target.type ?? null,
819
+ priority: op.priority ?? target.priority ?? null,
820
+ });
821
+ updated.push(target.key);
822
+ await this.workspaceRepo.insertTaskRevision({
823
+ taskId: target.id,
824
+ jobId,
825
+ commandRunId,
826
+ snapshotBefore: { ...target },
827
+ snapshotAfter: { ...target, storyPoints: afterSp, type: op.type ?? target.type ?? null, priority: op.priority ?? target.priority ?? null },
828
+ createdAt: new Date().toISOString(),
829
+ });
830
+ }
831
+ }
832
+ if (newTasks.length > 0) {
833
+ stage = "insert:newTasks";
834
+ const inserted = await this.workspaceRepo.insertTasks(newTasks, false);
835
+ const idByKey = new Map(inserted.map((t) => [t.key, t.id]));
836
+ for (const row of inserted) {
837
+ const current = taskByKey.get(row.key);
838
+ if (current) {
839
+ current.id = row.id;
840
+ current.createdAt = row.createdAt;
841
+ current.updatedAt = row.updatedAt;
842
+ }
843
+ }
844
+ const deps = [];
845
+ for (const dep of pendingDeps) {
846
+ const childId = idByKey.get(dep.childKey);
847
+ if (childId) {
848
+ deps.push({ taskId: childId, dependsOnTaskId: dep.dependsOnId, relationType: dep.relationType });
849
+ }
850
+ }
851
+ if (deps.length > 0) {
852
+ stage = "insert:deps";
853
+ await this.workspaceRepo.insertTaskDependencies(deps, false);
854
+ }
855
+ }
856
+ // cycle detection on current + new dependencies (by key)
857
+ const edgeSet = [];
858
+ for (const task of group.tasks) {
859
+ for (const dep of task.dependencies) {
860
+ edgeSet.push({ from: task.key, to: dep });
861
+ }
862
+ }
863
+ edgeSet.push(...dependencyEdges);
864
+ const hasCycle = this.detectCycle(edgeSet);
865
+ if (hasCycle) {
866
+ throw new Error("Dependency cycle detected after refinement; aborting apply.");
867
+ }
868
+ stage = "rollup:story";
869
+ const storyTotalRow = await this.workspaceRepo.getDb().get(`SELECT SUM(story_points) AS total FROM tasks WHERE user_story_id = ?`, group.story.id);
870
+ await this.workspaceRepo.updateStoryPointsTotal(group.story.id, storyTotalRow?.total ?? null);
871
+ stage = "rollup:epic";
872
+ const epicTotalRow = await this.workspaceRepo.getDb().get(`SELECT SUM(story_points_total) AS total FROM user_stories WHERE epic_id = ?`, group.epic.id);
873
+ await this.workspaceRepo.updateEpicStoryPointsTotal(group.epic.id, epicTotalRow?.total ?? null);
874
+ stage = "task-runs";
875
+ const allTouched = [...new Set([...created, ...updated, ...cancelled])];
876
+ const now = new Date().toISOString();
877
+ for (const key of allTouched) {
878
+ try {
879
+ const task = group.tasks.find((t) => t.key === key) ??
880
+ (await this.workspaceRepo.getDb().get(`SELECT id FROM tasks WHERE key = ?`, key));
881
+ if (task && task.id) {
882
+ const run = await this.workspaceRepo.createTaskRun({
883
+ taskId: task.id,
884
+ command: "refine-tasks",
885
+ status: "succeeded",
886
+ jobId,
887
+ commandRunId,
888
+ startedAt: now,
889
+ finishedAt: now,
890
+ runContext: { key },
891
+ });
892
+ await this.workspaceRepo.insertTaskLog({
893
+ taskRunId: run.id,
894
+ sequence: 0,
895
+ timestamp: now,
896
+ level: "info",
897
+ source: "refine-tasks",
898
+ message: `Applied refine operation for ${key}`,
899
+ details: { opCount: operations.length },
900
+ });
901
+ }
902
+ }
903
+ catch (error) {
904
+ warnings.push(`Logging failed for ${key}: ${error.message}`);
905
+ }
906
+ }
907
+ }
908
+ catch (error) {
909
+ throw new Error(`refine apply failed at ${stage}: ${error.message}`);
910
+ }
911
+ });
912
+ return { created, updated, cancelled, storyPointsDelta, warnings };
913
+ }
914
+ async invokeAgent(agentName, prompt, stream, jobId, commandRunId, metadata) {
915
+ const startedAt = Date.now();
916
+ const agent = await this.resolveAgent(agentName);
917
+ const parts = [];
918
+ let capturedChars = 0;
919
+ let truncated = false;
920
+ const logChunk = async (chunk) => {
921
+ if (!chunk)
922
+ return;
923
+ await this.jobService.appendLog(jobId, chunk);
924
+ if (stream)
925
+ process.stdout.write(chunk);
926
+ };
927
+ const capture = (chunk) => {
928
+ if (!chunk || truncated)
929
+ return;
930
+ const next = capturedChars + chunk.length;
931
+ if (next > MAX_AGENT_OUTPUT_CHARS) {
932
+ truncated = true;
933
+ return;
934
+ }
935
+ parts.push(chunk);
936
+ capturedChars = next;
937
+ };
938
+ const formatContext = () => {
939
+ const meta = metadata;
940
+ const epic = typeof meta?.epicKey === "string" && meta.epicKey ? ` epic=${meta.epicKey}` : "";
941
+ const story = typeof meta?.storyKey === "string" && meta.storyKey ? ` story=${meta.storyKey}` : "";
942
+ return `${epic}${story}`;
943
+ };
944
+ try {
945
+ if (stream) {
946
+ const gen = await this.agentService.invokeStream(agent.id, { input: prompt, metadata: { jobId, commandRunId } });
947
+ for await (const chunk of gen) {
948
+ const text = chunk.output ?? "";
949
+ capture(text);
950
+ await logChunk(text);
951
+ }
952
+ }
953
+ else {
954
+ const result = await this.agentService.invoke(agent.id, { input: prompt, metadata: { jobId, commandRunId } });
955
+ const text = result.output ?? "";
956
+ capture(text);
957
+ await logChunk(text);
958
+ }
959
+ }
960
+ catch (error) {
961
+ const message = error.message ?? String(error);
962
+ if (message.includes("Invalid string length")) {
963
+ throw new Error(`Agent output exceeded runtime limits (Invalid string length) while refining tasks.${formatContext()} ` +
964
+ `Try rerunning with a smaller scope (e.g. --max-tasks 200, or filter by --epic/--story/--status), or disable streaming (--agent-stream false).`);
965
+ }
966
+ throw error;
967
+ }
968
+ if (truncated) {
969
+ throw new Error(`Agent output exceeded ${MAX_AGENT_OUTPUT_CHARS.toLocaleString()} characters while refining tasks.${formatContext()} ` +
970
+ `Rerun with a smaller scope (e.g. --max-tasks 200, or filter by --epic/--story/--status), or disable streaming (--agent-stream false).`);
971
+ }
972
+ const output = parts.join("");
973
+ const promptTokens = estimateTokens(prompt);
974
+ const completionTokens = estimateTokens(output);
975
+ const durationSeconds = (Date.now() - startedAt) / 1000;
976
+ await this.jobService.recordTokenUsage({
977
+ workspaceId: this.workspace.workspaceId,
978
+ agentId: agent.id,
979
+ modelName: agent.defaultModel,
980
+ jobId,
981
+ commandRunId,
982
+ projectId: undefined,
983
+ epicId: undefined,
984
+ userStoryId: undefined,
985
+ tokensPrompt: promptTokens,
986
+ tokensCompletion: completionTokens,
987
+ tokensTotal: promptTokens + completionTokens,
988
+ durationSeconds,
989
+ timestamp: new Date().toISOString(),
990
+ metadata: { command: "refine-tasks", action: "agent_refine", ...(metadata ?? {}) },
991
+ });
992
+ return { raw: output, promptTokens, completionTokens };
993
+ }
994
+ async refineTasks(options) {
995
+ const strategy = options.strategy ?? DEFAULT_STRATEGY;
996
+ const agentStream = options.agentStream !== false;
997
+ const applyChanges = options.apply === true; // default to no DB writes unless explicitly requested
998
+ const shouldDefaultMaxTasks = options.planInPath == null &&
999
+ options.maxTasks == null &&
1000
+ !options.epicKey &&
1001
+ !(options.userStoryKey ?? options.storyKey) &&
1002
+ !(options.taskKeys && options.taskKeys.length) &&
1003
+ !(options.statusFilter && options.statusFilter.length);
1004
+ await this.workspaceRepo.createProjectIfMissing({
1005
+ key: options.projectKey,
1006
+ name: options.projectKey,
1007
+ description: `Workspace project ${options.projectKey}`,
1008
+ });
1009
+ const commandRun = await this.jobService.startCommandRun("refine-tasks", options.projectKey, {
1010
+ taskIds: options.taskKeys,
1011
+ });
1012
+ const job = await this.jobService.startJob("task_refinement", commandRun.id, options.projectKey, {
1013
+ commandName: "refine-tasks",
1014
+ payload: {
1015
+ projectKey: options.projectKey,
1016
+ epicKey: options.epicKey,
1017
+ storyKey: options.userStoryKey ?? options.storyKey,
1018
+ taskKeys: options.taskKeys,
1019
+ statusFilter: options.statusFilter,
1020
+ strategy,
1021
+ maxTasks: options.maxTasks,
1022
+ dryRun: options.dryRun,
1023
+ fromDb: options.fromDb !== false,
1024
+ planIn: options.planInPath,
1025
+ planOut: options.planOutPath,
1026
+ },
1027
+ });
1028
+ try {
1029
+ if (options.fromDb === false) {
1030
+ throw new Error("refine-tasks currently only supports DB-backed selection; set --from-db true");
1031
+ }
1032
+ const selection = await this.selectTasks(options.projectKey, {
1033
+ epicKey: options.epicKey,
1034
+ storyKey: options.userStoryKey ?? options.storyKey,
1035
+ taskKeys: options.taskKeys,
1036
+ statusFilter: options.statusFilter,
1037
+ maxTasks: shouldDefaultMaxTasks ? DEFAULT_MAX_TASKS : options.maxTasks,
1038
+ excludeAlreadyRefined: options.excludeAlreadyRefined === true,
1039
+ });
1040
+ const plan = {
1041
+ strategy,
1042
+ operations: [],
1043
+ warnings: [...selection.warnings],
1044
+ metadata: {
1045
+ generatedAt: new Date().toISOString(),
1046
+ projectKey: options.projectKey,
1047
+ epicKeys: selection.groups.map((g) => g.epic.key),
1048
+ storyKeys: selection.groups.map((g) => g.story.key),
1049
+ strategy,
1050
+ jobId: job.id,
1051
+ commandRunId: commandRun.id,
1052
+ },
1053
+ };
1054
+ if (selection.groups.length === 0 && !options.planInPath) {
1055
+ if (!options.allowEmptySelection) {
1056
+ throw new Error("No tasks matched the provided filters.");
1057
+ }
1058
+ plan.warnings?.push("No tasks matched the provided filters.");
1059
+ await this.jobService.updateJobStatus(job.id, "completed", {
1060
+ payload: {
1061
+ dryRun: options.dryRun ?? true,
1062
+ operations: 0,
1063
+ applied: false,
1064
+ emptySelection: true,
1065
+ },
1066
+ processedItems: 0,
1067
+ totalItems: 0,
1068
+ lastCheckpoint: "empty_selection",
1069
+ });
1070
+ await this.jobService.finishCommandRun(commandRun.id, "succeeded");
1071
+ return {
1072
+ jobId: job.id,
1073
+ commandRunId: commandRun.id,
1074
+ plan,
1075
+ applied: false,
1076
+ createdTasks: [],
1077
+ updatedTasks: [],
1078
+ cancelledTasks: [],
1079
+ summary: { tasksProcessed: 0, tasksAffected: 0, storyPointsDelta: 0 },
1080
+ };
1081
+ }
1082
+ let planInput;
1083
+ if (options.planInPath) {
1084
+ const raw = await fs.readFile(options.planInPath, "utf8");
1085
+ planInput = safeParsePlan(raw);
1086
+ if (!planInput) {
1087
+ throw new Error(`Failed to parse plan from ${options.planInPath}`);
1088
+ }
1089
+ if (planInput.metadata?.projectKey && planInput.metadata.projectKey !== options.projectKey) {
1090
+ throw new Error(`Plan project mismatch: ${planInput.metadata.projectKey} !== ${options.projectKey}`);
1091
+ }
1092
+ if (planInput.metadata?.jobId && options.jobId && planInput.metadata.jobId !== options.jobId) {
1093
+ throw new Error(`Plan was generated for job ${planInput.metadata.jobId}, mismatch with --job-id ${options.jobId}`);
1094
+ }
1095
+ const mergedMeta = {
1096
+ generatedAt: planInput.metadata?.generatedAt ?? plan.metadata?.generatedAt ?? new Date().toISOString(),
1097
+ projectKey: planInput.metadata?.projectKey ?? plan.metadata?.projectKey ?? options.projectKey,
1098
+ epicKeys: planInput.metadata?.epicKeys ?? plan.metadata?.epicKeys,
1099
+ storyKeys: planInput.metadata?.storyKeys ?? plan.metadata?.storyKeys,
1100
+ jobId: planInput.metadata?.jobId ?? plan.metadata?.jobId,
1101
+ commandRunId: planInput.metadata?.commandRunId ?? plan.metadata?.commandRunId,
1102
+ strategy: planInput.metadata?.strategy ?? plan.metadata?.strategy ?? strategy,
1103
+ };
1104
+ plan.metadata = mergedMeta;
1105
+ if (planInput.warnings)
1106
+ plan.warnings?.push(...planInput.warnings);
1107
+ // Validate ops against current selection and group membership.
1108
+ const taskToGroup = new Map();
1109
+ selection.groups.forEach((g) => g.tasks.forEach((t) => taskToGroup.set(t.key, g)));
1110
+ const allowCreateMissingPlanIn = false;
1111
+ for (const rawOp of planInput.operations) {
1112
+ const op = normalizeOperation(rawOp);
1113
+ const keyCandidate = op.taskKey ?? op.targetTaskKey ?? null;
1114
+ let group = keyCandidate ? taskToGroup.get(keyCandidate) : undefined;
1115
+ if (!group && allowCreateMissingPlanIn && keyCandidate) {
1116
+ const ensured = await this.ensureTaskExists(selection.projectId, options.projectKey, keyCandidate, true, op);
1117
+ if (ensured) {
1118
+ group =
1119
+ selection.groups.find((g) => g.story.key === ensured.story.key) ??
1120
+ (() => {
1121
+ const newGroup = {
1122
+ epic: ensured.epic,
1123
+ story: ensured.story,
1124
+ tasks: [],
1125
+ };
1126
+ selection.groups.push(newGroup);
1127
+ return newGroup;
1128
+ })();
1129
+ group.tasks.push(ensured.task);
1130
+ taskToGroup.set(keyCandidate, group);
1131
+ }
1132
+ }
1133
+ if (!group) {
1134
+ plan.warnings?.push(`Skipped plan-in op because task key not in selection: ${keyCandidate ?? op.op}`);
1135
+ continue;
1136
+ }
1137
+ const { valid, reason } = this.validateOperation(group, op);
1138
+ if (!valid) {
1139
+ if (reason)
1140
+ plan.warnings?.push(`Skipped plan-in op: ${reason}`);
1141
+ continue;
1142
+ }
1143
+ plan.operations.push(op);
1144
+ }
1145
+ }
1146
+ if (!planInput) {
1147
+ if (shouldDefaultMaxTasks) {
1148
+ plan.warnings?.push(`No filters were provided; defaulted --max-tasks to ${DEFAULT_MAX_TASKS} to keep refinement tractable. Pass --max-tasks explicitly to override.`);
1149
+ }
1150
+ for (const group of selection.groups) {
1151
+ try {
1152
+ const { summary: docSummary, warnings: docWarnings } = await this.summarizeDocs(options.projectKey, group.epic.key, group.story.key);
1153
+ group.docSummary = docSummary;
1154
+ const historySummary = await this.summarizeHistory(group.tasks.map((t) => t.id));
1155
+ group.historySummary = historySummary;
1156
+ await this.jobService.writeCheckpoint(job.id, {
1157
+ stage: "context_built",
1158
+ timestamp: new Date().toISOString(),
1159
+ details: { epic: group.epic.key, story: group.story.key, tasks: group.tasks.length },
1160
+ });
1161
+ if (docWarnings.length) {
1162
+ plan.warnings?.push(...docWarnings);
1163
+ // eslint-disable-next-line no-console
1164
+ console.warn(docWarnings.join("; "));
1165
+ await this.jobService.appendLog(job.id, docWarnings.join("\n"));
1166
+ await this.logWarningsToTasks(group.tasks.map((t) => t.id), job.id, commandRun.id, docWarnings.join("; "));
1167
+ }
1168
+ const prompt = this.buildStoryPrompt(group, strategy, docSummary);
1169
+ const { raw } = await this.invokeAgent(options.agentName, prompt, agentStream, job.id, commandRun.id, { epicKey: group.epic.key, storyKey: group.story.key });
1170
+ const parsed = extractJson(raw);
1171
+ const ops = parsed?.operations && Array.isArray(parsed.operations) ? parsed.operations : [];
1172
+ const normalized = ops.map(normalizeOperation);
1173
+ const filtered = normalized.filter((op) => {
1174
+ const { valid, reason } = this.validateOperation(group, op);
1175
+ if (!valid && reason) {
1176
+ plan.warnings?.push(`Skipped op for story ${group.story.key}: ${reason}`);
1177
+ }
1178
+ return valid;
1179
+ });
1180
+ plan.operations.push(...filtered);
1181
+ }
1182
+ catch (error) {
1183
+ throw new Error(`Failed while refining epic ${group.epic.key} story ${group.story.key}: ${error.message}`);
1184
+ }
1185
+ }
1186
+ }
1187
+ // Always persist the plan to disk in a unique folder (similar to create-tasks)
1188
+ const ensureUniquePath = async (candidate) => {
1189
+ try {
1190
+ await fs.access(candidate);
1191
+ const dir = path.dirname(candidate);
1192
+ const base = path.basename(candidate, path.extname(candidate));
1193
+ const ext = path.extname(candidate) || ".json";
1194
+ const suffix = new Date().toISOString().replace(/[:.]/g, "-");
1195
+ return path.join(dir, `${base}-${suffix}${ext}`);
1196
+ }
1197
+ catch {
1198
+ return candidate;
1199
+ }
1200
+ };
1201
+ const defaultPlanPath = path.join(this.workspace.workspaceRoot, ".mcoda", "tasks", options.projectKey, "refinements", job.id, "plan.json");
1202
+ const requestedOutPath = options.planOutPath ? path.resolve(options.planOutPath) : defaultPlanPath;
1203
+ const outPath = await ensureUniquePath(requestedOutPath);
1204
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
1205
+ await fs.writeFile(outPath, JSON.stringify(plan, null, 2), "utf8");
1206
+ await this.jobService.writeCheckpoint(job.id, {
1207
+ stage: "plan_written",
1208
+ timestamp: new Date().toISOString(),
1209
+ details: { path: outPath, ops: plan.operations.length },
1210
+ });
1211
+ if (plan.operations.length === 0) {
1212
+ await this.jobService.updateJobStatus(job.id, "completed", {
1213
+ payload: {
1214
+ dryRun: options.dryRun ?? !applyChanges,
1215
+ operations: 0,
1216
+ planPath: outPath,
1217
+ applied: false,
1218
+ reason: "no_operations",
1219
+ },
1220
+ processedItems: 0,
1221
+ totalItems: 0,
1222
+ lastCheckpoint: "no_operations",
1223
+ });
1224
+ await this.jobService.finishCommandRun(commandRun.id, "succeeded");
1225
+ return {
1226
+ jobId: job.id,
1227
+ commandRunId: commandRun.id,
1228
+ plan,
1229
+ applied: false,
1230
+ createdTasks: [],
1231
+ updatedTasks: [],
1232
+ cancelledTasks: [],
1233
+ summary: { tasksProcessed: selection.groups.reduce((acc, g) => acc + g.tasks.length, 0), tasksAffected: 0, storyPointsDelta: 0 },
1234
+ };
1235
+ }
1236
+ if (options.dryRun || !applyChanges) {
1237
+ await this.jobService.updateJobStatus(job.id, "completed", {
1238
+ payload: {
1239
+ dryRun: options.dryRun ?? true,
1240
+ operations: plan.operations.length,
1241
+ planPath: outPath,
1242
+ applied: false,
1243
+ },
1244
+ processedItems: plan.operations.length,
1245
+ totalItems: plan.operations.length,
1246
+ lastCheckpoint: "dry_run",
1247
+ });
1248
+ await this.jobService.finishCommandRun(commandRun.id, "succeeded");
1249
+ return {
1250
+ jobId: job.id,
1251
+ commandRunId: commandRun.id,
1252
+ plan,
1253
+ applied: false,
1254
+ createdTasks: [],
1255
+ updatedTasks: [],
1256
+ cancelledTasks: [],
1257
+ summary: { tasksProcessed: selection.groups.reduce((acc, g) => acc + g.tasks.length, 0), tasksAffected: 0 },
1258
+ };
1259
+ }
1260
+ const created = [];
1261
+ const updated = [];
1262
+ const cancelled = [];
1263
+ let storyPointsDelta = 0;
1264
+ const operationsByStory = new Map();
1265
+ for (const op of plan.operations) {
1266
+ const key = op.taskKey ?? op.targetTaskKey ?? null;
1267
+ if (!key)
1268
+ continue;
1269
+ const group = selection.groups.find((g) => g.tasks.some((t) => t.key === key));
1270
+ if (!group)
1271
+ continue;
1272
+ const list = operationsByStory.get(group.story.id) ?? [];
1273
+ list.push(op);
1274
+ operationsByStory.set(group.story.id, list);
1275
+ }
1276
+ for (const group of selection.groups) {
1277
+ const ops = operationsByStory.get(group.story.id) ?? [];
1278
+ if (ops.length === 0)
1279
+ continue;
1280
+ const { created: c, updated: u, cancelled: x, storyPointsDelta: delta, warnings: opWarnings } = await this.applyOperations(selection.projectId, job.id, commandRun.id, group, ops);
1281
+ await this.jobService.writeCheckpoint(job.id, {
1282
+ stage: "story_applied",
1283
+ timestamp: new Date().toISOString(),
1284
+ details: { epic: group.epic.key, story: group.story.key, ops: ops.length, created: c.length, updated: u.length, cancelled: x.length },
1285
+ });
1286
+ if (opWarnings.length) {
1287
+ plan.warnings?.push(...opWarnings);
1288
+ }
1289
+ created.push(...c);
1290
+ updated.push(...u);
1291
+ cancelled.push(...x);
1292
+ storyPointsDelta += delta;
1293
+ }
1294
+ await this.jobService.updateJobStatus(job.id, "completed", {
1295
+ payload: {
1296
+ created: created.length,
1297
+ updated: updated.length,
1298
+ cancelled: cancelled.length,
1299
+ storyPointsDelta,
1300
+ },
1301
+ processedItems: created.length + updated.length + cancelled.length,
1302
+ totalItems: plan.operations.length,
1303
+ lastCheckpoint: "completed",
1304
+ });
1305
+ await this.jobService.finishCommandRun(commandRun.id, "succeeded");
1306
+ return {
1307
+ jobId: job.id,
1308
+ commandRunId: commandRun.id,
1309
+ plan,
1310
+ applied: true,
1311
+ createdTasks: created,
1312
+ updatedTasks: updated,
1313
+ cancelledTasks: cancelled,
1314
+ summary: {
1315
+ tasksProcessed: selection.groups.reduce((acc, g) => acc + g.tasks.length, 0),
1316
+ tasksAffected: created.length + updated.length + cancelled.length,
1317
+ storyPointsDelta,
1318
+ },
1319
+ };
1320
+ }
1321
+ catch (error) {
1322
+ const message = error.message;
1323
+ await this.jobService.updateJobStatus(job.id, "failed", { errorSummary: message });
1324
+ await this.jobService.finishCommandRun(commandRun.id, "failed", message);
1325
+ throw error;
1326
+ }
1327
+ }
1328
+ }