@oss-scout/core 0.11.0 → 1.0.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 (63) hide show
  1. package/dist/cli.bundle.cjs +78 -61
  2. package/dist/cli.js +401 -425
  3. package/dist/commands/command-scout.d.ts +21 -0
  4. package/dist/commands/command-scout.js +21 -0
  5. package/dist/commands/config.js +10 -128
  6. package/dist/commands/features.js +15 -28
  7. package/dist/commands/results.d.ts +13 -2
  8. package/dist/commands/results.js +29 -2
  9. package/dist/commands/search.js +63 -70
  10. package/dist/commands/setup.d.ts +2 -0
  11. package/dist/commands/setup.js +35 -6
  12. package/dist/commands/skip.d.ts +4 -0
  13. package/dist/commands/skip.js +45 -55
  14. package/dist/commands/sync.d.ts +10 -0
  15. package/dist/commands/sync.js +10 -0
  16. package/dist/commands/vet-list.js +3 -19
  17. package/dist/commands/vet.js +18 -25
  18. package/dist/commands/with-scout.d.ts +32 -0
  19. package/dist/commands/with-scout.js +41 -0
  20. package/dist/core/anti-llm-policy.js +4 -5
  21. package/dist/core/bootstrap.d.ts +2 -2
  22. package/dist/core/bootstrap.js +5 -9
  23. package/dist/core/errors.d.ts +10 -0
  24. package/dist/core/errors.js +20 -5
  25. package/dist/core/feature-discovery.d.ts +13 -1
  26. package/dist/core/feature-discovery.js +104 -81
  27. package/dist/core/gist-state-store.d.ts +13 -12
  28. package/dist/core/gist-state-store.js +128 -53
  29. package/dist/core/http-cache.d.ts +32 -2
  30. package/dist/core/http-cache.js +74 -19
  31. package/dist/core/issue-discovery.d.ts +2 -0
  32. package/dist/core/issue-discovery.js +44 -29
  33. package/dist/core/issue-eligibility.d.ts +10 -4
  34. package/dist/core/issue-eligibility.js +119 -67
  35. package/dist/core/issue-graphql.d.ts +58 -0
  36. package/dist/core/issue-graphql.js +108 -0
  37. package/dist/core/issue-vetting.d.ts +105 -8
  38. package/dist/core/issue-vetting.js +234 -107
  39. package/dist/core/local-state.d.ts +6 -2
  40. package/dist/core/local-state.js +23 -5
  41. package/dist/core/logger.d.ts +12 -4
  42. package/dist/core/logger.js +33 -7
  43. package/dist/core/personalization.d.ts +15 -10
  44. package/dist/core/personalization.js +30 -22
  45. package/dist/core/preference-fields.d.ts +47 -0
  46. package/dist/core/preference-fields.js +178 -0
  47. package/dist/core/repo-health.js +31 -15
  48. package/dist/core/roadmap.js +17 -3
  49. package/dist/core/schemas.d.ts +144 -26
  50. package/dist/core/schemas.js +74 -17
  51. package/dist/core/search-budget.d.ts +9 -0
  52. package/dist/core/search-budget.js +36 -3
  53. package/dist/core/search-phases.d.ts +0 -18
  54. package/dist/core/search-phases.js +27 -82
  55. package/dist/core/types.d.ts +136 -38
  56. package/dist/core/utils.js +60 -26
  57. package/dist/formatters/markdown.d.ts +10 -0
  58. package/dist/formatters/markdown.js +31 -0
  59. package/dist/index.d.ts +6 -2
  60. package/dist/index.js +8 -0
  61. package/dist/scout.d.ts +59 -10
  62. package/dist/scout.js +244 -20
  63. package/package.json +1 -1
package/dist/scout.d.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  * Provides personalized issue discovery, vetting, and scoring.
5
5
  * Implements ScoutStateReader to bridge state with the search engine.
6
6
  */
7
- import type { ScoutStateReader } from "./core/issue-vetting.js";
7
+ import type { ScoutStateReader, ScoutStateWriter, SLMConfig } from "./core/issue-vetting.js";
8
8
  import { type FeatureSearchResult } from "./core/feature-discovery.js";
9
9
  import type { ScoutState, ScoutPreferences, RepoScore, SavedCandidate, SkippedIssue, Horizon } from "./core/schemas.js";
