@ramarivera/coding-buddy 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { moodReactionPool } from "./reaction-data.ts";
6
+ import {
7
+ fileExists,
8
+ isOnCooldown,
9
+ nonNegativeInteger,
10
+ parseHookInput,
11
+ pickRandom,
12
+ readJsonFile,
13
+ readStdin,
14
+ resolveHookSessionId,
15
+ resolveHookStateDir,
16
+ stringField,
17
+ type HookRuntime,
18
+ } from "./common.ts";
19
+
20
+ interface BuddyStatus {
21
+ muted?: unknown;
22
+ species?: unknown;
23
+ reaction?: unknown;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface MoodConfig {
28
+ moodCooldown?: unknown;
29
+ }
30
+
31
+ interface Events {
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ export interface MoodReactResult {
36
+ mood?: "frustrated" | "happy" | "stuck";
37
+ reaction?: string;
38
+ updated: boolean;
39
+ }
40
+
41
+ const MOOD_PATTERNS: ReadonlyArray<readonly [MoodReactResult["mood"], RegExp]> = [
42
+ ["frustrated", /\bwtf\b|\bugh\b|\bstupid\b|\bbroken\b|why won.?t|\bcome on\b|\bseriously\b|\bdamn\b|\bhell\b|this sucks|hate this|\bgrr\b|\bargh\b|\bannoying\b|\bfrustrat\b|\bterrible\b|\bhorrible\b|\bworst\b/i],
43
+ ["happy", /\bnice!?\b|\bworks!?\b|\bawesome\b|\bperfect\b|\bgreat\b|love it|\byay\b|\bsweet\b|\bbeautiful\b|\bamazing\b|hell yes|nailed it|fixed it|\bhell yeah\b|\bfantastic\b/i],
44
+ ["stuck", /\bstuck\b|\bhelp\b|\bconfused\b|don.?t understand|how do i\b|what does\b|why is\b|i can.?t\b|no idea|\blost\b|\bclueless\b|\bbaffled\b|\bpuzzled\b/i],
45
+ ];
46
+
47
+ export function moodForPrompt(prompt: string): MoodReactResult["mood"] {
48
+ return MOOD_PATTERNS.find(([, pattern]) => pattern.test(prompt))?.[0];
49
+ }
50
+
51
+ function incrementEvent(stateDir: string, key: string): void {
52
+ const eventsFile = join(stateDir, "events.json");
53
+ const parsedEvents = readJsonFile<Events>(eventsFile);
54
+ if (!parsedEvents && fileExists(eventsFile)) return;
55
+ const events = parsedEvents ?? {};
56
+ events[key] = typeof events[key] === "number" ? Number(events[key]) + 1 : 1;
57
+ writeFileSync(eventsFile, JSON.stringify(events, null, 2));
58
+ }
59
+
60
+ export function handleMoodReact(rawInput: string, runtime: HookRuntime = {}): MoodReactResult {
61
+ const stateDir = resolveHookStateDir(runtime);
62
+ const statusFile = join(stateDir, "status.json");
63
+ if (!fileExists(statusFile)) return { updated: false };
64
+
65
+ const config = readJsonFile<MoodConfig>(join(stateDir, "config.json")) ?? {};
66
+ const cooldown = nonNegativeInteger(config.moodCooldown, 60);
67
+ const sid = resolveHookSessionId(runtime);
68
+ const now = runtime.now?.() ?? Date.now();
69
+ if (isOnCooldown(join(stateDir, `.last_mood.${sid}`), cooldown, now)) {
70
+ return { updated: false };
71
+ }
72
+
73
+ const input = parseHookInput(rawInput);
74
+ if (!input) return { updated: false };
75
+ const prompt = stringField(input, "prompt");
76
+ if (!prompt) return { updated: false };
77
+
78
+ const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
79
+ const status = parsedStatus ?? {};
80
+ if (status.muted === true || status.muted === "true") return { updated: false };
81
+
82
+ const mood = moodForPrompt(prompt);
83
+ if (!mood) return { updated: false };
84
+ const species = typeof status.species === "string" ? status.species : "blob";
85
+ const reaction = pickRandom(moodReactionPool(species, mood), runtime);
86
+ if (!reaction) return { updated: false };
87
+
88
+ mkdirSync(stateDir, { recursive: true });
89
+ writeFileSync(join(stateDir, `.last_mood.${sid}`), String(Math.floor(now / 1000)));
90
+ writeFileSync(
91
+ join(stateDir, `reaction.${sid}.json`),
92
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood }),
93
+ );
94
+ if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
95
+ incrementEvent(stateDir, `mood_${mood}`);
96
+
97
+ return { mood, reaction, updated: true };
98
+ }
99
+
100
+ if (import.meta.main) {
101
+ try {
102
+ handleMoodReact(await readStdin());
103
+ } catch {
104
+ // Claude Code hooks must never fail the turn.
105
+ }
106
+ process.exit(0);
107
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import {
6
+ DEFAULT_NAME_REACTIONS,
7
+ NAME_REACTION_POOLS,
8
+ } from "./reaction-data.ts";
9
+ import {
10
+ fileExists,
11
+ parseHookInput,
12
+ pickRandom,
13
+ readJsonFile,
14
+ readStdin,
15
+ resolveHookSessionId,
16
+ resolveHookStateDir,
17
+ type JsonObject,
18
+ type HookRuntime,
19
+ } from "./common.ts";
20
+
21
+ interface BuddyStatus {
22
+ muted?: unknown;
23
+ name?: unknown;
24
+ species?: unknown;
25
+ reaction?: unknown;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ export interface NameReactResult {
30
+ reaction?: string;
31
+ updated: boolean;
32
+ }
33
+
34
+ function promptFromInput(input: JsonObject): string {
35
+ const direct = input.prompt ?? input.message ?? input.user_message;
36
+ if (direct !== undefined && direct !== null) {
37
+ return typeof direct === "string" ? direct : String(direct);
38
+ }
39
+
40
+ const messages = input.messages;
41
+ if (!Array.isArray(messages) || messages.length === 0) return "";
42
+ const content = (messages[messages.length - 1] as JsonObject | null)?.content;
43
+ if (Array.isArray(content)) {
44
+ const first = content[0];
45
+ return first && typeof first === "object" ? String((first as JsonObject).text ?? "") : "";
46
+ }
47
+ return typeof content === "string" ? content : "";
48
+ }
49
+
50
+ export function hasNameMention(prompt: string, name: string): boolean {
51
+ if (!name) return false;
52
+ try {
53
+ return new RegExp(`(^|[^a-zA-Z])${name}([^a-zA-Z]|$)`, "i").test(prompt);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ export function handleNameReact(rawInput: string, runtime: HookRuntime = {}): NameReactResult {
60
+ const stateDir = resolveHookStateDir(runtime);
61
+ const statusFile = join(stateDir, "status.json");
62
+ if (!fileExists(statusFile)) return { updated: false };
63
+
64
+ const input = parseHookInput(rawInput);
65
+ if (!input) return { updated: false };
66
+ const prompt = promptFromInput(input);
67
+ if (!prompt) return { updated: false };
68
+
69
+ const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
70
+ const status = parsedStatus ?? {};
71
+ const name = typeof status.name === "string" ? status.name : "";
72
+ if (!hasNameMention(prompt, name)) return { updated: false };
73
+ if (status.muted === true || status.muted === "true") return { updated: false };
74
+
75
+ const species = typeof status.species === "string" ? status.species : "blob";
76
+ const reaction = pickRandom(NAME_REACTION_POOLS[species] ?? DEFAULT_NAME_REACTIONS, runtime);
77
+ if (!reaction) return { updated: false };
78
+
79
+ const now = runtime.now?.() ?? Date.now();
80
+ const sid = resolveHookSessionId(runtime);
81
+ mkdirSync(stateDir, { recursive: true });
82
+ if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
83
+ writeFileSync(
84
+ join(stateDir, `reaction.${sid}.json`),
85
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name" }),
86
+ );
87
+
88
+ return { reaction, updated: true };
89
+ }
90
+
91
+ if (import.meta.main) {
92
+ try {
93
+ handleNameReact(await readStdin());
94
+ } catch {
95
+ // Claude Code hooks must never fail the turn.
96
+ }
97
+ process.exit(0);
98
+ }
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { reactionPool } from "./reaction-data.ts";
6
+ import {
7
+ defaultSpawnDetached,
8
+ fileExists,
9
+ hookClock,
10
+ isOnCooldown,
11
+ nonNegativeInteger,
12
+ parseHookInput,
13
+ pickRandom,
14
+ readJsonFile,
15
+ readStdin,
16
+ resolveHookSessionId,
17
+ resolveHookStateDir,
18
+ type HookRuntime,
19
+ } from "./common.ts";
20
+
21
+ interface BuddyStatus {
22
+ muted?: unknown;
23
+ species?: unknown;
24
+ name?: unknown;
25
+ }
26
+
27
+ interface ReactConfig {
28
+ commentCooldown?: unknown;
29
+ }
30
+
31
+ interface Events {
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ interface Classification {
36
+ branch?: string;
37
+ files?: string;
38
+ lines?: string;
39
+ reason: string;
40
+ }
41
+
42
+ export interface ReactResult {
43
+ reaction?: string;
44
+ reason?: string;
45
+ updated: boolean;
46
+ }
47
+
48
+ const MATCHERS: ReadonlyArray<readonly [string, RegExp]> = [
49
+ ["merge-conflict", /CONFLICT \(|Merge conflict in|both modified/i],
50
+ ["commit", /[0-9]+ files? changed|\[[a-zA-Z_-]+ [a-f0-9]{7,}\]/i],
51
+ ["push", /To .+:|[0-9a-f]+\.\.[0-9a-f]+\s+\w+ -> \w+|Everything up-to-date|remote: Resolving deltas/i],
52
+ ["branch", /Switched to a new branch|Created branch|onto a new branch/i],
53
+ ["rebase", /Successfully rebased|Rebasing|[0-9]+ done/i],
54
+ ["stash", /Saved working directory|Dropped .+ stash|stash@/i],
55
+ ["tag", /tagged|v[0-9]+\.[0-9]+|tag:.*->/i],
56
+ ["security-warning", /vulnerabilit|CVE-[0-9]{4}-[0-9]+|npm audit|found [0-9]+ vulnerabilities|in [0-9]+ scanned package/i],
57
+ ["build-fail", /Build failed|Failed to compile|ERROR in |compilation error|Command failed with exit code/i],
58
+ ["type-error", /TS[0-9]{4}:|Type .+ is not assignable|Argument of type|Cannot find name|Property .+ does not exist/i],
59
+ ["lint-fail", /✖|[0-9]+ problems? \([0-9]+ error|error:|warning:.+ ESLint|Ruff|flake8.*error|pylint.*error/i],
60
+ ["deprecation", /deprecat|will be removed in|is deprecated|DEPRECATED/i],
61
+ ["all-green", /all [0-9]+ tests passed|0 failures|100% passed|all [0-9]+ passed/i],
62
+ ["deploy", /deployed to|Deployment complete|Published to|vercel.*ready|netlify.*deployed/i],
63
+ ["release", /npm publish|gh release create|Published.*to.*registry/i],
64
+ ["coverage", /Coverage:.*[0-9]+%|All files.*\|.*[0-9]+%/i],
65
+ ["test-fail", /\b[1-9][0-9]* (failed|failing)\b|tests? failed|^FAIL(ED)?|✗|✘/im],
66
+ ["error", /\berror:|\bexception\b|\btraceback\b|\bpanicked at\b|\bfatal:|exit code [1-9]/im],
67
+ ["large-diff", /^\+.*[0-9]+ insertions|[0-9]+ files? changed/im],
68
+ ["success", /\b(all )?[0-9]+ tests? (passed|ok)\b|✓|✔|PASS(ED)?|\bDone\b|\bSuccess\b|exit code 0|Build succeeded/im],
69
+ ];
70
+
71
+ function randomInt(max: number, runtime: HookRuntime): number {
72
+ return Math.floor((runtime.random?.() ?? Math.random()) * max);
73
+ }
74
+
75
+ function textValue(value: unknown): string {
76
+ if (typeof value === "string") return value;
77
+ if (value === null || value === undefined) return "";
78
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
79
+ }
80
+
81
+ export function classifyToolResponse(result: string): Classification | undefined {
82
+ for (const [reason, matcher] of MATCHERS) {
83
+ if (!matcher.test(result)) continue;
84
+ if (reason === "merge-conflict") {
85
+ return { files: String((result.match(/Merge conflict in .*/gi) ?? []).length), reason };
86
+ }
87
+ if (reason === "commit") {
88
+ return { files: result.match(/[0-9]+ files? changed/i)?.[0]?.match(/[0-9]+/)?.[0], reason };
89
+ }
90
+ if (reason === "branch") {
91
+ return { branch: result.match(/'([^']+)'/)?.[1], reason };
92
+ }
93
+ if (reason === "large-diff") {
94
+ const lines = result.match(/[0-9]+ insertions/i)?.[0]?.match(/[0-9]+/)?.[0];
95
+ return lines && Number(lines) > 80 ? { lines, reason } : { reason: "" };
96
+ }
97
+ return { reason };
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function pickReaction(species: string, event: string, runtime: HookRuntime): string | undefined {
103
+ return pickRandom(reactionPool(species, event), runtime);
104
+ }
105
+
106
+ function seasonalReaction(species: string, reason: string): string | undefined {
107
+ const reactions: Readonly<Record<string, string>> = {
108
+ "ghost:halloween": "*is the Halloween spirit*",
109
+ "ghost:christmas": "*the ghost of Christmas coding*",
110
+ "cat:halloween": "*knocks pumpkin off desk*",
111
+ "cat:christmas": "*knocks ornaments off the tree*",
112
+ "dragon:halloween": "*breathes pumpkin-spiced fire*",
113
+ "dragon:christmas": "*volunteers as the star on top of the tree*",
114
+ "robot:christmas": "HOLIDAY PROTOCOL: ACTIVATED. FESTIVE SUBROUTINES: RUNNING.",
115
+ "robot:pi-day": "PI: 3.14159265358979323846264338327950288...",
116
+ "goose:halloween": "SPOOKY HONK! HONK!",
117
+ "goose:christmas": "FESTIVE HONK! HONK HONK HO-HO-HONK!",
118
+ "owl:pi-day": "*calculates pi to 100 digits from memory*",
119
+ "duck:christmas": "*wears tiny santa hat* quack! *festive quacking*",
120
+ "duck:halloween": "*dressed as a ghost* quack? ...boo?",
121
+ "ghost:april-fools": "*pretends to be alive for April Fools*",
122
+ "cat:april-fools": "*knocks an april fool off the desk*",
123
+ };
124
+ return reactions[`${species}:${reason}`];
125
+ }
126
+
127
+ function combinationReaction(species: string, combo: string): string | undefined {
128
+ const speciesReactions: Readonly<Record<string, string>> = {
129
+ "cat:friday-push": "*knocks the push off the table* it's FRIDAY.",
130
+ "goose:friday-push": "HONK! NO FRIDAY DEPLOYS! HONK! ABSOLUTELY NOT!",
131
+ "robot:late-night-error": "ERROR AT 0300. HUMAN JUDGMENT: IMPAIRED.",
132
+ "dragon:marathon-error": "even dragons rest. three hours. MULTIPLE ERRORS.",
133
+ "owl:late-night-commit": "*approves of the midnight commit* night is for coding.",
134
+ "ghost:late-night-error": "the bugs are haunted. and so are you, apparently.",
135
+ };
136
+ const generic: Readonly<Record<string, string>> = {
137
+ "late-night-error": "error at 3am. the universe is testing you.",
138
+ "late-night-commit": "a midnight commit. your future self will thank you. or curse you.",
139
+ "friday-push": "FRIDAY PUSH. the ballad of every developer.",
140
+ "marathon-error": "three hours in and ANOTHER error. *exhausted solidarity noises*",
141
+ "weekend-conflict": "merge conflict on a weekend. your dedication is... concerning.",
142
+ "marathon-test-fail": "hours of coding. still failing tests. the sunk cost is real.",
143
+ "build-after-push": "pushed with confidence. build failed with conviction.",
144
+ };
145
+ return speciesReactions[`${species}:${combo}`] ?? generic[combo];
146
+ }
147
+
148
+ function updateEvents(stateDir: string, reason: string): string | undefined {
149
+ const eventMap: Readonly<Record<string, readonly [string, string?]>> = {
150
+ "test-fail": ["tests_failed", "tests_failed"],
151
+ error: ["errors_seen", "errors_spotted"],
152
+ "large-diff": ["large_diffs", "large_diff"],
153
+ commit: ["commits_made"],
154
+ push: ["pushes_made"],
155
+ "merge-conflict": ["conflicts_resolved"],
156
+ branch: ["branches_created"],
157
+ rebase: ["rebases_done"],
158
+ "type-error": ["type_errors"],
159
+ "lint-fail": ["lint_fails"],
160
+ "build-fail": ["build_fails"],
161
+ "security-warning": ["security_warnings"],
162
+ deprecation: ["deprecations_seen"],
163
+ "all-green": ["all_green"],
164
+ deploy: ["deploys"],
165
+ release: ["releases"],
166
+ "late-night": ["late_night_sessions"],
167
+ "early-morning": ["early_sessions"],
168
+ marathon: ["marathon_sessions"],
169
+ weekend: ["weekend_sessions"],
170
+ };
171
+ const event = eventMap[reason];
172
+ if (!event) return undefined;
173
+ const eventsFile = join(stateDir, "events.json");
174
+ const parsedEvents = readJsonFile<Events>(eventsFile);
175
+ if (parsedEvents || !fileExists(eventsFile)) {
176
+ const events = parsedEvents ?? {};
177
+ events[event[0]] = typeof events[event[0]] === "number" ? Number(events[event[0]]) + 1 : 1;
178
+ writeFileSync(eventsFile, JSON.stringify(events, null, 2));
179
+ }
180
+ return event[1];
181
+ }
182
+
183
+ function writeErrorStreak(stateDir: string, sid: string, species: string, reason: string): string | undefined {
184
+ const streakFile = join(stateDir, `.error_streak.${sid}`);
185
+ if (/error|test-fail|build-fail|type-error|lint-fail/i.test(reason)) {
186
+ const previous = Number.parseInt(
187
+ fileExists(streakFile) ? readFileSync(streakFile, "utf8").trim() : "0",
188
+ 10,
189
+ );
190
+ const streak = (Number.isFinite(previous) ? previous : 0) + 1;
191
+ writeFileSync(streakFile, String(streak));
192
+ if (streak === 3) {
193
+ const special: Readonly<Record<string, string>> = {
194
+ cat: "*has already left the room*",
195
+ dragon: "*breathes fire on every error* THEY KEEP COMING.",
196
+ robot: "ERROR RATE: CRITICAL. RECOMMEND: RUBBER DUCK PROTOCOL.",
197
+ goose: "HONK HONK HONK! STOP! HONK!",
198
+ };
199
+ return special[species] ?? "that's three errors in a row. *concerned look*";
200
+ }
201
+ if (streak === 5) {
202
+ const special: Readonly<Record<string, string>> = {
203
+ cat: "*knocks all motivation off desk*",
204
+ dragon: "*enters berserker mode* ERROR STREAK. CHALLENGE ACCEPTED.",
205
+ owl: "*analyzes pattern* these errors are not random. there's a root cause.",
206
+ robot: "ERROR RATE: CRITICAL. RECOMMEND: RUBBER DUCK PROTOCOL.",
207
+ ghost: "*the error streak is haunting* literally.",
208
+ goose: "HONK HONK HONK! STOP! HONK!",
209
+ blob: "*turns progressively redder*",
210
+ axolotl: "*smiles nervously* we can do this!",
211
+ rabbit: "*frantic hopping*",
212
+ };
213
+ return special[species] ?? "FIVE ERRORS. have you considered a different approach?";
214
+ }
215
+ if (streak === 10) {
216
+ const special: Readonly<Record<string, string>> = {
217
+ cat: "*sleeps through the error streak* wake me when it's over.",
218
+ dragon: "*enters berserker mode* ERROR STREAK. CHALLENGE ACCEPTED.",
219
+ owl: "*deep thought* ten errors. the common denominator must be found.",
220
+ robot: "ERROR STREAK: 10. SYSTEM STABILITY: QUESTIONABLE.",
221
+ ghost: "*dies again* the errors killed me. twice.",
222
+ goose: "*HONKING INTENSIFIES TO MAXIMUM*",
223
+ blob: "*has split into multiple worried blobs*",
224
+ capybara: "*vibes through the chaos* it's fine. everything is fine.",
225
+ axolotl: "*regenerates hope* still smiling! *eye twitches*",
226
+ rabbit: "*has burrowed underground*",
227
+ };
228
+ return special[species] ?? "TEN. ERRORS. IN. A. ROW. *panics*";
229
+ }
230
+ if (streak >= 20) {
231
+ const special: Readonly<Record<string, string>> = {
232
+ cat: "*has permanently left*",
233
+ chonk: "*doesn't notice* still sleeping.",
234
+ goose: "HONKING HAS CEASED. GOOSE HAS GIVEN UP.",
235
+ };
236
+ return special[species] ?? "twenty errors. *stares into the void*";
237
+ }
238
+ } else if (reason) {
239
+ rmSync(streakFile, { force: true });
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ export function handleReact(rawInput: string, runtime: HookRuntime = {}): ReactResult {
245
+ const stateDir = resolveHookStateDir(runtime);
246
+ const sid = resolveHookSessionId(runtime);
247
+ const clock = hookClock(runtime);
248
+ const sessionStartFile = join(stateDir, `.session_start.${sid}`);
249
+ if (!fileExists(sessionStartFile) && fileExists(stateDir)) {
250
+ writeFileSync(sessionStartFile, String(clock.nowSeconds));
251
+ }
252
+ const sessionStart = fileExists(sessionStartFile)
253
+ ? Number.parseInt(readFileSync(sessionStartFile, "utf8").trim(), 10)
254
+ : clock.nowSeconds;
255
+ const sessionElapsed = clock.nowSeconds - (Number.isFinite(sessionStart) ? sessionStart : clock.nowSeconds);
256
+
257
+ const statusFile = join(stateDir, "status.json");
258
+ if (!fileExists(statusFile)) return { updated: false };
259
+
260
+ const config = readJsonFile<ReactConfig>(join(stateDir, "config.json")) ?? {};
261
+ const cooldown = nonNegativeInteger(config.commentCooldown, 30);
262
+ const now = runtime.now?.() ?? Date.now();
263
+ if (isOnCooldown(join(stateDir, `.last_reaction.${sid}`), cooldown, now)) {
264
+ return { updated: false };
265
+ }
266
+
267
+ const input = parseHookInput(rawInput);
268
+ if (!input) return { updated: false };
269
+ const result = textValue(input.tool_response);
270
+ if (!result) return { updated: false };
271
+
272
+ const status = readJsonFile<BuddyStatus>(statusFile) ?? {};
273
+ if (status.muted === true || status.muted === "true") return { updated: false };
274
+ const species = typeof status.species === "string" ? status.species : "blob";
275
+
276
+ const classification = classifyToolResponse(result);
277
+ let reason = classification?.reason ?? "";
278
+ let reaction = reason ? pickReaction(species, reason, runtime) : undefined;
279
+ let files = classification?.files;
280
+ const branch = classification?.branch;
281
+
282
+ if (!reason) {
283
+ const holiday = `${String(clock.month).padStart(2, "0")}${String(clock.day).padStart(2, "0")}`;
284
+ const holidayChecks: Readonly<Record<string, readonly [string, number, string]>> = {
285
+ "0101": ["new-year", 5, "happy new year! new year, new bugs."],
286
+ "0214": ["valentines", 5, "*offers a tiny heart-shaped leaf* happy valentine's."],
287
+ "0314": ["pi-day", 5, "3.14159265358979... happy pi day!"],
288
+ "0401": ["april-fools", 5, "APRIL FOOLS! ...the error is real though."],
289
+ "1031": ["halloween", 5, "*spooky debugging intensifies* happy halloween!"],
290
+ "1225": ["christmas", 5, "*wears tiny santa hat* happy holidays!"],
291
+ "1231": ["new-years-eve", 5, "one more commit before midnight?"],
292
+ };
293
+ const holidayCheck = holidayChecks[holiday];
294
+ if (holidayCheck && randomInt(holidayCheck[1], runtime) === 0) {
295
+ [reason, , reaction] = holidayCheck;
296
+ }
297
+ if (!reason && clock.month === 10 && randomInt(20, runtime) === 0) {
298
+ reason = "spooky-season";
299
+ reaction = "spooky season. every bug is a ghost now.";
300
+ }
301
+ }
302
+
303
+ if (reason && /halloween|christmas|april-fools|new-year|valentines|pi-day/i.test(reason)) {
304
+ reaction = seasonalReaction(species, reason) ?? reaction;
305
+ }
306
+
307
+ if (!reason) {
308
+ const random = randomInt(10, runtime);
309
+ if (clock.hour >= 0 && clock.hour < 5 && random === 0) {
310
+ reason = "late-night";
311
+ reaction = pickReaction(species, reason, runtime);
312
+ } else if (clock.hour >= 5 && clock.hour < 8 && random === 0) {
313
+ reason = "early-morning";
314
+ reaction = pickReaction(species, reason, runtime);
315
+ } else if (clock.dayOfWeek === 5 && random === 0) {
316
+ reason = "friday";
317
+ reaction = pickReaction(species, reason, runtime);
318
+ } else if (clock.dayOfWeek >= 6 && random === 0) {
319
+ reason = "weekend";
320
+ reaction = pickReaction(species, reason, runtime);
321
+ } else if (clock.dayOfWeek === 1 && random === 0) {
322
+ reason = "monday";
323
+ reaction = pickReaction(species, reason, runtime);
324
+ }
325
+ if (!reason && sessionElapsed > 10800 && randomInt(5, runtime) === 0) {
326
+ reason = "marathon";
327
+ reaction = pickReaction(species, reason, runtime);
328
+ } else if (!reason && sessionElapsed > 3600 && randomInt(10, runtime) === 0) {
329
+ reason = "long-session";
330
+ reaction = pickReaction(species, reason, runtime);
331
+ }
332
+ }
333
+
334
+ if (reason) {
335
+ let combo = "";
336
+ if (reason === "error" && clock.hour < 5 && randomInt(3, runtime) === 0) combo = "late-night-error";
337
+ else if (reason === "commit" && clock.hour < 5 && randomInt(3, runtime) === 0) combo = "late-night-commit";
338
+ else if (reason === "push" && clock.dayOfWeek === 5 && randomInt(2, runtime) === 0) combo = "friday-push";
339
+ else if (reason === "error" && sessionElapsed > 10800 && randomInt(3, runtime) === 0) combo = "marathon-error";
340
+ else if (reason === "merge-conflict" && clock.dayOfWeek >= 6 && randomInt(2, runtime) === 0) combo = "weekend-conflict";
341
+ else if (reason === "test-fail" && sessionElapsed > 7200 && randomInt(4, runtime) === 0) combo = "marathon-test-fail";
342
+ else if (reason === "build-fail" && randomInt(4, runtime) === 0) combo = "build-after-push";
343
+ if (combo) reaction = combinationReaction(species, combo) ?? pickReaction(species, combo, runtime) ?? reaction;
344
+ }
345
+
346
+ const streakReaction = reason ? writeErrorStreak(stateDir, sid, species, reason) : undefined;
347
+ if (streakReaction) reaction = streakReaction;
348
+
349
+ const lastBadFile = join(stateDir, `.last_bad.${sid}`);
350
+ if (/error|test-fail|build-fail|merge-conflict/i.test(reason)) {
351
+ writeFileSync(lastBadFile, `${reason}:${clock.nowSeconds}`);
352
+ } else if (/all-green|success|deploy|release/i.test(reason) && fileExists(lastBadFile)) {
353
+ const lastBad = readFileSync(lastBadFile, "utf8").trim().split(":");
354
+ const elapsedRecovery = clock.nowSeconds - Number.parseInt(lastBad[1] ?? "0", 10);
355
+ if (elapsedRecovery < 600) {
356
+ const recovery = `recovery-from-${lastBad[0]}`;
357
+ const recoveryReactions: Readonly<Record<string, string>> = {
358
+ cat: "*yawns* I knew you'd fix it. eventually.",
359
+ dragon: "VICTORY. as it should be.",
360
+ owl: "*satisfied hoot* knowledge gained through struggle.",
361
+ robot: "ISSUE: RESOLVED. STATUS: OPERATIONAL.",
362
+ ghost: "*the spirits of broken code find peace*",
363
+ goose: "HONK OF VICTORY! HONK HONK HOOOONK!",
364
+ duck: "*VICTORY QUACKING INTENSIFIES*",
365
+ blob: "*jiggles with pure joy*",
366
+ capybara: "*chill nod* nice recovery, friend.",
367
+ axolotl: "*happy gill flutter* you did it!",
368
+ rabbit: "*celebratory binky*!!!",
369
+ };
370
+ const generic: Readonly<Record<string, string>> = {
371
+ "recovery-from-error": "WE FIXED IT. *celebrates*",
372
+ "recovery-from-test-fail": "GREEN! after all that! *happy dance*",
373
+ "recovery-from-build-fail": "THE BUILD PASSES. *triumphant roar*",
374
+ "recovery-from-merge-conflict": "conflict resolved! *peace gesture*",
375
+ };
376
+ reaction = recoveryReactions[species] ?? generic[recovery] ?? reaction;
377
+ }
378
+ rmSync(lastBadFile, { force: true });
379
+ }
380
+
381
+ if (files) reaction = reaction?.replace("{files}", files);
382
+ if (branch) reaction = reaction?.replace("{branch}", branch);
383
+ if (!reason || !reaction) return { updated: false };
384
+
385
+ mkdirSync(stateDir, { recursive: true });
386
+ writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(clock.nowSeconds));
387
+ writeFileSync(
388
+ join(stateDir, `reaction.${sid}.json`),
389
+ JSON.stringify({ reaction, timestamp: clock.nowSeconds * 1000, reason }),
390
+ );
391
+
392
+ const xpEvent = updateEvents(stateDir, reason);
393
+ const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
394
+ if (xpEvent) spawnDetached("server/award-xp.ts", [xpEvent]);
395
+ const moodTrigger: Readonly<Record<string, string>> = {
396
+ "test-fail": "tests_fail",
397
+ error: "error",
398
+ "large-diff": "large_diff",
399
+ success: "tests_pass",
400
+ };
401
+ if (moodTrigger[reason]) spawnDetached("server/shift-mood.ts", [moodTrigger[reason]]);
402
+
403
+ return { reaction, reason, updated: true };
404
+ }
405
+
406
+ if (import.meta.main) {
407
+ try {
408
+ handleReact(await readStdin());
409
+ } catch {
410
+ // Claude Code hooks must never fail the turn.
411
+ }
412
+ process.exit(0);
413
+ }