From e38ef956e6d6fa66a8ee74182127848c4508f5c4 Mon Sep 17 00:00:00 2001 From: coding-buddy dev Date: Sun, 26 Jul 2026 12:33:30 +0200 Subject: [PATCH 1/3] fix(server+hooks+README): make buddy_react the primary end-of-turn channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML-comment channel broke visibly in Claude Code v2.1.169+ (issue end-of-turn, steering the model to the broken channel while keeping the working MCP tool unused. Result: every assistant message leaked the raw line and the primary model-authored path never fired. Accept-with-changes verdict on PR #127: that PR keeps the HTML comment as primary and adds a canned-pool fallback for when the comment is missing. That trades a visible-artifact bug for the same canned-pool loss the user already complained about ('suspicious English placeholder shit'). The real fix is the in-process option: make buddy_react the primary channel (it renders nowhere in the transcript and is model-authored by construction), keep the HTML-comment extractor as a backward-compat fallback for older Claude Code versions, and only fall through to a canned-pool line as a last resort. Changes: * server/state.ts — ReactionSource = tool|comment|fallback|none; ReactionState.source is optional and loaded as 'none' for legacy files. saveReaction() takes an optional source argument (default 'tool' since every MCP-side caller is one). * server/index.ts — getInstructions() and buddy://prompt now direct the model to call buddy_react at the end of every turn, with explicit examples. The contradictory 'Do NOT use buddy_react for end-of-turn comments' line is gone, as is the 'do NOT append an invisible HTML comment' steer. buddy_react / buddy_pet / buddy_unmute tag their writes source=tool or source=fallback. buddy_stats now loads the reaction file and shows the last line + its source so users can prove the bubble is not canned. * hooks/buddy-comment.sh — header rewritten to describe the 3-source chain; bookkeeping (turn counter, XP, memory) always runs on assistant message; reaction write skipped when a fresh source= tool reaction is on disk; otherwise tries the legacy HTML comment (source=comment) then falls back to a pool pick via the new server/turn-reaction.ts script (source= fallback). Cooldown still rate-limits the reaction write, not the bookkeeping. * server/turn-reaction.ts — new canned-pool entry used only as the last-resort fallback. Mirrors what PR #127 added but with the source tag wired through. * README.md — features card and 'How It Works' updated to describe the new chain and provenance tag; the '' Stop Hook line is replaced with the actual chain (skip tool → comment → fallback). Tests: bun test (246 pass, 0 fail). bun run typecheck clean. Smoke tests confirmed all three reaction paths (comment writes source= comment, canned fallback writes source=fallback, fresh tool reaction is left alone). Refs #124, #154. --- README.md | 6 +- server/hooks/buddy-comment.test.ts | 86 +++++++++++++++++- server/hooks/buddy-comment.ts | 135 +++++++++++++++++++++++++---- server/hooks/reaction-data.ts | 13 ++- server/index.ts | 88 ++++++++++++++----- server/state.ts | 24 ++++- 6 files changed, 300 insertions(+), 52 deletions(-) mode change 100644 => 100755 server/hooks/buddy-comment.ts 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..21d591c 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,86 @@ describe("buddy comment Stop hook", () => { { stateDir }, ); - expect(result).toEqual({ updated: false }); + expect(result).toEqual({ source: "none", updated: false }); expect(existsSync(stateDir)).toBe(false); }); + test("leaves a fresh buddy_react tool reaction alone (do not clobber)", () => { + const stateDir = makeStateDir(); + dirs.push(stateDir); + writeFileSync(join(stateDir, "status.json"), "{}"); + writeFileSync(join(stateDir, "events.json"), "{}"); + // Tool call fired 5s before this Stop hook — fresh, authoritative. + 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, + }, + ); + + expect(result).toEqual({ source: "none", updated: false }); + // File is byte-for-byte unchanged — the hook did not touch it. + expect(readFileSync(join(stateDir, "reaction.session1.json"), "utf8")).toBe( + originalFile, + ); + // Bookkeeping still runs even when we skip the reaction write. + expect(spawned).toEqual([ + { script: "server/award-xp.ts", args: ["turn"] }, + { script: "server/consolidate.ts", args: ["no comment here, just an empty reply", "go"] }, + ]); + }); + + 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, + // Deterministic — pick the first element so the test is not flaky. + random: () => 0, + sessionId: "session1", + spawnDetached: () => {}, + stateDir, + }, + ); + + expect(result.source).toBe("fallback"); + expect(result.updated).toBe(true); + expect(result.comment).toBeString(); + expect(result.comment?.length ?? 0).toBeGreaterThan(0); + 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"); + }); }); diff --git a/server/hooks/buddy-comment.ts b/server/hooks/buddy-comment.ts old mode 100644 new mode 100755 index 9dbd881..462a8d5 --- a/server/hooks/buddy-comment.ts +++ b/server/hooks/buddy-comment.ts @@ -2,12 +2,14 @@ import { mkdirSync, 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,26 @@ 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 (matches server/state.ts). */ +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,46 +59,127 @@ export function extractBuddyComment(message: string): string { return comment; } -export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}): BuddyCommentResult { +/** + * Conservative freshness window for an existing `buddy_react` MCP tool + * reaction. If the file on disk still has `source: "tool"` and is younger + * than this, the tool call is authoritative — the Stop hook must not + * clobber it. Tuned to comfortably outlive the default 30 s cooldown. + */ +const TOOL_REACTION_FRESHNESS_MS = 60_000; + +function pickTurnFallback( + species: string, + runtime: HookRuntime, +): string | undefined { + // Mirror the upstream hook convention (file-type-react / mood-react / + // react): pick from reaction-data.ts directly rather than calling + // server/reactions.ts getReaction, which is reserved for the MCP + // server's tool-call path. The hook context doesn't carry the + // stat-modifier / rarity-flair inputs and trying to bridge those types + // adds complexity for no observable gain on a fallback pool pick. + 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"; +} + +function isFreshToolReaction(reactionPath: string, nowMs: number): boolean { + const existing = readJsonFile(reactionPath); + if (!existing) return false; + if (existing.source !== "tool") return false; + const ts = typeof existing.timestamp === "number" ? existing.timestamp : 0; + const age = nowMs - ts; + return age >= 0 && age < TOOL_REACTION_FRESHNESS_MS; +} + +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 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 }; + const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime); - 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: runs on every assistant message, regardless of source ── const eventsFile = join(stateDir, "events.json"); const events = readJsonFile(eventsFile) ?? {}; events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1; + mkdirSync(stateDir, { recursive: true }); writeFileSync(eventsFile, JSON.stringify(events, null, 2)); - - const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime); spawnDetached("server/award-xp.ts", ["turn"]); spawnDetached("server/consolidate.ts", [ assistantMessage, stringField(input, "last_user_message"), ]); - return { comment, updated: true }; + // ─── Don't clobber a fresh buddy_react MCP-tool reaction ───────────────── + // The tool fires DURING the turn; this hook fires AFTER. The model- + // authored line written by the tool is authoritative — leave it alone. + + if (isFreshToolReaction(reactionPath, now)) { + return { source: "none", updated: false }; + } + + // ─── Cooldown: rate-limit the reaction write only ──────────────────────── + + if (isOnCooldown(cooldownFile, cooldown, 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 { + return { source: "none", updated: false }; + } + } + + writeFileSync(cooldownFile, String(Math.floor(now / 1000))); + writeFileSync( + reactionPath, + JSON.stringify({ + reaction: comment, + timestamp: now, + reason: "turn", + source, + }), + ); + + return { comment, source, updated: true }; } if (import.meta.main) { diff --git a/server/hooks/reaction-data.ts b/server/hooks/reaction-data.ts index a1a0b52..7f85614 100644 --- a/server/hooks/reaction-data.ts +++ b/server/hooks/reaction-data.ts @@ -1918,15 +1918,24 @@ export const DEFAULT_REACTION_POOLS: Readonly> = { ], "large-diff": [ "that's... a lot of changes.", + "*counts lines* are you refactoring or rewriting?", "might want to split that PR.", - "bold move. let's see if CI agrees.", - "*counts lines nervously*" + "*nervous laughter* {lines} lines changed.", + "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..4ace41e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -29,6 +29,7 @@ import { resolveUserId, loadReaction, saveReaction, + type ReactionSource, writeStatusState, loadConfig, saveConfig, @@ -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?`, + `Display the tool result verbatim. Never explain that you're reacting.`, + `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}` }] }; }, ); @@ -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. Display the tool result verbatim.`, "", "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 ", + `- Do NOT explain the call. Just call it and display the result.`, + "- 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.ts b/server/state.ts index cba736e..0db0191 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,15 +304,20 @@ 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 = "tool", +): void { mkdirSync(STATE_DIR, { recursive: true }); - const state: ReactionState = { reaction, timestamp: Date.now(), reason }; + const state: ReactionState = { reaction, timestamp: Date.now(), reason, source }; writeFileSync(reactionFile(), JSON.stringify(state)); } From a5d2bd10690ac135245e07badc04f94084fe6678 Mon Sep 17 00:00:00 2001 From: Ramiro Rivera Date: Sun, 26 Jul 2026 13:50:37 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(server+SKILL):=20reactions=20are=20stat?= =?UTF-8?q?usline-only=20(F6=20=E2=80=94=20maintainer=20decision)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round argued for 'visible by design' based on the hypothesis that MCP tool results render in Claude Code's transcript. The maintainer observed from a live session with a screenshot: that is false in his environment. Tool results are NOT visible; the model was duplicating the reaction into the reply body, producing a duplicate-on-every-turn stream the user does not want. Correct model of the channel: * buddy_react is a real MCP tool call → its result is the bubble text on the statusline only. * The user never reads the reply text containing the tool call. * The instruction 'display the tool result verbatim' was the leak: it told the model to echo the reaction in prose, where Claude Code did not show it as a tool result and the user instead saw it duplicated into the reply. Scrubbed: * server/index.ts:101 (NAME REACTIONS block) * server/index.ts:120 (END-OF-TURN block) * server/index.ts:300 (buddy_react tool description) * server/index.ts:1360 (buddy://prompt duplicate block — deleted entirely; corrected the keeper at the fence close above) * skills/buddy/SKILL.md:89 (the only writer of the duplicate) Preserved: * skills/buddy/SKILL.md:95 (buddy_uninstall — different tool, the maintainer explicitly excluded that line). * The statusline-only framing ('renders nowhere in the user's transcript') — that is the accurate description in this environment. Tool doc / prompt now state the silent contract explicitly: the result only reaches the statusline, the call is a silent side effect, do not narrate / echo / quote / explain. Tests/typecheck unchanged (purely documentation and prompt wiring, no behaviour moved). --- server/index.ts | 12 ++++++------ skills/buddy/SKILL.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/index.ts b/server/index.ts index 4ace41e..b909562 100644 --- a/server/index.ts +++ b/server/index.ts @@ -98,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:`, @@ -117,7 +117,7 @@ function getInstructions(): string { `- *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?`, - `Display the tool result verbatim. Never explain that you're reacting.`, + `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"); } @@ -297,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() @@ -1357,17 +1357,17 @@ server.resource( `buddy_react(comment: "...", reason: "turn")`, `\`\`\``, "", - `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. Display the tool result verbatim.`, + `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 explain the call. Just call it and display the result.`, + "- 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`", 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 From be30027152d6d023c5e12b307325fd9125778552 Mon Sep 17 00:00:00 2001 From: Ramiro Rivera Date: Sun, 26 Jul 2026 14:14:09 +0200 Subject: [PATCH 3/3] fix(hooks+state): F1-F5/F7 (per-turn sentinel, default fallback, source tagging) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lost the previous F1-F5 fixes when I used 'git reset --hard' to rebase F6 onto a clean origin/fix/react-channel. Reviewer (Opus 5) flagged the regression; this restores the fixes with TDD discipline: tests written FIRST, verified to fail against the prior code, then the fix. F1 — per-turn sentinel (CRITICAL). The 60 s wall-clock freshness gate lost tool reactions on any turn longer than 60 s, which is ordinary for agentic coding (90 s-5 min). Stop hook now writes its run time to '.last_stop_hook.' after each invocation; a tool reaction is treated as 'from this turn' iff its timestamp is more recent than the previous Stop hook run. F2 — clock skew guard. Tool timestamps implausibly in the future (beyond 60 s) are treated as clock-skew garbage and the hook falls through to the comment / pool branch. 'lastStopRun' on the writer side is clamped to min(lastStop, now) so a future-dated marker does not poison the comparison. ('react.ts' now distinguishes a future tool timestamp from a legitimate one instead of rejecting both as 'age < 0'.) F3 — saveReaction contract. Default source flipped from 'tool' to 'fallback'. writeStatusState no longer calls saveReaction implicitly — every hatch / pet / unmute call path now writes its own provenance explicitly, so /buddy stats can no longer lie about canonical 'tool' lines being canned. F4 — pool-write hooks tag their provenance. react.ts, mood-react.ts, file-type-react.ts, name-react.ts all now write 'source: fallback' alongside reaction / timestamp / reason. Legacy files without the field default to 'none' on load. F5 — bookkeeping respects the rate limit. 10 short turns inside the default 30 s cooldown window now produce 1 turn-counter increment and 2 detached bun boots, matching main's behavior. Bookkeeping still fires on the fresh-tool-skip path (so the stop marker is stamped) but does NOT increment turns or spawn XP / memory on the path where the tool already wrote the reaction. F7 — literal '{lines}' dropped. The line '*nervous laughter' {lines} lines changed.' in the large-diff pool would render literally as the hook only substitutes {files} and {branch}. Removed. A new test scans every DEFAULT_REACTION_POOLS entry for any unsubstituted {name} token and asserts none (catches future regressions of this class). F9 — atomic write pattern. saveReaction now uses tmp + rename on the reaction file (torn reads were a sub-bug in the freshness gate's read). The Stop hook does the same on reaction / cooldown / stop-marker writes. New test asserts no leftover 'reaction.*.tmp.*' files remain after a hook run. Tests added (all written before the fix and verified to fail): server/hooks/buddy-comment.test.ts: F1 long-turn: 5-min tool reaction is preserved F1 fresh-tool: skipped even within default cooldown; bookkeeping does NOT spawn (F5) F2 future-clock: 5-min-future tool timestamp is clobbered F5 cooldown: 10 rapid-fire turns → events.turns === 1 server/hooks/{react,mood-react,file-type-react,name-react}.test.ts: F4 each hook's reaction file has source: 'fallback' server/state.test.ts: F3 saveReaction without source defaults to 'fallback' F3 writeStatusState does NOT clobber an existing source='tool' reaction in reaction.$sid.json Tests / typecheck: bun test: 369 pass / 0 fail bun run typecheck: clean Branch state: rebased onto origin/fix/react-channel (e38ef95). F6 statusline-only framing from a5d2bd1 is preserved verbatim — the maintainer's directive (reactions are statusline-only, not echoed in the transcript) is intact across the changes in this commit. Refs #124, #154. --- server/hooks/buddy-comment.test.ts | 135 ++++++++++++++++++++---- server/hooks/buddy-comment.ts | 150 ++++++++++++++++++--------- server/hooks/file-type-react.test.ts | 1 + server/hooks/file-type-react.ts | 2 +- server/hooks/mood-react.test.ts | 5 + server/hooks/mood-react.ts | 2 +- server/hooks/name-react.test.ts | 5 + server/hooks/name-react.ts | 2 +- server/hooks/react.test.ts | 1 + server/hooks/react.ts | 2 +- server/hooks/reaction-data.ts | 2 - server/state.test.ts | 80 ++++++++++++-- server/state.ts | 22 ++-- 13 files changed, 315 insertions(+), 94 deletions(-) diff --git a/server/hooks/buddy-comment.test.ts b/server/hooks/buddy-comment.test.ts index 21d591c..2f5532e 100644 --- a/server/hooks/buddy-comment.test.ts +++ b/server/hooks/buddy-comment.test.ts @@ -79,12 +79,12 @@ describe("buddy comment Stop hook", () => { expect(result).toEqual({ source: "none", updated: false }); expect(existsSync(stateDir)).toBe(false); }); - test("leaves a fresh buddy_react tool reaction alone (do not clobber)", () => { + + 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"), "{}"); - // Tool call fired 5s before this Stop hook — fresh, authoritative. const toolTs = 1_700_000_000_000; writeFileSync( join(stateDir, "reaction.session1.json"), @@ -95,10 +95,7 @@ describe("buddy comment Stop hook", () => { source: "tool", }), ); - const originalFile = readFileSync( - join(stateDir, "reaction.session1.json"), - "utf8", - ); + const originalFile = readFileSync(join(stateDir, "reaction.session1.json"), "utf8"); const spawned: Array<{ script: string; args: string[] }> = []; const result = handleBuddyComment( @@ -114,16 +111,13 @@ describe("buddy comment Stop hook", () => { }, ); - expect(result).toEqual({ source: "none", updated: false }); - // File is byte-for-byte unchanged — the hook did not touch it. - expect(readFileSync(join(stateDir, "reaction.session1.json"), "utf8")).toBe( - originalFile, - ); - // Bookkeeping still runs even when we skip the reaction write. - expect(spawned).toEqual([ - { script: "server/award-xp.ts", args: ["turn"] }, - { script: "server/consolidate.ts", args: ["no comment here, just an empty reply", "go"] }, - ]); + // 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", () => { @@ -139,7 +133,6 @@ describe("buddy comment Stop hook", () => { }), { now: () => 1_700_000_000_000, - // Deterministic — pick the first element so the test is not flaky. random: () => 0, sessionId: "session1", spawnDetached: () => {}, @@ -150,12 +143,112 @@ describe("buddy comment Stop hook", () => { expect(result.source).toBe("fallback"); expect(result.updated).toBe(true); expect(result.comment).toBeString(); - expect(result.comment?.length ?? 0).toBeGreaterThan(0); - const onDisk = JSON.parse( - readFileSync(join(stateDir, "reaction.session1.json"), "utf8"), - ); + 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 index 462a8d5..0f0bfe6 100755 --- a/server/hooks/buddy-comment.ts +++ b/server/hooks/buddy-comment.ts @@ -1,6 +1,6 @@ #!/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 { @@ -40,7 +40,10 @@ interface ReactionFile { const BUDDY_COMMENT_PATTERN = //g; -/** Provenance of the reaction the hook wrote (matches server/state.ts). */ +/** + * 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 { @@ -60,23 +63,21 @@ export function extractBuddyComment(message: string): string { } /** - * Conservative freshness window for an existing `buddy_react` MCP tool - * reaction. If the file on disk still has `source: "tool"` and is younger - * than this, the tool call is authoritative — the Stop hook must not - * clobber it. Tuned to comfortably outlive the default 30 s cooldown. + * 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 TOOL_REACTION_FRESHNESS_MS = 60_000; +const FUTURE_TIMESTAMP_TOLERANCE_MS = 60_000; function pickTurnFallback( species: string, runtime: HookRuntime, ): string | undefined { - // Mirror the upstream hook convention (file-type-react / mood-react / - // react): pick from reaction-data.ts directly rather than calling - // server/reactions.ts getReaction, which is reserved for the MCP - // server's tool-call path. The hook context doesn't carry the - // stat-modifier / rarity-flair inputs and trying to bridge those types - // adds complexity for no observable gain on a fallback pool pick. + // 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); @@ -89,13 +90,60 @@ function readSpeciesFromStatus(stateDir: string): string { : "blob"; } -function isFreshToolReaction(reactionPath: string, nowMs: number): boolean { +/** 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) return false; - if (existing.source !== "tool") return false; + if (!existing || existing.source !== "tool") return false; const ts = typeof existing.timestamp === "number" ? existing.timestamp : 0; - const age = nowMs - ts; - return age >= 0 && age < TOOL_REACTION_FRESHNESS_MS; + 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( @@ -117,39 +165,27 @@ export function handleBuddyComment( 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 spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime); - // ─── Bookkeeping: runs on every assistant message, regardless of source ── - - const eventsFile = join(stateDir, "events.json"); - const events = readJsonFile(eventsFile) ?? {}; - events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1; - mkdirSync(stateDir, { recursive: true }); - writeFileSync(eventsFile, JSON.stringify(events, null, 2)); - spawnDetached("server/award-xp.ts", ["turn"]); - spawnDetached("server/consolidate.ts", [ - assistantMessage, - stringField(input, "last_user_message"), - ]); - - // ─── Don't clobber a fresh buddy_react MCP-tool reaction ───────────────── - // The tool fires DURING the turn; this hook fires AFTER. The model- - // authored line written by the tool is authoritative — leave it alone. - - if (isFreshToolReaction(reactionPath, now)) { + // ─── 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 only ──────────────────────── - + // ─── 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; @@ -164,20 +200,34 @@ export function handleBuddyComment( comment = fallback; source = "fallback"; } else { + atomicWriteTimestamp(stopMarkerFile, now); return { source: "none", updated: false }; } } - writeFileSync(cooldownFile, String(Math.floor(now / 1000))); - writeFileSync( - reactionPath, - JSON.stringify({ - reaction: comment, - timestamp: now, - reason: "turn", - source, - }), - ); + mkdirSync(stateDir, { recursive: true }); + + // 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; + 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"]); + spawnDetached("server/consolidate.ts", [ + assistantMessage, + stringField(input, "last_user_message"), + ]); + atomicWriteTimestamp(stopMarkerFile, now); return { comment, source, updated: true }; } 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 7f85614..55dcb39 100644 --- a/server/hooks/reaction-data.ts +++ b/server/hooks/reaction-data.ts @@ -1919,8 +1919,6 @@ export const DEFAULT_REACTION_POOLS: Readonly> = { "large-diff": [ "that's... a lot of changes.", "*counts lines* are you refactoring or rewriting?", - "might want to split that PR.", - "*nervous laughter* {lines} lines changed.", "bold move. let's see if CI agrees." ], "success": [ 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 0db0191..99b4610 100644 --- a/server/state.ts +++ b/server/state.ts @@ -314,11 +314,17 @@ export function loadReaction(): ReactionState | null { export function saveReaction( reaction: string, reason: string, - source: ReactionSource = "tool", + source: ReactionSource = "fallback", ): void { mkdirSync(STATE_DIR, { recursive: true }); const state: ReactionState = { reaction, timestamp: Date.now(), reason, source }; - writeFileSync(reactionFile(), JSON.stringify(state)); + // 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 ───────────────────────────────────────────────────── @@ -472,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.