@oss-autopilot/core 3.13.4 → 3.14.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 (85) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.js +50 -83
  3. package/dist/cli.bundle.cjs +110 -107
  4. package/dist/cli.js +5 -4
  5. package/dist/commands/comments.js +44 -10
  6. package/dist/commands/config.d.ts +2 -0
  7. package/dist/commands/config.js +50 -2
  8. package/dist/commands/curated-list.d.ts +17 -0
  9. package/dist/commands/curated-list.js +25 -0
  10. package/dist/commands/daily.d.ts +7 -1
  11. package/dist/commands/daily.js +136 -57
  12. package/dist/commands/dashboard-cache.d.ts +69 -0
  13. package/dist/commands/dashboard-cache.js +219 -0
  14. package/dist/commands/dashboard-data.d.ts +18 -10
  15. package/dist/commands/dashboard-data.js +35 -7
  16. package/dist/commands/dashboard-gist-sync.d.ts +93 -0
  17. package/dist/commands/dashboard-gist-sync.js +237 -0
  18. package/dist/commands/dashboard-server.d.ts +6 -10
  19. package/dist/commands/dashboard-server.js +148 -341
  20. package/dist/commands/features.js +6 -0
  21. package/dist/commands/guidelines.d.ts +6 -0
  22. package/dist/commands/guidelines.js +7 -0
  23. package/dist/commands/index.d.ts +2 -5
  24. package/dist/commands/index.js +2 -4
  25. package/dist/commands/init.d.ts +2 -0
  26. package/dist/commands/init.js +7 -1
  27. package/dist/commands/list-mark-done.js +6 -21
  28. package/dist/commands/list-move-tier.js +3 -5
  29. package/dist/commands/locate-issue-list.d.ts +25 -0
  30. package/dist/commands/locate-issue-list.js +67 -0
  31. package/dist/commands/merge-loop.d.ts +63 -0
  32. package/dist/commands/merge-loop.js +157 -0
  33. package/dist/commands/repo-vet.js +40 -1
  34. package/dist/commands/scout-bridge.d.ts +35 -2
  35. package/dist/commands/scout-bridge.js +65 -13
  36. package/dist/commands/search.d.ts +4 -6
  37. package/dist/commands/search.js +58 -11
  38. package/dist/commands/setup.d.ts +2 -0
  39. package/dist/commands/setup.js +56 -2
  40. package/dist/commands/skip-file-parser.d.ts +23 -0
  41. package/dist/commands/skip-file-parser.js +23 -10
  42. package/dist/commands/startup.d.ts +1 -6
  43. package/dist/commands/startup.js +25 -59
  44. package/dist/commands/track.d.ts +2 -2
  45. package/dist/commands/track.js +2 -2
  46. package/dist/commands/vet-list.js +4 -0
  47. package/dist/core/config-registry.js +36 -0
  48. package/dist/core/daily-logic.d.ts +25 -2
  49. package/dist/core/daily-logic.js +58 -3
  50. package/dist/core/gist-health.d.ts +81 -0
  51. package/dist/core/gist-health.js +39 -0
  52. package/dist/core/gist-state-store.d.ts +3 -1
  53. package/dist/core/gist-state-store.js +7 -2
  54. package/dist/core/github-stats.d.ts +2 -2
  55. package/dist/core/github-stats.js +20 -4
  56. package/dist/core/index.d.ts +4 -3
  57. package/dist/core/index.js +4 -3
  58. package/dist/core/issue-conversation.js +8 -2
  59. package/dist/core/issue-grading.d.ts +9 -0
  60. package/dist/core/issue-grading.js +9 -0
  61. package/dist/core/pagination.d.ts +27 -0
  62. package/dist/core/pagination.js +23 -5
  63. package/dist/core/pr-comments-fetcher.d.ts +7 -0
  64. package/dist/core/pr-comments-fetcher.js +19 -8
  65. package/dist/core/pr-monitor.d.ts +2 -0
  66. package/dist/core/pr-monitor.js +26 -9
  67. package/dist/core/repo-score-manager.d.ts +2 -2
  68. package/dist/core/repo-score-manager.js +3 -3
  69. package/dist/core/repo-vet.d.ts +2 -2
  70. package/dist/core/repo-vet.js +1 -1
  71. package/dist/core/review-analysis.d.ts +19 -0
  72. package/dist/core/review-analysis.js +28 -0
  73. package/dist/core/state-schema.d.ts +43 -6
  74. package/dist/core/state-schema.js +81 -4
  75. package/dist/core/state.d.ts +36 -5
  76. package/dist/core/state.js +177 -28
  77. package/dist/core/strategy.js +6 -5
  78. package/dist/core/types.d.ts +8 -0
  79. package/dist/core/untrusted-content.d.ts +45 -0
  80. package/dist/core/untrusted-content.js +54 -0
  81. package/dist/formatters/json.d.ts +81 -7
  82. package/dist/formatters/json.js +55 -2
  83. package/package.json +2 -2
  84. package/dist/commands/shelve.d.ts +0 -45
  85. package/dist/commands/shelve.js +0 -54
