@oss-autopilot/core 3.6.0 → 3.7.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.
@@ -7,6 +7,13 @@ export interface LaunchResult {
7
7
  url: string;
8
8
  port: number;
9
9
  alreadyRunning: boolean;
10
+ /**
11
+ * When `alreadyRunning` is true, the timestamp the running server most
12
+ * recently recorded a browser-open at (or undefined if never recorded).
13
+ * Used by startup to throttle duplicate browser tabs across `/oss` runs.
14
+ * Always undefined for fresh launches.
15
+ */
16
+ lastBrowserOpenedAt?: string;
10
17
  }
11
18
  /**
12
19
  * Launch the interactive dashboard SPA server as a detached background process.
@@ -61,12 +61,22 @@ export async function launchDashboardServer(options) {
61
61
  else {
62
62
  // Could not kill old server (e.g. EPERM); return it rather than
63
63
  // attempting a doomed spawn on the same port.
64
- return { url: existing.url, port: existing.port, alreadyRunning: true };
64
+ return {
65
+ url: existing.url,
66
+ port: existing.port,
67
+ alreadyRunning: true,
68
+ lastBrowserOpenedAt: info?.lastBrowserOpenedAt,
69
+ };
65
70
  }
66
71
  // Fall through to launch a new server
67
72
  }
68
73
  else {
69
- return { url: existing.url, port: existing.port, alreadyRunning: true };
74
+ return {
75
+ url: existing.url,
76
+ port: existing.port,
77
+ alreadyRunning: true,
78
+ lastBrowserOpenedAt: info.lastBrowserOpenedAt,
79
+ };
70
80
  }
71
81
  }
72
82
  // 3. Launch as detached child process
@@ -7,10 +7,18 @@ export interface DashboardServerInfo {
7
7
  port: number;
8
8
  startedAt: string;
9
9
  version?: string;
10
+ lastBrowserOpenedAt?: string;
10
11
  }
11
12
  export declare function getDashboardPidPath(): string;
12
13
  export declare function writeDashboardServerInfo(info: DashboardServerInfo): void;
13
14
  export declare function readDashboardServerInfo(): DashboardServerInfo | null;
15
+ /**
16
+ * Stamp the PID file with the current time as `lastBrowserOpenedAt`. Used by
17
+ * startup to throttle re-opening the dashboard tab — see the `shouldOpenBrowser`
18
+ * helper in `startup.ts`. No-ops if the PID file is missing or the recorded
19
+ * port doesn't match (server may have restarted between read and write).
20
+ */
21
+ export declare function recordBrowserOpened(port: number): void;
14
22
  export declare function removeDashboardServerInfo(): void;
15
23
  export declare function isDashboardServerRunning(port: number): Promise<boolean>;
16
24
  export declare function findRunningDashboardServer(): Promise<{
@@ -29,6 +29,9 @@ export function readDashboardServerInfo() {
29
29
  warn(MODULE, 'PID file has invalid structure, ignoring');
30
30
  return null;
31
31
  }
32
+ if (parsed.lastBrowserOpenedAt !== undefined && typeof parsed.lastBrowserOpenedAt !== 'string') {
33
+ delete parsed.lastBrowserOpenedAt;
34
+ }
32
35
  return parsed;
33
36
  }
34
37
  catch (err) {
@@ -39,6 +42,23 @@ export function readDashboardServerInfo() {
39
42
  return null;
40
43
  }
41
44
  }
