@mcoda/core 0.1.9 → 0.1.12

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 (204) hide show
  1. package/README.md +2 -2
  2. package/dist/api/AgentsApi.d.ts +1 -0
  3. package/dist/api/AgentsApi.d.ts.map +1 -1
  4. package/dist/api/AgentsApi.js +136 -11
  5. package/dist/api/QaTasksApi.d.ts.map +1 -1
  6. package/dist/api/QaTasksApi.js +4 -0
  7. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  8. package/dist/prompts/PdrPrompts.js +6 -0
  9. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  10. package/dist/prompts/SdsPrompts.js +7 -0
  11. package/dist/services/agents/AgentRatingService.d.ts +19 -0
  12. package/dist/services/agents/AgentRatingService.d.ts.map +1 -1
  13. package/dist/services/agents/AgentRatingService.js +66 -2
  14. package/dist/services/agents/GatewayAgentService.d.ts +8 -0
  15. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  16. package/dist/services/agents/GatewayAgentService.js +462 -65
  17. package/dist/services/agents/GatewayHandoff.d.ts +5 -1
  18. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -1
  19. package/dist/services/agents/GatewayHandoff.js +65 -32
  20. package/dist/services/agents/RoutingService.d.ts +1 -0
  21. package/dist/services/agents/RoutingService.d.ts.map +1 -1
  22. package/dist/services/agents/RoutingService.js +4 -4
  23. package/dist/services/backlog/BacklogService.d.ts +23 -0
  24. package/dist/services/backlog/BacklogService.d.ts.map +1 -1
  25. package/dist/services/backlog/BacklogService.js +62 -7
  26. package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
  27. package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
  28. package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
  29. package/dist/services/backlog/TaskOrderingService.d.ts +16 -4
  30. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  31. package/dist/services/backlog/TaskOrderingService.js +529 -73
  32. package/dist/services/docs/DocInventory.d.ts +11 -0
  33. package/dist/services/docs/DocInventory.d.ts.map +1 -0
  34. package/dist/services/docs/DocInventory.js +230 -0
  35. package/dist/services/docs/DocgenRunContext.d.ts +59 -0
  36. package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
  37. package/dist/services/docs/DocgenRunContext.js +4 -0
  38. package/dist/services/docs/DocsService.d.ts +59 -2
  39. package/dist/services/docs/DocsService.d.ts.map +1 -1
  40. package/dist/services/docs/DocsService.js +1701 -48
  41. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
  42. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
  43. package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
  44. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
  45. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
  46. package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
  47. package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
  48. package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
  49. package/dist/services/docs/patch/DocPatchEngine.js +331 -0
  50. package/dist/services/docs/review/Glossary.d.ts +16 -0
  51. package/dist/services/docs/review/Glossary.d.ts.map +1 -0
  52. package/dist/services/docs/review/Glossary.js +47 -0
  53. package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
  54. package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
  55. package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
  56. package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
  57. package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
  58. package/dist/services/docs/review/ReviewReportSchema.js +47 -0
  59. package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
  60. package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
  61. package/dist/services/docs/review/ReviewTypes.js +94 -0
  62. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
  63. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
  64. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
  65. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
  66. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
  67. package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
  68. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
  69. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
  70. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
  71. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
  72. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
  73. package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
  74. package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
  75. package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
  76. package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
  77. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
  78. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
  79. package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
  80. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
  81. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
  82. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
  83. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
  84. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
  85. package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
  86. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
  87. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
  88. package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
  89. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
  90. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
  91. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
  92. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
  93. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
  94. package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
  95. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
  96. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
  97. package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
  98. package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
  99. package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
  100. package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
  101. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
  102. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
  103. package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
  104. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
  105. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
  106. package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
  107. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
  108. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
  109. package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
  110. package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
  111. package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
  112. package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
  113. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
  114. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
  115. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
  116. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
  117. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
  118. package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
  119. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
  120. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
  121. package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
  122. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
  123. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
  124. package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
  125. package/dist/services/docs/review/glossary.json +47 -0
  126. package/dist/services/estimate/EstimateService.d.ts +2 -0
  127. package/dist/services/estimate/EstimateService.d.ts.map +1 -1
  128. package/dist/services/estimate/EstimateService.js +66 -18
  129. package/dist/services/estimate/VelocityService.d.ts +4 -0
  130. package/dist/services/estimate/VelocityService.d.ts.map +1 -1
  131. package/dist/services/estimate/VelocityService.js +179 -36
  132. package/dist/services/estimate/types.d.ts +1 -0
  133. package/dist/services/estimate/types.d.ts.map +1 -1
  134. package/dist/services/execution/GatewayTrioService.d.ts +71 -4
  135. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -1
  136. package/dist/services/execution/GatewayTrioService.js +1695 -328
  137. package/dist/services/execution/QaApiRunner.d.ts +30 -0
  138. package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
  139. package/dist/services/execution/QaApiRunner.js +881 -0
  140. package/dist/services/execution/QaFollowupService.d.ts +1 -0
  141. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  142. package/dist/services/execution/QaFollowupService.js +8 -2
  143. package/dist/services/execution/QaPlanValidator.d.ts +10 -0
  144. package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
  145. package/dist/services/execution/QaPlanValidator.js +128 -0
  146. package/dist/services/execution/QaProfileService.d.ts +21 -1
  147. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  148. package/dist/services/execution/QaProfileService.js +214 -29
  149. package/dist/services/execution/QaTasksService.d.ts +41 -1
  150. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  151. package/dist/services/execution/QaTasksService.js +2851 -500
  152. package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
  153. package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
  154. package/dist/services/execution/QaTestCommandBuilder.js +495 -0
  155. package/dist/services/execution/TaskSelectionService.d.ts +4 -2
  156. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  157. package/dist/services/execution/TaskSelectionService.js +144 -28
  158. package/dist/services/execution/TaskStateService.d.ts +19 -6
  159. package/dist/services/execution/TaskStateService.d.ts.map +1 -1
  160. package/dist/services/execution/TaskStateService.js +128 -13
  161. package/dist/services/execution/WorkOnTasksService.d.ts +19 -2
  162. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  163. package/dist/services/execution/WorkOnTasksService.js +3913 -1225
  164. package/dist/services/jobs/JobInsightsService.d.ts +4 -0
  165. package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
  166. package/dist/services/jobs/JobInsightsService.js +51 -5
  167. package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
  168. package/dist/services/jobs/JobResumeService.js +23 -10
  169. package/dist/services/jobs/JobService.d.ts +56 -4
  170. package/dist/services/jobs/JobService.d.ts.map +1 -1
  171. package/dist/services/jobs/JobService.js +232 -1
  172. package/dist/services/openapi/OpenApiService.d.ts +41 -0
  173. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  174. package/dist/services/openapi/OpenApiService.js +889 -98
  175. package/dist/services/planning/CreateTasksService.d.ts +15 -0
  176. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  177. package/dist/services/planning/CreateTasksService.js +311 -6
  178. package/dist/services/planning/RefineTasksService.d.ts +4 -0
  179. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  180. package/dist/services/planning/RefineTasksService.js +225 -24
  181. package/dist/services/review/CodeReviewService.d.ts +4 -0
  182. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  183. package/dist/services/review/CodeReviewService.js +778 -232
  184. package/dist/services/review/ReviewNormalizer.d.ts +9 -0
  185. package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
  186. package/dist/services/review/ReviewNormalizer.js +147 -0
  187. package/dist/services/shared/AuthErrors.d.ts +3 -0
  188. package/dist/services/shared/AuthErrors.d.ts.map +1 -0
  189. package/dist/services/shared/AuthErrors.js +17 -0
  190. package/dist/services/shared/DocdexGuidance.d.ts +7 -0
  191. package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
  192. package/dist/services/shared/DocdexGuidance.js +12 -0
  193. package/dist/services/shared/ProjectGuidance.d.ts +12 -1
  194. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -1
  195. package/dist/services/shared/ProjectGuidance.js +64 -7
  196. package/dist/services/system/ToolDenylist.d.ts +13 -0
  197. package/dist/services/system/ToolDenylist.d.ts.map +1 -0
  198. package/dist/services/system/ToolDenylist.js +85 -0
  199. package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
  200. package/dist/services/telemetry/TelemetryService.js +39 -7
  201. package/dist/workspace/WorkspaceManager.d.ts +22 -0
  202. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  203. package/dist/workspace/WorkspaceManager.js +203 -32
  204. 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
