Skip to content

fix(server): make buddy_react the primary end-of-turn reaction channel - #159

Merged
ramarivera merged 3 commits into
mainfrom
fix/react-channel
Jul 26, 2026
Merged

fix(server): make buddy_react the primary end-of-turn reaction channel#159
ramarivera merged 3 commits into
mainfrom
fix/react-channel

Conversation

@ramarivera

@ramarivera ramarivera commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Fixes #154. Addresses #124. Supersedes #127.

Problem

Reactions on Claude Code came through an invisible HTML comment (<!-- buddy: ... -->) that the Stop hook extracted. Two failures:

  1. It is no longer invisible. Claude Code v2.1.169+ renders HTML comments in the transcript, so users see the raw marker in every assistant message (Per-turn <!-- buddy: --> comment now renders visibly in Claude Code v2.1.169+ #124, reported by @k-g-j, reproduced locally). A Stop hook cannot fix this — Stop hooks run after the message is rendered and have no channel to edit displayed text. The trick is architecturally dead.
  2. It was unreliable even when it worked — it only fired if the model remembered to emit the comment, with no signal when it didn't. Meanwhile the buddy_react MCP tool existed and was reachable, but the instructions explicitly said "Do NOT use buddy_react for end-of-turn comments".

Change

Three-source chain, in order:

  1. skip if a fresh source: "tool" reaction is already on disk (≤60s)
  2. legacy <!-- buddy: --> extraction → tagged source: "comment" (kept for older Claude Code)
  3. canned pool → tagged source: "fallback"

buddy_react is now the documented primary path: it is a real tool call, renders nothing in the transcript, and is inherently model-authored. ReactionSource is persisted and buddy_stats shows it, so it is possible to prove a reaction was model-written rather than canned.

On #127

Rejected as-is, with its good idea kept. #127 leaves the HTML comment primary and adds a canned-pool branch when no comment is found — that hides the visible marker but trades away model-authored quality, which is the more valuable half. The stop-hook canned fallback is worth keeping, so it is here, demoted to last resort.

Verification

bun test 364 pass / 0 fail; bun run typecheck clean. Three-source-chain coverage added to server/hooks/buddy-comment.test.ts.

Needs human verification before release

No agent can confirm Claude Code actually emits buddy_react under the new instructions — that requires a live session with the package installed. Post-merge check: /buddy stats should report Source: tool, not fallback.

🤖 This content was generated with AI assistance using Claude Opus 5.


Open in Devin Review

Note

Make buddy_react the primary end-of-turn reaction channel with provenance tracking

  • Replaces the HTML comment convention with an explicit buddy_react MCP tool call as the primary end-of-turn reaction mechanism; the Stop hook now acts as a fallback only.
  • All reaction files now include a source field ("tool", "comment", "fallback", or "none") written atomically via tmp-file rename in server/state.ts.
  • The Stop hook in server/hooks/buddy-comment.ts preserves tool-authored reactions written in the same turn, falls back to a canned pool when no comment is present, and rejects implausibly future-dated tool timestamps.
  • server/index.ts instructions and the buddy_prompt resource are rewritten to direct models to call buddy_react instead of appending HTML comments.
  • Behavioral Change: writeStatusState no longer implicitly calls saveReaction, so status writes no longer clobber existing reaction provenance or content.

Macroscope summarized be30027.

…annel

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 <!-- buddy: ... --> 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 '<!-- buddy: ... -->' 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.
Copilot AI review requested due to automatic review settings July 26, 2026 10:58
@github-project-automation github-project-automation Bot moved this to Todo in Kanban Jul 26, 2026
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2b4da0d7-802f-474b-b4c7-1f181d1e1e62

📥 Commits

Reviewing files that changed from the base of the PR and between f23064b and be30027.

📒 Files selected for processing (16)
  • README.md
  • server/hooks/buddy-comment.test.ts
  • server/hooks/buddy-comment.ts
  • server/hooks/file-type-react.test.ts
  • server/hooks/file-type-react.ts
  • server/hooks/mood-react.test.ts
  • server/hooks/mood-react.ts
  • server/hooks/name-react.test.ts
  • server/hooks/name-react.ts
  • server/hooks/react.test.ts
  • server/hooks/react.ts
  • server/hooks/reaction-data.ts
  • server/index.ts
  • server/state.test.ts
  • server/state.ts
  • skills/buddy/SKILL.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/react-channel

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread server/hooks/reaction-data.ts Outdated
"might want to split that PR.",
"bold move. let's see if CI agrees.",
"*counts lines nervously*"
"*nervous laughter* {lines} lines changed.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Buddy speech bubble shows the raw text "{lines}" instead of the number of changed lines

A new large-diff reaction is added with a {lines} fill-in slot (server/hooks/reaction-data.ts:1923) that the code displaying it never fills in, so when this line is picked the buddy's bubble shows the literal text {lines} instead of the real change count.

Impact: On big diffs the user sometimes sees a broken-looking message reading "{lines} lines changed" rather than an actual number.

Why the placeholder leaks: hooks reaction path never substitutes {lines}

The hooks reaction data in server/hooks/reaction-data.ts is consumed by server/hooks/react.ts. When a large diff is detected, react.ts picks a line from the large-diff pool via pickReaction. Although classifyToolResponse computes the line count into classification.lines (server/hooks/react.ts:93-95), the handler only reads files/branch (server/hooks/react.ts:279-280) and only substitutes {files} and {branch} (server/hooks/react.ts:381-382). There is no {lines} replacement, so the newly added "*nervous laughter* {lines} lines changed." renders literally.

Note: the MCP-server copy core/reactions.ts:614 does replace {lines} via context.lines, but the hooks copy does not — before this PR the hooks large-diff pool contained no {lines} placeholder, so this is the first time the placeholder can leak on the hook path.

Prompt for agents
The large-diff reaction pool in server/hooks/reaction-data.ts now includes the line "*nervous laughter* {lines} lines changed." which contains a {lines} placeholder. The hook consumer server/hooks/react.ts substitutes {files} and {branch} (around lines 381-382) but never substitutes {lines}, even though classifyToolResponse already captures the count into classification.lines (around lines 93-95, and the type field is declared at line 38). As a result the literal text {lines} will be shown in the buddy speech bubble when this reaction is selected. Fix by wiring the captured lines value through handleReact (read classification?.lines alongside files/branch near lines 279-280) and adding a substitution like: if (lines) reaction = reaction?.replace("{lines}", lines); next to the existing {files}/{branch} replacements. Mirror how core/reactions.ts:614 already handles this placeholder.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread server/hooks/buddy-comment.ts Outdated
Comment on lines 124 to 135
// ─── Bookkeeping: runs on every assistant message, regardless of source ──

const eventsFile = join(stateDir, "events.json");
const events = readJsonFile<Events>(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"),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Bookkeeping (turn count, XP award, memory consolidation) now runs on every turn regardless of cooldown or reaction outcome

Previously events.turns++, spawnDetached("server/award-xp.ts", ["turn"]), and spawnDetached("server/consolidate.ts", ...) only ran when an HTML comment was extracted AND the cooldown had elapsed. After this change (server/hooks/buddy-comment.ts:124-135), they run on every assistant message before the fresh-tool skip and cooldown checks. This means: (1) the turns stat and turn XP are now incremented on every turn, no longer rate-limited by the 30s cooldown, so XP can accrue faster during rapid exchanges; and (2) consolidate.ts is spawned as a detached process on every turn instead of only when a bubble was written. The explicit comment and the added test (server/hooks/buddy-comment.test.ts:122-126) indicate this decoupling is intentional, but it is a meaningful behavioral/resource change worth confirming, especially the unbounded per-turn spawning of consolidate and the removal of cooldown gating on XP.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread server/hooks/buddy-comment.ts Outdated
Comment on lines +92 to +99
function isFreshToolReaction(reactionPath: string, nowMs: number): boolean {
const existing = readJsonFile<ReactionFile>(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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Fresh-tool skip relies on identical reaction file path between MCP server and Stop hook

The don't-clobber logic reads reaction.${sid}.json (server/hooks/buddy-comment.ts:118, isFreshToolReaction), where sid comes from resolveHookSessionId (CLAUDE_CODE_SESSION_ID first 8 chars / TMUX_PANE / "default"). The MCP buddy_react tool writes via saveReaction -> reactionFile() in server/state.ts:56-66, which derives the session id identically. I verified these match, so the skip works. Worth noting the coupling is implicit: if either session-id derivation diverges (e.g. env var differences between the MCP server process and the hook process), the hook would clobber fresh tool reactions and the primary channel guarantee would silently break.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR changes the end-of-turn “buddy reaction” mechanism to use the buddy_react MCP tool as the primary channel (instead of the legacy <!-- buddy: ... --> HTML comment), adds persisted reaction provenance (tool/comment/fallback/none), and updates the Stop hook to use a three-stage fallback chain while exposing provenance in /buddy stats.

Changes:

  • Add ReactionSource provenance to persisted reaction state and surface it in /buddy stats.
  • Update model/system instructions to use buddy_react as the required end-of-turn path and discourage HTML comment emission.
  • Update Stop hook to (1) skip if a fresh source:"tool" reaction exists, (2) extract legacy HTML comment, else (3) pick a canned “turn” fallback reaction.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
server/state.ts Adds persisted reaction provenance and extends saveReaction signature to include source.
server/index.ts Updates prompting/instructions, records provenance, and surfaces provenance in buddy_stats.
server/hooks/reaction-data.ts Tweaks reaction pools and adds a turn pool for Stop-hook fallback.
server/hooks/buddy-comment.ts Implements three-stage Stop-hook fallback chain with provenance tagging.
server/hooks/buddy-comment.test.ts Adds/updates tests for provenance and tool-reaction “do not clobber” behavior.
README.md Updates docs to describe the new primary buddy_react channel and provenance.
Comments suppressed due to low confidence (1)

server/index.ts:334

  • buddy_react persists the reaction with the correct reason and source: "tool", but the subsequent writeStatusState(companion, comment, ...) re-saves the reaction via saveReaction(comment, "mcp"), overwriting the just-written reason/timestamp (and potentially source depending on defaults). That will make /buddy stats show Reason: mcp instead of the actual trigger.
    saveReaction(comment, reason ?? "turn", "tool");
    incrementEvent("reactions_given", 1, activeSlot());

    const newAch = checkAndAward(activeSlot());
    const achName = newAch.length > 0 ? newAch[0].icon + " " + newAch[0].name : undefined;

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/index.ts
);
saveReaction(reaction, "pet");
saveReaction(reaction, "pet", "fallback");
writeStatusState(companion, reaction);
Comment thread server/state.ts Outdated
Comment on lines 314 to 321
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));
Comment thread server/hooks/reaction-data.ts Outdated
"might want to split that PR.",
"bold move. let's see if CI agrees.",
"*counts lines nervously*"
"*nervous laughter* {lines} lines changed.",
Comment thread README.md
- **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.
Comment thread README.md
```

- **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.
Comment thread server/index.ts Outdated
`- *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.`,
Comment thread server/index.ts Outdated
Comment on lines +1358 to +1367
`\`\`\``,
"",
"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 <!-- buddy: ... -->",
`- Do NOT explain the call. Just call it and display the result.`,
@ramarivera

Copy link
Copy Markdown
Owner Author

Decision on F6 (tool call vs plaintext): tool call, and stop echoing it

Maintainer decision, from a live Claude Code session.

Observed behaviour: the reaction currently appears verbatim in the response body AND again in the statusline — duplicated. The desired behaviour is statusline only.

Key fact that resolves the contradiction: tool call outputs are not displayed in this Claude Code setup. The user does not see buddy_react's result at all. So the tool call genuinely is the invisible channel — the premise of this PR is correct.

What leaks into the transcript is not the tool call. It is the instruction telling the model to print the reaction in prose.

Required

  1. Keep buddy_react as the primary end-of-turn channel. Confirmed as the right design.
  2. Remove every instruction telling the model to display/echo the reaction, including:
    • server/index.ts:120 and :1376 — "Display the tool result verbatim"
    • skills/buddy/SKILL.md:89 — same instruction
    • any remaining directive to append <!-- buddy: ... --> to responses
  3. Keep the "renders nowhere in the user's transcript" claim — it is accurate for tool calls here — but make it accurate about the tool call specifically, and make the docs, the MCP prompt, and SKILL.md agree.
  4. The reaction should reach the user only via the statusline/widget, never via response text.

The tool result string itself ((◕‿◕) Nimbus: "...") can stay as a return value — it just must never be instructed to be reproduced in the response body.

Everything else in the review (F1 freshness window, F2 clock skew, F3 source default, F4 provenance, F5 XP rate limit, F7 {lines}) stands as blocking.

🤖 This content was generated with AI assistance using Claude Opus 5.

…ision)

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).
…ce tagging)

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.<sid>' 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.
@ramarivera

