@oss-autopilot/core 1.10.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,
@@ -3,7 +3,7 @@
3
3
  * Shows or updates configuration
4
4
  */
5
5
  import { getStateManager } from '../core/index.js';
6
- import { ISSUE_SCOPES } from '../core/types.js';
6
+ import { ISSUE_SCOPES, DIFF_TOOLS } from '../core/types.js';
7
7
  import { validateGitHubUsername } from './validation.js';
8
8
  function validateScope(value) {
9
9
  if (!ISSUE_SCOPES.includes(value)) {
@@ -105,6 +105,18 @@ export async function runConfig(options) {
105
105
  case 'issueListPath':
106
106
  stateManager.updateConfig({ issueListPath: value || undefined });
107
107
  break;
108
+ case 'diffTool': {
109
+ if (!DIFF_TOOLS.includes(value)) {
110
+ throw new Error(`Invalid diffTool "${value}". Valid options: ${DIFF_TOOLS.join(', ')}`);
111
+ }
112
+ stateManager.updateConfig({ diffTool: value });
113
+ break;
114
+ }
115
+ case 'diffToolCustomCommand':
116
+ stateManager.updateConfig({
117
+ diffToolCustomCommand: value || undefined,
118
+ });
119
+ break;
108
120
  default:
109
121
  throw new Error(`Unknown config key: ${options.key}`);
110
122
  }
@@ -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,6 +24,7 @@ export interface SetupCompleteOutput {
24
24
  projectCategories: ProjectCategory[];
25
25
  preferredOrgs: string[];
26
26
  scope: IssueScope[];
27
+ persistence: 'local' | 'gist';
27
28
  };
28
29
  }
29
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);
@@ -190,10 +190,30 @@ export async function runSetup(options) {
190
190
  results[key] = dedupedScopes.length > 0 ? dedupedScopes.join(', ') : '(empty — using labels only)';
191
191
  break;
192
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;
193
200
  case 'issueListPath':
194
201
  stateManager.updateConfig({ issueListPath: value || undefined });
195
202
  results[key] = value || '(cleared)';
196
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;
197
217
  case 'complete':
198
218
  if (value === 'true') {
199
219
  stateManager.markSetupComplete();
@@ -221,6 +241,7 @@ export async function runSetup(options) {
221
241
  projectCategories: config.projectCategories ?? [],
222
242
  preferredOrgs: config.preferredOrgs ?? [],
223
243
  scope: config.scope ?? [],
244
+ persistence: config.persistence ?? 'local',
224
245
  },
225
246
  };
226
247
  }
@@ -298,6 +319,13 @@ export async function runSetup(options) {
298
319
  default: [],
299
320
  type: 'list',
300
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
+ },
301
329
  ],
302
330
  };
303
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
  */
