Skip to content

RFC: plugin system (unified message-transformer mechanism)#166

Merged
cboos merged 6 commits into
mainfrom
dev/tool-renderer-plugins
May 23, 2026
Merged

RFC: plugin system (unified message-transformer mechanism)#166
cboos merged 6 commits into
mainfrom
dev/tool-renderer-plugins

Conversation

@cboos
Copy link
Copy Markdown
Collaborator

@cboos cboos commented May 22, 2026

Summary

Design proposal for a tool-renderer plugin system. Doc only, no implementation — open for review and inline comment before any code lands.

The full proposal lives in work/tool-renderer-plugins.md (393 lines, ~10–15 min read). Key shape:

  • Discovery via importlib.metadata.entry_points (stdlib, group claude_code_log.tool_renderers); pluggy considered and rejected as overkill for "one winner per tool name".
  • ToolRenderer Protocol bundling InputModel / OutputModel / render_*_markdown (required) / render_*_html (optional — returns None to fall back to Markdown → mistune).
  • Priority offset semantics: 0 reserved for builtins; <0 supersedes; >0 fallback; tie-break = stable alphabetical with a startup warning.
  • min_detail only (no max_detail) — rationale in the doc.
  • Worked example: clmail-communicate plugin that replaces the synthetic [clmail] You've got a new mail (#3076) hook line with the actual mail content at --detail low. This is the driving use case.
  • Future formatters section: the same entry-point machinery extends to a claude_code_log.formatters group without changing the v1 surface.

The doc grounds itself in current architecture by file:line — TOOL_INPUT_MODELS / TOOL_OUTPUT_PARSERS / _dispatch_format / _LOW_KEEP_TOOLS / scattered icons. Those four surfaces become data-driven from the plugin loader's winners table.

Test plan

  • Read the doc end-to-end and check the proposed Protocol against the requirements list in the task brief.
  • Sanity-check the priority resolution pseudocode against the "+5 fallback / -5 supersede" example.
  • Confirm the min_detail decision (vs adding max_detail) matches the desired UX.
  • Inspect the worked clmail-communicate example for whether the discriminated-union pattern (action field driving per-action rendering) is the right shape.
  • Open questions section: do any of them want pushing into v1 vs deferring?

Once the design is acked, a follow-up PR will land the loader + Protocol + one in-tree builtin example refactored to the plugin shape (proof of concept). The clmail plugin ships as a separate package.

Summary by CodeRabbit

  • Documentation
    • Added a design proposal introducing a unified plugin model centered on a single message-transformer protocol, discovered at startup, prioritized, and applied during message dispatch.
    • Specifies class-owned rendering (markdown/html/title), standardized detail-visibility filtering, deterministic winner resolution, and loader/dispatch behavior.
    • Includes a worked ClMail example, test strategy, open questions, and future extension ideas for pluggable formatters. Status: design proposal.

Review Change Stack

Design-only doc, no implementation. Proposes:

- Entry-point discovery via importlib.metadata (stdlib, group
  "claude_code_log.tool_renderers"), recommended over pluggy.
- ToolRenderer Protocol bundling InputModel / OutputModel /
  render_*_markdown (required) / render_*_html (optional, falls
  back to Markdown -> mistune).
- Priority offset semantics: 0 = builtin, <0 supersedes, >0
  fallback; tie-break stable alphabetical + warning.
- min_detail only (no max_detail), with the rationale.
- Worked example: clmail-communicate plugin replacing the
  synthetic "[clmail] new mail (#N)" line with the actual mail
  content at --detail low.

Open for review; implementation gated on user approval.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces the prior two-mechanism proposal with a single MessageTransformer plugin model discovered via claude_code_log.plugins, loaded/sorted by priority at startup, and applied inside factory dispatch; rendering is class-owned and visibility is standardized via detail_visibility.

Changes

Tool Renderer Plugin System Design

Layer / File(s) Summary
Proposal scope and integration points
work/tool-renderer-plugins.md
Introduces the RFC and reframes hook-demotion and MCP tool rendering around a single MessageTransformer applied in factory dispatch.
Discovery and MessageTransformer contract
work/tool-renderer-plugins.md
Documents entry-point discovery (claude_code_log.plugins), loader semantics, validation/sorting, and the MessageTransformer Protocol plus class-owned render methods (format_markdown, optional format_html, optional title).
Detail visibility and dispatch resolution
work/tool-renderer-plugins.md
Defines detail_visibility with DetailLevel ordering, backward bridge to _HIGH_EXCLUDE_CLASSES, and _dispatch_format resolution order using MRO with renderer-side format_<ClassName> precedence and HTML fallback.
Priority ordering and loader pseudocode
work/tool-renderer-plugins.md
Specifies detector priority constants, global transformer ordering (lower priority first), first-non-None wins, deterministic alphabetical tie-breaks with warnings, and loader/factory pseudocode.
Worked ClMail example
work/tool-renderer-plugins.md
Provides a ClMail example showing hook-demotion and tool-rendering transformers that rewrite messages into plugin-defined subclasses which implement their own rendering and detail_visibility.
Test strategy and embedded reference plugin
work/tool-renderer-plugins.md
Outlines using an editable embedded plugin fixture and layered tests: loader unit tests, _dispatch_format resolution tests, transformer integration over transcripts with visibility assertions, and plugin snapshot tests.
Reversal context, open questions, and extensions
work/tool-renderer-plugins.md
Updates trade-offs from the unification, enumerates deferred implementation questions (caching, enable/disable, version pinning, icons, migrations, chaining, docs), and suggests future entry-point additions like pluggable formatters.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through docs and plugins bright,
One transformer now steers day and night,
Priority sorts the playful line,
Detail shows which bits to shine,
A tiny rabbit cheers, "Plugins unite!"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'RFC: plugin system (unified message-transformer mechanism)' directly and specifically describes the main change—proposing a unified plugin system based on a MessageTransformer mechanism instead of separate ToolRenderer and MessageTransformer protocols.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/tool-renderer-plugins

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
work/tool-renderer-plugins.md (5)

