@oss-autopilot/core 3.2.0 → 3.4.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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.js +39 -3
  3. package/dist/cli.bundle.cjs +103 -75
  4. package/dist/cli.js +17 -3
  5. package/dist/commands/check-integration.js +8 -8
  6. package/dist/commands/comments.js +3 -0
  7. package/dist/commands/config.js +14 -7
  8. package/dist/commands/daily-render.js +10 -5
  9. package/dist/commands/daily.d.ts +3 -9
  10. package/dist/commands/daily.js +12 -21
  11. package/dist/commands/dashboard-data.js +1 -1
  12. package/dist/commands/dashboard-lifecycle.js +1 -1
  13. package/dist/commands/dashboard-process.js +4 -4
  14. package/dist/commands/dashboard-server.js +26 -7
  15. package/dist/commands/dashboard.js +2 -2
  16. package/dist/commands/detect-formatters.js +3 -3
  17. package/dist/commands/doctor.js +5 -5
  18. package/dist/commands/guidelines.d.ts +10 -0
  19. package/dist/commands/guidelines.js +25 -6
  20. package/dist/commands/list-move-tier.js +5 -5
  21. package/dist/commands/local-repos.js +9 -9
  22. package/dist/commands/parse-list.js +10 -10
  23. package/dist/commands/scout-bridge.js +2 -2
  24. package/dist/commands/setup.js +24 -13
  25. package/dist/commands/skip-add.js +6 -3
  26. package/dist/commands/skip-file-parser.js +3 -3
  27. package/dist/commands/startup.js +11 -8
  28. package/dist/commands/state-cmd.js +1 -1
  29. package/dist/commands/status.js +7 -0
  30. package/dist/commands/validation.js +12 -3
  31. package/dist/commands/vet-list.js +12 -8
  32. package/dist/commands/vet.js +1 -2
  33. package/dist/core/__fixtures__/prompt-injection-payloads.d.ts +22 -0
  34. package/dist/core/__fixtures__/prompt-injection-payloads.js +109 -0
  35. package/dist/core/anti-llm-policy.js +5 -5
  36. package/dist/core/auth.js +12 -8
  37. package/dist/core/daily-logic.d.ts +13 -1
  38. package/dist/core/daily-logic.js +31 -4
  39. package/dist/core/dates.js +3 -3
  40. package/dist/core/errors.d.ts +29 -0
  41. package/dist/core/errors.js +63 -0
  42. package/dist/core/formatter-detection.js +9 -9
  43. package/dist/core/gist-state-store.d.ts +42 -3
  44. package/dist/core/gist-state-store.js +89 -19
  45. package/dist/core/guidelines-store.js +2 -2
  46. package/dist/core/http-cache.js +16 -7
  47. package/dist/core/index.d.ts +3 -1
  48. package/dist/core/index.js +6 -1
  49. package/dist/core/issue-conversation.js +3 -1
  50. package/dist/core/paths.js +4 -4
  51. package/dist/core/placeholder-usernames.d.ts +1 -0
  52. package/dist/core/placeholder-usernames.js +27 -0
  53. package/dist/core/pr-comments-fetcher.d.ts +14 -6
  54. package/dist/core/pr-comments-fetcher.js +8 -14
  55. package/dist/core/pr-monitor.d.ts +0 -2
  56. package/dist/core/pr-monitor.js +2 -25
  57. package/dist/core/pr-template.js +1 -1
  58. package/dist/core/state-persistence.d.ts +2 -2
  59. package/dist/core/state-persistence.js +15 -12
  60. package/dist/core/state-schema.js +8 -4
  61. package/dist/core/state.d.ts +27 -0
  62. package/dist/core/state.js +71 -14
  63. package/dist/core/untrusted-content.d.ts +48 -0
  64. package/dist/core/untrusted-content.js +106 -0
  65. package/dist/core/urls.js +2 -2
  66. package/dist/formatters/json.d.ts +53 -3
  67. package/dist/formatters/json.js +49 -14
  68. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -32,9 +32,23 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
32
32
  enableDebug();
33
33
  debug('cli', `Running command: ${actionCommand.name()}`);
34
34
  }
