# Csift

> Read, search and analyze Claude Code session transcripts (the .jsonl under ~/.claude/projects). Use this INSTEAD of grep/ripgrep/cat/jq/python: the format has documented traps that return plausible wrong answers, no error (a user-role filter overcounts human turns 3x; a sixth of human turns hide inside tool_result payloads; an AUQ answer can read (notes only) with the words in annotations). Reach for it when you would hand-scan a session jsonl or shadow session facts in your own state file - and BEFORE asserting something does not exist / was never done, or re-deriving a harness mechanism from binaries: past sessions usually derived it already. Search any regex across ALL sessions with timestamps and lines; read records by line, turn or uuid; what a session is doing now; tools, tokens, models, files changed; extract pasted images to files; recover a file or plan even when deleted; restore the verbatim turns a compaction clipped. Read-only, sub-second, safe inside hooks. Pure regex, not semantic search.

- Skill: `majiayu000/csift` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds add majiayu000/csift`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/csift/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/csift

---


# csift — ripgrep for Claude Code session transcripts

Surface: **v0.10.0** (must == `csift --version`). MECHANICAL GUARD: at first use after any compaction or context restore, run `csift --version` — if it differs from this Surface line, the copy you are reading is a stale in-context echo, and the installed SKILL.md is the one that matches the binary: Read it before anything else. Same diagnosis when an invocation you were CONFIDENT about errors (an older surface from prefill/summary/habit); never fall back to hand-parsing the jsonl.

Rust CLI over CC session `.jsonl` under `~/.claude/projects/<encoded-cwd>/`. Built for an LLM consumer: token-lean text, uniform JSON, pure regex (RE2-class, linear-time; no backrefs/lookaround — they fail to compile by design). Smart-case: a pattern is case-insensitive unless it carries an uppercase; `-i` forces insensitive. `csift <cmd> --help` is the authoritative flag manual. Flag order is genuinely free — before/after the subcommand, before/after positionals, all equivalent.

## Why not hand-roll this format

Every row below is measured, not hypothetical, and none of them threw an exception: a
hand-written pass returns a plausible number that is wrong in a direction you cannot see.

| what a hand-written pass does | what it actually returns | the csift move |
|---|---|---|
| filters `type:"user"` for human turns | **3.03x overcount** (3,607 vs 1,191 corpus-wide; 8.6x in the most multi-agent lane): peer-session inbox messages and harness notifications are `type:"user"` too | `-t user.message` |
| reads text blocks, skips `tool_result` | **16.5% of human turns extract as zero characters** (197 of 1,191); direction and approval turns ride inside tool_result payloads | `-t user.answer -t user.rejection` |
| one AskUserQuestion record, one answer | **121 records carry 166 question-answer pairs**; 30 of them (24.8%) carry more than one, hiding 45 interventions and shrinking every denominator 5.5% | `-t user.answer` renders the whole Q+options+answer unit |
| reads the AUQ answer field | it can read `(notes only)` / `(no option selected)` while the operator's actual words sit in `annotations[question].notes` | csift renders the notes as the answer |
| guesses AUQ field names | `chosenOption` / `answer` / `selected` **do not exist** (the real keys are `answers`, `questions`, `annotations`), so a regex fallback silently yields empty answers | never guess: `show --line N` renders it |
| flattens an AUQ turn to `Q: ... CHOSE: ...` | every option label and description is discarded. On 2026-08-28 that turned an eval corpus into a trivial cue: **74 of 137 rows wrong, 22% of the text gone, a 20-point improvement that did not exist**, verdict inverted on rebuild | `show --line N --format json` |
| concatenates the payload body as human text | **51 records** are harness rejection boilerplate (22 also carrying a harness memory note) counted as the operator's words | the label is the authorship boundary |
| parses one session file | **+82 human turns** live in that session's subagent transcripts, which a single-file parse never opens | spanning is the default; `--no-subagents` opts out |
| reads text only | **about 1 in 5 human turns carries a pasted image** (159 of 774 records, 528 image blocks) and a text reader drops it with no marker | `image --id <ID> --out DIR`, then read the file |
| greps the file after a compaction | the summary replaced the turns; the words are gone from the live transcript | `verbatim` reconstructs them |
| greps for a tool's output | large outputs are externalised to `tool-results/<id>.txt`, leaving a pointer the regex matches instead of the content | `search --resolve-persisted` |

`search "" TARGET --count-by label` prints the carrier distribution before you write a line
of parsing. The engineer in the 2026-08-28 incident put it best afterwards: *"it would also
have told me the carrier distribution before I wrote a single line of parsing. I never
asked."*

Hand-rolling a genuinely special case is fine, and csift is read-only so it will not stop
you. What this section exists to prevent is hand-rolling as the DEFAULT, where every trap
above fails quietly and the number you report is wrong in a direction you cannot see.

## Route by QUESTION — one question ⇒ one command

| you want to know… | run |
|---|---|
| where does text X appear (regex, full round-trips) | `search PATTERN [target…]` |
| read exact record(s) — by line, turn, or uuid | `show TARGET (--line SPEC \| --turn SPEC \| --uuid U)` |
| read a session's recent turns ("what's it doing now") | `show TARGET --turn -3..` |
| what record-types live here, and how many | `search "" TARGET --count-by label` |
| which tools ran, how often (per-record census) | `search "" TARGET --count-by tool` — or `stats` (per-CALL counts) |
| what did I almost send (esc-recalled drafts) | `search "" TARGET -t user.unsent` |
| what I typed into the queue while a turn ran (queued, recalled, absorbed) | `search "" TARGET -t user.queued` — label zone shows `[enqueue]`/`[popAll]`/`[remove · reason]` |
| how long each turn took; was background work still pending at turn end | `search "" TARGET -t harness.meta.turn-duration` (pendingBackgroundAgentCount / pendingWorkflowCount) |
| the recap I saw when I came back after being away | `search "" TARGET -t harness.meta.away-summary` |
| which Stop hooks ran at turn end, how long, did one block the turn | `search "" TARGET -t harness.meta.stop-hooks` |
| when a tracked file's version bumped (a silent settings.json rewrite) | `search "settings\.json@v" TARGET -t harness.meta.snapshot` |
| any pending / unanswered tool calls | `search "" T --count-by pairing` (the count) — or `agents` (per-lane detail: which tool, since when, escalation-blocked vs awaiting) |
| which model(s) produced the replies | `search "" TARGET --count-by model` |
| which CC version(s) a session ran under, where an upgrade landed | `search "" TARGET --count-by version` |
| what non-record lines fill the jsonl (attachments, snapshots) | `stats TARGET` — the `types` census |
| where did this conversation fork (rewind / retry / parallel) | `show TARGET --branch-points` |
| CC's own rewind checkpoints of a file (the file-history store) | `recover --file /abs/P --list-backups` |
| edits parked in a plan file the session does NOT own | `plan [target] --audit` |
| hits per turn (a histogram) | `search PATTERN TARGET --count-by turn` |
| tokens burned · tool totals · turn count · time span | `stats [target…]` |
| what files changed, when; mutation timeline | `files [target…] --by file` / `--by timeline` |
| the FULL text of matched records (no clipping) | `search PATTERN … --no-truncate` |
| any field csift does not render (usage, stop_reason, …) | `search PATTERN … --raw \| jq` / `show T --line N --raw` |
| which sessions matched → scope the NEXT command | `search P -l \| csift <cmd> --sessions-from -` |
| which session is this / who am I | `list` / `whoami` |
| was this question answered / mechanism derived before | `search PATTERN` unscoped — BEFORE asserting absence or re-deriving |
| rebuild a file (even deleted) from history | `recover TARGET --file P` |
| restore turns a compaction summary CLIPPED | `verbatim TARGET…` (only when a compaction ate them) |
| subagent tree: lifecycle · status · frozen lanes | `agents [target]` |
| the session's bound plan file | `plan [target]` |
| pasted images: list / extract to a file you can Read | `image [target] --out DIR` |
| has this session truly stopped? (LIVE verdict + evidence) | `status TARGET` |
| block until it stops / asks / reads a file (a monitor) | `wait TARGET --until COND --timeout S` (timeout REQUIRED; `--background-since now` to ignore what already dangles) |
| which background tasks are still dangling, how old, which never return by design | `status TARGET` — the `bg` rows; `--ignore-background RE` for the known services |

Two commands read transcript content — pick by intent: `show` fetches from the live transcript (this includes the tail-peek `show T --turn -3..`); `verbatim` reconstructs what a compaction summary already discarded (budget-bounded, crosses boundaries). Everything you want to READ is `show`; `verbatim` is only for compaction-clipped history — and it tells you (stderr note) when you use it on a session with no compaction.

## Wrong assumptions that cost real sessions

| you might assume | actually |
|---|---|
| empty pattern `""` matches nothing | it matches EVERYTHING — the base filter for `-t`/time/turn/census |
| what I typed but esc-recalled is gone | a sent-then-esc-recalled draft IS on disk — `-t user.unsent` finds it (7 in one real session, one a 2.48M-char paste). What is genuinely gone from user RECORDS: a QUEUED message edited before dispatch — but its bytes are on disk in a `queue-operation` line, searchable since v0.10.0 as `-t user.queued` (measured over 6 sessions: 19-28% of the human's enqueued texts never became a user record; an earlier 61% figure counted every queue operation, remove lines included, over 3 sessions) |
| every thinking block is the model's reasoning | since CC 2.1.170 the API can add a SECOND thinking block per message: a narration-tagged one-sentence SUMMARY (same wire shape; tag hidden in the signature). csift labels it `agent.thinking.narration`; `-t agent.thinking` selects both, pure reasoning is `-t agent.thinking -T agent.thinking.narration`. And NO thinking block is raw chain of thought — the API documents all thinking text as summarized |
| `-c` counts matching records/lines | it counts EXCHANGES (round-trips); per-record counts = `--count-by` |
| `-l` lists every matching transcript | it lists OWNING session uuids (re-feedable); per-transcript detail = JSON summary `transcript_ids` |
| `--sessions-from` scopes to exactly the listed ids | the ids then EXPAND to their subagents (span default) — add `--no-subagents` to pin |
| turn and line share a numbering | `turn` = 0-based logical (the `tN` search prints); `line` = 1-based physical jsonl (`Lnnnn`); read both from output, never compute |
| a line number works with any session id | line numbers are per-FILE: `show --line` must target the row's own `session_id` (a parent uuid + a subagent line silently fetches the wrong record); prefer running the row's `refetch` verbatim |
| `-t user -T user.message` is contradictory | it is set subtraction (→ `user.answer` + `user.rejection`); a selector typo is a parse error with suggestions, never a silent empty |
| an excerpt is a summary | it is a match-centered FRAGMENT (~400 chars); full text = `--no-truncate` (lifts the JSON `excerpt` too) or the hit's `refetch` |
| `--raw` and `--format json` combine | they exclude each other (`--raw` IS machine output: verbatim jsonl lines) |
| zero matches means your syntax failed | it is a DEFINITIVE absence (exit 0) and search says so on stderr — read the diagnosis; when a `-t` excluded the hits it NAMES the label they live under |
| a stopped teammate needs TaskStop / pkill | teammates are in-process: `SendMessage` by name with `{"type":"shutdown_request"}` — TaskStop rejects every teammate id form |
| `completed_utc` = "when it stopped" | non-null ONLY when `status:"completed"` — a frozen/running lane carries null; its tail instant is `last_activity_utc/_local` (every timestamped lane; == `pending_since_utc` when frozen) |
| the pairing census needs `-t agent.tool.use` | pairing rides the tool BLOCK through the communication views — a frozen `SendMessage` counts as `pending` with no `-t` at all |
| timestamps need timezone arithmetic | text timestamps are already LOCAL with the offset inline — `2026-07-11 15:33 AEST(UTC+10)`; UTC lives only in JSON `ts_utc` |
| a hook that needs a session fact needs its own state file | the transcript already records it - before persisting anything (last prompt time, ids, activity markers) ask: does the jsonl already have this? Query csift from the hook (read-only, sub-second, safe inside hooks); a shadow store duplicates ground truth and drifts |
| "previous prompt" from a UserPromptSubmit hook = the newest `-t user` hit | at that instant the CURRENT prompt's record may or may not be flushed yet (both observed live) - drop hits younger than now-3s (the measured main-lane flush window is ~1-3.4s) and take the newest survivor; the @trap MAIN-thread flush race, different consumer |
| `@trap` failing = you mistyped the marker | maybe, but from the MAIN thread a first use normally misses anyway: the main record is an async flush of the completed assistant message landing ~1-3.4s after dispatch, and csift finishes inside that window (a subagent flushes per block and resolves first try). A miss means EITHER wrong lane (`@main` is the direct answer) OR a non-literal marker; a FRESH marker just restarts the race |
| a same-script retry counts as a second attempt | it does not: both attempts run inside the SAME not-yet-landed window, whose width is invisible from inside the script. A retry must be a NEW, SEPARATE shell-tool invocation — but from the main thread the answer is `@main`, not a retry |
| a fresh nonce string is reliably absent from the corpus | not from YOUR OWN live session: using it as a search pattern writes it into your transcript the moment that tool call flushes — the next unscoped/`@main` search finds your own earlier invocation (a self-echo, label `agent.tool.use`). Absence checks: scope away from your own session, or only trust the FIRST use |
| piping text output through `head -N` is safe | excerpts keep a record's LITERAL newlines (a multiline Bash command renders as-is) — `head` can cut mid-record and hide overflow pointers; the line-safe form is `--format json` (one object per line) |
| `stats` and `--count-by tool` should agree | three count units, three commands: `-c` = EXCHANGES, `--count-by` = RECORDS, `stats` tools = CALLS. A call = tool_use record + tool_result carrier, so `--count-by tool` reads ≈2× the `stats` tally (an answered AskUserQuestion re-homes its carrier to `user.answer`, so AUQ stays ≈1×) — a unit difference, not a bug |
| image `#N` handles run densely 1..N | `#N` is inherited from CC's paste-time `[Image #N]` numbering — handles can start past #1 and carry HOLES (that number's image never landed in this transcript); a `--id` miss errors naming the handles that DO exist |
| `.hits[]` flattening loses the ids | not anymore: the id trio rides EVERY hit row too (matching the exchange row), so `jq '.hits[] \| {session_id, label}'` carries real ids bare; a hit's `refetch` stays the preferred single-record fetch |
| zero matches for a rollout/steering keyword proves the model never saw it | steering PROSE is often not persisted while its CONFIG attachment is (e.g. `auto_mode`/`auto_mode_exit` carrying `{bashFirst, steerOnly, bypass}`) — census the types first (`search "" T --count-by attachment`), then search the payload under `--attachments` |
| `ScheduleWakeup` calls live under `harness.schedule.*` | a tool CALL classifies by role — arming a wakeup is `agent.tool.use` like any other tool; `harness.schedule.wakeup` is only the FIRED tick (the harness-injected, marker-carrying wakeup prompt), and a custom-prompt tick lands as an isMeta record (excluded, like all isMeta) |
| `csift turns` reads a session's turns | `turns` was RENAMED `verbatim` in v0.4 (compaction reconstruction only); the old name never runs — it errors naming the successor. Plain turn READING is `show <target> --turn -3..` |
| csift only reads CC's exact compact serialization | candidate detection is serialization-TOLERANT (since v0.6.9): a reserialized `"role": "user"` line (json.dumps defaults, a jq round-trip) is a full citizen — same previews, counts, matches. The framing law still stands: one record per LINE (pretty-printed multi-line JSON breaks jsonl framing and counts as malformed) |
| the file-history store is a full edit history | it is a pruned, tool-layer checkpoint store: @vN counters reset per session dir and get reused (only the backup instant orders), and bash/manual edits never land there — `recover --list-backups` lists it with those bounds stated; absence proves nothing |
| csift can tell which rewind branch is live vs abandoned | not computable from the jsonl (parallel tool fan-out leaves the same shape) — `show --branch-points` reports the fork facts ranked by inter-child gap; you judge |
| search found fewer user turns than a raw grep | esc-edit DRAFTS: a superseded opener is collapsed from turn reconstruction (and DISCLOSED in the footer + `superseded_drafts`); fetch one with `show --line`. A raw grep also overcounts the other way (3.03x — see Why not hand-roll) |
| `files --by timeline` lists every mutation of a path | only TOOL-recorded ones plus the settings-family snapshot inference — CC's own settings writes (`/model`, `/config`, plugin toggles) leave no tool record; for other paths check `recover --coverage` (the snapshot comparison catches them) or `recover --file P --list-backups` |
| every top-level transcript is an independent session | a background-job FORK is a byte-copy of another session at a compaction point (uuids preserved, slug stripped): `list` detects it (first timestamped record = a compact_boundary) and names the origin (`clone_of`), but every spanning surface still DOUBLE-COUNTS the inherited records until you scope the clone away |
| `list skipped_lines: 0` = the file is clean | `list` reads only the head/tail lines it needs (the fast-overview contract), so its count covers the LINES READ — a mid-file tear is outside its windows BY DESIGN, and the text note says so. The whole-file corruption census is `stats` (full scan; search/files/recover agree with it). Each `agents` row's `skipped_lines` is the same window census (lifecycle reads the transcript's edges) |

## Five laws (all commands)

1. **Exit law**: an ADDRESS that misses = hard error, exit≠0 (`show --line 99`, `show --turn 99`, `--uuid`, a pinned `@id`, `--agent`, `recover --file`, `image --id`). A FILTER that matches nothing = honest empty, exit 0 (`search`, time windows, open/from-end ranges) — and a zero-match `search` self-diagnoses on stderr: definitive absence + active filters + (under `-t`/`-T`) the labels the pattern DOES occur under. Never re-derive syntax because a result came back empty. ONE exit-code exception, `wait`'s timeout = **124** (the GNU timeout convention): a monitor's timeout is a normal outcome a script must branch on; it never extends to any other command or outcome.
2. **One range grammar, two axes**: every range flag (`--line` / `--turn` / `--file-lines`) takes `N` · `A..B` · `N..` · `..N` · `-k` (k-th from the END; `-3..` = last 3) · `..` (all), inclusive. `A-B` hard-errors with the correct spelling; a statically reversed `9..3` errors at parse. Axes: `turn`/`tN` = 0-based logical turn; `line`/`Lnnnn` = 1-based physical jsonl line; `--file-lines` (recover) = the reconstructed FILE's lines. On `show`, an explicit `--turn N`/`A..B` is an address (law 1); open/from-end forms clamp. `--turn` (windowing) ∧ `--since/--until` intersect (AND) everywhere.
3. **Span law**: subagents included by default; `--no-subagents` restricts; both switches exist everywhere (contradictory pair = parse error). `verbatim` is the one opt-IN (`--subagents`; its budget multiplies per session) and the one command that REQUIRES a target. `agents` rejects both (it LISTS subagents).
4. **Caps law — no silent truncation**: every cap reports its drop and how to get more. Defaults: `list` 50 rows on an unscoped all-projects run; `show` 200 record units (the drop prints the exact continuation command); `search`/`stats` uncapped until `--max-count N`. `--max-count 0 = uncapped`, uniformly. Malformed lines are counted (`N malformed line(s) skipped`), never hidden — including obviously-corrupt lines the byte prefilters never parse (free-text garbage, crash-truncation: any non-blank line that isn't `{…}`-framed); the one undetectable residue is a `{…}`-framed line whose INTERIOR is invalid JSON on a non-candidate line (validating those would repeal the perf contract). SCOPE of the count: the full-scan commands (`search`/`stats`/`show`/`files`/`recover`/`verbatim`/`image`) census the whole file; the head/tail readers (`list` rows, `agents` rows) census the LINES THEY READ — booked exactly once (the two windows are disjoint), scope-qualified in the text note, and never a whole-file verdict (that is `stats`). A sidecar marker line the current schema cannot read (schema skew — e.g. a pre-release fossil) counts as malformed too; it never merges and never vanishes.
5. **Time law**: every TEXT timestamp is local — `YYYY-MM-DD HH:MM:SS[.mmm] <TZAB>(UTC±offset)`, e.g. `2026-07-11 15:33:37 AEST(UTC+10)`, `IST(UTC+05:30)`. The marker is a format, not a value: name + offset derive from the machine zone at that instant (DST-correct), so the only mental step is "shift by the given offset". No UTC copies in text. Machine time = JSON, always paired: `ts_utc`+`ts_local` for a record's own instant, `<name>_utc`+`<name>_local` for named instants (`first_*`, `trigger_*`, …). Raw bytes = `--raw`.

## Targeting (positional, every command; `whoami` optional)

`@<uuid>` one session · `@<uuid-prefix>` (4-11 hex, unique else error) · `@main` calling top-level (env) · `@trap:<marker>` calling SUBAGENT (§trap) · `@<agent-id>` a subagent + its subtree (ids from `agents`; bare hex ≥12 or teammate form `aVSRepro-68a2…` — a teammate name may itself carry dashes, `aP1-engine-9cf2…`) · `.`/real path/encoded dir (`-Users-…`; Windows `C--Users-…`; UNC via `@--server-…`) ⇒ project(s) · `*.jsonl` one transcript · 0 targets ⇒ ALL projects (`list` caps the unscoped flood; `verbatim` REQUIRES a target).
- A bare id without `@` errors with "did you mean '@…'?" — ids always take `@`.
- An unrecognized `@`-shape (a 1-3-char prefix, a dashed fragment, a non-id token) errors naming the grammar — it never falls through to path resolution.
- `--sessions-from <FILE|->` (every multi-target command): scope to an id list — whitespace-separated uuid/prefix/agent-id tokens, bare or `@`-prefixed (exactly what `search -l` emits); UNION with positionals, per-id fail-loud, an explicitly empty list = empty scope (exit 0 — a pipeline that found nothing propagates nothing).
- `search` is the one command whose FIRST positional is PATTERN; targets follow: `csift search P @<uuid>`. A pattern starting `@` errors (escape `\@`); a uuid-shaped pattern prints a stderr note.
- `show` targets exactly ONE transcript: `@<uuid>` = that top-level file (never spans), `@<agent-id>` = that subagent's file.

### @trap:<marker> — "which subagent am I?"
**Scope: this is the subagent-only tool.** A running subagent cannot read its own id from env; the top-level thread already has `@main` (env-based, no race, always correct) — reach for `@trap` only when you cannot name yourself. Invent a fresh marker, put it literally IN the csift command; csift finds the transcript whose shell tool_use carries it (Bash — or Windows' separate `PowerShell` tool, same `command` field). Grammar (enforced): exactly 3 CamelCase words + exactly 4 non-trivial trailing digits, hand-invented, context-independent — shaped like `@trap:JollyShinyBrook4283`, which is a RESERVED example csift hard-rejects (invent your own; never script-generate or reuse). TIMING: a subagent's transcript flushes per content block, so its launching command is on disk at dispatch and a **first try resolves** — that is the whole design. Diagnostic: from the MAIN thread a first use normally misses instead (the main record is an async flush of the completed message landing ~1-3.4s after dispatch, and csift beats it) — a miss therefore means EITHER you are the main thread (use `@main`) OR your marker was not literal; in neither branch is retrying `@trap` the answer. When @trap does resolve to the main transcript, csift says so on stderr. One-shot means one marker per identity question, not one per attempt (a fresh marker restarts the race). UNIQUENESS is conversation-wide: in a team/multi-subagent setting the marker must be unique across ALL concurrently-running agents, not just your own retries — a marker that lands in two transcripts (e.g. relayed to a peer in a message) errors AMBIGUOUS, fail-loud, never a silent wrong match. `whoami @trap:<marker>` returns the full upstream ancestry chain.

## Labels (`-t/--label` · `-T/--label-not`) — dotted `role.class.sub`, 3 roles, 33 leaves

Selector = dot-segment prefix, THREE forms (v0.9.4): a bare ROLE (`-t user`) = the role's **LLM-visible** leaves only — the conversation as the model receives/produces it; a GLOB (`-t 'user.*'`, quote it from the shell) = every leaf under the prefix, visibility ignored; an intermediate prefix (`-t agent.tool` = use+result, `-t harness.compaction` = summary+boundary) or a full leaf = its full set, a deliberate drill-down. No `-t` ⇒ all labels (drafts and boundaries stay searchable by default, with disclosure). `-T` EXCLUDES with the same grammar (effective set = includes minus excludes; a combination excluding everything it includes errors). Multi-label records emit once under the richest surviving view (an AUQ answer → `user.answer`; a SendMessage/spawn/`<result>` pulse → `agent.communication.*`; a slash-command-with-args → `user.message` rendered `/name args`). The complete rule is MECHANICAL, not a lookup table: JSON `labels[]` is always ordered richest-first, and the rendered view is simply the FIRST label in `labels[]` that survives your `-t`/`-T` — for any unlisted combination, read it off `labels[]`. Don't guess a record's leaf — run `--count-by label` to see the distribution.

```
user     .message   genuine human prose (incl. slash-command args, rendered `/name args`)
         .answer    AskUserQuestion answer (Q+options+answer unit)
         .rejection plan/tool reject + typed instruction
         .unsent    [not LLM-visible: outside `-t user`; reach via `-t user.unsent` or `-t 'user.*'`]
                    a SUPERSEDED draft: sent, esc-recalled, edited, re-sent — the original
                    stays on disk sharing the resend's parentUuid, OUTSIDE turn numbering,
                    never counted as user.message. LIMITS: a recalled-then-ABANDONED
                    message has no resend sibling and is undetectable; a QUEUED text
                    edited before dispatch never becomes a user record at all (its
                    queue-operation line is `.queued`, below)
         .queued    [not LLM-visible · GATED: parsed only under an explicit selector —
                    `-t user.queued` / `-t 'user.*'`; never by a bare scan, `-t user`,
                    or `--count-by label` without -t]
                    the human's text as it sat in the input QUEUE: a queue-operation
                    line with content — `[enqueue]` (typed while a turn ran), `[popAll]`
                    (recalled to the input box), `[remove · <reason>]` (consumed:
                    absorbed_mid_turn / delivered_to_agent). The label zone shows the
                    event. A queued <task-notification> / peer message is NOT the human
                    and carries no label; a content-less dequeue carries nothing. The
                    queue line has NO join key (measured: 4-6 keys, no promptId/uuid),
                    so `dispatched` is never asserted — a dispatched text simply also
                    exists as a later user.message; match by text if you must
agent    .message · .thinking (redacted → "[redacted thinking]") · .tool.use · .tool.result
         .thinking.narration   an API-issued one-sentence SUMMARY of the reasoning beside it
                               (tag hidden in the signature; renders "[narration summary]";
                               NOT the model's reasoning — pure reasoning = -t agent.thinking
                               -T agent.thinking.narration; excluded from verbatim replay)
         .communication.{inbox,sent,signal}   peer msgs — rendered `from ⇨ to` (self = owner)
harness  .notification.{workflow,monitor,subagent,background-command,task}  ← <task-notification>
                    (.subagent also carries the harness's agents-stopped notice
                    "N background agents were stopped by the user: …" — plain text,
                    no XML, not the human, never a turn opener, renders
                    "[subagent stopped] …"; .monitor = the Monitor tool's own
                    pulses and termination notices ONLY — since v0.10.0 a
                    `Background command "…"` pulse is always .background-command,
                    whatever its quoted name says)
         .compaction.{summary,boundary}   boundary renders its compactMetadata (trigger=…);
                    boundary is [not LLM-visible: outside `-t harness`; reach via
                    `-t harness.compaction` or the full leaf] — a metrics-only system record
         .command.{invocation,stdout} · .interrupt.{user,tool}
         .schedule.{wakeup,continuation} · .meta.{hook,loop,attachment}
         .meta.turn-duration   [not LLM-visible · GATED] the end-of-turn telemetry record:
                    `[turn duration: 1m 5s · durationMs=64911 messageCount=908
                    pendingBackgroundAgentCount=2]` — the structured body behind the
                    REPL's "Done in Ns" / "Waiting for N agents" lines (which never
                    land on disk). Present fields only; a turn that straddled a resume
                    gap reads as days, honestly. The pending counts cover background
                    agents and workflows ONLY: a turn that ended with a background SHELL
                    still running wrote no pending field at all (measured), so an EOT
                    record is never evidence that the session is done — that is
                    `csift status`
         .meta.away-summary    [not LLM-visible · GATED] the model-generated recap shown
                    when you return after 5+ minutes away (config-gated); verbatim text
         .meta.stop-hooks      [not LLM-visible · GATED] the Stop-hook execution ledger:
                    `[stop hooks: count=N errors=M prevented=false]` + one
                    `command (Nms)` line per hook — which hooks ran, how long, whether
                    one blocked the turn. NOT .meta.hook (that is text a hook INJECTED)
         .meta.snapshot        [not LLM-visible · GATED] a file-history snapshot
                    (`[file-history snapshot at <ts>: <path>@vN, …]`, every tracked
                    path) or delta (`[file-history delta at <ts>: <path>@vN
                    backup=<name>]`, one path's bump) — the v0.9.4 recover instrument,
                    searchable: "when did settings.json's version jump?"
```
LLM-VISIBILITY (v0.9.4, extended v0.10.0): SEVEN leaves are outside the bare-role selectors — `user.unsent` (a superseded draft is NOT in the surviving conversation: CC's own `preservedMessages` accounting excludes every draft uuid; say "not in the surviving conversation", never "the model never saw it" — a few drafts drew replies before the esc), `harness.compaction.boundary` (no message field at all), and the five v0.10.0 promoted leaves `user.queued` + `harness.meta.{turn-duration,away-summary,stop-hooks,snapshot}` — the same instrument: ZERO of the promoted line types carries a `message{}` field (every user/assistant record does), and CC's own source labels them REPL-render internals. Two instruments that do NOT decide visibility: parentUuid threading (a later user record names a turn_duration uuid as its parent — chain continuity, not delivery) and `preservedMessages` membership (a tail window listing these uuids at the same rate as messages). Everything else a role selector reaches is delivered-or-produced conversation. `-t user` therefore restores the 0.7-era contract ("what the human actually sent"): 0.9.2..0.9.3 briefly included drafts under it, which poisoned a real last-human-touch hook (a draft 12s before the real submit). `isMeta` is an AUTHORSHIP flag and `isVisibleInTranscriptOnly` a summary display flag — neither is a visibility instrument.
Gated meta leaves: `harness.meta.hook` needs `search --additional-context` (or the superset `--attachments`); `harness.meta.attachment` (any OTHER attachment payload — edited_text_file, compact_file_reference, file snapshots …) needs `--attachments` or `--count-by attachment` — a default scan never parses attachment lines; an explicit `show --line/--uuid` address renders any of them flag-free.
GATED PROMOTED LEAVES (v0.10.0): `user.queued` and `harness.meta.{turn-duration,away-summary,stop-hooks,snapshot}` need an EXPLICIT `-t` that reaches them — the full path, a glob (`-t 'user.*'`, `-t 'harness.*'`), or the `harness.meta` prefix. A bare scan, a bare role, a `-T`-only filter and `--count-by label` without `-t` never parse those lines (most queued content is a duplicate automation pulse; every promoted line is a non-message line), so the default surface stays the conversation and costs nothing extra. A zero-match run without such a selector says so on stderr (JSON summary `gated_leaves_unreached:true`) — that absence does not cover the gated lines. `show --line/--uuid` renders any of them flag-free; `--raw` still prints the bytes.
Glyphs: `◂` user · `▸` agent · `⚙` harness · `·` sibling · `▹` tool use↔result pair (unreturned → `(no result — pending)`; orphan → `(use not in scope)`) · `⇨` comm direction. Turn boundary = genuine user ∨ AUQ answer ∨ typed rejection ∨ inbound peer message; slash-command wrappers, interrupts, `<local-command-stdout>`, compaction summaries never open a turn.

---

## search — find round-trips

```
csift search PATTERN [target…] [-t SEL]… [-T SEL]… [-i] [--multiline] [--since W] [--until W]
  [--turn N|A..B|N..|-k] [--max-count ±N] [-c | -l | --count-by AXIS] [--raw] [--siblings]
  [--no-truncate] [--resolve-persisted] [--additional-context] [--attachments] [--sessions-from F] [--no-subagents] [--format json]
```
Empty `""` pattern = pure filter. A hit returns the complete round-trip (tool_use with its result; user turn with reply; an answered AUQ as one Q+A unit). Terminal modes (mutually exclusive): `-c` prints one integer (EXCHANGES, `--max-count` drops added back) · `-l` prints the distinct owning session uuids, one per line, uncapped — pipes into `--sessions-from -` · `--count-by AXIS` prints a census (below).
- `--count-by AXIS` — a per-key census of the matched RECORDS along ONE closed axis (not a query language; a record whose several sections match still counts once): `label` (per leaf; a record counts under every leaf it carries THAT SURVIVES your `-t`/`-T` — a dual-labeled record never leaks its filtered-out twin into the keys, so `-t user -T user.message --count-by label` shows user.* keys only; with no filter that is the full label set — run with `""` and no `-t` before guessing any `-t`) · `tool` (per tool name) · `turn` (ascending histogram) · `session` (per transcript) · `pairing` (paired | pending | orphan, joined by tool_use_id; rides the tool block through the communication views, so a frozen SendMessage is `pending` — "any pending tools?" needs no `-t`) · `model` (per assistant model — the raw `message.model` value; CC's `<synthetic>` placeholder, a fabricated stand-in assistant record such as an API-error notice, is reported verbatim) · `attachment` (per attachment payload type — IMPLIES the `--attachments` gate, so the census needs no separate flag) · `version` (per CC version stamp — where an upgrade landed mid-session) · `result` (per tool-result error state, `ok` | `error` — `pairing` answers "did a result come back", `result` answers "was it good": "any failed reads?" is `search "" T --count-by result`; an errored result also renders an inline `[error]` and carries JSON `is_error`). Records outside an axis's domain are excluded and the excluded count is reported.
- Excerpts are ~400-char match-centered fragments; when anything clipped, text prints a caution + JSON summary `excerpts_truncated:true`. Full text: `--no-truncate` (also un-clips JSON `excerpt`) or the hit's `refetch`.
- `--siblings` (zero-arg): also render the turn's other records — messages always, thinking≤2 · thinking.narration≤1 · tool.use≤3 · tool.result≤3 · harness≤2 per leaf — the caps apply to NON-matching context records only; your actual hits always render in full, so a block can legitimately show more than the cap count of same-leaf lines. Overflow prints `(+N more · csift show @<id> --line A..B)` — run it verbatim.
- `--raw`: the matched records' VERBATIM jsonl lines on the whole filter surface — stdout pure jsonl for `jq` (notes → stderr; sidecar-merged hits have no physical line and are omitted with a note). The answer to any unrendered-field question.
- `--resolve-persisted`: inline `tool-results/<id>.txt` files before matching (regex reaches externalized output); under `--raw` it affects matching only.
- `--additional-context`: ALSO scan hook-injected additionalContext (the `attachment` records a SessionStart/UserPromptSubmit/... hook writes — where `<stamp>`-style injected context lives). Off by default (machinery; echoes prompts/files wholesale). Hits surface under `harness.meta.hook`; the printed `csift show @<id> --line N` refetch renders one WITHOUT the flag (explicit address always works).
- `--attachments`: scan EVERY `type:"attachment"` record — a SUPERSET of `--additional-context` (hook payloads stay `harness.meta.hook`; every other payload surfaces under `harness.meta.attachment` with its VERBATIM payload JSON as the matchable text). Off by default: attachment lines are the bulk of many transcripts' bytes and embed whole files. An explicit `show` address renders any attachment record without the flag.
- `--multiline` sets `(?s)(?m)`. Caveat: EVERY tool_use's matchable text is its name + the RE-SERIALIZED JSON input (not only AskUserQuestion's), so a real newline inside e.g. a Bash `input.command` is already the two-character sequence `\n` by match time — match the literal `\\n`; `--multiline` is correctly irrelevant there. It helps only where rendered text keeps real newlines (message text, thinking, tool_result bodies).
- OUTPUT GEOMETRY (text): exchanges emit oldest-first (stable chronological across every transcript in scope; undated exchanges last). Each exchange header opens with a STABLE id-prefix token `<tok>·t<N>` — `<tok>` = the first 8 chars of the owning transcript id, directly usable as an `@` target, identical across invocations (a within-output collision lengthens the colliding group to 12 chars, then the full id; a teammate id renders whole); a subagent exchange carries `(parent <first-8-of-owning-uuid>)` on EVERY header. The head carries scope + match totals + direction (`matches  N exchanges · M sessions · oldest first[· showing earliest|latest K][· undated last]`); the tail repeats the totals and adds integrity notes + refetch guidance; each over-long fragment marks its own truncation inline (`(+N chars)`). To limit output, prefer `--max-count N` (earliest N) or `--max-count -N` (latest N) over piping into `head`/`tail` — a capped run keeps every note; a pipe amputates one end of the ledger.
- Regex patterns WITH metacharacters are prefiltered too (v0.9.4): a necessity-only literal extraction (every branch of an alternation must demand one safe needle) lets `search "TodoWrite.*legacy|legacy.*TodoWrite"` scan at near-literal speed — no flag, no semantic change, matches are byte-identical.
- `--max-count` is SIGNED: `N` keeps the EARLIEST N of the chronological stream, `-N` the LATEST N, `0` = uncapped; the kept exchanges still emit oldest-first among themselves. The footer names the dropped side (`N later|earlier dropped by --max-count`).
- Superseded drafts (esc-edit resends: an opener replaced by a later same-parent sibling) stay OUTSIDE turn numbering but are fully searchable under their own leaf: a matching draft em

…(truncated)
