@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,358 @@
1
+ /**
2
+ * Doctor command — a real system-health audit.
3
+ *
4
+ * Runs independent checks against the local environment and reports an
5
+ * aggregate `DoctorOutput` so users can diagnose common failure modes
6
+ * (missing token, unauthenticated gh CLI, stale bundle, corrupted state,
7
+ * unresolvable scout dependency, low rate-limit budget) in one go.
8
+ *
9
+ * Each `check*` function is exported individually so tests can mock its
10
+ * external dependencies in isolation.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import { execFile } from 'child_process';
15
+ import { promisify } from 'util';
16
+ import { getGitHubTokenAsync, getOctokit, getStatePath } from '../core/index.js';
17
+ import { AgentStateSchema } from '../core/state-schema.js';
18
+ import { errorMessage } from '../core/errors.js';
19
+ const execFileAsync = promisify(execFile);
20
+ const GH_CMD_TIMEOUT_MS = 3000;
21
+ // ── Individual checks ───────────────────────────────────────────────────
22
+ /** Extract a Node errno-style code from an unknown thrown value. */
23
+ function errCode(err) {
24
+ if (err && typeof err === 'object' && 'code' in err) {
25
+ const code = err.code;
26
+ if (typeof code === 'string')
27
+ return code;
28
+ }
29
+ return undefined;
30
+ }
31
+ export async function checkGhCli() {
32
+ try {
33
+ const { stdout } = await execFileAsync('gh', ['--version'], { timeout: GH_CMD_TIMEOUT_MS });
34
+ const versionLine = stdout.split('\n')[0]?.trim() || 'gh present';
35
+ return { name: 'gh CLI installed', status: 'ok', message: versionLine };
36
+ }
37
+ catch (err) {
38
+ // Distinguish "not on PATH" (ENOENT) from "present but broken" (anything
39
+ // else — spawn errors, timeouts, permission denied, non-zero exit). Telling
40
+ // users to "install gh" when gh is already installed is actively misleading.
41
+ const code = errCode(err);
42
+ if (code === 'ENOENT') {
43
+ return {
44
+ name: 'gh CLI installed',
45
+ status: 'warning',
46
+ message: 'gh CLI not found on PATH',
47
+ remediation: 'Install the GitHub CLI from https://cli.github.com/',
48
+ };
49
+ }
50
+ return {
51
+ name: 'gh CLI installed',
52
+ status: 'warning',
53
+ message: `gh CLI present but failed to run: ${errorMessage(err)}`,
54
+ remediation: 'Check that `gh --version` works in your shell; a timeout or permission error may indicate a broken install',
55
+ };
56
+ }
57
+ }
58
+ export async function checkGhAuth() {
59
+ try {
60
+ await execFileAsync('gh', ['auth', 'status'], { timeout: GH_CMD_TIMEOUT_MS });
61
+ return { name: 'gh CLI authenticated', status: 'ok', message: 'Signed in to github.com' };
62
+ }
63
+ catch (err) {
64
+ // `gh auth status` also exits non-zero on network/proxy failures and TLS
65
+ // issues reaching github.com — not only when the user isn't logged in.
66
+ // Mentioning both paths in the remediation avoids misleading users whose
67
+ // problem is connectivity, not auth.
68
+ return {
69
+ name: 'gh CLI authenticated',
70
+ status: 'warning',
71
+ message: `gh auth status failed: ${errorMessage(err)}`,
72
+ remediation: 'Run `gh auth login` to authenticate, or check network/proxy connectivity if the error looks network-related',
73
+ };
74
+ }
75
+ }
76
+ export async function checkGitHubToken(preloadedToken) {
77
+ const token = preloadedToken !== undefined ? preloadedToken : await getGitHubTokenAsync();
78
+ if (!token) {
79
+ return {
80
+ name: 'GitHub token resolvable',
81
+ status: 'warning',
82
+ message: 'No GitHub token available via $GITHUB_TOKEN or `gh auth token`',
83
+ remediation: 'Set $GITHUB_TOKEN or run `gh auth login`',
84
+ };
85
+ }
86
+ try {
87
+ const octokit = getOctokit(token);
88
+ const { data } = await octokit.users.getAuthenticated();
89
+ return {
90
+ name: 'GitHub token resolvable',
91
+ status: 'ok',
92
+ message: `Authenticated as @${data.login}`,
93
+ };
94
+ }
95
+ catch (err) {
96
+ return {
97
+ name: 'GitHub token resolvable',
98
+ status: 'error',
99
+ message: `Token present but API auth failed: ${errorMessage(err)}`,
100
+ remediation: 'Token may be expired or revoked — run `gh auth refresh` or set a fresh $GITHUB_TOKEN',
101
+ };
102
+ }
103
+ }
104
+ /**
105
+ * Locate `packages/core/dist/cli.bundle.cjs` across the two runtime contexts
106
+ * this code can run in:
107
+ *
108
+ * - Dev (tsx): `__dirname` = `.../packages/core/src/commands`, bundle sits
109
+ * two levels up in `../../dist/cli.bundle.cjs`.
110
+ * - Bundled (cjs): `__dirname` = `.../packages/core/dist`, bundle sits at
111
+ * `./cli.bundle.cjs` in that same directory.
112
+ */
113
+ function resolveBundlePaths() {
114
+ const devBundle = path.resolve(__dirname, '../../dist/cli.bundle.cjs');
115
+ const coDirBundle = path.resolve(__dirname, 'cli.bundle.cjs');
116
+ const devSource = path.resolve(__dirname, '../cli.ts');
117
+ const coDirSource = path.resolve(__dirname, '../src/cli.ts');
118
+ // Prefer whichever resolves an existing file; fall back to dev paths so the
119
+ // "missing" branches in checkBundleUpToDate still have a sensible path to
120
+ // report.
121
+ const bundlePath = fs.existsSync(devBundle) ? devBundle : fs.existsSync(coDirBundle) ? coDirBundle : devBundle;
122
+ const sourcePath = fs.existsSync(devSource) ? devSource : fs.existsSync(coDirSource) ? coDirSource : devSource;
123
+ return { bundlePath, sourcePath };
124
+ }
125
+ /**
126
+ * Try `fs.statSync`; if the file is merely missing (ENOENT), return null.
127
+ * For other fs errors (EACCES, ELOOP, ENOTDIR, …) surface the error so the
128
+ * caller can report it instead of misdiagnosing it as "file missing."
129
+ */
130
+ function tryStat(p) {
131
+ try {
132
+ return { stat: fs.statSync(p) };
133
+ }
134
+ catch (err) {
135
+ if (errCode(err) === 'ENOENT')
136
+ return { stat: null };
137
+ return { stat: null, error: err };
138
+ }
139
+ }
140
+ /**
141
+ * Bundle-freshness check only fires when the *source* file is present — that's
142
+ * the dev-repo workflow. npm consumers install the pre-built bundle with no
143
+ * source, so we just confirm the bundle exists.
144
+ */
145
+ export function checkBundleUpToDate(options) {
146
+ const resolved = resolveBundlePaths();
147
+ const bundlePath = options?.bundlePath ?? resolved.bundlePath;
148
+ const sourcePath = options?.sourcePath ?? resolved.sourcePath;
149
+ const bundleResult = tryStat(bundlePath);
150
+ const sourceResult = tryStat(sourcePath);
151
+ if (bundleResult.error || sourceResult.error) {
152
+ const err = bundleResult.error ?? sourceResult.error;
153
+ return {
154
+ name: 'CLI bundle',
155
+ status: 'error',
156
+ message: `Bundle freshness check failed: ${errorMessage(err)}`,
157
+ remediation: 'Check filesystem permissions on packages/core/dist/',
158
+ };
159
+ }
160
+ const bundleStat = bundleResult.stat;
161
+ const sourceStat = sourceResult.stat;
162
+ if (!bundleStat && !sourceStat) {
163
+ return {
164
+ name: 'CLI bundle',
165
+ status: 'warning',
166
+ message: 'Neither cli.ts nor cli.bundle.cjs could be located',
167
+ remediation: 'Reinstall the package or check your install location',
168
+ };
169
+ }
170
+ if (!bundleStat) {
171
+ return {
172
+ name: 'CLI bundle',
173
+ status: 'warning',
174
+ message: 'dist/cli.bundle.cjs not built yet',
175
+ remediation: 'Run `pnpm run bundle` to build the CLI bundle',
176
+ };
177
+ }
178
+ if (sourceStat && sourceStat.mtimeMs > bundleStat.mtimeMs) {
179
+ return {
180
+ name: 'CLI bundle',
181
+ status: 'warning',
182
+ message: 'Source files are newer than dist/cli.bundle.cjs',
183
+ remediation: 'Run `pnpm run bundle` to rebuild',
184
+ };
185
+ }
186
+ return { name: 'CLI bundle', status: 'ok', message: 'Bundle present and up-to-date' };
187
+ }
188
+ export function checkStateFile(options) {
189
+ const statePath = options?.statePathOverride ?? getStatePath();
190
+ if (!fs.existsSync(statePath)) {
191
+ return {
192
+ name: 'state.json valid',
193
+ status: 'ok',
194
+ message: 'No state.json yet (first run — nothing to validate)',
195
+ };
196
+ }
197
+ try {
198
+ const raw = fs.readFileSync(statePath, 'utf-8');
199
+ const parsed = JSON.parse(raw);
200
+ const result = AgentStateSchema.safeParse(parsed);
201
+ if (!result.success) {
202
+ const snippet = result.error.issues
203
+ .slice(0, 3)
204
+ .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
205
+ .join('; ');
206
+ return {
207
+ name: 'state.json valid',
208
+ status: 'error',
209
+ message: `state.json failed schema validation — ${snippet}`,
210
+ remediation: 'Restore from a backup in `~/.oss-autopilot/backups/` or re-run `oss-autopilot init`',
211
+ };
212
+ }
213
+ const version = typeof parsed?.version === 'number' ? parsed.version : '?';
214
+ return { name: 'state.json valid', status: 'ok', message: `state v${version} parses and validates` };
215
+ }
216
+ catch (err) {
217
+ return {
218
+ name: 'state.json valid',
219
+ status: 'error',
220
+ message: `Failed to read/parse state.json: ${errorMessage(err)}`,
221
+ remediation: 'Restore from a backup in `~/.oss-autopilot/backups/`',
222
+ };
223
+ }
224
+ }
225
+ const defaultScoutImporter = () => import('@oss-scout/core');
226
+ export async function checkScoutResolvable(importer = defaultScoutImporter) {
227
+ // The scout package is ESM-exports-only, so `require.resolve` fails even
228
+ // when it's installed. `import()` goes through the ESM resolver, which
229
+ // honors `exports` maps correctly. Dynamic import only loads the package
230
+ // on demand (not at doctor module load) and is cheap — no auth side effects.
231
+ try {
232
+ await importer();
233
+ return { name: '@oss-scout/core resolvable', status: 'ok', message: 'Found on module path' };
234
+ }
235
+ catch (err) {
236
+ // Three outcomes we want to distinguish:
237
+ // - "Package simply isn't installed" (missing from node_modules)
238
+ // - "Package is present but the install is corrupt" (bad exports map,
239
+ // unparseable source, missing internal file) — reinstall is the fix
240
+ // - "Package loaded but threw during init" — this is a scout bug
241
+ const code = errCode(err);
242
+ const isNotFound = code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
243
+ const isCorruptInstall = code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' ||
244
+ code === 'ERR_PACKAGE_IMPORT_NOT_DEFINED' ||
245
+ code === 'ERR_INVALID_PACKAGE_CONFIG' ||
246
+ err instanceof SyntaxError;
247
+ const reinstallRequired = isNotFound || isCorruptInstall;
248
+ return {
249
+ name: '@oss-scout/core resolvable',
250
+ status: 'error',
251
+ message: reinstallRequired
252
+ ? `@oss-scout/core cannot be loaded (install issue): ${errorMessage(err)}`
253
+ : `@oss-scout/core failed to load: ${errorMessage(err)}`,
254
+ remediation: reinstallRequired
255
+ ? 'Reinstall dependencies (`pnpm install` or `npm install`)'
256
+ : 'Scout loaded but threw during initialization — report this as a bug in oss-scout with the error above',
257
+ };
258
+ }
259
+ }
260
+ export async function checkGitHubRateLimit(preloadedToken) {
261
+ const token = preloadedToken !== undefined ? preloadedToken : await getGitHubTokenAsync();
262
+ if (!token) {
263
+ return {
264
+ name: 'GitHub rate limit',
265
+ status: 'warning',
266
+ message: 'Cannot check — no token available',
267
+ remediation: 'Set $GITHUB_TOKEN or run `gh auth login` to enable API-backed checks',
268
+ };
269
+ }
270
+ try {
271
+ const octokit = getOctokit(token);
272
+ const { data } = await octokit.rateLimit.get();
273
+ const core = data.resources.core;
274
+ const resetAt = new Date(core.reset * 1000).toISOString();
275
+ if (core.remaining < 100) {
276
+ return {
277
+ name: 'GitHub rate limit',
278
+ status: 'warning',
279
+ message: `Only ${core.remaining}/${core.limit} core requests remaining (resets at ${resetAt})`,
280
+ remediation: 'Wait for the window to reset or use a token with a higher limit',
281
+ };
282
+ }
283
+ return {
284
+ name: 'GitHub rate limit',
285
+ status: 'ok',
286
+ message: `${core.remaining}/${core.limit} core requests remaining`,
287
+ };
288
+ }
289
+ catch (err) {
290
+ return {
291
+ name: 'GitHub rate limit',
292
+ status: 'error',
293
+ message: `Failed to check rate limit: ${errorMessage(err)}`,
294
+ };
295
+ }
296
+ }
297
+ // ── Orchestrator ────────────────────────────────────────────────────────
298
+ /**
299
+ * Run all diagnostic checks and return a structured report.
300
+ *
301
+ * Checks are run concurrently where possible. None of the checks throw — each
302
+ * returns a `DoctorCheck` with its own status so a single failing check does
303
+ * not abort the rest of the audit.
304
+ */
305
+ /**
306
+ * Run a check defensively. If it rejects or the run itself throws, convert the
307
+ * failure into a synthetic `DoctorCheck` with status 'error' — `doctor` is the
308
+ * tool users reach for when things are already broken, so it must never abort
309
+ * itself because one individual check crashed.
310
+ */
311
+ async function runCheckSafely(name, runner) {
312
+ try {
313
+ return await runner();
314
+ }
315
+ catch (err) {
316
+ return {
317
+ name,
318
+ status: 'error',
319
+ message: `Check crashed unexpectedly: ${errorMessage(err)}`,
320
+ remediation: 'This is a bug in the doctor command — please report it with the error above',
321
+ };
322
+ }
323
+ }
324
+ export async function runDoctor() {
325
+ // Pre-resolve the token once and pass it to the token-dependent checks.
326
+ // Without this, the first call enters the async `gh auth token` branch
327
+ // (setting `tokenFetchAttempted = true` synchronously, then yielding at the
328
+ // `execFile` await). A second concurrent caller sees the flag set and the
329
+ // cache still empty, and returns `null` immediately — producing a spurious
330
+ // "no token available" warning while the first caller later resolves a valid
331
+ // token. Empirically reproduced before this fix.
332
+ // Best-effort token fetch — if it throws (sync or async), downstream
333
+ // token-dependent checks still run and will report "no token available" on
334
+ // their own. `try/catch` covers the sync-throw case; `.catch` alone would
335
+ // not.
336
+ let token = null;
337
+ try {
338
+ token = await getGitHubTokenAsync();
339
+ }
340
+ catch {
341
+ token = null;
342
+ }
343
+ const checks = await Promise.all([
344
+ runCheckSafely('gh CLI installed', () => checkGhCli()),
345
+ runCheckSafely('gh CLI authenticated', () => checkGhAuth()),
346
+ runCheckSafely('GitHub token resolvable', () => checkGitHubToken(token)),
347
+ runCheckSafely('GitHub rate limit', () => checkGitHubRateLimit(token)),
348
+ runCheckSafely('@oss-scout/core resolvable', () => checkScoutResolvable()),
349
+ runCheckSafely('CLI bundle', () => checkBundleUpToDate()),
350
+ runCheckSafely('state.json valid', () => checkStateFile()),
351
+ ]);
352
+ const summary = {
353
+ ok: checks.filter((c) => c.status === 'ok').length,
354
+ warnings: checks.filter((c) => c.status === 'warning').length,
355
+ errors: checks.filter((c) => c.status === 'error').length,
356
+ };
357
+ return { checks, summary };
358
+ }
@@ -67,6 +67,8 @@ export { runStateUnlink } from './state-cmd.js';
67
67
  export { runParseList, pruneIssueList } from './parse-list.js';
