@mcoda/core 0.1.7 → 0.1.9

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 (71) hide show
  1. package/CHANGELOG.md +4 -1
  2. package/README.md +22 -3
  3. package/dist/api/AgentsApi.d.ts +8 -1
  4. package/dist/api/AgentsApi.d.ts.map +1 -1
  5. package/dist/api/AgentsApi.js +70 -0
  6. package/dist/api/QaTasksApi.d.ts.map +1 -1
  7. package/dist/api/QaTasksApi.js +2 -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 +3 -1
  15. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  16. package/dist/prompts/SdsPrompts.js +2 -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 +41 -0
  21. package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
  22. package/dist/services/agents/AgentRatingService.js +299 -0
  23. package/dist/services/agents/GatewayAgentService.d.ts +3 -0
  24. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  25. package/dist/services/agents/GatewayAgentService.js +68 -24
  26. package/dist/services/agents/GatewayHandoff.d.ts +7 -0
  27. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
  28. package/dist/services/agents/GatewayHandoff.js +108 -0
  29. package/dist/services/backlog/TaskOrderingService.d.ts +1 -0
  30. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  31. package/dist/services/backlog/TaskOrderingService.js +19 -16
  32. package/dist/services/docs/DocsService.d.ts +11 -1
  33. package/dist/services/docs/DocsService.d.ts.map +1 -1
  34. package/dist/services/docs/DocsService.js +240 -52
  35. package/dist/services/execution/GatewayTrioService.d.ts +133 -0
  36. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
  37. package/dist/services/execution/GatewayTrioService.js +1125 -0
  38. package/dist/services/execution/QaFollowupService.d.ts +1 -0
  39. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  40. package/dist/services/execution/QaFollowupService.js +1 -0
  41. package/dist/services/execution/QaProfileService.d.ts +6 -0
  42. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  43. package/dist/services/execution/QaProfileService.js +165 -3
  44. package/dist/services/execution/QaTasksService.d.ts +18 -0
  45. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  46. package/dist/services/execution/QaTasksService.js +712 -34
  47. package/dist/services/execution/WorkOnTasksService.d.ts +14 -0
  48. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  49. package/dist/services/execution/WorkOnTasksService.js +1497 -240
  50. package/dist/services/openapi/OpenApiService.d.ts +10 -0
  51. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  52. package/dist/services/openapi/OpenApiService.js +66 -10
  53. package/dist/services/planning/CreateTasksService.d.ts +6 -0
  54. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  55. package/dist/services/planning/CreateTasksService.js +261 -28
  56. package/dist/services/planning/RefineTasksService.d.ts +5 -0
  57. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  58. package/dist/services/planning/RefineTasksService.js +184 -35
  59. package/dist/services/review/CodeReviewService.d.ts +14 -0
  60. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  61. package/dist/services/review/CodeReviewService.js +657 -61
  62. package/dist/services/shared/ProjectGuidance.d.ts +6 -0
  63. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
  64. package/dist/services/shared/ProjectGuidance.js +21 -0
  65. package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
  66. package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
  67. package/dist/services/tasks/TaskCommentFormatter.js +54 -0
  68. package/dist/workspace/WorkspaceManager.d.ts +4 -0
  69. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  70. package/dist/workspace/WorkspaceManager.js +3 -0
  71. package/package.json +5 -5
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
3
  import { AgentService } from "@mcoda/agents";
4
4
  import { DocdexClient, VcsClient } from "@mcoda/integrations";
5
- import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
5
+ import { GlobalRepository, WorkspaceRepository, } from "@mcoda/db";
6
6
  import { PathHelper } from "@mcoda/shared";
7
7
  import { JobService } from "../jobs/JobService.js";
8
8
  import { TaskSelectionService } from "../execution/TaskSelectionService.js";
@@ -11,9 +11,20 @@ import { BacklogService } from "../backlog/BacklogService.js";
11
11
  import yaml from "yaml";
12
12
  import { createTaskKeyGenerator } from "../planning/KeyHelpers.js";
13
13
  import { RoutingService } from "../agents/RoutingService.js";
14
+ import { AgentRatingService } from "../agents/AgentRatingService.js";
15
+ import { loadProjectGuidance } from "../shared/ProjectGuidance.js";
16
+ import { createTaskCommentSlug, formatTaskCommentBody } from "../tasks/TaskCommentFormatter.js";
14
17
  const DEFAULT_BASE_BRANCH = "mcoda-dev";
15
18
  const REVIEW_DIR = (workspaceRoot, jobId) => path.join(workspaceRoot, ".mcoda", "jobs", jobId, "review");
16
19
  const STATE_PATH = (workspaceRoot, jobId) => path.join(REVIEW_DIR(workspaceRoot, jobId), "state.json");
