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)
- 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-matchsearchself-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. - One range grammar, two axes: every range flag (
--line/--turn/--file-lines) takesN·A..B·N..·..N·-k(k-th from the END;-3..= last 3) ·..(all), inclusive.A-Bhard-errors with the correct spelling; a statically reversed9..3errors at parse. Axes:turn/tN= 0-based logical turn;line/Lnnnn= 1-based physical jsonl line;--file-lines(recover) = the reconstructed FILE's lines. Onshow, an explicit--turn N/A..Bis an address (law 1); open/from-end forms clamp.--turn(windowing) ∧--since/--untilintersect (AND) everywhere. - Span law: subagents included by default;
--no-subagentsrestricts; both switches exist everywhere (contradictory pair = parse error).verbatimis the one opt-IN (--subagents; its budget multiplies per session) and the one command that REQUIRES a target.agentsrejects both (it LISTS subagents). - Caps law — no silent truncation: every cap reports its drop and how to get more. Defaults:
list50 rows on an unscoped all-projects run;show200 record units (the drop prints the exact continuation command);search/statsuncapped 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 (listrows,agentsrows) 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 isstats). 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. - 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_localfor a record's own instant,<name>_utc+<name>_localfor 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 whatsearch -lemits); UNION with positionals, per-id fail-loud, an explicitly empty list = empty scope (exit 0 — a pipeline that found nothing propagates nothing).searchis 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.showtargets exactly ONE transcript:@<uuid>= that top-level file (never spans),@<agent-id>= that subagent's file.
@trap: — "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 labelshows user.* keys only; with no filter that is the full label set — run with""and no-tbefore 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 ispending— "any pending tools?" needs no-t) ·model(per assistant model — the rawmessage.modelvalue; 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--attachmentsgate, 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—pairinganswers "did a result come back",resultanswers "was it good": "any failed reads?" issearch "" T --count-by result; an errored result also renders an inline[error]and carries JSONis_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 JSONexcerpt) or the hit'srefetch. --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 forjq(notes → stderr; sidecar-merged hits have no physical line and are omitted with a note). The answer to any unrendered-field question.--resolve-persisted: inlinetool-results/<id>.txtfiles before matching (regex reaches externalized output); under--rawit affects matching only.--additional-context: ALSO scan hook-injected additionalContext (theattachmentrecords a SessionStart/UserPromptSubmit/... hook writes — where<stamp>-style injected context lives). Off by default (machinery; echoes prompts/files wholesale). Hits surface underharness.meta.hook; the printedcsift show @<id> --line Nrefetch renders one WITHOUT the flag (explicit address always works).--attachments: scan EVERYtype:"attachment"record — a SUPERSET of--additional-context(hook payloads stayharness.meta.hook; every other payload surfaces underharness.meta.attachmentwith 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 explicitshowaddress renders any attachment record without the flag.--multilinesets(?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 Bashinput.commandis already the two-character sequence\nby match time — match the literal\\n;--multilineis 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 intohead/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-countis SIGNED:Nkeeps the EARLIEST N of the chronological stream,-Nthe 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)