@oss-autopilot/core 3.10.0 → 3.12.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 (52) hide show
  1. package/dist/cli-registry.d.ts +7 -0
  2. package/dist/cli-registry.js +58 -5
  3. package/dist/cli.bundle.cjs +165 -112
  4. package/dist/cli.js +11 -3
  5. package/dist/commands/comments.js +31 -15
  6. package/dist/commands/compliance-score.js +12 -4
  7. package/dist/commands/daily-render.d.ts +2 -1
  8. package/dist/commands/daily-render.js +8 -2
  9. package/dist/commands/daily.d.ts +3 -1
  10. package/dist/commands/daily.js +54 -4
  11. package/dist/commands/dashboard-data.d.ts +17 -0
  12. package/dist/commands/dashboard-data.js +62 -4
  13. package/dist/commands/dashboard-server.js +100 -26
  14. package/dist/commands/dismiss.d.ts +4 -0
  15. package/dist/commands/dismiss.js +4 -4
  16. package/dist/commands/guidelines.d.ts +19 -0
  17. package/dist/commands/guidelines.js +23 -4
  18. package/dist/commands/index.d.ts +5 -1
  19. package/dist/commands/index.js +4 -0
  20. package/dist/commands/list-move-tier.d.ts +11 -3
  21. package/dist/commands/list-move-tier.js +18 -7
  22. package/dist/commands/move.d.ts +2 -0
  23. package/dist/commands/move.js +12 -8
  24. package/dist/commands/repo-vet.js +30 -8
  25. package/dist/commands/search.js +17 -3
  26. package/dist/commands/shelve.d.ts +4 -0
  27. package/dist/commands/shelve.js +4 -4
  28. package/dist/commands/verify-issue.d.ts +20 -0
  29. package/dist/commands/verify-issue.js +32 -0
  30. package/dist/core/daily-logic.js +65 -52
  31. package/dist/core/gist-state-store.js +42 -7
  32. package/dist/core/index.d.ts +3 -1
  33. package/dist/core/index.js +3 -1
  34. package/dist/core/issue-conversation.js +15 -2
  35. package/dist/core/issue-verification.d.ts +91 -0
  36. package/dist/core/issue-verification.js +270 -0
  37. package/dist/core/paths.d.ts +12 -0
  38. package/dist/core/paths.js +16 -0
  39. package/dist/core/pr-attention.d.ts +52 -0
  40. package/dist/core/pr-attention.js +76 -0
  41. package/dist/core/pr-comments-fetcher.d.ts +10 -2
  42. package/dist/core/pr-comments-fetcher.js +22 -4
  43. package/dist/core/state-persistence.d.ts +31 -9
  44. package/dist/core/state-persistence.js +51 -16
  45. package/dist/core/state.d.ts +18 -1
  46. package/dist/core/state.js +35 -3
  47. package/dist/core/types.d.ts +7 -0
  48. package/dist/core/untrusted-content.d.ts +24 -3
  49. package/dist/core/untrusted-content.js +31 -3
  50. package/dist/formatters/json.d.ts +83 -2
  51. package/dist/formatters/json.js +55 -1
  52. package/package.json +7 -7
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { Command } from 'commander';
12
12
  import { getGitHubTokenAsync, enableDebug, debug, getCLIVersion, stateFileExists } from './core/index.js';
13
- import { commands } from './cli-registry.js';
13
+ import { commands, handleCommandError } from './cli-registry.js';
14
14
  const VERSION = getCLIVersion();
15
15
  const program = new Command();
16
16
  program
@@ -100,5 +100,13 @@ Run oss-autopilot --help for all commands.
100
100
  `);
101
101
  process.exit(0);
102
102
  }
103
- // Parse and execute
104
- program.parse();
103
+ // Parse and execute. parseAsync, not parse: the preAction hook is async, so
104
+ // with synchronous parse() a rejected hook promise (corrupt Gist, missing
105
+ // gist scope, rate limit during bootstrap — all of which ensureGistPersistence
106
+ // now propagates) became an UnhandledPromiseRejection and the user saw a raw
107
+ // stack instead of the actionable message (#1386). Command actions already
108
+ // route their errors through executeAction/handleCommandError, so this catch
109
+ // covers exactly the hook path (plus any handler that escapes the wrapper).
110
+ program.parseAsync().catch((err) => {
111
+ handleCommandError(err, process.argv.includes('--json'));
112
+ });
@@ -3,7 +3,8 @@
3
3
  * Handles GitHub comment interactions
4
4
  */
5
5
  import { getStateManager, getOctokit, parseGitHubUrl, requireGitHubToken, maybeCheckpoint } from '../core/index.js';
6
- import { ValidationError } from '../core/errors.js';
6
+ import { wrapUntrustedContent } from '../core/untrusted-content.js';
7
+ import { ValidationError, isRateLimitOrAuthError } from '../core/errors.js';
7
8
  import { warn } from '../core/logger.js';
8
9
  const MODULE = 'comments';
9
10
  import { paginateAll } from '../core/pagination.js';
@@ -77,6 +78,12 @@ export async function runComments(options) {
77
78
  const relevantReviews = reviews
78
79
  .filter((r) => filterComment(r) && r.body && r.body.trim())
79
80
  .sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime());
81
+ // Fence every third-party body at this boundary: the output feeds agents
82
+ // directly (CLI --json, the MCP `comments` tool, and the `respond-to-pr`
83
+ // MCP prompt), so raw GitHub text must never reach a prompt unfenced
84
+ // (#1372). The CLI text display unwraps via safeExtractFromFence.
85
+ const fenceLabel = `${owner}/${repo}#${pull_number}`;
86
+ const fence = (body, source, author, association) => wrapUntrustedContent(body, fenceLabel, { author, association, source });
80
87
  const staleness = stateManager.getStateStaleness();
