@procrastivity/clast 0.0.1 → 0.0.2

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/bin/clast-wake ADDED
@@ -0,0 +1,579 @@
1
+ #!/usr/bin/env bash
2
+ # clast-wake — LLM-powered day curation without Claude Code.
3
+ #
4
+ # Replicates the /day-wakeup plugin skill using an OpenAI-compatible
5
+ # chat completions endpoint. Calls clast commands for data, assembles
6
+ # prompts, calls the LLM via curl, presents drafts interactively.
7
+ #
8
+ # Required env vars:
9
+ # CLAST_LLM_BASE_URL — e.g. https://api.openai.com/v1
10
+ # CLAST_LLM_API_KEY — bearer token
11
+ # CLAST_LLM_MODEL — e.g. gpt-4o, llama3
12
+ set -euo pipefail
13
+
14
+ CLAST_WAKE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
15
+
16
+ # ── Helpers ──────────────────────────────────────────────────────────
17
+
18
+ die() { printf 'clast-wake: %s\n' "$1" >&2; exit "${2:-1}"; }
19
+ warn() { printf 'clast-wake: warning: %s\n' "$1" >&2; }
20
+ info() { printf '%s\n' "$1"; }
21
+
22
+ slugify() {
23
+ local s="$1"
24
+ s="${s,,}"
25
+ s="$(printf '%s' "$s" | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//')"
26
+ printf '%s' "${s:0:60}"
27
+ }
28
+
29
+ separator() {
30
+ local label="$1"
31
+ local width=72
32
+ local pad=$(( width - ${#label} - 4 ))
33
+ (( pad < 2 )) && pad=2
34
+ local dashes=""
35
+ local j
36
+ for (( j = 0; j < pad; j++ )); do dashes+="─"; done
37
+ printf '── %s %s\n' "$label" "$dashes"
38
+ }
39
+
40
+ # ── Preflight ────────────────────────────────────────────────────────
41
+
42
+ preflight() {
43
+ if [[ ! -t 0 ]]; then
44
+ die "clast-wake requires an interactive terminal (stdin is not a tty)."
45
+ fi
46
+
47
+ for tool in clast curl jq; do
48
+ if ! command -v "$tool" >/dev/null 2>&1; then
49
+ die "required tool not found: $tool"
50
+ fi
51
+ done
52
+
53
+ local missing=0
54
+ if [[ -z "${CLAST_LLM_BASE_URL:-}" ]]; then
55
+ warn "CLAST_LLM_BASE_URL not set"; missing=1
56
+ fi
57
+ if [[ -z "${CLAST_LLM_API_KEY:-}" ]]; then
58
+ warn "CLAST_LLM_API_KEY not set"; missing=1
59
+ fi
60
+ if [[ -z "${CLAST_LLM_MODEL:-}" ]]; then
61
+ warn "CLAST_LLM_MODEL not set"; missing=1
62
+ fi
63
+
64
+ if (( missing )); then
65
+ cat >&2 <<'EOF'
66
+
67
+ Set these env vars before running clast-wake:
68
+
69
+ export CLAST_LLM_BASE_URL="https://api.openai.com/v1"
70
+ export CLAST_LLM_API_KEY="sk-..."
71
+ export CLAST_LLM_MODEL="gpt-4o"
72
+
73
+ Or for a local model (ollama, vllm, etc.):
74
+
75
+ export CLAST_LLM_BASE_URL="http://localhost:11434/v1"
76
+ export CLAST_LLM_API_KEY="unused"
77
+ export CLAST_LLM_MODEL="llama3"
78
+ EOF
79
+ exit 1
80
+ fi
81
+ }
82
+
83
+ # ── LLM call ─────────────────────────────────────────────────────────
84
+
85
+ llm_chat() {
86
+ local system_msg="$1"
87
+ local user_msg="$2"
88
+
89
+ local payload
90
+ payload="$(jq -cn \
91
+ --arg model "$CLAST_LLM_MODEL" \
92
+ --arg system "$system_msg" \
93
+ --arg user "$user_msg" \
94
+ '{
95
+ model: $model,
96
+ messages: [
97
+ {role: "system", content: $system},
98
+ {role: "user", content: $user}
99
+ ],
100
+ temperature: 0.3
101
+ }')"
102
+
103
+ local response http_code body
104
+ response="$(curl -s -w '\n%{http_code}' \
105
+ "${CLAST_LLM_BASE_URL}/chat/completions" \
106
+ -H "Authorization: Bearer $CLAST_LLM_API_KEY" \
107
+ -H "Content-Type: application/json" \
108
+ -d "$payload" 2>&1)" || true
109
+
110
+ http_code="$(tail -n1 <<<"$response")"
111
+ body="$(sed '$d' <<<"$response")"
112
+
113
+ if [[ "$http_code" != "200" ]]; then
114
+ warn "LLM API returned HTTP $http_code"
115
+ if [[ -n "$body" ]]; then
116
+ local err_msg
117
+ err_msg="$(jq -r '.error.message // .error // .' <<<"$body" 2>/dev/null || echo "$body")"
118
+ warn "$err_msg"
119
+ fi
120
+ return 1
121
+ fi
122
+
123
+ local content
124
+ content="$(jq -r '.choices[0].message.content // empty' <<<"$body" 2>/dev/null)" || {
125
+ warn "failed to parse LLM response"
126
+ return 1
127
+ }
128
+
129
+ if [[ -z "$content" ]]; then
130
+ warn "LLM returned empty content"
131
+ return 1
132
+ fi
133
+
134
+ printf '%s' "$content"
135
+ }
136
+
137
+ # ── Prompt template ──────────────────────────────────────────────────
138
+
139
+ resolve_prompt_dir() {
140
+ local dir="$CLAST_WAKE_DIR/lib/clast/prompts"
141
+ if [[ -d "$dir" ]]; then
142
+ printf '%s' "$dir"
143
+ return
144
+ fi
145
+ local installed
146
+ for installed in /usr/local/lib/clast/prompts "$HOME/.local/lib/clast/prompts"; do
147
+ if [[ -d "$installed" ]]; then
148
+ printf '%s' "$installed"
149
+ return
150
+ fi
151
+ done
152
+ die "cannot find prompts directory (checked $dir and install paths)"
153
+ }
154
+
155
+ load_system_prompt() {
156
+ local prompt_dir
157
+ prompt_dir="$(resolve_prompt_dir)"
158
+ local file="$prompt_dir/day-wakeup-draft-system.md"
159
+ if [[ ! -r "$file" ]]; then
160
+ die "system prompt not found: $file"
161
+ fi
162
+ cat "$file"
163
+ }
164
+
165
+ build_user_prompt() {
166
+ local project="$1" branch="$2" start="$3" end="$4" msg_count="$5"
167
+ local first_turns="$6" last_turns="$7" breadcrumbs="$8"
168
+
169
+ local prompt_dir
170
+ prompt_dir="$(resolve_prompt_dir)"
171
+ local template_file="$prompt_dir/day-wakeup-draft-user.md"
172
+
173
+ if [[ -r "$template_file" ]]; then
174
+ local template
175
+ template="$(cat "$template_file")"
176
+ template="${template//\{\{project\}\}/${project}}"
177
+ template="${template//\{\{branch\}\}/${branch:-unknown}}"
178
+ template="${template//\{\{start\}\}/${start}}"
179
+ template="${template//\{\{end\}\}/${end}}"
180
+ template="${template//\{\{msg_count\}\}/${msg_count}}"
181
+ template="${template//\{\{first_turns\}\}/${first_turns}}"
182
+ template="${template//\{\{last_turns\}\}/${last_turns}}"
183
+ template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
184
+ printf '%s' "$template"
185
+ else
186
+ warn "user prompt template not found: $template_file — using inline fallback"
187
+ cat <<EOF
188
+ Session metadata:
189
+ - Project: ${project}
190
+ - Branch: ${branch:-unknown}
191
+ - Start: ${start}
192
+ - End: ${end}
193
+ - Approximate messages: ${msg_count}
194
+
195
+ First turns of the session:
196
+ ${first_turns}
197
+
198
+ Last turns of the session:
199
+ ${last_turns}
200
+
201
+ Breadcrumbs the user left during this session's day:
202
+ ${breadcrumbs:-None.}
203
+ EOF
204
+ fi
205
+ }
206
+
207
+ # ── Draft parsing ────────────────────────────────────────────────────
208
+
209
+ extract_title() {
210
+ local draft="$1"
211
+ local title
212
+ title="$(grep -m1 '^# Session:' <<<"$draft" | sed 's/^# Session:[[:space:]]*//')"
213
+ printf '%s' "$title"
214
+ }
215
+
216
+ extract_tags() {
217
+ local draft="$1"
218
+ local tags
219
+ tags="$(grep -i '^Suggested tags:' <<<"$draft" | sed 's/^[Ss]uggested tags:[[:space:]]*//')"
220
+ tags="$(printf '%s' "$tags" | sed 's/[[:space:]]*,[[:space:]]*/,/g')"
221
+ printf '%s' "$tags"
222
+ }
223
+
224
+ strip_tags_trailer() {
225
+ local draft="$1"
226
+ printf '%s' "$draft" | sed '/^$/{ N; /\n[Ss]uggested tags:/d; }' | sed '/^[Ss]uggested tags:/d'
227
+ }
228
+
229
+ # ── Session slug ─────────────────────────────────────────────────────
230
+
231
+ get_session_slug() {
232
+ local snapshot_path="$1" draft_title="$2"
233
+ local journal_dir
234
+ journal_dir="$(clast whereami 2>/dev/null | grep '^journal_dir:' | awk '{print $2}')" || true
235
+ if [[ -z "$journal_dir" ]]; then
236
+ journal_dir="${HOME}/.claude/journal"
237
+ fi
238
+
239
+ local abs_path="$journal_dir/$snapshot_path"
240
+
241
+ if [[ -r "$abs_path" ]]; then
242
+ local ai_title
243
+ ai_title="$(jq -r 'select(.type == "ai-title") | .aiTitle' "$abs_path" 2>/dev/null | tail -1)" || true
244
+ if [[ -n "$ai_title" && "$ai_title" != "null" ]]; then
245
+ slugify "$ai_title"
246
+ return
247
+ fi
248
+ fi
249
+
250
+ if [[ -n "$draft_title" ]]; then
251
+ slugify "$draft_title"
252
+ return
253
+ fi
254
+
255
+ printf 'session'
256
+ }
257
+
258
+ # ── Interactive menu ─────────────────────────────────────────────────
259
+
260
+ prompt_choice() {
261
+ local choice
262
+ printf '\n [a] Accept [e] Edit [d] Dismiss [s] Skip [q] Stop here\n' >/dev/tty
263
+ printf ' Choice: ' >/dev/tty
264
+ read -r -n1 choice </dev/tty
265
+ printf '\n' >/dev/tty
266
+ printf '%s' "$choice"
267
+ }
268
+
269
+ prompt_edit_feedback() {
270
+ local feedback
271
+ printf '\n What should change? ' >/dev/tty
272
+ read -r feedback </dev/tty
273
+ printf '%s' "$feedback"
274
+ }
275
+
276
+ # ── Triage ───────────────────────────────────────────────────────────
277
+
278
+ _clast_wake_triage() {
279
+ local uncurated="$1" total="$2" day_count="$3"
280
+ local first_day="$4" last_day="$5" project_count="$6"
281
+
282
+ info "Found $total uncurated session(s) across $day_count day(s) ($first_day to $last_day)." >/dev/tty
283
+ printf '\n' >/dev/tty
284
+
285
+ # Show per-day breakdown
286
+ local breakdown
287
+ breakdown="$(jq -r '
288
+ group_by(.day_bucket) | sort_by(.[0].day_bucket) | .[] |
289
+ " \(.[0].day_bucket) \(length) session(s)"
290
+ ' <<<"$uncurated")"
291
+ printf '%s\n' "$breakdown" >/dev/tty
292
+ printf '\n' >/dev/tty
293
+
294
+ local choice
295
+ printf ' [a] Process all %s sessions\n' "$total" >/dev/tty
296
+ printf ' [y] Process yesterday only\n' >/dev/tty
297
+ printf ' [n] Choose how many days back\n' >/dev/tty
298
+ printf ' [o] Dismiss everything older, then process the rest\n' >/dev/tty
299
+ printf ' [q] Quit\n' >/dev/tty
300
+ printf ' Choice: ' >/dev/tty
301
+ read -r -n1 choice </dev/tty
302
+ printf '\n' >/dev/tty
303
+
304
+ case "$choice" in
305
+ a|A)
306
+ printf '%s' "$uncurated"
307
+ ;;
308
+ y|Y)
309
+ local yesterday
310
+ yesterday="$(date -d 'yesterday' +%Y-%m-%d)"
311
+ jq -c "[.[] | select(.day_bucket == \"$yesterday\")]" <<<"$uncurated"
312
+ ;;
313
+ n|N)
314
+ local days
315
+ printf ' How many days back? ' >/dev/tty
316
+ read -r days </dev/tty
317
+ if ! [[ "$days" =~ ^[1-9][0-9]*$ ]]; then
318
+ warn "invalid number — processing all sessions"
319
+ printf '%s' "$uncurated"
320
+ return
321
+ fi
322
+ local cutoff
323
+ cutoff="$(date -d "-${days} days" +%Y-%m-%d 2>/dev/null)" || {
324
+ warn "failed to compute date — processing all sessions"
325
+ printf '%s' "$uncurated"
326
+ return
327
+ }
328
+ jq -c "[.[] | select(.day_bucket >= \"$cutoff\")]" <<<"$uncurated"
329
+ ;;
330
+ o|O)
331
+ local days_keep
332
+ printf ' Keep how many days? (dismiss everything older) ' >/dev/tty
333
+ read -r days_keep </dev/tty
334
+ if ! [[ "$days_keep" =~ ^[1-9][0-9]*$ ]]; then
335
+ warn "invalid number — processing all sessions"
336
+ printf '%s' "$uncurated"
337
+ return
338
+ fi
339
+ local cutoff_keep
340
+ cutoff_keep="$(date -d "-${days_keep} days" +%Y-%m-%d 2>/dev/null)" || {
341
+ warn "failed to compute date — processing all sessions"
342
+ printf '%s' "$uncurated"
343
+ return
344
+ }
345
+ # Dismiss older sessions
346
+ local old_ids
347
+ old_ids="$(jq -r "[.[] | select(.day_bucket < \"$cutoff_keep\")] | .[].session_id" <<<"$uncurated")"
348
+ local dismiss_count=0
349
+ local old_id
350
+ while IFS= read -r old_id; do
351
+ [[ -z "$old_id" ]] && continue
352
+ clast sessions dismiss "$old_id" --reason "bulk dismiss via clast-wake triage" 2>/dev/null
353
+ dismiss_count=$(( dismiss_count + 1 ))
354
+ done <<<"$old_ids"
355
+ if (( dismiss_count > 0 )); then
356
+ info " Dismissed $dismiss_count older session(s)." >/dev/tty
357
+ fi
358
+ jq -c "[.[] | select(.day_bucket >= \"$cutoff_keep\")]" <<<"$uncurated"
359
+ ;;
360
+ q|Q)
361
+ printf '[]'
362
+ ;;
363
+ *)
364
+ warn "unknown choice — processing all sessions"
365
+ printf '%s' "$uncurated"
366
+ ;;
367
+ esac
368
+ }
369
+
370
+ # ── Main ─────────────────────────────────────────────────────────────
371
+
372
+ main() {
373
+ preflight
374
+
375
+ info "Snapshotting fresh transcripts..."
376
+ if ! clast snapshot 2>/dev/null; then
377
+ warn "clast snapshot failed — proceeding with existing data"
378
+ fi
379
+
380
+ info "Checking for uncurated or stale sessions..."
381
+ local sessions_json
382
+ sessions_json="$(clast --json sessions --since -30d 2>/dev/null)" || {
383
+ die "failed to list sessions"
384
+ }
385
+
386
+ local uncurated
387
+ uncurated="$(jq -c '[.[] | select(.curated == false or .stale == true)]' <<<"$sessions_json")"
388
+ local total
389
+ total="$(jq 'length' <<<"$uncurated")"
390
+
391
+ if (( total == 0 )); then
392
+ info "Nothing to curate — all sessions are curated or dismissed."
393
+ exit 0
394
+ fi
395
+
396
+ local day_count first_day last_day project_count
397
+ day_count="$(jq '[.[].day_bucket] | unique | length' <<<"$uncurated")"
398
+ first_day="$(jq -r '[.[].day_bucket] | sort | first' <<<"$uncurated")"
399
+ last_day="$(jq -r '[.[].day_bucket] | sort | last' <<<"$uncurated")"
400
+ project_count="$(jq '[.[].project] | unique | length' <<<"$uncurated")"
401
+
402
+ if (( day_count > 1 )); then
403
+ uncurated="$(_clast_wake_triage "$uncurated" "$total" "$day_count" "$first_day" "$last_day" "$project_count")"
404
+ total="$(jq 'length' <<<"$uncurated")"
405
+ if (( total == 0 )); then
406
+ info "Nothing left to curate."
407
+ exit 0
408
+ fi
409
+ project_count="$(jq '[.[].project] | unique | length' <<<"$uncurated")"
410
+ fi
411
+
412
+ info "Processing $total session(s) across $project_count project(s)."
413
+ printf '\n'
414
+
415
+ local curated_count=0 skipped_count=0 dismissed_count=0
416
+ local -a curated_projects=()
417
+ local i=0 stop=0
418
+
419
+ while (( i < total && stop == 0 )); do
420
+ local session
421
+ session="$(jq -c ".[$i]" <<<"$uncurated")"
422
+
423
+ local sid project branch start_ts end_ts msg_count snapshot_path
424
+ sid="$(jq -r '.session_id' <<<"$session")"
425
+ project="$(jq -r '.project' <<<"$session")"
426
+ branch="$(jq -r '.branch // ""' <<<"$session")"
427
+ start_ts="$(jq -r '.start' <<<"$session")"
428
+ end_ts="$(jq -r '.end' <<<"$session")"
429
+ msg_count="$(jq -r '.msg_count_approx' <<<"$session")"
430
+ snapshot_path="$(jq -r '.snapshot_path' <<<"$session")"
431
+
432
+ local start_short="${start_ts:11:5}"
433
+ [[ -z "$start_short" ]] && start_short="${start_ts:0:10}"
434
+
435
+ local is_stale
436
+ is_stale="$(jq -r '.stale // false' <<<"$session")"
437
+ local label="Session $((i+1))/$total: $project"
438
+ [[ "$is_stale" == "true" ]] && label="$label [STALE]"
439
+ [[ -n "$start_short" ]] && label="$label ($start_short"
440
+ [[ -n "$branch" && "$branch" != "null" ]] && label="$label, $branch"
441
+ label="$label)"
442
+
443
+ separator "$label"
444
+ info "Gathering context..."
445
+
446
+ local show_json
447
+ show_json="$(clast --json show "$sid" --full --turns 8 2>/dev/null)" || {
448
+ warn "failed to read session $sid — skipping"
449
+ skipped_count=$(( skipped_count + 1 ))
450
+ i=$(( i + 1 ))
451
+ continue
452
+ }
453
+
454
+ local first_turns last_turns
455
+ first_turns="$(jq -r '
456
+ .first_turns // [] | .[] |
457
+ "[\(.role)] \(.text)"
458
+ ' <<<"$show_json" 2>/dev/null)" || true
459
+
460
+ last_turns="$(jq -r '
461
+ .last_turns // [] | .[] |
462
+ "[\(.role)] \(.text)"
463
+ ' <<<"$show_json" 2>/dev/null)" || true
464
+
465
+ local breadcrumbs=""
466
+ breadcrumbs="$(clast breadcrumb --read --project "$project" --day yesterday 2>/dev/null)" || true
467
+
468
+ local user_prompt
469
+ user_prompt="$(build_user_prompt "$project" "$branch" "$start_ts" "$end_ts" "$msg_count" \
470
+ "$first_turns" "$last_turns" "$breadcrumbs")"
471
+
472
+ local system_prompt
473
+ system_prompt="$(load_system_prompt)"
474
+
475
+ local draft="" edit_extra=""
476
+ local drafting=1
477
+
478
+ while (( drafting )); do
479
+ local full_system="$system_prompt"
480
+ local full_user="$user_prompt"
481
+ if [[ -n "$edit_extra" ]]; then
482
+ full_user="${full_user}
483
+
484
+ Revisions requested by user: ${edit_extra}"
485
+ fi
486
+
487
+ info "Generating draft..."
488
+ if ! draft="$(llm_chat "$full_system" "$full_user")"; then
489
+ printf '\n'
490
+ warn "LLM call failed for session $sid"
491
+ local retry
492
+ printf ' [r] Retry [s] Skip [q] Stop\n Choice: '
493
+ read -r -n1 retry </dev/tty
494
+ printf '\n'
495
+ case "$retry" in
496
+ r|R) continue ;;
497
+ q|Q) stop=1; break ;;
498
+ *) skipped_count=$(( skipped_count + 1 )); break ;;
499
+ esac
500
+ fi
501
+
502
+ printf '\n%s\n' "$draft"
503
+ printf '\n'
504
+
505
+ local choice
506
+ choice="$(prompt_choice)"
507
+
508
+ case "$choice" in
509
+ a|A)
510
+ local title tags body slug
511
+ title="$(extract_title "$draft")"
512
+ tags="$(extract_tags "$draft")"
513
+ body="$(strip_tags_trailer "$draft")"
514
+ slug="$(get_session_slug "$snapshot_path" "$title")"
515
+
516
+ local write_args=(entries write --session "$sid" --slug "$slug" --body-stdin)
517
+ [[ -n "$tags" ]] && write_args+=(--tags "$tags")
518
+ [[ -n "$title" ]] && write_args+=(--title "$title")
519
+
520
+ local write_result
521
+ if write_result="$(printf '%s\n' "$body" | clast "${write_args[@]}" 2>&1)"; then
522
+ info " $write_result"
523
+ curated_count=$(( curated_count + 1 ))
524
+ curated_projects+=("$project")
525
+ else
526
+ warn "failed to write entry: $write_result"
527
+ fi
528
+ drafting=0
529
+ ;;
530
+ e|E)
531
+ edit_extra="$(prompt_edit_feedback)"
532
+ ;;
533
+ d|D)
534
+ if clast sessions dismiss "$sid" --reason "dismissed via clast-wake" 2>/dev/null; then
535
+ info " Dismissed."
536
+ dismissed_count=$(( dismissed_count + 1 ))
537
+ else
538
+ warn "failed to dismiss session $sid"
539
+ fi
540
+ drafting=0
541
+ ;;
542
+ s|S)
543
+ skipped_count=$(( skipped_count + 1 ))
544
+ drafting=0
545
+ ;;
546
+ q|Q)
547
+ stop=1
548
+ drafting=0
549
+ ;;
550
+ *)
551
+ info " Unknown choice '$choice' — skipping."
552
+ skipped_count=$(( skipped_count + 1 ))
553
+ drafting=0
554
+ ;;
555
+ esac
556
+ done
557
+
558
+ i=$(( i + 1 ))
559
+ printf '\n'
560
+ done
561
+
562
+ local remaining=$(( total - curated_count - skipped_count - dismissed_count ))
563
+ local unique_projects
564
+ unique_projects="$(printf '%s\n' "${curated_projects[@]}" 2>/dev/null | sort -u | wc -l | tr -d ' ')" || true
565
+ [[ -z "$unique_projects" || "$unique_projects" == "0" ]] && unique_projects=0
566
+
567
+ printf '\n'
568
+ separator "Summary"
569
+ info " Curated: $curated_count session(s) across $unique_projects project(s)"
570
+ if (( dismissed_count > 0 )); then
571
+ info " Dismissed: $dismissed_count session(s)"
572
+ fi
573
+ info " Skipped: $skipped_count session(s)"
574
+ if (( remaining > 0 )); then
575
+ info " Remaining: $remaining session(s) (stopped early)"
576
+ fi
577
+ }
578
+
579
+ main "$@"
package/hooks/snapshot.sh CHANGED
@@ -1,15 +1,23 @@
1
1
  #!/usr/bin/env bash
