@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
@@ -0,0 +1,514 @@
1
+ # clast wake — LLM-powered interactive day curation.
2
+ #
3
+ # Replicates the /wake plugin skill using an OpenAI-compatible chat
4
+ # completions endpoint. Calls clast-plumbing for data, assembles prompts,
5
+ # calls the LLM via curl, presents drafts interactively.
6
+ #
7
+ # Usage: clast wake
8
+ # shellcheck shell=bash
9
+
10
+ # --- Small helpers -----------------------------------------------------------
11
+
12
+ _clast_wake_slugify() {
13
+ local s="$1"
14
+ s="${s,,}"
15
+ s="$(printf '%s' "$s" | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//')"
16
+ printf '%s' "${s:0:60}"
17
+ }
18
+
19
+ _clast_wake_separator() {
20
+ local label="$1"
21
+ local width=72
22
+ local pad=$(( width - ${#label} - 4 ))
23
+ (( pad < 2 )) && pad=2
24
+ local dashes=""
25
+ local j
26
+ for (( j = 0; j < pad; j++ )); do dashes+="─"; done
27
+ printf '── %s %s\n' "$label" "$dashes"
28
+ }
29
+
30
+ # --- Preflight ---------------------------------------------------------------
31
+
32
+ _clast_wake_preflight() {
33
+ if [[ ! -t 0 ]]; then
34
+ clast_porcelain_die "clast wake requires an interactive terminal (stdin is not a tty)."
35
+ fi
36
+ clast_porcelain_preflight_llm
37
+ }
38
+
39
+ # --- User prompt -------------------------------------------------------------
40
+
41
+ _clast_wake_build_user_prompt() {
42
+ local project="$1" branch="$2" start="$3" end="$4" msg_count="$5"
43
+ local first_turns="$6" last_turns="$7" breadcrumbs="$8"
44
+
45
+ local template_file template
46
+ template_file="$(clast_porcelain_user_prompt_file wake-draft-user)"
47
+
48
+ if [[ -n "$template_file" ]]; then
49
+ template="$(cat "$template_file")"
50
+ template="${template//\{\{project\}\}/${project}}"
51
+ template="${template//\{\{branch\}\}/${branch:-unknown}}"
52
+ template="${template//\{\{start\}\}/${start}}"
53
+ template="${template//\{\{end\}\}/${end}}"
54
+ template="${template//\{\{msg_count\}\}/${msg_count}}"
55
+ template="${template//\{\{first_turns\}\}/${first_turns}}"
56
+ template="${template//\{\{last_turns\}\}/${last_turns}}"
57
+ template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
58
+ printf '%s' "$template"
59
+ else
60
+ clast_porcelain_warn "user prompt template not found: wake-draft-user.md — using inline fallback"
61
+ cat <<EOF
62
+ Session metadata:
63
+ - Project: ${project}
64
+ - Branch: ${branch:-unknown}
65
+ - Start: ${start}
66
+ - End: ${end}
67
+ - Approximate messages: ${msg_count}
68
+
69
+ First turns of the session:
70
+ ${first_turns}
71
+
72
+ Last turns of the session:
73
+ ${last_turns}
74
+
75
+ Breadcrumbs the user left during this session's day:
76
+ ${breadcrumbs:-None.}
77
+ EOF
78
+ fi
79
+ }
80
+
81
+ # --- Draft parsing -----------------------------------------------------------
82
+
83
+ _clast_wake_extract_title() {
84
+ local draft="$1"
85
+ grep -m1 '^# Session:' <<<"$draft" | sed 's/^# Session:[[:space:]]*//'
86
+ }
87
+
88
+ _clast_wake_extract_tags() {
89
+ local draft="$1"
90
+ local tags
91
+ tags="$(grep -i '^Suggested tags:' <<<"$draft" | sed 's/^[Ss]uggested tags:[[:space:]]*//')"
92
+ printf '%s' "$tags" | sed 's/[[:space:]]*,[[:space:]]*/,/g'
93
+ }
94
+
95
+ _clast_wake_strip_tags_trailer() {
96
+ local draft="$1"
97
+ printf '%s' "$draft" | sed '/^$/{ N; /\n[Ss]uggested tags:/d; }' | sed '/^[Ss]uggested tags:/d'
98
+ }
99
+
100
+ # --- Session slug ------------------------------------------------------------
101
+
102
+ _clast_wake_get_session_slug() {
103
+ local snapshot_path="$1" draft_title="$2"
104
+ local journal_dir
105
+ journal_dir="$(clast-plumbing whereami 2>/dev/null | grep '^journal_dir:' | awk '{print $2}')" || true
106
+ if [[ -z "$journal_dir" ]]; then
107
+ journal_dir="${HOME}/.claude/journal"
108
+ fi
109
+
110
+ local abs_path="$journal_dir/$snapshot_path"
111
+
112
+ if [[ -r "$abs_path" ]]; then
113
+ local ai_title
114
+ ai_title="$(jq -r 'select(.type == "ai-title") | .aiTitle' "$abs_path" 2>/dev/null | tail -1)" || true
115
+ if [[ -n "$ai_title" && "$ai_title" != "null" ]]; then
116
+ _clast_wake_slugify "$ai_title"
117
+ return
118
+ fi
119
+ fi
120
+
121
+ if [[ -n "$draft_title" ]]; then
122
+ _clast_wake_slugify "$draft_title"
123
+ return
124
+ fi
125
+
126
+ printf 'session'
127
+ }
128
+
129
+ # --- Interactive menu --------------------------------------------------------
130
+
131
+ _clast_wake_prompt_choice() {
132
+ local choice
133
+ printf '\n [a] Accept [e] Edit [d] Dismiss [s] Skip [q] Stop here\n' >/dev/tty
134
+ printf ' Choice: ' >/dev/tty
135
+ read -r -n1 choice </dev/tty
136
+ printf '\n' >/dev/tty
137
+ printf '%s' "$choice"
138
+ }
139
+
140
+ _clast_wake_prompt_edit_feedback() {
141
+ local feedback
142
+ printf '\n What should change? ' >/dev/tty
143
+ read -r feedback </dev/tty
144
+ printf '%s' "$feedback"
145
+ }
146
+
147
+ # --- Triage ------------------------------------------------------------------
148
+
149
+ _clast_wake_triage() {
150
+ local uncurated="$1" total="$2" day_count="$3"
151
+ local first_day="$4" last_day="$5" project_count="$6"
152
+
153
+ clast_porcelain_info "Found $total uncurated session(s) across $day_count day(s) ($first_day to $last_day)." >/dev/tty
154
+ printf '\n' >/dev/tty
155
+
156
+ local breakdown
157
+ breakdown="$(jq -r '
158
+ group_by(.day_bucket) | sort_by(.[0].day_bucket) | .[] |
159
+ " \(.[0].day_bucket) \(length) session(s)"
160
+ ' <<<"$uncurated")"
161
+ printf '%s\n' "$breakdown" >/dev/tty
162
+ printf '\n' >/dev/tty
163
+
164
+ local choice
165
+ printf ' [a] Process all %s sessions\n' "$total" >/dev/tty
166
+ printf ' [y] Process yesterday only\n' >/dev/tty
167
+ printf ' [n] Choose how many days back\n' >/dev/tty
168
+ printf ' [o] Dismiss everything older, then process the rest\n' >/dev/tty
169
+ printf ' [q] Quit\n' >/dev/tty
170
+ printf ' Choice: ' >/dev/tty
171
+ read -r -n1 choice </dev/tty
172
+ printf '\n' >/dev/tty
173
+
174
+ case "$choice" in
175
+ a|A)
176
+ printf '%s' "$uncurated"
177
+ ;;
178
+ y|Y)
179
+ local yesterday
180
+ yesterday="$(date -d 'yesterday' +%Y-%m-%d)"
181
+ jq -c "[.[] | select(.day_bucket == \"$yesterday\")]" <<<"$uncurated"
182
+ ;;
183
+ n|N)
184
+ local days
185
+ printf ' How many days back? ' >/dev/tty
186
+ read -r days </dev/tty
187
+ if ! [[ "$days" =~ ^[1-9][0-9]*$ ]]; then
188
+ clast_porcelain_warn "invalid number — processing all sessions"
189
+ printf '%s' "$uncurated"
190
+ return
191
+ fi
192
+ local cutoff
193
+ cutoff="$(date -d "-${days} days" +%Y-%m-%d 2>/dev/null)" || {
194
+ clast_porcelain_warn "failed to compute date — processing all sessions"
195
+ printf '%s' "$uncurated"
196
+ return
197
+ }
198
+ jq -c "[.[] | select(.day_bucket >= \"$cutoff\")]" <<<"$uncurated"
199
+ ;;
200
+ o|O)
201
+ local days_keep
202
+ printf ' Keep how many days? (dismiss everything older) ' >/dev/tty
203
+ read -r days_keep </dev/tty
204
+ if ! [[ "$days_keep" =~ ^[1-9][0-9]*$ ]]; then
205
+ clast_porcelain_warn "invalid number — processing all sessions"
206
+ printf '%s' "$uncurated"
207
+ return
208
+ fi
209
+ local cutoff_keep
210
+ cutoff_keep="$(date -d "-${days_keep} days" +%Y-%m-%d 2>/dev/null)" || {
211
+ clast_porcelain_warn "failed to compute date — processing all sessions"
212
+ printf '%s' "$uncurated"
213
+ return
214
+ }
215
+ local old_ids
216
+ old_ids="$(jq -r "[.[] | select(.day_bucket < \"$cutoff_keep\")] | .[].session_id" <<<"$uncurated")"
217
+ local dismiss_count=0
218
+ local old_id
219
+ while IFS= read -r old_id; do
220
+ [[ -z "$old_id" ]] && continue
221
+ clast-plumbing sessions dismiss "$old_id" --reason "bulk dismiss via clast wake triage" 2>/dev/null
222
+ dismiss_count=$(( dismiss_count + 1 ))
223
+ done <<<"$old_ids"
224
+ if (( dismiss_count > 0 )); then
225
+ clast_porcelain_info " Dismissed $dismiss_count older session(s)." >/dev/tty
226
+ fi
227
+ jq -c "[.[] | select(.day_bucket >= \"$cutoff_keep\")]" <<<"$uncurated"
228
+ ;;
229
+ q|Q)
230
+ printf '[]'
231
+ ;;
232
+ *)
233
+ clast_porcelain_warn "unknown choice — processing all sessions"
234
+ printf '%s' "$uncurated"
235
+ ;;
236
+ esac
237
+ }
238
+
239
+ # --- Main --------------------------------------------------------------------
240
+
241
+ clast_cmd_wake() {
242
+ _clast_wake_preflight
243
+
244
+ clast_porcelain_info "Snapshotting fresh transcripts..."
245
+ if ! clast-plumbing snapshot 2>/dev/null; then
246
+ clast_porcelain_warn "clast-plumbing snapshot failed — proceeding with existing data"
247
+ fi
248
+
249
+ clast_porcelain_info "Checking for uncurated or stale sessions..."
250
+ # Window is configurable; a shorter default keeps the scan fast on large
251
+ # journals (clast sessions cost scales with sessions in the window).
252
+ local since="${CLAST_WAKE_SINCE:--14d}"
253
+ local sessions_json
254
+ sessions_json="$(clast-plumbing --json sessions --since "$since" 2>/dev/null)" || {
255
+ clast_porcelain_die "failed to list sessions"
256
+ }
257
+
258
+ # Auto-dismiss no-op sessions before any LLM work: sessions where Claude
259
+ # never replied (substantive == false) — empty sessions, slash-command-only
260
+ # sessions (/clear, /model, /config), and sessions abandoned before any
261
+ # response. This is a deterministic pre-filter — the LLM is never called for
262
+ # them. Sessions driven by a custom slash command still have assistant
263
+ # replies, so they are kept. Reversible via `clast undismiss <id>`. Opt out
264
+ # by setting CLAST_WAKE_AUTODISMISS_NOOP=0.
265
+ local auto_dismissed_count=0
266
+ if [[ "${CLAST_WAKE_AUTODISMISS_NOOP:-1}" != "0" ]]; then
267
+ local noop_ids noop_id
268
+ noop_ids="$(jq -r '
269
+ .[] | select(.substantive == false and .curated == false and .dismissed == false)
270
+ | .session_id
271
+ ' <<<"$sessions_json")"
272
+ while IFS= read -r noop_id; do
273
+ [[ -z "$noop_id" ]] && continue
274
+ if clast-plumbing sessions dismiss "$noop_id" \
275
+ --reason "auto: no substantive content (empty / slash-command-only)" >/dev/null 2>&1; then
276
+ auto_dismissed_count=$(( auto_dismissed_count + 1 ))
277
+ fi
278
+ done <<<"$noop_ids"
279
+ if (( auto_dismissed_count > 0 )); then
280
+ clast_porcelain_info "Auto-dismissed $auto_dismissed_count no-op session(s) (empty / slash-command-only)."
281
+ # Drop the just-dismissed rows from the working set so they don't reappear.
282
+ sessions_json="$(jq -c '[.[] | select(.substantive != false or .curated == true)]' <<<"$sessions_json")"
283
+ fi
284
+ fi
285
+
286
+ local uncurated
287
+ uncurated="$(jq -c '[.[] | select(.curated == false or .stale == true)]' <<<"$sessions_json")"
288
+ local total
289
+ total="$(jq 'length' <<<"$uncurated")"
290
+
291
+ if (( total == 0 )); then
292
+ clast_porcelain_info "Nothing to curate — all sessions are curated or dismissed."
293
+ return 0
294
+ fi
295
+
296
+ local day_count first_day last_day project_count
297
+ day_count="$(jq '[.[].day_bucket] | unique | length' <<<"$uncurated")"
298
+ first_day="$(jq -r '[.[].day_bucket] | sort | first' <<<"$uncurated")"
299
+ last_day="$(jq -r '[.[].day_bucket] | sort | last' <<<"$uncurated")"
300
+ project_count="$(jq '[.[].project] | unique | length' <<<"$uncurated")"
301
+
302
+ if (( day_count > 1 )); then
303
+ uncurated="$(_clast_wake_triage "$uncurated" "$total" "$day_count" "$first_day" "$last_day" "$project_count")"
304
+ total="$(jq 'length' <<<"$uncurated")"
305
+ if (( total == 0 )); then
306
+ clast_porcelain_info "Nothing left to curate."
307
+ return 0
308
+ fi
309
+ project_count="$(jq '[.[].project] | unique | length' <<<"$uncurated")"
310
+ fi
311
+
312
+ clast_porcelain_info "Processing $total session(s) across $project_count project(s)."
313
+ printf '\n'
314
+
315
+ local curated_count=0 skipped_count=0 dismissed_count=0
316
+ local -a curated_projects=()
317
+ local i=0 stop=0
318
+
319
+ while (( i < total && stop == 0 )); do
320
+ local session
321
+ session="$(jq -c ".[$i]" <<<"$uncurated")"
322
+
323
+ local sid project branch start_ts end_ts msg_count snapshot_path
324
+ sid="$(jq -r '.session_id' <<<"$session")"
325
+ project="$(jq -r '.project' <<<"$session")"
326
+ branch="$(jq -r '.branch // ""' <<<"$session")"
327
+ start_ts="$(jq -r '.start' <<<"$session")"
328
+ end_ts="$(jq -r '.end' <<<"$session")"
329
+ msg_count="$(jq -r '.msg_count_approx' <<<"$session")"
330
+ snapshot_path="$(jq -r '.snapshot_path' <<<"$session")"
331
+
332
+ # Recorded date + time range so the reviewer can tell which day's work
333
+ # this is (BDS-54). Render the session's own start/end instant in the
334
+ # local timezone. Deliberately NOT day_bucket: that is clast's
335
+ # cutoff-adjusted *filing* day, which differs from the instant's calendar
336
+ # date for pre-cutoff sessions — pairing it with a clock time (and a "UTC"
337
+ # label) yielded a timestamp wrong by a day. Fall back to the raw UTC
338
+ # substrings if `date` can't parse the timestamp.
339
+ local rec_date start_short end_short tz
340
+ rec_date="$(date -d "$start_ts" +%Y-%m-%d 2>/dev/null)" || rec_date=""
341
+ [[ -z "$rec_date" ]] && rec_date="${start_ts:0:10}"
342
+ start_short="$(date -d "$start_ts" +%H:%M 2>/dev/null)" || start_short=""
343
+ [[ -z "$start_short" ]] && start_short="${start_ts:11:5}"
344
+ end_short="$(date -d "$end_ts" +%H:%M 2>/dev/null)" || end_short=""
345
+ [[ -z "$end_short" ]] && end_short="${end_ts:11:5}"
346
+ tz="$(date -d "$start_ts" +%Z 2>/dev/null)" || tz="UTC"
347
+ [[ -z "$tz" ]] && tz="UTC"
348
+
349
+ local recorded="$rec_date"
350
+ if [[ -n "$start_short" ]]; then
351
+ recorded="$recorded $start_short"
352
+ [[ -n "$end_short" && "$end_short" != "$start_short" ]] && recorded="$recorded–$end_short"
353
+ recorded="$recorded $tz"
354
+ fi
355
+
356
+ local is_stale
357
+ is_stale="$(jq -r '.stale // false' <<<"$session")"
358
+ local label="Session $((i+1))/$total: $project"
359
+ [[ "$is_stale" == "true" ]] && label="$label [STALE]"
360
+ [[ -n "$rec_date" ]] && label="$label ($rec_date $start_short"
361
+ [[ -n "$branch" && "$branch" != "null" ]] && label="$label, $branch"
362
+ label="$label)"
363
+
364
+ _clast_wake_separator "$label"
365
+ # Full session ID + recorded window: identifies exactly which session is
366
+ # being reviewed (e.g. for `clast-plumbing sessions dismiss/undismiss <id>`).
367
+ clast_porcelain_info " id: $sid"
368
+ clast_porcelain_info " recorded: $recorded"
369
+ clast_porcelain_info "Gathering context..."
370
+
371
+ local show_json
372
+ show_json="$(clast-plumbing --json show "$sid" --full --turns 8 2>/dev/null)" || {
373
+ local rc=$? reason
374
+ reason="$(jq -r '.error // empty' <<<"$show_json" 2>/dev/null || true)"
375
+ [[ -z "$reason" ]] && reason="exit $rc"
376
+ clast_porcelain_warn "failed to read session $sid ($reason) — skipping"
377
+ skipped_count=$(( skipped_count + 1 ))
378
+ i=$(( i + 1 ))
379
+ continue
380
+ }
381
+
382
+ # Cap each turn's text: a single pathological turn (e.g. a huge pasted
383
+ # blob or tool dump) would otherwise bloat the prompt — costly and liable
384
+ # to exceed the model's context. show --full keeps the full text; only the
385
+ # LLM-bound copy is bounded.
386
+ local first_turns last_turns turn_cap=2000
387
+ first_turns="$(jq -r --argjson cap "$turn_cap" '
388
+ .first_turns // [] | .[] |
389
+ (.text // "") as $t | ($t | length) as $n |
390
+ "[\(.role)] \(if $n > $cap then $t[0:$cap] + "… [\($n - $cap) more chars truncated]" else $t end)"
391
+ ' <<<"$show_json" 2>/dev/null)" || true
392
+
393
+ last_turns="$(jq -r --argjson cap "$turn_cap" '
394
+ .last_turns // [] | .[] |
395
+ (.text // "") as $t | ($t | length) as $n |
396
+ "[\(.role)] \(if $n > $cap then $t[0:$cap] + "… [\($n - $cap) more chars truncated]" else $t end)"
397
+ ' <<<"$show_json" 2>/dev/null)" || true
398
+
399
+ local breadcrumbs=""
400
+ breadcrumbs="$(clast-plumbing breadcrumb --read --project "$project" --day yesterday 2>/dev/null)" || true
401
+
402
+ local user_prompt
403
+ user_prompt="$(_clast_wake_build_user_prompt "$project" "$branch" "$start_ts" "$end_ts" "$msg_count" \
404
+ "$first_turns" "$last_turns" "$breadcrumbs")"
405
+
406
+ local system_prompt
407
+ system_prompt="$(clast_porcelain_load_system_prompt wake-draft-system)"
408
+
409
+ local draft="" edit_extra=""
410
+ local drafting=1
411
+
412
+ while (( drafting )); do
413
+ local full_system="$system_prompt"
414
+ local full_user="$user_prompt"
415
+ if [[ -n "$edit_extra" ]]; then
416
+ full_user="${full_user}
417
+
418
+ Revisions requested by user: ${edit_extra}"
419
+ fi
420
+
421
+ clast_porcelain_info "Generating draft..."
422
+ if ! draft="$(clast_porcelain_llm_chat "$full_system" "$full_user")"; then
423
+ printf '\n'
424
+ clast_porcelain_warn "LLM call failed for session $sid"
425
+ local retry
426
+ printf ' [r] Retry [s] Skip [q] Stop\n Choice: '
427
+ read -r -n1 retry </dev/tty
428
+ printf '\n'
429
+ case "$retry" in
430
+ r|R) continue ;;
431
+ q|Q) stop=1; break ;;
432
+ *) skipped_count=$(( skipped_count + 1 )); break ;;
433
+ esac
434
+ fi
435
+
436
+ printf '\n%s\n' "$draft"
437
+ printf '\n'
438
+
439
+ local choice
440
+ choice="$(_clast_wake_prompt_choice)"
441
+
442
+ case "$choice" in
443
+ a|A)
444
+ local title tags body slug
445
+ title="$(_clast_wake_extract_title "$draft")"
446
+ tags="$(_clast_wake_extract_tags "$draft")"
447
+ body="$(_clast_wake_strip_tags_trailer "$draft")"
448
+ slug="$(_clast_wake_get_session_slug "$snapshot_path" "$title")"
449
+
450
+ local write_args=(entries write --session "$sid" --slug "$slug" --body-stdin)
451
+ [[ -n "$tags" ]] && write_args+=(--tags "$tags")
452
+ [[ -n "$title" ]] && write_args+=(--title "$title")
453
+
454
+ local write_result
455
+ if write_result="$(printf '%s\n' "$body" | clast-plumbing "${write_args[@]}" 2>&1)"; then
456
+ clast_porcelain_info " $write_result"
457
+ curated_count=$(( curated_count + 1 ))
458
+ curated_projects+=("$project")
459
+ else
460
+ clast_porcelain_warn "failed to write entry: $write_result"
461
+ fi
462
+ drafting=0
463
+ ;;
464
+ e|E)
465
+ edit_extra="$(_clast_wake_prompt_edit_feedback)"
466
+ ;;
467
+ d|D)
468
+ if clast-plumbing sessions dismiss "$sid" --reason "dismissed via clast wake" 2>/dev/null; then
469
+ clast_porcelain_info " Dismissed."
470
+ dismissed_count=$(( dismissed_count + 1 ))
471
+ else
472
+ clast_porcelain_warn "failed to dismiss session $sid"
473
+ fi
474
+ drafting=0
475
+ ;;
476
+ s|S)
477
+ skipped_count=$(( skipped_count + 1 ))
478
+ drafting=0
479
+ ;;
480
+ q|Q)
481
+ stop=1
482
+ drafting=0
483
+ ;;
484
+ *)
485
+ clast_porcelain_info " Unknown choice '$choice' — skipping."
486
+ skipped_count=$(( skipped_count + 1 ))
487
+ drafting=0
488
+ ;;
489
+ esac
490
+ done
491
+
492
+ i=$(( i + 1 ))
493
+ printf '\n'
494
+ done
495
+
496
+ local remaining=$(( total - curated_count - skipped_count - dismissed_count ))
497
+ local unique_projects
498
+ unique_projects="$(printf '%s\n' "${curated_projects[@]}" 2>/dev/null | sort -u | wc -l | tr -d ' ')" || true
499
+ [[ -z "$unique_projects" || "$unique_projects" == "0" ]] && unique_projects=0
500
+
501
+ printf '\n'
502
+ _clast_wake_separator "Summary"
503
+ clast_porcelain_info " Curated: $curated_count session(s) across $unique_projects project(s)"
504
+ if (( auto_dismissed_count > 0 )); then
505
+ clast_porcelain_info " Auto-dismissed (no-op): $auto_dismissed_count session(s)"
506
+ fi
507
+ if (( dismissed_count > 0 )); then
508
+ clast_porcelain_info " Dismissed: $dismissed_count session(s)"
509
+ fi
510
+ clast_porcelain_info " Skipped: $skipped_count session(s)"
511
+ if (( remaining > 0 )); then
512
+ clast_porcelain_info " Remaining: $remaining session(s) (stopped early)"
513
+ fi
514
+ }