@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.
- package/.claude-plugin/plugin.json +11 -0
- package/.claude-plugin/skills/day-wakeup/SKILL.md +194 -0
- package/.claude-plugin/skills/wakeup/SKILL.md +115 -0
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/bin/.gitkeep +0 -0
- package/bin/clast +172 -0
- package/examples/config/config.toml.sample +32 -0
- package/examples/cron/clast-snapshot.service +10 -0
- package/examples/cron/clast-snapshot.timer +15 -0
- package/examples/cron/crontab.sample +16 -0
- package/examples/workflows/morning-briefing.md +137 -0
- package/hooks/hooks.json +8 -0
- package/hooks/snapshot.sh +15 -0
- package/lib/clast/.gitkeep +0 -0
- package/lib/clast/clast-decode-lib.bash +184 -0
- package/lib/clast/clast-lib.bash +233 -0
- package/lib/clast/clast-manifest-lib.bash +232 -0
- package/lib/clast/clast-registry-lib.bash +252 -0
- package/lib/clast/clast-subcommands/.gitkeep +0 -0
- package/lib/clast/clast-subcommands/breadcrumb.bash +454 -0
- package/lib/clast/clast-subcommands/doctor.bash +607 -0
- package/lib/clast/clast-subcommands/entries.bash +697 -0
- package/lib/clast/clast-subcommands/projects.bash +309 -0
- package/lib/clast/clast-subcommands/registry.bash +207 -0
- package/lib/clast/clast-subcommands/sessions.bash +275 -0
- package/lib/clast/clast-subcommands/show.bash +283 -0
- package/lib/clast/clast-subcommands/snapshot.bash +294 -0
- package/lib/clast/clast-subcommands/stats.bash +354 -0
- package/lib/clast/clast-subcommands/whereami.bash +108 -0
- package/package.json +39 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# clast-lib.bash — common helpers
|
|
2
|
+
#
|
|
3
|
+
# Sourced by the dispatcher and (transitively) by subcommands and tests.
|
|
4
|
+
# Requires bash 4.4+, jq, and GNU coreutils `date` on PATH.
|
|
5
|
+
# shellcheck shell=bash
|
|
6
|
+
|
|
7
|
+
# Guard against double-sourcing.
|
|
8
|
+
if [[ -n "${_CLAST_LIB_SOURCED:-}" ]]; then
|
|
9
|
+
return 0
|
|
10
|
+
fi
|
|
11
|
+
_CLAST_LIB_SOURCED=1
|
|
12
|
+
|
|
13
|
+
# Hard dependency check: jq must be on PATH. Exit 3 (env problem) per
|
|
14
|
+
# docs/overview.md#exit-codes if missing — no grep/sed fallback.
|
|
15
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
16
|
+
echo "clast: error: required dependency 'jq' not found on PATH" >&2
|
|
17
|
+
exit 3
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# --- Path helpers --------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
clast_journal_dir() {
|
|
23
|
+
printf '%s\n' "${CLAST_JOURNAL_DIR:-$HOME/.claude/journal}"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
clast_projects_dir() {
|
|
27
|
+
printf '%s\n' "${CLAST_PROJECTS_DIR:-$HOME/.claude/projects}"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# --- Logging -------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# CLAST_QUIET=1 silences info logs. The global --quiet flag (step 03)
|
|
33
|
+
# will set this before any subcommand runs.
|
|
34
|
+
clast_log_info() {
|
|
35
|
+
if [[ -z "${CLAST_QUIET:-}" ]]; then
|
|
36
|
+
printf 'clast: info: %s\n' "$*" >&2
|
|
37
|
+
fi
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clast_log_warn() {
|
|
41
|
+
printf 'clast: warn: %s\n' "$*" >&2
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clast_log_error() {
|
|
45
|
+
printf 'clast: error: %s\n' "$*" >&2
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# --- JSON ----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# clast_json_get <jq-expression> <input>
|
|
51
|
+
# Thin wrapper around jq. <input> is a JSON string (not a file).
|
|
52
|
+
# Use process substitution for file input: clast_json_get '.x' "$(cat f.json)".
|
|
53
|
+
clast_json_get() {
|
|
54
|
+
local expr="$1" input="$2"
|
|
55
|
+
jq -r "$expr" <<<"$input"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# --- Date math -----------------------------------------------------------
|
|
59
|
+
#
|
|
60
|
+
# Uses GNU `date -d` for relative-date math. The nix dev shell pulls in
|
|
61
|
+
# coreutils, so this works on macOS too when invoked from inside the shell.
|
|
62
|
+
# BSD `date` is NOT supported.
|
|
63
|
+
|
|
64
|
+
# _clast_now_epoch — overridable hook so tests can inject a fixed "now".
|
|
65
|
+
# Tests set CLAST_NOW_EPOCH=<seconds> to freeze time.
|
|
66
|
+
_clast_now_epoch() {
|
|
67
|
+
if [[ -n "${CLAST_NOW_EPOCH:-}" ]]; then
|
|
68
|
+
printf '%s\n' "$CLAST_NOW_EPOCH"
|
|
69
|
+
else
|
|
70
|
+
date +%s
|
|
71
|
+
fi
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# clast_today — local YYYY-MM-DD, adjusted by CLAST_DAY_CUTOFF (HH:MM, default 04:00).
|
|
75
|
+
# A session starting before today's cutoff belongs to yesterday's bucket.
|
|
76
|
+
clast_today() {
|
|
77
|
+
local cutoff="${CLAST_DAY_CUTOFF:-04:00}"
|
|
78
|
+
local cutoff_hours cutoff_mins cutoff_secs now adjusted
|
|
79
|
+
cutoff_hours="${cutoff%%:*}"
|
|
80
|
+
cutoff_mins="${cutoff##*:}"
|
|
81
|
+
# Strip leading zeros so bash arithmetic doesn't treat them as octal.
|
|
82
|
+
cutoff_hours=$((10#$cutoff_hours))
|
|
83
|
+
cutoff_mins=$((10#$cutoff_mins))
|
|
84
|
+
cutoff_secs=$((cutoff_hours * 3600 + cutoff_mins * 60))
|
|
85
|
+
now="$(_clast_now_epoch)"
|
|
86
|
+
adjusted=$((now - cutoff_secs))
|
|
87
|
+
date -d "@$adjusted" +%Y-%m-%d
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# clast_parse_date <input> — print YYYY-MM-DD on stdout, exit non-zero on bad input.
|
|
91
|
+
# Accepts:
|
|
92
|
+
# - ISO date: 2026-05-30
|
|
93
|
+
# - Keywords: today, yesterday, last-week
|
|
94
|
+
# - Offsets: -1d, -3d, -1w, -2w (negative-only; future dates not supported)
|
|
95
|
+
clast_parse_date() {
|
|
96
|
+
local input="$1"
|
|
97
|
+
if [[ -z "$input" ]]; then
|
|
98
|
+
clast_log_error "clast_parse_date: empty input"
|
|
99
|
+
return 2
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
case "$input" in
|
|
103
|
+
today)
|
|
104
|
+
clast_today
|
|
105
|
+
return 0
|
|
106
|
+
;;
|
|
107
|
+
yesterday)
|
|
108
|
+
_clast_offset_date 1 d
|
|
109
|
+
return 0
|
|
110
|
+
;;
|
|
111
|
+
last-week)
|
|
112
|
+
_clast_offset_date 1 w
|
|
113
|
+
return 0
|
|
114
|
+
;;
|
|
115
|
+
-[0-9]*d|-[0-9]*w)
|
|
116
|
+
local n unit
|
|
117
|
+
n="${input#-}"
|
|
118
|
+
unit="${n: -1}"
|
|
119
|
+
n="${n%?}"
|
|
120
|
+
if ! [[ "$n" =~ ^[0-9]+$ ]]; then
|
|
121
|
+
clast_log_error "clast_parse_date: invalid offset '$input'"
|
|
122
|
+
return 2
|
|
123
|
+
fi
|
|
124
|
+
_clast_offset_date "$n" "$unit"
|
|
125
|
+
return 0
|
|
126
|
+
;;
|
|
127
|
+
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])
|
|
128
|
+
# Validate by round-tripping through GNU date.
|
|
129
|
+
if ! date -d "$input" +%Y-%m-%d 2>/dev/null; then
|
|
130
|
+
clast_log_error "clast_parse_date: invalid ISO date '$input'"
|
|
131
|
+
return 2
|
|
132
|
+
fi
|
|
133
|
+
return 0
|
|
134
|
+
;;
|
|
135
|
+
*)
|
|
136
|
+
clast_log_error "clast_parse_date: unrecognized '$input'"
|
|
137
|
+
return 2
|
|
138
|
+
;;
|
|
139
|
+
esac
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# _clast_offset_date <n> <d|w> — print today's bucket minus N days/weeks.
|
|
143
|
+
_clast_offset_date() {
|
|
144
|
+
local n="$1" unit="$2" days base
|
|
145
|
+
case "$unit" in
|
|
146
|
+
d) days="$n" ;;
|
|
147
|
+
w) days=$((n * 7)) ;;
|
|
148
|
+
*)
|
|
149
|
+
clast_log_error "_clast_offset_date: bad unit '$unit'"
|
|
150
|
+
return 2
|
|
151
|
+
;;
|
|
152
|
+
esac
|
|
153
|
+
base="$(clast_today)"
|
|
154
|
+
date -d "$base -$days days" +%Y-%m-%d
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# --- Atomic write --------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
# clast_atomic_write <path> <content>
|
|
160
|
+
# Writes <content> to <path>.tmp.$$ then renames over <path>.
|
|
161
|
+
# On any failure during the temp write, leaves the original untouched and
|
|
162
|
+
# removes the temp file.
|
|
163
|
+
clast_atomic_write() {
|
|
164
|
+
local path="$1" content="$2" tmp
|
|
165
|
+
tmp="$path.tmp.$$"
|
|
166
|
+
if ! printf '%s' "$content" >"$tmp" 2>/dev/null; then
|
|
167
|
+
rm -f "$tmp" 2>/dev/null || true
|
|
168
|
+
clast_log_error "clast_atomic_write: failed to write '$tmp'"
|
|
169
|
+
return 1
|
|
170
|
+
fi
|
|
171
|
+
if ! mv -f "$tmp" "$path" 2>/dev/null; then
|
|
172
|
+
rm -f "$tmp" 2>/dev/null || true
|
|
173
|
+
clast_log_error "clast_atomic_write: failed to rename onto '$path'"
|
|
174
|
+
return 1
|
|
175
|
+
fi
|
|
176
|
+
return 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# --- Version / usage -----------------------------------------------------
|
|
180
|
+
|
|
181
|
+
_CLAST_VERSION_CACHE=""
|
|
182
|
+
|
|
183
|
+
clast_version() {
|
|
184
|
+
if [[ -n "$_CLAST_VERSION_CACHE" ]]; then
|
|
185
|
+
printf '%s\n' "$_CLAST_VERSION_CACHE"
|
|
186
|
+
return 0
|
|
187
|
+
fi
|
|
188
|
+
local pkg pkg_root
|
|
189
|
+
pkg_root="${CLAST_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
190
|
+
# CLAST_LIB points at lib/clast; the package.json sits two levels up.
|
|
191
|
+
if [[ -f "$pkg_root/package.json" ]]; then
|
|
192
|
+
pkg="$pkg_root/package.json"
|
|
193
|
+
elif [[ -f "$pkg_root/../../package.json" ]]; then
|
|
194
|
+
pkg="$pkg_root/../../package.json"
|
|
195
|
+
else
|
|
196
|
+
clast_log_error "clast_version: package.json not found"
|
|
197
|
+
return 1
|
|
198
|
+
fi
|
|
199
|
+
_CLAST_VERSION_CACHE="$(jq -r '.version' "$pkg")"
|
|
200
|
+
printf '%s\n' "$_CLAST_VERSION_CACHE"
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# clast_usage — print top-level usage to stdout. The dispatcher redirects
|
|
204
|
+
# to stderr for usage errors.
|
|
205
|
+
clast_usage() {
|
|
206
|
+
cat <<'EOF'
|
|
207
|
+
clast — Claude Code session journal
|
|
208
|
+
|
|
209
|
+
Usage:
|
|
210
|
+
clast [GLOBAL FLAGS] <subcommand> [ARGS...]
|
|
211
|
+
|
|
212
|
+
Subcommands:
|
|
213
|
+
whereami Show current path, registry, and journal state
|
|
214
|
+
snapshot Capture new transcripts into the journal
|
|
215
|
+
projects List projects with activity in a window
|
|
216
|
+
sessions List sessions in a window
|
|
217
|
+
show Dump session metadata
|
|
218
|
+
entries List or read curated journal entries
|
|
219
|
+
breadcrumb Append a one-line in-flight hint
|
|
220
|
+
registry Manage the project registry
|
|
221
|
+
stats Token/duration/session-count stats
|
|
222
|
+
doctor Sanity-check the journal
|
|
223
|
+
|
|
224
|
+
Global flags:
|
|
225
|
+
-h, --help Print this usage and exit
|
|
226
|
+
--version Print version and exit
|
|
227
|
+
--json Machine-readable JSON output
|
|
228
|
+
-v, --verbose Extra diagnostic output to stderr
|
|
229
|
+
-q, --quiet Suppress informational stdout output
|
|
230
|
+
--journal-dir P Override ~/.claude/journal/ (env: CLAST_JOURNAL_DIR)
|
|
231
|
+
--projects-dir P Override ~/.claude/projects/ (env: CLAST_PROJECTS_DIR)
|
|
232
|
+
EOF
|
|
233
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# clast-manifest-lib.bash — manifest read/append/lookup/iterate/rebuild
|
|
2
|
+
#
|
|
3
|
+
# Sourced after clast-lib.bash. The manifest is an append-only JSONL log at
|
|
4
|
+
# $(clast_journal_dir)/.manifest.jsonl, one line per capture event. Per
|
|
5
|
+
# docs/cli-contract.md#manifest-line each line has seven required fields:
|
|
6
|
+
# session_id, source, snapshot, captured_at, source_mtime, source_size,
|
|
7
|
+
# day_bucket. Lookups use "most recent line wins" semantics.
|
|
8
|
+
# shellcheck shell=bash
|
|
9
|
+
# shellcheck source=lib/clast/clast-lib.bash
|
|
10
|
+
|
|
11
|
+
if [[ -n "${_CLAST_MANIFEST_LIB_SOURCED:-}" ]]; then
|
|
12
|
+
return 0
|
|
13
|
+
fi
|
|
14
|
+
_CLAST_MANIFEST_LIB_SOURCED=1
|
|
15
|
+
|
|
16
|
+
# clast_manifest_path — single chokepoint so CLAST_JOURNAL_DIR override
|
|
17
|
+
# (via clast_journal_dir) redirects every read/write in this file.
|
|
18
|
+
clast_manifest_path() {
|
|
19
|
+
printf '%s\n' "$(clast_journal_dir)/.manifest.jsonl"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# _clast_manifest_now_iso — current UTC time, ISO 8601 with Z suffix.
|
|
23
|
+
# Honors CLAST_NOW_EPOCH (test-only freeze hook from clast-lib.bash).
|
|
24
|
+
_clast_manifest_now_iso() {
|
|
25
|
+
local epoch="${CLAST_NOW_EPOCH:-$(date +%s)}"
|
|
26
|
+
date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# clast_manifest_append <session-id> <source> <snapshot> <source-mtime> <source-size> <day-bucket>
|
|
30
|
+
clast_manifest_append() {
|
|
31
|
+
if [[ $# -ne 6 ]]; then
|
|
32
|
+
clast_log_error "clast_manifest_append: expected 6 args, got $#"
|
|
33
|
+
return 2
|
|
34
|
+
fi
|
|
35
|
+
local session_id="$1" source="$2" snapshot="$3" source_mtime="$4" source_size="$5" day_bucket="$6"
|
|
36
|
+
local field
|
|
37
|
+
for field in session_id source snapshot source_mtime source_size day_bucket; do
|
|
38
|
+
if [[ -z "${!field}" ]]; then
|
|
39
|
+
clast_log_error "clast_manifest_append: empty field '$field'"
|
|
40
|
+
return 2
|
|
41
|
+
fi
|
|
42
|
+
done
|
|
43
|
+
if ! [[ "$source_size" =~ ^[0-9]+$ ]]; then
|
|
44
|
+
clast_log_error "clast_manifest_append: source_size must be a non-negative integer, got '$source_size'"
|
|
45
|
+
return 2
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
local journal_dir
|
|
49
|
+
journal_dir="$(clast_journal_dir)"
|
|
50
|
+
if ! mkdir -p "$journal_dir"; then
|
|
51
|
+
clast_log_error "clast_manifest_append: failed to create '$journal_dir'"
|
|
52
|
+
return 1
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
local captured_at line
|
|
56
|
+
captured_at="$(_clast_manifest_now_iso)"
|
|
57
|
+
line="$(jq -c -n \
|
|
58
|
+
--arg session_id "$session_id" \
|
|
59
|
+
--arg source "$source" \
|
|
60
|
+
--arg snapshot "$snapshot" \
|
|
61
|
+
--arg captured_at "$captured_at" \
|
|
62
|
+
--arg source_mtime "$source_mtime" \
|
|
63
|
+
--argjson source_size "$source_size" \
|
|
64
|
+
--arg day_bucket "$day_bucket" \
|
|
65
|
+
'{session_id: $session_id, source: $source, snapshot: $snapshot, captured_at: $captured_at, source_mtime: $source_mtime, source_size: $source_size, day_bucket: $day_bucket}')" || {
|
|
66
|
+
clast_log_error "clast_manifest_append: jq failed to build manifest line"
|
|
67
|
+
return 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Append-only single-line write — crash-safe at line boundary per
|
|
71
|
+
# docs/overview.md#cross-machine-considerations. Do NOT use
|
|
72
|
+
# clast_atomic_write here: it rewrites the whole file and would clobber
|
|
73
|
+
# concurrent appends from another machine.
|
|
74
|
+
local manifest_path
|
|
75
|
+
manifest_path="$(clast_manifest_path)"
|
|
76
|
+
if ! printf '%s\n' "$line" >>"$manifest_path" 2>/dev/null; then
|
|
77
|
+
clast_log_error "clast_manifest_append: failed to append to '$manifest_path'"
|
|
78
|
+
return 1
|
|
79
|
+
fi
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# clast_manifest_lookup <session-id>
|
|
83
|
+
# Print the most recent manifest line for <session-id>. Exit 1 if no
|
|
84
|
+
# match (including when the manifest file does not exist).
|
|
85
|
+
clast_manifest_lookup() {
|
|
86
|
+
if [[ $# -ne 1 ]]; then
|
|
87
|
+
clast_log_error "clast_manifest_lookup: expected 1 arg, got $#"
|
|
88
|
+
return 2
|
|
89
|
+
fi
|
|
90
|
+
local sid="$1" path
|
|
91
|
+
path="$(clast_manifest_path)"
|
|
92
|
+
if [[ ! -f "$path" ]]; then
|
|
93
|
+
return 1
|
|
94
|
+
fi
|
|
95
|
+
# Manifest is append-only so file order is time order; tac to scan from
|
|
96
|
+
# newest backward, fromjson? skips garbage lines silently, head -n1 stops
|
|
97
|
+
# at the first match.
|
|
98
|
+
local line
|
|
99
|
+
line="$(tac "$path" | jq -cR --arg sid "$sid" 'fromjson? | select(.session_id == $sid)' | head -n1)"
|
|
100
|
+
if [[ -z "$line" ]]; then
|
|
101
|
+
return 1
|
|
102
|
+
fi
|
|
103
|
+
printf '%s\n' "$line"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# clast_manifest_has_capture <session-id> <source-mtime>
|
|
107
|
+
# Exit 0 if a manifest line exists for this (session_id, source_mtime)
|
|
108
|
+
# pair; exit 1 otherwise. Fast-path predicate for `clast snapshot`.
|
|
109
|
+
clast_manifest_has_capture() {
|
|
110
|
+
if [[ $# -ne 2 ]]; then
|
|
111
|
+
clast_log_error "clast_manifest_has_capture: expected 2 args, got $#"
|
|
112
|
+
return 2
|
|
113
|
+
fi
|
|
114
|
+
local sid="$1" mtime="$2" path
|
|
115
|
+
path="$(clast_manifest_path)"
|
|
116
|
+
if [[ ! -f "$path" ]]; then
|
|
117
|
+
return 1
|
|
118
|
+
fi
|
|
119
|
+
local match
|
|
120
|
+
match="$(jq -cR --arg sid "$sid" --arg mtime "$mtime" \
|
|
121
|
+
'fromjson? | select(.session_id == $sid and .source_mtime == $mtime)' \
|
|
122
|
+
"$path" | head -n1)"
|
|
123
|
+
if [[ -z "$match" ]]; then
|
|
124
|
+
return 1
|
|
125
|
+
fi
|
|
126
|
+
return 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# clast_manifest_iterate <jq-filter-body>
|
|
130
|
+
# Stream every manifest line whose parsed object matches the supplied
|
|
131
|
+
# select() body (e.g. '.day_bucket == "2026-05-30"'). Malformed lines
|
|
132
|
+
# are silently skipped via fromjson?. Missing manifest = no output, 0.
|
|
133
|
+
clast_manifest_iterate() {
|
|
134
|
+
if [[ $# -ne 1 ]]; then
|
|
135
|
+
clast_log_error "clast_manifest_iterate: expected 1 arg, got $#"
|
|
136
|
+
return 2
|
|
137
|
+
fi
|
|
138
|
+
local filter="$1" path
|
|
139
|
+
path="$(clast_manifest_path)"
|
|
140
|
+
if [[ ! -f "$path" ]]; then
|
|
141
|
+
return 0
|
|
142
|
+
fi
|
|
143
|
+
jq -cR 'fromjson? | select('"$filter"')' "$path"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# clast_manifest_rebuild_from_disk
|
|
147
|
+
# Walk $(clast_journal_dir)/transcripts/<day>/<segment>/<uuid>.jsonl and
|
|
148
|
+
# write a best-effort manifest atomically. For doctor --fix (step 10).
|
|
149
|
+
# source / source_size are unrecoverable from the snapshot alone, so
|
|
150
|
+
# they're emitted as null / 0 — round-trips through lookup, schema
|
|
151
|
+
# validators can still flag the lossy lines.
|
|
152
|
+
clast_manifest_rebuild_from_disk() {
|
|
153
|
+
local journal_dir manifest_path transcripts_root tmp_file
|
|
154
|
+
journal_dir="$(clast_journal_dir)"
|
|
155
|
+
manifest_path="$(clast_manifest_path)"
|
|
156
|
+
transcripts_root="$journal_dir/transcripts"
|
|
157
|
+
|
|
158
|
+
if ! mkdir -p "$journal_dir"; then
|
|
159
|
+
clast_log_error "clast_manifest_rebuild_from_disk: failed to create '$journal_dir'"
|
|
160
|
+
return 1
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
tmp_file="$(mktemp "$journal_dir/.manifest.jsonl.rebuild.XXXXXX")" || {
|
|
164
|
+
clast_log_error "clast_manifest_rebuild_from_disk: mktemp failed"
|
|
165
|
+
return 1
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
local count=0
|
|
169
|
+
if [[ -d "$transcripts_root" ]]; then
|
|
170
|
+
local snapshot day session_id mtime_epoch mtime_iso line
|
|
171
|
+
# source / source_size are unrecoverable: the snapshot file's size is
|
|
172
|
+
# the captured copy's size, not the original source's size at capture
|
|
173
|
+
# time, and the source path is not encoded in the snapshot itself.
|
|
174
|
+
while IFS= read -r snapshot; do
|
|
175
|
+
[[ -z "$snapshot" ]] && continue
|
|
176
|
+
session_id="$(basename "$snapshot" .jsonl)"
|
|
177
|
+
day="$(basename "$(dirname "$(dirname "$snapshot")")")"
|
|
178
|
+
if ! mtime_epoch="$(stat -c %Y "$snapshot" 2>/dev/null || stat -f %m "$snapshot" 2>/dev/null)" || [[ -z "$mtime_epoch" ]]; then
|
|
179
|
+
clast_log_error "clast_manifest_rebuild_from_disk: stat failed for '$snapshot'"
|
|
180
|
+
rm -f "$tmp_file"
|
|
181
|
+
return 1
|
|
182
|
+
fi
|
|
183
|
+
if ! mtime_iso="$(date -u -d "@$mtime_epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || [[ -z "$mtime_iso" ]]; then
|
|
184
|
+
clast_log_error "clast_manifest_rebuild_from_disk: date conversion failed for '$snapshot' (epoch '$mtime_epoch')"
|
|
185
|
+
rm -f "$tmp_file"
|
|
186
|
+
return 1
|
|
187
|
+
fi
|
|
188
|
+
line="$(jq -c -n \
|
|
189
|
+
--arg session_id "$session_id" \
|
|
190
|
+
--arg snapshot "${snapshot#"$journal_dir/"}" \
|
|
191
|
+
--arg captured_at "$mtime_iso" \
|
|
192
|
+
--arg source_mtime "$mtime_iso" \
|
|
193
|
+
--arg day_bucket "$day" \
|
|
194
|
+
'{session_id: $session_id, source: null, snapshot: $snapshot, captured_at: $captured_at, source_mtime: $source_mtime, source_size: 0, day_bucket: $day_bucket}')" || {
|
|
195
|
+
clast_log_error "clast_manifest_rebuild_from_disk: jq failed for '$snapshot'"
|
|
196
|
+
rm -f "$tmp_file"
|
|
197
|
+
return 1
|
|
198
|
+
}
|
|
199
|
+
if ! printf '%s\n' "$line" >>"$tmp_file" 2>/dev/null; then
|
|
200
|
+
clast_log_error "clast_manifest_rebuild_from_disk: failed to write to '$tmp_file'"
|
|
201
|
+
rm -f "$tmp_file"
|
|
202
|
+
return 1
|
|
203
|
+
fi
|
|
204
|
+
count=$((count + 1))
|
|
205
|
+
done < <(find "$transcripts_root" -mindepth 3 -maxdepth 3 -type f -name '*.jsonl' | sort)
|
|
206
|
+
|
|
207
|
+
# Sort the temp file by captured_at ascending. The find|sort above sorts
|
|
208
|
+
# by path; re-sort by captured_at to honor the documented invariant.
|
|
209
|
+
if (( count > 0 )); then
|
|
210
|
+
local sorted
|
|
211
|
+
sorted="$(jq -sc 'sort_by(.captured_at) | .[]' "$tmp_file")" || {
|
|
212
|
+
clast_log_error "clast_manifest_rebuild_from_disk: failed to sort rebuilt manifest"
|
|
213
|
+
rm -f "$tmp_file"
|
|
214
|
+
return 1
|
|
215
|
+
}
|
|
216
|
+
if ! printf '%s\n' "$sorted" >"$tmp_file" 2>/dev/null; then
|
|
217
|
+
clast_log_error "clast_manifest_rebuild_from_disk: failed to write sorted manifest to '$tmp_file'"
|
|
218
|
+
rm -f "$tmp_file"
|
|
219
|
+
return 1
|
|
220
|
+
fi
|
|
221
|
+
fi
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
if ! mv -f "$tmp_file" "$manifest_path"; then
|
|
225
|
+
clast_log_error "clast_manifest_rebuild_from_disk: failed to rename onto '$manifest_path'"
|
|
226
|
+
rm -f "$tmp_file"
|
|
227
|
+
return 1
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
clast_log_info "manifest rebuilt: $count line(s)"
|
|
231
|
+
return 0
|
|
232
|
+
}
|