@oss-autopilot/core 3.5.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.
Files changed (40) hide show
  1. package/dist/cli-registry.js +143 -1
  2. package/dist/cli.bundle.cjs +120 -108
  3. package/dist/commands/daily.d.ts +8 -0
  4. package/dist/commands/daily.js +21 -0
  5. package/dist/commands/dashboard-lifecycle.d.ts +7 -0
  6. package/dist/commands/dashboard-lifecycle.js +12 -2
  7. package/dist/commands/dashboard-process.d.ts +8 -0
  8. package/dist/commands/dashboard-process.js +20 -0
  9. package/dist/commands/features.d.ts +50 -0
  10. package/dist/commands/features.js +131 -0
  11. package/dist/commands/index.d.ts +5 -1
  12. package/dist/commands/index.js +4 -0
  13. package/dist/commands/scout-bridge.d.ts +12 -0
  14. package/dist/commands/scout-bridge.js +42 -2
  15. package/dist/commands/search.js +3 -1
  16. package/dist/commands/startup.js +75 -7
  17. package/dist/commands/vet-list.js +21 -5
  18. package/dist/commands/vet.js +3 -1
  19. package/dist/core/anti-llm-policy.d.ts +42 -13
  20. package/dist/core/anti-llm-policy.js +102 -13
  21. package/dist/core/ci-analysis.d.ts +32 -1
  22. package/dist/core/ci-analysis.js +92 -0
  23. package/dist/core/errors.d.ts +19 -0
  24. package/dist/core/errors.js +54 -0
  25. package/dist/core/index.d.ts +1 -1
  26. package/dist/core/index.js +1 -1
  27. package/dist/core/linked-pr-classification.d.ts +28 -0
  28. package/dist/core/linked-pr-classification.js +32 -0
  29. package/dist/core/pr-monitor.d.ts +1 -1
  30. package/dist/core/pr-monitor.js +31 -11
  31. package/dist/core/state-schema.d.ts +1 -0
  32. package/dist/core/state-schema.js +9 -0
  33. package/dist/core/state.d.ts +7 -0
  34. package/dist/core/state.js +10 -0
  35. package/dist/core/strategy.d.ts +21 -1
  36. package/dist/core/strategy.js +44 -0
  37. package/dist/core/types.d.ts +49 -0
  38. package/dist/formatters/json.d.ts +329 -35
  39. package/dist/formatters/json.js +102 -0
  40. package/package.json +2 -2
@@ -7,6 +7,7 @@
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
9
  import { type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
10
+ import { type StrategyResult } from '../core/strategy.js';
10
11
  import { type DailyOutput, type DailyWarning, type CapacityAssessment, type ActionableIssue, type ActionMenu } from '../formatters/json.js';
11
12
  export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
12
13
  import { buildStarFilter } from '../core/daily-logic.js';
@@ -28,6 +29,13 @@ export interface DailyCheckResult {
28
29
  failures: PRCheckFailure[];
29
30
  /** Non-fatal warnings from ancillary pipeline phases — see #1042. */
30
31
  warnings: DailyWarning[];
32
+ /**
33
+ * Periodic strategy snapshot (#1270). Set when the cadence trigger
34
+ * fires AND the user has crossed `STRATEGY_MIN_PRS`. The action-menu
35
+ * renderer in `commands/oss.md` reads this; absent or null on runs
36
+ * where the gate stays silent.
37
+ */
38
+ strategySummary?: StrategyResult | null;
31
39
  }
32
40
  /**
33
41
  * Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
10
10
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
11
+ import { computeStrategy, shouldComputeStrategy } from '../core/strategy.js';
11
12
  import { warn } from '../core/logger.js';
12
13
  import { emptyPRCountsResult } from '../core/github-stats.js';
13
14
  import { createAutopilotScout } from './scout-bridge.js';
@@ -421,6 +422,24 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
421
422
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
422
423
  const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues);
423
424
  const repoGroups = groupPRsByRepo(activePRs);
425
+ // Periodic strategy snapshot (#1270 Step 2). Cadence-gated to fire every
426
+ // 30 days OR after 5+ PRs merge since the last snapshot, whichever comes
427
+ // first. Below STRATEGY_MIN_PRS the gate stays silent. Compute failures
428
+ // are non-fatal — the daily run continues and the snapshot is omitted.
429
+ let strategySummary;
430
+ try {
431
+ const state = stateManager.getState();
432
+ const nowIso = new Date().toISOString();
433
+ if (shouldComputeStrategy(state, nowIso)) {
434
+ strategySummary = computeStrategy(state);
435
+ if (strategySummary) {
436
+ stateManager.setLastStrategyAt(nowIso);
437
+ }
438
+ }
439
+ }
440
+ catch (error) {
441
+ recordWarning(warnings, 'analytics', 'compute strategy snapshot', error);
442
+ }
424
443
  return {
425
444
  digest,
426
445
  capacity,
@@ -432,6 +451,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
432
451
  repoGroups,
433
452
  failures,
434
453
  warnings,
454
+ strategySummary,
435
455
  };
436
456
  }
437
457
  // ---------------------------------------------------------------------------
@@ -457,6 +477,7 @@ export function toDailyOutput(result) {
457
477
  repoGroups: compactRepoGroups(result.repoGroups),
458
478
  failures: result.failures,
459
479
  warnings: result.warnings,
480
+ strategySummary: result.strategySummary,
460
481
  };
461
482
  }
462
483
  /**
@@ -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. */
@@ -75,6 +77,8 @@ export { runStateUnlink } from './state-cmd.js';
75
77
  export { runParseList, pruneIssueList } from './parse-list.js';
76
78
  /** Move an issue between Pursue / Maybe / Skip sections of a curated list (#1107). */
77
79
  export { runListMoveTier, moveIssueToTier, type Tier, type ListMoveTierOptions, type ListMoveTierOutput, } from './list-move-tier.js';
80
+ /** Mark an issue line in a curated list as done with strikethrough + Done sub-bullet (#1299). */
81
+ export { runMarkIssueListItemDone, markIssueAsDone, type MarkDoneOptions, type MarkDoneOutput, } from './list-mark-done.js';
78
82
  /** Check if new files are properly referenced/integrated. */
79
83
  export { runCheckIntegration } from './check-integration.js';
80
84
  /** System-health diagnostic — verifies tokens, bundle, state, scout, rate limit. */
@@ -85,7 +89,7 @@ export { runDetectFormatters } from './detect-formatters.js';
85
89
  export { runLocalRepos } from './local-repos.js';
86
90
  export type { DashboardJsonData, DashboardStats, DashboardActionType, ActionRequest } from './dashboard-data.js';
87
91
  export type { ErrorCode } from '../formatters/json.js';
88
- 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';
89
93
  export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput, VetListOutput, VetListItemStatus, } from '../formatters/json.js';
