@procrastivity/clast 0.0.3 → 0.0.5

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.
Files changed (39) hide show
  1. package/README.md +41 -38
  2. package/bin/clast +31 -122
  3. package/bin/clast-plumbing +180 -0
  4. package/examples/cron/clast-snapshot.service +2 -2
  5. package/examples/cron/clast-snapshot.timer +1 -1
  6. package/examples/cron/crontab.sample +4 -4
  7. package/examples/workflows/morning-briefing.md +17 -17
  8. package/hooks/snapshot.sh +5 -5
  9. package/lib/clast/clast-classify-lib.bash +68 -0
  10. package/lib/clast/clast-dismissed-lib.bash +76 -5
  11. package/lib/clast/clast-lib.bash +93 -8
  12. package/lib/clast/clast-manifest-lib.bash +58 -5
  13. package/lib/clast/clast-porcelain-lib.bash +230 -0
  14. package/lib/clast/clast-porcelain-subcommands/brief.bash +236 -0
  15. package/lib/clast/clast-porcelain-subcommands/retro.bash +236 -0
  16. package/lib/clast/clast-porcelain-subcommands/undismiss.bash +27 -0
  17. package/lib/clast/clast-porcelain-subcommands/wake.bash +514 -0
  18. package/lib/clast/clast-registry-lib.bash +164 -41
  19. package/lib/clast/clast-retro-lib.bash +397 -0
  20. package/lib/clast/clast-subcommands/doctor.bash +29 -13
  21. package/lib/clast/clast-subcommands/entries.bash +40 -50
  22. package/lib/clast/clast-subcommands/projects.bash +16 -20
  23. package/lib/clast/clast-subcommands/registry.bash +26 -11
  24. package/lib/clast/clast-subcommands/retro.bash +217 -0
  25. package/lib/clast/clast-subcommands/sessions.bash +193 -48
  26. package/lib/clast/clast-subcommands/show.bash +69 -12
  27. package/lib/clast/clast-subcommands/snapshot.bash +29 -11
  28. package/lib/clast/clast-subcommands/stats.bash +7 -2
  29. package/lib/clast/prompts/brief-system.md +11 -5
  30. package/lib/clast/prompts/brief-user.md +4 -1
  31. package/lib/clast/prompts/retro-summary-system.md +14 -0
  32. package/lib/clast/prompts/retro-summary-user.md +7 -0
  33. package/lib/clast/prompts/{day-wakeup-draft-system.md → wake-draft-system.md} +1 -1
  34. package/package.json +3 -3
  35. package/{.claude-plugin/skills/wakeup → skills/brief}/SKILL.md +22 -20
  36. package/{.claude-plugin/skills/day-wakeup → skills/wake}/SKILL.md +54 -29
  37. package/bin/clast-brief +0 -305
  38. package/bin/clast-wake +0 -579
  39. /package/lib/clast/prompts/{day-wakeup-draft-user.md → wake-draft-user.md} +0 -0
@@ -6,6 +6,7 @@
6
6
  # shellcheck shell=bash
7
7
  # shellcheck source=lib/clast/clast-lib.bash
8
8
  # shellcheck source=lib/clast/clast-manifest-lib.bash
9
+ # shellcheck source=lib/clast/clast-classify-lib.bash
9
10
  # shellcheck source=lib/clast/clast-registry-lib.bash
10
11
  # shellcheck source=lib/clast/clast-decode-lib.bash
11
12
  # shellcheck source=lib/clast/clast-dismissed-lib.bash
