@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
@@ -1,6 +1,6 @@
1
1
  # clast wake — LLM-powered interactive day curation.
2
2
  #
3
- # Replicates the /day-wakeup plugin skill using an OpenAI-compatible chat
3
+ # Replicates the /wake plugin skill using an OpenAI-compatible chat
4
4
  # completions endpoint. Calls clast-plumbing for data, assembles prompts,
5
5
  # calls the LLM via curl, presents drafts interactively.
6
6
  #
@@ -43,7 +43,7 @@ _clast_wake_build_user_prompt() {
43
43
  local first_turns="$6" last_turns="$7" breadcrumbs="$8"
44
44
 
45
45
  local template_file template
46
- template_file="$(clast_porcelain_user_prompt_file day-wakeup-draft-user)"
46
+ template_file="$(clast_porcelain_user_prompt_file wake-draft-user)"
47
47
 
48
48
  if [[ -n "$template_file" ]]; then
49
49
  template="$(cat "$template_file")"
@@ -57,7 +57,7 @@ _clast_wake_build_user_prompt() {
57
57
  template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
58
58
  printf '%s' "$template"
59
59
  else
60
- clast_porcelain_warn "user prompt template not found: day-wakeup-draft-user.md — using inline fallback"
60
+ clast_porcelain_warn "user prompt template not found: wake-draft-user.md — using inline fallback"
61
61
  cat <<EOF
62
62
  Session metadata:
63
63
  - Project: ${project}
@@ -255,6 +255,34 @@ clast_cmd_wake() {
255
255
  clast_porcelain_die "failed to list sessions"
256
256
  }
257
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
+
258
286
  local uncurated
259
287
  uncurated="$(jq -c '[.[] | select(.curated == false or .stale == true)]' <<<"$sessions_json")"
260
288
  local total
@@ -301,37 +329,71 @@ clast_cmd_wake() {
301
329
  msg_count="$(jq -r '.msg_count_approx' <<<"$session")"
302
330
  snapshot_path="$(jq -r '.snapshot_path' <<<"$session")"
303
331
 
304
- local start_short="${start_ts:11:5}"
305
- [[ -z "$start_short" ]] && start_short="${start_ts:0:10}"
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
306
355
 
307
356
  local is_stale
308
357
  is_stale="$(jq -r '.stale // false' <<<"$session")"
309
358
  local label="Session $((i+1))/$total: $project"
310
359
  [[ "$is_stale" == "true" ]] && label="$label [STALE]"
311
- [[ -n "$start_short" ]] && label="$label ($start_short"
360
+ [[ -n "$rec_date" ]] && label="$label ($rec_date $start_short"
312
361
  [[ -n "$branch" && "$branch" != "null" ]] && label="$label, $branch"
313
362
  label="$label)"
314
363
 
315
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"
316
369
  clast_porcelain_info "Gathering context..."
317
370
 
318
371
  local show_json
319
372
  show_json="$(clast-plumbing --json show "$sid" --full --turns 8 2>/dev/null)" || {
320
- clast_porcelain_warn "failed to read session $sid — skipping"
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"
321
377
  skipped_count=$(( skipped_count + 1 ))
322
378
  i=$(( i + 1 ))
323
379
  continue
324
380
  }
325
381
 
326
- local first_turns last_turns
327
- first_turns="$(jq -r '
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" '
328
388
  .first_turns // [] | .[] |
329
- "[\(.role)] \(.text)"
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)"
330
391
  ' <<<"$show_json" 2>/dev/null)" || true
331
392
 
332
- last_turns="$(jq -r '
393
+ last_turns="$(jq -r --argjson cap "$turn_cap" '
333
394
  .last_turns // [] | .[] |
334
- "[\(.role)] \(.text)"
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)"
335
397
  ' <<<"$show_json" 2>/dev/null)" || true
336
398
 
337
399
  local breadcrumbs=""
@@ -342,7 +404,7 @@ clast_cmd_wake() {
342
404
  "$first_turns" "$last_turns" "$breadcrumbs")"
343
405
 
344
406
  local system_prompt
345
- system_prompt="$(clast_porcelain_load_system_prompt day-wakeup-draft-system)"
407
+ system_prompt="$(clast_porcelain_load_system_prompt wake-draft-system)"
346
408
 
347
409
  local draft="" edit_extra=""
348
410
  local drafting=1
@@ -439,6 +501,9 @@ Revisions requested by user: ${edit_extra}"
439
501
  printf '\n'
440
502
  _clast_wake_separator "Summary"
441
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
442
507
  if (( dismissed_count > 0 )); then
443
508
  clast_porcelain_info " Dismissed: $dismissed_count session(s)"
444
509
  fi
@@ -95,6 +95,58 @@ clast_registry_resolve() {
95
95
  printf '%s\n' "$slug"
96
96
  }
97
97
 
98
+ # clast_registry_line_for_path <path-or-segment>
99
+ # Like clast_registry_resolve, but prints the FULL matching registry line
100
+ # (the JSON object) instead of just the slug; empty + return 1 on miss.
101
+ # Use this to recover the per-directory path/label/remote for a specific
102
+ # session — slug-first-match collapses a multi-directory project onto its
103
+ # first line, which is wrong once a slug spans several directories.
104
+ clast_registry_line_for_path() {
105
+ local input="${1:-}"
106
+ if [[ -z "$input" ]]; then
107
+ return 1
108
+ fi
109
+
110
+ local arr
111
+ arr="$(clast_registry_list_json)"
112
+
113
+ local -a candidates=()
114
+ if [[ "$input" == -* ]]; then
115
+ candidates=("$input")
116
+ local -a decoded=()
117
+ mapfile -t decoded < <(clast_decode_candidates "$input")
118
+ candidates+=("${decoded[@]}")
119
+ else
120
+ local canon
121
+ canon="$(realpath -m "$input" 2>/dev/null || printf '%s' "$input")"
122
+ candidates=("$canon")
123
+ fi
124
+
125
+ local cands_json line
126
+ cands_json="$(printf '%s\n' "${candidates[@]}" | jq -Rn '[inputs]')"
127
+ line="$(_clast_registry_lookup_line "$cands_json" "$arr" 2>/dev/null)" || true
128
+ if [[ -z "$line" ]]; then
129
+ return 1
130
+ fi
131
+ printf '%s\n' "$line"
132
+ }
133
+
134
+ # _clast_registry_lookup_line <candidates-json-array> <registry-json-array>
135
+ # Like _clast_registry_lookup_paths but returns the FIRST matching line's
136
+ # whole object (path before alias). Print the compact JSON object or empty.
137
+ _clast_registry_lookup_line() {
138
+ local cands_json="$1" arr="$2"
139
+ jq -c --argjson cands "$cands_json" '
140
+ . as $arr
141
+ | first(
142
+ $cands[] as $c
143
+ | ( ($arr | map(select(.path == $c)) | .[0])
144
+ // ($arr | map(select((.aliases? // []) | index($c) != null)) | .[0]) )
145
+ | select(. != null)
146
+ ) // empty
147
+ ' <<<"$arr"
148
+ }
149
+
98
150
  # _clast_registry_lookup_path <path> <registry-json-array>
99
151
  # First match wins: scan .path, then .aliases[]. Print slug or empty.
100
152
  _clast_registry_lookup_path() {
@@ -125,14 +177,30 @@ _clast_registry_lookup_paths() {
125
177
  ' <<<"$arr"
126
178
  }
127
179
 
128
- # clast_registry_add <path> [--slug NAME] [--remote URL]
129
- # Append a single JSONL line. Default slug = basename(path). Default
130
- # remote = `git -C <path> remote get-url origin` (or absent). If the
131
- # resolved remote matches an existing entry's remote, append a new line
132
- # carrying that entry's slug and rolling its known paths into aliases.
180
+ # _clast_registry_slugify <string>
181
+ # Lowercase, map non-[a-z0-9] runs to single dashes, trim, cap at 32.
182
+ # Used for auto-derived labels (e.g. a parent-directory basename).
183
+ _clast_registry_slugify() {
184
+ local s
185
+ s="$(printf '%s' "${1,,}" | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//')"
186
+ s="${s:0:32}"
187
+ s="${s%-}"
188
+ printf '%s' "$s"
189
+ }
190
+
191
+ # clast_registry_add <path> [--slug NAME] [--label NAME] [--remote URL]
192
+ # Append a single JSONL line. The remote is a *grouping hint*, not an
193
+ # identity: when it matches an existing entry's remote, an absent --slug
194
+ # adopts that entry's slug (so clones of one repo share a project), but an
195
+ # explicit --slug always wins (a divergent one warns). Default slug =
196
+ # basename(path). Default label = basename(dirname(path)). Default remote
197
+ # = `git -C <path> remote get-url origin` (or absent). New lines carry
198
+ # `aliases: []` — sibling paths are no longer rolled up; a shared slug
199
+ # across distinct .path lines is the supported multi-directory shape.
133
200
  # Print the appended JSON on stdout. Exit 2 on bad args, 1 on write fail.
134
201
  clast_registry_add() {
135
- local path="" slug="" remote="" remote_explicit=0
202
+ local path="" slug="" slug_explicit=0 label_raw="" label_explicit=0
203
+ local remote="" remote_explicit=0
136
204
  while [[ $# -gt 0 ]]; do
137
205
  case "$1" in
138
206
  --slug)
@@ -140,8 +208,25 @@ clast_registry_add() {
140
208
  clast_log_error "clast_registry_add: --slug requires a value"
141
209
  return 2
142
210
  fi
143
- slug="$2"; shift 2 ;;
144
- --slug=*) slug="${1#*=}"; shift ;;
211
+ if [[ -z "$2" ]]; then
212
+ clast_log_error "clast_registry_add: --slug requires a non-empty value"
213
+ return 2
214
+ fi
215
+ slug="$2"; slug_explicit=1; shift 2 ;;
216
+ --slug=*)
217
+ slug="${1#*=}"
218
+ if [[ -z "$slug" ]]; then
219
+ clast_log_error "clast_registry_add: --slug requires a non-empty value"
220
+ return 2
221
+ fi
222
+ slug_explicit=1; shift ;;
223
+ --label)
224
+ if [[ $# -lt 2 ]]; then
225
+ clast_log_error "clast_registry_add: --label requires a value"
226
+ return 2
227
+ fi
228
+ label_raw="$2"; label_explicit=1; shift 2 ;;
229
+ --label=*) label_raw="${1#*=}"; label_explicit=1; shift ;;
145
230
  --remote)
146
231
  if [[ $# -lt 2 ]]; then
147
232
  clast_log_error "clast_registry_add: --remote requires a value"
@@ -196,30 +281,49 @@ clast_registry_add() {
196
281
  fi
197
282
  fi
198
283
 
199
- # Default slug from basename.
200
- if [[ -z "$slug" ]]; then
284
+ # Resolve the slug against any existing entry sharing this remote. The
285
+ # remote groups clones of one repo under a single logical project, but it
286
+ # is only a hint: an explicit --slug always wins. A divergent explicit
287
+ # slug warns (you are splitting the remote into two projects on purpose);
288
+ # the default slug silently adopts the match.
289
+ local matched_slug=""
290
+ if [[ -n "$remote" ]]; then
291
+ matched_slug="$(clast_registry_match_remote "$remote" 2>/dev/null || true)"
292
+ fi
293
+
294
+ if (( slug_explicit == 1 )); then
295
+ if [[ -n "$matched_slug" && "$matched_slug" != "$slug" ]]; then
296
+ clast_log_warn "registry: remote '$remote' is already registered under slug '$matched_slug'; registering '$canon' under requested slug '$slug' as a separate project (pass --slug '$matched_slug' to group them)"
297
+ fi
298
+ elif [[ -n "$matched_slug" ]]; then
299
+ slug="$matched_slug"
300
+ clast_log_info "registry: grouping '$canon' under existing slug '$slug' (shared remote; pass --slug to override)"
301
+ else
201
302
  slug="$(basename "$canon")"
202
303
  fi
203
304
 
204
- # Aliases roll-up only when remote matched an existing entry.
205
- local arr matched_slug
206
- arr="$(clast_registry_list_json)"
207
- local aliases_json='[]'
208
- if [[ -n "$remote" ]]; then
209
- matched_slug="$(jq -r --arg r "$remote" 'map(select(.remote == $r)) | .[0].slug // empty' <<<"$arr")"
210
- if [[ -n "$matched_slug" ]]; then
211
- slug="$matched_slug"
212
- # Collect every known path for this slug (excluding the new one),
213
- # plus their existing aliases.
214
- aliases_json="$(jq -c --arg s "$slug" --arg p "$canon" '
215
- [ .[] | select(.slug == $s) | [.path] + (.aliases // []) ]
216
- | add // []
217
- | map(select(. != $p))
218
- | unique
219
- ' <<<"$arr")"
305
+ # Per-directory label. An explicit --label is lowercased and validated;
306
+ # otherwise derive from the parent directory's basename (e.g.
307
+ # ~/Workspaces/performance/xesapps → "performance"). The label only
308
+ # distinguishes clones of one slug; a single-directory project never
309
+ # surfaces it, so a default like "code" is harmless.
310
+ local label=""
311
+ if (( label_explicit == 1 )); then
312
+ label="${label_raw,,}"
313
+ if [[ ! "$label" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
314
+ clast_log_error "clast_registry_add: invalid --label '$label_raw' (expected [a-z0-9][a-z0-9-]{0,31})"
315
+ return 2
220
316
  fi
317
+ else
318
+ label="$(_clast_registry_slugify "$(basename "$(dirname "$canon")")")"
221
319
  fi
222
320
 
321
+ # Sibling paths are no longer rolled into aliases: each directory is its
322
+ # own line keyed by .path, and a shared slug is the supported way to span
323
+ # directories. `aliases` stays present (reserved for genuine alternate
324
+ # paths of the same checkout) but empty.
325
+ local aliases_json='[]'
326
+
223
327
  local journal_dir
224
328
  journal_dir="$(clast_journal_dir)"
225
329
  if ! mkdir -p "$journal_dir"; then
@@ -234,10 +338,11 @@ clast_registry_add() {
234
338
  line="$(jq -c -n \
235
339
  --arg path "$canon" \
236
340
  --arg slug "$slug" \
341
+ --arg label "$label" \
237
342
  --arg remote "$remote" \
238
343
  --arg first_seen "$first_seen" \
239
344
  --argjson aliases "$aliases_json" \
240
- '{path: $path, slug: $slug, remote: $remote, first_seen: $first_seen}
345
+ '{path: $path, slug: $slug, label: $label, remote: $remote, first_seen: $first_seen}
241
346
  | with_entries(select(.value != null and .value != ""))
242
347
  | . + {aliases: $aliases}')" || {
243
348
  clast_log_error "clast_registry_add: jq failed to build registry line"