6
  import { GlobalRepository, WorkspaceRepository, } from "@mcoda/db";
6
- import { PathHelper } from "@mcoda/shared";
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";
@@ -12,11 +13,14 @@ import yaml from "yaml";
12
13
  import { createTaskKeyGenerator } from "../planning/KeyHelpers.js";
13
14
  import { RoutingService } from "../agents/RoutingService.js";
14
15
  import { AgentRatingService } from "../agents/AgentRatingService.js";
15
- import { loadProjectGuidance } from "../shared/ProjectGuidance.js";
16
+ import { isDocContextExcluded, loadProjectGuidance, normalizeDocType } from "../shared/ProjectGuidance.js";
17
+ import { buildDocdexUsageGuidance } from "../shared/DocdexGuidance.js";
16
18
  import { createTaskCommentSlug, formatTaskCommentBody } from "../tasks/TaskCommentFormatter.js";
19
+ import { AUTH_ERROR_REASON, isAuthErrorMessage } from "../shared/AuthErrors.js";
20
+ import { normalizeReviewOutput } from "./ReviewNormalizer.js";
17
21
  const DEFAULT_BASE_BRANCH = "mcoda-dev";
18
- const REVIEW_DIR = (workspaceRoot, jobId) => path.join(workspaceRoot, ".mcoda", "jobs", jobId, "review");
19
- 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");
20
24
  const REVIEW_PROMPT_LIMITS = {
21
25
  diff: 12000,
22
26
  history: 3000,
@@ -26,63 +30,70 @@ const REVIEW_PROMPT_LIMITS = {
26
30
  };
27
31
  const DOCDEX_TIMEOUT_MS = 8000;
28
32
  const DEFAULT_CODE_REVIEW_PROMPT = [
29
- "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 }),
30
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.",
31
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);
32
42
  const DEFAULT_JOB_PROMPT = "You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.";
33
43
  const DEFAULT_CHARACTER_PROMPT = "Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.";
34
- const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
35
- const extractJsonSlice = (candidate) => {
36
- const start = candidate.indexOf("{");
37
- const end = candidate.lastIndexOf("}");
38
- 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)))
39
61
  return undefined;
40
- return candidate.slice(start, end + 1);
62
+ return trimmed;
41
63
  };
42
- const sanitizeJsonCandidate = (value) => {
43
- const cleanedLines = value
44
- .split(/\r?\n/)
45
- .filter((line) => {
46
- const trimmed = line.trim();
47
- if (!trimmed)
48
- return true;
49
- if (trimmed.startsWith("{") ||
50
- trimmed.startsWith("}") ||
51
- trimmed.startsWith("[") ||
52
- trimmed.startsWith("]") ||
53
- trimmed.startsWith("\"")) {
54
- return true;
55
- }
56
- return false;
57
- })
58
- .join("\n");
59
- return cleanedLines.replace(/,\s*([}\]])/g, "$1");
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;
60
75
  };
61
- const parseJsonOutput = (raw) => {
62
- const trimmed = raw.trim();
63
- const fenced = trimmed.replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
64
- const candidates = [trimmed, fenced];
65
- for (const candidate of candidates) {
66
- const slice = extractJsonSlice(candidate);
67
- if (!slice)
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);
68
83
  continue;
69
- try {
70
- const parsed = JSON.parse(slice);
71
- return { ...parsed, raw: raw };
72
84
  }
73
- catch {
74
- const sanitized = sanitizeJsonCandidate(slice);
75
- try {
76
- const parsed = JSON.parse(sanitized);
77
- return { ...parsed, raw: raw };
78
- }
79
- catch {
80
- /* ignore */
81
- }
85
+ if (hasOpenApiSnippet) {
86
+ continue;
82
87
  }
88
+ if (openApiIncluded) {
89
+ continue;
90
+ }
91
+ openApiIncluded = true;
92
+ filtered.push(entry);
83
93
  }
84
- return undefined;
94
+ return filtered;
85
95
  };
