@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
@@ -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
  };
@@ -1,26 +1,35 @@
1
1
  /**
2
- * Anti-LLM policy scan (#108, #911, #979).
2
+ * AI/LLM policy scans (#108, #911, #979, #1269).
3
3
  *
4
- * Scan concatenated repo docs (CONTRIBUTING.md, CODE_OF_CONDUCT.md,
5
- * README) for language that indicates the project does not accept
6
- * AI/LLM-generated contributions. Previously described as a keyword
7
- * table in prose in agents/issue-scout.md.
4
+ * Two complementary scanners over the same input (concatenated repo docs
5
+ * CONTRIBUTING.md, CODE_OF_CONDUCT.md, README, etc.):
6
+ *
7
+ * 1. {@link scanForAntiLLMPolicy} detects language indicating the
8
+ * project does NOT accept AI/LLM-generated contributions. Used by
9
+ * issue-scout / repo-evaluator to filter out repos where landing a
10
+ * PR is impossible.
11
+ *
12
+ * 2. {@link scanAIDisclosureRequirement} — detects the opposite:
13
+ * language requiring or inviting AI disclosure ("must disclose AI
14
+ * use", "credit AI tools"). Used by pr-compliance-checker to decide
15
+ * whether AI attribution should be flagged as a violation, encouraged,
16
+ * or required (#1269 Improvement C).
8
17
  *
9
18
  * The long-term home for this logic is `@oss-scout/core`, where the
10
19
  * relevant files are already fetched during vetting. Keeping it here
11
20
  * for now lets the agent invoke it directly and gives scout a
12
21
  * reference implementation + test fixtures to adopt. See #979.
13
22
  *
14
- * Precision matters more than recall. False positives (flagging a
15
- * project that actually welcomes AI help) silently shrink the user's
16
- * contribution surface without recourse. We only match on phrases
17
- * that combine a rejection keyword (no / reject / will be closed /
18
- * don't accept) with an AI/LLM noun.
23
+ * Precision matters more than recall in both directions. A false
24
+ * positive on the anti-LLM side silently shrinks the user's
25
+ * contribution surface; a false positive on the disclosure side tells
26
+ * the user to add attribution that the maintainer didn't actually ask
27
+ * for. Patterns require an explicit verb-phrase + AI/LLM-noun pairing.
19
28
  *
20
29
  * **User-facing reference:** `docs/anti-llm-policy.md` — explains the
21
- * three categories, example phrases per category, and the false-positive-
22
- * resistance design (why "AI division will be closed at end of Q4"
23
- * does NOT match).
30
+ * three anti-LLM categories, example phrases per category, and the
31
+ * false-positive-resistance design (why "AI division will be closed at
32
+ * end of Q4" does NOT match).
24
33
  */
25
34
  export type AntiLLMCategory = 'explicit_ban' | 'tool_ban' | 'reject_framing';
