@oss-autopilot/core 3.13.0 → 3.13.2

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.
@@ -3,7 +3,7 @@
3
3
  * Handles GitHub API calls, PR grouping, stats computation, and monthly chart data.
4
4
  * Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
5
5
  */
6
- import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit, CRITICAL_STATUSES } from '../core/index.js';
6
+ import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit, CRITICAL_STATUSES, applyStatusOverrides, } from '../core/index.js';
7
7
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
8
8
  import { warn } from '../core/logger.js';
9
9
  import { emptyPRCountsResult, fetchMergedPRsSince, fetchClosedPRsSince } from '../core/github-stats.js';
@@ -236,6 +236,13 @@ export async function fetchDashboardData(token) {
236
236
  // try-catch: save errors should not crash the dashboard data fetch.
237
237
  try {
238
238
  stateManager.batch(() => {
239
+ // Apply status overrides BEFORE partitioning, mirroring the daily check
240
+ // (daily.ts partitions overriddenPRs). Partitioning on raw status let a
241
+ // dormant PR with a criticality-flipping override land in a different
242
+ // partition here than on the CLI surface (#1416). Inside the batch
243
+ // because getStatusOverride may auto-clear stale overrides with a save,
244
+ // which must defer to this guarded boundary rather than throw here.
245
+ const overriddenPRs = applyStatusOverrides(prs, stateManager.getState());
239
246
  // Store new merged PRs incrementally (dedupes by URL)
240
247
  try {
241
248
  const { dropped } = stateManager.addMergedPRs(newMergedPRs);
@@ -256,20 +263,33 @@ export async function fetchDashboardData(token) {
256
263
  catch (error) {
257
264
  warn(MODULE, `Failed to store closed PRs: ${errorMessage(error)}`);
258
265
  }
259
- // Store monthly chart data (opened/merged/closed) so charts have data
266
+ // Store monthly chart data (opened/merged/closed) so charts have data.
267
+ // Analytics intentionally consume RAW prs: overrides only change status,
268
+ // never the createdAt/mergedAt dates the monthly counts key off.
260
269
  const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
261
270
  const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
262
271
  updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
272
+ // The digest keeps RAW statuses in openPRs: this digest becomes the
273
+ // server's cached/persisted rebuild source, and baking overridden
274
+ // statuses into it would make CLEARING an override (move target=auto) a
275
+ // silent no-op until the next full refresh — buildDashboardJson can
276
+ // only apply overrides that still exist, never un-apply baked ones. It
277
+ // re-applies live overrides per request before partitioning.
263
278
  const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
264
279
  // Apply shelve partitioning for display (auto-unshelve only runs in daily check).
265
280
  // Dormant PRs are treated as shelved unless they need addressing, and a
266
281
  // critical PR is never display-shelved (#1352) — mirrors the daily
267
282
  // check's CRITICAL_STATUSES auto-unshelve so headline counts agree.
283
+ // Partitioned over overriddenPRs (#1416): the shelve decision must use
284
+ // the same post-override status the CLI partitions on.
268
285
  const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
269
- const freshShelved = prs.filter((pr) => !CRITICAL_STATUSES.has(pr.status) && (shelvedUrls.has(pr.url) || pr.stalenessTier === 'dormant'));
286
+ // Single copy of the shelve predicate (#1421): isShelvedForDisplay is
287
+ // the same rule reconcileShelvePartition applies on rebuilds, so its
288
+ // regression tests now guard this line too.
289
+ const freshShelved = overriddenPRs.filter((pr) => isShelvedForDisplay(pr, shelvedUrls));
270
290
  digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
271
291
  digest.autoUnshelvedPRs = [];
272
- digest.summary.totalActivePRs = prs.length - freshShelved.length;
292
+ digest.summary.totalActivePRs = overriddenPRs.length - freshShelved.length;
273
293
  stateManager.setLastDigest(digest);
274
294
  });
275
295
  }
@@ -9,7 +9,7 @@ import * as http from 'node:http';
9
9
  import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import * as crypto from 'node:crypto';
12
- import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, } from '../core/index.js';
12
+ import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, maybeCheckpoint, } from '../core/index.js';
13
13
  import { errorMessage, ValidationError, ConcurrencyError, GistConcurrencyError } from '../core/errors.js';
14
14
  import { warn } from '../core/logger.js';
15
15
  import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
@@ -75,12 +75,23 @@ function getIssueListMtimeMs() {
75
75
  * start-up, so tests that need a specific digest should call this directly).
76
76
  */
77
77
  export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs, partialFailures) {
78
+ // Apply status overrides ONCE, before the shelve partition is derived, so
79
+ // the dashboard partitions on the same post-override status the CLI
80
+ // partitions on (#1416). This also covers overrides set AFTER the digest
81
+ // was cached (a dashboard move stores an override; the action path rebuilds
82
+ // from the cached digest). Work on a copy — the caller's cached digest must
83
+ // not accumulate per-request derivations.
84
+ const overriddenDigest = {
85
+ ...digest,
86
+ openPRs: applyStatusOverrides(digest.openPRs || [], state),
87
+ summary: { ...digest.summary },
88
+ };
78
89
  // Re-derive the shelve partition from the CURRENT state before reading it.
79
90
  // The POST /api/action path rebuilds with a cached digest whose shelvedPRs
80
91
  // predates the shelve/unshelve, so without this the SPA action appears to do
81
92
  // nothing until the next full /api/refresh.
82
- reconcileShelvePartition(digest, state);
83
- const prsByRepo = computePRsByRepo(digest, state);
93
+ reconcileShelvePartition(overriddenDigest, state);
94
+ const prsByRepo = computePRsByRepo(overriddenDigest, state);
84
95
  const topRepos = computeTopRepos(prsByRepo);
85
96
  const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
86
97
  // Derive from state if not provided (e.g. initial load from cached state)
@@ -92,7 +103,7 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
92
103
  const isAboveMinStars = (pr) => !isBelowMinStars(repoScores[pr.repo]?.stargazersCount, minStars);
93
104
  const filteredMergedPRs = mergedPRs.filter(isAboveMinStars);
94
105
  const filteredClosedPRs = closedPRs.filter(isAboveMinStars);
95
- const stats = buildDashboardStats(digest, state, filteredMergedPRs.length, filteredClosedPRs.length);
106
+ const stats = buildDashboardStats(overriddenDigest, state, filteredMergedPRs.length, filteredClosedPRs.length);
96
107
  const dismissedIssues = state.config.dismissedIssues || {};
97
108
  const issueResponses = commentedIssues.filter((i) => i.status === 'new_response' && !(i.url in dismissedIssues));
98
109
  // Build repo metadata map from repoScores — omit repos without stars or language to avoid empty entries
@@ -115,7 +126,10 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
115
126
  monthlyClosed,
116
127
  // #1352: stamp the unified attention bucket so the SPA renders the same
117
128
  // taxonomy the CLI brief counts (single classifier, no second opinion).
118
- activePRs: applyStatusOverrides(digest.openPRs || [], state).map((pr) => ({
129
+ // Overrides were already applied when overriddenDigest was built — a
130
+ // second application here would be a no-op but obscures the single
131
+ // apply-then-partition ordering (#1416).
132
+ activePRs: (overriddenDigest.openPRs || []).map((pr) => ({
119
133
  ...pr,
120
134
  attentionBucket: classifyAttentionBucket(pr),
121
135
  })),
@@ -123,10 +137,10 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
123
137
  // and dormant-non-addressing PRs auto-shelved for display). Returning
124
138
  // only state.config.shelvedPRUrls would under-count and desync from
125
139
  // stats.shelvedPRs, which is already derived from digest.shelvedPRs. (#981)
126
- shelvedPRUrls: (digest.shelvedPRs || []).map((ref) => ref.url),
127
- recentlyMergedPRs: digest.recentlyMergedPRs || [],
128
- recentlyClosedPRs: digest.recentlyClosedPRs || [],
129
- autoUnshelvedPRs: digest.autoUnshelvedPRs || [],
140
+ shelvedPRUrls: (overriddenDigest.shelvedPRs || []).map((ref) => ref.url),
141
+ recentlyMergedPRs: overriddenDigest.recentlyMergedPRs || [],
142
+ recentlyClosedPRs: overriddenDigest.recentlyClosedPRs || [],
143
+ autoUnshelvedPRs: overriddenDigest.autoUnshelvedPRs || [],
130
144
  commentedIssues,
131
145
  issueResponses,
132
146
  allMergedPRs: filteredMergedPRs,
@@ -296,11 +310,46 @@ export async function startDashboardServer(options) {
296
310
  // not disappear when /api/data rebuilds after a state change or after a
297
311
  // POST /api/action completes.
298
312
  let cachedPartialFailures = undefined;
313
+ // Gist checkpoint warnings from dashboard mutations (#1417). DELIBERATELY
314
+ // separate from cachedPartialFailures: fetch failures clear on a successful
315
+ // PULL, but a gist-sync warning means an un-pushed mutation, and a pull is
316
+ // exactly the event that can destroy it (refreshFromGist wholesale-replaces
317
+ // state). These clear only when a checkpoint PUSH succeeds.
318
+ let pendingGistSyncWarnings = [];
299
319
  // Tracks the last background-refresh failure so /api/data can surface
300
320
  // staleness to the SPA via the X-Dashboard-Stale header (#1205). Cleared
301
321
  // when a refresh succeeds. Without this, token expiry / GitHub outage
302
322
  // produces silent stale data hours old with no client-visible signal.
303
323
  let lastBackgroundRefreshError = null;
324
+ /** Record a mutation's checkpoint outcome. `null` means the push succeeded
325
+ * (or local mode) — and a successful push carries the FULL current state,
326
+ * so any previously pending warning is resolved with it. */
327
+ function recordGistSyncOutcome(warning) {
328
+ if (warning === null) {
329
+ pendingGistSyncWarnings = [];
330
+ return;
331
+ }
332
+ if (!pendingGistSyncWarnings.includes(warning)) {
333
+ pendingGistSyncWarnings.push(warning);
334
+ }
335
+ }
336
+ /** Merge pending gist-sync warnings into a partialFailures payload for the
337
+ * SPA banner without coupling their lifecycles. */
338
+ function withPendingGistWarnings(failures) {
339
+ if (pendingGistSyncWarnings.length === 0)
340
+ return failures;
341
+ const base = failures ?? [];
342
+ return [...base, ...pendingGistSyncWarnings.filter((w) => !base.includes(w))];
343
+ }
344
+ /** Push-before-pull (#1417): an un-pushed mutation would be silently
345
+ * reverted by the next Gist pull. Retry the checkpoint first so a recovered
346
+ * network turns the pending warning into a real push before any pull runs.
347
+ * No-op when nothing is pending. */
348
+ async function flushPendingGistSync() {
349
+ if (pendingGistSyncWarnings.length === 0)
350
+ return;
351
+ recordGistSyncOutcome(await maybeCheckpoint(stateManager, MODULE));
352
+ }
304
353
  if (!cachedDigest) {
305
354
  throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
306
355
  }
@@ -308,7 +357,7 @@ export async function startDashboardServer(options) {
308
357
  let cachedJsonData;
309
358
  let cachedIssueListMtimeMs = getIssueListMtimeMs();
310
359
  try {
311
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
360
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
312
361
  }
313
362
  catch (error) {
314
363
  throw new Error(`Failed to build dashboard data: ${errorMessage(error)}. State data may be corrupted — try running: daily --json`, { cause: error });
@@ -343,6 +392,7 @@ export async function startDashboardServer(options) {
343
392
  // Re-read state if modified externally (file mtime for local, Gist API for Gist mode)
344
393
  let stateChanged = false;
345
394
  if (stateManager.isGistMode()) {
395
+ await flushPendingGistSync();
346
396
  stateChanged = await stateManager.refreshFromGist();
347
397
  }
348
398
  else {
@@ -353,7 +403,7 @@ export async function startDashboardServer(options) {
353
403
  const issueListChanged = currentIssueListMtimeMs !== cachedIssueListMtimeMs;
354
404
  if (stateChanged || issueListChanged) {
355
405
  try {
356
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
406
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
357
407
  cachedIssueListMtimeMs = currentIssueListMtimeMs;
358
408
  }
359
409
  catch (error) {
@@ -432,6 +482,7 @@ export async function startDashboardServer(options) {
432
482
  /** Re-read state written by external processes (CLI) before mutating. */
433
483
  async function reloadState() {
434
484
  if (stateManager.isGistMode()) {
485
+ await flushPendingGistSync();
435
486
  await stateManager.refreshFromGist();
436
487
  }
437
488
  else {
@@ -475,6 +526,11 @@ export async function startDashboardServer(options) {
475
526
  }
476
527
  // Resolve the mutation up-front so target validation happens before the
477
528
  // state reload — keeps the reload-to-save freshness window minimal.
529
+ // Each mutation records the Gist checkpoint outcome (#1417): in gist mode
530
+ // `save()` only writes the local cache, and an un-pushed mutation can be
531
+ // wholesale-reverted by the next successful refreshFromGist — so a failed
532
+ // push must reach the SPA, not vanish into a discarded return value.
533
+ let gistSyncWarning = null;
478
534
  let applyMutation;
479
535
  if (body.action === 'move') {
480
536
  const { VALID_TARGETS, runMove } = await import('./move.js');
@@ -484,13 +540,18 @@ export async function startDashboardServer(options) {
484
540
  }
485
541
  const target = body.target;
486
542
  applyMutation = async () => {
487
- await runMove({ prUrl: body.url, target });
543
+ const output = await runMove({ prUrl: body.url, target });
544
+ gistSyncWarning = output.gistSyncWarning ?? null;
488
545
  };
489
546
  }
490
547
  else {
491
548
  // dismiss_issue_response
492
- applyMutation = () => {
549
+ applyMutation = async () => {
493
550
  stateManager.dismissIssue(body.url, new Date().toISOString());
551
+ // Mirror runMove's contract: every mutating surface checkpoints to
552
+ // Gist and surfaces the warning. Never throws — failures come back
553
+ // as the warning string.
554
+ gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
494
555
  };
495
556
  }
496
557
  // Reload state before mutating to avoid overwriting external CLI changes.
@@ -530,11 +591,16 @@ export async function startDashboardServer(options) {
530
591
  return;
531
592
  }
532
593
  }
594
+ // Surface the checkpoint outcome through the partialFailures banner the
595
+ // SPA already renders (#1417). Tracked in pendingGistSyncWarnings — NOT
596
+ // cachedPartialFailures — because gist warnings clear on a successful
597
+ // PUSH, while fetch failures clear on a successful pull/refresh.
598
+ recordGistSyncOutcome(gistSyncWarning);
533
599
  // Rebuild dashboard data from cached digest + updated state. Persist
534
600
  // the last-known partialFailures across action rebuilds (#1035) so the
535
601
  // SPA banner survives user interactions until the next successful
536
602
  // refresh clears it.
537
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
603
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
538
604
  cachedIssueListMtimeMs = getIssueListMtimeMs();
539
605
  sendJson(res, 200, cachedJsonData);
540
606
  }
@@ -554,7 +620,7 @@ export async function startDashboardServer(options) {
554
620
  // Update the persistent banner signal — clear on a clean refresh,
555
621
  // set when one or more sub-fetches degraded. See #1035.
556
622
  cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
557
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
623
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, withPendingGistWarnings(cachedPartialFailures));
558
624
  cachedIssueListMtimeMs = getIssueListMtimeMs();
559
625
  sendJson(res, 200, cachedJsonData);
560
626
  }
@@ -675,6 +741,7 @@ export async function startDashboardServer(options) {
675
741
  fetchDashboardData(token)
676
742
  .then(async (result) => {
677
743
  if (stateManager.isGistMode()) {
744
+ await flushPendingGistSync();
678
745
  await stateManager.refreshFromGist();
679
746
  }
680
747
  else {
@@ -683,7 +750,7 @@ export async function startDashboardServer(options) {
683
750
  cachedDigest = result.digest;
684
751
  cachedCommentedIssues = result.commentedIssues;
685
752
  cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
686
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
753
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, withPendingGistWarnings(cachedPartialFailures));
687
754
  cachedIssueListMtimeMs = getIssueListMtimeMs();
688
755
  // Successful refresh clears any prior failure signal (#1205).
689
756
  lastBackgroundRefreshError = null;
@@ -52,7 +52,11 @@ function toFeaturesCandidate(scoutCandidate, getState) {
52
52
  // this repo" rather than a fabricated score.
53
53
  const grade = gradeFromCandidate({
54
54
  repo: scoutCandidate.issue.repo,
55
- projectHealth: { avgIssueResponseDays: null, daysSinceLastCommit: null, checkFailed: true },
55
+ projectHealth: {
56
+ repo: scoutCandidate.issue.repo,
57
+ checkFailed: true,
58
+ failureReason: 'health not fetched on the multi-issue feature surface',
59
+ },
56
60
  getRepoScore: (repo) => {
57
61
  const score = getState.getRepoScore(repo);
58
62
  return score
@@ -184,10 +184,20 @@ export async function runListMoveTier(options) {
184
184
  'Add the entry to the list first, then re-run list-move-tier.');
185
185
  }
186
186
  if (result.moved) {
187
+ // tmp+rename so a crash mid-write can't truncate the curated list —
188
+ // same atomic pattern as list-mark-done (#1421).
189
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
187
190
  try {
188
- fs.writeFileSync(filePath, result.content, 'utf8');
191
+ fs.writeFileSync(tmp, result.content, 'utf8');
192
+ fs.renameSync(tmp, filePath);
189
193
  }
190
194
  catch (error) {
195
+ try {
196
+ fs.unlinkSync(tmp);
197
+ }
198
+ catch {
199
+ // best-effort cleanup
200
+ }
191
201
  throw new Error(`Failed to write file: ${errorMessage(error)}`, { cause: error });
192
202
  }
193
203
  }
@@ -90,8 +90,20 @@ export function buildScoutState() {
90
90
  // `--split-ratio` flags for overrides.
91
91
  featuresAnchorThreshold: 3,
92
92
  featuresSplitRatio: 0.6,
93
+ // Personalization-bias prefs, likewise required on the inferred type
94
+ // (scout #1244 / #168). Autopilot doesn't surface these as settings yet,
95
+ // so we pass scout's documented "no bias" defaults.
96
+ preferLanguages: [],
97
+ preferRepos: [],
98
+ diversityRatio: 0,
99
+ avoidRepos: [],
100
+ boostIssueTypes: [],
93
101
  },
94
102
  repoScores: state.repoScores,
103
+ // Scout #117 added tombstones (deletion records for gist merge). Autopilot
104
+ // synthesizes a fresh ScoutState per operation and tracks no deletions, so
105
+ // an empty list is correct here.
106
+ tombstones: [],
95
107
  starredRepos: config.starredRepos,
96
108
  starredReposLastFetched: config.starredReposLastFetched,
97
109
  mergedPRs: (state.mergedPRs ?? []).map((pr) => ({
@@ -97,7 +97,11 @@ export async function runSearch(options) {
97
97
  // before" rather than a fabricated score.
98
98
  const grade = gradeFromCandidate({
99
99
  repo: c.issue.repo,
100
- projectHealth: { avgIssueResponseDays: null, daysSinceLastCommit: null, checkFailed: true },
100
+ projectHealth: {
101
+ repo: c.issue.repo,
102
+ checkFailed: true,
103
+ failureReason: 'health not fetched on the multi-issue search surface',
104
+ },
101
105
  getRepoScore: (repo) => {
102
106
  const score = stateManager.getRepoScore(repo);
103
107
  return score
@@ -135,9 +139,14 @@ export async function runSearch(options) {
135
139
  }
136
140
  : undefined,
137
141
  ...(linkedPR ? { linkedPR } : {}),
138
- ...(typeof c.boostScore === 'number' ? { boostScore: c.boostScore } : {}),
139
- ...(c.boostReasons && c.boostReasons.length > 0 ? { boostReasons: c.boostReasons } : {}),
140
- ...(c.diversitySlot === true ? { diversitySlot: true } : {}),
142
+ // Scout 1.0 folded the boostScore/boostReasons/diversitySlot trio into a
143
+ // single `personalization` field (#158). Derive the flat output fields
144
+ // from it so this command's JSON shape is unchanged.
145
+ ...(c.personalization?.kind === 'boosted' ? { boostScore: c.personalization.score } : {}),
146
+ ...(c.personalization?.kind === 'boosted' && c.personalization.reasons.length > 0
147
+ ? { boostReasons: c.personalization.reasons }
148
+ : {}),
149
+ ...(c.personalization?.kind === 'diversity' ? { diversitySlot: true } : {}),
141
150
  };
142
151
  }),
143
152
  excludedRepos: result.excludedRepos,
package/dist/core/auth.js CHANGED
@@ -13,6 +13,22 @@ const MODULE = 'auth';
13
13
  // Cached GitHub token (fetched once per session)
14
14
  let cachedGitHubToken = null;
15
15
  let tokenFetchAttempted = false;
16
+ /**
17
+ * A `gh auth token` spawn that timed out is transient (#1415): the CLI is
18
+ * installed and may answer on the next call (slow disk, machine waking,
19
+ * momentary load). Everything else is definitive for the process lifetime —
20
+ * ENOENT (gh not installed) and a non-zero exit (not authenticated) will not
21
+ * change without user action, so those still latch `tokenFetchAttempted`.
22
+ * The async execFile timeout surfaces as `killed: true` (signal SIGTERM);
23
+ * the sync execFileSync timeout surfaces as `code: 'ETIMEDOUT'` (no
24
+ * `killed` property) — both arms are load-bearing, one per path.
25
+ */
26
+ function isTransientTokenFetchError(err) {
27
+ if (!err || typeof err !== 'object')
28
+ return false;
29
+ const e = err;
30
+ return e.killed === true || e.code === 'ETIMEDOUT';
31
+ }
16
32
  /**
17
33
  * Retrieves a GitHub authentication token, checking sources in priority order.
18
34
  *
@@ -29,7 +45,6 @@ export function getGitHubToken() {
29
45
  if (tokenFetchAttempted) {
30
46
  return null;
31
47
  }
32
- tokenFetchAttempted = true;
33
48
  if (process.env.GITHUB_TOKEN) {
34
49
  cachedGitHubToken = process.env.GITHUB_TOKEN;
35
50
  return cachedGitHubToken;
@@ -40,6 +55,8 @@ export function getGitHubToken() {
40
55
  stdio: ['pipe', 'pipe', 'pipe'],
41
56
  timeout: 2000,
42
57
  }).trim();
58
+ // Definitive outcome (a token, or authoritatively none) — latch.
59
+ tokenFetchAttempted = true;
43
60
  if (token && token.length > 0) {
44
61
  cachedGitHubToken = token;
45
62
  debug(MODULE, 'Using GitHub token from gh CLI');
@@ -47,9 +64,14 @@ export function getGitHubToken() {
47
64
  }
48
65
  }
49
66
  catch (err) {
50
- // Promote to warn-once-per-session so a slow `gh` (2s timeout) or a
51
- // misconfigured CLI is visible without DEBUG=1 (#1209 L6). The
52
- // tokenFetchAttempted cache means subsequent calls don't re-warn.
67
+ // Latch only on definitive failures (#1415): a timeout must not
68
+ // permanently disable token fetch for a long-lived process (the MCP
69
+ // server), or gist-mode mutations silently degrade to local-only for
70
+ // the process lifetime. Promote to warn so a slow `gh` (2s timeout) or
71
+ // a misconfigured CLI is visible without DEBUG=1 (#1209 L6).
72
+ if (!isTransientTokenFetchError(err)) {
73
+ tokenFetchAttempted = true;
74
+ }
53
75
  warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
54
76
  }
55
77
  return null;
@@ -97,7 +119,6 @@ export async function getGitHubTokenAsync() {
97
119
  if (tokenFetchAttempted) {
98
120
  return null;
99
121
  }
100
- tokenFetchAttempted = true;
101
122
  if (process.env.GITHUB_TOKEN) {
102
123
  cachedGitHubToken = process.env.GITHUB_TOKEN;
103
124
  return cachedGitHubToken;
@@ -113,6 +134,8 @@ export async function getGitHubTokenAsync() {
113
134
  }
114
135
  });
115
136
  });
137
+ // Definitive outcome (a token, or authoritatively none) — latch.
138
+ tokenFetchAttempted = true;
116
139
  if (token && token.length > 0) {
117
140
  cachedGitHubToken = token;
118
141
  debug(MODULE, 'Using GitHub token from gh CLI (async)');
@@ -120,7 +143,11 @@ export async function getGitHubTokenAsync() {
120
143
  }
121
144
  }
122
145
  catch (err) {
123
- // Same warn-once promotion as the sync version (#1209 L6).
146
+ // Same transient/definitive split and warn promotion as the sync
147
+ // version (#1415, #1209 L6).
148
+ if (!isTransientTokenFetchError(err)) {
149
+ tokenFetchAttempted = true;
150
+ }
124
151
  warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
125
152
  }
126
153
  return null;
@@ -2,13 +2,13 @@
2
2
  * Core module exports
3
3
  * Re-exports all core functionality for convenient imports
4
4
  */
5
- export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
5
+ export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, type GistPersistenceStatus, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
6
6
  export { GistStateStore } from './gist-state-store.js';
7
7
  export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX, GUIDELINES_MAX_BYTES, GuidelinesNotAvailableError, GuidelinesTooLargeError, } from './guidelines-store.js';
8
8
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
9
9
  export { IssueConversationMonitor } from './issue-conversation.js';
10
10
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
11
- export { wrapUntrustedContent, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, type UntrustedContentMeta, } from './untrusted-content.js';
11
+ export { wrapUntrustedContent, fenceFetchedPR, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, type UntrustedContentMeta, } from './untrusted-content.js';
12
12
  export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
13
13
  export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
14
14
  export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
@@ -9,7 +9,7 @@ export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks,
9
9
  // Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
10
10
  export { IssueConversationMonitor } from './issue-conversation.js';
11
11
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
12
- export { wrapUntrustedContent, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, } from './untrusted-content.js';
12
+ export { wrapUntrustedContent, fenceFetchedPR, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, } from './untrusted-content.js';
13
13
  export { getOctokit, checkRateLimit } from './github.js';
14
14
  export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
15
15
  export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
@@ -10,6 +10,7 @@
10
10
  * treated as a risk, not ignored). This matches the policy previously
11
11
  * described as prose in agents/issue-scout.md.
12
12
  */
13
+ import type { ProjectHealth } from '@oss-scout/core';
13
14
  export type GradeLetter = 'A' | 'B' | 'C' | 'F';
14
15
  export interface GradeSignals {
15
16
  /** Average maintainer response time in days; null if unknown. */
@@ -61,11 +62,7 @@ export declare function deriveGradeSignals(params: {
61
62
  */
62
63
  export declare function gradeFromCandidate(params: {
63
64
  repo: string;
64
- projectHealth: {
65
- avgIssueResponseDays: number | null;
66
- daysSinceLastCommit: number | null;
67
- checkFailed?: boolean;
68
- };
65
+ projectHealth: ProjectHealth;
69
66
  getRepoScore: (repo: string) => {
70
67
  mergedPRCount: number;
71
68
  closedWithoutMergeCount: number;
@@ -103,8 +103,19 @@ export function deriveGradeSignals(params) {
103
103
  */
104
104
  export function gradeFromCandidate(params) {
105
105
  const repoScore = params.getRepoScore(params.repo);
106
+ // Narrow the union into the minimal shape deriveGradeSignals expects. The
107
+ // check-failed arm has no avgIssueResponseDays/daysSinceLastCommit, and the
108
+ // grader already treats checkFailed as untrusted (no snapshot signals).
109
+ const ph = params.projectHealth;
110
+ const projectHealth = ph.checkFailed
111
+ ? { avgIssueResponseDays: null, daysSinceLastCommit: null, checkFailed: true }
112
+ : {
113
+ avgIssueResponseDays: ph.avgIssueResponseDays,
114
+ daysSinceLastCommit: ph.daysSinceLastCommit,
115
+ checkFailed: false,
116
+ };
106
117
  return computeSuccessGrade(deriveGradeSignals({
107
- projectHealth: params.projectHealth,
118
+ projectHealth,
108
119
  repoScore: repoScore
109
120
  ? {
110
121
  mergedPRCount: repoScore.mergedPRCount,
@@ -441,7 +441,21 @@ export declare function getStateManagerAsync(token?: string): Promise<StateManag
441
441
  * // CLI bootstrap
442
442
  * await ensureGistPersistence(token);
443
443
  */
444
- export declare function ensureGistPersistence(token: string | null): Promise<void>;
444
+ export type GistPersistenceStatus =
445
+ /** No state file yet, or config does not request gist mode. */
446
+ 'local-mode'
447
+ /** The state file exists but could not be read/parsed for this attempt
448
+ * (permissions, transient FS error, corrupt JSON). Callers must NOT
449
+ * memoize this as "local mode chosen" — a later attempt may succeed. */
450
+ | 'state-unreadable'
451
+ /** Gist mode is configured but no token was available for this attempt. */
452
+ | 'no-token'
453
+ /** Gist mode active: the singleton is gist-backed. */
454
+ | 'gist'
455
+ /** Gist mode is configured and a token was available, but init fell back
456
+ * to local-only (transient network failure). A later call may recover. */
457
+ | 'degraded';
458
+ export declare function ensureGistPersistence(token: string | null): Promise<GistPersistenceStatus>;
445
459
  /**
446
460
  * Reset the singleton StateManager instance to null. Intended for test isolation.
447
461
  */