@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
@@ -31,18 +31,68 @@
31
31
  * `active` (1). ADO honors the status field on thread creation, so unlike GitHub
32
32
  * this needs no second API round-trip. Actionable comments (bugs, required
33
33
  * changes) stay `active` so they block/notify the author.
34
+ *
35
+ * Fix summaries on the ORIGINATING thread (W-msd9os8e00qnd628):
36
+ * `replyToAdoThread` posts a fix-summary reply onto an existing review thread
37
+ * and transitions that thread to `fixed` (2) / `closed` (4), rather than opening
38
+ * a fresh active summary thread. It reads the thread first so retries / restarts
39
+ * / duplicate callbacks are idempotent (marker-keyed reply dedupe; already-
40
+ * resolved threads left as-is) and a reply/PATCH partial failure is surfaced and
41
+ * retryable.
34
42
  */
35
43
 
36
- const { buildMinionsCommentBody } = require('../providers/comment-format');
44
+ const { buildMinionsCommentBody, parseMinionsMarker } = require('../providers/comment-format');
37
45
  const { acquireAdoToken } = require('./token');
38
46
 
39
47
  const ADO_API_VERSION = '7.1';
40
48
 
41
- // ADO CommentThreadStatus enum values we use. `active` (1) is the default for
42
- // actionable threads; `closed` (4) is used for non-actionable threads posted
43
- // pre-resolved so they don't clutter the PR with noise the author must triage.
49
+ // ADO CommentThreadStatus enum. `active` (1) is the default for actionable
50
+ // threads; `fixed` (2) records a finding the fix agent addressed; `closed` (4)
51
+ // is used for non-actionable threads posted pre-resolved so they don't clutter
52
+ // the PR with noise the author must triage.
53
+ const ADO_THREAD_STATUS_UNKNOWN = 0;
44
54
  const ADO_THREAD_STATUS_ACTIVE = 1;
55
+ const ADO_THREAD_STATUS_FIXED = 2;
56
+ const ADO_THREAD_STATUS_WONT_FIX = 3;
45
57
  const ADO_THREAD_STATUS_CLOSED = 4;
58
+ const ADO_THREAD_STATUS_BY_DESIGN = 5;
59
+ const ADO_THREAD_STATUS_PENDING = 6;
60
+
61
+ // Canonical mapping from a caller-friendly status name to the ADO enum value.
62
+ // Used by the reply-and-resolve path so a fix agent can transition the
63
+ // ORIGINATING thread to a resolved state (`fixed`/`closed`) rather than opening
64
+ // a new active summary thread. `active` is the "leave unresolved" sentinel.
65
+ const ADO_THREAD_STATUS_BY_NAME = Object.freeze({
66
+ active: ADO_THREAD_STATUS_ACTIVE,
67
+ fixed: ADO_THREAD_STATUS_FIXED,
68
+ wontfix: ADO_THREAD_STATUS_WONT_FIX,
69
+ 'wont-fix': ADO_THREAD_STATUS_WONT_FIX,
70
+ closed: ADO_THREAD_STATUS_CLOSED,
71
+ resolved: ADO_THREAD_STATUS_FIXED,
72
+ bydesign: ADO_THREAD_STATUS_BY_DESIGN,
73
+ 'by-design': ADO_THREAD_STATUS_BY_DESIGN,
74
+ pending: ADO_THREAD_STATUS_PENDING,
75
+ });
76
+
77
+ // Thread statuses that mean "resolved" for idempotency purposes — a thread
78
+ // already in one of these needs no further status PATCH.
79
+ const ADO_RESOLVED_THREAD_STATUSES = new Set([
80
+ ADO_THREAD_STATUS_FIXED,
81
+ ADO_THREAD_STATUS_WONT_FIX,
82
+ ADO_THREAD_STATUS_CLOSED,
83
+ ADO_THREAD_STATUS_BY_DESIGN,
84
+ ]);
85
+
86
+ function resolveAdoThreadStatus(status) {
87
+ if (typeof status === 'number' && Number.isInteger(status)) return status;
88
+ const key = String(status || '').trim().toLowerCase();
89
+ if (key === '') return ADO_THREAD_STATUS_ACTIVE;
90
+ const mapped = ADO_THREAD_STATUS_BY_NAME[key];
91
+ if (mapped === undefined) {
92
+ throw new Error(`invalid thread status: ${JSON.stringify(status)} (expected one of ${Object.keys(ADO_THREAD_STATUS_BY_NAME).join(', ')})`);
93
+ }
94
+ return mapped;
95
+ }
46
96
 
