@mcoda/core 0.1.8 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/README.md +2 -2
  3. package/dist/api/AgentsApi.d.ts +9 -1
  4. package/dist/api/AgentsApi.d.ts.map +1 -1
  5. package/dist/api/AgentsApi.js +201 -6
  6. package/dist/api/QaTasksApi.d.ts.map +1 -1
  7. package/dist/api/QaTasksApi.js +6 -0
  8. package/dist/api/TasksApi.d.ts.map +1 -1
  9. package/dist/api/TasksApi.js +1 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -0
  13. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  14. package/dist/prompts/PdrPrompts.js +9 -1
  15. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  16. package/dist/prompts/SdsPrompts.js +9 -0
  17. package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
  18. package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
  19. package/dist/services/agents/AgentRatingFormula.js +45 -0
  20. package/dist/services/agents/AgentRatingService.d.ts +60 -0
  21. package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
  22. package/dist/services/agents/AgentRatingService.js +363 -0
  23. package/dist/services/agents/GatewayAgentService.d.ts +11 -0
  24. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  25. package/dist/services/agents/GatewayAgentService.js +525 -84
  26. package/dist/services/agents/GatewayHandoff.d.ts +11 -0
  27. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
  28. package/dist/services/agents/GatewayHandoff.js +141 -0
  29. package/dist/services/agents/RoutingService.d.ts +1 -0
  30. package/dist/services/agents/RoutingService.d.ts.map +1 -1
  31. package/dist/services/agents/RoutingService.js +4 -4
  32. package/dist/services/backlog/BacklogService.d.ts +23 -0
  33. package/dist/services/backlog/BacklogService.d.ts.map +1 -1
  34. package/dist/services/backlog/BacklogService.js +62 -7
  35. package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
  36. package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
  37. package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
  38. package/dist/services/backlog/TaskOrderingService.d.ts +17 -4
  39. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  40. package/dist/services/backlog/TaskOrderingService.js +538 -79
  41. package/dist/services/docs/DocInventory.d.ts +11 -0
  42. package/dist/services/docs/DocInventory.d.ts.map +1 -0
  43. package/dist/services/docs/DocInventory.js +230 -0
  44. package/dist/services/docs/DocgenRunContext.d.ts +59 -0
  45. package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
  46. package/dist/services/docs/DocgenRunContext.js +4 -0
  47. package/dist/services/docs/DocsService.d.ts +70 -3
  48. package/dist/services/docs/DocsService.d.ts.map +1 -1
  49. package/dist/services/docs/DocsService.js +1930 -89
  50. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
  51. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
  52. package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
  53. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
  54. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
  55. package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
  56. package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
  57. package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
  58. package/dist/services/docs/patch/DocPatchEngine.js +331 -0
  59. package/dist/services/docs/review/Glossary.d.ts +16 -0
  60. package/dist/services/docs/review/Glossary.d.ts.map +1 -0
  61. package/dist/services/docs/review/Glossary.js +47 -0
  62. package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
  63. package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
  64. package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
  65. package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
  66. package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
  67. package/dist/services/docs/review/ReviewReportSchema.js +47 -0
  68. package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
  69. package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
  70. package/dist/services/docs/review/ReviewTypes.js +94 -0
  71. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
  72. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
  73. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
  74. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
  75. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
  76. package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
  77. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
  78. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
  79. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
  80. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
  81. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
  82. package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
  83. package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
  84. package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
  85. package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
  86. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
  87. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
  88. package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
  89. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
  90. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
  91. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
  92. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
  93. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
  94. package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
  95. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
  96. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
  97. package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
  98. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
  99. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
  100. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
  101. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
  102. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
  103. package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
  104. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
  105. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
  106. package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
  107. package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
  108. package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
  109. package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
  110. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
  111. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
  112. package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
  113. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
  114. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
  115. package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
  116. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
  117. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
  118. package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
  119. package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
  120. package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
  121. package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
  122. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
  123. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
  124. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
  125. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
  126. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
  127. package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
  128. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
  129. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
  130. package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
  131. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
  132. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
  133. package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
  134. package/dist/services/docs/review/glossary.json +47 -0
  135. package/dist/services/estimate/EstimateService.d.ts +2 -0
  136. package/dist/services/estimate/EstimateService.d.ts.map +1 -1
  137. package/dist/services/estimate/EstimateService.js +66 -18
  138. package/dist/services/estimate/VelocityService.d.ts +4 -0
  139. package/dist/services/estimate/VelocityService.d.ts.map +1 -1
  140. package/dist/services/estimate/VelocityService.js +179 -36
  141. package/dist/services/estimate/types.d.ts +1 -0
  142. package/dist/services/estimate/types.d.ts.map +1 -1
  143. package/dist/services/execution/GatewayTrioService.d.ts +200 -0
  144. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
  145. package/dist/services/execution/GatewayTrioService.js +2492 -0
  146. package/dist/services/execution/QaApiRunner.d.ts +30 -0
  147. package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
  148. package/dist/services/execution/QaApiRunner.js +881 -0
  149. package/dist/services/execution/QaFollowupService.d.ts +2 -0
  150. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  151. package/dist/services/execution/QaFollowupService.js +9 -2
  152. package/dist/services/execution/QaPlanValidator.d.ts +10 -0
  153. package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
  154. package/dist/services/execution/QaPlanValidator.js +128 -0
  155. package/dist/services/execution/QaProfileService.d.ts +27 -1
  156. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  157. package/dist/services/execution/QaProfileService.js +354 -7
  158. package/dist/services/execution/QaTasksService.d.ts +59 -1
  159. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  160. package/dist/services/execution/QaTasksService.js +3347 -318
  161. package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
  162. package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
  163. package/dist/services/execution/QaTestCommandBuilder.js +495 -0
  164. package/dist/services/execution/TaskSelectionService.d.ts +4 -2
  165. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  166. package/dist/services/execution/TaskSelectionService.js +144 -28
  167. package/dist/services/execution/TaskStateService.d.ts +19 -6
  168. package/dist/services/execution/TaskStateService.d.ts.map +1 -1
  169. package/dist/services/execution/TaskStateService.js +128 -13
  170. package/dist/services/execution/WorkOnTasksService.d.ts +32 -1
  171. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  172. package/dist/services/execution/WorkOnTasksService.js +4667 -722
  173. package/dist/services/jobs/JobInsightsService.d.ts +4 -0
  174. package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
  175. package/dist/services/jobs/JobInsightsService.js +51 -5
  176. package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
  177. package/dist/services/jobs/JobResumeService.js +23 -10
  178. package/dist/services/jobs/JobService.d.ts +56 -4
  179. package/dist/services/jobs/JobService.d.ts.map +1 -1
  180. package/dist/services/jobs/JobService.js +232 -1
  181. package/dist/services/openapi/OpenApiService.d.ts +51 -0
  182. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  183. package/dist/services/openapi/OpenApiService.js +953 -106
  184. package/dist/services/planning/CreateTasksService.d.ts +21 -0
  185. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  186. package/dist/services/planning/CreateTasksService.js +569 -31
  187. package/dist/services/planning/RefineTasksService.d.ts +9 -0
  188. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  189. package/dist/services/planning/RefineTasksService.js +409 -59
  190. package/dist/services/review/CodeReviewService.d.ts +18 -0
  191. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  192. package/dist/services/review/CodeReviewService.js +1309 -167
  193. package/dist/services/review/ReviewNormalizer.d.ts +9 -0
  194. package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
  195. package/dist/services/review/ReviewNormalizer.js +147 -0
  196. package/dist/services/shared/AuthErrors.d.ts +3 -0
  197. package/dist/services/shared/AuthErrors.d.ts.map +1 -0
  198. package/dist/services/shared/AuthErrors.js +17 -0
  199. package/dist/services/shared/DocdexGuidance.d.ts +7 -0
  200. package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
  201. package/dist/services/shared/DocdexGuidance.js +12 -0
  202. package/dist/services/shared/ProjectGuidance.d.ts +17 -0
  203. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
  204. package/dist/services/shared/ProjectGuidance.js +78 -0
  205. package/dist/services/system/ToolDenylist.d.ts +13 -0
  206. package/dist/services/system/ToolDenylist.d.ts.map +1 -0
  207. package/dist/services/system/ToolDenylist.js +85 -0
  208. package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
  209. package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
  210. package/dist/services/tasks/TaskCommentFormatter.js +54 -0
  211. package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
  212. package/dist/services/telemetry/TelemetryService.js +39 -7
  213. package/dist/workspace/WorkspaceManager.d.ts +26 -0
  214. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  215. package/dist/workspace/WorkspaceManager.js +206 -32
  216. package/package.json +6 -5
@@ -1,9 +1,10 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { AgentService } from "@mcoda/agents";
4
5
  import { DocdexClient, VcsClient } from "@mcoda/integrations";
5
- import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
6
- import { PathHelper } from "@mcoda/shared";
6
+ import { GlobalRepository, WorkspaceRepository, } from "@mcoda/db";
7
+ import { PathHelper, READY_TO_CODE_REVIEW, REVIEW_ALLOWED_STATUSES, filterTaskStatuses, normalizeReviewStatuses, } from "@mcoda/shared";
7
8
  import { JobService } from "../jobs/JobService.js";
8
9
  import { TaskSelectionService } from "../execution/TaskSelectionService.js";
9
10
  import { TaskStateService } from "../execution/TaskStateService.js";
@@ -11,36 +12,88 @@ import { BacklogService } from "../backlog/BacklogService.js";
11
12
  import yaml from "yaml";
12
13
  import { createTaskKeyGenerator } from "../planning/KeyHelpers.js";
13
14
  import { RoutingService } from "../agents/RoutingService.js";
15
+ import { AgentRatingService } from "../agents/AgentRatingService.js";
16
+ import { isDocContextExcluded, loadProjectGuidance, normalizeDocType } from "../shared/ProjectGuidance.js";
17
+ import { buildDocdexUsageGuidance } from "../shared/DocdexGuidance.js";
18
+ import { createTaskCommentSlug, formatTaskCommentBody } from "../tasks/TaskCommentFormatter.js";
19
+ import { AUTH_ERROR_REASON, isAuthErrorMessage } from "../shared/AuthErrors.js";
20
+ import { normalizeReviewOutput } from "./ReviewNormalizer.js";
14
21
  const DEFAULT_BASE_BRANCH = "mcoda-dev";
15
- const REVIEW_DIR = (workspaceRoot, jobId) => path.join(workspaceRoot, ".mcoda", "jobs", jobId, "review");
16
- const STATE_PATH = (workspaceRoot, jobId) => path.join(REVIEW_DIR(workspaceRoot, jobId), "state.json");
22
+ const REVIEW_DIR = (mcodaDir, jobId) => path.join(mcodaDir, "jobs", jobId, "review");
23
+ const STATE_PATH = (mcodaDir, jobId) => path.join(REVIEW_DIR(mcodaDir, jobId), "state.json");
24
+ const REVIEW_PROMPT_LIMITS = {
25
+ diff: 12000,
26
+ history: 3000,
27
+ docContext: 4000,
28
+ openapi: 8000,
29
+ checklist: 3000,
30
+ };
31
+ const DOCDEX_TIMEOUT_MS = 8000;
17
32
  const DEFAULT_CODE_REVIEW_PROMPT = [
18
- "You are the code-review agent. Before reviewing, query docdex with the task key and feature keywords (MCP `docdex_search` limit 4–8 or CLI `docdexd query --repo <repo> --query \"<term>\" --limit 6 --snippets=false`). If results look stale, reindex (`docdex_index` or `docdexd index --repo <repo>`) then re-run. Fetch snippets via `docdex_open` or `/snippet/:doc_id?text_only=true` only for specific hits.",
33
+ "You are the code-review agent.",
34
+ buildDocdexUsageGuidance({ contextLabel: "the review", includeHeading: false, includeFallback: true }),
19
35
  "Use docdex snippets to verify contracts (data shapes, offline scope, accessibility/perf guardrails, acceptance criteria). Call out mismatches, missing tests, and undocumented changes.",
36
+ "When recommending tests, prefer the repo's existing runner (tests/all.js or package manager scripts). Avoid suggesting new Jest configs unless the repo explicitly documents them.",
37
+ "Do not require docs/qa/<task>.md reports unless the task explicitly asks for one. QA artifacts typically live in mcoda workspace outputs.",
38
+ "Do not hardcode ports; if a port matters, call out that it must be discovered or configured dynamically.",
20
39
  ].join("\n");
40
+ const REPO_PROMPTS_DIR = fileURLToPath(new URL("../../../../../prompts/", import.meta.url));
41
+ const resolveRepoPromptPath = (filename) => path.join(REPO_PROMPTS_DIR, filename);
21
42
  const DEFAULT_JOB_PROMPT = "You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.";
22
43
  const DEFAULT_CHARACTER_PROMPT = "Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.";
