@oss-scout/core 0.11.0 → 1.1.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 (68) hide show
  1. package/dist/cli.bundle.cjs +89 -66
  2. package/dist/cli.js +302 -436
  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.d.ts +4 -0
  10. package/dist/commands/search.js +65 -70
  11. package/dist/commands/setup.d.ts +2 -0
  12. package/dist/commands/setup.js +35 -6
  13. package/dist/commands/skip.d.ts +4 -0
  14. package/dist/commands/skip.js +45 -55
  15. package/dist/commands/sync.d.ts +10 -0
  16. package/dist/commands/sync.js +10 -0
  17. package/dist/commands/vet-list.js +3 -19
  18. package/dist/commands/vet.js +18 -25
  19. package/dist/commands/with-scout.d.ts +32 -0
  20. package/dist/commands/with-scout.js +41 -0
  21. package/dist/core/anti-llm-policy.js +5 -33
  22. package/dist/core/bootstrap.d.ts +2 -2
  23. package/dist/core/bootstrap.js +5 -9
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.js +20 -5
  26. package/dist/core/feature-discovery.d.ts +13 -1
  27. package/dist/core/feature-discovery.js +104 -81
  28. package/dist/core/gist-state-store.d.ts +13 -12
  29. package/dist/core/gist-state-store.js +128 -53
  30. package/dist/core/http-cache.d.ts +32 -2
  31. package/dist/core/http-cache.js +74 -19
  32. package/dist/core/issue-discovery.d.ts +12 -1
  33. package/dist/core/issue-discovery.js +94 -67
  34. package/dist/core/issue-eligibility.d.ts +11 -4
  35. package/dist/core/issue-eligibility.js +124 -69
  36. package/dist/core/issue-graphql.d.ts +58 -0
  37. package/dist/core/issue-graphql.js +108 -0
  38. package/dist/core/issue-vetting.d.ts +115 -9
  39. package/dist/core/issue-vetting.js +246 -109
  40. package/dist/core/local-state.d.ts +6 -2
  41. package/dist/core/local-state.js +23 -5
  42. package/dist/core/logger.d.ts +12 -4
  43. package/dist/core/logger.js +33 -7
  44. package/dist/core/personalization.d.ts +30 -10
  45. package/dist/core/personalization.js +64 -24
  46. package/dist/core/preference-fields.d.ts +47 -0
  47. package/dist/core/preference-fields.js +180 -0
  48. package/dist/core/probe-repo-file.d.ts +47 -0
  49. package/dist/core/probe-repo-file.js +57 -0
  50. package/dist/core/repo-health.js +40 -32
  51. package/dist/core/roadmap.js +26 -22
  52. package/dist/core/schemas.d.ts +148 -26
  53. package/dist/core/schemas.js +83 -17
  54. package/dist/core/search-budget.d.ts +9 -0
  55. package/dist/core/search-budget.js +36 -3
  56. package/dist/core/search-phases.d.ts +4 -21
  57. package/dist/core/search-phases.js +37 -89
  58. package/dist/core/types.d.ts +151 -38
  59. package/dist/core/utils.js +60 -26
  60. package/dist/formatters/human.d.ts +60 -0
  61. package/dist/formatters/human.js +199 -0
  62. package/dist/formatters/markdown.d.ts +10 -0
  63. package/dist/formatters/markdown.js +31 -0
  64. package/dist/index.d.ts +6 -2
  65. package/dist/index.js +8 -0
  66. package/dist/scout.d.ts +75 -12
  67. package/dist/scout.js +265 -26
  68. package/package.json +1 -1
@@ -10,9 +10,7 @@
10
10
  * Auth (401) and rate-limit errors propagate, matching the rest of the
11
11
  * codebase's error strategy. Other errors degrade gracefully (warn + empty).
12
12
  */
13
- import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
14
- import { warn } from "./logger.js";
15
- const MODULE = "roadmap";
13
+ import { probeRepoFile } from "./probe-repo-file.js";
16
14
  /** TTL for roadmap fetch results (1 hour). */
17
15
  const CACHE_TTL_MS = 60 * 60 * 1000;
18
16
  /** Paths probed in priority order. First success wins. */
@@ -97,26 +95,32 @@ export async function fetchRoadmapIssueRefs(octokit, owner, repo) {
97
95
  if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
98
96
  return cached.refs;
99
97
  }
