@yemi33/minions 0.1.2022 → 0.1.2024

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.
@@ -49,7 +49,7 @@ const RENDER_VERSIONS = {
49
49
  prd: 1,
50
50
  prs: 1,
51
51
  archivedPrds: 1,
52
- engine: 1,
52
+ engine: 2,
53
53
  version: 1,
54
54
  adoThrottle: 1,
55
55
  ghThrottle: 1,
@@ -153,7 +153,7 @@ function _processStatusUpdate(data) {
153
153
  if (_prsChanged) renderPrs(data.pullRequests || []);
154
154
  if (_changed('archivedPrds', data.archivedPrds)) renderArchiveButtons(data.archivedPrds || []);
155
155
  if (_changed('engine', data.engine)) {
156
- renderEngineStatus(data.engine);
156
+ if (data.engine) renderEngineStatus(data.engine);
157
157
  var qs = document.getElementById('engine-quick-stats');
158
158
  if (qs && data.engine) {
159
159
  var wt = data.engine.worktreeCount != null ? data.engine.worktreeCount : '-';
@@ -39,13 +39,12 @@ function _resetEngineRestartStateForTest() {
39
39
  function renderEngineStatus(engine) {
40
40
  const badge = document.getElementById('engine-badge');
41
41
  let state = engine?.state || 'stopped';
42
- let staleMs = 0;
43
-
44
- // Detect stale enginesays running but heartbeat is old (>2 min)
45
- if (state === 'running' && engine?.heartbeat) {
46
- staleMs = Date.now() - engine.heartbeat;
47
- if (staleMs > 120000) state = 'stale';
48
- }
42
+ // Server stamps engine.heartbeatStale + heartbeatAgeMs at snapshot build time
43
+ // (dashboard.js _buildStatusFastState). Avoids client-side wall-clock math
44
+ // against a possibly-cached heartbeat the bug pattern that produced false-
45
+ // positive STALE banners after control.json was dropped from the mtime tracker.
46
+ const staleMs = engine?.heartbeatAgeMs || 0;
47
+ if (state === 'running' && engine?.heartbeatStale) state = 'stale';
49
48
 
50
49
  // Clear restart grace as soon as the engine reports a fresh heartbeat — the
51
50
  // new engine has caught up, so STALE/restart banners should vanish.
@@ -170,8 +170,20 @@ window.addEventListener('beforeunload', function() { _sendDashboardPresence(true
170
170
  // guaranteed) and can resurrect a just-deleted entry with visibility:"hidden".
171
171
  // Hidden tabs naturally expire via the 45s presence window; foreground returns
172
172
  // re-fire visibilitychange so we still get a fresh heartbeat when the user comes back.
173
+ //
174
+ // Also kick a refresh() so the SPA recovers from background-tab throttling.
175
+ // Chromium throttles setInterval to ~1/min on backgrounded tabs, which stretches
176
+ // our 4 s poll cadence to 60 s. A backgrounded tab that re-focuses after the
177
+ // engine paused / restarted / heartbeat advanced can otherwise sit on stale
178
+ // state (including a stuck "Engine heartbeat is stale" banner) until the next
179
+ // throttled tick fires. Triggering refresh() inline on becoming-visible
180
+ // closes that window to <1 s. refresh() is single-flight-gated by
181
+ // _refreshInFlight (see refresh.js), so concurrent calls collapse to one fetch.
173
182
  document.addEventListener('visibilitychange', function() {
174
- if (document.visibilityState === 'visible') _sendDashboardPresence(false);
183
+ if (document.visibilityState === 'visible') {
184
+ _sendDashboardPresence(false);
185
+ try { if (typeof refresh === 'function') refresh(); } catch {}
186
+ }
175
187
  });
176
188
 
177
189
  function rerenderPrdFromCache() {
package/dashboard.js CHANGED
@@ -127,6 +127,19 @@ function warmProjectGitStatusCache() {
127
127
  }
128
128
  warmProjectGitStatusCache();
129
129
 
130
+ // Wire the queries.js → dashboard.js cache-invalidation hook. When a
131
+ // background project-git-status probe lands with a NEW value (branch
132
+ // changed, dirty flipped, ahead/behind shifted), pop the /api/status
133
+ // cache immediately so the next SPA poll (≤4 s away) carries the fresh
134
+ // value. The `projects:` slice lives in the SLOW-state cache (60 s TTL),
135
+ // so the invalidation must include slow — otherwise the freshly-probed
136
+ // inner-cache value sits behind a stale 60 s slow-state snapshot.
137
+ try {
138
+ queries._setOnProjectGitStatusChanged(() => {
139
+ try { invalidateStatusCache({ includeSlow: true }); } catch { /* status cache may not be initialized yet */ }
140
+ });
141
+ } catch { /* defensive — wiring is best-effort, queries probes still work without it */ }
142
+
130
143
  function resolveScheduleProjectValue(project, projects = PROJECTS) {
131
144
  if (project === undefined) return { project: undefined };
132
145
  const target = shared.resolveConfiguredProject(project, projects);
@@ -1505,6 +1518,11 @@ const FAST_STATE_TTL = 10000; // 10s — dispatch, agents, metrics, work items,
1505
1518
  let _slowState = null;
1506
1519
  let _slowStateTs = 0;
1507
1520
  const SLOW_STATE_TTL = 60000; // 60s — skills, PRDs, pinned, version, projects, etc.
1521
+ // Heartbeat staleness threshold. Computed server-side at snapshot build time so
1522
+ // the client renders off a boolean instead of doing wall-clock math against a
1523
+ // possibly-cached heartbeat (which produced false-positive STALE banners after
1524
+ // control.json was dropped from the mtime tracker in W-mpg8aapw001d7e0c).
1525
+ const ENGINE_HEARTBEAT_STALE_MS = 120000;
1508
1526
  let _statusCache = null;
1509
1527
  let _statusCacheJson = null; // cached JSON.stringify(_statusCache) — avoids double-serialization for SSE
1510
1528
  let _statusCacheGzip = null; // pre-computed gzip of _statusCacheJson — avoids per-request gzipSync
@@ -1626,12 +1644,22 @@ function invalidateStatusCache(opts) {
1626
1644
  function _buildStatusFastState() {
1627
1645
  // reloadConfig is called by both sync and async callers before this — kept
1628
1646
  // outside so the async path can yield between reload and rebuild.
1647
+ const engineState = getEngineState();
1648
+ const hbAge = engineState.heartbeat ? Date.now() - engineState.heartbeat : 0;
1629
1649
  return {
1630
1650
  agents: getAgents(),
1631
1651
  inbox: getInbox(),
1632
1652
  notes: getNotesWithMeta(),
1633
1653
  pullRequests: getPullRequests(),
1634
- engine: { ...getEngineState(), worktreeCount: _countWorktrees() },
1654
+ engine: {
1655
+ ...engineState,
1656
+ worktreeCount: _countWorktrees(),
1657
+ // Snapshot-time staleness. Frozen with the snapshot; the client renders
1658
+ // off these instead of recomputing Date.now() - heartbeat against a
1659
+ // possibly-cached payload (false-positive banner regression #2754).
1660
+ heartbeatAgeMs: engineState.heartbeat ? hbAge : null,
1661
+ heartbeatStale: !!(engineState.heartbeat && hbAge > ENGINE_HEARTBEAT_STALE_MS),
1662
+ },
1635
1663
  adoThrottle: ado.getAdoThrottleState(),
1636
1664
  ghThrottle: gh.getGhThrottleState(),
1637
1665
  dispatch: getDispatchQueue(),
@@ -1,6 +1,6 @@
1
1
  # Auto-Discovery & Execution Pipeline
2
2
 
3
- > Last verified: 2026-05-17 against `engine.js` `tickInner()` (lines 5360-5993).
3
+ > Last verified: 2026-05-22 against `engine.js` `tickInner()` (lines 6068-6425).
4
4
 
5
5
  How the minions engine finds work and dispatches agents automatically.
6
6
 
@@ -39,7 +39,7 @@ The engine does not cap review→fix cycles or build-fix attempts. Each trigger
39
39
  ### A. Human comments (`humanFeedback.pendingFix`)
40
40
 
41
41
  - Gate: `pendingFix || coalescedFeedback` + not already dispatched/on cooldown
42
- - Agent comments filtered out via `/\bMinions\s*\(/i` regex on comment body
42
+ - Agent comments filtered out via `_isNonActionableComment()` (`engine/github.js`, `engine/ado.js`) composes preview/bot body checks and the structural Minions-author check from `engine/comment-classifier.js`. The structural check matches the `<!-- minions:agent=<id> kind=<kind> -->` HTML marker on the body's first line (`hasMinionsMarker`); on GitHub it must combine with `viewerDidAuthor === true` (anti-spoof), on ADO with a `config.engine.minionsAdoIdentities` author match. Marker presence alone never classifies a comment as agent-authored on either platform.
43
43
  - Coalesces multiple comments arriving during cooldown into single fix
44
44
  - Routes to author
45
45
  - Fix agents must treat human comments as claims to verify, not commands. They inspect or reproduce each claimed issue, make the smallest correct fix only when the claim is valid, and otherwise reply with evidence-backed rationale.
@@ -108,6 +108,7 @@ The engine does not cap review→fix cycles or build-fix attempts. Each trigger
108
108
  | `engine/lifecycle.js` | `syncPrsFromOutput()`, `updatePrAfterReview()`, `updatePrAfterFix()` |
109
109
  | `engine/github.js` | `pollPrStatus()`, `pollPrHumanComments()`, `fetchGhBuildErrorLog()` |
110
110
  | `engine/ado.js` | `pollPrStatus()`, `pollPrHumanComments()`, `fetchAdoBuildErrorLog()` |
111
+ | `engine/comment-classifier.js` | Host-agnostic body-only predicates (`isPreviewStatusBody`, `hasMinionsMarker`, `hasVerdictPrefix`) shared by GH + ADO pollers (extracted in F2+F3+F4) |
111
112
  | `engine/dispatch.js` | `addToDispatch()` — dedup by work item ID and dispatchKey |
112
113
  | `engine/cooldown.js` | `isBranchActive()`, cooldown management |
113
114
  | `playbooks/review.md` | Reviewer playbook |
@@ -174,10 +174,10 @@ Human comments on PR with "@minions fix the error handling here"
174
174
 
175
175
  ### How it works
176
176
 
177
- - **Trigger:** If you're the only human commenter, **any** comment triggers a fix. If multiple humans are commenting, `@minions` keyword is required to avoid noise
178
- - **Agent detection:** Comments matching `/\bMinions\s*\(/i` are skipped (agent signature pattern)
177
+ - **Trigger:** Any human comment newer than the per-PR cutoff that survives `_isNonActionableComment()` (preview/CI/Minions-author filter) flips `pr.humanFeedback.pendingFix = true` and queues a fix. There is no "@minions keyword required" gate — `@minions` mentions are merely stripped from `feedbackContent` before it is handed to the fix agent.
178
+ - **Agent detection:** Comments are filtered via `_isNonActionableComment()` in `engine/github.js` / `engine/ado.js`, which composes the body-only predicates from `engine/comment-classifier.js` (`isPreviewStatusBody`, `hasMinionsMarker`) with an identity gate (`viewerDidAuthor === true` on GitHub, `config.engine.minionsAdoIdentities` author match on ADO). Marker presence alone never classifies a comment as agent-authored on either platform.
179
179
  - **Dedup:** Only comments newer than `pr.humanFeedback.lastProcessedCommentDate` are processed
180
- - **Multiple comments:** All new `@minions` comments are concatenated into a single fix task
180
+ - **Multiple comments:** All new actionable comments since the cutoff are concatenated chronologically into a single fix task (each body individually fenced in `<UNTRUSTED-INPUT>` per F5 / W-mpeklod3000we69c)
181
181
  - **After fix:** `pendingFix` is cleared; PR re-enters normal review cycle
182
182
 
183
183
  ## 5. Quality Metrics
package/docs/watches.md CHANGED
@@ -49,7 +49,7 @@ When `stopAfter === 0`, these are **fire-once** — the engine flips the watch t
49
49
  > **Per-target override (W-mp7hg58e000b5212):** the global `WATCH_ABSOLUTE_CONDITIONS` set is the legacy fallback. Each target type now declares its own `absoluteConditions: [...]` array in its spec; `registerTargetType` normalizes that into a `Set` that takes precedence at evaluation time. The plugin contract (see below) uses this to keep absolute-vs-change semantics local to each target type. Plugins that omit `absoluteConditions` get an empty set (all change-based).
50
50
 
51
51
  ### Change-based conditions
52
- `status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`, plus the predicate conditions added under P-w4e2f6a1 / P-w5b8d2c9 for the `pr`, `work-item`, `plan`, and `pipeline` target types (`head-commit-change`, `mergeable-flipped`, `behind-master`, `draft-flipped`, `stalled`, `dependency-met`, `stage-advanced`, `stuck-in-stage`). See `engine/shared.js:1891-1929` for the canonical enum.
52
+ `status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`, plus the predicate conditions added under P-w4e2f6a1 / P-w5b8d2c9 for the `pr`, `work-item`, `plan`, and `pipeline` target types (`head-commit-change`, `mergeable-flipped`, `behind-master`, `draft-flipped`, `stalled`, `dependency-met`, `stage-advanced`, `stuck-in-stage`). See `engine/shared.js:2422-2460` for the canonical enum.
53
53
 
54
54
  These compare the live entity against the watch's `_lastState` snapshot and run forever when `stopAfter === 0`. Baseline `_lastState` is captured on the first check so the very next change triggers the watch *(source: `engine/watches.js:434, 520`)*.
55
55
 
@@ -89,7 +89,7 @@ Canonical example: `watches.d/http.js` (W-mp7i22mu00191b07) — a generic HTTP p
89
89
 
90
90
  ### Built-in target types
91
91
 
92
- The eight built-ins are registered at module load *(source: `engine/watches.js:672-1311`)*. Constants live at `engine/shared.js:1881-1890` (`WATCH_TARGET_TYPE`).
92
+ The eight built-ins are registered at module load *(source: `engine/watches.js:672-1313`)*. Constants live at `engine/shared.js:2412-2421` (`WATCH_TARGET_TYPE`).
93
93
 
94
94
  | `targetType` | Target value | Conditions | Notes |
95
95
  |---------------|--------------------------------------|----------------------------------------------------------------------------|-------|
@@ -174,7 +174,7 @@ I/O happens **outside the lock**: notifications via `writeToInbox`, follow-up ac
174
174
  | `archive-plan` | Set PRD `status="archived"` + `archivedAt` |
175
175
  | `resume-plan` | Set PRD `status=PLAN_STATUS.ACTIVE` and clear `planStale` |
176
176
 
177
- Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js:1960`); handlers in `engine/watch-actions.js`.
177
+ Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js:2491`); handlers in `engine/watch-actions.js`.
178
178
 
179
179
  ### Templating
180
180
 
@@ -245,7 +245,7 @@ Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `
245
245
 
246
246
  ## See Also
247
247
 
248
- - `engine/shared.js:1875-1960` — `WATCH_STATUS`, `WATCH_TARGET_TYPE`, `WATCH_CONDITION`, `WATCH_ABSOLUTE_CONDITIONS`, `WATCH_ACTION_TYPE` constants
248
+ - `engine/shared.js:2406-2500` — `WATCH_STATUS`, `WATCH_TARGET_TYPE`, `WATCH_CONDITION`, `WATCH_ABSOLUTE_CONDITIONS`, `WATCH_ACTION_TYPE` constants
249
249
  - `engine/watches.js` — registry, lifecycle, tick integration, `watches.d/` plugin loader
250
250
  - `engine/watch-actions.js` — action registry and built-in handlers (including `minions-api`)
251
251
  - `watches.d/http.js` — canonical user-extensible target type plugin
package/engine/ado.js CHANGED
@@ -998,6 +998,7 @@ async function pollPrStatus(config) {
998
998
  else if (prData.status === 'active') newStatus = PR_STATUS.ACTIVE;
999
999
 
1000
1000
  if (pr.status !== newStatus) {
1001
+ const oldStatus = pr.status;
1001
1002
  log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
1002
1003
  pr.status = newStatus;
1003
1004
  updated = true;
@@ -1030,12 +1031,41 @@ async function pollPrStatus(config) {
1030
1031
  delete pr._buildStatusDetail;
1031
1032
  delete pr.buildFixAttempts;
1032
1033
  }
1034
+ // F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
1035
+ // — no further re-dispatch path can fire from this PR, so keeping it
1036
+ // around would only bloat pull-requests.json.
1037
+ if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
1038
+ delete pr.humanFeedback.editsSeen;
1039
+ }
1033
1040
  // Cancel any pending review/fix dispatches — they're stale now that the PR is closed
1034
1041
  try {
1035
1042
  dispatchModule().cancelPendingDispatchesForPr(pr.id);
1036
1043
  } catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
1037
1044
  await engine().handlePostMerge(pr, project, config, newStatus);
1038
1045
  }
1046
+
1047
+ // P-f5reopenreset: reopen transient-state reset.
1048
+ // On reopen (abandoned→active), the prior review session is no longer
1049
+ // authoritative for the now-active PR — wipe transient state that
1050
+ // references it. Invariant #14 ('approved is permanent terminal') is
1051
+ // preserved: we keep reviewStatus when it was 'approved' rather than
1052
+ // carving a wasReopened exception into the 8+ guard sites in
1053
+ // engine/lifecycle.js, engine.js, engine/github.js, and engine/ado.js.
1054
+ // Re-review safety on new commits is handled by the pre-existing
1055
+ // pr.lastPushedAt > pr.lastReviewedAt path (engine.js alreadyReviewed
1056
+ // gate) plus the changes-requested override in lifecycle.js.
1057
+ if (oldStatus === PR_STATUS.ABANDONED && newStatus === PR_STATUS.ACTIVE) {
1058
+ pr.minionsReview = null;
1059
+ pr._lastDispatchByCause = {};
1060
+ pr.humanFeedback = { ...(pr.humanFeedback || {}), lastSeenAt: null, lastSeenCommentId: null };
1061
+ // F6 (P-f6commentedit): drop the comment-edit dedup map on reopen so a
1062
+ // re-edited comment after reopen still re-dispatches. Safe no-op when
1063
+ // F6 not yet shipped (field absent).
1064
+ delete pr.humanFeedback.editsSeen;
1065
+ pr.fixDispatched = false;
1066
+ if (pr.reviewStatus !== 'approved') pr.reviewStatus = 'pending';
1067
+ log('info', `PR ${pr.id} reopened — reset transient state (reviewStatus=${pr.reviewStatus})`);
1068
+ }
1039
1069
  }
1040
1070
 
1041
1071
  // Track head commit changes to detect new pushes (used for review re-dispatch gating)
@@ -1490,6 +1520,14 @@ async function pollPrHumanComments(config) {
1490
1520
  const newHumanComments = [];
1491
1521
  const allCommentDates = [];
1492
1522
  const ignoredAuthors = (config.engine?.ignoredCommentAuthors || []).map(a => a.toLowerCase());
1523
+ // F6 (P-f6commentedit): collect every non-system comment for the edit-dedup
1524
+ // map. ADO `lastUpdatedDate` is inconsistent (sometimes `+00:00`, sometimes
1525
+ // naive) — shared.normalizeIsoTimestamp normalizes via `new Date(raw).toISOString()`
1526
+ // before string comparison.
1527
+ const editsInput = [];
1528
+ // Threads-by-id index used by the F6 path to recover threadId when a
1529
+ // pre-cutoff edited comment is routed into newHumanComments.
1530
+ const threadIdByComment = new Map();
1493
1531
 
1494
1532
  // P-f23classifier (F3, security note): ADO has no `viewerDidAuthor`
1495
1533
  // equivalent. A human pasting our `<!-- minions:agent=... -->` marker
@@ -1531,6 +1569,14 @@ async function pollPrHumanComments(config) {
1531
1569
  // Track date for cutoff BEFORE author/body filters so bot/CI/ignored
1532
1570
  // comments still advance the cutoff.
1533
1571
  if (comment.publishedDate) allCommentDates.push(comment.publishedDate);
1572
+ if (comment.id != null) {
1573
+ threadIdByComment.set(String(comment.id), thread.id);
1574
+ editsInput.push({
1575
+ id: comment.id,
1576
+ createdRaw: comment.publishedDate,
1577
+ updatedRaw: comment.lastUpdatedDate,
1578
+ });
1579
+ }
1534
1580
  // Skip explicitly ignored authors.
1535
1581
  const authorName = (comment.author?.displayName || '').toLowerCase();
1536
1582
  if (ignoredAuthors.some(a => authorName.includes(a))) continue;
@@ -1575,14 +1621,44 @@ async function pollPrHumanComments(config) {
1575
1621
  }
1576
1622
  }
1577
1623
 
1624
+ // F6 (P-f6commentedit): TZ-normalized per-comment edit dedup map. Comments
1625
+ // that were EDITED (lastUpdatedDate > publishedDate) but whose normalized
1626
+ // edit timestamp differs from what we already saw queue as new feedback
1627
+ // (re-dispatch). First-seen comments and re-polls of already-seen edits
1628
+ // do NOT route through this path.
1629
+ const prevEditsSeen = (pr.humanFeedback && pr.humanFeedback.editsSeen) || {};
1630
+ editsInput.sort((a, b) => String(a.updatedRaw || '').localeCompare(String(b.updatedRaw || '')));
1631
+ const { editsSeen, newlyEditedIds, changed: editsSeenChanged } =
1632
+ shared.buildEditsSeen(editsInput, prevEditsSeen);
1633
+
1634
+ // F6: promote pre-cutoff comments that were newly edited into newHumanComments
1635
+ // (agent-authored edits do NOT re-dispatch — vote path handles agent reviews).
1636
+ const seenNewCommentIds = new Set(newHumanComments.map(c => String(c.commentId)));
1637
+ for (const entry of allHumanComments) {
1638
+ if (entry._isAgent) continue;
1639
+ const id = String(entry.commentId);
1640
+ if (seenNewCommentIds.has(id)) continue;
1641
+ if (!newlyEditedIds.has(id)) continue;
1642
+ newHumanComments.push({ ...entry, date: editsSeen[id] || entry.date, _isEditedRedispatch: true });
1643
+ seenNewCommentIds.add(id);
1644
+ }
1645
+
1578
1646
  // Persist cutoff unconditionally for any new comment we observed — even
1579
1647
  // if every new comment was bot/CI/ignored/agent. Mirrors GitHub poller.
1580
1648
  const allNewDates = allCommentDates.filter(d => (new Date(d).getTime() || 0) > cutoffMs);
1581
1649
  if (allNewDates.length > 0 && newHumanComments.length === 0) {
1582
1650
  pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
1651
+ if (editsSeenChanged) pr.humanFeedback.editsSeen = editsSeen;
1583
1652
  return true;
1584
1653
  }
1585
- if (newHumanComments.length === 0) return false;
1654
+ if (newHumanComments.length === 0) {
1655
+ // F6: persist pruned/updated editsSeen map even when no dispatch is queued.
1656
+ if (editsSeenChanged) {
1657
+ pr.humanFeedback = { ...(pr.humanFeedback || {}), editsSeen };
1658
+ return true;
1659
+ }
1660
+ return false;
1661
+ }
1586
1662
 
1587
1663
  // Sort all comments chronologically and build full context for the fix agent.
1588
1664
  // Cutoff advances to the latest of ALL new comment dates (so newer agent/CI
@@ -1614,12 +1690,14 @@ async function pollPrHumanComments(config) {
1614
1690
  })
1615
1691
  .join('\n\n---\n\n');
1616
1692
 
1693
+ const latestNewHuman = newHumanComments[newHumanComments.length - 1];
1617
1694
  pr.humanFeedback = {
1618
1695
  lastProcessedCommentDate: latestDate,
1619
- lastProcessedCommentId: String(newHumanComments[newHumanComments.length - 1].commentId),
1620
- lastProcessedCommentKey: `${newHumanComments[newHumanComments.length - 1].threadId}:${newHumanComments[newHumanComments.length - 1].commentId}`,
1696
+ lastProcessedCommentId: String(latestNewHuman.commentId),
1697
+ lastProcessedCommentKey: `${latestNewHuman.threadId || threadIdByComment.get(String(latestNewHuman.commentId)) || ''}:${latestNewHuman.commentId}`,
1621
1698
  pendingFix: true,
1622
- feedbackContent
1699
+ feedbackContent,
1700
+ editsSeen,
1623
1701
  };
1624
1702
 
1625
1703
  log('info', `PR ${pr.id}: ${newHumanComments.length} new comment(s), ${allHumanComments.length} total — full thread context provided`);
package/engine/github.js CHANGED
@@ -765,6 +765,7 @@ async function pollPrStatus(config) {
765
765
  }
766
766
 
767
767
  if (pr.status !== newStatus) {
768
+ const oldStatus = pr.status;
768
769
  log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
769
770
  pr.status = newStatus;
770
771
  updated = true;
@@ -795,12 +796,41 @@ async function pollPrStatus(config) {
795
796
  delete pr._buildFailNotified;
796
797
  delete pr.buildFixAttempts;
797
798
  }
799
+ // F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
800
+ // — no further re-dispatch path can fire from this PR, so keeping it
801
+ // around would only bloat pull-requests.json.
802
+ if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
803
+ delete pr.humanFeedback.editsSeen;
804
+ }
798
805
  // Cancel any pending review/fix dispatches — they're stale now that the PR is closed
799
806
  try {
800
807
  dispatchModule().cancelPendingDispatchesForPr(pr.id);
801
808
  } catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
802
809
  await engine().handlePostMerge(pr, project, config, newStatus);
803
810
  }
811
+
812
+ // P-f5reopenreset: reopen transient-state reset.
813
+ // On reopen (abandoned→active), the prior review session is no longer
814
+ // authoritative for the now-active PR — wipe transient state that
815
+ // references it. Invariant #14 ('approved is permanent terminal') is
816
+ // preserved: we keep reviewStatus when it was 'approved' rather than
817
+ // carving a wasReopened exception into the 8+ guard sites in
818
+ // engine/lifecycle.js, engine.js, engine/github.js, and engine/ado.js.
819
+ // Re-review safety on new commits is handled by the pre-existing
820
+ // pr.lastPushedAt > pr.lastReviewedAt path (engine.js alreadyReviewed
821
+ // gate) plus the changes-requested override in lifecycle.js.
822
+ if (oldStatus === PR_STATUS.ABANDONED && newStatus === PR_STATUS.ACTIVE) {
823
+ pr.minionsReview = null;
824
+ pr._lastDispatchByCause = {};
825
+ pr.humanFeedback = { ...(pr.humanFeedback || {}), lastSeenAt: null, lastSeenCommentId: null };
826
+ // F6 (P-f6commentedit): drop the comment-edit dedup map on reopen so a
827
+ // re-edited comment after reopen still re-dispatches. Safe no-op when
828
+ // F6 not yet shipped (field absent).
829
+ delete pr.humanFeedback.editsSeen;
830
+ pr.fixDispatched = false;
831
+ if (pr.reviewStatus !== 'approved') pr.reviewStatus = 'pending';
832
+ log('info', `PR ${pr.id} reopened — reset transient state (reviewStatus=${pr.reviewStatus})`);
833
+ }
804
834
  }
805
835
 
806
836
  // Review status from GitHub reviews
@@ -1006,12 +1036,30 @@ async function pollPrHumanComments(config) {
1006
1036
  const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || pr._attachedAt || '1970-01-01';
1007
1037
  const cutoffMs = new Date(cutoffStr).getTime() || 0;
1008
1038
 
1039
+ // F6 (P-f6commentedit): TZ-normalized per-comment edit dedup map. Comments
1040
+ // that were EDITED (updated_at > created_at) but whose normalized edit
1041
+ // timestamp differs from what we already saw are queued as new feedback so
1042
+ // a follow-up fix re-dispatches. First-seen comments and re-polls of
1043
+ // already-seen edits do NOT route through this path.
1044
+ const prevEditsSeen = (pr.humanFeedback && pr.humanFeedback.editsSeen) || {};
1045
+ const editsInput = allComments.map(c => ({
1046
+ id: c.id,
1047
+ createdRaw: c.created_at,
1048
+ updatedRaw: c.updated_at,
1049
+ }));
1050
+ // Sort chronologically by updated timestamp so FIFO cap eviction keeps the
1051
+ // most-recently-edited entries (the ones most likely to be re-edited).
1052
+ editsInput.sort((a, b) => String(a.updatedRaw || '').localeCompare(String(b.updatedRaw || '')));
1053
+ const { editsSeen, newlyEditedIds, changed: editsSeenChanged } =
1054
+ shared.buildEditsSeen(editsInput, prevEditsSeen);
1055
+
1009
1056
  // Collect comments that should advance the cutoff separately from comments
1010
1057
  // that should dispatch a fix. Informational bot/status comments and
1011
1058
  // Minions-authored triage comments should be seen once, then ignored.
1012
1059
  const allCommentDates = [];
1013
1060
  const allCommentEntries = [];
1014
1061
  const newComments = [];
1062
+ const seenInNewComments = new Set();
1015
1063
 
1016
1064
  for (const c of allComments) {
1017
1065
  const date = c.created_at || c.updated_at || '';
@@ -1035,6 +1083,13 @@ async function pollPrHumanComments(config) {
1035
1083
 
1036
1084
  if (dateMs && dateMs > cutoffMs) {
1037
1085
  newComments.push(entry);
1086
+ seenInNewComments.add(String(c.id));
1087
+ } else if (c.id != null && newlyEditedIds.has(String(c.id))) {
1088
+ // F6: pre-cutoff comment that was newly edited — route into newComments
1089
+ // so a fresh fix dispatches, but tag with the edit timestamp so the
1090
+ // fix-agent prompt context flags it as new.
1091
+ newComments.push({ ...entry, date: editsSeen[String(c.id)] || date, _isEditedRedispatch: true });
1092
+ seenInNewComments.add(String(c.id));
1038
1093
  }
1039
1094
  }
1040
1095
 
@@ -1042,9 +1097,17 @@ async function pollPrHumanComments(config) {
1042
1097
  const allNewDates = allCommentDates.filter(date => (new Date(date).getTime() || 0) > cutoffMs);
1043
1098
  if (allNewDates.length > 0 && newComments.length === 0) {
1044
1099
  pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
1100
+ if (editsSeenChanged) pr.humanFeedback.editsSeen = editsSeen;
1045
1101
  return true; // non-actionable comments only — persist cutoff without triggering fix
1046
1102
  }
1047
- if (newComments.length === 0) return false;
1103
+ if (newComments.length === 0) {
1104
+ // F6: persist pruned/updated editsSeen map even when no dispatch is queued.
1105
+ if (editsSeenChanged) {
1106
+ pr.humanFeedback = { ...(pr.humanFeedback || {}), editsSeen };
1107
+ return true;
1108
+ }
1109
+ return false;
1110
+ }
1048
1111
 
1049
1112
  // Sort all comments chronologically and build full context for the fix agent
1050
1113
  allCommentEntries.sort((a, b) => a.date.localeCompare(b.date));
@@ -1075,7 +1138,8 @@ async function pollPrHumanComments(config) {
1075
1138
  lastProcessedCommentId: String(newComments[newComments.length - 1].commentId),
1076
1139
  lastProcessedCommentKey: String(newComments[newComments.length - 1].commentId),
1077
1140
  pendingFix: true,
1078
- feedbackContent
1141
+ feedbackContent,
1142
+ editsSeen,
1079
1143
  };
1080
1144
 
1081
1145
  log('info', `PR ${pr.id}: ${newComments.length} new comment(s), ${allCommentEntries.length} total — full thread context provided`);
package/engine/queries.js CHANGED
@@ -1736,14 +1736,50 @@ async function _probeProjectGitStatus(localPath, configuredMainBranch) {
1736
1736
  }
1737
1737
  }
1738
1738
 
1739
+ // Hook fired when a background git-status probe lands with a value that
1740
+ // DIFFERS from the previously-cached entry. Lets dashboard.js wire its
1741
+ // `invalidateStatusCache()` here so a freshly-detected branch change /
1742
+ // dirty flip / ahead/behind shift pops the /api/status cache immediately
1743
+ // instead of waiting out its independent 10 s fast-state TTL. Without
1744
+ // this, even adding `.git/logs/HEAD` to the mtime tracker only buys you
1745
+ // the FIRST rebuild (which still returns the stale cached value while
1746
+ // the background probe is in flight); the rebuild that actually carries
1747
+ // the fresh value has to wait for the next mtime advance or TTL expiry,
1748
+ // adding another 10 s.
1749
+ let _onProjectGitStatusChanged = null;
1750
+ function _setOnProjectGitStatusChanged(fn) {
1751
+ _onProjectGitStatusChanged = typeof fn === 'function' ? fn : null;
1752
+ }
1753
+ // Cheap shallow equality for the PROJECT_GIT_STATUS_* shape. Enumerates
1754
+ // the known fields explicitly so a new field added to the shape requires
1755
+ // updating both the producer and this comparator.
1756
+ function _projectGitStatusEqual(a, b) {
1757
+ if (a === b) return true;
1758
+ if (!a || !b) return false;
1759
+ return (
1760
+ a.gitBranch === b.gitBranch
1761
+ && a.gitDetached === b.gitDetached
1762
+ && a.gitDirty === b.gitDirty
1763
+ && a.gitState === b.gitState
1764
+ && a.remoteDefaultBranch === b.remoteDefaultBranch
1765
+ && a.ahead === b.ahead
1766
+ && a.behind === b.behind
1767
+ );
1768
+ }
1769
+
1739
1770
  function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch) {
1740
1771
  const existing = _projectGitStatusCache.get(key);
1741
1772
  if (existing && existing.promise) return existing.promise;
1742
1773
  const entry = existing || { ts: 0, value: PROJECT_GIT_STATUS_PENDING, promise: null };
1774
+ const prevValue = entry.value;
1743
1775
  entry.promise = _probeProjectGitStatus(localPath, configuredMainBranch).then(value => {
1744
1776
  entry.ts = Date.now();
1745
1777
  entry.value = value;
1746
1778
  entry.promise = null;
1779
+ if (_onProjectGitStatusChanged && !_projectGitStatusEqual(prevValue, value)) {
1780
+ try { _onProjectGitStatusChanged(key, value, prevValue); }
1781
+ catch { /* hook must never break the probe */ }
1782
+ }
1747
1783
  return value;
1748
1784
  }, () => {
1749
1785
  entry.promise = null;
@@ -1877,9 +1913,21 @@ function getStatusFastStateMtimePaths(config) {
1877
1913
  // original tracked list — PR status flips (running → passing, waiting →
1878
1914
  // approved) were waiting on the 10 s SSE backstop instead of the next
1879
1915
  // 4 s SPA poll.
1916
+ //
1917
+ // Per-project `.git/logs/HEAD` (the reflog) — added to cut the project-
1918
+ // branch-state lag (was 15–25 s typical). The reflog is appended on every
1919
+ // HEAD-changing operation (checkout, commit, reset, merge, rebase), so
1920
+ // its mtime is a perfect signal that getProjectGitStatus's cached value
1921
+ // is stale. Unlike control.json (intentionally excluded above), it is
1922
+ // NOT advanced on a timer — it only moves on user-initiated git
1923
+ // operations, so it can't dominate legitimate state changes. Cheap (one
1924
+ // statSync per project per cache miss).
1880
1925
  for (const p of projects) {
1881
1926
  files.push(shared.projectWorkItemsPath(p));
1882
1927
  files.push(shared.projectPrPath(p));
1928
+ if (p && p.localPath) {
1929
+ files.push(path.join(p.localPath, '.git', 'logs', 'HEAD'));
1930
+ }
1883
1931
  }
1884
1932
  return files;
1885
1933
  }
@@ -1901,6 +1949,7 @@ module.exports = {
1901
1949
  getProjectGitStatus,
1902
1950
  warmProjectGitStatus,
1903
1951
  _awaitPendingProjectGitStatusProbes,
1952
+ _setOnProjectGitStatusChanged,
1904
1953
  // W-mpftp7na000td0f4 — engine→dashboard cache-invalidation registry
1905
1954
  getStatusFastStateMtimePaths,
1906
1955
 
package/engine/shared.js CHANGED
@@ -83,6 +83,92 @@ function ts() { return new Date().toISOString(); }
83
83
  function logTs() { return new Date().toLocaleTimeString(); }
84
84
  function dateStamp() { return new Date().toISOString().slice(0, 10); }
85
85
 
86
+ // ── F6 (P-f6commentedit): Comment-edit dedup helpers ────────────────────────
87
+ // Shared by engine/github.js + engine/ado.js pollPrHumanComments so a comment
88
+ // EDITED after first observation triggers a single re-dispatch (and only one).
89
+ // MANDATORY TZ NORMALIZATION: GH `updated_at` is always UTC (Z-suffix), but
90
+ // ADO `lastUpdatedDate` is inconsistent — sometimes `+00:00`, sometimes naive
91
+ // (no offset). Without normalizeIsoTimestamp() string comparison silently
92
+ // no-ops on the offset-variant case and re-dispatch never fires for ADO edits.
93
+
94
+ const EDITS_SEEN_CAP = 200;
95
+
96
+ function normalizeIsoTimestamp(raw) {
97
+ if (!raw) return null;
98
+ const d = new Date(raw);
99
+ const t = d.getTime();
100
+ return Number.isFinite(t) ? d.toISOString() : null;
101
+ }
102
+
103
+ /**
104
+ * Build the F6 editsSeen dedup map for a single poll cycle.
105
+ *
106
+ * @param {Array<{id: any, createdRaw: any, updatedRaw: any}>} comments
107
+ * Current poll's comment list (callers normalize per-host shape into this
108
+ * tuple — GH: c.id/c.created_at/c.updated_at; ADO: c.id/c.publishedDate/
109
+ * c.lastUpdatedDate).
110
+ * @param {Object} prev - prior pr.humanFeedback.editsSeen map (or empty).
111
+ * @param {number} [cap=200] - hard cap; entries beyond cap are evicted
112
+ * FIFO over insertion order (the comments array's iteration order, which
113
+ * callers should pass chronologically ascending).
114
+ * @returns {{ editsSeen: Object, newlyEditedIds: Set<string>, changed: boolean }}
115
+ *
116
+ * Semantics:
117
+ * - For each comment with `updatedRaw > createdRaw` (an actual edit, not
118
+ * a first-seen comment) record `editsSeen[id] = normalizedUpdatedAt`.
119
+ * - Comments absent from `comments` are dropped from the map (pruning rule).
120
+ * - `newlyEditedIds` contains comment ids whose normalized updatedAt differs
121
+ * from `prev[id]` (or had no prior entry). Callers route these into the
122
+ * re-dispatch path.
123
+ * - `changed` indicates whether the resulting map differs from `prev` so
124
+ * callers can skip a needless persistence write.
125
+ */
126
+ function buildEditsSeen(comments, prev, cap) {
127
+ const safePrev = (prev && typeof prev === 'object') ? prev : {};
128
+ const safeCap = Number.isInteger(cap) && cap > 0 ? cap : EDITS_SEEN_CAP;
129
+ const editsSeen = {};
130
+ const newlyEditedIds = new Set();
131
+
132
+ for (const c of comments || []) {
133
+ if (c == null || c.id == null) continue;
134
+ const key = String(c.id);
135
+ const createdNorm = normalizeIsoTimestamp(c.createdRaw);
136
+ const updatedNorm = normalizeIsoTimestamp(c.updatedRaw);
137
+ if (!updatedNorm || !createdNorm) continue;
138
+ // Only "edited" — first-seen comments (updated === created) must NOT
139
+ // route through this re-dispatch path.
140
+ if (updatedNorm <= createdNorm) continue;
141
+ editsSeen[key] = updatedNorm;
142
+ if (safePrev[key] !== updatedNorm) newlyEditedIds.add(key);
143
+ }
144
+
145
+ // FIFO eviction at cap — drop earliest insertion-order entries.
146
+ const keys = Object.keys(editsSeen);
147
+ let trimmed = editsSeen;
148
+ if (keys.length > safeCap) {
149
+ trimmed = {};
150
+ for (const k of keys.slice(keys.length - safeCap)) trimmed[k] = editsSeen[k];
151
+ // Newly-edited ids that were evicted no longer need a re-dispatch trigger
152
+ // (the map can't remember them anyway — they would re-trigger every poll).
153
+ for (const id of [...newlyEditedIds]) {
154
+ if (!Object.prototype.hasOwnProperty.call(trimmed, id)) newlyEditedIds.delete(id);
155
+ }
156
+ }
157
+
158
+ // Cheap shape diff — avoids JSON.stringify and stable across key ordering.
159
+ let changed = false;
160
+ const prevKeys = Object.keys(safePrev);
161
+ const newKeys = Object.keys(trimmed);
162
+ if (prevKeys.length !== newKeys.length) changed = true;
163
+ else {
164
+ for (const k of newKeys) {
165
+ if (safePrev[k] !== trimmed[k]) { changed = true; break; }
166
+ }
167
+ }
168
+
169
+ return { editsSeen: trimmed, newlyEditedIds, changed };
170
+ }
171
+
86
172
  // ── Secret Redaction (SEC-09) ──────────────────────────────────────────────
87
173
  // Pure, side-effect-free redactor applied to every entry on the log write path
88
174
  // so ADO tokens, JWTs, and azureauth stdout dumps never land in engine/log.json
@@ -4749,6 +4835,9 @@ module.exports = {
4749
4835
  CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
4750
4836
  currentLogPath: _currentLogPath,
4751
4837
  ts,
4838
+ normalizeIsoTimestamp, // F6 (P-f6commentedit)
4839
+ buildEditsSeen, // F6 (P-f6commentedit)
4840
+ EDITS_SEEN_CAP, // F6 (P-f6commentedit)
4752
4841
  logTs,
4753
4842
  dateStamp,
4754
4843
  log,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2022",
3
+ "version": "0.1.2024",
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"