@oss-autopilot/core 0.54.0 → 0.55.0

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 (37) hide show
  1. package/dist/cli.bundle.cjs +63 -63
  2. package/dist/commands/comments.js +0 -1
  3. package/dist/commands/config.js +45 -5
  4. package/dist/commands/daily.js +190 -157
  5. package/dist/commands/dashboard-data.js +37 -30
  6. package/dist/commands/dashboard-server.js +0 -1
  7. package/dist/commands/dismiss.js +0 -6
  8. package/dist/commands/init.js +0 -1
  9. package/dist/commands/local-repos.js +1 -2
  10. package/dist/commands/move.js +12 -11
  11. package/dist/commands/setup.d.ts +2 -1
  12. package/dist/commands/setup.js +166 -130
  13. package/dist/commands/shelve.js +10 -10
  14. package/dist/commands/startup.js +30 -14
  15. package/dist/core/ci-analysis.d.ts +6 -0
  16. package/dist/core/ci-analysis.js +89 -12
  17. package/dist/core/daily-logic.js +24 -33
  18. package/dist/core/index.d.ts +2 -1
  19. package/dist/core/index.js +2 -1
  20. package/dist/core/issue-discovery.d.ts +7 -44
  21. package/dist/core/issue-discovery.js +83 -188
  22. package/dist/core/issue-eligibility.d.ts +35 -0
  23. package/dist/core/issue-eligibility.js +126 -0
  24. package/dist/core/issue-vetting.d.ts +6 -21
  25. package/dist/core/issue-vetting.js +15 -279
  26. package/dist/core/pr-monitor.d.ts +7 -12
  27. package/dist/core/pr-monitor.js +14 -80
  28. package/dist/core/repo-health.d.ts +24 -0
  29. package/dist/core/repo-health.js +193 -0
  30. package/dist/core/search-phases.d.ts +55 -0
  31. package/dist/core/search-phases.js +155 -0
  32. package/dist/core/state.d.ts +11 -0
  33. package/dist/core/state.js +63 -4
  34. package/dist/core/types.d.ts +8 -1
  35. package/dist/core/types.js +7 -0
  36. package/dist/formatters/json.d.ts +1 -1
  37. package/package.json +1 -1
@@ -157,7 +157,6 @@ export async function runClaim(options) {
157
157
  updatedAt: new Date().toISOString(),
158
158
  vetted: false,
159
159
  });
160
- stateManager.save();
161
160
  }