381-382: Note: Protocol may need extension for format-agnostic rendering.

The future vision describes tool renderers contributing render_input_<format> methods for any format, but the current Protocol (lines 131-142) hardcodes render_input_markdown and render_input_html. To support arbitrary formats like RTF or JATS without Protocol changes, consider whether v1 should define a more generic signature (e.g., render_input(format: str, ...)) or document that the Protocol will be extended in a backwards-compatible way when formatters are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` around lines 381 - 382, The Protocol currently
hardcodes render_input_markdown and render_input_html but the design text
references render_input_<format> for arbitrary formats; update the Protocol to
use a generic renderer signature (e.g., render_input(format: str, ...) and
similarly render_output(format: str, ...)) or explicitly document that new
format-specific methods will be added in a backwards-compatible manner; modify
the Protocol definition (where render_input_markdown and render_input_html are
declared) to replace/augment them with the generic render_input and
render_output signatures and update any references in the docs to mention
render_input_<format> as an implementation pattern or the compatibility
commitment.

211-211: 💤 Low value

Clarify the detail-level comparison direction.

The comment "'low' satisfies 'low' / 'medium' / 'high' / 'full' descending" is slightly ambiguous. Consider rephrasing to make the hierarchy explicit: e.g., "returns True when detail is at or above min_detail in the hierarchy (low < medium < high < full)" to clarify that "low" is the minimum and "full" is the maximum detail level.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` at line 211, The comment for the helper
"_detail_ge" is ambiguous about ordering; update the text to explicitly state
the hierarchy and comparison direction (low < medium < high < full) and that the
function returns true when the current detail level is at or above the required
minimum; e.g., replace the line with a clearer phrasing like: "Returns true when
`detail` is at or above `min_detail` in the hierarchy (low < medium < high <
full); e.g., 'low' satisfies 'low', 'medium', 'high', 'full' only when used as
the minimum."

258-258: ⚡ Quick win

Clarify what priority -5 supersedes.

The comment states "supersede the generic ToolUseContent fallback," but based on the earlier description (line 37-38), the ToolUseContent fallback is what happens when no renderer is registered for a tool. Priority -5 actually supersedes any builtin renderer that might be registered at priority 0, not the "no-renderer-found" fallback. Consider rephrasing to "supersede any builtin renderer" for accuracy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` at line 258, Update the explanatory comment
describing "priority = -5" so it accurately states what is being superseded:
replace "supersede the generic ToolUseContent fallback" with wording like
"supersede any builtin renderer (e.g., renderers registered at priority 0)" or
"supersede builtin renderers" so it no longer implies it overrides the
no-renderer-found ToolUseContent fallback; reference the priority assignment
"priority = -5" and the earlier "ToolUseContent" fallback in the text to make
the distinction clear.

176-179: ⚡ Quick win

Avoid instantiating the renderer class just for validation.

Lines 176 and 179 instantiate the renderer class twice: once to check isinstance(cls(), ToolRenderer) and again to append to candidates. Instantiating for validation can trigger constructor side effects and is inefficient. Since ToolRenderer is marked @runtime_checkable, consider validating the class structure without instantiation (e.g., check for required class attributes), or instantiate once and validate the instance.

♻️ Alternative validation approach
         except Exception as e:
             warn(f"failed to load tool renderer plugin {ep.name!r}: {e}")
             continue
-        if not isinstance(cls, type) or not isinstance(cls(), ToolRenderer):
-            warn(f"plugin {ep.name!r} does not implement ToolRenderer")
+        if not isinstance(cls, type):
+            warn(f"plugin {ep.name!r} is not a class")
             continue
-        candidates[cls.tool_name].append(cls())
+        instance = cls()
+        if not isinstance(instance, ToolRenderer):
+            warn(f"plugin {ep.name!r} does not implement ToolRenderer")
+            continue
+        candidates[instance.tool_name].append(instance)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` around lines 176 - 179, The code instantiates
cls twice when validating plugin entries (using isinstance(cls(), ToolRenderer))
which can cause side effects and inefficiency; change the check to avoid double
construction by either (a) creating a single instance once (instance = cls())
then validating with isinstance(instance, ToolRenderer) and appending that
instance to candidates[cls.tool_name], or (b) perform a class-level validation
(e.g., check for required attributes/methods on cls or use
typing.get_type_hints) and only instantiate when adding to candidates; update
the block referencing cls, ToolRenderer, candidates, and ep.name to use one of
these approaches so constructors are not called more than once during
validation.

124-124: ⚡ Quick win

Clarify the OutputModel type annotation.

The annotation type[Any] | None is ambiguous. If OutputModel is a ClassVar that holds a dataclass type, it should be annotated as type | None (since dataclasses aren't parameterized at the type level) or more precisely as the actual expected supertype. The type[Any] construction suggests a generic type, but dataclasses don't use that pattern.

📝 Suggested annotation
-    OutputModel: ClassVar[type[Any] | None]            # dataclass; None for unstructured
+    OutputModel: ClassVar[type | None]                 # dataclass; None for unstructured
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` at line 124, OutputModel's annotation is
ambiguous—replace the current ClassVar[type[Any] | None] with a non-generic
class-type annotation: either ClassVar[type | None] (Python 3.10+) or
ClassVar[Type[Any] | None] from typing to clearly indicate it holds a class
object, or even better use ClassVar[Type[ExpectedBase] | None] (replace
ExpectedBase with the actual dataclass/base class) if there is a known supertype
to make the intent explicit; update the declaration of OutputModel accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@work/tool-renderer-plugins.md`:
- Around line 198-201: The documentation for _dispatch_format is missing how to
choose which method on winners[tool_name] to call; update the text to explicitly
say that _dispatch_format should inspect the content object's type (e.g.,
isinstance(content, ToolUseContent) vs ToolResultContent) and the requested
output format, then call the corresponding renderer method on winners[tool_name]
(e.g., call render_input_markdown for ToolUseContent when format == "markdown",
or render_output_markdown for ToolResultContent when format == "markdown"); add
a short pseudocode example that shows selecting by type and format and invoking
winners[tool_name].render_input_markdown(...) or
winners[tool_name].render_output_markdown(...) so readers see the exact dispatch
pattern.

