@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
package/dist/core/auth.js CHANGED
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * Extracted from utils.ts under #1116.
8
8
  */
9
- import { execFileSync, execFile } from 'child_process';
9
+ import { execFileSync, execFile } from 'node:child_process';
10
10
  import { ConfigurationError } from './errors.js';
11
- import { debug } from './logger.js';
11
+ import { debug, warn } from './logger.js';
12
12
  const MODULE = 'auth';
13
13
  // Cached GitHub token (fetched once per session)
14
14
  let cachedGitHubToken = null;
@@ -36,7 +36,7 @@ export function getGitHubToken() {
36
36
  }
37
37
  try {
38
38
  const token = execFileSync('gh', ['auth', 'token'], {
39
- encoding: 'utf-8',
39
+ encoding: 'utf8',
40
40
  stdio: ['pipe', 'pipe', 'pipe'],
41
41
  timeout: 2000,
42
42
  }).trim();
@@ -47,7 +47,10 @@ export function getGitHubToken() {
47
47
  }
48
48
  }
49
49
  catch (err) {
50
- debug(MODULE, 'gh auth token failed (CLI unavailable or not authenticated)', err);
50
+ // Promote to warn-once-per-session so a slow `gh` (2s timeout) or a
51
+ // misconfigured CLI is visible without DEBUG=1 (#1209 L6). The
52
+ // tokenFetchAttempted cache means subsequent calls don't re-warn.
53
+ warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
51
54
  }
52
55
  return null;
53
56
  }
@@ -101,7 +104,7 @@ export async function getGitHubTokenAsync() {
101
104
  }