47
97
  // ── Validation (ADO-specific) ────────────────────────────────────────────────
48
98
  // Field validation for the marker (agentId / kind / workItemId) is done inside
@@ -72,6 +122,12 @@ function _validatePrNumber(prNumber) {
72
122
  }
73
123
  }
74
124
 
125
+ function _validateThreadId(threadId) {
126
+ if (typeof threadId !== 'number' || !Number.isInteger(threadId) || threadId <= 0) {
127
+ throw new Error(`invalid threadId: ${JSON.stringify(threadId)} (expected positive integer)`);
128
+ }
129
+ }
130
+
75
131
  /**
76
132
  * Build the ADO threads endpoint for a PR. `orgBase` already includes the org
77
133
  * (e.g. https://dev.azure.com/contoso). Project + repositoryId are URL-encoded.
@@ -82,6 +138,25 @@ function buildThreadsUrl({ orgBase, project, repositoryId, prNumber }) {
82
138
  + `/pullRequests/${prNumber}/threads?api-version=${ADO_API_VERSION}`;
83
139
  }
84
140
 
141
+ /**
142
+ * Endpoint for a single existing thread (GET current status/comments, PATCH
143
+ * status). `orgBase` already includes the org.
144
+ */
145
+ function buildThreadUrl({ orgBase, project, repositoryId, prNumber, threadId }) {
146
+ const base = String(orgBase).replace(/\/+$/, '');
147
+ return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repositoryId)}`
148
+ + `/pullRequests/${prNumber}/threads/${threadId}?api-version=${ADO_API_VERSION}`;
149
+ }
150
+
151
+ /**
152
+ * Endpoint for the comments collection of an existing thread (POST a reply).
153
+ */
154
+ function buildThreadCommentsUrl({ orgBase, project, repositoryId, prNumber, threadId }) {
155
+ const base = String(orgBase).replace(/\/+$/, '');
156
+ return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repositoryId)}`
157
+ + `/pullRequests/${prNumber}/threads/${threadId}/comments?api-version=${ADO_API_VERSION}`;
158
+ }
159
+
85
160
  /**
86
161
  * Default token acquisition — thin wrapper over engine/ado/token.js so callers
87
162
  * can inject a stub in tests. Returns the bearer token string or throws.
@@ -174,12 +249,193 @@ async function postAdoPrComment({
174
249
  };
175
250
  }
176
251
 
252
+ /**
253
+ * Reply to an EXISTING review thread with a fix summary and transition that
254
+ * thread to a resolved status — instead of opening a new active summary thread.
255
+ *
256
+ * This is the fix-summary path for Azure DevOps (W-msd9os8e00qnd628): when a fix
257
+ * agent has evidence a review finding was addressed, it replies on the
258
+ * originating thread (identified by the structured `threadId` carried on the
259
+ * work item / completion report) and moves that thread to `fixed` (2) / `closed`
260
+ * (4). Contrast with `postAdoPrComment`, which creates a fresh thread.
261
+ *
262
+ * Idempotency (retries / engine restarts / duplicate completion callbacks /
263
+ * provider timeouts): the current thread is read first. A reply whose hidden
264
+ * marker already matches this dispatch's structured identity (agentId + kind +
265
+ * workItemId) is treated as already-posted and the reply POST is skipped; a
266
+ * thread already in a resolved status is left as-is. Resolution is keyed by the
267
+ * explicit `threadId` and the structured marker — never by body/title text — so
268
+ * sibling findings are never bulk-resolved.
269
+ *
270
+ * Partial-failure visibility: the reply POST and the status PATCH are distinct
271
+ * round-trips. If the reply succeeds but the status PATCH fails, we throw so the
272
+ * caller sees the failure and can retry; the retry re-reads the thread, finds
273
+ * the reply already present (skips it), and re-attempts only the PATCH. Local
274
+ * state is never marked resolved when the provider PATCH failed.
275
+ *
276
+ * @param {object} args
277
+ * @param {string} args.orgBase
278
+ * @param {string} args.project
279
+ * @param {string} args.repositoryId
280
+ * @param {number} args.prNumber
281
+ * @param {number} args.threadId existing ADO thread id (positive integer)
282
+ * @param {string} args.body reply body (pre-marker; builder prepends marker)
283
+ * @param {string} args.agentId
284
+ * @param {string} args.kind
285
+ * @param {string} [args.workItemId]
286
+ * @param {string} [args.model]
287
+ * @param {(string|number)} [args.status='fixed'] target status; 'active' leaves the thread unresolved
288
+ * @param {number} [args.timeoutMs=30000]
289
+ * @param {Function} [args.acquireToken]
290
+ * @param {Function} [args.fetchImpl]
291
+ * @returns {Promise<{threadId:number, commentId:(number|undefined), replied:boolean, alreadyReplied:boolean, resolved:boolean, alreadyResolved:boolean, statusApplied:(number|null)}>}
292
+ */
293
+ async function replyToAdoThread({
294
+ orgBase,
295
+ project,
296
+ repositoryId,
297
+ prNumber,
298
+ threadId,
299
+ body,
300
+ agentId,
301
+ kind,
302
+ workItemId,
303
+ model,
304
+ status = 'fixed',
305
+ timeoutMs = 30000,
306
+ acquireToken = _defaultAcquireToken,
307
+ fetchImpl,
308
+ } = {}) {
309
+ _validateOrgBase(orgBase);
310
+ _validateProject(project);
311
+ _validateRepositoryId(repositoryId);
312
+ _validatePrNumber(prNumber);
313
+ _validateThreadId(threadId);
314
+
315
+ const targetStatus = resolveAdoThreadStatus(status);
316
+
317
+ // buildMinionsCommentBody validates marker fields and throws on bad input.
318
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
319
+
320
+ const token = await acquireToken();
321
+ if (!token || typeof token !== 'string') {
322
+ throw new Error('ADO comment: could not acquire an access token (az account get-access-token failed)');
323
+ }
324
+
325
+ const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null);
326
+ if (!doFetch) throw new Error('ADO comment: no fetch implementation available');
327
+
328
+ const authHeaders = {
329
+ 'Authorization': `Bearer ${token}`,
330
+ 'Content-Type': 'application/json',
331
+ };
332
+ const threadUrl = buildThreadUrl({ orgBase, project, repositoryId, prNumber, threadId });
333
+
334
+ // ── 1. Read the current thread (status + existing comments) for idempotency ──
335
+ const getRes = await doFetch(threadUrl, {
336
+ method: 'GET',
337
+ headers: authHeaders,
338
+ signal: AbortSignal.timeout(timeoutMs),
339
+ });
340
+ if (!getRes.ok) {
341
+ const detail = await getRes.text().catch(() => '');
342
+ throw new Error(`ADO thread GET ${getRes.status} ${getRes.statusText}: ${String(detail).slice(0, 500)}`);
343
+ }
344
+ const thread = await getRes.json().catch(() => ({}));
345
+ const existingComments = Array.isArray(thread.comments) ? thread.comments : [];
346
+ const currentStatus = typeof thread.status === 'number'
347
+ ? thread.status
348
+ : resolveAdoThreadStatus(thread.status || 'active');
349
+
350
+ // Idempotency: has THIS dispatch (structured marker identity) already replied?
351
+ const alreadyReplied = existingComments.some((c) => {
352
+ const marker = parseMinionsMarker(String(c?.content || ''));
353
+ if (!marker) return false;
354
+ if (marker.agentId !== agentId || marker.kind !== kind) return false;
355
+ // When a workItemId is supplied it is the structured finding identity — a
356
+ // match on it (or its absence on both sides) confirms this is our reply.
357
+ if (workItemId) return marker.workItemId === workItemId;
358
+ return marker.workItemId === undefined;
359
+ });
360
+
361
+ // The first comment id anchors a reply to the originating thread.
362
+ const parentCommentId = existingComments.length > 0 && Number.isInteger(existingComments[0]?.id)
363
+ ? existingComments[0].id
364
+ : 1;
365
+
366
+ let replied = false;
367
+ let commentId;
368
+ if (!alreadyReplied) {
369
+ const commentsUrl = buildThreadCommentsUrl({ orgBase, project, repositoryId, prNumber, threadId });
370
+ const postRes = await doFetch(commentsUrl, {
371
+ method: 'POST',
372
+ headers: authHeaders,
373
+ body: JSON.stringify({ parentCommentId, content: finalBody, commentType: 1 }),
374
+ signal: AbortSignal.timeout(timeoutMs),
375
+ });
376
+ if (!postRes.ok) {
377
+ const detail = await postRes.text().catch(() => '');
378
+ throw new Error(`ADO thread reply POST ${postRes.status} ${postRes.statusText}: ${String(detail).slice(0, 500)}`);
379
+ }
380
+ const postJson = await postRes.json().catch(() => ({}));
381
+ commentId = postJson && postJson.id;
382
+ replied = true;
383
+ }
384
+
385
+ // ── 2. Transition the thread status (skip when unresolved requested or the
386
+ // thread is already in the requested/equivalent resolved state) ──
387
+ const wantsResolve = targetStatus !== ADO_THREAD_STATUS_ACTIVE
388
+ && targetStatus !== ADO_THREAD_STATUS_UNKNOWN;
389
+ const alreadyResolved = wantsResolve && currentStatus === targetStatus;
390
+ let resolved = ADO_RESOLVED_THREAD_STATUSES.has(currentStatus);
391
+ let statusApplied = null;
392
+
393
+ if (wantsResolve && currentStatus !== targetStatus) {
394
+ // The reply above may already be committed. A PATCH failure here throws so
395
+ // the caller can retry; the retry skips the (now-present) reply and PATCHes
396
+ // again — local state is never marked resolved on a failed PATCH.
397
+ const patchRes = await doFetch(threadUrl, {
398
+ method: 'PATCH',
399
+ headers: authHeaders,
400
+ body: JSON.stringify({ status: targetStatus }),
401
+ signal: AbortSignal.timeout(timeoutMs),
402
+ });
403
+ if (!patchRes.ok) {
404
+ const detail = await patchRes.text().catch(() => '');
405
+ throw new Error(`ADO thread status PATCH ${patchRes.status} ${patchRes.statusText}: ${String(detail).slice(0, 500)} (reply ${replied ? 'was' : 'was not'} posted; retry is idempotent)`);
406
+ }
407
+ statusApplied = targetStatus;
408
+ resolved = true;
409
+ }
410
+
411
+ return {
412
+ threadId,
413
+ commentId,
414
+ replied,
415
+ alreadyReplied,
416
+ resolved,
417
+ alreadyResolved: !!alreadyResolved,
418
+ statusApplied,
419
+ };
420
+ }
421
+
177
422
  module.exports = {
178
423
  postAdoPrComment,
424
+ replyToAdoThread,
179
425
  buildThreadsUrl,
426
+ buildThreadUrl,
427
+ buildThreadCommentsUrl,
428
+ resolveAdoThreadStatus,
180
429
  ADO_API_VERSION,
430
+ ADO_THREAD_STATUS_UNKNOWN,
181
431
  ADO_THREAD_STATUS_ACTIVE,
432
+ ADO_THREAD_STATUS_FIXED,
433
+ ADO_THREAD_STATUS_WONT_FIX,
182
434
  ADO_THREAD_STATUS_CLOSED,
435
+ ADO_THREAD_STATUS_BY_DESIGN,
436
+ ADO_THREAD_STATUS_PENDING,
437
+ ADO_THREAD_STATUS_BY_NAME,
438
+ ADO_RESOLVED_THREAD_STATUSES,
183
439
  // Re-export the neutral builder so ADO callers have a single import surface,
184
440
  // mirroring engine/providers/gh-comment.js.
185
441
  buildMinionsCommentBody,
@@ -188,5 +444,6 @@ module.exports = {
188
444
  _validateProject,
189
445
  _validateRepositoryId,
190
446
  _validatePrNumber,
447
+ _validateThreadId,
191
448
  _defaultAcquireToken,
192
449
  };
@@ -55,6 +55,26 @@ function _emptyDailyDelta() {
55
55
  return { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0 };
56
56
  }
57
57
 
58
+ // Merge a source billableUnits map ({unit:{total,reported,unavailable}}) into a
59
+ // target object's billableUnits (W-msdcgitc). Used to fold buffered CC/doc-chat
60
+ // deltas into the persisted metrics and to re-merge on a failed flush. Generic
61
+ // over unit name so no runtime-name branch is needed.
62
+ function _mergeBillableUnits(target, source) {
63
+ if (!source || typeof source !== 'object') return;
64
+ if (!target.billableUnits || typeof target.billableUnits !== 'object') {
65
+ target.billableUnits = {};
66
+ }
67
+ for (const [unit, b] of Object.entries(source)) {
68
+ if (!b || typeof b !== 'object') continue;
69
+ const bucket = target.billableUnits[unit]
70
+ || { total: 0, reported: 0, unavailable: 0 };
71
+ bucket.total += b.total || 0;
72
+ bucket.reported += b.reported || 0;
73
+ bucket.unavailable += b.unavailable || 0;
74
+ target.billableUnits[unit] = bucket;
75
+ }
76
+ }
77
+
58
78
  function _ensureFlushTimer() {
59
79
  if (_flushTimer) return;
60
80
  const interval = shared.ENGINE_DEFAULTS.metricsFlushIntervalMs || 10000;
@@ -74,6 +94,7 @@ function trackEngineUsage(category, usage) {
74
94
  cat.outputTokens += usage.outputTokens || 0;
75
95
  cat.cacheRead += usage.cacheRead || 0;
76
96
  cat.cacheCreation += usage.cacheCreation || 0;
97
+ shared.accumulateBillableUnits(cat, usage.billable);
77
98
  if (usage.durationMs) {
78
99
  cat.totalDurationMs += usage.durationMs;
79
100
  cat.timedCalls += 1;
@@ -86,6 +107,7 @@ function trackEngineUsage(category, usage) {
86
107
  daily.inputTokens += usage.inputTokens || 0;
87
108
  daily.outputTokens += usage.outputTokens || 0;
88
109
  daily.cacheRead += usage.cacheRead || 0;
110
+ shared.accumulateBillableUnits(daily, usage.billable);
89
111
 
90
112
  _ensureFlushTimer();
91
113
  }
@@ -135,6 +157,7 @@ function flushMetricsBuffer() {
135
157
  cat.errorsByCode[code] = (cat.errorsByCode[code] || 0) + count;
136
158
  }
137
159
  }
160
+ _mergeBillableUnits(cat, delta.billableUnits);
138
161
  }
139
162
  if (!metrics._daily) metrics._daily = {};
140
163
  for (const [day, delta] of Object.entries(pending.daily)) {
@@ -146,6 +169,7 @@ function flushMetricsBuffer() {
146
169
  d.inputTokens += delta.inputTokens;
147
170
  d.outputTokens += delta.outputTokens;
148
171
  d.cacheRead += delta.cacheRead;
172
+ _mergeBillableUnits(d, delta.billableUnits);
149
173
  }
150
174
  return metrics;
151
175
  });
@@ -164,12 +188,14 @@ function flushMetricsBuffer() {
164
188
  c.errorsByCode[code] = (c.errorsByCode[code] || 0) + count;
165
189
  }
166
190
  }