96
+ const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
86
97
  const summarizeComments = (comments) => {
87
98
  if (!comments.length)
88
99
  return "No prior comments.";
@@ -102,6 +113,17 @@ const truncateSection = (label, text, limit) => {
102
113
  const remaining = text.length - limit;
103
114
  return `${trimmed}\n...[truncated ${remaining} chars from ${label}]`;
104
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
+ };
105
127
  const withTimeout = async (promise, ms, label) => {
106
128
  let timeoutId;
107
129
  const timeoutPromise = new Promise((_, reject) => {
@@ -134,6 +156,12 @@ const JSON_CONTRACT = `{
134
156
  "resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],
135
157
  "unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]
136
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());
137
165
  const normalizeSingleLine = (value, fallback) => {
138
166
  const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
139
167
  return trimmed || fallback;
@@ -155,6 +183,56 @@ const normalizePath = (value) => value
155
183
  .replace(/\\/g, "/")
156
184
  .replace(/^\.\//, "")
157
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
+ };
158
236
  const parseCommentBody = (body) => {
159
237
  const trimmed = (body ?? "").trim();
160
238
  if (!trimmed)
@@ -189,8 +267,15 @@ const buildCommentBacklog = (comments) => {
189
267
  const lines = [];
190
268
  const toSingleLine = (value) => value.replace(/\s+/g, " ").trim();
191
269
  for (const comment of comments) {
192
- const slug = comment.slug?.trim() || undefined;
193
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
+ });
194
279
  const key = slug ??
195
280
  `${comment.sourceCommand}:${comment.file ?? ""}:${comment.line ?? ""}:${details.message || comment.body}`;
196
281
  if (seen.has(key))
@@ -256,9 +341,11 @@ export class CodeReviewService {
256
341
  const repo = await GlobalRepository.create();
257
342
  const agentService = new AgentService(repo);
258
343
  const routingService = await RoutingService.create();
344
+ const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
259
345
  const docdex = new DocdexClient({
260
346
  workspaceRoot: workspace.workspaceRoot,
261
347
  baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
348
+ repoId: docdexRepoId,
262
349
  });
263
350
  const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
264
351
  const jobService = new JobService(workspace, workspaceRepo);
@@ -296,6 +383,14 @@ export class CodeReviewService {
296
383
  await maybeClose(this.deps.routingService);
297
384
  await maybeClose(this.deps.docdex);
298
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
+ }
299
394
  async readPromptFiles(paths) {
300
395
  const contents = [];
301
396
  const seen = new Set();
@@ -316,21 +411,11 @@ export class CodeReviewService {
316
411
  }
317
412
  async ensureMcoda() {
318
413
  await PathHelper.ensureDir(this.workspace.mcodaDir);
319
- const gitignorePath = path.join(this.workspace.workspaceRoot, ".gitignore");
320
- const entry = ".mcoda/\n";
321
- try {
322
- const content = await fs.readFile(gitignorePath, "utf8");
323
- if (!content.includes(".mcoda/")) {
324
- await fs.writeFile(gitignorePath, `${content.trimEnd()}\n${entry}`, "utf8");
325
- }
326
- }
327
- catch {
328
- await fs.writeFile(gitignorePath, entry, "utf8");
329
- }
330
414
  }
331
415
  async loadPrompts(agentId) {
332
- const mcodaPromptPath = path.join(this.workspace.workspaceRoot, ".mcoda", "prompts", "code-reviewer.md");
416
+ const mcodaPromptPath = path.join(this.workspace.mcodaDir, "prompts", "code-reviewer.md");
333
417
  const workspacePromptPath = path.join(this.workspace.workspaceRoot, "prompts", "code-reviewer.md");
418
+ const repoPromptPath = resolveRepoPromptPath("code-reviewer.md");
334
419
  try {
335
420
  await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
336
421
  await fs.access(mcodaPromptPath);
@@ -343,30 +428,29 @@ export class CodeReviewService {
343
428
  console.info(`[code-review] copied code-reviewer prompt to ${mcodaPromptPath}`);
344
429
  }
345
430
  catch {
346
- console.info(`[code-review] no code-reviewer prompt found at ${workspacePromptPath}; writing default prompt to ${mcodaPromptPath}`);
347
- 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
+ }
348
440
  }
349
441
  }
350
- const filePrompts = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
351
442
  const agentPrompts = "getPrompts" in this.deps.agentService ? await this.deps.agentService.getPrompts(agentId) : undefined;
352
- const mergedCommandPrompt = (() => {
353
- const parts = [...filePrompts];
354
- if (agentPrompts?.commandPrompts?.["code-review"]) {
355
- parts.push(agentPrompts.commandPrompts["code-review"]);
356
- }
357
- if (!parts.length)
358
- parts.push(DEFAULT_CODE_REVIEW_PROMPT);
359
- return parts.filter(Boolean).join("\n\n");
360
- })();
443
+ const filePrompt = await readPromptFile(mcodaPromptPath, DEFAULT_CODE_REVIEW_PROMPT);
444
+ const commandPrompt = agentPrompts?.commandPrompts?.["code-review"]?.trim() || filePrompt;
361
445
  return {
362
- jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
363
- characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
364
- commandPrompt: mergedCommandPrompt || undefined,
446
+ jobPrompt: sanitizeNonGatewayPrompt(agentPrompts?.jobPrompt) ?? DEFAULT_JOB_PROMPT,
447
+ characterPrompt: sanitizeNonGatewayPrompt(agentPrompts?.characterPrompt) ?? DEFAULT_CHARACTER_PROMPT,
448
+ commandPrompt: commandPrompt || undefined,
365
449
  };
366
450
  }
367
451
  async loadRunbookAndChecklists() {
368
452
  const extras = [];
369
- 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");
370
454
  try {
371
455
  const content = await fs.readFile(runbookPath, "utf8");
372
456
  extras.push(content);
@@ -374,7 +458,7 @@ export class CodeReviewService {
374
458
  catch {
375
459
  /* optional */
376
460
  }
377
- const checklistDir = path.join(this.workspace.workspaceRoot, ".mcoda", "checklists");
461
+ const checklistDir = path.join(this.workspace.mcodaDir, "checklists");
378
462
  try {
379
463
  const entries = await fs.readdir(checklistDir);
380
464
  for (const entry of entries) {
@@ -395,7 +479,13 @@ export class CodeReviewService {
395
479
  commandName: "code-review",
396
480
  overrideAgentSlug: agentName,
397
481
  });
398
- 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;
399
489
  }
400
490
  ensureRatingService() {
401
491
  if (!this.ratingService) {
@@ -425,7 +515,7 @@ export class CodeReviewService {
425
515
  projectKey: filters.projectKey,
426
516
  epicKey: filters.epicKey,
427
517
  storyKey: filters.storyKey,
428
- statuses: filters.statusFilter,
518
+ statuses: filters.statusFilter && filters.statusFilter.length ? filters.statusFilter : undefined,
429
519
  verbose: true,
430
520
  });
431
521
  let tasks = result.summary.tasks;
@@ -447,13 +537,13 @@ export class CodeReviewService {
447
537
  }
448
538
  }
449
539
  async persistState(jobId, state) {
450
- const dir = REVIEW_DIR(this.workspace.workspaceRoot, jobId);
540
+ const dir = REVIEW_DIR(this.workspace.mcodaDir, jobId);
451
541
  await fs.mkdir(dir, { recursive: true });
452
- 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");
453
543
  }
454
544
  async loadState(jobId) {
455
545
  try {
456
- 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");
457
547
  return JSON.parse(raw);
458
548
  }
459
549
  catch {
@@ -480,21 +570,44 @@ export class CodeReviewService {
480
570
  }
481
571
  return Array.from(hints).slice(0, 8);
482
572
  }
483
- async gatherDocContext(taskTitle, paths, acceptance) {
573
+ async gatherDocContext(taskTitle, paths, acceptance, docLinks = []) {
484
574
  const snippets = [];
485
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
+ }
486
585
  const queries = [...new Set([...(paths.length ? this.componentHintsFromPaths(paths) : []), taskTitle, ...(acceptance ?? [])])].slice(0, 8);
487
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
+ };
488
600
  for (const query of queries) {
489
601
  try {
490
602
  const docs = await withTimeout(this.deps.docdex.search({
491
603
  query,
492
604
  profile: "workspace-code",
493
605
  }), DOCDEX_TIMEOUT_MS, `docdex search for "${query}"`);
494
- snippets.push(...docs.slice(0, 2).map((doc) => {
606
+ const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, false));
607
+ snippets.push(...filteredDocs.slice(0, 2).map((doc) => {
495
608
  const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
496
609
  const ref = doc.path ?? doc.id ?? doc.title ?? query;
497
- return `- [${doc.docType ?? "doc"}] ${ref}: ${content}`;
610
+ return `- [${resolveDocType(doc)}] ${ref}: ${content}`;
498
611
  }));
499
612
  }
500
613
  catch (error) {
@@ -506,10 +619,11 @@ export class CodeReviewService {
506
619
  query,
507
620
  profile: "workspace-code",
508
621
  }), DOCDEX_TIMEOUT_MS, `docdex search for "${query}" after reindex`);
509
- snippets.push(...docs.slice(0, 2).map((doc) => {
622
+ const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, false));
623
+ snippets.push(...filteredDocs.slice(0, 2).map((doc) => {
510
624
  const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
511
625
  const ref = doc.path ?? doc.id ?? doc.title ?? query;
512
- return `- [${doc.docType ?? "doc"}] ${ref}: ${content}`;
626
+ return `- [${resolveDocType(doc)}] ${ref}: ${content}`;
513
627
  }));
514
628
  continue;
515
629
  }
@@ -521,6 +635,44 @@ export class CodeReviewService {
521
635
  warnings.push(`docdex search failed for ${query}: ${error.message}`);
522
636
  }
523
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
+ }
524
676
  return { snippets: Array.from(new Set(snippets)), warnings };
525
677
  }
526
678
  buildReviewPrompt(params) {
@@ -533,13 +685,24 @@ export class CodeReviewService {
533
685
  const commentBacklog = params.commentBacklog
534
686
  ? truncateSection("comment backlog", params.commentBacklog, REVIEW_PROMPT_LIMITS.history)
535
687
  : "";
536
- const docContextText = params.docContext.length ? truncateSection("doc context", params.docContext.join("\n"), REVIEW_PROMPT_LIMITS.docContext) : "";
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
+ : "";
537
692
  const openapiSnippet = params.openapiSnippet ? truncateSection("openapi", params.openapiSnippet, REVIEW_PROMPT_LIMITS.openapi) : undefined;
538
693
  const checklistsText = params.checklists?.length
539
694
  ? truncateSection("checklists", params.checklists.join("\n\n"), REVIEW_PROMPT_LIMITS.checklist)
540
695
  : "";
541
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");
542
704
  parts.push([
705
+ reviewFocus,
543
706
  `Task ${params.task.key}: ${params.task.title}`,
544
707
  `Epic: ${params.task.epicKey ?? ""} ${params.task.epicTitle ?? ""}`.trim(),
545
708
  `Epic description: ${params.task.epicDescription ? params.task.epicDescription : "none"}`,
@@ -548,22 +711,24 @@ export class CodeReviewService {
548
711
  `Status: ${params.task.status}, Branch: ${params.branch ?? params.task.vcsBranch ?? "n/a"} (base ${params.baseRef})`,
549
712
  `Task description: ${params.task.description ? params.task.description : "none"}`,
550
713
  `History:\n${historySummary}`,
551
- commentBacklog ? `Comment backlog (unresolved slugs):\n${commentBacklog}` : "Comment backlog: none",
552
- `Acceptance criteria: ${acceptance}`,
714
+ commentBacklog
715
+ ? `Code-review comment backlog (unresolved slugs):\n${commentBacklog}`
716
+ : "Code-review comment backlog: none",
717
+ `Task DoD / acceptance criteria: ${acceptance}`,
553
718
  docContextText ? `Doc context (docdex excerpts):\n${docContextText}` : "Doc context: none",
554
719
  openapiSnippet
555
720
  ? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${openapiSnippet}`
556
721
  : "OpenAPI: not provided; avoid inventing endpoints.",
557
722
  checklistsText ? `Review checklists/runbook:\n${checklistsText}` : "Checklists: none",
558
- "Diff:\n" + diffText,
723
+ params.diffEmpty ? "Diff: (empty — no changes between base and branch)" : "Diff:\n" + diffText,
559
724
  "Respond with STRICT JSON only, matching:\n" + JSON_CONTRACT,
560
- "Rules: honor OpenAPI contracts; cite doc context where relevant; include resolvedSlugs/unresolvedSlugs for comment backlog items; 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.",
561
726
  ].join("\n"));
562
727
  return parts.join("\n\n");
563
728
  }
564
729
  async buildHistorySummary(taskId) {
565
730
  const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
566
- sourceCommands: ["work-on-tasks", "code-review", "qa-tasks"],
731
+ sourceCommands: ["work-on-tasks", "code-review"],
567
732
  limit: 10,
568
733
  });
