@oss-autopilot/core 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.js +39 -3
  3. package/dist/cli.bundle.cjs +103 -75
  4. package/dist/cli.js +17 -3
  5. package/dist/commands/check-integration.js +8 -8
  6. package/dist/commands/comments.js +3 -0
  7. package/dist/commands/config.js +14 -7
  8. package/dist/commands/daily-render.js +10 -5
  9. package/dist/commands/daily.d.ts +3 -9
  10. package/dist/commands/daily.js +12 -21
  11. package/dist/commands/dashboard-data.js +1 -1
  12. package/dist/commands/dashboard-lifecycle.js +1 -1
  13. package/dist/commands/dashboard-process.js +4 -4
  14. package/dist/commands/dashboard-server.js +26 -7
  15. package/dist/commands/dashboard.js +2 -2
  16. package/dist/commands/detect-formatters.js +3 -3
  17. package/dist/commands/doctor.js +5 -5
  18. package/dist/commands/guidelines.d.ts +10 -0
  19. package/dist/commands/guidelines.js +25 -6
  20. package/dist/commands/list-move-tier.js +5 -5
  21. package/dist/commands/local-repos.js +9 -9
  22. package/dist/commands/parse-list.js +10 -10
  23. package/dist/commands/scout-bridge.js +2 -2
  24. package/dist/commands/setup.js +24 -13
  25. package/dist/commands/skip-add.js +6 -3
  26. package/dist/commands/skip-file-parser.js +3 -3
  27. package/dist/commands/startup.js +11 -8
  28. package/dist/commands/state-cmd.js +1 -1
  29. package/dist/commands/status.js +7 -0
  30. package/dist/commands/validation.js +12 -3
  31. package/dist/commands/vet-list.js +12 -8
  32. package/dist/commands/vet.js +1 -2
  33. package/dist/core/__fixtures__/prompt-injection-payloads.d.ts +22 -0
  34. package/dist/core/__fixtures__/prompt-injection-payloads.js +109 -0
  35. package/dist/core/anti-llm-policy.js +5 -5
  36. package/dist/core/auth.js +12 -8
  37. package/dist/core/daily-logic.d.ts +13 -1
  38. package/dist/core/daily-logic.js +31 -4
  39. package/dist/core/dates.js +3 -3
  40. package/dist/core/errors.d.ts +29 -0
  41. package/dist/core/errors.js +63 -0
  42. package/dist/core/formatter-detection.js +9 -9
  43. package/dist/core/gist-state-store.d.ts +42 -3
  44. package/dist/core/gist-state-store.js +89 -19
  45. package/dist/core/guidelines-store.js +2 -2
  46. package/dist/core/http-cache.js +16 -7
  47. package/dist/core/index.d.ts +3 -1
  48. package/dist/core/index.js +6 -1
  49. package/dist/core/issue-conversation.js +3 -1
  50. package/dist/core/paths.js +4 -4
  51. package/dist/core/placeholder-usernames.d.ts +1 -0
  52. package/dist/core/placeholder-usernames.js +27 -0
  53. package/dist/core/pr-comments-fetcher.d.ts +14 -6
  54. package/dist/core/pr-comments-fetcher.js +8 -14
  55. package/dist/core/pr-monitor.d.ts +0 -2
  56. package/dist/core/pr-monitor.js +2 -25
  57. package/dist/core/pr-template.js +1 -1
  58. package/dist/core/state-persistence.d.ts +2 -2
  59. package/dist/core/state-persistence.js +15 -12
  60. package/dist/core/state-schema.js +8 -4
  61. package/dist/core/state.d.ts +27 -0
  62. package/dist/core/state.js +71 -14
  63. package/dist/core/untrusted-content.d.ts +48 -0
  64. package/dist/core/untrusted-content.js +106 -0
  65. package/dist/core/urls.js +2 -2
  66. package/dist/formatters/json.d.ts +53 -3
  67. package/dist/formatters/json.js +49 -14
  68. package/package.json +3 -3
@@ -9,9 +9,9 @@
9
9
  * for the same endpoint (e.g., star counts for two PRs in the same repo)
10
10
  * share a single HTTP round-trip.
11
11
  */
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
- import * as crypto from 'crypto';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import * as crypto from 'node:crypto';
15
15
  import { getCacheDir } from './paths.js';
16
16
  import { debug } from './logger.js';
17
17
  import { getHttpStatusCode } from './errors.js';
