@yemi33/minions 0.1.2423 → 0.1.2425

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 (184) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +6 -4
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +12 -7
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-auto-fix-dispatch.md +3 -3
  64. package/docs/pr-review-fix-loop.md +6 -6
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  82. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  83. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  84. package/docs/tutorials/README.md +4 -3
  85. package/docs/visual-evidence-ci.md +103 -0
  86. package/docs/watches.md +35 -30
  87. package/docs/worktree-lifecycle.md +151 -13
  88. package/engine/acp-transport.js +1 -0
  89. package/engine/ado.js +80 -33
  90. package/engine/agent-api-validation.js +571 -0
  91. package/engine/agent-worker-pool.js +7 -6
  92. package/engine/api-contracts/agent-content.js +761 -0
  93. package/engine/api-contracts/cc-ops.js +219 -0
  94. package/engine/api-contracts/config-runtime.js +419 -0
  95. package/engine/api-contracts/core.js +138 -0
  96. package/engine/api-contracts/index.js +506 -0
  97. package/engine/api-contracts/orchestration.js +602 -0
  98. package/engine/api-contracts/pull-requests.js +323 -0
  99. package/engine/api-contracts/qa-process.js +764 -0
  100. package/engine/api-contracts/work-plan-prd.js +494 -0
  101. package/engine/api-validation.js +543 -0
  102. package/engine/cc-api-validation.js +586 -0
  103. package/engine/cc-worker-pool.js +9 -1
  104. package/engine/cleanup.js +393 -271
  105. package/engine/cli.js +65 -7
  106. package/engine/comment-classifier.js +70 -5
  107. package/engine/consolidation.js +123 -5
  108. package/engine/content-api-validation.js +606 -0
  109. package/engine/create-pr-worktree.js +22 -1
  110. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  111. package/engine/dispatch.js +33 -0
  112. package/engine/distribution.js +189 -0
  113. package/engine/execution-model.js +59 -0
  114. package/engine/features.js +12 -0
  115. package/engine/github.js +67 -25
  116. package/engine/harness-context.js +313 -0
  117. package/engine/inbox-store.js +1 -1
  118. package/engine/kb-sweep.js +161 -38
  119. package/engine/keep-process-sweep.js +43 -3
  120. package/engine/lifecycle.js +1066 -165
  121. package/engine/llm.js +4 -0
  122. package/engine/managed-spawn.js +463 -48
  123. package/engine/meeting.js +197 -34
  124. package/engine/memory-retrieval.js +280 -0
  125. package/engine/memory-store.js +821 -38
  126. package/engine/model-discovery.js +17 -3
  127. package/engine/pipeline.js +802 -14
  128. package/engine/plan-prd-validation.js +745 -0
  129. package/engine/playbook.js +206 -17
  130. package/engine/pooled-agent-process.js +6 -1
  131. package/engine/pr-action.js +1 -1
  132. package/engine/pr-issue-validation.js +653 -0
  133. package/engine/pr-resolve.js +75 -13
  134. package/engine/prd-store.js +39 -6
  135. package/engine/preflight.js +7 -2
  136. package/engine/process-utils.js +79 -31
  137. package/engine/projects.js +198 -12
  138. package/engine/promotion.js +371 -0
  139. package/engine/qa-from-prd.js +3 -0
  140. package/engine/qa-process-validation.js +572 -0
  141. package/engine/qa-runbooks.js +41 -37
  142. package/engine/qa-runners/maestro.js +11 -7
  143. package/engine/qa-runners/playwright.js +37 -14
  144. package/engine/qa-runners.js +9 -10
  145. package/engine/qa-runs.js +124 -11
  146. package/engine/qa-sessions.js +139 -40
  147. package/engine/quarantine-refs.js +81 -11
  148. package/engine/queries.js +163 -9
  149. package/engine/review-learning-backfill.js +482 -0
  150. package/engine/review-learning.js +1236 -0
  151. package/engine/runtimes/claude.js +60 -2
  152. package/engine/runtimes/codex.js +57 -16
  153. package/engine/runtimes/contract.js +21 -0
  154. package/engine/runtimes/copilot.js +51 -3
  155. package/engine/runtimes/index.js +1 -0
  156. package/engine/scheduler.js +201 -32
  157. package/engine/settings-validation.js +909 -0
  158. package/engine/shared.js +705 -208
  159. package/engine/spawn-agent.js +48 -21
  160. package/engine/steering-constraints.js +31 -0
  161. package/engine/steering-store.js +10 -2
  162. package/engine/steering.js +14 -2
  163. package/engine/timeout.js +404 -111
  164. package/engine/untrusted-fence.js +24 -10
  165. package/engine/watch-actions.js +7 -2
  166. package/engine/watches.js +353 -51
  167. package/engine/work-item-validation.js +561 -0
  168. package/engine/work-items-store.js +61 -1
  169. package/engine/worktree-gc.js +656 -37
  170. package/engine/worktree-preflight.js +154 -0
  171. package/engine.js +2068 -342
  172. package/package.json +1 -1
  173. package/playbooks/_pr-description-audit.md +88 -41
  174. package/playbooks/build-fix-complex.md +3 -3
  175. package/playbooks/docs.md +2 -2
  176. package/playbooks/fix.md +18 -18
  177. package/playbooks/implement.md +18 -18
  178. package/playbooks/qa-session-draft.md +10 -12
  179. package/playbooks/qa-session-execute.md +16 -12
  180. package/playbooks/qa-validate.md +8 -7
  181. package/playbooks/shared-rules.md +45 -0
  182. package/playbooks/test.md +4 -0
  183. package/prompts/cc-system.md +1 -1
  184. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -10,12 +10,16 @@ const shared = require('./shared');
10
10
  const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, getPrLinks,
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
- const { resolveRuntime } = require('./runtimes');
13
+ const { resolveRuntime, hasUnfinishedToolCall } = require('./runtimes');
14
14
  const adoGitAuth = require('./ado-git-auth');
15
15
  const queries = require('./queries');
16
16
  const harness = require('./harness');
17
+ const reviewLearning = require('./review-learning');
17
18
  const smallStateStore = require('./small-state-store');
18
19
  const prdStore = require('./prd-store');
20
+ const features = require('./features');
21
+ const memoryStore = require('./memory-store');
22
+ const promotion = require('./promotion');
19
23
  const { isBranchActive } = require('./cooldown');
20
24
  const { resolveExecutionModel } = require('./execution-model');
21
25
  const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
@@ -2563,6 +2567,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2563
2567
  // Drop it when reviewer requests changes again — that starts a new fix cycle.
2564
2568
  ...(target.minionsReview?.fixedAt && postReviewStatus !== REVIEW_STATUS.CHANGES_REQUESTED ? { fixedAt: target.minionsReview.fixedAt } : {}),
2565
2569
  };
2570
+ target.minionsReview.findingIdentity = shared.prReviewFindingIdentity(target);
2566
2571
  updatedTarget = { ...reviewPr, ...target };
2567
2572
  return prs;
2568
2573
  }, { defaultValue: [] });
@@ -2585,6 +2590,9 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2585
2590
  project: reviewProject,
2586
2591
  dispatchId: dispatchItem?.id || structuredCompletion?.dispatchId || null,
2587
2592
  sourceItem: dispatchItem?.meta?.item?.id || null,
2593
+ postReviewStatus,
2594
+ reviewVerdict: verdictRaw,
2595
+ findingIdentity: shared.prReviewFindingIdentity(updatedTarget),
2588
2596
  });
2589
2597
  }
2590
2598
  }
@@ -3820,18 +3828,15 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeN
3820
3828
  const projectSkillRoot = skillWriteTargets(effectiveRuntime, proj).project
3821
3829
  || path.resolve(proj.localPath, '.claude', 'skills');
3822
3830
  const projectSkillPath = path.join(projectSkillRoot, skillDirName, 'SKILL.md');
