@oss-autopilot/core 0.44.0 → 0.44.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.
@@ -10,11 +10,18 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { getStateManager, getGitHubToken, getDataDir } from '../core/index.js';
12
12
  import { errorMessage, ValidationError } from '../core/errors.js';
13
- import { validateUrl, validateGitHubUrl, validateMessage, PR_URL_PATTERN } from './validation.js';
13
+ import { validateUrl, validateGitHubUrl, validateMessage, PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, } from './validation.js';
14
14
  import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, } from './dashboard-data.js';
15
15
  import { openInBrowser } from './startup.js';
16
16
  // ── Constants ────────────────────────────────────────────────────────────────
17
- const VALID_ACTIONS = new Set(['shelve', 'unshelve', 'snooze', 'unsnooze']);
17
+ const VALID_ACTIONS = new Set([
18
+ 'shelve',
19
+ 'unshelve',
20
+ 'snooze',
21
+ 'unsnooze',
22
+ 'dismiss',
23
+ 'undismiss',
24
+ ]);
18
25
  const MAX_BODY_BYTES = 10_240;
19
26
  const MIME_TYPES = {
20
27
  '.html': 'text/html',
@@ -125,6 +132,7 @@ function buildDashboardJson(digest, state, commentedIssues) {
125
132
  monthlyClosed,
126
133
  activePRs: digest.openPRs || [],
127
134
  shelvedPRUrls: state.config.shelvedPRUrls || [],
135
+ dismissedUrls: Object.keys(state.config.dismissedIssues || {}),
128
136
  recentlyMergedPRs: digest.recentlyMergedPRs || [],
129
137
  recentlyClosedPRs: digest.recentlyClosedPRs || [],
130
138
  autoUnshelvedPRs: digest.autoUnshelvedPRs || [],
@@ -257,10 +265,14 @@ export async function startDashboardServer(options) {
257
265
  sendError(res, 400, 'Missing or invalid "url" field');
258
266
  return;
259
267
  }
260
- // Validate URL format — same checks as CLI commands
268
+ // Validate URL format — same checks as CLI commands.
269
+ // Dismiss/undismiss accepts both PR and issue URLs; other actions are PR-only.
270
+ const isDismissAction = body.action === 'dismiss' || body.action === 'undismiss';
271
+ const urlPattern = isDismissAction ? ISSUE_OR_PR_URL_PATTERN : PR_URL_PATTERN;
272
+ const urlType = isDismissAction ? 'issue or PR' : 'PR';
261
273
  try {
262
274
  validateUrl(body.url);
263
- validateGitHubUrl(body.url, PR_URL_PATTERN, 'PR');
275
+ validateGitHubUrl(body.url, urlPattern, urlType);
264
276
  }
265
277
  catch (err) {
266
278
  if (err instanceof ValidationError) {
@@ -299,6 +311,7 @@ export async function startDashboardServer(options) {
299
311
  switch (body.action) {
300
312
  case 'shelve':
301
313
  stateManager.shelvePR(body.url);
314
+ stateManager.undismissIssue(body.url); // prevent dual state
302
315
  break;
303
316
  case 'unshelve':
304
317
  stateManager.unshelvePR(body.url);
@@ -309,6 +322,13 @@ export async function startDashboardServer(options) {
309
322
  case 'unsnooze':
310
323
  stateManager.unsnoozePR(body.url);
311
324
  break;
325
+ case 'dismiss':
326
+ stateManager.dismissIssue(body.url, new Date().toISOString());
327
+ stateManager.unshelvePR(body.url); // prevent dual state
328
+ break;
329
+ case 'undismiss':
330
+ stateManager.undismissIssue(body.url);
331
+ break;
312
332
  }
313
333
  stateManager.save();
314
334
  }
@@ -35,7 +35,14 @@ const STATUS_DISPLAY = {
35
35
  },
36
36
  ci_blocked: {
37
37
  label: '[CI Blocked]',
38
- description: () => 'CI cannot run (first-time contributor approval needed)',
38
+ description: (pr) => {
39
+ const checks = pr.classifiedChecks || [];
40
+ if (checks.length > 0 && checks.every((c) => c.category !== 'actionable')) {
41
+ const categories = [...new Set(checks.map((c) => c.category))];
42
+ return `All failing checks are non-actionable (${categories.join(', ')})`;
43
+ }
44
+ return 'CI checks are failing but no action is needed from you';
45
+ },
39
46
  },
40
47
  ci_not_running: {
41
48
  label: '[CI Not Running]',
@@ -101,3 +101,13 @@ export declare function cachedRequest<T>(cache: HttpCache, url: string, fetcher:
101
101
  data: T;
102
102
  headers?: Record<string, string>;
103
103
  }>): Promise<T>;
104
+ /**
105
+ * Time-based cache wrapper (no ETag / conditional requests).
106
+ *
107
+ * If a cached result exists and is younger than `maxAgeMs`, returns it.
108
+ * Otherwise calls `fetcher`, caches the result, and returns it.
109
+ *
110
+ * Use this for expensive operations whose results change slowly
111
+ * (e.g. search queries, project health checks).
112
+ */
113
+ export declare function cachedTimeBased<T>(cache: HttpCache, key: string, maxAgeMs: number, fetcher: () => Promise<T>): Promise<T>;
@@ -272,6 +272,25 @@ export async function cachedRequest(cache, url, fetcher) {
272
272
  cleanup();
273
273
  }
274
274
  }
275
+ /**
276
+ * Time-based cache wrapper (no ETag / conditional requests).
277
+ *
278
+ * If a cached result exists and is younger than `maxAgeMs`, returns it.
279
+ * Otherwise calls `fetcher`, caches the result, and returns it.
280
+ *
281
+ * Use this for expensive operations whose results change slowly
282
+ * (e.g. search queries, project health checks).
283
+ */
284
+ export async function cachedTimeBased(cache, key, maxAgeMs, fetcher) {
285
+ const cached = cache.getIfFresh(key, maxAgeMs);
286
+ if (cached) {
287
+ debug(MODULE, `Time-based cache hit for ${key}`);
288
+ return cached;
289
+ }
290
+ const result = await fetcher();
291
+ cache.set(key, '', result);
292
+ return result;
293
+ }
275
294
  /**
276
295
  * Detect whether an error is a 304 Not Modified response.
277
296
  * Octokit throws a RequestError with status 304 for conditional requests.
@@ -20,6 +20,12 @@ export declare class IssueDiscovery {
20
20
  /** Set after searchIssues() runs if rate limits affected the search (low pre-flight quota or mid-search rate limit hits). */
21
21
  rateLimitWarning: string | null;
22
22
  constructor(githubToken: string);
23
+ /**
24
+ * Wrap octokit.search.issuesAndPullRequests with time-based caching.
25
+ * Repeated identical queries within SEARCH_CACHE_TTL_MS return cached results
26
+ * without consuming GitHub API rate limit points.
27
+ */
28
+ private cachedSearch;
23
29
  /**
24
30
  * Fetch the authenticated user's starred repositories from GitHub.
25
31
  * Updates the state manager with the list and timestamp.
@@ -14,6 +14,7 @@ import { daysBetween, getDataDir } from './utils.js';
14
14
  import { DEFAULT_CONFIG } from './types.js';
15
15
  import { ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
16
16
  import { debug, info, warn } from './logger.js';
17
+ import { getHttpCache, cachedTimeBased } from './http-cache.js';
17
18
  import { isDocOnlyIssue, detectLabelFarmingRepos, applyPerRepoCap } from './issue-filtering.js';
18
19
  import { IssueVetter } from './issue-vetting.js';
19
20
  import { calculateViabilityScore as calcViabilityScore } from './issue-scoring.js';
@@ -22,6 +23,8 @@ import { calculateViabilityScore as calcViabilityScore } from './issue-scoring.j
22
23
  export { isDocOnlyIssue, applyPerRepoCap, isLabelFarming, hasTemplatedTitle, detectLabelFarmingRepos, DOC_ONLY_LABELS, BEGINNER_LABELS, } from './issue-filtering.js';
23
24
  export { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
24
25
  const MODULE = 'issue-discovery';
26
+ /** TTL for cached search API results (15 minutes). */
27
+ const SEARCH_CACHE_TTL_MS = 15 * 60 * 1000;
25
28
  export class IssueDiscovery {
26
29
  octokit;
27
30
  stateManager;
@@ -35,6 +38,18 @@ export class IssueDiscovery {
35
38
  this.stateManager = getStateManager();
36
39
  this.vetter = new IssueVetter(this.octokit, this.stateManager);
37
40
  }
41
+ /**
42
+ * Wrap octokit.search.issuesAndPullRequests with time-based caching.
43
+ * Repeated identical queries within SEARCH_CACHE_TTL_MS return cached results
44
+ * without consuming GitHub API rate limit points.
45
+ */
46
+ async cachedSearch(params) {
47
+ const cacheKey = `search:${params.q}:${params.sort}:${params.order}:${params.per_page}`;
48
+ return cachedTimeBased(getHttpCache(), cacheKey, SEARCH_CACHE_TTL_MS, async () => {
49
+ const { data } = await this.octokit.search.issuesAndPullRequests(params);
50
+ return data;
51
+ });
52
+ }
38
53
  /**
39
54
  * Fetch the authenticated user's starred repositories from GitHub.
40
55
  * Updates the state manager with the list and timestamp.
@@ -292,7 +307,7 @@ export class IssueDiscovery {
292
307
  info(MODULE, 'Phase 2: General issue search...');
293
308
  const remainingNeeded = maxResults - allCandidates.length;
294
309
  try {
295
- const { data } = await this.octokit.search.issuesAndPullRequests({
310
+ const data = await this.cachedSearch({
296
311
  q: baseQuery,
297
312
  sort: 'created',
298
313
  order: 'desc',
@@ -334,7 +349,7 @@ export class IssueDiscovery {
334
349
  .replace(/ +/g, ' ')
335
350
  .trim();
336
351
  try {
337
- const { data } = await this.octokit.search.issuesAndPullRequests({
352
+ const data = await this.cachedSearch({
338
353
  q: phase3Query,
339
354
  sort: 'updated',
340
355
  order: 'desc',
@@ -434,7 +449,7 @@ export class IssueDiscovery {
434
449
  // Build repo filter: (repo:a OR repo:b OR repo:c)
435
450
  const repoFilter = batch.map((r) => `repo:${r}`).join(' OR ');
436
451
  const batchQuery = `${baseQuery} (${repoFilter})`;
437
- const { data } = await this.octokit.search.issuesAndPullRequests({
452
+ const data = await this.cachedSearch({
438
453
  q: batchQuery,
439
454
  sort: 'created',
440
455
  order: 'desc',
@@ -8,7 +8,7 @@ import { paginateAll } from './pagination.js';
8
8
  import { parseGitHubUrl, daysBetween } from './utils.js';
9
9
  import { ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
10
10
  import { warn } from './logger.js';
11
- import { getHttpCache, cachedRequest } from './http-cache.js';
11
+ import { getHttpCache, cachedRequest, cachedTimeBased } from './http-cache.js';
12
12
  import { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
13
13
  const MODULE = 'issue-vetting';
14
14
  // Concurrency limit for parallel API calls
@@ -16,6 +16,8 @@ const MAX_CONCURRENT_REQUESTS = 5;
16
16
  // Cache for contribution guidelines (expires after 1 hour, max 100 entries)
17
17
  const guidelinesCache = new Map();
18
18
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
19
+ /** TTL for cached project health results (4 hours). Health data (stars, commits, CI) changes slowly. */
20
+ const HEALTH_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
19
21
  const CACHE_MAX_SIZE = 100;
20
22
  function pruneCache() {
21
23
  const now = Date.now();
@@ -376,47 +378,50 @@ export class IssueVetter {
376
378
  }
377
379
  }
378
380
  async checkProjectHealth(owner, repo) {
381
+ const cache = getHttpCache();
382
+ const healthCacheKey = `health:${owner}/${repo}`;
379
383
  try {
380
- // Get repo info (with ETag caching repo metadata changes infrequently)
381
- const cache = getHttpCache();
382
- const url = `/repos/${owner}/${repo}`;
383
- const repoData = await cachedRequest(cache, url, (headers) => this.octokit.repos.get({ owner, repo, headers }));
384
- // Get recent commits
385
- const { data: commits } = await this.octokit.repos.listCommits({
386
- owner,
387
- repo,
388
- per_page: 1,
389
- });
390
- const lastCommit = commits[0];
391
- const lastCommitAt = lastCommit?.commit?.author?.date || repoData.pushed_at;
392
- const daysSinceLastCommit = daysBetween(new Date(lastCommitAt));
393
- // Check CI status (simplified - just check if workflows exist)
394
- let ciStatus = 'unknown';
395
- try {
396
- const { data: workflows } = await this.octokit.actions.listRepoWorkflows({
384
+ return await cachedTimeBased(cache, healthCacheKey, HEALTH_CACHE_TTL_MS, async () => {
385
+ // Get repo info (with ETag caching — repo metadata changes infrequently)
386
+ const url = `/repos/${owner}/${repo}`;
387
+ const repoData = await cachedRequest(cache, url, (headers) => this.octokit.repos.get({ owner, repo, headers }));
388
+ // Get recent commits
389
+ const { data: commits } = await this.octokit.repos.listCommits({
397
390
  owner,
398
391
  repo,
399
392
  per_page: 1,
400
393
  });
401
- if (workflows.total_count > 0) {
402
- ciStatus = 'passing'; // Assume passing if workflows exist
394
+ const lastCommit = commits[0];
395
+ const lastCommitAt = lastCommit?.commit?.author?.date || repoData.pushed_at;
396
+ const daysSinceLastCommit = daysBetween(new Date(lastCommitAt));
397
+ // Check CI status (simplified - just check if workflows exist)
398
+ let ciStatus = 'unknown';
399
+ try {
400
+ const { data: workflows } = await this.octokit.actions.listRepoWorkflows({
401
+ owner,
402
+ repo,
403
+ per_page: 1,
404
+ });
405
+ if (workflows.total_count > 0) {
406
+ ciStatus = 'passing'; // Assume passing if workflows exist
407
+ }
403
408
  }
404
- }
405
- catch (error) {
406
- const errMsg = errorMessage(error);
407
- warn(MODULE, `Failed to check CI status for ${owner}/${repo}: ${errMsg}. Defaulting to unknown.`);
408
- }
409
- return {
410
- repo: `${owner}/${repo}`,
411
- lastCommitAt,
412
- daysSinceLastCommit,
413
- openIssuesCount: repoData.open_issues_count,
414
- avgIssueResponseDays: 0, // Would need more API calls to calculate
415
- ciStatus,
416
- isActive: daysSinceLastCommit < 30,
417
- stargazersCount: repoData.stargazers_count,
418
- forksCount: repoData.forks_count,
419
- };
409
+ catch (error) {
410
+ const errMsg = errorMessage(error);
411
+ warn(MODULE, `Failed to check CI status for ${owner}/${repo}: ${errMsg}. Defaulting to unknown.`);
412
+ }
413
+ return {
414
+ repo: `${owner}/${repo}`,
415
+ lastCommitAt,
416
+ daysSinceLastCommit,
417
+ openIssuesCount: repoData.open_issues_count,
418
+ avgIssueResponseDays: 0, // Would need more API calls to calculate
419
+ ciStatus,
420
+ isActive: daysSinceLastCommit < 30,
421
+ stargazersCount: repoData.stargazers_count,
422
+ forksCount: repoData.forks_count,
423
+ };
424
+ });
420
425
  }
421
426
  catch (error) {
422
427
  const errMsg = errorMessage(error);
@@ -234,7 +234,10 @@ export class PRMonitor {
234
234
  const daysSinceActivity = daysBetween(new Date(ghPR.updated_at), new Date());
235
235
  // Find the date of the latest changes_requested review (delegated to review-analysis module)
236
236
  const latestChangesRequestedDate = getLatestChangesRequestedDate(reviews);
237
+ // Classify failing checks (delegated to ci-analysis module)
238
+ const classifiedChecks = classifyFailingChecks(failingCheckNames, failingCheckConclusions);
237
239
  // Determine status
240
+ const hasActionableCIFailure = ciStatus === 'failing' && classifiedChecks.some((c) => c.category === 'actionable');
238
241
  const status = this.determineStatus({
239
242
  ciStatus,
240
243
  hasMergeConflict,
@@ -247,9 +250,8 @@ export class PRMonitor {
247
250
  latestCommitDate,
248
251
  lastMaintainerCommentDate: lastMaintainerComment?.createdAt,
249
252
  latestChangesRequestedDate,
253
+ hasActionableCIFailure,
250
254
  });
251
- // Classify failing checks (delegated to ci-analysis module)
252
- const classifiedChecks = classifyFailingChecks(failingCheckNames, failingCheckConclusions);
253
255
  return this.buildFetchedPR({
254
256
  id: ghPR.id,
255
257
  url: prUrl,
@@ -293,7 +295,7 @@ export class PRMonitor {
293
295
  * Determine the overall status of a PR
294
296
  */
295
297
  determineStatus(input) {
296
- const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate, lastMaintainerCommentDate, latestChangesRequestedDate, } = input;
298
+ const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
297
299
  // Priority order: needs_response/needs_changes/changes_addressed > failing_ci > merge_conflict > incomplete_checklist > dormant > approaching_dormant > waiting_on_maintainer > waiting/healthy
298
300
  if (hasUnrespondedComment) {
299
301
  // If the contributor pushed a commit after the maintainer's comment,
@@ -304,8 +306,10 @@ export class PRMonitor {
304
306
  if (latestChangesRequestedDate && latestCommitDate < latestChangesRequestedDate) {
305
307
  return 'needs_response';
306
308
  }
307
- if (ciStatus === 'failing')
309
+ if (ciStatus === 'failing' && hasActionableCIFailure)
308
310
  return 'failing_ci';
311
+ // Non-actionable CI failures (infrastructure, fork, auth) don't block changes_addressed —
312
+ // the contributor can't fix them, so the relevant status is "waiting for re-review" (#502)
309
313
  return 'changes_addressed';
310
314
  }
311
315
  return 'needs_response';
@@ -317,12 +321,13 @@ export class PRMonitor {
317
321
  return 'needs_changes';
318
322
  }
319
323
  // Commit is after review — changes have been addressed
320
- if (ciStatus === 'failing')
324
+ if (ciStatus === 'failing' && hasActionableCIFailure)
321
325
  return 'failing_ci';
326
+ // Non-actionable CI failures don't block changes_addressed (#502)
322
327
  return 'changes_addressed';
323
328
  }
324
329
  if (ciStatus === 'failing') {
325
- return 'failing_ci';
330
+ return hasActionableCIFailure ? 'failing_ci' : 'ci_blocked';
326
331
  }
327
332
  if (hasMergeConflict) {
328
333
  return 'merge_conflict';
@@ -67,7 +67,17 @@ export function isAllSelfReplies(reviewId, reviewComments) {
67
67
  return false; // New thread, not a reply
68
68
  const parentAuthor = authorMap.get(comment.in_reply_to_id);
69
69
  const commentAuthor = comment.user?.login?.toLowerCase();
70
- return parentAuthor != null && commentAuthor != null && parentAuthor === commentAuthor;
70
+ if (parentAuthor == null || commentAuthor == null || parentAuthor !== commentAuthor)
71
+ return false;
72
+ // A self-reply containing a question mark is likely a follow-up question
73
+ // directed at the PR author, not an informational addendum (#498).
74
+ // Null/empty body on a self-reply is anomalous — surface it rather than
75
+ // silently filtering, since the safe direction is to notify.
76
+ if (!comment.body)
77
+ return false;
78
+ if (comment.body.includes('?'))
79
+ return false;
80
+ return true;
71
81
  });
72
82
  }
73
83
  /**
@@ -13,6 +13,8 @@ const MODULE = 'state';
13
13
  const CURRENT_STATE_VERSION = 2;
14
14
  // Maximum number of events to retain in the event log
15
15
  const MAX_EVENTS = 1000;
16
+ /** Repo scores older than this are considered stale and excluded from low-scoring lists. */
17
+ const SCORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
16
18
  // Lock file timeout: if a lock is older than this, it is considered stale
17
19
  const LOCK_TIMEOUT_MS = 30_000; // 30 seconds
18
20
  // Legacy path for migration
@@ -1026,8 +1028,19 @@ export class StateManager {
1026
1028
  */
1027
1029
  getLowScoringRepos(maxScore) {
1028
1030
  const threshold = maxScore ?? this.state.config.minRepoScoreThreshold;
1031
+ const now = Date.now();
1029
1032
  return Object.values(this.state.repoScores)
1030
- .filter((rs) => rs.score <= threshold)
1033
+ .filter((rs) => {
1034
+ if (rs.score > threshold)
1035
+ return false;
1036
+ // Stale scores (>30 days) should not permanently block repos (#487)
1037
+ const age = now - new Date(rs.lastEvaluatedAt).getTime();
1038
+ if (!Number.isFinite(age)) {
1039
+ warn(MODULE, `Invalid lastEvaluatedAt for repo ${rs.repo}: "${rs.lastEvaluatedAt}", treating as stale`);
1040
+ return false;
1041
+ }
1042
+ return age <= SCORE_TTL_MS;
1043
+ })
1031
1044
  .sort((a, b) => a.score - b.score)
1032
1045
  .map((rs) => rs.repo);
1033
1046
  }
@@ -54,6 +54,8 @@ export interface DetermineStatusInput {
54
54
  latestCommitDate?: string;
55
55
  lastMaintainerCommentDate?: string;
56
56
  latestChangesRequestedDate?: string;
57
+ /** True if at least one failing CI check is classified as 'actionable'. */
58
+ hasActionableCIFailure?: boolean;
57
59
  }
58
60
  /**
59
61
  * Computed status for a {@link FetchedPR}, determined by `PRMonitor.determineStatus()`.
@@ -62,8 +64,7 @@ export interface DetermineStatusInput {
62
64
  * **Action required (contributor must act):**
63
65
  * - `needs_response` — Maintainer commented after the contributor's last activity
64
66
  * - `needs_changes` — Reviewer requested changes (via review, not just a comment)
65
- * - `failing_ci` — One or more CI checks are failing
66
- * - `ci_blocked` — CI cannot run (e.g., first-time contributor approval needed) *(reserved)*
67
+ * - `failing_ci` — One or more CI checks are failing (at least one is actionable)
67
68
  * - `ci_not_running` — No CI checks have been triggered *(reserved)*
68
69
  * - `merge_conflict` — PR has merge conflicts with the base branch
69
70
  * - `needs_rebase` — PR branch is significantly behind upstream *(reserved)*
@@ -71,6 +72,7 @@ export interface DetermineStatusInput {
71
72
  * - `incomplete_checklist` — PR body has unchecked required checkboxes
72
73
  *
73
74
  * **Waiting (no action needed right now):**
75
+ * - `ci_blocked` — All failing CI checks are non-actionable (infrastructure, fork limitation, auth gate)
74
76
  * - `changes_addressed` — Contributor pushed commits after reviewer feedback; awaiting re-review
75
77
  * - `waiting` — CI is pending or no specific action needed
76
78
  * - `waiting_on_maintainer` — PR is approved and CI passes; waiting for maintainer to merge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,7 +51,7 @@
51
51
  "commander": "^14.0.3"
52
52
  },
53
53
  "devDependencies": {
54
- "@types/node": "^25.3.0",
54
+ "@types/node": "^20.0.0",
55
55
  "@vitest/coverage-v8": "^4.0.18",
56
56
  "esbuild": "^0.27.3",
57
57
  "tsx": "^4.21.0",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsc",
63
- "bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --outfile=dist/cli.bundle.cjs",
63
+ "bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --sourcemap --outfile=dist/cli.bundle.cjs",
64
64
  "start": "tsx src/cli.ts",
65
65
  "dev": "tsx watch src/cli.ts",
66
66
  "typecheck": "tsc --noEmit",