@oss-autopilot/core 1.16.2 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/cli-registry.js +53 -11
  2. package/dist/cli.bundle.cjs +82 -69
  3. package/dist/cli.js +22 -10
  4. package/dist/commands/comments.js +38 -20
  5. package/dist/commands/config.d.ts +9 -2
  6. package/dist/commands/config.js +12 -3
  7. package/dist/commands/daily.d.ts +3 -1
  8. package/dist/commands/daily.js +126 -37
  9. package/dist/commands/dashboard-data.d.ts +26 -2
  10. package/dist/commands/dashboard-data.js +45 -19
  11. package/dist/commands/dashboard-server.d.ts +1 -1
  12. package/dist/commands/dashboard-server.js +104 -19
  13. package/dist/commands/dismiss.js +4 -1
  14. package/dist/commands/doctor.d.ts +49 -0
  15. package/dist/commands/doctor.js +358 -0
  16. package/dist/commands/index.d.ts +2 -0
  17. package/dist/commands/index.js +2 -0
  18. package/dist/commands/move.d.ts +1 -2
  19. package/dist/commands/move.js +8 -4
  20. package/dist/commands/read.js +2 -1
  21. package/dist/commands/search.d.ts +0 -18
  22. package/dist/commands/search.js +38 -1
  23. package/dist/commands/setup.js +42 -2
  24. package/dist/commands/shelve.js +4 -1
  25. package/dist/commands/skip-add.js +1 -1
  26. package/dist/commands/startup.js +14 -4
  27. package/dist/commands/track.js +2 -1
  28. package/dist/commands/vet-list.d.ts +23 -2
  29. package/dist/commands/vet-list.js +57 -10
  30. package/dist/core/anti-llm-policy.d.ts +5 -0
  31. package/dist/core/anti-llm-policy.js +5 -0
  32. package/dist/core/ci-analysis.js +6 -1
  33. package/dist/core/config-registry.d.ts +44 -0
  34. package/dist/core/config-registry.js +286 -0
  35. package/dist/core/dashboard-data-schema.d.ts +78 -0
  36. package/dist/core/dashboard-data-schema.js +80 -0
  37. package/dist/core/errors.d.ts +14 -0
  38. package/dist/core/errors.js +22 -0
  39. package/dist/core/http-cache.d.ts +8 -1
  40. package/dist/core/http-cache.js +59 -1
  41. package/dist/core/index.d.ts +3 -1
  42. package/dist/core/index.js +3 -1
  43. package/dist/core/maintainer-analysis.js +9 -3
  44. package/dist/core/pr-monitor.d.ts +7 -0
  45. package/dist/core/pr-monitor.js +45 -4
  46. package/dist/core/repo-score-manager.d.ts +17 -3
  47. package/dist/core/repo-score-manager.js +48 -19
  48. package/dist/core/state-persistence.d.ts +14 -1
  49. package/dist/core/state-persistence.js +24 -2
  50. package/dist/core/state-schema.d.ts +2 -0
  51. package/dist/core/state-schema.js +5 -0
  52. package/dist/core/state.d.ts +26 -2
  53. package/dist/core/state.js +50 -5
  54. package/dist/core/status-determination.d.ts +16 -0
  55. package/dist/core/status-determination.js +44 -11
  56. package/dist/formatters/json.d.ts +40 -2
  57. package/dist/formatters/json.js +1 -0
  58. package/package.json +1 -1
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Runtime schema for the dashboard server's `GET /api/data` response (#1050).
3
+ *
4
+ * The server (`commands/dashboard-data.ts`) and client (`packages/dashboard/`)
5
+ * run in different processes. TypeScript can't cross the process boundary —
6
+ * if the server removes or renames a field, the client hits runtime
7
+ * `undefined` with no diagnostic. This schema is the shared runtime contract:
8
+ * the server can optionally self-check outgoing payloads, and the dashboard
9
+ * validates every `/api/data` response before committing to state.
10
+ *
11
+ * Intentional scope: this schema validates **top-level presence and primitive
12
+ * shape** (required vs optional fields, container types like array/object),
13
+ * but uses `z.array(z.unknown())` for nested PR/issue arrays rather than
14
+ * pinning every field. The top-level surface is the only place drift tends
15
+ * to go silently undetected — nested fields already blow up loudly at the
16
+ * render site when they change. Exhaustive nested validation would turn the
17
+ * schema into a maintenance burden.
18
+ */
19
+ import { z } from 'zod';
20
+ export const DashboardStatsSchema = z.object({
21
+ activePRs: z.number(),
22
+ shelvedPRs: z.number(),
23
+ mergedPRs: z.number(),
24
+ closedPRs: z.number(),
25
+ mergeRate: z.string(),
26
+ availableIssues: z.number().optional(),
27
+ });
28
+ const RepoStatsEntrySchema = z.object({
29
+ active: z.number(),
30
+ merged: z.number(),
31
+ closed: z.number(),
32
+ });
33
+ const TopRepoSchema = z.object({
34
+ repo: z.string(),
35
+ active: z.number(),
36
+ merged: z.number(),
37
+ closed: z.number(),
38
+ });
39
+ export const DashboardDataSchema = z.object({
40
+ stats: DashboardStatsSchema,
41
+ prsByRepo: z.record(z.string(), RepoStatsEntrySchema),
42
+ topRepos: z.array(TopRepoSchema),
43
+ monthlyMerged: z.record(z.string(), z.number()),
44
+ monthlyOpened: z.record(z.string(), z.number()),
45
+ monthlyClosed: z.record(z.string(), z.number()),
46
+ // PR / issue arrays are validated loosely — the domain shapes are pinned by
47
+ // AgentStateSchema (state-schema.ts) on the server side. Use `z.unknown()`
48
+ // here so server-side additions to the nested shape don't break the
49
+ // dashboard's parse; the UI handles unknown extra fields gracefully.
50
+ activePRs: z.array(z.unknown()),
51
+ shelvedPRUrls: z.array(z.string()),
52
+ recentlyMergedPRs: z.array(z.unknown()),
53
+ recentlyClosedPRs: z.array(z.unknown()),
54
+ autoUnshelvedPRs: z.array(z.unknown()),
55
+ commentedIssues: z.array(z.unknown()),
56
+ issueResponses: z.array(z.unknown()),
57
+ allMergedPRs: z.array(z.unknown()),
58
+ allClosedPRs: z.array(z.unknown()),
59
+ repoMetadata: z.record(z.string(), z.unknown()).optional(),
60
+ vettedIssues: z.unknown().nullable().optional(),
61
+ offline: z.boolean().optional(),
62
+ lastUpdated: z.string().optional(),
63
+ partialFailures: z.array(z.string()).optional(),
64
+ });
65
+ /**
66
+ * Validate a raw `/api/data` payload. Returns `{ok: true, data}` on success or
67
+ * `{ok: false, message}` with a condensed Zod error string on failure. Never
68
+ * throws. The dashboard's `useDashboard` hook surfaces the message in the UI.
69
+ */
70
+ export function validateDashboardData(raw) {
71
+ const result = DashboardDataSchema.safeParse(raw);
72
+ if (result.success)
73
+ return { ok: true, data: result.data };
74
+ const issues = result.error.issues
75
+ .slice(0, 3)
76
+ .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
77
+ .join('; ');
78
+ const more = result.error.issues.length > 3 ? ` (+${result.error.issues.length - 3} more)` : '';
79
+ return { ok: false, message: `Server response did not match expected shape — ${issues}${more}` };
80
+ }
@@ -32,6 +32,20 @@ export declare class ValidationError extends OssAutopilotError {
32
32
  export declare class GistPermissionError extends ConfigurationError {
33
33
  constructor(message?: string);
34
34
  }
35
+ /**
36
+ * Thrown when an optimistic compare-and-swap on state.json detects that
37
+ * another process wrote the file between load and save. See issue #1030.
38
+ *
39
+ * Library consumers should call `stateManager.reloadIfChanged()` and
40
+ * re-apply their mutation. The runtime `message` is phrased for CLI
41
+ * end-users; the structured `expectedMtimeMs` / `actualMtimeMs` fields
42
+ * are for programmatic handling.
43
+ */
44
+ export declare class ConcurrencyError extends OssAutopilotError {
45
+ readonly expectedMtimeMs: number;
46
+ readonly actualMtimeMs: number;
47
+ constructor(expectedMtimeMs: number, actualMtimeMs: number);
48
+ }
35
49
  /**
36
50
  * Extract a human-readable message from an unknown error value.
37
51
  */
@@ -49,6 +49,26 @@ export class GistPermissionError extends ConfigurationError {
49
49
  this.name = 'GistPermissionError';
50
50
  }
51
51
  }
