@oss-autopilot/core 3.13.4 → 3.14.1

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 (88) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.js +59 -84
  3. package/dist/cli.bundle.cjs +112 -109
  4. package/dist/cli.js +5 -4
  5. package/dist/commands/comments.js +44 -10
  6. package/dist/commands/config.d.ts +2 -0
  7. package/dist/commands/config.js +50 -2
  8. package/dist/commands/curated-list.d.ts +17 -0
  9. package/dist/commands/curated-list.js +25 -0
  10. package/dist/commands/daily.d.ts +7 -1
  11. package/dist/commands/daily.js +136 -57
  12. package/dist/commands/dashboard-cache.d.ts +69 -0
  13. package/dist/commands/dashboard-cache.js +219 -0
  14. package/dist/commands/dashboard-data.d.ts +18 -10
  15. package/dist/commands/dashboard-data.js +58 -8
  16. package/dist/commands/dashboard-gist-sync.d.ts +93 -0
  17. package/dist/commands/dashboard-gist-sync.js +237 -0
  18. package/dist/commands/dashboard-server.d.ts +6 -10
  19. package/dist/commands/dashboard-server.js +181 -347
  20. package/dist/commands/features.js +6 -0
  21. package/dist/commands/guidelines.d.ts +6 -0
  22. package/dist/commands/guidelines.js +7 -0
  23. package/dist/commands/index.d.ts +2 -5
  24. package/dist/commands/index.js +2 -4
  25. package/dist/commands/init.d.ts +2 -0
  26. package/dist/commands/init.js +7 -1
  27. package/dist/commands/list-mark-done.js +6 -21
  28. package/dist/commands/list-move-tier.js +3 -5
  29. package/dist/commands/locate-issue-list.d.ts +25 -0
  30. package/dist/commands/locate-issue-list.js +67 -0
  31. package/dist/commands/merge-loop.d.ts +63 -0
  32. package/dist/commands/merge-loop.js +157 -0
  33. package/dist/commands/repo-vet.js +40 -1
  34. package/dist/commands/scout-bridge.d.ts +35 -2
  35. package/dist/commands/scout-bridge.js +65 -13
  36. package/dist/commands/search.d.ts +4 -6
  37. package/dist/commands/search.js +58 -11
  38. package/dist/commands/setup.d.ts +2 -0
  39. package/dist/commands/setup.js +56 -2
  40. package/dist/commands/skip-file-parser.d.ts +23 -0
  41. package/dist/commands/skip-file-parser.js +23 -10
  42. package/dist/commands/startup.d.ts +1 -6
  43. package/dist/commands/startup.js +25 -59
  44. package/dist/commands/track.d.ts +2 -2
  45. package/dist/commands/track.js +2 -2
  46. package/dist/commands/vet-list.d.ts +6 -6
  47. package/dist/commands/vet-list.js +194 -65
  48. package/dist/core/config-registry.js +36 -0
  49. package/dist/core/daily-logic.d.ts +25 -2
  50. package/dist/core/daily-logic.js +58 -3
  51. package/dist/core/gist-health.d.ts +81 -0
  52. package/dist/core/gist-health.js +39 -0
  53. package/dist/core/gist-state-store.d.ts +3 -1
  54. package/dist/core/gist-state-store.js +7 -2
  55. package/dist/core/github-stats.d.ts +2 -2
  56. package/dist/core/github-stats.js +20 -4
  57. package/dist/core/index.d.ts +5 -4
  58. package/dist/core/index.js +5 -4
  59. package/dist/core/issue-conversation.js +8 -2
  60. package/dist/core/issue-grading.d.ts +9 -0
  61. package/dist/core/issue-grading.js +9 -0
  62. package/dist/core/issue-verification.d.ts +39 -0
  63. package/dist/core/issue-verification.js +48 -0
  64. package/dist/core/pagination.d.ts +27 -0
  65. package/dist/core/pagination.js +23 -5
  66. package/dist/core/pr-comments-fetcher.d.ts +7 -0
  67. package/dist/core/pr-comments-fetcher.js +19 -8
  68. package/dist/core/pr-monitor.d.ts +2 -0
  69. package/dist/core/pr-monitor.js +26 -9
  70. package/dist/core/repo-score-manager.d.ts +2 -2
  71. package/dist/core/repo-score-manager.js +3 -3
  72. package/dist/core/repo-vet.d.ts +2 -2
  73. package/dist/core/repo-vet.js +1 -1
  74. package/dist/core/review-analysis.d.ts +19 -0
  75. package/dist/core/review-analysis.js +28 -0
  76. package/dist/core/state-schema.d.ts +43 -6
  77. package/dist/core/state-schema.js +81 -4
  78. package/dist/core/state.d.ts +36 -5
  79. package/dist/core/state.js +177 -28
  80. package/dist/core/strategy.js +6 -5
  81. package/dist/core/types.d.ts +8 -0
  82. package/dist/core/untrusted-content.d.ts +45 -0
  83. package/dist/core/untrusted-content.js +54 -0
  84. package/dist/formatters/json.d.ts +120 -12
  85. package/dist/formatters/json.js +55 -2
  86. package/package.json +2 -2
  87. package/dist/commands/shelve.d.ts +0 -45
  88. package/dist/commands/shelve.js +0 -54
@@ -6,15 +6,15 @@
6
6
  * Domain logic lives in src/core/daily-logic.ts; this file is a thin
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
- import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, summarizeAttentionBuckets, } from '../core/index.js';
9
+ import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, renderGistWarning, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, summarizeAttentionBuckets, } from '../core/index.js';
10
10
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
11
11
  import { wrapUntrustedContent } from '../core/untrusted-content.js';
