@yemi33/minions 0.1.2382 → 0.1.2384

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 (51) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +2 -1
  3. package/dashboard/js/settings.js +1 -1
  4. package/dashboard.js +37 -19
  5. package/docs/completion-reports.md +20 -1
  6. package/docs/keep-processes.md +7 -2
  7. package/docs/live-checkout-mode.md +7 -7
  8. package/docs/managed-spawn.md +14 -1
  9. package/docs/runtime-adapters.md +61 -26
  10. package/docs/skills.md +2 -0
  11. package/docs/workspace-manifests.md +1 -1
  12. package/docs/worktree-lifecycle.md +36 -0
  13. package/engine/acp-transport.js +273 -58
  14. package/engine/ado-comment.js +3 -1
  15. package/engine/agent-worker-pool.js +278 -54
  16. package/engine/cc-worker-pool.js +12 -3
  17. package/engine/cleanup.js +15 -11
  18. package/engine/cli.js +56 -28
  19. package/engine/comment-format.js +51 -14
  20. package/engine/create-pr-worktree.js +57 -79
  21. package/engine/dispatch.js +7 -2
  22. package/engine/execution-model.js +68 -0
  23. package/engine/gh-comment.js +6 -3
  24. package/engine/github.js +6 -7
  25. package/engine/keep-process-sweep.js +131 -9
  26. package/engine/lifecycle.js +126 -45
  27. package/engine/live-checkout.js +4 -2
  28. package/engine/llm.js +59 -83
  29. package/engine/managed-spawn.js +112 -5
  30. package/engine/memory-store.js +3 -1
  31. package/engine/playbook.js +4 -6
  32. package/engine/pooled-agent-process.js +512 -45
  33. package/engine/pr-action.js +6 -8
  34. package/engine/process-utils.js +154 -18
  35. package/engine/runtimes/claude.js +94 -25
  36. package/engine/runtimes/codex.js +1 -0
  37. package/engine/runtimes/contract.js +239 -0
  38. package/engine/runtimes/copilot.js +97 -9
  39. package/engine/runtimes/index.js +37 -9
  40. package/engine/shared.js +349 -82
  41. package/engine/spawn-agent.js +85 -116
  42. package/engine/spawn-phase-watchdog.js +8 -37
  43. package/engine/timeout.js +14 -10
  44. package/engine/worktree-gc.js +55 -46
  45. package/engine.js +418 -241
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +20 -0
  48. package/playbooks/review.md +2 -0
  49. package/playbooks/shared-rules.md +2 -2
  50. package/prompts/cc-system.md +11 -11
  51. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine/github.js CHANGED
