@oss-autopilot/core 3.1.0 → 3.3.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 (66) hide show
  1. package/dist/cli-registry.js +113 -3
  2. package/dist/cli.bundle.cjs +96 -92
  3. package/dist/commands/check-integration.js +8 -8
  4. package/dist/commands/comments.js +3 -0
  5. package/dist/commands/config.js +14 -7
  6. package/dist/commands/daily-render.js +10 -5
  7. package/dist/commands/daily.js +6 -1
  8. package/dist/commands/dashboard-lifecycle.js +1 -1
  9. package/dist/commands/dashboard-process.js +4 -4
  10. package/dist/commands/dashboard-server.js +7 -6
  11. package/dist/commands/dashboard.js +2 -2
  12. package/dist/commands/detect-formatters.js +3 -3
  13. package/dist/commands/doctor.js +5 -5
  14. package/dist/commands/guidelines.d.ts +67 -0
  15. package/dist/commands/guidelines.js +159 -0
  16. package/dist/commands/index.d.ts +9 -0
  17. package/dist/commands/index.js +9 -0
  18. package/dist/commands/list-move-tier.js +5 -5
  19. package/dist/commands/local-repos.js +9 -9
  20. package/dist/commands/parse-list.js +10 -10
  21. package/dist/commands/scout-bridge.js +2 -2
  22. package/dist/commands/setup.js +24 -13
  23. package/dist/commands/skip-add.js +6 -3
  24. package/dist/commands/skip-file-parser.js +3 -3
  25. package/dist/commands/startup.js +11 -8
  26. package/dist/commands/state-cmd.js +1 -1
  27. package/dist/commands/status.js +7 -0
  28. package/dist/commands/validation.js +3 -3
  29. package/dist/commands/vet-list.js +12 -8
  30. package/dist/commands/vet.js +1 -2
  31. package/dist/core/__fixtures__/prompt-injection-payloads.d.ts +22 -0
  32. package/dist/core/__fixtures__/prompt-injection-payloads.js +109 -0
  33. package/dist/core/anti-llm-policy.js +5 -5
  34. package/dist/core/auth.js +5 -5
  35. package/dist/core/daily-logic.js +8 -4
  36. package/dist/core/dates.js +3 -3
  37. package/dist/core/errors.d.ts +29 -0
  38. package/dist/core/errors.js +63 -0
  39. package/dist/core/formatter-detection.js +9 -9
  40. package/dist/core/gist-state-store.d.ts +19 -3
  41. package/dist/core/gist-state-store.js +81 -15
  42. package/dist/core/guidelines-store.d.ts +74 -0
  43. package/dist/core/guidelines-store.js +130 -0
  44. package/dist/core/http-cache.js +6 -6
  45. package/dist/core/index.d.ts +2 -0
  46. package/dist/core/index.js +2 -0
  47. package/dist/core/issue-conversation.js +3 -1
  48. package/dist/core/paths.js +4 -4
  49. package/dist/core/pr-comments-fetcher.d.ts +67 -0
  50. package/dist/core/pr-comments-fetcher.js +125 -0
  51. package/dist/core/pr-monitor.js +1 -2
  52. package/dist/core/pr-template.js +1 -1
  53. package/dist/core/state-persistence.d.ts +6 -0
  54. package/dist/core/state-persistence.js +27 -9
  55. package/dist/core/state-schema.d.ts +5 -1
  56. package/dist/core/state-schema.js +7 -1
  57. package/dist/core/state.d.ts +60 -0
  58. package/dist/core/state.js +136 -13
  59. package/dist/core/types.d.ts +1 -1
  60. package/dist/core/types.js +2 -2
  61. package/dist/core/untrusted-content.d.ts +48 -0
  62. package/dist/core/untrusted-content.js +106 -0
  63. package/dist/core/urls.js +2 -2
  64. package/dist/formatters/json.d.ts +53 -3
  65. package/dist/formatters/json.js +49 -14
  66. package/package.json +1 -1
package/dist/core/auth.js CHANGED
@@ -6,7 +6,7 @@
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
11
  import { debug } from './logger.js';
12
12
  const MODULE = 'auth';
@@ -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();
@@ -101,7 +101,7 @@ export async function getGitHubTokenAsync() {
101
101
  }