12
12
  import { computeStrategy, shouldComputeStrategy } from '../core/strategy.js';
13
13
  import { warn } from '../core/logger.js';
14
14
  import { emptyPRCountsResult } from '../core/github-stats.js';
15
- import { createAutopilotScout } from './scout-bridge.js';
16
15
  import { updateMonthlyAnalytics } from './dashboard-data.js';
17
16
  import { deduplicateDigest, compactActionableIssues, compactRepoGroups, buildStalenessWarning, } from '../formatters/json.js';
17
+ import { reconcileMergedPRsWithList } from './merge-loop.js';
18
18
  const MODULE = 'daily';
19
19
  /**
20
20
  * Record a non-fatal failure: push a structured entry into the run's warnings
@@ -45,7 +45,7 @@ export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacit
45
45
  // reuse it without crossing the commands → sibling-command boundary
46
46
  // (#1208 M7). Re-exported here for backward compatibility with anyone
47
47
  // importing from this module.
48
- import { buildStarFilter } from '../core/daily-logic.js';
48
+ import { buildStarFilter, firstMaintainerResponseFromDigest } from '../core/daily-logic.js';
49
49
  export { buildStarFilter };
50
50
  // ---------------------------------------------------------------------------
51
51
  // Phase functions
@@ -192,16 +192,17 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts, warn
192
192
  warn(MODULE, `Failed to update merged count for ${repo}: ${errorMessage(error)}`);
193
193
  }
194
194
  }
195
- if (mergedCountFailures === mergedCounts.size && mergedCounts.size > 0) {
196
- // Total failure: batch outer-catch sees nothing because the batch itself
197
- // succeeded, but every individual mutation inside threw. State may be
198
- // silently stale — surface it as a warning distinct from the outer catch.
195
+ if (mergedCountFailures > 0) {
196
+ // Partial or total failure (#1448): the batch outer-catch sees nothing
197
+ // because the batch itself succeeded, but individual mutations inside
198
+ // threw. The affected repos' scores are silently stale — surface it as
199
+ // a warning distinct from the outer catch.
199
200
  warnings.push({
200
201
  phase: 'repo-scores',
201
202
  operation: 'update merged counts',
202
- message: `All ${mergedCounts.size} merged count update(s) failed. This may indicate corrupted state.`,
203
+ message: `${mergedCountFailures} of ${mergedCounts.size} merged count update(s) failed. This may indicate corrupted state.`,
203
204
  });
204
- warn(MODULE, `[ALL_MERGED_COUNT_UPDATES_FAILED] All ${mergedCounts.size} merged count update(s) failed.`);
205
+ warn(MODULE, `[MERGED_COUNT_UPDATES_FAILED] ${mergedCountFailures} of ${mergedCounts.size} merged count update(s) failed.`);
205
206
  }
206
207
  // Populate closedWithoutMergeCount in repo scores.
207
208
  const existingReposWithClosed = Object.values(stateManager.getState().repoScores).filter((s) => (s.closedWithoutMergeCount || 0) > 0);
@@ -218,13 +219,13 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts, warn
218
219
  warn(MODULE, `Failed to update closed count for ${repo}: ${errorMessage(error)}`);
219
220
  }
220
221
  }
221
- if (closedCountFailures === closedCounts.size && closedCounts.size > 0) {
222
+ if (closedCountFailures > 0) {
222
223
  warnings.push({
223
224
  phase: 'repo-scores',
224
225
  operation: 'update closed counts',
225
- message: `All ${closedCounts.size} closed count update(s) failed. This may indicate corrupted state.`,
226
+ message: `${closedCountFailures} of ${closedCounts.size} closed count update(s) failed. This may indicate corrupted state.`,
226
227
  });
227
- warn(MODULE, `[ALL_CLOSED_COUNT_UPDATES_FAILED] All ${closedCounts.size} closed count update(s) failed.`);
228
+ warn(MODULE, `[CLOSED_COUNT_UPDATES_FAILED] ${closedCountFailures} of ${closedCounts.size} closed count update(s) failed.`);
228
229
  }
229
230
  // Update repo signals from observed open PR data
230
231
  const repoSignals = computeRepoSignals(prs);
@@ -238,13 +239,13 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts, warn
238
239
  warn(MODULE, `Failed to update signals for ${repo}: ${errorMessage(error)}`);
239
240
  }
240
241
  }
241
- if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
242
+ if (signalUpdateFailures > 0) {
242
243
  warnings.push({
243
244
  phase: 'repo-scores',
244
245
  operation: 'update repo signals',
245
- message: `All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`,
246
+ message: `${signalUpdateFailures} of ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`,
246
247
  });
247
- warn(MODULE, `[ALL_SIGNAL_UPDATES_FAILED] All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
248
+ warn(MODULE, `[SIGNAL_UPDATES_FAILED] ${signalUpdateFailures} of ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
248
249
  }
249
250
  });
250
251
  }
@@ -277,13 +278,13 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts, warn
277
278
  warn(MODULE, `Failed to update metadata for ${repo}: ${errorMessage(error)}`);
278
279
  }
279
280
  }
280
- if (metadataUpdateFailures === repoMetadata.size && repoMetadata.size > 0) {
281
+ if (metadataUpdateFailures > 0) {
281
282
  warnings.push({
282
283
  phase: 'repo-scores',
283
284
  operation: 'update repo metadata',
284
- message: `All ${repoMetadata.size} metadata update(s) failed. This may indicate corrupted state.`,
285
+ message: `${metadataUpdateFailures} of ${repoMetadata.size} metadata update(s) failed. This may indicate corrupted state.`,
285
286
  });
286
- warn(MODULE, `[ALL_METADATA_UPDATES_FAILED] All ${repoMetadata.size} metadata update(s) failed.`);
287
+ warn(MODULE, `[METADATA_UPDATES_FAILED] ${metadataUpdateFailures} of ${repoMetadata.size} metadata update(s) failed.`);
287
288
  }
288
289
  // Auto-sync trustedProjects from repos with merged PRs
289
290
  let trustSyncFailures = 0;
@@ -296,13 +297,13 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts, warn
296
297
  warn(MODULE, `Failed to sync trusted project ${repo}: ${errorMessage(error)}`);
297
298
  }
298
299
  }
299
- if (trustSyncFailures === mergedCounts.size && mergedCounts.size > 0) {
300
+ if (trustSyncFailures > 0) {
300
301
  warnings.push({
301
302
  phase: 'repo-scores',
302
303
  operation: 'sync trusted projects',
303
- message: `All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`,
304
+ message: `${trustSyncFailures} of ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`,
304
305
  });
305
- warn(MODULE, `[ALL_TRUST_SYNCS_FAILED] All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
306
+ warn(MODULE, `[TRUST_SYNCS_FAILED] ${trustSyncFailures} of ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
306
307
  }
307
308
  });
308
309
  }
@@ -320,13 +321,20 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs, warn
320
321
  // Apply dashboard/CLI status overrides before partitioning.
321
322
  // This ensures PRs reclassified in the dashboard (e.g., "Need Attention" → "Waiting")
322
323
  // are respected by the CLI pipeline.
323
- const overriddenPRs = applyStatusOverrides(prs, stateManager.getState());
324
+ // Override-application failures (#1448) mean a PR silently shows its
325
+ // un-overridden status — record each into the run's warnings[].
326
+ const overrideFailures = [];
327
+ const overriddenPRs = applyStatusOverrides(prs, stateManager.getState(), overrideFailures);
328
+ for (const failure of overrideFailures) {
329
+ recordWarning(warnings, 'partition', 'apply status override', null, failure);
330
+ }
324
331
  // Partition PRs into active vs shelved, auto-unshelving when maintainers engage
325
332
  const shelvedPRs = [];
326
333
  const autoUnshelvedPRs = [];
327
334
  const activePRs = [];
328
335
  // Wrap mutations in batch: unshelvePR calls + setLastDigest produce a single save.
329
336
  // Outer try-catch: save failure should not crash the daily check (in-memory mutations still apply).
337
+ let digest;
330
338
  try {
331
339
  stateManager.batch(() => {
332
340
  for (const pr of overriddenPRs) {
@@ -349,23 +357,49 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs, warn
349
357
  activePRs.push(pr);
350
358
  }
351
359
  }
352
- // Generate digest from override-applied PRs so status categories are correct.
360
+ // Generate the in-memory digest from override-applied PRs so daily's own
361
+ // status categories (needsAddressing/waiting partitions, counts, JSON
362
+ // output) reflect dashboard/CLI reclassifications.
353
363
  // Note: digest.openPRs contains ALL fetched PRs (including shelved).
354
364
  // We override summary fields below to reflect active-only counts.
355
- const digest = prMonitor.generateDigest(overriddenPRs, recentlyClosedPRs, recentlyMergedPRs);
365
+ digest = prMonitor.generateDigest(overriddenPRs, recentlyClosedPRs, recentlyMergedPRs);
356
366
  // Attach shelve info to digest
357
367
  digest.shelvedPRs = shelvedPRs;
358
368
  digest.autoUnshelvedPRs = autoUnshelvedPRs;
359
369
  digest.summary.totalActivePRs = activePRs.length;
360
- // Store digest in state so dashboard can render it
361
- stateManager.setLastDigest(digest);
370
+ // The PERSISTED digest keeps RAW statuses (#1445): it seeds the
371
+ // dashboard server's cached rebuild source, and applyStatusOverrides
372
+ // can only apply overrides, never un-apply baked ones — persisting the
373
+ // override-applied digest would make CLEARING an override (move
374
+ // target=auto) a silent no-op whenever the dashboard's background
375
+ // refresh fails. Mirrors the raw-digest contract in dashboard-data.ts
376
+ // (which also attaches override-derived shelve info to a raw digest).
377
+ const rawDigest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
378
+ rawDigest.shelvedPRs = shelvedPRs;
379
+ rawDigest.autoUnshelvedPRs = autoUnshelvedPRs;
380
+ rawDigest.summary.totalActivePRs = activePRs.length;
381
+ stateManager.setLastDigest(rawDigest);
362
382
  });
363
383
  }
364
384
  catch (error) {
365
385
  recordWarning(warnings, 'partition', 'persist partition state', error);
366
386
  }
367
- // Digest was created inside batch reconstruct from state
368
- const digest = stateManager.getState().lastDigest;
387
+ // If digest generation threw inside the batch, fall back to the persisted
388
+ // digest (mirrors the previous read-back-from-state behavior). The
389
+ // recordWarning call above already flagged the run as degraded.
390
+ if (digest === undefined) {
391
+ const persisted = stateManager.getState().lastDigest;
392
+ if (!persisted) {
393
+ // First-ever run with no persisted digest to fall back to: fail loudly
394
+ // with the recorded reason instead of letting Phase 5 die on an
395
+ // unrelated TypeError at `digest.summary` (#1456). Fabricating an
396
+ // empty digest here would silently report "no PRs" — dishonest.
397
+ const recorded = warnings.find((w) => w.phase === 'partition');
398
+ throw new Error(`Daily digest generation failed and no previously persisted digest exists to fall back to` +
399
+ (recorded ? `: ${recorded.message}` : '.'));
400
+ }
401
+ return { activePRs, shelvedPRs, autoUnshelvedPRs, digest: persisted };
402
+ }
369
403
  return { activePRs, shelvedPRs, autoUnshelvedPRs, digest };
370
404
  }
371
405
  /**
@@ -373,7 +407,7 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs, warn
373
407
  * Assesses capacity, filters dismissed issues, computes actionable items,
374
408
  * and assembles the action menu.
375
409
  */
