@mcoda/core 0.1.36 → 0.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/services/planning/CreateTasksService.d.ts +123 -1
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +4790 -888
- package/dist/services/planning/RefineTasksService.d.ts +10 -1
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +927 -216
- package/dist/services/planning/TaskSufficiencyService.d.ts.map +1 -1
- package/dist/services/planning/TaskSufficiencyService.js +147 -9
- package/package.json +6 -6
|
@@ -11,6 +11,7 @@ import { RoutingService } from "../agents/RoutingService.js";
|
|
|
11
11
|
import { AgentRatingService } from "../agents/AgentRatingService.js";
|
|
12
12
|
import { classifyTask } from "../backlog/TaskOrderingHeuristics.js";
|
|
13
13
|
import { TaskOrderingService } from "../backlog/TaskOrderingService.js";
|
|
14
|
+
import { QaTestCommandBuilder } from "../execution/QaTestCommandBuilder.js";
|
|
14
15
|
import { createTaskKeyGenerator } from "./KeyHelpers.js";
|
|
15
16
|
const DEFAULT_STRATEGY = "auto";
|
|
16
17
|
const FORBIDDEN_TARGET_STATUSES = new Set([READY_TO_CODE_REVIEW, "ready_to_qa", "completed"]);
|
|
@@ -24,8 +25,250 @@ const MAX_AGENT_OUTPUT_CHARS = 10000000;
|
|
|
24
25
|
const PLANNING_DOC_HINT_PATTERN = /(sds|pdr|rfp|requirements|architecture|openapi|swagger|design)/i;
|
|
25
26
|
const OPENAPI_METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
|
|
26
27
|
const OPENAPI_HINTS_LIMIT = 20;
|
|
28
|
+
const DOCDEX_HANDLE = /^docdex:/i;
|
|
29
|
+
const DOCDEX_LOCAL_HANDLE = /^docdex:local[-:/]/i;
|
|
30
|
+
const RELATED_DOC_PATH_PATTERN = /^(?:~\/|\/|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+\/)[A-Za-z0-9._/-]+(?:\.[A-Za-z0-9._-]+)?(?:#[A-Za-z0-9._:-]+)?$/;
|
|
31
|
+
const RELATIVE_DOC_PATH_PATTERN = /^(?:\.{1,2}\/)+[A-Za-z0-9._/-]+(?:\.[A-Za-z0-9._-]+)?(?:#[A-Za-z0-9._:-]+)?$/;
|
|
32
|
+
const REPO_PATH_PATTERN = /(?:^|[\s`"'(])((?:\.{1,2}\/)?(?:[A-Za-z0-9@._-]+\/)+(?:[A-Za-z0-9@._-]+(?:\.[A-Za-z0-9._-]+)?))(?:$|[\s`"',):;])/g;
|
|
33
|
+
const LIKELY_REPO_ROOTS = new Set([
|
|
34
|
+
"apps",
|
|
35
|
+
"api",
|
|
36
|
+
"bin",
|
|
37
|
+
"cmd",
|
|
38
|
+
"components",
|
|
39
|
+
"configs",
|
|
40
|
+
"contracts",
|
|
41
|
+
"consoles",
|
|
42
|
+
"docs",
|
|
43
|
+
"engines",
|
|
44
|
+
"lib",
|
|
45
|
+
"ops",
|
|
46
|
+
"openapi",
|
|
47
|
+
"packages",
|
|
48
|
+
"scripts",
|
|
49
|
+
"services",
|
|
50
|
+
"src",
|
|
51
|
+
"test",
|
|
52
|
+
"tests",
|
|
53
|
+
]);
|
|
54
|
+
const IMPLEMENTATION_VERB_PATTERN = /\b(implement|add|update|wire|create|build|fix|persist|register|normalize|connect|expose|refactor|seed|migrate|enforce|run|validate|test)\b/i;
|
|
55
|
+
const META_TASK_PATTERN = /\b(map|inventory|identify|analyze|scope|capture evidence|document baseline|baseline|research|review requirements|assess|audit|plan(?:\s+out)?)\b/i;
|
|
56
|
+
const DOCS_OR_COORDINATION_PATTERN = /\b(document|docs?|guide|runbook|playbook|policy|audit|review|report|analysis|investigation|evidence|baseline)\b/i;
|
|
57
|
+
const VERIFICATION_TASK_PATTERN = /\b(test|tests|verify|verification|scenario|smoke|acceptance|regression|qa|harness|fixture|assert|coverage|evidence)\b/i;
|
|
58
|
+
const RUNNABLE_TEST_REFERENCE_PATTERN = /\b(pnpm|npm|yarn|node|pytest|go test|cargo test|dotnet test|mvn|gradle|jest|ava|mocha|cypress|playwright)\b|(?:^|\/)(tests?|__tests__|specs?)(?:\/|$)|\.(?:test|spec)\.[A-Za-z0-9]+/i;
|
|
27
59
|
const estimateTokens = (text) => Math.max(1, Math.ceil(text.length / 4));
|
|
28
60
|
const isPlainObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
61
|
+
const isOperationObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
62
|
+
const emptyTestRequirements = () => ({ unit: [], component: [], integration: [], api: [] });
|
|
63
|
+
const uniqueStrings = (values) => Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
64
|
+
const truncate = (value, max = 140) => (value.length > max ? `${value.slice(0, max - 3)}...` : value);
|
|
65
|
+
const normalizeStringArray = (value) => {
|
|
66
|
+
if (typeof value === "string") {
|
|
67
|
+
const trimmed = value.trim();
|
|
68
|
+
return trimmed ? [trimmed] : [];
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(value))
|
|
71
|
+
return [];
|
|
72
|
+
return value
|
|
73
|
+
.filter((entry) => typeof entry === "string")
|
|
74
|
+
.map((entry) => entry.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
};
|
|
77
|
+
const normalizeRelatedDocs = (value) => {
|
|
78
|
+
if (!Array.isArray(value))
|
|
79
|
+
return [];
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const normalized = [];
|
|
82
|
+
for (const entry of value) {
|
|
83
|
+
const candidate = typeof entry === "string"
|
|
84
|
+
? entry.trim()
|
|
85
|
+
: entry && typeof entry === "object" && "handle" in entry && typeof entry.handle === "string"
|
|
86
|
+
? entry.handle.trim()
|
|
87
|
+
: "";
|
|
88
|
+
if (!candidate)
|
|
89
|
+
continue;
|
|
90
|
+
if (DOCDEX_LOCAL_HANDLE.test(candidate))
|
|
91
|
+
continue;
|
|
92
|
+
const isDocHandle = DOCDEX_HANDLE.test(candidate);
|
|
93
|
+
const isHttp = /^https?:\/\/\S+$/i.test(candidate);
|
|
94
|
+
const isPath = RELATED_DOC_PATH_PATTERN.test(candidate) || RELATIVE_DOC_PATH_PATTERN.test(candidate);
|
|
95
|
+
if (!isDocHandle && !isHttp && !isPath)
|
|
96
|
+
continue;
|
|
97
|
+
if (seen.has(candidate))
|
|
98
|
+
continue;
|
|
99
|
+
seen.add(candidate);
|
|
100
|
+
normalized.push(candidate);
|
|
101
|
+
}
|
|
102
|
+
return normalized;
|
|
103
|
+
};
|
|
104
|
+
const normalizeTestRequirements = (value) => {
|
|
105
|
+
const raw = isPlainObject(value) ? value : {};
|
|
106
|
+
return {
|
|
107
|
+
unit: normalizeStringArray(raw.unit),
|
|
108
|
+
component: normalizeStringArray(raw.component),
|
|
109
|
+
integration: normalizeStringArray(raw.integration),
|
|
110
|
+
api: normalizeStringArray(raw.api),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const mergeTestRequirements = (...sets) => {
|
|
114
|
+
const merged = emptyTestRequirements();
|
|
115
|
+
for (const value of sets) {
|
|
116
|
+
if (!value)
|
|
117
|
+
continue;
|
|
118
|
+
merged.unit = uniqueStrings([...merged.unit, ...value.unit]);
|
|
119
|
+
merged.component = uniqueStrings([...merged.component, ...value.component]);
|
|
120
|
+
merged.integration = uniqueStrings([...merged.integration, ...value.integration]);
|
|
121
|
+
merged.api = uniqueStrings([...merged.api, ...value.api]);
|
|
122
|
+
}
|
|
123
|
+
return merged;
|
|
124
|
+
};
|
|
125
|
+
const hasTestRequirements = (value) => value.unit.length > 0 || value.component.length > 0 || value.integration.length > 0 || value.api.length > 0;
|
|
126
|
+
const summarizeTestRequirements = (value) => {
|
|
127
|
+
const parts = [
|
|
128
|
+
value.unit.length ? `unit=${value.unit.join("; ")}` : "",
|
|
129
|
+
value.component.length ? `component=${value.component.join("; ")}` : "",
|
|
130
|
+
value.integration.length ? `integration=${value.integration.join("; ")}` : "",
|
|
131
|
+
value.api.length ? `api=${value.api.join("; ")}` : "",
|
|
132
|
+
].filter(Boolean);
|
|
133
|
+
return parts.join(" | ");
|
|
134
|
+
};
|
|
135
|
+
const sanitizePathCandidate = (candidate) => candidate.replace(/^[`"'(]+/, "").replace(/[`"'),.;:]+$/, "").trim();
|
|
136
|
+
const isLikelyRepoPath = (candidate) => {
|
|
137
|
+
const cleaned = sanitizePathCandidate(candidate);
|
|
138
|
+
if (!cleaned || /^https?:/i.test(cleaned) || DOCDEX_HANDLE.test(cleaned))
|
|
139
|
+
return false;
|
|
140
|
+
const normalized = cleaned.replace(/^\.\/+/, "").replace(/^\.\.\/+/, "");
|
|
141
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
142
|
+
if (parts.length < 2)
|
|
143
|
+
return false;
|
|
144
|
+
const root = parts[0]?.toLowerCase() ?? "";
|
|
145
|
+
if (LIKELY_REPO_ROOTS.has(root))
|
|
146
|
+
return true;
|
|
147
|
+
return /\.[A-Za-z0-9_-]+$/.test(parts[parts.length - 1] ?? "");
|
|
148
|
+
};
|
|
149
|
+
const extractRepoPaths = (value) => {
|
|
150
|
+
if (!value)
|
|
151
|
+
return [];
|
|
152
|
+
const matches = new Set();
|
|
153
|
+
const source = value.replace(/\r/g, "");
|
|
154
|
+
REPO_PATH_PATTERN.lastIndex = 0;
|
|
155
|
+
let match;
|
|
156
|
+
while ((match = REPO_PATH_PATTERN.exec(source)) !== null) {
|
|
157
|
+
const candidate = sanitizePathCandidate(match[1] ?? "");
|
|
158
|
+
if (!isLikelyRepoPath(candidate))
|
|
159
|
+
continue;
|
|
160
|
+
matches.add(candidate.replace(/^\.\/+/, ""));
|
|
161
|
+
}
|
|
162
|
+
return Array.from(matches);
|
|
163
|
+
};
|
|
164
|
+
const isLikelyTestPath = (candidate) => /(?:^|\/)(?:tests?|__tests__|specs?)(?:\/|$)|\.(?:test|spec)\.[A-Za-z0-9]+$/i.test(candidate);
|
|
165
|
+
const classifyRepoPathKind = (candidate) => {
|
|
166
|
+
const normalized = sanitizePathCandidate(candidate).replace(/\\/g, "/").toLowerCase();
|
|
167
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
168
|
+
const basename = parts[parts.length - 1] ?? normalized;
|
|
169
|
+
if (parts.some((part) => ["docs", "rfp", "pdr", "sds"].includes(part)) ||
|
|
170
|
+
/\.(?:md|mdx|txt|rst|adoc)$/i.test(basename)) {
|
|
171
|
+
return "doc";
|
|
172
|
+
}
|
|
173
|
+
if (isLikelyTestPath(normalized))
|
|
174
|
+
return "test";
|
|
175
|
+
if (parts.some((part) => ["ops", "scripts", "deploy", "deployment", "infra", "systemd", "terraform"].includes(part))) {
|
|
176
|
+
return "ops";
|
|
177
|
+
}
|
|
178
|
+
if (parts.some((part) => ["contract", "contracts", "interface", "interfaces", "schema", "schemas", "types", "proto", "protocol"].includes(part))) {
|
|
179
|
+
return "interface";
|
|
180
|
+
}
|
|
181
|
+
if (parts.some((part) => ["db", "data", "storage", "cache", "migrations", "migration", "models", "repositories"].includes(part))) {
|
|
182
|
+
return "data";
|
|
183
|
+
}
|
|
184
|
+
if (parts.some((part) => ["src", "app", "apps", "service", "services", "packages", "worker", "workers", "server", "client", "web", "ui", "lib", "core"].includes(part))) {
|
|
185
|
+
return "runtime";
|
|
186
|
+
}
|
|
187
|
+
return "unknown";
|
|
188
|
+
};
|
|
189
|
+
const isStrongExecutionPath = (candidate) => {
|
|
190
|
+
const kind = classifyRepoPathKind(candidate);
|
|
191
|
+
return kind === "runtime" || kind === "interface" || kind === "data" || kind === "test" || kind === "ops";
|
|
192
|
+
};
|
|
193
|
+
const getRepoPathRoot = (candidate) => sanitizePathCandidate(candidate)
|
|
194
|
+
.replace(/\\/g, "/")
|
|
195
|
+
.replace(/^\.\/+/, "")
|
|
196
|
+
.split("/")
|
|
197
|
+
.filter(Boolean)[0];
|
|
198
|
+
const basenameWithoutExt = (targetPath) => {
|
|
199
|
+
const base = path.basename(targetPath.trim());
|
|
200
|
+
const ext = path.extname(base);
|
|
201
|
+
return ext ? base.slice(0, base.length - ext.length) : base;
|
|
202
|
+
};
|
|
203
|
+
const tokenize = (value) => (value ?? "")
|
|
204
|
+
.toLowerCase()
|
|
205
|
+
.split(/[^a-z0-9]+/g)
|
|
206
|
+
.map((token) => token.trim())
|
|
207
|
+
.filter(Boolean);
|
|
208
|
+
const scoreTargetMatch = (text, targetPath) => {
|
|
209
|
+
const lower = text.toLowerCase();
|
|
210
|
+
const normalizedTarget = targetPath.toLowerCase();
|
|
211
|
+
if (lower.includes(normalizedTarget))
|
|
212
|
+
return 100;
|
|
213
|
+
const base = path.basename(targetPath).toLowerCase();
|
|
214
|
+
if (base && lower.includes(base))
|
|
215
|
+
return 80;
|
|
216
|
+
const stem = basenameWithoutExt(targetPath).toLowerCase();
|
|
217
|
+
if (stem && lower.includes(stem))
|
|
218
|
+
return 60;
|
|
219
|
+
const tokens = new Set(tokenize(text));
|
|
220
|
+
let score = 0;
|
|
221
|
+
for (const part of tokenize(stem)) {
|
|
222
|
+
if (tokens.has(part))
|
|
223
|
+
score += 10;
|
|
224
|
+
}
|
|
225
|
+
return score;
|
|
226
|
+
};
|
|
227
|
+
const selectRelevantTargets = (text, candidates, limit = 3) => candidates
|
|
228
|
+
.map((candidate) => ({ candidate, score: scoreTargetMatch(text, candidate) }))
|
|
229
|
+
.filter((entry) => entry.score > 0)
|
|
230
|
+
.sort((left, right) => right.score - left.score || left.candidate.localeCompare(right.candidate))
|
|
231
|
+
.slice(0, limit)
|
|
232
|
+
.map((entry) => entry.candidate);
|
|
233
|
+
const inferTestRequirementsFromTargets = (targets) => {
|
|
234
|
+
const inferred = emptyTestRequirements();
|
|
235
|
+
for (const target of targets) {
|
|
236
|
+
const bucket = /(?:^|\/)api(?:\/|$)/i.test(target)
|
|
237
|
+
? inferred.api
|
|
238
|
+
: /(?:^|\/)(?:integration|int)(?:\/|$)/i.test(target)
|
|
239
|
+
? inferred.integration
|
|
240
|
+
: /(?:^|\/)component(?:\/|$)/i.test(target)
|
|
241
|
+
? inferred.component
|
|
242
|
+
: /(?:^|\/)unit(?:\/|$)/i.test(target)
|
|
243
|
+
? inferred.unit
|
|
244
|
+
: inferred.integration;
|
|
245
|
+
bucket.push(target);
|
|
246
|
+
}
|
|
247
|
+
inferred.unit = uniqueStrings(inferred.unit);
|
|
248
|
+
inferred.component = uniqueStrings(inferred.component);
|
|
249
|
+
inferred.integration = uniqueStrings(inferred.integration);
|
|
250
|
+
inferred.api = uniqueStrings(inferred.api);
|
|
251
|
+
return inferred;
|
|
252
|
+
};
|
|
253
|
+
const isDocsOrCoordinationTask = (text) => DOCS_OR_COORDINATION_PATTERN.test(text) && !IMPLEMENTATION_VERB_PATTERN.test(text);
|
|
254
|
+
const isMetaTaskText = (text) => META_TASK_PATTERN.test(text) && !IMPLEMENTATION_VERB_PATTERN.test(text);
|
|
255
|
+
const isVerificationTask = (text) => VERIFICATION_TASK_PATTERN.test(text);
|
|
256
|
+
const hasRunnableTestReference = (text, metadata, testTargets) => RUNNABLE_TEST_REFERENCE_PATTERN.test(text) ||
|
|
257
|
+
normalizeStringArray(metadata.tests).length > 0 ||
|
|
258
|
+
normalizeStringArray(metadata.testCommands).length > 0 ||
|
|
259
|
+
hasTestRequirements(mergeTestRequirements(normalizeTestRequirements(metadata.test_requirements), normalizeTestRequirements(metadata.testRequirements))) ||
|
|
260
|
+
selectRelevantTargets(text, testTargets, 2).length > 0;
|
|
261
|
+
const REFINE_OUTPUT_CONTRACT = [
|
|
262
|
+
"STRICT OUTPUT CONTRACT:",
|
|
263
|
+
"- Use only the context already included in this prompt.",
|
|
264
|
+
"- Do not claim you are loading, checking, inspecting, searching, or gathering more context.",
|
|
265
|
+
"- Do not mention Docdex, repo scans, index checks, or tool usage.",
|
|
266
|
+
"- Do not narrate your process or provide status updates.",
|
|
267
|
+
"- Do not ask follow-up questions.",
|
|
268
|
+
"- Respond with JSON only (no prose, no markdown, no code fences).",
|
|
269
|
+
"- Output must be exactly one JSON object that starts with '{' and ends with '}'.",
|
|
270
|
+
'- If no safe refinement is possible, return {"operations":[]}.',
|
|
271
|
+
].join("\n");
|
|
29
272
|
const parseStructuredDoc = (raw) => {
|
|
30
273
|
if (!raw || raw.trim().length === 0)
|
|
31
274
|
return undefined;
|
|
@@ -186,14 +429,30 @@ const safeParsePlan = (content) => {
|
|
|
186
429
|
return normalizePlanJson(parsed);
|
|
187
430
|
};
|
|
188
431
|
const formatTaskSummary = (task) => {
|
|
432
|
+
const metadata = isPlainObject(task.metadata) ? task.metadata : {};
|
|
433
|
+
const files = normalizeStringArray(metadata.files).slice(0, 4);
|
|
434
|
+
const docLinks = normalizeRelatedDocs(metadata.doc_links).slice(0, 4);
|
|
435
|
+
const testCommands = uniqueStrings([
|
|
436
|
+
...normalizeStringArray(metadata.tests),
|
|
437
|
+
...normalizeStringArray(metadata.testCommands),
|
|
438
|
+
]).slice(0, 3);
|
|
439
|
+
const testRequirements = mergeTestRequirements(normalizeTestRequirements(metadata.test_requirements), normalizeTestRequirements(metadata.testRequirements));
|
|
189
440
|
return [
|
|
190
441
|
`- ${task.key}: ${task.title} [${task.status}${task.type ? `/${task.type}` : ""}]`,
|
|
191
442
|
task.storyPoints !== null && task.storyPoints !== undefined ? ` SP: ${task.storyPoints}` : "",
|
|
192
443
|
task.dependencies.length ? ` Depends on: ${task.dependencies.join(", ")}` : "",
|
|
444
|
+
files.length ? ` Files: ${files.join(", ")}` : "",
|
|
445
|
+
docLinks.length ? ` Docs: ${docLinks.join(", ")}` : "",
|
|
446
|
+
hasTestRequirements(testRequirements) ? ` Test requirements: ${summarizeTestRequirements(testRequirements)}` : "",
|
|
447
|
+
testCommands.length ? ` Test commands: ${testCommands.join(" && ")}` : "",
|
|
448
|
+
typeof metadata.stage === "string" ? ` Stage: ${metadata.stage}` : "",
|
|
449
|
+
typeof metadata.foundation === "boolean" ? ` Foundation: ${String(metadata.foundation)}` : "",
|
|
193
450
|
]
|
|
194
451
|
.filter(Boolean)
|
|
195
452
|
.join("\n");
|
|
196
453
|
};
|
|
454
|
+
const formatTargetSummary = (label, values, limit = 6) => values.length > 0 ? `- ${label}: ${values.slice(0, limit).join(", ")}` : `- ${label}: (none)`;
|
|
455
|
+
const summarizeQualityIssues = (issues, limit = 6) => issues.slice(0, limit).map((issue) => `- ${issue.taskKey}: ${issue.message}`);
|
|
197
456
|
const splitChildReferenceFields = ["taskKey", "key", "localId", "id", "slug", "alias", "ref"];
|
|
198
457
|
const normalizeSplitDependencyRef = (value) => value.trim().toLowerCase();
|
|
199
458
|
const collectSplitChildReferences = (child) => {
|
|
@@ -618,6 +877,18 @@ export class RefineTasksService {
|
|
|
618
877
|
story,
|
|
619
878
|
};
|
|
620
879
|
}
|
|
880
|
+
buildStoryExecutionContext(group) {
|
|
881
|
+
const suggestedTests = group.suggestedTestRequirements ?? emptyTestRequirements();
|
|
882
|
+
return [
|
|
883
|
+
formatTargetSummary("Architecture roots", group.architectureRoots ?? []),
|
|
884
|
+
formatTargetSummary("Implementation targets", group.implementationTargets ?? []),
|
|
885
|
+
formatTargetSummary("Test targets", group.testTargets ?? []),
|
|
886
|
+
formatTargetSummary("Doc links to preserve in metadata", group.docLinks ?? []),
|
|
887
|
+
hasTestRequirements(suggestedTests)
|
|
888
|
+
? `- Suggested test requirements: ${summarizeTestRequirements(suggestedTests)}`
|
|
889
|
+
: "- Suggested test requirements: (none)",
|
|
890
|
+
].join("\n");
|
|
891
|
+
}
|
|
621
892
|
buildStoryPrompt(group, strategy, docSummary) {
|
|
622
893
|
const taskList = group.tasks.map((t) => formatTaskSummary(t)).join("\n");
|
|
623
894
|
const constraints = [
|
|
@@ -626,30 +897,61 @@ export class RefineTasksService {
|
|
|
626
897
|
"- Splits: children stay under same story; keep parent unless keepParent=false; child dependsOn must reference existing tasks or siblings.",
|
|
627
898
|
"- Merges: target and sources must be in same story; prefer cancelling redundant sources (status=cancelled) and preserve useful details in target updates.",
|
|
628
899
|
"- Dependencies: maintain DAG; do not introduce cycles or cross-story edges.",
|
|
629
|
-
"-
|
|
900
|
+
"- Implementation tasks should name concrete repo targets when the context provides them. Prefer exact file paths, module names, scripts, or test entrypoints over generic area labels.",
|
|
901
|
+
"- When architecture roots are provided, keep new implementation paths inside those established top-level roots unless the source task already uses a different root.",
|
|
902
|
+
"- Verification tasks should name exact suites, scripts, commands, fixtures, entrypoints, or test files when the context provides them.",
|
|
903
|
+
"- Do not rewrite implementation work into planning, scoping, research, evidence-capture, or inventory tasks unless the source task is already docs/process work.",
|
|
904
|
+
"- If a task mixes unrelated subsystem families, split it into smaller tasks rather than bundling them together.",
|
|
905
|
+
"- Preserve and enrich useful metadata. When you know concrete files/docs/tests, place them in updates.metadata.files, updates.metadata.doc_links, updates.metadata.test_requirements, and updates.metadata.tests/testCommands as appropriate.",
|
|
630
906
|
"- Story points: non-negative, keep within typical agile range (0-13).",
|
|
631
907
|
"- Do not invent new epics/stories or change parentage.",
|
|
632
908
|
].join("\n");
|
|
633
909
|
return [
|
|
910
|
+
"ROLE: backlog refinement agent.",
|
|
911
|
+
"TASK: Refine the selected story tasks into work-agent-ready backlog operations using only the supplied context.",
|
|
912
|
+
REFINE_OUTPUT_CONTRACT,
|
|
634
913
|
`You are refining tasks for epic ${group.epic.key} "${group.epic.title}" and story ${group.story.key} "${group.story.title}".`,
|
|
635
914
|
`Strategy: ${strategy}`,
|
|
636
915
|
"Story acceptance criteria:",
|
|
637
916
|
group.story.acceptance?.length ? group.story.acceptance.map((c) => `- ${c}`).join("\n") : "- (none provided)",
|
|
638
917
|
"Current tasks:",
|
|
639
918
|
taskList || "- (no tasks selected)",
|
|
919
|
+
"Story execution context:",
|
|
920
|
+
this.buildStoryExecutionContext(group),
|
|
640
921
|
"Doc context (summaries only):",
|
|
641
922
|
docSummary || "(none)",
|
|
642
923
|
"Recent task history (logs/comments):",
|
|
643
924
|
group.historySummary || "(none)",
|
|
644
925
|
"Constraints:",
|
|
645
926
|
constraints,
|
|
646
|
-
"
|
|
647
|
-
|
|
927
|
+
"Output schema example:",
|
|
928
|
+
JSON.stringify({
|
|
929
|
+
operations: [
|
|
930
|
+
{
|
|
931
|
+
op: "update_task",
|
|
932
|
+
taskKey: "web-01-us-01-t01",
|
|
933
|
+
updates: {
|
|
934
|
+
title: "Wire provider registry into task selection",
|
|
935
|
+
description: "Update packages/core/src/services/execution/WorkOnTasksService.ts to consume the refined task metadata contract and validate the flow with node tests/all.js.",
|
|
936
|
+
storyPoints: 3,
|
|
937
|
+
metadata: {
|
|
938
|
+
files: ["packages/core/src/services/execution/WorkOnTasksService.ts"],
|
|
939
|
+
doc_links: ["docdex:doc-1"],
|
|
940
|
+
test_requirements: { unit: ["cover metadata selection"], integration: ["run task execution path"] },
|
|
941
|
+
tests: ["node tests/all.js"],
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
],
|
|
946
|
+
}, null, 0),
|
|
648
947
|
"Return JSON ONLY matching: { \"operations\": [UpdateTaskOp | SplitTaskOp | MergeTasksOp | UpdateEstimateOp] } where each item has an `op` discriminator (update_task|split_task|merge_tasks|update_estimate).",
|
|
948
|
+
'Never answer with lines like "I\'m checking context first" or "I\'ll inspect the repo".',
|
|
949
|
+
"Return the JSON object now.",
|
|
649
950
|
].join("\n\n");
|
|
650
951
|
}
|
|
651
952
|
buildOpenApiHintSummary(docs) {
|
|
652
953
|
const lines = [];
|
|
954
|
+
let suggestedTestRequirements = emptyTestRequirements();
|
|
653
955
|
for (const doc of docs ?? []) {
|
|
654
956
|
const raw = typeof doc?.content === "string" && doc.content.trim().length > 0
|
|
655
957
|
? doc.content
|
|
@@ -686,14 +988,105 @@ export class RefineTasksService {
|
|
|
686
988
|
const countItems = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string").length : 0;
|
|
687
989
|
const dependsOn = countItems(hints.depends_on_operations);
|
|
688
990
|
const testRequirements = isPlainObject(hints.test_requirements) ? hints.test_requirements : undefined;
|
|
991
|
+
suggestedTestRequirements = mergeTestRequirements(suggestedTestRequirements, normalizeTestRequirements(testRequirements));
|
|
689
992
|
lines.push(`- ${normalizedMethod.toUpperCase()} ${apiPath} :: service=${service}; capability=${capability}; stage=${stage}; complexity=${complexity}; deps=${dependsOn}; tests(u/c/i/a)=${countItems(testRequirements?.unit)}/${countItems(testRequirements?.component)}/${countItems(testRequirements?.integration)}/${countItems(testRequirements?.api)}`);
|
|
690
993
|
if (lines.length >= OPENAPI_HINTS_LIMIT) {
|
|
691
|
-
return lines.join("\n");
|
|
994
|
+
return { summary: lines.join("\n"), suggestedTestRequirements };
|
|
692
995
|
}
|
|
693
996
|
}
|
|
694
997
|
}
|
|
695
998
|
}
|
|
696
|
-
return lines.join("\n");
|
|
999
|
+
return { summary: lines.join("\n"), suggestedTestRequirements };
|
|
1000
|
+
}
|
|
1001
|
+
buildDocLinkHandle(doc) {
|
|
1002
|
+
const candidate = typeof doc?.id === "string" && doc.id.trim().length > 0
|
|
1003
|
+
? `docdex:${doc.id.trim()}`
|
|
1004
|
+
: typeof doc?.path === "string"
|
|
1005
|
+
? doc.path.trim()
|
|
1006
|
+
: typeof doc?.title === "string"
|
|
1007
|
+
? doc.title.trim()
|
|
1008
|
+
: undefined;
|
|
1009
|
+
return candidate ? normalizeRelatedDocs([candidate])[0] : undefined;
|
|
1010
|
+
}
|
|
1011
|
+
collectCandidateTargetsFromDocs(docs) {
|
|
1012
|
+
const allTargets = uniqueStrings(docs.flatMap((doc) => {
|
|
1013
|
+
const values = [];
|
|
1014
|
+
if (typeof doc?.path === "string" && isLikelyRepoPath(doc.path)) {
|
|
1015
|
+
values.push(doc.path.trim());
|
|
1016
|
+
}
|
|
1017
|
+
const rawContent = typeof doc?.content === "string" && doc.content.trim().length > 0
|
|
1018
|
+
? doc.content
|
|
1019
|
+
: Array.isArray(doc?.segments)
|
|
1020
|
+
? doc.segments
|
|
1021
|
+
.map((segment) => (typeof segment?.content === "string" ? segment.content : ""))
|
|
1022
|
+
.filter(Boolean)
|
|
1023
|
+
.join("\n")
|
|
1024
|
+
: "";
|
|
1025
|
+
values.push(...extractRepoPaths(rawContent));
|
|
1026
|
+
return values;
|
|
1027
|
+
}));
|
|
1028
|
+
return {
|
|
1029
|
+
implementationTargets: allTargets.filter((entry) => !isLikelyTestPath(entry)).slice(0, 8),
|
|
1030
|
+
testTargets: allTargets.filter((entry) => isLikelyTestPath(entry)).slice(0, 8),
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
async readPlanningArtifactJson(projectKey, fileName) {
|
|
1034
|
+
const artifactPath = path.join(this.workspace.mcodaDir, "tasks", projectKey, fileName);
|
|
1035
|
+
try {
|
|
1036
|
+
const raw = await fs.readFile(artifactPath, "utf8");
|
|
1037
|
+
return JSON.parse(raw);
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
const code = error.code;
|
|
1041
|
+
if (code === "ENOENT")
|
|
1042
|
+
return undefined;
|
|
1043
|
+
throw new Error(`Failed to parse ${fileName}: ${error.message}`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
async loadPlanningArtifactContext(projectKey, epicKey, storyKey) {
|
|
1047
|
+
const warnings = [];
|
|
1048
|
+
try {
|
|
1049
|
+
const [buildPlanRaw, tasksRaw] = await Promise.all([
|
|
1050
|
+
this.readPlanningArtifactJson(projectKey, "build-plan.json"),
|
|
1051
|
+
this.readPlanningArtifactJson(projectKey, "tasks.json"),
|
|
1052
|
+
]);
|
|
1053
|
+
const buildPlan = isPlainObject(buildPlanRaw) ? buildPlanRaw : undefined;
|
|
1054
|
+
const taskNodes = Array.isArray(tasksRaw) ? tasksRaw : [];
|
|
1055
|
+
const relevantTasks = taskNodes.filter((task) => {
|
|
1056
|
+
if (!isPlainObject(task))
|
|
1057
|
+
return false;
|
|
1058
|
+
if (storyKey && typeof task.storyLocalId === "string" && task.storyLocalId !== storyKey)
|
|
1059
|
+
return false;
|
|
1060
|
+
if (epicKey && typeof task.epicLocalId === "string" && task.epicLocalId !== epicKey)
|
|
1061
|
+
return false;
|
|
1062
|
+
return true;
|
|
1063
|
+
});
|
|
1064
|
+
const extractedTargets = uniqueStrings([
|
|
1065
|
+
...relevantTasks.flatMap((task) => normalizeStringArray(task.files)),
|
|
1066
|
+
...relevantTasks.flatMap((task) => extractRepoPaths(String(task.description ?? ""))),
|
|
1067
|
+
...extractRepoPaths(typeof buildPlan?.buildMethod === "string" ? buildPlan.buildMethod : ""),
|
|
1068
|
+
]);
|
|
1069
|
+
const implementationTargets = extractedTargets.filter((target) => isStrongExecutionPath(target) && !isLikelyTestPath(target));
|
|
1070
|
+
const testTargets = extractedTargets.filter((target) => isLikelyTestPath(target) || classifyRepoPathKind(target) === "test");
|
|
1071
|
+
const architectureRoots = uniqueStrings([...implementationTargets, ...testTargets]
|
|
1072
|
+
.map((target) => getRepoPathRoot(target))
|
|
1073
|
+
.filter((value) => Boolean(value))).slice(0, 8);
|
|
1074
|
+
return {
|
|
1075
|
+
warnings,
|
|
1076
|
+
implementationTargets: implementationTargets.slice(0, 8),
|
|
1077
|
+
testTargets: testTargets.slice(0, 8),
|
|
1078
|
+
architectureRoots,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
catch (error) {
|
|
1082
|
+
warnings.push(`Planning artifact lookup failed: ${error.message}`);
|
|
1083
|
+
return {
|
|
1084
|
+
warnings,
|
|
1085
|
+
implementationTargets: [],
|
|
1086
|
+
testTargets: [],
|
|
1087
|
+
architectureRoots: [],
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
697
1090
|
}
|
|
698
1091
|
async summarizeDocs(projectKey, epicKey, storyKey) {
|
|
699
1092
|
const warnings = [];
|
|
@@ -707,32 +1100,39 @@ export class RefineTasksService {
|
|
|
707
1100
|
profile: "sds",
|
|
708
1101
|
query,
|
|
709
1102
|
});
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1103
|
+
const openApiDocs = await this.docdex.search({
|
|
1104
|
+
projectKey,
|
|
1105
|
+
profile: "openapi",
|
|
1106
|
+
query,
|
|
1107
|
+
});
|
|
1108
|
+
const workspaceCodeDocs = await this.docdex.search({
|
|
1109
|
+
projectKey,
|
|
1110
|
+
profile: "workspace-code",
|
|
1111
|
+
query,
|
|
1112
|
+
});
|
|
1113
|
+
if ((!docs || docs.length === 0) && (!openApiDocs || openApiDocs.length === 0) && (!workspaceCodeDocs || workspaceCodeDocs.length === 0)) {
|
|
1114
|
+
return {
|
|
1115
|
+
summary: "(no relevant docdex entries)",
|
|
1116
|
+
warnings: [],
|
|
1117
|
+
docLinks: [],
|
|
1118
|
+
implementationTargets: [],
|
|
1119
|
+
testTargets: [],
|
|
1120
|
+
suggestedTestRequirements: emptyTestRequirements(),
|
|
1121
|
+
};
|
|
726
1122
|
}
|
|
727
|
-
const
|
|
1123
|
+
const planningDocs = uniqueStrings([
|
|
1124
|
+
...(docs ?? []).map((doc) => JSON.stringify(doc)),
|
|
1125
|
+
...(openApiDocs ?? []).map((doc) => JSON.stringify(doc)),
|
|
1126
|
+
]).map((entry) => JSON.parse(entry));
|
|
1127
|
+
const top = planningDocs
|
|
728
1128
|
.filter((doc) => {
|
|
729
1129
|
const type = (doc.docType ?? "").toLowerCase();
|
|
730
1130
|
const pathTitle = `${doc.path ?? ""} ${doc.title ?? ""}`.toLowerCase();
|
|
731
1131
|
return type.includes("sds") || type.includes("pdr") || type.includes("rfp") || PLANNING_DOC_HINT_PATTERN.test(pathTitle);
|
|
732
1132
|
})
|
|
733
1133
|
.slice(0, 5);
|
|
734
|
-
const
|
|
735
|
-
const summary =
|
|
1134
|
+
const selectedPlanning = top.length > 0 ? top : planningDocs.slice(0, 5);
|
|
1135
|
+
const summary = selectedPlanning
|
|
736
1136
|
.map((doc) => {
|
|
737
1137
|
const segments = (doc.segments ?? []).slice(0, 3);
|
|
738
1138
|
const segText = segments
|
|
@@ -745,9 +1145,26 @@ export class RefineTasksService {
|
|
|
745
1145
|
return [`- [${doc.docType}] ${doc.title ?? doc.path ?? doc.id}${head ? ` — ${head}` : ""}`, segText].filter(Boolean).join("\n");
|
|
746
1146
|
})
|
|
747
1147
|
.join("\n");
|
|
748
|
-
const finalSummary = (summary ||
|
|
749
|
-
|
|
750
|
-
const
|
|
1148
|
+
const finalSummary = (summary ||
|
|
1149
|
+
(selectedPlanning.length ? selectedPlanning.map((doc) => `- ${doc.title ?? doc.path ?? doc.id}`).join("\n") : "")).trim();
|
|
1150
|
+
const openApiHints = this.buildOpenApiHintSummary(openApiDocs ?? []);
|
|
1151
|
+
const codeTargets = this.collectCandidateTargetsFromDocs(workspaceCodeDocs ?? []);
|
|
1152
|
+
const planningTargets = this.collectCandidateTargetsFromDocs(selectedPlanning);
|
|
1153
|
+
const implementationTargets = uniqueStrings([
|
|
1154
|
+
...planningTargets.implementationTargets,
|
|
1155
|
+
...codeTargets.implementationTargets,
|
|
1156
|
+
]).slice(0, 8);
|
|
1157
|
+
const testTargets = uniqueStrings([...planningTargets.testTargets, ...codeTargets.testTargets]).slice(0, 8);
|
|
1158
|
+
const docLinks = normalizeRelatedDocs(selectedPlanning
|
|
1159
|
+
.map((doc) => this.buildDocLinkHandle(doc))
|
|
1160
|
+
.filter((value) => Boolean(value)));
|
|
1161
|
+
const suggestedTestRequirements = mergeTestRequirements(openApiHints.suggestedTestRequirements, inferTestRequirementsFromTargets(testTargets));
|
|
1162
|
+
const composedSummary = [
|
|
1163
|
+
finalSummary || "(no doc segments found)",
|
|
1164
|
+
openApiHints.summary ? `[OPENAPI_HINTS]\n${openApiHints.summary}` : "",
|
|
1165
|
+
implementationTargets.length ? `[REPO_TARGETS]\n${implementationTargets.map((target) => `- ${target}`).join("\n")}` : "",
|
|
1166
|
+
testTargets.length ? `[TEST_TARGETS]\n${testTargets.map((target) => `- ${target}`).join("\n")}` : "",
|
|
1167
|
+
]
|
|
751
1168
|
.filter(Boolean)
|
|
752
1169
|
.join("\n\n");
|
|
753
1170
|
const durationSeconds = (Date.now() - startedAt) / 1000;
|
|
@@ -764,11 +1181,18 @@ export class RefineTasksService {
|
|
|
764
1181
|
timestamp: new Date().toISOString(),
|
|
765
1182
|
metadata: { command: "refine-tasks", action: "docdex_search", projectKey, epicKey, storyKey },
|
|
766
1183
|
});
|
|
767
|
-
return { summary: composedSummary, warnings };
|
|
1184
|
+
return { summary: composedSummary, warnings, docLinks, implementationTargets, testTargets, suggestedTestRequirements };
|
|
768
1185
|
}
|
|
769
1186
|
catch (error) {
|
|
770
1187
|
warnings.push(`Docdex lookup failed: ${error.message}`);
|
|
771
|
-
return {
|
|
1188
|
+
return {
|
|
1189
|
+
summary: "(docdex unavailable)",
|
|
1190
|
+
warnings,
|
|
1191
|
+
docLinks: [],
|
|
1192
|
+
implementationTargets: [],
|
|
1193
|
+
testTargets: [],
|
|
1194
|
+
suggestedTestRequirements: emptyTestRequirements(),
|
|
1195
|
+
};
|
|
772
1196
|
}
|
|
773
1197
|
}
|
|
774
1198
|
async summarizeHistory(taskIds) {
|
|
@@ -777,7 +1201,7 @@ export class RefineTasksService {
|
|
|
777
1201
|
const db = this.workspaceRepo.getDb();
|
|
778
1202
|
const placeholders = taskIds.map(() => "?").join(", ");
|
|
779
1203
|
try {
|
|
780
|
-
const
|
|
1204
|
+
const logRows = await db.all(`
|
|
781
1205
|
SELECT r.task_id, l.timestamp, l.level, l.message, l.source
|
|
782
1206
|
FROM task_logs l
|
|
783
1207
|
INNER JOIN task_runs r ON r.id = l.task_run_id
|
|
@@ -785,14 +1209,38 @@ export class RefineTasksService {
|
|
|
785
1209
|
ORDER BY l.timestamp DESC
|
|
786
1210
|
LIMIT 15
|
|
787
1211
|
`, taskIds);
|
|
788
|
-
|
|
1212
|
+
const commentRows = await db.all(`
|
|
1213
|
+
SELECT task_id, created_at as timestamp, category, status, body, author_type
|
|
1214
|
+
FROM task_comments
|
|
1215
|
+
WHERE task_id IN (${placeholders})
|
|
1216
|
+
ORDER BY created_at DESC
|
|
1217
|
+
LIMIT 15
|
|
1218
|
+
`, taskIds);
|
|
1219
|
+
const historyEntries = [
|
|
1220
|
+
...(logRows ?? []).map((row) => {
|
|
1221
|
+
const level = row.level ? row.level.toUpperCase() : "INFO";
|
|
1222
|
+
const msg = row.message ?? "";
|
|
1223
|
+
return {
|
|
1224
|
+
timestamp: row.timestamp,
|
|
1225
|
+
line: `- ${row.task_id}: [${level}] ${truncate(msg, 180)} (${row.source ?? "run"})`,
|
|
1226
|
+
};
|
|
1227
|
+
}),
|
|
1228
|
+
...(commentRows ?? []).map((row) => {
|
|
1229
|
+
const category = row.category ? row.category : "comment";
|
|
1230
|
+
const status = row.status ? `/${row.status}` : "";
|
|
1231
|
+
const author = row.author_type ? row.author_type : "unknown";
|
|
1232
|
+
return {
|
|
1233
|
+
timestamp: row.timestamp,
|
|
1234
|
+
line: `- ${row.task_id}: [COMMENT ${category}${status}] ${truncate(row.body ?? "", 180)} (${author})`,
|
|
1235
|
+
};
|
|
1236
|
+
}),
|
|
1237
|
+
]
|
|
1238
|
+
.sort((left, right) => right.timestamp.localeCompare(left.timestamp))
|
|
1239
|
+
.slice(0, 15);
|
|
1240
|
+
if (historyEntries.length === 0)
|
|
789
1241
|
return "(none)";
|
|
790
|
-
return
|
|
791
|
-
.map((
|
|
792
|
-
const level = row.level ? row.level.toUpperCase() : "INFO";
|
|
793
|
-
const msg = row.message ?? "";
|
|
794
|
-
return `- ${row.task_id}: [${level}] ${msg} (${row.source ?? "run"})`;
|
|
795
|
-
})
|
|
1242
|
+
return historyEntries
|
|
1243
|
+
.map((entry) => entry.line)
|
|
796
1244
|
.join("\n");
|
|
797
1245
|
}
|
|
798
1246
|
catch {
|
|
@@ -847,111 +1295,18 @@ export class RefineTasksService {
|
|
|
847
1295
|
foundation: classification.foundation,
|
|
848
1296
|
};
|
|
849
1297
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
if (!op.taskKey || typeof op.updates !== "object") {
|
|
857
|
-
return { valid: false, reason: "update_task missing taskKey or updates" };
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
if (op.op === "split_task") {
|
|
861
|
-
const split = op;
|
|
862
|
-
if (!split.taskKey || !Array.isArray(split.children) || split.children.length === 0) {
|
|
863
|
-
return { valid: false, reason: "split_task missing taskKey or children" };
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
if (op.op === "merge_tasks") {
|
|
867
|
-
if (!op.targetTaskKey || !Array.isArray(op.sourceTaskKeys) || op.sourceTaskKeys.length === 0) {
|
|
868
|
-
return { valid: false, reason: "merge_tasks missing targets" };
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
if (op.op === "update_estimate") {
|
|
872
|
-
if (!op.taskKey)
|
|
873
|
-
return { valid: false, reason: "update_estimate missing taskKey" };
|
|
874
|
-
}
|
|
875
|
-
const keySet = new Set(group.tasks.map((t) => t.key));
|
|
876
|
-
if (op.taskKey && !keySet.has(op.taskKey)) {
|
|
877
|
-
return { valid: false, reason: `Unknown task key ${op.taskKey} for story ${group.story.key}` };
|
|
878
|
-
}
|
|
879
|
-
if (op.targetTaskKey && !keySet.has(op.targetTaskKey)) {
|
|
880
|
-
return { valid: false, reason: `Unknown merge target ${op.targetTaskKey}` };
|
|
881
|
-
}
|
|
882
|
-
if (op.sourceTaskKeys) {
|
|
883
|
-
const missing = op.sourceTaskKeys.filter((k) => !keySet.has(k));
|
|
884
|
-
if (missing.length) {
|
|
885
|
-
return { valid: false, reason: `Merge sources not in story ${group.story.key}: ${missing.join(", ")}` };
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
if (op.op === "update_task" && op.updates.status && FORBIDDEN_TARGET_STATUSES.has(op.updates.status.toLowerCase())) {
|
|
889
|
-
return { valid: false, reason: `Status ${op.updates.status} not allowed in refine-tasks` };
|
|
890
|
-
}
|
|
891
|
-
if (op.op === "update_task" && op.updates.storyPoints !== undefined) {
|
|
892
|
-
const sp = op.updates.storyPoints;
|
|
893
|
-
if (sp !== null && (typeof sp !== "number" || sp < 0 || sp > 13)) {
|
|
894
|
-
return { valid: false, reason: `Story points out of bounds for ${op.taskKey}` };
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
if (op.op === "split_task") {
|
|
898
|
-
const split = op;
|
|
899
|
-
const taskKeysByNormalized = new Map();
|
|
900
|
-
for (const key of keySet) {
|
|
901
|
-
taskKeysByNormalized.set(normalizeSplitDependencyRef(key), key);
|
|
902
|
-
}
|
|
903
|
-
const siblingReferences = new Set();
|
|
904
|
-
const selfReferencesByChild = split.children.map((child) => {
|
|
905
|
-
const selfReferences = new Set();
|
|
906
|
-
for (const reference of collectSplitChildReferences(child)) {
|
|
907
|
-
const normalized = normalizeSplitDependencyRef(reference);
|
|
908
|
-
if (!normalized)
|
|
909
|
-
continue;
|
|
910
|
-
selfReferences.add(normalized);
|
|
911
|
-
siblingReferences.add(normalized);
|
|
912
|
-
}
|
|
913
|
-
return selfReferences;
|
|
914
|
-
});
|
|
915
|
-
const invalidDep = split.children.some((child) => child.dependsOn?.some((dep) => {
|
|
916
|
-
const normalized = normalizeSplitDependencyRef(dep);
|
|
917
|
-
if (!normalized)
|
|
918
|
-
return false;
|
|
919
|
-
if (taskKeysByNormalized.has(normalized))
|
|
920
|
-
return false;
|
|
921
|
-
if (siblingReferences.has(normalized))
|
|
922
|
-
return false;
|
|
923
|
-
return true;
|
|
924
|
-
}));
|
|
925
|
-
if (invalidDep) {
|
|
926
|
-
return { valid: false, reason: "Split child references unknown dependency" };
|
|
927
|
-
}
|
|
928
|
-
const selfDependency = split.children.some((child, index) => child.dependsOn?.some((dep) => selfReferencesByChild[index]?.has(normalizeSplitDependencyRef(dep))));
|
|
929
|
-
if (selfDependency) {
|
|
930
|
-
return { valid: false, reason: "Split child cannot depend on itself" };
|
|
931
|
-
}
|
|
932
|
-
if (split.children.some((child) => child.storyPoints !== undefined && child.storyPoints !== null && (child.storyPoints < 0 || child.storyPoints > 13))) {
|
|
933
|
-
return { valid: false, reason: "Child story points out of bounds" };
|
|
934
|
-
}
|
|
935
|
-
const crossStory = split.children.some((child) => child.storyKey && child.storyKey !== group.story.key);
|
|
936
|
-
if (crossStory) {
|
|
937
|
-
return { valid: false, reason: "Split children must stay within the same story" };
|
|
938
|
-
}
|
|
1298
|
+
sanitizeStatusUpdate(status, taskKey, warnings) {
|
|
1299
|
+
if (status === undefined || status === null)
|
|
1300
|
+
return undefined;
|
|
1301
|
+
if (typeof status !== "string") {
|
|
1302
|
+
warnings.push(`Skipped status update for ${taskKey}: status must be a string.`);
|
|
1303
|
+
return undefined;
|
|
939
1304
|
}
|
|
940
|
-
if (
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
if (crossStory) {
|
|
944
|
-
return { valid: false, reason: "Merge must stay within the same story" };
|
|
945
|
-
}
|
|
946
|
-
const uniqueSources = new Set(op.sourceTaskKeys.filter(Boolean));
|
|
947
|
-
if (uniqueSources.size !== op.sourceTaskKeys.length) {
|
|
948
|
-
return { valid: false, reason: "Duplicate source task keys in merge" };
|
|
949
|
-
}
|
|
950
|
-
if (uniqueSources.has(op.targetTaskKey)) {
|
|
951
|
-
return { valid: false, reason: "Merge sources cannot include target" };
|
|
952
|
-
}
|
|
1305
|
+
if (FORBIDDEN_TARGET_STATUSES.has(status.toLowerCase())) {
|
|
1306
|
+
warnings.push(`Ignored terminal status ${status} for ${taskKey}; refine-tasks only enriches task data.`);
|
|
1307
|
+
return undefined;
|
|
953
1308
|
}
|
|
954
|
-
return
|
|
1309
|
+
return status;
|
|
955
1310
|
}
|
|
956
1311
|
detectCycle(edges) {
|
|
957
1312
|
const adj = new Map();
|
|
@@ -1004,6 +1359,239 @@ export class RefineTasksService {
|
|
|
1004
1359
|
}
|
|
1005
1360
|
return false;
|
|
1006
1361
|
}
|
|
1362
|
+
evaluateTaskQuality(group, taskKey, sourceTask, candidate) {
|
|
1363
|
+
const issues = [];
|
|
1364
|
+
const title = candidate.title ?? sourceTask?.title ?? "";
|
|
1365
|
+
const description = candidate.description ?? sourceTask?.description ?? "";
|
|
1366
|
+
const type = candidate.type ?? sourceTask?.type ?? "";
|
|
1367
|
+
const metadata = this.mergeMetadata(sourceTask?.metadata, candidate.metadata) ?? {};
|
|
1368
|
+
const text = [title, description, type].filter(Boolean).join("\n");
|
|
1369
|
+
const sourceText = [sourceTask?.title, sourceTask?.description, sourceTask?.type].filter(Boolean).join("\n");
|
|
1370
|
+
const existingFiles = normalizeStringArray(metadata.files);
|
|
1371
|
+
const explicitPaths = extractRepoPaths(text);
|
|
1372
|
+
const relevantTargets = selectRelevantTargets(text, group.implementationTargets ?? [], 3);
|
|
1373
|
+
const relevantTestTargets = selectRelevantTargets(text, group.testTargets ?? [], 3);
|
|
1374
|
+
const effectiveFiles = uniqueStrings([
|
|
1375
|
+
...existingFiles,
|
|
1376
|
+
...explicitPaths,
|
|
1377
|
+
...relevantTargets,
|
|
1378
|
+
...(isVerificationTask(text) ? relevantTestTargets : []),
|
|
1379
|
+
]);
|
|
1380
|
+
const strongFiles = effectiveFiles.filter((target) => isStrongExecutionPath(target));
|
|
1381
|
+
const docOnlyFiles = effectiveFiles.filter((target) => classifyRepoPathKind(target) === "doc");
|
|
1382
|
+
const docsOrCoordination = isDocsOrCoordinationTask(text) || isDocsOrCoordinationTask(sourceText);
|
|
1383
|
+
const shouldExpectConcreteTargets = !docsOrCoordination &&
|
|
1384
|
+
!isVerificationTask(text) &&
|
|
1385
|
+
((group.implementationTargets?.length ?? 0) > 0 || normalizeStringArray(sourceTask?.metadata?.files).length > 0);
|
|
1386
|
+
if (shouldExpectConcreteTargets && strongFiles.length === 0) {
|
|
1387
|
+
issues.push({
|
|
1388
|
+
code: "missing_targets",
|
|
1389
|
+
taskKey,
|
|
1390
|
+
message: "Missing concrete repo targets even though story-level targets are available.",
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
if (shouldExpectConcreteTargets && strongFiles.length === 0 && docOnlyFiles.length > 0) {
|
|
1394
|
+
issues.push({
|
|
1395
|
+
code: "docs_only_targets",
|
|
1396
|
+
taskKey,
|
|
1397
|
+
message: "Only documentation-style paths were provided; implementation work still lacks executable code surfaces.",
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
if (!docsOrCoordination && isMetaTaskText(text)) {
|
|
1401
|
+
issues.push({
|
|
1402
|
+
code: "meta_task",
|
|
1403
|
+
taskKey,
|
|
1404
|
+
message: "Task reads like planning/scoping work instead of executable implementation work.",
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
if (isVerificationTask(text) &&
|
|
1408
|
+
((group.testTargets?.length ?? 0) > 0 || hasTestRequirements(group.suggestedTestRequirements ?? emptyTestRequirements())) &&
|
|
1409
|
+
!hasRunnableTestReference(text, metadata, group.testTargets ?? [])) {
|
|
1410
|
+
issues.push({
|
|
1411
|
+
code: "missing_verification_harness",
|
|
1412
|
+
taskKey,
|
|
1413
|
+
message: "Verification work does not name a runnable test command, suite, fixture, entrypoint, or test file.",
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
const allowedRoots = new Set(group.architectureRoots ?? []);
|
|
1417
|
+
const sourceRoots = new Set(normalizeStringArray(sourceTask?.metadata?.files)
|
|
1418
|
+
.map((target) => getRepoPathRoot(target))
|
|
1419
|
+
.filter((value) => Boolean(value)));
|
|
1420
|
+
const driftRoots = uniqueStrings(strongFiles
|
|
1421
|
+
.map((target) => getRepoPathRoot(target))
|
|
1422
|
+
.filter((value) => typeof value === "string")
|
|
1423
|
+
.filter((value) => allowedRoots.size > 0 && !allowedRoots.has(value) && !sourceRoots.has(value)));
|
|
1424
|
+
if (!docsOrCoordination && driftRoots.length > 0) {
|
|
1425
|
+
issues.push({
|
|
1426
|
+
code: "path_drift",
|
|
1427
|
+
taskKey,
|
|
1428
|
+
message: `Task introduces paths outside the established architecture roots: ${driftRoots.join(", ")}.`,
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
const distinctRoots = new Set(strongFiles.map((target) => {
|
|
1432
|
+
const cleaned = target.replace(/^\.\/+/, "");
|
|
1433
|
+
const [root] = cleaned.split("/").filter(Boolean);
|
|
1434
|
+
return root ?? cleaned;
|
|
1435
|
+
}));
|
|
1436
|
+
if (strongFiles.length >= 4 && distinctRoots.size >= 3) {
|
|
1437
|
+
issues.push({
|
|
1438
|
+
code: "over_bundled",
|
|
1439
|
+
taskKey,
|
|
1440
|
+
message: "Task appears to span too many unrelated implementation surfaces and should likely be split.",
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
return issues;
|
|
1444
|
+
}
|
|
1445
|
+
evaluateOperationsQuality(group, operations) {
|
|
1446
|
+
const issues = [];
|
|
1447
|
+
const affectedTasks = new Set();
|
|
1448
|
+
let reviewableUnits = 0;
|
|
1449
|
+
for (const op of operations) {
|
|
1450
|
+
if (!op || typeof op !== "object")
|
|
1451
|
+
continue;
|
|
1452
|
+
if (op.op === "update_task") {
|
|
1453
|
+
reviewableUnits += 1;
|
|
1454
|
+
const taskKey = op.taskKey ?? "unknown-task";
|
|
1455
|
+
const sourceTask = group.tasks.find((task) => task.key === taskKey);
|
|
1456
|
+
const opIssues = this.evaluateTaskQuality(group, taskKey, sourceTask, {
|
|
1457
|
+
title: op.updates?.title,
|
|
1458
|
+
description: op.updates?.description,
|
|
1459
|
+
type: op.updates?.type,
|
|
1460
|
+
metadata: isPlainObject(op.updates?.metadata) ? op.updates.metadata : undefined,
|
|
1461
|
+
});
|
|
1462
|
+
if (opIssues.length > 0)
|
|
1463
|
+
affectedTasks.add(taskKey);
|
|
1464
|
+
issues.push(...opIssues);
|
|
1465
|
+
}
|
|
1466
|
+
else if (op.op === "split_task" && Array.isArray(op.children)) {
|
|
1467
|
+
for (const child of op.children) {
|
|
1468
|
+
reviewableUnits += 1;
|
|
1469
|
+
const taskKey = `${op.taskKey}:${truncate(child.title ?? "child", 48)}`;
|
|
1470
|
+
const sourceTask = group.tasks.find((task) => task.key === op.taskKey);
|
|
1471
|
+
const opIssues = this.evaluateTaskQuality(group, taskKey, sourceTask, {
|
|
1472
|
+
title: child.title,
|
|
1473
|
+
description: child.description,
|
|
1474
|
+
type: child.type,
|
|
1475
|
+
metadata: isPlainObject(child.metadata) ? child.metadata : undefined,
|
|
1476
|
+
});
|
|
1477
|
+
if (opIssues.length > 0)
|
|
1478
|
+
affectedTasks.add(op.taskKey);
|
|
1479
|
+
issues.push(...opIssues);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
const critiqueLines = summarizeQualityIssues(issues);
|
|
1484
|
+
const shouldRetry = reviewableUnits > 0 &&
|
|
1485
|
+
issues.length > 0 &&
|
|
1486
|
+
affectedTasks.size >= Math.max(1, Math.ceil(reviewableUnits / 2));
|
|
1487
|
+
return {
|
|
1488
|
+
issues,
|
|
1489
|
+
issueCount: issues.length,
|
|
1490
|
+
critiqueLines,
|
|
1491
|
+
shouldRetry,
|
|
1492
|
+
score: issues.length * 100 - Math.min(operations.length, 25),
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
buildQualityRetryPrompt(prompt, group, evaluation) {
|
|
1496
|
+
return [
|
|
1497
|
+
REFINE_OUTPUT_CONTRACT,
|
|
1498
|
+
"RETRY: Your previous response was parseable JSON, but it was not execution-ready enough for downstream work agents.",
|
|
1499
|
+
`Story: ${group.story.key}`,
|
|
1500
|
+
"Fix the following quality issues in the rewritten JSON plan:",
|
|
1501
|
+
evaluation.critiqueLines.join("\n") || "- Previous output was too generic.",
|
|
1502
|
+
"Rewrite the operations so implementation tasks point at concrete repo targets and verification tasks name runnable commands, suites, fixtures, entrypoints, or test files when the supplied context provides them.",
|
|
1503
|
+
'Return exactly one JSON object of the form {"operations":[...]}.',
|
|
1504
|
+
'If no safe changes are possible, return {"operations":[]}.',
|
|
1505
|
+
"",
|
|
1506
|
+
prompt,
|
|
1507
|
+
].join("\n\n");
|
|
1508
|
+
}
|
|
1509
|
+
async buildExecutionMetadata(group, testCommandBuilder, content) {
|
|
1510
|
+
const merged = this.mergeMetadata(content.existingMetadata, content.updateMetadata) ?? {};
|
|
1511
|
+
const text = [content.title, content.description ?? "", content.type ?? ""].join("\n");
|
|
1512
|
+
const explicitPaths = extractRepoPaths(text);
|
|
1513
|
+
const relevantTargets = selectRelevantTargets(text, group.implementationTargets ?? [], 3);
|
|
1514
|
+
const relevantTestTargets = selectRelevantTargets(text, group.testTargets ?? [], 3);
|
|
1515
|
+
const fallbackImplementationTargets = relevantTargets.length === 0 &&
|
|
1516
|
+
!isDocsOrCoordinationTask(text) &&
|
|
1517
|
+
!isVerificationTask(text) &&
|
|
1518
|
+
(group.implementationTargets?.length ?? 0) === 1
|
|
1519
|
+
? [group.implementationTargets[0]]
|
|
1520
|
+
: [];
|
|
1521
|
+
const files = uniqueStrings([
|
|
1522
|
+
...normalizeStringArray(merged.files),
|
|
1523
|
+
...explicitPaths,
|
|
1524
|
+
...relevantTargets,
|
|
1525
|
+
...fallbackImplementationTargets,
|
|
1526
|
+
...(isVerificationTask(text) ? relevantTestTargets : []),
|
|
1527
|
+
]).filter((target) => isDocsOrCoordinationTask(text) || isStrongExecutionPath(target));
|
|
1528
|
+
const docLinks = normalizeRelatedDocs([
|
|
1529
|
+
...normalizeRelatedDocs(merged.doc_links),
|
|
1530
|
+
...(group.docLinks ?? []),
|
|
1531
|
+
]);
|
|
1532
|
+
let testRequirements = mergeTestRequirements(normalizeTestRequirements(merged.test_requirements), normalizeTestRequirements(merged.testRequirements));
|
|
1533
|
+
if (!hasTestRequirements(testRequirements)) {
|
|
1534
|
+
if (isVerificationTask(text)) {
|
|
1535
|
+
testRequirements = mergeTestRequirements(testRequirements, inferTestRequirementsFromTargets(relevantTestTargets), group.suggestedTestRequirements);
|
|
1536
|
+
}
|
|
1537
|
+
else if (/\b(api|endpoint|route|schema|handler|server)\b/i.test(text)) {
|
|
1538
|
+
testRequirements = mergeTestRequirements(testRequirements, group.suggestedTestRequirements);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
let commands = uniqueStrings([
|
|
1542
|
+
...normalizeStringArray(merged.tests),
|
|
1543
|
+
...normalizeStringArray(merged.testCommands),
|
|
1544
|
+
]);
|
|
1545
|
+
if (commands.length === 0 && hasTestRequirements(testRequirements)) {
|
|
1546
|
+
try {
|
|
1547
|
+
const plan = await testCommandBuilder.build({
|
|
1548
|
+
task: {
|
|
1549
|
+
id: "refine-preview",
|
|
1550
|
+
projectId: "refine-preview",
|
|
1551
|
+
epicId: "refine-preview",
|
|
1552
|
+
userStoryId: "refine-preview",
|
|
1553
|
+
key: "refine-preview",
|
|
1554
|
+
title: content.title,
|
|
1555
|
+
description: content.description ?? "",
|
|
1556
|
+
type: content.type ?? "feature",
|
|
1557
|
+
status: "not_started",
|
|
1558
|
+
storyPoints: null,
|
|
1559
|
+
priority: null,
|
|
1560
|
+
assignedAgentId: null,
|
|
1561
|
+
assigneeHuman: null,
|
|
1562
|
+
vcsBranch: null,
|
|
1563
|
+
vcsBaseBranch: null,
|
|
1564
|
+
vcsLastCommitSha: null,
|
|
1565
|
+
openapiVersionAtCreation: null,
|
|
1566
|
+
createdAt: new Date(0).toISOString(),
|
|
1567
|
+
updatedAt: new Date(0).toISOString(),
|
|
1568
|
+
metadata: {
|
|
1569
|
+
...merged,
|
|
1570
|
+
test_requirements: testRequirements,
|
|
1571
|
+
...(files.length > 0 ? { files } : {}),
|
|
1572
|
+
...(docLinks.length > 0 ? { doc_links: docLinks } : {}),
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
});
|
|
1576
|
+
commands = uniqueStrings(plan.commands);
|
|
1577
|
+
}
|
|
1578
|
+
catch {
|
|
1579
|
+
commands = [];
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
const next = { ...merged };
|
|
1583
|
+
if (files.length > 0)
|
|
1584
|
+
next.files = files;
|
|
1585
|
+
if (docLinks.length > 0)
|
|
1586
|
+
next.doc_links = docLinks;
|
|
1587
|
+
if (hasTestRequirements(testRequirements))
|
|
1588
|
+
next.test_requirements = testRequirements;
|
|
1589
|
+
if (commands.length > 0) {
|
|
1590
|
+
next.tests = commands;
|
|
1591
|
+
next.testCommands = commands;
|
|
1592
|
+
}
|
|
1593
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
1594
|
+
}
|
|
1007
1595
|
async applyOperations(projectId, jobId, commandRunId, group, operations) {
|
|
1008
1596
|
const created = [];
|
|
1009
1597
|
const updated = [];
|
|
@@ -1020,30 +1608,45 @@ export class RefineTasksService {
|
|
|
1020
1608
|
const storyKeyRows = await this.workspaceRepo.getDb().all(`SELECT key FROM tasks WHERE user_story_id = ?`, group.story.id);
|
|
1021
1609
|
const existingKeys = storyKeyRows.map((r) => r.key);
|
|
1022
1610
|
const keyGen = createTaskKeyGenerator(group.story.key, existingKeys);
|
|
1611
|
+
const testCommandBuilder = new QaTestCommandBuilder(this.workspace.workspaceRoot);
|
|
1023
1612
|
for (const op of operations) {
|
|
1024
|
-
|
|
1613
|
+
if (!op || typeof op !== "object") {
|
|
1614
|
+
warnings.push("Skipped malformed refine op: operation must be an object.");
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
stage = `op:${op.op ?? "unknown"}`;
|
|
1025
1618
|
if (op.op === "update_task") {
|
|
1619
|
+
if (!op.taskKey) {
|
|
1620
|
+
warnings.push("Skipped update_task without taskKey.");
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1026
1623
|
const target = taskByKey.get(op.taskKey);
|
|
1027
1624
|
if (!target)
|
|
1028
1625
|
continue;
|
|
1626
|
+
const updates = (isPlainObject(op.updates) ? op.updates : {});
|
|
1029
1627
|
const before = { ...target };
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
},
|
|
1628
|
+
const metadata = this.applyStageMetadata(await this.buildExecutionMetadata(group, testCommandBuilder, {
|
|
1629
|
+
title: updates.title ?? target.title,
|
|
1630
|
+
description: updates.description ?? target.description ?? null,
|
|
1631
|
+
type: updates.type ?? target.type ?? null,
|
|
1632
|
+
existingMetadata: target.metadata,
|
|
1633
|
+
updateMetadata: isPlainObject(updates.metadata) ? updates.metadata : undefined,
|
|
1634
|
+
}), {
|
|
1635
|
+
title: updates.title ?? target.title,
|
|
1636
|
+
description: updates.description ?? target.description ?? null,
|
|
1637
|
+
type: updates.type ?? target.type ?? null,
|
|
1638
|
+
}, updates.title !== undefined || updates.description !== undefined || updates.type !== undefined);
|
|
1037
1639
|
const beforeSp = target.storyPoints ?? 0;
|
|
1038
|
-
const afterSp =
|
|
1640
|
+
const afterSp = updates.storyPoints ?? target.storyPoints ?? null;
|
|
1641
|
+
const nextStatus = this.sanitizeStatusUpdate(updates.status, op.taskKey, warnings) ?? target.status;
|
|
1039
1642
|
storyPointsDelta += (afterSp ?? 0) - (beforeSp ?? 0);
|
|
1040
1643
|
await this.workspaceRepo.updateTask(target.id, {
|
|
1041
|
-
title:
|
|
1042
|
-
description:
|
|
1043
|
-
type:
|
|
1644
|
+
title: updates.title ?? target.title,
|
|
1645
|
+
description: updates.description ?? target.description ?? null,
|
|
1646
|
+
type: updates.type ?? target.type ?? null,
|
|
1044
1647
|
storyPoints: afterSp,
|
|
1045
|
-
priority:
|
|
1046
|
-
status:
|
|
1648
|
+
priority: updates.priority ?? target.priority ?? null,
|
|
1649
|
+
status: nextStatus,
|
|
1047
1650
|
metadata,
|
|
1048
1651
|
});
|
|
1049
1652
|
updated.push(target.key);
|
|
@@ -1052,31 +1655,51 @@ export class RefineTasksService {
|
|
|
1052
1655
|
jobId,
|
|
1053
1656
|
commandRunId,
|
|
1054
1657
|
snapshotBefore: before,
|
|
1055
|
-
snapshotAfter: { ...before, ...
|
|
1658
|
+
snapshotAfter: { ...before, ...updates, storyPoints: afterSp, status: nextStatus, metadata },
|
|
1056
1659
|
createdAt: new Date().toISOString(),
|
|
1057
1660
|
});
|
|
1058
1661
|
}
|
|
1059
1662
|
else if (op.op === "split_task") {
|
|
1663
|
+
if (!op.taskKey) {
|
|
1664
|
+
warnings.push("Skipped split_task without taskKey.");
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
if (!Array.isArray(op.children) || op.children.length === 0) {
|
|
1668
|
+
warnings.push(`Skipped split_task for ${op.taskKey}: children array is required.`);
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1060
1671
|
const target = taskByKey.get(op.taskKey);
|
|
1061
1672
|
if (!target)
|
|
1062
1673
|
continue;
|
|
1063
|
-
|
|
1674
|
+
const parentUpdates = op.parentUpdates === undefined
|
|
1675
|
+
? undefined
|
|
1676
|
+
: isPlainObject(op.parentUpdates)
|
|
1677
|
+
? op.parentUpdates
|
|
1678
|
+
: undefined;
|
|
1679
|
+
if (op.parentUpdates !== undefined && !parentUpdates) {
|
|
1680
|
+
warnings.push(`Ignored malformed parentUpdates for ${op.taskKey}; expected an object.`);
|
|
1681
|
+
}
|
|
1682
|
+
if (parentUpdates) {
|
|
1064
1683
|
const before = { ...target };
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1684
|
+
const metadata = this.applyStageMetadata(await this.buildExecutionMetadata(group, testCommandBuilder, {
|
|
1685
|
+
title: parentUpdates.title ?? target.title,
|
|
1686
|
+
description: parentUpdates.description ?? target.description ?? null,
|
|
1687
|
+
type: parentUpdates.type ?? target.type ?? null,
|
|
1688
|
+
existingMetadata: target.metadata,
|
|
1689
|
+
updateMetadata: isPlainObject(parentUpdates.metadata) ? parentUpdates.metadata : undefined,
|
|
1690
|
+
}), {
|
|
1691
|
+
title: parentUpdates.title ?? target.title,
|
|
1692
|
+
description: parentUpdates.description ?? target.description ?? null,
|
|
1693
|
+
type: parentUpdates.type ?? target.type ?? null,
|
|
1694
|
+
}, parentUpdates.title !== undefined ||
|
|
1695
|
+
parentUpdates.description !== undefined ||
|
|
1696
|
+
parentUpdates.type !== undefined);
|
|
1074
1697
|
await this.workspaceRepo.updateTask(target.id, {
|
|
1075
|
-
title:
|
|
1076
|
-
description:
|
|
1077
|
-
type:
|
|
1078
|
-
storyPoints:
|
|
1079
|
-
priority:
|
|
1698
|
+
title: parentUpdates.title ?? target.title,
|
|
1699
|
+
description: parentUpdates.description ?? target.description ?? null,
|
|
1700
|
+
type: parentUpdates.type ?? target.type ?? null,
|
|
1701
|
+
storyPoints: parentUpdates.storyPoints ?? target.storyPoints ?? null,
|
|
1702
|
+
priority: parentUpdates.priority ?? target.priority ?? null,
|
|
1080
1703
|
metadata,
|
|
1081
1704
|
});
|
|
1082
1705
|
updated.push(target.key);
|
|
@@ -1087,8 +1710,8 @@ export class RefineTasksService {
|
|
|
1087
1710
|
snapshotBefore: before,
|
|
1088
1711
|
snapshotAfter: {
|
|
1089
1712
|
...before,
|
|
1090
|
-
...
|
|
1091
|
-
storyPoints:
|
|
1713
|
+
...parentUpdates,
|
|
1714
|
+
storyPoints: parentUpdates.storyPoints ?? before.storyPoints,
|
|
1092
1715
|
metadata,
|
|
1093
1716
|
},
|
|
1094
1717
|
createdAt: new Date().toISOString(),
|
|
@@ -1098,9 +1721,10 @@ export class RefineTasksService {
|
|
|
1098
1721
|
for (const key of taskByKey.keys()) {
|
|
1099
1722
|
existingTaskKeyByNormalized.set(normalizeSplitDependencyRef(key), key);
|
|
1100
1723
|
}
|
|
1101
|
-
const
|
|
1724
|
+
const children = op.children;
|
|
1725
|
+
const childKeys = children.map(() => keyGen());
|
|
1102
1726
|
const childRefToKey = new Map();
|
|
1103
|
-
|
|
1727
|
+
children.forEach((child, index) => {
|
|
1104
1728
|
const childKey = childKeys[index];
|
|
1105
1729
|
childRefToKey.set(normalizeSplitDependencyRef(childKey), childKey);
|
|
1106
1730
|
for (const reference of collectSplitChildReferences(child)) {
|
|
@@ -1110,20 +1734,24 @@ export class RefineTasksService {
|
|
|
1110
1734
|
childRefToKey.set(normalized, childKey);
|
|
1111
1735
|
}
|
|
1112
1736
|
});
|
|
1113
|
-
for (let index = 0; index <
|
|
1114
|
-
const child =
|
|
1737
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
1738
|
+
const child = children[index];
|
|
1115
1739
|
const childKey = childKeys[index];
|
|
1116
1740
|
const childSp = child.storyPoints ?? null;
|
|
1117
1741
|
if (childSp) {
|
|
1118
1742
|
storyPointsDelta += childSp;
|
|
1119
1743
|
}
|
|
1120
|
-
const childMetadata = this.mergeMetadata({}, child.metadata);
|
|
1121
1744
|
const childContent = {
|
|
1122
1745
|
title: child.title,
|
|
1123
1746
|
description: child.description ?? target.description ?? "",
|
|
1124
1747
|
type: child.type ?? target.type ?? "feature",
|
|
1125
1748
|
};
|
|
1126
|
-
const resolvedChildMetadata = this.applyStageMetadata(
|
|
1749
|
+
const resolvedChildMetadata = this.applyStageMetadata(await this.buildExecutionMetadata(group, testCommandBuilder, {
|
|
1750
|
+
title: childContent.title,
|
|
1751
|
+
description: childContent.description,
|
|
1752
|
+
type: childContent.type,
|
|
1753
|
+
updateMetadata: isPlainObject(child.metadata) ? child.metadata : undefined,
|
|
1754
|
+
}), childContent, true);
|
|
1127
1755
|
const childInsert = {
|
|
1128
1756
|
projectId,
|
|
1129
1757
|
epicId: target.epicId,
|
|
@@ -1144,7 +1772,11 @@ export class RefineTasksService {
|
|
|
1144
1772
|
openapiVersionAtCreation: target.openapiVersionAtCreation ?? null,
|
|
1145
1773
|
};
|
|
1146
1774
|
newTasks.push(childInsert);
|
|
1147
|
-
|
|
1775
|
+
const childDependsOn = Array.isArray(child.dependsOn) ? child.dependsOn : [];
|
|
1776
|
+
if (child.dependsOn !== undefined && !Array.isArray(child.dependsOn)) {
|
|
1777
|
+
warnings.push(`Ignored malformed dependsOn for split child ${childKey}; expected an array.`);
|
|
1778
|
+
}
|
|
1779
|
+
for (const dependencyReference of childDependsOn) {
|
|
1148
1780
|
const normalizedReference = normalizeSplitDependencyRef(dependencyReference);
|
|
1149
1781
|
if (!normalizedReference)
|
|
1150
1782
|
continue;
|
|
@@ -1170,30 +1802,46 @@ export class RefineTasksService {
|
|
|
1170
1802
|
updatedAt: "",
|
|
1171
1803
|
storyKey: group.story.key,
|
|
1172
1804
|
epicKey: group.epic.key,
|
|
1173
|
-
dependencies:
|
|
1805
|
+
dependencies: childDependsOn,
|
|
1174
1806
|
});
|
|
1175
1807
|
created.push(childKey);
|
|
1176
1808
|
}
|
|
1177
1809
|
}
|
|
1178
1810
|
else if (op.op === "merge_tasks") {
|
|
1811
|
+
if (!op.targetTaskKey || !Array.isArray(op.sourceTaskKeys) || op.sourceTaskKeys.length === 0) {
|
|
1812
|
+
warnings.push("Skipped merge_tasks without targetTaskKey/sourceTaskKeys.");
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1179
1815
|
const target = taskByKey.get(op.targetTaskKey);
|
|
1180
1816
|
if (!target)
|
|
1181
1817
|
continue;
|
|
1182
|
-
|
|
1818
|
+
const updates = op.updates === undefined
|
|
1819
|
+
? undefined
|
|
1820
|
+
: isPlainObject(op.updates)
|
|
1821
|
+
? op.updates
|
|
1822
|
+
: undefined;
|
|
1823
|
+
if (op.updates !== undefined && !updates) {
|
|
1824
|
+
warnings.push(`Ignored malformed merge updates for ${op.targetTaskKey}; expected an object.`);
|
|
1825
|
+
}
|
|
1826
|
+
if (updates) {
|
|
1183
1827
|
const before = { ...target };
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
},
|
|
1828
|
+
const metadata = this.applyStageMetadata(await this.buildExecutionMetadata(group, testCommandBuilder, {
|
|
1829
|
+
title: updates.title ?? target.title,
|
|
1830
|
+
description: updates.description ?? target.description ?? null,
|
|
1831
|
+
type: updates.type ?? target.type ?? null,
|
|
1832
|
+
existingMetadata: target.metadata,
|
|
1833
|
+
updateMetadata: isPlainObject(updates.metadata) ? updates.metadata : undefined,
|
|
1834
|
+
}), {
|
|
1835
|
+
title: updates.title ?? target.title,
|
|
1836
|
+
description: updates.description ?? target.description ?? null,
|
|
1837
|
+
type: updates.type ?? target.type ?? null,
|
|
1838
|
+
}, updates.title !== undefined || updates.description !== undefined || updates.type !== undefined);
|
|
1191
1839
|
await this.workspaceRepo.updateTask(target.id, {
|
|
1192
|
-
title:
|
|
1193
|
-
description:
|
|
1194
|
-
type:
|
|
1195
|
-
storyPoints:
|
|
1196
|
-
priority:
|
|
1840
|
+
title: updates.title ?? target.title,
|
|
1841
|
+
description: updates.description ?? target.description ?? null,
|
|
1842
|
+
type: updates.type ?? target.type ?? null,
|
|
1843
|
+
storyPoints: updates.storyPoints ?? target.storyPoints ?? null,
|
|
1844
|
+
priority: updates.priority ?? target.priority ?? null,
|
|
1197
1845
|
metadata,
|
|
1198
1846
|
});
|
|
1199
1847
|
updated.push(target.key);
|
|
@@ -1204,8 +1852,8 @@ export class RefineTasksService {
|
|
|
1204
1852
|
snapshotBefore: before,
|
|
1205
1853
|
snapshotAfter: {
|
|
1206
1854
|
...before,
|
|
1207
|
-
...
|
|
1208
|
-
storyPoints:
|
|
1855
|
+
...updates,
|
|
1856
|
+
storyPoints: updates.storyPoints ?? before.storyPoints,
|
|
1209
1857
|
metadata,
|
|
1210
1858
|
},
|
|
1211
1859
|
createdAt: new Date().toISOString(),
|
|
@@ -1233,6 +1881,10 @@ export class RefineTasksService {
|
|
|
1233
1881
|
}
|
|
1234
1882
|
}
|
|
1235
1883
|
else if (op.op === "update_estimate") {
|
|
1884
|
+
if (!op.taskKey) {
|
|
1885
|
+
warnings.push("Skipped update_estimate without taskKey.");
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1236
1888
|
const target = taskByKey.get(op.taskKey);
|
|
1237
1889
|
if (!target)
|
|
1238
1890
|
continue;
|
|
@@ -1267,6 +1919,9 @@ export class RefineTasksService {
|
|
|
1267
1919
|
createdAt: new Date().toISOString(),
|
|
1268
1920
|
});
|
|
1269
1921
|
}
|
|
1922
|
+
else {
|
|
1923
|
+
warnings.push(`Skipped unsupported refine op ${op.op ?? "(missing op)"}.`);
|
|
1924
|
+
}
|
|
1270
1925
|
}
|
|
1271
1926
|
const dependencyGraph = new Map();
|
|
1272
1927
|
const addEdge = (from, to) => {
|
|
@@ -1467,7 +2122,7 @@ export class RefineTasksService {
|
|
|
1467
2122
|
command: "refine-tasks",
|
|
1468
2123
|
action: "agent_refine",
|
|
1469
2124
|
phase: "agent_refine",
|
|
1470
|
-
attempt: 1,
|
|
2125
|
+
attempt: typeof metadata?.attempt === "number" ? metadata.attempt : 1,
|
|
1471
2126
|
...(metadata ?? {}),
|
|
1472
2127
|
},
|
|
1473
2128
|
});
|
|
@@ -1610,11 +2265,15 @@ export class RefineTasksService {
|
|
|
1610
2265
|
plan.metadata = mergedMeta;
|
|
1611
2266
|
if (planInput.warnings)
|
|
1612
2267
|
plan.warnings?.push(...planInput.warnings);
|
|
1613
|
-
//
|
|
2268
|
+
// Keep parsed plan-in ops as-is for downstream enrichment; only scope them to the current selection.
|
|
1614
2269
|
const taskToGroup = new Map();
|
|
1615
2270
|
selection.groups.forEach((g) => g.tasks.forEach((t) => taskToGroup.set(t.key, g)));
|
|
1616
2271
|
const allowCreateMissingPlanIn = false;
|
|
1617
2272
|
for (const rawOp of planInput.operations) {
|
|
2273
|
+
if (!isOperationObject(rawOp)) {
|
|
2274
|
+
plan.warnings?.push("Skipped plan-in item because it was not an operation object.");
|
|
2275
|
+
continue;
|
|
2276
|
+
}
|
|
1618
2277
|
const op = normalizeOperation(rawOp);
|
|
1619
2278
|
const keyCandidate = op.taskKey ?? op.targetTaskKey ?? null;
|
|
1620
2279
|
let group = keyCandidate ? taskToGroup.get(keyCandidate) : undefined;
|
|
@@ -1640,12 +2299,6 @@ export class RefineTasksService {
|
|
|
1640
2299
|
plan.warnings?.push(`Skipped plan-in op because task key not in selection: ${keyCandidate ?? op.op}`);
|
|
1641
2300
|
continue;
|
|
1642
2301
|
}
|
|
1643
|
-
const { valid, reason } = this.validateOperation(group, op);
|
|
1644
|
-
if (!valid) {
|
|
1645
|
-
if (reason)
|
|
1646
|
-
plan.warnings?.push(`Skipped plan-in op: ${reason}`);
|
|
1647
|
-
continue;
|
|
1648
|
-
}
|
|
1649
2302
|
plan.operations.push(op);
|
|
1650
2303
|
}
|
|
1651
2304
|
}
|
|
@@ -1655,8 +2308,25 @@ export class RefineTasksService {
|
|
|
1655
2308
|
}
|
|
1656
2309
|
for (const group of selection.groups) {
|
|
1657
2310
|
try {
|
|
1658
|
-
const
|
|
2311
|
+
const artifactContext = await this.loadPlanningArtifactContext(options.projectKey, group.epic.key, group.story.key);
|
|
2312
|
+
const { summary: docSummary, warnings: docWarnings, docLinks, implementationTargets, testTargets, suggestedTestRequirements, } = await this.summarizeDocs(options.projectKey, group.epic.key, group.story.key);
|
|
1659
2313
|
group.docSummary = docSummary;
|
|
2314
|
+
group.docLinks = normalizeRelatedDocs([
|
|
2315
|
+
...docLinks,
|
|
2316
|
+
...group.tasks.flatMap((task) => normalizeRelatedDocs(task.metadata?.doc_links)),
|
|
2317
|
+
]);
|
|
2318
|
+
group.implementationTargets = uniqueStrings([
|
|
2319
|
+
...artifactContext.implementationTargets,
|
|
2320
|
+
...implementationTargets,
|
|
2321
|
+
...group.tasks.flatMap((task) => normalizeStringArray(task.metadata?.files)),
|
|
2322
|
+
]).slice(0, 8);
|
|
2323
|
+
group.testTargets = uniqueStrings([...artifactContext.testTargets, ...testTargets]).slice(0, 8);
|
|
2324
|
+
group.architectureRoots = uniqueStrings([
|
|
2325
|
+
...artifactContext.architectureRoots,
|
|
2326
|
+
...group.implementationTargets.map((target) => getRepoPathRoot(target)).filter((value) => Boolean(value)),
|
|
2327
|
+
...group.testTargets.map((target) => getRepoPathRoot(target)).filter((value) => Boolean(value)),
|
|
2328
|
+
]).slice(0, 8);
|
|
2329
|
+
group.suggestedTestRequirements = mergeTestRequirements(suggestedTestRequirements, ...group.tasks.map((task) => mergeTestRequirements(normalizeTestRequirements(task.metadata?.test_requirements), normalizeTestRequirements(task.metadata?.testRequirements))));
|
|
1660
2330
|
const historySummary = await this.summarizeHistory(group.tasks.map((t) => t.id));
|
|
1661
2331
|
group.historySummary = historySummary;
|
|
1662
2332
|
await this.jobService.writeCheckpoint(job.id, {
|
|
@@ -1671,32 +2341,73 @@ export class RefineTasksService {
|
|
|
1671
2341
|
await this.jobService.appendLog(job.id, docWarnings.join("\n"));
|
|
1672
2342
|
await this.logWarningsToTasks(group.tasks.map((t) => t.id), job.id, commandRun.id, docWarnings.join("; "));
|
|
1673
2343
|
}
|
|
2344
|
+
if (artifactContext.warnings.length) {
|
|
2345
|
+
plan.warnings?.push(...artifactContext.warnings);
|
|
2346
|
+
}
|
|
1674
2347
|
const prompt = this.buildStoryPrompt(group, strategy, docSummary);
|
|
1675
2348
|
const parseOps = (raw) => {
|
|
1676
2349
|
const parsed = normalizePlanJson(extractJson(raw));
|
|
1677
2350
|
const ops = parsed?.operations && Array.isArray(parsed.operations) ? parsed.operations : [];
|
|
1678
|
-
const normalized =
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
2351
|
+
const normalized = [];
|
|
2352
|
+
for (const candidate of ops) {
|
|
2353
|
+
if (!isOperationObject(candidate)) {
|
|
2354
|
+
plan.warnings?.push(`Skipped malformed op for story ${group.story.key}: operation must be an object.`);
|
|
2355
|
+
continue;
|
|
1683
2356
|
}
|
|
1684
|
-
|
|
1685
|
-
}
|
|
2357
|
+
normalized.push(normalizeOperation(candidate));
|
|
2358
|
+
}
|
|
2359
|
+
return normalized;
|
|
1686
2360
|
};
|
|
1687
|
-
const { raw, agentId } = await this.invokeAgent(options.agentName, prompt, agentStream, job.id, commandRun.id, { epicKey: group.epic.key, storyKey: group.story.key });
|
|
2361
|
+
const { raw, agentId } = await this.invokeAgent(options.agentName, prompt, agentStream, job.id, commandRun.id, { epicKey: group.epic.key, storyKey: group.story.key, attempt: 1 });
|
|
1688
2362
|
ratingAgentId = agentId;
|
|
1689
2363
|
let filtered = parseOps(raw);
|
|
1690
2364
|
if (filtered.length === 0) {
|
|
1691
|
-
const retryPrompt =
|
|
1692
|
-
|
|
2365
|
+
const retryPrompt = [
|
|
2366
|
+
REFINE_OUTPUT_CONTRACT,
|
|
2367
|
+
"RETRY: Your previous response was invalid because it did not return a parseable JSON object.",
|
|
2368
|
+
'Return exactly one JSON object of the form {"operations":[...]}.',
|
|
2369
|
+
'Do not output sentences like "I\'m loading context" or "I\'ll inspect the repo".',
|
|
2370
|
+
'Do not mention Docdex, tools, or additional investigation.',
|
|
2371
|
+
'If you truly have no safe changes, return {"operations":[]}.',
|
|
2372
|
+
"",
|
|
2373
|
+
prompt,
|
|
2374
|
+
].join("\n\n");
|
|
2375
|
+
const retry = await this.invokeAgent(options.agentName, retryPrompt, false, job.id, commandRun.id, { epicKey: group.epic.key, storyKey: group.story.key, retry: true, attempt: 2 });
|
|
1693
2376
|
ratingAgentId = retry.agentId;
|
|
1694
2377
|
filtered = parseOps(retry.raw);
|
|
1695
2378
|
if (filtered.length === 0) {
|
|
1696
|
-
plan.warnings?.push(`No
|
|
2379
|
+
plan.warnings?.push(`No parseable operations returned for story ${group.story.key}.`);
|
|
1697
2380
|
}
|
|
1698
2381
|
}
|
|
1699
|
-
|
|
2382
|
+
let selectedOperations = filtered;
|
|
2383
|
+
let selectedEvaluation = filtered.length > 0 ? this.evaluateOperationsQuality(group, filtered) : undefined;
|
|
2384
|
+
if (selectedEvaluation?.shouldRetry) {
|
|
2385
|
+
plan.warnings?.push(`Triggered critique retry for story ${group.story.key}: ${selectedEvaluation.critiqueLines[0] ?? "generic execution guidance."}`);
|
|
2386
|
+
const retryPrompt = this.buildQualityRetryPrompt(prompt, group, selectedEvaluation);
|
|
2387
|
+
const retry = await this.invokeAgent(options.agentName, retryPrompt, false, job.id, commandRun.id, { epicKey: group.epic.key, storyKey: group.story.key, retry: true, qualityRetry: true, attempt: 3 });
|
|
2388
|
+
ratingAgentId = retry.agentId;
|
|
2389
|
+
const retryOperations = parseOps(retry.raw);
|
|
2390
|
+
if (retryOperations.length > 0) {
|
|
2391
|
+
const retryEvaluation = this.evaluateOperationsQuality(group, retryOperations);
|
|
2392
|
+
if (retryEvaluation.score <= selectedEvaluation.score) {
|
|
2393
|
+
selectedOperations = retryOperations;
|
|
2394
|
+
selectedEvaluation = retryEvaluation;
|
|
2395
|
+
}
|
|
2396
|
+
else {
|
|
2397
|
+
plan.warnings?.push(`Critique retry did not improve story ${group.story.key}; keeping the stronger first parseable result.`);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
else {
|
|
2401
|
+
plan.warnings?.push(`Critique retry for story ${group.story.key} returned no parseable operations; keeping the first parseable result.`);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
if (selectedEvaluation && selectedEvaluation.issueCount > 0) {
|
|
2405
|
+
plan.warnings?.push(`Execution-readiness warnings for story ${group.story.key}: ${selectedEvaluation.critiqueLines
|
|
2406
|
+
.slice(0, 3)
|
|
2407
|
+
.map((line) => line.replace(/^- /, ""))
|
|
2408
|
+
.join("; ")}`);
|
|
2409
|
+
}
|
|
2410
|
+
plan.operations.push(...selectedOperations);
|
|
1700
2411
|
}
|
|
1701
2412
|
catch (error) {
|
|
1702
2413
|
throw new Error(`Failed while refining epic ${group.epic.key} story ${group.story.key}: ${error.message}`);
|