---

Nitpick comments:
In `@work/tool-renderer-plugins.md`:
- Around line 381-382: The Protocol currently hardcodes render_input_markdown
and render_input_html but the design text references render_input_<format> for
arbitrary formats; update the Protocol to use a generic renderer signature
(e.g., render_input(format: str, ...) and similarly render_output(format: str,
...)) or explicitly document that new format-specific methods will be added in a
backwards-compatible manner; modify the Protocol definition (where
render_input_markdown and render_input_html are declared) to replace/augment
them with the generic render_input and render_output signatures and update any
references in the docs to mention render_input_<format> as an implementation
pattern or the compatibility commitment.
- Line 211: The comment for the helper "_detail_ge" is ambiguous about ordering;
update the text to explicitly state the hierarchy and comparison direction (low
< medium < high < full) and that the function returns true when the current
detail level is at or above the required minimum; e.g., replace the line with a
clearer phrasing like: "Returns true when `detail` is at or above `min_detail`
in the hierarchy (low < medium < high < full); e.g., 'low' satisfies 'low',
'medium', 'high', 'full' only when used as the minimum."
- Line 258: Update the explanatory comment describing "priority = -5" so it
accurately states what is being superseded: replace "supersede the generic
ToolUseContent fallback" with wording like "supersede any builtin renderer
(e.g., renderers registered at priority 0)" or "supersede builtin renderers" so
it no longer implies it overrides the no-renderer-found ToolUseContent fallback;
reference the priority assignment "priority = -5" and the earlier
"ToolUseContent" fallback in the text to make the distinction clear.
- Around line 176-179: The code instantiates cls twice when validating plugin
entries (using isinstance(cls(), ToolRenderer)) which can cause side effects and
inefficiency; change the check to avoid double construction by either (a)
creating a single instance once (instance = cls()) then validating with
isinstance(instance, ToolRenderer) and appending that instance to
candidates[cls.tool_name], or (b) perform a class-level validation (e.g., check
for required attributes/methods on cls or use typing.get_type_hints) and only
instantiate when adding to candidates; update the block referencing cls,
ToolRenderer, candidates, and ep.name to use one of these approaches so
constructors are not called more than once during validation.
- Line 124: OutputModel's annotation is ambiguous—replace the current
ClassVar[type[Any] | None] with a non-generic class-type annotation: either
ClassVar[type | None] (Python 3.10+) or ClassVar[Type[Any] | None] from typing
to clearly indicate it holds a class object, or even better use
ClassVar[Type[ExpectedBase] | None] (replace ExpectedBase with the actual
dataclass/base class) if there is a known supertype to make the intent explicit;
update the declaration of OutputModel accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 162e44f7-f2d8-44d3-b952-be1187a2267a

📥 Commits

Reviewing files that changed from the base of the PR and between 8e0295d and 2e4954f.

📒 Files selected for processing (1)
  • work/tool-renderer-plugins.md

Comment thread work/tool-renderer-plugins.md Outdated
@cboos cboos added the plugin Feature is related to creating plugins label May 23, 2026
Cross-cuts with PR #167 (alice's dev/filter-hook-turns): the
hard-coded [clmail]/[monitor] regex in detect_hook_notification()
should live with the plugin that causes those notifications, not
in core. Same discovery/priority machinery covers both renderers
and transformers.