569
734
  const lastReview = await this.deps.workspaceRepo.getLatestTaskReview(taskId);
@@ -585,12 +750,12 @@ export class CodeReviewService {
585
750
  }
586
751
  }
587
752
  if (!parts.length)
588
- return "No prior review or QA history.";
753
+ return "No prior review history.";
589
754
  return parts.join("\n");
590
755
  }
591
756
  async loadCommentContext(taskId) {
592
757
  const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
593
- sourceCommands: ["code-review", "qa-tasks"],
758
+ sourceCommands: ["code-review"],
594
759
  limit: 50,
595
760
  });
596
761
  const unresolved = comments.filter((comment) => !comment.resolvedAt);
@@ -649,9 +814,10 @@ export class CodeReviewService {
649
814
  }
650
815
  }
651
816
  const reviewSlugIndex = this.buildCommentSlugIndex(params.existingComments.filter((comment) => comment.sourceCommand === "code-review"));
652
- const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined);
817
+ const allowedSlugs = new Set(existingBySlug.keys());
818
+ const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined).filter((slug) => allowedSlugs.has(slug));
653
819
  const resolvedSet = new Set(resolvedSlugs);
654
- const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined));
820
+ const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined).filter((slug) => allowedSlugs.has(slug)));
655
821
  const findingSlugs = [];
656
822
  for (const finding of params.findings ?? []) {
657
823
  const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
@@ -881,12 +1047,12 @@ export class CodeReviewService {
881
1047
  });
882
1048
  }
883
1049
  async persistContext(jobId, taskId, context) {
884
- const dir = path.join(REVIEW_DIR(this.workspace.workspaceRoot, jobId), "context");
1050
+ const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "context");
885
1051
  await fs.mkdir(dir, { recursive: true });
