@mcoda/core 0.1.8 → 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 (70) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/api/AgentsApi.d.ts +8 -1
  3. package/dist/api/AgentsApi.d.ts.map +1 -1
  4. package/dist/api/AgentsApi.js +70 -0
  5. package/dist/api/QaTasksApi.d.ts.map +1 -1
  6. package/dist/api/QaTasksApi.js +2 -0
  7. package/dist/api/TasksApi.d.ts.map +1 -1
  8. package/dist/api/TasksApi.js +1 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  13. package/dist/prompts/PdrPrompts.js +3 -1
  14. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  15. package/dist/prompts/SdsPrompts.js +2 -0
  16. package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
  17. package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
  18. package/dist/services/agents/AgentRatingFormula.js +45 -0
  19. package/dist/services/agents/AgentRatingService.d.ts +41 -0
  20. package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
  21. package/dist/services/agents/AgentRatingService.js +299 -0
  22. package/dist/services/agents/GatewayAgentService.d.ts +3 -0
  23. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  24. package/dist/services/agents/GatewayAgentService.js +68 -24
  25. package/dist/services/agents/GatewayHandoff.d.ts +7 -0
  26. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
  27. package/dist/services/agents/GatewayHandoff.js +108 -0
  28. package/dist/services/backlog/TaskOrderingService.d.ts +1 -0
  29. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  30. package/dist/services/backlog/TaskOrderingService.js +19 -16
  31. package/dist/services/docs/DocsService.d.ts +11 -1
  32. package/dist/services/docs/DocsService.d.ts.map +1 -1
  33. package/dist/services/docs/DocsService.js +240 -52
  34. package/dist/services/execution/GatewayTrioService.d.ts +133 -0
  35. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
  36. package/dist/services/execution/GatewayTrioService.js +1125 -0
  37. package/dist/services/execution/QaFollowupService.d.ts +1 -0
  38. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  39. package/dist/services/execution/QaFollowupService.js +1 -0
  40. package/dist/services/execution/QaProfileService.d.ts +6 -0
  41. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  42. package/dist/services/execution/QaProfileService.js +165 -3
  43. package/dist/services/execution/QaTasksService.d.ts +18 -0
  44. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  45. package/dist/services/execution/QaTasksService.js +712 -34
  46. package/dist/services/execution/WorkOnTasksService.d.ts +14 -0
  47. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  48. package/dist/services/execution/WorkOnTasksService.js +1497 -240
  49. package/dist/services/openapi/OpenApiService.d.ts +10 -0
  50. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  51. package/dist/services/openapi/OpenApiService.js +66 -10
  52. package/dist/services/planning/CreateTasksService.d.ts +6 -0
  53. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  54. package/dist/services/planning/CreateTasksService.js +261 -28
  55. package/dist/services/planning/RefineTasksService.d.ts +5 -0
  56. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  57. package/dist/services/planning/RefineTasksService.js +184 -35
  58. package/dist/services/review/CodeReviewService.d.ts +14 -0
  59. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  60. package/dist/services/review/CodeReviewService.js +657 -61
  61. package/dist/services/shared/ProjectGuidance.d.ts +6 -0
  62. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
  63. package/dist/services/shared/ProjectGuidance.js +21 -0
  64. package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
  65. package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
  66. package/dist/services/tasks/TaskCommentFormatter.js +54 -0
  67. package/dist/workspace/WorkspaceManager.d.ts +4 -0
  68. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  69. package/dist/workspace/WorkspaceManager.js +3 -0
  70. package/package.json +5 -5
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
3
4
  import { WorkspaceRepository } from '@mcoda/db';
4
5
  import { PathHelper } from '@mcoda/shared';
5
6
  import { JobService } from '../jobs/JobService.js';
@@ -17,12 +18,92 @@ import { AgentService } from '@mcoda/agents';
17
18
  import { GlobalRepository } from '@mcoda/db';
18
19
  import { DocdexClient } from '@mcoda/integrations';
19
20
  import { RoutingService } from '../agents/RoutingService.js';
21
+ import { AgentRatingService } from '../agents/AgentRatingService.js';
22
+ import { loadProjectGuidance } from '../shared/ProjectGuidance.js';
23
+ import { createTaskCommentSlug, formatTaskCommentBody } from '../tasks/TaskCommentFormatter.js';
20
24
  const DEFAULT_QA_PROMPT = [
21
25
  'You are the QA agent. Before testing, 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.',
22
26
  'Use docdex snippets to derive acceptance criteria, data contracts, edge cases, and non-functional requirements (performance, accessibility, offline/online assumptions). Note if docdex is unavailable and fall back to local docs.',
27
+ 'QA policy: always run automated tests. Use browser (Playwright) tests only when the project has a web UI; otherwise run API/endpoint/CLI tests that simulate real usage.',
23
28
  ].join('\n');
29
+ const QA_TEST_POLICY = 'QA policy: always run automated tests. Use browser (Playwright) tests only when the project has a web UI; otherwise run API/endpoint/CLI tests that simulate real usage.';
24
30
  const DEFAULT_JOB_PROMPT = 'You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.';
25
31
  const DEFAULT_CHARACTER_PROMPT = 'Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.';
32
+ const RUN_ALL_TESTS_MARKER = 'mcoda_run_all_tests_complete';
33
+ const RUN_ALL_TESTS_GUIDANCE = 'Run-all tests did not emit the expected marker. Ensure tests/all.js prints "MCODA_RUN_ALL_TESTS_COMPLETE".';
34
+ const normalizeSlugList = (input) => {
35
+ if (!Array.isArray(input))
36
+ return [];
37
+ const cleaned = new Set();
38
+ for (const slug of input) {
39
+ if (typeof slug !== 'string')
40
+ continue;
41
+ const trimmed = slug.trim();
42
+ if (trimmed)
43
+ cleaned.add(trimmed);
44
+ }
45
+ return Array.from(cleaned);
46
+ };
47
+ const parseCommentBody = (body) => {
48
+ const trimmed = (body ?? '').trim();
49
+ if (!trimmed)
50
+ return { message: '(no details provided)' };
51
+ const lines = trimmed.split(/\r?\n/);
52
+ const normalize = (value) => value.trim().toLowerCase();
53
+ const messageIndex = lines.findIndex((line) => normalize(line) === 'message:');
54
+ const suggestedIndex = lines.findIndex((line) => {
55
+ const normalized = normalize(line);
56
+ return normalized === 'suggested_fix:' || normalized === 'suggested fix:';
57
+ });
58
+ if (messageIndex >= 0) {
59
+ const messageLines = lines.slice(messageIndex + 1, suggestedIndex >= 0 ? suggestedIndex : undefined);
60
+ const message = messageLines.join('\n').trim();
61
+ const suggestedLines = suggestedIndex >= 0 ? lines.slice(suggestedIndex + 1) : [];
62
+ const suggestedFix = suggestedLines.join('\n').trim();
63
+ return { message: message || trimmed, suggestedFix: suggestedFix || undefined };
64
+ }
65
+ if (suggestedIndex >= 0) {
66
+ const message = lines.slice(0, suggestedIndex).join('\n').trim() || trimmed;
67
+ const inlineFix = lines[suggestedIndex]?.split(/suggested fix:/i)[1]?.trim();
68
+ const suggestedTail = lines.slice(suggestedIndex + 1).join('\n').trim();
69
+ const suggestedFix = inlineFix || suggestedTail || undefined;
70
+ return { message, suggestedFix };
71
+ }
72
+ return { message: trimmed };
73
+ };
74
+ const buildCommentBacklog = (comments) => {
75
+ if (!comments.length)
76
+ return '';
77
+ const seen = new Set();
78
+ const lines = [];
79
+ const toSingleLine = (value) => value.replace(/\s+/g, ' ').trim();
80
+ for (const comment of comments) {
81
+ const slug = comment.slug?.trim() || undefined;
82
+ const details = parseCommentBody(comment.body);
83
+ const key = slug ??
84
+ `${comment.sourceCommand}:${comment.file ?? ''}:${comment.line ?? ''}:${details.message || comment.body}`;
85
+ if (seen.has(key))
86
+ continue;
87
+ seen.add(key);
88
+ const location = comment.file
89
+ ? `${comment.file}${typeof comment.line === 'number' ? `:${comment.line}` : ''}`
90
+ : '(location not specified)';
91
+ const message = toSingleLine(details.message || comment.body || '(no details provided)');
92
+ lines.push(`- [${slug ?? 'untracked'}] ${location} ${message}`);
93
+ const suggestedFix = comment.metadata?.suggestedFix ?? details.suggestedFix ?? undefined;
94
+ if (suggestedFix) {
95
+ lines.push(` Suggested fix: ${toSingleLine(suggestedFix)}`);
96
+ }
97
+ }
98
+ return lines.join('\n');
99
+ };
100
+ const formatSlugList = (slugs, limit = 12) => {
101
+ if (!slugs.length)
102
+ return 'none';
103
+ if (slugs.length <= limit)
104
+ return slugs.join(', ');
105
+ return `${slugs.slice(0, limit).join(', ')} (+${slugs.length - limit} more)`;
106
+ };
26
107
  const MCODA_GITIGNORE_ENTRY = '.mcoda/\n';
