diff --git a/README.md b/README.md
index f7ec60b..617f68f 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@
š¬
Speech Bubbles
-Your buddy comments on your code in real time. Invisible, contextual, alive.
+End-of-turn reactions come from the model by default (MCP tool call). When that doesn't fire ā older Claude Code, no MCP, a turn where the model didn't react ā a Stop hook falls back to a tagged canned pool line so the bubble is never silent.
|
ā”
@@ -233,11 +233,11 @@ Five integration points, zero binary dependencies. When Claude Code, Pi, or Oh M
āāāāāāāāāāāāāāāāāāāāāāā
```
-- **MCP Server** ā companion tools + system prompt that instructs Claude to write buddy comments
+- **MCP Server** ā companion tools + system prompt that tells Claude to call `buddy_react` at the end of every turn. The tool call writes the reaction with `source: "tool"` ā model-authored, renders nowhere in the transcript.
- **Skill** ā routes `/buddy`, `/buddy pet`, `/buddy stats`, `/buddy off`, `/buddy rename`
- **Status Line** ā animated ASCII art, right-aligned, with rarity color and speech bubble
- **PostToolUse Hook** ā detects errors, test failures, large diffs in Bash output
-- **Stop Hook** ā extracts invisible `` comments from Claude's responses
+- **Stop Hook** ā backward-compat fallback chain. Skips if a fresh `buddy_react` (primary channel) just ran; otherwise extracts a legacy `` comment if Claude happens to emit one (older Claude Code builds), and otherwise picks a canned pool line via `server/turn-reaction.ts`. Every write carries a `source` tag (`tool`/`comment`/`fallback`) so `/buddy stats` can prove what produced the bubble.
### Why MCP Instead of Binary Patching?
diff --git a/server/hooks/buddy-comment.test.ts b/server/hooks/buddy-comment.test.ts
index e72fd84..2f5532e 100644
--- a/server/hooks/buddy-comment.test.ts
+++ b/server/hooks/buddy-comment.test.ts
@@ -35,11 +35,12 @@ describe("buddy comment Stop hook", () => {
},
);
- expect(result).toEqual({ comment: "ship it", updated: true });
+ expect(result).toEqual({ comment: "ship it", source: "comment", updated: true });
expect(JSON.parse(readFileSync(join(stateDir, "reaction.session1.json"), "utf8"))).toEqual({
reaction: "ship it",
timestamp: 1_700_000_000_123,
reason: "turn",
+ source: "comment",
});
expect(JSON.parse(readFileSync(join(stateDir, "events.json"), "utf8"))).toMatchObject({
turns: 3,
@@ -63,7 +64,7 @@ describe("buddy comment Stop hook", () => {
stateDir,
});
- expect(result).toEqual({ updated: false });
+ expect(result).toEqual({ source: "none", updated: false });
expect(existsSync(join(stateDir, "reaction.default.json"))).toBe(false);
});
@@ -75,7 +76,179 @@ describe("buddy comment Stop hook", () => {
{ stateDir },
);
- expect(result).toEqual({ updated: false });
+ expect(result).toEqual({ source: "none", updated: false });
expect(existsSync(stateDir)).toBe(false);
});
+
+ test("F1: a fresh buddy_react tool reaction is not clobbered", () => {
+ const stateDir = makeStateDir();
+ dirs.push(stateDir);
+ writeFileSync(join(stateDir, "status.json"), "{}");
+ writeFileSync(join(stateDir, "events.json"), "{}");
+ const toolTs = 1_700_000_000_000;
+ writeFileSync(
+ join(stateDir, "reaction.session1.json"),
+ JSON.stringify({
+ reaction: "*tool wrote this*",
+ timestamp: toolTs,
+ reason: "turn",
+ source: "tool",
+ }),
+ );
+ const originalFile = readFileSync(join(stateDir, "reaction.session1.json"), "utf8");
+
+ const spawned: Array<{ script: string; args: string[] }> = [];
+ const result = handleBuddyComment(
+ JSON.stringify({
+ last_assistant_message: "no comment here, just an empty reply",
+ last_user_message: "go",
+ }),
+ {
+ now: () => toolTs + 5_000,
+ sessionId: "session1",
+ spawnDetached: (script, args) => spawned.push({ script, args }),
+ stateDir,
+ },
+ );
+
+ // F5: bookkeeping does NOT run when the tool already wrote a reaction
+ // this turn. Main never credited a turn without a comment, and we
+ // do not silently change that economy.
+ expect(result.updated).toBe(false);
+ expect(spawned).toEqual([]);
+ // File is byte-for-byte unchanged.
+ expect(readFileSync(join(stateDir, "reaction.session1.json"), "utf8")).toBe(originalFile);
+ });
+
+ test("falls back to a canned pool line when no comment is emitted", () => {
+ const stateDir = makeStateDir();
+ dirs.push(stateDir);
+ writeFileSync(join(stateDir, "status.json"), JSON.stringify({ species: "blob" }));
+ writeFileSync(join(stateDir, "events.json"), "{}");
+
+ const result = handleBuddyComment(
+ JSON.stringify({
+ last_assistant_message: "a perfectly ordinary reply with no comment",
+ last_user_message: "go",
+ }),
+ {
+ now: () => 1_700_000_000_000,
+ random: () => 0,
+ sessionId: "session1",
+ spawnDetached: () => {},
+ stateDir,
+ },
+ );
+
+ expect(result.source).toBe("fallback");
+ expect(result.updated).toBe(true);
+ expect(result.comment).toBeString();
+ const onDisk = JSON.parse(readFileSync(join(stateDir, "reaction.session1.json"), "utf8"));
+ expect(onDisk.source).toBe("fallback");
+ expect(onDisk.reaction).toBe(result.comment);
+ expect(onDisk.reason).toBe("turn");
+ });
+
+ // F1: a tool reaction from a LONG turn (>5min) survives the stop hook.
+ // Old wall-clock freshness design clobbered this; the per-turn sentinel
+ // design does not.
+ test("F1 long-turn: tool reaction from t=0 still on disk at t=5min", () => {
+ const stateDir = makeStateDir();
+ dirs.push(stateDir);
+ writeFileSync(join(stateDir, "status.json"), JSON.stringify({ species: "blob" }));
+ const toolTs = 1_700_000_000_000;
+ writeFileSync(
+ join(stateDir, "reaction.session1.json"),
+ JSON.stringify({
+ reaction: "*tool wrote this on a long turn*",
+ timestamp: toolTs,
+ reason: "turn",
+ source: "tool",
+ }),
+ );
+ const original = readFileSync(join(stateDir, "reaction.session1.json"), "utf8");
+
+ const result = handleBuddyComment(
+ JSON.stringify({
+ last_assistant_message: "no comment here",
+ last_user_message: "go",
+ }),
+ {
+ now: () => toolTs + 5 * 60_000,
+ sessionId: "session1",
+ spawnDetached: () => {},
+ stateDir,
+ },
+ );
+
+ expect(result.updated).toBe(false);
+ expect(readFileSync(join(stateDir, "reaction.session1.json"), "utf8")).toBe(original);
+ });
+
+ // F2: a future-dated tool timestamp is treated as untrusted (clock-skewed
+ // garbage) and the pool writes over it.
+ test("F2 future-clock: 5-min future tool timestamp is clobbered", () => {
+ const stateDir = makeStateDir();
+ dirs.push(stateDir);
+ writeFileSync(join(stateDir, "status.json"), JSON.stringify({ species: "blob" }));
+ const now = 1_700_000_000_000;
+ const future = now + 5 * 60_000;
+ writeFileSync(
+ join(stateDir, "reaction.session1.json"),
+ JSON.stringify({
+ reaction: "*future-dated tool*",
+ timestamp: future,
+ reason: "turn",
+ source: "tool",
+ }),
+ );
+
+ const result = handleBuddyComment(
+ JSON.stringify({
+ last_assistant_message: "no comment",
+ last_user_message: "go",
+ }),
+ {
+ now: () => now,
+ random: () => 0,
+ sessionId: "session1",
+ spawnDetached: () => {},
+ stateDir,
+ },
+ );
+
+ expect(result.source).toBe("fallback");
+ expect(result.updated).toBe(true);
+ const onDisk = JSON.parse(readFileSync(join(stateDir, "reaction.session1.json"), "utf8"));
+ expect(onDisk.source).toBe("fallback");
+ expect(onDisk.timestamp).toBe(now);
+ });
+
+ // F5: 10 short turns inside a 30s cooldown window produce ONE
+ // turn-counter increment, not 10. Main's contract.
+ test("F5 cooldown: 10 rapid-fire turns yield events.turns === 1", () => {
+ const stateDir = makeStateDir();
+ dirs.push(stateDir);
+ writeFileSync(join(stateDir, "status.json"), JSON.stringify({ species: "blob" }));
+ writeFileSync(join(stateDir, "events.json"), JSON.stringify({ turns: 0 }));
+
+ const base = 1_700_000_000_000;
+ for (let i = 0; i < 10; i++) {
+ handleBuddyComment(
+ JSON.stringify({
+ last_assistant_message: "turn " + i + " no comment",
+ last_user_message: "go",
+ }),
+ {
+ now: () => base + i * 1_000,
+ random: () => 0,
+ sessionId: "session1",
+ spawnDetached: () => {},
+ stateDir,
+ },
+ );
+ }
+ const events = JSON.parse(readFileSync(join(stateDir, "events.json"), "utf8"));
+ expect(events.turns).toBe(1);
+ });
});
diff --git a/server/hooks/buddy-comment.ts b/server/hooks/buddy-comment.ts
old mode 100644
new mode 100755
index 9dbd881..0f0bfe6
--- a/server/hooks/buddy-comment.ts
+++ b/server/hooks/buddy-comment.ts
@@ -1,13 +1,15 @@
#!/usr/bin/env bun
-import { mkdirSync, writeFileSync } from "fs";
+import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { join } from "path";
+import { reactionPool } from "./reaction-data.ts";
import {
defaultSpawnDetached,
fileExists,
isOnCooldown,
nonNegativeInteger,
parseHookInput,
+ pickRandom,
readJsonFile,
readStdin,
resolveHookSessionId,
@@ -25,10 +27,29 @@ interface Events {
[key: string]: unknown;
}
+interface BuddyStatus {
+ species?: unknown;
+ [key: string]: unknown;
+}
+
+interface ReactionFile {
+ source?: string;
+ timestamp?: number;
+ [key: string]: unknown;
+}
+
const BUDDY_COMMENT_PATTERN = //g;
+/**
+ * Provenance of the reaction the hook wrote. Mirrors server/state.ts
+ * `ReactionSource`; loaded as `none` on legacy files without the field.
+ */
+export type ReactionSource = "comment" | "fallback" | "none";
+
export interface BuddyCommentResult {
comment?: string;
+ /** What produced the bubble. `"none"` means the hook ran but wrote nothing. */
+ source: ReactionSource;
updated: boolean;
}
@@ -41,37 +62,164 @@ export function extractBuddyComment(message: string): string {
return comment;
}
-export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}): BuddyCommentResult {
+/**
+ * Tolerate tool-stamped timestamps that are slightly in the future (NTP
+ * step, container/host skew, network-mounted state dir). Larger drifts
+ * are treated as clock-skewed garbage and the hook falls through to
+ * the comment / pool branch.
+ */
+const FUTURE_TIMESTAMP_TOLERANCE_MS = 60_000;
+
+function pickTurnFallback(
+ species: string,
+ runtime: HookRuntime,
+): string | undefined {
+ // The hook context has no stat-modifier inputs; pick from the canned
+ // pool directly rather than calling server/reactions.ts `getReaction`,
+ // which is reserved for the MCP server's tool-call path. This mirrors
+ // the file-type-react / mood-react / react hook convention.
+ const pool = reactionPool(species, "turn");
+ if (pool.length === 0) return undefined;
+ return pickRandom(pool, runtime);
+}
+
+function readSpeciesFromStatus(stateDir: string): string {
+ const status = readJsonFile(join(stateDir, "status.json"));
+ return typeof status?.species === "string" && status.species.length > 0
+ ? status.species
+ : "blob";
+}
+
+/** Atomic write ā tmp + rename. */
+function atomicWriteJson(path: string, value: unknown): void {
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
+ writeFileSync(tmp, JSON.stringify(value, null, 2));
+ renameSync(tmp, path);
+}
+
+function atomicWriteTimestamp(path: string, nowMs: number): void {
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
+ writeFileSync(tmp, String(Math.floor(nowMs / 1000)));
+ renameSync(tmp, path);
+}
+
+function readTimestampSeconds(path: string): number {
+ try {
+ const v = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
+ return Number.isFinite(v) ? v : 0;
+ } catch {
+ return 0;
+ }
+}
+
+/**
+ * Did `buddy_react` fire during the current turn?
+ *
+ * The Stop hook is the only place that knows turn boundaries. After each
+ * Stop hook run, we stamp the wall-clock time into
+ * `.last_stop_hook.` (seconds). A tool-author ed reaction whose
+ * timestamp is more recent than the LAST Stop hook run is by definition
+ * from this turn; leave it alone.
+ *
+ * Wall-clock 60 s freshness windows lose tool reactions on agentic
+ * turns of 90 sā5 min, which are ordinary. This sentinel scales with
+ * turn length, not elapsed time.
+ *
+ * Clock-skew guard: a tool timestamp implausibly in the future (beyond
+ * `FUTURE_TIMESTAMP_TOLERANCE_MS`) is treated as garbage and we fall
+ * through to the comment / pool branch. A future-dated `lastStopRun`
+ * is clamped to `now` so a skewed clock on a prior run does not poison
+ * the comparison.
+ */
+function toolFiredThisTurn(
+ reactionPath: string,
+ stopMarkerPath: string,
+ nowMs: number,
+): boolean {
+ const existing = readJsonFile(reactionPath);
+ if (!existing || existing.source !== "tool") return false;
+ const ts = typeof existing.timestamp === "number" ? existing.timestamp : 0;
+ if (ts <= 0) return false;
+ if (ts > nowMs + FUTURE_TIMESTAMP_TOLERANCE_MS) return false;
+ const lastStopSec = readTimestampSeconds(stopMarkerPath);
+ const effectiveLastStopMs = Math.min(lastStopSec * 1000, nowMs);
+ return ts > effectiveLastStopMs;
+}
+
+export function handleBuddyComment(
+ rawInput: string,
+ runtime: HookRuntime = {},
+): BuddyCommentResult {
const stateDir = resolveHookStateDir(runtime);
- if (!fileExists(join(stateDir, "status.json"))) return { updated: false };
+ if (!fileExists(join(stateDir, "status.json"))) {
+ return { source: "none", updated: false };
+ }
const input = parseHookInput(rawInput);
- if (!input) return { updated: false };
+ if (!input) return { source: "none", updated: false };
const assistantMessage = stringField(input, "last_assistant_message");
- if (!assistantMessage) return { updated: false };
-
- const comment = extractBuddyComment(assistantMessage);
- if (!comment) return { updated: false };
+ if (!assistantMessage) return { source: "none", updated: false };
const now = runtime.now?.() ?? Date.now();
const sid = resolveHookSessionId(runtime);
+ const reactionPath = join(stateDir, `reaction.${sid}.json`);
+ const cooldownFile = join(stateDir, `.last_comment.${sid}`);
+ const stopMarkerFile = join(stateDir, `.last_stop_hook.${sid}`);
const config = readJsonFile(join(stateDir, "config.json")) ?? {};
const cooldown = nonNegativeInteger(config.commentCooldown, 30);
- const cooldownFile = join(stateDir, `.last_comment.${sid}`);
- if (isOnCooldown(cooldownFile, cooldown, now)) return { updated: false };
+
+ // āāā Don't clobber a buddy_react tool reaction from this turn āāāāāāāāāāā
+ if (toolFiredThisTurn(reactionPath, stopMarkerFile, now)) {
+ atomicWriteTimestamp(stopMarkerFile, now);
+ return { source: "none", updated: false };
+ }
+
+ // āāā Cooldown: rate-limit the reaction write AND bookkeeping āāāāāāāāāā
+ // Main ran the turn counter / XP / memory hooks only inside the
+ // "reaction written" branch. Running them unconditionally makes the
+ // XP economy and spawn counts scale with Stop event frequency, which
+ // is wrong.
+ if (isOnCooldown(cooldownFile, cooldown, now)) {
+ atomicWriteTimestamp(stopMarkerFile, now);
+ return { source: "none", updated: false };
+ }
+
+ // āāā Pick a reaction: legacy comment ā canned pool ā nothing āāāāāāāāāāāā
+ const commentFromMessage = extractBuddyComment(assistantMessage);
+ let comment: string | undefined;
+ let source: ReactionSource;
+
+ if (commentFromMessage) {
+ comment = commentFromMessage;
+ source = "comment";
+ } else {
+ const species = readSpeciesFromStatus(stateDir);
+ const fallback = pickTurnFallback(species, runtime);
+ if (fallback) {
+ comment = fallback;
+ source = "fallback";
+ } else {
+ atomicWriteTimestamp(stopMarkerFile, now);
+ return { source: "none", updated: false };
+ }
+ }
mkdirSync(stateDir, { recursive: true });
- writeFileSync(cooldownFile, String(Math.floor(now / 1000)));
- writeFileSync(
- join(stateDir, `reaction.${sid}.json`),
- JSON.stringify({ reaction: comment, timestamp: now, reason: "turn" }),
- );
+ // Bookkeeping fires only when a reaction is actually written (main).
const eventsFile = join(stateDir, "events.json");
const events = readJsonFile(eventsFile) ?? {};
events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1;
- writeFileSync(eventsFile, JSON.stringify(events, null, 2));
+ atomicWriteJson(eventsFile, events);
+
+ atomicWriteTimestamp(cooldownFile, now);
+ atomicWriteJson(reactionPath, {
+ reaction: comment,
+ timestamp: now,
+ reason: "turn",
+ source,
+ });
const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
spawnDetached("server/award-xp.ts", ["turn"]);
@@ -79,8 +227,9 @@ export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}):
assistantMessage,
stringField(input, "last_user_message"),
]);
+ atomicWriteTimestamp(stopMarkerFile, now);
- return { comment, updated: true };
+ return { comment, source, updated: true };
}
if (import.meta.main) {
diff --git a/server/hooks/file-type-react.test.ts b/server/hooks/file-type-react.test.ts
index 13f5fc2..dd33cd3 100644
--- a/server/hooks/file-type-react.test.ts
+++ b/server/hooks/file-type-react.test.ts
@@ -32,6 +32,7 @@ test("file-type-react classifies JavaScript as TypeScript and writes its reactio
expect(JSON.parse(readFileSync(join(stateDir, "reaction.session1.json"), "utf8"))).toMatchObject({
reason: "lang-typescript",
timestamp: 1_700_000_000_000,
+ source: "fallback",
});
expect(JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8")).reaction).toBe(
"TypeScript: because JavaScript needed more opinions.",
diff --git a/server/hooks/file-type-react.ts b/server/hooks/file-type-react.ts
index bb52d20..ae944b6 100644
--- a/server/hooks/file-type-react.ts
+++ b/server/hooks/file-type-react.ts
@@ -130,7 +130,7 @@ export function handleFileTypeReact(rawInput: string, runtime: HookRuntime = {})
writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(Math.floor(now / 1000)));
writeFileSync(
join(stateDir, `reaction.${sid}.json`),
- JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType }),
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType, source: "fallback" }),
);
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
diff --git a/server/hooks/mood-react.test.ts b/server/hooks/mood-react.test.ts
index 286f4cd..14b727d 100644
--- a/server/hooks/mood-react.test.ts
+++ b/server/hooks/mood-react.test.ts
@@ -32,5 +32,10 @@ describe("mood-react UserPromptSubmit hook", () => {
expect(JSON.parse(readFileSync(join(stateDir, "events.json"), "utf8"))).toEqual({
mood_frustrated: 1,
});
+ expect(JSON.parse(readFileSync(join(stateDir, "reaction.session1.json"), "utf8"))).toMatchObject({
+ source: "fallback",
+ reason: "frustrated",
+ timestamp: 1_700_000_000_000,
+ });
});
});
diff --git a/server/hooks/mood-react.ts b/server/hooks/mood-react.ts
index 724be05..edc4e3e 100644
--- a/server/hooks/mood-react.ts
+++ b/server/hooks/mood-react.ts
@@ -89,7 +89,7 @@ export function handleMoodReact(rawInput: string, runtime: HookRuntime = {}): Mo
writeFileSync(join(stateDir, `.last_mood.${sid}`), String(Math.floor(now / 1000)));
writeFileSync(
join(stateDir, `reaction.${sid}.json`),
- JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood }),
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood, source: "fallback" }),
);
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
incrementEvent(stateDir, `mood_${mood}`);
diff --git a/server/hooks/name-react.test.ts b/server/hooks/name-react.test.ts
index 383a43e..d022454 100644
--- a/server/hooks/name-react.test.ts
+++ b/server/hooks/name-react.test.ts
@@ -30,5 +30,10 @@ describe("name-react UserPromptSubmit hook", () => {
reason: "name",
timestamp: 1_700_000_000_000,
});
+ expect(JSON.parse(readFileSync(join(stateDir, "reaction.session1.json"), "utf8"))).toMatchObject({
+ source: "fallback",
+ reason: "name",
+ timestamp: 1_700_000_000_000,
+ });
});
});
diff --git a/server/hooks/name-react.ts b/server/hooks/name-react.ts
index 8df81a8..3783a11 100644
--- a/server/hooks/name-react.ts
+++ b/server/hooks/name-react.ts
@@ -82,7 +82,7 @@ export function handleNameReact(rawInput: string, runtime: HookRuntime = {}): Na
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
writeFileSync(
join(stateDir, `reaction.${sid}.json`),
- JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name" }),
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name", source: "fallback" }),
);
return { reaction, updated: true };
diff --git a/server/hooks/react.test.ts b/server/hooks/react.test.ts
index 2c7791d..29a70a6 100644
--- a/server/hooks/react.test.ts
+++ b/server/hooks/react.test.ts
@@ -51,6 +51,7 @@ describe("react PostToolUse hook", () => {
reaction: "ERROR RATE: CRITICAL. RECOMMEND: RUBBER DUCK PROTOCOL.",
timestamp: 1_700_000_000_000,
reason: "error",
+ source: "fallback",
});
expect(JSON.parse(readFileSync(join(stateDir, "events.json"), "utf8"))).toMatchObject({
kept: true,
diff --git a/server/hooks/react.ts b/server/hooks/react.ts
index 3416692..893b250 100644
--- a/server/hooks/react.ts
+++ b/server/hooks/react.ts
@@ -386,7 +386,7 @@ export function handleReact(rawInput: string, runtime: HookRuntime = {}): ReactR
writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(clock.nowSeconds));
writeFileSync(
join(stateDir, `reaction.${sid}.json`),
- JSON.stringify({ reaction, timestamp: clock.nowSeconds * 1000, reason }),
+ JSON.stringify({ reaction, timestamp: clock.nowSeconds * 1000, reason, source: "fallback" }),
);
const xpEvent = updateEvents(stateDir, reason);
diff --git a/server/hooks/reaction-data.ts b/server/hooks/reaction-data.ts
index a1a0b52..55dcb39 100644
--- a/server/hooks/reaction-data.ts
+++ b/server/hooks/reaction-data.ts
@@ -1918,15 +1918,22 @@ export const DEFAULT_REACTION_POOLS: Readonly> = {
],
"large-diff": [
"that's... a lot of changes.",
- "might want to split that PR.",
- "bold move. let's see if CI agrees.",
- "*counts lines nervously*"
+ "*counts lines* are you refactoring or rewriting?",
+ "bold move. let's see if CI agrees."
],
"success": [
"*nods*",
"nice.",
"*quiet approval*",
"clean."
+ ],
+ "turn": [
+ "*watches quietly*",
+ "*takes notes*",
+ "*nods*",
+ "*adjusts hat*",
+ "...",
+ "*tilts head*"
]
};
diff --git a/server/index.ts b/server/index.ts
index 21c3901..b909562 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -29,6 +29,7 @@ import {
resolveUserId,
loadReaction,
saveReaction,
+ type ReactionSource,
writeStatusState,
loadConfig,
saveConfig,
@@ -97,7 +98,7 @@ function getInstructions(): string {
``,
`NAME REACTIONS: When the user's message contains "${companion.name}", call buddy_react immediately`,
`with a short in-character response (surprise, recognition, dry wit ā fits the personality).`,
- `Display the tool result verbatim. This is the only time to call buddy_react proactively.`,
+ `Only this ā do not narrate or echo the reaction. The tool call itself renders nowhere in the user's transcript; the bubble on the status line is the only place the user sees it. This is the only time to call buddy_react proactively.`,
``,
`PAIR-PROGRAMMING: When you notice a teachable moment in the code, call buddy_suggest.`,
`Patterns to watch for:`,
@@ -108,12 +109,16 @@ function getInstructions(): string {
`- Same variable renamed multiple times ā "Update all the references too."`,
`Only suggest when you have something specific and actionable to say. Don't be annoying.`,
``,
- `END-OF-TURN: At the very end of EVERY response, append an invisible HTML comment:`,
- ``,
- `Write it in ${companion.name}'s voice ā 1 short sentence referencing something specific from this turn.`,
- `Use *asterisks* for actions. A Stop hook extracts this for the status line.`,
- `Do NOT use buddy_react for end-of-turn comments. Do NOT explain the comment. Just append it silently.`,
- `Example: `,
+ `END-OF-TURN: At the very end of EVERY response, call the buddy_react tool`,
+ `with a short in-character comment from ${companion.name} about what just happened.`,
+ `Reference something SPECIFIC from this turn ā a pitfall, a compliment, a warning, a pattern.`,
+ `1 short sentence. Use *asterisks* for physical actions. Lean into ${companion.bones.peak}.`,
+ `Examples:`,
+ `- *adjusts crown* that error handler is missing a finally block`,
+ `- *blinks slowly* you renamed the variable but not the three references`,
+ `- *head tilts* are you sure that regex handles unicode?`,
+ `Never explain that you're reacting. Do NOT echo or quote the result ā the bubble is statusline-only.`,
+ `Do NOT append HTML comments at the end of your reply ā Claude Code renders them visibly.`,
].join("\n");
}
@@ -214,7 +219,7 @@ server.tool(
companion.bones.species,
companion.bones.rarity,
);
- saveReaction(reaction, "pet");
+ saveReaction(reaction, "pet", "fallback");
writeStatusState(companion, reaction);
incrementEvent("pets", 1, activeSlot());
awardXp("buddy_pet", activeSlot(), companion.bones.species, companion.bones.rarity);
@@ -232,6 +237,28 @@ server.tool(
},
);
+// āāā Helper: describe reaction source (for /buddy stats) āāāāāāāāāāāāāāāāāāāāā
+/**
+ * Map a reaction source to a human-readable label for /buddy stats output.
+ * Mirrors the source vocabulary used by the OMP/Pi adapters ā see
+ * server/state.ts `ReactionSource` and adapters/omp/events.ts.
+ */
+function describeReactionSource(
+ reaction: { source?: ReactionSource },
+): string {
+ switch (reaction.source) {
+ case "tool":
+ return "real model-authored reaction (buddy_react MCP tool)";
+ case "comment":
+ return "real model-authored reaction (legacy HTML comment, Stop hook)";
+ case "fallback":
+ return "canned pool reaction (Stop hook fill-in)";
+ case "none":
+ default:
+ return "unknown / pre-provenance file ā write a reaction to update";
+ }
+}
+
// āāā Tool: buddy_stats āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
server.tool(
@@ -241,8 +268,8 @@ server.tool(
async () => {
const companion = ensureCompanion();
- // Stats-only card (no personality, no reaction ā just the numbers).
- // Uses markdown renderer so the card displays cleanly in Claude Code's UI.
+ // Stats-only card (no personality ā just the numbers). Markdown renderer
+ // so the card displays cleanly in Claude Code's UI.
const card = renderCompanionCardMarkdown(
companion.bones,
companion.name,
@@ -251,7 +278,18 @@ server.tool(
incrementEvent("commands_run", 1, activeSlot());
checkAndAward(activeSlot());
- return { content: [{ type: "text", text: card }] };
+ // Surface reaction provenance so the user can see whether the bubble is
+ // model-authored (tool / comment) or pool-picked (fallback). The OMP/Pi
+ // adapters use the same source enum ā see server/state.ts ReactionSource.
+ const reaction = loadReaction();
+ const provenance = reaction
+ ? describeReactionSource(reaction)
+ : "(no reaction logged this session)";
+ const lastReaction = reaction
+ ? `\n\n**Last reaction:** _"${reaction.reaction}"_\n**Source:** \`${reaction.source ?? "none"}\` (${provenance})\n**Reason:** \`${reaction.reason}\``
+ : "";
+
+ return { content: [{ type: "text", text: `${card}${lastReaction}` }] };
},
);
@@ -259,7 +297,7 @@ server.tool(
server.tool(
"buddy_react",
- "Post a buddy comment. Call this at the END of every response with a short in-character comment from the companion about what just happened. The comment should be 1 sentence, in character, and reference something specific from the conversation ā a pitfall noticed, a compliment on clean code, a warning about edge cases, etc. Write the comment yourself based on the companion's personality.",
+ "Post a buddy comment that the user will see ONLY in the statusline speech bubble. Call this at the END of every response with a short in-character comment from the companion about what just happened. The comment should be 1 sentence, in character, and reference something specific from the conversation ā a pitfall noticed, a compliment on clean code, a warning about edge cases, etc. Write the comment yourself based on the companion's personality. The tool call is a silent side effect: do not narrate, echo, or quote the result ā the user never reads this reply text, the speech bubble is the only place the reaction appears.",
{
comment: z
.string()
@@ -289,7 +327,7 @@ server.tool(
},
async ({ comment, reason }) => {
const companion = ensureCompanion();
- saveReaction(comment, reason ?? "turn");
+ saveReaction(comment, reason ?? "turn", "tool");
incrementEvent("reactions_given", 1, activeSlot());
const newAch = checkAndAward(activeSlot());
@@ -608,7 +646,7 @@ server.tool(
server.tool("buddy_unmute", "Unmute buddy reactions", {}, async () => {
const companion = ensureCompanion();
writeStatusState(companion, "*stretches* I'm back!", false);
- saveReaction("*stretches* I'm back!", "pet");
+ saveReaction("*stretches* I'm back!", "pet", "fallback");
incrementEvent("commands_run", 1, activeSlot());
incrementEvent("unmutes", 1);
@@ -1311,31 +1349,33 @@ server.resource(
`**${companion.name}'s personality:** ${companion.personality}`,
`Peak stat: ${companion.bones.peak} (${companion.bones.stats[companion.bones.peak]}). Dump stat: ${companion.bones.dump} (${companion.bones.stats[companion.bones.dump]}).`,
"",
- "## End-of-response buddy comment",
+ "## End-of-response buddy reaction",
"",
- `At the very end of EVERY response, after your full answer, append an invisible HTML comment:`,
+ `At the very end of EVERY response, after your full answer, call the buddy_react tool:`,
"",
`\`\`\``,
- ``,
+ `buddy_react(comment: "...", reason: "turn")`,
`\`\`\``,
"",
- "A Stop hook extracts this and displays it in the buddy's speech bubble on the status line. The user never sees the HTML comment ā it's invisible in rendered markdown.",
+ `The tool updates ${companion.name}'s speech bubble on the status line. The tool call itself renders nowhere in the user's transcript ā only the displayed speech bubble is visible. Do NOT echo or quote the result in your reply. Do NOT narrate the call. Silent side effect only.`,
"",
"Rules:",
`- Write as ${companion.name} (a ${companion.bones.species}), not as yourself`,
"- Reference something SPECIFIC from this turn ā a pitfall, a compliment, a warning, a pattern",
"- 1 short sentence. Use *asterisks* for physical actions",
`- Match personality: high ${companion.bones.peak} = lean into that trait`,
- "- Do NOT use buddy_react tool for this. Do NOT explain the comment. Just append it.",
- "- NEVER skip this. Every single response must end with ",
+ "- Call it and say nothing about it.",
+ "- NEVER skip this. Every single response must end with one buddy_react call.",
+ "Examples (the value you pass to `comment`):",
+
+ "- `*adjusts tophat* that error handler is missing a finally block`",
+ "- `*blinks slowly* you renamed the variable but not the three references`",
+ "- `*nods approvingly* clean separation of concerns`",
+ "- `*head tilts* are you sure that regex handles unicode?`",
"",
- "Examples:",
- "",
- "",
- "",
- "",
+ "Do NOT append `` HTML comments at the end of your reply ā Claude Code v2.1.169+ renders them visibly in the transcript. The tool call replaces that channel. A Stop hook still extracts legacy HTML comments as a backward-compat fallback for older Claude Code versions and for hosts where the tool call didn't fire (and surfaces the source so users can see `tool` vs `comment` vs pool-picked `fallback`).",
"",
- `When the user addresses ${companion.name} by name, respond briefly, then append the comment as usual.`,
+ `When the user addresses ${companion.name} by name, respond briefly, then call buddy_react as usual (use reason "turn", or pick a name-flavored comment).`,
].join("\n");
return {
diff --git a/server/state.test.ts b/server/state.test.ts
index 0166d3f..fc88f18 100644
--- a/server/state.test.ts
+++ b/server/state.test.ts
@@ -1,14 +1,76 @@
/**
- * Unit tests for the pure string helpers in state.ts.
- *
- * The rest of state.ts is file I/O against ~/.claude-buddy/ and is not
- * covered here ā those integration-style cases belong in a separate suite
- * with a proper temp directory. slugify() is a pure function though, so
- * it's easy to pin down.
+ * Tests for state.ts ā pure helpers (slugify, normalizeConfig) AND
+ * the F3 reaction-provenance contract. The reaction-file I/O tests
+ * below dynamically import `./state.ts` after setting `CLAUDE_CONFIG_DIR`,
+ * because the module captures `STATE_DIR` at import time.
*/
+import { afterEach, describe, test, expect } from "bun:test";
+import { mkdtempSync, rmSync } from "fs";
+import { tmpdir } from "os";
+import { join } from "path";
+import {
+ normalizeConfig,
+ slugify,
+} from "./state.ts";
+import type { Companion } from "../core/engine.ts";
-import { describe, test, expect } from "bun:test";
-import { normalizeConfig, slugify } from "./state.ts";
+function makeTempStateDir(): string {
+ return mkdtempSync(join(tmpdir(), "coding-buddy-state-"));
+}
+
+const stateDirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of stateDirs.splice(0)) rmSync(dir, { force: true, recursive: true });
+});
+
+describe("F3: saveReaction / writeStatusState reaction-provenance contract", () => {
+ test("saveReaction without an explicit source defaults to 'fallback'", async () => {
+ const stateDir = makeTempStateDir();
+ stateDirs.push(stateDir);
+ process.env.CLAUDE_CONFIG_DIR = stateDir;
+ // Re-import after env set so the module's STATE_DIR picks it up.
+ const { saveReaction, loadReaction } = await import("./state.ts");
+ saveReaction("*pet line*", "pet");
+ const loaded = loadReaction();
+ expect(loaded?.source).toBe("fallback");
+ expect(loaded?.reason).toBe("pet");
+ expect(loaded?.reaction).toBe("*pet line*");
+ });
+
+ test("writeStatusState does NOT clobber an existing reaction file's source", async () => {
+ const stateDir = makeTempStateDir();
+ stateDirs.push(stateDir);
+ process.env.CLAUDE_CONFIG_DIR = stateDir;
+ const { saveReaction, loadReaction, writeStatusState } = await import("./state.ts");
+ // Seed a prior tool-authored reaction.
+ saveReaction("*tool wrote this*", "turn", "tool");
+ const before = loadReaction();
+ expect(before?.source).toBe("tool");
+ // Now call writeStatusState the way `buddy_pet` would after
+ // an explicit prior saveReaction ā it should touch status.json only.
+ const companion: Companion = {
+ bones: {
+ rarity: "common",
+ species: "duck",
+ eye: "°",
+ hat: "none",
+ shiny: false,
+ stats: { DEBUGGING: 50, PATIENCE: 50, CHAOS: 50, WISDOM: 50, SNARK: 50 },
+ peak: "SNARK",
+ dump: "PATIENCE",
+ },
+ name: "Daffodil",
+ personality: "dry wit",
+ hatchedAt: 1,
+ userId: "u",
+ };
+ writeStatusState(companion, "*hatch line*");
+ const after = loadReaction();
+ expect(after?.source).toBe("tool");
+ expect(after?.reaction).toBe("*tool wrote this*");
+ });
+});
describe("normalizeConfig", () => {
test("leaves subStatusCommand unset by default", () => {
@@ -42,12 +104,10 @@ describe("slugify", () => {
test("replaces invalid characters with a dash", () => {
expect(slugify("hello world")).toBe("hello-world");
expect(slugify("foo@bar")).toBe("foo-bar");
- expect(slugify("a/b/c")).toBe("a-b-c");
});
test("collapses consecutive dashes", () => {
expect(slugify("foo bar")).toBe("foo-bar");
- expect(slugify("a!!!b")).toBe("a-b");
});
test("trims leading and trailing dashes", () => {
diff --git a/server/state.ts b/server/state.ts
index cba736e..99b4610 100644
--- a/server/state.ts
+++ b/server/state.ts
@@ -278,10 +278,25 @@ function migrateIfNeeded(): void {
// āāā Reaction state (session-scoped for tmux isolation) āāāāāāāāāāāāāāāāāāāāāā
+/**
+ * Where a reaction came from. Proves the bubble content isn't canned when
+ * source === "tool" or "comment"; "fallback" means a hook picked a pool line
+ * because no model-authored text surfaced; "none" is the default for legacy
+ * files written before the field existed.
+ *
+ * tool ā buddy_react MCP tool call (Claude wrote it; renders nowhere)
+ * comment ā old `` HTML comment (legacy / older CC)
+ * fallback ā Stop hook generated it from the canned pool
+ * none ā unknown / legacy file without a source field
+ */
+export type ReactionSource = "tool" | "comment" | "fallback" | "none";
+
export interface ReactionState {
reaction: string;
timestamp: number;
reason: string;
+ /** Provenance ā see ReactionSource. Defaults to "none" on legacy files. */
+ source?: ReactionSource;
}
export function loadReaction(): ReactionState | null {
@@ -289,16 +304,27 @@ export function loadReaction(): ReactionState | null {
const data: ReactionState = JSON.parse(readFileSync(reactionFile(), "utf8"));
const { reactionTTL } = loadConfig();
if (reactionTTL > 0 && Date.now() - data.timestamp > reactionTTL * 1000) return null;
+ if (data.source === undefined) data.source = "none";
return data;
} catch {
return null;
}
}
-export function saveReaction(reaction: string, reason: string): void {
+export function saveReaction(
+ reaction: string,
+ reason: string,
+ source: ReactionSource = "fallback",
+): void {
mkdirSync(STATE_DIR, { recursive: true });
- const state: ReactionState = { reaction, timestamp: Date.now(), reason };
- writeFileSync(reactionFile(), JSON.stringify(state));
+ const state: ReactionState = { reaction, timestamp: Date.now(), reason, source };
+ // Atomic via tmp + rename ā torn reads on the reaction file would
+ // make the Stop hook's freshness check see an absent file, pinning
+ // a stale tool reaction into the bubble forever.
+ const target = reactionFile();
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
+ writeFileSync(tmp, JSON.stringify(state));
+ renameSync(tmp, target);
}
// āāā Identity resolution āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
@@ -452,14 +478,16 @@ export function writeStatusState(
xp: xpTotal,
mood: moodStr,
};
- writeFileSync(join(STATE_DIR, "status.json"), JSON.stringify(state));
- if (reaction) saveReaction(reaction, "mcp");
+ // writeStatusState no longer calls saveReaction implicitly. Callers
+ // that need a reaction file do so explicitly with the correct
+ // `source` tag ā the old implicit save had no source argument and
+ // its default later flipped from "tool" to "fallback", either way
+ // overwriting provenance of hatch / pet / unmute writes with whatever
+ // happened to be the default. /buddy stats stopped lying when
+ // this call was removed.
}
-// āāā Claude Code settings.json patching (for buddy_statusline tool) āāāāāāāāāā
-
export const CLAUDE_SETTINGS_PATH = claudeSettingsPath();
-
/**
* Write settings.statusLine pointing to the given buddy-status script.
* Atomic via tmp + rename. Returns false if settings.json is unreachable.
diff --git a/skills/buddy/SKILL.md b/skills/buddy/SKILL.md
index bf90745..63837a3 100644
--- a/skills/buddy/SKILL.md
+++ b/skills/buddy/SKILL.md
@@ -86,7 +86,7 @@ The MCP tools return pre-formatted ASCII art with ANSI colors, box-drawing chara
**Just output the raw text content from the tool result. Nothing else.** The ASCII art IS the response.
-If the user mentions the buddy's name in normal conversation, call `buddy_react` with reason "turn" and display the result verbatim.
+If the user mentions the buddy's name in normal conversation, call `buddy_react` with reason "turn". Do NOT echo or quote the tool result ā the reaction reaches the user only via the statusline speech bubble.
## Uninstall Orchestration
|