Skip to content

Add --detail user-only level#118

Merged
cboos merged 5 commits intomainfrom
dev/detail-user-only
Apr 26, 2026
Merged

Add --detail user-only level#118
cboos merged 5 commits intomainfrom
dev/detail-user-only

Conversation

@cboos
Copy link
Copy Markdown
Collaborator

@cboos cboos commented Apr 19, 2026

Fifth detail level inspired by #26 ("have claude to collect all stated requirements into a formal requirements.md file"): a terser output than --detail minimal that keeps only content the user authored, so the transcript can be fed directly to a downstream agent that extracts requirements, builds a changelog-by-intent, etc.

Survivors at --detail user-only

  • UserTextMessage — real user prompts
  • UserSteeringMessagequeue-operation 'remove' entries whose payload carries user precisions ("actually, wait — use Postgres not MySQL")
  • SessionHeaderMessage — orientation markers so per-session boundaries remain visible

Everything else drops via _USER_ONLY_EXCLUDE_CLASSES = (*_MINIMAL_EXCLUDE_CLASSES, AssistantTextMessage): assistant text, thinking, tool_use/tool_result, bash input/output, slash commands, command output, compacted summaries, memory, hooks, system.

Level ordering

As documented in DetailLevel: full > high > low > minimal > user-only (smallest output last). Steering is user-authored content and now survives at every non-FULL level — each level keeps at least what the smaller level keeps.

Output filename

Automatic — variant_suffix() already concatenates the enum value, so the new variant lands as *.user-only.html / *.user-only.md. Covered by TestVariantSuffix.

UserSteeringMessage retention across levels (hierarchy fix)

_filter_by_detail used to drop every QueueOperationTranscriptEntry before the post-render filter got to look at it, silently removing UserSteeringMessage from HIGH/LOW/MINIMAL output. The post-render exclude chains (_HIGH/_LOW/_MINIMAL/_USER_ONLY_EXCLUDE_CLASSES) never listed UserSteeringMessage, so this was the only thing filtering it out.

Fix: _filter_by_detail now allows QueueOperationTranscriptEntry at every non-FULL level unconditionally. Steering is user-authored content — every view that claims to preserve the user's side of the conversation keeps it.

TestSteeringHierarchy is parametrised over HIGH/LOW/MINIMAL/USER_ONLY to lock the behaviour.

String-content queue-operation fix

QueueOperationTranscriptEntry.content is Optional[Union[list[ContentItem], str]]. Two pre-render sites (_filter_messages line 1904, session-info collection line 2161) were coercing non-list content to [], silently dropping steering that arrives as a plain string.

Fix: new _queue_op_content_as_list helper wraps non-empty strings in a single TextContent so downstream filters see a uniform list. TestSteeringStringContent covers the regression.

Index enumeration fix (regex)

VARIANT_ENTRY_RE in utils.py used \.[a-z]+ per suffix segment, rejecting the hyphen in user-only. The .user-only.html file was written to disk and picked up by _enumerate_project_variants' glob, but then silently dropped by the regex.

Fix: [a-z]+[a-z-]+ (hyphen at end of class, literal). Covered by TestVariantEntryRegex::test_accepts_entry_points (two new param cases) and TestEnumerateProjectVariants::test_lists_all_variants_default_first (now plants a user-only fixture file).

UI label polish

_variant_label_from_suffix(".user-only") now renders as "User only" (space) rather than "User-only" (hyphen). The hyphenated form is kept for the CLI flag, enum value, and on-disk filename — only the human-facing label is polished.

Tests

  • TestUserOnlyTemplateMessages (6 tests): assistant-text drop, steering retention, tools/thinking/bash/slash drop, sidechain drop, session-header preservation, size-vs-MINIMAL invariant.
  • TestUserOnlyCli: CLI accepts --detail user-only and emits *.user-only.html.
  • TestMinimalTemplateMessages::test_minimal_keeps_user_steering: MINIMAL path for steering retention.
  • TestSteeringHierarchy (parametrised over HIGH/LOW/MINIMAL/USER_ONLY): hierarchy consistency.
  • TestSteeringStringContent: string-content queue-operation produces a UserSteeringMessage.
  • TestVariantSuffix: USER_ONLY suffix correct for html + md.
  • TestVariantEntryRegex::test_accepts_entry_points: hyphenated suffix cases.
  • TestEnumerateProjectVariants::test_lists_all_variants_default_first: user-only fixture + rendered-label assertion.

