@oss-autopilot/core 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -49,6 +49,23 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
49
49
  console.error('Then run your command again.');
50
50
  process.exit(1);
51
51
  }
52
+ // Activate Gist persistence if configured, before any command runs.
53
+ // Peek at the config file directly to avoid creating a local-only singleton
54
+ // (getStateManager() would lock in local mode before getStateManagerAsync runs).
55
+ let persistence;
56
+ try {
57
+ const { getStatePath } = await import('./core/index.js');
58
+ const fs = await import('fs');
59
+ const raw = fs.readFileSync(getStatePath(), 'utf-8');
60
+ persistence = JSON.parse(raw)?.config?.persistence;
61
+ }
62
+ catch {
63
+ // No state file or unreadable — local mode, lazy init
64
+ }
65
+ if (persistence === 'gist' && token) {
66
+ const { getStateManagerAsync } = await import('./core/index.js');
67
+ await getStateManagerAsync(token);
68
+ }
52
69
  }
53
70
  });
54
71
  // First-run detection: if no subcommand was provided and no state file exists,
@@ -185,6 +185,17 @@ export async function runClaim(options) {
185
185
  updatedAt: new Date().toISOString(),
186
186
  vetted: false,
187
187
  });
188
+ // Push state to Gist if in Gist mode.
189
+ // If getStateManagerAsync was not called before this command ran,
190
+ // isGistMode() will be false and checkpoint is correctly skipped.
191
+ try {
192
+ if (stateManager.isGistMode()) {
193
+ await stateManager.checkpoint();
194
+ }
195
+ }
196
+ catch {
197
+ /* best-effort */
198
+ }
188
199
  }