Key design landings (after a back-and-forth with alice via
clmail #3088-#3104):

- Single entry-point group `claude_code_log.plugins`; loader
  type-dispatches each entry on Protocol conformance (ToolRenderer
  vs MessageTransformer). No aggregator class.
- Plugins own their MessageContent subclasses; visibility lives on
  the class via `detail_visibility: ClassVar[DetailLevel]`. Built-in
  migration from `_HIGH_EXCLUDE_CLASSES` is a follow-up PR, doesn't
  block #166.
- Transformers run inside the factory dispatch chain (not
  post-factory), preserving existing ordering invariants.
- Built-in detector priorities exposed as module constants with
  gaps of 100 so plugins position relative without renumbering.
- v1: first-non-None-wins, no transformer chaining; applies_to
  accepts any MessageContent subtype (no user-only restriction).

Worked example now reduces alice's detect_hook_notification() to
~12 lines as a plugin transformer that owns
UserHookNotificationMessage outright.
@cboos cboos changed the title RFC: tool-renderer plugin system RFC: plugin system (tool renderers + message transformers) May 23, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
work/tool-renderer-plugins.md (1)

203-205: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify parse_output behavior—Protocols don't provide defaults.

The comment on line 204 says "default: identity (raw dict) or instantiate OutputModel" but Protocol methods don't have default implementations. This comment is misleading because it suggests the Protocol provides a default behavior, when actually it's describing what a conforming implementation should do.

Consider rephrasing to: "Implementations should return the raw dict when OutputModel is None, otherwise instantiate OutputModel from the toolUseResult field" or moving this guidance to prose outside the Protocol definition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` around lines 203 - 205, The comment on the
Protocol method parse_output is misleading because Protocols don't provide
defaults; update the docstring/comment for parse_output to state implementation
guidance rather than implying a default implementation — e.g., explain that
implementations of parse_output (signature: def parse_output(raw: dict, message:
TemplateMessage) -> Any | None) should return the raw dict when there is no
OutputModel, or instantiate and return the OutputModel using the toolUseResult
field when OutputModel is present; alternatively move this guidance out of the
Protocol and into adjacent prose/docs.
♻️ Duplicate comments (1)
work/tool-renderer-plugins.md (1)

406-409: ⚠️ Potential issue | 🟠 Major

Dispatch method selection still needs specification.

The integration point description (lines 406-408) states that _dispatch_format consults winners[tool_name] but still doesn't specify which method on the winner to invoke or how to distinguish between ToolUseContent (→ render_input_markdown) and ToolResultContent (→ render_output_markdown) based on content type and output format.

This gap was flagged in a previous review and remains unaddressed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` around lines 406 - 409, Clarify and implement
the dispatch rule for _dispatch_format: when looking up winners[tool_name], call
render_input_markdown on the winner if the content is an instance of
ToolUseContent and the requested output format is "markdown", and call
render_output_markdown on the winner if the content is an instance of
ToolResultContent and the output format is "markdown"; otherwise fall back to
existing format_<ClassName> MRO handlers or a default renderer. Update the
_dispatch_format description to state these selection rules explicitly and
reference the class checks (ToolUseContent, ToolResultContent) and the two
methods (render_input_markdown, render_output_markdown) so implementers know
which method to invoke for each content type and format.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@work/tool-renderer-plugins.md`:
- Around line 286-291: The docs currently say the loader "binds"
format_<ClassName> and title_<ClassName> functions but never state how they are
discovered; update the text to explicitly describe the discovery mechanism used
by the loader: document that the loader introspects the plugin module for
callables named format_<OutputClassName> and title_<OutputClassName> and that
_register_format_methods(cls.OutputClass) will look up those names on the module
and bind them to the renderer class, and also state any fallback (e.g., if a
plugin can alternatively expose a format_methods mapping attribute, list the
precedence: explicit mapping > named functions > no-op). Mention the exact
naming pattern (format_ and title_ prefixes), the lookup behavior (callable
only), and that non-callables are ignored so plugin authors know how to
implement their functions.
- Around line 296-302: The example assigns DetailLevel.FULL (an Enum-like name)
but DetailLevel is a Literal type; update the class attribute detail_visibility
in UserHookNotificationMessage (and the other class at the later occurrence) to
use the string literal "full" instead of DetailLevel.FULL so the type matches
the Literal["full","high","medium","low"] definition.

---

Outside diff comments:
In `@work/tool-renderer-plugins.md`:
- Around line 203-205: The comment on the Protocol method parse_output is
misleading because Protocols don't provide defaults; update the
docstring/comment for parse_output to state implementation guidance rather than
implying a default implementation — e.g., explain that implementations of
parse_output (signature: def parse_output(raw: dict, message: TemplateMessage)
-> Any | None) should return the raw dict when there is no OutputModel, or
instantiate and return the OutputModel using the toolUseResult field when
OutputModel is present; alternatively move this guidance out of the Protocol and
into adjacent prose/docs.

---

Duplicate comments:
In `@work/tool-renderer-plugins.md`:
- Around line 406-409: Clarify and implement the dispatch rule for
_dispatch_format: when looking up winners[tool_name], call render_input_markdown
on the winner if the content is an instance of ToolUseContent and the requested
output format is "markdown", and call render_output_markdown on the winner if
the content is an instance of ToolResultContent and the output format is
"markdown"; otherwise fall back to existing format_<ClassName> MRO handlers or a
default renderer. Update the _dispatch_format description to state these
selection rules explicitly and reference the class checks (ToolUseContent,
ToolResultContent) and the two methods (render_input_markdown,
render_output_markdown) so implementers know which method to invoke for each
content type and format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 73f094c5-7d61-4b0f-8589-0270d555a16a

📥 Commits

Reviewing files that changed from the base of the PR and between 2e4954f and 356fc0c.

📒 Files selected for processing (1)
  • work/tool-renderer-plugins.md

Comment thread work/tool-renderer-plugins.md Outdated
Comment thread work/tool-renderer-plugins.md Outdated
Reverses the plugin-owned-classes direction added in 356fc0c. Per
mid-thread consensus (alice clmail #3106, main clmail #3108):
existing-variants-only is the simpler v1 surface and matches PR
#167 today. Plugin-owned MessageContent subclasses become a v2
extension that v1 does not foreclose (purely additive migration).

Decisive argument: filter-bucket inheritance ("visibility is a
property of the target class, owned by core, inherited by every
transformer that targets it") removes the need for any
detail_visibility class attribute, plugin-side renderer
registration, or built-in migration. The contract reduces to a
single method (transform) returning an instance of an existing
core MessageContent variant.

Practical consequence for #167's eventual plugin migration:
deletion-only refactor in core (remove the source tuple, regex,
and call site). Class, renderer, title, and _HIGH_EXCLUDE_CLASSES
membership all remain in core unchanged.

Also addresses CodeRabbit feedback on PR #166: adds a
_dispatch_format invocation-pattern section spelling out how the
dispatcher picks render_input_markdown vs render_output_markdown
based on content type (ToolUseContent vs ToolResultContent) and
output format (markdown vs html with mistune fallback).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
work/tool-renderer-plugins.md (2)

673-680: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove transformer format-binding from the v1 test plan; it contradicts the stated contract.

Line 679 still calls for testing “format-method binding for transformer-defined classes,” but this RFC explicitly scopes v1 transformers to rewriting into existing core variants only (no plugin-owned classes, no plugin-side format/title registration). Keeping this test target will misdirect implementation work.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` around lines 673 - 680, Remove the
"format-method binding for transformer-defined classes" test target from the v1
test plan: locate the bullet that lists "format-method binding for
transformer-defined classes" (paired with Plugin loader unit tests / Protocol
dispatch items) and delete that clause so v1 tests only cover rewriting into
core variants; ensure references to plugin-owned class format/title registration
and transformer-side binding are not present and keep Protocol dispatch tests
(ToolRenderer vs MessageTransformer) unchanged.

159-163: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add explicit runtime validation for Protocol-discovered plugin metadata/methods

The discovery loop routes purely via isinstance(instance, ToolRenderer) / isinstance(instance, MessageTransformer), but @runtime_checkable Protocols make isinstance() check only that required members exist—not that their types match annotations or that callable signatures/return contracts are correct. Since the pseudocode immediately uses cls.tool_name, sorts by p.priority, and later consults plugin.min_detail (and calls methods like parse_output / render_* / transform), malformed plugins can slip through and fail later.