Full just test-equivalent run (skipping pre-existing pagination SQLite failures in my local env): 929 passed, 7 skipped. ruff format + ruff check + pyright clean on modified files.

Refs #26.

Summary by CodeRabbit

  • New Features

    • Added a new CLI detail level: --detail user-only to emit only user prompts and steering content (lowest verbosity).
  • Documentation

    • README updated to document the new user-only detail level and its position in the verbosity ordering.
  • Output

    • Generated output now includes a .user-only variant suffix and displays the hyphenated label as "User only".
  • Tests

    • Expanded test coverage for the new detail level and output filename/variant behavior.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 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

Adds a new user-only detail level and wires it through the CLI, enum, renderer filtering (including queue-op normalization), filename/variant handling, and tests so outputs can be generated containing only user prompts and steering content.

Changes

Cohort / File(s) Summary
Docs
README.md
Documented new user-only --detail choice and added it to the ordered detail-level list.
CLI
claude_code_log/cli.py
Extended --detail Click option to accept user-only and updated help text to describe user-only behavior.
Model
claude_code_log/models.py
Added USER_ONLY = "user-only" to DetailLevel and updated ordering docstring.
Rendering / Filtering
claude_code_log/renderer.py
Added _queue_op_content_as_list() to normalize queue-op content; updated message/template filtering so USER_ONLY shares MINIMAL's stripping rules plus excludes assistant text and sidechain messages; allowlist for preserved entry types and explicit handling to preserve queue-op entries verbatim.
Filename / Variant Logic
claude_code_log/utils.py, claude_code_log/converter.py
Adjusted VARIANT_ENTRY_RE to accept hyphenated variant segments and updated _variant_label_from_suffix() to render hyphenated segments (e.g., user-onlyUser only) while preserving compact casing.
Tests
test/test_detail_levels.py, test/test_output_paths.py
Added extensive tests for USER_ONLY (filtering, steering preservation, queue-op normalization, sidechain removal) and updated output-path/variant tests to recognize .user-only suffix and expected label.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 I hopped through enums and nudged the CLI,
Kept only user whispers, let the rest slip by.
Queue-ops tucked in lists, sidechains out of sight,
Tests piled like carrots, tidy through the night.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding a new 'user-only' detail level to the --detail CLI option, which is the primary feature across all modified files.

✏️ 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/detail-user-only

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.

Caution

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

⚠️ Outside diff range comments (2)
claude_code_log/renderer.py (1)

1904-1907: ⚠️ Potential issue | 🟠 Major

Preserve string queue-operation remove steering payloads.

Line 2004 only helps queue operations that already survived _filter_messages, but Line 1904 treats string content as empty and drops it. Since the model allows QueueOperationTranscriptEntry.content to be a str, USER_ONLY can still lose real steering prompts.

🐛 Proposed fix
+def _queue_operation_content_items(
+    message: QueueOperationTranscriptEntry,
+) -> list[ContentItem]:
+    content = message.content
+    if isinstance(content, list):
+        return content
+    if isinstance(content, str) and content.strip():
+        return [TextContent(type="text", text=content)]
+    return []
+
+
 def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]:
@@
         message_content: list[ContentItem]
         if isinstance(message, QueueOperationTranscriptEntry):
-            content = message.content
-            message_content = content if isinstance(content, list) else []
+            message_content = _queue_operation_content_items(message)
         else:
             message_content = message.message.content
@@
         # Get message content
         if isinstance(message, QueueOperationTranscriptEntry):
-            message_content = message.content if message.content else []
+            message_content = _queue_operation_content_items(message)
         else:
             message_content = message.message.content  # type: ignore

Also applies to: 2004-2021

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 1904 - 1907, The code treats
QueueOperationTranscriptEntry.content strings as empty and drops steering
payloads; update the logic around QueueOperationTranscriptEntry handling (where
message_content is assigned) so that if content is a list it remains as-is, if
content is a str it is preserved by wrapping it into a single-element list
(e.g., message_content = content if isinstance(content, list) else [content] if
isinstance(content, str) else []), and apply the same fix to the similar block
later (the 2004-2021 region) so string `queue-operation remove` payloads are
retained.
test/test_output_paths.py (1)

