@yemi33/minions 0.1.2453 → 0.1.2455

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.
Files changed (48) hide show
  1. package/bin/install-internal-minions.js +136 -6
  2. package/bin/minions.js +27 -13
  3. package/dashboard/js/agent-identity.js +121 -0
  4. package/dashboard/js/modal.js +4 -0
  5. package/dashboard/js/refresh.js +38 -3
  6. package/dashboard/js/render-agents.js +14 -2
  7. package/dashboard/js/render-dispatch.js +2 -2
  8. package/dashboard/js/render-other.js +147 -13
  9. package/dashboard/js/render-prd.js +6 -5
  10. package/dashboard/js/render-prs.js +44 -25
  11. package/dashboard/js/render-work-items.js +442 -22
  12. package/dashboard/js/settings.js +8 -0
  13. package/dashboard/js/utils.js +40 -0
  14. package/dashboard/pages/tools.html +1 -0
  15. package/dashboard/pages/work.html +13 -0
  16. package/dashboard/shared/pr-author.js +75 -0
  17. package/dashboard/shared/pr-filters.js +28 -5
  18. package/dashboard/styles.css +85 -0
  19. package/dashboard-build.js +3 -3
  20. package/dashboard.js +93 -7
  21. package/docs/README.md +1 -0
  22. package/docs/copilot-cli-schema.md +1 -0
  23. package/docs/engine-restart.md +4 -2
  24. package/docs/internal-install.md +44 -5
  25. package/docs/named-agents.md +48 -0
  26. package/docs/pr-author-identity.md +63 -10
  27. package/docs/runtime-adapters.md +39 -0
  28. package/docs/temporary-agents.md +172 -0
  29. package/engine/ado/comment.js +261 -4
  30. package/engine/agents/llm.js +26 -0
  31. package/engine/agents/playbook.js +2 -1
  32. package/engine/api/settings-validation.js +25 -0
  33. package/engine/core/operator-identity.js +23 -1
  34. package/engine/core/queries.js +53 -2
  35. package/engine/core/shared.js +286 -9
  36. package/engine/db/migrations/032-review-enrolled-pr-context-only.js +95 -0
  37. package/engine/operations/cli.js +102 -1
  38. package/engine/orchestration/lifecycle.js +7 -0
  39. package/engine/orchestration/routing.js +4 -1
  40. package/engine/providers/gh-comment.js +159 -0
  41. package/engine/recovery/stop-stack.js +16 -2
  42. package/engine/runtimes/claude.js +3 -0
  43. package/engine/runtimes/codex.js +4 -0
  44. package/engine/runtimes/copilot.js +23 -2
  45. package/engine.js +2 -2
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +23 -2
  48. package/playbooks/shared-rules.md +24 -2
@@ -2044,6 +2044,116 @@ function uid() {
2044
2044
  return Date.now().toString(36) + _uidCounter.toString(36).padStart(4, '0') + crypto.randomBytes(2).toString('hex');
2045
2045
  }
2046
2046
 