886
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");
887
1053
  }
888
1054
  async persistDiff(jobId, taskId, diff) {
889
- const dir = path.join(REVIEW_DIR(this.workspace.workspaceRoot, jobId), "diffs");
1055
+ const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "diffs");
890
1056
  await fs.mkdir(dir, { recursive: true });
891
1057
  await fs.writeFile(path.join(dir, `${taskId}.diff`), diff, "utf8");
892
1058
  // structured review diff snapshot
@@ -919,6 +1085,13 @@ export class CodeReviewService {
919
1085
  files.push(current);
920
1086
  await fs.writeFile(path.join(dir, `${taskId}.json`), JSON.stringify({ schema_version: 1, task_id: taskId, files }, null, 2), "utf8");
921
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
+ }
922
1095
  severityToPriority(severity) {
923
1096
  if (!severity)
924
1097
  return null;
@@ -926,6 +1099,13 @@ export class CodeReviewService {
926
1099
  const order = { critical: 1, high: 2, medium: 3, low: 4, info: 5 };
927
1100
  return order[normalized] ?? null;
928
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
+ }
929
1109
  shouldCreateFollowupTask(decision, finding) {
930
1110
  // SDS rule: create follow-ups for blocking/changes_requested decisions or critical/high issues,
931
1111
  // and for contract/security/bug types at medium+ severity. Do not create for approve+low/info.
@@ -1026,6 +1206,9 @@ export class CodeReviewService {
1026
1206
  const storyId = genericTarget ? genericContainers.story.id : params.task.userStoryId;
1027
1207
  const storyKey = genericTarget ? genericContainers.story.key : params.task.storyKey ?? genericContainers?.story.key ?? "US-AUTO";
1028
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;
1029
1212
  const taskKey = await ensureKey(storyId, storyKey);
1030
1213
  inserts.push({
1031
1214
  projectId: params.task.projectId,
@@ -1036,7 +1219,7 @@ export class CodeReviewService {
1036
1219
  description: this.buildFollowupDescription(params.task, finding, params.decision),
1037
1220
  type: finding.type ?? (params.decision === "changes_requested" || params.decision === "block" ? "bug" : "issue"),
1038
1221
  status: "not_started",
1039
- storyPoints: null,
1222
+ storyPoints: boundedPoints,
1040
1223
  priority: this.severityToPriority(finding.severity),
1041
1224
  metadata: {
1042
1225
  source: "code-review",
@@ -1052,6 +1235,7 @@ export class CodeReviewService {
1052
1235
  suggestedFix: finding.suggestedFix,
1053
1236
  generic: genericTarget ? true : false,
1054
1237
  decision: params.decision,
1238
+ complexity: boundedPoints,
1055
1239
  },
1056
1240
  });
1057
1241
  }
@@ -1074,7 +1258,14 @@ export class CodeReviewService {
1074
1258
  await this.ensureMcoda();
1075
1259
  const agentStream = request.agentStream !== false;
1076
1260
  const baseRef = request.baseRef ?? this.workspace.config?.branch ?? DEFAULT_BASE_BRANCH;
1077
- 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);
1078
1269
  let state;
1079
1270
  const commandRun = await this.deps.jobService.startCommandRun("code-review", request.projectKey, {
1080
1271
  taskIds: request.taskKeys,
@@ -1084,6 +1275,10 @@ export class CodeReviewService {
1084
1275
  let jobId = request.resumeJobId;
1085
1276
  let selectedTaskIds = [];
1086
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;
1087
1282
  let selectedTasks = [];
1088
1283
  if (request.resumeJobId) {
1089
1284
  const job = await this.deps.jobService.getJob(request.resumeJobId);
@@ -1092,6 +1287,9 @@ export class CodeReviewService {
1092
1287
  if ((job.commandName ?? job.type) !== "code-review" && job.type !== "review") {
1093
1288
  throw new Error(`Job ${request.resumeJobId} is not a code-review job`);
1094
1289
  }
1290
+ if (request.createFollowupTasks === undefined) {
1291
+ allowFollowups = Boolean(job.payload?.createFollowupTasks);
1292
+ }
1095
1293
  state = await this.loadState(request.resumeJobId);
1096
1294
  selectedTaskIds = state?.selectedTaskIds ?? (Array.isArray(job.payload?.selection) ? job.payload.selection : []);
1097
1295
  if (!selectedTaskIds.length) {
@@ -1128,7 +1326,7 @@ export class CodeReviewService {
1128
1326
  epicKey: request.epicKey,
1129
1327
  storyKey: request.storyKey,
1130
1328
  taskKeys: request.taskKeys,
1131
- statusFilter,
1329
+ statusFilter: ignoreStatusFilter ? undefined : statusFilter,
1132
1330
  limit: request.limit,
1133
1331
  });
1134
1332
  }
@@ -1138,10 +1336,11 @@ export class CodeReviewService {
1138
1336
  epicKey: request.epicKey,
1139
1337
  storyKey: request.storyKey,
1140
1338
  taskKeys: request.taskKeys,
1141
- statusFilter,
1339
+ statusFilter: ignoreStatusFilter ? undefined : statusFilter,
1142
1340
  limit: request.limit,
1341
+ ignoreStatusFilter,
1143
1342
  });
1144
- warnings = [...selection.warnings];
1343
+ warnings = [...warnings, ...selection.warnings];
1145
1344
  selectedTasks = selection.ordered.map((t) => t.task);
1146
1345
  }
1147
1346
  selectedTaskIds = selectedTasks.map((t) => t.id);
@@ -1152,12 +1351,13 @@ export class CodeReviewService {
1152
1351
  epicKey: request.epicKey,
1153
1352
  storyKey: request.storyKey,
1154
1353
  tasks: request.taskKeys,
1155
- statusFilter,
1354
+ statusFilter: ignoreStatusFilter ? [] : statusFilter,
1156
1355
  baseRef,
1157
1356
  selection: selectedTaskIds,
1158
1357
  dryRun: request.dryRun ?? false,
1159
1358
  agent: request.agentName,
1160
1359
  agentStream,
1360
+ createFollowupTasks: allowFollowups,
1161
1361
  },
1162
1362
  totalItems: selectedTaskIds.length,
1163
1363
  processedItems: 0,
@@ -1194,10 +1394,38 @@ export class CodeReviewService {
1194
1394
  const tasks = selectedTasks.length && selectedTaskIds.length === selectedTasks.length
1195
1395
  ? selectedTasks
1196
1396
  : await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
1197
- 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
+ }
1198
1426
  const prompts = await this.loadPrompts(agent.id);
1199
1427
  const extras = await this.loadRunbookAndChecklists();
1200
- const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
1428
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
1201
1429
  if (projectGuidance) {
1202
1430
  console.info(`[code-review] loaded project guidance from ${projectGuidance.source}`);
1203
1431
  }
@@ -1232,6 +1460,78 @@ export class CodeReviewService {
1232
1460
  });
1233
1461
  };
1234
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
+ };
1235
1535
  const maybeRateTask = async (task, taskRunId, tokensTotal) => {
1236
1536
  if (!request.rateAgents || tokensTotal <= 0)
1237
1537
  return;
@@ -1245,7 +1545,7 @@ export class CodeReviewService {
1245
1545
  commandRunId: commandRun.id,
1246
1546
  taskId: task.id,
1247
1547
  taskKey: task.key,
1248
- discipline: task.type ?? undefined,
1548
+ discipline: task.type ?? "review",
1249
1549
  complexity: this.resolveTaskComplexity(task),
1250
1550
  });
1251
1551
  }
@@ -1266,8 +1566,36 @@ export class CodeReviewService {
1266
1566
  }
1267
1567
  }
1268
1568
  };
