@yemi33/minions 0.1.2087 → 0.1.2088

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/engine/github.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, mutatePullRequests, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
8
+ const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, mutatePullRequests, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const { isPreviewStatusBody, hasMinionsMarker, hasVerdictPrefix } = require('./comment-classifier');
11
11
  const { wrapUntrusted, buildSource } = require('./untrusted-fence');
@@ -392,7 +392,7 @@ async function fetchGhBuildErrorLog(slug, failedRuns) {
392
392
  const args = ['api', `repos/${shared.validateGhSlug(slug)}/actions/jobs/${jobId}/logs`];
393
393
  const token = ghToken.resolveTokenForSlug(slug);
394
394
  const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
395
- const result = await _runShellSafeGh(args, { timeout: 15000, maxBuffer: GH_MAX_BUFFER, env });
395
+ const result = await _runShellSafeGh(args, { timeout: FETCH_TIMEOUT_MS.GH_CLI, maxBuffer: GH_MAX_BUFFER, env });
396
396
  if (result && !result.includes('Not Found')) {
397
397
  logParts.push(`--- ${run.name || 'Check'} (log) ---\n${result}`);
398
398
  }
@@ -485,8 +485,8 @@ async function forEachActiveGhPr(config, callback) {
485
485
  const idx = currentPrs.findIndex(p => p.id === after.id);
486
486
  if (idx >= 0) {
487
487
  // Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
488
- if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
489
- after.reviewStatus = 'approved';
488
+ if (currentPrs[idx].reviewStatus === REVIEW_STATUS.APPROVED && after.reviewStatus !== REVIEW_STATUS.APPROVED) {
489
+ after.reviewStatus = REVIEW_STATUS.APPROVED;
490
490
  }
491
491
  // W-mp5trwh60008386d: never downgrade `status: merged` — terminal state. A stale
492
492
  // 404 reaching `prAbandonConfirmCount` could otherwise overwrite a concurrent
@@ -812,12 +812,12 @@ async function pollPrStatus(config) {
812
812
 
813
813
  if (newStatus === PR_STATUS.MERGED || newStatus === PR_STATUS.ABANDONED) {
814
814
  // Resolve stale 'waiting' review status — won't be polled again after this
815
- if (pr.reviewStatus === 'waiting') {
816
- pr.reviewStatus = newStatus === PR_STATUS.MERGED ? 'approved' : 'pending';
815
+ if (pr.reviewStatus === REVIEW_STATUS.WAITING) {
816
+ pr.reviewStatus = newStatus === PR_STATUS.MERGED ? REVIEW_STATUS.APPROVED : REVIEW_STATUS.PENDING;
817
817
  log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
818
818
  }
819
819
  // Clear stale build status — checks won't be polled after close
820
- if (pr.buildStatus && pr.buildStatus !== 'none') {
820
+ if (pr.buildStatus && pr.buildStatus !== BUILD_STATUS.NONE) {
821
821
  delete pr.buildStatus;
822
822
  delete pr.buildFailReason;
823
823
  delete pr.buildErrorLog;
@@ -857,7 +857,7 @@ async function pollPrStatus(config) {
857
857
  // F6 not yet shipped (field absent).
858
858
  delete pr.humanFeedback.editsSeen;
859
859
  pr.fixDispatched = false;
860
- if (pr.reviewStatus !== 'approved') pr.reviewStatus = 'pending';
860
+ if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = REVIEW_STATUS.PENDING;
861
861
  log('info', `PR ${pr.id} reopened — reset transient state (reviewStatus=${pr.reviewStatus})`);
862
862
  }
863
863
  }
@@ -887,20 +887,20 @@ async function pollPrStatus(config) {
887
887
  pr.reviewedBy = reviewedBy; updated = true;
888
888
  }
889
889
 
890
- let newReviewStatus = pr.reviewStatus || 'pending';
890
+ let newReviewStatus = pr.reviewStatus || REVIEW_STATUS.PENDING;
891
891
  // Once approved, it stays approved permanently
892
- if (pr.reviewStatus === 'approved') {
893
- newReviewStatus = 'approved';
892
+ if (pr.reviewStatus === REVIEW_STATUS.APPROVED) {
893
+ newReviewStatus = REVIEW_STATUS.APPROVED;
894
894
  } else if (states.some(s => s === 'CHANGES_REQUESTED')) {
895
- if (pr.reviewStatus === 'waiting' && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
896
- newReviewStatus = 'waiting';
895
+ if (pr.reviewStatus === REVIEW_STATUS.WAITING && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
896
+ newReviewStatus = REVIEW_STATUS.WAITING;
897
897
  } else {
898
- newReviewStatus = 'changes-requested';
898
+ newReviewStatus = REVIEW_STATUS.CHANGES_REQUESTED;
899
899
  }
900
900
  }
901
- else if (states.some(s => s === 'APPROVED')) newReviewStatus = 'approved';
902
- else if (states.length > 0) newReviewStatus = 'pending';
903
- else if (states.length === 0 && reviews.length > 0 && newReviewStatus === 'pending') newReviewStatus = 'waiting';
901
+ else if (states.some(s => s === 'APPROVED')) newReviewStatus = REVIEW_STATUS.APPROVED;
902
+ else if (states.length > 0) newReviewStatus = REVIEW_STATUS.PENDING;
903
+ else if (states.length === 0 && reviews.length > 0 && newReviewStatus === REVIEW_STATUS.PENDING) newReviewStatus = REVIEW_STATUS.WAITING;
904
904
 
905
905
  if (pr.reviewStatus !== newReviewStatus) {
906
906
  log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
@@ -919,7 +919,7 @@ async function pollPrStatus(config) {
919
919
  pr.lastBuildCheck = ts();
920
920
  updated = true;
921
921
  const runs = checksData.check_runs;
922
- let buildStatus = 'none';
922
+ let buildStatus = BUILD_STATUS.NONE;
923
923
  let buildFailReason = '';
924
924
  let buildFailureSignature = '';
925
925
 
@@ -929,7 +929,7 @@ async function pollPrStatus(config) {
929
929
  const allPassed = runs.every(r => r.conclusion === 'success' || r.conclusion === 'skipped' || r.conclusion === 'neutral');
930
930
 
931
931
  if (hasFailed) {
932
- buildStatus = 'failing';
932
+ buildStatus = BUILD_STATUS.FAILING;
933
933
  const failed = runs.find(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
934
934
  buildFailReason = failed?.name || 'Check failed';
935
935
  buildFailureSignature = shared.safeSlugComponent([
@@ -940,21 +940,21 @@ async function pollPrStatus(config) {
940
940
  failed?.output?.text,
941
941
  ].filter(Boolean).join('\n') || buildFailReason, 80);
942
942
  } else if (allDone && allPassed) {
943
- buildStatus = 'passing';
943
+ buildStatus = BUILD_STATUS.PASSING;
944
944
  } else if (allDone) {
945
945
  // Terminal-but-not-passing conclusions (cancelled, action_required,
946
946
  // stale, etc.) — map to 'none' rather than 'failing'. These are not
947
947
  // actionable build failures, so we deliberately do not block merge or
948
948
  // dispatch build-fix agents. Previously fell through to 'running'
949
949
  // forever (P-bf02-github-cancelled-stuck).
950
- buildStatus = 'none';
950
+ buildStatus = BUILD_STATUS.NONE;
951
951
  } else {
952
- buildStatus = 'running';
952
+ buildStatus = BUILD_STATUS.RUNNING;
953
953
  }
954
954
  }
955
955
 
956
956
  if (pr.buildStatus !== buildStatus) {
957
- log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
957
+ log('info', `PR ${pr.id} build: ${pr.buildStatus || BUILD_STATUS.NONE} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
958
958
  pr.buildStatus = buildStatus;
959
959
  if (buildFailReason) pr.buildFailReason = buildFailReason;
960
960
  else delete pr.buildFailReason;
@@ -962,14 +962,14 @@ async function pollPrStatus(config) {
962
962
  else delete pr.buildFailureSignature;
963
963
  // Build transitioned — clear grace period and auto-complete flag
964
964
  delete pr._buildFixPushedAt;
965
- if (buildStatus === 'failing') delete pr._autoCompleted; // allow re-merge after fix
966
- if (buildStatus !== 'failing') {
965
+ if (buildStatus === BUILD_STATUS.FAILING) delete pr._autoCompleted; // allow re-merge after fix
966
+ if (buildStatus !== BUILD_STATUS.FAILING) {
967
967
  delete pr._buildFailNotified;
968
968
  // Preserve buildErrorLog + buildFixAttempts through transient 'none'/'running'
969
969
  // transitions — only clear on confirmed 'passing' recovery. Issue #1232:
970
970
  // clearing on every non-failing transition blinded the next fix agent
971
971
  // while a queued build was still running.
972
- if (buildStatus === 'passing') {
972
+ if (buildStatus === BUILD_STATUS.PASSING) {
973
973
  delete pr.buildErrorLog;
974
974
  delete pr.buildFailureSignature;
975
975
  // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
@@ -978,7 +978,7 @@ async function pollPrStatus(config) {
978
978
  }
979
979
  updated = true;
980
980
  }
981
- if (buildStatus === 'failing') {
981
+ if (buildStatus === BUILD_STATUS.FAILING) {
982
982
  if (buildFailReason && pr.buildFailReason !== buildFailReason) {
983
983
  pr.buildFailReason = buildFailReason;
984
984
  updated = true;
@@ -1005,7 +1005,7 @@ async function pollPrStatus(config) {
1005
1005
  }
1006
1006
 
1007
1007
  // Auto-complete: merge PR when builds green + review approved
1008
- if (pr.status === PR_STATUS.ACTIVE && pr.reviewStatus === 'approved' && pr.buildStatus === 'passing' && !pr._autoCompleted) {
1008
+ if (pr.status === PR_STATUS.ACTIVE && pr.reviewStatus === REVIEW_STATUS.APPROVED && pr.buildStatus === BUILD_STATUS.PASSING && !pr._autoCompleted) {
1009
1009
  const autoComplete = config.engine?.autoCompletePrs === true; // opt-in
1010
1010
  if (autoComplete) {
1011
1011
  try {
@@ -1271,7 +1271,7 @@ async function reconcilePrs(config) {
1271
1271
  title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
1272
1272
  agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
1273
1273
  branch,
1274
- reviewStatus: 'pending',
1274
+ reviewStatus: REVIEW_STATUS.PENDING,
1275
1275
  status: 'active',
1276
1276
  created: ghPr.created_at || ts(),
1277
1277
  url: prUrl,
@@ -1297,7 +1297,7 @@ async function reconcilePrs(config) {
1297
1297
  title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
1298
1298
  agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
1299
1299
  branch,
1300
- reviewStatus: 'pending',
1300
+ reviewStatus: REVIEW_STATUS.PENDING,
1301
1301
  status: 'active',
1302
1302
  created: ghPr.created_at || ts(),
1303
1303
  url: prUrl,
@@ -1358,10 +1358,10 @@ async function checkLiveReviewStatus(pr, project) {
1358
1358
  latestByUser.set(r.user?.login || '', r.state);
1359
1359
  }
1360
1360
  const states = [...latestByUser.values()];
1361
- if (states.some(s => s === 'CHANGES_REQUESTED')) return 'changes-requested';
1362
- if (states.some(s => s === 'APPROVED')) return 'approved';
1363
- if (states.length > 0) return 'pending';
1364
- return 'pending';
1361
+ if (states.some(s => s === 'CHANGES_REQUESTED')) return REVIEW_STATUS.CHANGES_REQUESTED;
1362
+ if (states.some(s => s === 'APPROVED')) return REVIEW_STATUS.APPROVED;
1363
+ if (states.length > 0) return REVIEW_STATUS.PENDING;
1364
+ return REVIEW_STATUS.PENDING;
1365
1365
  } catch (e) {
1366
1366
  log('warn', `Live review check for ${pr.id}: ${e.message}`);
1367
1367
  return null;
@@ -1527,14 +1527,14 @@ async function checkLiveBuildAndConflict(pr, project) {
1527
1527
  if (checksData && Array.isArray(checksData.check_runs)) {
1528
1528
  const runs = checksData.check_runs;
1529
1529
  if (runs.length === 0) {
1530
- buildStatus = 'none';
1530
+ buildStatus = BUILD_STATUS.NONE;
1531
1531
  } else {
1532
1532
  const hasFailed = runs.some(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
1533
1533
  const allDone = runs.every(r => r.status === 'completed');
1534
1534
  const allPassed = runs.every(r => r.conclusion === 'success' || r.conclusion === 'skipped' || r.conclusion === 'neutral');
1535
- if (hasFailed) buildStatus = 'failing';
1536
- else if (allDone && allPassed) buildStatus = 'passing';
1537
- else buildStatus = 'running';
1535
+ if (hasFailed) buildStatus = BUILD_STATUS.FAILING;
1536
+ else if (allDone && allPassed) buildStatus = BUILD_STATUS.PASSING;
1537
+ else buildStatus = BUILD_STATUS.RUNNING;
1538
1538
  }
1539
1539
  }
1540
1540
  } catch (e) {
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
10
  const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
11
- log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
11
+ log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
14
14
  const { resolveRuntime } = require('./runtimes');
@@ -907,7 +907,7 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
907
907
  title: (title || `PR created by ${agentName}`).slice(0, 120),
908
908
  agent: agentName,
909
909
  branch: meta?.branch || '',
910
- reviewStatus: 'pending',
910
+ reviewStatus: REVIEW_STATUS.PENDING,
911
911
  status: PR_STATUS.ACTIVE,
912
912
  // W-mpej044m00076d63: do NOT seed `created` with ts() (engine discovery time).
913
913
  // The next GitHub/ADO poll backfills `pr.created` from the platform's real
@@ -1161,7 +1161,7 @@ async function findOpenPrForBranch(meta, config) {
1161
1161
  if (!ghSlug) return null;
1162
1162
  const maxAttempts = ENGINE_DEFAULTS.prAutoLinkRetries;
1163
1163
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1164
- if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
1164
+ if (attempt > 0) await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
1165
1165
  let raw = '';
1166
1166
  try {
1167
1167
  raw = await runFileCapture('gh', ['pr', 'list', '--head', String(meta.branch), '--repo', ghSlug, '--json', 'number,url,state', '--limit', '1'], { timeout: 15000 });
@@ -1225,7 +1225,7 @@ function _attachFoundPrToWi(found, meta, agentId, resultSummary, config) {
1225
1225
  title: meta.item?.title || `PR #${found.prNumber}`,
1226
1226
  agent: agentId,
1227
1227
  branch: meta.branch || '',
1228
- reviewStatus: 'pending',
1228
+ reviewStatus: REVIEW_STATUS.PENDING,
1229
1229
  status: PR_STATUS.ACTIVE,
1230
1230
  // W-mpej044m00076d63: omit `created` — the next poll backfills from the
1231
1231
  // platform's real createdAt. `_attachedAt` is the discovery-time fallback.
@@ -1699,10 +1699,10 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1699
1699
  // live platform status into local reviewStatus below (which can promote the
1700
1700
  // PR to 'approved' based on platform-side votes).
1701
1701
  const prevReviewStatus = reviewPr?.reviewStatus || '';
1702
- const wasNegative = prevReviewStatus === 'changes-requested' || prevReviewStatus === 'waiting'
1703
- || liveStatus === 'changes-requested' || liveStatus === 'waiting';
1702
+ const wasNegative = prevReviewStatus === REVIEW_STATUS.CHANGES_REQUESTED || prevReviewStatus === REVIEW_STATUS.WAITING
1703
+ || liveStatus === REVIEW_STATUS.CHANGES_REQUESTED || liveStatus === REVIEW_STATUS.WAITING;
1704
1704
  const autoApplyVote = config?.engine?.autoApplyReviewVote ?? ENGINE_DEFAULTS.autoApplyReviewVote;
1705
- const canDismissOwnPriorNegative = verdictRaw === 'approved' && !isSelfReview && wasNegative && projectObjForChecks;
1705
+ const canDismissOwnPriorNegative = verdictRaw === REVIEW_STATUS.APPROVED && !isSelfReview && wasNegative && projectObjForChecks;
1706
1706
  if (canDismissOwnPriorNegative) {
1707
1707
  try {
1708
1708
  const reconcileFn = hostForChecks === 'github'
@@ -1726,7 +1726,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1726
1726
  }
1727
1727
  }
1728
1728
 
1729
- if (autoApplyVote && liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
1729
+ if (autoApplyVote && liveStatus && liveStatus !== REVIEW_STATUS.PENDING) postReviewStatus = liveStatus;
1730
1730
 
1731
1731
  // Fallback: if live check returned pending (e.g., GitHub self-approval blocked), use the agent's completion report.
1732
1732
  if (!postReviewStatus) {
@@ -1740,7 +1740,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1740
1740
  // the verdict and skips the no-verdict retry path, so _retryCount stays
1741
1741
  // unchanged. REQUEST_CHANGES from a self-author is still accepted — a
1742
1742
  // self-author flagging issues on their own PR is harmless and useful.
1743
- if (verdict === 'approved' && isSelfReview) {
1743
+ if (verdict === REVIEW_STATUS.APPROVED && isSelfReview) {
1744
1744
  log('warn', `review verdict rejected: self-review (reviewer=${reviewerName}, author=${reviewPr.agent})`);
1745
1745
  } else {
1746
1746
  postReviewStatus = verdict;
@@ -1757,7 +1757,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1757
1757
  if (!target) return prs;
1758
1758
  // Once approved, stays approved — only changes-requested can override
1759
1759
  if (postReviewStatus) {
1760
- if (target.reviewStatus === 'approved' && postReviewStatus !== 'changes-requested') {
1760
+ if (target.reviewStatus === REVIEW_STATUS.APPROVED && postReviewStatus !== REVIEW_STATUS.CHANGES_REQUESTED) {
1761
1761
  // Keep approved — don't downgrade
1762
1762
  } else {
1763
1763
  target.reviewStatus = postReviewStatus;
@@ -1779,7 +1779,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1779
1779
  ...(reviewThreads.length > 0 ? { threads: reviewThreads } : {}),
1780
1780
  // Preserve fixedAt across re-reviews so the poller guard knows a fix was pushed.
1781
1781
  // Drop it when reviewer requests changes again — that starts a new fix cycle.
1782
- ...(target.minionsReview?.fixedAt && postReviewStatus !== 'changes-requested' ? { fixedAt: target.minionsReview.fixedAt } : {}),
1782
+ ...(target.minionsReview?.fixedAt && postReviewStatus !== REVIEW_STATUS.CHANGES_REQUESTED ? { fixedAt: target.minionsReview.fixedAt } : {}),
1783
1783
  };
1784
1784
  updatedTarget = { ...reviewPr, ...target };
1785
1785
  return prs;
@@ -1796,7 +1796,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1796
1796
  });
1797
1797
  }
1798
1798
 
1799
- log('info', `Updated ${reviewPr.id} → minions review: ${postReviewStatus || 'waiting'} by ${reviewerName}`);
1799
+ log('info', `Updated ${reviewPr.id} → minions review: ${postReviewStatus || REVIEW_STATUS.WAITING} by ${reviewerName}`);
1800
1800
  if (updatedTarget) {
1801
1801
  createReviewFeedbackForAuthor(agentId, updatedTarget, config, {
1802
1802
  reviewContent: reviewNote,
@@ -2188,7 +2188,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
2188
2188
  if (target.humanFeedback && clearPendingFix) target.humanFeedback.pendingFix = false;
2189
2189
  if (explicitlyChangedBranch) {
2190
2190
  // Never downgrade from approved — fix was dispatched but PR is already approved
2191
- if (target.reviewStatus !== 'approved') target.reviewStatus = 'waiting';
2191
+ if (target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = REVIEW_STATUS.WAITING;
2192
2192
  target.minionsReview = { ...target.minionsReview, note: 'Fixed human feedback, awaiting re-review', fixedAt: ts() };
2193
2193
  if (clearPendingFix) {
2194
2194
  log('info', `Updated ${pr.id} → cleared humanFeedback.pendingFix, reset to waiting for re-review`);
@@ -2204,7 +2204,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
2204
2204
  }
2205
2205
  }
2206
2206
  } else {
2207
- if (target.reviewStatus !== 'approved') target.reviewStatus = 'waiting';
2207
+ if (target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = REVIEW_STATUS.WAITING;
2208
2208
  if (explicitlyChangedBranch) {
2209
2209
  target.minionsReview = { ...target.minionsReview, note: 'Fixed, awaiting re-review', fixedAt: ts() };
2210
2210
  log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
@@ -3193,6 +3193,175 @@ function parseCompletionReportFile(dispatchItem, opts = {}) {
3193
3193
  return report;
3194
3194
  }
3195
3195
 
3196
+ function normalizeCompletionArtifacts(rawArtifacts) {
3197
+ let artifacts = rawArtifacts;
3198
+ if (typeof artifacts === 'string') {
3199
+ const trimmed = artifacts.trim();
3200
+ if (!trimmed) return [];
3201
+ try { artifacts = JSON.parse(trimmed); } catch { return []; }
3202
+ }
3203
+ if (!Array.isArray(artifacts)) {
3204
+ artifacts = shared.isPlainObject(artifacts) ? [artifacts] : [];
3205
+ }
3206
+ const out = [];
3207
+ for (const artifact of artifacts) {
3208
+ if (!shared.isPlainObject(artifact)) continue;
3209
+ const normalized = {};
3210
+ for (const key of ['type', 'path', 'title', 'id']) {
3211
+ if (artifact[key] == null) continue;
3212
+ const value = String(artifact[key]).trim();
3213
+ if (value) normalized[key] = value;
3214
+ }
3215
+ if (!normalized.path && !normalized.id && !normalized.title) continue;
3216
+ if (normalized.path) normalized.path = normalizeArtifactPath(normalized.path);
3217
+ out.push(normalized);
3218
+ }
3219
+ return out;
3220
+ }
3221
+
3222
+ function normalizeArtifactPath(artifactPath) {
3223
+ const raw = String(artifactPath || '').trim();
3224
+ if (!raw) return '';
3225
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return raw;
3226
+ let normalized = raw.replace(/\\/g, '/');
3227
+ if (path.isAbsolute(raw)) {
3228
+ try {
3229
+ const rel = path.relative(MINIONS_DIR, raw).replace(/\\/g, '/');
3230
+ if (rel && !rel.startsWith('../') && rel !== '..') normalized = rel;
3231
+ } catch {}
3232
+ }
3233
+ return normalized.replace(/^\.\//, '');
3234
+ }
3235
+
3236
+ function _artifactFilesystemPath(artifactPath) {
3237
+ if (!artifactPath || /^[a-z][a-z0-9+.-]*:\/\//i.test(artifactPath)) return null;
3238
+ if (path.isAbsolute(artifactPath)) return artifactPath;
3239
+ return path.join(MINIONS_DIR, artifactPath);
3240
+ }
3241
+
3242
+ function completionArtifactToNoteEntry(artifact) {
3243
+ if (!shared.isPlainObject(artifact)) return null;
3244
+ const type = String(artifact.type || '').toLowerCase();
3245
+ const artifactPath = normalizeArtifactPath(artifact.path || '');
3246
+ const notePath = artifactPath.replace(/\\/g, '/');
3247
+ const isNote = type === 'note' || /(^|\/)notes\/(?:inbox|archive)\//.test(notePath);
3248
+ if (!isNote || !notePath || /^[a-z][a-z0-9+.-]*:\/\//i.test(notePath)) return null;
3249
+
3250
+ const base = path.basename(notePath);
3251
+ const file = notePath.includes('notes/archive/') ? `archive:${base}` : base;
3252
+ if (!file || file === '.' || file === '/') return null;
3253
+ const entry = { file };
3254
+ if (artifact.id) entry.id = String(artifact.id);
3255
+ if (artifact.title) entry.title = String(artifact.title);
3256
+ if (artifact.path) entry.path = artifactPath;
3257
+ if (!entry.id) {
3258
+ const filePath = _artifactFilesystemPath(artifactPath);
3259
+ if (filePath) {
3260
+ const noteId = shared.parseNoteId(safeRead(filePath));
3261
+ if (noteId) entry.id = noteId;
3262
+ }
3263
+ }
3264
+ return entry;
3265
+ }
3266
+
3267
+ function mergeCompletionArtifacts(existing, additions) {
3268
+ const merged = [];
3269
+ const seen = new Set();
3270
+ function keyFor(artifact) {
3271
+ if (!shared.isPlainObject(artifact)) return '';
3272
+ return [
3273
+ String(artifact.type || ''),
3274
+ String(artifact.path || ''),
3275
+ String(artifact.id || ''),
3276
+ String(artifact.title || ''),
3277
+ ].join('\u0000');
3278
+ }
3279
+ for (const artifact of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(additions) ? additions : [])]) {
3280
+ if (!shared.isPlainObject(artifact)) continue;
3281
+ const normalized = normalizeCompletionArtifacts([artifact])[0];
3282
+ if (!normalized) continue;
3283
+ const key = keyFor(normalized);
3284
+ if (seen.has(key)) continue;
3285
+ seen.add(key);
3286
+ merged.push(normalized);
3287
+ }
3288
+ return merged;
3289
+ }
3290
+
3291
+ function mergeArtifactNotes(existing, additions) {
3292
+ const merged = [];
3293
+ const seen = new Set();
3294
+ function normalize(note) {
3295
+ if (shared.isPlainObject(note)) {
3296
+ const file = String(note.file || '').trim();
3297
+ if (!file) return null;
3298
+ const out = { file };
3299
+ for (const key of ['id', 'title', 'path']) {
3300
+ if (note[key] == null) continue;
3301
+ const value = String(note[key]).trim();
3302
+ if (value) out[key] = value;
3303
+ }
3304
+ return out;
3305
+ }
3306
+ const file = String(note || '').trim();
3307
+ return file ? file : null;
3308
+ }
3309
+ function keyFor(note) {
3310
+ return shared.isPlainObject(note)
3311
+ ? String(note.file || '')
3312
+ : String(note || '');
3313
+ }
3314
+ for (const note of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(additions) ? additions : [])]) {
3315
+ const normalized = normalize(note);
3316
+ if (!normalized) continue;
3317
+ const key = keyFor(normalized);
3318
+ if (seen.has(key)) continue;
3319
+ seen.add(key);
3320
+ merged.push(normalized);
3321
+ }
3322
+ return merged;
3323
+ }
3324
+
3325
+ function promoteCompletionArtifacts(meta, agentId, dispatchId, structuredCompletion, opts = {}) {
3326
+ const itemId = meta?.item?.id;
3327
+ if (!itemId) return { artifacts: [], notes: [] };
3328
+ const wiPath = resolveWorkItemPath(meta);
3329
+ if (!wiPath) return { artifacts: [], notes: [] };
3330
+
3331
+ const artifacts = normalizeCompletionArtifacts(structuredCompletion?.artifacts);
3332
+ const structuredNotes = artifacts.map(completionArtifactToNoteEntry).filter(Boolean);
3333
+ const additionalNotes = Array.isArray(opts.additionalNotes) ? opts.additionalNotes : [];
3334
+ const notes = mergeArtifactNotes(structuredNotes, additionalNotes);
3335
+ const outputLog = opts.outputLog ? String(opts.outputLog) : '';
3336
+ const branch = opts.branch || meta.branch || '';
3337
+ const resultSummary = opts.resultSummary ? String(opts.resultSummary).slice(0, 500) : '';
3338
+
3339
+ if (artifacts.length === 0 && notes.length === 0 && !outputLog && !branch && !resultSummary) {
3340
+ return { artifacts, notes };
3341
+ }
3342
+
3343
+ mutateJsonFileLocked(wiPath, data => {
3344
+ if (!Array.isArray(data)) return data;
3345
+ const wi = data.find(i => i.id === itemId);
3346
+ if (!wi) return data;
3347
+ const arts = shared.isPlainObject(wi._artifacts) ? { ...wi._artifacts } : {};
3348
+ if (outputLog) arts.outputLog = outputLog;
3349
+ if (branch) arts.branch = branch;
3350
+ if (wi._pr) arts.pr = wi._pr;
3351
+ if (wi._prUrl) arts.prUrl = wi._prUrl;
3352
+ if (notes.length > 0) arts.notes = mergeArtifactNotes(arts.notes, notes);
3353
+ if (meta.item?.planFile) arts.plan = meta.item.planFile;
3354
+ if (meta.item?._prdFilename) arts.prd = meta.item._prdFilename;
3355
+ if (meta.item?.sourcePlan) arts.sourcePlan = meta.item.sourcePlan;
3356
+ if (artifacts.length > 0) wi.artifacts = mergeCompletionArtifacts(wi.artifacts, artifacts);
3357
+ if (resultSummary) wi.resultSummary = resultSummary;
3358
+ wi._artifacts = arts;
3359
+ return data;
3360
+ }, { defaultValue: [], skipWriteIfUnchanged: true });
3361
+
3362
+ return { artifacts, notes, agentId, dispatchId };
3363
+ }
3364
+
3196
3365
  function persistCompletionReport(dispatchItem, completion, source = 'fallback') {
3197
3366
  if (!dispatchItem?.id || !completion || typeof completion !== 'object') return completion;
3198
3367
  const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem.id);
@@ -3408,8 +3577,8 @@ function parseCompletionNoop(completion) {
3408
3577
 
3409
3578
  function normalizeReviewVerdict(verdict) {
3410
3579
  const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
3411
- if (value === 'approve' || value === 'approved') return 'approved';
3412
- if (value === 'request_changes' || value === 'changes_requested' || value === 'changes-requested') return 'changes-requested';
3580
+ if (value === 'approve' || value === 'approved') return REVIEW_STATUS.APPROVED;
3581
+ if (value === 'request_changes' || value === 'changes_requested' || value === 'changes-requested') return REVIEW_STATUS.CHANGES_REQUESTED;
3413
3582
  return null;
3414
3583
  }
3415
3584
 
@@ -3795,8 +3964,8 @@ function dispatchReReviewForFix(fixDispatchItem, meta, config) {
3795
3964
  return null;
3796
3965
  }
3797
3966
  const reviewStatus = livePr.reviewStatus || '';
3798
- if (reviewStatus !== 'changes-requested' && reviewStatus !== 'waiting') {
3799
- log('info', `Re-review skipped for ${pr.id}: reviewStatus=${reviewStatus || 'pending'} (only changes-requested/waiting trigger closure-loop)`);
3967
+ if (reviewStatus !== REVIEW_STATUS.CHANGES_REQUESTED && reviewStatus !== REVIEW_STATUS.WAITING) {
3968
+ log('info', `Re-review skipped for ${pr.id}: reviewStatus=${reviewStatus || REVIEW_STATUS.PENDING} (only changes-requested/waiting trigger closure-loop)`);
3800
3969
  return null;
3801
3970
  }
3802
3971
 
@@ -4305,6 +4474,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
4305
4474
  meta._noopReason = noopRationale.slice(0, 500);
4306
4475
  }
4307
4476
  updateWorkItemStatus(meta, WI_STATUS.DONE, '');
4477
+ promoteCompletionArtifacts(meta, agentId, dispatchItem.id, structuredCompletion, { resultSummary });
4308
4478
  }
4309
4479
  // Failure retry is handled by completeDispatch in dispatch.js — not duplicated here.
4310
4480
  // Only clear _decomposing flag on failure so decompose items don't get permanently stuck.
@@ -4895,6 +5065,10 @@ module.exports = {
4895
5065
  enforcePrAttachmentContract,
4896
5066
  markMissingPrAttachment,
4897
5067
  parseCompletionReportFile,
5068
+ normalizeCompletionArtifacts,
5069
+ completionArtifactToNoteEntry,
5070
+ mergeArtifactNotes,
5071
+ promoteCompletionArtifacts,
4898
5072
  persistCompletionReport,
4899
5073
  runPostCompletionHooks,
4900
5074
  syncPrdFromPrs,
@@ -137,6 +137,10 @@ function removeProject(target, options = {}) {
137
137
  pipelineRefs: [],
138
138
  archivedTo: null,
139
139
  purgedDataDir: false,
140
+ // P-bfa2d-remove-project-cleanup — soft-delete tag counts for entries in
141
+ // the three central state files that survived the hard-cancel passes
142
+ // (steps 1, 2). See step 7.5 for the orphan-tag policy rationale.
143
+ orphanedRefs: { pullRequests: 0, workItems: 0, dispatches: 0 },
140
144
  warnings: [],
141
145
  };
142
146
 
@@ -300,6 +304,94 @@ function removeProject(target, options = {}) {
300
304
  } catch (e) { writeError = e; }
301
305
  if (writeError) return { ...summary, error: 'Failed to write config: ' + writeError.message };
302
306
 
307
+ // 7.5. P-bfa2d-remove-project-cleanup — Tag dangling refs in the three
308
+ // CENTRAL state files (`pull-requests.json`, `work-items.json`,
309
+ // `engine/dispatch.json`) so downstream consumers can filter or sweep
310
+ // them. Project-local files in `projects/<name>/` are handled by
311
+ // step 8 (archive or purge), so this step only touches central state.
312
+ //
313
+ // Policy is soft-delete (`_orphaned_at` + `_orphaned_project` tags)
314
+ // rather than hard-delete: preserves history for debugging, lets users
315
+ // see exactly which records were affected if a wrong project name was
316
+ // passed, and is reversible (strip the tags). Steps 1, 2 already
317
+ // hard-cancel/drain matching pending entries; this step only catches
318
+ // survivors (non-pending WIs, central PRs, defense-in-depth dispatch
319
+ // survivors).
320
+ //
321
+ // Lock acquisition is sequential and uses the mutate-* helpers from
322
+ // engine/dispatch.js and engine/shared.js so we never hold two file
323
+ // locks simultaneously. Order: dispatch.json → pull-requests.json →
324
+ // work-items.json (alphabetical by basename). Each helper acquires
325
+ // its own lock, runs the synchronous mutator, then releases before
326
+ // the next helper runs.
327
+ try {
328
+ const orphanedAt = new Date().toISOString();
329
+ const orphanedProject = project.name;
330
+
331
+ // (a) engine/dispatch.json — defense-in-depth. cleanDispatchEntries in
332
+ // step 2 should have already removed every matching entry; tag any
333
+ // survivor so the operator can see the residue.
334
+ try {
335
+ dispatch.mutateDispatch((state) => {
336
+ for (const queue of ['pending', 'active', 'completed']) {
337
+ const arr = Array.isArray(state?.[queue]) ? state[queue] : [];
338
+ for (const d of arr) {
339
+ if (!d || d._orphaned_at) continue;
340
+ if (!_dispatchMatchesProject(d, project, projects)) continue;
341
+ d._orphaned_at = orphanedAt;
342
+ d._orphaned_project = orphanedProject;
343
+ summary.orphanedRefs.dispatches++;
344
+ }
345
+ }
346
+ return state;
347
+ });
348
+ } catch (e) { summary.warnings.push('orphan-tag dispatch: ' + e.message); }
349
+
350
+ // (b) MINIONS_DIR/pull-requests.json — central PR registry. PRs in this
351
+ // file lack an explicit project field; match by canonical scope
352
+ // (e.g. `github:owner/repo`) against the removed project's PR scope.
353
+ const centralPrPath = shared.centralPullRequestsPath();
354
+ if (fs.existsSync(centralPrPath)) {
355
+ try {
356
+ const projectScope = shared.getProjectPrScope(project);
357
+ if (projectScope) {
358
+ shared.mutatePullRequests(centralPrPath, (prs) => {
359
+ for (const pr of prs) {
360
+ if (!pr || pr._orphaned_at) continue;
361
+ const canonical = shared.parseCanonicalPrId(pr.id);
362
+ if (canonical?.scope !== projectScope) continue;
363
+ pr._orphaned_at = orphanedAt;
364
+ pr._orphaned_project = orphanedProject;
365
+ summary.orphanedRefs.pullRequests++;
366
+ }
367
+ return prs;
368
+ });
369
+ }
370
+ } catch (e) { summary.warnings.push('orphan-tag pull-requests: ' + e.message); }
371
+ }
372
+
373
+ // (c) MINIONS_DIR/work-items.json — central WI list. Pending/queued WIs
374
+ // were already CANCELLED by cancelPendingWorkItems in step 1; tag
375
+ // the remaining matchers (dispatched / done / failed / etc.) so
376
+ // downstream consumers can filter them.
377
+ const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
378
+ if (fs.existsSync(centralWiPath)) {
379
+ try {
380
+ shared.mutateWorkItems(centralWiPath, (items) => {
381
+ for (const w of items) {
382
+ if (!w || w._orphaned_at) continue;
383
+ if (w.status === shared.WI_STATUS.CANCELLED) continue;
384
+ if (!_workItemMatchesProject(w, project, projects)) continue;
385
+ w._orphaned_at = orphanedAt;
386
+ w._orphaned_project = orphanedProject;
387
+ summary.orphanedRefs.workItems++;
388
+ }
389
+ return items;
390
+ });
391
+ } catch (e) { summary.warnings.push('orphan-tag work-items: ' + e.message); }
392
+ }
393
+ } catch (e) { summary.warnings.push('orphan-sweep: ' + e.message); }
394
+
303
395
  // 8. Move (or purge) projects/<name>/ — preserves PR/work-item history by
304
396
  // default so a re-add can pick up where it left off.
305
397
  const dataDir = shared.projectStateDir(project);