35
- // actionCommand is the command being executed (e.g., 'status', 'daily')
36
- const commandName = actionCommand.name();
37
- if (!localOnlySet.has(commandName)) {
35
+ // actionCommand is the command being executed (e.g., 'status', 'daily').
36
+ // For subcommand groups (e.g. `guidelines view`), Commander returns the
37
+ // leaf name `view` — but the registry sets `localOnly` on the parent
38
+ // entry `guidelines`. Walk the parent chain so a `localOnly` ancestor
39
+ // covers all its leaves (#1208 M2). Without this, `guidelines view` —
40
+ // which works fine in local mode (returns storageMode: 'local-unavailable')
41
+ // — would still hit the auth gate and fail.
42
+ let cmd = actionCommand;
43
+ let isLocalOnly = false;
44
+ while (cmd) {
45
+ if (localOnlySet.has(cmd.name())) {
46
+ isLocalOnly = true;
47
+ break;
48
+ }
49
+ cmd = cmd.parent;
50
+ }
51
+ if (!isLocalOnly) {
38
52
  const token = await getGitHubTokenAsync();
39
53
  if (!token) {
40
54
  // Honor --json at the CLI boundary so machine consumers (plugins, MCP
@@ -2,8 +2,8 @@
2
2
  * Check integration command (#83)
3
3
  * Detects new files in the current branch that aren't referenced elsewhere
4
4
  */
5
- import * as path from 'path';
6
- import { execFileSync } from 'child_process';
5
+ import * as path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
7
  import { debug } from '../core/index.js';
8
8
  import { errorMessage } from '../core/errors.js';
9
9
  /** File extensions we consider "code" that should be imported/referenced */
@@ -71,8 +71,8 @@ export async function runCheckIntegration(options) {
71
71
  let newFiles;
72
72
  try {
73
73
  const output = execFileSync('git', ['diff', '--name-only', '--diff-filter=A', `${base}...HEAD`], {
74
- encoding: 'utf-8',
75
- timeout: 10000,
74
+ encoding: 'utf8',
75
+ timeout: 10_000,
76
76
  }).trim();
77
77
  newFiles = output ? output.split('\n').filter(Boolean) : [];
78
78
  }
@@ -94,8 +94,8 @@ export async function runCheckIntegration(options) {
94
94
  let allFiles;
95
95
  try {
96
96
  allFiles = execFileSync('git', ['ls-files'], {
97
- encoding: 'utf-8',
98
- timeout: 10000,
97
+ encoding: 'utf8',
98
+ timeout: 10_000,
99
99
  })
100
100
  .trim()
101
101
  .split('\n')
@@ -124,8 +124,8 @@ export async function runCheckIntegration(options) {
124
124
  for (const pattern of patterns) {
125
125
  try {
126
126
  const grepOutput = execFileSync('git', ['grep', '-l', '--', pattern], {
127
- encoding: 'utf-8',
128
- timeout: 10000,
127
+ encoding: 'utf8',
128
+ timeout: 10_000,
129
129
  }).trim();
130
130
  if (grepOutput) {
131
131
  const matches = grepOutput.split('\n').filter((f) => f !== newFile);
@@ -7,6 +7,7 @@ import { ValidationError } from '../core/errors.js';
7
7
  import { warn } from '../core/logger.js';
8
8
  const MODULE = 'comments';
9
9
  import { paginateAll } from '../core/pagination.js';
10
+ import { buildStalenessWarning } from '../formatters/json.js';
10
11
  import { validateUrl, validateMessage, validateGitHubUrl, PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, ISSUE_URL_PATTERN, } from './validation.js';
11
12
  /**
12
13
  * Fetch all comments, reviews, and inline review comments for a PR.
@@ -76,6 +77,7 @@ export async function runComments(options) {
76
77
  const relevantReviews = reviews
77
78
  .filter((r) => filterComment(r) && r.body && r.body.trim())
78
79
  .sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime());
80
+ const staleness = stateManager.getStateStaleness();
79
81
  return {
80
82
  pr: {
81
83
  title: pr.title,
@@ -107,6 +109,7 @@ export async function runComments(options) {
107
109
  inlineCommentCount: relevantReviewComments.length,
108
110
  discussionCommentCount: relevantIssueComments.length,
109
111
  },
112
+ ...(staleness ? { warnings: [buildStalenessWarning(staleness)] } : {}),
110
113
  };
111
114
  }
112
115
  /**
@@ -44,25 +44,29 @@ export async function runConfig(options) {
44
44
  const value = options.value;
45
45
  // Handle specific config keys
46
46
  switch (options.key) {
47
- case 'username':
47
+ case 'username': {
48
48
  stateManager.updateConfig({ githubUsername: validateGitHubUsername(value) });
49
49
  break;
50
- case 'add-language':
50
+ }
51
+ case 'add-language': {
51
52
  if (!currentConfig.languages.includes(value)) {
52
53
  stateManager.updateConfig({ languages: [...currentConfig.languages, value] });
53
54
  }
54
55
  break;
55
- case 'add-label':
56
+ }
57
+ case 'add-label': {
56
58
  if (!currentConfig.labels.includes(value)) {
57
59
  stateManager.updateConfig({ labels: [...currentConfig.labels, value] });
58
60
  }
59
61
  break;
60
- case 'remove-label':
62
+ }
63
+ case 'remove-label': {
61
64
  if (!currentConfig.labels.includes(value)) {
62
65
  throw new Error(`Label "${value}" is not currently configured. Current labels: ${currentConfig.labels.join(', ')}`);
63
66
  }
64
67
  stateManager.updateConfig({ labels: currentConfig.labels.filter((l) => l !== value) });
65
68
  break;
69
+ }
66
70
  case 'add-scope': {
67
71
  const scope = validateScope(value);
68
72
  const currentScopes = currentConfig.scope ?? [];
@@ -111,9 +115,10 @@ export async function runConfig(options) {
111
115
  }
112
116
  break;
113
117
  }
114
- case 'issueListPath':
118
+ case 'issueListPath': {
115
119
  stateManager.updateConfig({ issueListPath: value || undefined });
116
120
  break;
121
+ }
117
122
  case 'diffTool': {
118
123
  if (!DIFF_TOOLS.includes(value)) {
119
124
  throw new Error(`Invalid diffTool "${value}". Valid options: ${DIFF_TOOLS.join(', ')}`);
@@ -121,13 +126,15 @@ export async function runConfig(options) {
121
126
  stateManager.updateConfig({ diffTool: value });
122
127
  break;
123
128
  }
124
- case 'diffToolCustomCommand':
129
+ case 'diffToolCustomCommand': {
125
130
  stateManager.updateConfig({
126
131
  diffToolCustomCommand: value || undefined,
127
132
  });
128
133
  break;
129
- default:
134
+ }
135
+ default: {
130
136
  throw new ValidationError(formatUnknownKeyError(options.key, 'config'));
137
+ }
131
138
  }
132
139
  return { success: true, key: options.key, value };
133
140
  }
@@ -20,16 +20,21 @@ import { formatRelativeTime } from '../core/dates.js';
20
20
  */
21
21
  export function formatActionHint(hint) {
22
22
  switch (hint) {
23
- case 'demo_requested':
23
+ case 'demo_requested': {
24
24
  return 'demo/screenshot requested';
25
- case 'tests_requested':
25
+ }
26
+ case 'tests_requested': {
26
27
  return 'tests requested';
27
- case 'changes_requested':
28
+ }
29
+ case 'changes_requested': {
28
30
  return 'code changes requested';
29
- case 'docs_requested':
31
+ }
32
+ case 'docs_requested': {
30
33
  return 'documentation requested';
31
- case 'rebase_requested':
34
+ }
35
+ case 'rebase_requested': {
32
36
  return 'rebase requested';
37
+ }
33
38
  }
34
39
  }
35
40
  /**
@@ -6,17 +6,11 @@
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, type AgentState, type StarFilter } from '../core/index.js';
9
+ import { type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
10
10
  import { type DailyOutput, type DailyWarning, type CapacityAssessment, type ActionableIssue, type ActionMenu } from '../formatters/json.js';
11
11
  export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
12
- /**
13
- * Build a star filter from state for use in fetchUserPRCounts.
14
- * Returns undefined if no star data is available (first run).
15
- *
16
- * @param state - Current agent state (read-only)
17
- * @returns Star filter with minimum threshold and known counts, or undefined on first run
18
- */
19
- export declare function buildStarFilter(state: Readonly<AgentState>): StarFilter | undefined;
12
+ import { buildStarFilter } from '../core/daily-logic.js';
13
+ export { buildStarFilter };
20
14
  /**
21
15
  * Internal result of the daily check, using full (non-deduplicated) types.
22
16
  * Consumed by printDigest() (text mode) and converted to DailyOutput (JSON mode)
@@ -12,7 +12,7 @@ import { warn } from '../core/logger.js';
12
12
  import { emptyPRCountsResult } from '../core/github-stats.js';
13
13
  import { createAutopilotScout } from './scout-bridge.js';
14
14
  import { updateMonthlyAnalytics } from './dashboard-data.js';
15
- import { deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '../formatters/json.js';
15
+ import { deduplicateDigest, compactActionableIssues, compactRepoGroups, buildStalenessWarning, } from '../formatters/json.js';
16
16
  const MODULE = 'daily';
17
17
  /**
18
18
  * Record a non-fatal failure: push a structured entry into the run's warnings
@@ -39,26 +39,12 @@ function nonFatalCatchWithWarning(opts) {
39
39
  // Re-export domain functions so existing consumers (tests, dashboard, startup)
40
40
  // can continue importing from './daily.js' without changes.
41
41
  export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
42
- /**
43
- * Build a star filter from state for use in fetchUserPRCounts.
44
- * Returns undefined if no star data is available (first run).
45
- *
46
- * @param state - Current agent state (read-only)
47
- * @returns Star filter with minimum threshold and known counts, or undefined on first run
48
- */
49
- export function buildStarFilter(state) {
50
- const minStars = state.config.minStars ?? 50;
51
- const knownStarCounts = new Map();
52
- for (const [repo, score] of Object.entries(state.repoScores)) {
53
- if (score.stargazersCount !== undefined) {
54
- knownStarCounts.set(repo, score.stargazersCount);
55
- }
56
- }
57
- // Only filter if we have some star data to work with
58
- if (knownStarCounts.size === 0)
59
- return undefined;
60
- return { minStars, knownStarCounts };
61
- }
42
+ // buildStarFilter moved to core/daily-logic.ts so the dashboard layer can
43
+ // reuse it without crossing the commands sibling-command boundary
44
+ // (#1208 M7). Re-exported here for backward compatibility with anyone
45
+ // importing from this module.
46
+ import { buildStarFilter } from '../core/daily-logic.js';
47
+ export { buildStarFilter };
62
48
  // ---------------------------------------------------------------------------
63
49
  // Phase functions
64
50
  // ---------------------------------------------------------------------------
@@ -504,6 +490,11 @@ async function executeDailyCheckInternal(token) {
504
490
  // One collector shared by every phase — threaded through explicitly so the
505
491
  // callgraph documents which phases can produce non-fatal warnings. See #1042.
506
492
  const warnings = [];
493
+ // Surface Gist staleness up-front so consumers see it even if Phase 1 fails (#1193).
494
+ const staleness = getStateManager().getStateStaleness();
495
+ if (staleness) {
496
+ warnings.push(buildStalenessWarning(staleness));
497
+ }
507
498
  // Phase 1: Fetch all PR data from GitHub
508
499
  const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token, warnings);
509
500
  // Phase 2: Update repo scores (signals, star counts, trust sync)
@@ -9,7 +9,7 @@ import { warn } from '../core/logger.js';
9
9
  import { emptyPRCountsResult, fetchMergedPRsSince, fetchClosedPRsSince } from '../core/github-stats.js';
10
10
  import { parseGitHubUrl } from '../core/urls.js';
11
11
  import { isBelowMinStars, } from '../core/types.js';
12
- import { toShelvedPRRef, buildStarFilter } from './daily.js';
12
+ import { toShelvedPRRef, buildStarFilter } from '../core/index.js';
13
13
  const MODULE = 'dashboard-data';
14
14
  export function buildDashboardStats(digest, state, storedMergedCount, storedClosedCount) {
15
15
  const summary = digest.summary || {
@@ -3,7 +3,7 @@
3
3
  * Handles launching the interactive SPA dashboard as a background process
4
4
  * and detecting whether a server is already running.
5
5
  */
6
- import { spawn } from 'child_process';
6
+ import { spawn } from 'node:child_process';
7
7
  import { findRunningDashboardServer, isDashboardServerRunning, readDashboardServerInfo, removeDashboardServerInfo, } from './dashboard-process.js';
8
8
  import { resolveAssetsDir } from './dashboard.js';
9
9
  import { getCLIVersion } from '../core/index.js';
@@ -2,9 +2,9 @@
2
2
  * Dashboard server process management.
3
3
  * PID file operations, health probes, and running server detection.
4
4
  */
5
- import * as http from 'http';
6
- import * as fs from 'fs';
7
- import * as path from 'path';
5
+ import * as http from 'node:http';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
8
  import { getDataDir } from '../core/index.js';
9
9
  import { warn } from '../core/logger.js';
10
10
  const MODULE = 'dashboard-server';
@@ -17,7 +17,7 @@ export function writeDashboardServerInfo(info) {
17
17
  }
18
18
  export function readDashboardServerInfo() {
19
19
  try {
20
- const content = fs.readFileSync(getDashboardPidPath(), 'utf-8');
20
+ const content = fs.readFileSync(getDashboardPidPath(), 'utf8');
21
21
  const parsed = JSON.parse(content);
22
22
  if (typeof parsed !== 'object' ||
23
23
  parsed === null ||
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * Uses Node's built-in http module — no Express/Fastify.
7
7
  */
8
- import * as http from 'http';
9
- import * as fs from 'fs';
10
- import * as path from 'path';
11
- import * as crypto from 'crypto';
8
+ import * as http from 'node:http';
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import * as crypto from 'node:crypto';
12
12
  import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
13
13
  import { errorMessage, ValidationError } from '../core/errors.js';
14
14
  import { warn } from '../core/logger.js';
@@ -44,7 +44,7 @@ function readVettedIssues() {
44
44
  const info = detectIssueList();
45
45
  if (!info)
46
46
  return null;
47
- const content = fs.readFileSync(info.path, 'utf-8');
47
+ const content = fs.readFileSync(info.path, 'utf8');
48
48
  return parseIssueList(content);
49
49
  }
50
50
  catch (error) {
@@ -148,7 +148,7 @@ function readBody(req, maxBytes = MAX_BODY_BYTES) {
148
148
  });
149
149
  req.on('end', () => {
150
150
  if (!aborted)
151
- resolve(Buffer.concat(chunks).toString('utf-8'));
151
+ resolve(Buffer.concat(chunks).toString('utf8'));
152
152
  });
153
153
  req.on('error', (err) => {
154
154
  if (!aborted)
@@ -265,6 +265,11 @@ export async function startDashboardServer(options) {
265
265
  // not disappear when /api/data rebuilds after a state change or after a
266
266
  // POST /api/action completes.
267
267
  let cachedPartialFailures = undefined;
268
+ // Tracks the last background-refresh failure so /api/data can surface
269
+ // staleness to the SPA via the X-Dashboard-Stale header (#1205). Cleared
270
+ // when a refresh succeeds. Without this, token expiry / GitHub outage
271
+ // produces silent stale data hours old with no client-visible signal.
272
+ let lastBackgroundRefreshError = null;
268
273
  if (!cachedDigest) {
269
274
  throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
270
275
  }
@@ -327,6 +332,14 @@ export async function startDashboardServer(options) {
327
332
  res.setHeader('X-Dashboard-Stale', '1');
328
333
  }
329
334
  }
335
+ // Surface staleness from a failed background refresh too (#1205) so
336
+ // token expiry / GitHub outage produces a client-visible signal
337
+ // rather than silent stale data. Only set the header when a failure
338
+ // is recorded — successful refreshes clear it.
339
+ if (lastBackgroundRefreshError !== null) {
340
+ res.setHeader('X-Dashboard-Stale', '1');
341
+ res.setHeader('X-Dashboard-Stale-Reason', `background-refresh-failed: ${lastBackgroundRefreshError}`);
342
+ }
330
343
  sendJson(res, 200, cachedJsonData);
331
344
  return;
332
345
  }
@@ -598,10 +611,16 @@ export async function startDashboardServer(options) {
598
611
  cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
599
612
  cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
600
613
  cachedIssueListMtimeMs = getIssueListMtimeMs();
614
+ // Successful refresh clears any prior failure signal (#1205).
615
+ lastBackgroundRefreshError = null;
601
616
  warn(MODULE, 'Background data refresh complete');
617
+ return;
602
618
  })
603
619
  .catch((error) => {
604
- warn(MODULE, `Background data refresh failed (serving cached data): ${errorMessage(error)}`);
620
+ // Capture so /api/data can surface staleness via X-Dashboard-Stale
621
+ // header — previously the catch only logged to stderr (#1205).
622
+ lastBackgroundRefreshError = errorMessage(error);
623
+ warn(MODULE, `Background data refresh failed (serving cached data): ${lastBackgroundRefreshError}`);
605
624
  });
606
625
  }
607
626
  // ── Open browser ─────────────────────────────────────────────────────────
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Dashboard command — serves the interactive Preact SPA dashboard.
3
3
  */
4
- import * as fs from 'fs';
5
- import * as path from 'path';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
6
  import { getGitHubToken } from '../core/index.js';
7
7
  /**
8
8
  * Resolve the SPA assets directory from packages/dashboard/dist/.
@@ -3,8 +3,8 @@
3
3
  * Scans a local repository for configured formatters/linters.
4
4
  * Optionally diagnoses CI log output for formatting failures.
5
5
  */
6
- import * as fs from 'fs';
7
- import * as path from 'path';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
8
  import { detectFormatters, diagnoseCIFormatterFailure } from '../core/formatter-detection.js';
9
9
  import { errorMessage } from '../core/errors.js';
10
10
  export async function runDetectFormatters(options) {
@@ -13,7 +13,7 @@ export async function runDetectFormatters(options) {
13
13
  if (options.ciLog) {
14
14
  let logContent;
15
15
  try {
16
- logContent = fs.readFileSync(path.resolve(options.ciLog), 'utf-8');
16
+ logContent = fs.readFileSync(path.resolve(options.ciLog), 'utf8');
17
17
  }
18
18
  catch (err) {
19
19
  throw new Error(`Failed to read CI log file: ${errorMessage(err)}`, { cause: err });
@@ -9,10 +9,10 @@
9
9
  * Each `check*` function is exported individually so tests can mock its
10
10
  * external dependencies in isolation.
11
11
  */
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
- import { execFile } from 'child_process';
15
- import { promisify } from 'util';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
16
  import { getGitHubTokenAsync, getOctokit, getStatePath } from '../core/index.js';
17
17
  import { AgentStateSchema } from '../core/state-schema.js';
18
18
  import { errorMessage } from '../core/errors.js';
@@ -195,7 +195,7 @@ export function checkStateFile(options) {
195
195
  };
196
196
  }
197
197
  try {
198
- const raw = fs.readFileSync(statePath, 'utf-8');
198
+ const raw = fs.readFileSync(statePath, 'utf8');
199
199
  const parsed = JSON.parse(raw);
200
200
  const result = AgentStateSchema.safeParse(parsed);
201
201
  if (!result.success) {
@@ -27,6 +27,16 @@ export interface FetchCorpusOutput {
27
27
  prCount: number;
28
28
  /** PRs skipped because `commentsFetchedAt` is already set (without --force). */
29
29
  skipped: number;
30
+ /**
31
+ * PRs that were attempted but errored (404, rate limit, transient API
32
+ * failure). Surfaced so the host can decide whether to retry or warn the
33
+ * user that the corpus is partial. Empty array when all attempted fetches
34
+ * succeeded. (#1209 L8)
35
+ */
36
+ failures: Array<{
37
+ prUrl: string;
38
+ error: string;
39
+ }>;
30
40
  }
31
41
  interface RepoOption {
32
42
  repo: string;
@@ -11,7 +11,7 @@
11
11
  * Standalone-mode users see "not available" on store/reset/fetch-corpus
12
12
  * because per-repo guidelines require Gist persistence to be useful.
13
13
  */
14
- import { getStateManager, requireGitHubToken, getOctokit, GuidelinesNotAvailableError } from '../core/index.js';
14
+ import { getStateManager, requireGitHubToken, getOctokit, GuidelinesNotAvailableError, maybeCheckpoint, } from '../core/index.js';
15
15
  import { fetchPRCommentBundlesBatch } from '../core/pr-comments-fetcher.js';
16
16
  import { warn } from '../core/logger.js';
17
17
  const MODULE = 'guidelines';
@@ -36,7 +36,7 @@ export async function runGuidelinesView(options) {
36
36
  return {
37
37
  repo: options.repo,
38
38
  content,
39
- byteSize: content === null ? 0 : Buffer.byteLength(content, 'utf-8'),
39
+ byteSize: content === null ? 0 : Buffer.byteLength(content, 'utf8'),
40
40
  exists: content !== null,
41
41
  storageMode: sm.isGuidelinesAvailable() ? 'gist' : 'local-unavailable',
42
42
  };
@@ -54,9 +54,13 @@ export async function runGuidelinesStore(options) {
54
54
  const sm = getStateManager();
55
55
  // Throws GuidelinesNotAvailableError or GuidelinesTooLargeError on failure.
56
56
  sm.setGuidelines(options.repo, options.content);
57
+ // Push to Gist — autoSave only writes the local state-cache mirror in Gist
58
+ // mode, so without this checkpoint the change never propagates across
59
+ // machines (#1200).
60
+ await maybeCheckpoint(sm, MODULE);
57
61
  return {
58
62
  repo: options.repo,
59
- byteSize: Buffer.byteLength(options.content, 'utf-8'),
63
+ byteSize: Buffer.byteLength(options.content, 'utf8'),
60
64
  stored: true,
61
65
  };
62
66
  }
@@ -70,6 +74,8 @@ export async function runGuidelinesReset(options) {
70
74
  const existed = sm.getGuidelines(options.repo) !== null;
71
75
  if (existed) {
72
76
  sm.deleteGuidelines(options.repo);
77
+ // Push to Gist — see runGuidelinesStore note (#1200).
78
+ await maybeCheckpoint(sm, MODULE);
73
79
  }
74
80
  return { repo: options.repo, deleted: existed };
75
81
  }
@@ -102,7 +108,11 @@ export async function runFetchCorpus(options) {
102
108
  const eligible = candidates.filter((c) => {
103
109
  if (!c.url.startsWith(repoUrlPrefix))
104
110
  return false;
105
- if (Date.parse(c.timestamp || '') < cutoffMs)
111
+ // Date.parse('') is NaN, and `NaN < cutoffMs` is false — the previous
112
+ // form silently passed PRs with empty/malformed timestamps through the
113
+ // recency cliff (#1204). Number.isFinite filters those out explicitly.
114
+ const ts = Date.parse(c.timestamp || '');
115
+ if (!Number.isFinite(ts) || ts < cutoffMs)
106
116
  return false;
107
117
  return true;
108
118
  });
@@ -112,10 +122,12 @@ export async function runFetchCorpus(options) {
112
122
  const skipped = options.forceRefetch ? 0 : eligible.filter((c) => c.alreadyFetched).length;
113
123
  const toFetch = (options.forceRefetch ? eligible : eligible.filter((c) => !c.alreadyFetched))
114
124
  // Most-recent first so the host always sees the freshest signal in its corpus window.
125
+ // After the eligibility filter (#1204), every entry has a finite Date.parse,
126
+ // so the comparator is well-defined.
115
127
  .sort((a, b) => Date.parse(b.timestamp || '') - Date.parse(a.timestamp || ''))
116
128
  .slice(0, limit);
117
129
  if (toFetch.length === 0) {
118
- return { repo: options.repo, bundles: [], prCount: 0, skipped };
130
+ return { repo: options.repo, bundles: [], prCount: 0, skipped, failures: [] };
119
131
  }
120
132
  const token = requireGitHubToken();
121
133
  const octokit = getOctokit(token);
@@ -123,7 +135,7 @@ export async function runFetchCorpus(options) {
123
135
  if (!username) {
124
136
  warn(MODULE, 'githubUsername is not set; bot/own-comment filtering will be incomplete');
125
137
  }
126
- const bundles = await fetchPRCommentBundlesBatch(octokit, toFetch.map((c) => c.url), username);
138
+ const { bundles, failures } = await fetchPRCommentBundlesBatch(octokit, toFetch.map((c) => c.url), username);
127
139
  // Stamp commentsFetchedAt on each PR we successfully fetched — the bundle
128
140
  // list mirrors the input order until paginateAll fan-out, so we mark on
129
141
  // a per-bundle basis to be safe.
@@ -131,11 +143,18 @@ export async function runFetchCorpus(options) {
131
143
  for (const bundle of bundles) {
132
144
  sm.markPRCommentsFetched(bundle.prUrl, now);
133
145
  }
146
+ // Push commentsFetchedAt stamps to Gist so other machines don't re-fetch
147
+ // the same PRs forever. autoSave only writes the local mirror in Gist
148
+ // mode (#1200).
149
+ if (bundles.length > 0) {
150
+ await maybeCheckpoint(sm, MODULE);
151
+ }
134
152
  return {
135
153
  repo: options.repo,
136
154
  bundles,
137
155
  prCount: bundles.length,
138
156
  skipped,
157
+ failures,
139
158
  };
140
159
  }
141
160
  function clampLimit(limit) {
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * No GitHub calls — pure read/transform/write of a local file.
12
12
  */
13
- import * as fs from 'fs';
14
- import * as path from 'path';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
15
  import { errorMessage } from '../core/errors.js';
16
16
  const TIER_HEADERS = {
17
17
  pursue: '## Pursue',
@@ -47,7 +47,7 @@ function findIssueBlocks(lines, issueUrl) {
47
47
  for (let i = 0; i < lines.length; i++) {
48
48
  const line = lines[i];
49
49
  // Top-level list item — `- `, `* `, `+ `, or `1.` at the start (with no leading whitespace).
50
- const isTopLevelListItem = /^[-*+]\s|^\d+\.\s/.test(line);
50
+ const isTopLevelListItem = /^[*+-]\s|^\d+\.\s/.test(line);
51
51
  if (!isTopLevelListItem || !line.includes(issueUrl))
52
52
  continue;
53
53
  // Capture indented sub-bullets that follow this line.
@@ -166,7 +166,7 @@ export async function runListMoveTier(options) {
166
166
  }
167
167
  let content;
168
168
  try {
169
- content = fs.readFileSync(filePath, 'utf-8');
169
+ content = fs.readFileSync(filePath, 'utf8');
170
170
  }
171
171
  catch (error) {
172
172
  throw new Error(`Failed to read file: ${errorMessage(error)}`, { cause: error });
@@ -174,7 +174,7 @@ export async function runListMoveTier(options) {
174
174
  const result = moveIssueToTier(content, options.issueUrl, options.tier);
175
175
  if (result.moved) {
176
176
  try {
177
- fs.writeFileSync(filePath, result.content, 'utf-8');
177
+ fs.writeFileSync(filePath, result.content, 'utf8');
178
178
  }
179
179
  catch (error) {
180
180
  throw new Error(`Failed to write file: ${errorMessage(error)}`, { cause: error });
@@ -2,10 +2,10 @@
2
2
  * Local repos command (#84)
3
3
  * Scans configurable directories for local git clones and caches results
4
4
  */
5
- import * as fs from 'fs';
6
- import * as path from 'path';
7
- import * as os from 'os';
8
- import { execFileSync } from 'child_process';
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import * as os from 'node:os';
8
+ import { execFileSync } from 'node:child_process';
9
9
  import { getStateManager, debug } from '../core/index.js';
10
10
  import { errorMessage } from '../core/errors.js';
11
11
  /** Default directories to scan for local clones */
@@ -21,7 +21,7 @@ const DEFAULT_SCAN_PATHS = [
21
21
  function getGitHubRemote(repoPath) {
22
22
  try {
23
23
  const remoteUrl = execFileSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], {
24
- encoding: 'utf-8',
24
+ encoding: 'utf8',
25
25
  timeout: 5000,
26
26
  stdio: ['pipe', 'pipe', 'pipe'],
27
27
  }).trim();
@@ -30,7 +30,7 @@ function getGitHubRemote(repoPath) {
30
30
  if (httpsMatch)
31
31
  return httpsMatch[1];
32
32
  // Match SSH: git@github.com:owner/repo.git
33
- const sshMatch = remoteUrl.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
33
+ const sshMatch = remoteUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
34
34
  if (sshMatch)
35
35
  return sshMatch[1];
36
36
  return null;
@@ -45,7 +45,7 @@ function getGitHubRemote(repoPath) {
45
45
  function getCurrentBranch(repoPath) {
46
46
  try {
47
47
  return (execFileSync('git', ['-C', repoPath, 'branch', '--show-current'], {
48
- encoding: 'utf-8',
48
+ encoding: 'utf8',
49
49
  timeout: 5000,
50
50
  stdio: ['pipe', 'pipe', 'pipe'],
51
51
  }).trim() || null);
@@ -66,8 +66,8 @@ export function scanForRepos(scanPaths) {
66
66
  let gitDirs;
67
67
  try {
68
68
  const output = execFileSync('find', [scanPath, '-maxdepth', '4', '-name', '.git', '-type', 'd'], {
69
- encoding: 'utf-8',
70
- timeout: 30000,
69
+ encoding: 'utf8',
70
+ timeout: 30_000,
71
71
  stdio: ['pipe', 'pipe', 'pipe'],
72
72
  }).trim();
73
73
  gitDirs = output ? output.split('\n').filter(Boolean) : [];