@oss-autopilot/core 3.13.4 → 3.14.1

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 (88) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.js +59 -84
  3. package/dist/cli.bundle.cjs +112 -109
  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 +58 -8
  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 +181 -347
  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.d.ts +6 -6
  47. package/dist/commands/vet-list.js +194 -65
  48. package/dist/core/config-registry.js +36 -0
  49. package/dist/core/daily-logic.d.ts +25 -2
  50. package/dist/core/daily-logic.js +58 -3
  51. package/dist/core/gist-health.d.ts +81 -0
  52. package/dist/core/gist-health.js +39 -0
  53. package/dist/core/gist-state-store.d.ts +3 -1
  54. package/dist/core/gist-state-store.js +7 -2
  55. package/dist/core/github-stats.d.ts +2 -2
  56. package/dist/core/github-stats.js +20 -4
  57. package/dist/core/index.d.ts +5 -4
  58. package/dist/core/index.js +5 -4
  59. package/dist/core/issue-conversation.js +8 -2
  60. package/dist/core/issue-grading.d.ts +9 -0
  61. package/dist/core/issue-grading.js +9 -0
  62. package/dist/core/issue-verification.d.ts +39 -0
  63. package/dist/core/issue-verification.js +48 -0
  64. package/dist/core/pagination.d.ts +27 -0
  65. package/dist/core/pagination.js +23 -5
  66. package/dist/core/pr-comments-fetcher.d.ts +7 -0
  67. package/dist/core/pr-comments-fetcher.js +19 -8
  68. package/dist/core/pr-monitor.d.ts +2 -0
  69. package/dist/core/pr-monitor.js +26 -9
  70. package/dist/core/repo-score-manager.d.ts +2 -2
  71. package/dist/core/repo-score-manager.js +3 -3
  72. package/dist/core/repo-vet.d.ts +2 -2
  73. package/dist/core/repo-vet.js +1 -1
  74. package/dist/core/review-analysis.d.ts +19 -0
  75. package/dist/core/review-analysis.js +28 -0
  76. package/dist/core/state-schema.d.ts +43 -6
  77. package/dist/core/state-schema.js +81 -4
  78. package/dist/core/state.d.ts +36 -5
  79. package/dist/core/state.js +177 -28
  80. package/dist/core/strategy.js +6 -5
  81. package/dist/core/types.d.ts +8 -0
  82. package/dist/core/untrusted-content.d.ts +45 -0
  83. package/dist/core/untrusted-content.js +54 -0
  84. package/dist/formatters/json.d.ts +120 -12
  85. package/dist/formatters/json.js +55 -2
  86. package/package.json +2 -2
  87. package/dist/commands/shelve.d.ts +0 -45
  88. package/dist/commands/shelve.js +0 -54
@@ -7,7 +7,8 @@ import { adaptScoutLinkedPR, buildCandidateLinkedPR, createAutopilotScout } from
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';
10
- import { getStateManager, classifyLinkedPR } from '../core/index.js';
10
+ import { ValidationError } from '../core/errors.js';
11
+ import { getStateManager, classifyLinkedPR, getOctokit, requireGitHubToken, parseGitHubUrl, verifyIssuesBatch, } from '../core/index.js';
11
12
  const UNKNOWN_GRADE = computeSuccessGrade({ avgResponseDays: null, mergeRate: null, daysSinceLastCommit: null });