52
+ /**
53
+ * Thrown when an optimistic compare-and-swap on state.json detects that
54
+ * another process wrote the file between load and save. See issue #1030.
55
+ *
56
+ * Library consumers should call `stateManager.reloadIfChanged()` and
57
+ * re-apply their mutation. The runtime `message` is phrased for CLI
58
+ * end-users; the structured `expectedMtimeMs` / `actualMtimeMs` fields
59
+ * are for programmatic handling.
60
+ */
61
+ export class ConcurrencyError extends OssAutopilotError {
62
+ expectedMtimeMs;
63
+ actualMtimeMs;
64
+ constructor(expectedMtimeMs, actualMtimeMs) {
65
+ super('Another oss-autopilot process wrote state.json concurrently. ' +
66
+ 'Re-run the command to retry — the last write wins and no data was lost from the other process.', 'CONCURRENCY_ERROR');
67
+ this.expectedMtimeMs = expectedMtimeMs;
68
+ this.actualMtimeMs = actualMtimeMs;
69
+ this.name = 'ConcurrencyError';
70
+ }
71
+ }
52
72
  /**
53
73
  * Extract a human-readable message from an unknown error value.
54
74
  */
@@ -126,6 +146,8 @@ export function resolveErrorCode(err) {
126
146
  return 'CONFIGURATION';
127
147
  if (err instanceof ValidationError)
128
148
  return 'VALIDATION';
149
+ if (err instanceof ConcurrencyError)
150
+ return 'CONCURRENCY';
129
151
  // Check HTTP status codes (Octokit errors)
130
152
  const status = getHttpStatusCode(err);
131
153
  if (status === 401)
@@ -25,9 +25,10 @@ export interface CacheEntry {
25
25
  */
26
26
  export declare class HttpCache {
27
27
  private readonly cacheDir;
28
+ private readonly maxEntries;
28
29
  /** In-flight request deduplication map: URL -> Promise<response>. */
29
30
  private readonly inflightRequests;
30
- constructor(cacheDir?: string);
31
+ constructor(cacheDir?: string, maxEntries?: number);
31
32
  /** Derive a filesystem-safe cache key from a URL. */
32
33
  private keyFor;
33
34
  /** Full path to the cache file for a given URL. */
@@ -46,6 +47,12 @@ export declare class HttpCache {
46
47
  * Store a response with its ETag.
47
48
  */
48
49
  set(url: string, etag: string, body: unknown): void;
50
+ /**
51
+ * If the cache directory exceeds `maxEntries`, evict the oldest entries
52
+ * (by mtime) until it's at the cap. Best-effort — any I/O failure is
53
+ * swallowed so cache-bookkeeping never breaks the request path.
54
+ */
55
+ private evictIfExceeds;
49
56
  /**
50
57
  * Check whether a URL has an in-flight request.
51
58
  */
@@ -23,6 +23,15 @@ const MODULE = 'http-cache';
23
23
  * `evictStale()` will remove them.
24
24
  */
25
25
  const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
26
+ /**
27
+ * Soft cap on the number of entries kept on disk (#1057 M27). `set()`
28
+ * opportunistically evicts the oldest entries when the directory grows past
29
+ * this ceiling. The cap is deliberately high (2000) so it doesn't thrash a
30
+ * normal day's traffic — it's a belt-and-suspenders guard against unbounded
31
+ * growth on long-running sessions that accumulate cache files between the
32
+ * daily `evictStale()` sweep.
33
+ */
34
+ const DEFAULT_MAX_ENTRIES = 2000;
26
35
  /**
27
36
  * File-based HTTP cache backed by `~/.oss-autopilot/cache/`.
28
37
  *
@@ -32,10 +41,12 @@ const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
32
41
  */
33
42
  export class HttpCache {
34
43
  cacheDir;
44
+ maxEntries;
35
45
  /** In-flight request deduplication map: URL -> Promise<response>. */
36
46
  inflightRequests = new Map();
37
- constructor(cacheDir) {
47
+ constructor(cacheDir, maxEntries = DEFAULT_MAX_ENTRIES) {
38
48
  this.cacheDir = cacheDir ?? getCacheDir();
49
+ this.maxEntries = maxEntries;
39
50
  }
40
51
  /** Derive a filesystem-safe cache key from a URL. */
41
52
  keyFor(url) {
@@ -91,12 +102,59 @@ export class HttpCache {
91
102
  try {
92
103
  fs.writeFileSync(this.pathFor(url), JSON.stringify(entry), { encoding: 'utf-8', mode: 0o600 });
93
104
  debug(MODULE, `Cached response for ${url}`);
105
+ // Best-effort size cap (#1057 M27). Runs after each write rather than on
106
+ // a schedule so long-lived sessions can't accumulate past the cap.
107
+ this.evictIfExceeds(this.maxEntries);
94
108
  }
95
109
  catch (err) {
96
110
  // Non-fatal: cache write failure should not break the request
97
111
  debug(MODULE, `Failed to write cache for ${url}`, err);
98
112
  }
99
113
  }
114
+ /**
115
+ * If the cache directory exceeds `maxEntries`, evict the oldest entries
116
+ * (by mtime) until it's at the cap. Best-effort — any I/O failure is
117
+ * swallowed so cache-bookkeeping never breaks the request path.
118
+ */
119
+ evictIfExceeds(maxEntries) {
120
+ if (maxEntries <= 0)
121
+ return;
122
+ try {
123
+ const entries = fs.readdirSync(this.cacheDir).filter((f) => f.endsWith('.json'));
124
+ if (entries.length <= maxEntries)
125
+ return;
126
+ // Stat once; sort oldest first so we evict the stalest files.
127
+ const withMtime = entries
128
+ .map((file) => {
129
+ const fullPath = path.join(this.cacheDir, file);
130
+ try {
131
+ return { fullPath, mtimeMs: fs.statSync(fullPath).mtimeMs };
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ })
137
+ .filter((e) => e !== null)
138
+ .sort((a, b) => a.mtimeMs - b.mtimeMs);
139
+ const toEvict = withMtime.length - maxEntries;
140
+ let evicted = 0;
141
+ for (let i = 0; i < toEvict; i++) {
142
+ try {
143
+ fs.unlinkSync(withMtime[i].fullPath);
144
+ evicted++;
145
+ }
146
+ catch {
147
+ // Another process may have raced us — ignore.
148
+ }
149
+ }
150
+ if (evicted > 0) {
151
+ debug(MODULE, `Capped cache at ${maxEntries} entries: evicted ${evicted} oldest`);
152
+ }
153
+ }
154
+ catch {
155
+ // Cache dir missing / unreadable — not fatal.
156
+ }
157
+ }
100
158
  /**
101
159
  * Check whether a URL has an in-flight request.
102
160
  */
@@ -2,7 +2,7 @@
2
2
  * Core module exports
3
3
  * Re-exports all core functionality for convenient imports
4
4
  */
5
- export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, resetStateManager, type Stats, } from './state.js';
5
+ export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
6
6
  export { GistStateStore } from './gist-state-store.js';
7
7
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
8
8
  export { IssueConversationMonitor } from './issue-conversation.js';
@@ -18,4 +18,6 @@ export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
18
18
  export { classifyLinkedPR, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
19
19
  export { scanForAntiLLMPolicy, type AntiLLMCategory, type AntiLLMMatch, type AntiLLMScanResult, } from './anti-llm-policy.js';
20
20
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, type DetectedFormatter, type FormatterDetectionResult, type CIFormatterDiagnosis, type FormatterName, } from './formatter-detection.js';
21
+ export { CONFIG_KEY_REGISTRY, type ConfigKeyDef, type SettableVia, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
22
+ export { DashboardDataSchema, DashboardStatsSchema, validateDashboardData, type DashboardDataParsed, } from './dashboard-data-schema.js';
21
23
  export * from './types.js';
@@ -2,7 +2,7 @@
2
2
  * Core module exports
3
3
  * Re-exports all core functionality for convenient imports
4
4
  */
5
- export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, resetStateManager, } from './state.js';
5
+ export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, } from './state.js';
6
6
  export { GistStateStore } from './gist-state-store.js';
7
7
  export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
8
8
  // Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
@@ -19,4 +19,6 @@ export { fetchPRTemplate } from './pr-template.js';
19
19
  export { classifyLinkedPR, } from './linked-pr-classification.js';
20
20
  export { scanForAntiLLMPolicy, } from './anti-llm-policy.js';
21
21
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, } from './formatter-detection.js';
22
+ export { CONFIG_KEY_REGISTRY, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
23
+ export { DashboardDataSchema, DashboardStatsSchema, validateDashboardData, } from './dashboard-data-schema.js';
22
24
  export * from './types.js';
@@ -50,9 +50,15 @@ export function extractMaintainerActionHints(commentBody, reviewDecision) {
50
50
  if (docKeywords.some((kw) => lower.includes(kw))) {
51
51
  hints.push('docs_requested');
52
52
  }
53
- // Rebase requests
54
- const rebaseKeywords = ['rebase', 'merge conflict', 'out of date', 'behind main', 'behind master'];
55
- if (rebaseKeywords.some((kw) => lower.includes(kw))) {
53
+ // Rebase requests.
54
+ //
55
+ // The `rebase` term uses a word-boundary regex so past-tense mentions like
56
+ // "after rebasing this was fine" or "I already rebased this" don't trigger
57
+ // a rebase_requested hint (#1057 M30). The other phrases are specific
58
+ // enough that plain substring matching is sufficient.
59
+ const rebasePhrases = ['merge conflict', 'out of date', 'behind main', 'behind master'];
60
+ const hasRebaseWord = /\brebase\b/i.test(commentBody);
61
+ if (hasRebaseWord || rebasePhrases.some((kw) => lower.includes(kw))) {
56
62
  hints.push('rebase_requested');
57
63
  }
58
64
  return hints;
@@ -33,6 +33,13 @@ export interface PRCheckFailure {
33
33
  export interface FetchPRsResult {
34
34
  prs: FetchedPR[];
35
35
  failures: PRCheckFailure[];
36
+ /**
37
+ * Non-fatal warnings accumulated while fetching. Currently populated when
38
+ * the GitHub Search API's 1000-result ceiling truncates the user's PR
39
+ * list — callers (daily, dashboard) surface these so users know the data
40
+ * may be incomplete (#1057 M25).
41
+ */
42
+ warnings?: string[];
36
43
  }
37
44
  /**
38
45
  * Fetches and enriches open PRs from GitHub for the configured user.
@@ -17,7 +17,7 @@ import { getStateManager } from './state.js';
17
17
  import { daysBetween, parseGitHubUrl, extractOwnerRepo, isOwnRepo, DEFAULT_CONCURRENCY } from './utils.js';
18
18
  import { determineStatus } from './status-determination.js';
19
19
  import { runWorkerPool } from './concurrency.js';
20
- import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
20
+ import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitOrAuthError, } from './errors.js';
21
21
  import { paginateAll } from './pagination.js';
22
22
  import { debug, warn, timed } from './logger.js';
23
23
  import { getHttpCache, cachedRequest } from './http-cache.js';
@@ -96,9 +96,50 @@ export class PRMonitor {
96
96
  });
97
97
  allItems.push(...firstPage.data.items);
98
98
  const totalCount = firstPage.data.total_count;
99
- debug('pr-monitor', `Found ${totalCount} open PRs`);
99
+ debug(MODULE, `Found ${totalCount} open PRs`);
100
100
  // Fetch remaining pages if needed (GitHub search API returns max 1000 results)
101
- const totalPages = Math.min(Math.ceil(totalCount / perPage), 10); // Cap at 1000 results
101
+ const SEARCH_API_RESULT_CAP = 1000;
102
+ const MAX_PAGES = Math.ceil(SEARCH_API_RESULT_CAP / perPage); // 10 pages at per_page=100
103
+ const totalPages = Math.min(Math.ceil(totalCount / perPage), MAX_PAGES);
104
+ // Non-fatal warnings threaded into the result (#1057 M25). When the
105
+ // Search API's hard 1000-result ceiling truncates the user's PR list we
106
+ // previously silently dropped the overflow; now the caller can surface
107
+ // it so the daily digest doesn't quietly report a partial view.
108
+ const warnings = [];
109
+ // Guardrail: if the Search API returned zero PRs, cross-check the
110
+ // configured username against the authenticated viewer. A real failure
111
+ // mode was a stale/placeholder username (e.g. "example-user") silently
112
+ // producing zero results with no error — the dashboard just showed
113
+ // "0 active PRs" and looked like a fresh install. getAuthenticated is
114
+ // advisory; a failure here never breaks the fetch.
115
+ if (totalCount === 0) {
116
+ try {
117
+ const { data: viewer } = await this.octokit.users.getAuthenticated();
118
+ if (viewer.login.toLowerCase() !== config.githubUsername.toLowerCase()) {
119
+ const message = `Configured GitHub username @${config.githubUsername} does not match ` +
120
+ `authenticated user @${viewer.login}. Did you mean to run ` +
121
+ `\`oss-autopilot config username ${viewer.login}\`? Zero PRs returned.`;
122
+ warnings.push(message);
123
+ warn(MODULE, message);
124
+ }
125
+ }
126
+ catch (err) {
127
+ // Rate-limit/401/403 errors must abort the run just like every sibling
128
+ // fetch in this pipeline — swallowing them here would mask the exact
129
+ // class of failure the guardrail is meant to surface (e.g. revoked
130
+ // token returning 401 while the unauthenticated Search above still
131
+ // succeeds with zero results).
132
+ if (isRateLimitOrAuthError(err))
133
+ throw err;
134
+ debug(MODULE, `Could not cross-check viewer login: ${errorMessage(err)}`);
135
+ }
136
+ }
137
+ if (totalCount > SEARCH_API_RESULT_CAP) {
138
+ warnings.push(`GitHub Search API returned ${totalCount} PRs for @${config.githubUsername}, ` +
139
+ `but results are capped at ${SEARCH_API_RESULT_CAP}. ` +
140
+ `Showing the ${SEARCH_API_RESULT_CAP} most recently updated PRs.`);
141
+ warn(MODULE, warnings[warnings.length - 1]);
142
+ }
102
143
  while (page < totalPages) {
103
144
  page++;
104
145
  const nextPage = await this.octokit.search.issuesAndPullRequests({
@@ -149,7 +190,7 @@ export class PRMonitor {
149
190
  return 0;
150
191
  return a.status === 'needs_addressing' ? -1 : 1;
151
192
  });
152
- return { prs, failures };
193
+ return warnings.length > 0 ? { prs, failures, warnings } : { prs, failures };
153
194
  }
154
195
  /**
155
196
  * Fetch detailed information for a single PR
@@ -3,12 +3,26 @@
3
3
  * Functions that operate on AgentState for scoring, querying,
4
4
  * and computing aggregate statistics. Mutation functions modify
5
5
  * the passed state object in place; query functions are pure.
6
+ *
7
+ * **User-facing reference:** `docs/repo-scoring.md` — plain-language
8
+ * explanation of the formula and what a given score means.
6
9
  */
7
10
  import { AgentState, RepoScore, RepoScoreUpdate, StoredMergedPR, StoredClosedPR } from './types.js';
8
11
  /**
9
- * Calculate the score based on the repo's metrics.
10
- * Base 5, logarithmic merge bonus (max +5), -1 per closed without merge (max -3),
11
- * +1 if recently merged (within 90 days), +1 if responsive, -2 if hostile. Clamp 1-10.
12
+ * Calculate a 1–10 score for a repo based on the user's PR history and the
13
+ * repo's maintainer-health signals.
14
+ *
15
+ * Formula (all constants named above with rationale):
16
+ * BASE_SCORE
17
+ * + min(round(log2(merged + 1) * MERGE_BONUS_COEFFICIENT), MERGE_BONUS_CAP)
18
+ * − min(closedWithoutMergeCount, CLOSED_PENALTY_CAP)
19
+ * + (lastMergedAt within RECENCY_WINDOW_DAYS ? RECENCY_BONUS : 0)
20
+ * + (isResponsive ? RESPONSIVENESS_BONUS : 0)
21
+ * − (hasHostileComments ? HOSTILITY_PENALTY : 0)
22
+ * clamped to [SCORE_MIN, SCORE_MAX].
23
+ *
24
+ * See `docs/repo-scoring.md` for user-facing intent and what a given
25
+ * score means in practice.
12
26
  */
13
27
  export declare function calculateScore(repoScore: RepoScore): number;
14
28
  /**
@@ -3,11 +3,36 @@
3
3
  * Functions that operate on AgentState for scoring, querying,
4
4
  * and computing aggregate statistics. Mutation functions modify
5
5
  * the passed state object in place; query functions are pure.
6
+ *
7
+ * **User-facing reference:** `docs/repo-scoring.md` — plain-language
8
+ * explanation of the formula and what a given score means.
6
9
  */
7
10
  import { isBelowMinStars } from './types.js';
8
11
  import { debug, warn } from './logger.js';
9
12
  import { parseGitHubUrl } from './utils.js';
10
13
  const MODULE = 'scoring';
14
+ // ── Scoring constants (#1054) ─────────────────────────────────────────
15
+ // Previously inlined as magic numbers in `calculateScore`. Extracted with
16
+ // rationale comments so the formula is auditable without source spelunking.
17
+ // Changing any of these is a behavior change — update docs/repo-scoring.md
18
+ // and the tests below in lockstep.
19
+ /** Starting point before any signals are applied. Deliberately optimistic so first-time repos aren't punished. */
20
+ const BASE_SCORE = 5;
21
+ /** Logarithmic merge bonus: 1 merge→+2, 2→+3, 3→+4, 4+→+5. Log instead of linear so a 20th merge doesn't dominate. */
22
+ const MERGE_BONUS_COEFFICIENT = 2;
23
+ const MERGE_BONUS_CAP = 5;
24
+ /** −1 per closed-without-merge PR, capped so a few rejections don't zero out an otherwise-healthy repo. */
25
+ const CLOSED_PENALTY_CAP = 3;
26
+ /** Repos whose most-recent merge is within this window get a +1 freshness bonus. */
27
+ const RECENCY_WINDOW_DAYS = 90;
28
+ const RECENCY_BONUS = 1;
29
+ /** +1 when the repo's signals say maintainers respond to PRs (per-PR computed; see issue-grading.ts). */
30
+ const RESPONSIVENESS_BONUS = 1;
31
+ /** −2 when hostile maintainer comments have been detected (e.g., explicit rejection of PRs without review). */
32
+ const HOSTILITY_PENALTY = 2;
33
+ /** Final clamp — scores always land in this inclusive range. */
34
+ const SCORE_MIN = 1;
35
+ const SCORE_MAX = 10;
11
36
  /** Repo scores older than this are considered stale and excluded from low-scoring lists. */
12
37
  const SCORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
13
38
  /**
@@ -16,7 +41,7 @@ const SCORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
16
41
  function createDefaultRepoScore(repo) {
17
42
  return {
18
43
  repo,
19
- score: 5, // Base score
44
+ score: BASE_SCORE,
20
45
  mergedPRCount: 0,
21
46
  closedWithoutMergeCount: 0,
22
47
  avgResponseDays: null,
@@ -29,21 +54,28 @@ function createDefaultRepoScore(repo) {
29
54
  };
30
55
  }
31
56
  /**
32
- * Calculate the score based on the repo's metrics.
33
- * Base 5, logarithmic merge bonus (max +5), -1 per closed without merge (max -3),
34
- * +1 if recently merged (within 90 days), +1 if responsive, -2 if hostile. Clamp 1-10.
57
+ * Calculate a 1–10 score for a repo based on the user's PR history and the
58
+ * repo's maintainer-health signals.
59
+ *
60
+ * Formula (all constants named above with rationale):
61
+ * BASE_SCORE
62
+ * + min(round(log2(merged + 1) * MERGE_BONUS_COEFFICIENT), MERGE_BONUS_CAP)
63
+ * − min(closedWithoutMergeCount, CLOSED_PENALTY_CAP)
64
+ * + (lastMergedAt within RECENCY_WINDOW_DAYS ? RECENCY_BONUS : 0)
65
+ * + (isResponsive ? RESPONSIVENESS_BONUS : 0)
66
+ * − (hasHostileComments ? HOSTILITY_PENALTY : 0)
67
+ * clamped to [SCORE_MIN, SCORE_MAX].
68
+ *
69
+ * See `docs/repo-scoring.md` for user-facing intent and what a given
70
+ * score means in practice.
35
71
  */
36
72
  export function calculateScore(repoScore) {
37
- let score = 5; // Base score
38
- // Logarithmic merge bonus (max +5): 1→+2, 2→+3, 3→+4, 4+→+5
73
+ let score = BASE_SCORE;
39
74
  if (repoScore.mergedPRCount > 0) {
40
- const mergedBonus = Math.min(Math.round(Math.log2(repoScore.mergedPRCount + 1) * 2), 5);
75
+ const mergedBonus = Math.min(Math.round(Math.log2(repoScore.mergedPRCount + 1) * MERGE_BONUS_COEFFICIENT), MERGE_BONUS_CAP);
41
76
  score += mergedBonus;
42
77
  }
43
- // -1 per closed without merge (max -3)
44
- const closedPenalty = Math.min(repoScore.closedWithoutMergeCount, 3);
45
- score -= closedPenalty;
46
- // +1 if lastMergedAt is set and within 90 days (recency)
78
+ score -= Math.min(repoScore.closedWithoutMergeCount, CLOSED_PENALTY_CAP);
47
79
  if (repoScore.lastMergedAt) {
48
80
  const lastMergedDate = new Date(repoScore.lastMergedAt);
49
81
  if (isNaN(lastMergedDate.getTime())) {
@@ -52,21 +84,18 @@ export function calculateScore(repoScore) {
52
84
  else {
53
85
  const msPerDay = 1000 * 60 * 60 * 24;
54
86
  const daysSince = Math.floor((Date.now() - lastMergedDate.getTime()) / msPerDay);
55
- if (daysSince <= 90) {
56
- score += 1;
87
+ if (daysSince <= RECENCY_WINDOW_DAYS) {
88
+ score += RECENCY_BONUS;
57
89
  }
58
90
  }
59
91
  }
60
- // +1 if responsive
61
92
  if (repoScore.signals.isResponsive) {
62
- score += 1;
93
+ score += RESPONSIVENESS_BONUS;
63
94
  }
64
- // -2 if hostile
65
95
  if (repoScore.signals.hasHostileComments) {
66
- score -= 2;
96
+ score -= HOSTILITY_PENALTY;
67
97
  }
68
- // Clamp to 1-10
69
- return Math.max(1, Math.min(10, score));
98
+ return Math.max(SCORE_MIN, Math.min(SCORE_MAX, score));
70
99
  }
71
100
  /**
72
101
  * Get the score record for a repository.
@@ -52,9 +52,22 @@ export declare function loadState(): {
52
52
  /**
53
53
  * Persist state to disk, creating a timestamped backup of the previous
54
54
  * state file first. Retains at most 10 backup files.
55
+ *
56
+ * When `expectedMtimeMs` is provided (non-null, non-zero), implements
57
+ * optimistic compare-and-swap: if the on-disk file has been modified since
58
+ * the caller last loaded it, throws `ConcurrencyError` instead of
59
+ * overwriting. This prevents the classic read-modify-write lost-update
60
+ * race across processes (see issue #1030). Pass `null` / `0` to disable
61
+ * the check (first write, or when the caller has already reloaded).
62
+ *
63
+ * The check runs *inside* the advisory lock so the compare-and-swap is
64
+ * atomic with respect to the write.
65
+ *
55
66
  * @returns The file's mtime after writing (for change detection).
67
+ * @throws ConcurrencyError when `expectedMtimeMs` is provided and the
68
+ * on-disk mtime no longer matches.
56
69
  */
57
- export declare function saveState(state: Readonly<AgentState>): number;
70
+ export declare function saveState(state: Readonly<AgentState>, expectedMtimeMs?: number | null): number;
58
71
  /**
59
72
  * Re-read state from disk if the file has been modified since the last load/save.
60
73
  * Uses mtime comparison (single statSync call) to avoid unnecessary JSON parsing.
@@ -7,7 +7,7 @@ import * as fs from 'fs';
7
7
  import * as path from 'path';
8
8
  import { AgentStateSchema } from './state-schema.js';
9
9
  import { getStatePath, getBackupDir, getDataDir } from './utils.js';
10
- import { errorMessage } from './errors.js';
10
+ import { errorMessage, ConcurrencyError } from './errors.js';
11
11
  import { debug, warn } from './logger.js';
12
12
  const MODULE = 'state';
13
13
  // Lock file timeout: if a lock is older than this, it is considered stale
@@ -439,15 +439,37 @@ function cleanupBackups() {
439
439
  /**
440
440
  * Persist state to disk, creating a timestamped backup of the previous
441
441
  * state file first. Retains at most 10 backup files.
442
+ *
443
+ * When `expectedMtimeMs` is provided (non-null, non-zero), implements
444
+ * optimistic compare-and-swap: if the on-disk file has been modified since
445
+ * the caller last loaded it, throws `ConcurrencyError` instead of
446
+ * overwriting. This prevents the classic read-modify-write lost-update
447
+ * race across processes (see issue #1030). Pass `null` / `0` to disable
448
+ * the check (first write, or when the caller has already reloaded).
449
+ *
450
+ * The check runs *inside* the advisory lock so the compare-and-swap is
451
+ * atomic with respect to the write.
452
+ *
442
453
  * @returns The file's mtime after writing (for change detection).
454
+ * @throws ConcurrencyError when `expectedMtimeMs` is provided and the
455
+ * on-disk mtime no longer matches.
443
456
  */
444
- export function saveState(state) {
457
+ export function saveState(state, expectedMtimeMs = null) {
445
458
  const statePath = getStatePath();
446
459
  const lockPath = statePath + '.lock';
447
460
  const backupDir = getBackupDir();
448
461
  // Acquire advisory lock to prevent concurrent writes
449
462
  acquireLock(lockPath);
450
463
  try {
464
+ // Compare-and-swap: reject the write if the file changed externally
465
+ // between the caller's last load and now. Zero/null bypasses the
466
+ // check for first writes and Gist-mode local-cache paths.
467
+ if (expectedMtimeMs !== null && expectedMtimeMs > 0 && fs.existsSync(statePath)) {
468
+ const currentMtimeMs = safeGetMtimeMs(statePath);
469
+ if (currentMtimeMs !== expectedMtimeMs) {
470
+ throw new ConcurrencyError(expectedMtimeMs, currentMtimeMs);
471
+ }
472
+ }
451
473
  // Create backup of existing state (best-effort, non-fatal)
452
474
  try {
453
475
  if (fs.existsSync(statePath)) {
@@ -240,6 +240,7 @@ export declare const AgentConfigSchema: z.ZodObject<{
240
240
  vscode: "vscode";
241
241
  }>>;
242
242
  diffToolCustomCommand: z.ZodOptional<z.ZodString>;
243
+ autoFormatBeforePush: z.ZodDefault<z.ZodBoolean>;
243
244
  }, z.core.$strip>;
244
245
  export declare const LocalRepoCacheSchema: z.ZodObject<{
245
246
  repos: z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -397,6 +398,7 @@ export declare const AgentStateSchema: z.ZodObject<{
397
398
  vscode: "vscode";
398
399
  }>>;
399
400
  diffToolCustomCommand: z.ZodOptional<z.ZodString>;
401
+ autoFormatBeforePush: z.ZodDefault<z.ZodBoolean>;
400
402
  }, z.core.$strip>>;
401
403
  lastRunAt: z.ZodDefault<z.ZodString>;
402
404
  lastDigestAt: z.ZodOptional<z.ZodString>;
@@ -146,6 +146,11 @@ export const AgentConfigSchema = z.object({
146
146
  preferredOrgs: z.array(z.string()).default([]),
147
147
  diffTool: DiffToolSchema.default('inline'),
148
148
  diffToolCustomCommand: z.string().optional(),
149
+ /**
150
+ * Opt-in gate for the auto-format-before-push hook (#1045). Default false:
151
+ * the hook does nothing on every push unless the user explicitly enables it.
152
+ */
153
+ autoFormatBeforePush: z.boolean().default(false),
149
154
  });
150
155
  // ── 6. Cache schemas ─────────────────────────────────────────────────
151
156
  export const LocalRepoCacheSchema = z.object({