50-55: ⚠️ Potential issue | 🔴 Critical

The regex pattern must also accept hyphens to match .user-only filenames.

Lines 54–55 test suffix generation correctly, but the discovery regex at claude_code_log/utils.py:44 uses [a-z]+ which rejects hyphens. The pattern currently fails to match combined_transcripts.user-only.html, causing the variant discovery in _enumerate_project_variants() to silently miss USER_ONLY outputs.

Two changes required:

  1. Update regex pattern to accept hyphens: change [a-z]+ to [a-z-]+ (or similar)
  2. Add test case to lines 77–87 to cover .user-only
Regex fix
-VARIANT_ENTRY_RE = re.compile(r"^combined_transcripts((?:\.[a-z]+)*)\.html$")
+VARIANT_ENTRY_RE = re.compile(r"^combined_transcripts((?:\.[a-z-]+)*)\.html$")
Test matrix addition
         [
             ("combined_transcripts.html", ""),
             ("combined_transcripts.low.html", ".low"),
             ("combined_transcripts.high.html", ".high"),
             ("combined_transcripts.low.compact.html", ".low.compact"),
             ("combined_transcripts.minimal.html", ".minimal"),
+            ("combined_transcripts.user-only.html", ".user-only"),
         ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_output_paths.py` around lines 50 - 55, The discovery regex used in
_enumerate_project_variants (the pattern defined in utils.py that currently uses
[a-z]+ to match variant suffixes) rejects hyphenated variants like "user-only";
update that pattern to accept hyphens (e.g., change [a-z]+ to [a-z-]+ or an
equivalent class) so filenames like combined_transcripts.user-only.html are
matched, and add a unit test in test_output_paths (the test matrix around the
existing cases) asserting that variant_suffix(DetailLevel.USER_ONLY, ...,
"html") and discovery recognise ".user-only" outputs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@claude_code_log/renderer.py`:
- Around line 1904-1907: The code treats QueueOperationTranscriptEntry.content
strings as empty and drops steering payloads; update the logic around
QueueOperationTranscriptEntry handling (where message_content is assigned) so
that if content is a list it remains as-is, if content is a str it is preserved
by wrapping it into a single-element list (e.g., message_content = content if
isinstance(content, list) else [content] if isinstance(content, str) else []),
and apply the same fix to the similar block later (the 2004-2021 region) so
string `queue-operation remove` payloads are retained.

In `@test/test_output_paths.py`:
- Around line 50-55: The discovery regex used in _enumerate_project_variants
(the pattern defined in utils.py that currently uses [a-z]+ to match variant
suffixes) rejects hyphenated variants like "user-only"; update that pattern to
accept hyphens (e.g., change [a-z]+ to [a-z-]+ or an equivalent class) so
filenames like combined_transcripts.user-only.html are matched, and add a unit
test in test_output_paths (the test matrix around the existing cases) asserting
that variant_suffix(DetailLevel.USER_ONLY, ..., "html") and discovery recognise
".user-only" outputs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b2bc18f9-44d1-4337-b3b3-e5df5021aa28

📥 Commits

Reviewing files that changed from the base of the PR and between 1dd392d and ff2fafd.

📒 Files selected for processing (6)
  • README.md
  • claude_code_log/cli.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • test/test_detail_levels.py
  • test/test_output_paths.py

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 (1)
claude_code_log/renderer.py (1)

1904-1907: ⚠️ Potential issue | 🟠 Major

Make steering retention complete and hierarchy-consistent.

QueueOperationTranscriptEntry.remove can carry steering as a plain string, but Line 1906 converts non-list content to [], so those valid steering messages are dropped before the new allowlist runs. Also, allowing queue operations only for MINIMAL/USER_ONLY makes MINIMAL contain user steering that LOW/HIGH still drop, which breaks the documented full > high > low > minimal > user-only ordering.

🐛 Proposed fix
-        if isinstance(message, QueueOperationTranscriptEntry):
-            content = message.content
-            message_content = content if isinstance(content, list) else []
+        if isinstance(message, QueueOperationTranscriptEntry):
+            content = message.content
+            if isinstance(content, list):
+                message_content = content
+            elif isinstance(content, str) and content.strip():
+                message_content = [TextContent(type="text", text=content)]
+            else:
+                message_content = []
         else:
             message_content = message.message.content
-    # MINIMAL and USER_ONLY keep queue-operation entries so their
+    # All non-FULL detail levels keep queue-operation entries so their
     # UserSteeringMessage survives — steering prompts carry real user
-    # intent and belong in any view that claims to preserve the user's
-    # side of the conversation. HIGH/LOW continue to drop them.
-    allowed_types: tuple[type, ...] = (UserTranscriptEntry, AssistantTranscriptEntry)
-    if detail in (DetailLevel.MINIMAL, DetailLevel.USER_ONLY):
-        allowed_types = (*allowed_types, QueueOperationTranscriptEntry)
+    # intent and belong in views that preserve the user's side of the
+    # conversation.
+    allowed_types: tuple[type, ...] = (
+        UserTranscriptEntry,
+        AssistantTranscriptEntry,
+        QueueOperationTranscriptEntry,
+    )
         if isinstance(message, QueueOperationTranscriptEntry):
-            message_content = message.content if message.content else []
+            content = message.content
+            if isinstance(content, list):
+                message_content = content
+            elif isinstance(content, str) and content.strip():
+                message_content = [TextContent(type="text", text=content)]
+            else:
+                message_content = []
         else:
             message_content = message.message.content  # type: ignore

Also applies to: 2004-2021, 2161-2166

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 1904 - 1907,
QueueOperationTranscriptEntry can carry steering as a plain string but the
current branch drops non-list content by setting message_content = []; change
that to preserve single-string steering by converting non-list content into a
single-element list (message_content = [content] when content is not None). Also
fix the retention allowlist logic so it respects the documented hierarchy (full
> high > low > minimal > user-only) by comparing retention levels ordinally
rather than checking equality for MINIMAL/USER_ONLY only — use the enum/order
comparison (e.g., retention_level >= MINIMAL or numeric enum comparison)
wherever queue operation allowance is decided (references:
QueueOperationTranscriptEntry, message_content, and the
retention_level/FULL/HIGH/LOW/MINIMAL/USER_ONLY checks in the
rendering/allowlist code).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@claude_code_log/renderer.py`:
- Around line 1904-1907: QueueOperationTranscriptEntry can carry steering as a
plain string but the current branch drops non-list content by setting
message_content = []; change that to preserve single-string steering by
converting non-list content into a single-element list (message_content =
[content] when content is not None). Also fix the retention allowlist logic so
it respects the documented hierarchy (full > high > low > minimal > user-only)
by comparing retention levels ordinally rather than checking equality for
MINIMAL/USER_ONLY only — use the enum/order comparison (e.g., retention_level >=
MINIMAL or numeric enum comparison) wherever queue operation allowance is
decided (references: QueueOperationTranscriptEntry, message_content, and the
retention_level/FULL/HIGH/LOW/MINIMAL/USER_ONLY checks in the
rendering/allowlist code).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 291c77f8-64f4-48ca-b941-22292f553933

📥 Commits

Reviewing files that changed from the base of the PR and between ff2fafd and e2ac6e5.

📒 Files selected for processing (2)
  • claude_code_log/renderer.py
  • test/test_detail_levels.py
✅ Files skipped from review due to trivial changes (1)
  • test/test_detail_levels.py

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 (1)
claude_code_log/renderer.py (1)

2111-2128: ⚠️ Potential issue | 🟠 Major

Scrub assistant-derived metadata for USER_ONLY.

Dropping AssistantTextMessage only after session metadata/header creation can still leave assistant-derived summaries, title text, token summaries, or empty assistant-only session headers in user-only output. Please add a USER_ONLY-specific scrub/drop step for SessionHeaderMessage/navigation metadata, or move assistant exclusion earlier while preserving any needed structural headers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 2111 - 2128, When
DetailLevel.USER_ONLY is selected, currently assistant-derived types are only
filtered later which can leave assistant-only session headers/metadata (e.g.,
SessionHeaderMessage, navigation/title/token summaries) in the output; update
the USER_ONLY branch to specifically remove or transform SessionHeaderMessage
and any assistant-derived metadata types before building headers by extending
_USER_ONLY_EXCLUDE_CLASSES or adding an explicit scrub step that drops instances
of SessionHeaderMessage, AssistantTextMessage, and any navigation/summary
message classes from the messages iterable (refer to DetailLevel,
_USER_ONLY_EXCLUDE_CLASSES, SessionHeaderMessage, AssistantTextMessage,
messages, and TemplateMessage) so assistant-only metadata never appears in
user-only outputs while preserving needed structural headers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@claude_code_log/renderer.py`:
- Around line 2111-2128: When DetailLevel.USER_ONLY is selected, currently
assistant-derived types are only filtered later which can leave assistant-only
session headers/metadata (e.g., SessionHeaderMessage, navigation/title/token
summaries) in the output; update the USER_ONLY branch to specifically remove or
transform SessionHeaderMessage and any assistant-derived metadata types before
building headers by extending _USER_ONLY_EXCLUDE_CLASSES or adding an explicit
scrub step that drops instances of SessionHeaderMessage, AssistantTextMessage,
and any navigation/summary message classes from the messages iterable (refer to
DetailLevel, _USER_ONLY_EXCLUDE_CLASSES, SessionHeaderMessage,
AssistantTextMessage, messages, and TemplateMessage) so assistant-only metadata
never appears in user-only outputs while preserving needed structural headers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f6ff615a-6f72-4c14-a39a-b598fde2481e

📥 Commits

Reviewing files that changed from the base of the PR and between 16596be and cad85a5.

📒 Files selected for processing (2)
  • claude_code_log/renderer.py
  • test/test_detail_levels.py
✅ Files skipped from review due to trivial changes (1)
  • test/test_detail_levels.py

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)
claude_code_log/renderer.py (2)

