@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
@@ -67,6 +67,45 @@ export const AnalyzedIssueConversationSchema = z.object({
67
67
  repo: z.string(),
68
68
  analyzedAt: z.string(),
69
69
  });
70
+ /**
71
+ * One entry in a PR's follow-up history (#1277). Tier matches the
72
+ * cadence labels in `skills/pr-etiquette/SKILL.md` (light_check_in
73
+ * for 7-13 days, direct_check_in for 14-29 days, final_check_in for
74
+ * 30+ days). The `draftPath` is optional so an entry recorded from a
75
+ * direct `gh pr comment` (no draft) still validates.
76
+ */
77
+ export const FollowUpEntrySchema = z.object({
78
+ tier: z.enum(['light_check_in', 'direct_check_in', 'final_check_in']),
79
+ timestamp: z.string().datetime(),
80
+ draftPath: z.string().optional(),
81
+ });
82
+ /**
83
+ * Pause-point snapshot for resumable workflows (#1280). When the user
84
+ * picks "Done for now" inside `draft-first-workflow.md`,
85
+ * `work-through-issues.md`, or `pre-commit-review.md`, the workflow
86
+ * records its position here. The next `/oss` run consults this state
87
+ * and offers "Resume / Restart / Discard" instead of restarting from
88
+ * the beginning.
89
+ *
90
+ * `stepData` is intentionally typed as `Record<string, unknown>` so
91
+ * each workflow can persist whatever per-step context it needs to
92
+ * resume cleanly (compliance-gap skip list, last review pass count,
93
+ * etc.) without forcing a tagged-union schema in shared state.
94
+ */
95
+ export const WorkflowStateSchema = z.object({
96
+ workflowName: z.enum(['draft-first', 'work-through-issues', 'pre-commit-review']),
97
+ currentStep: z.string(),
98
+ branchName: z.string().optional(),
99
+ issueContext: z
100
+ .object({
101
+ title: z.string(),
102
+ url: z.string(),
103
+ })
104
+ .optional(),
105
+ completedSteps: z.array(z.string()).default([]),
106
+ stepData: z.record(z.string(), z.unknown()).default({}),
107
+ lastUpdatedAt: z.string().datetime(),
108
+ });
70
109
  // ── 3. Contribution schemas ──────────────────────────────────────────
71
110
  export const ContributionGuidelinesSchema = z.object({
72
111
  branchNamingConvention: z.string().optional(),
@@ -161,6 +200,25 @@ export const AgentConfigSchema = z.object({
161
200
  * the hook does nothing on every push unless the user explicitly enables it.
162
201
  */
163
202
  autoFormatBeforePush: z.boolean().default(false),
203
+ /**
204
+ * Threshold (in minutes) for the SessionStart PR health one-liner (#1255).
205
+ * The cached digest only refreshes when the user runs `/oss`; SessionStart
206
+ * fires every session. Without a freshness gate the line drifts arbitrarily
207
+ * stale between runs. When the cache is older than this many minutes (and
208
+ * not yet 7 days old, which keeps the existing catch-up nudge), the line is
209
+ * suppressed entirely. Default 30 minutes.
210
+ */
211
+ healthCheckFreshnessMinutes: z.number().int().positive().default(30),
212
+ /**
213
+ * Convergence cap for the multi-agent review loop in
214
+ * `workflows/dispatch-review.md` (#1275). When unset, the workflow
215
+ * falls back to per-mode defaults (5 for diff, 3 for plan). Lower
216
+ * values shorten the loop at the cost of skipping later iterations
217
+ * if findings persist; higher values give the loop more chances to
218
+ * converge before bailing. Optional — leave unset to use the
219
+ * defaults.
220
+ */
221
+ reviewMaxPasses: z.number().int().positive().optional(),
164
222
  /**
165
223
  * Optional Ollama model for SLM pre-triage during issue vetting (#1122).
166
224
  * Empty disables the feature. Recommended: `gemma4:e4b` (default for
@@ -227,6 +285,15 @@ export const AgentStateSchema = z.object({
227
285
  config: AgentConfigSchema.default(() => AgentConfigSchema.parse({})),
228
286
  lastRunAt: z.string().default(() => new Date().toISOString()),
229
287
  lastDigestAt: z.string().optional(),
288
+ /**
289
+ * ISO timestamp of the most recent {@link computeStrategy} invocation
290
+ * embedded in a daily run output (#1270). The cadence gate in
291
+ * `daily.ts` consults this — strategy snapshots fire every 30 days OR
292
+ * when 5+ PRs have merged since the last snapshot, whichever comes
293
+ * first. Below {@link STRATEGY_MIN_PRS} merged PRs the gate stays
294
+ * silent regardless of cadence.
295
+ */
296
+ lastStrategyAt: z.string().optional(),
230
297
  lastDigest: DailyDigestSchema.optional(),
231
298
  monthlyMergedCounts: z.record(z.string(), z.number()).optional(),
232
299
  monthlyClosedCounts: z.record(z.string(), z.number()).optional(),
@@ -236,4 +303,21 @@ export const AgentStateSchema = z.object({
236
303
  closedPRs: z.array(StoredClosedPRSchema).optional(),
237
304
  analyzedIssueConversations: z.array(AnalyzedIssueConversationSchema).optional(),
238
305
  activeIssues: z.array(TrackedIssueSchema).default([]),
306
+ /**
307
+ * Per-PR follow-up history (#1277). Keyed by PR URL. Each entry
308
+ * records a tier-bucketed follow-up that the user drafted (and
309
+ * presumably posted via `draft-review-post`). The dormant-pr
310
+ * workflow reads this before drafting to enforce the
311
+ * one-follow-up-per-timeframe rule documented in
312
+ * `skills/pr-etiquette/SKILL.md`.
313
+ */
314
+ prFollowUpHistory: z.record(z.string(), z.array(FollowUpEntrySchema)).optional(),
315
+ /**
316
+ * Pause-point snapshot for resumable workflows (#1280). Set when
317
+ * the user picks "Done for now" mid-workflow; cleared when the
318
+ * workflow completes or the user explicitly discards. The router
319
+ * reads this on every `/oss` invocation and offers Resume /
320
+ * Restart / Discard when present.
321
+ */
322
+ workflowState: WorkflowStateSchema.optional(),
239
323
  });
@@ -174,6 +174,13 @@ export declare class StateManager {
174
174
  * @param digest - The daily digest to store
175
175
  */
176
176
  setLastDigest(digest: DailyDigest): void;
177
+ /**
178
+ * Persist the timestamp of the most recent strategy snapshot embedded
179
+ * in a daily run output (#1270). Called from the daily pipeline after
180
+ * `computeStrategy()` succeeds; the cadence gate in
181
+ * {@link shouldComputeStrategy} reads this on the next run.
182
+ */
183
+ setLastStrategyAt(iso: string): void;
177
184
  /**
178
185
  * Update monthly merged PR counts for dashboard display.
179
186
  * @param counts - Monthly merged PR counts keyed by YYYY-MM
@@ -416,6 +416,16 @@ export class StateManager {
416
416
  this.state.lastDigestAt = digest.generatedAt;
417
417
  this.autoSave();
418
418
  }
419
+ /**
420
+ * Persist the timestamp of the most recent strategy snapshot embedded
421
+ * in a daily run output (#1270). Called from the daily pipeline after
422
+ * `computeStrategy()` succeeds; the cadence gate in
423
+ * {@link shouldComputeStrategy} reads this on the next run.
424
+ */
425
+ setLastStrategyAt(iso) {
426
+ this.state.lastStrategyAt = iso;
427
+ this.autoSave();
428
+ }
419
429
  /**
420
430
  * Update monthly merged PR counts for dashboard display.
421
431
  * @param counts - Monthly merged PR counts keyed by YYYY-MM
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Contribution strategy compute (#1243).
3
+ *
4
+ * Extracts the deterministic compute layer from
5
+ * `agents/contribution-strategist.md` so the categorization,
6
+ * trajectory detection, and capacity overextension rules are typed,
7
+ * unit-testable, and consumable both by the agent (synthesis layer)
8
+ * and a future auto-display in `/oss`.
9
+ *
10
+ * Same architectural shape as success-grade (#858), linked-PR
11
+ * classifier (#910), and compliance-score (#1245). Pure function — no
12
+ * I/O, no global state, no LLM. The interpretive narrative ("you're
13
+ * doing X well, here's a growth opportunity") stays in the agent
14
+ * prompt.
15
+ */
16
+ import type { AgentState } from './state-schema.js';
17
+ export type ContributorStyle = 'maintainer' | 'explorer' | 'specialist' | 'generalist';
18
+ export type TrajectoryDirection = 'growing' | 'steady' | 'declining';
19
+ export type CapacityAction = 'open_more' | 'follow_up_dormant' | 'wait_on_maintainers' | null;
20
+ export interface StrategyProfile {
21
+ style: ContributorStyle;
22
+ totalPRs: number;
23
+ mergedCount: number;
24
+ /** Merge rate as a 0-1 fraction. `0` when no PRs are tracked. */
25
+ mergeRate: number;
26
+ /** Top languages by repo PR count, descending. Empty when language data
27
+ * is not available in repoScores. */
28
+ primaryLanguages: string[];
29
+ /** Top repos by merged PR count, descending. */
30
+ favoriteRepos: string[];
31
+ }
32
+ export interface StrategyCapacity {
33
+ openPRCount: number;
34
+ dormantPRCount: number;
35
+ /** Distinct repos hosting at least one dormant PR. Surfaces alongside
36
+ * `dormantPRCount` so consumers can render "N PRs across M repos"
37
+ * without re-deriving from `openPRs` themselves. */
38
+ dormantRepoCount: number;
39
+ /** True when dormant PRs span >=2 distinct repos (a signal that the
40
+ * contributor is awaiting reviews from multiple maintainers, not just
41
+ * one slow project). Equivalent to `dormantPRCount >= 2 &&
42
+ * dormantRepoCount >= 2` — exposed as a separate boolean so callers
43
+ * with a strict yes/no presentation don't have to recompute. */
44
+ overExtended: boolean;
45
+ /** The single highest-priority next action — null when state is too
46
+ * thin to recommend anything. */
47
+ suggestedAction: CapacityAction;
48
+ }
49
+ export interface StrategyPatterns {
50
+ prTypeDistribution: {
51
+ docs: number;
52
+ fixes: number;
53
+ features: number;
54
+ refactors: number;
55
+ tests: number;
56
+ other: number;
57
+ };
58
+ trajectoryDirection: TrajectoryDirection;
59
+ averagePRSize: number;
60
+ }
61
+ export interface StrategyRecommendations {
62
+ languages: string[];
63
+ repos: string[];
64
+ issueTypes: string[];
65
+ avoidPatterns: string[];
66
+ }
67
+ export interface StrategyResult {
68
+ profile: StrategyProfile;
69
+ capacity: StrategyCapacity;
70
+ patterns: StrategyPatterns;
71
+ recommendations: StrategyRecommendations;
72
+ }
73
+ /** Minimum tracked PR history before strategy output is meaningful (#1243). */
74
+ export declare const STRATEGY_MIN_PRS = 10;
75
+ /**
76
+ * Compute the deterministic strategy signal from agent state. Returns
77
+ * null when state is thinner than {@link STRATEGY_MIN_PRS} merged PRs —
78
+ * the auto-display surface uses this null sentinel as the
79
+ * minimum-data gate (#1243).
80
+ */
81
+ export declare function computeStrategy(state: AgentState): StrategyResult | null;
82
+ /** Cadence trigger thresholds for the auto-display in `/oss` (#1270). */
83
+ export declare const STRATEGY_CADENCE_DAYS = 30;
84
+ export declare const STRATEGY_CADENCE_MERGED_DELTA = 5;
85
+ /**
86
+ * Decide whether a strategy snapshot should be embedded in this daily run.
87
+ * Returns true when EITHER 30 days have elapsed since the last snapshot OR
88
+ * 5+ PRs have merged since then, AND the merge floor in
89
+ * {@link STRATEGY_MIN_PRS} is met. The caller is responsible for calling
90
+ * {@link computeStrategy} when this returns true and for persisting
91
+ * `state.lastStrategyAt` after a successful compute.
92
+ *
93
+ * `nowIso` is injected so tests can pin time without mocking `Date`.
94
+ */
95
+ export declare function shouldComputeStrategy(state: AgentState, nowIso: string): boolean;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Contribution strategy compute (#1243).
3
+ *
4
+ * Extracts the deterministic compute layer from
5
+ * `agents/contribution-strategist.md` so the categorization,
6
+ * trajectory detection, and capacity overextension rules are typed,
7
+ * unit-testable, and consumable both by the agent (synthesis layer)
8
+ * and a future auto-display in `/oss`.
9
+ *
10
+ * Same architectural shape as success-grade (#858), linked-PR
11
+ * classifier (#910), and compliance-score (#1245). Pure function — no
12
+ * I/O, no global state, no LLM. The interpretive narrative ("you're
13
+ * doing X well, here's a growth opportunity") stays in the agent
14
+ * prompt.
15
+ */
16
+ /** Minimum tracked PR history before strategy output is meaningful (#1243). */
17
+ export const STRATEGY_MIN_PRS = 10;
18
+ /** How many top-N items each "primary" / "favorite" list returns. */
19
+ const TOP_N = 5;
20
+ /** Days since `mergedAt` after which a PR no longer counts as recent for
21
+ * the active-now PR-type distribution. 90 days matches the issue's
22
+ * proposed trajectory window. */
23
+ const RECENT_WINDOW_DAYS = 90;
24
+ /** Match repo from a GitHub URL: `https://github.com/owner/repo/pull/N`. */
25
+ const REPO_FROM_URL = /https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/\d+/i;
26
+ function parseRepo(url) {
27
+ const m = url.match(REPO_FROM_URL);
28
+ return m ? `${m[1]}/${m[2]}` : null;
29
+ }
30
+ function classifyTitle(title) {
31
+ const t = title.trim().toLowerCase();
32
+ // Match Conventional Commits prefix first; fall back to keyword search
33
+ // in the title body.
34
+ const prefix = t.match(/^(feat|fix|docs|refactor|test|perf|chore|build|ci|style|revert)(?:\([^)]+\))?!?:/);
35
+ const tag = prefix?.[1];
36
+ if (tag === 'docs')
37
+ return 'docs';
38
+ if (tag === 'fix' || tag === 'perf')
39
+ return 'fixes';
40
+ if (tag === 'feat')
41
+ return 'features';
42
+ if (tag === 'refactor')
43
+ return 'refactors';
44
+ if (tag === 'test')
45
+ return 'tests';
46
+ // Heuristic fallbacks for non-conventional titles.
47
+ if (/\b(?:doc|readme|comment|typo)\b/i.test(t))
48
+ return 'docs';
49
+ if (/\b(?:fix|bug|crash|regression|broken)\b/i.test(t))
50
+ return 'fixes';
51
+ if (/\b(?:add|implement|introduce|support|new)\b/i.test(t))
52
+ return 'features';
53
+ if (/\b(?:refactor|cleanup|extract|simplify|rename)\b/i.test(t))
54
+ return 'refactors';
55
+ if (/\btest|spec\b/i.test(t))
56
+ return 'tests';
57
+ return 'other';
58
+ }
59
+ function topNByCount(counts, n) {
60
+ return Object.entries(counts)
61
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
62
+ .slice(0, n)
63
+ .map(([name]) => name);
64
+ }
65
+ function determineStyle(_totalPRs, primaryLanguages, favoriteRepos) {
66
+ // `totalPRs` is reserved for future "explorer" classification (low-volume
67
+ // contributors). Today the function is only reachable from `computeStrategy`
68
+ // which gates at `merged.length >= STRATEGY_MIN_PRS` (10), so a `totalPRs < 5`
69
+ // branch could never fire — removed to avoid misleading future readers.
70
+ // Heavy concentration in 1-2 repos → maintainer; spread across many → generalist.
71
+ if (favoriteRepos.length === 1)
72
+ return 'maintainer';
73
+ if (primaryLanguages.length === 1 && favoriteRepos.length >= 2 && favoriteRepos.length <= 3) {
74
+ return 'specialist';
75
+ }
76
+ if (favoriteRepos.length >= 4)
77
+ return 'generalist';
78
+ return 'specialist';
79
+ }
80
+ function determineTrajectory(state) {
81
+ // Compare the last three months' merged PR counts against the prior
82
+ // three months. If each window has at least one merge, ratio >1.2 is
83
+ // growing, <0.8 is declining, otherwise steady.
84
+ const counts = state.monthlyMergedCounts ?? {};
85
+ const months = Object.keys(counts).sort();
86
+ if (months.length < 6)
87
+ return 'steady';
88
+ const recent = months.slice(-3).reduce((sum, m) => sum + (counts[m] ?? 0), 0);
89
+ const prior = months.slice(-6, -3).reduce((sum, m) => sum + (counts[m] ?? 0), 0);
90
+ if (prior === 0)
91
+ return recent > 0 ? 'growing' : 'steady';
92
+ const ratio = recent / prior;
93
+ if (ratio >= 1.2)
94
+ return 'growing';
95
+ if (ratio <= 0.8)
96
+ return 'declining';
97
+ return 'steady';
98
+ }
99
+ function recommendForOverExtension(openPRCount, dormantPRCount, overExtended) {
100
+ if (overExtended)
101
+ return 'follow_up_dormant';
102
+ if (dormantPRCount > 0 && openPRCount > 5)
103
+ return 'wait_on_maintainers';
104
+ if (openPRCount === 0)
105
+ return 'open_more';
106
+ return null;
107
+ }
108
+ /**
109
+ * Compute the deterministic strategy signal from agent state. Returns
110
+ * null when state is thinner than {@link STRATEGY_MIN_PRS} merged PRs —
111
+ * the auto-display surface uses this null sentinel as the
112
+ * minimum-data gate (#1243).
113
+ */
114
+ export function computeStrategy(state) {
115
+ const merged = state.mergedPRs ?? [];
116
+ const closed = state.closedPRs ?? [];
117
+ const totalPRs = merged.length + closed.length;
118
+ if (merged.length < STRATEGY_MIN_PRS)
119
+ return null;
120
+ // Per-repo PR counts (merged). Used both for "favorite repos" and to
121
+ // back-fill primary languages from `repoScores` entries that overlap.
122
+ const mergedByRepo = {};
123
+ for (const pr of merged) {
124
+ const repo = parseRepo(pr.url);
125
+ if (!repo)
126
+ continue;
127
+ mergedByRepo[repo] = (mergedByRepo[repo] ?? 0) + 1;
128
+ }
129
+ const favoriteRepos = topNByCount(mergedByRepo, TOP_N);
130
+ // Languages: weight a repo's language by that repo's merged count so
131
+ // `vercel/next.js` (TypeScript) counts more than a one-off PR to a
132
+ // Lua project even if both repos are in `repoScores`.
133
+ const languageCounts = {};
134
+ for (const [repo, count] of Object.entries(mergedByRepo)) {
135
+ const score = state.repoScores[repo];
136
+ const lang = score?.language;
137
+ if (!lang)
138
+ continue;
139
+ languageCounts[lang] = (languageCounts[lang] ?? 0) + count;
140
+ }
141
+ const primaryLanguages = topNByCount(languageCounts, TOP_N);
142
+ // PR-type distribution over the recent window.
143
+ const distribution = {
144
+ docs: 0,
145
+ fixes: 0,
146
+ features: 0,
147
+ refactors: 0,
148
+ tests: 0,
149
+ other: 0,
150
+ };
151
+ const cutoff = Date.now() - RECENT_WINDOW_DAYS * 86400000;
152
+ for (const pr of merged) {
153
+ const mergedAt = Date.parse(pr.mergedAt);
154
+ if (Number.isNaN(mergedAt) || mergedAt < cutoff)
155
+ continue;
156
+ distribution[classifyTitle(pr.title)] += 1;
157
+ }
158
+ // Capacity: read from the last digest. When no digest exists yet,
159
+ // default to zero (the agent has nothing to recommend without a
160
+ // digest). The DailyDigest schema does not store a `dormantCount`
161
+ // directly — derive it from the `waitingOnMaintainerPRs` array,
162
+ // which is the "PR flagged as awaiting maintainer review" bucket
163
+ // produced by `pr-monitor`.
164
+ const summary = state.lastDigest?.summary;
165
+ const openPRCount = summary?.totalActivePRs ?? 0;
166
+ const waiting = state.lastDigest?.waitingOnMaintainerPRs ?? [];
167
+ const dormantPRCount = waiting.length;
168
+ // Overextended: dormant PRs spread across 2+ repos. Same definition
169
+ // the issue body uses.
170
+ const dormantRepos = new Set(waiting
171
+ .map((pr) => (typeof pr.url === 'string' ? parseRepo(pr.url) : null))
172
+ .filter((r) => r !== null));
173
+ const overExtended = dormantPRCount >= 2 && dormantRepos.size >= 2;
174
+ const profile = {
175
+ style: determineStyle(totalPRs, primaryLanguages, favoriteRepos),
176
+ totalPRs,
177
+ mergedCount: merged.length,
178
+ mergeRate: totalPRs === 0 ? 0 : merged.length / totalPRs,
179
+ primaryLanguages,
180
+ favoriteRepos,
181
+ };
182
+ const capacity = {
183
+ openPRCount,
184
+ dormantPRCount,
185
+ dormantRepoCount: dormantRepos.size,
186
+ overExtended,
187
+ suggestedAction: recommendForOverExtension(openPRCount, dormantPRCount, overExtended),
188
+ };
189
+ const patterns = {
190
+ prTypeDistribution: distribution,
191
+ trajectoryDirection: determineTrajectory(state),
192
+ // Without per-PR diff size in StoredMergedPR, a true "average PR
193
+ // size" lookup is out of scope until that data is captured (#1243
194
+ // explicitly defers this). Surface 0 so downstream callers know the
195
+ // signal is unavailable rather than fabricating a value.
196
+ averagePRSize: 0,
197
+ };
198
+ const recommendations = {
199
+ // The deterministic recommendations layer reuses the profile
200
+ // signals — the agent's coaching prose layers on top of these.
201
+ languages: primaryLanguages,
202
+ repos: favoriteRepos,
203
+ issueTypes: deriveIssueTypePreferences(distribution),
204
+ avoidPatterns: deriveAvoidPatterns(capacity, patterns),
205
+ };
206
+ return { profile, capacity, patterns, recommendations };
207
+ }
208
+ /** Cadence trigger thresholds for the auto-display in `/oss` (#1270). */
209
+ export const STRATEGY_CADENCE_DAYS = 30;
210
+ export const STRATEGY_CADENCE_MERGED_DELTA = 5;
211
+ /**
212
+ * Decide whether a strategy snapshot should be embedded in this daily run.
213
+ * Returns true when EITHER 30 days have elapsed since the last snapshot OR
214
+ * 5+ PRs have merged since then, AND the merge floor in
215
+ * {@link STRATEGY_MIN_PRS} is met. The caller is responsible for calling
216
+ * {@link computeStrategy} when this returns true and for persisting
217
+ * `state.lastStrategyAt` after a successful compute.
218
+ *
219
+ * `nowIso` is injected so tests can pin time without mocking `Date`.
220
+ */
221
+ export function shouldComputeStrategy(state, nowIso) {
222
+ const merged = state.mergedPRs ?? [];
223
+ if (merged.length < STRATEGY_MIN_PRS)
224
+ return false;
225
+ const lastIso = state.lastStrategyAt;
226
+ if (!lastIso)
227
+ return true;
228
+ // Time-based trigger: 30+ days since last snapshot.
229
+ const lastMs = Date.parse(lastIso);
230
+ const nowMs = Date.parse(nowIso);
231
+ if (Number.isFinite(lastMs) && Number.isFinite(nowMs)) {
232
+ const daysSince = (nowMs - lastMs) / (1000 * 60 * 60 * 24);
233
+ if (daysSince >= STRATEGY_CADENCE_DAYS)
234
+ return true;
235
+ }
236
+ else {
237
+ // Unparseable timestamps — fail open and recompute rather than
238
+ // silently never re-firing. The lastStrategyAt write below will
239
+ // refresh to a valid ISO string.
240
+ return true;
241
+ }
242
+ // Merge-count trigger: count PRs merged after `lastStrategyAt`.
243
+ // `mergedAt` is required on StoredMergedPRSchema; `Date.parse` returns
244
+ // NaN for malformed-but-stored data and Number.isFinite excludes those.
245
+ const mergedSince = merged.filter((pr) => {
246
+ const mergedMs = Date.parse(pr.mergedAt);
247
+ return Number.isFinite(mergedMs) && mergedMs > lastMs;
248
+ }).length;
249
+ return mergedSince >= STRATEGY_CADENCE_MERGED_DELTA;
250
+ }
251
+ function deriveIssueTypePreferences(distribution) {
252
+ // Recommend the user's two strongest PR types — they have a track
253
+ // record there, so issues in those buckets are higher-yield.
254
+ const ranked = Object.entries(distribution)
255
+ .filter(([type]) => type !== 'other')
256
+ .sort((a, b) => b[1] - a[1])
257
+ .slice(0, 2)
258
+ .map(([type]) => type);
259
+ return ranked;
260
+ }
261
+ function deriveAvoidPatterns(capacity, patterns) {
262
+ const out = [];
263
+ if (capacity.overExtended) {
264
+ out.push('opening new PRs while dormant ones await review across multiple repos');
265
+ }
266
+ if (patterns.trajectoryDirection === 'declining') {
267
+ out.push('drifting away from a previously productive cadence — capacity may be saturated');
268
+ }
269
+ return out;
270
+ }
@@ -19,6 +19,46 @@ export interface ClassifiedCheck {
19
19
  category: CIFailureCategory;
20
20
  conclusion?: string;
21
21
  }
22
+ /**
23
+ * Mutually exclusive overall-CI categories produced by
24
+ * {@link categorizeCIStatus} (#1272). The 5-row truth table that lived
25
+ * as prose in `agents/pr-health-checker.md` — extracted so any consumer
26
+ * (the agent, the dashboard, future MCP surfaces) reads one typed field
27
+ * instead of re-deriving the table.
28
+ *
29
+ * - `all_passing` — every reported check is green
30
+ * - `failing` — at least one actionable failure (real test/lint/build
31
+ * issue), OR ciStatus reported failing without per-check detail (the
32
+ * honest answer when the legacy combined-status endpoint can't tell
33
+ * us what failed)
34
+ * - `fork_limitation` — failures exist but ALL of them are
35
+ * `fork_limitation` / `auth_gate` (Vercel preview, internal CI) — purely
36
+ * informational
37
+ * - `blocked` — checks are pending (awaiting trigger / completion), OR
38
+ * non-actionable failures include `infrastructure` (cancelled /
39
+ * timed-out runner — re-running often resolves)
40
+ * - `not_running` — no checks reported
41
+ */
42
+ export type CIStatusCategory = 'all_passing' | 'failing' | 'fork_limitation' | 'blocked' | 'not_running';
43
+ /**
44
+ * Suggested action for the {@link CIStatusCategorization}. Hint, not
45
+ * enforcement — the consuming agent may still escalate or skip based on
46
+ * other PR context.
47
+ */
48
+ export type CIStatusAction = 'none' | 'investigate' | 'request_rerun' | 'check_workflows' | 'informational';
49
+ /**
50
+ * Aggregate CI status produced by {@link categorizeCIStatus} (#1272).
51
+ * Derived from `ciStatus + failingCheckNames + classifiedChecks` —
52
+ * exposed on {@link FetchedPR} so agents read a single field instead
53
+ * of re-implementing the truth table.
54
+ */
55
+ export interface CIStatusCategorization {
56
+ category: CIStatusCategory;
57
+ /** Short human-readable summary suitable for inline display. */
58
+ summary: string;
59
+ /** Suggested next action (hint, not enforcement). */
60
+ action: CIStatusAction;
61
+ }
22
62
  /** CI status result returned by getCIStatus(). */
23
63
  export interface CIStatusResult {
24
64
  status: CIStatus;
@@ -115,6 +155,15 @@ export interface FetchedPR {
115
155
  failingCheckNames: string[];
116
156
  /** Failing checks with category classification (#81). Separates actionable failures from fork limitations and auth gates. */
117
157
  classifiedChecks: ClassifiedCheck[];
158
+ /**
159
+ * Aggregate 5-state CI categorization (#1272). Derived from `ciStatus`,
160
+ * `failingCheckNames`, and `classifiedChecks` via `categorizeCIStatus()`
161
+ * — agents read this directly instead of re-deriving the truth table.
162
+ * Always populated on a fresh fetch (v2 architecture has no cached
163
+ * `FetchedPR` to migrate); pr-monitor's `fetchPRDetails` sets it on
164
+ * every PR before construction.
165
+ */
166
+ ciCategorization: CIStatusCategorization;
118
167
  hasMergeConflict: boolean;
119
168
  reviewDecision: ReviewDecision;
120
169
  /** How many commits the PR branch is behind the base branch. */
@@ -207,6 +256,8 @@ interface CommentedIssueBase {
207
256
  title: string;
208
257
  url: string;
209
258
  userLastCommentedAt: string;
259
+ /** User's most recent comment body, truncated to 200 chars (+ "..." suffix when truncated). #1290 */
260
+ userLastCommentBody: string;
210
261
  labels: string[];
211
262
  daysSinceUserComment: number;
212
263
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Workflow-state helpers (#1280).
3
+ *
4
+ * Centralizes the read/write logic for `state.workflowState`. Used
5
+ * by `draft-first-workflow.md`, `work-through-issues.md`, and
6
+ * `pre-commit-review.md` to record pause points and offer
7
+ * Resume / Restart / Discard at the next `/oss` invocation.
8
+ *
9
+ * Pure functions — callers manage state I/O. Same architectural
10
+ * shape as the recent #1277 (follow-up-history) helpers.
11
+ */
12
+ import type { AgentState, WorkflowState } from './state-schema.js';
13
+ export type WorkflowName = WorkflowState['workflowName'];
14
+ /**
15
+ * Snapshot the user's current workflow position. Returns the
16
+ * patched state; callers persist via `StateManager.save()` (or the
17
+ * gist `checkpoint()` path).
18
+ */
19
+ export declare function recordWorkflowPause(state: AgentState, next: Omit<WorkflowState, 'lastUpdatedAt'>, now?: Date): AgentState;
20
+ /**
21
+ * Read the current workflow snapshot, or null when no pause is
22
+ * recorded.
23
+ */
24
+ export declare function getWorkflowState(state: AgentState): WorkflowState | null;
25
+ /**
26
+ * Discard the current pause snapshot. Used by:
27
+ * - the workflow completing normally,
28
+ * - the user picking "Discard and start fresh" at the resume
29
+ * prompt,
30
+ * - any consumer that detects the snapshot has gone stale (branch
31
+ * deleted, repo no longer reachable).
32
+ */
33
+ export declare function clearWorkflowState(state: AgentState): AgentState;
34
+ /**
35
+ * Append a step name to `completedSteps` and update `currentStep`
36
+ * to the supplied next step. Convenience wrapper around the immer-
37
+ * style read/replace pattern. Returns the patched state.
38
+ */
39
+ export declare function advanceWorkflowStep(state: AgentState, nextStep: string, now?: Date): AgentState;
40
+ /**
41
+ * Merge per-step data into `stepData` without overwriting other
42
+ * keys. Useful when a workflow's resume needs to restore non-trivial
43
+ * context (skipped compliance items, last review pass count, etc.).
44
+ */
45
+ export declare function setStepData(state: AgentState, step: string, data: unknown, now?: Date): AgentState;
46
+ /**
47
+ * Read step data for a specific step, or undefined when none was
48
+ * stored. Callers typically narrow the unknown via a type guard.
49
+ */
50
+ export declare function getStepData(state: AgentState, step: string): unknown;
51
+ /**
52
+ * Whether the recorded pause is on the supplied workflow. Useful
53
+ * for the router to decide whether to offer Resume vs ignore the
54
+ * snapshot when the current `/oss` invocation is unrelated.
55
+ */
56
+ export declare function isPausedIn(state: AgentState, workflowName: WorkflowName): boolean;