3823
- let skillId = null;
3824
- mutateWorkItems('central', data => {
3825
- data = data || [];
3826
- if (data.some(i => i.title === `Add skill: ${name}` && i.status !== WI_STATUS.FAILED)) return data;
3827
- skillId = `SK${String(data.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
3828
- data.push({ id: skillId, type: WORK_TYPE.IMPLEMENT, title: `Add skill: ${name}`,
3829
- description: `Create project-level skill \`${skillDirName}/SKILL.md\` in ${project}.\n\nWrite this file to \`${projectSkillPath}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
3830
- priority: 'low', status: WI_STATUS.QUEUED, created: ts(), createdBy: `engine:skill-extraction:${agentName}` });
3831
- return data;
3831
+ const queued = promotion.queueProjectSkillWorkItem({
3832
+ config,
3833
+ projectName: project,
3834
+ skillName: name,
3835
+ description: `Create project-level skill \`${skillDirName}/SKILL.md\` in ${project}.\n\nWrite this file to \`${projectSkillPath}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
3836
+ agentName,
3832
3837
  });
3833
- if (skillId) {
3834
- log('info', `Queued work item ${skillId} to PR project skill "${name}" into ${project}`);
3838
+ if (queued && queued.created && queued.workItemId) {
3839
+ log('info', `Queued work item ${queued.workItemId} to PR project skill "${name}" into ${project}`);
3835
3840
  }
3836
3841
  }
3837
3842
  } else {
@@ -3904,93 +3909,84 @@ function updateAgentHistory(agentId, dispatchItem, result) {
3904
3909
  log('info', `Updated history for ${agentId}`);
3905
3910
  }
3906
3911
 
3907
- function reviewFeedbackSourceMatches({ fileName, content, reviewerAgentId, pr, dispatchItem, structuredCompletion }) {
3908
- if (String(fileName || '').startsWith('feedback-')) return false;
3909
- const text = String(content || '');
3910
- const scopedExpected = [
3911
- dispatchItem?.id,
3912
- dispatchItem?.meta?.item?.id,
3913
- structuredCompletion?.dispatchId,
3914
- pr?.minionsReview?.dispatchId,
3915
- pr?.minionsReview?.sourceItem,
3916
- ].filter(Boolean).map(String);
3917
- if (scopedExpected.length === 0) return true;
3918
-
3919
- const fileAndContent = `${fileName || ''}\n${text}`;
3920
- if (!scopedExpected.some(value => fileAndContent.includes(value))) {
3921
- log('warn', `Skipping review feedback source ${fileName || '(unknown)'} for ${pr?.id || 'unknown PR'}: missing current dispatch/source marker for ${reviewerAgentId}`);
3922
- return false;
3923
- }
3924
-
3925
- const prExpected = [
3926
- pr?.id,
3927
- pr?.url,
3928
- ].filter(Boolean).map(String);
3929
- if (prExpected.some(value => fileAndContent.includes(value))) return true;
3930
-
3931
- const prNumber = shared.getPrNumber(pr);
3932
- if (prNumber != null) {
3933
- const scope = shared.getPrScopeInfo(pr, pr.url || '')?.scope || shared.getProjectPrScope(dispatchItem?.meta?.project) || '';
3934
- const numberMention = new RegExp(`(?:#|PR[-\\s])${prNumber}(?!\\d)`, 'i').test(fileAndContent);
3935
- if (numberMention && (!scope || fileAndContent.toLowerCase().includes(scope.toLowerCase()))) return true;
3936
- }
3937
-
3938
- log('warn', `Skipping review feedback source ${fileName || '(unknown)'} for ${pr?.id || 'unknown PR'}: not tied to dispatch/PR scope for ${reviewerAgentId}`);
3939
- return false;
3940
- }
3941
-
3942
3912
  function createReviewFeedbackForAuthor(reviewerAgentId, pr, config, opts = {}) {
3943
3913
 
3944
- if (!pr?.id || !pr?.agent) return;
3914
+ if (!pr?.id || !pr?.agent) return null;
3945
3915
  const authorAgentId = pr.agent.toLowerCase();
3946
- if (!config.agents[authorAgentId]) return;
3947
- const today = dateStamp();
3916
+ if (!config.agents?.[authorAgentId]) return null;
3917
+ if (opts.reviewVerdict !== REVIEW_STATUS.CHANGES_REQUESTED) return null;
3918
+ if (!features.isFeatureOn('review-learning-capture', config)) return null;
3919
+
3948
3920
  const project = opts.project || opts.dispatchItem?.meta?.project || null;
3949
- let reviewContent = String(opts.reviewContent || '').trim();
3950
- if (reviewContent) {
3951
- if (!reviewContentMatchesPr(reviewContent, pr, project)) {
3952
- log('warn', `Skipped review feedback for ${pr.id}: review content references a different PR`);
3953
- return;
3954
- }
3955
- } else {
3956
- const inboxFiles = getInboxFiles();
3957
- const reviewFiles = inboxFiles.filter(f => f.includes(reviewerAgentId) && f.includes(today));
3958
- if (reviewFiles.length === 0) return;
3959
- const matchedReviewContent = [];
3960
- for (const f of reviewFiles) {
3961
- const content = safeRead(path.join(INBOX_DIR, f));
3962
- if (!content) continue;
3963
- if (!reviewFeedbackSourceMatches({
3964
- fileName: f,
3965
- content,
3966
- reviewerAgentId,
3967
- pr,
3968
- dispatchItem: opts.dispatchItem,
3969
- structuredCompletion: opts.structuredCompletion,
3970
- })) continue;
3971
- matchedReviewContent.push(content);
3972
- }
3973
- if (matchedReviewContent.length === 0) return;
3974
- reviewContent = matchedReviewContent.join('\n\n');
3975
- }
3976
- const prSlug = shared.safeSlugComponent(pr.id, 60);
3977
- const content = `# Review Feedback for ${config.agents[authorAgentId]?.name || authorAgentId}\n\n` +
3978
- `**PR:** ${pr.id} — ${pr.title || ''}\n` +
3979
- `**Reviewer:** ${config.agents[reviewerAgentId]?.name || reviewerAgentId}\n` +
3980
- `**Date:** ${today}\n\n` +
3981
- `## What the reviewer found\n\n${reviewContent}\n\n` +
3982
- `## Action Required\n\nRead this feedback carefully. When you work on similar tasks in the future, ` +
3983
- `avoid the patterns flagged here. If you are assigned to fix this PR, ` +
3984
- `address every point raised above.\n`;
3985
- shared.writeToInbox('feedback', `${authorAgentId}-from-${reviewerAgentId}-${prSlug}`, content, null, {
3986
- sourcePr: pr.id,
3987
- reviewer: reviewerAgentId,
3988
- author: authorAgentId,
3989
- dispatchId: opts.dispatchId || opts.dispatchItem?.id || opts.structuredCompletion?.dispatchId || null,
3990
- sourceItem: opts.sourceItem || opts.dispatchItem?.meta?.item?.id || null,
3991
- project: project?.name || null,
3921
+ if (!project?.name) {
3922
+ log('warn', `Skipped review-learning candidate for ${pr.id}: project scope is unavailable`);
3923
+ return null;
3924
+ }
3925
+
3926
+ const reviewer = String(reviewerAgentId || '').trim().toLowerCase();
3927
+ const findingIdentity = String(opts.findingIdentity || shared.prReviewFindingIdentity(pr)).trim().toLowerCase();
3928
+ const prId = shared.getCanonicalPrId(project, pr, pr.url || '');
3929
+ const host = String(prId).slice(0, String(prId).indexOf(':')).toLowerCase();
3930
+ const dispatchId = opts.dispatchId || opts.dispatchItem?.id || opts.structuredCompletion?.dispatchId || null;
3931
+ const sourceItem = opts.sourceItem || opts.dispatchItem?.meta?.item?.id || null;
3932
+ const sourceHash = reviewLearning.hashReviewCommentContent(opts.reviewContent || '');
3933
+ const findingKey = reviewLearning.buildFindingKey({
3934
+ project: project.name,
3935
+ prId,
3936
+ sourceKey: `${host}:review-finding:${findingIdentity}`,
3937
+ });
3938
+ const priorCandidate = memoryStore.listMemoryRecords({
3939
+ sourceType: reviewLearning.REVIEW_LEARNING_SOURCE_TYPE,
3940
+ findingKey,
3941
+ limit: 1,
3942
+ })[0] || null;
3943
+ const priorEvents = memoryStore.listMemoryLifecycleEvents({
3944
+ findingKey,
3945
+ limit: 500,
3992
3946
  });
3993
- log('info', `Created review feedback for ${authorAgentId} from ${reviewerAgentId} on ${pr.id}`);
3947
+ const candidate = reviewLearning.upsertReviewLearningCandidate({
3948
+ findingKey,
3949
+ project: project.name,
3950
+ prId,
3951
+ authorAgent: authorAgentId,
3952
+ reviewerAgent: reviewer,
3953
+ sourceHash,
3954
+ workItemId: sourceItem,
3955
+ dispatchId,
3956
+ metadata: {
3957
+ findingIdentity,
3958
+ postReviewStatus: opts.postReviewStatus || null,
3959
+ reviewVerdict: REVIEW_STATUS.CHANGES_REQUESTED,
3960
+ },
3961
+ });
3962
+ let eventType = 'candidate-created';
3963
+ const hasCreationEvent = priorEvents.some(event => event.eventType === 'candidate-created');
3964
+ const hasCurrentSourceEvent = priorEvents.some(event => (
3965
+ (event.eventType === 'candidate-created' || event.eventType === 'candidate-updated')
3966
+ && event.metadata.sourceHash === candidate.metadata.sourceHash
3967
+ ));
3968
+ if (priorCandidate && hasCreationEvent && candidate.status === 'candidate'
3969
+ && (candidate.metadata.sourceHash !== priorCandidate.metadata.sourceHash || !hasCurrentSourceEvent)) {
3970
+ eventType = 'candidate-updated';
3971
+ }
3972
+ memoryStore.recordMemoryLifecycleEvent({
3973
+ eventType,
3974
+ memoryId: candidate.id,
3975
+ findingKey,
3976
+ project: project.name,
3977
+ agent: authorAgentId,
3978
+ workItemId: candidate.metadata.workItemId || null,
3979
+ dispatchId: candidate.metadata.dispatchId || null,
3980
+ identity: eventType === 'candidate-updated'
3981
+ ? candidate.metadata.sourceHash
3982
+ : (candidate.metadata.findingIdentity || findingIdentity),
3983
+ metadata: {
3984
+ findingIdentity: candidate.metadata.findingIdentity || findingIdentity,
3985
+ sourceHash: candidate.metadata.sourceHash,
3986
+ },
3987
+ });
3988
+ log('info', `Upserted review-learning candidate ${candidate.id} for ${authorAgentId} from ${reviewer} on ${pr.id}`);
3989
+ return candidate;
3994
3990
  }
3995
3991
 
3996
3992
  function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount, model) {
@@ -4185,6 +4181,720 @@ function handleInjectionFlag(dispatchItem, agentId, structuredCompletion, config
4185
4181
  return { description, sources, at };
4186
4182
  }
4187
4183
 
4184
+ function _canonicalReviewLearningSourceKey(value, host, fallbackKind = 'comment') {
4185
+ if (typeof value !== 'string' || !value.trim() || !host) return null;
4186
+ const raw = value.trim().toLowerCase();
4187
+ const candidates = [raw];
4188
+ if (!raw.startsWith(`${host}:`)) {
4189
+ if (host === 'github' && /^(?:issue|review|review-comment|issue-comment):/.test(raw)) {
4190
+ candidates.push(`${host}:${raw}`);
4191
+ } else if (host === 'ado' && /^\d+:\d+$/.test(raw)) {
4192
+ const [threadId, commentId] = raw.split(':');
4193
+ candidates.push(`${host}:thread:${threadId}:comment:${commentId}`);
4194
+ } else {
4195
+ candidates.push(`${host}:${fallbackKind}:${raw}`);
4196
+ }
4197
+ }
4198
+ for (const candidate of candidates) {
4199
+ try {
4200
+ return reviewLearning.normalizeFindingSourceKey(candidate);
4201
+ } catch {}
4202
+ }
4203
+ return null;
4204
+ }
4205
+
4206
+ function buildReviewLearningCompletionContext(dispatchItem, agentId) {
4207
+ const meta = dispatchItem?.meta || {};
4208
+ const pr = meta.pr;
4209
+ const project = meta.project;
4210
+ const canonicalPr = shared.getCanonicalPrId(project, pr, pr?.url || '');
4211
+ const prId = reviewLearning.normalizeCanonicalPrId(canonicalPr);
4212
+ const normalizedProject = reviewLearning.normalizeProjectKey(project?.name || meta.item?.project);
4213
+ const authorAgent = pr?.agent && String(pr.agent).toLowerCase() !== 'human'
4214
+ ? pr.agent
4215
+ : (meta.item?.authorAgent || dispatchItem?.agent || agentId);
4216
+ const host = prId.slice(0, prId.indexOf(':'));
4217
+ const allowedSourceKeys = new Set();
4218
+ const addSourceKey = (value, fallbackKind) => {
4219
+ const sourceKey = _canonicalReviewLearningSourceKey(value, host, fallbackKind);
4220
+ if (sourceKey) allowedSourceKeys.add(sourceKey);
4221
+ };
4222
+ const addSourceEntries = (entries) => {
4223
+ if (!Array.isArray(entries)) return;
4224
+ for (const entry of entries) {
4225
+ if (typeof entry === 'string') {
4226
+ addSourceKey(entry);
4227
+ continue;
4228
+ }
4229
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
4230
+ addSourceKey(entry.sourceKey || entry.commentKey || entry.key || entry.id);
4231
+ }
4232
+ };
4233
+
4234
+ const findingIds = [
4235
+ meta.reviewFindingId,
4236
+ meta.review_finding_id,
4237
+ pr?.minionsReview ? shared.prReviewFindingIdentity(pr) : null,
4238
+ ].filter(value => typeof value === 'string' && value.trim());
4239
+ for (const findingId of findingIds) addSourceKey(`${host}:review-finding:${findingId}`, 'review-finding');
4240
+
4241
+ addSourceEntries(meta.reviewLearningSourceKeys);
4242
+ addSourceEntries(meta.commentSources);
4243
+ addSourceEntries(meta.item?.meta?.commentSources);
4244
+ addSourceEntries(pr?.humanFeedback?.commentSources);
4245
+ addSourceKey(pr?.humanFeedback?.lastProcessedCommentKey);
4246
+ if (!pr?.humanFeedback?.lastProcessedCommentKey) {
4247
+ addSourceKey(pr?.humanFeedback?.lastProcessedCommentId);
4248
+ }
4249
+
4250
+ return {
4251
+ project: normalizedProject,
4252
+ prId,
4253
+ authorAgent,
4254
+ allowedSourceKeys,
4255
+ };
4256
+ }
4257
+
4258
+ function suppressReviewLearningCompletionPayload(structuredCompletion) {
4259
+ if (!structuredCompletion || typeof structuredCompletion !== 'object') return;
4260
+ delete structuredCompletion.reviewLearnings;
4261
+ delete structuredCompletion.reviewLearningApplications;
4262
+ delete structuredCompletion._reviewLearningDiagnostics;
4263
+ }
4264
+
4265
+ function normalizeReviewLearningCompletionPayload(dispatchItem, agentId, structuredCompletion) {
4266
+ const diagnostics = [];
4267
+ if (!structuredCompletion || typeof structuredCompletion !== 'object') {
4268
+ return { structuredCompletion, diagnostics };
4269
+ }
4270
+ const rejectField = (field, err) => {
4271
+ delete structuredCompletion[field];
4272
+ const detail = reviewLearning.redactBoundedText(
4273
+ err?.message || String(err),
4274
+ 500,
4275
+ `${field} diagnostic`,
4276
+ );
4277
+ diagnostics.push(`${field} rejected: ${detail || 'invalid payload'}`);
4278
+ };
4279
+
4280
+ if (Object.prototype.hasOwnProperty.call(structuredCompletion, 'reviewLearnings')) {
4281
+ try {
4282
+ const context = buildReviewLearningCompletionContext(dispatchItem, agentId);
4283
+ structuredCompletion.reviewLearnings = reviewLearning.normalizeCompletionReviewLearnings(
4284
+ structuredCompletion.reviewLearnings,
4285
+ context,
4286
+ );
4287
+ } catch (err) {
4288
+ rejectField('reviewLearnings', err);
4289
+ }
4290
+ }
4291
+ if (Object.prototype.hasOwnProperty.call(structuredCompletion, 'reviewLearningApplications')) {
4292
+ try {
4293
+ structuredCompletion.reviewLearningApplications =
4294
+ reviewLearning.normalizeCompletionReviewLearningApplications(
4295
+ structuredCompletion.reviewLearningApplications,
4296
+ );
4297
+ } catch (err) {
4298
+ rejectField('reviewLearningApplications', err);
4299
+ }
4300
+ }
4301
+ if (diagnostics.length) structuredCompletion._reviewLearningDiagnostics = diagnostics;
4302
+ return { structuredCompletion, diagnostics };
4303
+ }
4304
+
4305
+ function recordReviewLearningCompletionDiagnostics(dispatchItem, agentId, diagnostics) {
4306
+ if (!Array.isArray(diagnostics) || diagnostics.length === 0) return;
4307
+ const messages = diagnostics.slice(0, reviewLearning.REVIEW_LEARNING_BOUNDS.diagnostics)
4308
+ .map(message => reviewLearning.redactBoundedText(
4309
+ message,
4310
+ reviewLearning.REVIEW_LEARNING_BOUNDS.diagnosticBytes,
4311
+ 'review-learning completion diagnostic',
4312
+ ));
4313
+ for (const message of messages) {
4314
+ log('warn', `Review-learning completion diagnostic for ${dispatchItem?.id || 'unknown'}: ${message}`);
4315
+ }
4316
+ try {
4317
+ const wiScope = resolveWorkItemScope(dispatchItem?.meta)
4318
+ || dispatchItem?.meta?.project?.name
4319
+ || null;
4320
+ const itemId = dispatchItem?.meta?.item?.id;
4321
+ if (!wiScope || !itemId) return;
4322
+ mutateWorkItems(wiScope, (items) => {
4323
+ if (!Array.isArray(items)) return items;
4324
+ const item = items.find(candidate => candidate.id === itemId);
4325
+ if (item) {
4326
+ const priorMessages = Array.isArray(item._reviewLearningDiagnostics?.messages)
4327
+ ? item._reviewLearningDiagnostics.messages
4328
+ : [];
4329
+ item._reviewLearningDiagnostics = {
4330
+ at: ts(),
4331
+ dispatchId: dispatchItem.id || null,
4332
+ agent: agentId || dispatchItem.agent || null,
4333
+ messages: [...new Set([...priorMessages, ...messages])]
4334
+ .slice(-reviewLearning.REVIEW_LEARNING_BOUNDS.diagnostics),
4335
+ };
4336
+ }
4337
+ return items;
4338
+ });
4339
+ } catch (err) {
4340
+ log('warn', `Could not persist review-learning completion diagnostic: ${err.message}`);
4341
+ }
4342
+ }
4343
+
4344
+ function _groupReviewLearningEntries(entries) {
4345
+ const groups = new Map();
4346
+ for (const entry of entries) {
4347
+ const findingKey = reviewLearning.buildFindingKey({
4348
+ project: entry.project,
4349
+ prId: entry.source.pr,
4350
+ sourceKey: entry.source.sourceKey,
4351
+ });
4352
+ let group = groups.get(findingKey);
4353
+ if (!group) {
4354
+ group = {
4355
+ findingKey,
4356
+ project: entry.project,
4357
+ prId: entry.source.pr,
4358
+ sourceKey: entry.source.sourceKey,
4359
+ authorAgent: entry.authorAgent,
4360
+ disposition: entry.disposition,
4361
+ audience: entry.applicability,
4362
+ rules: [],
4363
+ files: [],
4364
+ symbols: [],
4365
+ tags: [],
4366
+ evidence: [],
4367
+ confidences: [],
4368
+ };
4369
+ groups.set(findingKey, group);
4370
+ }
4371
+ if (group.disposition !== entry.disposition) {
4372
+ group.error = `review learning source ${entry.source.sourceKey} has conflicting dispositions`;
4373
+ }
4374
+ if (group.audience !== entry.applicability) {
4375
+ group.error = `review learning source ${entry.source.sourceKey} has conflicting audiences`;
4376
+ }
4377
+ group.rules.push(entry.rule);
4378
+ group.files.push(...entry.files);
4379
+ group.symbols.push(...entry.symbols);
4380
+ group.tags.push(...entry.tags);
4381
+ group.evidence.push(...entry.evidence);
4382
+ group.confidences.push(entry.confidence);
4383
+ }
4384
+ return [...groups.values()].map((group) => {
4385
+ const facets = reviewLearning.normalizeReviewFacets(group);
4386
+ return {
4387
+ ...group,
4388
+ rules: [...new Set(group.rules)].sort((a, b) => a.localeCompare(b)),
4389
+ files: facets.files,
4390
+ symbols: facets.symbols,
4391
+ tags: facets.tags,
4392
+ area: facets.area,
4393
+ evidence: [...new Set(group.evidence)].sort((a, b) => a.localeCompare(b)),
4394
+ confidence: Math.min(...group.confidences),
4395
+ };
4396
+ }).sort((a, b) => a.findingKey.localeCompare(b.findingKey));
4397
+ }
4398
+
4399
+ function _acceptedFixedEvidence(dispatchItem, branchChange, structuredCompletion) {
4400
+ if (shared.isFixLikeWorkType(dispatchItem?.type)
4401
+ && branchChange?.changed === true
4402
+ && branchChange.evidence === 'remote-head'
4403
+ && String(branchChange.afterHead || '').trim()) {
4404
+ return {
4405
+ verifiedBy: 'remote-head',
4406
+ verifiedHeadSha: String(branchChange.afterHead).trim(),
4407
+ };
4408
+ }
4409
+ if (dispatchItem?.type !== WORK_TYPE.REVIEW
4410
+ || reviewVerdictFromCompletion(structuredCompletion) !== REVIEW_STATUS.APPROVED) {
4411
+ return null;
4412
+ }
4413
+ const reviewerAgent = String(dispatchItem?.agent || '').trim().toLowerCase();
4414
+ const authorAgent = String(dispatchItem?.meta?.pr?.agent || '').trim().toLowerCase();
4415
+ if (reviewerAgent && authorAgent && reviewerAgent === authorAgent) return null;
4416
+ const reviewedHeadSha = String(dispatchItem?.meta?.reviewHeadSha || '').trim();
4417
+ const currentHeadSha = getPrFixBaselineHead(dispatchItem?.meta?.pr);
4418
+ if (!reviewedHeadSha || !currentHeadSha || reviewedHeadSha !== currentHeadSha) return null;
4419
+ return { verifiedBy: 'approved-current-head', verifiedHeadSha: currentHeadSha };
4420
+ }
4421
+
4422
+ function _followUpForReviewSource(sourceKey, followups) {
4423
+ const source = String(sourceKey || '').trim().toLowerCase();
4424
+ const aliases = new Set([source, source.slice(source.lastIndexOf(':') + 1)]);
4425
+ const ado = source.match(/^ado:thread:([^:]+):comment:([^:]+)$/);
4426
+ if (ado) aliases.add(`${ado[1]}:${ado[2]}`);
4427
+ const matches = [];
4428
+ for (const entry of Array.isArray(followups) ? followups : []) {
4429
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
4430
+ const parent = String(entry.parent_comment_id ?? entry.parentCommentId ?? '').trim().toLowerCase();
4431
+ const workItemId = String(entry.wi_id ?? entry.wiId ?? '').trim();
4432
+ if (!aliases.has(parent) || !/^[a-z0-9][a-z0-9._:-]{0,319}$/i.test(workItemId)) continue;
4433
+ matches.push({ workItemId, parent });
4434
+ }
4435
+ matches.sort((a, b) => a.workItemId.localeCompare(b.workItemId));
4436
+ return matches[0] || null;
4437
+ }
4438
+
4439
+ function _captureReviewLearningDiagnostic(result, sourceKey, error) {
4440
+ if (result.diagnostics.length >= reviewLearning.REVIEW_LEARNING_BOUNDS.diagnostics) return;
4441
+ const detail = reviewLearning.redactBoundedText(
4442
+ error?.message || String(error),
4443
+ 500,
4444
+ 'review-learning capture failure',
4445
+ );
4446
+ const prefix = sourceKey ? `Review-learning capture for ${sourceKey} failed: ` : 'Review-learning capture failed: ';
4447
+ result.diagnostics.push(reviewLearning.redactBoundedText(
4448
+ `${prefix}${detail || 'unknown persistence error'}`,
4449
+ reviewLearning.REVIEW_LEARNING_BOUNDS.diagnosticBytes,
4450
+ 'review-learning capture diagnostic',
4451
+ ));
4452
+ }
4453
+
4454
+ function _surfaceReviewLearningCaptureDiagnostics(
4455
+ dispatchItem,
4456
+ agentId,
4457
+ structuredCompletion,
4458
+ diagnostics,
4459
+ ) {
4460
+ if (!diagnostics.length) return;
4461
+ structuredCompletion._reviewLearningDiagnostics = [...new Set([
4462
+ ...(Array.isArray(structuredCompletion._reviewLearningDiagnostics)
4463
+ ? structuredCompletion._reviewLearningDiagnostics
4464
+ : []),
4465
+ ...diagnostics,
4466
+ ])].slice(-reviewLearning.REVIEW_LEARNING_BOUNDS.diagnostics);
4467
+ recordReviewLearningCompletionDiagnostics(dispatchItem, agentId, diagnostics);
4468
+ }
4469
+
4470
+ function _recordReviewLearningCreatedEvent(lesson, group, provenance) {
4471
+ return memoryStore.recordMemoryLifecycleEvent({
4472
+ eventType: 'created',
4473
+ memoryId: lesson.id,
4474
+ findingKey: group.findingKey,
4475
+ project: group.project,
4476
+ agent: group.authorAgent,
4477
+ area: group.area,
4478
+ disposition: group.disposition,
4479
+ identity: lesson.id,
4480
+ metadata: {
4481
+ sourceKey: group.sourceKey,
4482
+ prId: group.prId,
4483
+ audience: group.audience,
4484
+ workItemId: provenance.workItemId,
4485
+ dispatchId: provenance.dispatchId,
4486
+ verifiedBy: provenance.verifiedBy,
4487
+ verifiedHeadSha: provenance.verifiedHeadSha,
4488
+ },
4489
+ });
4490
+ }
4491
+
4492
+ function _recordRepeatedFindingEvent(lesson, group, match, provenance) {
4493
+ return memoryStore.recordMemoryLifecycleEvent({
4494
+ eventType: 'repeated-finding',
4495
+ memoryId: lesson.id,
4496
+ findingKey: group.findingKey,
4497
+ project: group.project,
4498
+ agent: group.authorAgent,
4499
+ area: group.area,
4500
+ disposition: group.disposition,
4501
+ identity: match.lessonId,
4502
+ metadata: {
4503
+ priorLessonId: match.lessonId,
4504
+ matchReason: match.reason,
4505
+ sourceKey: group.sourceKey,
4506
+ workItemId: provenance.workItemId,
4507
+ dispatchId: provenance.dispatchId,
4508
+ },
4509
+ });
4510
+ }
4511
+
4512
+ function captureVerifiedReviewLearnings({
4513
+ dispatchItem,
4514
+ agentId,
4515
+ config,
4516
+ structuredCompletion,
4517
+ branchChange,
4518
+ reviewFixResolution,
4519
+ } = {}) {
4520
+ const result = { lessons: [], transitions: [], repeated: [], diagnostics: [] };
4521
+ const rawLearnings = structuredCompletion?.reviewLearnings;
4522
+ const supportedType = dispatchItem?.type === WORK_TYPE.REVIEW
4523
+ || shared.isFixLikeWorkType(dispatchItem?.type);
4524
+ if (!supportedType || !Array.isArray(rawLearnings) || rawLearnings.length === 0) return result;
4525
+ if (!features.isFeatureOn('review-learning-capture', config || {})) return result;
4526
+
4527
+ let groups;
4528
+ try {
4529
+ const context = buildReviewLearningCompletionContext(dispatchItem, agentId);
4530
+ // Revalidate at the persistence boundary so future callers cannot bypass source binding.
4531
+ const normalized = reviewLearning.normalizeCompletionReviewLearnings(rawLearnings, context);
4532
+ groups = _groupReviewLearningEntries(normalized);
4533
+ } catch (err) {
4534
+ _captureReviewLearningDiagnostic(result, null, err);
4535
+ _surfaceReviewLearningCaptureDiagnostics(
4536
+ dispatchItem,
4537
+ agentId,
4538
+ structuredCompletion,
4539
+ result.diagnostics,
4540
+ );
4541
+ return result;
4542
+ }
4543
+
4544
+ let priorLessons;
4545
+ try {
4546
+ priorLessons = memoryStore.listActiveReviewLearnings({
4547
+ project: groups[0]?.project,
4548
+ limit: 500,
4549
+ });
4550
+ } catch (err) {
4551
+ _captureReviewLearningDiagnostic(result, null, err);
4552
+ _surfaceReviewLearningCaptureDiagnostics(
4553
+ dispatchItem,
4554
+ agentId,
4555
+ structuredCompletion,
4556
+ result.diagnostics,
4557
+ );
4558
+ return result;
4559
+ }
4560
+ const workItemId = dispatchItem?.meta?.item?.id || null;
4561
+ const dispatchId = dispatchItem?.id || null;
4562
+ const acceptedFixedEvidence = _acceptedFixedEvidence(
4563
+ dispatchItem,
4564
+ branchChange,
4565
+ structuredCompletion,
4566
+ );
4567
+
4568
+ for (const group of groups) {
4569
+ try {
4570
+ if (group.error) throw new TypeError(group.error);
4571
+ const evidence = [...group.evidence];
4572
+ const resolutionFindingId = String(reviewFixResolution?.findingId || '').trim().toLowerCase();
4573
+ const resolutionSourceKey = resolutionFindingId
4574
+ ? `${group.prId.slice(0, group.prId.indexOf(':'))}:review-finding:${resolutionFindingId}`
4575
+ : '';
4576
+ if (resolutionSourceKey === group.sourceKey && reviewFixResolution?.currentCodeEvidence) {
4577
+ evidence.push(String(reviewFixResolution.currentCodeEvidence));
4578
+ }
4579
+ const evidenceKinds = reviewLearning.classifyExactEvidence(evidence);
4580
+ let proof = null;
4581
+ if (group.disposition === reviewLearning.REVIEW_LEARNING_DISPOSITIONS.ACCEPTED_FIXED) {
4582
+ proof = acceptedFixedEvidence;
4583
+ if (!proof) {
4584
+ throw new TypeError('accepted-fixed requires a verified remote branch advancement or approved current head');
4585
+ }
4586
+ } else if (group.disposition === reviewLearning.REVIEW_LEARNING_DISPOSITIONS.SATISFIED_ELSEWHERE) {
4587
+ if (evidenceKinds.length === 0) {
4588
+ throw new TypeError('satisfied-elsewhere requires exact current-code, commit, or test evidence');
4589
+ }
4590
+ proof = { verifiedBy: `exact-${evidenceKinds.join('+')}`, verifiedHeadSha: null };
4591
+ } else if ((group.disposition === reviewLearning.REVIEW_LEARNING_DISPOSITIONS.INVALID
4592
+ || group.disposition === reviewLearning.REVIEW_LEARNING_DISPOSITIONS.STALE)
4593
+ && evidenceKinds.length === 0) {
4594
+ throw new TypeError(`${group.disposition} requires exact trace evidence`);
4595
+ }
4596
+
4597
+ const provenance = {
4598
+ sourceKey: group.sourceKey,
4599
+ workItemId,
4600
+ dispatchId,
4601
+ captureAgent: agentId || dispatchItem?.agent || null,
4602
+ verifiedBy: proof?.verifiedBy || (evidenceKinds.length ? `exact-${evidenceKinds.join('+')}` : 'disposition'),
4603
+ verifiedHeadSha: proof?.verifiedHeadSha || null,
4604
+ evidenceKinds,
4605
+ };
4606
+ const positive = reviewLearning.POSITIVE_DISPOSITIONS.has(group.disposition);
4607
+ if (!positive) {
4608
+ const transitionOptions = {
4609
+ evidence: group.evidence.slice(0, reviewLearning.REVIEW_LEARNING_BOUNDS.transitionEvidence),
4610
+ evidenceKinds,
4611
+ workItemId,
4612
+ dispatchId,
4613
+ };
4614
+ if (group.disposition === reviewLearning.REVIEW_LEARNING_DISPOSITIONS.OUT_OF_SCOPE_FOLLOW_UP) {
4615
+ const followUp = _followUpForReviewSource(group.sourceKey, structuredCompletion.followups);
4616
+ if (!followUp) {
4617
+ throw new TypeError('out-of-scope-follow-up requires a linked follow-up work item');
4618
+ }
4619
+ transitionOptions.followUpWorkItemId = followUp.workItemId;
4620
+ transitionOptions.followUpSourceKey = group.sourceKey;
4621
+ transitionOptions.followUpParentCommentId = followUp.parent;
4622
+ }
4623
+ result.transitions.push(...reviewLearning.transitionReviewLearningCandidate(
4624
+ group.findingKey,
4625
+ group.disposition,
4626
+ transitionOptions,
4627
+ ));
4628
+ continue;
4629
+ }
4630
+
4631
+ const repeatedMatch = reviewLearning.findDeterministicRepeatedLessonMatch(
4632
+ group,
4633
+ priorLessons.filter(record => record.sourceRef !== group.findingKey),
4634
+ );
4635
+ const lesson = reviewLearning.upsertReviewLearningLesson({
4636
+ findingKey: group.findingKey,
4637
+ project: group.project,
4638
+ prId: group.prId,
4639
+ authorAgent: group.authorAgent,
4640
+ audience: group.audience,
4641
+ disposition: group.disposition,
4642
+ rules: group.rules,
4643
+ files: group.files,
4644
+ symbols: group.symbols,
4645
+ tags: group.tags,
4646
+ evidence: group.evidence,
4647
+ confidence: group.confidence,
4648
+ metadata: provenance,
4649
+ });
4650
+ result.lessons.push(lesson);
4651
+ result.transitions.push(...reviewLearning.transitionReviewLearningCandidate(
4652
+ group.findingKey,
4653
+ group.disposition,
4654
+ {
4655
+ lessonId: lesson.id,
4656
+ evidence: group.evidence.slice(0, reviewLearning.REVIEW_LEARNING_BOUNDS.transitionEvidence),
4657
+ evidenceKinds,
4658
+ workItemId,
4659
+ dispatchId,
4660
+ verifiedBy: provenance.verifiedBy,
4661
+ verifiedHeadSha: provenance.verifiedHeadSha,
4662
+ },
4663
+ ));
4664
+ try {
4665
+ _recordReviewLearningCreatedEvent(lesson, group, provenance);
4666
+ } catch (err) {
4667
+ _captureReviewLearningDiagnostic(result, group.sourceKey, err);
4668
+ }
4669
+ if (repeatedMatch) {
4670
+ try {
4671
+ const event = _recordRepeatedFindingEvent(
4672
+ lesson,
4673
+ group,
4674
+ repeatedMatch,
4675
+ provenance,
4676
+ );
4677
+ result.repeated.push(event);
4678
+ } catch (err) {
4679
+ _captureReviewLearningDiagnostic(result, group.sourceKey, err);
4680
+ }
4681
+ }
4682
+ } catch (err) {
4683
+ _captureReviewLearningDiagnostic(result, group.sourceKey, err);
4684
+ }
4685
+ }
4686
+
4687
+ _surfaceReviewLearningCaptureDiagnostics(
4688
+ dispatchItem,
4689
+ agentId,
4690
+ structuredCompletion,
4691
+ result.diagnostics,
4692
+ );
4693
+ return result;
4694
+ }
4695
+
4696
+ // P-bfc75585 — Application & contradiction lifecycle. Process
4697
+ // `reviewLearningApplications` from a completion, but only against lesson IDs
4698
+ // this exact dispatch was shown (the trusted retrieval trace) and only for
4699
+ // lessons in the dispatch's own project. Emits idempotent applied /
4700
+ // considered-not-applicable / contradicted events; an evidence-backed
4701
+ // contradiction additionally quarantines the lesson through the retracted
4702
+ // lifecycle and raises an operator alert. `considered-not-applicable` and
4703
+ // `applied` are task-local and never mutate the lesson.
4704
+ function _resolveDispatchProject(dispatchItem) {
4705
+ const meta = dispatchItem?.meta || {};
4706
+ const raw = meta.project?.name || meta.item?.project || '';
4707
+ if (!raw) return '';
4708
+ try {
4709
+ return reviewLearning.normalizeProjectKey(raw);
4710
+ } catch {
4711
+ return '';
4712
+ }
4713
+ }
4714
+
4715
+ function processReviewLearningApplications({
4716
+ dispatchItem,
4717
+ agentId,
4718
+ config,
4719
+ structuredCompletion,
4720
+ } = {}) {
4721
+ const result = {
4722
+ applied: [],
4723
+ notApplicable: [],
4724
+ contradicted: [],
4725
+ retracted: [],
4726
+ diagnostics: [],
4727
+ };
4728
+ const rawApplications = structuredCompletion?.reviewLearningApplications;
4729
+ if (!Array.isArray(rawApplications) || rawApplications.length === 0) return result;
4730
+ // Applications describe how recalled lessons fared, so they are only
4731
+ // meaningful when the recall path is active.
4732
+ if (!features.isFeatureOn('review-learning-recall', config || {})) return result;
4733
+
4734
+ let applications;
4735
+ try {
4736
+ // Revalidate at the persistence boundary so no caller can bypass bounds.
4737
+ applications = reviewLearning.normalizeCompletionReviewLearningApplications(rawApplications);
4738
+ } catch (err) {
4739
+ _captureReviewLearningDiagnostic(result, null, err);
4740
+ _surfaceReviewLearningCaptureDiagnostics(dispatchItem, agentId, structuredCompletion, result.diagnostics);
4741
+ return result;
4742
+ }
4743
+
4744
+ const project = _resolveDispatchProject(dispatchItem);
4745
+ if (!project) {
4746
+ _captureReviewLearningDiagnostic(result, null, new TypeError('review learning applications require a resolved project'));
4747
+ _surfaceReviewLearningCaptureDiagnostics(dispatchItem, agentId, structuredCompletion, result.diagnostics);
4748
+ return result;
4749
+ }
4750
+ const dispatchId = dispatchItem?.id || null;
4751
+ const workItemId = dispatchItem?.meta?.item?.id || null;
4752
+ const applyingAgent = agentId || dispatchItem?.agent || null;
4753
+
4754
+ // Trusted binding: only the lesson IDs actually recalled for THIS dispatch
4755
+ // may be reported on. An agent cannot report application (or trigger a
4756
+ // contradiction) for a lesson it was never shown.
4757
+ const shownIds = new Set();
4758
+ try {
4759
+ const trace = memoryStore.getLatestRetrievalTrace({
4760
+ dispatchId,
4761
+ retrievalKind: 'review-learning',
4762
+ project,
4763
+ });
4764
+ for (const id of trace?.selectedIds || []) shownIds.add(String(id).trim());
4765
+ } catch (err) {
4766
+ _captureReviewLearningDiagnostic(result, null, err);
4767
+ }
4768
+
4769
+ for (const application of applications) {
4770
+ try {
4771
+ if (!shownIds.has(application.id)) {
4772
+ throw new TypeError(`review learning ${application.id} was not shown to this dispatch`);
4773
+ }
4774
+ const lesson = memoryStore.getMemoryRecord(application.id);
4775
+ if (!lesson || lesson.sourceType !== reviewLearning.REVIEW_LEARNING_SOURCE_TYPE) {
4776
+ throw new TypeError(`review learning ${application.id} is not a review-learning record`);
4777
+ }
4778
+ if (lesson.scopeKey !== project) {
4779
+ throw new TypeError(
4780
+ `review learning ${application.id} belongs to project ${lesson.scopeKey}, not ${project}`,
4781
+ );
4782
+ }
4783
+ const findingKey = lesson.sourceRef;
4784
+ const area = lesson.metadata?.area || 'general';
4785
+ const disposition = lesson.metadata?.disposition || null;
4786
+
4787
+ let evidenceKinds = [];
4788
+ if (application.outcome === 'contradicted') {
4789
+ evidenceKinds = reviewLearning.classifyExactEvidence([application.evidence]);
4790
+ if (!evidenceKinds.includes('current-code')) {
4791
+ throw new TypeError('contradicted requires exact current-code evidence');
4792
+ }
4793
+ }
4794
+
4795
+ const event = memoryStore.recordMemoryLifecycleEvent({
4796
+ eventType: application.outcome,
4797
+ memoryId: lesson.id,
4798
+ findingKey,
4799
+ project,
4800
+ agent: applyingAgent,
4801
+ area,
4802
+ disposition,
4803
+ workItemId,
4804
+ dispatchId,
4805
+ identity: lesson.id,
4806
+ metadata: {
4807
+ outcome: application.outcome,
4808
+ evidence: application.evidence,
4809
+ ...(evidenceKinds.length ? { evidenceKinds } : {}),
4810
+ },
4811
+ });
4812
+ if (application.outcome === 'applied') result.applied.push(event);
4813
+ else if (application.outcome === 'considered-not-applicable') result.notApplicable.push(event);
4814
+ else result.contradicted.push(event);
4815
+
4816
+ // Only a scoped, evidence-backed contradiction mutates the lesson:
4817
+ // quarantine it through the retracted lifecycle (provenance retained) and
4818
+ // raise an operator alert. considered-not-applicable / applied leave the
4819
+ // lesson untouched (task-local).
4820
+ if (application.outcome === 'contradicted') {
4821
+ const retraction = memoryStore.retractReviewLearningLesson(lesson.id, {
4822
+ reason: 'contradicted',
4823
+ contradictedBy: applyingAgent,
4824
+ dispatchId,
4825
+ workItemId,
4826
+ evidenceKinds,
4827
+ });
4828
+ const retractedEvent = memoryStore.recordMemoryLifecycleEvent({
4829
+ eventType: 'retracted',
4830
+ memoryId: lesson.id,
4831
+ findingKey,
4832
+ project,
4833
+ agent: applyingAgent,
4834
+ area,
4835
+ disposition,
4836
+ workItemId,
4837
+ dispatchId,
4838
+ identity: lesson.id,
4839
+ metadata: { reason: 'contradicted', evidenceKinds },
4840
+ });
4841
+ result.retracted.push(retractedEvent);
4842
+ if (retraction.changed) {
4843
+ _raiseReviewLearningContradictionAlert({
4844
+ lesson,
4845
+ project,
4846
+ applyingAgent,
4847
+ dispatchItem,
4848
+ evidence: application.evidence,
4849
+ evidenceKinds,
4850
+ });
4851
+ }
4852
+ }
4853
+ } catch (err) {
4854
+ _captureReviewLearningDiagnostic(result, application?.id || null, err);
4855
+ }
4856
+ }
4857
+
4858
+ _surfaceReviewLearningCaptureDiagnostics(dispatchItem, agentId, structuredCompletion, result.diagnostics);
4859
+ return result;
4860
+ }
4861
+
4862
+ function _raiseReviewLearningContradictionAlert({
4863
+ lesson,
4864
+ project,
4865
+ applyingAgent,
4866
+ dispatchItem,
4867
+ evidence,
4868
+ evidenceKinds,
4869
+ } = {}) {
4870
+ try {
4871
+ const detail = reviewLearning.redactBoundedText(
4872
+ evidence || '',
4873
+ reviewLearning.REVIEW_LEARNING_BOUNDS.evidenceBytes,
4874
+ 'contradiction evidence',
4875
+ );
4876
+ const lines = [
4877
+ `Review-learning lesson \`${lesson.id}\` was contradicted with exact current-code evidence and quarantined (retracted).`,
4878
+ '',
4879
+ `- Project: ${project}`,
4880
+ `- Finding: ${lesson.sourceRef}`,
4881
+ `- Contradicted by: ${applyingAgent || 'unknown'}`,
4882
+ `- Dispatch: ${dispatchItem?.id || 'unknown'}`,
4883
+ `- Evidence kinds: ${(evidenceKinds || []).join(', ') || 'current-code'}`,
4884
+ `- Evidence: ${detail || '(redacted)'}`,
4885
+ '',
4886
+ 'Restoring this lesson is an explicit human action (memory record restore).',
4887
+ ];
4888
+ shared.writeToInbox('engine', `review-learning-contradicted-${lesson.id}`, lines.join('\n'), null, {
4889
+ kind: 'review-learning-contradiction',
4890
+ memoryId: lesson.id,
4891
+ project,
4892
+ });
4893
+ } catch (err) {
4894
+ log('warn', `Could not raise review-learning contradiction alert for ${lesson?.id}: ${err.message}`);
4895
+ }
4896
+ }
4897
+
4188
4898
  function parseCompletionReportFile(dispatchItem, opts = {}) {
4189
4899
  const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
4190
4900
  if (!reportPath || !fs.existsSync(reportPath)) {
@@ -4203,11 +4913,64 @@ function parseCompletionReportFile(dispatchItem, opts = {}) {
4203
4913
  log('warn', `Ignoring completion report without status for ${dispatchItem?.id || 'unknown'}: ${reportPath}`);
4204
4914
  return null;
4205
4915
  }
4916
+ const normalizedStatus = normalizeCompletionStatus(report.status);
4917
+ if (!VALID_COMPLETION_STATUSES.has(normalizedStatus)) {
4918
+ log('warn', `Ignoring completion report with invalid status "${report.status}" for ${dispatchItem?.id || 'unknown'}: ${reportPath}`);
4919
+ return null;
4920
+ }
4206
4921
  report._source = 'report-file';
4207
4922
  report._path = reportPath;
4208
4923
  return report;
4209
4924
  }
4210
4925
 
4926
+ function requiredCompletionReportFailure({
4927
+ dispatchId,
4928
+ type,
4929
+ code,
4930
+ structuredCompletion,
4931
+ nonceMismatch,
4932
+ unfinishedToolCall = false,
4933
+ required = true,
4934
+ } = {}) {
4935
+ if (
4936
+ !required
4937
+ || code !== 0
4938
+ || nonceMismatch
4939
+ || structuredCompletion
4940
+ || shared.READ_ONLY_ROOT_TASK_TYPES.has(type)
4941
+ ) {
4942
+ return null;
4943
+ }
4944
+ // W-ms0vk9nv000z985b — separate "the runtime turn was truncated" from
4945
+ // "the agent finished but skipped the report".
4946
+ //
4947
+ // `unfinishedToolCall` comes from the dispatch's OWN runtime adapter via the
4948
+ // optional `hasUnfinishedToolCall` hook (engine/runtimes/contract.js): the
4949
+ // adapter is the only component that knows its event vocabulary, so no
4950
+ // runtime-name branching happens here. It is true only when the stream holds
4951
+ // a tool call that was started and never completed — exactly the observed
4952
+ // Copilot case of exiting 0 moments after starting a long `npm test`.
4953
+ //
4954
+ // Deliberately NOT keyed on "the runtime produced no terminal text": a
4955
+ // phantom dispatch (bare `system/init`, garbled stdout) also has no terminal
4956
+ // text but was never truncated mid-work, and must keep the original class.
4957
+ //
4958
+ // Both remain failures: the completion-report contract is NOT weakened. The
4959
+ // truncated case just gets a class that names the real cause, so it is not
4960
+ // mistaken for agent negligence and retry is understood as "re-run the
4961
+ // truncated turn" rather than "the agent will forget the report again".
4962
+ if (unfinishedToolCall) {
4963
+ return {
4964
+ reason: `Runtime for dispatch ${dispatchId || 'unknown'} exited 0 mid-turn: a tool call was started and never completed, and no completion report was written`,
4965
+ failureClass: FAILURE_CLASS.RUNTIME_TRUNCATED_EXIT,
4966
+ };
4967
+ }
4968
+ return {
4969
+ reason: `Required completion report missing or invalid for mutating dispatch ${dispatchId || 'unknown'}`,
4970
+ failureClass: FAILURE_CLASS.COMPLETION_REPORT_MISSING,
4971
+ };
4972
+ }
4973
+
4211
4974
  function normalizeCompletionArtifacts(rawArtifacts) {
4212
4975
  let artifacts = rawArtifacts;
4213
4976
  if (typeof artifacts === 'string') {
@@ -4398,6 +5161,18 @@ const NON_TERMINAL_COMPLETION_STATUSES = new Set([
4398
5161
  'blocked', 'incomplete', 'to-be-continued',
4399
5162
  'failed', 'failure', 'error',
4400
5163
  ]);
5164
+ const VALID_COMPLETION_STATUSES = new Set([
5165
+ 'success', 'succeeded', 'successful', 'done', 'complete', 'completed',
5166
+ ...NON_TERMINAL_COMPLETION_STATUSES,
5167
+ ]);
5168
+
5169
+ // Terminal (clean) success statuses only — VALID_COMPLETION_STATUSES minus the
5170
+ // non-terminal (partial/failure) tokens. Used to gate trustedCompletionSuccess so
5171
+ // a nonce-verified success report can override a kill-induced nonzero exit
5172
+ // (W-mrza78j3001r7a5d).
5173
+ const TERMINAL_SUCCESS_COMPLETION_STATUSES = new Set([
5174
+ 'success', 'succeeded', 'successful', 'done', 'complete', 'completed',
5175
+ ]);
4401
5176
 
4402
5177
  function detectNonTerminalResultSummary(_resultSummary, structuredCompletion, completionReport, opts) {
4403
5178
  const candidates = [completionReport?.status, structuredCompletion?.status];
@@ -4685,17 +5460,26 @@ function validateReviewFixCompletion({ type, meta, branchChange, structuredCompl
4685
5460
  const currentCodeEvidence = String(resolution?.currentCodeEvidence || '').trim();
4686
5461
  const hasCurrentCodeReference = /(?:[a-z]:[\\/])?[\w./\\-]+\.[a-z0-9]+:\d+\b/i.test(currentCodeEvidence)
4687
5462
  || /\bcommit\s+[0-9a-f]{7,40}\b/i.test(currentCodeEvidence);
4688
- const validDisposition = disposition === 'already-resolved' || disposition === 'invalid';
5463
+ let canonicalDisposition = null;
5464
+ try {
5465
+ canonicalDisposition = reviewLearning.normalizeDisposition(disposition);
5466
+ } catch {}
5467
+ const validDisposition = canonicalDisposition === reviewLearning.REVIEW_LEARNING_DISPOSITIONS.SATISFIED_ELSEWHERE
5468
+ || canonicalDisposition === reviewLearning.REVIEW_LEARNING_DISPOSITIONS.INVALID;
4689
5469
  const explicitNoop = !!parseCompletionNoop(structuredCompletion);
4690
5470
 
4691
5471
  if (explicitNoop && validDisposition && reportedFindingId === findingId && hasCurrentCodeReference) {
4692
- return { valid: true, findingId, resolution };
5472
+ return {
5473
+ valid: true,
5474
+ findingId,
5475
+ resolution: { ...resolution, disposition: canonicalDisposition },
5476
+ };
4693
5477
  }
4694
5478
 
4695
5479
  return {
4696
5480
  valid: false,
4697
5481
  findingId,
4698
- reason: `Review-fix completion did not advance the PR branch and must explicitly prove the exact finding is already resolved or invalid with current-code evidence. Expected reviewFindingResolution.findingId="${findingId}", disposition "already-resolved" or "invalid", and a file:line or commit reference. Shared platform ownership, viewerDidAuthor, or reviewer/fixer agent equality is not evidence.`,
5482
+ reason: `Review-fix completion did not advance the PR branch and must explicitly prove the exact finding is satisfied elsewhere or invalid with current-code evidence. Expected reviewFindingResolution.findingId="${findingId}", disposition "satisfied-elsewhere" (legacy "already-resolved" is accepted) or "invalid", and a file:line or commit reference. Shared platform ownership, viewerDidAuthor, or reviewer/fixer agent equality is not evidence.`,
4699
5483
  };
4700
5484
  }
4701
5485
 
@@ -4742,33 +5526,6 @@ function extractReviewThreadIds(completion) {
4742
5526
  return Array.from(ids);
4743
5527
  }
4744
5528
 
4745
- function reviewContentMatchesPr(content, pr, project) {
4746
- const text = String(content || '').trim();
4747
- if (!text) return false;
4748
- const targetId = shared.getCanonicalPrId(project, pr, pr?.url || '');
4749
- const targetNumber = shared.getPrNumber(pr);
4750
- if (!targetId) return true;
4751
-
4752
- const explicitRefs = new Set();
4753
- for (const match of text.matchAll(/\b(?:github|ado):[A-Za-z0-9._~/-]+#\d+\b/g)) {
4754
- explicitRefs.add(shared.getCanonicalPrId(project, match[0]));
4755
- }
4756
- for (const match of text.matchAll(/https?:\/\/[^\s)>"]+(?:\/pull\/|\/pullrequest\/)\d+[^\s)>"]*/gi)) {
4757
- const url = match[0].replace(/[.,;:]+$/g, '');
4758
- explicitRefs.add(shared.getCanonicalPrId(project, url, url));
4759
- }
4760
- if (explicitRefs.size > 0) return explicitRefs.size === 1 && explicitRefs.has(targetId);
4761
-
4762
- const mentionedNumbers = new Set();
4763
- for (const match of text.matchAll(/\bPR\s*(?:#|-)\s*(\d+)\b/gi)) {
4764
- mentionedNumbers.add(parseInt(match[1], 10));
4765
- }
4766
- if (mentionedNumbers.size > 0 && targetNumber != null) {
4767
- return mentionedNumbers.size === 1 && mentionedNumbers.has(targetNumber);
4768
- }
4769
- return true;
4770
- }
4771
-
4772
5529
  function writeNonCleanAgentReport(dispatchItem, agentId, outcome, structuredCompletion, resultSummary, exitCode) {
4773
5530
  if (!dispatchItem?.id || !outcome) {
4774
5531
  log('warn', 'Cannot write non-clean agent report without dispatch id and outcome');
@@ -5439,6 +6196,10 @@ function captureTaskMemoryEpisode(dispatchItem, agentId, outcome, structuredComp
5439
6196
  async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, opts) {
5440
6197
 
5441
6198
  const detectPhantom = !!(opts && opts.detectPhantom);
6199
+ const completionReportRequired = !!(opts && opts.completionReportRequired);
6200
+ const preFinalizationFailure = shared.isPlainObject(opts?.preFinalizationFailure)
6201
+ ? opts.preFinalizationFailure
6202
+ : null;
5442
6203
  const type = dispatchItem.type;
5443
6204
  const meta = dispatchItem.meta;
5444
6205
  const isSuccess = code === 0;
@@ -5504,6 +6265,22 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5504
6265
  }
5505
6266
 
5506
6267
  const structuredCompletion = reportCompletion;
6268
+ const completionReportFailure = requiredCompletionReportFailure({
6269
+ dispatchId: dispatchItem.id,
6270
+ type,
6271
+ code,
6272
+ structuredCompletion,
6273
+ nonceMismatch,
6274
+ // W-ms0vk9nv000z985b — did the runtime die between a tool start and its
6275
+ // completion? Answered by THIS dispatch's adapter through the registry
6276
+ // facade, which fails safe to `false` for adapters that don't implement
6277
+ // the hook (so the classification is unchanged for them).
6278
+ unfinishedToolCall: hasUnfinishedToolCall(resolveRuntime(runtimeName), stdout),
6279
+ required: completionReportRequired,
6280
+ });
6281
+ if (completionReportFailure) {
6282
+ log('error', `${completionReportFailure.reason} (agent=${agentId}, exitCode=${code})`);
6283
+ }
5507
6284
  if (structuredCompletion) {
5508
6285
  if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
5509
6286
  log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
@@ -5530,6 +6307,18 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5530
6307
  if (!structuredCompletion.status || /^(complete|success|done)/i.test(structuredCompletion.status)) {
5531
6308
  structuredCompletion.status = 'failed-injection-flagged';
5532
6309
  }
6310
+ suppressReviewLearningCompletionPayload(structuredCompletion);
6311
+ } else if (structuredCompletion) {
6312
+ const reviewLearningResult = normalizeReviewLearningCompletionPayload(
6313
+ dispatchItem,
6314
+ agentId,
6315
+ structuredCompletion,
6316
+ );
6317
+ recordReviewLearningCompletionDiagnostics(
6318
+ dispatchItem,
6319
+ agentId,
6320
+ reviewLearningResult.diagnostics,
6321
+ );
5533
6322
  }
5534
6323
  const completionGateSummary = resultSummary || (typeof stdout === 'string' && !stdout.includes('"type":') ? stdout : '');
5535
6324
 
@@ -5544,6 +6333,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5544
6333
  branch: dispatchItem.meta?.branch || null,
5545
6334
  sessionId,
5546
6335
  agentsDir: AGENTS_DIR,
6336
+ // W-ms0vk9nv000z985b — anchor the session to the directory it ran in
6337
+ // so a later dispatch can refuse to resume it from a dead worktree.
6338
+ cwd: dispatchItem.worktreePath || null,
5547
6339
  logger: { warn: (msg) => log('warn', msg) },
5548
6340
  });
5549
6341
  }
@@ -5584,7 +6376,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5584
6376
  const benignNoop = nonceMismatch
5585
6377
  ? null
5586
6378
  : classifyBenignZeroTargetOutcome(dispatchItem, structuredCompletion, completionGateSummary);
5587
- const agentReportedFailure = !!nonceMismatch || (!benignNoop && (
6379
+ const agentReportedFailure = !!nonceMismatch || !!completionReportFailure || !!preFinalizationFailure || (!benignNoop && (
5588
6380
  completionStatus.startsWith('fail')
5589
6381
  || completionStatus === 'error'
5590
6382
  || hasActionableFailureClass(structuredCompletion?.failure_class)
@@ -5602,7 +6394,22 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5602
6394
  if (autoRecovered) {
5603
6395
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
5604
6396
  }
5605
- const effectiveSuccess = !nonceMismatch && (benignNoop || (isSuccess && !agentReportedFailure) || autoRecovered);
6397
+ // W-mrza78j3001r7a5d trust a nonce-verified TERMINAL-success completion report
6398
+ // even when the process exited nonzero. The legacy stdout-only startup timer used
6399
+ // to `killGracefully` a productive-but-silent runtime, and other kill paths
6400
+ // (steering, spawn-phase watchdog) can also race a valid success report; without
6401
+ // this, `resolveTerminalDispatchResult` privileges the kill-induced nonzero exit
6402
+ // and persists result:error alongside structuredCompletion.status:success. Because
6403
+ // `agentReportedFailure` already folds in nonceMismatch / completionReportFailure /
6404
+ // preFinalizationFailure, this only fires for a clean, trusted success; hard
6405
+ // contract failures are still gated by the resolver's early-returns.
6406
+ const trustedCompletionSuccess = !!structuredCompletion
6407
+ && !agentReportedFailure
6408
+ && TERMINAL_SUCCESS_COMPLETION_STATUSES.has(completionStatus);
6409
+ if (trustedCompletionSuccess && !isSuccess) {
6410
+ log('info', `Trusted completion: nonce-verified success report from ${agentId} overrides process exit code ${code} for ${meta?.item?.id || dispatchItem.id}`);
6411
+ }
6412
+ const effectiveSuccess = !nonceMismatch && (benignNoop || (isSuccess && !agentReportedFailure) || autoRecovered || trustedCompletionSuccess);
5606
6413
  if (!nonceMismatch) {
5607
6414
  const episodeOutcome = effectiveSuccess
5608
6415
  ? (autoRecovered || completionStatus.startsWith('partial') ? 'partial' : 'success')
@@ -5921,7 +6728,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5921
6728
  log('info', `No-op completion for ${meta?.item?.id || dispatchItem.id}: ${noopRationale.slice(0, 200)}`);
5922
6729
  }
5923
6730
 
5924
- if (effectiveSuccess && meta?.item?.id && !skipDoneStatus && !noopRationale) {
6731
+ if ((effectiveSuccess || completionReportFailure || preFinalizationFailure) && meta?.item?.id && !skipDoneStatus && !noopRationale) {
5925
6732
  completionContractFailure = await enforcePrAttachmentContract(type, meta, agentId, config, resultSummary, stdout, { detectPhantom });
5926
6733
  if (completionContractFailure?.severity === 'hard' || completionContractFailure?.nonTerminal) {
5927
6734
  skipDoneStatus = true;
@@ -5931,7 +6738,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5931
6738
  // W-mqsk1ip00006cbae — verify WI PR guard. After enforcePrAttachmentContract
5932
6739
  // (which skips verify types), assert that a verify WI has a PR before
5933
6740
  // allowing the done transition.
5934
- if (effectiveSuccess && meta?.item?.id && !skipDoneStatus && !noopRationale) {
6741
+ if ((effectiveSuccess || completionReportFailure || preFinalizationFailure) && meta?.item?.id && !skipDoneStatus && !noopRationale) {
5935
6742
  const verifyContractFailure = enforceVerifyPrContract(type, meta, agentId, config, resultSummary);
5936
6743
  if (verifyContractFailure?.severity === 'hard') {
5937
6744
  completionContractFailure = completionContractFailure || verifyContractFailure;
@@ -5939,6 +6746,47 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5939
6746
  }
5940
6747
  }
5941
6748
 
6749
+ if (completionReportFailure || preFinalizationFailure) skipDoneStatus = true;
6750
+
6751
+ if (effectiveSuccess && !skipDoneStatus && structuredCompletion) {
6752
+ try {
6753
+ captureVerifiedReviewLearnings({
6754
+ dispatchItem,
6755
+ agentId,
6756
+ config,
6757
+ structuredCompletion,
6758
+ branchChange: prFixBranchChange,
6759
+ reviewFixResolution,
6760
+ });
6761
+ } catch (err) {
6762
+ const captureFailure = { diagnostics: [] };
6763
+ _captureReviewLearningDiagnostic(captureFailure, null, err);
6764
+ _surfaceReviewLearningCaptureDiagnostics(
6765
+ dispatchItem,
6766
+ agentId,
6767
+ structuredCompletion,
6768
+ captureFailure.diagnostics,
6769
+ );
6770
+ }
6771
+ try {
6772
+ processReviewLearningApplications({
6773
+ dispatchItem,
6774
+ agentId,
6775
+ config,
6776
+ structuredCompletion,
6777
+ });
6778
+ } catch (err) {
6779
+ const applyFailure = { diagnostics: [] };
6780
+ _captureReviewLearningDiagnostic(applyFailure, null, err);
6781
+ _surfaceReviewLearningCaptureDiagnostics(
6782
+ dispatchItem,
6783
+ agentId,
6784
+ structuredCompletion,
6785
+ applyFailure.diagnostics,
6786
+ );
6787
+ }
6788
+ }
6789
+
5942
6790
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
5943
6791
  meta._agentId = agentId;
5944
6792
  if (noopRationale) {
@@ -6011,27 +6859,32 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6011
6859
  // (engine/lifecycle.js, "_wiMetaForSkip" block) — that way fast-path
6012
6860
  // dispatchers that synthesize meta.qaRunId at the top level keep
6013
6861
  // working too. The agent writes agents/<id>/qa-run-result.json before
6014
- // exit. Happy path: parse qaRuns.completeRun({status, summary,
6015
- // artifacts}). Missing-sidecar path: qaRuns.completeRun({status:
6016
- // 'errored'}) so the run record always reaches a terminal state and
6017
- // the dashboard run list never shows a perma-pending row when the
6018
- // agent crashed before exit.
6862
+ // exit. qa-runs consumes the file exactly once and verifies its runId
6863
+ // before this hook completes the linked record. Missing, malformed, stale,
6864
+ // or invalid-status sidecars terminalize the current run as errored.
6019
6865
  const qaRunId = meta?.qaRunId || meta?.item?.meta?.qaRunId;
6020
- if (qaRunId) {
6866
+ const lifecycleGateCanRetry = !!(completionReportFailure || preFinalizationFailure)
6867
+ && completionContractFailure?.severity !== 'hard'
6868
+ && completionContractFailure?.nonTerminal !== true
6869
+ && Number(meta?.item?._retryCount || 0) < ENGINE_DEFAULTS.maxRetries;
6870
+ if (qaRunId && lifecycleGateCanRetry) {
6871
+ try { require('./qa-runs').prepareResultSidecar(agentId || '_unknown', qaRunId); }
6872
+ catch (e) { log('warn', `qa-validate deferred sidecar cleanup for ${qaRunId}: ${e.message}`); }
6873
+ log('warn', `qa-validate run ${qaRunId} remains pending while completion validation retries`);
6874
+ } else if (qaRunId) {
6021
6875
  try {
6022
6876
  const qaRuns = require('./qa-runs');
6023
- const sidecarPath = path.join(AGENTS_DIR, agentId || '_unknown', 'qa-run-result.json');
6024
- let parsed = null;
6025
- try {
6026
- const raw = fs.readFileSync(sidecarPath, 'utf8');
6027
- parsed = JSON.parse(raw);
6028
- } catch (e) {
6029
- if (e.code !== 'ENOENT') {
6030
- log('warn', `qa-validate sidecar parse for ${qaRunId}: ${e.message}`);
6031
- }
6877
+ const consumed = qaRuns.consumeResultSidecar(agentId || '_unknown', qaRunId);
6878
+ if (consumed.cleanupError) {
6879
+ log('warn', `qa-validate sidecar cleanup for ${qaRunId}: ${consumed.cleanupError}`);
6032
6880
  }
6033
- if (parsed && typeof parsed === 'object'
6034
- && (parsed.status === 'passed' || parsed.status === 'failed')) {
6881
+ const parsed = consumed.ok ? consumed.parsed : null;
6882
+ const sidecarCanComplete = parsed && (
6883
+ effectiveSuccess
6884
+ || parsed.status === qaRuns.QA_RUN_STATUS.FAILED
6885
+ || parsed.status === qaRuns.QA_RUN_STATUS.ERRORED
6886
+ );
6887
+ if (sidecarCanComplete) {
6035
6888
  qaRuns.completeRun(qaRunId, {
6036
6889
  status: parsed.status,
6037
6890
  summary: typeof parsed.summary === 'string' ? parsed.summary : '',
@@ -6039,17 +6892,24 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6039
6892
  });
6040
6893
  log('info', `qa-validate run ${qaRunId} → ${parsed.status} (${(parsed.artifacts || []).length} artifacts)`);
6041
6894
  } else {
6042
- // Sidecar missing, malformed, or claims a status outside the
6043
- // documented enum. Mark run errored so the UI surfaces the failure
6044
- // and the next dispatcher knows the slot is free.
6895
+ let summary;
6896
+ if (parsed) {
6897
+ summary = 'qa-validate dispatch failed completion validation';
6898
+ } else if (consumed.reason === 'missing') {
6899
+ summary = `qa-validate sidecar missing at ${consumed.filePath}`;
6900
+ } else if (consumed.reason === 'run-id-mismatch') {
6901
+ summary = `qa-validate sidecar runId mismatch (expected ${qaRunId}, got ${consumed.actualRunId || 'missing'})`;
6902
+ } else if (consumed.reason === 'invalid-status') {
6903
+ summary = `qa-validate sidecar malformed (status=${consumed.status})`;
6904
+ } else {
6905
+ summary = `qa-validate sidecar ${consumed.reason} at ${consumed.filePath}${consumed.detail ? `: ${consumed.detail}` : ''}`;
6906
+ }
6045
6907
  qaRuns.completeRun(qaRunId, {
6046
6908
  status: 'errored',
6047
- summary: parsed
6048
- ? `qa-validate sidecar malformed (status=${parsed.status})`
6049
- : `qa-validate sidecar missing at ${sidecarPath}`,
6909
+ summary,
6050
6910
  artifacts: [],
6051
6911
  });
6052
- log('warn', `qa-validate run ${qaRunId} → errored (sidecar ${parsed ? 'malformed' : 'missing'})`);
6912
+ log('warn', `qa-validate run ${qaRunId} → errored (${parsed ? 'dispatch completion invalid' : `sidecar ${consumed.reason}`})`);
6053
6913
  }
6054
6914
  } catch (err) {
6055
6915
  // qaRuns.completeRun throws on illegal transitions / missing run id.
@@ -6073,7 +6933,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6073
6933
  // it under `meta.item.meta`. Accept both.
6074
6934
  const qaSessionId = meta?.sessionId || meta?.item?.meta?.sessionId;
6075
6935
  const qaSessionPhase = meta?.sessionPhase || meta?.item?.meta?.sessionPhase;
6076
- if (qaSessionId && qaSessionPhase) {
6936
+ if (qaSessionId && qaSessionPhase && !lifecycleGateCanRetry) {
6077
6937
  try {
6078
6938
  const qaSessions = require('./qa-sessions');
6079
6939
  const scope = resolveWorkItemScope(meta);
@@ -6082,13 +6942,16 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6082
6942
  ? resultSummary
6083
6943
  : '';
6084
6944
  if (qaSessionPhase === 'setup') {
6085
- qaSessions.handleSetupComplete(qaSessionId, {
6086
- success: !!effectiveSuccess,
6087
- scope,
6088
- project,
6089
- failureClass: (structuredCompletion && structuredCompletion.failure_class) || null,
6090
- reason: failureReason,
6091
- });
6945
+ if (!opts?.deferQaSessionSetup) {
6946
+ handleQaSessionSetupCompletion(meta, {
6947
+ effectiveSuccess,
6948
+ structuredCompletion,
6949
+ resultSummary,
6950
+ scope,
6951
+ project,
6952
+ reason: failureReason,
6953
+ });
6954
+ }
6092
6955
  } else if (qaSessionPhase === 'draft') {
6093
6956
  // The DRAFT agent reports the relative test file path in its
6094
6957
  // structured completion as `testFile` (or the playbook can write a
@@ -6220,7 +7083,8 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6220
7083
  const _skipWorktreeRemovalForAcceptance =
6221
7084
  !!meta?.keep_processes || !!_wiMetaForSkip.keep_processes ||
6222
7085
  !!meta?.managed_spawn || !!_wiMetaForSkip.managed_spawn;
6223
- if (meta?.branch && meta?.branchStrategy !== 'shared-branch' && !_skipWorktreeRemovalForAcceptance) {
7086
+ const _deferWorktreeRemoval = !!(opts && opts.deferWorktreeRemoval);
7087
+ if (meta?.branch && meta?.branchStrategy !== 'shared-branch' && !_skipWorktreeRemovalForAcceptance && !_deferWorktreeRemoval) {
6224
7088
  try {
6225
7089
  const project = meta.project || {};
6226
7090
  const rootDir = project.localPath ? path.resolve(project.localPath) : null;
@@ -6376,7 +7240,27 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6376
7240
  const metricsResult = isAutoRetry ? 'retry' : finalResult;
6377
7241
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
6378
7242
 
6379
- return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch, benignNoop };
7243
+ return { resultSummary, taskUsage, autoRecovered, trustedCompletionSuccess, structuredCompletion, completionContractFailure, completionReportFailure, preFinalizationFailure, agentReportedFailure, agentRetryable, nonceMismatch, benignNoop, lifecycleGateCanRetry };
7244
+ }
7245
+
7246
+ function handleQaSessionSetupCompletion(meta, opts = {}) {
7247
+ const qaSessionId = meta?.sessionId || meta?.item?.meta?.sessionId;
7248
+ if (!qaSessionId) return null;
7249
+ const qaSessions = require('./qa-sessions');
7250
+ const scope = opts.scope || resolveWorkItemScope(meta);
7251
+ const project = opts.project !== undefined
7252
+ ? opts.project
7253
+ : (meta?.project?.name || meta?.item?.project || null);
7254
+ const resultSummary = typeof opts.resultSummary === 'string' ? opts.resultSummary : '';
7255
+ return qaSessions.handleSetupCompleteAfterManagedSpawn(qaSessionId, {
7256
+ success: !!opts.effectiveSuccess,
7257
+ scope,
7258
+ project,
7259
+ failureClass: opts.failureClass
7260
+ || (opts.structuredCompletion && opts.structuredCompletion.failure_class)
7261
+ || null,
7262
+ reason: typeof opts.reason === 'string' && opts.reason ? opts.reason : resultSummary,
7263
+ });
6380
7264
  }
6381
7265
 
6382
7266
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
@@ -6640,6 +7524,19 @@ function classifyFailure(code, stdout = '', stderr = '') {
6640
7524
  // Exit code 78 — configuration error (Claude CLI not found, bad setup)
6641
7525
  if (code === 78) return FAILURE_CLASS.CONFIG_ERROR;
6642
7526
 
7527
+ // Stale resumed session (W-ms0vk9nv000z985b). A `--resume`d CLI whose
7528
+ // persisted session or working directory is gone dies before it writes any
7529
+ // stdout, so without this branch the run fell all the way through to the
7530
+ // EMPTY_OUTPUT catch-all — a class that says "the agent produced nothing"
7531
+ // and implies the agent crashed on startup, when in fact the engine handed
7532
+ // it an unusable session id. Checked early: the signal is unambiguous and
7533
+ // the message can otherwise be swallowed by the generic phrase matchers
7534
+ // below. Prevention lives in each adapter's getResumeSessionId; this is the
7535
+ // classification for the residual race.
7536
+ if (shared.isStaleResumeSessionSignal(combined)) {
7537
+ return FAILURE_CLASS.RUNTIME_SESSION_STALE;
7538
+ }
7539
+
6643
7540
  // Max turns exhausted (error_max_turns) — definitive stop reason, retryable
6644
7541
  // Must be checked FIRST — hook startup failures (e.g. curl exit code 28) can inject
6645
7542
  // permission/auth text into stderr, but if the agent ran to turn exhaustion that's the
@@ -7162,9 +8059,11 @@ module.exports = {
7162
8059
  enforceVerifyPrContract,
7163
8060
  markMissingPrAttachment,
7164
8061
  parseCompletionReportFile,
7165
- // #308 — exported for unit testing: the final non-clean agent note now folds
7166
- // the grounded harness footprint in as a section instead of emitting a
7167
- // standalone harness-usage-* inbox note.
8062
+ buildReviewLearningCompletionContext, // exported for testing
8063
+ normalizeReviewLearningCompletionPayload, // exported for testing
8064
+ suppressReviewLearningCompletionPayload, // exported for testing
8065
+ captureVerifiedReviewLearnings, // exported for testing
8066
+ processReviewLearningApplications, // exported for testing
7168
8067
  writeNonCleanAgentReport,
7169
8068
  // exported for testing
7170
8069
  captureTaskMemoryEpisode,
@@ -7172,7 +8071,9 @@ module.exports = {
7172
8071
  completionArtifactToNoteEntry,
7173
8072
  mergeArtifactNotes,
7174
8073
  promoteCompletionArtifacts,
8074
+ requiredCompletionReportFailure, // exported for testing
7175
8075
  runPostCompletionHooks,
8076
+ handleQaSessionSetupCompletion, // exported for testing
7176
8077
  applyGoalInvalidation,
7177
8078
  syncPrdFromPrs,
7178
8079
  advancePrdStatusOnAllItemsDone,