@oss-autopilot/core 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/cli-registry.js +113 -3
  2. package/dist/cli.bundle.cjs +96 -92
  3. package/dist/commands/check-integration.js +8 -8
  4. package/dist/commands/comments.js +3 -0
  5. package/dist/commands/config.js +14 -7
  6. package/dist/commands/daily-render.js +10 -5
  7. package/dist/commands/daily.js +6 -1
  8. package/dist/commands/dashboard-lifecycle.js +1 -1
  9. package/dist/commands/dashboard-process.js +4 -4
  10. package/dist/commands/dashboard-server.js +7 -6
  11. package/dist/commands/dashboard.js +2 -2
  12. package/dist/commands/detect-formatters.js +3 -3
  13. package/dist/commands/doctor.js +5 -5
  14. package/dist/commands/guidelines.d.ts +67 -0
  15. package/dist/commands/guidelines.js +159 -0
  16. package/dist/commands/index.d.ts +9 -0
  17. package/dist/commands/index.js +9 -0
  18. package/dist/commands/list-move-tier.js +5 -5
  19. package/dist/commands/local-repos.js +9 -9
  20. package/dist/commands/parse-list.js +10 -10
  21. package/dist/commands/scout-bridge.js +2 -2
  22. package/dist/commands/setup.js +24 -13
  23. package/dist/commands/skip-add.js +6 -3
  24. package/dist/commands/skip-file-parser.js +3 -3
  25. package/dist/commands/startup.js +11 -8
  26. package/dist/commands/state-cmd.js +1 -1
  27. package/dist/commands/status.js +7 -0
  28. package/dist/commands/validation.js +3 -3
  29. package/dist/commands/vet-list.js +12 -8
  30. package/dist/commands/vet.js +1 -2
  31. package/dist/core/__fixtures__/prompt-injection-payloads.d.ts +22 -0
  32. package/dist/core/__fixtures__/prompt-injection-payloads.js +109 -0
  33. package/dist/core/anti-llm-policy.js +5 -5
  34. package/dist/core/auth.js +5 -5
  35. package/dist/core/daily-logic.js +8 -4
  36. package/dist/core/dates.js +3 -3
  37. package/dist/core/errors.d.ts +29 -0
  38. package/dist/core/errors.js +63 -0
  39. package/dist/core/formatter-detection.js +9 -9
  40. package/dist/core/gist-state-store.d.ts +19 -3
  41. package/dist/core/gist-state-store.js +81 -15
  42. package/dist/core/guidelines-store.d.ts +74 -0
  43. package/dist/core/guidelines-store.js +130 -0
  44. package/dist/core/http-cache.js +6 -6
  45. package/dist/core/index.d.ts +2 -0
  46. package/dist/core/index.js +2 -0
  47. package/dist/core/issue-conversation.js +3 -1
  48. package/dist/core/paths.js +4 -4
  49. package/dist/core/pr-comments-fetcher.d.ts +67 -0
  50. package/dist/core/pr-comments-fetcher.js +125 -0
  51. package/dist/core/pr-monitor.js +1 -2
  52. package/dist/core/pr-template.js +1 -1
  53. package/dist/core/state-persistence.d.ts +6 -0
  54. package/dist/core/state-persistence.js +27 -9
  55. package/dist/core/state-schema.d.ts +5 -1
  56. package/dist/core/state-schema.js +7 -1
  57. package/dist/core/state.d.ts +60 -0
  58. package/dist/core/state.js +136 -13
  59. package/dist/core/types.d.ts +1 -1
  60. package/dist/core/types.js +2 -2
  61. package/dist/core/untrusted-content.d.ts +48 -0
  62. package/dist/core/untrusted-content.js +106 -0
  63. package/dist/core/urls.js +2 -2
  64. package/dist/formatters/json.d.ts +53 -3
  65. package/dist/formatters/json.js +49 -14
  66. package/package.json +1 -1
