@yemi33/minions 0.1.2452 → 0.1.2454

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.
@@ -150,31 +150,50 @@ function prAutomationToggle(idMatch, automation, label, enabled) {
150
150
  // renders "Unknown" rather than borrowing the agent name. Links to the provider
151
151
  // profile only when a validated http(s) URL is present.
152
152
  function _prAuthorCellHtml(pr) {
153
- var a = pr ? pr.author : null;
154
- var login = '';
155
- var displayName = '';
156
- var url = '';
157
- if (a && typeof a === 'object') {
158
- login = a.login || '';
159
- displayName = a.displayName || '';
160
- url = a.url || '';
161
- } else if (typeof a === 'string') {
162
- login = a;
163
- }
164
- var primary = displayName || login;
153
+ var info = (window.MinionsPrAuthor && window.MinionsPrAuthor.display)
154
+ ? window.MinionsPrAuthor.display(pr ? pr.author : null)
155
+ : { name: '', handle: '', url: '' };
156
+ var primary = info.name;
165
157
  if (!primary) {
166
158
  return '<span style="color:var(--muted);font-size:var(--text-base)" title="Author unavailable">Unknown</span>';
167
159
  }
168
- var secondary = (displayName && login && login !== displayName) ? login : '';
160
+ var secondary = info.handle;
169
161
  var inner = escapeHtml(primary)
170
162
  + (secondary ? ' <span style="color:var(--muted);font-size:var(--text-base)">@' + escapeHtml(secondary) + '</span>' : '');
171
163
  var titleText = secondary ? primary + ' (' + secondary + ')' : primary;
172
- if (url && /^https?:\/\//i.test(url)) {
173
- return '<a class="pr-agent" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + inner + '</a>';
164
+ if (info.url) {
165
+ return '<a class="pr-agent" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(info.url)) + '" target="_blank" rel="noopener">' + inner + '</a>';
174
166
  }
175
167
  return '<span class="pr-agent" title="' + escapeHtml(titleText) + '">' + inner + '</span>';
176
168
  }
177
169
 
170
+ // W-msd8pr2300ev95e1 — Foreign-author guardrail for the AutoFix column. When
171
+ // auto-fix is ENABLED and the PR was NOT authored by the current authenticated
172
+ // identity for that provider, render a prominent warning so the operator sees
173
+ // that Minions may push commits to someone else's PR. Ownership tri-state is
174
+ // stamped engine-side (pr._authorOwnership) from STABLE provider identities and
175
+ // fails CLOSED: 'unknown' shows an "author ownership unknown" warning rather
176
+ // than silently treating the PR as self-authored. Never warns when auto-fix is
177
+ // off (no automatic pushes happen) or when ownership is 'self'.
178
+ function _prAutoFixForeignAuthorWarning(pr, autoFixEnabled) {
179
+ if (!autoFixEnabled) return '';
180
+ var ownership = pr && pr._authorOwnership;
181
+ if (ownership === 'self') return '';
182
+ var isForeign = ownership === 'foreign';
183
+ // Author name is presentation-only (never an email) via the shared formatter.
184
+ var authorName = (window.MinionsPrAuthor && window.MinionsPrAuthor.name)
185
+ ? window.MinionsPrAuthor.name(pr ? pr.author : null) : '';
186
+ var label = isForeign
187
+ ? (authorName
188
+ ? 'Auto-fix enabled for another author\'s PR (author: ' + authorName + ')'
189
+ : 'Auto-fix enabled for another author\'s PR')
190
+ : 'Auto-fix enabled but author ownership unknown';
191
+ var cls = 'pr-autofix-warning' + (isForeign ? ' pr-autofix-warning--foreign' : ' pr-autofix-warning--unknown');
192
+ return '<span class="' + cls + '" role="img" '
193
+ + 'title="' + escapeHtml(label) + '" '
194
+ + 'aria-label="' + escapeHtml(label) + '">\u26A0</span>';
195
+ }
196
+
178
197
  function prRow(pr) {
179
198
  // Minions review (agent) state — separate from ADO human review
180
199
  const sq = pr.minionsReview || {};
@@ -325,6 +344,7 @@ function prRow(pr) {
325
344
  : pr.contextOnly !== true;
326
345
  var autoFixBtn = prAutomationToggle(idMatch, 'fix', 'Auto Fix', autoFixEnabled);
327
346
  var autoReviewBtn = prAutomationToggle(idMatch, 'review', 'Auto Review', autoReviewEnabled);
347
+ var autoFixWarning = _prAutoFixForeignAuthorWarning(pr, autoFixEnabled);
328
348
  // Title attrs live on the inner element (link/span/badge) so hovering the
329
349
  // ellipsis-truncated content reveals the full text. Cell tags stay bare so
330
350
  // the header-to-cell count assertion in test/unit.test.js continues to
@@ -348,7 +368,7 @@ function prRow(pr) {
348
368
  '<td><span class="' + branchClass + '" title="' + escapeHtml(branchError || branchLabel) + '">' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
349
369
  '<td>' + authorCellHtml + '</td>' +
350
370
  '<td>' + reviewerCell + '</td>' +
351
- '<td>' + (autoFixBtn || '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
371
+ '<td>' + (autoFixBtn ? autoFixBtn + autoFixWarning : '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
352
372
  '<td>' + (autoReviewBtn || '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
353
373
  '<td><span class="pr-date" title="' + escapeHtml(createdLabel) + '">' + escapeHtml(createdLabel) + '</span></td>' +
354
374
  '<td><button class="btn-destructive" style="padding:1px 5px" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
@@ -622,15 +642,14 @@ function _renderPrDetail(pr) {
622
642
  // so operators can answer both "who is this for?" and "which agent handled it?".
623
643
  let author = '';
624
644
  {
625
- const a = pr.author;
626
- let login = '', displayName = '', url = '';
627
- if (a && typeof a === 'object') { login = a.login || ''; displayName = a.displayName || ''; url = a.url || ''; }
628
- else if (typeof a === 'string') { login = a; }
629
- const primary = displayName || login;
645
+ const info = (window.MinionsPrAuthor && window.MinionsPrAuthor.display)
646
+ ? window.MinionsPrAuthor.display(pr.author)
647
+ : { name: '', handle: '', url: '' };
648
+ const primary = info.name;
630
649
  if (primary) {
631
- const secondary = (displayName && login && login !== displayName) ? ' <span style="color:var(--muted)">@' + escapeHtml(login) + '</span>' : '';
632
- const valHtml = (url && /^https?:\/\//i.test(url))
633
- ? '<a href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(primary) + '</a>' + secondary
650
+ const secondary = info.handle ? ' <span style="color:var(--muted)">@' + escapeHtml(info.handle) + '</span>' : '';
651
+ const valHtml = info.url
652
+ ? '<a href="' + escapeHtml(safeUrl(info.url)) + '" target="_blank" rel="noopener">' + escapeHtml(primary) + '</a>' + secondary
634
653
  : escapeHtml(primary) + secondary;
635
654
  author = '<div><strong style="color:var(--muted);font-size:var(--text-base)">Author:</strong> ' + valHtml + '</div>';
636
655
  } else {
@@ -210,7 +210,12 @@ function wiRow(item) {
210
210
  var prRef = prFollowup.parent_pr_id || prFollowup.parent_pr_url;
211
211
  var prNumMatch = String(prRef).match(/(\d+)(?!.*\d)/);
212
212
  var prLabel = prNumMatch ? ('PR #' + prNumMatch[1]) : 'parent PR';
213
- followupChip = ' <a class="pr-badge draft" style="font-size:var(--text-sm);text-decoration:none" target="_blank" rel="noopener" href="' + escapeHtml(prFollowup.parent_pr_url) + '" title="Follow-up dispatched from ' + escapeHtml(prRef) + (prFollowup.parent_comment_author ? ' by ' + escapeHtml(prFollowup.parent_comment_author) : '') + '" onclick="event.stopPropagation()">&#8617; from ' + escapeHtml(prLabel) + '</a>';
213
+ var followupBy = prFollowup.parent_comment_author
214
+ ? ((window.MinionsPrAuthor && window.MinionsPrAuthor.name)
215
+ ? window.MinionsPrAuthor.name(prFollowup.parent_comment_author)
216
+ : prFollowup.parent_comment_author)
217
+ : '';
218
+ followupChip = ' <a class="pr-badge draft" style="font-size:var(--text-sm);text-decoration:none" target="_blank" rel="noopener" href="' + escapeHtml(prFollowup.parent_pr_url) + '" title="Follow-up dispatched from ' + escapeHtml(prRef) + (followupBy ? ' by ' + escapeHtml(followupBy) : '') + '" onclick="event.stopPropagation()">&#8617; from ' + escapeHtml(prLabel) + '</a>';
214
219
  }
215
220
  return '<tr data-wi-id="' + escapeHtml(item.id) + '" style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWorkItemDetail(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">' +
216
221
  '<td style="min-width:280px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + followupChip + '</td>' +
@@ -780,6 +780,10 @@ async function openSettings() {
780
780
  settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
781
781
  settingsField('Ignored Comment Authors', 'set-ignoredCommentAuthors', (e.ignoredCommentAuthors || []).join(', '), '', 'Comma-separated usernames — comments auto-closed, never trigger fixes') +
782
782
  '</div>' +
783
+ '<div class="settings-grid-2">' +
784
+ settingsField('ADO identity id', 'set-operatorAdoIdentityId', (e.operatorAdoIdentity && e.operatorAdoIdentity.id) || '', '', 'Your Azure DevOps identity GUID. Used ONLY to determine whether an auto-fix-enabled ADO PR is your own vs another author\'s (foreign-author warning). Empty = ADO ownership shows as "unknown". Never an email.') +
785
+ settingsField('ADO identity descriptor', 'set-operatorAdoIdentityDescriptor', (e.operatorAdoIdentity && e.operatorAdoIdentity.descriptor) || '', '', 'Your Azure DevOps identity descriptor (e.g. aad.<base64>). Alternative stable identity for the foreign-author ownership check. Never an email.') +
786
+ '</div>' +
783
787
  '<h4>Experimental Behavior</h4>' +
784
788
  '<div class="settings-stack">' +
785
789
  settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
@@ -1724,6 +1728,10 @@ async function saveSettings() {
1724
1728
  spawnPhaseGraceMs: secToMs('set-spawnPhaseGraceMs'),
1725
1729
  spawnPhaseMaxCpuSeconds: document.getElementById('set-spawnPhaseMaxCpuSeconds').value,
1726
1730
  operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
1731
+ operatorAdoIdentity: {
1732
+ id: (document.getElementById('set-operatorAdoIdentityId')?.value ?? '').trim(),
1733
+ descriptor: (document.getElementById('set-operatorAdoIdentityDescriptor')?.value ?? '').trim(),
1734
+ },
1727
1735
  autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
1728
1736
  evalLoop: document.getElementById('set-evalLoop').checked,
1729
1737
  autoDecompose: document.getElementById('set-autoDecompose').checked,
@@ -0,0 +1,75 @@
1
+ // Shared PR author display formatter (browser). Single source of truth for
2
+ // turning a structured PR author identity into a human-readable NAME that never
3
+ // exposes an email address, reused by every dashboard surface that renders a PR
4
+ // author (the classic Pull Requests table cell + detail panel, and the shared
5
+ // author filter — which slim embeds too). Mirrors engine/core/shared.js
6
+ // prAuthorDisplayName so the label the operator sees matches the projected SQL
7
+ // label; keep the two in sync (pinned by test/unit/pr-author.test.js).
8
+ //
9
+ // The structured identity fields (login/id/descriptor/url) are left untouched:
10
+ // this is a presentation-only transform, not destructive normalization of the
11
+ // machine identity used for filtering, matching, and reconciliation.
12
+
13
+ (function () {
14
+ function _text(value) {
15
+ if (value == null) return '';
16
+ return typeof value === 'string' ? value.trim() : String(value).trim();
17
+ }
18
+
19
+ // A human-readable name from ONE identity fragment that never contains an
20
+ // email address. Any '@' forces local-part extraction ("Name <addr>" keeps the
21
+ // display part; a bare/embedded address drops the domain). '' when unusable.
22
+ function _fragmentName(raw) {
23
+ var s = _text(raw);
24
+ if (!s) return '';
25
+ var m = s.match(/^\s*"?([^"<]*?)"?\s*<[^>]*>\s*$/);
26
+ if (m && m[1].trim() && m[1].indexOf('@') === -1) return m[1].trim();
27
+ if (s.indexOf('@') !== -1) {
28
+ s = s.replace(/<([^>]*)>/, '$1');
29
+ return s.split('@')[0].replace(/^["'<\s]+|["'>\s]+$/g, '').trim();
30
+ }
31
+ return s;
32
+ }
33
+
34
+ // Accept the canonical author shape, a raw provider createdBy object, or a
35
+ // bare string. `login` also reads ADO's `uniqueName`; `displayName` its `name`.
36
+ function _fields(author) {
37
+ if (author && typeof author === 'object') {
38
+ return {
39
+ displayName: _text(author.displayName) || _text(author.name),
40
+ login: _text(author.login) || _text(author.uniqueName),
41
+ id: _text(author.id),
42
+ url: _text(author.url),
43
+ };
44
+ }
45
+ if (typeof author === 'string') return { displayName: '', login: _text(author), id: '', url: '' };
46
+ return { displayName: '', login: '', id: '', url: '' };
47
+ }
48
+
49
+ // Human-readable name that NEVER exposes an email address. '' → render Unknown.
50
+ function name(author) {
51
+ var f = _fields(author);
52
+ return _fragmentName(f.displayName) || _fragmentName(f.login) || f.id || '';
53
+ }
54
+
55
+ // Optional secondary handle (rendered as "@handle"). Only a real login with no
56
+ // '@' that adds information beyond the name — never an email address.
57
+ function handle(author) {
58
+ var f = _fields(author);
59
+ if (f.login && f.login.indexOf('@') === -1 && f.login !== name(author)) return f.login;
60
+ return '';
61
+ }
62
+
63
+ // A validated http(s) profile URL, or '' — so a caller never links an
64
+ // unvalidated href.
65
+ function url(author) {
66
+ var f = _fields(author);
67
+ return /^https?:\/\//i.test(f.url) ? f.url : '';
68
+ }
69
+
70
+ function display(author) {
71
+ return { name: name(author), handle: handle(author), url: url(author) };
72
+ }
73
+
74
+ window.MinionsPrAuthor = { name: name, handle: handle, url: url, display: display };
75
+ })();
@@ -35,14 +35,37 @@ function _prFilterKey(value) {
35
35
  return window.MinionsRecordFilters.key(value);
36
36
  }
37
37
 
38
+ function _prAuthorSource(pr) {
39
+ if (!pr || typeof pr !== 'object') return null;
40
+ return pr.author || pr.createdBy || pr.created_by || null;
41
+ }
42
+
43
+ // Stable machine identity for the author filter VALUE (grouping key). Prefers a
44
+ // non-email provider id/descriptor/login so two people who happen to share a
45
+ // display name do not collapse, and an email is never used as a visible label.
46
+ // The displayed option text stays the email-free name from normalizePrAuthor.
47
+ function _prAuthorMachineKey(source) {
48
+ if (!source) return '';
49
+ if (typeof source === 'string') return source;
50
+ if (typeof source !== 'object') return '';
51
+ return _prFilterText(source.id)
52
+ || _prFilterText(source.descriptor)
53
+ || _prFilterText(source.login)
54
+ || _prFilterText(source.uniqueName)
55
+ || _prFilterText(source.displayName)
56
+ || _prFilterText(source.name);
57
+ }
58
+
38
59
  function normalizePrAuthor(pr) {
39
- if (!pr || typeof pr !== 'object') return '';
40
60
  // Real author only. Deliberately NOT falling back to pr.agent: the Minions
41
61
  // agent is a separate identity (W-mscibegy005e8b74) and a legacy record with
42
62
  // no author must group/render as Unknown rather than borrow the agent name.
43
- return _prFilterName(pr.author)
44
- || _prFilterName(pr.createdBy)
45
- || _prFilterName(pr.created_by);
63
+ // The email-free display name comes from the shared formatter so the filter
64
+ // dropdown matches the PR table's Author column (W-msd8ls85).
65
+ var src = _prAuthorSource(pr);
66
+ if (!src) return '';
67
+ if (window.MinionsPrAuthor && window.MinionsPrAuthor.name) return window.MinionsPrAuthor.name(src);
68
+ return _prFilterName(src);
46
69
  }
47
70
 
48
71
  function normalizePrLifecycleStatus(pr) {
@@ -96,7 +119,7 @@ function _prFilterEntry(pr, dimension) {
96
119
  var value = '';
97
120
  if (dimension === 'author') {
98
121
  label = normalizePrAuthor(pr);
99
- value = _prFilterKey(label);
122
+ value = _prFilterKey(_prAuthorMachineKey(_prAuthorSource(pr)) || label);
100
123
  } else if (dimension === 'lifecycle') {
101
124
  value = normalizePrLifecycleStatus(pr);
102
125
  label = PR_LIFECYCLE_FILTER_LABELS[value] || _formatPrFilterLabel(value);
@@ -518,6 +518,18 @@
518
518
  outline: 2px solid var(--blue); outline-offset: 2px;
519
519
  }
520
520
 
521
+ /* W-msd8pr2300ev95e1 — AutoFix foreign-author guardrail warning icon. Sits
522
+ beside the Auto Fix toggle when auto-fix is enabled on a PR the current
523
+ identity did not author (foreign), or when ownership can't be established
524
+ (unknown). Cursor:help so the accessible tooltip is discoverable. */
525
+ .pr-autofix-warning {
526
+ display: inline-block; margin-left: var(--space-3);
527
+ vertical-align: middle; cursor: help; font-size: var(--text-base);
528
+ line-height: 1;
529
+ }
530
+ .pr-autofix-warning--foreign { color: var(--red, #f85149); }
531
+ .pr-autofix-warning--unknown { color: var(--yellow, #d29922); }
532
+
521
533
  .archive-btn {
522
534
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
523
535
  font-size: var(--text-base); padding: var(--space-2) var(--space-5); border-radius: var(--radius-sm); cursor: pointer; transition: all var(--transition-base); margin-left: auto;
@@ -13,7 +13,7 @@ const MINIONS_DIR = __dirname;
13
13
  // 'wi-filters' publishes window.MinionsWorkItemFilters (the Work Items table's
14
14
  // filter + sort semantics), consumed by render-work-items.js — hence it precedes
15
15
  // the renderer bundle (DASHBOARD_JS_FILES) too.
16
- const DASHBOARD_SHARED_JS = ['record-filters', 'pr-merge-state', 'pr-filters', 'wi-filters', 'cc-suggestions', 'cc-limits', 'cc-queue-store', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
16
+ const DASHBOARD_SHARED_JS = ['record-filters', 'pr-merge-state', 'pr-author', 'pr-filters', 'wi-filters', 'cc-suggestions', 'cc-limits', 'cc-queue-store', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
17
17
 
18
18
  // ── Canonical classic-dashboard assembly manifest ──────────────────────────
19
19
  // Single source of truth, consumed by BOTH assemblers: buildDashboardHtml()
@@ -62,8 +62,15 @@ agent id. `pr.agent` is now **never** a human login.
62
62
  non-empty incoming value wins, otherwise the existing value is preserved, so a
63
63
  **partial provider refresh never erases a richer author already stored**. A
64
64
  provider change trusts the newer identity wholesale.
65
- - `prAuthorLabel(author)` — human-readable label (displayName → login → id) for
66
- the typed SQL column and filter/search text.
65
+ - `prAuthorDisplayName(author)` — the **email-free** human-readable name every
66
+ surface should show. Prefers a real `displayName` → `login`, and only when the
67
+ sole usable identity is an email does it fall back to the **local-part** (domain
68
+ dropped). Any `@` anywhere forces local-part extraction, so a malformed value
69
+ (`"Name <a@b>"`, `weird@x@y`, a bare address) can never leak an `@`. Returns
70
+ `''` (→ **Unknown**) when nothing usable remains. Presentation only — the
71
+ structured `login`/`id`/`descriptor` fields are left untouched.
72
+ - `prAuthorLabel(author)` — thin delegate to `prAuthorDisplayName` (kept for
73
+ callers wanting a "label"); therefore also email-free.
67
74
 
68
75
  `upsertPullRequestRecord` normalizes `author` on the create path and merges it
69
76
  via `mergePrAuthorIdentity` on the update path (and in `_mergeDuplicatePrInto`),
@@ -76,7 +83,11 @@ manual link, dashboard enrich, dedup — converge on one merge behavior.
76
83
  projection column (plus `idx_pr_author`). The canonical structured identity
77
84
  always lives in the row's `data` JSON; the column is a label projection for
78
85
  filters/sort. `engine/persistence/pull-requests-store.js` writes it via a local
79
- `_authorLabel(pr.author)` (displayName → login → id).
86
+ `_authorLabel(pr.author)` (displayName → login → id). This column feeds only the
87
+ `idx_pr_author` index for server-side filter/sort — it is **never selected back
88
+ or surfaced to any client** (reads return the `data` JSON), so it may still
89
+ contain an email and is deliberately left as machine-facing projection, not a
90
+ display label.
80
91
 
81
92
  Backfill is **fail-closed**. A legacy row whose `agent` is a human login (i.e.
82
93
  not `'human'`, not a configured Minions agent id from `config.json`, not
@@ -96,14 +107,26 @@ alongside the existing `agent` field.
96
107
 
97
108
  ## Dashboard
98
109
 
110
+ Every dashboard surface that shows a PR author routes through **one** shared
111
+ browser formatter, `window.MinionsPrAuthor` (`dashboard/shared/pr-author.js`),
112
+ which mirrors the engine `prAuthorDisplayName` so the operator-visible name is
113
+ always **email-free** — an ADO author stored as `Name <email>` or a bare email
114
+ `uniqueName` renders as the name / email local-part, never the address. It is
115
+ listed in `DASHBOARD_SHARED_JS` before `pr-filters`, so classic and slim share it.
116
+
99
117
  `dashboard/js/render-prs.js` replaces the Pull Requests table's **Agent** column
100
- with **Author** (display name + `@login`, safely escaped, linked to the provider
101
- profile only when a validated `http(s)` URL is present). The Minions agent stays
102
- visible in the PR detail panel under **Minions agent**, next to a new **Author**
103
- line, so operators can still answer both "who is this for?" and "which agent
104
- handled it?". The shared author filter (`dashboard/shared/pr-filters.js`) now
105
- reads structured `pr.author` only and no longer falls back to `pr.agent`, so the
106
- filter and the displayed column stay aligned (legacy rows group as Unknown).
118
+ with **Author** (`MinionsPrAuthor.display` name + optional `@handle` a handle
119
+ is offered only for a real non-email login, safely escaped, linked to the
120
+ provider profile only when a validated `http(s)` URL is present). The Minions
121
+ agent stays visible in the PR detail panel under **Minions agent**, next to the
122
+ **Author** line, so operators can still answer both "who is this for?" and "which
123
+ agent handled it?". The shared author filter (`dashboard/shared/pr-filters.js`)
124
+ shows the same email-free **label** while grouping on a stable machine **key**
125
+ (`id` → `descriptor` → `login` → … via `_prAuthorMachineKey`), so two people who
126
+ share a display name never collapse and the filter stays aligned with the column
127
+ (legacy rows group as Unknown). The work-item follow-up chip
128
+ (`dashboard/js/render-work-items.js`) strips the email from a
129
+ `pr_followup.parent_comment_author` the same way.
107
130
 
108
131
  ## Backward-compatible reads
109
132
 
@@ -112,3 +135,33 @@ filter and the displayed column stay aligned (legacy rows group as Unknown).
112
135
  author string keeps rendering. This is a tolerant read, not a retained
113
136
  compatibility shim — the overloaded `agent = login` write behavior was removed
114
137
  outright (no `docs/deprecated.json` entry required).
138
+
139
+ ## AutoFix foreign-author guardrail (W-msd8pr2300ev95e1)
140
+
141
+ When AutoFix is enabled on a PR whose author is **not** the current authenticated
142
+ identity for that provider/repo scope, the Pull Requests table's **AutoFix** cell
143
+ renders a prominent warning icon beside the toggle: a red `⚠`
144
+ ("Auto-fix enabled for another author's PR", naming the author when known) or, if
145
+ ownership cannot be established, a yellow `⚠` ("author ownership unknown"). This
146
+ never changes behavior — enabling auto-fix on another author's PR stays an
147
+ explicit operator action — it only surfaces the risk.
148
+
149
+ Ownership is resolved by `shared.resolvePrAuthorOwnership(pr, selfByProvider)`
150
+ (`self` | `foreign` | `unknown`) using **stable provider identities only** —
151
+ GitHub canonical `login` / numeric `id`; ADO identity `id` / `descriptor`. Display
152
+ names and emails are never compared, and the determination **fails closed** to
153
+ `unknown` (shown as the neutral warning, never silently treated as self) whenever
154
+ either side's stable identity is missing.
155
+
156
+ `engine/core/queries.js#getPullRequests` stamps `pr._authorOwnership` once per
157
+ build, resolving the current identity per provider:
158
+
159
+ - **GitHub** — `operator-identity.resolveGithubViewerLoginStrict(config)`, which
160
+ accepts only `engine.operatorLogin` or `gh api user` (never a git-email / OS
161
+ fallback).
162
+ - **ADO** — the optional operator-configured `engine.operatorAdoIdentity`
163
+ (`{ id?, descriptor? }`). There is no automatic ADO current-identity seam, so
164
+ when it is unset every ADO PR reports `unknown` (fail-closed). Set it under
165
+ **Settings → Operator & Comments → ADO identity id / descriptor**. It is a
166
+ read-only identity value (like `operatorLogin`), never an email — the ADO
167
+ `uniqueName`/login is deliberately excluded from both storage and comparison.
@@ -108,6 +108,7 @@ const ENGINE_SPECIAL_FIELDS = new Set([
108
108
  'ccWorkerIdleTimeoutMs',
109
109
  'worktreeRoot',
110
110
  'operatorLogin',
111
+ 'operatorAdoIdentity',
111
112
  'defaultCli',
112
113
  'defaultModel',
113
114
  'ccCli',
@@ -338,6 +339,30 @@ function applyEngineSettings(candidate, bodyEngine, errors) {
338
339
  const value = parseClearableString(bodyEngine.operatorLogin, 'engine.operatorLogin', errors);
339
340
  setOrDelete(engine, 'operatorLogin', value);
340
341
  }
342
+ if (hasOwn(bodyEngine, 'operatorAdoIdentity')) {
343
+ // Structured ADO self-identity used ONLY by the AutoFix foreign-author
344
+ // guardrail (id/descriptor — never email). An empty object clears it.
345
+ const raw = bodyEngine.operatorAdoIdentity;
346
+ if (raw === null || raw === undefined || CLEAR_SENTINELS.has(raw)) {
347
+ delete engine.operatorAdoIdentity;
348
+ } else if (typeof raw === 'object') {
349
+ const next = {};
350
+ if (raw.id !== undefined && raw.id !== null && raw.id !== '') {
351
+ const id = parseString(raw.id, 'engine.operatorAdoIdentity.id', errors);
352
+ if (id) next.id = id;
353
+ }
354
+ if (raw.descriptor !== undefined && raw.descriptor !== null && raw.descriptor !== '') {
355
+ const descriptor = parseString(raw.descriptor, 'engine.operatorAdoIdentity.descriptor', errors);
356
+ if (descriptor) next.descriptor = descriptor;
357
+ }
358
+ if (Object.keys(next).length) engine.operatorAdoIdentity = next;
359
+ else delete engine.operatorAdoIdentity;
360
+ } else {
361
+ errors.add('engine.operatorAdoIdentity', 'invalid-object',
362
+ 'engine.operatorAdoIdentity must be an object with id and/or descriptor', raw,
363
+ { expected: 'object' });
364
+ }
365
+ }
341
366
  for (const key of ['defaultCli', 'ccCli']) {
342
367
  if (!hasOwn(bodyEngine, key)) continue;
343
368
  const value = parseRuntimeName(bodyEngine[key], `engine.${key}`, errors);
@@ -75,9 +75,31 @@ function resolveOperatorLogin(config, { force = false } = {}) {
75
75
 
76
76
  // Clear the process-lifetime cache. Called by the dashboard when the operator
77
77
  // login override changes in Settings so the next resolve picks up the new value.
78
- function _resetOperatorLoginCacheForTest() { _cached = null; }
78
+ function _resetOperatorLoginCacheForTest() { _cached = null; _cachedGithubViewer = null; }
79
+
80
+ // ── Strict GitHub viewer login (W-msd8pr2300ev95e1) ─────────────────────────
81
+ // Unlike resolveOperatorLogin, this NEVER falls back to the git-email localpart
82
+ // or the OS username — those are not GitHub logins and must never be used to
83
+ // decide "did the current GitHub token author this PR?". It accepts ONLY:
84
+ // 1. `config.engine.operatorLogin` — explicit Settings override
85
+ // 2. `gh api user --jq .login` — the token's own canonical login
86
+ // and returns '' otherwise, so the caller fails CLOSED (author ownership
87
+ // "unknown") rather than guessing self-authorship from a weak fallback.
88
+ let _cachedGithubViewer = null;
89
+ function resolveGithubViewerLoginStrict(config, { force = false } = {}) {
90
+ if (!force && _cachedGithubViewer !== null) return _cachedGithubViewer;
91
+ const override = config?.engine?.operatorLogin;
92
+ if (override && typeof override === 'string' && override.trim()) {
93
+ _cachedGithubViewer = override.trim();
94
+ return _cachedGithubViewer;
95
+ }
96
+ const ghLogin = _execImpl('gh api user --jq .login');
97
+ _cachedGithubViewer = ghLogin ? ghLogin : '';
98
+ return _cachedGithubViewer;
99
+ }
79
100
 
80
101
  module.exports = {
81
102
  resolveOperatorLogin,
103
+ resolveGithubViewerLoginStrict,
82
104
  _resetOperatorLoginCacheForTest,
83
105
  };
@@ -1151,6 +1151,28 @@ function getPullRequests(config) {
1151
1151
  const projectByName = new Map(projects.map(p => [p.name, p]));
1152
1152
  const allPrs = [];
1153
1153
 
1154
+ // W-msd8pr2300ev95e1 — resolve the CURRENT authenticated identity per provider
1155
+ // ONCE (cached) so we can stamp each PR's author-ownership tri-state for the
1156
+ // AutoFix foreign-author guardrail. STABLE identities only (GitHub canonical
1157
+ // login / numeric id; ADO id / descriptor) — never display names or emails.
1158
+ // Fails CLOSED: a missing/blank provider entry yields 'unknown'.
1159
+ const selfByProvider = {};
1160
+ try {
1161
+ const ghLogin = require('./operator-identity').resolveGithubViewerLoginStrict(config);
1162
+ if (ghLogin) selfByProvider.github = { login: ghLogin };
1163
+ } catch { /* identity resolution is best-effort; absence → unknown */ }
1164
+ try {
1165
+ const adoSelf = config?.engine?.operatorAdoIdentity;
1166
+ if (adoSelf && typeof adoSelf === 'object') {
1167
+ const entry = {};
1168
+ if (adoSelf.id) entry.id = String(adoSelf.id);
1169
+ if (adoSelf.descriptor) entry.descriptor = String(adoSelf.descriptor);
1170
+ // Deliberately NOT copying uniqueName/login: ADO login is an email, and
1171
+ // the guardrail contract forbids comparing emails.
1172
+ if (Object.keys(entry).length) selfByProvider.ado = entry;
1173
+ }
1174
+ } catch { /* best-effort */ }
1175
+
1154
1176
  // SQL is the canonical (and only) PR store after Phase 9.
1155
1177
  const store = require('../persistence/pull-requests-store');
1156
1178
  const sqlPrs = store.readAllPullRequests() || [];
@@ -1231,6 +1253,10 @@ function getPullRequests(config) {
1231
1253
  // dashboards / API consumers can render a chip without re-parsing
1232
1254
  // _noOpFixes themselves.
1233
1255
  pr._pausedCauses = shared.getPrPausedCauses(pr);
1256
+ // W-msd8pr2300ev95e1 — author-ownership tri-state for the AutoFix
1257
+ // foreign-author warning. 'self' | 'foreign' | 'unknown' (fail closed).
1258
+ try { pr._authorOwnership = shared.resolvePrAuthorOwnership(pr, selfByProvider); }
1259
+ catch { pr._authorOwnership = shared.PR_AUTHOR_OWNERSHIP.UNKNOWN; }
1234
1260
  allPrs.push(pr);
1235
1261
  }
1236
1262
  allPrs.sort((a, b) => {
@@ -8702,11 +8702,113 @@ function mergePrAuthorIdentity(existing, incoming) {
8702
8702
  return out;
8703
8703
  }
8704
8704
 
8705
- // Human-readable label for a PR author (typed SQL column + filter/search text).
8706
- function prAuthorLabel(author) {
8705
+ // A human-readable name from ONE identity fragment that never contains an email
8706
+ // address:
8707
+ // - "Display Name <user@example.com>" → "Display Name"
8708
+ // - "user@example.com" → "user" (local-part, domain dropped)
8709
+ // - "Display Name" → "Display Name"
8710
+ // Any '@' anywhere forces local-part extraction, so a malformed address can
8711
+ // never leak through. Returns '' when nothing usable remains.
8712
+ function _authorFragmentName(raw) {
8713
+ let s = _authorField(raw, 256);
8714
+ if (!s) return '';
8715
+ // "Name <addr>" — keep the display part when it is a real (non-email) name.
8716
+ const m = s.match(/^\s*"?([^"<]*?)"?\s*<[^>]*>\s*$/);
8717
+ if (m && m[1].trim() && m[1].indexOf('@') === -1) return m[1].trim();
8718
+ if (s.indexOf('@') !== -1) {
8719
+ s = s.replace(/<([^>]*)>/, '$1'); // unwrap a "<addr>"
8720
+ return s.split('@')[0].replace(/^["'<\s]+|["'>\s]+$/g, '').trim();
8721
+ }
8722
+ return s;
8723
+ }
8724
+
8725
+ // Human-readable author name that NEVER exposes an email address. Prefers a real
8726
+ // provider display name / login, and only when the sole usable identity is an
8727
+ // email does it fall back to the local-part (no domain). Returns '' (→ renders
8728
+ // "Unknown") when nothing usable is present. The structured identity fields
8729
+ // (login/id/descriptor) are untouched — this is a presentation label only.
8730
+ function prAuthorDisplayName(author) {
8707
8731
  const a = normalizePrAuthorIdentity(author);
8708
8732
  if (!a) return '';
8709
- return a.displayName || a.login || a.id || '';
8733
+ return _authorFragmentName(a.displayName)
8734
+ || _authorFragmentName(a.login)
8735
+ || _authorField(a.id, 64)
8736
+ || '';
8737
+ }
8738
+
8739
+ // Human-readable label for a PR author (typed SQL column + filter/search text).
8740
+ // Routed through prAuthorDisplayName so the projected label is always email-free.
8741
+ function prAuthorLabel(author) {
8742
+ return prAuthorDisplayName(author);
8743
+ }
8744
+
8745
+ // ── Foreign-author ownership for the AutoFix guardrail (W-msd8pr2300ev95e1) ──
8746
+ //
8747
+ // Decide whether the CURRENT authenticated identity for a PR's provider authored
8748
+ // that PR, so the dashboard can warn when auto-fix is enabled on someone else's
8749
+ // PR (Minions would push commits to a PR the operator only asked to read/review).
8750
+ //
8751
+ // STABLE identities only — never display names or emails:
8752
+ // GitHub: canonical login (case-insensitive) or numeric id.
8753
+ // ADO: identity id or descriptor (exact), or uniqueName login (ci).
8754
+ //
8755
+ // Fail CLOSED: returns 'unknown' whenever EITHER the PR author identity or the
8756
+ // current provider identity cannot be established. The caller must render an
8757
+ // "author ownership unknown" warning rather than treating unknown as self.
8758
+ //
8759
+ // `selfByProvider` is `{ github: { login, id }, ado: { id, descriptor, login } }`
8760
+ // resolved by the caller (queries.js) from the per-provider token identity; a
8761
+ // missing provider entry (or empty fields) yields 'unknown' for that provider.
8762
+ const PR_AUTHOR_OWNERSHIP = { SELF: 'self', FOREIGN: 'foreign', UNKNOWN: 'unknown' };
8763
+
8764
+ function _ciEq(a, b) {
8765
+ const x = _authorField(a, 256).toLowerCase();
8766
+ const y = _authorField(b, 256).toLowerCase();
8767
+ return !!x && x === y;
8768
+ }
8769
+ function _exactEq(a, b) {
8770
+ const x = _authorField(a, 256);
8771
+ const y = _authorField(b, 256);
8772
+ return !!x && x === y;
8773
+ }
8774
+
8775
+ function _prProviderFromRecord(pr, author) {
8776
+ const p = _authorField(author && author.provider, 32).toLowerCase();
8777
+ if (p === 'github' || p === 'ado') return p;
8778
+ const id = _authorField(pr && pr.id, 128).toLowerCase();
8779
+ if (id.startsWith('github:') || id.startsWith('gh:')) return 'github';
8780
+ if (id.startsWith('ado:')) return 'ado';
8781
+ const url = _authorField(pr && pr.url, 400).toLowerCase();
8782
+ if (url.includes('github.com')) return 'github';
8783
+ if (url.includes('dev.azure.com') || url.includes('visualstudio.com') || url.includes('/_git/')) return 'ado';
8784
+ return '';
8785
+ }
8786
+
8787
+ function resolvePrAuthorOwnership(pr, selfByProvider) {
8788
+ const author = normalizePrAuthorIdentity(pr && pr.author);
8789
+ if (!author) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8790
+ const provider = _prProviderFromRecord(pr, author);
8791
+ if (!provider) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8792
+ const self = selfByProvider && selfByProvider[provider];
8793
+ if (!self) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8794
+
8795
+ if (provider === 'github') {
8796
+ const haveSelf = _authorField(self.login, 256) || _authorField(self.id, 64);
8797
+ const haveAuthor = _authorField(author.login, 256) || _authorField(author.id, 64);
8798
+ if (!haveSelf || !haveAuthor) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8799
+ if (_exactEq(self.id, author.id) || _ciEq(self.login, author.login)) return PR_AUTHOR_OWNERSHIP.SELF;
8800
+ return PR_AUTHOR_OWNERSHIP.FOREIGN;
8801
+ }
8802
+ // ADO: compare stable identity id/descriptor ONLY. ADO `login` is the
8803
+ // uniqueName (a sign-in email), and the guardrail contract forbids comparing
8804
+ // emails, so it is deliberately excluded here.
8805
+ const haveSelf = _authorField(self.id, 64) || _authorField(self.descriptor, 256);
8806
+ const haveAuthor = _authorField(author.id, 64) || _authorField(author.descriptor, 256);
8807
+ if (!haveSelf || !haveAuthor) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8808
+ if (_exactEq(self.id, author.id) || _exactEq(self.descriptor, author.descriptor)) {
8809
+ return PR_AUTHOR_OWNERSHIP.SELF;
8810
+ }
8811
+ return PR_AUTHOR_OWNERSHIP.FOREIGN;
8710
8812
  }
8711
8813
 
8712
8814
  function _jsonEqual(a, b) {
@@ -9396,16 +9498,35 @@ function classifyPrRefForVerification(prRef, project = null) {
9396
9498
  // .parent_pr_url). Description-only PR mentions are INTENTIONALLY skipped
9397
9499
  // per the "structured-vs-loose split" — enrollment must be intentional.
9398
9500
  //
9501
+ // Enrollment mode is type-dependent (W-msd8pr2300ev95e1): mutating PR-requiring
9502
+ // types (fix / test / build-fix-complex) enroll AUTO-MANAGED (contextOnly:false)
9503
+ // — the normal coding workflow; a read-only `review` WI enrolls CONTEXT-ONLY so
9504
+ // a "review this PR" request never silently enables auto-fix on the PR.
9505
+ //
9399
9506
  // Returns:
9400
- // { skipped: true, reason } — not a pr-requiring type / no ref / no URL / upsert error
9401
- // { alreadyEnrolled: true, id } — PR already tracked
9402
- // { enrolled: true, id, scope } — newly enrolled
9507
+ // { skipped: true, reason } — not a pr-requiring type / no ref / no URL / upsert error
9508
+ // { alreadyEnrolled: true, id } — PR already tracked
9509
+ // { enrolled: true, id, scope, contextOnly } — newly enrolled (contextOnly true for review)
9403
9510
  const _PR_REQUIRING_TYPES = new Set([
9404
9511
  WORK_TYPE.FIX,
9405
9512
  WORK_TYPE.BUILD_FIX_COMPLEX,
9406
9513
  WORK_TYPE.REVIEW,
9407
9514
  WORK_TYPE.TEST,
9408
9515
  ]);
9516
+ // W-msd8pr2300ev95e1 — READ-ONLY PR-requiring types. A `review` WI enrolls the
9517
+ // PR record so the review dispatch clears the `pr_not_found` gate (the #536
9518
+ // rationale), but a human "review this PR" request is one-shot / read-review
9519
+ // intent by default: it must NOT enable auto-fix, auto-review, auto-merge, or
9520
+ // any future automatic fix / re-review dispatch on someone else's PR. So a
9521
+ // review enrollment is context-only. The explicit review WI still dispatches
9522
+ // (isExplicitlyRequestedPrDispatch keeps work-item-sourced review dispatches
9523
+ // even on a contextOnly PR), and the upsert's non-demote guard means a review
9524
+ // WI that happens to target an ALREADY auto-managed PR (a normal coding
9525
+ // workflow) never demotes it. Mutating PR-requiring types (fix / test /
9526
+ // build-fix-complex) stay auto-managed — that is the normal coding workflow.
9527
+ const _READ_ONLY_PR_ENROLL_TYPES = new Set([
9528
+ WORK_TYPE.REVIEW,
9529
+ ]);
9409
9530
  function autoEnrollPrFromWorkItem(item, project, minionsDir) {
9410
9531
  if (!item || !_PR_REQUIRING_TYPES.has(item.type)) return { skipped: true, reason: 'not-pr-requiring-type' };
9411
9532
  const prRef = extractStructuredWorkItemPrRef(item);
@@ -9420,6 +9541,7 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
9420
9541
  const parsedUrl = parsePrUrl(url);
9421
9542
  const prNum = parsedUrl ? parsedUrl.prNumber : null;
9422
9543
  const prId = getCanonicalPrId(project, prRef, url);
9544
+ const readOnlyEnroll = _READ_ONLY_PR_ENROLL_TYPES.has(item.type);
9423
9545
  try {
9424
9546
  const result = upsertPullRequestRecord(source, {
9425
9547
  id: prId,
@@ -9432,10 +9554,15 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
9432
9554
  status: 'active',
9433
9555
  created: new Date().toISOString(),
9434
9556
  url,
9435
- contextOnly: false,
9557
+ // Read-review intent enrolls context-only with the per-row automation
9558
+ // controls explicitly OFF (belt-and-suspenders over the contextOnly
9559
+ // default derivation), so no later default change can silently re-enable
9560
+ // auto-fix on a review-enrolled PR.
9561
+ contextOnly: readOnlyEnroll,
9562
+ ...(readOnlyEnroll ? { autoFixEnabled: false, autoReviewEnabled: false } : {}),
9436
9563
  }, { project, itemId: item.id });
9437
9564
  return result.created
9438
- ? { enrolled: true, id: result.id, scope: stateScope(source) }
9565
+ ? { enrolled: true, id: result.id, scope: stateScope(source), contextOnly: readOnlyEnroll }
9439
9566
  : { alreadyEnrolled: true, id: result.id };
9440
9567
  } catch (e) {
9441
9568
  log('warn', `autoEnrollPrFromWorkItem ${item.id}: ${e.message}`);
@@ -11087,6 +11214,9 @@ module.exports = {
11087
11214
  normalizePrAuthorIdentity,
11088
11215
  mergePrAuthorIdentity,
11089
11216
  prAuthorLabel,
11217
+ prAuthorDisplayName,
11218
+ resolvePrAuthorOwnership,
11219
+ PR_AUTHOR_OWNERSHIP,
11090
11220
  applyPrFieldDelta,
11091
11221
  normalizePrRecord,
11092
11222
  normalizePrRecords,
@@ -0,0 +1,95 @@
1
+ // engine/db/migrations/032-review-enrolled-pr-context-only.js
2
+ //
3
+ // W-msd8pr2300ev95e1 — repair PR records that were SILENTLY auto-enrolled by a
4
+ // review request.
5
+ //
6
+ // Regression: PR #536 widened `autoEnrollPrFromWorkItem` from FIX-only to
7
+ // {FIX, REVIEW, TEST} and enrolled every one with `contextOnly:false`. Because
8
+ // `isPrAutoFixEnabled` / `isPrAutoReviewEnabled` derive from `!contextOnly`
9
+ // when no explicit boolean is stored, a human "review this PR" request turned
10
+ // another author's PR into an AUTO-MANAGED record — the engine then dispatched
11
+ // fixes/re-reviews and pushed commits to a PR the operator only asked to read.
12
+ // The code fix makes review enrollment context-only going forward; this
13
+ // migration conservatively repairs records already enrolled that way.
14
+ //
15
+ // CONSERVATIVE, STRUCTURED-PROVENANCE ONLY — never title/description text:
16
+ // 1. status === 'active' (don't touch merged/abandoned)
17
+ // 2. contextOnly !== true (currently auto-managed by default)
18
+ // 3. NO explicit autoFixEnabled/autoReviewEnabled boolean stored
19
+ // → an explicitly TRACKED PR (POST /api/pull-requests/link,
20
+ // /api/pr-action/track, or the per-row automation toggle) always
21
+ // persists these booleans, so this gate alone preserves every
22
+ // intentionally-tracked record.
23
+ // 4. agent === 'human' (the sentinel autoEnroll stamps;
24
+ // excludes agent-authored/synced coding PRs, which stay tracked)
25
+ // 5. prdItems links to >= 1 still-existing work item, and EVERY linked,
26
+ // still-existing work item is type `review` (no fix/test/implement/…):
27
+ // → proves the record's only provenance is a read-review request. If a
28
+ // mutating WI is linked, or no linked WI still exists, we cannot prove
29
+ // review-only provenance and DO NOT touch the record.
30
+ //
31
+ // Repair = set contextOnly:true + autoFixEnabled:false + autoReviewEnabled:false
32
+ // (belt-and-suspenders), matching the new read-review enrollment shape.
33
+
34
+ function _hasTable(db, table) {
35
+ return !!db.prepare(`
36
+ SELECT 1 FROM sqlite_master WHERE type='table' AND name=?
37
+ `).get(table);
38
+ }
39
+
40
+ module.exports = {
41
+ version: 32,
42
+ description: 'repair review-enrolled PRs to context-only (W-msd8pr2300ev95e1)',
43
+ up(db) {
44
+ if (!_hasTable(db, 'pull_requests')) return;
45
+
46
+ // Build id -> type map for every work item. Archived items live in the same
47
+ // `work_items` table (migration 021 added an `archived` column, not a
48
+ // separate table), so this single scan covers both live and archived rows.
49
+ const wiTypeById = new Map();
50
+ if (_hasTable(db, 'work_items')) {
51
+ for (const row of db.prepare('SELECT id, type FROM work_items').all()) {
52
+ if (row && row.id) wiTypeById.set(String(row.id), String(row.type || '').trim().toLowerCase());
53
+ }
54
+ }
55
+
56
+ const rows = db.prepare('SELECT scope, id, agent, status, data FROM pull_requests').all();
57
+ const update = db.prepare('UPDATE pull_requests SET data = ? WHERE scope = ? AND id = ?');
58
+
59
+ let repaired = 0;
60
+
61
+ for (const row of rows) {
62
+ let record;
63
+ try { record = JSON.parse(row.data); }
64
+ catch { continue; }
65
+ if (!record || typeof record !== 'object') continue;
66
+
67
+ // (1) active only.
68
+ if (String(record.status || row.status || '').toLowerCase() !== 'active') continue;
69
+ // (2) currently auto-managed by default (not already context-only).
70
+ if (record.contextOnly === true) continue;
71
+ // (3) no explicit per-row automation booleans → excludes intentional tracking.
72
+ if (typeof record.autoFixEnabled === 'boolean' || typeof record.autoReviewEnabled === 'boolean') continue;
73
+ // (4) enrolled under the human sentinel (autoEnroll stamp), not an agent author.
74
+ if (String(record.agent || row.agent || '').trim().toLowerCase() !== 'human') continue;
75
+
76
+ // (5) structured provenance: every linked, still-existing WI is a review.
77
+ const links = Array.isArray(record.prdItems) ? record.prdItems : [];
78
+ const knownTypes = [];
79
+ for (const wiId of links) {
80
+ const t = wiTypeById.get(String(wiId));
81
+ if (t) knownTypes.push(t);
82
+ }
83
+ if (knownTypes.length === 0) continue; // cannot prove provenance → leave alone
84
+ if (!knownTypes.every(t => t === 'review')) continue; // a mutating WI is linked → coding workflow
85
+
86
+ record.contextOnly = true;
87
+ record.autoFixEnabled = false;
88
+ record.autoReviewEnabled = false;
89
+ update.run(JSON.stringify(record), row.scope, row.id);
90
+ repaired += 1;
91
+ }
92
+
93
+ console.log(`[db-migrate] v32: repaired ${repaired} review-enrolled PR record(s) to context-only`);
94
+ },
95
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2452",
3
+ "version": "0.1.2454",
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"