613-615: ⚠️ Potential issue | 🟠 Major

Prevent session summaries from leaking into user-only output.

USER_ONLY removes assistant messages, but session summaries are collected before detail filtering and later attached to surviving SessionHeaderMessages. That can still expose summary/assistant-derived text in output intended for downstream agents as user-authored content only.

🛡️ Proposed fix
-        session_summaries = prepare_session_summaries(messages)
+        session_summaries = (
+            {}
+            if detail == DetailLevel.USER_ONLY
+            else prepare_session_summaries(messages)
+        )

Also applies to: 2111-2113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 613 - 615, The session summaries
are generated from the full messages list before the USER_ONLY filter and so
assistant-derived content can be re-attached to SessionHeaderMessage outputs; to
fix, run prepare_session_summaries only after applying the USER_ONLY/detail
filtering (or pass the already-filtered messages into prepare_session_summaries)
so that session_summaries are derived solely from surviving user messages;
update the code around log_timing("Session summary processing", t_start) to call
prepare_session_summaries(messages_filtered) (or move the filtering earlier) and
ensure any attachment logic for SessionHeaderMessage uses the filtered set.

2181-2207: ⚠️ Potential issue | 🟡 Minor

Treat steering messages as user-authored session starters.

QueueOperationTranscriptEntry content is now normalized and rendered as UserSteeringMessage, but session preview/navigation still depends on as_user_entry(message). A session containing only steering content can be rendered while being skipped by prepare_session_navigation() because first_user_message remains empty.

🧭 Proposed fix
         text_content = extract_text_content(message_content)
+        is_user_authored = (
+            isinstance(message, QueueOperationTranscriptEntry)
+            or as_user_entry(message) is not None
+        )
 
         # Get session info
         session_id = getattr(message, "sessionId", "unknown")
@@
             # Get first user message content for preview
             first_user_message = ""
-            if as_user_entry(message) and should_use_as_session_starter(text_content):
+            if is_user_authored and should_use_as_session_starter(text_content):
                 first_user_message = create_session_preview(text_content)
@@
-        elif as_user_entry(message) and not sessions[session_id]["first_user_message"]:
+        elif is_user_authored and not sessions[session_id]["first_user_message"]:
             if should_use_as_session_starter(text_content):
                 sessions[session_id]["first_user_message"] = create_session_preview(
                     text_content
                 )

Also applies to: 2223-2228

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 2181 - 2207, The session-starter
logic currently only uses as_user_entry(message) so messages normalized from
QueueOperationTranscriptEntry into UserSteeringMessage are ignored; update the
check that decides first_user_message/session initialization to treat steering
messages as user-authored by either extending as_user_entry to return true for
QueueOperationTranscriptEntry or by checking message_content (from
_queue_op_content_as_list) for any UserSteeringMessage items and treating that
as a user entry; then call should_use_as_session_starter(text_content) and
create_session_preview(text_content) the same way for steering messages. Apply
the same change to the analogous block referenced around 2223-2228 so
steering-only sessions produce a preview and are included in
prepare_session_navigation().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@claude_code_log/renderer.py`:
- Around line 613-615: The session summaries are generated from the full
messages list before the USER_ONLY filter and so assistant-derived content can
be re-attached to SessionHeaderMessage outputs; to fix, run
prepare_session_summaries only after applying the USER_ONLY/detail filtering (or
pass the already-filtered messages into prepare_session_summaries) so that
session_summaries are derived solely from surviving user messages; update the
code around log_timing("Session summary processing", t_start) to call
prepare_session_summaries(messages_filtered) (or move the filtering earlier) and
ensure any attachment logic for SessionHeaderMessage uses the filtered set.
- Around line 2181-2207: The session-starter logic currently only uses
as_user_entry(message) so messages normalized from QueueOperationTranscriptEntry
into UserSteeringMessage are ignored; update the check that decides
first_user_message/session initialization to treat steering messages as
user-authored by either extending as_user_entry to return true for
QueueOperationTranscriptEntry or by checking message_content (from
_queue_op_content_as_list) for any UserSteeringMessage items and treating that
as a user entry; then call should_use_as_session_starter(text_content) and
create_session_preview(text_content) the same way for steering messages. Apply
the same change to the analogous block referenced around 2223-2228 so
steering-only sessions produce a preview and are included in
prepare_session_navigation().

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce539286-3487-4859-887b-bbafcf68ded7

📥 Commits

Reviewing files that changed from the base of the PR and between cad85a5 and 2ce983d.

📒 Files selected for processing (1)
  • claude_code_log/renderer.py

@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 19, 2026

Thanks for the two follow-up findings. Acknowledging both, declining both, reasoning below:

1. "Prevent session summaries from leaking into user-only output"

Summaries are session labels (Claude-authored session titles like "Session about X"), attached to SessionHeaderMessage for navigation — they aren't prompt-like text that a downstream agent would mistake for user input. For the #26 use-case (feeding the transcript to an agent building requirements.md), session labels are useful scaffolding that tells the agent when topic boundaries occur. Stripping them would cost real context for a theoretical purity gain. Keeping them.

2. "Treat steering messages as user-authored session starters"

A session opening with only steering content can't exist in practice: steering (queue-operation "remove") is an out-of-band user message sent while the agent is busy processing something else. That "something else" is by definition the first UserTextMessage of the session. So the first entry is always a real user prompt, and steering only ever arrives after.

Even in the impossible case, the degradation is "session preview is empty" — the session header still renders, navigation still works. A graceful no-op, not a bug.

Not worth the plumbing to patch.

@coderabbitai resolve

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

Tip

For best results, initiate chat on the files or code changes.

@cboos: Understood on both counts — the reasoning is solid.

  1. Session labels in user-only: Agreed. SessionHeaderMessage summaries are structural/navigational metadata authored by Claude as labels, not as conversational content, so a downstream agent consuming the transcript won't conflate them with user intent. Keeping them is the right call.

  2. Steering as session starters: The protocol invariant you describe (steering is always in-flight, so a real UserTextMessage must precede it) makes the edge case truly unreachable in practice, and the graceful degradation in the impossible case is acceptable.

I'll resolve the open comments now.

[resolve]


✏️ Learnings added
Learnt from: cboos
URL: https://github.com/daaain/claude-code-log/pull/118

Timestamp: 2026-04-19T19:57:27.864Z
Learning: In `claude_code_log`, `SessionHeaderMessage` summaries (Claude-authored session titles) are intentionally preserved in `--detail user-only` output. They serve as structural/navigational scaffolding that helps downstream agents identify topic boundaries (e.g., building a `requirements.md` from a transcript). Do not flag them as assistant-authored content that should be excluded.

Learnt from: cboos
URL: https://github.com/daaain/claude-code-log/pull/118

Timestamp: 2026-04-19T19:57:27.864Z
Learning: In `claude_code_log`, `UserSteeringMessage` (derived from `queue-operation "remove"` entries) is always an out-of-band message sent while the agent is already processing a prior `UserTextMessage`. This means steering can never be the first entry in a session by protocol design, so there is no need to handle the case where a session header has no preceding user text message.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@cboos cboos assigned cboos and daaain and unassigned cboos and daaain Apr 19, 2026
@cboos cboos requested a review from daaain April 19, 2026 21:03
Copy link
Copy Markdown
Owner

@daaain daaain left a comment

Choose a reason for hiding this comment

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

Looks good, both the implementation and the mode, I was thinking myself that extracting just my messages and processing them could be useful to see how to improve documentation so I can steer less later!

cboos added 5 commits April 26, 2026 17:56
Inspired by #26 (requirements extraction): a fifth detail level that
keeps only the user's own conversational input, making the output
directly feedable to a downstream agent that builds a requirements
doc, changelog from intent, etc. Everything that came from Claude
Code — replies, thinking, tool_use, tool_result, bash input/output,
slash commands, hooks, system, memory — is excluded.

- `DetailLevel.USER_ONLY = "user-only"` added to the enum (between
  MINIMAL and the end, giving the documented ordering
  full > high > low > minimal > user-only).
- `_USER_ONLY_EXCLUDE_CLASSES` extends `_MINIMAL_EXCLUDE_CLASSES`
  with `AssistantTextMessage`. Wired into `_filter_template_by_detail`
  and mirrored in the pre-render `_filter_by_detail` (same stripping
  of Thinking/ToolUse/ToolResult content items, same sidechain drop).
- `_filter_by_detail` additionally allows `QueueOperationTranscriptEntry`
  through at USER_ONLY so queue-operation 'remove' entries survive
  to become UserSteeringMessage — steering prompts carry real user
  precisions ("actually, wait — use Postgres not MySQL"). Other levels
  continue to drop them; the MINIMAL asymmetry is pre-existing and
  left untouched to keep this change scoped.
- CLI `--detail` choice list gets `user-only`; help text mentions
  the downstream-agent use case.
- README level list updated (ordered smallest → largest as existing).
- Output filename: `variant_suffix(DetailLevel.USER_ONLY)` returns
  `.user-only` automatically since the enum value already has that
  string form — no helper change needed.

`TestUserOnlyTemplateMessages` in test_detail_levels.py covers the
six axes: assistant-text drop, steering retention, tool/thinking/
bash/slash drop, sidechain drop, session-header preservation, and
size-vs-MINIMAL invariant. `TestUserOnlyCli` runs the CLI
end-to-end and checks the `.user-only.html` filename is emitted.
`TestVariantSuffix` gains two USER_ONLY asserts (html + md).

All 921 unit tests pass (7 new).
The previous commit added a USER_ONLY-specific carve-out in
`_filter_by_detail` so QueueOperationTranscriptEntry entries survive
to become UserSteeringMessages, and noted the resulting asymmetry
with MINIMAL. Per review: MINIMAL was dropping steering by omission
and that's the bug, not USER_ONLY being special.

Promote the carve-out to also apply at MINIMAL. Both levels now
keep queue-operation entries so steering prompts survive. HIGH/LOW
continue to drop them (their exclude chain is explicit about
signal-vs-noise and steering-less was the status quo; leaving them
alone avoids scope creep).

Add `test_minimal_keeps_user_steering` mirroring the USER_ONLY test.
The pre-existing `test_minimal_representative_data` already had
`user-steering` in its allowed-type set but no fixture actually
exercised the path — the new test closes that gap.
Two fixes from monk's review (msg 2224):

## Regex blocker

VARIANT_ENTRY_RE uses `\.[a-z]+` per suffix segment, rejecting the
hyphen in `user-only`. Files land on disk as
`combined_transcripts.user-only.html`, are globbed by
`_enumerate_project_variants`, then silently filtered out by the
regex — the variant disappears from the index card. Fix:
`[a-z]+` → `[a-z-]+` (hyphen at the end of the class so it stays a
literal and not a range boundary).

Regression coverage:

- `TestVariantEntryRegex::test_accepts_entry_points` gains
  `user-only` + `user-only.compact` parameter cases.
- `TestEnumerateProjectVariants::test_lists_all_variants_default_first`
  now plants a `combined_transcripts.user-only.html` file alongside
  the others and asserts it survives the glob→regex pipeline,
  catching any future re-tightening of the regex.

## Label polish

`_variant_label_from_suffix(".user-only")` used to render
"User-only" (capitalize only touches the first character). UI
reads more naturally as "User only" (space). One-line change in
the comprehension: `.capitalize().replace("-", " ")` on non-
`compact` segments. Asserted in the enumeration test above.

CLI help, enum values, and filenames keep the hyphenated
`user-only` form — the space is purely UI rendering.
CodeRabbit flagged two remaining issues on top of 16596be:

1. The MINIMAL/USER_ONLY-only carve-out for `QueueOperationTranscriptEntry`
   broke the documented `full > high > low > minimal > user-only`
   hierarchy: MINIMAL kept steering but LOW/HIGH still dropped it,
   which is backwards (each higher level should keep at least what the
   level below keeps). The post-render exclude chains
   (_HIGH/_LOW/_MINIMAL/_USER_ONLY) never listed `UserSteeringMessage`,
   so the pre-render allowlist was the only thing filtering it out.

2. `QueueOperationTranscriptEntry.content` is declared
   `Optional[Union[list[ContentItem], str]]`. Two filter sites
   (`_filter_messages` line 1904, session-info collection line 2161)
   coerced non-list content to `[]`, silently dropping steering
   prompts that arrive as plain strings.

Higher-level fix instead of patch-over-patch:

- `_filter_by_detail` allows `QueueOperationTranscriptEntry` at every
  non-FULL level unconditionally. Steering is user-authored content;
  every view claiming to preserve the user's side must keep it.
- New `_queue_op_content_as_list` helper wraps a non-empty string
  in `[TextContent(type="text", text=content)]` so downstream chunkers
  see a uniform list. Both filter sites use it.

## Tests

- `TestSteeringHierarchy` (parametrised over HIGH/LOW/MINIMAL/USER_ONLY):
  asserts UserSteeringMessage survives at every non-FULL level.
- `TestSteeringStringContent`: asserts a plain-string queue-operation
  content produces a `UserSteeringMessage` (regression for the
  `message_content = []` coercion bug).

929 unit tests pass (+5 over the previous submission — 4 hierarchy
params + 1 string-content).
Monk's round-3 review flagged two `# type: ignore` comments that ty
reports as "unused blanket `type: ignore` directive" — both slated
for removal to match the zero-diagnostic baseline `ty` reports on
main.

1. `renderer.py:1873` — `return content  # type: ignore[return-value]`
   in `_queue_op_content_as_list`. The `isinstance(content, list)`
   narrow already types the return as `list`, so the ignore is
   redundant. Delete it.

2. `renderer.py:2187` — `extract_text_content(message_content)
   # type: ignore[arg-type]` in `_collect_session_info`. Removing
   the ignore exposes that `message_content` was untyped because
   the else branch used a blanket `# type: ignore` on
   `message.message.content`. Fix the root cause: add a
   `message_content: list[ContentItem]` annotation and replace the
   blanket ignore with an explicit `cast(UserTranscriptEntry |
   AssistantTranscriptEntry, message)` that narrows the union to
   the two members that actually reach this branch (System /
   Summary / Passthrough are filtered out upstream in
   `_filter_messages`).

Result: both `uv run pyright` and `uv run ty check` now pass with
zero diagnostics on the modified file, matching the main baseline.
@cboos cboos force-pushed the dev/detail-user-only branch from 2ce983d to 8c32255 Compare April 26, 2026 16:10
@cboos cboos merged commit 214f0cc into main Apr 26, 2026
11 checks passed
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.

2 participants