@@ -4,7 +4,20 @@
4
4
  */
5
5
  import { createScout } from '@oss-scout/core';
6
6
  import { getStateManager, isLinkedPRStalled, requireGitHubToken } from '../core/index.js';
7
- import { loadSkippedIssues } from './skip-file-parser.js';
7
+ import { computeStrategy } from '../core/strategy.js';
8
+ import { loadSkippedIssuesDetailed } from './skip-file-parser.js';
9
+ /**
10
+ * Fraction of search slots reserved for candidates that matched neither
11
+ * strategy-preferred languages nor repos (#1244). Counterweight against
12
+ * echo-chamber bias: without it, strategy-boosted searches return more of
13
+ * what already merged, which merges more of the same, and the profile
14
+ * narrows over time. Scout clamps to [0, 1]; 0.2 is the issue's proposal.
15
+ *
16
+ * Lives here (not `search.ts`, which re-exports it) since #1464: the same
17
+ * ratio is baked into the `ScoutPreferences` built by {@link buildScoutState}
18
+ * so every scout surface shares one policy.
19
+ */
20
+ export const SEARCH_DIVERSITY_RATIO = 0.2;
8
21
  /**
9
22
  * Convert scout 0.6.0's `LinkedPR` (separate `state` + `merged`) into the
10
23
  * shape `classifyLinkedPR` expects (`state` already folded with `merged`).
@@ -56,10 +69,30 @@ export function buildCandidateLinkedPR(scoutLinkedPR) {
56
69
  /**
57
70
  * Build a ScoutState from the current AgentState.
58
71
  * Maps oss-autopilot's config and state fields to oss-scout's state format.
72
+ *
73
+ * @param diagnostics - Optional collector for degradation signals (#1448);
74
+ * `skipListUnavailable` is set when the skipped-issues file exists but
75
+ * could not be read.
59
76
  */
60
- export function buildScoutState() {
77
+ export function buildScoutState(diagnostics) {
61
78
  const state = getStateManager().getState();
62
79
  const { config } = state;
80
+ // Read failure (not absence) of the skip file means scout will see an empty
81
+ // skip list and may resurface explicitly-skipped issues — flag it so the
82
+ // search envelope can carry the signal instead of only stderr (#1448).
83
+ const skippedIssuesLoad = loadSkippedIssuesDetailed(config.skippedIssuesPath);
84
+ if (skippedIssuesLoad.readError !== undefined && diagnostics) {
85
+ diagnostics.skipListUnavailable = true;
86
+ }
87
+ // Strategy-derived personalization (#1464). `computeStrategy` returns null
88
+ // below the merged-PR floor — empty lists then read as "no bias" on scout's
89
+ // side. Baking the bias into the preferences (rather than only per-call
90
+ // search options, #1244) makes every scout surface share it: `search()`
91
+ // falls back to these when no per-call override is passed, and `features()`
92
+ // exposes no per-call bias knobs at all (scout 1.1.0 accepts only
93
+ // count/anchorThreshold/splitRatio/broad), so preferences are the only
94
+ // channel by which bias can reach the features path.
95
+ const strategy = computeStrategy(state);
63
96
  return {
64
97
  version: 1,
65
98
  preferences: {
@@ -90,15 +123,31 @@ export function buildScoutState() {
90
123
  // `--split-ratio` flags for overrides.
91
124
  featuresAnchorThreshold: 3,
92
125
  featuresSplitRatio: 0.6,
93
- // Personalization-bias prefs, likewise required on the inferred type
94
- // (scout #1244 / #168). Autopilot doesn't surface these as settings yet,
95
- // so we pass scout's documented "no bias" defaults.
96
- preferLanguages: [],
97
- preferRepos: [],
98
- diversityRatio: 0,
99
- avoidRepos: [],
100
- boostIssueTypes: [],
126
+ // Personalization-bias prefs (scout #1244 / #168 / #1464).
127
+ // preferLanguages/preferRepos mirror the strategy derivation the search
128
+ // command uses per-call; avoidRepos/boostIssueTypes are user settings
129
+ // (config-registry keys); diversityRatio matches the search policy.
130
+ preferLanguages: strategy?.recommendations.languages ?? [],
131
+ preferRepos: strategy?.recommendations.repos ?? [],
132
+ diversityRatio: SEARCH_DIVERSITY_RATIO,
133
+ avoidRepos: config.avoidRepos ?? [],
134
+ boostIssueTypes: config.boostIssueTypes ?? [],
101
135
  },
136
+ // CONTRACT (#1458): repoScores is passed BY REFERENCE, not copied. Scout's
137
+ // updateRepoScore mutates entries of this shared object in place, so scout
138
+ // calls that touch scores (e.g. search's hasActiveMaintainers feedback,
139
+ // scout #167) write straight into the in-memory AgentState. Those writes
140
+ // reach disk only if a later StateManager mutation triggers a save —
141
+ // scout's own checkpoint() persists nothing under persistence:'provided'.
142
+ // Nothing may RELY on this alias for persistence: daily's former Phase 3.5
143
+ // did, and was deleted in #1458 (its record* calls were also dedup no-ops
144
+ // against the ledger Phase 3 had just written; see
145
+ // scout-bridge.repo-scores.test.ts for the pinning tests). Also note scout
146
+ // recalculates `score` with its own formula (linear merge bonus, no
147
+ // recency term), which differs from autopilot's repo-score-manager
148
+ // formula; daily Phase 2 recomputes every repo it touches with the
149
+ // autopilot formula each run, so the persisted history scores reflect
150
+ // autopilot's model.
102
151
  repoScores: state.repoScores,
103
152
  // Scout #117 added tombstones (deletion records for gist merge). Autopilot
104
153
  // synthesizes a fresh ScoutState per operation and tracks no deletions, so
@@ -124,19 +173,22 @@ export function buildScoutState() {
124
173
  openedAt: pr.createdAt,
125
174
  })),
126
175
  savedResults: [],
127
- skippedIssues: loadSkippedIssues(config.skippedIssuesPath),
176
+ skippedIssues: skippedIssuesLoad.issues,
128
177
  lastRunAt: state.lastRunAt,
129
178
  };
130
179
  }
