@yemi33/minions 0.1.1901 → 0.1.1903

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.
@@ -51,6 +51,20 @@ const _WATCH_CONDITION_LABELS = {
51
51
  enabled: 'Enabled',
52
52
  disabled: 'Disabled',
53
53
  'activity-change': 'Activity Change',
54
+ // P-w4e2f6a1 — Phase 2.1: PR predicate conditions
55
+ 'head-commit-change': 'Head Commit Changed',
56
+ 'mergeable-flipped': 'Mergeable Flipped',
57
+ 'ready-for-merge': 'Ready For Merge',
58
+ 'behind-master': 'Behind Master',
59
+ 'draft-flipped': 'Draft Flipped',
60
+ // P-w5b8d2c9 — Phase 2.2: work-item / plan / pipeline predicate conditions
61
+ stalled: 'Stalled',
62
+ 'retry-limit-reached': 'Retry Limit Reached',
63
+ 'dependency-met': 'Dependency Met',
64
+ 'all-items-done': 'All Items Done',
65
+ 'item-failed-n-times': 'Item Failed N Times',
66
+ 'stage-advanced': 'Stage Advanced',
67
+ 'stuck-in-stage': 'Stuck In Stage',
54
68
  };
55
69
 
56
70
  function _conditionLabel(cond) {
@@ -176,6 +190,79 @@ function _watchNext() { var tp = Math.ceil((window._lastWatches || []).length /
176
190
 
177
191
  // ─── Detail Modal ───────────────────────────────────────────────────────────
178
192
 
193
+ // P-w14e7a8c — Phase 7.1: render the action/chain block for the detail modal.
194
+ // Single-object action prints one step (parity with legacy view); array-form
195
+ // chain prints each step indexed. Each step surfaces guard.expr when present
196
+ // so users can verify the editor round-tripped through engine/watches.js.
197
+ function _renderWatchActionDetail(action) {
198
+ if (!action) return '';
199
+ if (Array.isArray(action)) {
200
+ if (action.length === 0) return '';
201
+ var stepHtml = action.map(function(step, i) {
202
+ return '<div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px">' +
203
+ '<div style="font-size:11px;color:var(--muted)">Step ' + (i + 1) + '</div>' +
204
+ '<div style="font-size:12px"><strong>Type:</strong> <span class="dispatch-type explore">' + escHtml(step && step.type ? step.type : '?') + '</span></div>' +
205
+ (step && step.guard && step.guard.expr ? '<div style="font-size:11px;margin-top:2px"><strong>Guard:</strong> <code>' + escHtml(step.guard.expr) + '</code></div>' : '') +
206
+ (step && step.params && Object.keys(step.params).length ? '<div style="margin-top:4px;font-size:11px;font-family:monospace;white-space:pre-wrap">' + escHtml(JSON.stringify(step.params, null, 2)) + '</div>' : '') +
207
+ '</div>';
208
+ }).join('');
209
+ return '<div><strong style="color:var(--muted)">Follow-up Action Chain (' + action.length + ' step' + (action.length === 1 ? '' : 's') + '):</strong>' + stepHtml + '</div>';
210
+ }
211
+ if (action.type) {
212
+ return '<div><strong style="color:var(--muted)">Follow-up Action:</strong> <span class="dispatch-type explore">' + escHtml(action.type) + '</span>' +
213
+ (action.guard && action.guard.expr ? '<div style="font-size:11px;margin-top:2px"><strong>Guard:</strong> <code>' + escHtml(action.guard.expr) + '</code></div>' : '') +
214
+ (action.params && Object.keys(action.params).length ? '<div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:11px;font-family:monospace;white-space:pre-wrap">' + escHtml(JSON.stringify(action.params, null, 2)) + '</div>' : '') +
215
+ '</div>';
216
+ }
217
+ return '';
218
+ }
219
+
220
+ // P-w14e7a8c — Phase 7.1: render the cross-target requires[] block for the
221
+ // detail modal. Empty / missing requires renders nothing.
222
+ function _renderWatchRequiresDetail(requires) {
223
+ if (!Array.isArray(requires) || requires.length === 0) return '';
224
+ var rows = requires.map(function(r, i) {
225
+ return '<div style="margin-top:4px;padding:6px 8px;background:var(--surface2);border-radius:4px;font-size:11px">' +
226
+ '<span style="color:var(--muted)">[' + (i + 1) + ']</span> ' +
227
+ escHtml(r && r.target ? r.target : '?') +
228
+ ' <span class="dispatch-type explore">' + escHtml(r && r.targetType ? r.targetType : '?') + '</span>' +
229
+ ' <span style="color:var(--blue)">' + escHtml(_conditionLabel(r && r.condition ? r.condition : '?')) + '</span>' +
230
+ '</div>';
231
+ }).join('');
232
+ return '<div><strong style="color:var(--muted)">Requires (AND-join, ' + requires.length + '):</strong>' + rows + '</div>';
233
+ }
234
+
235
+ // P-w15f1d4b — Phase 7.2: render the evaluation history block for the detail
236
+ // modal. `history` is the Phase 6.2 `_history` array shape
237
+ // `[{checked_at, fired, reason, condition}]` (oldest-first, capped at 25 by
238
+ // the engine). The lazy fetcher in openWatchDetail injects the rendered HTML
239
+ // into a `watch-history-<id>` placeholder div once /api/watches/:id/history
240
+ // resolves. Empty / missing arrays render a "no checks yet" hint so legacy
241
+ // watches that were created before Phase 6.2 don't show a half-empty table.
242
+ function _renderWatchHistoryDetail(history) {
243
+ if (!Array.isArray(history) || history.length === 0) {
244
+ return '<div style="color:var(--muted);font-style:italic;font-size:11px">no history yet — watch has not been checked, or pre-dates the history feature</div>';
245
+ }
246
+ // Render newest-first for readability — the engine stores oldest-first.
247
+ var entries = history.slice().reverse();
248
+ var rows = entries.map(function(entry) {
249
+ var fired = !!(entry && entry.fired);
250
+ var ts = entry && entry.checked_at ? formatLocalDateTime(entry.checked_at) : '?';
251
+ var reason = entry && entry.reason ? entry.reason : '';
252
+ var indicator = fired
253
+ ? '<span style="color:var(--green)" title="fired">✓ fired</span>'
254
+ : '<span style="color:var(--muted)" title="not fired">·</span>';
255
+ return '<tr>' +
256
+ '<td style="padding:2px 8px 2px 0;color:var(--muted);white-space:nowrap">' + escHtml(ts) + '</td>' +
257
+ '<td style="padding:2px 8px 2px 0;white-space:nowrap">' + indicator + '</td>' +
258
+ '<td style="padding:2px 0;color:var(--text)">' + escHtml(reason) + '</td>' +
259
+ '</tr>';
260
+ }).join('');
261
+ return '<div><strong style="color:var(--muted)">Evaluation History (' + history.length + ' check' + (history.length === 1 ? '' : 's') + ', newest first):</strong>' +
262
+ '<table style="margin-top:4px;font-size:11px;border-collapse:collapse;width:100%"><tbody>' + rows + '</tbody></table>' +
263
+ '</div>';
264
+ }
265
+
179
266
  function openWatchDetail(id) {
180
267
  var w = (window._lastWatches || []).find(function(x) { return x.id === id; });
181
268
  if (!w) return;
@@ -204,9 +291,14 @@ function openWatchDetail(id) {
204
291
  '<div><strong style="color:var(--muted)">Notify:</strong> ' + escHtml(w.notify || 'inbox') + '</div>' +
205
292
  '<div><strong style="color:var(--muted)">Triggers:</strong> ' + (w.triggerCount || 0) + (w.stopAfter > 0 ? ' / ' + w.stopAfter + ' (expires after)' : ' (runs forever)') + '</div>' +
206
293
  (w.onNotMet ? '<div><strong style="color:var(--muted)">On Each Poll (not met):</strong> ' + escHtml(w.onNotMet) + '</div>' : '') +
207
- (w.action && w.action.type ? '<div><strong style="color:var(--muted)">Follow-up Action:</strong> <span class="dispatch-type explore">' + escHtml(w.action.type) + '</span>' +
208
- (w.action.params && Object.keys(w.action.params).length ? '<div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:11px;font-family:monospace;white-space:pre-wrap">' + escHtml(JSON.stringify(w.action.params, null, 2)) + '</div>' : '') +
209
- '</div>' : '') +
294
+ // P-w14e7a8c Phase 7.1: render single action OR array-form chain.
295
+ // Each step shows its type, params, and (if present) guard expression
296
+ // so users can verify that the chain editor round-tripped through the
297
+ // back-end (engine/watches.js createWatch persists the shape unchanged).
298
+ _renderWatchActionDetail(w.action) +
299
+ // P-w14e7a8c — Phase 7.1: render cross-target requirements list when
300
+ // the watch carries a non-empty requires[] from the editor.
301
+ _renderWatchRequiresDetail(w.requires) +
210
302
  (w._lastActionResult ? '<div><strong style="color:var(--muted)">Last Action Result:</strong> <span style="color:' + (w._lastActionResult.ok ? 'var(--green)' : 'var(--red)') + '">' + (w._lastActionResult.ok ? 'OK' : 'FAILED') + '</span> — ' + escHtml(w._lastActionResult.summary || '') + (w._lastActionResult.dispatchedItemId ? ' (dispatched: ' + escHtml(w._lastActionResult.dispatchedItemId) + ')' : '') + '</div>' : '') +
211
303
  '<div><strong style="color:var(--muted)">Created:</strong> ' + escHtml(createdAt) + '</div>' +
212
304
  '<div><strong style="color:var(--muted)">Last Checked:</strong> ' + escHtml(lastChecked) + '</div>' +
@@ -214,12 +306,38 @@ function openWatchDetail(id) {
214
306
  (w._lastTriggerMessage ? '<div><strong style="color:var(--muted)">Last Trigger Message:</strong><div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:11px">' + escHtml(w._lastTriggerMessage) + '</div></div>' : '') +
215
307
  (w.project ? '<div><strong style="color:var(--muted)">Project:</strong> ' + escHtml(w.project) + '</div>' : '') +
216
308
  (w.description ? '<div><strong style="color:var(--muted)">Description:</strong> ' + escHtml(w.description) + '</div>' : '') +
309
+ // P-w15f1d4b — Phase 7.2: reserve a placeholder for the lazily-fetched
310
+ // evaluation history table. The fetch below populates it once
311
+ // /api/watches/:id/history resolves; failures fall back to the inline
312
+ // _history copy from /api/watches (best-effort) or a "no history" hint.
313
+ '<div id="watch-history-' + escHtml(w.id) + '"><div style="color:var(--muted);font-style:italic;font-size:11px">Loading history…</div></div>' +
217
314
  '</div>';
218
315
 
219
316
  document.getElementById('modal-body').innerHTML = body;
220
317
  document.getElementById('modal-body').style.whiteSpace = 'normal';
221
318
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
222
319
  document.getElementById('modal').classList.add('open');
320
+
321
+ // P-w15f1d4b — Phase 7.2: lazy history fetch. We don't block modal render
322
+ // on this — the placeholder above is replaced once /api/watches/:id/history
323
+ // resolves. On failure, fall back to whatever _history is already inline
324
+ // on the watch object (often present from the most recent /api/watches
325
+ // poll) so legacy watches still surface what they have locally.
326
+ (function loadHistory() {
327
+ var container = document.getElementById('watch-history-' + w.id);
328
+ if (!container) return;
329
+ fetch('/api/watches/' + encodeURIComponent(w.id) + '/history').then(function(res) {
330
+ if (!res.ok) throw new Error('HTTP ' + res.status);
331
+ return res.json();
332
+ }).then(function(data) {
333
+ if (!container.isConnected) return; // user closed the modal
334
+ container.innerHTML = _renderWatchHistoryDetail(data && data.history);
335
+ }).catch(function() {
336
+ if (!container.isConnected) return;
337
+ // Fall back to the inline _history from /api/watches (best-effort).
338
+ container.innerHTML = _renderWatchHistoryDetail(w._history || []);
339
+ });
340
+ })();
223
341
  }
224
342
 
225
343
  // ─── CRUD Actions ───────────────────────────────────────────────────────────
@@ -290,14 +408,6 @@ function _watchFormHtml() {
290
408
  var agentOpts = '<option value="">human</option>' + (cmdAgents || []).map(function(a) { return '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'; }).join('');
291
409
  var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.name) + '</option>'; }).join('');
292
410
 
293
- // Action picker: empty value = no follow-up action (legacy notify-only behavior).
294
- var actionOpts = '<option value="">None — notify only</option>';
295
- if (_watchActionTypesCache && _watchActionTypesCache.length) {
296
- actionOpts += _watchActionTypesCache.map(function(a) {
297
- return '<option value="' + escHtml(a.value) + '" title="' + escHtml(a.description || '') + '">' + escHtml(a.label) + '</option>';
298
- }).join('');
299
- }
300
-
301
411
  return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
302
412
  '<div id="watch-form-error" style="display:none;color:var(--red);font-size:12px;padding:6px 10px;background:rgba(255,50,50,0.1);border-radius:var(--radius-sm)"></div>' +
303
413
  '<label style="color:var(--text);font-size:var(--text-md)">Target (e.g. PR number, work item ID, meeting/plan/schedule/pipeline/dispatch/agent ID)<input id="watch-edit-target" placeholder="e.g. 1057, W-abc123, M-xyz, schedule-42" style="' + inputStyle + '"></label>' +
@@ -309,19 +419,82 @@ function _watchFormHtml() {
309
419
  '<label style="color:var(--text);font-size:var(--text-md)">Description<input id="watch-edit-desc" placeholder="Optional description" style="' + inputStyle + '"></label>' +
310
420
  '<label style="color:var(--text);font-size:var(--text-md)">Stop After N Triggers <span style="font-size:10px;color:var(--muted)">(0 = run forever, 1 = expire on first match)</span><input id="watch-edit-stop-after" type="number" value="0" min="0" style="' + inputStyle + '"></label>' +
311
421
  '<label style="color:var(--text);font-size:var(--text-md)">On Each Poll (if condition not met)<select id="watch-edit-on-not-met" style="' + inputStyle + '"><option value="">None — do nothing</option><option value="notify">Notify — write to inbox each poll</option></select></label>' +
312
- '<label style="color:var(--text);font-size:var(--text-md)">Follow-up Action <span style="font-size:10px;color:var(--muted)">(runs after the inbox notification when the watch fires)</span><select id="watch-edit-action-type" style="' + inputStyle + '" onchange="_updateWatchActionParamsHint()">' + actionOpts + '</select></label>' +
313
- '<label style="color:var(--text);font-size:var(--text-md)" id="watch-edit-action-params-label" style="display:none">Action Params (JSON) <span style="font-size:10px;color:var(--muted)" id="watch-edit-action-params-hint"></span><textarea id="watch-edit-action-params" rows="4" placeholder=\'{"title": "Re-check {{target}} after merge", "type": "verify"}\' style="' + inputStyle + ';font-family:monospace"></textarea></label>' +
422
+ // P-w14e7a8c Phase 7.1: action chain editor (step list + add/remove)
423
+ '<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;display:flex;flex-direction:column;gap:8px">' +
424
+ '<div style="color:var(--text);font-size:var(--text-md);display:flex;align-items:center;justify-content:space-between">' +
425
+ '<span>Follow-up Action(s) <span style="font-size:10px;color:var(--muted)">(runs after the inbox notification when the watch fires; 1 step = single, 2+ = chain)</span></span>' +
426
+ '<button type="button" class="pr-pager-btn" style="font-size:10px;padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_addWatchStepRow()">+ Add step</button>' +
427
+ '</div>' +
428
+ '<div id="watch-edit-steps"></div>' +
429
+ '</div>' +
430
+ // P-w14e7a8c — Phase 7.1: requires editor (cross-target AND-join rows)
431
+ '<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;display:flex;flex-direction:column;gap:8px">' +
432
+ '<div style="color:var(--text);font-size:var(--text-md);display:flex;align-items:center;justify-content:space-between">' +
433
+ '<span>Requires <span style="font-size:10px;color:var(--muted)">(cross-target AND-join — every requirement must also be true to fire)</span></span>' +
434
+ '<button type="button" class="pr-pager-btn" style="font-size:10px;padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_addRequireRow()">+ Add requirement</button>' +
435
+ '</div>' +
436
+ '<div id="watch-edit-requires"></div>' +
437
+ '</div>' +
314
438
  '</div>';
315
439
  }
316
440
 
317
- // Refresh the action params textarea hint based on the selected action type.
318
- function _updateWatchActionParamsHint() {
319
- var sel = document.getElementById('watch-edit-action-type');
320
- var label = document.getElementById('watch-edit-action-params-label');
321
- var hint = document.getElementById('watch-edit-action-params-hint');
322
- if (!sel || !label || !hint) return;
323
- if (!sel.value) { label.style.display = 'none'; return; }
324
- label.style.display = '';
441
+ // P-w14e7a8c Phase 7.1: action chain step editor.
442
+ // Renders a single step block with type / params / guard inputs into the
443
+ // container at #watch-edit-steps. Step indexes are stable per-block so the
444
+ // remove button can target the right node; the submit handler walks live
445
+ // rows (not these indexes) and packs them into a single object (1 step) or
446
+ // array (2+ steps) before sending to /api/watches.
447
+ var _watchStepSeq = 0;
448
+ function _addWatchStepRow(initial) {
449
+ var container = document.getElementById('watch-edit-steps');
450
+ if (!container) return;
451
+ _watchStepSeq += 1;
452
+ var idx = _watchStepSeq;
453
+ var inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
454
+ var actionOpts = '<option value="">— select action —</option>';
455
+ if (_watchActionTypesCache && _watchActionTypesCache.length) {
456
+ actionOpts += _watchActionTypesCache.map(function(a) {
457
+ return '<option value="' + escHtml(a.value) + '" title="' + escHtml(a.description || '') + '">' + escHtml(a.label) + '</option>';
458
+ }).join('');
459
+ }
460
+ var initType = (initial && initial.type) || '';
461
+ var initParams = initial && initial.params ? JSON.stringify(initial.params, null, 2) : '';
462
+ var initGuard = initial && initial.guard && initial.guard.expr ? initial.guard.expr : '';
463
+ var row = document.createElement('div');
464
+ row.className = 'watch-step-row';
465
+ row.setAttribute('data-step-idx', String(idx));
466
+ row.style.cssText = 'border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px;display:flex;flex-direction:column;gap:6px;background:var(--surface)';
467
+ row.innerHTML =
468
+ '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">' +
469
+ '<select id="watch-step-' + idx + '-type" style="' + inputStyle + ';margin-top:0;flex:1" onchange="_updateWatchStepParamsHint(' + idx + ')">' + actionOpts + '</select>' +
470
+ '<button type="button" class="pr-pager-btn" style="font-size:10px;padding:2px 8px;color:var(--red);border-color:var(--red)" onclick="_removeWatchStepRow(' + idx + ')">Remove</button>' +
471
+ '</div>' +
472
+ '<label style="color:var(--text);font-size:11px">Params (JSON) <span style="font-size:10px;color:var(--muted)" id="watch-step-' + idx + '-params-hint"></span>' +
473
+ '<textarea id="watch-step-' + idx + '-params" rows="3" placeholder=\'{"title": "Re-check {{target}} after merge"}\' style="' + inputStyle + ';font-family:monospace"></textarea>' +
474
+ '</label>' +
475
+ '<label style="color:var(--text);font-size:11px">Guard expression <span style="font-size:10px;color:var(--muted)">(optional — skip step if expr is false; e.g. <code>newState.mergeable === true</code>)</span>' +
476
+ '<input id="watch-step-' + idx + '-guard" placeholder="" style="' + inputStyle + ';font-family:monospace">' +
477
+ '</label>';
478
+ container.appendChild(row);
479
+ if (initType) document.getElementById('watch-step-' + idx + '-type').value = initType;
480
+ if (initParams) document.getElementById('watch-step-' + idx + '-params').value = initParams;
481
+ if (initGuard) document.getElementById('watch-step-' + idx + '-guard').value = initGuard;
482
+ _updateWatchStepParamsHint(idx);
483
+ }
484
+
485
+ function _removeWatchStepRow(idx) {
486
+ var row = document.querySelector('.watch-step-row[data-step-idx="' + idx + '"]');
487
+ if (row && row.parentNode) row.parentNode.removeChild(row);
488
+ }
489
+
490
+ // Refresh the hint next to a single step's params textarea based on its
491
+ // currently selected action type. Same registry-driven hint as the legacy
492
+ // single-action form (kept for parity).
493
+ function _updateWatchStepParamsHint(idx) {
494
+ var sel = document.getElementById('watch-step-' + idx + '-type');
495
+ var hint = document.getElementById('watch-step-' + idx + '-params-hint');
496
+ if (!sel || !hint) return;
497
+ if (!sel.value) { hint.textContent = ''; return; }
325
498
  var entry = (_watchActionTypesCache || []).find(function(a) { return a.value === sel.value; });
326
499
  if (!entry) { hint.textContent = ''; return; }
327
500
  var paramHints = (entry.params || []).map(function(p) {
@@ -330,6 +503,60 @@ function _updateWatchActionParamsHint() {
330
503
  hint.textContent = paramHints ? '— ' + paramHints : '';
331
504
  }
332
505
 
506
+ // P-w14e7a8c — Phase 7.1: requires-row editor.
507
+ // Each row is one cross-target requirement: {target, targetType, condition}.
508
+ // Per the engine contract (engine/watches.js _validateRequires) requirements
509
+ // MUST NOT themselves declare nested requires — the form intentionally
510
+ // surfaces only the three fields the back-end accepts.
511
+ var _watchRequireSeq = 0;
512
+ function _addRequireRow(initial) {
513
+ var container = document.getElementById('watch-edit-requires');
514
+ if (!container) return;
515
+ _watchRequireSeq += 1;
516
+ var idx = _watchRequireSeq;
517
+ var inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
518
+ var targetTypes = _watchTargetTypesCache && _watchTargetTypesCache.length ? _watchTargetTypesCache : [];
519
+ var ttOpts = '<option value="">— targetType —</option>' + targetTypes.map(function(t) {
520
+ return '<option value="' + escHtml(t.value) + '">' + escHtml(t.label) + '</option>';
521
+ }).join('');
522
+ var initTarget = (initial && initial.target) || '';
523
+ var initTT = (initial && initial.targetType) || '';
524
+ var initCond = (initial && initial.condition) || '';
525
+ var row = document.createElement('div');
526
+ row.className = 'watch-require-row';
527
+ row.setAttribute('data-require-idx', String(idx));
528
+ row.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:6px;align-items:end';
529
+ row.innerHTML =
530
+ '<label style="color:var(--text);font-size:11px;margin:0">Target<input id="watch-require-' + idx + '-target" placeholder="e.g. PR-123, pipeline-X" style="' + inputStyle + '"></label>' +
531
+ '<label style="color:var(--text);font-size:11px;margin:0">Target Type<select id="watch-require-' + idx + '-target-type" style="' + inputStyle + '" onchange="_updateRequireConditionOptions(' + idx + ')">' + ttOpts + '</select></label>' +
532
+ '<label style="color:var(--text);font-size:11px;margin:0">Condition<select id="watch-require-' + idx + '-condition" style="' + inputStyle + '"></select></label>' +
533
+ '<button type="button" class="pr-pager-btn" style="font-size:10px;padding:6px 8px;color:var(--red);border-color:var(--red)" onclick="_removeRequireRow(' + idx + ')">Remove</button>';
534
+ container.appendChild(row);
535
+ if (initTarget) document.getElementById('watch-require-' + idx + '-target').value = initTarget;
536
+ if (initTT) document.getElementById('watch-require-' + idx + '-target-type').value = initTT;
537
+ _updateRequireConditionOptions(idx);
538
+ if (initCond) document.getElementById('watch-require-' + idx + '-condition').value = initCond;
539
+ }
540
+
541
+ function _removeRequireRow(idx) {
542
+ var row = document.querySelector('.watch-require-row[data-require-idx="' + idx + '"]');
543
+ if (row && row.parentNode) row.parentNode.removeChild(row);
544
+ }
545
+
546
+ // Per-row condition refresh — mirrors _updateWatchConditionOptions for the
547
+ // primary watch's condition select but scoped to the requirement row.
548
+ function _updateRequireConditionOptions(idx) {
549
+ var ttSel = document.getElementById('watch-require-' + idx + '-target-type');
550
+ var condSel = document.getElementById('watch-require-' + idx + '-condition');
551
+ if (!ttSel || !condSel || !_watchTargetTypesCache) return;
552
+ var entry = _watchTargetTypesCache.find(function(t) { return t.value === ttSel.value; });
553
+ var conditions = (entry && entry.conditions) || [];
554
+ condSel.innerHTML = (conditions.length ? '' : '<option value="">— select target type first —</option>') +
555
+ conditions.map(function(c) {
556
+ return '<option value="' + escHtml(c) + '">' + escHtml(_conditionLabel(c)) + '</option>';
557
+ }).join('');
558
+ }
559
+
333
560
  // Refresh the condition <select> based on the currently selected target type.
334
561
  function _updateWatchConditionOptions() {
335
562
  var ttSel = document.getElementById('watch-edit-target-type');
@@ -349,6 +576,11 @@ function _renderCreateWatchModal() {
349
576
  document.getElementById('modal-body').style.whiteSpace = 'normal';
350
577
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
351
578
  document.getElementById('modal').classList.add('open');
579
+ // P-w14e7a8c — Phase 7.1: seed one empty action step row so the picker is
580
+ // visible by default (matches today's UX where the action <select> is
581
+ // always visible). Users who want notify-only just leave the type empty
582
+ // (or click Remove); users who want a chain click "+ Add step" again.
583
+ _addWatchStepRow();
352
584
  }
353
585
 
354
586
  function openCreateWatchModal() {
@@ -389,44 +621,84 @@ function submitWatch() {
389
621
  var description = (document.getElementById('watch-edit-desc') || {}).value || '';
390
622
  var stopAfter = parseInt((document.getElementById('watch-edit-stop-after') || {}).value, 10) || 0;
391
623
  var onNotMet = (document.getElementById('watch-edit-on-not-met') || {}).value || '';
392
- var actionType = (document.getElementById('watch-edit-action-type') || {}).value || '';
393
- var actionParamsRaw = (document.getElementById('watch-edit-action-params') || {}).value || '';
394
624
 
395
625
  var errEl = document.getElementById('watch-form-error');
396
626
  function showErr(msg) { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } }
397
627
 
398
628
  if (!target.trim()) { showErr('Target is required'); return; }
399
629
 
400
- var action = null;
401
- if (actionType) {
402
- var params = {};
403
- if (actionParamsRaw.trim()) {
404
- try { params = JSON.parse(actionParamsRaw); }
405
- catch (e) { showErr('Action Params must be valid JSON: ' + e.message); return; }
406
- if (params === null || typeof params !== 'object' || Array.isArray(params)) {
407
- showErr('Action Params must be a JSON object'); return;
630
+ // P-w14e7a8c — Phase 7.1: harvest action steps from the chain editor.
631
+ // 0 steps -> action: null (notify-only legacy behavior).
632
+ // 1 step -> action: { type, params, guard? } (single-object form).
633
+ // 2+ steps -> action: [ {...}, ... ] (array-form chain — engine/watch-actions.js
634
+ // runWatchActionChain handles it).
635
+ var stepRows = document.querySelectorAll('.watch-step-row');
636
+ var steps = [];
637
+ for (var i = 0; i < stepRows.length; i++) {
638
+ var row = stepRows[i];
639
+ var stepIdx = row.getAttribute('data-step-idx');
640
+ var typeEl = document.getElementById('watch-step-' + stepIdx + '-type');
641
+ var paramsEl = document.getElementById('watch-step-' + stepIdx + '-params');
642
+ var guardEl = document.getElementById('watch-step-' + stepIdx + '-guard');
643
+ var sType = typeEl && typeEl.value ? typeEl.value : '';
644
+ if (!sType) continue; // empty type → blank row, skip silently
645
+ var sParamsRaw = paramsEl ? paramsEl.value : '';
646
+ var sParams = {};
647
+ if (sParamsRaw && sParamsRaw.trim()) {
648
+ try { sParams = JSON.parse(sParamsRaw); }
649
+ catch (e) { showErr('Step ' + (steps.length + 1) + ' params must be valid JSON: ' + e.message); return; }
650
+ if (sParams === null || typeof sParams !== 'object' || Array.isArray(sParams)) {
651
+ showErr('Step ' + (steps.length + 1) + ' params must be a JSON object'); return;
408
652
  }
409
653
  }
410
- action = { type: actionType, params: params };
654
+ var step = { type: sType, params: sParams };
655
+ var sGuard = guardEl && guardEl.value ? guardEl.value.trim() : '';
656
+ if (sGuard) step.guard = { expr: sGuard };
657
+ steps.push(step);
411
658
  }
659
+ var action = null;
660
+ if (steps.length === 1) action = steps[0];
661
+ else if (steps.length > 1) action = steps;
662
+
663
+ // P-w14e7a8c — Phase 7.1: harvest cross-target requirements. Skip rows
664
+ // with no target (treat as empty/blank). Send `requires: []` (omit) when
665
+ // there are no usable rows so the back-end stays in legacy single-watch
666
+ // shape — engine/watches.js _validateRequires accepts both null and [].
667
+ var reqRows = document.querySelectorAll('.watch-require-row');
668
+ var requires = [];
669
+ for (var j = 0; j < reqRows.length; j++) {
670
+ var rrow = reqRows[j];
671
+ var rIdx = rrow.getAttribute('data-require-idx');
672
+ var rTarget = (document.getElementById('watch-require-' + rIdx + '-target') || {}).value || '';
673
+ var rType = (document.getElementById('watch-require-' + rIdx + '-target-type') || {}).value || '';
674
+ var rCond = (document.getElementById('watch-require-' + rIdx + '-condition') || {}).value || '';
675
+ if (!rTarget.trim() && !rType && !rCond) continue; // wholly blank row, skip
676
+ if (!rTarget.trim()) { showErr('Requirement ' + (requires.length + 1) + ': target is required'); return; }
677
+ if (!rType) { showErr('Requirement ' + (requires.length + 1) + ': target type is required'); return; }
678
+ if (!rCond) { showErr('Requirement ' + (requires.length + 1) + ': condition is required'); return; }
679
+ requires.push({ target: rTarget.trim(), targetType: rType, condition: rCond });
680
+ }
681
+
682
+ var payload = {
683
+ target: target.trim(),
684
+ targetType: targetType,
685
+ condition: condition,
686
+ interval: interval,
687
+ owner: owner || 'human',
688
+ project: project || null,
689
+ description: description || null,
690
+ notify: 'inbox',
691
+ stopAfter: stopAfter,
692
+ onNotMet: onNotMet || null,
693
+ action: action,
694
+ };
695
+ if (requires.length > 0) payload.requires = requires;
412
696
 
413
697
  showToast('cmd-toast', 'Creating watch...', true);
414
698
  fetch('/api/watches', {
415
699
  method: 'POST',
416
700
  headers: { 'Content-Type': 'application/json' },
417
- body: JSON.stringify({
418
- target: target.trim(),
419
- targetType: targetType,
420
- condition: condition,
421
- interval: interval,
422
- owner: owner || 'human',
423
- project: project || null,
424
- description: description || null,
425
- notify: 'inbox',
426
- stopAfter: stopAfter,
427
- onNotMet: onNotMet || null,
428
- action: action,
429
- })
701
+ body: JSON.stringify(payload)
430
702
  }).then(async function(res) {
431
703
  var data = await res.json().catch(function() { return {}; });
432
704
  if (!res.ok || data.error) {
@@ -453,4 +725,15 @@ window.MinionsWatches = {
453
725
  _watchPrev: _watchPrev,
454
726
  _watchNext: _watchNext,
455
727
  _updateWatchConditionOptions: _updateWatchConditionOptions,
728
+ // P-w14e7a8c — Phase 7.1: chain + requires editor handlers (called from
729
+ // inline onclick attributes generated by _watchFormHtml / _addWatchStepRow
730
+ // / _addRequireRow). Exposed on the namespace for testability.
731
+ _addWatchStepRow: _addWatchStepRow,
732
+ _removeWatchStepRow: _removeWatchStepRow,
733
+ _updateWatchStepParamsHint: _updateWatchStepParamsHint,
734
+ _addRequireRow: _addRequireRow,
735
+ _removeRequireRow: _removeRequireRow,
736
+ _updateRequireConditionOptions: _updateRequireConditionOptions,
737
+ // P-w15f1d4b — Phase 7.2: history table helper exposed for testability.
738
+ _renderWatchHistoryDetail: _renderWatchHistoryDetail,
456
739
  };
package/dashboard.js CHANGED
@@ -6501,12 +6501,26 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6501
6501
  return jsonReply(res, 200, { actionTypes: watchesMod.listActionTypes() });
6502
6502
  }
6503
6503
 
6504
+ // P-w13c2b6f — Phase 6.3: surface the per-watch evaluation history
6505
+ // (Phase 6.2's `_history` array) over an HTTP GET. Returns
6506
+ // {id, history:[…]} for known ids and 404 otherwise. The handler is
6507
+ // exported as `_handleWatchHistory` so unit tests can call it with a
6508
+ // mock res without booting the full server.
6509
+ async function handleWatchHistory(req, res, match) {
6510
+ const id = match && match[1];
6511
+ if (!id) return jsonReply(res, 400, { error: 'watch id required' });
6512
+ const watch = watchesMod.getWatches().find(w => w.id === id);
6513
+ if (!watch) return jsonReply(res, 404, { error: 'Watch not found' });
6514
+ const history = Array.isArray(watch._history) ? watch._history : [];
6515
+ return jsonReply(res, 200, { id, history });
6516
+ }
6517
+
6504
6518
  async function handleWatchesCreate(req, res) {
6505
6519
  const body = await readBody(req);
6506
- const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action } = body;
6520
+ const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action, requires } = body;
6507
6521
  if (!target) return jsonReply(res, 400, { error: 'target is required' });
6508
6522
  try {
6509
- const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action });
6523
+ const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action, requires });
6510
6524
  invalidateStatusCache();
6511
6525
  recordCcTurnIfPresent(req, {
6512
6526
  kind: 'watch', id: watch?.id || null, title: `Watch ${target}` + (condition ? ` (${condition})` : ''), project: project || null,
@@ -7536,6 +7550,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7536
7550
  { method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
7537
7551
  { method: 'GET', path: '/api/watches/target-types', desc: 'List registered watch target types and their valid conditions', handler: handleWatchesTargetTypes },
7538
7552
  { method: 'GET', path: '/api/watches/action-types', desc: 'List registered follow-up action types (notify, dispatch-work-item, webhook, ...)', handler: handleWatchesActionTypes },
7553
+ { method: 'GET', path: /^\/api\/watches\/([\w-]+)\/history$/, template: '/api/watches/:id/history', desc: 'Read the persisted evaluation history (last 25 checks) for a watch', handler: handleWatchHistory },
7539
7554
  { method: 'POST', path: '/api/watches', desc: 'Create a new watch', params: 'target, targetType, condition, interval?, owner?, description?, project?, notify?, stopAfter?, onNotMet?, action?', handler: handleWatchesCreate },
7540
7555
  { method: 'POST', path: '/api/watches/update', desc: 'Update a watch (pause/resume/modify)', params: 'id, status?, interval?, description?, notify?, stopAfter?, onNotMet?, condition?, action?', handler: handleWatchesUpdate },
7541
7556
  { method: 'POST', path: '/api/watches/delete', desc: 'Delete a watch', params: 'id', handler: handleWatchesDelete },
@@ -7942,6 +7957,7 @@ module.exports = {
7942
7957
  _collectArchivedWorkItems: collectArchivedWorkItems,
7943
7958
  buildCCStatePreamble,
7944
7959
  _routesAsMeta,
7960
+ _server: server,
7945
7961
  _buildTranscriptCarryover,
7946
7962
  _transcriptHasCarryoverContext,
7947
7963
  _ccRuntimeNeedsResumeCarryover,
package/engine/ado.js CHANGED
@@ -746,6 +746,70 @@ async function pollPrStatus(config) {
746
746
  updated = true;
747
747
  }
748
748
 
749
+ // P-w1a3f9b2 — Phase 1.1: plumb mergeable / isDraft / mergeStatus /
750
+ // headRefOid onto the PR object so watches captureState (engine/watches.js)
751
+ // and future predicates (Phase 2.1: head-commit-change, mergeable-flipped,
752
+ // ready-for-merge, draft-flipped) can read them.
753
+ //
754
+ // ADO ↔ GitHub mapping notes:
755
+ // - `headRefOid` ← lastMergeSourceCommit.commitId (source-branch tip,
756
+ // equivalent to GitHub's pulls API head.sha — the source-side push tip,
757
+ // not the merge commit).
758
+ // - `mergeable` is derived from prData.mergeStatus, which is ADO's
759
+ // "succeeded | conflicts | failure | queued | notSet | rejectedByPolicy"
760
+ // enum. We collapse it to GitHub's tri-state contract:
761
+ // succeeded → true (mergeable)
762
+ // conflicts | failure | rejectedByPolicy → false (not mergeable)
763
+ // queued | notSet | (anything else) → null (computing / unknown,
764
+ // treated as "no signal"
765
+ // by predicates).
766
+ // - `mergeStatus` is preserved verbatim as the ADO-native equivalent of
767
+ // GitHub's `mergeStateStatus`. **Gap:** ADO does not expose a
768
+ // direct equivalent of GitHub's `mergeable_state` (clean/dirty/unstable/
769
+ // blocked/behind/draft/has_hooks/unknown), so `pr.mergeStateStatus`
770
+ // stays unset for ADO PRs and watches that depend on it (Phase 2.1
771
+ // `behind-master` / fine-grained block detection) need to fall back to
772
+ // `mergeable === false` plus `pr.behindBy` (Phase 1.3) when
773
+ // `pr.mergeStateStatus` is null/undefined.
774
+ // - `isDraft` ← prData.isDraft (ADO field, same boolean semantics as
775
+ // GitHub's `draft`).
776
+ // - `behindBy` (P-w3a7b9c4 — Phase 1.3): **Gap:** ADO has no direct
777
+ // `behind_by` count equivalent to GitHub's /compare API, so ADO PRs
778
+ // always get `behindBy: null`. A future implementation could walk
779
+ // `lastMergeTargetCommit → master` via the commit-graph; deferred
780
+ // for now (see prd open_questions on ADO behindBy parity). The
781
+ // `behind-master` predicate treats null as "not behind" so this
782
+ // null contract keeps ADO PRs dormant against that watch (no
783
+ // false positives) instead of over-firing.
784
+ if (sourceCommit && pr.headRefOid !== sourceCommit) {
785
+ pr.headRefOid = sourceCommit;
786
+ updated = true;
787
+ }
788
+ const adoMergeStatus = typeof prData.mergeStatus === 'string' ? prData.mergeStatus : null;
789
+ if ((pr.mergeStatus || null) !== adoMergeStatus) {
790
+ pr.mergeStatus = adoMergeStatus;
791
+ updated = true;
792
+ }
793
+ const newMergeable = adoMergeStatus === 'succeeded' ? true
794
+ : (adoMergeStatus === 'conflicts' || adoMergeStatus === 'failure' || adoMergeStatus === 'rejectedByPolicy') ? false
795
+ : null; // queued | notSet | unknown → no signal
796
+ if (pr.mergeable !== newMergeable) {
797
+ pr.mergeable = newMergeable;
798
+ updated = true;
799
+ }
800
+ const newIsDraft = prData.isDraft === true;
801
+ if (pr.isDraft !== newIsDraft) {
802
+ pr.isDraft = newIsDraft;
803
+ updated = true;
804
+ }
805
+
806
+ // P-w3a7b9c4 — Phase 1.3: ADO PRs always get pr.behindBy = null
807
+ // (GitHub-only feature for v1; see comment block above for the gap).
808
+ if (pr.behindBy !== null) {
809
+ pr.behindBy = null;
810
+ updated = true;
811
+ }
812
+
749
813
  const reviewers = prData.reviewers || [];
750
814
  const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
751
815
  let newReviewStatus = pr.reviewStatus || 'pending';
@@ -1568,4 +1632,14 @@ module.exports = {
1568
1632
  _resetAdoThrottle, // exported for testing
1569
1633
  _setAdoThrottleForTest, // exported for testing
1570
1634
  _setAdoTokenForTest, // exported for testing
1635
+ // Exported for unit tests — engine code MUST go through pollPrStatus / reconcilePrs.
1636
+ decodeUrlSegment,
1637
+ stripGitSuffix,
1638
+ encodeAdoPathSegment,
1639
+ buildAdoPrUrlBase,
1640
+ adoRepoCandidateFromParts,
1641
+ parseAdoRepoMetadata,
1642
+ parseCanonicalAdoPrId,
1643
+ isAdoRepairCandidateCompatible,
1644
+ getMissingAdoProjectConfigFields,
1571
1645
  };