20
+ const REVIEW_PROMPT_LIMITS = {
21
+ diff: 12000,
22
+ history: 3000,
23
+ docContext: 4000,
24
+ openapi: 8000,
25
+ checklist: 3000,
26
+ };
27
+ const DOCDEX_TIMEOUT_MS = 8000;
17
28
  const DEFAULT_CODE_REVIEW_PROMPT = [
18
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.",
19
30
  "Use docdex snippets to verify contracts (data shapes, offline scope, accessibility/perf guardrails, acceptance criteria). Call out mismatches, missing tests, and undocumented changes.",
@@ -21,22 +32,53 @@ const DEFAULT_CODE_REVIEW_PROMPT = [
21
32
  const DEFAULT_JOB_PROMPT = "You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.";
22
33
  const DEFAULT_CHARACTER_PROMPT = "Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.";
23
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)
39
+ return undefined;
40
+ return candidate.slice(start, end + 1);
41
+ };
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");
60
+ };
24
61
  const parseJsonOutput = (raw) => {
25
62
  const trimmed = raw.trim();
26
63
  const fenced = trimmed.replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
27
64
  const candidates = [trimmed, fenced];
28
65
  for (const candidate of candidates) {
29
- const start = candidate.indexOf("{");
30
- const end = candidate.lastIndexOf("}");
31
- if (start === -1 || end === -1 || end <= start)
66
+ const slice = extractJsonSlice(candidate);
67
+ if (!slice)
32
68
  continue;
33
- const slice = candidate.slice(start, end + 1);
34
69
  try {
35
70
  const parsed = JSON.parse(slice);
36
71
  return { ...parsed, raw: raw };
37
72
  }
38
73
  catch {
39
- /* ignore */
74
+ const sanitized = sanitizeJsonCandidate(slice);
75
+ try {
76
+ const parsed = JSON.parse(sanitized);
77
+ return { ...parsed, raw: raw };
78
+ }
79
+ catch {
80
+ /* ignore */
81
+ }
40
82
  }
41
83
  }
42
84
  return undefined;
@@ -51,6 +93,30 @@ const summarizeComments = (comments) => {
51
93
  })
52
94
  .join("\n");
53
95
  };
96
+ const truncateSection = (label, text, limit) => {
97
+ if (!text)
98
+ return text;
99
+ if (text.length <= limit)
100
+ return text;
101
+ const trimmed = text.slice(0, limit);
102
+ const remaining = text.length - limit;
103
+ return `${trimmed}\n...[truncated ${remaining} chars from ${label}]`;
104
+ };
105
+ const withTimeout = async (promise, ms, label) => {
106
+ let timeoutId;
107
+ const timeoutPromise = new Promise((_, reject) => {
108
+ timeoutId = setTimeout(() => {
109
+ reject(new Error(`${label} timed out after ${ms}ms`));
110
+ }, ms);
111
+ });
112
+ try {
113
+ return await Promise.race([promise, timeoutPromise]);
114
+ }
115
+ finally {
116
+ if (timeoutId)
117
+ clearTimeout(timeoutId);
118
+ }
119
+ };
54
120
  const JSON_CONTRACT = `{
55
121
  "decision": "approve | changes_requested | block | info_only",
56
122
  "summary": "short textual summary",
@@ -64,28 +130,116 @@ const JSON_CONTRACT = `{
64
130
  "suggestedFix": "Optional suggested change"
65
131
  }
66
132
  ],
