use super::*;
#[derive(Debug, Args)]
#[command(
about = "Which files/dirs a session modified, when (Edit/Write/Notebook + heuristic Bash)",
long_about = "Show which FILES and DIRECTORIES a session modified, and when. csift \
extracts file mutations from a session's transcript (spanning its subagents by \
default, since OMC fan-out edits happen in subagents):\n \
• AUTHORITATIVE Edit / Write / MultiEdit (input.file_path) + NotebookEdit \
(input.notebook_path), with create-vs-edit resolved from the paired \
tool_result (`type:\"create\"` = a new file).\n \
• HEURISTIC Bash file mutations, parsed LEXICALLY from the command \
string (rm/mv/cp/mkdir/touch/tee/sed -i/git/redirection). Bash carries no path \
field in its result, so these are best-effort and ALWAYS labelled `(heuristic)`.\n\n\
DETAIL LEVEL: `--by <summary|dir|file|timeline>` (DEFAULT `summary`; STRICTLY \
coarsening):\n \
--by summary (DEFAULT) coarse TOP-LEVEL-PREFIX rollup (first few dir segments; a\n \
whole project tree collapses to one row); the smallest output\n \
--by dir one row per distinct directory (the FULL parent path, finer than\n \
summary) with per-op + distinct-file counts + first/last\n \
--by file one row per distinct file (per-op counts + first/last touch)\n \
--by timeline full chronological list, one line per mutation (HEAVY; opt-in only)\n\n\
PATH FILTERS (optional, combinable with each other AND with --by; matched against \
each mutation's FULL absolute path):\n \
--regex <RE> keep a path iff the Rust `regex` pattern matches ANYWHERE in it\n \
--glob <PAT> keep a path iff the glob matches the full path (`**` crosses `/`)\n \
Both filters AND together (a path must satisfy EVERY supplied filter) and apply \
BEFORE the --by rollup, so summary/dir/file/timeline AND the Edit-before-Read \
boundary section all reflect the filtered set; an invalid pattern is a hard error \
and a filter that removes everything yields the normal empty output.\n\n\
The TARGET selects the session(s): `@<uuid>` for one, or a project \
PATH/encoded-dir for every session under it; with neither, all projects are \
scanned. SUBAGENT SCOPE (default spans subagents, since OMC fan-out edits happen \
in subagents): `--no-subagents` restricts to the TOP-LEVEL session only.\n\n\
WINDOWING: `--turn (N|A..B|N..|-k)` (inclusive, 0-based on genuine-user order) \
INTERSECTS with `--since`/`--until` (both filters AND). Time bounds accept ISO8601 \
(`2026-06-01`, `2026-06-01T05:00:00Z`) or a relative form (`2h`, `3d`, `90m`, \
`45s`, `1w`) meaning \"that long ago\" in the system-local timezone; a mutation \
with no timestamp never falls inside a bounded window.\n\n\
THE SURVIVAL AXIS: turn numbers are the LIVE ones - the turns Claude Code's own \
conversation chain still reaches, the same numbers `search` and `show` print. A \
mutation made on a branch the operator later rewound past, or under a prompt that \
was recalled and re-typed, belongs to no numbered turn: its turn slot reads \
`abandoned (root L<n>)` naming the branch head, and the row carries an `[abandoned]` \
marker (`[pre-cut]` above a compaction cut). The ROW STAYS. Disk truth is file \
order, and that write really did land - dropping it would answer \"did this session \
write X?\" with a lie. The rollups count the share separately \
(`(N from abandoned branches)`). A `--turn` window admits such a row by the live \
turn it physically follows, for the same reason: a window over a span must not \
silently omit a write that happened inside it.\n\n\
No silent truncation: skipped malformed lines are counted and surfaced.",
after_help = "DETAIL LEVEL: `--by <summary|dir|file|timeline>` (default summary, strictly coarsening)\n \
--by summary (coarse top-level-prefix rollup) < --by dir (full parent dir) < --by file \
(per file) < --by timeline (per mutation). PATH FILTERS: --regex <RE> (Rust regex, \
matches anywhere in the full path) and --glob <PAT> (full-path glob, `**` crosses `/`) \
are optional, combinable with each other and with --by, and AND together over the \
full absolute path BEFORE the rollup. SUBAGENT SCOPE: --no-subagents restricts to the \
top-level session (default spans subagents).\n\n\
EXAMPLES\n \
csift files @<uuid> # default summary: coarse top-level-prefix op rollup\n \
csift files @<uuid> --by file # per-file op counts + first/last touch\n \
csift files @<uuid> --by file --regex '\\.rs$' # ONLY .rs paths, per-file\n \
csift files @<uuid> --by timeline --glob '**/src/**' # mutations anywhere under a src/ dir\n \
csift files @<uuid> --by timeline --since 2h # full chronological, last 2h (heavy)\n \
csift files . --format json --by dir # machine-readable per-dir rollup\n\n\
ACID TEST: \"how many distinct gap docs touched / how many /tmp docs created?\"\n \
csift files @<uuid> --by file # count rows ending in gaps-style docs\n \
csift files @<uuid> --by timeline --format json # filter is_create==true (path under /tmp)\n \
(there is NO `op` value `create`: `op` is one of {bash,edit,write,multi_edit,\n \
notebook_edit}; create-vs-edit is the SEPARATE `is_create` boolean, so a /tmp-doc\n \
CREATE test filters `is_create==true` [optionally AND op in {write,multi_edit,\n \
notebook_edit}], never `op==\"create\"`.)\n \
(NOTE: BOTH the per-mutation `op` AND `is_create` keys live ONLY in `--by timeline`\n \
JSON; a `--by file` row carries per-op COUNT fields (write/edit/bash/multi_edit/\n \
notebook_edit/total) + first/last timestamps, NOT `op`/`is_create`, so use\n \
`--by timeline` to test create-vs-edit or filter by op.)\n\n\
JSON SCHEMAS (per --format json)\n \
--by timeline : one object per mutation: {session_id, is_subagent, parent_session_id,\n \
path, op, ts_utc, ts_local, turn_index, survival, abandoned_root_line,\n \
is_create, heuristic, resolution, path_verbatim, command_errored} + a\n \
trailing summary object. (`survival` is live | pre-cut | abandoned; an\n \
ABANDONED mutation - one made on a branch the conversation was later\n \
rewound past - has a NULL turn_index and an abandoned_root_line naming\n \
its branch head's jsonl line. It is never dropped: the write hit the\n \
disk. session_id is the transcript's own id: a\n \
top-level uuid, or a bare SUBAGENT hex when is_subagent=true, which is\n \
NOT a re-feedable @<uuid> target; re-feed parent_session_id, always the\n \
owning top-level uuid. heuristic=true ONLY for a bash-derived mutation: a\n \
guessed path/op lexically parsed from a shell command, lower confidence;\n \
false = a definitive Edit/Write/Notebook/MultiEdit tool call with an\n \
exact file_path. Filter heuristic==false for confirmed mutations only.\n \
A bash row's `path` is RESOLVED against the recording shell's cwd, the\n \
record's own top-level `cwd` field: `resolution` names the class\n \
(absolute = typed absolute; cwd-joined = joined to the record cwd, no\n \
inference; cd-tracked = joined through literal in-command `cd`s, a\n \
lexical inference; unresolved = kept exactly as typed, `~`/`$VAR`/an\n \
unknowable cwd, never joined). `path_verbatim` keeps the typed spelling\n \
when it differs from `path`; both are null on structured tools and on\n \
class markers. A CLASS MARKER is a pseudo-path, not a file: it flags\n \
what kind of mutation ran when the file set is not in the command\n \
text. `git:<sub>` = a mutating git subcommand; `fmt:<tool>` = a\n \
formatter run naming no files (cargo fmt, a prettier write run, ...);\n \
`interp:<lang>` = an interpreter payload with a write whose target\n \
could not be extracted; `pkg:<manager>` = a package-manager\n \
install/update; `extract:<tool>` = an archive or patch extraction.\n \
An `external write` row is a SNAPSHOT-INFERRED mutation: the\n \
tracked file's file-history version jumped with no tool record\n \
in the interval - Claude Code itself rewrote the file (/model,\n \
/config, plugin toggles) or an outside editor did. Reported ONLY\n \
for the settings family (`.claude/settings*.json`); the tracked\n \
set spans thousands of ordinary paths and the harness writes\n \
bookkeeping constantly, so wider reporting would flood every\n \
timeline (58,115 such jumps over 1,121 paths in one measured\n \
corpus). `csift recover --file <path>` reads the SAME instrument\n \
on any single path, where that flood cannot arise. The row's\n \
detail names the version transition and the\n \
uncovered interval; a version-counter RESET (process restart)\n \
starts a new generation and is never reported as a write.\n \
`command_errored=true` flags a mutation kept\n \
from a bash chain whose tool_result errored: part of the chain failed\n \
and which arms ran is unknowable, so it is disclosed instead of\n \
dropped.)\n \
--by file : one object per file: {session_id, is_subagent, parent_session_id,\n \
file, write, edit, bash, multi_edit, notebook_edit, external_write,\n \
abandoned, total, distinct_files, first_utc, first_local, last_utc,\n \
last_local} + a\n \
trailing summary object. (is_subagent + parent_session_id discriminate\n \
the id-domain on EVERY grouped view, same as --by timeline: a subagent\n \
row's session_id is a bare hex; re-feed parent_session_id. `abandoned`\n \
is a SHARE of `total`, never a subtraction: how many of this group's\n \
mutations came from records off the surviving conversation.)\n \
--by dir / --by summary : the same per-op count keys + the same {session_id,\n \
is_subagent, parent_session_id} discriminators, grouped under a\n \
`dir`/`bucket` key, + a trailing summary {distinct_files,\n \
total_mutations, skipped_lines, detail_level}."
)]
pub struct FilesArgs {
#[arg(
value_name = "PATH",
allow_hyphen_values = true,
value_parser = parse_project_target
)]
pub paths: Vec<PathBuf>,
#[arg(long = "sessions-from", value_name = "FILE|-")]
pub sessions_from: Option<std::path::PathBuf>,
#[arg(long = "no-subagents")]
pub no_subagents: bool,
#[arg(long = "subagents", conflicts_with = "no_subagents")]
pub subagents: bool,
#[arg(long = "by", value_enum, default_value_t = FilesDetail::Summary)]
pub by: FilesDetail,
#[arg(long = "regex", value_name = "RE")]
pub regex: Option<String>,
#[arg(long = "glob", value_name = "PAT")]
pub glob: Option<String>,
#[arg(
long = "turn",
value_name = "N|A..B|N..|-k",
allow_hyphen_values = true
)]
pub turn_range: Option<String>,
#[arg(long, value_name = "WHEN")]
pub since: Option<String>,
#[arg(long, value_name = "WHEN")]
pub until: Option<String>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
}
impl FilesArgs {
#[must_use]
pub fn want_subagents(&self) -> bool {
self.subagents || !self.no_subagents
}
#[must_use]
pub fn detail(&self) -> FilesDetail {
self.by
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoverMode {
Restore,
Salvage,
Patches,
At,
Coverage,
}