27
108
  export class QaTasksService {
28
109
  constructor(workspace, deps) {
@@ -39,6 +120,7 @@ export class QaTasksService {
39
120
  this.docdex = deps.docdex;
40
121
  this.repo = deps.repo;
41
122
  this.routingService = deps.routingService;
123
+ this.ratingService = deps.ratingService;
42
124
  }
43
125
  static async create(workspace, options = {}) {
44
126
  const repo = await GlobalRepository.create();
@@ -151,10 +233,12 @@ export class QaTasksService {
151
233
  details,
152
234
  });
153
235
  }
154
- async ensureTaskBranch(task, taskRunId) {
236
+ async ensureTaskBranch(task, taskRunId, allowDirty) {
155
237
  try {
156
238
  await this.vcs.ensureRepo(this.workspace.workspaceRoot);
157
- await this.vcs.ensureClean(this.workspace.workspaceRoot, true);
239
+ if (!allowDirty) {
240
+ await this.vcs.ensureClean(this.workspace.workspaceRoot, true, ["test-results", "playwright-report"]);
241
+ }
158
242
  if (task.task.vcsBranch) {
159
243
  const exists = await this.vcs.branchExists(this.workspace.workspaceRoot, task.task.vcsBranch);
160
244
  if (!exists) {
@@ -203,6 +287,23 @@ export class QaTasksService {
203
287
  return 'infra_issue';
204
288
  return 'fix_required';
205
289
  }
290
+ adjustOutcomeForSkippedTests(profile, result, testCommand) {
291
+ if ((profile.runner ?? 'cli') !== 'cli')
292
+ return result;
293
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
294
+ const outputLower = output.toLowerCase();
295
+ const markers = ['no test script configured', 'skipping tests', 'no tests found'];
296
+ if (markers.some((marker) => outputLower.includes(marker))) {
297
+ return { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1 };
298
+ }
299
+ if (testCommand && testCommand.includes('tests/all.js')) {
300
+ if (!outputLower.includes(RUN_ALL_TESTS_MARKER)) {
301
+ const stderr = [result.stderr, RUN_ALL_TESTS_GUIDANCE].filter(Boolean).join('\n');
302
+ return { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1, stderr };
303
+ }
304
+ }
305
+ return result;
306
+ }
206
307
  combineOutcome(result, recommendation) {
207
308
  const base = this.mapOutcome(result);
208
309
  if (!recommendation)
@@ -262,9 +363,84 @@ export class QaTasksService {
262
363
  });
263
364
  return resolved.agent;
264
365
  }
366
+ ensureRatingService() {
367
+ if (!this.ratingService) {
368
+ if (!this.repo || !this.agentService || !this.routingService) {
369
+ throw new Error('Agent rating requires routing, agent, and repository services.');
370
+ }
371
+ this.ratingService = new AgentRatingService(this.workspace, {
372
+ workspaceRepo: this.deps.workspaceRepo,
373
+ globalRepo: this.repo,
374
+ agentService: this.agentService,
375
+ routingService: this.routingService,
376
+ });
377
+ }
378
+ return this.ratingService;
379
+ }
380
+ resolveTaskComplexity(task) {
381
+ const metadata = task.metadata ?? {};
382
+ const metaComplexity = typeof metadata.complexity === 'number' && Number.isFinite(metadata.complexity) ? metadata.complexity : undefined;
383
+ const storyPoints = typeof task.storyPoints === 'number' && Number.isFinite(task.storyPoints) ? task.storyPoints : undefined;
384
+ const candidate = metaComplexity ?? storyPoints;
385
+ if (!Number.isFinite(candidate ?? NaN))
386
+ return undefined;
387
+ return Math.min(10, Math.max(1, Math.round(candidate)));
388
+ }
265
389
  estimateTokens(text) {
266
390
  return Math.max(1, Math.ceil((text?.length ?? 0) / 4));
267
391
  }
392
+ async fileExists(absolutePath) {
393
+ try {
394
+ await fs.access(absolutePath);
395
+ return true;
396
+ }
397
+ catch {
398
+ return false;
399
+ }
400
+ }
401
+ async readPackageJson() {
402
+ const pkgPath = path.join(this.workspace.workspaceRoot, 'package.json');
403
+ try {
404
+ const raw = await fs.readFile(pkgPath, 'utf8');
405
+ return JSON.parse(raw);
406
+ }
407
+ catch {
408
+ return undefined;
409
+ }
410
+ }
411
+ async detectPackageManager() {
412
+ const root = this.workspace.workspaceRoot;
413
+ if (await this.fileExists(path.join(root, 'pnpm-lock.yaml')))
414
+ return 'pnpm';
415
+ if (await this.fileExists(path.join(root, 'pnpm-workspace.yaml')))
416
+ return 'pnpm';
417
+ if (await this.fileExists(path.join(root, 'yarn.lock')))
418
+ return 'yarn';
419
+ if (await this.fileExists(path.join(root, 'package-lock.json')))
420
+ return 'npm';
421
+ if (await this.fileExists(path.join(root, 'npm-shrinkwrap.json')))
422
+ return 'npm';
423
+ if (await this.fileExists(path.join(root, 'package.json')))
424
+ return 'npm';
425
+ return undefined;
426
+ }
427
+ async resolveTestCommand(profile, requestTestCommand) {
428
+ if (requestTestCommand)
429
+ return requestTestCommand;
430
+ if ((profile.runner ?? 'cli') !== 'cli')
431
+ return undefined;
432
+ if (profile.test_command)
433
+ return profile.test_command;
434
+ if (await this.fileExists(path.join(this.workspace.workspaceRoot, 'tests', 'all.js'))) {
435
+ return 'node tests/all.js';
436
+ }
437
+ const pkg = await this.readPackageJson();
438
+ if (pkg?.scripts?.test) {
439
+ const pm = (await this.detectPackageManager()) ?? 'npm';
440
+ return `${pm} test`;
441
+ }
442
+ return undefined;
443
+ }
268
444
  extractJsonCandidate(raw) {
269
445
  const fenced = raw.match(/```json([\s\S]*?)```/i);
270
446
  const candidate = fenced ? fenced[1] : raw;
@@ -272,43 +448,126 @@ export class QaTasksService {
272
448
  const end = candidate.lastIndexOf('}');
273
449
  if (start === -1 || end === -1 || end <= start)
274
450
  return undefined;
451
+ const slice = candidate.slice(start, end + 1);
275
452
  try {
276
- return JSON.parse(candidate.slice(start, end + 1));
453
+ return JSON.parse(slice);
277
454
  }
278
455
  catch {
279
- return undefined;
456
+ const cleanedLines = slice
457
+ .split(/\r?\n/)
458
+ .filter((line) => {
459
+ const trimmed = line.trim();
460
+ if (!trimmed)
461
+ return true;
462
+ if (trimmed.startsWith("{") ||
463
+ trimmed.startsWith("}") ||
464
+ trimmed.startsWith("[") ||
465
+ trimmed.startsWith("]") ||
466
+ trimmed.startsWith("\"")) {
467
+ return true;
468
+ }
469
+ return false;
470
+ })
471
+ .join("\n")
472
+ .replace(/,\s*([}\]])/g, "$1");
473
+ const withQuotedKeys = cleanedLines.replace(/([{,]\s*)([A-Za-z0-9_]+)\s*:/g, '$1"$2":');
474
+ try {
475
+ return JSON.parse(withQuotedKeys);
476
+ }
477
+ catch {
478
+ return undefined;
479
+ }
280
480
  }
281
481
  }
282
482
  normalizeAgentOutput(parsed) {
283
483
  if (!parsed || typeof parsed !== 'object')
284
484
  return undefined;
485
+ const asString = (value) => (typeof value === 'string' ? value.trim() : undefined);
486
+ const asNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
487
+ const asStringArray = (value) => Array.isArray(value) ? value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) : undefined;
285
488
  const recommendation = parsed.recommendation;
286
489
  if (!recommendation || !['pass', 'fix_required', 'infra_issue', 'unclear'].includes(recommendation))
287
490
  return undefined;
288
- const followUps = Array.isArray(parsed.follow_up_tasks)
491
+ const rawFollowUps = Array.isArray(parsed.follow_up_tasks)
289
492
  ? parsed.follow_up_tasks
290
493
  : Array.isArray(parsed.follow_ups)
291
494
  ? parsed.follow_ups
292
495
  : undefined;
496
+ const followUps = rawFollowUps
497
+ ? rawFollowUps.map((item) => ({
498
+ title: asString(item?.title),
499
+ description: asString(item?.description),
500
+ type: asString(item?.type),
501
+ priority: asNumber(item?.priority),
502
+ story_points: asNumber(item?.story_points ?? item?.storyPoints),
503
+ tags: asStringArray(item?.tags),
504
+ related_task_key: asString(item?.related_task_key ?? item?.relatedTaskKey),
505
+ epic_key: asString(item?.epic_key ?? item?.epicKey),
506
+ story_key: asString(item?.story_key ?? item?.storyKey),
507
+ components: asStringArray(item?.components),
508
+ doc_links: asStringArray(item?.doc_links ?? item?.docLinks),
509
+ evidence_url: asString(item?.evidence_url ?? item?.evidenceUrl),
510
+ artifacts: asStringArray(item?.artifacts),
511
+ }))
512
+ : undefined;
293
513
  const failures = Array.isArray(parsed.failures)
294
514
  ? parsed.failures.map((f) => ({ kind: f.kind, message: f.message ?? String(f), evidence: f.evidence }))
295
515
  : undefined;
516
+ const resolvedSlugs = normalizeSlugList(parsed.resolved_slugs ?? parsed.resolvedSlugs);
517
+ const unresolvedSlugs = normalizeSlugList(parsed.unresolved_slugs ?? parsed.unresolvedSlugs);
296
518
  return {
297
519
  recommendation,
298
520
  testedScope: parsed.tested_scope ?? parsed.scope,
299
521
  coverageSummary: parsed.coverage_summary ?? parsed.coverage,
300
522
  failures,
301
523
  followUps,
524
+ resolvedSlugs,
525
+ unresolvedSlugs,
302
526
  };
303
527
  }
304
- async interpretResult(task, profile, result, agentName, stream, jobId, commandRunId, taskRunId) {
528
+ async interpretResult(task, profile, result, agentName, stream, jobId, commandRunId, taskRunId, commentBacklog, abortSignal) {
305
529
  if (!this.agentService) {
306
530
  return { recommendation: this.mapOutcome(result) };
307
531
  }
532
+ const resolveAbortReason = () => {
533
+ const reason = abortSignal?.reason;
534
+ if (typeof reason === "string" && reason.trim().length > 0)
535
+ return reason;
536
+ if (reason instanceof Error && reason.message)
537
+ return reason.message;
538
+ return "qa_tasks_aborted";
539
+ };
540
+ const abortIfSignaled = () => {
541
+ if (abortSignal?.aborted) {
542
+ throw new Error(resolveAbortReason());
543
+ }
544
+ };
545
+ const withAbort = async (promise) => {
546
+ if (!abortSignal)
547
+ return promise;
548
+ if (abortSignal.aborted) {
549
+ throw new Error(resolveAbortReason());
550
+ }
551
+ return await new Promise((resolve, reject) => {
552
+ const onAbort = () => reject(new Error(resolveAbortReason()));
553
+ abortSignal.addEventListener("abort", onAbort, { once: true });
554
+ promise.then(resolve, reject).finally(() => {
555
+ abortSignal.removeEventListener("abort", onAbort);
556
+ });
557
+ });
558
+ };
308
559
  try {
560
+ abortIfSignaled();
309
561
  const agent = await this.resolveAgent(agentName);
310
562
  const prompts = await this.loadPrompts(agent.id);
311
- const systemPrompt = [prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
563
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
564
+ if (projectGuidance && taskRunId) {
565
+ await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
566
+ }
567
+ const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
568
+ const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, QA_TEST_POLICY]
569
+ .filter(Boolean)
570
+ .join('\n\n');
312
571
  const docCtx = await this.gatherDocContext(task.task, taskRunId);
313
572
  const acceptance = (task.task.acceptanceCriteria ?? []).map((line) => `- ${line}`).join('\n');
314
573
  const prompt = [
@@ -319,6 +578,7 @@ export class QaTasksService {
319
578
  task.task.description ? `Task description:\n${task.task.description}` : '',
320
579
  `Epic/Story: ${task.task.epicKey ?? task.task.epicId} / ${task.task.storyKey ?? task.task.userStoryId}`,
321
580
  acceptance ? `Acceptance criteria:\n${acceptance}` : 'Acceptance criteria: (not provided)',
581
+ commentBacklog ? `Comment backlog (unresolved slugs):\n${commentBacklog}` : 'Comment backlog: none',
322
582
  `QA profile: ${profile.name} (${profile.runner ?? 'cli'})`,
323
583
  `Test command / runner outcome: exit=${result.exitCode} outcome=${result.outcome}`,
324
584
  result.stdout ? `Stdout (truncated):\n${result.stdout.slice(0, 3000)}` : '',
@@ -332,9 +592,11 @@ export class QaTasksService {
332
592
  ' "coverage_summary": string,',
333
593
  ' "failures": [{ "kind": "functional|contract|perf|security|infra", "message": string, "evidence": string }],',
334
594
  ' "recommendation": "pass|fix_required|infra_issue|unclear",',
335
- ' "follow_up_tasks": [{ "title": string, "description": string, "type": "bug|qa_followup|chore", "priority": number, "story_points": number, "tags": string[], "related_task_key": string, "epic_key": string, "story_key": string, "doc_links": string[], "evidence_url": string, "artifacts": string[] }]',
595
+ ' "follow_up_tasks": [{ "title": string, "description": string, "type": "bug|qa_followup|chore", "priority": number, "story_points": number, "tags": string[], "related_task_key": string, "epic_key": string, "story_key": string, "doc_links": string[], "evidence_url": string, "artifacts": string[] }],',
596
+ ' "resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],',
597
+ ' "unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]',
336
598
  '}',
337
- 'Do not include prose outside the JSON.',
599
+ 'Do not include prose outside the JSON. Include resolvedSlugs/unresolvedSlugs when reviewing comment backlog.',
338
600
  ].join('\n'),
339
601
  ]
340
602
  .filter(Boolean)
@@ -355,14 +617,19 @@ export class QaTasksService {
355
617
  let output = '';
356
618
  let chunkCount = 0;
357
619
  if (stream && this.agentService.invokeStream) {
358
- const gen = await this.agentService.invokeStream(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
359
- for await (const chunk of gen) {
620
+ const gen = await withAbort(this.agentService.invokeStream(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } }));
621
+ while (true) {
622
+ abortIfSignaled();
623
+ const { value, done } = await withAbort(gen.next());
624
+ if (done)
625
+ break;
626
+ const chunk = value;
360
627
  output += chunk.output ?? '';
361
628
  chunkCount += 1;
362
629
  }
363
630
  }
364
631
  else {
365
- const res = await this.agentService.invoke(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
632
+ const res = await withAbort(this.agentService.invoke(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } }));
366
633
  output = res.output ?? '';
367
634
  }
368
635
  const tokensPrompt = this.estimateTokens(prompt);
@@ -401,7 +668,69 @@ export class QaTasksService {
401
668
  modelName: agent.defaultModel,
402
669
  };
403
670
  }
404
- return { recommendation: this.mapOutcome(result), rawOutput: output, tokensPrompt, tokensCompletion, agentId: agent.id, modelName: agent.defaultModel };
671
+ const retryPrompt = `${prompt}\n\nReturn STRICT JSON only. Do not include prose, markdown fences, or comments.`;
672
+ let retryOutput = "";
673
+ if (stream && this.agentService.invokeStream) {
674
+ const gen = await withAbort(this.agentService.invokeStream(agent.id, { input: retryPrompt, metadata: { command: 'qa-tasks' } }));
675
+ while (true) {
676
+ abortIfSignaled();
677
+ const { value, done } = await withAbort(gen.next());
678
+ if (done)
679
+ break;
680
+ const chunk = value;
681
+ retryOutput += chunk.output ?? '';
682
+ }
683
+ }
684
+ else {
685
+ const res = await withAbort(this.agentService.invoke(agent.id, { input: retryPrompt, metadata: { command: 'qa-tasks' } }));
686
+ retryOutput = res.output ?? '';
687
+ }
688
+ const retryTokensPrompt = this.estimateTokens(retryPrompt);
689
+ const retryTokensCompletion = this.estimateTokens(retryOutput);
690
+ if (!this.dryRunGuard) {
691
+ await this.jobService.recordTokenUsage({
692
+ workspaceId: this.workspace.workspaceId,
693
+ agentId: agent.id,
694
+ modelName: agent.defaultModel,
695
+ jobId,
696
+ taskId: task.task.id,
697
+ commandRunId,
698
+ taskRunId,
699
+ tokensPrompt: retryTokensPrompt,
700
+ tokensCompletion: retryTokensCompletion,
701
+ tokensTotal: retryTokensPrompt + retryTokensCompletion,
702
+ timestamp: new Date().toISOString(),
703
+ metadata: {
704
+ commandName: 'qa-tasks',
705
+ action: 'qa-interpret-retry',
706
+ taskKey: task.task.key,
707
+ },
708
+ });
709
+ }
710
+ const retryParsed = this.extractJsonCandidate(retryOutput);
711
+ const retryNormalized = this.normalizeAgentOutput(retryParsed);
712
+ if (retryNormalized) {
713
+ return {
714
+ ...retryNormalized,
715
+ rawOutput: retryOutput,
716
+ tokensPrompt: tokensPrompt + retryTokensPrompt,
717
+ tokensCompletion: tokensCompletion + retryTokensCompletion,
718
+ agentId: agent.id,
719
+ modelName: agent.defaultModel,
720
+ };
721
+ }
722
+ if (taskRunId) {
723
+ await this.logTask(taskRunId, "QA agent returned invalid JSON after retry; falling back to QA outcome.", "qa-agent");
724
+ }
725
+ return {
726
+ recommendation: 'unclear',
727
+ rawOutput: retryOutput || output,
728
+ tokensPrompt: tokensPrompt + retryTokensPrompt,
729
+ tokensCompletion: tokensCompletion + retryTokensCompletion,
730
+ agentId: agent.id,
731
+ modelName: agent.defaultModel,
732
+ invalidJson: true,
733
+ };
405
734
  }
406
735
  catch (error) {
407
736
  if (taskRunId) {
@@ -410,6 +739,152 @@ export class QaTasksService {
410
739
  return { recommendation: this.mapOutcome(result) };
411
740
  }
412
741
  }
742
+ async loadCommentContext(taskId) {
743
+ const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
744
+ sourceCommands: ['code-review', 'qa-tasks'],
745
+ limit: 50,
746
+ });
747
+ const unresolved = comments.filter((comment) => !comment.resolvedAt);
748
+ return { comments, unresolved };
749
+ }
750
+ resolveFailureSlug(failure) {
751
+ const message = (failure.message ?? '').trim() || 'QA issue';
752
+ return createTaskCommentSlug({
753
+ source: 'qa-tasks',
754
+ message,
755
+ category: failure.kind ?? 'qa_issue',
756
+ });
757
+ }
758
+ async applyCommentResolutions(params) {
759
+ const existingBySlug = new Map();
760
+ const openBySlug = new Set();
761
+ const resolvedBySlug = new Set();
762
+ for (const comment of params.existingComments) {
763
+ if (!comment.slug)
764
+ continue;
765
+ if (!existingBySlug.has(comment.slug)) {
766
+ existingBySlug.set(comment.slug, comment);
767
+ }
768
+ if (comment.resolvedAt) {
769
+ resolvedBySlug.add(comment.slug);
770
+ }
771
+ else {
772
+ openBySlug.add(comment.slug);
773
+ }
774
+ }
775
+ const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined);
776
+ const resolvedSet = new Set(resolvedSlugs);
777
+ const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined));
778
+ const failureSlugs = [];
779
+ for (const failure of params.failures ?? []) {
780
+ const slug = this.resolveFailureSlug(failure);
781
+ failureSlugs.push(slug);
782
+ if (!resolvedSet.has(slug)) {
783
+ unresolvedSet.add(slug);
784
+ }
785
+ }
786
+ for (const slug of resolvedSet) {
787
+ unresolvedSet.delete(slug);
788
+ }
789
+ const toResolve = resolvedSlugs.filter((slug) => openBySlug.has(slug));
790
+ const toReopen = Array.from(unresolvedSet).filter((slug) => resolvedBySlug.has(slug));
791
+ if (!this.dryRunGuard) {
792
+ for (const slug of toResolve) {
793
+ await this.deps.workspaceRepo.resolveTaskComment({
794
+ taskId: params.task.id,
795
+ slug,
796
+ resolvedAt: new Date().toISOString(),
797
+ resolvedBy: params.agentId ?? null,
798
+ });
799
+ }
800
+ for (const slug of toReopen) {
801
+ await this.deps.workspaceRepo.reopenTaskComment({ taskId: params.task.id, slug });
802
+ }
803
+ }
804
+ const createdSlugs = new Set();
805
+ for (const failure of params.failures ?? []) {
806
+ const slug = this.resolveFailureSlug(failure);
807
+ if (existingBySlug.has(slug) || createdSlugs.has(slug))
808
+ continue;
809
+ const baseMessage = (failure.message ?? '').trim() || '(no details provided)';
810
+ const message = failure.evidence ? `${baseMessage}\nEvidence: ${failure.evidence}` : baseMessage;
811
+ const body = formatTaskCommentBody({
812
+ slug,
813
+ source: 'qa-tasks',
814
+ message,
815
+ status: 'open',
816
+ category: failure.kind ?? 'qa_issue',
817
+ });
818
+ if (!this.dryRunGuard) {
819
+ await this.deps.workspaceRepo.createTaskComment({
820
+ taskId: params.task.id,
821
+ taskRunId: params.taskRunId,
822
+ jobId: params.jobId,
823
+ sourceCommand: 'qa-tasks',
824
+ authorType: 'agent',
825
+ authorAgentId: params.agentId ?? null,
826
+ category: failure.kind ?? 'qa_issue',
827
+ slug,
828
+ status: 'open',
829
+ body,
830
+ createdAt: new Date().toISOString(),
831
+ metadata: {
832
+ kind: failure.kind,
833
+ evidence: failure.evidence,
834
+ },
835
+ });
836
+ }
837
+ createdSlugs.add(slug);
838
+ }
839
+ const openSet = new Set(openBySlug);
840
+ for (const slug of unresolvedSet) {
841
+ openSet.add(slug);
842
+ }
843
+ for (const slug of resolvedSet) {
844
+ openSet.delete(slug);
845
+ }
846
+ if ((resolvedSlugs.length || toReopen.length || unresolvedSet.size) && !this.dryRunGuard) {
847
+ const resolutionMessage = [
848
+ `Resolved slugs: ${formatSlugList(toResolve)}`,
849
+ `Reopened slugs: ${formatSlugList(toReopen)}`,
850
+ `Open slugs: ${formatSlugList(Array.from(openSet))}`,
851
+ ].join('\n');
852
+ const resolutionSlug = createTaskCommentSlug({
853
+ source: 'qa-tasks',
854
+ message: resolutionMessage,
855
+ category: 'comment_resolution',
856
+ });
857
+ const resolutionBody = formatTaskCommentBody({
858
+ slug: resolutionSlug,
859
+ source: 'qa-tasks',
860
+ message: resolutionMessage,
861
+ status: 'resolved',
862
+ category: 'comment_resolution',
863
+ });
864
+ const createdAt = new Date().toISOString();
865
+ await this.deps.workspaceRepo.createTaskComment({
866
+ taskId: params.task.id,
867
+ taskRunId: params.taskRunId,
868
+ jobId: params.jobId,
869
+ sourceCommand: 'qa-tasks',
870
+ authorType: 'agent',
871
+ authorAgentId: params.agentId ?? null,
872
+ category: 'comment_resolution',
873
+ slug: resolutionSlug,
874
+ status: 'resolved',
875
+ body: resolutionBody,
876
+ createdAt,
877
+ resolvedAt: createdAt,
878
+ resolvedBy: params.agentId ?? null,
879
+ metadata: {
880
+ resolvedSlugs: toResolve,
881
+ reopenedSlugs: toReopen,
882
+ openSlugs: Array.from(openSet),
883
+ },
884
+ });
885
+ }
886
+ return { resolved: toResolve, reopened: toReopen, open: Array.from(openSet) };
887
+ }
413
888
  async createTaskRun(task, jobId, commandRunId) {
414
889
  const startedAt = new Date().toISOString();
415
890
  return this.deps.workspaceRepo.createTaskRun({
@@ -456,6 +931,9 @@ export class QaTasksService {
456
931
  else if (outcome === 'infra_issue') {
457
932
  await this.stateService.markBlocked(task, 'qa_infra_issue');
458
933
  }
934
+ else if (outcome === 'unclear') {
935
+ await this.stateService.markBlocked(task, 'qa_unclear');
936
+ }
459
937
  }
460
938
  buildFollowupSuggestion(task, result, notes) {
461
939
  const summary = notes || result.stderr || result.stdout || 'QA failure detected';
@@ -474,6 +952,38 @@ export class QaTasksService {
474
952
  testName: tests[0],
475
953
  };
476
954
  }
955
+ buildManualQaFollowup(task, rawOutput) {
956
+ const summary = rawOutput ? rawOutput.slice(0, 1000) : 'QA agent returned invalid JSON after retry.';
957
+ const components = Array.isArray(task.metadata?.components) ? task.metadata.components : [];
958
+ const docLinks = Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : [];
959
+ const tests = Array.isArray(task.metadata?.tests) ? task.metadata.tests : [];
960
+ return {
961
+ title: `Manual QA follow-up for ${task.key}`,
962
+ description: `QA agent returned invalid JSON after retry. Manual QA required.\n\nRaw output:\n${summary}`.slice(0, 2000),
963
+ type: 'qa_followup',
964
+ storyPoints: 1,
965
+ priority: 90,
966
+ tags: ['qa', 'manual', ...components],
967
+ components,
968
+ docLinks,
969
+ testName: tests[0],
970
+ };
971
+ }
972
+ buildFollowupSlug(task, suggestion) {
973
+ const seedParts = [
974
+ task.key,
975
+ suggestion.title ?? '',
976
+ suggestion.description ?? '',
977
+ suggestion.type ?? '',
978
+ suggestion.testName ?? '',
979
+ suggestion.evidenceUrl ?? '',
980
+ ...(suggestion.tags ?? []),
981
+ ...(suggestion.components ?? []),
982
+ ];
983
+ const seed = seedParts.join('|').toLowerCase();
984
+ const digest = createHash('sha1').update(seed).digest('hex').slice(0, 12);
985
+ return `qa-followup-${task.key}-${digest}`;
986
+ }
477
987
  toFollowupSuggestion(task, agentFollow, artifacts) {
478
988
  const taskComponents = Array.isArray(task.metadata?.components) ? task.metadata.components : [];
479
989
  const taskDocLinks = Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : [];
@@ -498,7 +1008,12 @@ export class QaTasksService {
498
1008
  return [];
499
1009
  const agent = await this.resolveAgent(undefined);
500
1010
  const prompts = await this.loadPrompts(agent.id);
501
- const systemPrompt = [prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
1011
+ const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
1012
+ if (projectGuidance && taskRunId) {
1013
+ await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
1014
+ }
1015
+ const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
1016
+ const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
502
1017
  const docCtx = await this.gatherDocContext(task.task, taskRunId);
503
1018
  const prompt = [
504
1019
  systemPrompt,
@@ -591,7 +1106,7 @@ export class QaTasksService {
591
1106
  }
592
1107
  return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
593
1108
  }
594
- const branchCheck = await this.ensureTaskBranch(task, taskRun.id);
1109
+ const branchCheck = await this.ensureTaskBranch(task, taskRun.id, ctx.request.allowDirty ?? false);
595
1110
  if (!branchCheck.ok) {
596
1111
  if (!this.dryRunGuard) {
597
1112
  await this.applyStateTransition(task.task, 'infra_issue');
@@ -607,6 +1122,15 @@ export class QaTasksService {
607
1122
  recommendation: 'infra_issue',
608
1123
  metadata: { reason: 'vcs_branch_missing', detail: branchCheck.message },
609
1124
  });
1125
+ const message = `VCS validation failed: ${branchCheck.message ?? 'unknown error'}`;
1126
+ const slug = createTaskCommentSlug({ source: 'qa-tasks', message, category: 'qa_issue' });
1127
+ const body = formatTaskCommentBody({
1128
+ slug,
1129
+ source: 'qa-tasks',
1130
+ message,
1131
+ status: 'open',
1132
+ category: 'qa_issue',
1133
+ });
610
1134
  await this.deps.workspaceRepo.createTaskComment({
611
1135
  taskId: task.task.id,
612
1136
  taskRunId: taskRun.id,
@@ -614,7 +1138,9 @@ export class QaTasksService {
614
1138
  sourceCommand: 'qa-tasks',
615
1139
  authorType: 'agent',
616
1140
  category: 'qa_issue',
617
- body: `VCS validation failed: ${branchCheck.message ?? 'unknown error'}`,
1141
+ slug,
1142
+ status: 'open',
1143
+ body,
618
1144
  createdAt: new Date().toISOString(),
619
1145
  });
620
1146
  }
@@ -684,16 +1210,22 @@ export class QaTasksService {
684
1210
  }
685
1211
  return { taskKey: task.task.key, outcome: 'infra_issue', profile: profile.name, runner: profile.runner, notes: 'no_adapter' };
686
1212
  }
1213
+ const testCommand = await this.resolveTestCommand(profile, ctx.request.testCommand);
687
1214
  const qaCtx = {
688
1215
  workspaceRoot: this.workspace.workspaceRoot,
689
1216
  jobId: ctx.jobId,
690
1217
  taskKey: task.task.key,
691
1218
  env: process.env,
692
- testCommandOverride: ctx.request.testCommand,
1219
+ testCommandOverride: testCommand,
693
1220
  };
694
1221
  const ensure = await adapter.ensureInstalled(profile, qaCtx);
695
1222
  if (!ensure.ok) {
696
- await this.logTask(taskRun.id, ensure.message ?? 'QA install failed', 'qa-install');
1223
+ const guidance = 'Run "docdex setup" to install Playwright and at least one browser.';
1224
+ const installMessage = ensure.message ?? 'QA install failed';
1225
+ const installMessageWithGuidance = installMessage.includes('docdex setup')
1226
+ ? installMessage
1227
+ : `${installMessage} ${guidance}`;
1228
+ await this.logTask(taskRun.id, installMessageWithGuidance, 'qa-install');
697
1229
  if (!this.dryRunGuard) {
698
1230
  await this.applyStateTransition(task.task, 'infra_issue');
699
1231
  await this.finishTaskRun(taskRun, 'failed');
@@ -708,7 +1240,28 @@ export class QaTasksService {
708
1240
  runner: profile.runner,
709
1241
  rawOutcome: 'infra_issue',
710
1242
  recommendation: 'infra_issue',
711
- metadata: { install: ensure.message, adapter: profile.runner },
1243
+ metadata: { install: installMessageWithGuidance, adapter: profile.runner },
1244
+ });
1245
+ const message = `QA infra issue: ${installMessageWithGuidance}`;
1246
+ const slug = createTaskCommentSlug({ source: 'qa-tasks', message, category: 'qa_issue' });
1247
+ const body = formatTaskCommentBody({
1248
+ slug,
1249
+ source: 'qa-tasks',
1250
+ message,
1251
+ status: 'open',
1252
+ category: 'qa_issue',
1253
+ });
1254
+ await this.deps.workspaceRepo.createTaskComment({
1255
+ taskId: task.task.id,
1256
+ taskRunId: taskRun.id,
1257
+ jobId: ctx.jobId,
1258
+ sourceCommand: 'qa-tasks',
1259
+ authorType: 'agent',
1260
+ category: 'qa_issue',
1261
+ slug,
1262
+ status: 'open',
1263
+ body,
1264
+ createdAt: new Date().toISOString(),
712
1265
  });
713
1266
  }
714
1267
  return {
@@ -716,18 +1269,36 @@ export class QaTasksService {
716
1269
  outcome: 'infra_issue',
717
1270
  profile: profile.name,
718
1271
  runner: profile.runner,
719
- notes: ensure.message,
1272
+ notes: installMessageWithGuidance,
720
1273
  };
721
1274
  }
722
1275
  const artifactDir = path.join(this.workspace.workspaceRoot, '.mcoda', 'jobs', ctx.jobId, 'qa', task.task.key);
723
1276
  await PathHelper.ensureDir(artifactDir);
724
- const result = await adapter.invoke(profile, { ...qaCtx, artifactDir });
1277
+ const qaEnv = { ...qaCtx.env };
1278
+ const browsersPath = ensure.details?.playwrightBrowsersPath;
1279
+ if (typeof browsersPath === 'string' && browsersPath.trim().length > 0) {
1280
+ qaEnv.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
1281
+ }
1282
+ let result = await adapter.invoke(profile, { ...qaCtx, env: qaEnv, artifactDir });
1283
+ result = this.adjustOutcomeForSkippedTests(profile, result, testCommand);
725
1284
  await this.logTask(taskRun.id, `QA run completed with outcome ${result.outcome}`, 'qa-exec', {
726
1285
  exitCode: result.exitCode,
727
1286
  });
728
- const interpretation = await this.interpretResult(task, profile, result, ctx.request.agentName, ctx.request.agentStream ?? true, ctx.jobId, ctx.commandRunId, taskRun.id);
1287
+ const commentContext = await this.loadCommentContext(task.task.id);
1288
+ const commentBacklog = buildCommentBacklog(commentContext.unresolved);
1289
+ const interpretation = await this.interpretResult(task, profile, result, ctx.request.agentName, ctx.request.agentStream ?? true, ctx.jobId, ctx.commandRunId, taskRun.id, commentBacklog, ctx.request.abortSignal);
729
1290
  const outcome = this.combineOutcome(result, interpretation.recommendation);
730
1291
  const artifacts = result.artifacts ?? [];
1292
+ const commentResolution = await this.applyCommentResolutions({
1293
+ task: task.task,
1294
+ taskRunId: taskRun.id,
1295
+ jobId: ctx.jobId,
1296
+ agentId: interpretation.agentId,
1297
+ failures: interpretation.failures ?? [],
1298
+ resolvedSlugs: interpretation.resolvedSlugs,
1299
+ unresolvedSlugs: interpretation.unresolvedSlugs,
1300
+ existingComments: commentContext.comments,
1301
+ });
731
1302
  let qaRun;
732
1303
  if (!this.dryRunGuard) {
733
1304
  qaRun = await this.deps.workspaceRepo.createTaskQaRun({
@@ -756,6 +1327,7 @@ export class QaTasksService {
756
1327
  testedScope: interpretation.testedScope,
757
1328
  coverageSummary: interpretation.coverageSummary,
758
1329
  failures: interpretation.failures,
1330
+ invalidJson: interpretation.invalidJson ?? false,
759
1331
  },
760
1332
  });
761
1333
  }
@@ -764,13 +1336,24 @@ export class QaTasksService {
764
1336
  await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
765
1337
  }
766
1338
  const followups = [];
767
- if (outcome === 'fix_required' && ctx.request.createFollowupTasks !== 'none') {
1339
+ const wantsFollowups = ctx.request.createFollowupTasks !== 'none';
1340
+ const needsManualFollowup = interpretation.invalidJson === true;
1341
+ if ((outcome === 'fix_required' || needsManualFollowup) && wantsFollowups) {
768
1342
  const suggestions = interpretation.followUps?.map((f) => this.toFollowupSuggestion(task.task, f, artifacts)) ?? [];
769
- if (suggestions.length === 0) {
1343
+ if (needsManualFollowup) {
1344
+ suggestions.unshift(this.buildManualQaFollowup(task.task, interpretation.rawOutput));
1345
+ }
1346
+ else if (suggestions.length === 0) {
770
1347
  suggestions.push(this.buildFollowupSuggestion(task.task, result, ctx.request.notes));
771
1348
  }
772
1349
  const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
773
1350
  for (const suggestion of suggestions) {
1351
+ const followupSlug = this.buildFollowupSlug(task.task, suggestion);
1352
+ const existing = await this.deps.workspaceRepo.listTasksByMetadataValue(task.task.projectId, 'qa_followup_slug', followupSlug);
1353
+ if (existing.length) {
1354
+ await this.logTask(taskRun.id, `Skipped follow-up ${followupSlug}; already exists: ${existing.map((item) => item.key).join(', ')}`, 'qa-followup');
1355
+ continue;
1356
+ }
774
1357
  let proceed = ctx.request.createFollowupTasks !== 'prompt';
775
1358
  if (interactive) {
776
1359
  const rl = readline.createInterface({ input, output });
@@ -782,7 +1365,7 @@ export class QaTasksService {
782
1365
  continue;
783
1366
  try {
784
1367
  if (!this.dryRunGuard) {
785
- const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, suggestion);
1368
+ const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, { ...suggestion, followupSlug });
786
1369
  followups.push(created.task.key);
787
1370
  await this.logTask(taskRun.id, `Created follow-up ${created.task.key}`, 'qa-followup');
788
1371
  }
@@ -794,32 +1377,89 @@ export class QaTasksService {
794
1377
  }
795
1378
  const bodyLines = [
796
1379
  `QA outcome: ${outcome}`,
1380
+ outcome === 'unclear'
1381
+ ? 'QA outcome unclear: provide missing acceptance criteria, reproduction steps, and expected behavior.'
1382
+ : '',
797
1383
  profile ? `Profile: ${profile.name} (${profile.runner ?? 'cli'})` : '',
798
1384
  interpretation.coverageSummary ? `Coverage: ${interpretation.coverageSummary}` : '',
799
1385
  interpretation.failures && interpretation.failures.length
800
1386
  ? `Failures:\n${interpretation.failures.map((f) => `- [${f.kind ?? 'issue'}] ${f.message}${f.evidence ? ` (${f.evidence})` : ''}`).join('\n')}`
801
1387
  : '',
1388
+ commentResolution
1389
+ ? `Comment slugs: resolved ${commentResolution.resolved.length}, reopened ${commentResolution.reopened.length}, open ${commentResolution.open.length}`
1390
+ : '',
1391
+ interpretation.invalidJson && interpretation.rawOutput
1392
+ ? `QA agent output (invalid JSON):\n${interpretation.rawOutput.slice(0, 4000)}`
1393
+ : '',
802
1394
  result.stdout ? `Stdout:\n${result.stdout.slice(0, 4000)}` : '',
803
1395
  result.stderr ? `Stderr:\n${result.stderr.slice(0, 4000)}` : '',
804
1396
  artifacts.length ? `Artifacts:\n${artifacts.join('\n')}` : '',
805
1397
  followups.length ? `Follow-ups: ${followups.join(', ')}` : '',
806
1398
  ].filter(Boolean);
807
1399
  if (!this.dryRunGuard) {
1400
+ const category = outcome === 'pass' ? 'qa_result' : 'qa_issue';
1401
+ const summaryMessage = bodyLines.join('\n\n');
1402
+ const summarySlug = createTaskCommentSlug({
1403
+ source: 'qa-tasks',
1404
+ message: summaryMessage,
1405
+ category,
1406
+ });
1407
+ const status = outcome === 'pass' ? 'resolved' : 'open';
1408
+ const summaryBody = formatTaskCommentBody({
1409
+ slug: summarySlug,
1410
+ source: 'qa-tasks',
1411
+ message: summaryMessage,
1412
+ status,
1413
+ category,
1414
+ });
1415
+ const createdAt = new Date().toISOString();
808
1416
  await this.deps.workspaceRepo.createTaskComment({
809
1417
  taskId: task.task.id,
810
1418
  taskRunId: taskRun.id,
811
1419
  jobId: ctx.jobId,
812
1420
  sourceCommand: 'qa-tasks',
813
1421
  authorType: 'agent',
814
- category: outcome === 'pass' ? 'qa_result' : 'qa_issue',
815
- body: bodyLines.join('\n\n'),
816
- createdAt: new Date().toISOString(),
1422
+ authorAgentId: interpretation.agentId ?? null,
1423
+ category,
1424
+ slug: summarySlug,
1425
+ status,
1426
+ body: summaryBody,
1427
+ createdAt,
1428
+ resolvedAt: status === 'resolved' ? createdAt : null,
1429
+ resolvedBy: status === 'resolved' ? interpretation.agentId ?? null : null,
817
1430
  metadata: {
818
1431
  ...(artifacts.length ? { artifacts } : {}),
819
1432
  ...(qaRun?.id ? { qaRunId: qaRun.id } : {}),
820
1433
  },
821
1434
  });
822
1435
  }
1436
+ const ratingTokens = (interpretation.tokensPrompt ?? 0) + (interpretation.tokensCompletion ?? 0);
1437
+ if (ctx.request.rateAgents && interpretation.agentId && ratingTokens > 0) {
1438
+ try {
1439
+ const ratingService = this.ensureRatingService();
1440
+ await ratingService.rate({
1441
+ workspace: this.workspace,
1442
+ agentId: interpretation.agentId,
1443
+ commandName: 'qa-tasks',
1444
+ jobId: ctx.jobId,
1445
+ commandRunId: ctx.commandRunId,
1446
+ taskId: task.task.id,
1447
+ taskKey: task.task.key,
1448
+ discipline: task.task.type ?? undefined,
1449
+ complexity: this.resolveTaskComplexity(task.task),
1450
+ });
1451
+ }
1452
+ catch (error) {
1453
+ const message = `Agent rating failed for ${task.task.key}: ${error instanceof Error ? error.message : String(error)}`;
1454
+ ctx.warnings?.push(message);
1455
+ try {
1456
+ await this.logTask(taskRun.id, message, 'rating');
1457
+ }
1458
+ catch {
1459
+ /* ignore rating log failures */
1460
+ }
1461
+ }
1462
+ }
823
1463
  return {
824
1464
  taskKey: task.task.key,
825
1465
  outcome,
@@ -881,6 +1521,12 @@ export class QaTasksService {
881
1521
  }
882
1522
  const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
883
1523
  for (const suggestion of suggestions) {
1524
+ const followupSlug = this.buildFollowupSlug(task.task, suggestion);
1525
+ const existing = await this.deps.workspaceRepo.listTasksByMetadataValue(task.task.projectId, 'qa_followup_slug', followupSlug);
1526
+ if (existing.length) {
1527
+ await this.logTask(taskRun.id, `Skipped follow-up ${followupSlug}; already exists: ${existing.map((item) => item.key).join(', ')}`, 'qa-followup');
1528
+ continue;
1529
+ }
884
1530
  let proceed = ctx.request.createFollowupTasks === 'auto' || ctx.request.createFollowupTasks === undefined;
885
1531
  if (interactive) {
886
1532
  const rl = readline.createInterface({ input, output });
@@ -891,7 +1537,7 @@ export class QaTasksService {
891
1537
  if (!proceed)
892
1538
  continue;
893
1539
  try {
894
- const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, suggestion);
1540
+ const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, { ...suggestion, followupSlug });
895
1541
  followups.push(created.task.key);
896
1542
  }
897
1543
  catch (error) {
@@ -909,15 +1555,30 @@ export class QaTasksService {
909
1555
  .filter(Boolean)
910
1556
  .join('\n');
911
1557
  if (!ctx.request.dryRun) {
1558
+ const category = result === 'pass' ? 'qa_result' : 'qa_issue';
1559
+ const status = result === 'pass' ? 'resolved' : 'open';
1560
+ const slug = createTaskCommentSlug({ source: 'qa-tasks', message: body, category });
1561
+ const formattedBody = formatTaskCommentBody({
1562
+ slug,
1563
+ source: 'qa-tasks',
1564
+ message: body,
1565
+ status,
1566
+ category,
1567
+ });
1568
+ const createdAt = new Date().toISOString();
912
1569
  await this.deps.workspaceRepo.createTaskComment({
913
1570
  taskId: task.task.id,
914
1571
  taskRunId: taskRun.id,
915
1572
  jobId: ctx.jobId,
916
1573
  sourceCommand: 'qa-tasks',
917
1574
  authorType: 'human',
918
- category: result === 'pass' ? 'qa_result' : 'qa_issue',
919
- body,
920
- createdAt: new Date().toISOString(),
1575
+ category,
1576
+ slug,
1577
+ status,
1578
+ body: formattedBody,
1579
+ createdAt,
1580
+ resolvedAt: status === 'resolved' ? createdAt : null,
1581
+ resolvedBy: status === 'resolved' ? 'human' : null,
921
1582
  metadata: {
922
1583
  ...(ctx.request.evidenceUrl ? { evidence: ctx.request.evidenceUrl } : {}),
923
1584
  ...(artifacts.length ? { artifacts } : {}),
@@ -949,10 +1610,25 @@ export class QaTasksService {
949
1610
  taskKeys: effectiveTasks,
950
1611
  statusFilter: effectiveStatus,
951
1612
  });
1613
+ const abortSignal = request.abortSignal;
1614
+ const resolveAbortReason = () => {
1615
+ const reason = abortSignal?.reason;
1616
+ if (typeof reason === "string" && reason.trim().length > 0)
1617
+ return reason;
1618
+ if (reason instanceof Error && reason.message)
1619
+ return reason.message;
1620
+ return "qa_tasks_aborted";
1621
+ };
1622
+ const abortIfSignaled = () => {
1623
+ if (abortSignal?.aborted) {
1624
+ throw new Error(resolveAbortReason());
1625
+ }
1626
+ };
952
1627
  this.dryRunGuard = request.dryRun ?? false;
953
1628
  if (request.dryRun) {
954
1629
  const dryResults = [];
955
1630
  for (const task of selection.ordered) {
1631
+ abortIfSignaled();
956
1632
  let profile;
957
1633
  try {
958
1634
  profile = await this.profileService.resolveProfileForTask(task.task, {
@@ -1062,6 +1738,7 @@ export class QaTasksService {
1062
1738
  blocked: selection.blocked.map((t) => t.task.key),
1063
1739
  completedTaskKeys: Array.from(completedKeys),
1064
1740
  });
1741
+ const warnings = [...selection.warnings];
1065
1742
  const results = [];
1066
1743
  for (const task of selection.ordered) {
1067
1744
  if (completedKeys.has(task.task.key)) {
@@ -1072,12 +1749,13 @@ export class QaTasksService {
1072
1749
  try {
1073
1750
  let processedCount = completedKeys.size;
1074
1751
  for (const [index, task] of filteredRemaining.entries()) {
1752
+ abortIfSignaled();
1075
1753
  const mode = request.mode ?? 'auto';
1076
1754
  if (mode === 'manual') {
1077
1755
  results.push(await this.runManual(task, { jobId: job.id, commandRunId: commandRun.id, request }));
1078
1756
  }
1079
1757
  else {
1080
- results.push(await this.runAuto(task, { jobId: job.id, commandRunId: commandRun.id, request }));
1758
+ results.push(await this.runAuto(task, { jobId: job.id, commandRunId: commandRun.id, request, warnings }));
1081
1759
  }
1082
1760
  completedKeys.add(task.task.key);
1083
1761
  processedCount = completedKeys.size;
@@ -1111,7 +1789,7 @@ export class QaTasksService {
1111
1789
  commandRunId: commandRun.id,
1112
1790
  selection,
1113
1791
  results,
1114
- warnings: selection.warnings,
1792
+ warnings,
1115
1793
  };
1116
1794
  }
1117
1795
  }