2047
+ // ---------------------------------------------------------------------------
2048
+ // Temporary-agent friendly display identity (W-msd8zdzb00i550a8)
2049
+ //
2050
+ // Dynamically-created temp agents keep their canonical id (`temp-<uid>`) for
2051
+ // ALL machine identity — routing, SQL keys, filesystem paths, logs, APIs,
2052
+ // process ownership, memory rules, reconciliation. Friendly naming here is a
2053
+ // purely ADDITIVE display identity; it never renames the canonical id.
2054
+ //
2055
+ // The friendly call sign is DERIVED DETERMINISTICALLY from the immutable
2056
+ // canonical id, so it is stable for the agent's whole lifetime and across
2057
+ // engine/dashboard restarts and in historical records — with no persisted
2058
+ // state (no SQL, no JSON). The active set of agents never influences the
2059
+ // derivation, which is exactly what "preserve an already assigned name rather
2060
+ // than renaming when the active set changes" requires.
2061
+ //
2062
+ // Collision handling is deterministic: the call sign is an `<adjective> <name>`
2063
+ // pair, each element chosen from a curated, roster-disjoint vocabulary via an
2064
+ // independent FNV-1a hash stream of the canonical id. The combined space keeps
2065
+ // concurrently-visible temp agents distinct, and the curated `name` list is
2066
+ // disjoint from the shipped roster (Ripley/Dallas/Lambert/Rebecca/Ralph) so a
2067
+ // call sign never equals a configured display name. The always-visible "Temp"
2068
+ // badge disambiguates even in the rare full collision, and the canonical id
2069
+ // stays discoverable in tooltips/detail for debugging.
2070
+ //
2071
+ // NOTE: dashboard/js/agent-identity.js mirrors this vocabulary + algorithm for
2072
+ // a client-side fallback. Keep the two in sync (asserted by
2073
+ // test/unit/temp-agent-identity.test.js).
2074
+ const TEMP_AGENT_CALL_SIGN_ADJECTIVES = [
2075
+ 'Cosmic', 'Starry', 'Nebular', 'Twinkly', 'Lunar', 'Solar', 'Astral', 'Galactic',
2076
+ 'Stellar', 'Orbital', 'Meteoric', 'Cloudy', 'Sunny', 'Radiant', 'Sparkly', 'Dreamy',
2077
+ 'Breezy', 'Drifting', 'Glowing', 'Floaty', 'Skybound', 'Wandering', 'Whirling', 'Shimmery',
2078
+ ];
2079
+ const TEMP_AGENT_CALL_SIGN_NAMES = [
2080
+ 'Comet', 'Nova', 'Luna', 'Sol', 'Astro', 'Orion', 'Nebula', 'Pluto',
2081
+ 'Halley', 'Vega', 'Rigel', 'Sirius', 'Polaris', 'Stardust', 'Cosmo', 'Nimbus',
2082
+ 'Zephyr', 'Aurora', 'Meteor', 'Galaxy', 'Twinkle', 'Cirrus', 'Cygnus', 'Juno',
2083
+ ];
2084
+
2085
+ // Case-insensitive canonical-temp-id test. Legacy mixed-case `Temp-*` ids are
2086
+ // treated as temp too; machine identity is unchanged, only display is affected.
2087
+ function isTempAgentId(id) {
2088
+ return typeof id === 'string' && /^temp-/i.test(id.trim());
2089
+ }
2090
+
2091
+ // FNV-1a 32-bit over a string → non-negative integer. Deterministic and
2092
+ // dependency-free; mirrored verbatim on the client.
2093
+ function _fnv1a32(str) {
2094
+ let h = 0x811c9dc5;
2095
+ for (let i = 0; i < str.length; i++) {
2096
+ h ^= str.charCodeAt(i);
2097
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
2098
+ }
2099
+ return h >>> 0;
2100
+ }
2101
+
2102
+ /**
2103
+ * Deterministic friendly call sign for a temp agent id, e.g. "Cosmic Comet".
2104
+ * Returns null for non-temp / malformed ids so callers can fall back to the
2105
+ * canonical id. Derivation depends ONLY on the immutable canonical id.
2106
+ */
2107
+ function tempAgentCallSign(id) {
2108
+ if (!isTempAgentId(id)) return null;
2109
+ const key = id.trim().toLowerCase();
2110
+ // Single hash of the canonical id, then slice independent index streams from
2111
+ // its low and high bits. Deriving both indices from ONE hash of the bare id
2112
+ // (rather than hashing `id+':adj'` / `id+':name'`) is deliberate: a trailing
2113
+ // discriminator suffix lets the identical suffix bytes dominate FNV-1a's low
2114
+ // bits, collapsing the joint (adjective,name) distribution for the
2115
+ // long-shared-prefix ids that concurrent temp allocation produces. Slicing
2116
+ // one hash keeps the full id entropy in both indices.
2117
+ const h = _fnv1a32(key);
2118
+ const adj = TEMP_AGENT_CALL_SIGN_ADJECTIVES[h % TEMP_AGENT_CALL_SIGN_ADJECTIVES.length];
2119
+ const name = TEMP_AGENT_CALL_SIGN_NAMES[
2120
+ Math.floor(h / TEMP_AGENT_CALL_SIGN_ADJECTIVES.length) % TEMP_AGENT_CALL_SIGN_NAMES.length
2121
+ ];
2122
+ return `${adj} ${name}`;
2123
+ }
2124
+
2125
+ /**
2126
+ * Friendly display name for any agent id. For temp ids this is the deterministic
2127
+ * call sign; for configured/permanent agents it is the configured display name
2128
+ * (or the id when unknown). The canonical id is never mutated.
2129
+ */
2130
+ function tempAgentDisplayName(id) {
2131
+ return tempAgentCallSign(id) || (typeof id === 'string' ? id : '');
2132
+ }
2133
+
2134
+ /**
2135
+ * Structured display identity for an agent id. Returns canonical id, friendly
2136
+ * display name, the call sign (temp only), the temporary flag, and a role hint.
2137
+ * API payloads should expose these structured fields rather than replacing ids
2138
+ * with formatted strings, so id-based clients stay compatible.
2139
+ */
2140
+ function resolveAgentIdentity(id, config = null) {
2141
+ const canonicalId = typeof id === 'string' ? id : '';
2142
+ const temp = isTempAgentId(canonicalId);
2143
+ const configured = config && config.agents ? config.agents[canonicalId] : null;
2144
+ if (temp) {
2145
+ const callSign = tempAgentCallSign(canonicalId);
2146
+ return { id: canonicalId, displayName: callSign, callSign, isTemp: true, role: 'Temporary Agent' };
2147
+ }
2148
+ return {
2149
+ id: canonicalId,
2150
+ displayName: (configured && configured.name) || canonicalId,
2151
+ callSign: null,
2152
+ isTemp: false,
2153
+ role: (configured && configured.role) || 'Agent',
2154
+ };
2155
+ }
2156
+
2047
2157
  /**
2048
2158
  * Return a unique filepath by appending -2, -3, etc. if the file already exists.
2049
2159
  * E.g. uniquePath('/plans/foo.json') → '/plans/foo-2.json' if foo.json exists.
@@ -5937,6 +6047,39 @@ function waitForDispatchFinalization(dispatchId) {
5937
6047
  // Structured completion protocol — fields agents must produce in ```completion blocks
5938
6048
  const COMPLETION_FIELDS = ['status', 'summary', 'files_changed', 'tests', 'pr', 'not_changed', 'failure_class', 'retryable', 'needs_rerun', 'verdict', 'artifacts', 'nonce', 'securityFlags', 'reviewLearnings', 'reviewLearningApplications'];
5939
6049
 
6050
+ // Generic runtime-reported billable-unit accumulator (W-msdcgitc).
6051
+ //
6052
+ // Adapters may report a NON-USD native billable unit — e.g. the Copilot CLI's
6053
+ // premium-request count — via `usage.billable = { unit, value, reported }`. This
6054
+ // folds such reports into `target.billableUnits[unit] = { total, reported,
6055
+ // unavailable }` so the engine accumulates credits by dispatch/agent/day WITHOUT
6056
+ // ever branching on the runtime name. USD is deliberately excluded: dollar cost
6057
+ // flows through the richer `costUsd` + token path, so routing it here too would
6058
+ // double-count. A report whose `reported` is not true (the CLI did not expose the
6059
+ // field for this run) is counted as an `unavailable` observation and does NOT
6060
+ // advance `total`/`reported`, keeping "unreported" distinct from a genuine 0.
6061
+ // The adapter value is already scoped to the finalized unit of work (per
6062
+ // invocation / per pooled turn), so summing is correct and no delta-vs-cumulative
6063
+ // diffing is required here.
6064
+ function accumulateBillableUnits(target, billable) {
6065
+ if (!target || typeof target !== 'object') return;
6066
+ if (!billable || typeof billable !== 'object') return;
6067
+ const unit = billable.unit;
6068
+ if (typeof unit !== 'string' || !unit || unit === 'usd') return;
6069
+ if (!target.billableUnits || typeof target.billableUnits !== 'object') {
6070
+ target.billableUnits = {};
6071
+ }
6072
+ const bucket = target.billableUnits[unit]
6073
+ || { total: 0, reported: 0, unavailable: 0 };
6074
+ if (billable.reported === true && Number.isFinite(billable.value)) {
6075
+ bucket.total += billable.value;
6076
+ bucket.reported += 1;
6077
+ } else {
6078
+ bucket.unavailable += 1;
6079
+ }
6080
+ target.billableUnits[unit] = bucket;
6081
+ }
6082
+
5940
6083
  const DEFAULT_AGENT_METRICS = {
5941
6084
  tasksCompleted: 0, tasksErrored: 0,
5942
6085
  prsCreated: 0, prsApproved: 0, prsRejected: 0, prsMerged: 0,
@@ -8702,11 +8845,113 @@ function mergePrAuthorIdentity(existing, incoming) {
8702
8845
  return out;
8703
8846
  }
8704
8847
 
8705
- // Human-readable label for a PR author (typed SQL column + filter/search text).
8706
- function prAuthorLabel(author) {
8848
+ // A human-readable name from ONE identity fragment that never contains an email
8849
+ // address:
8850
+ // - "Display Name <user@example.com>" → "Display Name"
8851
+ // - "user@example.com" → "user" (local-part, domain dropped)
8852
+ // - "Display Name" → "Display Name"
8853
+ // Any '@' anywhere forces local-part extraction, so a malformed address can
8854
+ // never leak through. Returns '' when nothing usable remains.
8855
+ function _authorFragmentName(raw) {
8856
+ let s = _authorField(raw, 256);
8857
+ if (!s) return '';
8858
+ // "Name <addr>" — keep the display part when it is a real (non-email) name.
8859
+ const m = s.match(/^\s*"?([^"<]*?)"?\s*<[^>]*>\s*$/);
8860
+ if (m && m[1].trim() && m[1].indexOf('@') === -1) return m[1].trim();
8861
+ if (s.indexOf('@') !== -1) {
8862
+ s = s.replace(/<([^>]*)>/, '$1'); // unwrap a "<addr>"
8863
+ return s.split('@')[0].replace(/^["'<\s]+|["'>\s]+$/g, '').trim();
8864
+ }
8865
+ return s;
8866
+ }
8867
+
8868
+ // Human-readable author name that NEVER exposes an email address. Prefers a real
8869
+ // provider display name / login, and only when the sole usable identity is an
8870
+ // email does it fall back to the local-part (no domain). Returns '' (→ renders
8871
+ // "Unknown") when nothing usable is present. The structured identity fields
8872
+ // (login/id/descriptor) are untouched — this is a presentation label only.
8873
+ function prAuthorDisplayName(author) {
8707
8874
  const a = normalizePrAuthorIdentity(author);
8708
8875
  if (!a) return '';
8709
- return a.displayName || a.login || a.id || '';
8876
+ return _authorFragmentName(a.displayName)
8877
+ || _authorFragmentName(a.login)
8878
+ || _authorField(a.id, 64)
8879
+ || '';
8880
+ }
8881
+
8882
+ // Human-readable label for a PR author (typed SQL column + filter/search text).
8883
+ // Routed through prAuthorDisplayName so the projected label is always email-free.
8884
+ function prAuthorLabel(author) {
8885
+ return prAuthorDisplayName(author);
8886
+ }
8887
+
8888
+ // ── Foreign-author ownership for the AutoFix guardrail (W-msd8pr2300ev95e1) ──
8889
+ //
8890
+ // Decide whether the CURRENT authenticated identity for a PR's provider authored
8891
+ // that PR, so the dashboard can warn when auto-fix is enabled on someone else's
8892
+ // PR (Minions would push commits to a PR the operator only asked to read/review).
8893
+ //
8894
+ // STABLE identities only — never display names or emails:
8895
+ // GitHub: canonical login (case-insensitive) or numeric id.
8896
+ // ADO: identity id or descriptor (exact), or uniqueName login (ci).
8897
+ //
8898
+ // Fail CLOSED: returns 'unknown' whenever EITHER the PR author identity or the
8899
+ // current provider identity cannot be established. The caller must render an
8900
+ // "author ownership unknown" warning rather than treating unknown as self.
8901
+ //
8902
+ // `selfByProvider` is `{ github: { login, id }, ado: { id, descriptor, login } }`
8903
+ // resolved by the caller (queries.js) from the per-provider token identity; a
8904
+ // missing provider entry (or empty fields) yields 'unknown' for that provider.
8905
+ const PR_AUTHOR_OWNERSHIP = { SELF: 'self', FOREIGN: 'foreign', UNKNOWN: 'unknown' };
8906
+
8907
+ function _ciEq(a, b) {
8908
+ const x = _authorField(a, 256).toLowerCase();
8909
+ const y = _authorField(b, 256).toLowerCase();
8910
+ return !!x && x === y;
8911
+ }
8912
+ function _exactEq(a, b) {
8913
+ const x = _authorField(a, 256);
8914
+ const y = _authorField(b, 256);
8915
+ return !!x && x === y;
8916
+ }
8917
+
8918
+ function _prProviderFromRecord(pr, author) {
8919
+ const p = _authorField(author && author.provider, 32).toLowerCase();
8920
+ if (p === 'github' || p === 'ado') return p;
8921
+ const id = _authorField(pr && pr.id, 128).toLowerCase();
8922
+ if (id.startsWith('github:') || id.startsWith('gh:')) return 'github';
8923
+ if (id.startsWith('ado:')) return 'ado';
8924
+ const url = _authorField(pr && pr.url, 400).toLowerCase();
8925
+ if (url.includes('github.com')) return 'github';
8926
+ if (url.includes('dev.azure.com') || url.includes('visualstudio.com') || url.includes('/_git/')) return 'ado';
8927
+ return '';
8928
+ }
8929
+
8930
+ function resolvePrAuthorOwnership(pr, selfByProvider) {
8931
+ const author = normalizePrAuthorIdentity(pr && pr.author);
8932
+ if (!author) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8933
+ const provider = _prProviderFromRecord(pr, author);
8934
+ if (!provider) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8935
+ const self = selfByProvider && selfByProvider[provider];
8936
+ if (!self) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8937
+
8938
+ if (provider === 'github') {
8939
+ const haveSelf = _authorField(self.login, 256) || _authorField(self.id, 64);
8940
+ const haveAuthor = _authorField(author.login, 256) || _authorField(author.id, 64);
8941
+ if (!haveSelf || !haveAuthor) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8942
+ if (_exactEq(self.id, author.id) || _ciEq(self.login, author.login)) return PR_AUTHOR_OWNERSHIP.SELF;
8943
+ return PR_AUTHOR_OWNERSHIP.FOREIGN;
8944
+ }
8945
+ // ADO: compare stable identity id/descriptor ONLY. ADO `login` is the
8946
+ // uniqueName (a sign-in email), and the guardrail contract forbids comparing
8947
+ // emails, so it is deliberately excluded here.
8948
+ const haveSelf = _authorField(self.id, 64) || _authorField(self.descriptor, 256);
8949
+ const haveAuthor = _authorField(author.id, 64) || _authorField(author.descriptor, 256);
8950
+ if (!haveSelf || !haveAuthor) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
8951
+ if (_exactEq(self.id, author.id) || _exactEq(self.descriptor, author.descriptor)) {
8952
+ return PR_AUTHOR_OWNERSHIP.SELF;
8953
+ }
8954
+ return PR_AUTHOR_OWNERSHIP.FOREIGN;
8710
8955
  }
8711
8956
 
8712
8957
  function _jsonEqual(a, b) {
@@ -9396,16 +9641,35 @@ function classifyPrRefForVerification(prRef, project = null) {
9396
9641
  // .parent_pr_url). Description-only PR mentions are INTENTIONALLY skipped
9397
9642
  // per the "structured-vs-loose split" — enrollment must be intentional.
9398
9643
  //
9644
+ // Enrollment mode is type-dependent (W-msd8pr2300ev95e1): mutating PR-requiring
9645
+ // types (fix / test / build-fix-complex) enroll AUTO-MANAGED (contextOnly:false)
9646
+ // — the normal coding workflow; a read-only `review` WI enrolls CONTEXT-ONLY so
9647
+ // a "review this PR" request never silently enables auto-fix on the PR.
9648
+ //
9399
9649
  // 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
9650
+ // { skipped: true, reason } — not a pr-requiring type / no ref / no URL / upsert error
9651
+ // { alreadyEnrolled: true, id } — PR already tracked
9652
+ // { enrolled: true, id, scope, contextOnly } — newly enrolled (contextOnly true for review)
9403
9653
  const _PR_REQUIRING_TYPES = new Set([
9404
9654
  WORK_TYPE.FIX,
9405
9655
  WORK_TYPE.BUILD_FIX_COMPLEX,
9406
9656
  WORK_TYPE.REVIEW,
9407
9657
  WORK_TYPE.TEST,
9408
9658
  ]);
9659
+ // W-msd8pr2300ev95e1 — READ-ONLY PR-requiring types. A `review` WI enrolls the
9660
+ // PR record so the review dispatch clears the `pr_not_found` gate (the #536
9661
+ // rationale), but a human "review this PR" request is one-shot / read-review
9662
+ // intent by default: it must NOT enable auto-fix, auto-review, auto-merge, or
9663
+ // any future automatic fix / re-review dispatch on someone else's PR. So a
9664
+ // review enrollment is context-only. The explicit review WI still dispatches
9665
+ // (isExplicitlyRequestedPrDispatch keeps work-item-sourced review dispatches
9666
+ // even on a contextOnly PR), and the upsert's non-demote guard means a review
9667
+ // WI that happens to target an ALREADY auto-managed PR (a normal coding
9668
+ // workflow) never demotes it. Mutating PR-requiring types (fix / test /
9669
+ // build-fix-complex) stay auto-managed — that is the normal coding workflow.
9670
+ const _READ_ONLY_PR_ENROLL_TYPES = new Set([
9671
+ WORK_TYPE.REVIEW,
9672
+ ]);
9409
9673
  function autoEnrollPrFromWorkItem(item, project, minionsDir) {
9410
9674
  if (!item || !_PR_REQUIRING_TYPES.has(item.type)) return { skipped: true, reason: 'not-pr-requiring-type' };
9411
9675
  const prRef = extractStructuredWorkItemPrRef(item);
@@ -9420,6 +9684,7 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
9420
9684
  const parsedUrl = parsePrUrl(url);
9421
9685
  const prNum = parsedUrl ? parsedUrl.prNumber : null;
9422
9686
  const prId = getCanonicalPrId(project, prRef, url);
9687
+ const readOnlyEnroll = _READ_ONLY_PR_ENROLL_TYPES.has(item.type);
9423
9688
  try {
9424
9689
  const result = upsertPullRequestRecord(source, {
9425
9690
  id: prId,
@@ -9432,10 +9697,15 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
9432
9697
  status: 'active',
9433
9698
  created: new Date().toISOString(),
9434
9699
  url,
9435
- contextOnly: false,
9700
+ // Read-review intent enrolls context-only with the per-row automation
9701
+ // controls explicitly OFF (belt-and-suspenders over the contextOnly
9702
+ // default derivation), so no later default change can silently re-enable
9703
+ // auto-fix on a review-enrolled PR.
9704
+ contextOnly: readOnlyEnroll,
9705
+ ...(readOnlyEnroll ? { autoFixEnabled: false, autoReviewEnabled: false } : {}),
9436
9706
  }, { project, itemId: item.id });
9437
9707
  return result.created
9438
- ? { enrolled: true, id: result.id, scope: stateScope(source) }
9708
+ ? { enrolled: true, id: result.id, scope: stateScope(source), contextOnly: readOnlyEnroll }
9439
9709
  : { alreadyEnrolled: true, id: result.id };
9440
9710
  } catch (e) {
9441
9711
  log('warn', `autoEnrollPrFromWorkItem ${item.id}: ${e.message}`);
@@ -10952,6 +11222,10 @@ module.exports = {
10952
11222
  readPullRequests,
10953
11223
  mutatePullRequests,
10954
11224
  uid,
11225
+ isTempAgentId,
11226
+ tempAgentCallSign,
11227
+ tempAgentDisplayName,
11228
+ resolveAgentIdentity,
10955
11229
  uniquePath,
10956
11230
  moveFileNoClobber,
10957
11231
  isPlainObject,
@@ -11008,7 +11282,7 @@ module.exports = {
11008
11282
  projectWorkSourceWarnings,
11009
11283
  backfillProjectWorkSourceDefaults,
11010
11284
  WORK_ITEM_PRIORITY, WORK_ITEM_PRIORITY_VALUES, WORK_ITEM_PRIORITY_ERROR_CODE, validateWorkItemPriority,
11011
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, isPrdArchived, isDefunctPrd, WORK_TYPE, isFixLikeWorkType, WORKTREE_REQUIRING_TYPES, LIVE_VALIDATION_WORK_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, isPrPollable, resolveAbandonedPrRepollWindowMs, getPrAbandonedAtMs, stampPrAbandonedAt, clearPrAbandonedAt, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
11285
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, isPrdArchived, isDefunctPrd, WORK_TYPE, isFixLikeWorkType, WORKTREE_REQUIRING_TYPES, LIVE_VALIDATION_WORK_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, isPrPollable, resolveAbandonedPrRepollWindowMs, getPrAbandonedAtMs, stampPrAbandonedAt, clearPrAbandonedAt, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, accumulateBillableUnits, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
11012
11286
  // W-ms5tb6ha016fda10 — explicit, fail-closed control-plane execution surface
11013
11287
  EXECUTION_SURFACE, EXECUTION_SURFACE_VALUES, CONTROL_PLANE_ELIGIBLE_TYPES, CLOUD_ELIGIBLE_TYPES,
11014
11288
  isControlPlaneEligibleType, validateExecutionSurface, readDeclaredExecutionSurface, isControlPlaneExecution,
@@ -11087,6 +11361,9 @@ module.exports = {
11087
11361
  normalizePrAuthorIdentity,
11088
11362
  mergePrAuthorIdentity,
11089
11363
  prAuthorLabel,
11364
+ prAuthorDisplayName,
11365
+ resolvePrAuthorOwnership,
11366
+ PR_AUTHOR_OWNERSHIP,
11090
11367
  applyPrFieldDelta,
11091
11368
  normalizePrRecord,
11092
11369
  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
+ };
@@ -45,6 +45,23 @@ function dispatchSessionBranch(item) {
45
45
  );
46
46
  }
47
47
 
48
+ /**
49
+ * Coerce a CLI `--thread <id>` value to the positive integer thread id that the
50
+ * ADO comment layer requires. `--thread` always arrives as a string, but
51
+ * engine/ado/comment.js#_validateThreadId requires `typeof number`, so passing
52
+ * the raw string through threw `invalid threadId` for every ADO fix-summary call
53
+ * (W-msd9os8e00qnd628). Returns the coerced integer, or null when the value is
54
+ * not a positive integer (the GitHub path keeps the raw string node id and must
55
+ * not use this). Kept as a pure, exported helper so the coercion is unit-tested
56
+ * directly rather than only through source inspection.
57
+ */
58
+ function coerceAdoThreadId(raw) {
59
+ if (raw === undefined || raw === null || raw === '') return null;
60
+ const n = Number(raw);
61
+ if (!Number.isInteger(n) || n <= 0) return null;
62
+ return n;
63
+ }
64
+
48
65
  function isEngineProcessAlive(control) {
49
66
  if (!control?.pid) return false;
50
67
  if (control.pid === process.pid) return true;
@@ -2199,6 +2216,13 @@ const commands = {
2199
2216
  console.log(' GitHub: minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
2200
2217
  console.log(' ADO: minions pr comment <prNumber> --host ado --ado-org <org> --ado-project <proj> --repo-id <id> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
2201
2218
  console.log(' --resolved (ADO only) post the thread pre-resolved/closed — for non-actionable FYI/praise notes');
2219
+ console.log(' --thread <id> reply to an EXISTING originating review thread instead of opening a new one');
2220
+ console.log(' (ADO: numeric thread id; GitHub: review-thread GraphQL node id)');
2221
+ console.log(' --resolve with --thread, transition the thread to a resolved/fixed state (fix-summary path)');
2222
+ console.log(' --resolve-status <s> (ADO only) terminal status for --resolve: fixed (default) | closed');
2223
+ console.log('');
2224
+ console.log('Fix summaries for ADDRESSED review findings should reply to the originating thread with');
2225
+ console.log('--thread <id> --resolve (keyed by structured finding/thread identity) — never open a fresh active thread.');
2202
2226
  console.log('');
2203
2227
  console.log('Posts a PR comment with the hidden minions marker and brand link folded in by the shared');
2204
2228
  console.log('builder — so engine classifiers identify agent-authored comments by');
@@ -2216,7 +2240,7 @@ const commands = {
2216
2240
  const positional = [];
2217
2241
  const flags = {};
2218
2242
  // Valueless boolean flags — present means true, no following value consumed.
2219
- const BOOLEAN_FLAGS = new Set(['resolved']);
2243
+ const BOOLEAN_FLAGS = new Set(['resolved', 'resolve']);
2220
2244
  let i = 0;
2221
2245
  while (i < rest.length) {
2222
2246
  const a = rest[i];
@@ -2242,6 +2266,17 @@ const commands = {
2242
2266
 
2243
2267
  const isAdo = String(flags.host || '').toLowerCase() === 'ado';
2244
2268
 
2269
+ // `--thread <id>` targets an EXISTING originating review thread: reply on it
2270
+ // and (with `--resolve`) transition it to a resolved/fixed state, instead of
2271
+ // opening a fresh active feedback thread for an addressed finding
2272
+ // (W-msd9os8e00qnd628). `--resolve` is meaningless without a thread to
2273
+ // transition, so require the pair.
2274
+ const threadId = flags.thread;
2275
+ if (flags.resolve === true && (threadId === undefined || threadId === '')) {
2276
+ console.error('error: --resolve requires --thread <id> (the originating review thread to transition)');
2277
+ process.exit(2);
2278
+ }
2279
+
2245
2280
  const agentId = flags.agent;
2246
2281
  const kind = flags.kind;
2247
2282
  const workItemId = flags.wi;
@@ -2287,6 +2322,41 @@ const commands = {
2287
2322
  }
2288
2323
  const orgBase = flags['org-base'] || `https://dev.azure.com/${adoOrg}`;
2289
2324
  const adoComment = require('../ado/comment');
2325
+
2326
+ // ── Reply-to-originating-thread + resolve (fix-summary path) ──
2327
+ // When a thread id is supplied we reply on THAT thread and (with
2328
+ // --resolve) transition it to fixed/closed, rather than creating a new
2329
+ // active thread. --resolve-status picks the terminal status; default
2330
+ // `fixed` for addressed findings, `closed` for non-actionable/deferred.
2331
+ if (threadId !== undefined && threadId !== '') {
2332
+ // ADO thread ids are numeric; `--thread` arrives as a string, and
2333
+ // replyToAdoThread's _validateThreadId requires typeof number. Coerce +
2334
+ // validate here (mirrors the prNumber handling above) so the fix-summary
2335
+ // path doesn't throw `invalid threadId` (W-msd9os8e00qnd628).
2336
+ const adoThreadId = coerceAdoThreadId(threadId);
2337
+ if (adoThreadId === null) {
2338
+ console.error(`error: invalid --thread: ${JSON.stringify(threadId)} (ADO expects a positive integer thread id)`);
2339
+ process.exit(2);
2340
+ }
2341
+ const wantsResolve = flags.resolve === true;
2342
+ const resolveStatus = flags['resolve-status'] || 'fixed';
2343
+ adoComment.replyToAdoThread({
2344
+ orgBase, project, repositoryId, prNumber, threadId: adoThreadId, body, agentId, kind, workItemId,
2345
+ model: executionModel.model,
2346
+ // `active` means "reply only, don't transition" inside replyToAdoThread.
2347
+ status: wantsResolve ? resolveStatus : 'active',
2348
+ }).then((result) => {
2349
+ const parts = [`ADO thread ${result.threadId}`];
2350
+ parts.push(result.replied ? 'replied' : (result.alreadyReplied ? 'reply already present' : 'no reply'));
2351
+ if (wantsResolve) parts.push(result.resolved ? 'resolved' : 'not resolved');
2352
+ console.log(parts.join('; '));
2353
+ }).catch((e) => {
2354
+ console.error(`error: ${e.message}`);
2355
+ process.exit(1);
2356
+ });
2357
+ return;
2358
+ }
2359
+
2290
2360
  adoComment.postAdoPrComment({
2291
2361
  orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId,
2292
2362
  model: executionModel.model,
@@ -2316,6 +2386,36 @@ const commands = {
2316
2386
  process.exit(2);
2317
2387
  }
2318
2388
  const [repo, prNumberRaw] = positional;
2389
+
2390
+ // ── GitHub review-thread reply + resolve (fix-summary path) ──
2391
+ // A `--thread <nodeId>` on GitHub is an inline review-thread GraphQL node id.
2392
+ // We reply on that thread and resolve it via resolveReviewThread — the only
2393
+ // GitHub surface with a real "resolved" state. Top-level conversation
2394
+ // comments have no such state, so an addressed finding MUST come through
2395
+ // here, not through a fresh `postPrComment` claiming "resolved"
2396
+ // (W-msd9os8e00qnd628). The thread node id is self-contained, so only <repo>
2397
+ // (for per-slug token routing) is required positionally — not <prNumber>.
2398
+ if (threadId !== undefined && threadId !== '') {
2399
+ if (!repo) {
2400
+ console.error('Usage: minions pr comment <repo> --thread <reviewThreadNodeId> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
2401
+ process.exit(2);
2402
+ }
2403
+ try {
2404
+ const result = ghComment.replyAndResolveReviewThread({
2405
+ repo, threadId, body, agentId, kind, workItemId, model: executionModel.model,
2406
+ resolve: true,
2407
+ });
2408
+ const parts = [`GitHub review thread ${result.threadId}`];
2409
+ parts.push(result.replied ? 'replied' : (result.alreadyReplied ? 'reply already present' : 'no reply'));
2410
+ parts.push(result.resolved ? 'resolved' : 'not resolved');
2411
+ console.log(parts.join('; '));
2412
+ } catch (e) {
2413
+ console.error(`error: ${e.message}`);
2414
+ process.exit(1);
2415
+ }
2416
+ return;
2417
+ }
2418
+
2319
2419
  if (!repo || prNumberRaw === undefined) {
2320
2420
  console.error('Usage: minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
2321
2421
  process.exit(2);
@@ -2518,6 +2618,7 @@ module.exports = {
2518
2618
  _normalizeSessionBranch: normalizeSessionBranch,
2519
2619
  _dispatchSessionBranch: dispatchSessionBranch,
2520
2620
  _resolveCommentExecutionModel: resolveCommentExecutionModel, // exported for testing
2621
+ _coerceAdoThreadId: coerceAdoThreadId, // exported for testing (W-msd9os8e00qnd628)
2521
2622
  // W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
2522
2623
  _writeHeartbeatNow: writeHeartbeatNow,
2523
2624
  _createHeartbeatInterval: createHeartbeatInterval,
@@ -4037,6 +4037,11 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
4037
4037
  m.totalInputTokens = (m.totalInputTokens || 0) + (taskUsage.inputTokens || 0);
4038
4038
  m.totalOutputTokens = (m.totalOutputTokens || 0) + (taskUsage.outputTokens || 0);
4039
4039
  m.totalCacheRead = (m.totalCacheRead || 0) + (taskUsage.cacheRead || 0);
4040
+ // Non-USD runtime-reported billable units (e.g. Copilot premium requests).
4041
+ // Accumulated generically off typed usage metadata, never a runtime-name
4042
+ // branch (W-msdcgitc). USD is skipped by the helper (it rides the costUsd
4043
+ // path above), so this cannot double-count dollar cost.
4044
+ shared.accumulateBillableUnits(m, taskUsage.billable);
4040
4045
  }
4041
4046
  // Track agent runs in _engine for LLM performance tile (alongside CC/doc-chat)
4042
4047
  if (!metrics._engine) metrics._engine = {};
@@ -4061,6 +4066,7 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
4061
4066
  eng.outputTokens += taskUsage.outputTokens || 0;
4062
4067
  eng.cacheRead += taskUsage.cacheRead || 0;
4063
4068
  eng.cacheCreation = (eng.cacheCreation || 0) + (taskUsage.cacheCreation || 0);
4069
+ shared.accumulateBillableUnits(eng, taskUsage.billable);
4064
4070
  }
4065
4071
 
4066
4072
  const today = dateStamp();
@@ -4074,6 +4080,7 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
4074
4080
  daily.inputTokens += taskUsage.inputTokens || 0;
4075
4081
  daily.outputTokens += taskUsage.outputTokens || 0;
4076
4082
  daily.cacheRead += taskUsage.cacheRead || 0;
4083
+ shared.accumulateBillableUnits(daily, taskUsage.billable);
4077
4084
  }
4078
4085
  const cutoff = new Date();
4079
4086
  cutoff.setDate(cutoff.getDate() - 30);
@@ -263,7 +263,10 @@ function resolveAgent(workType, config, opts = {}) {
263
263
  _tempBudget--;
264
264
  const tempId = `temp-${shared.uid()}`;
265
265
  _claimedAgents.add(tempId);
266
- tempAgents.set(tempId, { name: `Temp-${tempId.slice(5, 9)}`, role: 'Temporary Agent', createdAt: ts() });
266
+ // Friendly, deterministic call sign derived from the immutable temp id
267
+ // (shared.tempAgentCallSign) — stable across restarts; the canonical
268
+ // `temp-<uid>` id is unchanged and remains the machine identity.
269
+ tempAgents.set(tempId, { name: shared.tempAgentCallSign(tempId), role: 'Temporary Agent', createdAt: ts() });
267
270
  log('info', `Spawning temp agent ${tempId} — all permanent agents busy`);
268
271
  return tempId;
269
272
  }