@yemi33/minions 0.1.1650 → 0.1.1651

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.
package/CHANGELOG.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1650 (2026-05-01)
3
+ ## 0.1.1651 (2026-05-01)
4
+
5
+ ### Other
6
+ - refactor: delegate orchestration policy
7
+
8
+ ## 0.1.1649 (2026-05-01)
4
9
 
5
10
  ### Features
6
- - reject premature task_complete for nonterminal summaries
7
11
  - ADO build poll repositoryId GUID handling
8
12
 
9
13
  ### Fixes
10
- - yemi33/minions#1927
11
14
  - yemi33/minions#1925
12
15
 
13
16
  ## 0.1.1648 (2026-05-01)
package/engine/ado.js CHANGED
@@ -372,6 +372,7 @@ async function forEachActivePr(config, token, callback) {
372
372
  }
373
373
 
374
374
  let projectUpdated = 0;
375
+ const updatedRecords = [];
375
376
  const orgBase = getAdoOrgBase(project);
376
377
 
377
378
  // Parallelize PR polling within each project (max 5 concurrent to avoid rate limits)
@@ -381,31 +382,36 @@ async function forEachActivePr(config, token, callback) {
381
382
  const results = await Promise.allSettled(batch.map(async (pr) => {
382
383
  const prNum = shared.getPrNumber(pr);
383
384
  if (!prNum) return false;
384
- return callback(project, pr, prNum, orgBase, adoRepositoryId);
385
+ const before = shared.snapshotPrRecord(pr);
386
+ const updated = await callback(project, pr, prNum, orgBase, adoRepositoryId);
387
+ if (updated) return { before, after: shared.snapshotPrRecord(pr) };
388
+ return false;
385
389
  }));
386
390
  for (const r of results) {
387
- if (r.status === 'fulfilled' && r.value) projectUpdated++;
391
+ if (r.status === 'fulfilled' && r.value) {
392
+ projectUpdated++;
393
+ updatedRecords.push(r.value);
394
+ }
388
395
  if (r.status === 'rejected') log('warn', `PR poll error: ${r.reason?.message || r.reason}`);
389
396
  }
390
397
  }
391
398
 