2
- # hooks/snapshot.sh
3
- #
4
- # Fired on Claude Code SessionStart. Backgrounds `clast snapshot` so it doesn't
5
- # block session start. Silent if clast isn't installed — the plugin can still
6
- # load cleanly even if the CLI isn't on PATH.
7
- #
8
- # Idempotent. Safe to run repeatedly. Best-effort: never propagates a non-zero
9
- # exit to Claude Code (a failed snapshot is not a session-start failure).
2
+ # hooks/snapshot.sh — SessionStart hook. Backgrounds `clast snapshot`.
3
+ # Idempotent. Best-effort: never propagates a non-zero exit.
10
4
  # shellcheck shell=bash
11
5
 
12
- if command -v clast >/dev/null 2>&1; then
13
- (clast snapshot >/dev/null 2>&1 &)
6
+ # Derive plugin root from this script's location.
7
+ # $0 is the expanded path from ${CLAUDE_PLUGIN_ROOT}/hooks/snapshot.sh.
8
+ _snap_dir="$(cd "$(dirname "$0")" && pwd)"
9
+ _plugin_root="$(cd "$_snap_dir/.." && pwd)"
10
+
11
+ # Prefer the bundled binary (version-matched to this plugin).
12
+ # Fall back to a system-level install on PATH.
13
+ _clast=""
14
+ if [[ -x "$_plugin_root/bin/clast" ]]; then
15
+ _clast="$_plugin_root/bin/clast"
16
+ elif command -v clast >/dev/null 2>&1; then
17
+ _clast="clast"
18
+ fi
19
+
20
+ if [[ -n "$_clast" ]]; then
21
+ ($_clast snapshot >/dev/null 2>&1 &)
14
22
  fi
