@oss-autopilot/core 3.2.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 (55) hide show
  1. package/dist/cli-registry.js +33 -3
  2. package/dist/cli.bundle.cjs +96 -93
  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.js +15 -3
  15. package/dist/commands/list-move-tier.js +5 -5
  16. package/dist/commands/local-repos.js +9 -9
  17. package/dist/commands/parse-list.js +10 -10
  18. package/dist/commands/scout-bridge.js +2 -2
  19. package/dist/commands/setup.js +24 -13
  20. package/dist/commands/skip-add.js +6 -3
  21. package/dist/commands/skip-file-parser.js +3 -3
  22. package/dist/commands/startup.js +11 -8
  23. package/dist/commands/state-cmd.js +1 -1
  24. package/dist/commands/status.js +7 -0
  25. package/dist/commands/validation.js +3 -3
  26. package/dist/commands/vet-list.js +12 -8
  27. package/dist/commands/vet.js +1 -2
  28. package/dist/core/__fixtures__/prompt-injection-payloads.d.ts +22 -0
  29. package/dist/core/__fixtures__/prompt-injection-payloads.js +109 -0
  30. package/dist/core/anti-llm-policy.js +5 -5
  31. package/dist/core/auth.js +5 -5
  32. package/dist/core/daily-logic.js +8 -4
  33. package/dist/core/dates.js +3 -3
  34. package/dist/core/errors.d.ts +29 -0
  35. package/dist/core/errors.js +63 -0
  36. package/dist/core/formatter-detection.js +9 -9
  37. package/dist/core/gist-state-store.d.ts +18 -2
  38. package/dist/core/gist-state-store.js +73 -13
  39. package/dist/core/guidelines-store.js +2 -2
  40. package/dist/core/http-cache.js +6 -6
  41. package/dist/core/index.d.ts +1 -0
  42. package/dist/core/index.js +1 -0
  43. package/dist/core/issue-conversation.js +3 -1
  44. package/dist/core/paths.js +4 -4
  45. package/dist/core/pr-monitor.js +1 -2
  46. package/dist/core/pr-template.js +1 -1
  47. package/dist/core/state-persistence.js +7 -7
  48. package/dist/core/state.d.ts +27 -0
  49. package/dist/core/state.js +66 -13
  50. package/dist/core/untrusted-content.d.ts +48 -0
  51. package/dist/core/untrusted-content.js +106 -0
  52. package/dist/core/urls.js +2 -2
  53. package/dist/formatters/json.d.ts +53 -3
  54. package/dist/formatters/json.js +49 -14
  55. package/package.json +1 -1
@@ -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
210
+ * Parse `state.json` from the in-memory cache. Handles v1→v4 migration
203
211
  * by running through the Zod schema (which requires version: 4).
204
- * Falls back to fresh state if the file is missing or unparseable.
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
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');
@@ -381,15 +427,19 @@ export class GistStateStore {
381
427
  if (!this.gistId)
382
428
  return false;
383
429
  const now = Date.now();
430
+ // Throttle hits are not failures — preserve any previous lastRefreshError
431
+ // for the caller to inspect.
384
432
  if (now - this.lastRefreshAt < GistStateStore.REFRESH_THROTTLE_MS)
385
433
  return false;
386
434
  try {
387
435
  await this.fetchAndCache(this.gistId);
388
436
  this.lastRefreshAt = now;
437
+ this.lastRefreshError = null;
389
438
  return true;
390
439
  }
391
440
  catch (err) {
392
441
  warn(MODULE, `refreshFromGist failed: ${err}`);
442
+ this.lastRefreshError = err instanceof Error ? err : new Error(String(err));
393
443
  return false;
394
444
  }
395
445
  }
@@ -436,9 +486,17 @@ export class GistStateStore {
436
486
  return state;
437
487
  }
438
488
  /**
439
- * Parse `state.json` from the in-memory cache. Handles v2 migration
489
+ * Parse `state.json` from the in-memory cache. Handles v1→v4 migration
440
490
  * by running through the Zod schema (which requires version: 4).
441
- * Falls back to fresh state if the file is missing or unparseable.
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.
442
500
  */
443
501
  parseStateFromCache() {
444
502
  const raw = this.cachedFiles.get(STATE_FILE_NAME);
@@ -461,8 +519,10 @@ export class GistStateStore {
461
519
  return AgentStateSchema.parse(obj);
462
520
  }
463
521
  catch (err) {
464
- warn(MODULE, `Failed to parse state.json from Gist: ${err}`);
465
- 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);
466
526
  }
467
527
  }
468
528
  /**
@@ -541,7 +601,7 @@ export class GistStateStore {
541
601
  try {
542
602
  const gistIdPath = getGistIdPath();
543
603
  if (fs.existsSync(gistIdPath)) {
544
- const id = fs.readFileSync(gistIdPath, 'utf-8').trim();
604
+ const id = fs.readFileSync(gistIdPath, 'utf8').trim();
545
605
  return id || null;
546
606
  }
547
607
  }
@@ -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
  }
@@ -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) {
@@ -100,7 +100,7 @@ export class HttpCache {
100
100
  cachedAt: new Date().toISOString(),
101
101
  };
102
102
  try {
103
- fs.writeFileSync(this.pathFor(url), JSON.stringify(entry), { encoding: 'utf-8', mode: 0o600 });
103
+ fs.writeFileSync(this.pathFor(url), JSON.stringify(entry), { encoding: 'utf8', mode: 0o600 });
104
104
  debug(MODULE, `Cached response for ${url}`);
105
105
  // Best-effort size cap (#1057 M27). Runs after each write rather than on
106
106
  // a schedule so long-lived sessions can't accumulate past the cap.
@@ -191,7 +191,7 @@ export class HttpCache {
191
191
  continue;
192
192
  const filePath = path.join(this.cacheDir, file);
193
193
  try {
194
- const raw = fs.readFileSync(filePath, 'utf-8');
194
+ const raw = fs.readFileSync(filePath, 'utf8');
195
195
  const entry = JSON.parse(raw);
196
196
  const age = now - new Date(entry.cachedAt).getTime();
197
197
  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';
@@ -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';
@@ -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';
@@ -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';
@@ -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
  }
@@ -3,8 +3,8 @@
3
3
  * Handles file I/O, locking, backup/restore, and schema migration (v1→v2→v3).
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
  }
@@ -273,7 +273,7 @@ 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
278
  // Chain migrations: v1 → v2 → v3
279
279
  if (typeof raw === 'object' && raw !== null) {
@@ -325,7 +325,7 @@ export function loadState() {
325
325
  const statePath = getStatePath();
326
326
  try {
327
327
  if (fs.existsSync(statePath)) {
328
- const data = fs.readFileSync(statePath, 'utf-8');
328
+ const data = fs.readFileSync(statePath, 'utf8');
329
329
  let raw = JSON.parse(data);
330
330
  // Chain migrations: v1 → v2 → v3 → v4
331
331
  let wasMigrated = false;
@@ -491,7 +491,7 @@ export function saveState(state, expectedMtimeMs = null) {
491
491
  // Create backup of existing state (best-effort, non-fatal)
492
492
  try {
493
493
  if (fs.existsSync(statePath)) {
494
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
494
+ const timestamp = new Date().toISOString().replace(/[.:]/g, '-');
495
495
  const randomSuffix = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
496
496
  const backupFile = path.join(backupDir, `state-${timestamp}-${randomSuffix}.json`);
497
497
  fs.copyFileSync(statePath, backupFile);
@@ -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