@yemi33/minions 0.1.1723 → 0.1.1725

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,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1725 (2026-05-05)
4
+
5
+ ### Features
6
+ - fix pipeline action feedback (#2078)
7
+ - add per-cause PR automation dedupe (#2075)
8
+
3
9
  ## 0.1.1723 (2026-05-05)
4
10
 
5
11
  ### Other
@@ -934,7 +934,13 @@ function ccRetryLast(tabId, retryId) {
934
934
 
935
935
  async function _ccFetch(url, body) {
936
936
  var res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
937
- if (!res.ok) { var d = await res.json().catch(function() { return {}; }); throw new Error(d.error || 'Request failed (' + res.status + ')'); }
937
+ if (!res.ok) {
938
+ var d = await res.json().catch(function() { return {}; });
939
+ var err = new Error(d.error || 'Request failed (' + res.status + ')');
940
+ err.status = res.status;
941
+ err.data = d;
942
+ throw err;
943
+ }
938
944
  return res;
939
945
  }
940
946
 
@@ -946,6 +952,8 @@ function _tagServerExecuted(actions, actionResults) {
946
952
  if (r && r.ok) {
947
953
  actions[i]._serverExecuted = true;
948
954
  if (r.id) actions[i]._serverId = r.id;
955
+ if (r.warning) actions[i]._serverWarning = r.warning;
956
+ if (r.duplicate) actions[i]._serverDuplicate = true;
949
957
  } else if (r && r.error) {
950
958
  actions[i]._serverExecuted = true;
951
959
  actions[i]._serverError = r.error;
@@ -965,8 +973,10 @@ async function ccExecuteAction(action, targetTabId) {
965
973
  status.style.color = 'var(--red)';
966
974
  } else {
967
975
  var label = action._serverId ? escHtml(action._serverId) : escHtml(action.title || action.type);
968
- status.innerHTML = '&#10003; ' + escHtml(action.type) + ': <strong>' + label + '</strong>';
969
- status.style.color = 'var(--green)';
976
+ status.innerHTML = '&#10003; ' + escHtml(action.type) + ': <strong>' + label + '</strong>' +
977
+ (action._serverDuplicate ? ' <span style="color:var(--orange)">already exists</span>' : '') +
978
+ (action._serverWarning ? '<div style="font-size:10px;color:var(--muted);margin-top:2px">' + escHtml(action._serverWarning) + '</div>' : '');
979
+ status.style.color = action._serverDuplicate ? 'var(--orange)' : 'var(--green)';
970
980
  }
971
981
  ccAddMessage('action', status.outerHTML, false, targetTabId);
972
982
  if (['dispatch','fix','implement','explore','review','test','create-meeting'].includes(action.type)) wakeEngine();
@@ -1285,9 +1295,18 @@ async function ccExecuteAction(action, targetTabId) {
1285
1295
  break;
1286
1296
  }
1287
1297
  case 'create-pipeline': {
1288
- await _ccFetch('/api/pipelines', { id: action.id, title: action.title, stages: action.stages || [], trigger: action.trigger || null, stopWhen: action.stopWhen || null, monitoredResources: action.monitoredResources || null });
1289
- status.innerHTML = '&#10003; Pipeline created: <strong>' + escHtml(action.id) + '</strong>';
1290
- status.style.color = 'var(--green)';
1298
+ try {
1299
+ await _ccFetch('/api/pipelines', { id: action.id, title: action.title, stages: action.stages || [], trigger: action.trigger || null, stopWhen: action.stopWhen || null, monitoredResources: action.monitoredResources || null });
1300
+ status.innerHTML = '&#10003; Pipeline created: <strong>' + escHtml(action.id) + '</strong>';
1301
+ status.style.color = 'var(--green)';
1302
+ } catch (e) {
1303
+ if (e.status === 409) {
1304
+ status.innerHTML = '&#10003; Pipeline already exists: <strong>' + escHtml(action.id) + '</strong>';
1305
+ status.style.color = 'var(--orange)';
1306
+ } else {
1307
+ throw e;
1308
+ }
1309
+ }
1291
1310
  break;
1292
1311
  }
1293
1312
  case 'delete-pipeline': {
@@ -38,7 +38,7 @@ function cmdShowHistory() {
38
38
  '<div class="cmd-history-item-meta">' +
39
39
  '<span class="chip" style="color:' + intentColor + '">' + intentLabel + '</span>' +
40
40
  '<span>' + ago + '</span>' +
41
- '<span>' + date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + '</span>' +
41
+ '<span>' + formatLocalDateTime(date) + '</span>' +
42
42
  '</div>' +
43
43
  '</div>' +
44
44
  '<button class="cmd-history-resubmit" onclick="cmdResubmit(' + i + ')">Resubmit</button>' +
@@ -42,8 +42,8 @@ function renderDetailContent(detail, tab) {
42
42
  html += '<h4>Current Status</h4><div class="section">';
43
43
  html += 'Status: <span style="color:var(--' + (detail.statusData.status === 'working' ? 'yellow' : detail.statusData.status === 'done' ? 'green' : 'muted') + ')">' + (detail.statusData.status || 'idle').toUpperCase() + '</span>\n';
44
44
  if (detail.statusData.task) html += 'Task: ' + escHtml(detail.statusData.task) + '\n';
45
- if (detail.statusData.started_at) html += 'Started: ' + detail.statusData.started_at + '\n';
46
- if (detail.statusData.completed_at) html += 'Completed: ' + detail.statusData.completed_at + '\n';
45
+ if (detail.statusData.started_at) html += 'Started: ' + formatLocalDateTime(detail.statusData.started_at) + '\n';
46
+ if (detail.statusData.completed_at) html += 'Completed: ' + formatLocalDateTime(detail.statusData.completed_at) + '\n';
47
47
  if (detail.statusData.started_at && detail.statusData.completed_at) {
48
48
  var dMs = new Date(detail.statusData.completed_at).getTime() - new Date(detail.statusData.started_at).getTime();
49
49
  if (dMs > 0) {
@@ -132,7 +132,7 @@ function renderDetailContent(detail, tab) {
132
132
  '<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(d.task) + '">' + escHtml(d.task.slice(0, 80)) + '</td>' +
133
133
  '<td><span class="dispatch-type ' + d.type + '">' + escHtml(d.type) + '</span></td>' +
134
134
  '<td style="color:' + color + '"' + reason + '>' + escHtml(d.result) + (isError && d.reason ? ' <span style="font-size:10px;color:var(--muted)">(' + escHtml(d.reason.slice(0, 50)) + ')</span>' : '') + '</td>' +
135
- '<td style="font-size:10px;color:var(--muted)">' + (d.completed_at ? new Date(d.completed_at).toLocaleString() : '') + '</td>' +
135
+ '<td style="font-size:10px;color:var(--muted)">' + (d.completed_at ? formatLocalDateTime(d.completed_at) : '') + '</td>' +
136
136
  '</tr>';
137
137
  });
138
138
  html += '</tbody></table>';
@@ -60,7 +60,7 @@ function _processStatusUpdate(data) {
60
60
  localStorage.setItem('minions-install-id', data.installId);
61
61
  }
62
62
  // Always update cheap elements
63
- document.getElementById('ts').textContent = new Date(data.timestamp).toLocaleTimeString();
63
+ document.getElementById('ts').textContent = formatLocalTime(data.timestamp);
64
64
  const engineState = (data.engine && data.engine.state) ? data.engine.state : 'stopped';
65
65
  document.getElementById('setup-banner').style.display = (!data.initialized && engineState !== 'stopped') ? 'block' : 'none';
66
66
  const autoEl = document.getElementById('auto-approve-badge');
@@ -75,7 +75,7 @@ function renderAdoThrottleAlert(adoThrottle) {
75
75
  el.innerHTML = '';
76
76
  return;
77
77
  }
78
- const resumeTime = new Date(adoThrottle.retryAfter).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
78
+ const resumeTime = formatLocalTime(adoThrottle.retryAfter);
79
79
  el.innerHTML =
80
80
  '<span class="engine-alert-msg">&#x26A0;&#xFE0F; ADO rate-limited — resume ~' + resumeTime + ' (' + adoThrottle.consecutiveHits + ' consecutive hit' + (adoThrottle.consecutiveHits !== 1 ? 's' : '') + ')</span>';
81
81
  el.style.display = 'flex';
@@ -89,7 +89,7 @@ function renderGhThrottleAlert(ghThrottle) {
89
89
  el.innerHTML = '';
90
90
  return;
91
91
  }
92
- const resumeTime = new Date(ghThrottle.retryAfter).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
92
+ const resumeTime = formatLocalTime(ghThrottle.retryAfter);
93
93
  el.innerHTML =
94
94
  '<span class="engine-alert-msg">&#x26A0;&#xFE0F; GitHub rate-limited — resume ~' + resumeTime + ' (' + ghThrottle.consecutiveHits + ' consecutive hit' + (ghThrottle.consecutiveHits !== 1 ? 's' : '') + ')</span>';
95
95
  el.style.display = 'flex';
@@ -212,7 +212,7 @@ function renderEngineLog(log) {
212
212
 
213
213
  function shortTime(t) {
214
214
  if (!t) return '';
215
- try { return new Date(t).toLocaleTimeString(); } catch { return t; }
215
+ return formatLocalTime(t);
216
216
  }
217
217
 
218
218
  async function showErrorDetails(agentId, reason, task) {
@@ -81,8 +81,7 @@ function renderNotes(notes) {
81
81
  // Show last updated timestamp
82
82
  const updatedEl = document.getElementById('notes-updated');
83
83
  if (updatedEl && updatedAt) {
84
- const d = new Date(updatedAt);
85
- updatedEl.textContent = 'updated ' + d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
84
+ updatedEl.textContent = 'updated ' + formatLocalDateTime(updatedAt);
86
85
  }
87
86
 
88
87
  if (!content || !content.trim()) { el.innerHTML = '<p class="empty">No team notes yet.</p>'; return; }
@@ -46,7 +46,7 @@ function renderMeetings(meetings) {
46
46
  }).join(' ');
47
47
 
48
48
  const dt = m.completedAt || m.createdAt;
49
- const timeStr = dt ? new Date(dt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
49
+ const timeStr = dt ? formatLocalDateTime(dt) : '';
50
50
 
51
51
  return '<div data-file="meetings/' + escHtml(m.id) + '.json" style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;cursor:pointer;position:relative" onclick="if(shouldIgnoreSelectionClick(event))return;openMeetingDetail(\'' + escHtml(m.id) + '\')">' +
52
52
  '<div style="display:flex;justify-content:space-between;align-items:center">' +
@@ -61,6 +61,26 @@ function _renderMonitoredResources(resources, options) {
61
61
  return '<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:3px;align-items:center">' + heading + pills.join('') + '</div>';
62
62
  }
63
63
 
64
+ function _getPipelineActiveRun(pipeline) {
65
+ return ((pipeline && pipeline.runs) || []).find(function(r) { return r.status === 'running' || r.status === 'paused'; });
66
+ }
67
+
68
+ function _getPipelineTriggerLabel(pipeline) {
69
+ return pipeline?.trigger?.cron ? _cronToHuman(pipeline.trigger.cron) : 'Manual trigger';
70
+ }
71
+
72
+ function _getPipelineStageLabel(pipeline) {
73
+ var count = ((pipeline && pipeline.stages) || []).length;
74
+ return count + ' stage' + (count === 1 ? '' : 's');
75
+ }
76
+
77
+ function _getPipelineEmptyRunCopy(pipeline) {
78
+ if (pipeline?.trigger?.cron) {
79
+ return 'No runs yet. Scheduled for ' + _cronToHuman(pipeline.trigger.cron) + ' (' + Intl.DateTimeFormat().resolvedOptions().timeZone + '). Use Run Now to start the first run immediately.';
80
+ }
81
+ return 'No runs yet. Use Run Now to start this pipeline.';
82
+ }
83
+
64
84
  /**
65
85
  * Render clickable artifact links for a pipeline stage.
66
86
  * Each artifact type gets an icon and navigates to the relevant detail view.
@@ -233,13 +253,14 @@ function _buildNodeChain(stages, run, options) {
233
253
 
234
254
  html += '</div>';
235
255
 
236
- // Loop indicator — only for pipelines with stopWhen or condition stages (repeat-until pattern)
256
+ // Repeat indicator — cron pipelines repeat on schedule; condition/stopWhen pipelines repeat until a terminal condition.
237
257
  var hasStopWhen = !!pipeline?.stopWhen;
238
258
  var hasConditionStage = (pipeline?.stages || []).some(function(s) { return s.type === 'condition'; });
239
- if (hasStopWhen || hasConditionStage) {
259
+ var hasCron = !!pipeline?.trigger?.cron;
260
+ if (hasStopWhen || hasConditionStage || hasCron) {
240
261
  var runCount = (pipeline.runs || []).length;
241
262
  var cronLabel = pipeline?.trigger?.cron ? _cronToHuman(pipeline.trigger.cron) : 'until condition met';
242
- html += '<div class="pl-node-loop">\u21BA Loop (' + escHtml(cronLabel) + ')';
263
+ html += '<div class="pl-node-loop">\u21BA ' + (hasCron ? 'Repeats' : 'Loop') + ' (' + escHtml(cronLabel) + ')';
243
264
  if (runCount > 0) html += ' \u00b7 Run ' + runCount;
244
265
  html += '</div>';
245
266
  }
@@ -261,11 +282,11 @@ function renderPipelines(pipelines) {
261
282
  countEl.textContent = pipelines.length;
262
283
 
263
284
  el.innerHTML = pipelines.map(function(p) {
264
- const activeRun = (p.runs || []).find(function(r) { return r.status === 'running'; });
285
+ const activeRun = _getPipelineActiveRun(p);
265
286
  const lastRun = (p.runs || []).slice(-1)[0];
266
287
  const statusColor = activeRun ? 'var(--blue)' : lastRun?.status === 'completed' ? 'var(--green)' : lastRun?.status === 'failed' ? 'var(--red)' : lastRun?.status === 'stopped' ? 'var(--yellow)' : 'var(--muted)';
267
- const statusLabel = activeRun ? 'Running' : lastRun ? (lastRun.status === 'completed' ? 'Completed' : lastRun.status === 'failed' ? 'Failed' : lastRun.status === 'stopped' ? 'Stopped' : lastRun.status) : 'Never run';
268
- const trigger = p.trigger?.cron ? _cronToHuman(p.trigger.cron) : 'Manual';
288
+ const statusLabel = activeRun ? (activeRun.status === 'paused' ? 'Paused' : 'Running') : lastRun ? (lastRun.status === 'completed' ? 'Completed' : lastRun.status === 'failed' ? 'Failed' : lastRun.status === 'stopped' ? 'Stopped' : lastRun.status) : 'Not run yet';
289
+ const trigger = _getPipelineTriggerLabel(p);
269
290
 
270
291
  // Build node chain (renders for all pipelines, even never-run)
271
292
  var progressHtml = '';
@@ -278,12 +299,18 @@ function renderPipelines(pipelines) {
278
299
  var allResources = _collectMonitoredResources(p);
279
300
  var resourcesHtml = _renderMonitoredResources(allResources, { compact: true });
280
301
 
281
- return '<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openPipelineDetail(\'' + escHtml(p.id) + '\')">' +
282
- '<div style="display:flex;justify-content:space-between;align-items:center">' +
283
- '<strong style="font-size:13px">' + escHtml(p.title) + '</strong>' +
284
- '<div style="display:flex;align-items:center;gap:8px">' +
285
- '<span style="color:' + statusColor + ';font-size:11px;font-weight:600">' + statusLabel + '</span>' +
286
- '<span style="font-size:10px;color:var(--muted)">' + escHtml(trigger) + '</span>' +
302
+ return '<div class="pipeline-card" onclick="if(shouldIgnoreSelectionClick(event))return;openPipelineDetail(\'' + escHtml(p.id) + '\')">' +
303
+ '<div class="pipeline-card-header">' +
304
+ '<div class="pipeline-card-main">' +
305
+ '<strong class="pipeline-card-title">' + escHtml(p.title || p.id) + '</strong>' +
306
+ '<div class="pipeline-card-meta">' +
307
+ '<span>' + escHtml(p.id) + '</span>' +
308
+ '<span>' + escHtml(_getPipelineStageLabel(p)) + '</span>' +
309
+ '<span>' + escHtml(trigger) + '</span>' +
310
+ '</div>' +
311
+ '</div>' +
312
+ '<div class="pipeline-card-badges">' +
313
+ '<span style="color:' + statusColor + ';font-size:11px;font-weight:600">' + escHtml(statusLabel) + '</span>' +
287
314
  (p.stopWhen ? '<span style="font-size:9px;color:var(--yellow)" title="Auto-stops when condition met: ' + escHtml(typeof p.stopWhen === 'string' ? p.stopWhen : (p.stopWhen.check || 'condition')) + '">STOP-WHEN</span>' : '') +
288
315
  (p.enabled === false ? '<span style="font-size:9px;color:var(--red)"' + (p._stopReason ? ' title="' + escHtml(p._stopReason) + '"' : '') + '>' + (p._stoppedBy ? 'AUTO-STOPPED' : 'DISABLED') + '</span>' : '') +
289
316
  '</div>' +
@@ -302,9 +329,9 @@ function openPipelineDetail(id) {
302
329
  var html = '<div style="display:flex;flex-direction:column;gap:12px">';
303
330
 
304
331
  // Status + actions
305
- var activeRun = (p.runs || []).find(function(r) { return r.status === 'running'; });
332
+ var activeRun = _getPipelineActiveRun(p);
306
333
  html += '<div style="display:flex;justify-content:space-between;align-items:center">' +
307
- '<span style="font-size:10px;color:var(--muted)">' + (p.trigger?.cron ? escHtml(_cronToHuman(p.trigger.cron)) + ' <span style="opacity:0.6">(' + escHtml(p.trigger.cron) + ', ' + escHtml(Intl.DateTimeFormat().resolvedOptions().timeZone) + ')</span>' : 'Manual trigger') + '</span>' +
334
+ '<span style="font-size:10px;color:var(--muted)">' + (p.trigger?.cron ? escHtml(_cronToHuman(p.trigger.cron)) + ' <span style="opacity:0.6">(' + escHtml(p.trigger.cron) + ', ' + escHtml(Intl.DateTimeFormat().resolvedOptions().timeZone) + ')</span>' : 'Manual trigger') + ' · ' + escHtml(_getPipelineStageLabel(p)) + '</span>' +
308
335
  '<div style="display:flex;gap:6px">' +
309
336
  (activeRun
310
337
  ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red);border-color:var(--red)" onclick="_abortPipeline(\'' + escHtml(id) + '\',this)">Abort</button>' +
@@ -335,7 +362,7 @@ function openPipelineDetail(id) {
335
362
  var swLabel = typeof p.stopWhen === 'string' ? p.stopWhen : (p.stopWhen.check || JSON.stringify(p.stopWhen));
336
363
  html += '<div style="border:1px solid color-mix(in srgb, var(--yellow) 30%, transparent);border-radius:6px;padding:4px 10px;background:color-mix(in srgb, var(--yellow) 6%, transparent);font-size:11px">' +
337
364
  '<span style="color:var(--yellow);font-weight:600">Stop When:</span> <span style="color:var(--text)">' + escHtml(swLabel) + '</span>' +
338
- (p._stoppedBy ? ' <span style="color:var(--green);font-size:10px">\u2714 triggered' + (p._stoppedAt ? ' at ' + escHtml(p._stoppedAt.slice(0, 16).replace('T', ' ')) : '') + '</span>' : '') +
365
+ (p._stoppedBy ? ' <span style="color:var(--green);font-size:10px">\u2714 triggered' + (p._stoppedAt ? ' at ' + escHtml(formatLocalDateTime(p._stoppedAt)) : '') + '</span>' : '') +
339
366
  '</div>';
340
367
  }
341
368
  if (p._stopReason && p.enabled === false) {
@@ -379,13 +406,15 @@ function openPipelineDetail(id) {
379
406
  html += '<div style="font-size:10px">' +
380
407
  '<div style="display:flex;gap:8px;align-items:center">' +
381
408
  '<span style="color:' + color + ';font-weight:600">' + r.status + '</span>' +
382
- '<span style="color:var(--muted)">' + (r.startedAt ? new Date(r.startedAt).toLocaleString() : '') + '</span>' +
383
- (r.completedAt ? '<span style="color:var(--muted)">\u2192 ' + new Date(r.completedAt).toLocaleString() + '</span>' : '') +
409
+ '<span style="color:var(--muted)">' + (r.startedAt ? formatLocalDateTime(r.startedAt) : '') + '</span>' +
410
+ (r.completedAt ? '<span style="color:var(--muted)">\u2192 ' + formatLocalDateTime(r.completedAt) + '</span>' : '') +
384
411
  (artifactCount > 0 ? '<span style="color:var(--blue);cursor:pointer;user-select:none" onclick="var el=document.getElementById(\'' + toggleId + '\');el.style.display=el.style.display===\'none\'?\'flex\':\'none\'" title="Toggle artifacts">' + artifactCount + ' artifact' + (artifactCount !== 1 ? 's' : '') + ' ▾</span>' : '') +
385
412
  '</div>' +
386
413
  (artifactCount > 0 ? '<div id="' + toggleId + '" style="display:none;flex-wrap:wrap;gap:4px;margin-top:4px;margin-left:12px">' + _renderArtifactLinks(runArtifacts.merged, id) + '</div>' : '') +
387
414
  '</div>';
388
415
  });
416
+ } else {
417
+ html += '<div class="pipeline-empty-runs">' + escHtml(_getPipelineEmptyRunCopy(p)) + '</div>';
389
418
  }
390
419
 
391
420
  html += '</div>';
@@ -498,7 +498,7 @@ function _renderPlanModal(normalizedFile, raw, lastMod) {
498
498
  modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--red)" onclick="planDelete(\'' + escapeHtml(normalizedFile) + '\')">Delete</button>';
499
499
  }
500
500
 
501
- const lastModLabel = lastMod ? '<div style="font-size:10px;color:var(--muted);font-weight:400;margin-top:2px">Last updated: ' + new Date(lastMod).toLocaleString() + '</div>' : '';
501
+ const lastModLabel = lastMod ? '<div style="font-size:10px;color:var(--muted);font-weight:400;margin-top:2px">Last updated: ' + formatLocalDateTime(lastMod) + '</div>' : '';
502
502
  const actionBtns = '<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">' + modalActions + '</div>';
503
503
 
504
504
  document.getElementById('modal-title').innerHTML = escapeHtml(title) + (versionLabel ? ' <span style="font-size:11px;font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue)">' + escapeHtml(versionLabel) + '</span>' : '') + lastModLabel + actionBtns;
@@ -589,7 +589,7 @@ function openArchivedPrdModal() {
589
589
  html += groups.map((g, i) => {
590
590
  const done = g.items.filter(it => it.status === 'done').length;
591
591
  const failed = g.items.filter(it => it.status === 'failed').length;
592
- const completed = g.completedAt ? new Date(g.completedAt).toLocaleDateString() : '';
592
+ const completed = g.completedAt ? formatLocalDate(g.completedAt) : '';
593
593
  return '<div class="plan-card" style="cursor:pointer;margin-bottom:8px" onclick="if(shouldIgnoreSelectionClick(event))return;showArchivedPrdDetail(\'' + escHtml(g.file) + '\')">' +
594
594
  '<div class="plan-card-title" style="font-size:13px">' + escHtml(g.summary || g.file) + '</div>' +
595
595
  '<div class="plan-card-meta">' +
@@ -18,7 +18,7 @@ function _cronToHuman(cron) {
18
18
  const m = parseInt(minute, 10);
19
19
  if (isNaN(h) || isNaN(m)) return cron;
20
20
 
21
- const timeStr = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
21
+ const timeStr = formatLocalClock(h, m);
22
22
 
23
23
  if (dow === '*') return 'Daily at ' + timeStr;
24
24
 
@@ -332,7 +332,7 @@ function openScheduleDetail(id) {
332
332
  const s = (window._lastSchedules || []).find(x => x.id === id);
333
333
  if (!s) return;
334
334
  const humanCron = _cronToHuman(s.cron || '');
335
- const lastRun = s._lastRun ? new Date(s._lastRun).toLocaleString() : 'never';
335
+ const lastRun = s._lastRun ? formatLocalDateTime(s._lastRun) : 'never';
336
336
  const enabledLabel = s.enabled ? '<span class="pr-badge approved">enabled</span>' : '<span class="pr-badge rejected">disabled</span>';
337
337
 
338
338
  document.getElementById('modal-title').innerHTML = escHtml(s.title || s.id) +
@@ -137,9 +137,9 @@ function openWatchDetail(id) {
137
137
  var w = (window._lastWatches || []).find(function(x) { return x.id === id; });
138
138
  if (!w) return;
139
139
  var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || '');
140
- var lastChecked = w.last_checked ? new Date(w.last_checked).toLocaleString() : 'never';
141
- var lastTriggered = w.last_triggered ? new Date(w.last_triggered).toLocaleString() : 'never';
142
- var createdAt = w.created_at ? new Date(w.created_at).toLocaleString() : 'unknown';
140
+ var lastChecked = w.last_checked ? formatLocalDateTime(w.last_checked) : 'never';
141
+ var lastTriggered = w.last_triggered ? formatLocalDateTime(w.last_triggered) : 'never';
142
+ var createdAt = w.created_at ? formatLocalDateTime(w.created_at) : 'unknown';
143
143
  var targetLabel = _WATCH_TARGET_LABELS[w.targetType] || w.targetType;
144
144
  var condLabel = _WATCH_CONDITION_LABELS[w.condition] || w.condition;
145
145
 
@@ -458,9 +458,9 @@ function openWorkItemDetail(id) {
458
458
  html += field('Description', '<div style="font-size:12px;max-height:320px;overflow-y:auto;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">' + renderMd(item.description || item.title || '—') + '</div>');
459
459
  html += field('Agent', escapeHtml(item.dispatched_to || item.agent || 'Auto'));
460
460
  html += field('Source', escapeHtml(item._source || 'central'));
461
- if (item.created) html += field('Created', escapeHtml(new Date(item.created).toLocaleString()));
462
- if (item.dispatched_at) html += field('Dispatched', escapeHtml(new Date(item.dispatched_at).toLocaleString()) + ' to ' + escapeHtml(item.dispatched_to || '?'));
463
- if (item.completedAt) html += field('Completed', escapeHtml(new Date(item.completedAt).toLocaleString()));
461
+ if (item.created) html += field('Created', escapeHtml(formatLocalDateTime(item.created)));
462
+ if (item.dispatched_at) html += field('Dispatched', escapeHtml(formatLocalDateTime(item.dispatched_at)) + ' to ' + escapeHtml(item.dispatched_to || '?'));
463
+ if (item.completedAt) html += field('Completed', escapeHtml(formatLocalDateTime(item.completedAt)));
464
464
  if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escapeHtml(item.failReason) + '</span>');
465
465
  if (item._pendingReason && item.status === 'pending') html += field('Pending Reason', item._pendingReason === 'already_dispatched' ? 'Queued — waiting for available agent slot' : escapeHtml(item._pendingReason.replace(/_/g, ' ')));
466
466
  if (item._skipReason && item.status === 'pending') html += field('Dispatch Blocked', '<span style="color:var(--yellow)">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + '</span>' + (item._blockedBy ? ' — blocked by <strong>' + escapeHtml(item._blockedBy) + '</strong>' : ''));
@@ -151,6 +151,40 @@ function timeAgo(isoStr) {
151
151
  return d + 'd ago';
152
152
  }
153
153
 
154
+ const _LOCAL_DATE_TIME_OPTS = { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' };
155
+ const _LOCAL_DATE_OPTS = { month: 'short', day: 'numeric', year: 'numeric' };
156
+ const _LOCAL_TIME_OPTS = { hour: 'numeric', minute: '2-digit' };
157
+
158
+ function _coerceDashboardDate(value) {
159
+ if (!value) return null;
160
+ const date = value instanceof Date ? value : new Date(value);
161
+ return Number.isNaN(date.getTime()) ? null : date;
162
+ }
163
+
164
+ function formatLocalDateTime(value) {
165
+ const date = _coerceDashboardDate(value);
166
+ if (!date) return value ? String(value) : '';
167
+ return date.toLocaleString(undefined, _LOCAL_DATE_TIME_OPTS);
168
+ }
169
+
170
+ function formatLocalDate(value) {
171
+ const date = _coerceDashboardDate(value);
172
+ if (!date) return value ? String(value) : '';
173
+ return date.toLocaleDateString(undefined, _LOCAL_DATE_OPTS);
174
+ }
175
+
176
+ function formatLocalTime(value) {
177
+ const date = _coerceDashboardDate(value);
178
+ if (!date) return value ? String(value) : '';
179
+ return date.toLocaleTimeString(undefined, _LOCAL_TIME_OPTS);
180
+ }
181
+
182
+ function formatLocalClock(hour, minute) {
183
+ const date = new Date();
184
+ date.setHours(Number(hour) || 0, Number(minute) || 0, 0, 0);
185
+ return formatLocalTime(date);
186
+ }
187
+
154
188
  function statusColor(s) {
155
189
  return s === 'working' ? 'working' : s === 'done' ? 'done' : s === 'error' ? 'error' : '';
156
190
  }
@@ -207,6 +207,14 @@
207
207
  .pl-node-arrow { display: flex; align-items: center; padding: 0 2px; color: var(--muted); font-size: 12px; flex-shrink: 0; margin-top: 8px; }
208
208
  .pl-node-meta { font-size: 9px; color: var(--muted); margin-top: 2px; text-align: center; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
209
209
  .pl-node-loop { font-size: 10px; color: var(--muted); margin-top: 6px; display: flex; align-items: center; gap: 6px; }
210
+ .pipeline-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 12px 16px; margin-bottom: var(--space-4); cursor: pointer; transition: border-color var(--transition-fast), transform var(--transition-fast); }
211
+ .pipeline-card:hover { border-color: var(--blue); transform: translateY(-1px); }
212
+ .pipeline-card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-6); }
213
+ .pipeline-card-main { min-width: 0; flex: 1; }
214
+ .pipeline-card-title { display: block; font-size: var(--text-lg); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
215
+ .pipeline-card-meta { margin-top: var(--space-2); display: flex; flex-wrap: wrap; gap: var(--space-4); font-size: var(--text-sm); color: var(--muted); }
216
+ .pipeline-card-badges { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: var(--space-4); flex-shrink: 0; }
217
+ .pipeline-empty-runs { border: 1px dashed var(--border); border-radius: var(--radius-md); padding: var(--space-6); color: var(--muted); font-size: var(--text-md); background: rgba(139,148,158,0.04); }
210
218
  .prd-items-list { display: flex; flex-direction: column; gap: 3px; max-height: 400px; overflow-y: auto; padding: 0 8px; }
211
219
  .prd-item-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: var(--radius-sm); font-size: var(--text-base); background: var(--surface2); border: 1px solid var(--border); border-left: 3px solid var(--border); }
212
220
  .prd-item-row.st-done { border-left-color: var(--green); }
package/dashboard.js CHANGED
@@ -1581,6 +1581,70 @@ function meetingParticipantsFromAction(action) {
1581
1581
  );
1582
1582
  }
1583
1583
 
1584
+ function normalizePipelineForCompare(pipeline) {
1585
+ if (!pipeline || typeof pipeline !== 'object') return null;
1586
+ return {
1587
+ title: pipeline.title || '',
1588
+ stages: Array.isArray(pipeline.stages) ? pipeline.stages : [],
1589
+ trigger: pipeline.trigger && typeof pipeline.trigger === 'object' ? pipeline.trigger : {},
1590
+ enabled: pipeline.enabled !== false,
1591
+ stopWhen: pipeline.stopWhen || null,
1592
+ monitoredResources: Array.isArray(pipeline.monitoredResources) ? pipeline.monitoredResources : [],
1593
+ };
1594
+ }
1595
+
1596
+ function buildPipelineFromAction(action) {
1597
+ const pipeline = {
1598
+ id: String(action.id || '').trim(),
1599
+ title: String(action.title || '').trim(),
1600
+ stages: action.stages,
1601
+ trigger: action.trigger && typeof action.trigger === 'object' ? action.trigger : {},
1602
+ enabled: action.enabled !== false,
1603
+ };
1604
+ if (action.stopWhen) pipeline.stopWhen = action.stopWhen;
1605
+ if (Array.isArray(action.monitoredResources) && action.monitoredResources.length > 0) {
1606
+ pipeline.monitoredResources = action.monitoredResources;
1607
+ }
1608
+ return pipeline;
1609
+ }
1610
+
1611
+ function pipelineDefinitionsEqual(a, b) {
1612
+ return JSON.stringify(normalizePipelineForCompare(a)) === JSON.stringify(normalizePipelineForCompare(b));
1613
+ }
1614
+
1615
+ function createPipelineFromAction(action) {
1616
+ const { savePipeline, getPipeline } = require('./engine/pipeline');
1617
+ const pipeline = buildPipelineFromAction(action);
1618
+ const existing = getPipeline(pipeline.id);
1619
+ if (existing) {
1620
+ if (pipelineDefinitionsEqual(existing, pipeline)) {
1621
+ return {
1622
+ type: 'create-pipeline',
1623
+ id: pipeline.id,
1624
+ ok: true,
1625
+ duplicate: true,
1626
+ duplicateOf: pipeline.id,
1627
+ warning: `Pipeline "${pipeline.id}" already exists; no changes made.`,
1628
+ };
1629
+ }
1630
+ return {
1631
+ type: 'create-pipeline',
1632
+ id: pipeline.id,
1633
+ error: `Pipeline "${pipeline.id}" already exists with a different definition. Use edit-pipeline to update it.`,
1634
+ };
1635
+ }
1636
+ savePipeline(pipeline);
1637
+ const persisted = getPipeline(pipeline.id);
1638
+ if (!persisted) {
1639
+ return { type: 'create-pipeline', id: pipeline.id, error: `Pipeline "${pipeline.id}" was not persisted.` };
1640
+ }
1641
+ if (!pipelineDefinitionsEqual(persisted, pipeline)) {
1642
+ return { type: 'create-pipeline', id: pipeline.id, error: `Pipeline "${pipeline.id}" persisted with unexpected contents.` };
1643
+ }
1644
+ invalidateStatusCache();
1645
+ return { type: 'create-pipeline', id: pipeline.id, ok: true, created: true };
1646
+ }
1647
+
1584
1648
  // Required-field validator for CC actions. Returns null when valid, an error string when not.
1585
1649
  // Centralises field-required checks so the model can't quietly emit a malformed action and have
1586
1650
  // the server silently fall back to placeholder values (e.g. "Untitled"). The handler invokes this
@@ -1615,6 +1679,11 @@ function _ccValidateAction(action) {
1615
1679
  if (meetingParticipantsFromAction(action).length < 2) return 'create-meeting action requires at least 2 participants';
1616
1680
  return null;
1617
1681
  }
1682
+ case 'create-pipeline':
1683
+ if (!action.id || typeof action.id !== 'string' || !action.id.trim()) return 'create-pipeline action missing required field: id';
1684
+ if (!action.title || typeof action.title !== 'string' || !action.title.trim()) return 'create-pipeline action missing required field: title';
1685
+ if (!Array.isArray(action.stages) || action.stages.length === 0) return 'create-pipeline action requires non-empty stages array';
1686
+ return null;
1618
1687
  default:
1619
1688
  return null; // unknown types fall through to existing handler / generic fallback
1620
1689
  }
@@ -1853,6 +1922,10 @@ async function executeCCActions(actions) {
1853
1922
  results.push({ type: 'create-meeting', id: meeting.id, ok: true });
1854
1923
  break;
1855
1924
  }
1925
+ case 'create-pipeline': {
1926
+ results.push(createPipelineFromAction(action));
1927
+ break;
1928
+ }
1856
1929
  case 'delete-watch': {
1857
1930
  const deleted = watchesMod.deleteWatch(action.id);
1858
1931
  if (deleted) invalidateStatusCache();
@@ -6942,6 +7015,7 @@ module.exports = {
6942
7015
  _findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
6943
7016
  _createWorkItemWithDedup: createWorkItemWithDedup,
6944
7017
  _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
7018
+ _createPipelineFromAction: createPipelineFromAction,
6945
7019
  executeCCActions,
6946
7020
  };
6947
7021
 
package/engine/ado.js CHANGED
@@ -708,6 +708,7 @@ async function pollPrStatus(config) {
708
708
  delete pr.buildStatus;
709
709
  delete pr.buildFailReason;
710
710
  delete pr.buildErrorLog;
711
+ delete pr.buildFailureSignature;
711
712
  delete pr._buildFailNotified;
712
713
  delete pr._buildStatusStale;
713
714
  delete pr._buildStatusDetail;
@@ -738,6 +739,11 @@ async function pollPrStatus(config) {
738
739
  pr._adoSourceCommit = sourceCommit;
739
740
  updated = true;
740
741
  }
742
+ const targetCommit = prData.lastMergeTargetCommit?.commitId || '';
743
+ if (targetCommit && pr._adoTargetCommit !== targetCommit) {
744
+ pr._adoTargetCommit = targetCommit;
745
+ updated = true;
746
+ }
741
747
 
742
748
  const reviewers = prData.reviewers || [];
743
749
  const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
@@ -811,6 +817,7 @@ async function pollPrStatus(config) {
811
817
  const mergeCommitId = prData.lastMergeCommit?.commitId;
812
818
  let buildStatus = pr.buildStatus || 'none';
813
819
  let buildFailReason = pr.buildFailReason || '';
820
+ let buildFailureSignature = pr.buildFailureSignature || '';
814
821
  let buildStatusResolved = true;
815
822
  let buildStatusStaleDetail = '';
816
823
 
@@ -834,6 +841,11 @@ async function pollPrStatus(config) {
834
841
  if (buildStatus === 'failing') {
835
842
  const failed = prBuilds.find(b => b.result === 'failed');
836
843
  buildFailReason = failed?.definition?.name || 'Build failed';
844
+ buildFailureSignature = shared.safeSlugComponent([
845
+ failed?.definition?.name,
846
+ failed?.result,
847
+ failed?.status,
848
+ ].filter(Boolean).join('\n') || buildFailReason, 80);
837
849
  }
838
850
  } else if (allBuilds.length > 0 && pr.buildStatus) {
839
851
  // Stale merge-commit fallback — ADO returned builds for this PR's merge ref
@@ -879,6 +891,8 @@ async function pollPrStatus(config) {
879
891
  pr.buildStatus = buildStatus;
880
892
  if (buildFailReason) pr.buildFailReason = buildFailReason;
881
893
  else delete pr.buildFailReason;
894
+ if (buildFailureSignature) pr.buildFailureSignature = buildFailureSignature;
895
+ else delete pr.buildFailureSignature;
882
896
  // Build transitioned — clear grace period and auto-complete flag
883
897
  delete pr._buildFixPushedAt;
884
898
  if (buildStatus === 'failing') delete pr._autoCompleted;
@@ -894,6 +908,7 @@ async function pollPrStatus(config) {
894
908
  // fix agents to be dispatched blind.
895
909
  if (buildStatus === 'passing') {
896
910
  delete pr.buildErrorLog;
911
+ delete pr.buildFailureSignature;
897
912
  // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
898
913
  if (pr.buildFixAttempts) { delete pr.buildFixAttempts; delete pr.buildFixEscalated; }
899
914
  }
@@ -909,6 +924,16 @@ async function pollPrStatus(config) {
909
924
  } catch {}
910
925
  }
911
926
  }
927
+ if (buildStatus === 'failing') {
928
+ if (buildFailReason && pr.buildFailReason !== buildFailReason) {
929
+ pr.buildFailReason = buildFailReason;
930
+ updated = true;
931
+ }
932
+ if (buildFailureSignature && pr.buildFailureSignature !== buildFailureSignature) {
933
+ pr.buildFailureSignature = buildFailureSignature;
934
+ updated = true;
935
+ }
936
+ }
912
937
  }
913
938
 
914
939
  // Auto-complete: set auto-complete on PR when builds green + review approved
@@ -1041,6 +1066,8 @@ async function pollPrHumanComments(config) {
1041
1066
 
1042
1067
  pr.humanFeedback = {
1043
1068
  lastProcessedCommentDate: latestDate,
1069
+ lastProcessedCommentId: String(newHumanComments[newHumanComments.length - 1].commentId),
1070
+ lastProcessedCommentKey: `${newHumanComments[newHumanComments.length - 1].threadId}:${newHumanComments[newHumanComments.length - 1].commentId}`,
1044
1071
  pendingFix: true,
1045
1072
  feedbackContent
1046
1073
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-05T07:30:27.028Z"
4
+ "cachedAt": "2026-05-05T09:10:48.964Z"
5
5
  }
package/engine/github.js CHANGED
@@ -372,6 +372,10 @@ async function pollPrStatus(config) {
372
372
  pr.lastPushedAt = ts();
373
373
  updated = true;
374
374
  }
375
+ if (prData.base?.sha && pr.baseSha !== prData.base.sha) {
376
+ pr.baseSha = prData.base.sha;
377
+ updated = true;
378
+ }
375
379
 
376
380
  if (pr.status !== newStatus) {
377
381
  log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
@@ -389,6 +393,7 @@ async function pollPrStatus(config) {
389
393
  delete pr.buildStatus;
390
394
  delete pr.buildFailReason;
391
395
  delete pr.buildErrorLog;
396
+ delete pr.buildFailureSignature;
392
397
  delete pr._buildFailNotified;
393
398
  delete pr.buildFixAttempts;
394
399
  delete pr.buildFixEscalated;
@@ -471,6 +476,7 @@ async function pollPrStatus(config) {
471
476
  const runs = checksData.check_runs;
472
477
  let buildStatus = 'none';
473
478
  let buildFailReason = '';
479
+ let buildFailureSignature = '';
474
480
 
475
481
  if (runs.length > 0) {
476
482
  const hasFailed = runs.some(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
@@ -481,6 +487,13 @@ async function pollPrStatus(config) {
481
487
  buildStatus = 'failing';
482
488
  const failed = runs.find(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
483
489
  buildFailReason = failed?.name || 'Check failed';
490
+ buildFailureSignature = shared.safeSlugComponent([
491
+ failed?.name,
492
+ failed?.conclusion,
493
+ failed?.output?.title,
494
+ failed?.output?.summary,
495
+ failed?.output?.text,
496
+ ].filter(Boolean).join('\n') || buildFailReason, 80);
484
497
  } else if (allDone && allPassed) {
485
498
  buildStatus = 'passing';
486
499
  } else {
@@ -493,6 +506,8 @@ async function pollPrStatus(config) {
493
506
  pr.buildStatus = buildStatus;
494
507
  if (buildFailReason) pr.buildFailReason = buildFailReason;
495
508
  else delete pr.buildFailReason;
509
+ if (buildFailureSignature) pr.buildFailureSignature = buildFailureSignature;
510
+ else delete pr.buildFailureSignature;
496
511
  // Build transitioned — clear grace period and auto-complete flag
497
512
  delete pr._buildFixPushedAt;
498
513
  if (buildStatus === 'failing') delete pr._autoCompleted; // allow re-merge after fix
@@ -504,6 +519,7 @@ async function pollPrStatus(config) {
504
519
  // while a queued build was still running.
505
520
  if (buildStatus === 'passing') {
506
521
  delete pr.buildErrorLog;
522
+ delete pr.buildFailureSignature;
507
523
  // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
508
524
  if (pr.buildFixAttempts) { delete pr.buildFixAttempts; delete pr.buildFixEscalated; }
509
525
  }
@@ -519,6 +535,16 @@ async function pollPrStatus(config) {
519
535
  } catch {}
520
536
  }
521
537
  }
538
+ if (buildStatus === 'failing') {
539
+ if (buildFailReason && pr.buildFailReason !== buildFailReason) {
540
+ pr.buildFailReason = buildFailReason;
541
+ updated = true;
542
+ }
543
+ if (buildFailureSignature && pr.buildFailureSignature !== buildFailureSignature) {
544
+ pr.buildFailureSignature = buildFailureSignature;
545
+ updated = true;
546
+ }
547
+ }
522
548
  }
523
549
  }
524
550
 
@@ -636,6 +662,8 @@ async function pollPrHumanComments(config) {
636
662
 
637
663
  pr.humanFeedback = {
638
664
  lastProcessedCommentDate: latestDate,
665
+ lastProcessedCommentId: String(newComments[newComments.length - 1].commentId),
666
+ lastProcessedCommentKey: String(newComments[newComments.length - 1].commentId),
639
667
  pendingFix: true,
640
668
  feedbackContent
641
669
  };
@@ -1431,7 +1431,26 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1431
1431
  }
1432
1432
  }
1433
1433
 
1434
- function updatePrAfterFix(pr, project, source) {
1434
+ function getHumanFeedbackAutomationCauseKey(pr) {
1435
+ const feedback = pr?.humanFeedback;
1436
+ if (!feedback || typeof feedback !== 'object') return '';
1437
+ const commentRef = feedback.lastProcessedCommentKey
1438
+ || feedback.lastProcessedCommentId
1439
+ || feedback.commentId
1440
+ || feedback.lastProcessedCommentDate
1441
+ || feedback.feedbackContent
1442
+ || '';
1443
+ return commentRef ? `human-comment:${shared.safeSlugComponent(commentRef, 80)}` : '';
1444
+ }
1445
+
1446
+ function shouldClearHumanFeedbackPendingFix(target, completedPr, automationCauseKey) {
1447
+ if (!target?.humanFeedback?.pendingFix) return true;
1448
+ const currentCauseKey = getHumanFeedbackAutomationCauseKey(target);
1449
+ const completedCauseKey = automationCauseKey || getHumanFeedbackAutomationCauseKey(completedPr);
1450
+ return !currentCauseKey || !completedCauseKey || currentCauseKey === completedCauseKey;
1451
+ }
1452
+
1453
+ function updatePrAfterFix(pr, project, source, automationCauseKey = '', dispatchId = '') {
1435
1454
 
1436
1455
  if (!pr?.id) return;
1437
1456
  const prPath = project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json');
@@ -1442,13 +1461,26 @@ function updatePrAfterFix(pr, project, source) {
1442
1461
  // Never downgrade from approved — fix was dispatched but PR is already approved
1443
1462
  if (target.reviewStatus !== 'approved') target.reviewStatus = 'waiting';
1444
1463
  if (source === 'pr-human-feedback') {
1445
- if (target.humanFeedback) target.humanFeedback.pendingFix = false;
1464
+ const clearPendingFix = shouldClearHumanFeedbackPendingFix(target, pr, automationCauseKey);
1465
+ if (target.humanFeedback && clearPendingFix) target.humanFeedback.pendingFix = false;
1446
1466
  target.minionsReview = { ...target.minionsReview, note: 'Fixed human feedback, awaiting re-review', fixedAt: ts() };
1447
- log('info', `Updated ${pr.id} → cleared humanFeedback.pendingFix, reset to waiting for re-review`);
1467
+ if (clearPendingFix) {
1468
+ log('info', `Updated ${pr.id} → cleared humanFeedback.pendingFix, reset to waiting for re-review`);
1469
+ } else {
1470
+ log('info', `Updated ${pr.id} → preserved newer humanFeedback.pendingFix, reset to waiting for re-review`);
1471
+ }
1448
1472
  } else {
1449
1473
  target.minionsReview = { ...target.minionsReview, note: 'Fixed, awaiting re-review', fixedAt: ts() };
1450
1474
  log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
1451
1475
  }
1476
+ if (automationCauseKey) {
1477
+ shared.markPrAutomationCause(target, automationCauseKey, {
1478
+ source,
1479
+ dispatchId: dispatchId || null,
1480
+ status: 'handled',
1481
+ handledAt: ts(),
1482
+ });
1483
+ }
1452
1484
  return prs;
1453
1485
  }, { defaultValue: [] });
1454
1486
  }
@@ -2762,7 +2794,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2762
2794
  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`);
2763
2795
  }
2764
2796
  if (type === WORK_TYPE.FIX && effectiveSuccess) {
2765
- updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
2797
+ updatePrAfterFix(meta?.pr, meta?.project, meta?.source, meta?.automationCauseKey, dispatchItem?.id);
2766
2798
  // (#984) Sync PRD status for PR-linked features: fix work items have a different ID
2767
2799
  // than the original PRD feature, so syncPrdItemStatus(fixWiId, ...) finds nothing.
2768
2800
  // Use the PR's prdItems to propagate done status when the original work item is done.
package/engine/shared.js CHANGED
@@ -2532,6 +2532,40 @@ function safeSlugComponent(text, maxLen = 80) {
2532
2532
  return `${base}-${hash}`.slice(0, maxLen);
2533
2533
  }
2534
2534
 
2535
+ const PR_AUTOMATION_CAUSE_LIMIT = 50;
2536
+
2537
+ function getPrAutomationCauses(pr) {
2538
+ const causes = pr?._automationFixCauses;
2539
+ return causes && typeof causes === 'object' && !Array.isArray(causes) ? causes : {};
2540
+ }
2541
+
2542
+ function hasPrAutomationCause(pr, causeKey) {
2543
+ return !!(causeKey && getPrAutomationCauses(pr)[causeKey]);
2544
+ }
2545
+
2546
+ function markPrAutomationCause(pr, causeKey, details = {}) {
2547
+ if (!pr || !causeKey) return false;
2548
+ const now = ts();
2549
+ const causes = { ...getPrAutomationCauses(pr) };
2550
+ causes[causeKey] = {
2551
+ ...(causes[causeKey] || {}),
2552
+ ...details,
2553
+ status: details.status || causes[causeKey]?.status || 'handled',
2554
+ updatedAt: now,
2555
+ };
2556
+ if (!causes[causeKey].firstSeenAt) causes[causeKey].firstSeenAt = now;
2557
+
2558
+ const entries = Object.entries(causes);
2559
+ if (entries.length > PR_AUTOMATION_CAUSE_LIMIT) {
2560
+ entries
2561
+ .sort((a, b) => String(b[1]?.updatedAt || b[1]?.firstSeenAt || '').localeCompare(String(a[1]?.updatedAt || a[1]?.firstSeenAt || '')))
2562
+ .slice(PR_AUTOMATION_CAUSE_LIMIT)
2563
+ .forEach(([key]) => delete causes[key]);
2564
+ }
2565
+ pr._automationFixCauses = causes;
2566
+ return true;
2567
+ }
2568
+
2535
2569
  function formatTranscriptEntry(t) {
2536
2570
  return '### ' + (t.agent || 'agent') + ' (' + (t.type || '') + ', Round ' + (t.round || '?') + ')\n\n' + (t.content || '');
2537
2571
  }
@@ -2716,6 +2750,9 @@ module.exports = {
2716
2750
  redactSecrets,
2717
2751
  slugify,
2718
2752
  safeSlugComponent,
2753
+ getPrAutomationCauses,
2754
+ hasPrAutomationCause,
2755
+ markPrAutomationCause,
2719
2756
  formatTranscriptEntry,
2720
2757
  getPinnedItems,
2721
2758
  _logBuffer, // exported for testing
package/engine.js CHANGED
@@ -2308,6 +2308,93 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
2308
2308
  return '';
2309
2309
  }
2310
2310
 
2311
+ function prCausePart(value, fallback = 'unknown') {
2312
+ const raw = String(value || '').trim();
2313
+ return shared.safeSlugComponent(raw || fallback, 80);
2314
+ }
2315
+
2316
+ function getPrCauseHead(pr) {
2317
+ return pr?.headSha
2318
+ || pr?.headSHA
2319
+ || pr?.head?.sha
2320
+ || pr?._adoSourceCommit
2321
+ || pr?._adoHeadCommit
2322
+ || pr?.lastMergeSourceCommit?.commitId
2323
+ || pr?.lastMergeCommit?.commitId
2324
+ || '';
2325
+ }
2326
+
2327
+ function getPrCauseBase(pr) {
2328
+ return pr?.baseSha
2329
+ || pr?.base?.sha
2330
+ || pr?._adoTargetCommit
2331
+ || pr?.lastMergeTargetCommit?.commitId
2332
+ || pr?.targetSha
2333
+ || pr?.targetRefSha
2334
+ || pr?.baseRefName
2335
+ || pr?.targetRefName
2336
+ || '';
2337
+ }
2338
+
2339
+ function getPrAutomationCauseKey(kind, pr) {
2340
+ if (kind === 'review-feedback') {
2341
+ const reviewRef = pr?.minionsReview?.dispatchId
2342
+ || pr?.minionsReview?.reviewedAt
2343
+ || pr?.lastReviewedAt
2344
+ || getPrCauseHead(pr)
2345
+ || pr?.reviewNote
2346
+ || pr?.minionsReview?.note
2347
+ || 'review';
2348
+ return `review-feedback:${prCausePart(reviewRef)}`;
2349
+ }
2350
+ if (kind === 'human-comment') {
2351
+ const commentRef = pr?.humanFeedback?.lastProcessedCommentKey
2352
+ || pr?.humanFeedback?.lastProcessedCommentId
2353
+ || pr?.humanFeedback?.commentId
2354
+ || pr?.humanFeedback?.lastProcessedCommentDate
2355
+ || pr?.humanFeedback?.feedbackContent
2356
+ || 'comment';
2357
+ return `human-comment:${prCausePart(commentRef)}`;
2358
+ }
2359
+ if (kind === 'build') {
2360
+ const checkName = pr?.buildFailReason || pr?._buildStatusDetail || 'check';
2361
+ const signature = pr?.buildFailureSignature
2362
+ || pr?.buildErrorLog
2363
+ || pr?._buildStatusDetail
2364
+ || pr?.buildStatusDetail
2365
+ || pr?.buildFailReason
2366
+ || 'failure';
2367
+ return `build:${prCausePart(checkName)}:${prCausePart(getPrCauseHead(pr), 'unknown-head')}:${prCausePart(signature)}`;
2368
+ }
2369
+ if (kind === 'merge-conflict') {
2370
+ return `merge-conflict:${prCausePart(getPrCauseBase(pr), 'unknown-base')}:${prCausePart(getPrCauseHead(pr), 'unknown-head')}`;
2371
+ }
2372
+ return `${kind}:${prCausePart(getPrCauseHead(pr) || pr?.id || 'pr')}`;
2373
+ }
2374
+
2375
+ function getPrAutomationDispatchKey(baseKey, causeKey) {
2376
+ return `${baseKey}-${shared.safeSlugComponent(causeKey, 96)}`;
2377
+ }
2378
+
2379
+ function isPrAutomationCausePending(project, pr, causeKey) {
2380
+ if (!causeKey) return false;
2381
+ const prCanonicalId = shared.getCanonicalPrId(project, pr, pr?.url || '');
2382
+ const dispatch = getDispatch();
2383
+ return [...(dispatch.pending || []), ...(dispatch.active || [])].some(d => {
2384
+ if (d.meta?.automationCauseKey !== causeKey) return false;
2385
+ if (!prCanonicalId) return true;
2386
+ const dispatchProject = d.meta?.project?.name
2387
+ ? (getProjects(getConfig()).find(p => p.name === d.meta.project.name) || d.meta.project)
2388
+ : (d.meta?.project || null);
2389
+ const dispatchPrId = shared.getCanonicalPrId(dispatchProject, d.meta?.pr, d.meta?.pr?.url || '');
2390
+ return !dispatchPrId || dispatchPrId === prCanonicalId;
2391
+ });
2392
+ }
2393
+
2394
+ function isPrAutomationCauseHandledOrPending(project, pr, causeKey) {
2395
+ return shared.hasPrAutomationCause(pr, causeKey) || isPrAutomationCausePending(project, pr, causeKey);
2396
+ }
2397
+
2311
2398
 
2312
2399
  // Tracks per-process which silent-discovery warnings have already been logged
2313
2400
  // so we don't spam the log every tick. Cleared on process exit (no need to
@@ -2448,10 +2535,13 @@ async function discoverFromPrs(config, project) {
2448
2535
 
2449
2536
  // Fresh reviewer comments are actionable fixes, even while the PR is otherwise
2450
2537
  // awaiting a stale-vote re-review or has build-fix retries escalated.
2451
- const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
2538
+ const humanFixBaseKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
2539
+ const humanCauseKey = getPrAutomationCauseKey('human-comment', pr);
2540
+ const humanFixKey = getPrAutomationDispatchKey(humanFixBaseKey, humanCauseKey);
2452
2541
  const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
2453
2542
  if (pollEnabled && autoFixHumanComments && (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched) {
2454
2543
  const key = humanFixKey;
2544
+ if (isPrAutomationCauseHandledOrPending(project, pr, humanCauseKey)) continue;
2455
2545
  let staleCoalesced = [];
2456
2546
  const alreadyDispatched = isAlreadyDispatched(key);
2457
2547
  const blockedByCooldown = isOnCooldown(key, cooldownMs);
@@ -2492,7 +2582,7 @@ async function discoverFromPrs(config, project) {
2492
2582
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
2493
2583
  reviewer: 'Human Reviewer',
2494
2584
  review_note: reviewNote,
2495
- }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
2585
+ }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, automationCauseKey: humanCauseKey, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
2496
2586
  if (item) { newWork.push(item); fixDispatched = true; }
2497
2587
  }
2498
2588
 
@@ -2547,7 +2637,9 @@ async function discoverFromPrs(config, project) {
2547
2637
  // PRs with changes requested → route back to author for fix.
2548
2638
  // Gate on evalLoopEnabled and provider polling — the review→fix cycle depends on fresh vote state.
2549
2639
  if (evalLoopEnabled && pollEnabled && autoFixReviewFeedback && reviewStatus === 'changes-requested' && !awaitingReReview && !fixDispatched) {
2550
- const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
2640
+ const reviewCauseKey = getPrAutomationCauseKey('review-feedback', pr);
2641
+ const key = getPrAutomationDispatchKey(`fix-${project?.name || 'default'}-${prDisplayId}`, reviewCauseKey);
2642
+ if (isPrAutomationCauseHandledOrPending(project, pr, reviewCauseKey)) continue;
2551
2643
  if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2552
2644
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2553
2645
  if (!agentId) continue;
@@ -2557,7 +2649,7 @@ async function discoverFromPrs(config, project) {
2557
2649
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2558
2650
  pr_id: pr.id, pr_branch: prBranch,
2559
2651
  review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
2560
- }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2652
+ }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
2561
2653
  if (item) {
2562
2654
  newWork.push(item); setCooldown(key); fixDispatched = true;
2563
2655
  }
@@ -2572,7 +2664,9 @@ async function discoverFromPrs(config, project) {
2572
2664
  }
2573
2665
  const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
2574
2666
  if (pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing') {
2575
- const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
2667
+ const buildCauseKey = getPrAutomationCauseKey('build', pr);
2668
+ const key = getPrAutomationDispatchKey(`build-fix-${project?.name || 'default'}-${prDisplayId}`, buildCauseKey);
2669
+ if (isPrAutomationCauseHandledOrPending(project, pr, buildCauseKey)) continue;
2576
2670
  if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2577
2671
 
2578
2672
  // Pre-dispatch live build check — cached buildStatus may be stale: ADO can
@@ -2633,7 +2727,7 @@ async function discoverFromPrs(config, project) {
2633
2727
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2634
2728
  pr_id: pr.id, pr_branch: prBranch,
2635
2729
  review_note: reviewNote,
2636
- }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2730
+ }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, automationCauseKey: buildCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
2637
2731
  if (item) {
2638
2732
  newWork.push(item); setCooldown(key); fixDispatched = true;
2639
2733
  try {
@@ -2664,13 +2758,14 @@ async function discoverFromPrs(config, project) {
2664
2758
  // PRs with merge conflicts — dispatch fix to resolve (gated by provider polling + autoFixConflicts)
2665
2759
  const autoFixConflicts = config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts;
2666
2760
  if (pollEnabled && autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched) {
2667
- const key = `conflict-fix-${project?.name || 'default'}-${prDisplayId}`;
2761
+ const conflictCauseKey = getPrAutomationCauseKey('merge-conflict', pr);
2762
+ const key = getPrAutomationDispatchKey(`conflict-fix-${project?.name || 'default'}-${prDisplayId}`, conflictCauseKey);
2668
2763
  // Suppress re-dispatch for 10 min after last attempt — ADO/GitHub recomputes
2669
2764
  // mergeStatus asynchronously (1–5 min lag), so the flag may stay set even after
2670
2765
  // a successful push. _conflictFixedAt is cleared when the poller confirms clean status.
2671
2766
  const conflictFixedAt = pr._conflictFixedAt;
2672
2767
  const withinLag = conflictFixedAt && Date.now() - new Date(conflictFixedAt).getTime() < 10 * 60 * 1000;
2673
- if (!withinLag && !fixThrottled && !isAlreadyDispatched(key) && !isOnCooldown(key, cooldownMs)) {
2768
+ if (!withinLag && !fixThrottled && !isPrAutomationCauseHandledOrPending(project, pr, conflictCauseKey) && !isAlreadyDispatched(key) && !isOnCooldown(key, cooldownMs)) {
2674
2769
  // Pre-dispatch live conflict check — cached `_mergeConflict` may be
2675
2770
  // stale: ADO/GitHub recompute mergeStatus asynchronously (1–5 min lag),
2676
2771
  // so a successful upstream merge can leave the flag set even after the
@@ -2702,7 +2797,7 @@ async function discoverFromPrs(config, project) {
2702
2797
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2703
2798
  pr_id: pr.id, pr_branch: prBranch,
2704
2799
  review_note: `This PR has merge conflicts with the target branch. Inspect the live PR and repository history, choose the safest merge/rebase/update strategy, resolve all conflicts, validate the result, and push the branch.`,
2705
- }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2800
+ }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, automationCauseKey: conflictCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
2706
2801
  if (item) {
2707
2802
  newWork.push(item);
2708
2803
  setCooldown(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1723",
3
+ "version": "0.1.1725",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"