@@ -14,11 +15,13 @@ _clast_sessions_usage() {
14
15
  cat <<'EOF'
15
16
  Usage: clast sessions [--day DATE] [--since DATE] [--until DATE] [--project SLUG]
16
17
  clast sessions dismiss <session-id> [<session-id>...] [--reason TEXT]
18
+ clast sessions undismiss <session-id> [<session-id>...]
17
19
 
18
20
  List sessions captured in a date window.
19
21
 
20
22
  Subcommands:
21
23
  dismiss ID... Mark session(s) as dismissed (excluded from future queries).
24
+ undismiss ID... Restore previously dismissed session(s).
22
25
 
23
26
  Flags:
24
27
  --day DATE Single-day window (default: today). Mutually exclusive
@@ -98,6 +101,54 @@ _clast_sessions_dismiss() {
98
101
  fi
99
102
  }
100
103
 
104
+ # _clast_sessions_undismiss <id> [<id>...]
105
+ # Reverse a dismissal so the session reappears in queries.
106
+ _clast_sessions_undismiss() {
107
+ source "$CLAST_LIB/clast-dismissed-lib.bash"
108
+
109
+ local -a ids=()
110
+ while [[ $# -gt 0 ]]; do
111
+ case "$1" in
112
+ -h|--help)
113
+ _clast_sessions_usage; return 0 ;;
114
+ -*)
115
+ _clast_sessions_err "undismiss: unknown flag '$1'"; return 2 ;;
116
+ *)
117
+ ids+=("$1"); shift ;;
118
+ esac
119
+ done
120
+
121
+ if [[ ${#ids[@]} -eq 0 ]]; then
122
+ _clast_sessions_err "undismiss requires at least one session ID"
123
+ return 2
124
+ fi
125
+
126
+ local id count=0 rc
127
+ for id in "${ids[@]}"; do
128
+ if ! [[ "$id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
129
+ _clast_sessions_err "undismiss: '$id' is not a valid UUID"
130
+ return 2
131
+ fi
132
+ # `|| rc=$?` keeps the non-zero return from tripping `set -e` (the normal
133
+ # "not dismissed" path returns 1); rc stays 0 on success.
134
+ rc=0
135
+ clast_dismissed_remove "$id" >/dev/null || rc=$?
136
+ case "$rc" in
137
+ 0) count=$(( count + 1 )) ;;
138
+ 1) [[ -z "${CLAST_QUIET:-}" ]] && clast_log_info "not dismissed: $id" ;;
139
+ # Any other code is a real failure (temp file / rewrite / mv). Don't
140
+ # report success while the session is still dismissed — surface it.
141
+ *) _clast_sessions_err "undismiss: failed to restore $id"; return 1 ;;
142
+ esac
143
+ done
144
+
145
+ if [[ -n "${CLAST_JSON:-}" ]]; then
146
+ jq -cn --argjson count "$count" '{undismissed: $count}'
147
+ elif [[ -z "${CLAST_QUIET:-}" ]]; then
148
+ clast_log_info "Restored $count session(s)."
149
+ fi
150
+ }
151
+
101
152
  clast_cmd_sessions() {
102
153
  # Handle "sessions dismiss" sub-action before flag parsing.
103
154
  if [[ "${1:-}" == "dismiss" ]]; then
@@ -106,6 +157,13 @@ clast_cmd_sessions() {
106
157
  return $?
107
158
  fi
108
159
 
160
+ # Handle "sessions undismiss" sub-action before flag parsing.
161
+ if [[ "${1:-}" == "undismiss" ]]; then
162
+ shift
163
+ _clast_sessions_undismiss "$@"
164
+ return $?
165
+ fi
166
+
109
167
  local day_filter="" since_date="" until_date=""
110
168
  local project_filter=""
111
169
  local include_dismissed=0
@@ -207,32 +265,61 @@ clast_cmd_sessions() {
207
265
  | jq -r --arg s "$project_filter" '.[] | select(.slug == $s) | .path')
208
266
  fi
209
267
 
210
- # Most-recent manifest line per session_id within window.
211
- declare -A latest_line=()
212
- local line sid
213
- while IFS= read -r line; do
214
- [[ -z "$line" ]] && continue
215
- sid="$(jq -r '.session_id' <<<"$line")"
216
- [[ -z "$sid" || "$sid" == "null" ]] && continue
217
- # Skip dismissed sessions early to avoid unnecessary work.
218
- if [[ -n "${dismissed_ids[$sid]:-}" ]]; then
219
- continue
220
- fi
221
- latest_line["$sid"]="$line"
222
- done < <(clast_manifest_iterate "$filter")
268
+ local manifest_path
269
+ manifest_path="$(clast_manifest_path)"
223
270
 
224
- local -a rows=()
225
- local snapshot day_bucket mtime seg abs_path msgs
226
- local first_ts last_ts start_ts end_ts curated branch slug
227
271
  local entries_dir
228
272
  entries_dir="$journal_dir/entries"
229
273
 
230
- for sid in "${!latest_line[@]}"; do
231
- line="${latest_line[$sid]}"
232
- snapshot="$(jq -r '.snapshot' <<<"$line")"
233
- day_bucket="$(jq -r '.day_bucket' <<<"$line")"
234
- mtime="$(jq -r '.source_mtime' <<<"$line")"
235
- seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot")"
274
+ # Curated/stale index: scan every entry file ONCE up front instead of
275
+ # grepping the entries dir per session (the old approach was
276
+ # O(sessions × entries)). `curated_seen[sid]` marks a session as curated;
277
+ # `curated_mtime[sid]` holds the max curated_source_mtime across its
278
+ # entries ("" when none carry one — legacy entries then stay non-stale).
279
+ declare -A curated_seen=() curated_mtime=()
280
+ if [[ -d "$entries_dir" ]] && compgen -G "$entries_dir/*.md" >/dev/null 2>&1; then
281
+ local idx_sid idx_cm
282
+ while IFS=$'\t' read -r idx_sid idx_cm; do
283
+ [[ -z "$idx_sid" ]] && continue
284
+ curated_seen["$idx_sid"]=1
285
+ if [[ -n "$idx_cm" ]]; then
286
+ if [[ -z "${curated_mtime[$idx_sid]:-}" || "$idx_cm" > "${curated_mtime[$idx_sid]}" ]]; then
287
+ curated_mtime["$idx_sid"]="$idx_cm"
288
+ fi
289
+ fi
290
+ done < <(awk '
291
+ function flush() { if (sid != "") { print sid "\t" cm } sid=""; cm="" }
292
+ FNR==1 && NR>1 { flush() }
293
+ /^session_id:/ { v=$0; sub(/^session_id:[[:space:]]*/, "", v); sid=v }
294
+ /^curated_source_mtime:/ { v=$0; sub(/^curated_source_mtime:[[:space:]]*/, "", v); gsub(/"/, "", v); cm=v }
295
+ END { flush() }
296
+ ' "$entries_dir"/*.md 2>/dev/null)
297
+ fi
298
+
299
+ # Most-recent manifest line per session_id within window, in a SINGLE jq
300
+ # pass. Previously this forked one jq per manifest line plus several more
301
+ # per session; for large manifests that dominated runtime. The reduce keeps
302
+ # the last line seen per session_id (manifest is append-only, so last =
303
+ # most recent). Emits @tsv columns: session_id, snapshot, day_bucket,
304
+ # source_mtime.
305
+ local -a rows=()
306
+ local sid snapshot day_bucket mtime seg abs_path msgs
307
+ local cached_msgs cached_first cached_last cached_user cached_assistant
308
+ local user_count assistant_count substantive
309
+ local first_ts last_ts start_ts end_ts curated stale branch slug
310
+ declare -A seg_slug=()
311
+
312
+ while IFS=$'\t' read -r sid snapshot day_bucket mtime cached_msgs cached_first cached_last cached_user cached_assistant; do
313
+ [[ -z "$sid" ]] && continue
314
+ # Skip dismissed sessions early, before any per-session file work.
315
+ if [[ -n "${dismissed_ids[$sid]:-}" ]]; then
316
+ continue
317
+ fi
318
+
319
+ # segment = third path component of transcripts/<day>/<segment>/<uuid>.
320
+ seg="${snapshot#transcripts/}" # <day>/<segment>/<uuid>.jsonl
321
+ seg="${seg#*/}" # <segment>/<uuid>.jsonl
322
+ seg="${seg%%/*}" # <segment>
236
323
 
237
324
  if (( have_project_filter == 1 )); then
238
325
  if [[ -z "${allowed_segs[$seg]:-}" ]]; then
@@ -241,7 +328,15 @@ clast_cmd_sessions() {
241
328
  fi
242
329
 
243
330
  abs_path="$journal_dir/$snapshot"
244
- if [[ -r "$abs_path" ]]; then
331
+ if [[ -n "$cached_msgs" ]]; then
332
+ # Cache hit (step 21): the manifest line carries msg_count/first_ts/
333
+ # last_ts, so we never open the transcript. Empty cached_first/last
334
+ # means the cached value was JSON null (no first/last timestamp).
335
+ msgs="$cached_msgs"
336
+ first_ts="$cached_first"
337
+ last_ts="$cached_last"
338
+ elif [[ -r "$abs_path" ]]; then
339
+ # Legacy line (pre-cache): fall back to reading the transcript.
245
340
  msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
246
341
  [[ -z "$msgs" ]] && msgs=0
247
342
  first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
@@ -257,35 +352,64 @@ clast_cmd_sessions() {
257
352
  start_ts="${first_ts:-$mtime}"
258
353
  end_ts="${last_ts:-$mtime}"
259
354
 
260
- if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
261
- :
355
+ # Session classification (clast-classify-lib.bash). Prefer the cached
356
+ # counts on the manifest line; recompute from the transcript for legacy
357
+ # lines that predate them. When neither is available (snapshot missing),
358
+ # leave the counts unknown and fall SAFE — substantive=true — so wake
359
+ # never auto-dismisses a session we couldn't classify. Note: a line may
360
+ # carry cached_msgs (step 21) yet lack these counts, so resolve them
361
+ # independently of the msg_count branch above.
362
+ if [[ -n "$cached_user" ]]; then
363
+ user_count="$cached_user"
364
+ assistant_count="$cached_assistant"
365
+ elif [[ -r "$abs_path" ]]; then
366
+ IFS=$'\t' read -r user_count assistant_count < <(clast_session_msg_counts "$abs_path")
367
+ [[ "$user_count" =~ ^[0-9]+$ ]] || user_count=""
368
+ [[ "$assistant_count" =~ ^[0-9]+$ ]] || assistant_count=""
369
+ else
370
+ user_count=""
371
+ assistant_count=""
372
+ fi
373
+ # substantive iff Claude actually produced a reply. assistant_msg_count==0
374
+ # captures BOTH no-op shapes the feature targets: empty / slash-command-only
375
+ # sessions (/clear, /model, /config) AND sessions where the user typed but
376
+ # quit before any response. Deliberately NOT gated on user_msg_count: a
377
+ # custom slash command (e.g. /review) leaves zero prose prompts yet drives
378
+ # real assistant work — those must be kept. Unknown count → SAFE (true).
379
+ if [[ "$assistant_count" =~ ^[0-9]+$ ]]; then
380
+ if (( assistant_count > 0 )); then
381
+ substantive=true
382
+ else
383
+ substantive=false
384
+ fi
385
+ else
386
+ substantive=true
387
+ fi
388
+
389
+ # Resolve the slug once per unique segment (segments repeat heavily
390
+ # across sessions; each resolve forks several jq calls).
391
+ if [[ -n "${seg_slug[$seg]+x}" ]]; then
392
+ slug="${seg_slug[$seg]}"
262
393
  else
263
- slug="$seg"
394
+ if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
395
+ :
396
+ else
397
+ slug="$seg"
398
+ fi
399
+ seg_slug["$seg"]="$slug"
264
400
  fi
265
401
 
266
402
  # TODO(step-10): branch field is best-effort; revisit when stats command lands.
267
403
  branch=""
268
404
 
405
+ # Curated/stale from the prebuilt index (no per-session grep).
269
406
  curated=false
270
- local stale=false
271
- if [[ -d "$entries_dir" ]]; then
272
- local entry_matches
273
- entry_matches="$(grep -l "session_id: $sid" "$entries_dir"/*.md 2>/dev/null)" || true
274
- if [[ -n "$entry_matches" ]]; then
275
- curated=true
276
- local best_curated_mtime="" ef cm
277
- while IFS= read -r ef; do
278
- [[ -z "$ef" ]] && continue
279
- cm="$(grep '^curated_source_mtime:' "$ef" 2>/dev/null | head -n1 | sed 's/^curated_source_mtime:[[:space:]]*//')" || true
280
- cm="${cm#\"}"
281
- cm="${cm%\"}"
282
- if [[ -n "$cm" && ( -z "$best_curated_mtime" || "$cm" > "$best_curated_mtime" ) ]]; then
283
- best_curated_mtime="$cm"
284
- fi
285
- done <<<"$entry_matches"
286
- if [[ -n "$best_curated_mtime" && "$best_curated_mtime" != "$mtime" ]]; then
287
- stale=true
288
- fi
407
+ stale=false
408
+ if [[ -n "${curated_seen[$sid]:-}" ]]; then
409
+ curated=true
410
+ local best_curated_mtime="${curated_mtime[$sid]:-}"
411
+ if [[ -n "$best_curated_mtime" && "$best_curated_mtime" != "$mtime" ]]; then
412
+ stale=true
289
413
  fi
290
414
  fi
291
415
 
@@ -307,6 +431,9 @@ clast_cmd_sessions() {
307
431
  --argjson curated "$curated" \
308
432
  --argjson stale "$stale" \
309
433
  --argjson dismissed "$is_dismissed" \
434
+ --arg user_msg_count "$user_count" \
435
+ --arg assistant_msg_count "$assistant_count" \
436
+ --argjson substantive "$substantive" \
310
437
  '{
311
438
  session_id: $session_id,
312
439
  project: $project,
@@ -319,13 +446,31 @@ clast_cmd_sessions() {
319
446
  day_bucket: $day_bucket,
320
447
  curated: $curated,
321
448
  stale: $stale,
322
- dismissed: $dismissed
449
+ dismissed: $dismissed,
450
+ user_msg_count: (if $user_msg_count == "" then null else ($user_msg_count | tonumber) end),
451
+ assistant_msg_count: (if $assistant_msg_count == "" then null else ($assistant_msg_count | tonumber) end),
452
+ substantive: $substantive
323
453
  }')")
324
- done
454
+ done < <(
455
+ if [[ -f "$manifest_path" ]]; then
456
+ jq -rRn '
457
+ reduce (inputs | fromjson?
458
+ | select('"$filter"')
459
+ | select(.session_id != null and .session_id != "")) as $l
460
+ ({}; .[$l.session_id] = $l)
461
+ | to_entries[] | .value
462
+ | [.session_id, .snapshot, .day_bucket, .source_mtime,
463
+ .msg_count, .first_ts, .last_ts,
464
+ .user_msg_count, .assistant_msg_count] | @tsv
465
+ ' "$manifest_path"
466
+ fi
467
+ )
325
468
 
326
469
  local rows_json='[]'
327
470
  if (( ${#rows[@]} > 0 )); then
328
- rows_json="$(printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.start)')"
471
+ # Tiebreak on session_id so the order is deterministic when sessions
472
+ # share a start timestamp (the old assoc-array iteration order was not).
473
+ rows_json="$(printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.start, .session_id)')"
329
474
  fi
330
475
 
331
476
  if [[ -n "${CLAST_JSON:-}" ]]; then
@@ -5,6 +5,7 @@
5
5
  # shellcheck shell=bash
6
6
  # shellcheck source=lib/clast/clast-lib.bash
7
7
  # shellcheck source=lib/clast/clast-manifest-lib.bash
8
+ # shellcheck source=lib/clast/clast-classify-lib.bash
8
9
  # shellcheck source=lib/clast/clast-registry-lib.bash
9
10
  # shellcheck source=lib/clast/clast-decode-lib.bash
10
11
 
@@ -94,22 +95,54 @@ clast_cmd_show() {
94
95
  slug="$seg"
95
96
  fi
96
97
 
98
+ # Prefer the cached metadata on the manifest line (step 21); fall back to
99
+ # reading the transcript for legacy lines that predate the cache.
97
100
  local msgs first_ts last_ts start_ts end_ts
98
- msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
99
- [[ -z "$msgs" ]] && msgs=0
100
- first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
101
- last_ts="$(tail -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
101
+ local cached_msgs cached_first cached_last
102
+ IFS=$'\t' read -r cached_msgs cached_first cached_last \
103
+ < <(jq -r '[(.msg_count // ""), (.first_ts // ""), (.last_ts // "")] | @tsv' <<<"$line")
104
+ if [[ -n "$cached_msgs" ]]; then
105
+ msgs="$cached_msgs"
106
+ first_ts="$cached_first"
107
+ last_ts="$cached_last"
108
+ else
109
+ msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
110
+ [[ -z "$msgs" ]] && msgs=0
111
+ first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
112
+ last_ts="$(tail -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
113
+ fi
102
114
  start_ts="${first_ts:-$source_mtime}"
103
115
  end_ts="${last_ts:-$source_mtime}"
104
116
 
117
+ # Session classification (clast-classify-lib.bash): prefer cached counts on
118
+ # the manifest line, else recompute from the transcript (always readable
119
+ # here — the -r guard above already returned otherwise). substantive is true
120
+ # iff Claude produced at least one reply (assistant_msg_count > 0) — this is
121
+ # the no-op signal wake keys on; see sessions.bash for the full rationale.
122
+ local user_count assistant_count substantive
123
+ IFS=$'\t' read -r user_count assistant_count \
124
+ < <(jq -r '[(.user_msg_count // ""), (.assistant_msg_count // "")] | @tsv' <<<"$line")
125
+ if ! [[ "$user_count" =~ ^[0-9]+$ && "$assistant_count" =~ ^[0-9]+$ ]]; then
126
+ IFS=$'\t' read -r user_count assistant_count < <(clast_session_msg_counts "$abs_path")
127
+ [[ "$user_count" =~ ^[0-9]+$ ]] || user_count=0
128
+ [[ "$assistant_count" =~ ^[0-9]+$ ]] || assistant_count=0
129
+ fi
130
+ if (( assistant_count > 0 )); then
131
+ substantive=true
132
+ else
133
+ substantive=false
134
+ fi
135
+
105
136
  # first_prompt / last_prompt — best-effort scan of user messages.
106
137
  # These pipelines must never abort the command: malformed JSONL lines are
107
138
  # routine, and the fields are explicitly documented as best-effort.
108
139
  local first_prompt last_prompt user_msgs=""
109
140
  user_msgs="$(_clast_show_user_messages "$abs_path" 2>/dev/null || true)"
110
- first_prompt="$(printf '%s\n' "$user_msgs" | head -n1)"
111
- last_prompt="$(printf '%s\n' "$user_msgs" | tail -n1)"
112
- # If the stream was empty the head/tail of '\n' is empty keep as ''.
141
+ # Pure parameter expansion no subshell/pipe, so a >64KB multi-line
142
+ # $user_msgs can never SIGPIPE a `printf | head` and abort under pipefail.
143
+ first_prompt="${user_msgs%%$'\n'*}" # text up to the first newline
144
+ last_prompt="${user_msgs##*$'\n'}" # text after the last newline
145
+ # If the stream was empty the expansions yield '' anyway — keep as ''.
113
146
  [[ -z "$user_msgs" ]] && first_prompt="" && last_prompt=""
114
147
  first_prompt="$(_clast_show_truncate "$first_prompt")"
115
148
  last_prompt="$(_clast_show_truncate "$last_prompt")"
@@ -163,6 +196,9 @@ clast_cmd_show() {
163
196
  --argjson curated "$curated" \
164
197
  --arg first_prompt "$first_prompt" \
165
198
  --arg last_prompt "$last_prompt" \
199
+ --argjson user_msg_count "$user_count" \
200
+ --argjson assistant_msg_count "$assistant_count" \
201
+ --argjson substantive "$substantive" \
166
202
  '{
167
203
  session_id: $session_id,
168
204
  project: $project,
@@ -176,11 +212,19 @@ clast_cmd_show() {
176
212
  day_bucket: $day_bucket,
177
213
  curated: $curated,
178
214
  first_prompt: (if $first_prompt == "" then null else $first_prompt end),
179
- last_prompt: (if $last_prompt == "" then null else $last_prompt end)
215
+ last_prompt: (if $last_prompt == "" then null else $last_prompt end),
216
+ user_msg_count: $user_msg_count,
217
+ assistant_msg_count: $assistant_msg_count,
218
+ substantive: $substantive
180
219
  }')"
181
220
  if (( include_turns == 1 )); then
182
- obj="$(jq -c --argjson f "$first_turns_json" --argjson l "$last_turns_json" \
183
- '. + {first_turns:$f, last_turns:$l}' <<<"$obj")"
221
+ # Feed the (potentially multi-hundred-KB) turn arrays via stdin, not as
222
+ # --argjson argv: a single argument >128KB (MAX_ARG_STRLEN) fails with
223
+ # "Argument list too long" even when total ARG_MAX is far larger. printf
224
+ # is a builtin, so it has no argv size limit; jq reads three JSON values.
225
+ obj="$(printf '%s\n%s\n%s\n' "$obj" "$first_turns_json" "$last_turns_json" \
226
+ | jq -cn 'input as $o | input as $f | input as $l
227
+ | $o + {first_turns:$f, last_turns:$l}')"
184
228
  fi
185
229
  printf '%s\n' "$obj"
186
230
  return 0
@@ -197,6 +241,13 @@ clast_cmd_show() {
197
241
  printf 'duration: %s\n' "$duration_str"
198
242
  fi
199
243
  printf 'msg_count: %s (approx)\n' "$msgs"
244
+ printf 'user_msgs: %s\n' "$user_count"
245
+ printf 'assistant_msgs: %s\n' "$assistant_count"
246
+ if [[ "$substantive" == "true" ]]; then
247
+ printf 'substantive: yes\n'
248
+ else
249
+ printf 'substantive: no (no-op — empty / slash-command-only)\n'
250
+ fi
200
251
  printf 'snapshot: %s\n' "$abs_path"
201
252
  if [[ "$curated" == "true" ]]; then
202
253
  printf 'curated: yes\n'
@@ -220,14 +271,20 @@ clast_cmd_show() {
220
271
  }
221
272
 
222
273
  # _clast_show_user_messages <abs_path>
223
- # Stream user-message text content, one per line. Empty lines dropped.
274
+ # Stream real user-prompt text content, one per line. Empty lines, meta
275
+ # messages, and slash-command wrappers (`/clear`, `/model`, …) are dropped
276
+ # so first_prompt/last_prompt reflect what the user actually typed — not a
277
+ # `<local-command-caveat>…` marker. Shares CLAST_COMMAND_MARKER_RE with the
278
+ # classifier (clast-classify-lib.bash) so the two can't drift.
224
279
  _clast_show_user_messages() {
225
280
  local path="$1"
226
- jq -r '
281
+ jq -r --arg cmd_re "$CLAST_COMMAND_MARKER_RE" '
227
282
  select((.role // .message.role // .type) == "user")
283
+ | select((.isMeta // false) != true)
228
284
  | (.message.content // .content // empty)
229
285
  | if type == "array" then map(.text? // "") | join(" ") else . end
230
286
  | select(. != null and . != "")
287
+ | select(test($cmd_re) | not)
231
288
  ' "$path" 2>/dev/null
232
289
  }
233
290
 
@@ -7,6 +7,7 @@
7
7
  # shellcheck shell=bash
8
8
  # shellcheck source=lib/clast/clast-lib.bash
9
9
  # shellcheck source=lib/clast/clast-manifest-lib.bash
10
+ # shellcheck source=lib/clast/clast-classify-lib.bash
10
11
  # shellcheck source=lib/clast/clast-decode-lib.bash
11
12
  # shellcheck source=lib/clast/clast-registry-lib.bash
12
13
 
@@ -28,16 +29,10 @@ EOF
28
29
 
29
30
  # _clast_snapshot_bucket_for_epoch <epoch>
30
31
  # Mirror of clast_today's cutoff math against an arbitrary epoch. Local
31
- # time per docs/explanation/conventions.md.
32
+ # time per docs/explanation/conventions.md. Thin alias over the shared
33
+ # primitive in clast-lib.bash.
32
34
  _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
35
+ clast_day_bucket_for_epoch "$1"
41
36
  }
42
37
 
43
38
  clast_cmd_snapshot() {
@@ -117,7 +112,8 @@ clast_cmd_snapshot() {
117
112
  # TODO(v1.1): parallel capture across segments.
118
113
  if [[ -d "$projects_dir" ]]; then
119
114
  local source segment session_id mtime_epoch mtime_iso source_size
120
- local first_ts ts_epoch day_bucket
115
+ local first_ts ts_epoch day_bucket last_ts msg_count
116
+ local user_msg_count assistant_msg_count
121
117
  local dest_rel dest dest_dir tmp
122
118
  while IFS= read -r -d '' source; do
123
119
  segment="$(basename "$(dirname "$source")")"
@@ -163,6 +159,16 @@ clast_cmd_snapshot() {
163
159
  day_bucket="$(_clast_snapshot_bucket_for_epoch "$mtime_epoch")"
164
160
  fi
165
161
 
162
+ # Cache per-session metadata in the manifest (step 21) so readers
163
+ # don't re-open the transcript. msg_count uses wc -l semantics to
164
+ # match the readers' file-read fallback exactly; last_ts mirrors the
165
+ # existing first_ts extraction. Kept as separate reads (not a fused
166
+ # awk pass) to avoid the trailing-newline parsing hazard and because
167
+ # snapshot is a batch path where the extra forks are negligible.
168
+ last_ts="$(tail -n1 "$source" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
169
+ msg_count="$(wc -l <"$source" 2>/dev/null | tr -d ' ')"
170
+ [[ "$msg_count" =~ ^[0-9]+$ ]] || msg_count=0
171
+
166
172
  # Dedup on (session_id, source_mtime). Mtime — not ctime or size —
167
173
  # is the manifest's "most recent line wins" key; Claude Code's
168
174
  # writer bumps mtime whenever a session grows.
@@ -171,6 +177,18 @@ clast_cmd_snapshot() {
171
177
  continue
172
178
  fi
173
179
 
180
+ # Session classification (clast-classify-lib.bash): count real user
181
+ # prompts and assistant replies so wake can auto-dismiss no-op sessions
182
+ # (empty / slash-command-only) without an LLM call. Cached on the
183
+ # manifest line alongside msg_count so readers never re-open the file.
184
+ # Computed AFTER the dedupe check: unlike the head/tail/wc reads above,
185
+ # this streams the whole transcript through jq, so we must not pay it for
186
+ # already-captured sessions skipped on every wake/hook run.
187
+ IFS=$'\t' read -r user_msg_count assistant_msg_count \
188
+ < <(clast_session_msg_counts "$source")
189
+ [[ "$user_msg_count" =~ ^[0-9]+$ ]] || user_msg_count=0
190
+ [[ "$assistant_msg_count" =~ ^[0-9]+$ ]] || assistant_msg_count=0
191
+
174
192
  dest_rel="transcripts/$day_bucket/$segment/$session_id.jsonl"
175
193
  dest="$journal_dir/$dest_rel"
176
194
 
@@ -199,7 +217,7 @@ clast_cmd_snapshot() {
199
217
  # Invariant: a manifest line implies the dest file exists. If the
200
218
  # append fails the dest is an orphan; doctor (step 10) will reap
201
219
  # 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
220
+ if ! clast_manifest_append "$session_id" "$source" "$dest_rel" "$mtime_iso" "$source_size" "$day_bucket" "$msg_count" "$first_ts" "$last_ts" "$user_msg_count" "$assistant_msg_count"; then
203
221
  error_lines+=("$(jq -cn --arg f "$source" --arg r "manifest append failed" '{file:$f,reason:$r}')")
204
222
  continue
205
223
  fi
@@ -244,8 +244,13 @@ clast_cmd_stats() {
244
244
  [[ "$size" =~ ^[0-9]+$ ]] || size=0
245
245
  bytes_sum=$((bytes_sum + size))
246
246
 
247
- local abs="$journal_dir/$snapshot" m=0
248
- if [[ -r "$abs" ]]; then
247
+ # Prefer cached msg_count on the manifest line (step 21); fall back to
248
+ # counting transcript lines for legacy lines that predate the cache.
249
+ local abs="$journal_dir/$snapshot" m=0 cached_m
250
+ cached_m="$(jq -r '.msg_count // empty' <<<"$row")"
251
+ if [[ -n "$cached_m" ]]; then
252
+ m="$cached_m"
253
+ elif [[ -r "$abs" ]]; then
249
254
  m="$(wc -l <"$abs" 2>/dev/null | tr -d ' ')"
250
255
  [[ -z "$m" ]] && m=0
251
256
  fi
@@ -1,17 +1,23 @@
1
1
  You are synthesizing a project briefing for the user. They are about to start work on a project and want a tight summary of where they left off.
2
2
 
3
+ The recent entries may be grouped under `## Workspace: <label>` headers when
4
+ this project spans more than one directory (separate clones/worktrees of the
5
+ same repo). Treat each workspace as a distinct line of work — do NOT blend
6
+ them. When there are no workspace headers, the project is a single directory;
7
+ render exactly as the structure below.
8
+
3
9
  Produce a briefing using this structure (omit any section that has no content):
4
10
 
5
11
  ## Wakeup briefing — {project}
6
12
 
7
- **Active thread:** one-line from most recent entry's "Open threads", or "None"
13
+ **Active thread:** one-line from the most recent entry's "Open threads", or "None". When the entries are split across workspaces, pick the active thread from the **current workspace** (named in the user prompt) if it has any entries; otherwise from the most recent entry overall. State which workspace it came from, e.g. "(dev)".
8
14
 
9
- **Last session:** date, branch, one-line goal
10
- - Work done: 2-3 bullets condensed from most recent entry
15
+ **Last session:** date, workspace/branch, one-line goal
16
+ - Work done: 2-3 bullets condensed from the most recent entry
11
17
  - Open threads: bullets, if any
12
18
  - Dead ends to avoid: bullets, if any
13
19
 
14
- **Recent sessions:** (up to 5 entries)
20
+ **Recent sessions:** (up to ~8 entries) — when multiple workspaces are present, group these under a short `**<label>:**` subheading per workspace, newest first within each:
15
21
  - date [branch] slug: one-line goal
16
22
 
17
23
  **Today's breadcrumbs:** (if any)
@@ -24,7 +30,7 @@ Produce a briefing using this structure (omit any section that has no content):
24
30
 
25
31
  Be concise. Use the user's terminology. Don't repeat content across sections. The total briefing should be 2-5k tokens — if you're approaching that, summarize rather than list verbatim.
26
32
 
27
- For the "Suggested next step": prefer the most recent entry's "Open threads" content, then the most recent breadcrumb, then a synthesis of the recent work. If nothing concrete, say "No active thread."
33
+ For the "Suggested next step": prefer the active thread's workspace — its most recent entry's "Open threads" content, then the most recent breadcrumb, then a synthesis of the recent work. If nothing concrete, say "No active thread."
28
34
 
29
35
  End with one of:
30
36
  - "Resume? Active thread: '<thread>'. Suggested next step: <step>."
@@ -1,6 +1,9 @@
1
1
  Project: {{project}}
2
+ Current workspace (the directory you are in now): {{current_label}}
2
3
 
3
- Recent curated entries (newest first):
4
+ Recent curated entries, grouped by workspace (newest first). When a project
5
+ spans more than one directory, entries appear under `## Workspace: <label>`
6
+ headers; a single-workspace project has no headers:
4
7
  {{entries}}
5
8
 
6
9
  Today's breadcrumbs for this project:
@@ -0,0 +1,14 @@
1
+ You are condensing a single already-curated Claude Code session into a few tight retro bullets. The input is the body of a journal entry (or several merged entries for one session). Your output will appear in a work retrospective grouped by day and project, so it must be skimmable and factual.
2
+
3
+ Condense the session into at most 3-5 bullets, in this order, omitting any that have no content (do not write "N/A" or empty headers):
4
+
5
+ - **Shipped:** what actually got done — features, fixes, files, decisions. One bullet per distinct outcome.
6
+ - **Issues:** notable problems hit and how they were resolved (only if the body records them).
7
+ - **Open:** anything left unfinished, deferred, or flagged for next time.
8
+
9
+ Rules:
10
+ - Use only what the body states. Do not invent, infer motivation, or add detail that is not present.
11
+ - Keep the user's terminology — project names, file paths, branch names, ticket ids — verbatim.
12
+ - Be terse: bullets, not paragraphs. No preamble, no closing summary, no headers other than the bold lead-ins above.
13
+ - If the body is an interrupted session (a goal and open threads but nothing shipped), say so in one **Open:** bullet rather than overstating progress.
14
+ - Output the bullets only — no title line, no tags, no surrounding prose.
@@ -0,0 +1,7 @@
1
+ Session to condense:
2
+ - Project: {{project}}
3
+ - Work day: {{work_day}}
4
+ - Session id: {{session_id}}
5
+
6
+ Entry body:
7
+ {{body}}
@@ -26,4 +26,4 @@ One sentence describing what this session was trying to accomplish.
26
26
  Be concise. Prefer bullets over paragraphs. Use the user's terminology (project-specific names, file paths). Do not invent details. If you are uncertain about something, omit it rather than guess.
27
27
 
28
28
  After the entry, add a blank line and then: "Suggested tags: tag1, tag2, tag3"
29
- Tags must be lowercase kebab-case (regex: `^[a-z0-9][a-z0-9-]{0,31}$`). Examples: `adrs`, `symfony-bot`, `mr-umbrella`, `phase-0`. Never use uppercase letters.
29
+ Tags must be lowercase kebab-case (regex: `^[a-z0-9][a-z0-9-]{0,31}$`). Examples: `adrs`, `symfony-bot`, `mr-umbrella`, `phase-0`. Never use uppercase letters. Use hyphens, not dots or spaces, for separators — write versions like `php-8-5`, not `php-8.5`.