26
35
  export interface AntiLLMMatch {
@@ -35,3 +44,23 @@ export interface AntiLLMScanResult {
35
44
  matches: AntiLLMMatch[];
36
45
  }
37
46
  export declare function scanForAntiLLMPolicy(text: string): AntiLLMScanResult;
47
+ export type AIDisclosureCategory = 'mandatory' | 'recommended' | 'invited';
48
+ export interface AIDisclosureMatch {
49
+ category: AIDisclosureCategory;
50
+ phrase: string;
51
+ excerpt: string;
52
+ }
53
+ export interface AIDisclosureScanResult {
54
+ matched: boolean;
55
+ matches: AIDisclosureMatch[];
56
+ }
57
+ /**
58
+ * Scan repo docs for language requiring or inviting AI disclosure (#1269).
59
+ *
60
+ * Mirrors {@link scanForAntiLLMPolicy}'s shape: same input contract,
61
+ * same false-positive-resistance discipline, same per-match excerpt for
62
+ * surfacing to the user. Categories are ordered by binding strength —
63
+ * callers that want to avoid false-positive flagging on weak invitations
64
+ * can filter to 'mandatory' / 'recommended' only.
65
+ */
66
+ export declare function scanAIDisclosureRequirement(text: string): AIDisclosureScanResult;
@@ -1,26 +1,35 @@
1
1
  /**
2
- * Anti-LLM policy scan (#108, #911, #979).
2
+ * AI/LLM policy scans (#108, #911, #979, #1269).
3
3
  *
4
- * Scan concatenated repo docs (CONTRIBUTING.md, CODE_OF_CONDUCT.md,
5
- * README) for language that indicates the project does not accept
6
- * AI/LLM-generated contributions. Previously described as a keyword
7
- * table in prose in agents/issue-scout.md.
4
+ * Two complementary scanners over the same input (concatenated repo docs
5
+ * CONTRIBUTING.md, CODE_OF_CONDUCT.md, README, etc.):
6
+ *
7
+ * 1. {@link scanForAntiLLMPolicy} detects language indicating the
8
+ * project does NOT accept AI/LLM-generated contributions. Used by
9
+ * issue-scout / repo-evaluator to filter out repos where landing a
10
+ * PR is impossible.
11
+ *
12
+ * 2. {@link scanAIDisclosureRequirement} — detects the opposite:
13
+ * language requiring or inviting AI disclosure ("must disclose AI
14
+ * use", "credit AI tools"). Used by pr-compliance-checker to decide
15
+ * whether AI attribution should be flagged as a violation, encouraged,
16
+ * or required (#1269 Improvement C).
8
17
  *
9
18
  * The long-term home for this logic is `@oss-scout/core`, where the
10
19
  * relevant files are already fetched during vetting. Keeping it here
11
20
  * for now lets the agent invoke it directly and gives scout a
12
21
  * reference implementation + test fixtures to adopt. See #979.
13
22
  *
14
- * Precision matters more than recall. False positives (flagging a
15
- * project that actually welcomes AI help) silently shrink the user's
16
- * contribution surface without recourse. We only match on phrases
17
- * that combine a rejection keyword (no / reject / will be closed /
18
- * don't accept) with an AI/LLM noun.
23
+ * Precision matters more than recall in both directions. A false
24
+ * positive on the anti-LLM side silently shrinks the user's
25
+ * contribution surface; a false positive on the disclosure side tells
26
+ * the user to add attribution that the maintainer didn't actually ask
27
+ * for. Patterns require an explicit verb-phrase + AI/LLM-noun pairing.
19
28
  *
20
29
  * **User-facing reference:** `docs/anti-llm-policy.md` — explains the
21
- * three categories, example phrases per category, and the false-positive-
22
- * resistance design (why "AI division will be closed at end of Q4"
23
- * does NOT match).
30
+ * three anti-LLM categories, example phrases per category, and the
31
+ * false-positive-resistance design (why "AI division will be closed at
32
+ * end of Q4" does NOT match).
24
33
  */
25
34
  const PATTERNS = [
26
35
  // Explicit "no X" bans against AI/LLM nouns.
@@ -104,3 +113,83 @@ export function scanForAntiLLMPolicy(text) {
104
113
  }
105
114
  return { matched: matches.length > 0, matches };
106
115
  }
116
+ const DISCLOSURE_PATTERNS = [
117
+ // Mandatory: imperative verbs binding to an AI/LLM disclosure noun.
118
+ // Match shape: <verb-phrase> <AI noun> <disclosure noun>?
119
+ // Verb phrases: "must disclose", "required to disclose", "are required
120
+ // to indicate", "you must indicate". The optional disclosure-action
121
+ // noun catches "must disclose use of AI" without requiring a separate
122
+ // pattern. The AI noun can be plain "ai/llm" or a tool name.
123
+ {
124
+ category: 'mandatory',
125
+ regex: /\b(must|required\s+to|are\s+required\s+to)\s+(disclose|indicate|declare|note|state|mention|label|tag|credit|acknowledge)\s+(?:[a-z\s'-]{0,40}?\b)?(ai|llm|generative\s+ai|copilot|chatgpt|claude|cursor)\b/i,
126
+ },
127
+ // "PRs using AI must be labeled / tagged / disclosed"
128
+ {
129
+ category: 'mandatory',
130
+ regex: /\b(prs?|contributions?|commits?|code)\s+(using|generated\s+by|written\s+by|made\s+with)\s+(ai|llm|copilot|chatgpt|claude|cursor)\s+(?:tools?\s+)?(must\s+be|need\s+to\s+be|are\s+to\s+be)\s+(labeled|tagged|disclosed|marked|flagged|noted)\b/i,
131
+ },
132
+ // "Disclosure of AI assistance is required" — passive form
133
+ {
134
+ category: 'mandatory',
135
+ regex: /\b(disclosure|disclosing|labeling|labelling|tagging|crediting)\s+(of\s+)?(ai|llm|copilot|chatgpt|claude|cursor)(\s+(use|usage|assistance|tools?|contributions?|generated\s+code))?\s+(is\s+required|is\s+mandatory|must\s+be\s+included)\b/i,
136
+ },
137
+ // Recommended: softer "should" or "we ask" framing. Same noun anchors.
138
+ // Verb forms allow optional gerund (-ing) endings since "we strongly
139
+ // encourage acknowledging AI" reads naturally even though "acknowledge"
140
+ // is the bare verb in the imperative list.
141
+ {
142
+ category: 'recommended',
143
+ regex: /\b(should|we\s+ask\s+you\s+to|we\s+ask\s+that\s+you|we\s+(?:strongly\s+)?(?:encourage|recommend))\s+(disclose|disclosing|indicate|indicating|declare|declaring|note|noting|mention|mentioning|label|labeling|labelling|tag|tagging|credit|crediting|acknowledge|acknowledging)\s+(?:[a-z\s'-]{0,40}?\b)?(ai|llm|generative\s+ai|copilot|chatgpt|claude|cursor)\b/i,
144
+ },
145
+ // "credit AI tools you used" — direct imperative without "must/should"
146
+ {
147
+ category: 'recommended',
148
+ regex: /\b(credit|acknowledge|attribute)\s+(ai|llm|generative\s+ai)\s+(tools?|assistants?|use|usage|assistance)\b/i,
149
+ },
150
+ // Invited: permissive framing. Lower confidence.
151
+ // The "please" branch is dropped intentionally — bare "please mention X"
152
+ // doesn't carry enough policy weight on its own, and including it as
153
+ // `please\s+(?:feel\s+free\s+to)?\s+` introduces super-linear backtracking
154
+ // (regexp/no-super-linear-backtracking) because of the ambiguous \s+
155
+ // boundary on either side of the optional group.
156
+ {
157
+ category: 'invited',
158
+ regex: /\b(feel\s+free\s+to|please\s+feel\s+free\s+to|you('re|\s+are)\s+welcome\s+to|welcome\s+to)\s+(disclose|mention|note|indicate|label|tag|credit)\s+(?:[a-z\s'-]{0,40}?\b)?(ai|llm|generative\s+ai|copilot|chatgpt|claude|cursor)\b/i,
159
+ },
160
+ ];
161
+ /**
162
+ * Scan repo docs for language requiring or inviting AI disclosure (#1269).
163
+ *
164
+ * Mirrors {@link scanForAntiLLMPolicy}'s shape: same input contract,
165
+ * same false-positive-resistance discipline, same per-match excerpt for
166
+ * surfacing to the user. Categories are ordered by binding strength —
167
+ * callers that want to avoid false-positive flagging on weak invitations
168
+ * can filter to 'mandatory' / 'recommended' only.
169
+ */
170
+ export function scanAIDisclosureRequirement(text) {
171
+ if (typeof text !== 'string') {
172
+ throw new TypeError(`scanAIDisclosureRequirement: expected string, received ${typeof text}`);
173
+ }
174
+ if (text === '')
175
+ return { matched: false, matches: [] };
176
+ const normalized = normalizeText(text);
177
+ const seenLabels = new Set();
178
+ const matches = [];
179
+ for (const pattern of DISCLOSURE_PATTERNS) {
180
+ const hit = normalized.match(pattern.regex);
181
+ if (!hit || hit.index === undefined)
182
+ continue;
183
+ const phrase = hit[0];
184
+ const key = `${pattern.category}:${phrase.toLowerCase()}`;
185
+ if (seenLabels.has(key))
186
+ continue;
187
+ seenLabels.add(key);
188
+ matches.push({
189
+ category: pattern.category,
190
+ phrase,
191
+ excerpt: makeExcerpt(normalized, hit.index, phrase.length),
192
+ });
193
+ }
194
+ return { matched: matches.length > 0, matches };
195
+ }
@@ -3,7 +3,7 @@
3
3
  * Extracted from PRMonitor to isolate CI-related logic (#263).
4
4
  */
5
5
  import type { Octokit } from '@octokit/rest';
6
- import { CIFailureCategory, ClassifiedCheck, CIStatusResult } from './types.js';
6
+ import { CIFailureCategory, ClassifiedCheck, CIStatusResult, CIStatus, CIStatusCategorization } from './types.js';
7
7
  /**
8
8
  * Classify a failing CI check as actionable, fork_limitation, auth_gate, or infrastructure (#81, #145, #743).
9
9
  * Default is 'actionable' — only known patterns get reclassified.
@@ -16,6 +16,37 @@ export declare function classifyCICheck(name: string, description?: string, conc
16
16
  * Accepts optional conclusion data to detect infrastructure failures and auth gates.
17
17
  */
18
18
  export declare function classifyFailingChecks(failingCheckNames: string[], conclusions?: Map<string, string>): ClassifiedCheck[];
19
+ /**
20
+ * Map an aggregate `ciStatus + failingCheckNames + classifiedChecks` triple
21
+ * into one of five mutually exclusive overall states (#1272). The 5-row
22
+ * truth table previously lived as prose in `agents/pr-health-checker.md`;
23
+ * extracting it lets that agent (and any future consumer — dashboard,
24
+ * MCP, sibling agents that adopt the field) read a single typed value
25
+ * instead of re-deriving from `ciStatus + failingCheckNames + classifiedChecks`.
26
+ *
27
+ * Decision order (each branch is exclusive):
28
+ * 1. `passing` → `all_passing`
29
+ * 2. `pending` → `blocked` (awaiting trigger / completion)
30
+ * 3. `failing` + actionable → `failing` (real test/lint/build issue)
31
+ * 4. `failing` + only infrastructure → `blocked` (cancelled/timed-out runner — needs rerun)
32
+ * 5. `failing` + only fork/auth → `fork_limitation` (informational)
33
+ * 6. `failing` + zero classified → `failing` w/ "details unavailable" summary
34
+ * 7. `unknown` → `not_running`
35
+ *
36
+ * Why infrastructure routes to `blocked` and not `fork_limitation`:
37
+ * a cancelled or timed-out runner is genuinely worth re-running; calling
38
+ * it "informational" would tell the agent to ignore something the user
39
+ * can fix with a rerun-request.
40
+ *
41
+ * The `summary` is short (≤180 char even for 10+ failing checks) and
42
+ * suitable for inline display. `action` is a hint, not enforcement —
43
+ * agents may still escalate based on other PR context.
44
+ */
45
+ export declare function categorizeCIStatus(input: {
46
+ ciStatus: CIStatus;
47
+ failingCheckNames: string[];
48
+ classifiedChecks: ClassifiedCheck[];
49
+ }): CIStatusCategorization;
19
50
  /**
20
51
  * Analyze check runs (GitHub Actions, etc.) and categorize them.
21
52
  * Returns flags for failing/pending/success and lists of failing check names + conclusions.
@@ -92,6 +92,98 @@ export function classifyFailingChecks(failingCheckNames, conclusions) {
92
92
  };
93
93
  });
94
94
  }
95
+ /**
96
+ * Map an aggregate `ciStatus + failingCheckNames + classifiedChecks` triple
97
+ * into one of five mutually exclusive overall states (#1272). The 5-row
98
+ * truth table previously lived as prose in `agents/pr-health-checker.md`;
99
+ * extracting it lets that agent (and any future consumer — dashboard,
100
+ * MCP, sibling agents that adopt the field) read a single typed value
101
+ * instead of re-deriving from `ciStatus + failingCheckNames + classifiedChecks`.
102
+ *
103
+ * Decision order (each branch is exclusive):
104
+ * 1. `passing` → `all_passing`
105
+ * 2. `pending` → `blocked` (awaiting trigger / completion)
106
+ * 3. `failing` + actionable → `failing` (real test/lint/build issue)
107
+ * 4. `failing` + only infrastructure → `blocked` (cancelled/timed-out runner — needs rerun)
108
+ * 5. `failing` + only fork/auth → `fork_limitation` (informational)
109
+ * 6. `failing` + zero classified → `failing` w/ "details unavailable" summary
110
+ * 7. `unknown` → `not_running`
111
+ *
112
+ * Why infrastructure routes to `blocked` and not `fork_limitation`:
113
+ * a cancelled or timed-out runner is genuinely worth re-running; calling
114
+ * it "informational" would tell the agent to ignore something the user
115
+ * can fix with a rerun-request.
116
+ *
117
+ * The `summary` is short (≤180 char even for 10+ failing checks) and
118
+ * suitable for inline display. `action` is a hint, not enforcement —
119
+ * agents may still escalate based on other PR context.
120
+ */
121
+ export function categorizeCIStatus(input) {
122
+ const { ciStatus, failingCheckNames, classifiedChecks } = input;
123
+ if (ciStatus === 'passing') {
124
+ return { category: 'all_passing', summary: 'All checks passing', action: 'none' };
125
+ }
126
+ if (ciStatus === 'pending') {
127
+ // `mergeStatuses` currently sets `failingCheckNames: []` on pending,
128
+ // so the count branch is defensive — kept so a future caller that
129
+ // forwards pending names doesn't silently drop them.
130
+ const count = failingCheckNames.length;
131
+ const summary = count > 0 ? `${count} pending check(s); CI run incomplete` : 'CI checks pending';
132
+ return { category: 'blocked', summary, action: 'request_rerun' };
133
+ }
134
+ if (ciStatus === 'failing') {
135
+ const actionable = classifiedChecks.filter((c) => c.category === 'actionable');
136
+ if (actionable.length > 0) {
137
+ const preview = actionable
138
+ .slice(0, 3)
139
+ .map((c) => c.name)
140
+ .join(', ');
141
+ const more = actionable.length > 3 ? ` (+${actionable.length - 3} more)` : '';
142
+ return {
143
+ category: 'failing',
144
+ summary: `${actionable.length} actionable failure(s): ${preview}${more}`,
145
+ action: 'investigate',
146
+ };
147
+ }
148
+ // No actionable failures. Distinguish three sub-cases:
149
+ // (a) failing with zero classified checks: status came from the
150
+ // legacy combined-status endpoint without per-check detail. Be
151
+ // honest about the missing detail rather than asserting "fork
152
+ // limitations" the caller can't verify.
153
+ if (classifiedChecks.length === 0) {
154
+ return {
155
+ category: 'failing',
156
+ summary: 'CI reported failure but no check details available',
157
+ action: 'investigate',
158
+ };
159
+ }
160
+ // (b) at least one infrastructure failure (cancelled / timed-out /
161
+ // dependency-install). Re-running often fixes the issue, so
162
+ // surface as `blocked` with `request_rerun` rather than
163
+ // mislabeling as "fork limits / auth gates."
164
+ const hasInfrastructure = classifiedChecks.some((c) => c.category === 'infrastructure');
165
+ if (hasInfrastructure) {
166
+ const total = classifiedChecks.length;
167
+ return {
168
+ category: 'blocked',
169
+ summary: `${total} non-actionable failure(s) including infrastructure issues; rerun may resolve`,
170
+ action: 'request_rerun',
171
+ };
172
+ }
173
+ // (c) only fork-limitation / auth-gate failures — purely informational.
174
+ const total = classifiedChecks.length;
175
+ return {
176
+ category: 'fork_limitation',
177
+ summary: `${total} non-actionable failure(s) (fork limits / auth gates)`,
178
+ action: 'informational',
179
+ };
180
+ }
181
+ // ciStatus === 'unknown' — no checks reported, or status couldn't be
182
+ // determined. Treat both as not_running so callers don't have to
183
+ // distinguish the rare indeterminate case from the common "no CI
184
+ // configured" case.
185
+ return { category: 'not_running', summary: 'No CI checks reported', action: 'check_workflows' };
186
+ }
95
187
  /**
96
188
  * Analyze check runs (GitHub Actions, etc.) and categorize them.
97
189
  * Returns flags for failing/pending/success and lists of failing check names + conclusions.
@@ -85,6 +85,25 @@ export declare function errorMessage(e: unknown): string;
85
85
  export declare function getHttpStatusCode(error: unknown): number | undefined;
86
86
  /** Check if an error is a GitHub rate limit error (429 or rate-limit 403). */
87
87
  export declare function isRateLimitError(error: unknown): boolean;
88
+ /**
89
+ * Check if an error is GitHub's "users do not exist" Search-API validation
90
+ * failure (HTTP 422 with `resource: 'Search', code: 'invalid'` and a message
91
+ * indicating the user couldn't be resolved). Returned when the Search API
92
+ * can't resolve the user named in an `author:`/`user:` qualifier — the
93
+ * typical cause is a stale or mis-typed `githubUsername` in
94
+ * `~/.oss-autopilot/state.json`.
95
+ *
96
+ * Surfaced as a generic "Validation Failed" string by Octokit, which gives
97
+ * the user no actionable signal. Callers wrap the search and rethrow this
98
+ * as a {@link ConfigurationError} so the CLI prints the configured username
99
+ * and points at `/setup-oss`.
100
+ *
101
+ * The message-text gate is load-bearing: GitHub returns the same
102
+ * `resource`/`code` pair for other Search 422s (query too long, too many
103
+ * ORs). Without the gate, those would silently rewrite to "your configured
104
+ * username is wrong," which is actively misleading.
105
+ */
106
+ export declare function isInvalidUserSearchError(err: unknown): boolean;
88
107
  /** Return true for errors that should propagate (not degrade gracefully): rate limits, auth failures, abuse detection. */
89
108
  export declare function isRateLimitOrAuthError(err: unknown): boolean;
90
109
  /**
@@ -142,6 +142,60 @@ export function isRateLimitError(error) {
142
142
  }
143
143
  return false;
144
144
  }
145
+ /**
146
+ * Match-text used to discriminate the user-resolution failure from sibling
147
+ * `resource: 'Search', code: 'invalid'` 422s (query-too-long,
148
+ * too-many-OR-operators, malformed qualifier). Both the structured and the
149
+ * fallback paths gate on this pattern so the matcher's name remains accurate
150
+ * if a future caller uses a different Search query.
151
+ */
152
+ const USER_NOT_FOUND_SEARCH_MESSAGE = /users.*do not exist|cannot be searched/i;
153
+ /**
154
+ * Check if an error is GitHub's "users do not exist" Search-API validation
155
+ * failure (HTTP 422 with `resource: 'Search', code: 'invalid'` and a message
156
+ * indicating the user couldn't be resolved). Returned when the Search API
157
+ * can't resolve the user named in an `author:`/`user:` qualifier — the
158
+ * typical cause is a stale or mis-typed `githubUsername` in
159
+ * `~/.oss-autopilot/state.json`.
160
+ *
161
+ * Surfaced as a generic "Validation Failed" string by Octokit, which gives
162
+ * the user no actionable signal. Callers wrap the search and rethrow this
163
+ * as a {@link ConfigurationError} so the CLI prints the configured username
164
+ * and points at `/setup-oss`.
165
+ *
166
+ * The message-text gate is load-bearing: GitHub returns the same
167
+ * `resource`/`code` pair for other Search 422s (query too long, too many
168
+ * ORs). Without the gate, those would silently rewrite to "your configured
169
+ * username is wrong," which is actively misleading.
170
+ */
171
+ export function isInvalidUserSearchError(err) {
172
+ if (getHttpStatusCode(err) !== 422)
173
+ return false;
174
+ const data = err?.response?.data;
175
+ const errors = data && typeof data === 'object' ? data.errors : undefined;
176
+ if (Array.isArray(errors)) {
177
+ return errors.some((e) => {
178
+ if (!e || typeof e !== 'object')
179
+ return false;
180
+ const entry = e;
181
+ if (entry.resource !== 'Search' || entry.code !== 'invalid')
182
+ return false;
183
+ // The Search API includes a per-error `message` for this case. When
184
+ // present, gate on it to avoid matching sibling validation failures
185
+ // that share the resource/code pair. When absent, fall back to the
186
+ // top-level message check below — some serializations drop the
187
+ // per-entry message but keep it on the response.
188
+ if (typeof entry.message === 'string') {
189
+ return USER_NOT_FOUND_SEARCH_MESSAGE.test(entry.message);
190
+ }
191
+ return USER_NOT_FOUND_SEARCH_MESSAGE.test(errorMessage(err));
192
+ });
193
+ }
194
+ // Fallback for serialized errors that lost the structured `response.data`
195
+ // (e.g. messages re-thrown across boundaries). The Search API's own copy
196
+ // is stable enough to match against.
197
+ return USER_NOT_FOUND_SEARCH_MESSAGE.test(errorMessage(err));
198
+ }
145
199
  /** Return true for errors that should propagate (not degrade gracefully): rate limits, auth failures, abuse detection. */
146
200
  export function isRateLimitOrAuthError(err) {
147
201
  const status = getHttpStatusCode(err);
@@ -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';
@@ -23,7 +23,35 @@ export interface LinkedPR {
23
23
  login: string;
24
24
  } | null;
25
25
  state: LinkedPRState;
26
+ /**
27
+ * ISO timestamp of the linked PR's last update, surfaced from scout's
28
+ * timeline-event metadata when available (#97). Optional so existing
29
+ * callers and fixtures that don't carry the field continue to type-check.
30
+ * Used by `isLinkedPRStalled` to flag open PRs that haven't been touched
31
+ * in the last `STALLED_PR_THRESHOLD_DAYS` days.
32
+ */
33
+ updatedAt?: string;
26
34
  }
35
+ /**
36
+ * Days of inactivity that classify an open linked PR as "stalled" — kept
37
+ * in sync with scout's `STALLED_PR_THRESHOLD_DAYS` so the autopilot helper
38
+ * and scout's own annotation use the same boundary.
39
+ */
40
+ export declare const STALLED_PR_THRESHOLD_DAYS = 30;
41
+ /**
42
+ * Determine whether an autopilot-shaped `LinkedPR` is stalled.
43
+ *
44
+ * True when:
45
+ * - the PR is open, AND
46
+ * - `updatedAt` is set, AND
47
+ * - the elapsed time since `updatedAt` is at least `thresholdDays`.
48
+ *
49
+ * Returns false for closed/merged PRs, missing `updatedAt`, or invalid
50
+ * timestamps. Reimplemented locally rather than delegating to scout's
51
+ * helper so this module owns autopilot's `LinkedPR` shape contract end
52
+ * to end (avoids a runtime import dependency on scout's internal type).
53
+ */
54
+ export declare function isLinkedPRStalled(linkedPR: LinkedPR | null | undefined, now?: Date, thresholdDays?: number): boolean;
27
55
  export declare function classifyLinkedPR(params: {
28
56
  linkedPR: LinkedPR | null;
29
57
  userLogin: string;
@@ -11,6 +11,38 @@
11
11
  * linked PR themselves (e.g. via Octokit) and hand the shape to this
12
12
  * function. See #978 for the upstream data-contract work.
13
13
  */
14
+ /**
15
+ * Days of inactivity that classify an open linked PR as "stalled" — kept
16
+ * in sync with scout's `STALLED_PR_THRESHOLD_DAYS` so the autopilot helper
17
+ * and scout's own annotation use the same boundary.
18
+ */
19
+ export const STALLED_PR_THRESHOLD_DAYS = 30;
20
+ /**
21
+ * Determine whether an autopilot-shaped `LinkedPR` is stalled.
22
+ *
23
+ * True when:
24
+ * - the PR is open, AND
25
+ * - `updatedAt` is set, AND
26
+ * - the elapsed time since `updatedAt` is at least `thresholdDays`.
27
+ *
28
+ * Returns false for closed/merged PRs, missing `updatedAt`, or invalid
29
+ * timestamps. Reimplemented locally rather than delegating to scout's
30
+ * helper so this module owns autopilot's `LinkedPR` shape contract end
31
+ * to end (avoids a runtime import dependency on scout's internal type).
32
+ */
33
+ export function isLinkedPRStalled(linkedPR, now = new Date(), thresholdDays = STALLED_PR_THRESHOLD_DAYS) {
34
+ if (!linkedPR)
35
+ return false;
36
+ if (linkedPR.state !== 'open')
37
+ return false;
38
+ if (!linkedPR.updatedAt)
39
+ return false;
40
+ const updatedMs = Date.parse(linkedPR.updatedAt);
41
+ if (!Number.isFinite(updatedMs))
42
+ return false;
43
+ const ageDays = (now.getTime() - updatedMs) / (1000 * 60 * 60 * 24);
44
+ return ageDays >= thresholdDays;
45
+ }
14
46
  /**
15
47
  * Normalize a state value from either REST (lowercase `open`/`closed`
16
48
  * plus a separate `merged` boolean) or GraphQL (uppercase `OPEN`/