98
+ // Concurrent feature vets of issues from one repo share a probe (#124)
99
+ const inflight = roadmapInflight.get(cacheKey);
100
+ if (inflight)
101
+ return inflight;
102
+ const promise = fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey);
103
+ roadmapInflight.set(cacheKey, promise);
104
+ try {
105
+ return await promise;
106
+ }
107
+ finally {
108
+ roadmapInflight.delete(cacheKey);
109
+ }
110
+ }
111
+ const roadmapInflight = new Map();
112
+ async function fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey) {
100
113
  for (const path of ROADMAP_PATHS) {
101
- try {
102
- const { data } = await octokit.repos.getContent({ owner, repo, path });
103
- if (!("content" in data))
104
- continue;
105
- const content = Buffer.from(data.content, "base64").toString("utf-8");
106
- const refs = parseRoadmapIssueRefs(content, owner, repo);
107
- roadmapCache.set(cacheKey, { refs, fetchedAt: Date.now() });
108
- pruneCache();
109
- return refs;
110
- }
111
- catch (err) {
112
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
113
- throw err;
114
- const status = getHttpStatusCode(err);
115
- if (status === 404)
116
- continue; // path missing — try next
117
- warn(MODULE, `Unexpected error fetching ${path} from ${owner}/${repo}: ${errorMessage(err)}`);
118
- // Fall through and try next path.
119
- }
114
+ // probeRepoFile rethrows 401/rate-limit, treats 404 and non-content
115
+ // payloads as a null text, and warns on 5xx — all of which we degrade past
116
+ // by trying the next path.
117
+ const { text } = await probeRepoFile(octokit, owner, repo, path);
118
+ if (!text)
119
+ continue;
120
+ const refs = parseRoadmapIssueRefs(text, owner, repo);
121
+ roadmapCache.set(cacheKey, { refs, fetchedAt: Date.now() });
122
+ pruneCache();
123
+ return refs;
120
124
  }
121
125
  // No roadmap found (or all probes errored softly). Cache the empty result
122
126
  // so we don't re-probe every run.
@@ -7,9 +7,6 @@
7
7
  import { z } from "zod";
8
8
  export declare const IssueStatusSchema: z.ZodEnum<{
9
9
  candidate: "candidate";
10
- claimed: "claimed";
11
- in_progress: "in_progress";
12
- pr_submitted: "pr_submitted";
13
10
  }>;
14
11
  export declare const ProjectCategorySchema: z.ZodEnum<{
15
12
  nonprofit: "nonprofit";
@@ -37,7 +34,7 @@ export declare const RepoSignalsSchema: z.ZodObject<{
37
34
  hasActiveMaintainers: z.ZodBoolean;
38
35
  isResponsive: z.ZodBoolean;
39
36
  hasHostileComments: z.ZodBoolean;
40
- }, z.core.$strip>;
37
+ }, z.core.$loose>;
41
38
  export declare const RepoScoreSchema: z.ZodObject<{
42
39
  repo: z.ZodString;
43
40
  score: z.ZodNumber;
@@ -50,25 +47,25 @@ export declare const RepoScoreSchema: z.ZodObject<{
50
47
  hasActiveMaintainers: z.ZodBoolean;
51
48
  isResponsive: z.ZodBoolean;
52
49
  hasHostileComments: z.ZodBoolean;
53
- }, z.core.$strip>;
50
+ }, z.core.$loose>;
54
51
  stargazersCount: z.ZodOptional<z.ZodNumber>;
55
52
  language: z.ZodOptional<z.ZodNullable<z.ZodString>>;
56
- }, z.core.$strip>;
53
+ }, z.core.$loose>;
57
54
  export declare const StoredMergedPRSchema: z.ZodObject<{
58
55
  url: z.ZodString;
59
56
  title: z.ZodString;
60
57
  mergedAt: z.ZodString;
61
- }, z.core.$strip>;
58
+ }, z.core.$loose>;
62
59
  export declare const StoredClosedPRSchema: z.ZodObject<{
63
60
  url: z.ZodString;
64
61
  title: z.ZodString;
65
62
  closedAt: z.ZodString;
66
- }, z.core.$strip>;
63
+ }, z.core.$loose>;
67
64
  export declare const StoredOpenPRSchema: z.ZodObject<{
68
65
  url: z.ZodString;
69
66
  title: z.ZodString;
70
67
  openedAt: z.ZodString;
71
- }, z.core.$strip>;
68
+ }, z.core.$loose>;
72
69
  export declare const ContributionGuidelinesSchema: z.ZodObject<{
73
70
  branchNamingConvention: z.ZodOptional<z.ZodString>;
74
71
  commitMessageFormat: z.ZodOptional<z.ZodString>;
@@ -142,9 +139,6 @@ export declare const TrackedIssueSchema: z.ZodObject<{
142
139
  title: z.ZodString;
143
140
  status: z.ZodEnum<{
144
141
  candidate: "candidate";
145
- claimed: "claimed";
146
- in_progress: "in_progress";
147
- pr_submitted: "pr_submitted";
148
142
  }>;
149
143
  labels: z.ZodArray<z.ZodString>;
150
144
  createdAt: z.ZodString;
@@ -195,7 +189,7 @@ export declare const SkippedIssueSchema: z.ZodObject<{
195
189
  number: z.ZodNumber;
196
190
  title: z.ZodString;
197
191
  skippedAt: z.ZodString;
198
- }, z.core.$strip>;
192
+ }, z.core.$loose>;
199
193
  export declare const HorizonSchema: z.ZodEnum<{
200
194
  "quick-win": "quick-win";
201
195
  "bigger-bet": "bigger-bet";
@@ -212,7 +206,11 @@ export declare const SavedCandidateSchema: z.ZodObject<{
212
206
  needs_review: "needs_review";
213
207
  }>;
214
208
  viabilityScore: z.ZodNumber;
215
- searchPriority: z.ZodString;
209
+ searchPriority: z.ZodCatch<z.ZodEnum<{
210
+ normal: "normal";
211
+ starred: "starred";
212
+ merged_pr: "merged_pr";
213
+ }>>;
216
214
  firstSeenAt: z.ZodString;
217
215
  lastSeenAt: z.ZodString;
218
216
  lastScore: z.ZodNumber;
@@ -220,7 +218,18 @@ export declare const SavedCandidateSchema: z.ZodObject<{
220
218
  "quick-win": "quick-win";
221
219
  "bigger-bet": "bigger-bet";
222
220
  }>>;
223
- }, z.core.$strip>;
221
+ /**
222
+ * Availability status recorded by the previous `vet-list` run, so the next
223
+ * run can report transitions ("was available, now claimed") instead of
224
+ * re-diffing full snapshots (#165). "error" is never stored.
225
+ */
226
+ lastStatus: z.ZodOptional<z.ZodEnum<{
227
+ closed: "closed";
228
+ still_available: "still_available";
229
+ claimed: "claimed";
230
+ has_pr: "has_pr";
231
+ }>>;
232
+ }, z.core.$loose>;
224
233
  export declare const PersistenceModeSchema: z.ZodEnum<{
225
234
  local: "local";
226
235
  gist: "gist";
@@ -261,13 +270,50 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
261
270
  broad: "broad";
262
271
  maintained: "maintained";
263
272
  }>>>;
