@procrastivity/clast 0.0.4 → 0.0.6

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 (32) hide show
  1. package/README.md +9 -9
  2. package/bin/clast +13 -2
  3. package/bin/clast-plumbing +7 -0
  4. package/examples/workflows/morning-briefing.md +6 -6
  5. package/lib/clast/clast-classify-lib.bash +68 -0
  6. package/lib/clast/clast-dismissed-lib.bash +70 -0
  7. package/lib/clast/clast-lib.bash +88 -6
  8. package/lib/clast/clast-manifest-lib.bash +35 -5
  9. package/lib/clast/clast-porcelain-lib.bash +29 -16
  10. package/lib/clast/clast-porcelain-subcommands/brief.bash +90 -21
  11. package/lib/clast/clast-porcelain-subcommands/retro.bash +317 -0
  12. package/lib/clast/clast-porcelain-subcommands/undismiss.bash +27 -0
  13. package/lib/clast/clast-porcelain-subcommands/wake.bash +78 -13
  14. package/lib/clast/clast-registry-lib.bash +132 -27
  15. package/lib/clast/clast-retro-lib.bash +397 -0
  16. package/lib/clast/clast-subcommands/doctor.bash +29 -13
  17. package/lib/clast/clast-subcommands/entries.bash +40 -50
  18. package/lib/clast/clast-subcommands/projects.bash +9 -19
  19. package/lib/clast/clast-subcommands/registry.bash +26 -11
  20. package/lib/clast/clast-subcommands/retro.bash +217 -0
  21. package/lib/clast/clast-subcommands/sessions.bash +104 -4
  22. package/lib/clast/clast-subcommands/show.bash +54 -8
  23. package/lib/clast/clast-subcommands/snapshot.bash +18 -10
  24. package/lib/clast/prompts/brief-system.md +11 -5
  25. package/lib/clast/prompts/brief-user.md +4 -1
  26. package/lib/clast/prompts/retro-summary-system.md +14 -0
  27. package/lib/clast/prompts/retro-summary-user.md +7 -0
  28. package/lib/clast/prompts/{day-wakeup-draft-system.md → wake-draft-system.md} +1 -1
  29. package/package.json +2 -1
  30. package/{.claude-plugin/skills/wakeup → skills/brief}/SKILL.md +9 -9
  31. package/{.claude-plugin/skills/day-wakeup → skills/wake}/SKILL.md +35 -12
  32. /package/lib/clast/prompts/{day-wakeup-draft-user.md → wake-draft-user.md} +0 -0
@@ -63,15 +63,16 @@ EOF
63
63
  return 0
64
64
  fi
65
65
 
66
- printf '%-17s %-33s %-43s %s\n' slug path remote aliases
67
- local n i slug path remote aliases
66
+ printf '%-17s %-13s %-33s %-43s %s\n' slug label path remote aliases
67
+ local n i slug label path remote aliases
68
68
  n="$(jq 'length' <<<"$arr")"
69
69
  for (( i = 0; i < n; i++ )); do
70
70
  slug="$(jq -r ".[$i].slug // \"\"" <<<"$arr")"
71
+ label="$(jq -r ".[$i].label // \"\" | if . == \"\" then \"(none)\" else . end" <<<"$arr")"
71
72
  path="$(jq -r ".[$i].path // \"\"" <<<"$arr")"
72
73
  remote="$(jq -r ".[$i].remote // \"\"" <<<"$arr")"
73
74
  aliases="$(jq -r ".[$i].aliases // [] | if length == 0 then \"(none)\" else join(\",\") end" <<<"$arr")"
74
- printf '%-17s %-33s %-43s %s\n' "$slug" "$path" "$remote" "$aliases"
75
+ printf '%-17s %-13s %-33s %-43s %s\n' "$slug" "$label" "$path" "$remote" "$aliases"
75
76
  done
76
77
  }
77
78
 
@@ -83,18 +84,18 @@ _clast_registry_op_add() {
83
84
  --json) json=1; shift ;;
84
85
  -h|--help)
85
86
  cat <<'EOF'
86
- Usage: clast registry add <path> [--slug NAME] [--remote URL] [--json]
87
+ Usage: clast registry add <path> [--slug NAME] [--label NAME] [--remote URL] [--json]
87
88
  EOF