376
- function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, warnings, previousLastDigestAt) {
410
+ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, warnings, previousLastDigestAt, unextractedMergeCount) {
377
411
  const stateManager = getStateManager();
378
412
  // Assess capacity from active PRs only (shelved PRs excluded)
379
413
  const capacity = assessCapacity(activePRs, stateManager.getState().config.maxActivePRs, shelvedPRs.length);
@@ -424,7 +458,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
424
458
  // the same buckets per-PR, so the headline counts cannot diverge.
425
459
  const attention = summarizeAttentionBuckets(activePRs);
426
460
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length, attention);
427
- const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues);
461
+ const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues, attention, unextractedMergeCount);
428
462
  const repoGroups = groupPRsByRepo(activePRs);
429
463
  // Periodic strategy snapshot (#1270 Step 2). Cadence-gated to fire every
430
464
  // 30 days OR after 5+ PRs merge since the last snapshot, whichever comes
@@ -513,6 +547,9 @@ export function toDailyOutput(result) {
513
547
  failures: result.failures,
514
548
  warnings: result.warnings,
515
549
  strategySummary: result.strategySummary,
550
+ // Conditional spread (not a plain assignment) so merge-free runs carry
551
+ // no `listUpdates` key at all — serialized output and goldens unchanged.
552
+ ...(result.listUpdates ? { listUpdates: result.listUpdates } : {}),
516
553
  };