Copy link
Copy Markdown
Owner Author

Review round complete — all findings addressed

Verified by reading the code, not the worker's report.

Finding Status
F1 CRITICAL — 60s wall-clock freshness discarded buddy_react output on long turns TOOL_REACTION_FRESHNESS_MS removed entirely, replaced by a per-turn sentinel — the fix Opus specified, not a bigger number
F2 HIGH — future-dated timestamps treated as stale and clobbered age >= 0 gone
F3 HIGH — saveReaction defaulted to "tool", overwriting explicit "fallback" source: ReactionSource = "fallback"
F4 — pool writes had no provenance ✅ tagged
F5 — XP/turn counting and 2 detached spawns ran before the cooldown gate ✅ both now behind it, with a comment explaining why
F6 — instructions contradicted the PR's premise ✅ resolved per maintainer decision: statusline-only, all echo instructions removed
F7{lines} placeholder the consumer cannot substitute
F9 — non-atomic writes on a contended file atomicWriteJson / atomicWriteTimestamp

bun test: 369 pass / 0 fail (up from 364 — five new regression tests covering a turn longer than the old freshness window, a future-dated timestamp, the writeStatusState source round trip, and cooldown-gated bookkeeping).

Note on process

An earlier fix commit for F1–F5 was silently lost from this branch and the suite stayed green with all three critical bugs present — because no test covered long turns, clock skew, or the source round trip. Those tests now exist and were written to fail first. A green suite on this PR previously proved nothing; it does now.

🤖 This content was generated with AI assistance using Claude Opus 5.

@ramarivera
ramarivera merged commit 124a097 into main Jul 26, 2026
4 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Kanban Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reactions are canned pools, not inference: the contextual <!-- buddy: --> channel is never triggered

2 participants