88
89
  return 0
89
90
  ;;
90
91
  # TODO(v1.1): interactive --slug prompt when stdin is a TTY.
91
- --slug|--remote)
92
+ --slug|--label|--remote)
92
93
  if [[ $# -lt 2 ]]; then
93
94
  clast_log_error "registry add: $1 requires a value"
94
95
  return 2
95
96
  fi
96
97
  passthrough+=("$1" "$2"); shift 2 ;;
97
- --slug=*|--remote=*)
98
+ --slug=*|--label=*|--remote=*)
98
99
  passthrough+=("$1"); shift ;;
99
100
  *)
100
101
  passthrough+=("$1"); shift ;;
@@ -110,10 +111,15 @@ EOF
110
111
  if [[ -n "$json" ]]; then
111
112
  printf '%s\n' "$line"
112
113
  else
113
- local slug path
114
+ local slug label path
114
115
  slug="$(jq -r '.slug' <<<"$line")"
116
+ label="$(jq -r '.label // ""' <<<"$line")"
115
117
  path="$(jq -r '.path' <<<"$line")"
116
- printf 'registered %s → %s\n' "$slug" "$path"
118
+ if [[ -n "$label" ]]; then
119
+ printf 'registered %s (%s) → %s\n' "$slug" "$label" "$path"
120
+ else
121
+ printf 'registered %s → %s\n' "$slug" "$path"
122
+ fi
117
123
  fi
118
124
  }
119
125
 
@@ -126,6 +132,9 @@ _clast_registry_op_resolve() {
126
132
  -h|--help)
127
133
  cat <<'EOF'
128
134
  Usage: clast registry resolve <path-or-segment> [--json]
135
+
136
+ Prints the resolved slug. With --json, also includes the directory's
137
+ `label` when the matched line has one.
129
138
  EOF
130
139
  return 0
131
140
  ;;
@@ -144,10 +153,16 @@ EOF
144
153
  return 2
145
154
  fi
146
155
 
147
- local slug
148
- if slug="$(clast_registry_resolve "$input")" && [[ -n "$slug" ]]; then
156
+ # Resolve to the specific line so --json can surface the per-directory
157
+ # label (not just the slug). Human output stays slug-only.
158
+ local line
159
+ if line="$(clast_registry_line_for_path "$input")" && [[ -n "$line" ]]; then
160
+ local slug label
161
+ slug="$(jq -r '.slug // empty' <<<"$line")"
162
+ label="$(jq -r '.label // empty' <<<"$line")"
149
163
  if [[ -n "$json" ]]; then
150
- jq -cn --arg slug "$slug" '{slug: $slug}'
164
+ jq -cn --arg slug "$slug" --arg label "$label" \
165
+ '{slug: $slug} + (if $label == "" then {} else {label: $label} end)'
151
166
  else
152
167
  printf '%s\n' "$slug"
153
168
  fi