191
+ _mergeBillableUnits(c, delta.billableUnits);
167
192
  }
168
193
  for (const [day, delta] of Object.entries(pending.daily)) {
169
194
  if (!_pendingMetrics.daily[day]) _pendingMetrics.daily[day] = _emptyDailyDelta();
170
195
  const d = _pendingMetrics.daily[day];
171
196
  d.costUsd += delta.costUsd; d.inputTokens += delta.inputTokens;
172
197
  d.outputTokens += delta.outputTokens; d.cacheRead += delta.cacheRead;
198
+ _mergeBillableUnits(d, delta.billableUnits);
173
199
  }
174
200
  console.error('metrics flush:', e.message);
175
201
  }
@@ -1373,7 +1373,8 @@ function _checkPlaybookSections(content, context = '') {
1373
1373
 
1374
1374
  // Lean system prompt: agent identity + rules only (~2-4KB, never grows)
1375
1375
  function buildSystemPrompt(agentId, config, project) {
1376
- const agent = config.agents[agentId] || tempAgents.get(agentId) || { name: agentId, role: 'Temporary Agent', expertise: [] };
1376
+ const agent = config.agents[agentId] || tempAgents.get(agentId)
1377
+ || { name: shared.tempAgentDisplayName(agentId), role: 'Temporary Agent', expertise: [] };
1377
1378
  const charter = safeRead(path.join(AGENTS_DIR, agentId, 'charter.md')); // returns '' for temp agents (no charter file)
1378
1379
  project = project || getProjects(config)[0] || {};
1379
1380
 
@@ -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
  };
@@ -501,9 +501,15 @@ function getMetrics() {
501
501
  const runtimeName = shared.resolveAgentCli(configuredAgents[agentId], engineConfig);
502
502
  m.runtime = runtimeName;
503
503
  try {
504
- m.costTracking = resolveRuntime(runtimeName).capabilities?.costTracking === true;
504
+ const caps = resolveRuntime(runtimeName).capabilities || {};
505
+ m.costTracking = caps.costTracking === true;
506
+ // Typed native billable unit for the display layer (W-msdcgitc). Copilot
507
+ // reports 'premiumRequests'; adapters without one default to 'usd'. The
508
+ // dashboard reads THIS, never the runtime name, to decide labels/units.
509
+ m.billableUnit = typeof caps.billableUnit === 'string' ? caps.billableUnit : 'usd';
505
510
  } catch {
506
511
  m.costTracking = false;
512
+ m.billableUnit = 'usd';
507
513
  }
508
514
  }
509
515
 
@@ -895,7 +901,21 @@ function getAgents(config) {
895
901
  const seen = new Set(roster.map(a => a.id));
896
902
  for (const d of (dispatch.active || [])) {
897
903
  if (d.agent && d.agent.startsWith('temp-') && !seen.has(d.agent)) {
898
- roster.push({ id: d.agent, name: d.agentName || d.agent, role: d.agentRole || 'Temp Agent', emoji: '\u{1F4A8}', expertise: [], _temp: true });
904
+ // Additive friendly display identity; the canonical `id` (temp-<uid>)
905
+ // stays the machine identity for routing/SQL/paths/memory. `name` falls
906
+ // back to the deterministic call sign when the dispatch record predates
907
+ // friendly naming (e.g. after a restart with no in-memory tempAgents).
908
+ const identity = shared.resolveAgentIdentity(d.agent, config);
909
+ roster.push({
910
+ id: d.agent,
911
+ name: d.agentName || identity.displayName || d.agent,
912
+ role: d.agentRole || 'Temporary Agent',
913
+ emoji: '\u{1F4A8}',
914
+ expertise: [],
915
+ _temp: true,
916
+ isTemp: true,
917
+ callSign: identity.callSign,
918
+ });
899
919
  seen.add(d.agent);
900
920
  }
901
921
  }
@@ -949,8 +969,13 @@ function getAgents(config) {
949
969
 
950
970
  const chartered = fs.existsSync(path.join(AGENTS_DIR, a.id, 'charter.md'));
951
971
  if (lastAction.length > 120) lastAction = lastAction.slice(0, 120) + '...';
972
+ // Structured display identity (canonical id stays authoritative; friendly
973
+ // name + temp flag are additive). Clients render a "Temp" badge from `isTemp`.
974
+ const isTemp = a.isTemp === true || shared.isTempAgentId(a.id);
952
975
  return {
953
976
  ...a,
977
+ isTemp,
978
+ callSign: isTemp ? (a.callSign || shared.tempAgentCallSign(a.id)) : null,
954
979
  runtime,
955
980
  model,
956
981
  displayModel: modelDisplay.model,
@@ -1151,6 +1176,28 @@ function getPullRequests(config) {
1151
1176
  const projectByName = new Map(projects.map(p => [p.name, p]));
1152
1177
  const allPrs = [];
1153
1178
 
1179
+ // W-msd8pr2300ev95e1 — resolve the CURRENT authenticated identity per provider
1180
+ // ONCE (cached) so we can stamp each PR's author-ownership tri-state for the
1181
+ // AutoFix foreign-author guardrail. STABLE identities only (GitHub canonical
1182
+ // login / numeric id; ADO id / descriptor) — never display names or emails.
1183
+ // Fails CLOSED: a missing/blank provider entry yields 'unknown'.
1184
+ const selfByProvider = {};
1185
+ try {
1186
+ const ghLogin = require('./operator-identity').resolveGithubViewerLoginStrict(config);
1187
+ if (ghLogin) selfByProvider.github = { login: ghLogin };
1188
+ } catch { /* identity resolution is best-effort; absence → unknown */ }
1189
+ try {
1190
+ const adoSelf = config?.engine?.operatorAdoIdentity;
1191
+ if (adoSelf && typeof adoSelf === 'object') {
1192
+ const entry = {};
1193
+ if (adoSelf.id) entry.id = String(adoSelf.id);
1194
+ if (adoSelf.descriptor) entry.descriptor = String(adoSelf.descriptor);
1195
+ // Deliberately NOT copying uniqueName/login: ADO login is an email, and
1196
+ // the guardrail contract forbids comparing emails.
1197
+ if (Object.keys(entry).length) selfByProvider.ado = entry;
1198
+ }
1199
+ } catch { /* best-effort */ }
1200
+
1154
1201
  // SQL is the canonical (and only) PR store after Phase 9.
1155
1202
  const store = require('../persistence/pull-requests-store');
1156
1203
  const sqlPrs = store.readAllPullRequests() || [];
@@ -1231,6 +1278,10 @@ function getPullRequests(config) {
1231
1278
  // dashboards / API consumers can render a chip without re-parsing
1232
1279
  // _noOpFixes themselves.
1233
1280
  pr._pausedCauses = shared.getPrPausedCauses(pr);
1281
+ // W-msd8pr2300ev95e1 — author-ownership tri-state for the AutoFix
1282
+ // foreign-author warning. 'self' | 'foreign' | 'unknown' (fail closed).
1283
+ try { pr._authorOwnership = shared.resolvePrAuthorOwnership(pr, selfByProvider); }
1284
+ catch { pr._authorOwnership = shared.PR_AUTHOR_OWNERSHIP.UNKNOWN; }
1234
1285
  allPrs.push(pr);
1235
1286
  }
1236
1287
  allPrs.sort((a, b) => {