273
+ /**
274
+ * Persisted personalization defaults (#168). The `--prefer-languages`,
275
+ * `--prefer-repos`, and `--diversity-ratio` search flags override these when
276
+ * passed, so a stored preference removes the need to retype the boost every
277
+ * search. Empty / 0 disables the corresponding signal (same as the flags).
278
+ */
279
+ preferLanguages: z.ZodDefault<z.ZodArray<z.ZodString>>;
280
+ preferRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
281
+ diversityRatio: z.ZodDefault<z.ZodNumber>;
282
+ avoidRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
283
+ boostIssueTypes: z.ZodDefault<z.ZodArray<z.ZodString>>;
264
284
  broadPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
285
+ /**
286
+ * Skip the expensive broad phase once this many candidates were found by
287
+ * the cheaper phases. Clamped at runtime to maxResults - 1 so it stays
288
+ * satisfiable; 0 disables skipping entirely.
289
+ */
265
290
  skipBroadWhenSufficientResults: z.ZodDefault<z.ZodNumber>;
291
+ /**
292
+ * Optional Ollama model id used for SLM pre-triage during vetting
293
+ * (oss-autopilot#1122). Empty disables the feature. Recommended values:
294
+ * `gemma4:e4b` (default for capable hardware) or `gemma4:e2b` /
295
+ * `qwen3:1.7b` for low-RAM machines.
296
+ */
266
297
  slmTriageModel: z.ZodDefault<z.ZodString>;
298
+ /**
299
+ * Override the Ollama HTTP host. Defaults to `http://127.0.0.1:11434`
300
+ * when empty. Useful when Ollama runs on a different machine on the
301
+ * local network.
302
+ */
267
303
  slmTriageHost: z.ZodDefault<z.ZodString>;
304
+ /**
305
+ * Minimum merged-PR count for a repo to qualify as an anchor in
306
+ * `scout features` (#98). Lowering surfaces more anchors at the cost
307
+ * of weaker prior engagement signal.
308
+ */
268
309
  featuresAnchorThreshold: z.ZodDefault<z.ZodNumber>;
310
+ /**
311
+ * Quick-wins / bigger-bets split ratio for `scout features` (#99).
312
+ * 0.6 means 60% quick wins, 40% bigger bets when both pools are
313
+ * abundant. Deficits redirect to the other bucket.
314
+ */
269
315
  featuresSplitRatio: z.ZodDefault<z.ZodNumber>;