1569
+ let abortRemainingReason = null;
1269
1570
  for (const task of tasks) {
1571
+ if (abortRemainingReason)
1572
+ break;
1270
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
+ };
1271
1599
  const statusBefore = task.status;
1272
1600
  const taskRun = await this.deps.workspaceRepo.createTaskRun({
1273
1601
  taskId: task.id,
@@ -1276,21 +1604,42 @@ export class CodeReviewService {
1276
1604
  commandRunId: commandRun.id,
1277
1605
  agentId: agent.id,
1278
1606
  status: "running",
1279
- startedAt: new Date().toISOString(),
1607
+ startedAt,
1280
1608
  storyPointsAtRun: task.storyPoints ?? null,
1281
1609
  gitBranch: task.vcsBranch ?? null,
1282
1610
  gitBaseBranch: task.vcsBaseBranch ?? null,
1283
1611
  gitCommitSha: task.vcsLastCommitSha ?? null,
1284
1612
  });
1613
+ const statusContext = {
1614
+ commandName: "code-review",
1615
+ jobId,
1616
+ taskRunId: taskRun.id,
1617
+ agentId: agent.id,
1618
+ metadata: { lane: "review" },
1619
+ };
1285
1620
  const findings = [];
1286
1621
  let decision;
1287
1622
  let statusAfter;
1623
+ let reviewErrorCode;
1288
1624
  const followupCreated = [];
1289
1625
  let commentResolution;
1290
1626
  // Debug visibility: show prompts/task details for this run
1291
1627
  const systemPrompt = systemPrompts.join("\n\n");
1292
1628
  let tokensTotal = 0;
1629
+ let agentOutput = "";
1293
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
+ });
1294
1643
  const metadata = task.metadata ?? {};
1295
1644
  const allowedFiles = Array.isArray(metadata.files) ? metadata.files : [];
1296
1645
  const diffResult = await this.buildDiff(task, state?.baseRef ?? baseRef, allowedFiles);
@@ -1312,9 +1661,10 @@ export class CodeReviewService {
1312
1661
  allowedFiles,
1313
1662
  },
1314
1663
  });
1315
- if (!diff.trim()) {
1316
- const message = "Review diff is empty; blocking review until changes are produced.";
1317
- warnings.push(`Empty diff for ${task.key}; blocking review.`);
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);
1318
1668
  await this.deps.workspaceRepo.insertTaskLog({
1319
1669
  taskRunId: taskRun.id,
1320
1670
  sequence: this.sequenceForTask(taskRun.id),
@@ -1322,43 +1672,6 @@ export class CodeReviewService {
1322
1672
  source: "review_warning",
1323
1673
  message,
1324
1674
  });
1325
- if (!request.dryRun) {
1326
- await this.stateService.markBlocked(task, "review_empty_diff");
1327
- statusAfter = "blocked";
1328
- }
1329
- await this.writeReviewSummaryComment({
1330
- task,
1331
- taskRunId: taskRun.id,
1332
- jobId,
1333
- agentId: agent.id,
1334
- statusBefore,
1335
- statusAfter: statusAfter ?? statusBefore,
1336
- decision: "block",
1337
- summary: message,
1338
- findingsCount: 0,
1339
- });
1340
- await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
1341
- status: "failed",
1342
- finishedAt: new Date().toISOString(),
1343
- runContext: { decision: "block", reason: "empty_diff" },
1344
- });
1345
- state?.reviewed.push({ taskId: task.id, decision: "block" });
1346
- await this.persistState(jobId, state);
1347
- await this.writeCheckpoint(jobId, "review_applied", { reviewed: state?.reviewed ?? [], schema_version: 1 });
1348
- results.push({
1349
- taskId: task.id,
1350
- taskKey: task.key,
1351
- statusBefore,
1352
- statusAfter: statusAfter ?? statusBefore,
1353
- decision: "block",
1354
- findings,
1355
- followupTasks: followupCreated,
1356
- });
1357
- await this.deps.jobService.updateJobStatus(jobId, "running", {
1358
- processedItems: state?.reviewed.length ?? 0,
1359
- });
1360
- await maybeRateTask(task, taskRun.id, tokensTotal);
1361
- continue;
1362
1675
  }
1363
1676
  const historySummary = await this.buildHistorySummary(task.id);
1364
1677
  const commentContext = await this.loadCommentContext(task.id);
@@ -1371,7 +1684,8 @@ export class CodeReviewService {
1371
1684
  message: "Loaded task history",
1372
1685
  });
1373
1686
  const changedPaths = this.extractPathsFromDiff(diff);
1374
- 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 : []);
1375
1689
  if (docLinks.warnings.length)
1376
1690
  warnings.push(...docLinks.warnings);