102
105
  try {
103
106
  const token = await new Promise((resolve, reject) => {
104
- execFile('gh', ['auth', 'token'], { encoding: 'utf-8', timeout: 2000 }, (error, stdout) => {
107
+ execFile('gh', ['auth', 'token'], { encoding: 'utf8', timeout: 2000 }, (error, stdout) => {
105
108
  if (error) {
106
109
  reject(error);
107
110
  }
@@ -117,7 +120,8 @@ export async function getGitHubTokenAsync() {
117
120
  }
118
121
  }
119
122
  catch (err) {
120
- debug(MODULE, 'gh auth token failed (CLI unavailable or not authenticated)', err);
123
+ // Same warn-once promotion as the sync version (#1209 L6).
124
+ warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
121
125
  }
122
126
  return null;
123
127
  }
@@ -126,7 +130,7 @@ export async function getGitHubTokenAsync() {
126
130
  * Usernames must start with an alphanumeric character, can contain hyphens
127
131
  * (but not consecutive ones and not at the end), and be 1-39 characters.
128
132
  */
129
- const GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
133
+ const GITHUB_USERNAME_RE = /^[\da-z](?:[\da-z]|-(?=[\da-z])){0,38}$/i;
130
134
  /**
131
135
  * Detect the authenticated GitHub username via the `gh` CLI.
132
136
  *
@@ -137,7 +141,7 @@ const GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/
137
141
  export async function detectGitHubUsername() {
138
142
  try {
139
143
  const login = await new Promise((resolve, reject) => {
140
- execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf-8', timeout: 5000 }, (error, stdout) => {
144
+ execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', timeout: 5000 }, (error, stdout) => {
141
145
  if (error) {
142
146
  reject(error);
143
147
  }
@@ -14,7 +14,7 @@
14
14
  * re-exports them at the bottom so existing imports keep working without
15
15
  * a sweep.
16
16
  */
17
- import type { FetchedPR, FetchedPRStatus, StalenessTier, ActionReason, AgentState, ShelvedPRRef, ComputedRepoSignals, RepoGroup, CommentedIssue, CapacityAssessment, ActionableIssue, ActionMenu } from './types.js';
17
+ import type { FetchedPR, FetchedPRStatus, StalenessTier, ActionReason, AgentState, ShelvedPRRef, ComputedRepoSignals, RepoGroup, CommentedIssue, CapacityAssessment, ActionableIssue, ActionMenu, StarFilter } from './types.js';
18
18
  /**
19
19
  * Statuses indicating action needed from the contributor.
20
20
  * Used for auto-unshelving shelved PRs.
@@ -49,6 +49,18 @@ export declare function applyStatusOverrides(prs: FetchedPR[], state: Readonly<A
49
49
  * @returns Lightweight reference for display
50
50
  */
51
51
  export declare function toShelvedPRRef(pr: ShelvedPRRef): ShelvedPRRef;
52
+ /**
53
+ * Build a star filter from state for use in fetchUserPRCounts.
54
+ *
55
+ * Returns undefined if no star data is available (first run). Pure logic
56
+ * over `Readonly<AgentState>` — lives here in core/daily-logic.ts so the
57
+ * dashboard layer can reuse it without importing from a sibling command
58
+ * module (#1208 M7).
59
+ *
60
+ * @param state - Current agent state (read-only)
61
+ * @returns Star filter with minimum threshold and known counts, or undefined on first run
62
+ */
63
+ export declare function buildStarFilter(state: Readonly<AgentState>): StarFilter | undefined;
52
64
  /**
53
65
  * Group PRs by repository (#80).
54
66
  * Ensures one agent per repo during parallel dispatch, preventing branch checkout conflicts.
@@ -129,6 +129,29 @@ export function toShelvedPRRef(pr) {
129
129
  status: pr.status,
130
130
  };
131
131
  }
132
+ /**
133
+ * Build a star filter from state for use in fetchUserPRCounts.
134
+ *
135
+ * Returns undefined if no star data is available (first run). Pure logic
136
+ * over `Readonly<AgentState>` — lives here in core/daily-logic.ts so the
137
+ * dashboard layer can reuse it without importing from a sibling command
138
+ * module (#1208 M7).
139
+ *
140
+ * @param state - Current agent state (read-only)
141
+ * @returns Star filter with minimum threshold and known counts, or undefined on first run
142
+ */
143
+ export function buildStarFilter(state) {
144
+ const minStars = state.config.minStars ?? 50;
145
+ const knownStarCounts = new Map();
146
+ for (const [repo, score] of Object.entries(state.repoScores)) {
147
+ if (score.stargazersCount !== undefined) {
148
+ knownStarCounts.set(repo, score.stargazersCount);
149
+ }
150
+ }
151
+ if (knownStarCounts.size === 0)
152
+ return undefined;
153
+ return { minStars, knownStarCounts };
154
+ }
132
155
  /**
133
156
  * Group PRs by repository (#80).
134
157
  * Ensures one agent per repo during parallel dispatch, preventing branch checkout conflicts.
@@ -232,37 +255,41 @@ export function collectActionableIssues(prs, lastDigestAt) {
232
255
  let label;
233
256
  let type;
234
257
  switch (reason) {
235
- case 'needs_response':
258
+ case 'needs_response': {
236
259
  label = '[Needs Response]';
237
260
  type = 'needs_response';
238
261
  break;
239
- case 'needs_changes':
262
+ }
263
+ case 'needs_changes': {
240
264
  label = '[Needs Changes]';
241
265
  type = 'needs_changes';
242
266
  break;
267
+ }
243
268
  case 'failing_ci': {
244
269
  const checkInfo = pr.failingCheckNames.length > 0 ? ` (${pr.failingCheckNames.join(', ')})` : '';
245
270
  label = `[CI Failing${checkInfo}]`;
246
271
  type = 'ci_failing';
247
272
  break;
248
273
  }
249
- case 'merge_conflict':
274
+ case 'merge_conflict': {
250
275
  label = '[Merge Conflict]';
251
276
  type = 'merge_conflict';
252
277
  break;
278
+ }
253
279
  case 'incomplete_checklist': {
254
280
  const stats = pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : '';
255
281
  label = `[Incomplete Checklist${stats}]`;
256
282
  type = 'incomplete_checklist';
257
283
  break;
258
284
  }
259
- default:
285
+ default: {
260
286
  // Defensive fallback for ActionReason values not explicitly handled
261
287
  // above (e.g. ci_not_running, needs_rebase, missing_required_files).
262
288
  // These aren't in reasonOrder today but this guards future additions.
263
289
  warn('daily-logic', `Unhandled ActionReason "${reason}" for PR ${pr.url} — falling back to needs_response`);
264
290
  label = `[${reason}]`;
265
291
  type = 'needs_response';
292
+ }
266
293
  }
267
294
  // A PR is "new" if it was created after the last daily digest (first time seen).
268
295
  // If there's no previous digest (first run) or createdAt is invalid, assume new.
@@ -30,9 +30,9 @@ export function formatRelativeTime(dateStr) {
30
30
  const diffMs = Date.now() - date.getTime();
31
31
  if (diffMs < 0)
32
32
  return 'just now';
33
- const diffMins = Math.floor(diffMs / 60000);
34
- const diffHours = Math.floor(diffMs / 3600000);
35
- const diffDays = Math.floor(diffMs / 86400000);
33
+ const diffMins = Math.floor(diffMs / 60_000);
34
+ const diffHours = Math.floor(diffMs / 3_600_000);
35
+ const diffDays = Math.floor(diffMs / 86_400_000);
36
36
  if (diffMins < 60)
37
37
  return `${diffMins}m ago`;
38
38
  if (diffHours < 24)
@@ -32,6 +32,20 @@ export declare class ValidationError extends OssAutopilotError {
32
32
  export declare class GistPermissionError extends ConfigurationError {
33
33
  constructor(message?: string);
34
34
  }
35
+ /**
36
+ * Thrown when state.json fetched from a Gist is malformed or fails Zod
37
+ * validation. The Gist content is preserved as a `.rejected-<ts>.json` file
38
+ * in the local cache directory so the user can inspect or recover from it
39
+ * before the next push overwrites the Gist with fresh state.
40
+ *
41
+ * See issue #1201.
42
+ */
43
+ export declare class GistCorruptError extends ConfigurationError {
44
+ readonly gistId: string;
45
+ readonly rejectedPath: string | null;
46
+ readonly cause: unknown;
47
+ constructor(gistId: string, rejectedPath: string | null, cause: unknown);
48
+ }
35
49
  /**
36
50
  * Thrown when an optimistic compare-and-swap on state.json detects that
37
51
  * another process wrote the file between load and save. See issue #1030.
@@ -73,6 +87,21 @@ export declare function getHttpStatusCode(error: unknown): number | undefined;
73
87
  export declare function isRateLimitError(error: unknown): boolean;
74
88
  /** Return true for errors that should propagate (not degrade gracefully): rate limits, auth failures, abuse detection. */
75
89
  export declare function isRateLimitOrAuthError(err: unknown): boolean;
90
+ /**
91
+ * Check if an error is a transient network/server-side failure that's safe
92
+ * to retry or fall back from (vs. a permanent error that should surface).
93
+ *
94
+ * Returns true for:
95
+ * - Node socket errors: ECONNRESET, ETIMEDOUT, ENETUNREACH, ENOTFOUND, ECONNREFUSED
96
+ * - HTTP 5xx (server errors)
97
+ * - AbortError (timeout)
98
+ *
99
+ * Returns false for everything else (including 4xx, schema errors, config errors).
100
+ *
101
+ * Used by {@link getStateManagerAsync} to decide whether a Gist init failure
102
+ * is recoverable enough to silently fall back to local-only mode (#1202).
103
+ */
104
+ export declare function isTransientNetworkError(err: unknown): boolean;
76
105
  /**
77
106
  * Build a `.catch()` handler for the "non-fatal parallel fetch" pattern used
78
107
  * by daily.ts and dashboard-data.ts (#960). When a sibling fetch fails during
@@ -49,6 +49,31 @@ export class GistPermissionError extends ConfigurationError {
49
49
  this.name = 'GistPermissionError';
50
50
  }
51
51
  }
52
+ /**
53
+ * Thrown when state.json fetched from a Gist is malformed or fails Zod
54
+ * validation. The Gist content is preserved as a `.rejected-<ts>.json` file
55
+ * in the local cache directory so the user can inspect or recover from it
56
+ * before the next push overwrites the Gist with fresh state.
57
+ *
58
+ * See issue #1201.
59
+ */
60
+ export class GistCorruptError extends ConfigurationError {
61
+ gistId;
62
+ rejectedPath;
63
+ cause;
64
+ constructor(gistId, rejectedPath, cause) {
65
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
66
+ const recoverHint = rejectedPath
67
+ ? `\nCorrupt content preserved at: ${rejectedPath}\n` +
68
+ 'Inspect or restore manually, then re-run. Falling back to a fresh state would overwrite your Gist.'
69
+ : '\nCould not preserve the rejected content; do not push without inspecting the Gist manually.';
70
+ super(`Gist ${gistId} state.json is corrupt or fails schema validation: ${causeMsg}${recoverHint}`);
71
+ this.gistId = gistId;
72
+ this.rejectedPath = rejectedPath;
73
+ this.cause = cause;
74
+ this.name = 'GistCorruptError';
75
+ }
76
+ }
52
77
  /**
53
78
  * Thrown when an optimistic compare-and-swap on state.json detects that
54
79
  * another process wrote the file between load and save. See issue #1030.
@@ -128,6 +153,44 @@ export function isRateLimitOrAuthError(err) {
128
153
  }
129
154
  return false;
130
155
  }
156
+ /**
157
+ * Check if an error is a transient network/server-side failure that's safe
158
+ * to retry or fall back from (vs. a permanent error that should surface).
159
+ *
160
+ * Returns true for:
161
+ * - Node socket errors: ECONNRESET, ETIMEDOUT, ENETUNREACH, ENOTFOUND, ECONNREFUSED
162
+ * - HTTP 5xx (server errors)
163
+ * - AbortError (timeout)
164
+ *
165
+ * Returns false for everything else (including 4xx, schema errors, config errors).
166
+ *
167
+ * Used by {@link getStateManagerAsync} to decide whether a Gist init failure
168
+ * is recoverable enough to silently fall back to local-only mode (#1202).
169
+ */
170
+ export function isTransientNetworkError(err) {
171
+ if (!err || typeof err !== 'object')
172
+ return false;
173
+ const status = getHttpStatusCode(err);
174
+ if (typeof status === 'number' && status >= 500 && status < 600)
175
+ return true;
176
+ // Node socket-level errors expose `code`
177
+ const code = err.code;
178
+ if (typeof code === 'string') {
179
+ if (code === 'ECONNRESET' ||
180
+ code === 'ETIMEDOUT' ||
181
+ code === 'ENETUNREACH' ||
182
+ code === 'ENOTFOUND' ||
183
+ code === 'ECONNREFUSED' ||
184
+ code === 'EAI_AGAIN') {
185
+ return true;
186
+ }
187
+ }
188
+ // Octokit's RequestError surfaces the underlying name; fetch timeout is AbortError
189
+ const name = err.name;
190
+ if (typeof name === 'string' && (name === 'AbortError' || name === 'TimeoutError'))
191
+ return true;
192
+ return false;
193
+ }
131
194
  /**
132
195
  * Build a `.catch()` handler for the "non-fatal parallel fetch" pattern used
133
196
  * by daily.ts and dashboard-data.ts (#960). When a sibling fetch fails during
@@ -4,8 +4,8 @@
4
4
  * Programmatically detects formatters/linters configured in a local repo directory
5
5
  * and diagnoses CI formatting failures from log output.
6
6
  */
7
- import * as fs from 'fs';
8
- import * as path from 'path';
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
9
  import { debug } from './logger.js';
10
10
  const MODULE = 'formatter-detection';
11
11
  // ── Prettier config file patterns ──────────────────────────────────────────
@@ -42,7 +42,7 @@ const FORMAT_SCRIPT_NAMES = ['lint:fix', 'format', 'fmt', 'lint', 'format:check'
42
42
  const CI_PATTERNS = [
43
43
  {
44
44
  formatter: 'prettier',
45
- patterns: [/Code style issues found/i, /Forgot to run Prettier/i, /prettier --check/i],
45
+ patterns: [/code style issues found/i, /forgot to run prettier/i, /prettier --check/i],
46
46
  },
47
47
  {
48
48
  formatter: 'ruff',
@@ -50,19 +50,19 @@ const CI_PATTERNS = [
50
50
  },
51
51
  {
52
52
  formatter: 'black',
53
- patterns: [/Oh no! .* files? would be reformatted/i, /black --check/i],
53
+ patterns: [/oh no! .* files? would be reformatted/i, /black --check/i],
54
54
  },
55
55
  {
56
56
  formatter: 'rustfmt',
57
- patterns: [/Diff in .*\.rs/i, /rustfmt --check/i, /cargo fmt.*--check/i],
57
+ patterns: [/diff in .*\.rs/i, /rustfmt --check/i, /cargo fmt.*--check/i],
58
58
  },
59
59
  {
60
60
  formatter: 'biome',
61
- patterns: [/biome check/i, /biome ci/i, /Found \d+ fixable diagnostics?/i],
61
+ patterns: [/biome check/i, /biome ci/i, /found \d+ fixable diagnostics?/i],
62
62
  },
63
63
  {
64
64
  formatter: 'eslint',
65
- patterns: [/eslint.*--fix/i, /eslint.*\d+ problems?/i],
65
+ patterns: [/eslint.*--fix/i, /eslint\D+\d+ problems?/i],
66
66
  },
67
67
  {
68
68
  formatter: 'gofmt',
@@ -82,7 +82,7 @@ const CI_PATTERNS = [
82
82
  */
83
83
  function readJsonFile(filePath) {
84
84
  try {
85
- const content = fs.readFileSync(filePath, 'utf-8');
85
+ const content = fs.readFileSync(filePath, 'utf8');
86
86
  return JSON.parse(content);
87
87
  }
88
88
  catch (err) {
@@ -95,7 +95,7 @@ function readJsonFile(filePath) {
95
95
  */
96
96
  function readTextFile(filePath) {
97
97
  try {
98
- return fs.readFileSync(filePath, 'utf-8');
98
+ return fs.readFileSync(filePath, 'utf8');
99
99
  }
100
100
  catch (err) {
101
101
  debug(MODULE, `Failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
@@ -44,6 +44,22 @@ export interface BootstrapResult {
44
44
  /** True when state was loaded from local cache due to API failure. */
45
45
  degraded?: boolean;
46
46
  }
47
+ /**
48
+ * Discriminated outcome of {@link GistStateStore.refreshFromGist} (#1209 L9).
49
+ * Lets callers distinguish "got fresh data" from "throttled / no-op / failed"
50
+ * without conflating them as a single boolean.
51
+ */
52
+ export type RefreshResult = {
53
+ status: 'refreshed';
54
+ } | {
55
+ status: 'no-gist';
56
+ } | {
57
+ status: 'throttled';
58
+ sinceLastMs: number;
59
+ } | {
60
+ status: 'error';
61
+ error: Error;
62
+ };
47
63
  /**
48
64
  * Minimal Octokit-shaped interface for the Gist API methods we use.
49
65
  * Accepts the real ThrottledOctokit or a plain mock object in tests.
@@ -121,6 +137,14 @@ export declare class GistStateStore {
121
137
  private readonly octokit;
122
138
  private lastRefreshAt;
123
139
  private static readonly REFRESH_THROTTLE_MS;
140
+ /**
141
+ * Most recent error from a `refreshFromGist()` attempt, or `null` when the
142
+ * last attempt succeeded or was skipped by the throttle. Lets callers
143
+ * (StateManager) distinguish "throttled, nothing new to see" from "fetch
144
+ * failed, you're now operating on stale state" without changing the
145
+ * existing boolean return contract (#1193).
146
+ */
147
+ lastRefreshError: Error | null;
124
148
  constructor(octokit: OctokitLike);
125
149
  /**
126
150
  * Bootstrap the Gist store: locate or create the backing Gist,
@@ -186,8 +210,15 @@ export declare class GistStateStore {
186
210
  /**
187
211
  * Re-fetch the Gist and update the in-memory cache.
188
212
  * Throttled to at most once per 30 seconds.
213
+ *
214
+ * Returns a discriminated union so callers can tell apart the four
215
+ * outcomes that previously collapsed into a single boolean (#1209 L9):
216
+ * - `{ status: 'refreshed' }` — fresh data loaded successfully.
217
+ * - `{ status: 'no-gist' }` — store not in Gist mode (e.g. degraded).
218
+ * - `{ status: 'throttled', sinceLastMs }` — within the 30s throttle.
219
+ * - `{ status: 'error', error }` — fetch attempt failed.
189
220
  */
190
- refreshFromGist(): Promise<boolean>;
221
+ refreshFromGist(): Promise<RefreshResult>;
191
222
  /**
192
223
  * Preflight check: verify the token has Gist API scope.
193
224
  * Costs one cheap API call; catches permission issues early with a clear message.
@@ -199,9 +230,17 @@ export declare class GistStateStore {
199
230
  */
200
231
  private fetchAndCache;
201
232
  /**
202
- * Parse `state.json` from the in-memory cache. Handles v2 migration
233
+ * Parse `state.json` from the in-memory cache. Handles v1→v4 migration
203
234
  * by running through the Zod schema (which requires version: 4).
204
- * Falls back to fresh state if the file is missing or unparseable.
235
+ *
236
+ * Throws {@link GistCorruptError} on parse or schema-validation failure.
237
+ * The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
238
+ * so the caller can recover (#1201).
239
+ *
240
+ * Returning fresh state on failure (the previous behavior) is unsafe in
241
+ * Gist mode because the next `push()` would overwrite the Gist with the
242
+ * empty fallback, silently destroying repoScores, dismissedIssues,
243
+ * guidelines pointers, and digest history.
205
244
  */
206
245
  private parseStateFromCache;
207
246
  /**
@@ -31,12 +31,12 @@
31
31
  * cells, which matches the "last-write-wins by intent" model the rest of
32
32
  * oss-autopilot already assumes for state.json.
33
33
  */
34
- import * as fs from 'fs';
34
+ import * as fs from 'node:fs';
35
35
  import { AgentStateSchema } from './state-schema.js';
36
36
  import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3, migrateV3ToV4, } from './state-persistence.js';
37
37
  import { getGistIdPath, getStateCachePath } from './paths.js';
38
38
  import { debug, warn } from './logger.js';
39
- import { GistPermissionError, GistConcurrencyError, isRateLimitError } from './errors.js';
39
+ import { GistPermissionError, GistConcurrencyError, GistCorruptError, isRateLimitError } from './errors.js';
40
40
  const MODULE = 'gist-store';
41
41
  /**
42
42
  * Extract the ETag header from an Octokit response, tolerating both lower-
@@ -48,6 +48,24 @@ function extractEtag(headers) {
48
48
  return null;
49
49
  return headers.etag ?? headers.ETag ?? null;
50
50
  }
51
+ /**
52
+ * Preserve corrupt Gist content for user recovery. Returns the path written,
53
+ * or null if the preservation itself failed (logged at warn). Mirrors the
54
+ * pattern in state-persistence.ts:401-407 for the local-state path.
55
+ *
56
+ * See {@link GistCorruptError} and issue #1201.
57
+ */
58
+ function preserveRejectedGistContent(raw, gistId) {
59
+ try {
60
+ const rejectedPath = `${getStateCachePath()}.rejected-${Date.now()}`;
61
+ fs.writeFileSync(rejectedPath, raw, { encoding: 'utf8', mode: 0o600 });
62
+ return rejectedPath;
63
+ }
64
+ catch (err) {
65
+ warn(MODULE, `Could not preserve rejected Gist content for ${gistId}: ${err instanceof Error ? err.message : String(err)}`);
66
+ return null;
67
+ }
68
+ }
51
69
  /** Well-known Gist description used for search-based discovery. */
52
70
  export const GIST_DESCRIPTION = 'oss-autopilot-state';
53
71
  /** Primary state file name inside the Gist. */
@@ -70,6 +88,14 @@ export class GistStateStore {
70
88
  octokit;
71
89
  lastRefreshAt = 0;
72
90
  static REFRESH_THROTTLE_MS = 30_000;
91
+ /**
92
+ * Most recent error from a `refreshFromGist()` attempt, or `null` when the
93
+ * last attempt succeeded or was skipped by the throttle. Lets callers
94
+ * (StateManager) distinguish "throttled, nothing new to see" from "fetch
95
+ * failed, you're now operating on stale state" without changing the
96
+ * existing boolean return contract (#1193).
97
+ */
98
+ lastRefreshError = null;
73
99
  constructor(octokit) {
74
100
  this.octokit = octokit;
75
101
  }
@@ -116,10 +142,12 @@ export class GistStateStore {
116
142
  // All API paths failed — enter degraded mode
117
143
  warn(MODULE, 'All Gist API paths failed, entering degraded mode', err);
118
144
  // Try reading from local cache file
145
+ const cachePath = getStateCachePath();
146
+ let cacheRaw = null;
119
147
  try {
120
- const cachePath = getStateCachePath();
121
148
  if (fs.existsSync(cachePath)) {
122
- let obj = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
149
+ cacheRaw = fs.readFileSync(cachePath, 'utf8');
150
+ let obj = JSON.parse(cacheRaw);
123
151
  // Chain migrations
124
152
  if (typeof obj === 'object' && obj !== null) {
125
153
  const record = obj;
@@ -136,7 +164,16 @@ export class GistStateStore {
136
164
  }
137
165
  }
138
166
  catch (cacheErr) {
139
- debug(MODULE, `Failed to read local cache in degraded mode: ${cacheErr}`);
167
+ // Promote to warn (#1201) and preserve corrupt cache so fresh-state
168
+ // fallback doesn't silently destroy recoverable data on next push.
169
+ if (cacheRaw) {
170
+ const rejectedPath = preserveRejectedGistContent(cacheRaw, 'local-cache');
171
+ warn(MODULE, `Local state cache failed to parse in degraded mode: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}. ` +
172
+ `Corrupt cache preserved at: ${rejectedPath ?? '(could not preserve)'}`);
173
+ }
174
+ else {
175
+ warn(MODULE, `Failed to read local cache in degraded mode: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}`);
176
+ }
140
177
  }
141
178
  // No cache either — return fresh state in degraded mode
142
179
  debug(MODULE, 'No local cache found, returning fresh state in degraded mode');
@@ -188,10 +225,12 @@ export class GistStateStore {
188
225
  // All API paths failed — enter degraded mode
189
226
  warn(MODULE, 'bootstrapWithMigration: all Gist API paths failed, entering degraded mode', err);
190
227
  // Try reading from local cache file
228
+ const cachePath = getStateCachePath();
229
+ let cacheRaw = null;
191
230
  try {
192
- const cachePath = getStateCachePath();
193
231
  if (fs.existsSync(cachePath)) {
194
- let obj = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
232
+ cacheRaw = fs.readFileSync(cachePath, 'utf8');
233
+ let obj = JSON.parse(cacheRaw);
195
234
  // Chain migrations
196
235
  if (typeof obj === 'object' && obj !== null) {
197
236
  const record = obj;
@@ -208,7 +247,14 @@ export class GistStateStore {
208
247
  }
209
248
  }
210
249
  catch (cacheErr) {
211
- debug(MODULE, `bootstrapWithMigration: failed to read local cache in degraded mode: ${cacheErr}`);
250
+ if (cacheRaw) {
251
+ const rejectedPath = preserveRejectedGistContent(cacheRaw, 'local-cache');
252
+ warn(MODULE, `bootstrapWithMigration: local cache failed to parse: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}. ` +
253
+ `Corrupt cache preserved at: ${rejectedPath ?? '(could not preserve)'}`);
254
+ }
255
+ else {
256
+ warn(MODULE, `bootstrapWithMigration: failed to read local cache: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}`);
257
+ }
212
258
  }
213
259
  // No cache either — use the provided existingState in degraded mode
214
260
  debug(MODULE, 'bootstrapWithMigration: no local cache found, returning existing state in degraded mode');
@@ -376,21 +422,35 @@ export class GistStateStore {
376
422
  /**
377
423
  * Re-fetch the Gist and update the in-memory cache.
378
424
  * Throttled to at most once per 30 seconds.
425
+ *
426
+ * Returns a discriminated union so callers can tell apart the four
427
+ * outcomes that previously collapsed into a single boolean (#1209 L9):
428
+ * - `{ status: 'refreshed' }` — fresh data loaded successfully.
429
+ * - `{ status: 'no-gist' }` — store not in Gist mode (e.g. degraded).
430
+ * - `{ status: 'throttled', sinceLastMs }` — within the 30s throttle.
431
+ * - `{ status: 'error', error }` — fetch attempt failed.
379
432
  */
380
433
  async refreshFromGist() {
381
434
  if (!this.gistId)
382
- return false;
435
+ return { status: 'no-gist' };
383
436
  const now = Date.now();
384
- if (now - this.lastRefreshAt < GistStateStore.REFRESH_THROTTLE_MS)
385
- return false;
437
+ const sinceLastMs = now - this.lastRefreshAt;
438
+ // Throttle hits are not failures — preserve any previous lastRefreshError
439
+ // for the caller to inspect.
440
+ if (sinceLastMs < GistStateStore.REFRESH_THROTTLE_MS) {
441
+ return { status: 'throttled', sinceLastMs };
442
+ }
386
443
  try {
387
444
  await this.fetchAndCache(this.gistId);
388
445
  this.lastRefreshAt = now;
389
- return true;
446
+ this.lastRefreshError = null;
447
+ return { status: 'refreshed' };
390
448
  }
391
449
  catch (err) {
392
- warn(MODULE, `refreshFromGist failed: ${err}`);
393
- return false;
450
+ const error = err instanceof Error ? err : new Error(String(err));
451
+ warn(MODULE, `refreshFromGist failed: ${error.message}`);
452
+ this.lastRefreshError = error;
453
+ return { status: 'error', error };
394
454
  }
395
455
  }
396
456
  // ── Private helpers ─────────────────────────────────────────────────
@@ -436,9 +496,17 @@ export class GistStateStore {
436
496
  return state;
437
497
  }
438
498
  /**
439
- * Parse `state.json` from the in-memory cache. Handles v2 migration
499
+ * Parse `state.json` from the in-memory cache. Handles v1→v4 migration
440
500
  * by running through the Zod schema (which requires version: 4).
441
- * Falls back to fresh state if the file is missing or unparseable.
501
+ *
502
+ * Throws {@link GistCorruptError} on parse or schema-validation failure.
503
+ * The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
504
+ * so the caller can recover (#1201).
505
+ *
506
+ * Returning fresh state on failure (the previous behavior) is unsafe in
507
+ * Gist mode because the next `push()` would overwrite the Gist with the
508
+ * empty fallback, silently destroying repoScores, dismissedIssues,
509
+ * guidelines pointers, and digest history.
442
510
  */
443
511
  parseStateFromCache() {
444
512
  const raw = this.cachedFiles.get(STATE_FILE_NAME);
@@ -461,8 +529,10 @@ export class GistStateStore {
461
529
  return AgentStateSchema.parse(obj);
462
530
  }
463
531
  catch (err) {
464
- warn(MODULE, `Failed to parse state.json from Gist: ${err}`);
465
- return createFreshState();
532
+ const rejectedPath = preserveRejectedGistContent(raw, this.gistId ?? 'unknown');
533
+ warn(MODULE, `Gist state.json failed to parse — refusing to overwrite with fresh state. ` +
534
+ `Corrupt content preserved at: ${rejectedPath ?? '(could not preserve)'}`);
535
+ throw new GistCorruptError(this.gistId ?? 'unknown', rejectedPath, err);
466
536
  }
467
537
  }
468
538
  /**
@@ -541,7 +611,7 @@ export class GistStateStore {
541
611
  try {
542
612
  const gistIdPath = getGistIdPath();
543
613
  if (fs.existsSync(gistIdPath)) {
544
- const id = fs.readFileSync(gistIdPath, 'utf-8').trim();
614
+ const id = fs.readFileSync(gistIdPath, 'utf8').trim();
545
615
  return id || null;
546
616
  }
547
617
  }
@@ -2,7 +2,7 @@ import { OssAutopilotError } from './errors.js';
2
2
  /** Filename prefix shared by every guidelines file in the Gist. */
3
3
  export const GUIDELINES_FILE_PREFIX = 'guidelines--';
4
4
  /** Hard byte budget for a single guidelines file (#867 design log §1). */
5
- export const GUIDELINES_MAX_BYTES = 8_192;
5
+ export const GUIDELINES_MAX_BYTES = 8192;
6
6
  /** Suffix appended to the filename so it renders as markdown in Gist. */
7
7
  const GUIDELINES_FILE_SUFFIX = '.md';
8
8
  /**
@@ -83,7 +83,7 @@ export function getGuidelines(store, repo) {
83
83
  export function setGuidelines(store, repo, content) {
84
84
  if (!store)
85
85
  throw new GuidelinesNotAvailableError();
86
- const byteSize = Buffer.byteLength(content, 'utf-8');
86
+ const byteSize = Buffer.byteLength(content, 'utf8');
87
87
  if (byteSize > GUIDELINES_MAX_BYTES) {
88
88
  throw new GuidelinesTooLargeError(byteSize);
89
89
  }