@oss-autopilot/core 1.15.1 → 1.16.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.
package/dist/cli.js CHANGED
@@ -50,22 +50,10 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
50
50
  process.exit(1);
51
51
  }
52
52
  // Activate Gist persistence if configured, before any command runs.
53
- // Peek at the config file directly to avoid creating a local-only singleton
54
- // (getStateManager() would lock in local mode before getStateManagerAsync runs).
55
- let persistence;
56
- try {
57
- const { getStatePath } = await import('./core/index.js');
58
- const fs = await import('fs');
59
- const raw = fs.readFileSync(getStatePath(), 'utf-8');
60
- persistence = JSON.parse(raw)?.config?.persistence;
61
- }
62
- catch {
63
- // No state file or unreadable — local mode, lazy init
64
- }
65
- if (persistence === 'gist' && token) {
66
- const { getStateManagerAsync } = await import('./core/index.js');
67
- await getStateManagerAsync(token);
68
- }
53
+ // Shared helper peeks at the state file and only pre-sets the singleton
54
+ // when Gist mode is the configured persistence (#1000).
55
+ const { ensureGistPersistence } = await import('./core/index.js');
56
+ await ensureGistPersistence(token);
69
57
  }
70
58
  });
71
59
  // First-run detection: if no subcommand was provided and no state file exists,
@@ -33,6 +33,15 @@ export interface DailyCheckResult {
33
33
  repoGroups: RepoGroup[];
34
34
  failures: PRCheckFailure[];
35
35
  }
36
+ /**
37
+ * Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
38
+ * Deduplicates PR objects: category arrays become PR URL references,
39
+ * full objects live only in digest.openPRs. Reduces JSON payload size ~60-70%.
40
+ *
41
+ * Exported for the `daily --json` contract test (#986), which pins this
42
+ * shape transformation without spinning up the full fetch pipeline.
43
+ */
44
+ export declare function toDailyOutput(result: DailyCheckResult): DailyOutput;
36
45
  /**
37
46
  * Core daily check logic, extracted for reuse by the startup command.
38
47
  * Fetches all open PRs, updates state, and returns structured output.
@@ -370,8 +370,11 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
370
370
  * Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
371
371
  * Deduplicates PR objects: category arrays become PR URL references,
372
372
  * full objects live only in digest.openPRs. Reduces JSON payload size ~60-70%.
373
+ *
374
+ * Exported for the `daily --json` contract test (#986), which pins this
375
+ * shape transformation without spinning up the full fetch pipeline.
373
376
  */