15
23
  exit 0
@@ -6,8 +6,8 @@
6
6
  # segment, identical to a path separator. Decoding ambiguity is resolved
7
7
  # against the filesystem (and, for git repos, a `git rev-parse` probe).
8
8
  #
9
- # See docs/overview.md#glossary for the "segment" term and
10
- # docs/repo-bootstrap.md#libclastclast-decode-libbash for the algorithm.
9
+ # See docs/explanation/data-model.md for the "segment" term and
10
+ # docs/reference/repo-bootstrap.md#libclastclast-decode-libbash for the algorithm.
11
11
  # shellcheck shell=bash
12
12
 
13
13
  if [[ -n "${_CLAST_DECODE_LIB_SOURCED:-}" ]]; then
@@ -0,0 +1,73 @@
1
+ # clast-dismissed-lib.bash — dismissed session tracking
2
+ #
3
+ # Thin layer over $(clast_journal_dir)/.dismissed.jsonl. Each line
4
+ # records a session_id + timestamp. Used by sessions.bash to exclude
5
+ # throwaway sessions from query results.
6
+ # shellcheck shell=bash
7
+ # shellcheck source=lib/clast/clast-lib.bash
8
+
9
+ if [[ -n "${_CLAST_DISMISSED_LIB_SOURCED:-}" ]]; then
10
+ return 0
11
+ fi
12
+ _CLAST_DISMISSED_LIB_SOURCED=1
13
+
14
+ clast_dismissed_path() {
15
+ printf '%s\n' "$(clast_journal_dir)/.dismissed.jsonl"
16
+ }
17
+
18
+ # clast_dismissed_add <session-id> [reason]
19
+ clast_dismissed_add() {
20
+ local session_id="$1"
21
+ local reason="${2:-}"
22
+ if [[ -z "$session_id" ]]; then
23
+ clast_log_error "clast_dismissed_add: empty session_id"
24
+ return 2
25
+ fi
26
+
27
+ local dismissed_file
28
+ dismissed_file="$(clast_dismissed_path)"
29
+
30
+ local now
31
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
32
+
33
+ local line
34
+ line="$(jq -cn \
35
+ --arg sid "$session_id" \
36
+ --arg ts "$now" \
37
+ --arg reason "$reason" \
38
+ '{session_id: $sid, dismissed_at: $ts, reason: (if $reason == "" then null else $reason end)}')"
39
+
40
+ printf '%s\n' "$line" >> "$dismissed_file"
41
+ }
42
+
43
+ # clast_dismissed_set — populate an associative array with dismissed IDs.
44
+ # Usage: declare -A dismissed=(); clast_dismissed_set dismissed
45
+ clast_dismissed_set() {
46
+ local -n _ref=$1
47
+ local dismissed_file
48
+ dismissed_file="$(clast_dismissed_path)"
49
+
50
+ if [[ ! -f "$dismissed_file" ]]; then
51
+ return 0
52
+ fi
53
+
54
+ local line sid
55
+ while IFS= read -r line; do
56
+ [[ -z "$line" ]] && continue
57
+ sid="$(jq -r '.session_id // empty' <<<"$line" 2>/dev/null)" || continue
58
+ [[ -n "$sid" ]] && _ref["$sid"]=1
59
+ done < "$dismissed_file"
60
+ }
61
+
62
+ # clast_dismissed_check <session-id> — exit 0 if dismissed, 1 if not.
63
+ clast_dismissed_check() {
64
+ local session_id="$1"
65
+ local dismissed_file
66
+ dismissed_file="$(clast_dismissed_path)"
67
+
68
+ if [[ ! -f "$dismissed_file" ]]; then
69
+ return 1
70
+ fi
71
+
72
+ grep -q "\"session_id\":\"$session_id\"" "$dismissed_file" 2>/dev/null
73
+ }