131
180
  /**
132
181
  * Create an OssScout instance backed by the current AgentState.
133
182
  * Uses 'provided' persistence so scout reads from oss-autopilot's state.
183
+ *
184
+ * @param diagnostics - Optional collector for degradation signals (#1448),
185
+ * forwarded to {@link buildScoutState}.
134
186
  */
135
- export async function createAutopilotScout() {
187
+ export async function createAutopilotScout(diagnostics) {
136
188
  const token = requireGitHubToken();
137
189
  return createScout({
138
190
  githubToken: token,
139
191
  persistence: 'provided',
140
- initialState: buildScoutState(),
192
+ initialState: buildScoutState(diagnostics),
141
193
  });
142
194
  }
@@ -11,13 +11,11 @@ export { type SearchOutput } from '../formatters/json.js';
11
11
  */
12
12
  export declare const MAX_SEARCH_RESULTS = 100;
13
13
  /**
14
- * Fraction of search slots reserved for candidates that matched neither
15
- * strategy-preferred languages nor repos (#1244). Counterweight against
16
- * echo-chamber bias: without it, strategy-boosted searches return more of
17
- * what already merged, which merges more of the same, and the profile
18
- * narrows over time. Scout clamps to [0, 1]; 0.2 is the issue's proposal.
14
+ * Diversity-counterweight ratio (#1244). Moved to `scout-bridge.ts` in #1464
15
+ * (it is now also baked into the bridge-built `ScoutPreferences`); re-exported
16
+ * here so existing importers keep working.
19
17
  */
20
- export declare const SEARCH_DIVERSITY_RATIO = 0.2;
18
+ export { SEARCH_DIVERSITY_RATIO } from './scout-bridge.js';
21
19
  interface SearchOptions {
22
20
  maxResults: number;
23
21
  }
@@ -2,7 +2,7 @@
2
2
  * Search command
3
3
  * Searches for new issues to work on via @oss-scout/core
4
4
  */
5
- import { adaptScoutLinkedPR, buildCandidateLinkedPR, createAutopilotScout } from './scout-bridge.js';
5
+ import { adaptScoutLinkedPR, buildCandidateLinkedPR, createAutopilotScout, SEARCH_DIVERSITY_RATIO, } from './scout-bridge.js';
6
6
  import { classifyLinkedPR, getStateManager } from '../core/index.js';
7
7
  import { gradeFromCandidate } from '../core/issue-grading.js';
8
8
  import { computeStrategy } from '../core/strategy.js';
@@ -15,13 +15,11 @@ const MODULE = 'search';
15
15
  */
16
16
  export const MAX_SEARCH_RESULTS = 100;
17
17
  /**
18
- * Fraction of search slots reserved for candidates that matched neither
19
- * strategy-preferred languages nor repos (#1244). Counterweight against
20
- * echo-chamber bias: without it, strategy-boosted searches return more of
21
- * what already merged, which merges more of the same, and the profile
22
- * narrows over time. Scout clamps to [0, 1]; 0.2 is the issue's proposal.
18
+ * Diversity-counterweight ratio (#1244). Moved to `scout-bridge.ts` in #1464
19
+ * (it is now also baked into the bridge-built `ScoutPreferences`); re-exported
20
+ * here so existing importers keep working.
23
21
  */
24
- export const SEARCH_DIVERSITY_RATIO = 0.2;
22
+ export { SEARCH_DIVERSITY_RATIO } from './scout-bridge.js';
25
23
  /**
26
24
  * Search GitHub for contributable issues using multi-phase discovery.
27
25
  *
@@ -53,25 +51,66 @@ function sanitizeViabilityScore(raw) {
53
51
  }
54
52
  return raw;
55
53
  }
54
+ /**
55
+ * Read a non-negative integer scout-delay override from an env var.
56
+ *
57
+ * Scout spaces its multi-phase `/search/issues` calls with an inter-phase
58
+ * delay (default 30s) and a broad-phase delay (default 90s) to stay under
59
+ * GitHub's secondary rate limit (see `buildScoutState`). Those delays make a
60
+ * single `search` invocation take ~100s, which is fine in normal use but makes
61
+ * the live-API e2e suite (`search.e2e.test.ts`, run only in the nightly
62
+ * workflow — see #1452) impractically slow. These env vars let that suite
63
+ * collapse the delays so the test completes in a realistic CI window.
64
+ *
65
+ * Returns `undefined` when the var is unset/empty or not a parseable
66
+ * non-negative integer, so scout falls back to its preference value and
67
+ * production behavior is unchanged. Only the live test/nightly run sets them.
68
+ */
69
+ function readScoutDelayOverride(envVar) {
70
+ const raw = process.env[envVar];
71
+ if (raw === undefined || raw === '') {
72
+ return undefined;
73
+ }
74
+ const parsed = Number(raw);
75
+ if (!Number.isInteger(parsed) || parsed < 0) {
76
+ warn(MODULE, `Ignoring invalid ${envVar}="${raw}" (expected a non-negative integer); using scout's default delay`);
77
+ return undefined;
78
+ }
79
+ return parsed;
80
+ }
56
81
  export async function runSearch(options) {
57
- const scout = await createAutopilotScout();
82
+ // Collect bridge-level degradation signals (#1448): an unreadable skip file
83
+ // means scout searched with an empty skip list, so explicitly-skipped
84
+ // issues may resurface — the envelope must say so, not just stderr.
85
+ const bridgeDiagnostics = {};
86
+ const scout = await createAutopilotScout(bridgeDiagnostics);
58
87
  const stateManager = getStateManager();
59
88
  // Derive personalization from local history (#1244). `computeStrategy`
60
89
  // returns null below the merged-PR floor — in that case we pass nothing
61
- // and scout's sort behaves exactly as before. Once strategy data is
62
- // available, scout boosts language/repo matches into a separate sort
63
- // tier (still no filtering).
90
+ // and scout falls back to the bridge-built preferences, which derive the
91
+ // same values from the same state (#1464), so the sort is identical either
92
+ // way. The explicit per-call pass is kept for self-documentation. Once
93
+ // strategy data is available, scout boosts language/repo matches into a
94
+ // separate sort tier (still no filtering). avoidRepos/boostIssueTypes are
95
+ // not passed per-call: scout reads them from the bridge-built preferences.
64
96
  const strategy = computeStrategy(stateManager.getState());
65
97
  const preferLanguages = strategy?.recommendations.languages ?? undefined;
66
98
  const preferRepos = strategy?.recommendations.repos ?? undefined;
67
99
  if (preferLanguages?.length || preferRepos?.length) {
68
100
  debug(MODULE, `Applying strategy bias to search: preferLanguages=${JSON.stringify(preferLanguages ?? [])}, preferRepos=${JSON.stringify(preferRepos ?? [])}`);
69
101
  }
102
+ // Live-API test/nightly affordance (#1452): collapse scout's inter-phase
103
+ // delays so the e2e suite finishes in a realistic window. Unset in normal
104
+ // use → undefined → scout falls back to the 30s/90s preference defaults.
105
+ const interPhaseDelayMs = readScoutDelayOverride('OSS_AUTOPILOT_SCOUT_INTER_PHASE_DELAY_MS');
106
+ const broadPhaseDelayMs = readScoutDelayOverride('OSS_AUTOPILOT_SCOUT_BROAD_PHASE_DELAY_MS');
70
107
  const result = await scout.search({
71
108
  maxResults: options.maxResults,
72
109
  preferLanguages,
73
110
  preferRepos,
74
111
  diversityRatio: SEARCH_DIVERSITY_RATIO,
112
+ ...(interPhaseDelayMs !== undefined ? { interPhaseDelayMs } : {}),
113
+ ...(broadPhaseDelayMs !== undefined ? { broadPhaseDelayMs } : {}),
75
114
  });
76
115
  // #1354: never surface issues the user already has an open PR for. Uses
77
116
  // scout's structured linked-PR metadata when present; candidates without it
@@ -95,6 +134,11 @@ export async function runSearch(options) {
95
134
  // the autopilot-tracked repoScore. Candidates without a repoScore
96
135
  // receive 'F' — that's an honest signal for "we haven't seen this repo
97
136
  // before" rather than a fabricated score.
137
+ //
138
+ // Note (#1465): repoScore here is the cached HISTORY score (the user's
139
+ // own merge outcomes — docs/repo-scores.md §History score), so this
140
+ // grade reflects history only; `vet` later re-grades the same issue
141
+ // with freshly fetched repo health and can legitimately disagree.
98
142
  const grade = gradeFromCandidate({
99
143
  repo: c.issue.repo,
100
144
  projectHealth: {
@@ -156,5 +200,8 @@ export async function runSearch(options) {
156
200
  if (result.rateLimitWarning) {
157
201
  searchOutput.rateLimitWarning = result.rateLimitWarning;
158
202
  }
203
+ if (bridgeDiagnostics.skipListUnavailable) {
204
+ searchOutput.skipListUnavailable = true;
205
+ }
159
206
  return searchOutput;
160
207
  }
@@ -11,6 +11,8 @@ export interface SetupSetOutput {
11
11
  success: true;
12
12
  settings: Record<string, string>;
13
13
  warnings?: string[];
14
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1440). */
15
+ gistSyncWarning?: string;
14
16
  }
15
17
  export interface SetupCompleteOutput {
16
18
  setupComplete: true;
@@ -2,10 +2,11 @@
2
2
  * Setup command
3
3
  * Interactive setup / configuration
4
4
  */
5
- import { getStateManager, DEFAULT_CONFIG, formatUnknownKeyError, getSetupKeys } from '../core/index.js';
5
+ import { getStateManager, DEFAULT_CONFIG, formatUnknownKeyError, getSetupKeys, maybeCheckpoint, } from '../core/index.js';
6
6
  import { ValidationError } from '../core/errors.js';
7
7
  import { validateGitHubUsername } from './validation.js';
8
8
  import { PROJECT_CATEGORIES, ISSUE_SCOPES, DIFF_TOOLS, } from '../core/types.js';
9
+ const MODULE = 'setup';
9
10
  /** Parse and validate a positive integer setting value. */
10
11
  function parsePositiveInt(value, settingName) {
11
12
  const parsed = Number(value);
@@ -168,6 +169,50 @@ export async function runSetup(options) {
168
169
  results[key] = valid.length > 0 ? valid.join(', ') : '(empty)';
169
170
  break;
170
171
  }
172
+ case 'avoidRepos': {
173
+ // Same owner/repo validation as aiPolicyBlocklist: skip (and warn
174
+ // about) malformed entries instead of failing the whole set.
175
+ const entries = value
176
+ .split(',')
177
+ .map((r) => r.trim())
178
+ .filter(Boolean);
179
+ const valid = [];
180
+ const invalid = [];
181
+ for (const entry of entries) {
182
+ const normalized = entry.replace(/\s+/g, '');
183
+ if (/^[\w.-]+\/[\w.-]+$/.test(normalized)) {
184
+ valid.push(normalized);
185
+ }
186
+ else {
187
+ invalid.push(entry);
188
+ }
189
+ }
190
+ if (invalid.length > 0) {
191
+ warnings.push(`Warning: Skipping invalid entries (expected "owner/repo" format): ${invalid.join(', ')}`);
192
+ }
193
+ if (valid.length === 0 && entries.length > 0) {
194
+ warnings.push('Warning: All entries were invalid. avoidRepos not updated.');
195
+ results[key] = '(all entries invalid)';
196
+ break;
197
+ }
198
+ const dedupedRepos = [...new Set(valid)];
199
+ stateManager.updateConfig({ avoidRepos: dedupedRepos });
200
+ results[key] = dedupedRepos.length > 0 ? dedupedRepos.join(', ') : '(empty)';
201
+ break;
202
+ }
203
+ case 'boostIssueTypes': {
204
+ // Free-form issue labels (scout matches them case-insensitively);
205
+ // an empty value clears the list.
206
+ const types = [
207
+ ...new Set(value
208
+ .split(',')
209
+ .map((t) => t.trim())
210
+ .filter(Boolean)),
211
+ ];
212
+ stateManager.updateConfig({ boostIssueTypes: types });
213
+ results[key] = types.length > 0 ? types.join(', ') : '(empty)';
214
+ break;
215
+ }
171
216
  case 'projectCategories': {
172
217
  const categories = value
173
218
  .split(',')
@@ -276,7 +321,16 @@ export async function runSetup(options) {
276
321
  }
277
322
  }
278
323
  });
279
- return { success: true, settings: results, warnings: warnings.length > 0 ? warnings : undefined };
324
+ // Push the settings mutation to the Gist in gist mode (no-op locally).
325
+ // Without this the change only hits the local cache and the next
326
+ // bootstrap reverts it from the Gist (#1440).
327
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
328
+ return {
329
+ success: true,
330
+ settings: results,
331
+ warnings: warnings.length > 0 ? warnings : undefined,
332
+ ...(gistSyncWarning ? { gistSyncWarning } : {}),
333
+ };
280
334
  }
281
335
  // Show setup status
282
336
  if (config.setupComplete && !options.reset) {
@@ -15,11 +15,34 @@ import type { SkippedIssue } from '@oss-scout/core';
15
15
  * pass through unchanged.
16
16
  */
17
17
  export declare function parseSkippedIssuesContent(content: string): SkippedIssue[];
18
+ /** Result of {@link loadSkippedIssuesDetailed}: parsed entries plus read-failure signal (#1448). */
19
+ export interface SkippedIssuesLoadResult {
20
+ issues: SkippedIssue[];
21
+ /**
22
+ * Set when the file exists but could not be read (`issues` is `[]` in that
23
+ * case). Distinguishes "no skips configured" from "skip list unavailable" —
24
+ * without it, a read failure silently resurfaces explicitly-skipped issues
25
+ * in search results. Absent when the path is unset, the file does not
26
+ * exist, or the read succeeded.
27
+ */
28
+ readError?: string;
29
+ }
30
+ /**
31
+ * Read the skipped-issues file from disk and parse it, surfacing read
32
+ * failures to the caller (#1448).
33
+ * Returns `{ issues: [] }` (no `readError`) when `path` is undefined/empty or
34
+ * the file does not exist; `{ issues: [], readError }` when the read throws
35
+ * (a warning is also logged).
36
+ */
37
+ export declare function loadSkippedIssuesDetailed(path: string | undefined): SkippedIssuesLoadResult;
18
38
  /**
19
39
  * Read the skipped-issues file from disk and parse it.
20
40
  * Returns `[]` when:
21
41
  * - `path` is undefined or empty,
22
42
  * - the file does not exist,
23
43
  * - the file cannot be read (a warning is logged).
44
+ *
45
+ * Callers that need to distinguish "unreadable" from "no skips" should use
46
+ * {@link loadSkippedIssuesDetailed} instead.
24
47
  */
25
48
  export declare function loadSkippedIssues(path: string | undefined): SkippedIssue[];
@@ -69,24 +69,37 @@ export function parseSkippedIssuesContent(content) {
69
69
  return results;
70
70
  }
71
71
  /**
72
- * Read the skipped-issues file from disk and parse it.
73
- * Returns `[]` when:
74
- * - `path` is undefined or empty,
75
- * - the file does not exist,
76
- * - the file cannot be read (a warning is logged).
72
+ * Read the skipped-issues file from disk and parse it, surfacing read
73
+ * failures to the caller (#1448).
74
+ * Returns `{ issues: [] }` (no `readError`) when `path` is undefined/empty or
75
+ * the file does not exist; `{ issues: [], readError }` when the read throws
76
+ * (a warning is also logged).
77
77
  */
78
- export function loadSkippedIssues(path) {
78
+ export function loadSkippedIssuesDetailed(path) {
79
79
  if (!path)
80
- return [];
80
+ return { issues: [] };
81
81
  if (!fs.existsSync(path))
82
- return [];
82
+ return { issues: [] };
83
83
  let content;
84
84
  try {
85
85
  content = fs.readFileSync(path, 'utf8');
86
86
  }
87
87
  catch (err) {
88
88
  warn('skip-file-parser', `Failed to read skipped-issues file at ${path}: ${errorMessage(err)}`);
89
- return [];
89
+ return { issues: [], readError: errorMessage(err) };
90
90
  }
91
- return parseSkippedIssuesContent(content);
91
+ return { issues: parseSkippedIssuesContent(content) };
92
+ }
93
+ /**
94
+ * Read the skipped-issues file from disk and parse it.
95
+ * Returns `[]` when:
96
+ * - `path` is undefined or empty,
97
+ * - the file does not exist,
98
+ * - the file cannot be read (a warning is logged).
99
+ *
100
+ * Callers that need to distinguish "unreadable" from "no skips" should use
101
+ * {@link loadSkippedIssuesDetailed} instead.
102
+ */
103
+ export function loadSkippedIssues(path) {
104
+ return loadSkippedIssuesDetailed(path).issues;
92
105
  }
@@ -7,12 +7,7 @@
7
7
  * `node cli.bundle.cjs startup --json` call, reducing UI noise in Claude Code.
8
8
  */
9
9
  import { type StartupOutput, type IssueListInfo } from '../formatters/json.js';
10
- /**
11
- * Parse issueListPath from a config file's YAML frontmatter.
12
- * @param configContent - Raw content of the config.md file
13
- * @returns The path string or undefined if not found
14
- */
15
- export declare function parseIssueListPathFromConfig(configContent: string): string | undefined;
10
+ export { parseIssueListPathFromConfig } from './locate-issue-list.js';
16
11
  /**
17
12
  * Count available and completed items in an issue list file.
18
13
  * Available items exclude any vetting status that moves an item out of the
@@ -16,19 +16,11 @@ import { executeDailyCheck } from './daily.js';
16
16
  import { launchDashboardServer } from './dashboard-lifecycle.js';
17
17
  import { recordBrowserOpened } from './dashboard-process.js';
18
18
  import { parseIssueList } from './parse-list.js';
19
- /**
20
- * Parse issueListPath from a config file's YAML frontmatter.
21
- * @param configContent - Raw content of the config.md file
22
- * @returns The path string or undefined if not found
23
- */
24
- export function parseIssueListPathFromConfig(configContent) {
25
- const match = configContent.match(/^---\n([\s\S]*?)\n---/);
26
- if (!match)
27
- return undefined;
28
- const frontmatter = match[1];
29
- const pathMatch = frontmatter.match(/issueListPath:\s*["']?([^"'\n]+)["']?/);
30
- return pathMatch ? pathMatch[1].trim() : undefined;
31
- }
19
+ import { detectIssueListPath } from './locate-issue-list.js';
20
+ // Path detection moved to locate-issue-list.ts (#1463) so the daily
21
+ // merge-loop can use it without an import cycle through this module.
22
+ // Re-exported here for back-compat with existing consumers/tests.
23
+ export { parseIssueListPathFromConfig } from './locate-issue-list.js';
32
24
  /**
33
25
  * Count available and completed items in an issue list file.
34
26
  * Available items exclude any vetting status that moves an item out of the
@@ -45,60 +37,27 @@ export function countIssueListItems(content) {
45
37
  * @returns Issue list info with path and item counts, or undefined if not found
46
38
  */
47
39
  export function detectIssueList() {
48
- let issueListPath = '';
49
- let source = 'auto-detected';
50
- // 1. Check state.json config (primary)
51
- try {
52
- const stateManager = getStateManager();
53
- const configuredPath = stateManager.getState().config.issueListPath;
54
- if (configuredPath && fs.existsSync(configuredPath)) {
55
- issueListPath = configuredPath;
56
- source = 'configured';
57
- }
58
- }
59
- catch (error) {
60
- // State manager may not be initialized yet — fall through to legacy config.md
61
- warn('startup', `Could not read issueListPath from state: ${errorMessage(error)}`);
62
- }
63
- // 2. Fallback: config.md (legacy — will be removed in future)
64
- if (!issueListPath) {
65
- const configPath = '.claude/oss-autopilot/config.md';
66
- if (fs.existsSync(configPath)) {
67
- try {
68
- const configContent = fs.readFileSync(configPath, 'utf8');
69
- const configuredPath = parseIssueListPathFromConfig(configContent);
70
- if (configuredPath && fs.existsSync(configuredPath)) {
71
- issueListPath = configuredPath;
72
- source = 'configured';
73
- }
74
- }
75
- catch (error) {
76
- console.error('[STARTUP] Failed to read config:', errorMessage(error));
77
- }
78
- }
79
- }
80
- // 3. Probe known paths
81
- if (!issueListPath) {
82
- const probes = ['open-source/potential-issue-list.md', 'oss/issue-list.md', 'issues.md'];
83
- for (const probe of probes) {
84
- if (fs.existsSync(probe)) {
85
- issueListPath = probe;
86
- source = 'auto-detected';
87
- break;
88
- }
89
- }
90
- }
91
- if (!issueListPath)
40
+ // Steps 1-3 (state config → legacy config.md → known-path probes) live in
41
+ // locate-issue-list.ts (#1463), shared with the daily merge-loop.
42
+ const located = detectIssueListPath();
43
+ if (!located)
92
44
  return undefined;
45
+ const issueListPath = located.path;
46
+ const source = located.source;
93
47
  // 4. Count available/completed items
94
48
  let availableCount = 0;
95
49
  let completedCount = 0;
50
+ let readError;
96
51
  try {
97
52
  const content = fs.readFileSync(issueListPath, 'utf8');
98
53
  ({ availableCount, completedCount } = countIssueListItems(content));
99
54
  }
100
55
  catch (error) {
101
- console.error(`[STARTUP] Failed to read issue list at ${issueListPath}:`, errorMessage(error));
56
+ // Surface the failure in the envelope (#1448): a 0/0 count from an
57
+ // unreadable list is indistinguishable from a genuinely empty list, and
58
+ // an agent seeing 0 available may trigger a redundant fresh search.
59
+ readError = errorMessage(error);
60
+ console.error(`[STARTUP] Failed to read issue list at ${issueListPath}:`, readError);
102
61
  }
103
62
  // 5. Detect skipped issues file
104
63
  let skippedIssuesPath;
@@ -145,7 +104,14 @@ export function detectIssueList() {
145
104
  }
146
105
  }
147
106
  }
148
- return { path: issueListPath, source, availableCount, completedCount, skippedIssuesPath };
107
+ return {
108
+ path: issueListPath,
109
+ source,
110
+ availableCount,
111
+ completedCount,
112
+ skippedIssuesPath,
113
+ ...(readError !== undefined ? { readError } : {}),
114
+ };
149
115
  }
150
116
  /**
151
117
  * Open a URL in the default system browser.
@@ -7,8 +7,8 @@
7
7
  * metadata from GitHub and returns it; useful for inspecting a specific
8
8
  * PR's shape without waiting for the next `daily` run. Nothing is persisted.
9
9
  *
10
- * The `runUntrack` v1→v2 stub was removed in v4 (#1133). Use `shelve`/
11
- * `unshelve` to hide a PR from the daily digest.
10
+ * The `runUntrack` v1→v2 stub was removed in v4 (#1133). Use `move`
11
+ * (target `shelved`/`auto`) to hide/restore a PR in the daily digest.
12
12
  */
13
13
  import type { TrackOutput } from '../formatters/json.js';
14
14
  /**
@@ -7,8 +7,8 @@
7
7
  * metadata from GitHub and returns it; useful for inspecting a specific
8
8
  * PR's shape without waiting for the next `daily` run. Nothing is persisted.
9
9
  *
10
- * The `runUntrack` v1→v2 stub was removed in v4 (#1133). Use `shelve`/
11
- * `unshelve` to hide a PR from the daily digest.
10
+ * The `runUntrack` v1→v2 stub was removed in v4 (#1133). Use `move`
11
+ * (target `shelved`/`auto`) to hide/restore a PR in the daily digest.
12
12
  */
13
13
  import { getOctokit, requireGitHubToken } from '../core/index.js';
14
14
  import { ValidationError } from '../core/errors.js';
@@ -207,6 +207,10 @@ export async function runVetList(options = {}) {
207
207
  catch (error) {
208
208
  const msg = error instanceof Error ? error.message : String(error);
209
209
  console.error(`Warning: Failed to prune ${issueListPath}: ${msg}`);
210
+ // Surface the failure in the envelope (#1448): without this, a failed
211
+ // prune returned a plain success with pruneResult simply absent — the
212
+ // same shape as "prune not requested".
213
+ pruneResult = { removedCount: 0, error: msg };
210
214
  }
211
215
  }
212
216
  return { results, summary, pruneResult };
@@ -104,6 +104,18 @@ export const CONFIG_KEY_REGISTRY = [
104
104
  settableVia: 'setup',
105
105
  valueHint: 'comma-separated list of owner/repo',
106
106
  },
107
+ {
108
+ key: 'avoidRepos',
109
+ description: 'Repos (owner/repo) to softly downrank in discovery results — milder than excludeRepos (whole-list replace).',
110
+ settableVia: 'setup',
111
+ valueHint: 'comma-separated list of owner/repo',
112
+ },
113
+ {
114
+ key: 'boostIssueTypes',
115
+ description: 'Issue label types to softly boost in discovery ranking (whole-list replace).',
116
+ settableVia: 'setup',
117
+ valueHint: 'comma-separated list (e.g. bug,good first issue)',
118
+ },
107
119
  // ── Exclusion list mutators (config-only) ────────────────────────────
108
120
  {
109
121
  key: 'add-language',
@@ -147,6 +159,30 @@ export const CONFIG_KEY_REGISTRY = [
147
159
  settableVia: 'config',
148
160
  valueHint: 'org name (no slash)',
149
161
  },
162
+ {
163
+ key: 'add-avoid-repo',
164
+ description: 'Append a repo (owner/repo) to the soft-downrank avoid list.',
165
+ settableVia: 'config',
166
+ valueHint: 'owner/repo',
167
+ },
168
+ {
169
+ key: 'remove-avoid-repo',
170
+ description: 'Remove a repo from the soft-downrank avoid list.',
171
+ settableVia: 'config',
172
+ valueHint: 'owner/repo (must already be present)',
173
+ },
174
+ {
175
+ key: 'add-boost-issue-type',
176
+ description: 'Append an issue label type to the discovery ranking boost list.',
177
+ settableVia: 'config',
178
+ valueHint: 'issue label (e.g. bug)',
179
+ },
180
+ {
181
+ key: 'remove-boost-issue-type',
182
+ description: 'Remove an issue label type from the discovery ranking boost list.',
183
+ settableVia: 'config',
184
+ valueHint: 'issue label (must already be present)',
185
+ },
150
186
  // ── Tooling ──────────────────────────────────────────────────────────
151
187
  {
152
188
  key: 'issueListPath',