81
88
  return {
82
89
  pr: {
@@ -90,18 +97,18 @@ export async function runComments(options) {
90
97
  reviews: relevantReviews.map((r) => ({
91
98
  user: r.user?.login,
92
99
  state: r.state,
93
- body: r.body ?? null,
100
+ body: r.body == null ? null : fence(r.body, 'pr-review', r.user?.login, r.author_association),
94
101
  submittedAt: r.submitted_at ?? null,
95
102
  })),
96
103
  reviewComments: relevantReviewComments.map((c) => ({
97
104
  user: c.user?.login,
98
- body: c.body,
105
+ body: fence(c.body, 'pr-review-comment', c.user?.login, c.author_association),
99
106
  path: c.path,
100
107
  createdAt: c.created_at,
101
108
  })),
102
109
  issueComments: relevantIssueComments.map((c) => ({
103
110
  user: c.user?.login,
104
- body: c.body,
111
+ body: c.body == null ? c.body : fence(c.body, 'pr-issue-comment', c.user?.login, c.author_association),
105
112
  createdAt: c.created_at,
106
113
  })),
107
114
  summary: {
@@ -170,16 +177,15 @@ export async function runClaim(options) {
170
177
  }
171
178
  const { owner, repo, number } = parsed;
172
179
  const octokit = getOctokit(token);
173
- const { data: comment } = await octokit.issues.createComment({
174
- owner,
175
- repo,
176
- issue_number: number,
177
- body: message,
178
- });
179
180
  // Fetch the real issue title + labels so the tracked entry has useful metadata
180
181
  // rather than a permanent "(claimed)" placeholder that never gets backfilled
181
182
  // (#1056 M24). Best-effort: if the fetch fails, fall back to the placeholder
182
183
  // so state still records the claim.
184
+ //
185
+ // Ordering matters: enrichment runs BEFORE the claim comment is posted
186
+ // (#1391). Rate-limit / auth failures rethrow here, and aborting after the
187
+ // comment landed would leave an orphaned claim on GitHub that local state
188
+ // never tracked — aborting before it means a clean no-op the user can retry.
183
189
  let issueTitle = '(claimed)';
184
190
  let issueLabels = [];
185
191
  let issueCreatedAt = new Date().toISOString();
@@ -194,9 +200,18 @@ export async function runClaim(options) {
194
200
  issueCreatedAt = issue.created_at;
195
201
  }
196
202
  catch (error) {
197
- warn(MODULE, `Claimed ${options.issueUrl} but failed to enrich issue metadata (title/labels): ${error instanceof Error ? error.message : error}`);
203
+ if (isRateLimitOrAuthError(error))
204
+ throw error;
205
+ warn(MODULE, `Failed to enrich issue metadata (title/labels) for ${options.issueUrl}; claiming with placeholder: ${error instanceof Error ? error.message : error}`);
198
206
  }
207
+ const { data: comment } = await octokit.issues.createComment({
208
+ owner,
209
+ repo,
210
+ issue_number: number,
211
+ body: message,
212
+ });
199
213
  // Add to tracked issues — non-fatal if state save fails (comment already posted)
214
+ let gistSyncWarning = null;
200
215
  try {
201
216
  const stateManager = getStateManager();
202
217
  stateManager.addIssue({
@@ -211,10 +226,10 @@ export async function runClaim(options) {
211
226
  updatedAt: new Date().toISOString(),
212
227
  vetted: false,
213
228
  });
214
- // Push state to Gist if in Gist mode. Best-effort — logs on failure
215
- // rather than silently swallowing, so operators see the degraded-sync
216
- // signal (#1036 audit H1).
217
- await maybeCheckpoint(stateManager, MODULE);
229
+ // Push state to Gist if in Gist mode. Best-effort — the warning is
230
+ // threaded into the structured output so MCP/dashboard consumers see
231
+ // the degraded-sync signal, not just the stderr log (#1036 audit H1, #1370).
232
+ gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
218
233
  }
219
234
  catch (error) {
220
235
  // Structured warning instead of bare console.error so the breadcrumb shows
@@ -224,5 +239,6 @@ export async function runClaim(options) {
224
239
  return {
225
240
  commentUrl: comment.html_url,
226
241
  issueUrl: options.issueUrl,
242
+ ...(gistSyncWarning ? { gistSyncWarning } : {}),
227
243
  };
228
244
  }
@@ -11,15 +11,20 @@
11
11
  * mutation, runs against a public PR URL.
12
12
  */
13
13
  import { getOctokit, requireGitHubToken } from '../core/index.js';
14
- import { ValidationError } from '../core/errors.js';
14
+ import { ValidationError, errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
15
+ import { warn } from '../core/logger.js';
15
16
  import { validateUrl, PR_URL_PATTERN, validateGitHubUrl } from './validation.js';
16
17
  import { parseGitHubUrl } from '../core/urls.js';
17
18
  import { computeComplianceScore, } from '../core/compliance-score.js';
19
+ const MODULE = 'compliance-score';
18
20
  /**
19
21
  * Detect whether the target repo has visible test infrastructure. Looks
20
22
  * for the well-known directories at the repo root in a single contents
21
- * call. Failures (missing repo, rate limit, network) surface as
22
- * `undefined` so the score function falls back to its strict default.
23
+ * call. Non-fatal failures (missing repo, 5xx, network) surface as
24
+ * `undefined` so the score function falls back to its strict default,
25
+ * with a warning so "couldn't look" is distinguishable from "no test
26
+ * dir" in logs. Rate-limit / auth errors propagate — swallowing them
27
+ * here would mask a systemic problem affecting the whole run (#1373).
23
28
  */
24
29
  async function detectTestInfrastructure(octokit, owner, repo) {
25
30
  try {
@@ -29,7 +34,10 @@ async function detectTestInfrastructure(octokit, owner, repo) {
29
34
  const TEST_DIR = /^(?:tests?|__tests__|spec)$/i;
30
35
  return data.some((entry) => entry.type === 'dir' && TEST_DIR.test(entry.name));
31
36
  }
32
- catch {
37
+ catch (err) {
38
+ if (isRateLimitOrAuthError(err))
39
+ throw err;
40
+ warn(MODULE, `test-infrastructure probe for ${owner}/${repo} failed: ${errorMessage(err)}`);
33
41
  return undefined;
34
42
  }
35
43
  }
@@ -15,6 +15,7 @@
15
15
  * (daily-logic.test.ts) — pure rendering, easy to assert on string output.
16
16
  */
17
17
  import type { CapacityAssessment, CommentedIssue, CommentedIssueWithResponse, DailyDigest, MaintainerActionHint } from '../core/types.js';
18
+ import type { AttentionSummary } from '../core/pr-attention.js';
18
19
  /**
19
20
  * Format a maintainer action hint as a human-readable label.
20
21
  */
@@ -24,7 +25,7 @@ export declare function formatActionHint(hint: MaintainerActionHint): string;
24
25
  *
25
26
  * @returns One-line status string (e.g., "3 Active PRs | 1 needs attention | 2 issue replies")
26
27
  */
27
- export declare function formatBriefSummary(digest: DailyDigest, issueCount: number, issueResponseCount?: number): string;
28
+ export declare function formatBriefSummary(digest: DailyDigest, issueCount: number, issueResponseCount?: number, attention?: Pick<AttentionSummary, 'stuckCI' | 'dormantFollowup'>): string;
28
29
  /**
29
30
  * Format the full dashboard summary as markdown.
30
31
  * Used in JSON output for Claude to display verbatim — includes all PR sections,
@@ -42,10 +42,16 @@ export function formatActionHint(hint) {
42
42
  *
43
43
  * @returns One-line status string (e.g., "3 Active PRs | 1 needs attention | 2 issue replies")
44
44
  */
45
- export function formatBriefSummary(digest, issueCount, issueResponseCount = 0) {
45
+ export function formatBriefSummary(digest, issueCount, issueResponseCount = 0, attention) {
46
46
  const attentionText = issueCount > 0 ? `${issueCount} need${issueCount === 1 ? 's' : ''} attention` : 'all on track';
47
47
  const issueReplyText = issueResponseCount > 0 ? ` | ${issueResponseCount} issue repl${issueResponseCount === 1 ? 'y' : 'ies'}` : '';
48
- return `\u{1F4CA} ${digest.summary.totalActivePRs} Active PRs | ${attentionText}${issueReplyText}`;
48
+ // #1352: the watch-style buckets are appended only when non-zero — they're
49
+ // ping/nudge workflows, not part of the headline "need attention" count.
50
+ const stuckText = attention && attention.stuckCI > 0 ? ` | ${attention.stuckCI} stuck CI` : '';
51
+ const dormantText = attention && attention.dormantFollowup > 0
52
+ ? ` | ${attention.dormantFollowup} dormant follow-up${attention.dormantFollowup === 1 ? '' : 's'}`
53
+ : '';
54
+ return `\u{1F4CA} ${digest.summary.totalActivePRs} Active PRs | ${attentionText}${issueReplyText}${stuckText}${dormantText}`;
49
55
  }
50
56
  /**
51
57
  * Format the full dashboard summary as markdown.
@@ -6,7 +6,7 @@
6
6
  * Domain logic lives in src/core/daily-logic.ts; this file is a thin
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
- import { type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
9
+ import { type AttentionSummary, type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
10
10
  import { type StrategyResult } from '../core/strategy.js';
11
11
  import { type DailyOutput, type DailyWarning, type CapacityAssessment, type ActionableIssue, type ActionMenu } from '../formatters/json.js';
12
12
  export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
@@ -23,6 +23,8 @@ export interface DailyCheckResult {
23
23
  summary: string;
24
24
  briefSummary: string;
25
25
  actionableIssues: ActionableIssue[];
26
+ /** Unified attention-bucket counts over active PRs (#1352). */
27
+ attention: AttentionSummary;
26
28
  actionMenu: ActionMenu;
27
29
  commentedIssues: CommentedIssue[];
28
30
  repoGroups: RepoGroup[];
@@ -6,8 +6,9 @@
6
6
  * Domain logic lives in src/core/daily-logic.ts; this file is a thin
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
- import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
9
+ import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, summarizeAttentionBuckets, } from '../core/index.js';
10
10
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
11
+ import { wrapUntrustedContent } from '../core/untrusted-content.js';
11
12
  import { computeStrategy, shouldComputeStrategy } from '../core/strategy.js';
12
13
  import { warn } from '../core/logger.js';
13
14
  import { emptyPRCountsResult } from '../core/github-stats.js';
@@ -419,7 +420,10 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
419
420
  // Auto-undismiss mutations are auto-saved by undismissIssue()
420
421
  const actionableIssues = collectActionableIssues(activePRs, previousLastDigestAt);
421
422
  digest.summary.totalNeedingAttention = actionableIssues.length;
422
- const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
423
+ // #1352: one classifier for all attention surfaces — the dashboard stamps
424
+ // the same buckets per-PR, so the headline counts cannot diverge.
425
+ const attention = summarizeAttentionBuckets(activePRs);
426
+ const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length, attention);
423
427
  const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues);
424
428
  const repoGroups = groupPRsByRepo(activePRs);
425
429
  // Periodic strategy snapshot (#1270 Step 2). Cadence-gated to fire every
@@ -446,6 +450,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
446
450
  summary,
447
451
  briefSummary,
448
452
  actionableIssues,
453
+ attention,
449
454
  actionMenu,
450
455
  commentedIssues: filteredCommentedIssues,
451
456
  repoGroups,
@@ -457,6 +462,35 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
457
462
  // ---------------------------------------------------------------------------
458
463
  // Public API
459
464
  // ---------------------------------------------------------------------------
465
+ /**
466
+ * Fence the GitHub-sourced comment excerpts in a CommentedIssue for
467
+ * agent-facing JSON (#1372). This happens HERE — not in
468
+ * `issue-conversation.ts` — because the producer's objects also feed the
469
+ * dashboard SPA and the CLI text renderers (`daily-render.ts`), which must
470
+ * not display raw fence tags, and the producer's internal classification
471
+ * (acknowledgment / @mention matching) string-matches on raw bodies.
472
+ *
473
+ * `userLastCommentBody` is fenced too: it is the user's own comment, but
474
+ * repo maintainers can edit any comment in their repos, so it is still
475
+ * GitHub-controlled text by the time we re-fetch it.
476
+ */
477
+ function fenceCommentedIssue(issue) {
478
+ const label = `${issue.repo}#${issue.number}`;
479
+ if (issue.status === 'new_response') {
480
+ return {
481
+ ...issue,
482
+ userLastCommentBody: wrapUntrustedContent(issue.userLastCommentBody, label, { source: 'issue-comment' }),
483
+ lastResponseBody: wrapUntrustedContent(issue.lastResponseBody, label, {
484
+ author: issue.lastResponseAuthor,
485
+ source: 'issue-comment',
486
+ }),
487
+ };
488
+ }
489
+ return {
490
+ ...issue,
491
+ userLastCommentBody: wrapUntrustedContent(issue.userLastCommentBody, label, { source: 'issue-comment' }),
492
+ };
493
+ }
460
494
  /**
461
495
  * Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
462
496
  * Deduplicates PR objects: category arrays become PR URL references,
@@ -472,8 +506,9 @@ export function toDailyOutput(result) {
472
506
  summary: result.summary,
473
507
  briefSummary: result.briefSummary,
474
508
  actionableIssues: compactActionableIssues(result.actionableIssues),
509
+ attention: result.attention,
475
510
  actionMenu: result.actionMenu,
476
- commentedIssues: result.commentedIssues,
511
+ commentedIssues: result.commentedIssues.map(fenceCommentedIssue),
477
512
  repoGroups: compactRepoGroups(result.repoGroups),
478
513
  failures: result.failures,
479
514
  warnings: result.warnings,
@@ -516,6 +551,13 @@ async function executeDailyCheckInternal(token) {
516
551
  if (staleness) {
517
552
  warnings.push(buildStalenessWarning(staleness));
518
553
  }
554
+ // Surface a state-load recovery (corrupt state.json restored from backup or
555
+ // replaced with fresh state) so it's machine-visible, not just stderr (#1371).
556
+ const loadRecovery = getStateManager().getLoadRecovery();
557
+ if (loadRecovery) {
558
+ recordWarning(warnings, 'state-load', 'State file recovery', new Error(`${loadRecovery.reason}; recovered from ${loadRecovery.source}` +
559
+ (loadRecovery.rejectedPath ? `; rejected file preserved at ${loadRecovery.rejectedPath}` : '')));
560
+ }
519
561
  // Phase 1: Fetch all PR data from GitHub
520
562
  const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token, warnings);
521
563
  // Phase 2: Update repo scores (signals, star counts, trust sync)
@@ -566,10 +608,18 @@ async function executeDailyCheckInternal(token) {
566
608
  // Checkpoint: push state to Gist if in Gist mode.
567
609
  // If getStateManagerAsync was not called before this command ran,
568
610
  // isGistMode() will be false and checkpoint is correctly skipped.
611
+ // `warnings` is the same array referenced by `result`, so warnings
612
+ // recorded here still reach the structured output.
569
613
  try {
570
614
  const sm = getStateManager();
571
615
  if (sm.isGistMode()) {
572
- await sm.checkpoint();
616
+ // checkpoint() resolves false (no throw) when the push failed after
617
+ // its retry — previously discarded, reporting clean success while
618
+ // cross-machine state silently drifted (#1370).
619
+ const pushed = await sm.checkpoint();
620
+ if (!pushed) {
621
+ recordWarning(warnings, 'gist-checkpoint', 'Gist checkpoint', new Error('push failed after retry; state not synced to Gist this run'));
622
+ }
573
623
  }
574
624
  }
575
625
  catch (err) {
@@ -67,6 +67,23 @@ export interface ActionRequest {
67
67
  target?: 'attention' | 'waiting' | 'shelved' | 'auto';
68
68
  }
69
69
  export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>, storedMergedCount?: number, storedClosedCount?: number): DashboardStats;
70
+ /**
71
+ * Re-derive `digest.shelvedPRs` and `summary.totalActivePRs` from the CURRENT
72
+ * `state.config.shelvedPRUrls` so a shelve/unshelve issued from the dashboard
73
+ * SPA is reflected immediately.
74
+ *
75
+ * Why this exists: POST /api/action → runMove only mutates
76
+ * `state.config.shelvedPRUrls`; it never touches the cached digest.
77
+ * buildDashboardJson derives both `shelvedPRUrls` and `stats.shelvedPRs` from
78
+ * `digest.shelvedPRs`, so without this reconciliation a dashboard shelve/unshelve
79
+ * appears to do nothing until the next full /api/refresh rebuilds the digest.
80
+ *
81
+ * Baked shelved entries that are NOT among the current open PRs (the daily-check
82
+ * dormant partition, #981) are preserved as-is — the SPA cannot act on those —
83
+ * while explicit shelve/unshelve of open PRs and the dormant-auto-shelve rule
84
+ * are honored.
85
+ */
86
+ export declare function reconcileShelvePartition(digest: DailyDigest, state: Readonly<AgentState>): void;
70
87
  /**
71
88
  * Merge fresh API counts into existing stored counts.
72
89
  * Months present in the fresh data are updated; months only in the existing data are preserved.
@@ -3,7 +3,7 @@
3
3
  * Handles GitHub API calls, PR grouping, stats computation, and monthly chart data.
4
4
  * Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
5
5
  */
6
- import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit } from '../core/index.js';
6
+ import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit, CRITICAL_STATUSES } from '../core/index.js';
7
7
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
8
8
  import { warn } from '../core/logger.js';
9
9
  import { emptyPRCountsResult, fetchMergedPRsSince, fetchClosedPRsSince } from '../core/github-stats.js';
@@ -35,6 +35,62 @@ export function buildDashboardStats(digest, state, storedMergedCount, storedClos
35
35
  mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
36
36
  };
37
37
  }
38
+ /**
39
+ * A PR is shelved for display when the user explicitly shelved it, or the
40
+ * dormant-auto-shelve rule applies (dormant + not needing attention). Mirrors
41
+ * the partition built in fetchDashboardData (see the `freshShelved` filter).
42
+ *
43
+ * #1352: an explicitly shelved PR that turns critical is NOT shelved for
44
+ * display — the daily check auto-unshelves it (`CRITICAL_STATUSES`), so the
45
+ * dashboard must agree immediately rather than diverging from the CLI's
46
+ * headline count until the next daily run.
47
+ */
48
+ function isShelvedForDisplay(pr, explicitlyShelved) {
49
+ if (CRITICAL_STATUSES.has(pr.status))
50
+ return false;
51
+ return explicitlyShelved.has(pr.url) || pr.stalenessTier === 'dormant';
52
+ }
53
+ /**
54
+ * Re-derive `digest.shelvedPRs` and `summary.totalActivePRs` from the CURRENT
55
+ * `state.config.shelvedPRUrls` so a shelve/unshelve issued from the dashboard
56
+ * SPA is reflected immediately.
57
+ *
58
+ * Why this exists: POST /api/action → runMove only mutates
59
+ * `state.config.shelvedPRUrls`; it never touches the cached digest.
60
+ * buildDashboardJson derives both `shelvedPRUrls` and `stats.shelvedPRs` from
61
+ * `digest.shelvedPRs`, so without this reconciliation a dashboard shelve/unshelve
62
+ * appears to do nothing until the next full /api/refresh rebuilds the digest.
63
+ *
64
+ * Baked shelved entries that are NOT among the current open PRs (the daily-check
65
+ * dormant partition, #981) are preserved as-is — the SPA cannot act on those —
66
+ * while explicit shelve/unshelve of open PRs and the dormant-auto-shelve rule
67
+ * are honored.
68
+ */
69
+ export function reconcileShelvePartition(digest, state) {
70
+ const openPRs = (digest.openPRs || []);
71
+ const openByUrl = new Map(openPRs.map((pr) => [pr.url, pr]));
72
+ const explicitlyShelved = new Set(state.config.shelvedPRUrls || []);
73
+ // Keep baked entries that are either off the open-PR list (daily dormant
74
+ // partition we can't recompute here) or still shelved under current state.
75
+ const reconciled = (digest.shelvedPRs || []).filter((ref) => {
76
+ const pr = openByUrl.get(ref.url);
77
+ return !pr || isShelvedForDisplay(pr, explicitlyShelved);
78
+ });
79
+ // Add open PRs that became shelved but aren't represented in the baked list yet.
80
+ const present = new Set(reconciled.map((ref) => ref.url));
81
+ for (const pr of openPRs) {
82
+ if (!present.has(pr.url) && isShelvedForDisplay(pr, explicitlyShelved)) {
83
+ reconciled.push(toShelvedPRRef(pr));
84
+ }
85
+ }
86
+ digest.shelvedPRs = reconciled;
87
+ // Only re-derive the active count when we actually have the open-PR list;
88
+ // some digests carry an authoritative summary with an empty openPRs fixture.
89
+ if (openPRs.length > 0) {
90
+ const shelvedOpen = reconciled.filter((ref) => openByUrl.has(ref.url)).length;
91
+ digest.summary.totalActivePRs = openPRs.length - shelvedOpen;
92
+ }
93
+ }
38
94
  /**
39
95
  * Merge fresh API counts into existing stored counts.
40
96
  * Months present in the fresh data are updated; months only in the existing data are preserved.
@@ -205,10 +261,12 @@ export async function fetchDashboardData(token) {
205
261
  const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
206
262
  updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
207
263
  const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
208
- // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
209
- // Dormant PRs are treated as shelved unless they need addressing
264
+ // Apply shelve partitioning for display (auto-unshelve only runs in daily check).
265
+ // Dormant PRs are treated as shelved unless they need addressing, and a
266
+ // critical PR is never display-shelved (#1352) — mirrors the daily
267
+ // check's CRITICAL_STATUSES auto-unshelve so headline counts agree.
210
268
  const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
211
- const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing'));
269
+ const freshShelved = prs.filter((pr) => !CRITICAL_STATUSES.has(pr.status) && (shelvedUrls.has(pr.url) || pr.stalenessTier === 'dormant'));
212
270
  digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
213
271
  digest.autoUnshelvedPRs = [];
214
272
  digest.summary.totalActivePRs = prs.length - freshShelved.length;
@@ -9,11 +9,11 @@ import * as http from 'node:http';
9
9
  import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import * as crypto from 'node:crypto';
12
- import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
13
- import { errorMessage, ValidationError } from '../core/errors.js';
12
+ import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, } from '../core/index.js';
13
+ import { errorMessage, ValidationError, ConcurrencyError, GistConcurrencyError } from '../core/errors.js';
14
14
  import { warn } from '../core/logger.js';
15
15
  import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
16
- import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
16
+ import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, reconcileShelvePartition, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
17
17
  import { openInBrowser, detectIssueList } from './startup.js';
18
18
  import { parseIssueList } from './parse-list.js';
19
19
  import { writeDashboardServerInfo, removeDashboardServerInfo } from './dashboard-process.js';
@@ -75,6 +75,11 @@ function getIssueListMtimeMs() {
75
75
  * start-up, so tests that need a specific digest should call this directly).
76
76
  */
77
77
  export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs, partialFailures) {
78
+ // Re-derive the shelve partition from the CURRENT state before reading it.
79
+ // The POST /api/action path rebuilds with a cached digest whose shelvedPRs
80
+ // predates the shelve/unshelve, so without this the SPA action appears to do
81
+ // nothing until the next full /api/refresh.
82
+ reconcileShelvePartition(digest, state);
78
83
  const prsByRepo = computePRsByRepo(digest, state);
79
84
  const topRepos = computeTopRepos(prsByRepo);
80
85
  const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
@@ -108,7 +113,12 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
108
113
  monthlyMerged,
109
114
  monthlyOpened,
110
115
  monthlyClosed,
111
- activePRs: applyStatusOverrides(digest.openPRs || [], state),
116
+ // #1352: stamp the unified attention bucket so the SPA renders the same
117
+ // taxonomy the CLI brief counts (single classifier, no second opinion).
118
+ activePRs: applyStatusOverrides(digest.openPRs || [], state).map((pr) => ({
119
+ ...pr,
120
+ attentionBucket: classifyAttentionBucket(pr),
121
+ })),
112
122
  // Source of truth is digest.shelvedPRs (union of explicitly-shelved URLs
113
123
  // and dormant-non-addressing PRs auto-shelved for display). Returning
114
124
  // only state.config.shelvedPRUrls would under-count and desync from
@@ -240,6 +250,27 @@ function sendJson(res, statusCode, data) {
240
250
  function sendError(res, statusCode, message) {
241
251
  sendJson(res, statusCode, { error: message });
242
252
  }
253
+ /**
254
+ * True when an error is an optimistic-concurrency conflict on state.json
255
+ * (local mtime CAS) or the state Gist (ETag CAS). Both carry the
256
+ * CONCURRENCY_ERROR code and the same recovery contract: reload, re-apply,
257
+ * save. See state-concurrency.test.ts (#1378) and errors.ts.
258
+ */
259
+ function isConcurrencyConflict(error) {
260
+ return error instanceof ConcurrencyError || error instanceof GistConcurrencyError;
261
+ }
262
+ /**
263
+ * Send the machine-readable 409 for a concurrency conflict (#1397).
264
+ * `retryable: true` tells the SPA it can re-prime via GET /api/data and
265
+ * retry the POST; `code` matches the structured code on ConcurrencyError.
266
+ */
267
+ function sendConflict(res) {
268
+ sendJson(res, 409, {
269
+ error: 'Another process wrote state concurrently — retry the request',
270
+ code: 'CONCURRENCY_ERROR',
271
+ retryable: true,
272
+ });
273
+ }
243
274
  // ── Server ─────────────────────────────────────────────────────────────────────
244
275
  export async function startDashboardServer(options) {
245
276
  const { port: requestedPort, assetsDir, token, open } = options;
@@ -398,14 +429,16 @@ export async function startDashboardServer(options) {
398
429
  });
399
430
  server.requestTimeout = REQUEST_TIMEOUT_MS;
400
431
  // ── POST /api/action handler ─────────────────────────────────────────────
401
- async function handleAction(req, res) {
402
- // Reload state before mutating to avoid overwriting external CLI changes
432
+ /** Re-read state written by external processes (CLI) before mutating. */
433
+ async function reloadState() {
403
434
  if (stateManager.isGistMode()) {
404
435
  await stateManager.refreshFromGist();
405
436
  }
406
437
  else {
407
438
  stateManager.reloadIfChanged();
408
439
  }
440
+ }
441
+ async function handleAction(req, res) {
409
442
  let body;
410
443
  try {
411
444
  const raw = await readBody(req);
@@ -440,24 +473,62 @@ export async function startDashboardServer(options) {
440
473
  }
441
474
  return;
442
475
  }
443
- try {
444
- if (body.action === 'move') {
445
- const { VALID_TARGETS, runMove } = await import('./move.js');
446
- if (!body.target || !VALID_TARGETS.includes(body.target)) {
447
- sendError(res, 400, `move requires a valid "target" field (${VALID_TARGETS.join(', ')})`);
448
- return;
449
- }
450
- await runMove({ prUrl: body.url, target: body.target });
476
+ // Resolve the mutation up-front so target validation happens before the
477
+ // state reload keeps the reload-to-save freshness window minimal.
478
+ let applyMutation;
479
+ if (body.action === 'move') {
480
+ const { VALID_TARGETS, runMove } = await import('./move.js');
481
+ if (!body.target || !VALID_TARGETS.includes(body.target)) {
482
+ sendError(res, 400, `move requires a valid "target" field (${VALID_TARGETS.join(', ')})`);
483
+ return;
451
484
  }
452
- else {
453
- // dismiss_issue_response
485
+ const target = body.target;
486
+ applyMutation = async () => {
487
+ await runMove({ prUrl: body.url, target });
488
+ };
489
+ }
490
+ else {
491
+ // dismiss_issue_response
492
+ applyMutation = () => {
454
493
  stateManager.dismissIssue(body.url, new Date().toISOString());
455
- }
494
+ };
495
+ }
496
+ // Reload state before mutating to avoid overwriting external CLI changes.
497
+ // Runs AFTER body parsing/validation (which only inspects the request,
498
+ // never the loaded state) so the read-modify-write window excludes the
499
+ // body-streaming time (#1397).
500
+ await reloadState();
501
+ try {
502
+ await applyMutation();
456
503
  }
457
504
  catch (error) {
458
- warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
459
- sendError(res, 500, 'Action failed');
460
- return;
505
+ if (!isConcurrencyConflict(error)) {
506
+ warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
507
+ sendError(res, 500, 'Action failed');
508
+ return;
509
+ }
510
+ // Concurrency conflict: an external write landed between our reload and
511
+ // save. Decision (#1397): retry ONCE server-side instead of bouncing the
512
+ // first conflict to the client. Both mutations (move targets, dismiss)
513
+ // are absolute set operations, so re-applying them on a freshly reloaded
514
+ // baseline is safe — exactly the reload-reapply recovery contract pinned
515
+ // in state-concurrency.test.ts. A second consecutive conflict means
516
+ // sustained contention; surface it as a retryable 409 and let the SPA
517
+ // re-prime and retry.
518
+ try {
519
+ await reloadState();
520
+ await applyMutation();
521
+ }
522
+ catch (retryError) {
523
+ if (isConcurrencyConflict(retryError)) {
524
+ warn(MODULE, `Action conflicted twice: ${body.action} ${body.url} ${errorMessage(retryError)}`);
525
+ sendConflict(res);
526
+ return;
527
+ }
528
+ warn(MODULE, `Action failed on conflict retry: ${body.action} ${body.url} ${errorMessage(retryError)}`);
529
+ sendError(res, 500, 'Action failed');
530
+ return;
531
+ }
461
532
  }
462
533
  // Rebuild dashboard data from cached digest + updated state. Persist
463
534
  // the last-known partialFailures across action rebuilds (#1035) so the
@@ -475,12 +546,7 @@ export async function startDashboardServer(options) {
475
546
  return;
476
547
  }
477
548
  try {
478
- if (stateManager.isGistMode()) {
479
- await stateManager.refreshFromGist();
480
- }
481
- else {
482
- stateManager.reloadIfChanged();
483
- }
549
+ await reloadState();
484
550
  warn(MODULE, 'Refreshing dashboard data from GitHub...');
485
551
  const result = await fetchDashboardData(currentToken);
486
552
  cachedDigest = result.digest;
@@ -493,6 +559,14 @@ export async function startDashboardServer(options) {
493
559
  sendJson(res, 200, cachedJsonData);
494
560
  }
495
561
  catch (error) {
562
+ // No server-side retry here (unlike handleAction): a refresh re-run is a
563
+ // full GitHub fetch — expensive and rate-limited. The 409 is retryable
564
+ // by the client, which re-POSTs /api/refresh on its own schedule (#1397).
565
+ if (isConcurrencyConflict(error)) {
566
+ warn(MODULE, `Dashboard refresh hit a concurrent state write: ${errorMessage(error)}`);
567
+ sendConflict(res);
568
+ return;
569
+ }
496
570
  warn(MODULE, `Dashboard refresh failed: ${errorMessage(error)}`);
497
571
  sendError(res, 500, 'Refresh failed');
498
572
  }