@@ -282,10 +282,10 @@ async function _resolveViewerLogin(slug) {
282
282
  // `_isMinionsAuthoredComment` cannot tell minions posts apart from human
283
283
  // posts (the `viewerDidAuthor` gate stays false for everything), so the
284
284
  // comment classifier reclassifies every minions comment as human and
285
- // queues a spurious fix dispatch on every poll cycle. The downstream
286
- // `check-self-authored-review-comment` skill no-ops the dispatch, so this
287
- // is a slot/token tax rather than state corruption — but the frequency
288
- // data is worth tracking so future audits can prioritize a deeper fix
285
+ // queues a spurious fix dispatch on every poll cycle. Review-fix completion
286
+ // now rejects shared-identity-only no-ops, so failing closed here prevents
287
+ // both duplicate traffic and false completion retries. The frequency
288
+ // data is still worth tracking so future audits can prioritize a deeper fix
289
289
  // (e.g. a credentialed re-probe of the gh CLI). Global counter under
290
290
  // `_engine.viewerLoginResolutionFailures`; surfaced through the existing
291
291
  // engine metrics aggregation (`getMetrics()` in `engine/queries.js`).
@@ -1241,9 +1241,8 @@ async function pollPrHumanComments(config) {
1241
1241
  // `c.viewerDidAuthor === true`, and `_backfillViewerDidAuthor` is a
1242
1242
  // no-op when viewerLogin is null. Running the classifier without a
1243
1243
  // login mass-misclassifies every minions comment as human and queues
1244
- // a redundant fix dispatch on every poll cycle (the downstream
1245
- // `check-self-authored-review-comment` skill no-ops them, so this is
1246
- // a slot/token tax — see audit OQ-Y, HIGH not CRITICAL). Skip the
1244
+ // a redundant fix dispatch on every poll cycle. Shared identity is not
1245
+ // valid review-fix no-op evidence, so skip the
1247
1246
  // entire round instead. One log + one counter increment per affected
1248
1247
  // PR per cycle (no per-comment fanout).
1249
1248
  if (!viewerLogin) {
@@ -81,6 +81,20 @@ function validateKeepPidsRecord(parsed, opts) {
81
81
  if (typeof parsed.cwd === 'string' && parsed.cwd.length > 500) {
82
82
  return { ok: false, reason: 'cwd-too-long' };
83
83
  }
84
+ if (opts.allowedCwdRoot) {
85
+ if (typeof parsed.cwd !== 'string' || parsed.cwd.length === 0) {
86
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-required-for-dispatch-confinement' };
87
+ }
88
+ try {
89
+ const cwdReal = shared.realPathForComparison(parsed.cwd);
90
+ const rootReal = shared.realPathForComparison(opts.allowedCwdRoot);
91
+ if (!shared.isPathInsideOrEqual(cwdReal, rootReal)) {
92
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-outside-dispatch-worktree' };
93
+ }
94
+ } catch (err) {
95
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-containment-check-failed (' + err.message + ')' };
96
+ }
97
+ }
84
98
  // W-mp6k7ywi000fa33c — when the engine requires a real git workdir
85
99
  // (default true; per-WI override via `meta.keep_processes_skip_workdir_check`),
86
100
  // verify the recorded `cwd` looks like a real worktree. Empty/missing
@@ -110,22 +124,105 @@ function validateKeepPidsRecord(parsed, opts) {
110
124
  if (parsed.wi_id != null && typeof parsed.wi_id !== 'string') {
111
125
  return { ok: false, reason: 'wi_id-not-string' };
112
126
  }
127
+ for (const field of ['project', 'work_type', 'checkout_mode', 'dispatch_root']) {
128
+ if (parsed[field] != null && typeof parsed[field] !== 'string') {
129
+ return { ok: false, reason: field + '-not-string' };
130
+ }
131
+ }
132
+
133
+ let canonicalCwd = typeof parsed.cwd === 'string' ? parsed.cwd : '';
134
+ if (canonicalCwd) {
135
+ try { canonicalCwd = shared.realPathForComparison(canonicalCwd); }
136
+ catch (err) {
137
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-canonicalization-failed (' + err.message + ')' };
138
+ }
139
+ }
140
+ let canonicalDispatchRoot = typeof parsed.dispatch_root === 'string' ? parsed.dispatch_root : '';
141
+ if (canonicalDispatchRoot) {
142
+ try { canonicalDispatchRoot = shared.realPathForComparison(canonicalDispatchRoot); }
143
+ catch (err) {
144
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'dispatch-root-canonicalization-failed (' + err.message + ')' };
145
+ }
146
+ }
113
147
 
114
148
  return {
115
149
  ok: true,
116
150
  value: {
117
151
  pids: pids,
118
152
  purpose: typeof parsed.purpose === 'string' ? parsed.purpose : '',
119
- cwd: typeof parsed.cwd === 'string' ? parsed.cwd : '',
153
+ cwd: canonicalCwd,
120
154
  ports: Array.isArray(parsed.ports) ? parsed.ports.map(Number) : [],
121
155
  expires_at: parsed.expires_at,
122
156
  expiresAtMs: expiresAtMs,
123
157
  written_by: typeof parsed.written_by === 'string' ? parsed.written_by : '',
124
158
  wi_id: typeof parsed.wi_id === 'string' ? parsed.wi_id : '',
159
+ project: typeof parsed.project === 'string' ? parsed.project : '',
160
+ work_type: typeof parsed.work_type === 'string' ? parsed.work_type : '',
161
+ checkout_mode: typeof parsed.checkout_mode === 'string' ? parsed.checkout_mode : '',
162
+ dispatch_root: canonicalDispatchRoot,
125
163
  },
126
164
  };
127
165
  }
128
166
 
167
+ function validateKeepProcessExecutionContext(record, opts) {
168
+ opts = opts || {};
169
+ if (!record) return { ok: false, reason: 'record-missing' };
170
+ if (!record.cwd) {
171
+ return record.checkout_mode || record.dispatch_root
172
+ ? { ok: false, reason: 'cwd-missing' }
173
+ : { ok: true, legacy: true };
174
+ }
175
+ let projects;
176
+ try {
177
+ projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
178
+ } catch {
179
+ return { ok: false, reason: 'project-config-unavailable' };
180
+ }
181
+
182
+ for (const project of projects) {
183
+ if (!project?.localPath) continue;
184
+ const mode = shared.resolveCheckoutMode(
185
+ project,
186
+ record.work_type || shared.WORK_TYPE.IMPLEMENT,
187
+ );
188
+ if (mode !== shared.CHECKOUT_MODES.WORKTREE) continue;
189
+ try {
190
+ if (shared.pathsOverlap(record.cwd, project.localPath)) {
191
+ return { ok: false, reason: 'cwd-overlaps-worktree-mode-operator-checkout' };
192
+ }
193
+ } catch {
194
+ return { ok: false, reason: 'cwd-overlap-check-failed' };
195
+ }
196
+ }
197
+
198
+ const project = record.project ? shared.findProjectByName(projects, record.project) : null;
199
+ if (project && shared.resolveCheckoutMode(project, record.work_type || shared.WORK_TYPE.IMPLEMENT) === shared.CHECKOUT_MODES.LIVE) {
200
+ try {
201
+ if (!shared.isPathInsideOrEqual(record.cwd, project.localPath)) {
202
+ return { ok: false, reason: 'live-cwd-outside-project' };
203
+ }
204
+ } catch {
205
+ return { ok: false, reason: 'live-cwd-unavailable' };
206
+ }
207
+ return { ok: true };
208
+ }
209
+
210
+ if (!record.dispatch_root) {
211
+ return record.checkout_mode
212
+ ? { ok: false, reason: 'dispatch-root-missing' }
213
+ : { ok: true, legacy: true };
214
+ }
215
+ try {
216
+ shared.assertWorktreeOutsideProjects(record.dispatch_root, projects);
217
+ if (!shared.isPathInsideOrEqual(record.cwd, record.dispatch_root)) {
218
+ return { ok: false, reason: 'cwd-outside-dispatch-root' };
219
+ }
220
+ } catch {
221
+ return { ok: false, reason: 'dispatch-root-overlaps-operator-checkout' };
222
+ }
223
+ return { ok: true };
224
+ }
225
+
129
226
  function readKeepPidsFile(agentId, opts) {
130
227
  opts = opts || {};
131
228
  const filePath = path.join(_agentsDir(), agentId, KEEP_PIDS_FILENAME);
@@ -198,6 +295,16 @@ function sweepKeepProcesses(opts) {
198
295
  const value = rec.value;
199
296
  const filePath = rec.filePath;
200
297
  const agentId = rec.agentId;
298
+ const executionContext = validateKeepProcessExecutionContext(value, opts);
299
+ if (!executionContext.ok) {
300
+ for (const pid of value.pids) {
301
+ if (killOne(pid)) stats.killedPids++;
302
+ _audit('unsafe-context-kill', { agentId, pid, reason: executionContext.reason, wi_id: value.wi_id });
303
+ }
304
+ try { fs.unlinkSync(filePath); } catch {}
305
+ log('warn', 'keep-processes: removed unsafe execution context for ' + agentId + ' — ' + executionContext.reason);
306
+ continue;
307
+ }
201
308
  if (value.expiresAtMs < now) {
202
309
  let killed = 0;
203
310
  for (const pid of value.pids) {
@@ -227,6 +334,7 @@ function getActiveAnchorPids(opts) {
227
334
  for (const rec of listAllKeepPidsFiles({ now: now })) {
228
335
  if (!rec.valid) continue;
229
336
  if (rec.value.expiresAtMs < now) continue;
337
+ if (!validateKeepProcessExecutionContext(rec.value, opts).ok) continue;
230
338
  for (const pid of rec.value.pids) out.add(pid);
231
339
  }
232
340
  return out;
@@ -237,13 +345,7 @@ function getActiveAnchorPidsForAgent(agentId, opts) {
237
345
  const now = Number.isFinite(opts.now) ? opts.now : Date.now();
238
346
  const out = new Set();
239
347
  if (!agentId) return { pids: out, record: null };
240
- // W-mp6k7ywi000fa33c forward `requireGitWorkdir` (and any future opts)
241
- // so the per-WI override path can disable workdir validation. Earlier
242
- // implementation only forwarded `now`, which silently ignored opts.
243
- const readOpts = { now: now };
244
- if (Object.prototype.hasOwnProperty.call(opts, 'requireGitWorkdir')) {
245
- readOpts.requireGitWorkdir = opts.requireGitWorkdir;
246
- }
348
+ const readOpts = { ...opts, now };
247
349
  const rec = readKeepPidsFile(String(agentId), readOpts);
248
350
  if (!rec) return { pids: out, record: null };
249
351
  if (!rec.valid) {
@@ -251,10 +353,26 @@ function getActiveAnchorPidsForAgent(agentId, opts) {
251
353
  return { pids: out, record: null, reason: rec.reason };
252
354
  }
253
355
  if (rec.value.expiresAtMs < now) return { pids: out, record: rec.value, reason: 'expired' };
356
+ const executionContext = validateKeepProcessExecutionContext(rec.value, opts);
357
+ if (!executionContext.ok) return { pids: out, record: rec.value, reason: executionContext.reason };
254
358
  for (const pid of rec.value.pids) out.add(pid);
255
359
  return { pids: out, record: rec.value };
256
360
  }
257
361
 
362
+ function getActiveKeepProcessCwds(opts) {
363
+ opts = opts || {};
364
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
365
+ const out = [];
366
+ for (const rec of listAllKeepPidsFiles({ now })) {
367
+ if (!rec.valid || rec.value.expiresAtMs < now || !rec.value.cwd) continue;
368
+ if (!validateKeepProcessExecutionContext(rec.value, opts).ok) continue;
369
+ if (alivePids(rec.value.pids, opts).length === 0) continue;
370
+ try { out.push(shared.realPathForComparison(rec.value.cwd)); }
371
+ catch { /* malformed/unreachable cwd contributes no anchor */ }
372
+ }
373
+ return out;
374
+ }
375
+
258
376
  function computeReapPlan(descendants, agentId, opts) {
259
377
  opts = opts || {};
260
378
  const list = (Array.isArray(descendants) ? descendants : []).map(Number)
@@ -311,6 +429,7 @@ function evaluateKeepPidsAcceptance(agentId, opts) {
311
429
  record: rec.value,
312
430
  recordedCwd: rec.value.cwd || null,
313
431
  filePath: rec.filePath,
432
+ parsedRaw: rec.parsed || null,
314
433
  };
315
434
  }
316
435
  const reason = rec.reason || 'unknown';
@@ -368,7 +487,7 @@ function buildKeepProcessesHint(opts) {
368
487
  '{',
369
488
  ' "pids": [12345, 12346],',
370
489
  ' "purpose": "bun run dev (Constellation server + dashboard)",',
371
- ' "cwd": "D:/repos/constellation",',
490
+ ' "cwd": "<absolute path at or below MINIONS_AGENT_CWD>",',
372
491
  ' "ports": [3001, 5173],',
373
492
  ' "expires_at": "<ISO-8601 timestamp <= ' + ttl + ' minutes from now>",',
374
493
  ' "written_by": "' + agentId + '",',
@@ -377,6 +496,7 @@ function buildKeepProcessesHint(opts) {
377
496
  '```',
378
497
  '',
379
498
  'Caps the engine enforces: max ' + maxPids + ' PIDs, TTL <= ' + maxTtl + ' minutes, `purpose`/`cwd` <= 500 chars, <= 20 ports. Files outside the cap are dropped (engine reaps as normal) and a warning is logged.',
499
+ '`cwd` MUST resolve at or below the current dispatch root from `MINIONS_AGENT_CWD`. The operator checkout is rejected for worktree-mode dispatches, even when it is a valid Git checkout.',
380
500
  '',
381
501
  'If you do NOT write the file, the engine will kill ALL of your descendant processes when you exit (today\'s default). Do not write the file unless you are intentionally leaving a process behind for the human or follow-up work.',
382
502
  '',
@@ -452,6 +572,8 @@ module.exports = {
452
572
  sweepKeepProcesses: sweepKeepProcesses,
453
573
  getActiveAnchorPids: getActiveAnchorPids,
454
574
  getActiveAnchorPidsForAgent: getActiveAnchorPidsForAgent,
575
+ getActiveKeepProcessCwds: getActiveKeepProcessCwds,
576
+ validateKeepProcessExecutionContext: validateKeepProcessExecutionContext,
455
577
  computeReapPlan: computeReapPlan,
456
578
  buildKeepProcessesHint: buildKeepProcessesHint,
457
579
  evaluateKeepPidsAcceptance: evaluateKeepPidsAcceptance,
@@ -17,6 +17,7 @@ const harness = require('./harness');
17
17
  const smallStateStore = require('./small-state-store');
18
18
  const prdStore = require('./prd-store');
19
19
  const { isBranchActive } = require('./cooldown');
20
+ const { resolveExecutionModel } = require('./execution-model');
20
21
  const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
21
22
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
22
23
  MINIONS_DIR, ENGINE_DIR, PLANS_DIR, INBOX_DIR, AGENTS_DIR } = queries;
@@ -2319,7 +2320,7 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
2319
2320
  : null;
2320
2321
  }
2321
2322
 
2322
- async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null, dispatchItem = null) {
2323
+ async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null, dispatchItem = null, executionMetadata = null) {
2323
2324
 
2324
2325
  if (!config) config = getConfig();
2325
2326
  const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
@@ -2338,6 +2339,11 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2338
2339
  const reviewProject = reviewContext.project;
2339
2340
  const prScope = reviewContext.scope;
2340
2341
  const reviewerName = config.agents?.[agentId]?.name || agentId;
2342
+ const reviewExecutionModel = resolveExecutionModel({
2343
+ reportedModel: executionMetadata?.reportedModel,
2344
+ capturedModel: dispatchItem?.executionModel,
2345
+ requestedModel: dispatchItem?.requestedModel,
2346
+ });
2341
2347
 
2342
2348
  // Check actual review status from the platform (agent may have approved or requested changes)
2343
2349
  // If platform hasn't propagated the vote yet (returns 'pending'), keep current status unchanged.
@@ -2549,6 +2555,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2549
2555
  reviewer: reviewerName,
2550
2556
  reviewedAt: ts(),
2551
2557
  note: reviewNote,
2558
+ model: reviewExecutionModel.model,
2552
2559
  dispatchId: dispatchItem?.id || structuredCompletion?.dispatchId || null,
2553
2560
  sourceItem: dispatchItem?.meta?.item?.id || null,
2554
2561
  ...(reviewThreads.length > 0 ? { threads: reviewThreads } : {}),
@@ -3134,7 +3141,6 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3134
3141
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
3135
3142
  options = { automationCauseKey: options, dispatchId: legacyDispatchId };
3136
3143
  }
3137
- const explicitlyChangedBranch = options.branchChanged !== false;
3138
3144
  const prScope = project || 'central';
3139
3145
  const automationCauseKey = options.automationCauseKey || options.dispatchItem?.meta?.automationCauseKey || '';
3140
3146
  const fixDispatchId = options.dispatchItem?.id || options.dispatchId || legacyDispatchId || '';
@@ -3143,6 +3149,15 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3143
3149
  source,
3144
3150
  task: options.dispatchItem?.task,
3145
3151
  });
3152
+ const evidenceOnlyReviewResolution = cause === shared.PR_FIX_CAUSE.REVIEW_FEEDBACK
3153
+ && options.reviewFindingResolution;
3154
+ // Review-fix completion is governed by the live branch probe. Preserve the
3155
+ // legacy files_changed behavior for unrelated fix causes.
3156
+ let explicitlyChangedBranch = options.branchChanged !== false;
3157
+ if (cause === shared.PR_FIX_CAUSE.REVIEW_FEEDBACK && options.branchChange) {
3158
+ explicitlyChangedBranch = true;
3159
+ }
3160
+ if (evidenceOnlyReviewResolution) explicitlyChangedBranch = false;
3146
3161
  let result = null;
3147
3162
  shared.mutatePullRequests(prScope, (prs) => {
3148
3163
  if (!Array.isArray(prs)) return prs;
@@ -3486,7 +3501,7 @@ async function rebaseBranchOntoMain(pr, project, config) {
3486
3501
  // and produce mirror writes. Misconfigured engine.worktreeRoot is the only
3487
3502
  // way this lands inside root; the assert throws so the caller can recover.
3488
3503
  try {
3489
- shared.assertWorktreeOutsideProject(tmpWt, root);
3504
+ shared.assertWorktreeOutsideProjects(tmpWt, shared.getProjects(config));
3490
3505
  } catch (err) {
3491
3506
  log('warn', `Post-merge rebase: refusing nested worktree path — ${err.message}`);
3492
3507
  return { success: false, error: err.message };
@@ -4569,8 +4584,8 @@ function parseCompletionBoolean(value) {
4569
4584
  }
4570
4585
 
4571
4586
  // Detect a deliberate no-op completion — the agent correctly declined to make
4572
- // changes (work was already shipped, dispatch premise was wrong, self-authored
4573
- // review with no actionable feedback, etc.) and should NOT be flagged as a
4587
+ // changes (work was already shipped, dispatch premise was wrong, or a finding
4588
+ // was proven resolved/invalid with current-code evidence) and should NOT be flagged as a
4574
4589
  // silent failure for missing a PR. Honored signals:
4575
4590
  // - completion.noop === true (canonical, primary)
4576
4591
  // - completion.result === 'noop' OR completion.result.type === 'noop'
@@ -4651,6 +4666,39 @@ function classifyBenignZeroTargetOutcome(dispatchItem, completion, resultSummary
4651
4666
  return noMutation ? evidence : null;
4652
4667
  }
4653
4668
 
4669
+ function validateReviewFixCompletion({ type, meta, branchChange, structuredCompletion } = {}) {
4670
+ if (!shared.isFixLikeWorkType(type)) return { valid: true };
4671
+ const cause = shared.getPrFixAutomationCause({
4672
+ dispatchKey: meta?.dispatchKey,
4673
+ source: meta?.source,
4674
+ task: meta?.item?.title,
4675
+ });
4676
+ if (cause !== shared.PR_FIX_CAUSE.REVIEW_FEEDBACK
4677
+ || (branchChange?.changed === true && branchChange.evidence === 'remote-head')) {
4678
+ return { valid: true };
4679
+ }
4680
+
4681
+ const findingId = shared.prReviewFindingIdentity(meta?.pr);
4682
+ const resolution = structuredCompletion?.reviewFindingResolution;
4683
+ const disposition = String(resolution?.disposition || '').trim().toLowerCase();
4684
+ const reportedFindingId = String(resolution?.findingId || '').trim();
4685
+ const currentCodeEvidence = String(resolution?.currentCodeEvidence || '').trim();
4686
+ const hasCurrentCodeReference = /(?:[a-z]:[\\/])?[\w./\\-]+\.[a-z0-9]+:\d+\b/i.test(currentCodeEvidence)
4687
+ || /\bcommit\s+[0-9a-f]{7,40}\b/i.test(currentCodeEvidence);
4688
+ const validDisposition = disposition === 'already-resolved' || disposition === 'invalid';
4689
+ const explicitNoop = !!parseCompletionNoop(structuredCompletion);
4690
+
4691
+ if (explicitNoop && validDisposition && reportedFindingId === findingId && hasCurrentCodeReference) {
4692
+ return { valid: true, findingId, resolution };
4693
+ }
4694
+
4695
+ return {
4696
+ valid: false,
4697
+ 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.`,
4699
+ };
4700
+ }
4701
+
4654
4702
  function normalizeReviewVerdict(verdict) {
4655
4703
  const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
4656
4704
  if (value === 'approve' || value === 'approved') return REVIEW_STATUS.APPROVED;
@@ -5807,13 +5855,70 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5807
5855
  }
5808
5856
 
5809
5857
  // No-op signal: agent declared the work was correctly NOT done (already
5810
- // shipped, dispatch premise wrong, self-authored review, etc.). Skip the PR
5858
+ // shipped, dispatch premise wrong, evidence-backed resolved finding, etc.). Skip the PR
5811
5859
  // attachment contract — a missing PR is intentional, not a silent failure.
5812
- const noopRationale = (effectiveSuccess && meta?.item?.id && !skipDoneStatus)
5860
+ let prFixBranchChange = null;
5861
+ let prFixBuildStillFailing = null;
5862
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
5863
+ try {
5864
+ prFixBranchChange = await detectPrFixBranchChange(meta, config);
5865
+ } catch (err) {
5866
+ log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
5867
+ prFixBranchChange = { changed: null, reason: err.message };
5868
+ }
5869
+ if (prFixBranchChange?.changed === false) {
5870
+ const fixCause = shared.getPrFixAutomationCause({
5871
+ dispatchKey: dispatchItem?.meta?.dispatchKey,
5872
+ source: meta?.source,
5873
+ task: dispatchItem?.task,
5874
+ });
5875
+ if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
5876
+ try {
5877
+ const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
5878
+ if (liveStatus === shared.BUILD_STATUS.FAILING) {
5879
+ prFixBuildStillFailing = liveStatus;
5880
+ }
5881
+ } catch (err) {
5882
+ log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
5883
+ }
5884
+ }
5885
+ }
5886
+ }
5887
+ let noopRationale = (effectiveSuccess && !skipDoneStatus)
5813
5888
  ? (parseCompletionNoop(structuredCompletion) || benignNoop)
5814
5889
  : null;
5890
+ let reviewFixResolution = null;
5891
+ if (effectiveSuccess && !skipDoneStatus) {
5892
+ const reviewFixValidation = validateReviewFixCompletion({
5893
+ type,
5894
+ meta,
5895
+ branchChange: prFixBranchChange,
5896
+ structuredCompletion,
5897
+ });
5898
+ if (!reviewFixValidation.valid) {
5899
+ skipDoneStatus = true;
5900
+ meta._agentId = agentId;
5901
+ const detection = {
5902
+ phrase: 'review-fix-resolution-evidence-missing',
5903
+ reason: reviewFixValidation.reason,
5904
+ };
5905
+ const reason = meta?.item?.id
5906
+ ? deferNonTerminalCompletion(meta, detection)
5907
+ : detection.reason;
5908
+ completionContractFailure = {
5909
+ reason,
5910
+ itemId: meta?.item?.id || dispatchItem.id,
5911
+ nonTerminal: true,
5912
+ processWorkItemFailure: false,
5913
+ };
5914
+ noopRationale = null;
5915
+ log('warn', `Review-fix completion rejected for ${meta?.item?.id || dispatchItem.id}: ${reviewFixValidation.reason}`);
5916
+ } else {
5917
+ reviewFixResolution = reviewFixValidation.resolution || null;
5918
+ }
5919
+ }
5815
5920
  if (noopRationale) {
5816
- log('info', `No-op completion for ${meta.item.id}: ${noopRationale.slice(0, 200)}`);
5921
+ log('info', `No-op completion for ${meta?.item?.id || dispatchItem.id}: ${noopRationale.slice(0, 200)}`);
5817
5922
  }
5818
5923
 
5819
5924
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus && !noopRationale) {
@@ -6051,41 +6156,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6051
6156
 
6052
6157
  // Archive is manual — user archives plans from the dashboard when ready
6053
6158
 
6054
- let prFixBranchChange = null;
6055
- let prFixBuildStillFailing = null;
6056
- if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
6057
- try {
6058
- prFixBranchChange = await detectPrFixBranchChange(meta, config);
6059
- } catch (err) {
6060
- log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
6061
- prFixBranchChange = { changed: null, reason: err.message };
6062
- }
6063
- // #639 — a BUILD_FAILURE fix that didn't change the branch is not
6064
- // necessarily a genuine no-op: the agent may have found its own (or a
6065
- // prior dispatch's) fix commit already present and concluded "nothing to
6066
- // change" without checking whether CI actually turned green on that
6067
- // commit. Re-poll live CI for the head before letting updatePrAfterFix
6068
- // accept the no-op — if it's still failing, flag it so the no-op path
6069
- // records a distinct fixIneffective signal instead of a clean no-op.
6070
- if (prFixBranchChange?.changed === false) {
6071
- const fixCause = shared.getPrFixAutomationCause({
6072
- dispatchKey: dispatchItem?.meta?.dispatchKey,
6073
- source: meta?.source,
6074
- task: dispatchItem?.task,
6075
- });
6076
- if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
6077
- try {
6078
- const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
6079
- if (liveStatus === shared.BUILD_STATUS.FAILING) {
6080
- prFixBuildStillFailing = liveStatus;
6081
- }
6082
- } catch (err) {
6083
- log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
6084
- }
6085
- }
6086
- }
6087
- }
6088
-
6089
6159
  // Scheduled task back-reference: update SQL state and write a linked inbox note.
6090
6160
  if (meta?.item?._scheduleId) {
6091
6161
  try {
@@ -6191,11 +6261,20 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6191
6261
  || completionContractFailure?.nonTerminal === true;
6192
6262
  const finalResult = hardContractFail ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
6193
6263
  if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus && !benignNoop) {
6194
- await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary, structuredCompletion, dispatchItem);
6264
+ await updatePrAfterReview(
6265
+ agentId,
6266
+ meta?.pr,
6267
+ meta?.project,
6268
+ config,
6269
+ resultSummary,
6270
+ structuredCompletion,
6271
+ dispatchItem,
6272
+ { reportedModel: model },
6273
+ );
6195
6274
  } else if (type === WORK_TYPE.REVIEW) {
6196
6275
  log('warn', `Skipping PR review metadata update for ${meta?.pr?.id || meta?.pr?.url || '(unknown PR)'} because review dispatch ${dispatchItem.id} did not complete cleanly`);
6197
6276
  }
6198
- if (shared.isFixLikeWorkType(type) && effectiveSuccess) {
6277
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess && !skipDoneStatus) {
6199
6278
  updatePrAfterFix(meta?.pr, meta?.project, meta?.source, {
6200
6279
  branchChanged: fixCompletionChangedBranch(structuredCompletion),
6201
6280
  automationCauseKey: meta?.automationCauseKey,
@@ -6204,6 +6283,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6204
6283
  buildStillFailing: prFixBuildStillFailing,
6205
6284
  config,
6206
6285
  noopReason: noopRationale || meta?._noopReason || '',
6286
+ reviewFindingResolution: reviewFixResolution,
6207
6287
  });
6208
6288
  // W-mpg58wv3 — closure-loop dispatch. When the completed fix WI was spawned
6209
6289
  // to address a minion REQUEST_CHANGES (its meta carries
@@ -7044,6 +7124,7 @@ module.exports = {
7044
7124
  isReviewBailout,
7045
7125
  parseCompletionNoop,
7046
7126
  classifyBenignZeroTargetOutcome, // exported for testing
7127
+ validateReviewFixCompletion,
7047
7128
  detectNonTerminalResultSummary,
7048
7129
  deferNonTerminalCompletion,
7049
7130
  deferPhantomCompletion,
@@ -2034,6 +2034,7 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
2034
2034
  *
2035
2035
  * @param {object} opts
2036
2036
  * @param {object} opts.item persisted dispatch record (has id, originalRef, meta.branch, meta.project.{localPath,name})
2037
+ * @param {object} [opts.config] current config; worktree mode makes this helper fail closed
2037
2038
  * @param {boolean} [opts.isTerminalFailure] true on error/timeout/crash
2038
2039
  * @param {string} [opts.resultLabel] short status word for the alert body
2039
2040
  * @param {function} [opts.log] (level, msg) => void
@@ -2043,8 +2044,9 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
2043
2044
  * @returns {Promise<object>} restore result (or { skipped:true } for a non-live record)
2044
2045
  */
2045
2046
  async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
2046
- const { item, isTerminalFailure, resultLabel, log, writeInboxAlert, gitOpts, _git } = opts;
2047
- if (!item || !item.originalRef || !item.meta?.branch || !item.meta?.project?.localPath) {
2047
+ const { item, config, isTerminalFailure, resultLabel, log, writeInboxAlert, gitOpts, _git } = opts;
2048
+ if (!shared.isLiveCheckoutDispatchRecord(item, config)
2049
+ || !item.originalRef || !item.meta?.branch || !item.meta?.project?.localPath) {
2048
2050
  return { restored: false, reason: 'not-live-record', skipped: true };
2049
2051
  }
2050
2052
  try {