@procrastivity/clast 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ # clast-subcommands/snapshot.bash — `clast snapshot`.
2
+ #
3
+ # Walks $(clast_projects_dir)/<segment>/<uuid>.jsonl, copies each new or
4
+ # modified session into $(clast_journal_dir)/transcripts/<day>/<segment>/,
5
+ # and appends one manifest line per capture. Idempotent: re-running a file
6
+ # whose (session_id, source_mtime) is already in the manifest is a no-op.
7
+ # shellcheck shell=bash
8
+ # shellcheck source=lib/clast/clast-lib.bash
9
+ # shellcheck source=lib/clast/clast-manifest-lib.bash
10
+ # shellcheck source=lib/clast/clast-decode-lib.bash
11
+ # shellcheck source=lib/clast/clast-registry-lib.bash
12
+
13
+ _clast_snapshot_usage() {
14
+ cat <<'EOF'
15
+ Usage: clast snapshot [--dry-run] [--since TIMESTAMP] [--include-segment SEG]
16
+
17
+ Capture new Claude Code transcripts into the journal.
18
+
19
+ Flags:
20
+ --dry-run Preview only; do not copy or write manifest.
21
+ --since TIMESTAMP Skip sources with mtime < TIMESTAMP (ISO 8601 or
22
+ any string GNU `date -d` understands; e.g. -1d).
23
+ --include-segment SEG Limit scan to SEG (repeatable). Value must start
24
+ with '-' (segments always do).
25
+ -h, --help Print this usage and exit.
26
+ EOF
27
+ }
28
+
29
+ # _clast_snapshot_bucket_for_epoch <epoch>
30
+ # Mirror of clast_today's cutoff math against an arbitrary epoch. Local
31
+ # time per docs/overview.md#conventions.
32
+ _clast_snapshot_bucket_for_epoch() {
33
+ local epoch="$1"
34
+ local cutoff="${CLAST_DAY_CUTOFF:-04:00}"
35
+ local h="${cutoff%%:*}" m="${cutoff##*:}"
36
+ h=$((10#$h))
37
+ m=$((10#$m))
38
+ local off=$((h * 3600 + m * 60))
39
+ # GNU `date -d` — BSD date not supported, per overview.md.
40
+ date -d "@$((epoch - off))" +%Y-%m-%d
41
+ }
42
+
43
+ clast_cmd_snapshot() {
44
+ local dry_run=0
45
+ local since_epoch=""
46
+ local -a include_segments=()
47
+
48
+ while [[ $# -gt 0 ]]; do
49
+ case "$1" in
50
+ --dry-run)
51
+ dry_run=1; shift ;;
52
+ --since)
53
+ if [[ $# -lt 2 ]]; then
54
+ clast_log_error "snapshot: --since requires a value"; return 2
55
+ fi
56
+ if ! since_epoch="$(date -d "$2" +%s 2>/dev/null)" || [[ -z "$since_epoch" ]]; then
57
+ clast_log_error "snapshot: cannot parse --since '$2'"; return 2
58
+ fi
59
+ shift 2 ;;
60
+ --since=*)
61
+ local _v="${1#*=}"
62
+ if ! since_epoch="$(date -d "$_v" +%s 2>/dev/null)" || [[ -z "$since_epoch" ]]; then
63
+ clast_log_error "snapshot: cannot parse --since '$_v'"; return 2
64
+ fi
65
+ shift ;;
66
+ --include-segment)
67
+ if [[ $# -lt 2 ]]; then
68
+ clast_log_error "snapshot: --include-segment requires a value"; return 2
69
+ fi
70
+ if [[ "$2" != -* ]]; then
71
+ clast_log_error "snapshot: --include-segment value must start with '-' (got '$2')"; return 2
72
+ fi
73
+ include_segments+=("$2"); shift 2 ;;
74
+ --include-segment=*)
75
+ local _v="${1#*=}"
76
+ if [[ "$_v" != -* ]]; then
77
+ clast_log_error "snapshot: --include-segment value must start with '-' (got '$_v')"; return 2
78
+ fi
79
+ include_segments+=("$_v"); shift ;;
80
+ --json)
81
+ # Accept the global flag positionally as well, mirroring registry.
82
+ # Lets `clast snapshot --json` work alongside `clast --json snapshot`.
83
+ export CLAST_JSON=1; shift ;;
84
+ -h|--help)
85
+ _clast_snapshot_usage; return 0 ;;
86
+ *)
87
+ clast_log_error "snapshot: unknown flag '$1'"; return 2 ;;
88
+ esac
89
+ done
90
+
91
+ # Manifest precondition: cron-mode safety. clast_manifest_iterate uses
92
+ # fromjson? today, so most corruption is swallowed silently — this guard
93
+ # is the documented hook for a future strict-iterate contract (step plan
94
+ # task 3). Leaving it in keeps the exit-4 path wired the moment iterate
95
+ # learns to surface corruption.
96
+ local _it_rc=0
97
+ clast_manifest_iterate 'true' >/dev/null 2>&1 || _it_rc=$?
98
+ if (( _it_rc != 0 )); then
99
+ if [[ -n "${CLAST_JSON:-}" ]]; then
100
+ printf '{"error":"manifest is corrupt","code":4}\n'
101
+ else
102
+ clast_log_error "snapshot: manifest is corrupt; refusing to write"
103
+ fi
104
+ return 4
105
+ fi
106
+
107
+ local projects_dir journal_dir
108
+ projects_dir="$(clast_projects_dir)"
109
+ journal_dir="$(clast_journal_dir)"
110
+
111
+ local skipped=0
112
+ local total_bytes=0
113
+ local -a captured_lines=()
114
+ local -a error_lines=()
115
+ local -a captured_segments=()
116
+
117
+ # TODO(v1.1): parallel capture across segments.
118
+ if [[ -d "$projects_dir" ]]; then
119
+ local source segment session_id mtime_epoch mtime_iso source_size
120
+ local first_ts ts_epoch day_bucket
121
+ local dest_rel dest dest_dir tmp
122
+ while IFS= read -r -d '' source; do
123
+ segment="$(basename "$(dirname "$source")")"
124
+
125
+ if (( ${#include_segments[@]} > 0 )); then
126
+ local _hit=0 _seg
127
+ for _seg in "${include_segments[@]}"; do
128
+ if [[ "$_seg" == "$segment" ]]; then _hit=1; break; fi
129
+ done
130
+ if (( _hit == 0 )); then
131
+ skipped=$((skipped + 1))
132
+ continue
133
+ fi
134
+ fi
135
+
136
+ if ! mtime_epoch="$(stat -c %Y "$source" 2>/dev/null)" || [[ -z "$mtime_epoch" ]]; then
137
+ error_lines+=("$(jq -cn --arg f "$source" --arg r "stat failed" '{file:$f,reason:$r}')")
138
+ continue
139
+ fi
140
+
141
+ if [[ -n "$since_epoch" ]] && (( mtime_epoch < since_epoch )); then
142
+ skipped=$((skipped + 1))
143
+ continue
144
+ fi
145
+
146
+ session_id="$(basename "$source" .jsonl)"
147
+ if ! [[ "$session_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
148
+ error_lines+=("$(jq -cn --arg f "$source" --arg r "non-uuid filename" '{file:$f,reason:$r}')")
149
+ continue
150
+ fi
151
+
152
+ mtime_iso="$(date -u -d "@$mtime_epoch" +%Y-%m-%dT%H:%M:%SZ)"
153
+ source_size="$(stat -c %s "$source" 2>/dev/null || echo 0)"
154
+
155
+ first_ts="$(head -n1 "$source" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
156
+ if [[ -n "$first_ts" ]] && ts_epoch="$(date -d "$first_ts" +%s 2>/dev/null)" && [[ -n "$ts_epoch" ]]; then
157
+ day_bucket="$(_clast_snapshot_bucket_for_epoch "$ts_epoch")"
158
+ else
159
+ # --verbose only: a malformed first line shouldn't spam cron/hook logs.
160
+ if [[ -n "${CLAST_VERBOSE:-}" ]]; then
161
+ clast_log_warn "snapshot: '$source' missing first-line timestamp; falling back to mtime"
162
+ fi
163
+ day_bucket="$(_clast_snapshot_bucket_for_epoch "$mtime_epoch")"
164
+ fi
165
+
166
+ # Dedup on (session_id, source_mtime). Mtime — not ctime or size —
167
+ # is the manifest's "most recent line wins" key; Claude Code's
168
+ # writer bumps mtime whenever a session grows.
169
+ if clast_manifest_has_capture "$session_id" "$mtime_iso"; then
170
+ skipped=$((skipped + 1))
171
+ continue
172
+ fi
173
+
174
+ dest_rel="transcripts/$day_bucket/$segment/$session_id.jsonl"
175
+ dest="$journal_dir/$dest_rel"
176
+
177
+ if (( dry_run == 0 )); then
178
+ dest_dir="$(dirname "$dest")"
179
+ if ! mkdir -p "$dest_dir"; then
180
+ error_lines+=("$(jq -cn --arg f "$source" --arg r "mkdir failed" '{file:$f,reason:$r}')")
181
+ continue
182
+ fi
183
+ if ! tmp="$(mktemp "$dest.copy.XXXXXX" 2>/dev/null)"; then
184
+ error_lines+=("$(jq -cn --arg f "$source" --arg r "mktemp failed" '{file:$f,reason:$r}')")
185
+ continue
186
+ fi
187
+ # cp+mv, not clast_atomic_write: the helper takes content as a
188
+ # string and would slurp a multi-megabyte session into a bash var.
189
+ if ! cp "$source" "$tmp" 2>/dev/null; then
190
+ rm -f "$tmp"
191
+ error_lines+=("$(jq -cn --arg f "$source" --arg r "copy failed" '{file:$f,reason:$r}')")
192
+ continue
193
+ fi
194
+ if ! mv -f "$tmp" "$dest" 2>/dev/null; then
195
+ rm -f "$tmp"
196
+ error_lines+=("$(jq -cn --arg f "$source" --arg r "rename failed" '{file:$f,reason:$r}')")
197
+ continue
198
+ fi
199
+ # Invariant: a manifest line implies the dest file exists. If the
200
+ # append fails the dest is an orphan; doctor (step 10) will reap
201
+ # it, and a re-run of snapshot overwrites + retries the append.
202
+ if ! clast_manifest_append "$session_id" "$source" "$dest_rel" "$mtime_iso" "$source_size" "$day_bucket"; then
203
+ error_lines+=("$(jq -cn --arg f "$source" --arg r "manifest append failed" '{file:$f,reason:$r}')")
204
+ continue
205
+ fi
206
+ fi
207
+
208
+ captured_lines+=("$(jq -cn \
209
+ --arg sid "$session_id" \
210
+ --arg src "$source" \
211
+ --arg snap "$dest_rel" \
212
+ --argjson b "$source_size" \
213
+ --arg day "$day_bucket" \
214
+ '{session_id:$sid, source:$src, snapshot:$snap, bytes:$b, day_bucket:$day}')")
215
+ captured_segments+=("$segment")
216
+ total_bytes=$((total_bytes + source_size))
217
+ done < <(find "$projects_dir" -mindepth 2 -maxdepth 2 -type f -name '*.jsonl' -print0 2>/dev/null | sort -z)
218
+ fi
219
+
220
+ _clast_snapshot_emit_summary "$dry_run" "$skipped" "$total_bytes" \
221
+ captured_lines error_lines captured_segments
222
+
223
+ if (( ${#error_lines[@]} > 0 )); then
224
+ return 1
225
+ fi
226
+ return 0
227
+ }
228
+
229
+ # _clast_snapshot_emit_summary <dry_run> <skipped> <total_bytes> <caps-arr-name> <errs-arr-name> <segs-arr-name>
230
+ _clast_snapshot_emit_summary() {
231
+ local dry_run="$1" skipped="$2" total_bytes="$3"
232
+ local -n _caps="$4"
233
+ local -n _errs="$5"
234
+ local -n _segs="$6"
235
+
236
+ if [[ -n "${CLAST_JSON:-}" ]]; then
237
+ local caps_json='[]' errs_json='[]'
238
+ if (( ${#_caps[@]} > 0 )); then
239
+ caps_json="$(printf '%s\n' "${_caps[@]}" | jq -cs '.')"
240
+ fi
241
+ if (( ${#_errs[@]} > 0 )); then
242
+ errs_json="$(printf '%s\n' "${_errs[@]}" | jq -cs '.')"
243
+ fi
244
+ jq -cn --argjson c "$caps_json" --argjson e "$errs_json" --argjson s "$skipped" \
245
+ '{captured:$c, skipped:$s, errors:$e}'
246
+ return 0
247
+ fi
248
+
249
+ # Silent no-op: load-bearing for the SessionStart hook (step 11). Re-read
250
+ # docs/cli-contract.md#clast-snapshot before changing this.
251
+ if (( ${#_caps[@]} == 0 && ${#_errs[@]} == 0 )); then
252
+ return 0
253
+ fi
254
+
255
+ if (( dry_run == 1 )); then
256
+ local i seg sid day
257
+ for (( i = 0; i < ${#_caps[@]}; i++ )); do
258
+ seg="${_segs[$i]}"
259
+ sid="$(jq -r '.session_id' <<<"${_caps[$i]}")"
260
+ day="$(jq -r '.day_bucket' <<<"${_caps[$i]}")"
261
+ printf 'would capture: %s/%s → %s\n' "$seg" "$sid" "$day" >&2
262
+ done
263
+ elif (( ${#_caps[@]} > 0 )) && [[ -z "${CLAST_QUIET:-}" ]]; then
264
+ declare -A counts=()
265
+ local i seg label slug
266
+ for (( i = 0; i < ${#_caps[@]}; i++ )); do
267
+ seg="${_segs[$i]}"
268
+ if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
269
+ label="$slug"
270
+ else
271
+ label="$seg"
272
+ fi
273
+ counts["$label"]=$(( ${counts["$label"]:-0} + 1 ))
274
+ done
275
+
276
+ local mb
277
+ mb="$(awk -v b="$total_bytes" 'BEGIN{printf "%.1f", b/1048576}')"
278
+ printf 'Captured %d session(s) across %d project(s) (%s MB).\n' \
279
+ "${#_caps[@]}" "${#counts[@]}" "$mb"
280
+
281
+ local key cnt
282
+ while IFS=$'\t' read -r key cnt; do
283
+ printf ' %s: %s session(s)\n' "$key" "$cnt"
284
+ done < <(
285
+ for key in "${!counts[@]}"; do
286
+ printf '%s\t%s\n' "$key" "${counts[$key]}"
287
+ done | sort -t$'\t' -k2,2nr -k1,1
288
+ )
289
+ fi
290
+
291
+ if (( ${#_errs[@]} > 0 )); then
292
+ printf '%d error(s); see --json for details.\n' "${#_errs[@]}" >&2
293
+ fi
294
+ }
@@ -0,0 +1,354 @@
1
+ # clast-subcommands/stats.bash — `clast stats`.
2
+ #
3
+ # Read-only summary of journal activity over a date window using manifest
4
+ # + filesystem stat only. No JSONL body parsing. See
5
+ # docs/cli-contract.md#clast-stats.
6
+ # shellcheck shell=bash
7
+ # shellcheck source=lib/clast/clast-lib.bash
8
+ # shellcheck source=lib/clast/clast-manifest-lib.bash
9
+ # shellcheck source=lib/clast/clast-registry-lib.bash
10
+ # shellcheck source=lib/clast/clast-decode-lib.bash
11
+
12
+ _clast_stats_usage() {
13
+ cat <<'EOF'
14
+ Usage: clast stats [--day DATE] [--since DATE] [--until DATE] [--project SLUG]
15
+
16
+ Summarize journal activity over a date window (default: today).
17
+
18
+ Flags:
19
+ --day DATE Single-day window. Mutually exclusive with --since/--until.
20
+ --since DATE Start of range (inclusive).
21
+ --until DATE End of range (inclusive).
22
+ --project SLUG Filter to one registered project slug.
23
+ -h, --help Print this usage and exit.
24
+
25
+ DATE accepts ISO (YYYY-MM-DD), `today`, `yesterday`, `last-week`,
26
+ `-Nd`, or `-Nw`. See docs/cli-contract.md#date-parsing.
27
+ EOF
28
+ }
29
+
30
+ _clast_stats_err() {
31
+ local msg="$1" code="${2:-2}"
32
+ if [[ -n "${CLAST_JSON:-}" ]]; then
33
+ jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
34
+ else
35
+ clast_log_error "stats: $msg"
36
+ fi
37
+ }
38
+
39
+ # _clast_stats_human_bytes <int>
40
+ # Render bytes as "<n> B" / "<x.x> KB" / "<x.x> MB" / "<x.x> GB".
41
+ # Base-2 math, base-10 labels — matches snapshot summary from step 06.
42
+ _clast_stats_human_bytes() {
43
+ local b="$1"
44
+ if (( b < 1024 )); then
45
+ printf '%s B' "$b"
46
+ return 0
47
+ fi
48
+ awk -v b="$b" 'BEGIN {
49
+ units[0] = "KB"; units[1] = "MB"; units[2] = "GB"; units[3] = "TB"
50
+ i = 0
51
+ v = b / 1024
52
+ while (v >= 1024 && i < 3) { v = v / 1024; i++ }
53
+ printf "%.1f %s", v, units[i]
54
+ }'
55
+ }
56
+
57
+ clast_cmd_stats() {
58
+ local day_input="" since_input="" until_input=""
59
+ local day_filter="" since_date="" until_date=""
60
+ local project_filter=""
61
+
62
+ while [[ $# -gt 0 ]]; do
63
+ case "$1" in
64
+ --day)
65
+ if [[ $# -lt 2 ]]; then _clast_stats_err "--day requires a value"; return 2; fi
66
+ day_input="$2"
67
+ if ! day_filter="$(clast_parse_date "$2" 2>/dev/null)"; then
68
+ _clast_stats_err "invalid date '$2'"; return 2
69
+ fi
70
+ shift 2 ;;
71
+ --day=*)
72
+ day_input="${1#*=}"
73
+ if ! day_filter="$(clast_parse_date "$day_input" 2>/dev/null)"; then
74
+ _clast_stats_err "invalid date '$day_input'"; return 2
75
+ fi
76
+ shift ;;
77
+ --since)
78
+ if [[ $# -lt 2 ]]; then _clast_stats_err "--since requires a value"; return 2; fi
79
+ since_input="$2"
80
+ if ! since_date="$(clast_parse_date "$2" 2>/dev/null)"; then
81
+ _clast_stats_err "invalid date '$2'"; return 2
82
+ fi
83
+ shift 2 ;;
84
+ --since=*)
85
+ since_input="${1#*=}"
86
+ if ! since_date="$(clast_parse_date "$since_input" 2>/dev/null)"; then
87
+ _clast_stats_err "invalid date '$since_input'"; return 2
88
+ fi
89
+ shift ;;
90
+ --until)
91
+ if [[ $# -lt 2 ]]; then _clast_stats_err "--until requires a value"; return 2; fi
92
+ until_input="$2"
93
+ if ! until_date="$(clast_parse_date "$2" 2>/dev/null)"; then
94
+ _clast_stats_err "invalid date '$2'"; return 2
95
+ fi
96
+ shift 2 ;;
97
+ --until=*)
98
+ until_input="${1#*=}"
99
+ if ! until_date="$(clast_parse_date "$until_input" 2>/dev/null)"; then
100
+ _clast_stats_err "invalid date '$until_input'"; return 2
101
+ fi
102
+ shift ;;
103
+ --project)
104
+ if [[ $# -lt 2 ]]; then _clast_stats_err "--project requires a value"; return 2; fi
105
+ project_filter="$2"; shift 2 ;;
106
+ --project=*)
107
+ project_filter="${1#*=}"; shift ;;
108
+ -h|--help)
109
+ _clast_stats_usage; return 0 ;;
110
+ --) shift; break ;;
111
+ -*) _clast_stats_err "unknown flag '$1'"; return 2 ;;
112
+ *) _clast_stats_err "unexpected positional '$1'"; return 2 ;;
113
+ esac
114
+ done
115
+
116
+ if [[ -n "$day_filter" && ( -n "$since_date" || -n "$until_date" ) ]]; then
117
+ _clast_stats_err "--day cannot be combined with --since or --until"
118
+ return 2
119
+ fi
120
+
121
+ local today
122
+ today="$(clast_today)"
123
+ local label_suffix="" json_label=""
124
+ local window_start window_end
125
+ if [[ -n "$day_filter" ]]; then
126
+ window_start="$day_filter"
127
+ window_end="$day_filter"
128
+ if [[ "$day_filter" == "$today" ]]; then
129
+ label_suffix=" (today)"; json_label="today"
130
+ else
131
+ local yest
132
+ yest="$(clast_parse_date yesterday)"
133
+ if [[ "$day_filter" == "$yest" ]]; then
134
+ label_suffix=" (yesterday)"; json_label="yesterday"
135
+ fi
136
+ fi
137
+ elif [[ -z "$since_date" && -z "$until_date" ]]; then
138
+ window_start="$today"
139
+ window_end="$today"
140
+ label_suffix=" (today)"; json_label="today"
141
+ else
142
+ if [[ -z "$since_date" ]]; then
143
+ since_date="1970-01-01"
144
+ fi
145
+ local until_was_default=0
146
+ if [[ -z "$until_date" ]]; then
147
+ until_date="$today"
148
+ until_was_default=1
149
+ fi
150
+ window_start="$since_date"
151
+ window_end="$until_date"
152
+ if (( until_was_default == 1 )); then
153
+ label_suffix=" (through today)"; json_label="through_today"
154
+ fi
155
+ fi
156
+
157
+ if ! [[ "$window_start" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
158
+ _clast_stats_err "invalid date '$window_start'"; return 2
159
+ fi
160
+ if ! [[ "$window_end" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
161
+ _clast_stats_err "invalid date '$window_end'"; return 2
162
+ fi
163
+ if [[ "$window_start" > "$window_end" ]]; then
164
+ _clast_stats_err "--since must be <= --until"
165
+ return 2
166
+ fi
167
+
168
+ # Validate --project slug exists in registry.
169
+ if [[ -n "$project_filter" ]]; then
170
+ local reg_json slug_match
171
+ reg_json="$(clast_registry_list_json)"
172
+ slug_match="$(jq -r --arg s "$project_filter" \
173
+ 'map(select(.slug == $s)) | .[0].slug // empty' <<<"$reg_json")"
174
+ if [[ -z "$slug_match" ]]; then
175
+ _clast_stats_err "unknown project slug '$project_filter'" 1
176
+ return 1
177
+ fi
178
+ fi
179
+
180
+ if [[ -n "${CLAST_VERBOSE:-}" ]]; then
181
+ clast_log_info "stats: window $window_start..$window_end"
182
+ fi
183
+
184
+ # Stream manifest rows in window, reduce to most-recent per session_id.
185
+ local journal_dir
186
+ journal_dir="$(clast_journal_dir)"
187
+
188
+ local filter
189
+ if [[ "$window_start" == "$window_end" ]]; then
190
+ filter='.day_bucket == "'"$window_start"'"'
191
+ else
192
+ filter='.day_bucket >= "'"$window_start"'" and .day_bucket <= "'"$window_end"'"'
193
+ fi
194
+
195
+ local rows_json='[]'
196
+ local raw
197
+ raw="$(clast_manifest_iterate "$filter" | jq -cs \
198
+ 'group_by(.session_id) | map(max_by(.captured_at))')"
199
+ if [[ -n "$raw" ]]; then
200
+ rows_json="$raw"
201
+ fi
202
+
203
+ local n
204
+ n="$(jq 'length' <<<"$rows_json")"
205
+
206
+ # Resolve project slug per row, apply --project filter.
207
+ local -A slug_counts=()
208
+ local i row source snapshot seg slug bytes_sum=0 msgs_sum=0
209
+ local n_sessions=0
210
+ for (( i = 0; i < n; i++ )); do
211
+ row="$(jq -c ".[$i]" <<<"$rows_json")"
212
+ source="$(jq -r '.source' <<<"$row")"
213
+ snapshot="$(jq -r '.snapshot' <<<"$row")"
214
+ seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot")"
215
+
216
+ slug=""
217
+ if [[ "$source" != "null" && -n "$source" ]]; then
218
+ # Prefer the manifest source (authoritative under path moves) — try
219
+ # dirname(source) since the registry stores project directories while
220
+ # source carries the .jsonl filename. Fall back to the snapshot
221
+ # segment when the source path is not registered.
222
+ local source_dir
223
+ source_dir="$(dirname "$source")"
224
+ if ! slug="$(clast_registry_resolve "$source_dir" 2>/dev/null)" || [[ -z "$slug" ]]; then
225
+ if ! slug="$(clast_registry_resolve "$seg" 2>/dev/null)" || [[ -z "$slug" ]]; then
226
+ slug="$seg"
227
+ fi
228
+ fi
229
+ fi
230
+
231
+ if [[ -n "$project_filter" ]]; then
232
+ if [[ "$slug" != "$project_filter" ]]; then
233
+ continue
234
+ fi
235
+ fi
236
+
237
+ n_sessions=$((n_sessions + 1))
238
+ if [[ -n "$slug" ]]; then
239
+ slug_counts["$slug"]=1
240
+ fi
241
+
242
+ local size
243
+ size="$(jq -r '.source_size // 0' <<<"$row")"
244
+ [[ "$size" =~ ^[0-9]+$ ]] || size=0
245
+ bytes_sum=$((bytes_sum + size))
246
+
247
+ local abs="$journal_dir/$snapshot" m=0
248
+ if [[ -r "$abs" ]]; then
249
+ m="$(wc -l <"$abs" 2>/dev/null | tr -d ' ')"
250
+ [[ -z "$m" ]] && m=0
251
+ fi
252
+ msgs_sum=$((msgs_sum + m))
253
+ done
254
+
255
+ local n_projects=${#slug_counts[@]}
256
+
257
+ # Curated count: entries/*.md files whose date prefix is in window.
258
+ local curated=0
259
+ local entries_dir="$journal_dir/entries"
260
+ if [[ -d "$entries_dir" ]]; then
261
+ local f base prefix
262
+ while IFS= read -r f; do
263
+ [[ -z "$f" ]] && continue
264
+ base="$(basename "$f")"
265
+ prefix="${base:0:10}"
266
+ if [[ "$prefix" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
267
+ && ! [[ "$prefix" < "$window_start" ]] \
268
+ && ! [[ "$prefix" > "$window_end" ]]; then
269
+ curated=$((curated + 1))
270
+ fi
271
+ done < <(find "$entries_dir" -mindepth 1 -maxdepth 1 -type f -name '*.md' 2>/dev/null)
272
+ fi
273
+
274
+ local curated_pct=0
275
+ if (( n_sessions > 0 )); then
276
+ curated_pct=$(( (curated * 100 + n_sessions / 2) / n_sessions ))
277
+ if (( curated_pct > 100 )); then curated_pct=100; fi
278
+ fi
279
+
280
+ # Breadcrumb count: breadcrumbs/<YYYY-MM-DD>-<slug>.md in window.
281
+ local breadcrumbs=0
282
+ local -A breadcrumb_slugs=()
283
+ local breadcrumbs_dir="$journal_dir/breadcrumbs"
284
+ if [[ -d "$breadcrumbs_dir" ]]; then
285
+ local bf bname bprefix brest bslug
286
+ while IFS= read -r bf; do
287
+ [[ -z "$bf" ]] && continue
288
+ bname="$(basename "$bf")"
289
+ bprefix="${bname:0:10}"
290
+ if [[ "$bprefix" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
291
+ && ! [[ "$bprefix" < "$window_start" ]] \
292
+ && ! [[ "$bprefix" > "$window_end" ]]; then
293
+ breadcrumbs=$((breadcrumbs + 1))
294
+ brest="${bname:11}"
295
+ bslug="${brest%.md}"
296
+ if [[ -n "$bslug" ]]; then
297
+ breadcrumb_slugs["$bslug"]=1
298
+ fi
299
+ fi
300
+ done < <(find "$breadcrumbs_dir" -mindepth 1 -maxdepth 1 -type f -name '*.md' 2>/dev/null)
301
+ fi
302
+ local breadcrumb_projects=${#breadcrumb_slugs[@]}
303
+
304
+ local bytes_human
305
+ bytes_human="$(_clast_stats_human_bytes "$bytes_sum")"
306
+
307
+ if [[ -n "${CLAST_JSON:-}" ]]; then
308
+ jq -cn \
309
+ --arg start "$window_start" \
310
+ --arg end "$window_end" \
311
+ --arg label "$json_label" \
312
+ --argjson projects "$n_projects" \
313
+ --argjson sessions "$n_sessions" \
314
+ --argjson messages_approx "$msgs_sum" \
315
+ --argjson bytes "$bytes_sum" \
316
+ --arg bytes_human "$bytes_human" \
317
+ --argjson curated "$curated" \
318
+ --argjson curated_pct "$curated_pct" \
319
+ --argjson breadcrumbs "$breadcrumbs" \
320
+ --argjson breadcrumb_projects "$breadcrumb_projects" \
321
+ '{
322
+ window: {start:$start, end:$end, label:$label},
323
+ projects: $projects,
324
+ sessions: $sessions,
325
+ messages_approx: $messages_approx,
326
+ bytes: $bytes,
327
+ bytes_human: $bytes_human,
328
+ curated: $curated,
329
+ curated_pct: $curated_pct,
330
+ breadcrumbs: $breadcrumbs,
331
+ breadcrumb_projects: $breadcrumb_projects
332
+ }'
333
+ return 0
334
+ fi
335
+
336
+ if [[ -n "${CLAST_QUIET:-}" ]]; then
337
+ return 0
338
+ fi
339
+
340
+ local window_text
341
+ if [[ "$window_start" == "$window_end" ]]; then
342
+ window_text="$window_start"
343
+ else
344
+ window_text="$window_start..$window_end"
345
+ fi
346
+
347
+ printf '%-12s %s%s\n' "Window:" "$window_text" "$label_suffix"
348
+ printf '%-12s %d\n' "Projects:" "$n_projects"
349
+ printf '%-12s %d\n' "Sessions:" "$n_sessions"
350
+ printf '%-12s %d (approx)\n' "Messages:" "$msgs_sum"
351
+ printf '%-12s %s\n' "Bytes:" "$bytes_human"
352
+ printf '%-12s %d of %d sessions (%d%%)\n' "Curated:" "$curated" "$n_sessions" "$curated_pct"
353
+ printf '%-12s %d across %d projects\n' "Breadcrumbs:" "$breadcrumbs" "$breadcrumb_projects"
354
+ }