374
- function toDailyOutput(result) {
377
+ export function toDailyOutput(result) {
375
378
  return {
376
379
  digest: deduplicateDigest(result.digest),
377
380
  capacity: result.capacity,
@@ -49,6 +49,15 @@ export interface DashboardJsonData {
49
49
  offline?: boolean;
50
50
  lastUpdated?: string;
51
51
  }
52
+ /** Action types the dashboard can request via POST /api/action. */
53
+ export type DashboardActionType = 'move' | 'dismiss_issue_response';
54
+ /** Body shape for POST /api/action — single source for both server validation and SPA client (#998). */
55
+ export interface ActionRequest {
56
+ action: DashboardActionType;
57
+ url: string;
58
+ /** Target state for move action. */
59
+ target?: 'attention' | 'waiting' | 'shelved' | 'auto';
60
+ }
52
61
  export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>, storedMergedCount?: number, storedClosedCount?: number): DashboardStats;
53
62
  /**
54
63
  * Merge fresh API counts into existing stored counts.
@@ -251,7 +251,9 @@ export async function startDashboardServer(options) {
251
251
  }
252
252
  catch (error) {
253
253
  warn(MODULE, `Failed to rebuild dashboard data after state reload: ${errorMessage(error)}`);
254
- // Intentional: serve previous cachedJsonData rather than returning 500
254
+ // Serve previous cachedJsonData rather than returning 500.
255
+ // Signal staleness via response header so clients can detect the degraded mode (#994).
256
+ res.setHeader('X-Dashboard-Stale', '1');
255
257
  }
256
258
  }
257
259
  sendJson(res, 200, cachedJsonData);
@@ -22,7 +22,7 @@ export { runStartup } from './startup.js';
22
22
  /** Return contribution statistics (merge rate, PR counts, repo breakdown) from local state. */
23
23
  export { runStatus } from './status.js';
24
24
  /** Search GitHub for contributable issues using multi-strategy discovery. */
25
- export { runSearch } from './search.js';
25
+ export { runSearch, MAX_SEARCH_RESULTS } from './search.js';
26
26
  /** Vet a single GitHub issue for claimability (open, unassigned, no linked PRs, repo health). */
27
27
  export { runVet } from './vet.js';
28
28
  /** Re-vet all available issues in a curated issue list for freshness. */
@@ -71,6 +71,7 @@ export { runCheckIntegration } from './check-integration.js';
71
71
  export { runDetectFormatters } from './detect-formatters.js';
72
72
  /** Scan for locally cloned repos. */
73
73
  export { runLocalRepos } from './local-repos.js';
74
+ export type { DashboardJsonData, DashboardStats, DashboardActionType, ActionRequest } from './dashboard-data.js';
74
75
  export type { ErrorCode } from '../formatters/json.js';
75
76
  export type { DailyOutput, SearchOutput, StartupOutput, StatusOutput, TrackOutput } from '../formatters/json.js';
76
77
  export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput, VetListOutput, VetListItemStatus, } from '../formatters/json.js';
@@ -23,7 +23,7 @@ export { runStartup } from './startup.js';
23
23
  /** Return contribution statistics (merge rate, PR counts, repo breakdown) from local state. */
24
24
  export { runStatus } from './status.js';
25
25
  /** Search GitHub for contributable issues using multi-strategy discovery. */
26
- export { runSearch } from './search.js';
26
+ export { runSearch, MAX_SEARCH_RESULTS } from './search.js';
27
27
  /** Vet a single GitHub issue for claimability (open, unassigned, no linked PRs, repo health). */
28
28
  export { runVet } from './vet.js';
29
29
  /** Re-vet all available issues in a curated issue list for freshness. */
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { createScout } from '@oss-scout/core';
6
6
  import { getStateManager, requireGitHubToken } from '../core/index.js';
7
+ import { loadSkippedIssues } from './skip-file-parser.js';
7
8
  /**
8
9
  * Build a ScoutState from the current AgentState.
9
10
  * Maps oss-autopilot's config and state fields to oss-scout's state format.
@@ -52,7 +53,7 @@ export function buildScoutState() {
52
53
  openedAt: pr.createdAt,
53
54
  })),
54
55
  savedResults: [],
55
- skippedIssues: [],
56
+ skippedIssues: loadSkippedIssues(config.skippedIssuesPath),
56
57
  lastRunAt: state.lastRunAt,
57
58
  };
58
59
  }
@@ -4,6 +4,12 @@
4
4
  */
5
5
  import { type SearchOutput } from '../formatters/json.js';
6
6
  export { type SearchOutput } from '../formatters/json.js';
7
+ /**
8
+ * Hard cap on issue-search result count. Shared between CLI (`cli-registry.ts`),
9
+ * MCP tool (`tools.ts`), and MCP prompt (`prompts.ts`) so a future adjustment
10
+ * lands in one place instead of three (#1002).
11
+ */
12
+ export declare const MAX_SEARCH_RESULTS = 100;
7
13
  interface SearchOptions {
8
14
  maxResults: number;
9
15
  }
@@ -4,6 +4,12 @@
4
4
  */
5
5
  import { createAutopilotScout } from './scout-bridge.js';
6
6
  import { getStateManager } from '../core/index.js';
7
+ /**
8
+ * Hard cap on issue-search result count. Shared between CLI (`cli-registry.ts`),
9
+ * MCP tool (`tools.ts`), and MCP prompt (`prompts.ts`) so a future adjustment
10
+ * lands in one place instead of three (#1002).
11
+ */
12
+ export const MAX_SEARCH_RESULTS = 100;
7
13
  /**
8
14
  * Search GitHub for contributable issues using multi-phase discovery.
9
15
  *
@@ -0,0 +1,25 @@
1
+ export interface SkipAddOptions {
2
+ issueUrl: string;
3
+ /** Override the configured `skippedIssuesPath`. */
4
+ skipFilePath?: string;
5
+ /** Injected for deterministic tests. Defaults to `new Date()`. */
6
+ now?: Date;
7
+ }
8
+ export interface SkipAddOutput {
9
+ added: boolean;
10
+ alreadyPresent: boolean;
11
+ url: string;
12
+ path: string;
13
+ /** Populated only when an entry was appended. */
14
+ date?: string;
15
+ }
16
+ /**
17
+ * Append an issue URL to the skipped-issues file.
18
+ *
19
+ * Creates the file with the standard header if it doesn't exist. If the URL
20
+ * is already present (per the skip-file-parser's view of the file), this is
21
+ * a no-op and returns `alreadyPresent: true`.
22
+ *
23
+ * @throws when no path can be resolved or the URL isn't a GitHub issue/PR URL.
24
+ */
25
+ export declare function runSkipAdd(options: SkipAddOptions): SkipAddOutput;
@@ -0,0 +1,48 @@
1
+ import * as fs from 'fs';
2
+ import { loadSkippedIssues } from './skip-file-parser.js';
3
+ import { getStateManager } from '../core/index.js';
4
+ // Keep in sync with GITHUB_URL_RE in skip-file-parser.ts.
5
+ const GITHUB_URL_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:issues|pull)\/(\d+)(?:[/?#].*)?$/;
6
+ const FILE_HEADER = '# Skipped Issues — auto-culled after 90 days\n# Format: YYYY-MM-DD URL\n\n';
7
+ function formatUtcDate(d) {
8
+ return d.toISOString().slice(0, 10);
9
+ }
10
+ /**
11
+ * Append an issue URL to the skipped-issues file.
12
+ *
13
+ * Creates the file with the standard header if it doesn't exist. If the URL
14
+ * is already present (per the skip-file-parser's view of the file), this is
15
+ * a no-op and returns `alreadyPresent: true`.
16
+ *
17
+ * @throws when no path can be resolved or the URL isn't a GitHub issue/PR URL.
18
+ */
19
+ export function runSkipAdd(options) {
20
+ const skipFilePath = options.skipFilePath ?? getStateManager().getState().config.skippedIssuesPath;
21
+ if (!skipFilePath) {
22
+ throw new Error('No skipped-issues path configured. Set one via `oss-autopilot config --set skippedIssuesPath=<path>` or pass --path.');
23
+ }
24
+ if (!GITHUB_URL_RE.test(options.issueUrl)) {
25
+ throw new Error(`Invalid GitHub issue or PR URL: ${options.issueUrl}`);
26
+ }
27
+ const existing = loadSkippedIssues(skipFilePath);
28
+ if (existing.some((entry) => entry.url === options.issueUrl)) {
29
+ return {
30
+ added: false,
31
+ alreadyPresent: true,
32
+ url: options.issueUrl,
33
+ path: skipFilePath,
34
+ };
35
+ }
36
+ if (!fs.existsSync(skipFilePath)) {
37
+ fs.writeFileSync(skipFilePath, FILE_HEADER);
38
+ }
39
+ const date = formatUtcDate(options.now ?? new Date());
40
+ fs.appendFileSync(skipFilePath, `${date} ${options.issueUrl}\n`);
41
+ return {
42
+ added: true,
43
+ alreadyPresent: false,
44
+ url: options.issueUrl,
45
+ path: skipFilePath,
46
+ date,
47
+ };
48
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Parser for the skipped-issues markdown file (#989).
3
+ *
4
+ * The file format is one entry per line:
5
+ * 2026-04-15 https://github.com/owner/repo/issues/123
6
+ * Lines starting with `#` and blank lines are ignored.
7
+ *
8
+ * Produces SkippedIssue entries that plug directly into oss-scout's ScoutState
9
+ * so the search engine filters already-skipped URLs out of results.
10
+ */
11
+ import type { SkippedIssue } from '@oss-scout/core';
12
+ /**
13
+ * Parse the raw text of a skipped-issues file into SkippedIssue entries.
14
+ * Pure function — no I/O. Malformed lines are warned and skipped; the rest
15
+ * pass through unchanged.
16
+ */
17
+ export declare function parseSkippedIssuesContent(content: string): SkippedIssue[];
18
+ /**
19
+ * Read the skipped-issues file from disk and parse it.
20
+ * Returns `[]` when:
21
+ * - `path` is undefined or empty,
22
+ * - the file does not exist,
23
+ * - the file cannot be read (a warning is logged).
24
+ */
25
+ export declare function loadSkippedIssues(path: string | undefined): SkippedIssue[];
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Parser for the skipped-issues markdown file (#989).
3
+ *
4
+ * The file format is one entry per line:
5
+ * 2026-04-15 https://github.com/owner/repo/issues/123
6
+ * Lines starting with `#` and blank lines are ignored.
7
+ *
8
+ * Produces SkippedIssue entries that plug directly into oss-scout's ScoutState
9
+ * so the search engine filters already-skipped URLs out of results.
10
+ */
11
+ import * as fs from 'fs';
12
+ import { warn } from '../core/logger.js';
13
+ import { errorMessage } from '../core/errors.js';
14
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
15
+ const GITHUB_URL_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:issues|pull)\/(\d+)(?:[/?#].*)?$/;
16
+ /**
17
+ * Parse the raw text of a skipped-issues file into SkippedIssue entries.
18
+ * Pure function — no I/O. Malformed lines are warned and skipped; the rest
19
+ * pass through unchanged.
20
+ */
21
+ export function parseSkippedIssuesContent(content) {
22
+ const results = [];
23
+ const lines = content.split(/\r?\n/);
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const lineNumber = i + 1;
26
+ const line = lines[i].trim();
27
+ if (line === '' || line.startsWith('#'))
28
+ continue;
29
+ // Split on first whitespace run: "YYYY-MM-DD <url>"
30
+ const match = line.match(/^(\S+)\s+(\S+)\s*$/);
31
+ if (!match) {
32
+ warn('skip-file-parser', `Line ${lineNumber}: malformed (expected "<date> <url>"): ${line}`);
33
+ continue;
34
+ }
35
+ const [, dateStr, url] = match;
36
+ if (!DATE_RE.test(dateStr)) {
37
+ warn('skip-file-parser', `Line ${lineNumber}: invalid date format (expected YYYY-MM-DD): ${line}`);
38
+ continue;
39
+ }
40
+ const dateMs = Date.parse(`${dateStr}T00:00:00.000Z`);
41
+ if (Number.isNaN(dateMs)) {
42
+ warn('skip-file-parser', `Line ${lineNumber}: unparseable date: ${line}`);
43
+ continue;
44
+ }
45
+ // Guard against JS Date normalization — Date.parse silently shifts
46
+ // invalid calendar dates (e.g. 2026-02-30 → 2026-03-02). Without this
47
+ // round-trip check the entry would be stored under the wrong date and
48
+ // scout's 90-day cull would run against a shifted value.
49
+ const roundTrip = new Date(dateMs).toISOString().slice(0, 10);
50
+ if (roundTrip !== dateStr) {
51
+ warn('skip-file-parser', `Line ${lineNumber}: invalid calendar date ${dateStr} (would be normalized to ${roundTrip}): ${line}`);
52
+ continue;
53
+ }
54
+ const urlMatch = url.match(GITHUB_URL_RE);
55
+ if (!urlMatch) {
56
+ warn('skip-file-parser', `Line ${lineNumber}: non-GitHub-issue URL: ${line}`);
57
+ continue;
58
+ }
59
+ const [, repo, numberStr] = urlMatch;
60
+ const number = Number.parseInt(numberStr, 10);
61
+ results.push({
62
+ url,
63
+ repo,
64
+ number,
65
+ title: '',
66
+ skippedAt: new Date(dateMs).toISOString(),
67
+ });
68
+ }
69
+ return results;
70
+ }
71
+ /**
72
+ * Read the skipped-issues file from disk and parse it.
73
+ * Returns `[]` when:
74
+ * - `path` is undefined or empty,
75
+ * - the file does not exist,
76
+ * - the file cannot be read (a warning is logged).
77
+ */
78
+ export function loadSkippedIssues(path) {
79
+ if (!path)
80
+ return [];
81
+ if (!fs.existsSync(path))
82
+ return [];
83
+ let content;
84
+ try {
85
+ content = fs.readFileSync(path, 'utf-8');
86
+ }
87
+ catch (err) {
88
+ warn('skip-file-parser', `Failed to read skipped-issues file at ${path}: ${errorMessage(err)}`);
89
+ return [];
90
+ }
91
+ return parseSkippedIssuesContent(content);
92
+ }
@@ -109,8 +109,11 @@ export function detectIssueList() {
109
109
  skippedIssuesPath = configuredSkipPath;
110
110
  }
111
111
  }
112
- catch {
113
- /* fall through */
112
+ catch (err) {
113
+ // State access can fail on a degraded state file (corrupt JSON, EACCES).
114
+ // Default-path probe below still runs; warn so the underlying cause is visible (#994).
115
+ // Matches the sibling warn at line 64 for `issueListPath` read failures.
116
+ warn('startup', `Could not read skippedIssuesPath from state: ${errorMessage(err)}`);
114
117
  }
115
118
  // Probe default path: same directory as issue list, named skipped-issues.md
116
119
  if (!skippedIssuesPath && issueListPath) {
@@ -1,7 +1,16 @@
1
1
  /**
2
- * Track/Untrack commands
3
- * In v2, PRs are fetched fresh from GitHub on each `daily` run.
4
- * These commands are preserved for backward compatibility.
2
+ * Track/Untrack commands (v2 semantics — see #1001)
3
+ *
4
+ * **These commands do not mutate state.** In v2, PRs are discovered and
5
+ * enriched automatically on every `daily` run — there is no local tracking
6
+ * list to add to or remove from. The commands are preserved for backwards
7
+ * compatibility with v1 callers, but:
8
+ *
9
+ * - `runTrack` is an **informational lookup** that fetches PR metadata from
10
+ * GitHub and returns it. Useful for inspecting a specific PR's shape
11
+ * without waiting for the next `daily` run. Nothing is persisted.
12
+ * - `runUntrack` is **deprecated** and always a no-op. Use `shelve` to hide
13
+ * a PR from the daily digest.
5
14
  */
6
15
  import type { TrackOutput } from '../formatters/json.js';
7
16
  export interface UntrackOutput {
@@ -10,7 +19,11 @@ export interface UntrackOutput {
10
19
  message: string;
11
20
  }
12
21
  /**
13
- * Validate and fetch metadata for a PR URL.
22
+ * Fetch metadata for a PR URL (informational — does not persist).
23
+ *
24
+ * In v2 this is a read-only lookup. PRs are discovered automatically on each
25
+ * `daily` run; this command exists for one-off inspection of a specific PR's
26
+ * shape (title, repo, number).
14
27
  *
15
28
  * @param options - Track options
16
29
  * @param options.prUrl - Full GitHub PR URL
@@ -21,11 +34,14 @@ export declare function runTrack(options: {
21
34
  prUrl: string;
22
35
  }): Promise<TrackOutput>;
23
36
  /**
24
- * No-op in v2 PRs are fetched fresh on each daily run.
37
+ * @deprecated No-op in v2. Use `runShelve` to hide a PR from the daily digest.
38
+ *
39
+ * Kept for backwards compatibility with v1 callers. PRs are fetched fresh
40
+ * on each `daily` run, so there is no local tracking list to remove from.
25
41
  *
26
42
  * @param options - Untrack options
27
- * @param options.prUrl - Full GitHub PR URL
28
- * @returns Message explaining v2 behavior
43
+ * @param options.prUrl - Full GitHub PR URL (validated but not used)
44
+ * @returns Output object with `removed: false` and a message explaining v2 behavior
29
45
  * @throws {ValidationError} If the URL is not a valid GitHub PR URL
30
46
  */
31
47
  export declare function runUntrack(options: {
@@ -1,13 +1,26 @@
1
1
  /**
2
- * Track/Untrack commands
3
- * In v2, PRs are fetched fresh from GitHub on each `daily` run.
4
- * These commands are preserved for backward compatibility.
2
+ * Track/Untrack commands (v2 semantics — see #1001)
3
+ *
4
+ * **These commands do not mutate state.** In v2, PRs are discovered and
5
+ * enriched automatically on every `daily` run — there is no local tracking
6
+ * list to add to or remove from. The commands are preserved for backwards
7
+ * compatibility with v1 callers, but:
8
+ *
9
+ * - `runTrack` is an **informational lookup** that fetches PR metadata from
10
+ * GitHub and returns it. Useful for inspecting a specific PR's shape
11
+ * without waiting for the next `daily` run. Nothing is persisted.
12
+ * - `runUntrack` is **deprecated** and always a no-op. Use `shelve` to hide
13
+ * a PR from the daily digest.
5
14
  */
6
15
  import { getOctokit, requireGitHubToken } from '../core/index.js';
7
16
  import { validateUrl, PR_URL_PATTERN, validateGitHubUrl } from './validation.js';
8
17
  import { parseGitHubUrl } from '../core/utils.js';
9
18
  /**
10
- * Validate and fetch metadata for a PR URL.
19
+ * Fetch metadata for a PR URL (informational — does not persist).
20
+ *
21
+ * In v2 this is a read-only lookup. PRs are discovered automatically on each
22
+ * `daily` run; this command exists for one-off inspection of a specific PR's
23
+ * shape (title, repo, number).
11
24
  *
12
25
  * @param options - Track options
13
26
  * @param options.prUrl - Full GitHub PR URL
@@ -35,11 +48,14 @@ export async function runTrack(options) {
35
48
  };
36
49
  }
37
50
  /**
38
- * No-op in v2 PRs are fetched fresh on each daily run.
51
+ * @deprecated No-op in v2. Use `runShelve` to hide a PR from the daily digest.
52
+ *
53
+ * Kept for backwards compatibility with v1 callers. PRs are fetched fresh
54
+ * on each `daily` run, so there is no local tracking list to remove from.
39
55
  *
40
56
  * @param options - Untrack options
41
- * @param options.prUrl - Full GitHub PR URL
42
- * @returns Message explaining v2 behavior
57
+ * @param options.prUrl - Full GitHub PR URL (validated but not used)
58
+ * @returns Output object with `removed: false` and a message explaining v2 behavior
43
59
  * @throws {ValidationError} If the URL is not a valid GitHub PR URL
44
60
  */
45
61
  export async function runUntrack(options) {
@@ -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, resetStateManager, type Stats } from './state.js';
5
+ export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, 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';
@@ -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, resetStateManager } from './state.js';
5
+ export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, 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
@@ -301,6 +301,25 @@ export declare function getStateManager(): StateManager;
301
301
  * StateManager and Gist checkpoints will be no-ops.
302
302
  */
303
303
  export declare function getStateManagerAsync(token?: string): Promise<StateManager>;
304
+ /**
305
+ * Bootstrap helper for processes that may run in Gist persistence mode.
306
+ *
307
+ * Peeks at the state file to check if Gist mode is configured. If so and a
308
+ * valid token is provided, pre-sets the singleton via {@link getStateManagerAsync}
309
+ * so subsequent synchronous {@link getStateManager} calls return the Gist-backed
310
+ * instance. No-op when the state file is absent, unparseable, or not in Gist mode.
311
+ *
312
+ * Consolidates identical filesystem-peek + getStateManagerAsync logic that
313
+ * was duplicated between the CLI bootstrap (`cli.ts`) and MCP tool bootstrap
314
+ * (`mcp-server/src/tools.ts`) — #1000.
315
+ *
316
+ * @param token - GitHub token with `gist` scope, or `null` to skip activation
317
+ *
318
+ * @example
319
+ * // CLI bootstrap
320
+ * await ensureGistPersistence(token);
321
+ */
322
+ export declare function ensureGistPersistence(token: string | null): Promise<void>;
304
323
  /**
305
324
  * Reset the singleton StateManager instance to null. Intended for test isolation.
306
325
  */
@@ -185,8 +185,10 @@ export class StateManager {
185
185
  try {
186
186
  atomicWriteFileSync(getStateCachePath(), JSON.stringify(this.state, null, 2), 0o600);
187
187
  }
188
- catch {
189
- // Best-effort cache write
188
+ catch (err) {
189
+ // Cache write failure is non-fatal (Gist remains source of truth), but
190
+ // silent failure masks degraded-mode risk on next offline bootstrap (#994).
191
+ warn(MODULE, `Failed to write Gist local cache: ${errorMessage(err)}`);
190
192
  }
191
193
  return;
192
194
  }
@@ -705,6 +707,40 @@ export async function getStateManagerAsync(token) {
705
707
  }
706
708
  return getStateManager();
707
709
  }
710
+ /**
711
+ * Bootstrap helper for processes that may run in Gist persistence mode.
712
+ *
713
+ * Peeks at the state file to check if Gist mode is configured. If so and a
714
+ * valid token is provided, pre-sets the singleton via {@link getStateManagerAsync}
715
+ * so subsequent synchronous {@link getStateManager} calls return the Gist-backed
716
+ * instance. No-op when the state file is absent, unparseable, or not in Gist mode.
717
+ *
718
+ * Consolidates identical filesystem-peek + getStateManagerAsync logic that
719
+ * was duplicated between the CLI bootstrap (`cli.ts`) and MCP tool bootstrap
720
+ * (`mcp-server/src/tools.ts`) — #1000.
721
+ *
722
+ * @param token - GitHub token with `gist` scope, or `null` to skip activation
723
+ *
724
+ * @example
725
+ * // CLI bootstrap
726
+ * await ensureGistPersistence(token);
727
+ */
728
+ export async function ensureGistPersistence(token) {
729
+ if (!token)
730
+ return;
731
+ let persistence;
732
+ try {
733
+ const raw = fs.readFileSync(getStatePath(), 'utf-8');
734
+ persistence = JSON.parse(raw)?.config?.persistence;
735
+ }
736
+ catch {
737
+ // No state file or unreadable — stay in local mode
738
+ return;
739
+ }
740
+ if (persistence === 'gist') {
741
+ await getStateManagerAsync(token);
742
+ }
743
+ }
708
744
  /**
709
745
  * Reset the singleton StateManager instance to null. Intended for test isolation.
710
746
  */
@@ -145,14 +145,14 @@ export declare function extractOwnerRepo(url: string): {
145
145
  repo: string;
146
146
  } | null;
147
147
  /**
148
- * Calculates the number of whole days between two dates, using floor rounding.
148
+ * Calculates the number of whole days between two dates, clamped to zero.
149
149
  *
150
- * Can return negative values if `from` is after `to`. Partial days are truncated
151
- * (e.g., 1.9 days returns 1).
150
+ * Returns `0` if `from` is after `to` reversed ranges and clock-skew do not
151
+ * produce negative values. Partial days are truncated (e.g., 1.9 days -> 1).
152
152
  *
153
153
  * @param from - The start date
154
154
  * @param to - The end date (defaults to the current date/time)
155
- * @returns Number of whole days between the two dates (may be negative)
155
+ * @returns Number of whole days between the two dates, minimum `0`
156
156
  *
157
157
  * @example
158
158
  * daysBetween(new Date('2024-01-01'), new Date('2024-01-10'))
@@ -160,7 +160,7 @@ export declare function extractOwnerRepo(url: string): {
160
160
  *
161
161
  * @example
162
162
  * daysBetween(new Date('2024-01-10'), new Date('2024-01-01'))
163
- * // -9
163
+ * // 0 (clamped; reversed ranges are not signed)
164
164
  */
165
165
  export declare function daysBetween(from: Date, to?: Date): number;
166
166
  /**
@@ -220,14 +220,14 @@ export function extractOwnerRepo(url) {
220
220
  return { owner, repo };
221
221
  }
222
222
  /**
223
- * Calculates the number of whole days between two dates, using floor rounding.
223
+ * Calculates the number of whole days between two dates, clamped to zero.
224
224
  *
225
- * Can return negative values if `from` is after `to`. Partial days are truncated
226
- * (e.g., 1.9 days returns 1).
225
+ * Returns `0` if `from` is after `to` reversed ranges and clock-skew do not
226
+ * produce negative values. Partial days are truncated (e.g., 1.9 days -> 1).
227
227
  *
228
228
  * @param from - The start date
229
229
  * @param to - The end date (defaults to the current date/time)
230
- * @returns Number of whole days between the two dates (may be negative)
230
+ * @returns Number of whole days between the two dates, minimum `0`
231
231
  *
232
232
  * @example
233
233
  * daysBetween(new Date('2024-01-01'), new Date('2024-01-10'))
@@ -235,7 +235,7 @@ export function extractOwnerRepo(url) {
235
235
  *
236
236
  * @example
237
237
  * daysBetween(new Date('2024-01-10'), new Date('2024-01-01'))
238
- * // -9
238
+ * // 0 (clamped; reversed ranges are not signed)
239
239
  */
240
240
  export function daysBetween(from, to = new Date()) {
241
241
  return Math.max(0, Math.floor((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {