Skip to content
125 linesCodeBlameRaw
1/**
2 * What a routine can run on, in words, and routines that fit an agent's
3 * responsibilities, for owners to add in one step: Margo's "Reviewing pull
4 * requests for risk and test coverage" runs when a pull request is ready
5 * for review. Matched on words, never by a model, so it is instant and the
6 * same every time. Pure, so it is tested on its own.
7 */
8import type { NewRoutine, RoutineEvent, RoutineSuggestion } from "@g1t/contracts";
9
10/** The events a routine can run on, by key. */
11export const EVENT_KEYS: RoutineEvent[] = ["pull_ready", "pull_merged", "checks_failed", "issue_opened", "deploy_failed"];
12
13const WORDS: Record<RoutineEvent, string> = {
14 pull_ready: "a pull request is ready for review",
15 pull_merged: "a pull request is merged",
16 checks_failed: "checks fail on a pull request",
17 issue_opened: "an issue is opened",
18 deploy_failed: "a deploy fails",
19};
20
21/** A routine's events in words: "When a pull request is ready for review or checks fail on a pull request". */
22export function describeEvents(events: RoutineEvent[]): string {
23 const list = events.map((e) => WORDS[e]).filter(Boolean);
24 if (!list.length) return "";
25 const joined = list.length === 1 ? list[0] : `${list.slice(0, -1).join(", ")} or ${list.at(-1)}`;
26 return `When ${joined}`;
27}
28
29const trimmed = (duty: string) => duty.trim().replace(/\.$/, "");
30
31type Rule = { match: RegExp; routine: (duty: string) => Omit<NewRoutine, "channel_id"> };
32
33const RULES: Rule[] = [
34 {
35 match: /\b(review|reviewing|reviews)\b.*\b(pull requests?|prs?|changes?|code)\b|\b(pull requests?|prs?)\b.*\breview/i,
36 routine: (duty) => ({
37 name: "Review pull requests",
38 instructions: `When a pull request is ready for review, review it (${trimmed(duty)}). Read the change and its checks, then post a short review: what it changes, the risks, which tests cover it and what they miss, and a verdict: looks good, or what to fix first. Link the pull request.`,
39 schedule: null,
40 events: ["pull_ready"],
41 repos: [],
42 }),
43 },
44 {
45 match: /\b(flaky|failing|broken)\b.*\b(checks?|tests?|builds?|ci)\b|\b(checks?|ci|builds?)\b.*\b(flaky|fail)/i,
46 routine: (duty) => ({
47 name: "Chase failing checks",
48 instructions: `When checks fail on a pull request, look into it (${trimmed(duty)}). Read the failure, say whether it looks real or flaky and why, and what to do next. If a flaky check keeps coming back, offer to file an issue.`,
49 schedule: null,
50 events: ["checks_failed"],
51 repos: [],
52 }),
53 },
54 {
55 match: /\btest plans?\b|\bnew features?\b.*\btest/i,
56 routine: (duty) => ({
57 name: "Test plans for new work",
58 instructions: `When an issue is opened for new work, draft a test plan (${trimmed(duty)}): what to test, the edge cases, and what can be automated. Skip bug reports and questions.`,
59 schedule: null,
60 events: ["issue_opened"],
61 repos: [],
62 }),
63 },
64 {
65 match: /\b(triage|triaging)\b|\bincoming (issues|bugs|requests)\b/i,
66 routine: (duty) => ({
67 name: "Triage new issues",
68 instructions: `When an issue is opened, triage it (${trimmed(duty)}): what it is (bug, feature, question), how urgent, which area of the code it touches, and who should own it.`,
69 schedule: null,
70 events: ["issue_opened"],
71 repos: [],
72 }),
73 },
74 {
75 match: /\b(deploys?|deployments?|incidents?|on-?call|outages?|rollbacks?)\b/i,
76 routine: (duty) => ({
77 name: "Watch deploys",
78 instructions: `When a deploy fails, find out why (${trimmed(duty)}): what failed, what changed since the last good deploy, and whether to retry, roll back or fix forward.`,
79 schedule: null,
80 events: ["deploy_failed"],
81 repos: [],
82 }),
83 },
84 {
85 match: /\b(release notes|changelog|what shipped|weekly (summary|update|digest)|digests?)\b/i,
86 routine: (duty) => ({
87 name: "Weekly summary",
88 instructions: `Every Friday (${trimmed(duty)}): what merged and shipped this week, what's still open and what's at risk, in a short post people can skim.`,
89 schedule: { every: "week", minute: 0, hour: 16, weekday: 5 },
90 events: [],
91 repos: [],
92 }),
93 },
94 {
95 match: /\b(support|customers?|tickets?|complaints?|feedback)\b/i,
96 routine: (duty) => ({
97 name: "Support themes",
98 instructions: `Every weekday morning (${trimmed(duty)}): read yesterday's conversations you can see, group questions and complaints into themes, and post the top ones with how often each came up and whether an issue already covers it.`,
99 schedule: { every: "weekday", minute: 0, hour: 9, weekday: 1 },
100 events: [],
101 repos: [],
102 }),
103 },
104];
105
106/**
107 * Routines for an agent's responsibilities that it doesn't have yet: one
108 * per kind, skipping those whose name it already uses or whose events its
109 * routines already run on.
110 */
111export function suggestRoutines(responsibilities: string[], existing: { name: string; events: RoutineEvent[] }[]): RoutineSuggestion[] {
112 const out: RoutineSuggestion[] = [];
113 const taken = new Set(existing.map((r) => r.name.toLowerCase()));
114 const covered = new Set(existing.flatMap((r) => r.events));
115 for (const duty of responsibilities) {
116 const rule = RULES.find((r) => r.match.test(duty));
117 if (!rule) continue;
118 const routine = rule.routine(duty);
119 if (taken.has(routine.name.toLowerCase())) continue;
120 if (routine.events?.length && routine.events.every((e) => covered.has(e))) continue;
121 taken.add(routine.name.toLowerCase());
122 out.push({ responsibility: duty, routine });
123 }
124 return out;
125}