@ramarivera/coding-buddy 0.7.3 → 0.9.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 (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -4
  4. package/adapters/omp/context.ts +1 -1
  5. package/adapters/omp/events.ts +286 -81
  6. package/adapters/omp/index.ts +3 -1
  7. package/adapters/omp/renderers.ts +4 -54
  8. package/adapters/omp/storage.ts +6 -1
  9. package/adapters/omp/ui.ts +30 -1
  10. package/adapters/pi/events.ts +280 -83
  11. package/adapters/pi/index.ts +11 -2
  12. package/adapters/pi/renderers.ts +3 -47
  13. package/adapters/pi/storage.ts +6 -1
  14. package/adapters/pi/ui.ts +30 -1
  15. package/adapters/shared/file-storage.ts +21 -0
  16. package/adapters/shared/widget-layout.ts +134 -2
  17. package/cli/settings.ts +1 -1
  18. package/cli/tui.tsx +1 -1
  19. package/core/command-service.ts +13 -0
  20. package/core/engine.ts +11 -2
  21. package/core/identity.ts +15 -0
  22. package/core/model.ts +2 -0
  23. package/core/ports.ts +4 -0
  24. package/core/reactions.ts +29 -2
  25. package/core/render-model.ts +4 -3
  26. package/hooks/file-type-react.sh +1 -1
  27. package/hooks/mood-react.sh +1 -1
  28. package/hooks/name-react.sh +1 -1
  29. package/hooks/react.sh +1 -1
  30. package/package.json +6 -2
  31. package/scripts/ci/Dockerfile.vhs +125 -0
  32. package/scripts/ci/check-statusline.sh +338 -0
  33. package/scripts/ci/check-vhs-goldens.sh +97 -0
  34. package/scripts/ci/fonts.conf +37 -0
  35. package/scripts/ci/goldens/baseline-40.png +0 -0
  36. package/scripts/ci/goldens/baseline-80.png +0 -0
  37. package/scripts/ci/goldens/cjk-80.png +0 -0
  38. package/scripts/ci/goldens/emoji-80.png +0 -0
  39. package/scripts/ci/render-statusline-pty.py +99 -0
  40. package/scripts/ci/render-vhs-goldens.sh +235 -0
  41. package/scripts/ci/statusline-fixture/buddy-state/config.json +1 -0
  42. package/scripts/ci/statusline-fixture/buddy-state/status.json +39 -0
  43. package/scripts/ci/statusline.tape +9 -0
  44. package/scripts/ci/vhs-run-statusline.sh +16 -0
  45. package/scripts/gen-emoji-widths.ts +41 -17
  46. package/scripts/paths.sh +3 -1
  47. package/server/art.ts +45 -13
  48. package/server/hooks/buddy-comment.ts +166 -17
  49. package/server/hooks/common.ts +33 -0
  50. package/server/hooks/file-type-react.ts +147 -0
  51. package/server/hooks/mood-react.ts +107 -0
  52. package/server/hooks/name-react.ts +98 -0
  53. package/server/hooks/react.ts +413 -0
  54. package/server/hooks/reaction-data.ts +2722 -0
  55. package/server/index.ts +67 -27
  56. package/server/path.ts +3 -0
  57. package/server/state.ts +68 -17
  58. package/skills/buddy/SKILL.md +1 -1
  59. package/statusline/buddy-status.sh +351 -59
  60. package/statusline/emoji-text.data +3 -0
  61. package/statusline/emoji-widths.data +2 -3
  62. package/statusline/substatus.sh +73 -10
  63. package/hooks/legacy/file-type-react.sh +0 -403
  64. package/hooks/legacy/mood-react.sh +0 -210
  65. package/hooks/legacy/name-react.sh +0 -178
  66. package/hooks/legacy/react.sh +0 -1364
  67. package/server/hooks/legacy-bash.ts +0 -34
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { mkdirSync, writeFileSync } from "fs";
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
5
+ import { reactionPool } from "./reaction-data.ts";
5
6
  import {
6
7
  defaultSpawnDetached,
7
8
  fileExists,
8
9
  isOnCooldown,
9
10
  nonNegativeInteger,
10
11
  parseHookInput,
12
+ pickRandom,
11
13
  readJsonFile,
12
14
  readStdin,
13
15
  resolveHookSessionId,
@@ -25,10 +27,29 @@ interface Events {
25
27
  [key: string]: unknown;
26
28
  }
27
29
 
30
+ interface BuddyStatus {
31
+ species?: unknown;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ interface ReactionFile {
36
+ source?: string;
37
+ timestamp?: number;
38
+ [key: string]: unknown;
39
+ }
40
+
28
41
  const BUDDY_COMMENT_PATTERN = /<!--\s*buddy:\s*([\s\S]*?)\s*-->/g;
29
42
 
43
+ /**
44
+ * Provenance of the reaction the hook wrote. Mirrors server/state.ts
45
+ * `ReactionSource`; loaded as `none` on legacy files without the field.
46
+ */
47
+ export type ReactionSource = "comment" | "fallback" | "none";
48
+
30
49
  export interface BuddyCommentResult {
31
50
  comment?: string;
51
+ /** What produced the bubble. `"none"` means the hook ran but wrote nothing. */
52
+ source: ReactionSource;
32
53
  updated: boolean;
33
54
  }
34
55
 
@@ -41,37 +62,164 @@ export function extractBuddyComment(message: string): string {
41
62
  return comment;
42
63
  }
43
64
 
44
- export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}): BuddyCommentResult {
65
+ /**
66
+ * Tolerate tool-stamped timestamps that are slightly in the future (NTP
67
+ * step, container/host skew, network-mounted state dir). Larger drifts
68
+ * are treated as clock-skewed garbage and the hook falls through to
69
+ * the comment / pool branch.
70
+ */
71
+ const FUTURE_TIMESTAMP_TOLERANCE_MS = 60_000;
72
+
73
+ function pickTurnFallback(
74
+ species: string,
75
+ runtime: HookRuntime,
76
+ ): string | undefined {
77
+ // The hook context has no stat-modifier inputs; pick from the canned
78
+ // pool directly rather than calling server/reactions.ts `getReaction`,
79
+ // which is reserved for the MCP server's tool-call path. This mirrors
80
+ // the file-type-react / mood-react / react hook convention.
81
+ const pool = reactionPool(species, "turn");
82
+ if (pool.length === 0) return undefined;
83
+ return pickRandom(pool, runtime);
84
+ }
85
+
86
+ function readSpeciesFromStatus(stateDir: string): string {
87
+ const status = readJsonFile<BuddyStatus>(join(stateDir, "status.json"));
88
+ return typeof status?.species === "string" && status.species.length > 0
89
+ ? status.species
90
+ : "blob";
91
+ }
92
+
93
+ /** Atomic write — tmp + rename. */
94
+ function atomicWriteJson(path: string, value: unknown): void {
95
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
96
+ writeFileSync(tmp, JSON.stringify(value, null, 2));
97
+ renameSync(tmp, path);
98
+ }
99
+
100
+ function atomicWriteTimestamp(path: string, nowMs: number): void {
101
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
102
+ writeFileSync(tmp, String(Math.floor(nowMs / 1000)));
103
+ renameSync(tmp, path);
104
+ }
105
+
106
+ function readTimestampSeconds(path: string): number {
107
+ try {
108
+ const v = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
109
+ return Number.isFinite(v) ? v : 0;
110
+ } catch {
111
+ return 0;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Did `buddy_react` fire during the current turn?
117
+ *
118
+ * The Stop hook is the only place that knows turn boundaries. After each
119
+ * Stop hook run, we stamp the wall-clock time into
120
+ * `.last_stop_hook.<sid>` (seconds). A tool-author ed reaction whose
121
+ * timestamp is more recent than the LAST Stop hook run is by definition
122
+ * from this turn; leave it alone.
123
+ *
124
+ * Wall-clock 60 s freshness windows lose tool reactions on agentic
125
+ * turns of 90 s–5 min, which are ordinary. This sentinel scales with
126
+ * turn length, not elapsed time.
127
+ *
128
+ * Clock-skew guard: a tool timestamp implausibly in the future (beyond
129
+ * `FUTURE_TIMESTAMP_TOLERANCE_MS`) is treated as garbage and we fall
130
+ * through to the comment / pool branch. A future-dated `lastStopRun`
131
+ * is clamped to `now` so a skewed clock on a prior run does not poison
132
+ * the comparison.
133
+ */
134
+ function toolFiredThisTurn(
135
+ reactionPath: string,
136
+ stopMarkerPath: string,
137
+ nowMs: number,
138
+ ): boolean {
139
+ const existing = readJsonFile<ReactionFile>(reactionPath);
140
+ if (!existing || existing.source !== "tool") return false;
141
+ const ts = typeof existing.timestamp === "number" ? existing.timestamp : 0;
142
+ if (ts <= 0) return false;
143
+ if (ts > nowMs + FUTURE_TIMESTAMP_TOLERANCE_MS) return false;
144
+ const lastStopSec = readTimestampSeconds(stopMarkerPath);
145
+ const effectiveLastStopMs = Math.min(lastStopSec * 1000, nowMs);
146
+ return ts > effectiveLastStopMs;
147
+ }
148
+
149
+ export function handleBuddyComment(
150
+ rawInput: string,
151
+ runtime: HookRuntime = {},
152
+ ): BuddyCommentResult {
45
153
  const stateDir = resolveHookStateDir(runtime);
46
- if (!fileExists(join(stateDir, "status.json"))) return { updated: false };
154
+ if (!fileExists(join(stateDir, "status.json"))) {
155
+ return { source: "none", updated: false };
156
+ }
47
157
 
48
158
  const input = parseHookInput(rawInput);
49
- if (!input) return { updated: false };
159
+ if (!input) return { source: "none", updated: false };
50
160
 
51
161
  const assistantMessage = stringField(input, "last_assistant_message");
52
- if (!assistantMessage) return { updated: false };
53
-
54
- const comment = extractBuddyComment(assistantMessage);
55
- if (!comment) return { updated: false };
162
+ if (!assistantMessage) return { source: "none", updated: false };
56
163
 
57
164
  const now = runtime.now?.() ?? Date.now();
58
165
  const sid = resolveHookSessionId(runtime);
166
+ const reactionPath = join(stateDir, `reaction.${sid}.json`);
167
+ const cooldownFile = join(stateDir, `.last_comment.${sid}`);
168
+ const stopMarkerFile = join(stateDir, `.last_stop_hook.${sid}`);
59
169
  const config = readJsonFile<BuddyConfig>(join(stateDir, "config.json")) ?? {};
60
170
  const cooldown = nonNegativeInteger(config.commentCooldown, 30);
61
- const cooldownFile = join(stateDir, `.last_comment.${sid}`);
62
- if (isOnCooldown(cooldownFile, cooldown, now)) return { updated: false };
171
+
172
+ // ─── Don't clobber a buddy_react tool reaction from this turn ───────────
173
+ if (toolFiredThisTurn(reactionPath, stopMarkerFile, now)) {
174
+ atomicWriteTimestamp(stopMarkerFile, now);
175
+ return { source: "none", updated: false };
176
+ }
177
+
178
+ // ─── Cooldown: rate-limit the reaction write AND bookkeeping ──────────
179
+ // Main ran the turn counter / XP / memory hooks only inside the
180
+ // "reaction written" branch. Running them unconditionally makes the
181
+ // XP economy and spawn counts scale with Stop event frequency, which
182
+ // is wrong.
183
+ if (isOnCooldown(cooldownFile, cooldown, now)) {
184
+ atomicWriteTimestamp(stopMarkerFile, now);
185
+ return { source: "none", updated: false };
186
+ }
187
+
188
+ // ─── Pick a reaction: legacy comment → canned pool → nothing ────────────
189
+ const commentFromMessage = extractBuddyComment(assistantMessage);
190
+ let comment: string | undefined;
191
+ let source: ReactionSource;
192
+
193
+ if (commentFromMessage) {
194
+ comment = commentFromMessage;
195
+ source = "comment";
196
+ } else {
197
+ const species = readSpeciesFromStatus(stateDir);
198
+ const fallback = pickTurnFallback(species, runtime);
199
+ if (fallback) {
200
+ comment = fallback;
201
+ source = "fallback";
202
+ } else {
203
+ atomicWriteTimestamp(stopMarkerFile, now);
204
+ return { source: "none", updated: false };
205
+ }
206
+ }
63
207
 
64
208
  mkdirSync(stateDir, { recursive: true });
65
- writeFileSync(cooldownFile, String(Math.floor(now / 1000)));
66
- writeFileSync(
67
- join(stateDir, `reaction.${sid}.json`),
68
- JSON.stringify({ reaction: comment, timestamp: now, reason: "turn" }),
69
- );
70
209
 
210
+ // Bookkeeping fires only when a reaction is actually written (main).
71
211
  const eventsFile = join(stateDir, "events.json");
72
212
  const events = readJsonFile<Events>(eventsFile) ?? {};
73
213
  events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1;
74
- writeFileSync(eventsFile, JSON.stringify(events, null, 2));
214
+ atomicWriteJson(eventsFile, events);
215
+
216
+ atomicWriteTimestamp(cooldownFile, now);
217
+ atomicWriteJson(reactionPath, {
218
+ reaction: comment,
219
+ timestamp: now,
220
+ reason: "turn",
221
+ source,
222
+ });
75
223
 
76
224
  const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
77
225
  spawnDetached("server/award-xp.ts", ["turn"]);
@@ -79,8 +227,9 @@ export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}):
79
227
  assistantMessage,
80
228
  stringField(input, "last_user_message"),
81
229
  ]);
230
+ atomicWriteTimestamp(stopMarkerFile, now);
82
231
 
83
- return { comment, updated: true };
232
+ return { comment, source, updated: true };
84
233
  }