67
- "testRecommendations": ["Optional test or QA recommendations per task"]
133
+ "testRecommendations": ["Optional test or QA recommendations per task"],
134
+ "resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],
135
+ "unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]
68
136
  }`;
69
137
  const normalizeSingleLine = (value, fallback) => {
70
138
  const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
71
139
  return trimmed || fallback;
72
140
  };
141
+ const normalizeSlugList = (input) => {
142
+ if (!Array.isArray(input))
143
+ return [];
144
+ const cleaned = new Set();
145
+ for (const slug of input) {
146
+ if (typeof slug !== "string")
147
+ continue;
148
+ const trimmed = slug.trim();
149
+ if (trimmed)
150
+ cleaned.add(trimmed);
151
+ }
152
+ return Array.from(cleaned);
153
+ };
154
+ const normalizePath = (value) => value
155
+ .replace(/\\/g, "/")
156
+ .replace(/^\.\//, "")
157
+ .replace(/^\/+/, "");
158
+ const parseCommentBody = (body) => {
159
+ const trimmed = (body ?? "").trim();
160
+ if (!trimmed)
161
+ return { message: "(no details provided)" };
162
+ const lines = trimmed.split(/\r?\n/);
163
+ const normalize = (value) => value.trim().toLowerCase();
164
+ const messageIndex = lines.findIndex((line) => normalize(line) === "message:");
165
+ const suggestedIndex = lines.findIndex((line) => {
166
+ const normalized = normalize(line);
167
+ return normalized === "suggested_fix:" || normalized === "suggested fix:";
168
+ });
169
+ if (messageIndex >= 0) {
170
+ const messageLines = lines.slice(messageIndex + 1, suggestedIndex >= 0 ? suggestedIndex : undefined);
171
+ const message = messageLines.join("\n").trim();
172
+ const suggestedLines = suggestedIndex >= 0 ? lines.slice(suggestedIndex + 1) : [];
173
+ const suggestedFix = suggestedLines.join("\n").trim();
174
+ return { message: message || trimmed, suggestedFix: suggestedFix || undefined };
175
+ }
176
+ if (suggestedIndex >= 0) {
177
+ const message = lines.slice(0, suggestedIndex).join("\n").trim() || trimmed;
178
+ const inlineFix = lines[suggestedIndex]?.split(/suggested fix:/i)[1]?.trim();
179
+ const suggestedTail = lines.slice(suggestedIndex + 1).join("\n").trim();
180
+ const suggestedFix = inlineFix || suggestedTail || undefined;
181
+ return { message, suggestedFix };
182
+ }
183
+ return { message: trimmed };
184
+ };
185
+ const buildCommentBacklog = (comments) => {
186
+ if (!comments.length)
187
+ return "";
188
+ const seen = new Set();
189
+ const lines = [];
190
+ const toSingleLine = (value) => value.replace(/\s+/g, " ").trim();
191
+ for (const comment of comments) {
192
+ const slug = comment.slug?.trim() || undefined;
193
+ const details = parseCommentBody(comment.body);
194
+ const key = slug ??
195
+ `${comment.sourceCommand}:${comment.file ?? ""}:${comment.line ?? ""}:${details.message || comment.body}`;
196
+ if (seen.has(key))
197
+ continue;
198
+ seen.add(key);
199
+ const location = comment.file
200
+ ? `${comment.file}${typeof comment.line === "number" ? `:${comment.line}` : ""}`
201
+ : "(location not specified)";
202
+ const message = toSingleLine(details.message || comment.body || "(no details provided)");
203
+ lines.push(`- [${slug ?? "untracked"}] ${location} ${message}`);
204
+ const suggestedFix = comment.metadata?.suggestedFix ?? details.suggestedFix ?? undefined;
205
+ if (suggestedFix) {
206
+ lines.push(` Suggested fix: ${toSingleLine(suggestedFix)}`);
207
+ }
208
+ }
209
+ return lines.join("\n");
210
+ };
211
+ const formatSlugList = (slugs, limit = 12) => {
212
+ if (!slugs.length)
213
+ return "none";
214
+ if (slugs.length <= limit)
215
+ return slugs.join(", ");
216
+ return `${slugs.slice(0, limit).join(", ")} (+${slugs.length - limit} more)`;
217
+ };
73
218
  const buildStandardReviewComment = (params) => {
74
219
  const decision = params.decision ?? (params.error ? "error" : "info_only");
75
220
  const statusAfter = params.statusAfter ?? params.statusBefore;
76
221
  const summary = normalizeSingleLine(params.summary, params.error ? "Review failed." : "No summary provided.");
77
222
  const error = normalizeSingleLine(params.error, "none");
78
223
  const followups = params.followupTaskKeys && params.followupTaskKeys.length ? params.followupTaskKeys.join(", ") : "none";
79
- return [
224
+ const lines = [
80
225
  "[code-review]",
81
226
  `decision: ${decision}`,
82
227
  `status_before: ${params.statusBefore}`,
83
228
  `status_after: ${statusAfter}`,
84
229
  `findings: ${params.findingsCount}`,
85
230
  `summary: ${summary}`,
86
- `followups: ${followups}`,
87
- `error: ${error}`,
88
- ].join("\n");
231
+ ];
232
+ if (typeof params.resolvedCount === "number") {
233
+ lines.push(`resolved_slugs: ${params.resolvedCount}`);
234
+ }
235
+ if (typeof params.reopenedCount === "number") {
236
+ lines.push(`reopened_slugs: ${params.reopenedCount}`);
237
+ }
238
+ if (typeof params.openCount === "number") {
239
+ lines.push(`open_slugs: ${params.openCount}`);
240
+ }
241
+ lines.push(`followups: ${followups}`, `error: ${error}`);
242
+ return lines.join("\n");
89
243
  };
90
244
  export class CodeReviewService {
91
245
  constructor(workspace, deps) {
@@ -96,6 +250,7 @@ export class CodeReviewService {
96
250
  this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
97
251
  this.vcs = deps.vcsClient ?? new VcsClient();
98
252
  this.routingService = deps.routingService;
253
+ this.ratingService = deps.ratingService;
99
254
  }
100
255
  static async create(workspace) {
101
256
  const repo = await GlobalRepository.create();
@@ -242,6 +397,26 @@ export class CodeReviewService {
242
397
  });
243
398
  return resolved.agent;
244
399
  }
400
+ ensureRatingService() {
401
+ if (!this.ratingService) {
402
+ this.ratingService = new AgentRatingService(this.workspace, {
403
+ workspaceRepo: this.deps.workspaceRepo,
404
+ globalRepo: this.deps.repo,
405
+ agentService: this.deps.agentService,
406
+ routingService: this.routingService,
407
+ });
408
+ }
409
+ return this.ratingService;
410
+ }
411
+ resolveTaskComplexity(task) {
412
+ const metadata = task.metadata ?? {};
413
+ const metaComplexity = typeof metadata.complexity === "number" && Number.isFinite(metadata.complexity) ? metadata.complexity : undefined;
414
+ const storyPoints = typeof task.storyPoints === "number" && Number.isFinite(task.storyPoints) ? task.storyPoints : undefined;
415
+ const candidate = metaComplexity ?? storyPoints;
416
+ if (!Number.isFinite(candidate ?? NaN))
417
+ return undefined;
418
+ return Math.min(10, Math.max(1, Math.round(candidate)));
419
+ }
245
420
  async selectTasksViaApi(filters) {
246
421
  // Prefer the backlog/task OpenAPI surface (via BacklogService) to mirror API filtering semantics.
247
422
  const backlog = await BacklogService.create(this.workspace);
@@ -309,12 +484,13 @@ export class CodeReviewService {
309
484
  const snippets = [];
310
485
  const warnings = [];
311
486
  const queries = [...new Set([...(paths.length ? this.componentHintsFromPaths(paths) : []), taskTitle, ...(acceptance ?? [])])].slice(0, 8);
487
+ let reindexed = false;
312
488
  for (const query of queries) {
313
489
  try {
314
- const docs = await this.deps.docdex.search({
490
+ const docs = await withTimeout(this.deps.docdex.search({
315
491
  query,
316
492
  profile: "workspace-code",
317
- });
493
+ }), DOCDEX_TIMEOUT_MS, `docdex search for "${query}"`);
318
494
  snippets.push(...docs.slice(0, 2).map((doc) => {
319
495
  const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
320
496
  const ref = doc.path ?? doc.id ?? doc.title ?? query;
@@ -322,6 +498,26 @@ export class CodeReviewService {
322
498
  }));
323
499
  }
324
500
  catch (error) {
501
+ if (!reindexed && typeof this.deps.docdex.reindex === "function") {
502
+ reindexed = true;
503
+ try {
504
+ await this.deps.docdex.reindex();
505
+ const docs = await withTimeout(this.deps.docdex.search({
506
+ query,
507
+ profile: "workspace-code",
508
+ }), DOCDEX_TIMEOUT_MS, `docdex search for "${query}" after reindex`);
509
+ snippets.push(...docs.slice(0, 2).map((doc) => {
510
+ const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
511
+ const ref = doc.path ?? doc.id ?? doc.title ?? query;
512
+ return `- [${doc.docType ?? "doc"}] ${ref}: ${content}`;
513
+ }));
514
+ continue;
515
+ }
516
+ catch (retryError) {
517
+ warnings.push(`docdex search failed after reindex for ${query}: ${retryError.message}`);
518
+ continue;
519
+ }
520
+ }
325
521
  warnings.push(`docdex search failed for ${query}: ${error.message}`);
326
522
  }
327
523
  }
@@ -333,6 +529,16 @@ export class CodeReviewService {
333
529
  parts.push(params.systemPrompts.join("\n\n"));
334
530
  }
335
531
  const acceptance = params.task.acceptanceCriteria && params.task.acceptanceCriteria.length ? params.task.acceptanceCriteria.join(" | ") : "none provided";
532
+ const historySummary = truncateSection("history", params.historySummary, REVIEW_PROMPT_LIMITS.history);
533
+ const commentBacklog = params.commentBacklog
534
+ ? truncateSection("comment backlog", params.commentBacklog, REVIEW_PROMPT_LIMITS.history)
535
+ : "";
536
+ const docContextText = params.docContext.length ? truncateSection("doc context", params.docContext.join("\n"), REVIEW_PROMPT_LIMITS.docContext) : "";
537
+ const openapiSnippet = params.openapiSnippet ? truncateSection("openapi", params.openapiSnippet, REVIEW_PROMPT_LIMITS.openapi) : undefined;
538
+ const checklistsText = params.checklists?.length
539
+ ? truncateSection("checklists", params.checklists.join("\n\n"), REVIEW_PROMPT_LIMITS.checklist)
540
+ : "";
541
+ const diffText = truncateSection("diff", params.diff || "(no diff)", REVIEW_PROMPT_LIMITS.diff);
336
542
  parts.push([
337
543
  `Task ${params.task.key}: ${params.task.title}`,
338
544
  `Epic: ${params.task.epicKey ?? ""} ${params.task.epicTitle ?? ""}`.trim(),
@@ -341,16 +547,17 @@ export class CodeReviewService {
341
547
  `Story description: ${params.task.storyDescription ? params.task.storyDescription : "none"}`,
342
548
  `Status: ${params.task.status}, Branch: ${params.branch ?? params.task.vcsBranch ?? "n/a"} (base ${params.baseRef})`,
343
549
  `Task description: ${params.task.description ? params.task.description : "none"}`,
344
- `History:\n${params.historySummary}`,
550
+ `History:\n${historySummary}`,
551
+ commentBacklog ? `Comment backlog (unresolved slugs):\n${commentBacklog}` : "Comment backlog: none",
345
552
  `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}`
553
+ docContextText ? `Doc context (docdex excerpts):\n${docContextText}` : "Doc context: none",
554
+ openapiSnippet
555
+ ? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${openapiSnippet}`
349
556
  : "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)"),