1377
1691
  await this.deps.workspaceRepo.insertTaskLog({
@@ -1397,6 +1711,7 @@ export class CodeReviewService {
1397
1711
  systemPrompts,
1398
1712
  task,
1399
1713
  diff,
1714
+ diffEmpty,
1400
1715
  docContext: docLinks.snippets,
1401
1716
  openapiSnippet,
1402
1717
  historySummary,
@@ -1404,6 +1719,7 @@ export class CodeReviewService {
1404
1719
  baseRef: state?.baseRef ?? baseRef,
1405
1720
  branch: task.vcsBranch ?? undefined,
1406
1721
  });
1722
+ const requireCommentSlugs = Boolean(commentBacklog.trim());
1407
1723
  const separator = "============================================================";
1408
1724
  const deps = Array.isArray(task.dependencyKeys) && task.dependencyKeys.length
1409
1725
  ? task.dependencyKeys
@@ -1411,7 +1727,6 @@ export class CodeReviewService {
1411
1727
  ? task.metadata.depends_on
1412
1728
  : [];
1413
1729
  console.info(separator);
1414
- console.info("[code-review] START OF TASK");
1415
1730
  console.info(`[code-review] Task key: ${task.key}`);
1416
1731
  console.info(`[code-review] Title: ${task.title ?? "(none)"}`);
1417
1732
  console.info(`[code-review] Description: ${task.description ?? "(none)"}`);
@@ -1429,19 +1744,20 @@ export class CodeReviewService {
1429
1744
  docdex: docLinks.snippets,
1430
1745
  openapiSnippet,
1431
1746
  changedPaths,
1747
+ diffEmpty,
1432
1748
  });
1433
1749
  state?.contextBuilt.push(task.id);
1434
1750
  await this.persistState(jobId, state);
1435
1751
  await this.writeCheckpoint(jobId, "context_built", { contextBuilt: state?.contextBuilt ?? [], schema_version: 1 });
1436
- const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta) => {
1752
+ const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta, agentUsed = agent, attempt = 1) => {
1437
1753
  const tokensPrompt = tokenMeta?.tokensPrompt ?? estimateTokens(promptText);
1438
1754
  const tokensCompletion = tokenMeta?.tokensCompletion ?? estimateTokens(outputText);
1439
1755
  const entryTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
1440
1756
  tokensTotal += entryTotal;
1441
1757
  await this.deps.jobService.recordTokenUsage({
1442
1758
  workspaceId: this.workspace.workspaceId,
1443
- agentId: agent.id,
1444
- modelName: tokenMeta?.model ?? agent.defaultModel ?? undefined,
1759
+ agentId: agentUsed.id,
1760
+ modelName: tokenMeta?.model ?? agentUsed.defaultModel ?? undefined,
1445
1761
  jobId,
1446
1762
  commandRunId: commandRun.id,
1447
1763
  taskRunId: taskRun.id,
@@ -1452,45 +1768,82 @@ export class CodeReviewService {
1452
1768
  tokensTotal: entryTotal,
1453
1769
  durationSeconds,
1454
1770
  timestamp: new Date().toISOString(),
1455
- metadata: { commandName: "code-review", phase, action: phase },
1771
+ metadata: { commandName: "code-review", phase, action: phase, attempt },
1456
1772
  });
1457
1773
  };
1458
- let agentOutput = "";
1774
+ agentOutput = "";
1459
1775
  let durationSeconds = 0;
1460
- const started = Date.now();
1461
1776
  let lastStreamMeta;
1462
- if (agentStream && this.deps.agentService.invokeStream) {
1463
- const stream = await withAbort(this.deps.agentService.invokeStream(agent.id, { input: prompt, metadata: { taskKey: task.key } }));
1464
- while (true) {
1465
- abortIfSignaled();
1466
- const { value, done } = await withAbort(stream.next());
1467
- if (done)
1468
- break;
1469
- const chunk = value;
1470
- agentOutput += chunk.output ?? "";
1471
- 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;
1472
1812
  await this.deps.workspaceRepo.insertTaskLog({
1473
1813
  taskRunId: taskRun.id,
1474
1814
  sequence: this.sequenceForTask(taskRun.id),
1475
1815
  timestamp: new Date().toISOString(),
1476
- source: "agent",
1477
- message: chunk.output ?? "",
1816
+ source: logSource,
1817
+ message: output,
1478
1818
  });
1479
1819
  }
1480
- 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;
1481
1828
  }
1482
- else {
1483
- const response = await withAbort(this.deps.agentService.invoke(agent.id, { input: prompt, metadata: { taskKey: task.key } }));
1484
- agentOutput = response.output ?? "";
1485
- 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;
1486
1836
  await this.deps.workspaceRepo.insertTaskLog({
1487
1837
  taskRunId: taskRun.id,
1488
1838
  sequence: this.sequenceForTask(taskRun.id),
1489
1839
  timestamp: new Date().toISOString(),
1490
- source: "agent",
1491
- message: agentOutput,
1840
+ source: "agent_retry",
1841
+ message: `Transient agent error (${message}); retrying once with ${agentUsedForOutput.slug ?? agentUsedForOutput.id}.`,
1492
1842
  });
1493
- lastStreamMeta = response.metadata;
1843
+ const invocation = await invokeReviewAgent(agentUsedForOutput, false, "agent_retry");
1844
+ agentOutput = invocation.output;
1845
+ durationSeconds = invocation.durationSeconds;
1846
+ lastStreamMeta = invocation.metadata;
1494
1847
  }
1495
1848
  const tokenMetaMain = lastStreamMeta
1496
1849
  ? {
@@ -1502,21 +1855,60 @@ export class CodeReviewService {
1502
1855
  model: (lastStreamMeta.model ?? lastStreamMeta.model_name ?? null),
1503
1856
  }
1504
1857
  : undefined;
1505
- await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain);
1506
- let parsed = parseJsonOutput(agentOutput);
1507
- let invalidJson = false;
1508
- 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.";
1509
1882
  await this.deps.workspaceRepo.insertTaskLog({
1510
1883
  taskRunId: taskRun.id,
1511
1884
  sequence: this.sequenceForTask(taskRun.id),
1512
1885
  timestamp: new Date().toISOString(),
1513
1886
  source: "agent",
1514
- message: "Invalid JSON from agent; retrying once with stricter instructions.",
1887
+ message: retryReason,
1515
1888
  });
1516
- 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}`;
1517
1899
  const retryStarted = Date.now();
1518
- const retryResp = await withAbort(this.deps.agentService.invoke(agent.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } }));
1519
- 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 ?? "";
1520
1912
  const retryDuration = Math.round(((Date.now() - retryStarted) / 1000) * 1000) / 1000;
1521
1913
  await this.deps.workspaceRepo.insertTaskLog({
1522
1914
  taskRunId: taskRun.id,
@@ -1539,14 +1931,27 @@ export class CodeReviewService {
1539
1931
  model: (retryResp.metadata.model ?? retryResp.metadata.model_name ?? null),
1540
1932
  }
1541
1933
  : undefined;
1542
- await recordUsage("review_retry", retryPrompt, retryOutput, retryDuration, retryTokenMeta);
1543
- 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
+ }
1544
1950
  agentOutput = retryOutput;
1545
1951
  }
1546
- if (!parsed) {
1547
- invalidJson = true;
1548
- const fallbackSummary = "Review agent returned non-JSON output after retry; block review and re-run with a stricter JSON-only model.";
1549
- warnings.push(`Review agent returned non-JSON output for ${task.key}; blocking review.`);
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.`);
1550
1955
  await this.deps.workspaceRepo.insertTaskLog({
1551
1956
  taskRunId: taskRun.id,
1552
1957
  sequence: this.sequenceForTask(taskRun.id),
@@ -1555,17 +1960,60 @@ export class CodeReviewService {
1555
1960
  message: fallbackSummary,
1556
1961
  });
1557
1962
  parsed = {
1558
- decision: "block",
1963
+ decision: "info_only",
1559
1964
  summary: fallbackSummary,
1560
1965
  findings: [],
1561
1966
  testRecommendations: [],
1562
- raw: agentOutput,
1967
+ raw: retryOutput ?? agentOutput,
1563
1968
  };
1969
+ normalization = { parsedFromJson: false, usedFallback: true, issues: ["validation_error"], result: parsed };
1564
1970
  }