102
102
  try {
103
103
  const token = await new Promise((resolve, reject) => {
104
- execFile('gh', ['auth', 'token'], { encoding: 'utf-8', timeout: 2000 }, (error, stdout) => {
104
+ execFile('gh', ['auth', 'token'], { encoding: 'utf8', timeout: 2000 }, (error, stdout) => {
105
105
  if (error) {
106
106
  reject(error);
107
107
  }
@@ -126,7 +126,7 @@ export async function getGitHubTokenAsync() {
126
126
  * Usernames must start with an alphanumeric character, can contain hyphens
127
127
  * (but not consecutive ones and not at the end), and be 1-39 characters.
128
128
  */
129
- const GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
129
+ const GITHUB_USERNAME_RE = /^[\da-z](?:[\da-z]|-(?=[\da-z])){0,38}$/i;
130
130
  /**
131
131
  * Detect the authenticated GitHub username via the `gh` CLI.
132
132
  *
@@ -137,7 +137,7 @@ const GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/
137
137
  export async function detectGitHubUsername() {
138
138
  try {
139
139
  const login = await new Promise((resolve, reject) => {
140
- execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf-8', timeout: 5000 }, (error, stdout) => {
140
+ execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', timeout: 5000 }, (error, stdout) => {
141
141
  if (error) {
142
142
  reject(error);
143
143
  }
@@ -232,37 +232,41 @@ export function collectActionableIssues(prs, lastDigestAt) {
232
232
  let label;
233
233
  let type;
234
234
  switch (reason) {
235
- case 'needs_response':
235
+ case 'needs_response': {
236
236
  label = '[Needs Response]';
237
237
  type = 'needs_response';
238
238
  break;
239
- case 'needs_changes':
239
+ }
240
+ case 'needs_changes': {
240
241
  label = '[Needs Changes]';
241
242
  type = 'needs_changes';
242
243
  break;
244
+ }
243
245
  case 'failing_ci': {
244
246
  const checkInfo = pr.failingCheckNames.length > 0 ? ` (${pr.failingCheckNames.join(', ')})` : '';
245
247
  label = `[CI Failing${checkInfo}]`;
246
248
  type = 'ci_failing';
247
249
  break;
248
250
  }
249
- case 'merge_conflict':
251
+ case 'merge_conflict': {
250
252
  label = '[Merge Conflict]';
251
253
  type = 'merge_conflict';
252
254
  break;
255
+ }
253
256
  case 'incomplete_checklist': {
254
257
  const stats = pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : '';
255
258
  label = `[Incomplete Checklist${stats}]`;
256
259
  type = 'incomplete_checklist';
257
260
  break;
258
261
  }
259
- default:
262
+ default: {
260
263
  // Defensive fallback for ActionReason values not explicitly handled
261
264
  // above (e.g. ci_not_running, needs_rebase, missing_required_files).
262
265
  // These aren't in reasonOrder today but this guards future additions.
263
266
  warn('daily-logic', `Unhandled ActionReason "${reason}" for PR ${pr.url} — falling back to needs_response`);
264
267
  label = `[${reason}]`;
265
268
  type = 'needs_response';
269
+ }
266
270
  }
267
271
  // A PR is "new" if it was created after the last daily digest (first time seen).
268
272
  // 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)}`);
@@ -121,6 +121,14 @@ export declare class GistStateStore {
121
121
  private readonly octokit;
122
122
  private lastRefreshAt;
123
123
  private static readonly REFRESH_THROTTLE_MS;
124
+ /**
125
+ * Most recent error from a `refreshFromGist()` attempt, or `null` when the
126
+ * last attempt succeeded or was skipped by the throttle. Lets callers
127
+ * (StateManager) distinguish "throttled, nothing new to see" from "fetch
128
+ * failed, you're now operating on stale state" without changing the
129
+ * existing boolean return contract (#1193).
130
+ */
131
+ lastRefreshError: Error | null;
124
132
  constructor(octokit: OctokitLike);
125
133
  /**
126
134
  * Bootstrap the Gist store: locate or create the backing Gist,
@@ -199,9 +207,17 @@ export declare class GistStateStore {
199
207
  */
200
208
  private fetchAndCache;
201
209
  /**
202
- * Parse `state.json` from the in-memory cache. Handles v2 migration
203
- * by running through the Zod schema (which requires version: 3).
204
- * Falls back to fresh state if the file is missing or unparseable.
210
+ * Parse `state.json` from the in-memory cache. Handles v1→v4 migration
211
+ * by running through the Zod schema (which requires version: 4).
212
+ *
213
+ * Throws {@link GistCorruptError} on parse or schema-validation failure.
214
+ * The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
215
+ * so the caller can recover (#1201).
216
+ *
217
+ * Returning fresh state on failure (the previous behavior) is unsafe in
218
+ * Gist mode because the next `push()` would overwrite the Gist with the
219
+ * empty fallback, silently destroying repoScores, dismissedIssues,
220
+ * guidelines pointers, and digest history.
205
221
  */
206
222
  private parseStateFromCache;
207
223
  /**
@@ -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
- import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3 } from './state-persistence.js';
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;
@@ -127,6 +155,8 @@ export class GistStateStore {
127
155
  obj = migrateV1ToV2(record);
128
156
  if (obj.version === 2)
129
157
  obj = migrateV2ToV3(obj);
158
+ if (obj.version === 3)
159
+ obj = migrateV3ToV4(obj);
130
160
  }
131
161
  const cachedState = AgentStateSchema.parse(obj);
132
162
  debug(MODULE, 'Loaded state from local cache in degraded mode');
@@ -134,7 +164,16 @@ export class GistStateStore {
134
164
  }
135
165
  }
136
166
  catch (cacheErr) {
137
- 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
+ }
138
177
  }
139
178
  // No cache either — return fresh state in degraded mode
140
179
  debug(MODULE, 'No local cache found, returning fresh state in degraded mode');
@@ -186,10 +225,12 @@ export class GistStateStore {
186
225
  // All API paths failed — enter degraded mode
187
226
  warn(MODULE, 'bootstrapWithMigration: all Gist API paths failed, entering degraded mode', err);
188
227
  // Try reading from local cache file
228
+ const cachePath = getStateCachePath();
229
+ let cacheRaw = null;
189
230
  try {
190
- const cachePath = getStateCachePath();
191
231
  if (fs.existsSync(cachePath)) {
192
- let obj = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
232
+ cacheRaw = fs.readFileSync(cachePath, 'utf8');
233
+ let obj = JSON.parse(cacheRaw);
193
234
  // Chain migrations
194
235
  if (typeof obj === 'object' && obj !== null) {
195
236
  const record = obj;
@@ -197,6 +238,8 @@ export class GistStateStore {
197
238
  obj = migrateV1ToV2(record);
198
239
  if (obj.version === 2)
199
240
  obj = migrateV2ToV3(obj);
241
+ if (obj.version === 3)
242
+ obj = migrateV3ToV4(obj);
200
243
  }
201
244
  const cachedState = AgentStateSchema.parse(obj);
202
245
  debug(MODULE, 'bootstrapWithMigration: loaded state from local cache in degraded mode');
@@ -204,7 +247,14 @@ export class GistStateStore {
204
247
  }
205
248
  }
206
249
  catch (cacheErr) {
207
- 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
+ }
208
258
  }
209
259
  // No cache either — use the provided existingState in degraded mode
210
260
  debug(MODULE, 'bootstrapWithMigration: no local cache found, returning existing state in degraded mode');
@@ -377,15 +427,19 @@ export class GistStateStore {
377
427
  if (!this.gistId)
378
428
  return false;
379
429
  const now = Date.now();
430
+ // Throttle hits are not failures — preserve any previous lastRefreshError
431
+ // for the caller to inspect.
380
432
  if (now - this.lastRefreshAt < GistStateStore.REFRESH_THROTTLE_MS)
381
433
  return false;
382
434
  try {
383
435
  await this.fetchAndCache(this.gistId);
384
436
  this.lastRefreshAt = now;
437
+ this.lastRefreshError = null;
385
438
  return true;
386
439
  }
387
440
  catch (err) {
388
441
  warn(MODULE, `refreshFromGist failed: ${err}`);
442
+ this.lastRefreshError = err instanceof Error ? err : new Error(String(err));
389
443
  return false;
390
444
  }
391
445
  }
@@ -432,9 +486,17 @@ export class GistStateStore {
432
486
  return state;
433
487
  }
434
488
  /**
435
- * Parse `state.json` from the in-memory cache. Handles v2 migration
436
- * by running through the Zod schema (which requires version: 3).
437
- * Falls back to fresh state if the file is missing or unparseable.
489
+ * Parse `state.json` from the in-memory cache. Handles v1→v4 migration
490
+ * by running through the Zod schema (which requires version: 4).
491
+ *
492
+ * Throws {@link GistCorruptError} on parse or schema-validation failure.
493
+ * The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
494
+ * so the caller can recover (#1201).
495
+ *
496
+ * Returning fresh state on failure (the previous behavior) is unsafe in
497
+ * Gist mode because the next `push()` would overwrite the Gist with the
498
+ * empty fallback, silently destroying repoScores, dismissedIssues,
499
+ * guidelines pointers, and digest history.
438
500
  */
439
501
  parseStateFromCache() {
440
502
  const raw = this.cachedFiles.get(STATE_FILE_NAME);
@@ -451,12 +513,16 @@ export class GistStateStore {
451
513
  obj = migrateV1ToV2(record);
452
514
  if (obj.version === 2)
453
515
  obj = migrateV2ToV3(obj);
516
+ if (obj.version === 3)
517
+ obj = migrateV3ToV4(obj);
454
518
  }
455
519
  return AgentStateSchema.parse(obj);
456
520
  }
457
521
  catch (err) {
458
- warn(MODULE, `Failed to parse state.json from Gist: ${err}`);
459
- return createFreshState();
522
+ const rejectedPath = preserveRejectedGistContent(raw, this.gistId ?? 'unknown');
523
+ warn(MODULE, `Gist state.json failed to parse — refusing to overwrite with fresh state. ` +
524
+ `Corrupt content preserved at: ${rejectedPath ?? '(could not preserve)'}`);
525
+ throw new GistCorruptError(this.gistId ?? 'unknown', rejectedPath, err);
460
526
  }
461
527
  }
462
528
  /**
@@ -535,7 +601,7 @@ export class GistStateStore {
535
601
  try {
536
602
  const gistIdPath = getGistIdPath();
537
603
  if (fs.existsSync(gistIdPath)) {
538
- const id = fs.readFileSync(gistIdPath, 'utf-8').trim();
604
+ const id = fs.readFileSync(gistIdPath, 'utf8').trim();
539
605
  return id || null;
540
606
  }
541
607
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Per-repo guidelines persistence on top of the Gist freeform-document API
3
+ * (#867 PR 2).
4
+ *
5
+ * Each repo gets one markdown file named `guidelines--{owner}--{repo}.md`
6
+ * stored in the user's oss-autopilot Gist alongside `state.json`. The file
7
+ * holds extracted guidance from past PR review feedback. Reads and writes go
8
+ * through the in-memory cache that `GistStateStore` already maintains; the
9
+ * file persists on the next `push()`.
10
+ *
11
+ * The byte cap (8 KB) is enforced at write time to keep claim-time context
12
+ * injection small. Larger guidance should be split across categories or
13
+ * deferred to a follow-up consolidation pass.
14
+ */
15
+ import type { GistStateStore } from './gist-state-store.js';
16
+ import { OssAutopilotError } from './errors.js';
17
+ /** Filename prefix shared by every guidelines file in the Gist. */
18
+ export declare const GUIDELINES_FILE_PREFIX = "guidelines--";
19
+ /** Hard byte budget for a single guidelines file (#867 design log §1). */
20
+ export declare const GUIDELINES_MAX_BYTES = 8192;
21
+ /**
22
+ * Convert an `owner/repo` pair into the filename used inside the Gist.
23
+ * Slashes are escaped as `--` so the filename is filesystem-safe and
24
+ * unambiguous when parsing back to a repo string.
25
+ */
26
+ export declare function guidelinesFilename(repo: string): string;
27
+ /**
28
+ * Inverse of {@link guidelinesFilename}. Returns null when the filename
29
+ * doesn't match the guidelines convention.
30
+ */
31
+ export declare function repoFromGuidelinesFilename(filename: string): string | null;
32
+ /**
33
+ * Thrown by {@link setGuidelines} / {@link deleteGuidelines} when the
34
+ * StateManager is not in Gist mode. Catch + degrade gracefully when surfacing
35
+ * to user-facing flows: per-repo guidelines simply aren't available without a
36
+ * Gist to store them in.
37
+ */
38
+ export declare class GuidelinesNotAvailableError extends OssAutopilotError {
39
+ constructor(message?: string);
40
+ }
41
+ /**
42
+ * Thrown by {@link setGuidelines} when content exceeds {@link GUIDELINES_MAX_BYTES}.
43
+ * Surfaced separately from generic validation errors so consumers can prompt the
44
+ * user with a "trim or split" UX rather than a generic shape rejection.
45
+ */
46
+ export declare class GuidelinesTooLargeError extends OssAutopilotError {
47
+ constructor(byteSize: number, max?: number);
48
+ }
49
+ /**
50
+ * Read the guidelines file for a repo from the Gist cache. Returns null when
51
+ * the store is not in Gist mode, the file does not exist, or the file is
52
+ * present but empty (treated as a tombstone left by {@link deleteGuidelines}).
53
+ */
54
+ export declare function getGuidelines(store: GistStateStore | null, repo: string): string | null;
55
+ /**
56
+ * Write or replace the guidelines file for a repo. Throws if the store is not
57
+ * in Gist mode or the content exceeds the byte budget.
58
+ */
59
+ export declare function setGuidelines(store: GistStateStore | null, repo: string, content: string): void;
60
+ /**
61
+ * Delete the guidelines file for a repo. No-op if the file doesn't exist.
62
+ * Implementation: write an empty string. The Gist API treats files with
63
+ * empty content as deletions on the next push, matching the existing
64
+ * single-source-of-truth model.
65
+ */
66
+ export declare function deleteGuidelines(store: GistStateStore | null, repo: string): void;
67
+ /**
68
+ * List every repo (as `owner/repo`) that has a non-empty guidelines file in
69
+ * the cache. Tombstoned (empty-content) files are excluded so the result
70
+ * matches what {@link getGuidelines} would actually return.
71
+ *
72
+ * Returns an empty array when the store is null or no files exist.
73
+ */
74
+ export declare function listGuidelinesRepos(store: GistStateStore | null): string[];