162
161
  catch (error) {
163
162
  console.error(`Warning: Comment posted on ${options.issueUrl} but failed to save to local state: ${error instanceof Error ? error.message : error}`);
@@ -3,7 +3,14 @@
3
3
  * Shows or updates configuration
4
4
  */
5
5
  import { getStateManager } from '../core/index.js';
6
+ import { ISSUE_SCOPES } from '../core/types.js';
6
7
  import { validateGitHubUsername } from './validation.js';
8
+ function validateScope(value) {
9
+ if (!ISSUE_SCOPES.includes(value)) {
10
+ throw new Error(`Invalid scope "${value}". Valid scopes: ${ISSUE_SCOPES.join(', ')}`);
11
+ }
12
+ return value;
13
+ }
7
14
  export async function runConfig(options) {
8
15
  const stateManager = getStateManager();
9
16
  const currentConfig = stateManager.getState().config;
@@ -30,6 +37,33 @@ export async function runConfig(options) {
30
37
  stateManager.updateConfig({ labels: [...currentConfig.labels, value] });
31
38
  }
32
39
  break;
40
+ case 'remove-label':
41
+ if (!currentConfig.labels.includes(value)) {
42
+ throw new Error(`Label "${value}" is not currently configured. Current labels: ${currentConfig.labels.join(', ')}`);
43
+ }
44
+ stateManager.updateConfig({ labels: currentConfig.labels.filter((l) => l !== value) });
45
+ break;
46
+ case 'add-scope': {
47
+ const scope = validateScope(value);
48
+ const currentScopes = currentConfig.scope ?? [];
49
+ if (!currentScopes.includes(scope)) {
50
+ stateManager.updateConfig({ scope: [...currentScopes, scope] });
51
+ }
52
+ break;
53
+ }
54
+ case 'remove-scope': {
55
+ const scope = validateScope(value);
56
+ const existingScopes = currentConfig.scope ?? [];
57
+ if (!existingScopes.includes(scope)) {
58
+ throw new Error(`Scope "${value}" is not currently set`);
59
+ }
60
+ const filtered = existingScopes.filter((s) => s !== scope);
61
+ if (filtered.length === 0) {
62
+ throw new Error('Cannot remove the last scope. Use setup to clear scopes entirely.');
63
+ }
64
+ stateManager.updateConfig({ scope: filtered });
65
+ break;
66
+ }
33
67
  case 'exclude-repo': {
34
68
  const parts = value.split('/');
35
69
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
@@ -37,8 +71,10 @@ export async function runConfig(options) {
37
71
  }
38
72
  const valueLower = value.toLowerCase();
39
73
  if (!currentConfig.excludeRepos.some((r) => r.toLowerCase() === valueLower)) {
40
- stateManager.updateConfig({ excludeRepos: [...currentConfig.excludeRepos, value] });
41
- stateManager.cleanupExcludedData([value], []);
74
+ stateManager.batch(() => {
75
+ stateManager.updateConfig({ excludeRepos: [...currentConfig.excludeRepos, value] });
76
+ stateManager.cleanupExcludedData([value], []);
77
+ });
42
78
  }
43
79
  break;
44
80
  }
@@ -48,14 +84,18 @@ export async function runConfig(options) {
48
84
  }
49
85
  const currentOrgs = currentConfig.excludeOrgs ?? [];
50
86
  if (!currentOrgs.some((o) => o.toLowerCase() === value.toLowerCase())) {
51
- stateManager.updateConfig({ excludeOrgs: [...currentOrgs, value] });
52
- stateManager.cleanupExcludedData([], [value]);
87
+ stateManager.batch(() => {
88
+ stateManager.updateConfig({ excludeOrgs: [...currentOrgs, value] });
89
+ stateManager.cleanupExcludedData([], [value]);
90
+ });
53
91
  }
54
92
  break;
55
93
  }
94
+ case 'issueListPath':
95
+ stateManager.updateConfig({ issueListPath: value || undefined });
96
+ break;
56
97
  default:
57
98
  throw new Error(`Unknown config key: ${options.key}`);
58
99
  }
59
- stateManager.save();
60
100
  return { success: true, key: options.key, value };
61
101
  }