@@ -76,7 +76,7 @@ export class HttpCache {
76
76
  get(url) {
77
77
  const filePath = this.pathFor(url);
78
78
  try {
79
- const raw = fs.readFileSync(filePath, 'utf-8');
79
+ const raw = fs.readFileSync(filePath, 'utf8');
80
80
  const entry = JSON.parse(raw);
81
81
  // Sanity-check: the file should contain the URL we asked for
82
82
  if (entry.url !== url) {
@@ -85,7 +85,16 @@ export class HttpCache {
85
85
  }
86
86
  return entry;
87
87
  }
88
- catch {
88
+ catch (err) {
89
+ // ENOENT (cache miss) is the common case and not worth logging — but
90
+ // EISDIR / JSON parse / disk corruption all looked identical to a miss
91
+ // before, hiding repeated cache misses caused by a corrupt entry. Log
92
+ // anything that's not "file not found" so the cause becomes
93
+ // diagnosable in DEBUG mode (#1209 L5).
94
+ if (err && typeof err === 'object' && err.code !== 'ENOENT') {
95
+ const msg = err instanceof Error ? err.message : 'unknown error';
96
+ debug(MODULE, `Cache read failed for ${url} (treating as miss): ${msg}`);
97
+ }
89
98
  return null;
90
99
  }
91
100
  }
@@ -100,7 +109,7 @@ export class HttpCache {
100
109
  cachedAt: new Date().toISOString(),
101
110
  };
102
111
  try {
103
- fs.writeFileSync(this.pathFor(url), JSON.stringify(entry), { encoding: 'utf-8', mode: 0o600 });
112
+ fs.writeFileSync(this.pathFor(url), JSON.stringify(entry), { encoding: 'utf8', mode: 0o600 });
104
113
  debug(MODULE, `Cached response for ${url}`);
105
114
  // Best-effort size cap (#1057 M27). Runs after each write rather than on
106
115
  // a schedule so long-lived sessions can't accumulate past the cap.
@@ -191,7 +200,7 @@ export class HttpCache {
191
200
  continue;
192
201
  const filePath = path.join(this.cacheDir, file);
193
202
  try {
194
- const raw = fs.readFileSync(filePath, 'utf-8');
203
+ const raw = fs.readFileSync(filePath, 'utf8');
195
204
  const entry = JSON.parse(raw);
196
205
  const age = now - new Date(entry.cachedAt).getTime();
197
206
  if (age > maxAgeMs) {
@@ -8,6 +8,7 @@ export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX,
8
8
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
9
9
  export { IssueConversationMonitor } from './issue-conversation.js';
10
10
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
11
+ export { wrapUntrustedContent, extractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, type UntrustedContentMeta, } from './untrusted-content.js';
11
12
  export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
12
13
  export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
13
14
  export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
@@ -17,7 +18,7 @@ export { DEFAULT_CONCURRENCY } from './concurrency.js';
17
18
  export { OssAutopilotError, ConfigurationError, ValidationError, GistPermissionError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, nonFatalCatch, resolveErrorCode, } from './errors.js';
18
19
  export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
19
20
  export { HttpCache, getHttpCache, cachedRequest, type CacheEntry } from './http-cache.js';
20
- export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
21
+ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, buildStarFilter, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
21
22
  export { computeContributionStats, type ContributionStats, type ComputeStatsInput } from './stats.js';
22
23
  export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
23
24
  export { classifyLinkedPR, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
@@ -25,4 +26,5 @@ export { scanForAntiLLMPolicy, type AntiLLMCategory, type AntiLLMMatch, type Ant
25
26
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, type DetectedFormatter, type FormatterDetectionResult, type CIFormatterDiagnosis, type FormatterName, } from './formatter-detection.js';
26
27
  export { CONFIG_KEY_REGISTRY, type ConfigKeyDef, type SettableVia, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
27
28
  export { DashboardDataSchema, DashboardStatsSchema, validateDashboardData, type DashboardDataParsed, } from './dashboard-data-schema.js';
29
+ export { fetchPRCommentBundle, fetchPRCommentBundlesBatch, type PRCommentBundle, type PRReviewEntry, type PRReviewCommentEntry, type PRIssueCommentEntry, } from './pr-comments-fetcher.js';
28
30
  export * from './types.js';
@@ -9,6 +9,7 @@ export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks,
9
9
  // Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
10
10
  export { IssueConversationMonitor } from './issue-conversation.js';
11
11
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
12
+ export { wrapUntrustedContent, extractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, } from './untrusted-content.js';
12
13
  export { getOctokit, checkRateLimit } from './github.js';
13
14
  export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
14
15
  export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
@@ -18,7 +19,7 @@ export { DEFAULT_CONCURRENCY } from './concurrency.js';
18
19
  export { OssAutopilotError, ConfigurationError, ValidationError, GistPermissionError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, nonFatalCatch, resolveErrorCode, } from './errors.js';
19
20
  export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
20
21
  export { HttpCache, getHttpCache, cachedRequest } from './http-cache.js';
21
- export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
22
+ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, buildStarFilter, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
22
23
  export { computeContributionStats } from './stats.js';
23
24
  export { fetchPRTemplate } from './pr-template.js';
24
25
  export { classifyLinkedPR, } from './linked-pr-classification.js';
@@ -26,4 +27,8 @@ export { scanForAntiLLMPolicy, } from './anti-llm-policy.js';
26
27
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, } from './formatter-detection.js';
27
28
  export { CONFIG_KEY_REGISTRY, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
28
29
  export { DashboardDataSchema, DashboardStatsSchema, validateDashboardData, } from './dashboard-data-schema.js';
30
+ // PR comment bundle types — wire shape consumed by the MCP extract-learnings
31
+ // prompt. Re-exported so MCP doesn't have to redeclare the interface and
32
+ // silently drift (#1208 M5).
33
+ export { fetchPRCommentBundle, fetchPRCommentBundlesBatch, } from './pr-comments-fetcher.js';
29
34
  export * from './types.js';
@@ -151,7 +151,9 @@ export class IssueConversationMonitor {
151
151
  body: comment.body || '',
152
152
  createdAt: comment.created_at,
153
153
  isUser: author.toLowerCase() === username.toLowerCase(),
154
- authorAssociation: String(comment.author_association ?? ''),
154
+ authorAssociation: typeof comment.author_association === 'string'
155
+ ? comment.author_association
156
+ : '',
155
157
  });
156
158
  }
157
159
  timeline.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * Extracted from utils.ts under #1116.
8
8
  */
9
- import * as fs from 'fs';
10
- import * as path from 'path';
11
- import * as os from 'os';
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import * as os from 'node:os';
12
12
  /**
13
13
  * Returns the oss-autopilot data directory path, creating it if it does not exist.
14
14
  *
@@ -98,7 +98,7 @@ export function stateFileExists() {
98
98
  export function getCLIVersion() {
99
99
  try {
100
100
  const pkgPath = path.join(path.dirname(process.argv[1]), '..', 'package.json');
101
- return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
101
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
102
102
  }
103
103
  catch {
104
104
  return '0.0.0';
@@ -0,0 +1 @@
1
+ export declare function isPlaceholderUsername(username: string): boolean;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Known placeholder values that can end up in `config.githubUsername` from
3
+ * doc snippets, example configs, or aborted setup flows.
4
+ *
5
+ * Two callers consult this list:
6
+ * - `pr-monitor.ts` — runtime auto-repair: if a fetch is about to run with
7
+ * a placeholder, the configured value is replaced with the authenticated
8
+ * viewer's login before the search hits GitHub. Without this, the search
9
+ * silently returns zero results and the dashboard looks like a fresh install.
10
+ * - `commands/validation.ts` — write-side rejection: prevents `init`,
11
+ * `setup --set username=`, and the MCP `config` tool from persisting one
12
+ * of these values in the first place, so the auto-repair only runs as a
13
+ * fallback for legacy state rather than masking a fresh user error.
14
+ *
15
+ * Entries must be lowercase — `Lowercase<string>` on the source tuple makes a
16
+ * non-lowercase entry a compile error, keeping the case-insensitive lookup
17
+ * contract type-checked instead of comment-documented.
18
+ */
19
+ const PLACEHOLDER_USERNAMES = [
20
+ 'example-user',
21
+ 'your-username',
22
+ 'your-github-username',
23
+ ];
24
+ const KNOWN_PLACEHOLDER_USERNAMES = new Set(PLACEHOLDER_USERNAMES);
25
+ export function isPlaceholderUsername(username) {
26
+ return KNOWN_PLACEHOLDER_USERNAMES.has(username.toLowerCase());
27
+ }
@@ -58,10 +58,18 @@ export declare function fetchPRCommentBundle(octokit: Octokit, prUrl: string, gi
58
58
  /**
59
59
  * Fetch comment bundles for many PRs with a small concurrency cap (default 3).
60
60
  *
61
- * Failures on individual PRs are logged and skipped — the batch returns a
62
- * shorter array rather than aborting. Rationale: extraction quality is
63
- * already a partial-information problem (users contribute to many repos and
64
- * many PRs), so a single 404 / rate limit on one PR should not deny the
65
- * host the corpus from the other 4.
61
+ * Failures on individual PRs are logged and recorded — the batch returns
62
+ * `{ bundles, failures }` so the caller can decide whether to retry, surface
63
+ * a partial-data banner, or proceed. Rationale: extraction quality is already
64
+ * a partial-information problem (users contribute to many repos and many PRs),
65
+ * so a single 404 / rate limit on one PR should not deny the host the corpus
66
+ * from the other 4 — but the failure should still be visible (#1209 L8).
66
67
  */
67
- export declare function fetchPRCommentBundlesBatch(octokit: Octokit, prUrls: string[], githubUsername: string, concurrency?: number): Promise<PRCommentBundle[]>;
68
+ export interface PRCommentBundlesBatchResult {
69
+ bundles: PRCommentBundle[];
70
+ failures: Array<{
71
+ prUrl: string;
72
+ error: string;
73
+ }>;
74
+ }
75
+ export declare function fetchPRCommentBundlesBatch(octokit: Octokit, prUrls: string[], githubUsername: string, concurrency?: number): Promise<PRCommentBundlesBatchResult>;
@@ -92,17 +92,9 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
92
92
  })),
93
93
  };
94
94
  }
95
- /**
96
- * Fetch comment bundles for many PRs with a small concurrency cap (default 3).
97
- *
98
- * Failures on individual PRs are logged and skipped — the batch returns a
99
- * shorter array rather than aborting. Rationale: extraction quality is
100
- * already a partial-information problem (users contribute to many repos and
101
- * many PRs), so a single 404 / rate limit on one PR should not deny the
102
- * host the corpus from the other 4.
103
- */
104
95
  export async function fetchPRCommentBundlesBatch(octokit, prUrls, githubUsername, concurrency = DEFAULT_BATCH_CONCURRENCY) {
105
- const results = [];
96
+ const bundles = [];
97
+ const failures = [];
106
98
  const queue = [...prUrls];
107
99
  async function worker() {
108
100
  while (queue.length > 0) {
@@ -111,15 +103,17 @@ export async function fetchPRCommentBundlesBatch(octokit, prUrls, githubUsername
111
103
  return;
112
104
  try {
113
105
  const bundle = await fetchPRCommentBundle(octokit, url, githubUsername);
114
- results.push(bundle);
106
+ bundles.push(bundle);
115
107
  }
116
108
  catch (err) {
117
- warn(MODULE, `Skipping ${url}: ${errorMessage(err)}`);
109
+ const errorMsg = errorMessage(err);
110
+ failures.push({ prUrl: url, error: errorMsg });
111
+ warn(MODULE, `Skipping ${url}: ${errorMsg}`);
118
112
  }
119
113
  }
120
114
  }
121
115
  const workers = Array.from({ length: Math.min(concurrency, prUrls.length) }, worker);
122
116
  await Promise.all(workers);
123
- debug(MODULE, `Fetched ${results.length}/${prUrls.length} comment bundles`);
124
- return results;
117
+ debug(MODULE, `Fetched ${bundles.length}/${prUrls.length} comment bundles (${failures.length} failed)`);
118
+ return { bundles, failures };
125
119
  }
@@ -18,8 +18,6 @@ export { computeDisplayLabel } from './display-utils.js';
18
18
  export { classifyCICheck, classifyFailingChecks, getCIStatus } from './ci-analysis.js';
19
19
  export { isConditionalChecklistItem } from './checklist-analysis.js';
20
20
  export { determineStatus } from './status-determination.js';
21
- declare function isPlaceholderUsername(username: string): boolean;
22
- export { isPlaceholderUsername };
23
21
  /**
24
22
  * Check if a PR has a merge conflict based on GitHub's mergeable flag and mergeable_state.
25
23
  * Returns true when mergeable is explicitly false or the mergeable_state is 'dirty'.
@@ -16,9 +16,8 @@ import { getOctokit } from './github.js';
16
16
  import { getStateManager } from './state.js';
17
17
  import { daysBetween } from './dates.js';
18
18
  import { parseGitHubUrl, extractOwnerRepo, isOwnRepo } from './urls.js';
19
- import { DEFAULT_CONCURRENCY } from './concurrency.js';
19
+ import { DEFAULT_CONCURRENCY, runWorkerPool } from './concurrency.js';
20
20
  import { determineStatus } from './status-determination.js';
21
- import { runWorkerPool } from './concurrency.js';
22
21
  import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitOrAuthError, } from './errors.js';
23
22
  import { paginateAll } from './pagination.js';
24
23
  import { debug, warn, timed } from './logger.js';
@@ -29,34 +28,12 @@ import { analyzeChecklist } from './checklist-analysis.js';
29
28
  import { extractMaintainerActionHints } from './maintainer-analysis.js';
30
29
  import { computeDisplayLabel } from './display-utils.js';
31
30
  import { fetchUserMergedPRCounts as fetchUserMergedPRCountsImpl, fetchUserClosedPRCounts as fetchUserClosedPRCountsImpl, fetchRecentlyClosedPRs as fetchRecentlyClosedPRsImpl, fetchRecentlyMergedPRs as fetchRecentlyMergedPRsImpl, } from './github-stats.js';
31
+ import { isPlaceholderUsername } from './placeholder-usernames.js';
32
32
  // Re-export so existing consumers can still import from pr-monitor
33
33
  export { computeDisplayLabel } from './display-utils.js';
34
34
  export { classifyCICheck, classifyFailingChecks, getCIStatus } from './ci-analysis.js';
35
35
  export { isConditionalChecklistItem } from './checklist-analysis.js';
36
36
  export { determineStatus } from './status-determination.js';
37
- /**
38
- * Known placeholder values that can end up in `config.githubUsername` from
39
- * doc snippets, example configs, or aborted setup flows. When the configured
40
- * username matches one of these, the PR fetch silently returns zero results
41
- * and the dashboard looks like a fresh install. Detecting these lets us
42
- * auto-repair the config from the authenticated viewer before fetching.
43
- *
44
- * Entries must be lowercase — `Lowercase<string>` on the source tuple makes
45
- * a non-lowercase entry a compile error, keeping the case-insensitive lookup
46
- * contract type-checked instead of comment-documented.
47
- */
48
- const PLACEHOLDER_USERNAMES = [
49
- 'example-user',
50
- 'your-username',
51
- 'your-github-username',
52
- ];
53
- const KNOWN_PLACEHOLDER_USERNAMES = new Set(PLACEHOLDER_USERNAMES);
54
- function isPlaceholderUsername(username) {
55
- return KNOWN_PLACEHOLDER_USERNAMES.has(username.toLowerCase());
56
- }
57
- // Module-private on purpose: callers should only use the predicate so the
58
- // `.toLowerCase()` contract can't be bypassed by reading the set directly.
59
- export { isPlaceholderUsername };
60
37
  /**
61
38
  * Check if a PR has a merge conflict based on GitHub's mergeable flag and mergeable_state.
62
39
  * Returns true when mergeable is explicitly false or the mergeable_state is 'dirty'.
@@ -43,7 +43,7 @@ export async function fetchPRTemplate(octokit, owner, repo) {
43
43
  debug(MODULE, `${path} has no content, skipping`);
44
44
  continue;
45
45
  }
46
- const template = Buffer.from(data.content, 'base64').toString('utf-8');
46
+ const template = Buffer.from(data.content, 'base64').toString('utf8');
47
47
  debug(MODULE, `Found PR template at ${path} (${template.length} chars)`);
48
48
  return { template, source: path };
49
49
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * State persistence layer for the OSS Contribution Agent.
3
- * Handles file I/O, locking, backup/restore, and schema migration (v1→v2→v3).
3
+ * Handles file I/O, locking, backup/restore, and schema migration (v1→v2→v3→v4).
4
4
  * No module-level mutable state — functions accept/return AgentState objects.
5
5
  */
6
6
  import { AgentState } from './types.js';
@@ -41,7 +41,7 @@ export declare function migrateV2ToV3(rawState: Record<string, unknown>): Record
41
41
  */
42
42
  export declare function migrateV3ToV4(rawState: Record<string, unknown>): Record<string, unknown>;
43
43
  /**
44
- * Create a fresh state (v3).
44
+ * Create a fresh state (v4).
45
45
  * Leverages Zod schema defaults to produce a complete state.
46
46
  */
47
47
  export declare function createFreshState(): AgentState;
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * State persistence layer for the OSS Contribution Agent.
3
- * Handles file I/O, locking, backup/restore, and schema migration (v1→v2→v3).
3
+ * Handles file I/O, locking, backup/restore, and schema migration (v1→v2→v3→v4).
4
4
  * No module-level mutable state — functions accept/return AgentState objects.
5
5
  */
6
- import * as fs from 'fs';
7
- import * as path from 'path';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
8
  import { AgentStateSchema } from './state-schema.js';
9
9
  import { getStatePath, getBackupDir, getDataDir } from './paths.js';
10
10
  import { errorMessage, ConcurrencyError } from './errors.js';
@@ -21,7 +21,7 @@ const LEGACY_BACKUP_DIR = path.join(process.cwd(), 'data', 'backups');
21
21
  */
22
22
  function isLockStale(lockPath) {
23
23
  try {
24
- const existing = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
24
+ const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
25
25
  return Date.now() - existing.timestamp > LOCK_TIMEOUT_MS;
26
26
  }
27
27
  catch (err) {
@@ -72,7 +72,7 @@ export function acquireLock(lockPath) {
72
72
  */
73
73
  export function releaseLock(lockPath) {
74
74
  try {
75
- const data = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
75
+ const data = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
76
76
  if (data.pid === process.pid) {
77
77
  fs.unlinkSync(lockPath);
78
78
  }
@@ -170,7 +170,7 @@ export function migrateV3ToV4(rawState) {
170
170
  return rawState;
171
171
  }
172
172
  /**
173
- * Create a fresh state (v3).
173
+ * Create a fresh state (v4).
174
174
  * Leverages Zod schema defaults to produce a complete state.
175
175
  */
176
176
  export function createFreshState() {
@@ -273,9 +273,9 @@ function tryRestoreFromBackup() {
273
273
  for (const backupFile of backupFiles) {
274
274
  const backupPath = path.join(backupDir, backupFile);
275
275
  try {
276
- const data = fs.readFileSync(backupPath, 'utf-8');
276
+ const data = fs.readFileSync(backupPath, 'utf8');
277
277
  let raw = JSON.parse(data);
278
- // Chain migrations: v1 → v2 → v3
278
+ // Chain migrations: v1 → v2 → v3 → v4
279
279
  if (typeof raw === 'object' && raw !== null) {
280
280
  const rawObj = raw;
281
281
  if (rawObj.version === 1) {
@@ -306,8 +306,11 @@ function tryRestoreFromBackup() {
306
306
  debug(MODULE, `Backup ${backupFile} full validation errors:`, parsed.error.issues);
307
307
  }
308
308
  catch (backupErr) {
309
- // This backup is also corrupted, try the next one
310
- warn(MODULE, `Backup ${backupFile} is corrupted, trying next...`);
309
+ // This backup is also corrupted, try the next one. Include the error
310
+ // message in the warn so non-DEBUG users can diagnose without enabling
311
+ // DEBUG=1 (#1209 L7); the full stack still goes to debug.
312
+ const msg = backupErr instanceof Error ? backupErr.message : String(backupErr);
313
+ warn(MODULE, `Backup ${backupFile} is corrupted (${msg}), trying next...`);
311
314
  debug(MODULE, `Backup ${backupFile} parse failed`, backupErr);
312
315
  }
313
316
  }
@@ -325,7 +328,7 @@ export function loadState() {
325
328
  const statePath = getStatePath();
326
329
  try {
327
330
  if (fs.existsSync(statePath)) {
328
- const data = fs.readFileSync(statePath, 'utf-8');
331
+ const data = fs.readFileSync(statePath, 'utf8');
329
332
  let raw = JSON.parse(data);
330
333
  // Chain migrations: v1 → v2 → v3 → v4
331
334
  let wasMigrated = false;
@@ -491,7 +494,7 @@ export function saveState(state, expectedMtimeMs = null) {
491
494
  // Create backup of existing state (best-effort, non-fatal)
492
495
  try {
493
496
  if (fs.existsSync(statePath)) {
494
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
497
+ const timestamp = new Date().toISOString().replace(/[.:]/g, '-');
495
498
  const randomSuffix = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
496
499
  const backupFile = path.join(backupDir, `state-${timestamp}-${randomSuffix}.json`);
497
500
  fs.copyFileSync(statePath, backupFile);
@@ -45,18 +45,22 @@ export const StoredMergedPRSchema = z.object({
45
45
  title: z.string(),
46
46
  mergedAt: z.string(),
47
47
  /** When the raw review-comment bundle for this PR was last fetched (#867). */
48
- commentsFetchedAt: z.string().optional(),
48
+ // ISO-8601 datetime guards against `markPRCommentsFetched(url, "garbage")`
49
+ // poisoning state through the stamping API (#1209 L4).
50
+ commentsFetchedAt: z.string().datetime().optional(),
49
51
  /** When the host last ran LLM extraction over this PR's comment bundle (#867). */
50
- learningsExtractedAt: z.string().optional(),
52
+ learningsExtractedAt: z.string().datetime().optional(),
51
53
  });
52
54
  export const StoredClosedPRSchema = z.object({
53
55
  url: z.string(),
54
56
  title: z.string(),
55
57
  closedAt: z.string(),
56
58
  /** When the raw review-comment bundle for this PR was last fetched (#867). */
57
- commentsFetchedAt: z.string().optional(),
59
+ // ISO-8601 datetime guards against `markPRCommentsFetched(url, "garbage")`
60
+ // poisoning state through the stamping API (#1209 L4).
61
+ commentsFetchedAt: z.string().datetime().optional(),
58
62
  /** When the host last ran LLM extraction over this PR's comment bundle (#867). */
59
- learningsExtractedAt: z.string().optional(),
63
+ learningsExtractedAt: z.string().datetime().optional(),
60
64
  });
61
65
  export const AnalyzedIssueConversationSchema = z.object({
62
66
  url: z.string(),
@@ -26,6 +26,24 @@ export declare function maybeCheckpoint(stateManager: StateManager, callerModule
26
26
  * Retains lightweight CRUD operations for config, issues, shelving, dismissal,
27
27
  * and status overrides.
28
28
  */
29
+ /**
30
+ * Surfaced when the in-memory cached state is no longer in sync with the
31
+ * canonical Gist — typically because `refreshFromGist()` failed (network
32
+ * blip, rate limit, expired token) or because the bootstrap fell back to
33
+ * the local cache file (#1193). Commands include this in their `--json`
34
+ * envelope so cron/dashboard consumers can react instead of silently
35
+ * operating on stale data.
36
+ */
37
+ export interface StalenessInfo {
38
+ /** Why we're operating on cached data. Forward-compatible with future sources. */
39
+ source: 'cache';
40
+ /** Human-readable reason from the underlying error. */
41
+ reason: string;
42
+ /** ISO timestamp of the most recent successful refresh, or null if never. */
43
+ lastSuccessfulRefresh: string | null;
44
+ /** ISO timestamp when this staleness marker was first set. */
45
+ detectedAt: string;
46
+ }
29
47
  export declare class StateManager {
30
48
  protected state: AgentState;
31
49
  protected inMemoryOnly: boolean;
@@ -34,6 +52,8 @@ export declare class StateManager {
34
52
  private _batchDirty;
35
53
  protected gistStore: GistStateStore | null;
36
54
  protected gistDegraded: boolean;
55
+ private staleness;
56
+ private lastSuccessfulRefreshAt;
37
57
  /**
38
58
  * Create a new StateManager instance.
39
59
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -142,6 +162,13 @@ export declare class StateManager {
142
162
  * Throttled to once per 30 seconds by GistStateStore. Returns true if state was refreshed.
143
163
  */
144
164
  refreshFromGist(): Promise<boolean>;
165
+ /**
166
+ * Returns a staleness marker when the in-memory state diverged from the
167
+ * canonical Gist (refresh failure or degraded bootstrap), or `null` when
168
+ * state is current. Commands surface this via their `--json` warnings
169
+ * envelope (#1193).
170
+ */
171
+ getStateStaleness(): StalenessInfo | null;
145
172
  /**
146
173
  * Store the latest daily digest and update the digest timestamp.
147
174
  * @param digest - The daily digest to store
@@ -3,12 +3,12 @@
3
3
  * Thin coordinator that delegates persistence to state-persistence.ts
4
4
  * and scoring logic to repo-score-manager.ts.
5
5
  */
6
- import * as fs from 'fs';
6
+ import * as fs from 'node:fs';
7
7
  import { AgentStateSchema } from './state-schema.js';
8
8
  import { loadState, saveState, reloadStateIfChanged, createFreshState, atomicWriteFileSync, } from './state-persistence.js';
9
9
  import * as repoScoring from './repo-score-manager.js';
10
10
  import { debug, warn } from './logger.js';
11
- import { errorMessage, ConfigurationError, ConcurrencyError } from './errors.js';
11
+ import { errorMessage, ConfigurationError, ConcurrencyError, isTransientNetworkError } from './errors.js';
12
12
  import { GistStateStore } from './gist-state-store.js';
13
13
  import * as guidelinesStoreModule from './guidelines-store.js';
14
14
  import { getStatePath, getStateCachePath } from './paths.js';
@@ -53,13 +53,6 @@ export async function maybeCheckpoint(stateManager, callerModule) {
53
53
  warn(callerModule, `Gist checkpoint failed (local mutation succeeded, will retry on next push): ${errorMessage(err)}`);
54
54
  }
55
55
  }
56
- /**
57
- * Singleton manager for persistent agent state stored in ~/.oss-autopilot/state.json.
58
- *
59
- * Delegates file I/O to state-persistence.ts and scoring logic to repo-score-manager.ts.
60
- * Retains lightweight CRUD operations for config, issues, shelving, dismissal,
61
- * and status overrides.
62
- */
63
56
  export class StateManager {
64
57
  state;
65
58
  inMemoryOnly;
@@ -68,6 +61,8 @@ export class StateManager {
68
61
  _batchDirty = false;
69
62
  gistStore = null;
70
63
  gistDegraded = false;
64
+ staleness = null;
65
+ lastSuccessfulRefreshAt = null;
71
66
  /**
72
67
  * Create a new StateManager instance.
73
68
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -131,6 +126,16 @@ export class StateManager {
131
126
  manager.gistStore = gistStore;
132
127
  manager.gistDegraded = result.degraded ?? false;
133
128
  manager.inMemoryOnly = false; // re-enable persistence
129
+ // Seed the staleness marker if bootstrap fell back to the local cache —
130
+ // a `daily` running on a cron right after this start needs to know.
131
+ if (result.degraded) {
132
+ manager.staleness = {
133
+ source: 'cache',
134
+ reason: 'initial Gist bootstrap fell back to local cache',
135
+ lastSuccessfulRefresh: null,
136
+ detectedAt: new Date().toISOString(),
137
+ };
138
+ }
134
139
  return manager;
135
140
  }
136
141
  /**
@@ -340,11 +345,24 @@ export class StateManager {
340
345
  async refreshFromGist() {
341
346
  if (!this.gistStore)
342
347
  return false;
343
- const refreshed = await this.gistStore.refreshFromGist();
348
+ const result = await this.gistStore.refreshFromGist();
349
+ // StateManager keeps its boolean shape (callers rely on truthy-check
350
+ // semantics) but consults the discriminated union internally to know
351
+ // whether to reload state.json from the now-fresh cache (#1209 L9).
352
+ const refreshed = result.status === 'refreshed';
344
353
  if (refreshed) {
345
354
  const raw = this.gistStore.cachedFiles.get('state.json');
346
355
  if (!raw) {
347
356
  warn(MODULE, 'Gist refreshed but state.json missing from cache');
357
+ // HTTP fetch succeeded but the Gist body is missing state.json — we
358
+ // still have stale in-memory data, so surface a marker rather than
359
+ // silently returning false (#1193 review).
360
+ this.staleness = {
361
+ source: 'cache',
362
+ reason: 'Gist refresh returned no state.json file',
363
+ lastSuccessfulRefresh: this.lastSuccessfulRefreshAt,
364
+ detectedAt: new Date().toISOString(),
365
+ };
348
366
  return false;
349
367
  }
350
368
  try {
@@ -353,11 +371,41 @@ export class StateManager {
353
371
  }
354
372
  catch (err) {
355
373
  warn(MODULE, `Failed to parse refreshed Gist state: ${errorMessage(err)}`);
374
+ // Same reasoning as the missing-file branch: parse failure leaves us
375
+ // on stale in-memory state, so flag it.
376
+ this.staleness = {
377
+ source: 'cache',
378
+ reason: `Gist refresh succeeded but payload was invalid: ${errorMessage(err)}`,
379
+ lastSuccessfulRefresh: this.lastSuccessfulRefreshAt,
380
+ detectedAt: new Date().toISOString(),
381
+ };
356
382
  return false;
357
383
  }
384
+ // Successful refresh clears any prior staleness (#1193).
385
+ this.lastSuccessfulRefreshAt = new Date().toISOString();
386
+ this.staleness = null;
387
+ }
388
+ else if (this.gistStore.lastRefreshError) {
389
+ // Distinguish "fetch failed" (set marker) from "throttled" (preserve
390
+ // any existing marker, set nothing new).
391
+ this.staleness = {
392
+ source: 'cache',
393
+ reason: errorMessage(this.gistStore.lastRefreshError),
394
+ lastSuccessfulRefresh: this.lastSuccessfulRefreshAt,
395
+ detectedAt: new Date().toISOString(),
396
+ };
358
397
  }
359
398
  return refreshed;
360
399
  }
400
+ /**
401
+ * Returns a staleness marker when the in-memory state diverged from the
402
+ * canonical Gist (refresh failure or degraded bootstrap), or `null` when
403
+ * state is current. Commands surface this via their `--json` warnings
404
+ * envelope (#1193).
405
+ */
406
+ getStateStaleness() {
407
+ return this.staleness;
408
+ }
361
409
  // === Dashboard Data Setters ===
362
410
  /**
363
411
  * Store the latest daily digest and update the digest timestamp.
@@ -847,11 +895,20 @@ export async function getStateManagerAsync(token) {
847
895
  })
848
896
  .catch((err) => {
849
897
  asyncManagerPromise = null;
850
- // Configuration errors (e.g. GistPermissionError) must surface to the user
898
+ // Configuration errors (e.g. GistPermissionError, GistCorruptError)
899
+ // must surface — falling back to local-only would silently split state
900
+ // across machines (#1202).
851
901
  if (err instanceof ConfigurationError)
852
902
  throw err;
853
- warn(MODULE, `Gist initialization failed, falling back to local-only mode: ${err}`);
854
- return getStateManager(); // fall back to sync/local for transient errors
903
+ // Only fall back on actual network/server errors. Other failures
904
+ // (auth, schema, concurrency conflicts) indicate the Gist mode is
905
+ // broken in a way the user needs to address — silently falling back
906
+ // would write subsequent mutations to the local file while the Gist
907
+ // marker stays in config, causing permanent cross-machine divergence.
908
+ if (!isTransientNetworkError(err))
909
+ throw err;
910
+ warn(MODULE, `Gist initialization failed (transient network error), falling back to local-only mode: ${err}`);
911
+ return getStateManager();
855
912
  });
856
913
  return asyncManagerPromise;
857
914
  }
@@ -880,7 +937,7 @@ export async function ensureGistPersistence(token) {
880
937
  return;
881
938
  let persistence;
882
939
  try {
883
- const raw = fs.readFileSync(getStatePath(), 'utf-8');
940
+ const raw = fs.readFileSync(getStatePath(), 'utf8');
884
941
  persistence = JSON.parse(raw)?.config?.persistence;
885
942
  }
886
943
  catch {