270
- }, z.core.$strip>;
316
+ }, z.core.$loose>;
271
317
  export declare const ScoutStateSchema: z.ZodObject<{
272
318
  version: z.ZodLiteral<1>;
273
319
  preferences: z.ZodDefault<z.ZodObject<{
@@ -306,13 +352,50 @@ export declare const ScoutStateSchema: z.ZodObject<{
306
352
  broad: "broad";
307
353
  maintained: "maintained";
308
354
  }>>>;
355
+ /**
356
+ * Persisted personalization defaults (#168). The `--prefer-languages`,
357
+ * `--prefer-repos`, and `--diversity-ratio` search flags override these when
358
+ * passed, so a stored preference removes the need to retype the boost every
359
+ * search. Empty / 0 disables the corresponding signal (same as the flags).
360
+ */
361
+ preferLanguages: z.ZodDefault<z.ZodArray<z.ZodString>>;
362
+ preferRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
363
+ diversityRatio: z.ZodDefault<z.ZodNumber>;
364
+ avoidRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
365
+ boostIssueTypes: z.ZodDefault<z.ZodArray<z.ZodString>>;
309
366
  broadPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
367
+ /**
368
+ * Skip the expensive broad phase once this many candidates were found by
369
+ * the cheaper phases. Clamped at runtime to maxResults - 1 so it stays
370
+ * satisfiable; 0 disables skipping entirely.
371
+ */
310
372
  skipBroadWhenSufficientResults: z.ZodDefault<z.ZodNumber>;
373
+ /**
374
+ * Optional Ollama model id used for SLM pre-triage during vetting
375
+ * (oss-autopilot#1122). Empty disables the feature. Recommended values:
376
+ * `gemma4:e4b` (default for capable hardware) or `gemma4:e2b` /
377
+ * `qwen3:1.7b` for low-RAM machines.
378
+ */
311
379
  slmTriageModel: z.ZodDefault<z.ZodString>;
380
+ /**
381
+ * Override the Ollama HTTP host. Defaults to `http://127.0.0.1:11434`
382
+ * when empty. Useful when Ollama runs on a different machine on the
383
+ * local network.
384
+ */
312
385
  slmTriageHost: z.ZodDefault<z.ZodString>;
386
+ /**
387
+ * Minimum merged-PR count for a repo to qualify as an anchor in
388
+ * `scout features` (#98). Lowering surfaces more anchors at the cost
389
+ * of weaker prior engagement signal.
390
+ */
313
391
  featuresAnchorThreshold: z.ZodDefault<z.ZodNumber>;
392
+ /**
393
+ * Quick-wins / bigger-bets split ratio for `scout features` (#99).
394
+ * 0.6 means 60% quick wins, 40% bigger bets when both pools are
395
+ * abundant. Deficits redirect to the other bucket.
396
+ */
314
397
  featuresSplitRatio: z.ZodDefault<z.ZodNumber>;
315
- }, z.core.$strip>>;
398
+ }, z.core.$loose>>;
316
399
  repoScores: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
317
400
  repo: z.ZodString;
318
401
  score: z.ZodNumber;
@@ -325,27 +408,27 @@ export declare const ScoutStateSchema: z.ZodObject<{
325
408
  hasActiveMaintainers: z.ZodBoolean;
326
409
  isResponsive: z.ZodBoolean;
327
410
  hasHostileComments: z.ZodBoolean;
328
- }, z.core.$strip>;
411
+ }, z.core.$loose>;
329
412
  stargazersCount: z.ZodOptional<z.ZodNumber>;
330
413
  language: z.ZodOptional<z.ZodNullable<z.ZodString>>;
331
- }, z.core.$strip>>>;
414
+ }, z.core.$loose>>>;
332
415
  starredRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
333
416
  starredReposLastFetched: z.ZodOptional<z.ZodString>;
334
417
  mergedPRs: z.ZodDefault<z.ZodArray<z.ZodObject<{
335
418
  url: z.ZodString;
336
419
  title: z.ZodString;
337
420
  mergedAt: z.ZodString;
338
- }, z.core.$strip>>>;
421
+ }, z.core.$loose>>>;
339
422
  closedPRs: z.ZodDefault<z.ZodArray<z.ZodObject<{
340
423
  url: z.ZodString;
341
424
  title: z.ZodString;
342
425
  closedAt: z.ZodString;
343
- }, z.core.$strip>>>;
426
+ }, z.core.$loose>>>;
344
427
  openPRs: z.ZodDefault<z.ZodArray<z.ZodObject<{
345
428
  url: z.ZodString;
346
429
  title: z.ZodString;
347
430
  openedAt: z.ZodString;
348
- }, z.core.$strip>>>;
431
+ }, z.core.$loose>>>;
349
432
  savedResults: z.ZodDefault<z.ZodArray<z.ZodObject<{
350
433
  issueUrl: z.ZodString;
351
434
  repo: z.ZodString;
@@ -358,7 +441,11 @@ export declare const ScoutStateSchema: z.ZodObject<{
358
441
  needs_review: "needs_review";
359
442
  }>;
360
443
  viabilityScore: z.ZodNumber;
361
- searchPriority: z.ZodString;
444
+ searchPriority: z.ZodCatch<z.ZodEnum<{
445
+ normal: "normal";
446
+ starred: "starred";
447
+ merged_pr: "merged_pr";
448
+ }>>;
362
449
  firstSeenAt: z.ZodString;
363
450
  lastSeenAt: z.ZodString;
364
451
  lastScore: z.ZodNumber;
@@ -366,18 +453,53 @@ export declare const ScoutStateSchema: z.ZodObject<{
366
453
  "quick-win": "quick-win";
367
454
  "bigger-bet": "bigger-bet";
368
455
  }>>;
369
- }, z.core.$strip>>>;
456
+ /**
457
+ * Availability status recorded by the previous `vet-list` run, so the next
458
+ * run can report transitions ("was available, now claimed") instead of
459
+ * re-diffing full snapshots (#165). "error" is never stored.
460
+ */
461
+ lastStatus: z.ZodOptional<z.ZodEnum<{
462
+ closed: "closed";
463
+ still_available: "still_available";
464
+ claimed: "claimed";
465
+ has_pr: "has_pr";
466
+ }>>;
467
+ }, z.core.$loose>>>;
370
468
  skippedIssues: z.ZodDefault<z.ZodArray<z.ZodObject<{
371
469
  url: z.ZodString;
372
470
  repo: z.ZodString;
373
471
  number: z.ZodNumber;
374
472
  title: z.ZodString;
375
473
  skippedAt: z.ZodString;
376
- }, z.core.$strip>>>;
474
+ }, z.core.$loose>>>;
475
+ /**
476
+ * Deletion tombstones (#117). Without these, the gist union-merge would
477
+ * resurrect any saved result or skipped issue removed on one machine the
478
+ * next time it merged against another machine's copy that still had it.
479
+ * A tombstone suppresses an item across a merge until the item is
480
+ * re-added with a newer timestamp. Pruned after 90 days.
481
+ */
482
+ tombstones: z.ZodDefault<z.ZodArray<z.ZodObject<{
483
+ url: z.ZodString;
484
+ removedAt: z.ZodString;
485
+ }, z.core.$loose>>>;
486
+ /**
487
+ * When preferences were last changed (#117). The gist merge previously
488
+ * always took the remote preferences, silently reverting a local edit;
489
+ * the side with the fresher timestamp now wins.
490
+ */
491
+ preferencesUpdatedAt: z.ZodOptional<z.ZodString>;
377
492
  lastSearchAt: z.ZodOptional<z.ZodString>;
378
493
  lastRunAt: z.ZodDefault<z.ZodString>;
379
494
  gistId: z.ZodOptional<z.ZodString>;
380
- }, z.core.$strip>;
495
+ }, z.core.$loose>;
496
+ /**
497
+ * Single entry point for parsing persisted state (local file, gist, gist
498
+ * cache). Version migrations belong here: when a version 2 ships, transform
499
+ * older raw shapes before validation so no load site ever sees unmigrated
500
+ * data. Unknown keys round-trip via the loose schemas above.
501
+ */
502
+ export declare function parseScoutState(raw: unknown): ScoutState;
381
503
  export type ProjectCategory = z.infer<typeof ProjectCategorySchema>;
382
504
  export type IssueScope = z.infer<typeof IssueScopeSchema>;
383
505
  export type SearchStrategy = z.infer<typeof SearchStrategySchema>;
@@ -6,12 +6,12 @@
6
6
  */
7
7
  import { z } from "zod";
8
8
  // ── Enum schemas ────────────────────────────────────────────────────