@@ -0,0 +1,217 @@
1
+ # clast-subcommands/retro.bash — `clast-plumbing retro`.
2
+ #
3
+ # Round 1 of the retro feature: run the deterministic day→project manifest
4
+ # (clast_retro_manifest) and render it from the raw entry bodies, in work-day
5
+ # order. No LLM. `--json` emits the manifest verbatim; the human render is the
6
+ # deterministic retro document the porcelain `clast retro` will later condense.
7
+ # See .wip/initiatives/clast-retro/.
8
+ # shellcheck shell=bash
9
+ # shellcheck source=lib/clast/clast-lib.bash
10
+ # shellcheck source=lib/clast/clast-retro-lib.bash
11
+ source "$CLAST_LIB/clast-retro-lib.bash"
12
+
13
+ _clast_retro_usage() {
14
+ cat <<'EOF'
15
+ Usage: clast retro [--from DATE] [--to DATE] [--window work-days|file-dates]
16
+
17
+ Summarize work grouped by the day it actually happened → project, from the
18
+ curated journal entries. Deterministic; no model call.
19
+
20
+ Flags:
21
+ --from DATE Start of the window (inclusive). Default: corpus start.
22
+ --to DATE End of the window (inclusive). Default: corpus end.
23
+ --window WHICH work-days (default): keep sessions whose work day is in range.
24
+ file-dates: keep entries whose filename date is in range
25
+ (pulls earlier work days reachable from those files).
26
+ --bodies With --json only: add each session's merged entry `body`.
27
+ -h, --help Print this usage and exit.
28
+
29
+ DATE accepts ISO (YYYY-MM-DD), `today`, `yesterday`, `last-week`, `-Nd`, `-Nw`.
30
+ With --json, emits the raw day→project manifest instead of the rendered report.
31
+ EOF
32
+ }
33
+
34
+ _clast_retro_err() {
35
+ local msg="$1" code="${2:-2}"
36
+ if [[ -n "${CLAST_JSON:-}" ]]; then
37
+ jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
38
+ else
39
+ clast_log_error "retro: $msg"
40
+ fi
41
+ }
42
+
43
+ clast_cmd_retro() {
44
+ local from="" to="" window="work-days" bodies=0
45
+
46
+ while [[ $# -gt 0 ]]; do
47
+ case "$1" in
48
+ --bodies) bodies=1; shift ;;
49
+ --from)
50
+ if [[ $# -lt 2 ]]; then _clast_retro_err "--from requires a value"; return 2; fi
51
+ if ! from="$(clast_parse_date "$2" 2>/dev/null)"; then
52
+ _clast_retro_err "invalid date '$2'"; return 2
53
+ fi
54
+ shift 2 ;;
55
+ --from=*)
56
+ if ! from="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
57
+ _clast_retro_err "invalid date '${1#*=}'"; return 2
58
+ fi
59
+ shift ;;
60
+ --to)
61
+ if [[ $# -lt 2 ]]; then _clast_retro_err "--to requires a value"; return 2; fi
62
+ if ! to="$(clast_parse_date "$2" 2>/dev/null)"; then
63
+ _clast_retro_err "invalid date '$2'"; return 2
64
+ fi
65
+ shift 2 ;;
66
+ --to=*)
67
+ if ! to="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
68
+ _clast_retro_err "invalid date '${1#*=}'"; return 2
69
+ fi
70
+ shift ;;
71
+ --window)
72
+ if [[ $# -lt 2 ]]; then _clast_retro_err "--window requires a value"; return 2; fi
73
+ window="$2"; shift 2 ;;
74
+ --window=*) window="${1#*=}"; shift ;;
75
+ -h|--help) _clast_retro_usage; return 0 ;;
76
+ --) shift; break ;;
77
+ -*) _clast_retro_err "unknown flag '$1'"; return 2 ;;
78
+ *) _clast_retro_err "unexpected positional '$1'"; return 2 ;;
79
+ esac
80
+ done
81
+
82
+ case "$window" in
83
+ work-days|file-dates) ;;
84
+ *) _clast_retro_err "--window must be 'work-days' or 'file-dates'"; return 2 ;;
85
+ esac
86
+
87
+ if [[ -n "$from" && -n "$to" && "$from" > "$to" ]]; then
88
+ _clast_retro_err "--from must be <= --to"; return 2
89
+ fi
90
+
91
+ local manifest
92
+ manifest="$(clast_retro_manifest \
93
+ ${from:+--from "$from"} ${to:+--to "$to"} --window "$window")" || return $?
94
+
95
+ if [[ -n "${CLAST_JSON:-}" ]]; then
96
+ if (( bodies )); then
97
+ manifest="$(_clast_retro_inject_bodies "$manifest")"
98
+ fi
99
+ printf '%s\n' "$manifest"
100
+ return 0
101
+ fi
102
+ if (( bodies )); then
103
+ _clast_retro_err "--bodies is only meaningful with --json"; return 2
104
+ fi
105
+ if [[ -n "${CLAST_QUIET:-}" ]]; then
106
+ return 0
107
+ fi
108
+
109
+ _clast_retro_render "$manifest"
110
+ }
111
+
112
+ # _clast_retro_inject_bodies <manifest-json>
113
+ # Add a `body` string (the merged trimmed entry bodies) to every session,
114
+ # for `--json --bodies`. Reuses clast_retro_session_body so the body matches
115
+ # the human render byte-for-byte.
116
+ _clast_retro_inject_bodies() {
117
+ local manifest="$1"
118
+ local -a pairs=()
119
+ local sess key body entry0 title interrupted
120
+ while IFS= read -r sess; do
121
+ [[ -z "$sess" ]] && continue
122
+ # Key by session_id, falling back to the first entry path when a
123
+ # legacy/hand-curated session has no id (JSON null): entries[0] is unique
124
+ # per session, so id-less sessions don't collide on a shared "null" key.
125
+ key="$(jq -r '.session_id // .entries[0]' <<<"$sess")"
126
+ body="$(clast_retro_session_body "$sess")"
127
+ entry0="$(jq -r '.entries[0]' <<<"$sess")"
128
+ title=""
129
+ [[ -r "$entry0" ]] && title="$(clast_entry_title "$entry0")"
130
+ if printf '%s' "$body" | clast_retro_is_interrupted; then
131
+ interrupted=true
132
+ else
133
+ interrupted=false
134
+ fi
135
+ # Body via --rawfile (process substitution), never argv: a single merged
136
+ # body can exceed MAX_ARG_STRLEN (~128 KiB) and silently fail the exec.
137
+ pairs+=("$(jq -cn --arg k "$key" --rawfile b <(printf '%s' "$body") \
138
+ --arg t "$title" --argjson i "$interrupted" \
139
+ '{key:$k, body:$b, title:$t, interrupted:$i}')")
140
+ done < <(jq -c '.days[].projects[].sessions[]' <<<"$manifest")
141
+
142
+ if (( ${#pairs[@]} == 0 )); then
143
+ printf '%s' "$manifest"
144
+ return 0
145
+ fi
146
+
147
+ # Manifest on stdin and the body-bearing pairs via --slurpfile — both can be
148
+ # large, so neither goes through argv. The `session_id // .entries[0]` key is
149
+ # always a non-null string, so this can't `$x[null]` abort jq ("Cannot index
150
+ # object with null") and id-less sessions don't collide on a shared key.
151
+ printf '%s' "$manifest" | jq -c --slurpfile pairs <(printf '%s\n' "${pairs[@]}") '
152
+ (reduce $pairs[] as $p ({}; .[$p.key] = $p)) as $x
153
+ | .days |= map(.projects |= map(.sessions |= map(
154
+ (.session_id // .entries[0]) as $k
155
+ | . + {body: ($x[$k].body // null),
156
+ title: ($x[$k].title // null),
157
+ interrupted: ($x[$k].interrupted // false)} )))
158
+ '
159
+ }
160
+
161
+ # _clast_retro_render <manifest-json>
162
+ # Render the day→project report to stdout.
163
+ _clast_retro_render() {
164
+ local manifest="$1"
165
+ local from to window
166
+ from="$(jq -r '.from // "(start)"' <<<"$manifest")"
167
+ to="$(jq -r '.to // "(end)"' <<<"$manifest")"
168
+ window="$(jq -r '.window' <<<"$manifest")"
169
+ printf 'Retro: %s -> %s (%s)\n' "$from" "$to" "$window"
170
+
171
+ local nd
172
+ nd="$(jq '.days | length' <<<"$manifest")"
173
+ if (( nd == 0 )); then
174
+ printf '\n(no sessions in range)\n'
175
+ return 0
176
+ fi
177
+
178
+ local di pj si
179
+ local day np project ns sess sid shortsid title entry note body flag
180
+ for (( di = 0; di < nd; di++ )); do
181
+ day="$(jq -r ".days[$di].day" <<<"$manifest")"
182
+ printf '\n== %s ==\n' "$day"
183
+ note="$(_clast_retro_provenance_note "$day" "$(jq -c ".days[$di].curation_dates // []" <<<"$manifest")")"
184
+ [[ -n "$note" ]] && printf '%s\n' "$note"
185
+ np="$(jq ".days[$di].projects | length" <<<"$manifest")"
186
+ for (( pj = 0; pj < np; pj++ )); do
187
+ project="$(jq -r ".days[$di].projects[$pj].project_name // .days[$di].projects[$pj].project_path // \"(no project)\"" <<<"$manifest")"
188
+ printf '\n[%s]\n' "$project"
189
+ ns="$(jq ".days[$di].projects[$pj].sessions | length" <<<"$manifest")"
190
+ for (( si = 0; si < ns; si++ )); do
191
+ sess="$(jq -c ".days[$di].projects[$pj].sessions[$si]" <<<"$manifest")"
192
+ sid="$(jq -r '.session_id' <<<"$sess")"
193
+ shortsid="${sid:0:8}"
194
+ entry="$(jq -r '.entries[0]' <<<"$sess")"
195
+ title=""
196
+ [[ -r "$entry" ]] && title="$(clast_entry_title "$entry")"
197
+ [[ -z "$title" ]] && title="(untitled)"
198
+ body="$(clast_retro_session_body "$sess")"
199
+ flag=""
200
+ printf '%s' "$body" | clast_retro_is_interrupted && flag=" [interrupted]"
201
+ printf '\n * %s (%s)%s\n' "$title" "$shortsid" "$flag"
202
+ printf '%s\n' "$body"
203
+ done
204
+ done
205
+ done
206
+ }
207
+
208
+ # _clast_retro_provenance_note <work-day> <curation_dates-json>
209
+ # One-line note when a day's entries were curated on other date(s) than the
210
+ # work day. Empty when they match (or there's nothing to say).
211
+ _clast_retro_provenance_note() {
212
+ local day="$1" cd_json="$2"
213
+ jq -r --arg d "$day" '
214
+ if . == [] or . == [$d] then empty
215
+ else " (filed " + (join(", ")) + "; work day reconstructed from session snapshots)"
216
+ end' <<<"$cd_json"
217
+ }
@@ -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
@@ -246,11 +304,12 @@ clast_cmd_sessions() {
246
304
  # source_mtime.
247
305
  local -a rows=()
248
306
  local sid snapshot day_bucket mtime seg abs_path msgs
249
- local cached_msgs cached_first cached_last
307
+ local cached_msgs cached_first cached_last cached_user cached_assistant
308
+ local user_count assistant_count substantive
250
309
  local first_ts last_ts start_ts end_ts curated stale branch slug
251
310
  declare -A seg_slug=()
252
311
 
253
- while IFS=$'\t' read -r sid snapshot day_bucket mtime cached_msgs cached_first cached_last; do
312
+ while IFS=$'\t' read -r sid snapshot day_bucket mtime cached_msgs cached_first cached_last cached_user cached_assistant; do
254
313
  [[ -z "$sid" ]] && continue
255
314
  # Skip dismissed sessions early, before any per-session file work.
256
315
  if [[ -n "${dismissed_ids[$sid]:-}" ]]; then
@@ -293,6 +352,40 @@ clast_cmd_sessions() {
293
352
  start_ts="${first_ts:-$mtime}"
294
353
  end_ts="${last_ts:-$mtime}"
295
354
 
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
+
296
389
  # Resolve the slug once per unique segment (segments repeat heavily
297
390
  # across sessions; each resolve forks several jq calls).
298
391
  if [[ -n "${seg_slug[$seg]+x}" ]]; then
@@ -338,6 +431,9 @@ clast_cmd_sessions() {
338
431
  --argjson curated "$curated" \
339
432
  --argjson stale "$stale" \
340
433
  --argjson dismissed "$is_dismissed" \
434
+ --arg user_msg_count "$user_count" \
435
+ --arg assistant_msg_count "$assistant_count" \
436
+ --argjson substantive "$substantive" \
341
437
  '{
342
438
  session_id: $session_id,
343
439
  project: $project,
@@ -350,7 +446,10 @@ clast_cmd_sessions() {
350
446
  day_bucket: $day_bucket,
351
447
  curated: $curated,
352
448
  stale: $stale,
353
- 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
354
453
  }')")
355
454
  done < <(
356
455
  if [[ -f "$manifest_path" ]]; then
@@ -361,7 +460,8 @@ clast_cmd_sessions() {
361
460
  ({}; .[$l.session_id] = $l)
362
461
  | to_entries[] | .value
363
462
  | [.session_id, .snapshot, .day_bucket, .source_mtime,
364
- .msg_count, .first_ts, .last_ts] | @tsv
463
+ .msg_count, .first_ts, .last_ts,
464
+ .user_msg_count, .assistant_msg_count] | @tsv
365
465
  ' "$manifest_path"
366
466
  fi
367
467
  )
@@ -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
 
@@ -113,14 +114,35 @@ clast_cmd_show() {
113
114
  start_ts="${first_ts:-$source_mtime}"
114
115
  end_ts="${last_ts:-$source_mtime}"
115
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
+
116
136
  # first_prompt / last_prompt — best-effort scan of user messages.
117
137
  # These pipelines must never abort the command: malformed JSONL lines are
118
138
  # routine, and the fields are explicitly documented as best-effort.
119
139
  local first_prompt last_prompt user_msgs=""
120
140
  user_msgs="$(_clast_show_user_messages "$abs_path" 2>/dev/null || true)"
121
- first_prompt="$(printf '%s\n' "$user_msgs" | head -n1)"
122
- last_prompt="$(printf '%s\n' "$user_msgs" | tail -n1)"
123
- # 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 ''.
124
146
  [[ -z "$user_msgs" ]] && first_prompt="" && last_prompt=""
125
147
  first_prompt="$(_clast_show_truncate "$first_prompt")"
126
148
  last_prompt="$(_clast_show_truncate "$last_prompt")"
@@ -174,6 +196,9 @@ clast_cmd_show() {
174
196
  --argjson curated "$curated" \
175
197
  --arg first_prompt "$first_prompt" \
176
198
  --arg last_prompt "$last_prompt" \
199
+ --argjson user_msg_count "$user_count" \
200
+ --argjson assistant_msg_count "$assistant_count" \
201
+ --argjson substantive "$substantive" \
177
202
  '{
178
203
  session_id: $session_id,
179
204
  project: $project,
@@ -187,11 +212,19 @@ clast_cmd_show() {
187
212
  day_bucket: $day_bucket,
188
213
  curated: $curated,
189
214
  first_prompt: (if $first_prompt == "" then null else $first_prompt end),
190
- 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
191
219
  }')"
192
220
  if (( include_turns == 1 )); then
193
- obj="$(jq -c --argjson f "$first_turns_json" --argjson l "$last_turns_json" \
194
- '. + {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}')"
195
228
  fi
196
229
  printf '%s\n' "$obj"
197
230
  return 0
@@ -208,6 +241,13 @@ clast_cmd_show() {
208
241
  printf 'duration: %s\n' "$duration_str"
209
242
  fi
210
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
211
251
  printf 'snapshot: %s\n' "$abs_path"
212
252
  if [[ "$curated" == "true" ]]; then
213
253
  printf 'curated: yes\n'
@@ -231,14 +271,20 @@ clast_cmd_show() {
231
271
  }
232
272
 
233
273
  # _clast_show_user_messages <abs_path>
234
- # 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.
235
279
  _clast_show_user_messages() {
236
280
  local path="$1"
237
- jq -r '
281
+ jq -r --arg cmd_re "$CLAST_COMMAND_MARKER_RE" '
238
282
  select((.role // .message.role // .type) == "user")
283
+ | select((.isMeta // false) != true)
239
284
  | (.message.content // .content // empty)
240
285
  | if type == "array" then map(.text? // "") | join(" ") else . end
241
286
  | select(. != null and . != "")
287
+ | select(test($cmd_re) | not)
242
288
  ' "$path" 2>/dev/null
243
289
  }
244
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() {
@@ -118,6 +113,7 @@ clast_cmd_snapshot() {
118
113
  if [[ -d "$projects_dir" ]]; then
119
114
  local source segment session_id mtime_epoch mtime_iso source_size
120
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")")"
@@ -181,6 +177,18 @@ clast_cmd_snapshot() {
181
177
  continue
182
178
  fi
183
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
+
184
192
  dest_rel="transcripts/$day_bucket/$segment/$session_id.jsonl"
185
193
  dest="$journal_dir/$dest_rel"
186
194
 
@@ -209,7 +217,7 @@ clast_cmd_snapshot() {
209
217
  # Invariant: a manifest line implies the dest file exists. If the
210
218
  # append fails the dest is an orphan; doctor (step 10) will reap
211
219
  # it, and a re-run of snapshot overwrites + retries the append.
212
- if ! clast_manifest_append "$session_id" "$source" "$dest_rel" "$mtime_iso" "$source_size" "$day_bucket" "$msg_count" "$first_ts" "$last_ts"; 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
213
221
  error_lines+=("$(jq -cn --arg f "$source" --arg r "manifest append failed" '{file:$f,reason:$r}')")
214
222
  continue
215
223
  fi