@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,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
+ }