189
200
  catch (error) {
190
201
  console.error(`Warning: Comment posted on ${options.issueUrl} but failed to save to local state: ${error instanceof Error ? error.message : error}`);
@@ -3,8 +3,7 @@
3
3
  * Shows or updates configuration
4
4
  */
5
5
  import { getStateManager } from '../core/index.js';
6
- import { ValidationError } from '../core/errors.js';
7
- import { ISSUE_SCOPES } from '../core/types.js';
6
+ import { ISSUE_SCOPES, DIFF_TOOLS } from '../core/types.js';
8
7
  import { validateGitHubUsername } from './validation.js';
9
8
  function validateScope(value) {
10
9
  if (!ISSUE_SCOPES.includes(value)) {
@@ -106,14 +105,18 @@ export async function runConfig(options) {
106
105
  case 'issueListPath':
107
106
  stateManager.updateConfig({ issueListPath: value || undefined });
108
107
  break;
109
- case 'scoreThreshold': {
110
- const threshold = Number(value);
111
- if (!Number.isInteger(threshold) || threshold < 1 || threshold > 10) {
112
- throw new ValidationError(`Invalid value for scoreThreshold: "${value}". Must be an integer between 1 and 10.`);
108
+ case 'diffTool': {
109
+ if (!DIFF_TOOLS.includes(value)) {
110
+ throw new Error(`Invalid diffTool "${value}". Valid options: ${DIFF_TOOLS.join(', ')}`);
113
111
  }
114
- stateManager.updateConfig({ scoreThreshold: threshold });
112
+ stateManager.updateConfig({ diffTool: value });
115
113
  break;
116
114
  }
115
+ case 'diffToolCustomCommand':
116
+ stateManager.updateConfig({
117
+ diffToolCustomCommand: value || undefined,
118
+ });
119
+ break;
117
120
  default:
118
121
  throw new Error(`Unknown config key: ${options.key}`);
119
122
  }
@@ -424,11 +424,21 @@ async function executeDailyCheckInternal(token) {
424
424
  const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token);
425
425
  // Phase 2: Update repo scores (signals, star counts, trust sync)
426
426
  await updateRepoScores(prMonitor, prs, mergedCounts, closedCounts);
427
- // Phase 3: Persist monthly analytics (batch the 3 monthly setter calls).
427
+ // Phase 3: Persist monthly analytics and store merged/closed PR history.
428
428
  // try-catch: analytics are supplementary — save failure should not crash the daily check.
429
429
  try {
430
430
  getStateManager().batch(() => {
431
431
  updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
432
+ // Store recently merged/closed PRs in the persistent arrays.
433
+ // This ensures the mergedPRs/closedPRs ledger is populated even when
434
+ // the dashboard is never opened (which has its own fetch path).
435
+ // addMergedPRs/addClosedPRs deduplicate by URL, so overlaps are safe.
436
+ if (recentlyMergedPRs.length > 0) {
437
+ getStateManager().addMergedPRs(recentlyMergedPRs.map((pr) => ({ url: pr.url, title: pr.title, mergedAt: pr.mergedAt })));
438
+ }
439
+ if (recentlyClosedPRs.length > 0) {
440
+ getStateManager().addClosedPRs(recentlyClosedPRs.map((pr) => ({ url: pr.url, title: pr.title, closedAt: pr.closedAt })));
441
+ }
432
442
  });
433
443
  }
434
444
  catch (error) {
@@ -440,7 +450,20 @@ async function executeDailyCheckInternal(token) {
440
450
  // Phase 4: Partition PRs, generate and save digest
441
451
  const { activePRs, shelvedPRs, digest } = partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs);
442
452
  // Phase 5: Build structured output (capacity, dismiss filter, action menu)
443
- return generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, previousLastDigestAt);
453
+ const result = generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, previousLastDigestAt);
454
+ // Checkpoint: push state to Gist if in Gist mode.
455
+ // If getStateManagerAsync was not called before this command ran,
456
+ // isGistMode() will be false and checkpoint is correctly skipped.
457
+ try {
458
+ const sm = getStateManager();
459
+ if (sm.isGistMode()) {
460
+ await sm.checkpoint();
461
+ }
462
+ }
463
+ catch (err) {
464
+ warn(MODULE, `Gist checkpoint failed: ${errorMessage(err)}`);
465
+ }
466
+ return result;
444
467
  }
445
468
  /**
446
469
  * Run the daily PR check and return a deduplicated digest.
@@ -209,8 +209,15 @@ export async function startDashboardServer(options) {
209
209
  sendError(res, 429, 'Too many requests');
210
210
  return;
211
211
  }
212
- // Re-read state.json if CLI commands modified it externally
213
- if (stateManager.reloadIfChanged()) {
212
+ // Re-read state if modified externally (file mtime for local, Gist API for Gist mode)
213
+ let stateChanged = false;
214
+ if (stateManager.isGistMode()) {
215
+ stateChanged = await stateManager.refreshFromGist();
216
+ }
217
+ else {
218
+ stateChanged = stateManager.reloadIfChanged();
219
+ }
220
+ if (stateChanged) {
214
221
  try {
215
222
  cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
216
223
  }
@@ -268,7 +275,12 @@ export async function startDashboardServer(options) {
268
275
  // ── POST /api/action handler ─────────────────────────────────────────────
269
276
  async function handleAction(req, res) {
270
277
  // Reload state before mutating to avoid overwriting external CLI changes
271
- stateManager.reloadIfChanged();
278
+ if (stateManager.isGistMode()) {
279
+ await stateManager.refreshFromGist();
280
+ }
281
+ else {
282
+ stateManager.reloadIfChanged();
283
+ }
272
284
  let body;
273
285
  try {
274
286
  const raw = await readBody(req);
@@ -334,7 +346,12 @@ export async function startDashboardServer(options) {
334
346
  return;
335
347
  }
336
348
  try {
337
- stateManager.reloadIfChanged();
349
+ if (stateManager.isGistMode()) {
350
+ await stateManager.refreshFromGist();
351
+ }
352
+ else {
353
+ stateManager.reloadIfChanged();
354
+ }
338
355
  warn(MODULE, 'Refreshing dashboard data from GitHub...');
339
356
  const result = await fetchDashboardData(currentToken);
340
357
  cachedDigest = result.digest;
@@ -449,8 +466,13 @@ export async function startDashboardServer(options) {
449
466
  // so subsequent /api/data requests get live data instead of cached state.
450
467
  if (token) {
451
468
  fetchDashboardData(token)
452
- .then((result) => {
453
- stateManager.reloadIfChanged();
469
+ .then(async (result) => {
470
+ if (stateManager.isGistMode()) {
471
+ await stateManager.refreshFromGist();
472
+ }
473
+ else {
474
+ stateManager.reloadIfChanged();
475
+ }
454
476
  cachedDigest = result.digest;
455
477
  cachedCommentedIssues = result.commentedIssues;
456
478
  cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs);
@@ -55,6 +55,12 @@ export { runInit } from './init.js';
55
55
  export { runSetup } from './setup.js';
56
56
  /** Check whether setup has been completed. */
57
57
  export { runCheckSetup } from './setup.js';
58
+ /** Show current persistence mode, Gist ID, and sync status. */
59
+ export { runStateShow } from './state-cmd.js';
60
+ /** Force push state to the backing Gist (no-op in local mode). */
61
+ export { runStateSync } from './state-cmd.js';
62
+ /** Disconnect from Gist persistence and switch to local-only mode. */
63
+ export { runStateUnlink } from './state-cmd.js';
58
64
  /** Parse a curated markdown issue list file into structured issue items. */
59
65
  export { runParseList, pruneIssueList } from './parse-list.js';
60
66
  /** Check if new files are properly referenced/integrated. */
@@ -76,3 +82,4 @@ export type { InitOutput } from './init.js';
76
82
  export type { ConfigSetOutput, ConfigCommandOutput } from './config.js';
77
83
  export type { SetupSetOutput, SetupCompleteOutput, SetupRequiredOutput, SetupOutput, CheckSetupOutput, } from './setup.js';
78
84
  export type { DailyCheckResult } from './daily.js';
85
+ export type { StateShowOutput, StateSyncOutput, StateUnlinkOutput } from './state-cmd.js';
@@ -59,6 +59,13 @@ export { runInit } from './init.js';
59
59
  export { runSetup } from './setup.js';
60
60
  /** Check whether setup has been completed. */
61
61
  export { runCheckSetup } from './setup.js';
62
+ // ── State Persistence ────────────────────────────────────────────────────────
63
+ /** Show current persistence mode, Gist ID, and sync status. */
64
+ export { runStateShow } from './state-cmd.js';
65
+ /** Force push state to the backing Gist (no-op in local mode). */
66
+ export { runStateSync } from './state-cmd.js';
67
+ /** Disconnect from Gist persistence and switch to local-only mode. */
68
+ export { runStateUnlink } from './state-cmd.js';
62
69
  // ── Utilities ───────────────────────────────────────────────────────────────
63
70
  /** Parse a curated markdown issue list file into structured issue items. */
64
71
  export { runParseList, pruneIssueList } from './parse-list.js';
@@ -24,7 +24,7 @@ export interface SetupCompleteOutput {
24
24
  projectCategories: ProjectCategory[];
25
25
  preferredOrgs: string[];
26
26
  scope: IssueScope[];
27
- scoreThreshold: number;
27
+ persistence: 'local' | 'gist';
28
28
  };
29
29
  }
30
30
  export interface SetupPrompt {
@@ -5,7 +5,7 @@
5
5
  import { getStateManager, DEFAULT_CONFIG } from '../core/index.js';
6
6
  import { ValidationError } from '../core/errors.js';
7
7
  import { validateGitHubUsername } from './validation.js';
8
- import { PROJECT_CATEGORIES, ISSUE_SCOPES } from '../core/types.js';
8
+ import { PROJECT_CATEGORIES, ISSUE_SCOPES, DIFF_TOOLS, } from '../core/types.js';
9
9
  /** Parse and validate a positive integer setting value. */
10
10
  function parsePositiveInt(value, settingName) {
11
11
  const parsed = Number(value);
@@ -14,14 +14,6 @@ function parsePositiveInt(value, settingName) {
14
14
  }
15
15
  return parsed;
16
16
  }
17
- /** Parse and validate an integer within a specific range [min, max]. */
18
- function parseBoundedInt(value, settingName, min, max) {
19
- const parsed = Number(value);
20
- if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
21
- throw new ValidationError(`Invalid value for ${settingName}: "${value}". Must be an integer between ${min} and ${max}.`);
22
- }
23
- return parsed;
24
- }
25
17
  /**
26
18
  * Interactive setup wizard or direct setting application.
27
19
  *
@@ -78,10 +70,6 @@ export async function runSetup(options) {
78
70
  stateManager.updateConfig({ labels: value.split(',').map((l) => l.trim()) });
79
71
  results[key] = value;
80
72
  break;
81
- case 'showHealthCheck':
82
- stateManager.updateConfig({ showHealthCheck: value !== 'false' });
83
- results[key] = value !== 'false' ? 'true' : 'false';
84
- break;
85
73
  case 'squashByDefault':
86
74
  if (value === 'ask') {
87
75
  stateManager.updateConfig({ squashByDefault: 'ask' });
@@ -92,12 +80,6 @@ export async function runSetup(options) {
92
80
  results[key] = value !== 'false' ? 'true' : 'false';
93
81
  }
94
82
  break;
95
- case 'scoreThreshold': {
96
- const threshold = parseBoundedInt(value, 'scoreThreshold', 1, 10);
97
- stateManager.updateConfig({ scoreThreshold: threshold });
98
- results[key] = String(threshold);
99
- break;
100
- }
101
83
  case 'minStars': {
102
84
  const stars = Number(value);
103
85
  if (!Number.isInteger(stars) || stars < 0) {
@@ -208,10 +190,30 @@ export async function runSetup(options) {
208
190
  results[key] = dedupedScopes.length > 0 ? dedupedScopes.join(', ') : '(empty — using labels only)';
209
191
  break;
210
192
  }
193
+ case 'persistence':
194
+ if (value !== 'local' && value !== 'gist') {
195
+ throw new ValidationError(`Invalid value for persistence: "${value}". Must be "local" or "gist".`);
196
+ }
197
+ stateManager.updateConfig({ persistence: value });
198
+ results[key] = value;
199
+ break;
211
200
  case 'issueListPath':
212
201
  stateManager.updateConfig({ issueListPath: value || undefined });
213
202
  results[key] = value || '(cleared)';
214
203
  break;
204
+ case 'diffTool': {
205
+ if (!DIFF_TOOLS.includes(value)) {
206
+ warnings.push(`Invalid diffTool "${value}". Valid: ${DIFF_TOOLS.join(', ')}`);
207
+ break;
208
+ }
209
+ stateManager.updateConfig({ diffTool: value });
210
+ results[key] = value;
211
+ break;
212
+ }
213
+ case 'diffToolCustomCommand':
214
+ stateManager.updateConfig({ diffToolCustomCommand: value || undefined });
215
+ results[key] = value || '(cleared)';
216
+ break;
215
217
  case 'complete':
216
218
  if (value === 'true') {
217
219
  stateManager.markSetupComplete();
@@ -239,7 +241,7 @@ export async function runSetup(options) {
239
241
  projectCategories: config.projectCategories ?? [],
240
242
  preferredOrgs: config.preferredOrgs ?? [],
241
243
  scope: config.scope ?? [],
242
- scoreThreshold: config.scoreThreshold,
244
+ persistence: config.persistence ?? 'local',
243
245
  },
244
246
  };
245
247
  }
@@ -296,13 +298,6 @@ export async function runSetup(options) {
296
298
  default: [],
297
299
  type: 'list',
298
300
  },
299
- {
300
- setting: 'scoreThreshold',
301
- prompt: 'Minimum vet score (1-10) for issues to keep after vetting? Issues below this are auto-filtered.',
302
- current: config.scoreThreshold,
303
- default: 6,
304
- type: 'number',
305
- },
306
301
  {
307
302
  setting: 'aiPolicyBlocklist',
308
303
  prompt: 'Repos with anti-AI contribution policies to block (owner/repo, comma-separated)?',
@@ -324,6 +319,13 @@ export async function runSetup(options) {
324
319
  default: [],
325
320
  type: 'list',
326
321
  },
322
+ {
323
+ setting: 'persistence',
324
+ prompt: 'Where should state be stored? "local" for file only, "gist" for GitHub Gist (survives device loss)',
325
+ current: config.persistence ?? 'local',
326
+ default: 'local',
327
+ type: 'string',
328
+ },
327
329
  ],
328
330
  };
329
331
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * State persistence management commands.
3
+ * Provides --show, --sync, and --unlink subcommands for the Gist persistence layer.
4
+ */
5
+ export interface StateShowOutput {
6
+ persistence: 'local' | 'gist';
7
+ gistId: string | null;
8
+ gistDegraded: boolean;
9
+ lastRunAt: string | undefined;
10
+ }
11
+ export interface StateSyncOutput {
12
+ pushed: boolean;
13
+ gistId: string | null;
14
+ }
15
+ export interface StateUnlinkOutput {
16
+ unlinked: boolean;
17
+ localStatePath: string;
18
+ previousGistId: string | null;
19
+ }
20
+ export declare function runStateShow(): Promise<StateShowOutput>;
21
+ export declare function runStateSync(): Promise<StateSyncOutput>;
22
+ export declare function runStateUnlink(): Promise<StateUnlinkOutput>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * State persistence management commands.
3
+ * Provides --show, --sync, and --unlink subcommands for the Gist persistence layer.
4
+ */
5
+ import * as fs from 'fs';
6
+ import { getStateManager, resetStateManager } from '../core/state.js';
7
+ import { atomicWriteFileSync } from '../core/state-persistence.js';
8
+ import { getStatePath, getGistIdPath } from '../core/utils.js';
9
+ import { warn } from '../core/logger.js';
10
+ const MODULE = 'state-cmd';
11
+ export async function runStateShow() {
12
+ const sm = getStateManager();
13
+ const state = sm.getState();
14
+ return {
15
+ persistence: state.config.persistence ?? 'local',
16
+ gistId: state.gistId ?? null,
17
+ gistDegraded: sm.isGistDegraded(),
18
+ lastRunAt: state.lastRunAt,
19
+ };
20
+ }
21
+ export async function runStateSync() {
22
+ const sm = getStateManager();
23
+ if (!sm.isGistMode()) {
24
+ return { pushed: false, gistId: null };
25
+ }
26
+ const pushed = await sm.checkpoint();
27
+ if (!pushed) {
28
+ throw new Error('Failed to push state to Gist after retry. Check network connectivity and token permissions.');
29
+ }
30
+ return { pushed: true, gistId: sm.getState().gistId ?? null };
31
+ }
32
+ export async function runStateUnlink() {
33
+ const sm = getStateManager();
34
+ const state = sm.getState();
35
+ const previousGistId = state.gistId ?? null;
36
+ const statePath = getStatePath();
37
+ // Refresh from Gist to get the latest state before unlinking
38
+ if (sm.isGistMode()) {
39
+ await sm.refreshFromGist();
40
+ }
41
+ // Write current state to local state.json with persistence set to local
42
+ const localState = JSON.parse(JSON.stringify(sm.getState()));
43
+ delete localState.gistId;
44
+ localState.config.persistence = 'local';
45
+ atomicWriteFileSync(statePath, JSON.stringify(localState, null, 2), 0o600);
46
+ // Remove the gist-id file so future startups don't try to use the Gist
47
+ const gistIdPath = getGistIdPath();
48
+ try {
49
+ if (fs.existsSync(gistIdPath)) {
50
+ fs.unlinkSync(gistIdPath);
51
+ }
52
+ }
53
+ catch (err) {
54
+ warn(MODULE, `Failed to remove gist-id file: ${err}`);
55
+ }
56
+ // Do NOT delete the remote Gist — the user may want it as a backup
57
+ // Reset the singleton so subsequent calls in this process use local mode
58
+ resetStateManager();
59
+ return {
60
+ unlinked: true,
61
+ localStatePath: statePath,
62
+ previousGistId,
63
+ };
64
+ }
@@ -26,6 +26,12 @@ export declare class ConfigurationError extends OssAutopilotError {
26
26
  export declare class ValidationError extends OssAutopilotError {
27
27
  constructor(message: string);
28
28
  }
29
+ /**
30
+ * Gist API scope error (token lacks the "gist" scope).
31
+ */
32
+ export declare class GistPermissionError extends ConfigurationError {
33
+ constructor(message?: string);
34
+ }
29
35
  /**
30
36
  * Extract a human-readable message from an unknown error value.
31
37
  */
@@ -36,6 +36,18 @@ export class ValidationError extends OssAutopilotError {
36
36
  this.name = 'ValidationError';
37
37
  }
38
38
  }
39
+ /**
40
+ * Gist API scope error (token lacks the "gist" scope).
41
+ */
42
+ export class GistPermissionError extends ConfigurationError {
43
+ constructor(message) {
44
+ super(message ??
45
+ 'Your GitHub token does not have Gist permissions. ' +
46
+ 'Run `gh auth refresh -s gist` to add the required scope, ' +
47
+ 'or create a token with the "gist" scope.');
48
+ this.name = 'GistPermissionError';
49
+ }
50
+ }
39
51
  /**
40
52
  * Extract a human-readable message from an unknown error value.
41
53
  */
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Gist-based persistence layer for oss-autopilot state.
3
+ *
4
+ * Manages a single private GitHub Gist that stores `state.json` (structured state)
5
+ * and potentially freeform markdown documents. Provides an in-memory file cache
6
+ * for session-scoped reads and a local cache write-through for degraded-mode fallback.
7
+ *
8
+ * Bootstrap flow:
9
+ * 1. Check for locally stored Gist ID file (`~/.oss-autopilot/gist-id`)
10
+ * 2. If found, fetch that Gist directly via `GET /gists/:id`
11
+ * 3. If not found locally, search the user's Gists for description `oss-autopilot-state`
12
+ * 4. If found via search, store the ID locally and fetch it
13
+ * 5. If not found anywhere, create a new private Gist with seed files and store the ID
14
+ * 6. Cache all Gist file contents in memory for session-scoped reads
15
+ * 7. Write state to a local cache file for fallback
16
+ */
17
+ import { AgentState } from './types.js';
18
+ /** Well-known Gist description used for search-based discovery. */
19
+ export declare const GIST_DESCRIPTION = "oss-autopilot-state";
20
+ /** Primary state file name inside the Gist. */
21
+ export declare const STATE_FILE_NAME = "state.json";
22
+ /** Result of a successful bootstrap. */
23
+ export interface BootstrapResult {
24
+ gistId: string;
25
+ state: AgentState;
26
+ created: boolean;
27
+ /** True when state was loaded from local cache due to API failure. */
28
+ degraded?: boolean;
29
+ }
30
+ /**
31
+ * Minimal Octokit-shaped interface for the Gist API methods we use.
32
+ * Accepts the real ThrottledOctokit or a plain mock object in tests.
33
+ */
34
+ export interface OctokitLike {
35
+ gists: {
36
+ get: (params: {
37
+ gist_id: string;
38
+ }) => Promise<{
39
+ data: GistResponseData;
40
+ }>;
41
+ list: (params: {
42
+ per_page: number;
43
+ page: number;
44
+ }) => Promise<{
45
+ data: GistListItem[];
46
+ }>;
47
+ create: (params: {
48
+ description: string;
49
+ public: boolean;
50
+ files: Record<string, {
51
+ content: string;
52
+ }>;
53
+ }) => Promise<{
54
+ data: GistResponseData;
55
+ }>;
56
+ update: (params: {
57
+ gist_id: string;
58
+ files: Record<string, {
59
+ content: string;
60
+ }>;
61
+ }) => Promise<{
62
+ data: GistResponseData;
63
+ }>;
64
+ };
65
+ }
66
+ /** Shape of a single Gist in a list response (subset). */
67
+ interface GistListItem {
68
+ id: string;
69
+ description: string | null;
70
+ }
71
+ /** Shape of a full Gist response (subset). */
72
+ interface GistResponseData {
73
+ id: string;
74
+ description: string | null;
75
+ files: Record<string, {
76
+ filename: string;
77
+ content?: string;
78
+ } | null>;
79
+ }
80
+ /**
81
+ * Gist-backed state store with in-memory file cache and local write-through.
82
+ */
83
+ export declare class GistStateStore {
84
+ private gistId;
85
+ readonly cachedFiles: Map<string, string>;
86
+ readonly dirtyFiles: Set<string>;
87
+ private readonly octokit;
88
+ private lastRefreshAt;
89
+ private static readonly REFRESH_THROTTLE_MS;
90
+ constructor(octokit: OctokitLike);
91
+ /**
92
+ * Bootstrap the Gist store: locate or create the backing Gist,
93
+ * populate the in-memory cache, and write the local cache file.
94
+ */
95
+ bootstrap(): Promise<BootstrapResult>;
96
+ /**
97
+ * Bootstrap with migration from an existing local state.
98
+ * If a Gist already exists (found via local ID or search), uses it — no migration needed.
99
+ * If no Gist exists, creates one seeded with the provided existingState instead of a fresh state.
100
+ * @returns BootstrapResult extended with `migrated: true` if a new Gist was created from local state
101
+ */
102
+ bootstrapWithMigration(existingState: AgentState): Promise<BootstrapResult & {
103
+ migrated: boolean;
104
+ }>;
105
+ /** Return the resolved Gist ID (available after bootstrap). */
106
+ getGistId(): string | null;
107
+ /**
108
+ * Mark a file as dirty so it will be included in the next `push()` call.
109
+ */
110
+ markDirty(filename: string): void;
111
+ /**
112
+ * Read a freeform document from the in-memory cache.
113
+ * Returns null if the file has not been loaded (or does not exist in the Gist).
114
+ * Synchronous — all Gist contents are loaded into memory at bootstrap.
115
+ */
116
+ getDocument(filename: string): string | null;
117
+ /**
118
+ * Write a freeform document into the in-memory cache and mark it dirty
119
+ * so it will be included in the next `push()` call.
120
+ */
121
+ setDocument(filename: string, content: string): void;
122
+ /**
123
+ * Return all filenames in the in-memory cache whose names start with `prefix`.
124
+ * Useful for listing all guidelines files (e.g. prefix `guidelines--`).
125
+ */
126
+ listDocuments(prefix: string): string[];
127
+ /**
128
+ * Stage new state JSON for the next `push()`. Updates the in-memory cache
129
+ * for `state.json` and marks it dirty.
130
+ */
131
+ setState(stateJson: string): void;
132
+ /**
133
+ * Push all dirty files to the backing Gist. Retries once on failure.
134
+ *
135
+ * Returns `true` on success (or when there is nothing to push).
136
+ * Returns `false` if both attempts fail.
137
+ * Throws if the Gist ID has not been resolved yet (bootstrap not called).
138
+ */
139
+ push(): Promise<boolean>;
140
+ /**
141
+ * Re-fetch the Gist and update the in-memory cache.
142
+ * Throttled to at most once per 30 seconds.
143
+ */
144
+ refreshFromGist(): Promise<boolean>;
145
+ /**
146
+ * Preflight check: verify the token has Gist API scope.
147
+ * Costs one cheap API call; catches permission issues early with a clear message.
148
+ */
149
+ private checkGistScope;
150
+ /**
151
+ * Fetch a Gist by ID, populate the in-memory cache, parse state,
152
+ * and write the local cache file.
153
+ */
154
+ private fetchAndCache;
155
+ /**
156
+ * Parse `state.json` from the in-memory cache. Handles v2 migration
157
+ * by running through the Zod schema (which requires version: 3).
158
+ * Falls back to fresh state if the file is missing or unparseable.
159
+ */
160
+ private parseStateFromCache;
161
+ /**
162
+ * Search the authenticated user's Gists for one with the well-known description.
163
+ * Pages through up to 10 pages (100 Gists per page) to find it.
164
+ */
165
+ private searchForGist;
166
+ /**
167
+ * Create a new private Gist with seed files and store it in memory.
168
+ */
169
+ private createGist;
170
+ /**
171
+ * Create a new private Gist seeded with the provided state (for migration).
172
+ */
173
+ private createGistFromState;
174
+ /** Read the locally persisted Gist ID, or return null if not found. */
175
+ private readLocalGistId;
176
+ /** Persist the Gist ID locally for fast lookup on next session. */
177
+ private writeLocalGistId;
178
+ /** Write state to the local cache file for degraded-mode fallback. */
179
+ private writeLocalStateCache;
180
+ }
181
+ export {};