557
+ checklistsText ? `Review checklists/runbook:\n${checklistsText}` : "Checklists: none",
558
+ "Diff:\n" + diffText,
352
559
  "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.",
560
+ "Rules: honor OpenAPI contracts; cite doc context where relevant; include resolvedSlugs/unresolvedSlugs for comment backlog items; do not add prose outside JSON.",
354
561
  ].join("\n"));
355
562
  return parts.join("\n\n");
356
563
  }
@@ -381,6 +588,190 @@ export class CodeReviewService {
381
588
  return "No prior review or QA history.";
382
589
  return parts.join("\n");
383
590
  }
591
+ async loadCommentContext(taskId) {
592
+ const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
593
+ sourceCommands: ["code-review", "qa-tasks"],
594
+ limit: 50,
595
+ });
596
+ const unresolved = comments.filter((comment) => !comment.resolvedAt);
597
+ return { comments, unresolved };
598
+ }
599
+ commentSlugKey(file, line, category) {
600
+ if (!file)
601
+ return undefined;
602
+ const normalizedFile = normalizePath(file);
603
+ const linePart = typeof line === "number" ? String(line) : "";
604
+ const categoryPart = category?.toLowerCase() ?? "";
605
+ return `${normalizedFile}|${linePart}|${categoryPart}`;
606
+ }
607
+ buildCommentSlugIndex(comments) {
608
+ const index = new Map();
609
+ for (const comment of comments) {
610
+ if (!comment.slug)
611
+ continue;
612
+ const key = this.commentSlugKey(comment.file, comment.line, comment.category);
613
+ if (!key)
614
+ continue;
615
+ if (!index.has(key))
616
+ index.set(key, comment.slug);
617
+ }
618
+ return index;
619
+ }
620
+ resolveFindingSlug(finding, slugIndex) {
621
+ const key = this.commentSlugKey(finding.file, finding.line, finding.type ?? null);
622
+ const existing = key ? slugIndex.get(key) : undefined;
623
+ if (existing)
624
+ return existing;
625
+ const message = (finding.message ?? "").trim() || "Review finding.";
626
+ return createTaskCommentSlug({
627
+ source: "code-review",
628
+ message,
629
+ file: finding.file,
630
+ line: finding.line,
631
+ category: finding.type ?? null,
632
+ });
633
+ }
634
+ async applyCommentResolutions(params) {
635
+ const existingBySlug = new Map();
636
+ const openBySlug = new Set();
637
+ const resolvedBySlug = new Set();
638
+ for (const comment of params.existingComments) {
639
+ if (!comment.slug)
640
+ continue;
641
+ if (!existingBySlug.has(comment.slug)) {
642
+ existingBySlug.set(comment.slug, comment);
643
+ }
644
+ if (comment.resolvedAt) {
645
+ resolvedBySlug.add(comment.slug);
646
+ }
647
+ else {
648
+ openBySlug.add(comment.slug);
649
+ }
650
+ }
651
+ const reviewSlugIndex = this.buildCommentSlugIndex(params.existingComments.filter((comment) => comment.sourceCommand === "code-review"));
652
+ const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined);
653
+ const resolvedSet = new Set(resolvedSlugs);
654
+ const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined));
655
+ const findingSlugs = [];
656
+ for (const finding of params.findings ?? []) {
657
+ const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
658
+ findingSlugs.push(slug);
659
+ const severity = (finding.severity ?? "").toLowerCase();
660
+ const autoResolve = (params.decision === "approve" || params.decision === "info_only") &&
661
+ ["info", "low"].includes(severity);
662
+ if (!resolvedSet.has(slug) && !autoResolve) {
663
+ unresolvedSet.add(slug);
664
+ }
665
+ }
666
+ for (const slug of resolvedSet) {
667
+ unresolvedSet.delete(slug);
668
+ }
669
+ const toResolve = resolvedSlugs.filter((slug) => openBySlug.has(slug));
670
+ const toReopen = Array.from(unresolvedSet).filter((slug) => resolvedBySlug.has(slug));
671
+ for (const slug of toResolve) {
672
+ await this.deps.workspaceRepo.resolveTaskComment({
673
+ taskId: params.task.id,
674
+ slug,
675
+ resolvedAt: new Date().toISOString(),
676
+ resolvedBy: params.agentId,
677
+ });
678
+ }
679
+ for (const slug of toReopen) {
680
+ await this.deps.workspaceRepo.reopenTaskComment({ taskId: params.task.id, slug });
681
+ }
682
+ const createdSlugs = new Set();
683
+ for (const finding of params.findings ?? []) {
684
+ const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
685
+ if (existingBySlug.has(slug) || createdSlugs.has(slug))
686
+ continue;
687
+ const severity = (finding.severity ?? "").toLowerCase();
688
+ const autoResolve = (params.decision === "approve" || params.decision === "info_only") &&
689
+ ["info", "low"].includes(severity);
690
+ const message = (finding.message ?? "").trim() || "(no details provided)";
691
+ const body = formatTaskCommentBody({
692
+ slug,
693
+ source: "code-review",
694
+ message,
695
+ status: autoResolve ? "resolved" : "open",
696
+ category: finding.type ?? "other",
697
+ file: finding.file ?? null,
698
+ line: finding.line ?? null,
699
+ suggestedFix: finding.suggestedFix ?? null,
700
+ });
701
+ const resolvedAt = autoResolve ? new Date().toISOString() : undefined;
702
+ await this.deps.workspaceRepo.createTaskComment({
703
+ taskId: params.task.id,
704
+ taskRunId: params.taskRunId,
705
+ jobId: params.jobId,
706
+ sourceCommand: "code-review",
707
+ authorType: "agent",
708
+ authorAgentId: params.agentId,
709
+ category: finding.type ?? "other",
710
+ slug,
711
+ status: autoResolve ? "resolved" : "open",
712
+ file: finding.file ?? null,
713
+ line: finding.line ?? null,
714
+ pathHint: finding.file ?? null,
715
+ body,
716
+ resolvedAt,
717
+ resolvedBy: autoResolve ? params.agentId : undefined,
718
+ metadata: {
719
+ severity: finding.severity,
720
+ suggestedFix: finding.suggestedFix,
721
+ },
722
+ createdAt: new Date().toISOString(),
723
+ });
724
+ createdSlugs.add(slug);
725
+ }
726
+ const openSet = new Set(openBySlug);
727
+ for (const slug of unresolvedSet) {
728
+ openSet.add(slug);
729
+ }
730
+ for (const slug of resolvedSet) {
731
+ openSet.delete(slug);
732
+ }
733
+ if (resolvedSlugs.length || toReopen.length || unresolvedSet.size) {
734
+ const resolutionMessage = [
735
+ `Resolved slugs: ${formatSlugList(toResolve)}`,
736
+ `Reopened slugs: ${formatSlugList(toReopen)}`,
737
+ `Open slugs: ${formatSlugList(Array.from(openSet))}`,
738
+ ].join("\n");
739
+ const resolutionSlug = createTaskCommentSlug({
740
+ source: "code-review",
741
+ message: resolutionMessage,
742
+ category: "comment_resolution",
743
+ });
744
+ const resolutionBody = formatTaskCommentBody({
745
+ slug: resolutionSlug,
746
+ source: "code-review",
747
+ message: resolutionMessage,
748
+ status: "resolved",
749
+ category: "comment_resolution",
750
+ });
751
+ const createdAt = new Date().toISOString();
752
+ await this.deps.workspaceRepo.createTaskComment({
753
+ taskId: params.task.id,
754
+ taskRunId: params.taskRunId,
755
+ jobId: params.jobId,
756
+ sourceCommand: "code-review",
757
+ authorType: "agent",
758
+ authorAgentId: params.agentId,
759
+ category: "comment_resolution",
760
+ slug: resolutionSlug,
761
+ status: "resolved",
762
+ body: resolutionBody,
763
+ createdAt,
764
+ resolvedAt: createdAt,
765
+ resolvedBy: params.agentId,
766
+ metadata: {
767
+ resolvedSlugs: toResolve,
768
+ reopenedSlugs: toReopen,
769
+ openSlugs: Array.from(openSet),
770
+ },
771
+ });
772
+ }
773
+ return { resolved: toResolve, reopened: toReopen, open: Array.from(openSet) };
774
+ }
384
775
  extractPathsFromDiff(diff) {
385
776
  const regex = /^(?:\+\+\+ b\/|\-\-\- a\/)([^\s]+)$/gm;
386
777
  const paths = new Set();
@@ -473,6 +864,9 @@ export class CodeReviewService {
473
864
  summary: params.summary,
474
865
  followupTaskKeys: params.followupTaskKeys,
475
866
  error: params.error,
867
+ resolvedCount: params.resolvedCount,
868
+ reopenedCount: params.reopenedCount,
869
+ openCount: params.openCount,
476
870
  });