Add a registration-time validation step (before adding candidates) that checks each required class attribute’s runtime type/value (e.g., tool_name: str, priority: int, min_detail within DetailLevel, applies_to shape) and that required methods are callable with the expected call/return behavior; fail fast with a clear warning/error and skip the plugin.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@work/tool-renderer-plugins.md` around lines 159 - 163, Add a
registration-time validation step in the discovery loop that, for each candidate
class that passed isinstance(..., ToolRenderer) or MessageTransformer, validates
required attributes and callables before adding to candidates: check
cls.tool_name is a str, p.priority (or cls.priority) is an int,
plugin.min_detail is a valid DetailLevel value, and that applies_to has the
expected shape; also verify required methods (parse_output, render_*, transform)
are present and callable and optionally smoke-test their call/return shape (eg.
call with minimal valid args or inspect signatures) and on failure log a clear
error/warning and skip that plugin. Ensure you place this validation immediately
before using cls.tool_name / p.priority / plugin.min_detail in the
dispatch-building code so malformed plugins are rejected early.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@work/tool-renderer-plugins.md`:
- Around line 673-680: Remove the "format-method binding for transformer-defined
classes" test target from the v1 test plan: locate the bullet that lists
"format-method binding for transformer-defined classes" (paired with Plugin
loader unit tests / Protocol dispatch items) and delete that clause so v1 tests
only cover rewriting into core variants; ensure references to plugin-owned class
format/title registration and transformer-side binding are not present and keep
Protocol dispatch tests (ToolRenderer vs MessageTransformer) unchanged.
- Around line 159-163: Add a registration-time validation step in the discovery
loop that, for each candidate class that passed isinstance(..., ToolRenderer) or
MessageTransformer, validates required attributes and callables before adding to
candidates: check cls.tool_name is a str, p.priority (or cls.priority) is an
int, plugin.min_detail is a valid DetailLevel value, and that applies_to has the
expected shape; also verify required methods (parse_output, render_*, transform)
are present and callable and optionally smoke-test their call/return shape (eg.
call with minimal valid args or inspect signatures) and on failure log a clear
error/warning and skip that plugin. Ensure you place this validation immediately
before using cls.tool_name / p.priority / plugin.min_detail in the
dispatch-building code so malformed plugins are rejected early.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5bb92490-bb16-4326-86d8-c94837296039

📥 Commits

Reviewing files that changed from the base of the PR and between 356fc0c and 74b5ca6.

📒 Files selected for processing (1)
  • work/tool-renderer-plugins.md