10
- import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectCategory, VetListOptions, VetListResult } from "./core/types.js";
10
+ import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectCategory, SyncResult, VetListOptions, VetListResult } from "./core/types.js";
11
11
  import { GistStateStore } from "./core/gist-state-store.js";
12
12
  /**
13
13
  * Create an OssScout instance.
@@ -37,17 +37,43 @@ export declare function createScout(config: ScoutConfig): Promise<OssScout>;
37
37
  * Implements ScoutStateReader so the search engine can read state
38
38
  * without knowing about the persistence layer.
39
39
  */
40
- export declare class OssScout implements ScoutStateReader {
40
+ export declare class OssScout implements ScoutStateReader, ScoutStateWriter {
41
41
  private githubToken;
42
42
  private gistStore;
43
43
  private state;
44
44
  private dirty;
45
- constructor(githubToken: string, initialState: ScoutState, gistStore?: GistStateStore | null);
45
+ /** When true, checkpoint() also writes ~/.oss-scout/state.json. */
46
+ private persistLocal;
47
+ constructor(githubToken: string, initialState: ScoutState, gistStore?: GistStateStore | null, opts?: {
48
+ persistLocal?: boolean;
49
+ });
50
+ /**
51
+ * Drop stale disk-cache entries. Called at the top of every cache-burning
52
+ * entry point (search, features, vetList); without it ~/.oss-scout/cache
53
+ * grows without bound. evictStale never throws (fs errors degrade to warn).
54
+ */
55
+ private evictStaleCacheEntries;
46
56
  /**
47
57
  * Multi-strategy issue search. Returns scored, sorted candidates.
48
58
  * Automatically culls expired skip entries and filters skipped issues.
49
59
  */
50
60
  search(options?: SearchOptions): Promise<SearchResult>;
61
+ /**
62
+ * Populate the `hasActiveMaintainers` repo-score signal from a freshly
63
+ * computed projectHealth (#167). It was initialized false and never set, so
64
+ * calculateScore's +1 active-maintainers weight was inert; `isActive`
65
+ * (recent commit activity) is a real, already-computed proxy.
66
+ *
67
+ * `isResponsive` and `avgResponseDays` are deliberately NOT set here:
68
+ * `projectHealth.avgIssueResponseDays` is a hardcoded `0` placeholder
69
+ * (repo-health.ts), so deriving responsiveness from it would award +1 to
70
+ * every repo — a fake signal worse than the inert one. Real responsiveness
71
+ * needs an actual response-time measurement (extra API calls), deferred.
72
+ * `hasHostileComments` likewise stays a host-settable capability (it needs
73
+ * comment sentiment, out of scope). A failed health check is skipped so its
74
+ * neutral-default fields don't pollute the score.
75
+ */
76
+ private updateRepoSignalsFromHealth;
51
77
  /**
52
78
  * Vet a single issue URL for claimability.
53
79
  */
@@ -83,15 +109,21 @@ export declare class OssScout implements ScoutStateReader {
83
109
  getReposWithOpenPRs(): string[];
84
110
  getStarredRepos(): string[];
85
111
  getProjectCategories(): ProjectCategory[];
112
+ /** Configured GitHub username (used to classify own vs competing PRs, #166). */
113
+ getGitHubUsername(): string;
86
114
  getRepoScore(repo: string): number | null;
87
115
  /**
88
- * Optional SLM pre-triage config read from preferences (oss-autopilot#1122).
89
- * Empty `model` disables the call; the vetter treats it as a no-op.
116
+ * Number of the user's PRs closed without merge in this repo (#125).
117
+ * Prefers the tracked repo score; falls back to counting closedPRs so the
118
+ * scoring penalty works even before a score record exists.
119
+ */
120
+ getClosedWithoutMergeCount(repo: string): number;
121
+ /**
122
+ * SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
123
+ * `null` when no `slmTriageModel` is configured — the vetter skips the SLM
124
+ * call entirely (#158).
90
125
  */
91
- getSLMTriageConfig(): {
92
- model: string;
93
- host: string;
94
- };
126
+ getSLMTriageConfig(): SLMConfig | null;
95
127
  /** Get current preferences (read-only). */
96
128
  getPreferences(): Readonly<ScoutPreferences>;
97
129
  /** Get repo score record for a specific repository. */
@@ -110,6 +142,17 @@ export declare class OssScout implements ScoutStateReader {
110
142
  * Open PRs signal active engagement even when nothing is merged yet.
111
143
  */
112
144
  recordOpenPR(pr: OpenPRRecord): void;
145
+ /**
146
+ * Reconcile tracked open PRs against their current GitHub state (#164).
147
+ *
148
+ * `state.openPRs` was append-only — nothing transitioned an open PR to
149
+ * merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
150
+ * merged. This checks each open PR, records merges/closures (which updates
151
+ * the repo score), prunes resolved entries, and checkpoints. Cheaper than a
152
+ * full bootstrap, so a host can call it on daily startup. Transient errors
153
+ * leave the entry in place; auth/rate-limit failures propagate.
154
+ */
155
+ syncOpenPRs(): Promise<SyncResult>;
113
156
  /**
114
157
  * Update repo score with observed signals.
115
158
  */
@@ -138,6 +181,12 @@ export declare class OssScout implements ScoutStateReader {
138
181
  * Clear all saved results.
139
182
  */
140
183
  clearResults(): void;
184
+ /**
185
+ * Record deletion tombstones (#117) so a later gist merge does not
186
+ * resurrect these URLs from another machine's copy. A re-add with a newer
187
+ * timestamp overrides the tombstone in mergeStates.
188
+ */
189
+ private addTombstones;
141
190
  /**
142
191
  * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
143
192
  */
package/dist/scout.js CHANGED
@@ -7,13 +7,13 @@
7
7
  import { IssueDiscovery } from "./core/issue-discovery.js";
8
8
  import { IssueVetter } from "./core/issue-vetting.js";
9
9
  import { discoverFeatures, discoverFeaturesBroad, } from "./core/feature-discovery.js";
10
- import { ScoutStateSchema } from "./core/schemas.js";
11
10
  import { GistStateStore, mergeStates } from "./core/gist-state-store.js";
12
11
  import { getOctokit } from "./core/github.js";
13
- import { loadLocalState } from "./core/local-state.js";
14
- import { warn } from "./core/logger.js";
15
- import { extractRepoFromUrl } from "./core/utils.js";
16
- import { errorMessage, getHttpStatusCode, isRateLimitError, } from "./core/errors.js";
12
+ import { loadLocalState, saveLocalState } from "./core/local-state.js";
13
+ import { warn, setLogLevel } from "./core/logger.js";
14
+ import { extractRepoFromUrl, parseGitHubUrl } from "./core/utils.js";
15
+ import { errorMessage, getHttpStatusCode, isRateLimitError, rethrowIfFatal, } from "./core/errors.js";
16
+ import { getHttpCache } from "./core/http-cache.js";
17
17
  /** Cause-specific user-facing message for degraded (offline) mode. */
18
18
  function offlineModeMessage(reason) {
19
19
  const tail = "Changes will only be saved locally.";
@@ -93,8 +93,13 @@ function toGistOctokit(octokit) {
93
93
  * ```
94
94
  */
95
95
  export async function createScout(config) {
96
+ // Apply the host's log-level preference before any bootstrap chatter (#156).
97
+ if (config.logLevel !== undefined) {
98
+ setLogLevel(config.logLevel);
99
+ }
96
100
  let state;
97
101
  let gistStore = null;
102
+ let persistLocal = false;
98
103
  if (config.persistence === "provided") {
99
104
  state = config.initialState;
100
105
  }
@@ -114,9 +119,15 @@ export async function createScout(config) {
114
119
  }
115
120
  }
116
121
  else {
117
- state = ScoutStateSchema.parse({ version: 1 });
118
- }
119
- return new OssScout(config.githubToken, state, gistStore);
122
+ // Default: local-file persistence. The previous else-branch silently
123
+ // created throwaway in-memory state, so a documented standalone scout
124
+ // (and the MCP server) read no preferences and persisted nothing while
125
+ // checkpoint() reported success (#116). Load the real state and save it
126
+ // on checkpoint.
127
+ state = loadLocalState();
128
+ persistLocal = true;
129
+ }
130
+ return new OssScout(config.githubToken, state, gistStore, { persistLocal });
120
131
  }
121
132
  /**
122
133
  * Main oss-scout class. Provides search, vetting, and state management.
@@ -129,29 +140,57 @@ export class OssScout {
129
140
  gistStore;
130
141
  state;
131
142
  dirty = false;
132
- constructor(githubToken, initialState, gistStore = null) {
143
+ /** When true, checkpoint() also writes ~/.oss-scout/state.json. */
144
+ persistLocal;
145
+ constructor(githubToken, initialState, gistStore = null, opts = {}) {
133
146
  this.githubToken = githubToken;
134
147
  this.gistStore = gistStore;
135
148
  this.state = initialState;
149
+ this.persistLocal = opts.persistLocal ?? false;
136
150
  }
137
151
  // ── Search ──────────────────────────────────────────────────────────
152
+ /**
153
+ * Drop stale disk-cache entries. Called at the top of every cache-burning
154
+ * entry point (search, features, vetList); without it ~/.oss-scout/cache
155
+ * grows without bound. evictStale never throws (fs errors degrade to warn).
156
+ */
157
+ evictStaleCacheEntries() {
158
+ getHttpCache().evictStale();
159
+ }
138
160
  /**
139
161
  * Multi-strategy issue search. Returns scored, sorted candidates.
140
162
  * Automatically culls expired skip entries and filters skipped issues.
141
163
  */
142
164
  async search(options) {
165
+ this.evictStaleCacheEntries();
143
166
  // Auto-cull expired skips before searching
144
167
  this.cullExpiredSkips();
145
168
  const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
146
169
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
170
+ // Per-call flags override the persisted personalization defaults (#168).
171
+ // An empty preference array reads as "no boost" just like an absent flag.
172
+ const prefs = this.state.preferences;
173
+ const prefLangs = prefs.preferLanguages ?? [];
174
+ const prefRepos = prefs.preferRepos ?? [];
175
+ const preferLanguages = options?.preferLanguages ??
176
+ (prefLangs.length > 0 ? prefLangs : undefined);
177
+ const preferRepos = options?.preferRepos ?? (prefRepos.length > 0 ? prefRepos : undefined);
178
+ const diversityRatio = options?.diversityRatio ?? prefs.diversityRatio ?? 0;
147
179
  const { candidates, strategiesUsed } = await discovery.searchIssues({
148
180
  maxResults: options?.maxResults,
149
181
  strategies: options?.strategies,
150
182
  skippedUrls,
151
- preferLanguages: options?.preferLanguages,
152
- preferRepos: options?.preferRepos,
153
- diversityRatio: options?.diversityRatio,
183
+ preferLanguages,
184
+ preferRepos,
185
+ diversityRatio,
186
+ interPhaseDelayMs: options?.interPhaseDelayMs,
187
+ broadPhaseDelayMs: options?.broadPhaseDelayMs,
154
188
  });
189
+ // Feed the freshly observed maintainer-responsiveness signals back into the
190
+ // repo scores so the next search ranks responsive/active repos higher (#167).
191
+ for (const c of candidates) {
192
+ this.updateRepoSignalsFromHealth(c.projectHealth);
193
+ }
155
194
  this.state.lastSearchAt = new Date().toISOString();
156
195
  this.dirty = true;
157
196
  return {
@@ -162,6 +201,28 @@ export class OssScout {
162
201
  strategiesUsed,
163
202
  };
164
203
  }
204
+ /**
205
+ * Populate the `hasActiveMaintainers` repo-score signal from a freshly
206
+ * computed projectHealth (#167). It was initialized false and never set, so
207
+ * calculateScore's +1 active-maintainers weight was inert; `isActive`
208
+ * (recent commit activity) is a real, already-computed proxy.
209
+ *
210
+ * `isResponsive` and `avgResponseDays` are deliberately NOT set here:
211
+ * `projectHealth.avgIssueResponseDays` is a hardcoded `0` placeholder
212
+ * (repo-health.ts), so deriving responsiveness from it would award +1 to
213
+ * every repo — a fake signal worse than the inert one. Real responsiveness
214
+ * needs an actual response-time measurement (extra API calls), deferred.
215
+ * `hasHostileComments` likewise stays a host-settable capability (it needs
216
+ * comment sentiment, out of scope). A failed health check is skipped so its
217
+ * neutral-default fields don't pollute the score.
218
+ */
219
+ updateRepoSignalsFromHealth(health) {
220
+ if (health.checkFailed)
221
+ return;
222
+ this.updateRepoScore(health.repo, {
223
+ signals: { hasActiveMaintainers: health.isActive },
224
+ });
225
+ }
165
226
  /**
166
227
  * Vet a single issue URL for claimability.
167
228
  */
@@ -184,6 +245,7 @@ export class OssScout {
184
245
  * preferences and excluded repos/orgs.
185
246
  */
186
247
  async features(options) {
248
+ this.evictStaleCacheEntries();
187
249
  const count = options?.count ?? 10;
188
250
  const octokit = getOctokit(this.githubToken);
189
251
  const vetter = new IssueVetter(octokit, this);
@@ -218,6 +280,7 @@ export class OssScout {
218
280
  * Optionally prunes unavailable issues from saved results.
219
281
  */
220
282
  async vetList(options) {
283
+ this.evictStaleCacheEntries();
221
284
  const saved = this.getSavedResults();
222
285
  const concurrency = options?.concurrency ?? 5;
223
286
  const results = [];
@@ -239,6 +302,7 @@ export class OssScout {
239
302
  number: item.number,
240
303
  title: item.title,
241
304
  status: this.classifyVetResult(candidate),
305
+ ok: true,
242
306
  recommendation: candidate.recommendation,
243
307
  viabilityScore: candidate.viabilityScore,
244
308
  });
@@ -256,6 +320,7 @@ export class OssScout {
256
320
  number: item.number,
257
321
  title: item.title,
258
322
  status: isGone ? "closed" : "error",
323
+ ok: false,
259
324
  errorMessage: errorMessage(error),
260
325
  });
261
326
  })
@@ -283,6 +348,33 @@ export class OssScout {
283
348
  hasPR: results.filter((r) => r.status === "has_pr").length,
284
349
  errors: results.filter((r) => r.status === "error").length,
285
350
  };
351
+ // Claim-watch (#165): compare each result's current status to the status
352
+ // recorded on the saved result last time, then persist the new status so
353
+ // the next run can diff again. "error" is transient — never a transition
354
+ // target and never stored.
355
+ const prevStatus = new Map((this.state.savedResults ?? []).map((r) => [r.issueUrl, r.lastStatus]));
356
+ const transitions = results
357
+ .filter((r) => r.status !== "error")
358
+ .filter((r) => {
359
+ const prev = prevStatus.get(r.issueUrl);
360
+ return prev !== undefined && prev !== r.status;
361
+ })
362
+ .map((r) => ({
363
+ issueUrl: r.issueUrl,
364
+ repo: r.repo,
365
+ number: r.number,
366
+ from: prevStatus.get(r.issueUrl),
367
+ to: r.status,
368
+ }));
369
+ const currentStatus = new Map(results.map((r) => [r.issueUrl, r.status]));
370
+ for (const saved of this.state.savedResults ?? []) {
371
+ const status = currentStatus.get(saved.issueUrl);
372
+ if (status !== undefined && status !== "error") {
373
+ saved.lastStatus = status;
374
+ }
375
+ }
376
+ if (results.length > 0)
377
+ this.dirty = true;
286
378
  let prunedCount;
287
379
  if (options?.prune) {
288
380
  const unavailableUrls = new Set(results
@@ -291,11 +383,18 @@ export class OssScout {
291
383
  const before = (this.state.savedResults ?? []).length;
292
384
  this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
293
385
  prunedCount = before - (this.state.savedResults?.length ?? 0);
386
+ if (prunedCount > 0)
387
+ this.addTombstones([...unavailableUrls]);
294
388
  this.dirty = true;
295
389
  }
296
- return { results, summary, prunedCount };
390
+ return { results, summary, prunedCount, transitions };
297
391
  }
298
392
  classifyVetResult(candidate) {
393
+ // Closed wins over everything: GitHub returns 200 for closed issues, so
394
+ // the 404/410 catch path alone never saw them (#120). Candidates cached
395
+ // by older versions lack issueState and read as open.
396
+ if (candidate.issueState === "closed")
397
+ return "closed";
299
398
  const checks = candidate.vettingResult.checks;
300
399
  if (!checks.noExistingPR)
301
400
  return "has_pr";
@@ -335,19 +434,35 @@ export class OssScout {
335
434
  getProjectCategories() {
336
435
  return this.state.preferences.projectCategories;
337
436
  }
437
+ /** Configured GitHub username (used to classify own vs competing PRs, #166). */
438
+ getGitHubUsername() {
439
+ return this.state.preferences.githubUsername;
440
+ }
338
441
  getRepoScore(repo) {
339
442
  const score = this.state.repoScores[repo];
340
443
  return score ? score.score : null;
341
444
  }
342
445
  /**
343
- * Optional SLM pre-triage config read from preferences (oss-autopilot#1122).
344
- * Empty `model` disables the call; the vetter treats it as a no-op.
446
+ * Number of the user's PRs closed without merge in this repo (#125).
447
+ * Prefers the tracked repo score; falls back to counting closedPRs so the
448
+ * scoring penalty works even before a score record exists.
449
+ */
450
+ getClosedWithoutMergeCount(repo) {
451
+ const score = this.state.repoScores[repo];
452
+ if (score)
453
+ return score.closedWithoutMergeCount;
454
+ return (this.state.closedPRs ?? []).filter((p) => extractRepoFromUrl(p.url) === repo).length;
455
+ }
456
+ /**
457
+ * SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
458
+ * `null` when no `slmTriageModel` is configured — the vetter skips the SLM
459
+ * call entirely (#158).
345
460
  */
346
461
  getSLMTriageConfig() {
347
- return {
348
- model: this.state.preferences.slmTriageModel ?? "",
349
- host: this.state.preferences.slmTriageHost ?? "",
350
- };
462
+ const model = this.state.preferences.slmTriageModel ?? "";
463
+ if (!model)
464
+ return null;
465
+ return { model, host: this.state.preferences.slmTriageHost ?? "" };
351
466
  }
352
467
  /** Get current preferences (read-only). */
353
468
  getPreferences() {
@@ -402,6 +517,78 @@ export class OssScout {
402
517
  ];
403
518
  this.dirty = true;
404
519
  }
520
+ /**
521
+ * Reconcile tracked open PRs against their current GitHub state (#164).
522
+ *
523
+ * `state.openPRs` was append-only — nothing transitioned an open PR to
524
+ * merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
525
+ * merged. This checks each open PR, records merges/closures (which updates
526
+ * the repo score), prunes resolved entries, and checkpoints. Cheaper than a
527
+ * full bootstrap, so a host can call it on daily startup. Transient errors
528
+ * leave the entry in place; auth/rate-limit failures propagate.
529
+ */
530
+ async syncOpenPRs() {
531
+ const octokit = getOctokit(this.githubToken);
532
+ const open = this.state.openPRs ?? [];
533
+ const result = {
534
+ checked: open.length,
535
+ merged: 0,
536
+ closed: 0,
537
+ stillOpen: 0,
538
+ errors: 0,
539
+ };
540
+ const remaining = [];
541
+ for (const pr of open) {
542
+ const parsed = parseGitHubUrl(pr.url);
543
+ if (!parsed || parsed.type !== "pull") {
544
+ remaining.push(pr);
545
+ result.errors++;
546
+ continue;
547
+ }
548
+ const repoFullName = `${parsed.owner}/${parsed.repo}`;
549
+ try {
550
+ const { data } = await octokit.pulls.get({
551
+ owner: parsed.owner,
552
+ repo: parsed.repo,
553
+ pull_number: parsed.number,
554
+ });
555
+ if (data.merged) {
556
+ this.recordMergedPR({
557
+ url: pr.url,
558
+ title: pr.title,
559
+ mergedAt: data.merged_at ?? new Date().toISOString(),
560
+ repo: repoFullName,
561
+ });
562
+ result.merged++;
563
+ }
564
+ else if (data.state === "closed") {
565
+ this.recordClosedPR({
566
+ url: pr.url,
567
+ title: pr.title,
568
+ closedAt: data.closed_at ?? new Date().toISOString(),
569
+ repo: repoFullName,
570
+ });
571
+ result.closed++;
572
+ }
573
+ else {
574
+ remaining.push(pr);
575
+ result.stillOpen++;
576
+ }
577
+ }
578
+ catch (err) {
579
+ // Auth/rate-limit aborts the whole sync; a transient/404 leaves the
580
+ // entry untouched so a later sync can retry rather than losing it.
581
+ rethrowIfFatal(err);
582
+ warn("scout", `sync: could not check ${pr.url}: ${errorMessage(err)}`);
583
+ remaining.push(pr);
584
+ result.errors++;
585
+ }
586
+ }
587
+ this.state.openPRs = remaining;
588
+ this.dirty = true;
589
+ await this.checkpoint();
590
+ return result;
591
+ }
405
592
  /**
406
593
  * Update repo score with observed signals.
407
594
  */
@@ -437,6 +624,9 @@ export class OssScout {
437
624
  */
438
625
  updatePreferences(updates) {
439
626
  this.state.preferences = { ...this.state.preferences, ...updates };
627
+ // Stamp so the gist merge keeps the fresher preferences instead of
628
+ // always taking the remote copy (#117).
629
+ this.state.preferencesUpdatedAt = new Date().toISOString();
440
630
  this.dirty = true;
441
631
  }
442
632
  /**
@@ -486,9 +676,25 @@ export class OssScout {
486
676
  * Clear all saved results.
487
677
  */
488
678
  clearResults() {
679
+ this.addTombstones((this.state.savedResults ?? []).map((r) => r.issueUrl));
489
680
  this.state.savedResults = [];
490
681
  this.dirty = true;
491
682
  }
683
+ /**
684
+ * Record deletion tombstones (#117) so a later gist merge does not
685
+ * resurrect these URLs from another machine's copy. A re-add with a newer
686
+ * timestamp overrides the tombstone in mergeStates.
687
+ */
688
+ addTombstones(urls) {
689
+ if (urls.length === 0)
690
+ return;
691
+ const removedAt = new Date().toISOString();
692
+ const existing = this.state.tombstones ?? [];
693
+ const byUrl = new Map(existing.map((t) => [t.url, t]));
694
+ for (const url of urls)
695
+ byUrl.set(url, { url, removedAt });
696
+ this.state.tombstones = [...byUrl.values()];
697
+ }
492
698
  // ── Skip List ───────────────────────────────────────────────────────
493
699
  /**
494
700
  * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
@@ -507,7 +713,10 @@ export class OssScout {
507
713
  skippedAt: new Date().toISOString(),
508
714
  },
509
715
  ];
510
- // Also remove from saved results if present
716
+ // Also remove from saved results if present. No tombstone needed: the
717
+ // skip entry itself is the durable record, and mergeStates reconciles
718
+ // saved results against the skip list so a merge can't resurrect a
719
+ // skipped URL into the saved list (#117).
511
720
  if (this.state.savedResults) {
512
721
  this.state.savedResults = this.state.savedResults.filter((r) => r.issueUrl !== url);
513
722
  }
@@ -523,13 +732,17 @@ export class OssScout {
523
732
  * Remove a specific issue from the skip list.
524
733
  */
525
734
  unskipIssue(url) {
735
+ const before = (this.state.skippedIssues ?? []).length;
526
736
  this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url);
737
+ if (this.state.skippedIssues.length < before)
738
+ this.addTombstones([url]);
527
739
  this.dirty = true;
528
740
  }
529
741
  /**
530
742
  * Clear all skipped issues.
531
743
  */
532
744
  clearSkippedIssues() {
745
+ this.addTombstones((this.state.skippedIssues ?? []).map((s) => s.url));
533
746
  this.state.skippedIssues = [];
534
747
  this.dirty = true;
535
748
  }
@@ -573,6 +786,17 @@ export class OssScout {
573
786
  if (!ok)
574
787
  return false;
575
788
  }
789
+ if (this.persistLocal) {
790
+ // Honest persistence: in local mode the previous no-op return true
791
+ // claimed success while saving nothing (#116). A failed write keeps
792
+ // the dirty flag and reports failure.
793
+ try {
794
+ saveLocalState(this.state);
795
+ }
796
+ catch {
797
+ return false;
798
+ }
799
+ }
576
800
  this.dirty = false;
577
801
  return true;
578
802
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "0.11.0",
3
+ "version": "1.0.0",
4
4
  "description": "Personalized GitHub issue finder with multi-strategy search, deep vetting, and viability scoring — CLI, library, MCP server, and Claude Code plugin",
5
5
  "type": "module",
6
6
  "bin": {