68
68
  /** Check if new files are properly referenced/integrated. */
69
69
  export { runCheckIntegration } from './check-integration.js';
70
+ /** System-health diagnostic — verifies tokens, bundle, state, scout, rate limit. */
71
+ export { runDoctor, type DoctorCheck, type DoctorCheckStatus, type DoctorOutput } from './doctor.js';
70
72
  /** Detect formatters/linters configured in a local repository (#703). */
71
73
  export { runDetectFormatters } from './detect-formatters.js';
72
74
  /** Scan for locally cloned repos. */
@@ -73,6 +73,8 @@ export { runStateUnlink } from './state-cmd.js';
73
73
  export { runParseList, pruneIssueList } from './parse-list.js';
74
74
  /** Check if new files are properly referenced/integrated. */
75
75
  export { runCheckIntegration } from './check-integration.js';
76
+ /** System-health diagnostic — verifies tokens, bundle, state, scout, rate limit. */
77
+ export { runDoctor } from './doctor.js';
76
78
  /** Detect formatters/linters configured in a local repository (#703). */
77
79
  export { runDetectFormatters } from './detect-formatters.js';
78
80
  /** Scan for locally cloned repos. */
@@ -17,8 +17,7 @@ export interface MoveOutput {
17
17
  * @param options.prUrl - Full GitHub PR URL
18
18
  * @param options.target - Target state: 'attention' | 'waiting' | 'shelved' | 'auto'
19
19
  * @returns The move result with a human-readable description
20
- * @throws {ValidationError} If the URL is not a valid GitHub PR URL
21
- * @throws {Error} If the target is invalid
20
+ * @throws {ValidationError} If the URL is not a valid GitHub PR URL or the target is invalid
22
21
  */
23
22
  export declare function runMove(options: {
24
23
  prUrl: string;
@@ -2,8 +2,10 @@
2
2
  * Move command — transition a PR between states:
3
3
  * attention, waiting, shelved, or auto (reset to computed status).
4
4
  */
5
- import { getStateManager } from '../core/index.js';
5
+ import { getStateManager, maybeCheckpoint } from '../core/index.js';
6
+ import { ValidationError } from '../core/errors.js';
6
7
  import { PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
8
+ const MODULE = 'move';
7
9
  export const VALID_TARGETS = ['attention', 'waiting', 'shelved', 'auto'];
8
10
  /**
9
11
  * Move a PR between states: attention, waiting, shelved, or auto (computed).
@@ -12,15 +14,14 @@ export const VALID_TARGETS = ['attention', 'waiting', 'shelved', 'auto'];
12
14
  * @param options.prUrl - Full GitHub PR URL
13
15
  * @param options.target - Target state: 'attention' | 'waiting' | 'shelved' | 'auto'
14
16
  * @returns The move result with a human-readable description
15
- * @throws {ValidationError} If the URL is not a valid GitHub PR URL
16
- * @throws {Error} If the target is invalid
17
+ * @throws {ValidationError} If the URL is not a valid GitHub PR URL or the target is invalid
17
18
  */
18
19
  export async function runMove(options) {
19
20
  validateUrl(options.prUrl);
20
21
  validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
21
22
  const target = options.target;
22
23
  if (!VALID_TARGETS.includes(target)) {
23
- throw new Error(`Invalid target "${options.target}". Must be one of: ${VALID_TARGETS.join(', ')}`);
24
+ throw new ValidationError(`Invalid target "${options.target}". Must be one of: ${VALID_TARGETS.join(', ')}`);
24
25
  }
25
26
  const stateManager = getStateManager();
26
27
  switch (target) {
@@ -35,6 +36,7 @@ export async function runMove(options) {
35
36
  stateManager.setStatusOverride(options.prUrl, status, lastActivityAt);
36
37
  stateManager.unshelvePR(options.prUrl);
37
38
  });
39
+ await maybeCheckpoint(stateManager, MODULE);
38
40
  return { url: options.prUrl, target, description: `Moved to ${label}` };
39
41
  }
40
42
  case 'shelved': {
@@ -42,6 +44,7 @@ export async function runMove(options) {
42
44
  stateManager.shelvePR(options.prUrl);
43
45
  stateManager.clearStatusOverride(options.prUrl);
44
46
  });
47
+ await maybeCheckpoint(stateManager, MODULE);
45
48
  return {
46
49
  url: options.prUrl,
47
50
  target,
@@ -53,6 +56,7 @@ export async function runMove(options) {
53
56
  stateManager.clearStatusOverride(options.prUrl);
54
57
  stateManager.unshelvePR(options.prUrl);
55
58
  });
59
+ await maybeCheckpoint(stateManager, MODULE);
56
60
  return {
57
61
  url: options.prUrl,
58
62
  target,
@@ -3,10 +3,11 @@
3
3
  * In v2, PR read/unread state is not tracked locally.
4
4
  * This command is a no-op preserved for backward compatibility.
5
5
  */
6
+ import { ValidationError } from '../core/errors.js';
6
7
  import { validateUrl } from './validation.js';
7
8
  export async function runRead(options) {
8
9
  if (!options.all && !options.prUrl) {
9
- throw new Error('PR URL or --all flag required');
10
+ throw new ValidationError('PR URL or --all flag required');
10
11
  }
11
12
  if (options.prUrl) {
12
13
  validateUrl(options.prUrl);
@@ -13,22 +13,4 @@ export declare const MAX_SEARCH_RESULTS = 100;
13
13
  interface SearchOptions {
14
14
  maxResults: number;
15
15
  }
16
- /**
17
- * Search GitHub for contributable issues using multi-phase discovery.
18
- *
19
- * @param options - Search configuration
20
- * @param options.maxResults - Maximum number of candidates to return
21
- * @returns Search results with scored candidates and exclusion lists
22
- * @throws {ConfigurationError} If no GitHub token is available
23
- *
24
- * @example
25
- * ```typescript
26
- * import { runSearch } from '@oss-autopilot/core/commands';
27
- *
28
- * const results = await runSearch({ maxResults: 10 });
29
- * for (const c of results.candidates) {
30
- * console.log(`${c.issue.repo}#${c.issue.number} — ${c.viabilityScore}/100`);
31
- * }
32
- * ```
33
- */
34
16
  export declare function runSearch(options: SearchOptions): Promise<SearchOutput>;
@@ -4,6 +4,9 @@
4
4
  */
5
5
  import { createAutopilotScout } from './scout-bridge.js';
6
6
  import { getStateManager } from '../core/index.js';
7
+ import { gradeFromCandidate } from '../core/issue-grading.js';
8
+ import { warn } from '../core/logger.js';
9
+ const MODULE = 'search';
7
10
  /**
8
11
  * Hard cap on issue-search result count. Shared between CLI (`cli-registry.ts`),
9
12
  * MCP tool (`tools.ts`), and MCP prompt (`prompts.ts`) so a future adjustment
@@ -28,6 +31,19 @@ export const MAX_SEARCH_RESULTS = 100;
28
31
  * }
29
32
  * ```
30
33
  */
34
+ /**
35
+ * Coerce scout's raw `viabilityScore` into a trustworthy 0–100 number.
36
+ * Scout is supposed to emit a finite integer in range, but out-of-contract
37
+ * values (NaN, Infinity, negative, >100, non-number) would otherwise flow
38
+ * straight through to consumers. Defend at the boundary — see #1043.
39
+ */
40
+ function sanitizeViabilityScore(raw) {
41
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0 || raw > 100) {
42
+ warn(MODULE, `Ignoring out-of-contract viabilityScore from scout: ${JSON.stringify(raw)}`);
43
+ return 0;
44
+ }
45
+ return raw;
46
+ }
31
47
  export async function runSearch(options) {
32
48
  const scout = await createAutopilotScout();
33
49
  const result = await scout.search({ maxResults: options.maxResults });
@@ -35,6 +51,26 @@ export async function runSearch(options) {
35
51
  const searchOutput = {
36
52
  candidates: result.candidates.map((c) => {
37
53
  const repoScoreRecord = stateManager.getRepoScore(c.issue.repo);
54
+ // Scout's `search` does not emit per-candidate projectHealth (only
55
+ // `vetIssue` does). Pass a sentinel `checkFailed: true` so the grader
56
+ // correctly treats scout-side signals as unknown and grades purely from
57
+ // the autopilot-tracked repoScore. Candidates without a repoScore
58
+ // receive 'F' — that's an honest signal for "we haven't seen this repo
59
+ // before" rather than a fabricated score.
60
+ const grade = gradeFromCandidate({
61
+ repo: c.issue.repo,
62
+ projectHealth: { avgIssueResponseDays: null, daysSinceLastCommit: null, checkFailed: true },
63
+ getRepoScore: (repo) => {
64
+ const score = stateManager.getRepoScore(repo);
65
+ return score
66
+ ? {
67
+ mergedPRCount: score.mergedPRCount,
68
+ closedWithoutMergeCount: score.closedWithoutMergeCount,
69
+ avgResponseDays: score.avgResponseDays ?? null,
70
+ }
71
+ : undefined;
72
+ },
73
+ });
38
74
  return {
39
75
  issue: {
40
76
  repo: c.issue.repo,
@@ -48,7 +84,8 @@ export async function runSearch(options) {
48
84
  reasonsToApprove: c.reasonsToApprove,
49
85
  reasonsToSkip: c.reasonsToSkip,
50
86
  searchPriority: c.searchPriority,
51
- viabilityScore: c.viabilityScore,
87
+ viabilityScore: sanitizeViabilityScore(c.viabilityScore),
88
+ grade,
52
89
  repoScore: repoScoreRecord
53
90
  ? {
54
91
  score: repoScoreRecord.score,
@@ -2,7 +2,7 @@
2
2
  * Setup command
3
3
  * Interactive setup / configuration
4
4
  */
5
- import { getStateManager, DEFAULT_CONFIG } from '../core/index.js';
5
+ import { getStateManager, DEFAULT_CONFIG, formatUnknownKeyError, getSetupKeys } from '../core/index.js';
6
6
  import { ValidationError } from '../core/errors.js';
7
7
  import { validateGitHubUsername } from './validation.js';
8
8
  import { PROJECT_CATEGORIES, ISSUE_SCOPES, DIFF_TOOLS, } from '../core/types.js';
@@ -34,6 +34,18 @@ export async function runSetup(options) {
34
34
  if (options.set && options.set.length > 0) {
35
35
  const results = {};
36
36
  const warnings = [];
37
+ // Pre-validate every key before mutating state. `stateManager.batch()` only
38
+ // defers the disk write — it does not snapshot in-memory state, so throwing
39
+ // mid-loop would leave earlier successful updates applied in memory (a real
40
+ // issue for long-running consumers like the MCP server and dashboard that
41
+ // share the StateManager singleton across requests).
42
+ const knownKeys = new Set(getSetupKeys());
43
+ for (const setting of options.set) {
44
+ const [key] = setting.split('=');
45
+ if (!knownKeys.has(key)) {
46
+ throw new ValidationError(formatUnknownKeyError(key, 'setup'));
47
+ }
48
+ }
37
49
  stateManager.batch(() => {
38
50
  for (const setting of options.set) {
39
51
  const [key, ...valueParts] = setting.split('=');
@@ -89,6 +101,34 @@ export async function runSetup(options) {
89
101
  results[key] = String(stars);
90
102
  break;
91
103
  }
104
+ case 'maxIssueAgeDays': {
105
+ const days = parsePositiveInt(value, 'maxIssueAgeDays');
106
+ stateManager.updateConfig({ maxIssueAgeDays: days });
107
+ results[key] = String(days);
108
+ break;
109
+ }
110
+ case 'minRepoScoreThreshold': {
111
+ const threshold = Number(value);
112
+ if (!Number.isInteger(threshold) || threshold < 0) {
113
+ throw new ValidationError(`Invalid value for minRepoScoreThreshold: "${value}". Must be a non-negative integer.`);
114
+ }
115
+ stateManager.updateConfig({ minRepoScoreThreshold: threshold });
116
+ results[key] = String(threshold);
117
+ break;
118
+ }
119
+ case 'skippedIssuesPath':
120
+ stateManager.updateConfig({ skippedIssuesPath: value || undefined });
121
+ results[key] = value || '(cleared)';
122
+ break;
123
+ case 'autoFormatBeforePush': {
124
+ if (value !== 'true' && value !== 'false') {
125
+ throw new ValidationError(`Invalid value for autoFormatBeforePush: "${value}". Must be "true" or "false".`);
126
+ }
127
+ const enabled = value === 'true';
128
+ stateManager.updateConfig({ autoFormatBeforePush: enabled });
129
+ results[key] = String(enabled);
130
+ break;
131
+ }
92
132
  case 'includeDocIssues':
93
133
  stateManager.updateConfig({ includeDocIssues: value === 'true' });
94
134
  results[key] = value === 'true' ? 'true' : 'false';
@@ -221,7 +261,7 @@ export async function runSetup(options) {
221
261
  }
222
262
  break;
223
263
  default:
224
- warnings.push(`Unknown setting: ${key}`);
264
+ throw new ValidationError(formatUnknownKeyError(key, 'setup'));
225
265
  }
226
266
  }
227
267
  });
@@ -7,8 +7,9 @@
7
7
  * which also clears status overrides. These functions match that behavior
8
8
  * to keep the library API consistent.
9
9
  */
10
- import { getStateManager } from '../core/index.js';
10
+ import { getStateManager, maybeCheckpoint } from '../core/index.js';
11
11
  import { PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
12
+ const MODULE = 'shelve';
12
13
  // Re-export for backward compatibility with tests
13
14
  export { PR_URL_PATTERN };
14
15
  /**
@@ -28,6 +29,7 @@ export async function runShelve(options) {
28
29
  added = stateManager.shelvePR(options.prUrl);
29
30
  stateManager.clearStatusOverride(options.prUrl);
30
31
  });
32
+ await maybeCheckpoint(stateManager, MODULE);
31
33
  return { shelved: added, url: options.prUrl };
32
34
  }
33
35
  /**
@@ -47,5 +49,6 @@ export async function runUnshelve(options) {
47
49
  removed = stateManager.unshelvePR(options.prUrl);
48
50
  stateManager.clearStatusOverride(options.prUrl);
49
51
  });
52
+ await maybeCheckpoint(stateManager, MODULE);
50
53
  return { unshelved: removed, url: options.prUrl };
51
54
  }
@@ -19,7 +19,7 @@ function formatUtcDate(d) {
19
19
  export function runSkipAdd(options) {
20
20
  const skipFilePath = options.skipFilePath ?? getStateManager().getState().config.skippedIssuesPath;
21
21
  if (!skipFilePath) {
22
- throw new Error('No skipped-issues path configured. Set one via `oss-autopilot config --set skippedIssuesPath=<path>` or pass --path.');
22
+ throw new Error('No skipped-issues path configured. Set one via `oss-autopilot setup --set skippedIssuesPath=<path>` or pass --path.');
23
23
  }
24
24
  if (!GITHUB_URL_RE.test(options.issueUrl)) {
25
25
  throw new Error(`Invalid GitHub issue or PR URL: ${options.issueUrl}`);