45
+ /**
46
+ * Stamp the PID file with the current time as `lastBrowserOpenedAt`. Used by
47
+ * startup to throttle re-opening the dashboard tab — see the `shouldOpenBrowser`
48
+ * helper in `startup.ts`. No-ops if the PID file is missing or the recorded
49
+ * port doesn't match (server may have restarted between read and write).
50
+ */
51
+ export function recordBrowserOpened(port) {
52
+ const info = readDashboardServerInfo();
53
+ if (!info || info.port !== port)
54
+ return;
55
+ try {
56
+ writeDashboardServerInfo({ ...info, lastBrowserOpenedAt: new Date().toISOString() });
57
+ }
58
+ catch (err) {
59
+ warn(MODULE, `Failed to record browser-opened timestamp: ${err.message}`);
60
+ }
61
+ }
42
62
  export function removeDashboardServerInfo() {
43
63
  try {
44
64
  fs.unlinkSync(getDashboardPidPath());
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Features command (scout 0.9.0 #97/#98/#99)
3
+ *
4
+ * Wraps `scout.features()` to surface feature-scoped contribution
5
+ * opportunities in repos where the user already has 3+ merged PRs
6
+ * (configurable via `featuresAnchorThreshold`). Returns two ranked
7
+ * buckets — "quick wins" and "bigger bets" — split by maintainer-
8
+ * commitment signals (milestones, roadmap membership, label set).
9
+ *
10
+ * Mirrors the shape of `runSearch`: each bucket entry is a
11
+ * {@link SearchCandidate} augmented with a `horizon` literal so callers
12
+ * can render bucket-specific UX while reusing the search candidate
13
+ * formatter.
14
+ */
15
+ import { type FeaturesOutput } from '../formatters/json.js';
16
+ export { type FeaturesOutput, type FeaturesCandidate } from '../formatters/json.js';
17
+ /**
18
+ * Hard cap on feature-search result count, matching `MAX_SEARCH_RESULTS`
19
+ * for `runSearch`. Shared between CLI (`cli-registry.ts`) and the MCP
20
+ * tool registration so a future adjustment lands in one place.
21
+ */
22
+ export declare const MAX_FEATURES_RESULTS = 100;
23
+ interface FeaturesOptions {
24
+ maxResults: number;
25
+ /** Override `featuresAnchorThreshold` for this call (1-50). */
26
+ anchorThreshold?: number;
27
+ /** Override `featuresSplitRatio` for this call (0-1). */
28
+ splitRatio?: number;
29
+ }
30
+ /**
31
+ * Run feature-scoped contribution discovery via `scout.features()`.
32
+ *
33
+ * @param options - Feature search configuration
34
+ * @param options.maxResults - Total candidates returned across both buckets
35
+ * @param options.anchorThreshold - Optional per-call override for the merged-PR threshold (default: scout's `featuresAnchorThreshold`)
36
+ * @param options.splitRatio - Optional per-call override for the quick-win split ratio (default: scout's `featuresSplitRatio`)
37
+ * @returns Two ranked buckets (quick-wins / bigger-bets) plus anchor list and an optional human message
38
+ * @throws {ConfigurationError} If no GitHub token is available
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * import { runFeatures } from '@oss-autopilot/core/commands';
43
+ *
44
+ * const result = await runFeatures({ maxResults: 10 });
45
+ * for (const c of [...result.quickWins, ...result.biggerBets]) {
46
+ * console.log(`${c.issue.repo}#${c.issue.number} — ${c.horizon}`);
47
+ * }
48
+ * ```
49
+ */
50
+ export declare function runFeatures(options: FeaturesOptions): Promise<FeaturesOutput>;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Features command (scout 0.9.0 #97/#98/#99)
3
+ *
4
+ * Wraps `scout.features()` to surface feature-scoped contribution
5
+ * opportunities in repos where the user already has 3+ merged PRs
6
+ * (configurable via `featuresAnchorThreshold`). Returns two ranked
7
+ * buckets — "quick wins" and "bigger bets" — split by maintainer-
8
+ * commitment signals (milestones, roadmap membership, label set).
9
+ *
10
+ * Mirrors the shape of `runSearch`: each bucket entry is a
11
+ * {@link SearchCandidate} augmented with a `horizon` literal so callers
12
+ * can render bucket-specific UX while reusing the search candidate
13
+ * formatter.
14
+ */
15
+ import { buildCandidateLinkedPR, createAutopilotScout } from './scout-bridge.js';
16
+ import { getStateManager } from '../core/index.js';
17
+ import { gradeFromCandidate } from '../core/issue-grading.js';
18
+ import { warn } from '../core/logger.js';
19
+ const MODULE = 'features';
20
+ /**
21
+ * Hard cap on feature-search result count, matching `MAX_SEARCH_RESULTS`
22
+ * for `runSearch`. Shared between CLI (`cli-registry.ts`) and the MCP
23
+ * tool registration so a future adjustment lands in one place.
24
+ */
25
+ export const MAX_FEATURES_RESULTS = 100;
26
+ /**
27
+ * Coerce scout's raw `viabilityScore` into a trustworthy 0-100 number.
28
+ * Same defensive boundary check as `runSearch` — kept inline (not
29
+ * imported) so this command remains self-contained per the existing
30
+ * autopilot convention. See #1043.
31
+ */
32
+ function sanitizeViabilityScore(raw) {
33
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0 || raw > 100) {
34
+ warn(MODULE, `Ignoring out-of-contract viabilityScore from scout: ${JSON.stringify(raw)}`);
35
+ return 0;
36
+ }
37
+ return raw;
38
+ }
39
+ /**
40
+ * Map one scout `FeatureCandidate` into the autopilot output shape.
41
+ * Mirrors the candidate-mapping branch in `runSearch` and stamps the
42
+ * `horizon` field.
43
+ */
44
+ function toFeaturesCandidate(scoutCandidate, getState) {
45
+ const repoScoreRecord = getState.getRepoScore(scoutCandidate.issue.repo);
46
+ // Same `checkFailed: true` sentinel `runSearch` uses — scout's `features`
47
+ // pipeline reuses the same vetting code that does emit projectHealth on
48
+ // `vetIssue`, but on this surface scout returns the multi-issue list view
49
+ // which today does not propagate per-candidate health into IssueCandidate.
50
+ // Treating health as unknown grades from the autopilot-tracked repoScore
51
+ // alone, falling to 'F' for unfamiliar repos — an honest "we haven't seen
52
+ // this repo" rather than a fabricated score.
53
+ const grade = gradeFromCandidate({
54
+ repo: scoutCandidate.issue.repo,
55
+ projectHealth: { avgIssueResponseDays: null, daysSinceLastCommit: null, checkFailed: true },
56
+ getRepoScore: (repo) => {
57
+ const score = getState.getRepoScore(repo);
58
+ return score
59
+ ? {
60
+ mergedPRCount: score.mergedPRCount,
61
+ closedWithoutMergeCount: score.closedWithoutMergeCount,
62
+ avgResponseDays: score.avgResponseDays ?? null,
63
+ }
64
+ : undefined;
65
+ },
66
+ });
67
+ const linkedPR = buildCandidateLinkedPR(scoutCandidate.vettingResult?.linkedPR);
68
+ return {
69
+ issue: {
70
+ repo: scoutCandidate.issue.repo,
71
+ repoUrl: `https://github.com/${scoutCandidate.issue.repo}`,
72
+ number: scoutCandidate.issue.number,
73
+ title: scoutCandidate.issue.title,
74
+ url: scoutCandidate.issue.url,
75
+ labels: scoutCandidate.issue.labels,
76
+ },
77
+ recommendation: scoutCandidate.recommendation,
78
+ reasonsToApprove: scoutCandidate.reasonsToApprove,
79
+ reasonsToSkip: scoutCandidate.reasonsToSkip,
80
+ searchPriority: scoutCandidate.searchPriority,
81
+ viabilityScore: sanitizeViabilityScore(scoutCandidate.viabilityScore),
82
+ grade,
83
+ repoScore: repoScoreRecord
84
+ ? {
85
+ score: repoScoreRecord.score,
86
+ mergedPRCount: repoScoreRecord.mergedPRCount,
87
+ closedWithoutMergeCount: repoScoreRecord.closedWithoutMergeCount,
88
+ isResponsive: repoScoreRecord.signals?.isResponsive ?? false,
89
+ lastMergedAt: repoScoreRecord.lastMergedAt,
90
+ }
91
+ : undefined,
92
+ ...(linkedPR ? { linkedPR } : {}),
93
+ horizon: scoutCandidate.horizon,
94
+ };
95
+ }
96
+ /**
97
+ * Run feature-scoped contribution discovery via `scout.features()`.
98
+ *
99
+ * @param options - Feature search configuration
100
+ * @param options.maxResults - Total candidates returned across both buckets
101
+ * @param options.anchorThreshold - Optional per-call override for the merged-PR threshold (default: scout's `featuresAnchorThreshold`)
102
+ * @param options.splitRatio - Optional per-call override for the quick-win split ratio (default: scout's `featuresSplitRatio`)
103
+ * @returns Two ranked buckets (quick-wins / bigger-bets) plus anchor list and an optional human message
104
+ * @throws {ConfigurationError} If no GitHub token is available
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * import { runFeatures } from '@oss-autopilot/core/commands';
109
+ *
110
+ * const result = await runFeatures({ maxResults: 10 });
111
+ * for (const c of [...result.quickWins, ...result.biggerBets]) {
112
+ * console.log(`${c.issue.repo}#${c.issue.number} — ${c.horizon}`);
113
+ * }
114
+ * ```
115
+ */
116
+ export async function runFeatures(options) {
117
+ const scout = await createAutopilotScout();
118
+ const result = await scout.features({
119
+ count: options.maxResults,
120
+ anchorThreshold: options.anchorThreshold,
121
+ splitRatio: options.splitRatio,
122
+ });
123
+ const stateManager = getStateManager();
124
+ const featuresOutput = {
125
+ quickWins: result.quickWins.map((c) => toFeaturesCandidate(c, stateManager)),
126
+ biggerBets: result.biggerBets.map((c) => toFeaturesCandidate(c, stateManager)),
127
+ anchorRepos: result.anchorRepos,
128
+ message: result.message,
129
+ };
130
+ return featuresOutput;
131
+ }
@@ -23,6 +23,8 @@ export { runStartup } from './startup.js';
23
23
  export { runStatus } from './status.js';
24
24
  /** Search GitHub for contributable issues using multi-strategy discovery. */
25
25
  export { runSearch, MAX_SEARCH_RESULTS } from './search.js';
26
+ /** Surface feature-scoped opportunities in repos with 3+ merged PRs (scout 0.9.0). */
27
+ export { runFeatures, MAX_FEATURES_RESULTS } from './features.js';
26
28
  /** Vet a single GitHub issue for claimability (open, unassigned, no linked PRs, repo health). */
27
29
  export { runVet } from './vet.js';
28
30
  /** Re-vet all available issues in a curated issue list for freshness. */
@@ -87,7 +89,7 @@ export { runDetectFormatters } from './detect-formatters.js';
87
89
  export { runLocalRepos } from './local-repos.js';
88
90
  export type { DashboardJsonData, DashboardStats, DashboardActionType, ActionRequest } from './dashboard-data.js';
89
91
  export type { ErrorCode } from '../formatters/json.js';
90
- export type { DailyOutput, SearchOutput, StartupOutput, StatusOutput, TrackOutput } from '../formatters/json.js';
92
+ export type { DailyOutput, SearchOutput, SearchCandidate, CandidateLinkedPR, FeaturesOutput, FeaturesCandidate, FeaturesHorizon, StartupOutput, StatusOutput, TrackOutput, } from '../formatters/json.js';
91
93
  export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput, VetListOutput, VetListItemStatus, } from '../formatters/json.js';
92
94
  export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
93
95
  export type { ShelveOutput, UnshelveOutput } from './shelve.js';
@@ -24,6 +24,8 @@ export { runStartup } from './startup.js';
24
24
  export { runStatus } from './status.js';
25
25
  /** Search GitHub for contributable issues using multi-strategy discovery. */
26
26
  export { runSearch, MAX_SEARCH_RESULTS } from './search.js';
27
+ /** Surface feature-scoped opportunities in repos with 3+ merged PRs (scout 0.9.0). */
28
+ export { runFeatures, MAX_FEATURES_RESULTS } from './features.js';
27
29
  /** Vet a single GitHub issue for claimability (open, unassigned, no linked PRs, repo health). */
28
30
  export { runVet } from './vet.js';
29
31
  /** Re-vet all available issues in a curated issue list for freshness. */
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { type LinkedPR as ScoutLinkedPR, type OssScout, type ScoutState } from '@oss-scout/core';
6
6
  import type { LinkedPR } from '../core/linked-pr-classification.js';
7
+ import type { CandidateLinkedPR } from '../formatters/json.js';
7
8
  /**
8
9
  * Convert scout 0.6.0's `LinkedPR` (separate `state` + `merged`) into the
9
10
  * shape `classifyLinkedPR` expects (`state` already folded with `merged`).
@@ -12,8 +13,19 @@ import type { LinkedPR } from '../core/linked-pr-classification.js';
12
13
  * written before scout surfaced this data and uses a tri-state
13
14
  * `'open' | 'closed' | 'merged'` enum. Folding `merged` into the state
14
15
  * preserves the function's existing contract + tests.
16
+ *
17
+ * `updatedAt` (added in scout 0.9.0) flows through unchanged so consumers
18
+ * can hand the result to `isLinkedPRStalled` without re-fetching.
15
19
  */
16
20
  export declare function adaptScoutLinkedPR(scoutLinkedPR: ScoutLinkedPR | null | undefined): LinkedPR | null;
21
+ /**
22
+ * Build the autopilot-shaped `linkedPR` slice consumed by `SearchOutput`,
23
+ * `VetOutput`, and `FeaturesOutput` from scout's raw `LinkedPR`
24
+ * (#97 / scout 0.9.0). Returns `undefined` when scout reported no linked
25
+ * PR. Computes `isStalled` from the adapted (autopilot-shape) PR so the
26
+ * rule stays consistent with `classifyLinkedPR` and downstream consumers.
27
+ */
28
+ export declare function buildCandidateLinkedPR(scoutLinkedPR: ScoutLinkedPR | null | undefined): CandidateLinkedPR | undefined;
17
29
  /**
18
30
  * Build a ScoutState from the current AgentState.
19
31
  * Maps oss-autopilot's config and state fields to oss-scout's state format.
@@ -3,7 +3,7 @@
3
3
  * Maps state fields and creates scout instances for search/vet commands.
4
4
  */
5
5
  import { createScout } from '@oss-scout/core';
6
- import { getStateManager, requireGitHubToken } from '../core/index.js';
6
+ import { getStateManager, isLinkedPRStalled, requireGitHubToken } from '../core/index.js';
7
7
  import { loadSkippedIssues } from './skip-file-parser.js';
8
8
  /**
9
9
  * Convert scout 0.6.0's `LinkedPR` (separate `state` + `merged`) into the
@@ -13,14 +13,45 @@ import { loadSkippedIssues } from './skip-file-parser.js';
13
13
  * written before scout surfaced this data and uses a tri-state
14
14
  * `'open' | 'closed' | 'merged'` enum. Folding `merged` into the state
15
15
  * preserves the function's existing contract + tests.
16
+ *
17
+ * `updatedAt` (added in scout 0.9.0) flows through unchanged so consumers
18
+ * can hand the result to `isLinkedPRStalled` without re-fetching.
16
19
  */
17
20
  export function adaptScoutLinkedPR(scoutLinkedPR) {
18
21
  if (!scoutLinkedPR)
19
22
  return null;
20
- return {
23
+ const adapted = {
21
24
  author: { login: scoutLinkedPR.author },
22
25
  state: scoutLinkedPR.merged ? 'merged' : scoutLinkedPR.state,
23
26
  };
27
+ if (scoutLinkedPR.updatedAt !== undefined) {
28
+ adapted.updatedAt = scoutLinkedPR.updatedAt;
29
+ }
30
+ return adapted;
31
+ }
32
+ /**
33
+ * Build the autopilot-shaped `linkedPR` slice consumed by `SearchOutput`,
34
+ * `VetOutput`, and `FeaturesOutput` from scout's raw `LinkedPR`
35
+ * (#97 / scout 0.9.0). Returns `undefined` when scout reported no linked
36
+ * PR. Computes `isStalled` from the adapted (autopilot-shape) PR so the
37
+ * rule stays consistent with `classifyLinkedPR` and downstream consumers.
38
+ */
39
+ export function buildCandidateLinkedPR(scoutLinkedPR) {
40
+ if (!scoutLinkedPR)
41
+ return undefined;
42
+ const adapted = adaptScoutLinkedPR(scoutLinkedPR);
43
+ if (!adapted)
44
+ return undefined;
45
+ const linkedPR = {
46
+ number: scoutLinkedPR.number,
47
+ state: adapted.state,
48
+ url: scoutLinkedPR.url,
49
+ isStalled: isLinkedPRStalled(adapted),
50
+ };
51
+ if (scoutLinkedPR.updatedAt !== undefined) {
52
+ linkedPR.updatedAt = scoutLinkedPR.updatedAt;
53
+ }
54
+ return linkedPR;
24
55
  }
25
56
  /**
26
57
  * Build a ScoutState from the current AgentState.
@@ -50,6 +81,15 @@ export function buildScoutState() {
50
81
  persistence: config.persistence,
51
82
  slmTriageModel: config.slmTriageModel,
52
83
  slmTriageHost: config.slmTriageHost,
84
+ // Scout 0.9.0 made these required on `ScoutPreferences` (their schema
85
+ // ZodDefaults still apply at parse time, but the inferred TS type now
86
+ // demands the fields). We pass scout's documented defaults here so
87
+ // autopilot doesn't have to introduce a new pair of user-visible
88
+ // settings just to satisfy the type. The features CLI command
89
+ // (#runFeatures) accepts per-call `--anchor-threshold` and
90
+ // `--split-ratio` flags for overrides.
91
+ featuresAnchorThreshold: 3,
92
+ featuresSplitRatio: 0.6,
53
93
  },
54
94
  repoScores: state.repoScores,
55
95
  starredRepos: config.starredRepos,
@@ -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 { createAutopilotScout } from './scout-bridge.js';
5
+ import { buildCandidateLinkedPR, createAutopilotScout } from './scout-bridge.js';
6
6
  import { getStateManager } from '../core/index.js';
7
7
  import { gradeFromCandidate } from '../core/issue-grading.js';
8
8
  import { warn } from '../core/logger.js';
@@ -71,6 +71,7 @@ export async function runSearch(options) {
71
71
  : undefined;
72
72
  },
73
73
  });
74
+ const linkedPR = buildCandidateLinkedPR(c.vettingResult?.linkedPR);
74
75
  return {
75
76
  issue: {
76
77
  repo: c.issue.repo,
@@ -95,6 +96,7 @@ export async function runSearch(options) {
95
96
  lastMergedAt: repoScoreRecord.lastMergedAt,
96
97
  }
97
98
  : undefined,
99
+ ...(linkedPR ? { linkedPR } : {}),
98
100
  };
99
101
  }),
100
102
  excludedRepos: result.excludedRepos,
@@ -14,6 +14,7 @@ import { errorMessage } from '../core/errors.js';
14
14
  import { warn } from '../core/logger.js';
15
15
  import { executeDailyCheck } from './daily.js';
16
16
  import { launchDashboardServer } from './dashboard-lifecycle.js';
17
+ import { recordBrowserOpened } from './dashboard-process.js';
17
18
  import { parseIssueList } from './parse-list.js';
18
19
  /**
19
20
  * Parse issueListPath from a config file's YAML frontmatter.
@@ -181,6 +182,48 @@ export function openInBrowser(url) {
181
182
  * Hits POST /api/refresh so the SPA picks up fresh data on its next poll.
182
183
  * Non-fatal: errors are logged but don't propagate (#830).
183
184
  */
185
+ const DEFAULT_REOPEN_THROTTLE_MS = 30 * 60 * 1000; // 30 minutes
186
+ /**
187
+ * Resolve the browser-reopen throttle window from `OSS_DASHBOARD_REOPEN_THROTTLE_MS`,
188
+ * falling back to {@link DEFAULT_REOPEN_THROTTLE_MS}. A value of `0` disables the
189
+ * throttle entirely, restoring the pre-#1339 behavior of always re-opening.
190
+ */
191
+ function getReopenThrottleMs() {
192
+ const raw = process.env.OSS_DASHBOARD_REOPEN_THROTTLE_MS;
193
+ if (!raw)
194
+ return DEFAULT_REOPEN_THROTTLE_MS;
195
+ const n = Number(raw);
196
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_REOPEN_THROTTLE_MS;
197
+ }
198
+ /**
199
+ * Decide whether `openInBrowser` should run. Throttles re-opens against an
200
+ * already-running server's `lastBrowserOpenedAt` timestamp so back-to-back
201
+ * `/oss` runs don't pile up duplicate tabs (#1339), while still re-surfacing
202
+ * the dashboard for users who closed the tab and came back later (#1100).
203
+ *
204
+ * Fresh launches (`alreadyRunning === false`) always open; the new server has
205
+ * no recorded open yet by definition.
206
+ *
207
+ * Set `OSS_NO_BROWSER=1` to skip opening unconditionally (e.g., headless / CI).
208
+ */
209
+ function shouldOpenBrowser(spaResult, throttleMs) {
210
+ if (process.env.OSS_NO_BROWSER === '1')
211
+ return false;
212
+ if (!spaResult.alreadyRunning)
213
+ return true;
214
+ if (throttleMs === 0)
215
+ return true;
216
+ const last = spaResult.lastBrowserOpenedAt;
217
+ if (!last)
218
+ return true;
219
+ const lastMs = Date.parse(last);
220
+ if (!Number.isFinite(lastMs))
221
+ return true;
222
+ const elapsed = Date.now() - lastMs;
223
+ if (elapsed < 0)
224
+ return true; // clock skew or future timestamp; treat as stale
225
+ return elapsed >= throttleMs;
226
+ }
184
227
  async function triggerDashboardRefresh(port) {
185
228
  try {
186
229
  const res = await fetch(`http://127.0.0.1:${port}/api/refresh`, {
@@ -271,12 +314,15 @@ export async function runStartup() {
271
314
  else {
272
315
  dashboardStatus = 'opened';
273
316
  }
274
- // `open`/`xdg-open`/`start` focus an existing tab matching the URL
275
- // instead of duplicating it, so this is safe whether the server was
276
- // just started or was already running. Closes #830 properly a user
277
- // can close the dashboard tab while the daemon keeps running, leaving
278
- // subsequent /oss runs with no visible dashboard if we didn't re-open.
279
- openInBrowser(spaResult.url);
317
+ // Throttle re-opens against the running server's last-opened timestamp
318
+ // so back-to-back /oss runs don't pile up duplicate tabs (#1339), while
319
+ // still re-surfacing the dashboard for users who closed the tab and
320
+ // came back later (#1100). `OSS_NO_BROWSER=1` skips entirely;
321
+ // `OSS_DASHBOARD_REOPEN_THROTTLE_MS=0` restores always-open behavior.
322
+ if (shouldOpenBrowser(spaResult, getReopenThrottleMs())) {
323
+ openInBrowser(spaResult.url);
324
+ recordBrowserOpened(spaResult.port);
325
+ }
280
326
  }
281
327
  else {
282
328
  dashboardError = 'Dashboard SPA assets not found. Build with: cd packages/dashboard && pnpm run build';
@@ -3,7 +3,7 @@
3
3
  * Re-vets all available issues in a curated issue list file via @oss-scout/core.
4
4
  */
5
5
  import * as fs from 'node:fs';
6
- import { adaptScoutLinkedPR, createAutopilotScout } from './scout-bridge.js';
6
+ import { adaptScoutLinkedPR, buildCandidateLinkedPR, createAutopilotScout } from './scout-bridge.js';
7
7
  import { runParseList, pruneIssueList } from './parse-list.js';
8
8
  import { detectIssueList } from './startup.js';
9
9
  import { computeSuccessGrade, gradeFromCandidate } from '../core/issue-grading.js';
@@ -17,7 +17,7 @@ const KNOWN_SKIP_REASONS = new Set([
17
17
  'anti_llm_policy',
18
18
  'other',
19
19
  ]);
20
- function mapSkipReasonToStatus(reason) {
20
+ function mapSkipReasonToStatus(reason, vetResult) {
21
21
  switch (reason) {
22
22
  case 'issue_closed': {
23
23
  return 'closed';
@@ -26,6 +26,12 @@ function mapSkipReasonToStatus(reason) {
26
26
  return 'claimed';
27
27
  }
28
28
  case 'has_linked_pr': {
29
+ // Open linked PRs that have been idle for 30+ days are revive
30
+ // opportunities (#97 / scout 0.9.0) — surface them with a distinct
31
+ // status rather than auto-dropping as `has_pr`.
32
+ if (vetResult.linkedPR?.state === 'open' && vetResult.linkedPR.isStalled) {
33
+ return 'has_stalled_pr';
34
+ }
29
35
  return 'has_pr';
30
36
  }
31
37
  case 'score_too_low':
@@ -57,7 +63,7 @@ export function extractSkipReason(candidate) {
57
63
  */
58
64
  export function classifyListStatus(vetResult, skipReason) {
59
65
  if (skipReason) {
60
- const fromEnum = mapSkipReasonToStatus(skipReason);
66
+ const fromEnum = mapSkipReasonToStatus(skipReason, vetResult);
61
67
  if (fromEnum)
62
68
  return fromEnum;
63
69
  // skipReason was set but maps to 'other' / low-score / policy — let the
@@ -69,8 +75,15 @@ export function classifyListStatus(vetResult, skipReason) {
69
75
  return 'closed';
70
76
  if (skipReasons.some((r) => r.includes('claimed') || r.includes('assigned')))
71
77
  return 'claimed';
72
- if (skipReasons.some((r) => r.includes('existing pr') || r.includes('linked pr') || r.includes('pull request')))
78
+ if (skipReasons.some((r) => r.includes('existing pr') || r.includes('linked pr') || r.includes('pull request'))) {
79
+ // Same revive-opportunity branch as the enum path above — when scout
80
+ // hasn't yet emitted skipReason but we can see a stalled open PR on
81
+ // the candidate, prefer the dedicated status (#97 / scout 0.9.0).
82
+ if (vetResult.linkedPR?.state === 'open' && vetResult.linkedPR.isStalled) {
83
+ return 'has_stalled_pr';
84
+ }
73
85
  return 'has_pr';
86
+ }
74
87
  }
75
88
  if (vetResult.recommendation === 'approve' || vetResult.recommendation === 'needs_review') {
76
89
  return 'still_available';
@@ -102,7 +115,7 @@ export async function runVetList(options = {}) {
102
115
  if (parsed.available.length === 0) {
103
116
  return {
104
117
  results: [],
105
- summary: { total: 0, stillAvailable: 0, claimed: 0, closed: 0, hasPR: 0, errors: 0 },
118
+ summary: { total: 0, stillAvailable: 0, claimed: 0, closed: 0, hasPR: 0, hasStalledPR: 0, errors: 0 },
106
119
  };
107
120
  }
108
121
  // 2. Vet each available issue in parallel with concurrency limit
@@ -126,6 +139,7 @@ export async function runVetList(options = {}) {
126
139
  linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
127
140
  userLogin,
128
141
  });
142
+ const linkedPR = buildCandidateLinkedPR(candidate.vettingResult.linkedPR);
129
143
  const vetResult = {
130
144
  issue: {
131
145
  repo: candidate.issue.repo,
@@ -141,6 +155,7 @@ export async function runVetList(options = {}) {
141
155
  vettingResult: candidate.vettingResult,
142
156
  antiLLMPolicy: candidate.antiLLMPolicy,
143
157
  linkedPRClassification,
158
+ ...(linkedPR ? { linkedPR } : {}),
144
159
  slmTriage: candidate.slmTriage ?? null,
145
160
  grade,
146
161
  };
@@ -175,6 +190,7 @@ export async function runVetList(options = {}) {
175
190
  claimed: results.filter((r) => r.listStatus === 'claimed').length,
176
191
  closed: results.filter((r) => r.listStatus === 'closed').length,
177
192
  hasPR: results.filter((r) => r.listStatus === 'has_pr').length,
193
+ hasStalledPR: results.filter((r) => r.listStatus === 'has_stalled_pr').length,
178
194
  errors: results.filter((r) => r.listStatus === 'error').length,
179
195
  };
180
196
  // 4. Prune the file if requested — remove completed/skipped/low-score items
@@ -2,7 +2,7 @@
2
2
  * Vet command
3
3
  * Vets a specific issue before working on it via @oss-scout/core
4
4
  */
5
- import { createAutopilotScout, adaptScoutLinkedPR } from './scout-bridge.js';
5
+ import { adaptScoutLinkedPR, buildCandidateLinkedPR, createAutopilotScout } from './scout-bridge.js';
6
6
  import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
7
7
  import { gradeFromCandidate } from '../core/issue-grading.js';
8
8
  import { getStateManager, classifyLinkedPR } from '../core/index.js';
@@ -29,6 +29,7 @@ export async function runVet(options) {
29
29
  linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
30
30
  userLogin,
31
31
  });
32
+ const linkedPR = buildCandidateLinkedPR(candidate.vettingResult.linkedPR);
32
33
  return {
33
34
  issue: {
34
35
  repo: candidate.issue.repo,
@@ -44,6 +45,7 @@ export async function runVet(options) {
44
45
  vettingResult: candidate.vettingResult,
45
46
  antiLLMPolicy: candidate.antiLLMPolicy,
46
47
  linkedPRClassification,
48
+ ...(linkedPR ? { linkedPR } : {}),
47
49
  slmTriage: candidate.slmTriage ?? null,
48
50
  grade,
49
51
  };
@@ -21,7 +21,7 @@ export { HttpCache, getHttpCache, cachedRequest, type CacheEntry } from './http-
21
21
  export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, buildStarFilter, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
22
22
  export { computeContributionStats, type ContributionStats, type ComputeStatsInput } from './stats.js';
23
23
  export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
24
- export { classifyLinkedPR, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
24
+ export { classifyLinkedPR, isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
25
25
  export { scanForAntiLLMPolicy, type AntiLLMCategory, type AntiLLMMatch, type AntiLLMScanResult, } from './anti-llm-policy.js';
26
26
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, type DetectedFormatter, type FormatterDetectionResult, type CIFormatterDiagnosis, type FormatterName, } from './formatter-detection.js';
27
27
  export { CONFIG_KEY_REGISTRY, type ConfigKeyDef, type SettableVia, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
@@ -22,7 +22,7 @@ export { HttpCache, getHttpCache, cachedRequest } from './http-cache.js';
22
22
  export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, buildStarFilter, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
23
23
  export { computeContributionStats } from './stats.js';
24
24
  export { fetchPRTemplate } from './pr-template.js';
25
- export { classifyLinkedPR, } from './linked-pr-classification.js';
25
+ export { classifyLinkedPR, isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, } from './linked-pr-classification.js';
26
26
  export { scanForAntiLLMPolicy, } from './anti-llm-policy.js';
27
27
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, } from './formatter-detection.js';
28
28
  export { CONFIG_KEY_REGISTRY, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';