517
554
  }
518
555
  /**
@@ -547,13 +584,15 @@ async function executeDailyCheckInternal(token) {
547
584
  // callgraph documents which phases can produce non-fatal warnings. See #1042.
548
585
  const warnings = [];
549
586
  // Surface gist-mode degradation in the machine-readable envelope (#1431):
550
- // a process whose config says `persistence: gist` but whose manager is
551
- // local-only (transient init fallback, or a localOnly entry point that
552
- // never bootstrapped) writes mutations that will not sync. Previously the
553
- // only signal was a stderr warn, invisible to --json consumers.
554
- const smForGistCheck = getStateManager();
555
- if (smForGistCheck.getState().config.persistence === 'gist' && !smForGistCheck.isGistMode()) {
556
- recordWarning(warnings, 'gist-init', 'Gist persistence degraded', new Error('configured for Gist but running local-only in this process; mutations will not sync until Gist init succeeds'));
587
+ // a degraded process (local-only under a gist config, or a #1443 degraded
588
+ // bootstrap) writes mutations that will not sync. Previously the only
589
+ // signal was a stderr warn, invisible to --json consumers. Predicate and
590
+ // prose both come from the one health source (#1444). When this warning
591
+ // is present, the JSON envelope suppresses its process-level
592
+ // `gistInitWarning` duplicate see `jsonSuccess` in formatters/json.ts.
593
+ const gistHealth = getStateManager().getGistHealth();
594
+ if (gistHealth.degraded) {
595
+ recordWarning(warnings, 'gist-init', 'Gist persistence degraded', null, renderGistWarning(gistHealth.degraded.cause));
557
596
  }
558
597
  // Surface Gist staleness up-front so consumers see it even if Phase 1 fails (#1193).
559
598
  const staleness = getStateManager().getStateStaleness();
@@ -574,46 +613,86 @@ async function executeDailyCheckInternal(token) {
574
613
  // Phase 3: Persist monthly analytics and store merged/closed PR history.
575
614
  // try-catch: analytics are supplementary — save failure should not crash the daily check.
576
615
  try {
616
+ // Previous run's digest — read BEFORE Phase 4 overwrites it. If a
617
+ // just-merged/closed PR was open during a prior enriched run, its
618
+ // openPRs entry carries firstMaintainerResponseAt for the ledger (#1461).
619
+ const previousDigest = getStateManager().getState().lastDigest;
577
620
  getStateManager().batch(() => {
578
- updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
621
+ const analyticsFailures = updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
622
+ // Per-metric setter failures are caught inside updateMonthlyAnalytics
623
+ // (one bad metric must not sink the others); surface each in the run's
624
+ // warnings[] instead of dropping the returned labels (#1447).
625
+ for (const failure of analyticsFailures) {
626
+ recordWarning(warnings, 'analytics', failure, null, 'persist failed');
627
+ }
579
628
  // Store recently merged/closed PRs in the persistent arrays.
580
629
  // This ensures the mergedPRs/closedPRs ledger is populated even when
581
630
  // the dashboard is never opened (which has its own fetch path).
582
- // addMergedPRs/addClosedPRs deduplicate by URL, so overlaps are safe.
631
+ // addMergedPRs/addClosedPRs deduplicate by URL (re-seen entries upgrade
632
+ // missing ledger fields in place), so overlaps are safe. openedAt comes
633
+ // free from the search result; firstMaintainerResponseAt is best-effort
634
+ // from the previous run's enriched digest — zero new API calls (#1461).
583
635
  if (recentlyMergedPRs.length > 0) {
584
- getStateManager().addMergedPRs(recentlyMergedPRs.map((pr) => ({ url: pr.url, title: pr.title, mergedAt: pr.mergedAt })));
636
+ getStateManager().addMergedPRs(recentlyMergedPRs.map((pr) => ({
637
+ url: pr.url,
638
+ title: pr.title,
639
+ mergedAt: pr.mergedAt,
640
+ openedAt: pr.openedAt,
641
+ firstMaintainerResponseAt: firstMaintainerResponseFromDigest(previousDigest, pr.url),
642
+ })));
585
643
  }
586
644
  if (recentlyClosedPRs.length > 0) {
587
- getStateManager().addClosedPRs(recentlyClosedPRs.map((pr) => ({ url: pr.url, title: pr.title, closedAt: pr.closedAt })));
645
+ getStateManager().addClosedPRs(recentlyClosedPRs.map((pr) => ({
646
+ url: pr.url,
647
+ title: pr.title,
648
+ closedAt: pr.closedAt,
649
+ openedAt: pr.openedAt,
650
+ firstMaintainerResponseAt: firstMaintainerResponseFromDigest(previousDigest, pr.url),
651
+ })));
588
652
  }
589
653
  });
590
654
  }
591
655
  catch (error) {
592
656
  recordWarning(warnings, 'analytics', 'persist monthly analytics', error);
593
657
  }
594
- // Phase 3.5: Feed merged/closed PRs to oss-scout for cross-tool state sync.
595
- if (recentlyMergedPRs.length > 0 || recentlyClosedPRs.length > 0) {
596
- try {
597
- const scout = await createAutopilotScout();
598
- for (const pr of recentlyMergedPRs) {
599
- scout.recordMergedPR({ url: pr.url, title: pr.title, mergedAt: pr.mergedAt, repo: pr.repo });
600
- }
601
- for (const pr of recentlyClosedPRs) {
602
- scout.recordClosedPR({ url: pr.url, title: pr.title, closedAt: pr.closedAt, repo: pr.repo });
603
- }
604
- await scout.checkpoint();
605
- }
606
- catch (error) {
607
- recordWarning(warnings, 'scout-sync', 'sync PR data to oss-scout', error);
608
- }
658
+ // Phase 3.5: Close the merge loop (#1463). Join detected merges against
659
+ // the curated issue list (the PR URL recorded in an entry's sub-bullets
660
+ // is the only deterministic PR→issue link that exists — see
661
+ // merge-loop.ts) and auto-mark matching entries done. Safe to auto-run:
662
+ // the PR is MERGED per GitHub, and already-marked entries are quiet
663
+ // no-ops. Failures land in warnings[] via the shared collector.
664
+ const listUpdates = reconcileMergedPRsWithList({ mergedPRs: recentlyMergedPRs, warnings });
665
+ // Count freshly-merged PRs whose ledger entry has no learnings extraction
666
+ // stamp yet (#867 plumbing). Drives the action menu's extract_learnings
667
+ // nudge: it stays up for the whole recently-merged window (7 days) until
668
+ // the user runs the extraction, then self-clears.
669
+ let unextractedMergeCount = 0;
670
+ try {
671
+ const ledger = getStateManager().getState().mergedPRs ?? [];
672
+ const recentUrls = new Set(recentlyMergedPRs.map((pr) => pr.url));
673
+ unextractedMergeCount = ledger.filter((pr) => recentUrls.has(pr.url) && !pr.learningsExtractedAt).length;
609
674
  }
675
+ catch (error) {
676
+ recordWarning(warnings, 'merge-loop', 'count unextracted merged PRs', error);
677
+ }
678
+ // No scout pass here (#1458): the former Phase 3.5 fed recentlyMerged/ClosedPRs
679
+ // to an oss-scout instance and called scout.checkpoint(). Both halves were dead:
680
+ // scout's recordMergedPR/recordClosedPR deduplicate by URL against the ledger
681
+ // that Phase 3 just wrote (buildScoutState maps state.mergedPRs/closedPRs into
682
+ // the scout state), so they returned before touching any repo score; and under
683
+ // persistence:'provided' checkpoint() has no gist store and no local store, so
684
+ // it persisted nothing. Cross-tool sharing happens through buildScoutState
685
+ // reading AgentState, not through pushing events at scout.
610
686
  // Capture lastDigestAt BEFORE Phase 4 overwrites it with the current run's timestamp.
611
687
  // Used by collectActionableIssues to determine which PRs are "new" (created since last digest).
612
688
  const previousLastDigestAt = getStateManager().getState().lastDigestAt;
613
689
  // Phase 4: Partition PRs, generate and save digest
614
690
  const { activePRs, shelvedPRs, digest } = partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs, warnings);
615
691
  // Phase 5: Build structured output (capacity, dismiss filter, action menu)
616
- const result = generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, warnings, previousLastDigestAt);
692
+ const result = generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, warnings, previousLastDigestAt, unextractedMergeCount);
693
+ if (listUpdates) {
694
+ result.listUpdates = listUpdates;
695
+ }
617
696
  // Checkpoint: push state to Gist if in Gist mode.
618
697
  // If getStateManagerAsync was not called before this command ran,
619
698
  // isGistMode() will be false and checkpoint is correctly skipped.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * DashboardCache — the dashboard server's cached-response state.
3
+ *
4
+ * Extracted from dashboard-server.ts (#1457). Owns the cached digest,
5
+ * commented issues, partialFailures, built JSON payload, issue-list mtime
6
+ * and the last background-refresh error, plus buildDashboardJson (the
7
+ * payload builder the cache exists to memoize). The HTTP handlers consume
8
+ * it as a collaborator; nothing here touches req/res.
9
+ */
10
+ import { type DashboardJsonData } from './dashboard-data.js';
11
+ import { type DailyDigest, type AgentState, type CommentedIssue, type MergedPR, type ClosedPR } from '../core/types.js';
12
+ /**
13
+ * Get the mtime of the vetted issue list file in ms, or null if unknown.
14
+ * Used to detect external edits and invalidate the cached dashboard payload.
15
+ */
16
+ export declare function getIssueListMtimeMs(): number | null;
17
+ /**
18
+ * Build the JSON payload that the SPA expects from GET /api/data.
19
+ *
20
+ * Exported for unit testing of response-shape concerns that the full
21
+ * handler harness can't reach (it bakes a stale cachedDigest at server
22
+ * start-up, so tests that need a specific digest should call this directly).
23
+ */
24
+ export declare function buildDashboardJson(digest: DailyDigest, state: Readonly<AgentState>, commentedIssues: CommentedIssue[], allMergedPRs?: MergedPR[], allClosedPRs?: ClosedPR[], partialFailures?: string[]): DashboardJsonData;
25
+ /** The slice of DashboardFetchResult the cache adopts after a full refresh. */
26
+ export interface DashboardFetchSnapshot {
27
+ digest: DailyDigest;
28
+ commentedIssues: CommentedIssue[];
29
+ partialFailures: string[];
30
+ }
31
+ export declare class DashboardCache {
32
+ /** Decorates a partialFailures payload with the gist-sync lifecycle's
33
+ * warnings/banners (GistSyncCoordinator.withPendingGistWarnings) on
34
+ * every rebuild — injected so the two lifecycles stay uncoupled. */
35
+ private readonly mergeWarnings;
36
+ private cachedDigest;
37
+ private cachedCommentedIssues;
38
+ private cachedPartialFailures;
39
+ private cachedJsonData;
40
+ private cachedIssueListMtimeMs;
41
+ lastBackgroundRefreshError: string | null;
42
+ constructor(initialDigest: DailyDigest,
43
+ /** Decorates a partialFailures payload with the gist-sync lifecycle's
44
+ * warnings/banners (GistSyncCoordinator.withPendingGistWarnings) on
45
+ * every rebuild — injected so the two lifecycles stay uncoupled. */
46
+ mergeWarnings: (failures: string[] | undefined) => string[] | undefined);
47
+ get digest(): DailyDigest;
48
+ get jsonData(): DashboardJsonData;
49
+ get issueListMtimeMs(): number | null;
50
+ /** Adopt a full fetchDashboardData result: replace the digest and
51
+ * commented issues unconditionally, and update the persistent banner
52
+ * signal — clear on a clean refresh, set when one or more sub-fetches
53
+ * degraded. See #1035. */
54
+ adoptFetchResult(result: DashboardFetchSnapshot): void;
55
+ /** A gist pull wholesale-replaces state, and the pulled state.lastDigest
56
+ * may be NEWER than this server's long-lived cachedDigest (another machine
57
+ * ran its daily check). Adopt it when its generatedAt is strictly newer so
58
+ * cross-machine results appear without a manual full refresh (#1446
59
+ * item 6). Unparsable timestamps compare as NaN and never adopt —
60
+ * conservative: keep what we have. */
61
+ adoptNewerPulledDigest(pulled: DailyDigest | undefined): void;
62
+ /** Rebuild the cached JSON payload from the cached digest + the given
63
+ * state, then stamp the issue-list mtime the payload reflects. Throws
64
+ * when buildDashboardJson does — on failure neither the payload nor the
65
+ * mtime is updated, so the next request retries the rebuild. `issueListMtimeMs`
66
+ * lets GET /api/data stamp the value it compared against (#924); the other
67
+ * call sites re-stat after the build. */
68
+ rebuild(state: Readonly<AgentState>, allMergedPRs?: MergedPR[], allClosedPRs?: ClosedPR[], issueListMtimeMs?: number | null): void;
69
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * DashboardCache — the dashboard server's cached-response state.
3
+ *
4
+ * Extracted from dashboard-server.ts (#1457). Owns the cached digest,
5
+ * commented issues, partialFailures, built JSON payload, issue-list mtime
6
+ * and the last background-refresh error, plus buildDashboardJson (the
7
+ * payload builder the cache exists to memoize). The HTTP handlers consume
8
+ * it as a collaborator; nothing here touches req/res.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import { getStateManager, applyStatusOverrides, classifyAttentionBucket } from '../core/index.js';
12
+ import { errorMessage } from '../core/errors.js';
13
+ import { warn } from '../core/logger.js';
14
+ import { computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, reconcileShelvePartition, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
15
+ import { detectIssueList } from './startup.js';
16
+ import { parseIssueList } from './parse-list.js';
17
+ import { isBelowMinStars, } from '../core/types.js';
18
+ const MODULE = 'dashboard-server';
19
+ /**
20
+ * Read and parse the vetted issue list file (non-fatal on failure).
21
+ *
22
+ * @param failures - Optional collector (#1448): a read/parse failure pushes a
23
+ * label so the SPA's partialFailures banner shows the panel is degraded
24
+ * rather than silently empty. "No list detected" is not a failure and
25
+ * pushes nothing.
26
+ */
27
+ function readVettedIssues(failures) {
28
+ try {
29
+ const info = detectIssueList();
30
+ if (!info)
31
+ return null;
32
+ const content = fs.readFileSync(info.path, 'utf8');
33
+ return parseIssueList(content);
34
+ }
35
+ catch (error) {
36
+ warn(MODULE, `Failed to read vetted issue list: ${errorMessage(error)}`);
37
+ failures?.push('read vetted issue list');
38
+ return null;
39
+ }
40
+ }
41
+ /**
42
+ * Get the mtime of the vetted issue list file in ms, or null if unknown.
43
+ * Used to detect external edits and invalidate the cached dashboard payload.
44
+ */
45
+ export function getIssueListMtimeMs() {
46
+ try {
47
+ const info = detectIssueList();
48
+ if (!info)
49
+ return null;
50
+ return fs.statSync(info.path).mtimeMs;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /**
57
+ * Build the JSON payload that the SPA expects from GET /api/data.
58
+ *
59
+ * Exported for unit testing of response-shape concerns that the full
60
+ * handler harness can't reach (it bakes a stale cachedDigest at server
61
+ * start-up, so tests that need a specific digest should call this directly).
62
+ */
63
+ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs, partialFailures) {
64
+ // Collect build-local failures (#1448) alongside the caller-provided ones
65
+ // so degradations detected during THIS build (vetted-list read failure,
66
+ // status-override application failure) reach the SPA's partialFailures
67
+ // banner instead of living only in stderr.
68
+ const buildFailures = [...(partialFailures ?? [])];
69
+ // Apply status overrides ONCE, before the shelve partition is derived, so
70
+ // the dashboard partitions on the same post-override status the CLI
71
+ // partitions on (#1416). This also covers overrides set AFTER the digest
72
+ // was cached (a dashboard move stores an override; the action path rebuilds
73
+ // from the cached digest). Work on a copy — the caller's cached digest must
74
+ // not accumulate per-request derivations.
75
+ const overriddenDigest = {
76
+ ...digest,
77
+ openPRs: applyStatusOverrides(digest.openPRs || [], state, buildFailures),
78
+ summary: { ...digest.summary },
79
+ };
80
+ // Re-derive the shelve partition from the CURRENT state before reading it.
81
+ // The POST /api/action path rebuilds with a cached digest whose shelvedPRs
82
+ // predates the shelve/unshelve, so without this the SPA action appears to do
83
+ // nothing until the next full /api/refresh.
84
+ reconcileShelvePartition(overriddenDigest, state);
85
+ const prsByRepo = computePRsByRepo(overriddenDigest, state);
86
+ const topRepos = computeTopRepos(prsByRepo);
87
+ const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
88
+ // Derive from state if not provided (e.g. initial load from cached state)
89
+ const mergedPRs = allMergedPRs ?? storedToMergedPRs(getStateManager().getMergedPRs());
90
+ const closedPRs = allClosedPRs ?? storedToClosedPRs(getStateManager().getClosedPRs());
91
+ // Filter out PRs from repos below the minStars threshold
92
+ const minStars = state.config.minStars ?? 50;
93
+ const repoScores = state.repoScores || {};
94
+ const isAboveMinStars = (pr) => !isBelowMinStars(repoScores[pr.repo]?.stargazersCount, minStars);
95
+ const filteredMergedPRs = mergedPRs.filter(isAboveMinStars);
96
+ const filteredClosedPRs = closedPRs.filter(isAboveMinStars);
97
+ const stats = buildDashboardStats(overriddenDigest, state, filteredMergedPRs.length, filteredClosedPRs.length);
98
+ const dismissedIssues = state.config.dismissedIssues || {};
99
+ const issueResponses = commentedIssues.filter((i) => i.status === 'new_response' && !(i.url in dismissedIssues));
100
+ // Build repo metadata map from repoScores — omit repos without stars or language to avoid empty entries
101
+ const repoMetadata = {};
102
+ for (const [repo, score] of Object.entries(repoScores)) {
103
+ if (score.stargazersCount !== undefined || score.language !== undefined) {
104
+ repoMetadata[repo] = { stars: score.stargazersCount, language: score.language };
105
+ }
106
+ }
107
+ // A vetted-list read failure reaches the SPA banner via buildFailures
108
+ // instead of leaving the panel silently empty (#1448).
109
+ const vettedIssues = readVettedIssues(buildFailures);
110
+ if (vettedIssues) {
111
+ stats.availableIssues = vettedIssues.availableCount;
112
+ }
113
+ return {
114
+ stats,
115
+ prsByRepo,
116
+ topRepos: topRepos.map(([repo, data]) => ({ repo, ...data })),
117
+ monthlyMerged,
118
+ monthlyOpened,
119
+ monthlyClosed,
120
+ // #1352: stamp the unified attention bucket so the SPA renders the same
121
+ // taxonomy the CLI brief counts (single classifier, no second opinion).
122
+ // Overrides were already applied when overriddenDigest was built — a
123
+ // second application here would be a no-op but obscures the single
124
+ // apply-then-partition ordering (#1416).
125
+ activePRs: (overriddenDigest.openPRs || []).map((pr) => ({
126
+ ...pr,
127
+ attentionBucket: classifyAttentionBucket(pr),
128
+ })),
129
+ // Source of truth is digest.shelvedPRs (union of explicitly-shelved URLs
130
+ // and dormant-non-addressing PRs auto-shelved for display). Returning
131
+ // only state.config.shelvedPRUrls would under-count and desync from
132
+ // stats.shelvedPRs, which is already derived from digest.shelvedPRs. (#981)
133
+ shelvedPRUrls: (overriddenDigest.shelvedPRs || []).map((ref) => ref.url),
134
+ recentlyMergedPRs: overriddenDigest.recentlyMergedPRs || [],
135
+ recentlyClosedPRs: overriddenDigest.recentlyClosedPRs || [],
136
+ autoUnshelvedPRs: overriddenDigest.autoUnshelvedPRs || [],
137
+ commentedIssues,
138
+ issueResponses,
139
+ allMergedPRs: filteredMergedPRs,
140
+ allClosedPRs: filteredClosedPRs,
141
+ repoMetadata,
142
+ vettedIssues,
143
+ // Dedup: the fetch path already applies status overrides into the cached
144
+ // partialFailures, and the rebuild applies them again above — the same
145
+ // failing override must not appear (and be counted) twice (#1448).
146
+ partialFailures: buildFailures.length > 0 ? [...new Set(buildFailures)] : undefined,
147
+ };
148
+ }
149
+ export class DashboardCache {
150
+ mergeWarnings;
151
+ // Start immediately with state.json data (written by the daily check that
152
+ // precedes this server launch). A background GitHub fetch refreshes the
153
+ // cache after the port is bound, so the startup poller sees us in time.
154
+ cachedDigest;
155
+ cachedCommentedIssues = [];
156
+ // Persist the last-known partialFailures across rebuild requests (#1035).
157
+ // Cleared only when a fresh fetchDashboardData returns zero failures;
158
+ // re-threaded into every buildDashboardJson call so the SPA banner does
159
+ // not disappear when /api/data rebuilds after a state change or after a
160
+ // POST /api/action completes.
161
+ cachedPartialFailures = undefined;
162
+ cachedJsonData;
163
+ cachedIssueListMtimeMs;
164
+ // Tracks the last background-refresh failure so /api/data can surface
165
+ // staleness to the SPA via the X-Dashboard-Stale header (#1205). Cleared
166
+ // when a refresh succeeds. Without this, token expiry / GitHub outage
167
+ // produces silent stale data hours old with no client-visible signal.
168
+ lastBackgroundRefreshError = null;
169
+ constructor(initialDigest,
170
+ /** Decorates a partialFailures payload with the gist-sync lifecycle's
171
+ * warnings/banners (GistSyncCoordinator.withPendingGistWarnings) on
172
+ * every rebuild — injected so the two lifecycles stay uncoupled. */
173
+ mergeWarnings) {
174
+ this.mergeWarnings = mergeWarnings;
175
+ this.cachedDigest = initialDigest;
176
+ this.cachedIssueListMtimeMs = getIssueListMtimeMs();
177
+ }
178
+ get digest() {
179
+ return this.cachedDigest;
180
+ }
181
+ get jsonData() {
182
+ return this.cachedJsonData;
183
+ }
184
+ get issueListMtimeMs() {
185
+ return this.cachedIssueListMtimeMs;
186
+ }
187
+ /** Adopt a full fetchDashboardData result: replace the digest and
188
+ * commented issues unconditionally, and update the persistent banner
189
+ * signal — clear on a clean refresh, set when one or more sub-fetches
190
+ * degraded. See #1035. */
191
+ adoptFetchResult(result) {
192
+ this.cachedDigest = result.digest;
193
+ this.cachedCommentedIssues = result.commentedIssues;
194
+ this.cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
195
+ }
196
+ /** A gist pull wholesale-replaces state, and the pulled state.lastDigest
197
+ * may be NEWER than this server's long-lived cachedDigest (another machine
198
+ * ran its daily check). Adopt it when its generatedAt is strictly newer so
199
+ * cross-machine results appear without a manual full refresh (#1446
200
+ * item 6). Unparsable timestamps compare as NaN and never adopt —
201
+ * conservative: keep what we have. */
202
+ adoptNewerPulledDigest(pulled) {
203
+ if (!pulled)
204
+ return;
205
+ if (Date.parse(pulled.generatedAt) > Date.parse(this.cachedDigest.generatedAt)) {
206
+ this.cachedDigest = pulled;
207
+ }
208
+ }
209
+ /** Rebuild the cached JSON payload from the cached digest + the given
210
+ * state, then stamp the issue-list mtime the payload reflects. Throws
211
+ * when buildDashboardJson does — on failure neither the payload nor the
212
+ * mtime is updated, so the next request retries the rebuild. `issueListMtimeMs`
213
+ * lets GET /api/data stamp the value it compared against (#924); the other
214
+ * call sites re-stat after the build. */
215
+ rebuild(state, allMergedPRs, allClosedPRs, issueListMtimeMs) {
216
+ this.cachedJsonData = buildDashboardJson(this.cachedDigest, state, this.cachedCommentedIssues, allMergedPRs, allClosedPRs, this.mergeWarnings(this.cachedPartialFailures));
217
+ this.cachedIssueListMtimeMs = issueListMtimeMs !== undefined ? issueListMtimeMs : getIssueListMtimeMs();
218
+ }
219
+ }