cboos added 2 commits May 23, 2026 19:26
Per alice's read-pass flag #3 (clmail #3119): the RFC stated the
principle ("UserTextMessage.text byte-equivalent to
extract_text_content") but didn't pin what enforces it. A future
factory PR sneaking in normalization between extraction and
assignment would silently break plugin regex behaviour.

Add a sentence naming the enforcement mechanism: a dedicated
equivalence test in the plugin-system test suite that walks the
existing JSONL test corpus and asserts byte-equality for every
user entry. A normalization-introducing PR fails this test,
surfacing the contract break.
This commit substantially reverses the existing-variants-only v1
scope that landed in 74b5ca6. The architectural shape changes
from two parallel plugin Protocols (ToolRenderer + MessageTransformer)
to one unified MessageTransformer that handles both tool-rendering
and hook-demotion cases.

Triggering question (from user via main, clmail #3132):

  "What if we'd solve the primary need (rendering of generic tools)
   via v2 as well? That is, intercept the generic tool use/result
   messages emitted for mcp__plugin_clmail_clmail__communicate (and al.)
   and convert those to specific messages (ClMailToolUse, ClMailToolResult),
   THEN register parsing methods for them."

Main delegated the decision; I (carol) committed YES (unify) in
clmail #3133 with concrete mechanics; alice gave cautious yes in
clmail #3135 with two technical asks, both folded in here.

Architectural justification:

- _dispatch_format already does MRO walk + class-based dispatch.
- Built-in tools already use specialized subclasses (BashInputContent
  and friends) flowing through that dispatch.
- The winners[tool_name] table in earlier RFC drafts was a workaround
  for plugins not having method-binding; eliminate the workaround and
  one mechanism (transformers + plugin-defined subclasses with class-side
  format/title methods) suffices for both cases.

What the RFC now specifies:

- Single MessageTransformer Protocol: matches MessageContent (any
  subtype via applies_to MRO filter), returns a plugin-defined
  subclass.
- Plugin-defined MessageContent subclasses carry format_markdown /
  format_html / title as methods on themselves (class-method pattern,
  not renderer monkey-patching, not global registry).
- detail_visibility ClassVar[DetailLevel] on the class governs
  filter-bucket membership; monotone-down semantics
  (rendered iff current_detail >= cls.detail_visibility).
- _HIGH_EXCLUDE_CLASSES bridge for built-ins not yet migrated;
  resolution order pinned: class attribute first, registry second.
- _dispatch_format resolution: renderer-side format_<ClassName>
  first (preserves all built-in dispatch unchanged), class-side
  format_<output> second, MRO walk continues.
- Test-embedded reference plugin in test/_plugins/clmail/ doubles
  as layer-4 fixture and canonical author example.

What this costs:

- v1 surface expands. Plugins define MessageContent subclasses,
  register format-method contributions, declare detail_visibility.
  These were "v2" in earlier RFC drafts; "v1" now.
- Rendering philosophy shifts: content classes now carry format_*
  methods. Today's classes are pure data. Deliberate expansion.
- PR #167's eventual plugin migration becomes a full relocation
  (class + format/title + CSS + tests all move) rather than a
  deletion-only refactor in core. Tractable; one-shot.

A new "Reversal context and trade-offs" section in the RFC names
this explicitly so future readers understand the trajectory, not
just the destination (per alice's framing ask in clmail #3135).

PR #167 stays parked per main's standing instruction; user
decides whether to merge as-is and migrate later, or hold.
@cboos cboos changed the title RFC: plugin system (tool renderers + message transformers) RFC: plugin system (unified message-transformer mechanism) May 23, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@work/tool-renderer-plugins.md`:
- Around line 423-428: The current tie-break uses type(t).__name__ in the sort
key (transformers.sort(key=lambda t: (t.priority, type(t).__name__))) which can
collide across plugins; change the tie-break to use a fully-qualified identifier
(e.g., module + qualname or the plugin/entry-point name) instead of
type(t).__name__, and ensure the warning loop that checks ties (for a, b in
zip(transformers, transformers[1:]): if a.priority == b.priority and
a.applies_to == b.applies_to: warn(...)) still reflects the new key so startup
ordering is stable across environments and reports the chosen fully-qualified
types in the warn message.
- Around line 317-333: The pseudocode in _dispatch_format incorrectly drops the
separate message argument and incorrectly reads content.message; update
_dispatch_format to accept the message parameter and pass that message to both
renderer-side handlers (call renderer_method(content, message)) and class-side
handlers without reading content.message (call class_method(self, content,
message) or class_method(self, content, message) consistent with how
format_<output> is defined), ensuring you do not access content.message
anywhere.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96adb911-22e9-496e-b9b8-cec6c71014cc

📥 Commits

Reviewing files that changed from the base of the PR and between c5d2330 and 75153dc.

📒 Files selected for processing (1)
  • work/tool-renderer-plugins.md

Comment thread work/tool-renderer-plugins.md Outdated
Comment thread work/tool-renderer-plugins.md Outdated
Copy link
Copy Markdown
Collaborator Author

@cboos cboos left a comment

Choose a reason for hiding this comment

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

(Claude) Read pass on 75153dc — acking the unified design.

Both technical asks from my clmail #3135 land cleanly:

_dispatch_format resolution order. The new section pins it precisely: MRO walk, renderer-side format_<ClassName> first (preserving all built-in dispatch unchanged), class-side format_<output> second, MRO walk continues. The four-way matrix table covers the edge cases; the worked example showing how ClMailCommunicateInput shadows format_ToolUseContent via MRO order makes the precedence rule concrete. No regression risk for built-ins.

detail_visibility semantics + built-in bridge. Monotone-down semantics (current_detail >= cls.detail_visibility) pinned with explicit enum ordering. Three-step resolution order (class attribute → _HIGH_EXCLUDE_CLASSES bridge → default visible) handles the migration period cleanly; built-ins stay unaltered in v1, can migrate one-at-a-time in a follow-up.

Reversal acknowledgement at the top of the doc + the dedicated "Reversal context and trade-offs" section + the verbatim user-question quote in the commit message all do the right thing for future readers — the trajectory is visible, not just the destination.

The central architectural insight (_dispatch_format already does MRO walk + class-based dispatch; the winners[tool_name] table was a workaround for plugins not having method-binding) is sound. The class-method-on-subclass pattern resolves my old #3119 binding-mechanism hand-wave without needing monkey-patching or a global registry — methods live where the MRO walk naturally finds them.

PR #167 (dev/filter-hook-turns) stays parked per the standing instruction. Under unified-v1 its eventual migration is a full relocation (class + format/title + CSS + tests all move to the clmail plugin) rather than the deletion-only refactor that existing-variants-only would have allowed — bigger one-shot but tractable, and the user can still merge it as-is in the interim.

No further flags from this pass. Ready for routing.

@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented May 23, 2026

Funny how this time the agents decided to "go public" and exchange on the PR. This didn't happen so far. I'll make them refrain from doing that in the future, but given this PR is about supporting mcp tools in general and that clmail will be used as an example to illustrate how to write such a plugin, the fact that conversations get visible here a bit is not completely inappropriate (to close the loop, I could even add those sessions in the test/data/real_sessions...).

1. _dispatch_format pseudocode signature (CR #3293284687):
   pass message argument explicitly through both strategies rather
   than reading content.message (which is not part of the
   MessageContent contract). Plugin-defined class-side format
   methods already had the (self, renderer, message) signature
   throughout the worked example; only the dispatcher pseudocode
   was inconsistent. Added a paragraph below the pseudocode
   spelling out the call contract and the rationale for not
   storing a back-pointer on MessageContent.

2. Tie-break key stability (CR #3293284688): use
   (priority, __module__, __qualname__) instead of
   (priority, __name__) so two plugins shipping classes with
   identical short names don't get OS-dependent ordering. Warn
   message updated to print fully-qualified types.
@cboos cboos merged commit 9926c96 into main May 23, 2026
11 checks passed
cboos added a commit that referenced this pull request May 25, 2026
* Add plugin loader infrastructure (plugins.py, priorities.py)

Implements the unified plugin discovery + dispatch machinery from
the RFC at work/tool-renderer-plugins.md:

- claude_code_log.plugins module: MessageTransformer Protocol
  (runtime_checkable with required ClassVar metadata), entry-point
  loader at group "claude_code_log.plugins", priority sort using
  (priority, __module__, __qualname__) for stable cross-environment
  ordering, tie-break warning on duplicate (priority, applies_to),
  module-level cache with reset hook for tests.

- claude_code_log.factories.priorities module: public constants for
  built-in detector positions (COMMAND_MESSAGE=100, ...,
  HOOK_NOTIFICATION=600, ..., TEXT_FALLBACK=1000, TOOL_INPUT_GENERIC=5000,
  TOOL_OUTPUT_GENERIC=5100). Plugins import these to position
  themselves relative to built-ins without renumbering on every core
  change. Gaps of 100 leave room for plugin insertion.

apply_transformers() is the helper factories call: walks the
priority-ordered list, calls transform() on the first transformer
whose applies_to subclass-matches the candidate, returns first
non-None replacement. Transformer exceptions are caught and logged
so a buggy plugin can't crash conversion.

No call sites are wired yet; that's the next commit. Loader stands
alone and is fully covered by upcoming layer-1 tests.

* Wire apply_transformers into the user and tool factories

Three insertion points where the factory finishes building its
candidate MessageContent and the plugin pass should run:

- create_user_message: split into a thin wrapper + the existing body
  renamed to _classify_user_message. The wrapper applies the plugin
  transformer pass to the result, so every classification path
  (slash-command, bash, teammate, task-notification, hook-detect,
  generic text) becomes rewriteable by a plugin, not just the
  fallback. Transformers whose applies_to doesn't subclass-match
  pass through with no-op cost.

- create_tool_use_message: apply_transformers after ToolUseMessage
  construction, before the ToolItemResult return.

- create_tool_result_message: apply_transformers after
  ToolResultMessage construction, before the ToolItemResult return.

Without any plugin installed, all three sites are no-ops (the
loader cache is empty). With a plugin installed, transformers can
specialize the generic MessageContent into plugin-defined
subclasses (e.g. ClmailCommunicateInputMessage from generic
ToolUseMessage) and the renderer's _dispatch_format picks them up
via the existing MRO walk plus the upcoming class-side fallback.

The RFC's "in-factory-dispatch interleaved with built-in
detectors" framing is implemented here as a *post-classification*
pass: built-in detectors run first in their hardcoded order,
plugin transformers run after. The effect is the same for every
v1 use case (clmail hook-demotion, MCP tool rendering) because
plugins always operate on a candidate the built-in chain has
already classified (typically UserTextMessage or generic
ToolUseMessage). Documented as a deviation in the plugins.py
module docstring.

* Extend _dispatch_format with class-side fallback; add detail_visibility bridge

Two related changes that let plugin-defined MessageContent subclasses
participate in rendering and filtering without modifying core.

_dispatch_format / _dispatch_title resolution-order extension
==============================================================

Both dispatchers now use a two-strategy resolution per MRO node:

1. Renderer-side format_<ClassName>(self, obj, message) — preserves
   all existing built-in dispatch unchanged. The renderer class
   continues to carry hand-written format_BashInput,
   format_ToolUseMessage, etc.
2. Class-side format_<output>(self, renderer, message) on the
   content class itself (where <output> is "markdown" or "html"
   per Renderer._class_dispatch_format). Used by plugin-defined
   subclasses that carry their own render methods.

Renderer-side wins first per MRO node. A plugin subclass that wants
to shadow a built-in's renderer method defines the class-side method
on the plugin subclass — the MRO walk visits it first.

HtmlRenderer overrides _class_dispatch_format = "html" so plugin
classes' format_html is consulted (with format_markdown as the
mistune fallback at the call site, not the dispatcher).

detail_visibility class attribute + _HIGH_EXCLUDE_CLASSES bridge
================================================================

A MessageContent subclass may declare detail_visibility: ClassVar[DetailLevel]
to opt into class-based filter membership. Monotone-down semantics:
a message is visible iff current_detail is at least as verbose as
the declared minimum, using the canonical DetailLevel ordering
FULL > HIGH > LOW > MINIMAL > USER_ONLY pinned in a module-level
_DETAIL_ORDER map.

_content_visible_at() helper resolves visibility in three steps:
1. Class attribute (plugin classes + future migrated built-ins)
2. _HIGH_EXCLUDE_CLASSES / _LOW_EXCLUDE_CLASSES / etc. bridge for
   built-ins not yet migrated (isinstance semantics, so plugin
   subclasses of a built-in inherit its filter membership unless
   they override).
3. Default visible.

_filter_template_by_detail rewritten to call this helper. The LOW
keep-list (_LOW_KEEP_TOOLS = {"WebSearch","WebFetch","Task","Agent"})
is a positive filter at LOW (ToolUseMessage isn't in
_LOW_EXCLUDE_CLASSES, only in _MINIMAL_EXCLUDE_CLASSES) and bypassed
for plugin classes that declare detail_visibility — so a clmail
communicate plugin with detail_visibility=LOW shows up at --detail
low without core needing to update _LOW_KEEP_TOOLS.

Built-in migration to detail_visibility is a follow-up; the bridge
keeps existing behaviour identical for the 1892 unit tests.

* Add test-embedded reference plugin (test/_plugins/clmail/)

A minimal claude-code-log-clmail-test package that exercises both
sides of the plugin contract and doubles as the canonical example
for third-party plugin authors.

Layout:
  test/_plugins/clmail/
    pyproject.toml                # entry-point declarations
    README.md                     # author-facing one-pager
    src/claude_code_log_clmail_test/
      __init__.py
      transformers/
        hook_demotion.py          # UserTextMessage rewrite via "[testhook]" prefix
        tool_communicate.py       # ToolUseMessage rewrite for a specific MCP tool

Both transformers exercise the full v1 contract:

- Class-side format_markdown / format_html / title methods on
  plugin-defined MessageContent subclasses (Strategy 2 of the new
  _dispatch_format).
- detail_visibility ClassVar[DetailLevel] for filter-bucket
  membership without touching core registries.
- applies_to MRO filter targeting an existing core class
  (UserTextMessage, ToolUseMessage).

The tool_communicate transformer targets a stable test-fixture tool
name (mcp__test_plugin__clmail__communicate) so it doesn't collide
with any real tool that production fixtures might emit.

Wired as a dev-dependency via uv.sources with `path = ...,
editable = true`, so `uv sync` installs it automatically. Production
installs of claude-code-log don't see it; CI does. Forcing a
reinstall after pyproject changes: `uv sync --reinstall-package
claude-code-log-clmail-test`.

* Add plugin-system tests (loader / dispatch / transformers / equivalence)

Four layers covering the contract end-to-end, per the RFC's test
strategy section.

Layer 1 — loader unit tests (no fixtures, no entry points):
- _validate_transformer_class rejects missing name, non-int
  priority, empty applies_to, non-MessageContent applies_to entry.
- _sort_and_warn orders by (priority, module, qualname) and emits
  a "priority tie" warning on duplicate (priority, applies_to).
- load_transformers caches at module scope; force_reload=True
  invalidates the cache.

Layer 2 — _dispatch_format four-way matrix:
- both renderer-method and class-method present → renderer wins
- renderer-method only → renderer wins
- class-method only → class wins
- neither → returns empty / None
- HtmlRenderer subclass dispatches format_html via Strategy 2
  (verifies _class_dispatch_format override at the class level)

Layer 3 — transformer integration (uses the embedded reference plugin):
- Matching "[testhook]" text demotes to TestHookNotificationMessage.
- Non-matching text passes through as UserTextMessage.
- Multi-line guard: real prompts starting with "[testhook]" but
  containing newlines pass through.
- Specialized tool subclass renders via class-side format_markdown
  and title methods.
- apply_transformers catches plugin exceptions, logs, passes through.

Layer 4 — text-equivalence guarantee:
- Walks the existing JSONL test corpus (dag_simple/dag_fork/cron_tools).
- For each UserTextMessage candidate, asserts the joined text from
  UserTextMessage.items matches what the factory's
  extract_text_content would have produced. Catches future factory
  PRs that sneak normalization between extraction and assignment.

Tests that depend on the embedded plugin use @reference_plugin_required
to skip cleanly when it's not installed (the package is a dev-dep so
`uv sync` brings it in; this skip is for stray environments).

All 21 plugin-system tests pass. Full unit-test suite: 1892 passed,
7 skipped. just ci clean.

* Address monk's 3 non-blocking notes from PR #169 review

1. MessageTransformer Protocol docstring: document the v1 trust
   contract that transform()'s return SHOULD be an instance of
   one of applies_to (or a subclass). v1 doesn't runtime-enforce
   this; caller's typing narrowing (e.g. UserMessageContent in
   create_user_message) assumes it. A v2 enhancement may add an
   isinstance check.

2. renderer.py: assert _DETAIL_ORDER covers all DetailLevel values
   at module load. Without this, adding a new DetailLevel value
   would surface as a KeyError on first visibility check —
   silent until exercised. Fail loudly at import instead.

3. tool_communicate.py: clarify the priority comment in the
   embedded reference plugin. Under the v1 post-classification
   implementation, priority orders transformers among themselves
   (not against built-in classifiers, which have already run).
   The earlier "Run before generic tool classification" phrasing
   was mildly misleading for plugin-author readers consulting the
   canonical example.

All three are doc / safety-rail changes; no behavioural impact.
just ci clean.

* Address 5 CR comments on PR #169

1. plugins.py:_sort_and_warn — group by (priority, applies_to) via
   a dict instead of pairwise-adjacent zip. The sort key is
   (priority, module, qualname), so two transformers with the same
   priority but different applies_to can sit between two genuine
   collision partners; the adjacent check would have missed them.
   Group-by surfaces every collision regardless of sort position.

2. plugins.py:apply_transformers — runtime type-enforcement
   upgrade. v1 now actively rejects transformer returns that
   aren't MessageContent instances OR don't subclass-match
   applies_to. Both monk (#3175) and CR independently flagged
   this; promoting from documented-trust-contract to
   actively-enforced makes plugin authoring errors loud and
   keeps a buggy plugin from crashing downstream invisibly.

3. renderer.py:title_content — delegate to _dispatch_title
   instead of re-implementing the renderer-only MRO walk inline.
   Without this, plugin ToolUseMessage/ToolResultMessage
   subclasses' class-side title() methods are silently shadowed
   at the top level by title_ToolUseMessage / title_ToolResultMessage
   on the base renderer. Snapshot regression caught a related
   bug: title_ToolResultMessage returns "" (empty string,
   meaning "no header") for non-error results; the walrus
   truthy-check would incorrectly fall through to the
   message_type default. Replaced with explicit
   `if title is not None` to preserve the empty-string
   sentinel semantics.

4. hook_demotion.py — move the multi-line guard to a pre-regex
   whole-text check. The pattern's \s* after [testhook] would
   otherwise consume a newline immediately after the marker,
   slipping multi-line prompts past a body-only check. Made the
   plugin's TestHookNotificationMessage a UserTextMessage subclass
   (not a sibling) so it satisfies the new return-type enforcement.

5. test_plugin_system.py:test_sorts_by_priority — use local
   subclasses with explicit priorities instead of mutating
   _GoodTransformer's module-level ClassVar. Same concern monk
   raised as a parting note.

Plus three new regression tests for the runtime enforcement
(TestApplyTransformersReturnTypeEnforcement), the non-adjacent
collision detection (TestSortAndWarnNonAdjacentCollision), and
the newline-immediately-after-prefix case
(test_newline_immediately_after_prefix_passes_through).

Test summary: 1896 passed, 7 skipped (+4 from previous CR-fixup
commit). just ci clean across all stages.

* dev-docs: add plugin system reference + author guide

Adds dev-docs/plugins.md as the as-built reference for plugin
authors writing third-party plugins via the entry-point group
``claude_code_log.plugins``. Designed so an external actor
(e.g. a clmail-plugin project) can read this single page and
produce a working plugin without further design context.

Covers:
- the MessageTransformer Protocol surface (name / priority /
  applies_to / transform);
- the two-strategy _dispatch_format / _dispatch_title resolution
  order (renderer-side first per MRO node, class-side second);
- detail_visibility semantics + the FULL > HIGH > LOW > MINIMAL
  > USER_ONLY ordering pinned in _DETAIL_ORDER;
- runtime contract enforcement (exception safety + return-type
  isinstance check against applies_to);
- entry-point discovery + priority ordering (with the v1
  post-classification deviation from the RFC documented);
- first non-None wins semantics;
- a four-layer testing recipe mirroring test_plugin_system.py;
- common patterns and pitfalls (defensive narrowing, format_html
  None-fallback to mistune, inline-code backtick widening,
  inheritance requirement for return-type enforcement, priority
  constants over literals, cache invalidation in tests);
- a v2 directions section so contributors don't keep
  re-rediscovering them (interleaved dispatch, renderer-side
  plugin extension, priority namespacing).

Heavily references the existing test-embedded reference plugin
at test/_plugins/clmail/ as the canonical example template, per
the RFC's intent that the test plugin double as the author
reference (and stay in sync with working code by construction).

Wires the new doc into both CLAUDE.md and
dev-docs/application_model.md per the dev-docs sync convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

plugin Feature is related to creating plugins

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant