feat(sources): report per-type sidecar coverage for Claude Code parse - #3419
Conversation
Problem: polylogue-pbuh AC5 requires coverage reported per sidecar record type (seen/parsed/persisted) so a future silent skip is visible instead of requiring another rg-the-corpus audit. AC1/AC2/AC3 were already satisfied by PR #3390 (index v46 wire-evidence batch, already on master) -- this closes the AC5 gap that remained after that PR. What changed: _parse_code_records now counts, per skipped sidecar record type, how many records were seen vs. how many actually turned into persisted evidence (a session_event, a session_ref, a title override, or a delegation edge), plus a sample of record types that reached ordinary message parsing but produced no text/blocks and were dropped there. One bounded claude_parse_coverage session_event is emitted per session when either counter is non-empty (no event for the common no-sidecar-activity case). session_events.event_type has no CHECK-constrained vocabulary, so this is additive data, not a schema change. Existing exact-list session_events assertions across test_claude_code_sidecar_evidence.py needed a _typed_events() filter to look through the new coverage event; two new tests pin the coverage event's own seen/persisted divergence (permission-mode always persists, a bash_progress tick under progress never does) and its absence on an ordinary session. Verification: devtools test tests/unit/sources/test_claude_code_sidecar_evidence.py tests/unit/sources/test_parsers_claude_code_artifacts.py tests/unit/sources/test_claude_code_unread_wire_fields.py tests/unit/sources/test_compaction.py tests/unit/sources/test_dispatch_payloads.py tests/unit/sources/test_parsed_session_typed_context.py tests/unit/sources/test_parser_crashlessness.py tests/unit/sources/test_parsers_base.py tests/unit/sources/test_source_laws.py tests/unit/sources/test_tool_result_sidecars.py tests/unit/sources/test_parsers_props.py -> 397 passed devtools verify --quick -> exit 0 (ruff, mypy --strict, render all --check, layering, closure-matrix, schema-versioning all green) Ref polylogue-pbuh Co-Authored-By: Claude <noreply@anthropic.com>
Problem: polylogue-pbuh's 6 acceptance criteria were partially satisfied by prior work (PR #3390) and partially by this pass, but nothing recorded which were which -- re-closing honestly requires the split to be legible. What changed: polylogue-pbuh notes now state AC1/AC2/AC3 satisfied by #3390, AC5 satisfied by this pass (feat commit cd8ad73), and AC4/AC6 as measured remaining gaps (session_refs has no CLI/insights/MCP reader; no actual before/after census was run against the live archive from this pass). polylogue-cijx.1 gets a matching note: its own "producer may already exist" hunch is confirmed for pr-link specifically, but its four dependents (212.2/xyel/kph/fs1.4) remain unblocked-in-name-only until a consumer reads session_refs. Ref polylogue-pbuh Co-Authored-By: Claude <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
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. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b1ddcab0f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if sidecar_seen_counts or empty_drop_counts: | ||
| session_events.append( | ||
| ParsedSessionEvent( | ||
| event_type="claude_parse_coverage", |
There was a problem hiding this comment.
Merge streaming coverage before emitting the session event
When a streamed aggregate returns to the same session after records from another session, _parse_code_records runs once per contiguous chunk and emits this event each time; merge_parsed_session_chunks concatenates those events, while reconcile_code_session_chunks only coalesces background notifications. The resulting session therefore has multiple claude_parse_coverage events with per-chunk counts rather than the promised single event with per-session totals. Aggregate these payloads during reconciliation or defer emission until chunk merging is complete.
AGENTS.md reference: AGENTS.md:L129-L133
Useful? React with 👍 / 👎.
| if not keep_empty_human_turn: | ||
| empty_drop_counts[record_type] = empty_drop_counts.get(record_type, 0) + 1 |
There was a problem hiding this comment.
Bound the set of empty-drop record types
For a large or malformed JSONL stream containing many distinct unknown type strings with empty content, this dictionary grows once per unique attacker/provider-controlled value and is later serialized wholesale into the coverage event. Thus the newly described “bounded sample” breaks the parser's memory-bounded streaming guarantee; cap the number of retained type names and aggregate excess entries into an overflow bucket.
AGENTS.md reference: AGENTS.md:L132-L133
Useful? React with 👍 / 👎.
…3423) ## Summary Closes the three gaps flagged in the `_SKIPPED_SIDECAR_RECORD_TYPES` disposition audit (polylogue-pbuh follow-up): the `attachment` record type's 20+ subtypes were collapsed into one `claude_attachment` session_event; `init`/`mode` were dismissed as transient without a fresh re-check; and the frozenset's own name ("skipped") had been wrong since most of its members started persisting as `session_events`. ## Problem **Gap 1 (attachment collapse).** `code_parser.py`'s own comment conceded it: "20 distinct attachment.type payloads incl. real files, edited-file records, diagnostics; per-subtype fidelity split is a follow-up, not this pass." A full-corpus enumeration (`~/.claude/projects`, ~11,700 session files, 2026-07-31) found **38 distinct subtypes**, 87,539 total occurrences — a real referenced file's content and a `hook_success` ping were both landing as `event_type="claude_attachment"`, distinguishable only by JSON-inspecting `payload["type"]`. **Gap 2 (init/mode dismissal).** `permission-mode` was kept ("real operational signal, varies") while its sibling `mode` was dropped as transient, on the strength of a single earlier pass. That needed independent re-verification, not a repeated assertion. **Gap 3 (misleading name).** `_SKIPPED_SIDECAR_RECORD_TYPES` said "skipped." 13 of its 15 members are persisted as `session_events` today (polylogue-pbuh). The name described the pre-polylogue-pbuh behavior only. ## Solution ### Gap 1 — attachment subtype dispatch (`_attachment_sidecar_event`, `_ATTACHMENT_SUBTYPE_EVENT_TYPES`) Full per-subtype disposition (live corpus, `~/.claude/projects`, 2026-07-31; counts are subtype occurrences, not full-corpus row totals — see Verification for the total): | subtype | count | disposition | event_type | |---|---:|---|---| | `file` | 711 | EVIDENCE — real referenced file, full content | `claude_attachment_file` | | `edited_text_file` | 2,446 | EVIDENCE — editor-buffer snippet at edit time | `claude_attachment_edited_file` | | `nested_memory` | 487 | EVIDENCE — nested CLAUDE.md/memory content | `claude_attachment_nested_memory` | | `plan_file_reference` | 43 | EVIDENCE — full plan file content | `claude_attachment_plan_reference` | | `compact_file_reference` | 460 | EVIDENCE — bare path reference, no content | `claude_attachment_file_reference` | | `directory` | 16 | EVIDENCE — directory listing | `claude_attachment_directory_listing` | | `hook_success` | 27,678 | EVIDENCE — hook lifecycle (outcome=success) | `claude_hook_event` | | `hook_non_blocking_error` | 558 | EVIDENCE — hook lifecycle (outcome=non-blocking error) | `claude_hook_event` | | `hook_blocking_error` | 60 | EVIDENCE — hook lifecycle (outcome=blocking error) | `claude_hook_event` | | `hook_cancelled` | 303 | EVIDENCE — hook lifecycle (outcome=cancelled) | `claude_hook_event` | | `hook_system_message` | 129 | EVIDENCE — hook lifecycle (system message) | `claude_hook_event` | | `hook_additional_context` | 1,003 | EVIDENCE — hook lifecycle (injected context) | `claude_hook_event` | | `auto_mode` | 644 | EVIDENCE — agent-mode transition | `claude_agent_mode_event` | | `auto_mode_exit` | 130 | EVIDENCE — agent-mode transition | `claude_agent_mode_event` | | `plan_mode` | 84 | EVIDENCE — agent-mode transition | `claude_agent_mode_event` | | `plan_mode_exit` | 99 | EVIDENCE — agent-mode transition | `claude_agent_mode_event` | | `plan_mode_reentry` | 15 | EVIDENCE — agent-mode transition | `claude_agent_mode_event` | | `deferred_tools_delta` | 2,856 | EVIDENCE, bounded (names/counts, drops body text) | `claude_capability_delta` | | `mcp_instructions_delta` | 1,050 | EVIDENCE, bounded | `claude_capability_delta` | | `agent_listing_delta` | 200 | EVIDENCE, bounded | `claude_capability_delta` | | `skill_listing` | 2,690 | EVIDENCE, bounded (names extracted, not full text) | `claude_capability_snapshot` | | `invoked_skills` | 117 | EVIDENCE, bounded | `claude_capability_snapshot` | | `output_style` | 10,055 | EVIDENCE — real operational signal (style mode) | `claude_output_style` | | `command_permissions` | 570 | EVIDENCE — real operational signal (allowed tools) | `claude_command_permissions` | | `queued_command` | 7,387 | EVIDENCE — same entity as top-level `queue-operation`, reused type | `claude_queue_operation` | | `task_status` | 80 | EVIDENCE — polled background task status | `claude_task_status` | | `task_reminder` | 19,751 | EVIDENCE — 37% non-empty, carries real todo id/subject/status/blocks/blockedBy | `claude_task_reminder` | | `diagnostics` | 1,583 | EVIDENCE, bounded (per-file finding counts, not full LSP messages) | `claude_diagnostics` | | `goal_status` | 367 | EVIDENCE — sentinel goal condition | `claude_agent_goal_status` | | `date_change` | 163 | EVIDENCE — session crossed a calendar day | `claude_date_change` | | `read_truncation_notice` | 97 | EVIDENCE — a Read tool output was truncated | `claude_read_truncation_notice` | | `ultrathink_effort` | 11 | EVIDENCE — explicit reasoning-effort signal | `claude_agent_effort` | | `structured_output` | 1 | EVIDENCE — rare, potentially meaningful | `claude_structured_output` | | `max_turns_reached` | 1 | EVIDENCE — session hit a turn budget | `claude_max_turns_reached` | | `total_tokens_reminder` | 5,677 | **TRANSIENT** — constant string `"<total_tokens>Infinite tokens left</total_tokens>"` in every sample | — (dropped) | | `todo_reminder` | 5 | **TRANSIENT** — always `{"content": [], "itemCount": 0}` in every occurrence observed | — (dropped) | | `context_tip` | 11 | **TRANSIENT** — CLI feature-adoption UI hints, not session evidence | — (dropped) | | `companion_intro` | 1 | **TRANSIENT** — novelty/branding record (pet companion name) | — (dropped) | Grouping rule: subtypes reporting the *same real-world entity* (a hook firing, an agent-mode transition, a capability-surface delta) share one `event_type` distinguished by payload fields — not 38 near-empty types. Real file/reference content gets its own `event_type` per subtype since each is a structurally distinct artifact. An unrecognized future subtype (39th+) routes to `claude_attachment_unclassified` rather than silently merging into a known bucket — FAIL LOUD, independent of whether #3419's `claude_parse_coverage` event lands first. Deliberately **not** wired into `ParsedAttachment`/the blob store this pass: `_acquire_attachment_blob` in `storage/sqlite/archive_tiers/write.py` raises on `inline_bytes` unless routed through an archive-owned blob publisher, and `write.py` is out of this lane's scope (owned by #3419's neighboring work). Real file content (`file`, `edited_text_file`, `nested_memory`, `plan_file_reference`) is kept at full fidelity as `session_events` payloads instead — promoting these to first-class `attachments` rows is a bigger, separately-scoped change through the blob-publisher path. Capability-delta/skill-listing/diagnostics payloads are bounded (names/counts, not full injected instruction text or full LSP message text) — same precedent as the existing `_tool_execution_result_payload` (hunk counts, not full diffs). ### Gap 2 — init/mode re-verification Re-ran the corpus scan independently (not just re-asserting the prior comment): `init` — 0 occurrences (confirmed twice now); `mode` — 21,545/21,545 (100%) literal `"normal"` (up from 20,779 total on the prior pass, same zero-variance result). Disposition unchanged; the comment now cites two independent measurements instead of one, and explicitly contrasts with `permission-mode` (5 distinct values, kept). ### Gap 3 — rename `_SKIPPED_SIDECAR_RECORD_TYPES` → `_NON_MESSAGE_SIDECAR_RECORD_TYPES`. Hard rename, no compat alias (repo policy). Updated the one other reference (`origin_specs.py`'s admission-declaration comment) and an illustrative event-type mention in `api/archive.py`'s docstring. ## Verification - `devtools test tests/unit/sources/test_claude_code_sidecar_evidence.py tests/unit/sources/test_parsers_claude_code_artifacts.py tests/unit/sources/test_assembly_claude_code_history.py tests/unit/sources/test_source_laws.py` — **204 passed**. - `devtools verify --quick` (ruff format/check, mypy --strict, render all --check, topology/layering/closure-matrix/schema-versioning/schema-promotion gates) — exit 0. - `ruff format --check` / `ruff check` / `mypy` on the touched file individually — clean. - **Projected row counts** (no session inflation — attachment records only, never new sessions): total attachment `session_events` emitted goes from 87,539 (all `event_type="claude_attachment"`) to **81,845 across ~22 event_types**, i.e. a **net reduction** of 5,694 rows (the 4 confirmed-transient subtypes now correctly emit nothing instead of noise). `init`/`mode` disposition is unchanged (still 0 / still dropped) so no additional rows from Gap 2. ## Out of scope (explicitly, per instructions) - The `progress` record type's disposition (only `agent_progress` persists; the other six subtypes are superseded streaming ticks) — untouched, per instructions. - Promoting real-file-content attachment subtypes (`file`, `edited_text_file`, `nested_memory`, `plan_file_reference`) to first-class `ParsedAttachment`/blob-store rows — needs the archive-owned blob-publisher wiring in `storage/sqlite/archive_tiers/write.py`, which this lane was told to avoid. Filing a follow-up bead is reasonable next-session work if the operator wants that fidelity bump. - `#3419`'s `claude_parse_coverage` seen/persisted counter — independent PR, not merged as of this branch; this PR's fail-loud mechanism (`claude_attachment_unclassified`) works standalone and composes cleanly if #3419 lands first or second. --------- Co-authored-by: Claude <noreply@anthropic.com>
# Conflicts: # .beads/issues.jsonl # polylogue/sources/parsers/claude/code_parser.py
…nts (#3437) ## Summary Restores master to green. Two independently-correct PRs — #3419 (per-type sidecar coverage) and #3423 (attachment sidecar split) — produced a broken combination, and the merge that joined them was verified against a narrower test selection than the change warranted. ## Problem `devtools test -k "sidecar or code_parser"` on master: **7 failed, 138 passed**. All seven are `test_attachment_*` in `tests/unit/sources/test_claude_code_sidecar_evidence.py`. They assert exact equality on `parsed.session_events`; #3419 unconditionally appends a `claude_parse_coverage` event, so every expected list gained an unexpected member: ``` Left contains one more item: ('claude_parse_coverage', {'sidecar_seen': {'attachment': 1}, 'sidecar_persisted': {}, ...}) ``` That payload also exposes a real defect rather than only a test-reconciliation problem: `sidecar_persisted` is empty while `sidecar_seen` counts the record. The attachment branch appends a `session_event` but never sets `persisted_this_record`, so per-type coverage understated persistence for every attachment record — the exact metric #3419 exists to report. ## Solution - `polylogue/sources/parsers/claude/code_parser.py` — the attachment branch sets `persisted_this_record = True` when it emits an event, matching every sibling branch in the same dispatch. - `tests/unit/sources/test_claude_code_sidecar_evidence.py` — the seven exact-equality assertions exclude `claude_parse_coverage`. It is a parse-level report orthogonal to the attachment evidence each test is about; filtering keeps each test pinned to its own subject rather than loosening the assertion. ## Verification ``` devtools test -k "sidecar or code_parser" before: 7 failed, 138 passed after: 145 passed ``` `devtools test tests/unit/sources/test_claude_code_sidecar_evidence.py` → 30 passed. ## Note The conflict resolution that produced this was verified with `devtools test tests/unit/sources/test_assembly_claude_code_history.py` (14 passed) — a file that does not exercise the attachment path. A cross-feature merge deserves the affected-area selection, not the file the conflict happened to sit in. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…eation (#3497) ## Summary Two-part fix for the classification-gate correctness problem: (1) repairs a genuine regression introduced by the polylogue-9ykn content-classification gate (PR #3428) that refused genuine Claude Code session records, and (2) implements the general "session requires positive conversational evidence" invariant at the parse/ingest chokepoint, with a read-only reconciliation of the 5,257 zero-message sessions currently in the live archive. ## Problem **polylogue-6mpy** (failing test on clean master): `tests/unit/sources/test_revision_backfill.py::test_parse_one_still_replays_real_claude_code_sessions_with_no_path_rule` failed with `assert 0 == 1`. Root cause: `classify_artifact`'s content gate (added for polylogue-9ykn, PR #3428) consulted `classify_artifact_path` first and returned unconditionally — any source path containing an `analysis/` directory segment was refused as a self-generated side-output artifact **regardless of record content**, so a genuine Claude Code session record (`role=user`, real `sessionId`, real timestamp) sitting under such a path was silently dropped on replay. **polylogue-9ykn** (P0): the ingest path's default disposition was "this is a session" — anything not positively recognised as something else still became one. Measured against the live archive (2026-07-31, read-only queries against `/realm/db/polylogue/{index,source}.db`): ```sql SELECT COUNT(*) FROM sessions; -- 23,496 SELECT origin, COUNT(*) FROM sessions WHERE message_count=0 GROUP BY origin ORDER BY 2 DESC; -- claude-code-session 5,193 -- claude-ai-export 47 -- codex-session 17 -- total: 5,257 (22.4%) -- C4 overlap (created_at_ms NULL population): SELECT COUNT(*) FROM sessions WHERE created_at_ms IS NULL; -- 5,383 SELECT COUNT(*) FROM sessions WHERE message_count=0 AND created_at_ms IS NULL; -- 5,192 ``` 5,192 of the 5,193 empty `claude-code-session` rows (and 5,192/5,193 overall) also carry `created_at_ms IS NULL` — this **is** the C4 population (the one row with a non-NULL `created_at_ms` is `problems_index`, a self-generated analysis artifact with its own real acquisition timestamp). ## Solution ### polylogue-6mpy `polylogue/archive/artifact_taxonomy/runtime.py`: split `classify_artifact_path` into a weak, content-blind `analysis/`-directory heuristic (`_self_generated_artifact_dir_classification`) and the strong, definitive path rules (`_classify_artifact_path_strong`: OriginSpec rules, known sidecar filenames, Hermes/Antigravity path markers). `classify_artifact` (content-aware) now checks strong path rules first (unconditional, unchanged), then content classification, and only falls back to the weak `analysis/` heuristic when content shows no positive session evidence — so a relationship-index pointer record (no envelope markers) is still refused, but a genuine session record wins on content regardless of directory naming. ### polylogue-9ykn `polylogue/sources/dispatch.py` gains `require_positive_conversational_evidence()` + `message_carries_authored_content()`: a session is kept only if at least one message has real text or a content block. **Deliberately not folded into `parse_payload`/`parse_stream_payload` themselves** — those two functions are pure provider-routing dispatch, pinned by a large "law" test surface (`test_source_laws.py` and friends) that monkeypatches provider parsers with zero-message stubs specifically to test *routing* independent of content. Folding the gate in there broke 20+ of those tests. Instead every real production write path applies the filter explicitly after calling `parse_payload`/`parse_stream_payload`: - `sources/live/batch.py`'s full-ingest loop (reuses the *existing* `mark_raw_parse_failed` "recorded, bounded failure" mechanism — a filtered-to-empty result trips the same `if not sessions:` branch a genuinely-unparseable payload already hits) - `pipeline/services/ingest_worker.py`'s `_parse_plan_sessions` (subprocess decode/parse worker; reuses the existing `"parse: session artifact produced no materializable sessions"` error path) - `sources/live/append_ingest.py` (incremental append) - `sources/revision_backfill.py`'s `_parse_one`/`_parse_stream` (offline replay/rebuild, alongside its own polylogue-6mpy path/shape gate — catches the sibling case where the shape is recognised but parsed content still carries no message) Checking message *content*, not just message *count*, closes a sibling gap found while testing: an unrecognized single-record document with no known provider markers fell through Claude Code's generic single-document lowering into a one-message session whose sole message had empty text and no blocks — structurally "has a message" but zero actual evidence. ### Reconciliation (AC b) — read-only, `?mode=ro` Joining `index.db.sessions` to `source.db.raw_sessions` via `raw_id`: | Class | Count | Disposition | |---|---|---| | `agent-*.meta` sidecars | 4,945 | Artifact — already refused by `classify_artifact`'s path rule (pre-dates this PR); pending purge | | JSONL files with only non-conversational envelope records (`file-history-snapshot`/`progress`/`bridge-session`/`custom-title`/`agent-name`) | 228+ | Artifact — genuine per-session JSONL files whose entire content is Claude Code checkpoint/protocol records, never a real message. **Eliminated at source by this PR's gate.** | | `tool-results/*.json` sidecars mis-dispatched as sessions | 3 | Artifact — wrong-provider-dispatch of a single-JSON-object sidecar file as if it were a session-shaped stream | | `.gemini/` paths misdetected as `claude-code-session` | 2 | Artifact | | `claude-ai-export` conversations with a real title but `chat_messages: []` | 47 | Content-legitimate export entity, but carries no conversation — refused by this PR's gate too | | `codex-session` empty rows | 17 | Mixed: some are zero-byte scan/write-race captures (now refused instead of silently materializing, see `test_full_ingest_empty_jsonl_is_not_misclassified_as_truncated`); a few (996KB/1.4MB blobs) look like a **separate, unrelated Codex message-extraction defect** — flagged as a residual, not fixed here (out of scope; see Residuals) | **On the 832 ne6k-retained rows**: polylogue-ne6k's own 2026-07-31 investigation (same day) already superseded the original "832 intentionally-retained genuinely-empty sessions" framing — its corrected note states "the 832 the hook-inflation postmortem retained were retained precisely BECAUSE the blanket predicate could not tell them apart, not because they were verified worth keeping" and "no 'genuinely empty session' is a legitimate construct." This PR's gate is therefore consistent with that corrected finding — it does not carve out a legitimacy exception for any zero-message row. **AC (c) — eliminated at source**: the gate above stops all classes above from ever entering the index again, in one place, for every ingest/replay route. Existing phantom rows in the live archive are left for the already-planned v46→v50 index rebuild (polylogue-x1gd) per this lane's non-goals — **no live-archive writes were made by this PR**. ## Anti-vacuity statement Production callers exercising the new surface: `polylogue/sources/live/batch.py:2086` (daemon full-ingest, in-process), `polylogue/pipeline/services/ingest_worker.py:_parse_plan_sessions` (subprocess decode/parse worker), `polylogue/sources/live/append_ingest.py` (incremental append), `polylogue/sources/revision_backfill.py:_parse_one`/`_parse_stream` (offline replay/rebuild). Mutation that makes the tests fail: removing the message-content check from `require_positive_conversational_evidence` (or reverting it to a message-*count* check) makes `test_parse_payload_generic_unrecognized_record_shape_manufactures_only_a_content_free_message` fail; removing the `require_positive_conversational_evidence` call from any of the four call sites makes that call site's corresponding fixture test in `test_live_batch_support.py`/`test_resilience.py`/`test_revision_backfill.py` fail (each asserts the specific refused/empty outcome, not merely "no crash"). ## AC matrix | AC | Status | |---|---| | 6mpy: content evidence overrides weak path heuristic; test passes | Satisfied | | 6mpy: relationship-index / evidence-free shape stays refused | Satisfied (regression-pinned, `test_parse_one_refuses_non_conversational_content_with_no_path_rule`) | | 9ykn (a): default disposition for unrecognised record is refusal-with-reason, regression-pinned, no session created | Satisfied — 3 new tests in `test_dispatch_payloads.py` pin `require_positive_conversational_evidence` against a session-meta-only stream, a `chat_messages: []` export conversation, and the unrecognized-shape single-message case | | 9ykn (b): 5,255/5,257 empty-session population explained (intentional vs artifact) | Satisfied — see reconciliation table above; no intentional/legitimate subset remains per ne6k's corrected finding | | 9ykn (b): C4 `created_at_ms` NULL overlap reported with proving query | Satisfied — 5,192/5,193 overlap, query above | | 9ykn (c): artifact class eliminated at ingest source (code fix) | Satisfied for the classes measured above; existing rows deferred to the v46→v50 rebuild (non-goal: no live writes) | | 9ykn: regression test pins unrecognized record ≠ session | Satisfied | ## Verification ``` python -m devtools test tests/unit/sources/test_revision_backfill.py tests/unit/sources/test_artifact_taxonomy.py -> 65 passed python -m devtools test tests/infra/pipeline_roundtrip.py tests/unit/sources/test_dispatch_payloads.py \ tests/unit/sources/test_dispatch_ordering.py tests/unit/sources/test_source_laws.py \ tests/unit/sources/test_revision_backfill.py tests/unit/sources/test_artifact_taxonomy.py \ tests/unit/sources/test_live_batch_support.py tests/unit/pipeline/test_resilience.py \ tests/unit/daemon/test_ingest_worker_handoff.py tests/unit/pipeline/test_ingest_worker_assembly.py -> 334 passed, 5 failed python -m devtools verify --quick -> exit 0 (ruff format/check, mypy --strict, render all --check, topology, layering, closure-matrix, schema/manifest/policy lab checks all pass) ``` The 5 remaining failures were each reproduced against a clean-master baseline worktree (`git worktree add --detach <tmp> 4477b96`, the commit immediately before this branch) and confirmed to fail identically there — pre-existing, unrelated to this change: - `test_dispatch_payloads.py::test_parse_stream_payload_codex_long_rollout_with_repeated_session_meta_yields_messages` - `test_pipeline/test_resilience.py::test_validation_law_matches_mode_and_payload_contract` - `test_live_batch_support.py::test_append_multi_session_payload_is_rejected_before_index_write` - `test_live_batch_support.py::test_full_ingest_skips_durably_excised_content_without_aborting_batch` - `test_live_batch_support.py::test_full_ingest_writes_archive_with_route_observability` (A wider batch also confirmed as pre-existing this way: `test_web_reader.py::test_archive_filter_kwargs_cover_every_storage_lowerable_spec_field`, `test_synthetic_semantics.py`'s two antigravity parametrizations, `test_claude_code_normalization_laws.py::test_family_fixture_detector_and_streaming_paths_preserve_one_normalized_identity`.) `devtools verify` (full testmon-affected run) was not run: this fresh worktree has no seeded testmon database (`.cache/testmon/testmondata` absent) and `--seed-testmon` would run the full non-integration suite, which is out of the narrow-node-reproduction budget for this lane. The targeted file list above plus `--quick`'s static/generated gates are the verification evidence for this PR. ## Residuals / follow-ups - A handful of `codex-session` zero-message rows (2 of the 17) have large (996KB/1.4MB) raw blobs that look like genuine conversations whose messages failed to extract for an unrelated reason — flagged for a follow-up bead, not investigated further here (out of scope: this lane is about the classification/evidence gate, not a Codex parser defect). - Other in-flight worktrees in this repo have branches named `feature/sources/positive-session-classification` and `feature/sources/self-generated-analysis-artifact-refusal` (no open PRs found for either at push time) — their scope may overlap this PR's; worth a coordinator check before merge. - `raw_sessions.detection_warnings_json` (the durable per-raw-revision warning sink) is not fed by `require_positive_conversational_evidence`'s refusal — the filter runs after a `ParsedSession` already exists in memory, with no natural session-scoped event sink to write to since no session is created. The refusal is currently recorded via `logger.warning` plus the existing `mark_raw_parse_failed`/`"produced no materializable sessions"` bounded-error paths at each call site. Wiring a `claude_parse_coverage`-family detection event for this specific refusal reason (PR #3419) would need a raw-revision-scoped (not session-scoped) sink and is left as a follow-up rather than invented ad hoc here. Ref polylogue-6mpy, polylogue-9ykn --------- Co-authored-by: Claude <noreply@anthropic.com>
…ad model (#3543) ## Summary Ingests Claude Code's live plan-snapshot directory (`~/.claude/todos/*.json`) into the archive end-to-end: raw admission with provenance, a session-linked read model over every retained revision, and a registered plan-completion metric. `~/.claude/file-history/` is deferred as a scoped follow-up (see below). ## Problem polylogue-t0p: Claude Code writes several artifacts beyond the session JSONL transcript that answer questions the transcript alone cannot, and the harness prunes some of them on its own schedule -- unread state is eventually lost, not merely delayed. `~/.claude/todos/<session-id>[-agent-<agent-id>].json` holds the agent's current TODO/plan list (task content/status/priority/id), overwritten wholesale on every `TodoWrite` call. Nothing in the archive captured this before. ## Solution - **Admission**: a new `todo_snapshot` `OriginArtifactRule` in `polylogue/sources/origin_specs.py` classifies `todos/*.json` as a fact-tier Claude Code artifact, the same admission shape already used for `workflow_run_snapshot`/`agent_sidecar_meta` (PR #3419/#3437/#3448 sidecar-join precedent this bead pointed at). Fidelity is declared explicitly: preserved -- every item's content/status/priority/id and the agent's own list order; lost -- intermediate status transitions between two watcher-observed snapshots (each write is a full overwrite, no in-file timestamp), and anything pruned before observation. - **Parser**: `polylogue/sources/parsers/claude/todos.py` parses the bare JSON array and recovers `session_id`/`agent_id` from the filename (`<uuid>[-agent-<uuid>].json`, verified against the real on-disk shape under `~/.claude/todos/`). - **Watched root**: `~/.claude/todos` is a new configured/watched source (`polylogue/config.py`, `polylogue/paths/_roots.py`, `polylogue/sources/live/watcher.py` -- a second `WatchSource` sibling to `claude-code`, same pattern as the existing `codex-state` root), aliased to the `claude-code` provider (`polylogue/core/provider_identity.py`) so the existing `_admit_non_session_origin_artifacts` admission path and `classify_artifact_path` pick it up with zero bespoke wiring -- confirmed by tracing that gate's `Provider.from_string(source.name) is Provider.CLAUDE_CODE` filter. - **Read model**: `polylogue/insights/claude_todo_projection.py` is a storage-free, session-linked projection (same justification as `insights/run_projection.py`) over every retained `todo_snapshot` raw revision in `raw_sessions` (not just the current-pointer `raw_artifacts` row, which would collapse history to the latest snapshot) -- per-session plan state, latest completion rate, and per-item status-transition history across observed snapshots. - **Measure registration**: `plan_completion_rate` registered in `insights/measurement/registered_metrics.py`'s `DEFAULT_METRIC_REGISTRY` (rxdo.9.1 identity layer), the same bounded slice `session_cost_usd` already uses -- reachable via MCP `get(ref="metric:plan_completion_rate")`. polylogue-9l5.7's full statistics/composition registry (checked: still open, `MeasureSpec` unimplemented) is *not* duplicated here; this registers identity only, same limitation `session_cost_usd` already documents. ## Deferred: `~/.claude/file-history/` Investigated the real on-disk shape to save the follow-up lane research time: - Each session has a directory `~/.claude/file-history/<session-id>/<hash>@v<N>` holding **raw pre-edit file bytes with no wrapper** (no path, no timestamp inside the file itself). - The mapping from `<hash>@v<N>` back to a real file path lives **inside the session's own JSONL transcript**, in a `type: "file-history-snapshot"` record's `snapshot.trackedFileBackups: {<path>: {backupFileName, version, backupTime}}` map (plus `type: "file-history-delta"` records for incremental updates). - This is **already parsed today** as an evidence-only session event (`code_parser.py`'s `_sidecar_evidence_payload` for `file-history-snapshot`/`file-history-delta`, `#2qx.4`) -- it records the file list and backup pointers, but never acquires the actual snapshot bytes at `~/.claude/file-history/`. - Follow-up scope: join the already-parsed `trackedFileBackups` pointers to the sibling `~/.claude/file-history/<session-id>/` directory, content-address-acquire each snapshot as a blob keyed to (session, path, version), and apply the same privacy/redaction discipline `_acquire_attachment_blob` uses for attachments (these are literal pre-edit file contents from real projects). This is a distinct acquisition shape from `todo_snapshot` (cross-referencing two directories, not a single self-contained file) and was intentionally left out of this PR per the bead's own note that if full scope is too large for one PR, todos should land alone. Lower-priority artifacts the bead also names (`history.jsonl`, `history-summaries/`, `debug/`, `mcp-logs/`, `ide/` locks, `jobs/`, `ccusage`/stats caches) were not investigated this session; still open. ## Verification - `devtools test tests/unit/sources/test_origin_specs.py tests/unit/sources/test_parsers_claude_todos.py tests/unit/insights/test_claude_todo_projection.py tests/unit/core/test_config.py tests/unit/sources/test_live_watcher.py` -> 219 passed. (One pre-existing failure, `test_live_batch_processor_records_durable_attempt`, reproduces identically with this diff stashed on this same checkout -- unrelated codex-path assertion, not touched by this change.) - `mypy polylogue` -> `Success: no issues found in 1113 source files`. - `ruff check` / `ruff format --check` -> clean. - `devtools render all --check` -> OK (topology projection regenerated for the two new modules, `devtools render topology-projection`). - `devtools verify --quick` -> exit 0 (includes a cherry-picked, already-vetted-elsewhere ack commit for an unrelated pre-existing `classifier-fingerprints` gate failure from PR #3537, tracked separately at issue #3540 -- confirmed via `git stash` that this failure predates and is independent of this branch's changes). Anti-vacuity: removing the `todo_snapshot` `OriginArtifactRule` (or narrowing its path pattern) makes `artifact_rule_for_path`/`classify_artifact_path` return `None` for a real todos path, which `test_origin_spec_admits_todos_directory_artifact_as_fact_tier` and the end-to-end `test_todos_admitted_and_materialized_into_session_linked_plan_states` (via `parse_sources_archive`) both catch. Removing the `claude-code-todos` -> `claude-code` provider alias makes `_admit_non_session_origin_artifacts`'s `Provider.from_string(source.name) is Provider.CLAUDE_CODE` gate skip the source entirely, caught by `test_todos_source_name_resolves_to_claude_code_provider` and the same end-to-end test (would return zero plan states). Ref polylogue-t0p Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
Summary
Closes the remaining measured gap in polylogue-pbuh ("Claude Code sidecar
records are discarded at parse: 1,172,890 records"): per-type coverage
reporting (AC5), plus honest bd bookkeeping on what was already done, what
this pass did, and what is still open.
Problem
polylogue-pbuh listed 6 acceptance criteria. Auditing the bead against the
current codebase (not re-deriving it from scratch, per the bead's own method
note) found that AC1 ("classify every skip type"), AC2 ("persist typed
evidence for ai-title/agent-name/pr-link/bridge-session/
file-history-snapshot"), and AC3 ("titles/agent names reach read surfaces")
were already fully satisfied by PR #3390 ("index v46 wire-evidence batch"),
already merged to master before this pass started. Re-implementing that work
would have been redundant and risked silently regressing it.
What PR #3390 did NOT do: AC5 (per-type coverage reporting: seen vs. parsed
vs. persisted, so a future silent skip is visible) had no implementation
anywhere in
sources/parsers/claude/ordevtools/.Solution
polylogue/sources/parsers/claude/code_parser.py:_parse_code_recordsnow counts, per skipped sidecar record type, how manyrecords were seen vs. how many actually turned into persisted
evidence (a
session_event, asession_ref, a title override, or adelegation edge) -- these can genuinely diverge, e.g. a
bash_progresstick under
progressis seen but never persisted (see the classificationcomment above
_SKIPPED_SIDECAR_RECORD_TYPES).carried no text/blocks and were dropped there (
empty_dropped_by_record_type)-- the pre-fix(parsers/claude-code):
type=progressevents persisted as 740k emptytool_resultrows (23% of messages table) #1617 failure mode the bead's method note warns againstrepeating by assumption.
claude_parse_coveragesession_eventis emitted per sessionwhen either counter is non-empty (no event for the common
no-sidecar-activity case, so this doesn't bloat every session).
session_events.event_typehas no CHECK-constrained vocabulary(
storage/sqlite/archive_tiers/index.py), so this is additive data, not aschema change -- no migration, no index bump.
_accumulate_delegation_progressnow returns whether a record folded intoa genuine dispatch edge, feeding the persisted counter for
progress.tests/unit/sources/test_claude_code_sidecar_evidence.py:_typed_events()helper so the 9 pre-existing exact-event-listassertions look through the new coverage event instead of hard-coding it
into every one (it's orthogonal to what each of those tests actually pins).
test_parse_coverage_event_reports_seen_and_persisted_counts(pins the seen/persisted divergence on a real example) and
test_parse_coverage_event_absent_when_only_ordinary_messages_parsed(pins the no-bloat case).
Not done in this pass, recorded honestly on the bead instead of claimed:
unblocked/re-scoped): the producer is real (
session_refstable,storage/sqlite/queries/session_refs.py) via PR feat(archive): index v46 wire-evidence batch, free-threaded-only runtime, parse-failure recovery #3390, but nothing on theCLI/insights/MCP surface reads
session_refsyet, sopolylogue-cijx.1andits four dependents (
212.2/xyel/kph/fs1.4) are not actuallyunblocked. Noted on both
polylogue-pbuhandpolylogue-cijx.1with thespecific gap (reader, not producer). Consumer wiring is insights/CLI/MCP
territory, outside this pass's declared surface
(
sources/parsers/claude/,assembly_claude_code.py,providers/claude_code*.py).census): PR feat(archive): index v46 wire-evidence batch, free-threaded-only runtime, parse-failure recovery #3390's body recorded expected post-rebuild numbers, not an
actual measured before/after census, and whether the v46 SEMANTIC_REPARSE
rebuild has actually run against the real corpus since merge is an
operational fact about the live archive, unverifiable from a sandboxed
worktree. Recorded on the bead as an explicit open item for whoever has
archive access next.
No inflation risk: this shape (typed
session_events/session_refs, onerow per real fact, plus one bounded coverage event per session) does not add
sessions or messages -- the same producer/consumer shape PR #3390 already
established and that this PR only extends with counters.
Verification
Ref polylogue-pbuh