85
234
 
86
235
  if (import.meta.main) {
@@ -6,13 +6,23 @@ import { buddyStateDir } from "../path.ts";
6
6
  export type JsonObject = Record<string, unknown>;
7
7
 
8
8
  export interface HookRuntime {
9
+ clock?: () => HookClock;
9
10
  now?: () => number;
10
11
  packageRoot?: string;
12
+ random?: () => number;
11
13
  sessionId?: string;
12
14
  spawnDetached?: (script: string, args: string[]) => void;
13
15
  stateDir?: string;
14
16
  }
15
17
 
18
+ export interface HookClock {
19
+ day: number;
20
+ dayOfWeek: number;
21
+ hour: number;
22
+ month: number;
23
+ nowSeconds: number;
24
+ }
25
+
16
26
  export function parseHookInput(raw: string): JsonObject | null {
17
27
  try {
18
28
  const value = JSON.parse(raw) as unknown;
@@ -56,6 +66,29 @@ export function nonNegativeInteger(value: unknown, fallback: number): number {
56
66
  return fallback;
57
67
  }
58
68
 
69
+ export function randomIndex(length: number, runtime: HookRuntime = {}): number {
70
+ return Math.floor((runtime.random?.() ?? Math.random()) * length);
71
+ }
72
+
73
+ export function pickRandom<T>(values: readonly T[], runtime: HookRuntime = {}): T | undefined {
74
+ if (values.length === 0) return undefined;
75
+ return values[randomIndex(values.length, runtime)];
76
+ }
77
+
78
+ export function hookClock(runtime: HookRuntime = {}): HookClock {
79
+ if (runtime.clock) return runtime.clock();
80
+ const nowMs = runtime.now?.() ?? Date.now();
81
+ const date = new Date(nowMs);
82
+ const jsDay = date.getDay();
83
+ return {
84
+ day: date.getDate(),
85
+ dayOfWeek: jsDay === 0 ? 7 : jsDay,
86
+ hour: date.getHours(),
87
+ month: date.getMonth() + 1,
88
+ nowSeconds: Math.floor(nowMs / 1000),
89
+ };
90
+ }
91
+
59
92
  export function isOnCooldown(path: string, cooldownSeconds: number, nowMs: number): boolean {
60
93
  try {
61
94
  const last = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { fileReactionPool } 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
+ type HookRuntime,
17
+ } from "./common.ts";
18
+
19
+ interface BuddyStatus {
20
+ muted?: unknown;
21
+ species?: unknown;
22
+ reaction?: unknown;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ interface FileConfig {
27
+ commentCooldown?: unknown;
28
+ }
29
+
30
+ export interface FileTypeReactResult {
31
+ fileType?: string;
32
+ reaction?: string;
33
+ updated: boolean;
34
+ }
35
+
36
+ const FILE_TYPE_PATTERNS: ReadonlyArray<readonly [string, string]> = [
37
+ ["readme", "*/README*|*/CHANGELOG*|*/CONTRIBUTING*|*/LICENSE*"],
38
+ ["package-file", "*/package.json|*/Cargo.toml|*/go.mod|*/go.sum|*/pyproject.toml|*/requirements*.txt"],
39
+ ["ci-file", "*/.github/workflows/*|*/Jenkinsfile*|*/.gitlab-ci*|*.ci.yml|*.ci.yaml"],
40
+ ["gitignore", "*/.gitignore|*/.dockerignore|*/.eslintignore|*/.prettierignore|.gitignore|.dockerignore|.eslintignore|.prettierignore"],
41
+ ["env-file", "*/.env*|.env*"],
42
+ ["makefile", "*/Makefile*|Makefile*|*.mk"],
43
+ ["docker-file", "*/Dockerfile*|Dockerfile*|*/docker-compose*|docker-compose*|*.dockerfile"],
44
+ ["regex-file", "*.regex|*.pattern"],
45
+ ["css-file", "*.css|*.scss|*.less|*.sass"],
46
+ ["sql-file", "*.sql|*migration*"],
47
+ ["lock-file", "*.lock|*/package-lock.json|*/bun.lockb|*/yarn.lock|*/pnpm-lock.yaml"],
48
+ ["test-file", "*.test.*|*.spec.*|*_test.*|*test_*"],
49
+ ["doc-file", "*.md|*.rst|*.txt|*.adoc"],
50
+ ["config-file", "*.json|*.yaml|*.yml|*.toml|*.ini|*.conf"],
51
+ ["binary-file", "*.png|*.jpg|*.gif|*.woff*|*.ico|*.exe|*.so|*.dylib|*.bin"],
52
+ ["proto-file", "*.proto|*.graphql|*.gql"],
53
+ ["lang-python", "*.py"],
54
+ ["lang-typescript", "*.ts|*.tsx"],
55
+ ["lang-javascript", "*.js|*.jsx"],
56
+ ["lang-rust", "*.rs"],
57
+ ["lang-go", "*.go"],
58
+ ["lang-java", "*.java"],
59
+ ["lang-ruby", "*.rb"],
60
+ ["lang-php", "*.php"],
61
+ ["lang-c", "*.c|*.h"],
62
+ ["lang-cpp", "*.cpp|*.hpp"],
63
+ ["lang-swift", "*.swift"],
64
+ ["lang-kotlin", "*.kt"],
65
+ ["lang-haskell", "*.hs"],
66
+ ["lang-elixir", "*.ex|*.exs"],
67
+ ["lang-zig", "*.zig"],
68
+ ];
69
+
70
+ function shellGlob(pattern: string): RegExp {
71
+ let source = "^";
72
+ for (const character of pattern) {
73
+ if (character === "*") source += ".*";
74
+ else if (character === "?") source += ".";
75
+ else source += character.replace(/[\\^$.*+()[\]{}|]/g, "\\$&");
76
+ }
77
+ return new RegExp(`${source}$`);
78
+ }
79
+
80
+ function matchesAnyPattern(path: string, patterns: string): boolean {
81
+ return patterns.split("|").some((pattern) => shellGlob(pattern).test(path));
82
+ }
83
+
84
+ export function fileTypeForPath(filePath: string): string {
85
+ for (const [fileType, patterns] of FILE_TYPE_PATTERNS) {
86
+ if (matchesAnyPattern(filePath, patterns)) return fileType;
87
+ }
88
+ return "";
89
+ }
90
+
91
+ function textValue(value: unknown): string {
92
+ if (typeof value === "string") return value;
93
+ if (value === null || value === undefined) return "";
94
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
95
+ }
96
+
97
+ export function handleFileTypeReact(rawInput: string, runtime: HookRuntime = {}): FileTypeReactResult {
98
+ const stateDir = resolveHookStateDir(runtime);
99
+ const statusFile = join(stateDir, "status.json");
100
+ if (!fileExists(statusFile)) return { updated: false };
101
+
102
+ const config = readJsonFile<FileConfig>(join(stateDir, "config.json")) ?? {};
103
+ const cooldown = nonNegativeInteger(config.commentCooldown, 30);
104
+ const sid = resolveHookSessionId(runtime);
105
+ const now = runtime.now?.() ?? Date.now();
106
+ if (isOnCooldown(join(stateDir, `.last_reaction.${sid}`), cooldown, now)) {
107
+ return { updated: false };
108
+ }
109
+
110
+ const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
111
+ const status = parsedStatus ?? {};
112
+ if (status.muted === true || status.muted === "true") return { updated: false };
113
+
114
+ const input = parseHookInput(rawInput);
115
+ if (!input) return { updated: false };
116
+ const filePath = textValue(input.file_path);
117
+ if (!filePath) return { updated: false };
118
+
119
+ if ((runtime.random?.() ?? Math.random()) * 100 >= 15) return { updated: false };
120
+
121
+ let fileType = fileTypeForPath(filePath);
122
+ if (!fileType) return { updated: false };
123
+ if (fileType === "lang-javascript") fileType = "lang-typescript";
124
+
125
+ const species = textValue(status.species) || "blob";
126
+ const reaction = pickRandom(fileReactionPool(species, fileType), runtime);
127
+ if (!reaction) return { updated: false };
128
+
129
+ mkdirSync(stateDir, { recursive: true });
130
+ writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(Math.floor(now / 1000)));
131
+ writeFileSync(
132
+ join(stateDir, `reaction.${sid}.json`),
133
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType, source: "fallback" }),
134
+ );
135
+ if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
136
+
137
+ return { fileType, reaction, updated: true };
138
+ }
139
+
140
+ if (import.meta.main) {
141
+ try {
142
+ handleFileTypeReact(await readStdin());
143
+ } catch {
144
+ // Claude Code hooks must never fail the turn.
145
+ }
146
+ process.exit(0);
147
+ }
@@ -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, source: "fallback" }),
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", source: "fallback" }),
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
+ }