23
- const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
24
- const parseJsonOutput = (raw) => {
25
- const trimmed = raw.trim();
26
- const fenced = trimmed.replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
27
- const candidates = [trimmed, fenced];
28
- for (const candidate of candidates) {
29
- const start = candidate.indexOf("{");
30
- const end = candidate.lastIndexOf("}");
31
- if (start === -1 || end === -1 || end <= start)
44
+ const GATEWAY_PROMPT_MARKERS = [
45
+ "you are the gateway agent",
46
+ "return json only",
47
+ "output json only",
48
+ "docdexnotes",
49
+ "fileslikelytouched",
50
+ "filestocreate",
51
+ "do not include fields outside the schema",
52
+ ];
53
+ const sanitizeNonGatewayPrompt = (value) => {
54
+ if (!value)
55
+ return undefined;
56
+ const trimmed = value.trim();
57
+ if (!trimmed)
58
+ return undefined;
59
+ const lower = trimmed.toLowerCase();
60
+ if (GATEWAY_PROMPT_MARKERS.some((marker) => lower.includes(marker)))
61
+ return undefined;
62
+ return trimmed;
63
+ };
64
+ const readPromptFile = async (promptPath, fallback) => {
65
+ try {
66
+ const content = await fs.readFile(promptPath, "utf8");
67
+ const trimmed = content.trim();
68
+ if (trimmed)
69
+ return trimmed;
70
+ }
71
+ catch {
72
+ // fall through to fallback
73
+ }
74
+ return fallback;
75
+ };
76
+ const filterOpenApiContext = (entries, hasOpenApiSnippet) => {
77
+ let openApiIncluded = false;
78
+ const filtered = [];
79
+ for (const entry of entries) {
80
+ const isOpenApi = /\[linked:openapi\]|\[openapi\]/i.test(entry);
81
+ if (!isOpenApi) {
82
+ filtered.push(entry);
32
83
  continue;
33
- const slice = candidate.slice(start, end + 1);
34
- try {
35
- const parsed = JSON.parse(slice);
36
- return { ...parsed, raw: raw };
37
84
  }
38
- catch {
39
- /* ignore */
85
+ if (hasOpenApiSnippet) {
86
+ continue;
87
+ }
88
+ if (openApiIncluded) {
89
+ continue;
40
90
  }
91
+ openApiIncluded = true;
92
+ filtered.push(entry);
41
93
  }
42
- return undefined;
94
+ return filtered;
43
95
  };
96
+ const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
44
97
  const summarizeComments = (comments) => {
45
98
  if (!comments.length)
46
99
  return "No prior comments.";
@@ -51,6 +104,41 @@ const summarizeComments = (comments) => {
51
104
  })
52
105
  .join("\n");
53
106
  };
107
+ const truncateSection = (label, text, limit) => {
108
+ if (!text)
109
+ return text;
110
+ if (text.length <= limit)
111
+ return text;
112
+ const trimmed = text.slice(0, limit);
113
+ const remaining = text.length - limit;
114
+ return `${trimmed}\n...[truncated ${remaining} chars from ${label}]`;
115
+ };
116
+ const isNonBlockingFinding = (finding) => {
117
+ const severity = (finding.severity ?? "").toLowerCase();
118
+ if (["info", "low"].includes(severity))
119
+ return true;
120
+ return false;
121
+ };
122
+ const isNonBlockingOnly = (findings = []) => {
123
+ if (!findings.length)
124
+ return false;
125
+ return findings.every((finding) => isNonBlockingFinding(finding));
126
+ };
127
+ const withTimeout = async (promise, ms, label) => {
128
+ let timeoutId;
129
+ const timeoutPromise = new Promise((_, reject) => {
130
+ timeoutId = setTimeout(() => {
131
+ reject(new Error(`${label} timed out after ${ms}ms`));
132
+ }, ms);
133
+ });
134
+ try {
135
+ return await Promise.race([promise, timeoutPromise]);
136
+ }
137
+ finally {
138
+ if (timeoutId)
139
+ clearTimeout(timeoutId);
140
+ }
141
+ };
54
142
  const JSON_CONTRACT = `{
55
143
  "decision": "approve | changes_requested | block | info_only",
56
144
  "summary": "short textual summary",
@@ -64,28 +152,179 @@ const JSON_CONTRACT = `{
64
152
  "suggestedFix": "Optional suggested change"
65
153
  }
66
154
  ],
67
- "testRecommendations": ["Optional test or QA recommendations per task"]
155
+ "testRecommendations": ["Optional test or QA recommendations per task"],
156
+ "resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],
157
+ "unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]
68
158
  }`;
159
+ const JSON_RETRY_RULES = [
160
+ "Return ONLY valid JSON. No markdown, no prose, no code fences.",
161
+ "The response must start with '{' and end with '}'.",
162
+ "Match the schema exactly; use empty arrays when no items apply.",
163
+ ].join("\n");
164
+ const isRetryableAgentError = (message) => /unexpected eof|econnreset|etimedout|socket hang up|fetch failed|connection closed/i.test(message.toLowerCase());
69
165
  const normalizeSingleLine = (value, fallback) => {
70
166
  const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
71
167
  return trimmed || fallback;
72
168
  };
169
+ const normalizeSlugList = (input) => {
170
+ if (!Array.isArray(input))
171
+ return [];
172
+ const cleaned = new Set();
173
+ for (const slug of input) {
174
+ if (typeof slug !== "string")
175
+ continue;
176
+ const trimmed = slug.trim();
177
+ if (trimmed)
178
+ cleaned.add(trimmed);
179
+ }
180
+ return Array.from(cleaned);
181
+ };
182
+ const normalizePath = (value) => value
183
+ .replace(/\\/g, "/")
184
+ .replace(/^\.\//, "")
185
+ .replace(/^\/+/, "");
186
+ const normalizeLineNumber = (value) => {
187
+ if (typeof value === "number" && Number.isFinite(value)) {
188
+ return Math.max(1, Math.round(value));
189
+ }
190
+ if (typeof value === "string") {
191
+ const parsed = Number.parseInt(value, 10);
192
+ if (Number.isFinite(parsed))
193
+ return Math.max(1, parsed);
194
+ }
195
+ return undefined;
196
+ };
197
+ const summaryIndicatesNoChanges = (summary) => {
198
+ const normalized = (summary ?? "").toLowerCase();
199
+ if (!normalized)
200
+ return false;
201
+ const patterns = [
202
+ "no changes required",
203
+ "no changes needed",
204
+ "no change required",
205
+ "no change needed",
206
+ "no code changes",
207
+ "already complete",
208
+ "already completed",
209
+ "already satisfied",
210
+ ];
211
+ return patterns.some((pattern) => normalized.includes(pattern));
212
+ };
213
+ const validateReviewOutput = (result, options = {}) => {
214
+ if (!result.decision || !["approve", "changes_requested", "block", "info_only"].includes(result.decision)) {
215
+ return "Review decision is required.";
216
+ }
217
+ if (!result.summary || !result.summary.trim()) {
218
+ return "Review summary is required.";
219
+ }
220
+ if (options.requireCommentSlugs && result.resolvedSlugs === undefined && result.unresolvedSlugs === undefined) {
221
+ return "resolvedSlugs/unresolvedSlugs required when comment backlog exists.";
222
+ }
223
+ for (const finding of result.findings ?? []) {
224
+ const message = (finding.message ?? "").trim();
225
+ const file = typeof finding.file === "string" ? finding.file.trim() : "";
226
+ const line = normalizeLineNumber(finding.line);
227
+ if (!message || !file || !line) {
228
+ return "Each review finding must include file, line, and message.";
229
+ }
230
+ finding.file = normalizePath(file);
231
+ finding.line = line;
232
+ finding.message = message;
233
+ }
234
+ return undefined;
235
+ };
236
+ const parseCommentBody = (body) => {
237
+ const trimmed = (body ?? "").trim();
238
+ if (!trimmed)
239
+ return { message: "(no details provided)" };
240
+ const lines = trimmed.split(/\r?\n/);
241
+ const normalize = (value) => value.trim().toLowerCase();
242
+ const messageIndex = lines.findIndex((line) => normalize(line) === "message:");
243
+ const suggestedIndex = lines.findIndex((line) => {
244
+ const normalized = normalize(line);
245
+ return normalized === "suggested_fix:" || normalized === "suggested fix:";
246
+ });
247
+ if (messageIndex >= 0) {
248
+ const messageLines = lines.slice(messageIndex + 1, suggestedIndex >= 0 ? suggestedIndex : undefined);
249
+ const message = messageLines.join("\n").trim();
250
+ const suggestedLines = suggestedIndex >= 0 ? lines.slice(suggestedIndex + 1) : [];
251
+ const suggestedFix = suggestedLines.join("\n").trim();
252
+ return { message: message || trimmed, suggestedFix: suggestedFix || undefined };
253
+ }
254
+ if (suggestedIndex >= 0) {
255
+ const message = lines.slice(0, suggestedIndex).join("\n").trim() || trimmed;
256
+ const inlineFix = lines[suggestedIndex]?.split(/suggested fix:/i)[1]?.trim();
257
+ const suggestedTail = lines.slice(suggestedIndex + 1).join("\n").trim();
258
+ const suggestedFix = inlineFix || suggestedTail || undefined;
259
+ return { message, suggestedFix };
260
+ }
261
+ return { message: trimmed };
262
+ };
263
+ const buildCommentBacklog = (comments) => {
264
+ if (!comments.length)
265
+ return "";
266
+ const seen = new Set();
267
+ const lines = [];
268
+ const toSingleLine = (value) => value.replace(/\s+/g, " ").trim();
269
+ for (const comment of comments) {
270
+ const details = parseCommentBody(comment.body);
271
+ const slug = comment.slug?.trim() ||
272
+ createTaskCommentSlug({
273
+ source: comment.sourceCommand ?? "comment",
274
+ message: details.message || comment.body,
275
+ file: comment.file,
276
+ line: comment.line,
277
+ category: comment.category ?? null,
278
+ });
279
+ const key = slug ??
280
+ `${comment.sourceCommand}:${comment.file ?? ""}:${comment.line ?? ""}:${details.message || comment.body}`;
281
+ if (seen.has(key))
282
+ continue;
283
+ seen.add(key);
284
+ const location = comment.file
285
+ ? `${comment.file}${typeof comment.line === "number" ? `:${comment.line}` : ""}`
286
+ : "(location not specified)";
287
+ const message = toSingleLine(details.message || comment.body || "(no details provided)");
288
+ lines.push(`- [${slug ?? "untracked"}] ${location} ${message}`);
289
+ const suggestedFix = comment.metadata?.suggestedFix ?? details.suggestedFix ?? undefined;
290
+ if (suggestedFix) {
291
+ lines.push(` Suggested fix: ${toSingleLine(suggestedFix)}`);
292
+ }
293
+ }
294
+ return lines.join("\n");
295
+ };
296
+ const formatSlugList = (slugs, limit = 12) => {
297
+ if (!slugs.length)
298
+ return "none";
299
+ if (slugs.length <= limit)
300
+ return slugs.join(", ");
301
+ return `${slugs.slice(0, limit).join(", ")} (+${slugs.length - limit} more)`;
302
+ };
73
303
  const buildStandardReviewComment = (params) => {
74
304
  const decision = params.decision ?? (params.error ? "error" : "info_only");
75
305
  const statusAfter = params.statusAfter ?? params.statusBefore;
76
306
  const summary = normalizeSingleLine(params.summary, params.error ? "Review failed." : "No summary provided.");
77
307
  const error = normalizeSingleLine(params.error, "none");
78
308
  const followups = params.followupTaskKeys && params.followupTaskKeys.length ? params.followupTaskKeys.join(", ") : "none";
79
- return [
309
+ const lines = [
80
310
  "[code-review]",
81
311
  `decision: ${decision}`,
82
312
  `status_before: ${params.statusBefore}`,
83
313
  `status_after: ${statusAfter}`,
84
314
  `findings: ${params.findingsCount}`,
85
315
  `summary: ${summary}`,
86
- `followups: ${followups}`,
87
- `error: ${error}`,
88
- ].join("\n");
316
+ ];
317
+ if (typeof params.resolvedCount === "number") {
318
+ lines.push(`resolved_slugs: ${params.resolvedCount}`);
319
+ }
320
+ if (typeof params.reopenedCount === "number") {
321
+ lines.push(`reopened_slugs: ${params.reopenedCount}`);
322
+ }
323
+ if (typeof params.openCount === "number") {
324
+ lines.push(`open_slugs: ${params.openCount}`);
325
+ }
326
+ lines.push(`followups: ${followups}`, `error: ${error}`);
327
+ return lines.join("\n");
89
328
  };
90
329
  export class CodeReviewService {
91
330
  constructor(workspace, deps) {
@@ -96,14 +335,17 @@ export class CodeReviewService {
96
335
  this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
97
336
  this.vcs = deps.vcsClient ?? new VcsClient();
98
337
  this.routingService = deps.routingService;
338
+ this.ratingService = deps.ratingService;
99
339
  }
100
340
  static async create(workspace) {
101
341
  const repo = await GlobalRepository.create();
102
342
  const agentService = new AgentService(repo);
103
343
  const routingService = await RoutingService.create();
344
+ const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
104
345
  const docdex = new DocdexClient({
105
346
  workspaceRoot: workspace.workspaceRoot,
106
347
  baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
348
+ repoId: docdexRepoId,
107
349
  });
108
350
  const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
109
351
  const jobService = new JobService(workspace, workspaceRepo);
@@ -141,6 +383,14 @@ export class CodeReviewService {
141
383
  await maybeClose(this.deps.routingService);
142
384
  await maybeClose(this.deps.docdex);
143
385
  }
386
+ setDocdexAvailability(available, reason) {
387
+ if (available)
388
+ return;
389
+ const docdex = this.deps.docdex;
390
+ if (docdex && typeof docdex.disable === "function") {
391
+ docdex.disable(reason);
392
+ }
393
+ }
144
394
  async readPromptFiles(paths) {
145
395
  const contents = [];
146
396
  const seen = new Set();
@@ -161,21 +411,11 @@ export class CodeReviewService {
161
411
  }
162
412
  async ensureMcoda() {
163
413
  await PathHelper.ensureDir(this.workspace.mcodaDir);
164
- const gitignorePath = path.join(this.workspace.workspaceRoot, ".gitignore");
165
- const entry = ".mcoda/\n";
166
- try {
167
- const content = await fs.readFile(gitignorePath, "utf8");
168
- if (!content.includes(".mcoda/")) {
169
- await fs.writeFile(gitignorePath, `${content.trimEnd()}\n${entry}`, "utf8");
170
- }
171
- }
172
- catch {
173
- await fs.writeFile(gitignorePath, entry, "utf8");
174
- }
175
414
  }
176
415
  async loadPrompts(agentId) {
177
- const mcodaPromptPath = path.join(this.workspace.workspaceRoot, ".mcoda", "prompts", "code-reviewer.md");
416
+ const mcodaPromptPath = path.join(this.workspace.mcodaDir, "prompts", "code-reviewer.md");
178
417
  const workspacePromptPath = path.join(this.workspace.workspaceRoot, "prompts", "code-reviewer.md");
418
+ const repoPromptPath = resolveRepoPromptPath("code-reviewer.md");
179
419
  try {
180
420
  await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
181
421
  await fs.access(mcodaPromptPath);
@@ -188,30 +428,29 @@ export class CodeReviewService {
188
428
  console.info(`[code-review] copied code-reviewer prompt to ${mcodaPromptPath}`);
189
429
  }
190
430
  catch {
191
- console.info(`[code-review] no code-reviewer prompt found at ${workspacePromptPath}; writing default prompt to ${mcodaPromptPath}`);
192
- await fs.writeFile(mcodaPromptPath, DEFAULT_CODE_REVIEW_PROMPT, 'utf8');
431
+ try {
432
+ await fs.access(repoPromptPath);
433
+ await fs.copyFile(repoPromptPath, mcodaPromptPath);
434
+ console.info(`[code-review] copied repo code-reviewer prompt to ${mcodaPromptPath}`);
435
+ }
436
+ catch {
437
+ console.info(`[code-review] no code-reviewer prompt found at ${workspacePromptPath} or repo prompts; writing default prompt to ${mcodaPromptPath}`);
438
+ await fs.writeFile(mcodaPromptPath, DEFAULT_CODE_REVIEW_PROMPT, "utf8");
439
+ }
193
440
  }
194
441
  }
195
- const filePrompts = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
196
442
  const agentPrompts = "getPrompts" in this.deps.agentService ? await this.deps.agentService.getPrompts(agentId) : undefined;
197
- const mergedCommandPrompt = (() => {
198
- const parts = [...filePrompts];
199
- if (agentPrompts?.commandPrompts?.["code-review"]) {
200
- parts.push(agentPrompts.commandPrompts["code-review"]);
201
- }
202
- if (!parts.length)
203
- parts.push(DEFAULT_CODE_REVIEW_PROMPT);
204
- return parts.filter(Boolean).join("\n\n");
205
- })();
443
+ const filePrompt = await readPromptFile(mcodaPromptPath, DEFAULT_CODE_REVIEW_PROMPT);
444
+ const commandPrompt = agentPrompts?.commandPrompts?.["code-review"]?.trim() || filePrompt;
206
445
  return {
207
- jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
208
- characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
209
- commandPrompt: mergedCommandPrompt || undefined,
446
+ jobPrompt: sanitizeNonGatewayPrompt(agentPrompts?.jobPrompt) ?? DEFAULT_JOB_PROMPT,
447
+ characterPrompt: sanitizeNonGatewayPrompt(agentPrompts?.characterPrompt) ?? DEFAULT_CHARACTER_PROMPT,
448
+ commandPrompt: commandPrompt || undefined,
210
449
  };
211
450
  }
212
451
  async loadRunbookAndChecklists() {
213
452
  const extras = [];
214
- const runbookPath = path.join(this.workspace.workspaceRoot, ".mcoda", "prompts", "commands", "code-review.md");
453
+ const runbookPath = path.join(this.workspace.mcodaDir, "prompts", "commands", "code-review.md");
215
454
  try {
216
455
  const content = await fs.readFile(runbookPath, "utf8");
217
456
  extras.push(content);
@@ -219,7 +458,7 @@ export class CodeReviewService {
219
458
  catch {
220
459
  /* optional */
221
460
  }
222
- const checklistDir = path.join(this.workspace.workspaceRoot, ".mcoda", "checklists");
461
+ const checklistDir = path.join(this.workspace.mcodaDir, "checklists");
223
462
  try {
224
463
  const entries = await fs.readdir(checklistDir);
225
464
  for (const entry of entries) {
@@ -240,7 +479,33 @@ export class CodeReviewService {
240
479
  commandName: "code-review",
241
480
  overrideAgentSlug: agentName,
242
481
  });
243
- return resolved.agent;
482
+ if (agentName) {
483
+ const matches = agentName === resolved.agent.id || agentName === resolved.agent.slug;
484
+ if (!matches) {
485
+ throw new Error(`Review agent override "${agentName}" resolved to "${resolved.agent.slug}" (source: ${resolved.source}).`);
486
+ }
487
+ }
488
+ return resolved;
489
+ }
490
+ ensureRatingService() {
491
+ if (!this.ratingService) {
492
+ this.ratingService = new AgentRatingService(this.workspace, {
493
+ workspaceRepo: this.deps.workspaceRepo,
494
+ globalRepo: this.deps.repo,
495
+ agentService: this.deps.agentService,
496
+ routingService: this.routingService,
497
+ });
498
+ }
499
+ return this.ratingService;
500
+ }
501
+ resolveTaskComplexity(task) {
502
+ const metadata = task.metadata ?? {};
503
+ const metaComplexity = typeof metadata.complexity === "number" && Number.isFinite(metadata.complexity) ? metadata.complexity : undefined;
504
+ const storyPoints = typeof task.storyPoints === "number" && Number.isFinite(task.storyPoints) ? task.storyPoints : undefined;
505
+ const candidate = metaComplexity ?? storyPoints;
506
+ if (!Number.isFinite(candidate ?? NaN))
507
+ return undefined;
508
+ return Math.min(10, Math.max(1, Math.round(candidate)));
244
509
  }
245
510
  async selectTasksViaApi(filters) {
246
511
  // Prefer the backlog/task OpenAPI surface (via BacklogService) to mirror API filtering semantics.
@@ -250,7 +515,7 @@ export class CodeReviewService {
250
515
  projectKey: filters.projectKey,
251
516
  epicKey: filters.epicKey,
252
517
  storyKey: filters.storyKey,
253
- statuses: filters.statusFilter,
518
+ statuses: filters.statusFilter && filters.statusFilter.length ? filters.statusFilter : undefined,
254
519
  verbose: true,
255
520
  });
256
521
  let tasks = result.summary.tasks;
@@ -272,13 +537,13 @@ export class CodeReviewService {
272
537
  }
273
538
  }
274
539
  async persistState(jobId, state) {
275
- const dir = REVIEW_DIR(this.workspace.workspaceRoot, jobId);
540
+ const dir = REVIEW_DIR(this.workspace.mcodaDir, jobId);
276
541
  await fs.mkdir(dir, { recursive: true });
277
- await fs.writeFile(STATE_PATH(this.workspace.workspaceRoot, jobId), JSON.stringify({ schema_version: 1, job_id: jobId, updated_at: new Date().toISOString(), ...state }, null, 2), "utf8");
542
+ await fs.writeFile(STATE_PATH(this.workspace.mcodaDir, jobId), JSON.stringify({ schema_version: 1, job_id: jobId, updated_at: new Date().toISOString(), ...state }, null, 2), "utf8");
278
543
  }
279
544
  async loadState(jobId) {
280
545
  try {
281
- const raw = await fs.readFile(STATE_PATH(this.workspace.workspaceRoot, jobId), "utf8");
546
+ const raw = await fs.readFile(STATE_PATH(this.workspace.mcodaDir, jobId), "utf8");
282
547
  return JSON.parse(raw);
283
548
  }
284
549
  catch {
@@ -305,26 +570,109 @@ export class CodeReviewService {
305
570
  }
306
571
  return Array.from(hints).slice(0, 8);
307
572
  }
308
- async gatherDocContext(taskTitle, paths, acceptance) {
573
+ async gatherDocContext(taskTitle, paths, acceptance, docLinks = []) {
309
574
  const snippets = [];
310
575
  const warnings = [];
576
+ if (typeof this.deps.docdex?.ensureRepoScope === "function") {
577
+ try {
578
+ await this.deps.docdex.ensureRepoScope();
579
+ }
580
+ catch (error) {
581
+ warnings.push(`docdex scope missing: ${error.message}`);
582
+ return { snippets, warnings };
583
+ }
584
+ }
311
585
  const queries = [...new Set([...(paths.length ? this.componentHintsFromPaths(paths) : []), taskTitle, ...(acceptance ?? [])])].slice(0, 8);
586
+ let reindexed = false;
587
+ const resolveDocType = (doc, pathOverride) => {
588
+ const content = doc.segments?.[0]?.content ?? doc.content ?? "";
589
+ const normalized = normalizeDocType({
590
+ docType: doc.docType,
591
+ path: doc.path ?? pathOverride,
592
+ title: doc.title,
593
+ content,
594
+ });
595
+ if (normalized.downgraded) {
596
+ warnings.push(`Docdex docType downgraded from SDS to DOC for ${doc.path ?? doc.title ?? doc.docType ?? "unknown"}: ${normalized.reason ?? "not_sds"}`);
597
+ }
598
+ return normalized.docType;
599
+ };
312
600
  for (const query of queries) {
313
601
  try {
314
- const docs = await this.deps.docdex.search({
602
+ const docs = await withTimeout(this.deps.docdex.search({
315
603
  query,
316
604
  profile: "workspace-code",
317
- });
318
- snippets.push(...docs.slice(0, 2).map((doc) => {
605
+ }), DOCDEX_TIMEOUT_MS, `docdex search for "${query}"`);
606
+ const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, false));
607
+ snippets.push(...filteredDocs.slice(0, 2).map((doc) => {
319
608
  const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
320
609
  const ref = doc.path ?? doc.id ?? doc.title ?? query;
321
- return `- [${doc.docType ?? "doc"}] ${ref}: ${content}`;
610
+ return `- [${resolveDocType(doc)}] ${ref}: ${content}`;
322
611
  }));
323
612
  }
324
613
  catch (error) {
614
+ if (!reindexed && typeof this.deps.docdex.reindex === "function") {
615
+ reindexed = true;
616
+ try {
617
+ await this.deps.docdex.reindex();
618
+ const docs = await withTimeout(this.deps.docdex.search({
619
+ query,
620
+ profile: "workspace-code",
621
+ }), DOCDEX_TIMEOUT_MS, `docdex search for "${query}" after reindex`);
622
+ const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, false));
623
+ snippets.push(...filteredDocs.slice(0, 2).map((doc) => {
624
+ const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
625
+ const ref = doc.path ?? doc.id ?? doc.title ?? query;
626
+ return `- [${resolveDocType(doc)}] ${ref}: ${content}`;
627
+ }));
628
+ continue;
629
+ }
630
+ catch (retryError) {
631
+ warnings.push(`docdex search failed after reindex for ${query}: ${retryError.message}`);
632
+ continue;
633
+ }
634
+ }
325
635
  warnings.push(`docdex search failed for ${query}: ${error.message}`);
326
636
  }
327
637
  }
638
+ const normalizeDocLink = (value) => {
639
+ const trimmed = value.trim();
640
+ const stripped = trimmed.replace(/^docdex:/i, "").replace(/^doc:/i, "");
641
+ const candidate = stripped || trimmed;
642
+ const looksLikePath = candidate.includes("/") ||
643
+ candidate.includes("\\") ||
644
+ /\.(md|markdown|txt|rst|yaml|yml|json)$/i.test(candidate);
645
+ return { type: looksLikePath ? "path" : "id", ref: candidate };
646
+ };
647
+ for (const link of docLinks) {
648
+ try {
649
+ const { type, ref } = normalizeDocLink(link);
650
+ if (type === "path" && isDocContextExcluded(ref, false)) {
651
+ snippets.push(`- [linked:filtered] ${link} — excluded from non-QA context`);
652
+ continue;
653
+ }
654
+ let doc = undefined;
655
+ if (type === "path" && "findDocumentByPath" in this.deps.docdex) {
656
+ doc = await this.deps.docdex.findDocumentByPath(ref);
657
+ }
658
+ if (!doc) {
659
+ doc = await this.deps.docdex.fetchDocumentById(ref);
660
+ }
661
+ if (!doc) {
662
+ warnings.push(`docdex fetch returned no document for ${link}`);
663
+ snippets.push(`- [linked:missing] ${link} — no docdex entry found`);
664
+ continue;
665
+ }
666
+ const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
667
+ const refLabel = doc.path ?? doc.id ?? doc.title ?? link;
668
+ snippets.push(`- [linked:${resolveDocType(doc, type === "path" ? ref : undefined)}] ${refLabel}: ${content}`);
669
+ }
670
+ catch (error) {
671
+ const message = error.message;
672
+ warnings.push(`docdex fetch failed for ${link}: ${message}`);
673
+ snippets.push(`- [linked:missing] ${link} — ${message}`);
674
+ }
675
+ }
328
676
  return { snippets: Array.from(new Set(snippets)), warnings };
329
677
  }
330
678
  buildReviewPrompt(params) {
@@ -333,7 +681,28 @@ export class CodeReviewService {
333
681
  parts.push(params.systemPrompts.join("\n\n"));
334
682
  }
335
683
  const acceptance = params.task.acceptanceCriteria && params.task.acceptanceCriteria.length ? params.task.acceptanceCriteria.join(" | ") : "none provided";
684
+ const historySummary = truncateSection("history", params.historySummary, REVIEW_PROMPT_LIMITS.history);
685
+ const commentBacklog = params.commentBacklog
686
+ ? truncateSection("comment backlog", params.commentBacklog, REVIEW_PROMPT_LIMITS.history)
687
+ : "";
688
+ const filteredDocContext = filterOpenApiContext(params.docContext, Boolean(params.openapiSnippet));
689
+ const docContextText = filteredDocContext.length
690
+ ? truncateSection("doc context", filteredDocContext.join("\n"), REVIEW_PROMPT_LIMITS.docContext)
691
+ : "";
692
+ const openapiSnippet = params.openapiSnippet ? truncateSection("openapi", params.openapiSnippet, REVIEW_PROMPT_LIMITS.openapi) : undefined;
693
+ const checklistsText = params.checklists?.length
694
+ ? truncateSection("checklists", params.checklists.join("\n\n"), REVIEW_PROMPT_LIMITS.checklist)
695
+ : "";
696
+ const diffText = truncateSection("diff", params.diff || "(no diff)", REVIEW_PROMPT_LIMITS.diff);
697
+ const reviewFocus = [
698
+ "Review focus:",
699
+ "- First validate the task requirements and the work-on-tasks actions (history/comments) rather than the diff.",
700
+ "- If the diff is empty, decide whether no code changes are required to satisfy the task.",
701
+ "- If no changes are required, use decision=approve or info_only and explicitly say no code changes are needed.",
702
+ "- If changes are required, use decision=changes_requested and explain exactly what is missing and why.",
703
+ ].join("\n");
336
704
  parts.push([
705
+ reviewFocus,
337
706
  `Task ${params.task.key}: ${params.task.title}`,
338
707
  `Epic: ${params.task.epicKey ?? ""} ${params.task.epicTitle ?? ""}`.trim(),
339
708
  `Epic description: ${params.task.epicDescription ? params.task.epicDescription : "none"}`,
@@ -341,22 +710,25 @@ export class CodeReviewService {
341
710
  `Story description: ${params.task.storyDescription ? params.task.storyDescription : "none"}`,
342
711
  `Status: ${params.task.status}, Branch: ${params.branch ?? params.task.vcsBranch ?? "n/a"} (base ${params.baseRef})`,
343
712
  `Task description: ${params.task.description ? params.task.description : "none"}`,
344
- `History:\n${params.historySummary}`,
345
- `Acceptance criteria: ${acceptance}`,
346
- params.docContext.length ? `Doc context (docdex excerpts):\n${params.docContext.join("\n")}` : "Doc context: none",
347
- params.openapiSnippet
348
- ? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${params.openapiSnippet}`
713
+ `History:\n${historySummary}`,
714
+ commentBacklog
715
+ ? `Code-review comment backlog (unresolved slugs):\n${commentBacklog}`
716
+ : "Code-review comment backlog: none",
717
+ `Task DoD / acceptance criteria: ${acceptance}`,
718
+ docContextText ? `Doc context (docdex excerpts):\n${docContextText}` : "Doc context: none",
719
+ openapiSnippet
720
+ ? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${openapiSnippet}`
349
721
  : "OpenAPI: not provided; avoid inventing endpoints.",
350
- params.checklists && params.checklists.length ? `Review checklists/runbook:\n${params.checklists.join("\n\n")}` : "Checklists: none",
351
- "Diff:\n" + (params.diff || "(no diff)"),
722
+ checklistsText ? `Review checklists/runbook:\n${checklistsText}` : "Checklists: none",
723
+ params.diffEmpty ? "Diff: (empty no changes between base and branch)" : "Diff:\n" + diffText,
352
724
  "Respond with STRICT JSON only, matching:\n" + JSON_CONTRACT,
353
- "Rules: honor OpenAPI contracts; cite doc context where relevant; do not add prose outside JSON.",
725
+ "Rules: honor OpenAPI contracts; cite doc context where relevant; include resolvedSlugs/unresolvedSlugs for code-review comment backlog items only; do not require docs/qa/* reports; avoid hardcoded ports; do not add prose or markdown fences outside JSON.",
354
726
  ].join("\n"));
355
727
  return parts.join("\n\n");
356
728
  }
357
729
  async buildHistorySummary(taskId) {
358
730
  const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
359
- sourceCommands: ["work-on-tasks", "code-review", "qa-tasks"],
731
+ sourceCommands: ["work-on-tasks", "code-review"],
360
732
  limit: 10,
361
733
  });
362
734
  const lastReview = await this.deps.workspaceRepo.getLatestTaskReview(taskId);
@@ -378,9 +750,194 @@ export class CodeReviewService {
378
750
  }
379
751
  }
380
752
  if (!parts.length)
381
- return "No prior review or QA history.";
753
+ return "No prior review history.";
382
754
  return parts.join("\n");
383
755
  }
756
+ async loadCommentContext(taskId) {
757
+ const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
758
+ sourceCommands: ["code-review"],
759
+ limit: 50,
760
+ });
761
+ const unresolved = comments.filter((comment) => !comment.resolvedAt);
762
+ return { comments, unresolved };
763
+ }
764
+ commentSlugKey(file, line, category) {
765
+ if (!file)
766
+ return undefined;
767
+ const normalizedFile = normalizePath(file);
768
+ const linePart = typeof line === "number" ? String(line) : "";
769
+ const categoryPart = category?.toLowerCase() ?? "";
770
+ return `${normalizedFile}|${linePart}|${categoryPart}`;
771
+ }
772
+ buildCommentSlugIndex(comments) {
773
+ const index = new Map();
774
+ for (const comment of comments) {
775
+ if (!comment.slug)
776
+ continue;
777
+ const key = this.commentSlugKey(comment.file, comment.line, comment.category);
778
+ if (!key)
779
+ continue;
780
+ if (!index.has(key))
781
+ index.set(key, comment.slug);
782
+ }
783
+ return index;
784
+ }
785
+ resolveFindingSlug(finding, slugIndex) {
786
+ const key = this.commentSlugKey(finding.file, finding.line, finding.type ?? null);
787
+ const existing = key ? slugIndex.get(key) : undefined;
788
+ if (existing)
789
+ return existing;
790
+ const message = (finding.message ?? "").trim() || "Review finding.";
791
+ return createTaskCommentSlug({
792
+ source: "code-review",
793
+ message,
794
+ file: finding.file,
795
+ line: finding.line,
796
+ category: finding.type ?? null,
797
+ });
798
+ }
799
+ async applyCommentResolutions(params) {
800
+ const existingBySlug = new Map();
801
+ const openBySlug = new Set();
802
+ const resolvedBySlug = new Set();
803
+ for (const comment of params.existingComments) {
804
+ if (!comment.slug)
805
+ continue;
806
+ if (!existingBySlug.has(comment.slug)) {
807
+ existingBySlug.set(comment.slug, comment);
808
+ }
809
+ if (comment.resolvedAt) {
810
+ resolvedBySlug.add(comment.slug);
811
+ }
812
+ else {
813
+ openBySlug.add(comment.slug);
814
+ }
815
+ }
816
+ const reviewSlugIndex = this.buildCommentSlugIndex(params.existingComments.filter((comment) => comment.sourceCommand === "code-review"));
817
+ const allowedSlugs = new Set(existingBySlug.keys());
818
+ const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined).filter((slug) => allowedSlugs.has(slug));
819
+ const resolvedSet = new Set(resolvedSlugs);
820
+ const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined).filter((slug) => allowedSlugs.has(slug)));
821
+ const findingSlugs = [];
822
+ for (const finding of params.findings ?? []) {
823
+ const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
824
+ findingSlugs.push(slug);
825
+ const severity = (finding.severity ?? "").toLowerCase();
826
+ const autoResolve = (params.decision === "approve" || params.decision === "info_only") &&
827
+ ["info", "low"].includes(severity);
828
+ if (!resolvedSet.has(slug) && !autoResolve) {
829
+ unresolvedSet.add(slug);
830
+ }
831
+ }
832
+ for (const slug of resolvedSet) {
833
+ unresolvedSet.delete(slug);
834
+ }
835
+ const toResolve = resolvedSlugs.filter((slug) => openBySlug.has(slug));
836
+ const toReopen = Array.from(unresolvedSet).filter((slug) => resolvedBySlug.has(slug));
837
+ for (const slug of toResolve) {
838
+ await this.deps.workspaceRepo.resolveTaskComment({
839
+ taskId: params.task.id,
840
+ slug,
841
+ resolvedAt: new Date().toISOString(),
842
+ resolvedBy: params.agentId,
843
+ });
844
+ }
845
+ for (const slug of toReopen) {
846
+ await this.deps.workspaceRepo.reopenTaskComment({ taskId: params.task.id, slug });
847
+ }
848
+ const createdSlugs = new Set();
849
+ for (const finding of params.findings ?? []) {
850
+ const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
851
+ if (existingBySlug.has(slug) || createdSlugs.has(slug))
852
+ continue;
853
+ const severity = (finding.severity ?? "").toLowerCase();
854
+ const autoResolve = (params.decision === "approve" || params.decision === "info_only") &&
855
+ ["info", "low"].includes(severity);
856
+ const message = (finding.message ?? "").trim() || "(no details provided)";
857
+ const body = formatTaskCommentBody({
858
+ slug,
859
+ source: "code-review",
860
+ message,
861
+ status: autoResolve ? "resolved" : "open",
862
+ category: finding.type ?? "other",
863
+ file: finding.file ?? null,
864
+ line: finding.line ?? null,
865
+ suggestedFix: finding.suggestedFix ?? null,
866
+ });
867
+ const resolvedAt = autoResolve ? new Date().toISOString() : undefined;
868
+ await this.deps.workspaceRepo.createTaskComment({
869
+ taskId: params.task.id,
870
+ taskRunId: params.taskRunId,
871
+ jobId: params.jobId,
872
+ sourceCommand: "code-review",
873
+ authorType: "agent",
874
+ authorAgentId: params.agentId,
875
+ category: finding.type ?? "other",
876
+ slug,
877
+ status: autoResolve ? "resolved" : "open",
878
+ file: finding.file ?? null,
879
+ line: finding.line ?? null,
880
+ pathHint: finding.file ?? null,
881
+ body,
882
+ resolvedAt,
883
+ resolvedBy: autoResolve ? params.agentId : undefined,
884
+ metadata: {
885
+ severity: finding.severity,
886
+ suggestedFix: finding.suggestedFix,
887
+ },
888
+ createdAt: new Date().toISOString(),
889
+ });
890
+ createdSlugs.add(slug);
891
+ }
892
+ const openSet = new Set(openBySlug);
893
+ for (const slug of unresolvedSet) {
894
+ openSet.add(slug);
895
+ }
896
+ for (const slug of resolvedSet) {
897
+ openSet.delete(slug);
898
+ }
899
+ if (resolvedSlugs.length || toReopen.length || unresolvedSet.size) {
900
+ const resolutionMessage = [
901
+ `Resolved slugs: ${formatSlugList(toResolve)}`,
902
+ `Reopened slugs: ${formatSlugList(toReopen)}`,
903
+ `Open slugs: ${formatSlugList(Array.from(openSet))}`,
904
+ ].join("\n");
905
+ const resolutionSlug = createTaskCommentSlug({
906
+ source: "code-review",
907
+ message: resolutionMessage,
908
+ category: "comment_resolution",
909
+ });
910
+ const resolutionBody = formatTaskCommentBody({
911
+ slug: resolutionSlug,
912
+ source: "code-review",
913
+ message: resolutionMessage,
914
+ status: "resolved",
915
+ category: "comment_resolution",
916
+ });
917
+ const createdAt = new Date().toISOString();
918
+ await this.deps.workspaceRepo.createTaskComment({
919
+ taskId: params.task.id,
920
+ taskRunId: params.taskRunId,
921
+ jobId: params.jobId,
922
+ sourceCommand: "code-review",
923
+ authorType: "agent",
924
+ authorAgentId: params.agentId,
925
+ category: "comment_resolution",
926
+ slug: resolutionSlug,
927
+ status: "resolved",
928
+ body: resolutionBody,
929
+ createdAt,
930
+ resolvedAt: createdAt,
931
+ resolvedBy: params.agentId,
932
+ metadata: {
933
+ resolvedSlugs: toResolve,
934
+ reopenedSlugs: toReopen,
935
+ openSlugs: Array.from(openSet),
936
+ },
937
+ });
938
+ }
939
+ return { resolved: toResolve, reopened: toReopen, open: Array.from(openSet) };
940
+ }
384
941
  extractPathsFromDiff(diff) {
385
942
  const regex = /^(?:\+\+\+ b\/|\-\-\- a\/)([^\s]+)$/gm;
386
943
  const paths = new Set();
@@ -473,6 +1030,9 @@ export class CodeReviewService {
473
1030
  summary: params.summary,
474
1031
  followupTaskKeys: params.followupTaskKeys,
475
1032
  error: params.error,
1033
+ resolvedCount: params.resolvedCount,
1034
+ reopenedCount: params.reopenedCount,
1035
+ openCount: params.openCount,
476
1036
  });
477
1037
  await this.deps.workspaceRepo.createTaskComment({
478
1038
  taskId: params.task.id,
@@ -487,12 +1047,12 @@ export class CodeReviewService {
487
1047
  });
488
1048
  }
489
1049
  async persistContext(jobId, taskId, context) {
490
- const dir = path.join(REVIEW_DIR(this.workspace.workspaceRoot, jobId), "context");
1050
+ const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "context");
491
1051
  await fs.mkdir(dir, { recursive: true });
492
1052
  await fs.writeFile(path.join(dir, `${taskId}.json`), JSON.stringify({ schema_version: 1, task_id: taskId, created_at: new Date().toISOString(), ...context }, null, 2), "utf8");
493
1053
  }
494
1054
  async persistDiff(jobId, taskId, diff) {
495
- const dir = path.join(REVIEW_DIR(this.workspace.workspaceRoot, jobId), "diffs");
1055
+ const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "diffs");
496
1056
  await fs.mkdir(dir, { recursive: true });
497
1057
  await fs.writeFile(path.join(dir, `${taskId}.diff`), diff, "utf8");
498
1058
  // structured review diff snapshot
@@ -525,6 +1085,13 @@ export class CodeReviewService {
525
1085
  files.push(current);
526
1086
  await fs.writeFile(path.join(dir, `${taskId}.json`), JSON.stringify({ schema_version: 1, task_id: taskId, files }, null, 2), "utf8");
527
1087
  }
1088
+ async persistReviewOutput(jobId, taskId, payload) {
1089
+ const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "outputs");
1090
+ await fs.mkdir(dir, { recursive: true });
1091
+ const target = path.join(dir, `${taskId}.json`);
1092
+ await fs.writeFile(target, JSON.stringify(payload, null, 2), "utf8");
1093
+ return path.relative(this.workspace.mcodaDir, target);
1094
+ }
528
1095
  severityToPriority(severity) {
529
1096
  if (!severity)
530
1097
  return null;
@@ -532,6 +1099,13 @@ export class CodeReviewService {
532
1099
  const order = { critical: 1, high: 2, medium: 3, low: 4, info: 5 };
533
1100
  return order[normalized] ?? null;
534
1101
  }
1102
+ severityToStoryPoints(severity) {
1103
+ if (!severity)
1104
+ return null;
1105
+ const normalized = severity.toLowerCase();
1106
+ const points = { critical: 8, high: 5, medium: 3, low: 2, info: 1 };
1107
+ return points[normalized] ?? null;
1108
+ }
535
1109
  shouldCreateFollowupTask(decision, finding) {
536
1110
  // SDS rule: create follow-ups for blocking/changes_requested decisions or critical/high issues,
537
1111
  // and for contract/security/bug types at medium+ severity. Do not create for approve+low/info.
@@ -632,6 +1206,9 @@ export class CodeReviewService {
632
1206
  const storyId = genericTarget ? genericContainers.story.id : params.task.userStoryId;
633
1207
  const storyKey = genericTarget ? genericContainers.story.key : params.task.storyKey ?? genericContainers?.story.key ?? "US-AUTO";
634
1208
  const epicId = genericTarget ? genericContainers.epic.id : params.task.epicId;
1209
+ const fallbackPoints = this.resolveTaskComplexity(params.task) ?? params.task.storyPoints ?? 1;
1210
+ const storyPoints = this.severityToStoryPoints(finding.severity) ?? fallbackPoints;
1211
+ const boundedPoints = Number.isFinite(storyPoints) ? Math.min(10, Math.max(1, Math.round(storyPoints))) : 1;
635
1212
  const taskKey = await ensureKey(storyId, storyKey);
636
1213
  inserts.push({
637
1214
  projectId: params.task.projectId,
@@ -642,7 +1219,7 @@ export class CodeReviewService {
642
1219
  description: this.buildFollowupDescription(params.task, finding, params.decision),
643
1220
  type: finding.type ?? (params.decision === "changes_requested" || params.decision === "block" ? "bug" : "issue"),
644
1221
  status: "not_started",
645
- storyPoints: null,
1222
+ storyPoints: boundedPoints,
646
1223
  priority: this.severityToPriority(finding.severity),
647
1224
  metadata: {
648
1225
  source: "code-review",
@@ -658,6 +1235,7 @@ export class CodeReviewService {
658
1235
  suggestedFix: finding.suggestedFix,
659
1236
  generic: genericTarget ? true : false,
660
1237
  decision: params.decision,
1238
+ complexity: boundedPoints,
661
1239
  },
662
1240
  });
663
1241
  }
@@ -680,7 +1258,14 @@ export class CodeReviewService {
680
1258
  await this.ensureMcoda();
681
1259
  const agentStream = request.agentStream !== false;
682
1260
  const baseRef = request.baseRef ?? this.workspace.config?.branch ?? DEFAULT_BASE_BRANCH;
683
- const statusFilter = request.statusFilter && request.statusFilter.length ? request.statusFilter : ["ready_to_review"];
1261
+ const ignoreStatusFilter = Boolean(request.taskKeys?.length) || request.ignoreStatusFilter === true;
1262
+ const rawStatusFilter = ignoreStatusFilter
1263
+ ? []
1264
+ : request.statusFilter && request.statusFilter.length
1265
+ ? request.statusFilter
1266
+ : [READY_TO_CODE_REVIEW];
1267
+ const { filtered: allowedStatusFilter, rejected } = filterTaskStatuses(rawStatusFilter, REVIEW_ALLOWED_STATUSES, REVIEW_ALLOWED_STATUSES);
1268
+ const statusFilter = normalizeReviewStatuses(allowedStatusFilter);
684
1269
  let state;
685
1270
  const commandRun = await this.deps.jobService.startCommandRun("code-review", request.projectKey, {
686
1271
  taskIds: request.taskKeys,
@@ -690,6 +1275,10 @@ export class CodeReviewService {
690
1275
  let jobId = request.resumeJobId;
691
1276
  let selectedTaskIds = [];
692
1277
  let warnings = [];
1278
+ if (rejected.length > 0 && !ignoreStatusFilter) {
1279
+ warnings.push(`code-review ignores unsupported statuses: ${rejected.join(", ")}. Allowed: ${REVIEW_ALLOWED_STATUSES.join(", ")}.`);
1280
+ }
1281
+ let allowFollowups = request.createFollowupTasks === true;
693
1282
  let selectedTasks = [];
694
1283
  if (request.resumeJobId) {
695
1284
  const job = await this.deps.jobService.getJob(request.resumeJobId);
@@ -698,16 +1287,37 @@ export class CodeReviewService {
698
1287
  if ((job.commandName ?? job.type) !== "code-review" && job.type !== "review") {
699
1288
  throw new Error(`Job ${request.resumeJobId} is not a code-review job`);
700
1289
  }
1290
+ if (request.createFollowupTasks === undefined) {
1291
+ allowFollowups = Boolean(job.payload?.createFollowupTasks);
1292
+ }
701
1293
  state = await this.loadState(request.resumeJobId);
702
1294
  selectedTaskIds = state?.selectedTaskIds ?? (Array.isArray(job.payload?.selection) ? job.payload.selection : []);
703
1295
  if (!selectedTaskIds.length) {
704
1296
  throw new Error("Resume requested but no task selection found in job payload");
705
1297
  }
1298
+ selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
1299
+ const terminalStatuses = new Set(["completed", "cancelled"]);
1300
+ const terminalTasks = selectedTasks.filter((task) => terminalStatuses.has((task.status ?? "").toLowerCase()));
1301
+ if (terminalTasks.length) {
1302
+ const terminalIds = new Set(terminalTasks.map((task) => task.id));
1303
+ const terminalKeys = terminalTasks.map((task) => task.key);
1304
+ warnings.push(`Skipping terminal tasks on resume: ${terminalKeys.join(", ")}`);
1305
+ selectedTasks = selectedTasks.filter((task) => !terminalIds.has(task.id));
1306
+ selectedTaskIds = selectedTaskIds.filter((id) => !terminalIds.has(id));
1307
+ if (state) {
1308
+ state.selectedTaskIds = selectedTaskIds;
1309
+ await this.persistState(job.id, state);
1310
+ }
1311
+ await this.writeCheckpoint(job.id, "resume_filtered", {
1312
+ skippedTaskKeys: terminalKeys,
1313
+ selectedTaskIds,
1314
+ schema_version: 1,
1315
+ });
1316
+ }
706
1317
  await this.deps.jobService.updateJobStatus(job.id, "running", {
707
- totalItems: job.totalItems ?? selectedTaskIds.length,
1318
+ totalItems: selectedTaskIds.length,
708
1319
  processedItems: state?.reviewed.length ?? 0,
709
1320
  });
710
- selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
711
1321
  }
712
1322
  else {
713
1323
  try {
@@ -716,7 +1326,7 @@ export class CodeReviewService {
716
1326
  epicKey: request.epicKey,
717
1327
  storyKey: request.storyKey,
718
1328
  taskKeys: request.taskKeys,
719
- statusFilter,
1329
+ statusFilter: ignoreStatusFilter ? undefined : statusFilter,
720
1330
  limit: request.limit,
721
1331
  });
722
1332
  }
@@ -726,10 +1336,11 @@ export class CodeReviewService {
726
1336
  epicKey: request.epicKey,
727
1337
  storyKey: request.storyKey,
728
1338
  taskKeys: request.taskKeys,
729
- statusFilter,
1339
+ statusFilter: ignoreStatusFilter ? undefined : statusFilter,
730
1340
  limit: request.limit,
1341
+ ignoreStatusFilter,
731
1342
  });
732
- warnings = [...selection.warnings];
1343
+ warnings = [...warnings, ...selection.warnings];
733
1344
  selectedTasks = selection.ordered.map((t) => t.task);
734
1345
  }
735
1346
  selectedTaskIds = selectedTasks.map((t) => t.id);
@@ -740,12 +1351,13 @@ export class CodeReviewService {
740
1351
  epicKey: request.epicKey,
741
1352
  storyKey: request.storyKey,
742
1353
  tasks: request.taskKeys,
743
- statusFilter,
1354
+ statusFilter: ignoreStatusFilter ? [] : statusFilter,
744
1355
  baseRef,
745
1356
  selection: selectedTaskIds,
746
1357
  dryRun: request.dryRun ?? false,
747
1358
  agent: request.agentName,
748
1359
  agentStream,
1360
+ createFollowupTasks: allowFollowups,
749
1361
  },
750
1362
  totalItems: selectedTaskIds.length,
751
1363
  processedItems: 0,
@@ -782,12 +1394,208 @@ export class CodeReviewService {
782
1394
  const tasks = selectedTasks.length && selectedTaskIds.length === selectedTasks.length
783
1395
  ? selectedTasks
784
1396
  : await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
785
- const agent = await this.resolveAgent(request.agentName);
1397
+ let resolvedAgent;
1398
+ try {
1399
+ resolvedAgent = await this.resolveAgent(request.agentName);
1400
+ }
1401
+ catch (error) {
1402
+ const message = error instanceof Error ? error.message : String(error);
1403
+ if (request.agentName) {
1404
+ const warning = `Review agent override (${request.agentName}) failed: ${message}`;
1405
+ warnings.push(warning);
1406
+ console.warn(`[code-review] ${warning}`);
1407
+ }
1408
+ throw error;
1409
+ }
1410
+ const agent = resolvedAgent.agent;
1411
+ const reviewJsonAgentOverride = this.workspace.config?.reviewJsonAgent ??
1412
+ process.env.MCODA_REVIEW_JSON_AGENT ??
1413
+ process.env.MCODA_REVIEW_JSON_AGENT_NAME;
1414
+ let reviewJsonAgent;
1415
+ if (reviewJsonAgentOverride &&
1416
+ reviewJsonAgentOverride !== agent.id &&
1417
+ reviewJsonAgentOverride !== agent.slug) {
1418
+ try {
1419
+ reviewJsonAgent = (await this.resolveAgent(reviewJsonAgentOverride)).agent;
1420
+ }
1421
+ catch (error) {
1422
+ const message = error instanceof Error ? error.message : String(error);
1423
+ warnings.push(`Review JSON agent override (${reviewJsonAgentOverride}) failed: ${message}`);
1424
+ }
1425
+ }
786
1426
  const prompts = await this.loadPrompts(agent.id);
787
1427
  const extras = await this.loadRunbookAndChecklists();
788
- const systemPrompts = [prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, ...extras].filter(Boolean);
1428
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
1429
+ if (projectGuidance) {
1430
+ console.info(`[code-review] loaded project guidance from ${projectGuidance.source}`);
1431
+ }
1432
+ const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
1433
+ const systemPrompts = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, ...extras].filter(Boolean);
1434
+ const abortSignal = request.abortSignal;
1435
+ const resolveAbortReason = () => {
1436
+ const reason = abortSignal?.reason;
1437
+ if (typeof reason === "string" && reason.trim().length > 0)
1438
+ return reason;
1439
+ if (reason instanceof Error && reason.message)
1440
+ return reason.message;
1441
+ return "code_review_aborted";
1442
+ };
1443
+ const abortIfSignaled = () => {
1444
+ if (abortSignal?.aborted) {
1445
+ throw new Error(resolveAbortReason());
1446
+ }
1447
+ };
1448
+ const withAbort = async (promise) => {
1449
+ if (!abortSignal)
1450
+ return promise;
1451
+ if (abortSignal.aborted) {
1452
+ throw new Error(resolveAbortReason());
1453
+ }
1454
+ return await new Promise((resolve, reject) => {
1455
+ const onAbort = () => reject(new Error(resolveAbortReason()));
1456
+ abortSignal.addEventListener("abort", onAbort, { once: true });
1457
+ promise.then(resolve, reject).finally(() => {
1458
+ abortSignal.removeEventListener("abort", onAbort);
1459
+ });
1460
+ });
1461
+ };
789
1462
  const results = [];
1463
+ const formatSessionId = (iso) => {
1464
+ const date = new Date(iso);
1465
+ const pad = (value) => String(value).padStart(2, "0");
1466
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
1467
+ };
1468
+ const formatDuration = (ms) => {
1469
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
1470
+ const seconds = totalSeconds % 60;
1471
+ const minutesTotal = Math.floor(totalSeconds / 60);
1472
+ const minutes = minutesTotal % 60;
1473
+ const hours = Math.floor(minutesTotal / 60);
1474
+ if (hours > 0)
1475
+ return `${hours}H ${minutes}M ${seconds}S`;
1476
+ return `${minutes}M ${seconds}S`;
1477
+ };
1478
+ const resolveProvider = (adapter) => {
1479
+ if (!adapter)
1480
+ return "n/a";
1481
+ const trimmed = adapter.trim();
1482
+ if (!trimmed)
1483
+ return "n/a";
1484
+ if (trimmed.includes("-"))
1485
+ return trimmed.split("-")[0];
1486
+ return trimmed;
1487
+ };
1488
+ const resolveReasoning = (config) => {
1489
+ if (!config)
1490
+ return "n/a";
1491
+ const raw = config.reasoning ?? config.thinking;
1492
+ if (typeof raw === "string")
1493
+ return raw;
1494
+ if (typeof raw === "boolean")
1495
+ return raw ? "enabled" : "disabled";
1496
+ return "n/a";
1497
+ };
1498
+ const emitLine = (line) => {
1499
+ console.info(line);
1500
+ };
1501
+ const emitBlank = () => emitLine("");
1502
+ const emitReviewStart = (details) => {
1503
+ emitLine("╭──────────────────────────────────────────────────────────╮");
1504
+ emitLine("│ START OF CODE REVIEW TASK │");
1505
+ emitLine("╰──────────────────────────────────────────────────────────╯");
1506
+ emitLine(` [🪪] Code Review Task ID: ${details.taskKey}`);
1507
+ emitLine(` [👹] Alias: ${details.alias}`);
1508
+ emitLine(` [ℹ️] Summary: ${details.summary}`);
1509
+ emitLine(` [🤖] Model: ${details.model}`);
1510
+ emitLine(` [🕹️] Provider: ${details.provider}`);
1511
+ emitLine(` [🧩] Step: ${details.step}`);
1512
+ emitLine(` [🧠] Reasoning: ${details.reasoning}`);
1513
+ emitLine(` [📁] Workdir: ${details.workdir}`);
1514
+ emitLine(` [🔑] Session: ${details.sessionId}`);
1515
+ emitLine(` [🕒] Started: ${details.startedAt}`);
1516
+ emitBlank();
1517
+ emitLine(" ░░░░░ START OF CODE REVIEW TASK ░░░░░");
1518
+ emitBlank();
1519
+ emitLine(` [STEP ${details.step}] [MODEL ${details.model}]`);
1520
+ emitBlank();
1521
+ emitBlank();
1522
+ };
1523
+ const emitReviewEnd = (details) => {
1524
+ emitLine("╭──────────────────────────────────────────────────────────╮");
1525
+ emitLine("│ END OF CODE REVIEW TASK │");
1526
+ emitLine("╰──────────────────────────────────────────────────────────╯");
1527
+ emitLine(` 👀 CODE REVIEW TASK ${details.taskKey} | 📜 STATUS ${details.statusLabel} | ✅ DECISION ${details.decision ?? "n/a"} | 🔎 FINDINGS ${details.findingsCount} | ⌛ TIME ${formatDuration(details.elapsedMs)}`);
1528
+ emitLine(` [🕒] Started: ${details.startedAt}`);
1529
+ emitLine(` [🕒] Ended: ${details.endedAt}`);
1530
+ emitLine(` Tokens used: ${details.tokensTotal.toLocaleString("en-US")}`);
1531
+ emitBlank();
1532
+ emitLine(" ░░░░░ END OF CODE REVIEW TASK ░░░░░");
1533
+ emitBlank();
1534
+ };
1535
+ const maybeRateTask = async (task, taskRunId, tokensTotal) => {
1536
+ if (!request.rateAgents || tokensTotal <= 0)
1537
+ return;
1538
+ try {
1539
+ const ratingService = this.ensureRatingService();
1540
+ await ratingService.rate({
1541
+ workspace: this.workspace,
1542
+ agentId: agent.id,
1543
+ commandName: "code-review",
1544
+ jobId,
1545
+ commandRunId: commandRun.id,
1546
+ taskId: task.id,
1547
+ taskKey: task.key,
1548
+ discipline: task.type ?? "review",
1549
+ complexity: this.resolveTaskComplexity(task),
1550
+ });
1551
+ }
1552
+ catch (error) {
1553
+ const message = `Agent rating failed for ${task.key}: ${error instanceof Error ? error.message : String(error)}`;
1554
+ warnings.push(message);
1555
+ try {
1556
+ await this.deps.workspaceRepo.insertTaskLog({
1557
+ taskRunId,
1558
+ sequence: this.sequenceForTask(taskRunId),
1559
+ timestamp: new Date().toISOString(),
1560
+ source: "rating",
1561
+ message,
1562
+ });
1563
+ }
1564
+ catch {
1565
+ /* ignore rating log failures */
1566
+ }
1567
+ }
1568
+ };
1569
+ let abortRemainingReason = null;
790
1570
  for (const task of tasks) {
1571
+ if (abortRemainingReason)
1572
+ break;
1573
+ abortIfSignaled();
1574
+ const startedAt = new Date().toISOString();
1575
+ const taskStartMs = Date.now();
1576
+ const sessionId = formatSessionId(startedAt);
1577
+ const taskAlias = `Reviewing task ${task.key}`;
1578
+ const taskSummary = task.title ?? task.description ?? "(none)";
1579
+ const modelLabel = agent.defaultModel ?? "(default)";
1580
+ const providerLabel = resolveProvider(agent.adapter);
1581
+ const reasoningLabel = resolveReasoning(agent.config);
1582
+ const stepLabel = "review";
1583
+ let endEmitted = false;
1584
+ const emitReviewEndOnce = (details) => {
1585
+ if (endEmitted)
1586
+ return;
1587
+ endEmitted = true;
1588
+ emitReviewEnd({
1589
+ taskKey: task.key,
1590
+ statusLabel: details.statusLabel,
1591
+ decision: details.decision,
1592
+ findingsCount: details.findingsCount,
1593
+ elapsedMs: Date.now() - taskStartMs,
1594
+ tokensTotal: details.tokensTotal,
1595
+ startedAt,
1596
+ endedAt: new Date().toISOString(),
1597
+ });
1598
+ };
791
1599
  const statusBefore = task.status;
792
1600
  const taskRun = await this.deps.workspaceRepo.createTaskRun({
793
1601
  taskId: task.id,
@@ -796,19 +1604,42 @@ export class CodeReviewService {
796
1604
  commandRunId: commandRun.id,
797
1605
  agentId: agent.id,
798
1606
  status: "running",
799
- startedAt: new Date().toISOString(),
1607
+ startedAt,
800
1608
  storyPointsAtRun: task.storyPoints ?? null,
801
1609
  gitBranch: task.vcsBranch ?? null,
802
1610
  gitBaseBranch: task.vcsBaseBranch ?? null,
803
1611
  gitCommitSha: task.vcsLastCommitSha ?? null,
804
1612
  });
1613
+ const statusContext = {
1614
+ commandName: "code-review",
1615
+ jobId,
1616
+ taskRunId: taskRun.id,
1617
+ agentId: agent.id,
1618
+ metadata: { lane: "review" },
1619
+ };
805
1620
  const findings = [];
806
1621
  let decision;
807
1622
  let statusAfter;
1623
+ let reviewErrorCode;
808
1624
  const followupCreated = [];
1625
+ let commentResolution;
809
1626
  // Debug visibility: show prompts/task details for this run
810
1627
  const systemPrompt = systemPrompts.join("\n\n");
1628
+ let tokensTotal = 0;
1629
+ let agentOutput = "";
811
1630
  try {
1631
+ emitReviewStart({
1632
+ taskKey: task.key,
1633
+ alias: taskAlias,
1634
+ summary: taskSummary,
1635
+ model: modelLabel,
1636
+ provider: providerLabel,
1637
+ step: stepLabel,
1638
+ reasoning: reasoningLabel,
1639
+ workdir: this.workspace.workspaceRoot,
1640
+ sessionId,
1641
+ startedAt,
1642
+ });
812
1643
  const metadata = task.metadata ?? {};
813
1644
  const allowedFiles = Array.isArray(metadata.files) ? metadata.files : [];
814
1645
  const diffResult = await this.buildDiff(task, state?.baseRef ?? baseRef, allowedFiles);
@@ -830,7 +1661,21 @@ export class CodeReviewService {
830
1661
  allowedFiles,
831
1662
  },
832
1663
  });
1664
+ const diffEmpty = !diff.trim();
1665
+ if (diffEmpty) {
1666
+ const message = `Empty diff for ${task.key}; reviewing task requirements to confirm whether no changes are acceptable.`;
1667
+ warnings.push(message);
1668
+ await this.deps.workspaceRepo.insertTaskLog({
1669
+ taskRunId: taskRun.id,
1670
+ sequence: this.sequenceForTask(taskRun.id),
1671
+ timestamp: new Date().toISOString(),
1672
+ source: "review_warning",
1673
+ message,
1674
+ });
1675
+ }
833
1676
  const historySummary = await this.buildHistorySummary(task.id);
1677
+ const commentContext = await this.loadCommentContext(task.id);
1678
+ const commentBacklog = buildCommentBacklog(commentContext.unresolved);
834
1679
  await this.deps.workspaceRepo.insertTaskLog({
835
1680
  taskRunId: taskRun.id,
836
1681
  sequence: this.sequenceForTask(taskRun.id),
@@ -839,7 +1684,8 @@ export class CodeReviewService {
839
1684
  message: "Loaded task history",
840
1685
  });
841
1686
  const changedPaths = this.extractPathsFromDiff(diff);
842
- const docLinks = await this.gatherDocContext(task.title, changedPaths.length ? changedPaths : allowedFiles, task.acceptanceCriteria);
1687
+ const diffMeta = { diffEmpty, changedPaths };
1688
+ const docLinks = await this.gatherDocContext(task.title, changedPaths.length ? changedPaths : allowedFiles, task.acceptanceCriteria, Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : []);
843
1689
  if (docLinks.warnings.length)
844
1690
  warnings.push(...docLinks.warnings);
845
1691
  await this.deps.workspaceRepo.insertTaskLog({
@@ -865,12 +1711,15 @@ export class CodeReviewService {
865
1711
  systemPrompts,
866
1712
  task,
867
1713
  diff,
1714
+ diffEmpty,
868
1715
  docContext: docLinks.snippets,
869
1716
  openapiSnippet,
870
1717
  historySummary,
1718
+ commentBacklog,
871
1719
  baseRef: state?.baseRef ?? baseRef,
872
1720
  branch: task.vcsBranch ?? undefined,
873
1721
  });
1722
+ const requireCommentSlugs = Boolean(commentBacklog.trim());
874
1723
  const separator = "============================================================";
875
1724
  const deps = Array.isArray(task.dependencyKeys) && task.dependencyKeys.length
876
1725
  ? task.dependencyKeys
@@ -878,7 +1727,6 @@ export class CodeReviewService {
878
1727
  ? task.metadata.depends_on
879
1728
  : [];
880
1729
  console.info(separator);
881
- console.info("[code-review] START OF TASK");
882
1730
  console.info(`[code-review] Task key: ${task.key}`);
883
1731
  console.info(`[code-review] Title: ${task.title ?? "(none)"}`);
884
1732
  console.info(`[code-review] Description: ${task.description ?? "(none)"}`);
@@ -892,21 +1740,24 @@ export class CodeReviewService {
892
1740
  console.info(separator);
893
1741
  await this.persistContext(jobId, task.id, {
894
1742
  historySummary,
1743
+ commentBacklog,
895
1744
  docdex: docLinks.snippets,
896
1745
  openapiSnippet,
897
1746
  changedPaths,
1747
+ diffEmpty,
898
1748
  });
899
1749
  state?.contextBuilt.push(task.id);
900
1750
  await this.persistState(jobId, state);
901
1751
  await this.writeCheckpoint(jobId, "context_built", { contextBuilt: state?.contextBuilt ?? [], schema_version: 1 });
902
- const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta) => {
1752
+ const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta, agentUsed = agent, attempt = 1) => {
903
1753
  const tokensPrompt = tokenMeta?.tokensPrompt ?? estimateTokens(promptText);
904
1754
  const tokensCompletion = tokenMeta?.tokensCompletion ?? estimateTokens(outputText);
905
- const tokensTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
1755
+ const entryTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
1756
+ tokensTotal += entryTotal;
906
1757
  await this.deps.jobService.recordTokenUsage({
907
1758
  workspaceId: this.workspace.workspaceId,
908
- agentId: agent.id,
909
- modelName: tokenMeta?.model ?? agent.defaultModel ?? undefined,
1759
+ agentId: agentUsed.id,
1760
+ modelName: tokenMeta?.model ?? agentUsed.defaultModel ?? undefined,
910
1761
  jobId,
911
1762
  commandRunId: commandRun.id,
912
1763
  taskRunId: taskRun.id,
@@ -914,43 +1765,85 @@ export class CodeReviewService {
914
1765
  projectId: task.projectId,
915
1766
  tokensPrompt,
916
1767
  tokensCompletion,
917
- tokensTotal,
1768
+ tokensTotal: entryTotal,
918
1769
  durationSeconds,
919
1770
  timestamp: new Date().toISOString(),
920
- metadata: { commandName: "code-review", phase, action: phase },
1771
+ metadata: { commandName: "code-review", phase, action: phase, attempt },
921
1772
  });
922
1773
  };
923
- let agentOutput = "";
1774
+ agentOutput = "";
924
1775
  let durationSeconds = 0;
925
- const started = Date.now();
926
1776
  let lastStreamMeta;
927
- if (agentStream && this.deps.agentService.invokeStream) {
928
- const stream = await this.deps.agentService.invokeStream(agent.id, { input: prompt, metadata: { taskKey: task.key } });
929
- for await (const chunk of stream) {
930
- agentOutput += chunk.output ?? "";
931
- lastStreamMeta = chunk.metadata ?? lastStreamMeta;
1777
+ let agentUsedForOutput = agent;
1778
+ let outputAttempt = 1;
1779
+ const invokeReviewAgent = async (agentToUse, useStream, logSource) => {
1780
+ let output = "";
1781
+ let metadata;
1782
+ const started = Date.now();
1783
+ if (useStream && this.deps.agentService.invokeStream) {
1784
+ const stream = await withAbort(this.deps.agentService.invokeStream(agentToUse.id, {
1785
+ input: prompt,
1786
+ metadata: { taskKey: task.key, retry: logSource === "agent_retry" },
1787
+ }));
1788
+ while (true) {
1789
+ abortIfSignaled();
1790
+ const { value, done } = await withAbort(stream.next());
1791
+ if (done)
1792
+ break;
1793
+ const chunk = value;
1794
+ output += chunk.output ?? "";
1795
+ metadata = chunk.metadata ?? metadata;
1796
+ await this.deps.workspaceRepo.insertTaskLog({
1797
+ taskRunId: taskRun.id,
1798
+ sequence: this.sequenceForTask(taskRun.id),
1799
+ timestamp: new Date().toISOString(),
1800
+ source: logSource,
1801
+ message: chunk.output ?? "",
1802
+ });
1803
+ }
1804
+ }
1805
+ else {
1806
+ const response = await withAbort(this.deps.agentService.invoke(agentToUse.id, {
1807
+ input: prompt,
1808
+ metadata: { taskKey: task.key, retry: logSource === "agent_retry" },
1809
+ }));
1810
+ output = response.output ?? "";
1811
+ metadata = response.metadata;
932
1812
  await this.deps.workspaceRepo.insertTaskLog({
933
1813
  taskRunId: taskRun.id,
934
1814
  sequence: this.sequenceForTask(taskRun.id),
935
1815
  timestamp: new Date().toISOString(),
936
- source: "agent",
937
- message: chunk.output ?? "",
1816
+ source: logSource,
1817
+ message: output,
938
1818
  });
939
1819
  }
940
- durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
1820
+ const durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
1821
+ return { output, durationSeconds, metadata };
1822
+ };
1823
+ try {
1824
+ const invocation = await invokeReviewAgent(agent, Boolean(agentStream && this.deps.agentService.invokeStream), "agent");
1825
+ agentOutput = invocation.output;
1826
+ durationSeconds = invocation.durationSeconds;
1827
+ lastStreamMeta = invocation.metadata;
941
1828
  }
942
- else {
943
- const response = await this.deps.agentService.invoke(agent.id, { input: prompt, metadata: { taskKey: task.key } });
944
- agentOutput = response.output ?? "";
945
- durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
1829
+ catch (error) {
1830
+ const message = error instanceof Error ? error.message : String(error);
1831
+ if (!isRetryableAgentError(message)) {
1832
+ throw error;
1833
+ }
1834
+ outputAttempt = 2;
1835
+ agentUsedForOutput = reviewJsonAgent ?? agent;
946
1836
  await this.deps.workspaceRepo.insertTaskLog({
947
1837
  taskRunId: taskRun.id,
948
1838
  sequence: this.sequenceForTask(taskRun.id),
949
1839
  timestamp: new Date().toISOString(),
950
- source: "agent",
951
- message: agentOutput,
1840
+ source: "agent_retry",
1841
+ message: `Transient agent error (${message}); retrying once with ${agentUsedForOutput.slug ?? agentUsedForOutput.id}.`,
952
1842
  });
953
- lastStreamMeta = response.metadata;
1843
+ const invocation = await invokeReviewAgent(agentUsedForOutput, false, "agent_retry");
1844
+ agentOutput = invocation.output;
1845
+ durationSeconds = invocation.durationSeconds;
1846
+ lastStreamMeta = invocation.metadata;
954
1847
  }
955
1848
  const tokenMetaMain = lastStreamMeta
956
1849
  ? {
@@ -962,20 +1855,60 @@ export class CodeReviewService {
962
1855
  model: (lastStreamMeta.model ?? lastStreamMeta.model_name ?? null),
963
1856
  }
964
1857
  : undefined;
965
- await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain);
966
- let parsed = parseJsonOutput(agentOutput);
967
- if (!parsed) {
1858
+ await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain, agentUsedForOutput, outputAttempt);
1859
+ const primaryOutput = agentOutput;
1860
+ let retryOutput;
1861
+ let retryAgentUsed;
1862
+ let normalization = normalizeReviewOutput(agentOutput);
1863
+ let parsed = normalization.result;
1864
+ let validationError = validateReviewOutput(parsed, { requireCommentSlugs });
1865
+ if (validationError === "resolvedSlugs/unresolvedSlugs required when comment backlog exists.") {
1866
+ const warning = `Review output missing comment slugs for ${task.key}; assuming no backlog items resolved.`;
1867
+ warnings.push(warning);
1868
+ await this.deps.workspaceRepo.insertTaskLog({
1869
+ taskRunId: taskRun.id,
1870
+ sequence: this.sequenceForTask(taskRun.id),
1871
+ timestamp: new Date().toISOString(),
1872
+ source: "review_warning",
1873
+ message: warning,
1874
+ });
1875
+ validationError = undefined;
1876
+ }
1877
+ const needsRetry = Boolean(validationError) || normalization.usedFallback;
1878
+ if (needsRetry) {
1879
+ const retryReason = validationError
1880
+ ? `Invalid review schema (${validationError}); retrying once with stricter instructions.`
1881
+ : "Unstructured review output; retrying once with stricter instructions.";
968
1882
  await this.deps.workspaceRepo.insertTaskLog({
969
1883
  taskRunId: taskRun.id,
970
1884
  sequence: this.sequenceForTask(taskRun.id),
971
1885
  timestamp: new Date().toISOString(),
972
1886
  source: "agent",
973
- message: "Invalid JSON from agent; retrying once with stricter instructions.",
1887
+ message: retryReason,
974
1888
  });
975
- const retryPrompt = `${prompt}\n\nRespond ONLY with valid JSON matching the schema above. Do not include prose or fences.`;
1889
+ const buildRetryPrompt = (raw) => [
1890
+ "Your previous response was invalid JSON. Reformat it to match the schema below.",
1891
+ JSON_RETRY_RULES,
1892
+ JSON_CONTRACT,
1893
+ "RESPONSE_TO_CONVERT:",
1894
+ raw,
1895
+ ].join("\n");
1896
+ const retryPrompt = agentOutput.trim()
1897
+ ? buildRetryPrompt(agentOutput)
1898
+ : `${prompt}\n\n${JSON_RETRY_RULES}\n${JSON_CONTRACT}`;
976
1899
  const retryStarted = Date.now();
977
- const retryResp = await this.deps.agentService.invoke(agent.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } });
978
- const retryOutput = retryResp.output ?? "";
1900
+ retryAgentUsed = reviewJsonAgent ?? agent;
1901
+ if (retryAgentUsed.id !== agent.id) {
1902
+ await this.deps.workspaceRepo.insertTaskLog({
1903
+ taskRunId: taskRun.id,
1904
+ sequence: this.sequenceForTask(taskRun.id),
1905
+ timestamp: new Date().toISOString(),
1906
+ source: "agent_retry",
1907
+ message: `Retrying with JSON-only agent override: ${retryAgentUsed.slug ?? retryAgentUsed.id}`,
1908
+ });
1909
+ }
1910
+ const retryResp = await withAbort(this.deps.agentService.invoke(retryAgentUsed.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } }));
1911
+ retryOutput = retryResp.output ?? "";
979
1912
  const retryDuration = Math.round(((Date.now() - retryStarted) / 1000) * 1000) / 1000;
980
1913
  await this.deps.workspaceRepo.insertTaskLog({
981
1914
  taskRunId: taskRun.id,
@@ -998,47 +1931,231 @@ export class CodeReviewService {
998
1931
  model: (retryResp.metadata.model ?? retryResp.metadata.model_name ?? null),
999
1932
  }
1000
1933
  : undefined;
1001
- await recordUsage("review_retry", retryPrompt, retryOutput, retryDuration, retryTokenMeta);
1002
- parsed = parseJsonOutput(retryOutput);
1934
+ await recordUsage("review_retry", retryPrompt, retryOutput, retryDuration, retryTokenMeta, retryAgentUsed, 2);
1935
+ normalization = normalizeReviewOutput(retryOutput);
1936
+ parsed = normalization.result;
1937
+ validationError = validateReviewOutput(parsed, { requireCommentSlugs });
1938
+ if (validationError === "resolvedSlugs/unresolvedSlugs required when comment backlog exists.") {
1939
+ const warning = `Review output missing comment slugs for ${task.key} after retry; assuming no backlog items resolved.`;
1940
+ warnings.push(warning);
1941
+ await this.deps.workspaceRepo.insertTaskLog({
1942
+ taskRunId: taskRun.id,
1943
+ sequence: this.sequenceForTask(taskRun.id),
1944
+ timestamp: new Date().toISOString(),
1945
+ source: "review_warning",
1946
+ message: warning,
1947
+ });
1948
+ validationError = undefined;
1949
+ }
1003
1950
  agentOutput = retryOutput;
1004
1951
  }
1005
- if (!parsed) {
1006
- throw new Error("Agent output did not contain valid JSON review result after retry");
1952
+ if (validationError) {
1953
+ const fallbackSummary = `Review output missing required fields (${validationError}); treated as informational.`;
1954
+ warnings.push(`Review output missing required fields for ${task.key}; proceeding with info_only.`);
1955
+ await this.deps.workspaceRepo.insertTaskLog({
1956
+ taskRunId: taskRun.id,
1957
+ sequence: this.sequenceForTask(taskRun.id),
1958
+ timestamp: new Date().toISOString(),
1959
+ source: "review_warning",
1960
+ message: fallbackSummary,
1961
+ });
1962
+ parsed = {
1963
+ decision: "info_only",
1964
+ summary: fallbackSummary,
1965
+ findings: [],
1966
+ testRecommendations: [],
1967
+ raw: retryOutput ?? agentOutput,
1968
+ };
1969
+ normalization = { parsedFromJson: false, usedFallback: true, issues: ["validation_error"], result: parsed };
1970
+ }
1971
+ if (normalization.usedFallback) {
1972
+ const fallbackMessage = `Review output was not valid JSON for ${task.key}; treated as informational.`;
1973
+ warnings.push(fallbackMessage);
1974
+ await this.deps.workspaceRepo.insertTaskLog({
1975
+ taskRunId: taskRun.id,
1976
+ sequence: this.sequenceForTask(taskRun.id),
1977
+ timestamp: new Date().toISOString(),
1978
+ source: "review_warning",
1979
+ message: fallbackMessage,
1980
+ });
1981
+ try {
1982
+ const artifactPath = await this.persistReviewOutput(jobId, task.id, {
1983
+ schema_version: 1,
1984
+ task_key: task.key,
1985
+ created_at: new Date().toISOString(),
1986
+ agent_id: agent.id,
1987
+ retry_agent_id: retryAgentUsed?.id ?? agent.id,
1988
+ primary_output: primaryOutput,
1989
+ retry_output: retryOutput ?? agentOutput,
1990
+ validation_error: validationError ?? null,
1991
+ });
1992
+ warnings.push(`Review output saved to ${artifactPath} for ${task.key}.`);
1993
+ }
1994
+ catch (persistError) {
1995
+ warnings.push(`Failed to persist review output for ${task.key}: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
1996
+ }
1007
1997
  }