392
399
  if (projectUpdated > 0) {
393
400
  mutateJsonFileLocked(shared.projectPrPath(project), (currentPrs) => {
394
- // Merge back updated PRs preserve disk values that may have been changed
395
- // by other writers between poll start and this write
396
- for (const updatedPr of activePrs) {
397
- const updatedPrNumber = shared.getPrNumber(updatedPr);
401
+ // Merge back only fields changed by callbacks; preserve concurrent disk updates.
402
+ for (const { before, after } of updatedRecords) {
403
+ const updatedPrNumber = shared.getPrNumber(after);
398
404
  const idx = currentPrs.findIndex(p =>
399
- p.id === updatedPr.id
405
+ p.id === after.id
400
406
  || (updatedPrNumber != null && shared.getPrNumber(p) === updatedPrNumber)
401
407
  );
402
408
  if (idx >= 0) {
403
409
  // Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
404
410
  // The disk version may have been set to 'approved' by another writer after we read
405
- if (currentPrs[idx].reviewStatus === 'approved' && updatedPr.reviewStatus !== 'approved') {
406
- updatedPr.reviewStatus = 'approved';
411
+ if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
412
+ after.reviewStatus = 'approved';
407
413
  }
408
- currentPrs[idx] = updatedPr;
414
+ shared.applyPrFieldDelta(currentPrs[idx], before, after);
409
415
  }
410
416
  // Don't push if not found — it was deleted by another writer, respect that
411
417
  }
@@ -579,7 +585,6 @@ async function pollPrStatus(config) {
579
585
  const mergeCommitId = prData.lastMergeCommit?.commitId;
580
586
  let buildStatus = pr.buildStatus || 'none';
581
587
  let buildFailReason = pr.buildFailReason || '';
582
- let buildStatuses = []; // for error log fetching
583
588
  let buildStatusResolved = true;
584
589
  let buildStatusStaleDetail = '';
585
590
 
@@ -603,11 +608,6 @@ async function pollPrStatus(config) {
603
608
  if (buildStatus === 'failing') {
604
609
  const failed = prBuilds.find(b => b.result === 'failed');
605
610
  buildFailReason = failed?.definition?.name || 'Build failed';
606
- // Build fake status objects for error log fetching
607
- buildStatuses = prBuilds.filter(b => b.result === 'failed').map(b => ({
608
- state: 'failed', targetUrl: `${orgBase}/${project.adoProject}/_build/results?buildId=${b.id}`,
609
- _buildId: String(b.id),
610
- }));
611
611
  }
612
612
  } else if (allBuilds.length > 0 && pr.buildStatus) {
613
613
  // Stale merge-commit fallback — ADO returned builds for this PR's merge ref
@@ -674,20 +674,7 @@ async function pollPrStatus(config) {
674
674
  }
675
675
  updated = true;
676
676
 
677
- // Fetch actual compiler/build error logs when transitioning to failing
678
677
  if (buildStatus === 'failing') {
679
- const failedStatusObjs = buildStatuses.filter(s => s.state === 'failed' || s.state === 'error').slice(0, 10);
680
- const logParts = [];
681
- const seenBuildIds = new Set();
682
- for (const failedStatusObj of failedStatusObjs) {
683
- const errorLog = await fetchAdoBuildErrorLog(orgBase, project, failedStatusObj, token, pr, seenBuildIds);
684
- if (errorLog) logParts.push(errorLog);
685
- }
686
- if (logParts.length > 0) {
687
- pr.buildErrorLog = logParts.join('\n\n');
688
- log('info', `PR ${pr.id}: fetched error logs from ${logParts.length} failing pipeline(s)`);
689
- }
690
-
691
678
  // Teams notification for build failure — non-blocking
692
679
  try {
693
680
  const teams = require('./teams');
@@ -810,7 +797,7 @@ async function pollPrHumanComments(config) {
810
797
  const allNewDates = allHumanComments.filter(c => (new Date(c.date).getTime() || 0) > cutoffMs).map(c => c.date);
811
798
  if (allNewDates.length > 0 && newHumanComments.length === 0) {
812
799
  pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
813
- return false;
800
+ return true;
814
801
  }
815
802
  if (newHumanComments.length === 0) return false;
816
803
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T01:28:04.266Z"
4
+ "cachedAt": "2026-05-01T02:26:00.431Z"
5
5
  }
@@ -20,8 +20,6 @@ const MINIONS_DIR = shared.MINIONS_DIR;
20
20
  // Lazy require to break circular dependency with engine.js
21
21
  let _lifecycle = null;
22
22
  function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }
23
- let _recovery = null;
24
- function recovery() { if (!_recovery) _recovery = require('./recovery'); return _recovery; }
25
23
 
26
24
  // ─── Dispatch Mutation ───────────────────────────────────────────────────────
27
25
 
@@ -184,6 +182,16 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
184
182
  return !nonRetryable.some(s => r.includes(s));
185
183
  }
186
184
 
185
+ function normalizeRetryableDecision(value) {
186
+ if (typeof value === 'boolean') return value;
187
+ if (typeof value === 'string') {
188
+ const normalized = value.trim().toLowerCase();
189
+ if (['true', 'yes', '1'].includes(normalized)) return true;
190
+ if (['false', 'no', '0'].includes(normalized)) return false;
191
+ }
192
+ return undefined;
193
+ }
194
+
187
195
  function isCompletedWorkItemForFailure(item) {
188
196
  return !!item && (
189
197
  item.status === WI_STATUS.DONE ||
@@ -234,6 +242,7 @@ function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
234
242
 
235
243
  function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
236
244
  const { processWorkItemFailure = true, failureClass } = opts;
245
+ const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
237
246
  let item = null;
238
247
 
239
248
  mutateDispatch((dispatch) => {
@@ -273,7 +282,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
273
282
  }
274
283
 
275
284
  // Update source work item status on failure + auto-retry with backoff
276
- const retryableFailure = isRetryableFailureReason(reason, failureClass);
285
+ const retryableFailure = agentRetryable !== undefined ? agentRetryable : isRetryableFailureReason(reason, failureClass);
277
286
  let completedWorkItemFailure = false;
278
287
  if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
279
288
  // If the live item cannot be resolved, keep the existing retry path.
@@ -295,9 +304,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
295
304
  if (wi) retries = wi._retryCount || 0;
296
305
  } catch (e) { log('warn', 'read retry count: ' + e.message); }
297
306
  const maxRetries = ENGINE_DEFAULTS.maxRetries;
298
- // Use per-class retry limits from recovery.js when failureClass is available
299
- const classAllowsRetry = failureClass ? recovery().shouldRetry(failureClass, retries) : (retries < maxRetries);
300
- if (retryableFailure && classAllowsRetry) {
307
+ const withinSafetyCap = retries < maxRetries;
308
+ if (retryableFailure && withinSafetyCap) {
301
309
  log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/${maxRetries}${failureClass ? ' [' + failureClass + ']' : ''}`);
302
310
  lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
303
311
  // Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
package/engine/github.js CHANGED
@@ -219,14 +219,19 @@ async function forEachActiveGhPr(config, callback) {
219
219
  resetSlugBackoff(slug);
220
220
 
221
221
  let projectUpdated = 0;
222
+ const updatedRecords = [];
222
223
 
223
224
  for (const pr of activePrs) {
224
225
  const prNum = shared.getPrNumber(pr);
225
226
  if (!prNum) continue;
226
227
 
227
228
  try {
229
+ const before = shared.snapshotPrRecord(pr);
228
230
  const updated = await callback(project, pr, prNum, slug);
229
- if (updated) projectUpdated++;
231
+ if (updated) {
232
+ projectUpdated++;
233
+ updatedRecords.push({ before, after: shared.snapshotPrRecord(pr) });
234
+ }
230
235
  } catch (err) {
231
236
  log('warn', `GitHub: failed to poll PR ${pr.id}: ${err.message}`);
232
237
  }
@@ -234,15 +239,15 @@ async function forEachActiveGhPr(config, callback) {
234
239
 
235
240
  if (projectUpdated > 0) {
236
241
  mutateJsonFileLocked(projectPrPath(project), (currentPrs) => {
237
- // Merge back updated PRs and deduplicate
238
- for (const updatedPr of activePrs) {
239
- const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
242
+ // Merge back only fields changed by callbacks; preserve concurrent disk updates.
243
+ for (const { before, after } of updatedRecords) {
244
+ const idx = currentPrs.findIndex(p => p.id === after.id);
240
245
  if (idx >= 0) {
241
246
  // Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
242
- if (currentPrs[idx].reviewStatus === 'approved' && updatedPr.reviewStatus !== 'approved') {
243
- updatedPr.reviewStatus = 'approved';
247
+ if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
248
+ after.reviewStatus = 'approved';
244
249
  }
245
- currentPrs[idx] = updatedPr;
250
+ shared.applyPrFieldDelta(currentPrs[idx], before, after);
246
251
  }
247
252
  }
248
253
  // Remove duplicates — prefer merged/abandoned over active
@@ -265,6 +270,7 @@ async function forEachActiveGhPr(config, callback) {
265
270
  const centralPrs = safeJson(centralPath) || [];
266
271
  const activeCentral = centralPrs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status) && pr.url);
267
272
  let centralUpdated = 0;
273
+ const updatedCentralRecords = [];
268
274
  for (const pr of activeCentral) {
269
275
  const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
270
276
  if (!ghMatch) continue;
@@ -272,14 +278,17 @@ async function forEachActiveGhPr(config, callback) {
272
278
  if (isSlugInBackoff(slug)) continue;
273
279
  const prNum = ghMatch[2];
274
280
  try {
281
+ const before = shared.snapshotPrRecord(pr);
275
282
  const updated = await callback(null, pr, prNum, slug);
276
283
  if (updated) {
277
284
  // Also update title/author/branch if still placeholder
278
- if (pr.title.includes('polling...') || pr.agent === 'human' || pr.description === undefined) {
285
+ const currentTitle = pr.title || '';
286
+ if (!currentTitle || currentTitle.includes('polling...') || pr.agent === 'human' || pr.description === undefined) {
279
287
  const prData = await ghApi(`/pulls/${prNum}`, slug);
280
288
  if (prData) {
281
- if (pr.title.includes('polling...') || /[{}"\[\]]/.test(pr.title) || /^[0-9a-f-]{8,}$/i.test(pr.title)) {
282
- pr.title = (prData.title || pr.title).slice(0, 120);
289
+ const latestTitle = pr.title || '';
290
+ if (!latestTitle || latestTitle.includes('polling...') || /[{}"\[\]]/.test(latestTitle) || /^[0-9a-f-]{8,}$/i.test(latestTitle)) {
291
+ pr.title = (prData.title || latestTitle).slice(0, 120);
283
292
  }
284
293
  if (pr.description === undefined) pr.description = (prData.body || '').slice(0, 500);
285
294
  if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
@@ -291,6 +300,7 @@ async function forEachActiveGhPr(config, callback) {
291
300
  }
292
301
  }
293
302
  centralUpdated++;
303
+ updatedCentralRecords.push({ before, after: shared.snapshotPrRecord(pr) });
294
304
  }
295
305
  } catch (err) {
296
306
  log('warn', `GitHub: failed to poll central PR ${pr.id}: ${err.message}`);
@@ -299,9 +309,9 @@ async function forEachActiveGhPr(config, callback) {
299
309
  if (centralUpdated > 0) {
300
310
  mutateJsonFileLocked(centralPath, (currentPrs) => {
301
311
  // Only merge back central PRs that the callback actually modified
302
- for (const updatedPr of activeCentral) {
303
- const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
304
- if (idx >= 0) currentPrs[idx] = updatedPr;
312
+ for (const { before, after } of updatedCentralRecords) {
313
+ const idx = currentPrs.findIndex(p => p.id === after.id);
314
+ if (idx >= 0) shared.applyPrFieldDelta(currentPrs[idx], before, after);
305
315
  }
306
316
  return currentPrs;
307
317
  }, { defaultValue: [] });
@@ -487,15 +497,7 @@ async function pollPrStatus(config) {
487
497
  }
488
498
  updated = true;
489
499
 
490
- // Fetch actual compiler/build error logs when transitioning to failing
491
500
  if (buildStatus === 'failing') {
492
- const failedRuns = runs.filter(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
493
- const errorLog = await fetchGhBuildErrorLog(slug, failedRuns);
494
- if (errorLog) {
495
- pr.buildErrorLog = errorLog;
496
- log('info', `PR ${pr.id}: fetched ${errorLog.split('\n').length} lines of build error log`);
497
- }
498
-
499
501
  // Teams notification for build failure — non-blocking
500
502
  try {
501
503
  const teams = require('./teams');
@@ -611,7 +613,7 @@ async function pollPrHumanComments(config) {
611
613
  const allNewDates = allCommentEntries.filter(c => (new Date(c.date).getTime() || 0) > cutoffMs).map(c => c.date);
612
614
  if (allNewDates.length > 0 && newComments.length === 0) {
613
615
  pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
614
- return false; // agent comments only — don't trigger fix
616
+ return true; // agent comments only — persist cutoff without triggering fix
615
617
  }
616
618
  if (newComments.length === 0) return false;
617
619
 
@@ -11,6 +11,7 @@ const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, ex
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
14
+ const { resolveRuntime } = require('./runtimes');
14
15
  const queries = require('./queries');
15
16
  const { isBranchActive } = require('./cooldown');
16
17
  const { worktreeDirMatchesBranch } = require('./cleanup');
@@ -980,36 +981,72 @@ async function findOpenPrForBranch(meta, config) {
980
981
  return null;
981
982
  }
982
983
 
983
- function markMissingPrAttachment(meta, agentId, reason, resultSummary) {
984
+ // Lightweight probe for "did the agent's output contain ANY PR URL?". Used by
985
+ // the PR-attachment contract to distinguish silent-failure (no URL anywhere)
986
+ // from auto-link-miss (URL present but engine couldn't canonically attach it).
987
+ // Keep this regex roughly in sync with the gated detection in syncPrsFromOutput
988
+ // — this is yes/no only; no capture groups required.
989
+ function _outputContainsPrUrl(output) {
990
+ if (!output || typeof output !== 'string') return false;
991
+ const prUrlPattern = /https?:\/\/(?:github\.com\/[^\s"'\\)\]]+\/[^\s"'\\)\]]+\/pull\/\d+|(?:dev\.azure\.com|[^/\s"'\\)\]]+\.visualstudio\.com)[^\s"'\\)\]]*?pullrequest\/\d+)/i;
992
+ return prUrlPattern.test(output);
993
+ }
994
+
995
+ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity) {
984
996
  const noPrWiPath = resolveWorkItemPath(meta);
997
+ const isHard = severity !== 'soft';
985
998
  if (noPrWiPath) {
986
999
  mutateJsonFileLocked(noPrWiPath, data => {
987
1000
  if (!Array.isArray(data)) return data;
988
1001
  const w = data.find(i => i.id === meta.item.id);
989
1002
  if (!w) return data;
990
- w.status = WI_STATUS.NEEDS_REVIEW;
991
- w._missingPrAttachment = true;
992
- w.failReason = reason;
993
- w._lastReviewReason = reason;
994
- delete w.completedAt;
995
- delete w._noPr;
996
- delete w._noPrReason;
1003
+ if (isHard) {
1004
+ w.status = WI_STATUS.NEEDS_REVIEW;
1005
+ w._missingPrAttachment = true;
1006
+ w.failReason = reason;
1007
+ w._lastReviewReason = reason;
1008
+ delete w.completedAt;
1009
+ delete w._noPr;
1010
+ delete w._noPrReason;
1011
+ } else {
1012
+ // Soft: don't change status or failReason — the agent did the work,
1013
+ // we just couldn't auto-attach the PR. Surface a flag for the dashboard
1014
+ // so the dispatch row can render a yellow "verify" badge.
1015
+ w._unverifiedPrAttachment = true;
1016
+ w._lastReviewReason = reason;
1017
+ }
997
1018
  return data;
998
1019
  }, { skipWriteIfUnchanged: true });
999
1020
  }
1000
- shared.writeToInbox('engine', `missing-pr-attachment-${meta.item.id}`,
1001
- `# PR attachment missing for ${meta.item.id}\n\n` +
1002
- `**Agent:** ${agentId}\n` +
1003
- `**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
1004
- `**Type:** ${meta.item.type || 'unknown'}\n` +
1005
- `**Branch:** ${meta.branch || '(none)'}\n\n` +
1006
- `${reason}\n` +
1007
- (resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
1008
- null,
1009
- { sourceItem: meta.item.id, reason: 'missing-pr-attachment' });
1021
+ if (isHard) {
1022
+ shared.writeToInbox('engine', `missing-pr-attachment-${meta.item.id}`,
1023
+ `# PR attachment missing for ${meta.item.id}\n\n` +
1024
+ `**Agent:** ${agentId}\n` +
1025
+ `**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
1026
+ `**Type:** ${meta.item.type || 'unknown'}\n` +
1027
+ `**Branch:** ${meta.branch || '(none)'}\n\n` +
1028
+ `${reason}\n` +
1029
+ (resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
1030
+ null,
1031
+ { sourceItem: meta.item.id, reason: 'missing-pr-attachment' });
1032
+ } else {
1033
+ shared.writeToInbox('engine', `pr-auto-link-unverified-${meta.item.id}`,
1034
+ `# PR auto-link unverified for ${meta.item.id}\n\n` +
1035
+ `**Agent:** ${agentId}\n` +
1036
+ `**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
1037
+ `**Type:** ${meta.item.type || 'unknown'}\n` +
1038
+ `**Branch:** ${meta.branch || '(none)'}\n\n` +
1039
+ `${reason}\n\n` +
1040
+ `The agent's output mentioned a PR URL but the engine couldn't canonically attach it ` +
1041
+ `(URL detection regex miss, branch lookup race, untrusted tool_use signature, etc.). ` +
1042
+ `The work likely succeeded — verify against the project's PR list.\n` +
1043
+ (resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
1044
+ null,
1045
+ { sourceItem: meta.item.id, reason: 'pr-auto-link-unverified' });
1046
+ }
1010
1047
  }
1011
1048
 
1012
- async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary) {
1049
+ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary, output) {
1013
1050
  if (!isPrAttachmentRequired(type, meta?.item, meta)) return null;
1014
1051
  if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1015
1052
 
@@ -1037,10 +1074,16 @@ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSu
1037
1074
  if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1038
1075
  }
1039
1076
 
1040
- const reason = `PR-producing work item ${meta.item.id} completed without a canonically attached PR record. Successful completion requires PR.prdItems/pr-links.json to include the work item; branch names, note URLs, and _context.workItemId metadata are not sufficient.`;
1041
- markMissingPrAttachment(meta, agentId, reason, resultSummary);
1042
- log('warn', reason);
1043
- return { reason, itemId: meta.item.id };
1077
+ // Distinguish "agent never claimed a PR" (hard silent failure the contract
1078
+ // was designed to catch) from "agent claimed a PR but engine couldn't attach
1079
+ // it canonically" (soft — verification gap, not a failure).
1080
+ const severity = _outputContainsPrUrl(output) ? 'soft' : 'hard';
1081
+ const reason = severity === 'hard'
1082
+ ? `${meta.item.id} completed but no PR URL was detected in the agent's output. Expected a PR — verify the agent didn't fail silently. (Branch: ${meta.branch || '(none)'}, agent: ${agentId})`
1083
+ : `${meta.item.id} completed and a PR URL was found in the agent's output, but it couldn't be canonically attached. The work likely succeeded — verify by checking the PR list. (Branch: ${meta.branch || '(none)'}, agent: ${agentId})`;
1084
+ markMissingPrAttachment(meta, agentId, reason, resultSummary, severity);
1085
+ log(severity === 'hard' ? 'warn' : 'info', reason);
1086
+ return { reason, itemId: meta.item.id, severity };
1044
1087
  }
1045
1088
 
1046
1089
  // ─── Post-Completion Hooks ──────────────────────────────────────────────────
@@ -1059,9 +1102,7 @@ function parseReviewVerdict(text) {
1059
1102
  // Match "VERDICT: APPROVE" or "VERDICT: REQUEST_CHANGES" (case-insensitive, optional markdown bold)
1060
1103
  const verdictMatch = text.match(/VERDICT[:\s]+\*{0,2}(APPROVE|REQUEST[_\s-]?CHANGES)\*{0,2}/i);
1061
1104
  if (verdictMatch) {
1062
- const v = verdictMatch[1].toUpperCase().replace(/[\s-]/g, '_');
1063
- if (v === 'APPROVE') return 'approved';
1064
- if (v.includes('CHANGES')) return 'changes-requested';
1105
+ return normalizeReviewVerdict(verdictMatch[1]);
1065
1106
  }
1066
1107
  return null;
1067
1108
  }
@@ -1083,7 +1124,7 @@ function isReviewBailout(text) {
1083
1124
  return /bail(ing)?\s+out/i.test(text) || /already\s+posted/i.test(text);
1084
1125
  }
1085
1126
 
1086
- async function updatePrAfterReview(agentId, pr, project, config, resultSummary) {
1127
+ async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null) {
1087
1128
 
1088
1129
  if (!pr?.id) return;
1089
1130
 
@@ -1108,12 +1149,12 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary)
1108
1149
  }
1109
1150
  } catch (e) { log('warn', `Post-review status check for ${pr.id}: ${e.message}`); }
1110
1151
 
1111
- // Fallback: if live check returned pending (e.g., GitHub self-approval blocked), parse verdict from agent output
1152
+ // Fallback: if live check returned pending (e.g., GitHub self-approval blocked), use the agent's completion report.
1112
1153
  if (!postReviewStatus) {
1113
- const verdict = parseReviewVerdict(resultSummary);
1154
+ const verdict = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
1114
1155
  if (verdict) {
1115
1156
  postReviewStatus = verdict;
1116
- log('info', `Parsed review verdict from agent output for ${pr.id}: ${verdict}`);
1157
+ log('info', `Read review verdict from agent completion for ${pr.id}: ${verdict}`);
1117
1158
  }
1118
1159
  }
1119
1160
 
@@ -1700,6 +1741,24 @@ function parseStructuredCompletion(stdout, runtimeName) {
1700
1741
  return result;
1701
1742
  }
1702
1743
 
1744
+ function parseCompletionReportFile(dispatchItem) {
1745
+ const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
1746
+ if (!reportPath || !fs.existsSync(reportPath)) return null;
1747
+ const report = safeJson(reportPath);
1748
+ if (!report || typeof report !== 'object' || Array.isArray(report)) {
1749
+ log('warn', `Ignoring malformed completion report for ${dispatchItem?.id || 'unknown'}: ${reportPath}`);
1750
+ return null;
1751
+ }
1752
+ if (!report.status && report.outcome) report.status = report.outcome;
1753
+ if (!report.status) {
1754
+ log('warn', `Ignoring completion report without status for ${dispatchItem?.id || 'unknown'}: ${reportPath}`);
1755
+ return null;
1756
+ }
1757
+ report._source = 'report-file';
1758
+ report._path = reportPath;
1759
+ return report;
1760
+ }
1761
+
1703
1762
  function normalizeCompletionStatus(status) {
1704
1763
  return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
1705
1764
  }
@@ -1817,6 +1876,28 @@ function deferNonTerminalCompletion(meta, detection) {
1817
1876
  return reason;
1818
1877
  }
1819
1878
 
1879
+ function parseCompletionBoolean(value) {
1880
+ if (typeof value === 'boolean') return value;
1881
+ if (typeof value === 'string') {
1882
+ const normalized = value.trim().toLowerCase();
1883
+ if (['true', 'yes', '1'].includes(normalized)) return true;
1884
+ if (['false', 'no', '0'].includes(normalized)) return false;
1885
+ }
1886
+ return undefined;
1887
+ }
1888
+
1889
+ function normalizeReviewVerdict(verdict) {
1890
+ const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
1891
+ if (value === 'approve' || value === 'approved') return 'approved';
1892
+ if (value === 'request_changes' || value === 'changes_requested' || value === 'changes-requested') return 'changes-requested';
1893
+ return null;
1894
+ }
1895
+
1896
+ function reviewVerdictFromCompletion(completion) {
1897
+ if (!completion || typeof completion !== 'object') return null;
1898
+ return normalizeReviewVerdict(completion.verdict || completion.review_verdict || completion.reviewVerdict);
1899
+ }
1900
+
1820
1901
  function writeNonCleanAgentReport(dispatchItem, agentId, outcome, structuredCompletion, resultSummary, exitCode) {
1821
1902
  if (!dispatchItem?.id || !outcome) {
1822
1903
  log('warn', 'Cannot write non-clean agent report without dispatch id and outcome');
@@ -1952,22 +2033,31 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1952
2033
  // and for the foundation-only state of this plan item; downstream items
1953
2034
  // (P-2a6d9c4f, P-9c4f2d6a) populate dispatchItem.meta.runtimeName at spawn time.
1954
2035
  const runtimeName = dispatchItem.meta?.runtimeName || dispatchItem.runtimeName || 'claude';
1955
- const { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
1956
- const completionGateSummary = resultSummary || (typeof stdout === 'string' && !stdout.includes('"type":') ? stdout : '');
2036
+ let { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
1957
2037
 
1958
- // Try structured completion protocol first (```completion block from agent output)
1959
- const structuredCompletion = parseStructuredCompletion(stdout, runtimeName);
2038
+ // Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
2039
+ const reportCompletion = parseCompletionReportFile(dispatchItem);
2040
+ const structuredCompletion = reportCompletion || parseStructuredCompletion(stdout, runtimeName);
1960
2041
  if (structuredCompletion) {
1961
- log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}`);
2042
+ if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
2043
+ log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
1962
2044
  }
2045
+ const completionGateSummary = resultSummary || (typeof stdout === 'string' && !stdout.includes('"type":') ? stdout : '');
1963
2046
 
1964
2047
  // Save session for potential resume on next dispatch
1965
2048
  if (isSuccess && sessionId && agentId && !agentId.startsWith('temp-')) {
1966
2049
  try {
1967
- shared.safeWrite(path.join(AGENTS_DIR, agentId, 'session.json'), {
1968
- sessionId, dispatchId: dispatchItem.id, savedAt: ts(),
1969
- branch: dispatchItem.meta?.branch || null,
1970
- });
2050
+ const runtime = resolveRuntime(runtimeName);
2051
+ if (runtime && typeof runtime.saveSession === 'function') {
2052
+ runtime.saveSession({
2053
+ agentId,
2054
+ dispatchId: dispatchItem.id,
2055
+ branch: dispatchItem.meta?.branch || null,
2056
+ sessionId,
2057
+ agentsDir: AGENTS_DIR,
2058
+ logger: { warn: (msg) => log('warn', msg) },
2059
+ });
2060
+ }
1971
2061
  } catch (err) { log('warn', `Session save: ${err.message}`); }
1972
2062
  }
1973
2063
 
@@ -1983,15 +2073,19 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1983
2073
  log('info', `Structured completion reports PR (${structuredCompletion.pr}) but regex sync found none — PR may already be tracked`);
1984
2074
  }
1985
2075
 
2076
+ const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
2077
+ const agentNeedsRerun = parseCompletionBoolean(structuredCompletion?.needs_rerun ?? structuredCompletion?.needsRerun) === true;
2078
+ const agentReportedFailure = completionStatus.startsWith('fail') || agentNeedsRerun;
2079
+ const agentRetryable = parseCompletionBoolean(structuredCompletion?.retryable);
2080
+
1986
2081
  // Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
1987
2082
  const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX || type === WORK_TYPE.TEST;
1988
- const autoRecovered = !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
2083
+ const autoRecovered = !agentReportedFailure && !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
1989
2084
  if (autoRecovered) {
1990
2085
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
1991
2086
  }
1992
- const effectiveSuccess = isSuccess || autoRecovered;
2087
+ const effectiveSuccess = (isSuccess && !agentReportedFailure) || autoRecovered;
1993
2088
 
1994
- const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
1995
2089
  let nonCleanReportWritten = false;
1996
2090
  if (completionStatus.startsWith('partial') || autoRecovered || (completionStatus.startsWith('fail') && isSuccess)) {
1997
2091
  const outcome = completionStatus.startsWith('fail') ? 'failure' : 'partial';
@@ -2019,7 +2113,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2019
2113
  // and after 3 such bailouts the WI flips to status=failed even though the
2020
2114
  // original review was posted on the first run.
2021
2115
  if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id) {
2022
- const verdict = parseReviewVerdict(resultSummary);
2116
+ const verdict = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
2023
2117
  if (!verdict && isReviewBailout(resultSummary)) {
2024
2118
  log('info', `Review ${meta.item.id} bailed out (review already posted) — treating as DONE without retry`);
2025
2119
  } else if (!verdict) {
@@ -2116,8 +2210,10 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2116
2210
  }
2117
2211
 
2118
2212
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
2119
- completionContractFailure = await enforcePrAttachmentContract(type, meta, agentId, config, resultSummary);
2120
- if (completionContractFailure) skipDoneStatus = true;
2213
+ completionContractFailure = await enforcePrAttachmentContract(type, meta, agentId, config, resultSummary, stdout);
2214
+ if (completionContractFailure?.severity === 'hard' || completionContractFailure?.nonTerminal) {
2215
+ skipDoneStatus = true;
2216
+ }
2121
2217
  }
2122
2218
 
2123
2219
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
@@ -2223,7 +2319,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2223
2319
  // (retryCount was being deleted by done-marking before the check could read it)
2224
2320
  // Review verdict check similarly moved before updateWorkItemStatus(DONE) — same root cause.
2225
2321
 
2226
- if (type === WORK_TYPE.REVIEW) await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary);
2322
+ if (type === WORK_TYPE.REVIEW) await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary, structuredCompletion);
2227
2323
  if (type === WORK_TYPE.FIX) {
2228
2324
  updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
2229
2325
  // (#984) Sync PRD status for PR-linked features: fix work items have a different ID
@@ -2242,7 +2338,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2242
2338
  }
2243
2339
  }
2244
2340
  checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
2245
- const finalResult = completionContractFailure ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
2341
+ const hardContractFail = completionContractFailure?.severity === 'hard'
2342
+ || completionContractFailure?.nonTerminal === true;
2343
+ const finalResult = hardContractFail ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
2246
2344
  if (finalResult === DISPATCH_RESULT.SUCCESS) {
2247
2345
  extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
2248
2346
  // Also scan inbox notes for skill blocks — agents often write skills to inbox, not stdout
@@ -2270,7 +2368,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2270
2368
  teams.teamsNotifyCompletion(dispatchItem, finalResult, agentId).catch(() => {});
2271
2369
  } catch {}
2272
2370
 
2273
- return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure };
2371
+ return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable };
2274
2372
  }
2275
2373
 
2276
2374
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
@@ -2451,6 +2549,7 @@ module.exports = {
2451
2549
  isReviewBailout,
2452
2550
  parseStructuredCompletion,
2453
2551
  detectNonTerminalResultSummary,
2552
+ parseCompletionReportFile,
2454
2553
  runPostCompletionHooks,
2455
2554
  syncPrdFromPrs,
2456
2555
  resolveWorkItemPath,