@@ -123,74 +123,78 @@ async function fetchPRData(prMonitor, token) {
123
123
  */
124
124
  async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
125
125
  const stateManager = getStateManager();
126
- // Reset stale repos first (so excluded/removed repos get zeroed).
127
- // Guard: if the API returned zero results but we have existing repos with merged PRs,
128
- // skip the reset to avoid wiping scores due to transient API failures.
129
- const existingReposWithMerges = Object.values(stateManager.getState().repoScores).filter((s) => s.mergedPRCount > 0);
130
- if (mergedCounts.size === 0 && existingReposWithMerges.length > 0) {
131
- warn(MODULE, `Skipping stale repo reset: API returned 0 merged PR results but state has ${existingReposWithMerges.length} repo(s) with merges. Possible API issue.`);
132
- }
133
- else {
134
- for (const score of Object.values(stateManager.getState().repoScores)) {
135
- if (!mergedCounts.has(score.repo)) {
136
- stateManager.updateRepoScore(score.repo, { mergedPRCount: 0 });
126
+ // Batch all synchronous score mutations for a single disk write.
127
+ // Per-repo try-catch: a single corrupted repo should not prevent updates to others.
128
+ // Outer try-catch: save failure should not crash the daily check (in-memory mutations still apply).
129
+ try {
130
+ stateManager.batch(() => {
131
+ // Reset stale repos first (so excluded/removed repos get zeroed).
132
+ // Guard: if the API returned zero results but we have existing repos with merged PRs,
133
+ // skip the reset to avoid wiping scores due to transient API failures.
134
+ const existingReposWithMerges = Object.values(stateManager.getState().repoScores).filter((s) => s.mergedPRCount > 0);
135
+ if (mergedCounts.size === 0 && existingReposWithMerges.length > 0) {
136
+ warn(MODULE, `Skipping stale repo reset: API returned 0 merged PR results but state has ${existingReposWithMerges.length} repo(s) with merges. Possible API issue.`);
137
137
  }
138
- }
139
- }
140
- // Update merged/closed counts with per-repo error isolation (matches signal/trust loops below)
141
- let mergedCountFailures = 0;
142
- for (const [repo, { count, lastMergedAt }] of mergedCounts) {
143
- try {
144
- stateManager.updateRepoScore(repo, { mergedPRCount: count, lastMergedAt: lastMergedAt || undefined });
145
- }
146
- catch (error) {
147
- mergedCountFailures++;
148
- warn(MODULE, `Failed to update merged count for ${repo}: ${errorMessage(error)}`);
149
- }
150
- }
151
- if (mergedCountFailures === mergedCounts.size && mergedCounts.size > 0) {
152
- warn(MODULE, `[ALL_MERGED_COUNT_UPDATES_FAILED] All ${mergedCounts.size} merged count update(s) failed.`);
153
- }
154
- // Populate closedWithoutMergeCount in repo scores.
155
- // Diagnostic: warn if API returned empty but we have known closed PRs (possible transient API failure).
156
- // Unlike merged counts above, there is no stale-reset loop for closed counts, so no skip is needed.
157
- const existingReposWithClosed = Object.values(stateManager.getState().repoScores).filter((s) => (s.closedWithoutMergeCount || 0) > 0);
158
- if (closedCounts.size === 0 && existingReposWithClosed.length > 0) {
159
- warn(MODULE, `API returned 0 closed PR results but state has ${existingReposWithClosed.length} repo(s) with closed PRs. Possible transient API issue.`);
160
- }
161
- let closedCountFailures = 0;
162
- for (const [repo, count] of closedCounts) {
163
- try {
164
- stateManager.updateRepoScore(repo, { closedWithoutMergeCount: count });
165
- }
166
- catch (error) {
167
- closedCountFailures++;
168
- warn(MODULE, `Failed to update closed count for ${repo}: ${errorMessage(error)}`);
169
- }
170
- }
171
- if (closedCountFailures === closedCounts.size && closedCounts.size > 0) {
172
- warn(MODULE, `[ALL_CLOSED_COUNT_UPDATES_FAILED] All ${closedCounts.size} closed count update(s) failed.`);
173
- }
174
- // Update repo signals from observed open PR data (responsiveness, active maintainers).
175
- // Only repos with current open PRs get signal updates — repos with no open PRs
176
- // preserve their existing signals to avoid degrading scores when PRs are merged.
177
- // Per-repo try-catch: signal/trust syncing is secondary to the daily digest —
178
- // a single corrupted repo score should not prevent updates to other repos.
179
- const repoSignals = computeRepoSignals(prs);
180
- let signalUpdateFailures = 0;
181
- for (const [repo, signals] of repoSignals) {
182
- try {
183
- stateManager.updateRepoScore(repo, { signals });
184
- }
185
- catch (error) {
186
- signalUpdateFailures++;
187
- warn(MODULE, `Failed to update signals for ${repo}: ${errorMessage(error)}`);
188
- }
138
+ else {
139
+ for (const score of Object.values(stateManager.getState().repoScores)) {
140
+ if (!mergedCounts.has(score.repo)) {
141
+ stateManager.updateRepoScore(score.repo, { mergedPRCount: 0 });
142
+ }
143
+ }
144
+ }
145
+ // Update merged/closed counts
146
+ let mergedCountFailures = 0;
147
+ for (const [repo, { count, lastMergedAt }] of mergedCounts) {
148
+ try {
149
+ stateManager.updateRepoScore(repo, { mergedPRCount: count, lastMergedAt: lastMergedAt || undefined });
150
+ }
151
+ catch (error) {
152
+ mergedCountFailures++;
153
+ warn(MODULE, `Failed to update merged count for ${repo}: ${errorMessage(error)}`);
154
+ }
155
+ }
156
+ if (mergedCountFailures === mergedCounts.size && mergedCounts.size > 0) {
157
+ warn(MODULE, `[ALL_MERGED_COUNT_UPDATES_FAILED] All ${mergedCounts.size} merged count update(s) failed.`);
158
+ }
159
+ // Populate closedWithoutMergeCount in repo scores.
160
+ const existingReposWithClosed = Object.values(stateManager.getState().repoScores).filter((s) => (s.closedWithoutMergeCount || 0) > 0);
161
+ if (closedCounts.size === 0 && existingReposWithClosed.length > 0) {
162
+ warn(MODULE, `API returned 0 closed PR results but state has ${existingReposWithClosed.length} repo(s) with closed PRs. Possible transient API issue.`);
163
+ }
164
+ let closedCountFailures = 0;
165
+ for (const [repo, count] of closedCounts) {
166
+ try {
167
+ stateManager.updateRepoScore(repo, { closedWithoutMergeCount: count });
168
+ }
169
+ catch (error) {
170
+ closedCountFailures++;
171
+ warn(MODULE, `Failed to update closed count for ${repo}: ${errorMessage(error)}`);
172
+ }
173
+ }
174
+ if (closedCountFailures === closedCounts.size && closedCounts.size > 0) {
175
+ warn(MODULE, `[ALL_CLOSED_COUNT_UPDATES_FAILED] All ${closedCounts.size} closed count update(s) failed.`);
176
+ }
177
+ // Update repo signals from observed open PR data
178
+ const repoSignals = computeRepoSignals(prs);
179
+ let signalUpdateFailures = 0;
180
+ for (const [repo, signals] of repoSignals) {
181
+ try {
182
+ stateManager.updateRepoScore(repo, { signals });
183
+ }
184
+ catch (error) {
185
+ signalUpdateFailures++;
186
+ warn(MODULE, `Failed to update signals for ${repo}: ${errorMessage(error)}`);
187
+ }
188
+ }
189
+ if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
190
+ warn(MODULE, `[ALL_SIGNAL_UPDATES_FAILED] All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
191
+ }
192
+ });
189
193
  }
190
- if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
191
- warn(MODULE, `[ALL_SIGNAL_UPDATES_FAILED] All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
194
+ catch (error) {
195
+ warn(MODULE, `Failed to persist repo score updates: ${errorMessage(error)}`);
192
196
  }
193
- // Fetch metadata (stars + language) for all scored repos (used by dashboard minStars filter and merged PR view, #216, #677)
197
+ // Fetch metadata (stars + language) for all scored repos async, so outside the batch above
194
198
  const allRepos = Object.keys(stateManager.getState().repoScores);
195
199
  let repoMetadata;
196
200
  try {
@@ -203,32 +207,40 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
203
207
  warn(MODULE, 'Repos without cached metadata will be excluded from dashboard stats and metadata badges until fetched on the next successful run.');
204
208
  repoMetadata = new Map();
205
209
  }
206
- let metadataUpdateFailures = 0;
207
- for (const [repo, { stars, language }] of repoMetadata) {
208
- try {
209
- stateManager.updateRepoScore(repo, { stargazersCount: stars, language });
210
- }
211
- catch (error) {
212
- metadataUpdateFailures++;
213
- warn(MODULE, `Failed to update metadata for ${repo}: ${errorMessage(error)}`);
214
- }
215
- }
216
- if (metadataUpdateFailures === repoMetadata.size && repoMetadata.size > 0) {
217
- warn(MODULE, `[ALL_METADATA_UPDATES_FAILED] All ${repoMetadata.size} metadata update(s) failed.`);
218
- }
219
- // Auto-sync trustedProjects from repos with merged PRs
220
- let trustSyncFailures = 0;
221
- for (const [repo] of mergedCounts) {
222
- try {
223
- stateManager.addTrustedProject(repo);
224
- }
225
- catch (error) {
226
- trustSyncFailures++;
227
- warn(MODULE, `Failed to sync trusted project ${repo}: ${errorMessage(error)}`);
228
- }
210
+ // Batch metadata + trust sync mutations for a single disk write
211
+ try {
212
+ stateManager.batch(() => {
213
+ let metadataUpdateFailures = 0;
214
+ for (const [repo, { stars, language }] of repoMetadata) {
215
+ try {
216
+ stateManager.updateRepoScore(repo, { stargazersCount: stars, language });
217
+ }
218
+ catch (error) {
219
+ metadataUpdateFailures++;
220
+ warn(MODULE, `Failed to update metadata for ${repo}: ${errorMessage(error)}`);
221
+ }
222
+ }
223
+ if (metadataUpdateFailures === repoMetadata.size && repoMetadata.size > 0) {
224
+ warn(MODULE, `[ALL_METADATA_UPDATES_FAILED] All ${repoMetadata.size} metadata update(s) failed.`);
225
+ }
226
+ // Auto-sync trustedProjects from repos with merged PRs
227
+ let trustSyncFailures = 0;
228
+ for (const [repo] of mergedCounts) {
229
+ try {
230
+ stateManager.addTrustedProject(repo);
231
+ }
232
+ catch (error) {
233
+ trustSyncFailures++;
234
+ warn(MODULE, `Failed to sync trusted project ${repo}: ${errorMessage(error)}`);
235
+ }
236
+ }
237
+ if (trustSyncFailures === mergedCounts.size && mergedCounts.size > 0) {
238
+ warn(MODULE, `[ALL_TRUST_SYNCS_FAILED] All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
239
+ }
240
+ });
229
241
  }
230
- if (trustSyncFailures === mergedCounts.size && mergedCounts.size > 0) {
231
- warn(MODULE, `[ALL_TRUST_SYNCS_FAILED] All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
242
+ catch (error) {
243
+ warn(MODULE, `Failed to persist metadata/trust updates: ${errorMessage(error)}`);
232
244
  }
233
245
  }
234
246
  /**
@@ -246,38 +258,47 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
246
258
  const shelvedPRs = [];
247
259
  const autoUnshelvedPRs = [];
248
260
  const activePRs = [];
249
- for (const pr of overriddenPRs) {
250
- if (stateManager.isPRShelved(pr.url)) {
251
- if (CRITICAL_STATUSES.has(pr.status)) {
252
- stateManager.unshelvePR(pr.url);
253
- autoUnshelvedPRs.push(toShelvedPRRef(pr));
254
- activePRs.push(pr);
255
- }
256
- else {
257
- shelvedPRs.push(toShelvedPRRef(pr));
261
+ // Wrap mutations in batch: unshelvePR calls + setLastDigest produce a single save.
262
+ // Outer try-catch: save failure should not crash the daily check (in-memory mutations still apply).
263
+ try {
264
+ stateManager.batch(() => {
265
+ for (const pr of overriddenPRs) {
266
+ if (stateManager.isPRShelved(pr.url)) {
267
+ if (CRITICAL_STATUSES.has(pr.status)) {
268
+ stateManager.unshelvePR(pr.url);
269
+ autoUnshelvedPRs.push(toShelvedPRRef(pr));
270
+ activePRs.push(pr);
271
+ }
272
+ else {
273
+ shelvedPRs.push(toShelvedPRRef(pr));
274
+ }
275
+ }
276
+ else if (pr.stalenessTier === 'dormant' && !CRITICAL_STATUSES.has(pr.status)) {
277
+ // Dormant PRs are auto-shelved unless they need addressing
278
+ // (e.g. maintainer commented on a stale PR — it should resurface)
279
+ shelvedPRs.push(toShelvedPRRef(pr));
280
+ }
281
+ else {
282
+ activePRs.push(pr);
283
+ }
258
284
  }
259
- }
260
- else if (pr.stalenessTier === 'dormant' && !CRITICAL_STATUSES.has(pr.status)) {
261
- // Dormant PRs are auto-shelved unless they need addressing
262
- // (e.g. maintainer commented on a stale PR — it should resurface)
263
- shelvedPRs.push(toShelvedPRRef(pr));
264
- }
265
- else {
266
- activePRs.push(pr);
267
- }
285
+ // Generate digest from override-applied PRs so status categories are correct.
286
+ // Note: digest.openPRs contains ALL fetched PRs (including shelved).
287
+ // We override summary fields below to reflect active-only counts.
288
+ const digest = prMonitor.generateDigest(overriddenPRs, recentlyClosedPRs, recentlyMergedPRs);
289
+ // Attach shelve info to digest
290
+ digest.shelvedPRs = shelvedPRs;
291
+ digest.autoUnshelvedPRs = autoUnshelvedPRs;
292
+ digest.summary.totalActivePRs = activePRs.length;
293
+ // Store digest in state so dashboard can render it
294
+ stateManager.setLastDigest(digest);
295
+ });
296
+ }
297
+ catch (error) {
298
+ warn(MODULE, `Failed to persist partition state: ${errorMessage(error)}`);
268
299
  }
269
- // Generate digest from override-applied PRs so status categories are correct.
270
- // Note: digest.openPRs contains ALL fetched PRs (including shelved).
271
- // We override summary fields below to reflect active-only counts.
272
- const digest = prMonitor.generateDigest(overriddenPRs, recentlyClosedPRs, recentlyMergedPRs);
273
- // Attach shelve info to digest
274
- digest.shelvedPRs = shelvedPRs;
275
- digest.autoUnshelvedPRs = autoUnshelvedPRs;
276
- digest.summary.totalActivePRs = activePRs.length;
277
- // Store digest in state so dashboard can render it
278
- stateManager.setLastDigest(digest);
279
- // Save state (updates lastRunAt, lastDigest, and any auto-unshelve changes)
280
- stateManager.save();
300
+ // Digest was created inside batch reconstruct from state
301
+ const digest = stateManager.getState().lastDigest;
281
302
  return { activePRs, shelvedPRs, autoUnshelvedPRs, digest };
282
303
  }
283
304
  /**
@@ -289,43 +310,47 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
289
310
  const stateManager = getStateManager();
290
311
  // Assess capacity from active PRs only (shelved PRs excluded)
291
312
  const capacity = assessCapacity(activePRs, stateManager.getState().config.maxActivePRs, shelvedPRs.length);
292
- // Filter dismissed issues: suppress if dismissed after last response, resurface + auto-undismiss if new activity
293
- let hasAutoUndismissed = false;
294
- const filteredCommentedIssues = commentedIssues.filter((issue) => {
295
- const dismissedAt = stateManager.getIssueDismissedAt(issue.url);
296
- if (!dismissedAt)
297
- return true; // Not dismissed — include
298
- if (issue.status === 'new_response') {
299
- const responseTime = new Date(issue.lastResponseAt).getTime();
300
- const dismissTime = new Date(dismissedAt).getTime();
301
- if (isNaN(responseTime) || isNaN(dismissTime)) {
302
- // Invalid timestamp fail open (include issue to be safe) without
303
- // permanently removing dismiss record (may be a transient data issue)
304
- warn(MODULE, `Invalid timestamp in dismiss check for ${issue.url}, including issue`);
305
- return true;
306
- }
307
- if (responseTime > dismissTime) {
308
- // New activity after dismiss — auto-undismiss and resurface
309
- warn(MODULE, `Auto-undismissing issue ${issue.url}: new response at ${issue.lastResponseAt} after dismiss at ${dismissedAt}`);
310
- stateManager.undismissIssue(issue.url);
311
- hasAutoUndismissed = true;
312
- return true;
313
- }
314
- }
315
- // Still dismissed (last response is at or before dismiss timestamp)
316
- return false;
317
- });
313
+ // Filter dismissed issues: suppress if dismissed after last response, resurface + auto-undismiss if new activity.
314
+ // Batch: undismissIssue calls trigger autoSave — batch produces a single disk write for all auto-undismisses.
315
+ let filteredCommentedIssues = [];
316
+ try {
317
+ stateManager.batch(() => {
318
+ filteredCommentedIssues = commentedIssues.filter((issue) => {
319
+ const dismissedAt = stateManager.getIssueDismissedAt(issue.url);
320
+ if (!dismissedAt)
321
+ return true; // Not dismissed — include
322
+ if (issue.status === 'new_response') {
323
+ const responseTime = new Date(issue.lastResponseAt).getTime();
324
+ const dismissTime = new Date(dismissedAt).getTime();
325
+ if (isNaN(responseTime) || isNaN(dismissTime)) {
326
+ // Invalid timestamp — fail open (include issue to be safe) without
327
+ // permanently removing dismiss record (may be a transient data issue)
328
+ warn(MODULE, `Invalid timestamp in dismiss check for ${issue.url}, including issue`);
329
+ return true;
330
+ }
331
+ if (responseTime > dismissTime) {
332
+ // New activity after dismiss — auto-undismiss and resurface
333
+ warn(MODULE, `Auto-undismissing issue ${issue.url}: new response at ${issue.lastResponseAt} after dismiss at ${dismissedAt}`);
334
+ try {
335
+ stateManager.undismissIssue(issue.url);
336
+ }
337
+ catch (error) {
338
+ warn(MODULE, `Failed to persist auto-undismiss for ${issue.url}: ${errorMessage(error)}`);
339
+ }
340
+ return true;
341
+ }
342
+ }
343
+ // Still dismissed (last response is at or before dismiss timestamp)
344
+ return false;
345
+ });
346
+ });
347
+ }
348
+ catch (error) {
349
+ warn(MODULE, `Failed to persist auto-undismiss state: ${errorMessage(error)}`);
350
+ }
318
351
  const issueResponses = filteredCommentedIssues.filter((i) => i.status === 'new_response');
319
352
  const summary = formatSummary(digest, capacity, issueResponses);
320
- // Persist auto-undismiss state changes for issues
321
- if (hasAutoUndismissed) {
322
- try {
323
- stateManager.save();
324
- }
325
- catch (error) {
326
- warn(MODULE, `Failed to persist auto-undismissed state: ${errorMessage(error)}`);
327
- }
328
- }
353
+ // Auto-undismiss mutations are auto-saved by undismissIssue()
329
354
  const actionableIssues = collectActionableIssues(activePRs, previousLastDigestAt);
330
355
  digest.summary.totalNeedingAttention = actionableIssues.length;
331
356
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
@@ -392,8 +417,16 @@ async function executeDailyCheckInternal(token) {
392
417
  const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token);
393
418
  // Phase 2: Update repo scores (signals, star counts, trust sync)
394
419
  await updateRepoScores(prMonitor, prs, mergedCounts, closedCounts);
395
- // Phase 3: Persist monthly analytics
396
- updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
420
+ // Phase 3: Persist monthly analytics (batch the 3 monthly setter calls).
421
+ // try-catch: analytics are supplementary — save failure should not crash the daily check.
422
+ try {
423
+ getStateManager().batch(() => {
424
+ updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
425
+ });
426
+ }
427
+ catch (error) {
428
+ warn(MODULE, `Failed to persist monthly analytics: ${errorMessage(error)}`);
429
+ }
397
430
  // Capture lastDigestAt BEFORE Phase 4 overwrites it with the current run's timestamp.
398
431
  // Used by collectActionableIssues to determine which PRs are "new" (created since last digest).
399
432
  const previousLastDigestAt = getStateManager().getState().lastDigestAt;
@@ -169,43 +169,50 @@ export async function fetchDashboardData(token) {
169
169
  if (failures.length > 0) {
170
170
  warn(MODULE, `${failures.length} PR fetch(es) failed`);
171
171
  }
172
- // Store new merged PRs incrementally (dedupes by URL)
172
+ // Wrap all state mutations in a batch for a single disk write.
173
+ // try-catch: save errors should not crash the dashboard data fetch.
173
174
  try {
174
- stateManager.addMergedPRs(newMergedPRs);
175
- }
176
- catch (error) {
177
- warn(MODULE, `Failed to store merged PRs: ${errorMessage(error)}`);
178
- }
179
- // Store new closed PRs incrementally (dedupes by URL)
180
- try {
181
- stateManager.addClosedPRs(newClosedPRs);
175
+ stateManager.batch(() => {
176
+ // Store new merged PRs incrementally (dedupes by URL)
177
+ try {
178
+ stateManager.addMergedPRs(newMergedPRs);
179
+ }
180
+ catch (error) {
181
+ warn(MODULE, `Failed to store merged PRs: ${errorMessage(error)}`);
182
+ }
183
+ // Store new closed PRs incrementally (dedupes by URL)
184
+ try {
185
+ stateManager.addClosedPRs(newClosedPRs);
186
+ }
187
+ catch (error) {
188
+ warn(MODULE, `Failed to store closed PRs: ${errorMessage(error)}`);
189
+ }
190
+ // Store monthly chart data (opened/merged/closed) so charts have data
191
+ const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
192
+ const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
193
+ updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
194
+ const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
195
+ // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
196
+ // Dormant PRs are treated as shelved unless they need addressing
197
+ const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
198
+ const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing'));
199
+ digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
200
+ digest.autoUnshelvedPRs = [];
201
+ digest.summary.totalActivePRs = prs.length - freshShelved.length;
202
+ stateManager.setLastDigest(digest);
203
+ });
182
204
  }
183
205
  catch (error) {
184
- warn(MODULE, `Failed to store closed PRs: ${errorMessage(error)}`);
206
+ warn(MODULE, `Failed to persist dashboard state: ${errorMessage(error)}`);
185
207
  }
186
- // Convert stored PRs to full types (derive repo/number from URL)
208
+ warn(MODULE, `Refreshed: ${prs.length} PRs fetched`);
209
+ // Convert stored PRs to full types (derive repo/number from URL) — read-only, outside batch
187
210
  const allMergedPRs = storedToMergedPRs(stateManager.getMergedPRs());
188
211
  const allClosedPRs = storedToClosedPRs(stateManager.getClosedPRs());
189
- // Store monthly chart data (opened/merged/closed) so charts have data
190
- const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
191
- const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
192
- updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
193
- const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
194
- // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
195
- // Dormant PRs are treated as shelved unless they need addressing
196
- const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
197
- const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing'));
198
- digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
199
- digest.autoUnshelvedPRs = [];
200
- digest.summary.totalActivePRs = prs.length - freshShelved.length;
201
- stateManager.setLastDigest(digest);
202
- try {
203
- stateManager.save();
212
+ const digest = stateManager.getState().lastDigest;
213
+ if (!digest) {
214
+ throw new Error('Dashboard data fetch failed: digest was not generated');
204
215
  }
205
- catch (error) {
206
- warn(MODULE, `Failed to save dashboard digest to state: ${errorMessage(error)}`);
207
- }
208
- warn(MODULE, `Refreshed: ${prs.length} PRs fetched`);
209
216
  return { digest, commentedIssues, allMergedPRs, allClosedPRs };
210
217
  }
211
218
  /**
@@ -293,7 +293,6 @@ export async function startDashboardServer(options) {
293
293
  else {
294
294
  // dismiss_issue_response
295
295
  stateManager.dismissIssue(body.url, new Date().toISOString());
296
- stateManager.save();
297
296
  }
298
297
  }
299
298
  catch (error) {
@@ -10,9 +10,6 @@ export async function runDismiss(options) {
10
10
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
11
11
  const stateManager = getStateManager();
12
12
  const added = stateManager.dismissIssue(options.url, new Date().toISOString());
13
- if (added) {
14
- stateManager.save();
15
- }
16
13
  return { dismissed: added, url: options.url };
17
14
  }
18
15
  export async function runUndismiss(options) {
@@ -20,8 +17,5 @@ export async function runUndismiss(options) {
20
17
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
21
18
  const stateManager = getStateManager();
22
19
  const removed = stateManager.undismissIssue(options.url);
23
- if (removed) {
24
- stateManager.save();
25
- }
26
20
  return { undismissed: removed, url: options.url };
27
21
  }
@@ -9,7 +9,6 @@ export async function runInit(options) {
9
9
  const stateManager = getStateManager();
10
10
  // Set username in config
11
11
  stateManager.updateConfig({ githubUsername: options.username });
12
- stateManager.save();
13
12
  return {
14
13
  username: options.username,
15
14
  message: 'Username saved. Run `daily` to fetch your open PRs from GitHub.',
@@ -113,11 +113,10 @@ export async function runLocalRepos(options) {
113
113
  const cachedAt = new Date().toISOString();
114
114
  try {
115
115
  stateManager.setLocalRepoCache({ repos, scanPaths, cachedAt });
116
- stateManager.save();
117
116
  }
118
117
  catch (error) {
119
118
  const msg = errorMessage(error);
120
- console.error(`Warning: Failed to cache scan results: ${msg}`);
119
+ console.error(`Warning: Failed to cache scan results to disk: ${msg}`);
121
120
  }
122
121
  return {
123
122
  repos,