@@ -85,6 +85,8 @@ export declare class GistStateStore {
85
85
  readonly cachedFiles: Map<string, string>;
86
86
  readonly dirtyFiles: Set<string>;
87
87
  private readonly octokit;
88
+ private lastRefreshAt;
89
+ private static readonly REFRESH_THROTTLE_MS;
88
90
  constructor(octokit: OctokitLike);
89
91
  /**
90
92
  * Bootstrap the Gist store: locate or create the backing Gist,
@@ -135,6 +137,16 @@ export declare class GistStateStore {
135
137
  * Throws if the Gist ID has not been resolved yet (bootstrap not called).
136
138
  */
137
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;
138
150
  /**
139
151
  * Fetch a Gist by ID, populate the in-memory cache, parse state,
140
152
  * and write the local cache file.
@@ -19,6 +19,7 @@ import { AgentStateSchema } from './state-schema.js';
19
19
  import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3 } from './state-persistence.js';
20
20
  import { getGistIdPath, getStateCachePath } from './utils.js';
21
21
  import { debug, warn } from './logger.js';
22
+ import { GistPermissionError, isRateLimitError } from './errors.js';
22
23
  const MODULE = 'gist-store';
23
24
  /** Well-known Gist description used for search-based discovery. */
24
25
  export const GIST_DESCRIPTION = 'oss-autopilot-state';
@@ -32,6 +33,8 @@ export class GistStateStore {
32
33
  cachedFiles = new Map();
33
34
  dirtyFiles = new Set();
34
35
  octokit;
36
+ lastRefreshAt = 0;
37
+ static REFRESH_THROTTLE_MS = 30_000;
35
38
  constructor(octokit) {
36
39
  this.octokit = octokit;
37
40
  }
@@ -41,6 +44,7 @@ export class GistStateStore {
41
44
  */
42
45
  async bootstrap() {
43
46
  try {
47
+ await this.checkGistScope();
44
48
  // Step 1: Try loading Gist ID from local file
45
49
  const localId = this.readLocalGistId();
46
50
  if (localId) {
@@ -71,6 +75,9 @@ export class GistStateStore {
71
75
  return { gistId: id, state, created: true };
72
76
  }
73
77
  catch (err) {
78
+ // Configuration errors (e.g. GistPermissionError) must surface, not degrade
79
+ if (err instanceof GistPermissionError)
80
+ throw err;
74
81
  // All API paths failed — enter degraded mode
75
82
  warn(MODULE, 'All Gist API paths failed, entering degraded mode', err);
76
83
  // Try reading from local cache file
@@ -107,6 +114,7 @@ export class GistStateStore {
107
114
  */
108
115
  async bootstrapWithMigration(existingState) {
109
116
  try {
117
+ await this.checkGistScope();
110
118
  // Step 1: Try loading Gist ID from local file
111
119
  const localId = this.readLocalGistId();
112
120
  if (localId) {
@@ -137,6 +145,9 @@ export class GistStateStore {
137
145
  return { gistId: id, state, created: true, migrated: true };
138
146
  }
139
147
  catch (err) {
148
+ // Configuration errors (e.g. GistPermissionError) must surface, not degrade
149
+ if (err instanceof GistPermissionError)
150
+ throw err;
140
151
  // All API paths failed — enter degraded mode
141
152
  warn(MODULE, 'bootstrapWithMigration: all Gist API paths failed, entering degraded mode', err);
142
153
  // Try reading from local cache file
@@ -265,7 +276,45 @@ export class GistStateStore {
265
276
  }
266
277
  return true;
267
278
  }
279
+ /**
280
+ * Re-fetch the Gist and update the in-memory cache.
281
+ * Throttled to at most once per 30 seconds.
282
+ */
283
+ async refreshFromGist() {
284
+ if (!this.gistId)
285
+ return false;
286
+ const now = Date.now();
287
+ if (now - this.lastRefreshAt < GistStateStore.REFRESH_THROTTLE_MS)
288
+ return false;
289
+ try {
290
+ await this.fetchAndCache(this.gistId);
291
+ this.lastRefreshAt = now;
292
+ return true;
293
+ }
294
+ catch (err) {
295
+ warn(MODULE, `refreshFromGist failed: ${err}`);
296
+ return false;
297
+ }
298
+ }
268
299
  // ── Private helpers ─────────────────────────────────────────────────
300
+ /**
301
+ * Preflight check: verify the token has Gist API scope.
302
+ * Costs one cheap API call; catches permission issues early with a clear message.
303
+ */
304
+ async checkGistScope() {
305
+ try {
306
+ await this.octokit.gists.list({ per_page: 1, page: 1 });
307
+ }
308
+ catch (err) {
309
+ if (isRateLimitError(err))
310
+ throw err;
311
+ const status = err.status;
312
+ if (status === 401 || status === 403) {
313
+ throw new GistPermissionError();
314
+ }
315
+ throw err;
316
+ }
317
+ }
269
318
  /**
270
319
  * Fetch a Gist by ID, populate the in-memory cache, parse state,
271
320
  * and write the local cache file.
@@ -11,7 +11,7 @@ export { IssueConversationMonitor } from './issue-conversation.js';
11
11
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
12
12
  export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
13
13
  export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, detectGitHubUsername, stateFileExists, DEFAULT_CONCURRENCY, } from './utils.js';
14
- export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, resolveErrorCode, } from './errors.js';
14
+ export { OssAutopilotError, ConfigurationError, ValidationError, GistPermissionError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, resolveErrorCode, } from './errors.js';
15
15
  export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
16
16
  export { HttpCache, getHttpCache, cachedRequest, type CacheEntry } from './http-cache.js';
17
17
  export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
@@ -11,7 +11,7 @@ export { IssueConversationMonitor } from './issue-conversation.js';
11
11
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
12
12
  export { getOctokit, checkRateLimit } from './github.js';
13
13
  export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, detectGitHubUsername, stateFileExists, DEFAULT_CONCURRENCY, } from './utils.js';
14
- export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, resolveErrorCode, } from './errors.js';
14
+ export { OssAutopilotError, ConfigurationError, ValidationError, GistPermissionError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, resolveErrorCode, } from './errors.js';
15
15
  export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
16
16
  export { HttpCache, getHttpCache, cachedRequest } from './http-cache.js';
17
17
  export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
@@ -32,6 +32,12 @@ export declare const IssueScopeSchema: z.ZodEnum<{
32
32
  beginner: "beginner";
33
33
  intermediate: "intermediate";
34
34
  }>;
35
+ export declare const DiffToolSchema: z.ZodEnum<{
36
+ inline: "inline";
37
+ custom: "custom";
38
+ sourcetree: "sourcetree";
39
+ vscode: "vscode";
40
+ }>;
35
41
  export declare const RepoSignalsSchema: z.ZodObject<{
36
42
  hasActiveMaintainers: z.ZodBoolean;
37
43
  isResponsive: z.ZodBoolean;
@@ -179,6 +185,10 @@ export declare const StatusOverrideSchema: z.ZodObject<{
179
185
  export declare const AgentConfigSchema: z.ZodObject<{
180
186
  setupComplete: z.ZodDefault<z.ZodBoolean>;
181
187
  setupCompletedAt: z.ZodOptional<z.ZodString>;
188
+ persistence: z.ZodDefault<z.ZodEnum<{
189
+ local: "local";
190
+ gist: "gist";
191
+ }>>;
182
192
  maxActivePRs: z.ZodDefault<z.ZodNumber>;
183
193
  dormantThresholdDays: z.ZodDefault<z.ZodNumber>;
184
194
  approachingDormantDays: z.ZodDefault<z.ZodNumber>;
@@ -223,6 +233,13 @@ export declare const AgentConfigSchema: z.ZodObject<{
223
233
  education: "education";
224
234
  }>>>;
225
235
  preferredOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
236
+ diffTool: z.ZodDefault<z.ZodEnum<{
237
+ inline: "inline";
238
+ custom: "custom";
239
+ sourcetree: "sourcetree";
240
+ vscode: "vscode";
241
+ }>>;
242
+ diffToolCustomCommand: z.ZodOptional<z.ZodString>;
226
243
  }, z.core.$strip>;
227
244
  export declare const LocalRepoCacheSchema: z.ZodObject<{
228
245
  repos: z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -325,6 +342,10 @@ export declare const AgentStateSchema: z.ZodObject<{
325
342
  config: z.ZodDefault<z.ZodObject<{
326
343
  setupComplete: z.ZodDefault<z.ZodBoolean>;
327
344
  setupCompletedAt: z.ZodOptional<z.ZodString>;
345
+ persistence: z.ZodDefault<z.ZodEnum<{
346
+ local: "local";
347
+ gist: "gist";
348
+ }>>;
328
349
  maxActivePRs: z.ZodDefault<z.ZodNumber>;
329
350
  dormantThresholdDays: z.ZodDefault<z.ZodNumber>;
330
351
  approachingDormantDays: z.ZodDefault<z.ZodNumber>;
@@ -369,6 +390,13 @@ export declare const AgentStateSchema: z.ZodObject<{
369
390
  education: "education";
370
391
  }>>>;
371
392
  preferredOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
393
+ diffTool: z.ZodDefault<z.ZodEnum<{
394
+ inline: "inline";
395
+ custom: "custom";
396
+ sourcetree: "sourcetree";
397
+ vscode: "vscode";
398
+ }>>;
399
+ diffToolCustomCommand: z.ZodOptional<z.ZodString>;
372
400
  }, z.core.$strip>>;
373
401
  lastRunAt: z.ZodDefault<z.ZodString>;
374
402
  lastDigestAt: z.ZodOptional<z.ZodString>;
@@ -499,6 +527,7 @@ export type IssueStatus = z.infer<typeof IssueStatusSchema>;
499
527
  export type FetchedPRStatus = z.infer<typeof FetchedPRStatusSchema>;
500
528
  export type ProjectCategory = z.infer<typeof ProjectCategorySchema>;
501
529
  export type IssueScope = z.infer<typeof IssueScopeSchema>;
530
+ export type DiffTool = z.infer<typeof DiffToolSchema>;
502
531
  export type RepoSignals = z.infer<typeof RepoSignalsSchema>;
503
532
  export type RepoScore = z.infer<typeof RepoScoreSchema>;
504
533
  export type StoredMergedPR = z.infer<typeof StoredMergedPRSchema>;
@@ -21,6 +21,7 @@ export const ProjectCategorySchema = z.enum([
21
21
  'education',
22
22
  ]);
23
23
  export const IssueScopeSchema = z.enum(['beginner', 'intermediate', 'advanced']);
24
+ export const DiffToolSchema = z.enum(['inline', 'sourcetree', 'vscode', 'custom']);
24
25
  // ── 2. Leaf schemas ──────────────────────────────────────────────────
25
26
  export const RepoSignalsSchema = z.object({
26
27
  hasActiveMaintainers: z.boolean(),
@@ -116,6 +117,7 @@ export const StatusOverrideSchema = z.object({
116
117
  export const AgentConfigSchema = z.object({
117
118
  setupComplete: z.boolean().default(false),
118
119
  setupCompletedAt: z.string().optional(),
120
+ persistence: z.enum(['local', 'gist']).default('local'),
119
121
  maxActivePRs: z.number().default(10),
120
122
  dormantThresholdDays: z.number().default(30),
121
123
  approachingDormantDays: z.number().default(25),
@@ -142,6 +144,8 @@ export const AgentConfigSchema = z.object({
142
144
  skippedIssuesPath: z.string().optional(),
143
145
  projectCategories: z.array(ProjectCategorySchema).default([]),
144
146
  preferredOrgs: z.array(z.string()).default([]),
147
+ diffTool: DiffToolSchema.default('inline'),
148
+ diffToolCustomCommand: z.string().optional(),
145
149
  });
146
150
  // ── 6. Cache schemas ─────────────────────────────────────────────────
147
151
  export const LocalRepoCacheSchema = z.object({
@@ -90,6 +90,11 @@ export declare class StateManager {
90
90
  * Returns true if state was reloaded, false if unchanged or in-memory mode.
91
91
  */
92
92
  reloadIfChanged(): boolean;
93
+ /**
94
+ * Re-fetch state from the backing Gist (if in Gist mode).
95
+ * Throttled to once per 30 seconds by GistStateStore. Returns true if state was refreshed.
96
+ */
97
+ refreshFromGist(): Promise<boolean>;
93
98
  /**
94
99
  * Store the latest daily digest and update the digest timestamp.
95
100
  * @param digest - The daily digest to store
@@ -4,10 +4,11 @@
4
4
  * and scoring logic to repo-score-manager.ts.
5
5
  */
6
6
  import * as fs from 'fs';
7
+ import { AgentStateSchema } from './state-schema.js';
7
8
  import { loadState, saveState, reloadStateIfChanged, createFreshState, atomicWriteFileSync, } from './state-persistence.js';
8
9
  import * as repoScoring from './repo-score-manager.js';
9
10
  import { debug, warn } from './logger.js';
10
- import { errorMessage } from './errors.js';
11
+ import { errorMessage, ConfigurationError } from './errors.js';
11
12
  import { GistStateStore } from './gist-state-store.js';
12
13
  import { getStatePath, getStateCachePath } from './utils.js';
13
14
  export { acquireLock, releaseLock, atomicWriteFileSync } from './state-persistence.js';
@@ -230,6 +231,31 @@ export class StateManager {
230
231
  this.tryReconcilePRCounts();
231
232
  return true;
232
233
  }
234
+ /**
235
+ * Re-fetch state from the backing Gist (if in Gist mode).
236
+ * Throttled to once per 30 seconds by GistStateStore. Returns true if state was refreshed.
237
+ */
238
+ async refreshFromGist() {
239
+ if (!this.gistStore)
240
+ return false;
241
+ const refreshed = await this.gistStore.refreshFromGist();
242
+ if (refreshed) {
243
+ const raw = this.gistStore.cachedFiles.get('state.json');
244
+ if (!raw) {
245
+ warn(MODULE, 'Gist refreshed but state.json missing from cache');
246
+ return false;
247
+ }
248
+ try {
249
+ this.state = AgentStateSchema.parse(JSON.parse(raw));
250
+ this.tryReconcilePRCounts();
251
+ }
252
+ catch (err) {
253
+ warn(MODULE, `Failed to parse refreshed Gist state: ${errorMessage(err)}`);
254
+ return false;
255
+ }
256
+ }
257
+ return refreshed;
258
+ }
233
259
  // === Dashboard Data Setters ===
234
260
  /**
235
261
  * Store the latest daily digest and update the digest timestamp.
@@ -669,8 +695,11 @@ export async function getStateManagerAsync(token) {
669
695
  })
670
696
  .catch((err) => {
671
697
  asyncManagerPromise = null;
672
- warn(MODULE, `Unhandled Gist initialization error, falling back to local-only mode (not a normal degraded bootstrap): ${err}`);
673
- return getStateManager(); // fall back to sync/local
698
+ // Configuration errors (e.g. GistPermissionError) must surface to the user
699
+ if (err instanceof ConfigurationError)
700
+ throw err;
701
+ warn(MODULE, `Gist initialization failed, falling back to local-only mode: ${err}`);
702
+ return getStateManager(); // fall back to sync/local for transient errors
674
703
  });
675
704
  return asyncManagerPromise;
676
705
  }