1565
- parsed.raw = agentOutput;
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
+ }
1997
+ }
1998
+ parsed.raw = parsed.raw ?? agentOutput;
1566
1999
  const originalDecision = parsed.decision;
1567
2000
  decision = parsed.decision;
1568
2001
  findings.push(...(parsed.findings ?? []));
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
+ }
1569
2017
  commentResolution = await this.applyCommentResolutions({
1570
2018
  task,
1571
2019
  taskRunId: taskRun.id,
@@ -1574,10 +2022,9 @@ export class CodeReviewService {
1574
2022
  findings: parsed.findings ?? [],
1575
2023
  resolvedSlugs: parsed.resolvedSlugs ?? undefined,
1576
2024
  unresolvedSlugs: parsed.unresolvedSlugs ?? undefined,
1577
- decision: parsed.decision,
2025
+ decision: finalDecision,
1578
2026
  existingComments: commentContext.comments,
1579
2027
  });
1580
- let finalDecision = parsed.decision;
1581
2028
  if (commentResolution?.open?.length &&
1582
2029
  (finalDecision === "approve" || finalDecision === "info_only")) {
1583
2030
  const openSlugs = commentResolution.open;
@@ -1610,47 +2057,106 @@ export class CodeReviewService {
1610
2057
  createdAt: new Date().toISOString(),
1611
2058
  });
1612
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
+ }
1613
2117
  parsed.decision = finalDecision;
1614
2118
  decision = finalDecision;
1615
- const followups = await this.createFollowupTasksForFindings({
1616
- task,
1617
- findings: parsed.findings ?? [],
1618
- decision: originalDecision,
1619
- jobId,
1620
- commandRunId: commandRun.id,
1621
- taskRunId: taskRun.id,
1622
- });
1623
- if (followups.length) {
1624
- followupCreated.push(...followups.map((t) => ({
1625
- taskId: t.id,
1626
- taskKey: t.key,
1627
- epicId: t.epicId,
1628
- userStoryId: t.userStoryId,
1629
- generic: t?.metadata?.generic ? true : undefined,
1630
- })));
1631
- warnings.push(`Created follow-up tasks for ${task.key}: ${followups.map((t) => t.key).join(", ")}`);
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
+ }
1632
2138
  }
1633
2139
  let taskStatusUpdate = statusBefore;
1634
2140
  if (!request.dryRun) {
1635
- if (invalidJson) {
1636
- await this.stateService.markBlocked(task, "review_invalid_output");
1637
- taskStatusUpdate = "blocked";
1638
- }
1639
- else {
1640
- const approveDecision = parsed.decision === "approve" || parsed.decision === "info_only";
1641
- if (approveDecision) {
1642
- await this.stateService.markReadyToQa(task);
1643
- taskStatusUpdate = "ready_to_qa";
1644
- }
1645
- else if (parsed.decision === "changes_requested") {
1646
- await this.stateService.returnToInProgress(task);
1647
- taskStatusUpdate = "in_progress";
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";
1648
2146
  }
1649
- else if (parsed.decision === "block") {
1650
- await this.stateService.markBlocked(task, "review_blocked");
1651
- taskStatusUpdate = "blocked";
2147
+ else {
2148
+ await this.stateService.markReadyToQa(task, undefined, statusContext);
2149
+ taskStatusUpdate = "ready_to_qa";
1652
2150
  }
1653
2151
  }
2152
+ else if (parsed.decision === "changes_requested") {
2153
+ await this.stateService.markChangesRequested(task, undefined, statusContext);
2154
+ taskStatusUpdate = "changes_requested";
2155
+ }
2156
+ else if (parsed.decision === "block") {
2157
+ await this.stateService.markFailed(task, "review_blocked", statusContext);
2158
+ taskStatusUpdate = "failed";
2159
+ }
1654
2160
  }
1655
2161
  else {
1656
2162
  await this.deps.workspaceRepo.insertTaskLog({
@@ -1678,7 +2184,7 @@ export class CodeReviewService {
1678
2184
  reopenedCount: commentResolution?.reopened.length,
1679
2185
  openCount: commentResolution?.open.length,
1680
2186
  });
1681
- await this.deps.workspaceRepo.createTaskReview({
2187
+ const review = await this.deps.workspaceRepo.createTaskReview({
1682
2188
  taskId: task.id,
1683
2189
  jobId,
1684
2190
  agentId: agent.id,
@@ -1687,6 +2193,7 @@ export class CodeReviewService {
1687
2193
  summary: parsed.summary ?? undefined,
1688
2194
  findingsJson: parsed.findings ?? [],
1689
2195
  testRecommendationsJson: parsed.testRecommendations ?? [],
2196
+ metadata: diffMeta,
1690
2197
  createdAt: new Date().toISOString(),
1691
2198
  });
1692
2199
  await this.stateService.recordReviewMetadata(task, {
@@ -1694,6 +2201,9 @@ export class CodeReviewService {
1694
2201
  agentId: agent.id,
1695
2202
  modelName: agent.defaultModel ?? null,
1696
2203
  jobId,
2204
+ reviewId: review.id,
2205
+ diffEmpty,
2206
+ changedPaths,
1697
2207
  });
1698
2208
  await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
1699
2209
  status: "succeeded",
@@ -1745,7 +2255,18 @@ export class CodeReviewService {
1745
2255
  await this.deps.jobService.updateJobStatus(jobId, "running", {
1746
2256
  processedItems: state?.reviewed.length ?? 0,
1747
2257
  });
2258
+ emitReviewEndOnce({
2259
+ statusLabel: "FAILED",
2260
+ decision: "error",
2261
+ findingsCount: findings.length,
2262
+ tokensTotal,
2263
+ });
1748
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
+ }
1749
2270
  continue;
1750
2271
  }
1751
2272
  results.push({
@@ -1755,13 +2276,38 @@ export class CodeReviewService {
1755
2276
  statusAfter,
1756
2277
  decision,
1757
2278
  findings,
2279
+ error: reviewErrorCode,
1758
2280
  followupTasks: followupCreated,
1759
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
+ });
1760
2297
  await this.deps.jobService.updateJobStatus(jobId, "running", {
1761
2298
  processedItems: state?.reviewed.length ?? 0,
1762
2299
  });
1763
2300
  await maybeRateTask(task, taskRun.id, tokensTotal);
1764
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 };
2310
+ }
1765
2311
  await this.deps.jobService.updateJobStatus(jobId, "completed", {
1766
2312
  processedItems: state?.reviewed.length ?? selectedTaskIds.length,
1767
2313
  totalItems: selectedTaskIds.length,