@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.
@@ -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
- "- Enrichment focus: strengthen task descriptions with concrete implementation scope, expected files/modules, and actionable validation details.",
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
- "Example JSON:",
647
- "{\"operations\":[{\"op\":\"update_task\",\"taskKey\":\"web-01-us-01-t01\",\"updates\":{\"title\":\"Refined title\",\"storyPoints\":3}}]}",
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
- if (!docs || docs.length === 0) {
711
- docs = await this.docdex.search({
712
- projectKey,
713
- profile: "openapi",
714
- query,
715
- });
716
- }
717
- if (!docs || docs.length === 0) {
718
- docs = await this.docdex.search({
719
- projectKey,
720
- profile: "workspace-code",
721
- query,
722
- });
723
- }
724
- if (!docs || docs.length === 0) {
725
- return { summary: "(no relevant docdex entries)", warnings: [] };
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 top = docs
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 selected = top.length > 0 ? top : docs.slice(0, 5);
735
- const summary = top
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 || (selected.length ? selected.map((doc) => `- ${doc.title ?? doc.path ?? doc.id}`).join("\n") : "")).trim();
749
- const openApiHintSummary = this.buildOpenApiHintSummary(selected);
750
- const composedSummary = [finalSummary || "(no doc segments found)", openApiHintSummary ? `[OPENAPI_HINTS]\n${openApiHintSummary}` : ""]
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 { summary: "(docdex unavailable)", warnings };
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 rows = await db.all(`
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
- if (!rows || rows.length === 0)
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 rows
791
- .map((row) => {
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
- validateOperation(group, op) {
851
- const allowedOps = new Set(["update_task", "split_task", "merge_tasks", "update_estimate"]);
852
- if (!op || typeof op.op !== "string" || !allowedOps.has(op.op)) {
853
- return { valid: false, reason: "Unknown op type" };
854
- }
855
- if (op.op === "update_task") {
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 (op.op === "merge_tasks") {
941
- const crossStory = op.sourceTaskKeys.some((k) => !keySet.has(k)) ||
942
- (op.targetTaskKey && !keySet.has(op.targetTaskKey));
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 { valid: true };
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
- stage = `op:${op.op}`;
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 mergedMetadata = this.mergeMetadata(target.metadata, op.updates.metadata);
1031
- const contentUpdated = op.updates.title !== undefined || op.updates.description !== undefined || op.updates.type !== undefined;
1032
- const metadata = this.applyStageMetadata(mergedMetadata, {
1033
- title: op.updates.title ?? target.title,
1034
- description: op.updates.description ?? target.description ?? null,
1035
- type: op.updates.type ?? target.type ?? null,
1036
- }, contentUpdated);
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 = op.updates.storyPoints ?? target.storyPoints ?? null;
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: op.updates.title ?? target.title,
1042
- description: op.updates.description ?? target.description ?? null,
1043
- type: op.updates.type ?? target.type ?? null,
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: op.updates.priority ?? target.priority ?? null,
1046
- status: op.updates.status ?? target.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, ...op.updates, storyPoints: afterSp, metadata },
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
- if (op.parentUpdates) {
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 mergedMetadata = this.mergeMetadata(target.metadata, op.parentUpdates.metadata);
1066
- const contentUpdated = op.parentUpdates.title !== undefined ||
1067
- op.parentUpdates.description !== undefined ||
1068
- op.parentUpdates.type !== undefined;
1069
- const metadata = this.applyStageMetadata(mergedMetadata, {
1070
- title: op.parentUpdates.title ?? target.title,
1071
- description: op.parentUpdates.description ?? target.description ?? null,
1072
- type: op.parentUpdates.type ?? target.type ?? null,
1073
- }, contentUpdated);
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: op.parentUpdates.title ?? target.title,
1076
- description: op.parentUpdates.description ?? target.description ?? null,
1077
- type: op.parentUpdates.type ?? target.type ?? null,
1078
- storyPoints: op.parentUpdates.storyPoints ?? target.storyPoints ?? null,
1079
- priority: op.parentUpdates.priority ?? target.priority ?? null,
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
- ...op.parentUpdates,
1091
- storyPoints: op.parentUpdates.storyPoints ?? before.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 childKeys = op.children.map(() => keyGen());
1724
+ const children = op.children;
1725
+ const childKeys = children.map(() => keyGen());
1102
1726
  const childRefToKey = new Map();
1103
- op.children.forEach((child, index) => {
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 < op.children.length; index += 1) {
1114
- const child = op.children[index];
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(childMetadata, childContent, true);
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
- for (const dependencyReference of child.dependsOn ?? []) {
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: child.dependsOn ?? [],
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
- if (op.updates) {
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 mergedMetadata = this.mergeMetadata(target.metadata, op.updates.metadata);
1185
- const contentUpdated = op.updates.title !== undefined || op.updates.description !== undefined || op.updates.type !== undefined;
1186
- const metadata = this.applyStageMetadata(mergedMetadata, {
1187
- title: op.updates.title ?? target.title,
1188
- description: op.updates.description ?? target.description ?? null,
1189
- type: op.updates.type ?? target.type ?? null,
1190
- }, contentUpdated);
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: op.updates.title ?? target.title,
1193
- description: op.updates.description ?? target.description ?? null,
1194
- type: op.updates.type ?? target.type ?? null,
1195
- storyPoints: op.updates.storyPoints ?? target.storyPoints ?? null,
1196
- priority: op.updates.priority ?? target.priority ?? null,
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
- ...op.updates,
1208
- storyPoints: op.updates.storyPoints ?? before.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
- // Validate ops against current selection and group membership.
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 { summary: docSummary, warnings: docWarnings } = await this.summarizeDocs(options.projectKey, group.epic.key, group.story.key);
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 = ops.map(normalizeOperation);
1679
- return normalized.filter((op) => {
1680
- const { valid, reason } = this.validateOperation(group, op);
1681
- if (!valid && reason) {
1682
- plan.warnings?.push(`Skipped op for story ${group.story.key}: ${reason}`);
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
- return valid;
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 = `${prompt}\n\nRETRY: Your previous response did not match the JSON schema. Return only a JSON object with an operations array (no prose, no markdown, no <think> tags).`;
1692
- const retry = await this.invokeAgent(options.agentName, retryPrompt, agentStream, job.id, commandRun.id, { epicKey: group.epic.key, storyKey: group.story.key, retry: true });
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 valid operations returned for story ${group.story.key}.`);
2379
+ plan.warnings?.push(`No parseable operations returned for story ${group.story.key}.`);
1697
2380
  }
1698
2381
  }
1699
- plan.operations.push(...filtered);
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}`);