9
- export const IssueStatusSchema = z.enum([
10
- "candidate",
11
- "claimed",
12
- "in_progress",
13
- "pr_submitted",
14
- ]);
9
+ // Only "candidate" is ever assigned (TrackedIssue.status is hardcoded in
10
+ // issue-vetting). The "claimed" / "in_progress" / "pr_submitted" values were
11
+ // vestigial from the autopilot extraction and never set; TrackedIssue is an
12
+ // in-memory type, not part of persisted ScoutState, so trimming them carries
13
+ // no migration risk (#155).
14
+ export const IssueStatusSchema = z.enum(["candidate"]);
15
15
  export const ProjectCategorySchema = z.enum([
16
16
  "nonprofit",
17
17
  "devtools",
@@ -40,12 +40,15 @@ export const CONCRETE_STRATEGIES = [
40
40
  "maintained",
41
41
  ];
42
42
  // ── Leaf schemas ────────────────────────────────────────────────────
43
- export const RepoSignalsSchema = z.object({
43
+ export const RepoSignalsSchema = z.looseObject({
44
44
  hasActiveMaintainers: z.boolean(),
45
+ // Retained for backward compatibility but no longer affects the repo score
46
+ // (#167): nothing computes it, and hasActiveMaintainers is the live activity
47
+ // proxy. Kept so old persisted state and the search JSON output still parse.
45
48
  isResponsive: z.boolean(),
46
49
  hasHostileComments: z.boolean(),
47
50
  });
48
- export const RepoScoreSchema = z.object({
51
+ export const RepoScoreSchema = z.looseObject({
49
52
  repo: z.string(),
50
53
  score: z.number(),
51
54
  mergedPRCount: z.number(),
@@ -57,17 +60,17 @@ export const RepoScoreSchema = z.object({
57
60
  stargazersCount: z.number().optional(),
58
61
  language: z.string().nullable().optional(),
59
62
  });
60
- export const StoredMergedPRSchema = z.object({
63
+ export const StoredMergedPRSchema = z.looseObject({
61
64
  url: z.string(),
62
65
  title: z.string(),
63
66
  mergedAt: z.string(),
64
67
  });
65
- export const StoredClosedPRSchema = z.object({
68
+ export const StoredClosedPRSchema = z.looseObject({
66
69
  url: z.string(),
67
70
  title: z.string(),
68
71
  closedAt: z.string(),
69
72
  });
70
- export const StoredOpenPRSchema = z.object({
73
+ export const StoredOpenPRSchema = z.looseObject({
71
74
  url: z.string(),
72
75
  title: z.string(),
73
76
  openedAt: z.string(),
@@ -129,7 +132,7 @@ export const TrackedIssueSchema = z.object({
129
132
  vettingResult: IssueVettingResultSchema.optional(),
130
133
  });
131
134
  // ── Skipped issue schema ──────────────────────────────────────────
132
- export const SkippedIssueSchema = z.object({
135
+ export const SkippedIssueSchema = z.looseObject({
133
136
  url: z.string(),
134
137
  repo: z.string(),
135
138
  number: z.number(),
@@ -138,7 +141,7 @@ export const SkippedIssueSchema = z.object({
138
141
  });
139
142
  // ── Saved candidate schema ─────────────────────────────────────────
140
143
  export const HorizonSchema = z.enum(["quick-win", "bigger-bet"]);
141
- export const SavedCandidateSchema = z.object({
144
+ export const SavedCandidateSchema = z.looseObject({
142
145
  issueUrl: z.string(),
143
146
  repo: z.string(),
144
147
  number: z.number(),
@@ -146,15 +149,26 @@ export const SavedCandidateSchema = z.object({
146
149
  labels: z.array(z.string()),
147
150
  recommendation: z.enum(["approve", "skip", "needs_review"]),
148
151
  viabilityScore: z.number(),
149
- searchPriority: z.string(),
152
+ // Tightened from z.string() to the actual 3-value union (#158). `.catch`
153
+ // keeps old persisted state loadable: any unrecognized stored value (or a
154
+ // future-version value) decodes to "normal" instead of failing the parse.
155
+ searchPriority: z.enum(["merged_pr", "starred", "normal"]).catch("normal"),
150
156
  firstSeenAt: z.string(),
151
157
  lastSeenAt: z.string(),
152
158
  lastScore: z.number(),
153
159
  horizon: HorizonSchema.optional(),
160
+ /**
161
+ * Availability status recorded by the previous `vet-list` run, so the next
162
+ * run can report transitions ("was available, now claimed") instead of
163
+ * re-diffing full snapshots (#165). "error" is never stored.
164
+ */
165
+ lastStatus: z
166
+ .enum(["still_available", "claimed", "closed", "has_pr"])
167
+ .optional(),
154
168
  });
155
169
  // ── Scout preferences schema ────────────────────────────────────────
156
170
  export const PersistenceModeSchema = z.enum(["local", "gist"]);
157
- export const ScoutPreferencesSchema = z.object({
171
+ export const ScoutPreferencesSchema = z.looseObject({
158
172
  githubUsername: z.string().default(""),
159
173
  languages: z.array(z.string()).default(["any"]),
160
174
  labels: z.array(z.string()).default(["good first issue", "help wanted"]),
@@ -170,8 +184,28 @@ export const ScoutPreferencesSchema = z.object({
170
184
  interPhaseDelayMs: z.number().min(0).max(120000).default(30000),
171
185
  persistence: PersistenceModeSchema.default("local"),
172
186
  defaultStrategy: z.array(SearchStrategySchema).optional(),
187
+ /**
188
+ * Persisted personalization defaults (#168). The `--prefer-languages`,
189
+ * `--prefer-repos`, and `--diversity-ratio` search flags override these when
190
+ * passed, so a stored preference removes the need to retype the boost every
191
+ * search. Empty / 0 disables the corresponding signal (same as the flags).
192
+ */
193
+ preferLanguages: z.array(z.string()).default([]),
194
+ preferRepos: z.array(z.string()).default([]),
195
+ diversityRatio: z.number().min(0).max(1).default(0),
196
+ // Soft penalty (milder than the hard excludeRepos filter): candidates in
197
+ // these `owner/repo` slugs are pushed down the ranking but not removed (#168).
198
+ avoidRepos: z.array(z.string()).default([]),
199
+ // Soft boost for candidates whose issue labels match one of these types,
200
+ // case-insensitive (e.g. "bug", "good first issue") (#168).
201
+ boostIssueTypes: z.array(z.string()).default([]),
173
202
  broadPhaseDelayMs: z.number().min(0).max(300000).default(90000),
174
- skipBroadWhenSufficientResults: z.number().int().min(0).max(100).default(15),
203
+ /**
204
+ * Skip the expensive broad phase once this many candidates were found by
205
+ * the cheaper phases. Clamped at runtime to maxResults - 1 so it stays
206
+ * satisfiable; 0 disables skipping entirely.
207
+ */
208
+ skipBroadWhenSufficientResults: z.number().int().min(0).max(100).default(8),
175
209
  /**
176
210
  * Optional Ollama model id used for SLM pre-triage during vetting
177
211
  * (oss-autopilot#1122). Empty disables the feature. Recommended values:
@@ -199,7 +233,10 @@ export const ScoutPreferencesSchema = z.object({
199
233
  featuresSplitRatio: z.number().min(0).max(1).default(0.6),
200
234
  });
201
235
  // ── Root state schema ───────────────────────────────────────────────
202
- export const ScoutStateSchema = z.object({
236
+ // Persisted schemas are loose (unknown keys round-trip) so an older binary
237
+ // loading state written by a newer one cannot silently strip and then
238
+ // persist away the newer fields (#137).
239
+ export const ScoutStateSchema = z.looseObject({
203
240
  version: z.literal(1),
204
241
  preferences: ScoutPreferencesSchema.default(() => ScoutPreferencesSchema.parse({})),
205
242
  repoScores: z.record(z.string(), RepoScoreSchema).default({}),
@@ -210,7 +247,36 @@ export const ScoutStateSchema = z.object({
210
247
  openPRs: z.array(StoredOpenPRSchema).default([]),
211
248
  savedResults: z.array(SavedCandidateSchema).default([]),
212
249
  skippedIssues: z.array(SkippedIssueSchema).default([]),
250
+ /**
251
+ * Deletion tombstones (#117). Without these, the gist union-merge would
252
+ * resurrect any saved result or skipped issue removed on one machine the
253
+ * next time it merged against another machine's copy that still had it.
254
+ * A tombstone suppresses an item across a merge until the item is
255
+ * re-added with a newer timestamp. Pruned after 90 days.
256
+ */
257
+ tombstones: z
258
+ .array(z.looseObject({
259
+ url: z.string(),
260
+ removedAt: z.string(),
261
+ }))
262
+ .default([]),
263
+ /**
264
+ * When preferences were last changed (#117). The gist merge previously
265
+ * always took the remote preferences, silently reverting a local edit;
266
+ * the side with the fresher timestamp now wins.
267
+ */
268
+ preferencesUpdatedAt: z.string().optional(),
213
269
  lastSearchAt: z.string().optional(),
214
270
  lastRunAt: z.string().default(() => new Date().toISOString()),
215
271
  gistId: z.string().optional(),
216
272
  });
273
+ /**
274
+ * Single entry point for parsing persisted state (local file, gist, gist
275
+ * cache). Version migrations belong here: when a version 2 ships, transform
276
+ * older raw shapes before validation so no load site ever sees unmigrated
277
+ * data. Unknown keys round-trip via the loose schemas above.
278
+ */
279
+ export function parseScoutState(raw) {
280
+ // No migrations yet; version 1 is current.
281
+ return ScoutStateSchema.parse(raw);
282
+ }
@@ -20,6 +20,8 @@ export declare class SearchBudgetTracker {
20
20
  private resetAt;
21
21
  /** Total calls recorded since init (for diagnostics). */
22
22
  private totalCalls;
23
+ /** Calls made since the last known quota reset (external accounting). */
24
+ private callsSinceReset;
23
25
  /**
24
26
  * Initialize with pre-flight rate limit data from GitHub.
25
27
  */
@@ -28,6 +30,13 @@ export declare class SearchBudgetTracker {
28
30
  * Record that a Search API call was just made.
29
31
  */
30
32
  recordCall(): void;
33
+ /**
34
+ * Replenish the external budget once GitHub's quota window has reset.
35
+ * Without this, the pre-flight remaining acted as a never-replenishing
36
+ * run-lifetime total, throttling long multi-phase runs to ~1 call/min
37
+ * even though GitHub fully resets every 60 seconds (#119).
38
+ */
39
+ private refreshExternalBudget;
31
40
  /**
32
41
  * Remove timestamps older than the sliding window.
33
42
  */
@@ -30,6 +30,8 @@ export class SearchBudgetTracker {
30
30
  resetAt = 0;
31
31
  /** Total calls recorded since init (for diagnostics). */
32
32
  totalCalls = 0;
33
+ /** Calls made since the last known quota reset (external accounting). */
34
+ callsSinceReset = 0;
33
35
  /**
34
36
  * Initialize with pre-flight rate limit data from GitHub.
35
37
  */
@@ -38,6 +40,7 @@ export class SearchBudgetTracker {
38
40
  this.resetAt = new Date(resetAt).getTime();
39
41
  this.callTimestamps = [];
40
42
  this.totalCalls = 0;
43
+ this.callsSinceReset = 0;
41
44
  debug(MODULE, `Initialized: ${remaining} remaining, resets at ${new Date(this.resetAt).toLocaleTimeString()}`);
42
45
  }
43
46
  /**
@@ -46,8 +49,28 @@ export class SearchBudgetTracker {
46
49
  recordCall() {
47
50
  this.callTimestamps.push(Date.now());
48
51
  this.totalCalls++;
52
+ this.callsSinceReset++;
49
53
  this.pruneOldTimestamps();
50
54
  }
55
+ /**
56
+ * Replenish the external budget once GitHub's quota window has reset.
57
+ * Without this, the pre-flight remaining acted as a never-replenishing
58
+ * run-lifetime total, throttling long multi-phase runs to ~1 call/min
59
+ * even though GitHub fully resets every 60 seconds (#119).
60
+ */
61
+ refreshExternalBudget() {
62
+ if (this.resetAt <= 0)
63
+ return;
64
+ const now = Date.now();
65
+ if (now < this.resetAt)
66
+ return;
67
+ this.knownRemaining = SEARCH_RATE_LIMIT;
68
+ this.callsSinceReset = 0;
69
+ while (this.resetAt <= now) {
70
+ this.resetAt += SEARCH_WINDOW_MS;
71
+ }
72
+ debug(MODULE, "Quota window reset; external search budget replenished");
73
+ }
51
74
  /**
52
75
  * Remove timestamps older than the sliding window.
53
76
  */
@@ -69,9 +92,11 @@ export class SearchBudgetTracker {
69
92
  * and the pre-flight remaining quota from GitHub.
70
93
  */
71
94
  getEffectiveBudget() {
72
- // Use the stricter of: local window limit vs. pre-flight remaining minus calls made
95
+ this.refreshExternalBudget();
96
+ // Use the stricter of: local window limit vs. known remaining quota
97
+ // minus calls made since the last reset
73
98
  const localBudget = EFFECTIVE_BUDGET - this.callTimestamps.length;
74
- const externalBudget = this.knownRemaining - this.totalCalls;
99
+ const externalBudget = this.knownRemaining - this.callsSinceReset;
75
100
  return Math.max(0, Math.min(localBudget, externalBudget));
76
101
  }
77
102
  /**
@@ -97,7 +122,15 @@ export class SearchBudgetTracker {
97
122
  // Wait until the oldest call in the window ages out
98
123
  const oldestInWindow = this.callTimestamps[0];
99
124
  if (!oldestInWindow) {
100
- return; // No calls in window — budget exhausted by external consumption, can't wait it out
125
+ // No calls in window — the external quota is exhausted. Wait for
126
+ // GitHub's reset when we know it, otherwise proceed (#119).
127
+ const untilReset = this.resetAt - Date.now();
128
+ if (this.resetAt > 0 && untilReset > 0) {
129
+ debug(MODULE, `External quota exhausted, waiting ${untilReset}ms for reset`);
130
+ await sleep(untilReset + 100);
131
+ continue;
132
+ }
133
+ return;
101
134
  }
102
135
  const waitUntil = oldestInWindow + SEARCH_WINDOW_MS;
103
136
  const waitMs = waitUntil - Date.now();