@oss-autopilot/core 3.4.1 → 3.6.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 (58) hide show
  1. package/dist/cli-registry.js +99 -0
  2. package/dist/cli.bundle.cjs +112 -105
  3. package/dist/commands/compliance-score.d.ts +21 -0
  4. package/dist/commands/compliance-score.js +156 -0
  5. package/dist/commands/daily.d.ts +8 -0
  6. package/dist/commands/daily.js +21 -0
  7. package/dist/commands/index.d.ts +6 -0
  8. package/dist/commands/index.js +6 -0
  9. package/dist/commands/list-mark-done.d.ts +48 -0
  10. package/dist/commands/list-mark-done.js +213 -0
  11. package/dist/commands/parse-list.js +86 -9
  12. package/dist/commands/repo-vet.d.ts +21 -0
  13. package/dist/commands/repo-vet.js +215 -0
  14. package/dist/commands/startup.js +41 -1
  15. package/dist/core/anti-llm-policy.d.ts +42 -13
  16. package/dist/core/anti-llm-policy.js +102 -13
  17. package/dist/core/ci-analysis.d.ts +32 -1
  18. package/dist/core/ci-analysis.js +92 -0
  19. package/dist/core/ci-enforced-tools.d.ts +35 -0
  20. package/dist/core/ci-enforced-tools.js +109 -0
  21. package/dist/core/comment-decision.d.ts +72 -0
  22. package/dist/core/comment-decision.js +74 -0
  23. package/dist/core/compliance-score.d.ts +127 -0
  24. package/dist/core/compliance-score.js +277 -0
  25. package/dist/core/config-registry.js +12 -0
  26. package/dist/core/contributing.d.ts +52 -0
  27. package/dist/core/contributing.js +139 -0
  28. package/dist/core/errors.d.ts +19 -0
  29. package/dist/core/errors.js +54 -0
  30. package/dist/core/extraction-categories.d.ts +55 -0
  31. package/dist/core/extraction-categories.js +108 -0
  32. package/dist/core/follow-up-history.d.ts +41 -0
  33. package/dist/core/follow-up-history.js +71 -0
  34. package/dist/core/gist-state-store.d.ts +30 -7
  35. package/dist/core/gist-state-store.js +87 -11
  36. package/dist/core/issue-conversation.js +1 -0
  37. package/dist/core/issue-effort.d.ts +29 -0
  38. package/dist/core/issue-effort.js +41 -0
  39. package/dist/core/maintainer-hints.d.ts +23 -0
  40. package/dist/core/maintainer-hints.js +36 -0
  41. package/dist/core/pr-monitor.d.ts +1 -1
  42. package/dist/core/pr-monitor.js +31 -11
  43. package/dist/core/pr-quality-rubric.d.ts +70 -0
  44. package/dist/core/pr-quality-rubric.js +121 -0
  45. package/dist/core/repo-vet.d.ts +90 -0
  46. package/dist/core/repo-vet.js +178 -0
  47. package/dist/core/state-schema.d.ts +77 -0
  48. package/dist/core/state-schema.js +84 -0
  49. package/dist/core/state.d.ts +7 -0
  50. package/dist/core/state.js +10 -0
  51. package/dist/core/strategy.d.ts +95 -0
  52. package/dist/core/strategy.js +270 -0
  53. package/dist/core/types.d.ts +51 -0
  54. package/dist/core/workflow-state.d.ts +56 -0
  55. package/dist/core/workflow-state.js +101 -0
  56. package/dist/formatters/json.d.ts +252 -0
  57. package/dist/formatters/json.js +153 -0
  58. package/package.json +1 -1
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Per-repo extraction category configuration (#1284).
3
+ *
4
+ * The `extract-learnings` MCP prompt produces a structured markdown
5
+ * document organized into category sections. The default category
6
+ * set is sensible for typical web/library OSS work; specialized
7
+ * repos (security-focused, performance-critical, accessibility-
8
+ * forward) benefit from a tailored taxonomy.
9
+ *
10
+ * This module is the single source of truth for:
11
+ * - The default category list.
12
+ * - Validation of custom category lists (non-empty, no duplicates,
13
+ * reasonable string lengths).
14
+ * - Resolution: given a repo's optional override, produce the list
15
+ * of categories the prompt and the storage layer should use.
16
+ *
17
+ * Pure typed helper — no I/O. Same architectural shape as the recent
18
+ * #1252 / #1264 / #1286 / #1279 / #1277 extractions.
19
+ *
20
+ * Out of scope (deferred per #1284):
21
+ * - Wiring `categories` into the `guidelines store` / `guidelines view`
22
+ * shape so the override persists alongside the markdown.
23
+ * - Updating the `extract-learnings` MCP prompt to consume the
24
+ * resolved category list at run time.
25
+ * - Detecting repo type from signals (SECURITY.md presence, repo
26
+ * topics, etc.) to suggest categories.
27
+ */
28
+ /**
29
+ * The default category list used by `extract-learnings` when no
30
+ * per-repo override is configured. Order matters: the prompt
31
+ * renders sections in this order, so `Code Style` first /
32
+ * `Other` last is the established convention.
33
+ */
34
+ export const DEFAULT_EXTRACTION_CATEGORIES = [
35
+ 'Code Style',
36
+ 'Process',
37
+ 'Architecture',
38
+ 'Testing',
39
+ 'Other',
40
+ ];
41
+ /**
42
+ * Reasonable bounds on an override list. The prompt produces output
43
+ * organized into one heading per category; very long lists become
44
+ * unreadable, very long category names blow out heading width.
45
+ */
46
+ const CATEGORY_NAME_MAX_LENGTH = 40;
47
+ const MAX_CATEGORIES = 12;
48
+ /**
49
+ * Validate a user-supplied category list. Returns a structured
50
+ * result rather than throwing — the CLI / slash-command callers
51
+ * surface error strings inline.
52
+ */
53
+ export function validateCategories(input) {
54
+ const errors = [];
55
+ if (input.length === 0) {
56
+ errors.push('At least one category is required.');
57
+ return { ok: false, errors };
58
+ }
59
+ if (input.length > MAX_CATEGORIES) {
60
+ errors.push(`At most ${MAX_CATEGORIES} categories are supported (received ${input.length}).`);
61
+ }
62
+ const seen = new Set();
63
+ const cleaned = [];
64
+ for (const raw of input) {
65
+ const trimmed = raw.trim();
66
+ if (trimmed.length === 0) {
67
+ errors.push('Category names cannot be empty or whitespace-only.');
68
+ continue;
69
+ }
70
+ if (trimmed.length > CATEGORY_NAME_MAX_LENGTH) {
71
+ errors.push(`Category "${trimmed.slice(0, 30)}…" is longer than ${CATEGORY_NAME_MAX_LENGTH} characters.`);
72
+ continue;
73
+ }
74
+ const lowerKey = trimmed.toLowerCase();
75
+ if (seen.has(lowerKey)) {
76
+ errors.push(`Duplicate category "${trimmed}" (case-insensitive).`);
77
+ continue;
78
+ }
79
+ seen.add(lowerKey);
80
+ cleaned.push(trimmed);
81
+ }
82
+ if (errors.length > 0) {
83
+ return { ok: false, errors };
84
+ }
85
+ // Always ensure an "Other" bucket exists at the end so the prompt
86
+ // has a place to put feedback that doesn't fit the user's chosen
87
+ // categories. Append silently if missing rather than treating the
88
+ // omission as an error — most users won't think to add it.
89
+ if (!seen.has('other')) {
90
+ cleaned.push('Other');
91
+ }
92
+ return { ok: true, errors: [], normalized: cleaned };
93
+ }
94
+ /**
95
+ * Resolve the categories the prompt + storage layer should use for a
96
+ * specific extraction. Falls back to the default list when no
97
+ * override is supplied or when the override fails validation.
98
+ */
99
+ export function resolveCategories(override) {
100
+ if (!override || override.length === 0) {
101
+ return DEFAULT_EXTRACTION_CATEGORIES;
102
+ }
103
+ const result = validateCategories(override);
104
+ if (!result.ok || !result.normalized) {
105
+ return DEFAULT_EXTRACTION_CATEGORIES;
106
+ }
107
+ return result.normalized;
108
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * PR follow-up history helpers (#1277).
3
+ *
4
+ * Centralizes the read/write logic for `state.prFollowUpHistory`.
5
+ * Used by `workflows/dormant-pr-follow-up.md` to enforce the
6
+ * one-follow-up-per-timeframe rule documented in
7
+ * `skills/pr-etiquette/SKILL.md`.
8
+ *
9
+ * Pure functions — callers manage state I/O.
10
+ */
11
+ import type { AgentState } from './state-schema.js';
12
+ import type { FollowUpEntry } from './state-schema.js';
13
+ export type FollowUpTier = 'light_check_in' | 'direct_check_in' | 'final_check_in';
14
+ /**
15
+ * Tier window in days. The "no second ping in the same window" rule
16
+ * uses this to decide whether a recent follow-up still locks out the
17
+ * next draft. Sourced from `skills/pr-etiquette/SKILL.md`.
18
+ */
19
+ export declare const TIER_WINDOW_DAYS: Record<FollowUpTier, number>;
20
+ /**
21
+ * Read the existing follow-up entries for a PR. Returns an empty
22
+ * array when no entries exist or when the state field is unset.
23
+ */
24
+ export declare function getFollowUpHistory(state: AgentState, prUrl: string): FollowUpEntry[];
25
+ /**
26
+ * Find the most recent entry, or null when there are none. Used by
27
+ * the workflow's pre-draft gate.
28
+ */
29
+ export declare function lastFollowUp(state: AgentState, prUrl: string): FollowUpEntry | null;
30
+ /**
31
+ * Whether a follow-up of the given tier was drafted within the
32
+ * tier's own window. The workflow uses this to surface a warning
33
+ * before the user drafts a second ping in the same window. Different
34
+ * tiers are always allowed (escalating light → direct → final).
35
+ */
36
+ export declare function isWithinTierWindow(state: AgentState, prUrl: string, tier: FollowUpTier, now?: Date): boolean;
37
+ /**
38
+ * Record a new follow-up entry. Returns the next state; callers
39
+ * decide when to persist.
40
+ */
41
+ export declare function recordFollowUp(state: AgentState, prUrl: string, entry: FollowUpEntry): AgentState;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * PR follow-up history helpers (#1277).
3
+ *
4
+ * Centralizes the read/write logic for `state.prFollowUpHistory`.
5
+ * Used by `workflows/dormant-pr-follow-up.md` to enforce the
6
+ * one-follow-up-per-timeframe rule documented in
7
+ * `skills/pr-etiquette/SKILL.md`.
8
+ *
9
+ * Pure functions — callers manage state I/O.
10
+ */
11
+ /**
12
+ * Tier window in days. The "no second ping in the same window" rule
13
+ * uses this to decide whether a recent follow-up still locks out the
14
+ * next draft. Sourced from `skills/pr-etiquette/SKILL.md`.
15
+ */
16
+ export const TIER_WINDOW_DAYS = {
17
+ light_check_in: 7,
18
+ direct_check_in: 14,
19
+ final_check_in: 30,
20
+ };
21
+ /**
22
+ * Read the existing follow-up entries for a PR. Returns an empty
23
+ * array when no entries exist or when the state field is unset.
24
+ */
25
+ export function getFollowUpHistory(state, prUrl) {
26
+ return state.prFollowUpHistory?.[prUrl] ?? [];
27
+ }
28
+ /**
29
+ * Find the most recent entry, or null when there are none. Used by
30
+ * the workflow's pre-draft gate.
31
+ */
32
+ export function lastFollowUp(state, prUrl) {
33
+ const entries = getFollowUpHistory(state, prUrl);
34
+ if (entries.length === 0)
35
+ return null;
36
+ // Newest by timestamp wins. Defensive sort because the persistence
37
+ // path doesn't guarantee insertion order across machines.
38
+ return [...entries].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))[0];
39
+ }
40
+ /**
41
+ * Whether a follow-up of the given tier was drafted within the
42
+ * tier's own window. The workflow uses this to surface a warning
43
+ * before the user drafts a second ping in the same window. Different
44
+ * tiers are always allowed (escalating light → direct → final).
45
+ */
46
+ export function isWithinTierWindow(state, prUrl, tier, now = new Date()) {
47
+ const last = lastFollowUp(state, prUrl);
48
+ if (!last)
49
+ return false;
50
+ if (last.tier !== tier)
51
+ return false;
52
+ const ageMs = now.getTime() - Date.parse(last.timestamp);
53
+ if (Number.isNaN(ageMs))
54
+ return false;
55
+ const windowMs = TIER_WINDOW_DAYS[tier] * 86400000;
56
+ return ageMs < windowMs;
57
+ }
58
+ /**
59
+ * Record a new follow-up entry. Returns the next state; callers
60
+ * decide when to persist.
61
+ */
62
+ export function recordFollowUp(state, prUrl, entry) {
63
+ const next = {
64
+ ...state,
65
+ prFollowUpHistory: { ...(state.prFollowUpHistory ?? {}) },
66
+ };
67
+ const list = next.prFollowUpHistory[prUrl] ? [...next.prFollowUpHistory[prUrl]] : [];
68
+ list.push(entry);
69
+ next.prFollowUpHistory[prUrl] = list;
70
+ return next;
71
+ }
@@ -14,7 +14,7 @@
14
14
  * 6. Cache all Gist file contents in memory for session-scoped reads
15
15
  * 7. Write state to a local cache file for fallback
16
16
  *
17
- * Concurrency model (#1115):
17
+ * Concurrency model (#1115, #1235):
18
18
  *
19
19
  * Each fetch captures the response's `ETag` and stores it as
20
20
  * `lastFetchedEtag`. The next `push()` sends that ETag back as
@@ -24,12 +24,25 @@
24
24
  * On 412, the store attempts a single merge: it re-fetches the Gist
25
25
  * (refreshing the ETag), re-applies the in-memory dirty content on top of
26
26
  * the now-current remote state, and pushes once more. If that second push
27
- * also returns 412, `push()` throws {@link GistConcurrencyError} — local
28
- * mutations stay in memory so the caller can decide whether to refresh and
29
- * retry. The merge step is intentionally cheap (state is a single JSON
30
- * blob) and treats local dirty changes as authoritative for conflict
31
- * cells, which matches the "last-write-wins by intent" model the rest of
32
- * oss-autopilot already assumes for state.json.
27
+ * also returns 412, `push()` throws {@link GistConcurrencyError}.
28
+ *
29
+ * Per-file conflict policy on the merge re-apply step:
30
+ *
31
+ * - `state.json` is fail-loud. The `StateManager` contract advertises
32
+ * optimistic compare-and-swap (state.ts:285-296), so silently clobbering
33
+ * a concurrent remote write would violate it. To detect concurrent
34
+ * writes without per-file ETags (the Gist API only exposes one ETag for
35
+ * the whole gist), we snapshot each file's content at fetch time. If
36
+ * the post-refresh `state.json` differs from the snapshot we held
37
+ * before the refresh, another machine wrote it: `push()` throws
38
+ * `GistConcurrencyError` instead of overwriting.
39
+ * - Freeform documents (e.g. per-repo guidelines) keep last-write-wins.
40
+ * The `setDocument` / `setGuidelines` callers' intent on a manual write
41
+ * is "overwrite". Local dirty content is reapplied on top of the
42
+ * refreshed remote.
43
+ *
44
+ * In both cases, when the second push also 412s, local mutations stay in
45
+ * memory so the caller can decide whether to refresh and retry.
33
46
  */
34
47
  import { AgentState } from './types.js';
35
48
  /** Well-known Gist description used for search-based discovery. */
@@ -126,6 +139,16 @@ export declare class GistStateStore {
126
139
  private gistId;
127
140
  readonly cachedFiles: Map<string, string>;
128
141
  readonly dirtyFiles: Set<string>;
142
+ /**
143
+ * Per-file content snapshot captured at fetch time (#1235). The Gist API
144
+ * exposes a single ETag for the whole gist, so we cannot use `If-Match`
145
+ * to detect a per-file conflict on the 412 merge re-apply. Holding the
146
+ * baseline content lets us compare it against the freshly-fetched remote
147
+ * and decide whether `state.json` changed under us. Updated only by
148
+ * `fetchAndCache` and successful `push()`; not mutated by `setState` /
149
+ * `setDocument` so the baseline reflects the remote, not local edits.
150
+ */
151
+ private baselineFiles;
129
152
  /**
130
153
  * ETag captured from the most recent successful Gist fetch (#1115).
131
154
  * Sent back as `If-Match` on `update` so concurrent writes from another
@@ -14,7 +14,7 @@
14
14
  * 6. Cache all Gist file contents in memory for session-scoped reads
15
15
  * 7. Write state to a local cache file for fallback
16
16
  *
17
- * Concurrency model (#1115):
17
+ * Concurrency model (#1115, #1235):
18
18
  *
19
19
  * Each fetch captures the response's `ETag` and stores it as
20
20
  * `lastFetchedEtag`. The next `push()` sends that ETag back as
@@ -24,12 +24,25 @@
24
24
  * On 412, the store attempts a single merge: it re-fetches the Gist
25
25
  * (refreshing the ETag), re-applies the in-memory dirty content on top of
26
26
  * the now-current remote state, and pushes once more. If that second push
27
- * also returns 412, `push()` throws {@link GistConcurrencyError} — local
28
- * mutations stay in memory so the caller can decide whether to refresh and
29
- * retry. The merge step is intentionally cheap (state is a single JSON
30
- * blob) and treats local dirty changes as authoritative for conflict
31
- * cells, which matches the "last-write-wins by intent" model the rest of
32
- * oss-autopilot already assumes for state.json.
27
+ * also returns 412, `push()` throws {@link GistConcurrencyError}.
28
+ *
29
+ * Per-file conflict policy on the merge re-apply step:
30
+ *
31
+ * - `state.json` is fail-loud. The `StateManager` contract advertises
32
+ * optimistic compare-and-swap (state.ts:285-296), so silently clobbering
33
+ * a concurrent remote write would violate it. To detect concurrent
34
+ * writes without per-file ETags (the Gist API only exposes one ETag for
35
+ * the whole gist), we snapshot each file's content at fetch time. If
36
+ * the post-refresh `state.json` differs from the snapshot we held
37
+ * before the refresh, another machine wrote it: `push()` throws
38
+ * `GistConcurrencyError` instead of overwriting.
39
+ * - Freeform documents (e.g. per-repo guidelines) keep last-write-wins.
40
+ * The `setDocument` / `setGuidelines` callers' intent on a manual write
41
+ * is "overwrite". Local dirty content is reapplied on top of the
42
+ * refreshed remote.
43
+ *
44
+ * In both cases, when the second push also 412s, local mutations stay in
45
+ * memory so the caller can decide whether to refresh and retry.
33
46
  */
34
47
  import * as fs from 'node:fs';
35
48
  import { AgentStateSchema } from './state-schema.js';
@@ -77,6 +90,16 @@ export class GistStateStore {
77
90
  gistId = null;
78
91
  cachedFiles = new Map();
79
92
  dirtyFiles = new Set();
93
+ /**
94
+ * Per-file content snapshot captured at fetch time (#1235). The Gist API
95
+ * exposes a single ETag for the whole gist, so we cannot use `If-Match`
96
+ * to detect a per-file conflict on the 412 merge re-apply. Holding the
97
+ * baseline content lets us compare it against the freshly-fetched remote
98
+ * and decide whether `state.json` changed under us. Updated only by
99
+ * `fetchAndCache` and successful `push()`; not mutated by `setState` /
100
+ * `setDocument` so the baseline reflects the remote, not local edits.
101
+ */
102
+ baselineFiles = new Map();
80
103
  /**
81
104
  * ETag captured from the most recent successful Gist fetch (#1115).
82
105
  * Sent back as `If-Match` on `update` so concurrent writes from another
@@ -379,17 +402,61 @@ export class GistStateStore {
379
402
  if (content !== undefined)
380
403
  stagedContents[filename] = content;
381
404
  }
405
+ // Snapshot the full cache and baseline before refresh so a partial
406
+ // failure inside fetchAndCache (e.g. parse throws after the cache
407
+ // was already cleared and repopulated) can be rolled back without
408
+ // leaving the store with a stale ETag pointing at corrupt or
409
+ // half-populated state (#1235).
410
+ const preRefreshCache = new Map(this.cachedFiles);
411
+ const preRefreshBaseline = new Map(this.baselineFiles);
412
+ const preRefreshEtag = this.lastFetchedEtag;
413
+ const reapplyStaged = () => {
414
+ for (const [filename, content] of Object.entries(stagedContents)) {
415
+ this.cachedFiles.set(filename, content);
416
+ }
417
+ };
382
418
  try {
383
419
  await this.fetchAndCache(this.gistId);
384
420
  }
385
421
  catch (refreshErr) {
386
422
  warn(MODULE, `Merge refresh after 412 failed: ${refreshErr}`);
423
+ // Roll back any partial mutation from fetchAndCache so the
424
+ // caller's retry path sees the prior state (with local
425
+ // mutations re-applied on top).
426
+ this.cachedFiles.clear();
427
+ for (const [filename, content] of preRefreshCache) {
428
+ this.cachedFiles.set(filename, content);
429
+ }
430
+ this.baselineFiles = preRefreshBaseline;
431
+ this.lastFetchedEtag = preRefreshEtag;
432
+ reapplyStaged();
433
+ // Preserve the GistCorruptError type so callers don't retry
434
+ // against a corrupt remote (which a CONCURRENCY_ERROR would
435
+ // invite). Other failure types continue to surface as a
436
+ // concurrency error — the gist content is still likely fine.
437
+ if (refreshErr instanceof GistCorruptError)
438
+ throw refreshErr;
387
439
  throw new GistConcurrencyError(expectedEtag, null);
388
440
  }
389
- // Re-apply our staged dirty contents on top of the refreshed remote.
390
- for (const [filename, content] of Object.entries(stagedContents)) {
391
- this.cachedFiles.set(filename, content);
441
+ // Per-file conflict policy (#1235):
442
+ // - state.json must fail loud when its remote copy moved under us;
443
+ // silently overwriting would violate the StateManager
444
+ // optimistic-concurrency contract (state.ts:285-296).
445
+ // - Freeform documents (guidelines, etc.) keep last-write-wins —
446
+ // `setDocument` callers' intent on a manual write is "overwrite".
447
+ if (stagedContents[STATE_FILE_NAME] !== undefined) {
448
+ const baselineBeforeRefresh = preRefreshBaseline.get(STATE_FILE_NAME);
449
+ const remoteAfterRefresh = this.cachedFiles.get(STATE_FILE_NAME);
450
+ if (baselineBeforeRefresh !== remoteAfterRefresh) {
451
+ warn(MODULE, 'state.json changed remotely while a push was in flight; surfacing GistConcurrencyError instead of clobbering (#1235)');
452
+ // Preserve local state.json (and other staged dirty files) in
453
+ // memory so the caller can refresh and reapply if appropriate.
454
+ reapplyStaged();
455
+ throw new GistConcurrencyError(expectedEtag, this.lastFetchedEtag);
456
+ }
392
457
  }
458
+ // Re-apply our staged dirty contents on top of the refreshed remote.
459
+ reapplyStaged();
393
460
  result = await attempt(this.lastFetchedEtag);
394
461
  if (!result.ok && result.status === 412) {
395
462
  warn(MODULE, 'Second push after merge also returned 412; surfacing GistConcurrencyError');
@@ -405,8 +472,11 @@ export class GistStateStore {
405
472
  return false;
406
473
  }
407
474
  }
408
- // Success: flush dirty set and write local cache
475
+ // Success: flush dirty set and write local cache. Refresh the
476
+ // per-file baseline to reflect what we just wrote — the next 412
477
+ // merge re-apply check compares against this.
409
478
  this.dirtyFiles.clear();
479
+ this.baselineFiles = new Map(this.cachedFiles);
410
480
  const raw = this.cachedFiles.get(STATE_FILE_NAME);
411
481
  if (raw) {
412
482
  try {
@@ -489,6 +559,10 @@ export class GistStateStore {
489
559
  this.cachedFiles.set(filename, file.content);
490
560
  }
491
561
  }
562
+ // Snapshot the just-fetched contents as the per-file baseline (#1235).
563
+ // The 412 merge path uses this to detect whether a concurrent write
564
+ // changed `state.json` between this fetch and the next push attempt.
565
+ this.baselineFiles = new Map(this.cachedFiles);
492
566
  // Parse state.json
493
567
  const state = this.parseStateFromCache();
494
568
  // Write-through to local cache for degraded-mode fallback
@@ -578,6 +652,7 @@ export class GistStateStore {
578
652
  this.cachedFiles.set(filename, file.content);
579
653
  }
580
654
  }
655
+ this.baselineFiles = new Map(this.cachedFiles);
581
656
  // Write-through to local cache
582
657
  this.writeLocalStateCache(freshState);
583
658
  return { id: data.id, state: freshState };
@@ -602,6 +677,7 @@ export class GistStateStore {
602
677
  this.cachedFiles.set(filename, file.content);
603
678
  }
604
679
  }
680
+ this.baselineFiles = new Map(this.cachedFiles);
605
681
  // Write-through to local cache
606
682
  this.writeLocalStateCache(seedState);
607
683
  return { id: data.id, state: seedState };
@@ -205,6 +205,7 @@ export class IssueConversationMonitor {
205
205
  title: item.title,
206
206
  url: item.html_url,
207
207
  userLastCommentedAt: userLastComment.createdAt,
208
+ userLastCommentBody: userLastComment.body.slice(0, 200) + (userLastComment.body.length > 200 ? '...' : ''),
208
209
  labels,
209
210
  daysSinceUserComment: daysBetween(userLastCommentTime, new Date()),
210
211
  };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Action-menu issue effort classification (#1264).
3
+ *
4
+ * Extracted from `workflows/action-menu.md`'s in-prompt truth table so
5
+ * the rules are typed, unit-testable, and tunable without editing
6
+ * markdown. Same architectural shape as the typed-core extractions in
7
+ * #1245 (compliance-score), #1242 (repo-vet), #1243 (strategy), and
8
+ * #1252 (pr-quality-rubric).
9
+ */
10
+ /** Issue types the action menu surfaces. Match the strings the CLI emits in
11
+ * `data.daily.actionableIssues[*].type`. */
12
+ export type ActionableIssueType = 'needs_response' | 'needs_changes' | 'incomplete_checklist' | 'ci_failing' | 'merge_conflict';
13
+ export type IssueEffort = 'small' | 'medium' | 'large';
14
+ /**
15
+ * Decide the effort label for an actionable issue based on its type and
16
+ * how many maintainer hints accompany it. Default falls through to
17
+ * `medium` for any unrecognized combination — the action-menu workflow
18
+ * still renders something useful when the CLI surfaces a new issue
19
+ * type before this function is updated.
20
+ *
21
+ * Truth table sourced verbatim from `workflows/action-menu.md`:
22
+ *
23
+ * | Effort | Condition |
24
+ * |--------|-----------|
25
+ * | small | needs_response with 0-1 hints, incomplete_checklist |
26
+ * | medium | needs_response with 2+ hints, needs_changes with 0-2 hints, ci_failing |
27
+ * | large | merge_conflict, needs_changes with 3+ hints |
28
+ */
29
+ export declare function computeIssueEffort(type: ActionableIssueType, hintCount: number): IssueEffort;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Action-menu issue effort classification (#1264).
3
+ *
4
+ * Extracted from `workflows/action-menu.md`'s in-prompt truth table so
5
+ * the rules are typed, unit-testable, and tunable without editing
6
+ * markdown. Same architectural shape as the typed-core extractions in
7
+ * #1245 (compliance-score), #1242 (repo-vet), #1243 (strategy), and
8
+ * #1252 (pr-quality-rubric).
9
+ */
10
+ /**
11
+ * Decide the effort label for an actionable issue based on its type and
12
+ * how many maintainer hints accompany it. Default falls through to
13
+ * `medium` for any unrecognized combination — the action-menu workflow
14
+ * still renders something useful when the CLI surfaces a new issue
15
+ * type before this function is updated.
16
+ *
17
+ * Truth table sourced verbatim from `workflows/action-menu.md`:
18
+ *
19
+ * | Effort | Condition |
20
+ * |--------|-----------|
21
+ * | small | needs_response with 0-1 hints, incomplete_checklist |
22
+ * | medium | needs_response with 2+ hints, needs_changes with 0-2 hints, ci_failing |
23
+ * | large | merge_conflict, needs_changes with 3+ hints |
24
+ */
25
+ export function computeIssueEffort(type, hintCount) {
26
+ const hints = Math.max(0, hintCount);
27
+ switch (type) {
28
+ case 'needs_response':
29
+ return hints <= 1 ? 'small' : 'medium';
30
+ case 'incomplete_checklist':
31
+ return 'small';
32
+ case 'needs_changes':
33
+ return hints >= 3 ? 'large' : 'medium';
34
+ case 'ci_failing':
35
+ return 'medium';
36
+ case 'merge_conflict':
37
+ return 'large';
38
+ default:
39
+ return 'medium';
40
+ }
41
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Maintainer-hint label constants (#1264).
3
+ *
4
+ * Extracted from `workflows/action-menu.md`'s in-prompt mapping so the
5
+ * label strings live in typed code instead of markdown. Adding a new
6
+ * hint type becomes a single-file change in core; the workflow gets
7
+ * the new label for free instead of requiring a parallel markdown
8
+ * edit. Same pattern as #1252.
9
+ */
10
+ export type MaintainerHint = 'demo_requested' | 'tests_requested' | 'changes_requested' | 'docs_requested' | 'rebase_requested';
11
+ /**
12
+ * Display labels the action-menu workflow renders for each hint type.
13
+ * Source of truth — both `data.daily.actionableIssues[*].maintainerHintsLabel`
14
+ * (computed by the CLI) and the workflow's prose docs reference this map.
15
+ */
16
+ export declare const MAINTAINER_HINT_LABELS: Record<MaintainerHint, string>;
17
+ /**
18
+ * Format an array of hints as a comma-joined human label, in the same
19
+ * order the caller passed them. Unknown hint values are dropped silently —
20
+ * a future CLI version might emit a hint type the workflow doesn't yet
21
+ * know about; rendering "undefined" would be worse than omitting it.
22
+ */
23
+ export declare function formatMaintainerHints(hints: readonly MaintainerHint[] | readonly string[]): string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Maintainer-hint label constants (#1264).
3
+ *
4
+ * Extracted from `workflows/action-menu.md`'s in-prompt mapping so the
5
+ * label strings live in typed code instead of markdown. Adding a new
6
+ * hint type becomes a single-file change in core; the workflow gets
7
+ * the new label for free instead of requiring a parallel markdown
8
+ * edit. Same pattern as #1252.
9
+ */
10
+ /**
11
+ * Display labels the action-menu workflow renders for each hint type.
12
+ * Source of truth — both `data.daily.actionableIssues[*].maintainerHintsLabel`
13
+ * (computed by the CLI) and the workflow's prose docs reference this map.
14
+ */
15
+ export const MAINTAINER_HINT_LABELS = {
16
+ demo_requested: 'demo/screenshot requested',
17
+ tests_requested: 'tests requested',
18
+ changes_requested: 'code changes requested',
19
+ docs_requested: 'documentation requested',
20
+ rebase_requested: 'rebase requested',
21
+ };
22
+ /**
23
+ * Format an array of hints as a comma-joined human label, in the same
24
+ * order the caller passed them. Unknown hint values are dropped silently —
25
+ * a future CLI version might emit a hint type the workflow doesn't yet
26
+ * know about; rendering "undefined" would be worse than omitting it.
27
+ */
28
+ export function formatMaintainerHints(hints) {
29
+ const labels = [];
30
+ for (const h of hints) {
31
+ const label = MAINTAINER_HINT_LABELS[h];
32
+ if (label)
33
+ labels.push(label);
34
+ }
35
+ return labels.join(', ');
36
+ }
@@ -15,7 +15,7 @@
15
15
  import { FetchedPR, DailyDigest, ClosedPR, MergedPR, StarFilter } from './types.js';
16
16
  import { type PRCountsResult } from './github-stats.js';
17
17
  export { computeDisplayLabel } from './display-utils.js';
18
- export { classifyCICheck, classifyFailingChecks, getCIStatus } from './ci-analysis.js';
18
+ export { categorizeCIStatus, classifyCICheck, classifyFailingChecks, getCIStatus } from './ci-analysis.js';
19
19
  export { isConditionalChecklistItem } from './checklist-analysis.js';
20
20
  export { determineStatus } from './status-determination.js';
21
21
  /**