@oss-autopilot/core 3.0.1 → 3.2.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.
@@ -0,0 +1,67 @@
1
+ import { type PRCommentBundle } from '../core/pr-comments-fetcher.js';
2
+ export interface GuidelinesViewOutput {
3
+ repo: string;
4
+ /** Markdown content, or null when the repo has no guidelines stored. */
5
+ content: string | null;
6
+ /** UTF-8 byte size of `content`, or 0 when content is null. */
7
+ byteSize: number;
8
+ /** Whether a guidelines file exists for this repo. */
9
+ exists: boolean;
10
+ /** Where the guidelines would be persisted if a write happened now. */
11
+ storageMode: 'gist' | 'local-unavailable';
12
+ }
13
+ export interface GuidelinesStoreOutput {
14
+ repo: string;
15
+ byteSize: number;
16
+ stored: boolean;
17
+ }
18
+ export interface GuidelinesResetOutput {
19
+ repo: string;
20
+ /** True when an existing file was tombstoned, false when no file existed. */
21
+ deleted: boolean;
22
+ }
23
+ export interface FetchCorpusOutput {
24
+ repo: string;
25
+ bundles: PRCommentBundle[];
26
+ /** How many PRs were considered after recency + already-fetched filtering. */
27
+ prCount: number;
28
+ /** PRs skipped because `commentsFetchedAt` is already set (without --force). */
29
+ skipped: number;
30
+ }
31
+ interface RepoOption {
32
+ repo: string;
33
+ }
34
+ interface FetchCorpusOptions extends RepoOption {
35
+ /** Cap on PRs to process. Defaults to 5; capped at 10 to bound prompt size. */
36
+ limit?: number;
37
+ /** Re-fetch even when commentsFetchedAt is already set. */
38
+ forceRefetch?: boolean;
39
+ }
40
+ interface StoreOptions extends RepoOption {
41
+ content: string;
42
+ }
43
+ /** Read the per-repo guidelines for `repo`. Returns a `local-unavailable` envelope in non-Gist mode. */
44
+ export declare function runGuidelinesView(options: RepoOption): Promise<GuidelinesViewOutput>;
45
+ /**
46
+ * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
47
+ * when content exceeds the byte budget — the CLI surface relies on the
48
+ * outputJson error envelope to surface that to the host cleanly.
49
+ */
50
+ export declare function runGuidelinesStore(options: StoreOptions): Promise<GuidelinesStoreOutput>;
51
+ /** Tombstone the guidelines file for `repo`. */
52
+ export declare function runGuidelinesReset(options: RepoOption): Promise<GuidelinesResetOutput>;
53
+ /**
54
+ * Fetch raw PR comment bundles for the most recent merged/closed PRs in `repo`
55
+ * that haven't already been processed. Returns a serializable corpus for the
56
+ * host's extract-learnings prompt.
57
+ *
58
+ * Filters applied at the CLI layer (not the fetcher):
59
+ * - Only PRs in the requested `repo`
60
+ * - PRs older than 12 months are dropped (recency cliff)
61
+ * - PRs with `commentsFetchedAt` already set are skipped unless `forceRefetch`
62
+ * - Capped at `limit` (default 5, max 10)
63
+ *
64
+ * Stamps `commentsFetchedAt` on every PR that was successfully fetched.
65
+ */
66
+ export declare function runFetchCorpus(options: FetchCorpusOptions): Promise<FetchCorpusOutput>;
67
+ export {};
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Guidelines CLI commands (#867 PR 4).
3
+ *
4
+ * `guidelines view` — read the per-repo guidelines file from the Gist.
5
+ * `guidelines store` — overwrite the per-repo guidelines file.
6
+ * `guidelines reset` — tombstone the file so subsequent reads return null.
7
+ * `guidelines fetch-corpus` — pull raw PR comment bundles for the host's
8
+ * extract-learnings prompt to consume.
9
+ *
10
+ * The CLI layer is pure data plumbing — extraction is the host's job.
11
+ * Standalone-mode users see "not available" on store/reset/fetch-corpus
12
+ * because per-repo guidelines require Gist persistence to be useful.
13
+ */
14
+ import { getStateManager, requireGitHubToken, getOctokit, GuidelinesNotAvailableError } from '../core/index.js';
15
+ import { fetchPRCommentBundlesBatch } from '../core/pr-comments-fetcher.js';
16
+ import { warn } from '../core/logger.js';
17
+ const MODULE = 'guidelines';
18
+ const REPO_ID_PATTERN = /^[^/]+\/[^/]+$/;
19
+ const DEFAULT_FETCH_LIMIT = 5;
20
+ const MAX_FETCH_LIMIT = 10;
21
+ /**
22
+ * Cliff at 12 months: PRs older than this are excluded from corpus fetches
23
+ * (#867 design decision §5). Computed at call time, not module load.
24
+ */
25
+ const RECENCY_CLIFF_MS = 365 * 24 * 60 * 60 * 1000;
26
+ function validateRepo(repo) {
27
+ if (!REPO_ID_PATTERN.test(repo)) {
28
+ throw new Error(`Invalid repo identifier "${repo}". Expected "owner/repo" format.`);
29
+ }
30
+ }
31
+ /** Read the per-repo guidelines for `repo`. Returns a `local-unavailable` envelope in non-Gist mode. */
32
+ export async function runGuidelinesView(options) {
33
+ validateRepo(options.repo);
34
+ const sm = getStateManager();
35
+ const content = sm.getGuidelines(options.repo);
36
+ return {
37
+ repo: options.repo,
38
+ content,
39
+ byteSize: content === null ? 0 : Buffer.byteLength(content, 'utf-8'),
40
+ exists: content !== null,
41
+ storageMode: sm.isGuidelinesAvailable() ? 'gist' : 'local-unavailable',
42
+ };
43
+ }
44
+ /**
45
+ * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
46
+ * when content exceeds the byte budget — the CLI surface relies on the
47
+ * outputJson error envelope to surface that to the host cleanly.
48
+ */
49
+ export async function runGuidelinesStore(options) {
50
+ validateRepo(options.repo);
51
+ if (!options.content || options.content.length === 0) {
52
+ throw new Error('Cannot store empty content. Use `guidelines reset` to delete a guidelines file.');
53
+ }
54
+ const sm = getStateManager();
55
+ // Throws GuidelinesNotAvailableError or GuidelinesTooLargeError on failure.
56
+ sm.setGuidelines(options.repo, options.content);
57
+ return {
58
+ repo: options.repo,
59
+ byteSize: Buffer.byteLength(options.content, 'utf-8'),
60
+ stored: true,
61
+ };
62
+ }
63
+ /** Tombstone the guidelines file for `repo`. */
64
+ export async function runGuidelinesReset(options) {
65
+ validateRepo(options.repo);
66
+ const sm = getStateManager();
67
+ if (!sm.isGuidelinesAvailable()) {
68
+ throw new GuidelinesNotAvailableError();
69
+ }
70
+ const existed = sm.getGuidelines(options.repo) !== null;
71
+ if (existed) {
72
+ sm.deleteGuidelines(options.repo);
73
+ }
74
+ return { repo: options.repo, deleted: existed };
75
+ }
76
+ /**
77
+ * Fetch raw PR comment bundles for the most recent merged/closed PRs in `repo`
78
+ * that haven't already been processed. Returns a serializable corpus for the
79
+ * host's extract-learnings prompt.
80
+ *
81
+ * Filters applied at the CLI layer (not the fetcher):
82
+ * - Only PRs in the requested `repo`
83
+ * - PRs older than 12 months are dropped (recency cliff)
84
+ * - PRs with `commentsFetchedAt` already set are skipped unless `forceRefetch`
85
+ * - Capped at `limit` (default 5, max 10)
86
+ *
87
+ * Stamps `commentsFetchedAt` on every PR that was successfully fetched.
88
+ */
89
+ export async function runFetchCorpus(options) {
90
+ validateRepo(options.repo);
91
+ const limit = clampLimit(options.limit);
92
+ const sm = getStateManager();
93
+ const merged = sm.getMergedPRs() ?? [];
94
+ const closed = sm.getClosedPRs() ?? [];
95
+ const cutoffMs = Date.now() - RECENCY_CLIFF_MS;
96
+ /** Treat both merged and closed-without-merge PRs as candidates. */
97
+ const candidates = [
98
+ ...merged.map((pr) => ({ url: pr.url, timestamp: pr.mergedAt, alreadyFetched: !!pr.commentsFetchedAt })),
99
+ ...closed.map((pr) => ({ url: pr.url, timestamp: pr.closedAt, alreadyFetched: !!pr.commentsFetchedAt })),
100
+ ];
101
+ const repoUrlPrefix = `https://github.com/${options.repo}/`;
102
+ const eligible = candidates.filter((c) => {
103
+ if (!c.url.startsWith(repoUrlPrefix))
104
+ return false;
105
+ if (Date.parse(c.timestamp || '') < cutoffMs)
106
+ return false;
107
+ return true;
108
+ });
109
+ // Skipped count tracks ONLY the eligibility-passing PRs that were excluded
110
+ // for already-fetched. PRs filtered by repo/recency aren't "skipped" — they
111
+ // just don't apply to this repo or cutoff.
112
+ const skipped = options.forceRefetch ? 0 : eligible.filter((c) => c.alreadyFetched).length;
113
+ const toFetch = (options.forceRefetch ? eligible : eligible.filter((c) => !c.alreadyFetched))
114
+ // Most-recent first so the host always sees the freshest signal in its corpus window.
115
+ .sort((a, b) => Date.parse(b.timestamp || '') - Date.parse(a.timestamp || ''))
116
+ .slice(0, limit);
117
+ if (toFetch.length === 0) {
118
+ return { repo: options.repo, bundles: [], prCount: 0, skipped };
119
+ }
120
+ const token = requireGitHubToken();
121
+ const octokit = getOctokit(token);
122
+ const username = sm.getState().config.githubUsername;
123
+ if (!username) {
124
+ warn(MODULE, 'githubUsername is not set; bot/own-comment filtering will be incomplete');
125
+ }
126
+ const bundles = await fetchPRCommentBundlesBatch(octokit, toFetch.map((c) => c.url), username);
127
+ // Stamp commentsFetchedAt on each PR we successfully fetched — the bundle
128
+ // list mirrors the input order until paginateAll fan-out, so we mark on
129
+ // a per-bundle basis to be safe.
130
+ const now = new Date().toISOString();
131
+ for (const bundle of bundles) {
132
+ sm.markPRCommentsFetched(bundle.prUrl, now);
133
+ }
134
+ return {
135
+ repo: options.repo,
136
+ bundles,
137
+ prCount: bundles.length,
138
+ skipped,
139
+ };
140
+ }
141
+ function clampLimit(limit) {
142
+ if (limit === undefined)
143
+ return DEFAULT_FETCH_LIMIT;
144
+ if (!Number.isFinite(limit) || limit < 1)
145
+ return DEFAULT_FETCH_LIMIT;
146
+ return Math.min(limit, MAX_FETCH_LIMIT);
147
+ }
@@ -53,6 +53,14 @@ export { runInit } from './init.js';
53
53
  export { runSetup } from './setup.js';
54
54
  /** Check whether setup has been completed. */
55
55
  export { runCheckSetup } from './setup.js';
56
+ /** Read the guidelines file for a repo. */
57
+ export { runGuidelinesView } from './guidelines.js';
58
+ /** Persist a guidelines file for a repo (overwrites on subsequent calls). */
59
+ export { runGuidelinesStore } from './guidelines.js';
60
+ /** Tombstone a guidelines file so subsequent reads return null. */
61
+ export { runGuidelinesReset } from './guidelines.js';
62
+ /** Fetch raw PR comment bundles for the host's extract-learnings prompt. */
63
+ export { runFetchCorpus } from './guidelines.js';
56
64
  /** Show current persistence mode, Gist ID, and sync status. */
57
65
  export { runStateShow } from './state-cmd.js';
58
66
  /** Force push state to the backing Gist (no-op in local mode). */
@@ -78,6 +86,7 @@ export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput, VetListOutput,
78
86
  export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
79
87
  export type { ShelveOutput, UnshelveOutput } from './shelve.js';
80
88
  export type { MoveOutput, MoveTarget } from './move.js';
89
+ export type { GuidelinesViewOutput, GuidelinesStoreOutput, GuidelinesResetOutput, FetchCorpusOutput, } from './guidelines.js';
81
90
  export type { DismissOutput, UndismissOutput } from './dismiss.js';
82
91
  export type { InitOutput } from './init.js';
83
92
  export type { ConfigSetOutput, ConfigCommandOutput } from './config.js';
@@ -57,6 +57,15 @@ export { runInit } from './init.js';
57
57
  export { runSetup } from './setup.js';
58
58
  /** Check whether setup has been completed. */
59
59
  export { runCheckSetup } from './setup.js';
60
+ // ── Per-Repo Guidelines (#867) ──────────────────────────────────────────────
61
+ /** Read the guidelines file for a repo. */
62
+ export { runGuidelinesView } from './guidelines.js';
63
+ /** Persist a guidelines file for a repo (overwrites on subsequent calls). */
64
+ export { runGuidelinesStore } from './guidelines.js';
65
+ /** Tombstone a guidelines file so subsequent reads return null. */
66
+ export { runGuidelinesReset } from './guidelines.js';
67
+ /** Fetch raw PR comment bundles for the host's extract-learnings prompt. */
68
+ export { runFetchCorpus } from './guidelines.js';
60
69
  // ── State Persistence ────────────────────────────────────────────────────────
61
70
  /** Show current persistence mode, Gist ID, and sync status. */
62
71
  export { runStateShow } from './state-cmd.js';
@@ -2,7 +2,18 @@
2
2
  * Bridge between oss-autopilot's AgentState and oss-scout's OssScout API.
3
3
  * Maps state fields and creates scout instances for search/vet commands.
4
4
  */
5
- import { type OssScout, type ScoutState } from '@oss-scout/core';
5
+ import { type LinkedPR as ScoutLinkedPR, type OssScout, type ScoutState } from '@oss-scout/core';
6
+ import type { LinkedPR } from '../core/linked-pr-classification.js';
7
+ /**
8
+ * Convert scout 0.6.0's `LinkedPR` (separate `state` + `merged`) into the
9
+ * shape `classifyLinkedPR` expects (`state` already folded with `merged`).
10
+ *
11
+ * Scout exposes the raw GitHub fields verbatim, but the classifier was
12
+ * written before scout surfaced this data and uses a tri-state
13
+ * `'open' | 'closed' | 'merged'` enum. Folding `merged` into the state
14
+ * preserves the function's existing contract + tests.
15
+ */
16
+ export declare function adaptScoutLinkedPR(scoutLinkedPR: ScoutLinkedPR | null | undefined): LinkedPR | null;
6
17
  /**
7
18
  * Build a ScoutState from the current AgentState.
8
19
  * Maps oss-autopilot's config and state fields to oss-scout's state format.
@@ -5,6 +5,23 @@
5
5
  import { createScout } from '@oss-scout/core';
6
6
  import { getStateManager, requireGitHubToken } from '../core/index.js';
7
7
  import { loadSkippedIssues } from './skip-file-parser.js';
8
+ /**
9
+ * Convert scout 0.6.0's `LinkedPR` (separate `state` + `merged`) into the
10
+ * shape `classifyLinkedPR` expects (`state` already folded with `merged`).
11
+ *
12
+ * Scout exposes the raw GitHub fields verbatim, but the classifier was
13
+ * written before scout surfaced this data and uses a tri-state
14
+ * `'open' | 'closed' | 'merged'` enum. Folding `merged` into the state
15
+ * preserves the function's existing contract + tests.
16
+ */
17
+ export function adaptScoutLinkedPR(scoutLinkedPR) {
18
+ if (!scoutLinkedPR)
19
+ return null;
20
+ return {
21
+ author: { login: scoutLinkedPR.author },
22
+ state: scoutLinkedPR.merged ? 'merged' : scoutLinkedPR.state,
23
+ };
24
+ }
8
25
  /**
9
26
  * Build a ScoutState from the current AgentState.
10
27
  * Maps oss-autopilot's config and state fields to oss-scout's state format.
@@ -31,6 +48,8 @@ export function buildScoutState() {
31
48
  broadPhaseDelayMs: 90000,
32
49
  skipBroadWhenSufficientResults: 15,
33
50
  persistence: config.persistence,
51
+ slmTriageModel: config.slmTriageModel,
52
+ slmTriageHost: config.slmTriageHost,
34
53
  },
35
54
  repoScores: state.repoScores,
36
55
  starredRepos: config.starredRepos,
@@ -3,11 +3,11 @@
3
3
  * Re-vets all available issues in a curated issue list file via @oss-scout/core.
4
4
  */
5
5
  import * as fs from 'fs';
6
- import { createAutopilotScout } from './scout-bridge.js';
6
+ import { adaptScoutLinkedPR, createAutopilotScout } from './scout-bridge.js';
7
7
  import { runParseList, pruneIssueList } from './parse-list.js';
8
8
  import { detectIssueList } from './startup.js';
9
9
  import { computeSuccessGrade, gradeFromCandidate } from '../core/issue-grading.js';
10
- import { getStateManager } from '../core/index.js';
10
+ import { getStateManager, classifyLinkedPR } from '../core/index.js';
11
11
  const UNKNOWN_GRADE = computeSuccessGrade({ avgResponseDays: null, mergeRate: null, daysSinceLastCommit: null });
12
12
  const KNOWN_SKIP_REASONS = new Set([
13
13
  'issue_closed',
@@ -117,6 +117,11 @@ export async function runVetList(options = {}) {
117
117
  projectHealth: candidate.projectHealth,
118
118
  getRepoScore: (repo) => getStateManager().getRepoScore(repo),
119
119
  });
120
+ const userLogin = getStateManager().getState().config.githubUsername;
121
+ const linkedPRClassification = classifyLinkedPR({
122
+ linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
123
+ userLogin,
124
+ });
120
125
  const vetResult = {
121
126
  issue: {
122
127
  repo: candidate.issue.repo,
@@ -130,6 +135,9 @@ export async function runVetList(options = {}) {
130
135
  reasonsToSkip: candidate.reasonsToSkip,
131
136
  projectHealth: candidate.projectHealth,
132
137
  vettingResult: candidate.vettingResult,
138
+ antiLLMPolicy: candidate.antiLLMPolicy,
139
+ linkedPRClassification,
140
+ slmTriage: candidate.slmTriage ?? null,
133
141
  grade,
134
142
  };
135
143
  results.push({
@@ -5,7 +5,8 @@
5
5
  import { createAutopilotScout } from './scout-bridge.js';
6
6
  import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
7
7
  import { gradeFromCandidate } from '../core/issue-grading.js';
8
- import { getStateManager } from '../core/index.js';
8
+ import { getStateManager, classifyLinkedPR } from '../core/index.js';
9
+ import { adaptScoutLinkedPR } from './scout-bridge.js';
9
10
  /**
10
11
  * Vet a specific GitHub issue for claimability and project health.
11
12
  *
@@ -24,6 +25,11 @@ export async function runVet(options) {
24
25
  projectHealth: candidate.projectHealth,
25
26
  getRepoScore: (repo) => getStateManager().getRepoScore(repo),
26
27
  });
28
+ const userLogin = getStateManager().getState().config.githubUsername;
29
+ const linkedPRClassification = classifyLinkedPR({
30
+ linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
31
+ userLogin,
32
+ });
27
33
  return {
28
34
  issue: {
29
35
  repo: candidate.issue.repo,
@@ -37,6 +43,9 @@ export async function runVet(options) {
37
43
  reasonsToSkip: candidate.reasonsToSkip,
38
44
  projectHealth: candidate.projectHealth,
39
45
  vettingResult: candidate.vettingResult,
46
+ antiLLMPolicy: candidate.antiLLMPolicy,
47
+ linkedPRClassification,
48
+ slmTriage: candidate.slmTriage ?? null,
40
49
  grade,
41
50
  };
42
51
  }
@@ -200,7 +200,7 @@ export declare class GistStateStore {
200
200
  private fetchAndCache;
201
201
  /**
202
202
  * Parse `state.json` from the in-memory cache. Handles v2 migration
203
- * by running through the Zod schema (which requires version: 3).
203
+ * by running through the Zod schema (which requires version: 4).
204
204
  * Falls back to fresh state if the file is missing or unparseable.
205
205
  */
206
206
  private parseStateFromCache;
@@ -33,7 +33,7 @@
33
33
  */
34
34
  import * as fs from '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
39
  import { GistPermissionError, GistConcurrencyError, isRateLimitError } from './errors.js';
@@ -127,6 +127,8 @@ export class GistStateStore {
127
127
  obj = migrateV1ToV2(record);
128
128
  if (obj.version === 2)
129
129
  obj = migrateV2ToV3(obj);
130
+ if (obj.version === 3)
131
+ obj = migrateV3ToV4(obj);
130
132
  }
131
133
  const cachedState = AgentStateSchema.parse(obj);
132
134
  debug(MODULE, 'Loaded state from local cache in degraded mode');
@@ -197,6 +199,8 @@ export class GistStateStore {
197
199
  obj = migrateV1ToV2(record);
198
200
  if (obj.version === 2)
199
201
  obj = migrateV2ToV3(obj);
202
+ if (obj.version === 3)
203
+ obj = migrateV3ToV4(obj);
200
204
  }
201
205
  const cachedState = AgentStateSchema.parse(obj);
202
206
  debug(MODULE, 'bootstrapWithMigration: loaded state from local cache in degraded mode');
@@ -433,7 +437,7 @@ export class GistStateStore {
433
437
  }
434
438
  /**
435
439
  * Parse `state.json` from the in-memory cache. Handles v2 migration
436
- * by running through the Zod schema (which requires version: 3).
440
+ * by running through the Zod schema (which requires version: 4).
437
441
  * Falls back to fresh state if the file is missing or unparseable.
438
442
  */
439
443
  parseStateFromCache() {
@@ -451,6 +455,8 @@ export class GistStateStore {
451
455
  obj = migrateV1ToV2(record);
452
456
  if (obj.version === 2)
453
457
  obj = migrateV2ToV3(obj);
458
+ if (obj.version === 3)
459
+ obj = migrateV3ToV4(obj);
454
460
  }
455
461
  return AgentStateSchema.parse(obj);
456
462
  }
@@ -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[];
@@ -0,0 +1,130 @@
1
+ import { OssAutopilotError } from './errors.js';
2
+ /** Filename prefix shared by every guidelines file in the Gist. */
3
+ export const GUIDELINES_FILE_PREFIX = 'guidelines--';
4
+ /** Hard byte budget for a single guidelines file (#867 design log §1). */
5
+ export const GUIDELINES_MAX_BYTES = 8_192;
6
+ /** Suffix appended to the filename so it renders as markdown in Gist. */
7
+ const GUIDELINES_FILE_SUFFIX = '.md';
8
+ /**
9
+ * Convert an `owner/repo` pair into the filename used inside the Gist.
10
+ * Slashes are escaped as `--` so the filename is filesystem-safe and
11
+ * unambiguous when parsing back to a repo string.
12
+ */
13
+ export function guidelinesFilename(repo) {
14
+ if (!repo.includes('/')) {
15
+ throw new OssAutopilotError(`Invalid repo identifier "${repo}". Expected "owner/repo" format.`, 'INVALID_REPO_ID');
16
+ }
17
+ // GitHub forbids `/` in owner and repo, so the only `/` is the separator.
18
+ const [owner, name] = repo.split('/');
19
+ return `${GUIDELINES_FILE_PREFIX}${owner}--${name}${GUIDELINES_FILE_SUFFIX}`;
20
+ }
21
+ /**
22
+ * Inverse of {@link guidelinesFilename}. Returns null when the filename
23
+ * doesn't match the guidelines convention.
24
+ */
25
+ export function repoFromGuidelinesFilename(filename) {
26
+ if (!filename.startsWith(GUIDELINES_FILE_PREFIX))
27
+ return null;
28
+ if (!filename.endsWith(GUIDELINES_FILE_SUFFIX))
29
+ return null;
30
+ const middle = filename.slice(GUIDELINES_FILE_PREFIX.length, filename.length - GUIDELINES_FILE_SUFFIX.length);
31
+ // Only split on the FIRST `--` separator. Repo names with `--` are rare
32
+ // but legal; owner names cannot contain `--` per GitHub username rules.
33
+ const sep = middle.indexOf('--');
34
+ if (sep === -1)
35
+ return null;
36
+ const owner = middle.slice(0, sep);
37
+ const name = middle.slice(sep + 2);
38
+ if (!owner || !name)
39
+ return null;
40
+ return `${owner}/${name}`;
41
+ }
42
+ /**
43
+ * Thrown by {@link setGuidelines} / {@link deleteGuidelines} when the
44
+ * StateManager is not in Gist mode. Catch + degrade gracefully when surfacing
45
+ * to user-facing flows: per-repo guidelines simply aren't available without a
46
+ * Gist to store them in.
47
+ */
48
+ export class GuidelinesNotAvailableError extends OssAutopilotError {
49
+ constructor(message) {
50
+ super(message ??
51
+ 'Per-repo guidelines require Gist persistence. Run `oss-autopilot setup` to enable Gist sync, then retry.', 'GUIDELINES_NOT_AVAILABLE');
52
+ this.name = 'GuidelinesNotAvailableError';
53
+ }
54
+ }
55
+ /**
56
+ * Thrown by {@link setGuidelines} when content exceeds {@link GUIDELINES_MAX_BYTES}.
57
+ * Surfaced separately from generic validation errors so consumers can prompt the
58
+ * user with a "trim or split" UX rather than a generic shape rejection.
59
+ */
60
+ export class GuidelinesTooLargeError extends OssAutopilotError {
61
+ constructor(byteSize, max = GUIDELINES_MAX_BYTES) {
62
+ super(`Guidelines content is ${byteSize} bytes, exceeding the ${max}-byte cap. ` + `Trim or split across categories.`, 'GUIDELINES_TOO_LARGE');
63
+ this.name = 'GuidelinesTooLargeError';
64
+ }
65
+ }
66
+ /**
67
+ * Read the guidelines file for a repo from the Gist cache. Returns null when
68
+ * the store is not in Gist mode, the file does not exist, or the file is
69
+ * present but empty (treated as a tombstone left by {@link deleteGuidelines}).
70
+ */
71
+ export function getGuidelines(store, repo) {
72
+ if (!store)
73
+ return null;
74
+ const content = store.getDocument(guidelinesFilename(repo));
75
+ if (content === null || content === '')
76
+ return null;
77
+ return content;
78
+ }
79
+ /**
80
+ * Write or replace the guidelines file for a repo. Throws if the store is not
81
+ * in Gist mode or the content exceeds the byte budget.
82
+ */
83
+ export function setGuidelines(store, repo, content) {
84
+ if (!store)
85
+ throw new GuidelinesNotAvailableError();
86
+ const byteSize = Buffer.byteLength(content, 'utf-8');
87
+ if (byteSize > GUIDELINES_MAX_BYTES) {
88
+ throw new GuidelinesTooLargeError(byteSize);
89
+ }
90
+ store.setDocument(guidelinesFilename(repo), content);
91
+ }
92
+ /**
93
+ * Delete the guidelines file for a repo. No-op if the file doesn't exist.
94
+ * Implementation: write an empty string. The Gist API treats files with
95
+ * empty content as deletions on the next push, matching the existing
96
+ * single-source-of-truth model.
97
+ */
98
+ export function deleteGuidelines(store, repo) {
99
+ if (!store)
100
+ throw new GuidelinesNotAvailableError();
101
+ // setDocument('') is interpreted as deletion; the GistStateStore push path
102
+ // already strips empty-content files before sending to the Gist API.
103
+ store.setDocument(guidelinesFilename(repo), '');
104
+ }
105
+ /**
106
+ * List every repo (as `owner/repo`) that has a non-empty guidelines file in
107
+ * the cache. Tombstoned (empty-content) files are excluded so the result
108
+ * matches what {@link getGuidelines} would actually return.
109
+ *
110
+ * Returns an empty array when the store is null or no files exist.
111
+ */
112
+ export function listGuidelinesRepos(store) {
113
+ if (!store)
114
+ return [];
115
+ const filenames = store.listDocuments(GUIDELINES_FILE_PREFIX);
116
+ const repos = [];
117
+ for (const filename of filenames) {
118
+ const repo = repoFromGuidelinesFilename(filename);
119
+ // Skip files we can't decode (e.g. older formats, hand-edited) — better
120
+ // than throwing and breaking listGuidelinesRepos for everyone.
121
+ if (!repo)
122
+ continue;
123
+ // Skip tombstones — empty content means the user deleted these guidelines.
124
+ const content = store.getDocument(filename);
125
+ if (content === null || content === '')
126
+ continue;
127
+ repos.push(repo);
128
+ }
129
+ return repos.sort();
130
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
6
6
  export { GistStateStore } from './gist-state-store.js';
7
+ export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX, GUIDELINES_MAX_BYTES, GuidelinesNotAvailableError, GuidelinesTooLargeError, } from './guidelines-store.js';
7
8
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
8
9
  export { IssueConversationMonitor } from './issue-conversation.js';
9
10
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, } from './state.js';
6
6
  export { GistStateStore } from './gist-state-store.js';
7
+ export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX, GUIDELINES_MAX_BYTES, GuidelinesNotAvailableError, GuidelinesTooLargeError, } from './guidelines-store.js';
7
8
  export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
8
9
  // Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
9
10
  export { IssueConversationMonitor } from './issue-conversation.js';