477
871
  await this.deps.workspaceRepo.createTaskComment({
478
872
  taskId: params.task.id,
@@ -703,11 +1097,29 @@ export class CodeReviewService {
703
1097
  if (!selectedTaskIds.length) {
704
1098
  throw new Error("Resume requested but no task selection found in job payload");
705
1099
  }
1100
+ selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
1101
+ const terminalStatuses = new Set(["completed", "cancelled"]);
1102
+ const terminalTasks = selectedTasks.filter((task) => terminalStatuses.has((task.status ?? "").toLowerCase()));
1103
+ if (terminalTasks.length) {
1104
+ const terminalIds = new Set(terminalTasks.map((task) => task.id));
1105
+ const terminalKeys = terminalTasks.map((task) => task.key);
1106
+ warnings.push(`Skipping terminal tasks on resume: ${terminalKeys.join(", ")}`);
1107
+ selectedTasks = selectedTasks.filter((task) => !terminalIds.has(task.id));
1108
+ selectedTaskIds = selectedTaskIds.filter((id) => !terminalIds.has(id));
1109
+ if (state) {
1110
+ state.selectedTaskIds = selectedTaskIds;
1111
+ await this.persistState(job.id, state);
1112
+ }
1113
+ await this.writeCheckpoint(job.id, "resume_filtered", {
1114
+ skippedTaskKeys: terminalKeys,
1115
+ selectedTaskIds,
1116
+ schema_version: 1,
1117
+ });
1118
+ }
706
1119
  await this.deps.jobService.updateJobStatus(job.id, "running", {
707
- totalItems: job.totalItems ?? selectedTaskIds.length,
1120
+ totalItems: selectedTaskIds.length,
708
1121
  processedItems: state?.reviewed.length ?? 0,
709
1122
  });
710
- selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
711
1123
  }
712
1124
  else {
713
1125
  try {
@@ -785,9 +1197,77 @@ export class CodeReviewService {
785
1197
  const agent = await this.resolveAgent(request.agentName);
786
1198
  const prompts = await this.loadPrompts(agent.id);
787
1199
  const extras = await this.loadRunbookAndChecklists();
788
- const systemPrompts = [prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, ...extras].filter(Boolean);
1200
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
1201
+ if (projectGuidance) {
1202
+ console.info(`[code-review] loaded project guidance from ${projectGuidance.source}`);
1203
+ }
1204
+ const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
1205
+ const systemPrompts = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, ...extras].filter(Boolean);
1206
+ const abortSignal = request.abortSignal;
1207
+ const resolveAbortReason = () => {
1208
+ const reason = abortSignal?.reason;
1209
+ if (typeof reason === "string" && reason.trim().length > 0)
1210
+ return reason;
1211
+ if (reason instanceof Error && reason.message)
1212
+ return reason.message;
1213
+ return "code_review_aborted";
1214
+ };
1215
+ const abortIfSignaled = () => {
1216
+ if (abortSignal?.aborted) {
1217
+ throw new Error(resolveAbortReason());
1218
+ }
1219
+ };
1220
+ const withAbort = async (promise) => {
1221
+ if (!abortSignal)
1222
+ return promise;
1223
+ if (abortSignal.aborted) {
1224
+ throw new Error(resolveAbortReason());
1225
+ }
1226
+ return await new Promise((resolve, reject) => {
1227
+ const onAbort = () => reject(new Error(resolveAbortReason()));
1228
+ abortSignal.addEventListener("abort", onAbort, { once: true });
1229
+ promise.then(resolve, reject).finally(() => {
1230
+ abortSignal.removeEventListener("abort", onAbort);
1231
+ });
1232
+ });
1233
+ };
789
1234
  const results = [];
1235
+ const maybeRateTask = async (task, taskRunId, tokensTotal) => {
1236
+ if (!request.rateAgents || tokensTotal <= 0)
1237
+ return;
1238
+ try {
1239
+ const ratingService = this.ensureRatingService();
1240
+ await ratingService.rate({
1241
+ workspace: this.workspace,
1242
+ agentId: agent.id,
1243
+ commandName: "code-review",
1244
+ jobId,
1245
+ commandRunId: commandRun.id,
1246
+ taskId: task.id,
1247
+ taskKey: task.key,
1248
+ discipline: task.type ?? undefined,
1249
+ complexity: this.resolveTaskComplexity(task),
1250
+ });
1251
+ }
1252
+ catch (error) {
1253
+ const message = `Agent rating failed for ${task.key}: ${error instanceof Error ? error.message : String(error)}`;
1254
+ warnings.push(message);
1255
+ try {
1256
+ await this.deps.workspaceRepo.insertTaskLog({
1257
+ taskRunId,
1258
+ sequence: this.sequenceForTask(taskRunId),
1259
+ timestamp: new Date().toISOString(),
1260
+ source: "rating",
1261
+ message,
1262
+ });
1263
+ }
1264
+ catch {
1265
+ /* ignore rating log failures */
1266
+ }
1267
+ }
1268
+ };
790
1269
  for (const task of tasks) {
1270
+ abortIfSignaled();
791
1271
  const statusBefore = task.status;
792
1272
  const taskRun = await this.deps.workspaceRepo.createTaskRun({
793
1273
  taskId: task.id,
@@ -806,8 +1286,10 @@ export class CodeReviewService {
806
1286
  let decision;
807
1287
  let statusAfter;
808
1288
  const followupCreated = [];
1289
+ let commentResolution;
809
1290
  // Debug visibility: show prompts/task details for this run
810
1291
  const systemPrompt = systemPrompts.join("\n\n");
1292
+ let tokensTotal = 0;
811
1293
  try {
812
1294
  const metadata = task.metadata ?? {};
813
1295
  const allowedFiles = Array.isArray(metadata.files) ? metadata.files : [];
@@ -830,7 +1312,57 @@ export class CodeReviewService {
830
1312
  allowedFiles,
831
1313
  },
832
1314
  });
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.`);
1318
+ await this.deps.workspaceRepo.insertTaskLog({
1319
+ taskRunId: taskRun.id,
1320
+ sequence: this.sequenceForTask(taskRun.id),
1321
+ timestamp: new Date().toISOString(),
1322
+ source: "review_warning",
1323
+ message,
1324
+ });
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
+ }
833
1363
  const historySummary = await this.buildHistorySummary(task.id);
1364
+ const commentContext = await this.loadCommentContext(task.id);
1365
+ const commentBacklog = buildCommentBacklog(commentContext.unresolved);
834
1366
  await this.deps.workspaceRepo.insertTaskLog({
835
1367
  taskRunId: taskRun.id,
836
1368
  sequence: this.sequenceForTask(taskRun.id),
@@ -868,6 +1400,7 @@ export class CodeReviewService {
868
1400
  docContext: docLinks.snippets,
869
1401
  openapiSnippet,
870
1402
  historySummary,
1403
+ commentBacklog,
871
1404
  baseRef: state?.baseRef ?? baseRef,
872
1405
  branch: task.vcsBranch ?? undefined,
873
1406
  });
@@ -892,6 +1425,7 @@ export class CodeReviewService {
892
1425
  console.info(separator);
893
1426
  await this.persistContext(jobId, task.id, {
894
1427
  historySummary,
1428
+ commentBacklog,
895
1429
  docdex: docLinks.snippets,
896
1430
  openapiSnippet,
897
1431
  changedPaths,
@@ -902,7 +1436,8 @@ export class CodeReviewService {
902
1436
  const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta) => {
903
1437
  const tokensPrompt = tokenMeta?.tokensPrompt ?? estimateTokens(promptText);
904
1438
  const tokensCompletion = tokenMeta?.tokensCompletion ?? estimateTokens(outputText);
905
- const tokensTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
1439
+ const entryTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
1440
+ tokensTotal += entryTotal;
906
1441
  await this.deps.jobService.recordTokenUsage({
907
1442
  workspaceId: this.workspace.workspaceId,
908
1443
  agentId: agent.id,
@@ -914,7 +1449,7 @@ export class CodeReviewService {
914
1449
  projectId: task.projectId,
915
1450
  tokensPrompt,
916
1451
  tokensCompletion,
917
- tokensTotal,
1452
+ tokensTotal: entryTotal,
918
1453
  durationSeconds,
919
1454
  timestamp: new Date().toISOString(),
920
1455
  metadata: { commandName: "code-review", phase, action: phase },
@@ -925,8 +1460,13 @@ export class CodeReviewService {
925
1460
  const started = Date.now();
926
1461
  let lastStreamMeta;
927
1462
  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) {
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;
930
1470
  agentOutput += chunk.output ?? "";
931
1471
  lastStreamMeta = chunk.metadata ?? lastStreamMeta;
932
1472
  await this.deps.workspaceRepo.insertTaskLog({
@@ -940,7 +1480,7 @@ export class CodeReviewService {
940
1480
  durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
941
1481
  }
942
1482
  else {
943
- const response = await this.deps.agentService.invoke(agent.id, { input: prompt, metadata: { taskKey: task.key } });
1483
+ const response = await withAbort(this.deps.agentService.invoke(agent.id, { input: prompt, metadata: { taskKey: task.key } }));
944
1484
  agentOutput = response.output ?? "";
945
1485
  durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
946
1486
  await this.deps.workspaceRepo.insertTaskLog({
@@ -964,6 +1504,7 @@ export class CodeReviewService {
964
1504
  : undefined;
965
1505
  await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain);
966
1506
  let parsed = parseJsonOutput(agentOutput);
1507
+ let invalidJson = false;
967
1508
  if (!parsed) {
968
1509
  await this.deps.workspaceRepo.insertTaskLog({
969
1510
  taskRunId: taskRun.id,
@@ -974,7 +1515,7 @@ export class CodeReviewService {
974
1515
  });
975
1516
  const retryPrompt = `${prompt}\n\nRespond ONLY with valid JSON matching the schema above. Do not include prose or fences.`;
976
1517
  const retryStarted = Date.now();
977
- const retryResp = await this.deps.agentService.invoke(agent.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } });
1518
+ const retryResp = await withAbort(this.deps.agentService.invoke(agent.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } }));
978
1519
  const retryOutput = retryResp.output ?? "";
979
1520
  const retryDuration = Math.round(((Date.now() - retryStarted) / 1000) * 1000) / 1000;
980
1521
  await this.deps.workspaceRepo.insertTaskLog({
@@ -1003,15 +1544,78 @@ export class CodeReviewService {
1003
1544
  agentOutput = retryOutput;
1004
1545
  }
1005
1546
  if (!parsed) {
1006
- throw new Error("Agent output did not contain valid JSON review result after retry");
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.`);
1550
+ await this.deps.workspaceRepo.insertTaskLog({
1551
+ taskRunId: taskRun.id,
1552
+ sequence: this.sequenceForTask(taskRun.id),
1553
+ timestamp: new Date().toISOString(),
1554
+ source: "review_warning",
1555
+ message: fallbackSummary,
1556
+ });
1557
+ parsed = {
1558
+ decision: "block",
1559
+ summary: fallbackSummary,
1560
+ findings: [],
1561
+ testRecommendations: [],
1562
+ raw: agentOutput,
1563
+ };
1007
1564
  }
1008
1565
  parsed.raw = agentOutput;
1566
+ const originalDecision = parsed.decision;
1009
1567
  decision = parsed.decision;
1010
1568
  findings.push(...(parsed.findings ?? []));
1011
- const followups = await this.createFollowupTasksForFindings({
1569
+ commentResolution = await this.applyCommentResolutions({
1012
1570
  task,
1571
+ taskRunId: taskRun.id,
1572
+ jobId,
1573
+ agentId: agent.id,
1013
1574
  findings: parsed.findings ?? [],
1575
+ resolvedSlugs: parsed.resolvedSlugs ?? undefined,
1576
+ unresolvedSlugs: parsed.unresolvedSlugs ?? undefined,
1014
1577
  decision: parsed.decision,
1578
+ existingComments: commentContext.comments,
1579
+ });
1580
+ let finalDecision = parsed.decision;
1581
+ if (commentResolution?.open?.length &&
1582
+ (finalDecision === "approve" || finalDecision === "info_only")) {
1583
+ const openSlugs = commentResolution.open;
1584
+ finalDecision = "changes_requested";
1585
+ const message = `Unresolved comment slugs remain: ${formatSlugList(openSlugs)}. Review approval requires resolving these items.`;
1586
+ const backlogSlug = createTaskCommentSlug({
1587
+ source: "code-review",
1588
+ message,
1589
+ category: "comment_backlog",
1590
+ });
1591
+ const backlogBody = formatTaskCommentBody({
1592
+ slug: backlogSlug,
1593
+ source: "code-review",
1594
+ message,
1595
+ status: "open",
1596
+ category: "comment_backlog",
1597
+ });
1598
+ await this.deps.workspaceRepo.createTaskComment({
1599
+ taskId: task.id,
1600
+ taskRunId: taskRun.id,
1601
+ jobId,
1602
+ sourceCommand: "code-review",
1603
+ authorType: "agent",
1604
+ authorAgentId: agent.id,
1605
+ category: "comment_backlog",
1606
+ slug: backlogSlug,
1607
+ status: "open",
1608
+ body: backlogBody,
1609
+ metadata: { openSlugs },
1610
+ createdAt: new Date().toISOString(),
1611
+ });
1612
+ }
1613
+ parsed.decision = finalDecision;
1614
+ decision = finalDecision;
1615
+ const followups = await this.createFollowupTasksForFindings({
1616
+ task,
1617
+ findings: parsed.findings ?? [],
1618
+ decision: originalDecision,
1015
1619
  jobId,
1016
1620
  commandRunId: commandRun.id,
1017
1621
  taskRunId: taskRun.id,
@@ -1028,18 +1632,25 @@ export class CodeReviewService {
1028
1632
  }
1029
1633
  let taskStatusUpdate = statusBefore;
1030
1634
  if (!request.dryRun) {
1031
- if (parsed.decision === "approve") {
1032
- await this.stateService.markReadyToQa(task);
1033
- taskStatusUpdate = "ready_to_qa";
1034
- }
1035
- else if (parsed.decision === "changes_requested") {
1036
- await this.stateService.returnToInProgress(task);
1037
- taskStatusUpdate = "in_progress";
1038
- }
1039
- else if (parsed.decision === "block") {
1040
- await this.stateService.markBlocked(task, "review_blocked");
1635
+ if (invalidJson) {
1636
+ await this.stateService.markBlocked(task, "review_invalid_output");
1041
1637
  taskStatusUpdate = "blocked";
1042
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";
1648
+ }
1649
+ else if (parsed.decision === "block") {
1650
+ await this.stateService.markBlocked(task, "review_blocked");
1651
+ taskStatusUpdate = "blocked";
1652
+ }
1653
+ }
1043
1654
  }
1044
1655
  else {
1045
1656
  await this.deps.workspaceRepo.insertTaskLog({
@@ -1052,26 +1663,6 @@ export class CodeReviewService {
1052
1663
  });
1053
1664
  }
1054
1665
  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
1666
  await this.writeReviewSummaryComment({
1076
1667
  task,
1077
1668
  taskRunId: taskRun.id,
@@ -1083,6 +1674,9 @@ export class CodeReviewService {
1083
1674
  summary: parsed.summary,
1084
1675
  findingsCount: parsed.findings?.length ?? 0,
1085
1676
  followupTaskKeys: followupCreated.map((t) => t.taskKey),
1677
+ resolvedCount: commentResolution?.resolved.length,
1678
+ reopenedCount: commentResolution?.reopened.length,
1679
+ openCount: commentResolution?.open.length,
1086
1680
  });
1087
1681
  await this.deps.workspaceRepo.createTaskReview({
1088
1682
  taskId: task.id,
@@ -1151,6 +1745,7 @@ export class CodeReviewService {
1151
1745
  await this.deps.jobService.updateJobStatus(jobId, "running", {
1152
1746
  processedItems: state?.reviewed.length ?? 0,
1153
1747
  });
1748
+ await maybeRateTask(task, taskRun.id, tokensTotal);
1154
1749
  continue;
1155
1750
  }
1156
1751
  results.push({
@@ -1165,6 +1760,7 @@ export class CodeReviewService {
1165
1760
  await this.deps.jobService.updateJobStatus(jobId, "running", {
1166
1761
  processedItems: state?.reviewed.length ?? 0,
1167
1762
  });
1763
+ await maybeRateTask(task, taskRun.id, tokensTotal);
1168
1764
  }
1169
1765
  await this.deps.jobService.updateJobStatus(jobId, "completed", {
1170
1766
  processedItems: state?.reviewed.length ?? selectedTaskIds.length,