12
13
  const KNOWN_SKIP_REASONS = new Set([
13
14
  'issue_closed',
@@ -93,13 +94,64 @@ export function classifyListStatus(vetResult, skipReason) {
93
94
  return 'still_available';
94
95
  }
95
96
  /**
96
- * Re-vet all available issues in a curated issue list.
97
- * Reads the list file, extracts available (non-done) issues,
98
- * and vets each in parallel with concurrency control.
99
- *
100
- * @param options - Vet-list options
101
- * @returns Consolidated vetting results with list status for each issue
97
+ * Map a deterministic verify-issue verdict onto the vet-list status taxonomy
98
+ * (#1494). This is the authoritative path: the GraphQL verdict knows the
99
+ * closing-vs-mention distinction (#1353) that scout's substring heuristic
100
+ * cannot, so `at_risk` (mention only) no longer collapses into `claimed`.
101
+ */
102
+ export function listStatusFromVerdict(verdict) {
103
+ switch (verdict) {
104
+ case 'closed': {
105
+ return 'closed';
106
+ }
107
+ case 'own-open-pr': {
108
+ return 'own_open_pr';
109
+ }
110
+ case 'taken': {
111
+ return 'claimed';
112
+ }
113
+ case 'at-risk': {
114
+ return 'at_risk';
115
+ }
116
+ case 'available': {
117
+ return 'still_available';
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * Verdicts that conclusively rule an issue out as a new opportunity. For these
123
+ * we skip scout's grade/health vet entirely — there is nothing to grade — and
124
+ * build the result row straight from the verification.
102
125
  */
126
+ const RULED_OUT_VERDICTS = new Set(['closed', 'taken', 'own-open-pr']);
127
+ /** The per-row `verification` sub-object: the authoritative facts behind the
128
+ * status, projected from the full {@link IssueVerification}. */
129
+ function toVerificationSummary(v) {
130
+ return {
131
+ verdict: v.verdict,
132
+ verdictReason: v.verdictReason,
133
+ state: v.state,
134
+ stateReason: v.stateReason,
135
+ assignees: v.assignees,
136
+ linkedPRs: v.linkedPRs,
137
+ };
138
+ }
139
+ /** Minimal VetOutput for a ruled-out issue (no scout vet was run). The
140
+ * verification carries the real reason; scout-derived fields are left empty. */
141
+ function ruledOutVetOutput(v) {
142
+ return {
143
+ issue: { repo: `${v.owner}/${v.repo}`, number: v.number, title: v.title, url: v.url, labels: [] },
144
+ recommendation: 'skip',
145
+ reasonsToApprove: [],
146
+ reasonsToSkip: [v.verdictReason],
147
+ projectHealth: {},
148
+ vettingResult: {},
149
+ grade: UNKNOWN_GRADE,
150
+ };
151
+ }
152
+ function toMessage(error) {
153
+ return error instanceof Error ? error.message : String(error);
154
+ }
103
155
  export async function runVetList(options = {}) {
104
156
  const concurrency = options.concurrency ?? 5;
105
157
  // 1. Find and parse the issue list
@@ -115,75 +167,146 @@ export async function runVetList(options = {}) {
115
167
  if (parsed.available.length === 0) {
116
168
  return {
117
169
  results: [],
118
- summary: { total: 0, stillAvailable: 0, claimed: 0, closed: 0, hasPR: 0, hasStalledPR: 0, errors: 0 },
170
+ summary: {
171
+ total: 0,
172
+ stillAvailable: 0,
173
+ claimed: 0,
174
+ closed: 0,
175
+ hasPR: 0,
176
+ hasStalledPR: 0,
177
+ atRisk: 0,
178
+ ownOpenPr: 0,
179
+ errors: 0,
180
+ },
119
181
  };
120
182
  }
121
- // 2. Vet each available issue in parallel with concurrency limit
122
- const scout = await createAutopilotScout();
123
- const results = [];
124
- // Simple concurrency limiter
125
183
  const items = parsed.available;
126
- let index = 0;
127
- async function processNext() {
128
- while (index < items.length) {
129
- const item = items[index++];
184
+ // 2. Verify FIRST (#1494): one deterministic GraphQL check per entry, in a
185
+ // bounded batch. The verdict is closing-vs-mention aware, so it carries
186
+ // the authoritative status and ruling issues out here means we skip
187
+ // scout's search-driven vet (a net REDUCTION in /search/issues calls).
188
+ const octokit = getOctokit(requireGitHubToken());
189
+ const verifyTargets = [];
190
+ items.forEach((item, i) => {
191
+ const ghUrl = parseGitHubUrl(item.url);
192
+ if (ghUrl && ghUrl.type === 'issues') {
193
+ verifyTargets.push({ index: i, params: { owner: ghUrl.owner, repo: ghUrl.repo, number: ghUrl.number } });
194
+ }
195
+ });
196
+ const batch = await verifyIssuesBatch(octokit, verifyTargets.map((t) => t.params), { concurrency });
197
+ const verificationByIndex = new Map();
198
+ verifyTargets.forEach((t, k) => verificationByIndex.set(t.index, batch[k]));
199
+ // 3. Vet only the issues still in play; build each row.
200
+ const scout = await createAutopilotScout();
201
+ /** Run scout's grade/health vet for an issue (the search-driven path). */
202
+ async function runScoutVet(item) {
203
+ const candidate = await scout.vetIssue(item.url);
204
+ const grade = gradeFromCandidate({
205
+ repo: candidate.issue.repo,
206
+ projectHealth: candidate.projectHealth,
207
+ getRepoScore: (repo) => getStateManager().getRepoScore(repo),
208
+ });
209
+ const userLogin = getStateManager().getState().config.githubUsername;
210
+ const linkedPRClassification = classifyLinkedPR({
211
+ linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
212
+ userLogin,
213
+ });
214
+ const linkedPR = buildCandidateLinkedPR(candidate.vettingResult.linkedPR);
215
+ const vetResult = {
216
+ issue: {
217
+ repo: candidate.issue.repo,
218
+ number: candidate.issue.number,
219
+ title: candidate.issue.title,
220
+ url: candidate.issue.url,
221
+ labels: candidate.issue.labels,
222
+ },
223
+ recommendation: candidate.recommendation,
224
+ reasonsToApprove: candidate.reasonsToApprove,
225
+ reasonsToSkip: candidate.reasonsToSkip,
226
+ projectHealth: candidate.projectHealth,
227
+ vettingResult: candidate.vettingResult,
228
+ antiLLMPolicy: candidate.antiLLMPolicy,
229
+ linkedPRClassification,
230
+ ...(linkedPR ? { linkedPR } : {}),
231
+ slmTriage: candidate.slmTriage ?? null,
232
+ grade,
233
+ };
234
+ return { vetResult, skipReason: extractSkipReason(candidate) };
235
+ }
236
+ function errorRow(item, error) {
237
+ const message = toMessage(error);
238
+ return {
239
+ issue: { repo: item.repo, number: item.number, title: item.title, url: item.url, labels: [] },
240
+ recommendation: 'skip',
241
+ reasonsToApprove: [],
242
+ reasonsToSkip: [`Error: ${message}`],
243
+ projectHealth: {},
244
+ vettingResult: {},
245
+ grade: UNKNOWN_GRADE,
246
+ listStatus: 'error',
247
+ errorMessage: message,
248
+ };
249
+ }
250
+ async function processItem(item, verification) {
251
+ // No authoritative verdict for this row: either verify errored, or the URL
252
+ // was never enrolled (parse-list also accepts PR URLs, which `verify-issue`
253
+ // can't classify). Either way `listStatus` falls back to scout's heuristic,
254
+ // so we ALWAYS tag the row with `verifyError` — the explicit signal that the
255
+ // status is not authoritative and must be re-verified before acting.
256
+ if (!verification || verification.verification === undefined) {
257
+ const verifyError = verification?.error;
258
+ // A ValidationError means the issue definitively doesn't exist (deleted,
259
+ // private, or the URL points at a PR). Don't re-grade it via scout — that
260
+ // could mislabel a dead issue as available. Report it as an error row.
261
+ if (verifyError instanceof ValidationError) {
262
+ return { ...errorRow(item, verifyError), verifyError: toMessage(verifyError) };
263
+ }
264
+ // Transient verify failure (rate limit, network, truncated timeline), or a
265
+ // non-issue URL that was never sent to verify. Fall back to scout's
266
+ // heuristic, tagged so the row is re-verified later.
267
+ const verifyErrorTag = {
268
+ verifyError: verifyError === undefined
269
+ ? `not verified: ${item.url} is not a verifiable issue URL`
270
+ : toMessage(verifyError),
271
+ };
130
272
  try {
131
- const candidate = await scout.vetIssue(item.url);
132
- const grade = gradeFromCandidate({
133
- repo: candidate.issue.repo,
134
- projectHealth: candidate.projectHealth,
135
- getRepoScore: (repo) => getStateManager().getRepoScore(repo),
136
- });
137
- const userLogin = getStateManager().getState().config.githubUsername;
138
- const linkedPRClassification = classifyLinkedPR({
139
- linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
140
- userLogin,
141
- });
142
- const linkedPR = buildCandidateLinkedPR(candidate.vettingResult.linkedPR);
143
- const vetResult = {
144
- issue: {
145
- repo: candidate.issue.repo,
146
- number: candidate.issue.number,
147
- title: candidate.issue.title,
148
- url: candidate.issue.url,
149
- labels: candidate.issue.labels,
150
- },
151
- recommendation: candidate.recommendation,
152
- reasonsToApprove: candidate.reasonsToApprove,
153
- reasonsToSkip: candidate.reasonsToSkip,
154
- projectHealth: candidate.projectHealth,
155
- vettingResult: candidate.vettingResult,
156
- antiLLMPolicy: candidate.antiLLMPolicy,
157
- linkedPRClassification,
158
- ...(linkedPR ? { linkedPR } : {}),
159
- slmTriage: candidate.slmTriage ?? null,
160
- grade,
161
- };
162
- results.push({
163
- ...vetResult,
164
- listStatus: classifyListStatus(vetResult, extractSkipReason(candidate)),
165
- });
273
+ const { vetResult, skipReason } = await runScoutVet(item);
274
+ return { ...vetResult, listStatus: classifyListStatus(vetResult, skipReason), ...verifyErrorTag };
166
275
  }
167
276
  catch (error) {
168
- // Per-issue errors don't fail the batch
169
- results.push({
170
- issue: { repo: item.repo, number: item.number, title: item.title, url: item.url, labels: [] },
171
- recommendation: 'skip',
172
- reasonsToApprove: [],
173
- reasonsToSkip: [`Error: ${error instanceof Error ? error.message : String(error)}`],
174
- projectHealth: {},
175
- vettingResult: {},
176
- grade: UNKNOWN_GRADE,
177
- listStatus: 'error',
178
- errorMessage: error instanceof Error ? error.message : String(error),
179
- });
277
+ return { ...errorRow(item, error), ...verifyErrorTag };
180
278
  }
181
279
  }
280
+ const v = verification.verification;
281
+ const summary = toVerificationSummary(v);
282
+ const listStatus = listStatusFromVerdict(v.verdict);
283
+ // Conclusively ruled out — skip the scout vet entirely.
284
+ if (RULED_OUT_VERDICTS.has(v.verdict)) {
285
+ return { ...ruledOutVetOutput(v), listStatus, verification: summary };
286
+ }
287
+ // available / at-risk: still worth grading. The verdict stays authoritative
288
+ // for listStatus; scout only supplies grade/health/recommendation.
289
+ try {
290
+ const { vetResult } = await runScoutVet(item);
291
+ return { ...vetResult, listStatus, verification: summary };
292
+ }
293
+ catch (error) {
294
+ // Verify succeeded, so keep its verdict facts even though grading failed.
295
+ return { ...errorRow(item, error), verification: summary };
296
+ }
297
+ }
298
+ const results = new Array(items.length);
299
+ let index = 0;
300
+ async function processNext() {
301
+ while (index < items.length) {
302
+ const i = index++;
303
+ results[i] = await processItem(items[i], verificationByIndex.get(i));
304
+ }
182
305
  }
183
- // Start `concurrency` workers
306
+ // Start `concurrency` workers (bounds the scout-vet fan-out).
184
307
  const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => processNext());
185
308
  await Promise.all(workers);
186
- // 3. Compute summary
309
+ // 4. Compute summary
187
310
  const summary = {
188
311
  total: results.length,
189
312
  stillAvailable: results.filter((r) => r.listStatus === 'still_available').length,
@@ -191,6 +314,8 @@ export async function runVetList(options = {}) {
191
314
  closed: results.filter((r) => r.listStatus === 'closed').length,
192
315
  hasPR: results.filter((r) => r.listStatus === 'has_pr').length,
193
316
  hasStalledPR: results.filter((r) => r.listStatus === 'has_stalled_pr').length,
317
+ atRisk: results.filter((r) => r.listStatus === 'at_risk').length,
318
+ ownOpenPr: results.filter((r) => r.listStatus === 'own_open_pr').length,
194
319
  errors: results.filter((r) => r.listStatus === 'error').length,
195
320
  };
196
321
  // 4. Prune the file if requested — remove completed/skipped/low-score items
@@ -207,6 +332,10 @@ export async function runVetList(options = {}) {
207
332
  catch (error) {
208
333
  const msg = error instanceof Error ? error.message : String(error);
209
334
  console.error(`Warning: Failed to prune ${issueListPath}: ${msg}`);
335
+ // Surface the failure in the envelope (#1448): without this, a failed
336
+ // prune returned a plain success with pruneResult simply absent — the
337
+ // same shape as "prune not requested".
338
+ pruneResult = { removedCount: 0, error: msg };
210
339
  }
211
340
  }
212
341
  return { results, summary, pruneResult };
@@ -104,6 +104,18 @@ export const CONFIG_KEY_REGISTRY = [
104
104
  settableVia: 'setup',
105
105
  valueHint: 'comma-separated list of owner/repo',
106
106
  },
107
+ {
108
+ key: 'avoidRepos',
109
+ description: 'Repos (owner/repo) to softly downrank in discovery results — milder than excludeRepos (whole-list replace).',
110
+ settableVia: 'setup',
111
+ valueHint: 'comma-separated list of owner/repo',
112
+ },
113
+ {
114
+ key: 'boostIssueTypes',
115
+ description: 'Issue label types to softly boost in discovery ranking (whole-list replace).',
116
+ settableVia: 'setup',
117
+ valueHint: 'comma-separated list (e.g. bug,good first issue)',
118
+ },
107
119
  // ── Exclusion list mutators (config-only) ────────────────────────────
108
120
  {
109
121
  key: 'add-language',
@@ -147,6 +159,30 @@ export const CONFIG_KEY_REGISTRY = [
147
159
  settableVia: 'config',
148
160
  valueHint: 'org name (no slash)',
149
161
  },
162
+ {
163
+ key: 'add-avoid-repo',
164
+ description: 'Append a repo (owner/repo) to the soft-downrank avoid list.',
165
+ settableVia: 'config',
166
+ valueHint: 'owner/repo',
167
+ },
168
+ {
169
+ key: 'remove-avoid-repo',
170
+ description: 'Remove a repo from the soft-downrank avoid list.',
171
+ settableVia: 'config',
172
+ valueHint: 'owner/repo (must already be present)',
173
+ },
174
+ {
175
+ key: 'add-boost-issue-type',
176
+ description: 'Append an issue label type to the discovery ranking boost list.',
177
+ settableVia: 'config',
178
+ valueHint: 'issue label (e.g. bug)',
179
+ },
180
+ {
181
+ key: 'remove-boost-issue-type',
182
+ description: 'Remove an issue label type from the discovery ranking boost list.',
183
+ settableVia: 'config',
184
+ valueHint: 'issue label (must already be present)',
185
+ },
150
186
  // ── Tooling ──────────────────────────────────────────────────────────
151
187
  {
152
188
  key: 'issueListPath',
@@ -14,6 +14,7 @@
14
14
  * re-exports them at the bottom so existing imports keep working without
15
15
  * a sweep.
16
16
  */
17
+ import type { AttentionSummary } from './pr-attention.js';
17
18
  import type { FetchedPR, FetchedPRStatus, StalenessTier, ActionReason, AgentState, ShelvedPRRef, ComputedRepoSignals, RepoGroup, CommentedIssue, CapacityAssessment, ActionableIssue, ActionMenu, StarFilter } from './types.js';
18
19
  /**
19
20
  * Statuses indicating action needed from the contributor.
@@ -38,9 +39,13 @@ export declare const STALE_STATUSES: ReadonlySet<StalenessTier>;
38
39
  *
39
40
  * @param prs - The fetched PR list to apply overrides to
40
41
  * @param state - Current agent state containing status overrides
42
+ * @param failures - Optional collector (#1448): a message is pushed for each
43
+ * PR whose override lookup threw (that PR keeps its un-overridden status).
44
+ * Call sites surface these in warnings[]/partialFailures so the silently
45
+ * wrong status is visible in the envelope, not just stderr.
41
46
  * @returns New PR array with overrides applied (original array is not mutated)
42
47
  */
43
- export declare function applyStatusOverrides(prs: FetchedPR[], state: Readonly<AgentState>): FetchedPR[];
48
+ export declare function applyStatusOverrides(prs: FetchedPR[], state: Readonly<AgentState>, failures?: string[]): FetchedPR[];
44
49
  /**
45
50
  * Project a PR to a lightweight ShelvedPRRef for digest output.
46
51
  * Only the fields needed for display are retained, reducing JSON payload size.
@@ -61,6 +66,22 @@ export declare function toShelvedPRRef(pr: ShelvedPRRef): ShelvedPRRef;
61
66
  * @returns Star filter with minimum threshold and known counts, or undefined on first run
62
67
  */
63
68
  export declare function buildStarFilter(state: Readonly<AgentState>): StarFilter | undefined;
69
+ /**
70
+ * Look up a just-merged/closed PR's `firstMaintainerResponseAt` from the
71
+ * previous run's persisted digest (#1461). At detection time the PR is no
72
+ * longer open, so it cannot be enriched without new API calls — but if it
73
+ * was open during a prior enriched run, the persisted digest's openPRs carry
74
+ * the timestamp. Best-effort: returns undefined when the PR never appeared
75
+ * in a persisted digest (e.g. opened and merged between runs, or the digest
76
+ * predates the field).
77
+ *
78
+ * Lives in core/daily-logic.ts so both ledger writers (daily Phase 3 and
79
+ * dashboard-data) share one lookup. Callers must read `state.lastDigest`
80
+ * BEFORE the current run overwrites it via setLastDigest.
81
+ */
82
+ export declare function firstMaintainerResponseFromDigest(digest: {
83
+ openPRs: FetchedPR[];
84
+ } | undefined, url: string): string | undefined;
64
85
  /**
65
86
  * Group PRs by repository (#80).
66
87
  * Ensures one agent per repo during parallel dispatch, preventing branch checkout conflicts.
@@ -108,7 +129,9 @@ export declare function collectActionableIssues(prs: FetchedPR[], lastDigestAt?:
108
129
  * @param actionableIssues - Issues requiring attention
109
130
  * @param capacity - Current capacity assessment
110
131
  * @param commentedIssues - Issues with comment activity
132
+ * @param attention - Attention bucket counts; non-zero stuck-CI / dormant-followup buckets add a follow_up item
133
+ * @param unextractedMergeCount - Recently merged PRs whose learnings have not been extracted yet (#1463); non-zero adds an extract_learnings item
111
134
  * @returns Action menu with context flags for orchestration
112
135
  */
113
- export declare function computeActionMenu(actionableIssues: ActionableIssue[], capacity: CapacityAssessment, commentedIssues?: CommentedIssue[]): ActionMenu;
136
+ export declare function computeActionMenu(actionableIssues: ActionableIssue[], capacity: CapacityAssessment, commentedIssues?: CommentedIssue[], attention?: Pick<AttentionSummary, 'stuckCI' | 'dormantFollowup'>, unextractedMergeCount?: number): ActionMenu;
114
137
  export { formatActionHint, formatBriefSummary, formatSummary, printDigest } from '../commands/daily-render.js';
@@ -52,9 +52,13 @@ const VALID_OVERRIDE_STATUSES = new Set(['needs_addressing', 'waiting_on_maintai
52
52
  *
53
53
  * @param prs - The fetched PR list to apply overrides to
54
54
  * @param state - Current agent state containing status overrides
55
+ * @param failures - Optional collector (#1448): a message is pushed for each
56
+ * PR whose override lookup threw (that PR keeps its un-overridden status).
57
+ * Call sites surface these in warnings[]/partialFailures so the silently
58
+ * wrong status is visible in the envelope, not just stderr.
55
59
  * @returns New PR array with overrides applied (original array is not mutated)
56
60
  */
57
- export function applyStatusOverrides(prs, state) {
61
+ export function applyStatusOverrides(prs, state, failures) {
58
62
  const overrides = state.config.statusOverrides;
59
63
  if (!overrides || Object.keys(overrides).length === 0)
60
64
  return prs;
@@ -82,7 +86,9 @@ export function applyStatusOverrides(prs, state) {
82
86
  return { ...pr, status: override.status, waitReason: undefined, actionReason: 'needs_response' };
83
87
  }
84
88
  catch (err) {
85
- warn('daily-logic', `Failed to apply status override for ${pr.url}: ${errorMessage(err)}`);
89
+ const message = `Failed to apply status override for ${pr.url}: ${errorMessage(err)}`;
90
+ warn('daily-logic', message);
91
+ failures?.push(message);
86
92
  return pr;
87
93
  }
88
94
  });
@@ -152,6 +158,22 @@ export function buildStarFilter(state) {
152
158
  return undefined;
153
159
  return { minStars, knownStarCounts };
154
160
  }
161
+ /**
162
+ * Look up a just-merged/closed PR's `firstMaintainerResponseAt` from the
163
+ * previous run's persisted digest (#1461). At detection time the PR is no
164
+ * longer open, so it cannot be enriched without new API calls — but if it
165
+ * was open during a prior enriched run, the persisted digest's openPRs carry
166
+ * the timestamp. Best-effort: returns undefined when the PR never appeared
167
+ * in a persisted digest (e.g. opened and merged between runs, or the digest
168
+ * predates the field).
169
+ *
170
+ * Lives in core/daily-logic.ts so both ledger writers (daily Phase 3 and
171
+ * dashboard-data) share one lookup. Callers must read `state.lastDigest`
172
+ * BEFORE the current run overwrites it via setLastDigest.
173
+ */
174
+ export function firstMaintainerResponseFromDigest(digest, url) {
175
+ return digest?.openPRs.find((pr) => pr.url === url)?.firstMaintainerResponseAt;
176
+ }
155
177
  /**
156
178
  * Group PRs by repository (#80).
157
179
  * Ensures one agent per repo during parallel dispatch, preventing branch checkout conflicts.
@@ -328,9 +350,11 @@ export function collectActionableIssues(prs, lastDigestAt) {
328
350
  * @param actionableIssues - Issues requiring attention
329
351
  * @param capacity - Current capacity assessment
330
352
  * @param commentedIssues - Issues with comment activity
353
+ * @param attention - Attention bucket counts; non-zero stuck-CI / dormant-followup buckets add a follow_up item
354
+ * @param unextractedMergeCount - Recently merged PRs whose learnings have not been extracted yet (#1463); non-zero adds an extract_learnings item
331
355
  * @returns Action menu with context flags for orchestration
332
356
  */
333
- export function computeActionMenu(actionableIssues, capacity, commentedIssues = []) {
357
+ export function computeActionMenu(actionableIssues, capacity, commentedIssues = [], attention, unextractedMergeCount) {
334
358
  const issueResponses = commentedIssues.filter((i) => i.status === 'new_response');
335
359
  const items = [];
336
360
  const hasActionableIssues = actionableIssues.length > 0;
@@ -355,6 +379,37 @@ export function computeActionMenu(actionableIssues, capacity, commentedIssues =
355
379
  description: 'Maintainers responded to your comments on issues',
356
380
  });
357
381
  }
382
+ // Follow-up nudges (#1462) — the stuck_ci / dormant_followup buckets from
383
+ // summarizeAttentionBuckets get a menu path to workflows/dormant-pr-follow-up.md
384
+ // instead of only a headline count. Emitted only when a bucket is non-zero,
385
+ // so menus without stuck/dormant PRs are unchanged.
386
+ const stuckCI = attention?.stuckCI ?? 0;
387
+ const dormantFollowup = attention?.dormantFollowup ?? 0;
388
+ if (stuckCI > 0 || dormantFollowup > 0) {
389
+ const parts = [];
390
+ if (stuckCI > 0)
391
+ parts.push(`${stuckCI} stuck-CI`);
392
+ if (dormantFollowup > 0)
393
+ parts.push(`${dormantFollowup} dormant`);
394
+ const total = stuckCI + dormantFollowup;
395
+ items.push({
396
+ key: 'follow_up',
397
+ label: `Follow up on ${parts.join(' and ')} PR${total === 1 ? '' : 's'}`,
398
+ description: 'Waiting PRs that may need a nudge (pending CI or no review activity)',
399
+ });
400
+ }
401
+ // Extract-learnings nudge (#1463) — recently merged PRs whose ledger
402
+ // entries carry no learningsExtractedAt stamp get a menu path to
403
+ // workflows/extract-learnings.md. The nudge persists across runs for the
404
+ // recently-merged window until the extraction runs, then self-clears.
405
+ const unextracted = unextractedMergeCount ?? 0;
406
+ if (unextracted > 0) {
407
+ items.push({
408
+ key: 'extract_learnings',
409
+ label: `Extract learnings from ${unextracted} recently merged PR${unextracted === 1 ? '' : 's'}`,
410
+ description: 'Distill maintainer review feedback into per-repo contribution guidelines',
411
+ });
412
+ }
358
413
  // The orchestration layer (commands/oss.md Action Menu section) may insert issue-list
359
414
  // options before the search item when a curated list is available.
360
415
  const searchItem = {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * One health source and one warning renderer for gist persistence (#1444).
3
+ *
4
+ * "Are we degraded?" used to be answered by three independent predicates
5
+ * (`config.persistence === 'gist' && !isGistMode()`, the
6
+ * `GistPersistenceStatus` union, `isGistMode() && isGistDegraded()`), each
7
+ * re-rendering its own warning prose. `StateManager.getGistHealth()` is now
8
+ * the single predicate for live-manager surfaces (daily warnings, dashboard
9
+ * probes), and {@link renderGistWarning} is the single prose renderer for
10
+ * every surface (CLI envelope, MCP injection, bootstrapGistBestEffort,
11
+ * daily warnings).
12
+ */
13
+ /**
14
+ * Degradation causes a live StateManager can attest to from its own fields
15
+ * (see `StateManager.getGistHealth()`):
16
+ *
17
+ * - `configured-but-local`: config says `persistence: 'gist'` but this
18
+ * process's manager has no gist store — init never ran here, or fell back
19
+ * to a local-only manager. Mutations save locally and will not sync.
20
+ * - `bootstrap-degraded`: the manager IS gist-backed, but the bootstrap fell
21
+ * back to the local cache and the store is disarmed (#1443) — reads may be
22
+ * stale and pushes fail.
23
+ */
24
+ export type GistHealthDegradedCause = 'configured-but-local' | 'bootstrap-degraded';
25
+ /**
26
+ * Causes {@link renderGistWarning} can name. Superset of
27
+ * {@link GistHealthDegradedCause}: the process-level bootstrap outcomes are
28
+ * observed by config-peek paths (`ensureGistPersistence`, the MCP init memo)
29
+ * that run before or around manager creation, where there is no
30
+ * StateManager to ask:
31
+ *
32
+ * - `no-token`: gist is configured but no GitHub token was available.
33
+ * - `state-unreadable`: the state file could not be read for this attempt.
34
+ * - `init-fallback`: init resolved degraded — `ensureGistPersistence`
35
+ * reported `'degraded'` (transient network fallback, or a #1443 degraded
36
+ * bootstrap, which it deliberately conflates at that layer).
37
+ */
38
+ export type GistWarningCause = GistHealthDegradedCause | 'no-token' | 'state-unreadable' | 'init-fallback';
39
+ /**
40
+ * Snapshot of gist persistence health from the one source of truth
41
+ * (`StateManager.getGistHealth()`), shaped per #1444.
42
+ */
43
+ export interface GistHealth {
44
+ /** `'gist'` when the manager is gist-backed (a GistStateStore is attached),
45
+ * `'local'` otherwise — regardless of what the config asks for. */
46
+ mode: 'local' | 'gist';
47
+ /** `null` when healthy: either genuinely local (config agrees) or
48
+ * gist-backed with an armed store. Non-null when this process is not
49
+ * reliably syncing to the Gist. */
50
+ degraded: null | {
51
+ cause: GistHealthDegradedCause;
52
+ /** ISO timestamp when the degradation was first observed, when known
53
+ * (a degraded bootstrap seeds the staleness marker; a
54
+ * configured-but-local manager has no marker to date it by). */
55
+ since?: string;
56
+ /** Whether a later init/recovery attempt can heal this without user
57
+ * action. Both manager-level causes are recoverable by re-running
58
+ * `ensureGistPersistence` with a token (#1415/#1443); a PERMANENT halt
59
+ * (e.g. token lacking the gist scope) surfaces as a thrown
60
+ * ConfigurationError at the surface that attempted recovery — the
61
+ * manager itself cannot observe it, so it never reports false here. */
62
+ recoverable: boolean;
63
+ };
64
+ }
65
+ /**
66
+ * Render the one gist-degradation warning shown by every surface (#1444).
67
+ *
68
+ * Accepts either a known {@link GistWarningCause} or `{ reason }` for the
69
+ * hard-error paths (ConfigurationError repair carve-outs) whose reason text
70
+ * is built from the underlying error.
71
+ *
72
+ * @param cause - Known cause, or a free-form reason for hard init errors.
73
+ * @param opts.retryHint - Optional trailing sentence describing the
74
+ * surface-specific retry behavior (e.g. the MCP's "Will retry on the next
75
+ * tool call.").
76
+ */
77
+ export declare function renderGistWarning(cause: GistWarningCause | {
78
+ reason: string;
79
+ }, opts?: {
80
+ retryHint?: string;
81
+ }): string;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * One health source and one warning renderer for gist persistence (#1444).
3
+ *
4
+ * "Are we degraded?" used to be answered by three independent predicates
5
+ * (`config.persistence === 'gist' && !isGistMode()`, the
6
+ * `GistPersistenceStatus` union, `isGistMode() && isGistDegraded()`), each
7
+ * re-rendering its own warning prose. `StateManager.getGistHealth()` is now
8
+ * the single predicate for live-manager surfaces (daily warnings, dashboard
9
+ * probes), and {@link renderGistWarning} is the single prose renderer for
10
+ * every surface (CLI envelope, MCP injection, bootstrapGistBestEffort,
11
+ * daily warnings).
12
+ */
13
+ /** Cause → human reason, the MCP's former `gistWarningText` mapping promoted
14
+ * to core (#1444). Keep these one-line and append-only: surfaces interpolate
15
+ * them mid-sentence. */
16
+ const GIST_DEGRADED_REASON = {
17
+ 'no-token': 'no GitHub token was available',
18
+ 'state-unreadable': 'the state file could not be read',
19
+ 'init-fallback': 'initialization hit a transient network failure',
20
+ 'configured-but-local': 'this process is running local-only (Gist init has not succeeded)',
21
+ 'bootstrap-degraded': 'the Gist bootstrap fell back to the local cache (reads may be stale and pushes are failing)',
22
+ };
23
+ /**
24
+ * Render the one gist-degradation warning shown by every surface (#1444).
25
+ *
26
+ * Accepts either a known {@link GistWarningCause} or `{ reason }` for the
27
+ * hard-error paths (ConfigurationError repair carve-outs) whose reason text
28
+ * is built from the underlying error.
29
+ *
30
+ * @param cause - Known cause, or a free-form reason for hard init errors.
31
+ * @param opts.retryHint - Optional trailing sentence describing the
32
+ * surface-specific retry behavior (e.g. the MCP's "Will retry on the next
33
+ * tool call.").
34
+ */
35
+ export function renderGistWarning(cause, opts = {}) {
36
+ const reason = typeof cause === 'string' ? GIST_DEGRADED_REASON[cause] : cause.reason;
37
+ return (`Gist persistence is configured but ${reason}; writes are LOCAL-ONLY and may be ` +
38
+ `overwritten by the next successful Gist sync.${opts.retryHint ? ` ${opts.retryHint}` : ''}`);
39
+ }
@@ -232,7 +232,9 @@ export declare class GistStateStore {
232
232
  push(): Promise<boolean>;
233
233
  /**
234
234
  * Re-fetch the Gist and update the in-memory cache.
235
- * Throttled to at most once per 30 seconds.
235
+ * Throttled to at most once per 30 seconds — ATTEMPTS, not just successes:
236
+ * a failed fetch stamps the throttle too (#1443), so an outage does not
237
+ * turn every SPA poll into an immediate full re-fetch.
236
238
  *
237
239
  * Returns a discriminated union so callers can tell apart the four
238
240
  * outcomes that previously collapsed into a single boolean (#1209 L9):