1008
- parsed.raw = agentOutput;
1998
+ parsed.raw = parsed.raw ?? agentOutput;
1999
+ const originalDecision = parsed.decision;
1009
2000
  decision = parsed.decision;
1010
2001
  findings.push(...(parsed.findings ?? []));
1011
- const followups = await this.createFollowupTasksForFindings({
2002
+ const historySupportsNoChanges = task.metadata?.completed_reason === "no_changes" ||
2003
+ historySummary.toLowerCase().includes("no_changes") ||
2004
+ historySummary.toLowerCase().includes("no changes");
2005
+ const summarySupportsNoChanges = summaryIndicatesNoChanges(parsed.summary);
2006
+ const approveDecision = parsed.decision === "approve" || parsed.decision === "info_only";
2007
+ let finalDecision = parsed.decision;
2008
+ let emptyDiffOverride = false;
2009
+ if (diffEmpty && approveDecision && !(summarySupportsNoChanges && historySupportsNoChanges)) {
2010
+ finalDecision = "changes_requested";
2011
+ emptyDiffOverride = true;
2012
+ }
2013
+ if (finalDecision === "changes_requested" && isNonBlockingOnly(parsed.findings ?? []) && !emptyDiffOverride) {
2014
+ finalDecision = "info_only";
2015
+ warnings.push(`Review for ${task.key} requested changes but only low/info findings were reported; downgrading to info_only.`);
2016
+ }
2017
+ commentResolution = await this.applyCommentResolutions({
1012
2018
  task,
1013
- findings: parsed.findings ?? [],
1014
- decision: parsed.decision,
1015
- jobId,
1016
- commandRunId: commandRun.id,
1017
2019
  taskRunId: taskRun.id,
2020
+ jobId,
2021
+ agentId: agent.id,
2022
+ findings: parsed.findings ?? [],
2023
+ resolvedSlugs: parsed.resolvedSlugs ?? undefined,
2024
+ unresolvedSlugs: parsed.unresolvedSlugs ?? undefined,
2025
+ decision: finalDecision,
2026
+ existingComments: commentContext.comments,
1018
2027
  });
1019
- if (followups.length) {
1020
- followupCreated.push(...followups.map((t) => ({
1021
- taskId: t.id,
1022
- taskKey: t.key,
1023
- epicId: t.epicId,
1024
- userStoryId: t.userStoryId,
1025
- generic: t?.metadata?.generic ? true : undefined,
1026
- })));
1027
- warnings.push(`Created follow-up tasks for ${task.key}: ${followups.map((t) => t.key).join(", ")}`);
2028
+ if (commentResolution?.open?.length &&
2029
+ (finalDecision === "approve" || finalDecision === "info_only")) {
2030
+ const openSlugs = commentResolution.open;
2031
+ finalDecision = "changes_requested";
2032
+ const message = `Unresolved comment slugs remain: ${formatSlugList(openSlugs)}. Review approval requires resolving these items.`;
2033
+ const backlogSlug = createTaskCommentSlug({
2034
+ source: "code-review",
2035
+ message,
2036
+ category: "comment_backlog",
2037
+ });
2038
+ const backlogBody = formatTaskCommentBody({
2039
+ slug: backlogSlug,
2040
+ source: "code-review",
2041
+ message,
2042
+ status: "open",
2043
+ category: "comment_backlog",
2044
+ });
2045
+ await this.deps.workspaceRepo.createTaskComment({
2046
+ taskId: task.id,
2047
+ taskRunId: taskRun.id,
2048
+ jobId,
2049
+ sourceCommand: "code-review",
2050
+ authorType: "agent",
2051
+ authorAgentId: agent.id,
2052
+ category: "comment_backlog",
2053
+ slug: backlogSlug,
2054
+ status: "open",
2055
+ body: backlogBody,
2056
+ metadata: { openSlugs },
2057
+ createdAt: new Date().toISOString(),
2058
+ });
2059
+ }
2060
+ if (emptyDiffOverride) {
2061
+ const message = [
2062
+ "Empty diff detected; approval requires an explicit no-changes justification",
2063
+ "and task history indicating no changes were needed.",
2064
+ ].join(" ");
2065
+ const slug = createTaskCommentSlug({
2066
+ source: "code-review",
2067
+ message,
2068
+ category: "review_empty_diff",
2069
+ });
2070
+ const body = formatTaskCommentBody({
2071
+ slug,
2072
+ source: "code-review",
2073
+ message,
2074
+ status: "open",
2075
+ category: "review_empty_diff",
2076
+ });
2077
+ await this.deps.workspaceRepo.createTaskComment({
2078
+ taskId: task.id,
2079
+ taskRunId: taskRun.id,
2080
+ jobId,
2081
+ sourceCommand: "code-review",
2082
+ authorType: "agent",
2083
+ authorAgentId: agent.id,
2084
+ category: "review_empty_diff",
2085
+ slug,
2086
+ status: "open",
2087
+ body,
2088
+ createdAt: new Date().toISOString(),
2089
+ });
2090
+ warnings.push(`Empty diff approval rejected for ${task.key}; requesting explicit no-changes justification.`);
2091
+ }
2092
+ const appendSyntheticFinding = (message, suggestedFix) => {
2093
+ const finding = {
2094
+ type: "process",
2095
+ severity: "info",
2096
+ message,
2097
+ suggestedFix,
2098
+ };
2099
+ if (!parsed.findings) {
2100
+ parsed.findings = [];
2101
+ }
2102
+ parsed.findings.push(finding);
2103
+ findings.push(finding);
2104
+ };
2105
+ if (finalDecision === "changes_requested" && (parsed.findings?.length ?? 0) === 0) {
2106
+ if (emptyDiffOverride) {
2107
+ appendSyntheticFinding("Empty diff lacks explicit no-changes justification; changes requested to confirm no code updates were required.", "Update the review summary to state no changes were required and confirm task history reflects no_changes.");
2108
+ }
2109
+ else if (commentResolution?.open?.length) {
2110
+ appendSyntheticFinding(`Unresolved comment backlog remains (${formatSlugList(commentResolution.open)}); approval requires resolving these items.`, "Resolve or explicitly reopen the listed comment slugs before approving.");
2111
+ }
2112
+ else {
2113
+ finalDecision = "info_only";
2114
+ warnings.push(`Review requested changes for ${task.key} but provided no findings; downgrading to info_only.`);
2115
+ }
2116
+ }
2117
+ parsed.decision = finalDecision;
2118
+ decision = finalDecision;
2119
+ if (allowFollowups) {
2120
+ const followups = await this.createFollowupTasksForFindings({
2121
+ task,
2122
+ findings: parsed.findings ?? [],
2123
+ decision: originalDecision,
2124
+ jobId,
2125
+ commandRunId: commandRun.id,
2126
+ taskRunId: taskRun.id,
2127
+ });
2128
+ if (followups.length) {
2129
+ followupCreated.push(...followups.map((t) => ({
2130
+ taskId: t.id,
2131
+ taskKey: t.key,
2132
+ epicId: t.epicId,
2133
+ userStoryId: t.userStoryId,
2134
+ generic: t?.metadata?.generic ? true : undefined,
2135
+ })));
2136
+ warnings.push(`Created follow-up tasks for ${task.key}: ${followups.map((t) => t.key).join(", ")}`);
2137
+ }
1028
2138
  }
1029
2139
  let taskStatusUpdate = statusBefore;
1030
2140
  if (!request.dryRun) {
1031
- if (parsed.decision === "approve") {
1032
- await this.stateService.markReadyToQa(task);
1033
- taskStatusUpdate = "ready_to_qa";
2141
+ const approveDecision = parsed.decision === "approve" || parsed.decision === "info_only";
2142
+ if (approveDecision) {
2143
+ if (diffEmpty) {
2144
+ await this.stateService.markCompleted(task, { review_no_changes: true }, statusContext);
2145
+ taskStatusUpdate = "completed";
2146
+ }
2147
+ else {
2148
+ await this.stateService.markReadyToQa(task, undefined, statusContext);
2149
+ taskStatusUpdate = "ready_to_qa";
2150
+ }
1034
2151
  }
1035
2152
  else if (parsed.decision === "changes_requested") {
1036
- await this.stateService.returnToInProgress(task);
1037
- taskStatusUpdate = "in_progress";
2153
+ await this.stateService.markChangesRequested(task, undefined, statusContext);
2154
+ taskStatusUpdate = "changes_requested";
1038
2155
  }
1039
2156
  else if (parsed.decision === "block") {
1040
- await this.stateService.markBlocked(task, "review_blocked");
1041
- taskStatusUpdate = "blocked";
2157
+ await this.stateService.markFailed(task, "review_blocked", statusContext);
2158
+ taskStatusUpdate = "failed";
1042
2159
  }
1043
2160
  }
1044
2161
  else {
@@ -1052,26 +2169,6 @@ export class CodeReviewService {
1052
2169
  });
1053
2170
  }
1054
2171
  statusAfter = taskStatusUpdate;
1055
- for (const finding of parsed.findings ?? []) {
1056
- await this.deps.workspaceRepo.createTaskComment({
1057
- taskId: task.id,
1058
- taskRunId: taskRun.id,
1059
- jobId,
1060
- sourceCommand: "code-review",
1061
- authorType: "agent",
1062
- authorAgentId: agent.id,
1063
- category: finding.type ?? "other",
1064
- file: finding.file,
1065
- line: finding.line,
1066
- pathHint: finding.file,
1067
- body: finding.message + (finding.suggestedFix ? `\n\nSuggested fix: ${finding.suggestedFix}` : ""),
1068
- metadata: {
1069
- severity: finding.severity,
1070
- suggestedFix: finding.suggestedFix,
1071
- },
1072
- createdAt: new Date().toISOString(),
1073
- });
1074
- }
1075
2172
  await this.writeReviewSummaryComment({
1076
2173
  task,
1077
2174
  taskRunId: taskRun.id,
@@ -1083,8 +2180,11 @@ export class CodeReviewService {
1083
2180
  summary: parsed.summary,
1084
2181
  findingsCount: parsed.findings?.length ?? 0,
1085
2182
  followupTaskKeys: followupCreated.map((t) => t.taskKey),
2183
+ resolvedCount: commentResolution?.resolved.length,
2184
+ reopenedCount: commentResolution?.reopened.length,
2185
+ openCount: commentResolution?.open.length,
1086
2186
  });
1087
- await this.deps.workspaceRepo.createTaskReview({
2187
+ const review = await this.deps.workspaceRepo.createTaskReview({
1088
2188
  taskId: task.id,
1089
2189
  jobId,
1090
2190
  agentId: agent.id,
@@ -1093,6 +2193,7 @@ export class CodeReviewService {
1093
2193
  summary: parsed.summary ?? undefined,
1094
2194
  findingsJson: parsed.findings ?? [],
1095
2195
  testRecommendationsJson: parsed.testRecommendations ?? [],
2196
+ metadata: diffMeta,
1096
2197
  createdAt: new Date().toISOString(),
1097
2198
  });
1098
2199
  await this.stateService.recordReviewMetadata(task, {
@@ -1100,6 +2201,9 @@ export class CodeReviewService {
1100
2201
  agentId: agent.id,
1101
2202
  modelName: agent.defaultModel ?? null,
1102
2203
  jobId,
2204
+ reviewId: review.id,
2205
+ diffEmpty,
2206
+ changedPaths,
1103
2207
  });
1104
2208
  await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
1105
2209
  status: "succeeded",
@@ -1151,6 +2255,18 @@ export class CodeReviewService {
1151
2255
  await this.deps.jobService.updateJobStatus(jobId, "running", {
1152
2256
  processedItems: state?.reviewed.length ?? 0,
1153
2257
  });
2258
+ emitReviewEndOnce({
2259
+ statusLabel: "FAILED",
2260
+ decision: "error",
2261
+ findingsCount: findings.length,
2262
+ tokensTotal,
2263
+ });
2264
+ await maybeRateTask(task, taskRun.id, tokensTotal);
2265
+ if (isAuthErrorMessage(message)) {
2266
+ abortRemainingReason = message;
2267
+ warnings.push(`Auth/rate limit error detected; stopping after ${task.key}. ${message}`);
2268
+ break;
2269
+ }
1154
2270
  continue;
1155
2271
  }
1156
2272
  results.push({
@@ -1160,11 +2276,37 @@ export class CodeReviewService {
1160
2276
  statusAfter,
1161
2277
  decision,
1162
2278
  findings,
2279
+ error: reviewErrorCode,
1163
2280
  followupTasks: followupCreated,
1164
2281
  });
2282
+ const statusLabel = reviewErrorCode
2283
+ ? "FAILED"
2284
+ : decision === "approve" || decision === "info_only"
2285
+ ? "APPROVED"
2286
+ : decision === "block"
2287
+ ? "FAILED"
2288
+ : decision === "changes_requested"
2289
+ ? "CHANGES_REQUESTED"
2290
+ : "FAILED";
2291
+ emitReviewEndOnce({
2292
+ statusLabel,
2293
+ decision,
2294
+ findingsCount: findings.length,
2295
+ tokensTotal,
2296
+ });
1165
2297
  await this.deps.jobService.updateJobStatus(jobId, "running", {
1166
2298
  processedItems: state?.reviewed.length ?? 0,
1167
2299
  });
2300
+ await maybeRateTask(task, taskRun.id, tokensTotal);
2301
+ }
2302
+ if (abortRemainingReason) {
2303
+ await this.deps.jobService.updateJobStatus(jobId, "failed", {
2304
+ processedItems: state?.reviewed.length ?? 0,
2305
+ totalItems: selectedTaskIds.length,
2306
+ errorSummary: AUTH_ERROR_REASON,
2307
+ });
2308
+ await this.deps.jobService.finishCommandRun(commandRun.id, "failed", abortRemainingReason);
2309
+ return { jobId, commandRunId: commandRun.id, tasks: results, warnings };
1168
2310
  }
1169
2311
  await this.deps.jobService.updateJobStatus(jobId, "completed", {
1170
2312
  processedItems: state?.reviewed.length ?? selectedTaskIds.length,