@@ -2,8 +2,8 @@
2
2
  * Check integration command (#83)
3
3
  * Detects new files in the current branch that aren't referenced elsewhere
4
4
  */
5
- import * as path from 'path';
6
- import { execFileSync } from 'child_process';
5
+ import * as path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
7
  import { debug } from '../core/index.js';
8
8
  import { errorMessage } from '../core/errors.js';
9
9
  /** File extensions we consider "code" that should be imported/referenced */
@@ -71,8 +71,8 @@ export async function runCheckIntegration(options) {
71
71
  let newFiles;
72
72
  try {
73
73
  const output = execFileSync('git', ['diff', '--name-only', '--diff-filter=A', `${base}...HEAD`], {
74
- encoding: 'utf-8',
75
- timeout: 10000,
74
+ encoding: 'utf8',
75
+ timeout: 10_000,
76
76
  }).trim();
77
77
  newFiles = output ? output.split('\n').filter(Boolean) : [];
78
78
  }
@@ -94,8 +94,8 @@ export async function runCheckIntegration(options) {
94
94
  let allFiles;
95
95
  try {
96
96
  allFiles = execFileSync('git', ['ls-files'], {
97
- encoding: 'utf-8',
98
- timeout: 10000,
97
+ encoding: 'utf8',
98
+ timeout: 10_000,
99
99
  })
100
100
  .trim()
101
101
  .split('\n')
@@ -124,8 +124,8 @@ export async function runCheckIntegration(options) {
124
124
  for (const pattern of patterns) {
125
125
  try {
126
126
  const grepOutput = execFileSync('git', ['grep', '-l', '--', pattern], {
127
- encoding: 'utf-8',
128
- timeout: 10000,
127
+ encoding: 'utf8',
128
+ timeout: 10_000,
129
129
  }).trim();
130
130
  if (grepOutput) {
131
131
  const matches = grepOutput.split('\n').filter((f) => f !== newFile);
@@ -7,6 +7,7 @@ import { ValidationError } from '../core/errors.js';
7
7
  import { warn } from '../core/logger.js';
8
8
  const MODULE = 'comments';
9
9
  import { paginateAll } from '../core/pagination.js';
10
+ import { buildStalenessWarning } from '../formatters/json.js';
10
11
  import { validateUrl, validateMessage, validateGitHubUrl, PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, ISSUE_URL_PATTERN, } from './validation.js';
11
12
  /**
12
13
  * Fetch all comments, reviews, and inline review comments for a PR.
@@ -76,6 +77,7 @@ export async function runComments(options) {
76
77
  const relevantReviews = reviews
77
78
  .filter((r) => filterComment(r) && r.body && r.body.trim())
78
79
  .sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime());
80
+ const staleness = stateManager.getStateStaleness();
79
81
  return {
80
82
  pr: {
81
83
  title: pr.title,
@@ -107,6 +109,7 @@ export async function runComments(options) {
107
109
  inlineCommentCount: relevantReviewComments.length,
108
110
  discussionCommentCount: relevantIssueComments.length,
109
111
  },
112
+ ...(staleness ? { warnings: [buildStalenessWarning(staleness)] } : {}),
110
113
  };
111
114
  }
112
115
  /**
@@ -44,25 +44,29 @@ export async function runConfig(options) {
44
44
  const value = options.value;
45
45
  // Handle specific config keys
46
46
  switch (options.key) {
47
- case 'username':
47
+ case 'username': {
48
48
  stateManager.updateConfig({ githubUsername: validateGitHubUsername(value) });
49
49
  break;
50
- case 'add-language':
50
+ }
51
+ case 'add-language': {
51
52
  if (!currentConfig.languages.includes(value)) {
52
53
  stateManager.updateConfig({ languages: [...currentConfig.languages, value] });
53
54
  }
54
55
  break;
55
- case 'add-label':
56
+ }
57
+ case 'add-label': {
56
58
  if (!currentConfig.labels.includes(value)) {
57
59
  stateManager.updateConfig({ labels: [...currentConfig.labels, value] });
58
60
  }
59
61
  break;
60
- case 'remove-label':
62
+ }
63
+ case 'remove-label': {
61
64
  if (!currentConfig.labels.includes(value)) {
62
65
  throw new Error(`Label "${value}" is not currently configured. Current labels: ${currentConfig.labels.join(', ')}`);
63
66
  }
64
67
  stateManager.updateConfig({ labels: currentConfig.labels.filter((l) => l !== value) });
65
68
  break;
69
+ }
66
70
  case 'add-scope': {
67
71
  const scope = validateScope(value);
68
72
  const currentScopes = currentConfig.scope ?? [];
@@ -111,9 +115,10 @@ export async function runConfig(options) {
111
115
  }
112
116
  break;
113
117
  }
114
- case 'issueListPath':
118
+ case 'issueListPath': {
115
119
  stateManager.updateConfig({ issueListPath: value || undefined });
116
120
  break;
121
+ }
117
122
  case 'diffTool': {
118
123
  if (!DIFF_TOOLS.includes(value)) {
119
124
  throw new Error(`Invalid diffTool "${value}". Valid options: ${DIFF_TOOLS.join(', ')}`);
@@ -121,13 +126,15 @@ export async function runConfig(options) {
121
126
  stateManager.updateConfig({ diffTool: value });
122
127
  break;
123
128
  }
124
- case 'diffToolCustomCommand':
129
+ case 'diffToolCustomCommand': {
125
130
  stateManager.updateConfig({
126
131
  diffToolCustomCommand: value || undefined,
127
132
  });
128
133
  break;
129
- default:
134
+ }
135
+ default: {
130
136
  throw new ValidationError(formatUnknownKeyError(options.key, 'config'));
137
+ }
131
138
  }
132
139
  return { success: true, key: options.key, value };
133
140
  }
@@ -20,16 +20,21 @@ import { formatRelativeTime } from '../core/dates.js';
20
20
  */
21
21
  export function formatActionHint(hint) {
22
22
  switch (hint) {
23
- case 'demo_requested':
23
+ case 'demo_requested': {
24
24
  return 'demo/screenshot requested';
25
- case 'tests_requested':
25
+ }
26
+ case 'tests_requested': {
26
27
  return 'tests requested';
27
- case 'changes_requested':
28
+ }
29
+ case 'changes_requested': {
28
30
  return 'code changes requested';
29
- case 'docs_requested':
31
+ }
32
+ case 'docs_requested': {
30
33
  return 'documentation requested';
31
- case 'rebase_requested':
34
+ }
35
+ case 'rebase_requested': {
32
36
  return 'rebase requested';
37
+ }
33
38
  }
34
39
  }
35
40
  /**
@@ -12,7 +12,7 @@ import { warn } from '../core/logger.js';
12
12
  import { emptyPRCountsResult } from '../core/github-stats.js';
13
13
  import { createAutopilotScout } from './scout-bridge.js';
14
14
  import { updateMonthlyAnalytics } from './dashboard-data.js';
15
- import { deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '../formatters/json.js';
15
+ import { deduplicateDigest, compactActionableIssues, compactRepoGroups, buildStalenessWarning, } from '../formatters/json.js';
16
16
  const MODULE = 'daily';
17
17
  /**
18
18
  * Record a non-fatal failure: push a structured entry into the run's warnings
@@ -504,6 +504,11 @@ async function executeDailyCheckInternal(token) {
504
504
  // One collector shared by every phase — threaded through explicitly so the
505
505
  // callgraph documents which phases can produce non-fatal warnings. See #1042.
506
506
  const warnings = [];
507
+ // Surface Gist staleness up-front so consumers see it even if Phase 1 fails (#1193).
508
+ const staleness = getStateManager().getStateStaleness();
509
+ if (staleness) {
510
+ warnings.push(buildStalenessWarning(staleness));
511
+ }
507
512
  // Phase 1: Fetch all PR data from GitHub
508
513
  const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token, warnings);
509
514
  // Phase 2: Update repo scores (signals, star counts, trust sync)
@@ -3,7 +3,7 @@
3
3
  * Handles launching the interactive SPA dashboard as a background process
4
4
  * and detecting whether a server is already running.
5
5
  */
6
- import { spawn } from 'child_process';
6
+ import { spawn } from 'node:child_process';
7
7
  import { findRunningDashboardServer, isDashboardServerRunning, readDashboardServerInfo, removeDashboardServerInfo, } from './dashboard-process.js';
8
8
  import { resolveAssetsDir } from './dashboard.js';
9
9
  import { getCLIVersion } from '../core/index.js';
@@ -2,9 +2,9 @@
2
2
  * Dashboard server process management.
3
3
  * PID file operations, health probes, and running server detection.
4
4
  */
5
- import * as http from 'http';
6
- import * as fs from 'fs';
7
- import * as path from 'path';
5
+ import * as http from 'node:http';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
8
  import { getDataDir } from '../core/index.js';
9
9
  import { warn } from '../core/logger.js';
10
10
  const MODULE = 'dashboard-server';
@@ -17,7 +17,7 @@ export function writeDashboardServerInfo(info) {
17
17
  }
18
18
  export function readDashboardServerInfo() {
19
19
  try {
20
- const content = fs.readFileSync(getDashboardPidPath(), 'utf-8');
20
+ const content = fs.readFileSync(getDashboardPidPath(), 'utf8');
21
21
  const parsed = JSON.parse(content);
22
22
  if (typeof parsed !== 'object' ||
23
23
  parsed === null ||
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * Uses Node's built-in http module — no Express/Fastify.
7
7
  */
8
- import * as http from 'http';
9
- import * as fs from 'fs';
10
- import * as path from 'path';
11
- import * as crypto from 'crypto';
8
+ import * as http from 'node:http';
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import * as crypto from 'node:crypto';
12
12
  import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
13
13
  import { errorMessage, ValidationError } from '../core/errors.js';
14
14
  import { warn } from '../core/logger.js';
@@ -44,7 +44,7 @@ function readVettedIssues() {
44
44
  const info = detectIssueList();
45
45
  if (!info)
46
46
  return null;
47
- const content = fs.readFileSync(info.path, 'utf-8');
47
+ const content = fs.readFileSync(info.path, 'utf8');
48
48
  return parseIssueList(content);
49
49
  }
50
50
  catch (error) {
@@ -148,7 +148,7 @@ function readBody(req, maxBytes = MAX_BODY_BYTES) {
148
148
  });
149
149
  req.on('end', () => {
150
150
  if (!aborted)
151
- resolve(Buffer.concat(chunks).toString('utf-8'));
151
+ resolve(Buffer.concat(chunks).toString('utf8'));
152
152
  });
153
153
  req.on('error', (err) => {
154
154
  if (!aborted)
@@ -599,6 +599,7 @@ export async function startDashboardServer(options) {
599
599
  cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
600
600
  cachedIssueListMtimeMs = getIssueListMtimeMs();
601
601
  warn(MODULE, 'Background data refresh complete');
602
+ return;
602
603
  })
603
604
  .catch((error) => {
604
605
  warn(MODULE, `Background data refresh failed (serving cached data): ${errorMessage(error)}`);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Dashboard command — serves the interactive Preact SPA dashboard.
3
3
  */
4
- import * as fs from 'fs';
5
- import * as path from 'path';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
6
  import { getGitHubToken } from '../core/index.js';
7
7
  /**
8
8
  * Resolve the SPA assets directory from packages/dashboard/dist/.
@@ -3,8 +3,8 @@
3
3
  * Scans a local repository for configured formatters/linters.
4
4
  * Optionally diagnoses CI log output for formatting failures.
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 { detectFormatters, diagnoseCIFormatterFailure } from '../core/formatter-detection.js';
9
9
  import { errorMessage } from '../core/errors.js';
10
10
  export async function runDetectFormatters(options) {
@@ -13,7 +13,7 @@ export async function runDetectFormatters(options) {
13
13
  if (options.ciLog) {
14
14
  let logContent;
15
15
  try {
16
- logContent = fs.readFileSync(path.resolve(options.ciLog), 'utf-8');
16
+ logContent = fs.readFileSync(path.resolve(options.ciLog), 'utf8');
17
17
  }
18
18
  catch (err) {
19
19
  throw new Error(`Failed to read CI log file: ${errorMessage(err)}`, { cause: err });
@@ -9,10 +9,10 @@
9
9
  * Each `check*` function is exported individually so tests can mock its
10
10
  * external dependencies in isolation.
11
11
  */
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
- import { execFile } from 'child_process';
15
- import { promisify } from 'util';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
16
  import { getGitHubTokenAsync, getOctokit, getStatePath } from '../core/index.js';
17
17
  import { AgentStateSchema } from '../core/state-schema.js';
18
18
  import { errorMessage } from '../core/errors.js';
@@ -195,7 +195,7 @@ export function checkStateFile(options) {
195
195
  };
196
196
  }
197
197
  try {
198
- const raw = fs.readFileSync(statePath, 'utf-8');
198
+ const raw = fs.readFileSync(statePath, 'utf8');
199
199
  const parsed = JSON.parse(raw);
200
200
  const result = AgentStateSchema.safeParse(parsed);
201
201
  if (!result.success) {
@@ -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,159 @@
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, maybeCheckpoint, } 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, 'utf8'),
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
+ // Push to Gist — autoSave only writes the local state-cache mirror in Gist
58
+ // mode, so without this checkpoint the change never propagates across
59
+ // machines (#1200).
60
+ await maybeCheckpoint(sm, MODULE);
61
+ return {
62
+ repo: options.repo,
63
+ byteSize: Buffer.byteLength(options.content, 'utf8'),
64
+ stored: true,
65
+ };
66
+ }
67
+ /** Tombstone the guidelines file for `repo`. */
68
+ export async function runGuidelinesReset(options) {
69
+ validateRepo(options.repo);
70
+ const sm = getStateManager();
71
+ if (!sm.isGuidelinesAvailable()) {
72
+ throw new GuidelinesNotAvailableError();
73
+ }
74
+ const existed = sm.getGuidelines(options.repo) !== null;
75
+ if (existed) {
76
+ sm.deleteGuidelines(options.repo);
77
+ // Push to Gist — see runGuidelinesStore note (#1200).
78
+ await maybeCheckpoint(sm, MODULE);
79
+ }
80
+ return { repo: options.repo, deleted: existed };
81
+ }
82
+ /**
83
+ * Fetch raw PR comment bundles for the most recent merged/closed PRs in `repo`
84
+ * that haven't already been processed. Returns a serializable corpus for the
85
+ * host's extract-learnings prompt.
86
+ *
87
+ * Filters applied at the CLI layer (not the fetcher):
88
+ * - Only PRs in the requested `repo`
89
+ * - PRs older than 12 months are dropped (recency cliff)
90
+ * - PRs with `commentsFetchedAt` already set are skipped unless `forceRefetch`
91
+ * - Capped at `limit` (default 5, max 10)
92
+ *
93
+ * Stamps `commentsFetchedAt` on every PR that was successfully fetched.
94
+ */
95
+ export async function runFetchCorpus(options) {
96
+ validateRepo(options.repo);
97
+ const limit = clampLimit(options.limit);
98
+ const sm = getStateManager();
99
+ const merged = sm.getMergedPRs() ?? [];
100
+ const closed = sm.getClosedPRs() ?? [];
101
+ const cutoffMs = Date.now() - RECENCY_CLIFF_MS;
102
+ /** Treat both merged and closed-without-merge PRs as candidates. */
103
+ const candidates = [
104
+ ...merged.map((pr) => ({ url: pr.url, timestamp: pr.mergedAt, alreadyFetched: !!pr.commentsFetchedAt })),
105
+ ...closed.map((pr) => ({ url: pr.url, timestamp: pr.closedAt, alreadyFetched: !!pr.commentsFetchedAt })),
106
+ ];
107
+ const repoUrlPrefix = `https://github.com/${options.repo}/`;
108
+ const eligible = candidates.filter((c) => {
109
+ if (!c.url.startsWith(repoUrlPrefix))
110
+ return false;
111
+ if (Date.parse(c.timestamp || '') < cutoffMs)
112
+ return false;
113
+ return true;
114
+ });
115
+ // Skipped count tracks ONLY the eligibility-passing PRs that were excluded
116
+ // for already-fetched. PRs filtered by repo/recency aren't "skipped" — they
117
+ // just don't apply to this repo or cutoff.
118
+ const skipped = options.forceRefetch ? 0 : eligible.filter((c) => c.alreadyFetched).length;
119
+ const toFetch = (options.forceRefetch ? eligible : eligible.filter((c) => !c.alreadyFetched))
120
+ // Most-recent first so the host always sees the freshest signal in its corpus window.
121
+ .sort((a, b) => Date.parse(b.timestamp || '') - Date.parse(a.timestamp || ''))
122
+ .slice(0, limit);
123
+ if (toFetch.length === 0) {
124
+ return { repo: options.repo, bundles: [], prCount: 0, skipped };
125
+ }
126
+ const token = requireGitHubToken();
127
+ const octokit = getOctokit(token);
128
+ const username = sm.getState().config.githubUsername;
129
+ if (!username) {
130
+ warn(MODULE, 'githubUsername is not set; bot/own-comment filtering will be incomplete');
131
+ }
132
+ const bundles = await fetchPRCommentBundlesBatch(octokit, toFetch.map((c) => c.url), username);
133
+ // Stamp commentsFetchedAt on each PR we successfully fetched — the bundle
134
+ // list mirrors the input order until paginateAll fan-out, so we mark on
135
+ // a per-bundle basis to be safe.
136
+ const now = new Date().toISOString();
137
+ for (const bundle of bundles) {
138
+ sm.markPRCommentsFetched(bundle.prUrl, now);
139
+ }
140
+ // Push commentsFetchedAt stamps to Gist so other machines don't re-fetch
141
+ // the same PRs forever. autoSave only writes the local mirror in Gist
142
+ // mode (#1200).
143
+ if (bundles.length > 0) {
144
+ await maybeCheckpoint(sm, MODULE);
145
+ }
146
+ return {
147
+ repo: options.repo,
148
+ bundles,
149
+ prCount: bundles.length,
150
+ skipped,
151
+ };
152
+ }
153
+ function clampLimit(limit) {
154
+ if (limit === undefined)
155
+ return DEFAULT_FETCH_LIMIT;
156
+ if (!Number.isFinite(limit) || limit < 1)
157
+ return DEFAULT_FETCH_LIMIT;
158
+ return Math.min(limit, MAX_FETCH_LIMIT);
159
+ }
@@ -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';
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * No GitHub calls — pure read/transform/write of a local file.
12
12
  */
13
- import * as fs from 'fs';
14
- import * as path from 'path';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
15
  import { errorMessage } from '../core/errors.js';
16
16
  const TIER_HEADERS = {
17
17
  pursue: '## Pursue',
@@ -47,7 +47,7 @@ function findIssueBlocks(lines, issueUrl) {
47
47
  for (let i = 0; i < lines.length; i++) {
48
48
  const line = lines[i];
49
49
  // Top-level list item — `- `, `* `, `+ `, or `1.` at the start (with no leading whitespace).
50
- const isTopLevelListItem = /^[-*+]\s|^\d+\.\s/.test(line);
50
+ const isTopLevelListItem = /^[*+-]\s|^\d+\.\s/.test(line);
51
51
  if (!isTopLevelListItem || !line.includes(issueUrl))
52
52
  continue;
53
53
  // Capture indented sub-bullets that follow this line.
@@ -166,7 +166,7 @@ export async function runListMoveTier(options) {
166
166
  }
167
167
  let content;
168
168
  try {
169
- content = fs.readFileSync(filePath, 'utf-8');
169
+ content = fs.readFileSync(filePath, 'utf8');
170
170
  }
171
171
  catch (error) {
172
172
  throw new Error(`Failed to read file: ${errorMessage(error)}`, { cause: error });
@@ -174,7 +174,7 @@ export async function runListMoveTier(options) {
174
174
  const result = moveIssueToTier(content, options.issueUrl, options.tier);
175
175
  if (result.moved) {
176
176
  try {
177
- fs.writeFileSync(filePath, result.content, 'utf-8');
177
+ fs.writeFileSync(filePath, result.content, 'utf8');
178
178
  }
179
179
  catch (error) {
180
180
  throw new Error(`Failed to write file: ${errorMessage(error)}`, { cause: error });