90
94
  export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
91
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. */
@@ -82,6 +84,8 @@ export { runStateUnlink } from './state-cmd.js';
82
84
  export { runParseList, pruneIssueList } from './parse-list.js';
83
85
  /** Move an issue between Pursue / Maybe / Skip sections of a curated list (#1107). */
84
86
  export { runListMoveTier, moveIssueToTier, } from './list-move-tier.js';
87
+ /** Mark an issue line in a curated list as done with strikethrough + Done sub-bullet (#1299). */
88
+ export { runMarkIssueListItemDone, markIssueAsDone, } from './list-mark-done.js';
85
89
  /** Check if new files are properly referenced/integrated. */
86
90
  export { runCheckIntegration } from './check-integration.js';
87
91
  /** System-health diagnostic — verifies tokens, bundle, state, scout, rate limit. */
@@ -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.
@@ -115,11 +116,33 @@ export function detectIssueList() {
115
116
  // Matches the sibling warn at line 64 for `issueListPath` read failures.
116
117
  warn('startup', `Could not read skippedIssuesPath from state: ${errorMessage(err)}`);
117
118
  }
118
- // Probe default path: same directory as issue list, named skipped-issues.md
119
+ // Probe default path: same directory as issue list, named skipped-issues.md.
120
+ // When found, also persist to state.config so downstream commands
121
+ // (`skip-add`, `scout search`'s skip-list filter) read the same path
122
+ // instead of silently no-opping with "No skipped-issues path configured"
123
+ // (#1330). Without persistence, the auto-detect printed the path on
124
+ // every startup but nothing else honored it — search would re-surface
125
+ // already-skipped candidates round after round.
119
126
  if (!skippedIssuesPath && issueListPath) {
120
127
  const defaultSkipPath = path.join(path.dirname(issueListPath), 'skipped-issues.md');
121
128
  if (fs.existsSync(defaultSkipPath)) {
122
129
  skippedIssuesPath = defaultSkipPath;
130
+ try {
131
+ const stateManager = getStateManager();
132
+ // Only write when config actually doesn't have one — re-running
133
+ // startup shouldn't trigger an autoSave on every run if the
134
+ // value is already there.
135
+ const current = stateManager.getState().config.skippedIssuesPath;
136
+ if (!current) {
137
+ stateManager.updateConfig({ skippedIssuesPath: defaultSkipPath });
138
+ }
139
+ }
140
+ catch (err) {
141
+ // Persistence is best-effort — startup still surfaces the path
142
+ // in its return value so the current run benefits, but the next
143
+ // run won't. Log so the failure is debuggable.
144
+ warn('startup', `Could not persist auto-detected skippedIssuesPath: ${errorMessage(err)}`);
145
+ }
123
146
  }
124
147
  }
125
148
  return { path: issueListPath, source, availableCount, completedCount, skippedIssuesPath };
@@ -159,6 +182,48 @@ export function openInBrowser(url) {
159
182
  * Hits POST /api/refresh so the SPA picks up fresh data on its next poll.
160
183
  * Non-fatal: errors are logged but don't propagate (#830).
161
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
+ }
162
227
  async function triggerDashboardRefresh(port) {
163
228
  try {
164
229
  const res = await fetch(`http://127.0.0.1:${port}/api/refresh`, {
@@ -249,12 +314,15 @@ export async function runStartup() {
249
314
  else {
250
315
  dashboardStatus = 'opened';
251
316
  }
252
- // `open`/`xdg-open`/`start` focus an existing tab matching the URL
253
- // instead of duplicating it, so this is safe whether the server was
254
- // just started or was already running. Closes #830 properly a user
255
- // can close the dashboard tab while the daemon keeps running, leaving
256
- // subsequent /oss runs with no visible dashboard if we didn't re-open.
257
- 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
+ }
258
326
  }
259
327
  else {
260
328
  dashboardError = 'Dashboard SPA assets not found. Build with: cd packages/dashboard && pnpm run build';