@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
@@ -119,6 +119,12 @@ function toFeaturesCandidate(scoutCandidate, getState) {
119
119
  */
120
120
  export async function runFeatures(options) {
121
121
  const scout = await createAutopilotScout();
122
+ // Strategy bias (preferLanguages/preferRepos/avoidRepos/boostIssueTypes/
123
+ // diversityRatio) reaches this path through the bridge-built
124
+ // ScoutPreferences (#1464) — the same derivation `runSearch` uses per-call.
125
+ // scout.features() in scout 1.1.0 accepts only count/anchorThreshold/
126
+ // splitRatio/broad — do NOT pass bias knobs here; unsupported options
127
+ // would be silently ignored. Preferences are the supported channel.
122
128
  const result = await scout.features({
123
129
  count: options.maxResults,
124
130
  anchorThreshold: options.anchorThreshold,
@@ -50,6 +50,12 @@ export interface FetchCorpusOutput {
50
50
  }>;
51
51
  /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
52
52
  gistSyncWarning?: string;
53
+ /**
54
+ * Non-fatal data-quality warnings (#1456). Currently produced when a
55
+ * bundle's comment streams hit the pagination cap, meaning the newest
56
+ * comments on that PR are missing from the corpus. Omitted on clean runs.
57
+ */
58
+ warnings?: string[];
53
59
  }
54
60
  interface RepoOption {
55
61
  repo: string;
@@ -167,6 +167,12 @@ export async function runFetchCorpus(options) {
167
167
  if (bundles.length > 0) {
168
168
  gistSyncWarning = await maybeCheckpoint(sm, MODULE);
169
169
  }
170
+ // Surface pagination truncation per bundle (#1456): a truncated bundle is
171
+ // missing its newest comments, so the host should know the corpus for that
172
+ // PR is partial rather than assuming every reviewer voice is present.
173
+ const warnings = bundles
174
+ .filter((b) => b.truncated)
175
+ .map((b) => `${b.prUrl}: comment streams hit the pagination cap; newest comments are missing from the corpus`);
170
176
  return {
171
177
  repo: options.repo,
172
178
  bundles,
@@ -174,6 +180,7 @@ export async function runFetchCorpus(options) {
174
180
  skipped,
175
181
  failures,
176
182
  ...(gistSyncWarning ? { gistSyncWarning } : {}),
183
+ ...(warnings.length > 0 ? { warnings } : {}),
177
184
  };
178
185
  }
179
186
  function clampLimit(limit) {
@@ -39,10 +39,6 @@ export { runTrack } from './track.js';
39
39
  export { runComplianceScore } from './compliance-score.js';
40
40
  /** Compute repo health rubric (1–10 score + verdict) via the typed core function (#1271, follow-up to #1242). */
41
41
  export { runRepoVet } from './repo-vet.js';
42
- /** Temporarily hide a PR from the daily digest. */
43
- export { runShelve } from './shelve.js';
44
- /** Restore a shelved PR to the daily digest. */
45
- export { runUnshelve } from './shelve.js';
46
42
  /** Move a PR between states: attention, waiting, shelved, auto. */
47
43
  export { runMove } from './move.js';
48
44
  /** Dismiss issue reply notifications (auto-resurfaces on new activity). */
@@ -85,6 +81,8 @@ export { runParseList, pruneIssueList } from './parse-list.js';
85
81
  export { runListMoveTier, moveIssueToTier, type Tier, type ListMoveTierOptions, type ListMoveTierOutput, } from './list-move-tier.js';
86
82
  /** Mark an issue line in a curated list as done with strikethrough + Done sub-bullet (#1299). */
87
83
  export { runMarkIssueListItemDone, markIssueAsDone, type MarkDoneOptions, type MarkDoneOutput, } from './list-mark-done.js';
84
+ /** Daily merge-loop reconciliation — auto-mark curated-list entries whose PR merged (#1463). */
85
+ export { reconcileMergedPRsWithList, findListEntryUrlByPrUrl } from './merge-loop.js';
88
86
  /** Check if new files are properly referenced/integrated. */
89
87
  export { runCheckIntegration } from './check-integration.js';
90
88
  /** System-health diagnostic — verifies tokens, bundle, state, scout, rate limit. */
@@ -98,7 +96,6 @@ export type { ErrorCode } from '../formatters/json.js';
98
96
  export type { DailyOutput, SearchOutput, SearchCandidate, CandidateLinkedPR, FeaturesOutput, FeaturesCandidate, FeaturesHorizon, StartupOutput, StatusOutput, TrackOutput, } from '../formatters/json.js';
99
97
  export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput, VetListOutput, VetListItemStatus, } from '../formatters/json.js';
100
98
  export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
101
- export type { ShelveOutput, UnshelveOutput } from './shelve.js';
102
99
  export type { MoveOutput, MoveTarget } from './move.js';
103
100
  export type { GuidelinesListOutput, GuidelinesViewOutput, GuidelinesStoreOutput, GuidelinesResetOutput, FetchCorpusOutput, } from './guidelines.js';
104
101
  export type { DismissOutput, UndismissOutput } from './dismiss.js';
@@ -41,10 +41,6 @@ export { runTrack } from './track.js';
41
41
  export { runComplianceScore } from './compliance-score.js';
42
42
  /** Compute repo health rubric (1–10 score + verdict) via the typed core function (#1271, follow-up to #1242). */
43
43
  export { runRepoVet } from './repo-vet.js';
44
- /** Temporarily hide a PR from the daily digest. */
45
- export { runShelve } from './shelve.js';
46
- /** Restore a shelved PR to the daily digest. */
47
- export { runUnshelve } from './shelve.js';
48
44
  /** Move a PR between states: attention, waiting, shelved, auto. */
49
45
  export { runMove } from './move.js';
50
46
  /** Dismiss issue reply notifications (auto-resurfaces on new activity). */
@@ -92,6 +88,8 @@ export { runParseList, pruneIssueList } from './parse-list.js';
92
88
  export { runListMoveTier, moveIssueToTier, } from './list-move-tier.js';
93
89
  /** Mark an issue line in a curated list as done with strikethrough + Done sub-bullet (#1299). */
94
90
  export { runMarkIssueListItemDone, markIssueAsDone, } from './list-mark-done.js';
91
+ /** Daily merge-loop reconciliation — auto-mark curated-list entries whose PR merged (#1463). */
92
+ export { reconcileMergedPRsWithList, findListEntryUrlByPrUrl } from './merge-loop.js';
95
93
  /** Check if new files are properly referenced/integrated. */
96
94
  export { runCheckIntegration } from './check-integration.js';
97
95
  /** System-health diagnostic — verifies tokens, bundle, state, scout, rate limit. */
@@ -5,6 +5,8 @@
5
5
  export interface InitOutput {
6
6
  username: string;
7
7
  message: string;
8
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1440). */
9
+ gistSyncWarning?: string;
8
10
  }
9
11
  /**
10
12
  * Initialize with a GitHub username.
@@ -2,8 +2,9 @@
2
2
  * Init command
3
3
  * Initialize with GitHub username. In v2, PRs are fetched fresh on each daily run.
4
4
  */
5
- import { getStateManager } from '../core/index.js';
5
+ import { getStateManager, maybeCheckpoint } from '../core/index.js';
6
6
  import { validateGitHubUsername } from './validation.js';
7
+ const MODULE = 'init';
7
8
  /**
8
9
  * Initialize with a GitHub username.
9
10
  *
@@ -17,8 +18,13 @@ export async function runInit(options) {
17
18
  const stateManager = getStateManager();
18
19
  // Set username in config
19
20
  stateManager.updateConfig({ githubUsername: options.username });
21
+ // Push the config mutation to the Gist in gist mode (no-op locally).
22
+ // Without this the change only hits the local cache and the next
23
+ // bootstrap reverts it from the Gist (#1440).
24
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
20
25
  return {
21
26
  username: options.username,
22
27
  message: 'Username saved. Run `daily` to fetch your open PRs from GitHub.',
28
+ ...(gistSyncWarning ? { gistSyncWarning } : {}),
23
29
  };
24
30
  }
@@ -17,9 +17,9 @@
17
17
  import * as fs from 'node:fs';
18
18
  import * as path from 'node:path';
19
19
  import { errorMessage, ValidationError } from '../core/errors.js';
20
+ import { ENTRY_LINE_RE, ENTRY_MARKER_RE, lineMentionsUrl } from './curated-list.js';
20
21
  const STRIKE = '~~';
21
22
  const DONE_PREFIX = ' - **Done**';
22
- const ISSUE_LINE_RE = /^[*+-]\s/;
23
23
  const REPO_HEADING_RE = /^###\s/;
24
24
  const SECTION_BREAK_RE = /^#{1,3}\s/;
25
25
  const PR_NUMBER_RE = /\/(?:pull|issues)\/(\d+)(?:[/?#]|$)/;
@@ -31,7 +31,7 @@ function prNumberLabel(prUrl) {
31
31
  /** Wrap a line in `~~...~~` if it isn't already. Returns the input unchanged when already wrapped. */
32
32
  function strikeLine(line) {
33
33
  // Strip the leading list marker so we wrap the content, not the bullet.
34
- const markerMatch = line.match(/^(?:[*+-]\s+|#{1,6}\s+)/);
34
+ const markerMatch = line.match(/^(?:[*+-]\s+|\d+\.\s+|#{1,6}\s+)/);
35
35
  const prefix = markerMatch ? markerMatch[0] : '';
36
36
  const body = line.slice(prefix.length);
37
37
  if (body.startsWith(STRIKE) && body.endsWith(STRIKE) && body.length >= 4) {
@@ -39,26 +39,11 @@ function strikeLine(line) {
39
39
  }
40
40
  return { line: `${prefix}${STRIKE}${body}${STRIKE}`, alreadyStruck: false };
41
41
  }
42
- /**
43
- * Match the URL only when followed by a non-digit, non-word character (or
44
- * end of line). A bare `includes(issueUrl)` would match `issues/1` against
45
- * a line containing `issues/10`, so finding/marking issue 1 would mark
46
- * whichever number-prefix line appears first.
47
- */
48
- function lineMentionsUrl(line, issueUrl) {
49
- const idx = line.indexOf(issueUrl);
50
- if (idx === -1)
51
- return false;
52
- const next = line.charCodeAt(idx + issueUrl.length);
53
- // NaN (end of string) → digit boundary OK. Otherwise reject any digit
54
- // immediately after the URL so 'issues/1' doesn't match 'issues/10'.
55
- return Number.isNaN(next) || next < 48 /* '0' */ || next > 57; /* '9' */
56
- }
57
42
  /** Find the issue block (issue line plus any indented sub-bullets) that mentions the URL. */
58
43
  function findIssueBlock(lines, issueUrl) {
59
44
  for (let i = 0; i < lines.length; i++) {
60
45
  const line = lines[i];
61
- if (!ISSUE_LINE_RE.test(line))
46
+ if (!ENTRY_LINE_RE.test(line))
62
47
  continue;
63
48
  if (!lineMentionsUrl(line, issueUrl))
64
49
  continue;
@@ -96,9 +81,9 @@ function countOpenIssues(lines, section) {
96
81
  let open = 0;
97
82
  for (let i = section.headingIndex + 1; i < section.end; i++) {
98
83
  const line = lines[i];
99
- if (!ISSUE_LINE_RE.test(line))
84
+ if (!ENTRY_LINE_RE.test(line))
100
85
  continue;
101
- const body = line.replace(/^[*+-]\s+/, '');
86
+ const body = line.replace(ENTRY_MARKER_RE, '');
102
87
  if (!(body.startsWith(STRIKE) && body.endsWith(STRIKE)))
103
88
  open++;
104
89
  }
@@ -120,7 +105,7 @@ export function markIssueAsDone(content, opts) {
120
105
  };
121
106
  }
122
107
  const issueLine = lines[block.start];
123
- const issueBody = issueLine.replace(/^[*+-]\s+/, '');
108
+ const issueBody = issueLine.replace(ENTRY_MARKER_RE, '');
124
109
  const alreadyMarked = issueBody.startsWith(STRIKE) && issueBody.endsWith(STRIKE);
125
110
  if (alreadyMarked) {
126
111
  const section = findRepoSection(lines, block.start);
@@ -15,6 +15,7 @@
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import { errorMessage, ValidationError } from '../core/errors.js';
18
+ import { ENTRY_LINE_RE, lineMentionsUrl } from './curated-list.js';
18
19
  const TIER_HEADERS = {
19
20
  pursue: '## Pursue',
20
21
  maybe: '## Maybe',
@@ -44,13 +45,10 @@ function tierForLine(lines, lineIndex) {
44
45
  /** Locate every issue block (issue line + any sub-bullets) that mentions the URL. */
45
46
  function findIssueBlocks(lines, issueUrl) {
46
47
  const blocks = [];
47
- // Use exact substring match on the URL — no regex escaping pitfalls and
48
- // the URL itself contains no markdown delimiters that would split a line.
49
48
  for (let i = 0; i < lines.length; i++) {
50
49
  const line = lines[i];
51
- // Top-level list item `- `, `* `, `+ `, or `1.` at the start (with no leading whitespace).
52
- const isTopLevelListItem = /^[*+-]\s|^\d+\.\s/.test(line);
53
- if (!isTopLevelListItem || !line.includes(issueUrl))
50
+ // lineMentionsUrl (not a bare includes) so issues/1 can't match issues/10 (#1442).
51
+ if (!ENTRY_LINE_RE.test(line) || !lineMentionsUrl(line, issueUrl))
54
52
  continue;
55
53
  // Capture indented sub-bullets that follow this line.
56
54
  let end = i + 1;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Curated-list path detection (#1463).
3
+ *
4
+ * Extracted from `startup.ts` so the daily merge-loop reconciliation can
5
+ * locate the list without importing `startup.ts` — that would close an
6
+ * import cycle (startup → daily → merge-loop → startup). `startup.ts`
7
+ * re-exports `parseIssueListPathFromConfig` for back-compat and layers item
8
+ * counts / skipped-issues detection on top of `detectIssueListPath`.
9
+ */
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;
16
+ export interface IssueListLocation {
17
+ path: string;
18
+ source: 'configured' | 'auto-detected';
19
+ }
20
+ /**
21
+ * Locate the curated issue-list file: state config first, then the legacy
22
+ * config.md frontmatter, then known default paths. Returns undefined when
23
+ * no list exists — callers treat that as "user has no curated list".
24
+ */
25
+ export declare function detectIssueListPath(): IssueListLocation | undefined;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Curated-list path detection (#1463).
3
+ *
4
+ * Extracted from `startup.ts` so the daily merge-loop reconciliation can
5
+ * locate the list without importing `startup.ts` — that would close an
6
+ * import cycle (startup → daily → merge-loop → startup). `startup.ts`
7
+ * re-exports `parseIssueListPathFromConfig` for back-compat and layers item
8
+ * counts / skipped-issues detection on top of `detectIssueListPath`.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import { getStateManager } from '../core/index.js';
12
+ import { errorMessage } from '../core/errors.js';
13
+ import { warn } from '../core/logger.js';
14
+ /**
15
+ * Parse issueListPath from a config file's YAML frontmatter.
16
+ * @param configContent - Raw content of the config.md file
17
+ * @returns The path string or undefined if not found
18
+ */
19
+ export function parseIssueListPathFromConfig(configContent) {
20
+ const match = configContent.match(/^---\n([\s\S]*?)\n---/);
21
+ if (!match)
22
+ return undefined;
23
+ const frontmatter = match[1];
24
+ const pathMatch = frontmatter.match(/issueListPath:\s*["']?([^"'\n]+)["']?/);
25
+ return pathMatch ? pathMatch[1].trim() : undefined;
26
+ }
27
+ /**
28
+ * Locate the curated issue-list file: state config first, then the legacy
29
+ * config.md frontmatter, then known default paths. Returns undefined when
30
+ * no list exists — callers treat that as "user has no curated list".
31
+ */
32
+ export function detectIssueListPath() {
33
+ // 1. Check state.json config (primary)
34
+ try {
35
+ const stateManager = getStateManager();
36
+ const configuredPath = stateManager.getState().config.issueListPath;
37
+ if (configuredPath && fs.existsSync(configuredPath)) {
38
+ return { path: configuredPath, source: 'configured' };
39
+ }
40
+ }
41
+ catch (error) {
42
+ // State manager may not be initialized yet — fall through to legacy config.md
43
+ warn('startup', `Could not read issueListPath from state: ${errorMessage(error)}`);
44
+ }
45
+ // 2. Fallback: config.md (legacy — will be removed in future)
46
+ const configPath = '.claude/oss-autopilot/config.md';
47
+ if (fs.existsSync(configPath)) {
48
+ try {
49
+ const configContent = fs.readFileSync(configPath, 'utf8');
50
+ const configuredPath = parseIssueListPathFromConfig(configContent);
51
+ if (configuredPath && fs.existsSync(configuredPath)) {
52
+ return { path: configuredPath, source: 'configured' };
53
+ }
54
+ }
55
+ catch (error) {
56
+ console.error('[STARTUP] Failed to read config:', errorMessage(error));
57
+ }
58
+ }
59
+ // 3. Probe known paths
60
+ const probes = ['open-source/potential-issue-list.md', 'oss/issue-list.md', 'issues.md'];
61
+ for (const probe of probes) {
62
+ if (fs.existsSync(probe)) {
63
+ return { path: probe, source: 'auto-detected' };
64
+ }
65
+ }
66
+ return undefined;
67
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Close the merge loop (#1463).
3
+ *
4
+ * When the daily check detects recently merged PRs, nothing previously
5
+ * connected the merge back to the curated issue list — `list-mark-done` is
6
+ * only offered interactively at PR-creation time (draft-first-workflow
7
+ * Step 10), so a skipped prompt left the entry open forever.
8
+ *
9
+ * The join: neither the merged-PR search result (url/repo/number/title/
10
+ * mergedAt) nor `state.activeIssues` (TrackedIssue — issue URL only, no PR
11
+ * URL) carries a deterministic PR→issue link. The only deterministic
12
+ * linkage lives in the curated list itself: workflows record the PR URL in
13
+ * an entry's sub-bullets (`**Done**` at Step 10, or in-progress markers
14
+ * like `**In Progress** — PR [#N](url)`). So the reconciliation traverses
15
+ * the list file's entry blocks for the merged PR URL, recovers the entry's
16
+ * own URL from its entry line, and marks it done via the exact transform
17
+ * `list-mark-done` uses. Repo-level guessing (matching a merged PR to "some
18
+ * claimed issue in the same repo") is deliberately avoided — it could
19
+ * strike the wrong entry when a repo has several listed issues.
20
+ *
21
+ * Auto vs offer: marking a list entry done for a PR that GitHub reports as
22
+ * MERGED is safe and idempotent (already-marked entries are quiet no-ops),
23
+ * so it runs automatically. The extract-learnings nudge stays an offer —
24
+ * it costs API calls and an LLM pass — surfaced via the action menu's
25
+ * `extract_learnings` item (see computeActionMenu in core/daily-logic.ts).
26
+ *
27
+ * Failures never crash the daily run: they land in `warnings[]` under the
28
+ * `merge-loop` phase.
29
+ */
30
+ import type { DailyWarning, MergedPRListUpdate } from '../formatters/json.js';
31
+ /**
32
+ * Find the curated-list entry whose block (entry line plus indented
33
+ * sub-bullets) mentions `prUrl` on a STATUS-marked line, and return the
34
+ * entry line's own GitHub URL — the URL `markIssueAsDone` needs to locate
35
+ * the block again.
36
+ *
37
+ * Returns undefined when no entry block mentions the PR URL on a marked
38
+ * line, or when the mentioning block's entry line carries no GitHub URL
39
+ * (nothing for the mark-done transform to anchor on).
40
+ */
41
+ export declare function findListEntryUrlByPrUrl(content: string, prUrl: string): string | undefined;
42
+ export interface ReconcileMergedPRsInput {
43
+ /** Recently merged PRs from the daily fetch (Phase 1). */
44
+ mergedPRs: Array<{
45
+ url: string;
46
+ mergedAt?: string;
47
+ }>;
48
+ /** Shared daily warnings collector — failures land here, never throw. */
49
+ warnings: DailyWarning[];
50
+ }
51
+ /**
52
+ * Auto-mark curated-list entries done for recently merged PRs.
53
+ *
54
+ * Silent no-op cases (by design — these are normal, not failures):
55
+ * - no merged PRs this run
56
+ * - no curated list configured/detected
57
+ * - no list entry mentions a merged PR's URL
58
+ * - the matching entry is already marked done (idempotent re-run)
59
+ *
60
+ * Returns the entries actually struck this run, or undefined when nothing
61
+ * changed — callers omit the field from output rather than emitting `[]`.
62
+ */
63
+ export declare function reconcileMergedPRsWithList(input: ReconcileMergedPRsInput): MergedPRListUpdate[] | undefined;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Close the merge loop (#1463).
3
+ *
4
+ * When the daily check detects recently merged PRs, nothing previously
5
+ * connected the merge back to the curated issue list — `list-mark-done` is
6
+ * only offered interactively at PR-creation time (draft-first-workflow
7
+ * Step 10), so a skipped prompt left the entry open forever.
8
+ *
9
+ * The join: neither the merged-PR search result (url/repo/number/title/
10
+ * mergedAt) nor `state.activeIssues` (TrackedIssue — issue URL only, no PR
11
+ * URL) carries a deterministic PR→issue link. The only deterministic
12
+ * linkage lives in the curated list itself: workflows record the PR URL in
13
+ * an entry's sub-bullets (`**Done**` at Step 10, or in-progress markers
14
+ * like `**In Progress** — PR [#N](url)`). So the reconciliation traverses
15
+ * the list file's entry blocks for the merged PR URL, recovers the entry's
16
+ * own URL from its entry line, and marks it done via the exact transform
17
+ * `list-mark-done` uses. Repo-level guessing (matching a merged PR to "some
18
+ * claimed issue in the same repo") is deliberately avoided — it could
19
+ * strike the wrong entry when a repo has several listed issues.
20
+ *
21
+ * Auto vs offer: marking a list entry done for a PR that GitHub reports as
22
+ * MERGED is safe and idempotent (already-marked entries are quiet no-ops),
23
+ * so it runs automatically. The extract-learnings nudge stays an offer —
24
+ * it costs API calls and an LLM pass — surfaced via the action menu's
25
+ * `extract_learnings` item (see computeActionMenu in core/daily-logic.ts).
26
+ *
27
+ * Failures never crash the daily run: they land in `warnings[]` under the
28
+ * `merge-loop` phase.
29
+ */
30
+ import * as fs from 'node:fs';
31
+ import * as path from 'node:path';
32
+ import { errorMessage } from '../core/errors.js';
33
+ import { warn } from '../core/logger.js';
34
+ import { ENTRY_LINE_RE, lineMentionsUrl } from './curated-list.js';
35
+ import { markIssueAsDone } from './list-mark-done.js';
36
+ import { detectIssueListPath } from './locate-issue-list.js';
37
+ const MODULE = 'merge-loop';
38
+ /** First GitHub issue-or-PR URL on a line, without trailing punctuation. */
39
+ const GITHUB_URL_RE = /https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:issues|pull)\/\d+/;
40
+ /**
41
+ * A line counts as a STATUS sub-bullet only when it carries one of the
42
+ * markers the workflows write next to an entry's own PR (`**Done**`,
43
+ * `**In Progress**`, or a `PR [#N](...)` reference). A bare URL mention
44
+ * (e.g. a hand-written "blocked by <pr-url>" note under a DIFFERENT
45
+ * entry) must not auto-strike that entry (#1463 review).
46
+ */
47
+ const STATUS_MARKER_RE = /\*\*(?:Done|In Progress)\*\*|PR \[#?\d/;
48
+ /**
49
+ * Find the curated-list entry whose block (entry line plus indented
50
+ * sub-bullets) mentions `prUrl` on a STATUS-marked line, and return the
51
+ * entry line's own GitHub URL — the URL `markIssueAsDone` needs to locate
52
+ * the block again.
53
+ *
54
+ * Returns undefined when no entry block mentions the PR URL on a marked
55
+ * line, or when the mentioning block's entry line carries no GitHub URL
56
+ * (nothing for the mark-done transform to anchor on).
57
+ */
58
+ export function findListEntryUrlByPrUrl(content, prUrl) {
59
+ const lines = content.split('\n');
60
+ let i = 0;
61
+ while (i < lines.length) {
62
+ if (!ENTRY_LINE_RE.test(lines[i])) {
63
+ i++;
64
+ continue;
65
+ }
66
+ // Block = entry line + following indented sub-bullets (same shape as
67
+ // list-mark-done's findIssueBlock).
68
+ let end = i + 1;
69
+ while (end < lines.length && /^\s{2,}/.test(lines[end]))
70
+ end++;
71
+ // The entry line mentioning the PR itself is self-anchored (no
72
+ // cross-entry ambiguity); sub-bullets must carry a status marker.
73
+ const entryMentions = lineMentionsUrl(lines[i], prUrl);
74
+ const markedSubBulletMentions = lines
75
+ .slice(i + 1, end)
76
+ .some((line) => lineMentionsUrl(line, prUrl) && STATUS_MARKER_RE.test(line));
77
+ if (entryMentions || markedSubBulletMentions) {
78
+ const match = GITHUB_URL_RE.exec(lines[i]);
79
+ if (match)
80
+ return match[0];
81
+ // Entry line has no URL to anchor a mark-done on — keep scanning in
82
+ // case a later block also mentions the PR (e.g. duplicate sections).
83
+ }
84
+ i = end;
85
+ }
86
+ return undefined;
87
+ }
88
+ /**
89
+ * Auto-mark curated-list entries done for recently merged PRs.
90
+ *
91
+ * Silent no-op cases (by design — these are normal, not failures):
92
+ * - no merged PRs this run
93
+ * - no curated list configured/detected
94
+ * - no list entry mentions a merged PR's URL
95
+ * - the matching entry is already marked done (idempotent re-run)
96
+ *
97
+ * Returns the entries actually struck this run, or undefined when nothing
98
+ * changed — callers omit the field from output rather than emitting `[]`.
99
+ */
100
+ export function reconcileMergedPRsWithList(input) {
101
+ const { mergedPRs, warnings } = input;
102
+ if (mergedPRs.length === 0)
103
+ return undefined;
104
+ const located = detectIssueListPath();
105
+ if (!located)
106
+ return undefined;
107
+ const listPath = path.resolve(located.path);
108
+ let content;
109
+ try {
110
+ content = fs.readFileSync(listPath, 'utf8');
111
+ }
112
+ catch (error) {
113
+ const message = `read issue list at ${listPath}: ${errorMessage(error)}`;
114
+ warnings.push({ phase: 'merge-loop', operation: 'auto-mark merged list entries', message });
115
+ warn(MODULE, message);
116
+ return undefined;
117
+ }
118
+ const updates = [];
119
+ for (const pr of mergedPRs) {
120
+ const entryUrl = findListEntryUrlByPrUrl(content, pr.url);
121
+ if (!entryUrl)
122
+ continue;
123
+ const prStatus = pr.mergedAt ? `merged ${pr.mergedAt.slice(0, 10)}` : 'merged';
124
+ const result = markIssueAsDone(content, { issueUrl: entryUrl, prUrl: pr.url, prStatus });
125
+ if (!result.marked)
126
+ continue; // already-marked (or not-found race) — quiet no-op
127
+ content = result.content;
128
+ updates.push({
129
+ prUrl: pr.url,
130
+ issueUrl: entryUrl,
131
+ repoHeadingStruck: result.repoHeadingStruck,
132
+ listPath,
133
+ });
134
+ }
135
+ if (updates.length === 0)
136
+ return undefined;
137
+ // Atomic write (tmp + rename), mirroring runMarkIssueListItemDone.
138
+ const tmp = `${listPath}.tmp-${process.pid}-${Date.now()}`;
139
+ try {
140
+ fs.writeFileSync(tmp, content, 'utf8');
141
+ fs.renameSync(tmp, listPath);
142
+ }
143
+ catch (error) {
144
+ try {
145
+ fs.unlinkSync(tmp);
146
+ }
147
+ catch {
148
+ // best-effort cleanup
149
+ }
150
+ const message = `write issue list at ${listPath}: ${errorMessage(error)}`;
151
+ warnings.push({ phase: 'merge-loop', operation: 'auto-mark merged list entries', message });
152
+ warn(MODULE, message);
153
+ // Nothing persisted — do not report updates that are not on disk.
154
+ return undefined;
155
+ }
156
+ return updates;
157
+ }
@@ -10,7 +10,7 @@
10
10
  * Same architectural shape as `compliance-score`: read-only API calls,
11
11
  * no state mutation, runs against a public `owner/repo` slug.
12
12
  */
13
- import { getOctokit, requireGitHubToken } from '../core/index.js';
13
+ import { getOctokit, getStateManager, requireGitHubToken } from '../core/index.js';
14
14
  import { errorMessage, getHttpStatusCode, isRateLimitOrAuthError } from '../core/errors.js';
15
15
  import { warn } from '../core/logger.js';
16
16
  import { validateRepoIdentifier } from './validation.js';
@@ -102,6 +102,39 @@ async function checkCommunityHealth(octokit, owner, repo) {
102
102
  incomplete,
103
103
  };
104
104
  }
105
+ /**
106
+ * Look up the user's cached HISTORY score for a repo slug (#1465).
107
+ *
108
+ * This is the relationship score from `state.repoScores` (the user's own
109
+ * merge outcomes, computed by `repo-score-manager.ts`) — a different number
110
+ * from the fresh `rubricScore` this command computes. Surfacing both in one
111
+ * envelope, under distinct names, is the point: agents must never quote one
112
+ * as the other. See docs/repo-scores.md.
113
+ *
114
+ * State keys come from parsed GitHub URLs and preserve their original case,
115
+ * while the user types the slug by hand — GitHub slugs are case-insensitive,
116
+ * so fall back to a case-insensitive scan before reporting "no history".
117
+ * Returns `undefined` (field omitted from output) when the user has no
118
+ * cached score for the repo.
119
+ */
120
+ function lookupHistoryScore(repoSlug) {
121
+ // Best-effort by design: repo-vet ran for years without touching local
122
+ // state, and an unreadable state file (EACCES rethrows from loadState)
123
+ // must degrade to "no history", not abort the vet (#1465 review).
124
+ try {
125
+ const stateManager = getStateManager();
126
+ const exact = stateManager.getRepoScore(repoSlug);
127
+ if (exact)
128
+ return exact.score;
129
+ const lower = repoSlug.toLowerCase();
130
+ const match = Object.values(stateManager.getState().repoScores).find((rs) => rs.repo.toLowerCase() === lower);
131
+ return match?.score;
132
+ }
133
+ catch (error) {
134
+ warn(MODULE, `History-score lookup skipped (state unavailable): ${errorMessage(error)}`);
135
+ return undefined;
136
+ }
137
+ }
105
138
  function summarizePRMerges(prs, windowDays, now) {
106
139
  const cutoff = now.getTime() - windowDays * DAY_MS;
107
140
  const prMergeTimesDays = [];
@@ -226,6 +259,11 @@ export async function runRepoVet(options) {
226
259
  // rubric score — the score reflects the fetched signals as-is and the
227
260
  // flag tells consumers which signal was unverified.
228
261
  const { repo: repoMeta, communityHealth, maintainerActivity, ...rest } = result;
262
+ // #1465: name both repo scores in one envelope. `rubricScore` is the fresh
263
+ // health score computed above; `historyScore` is the user's cached
264
+ // relationship score when one exists. Optional so the output is unchanged
265
+ // for repos the user has no history with (back-compat).
266
+ const historyScore = lookupHistoryScore(options.repo);
229
267
  return {
230
268
  repoSlug: options.repo,
231
269
  fetchedAt: now.toISOString(),
@@ -233,5 +271,6 @@ export async function runRepoVet(options) {
233
271
  communityHealth: { ...communityHealth, incomplete: communityHealthSummary.incomplete },
234
272
  maintainerActivity: { ...maintainerActivity, releasesIncomplete },
235
273
  ...rest,
274
+ ...(historyScore !== undefined ? { historyScore } : {}),
236
275
  };
237
276
  }
@@ -5,6 +5,32 @@
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
7
  import type { CandidateLinkedPR } from '../formatters/json.js';
8
+ /**
9
+ * Fraction of search slots reserved for candidates that matched neither
10
+ * strategy-preferred languages nor repos (#1244). Counterweight against
11
+ * echo-chamber bias: without it, strategy-boosted searches return more of
12
+ * what already merged, which merges more of the same, and the profile
13
+ * narrows over time. Scout clamps to [0, 1]; 0.2 is the issue's proposal.
14
+ *
15
+ * Lives here (not `search.ts`, which re-exports it) since #1464: the same
16
+ * ratio is baked into the `ScoutPreferences` built by {@link buildScoutState}
17
+ * so every scout surface shares one policy.
18
+ */
19
+ export declare const SEARCH_DIVERSITY_RATIO = 0.2;
20
+ /**
21
+ * Degradation signals collected while building a scout state (#1448).
22
+ * Callers pass an empty object and inspect it after the build — threading an
23
+ * out-param keeps `buildScoutState`/`createAutopilotScout` return types
24
+ * unchanged for the five command call sites.
25
+ */
26
+ export interface ScoutBridgeDiagnostics {
27
+ /**
28
+ * Set when a configured skipped-issues file exists but could not be read.
29
+ * The scout state was built with an EMPTY skip list, so explicitly-skipped
30
+ * issues may resurface in results.
31
+ */
32
+ skipListUnavailable?: boolean;
33
+ }
8
34
  /**
9
35
  * Convert scout 0.6.0's `LinkedPR` (separate `state` + `merged`) into the
10
36
  * shape `classifyLinkedPR` expects (`state` already folded with `merged`).
@@ -29,10 +55,17 @@ export declare function buildCandidateLinkedPR(scoutLinkedPR: ScoutLinkedPR | nu
29
55
  /**
30
56
  * Build a ScoutState from the current AgentState.
31
57
  * Maps oss-autopilot's config and state fields to oss-scout's state format.
58
+ *
59
+ * @param diagnostics - Optional collector for degradation signals (#1448);
60
+ * `skipListUnavailable` is set when the skipped-issues file exists but
61
+ * could not be read.
32
62
  */
33
- export declare function buildScoutState(): ScoutState;
63
+ export declare function buildScoutState(diagnostics?: ScoutBridgeDiagnostics): ScoutState;
34
64
  /**
35
65
  * Create an OssScout instance backed by the current AgentState.
36
66
  * Uses 'provided' persistence so scout reads from oss-autopilot's state.
67
+ *
68
+ * @param diagnostics - Optional collector for degradation signals (#1448),
69
+ * forwarded to {@link buildScoutState}.
37
70
  */
38
- export declare function createAutopilotScout(): Promise<OssScout>;
71
+ export declare function createAutopilotScout(diagnostics?: ScoutBridgeDiagnostics): Promise<OssScout>;