Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
<td align="center" width="25%">
<h3>💬</h3>
<b>Speech Bubbles</b><br>
<sub>Your buddy comments on your code in real time. Invisible, contextual, alive.</sub>
<sub>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.</sub>
</td>
<td align="center" width="25%">
<h3>⚡</h3>
Expand Down Expand Up @@ -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 `<!-- buddy: ... -->` 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 `<!-- buddy: ... -->` 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?

Expand Down
179 changes: 176 additions & 3 deletions server/hooks/buddy-comment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
});

Expand All @@ -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);
});
});
Loading
Loading