@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 brief — LLM-powered project briefing.
2
2
  #
3
- # Replicates the /wakeup plugin skill using an OpenAI-compatible chat
3
+ # Replicates the /brief plugin skill using an OpenAI-compatible chat
4
4
  # completions endpoint. Reads curated entries, breadcrumbs, and today's
5
5
  # sessions for a project (via clast-plumbing), then synthesizes a briefing.
6
6
  #
@@ -30,38 +30,94 @@ _clast_brief_resolve_project() {
30
30
 
31
31
  # --- Gather data -------------------------------------------------------------
32
32
 
33
+ # Group key for an entry: per-directory label, else branch, else "default".
34
+ # A slug may span several directories; grouping keeps their work distinct
35
+ # instead of letting the single newest entry stand in for the whole project.
33
36
  _clast_brief_gather_entries() {
34
- local project="$1"
37
+ local project="$1" current_label="${2:-}"
38
+
39
+ # Pull the full entries list (no global cap). A global `--limit` here would
40
+ # let one busy clone with N newer entries push the current clone out of the
41
+ # window entirely before we ever see it — even though the per-group/total
42
+ # caps below already keep the prompt within the brief's token budget.
35
43
  local entries_json
36
- entries_json="$(clast-plumbing --json entries --project "$project" --limit 5 2>/dev/null)" || true
44
+ entries_json="$(clast-plumbing --json entries --project "$project" 2>/dev/null)" || true
37
45
 
38
46
  if [[ -z "$entries_json" ]] || [[ "$(jq 'length' <<<"$entries_json" 2>/dev/null)" == "0" ]]; then
39
47
  return
40
48
  fi
41
49
 
42
- local n i entry_meta entry_path entry_body result=""
43
- n="$(jq 'length' <<<"$entries_json")"
50
+ # Ordered, de-duplicated group keys in newest-first order of first
51
+ # appearance (entries list is already sorted newest-first). If a
52
+ # current_label was provided AND it appears in the entries, hoist it to
53
+ # the front so the active thread gets its share before total_cap is hit.
54
+ local -a groups=()
55
+ mapfile -t groups < <(jq -r --arg cur "$current_label" '
56
+ [ .[] | (.label // .branch // "default") ] as $keys
57
+ | (reduce $keys[] as $k ([]; if index($k) then . else . + [$k] end)) as $uniq
58
+ | (if $cur != "" and ($uniq | index($cur)) != null
59
+ then [$cur] + ($uniq - [$cur])
60
+ else $uniq end)
61
+ | .[]
62
+ ' <<<"$entries_json")
63
+
64
+ # A single-workspace project renders exactly as before (no group headers),
65
+ # so this change is a strict superset for the common case.
66
+ local single_group=0
67
+ (( ${#groups[@]} <= 1 )) && single_group=1
68
+
69
+ # Per-group and total caps keep the prompt within the brief's token budget.
70
+ local per_group=3 total_cap=8 emitted=0
71
+ local result="" g
72
+
73
+ for g in "${groups[@]}"; do
74
+ (( emitted >= total_cap )) && break
75
+
76
+ local -a paths=()
77
+ mapfile -t paths < <(jq -r --arg g "$g" --argjson per "$per_group" '
78
+ [ .[] | select((.label // .branch // "default") == $g) ][0:$per] | .[].path
79
+ ' <<<"$entries_json")
80
+ (( ${#paths[@]} == 0 )) && continue
81
+
82
+ # Build this group's body first, so we can skip the header if nothing reads.
83
+ local group_block="" p entry_body
84
+ for p in "${paths[@]}"; do
85
+ (( emitted >= total_cap )) && break
86
+ entry_body="$(clast-plumbing entries read "$p" 2>/dev/null)" || true
87
+ if [[ -z "$entry_body" ]]; then continue; fi
88
+ if [[ -n "$group_block" ]]; then
89
+ group_block="${group_block}
44
90
 
45
- for (( i = 0; i < n; i++ )); do
46
- entry_meta="$(jq -c ".[$i]" <<<"$entries_json")"
47
- entry_path="$(jq -r '.path' <<<"$entry_meta")"
91
+ ---
48
92
 
49
- entry_body="$(clast-plumbing entries read "$entry_path" 2>/dev/null)" || true
50
- if [[ -z "$entry_body" ]]; then
51
- local title date
52
- title="$(jq -r '.title // "untitled"' <<<"$entry_meta")"
53
- date="$(jq -r '.date' <<<"$entry_meta")"
54
- entry_body="# $date $title (body not available)"
93
+ "
94
+ fi
95
+ group_block="${group_block}${entry_body}"
96
+ emitted=$(( emitted + 1 ))
97
+ done
98
+ [[ -z "$group_block" ]] && continue
99
+
100
+ local block="$group_block"
101
+ if (( single_group == 0 )); then
102
+ local branch_disp
103
+ branch_disp="$(jq -r --arg g "$g" '
104
+ [ .[] | select((.label // .branch // "default") == $g) ][0].branch // ""
105
+ ' <<<"$entries_json")"
106
+ local header="## Workspace: $g"
107
+ if [[ -n "$branch_disp" && "$branch_disp" != "null" ]]; then
108
+ header="$header (branch: $branch_disp)"
109
+ fi
110
+ block="${header}
111
+
112
+ ${group_block}"
55
113
  fi
56
114
 
57
115
  if [[ -n "$result" ]]; then
58
116
  result="${result}
59
117
 
60
- ---
61
-
62
118
  "
63
119
  fi
64
- result="${result}${entry_body}"
120
+ result="${result}${block}"
65
121
  done
66
122
 
67
123
  printf '%s' "$result"
@@ -73,6 +129,9 @@ _clast_brief_gather_breadcrumbs() {
73
129
  }
74
130
 
75
131
  _clast_brief_gather_sessions() {
132
+ # NOTE: today's sessions come from the manifest, not curated entries, so
133
+ # they are not (yet) grouped by workspace label the way entries are. Per-
134
+ # directory session grouping is a possible follow-up.
76
135
  local project="$1"
77
136
  local sessions_json
78
137
  sessions_json="$(clast-plumbing --json sessions --day today --project "$project" 2>/dev/null)" || true
@@ -100,7 +159,7 @@ _clast_brief_gather_sessions() {
100
159
  # --- User prompt -------------------------------------------------------------
101
160
 
102
161
  _clast_brief_build_user_prompt() {
103
- local project="$1" entries="$2" breadcrumbs="$3" sessions="$4"
162
+ local project="$1" entries="$2" breadcrumbs="$3" sessions="$4" current_label="$5"
104
163
 
105
164
  local template_file template
106
165
  template_file="$(clast_porcelain_user_prompt_file brief-user)"
@@ -108,6 +167,7 @@ _clast_brief_build_user_prompt() {
108
167
  if [[ -n "$template_file" ]]; then
109
168
  template="$(cat "$template_file")"
110
169
  template="${template//\{\{project\}\}/${project}}"
170
+ template="${template//\{\{current_label\}\}/${current_label:-unknown}}"
111
171
  template="${template//\{\{entries\}\}/${entries:-None.}}"
112
172
  template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
113
173
  template="${template//\{\{sessions\}\}/${sessions:-None.}}"
@@ -116,8 +176,9 @@ _clast_brief_build_user_prompt() {
116
176
  clast_porcelain_warn "user prompt template not found: brief-user.md — using inline fallback"
117
177
  cat <<EOF
118
178
  Project: ${project}
179
+ Current workspace (the directory you are in now): ${current_label:-unknown}
119
180
 
120
- Recent curated entries (newest first):
181
+ Recent curated entries, grouped by workspace (newest first):
121
182
  ${entries:-None.}
122
183
 
123
184
  Today's breadcrumbs for this project:
@@ -138,10 +199,18 @@ clast_cmd_brief() {
138
199
  project="$(_clast_brief_resolve_project "${1:-}")"
139
200
  clast_porcelain_info "Briefing for project: $project"
140
201
 
202
+ # When the project was resolved from the current directory (no positional
203
+ # slug), find that directory's workspace label so the briefing can prefer
204
+ # the active thread for the clone the user is actually in.
205
+ local current_label=""
206
+ if [[ -z "${1:-}" ]]; then
207
+ current_label="$(clast-plumbing registry resolve "$(pwd)" --json 2>/dev/null | jq -r '.label // empty' 2>/dev/null)" || true
208
+ fi
209
+
141
210
  clast_porcelain_info "Gathering context..."
142
211
 
143
212
  local entries breadcrumbs sessions
144
- entries="$(_clast_brief_gather_entries "$project")"
213
+ entries="$(_clast_brief_gather_entries "$project" "$current_label")"
145
214
  breadcrumbs="$(_clast_brief_gather_breadcrumbs "$project")"
146
215
  sessions="$(_clast_brief_gather_sessions "$project")"
147
216
 
@@ -153,7 +222,7 @@ clast_cmd_brief() {
153
222
 
154
223
  local system_prompt user_prompt
155
224
  system_prompt="$(clast_porcelain_load_system_prompt brief-system)"
156
- user_prompt="$(_clast_brief_build_user_prompt "$project" "$entries" "$breadcrumbs" "$sessions")"
225
+ user_prompt="$(_clast_brief_build_user_prompt "$project" "$entries" "$breadcrumbs" "$sessions" "$current_label")"
157
226
 
158
227
  clast_porcelain_info "Synthesizing briefing..."
159
228
  printf '\n'
@@ -0,0 +1,317 @@
1
+ # clast retro — LLM-condensed work retrospective grouped by work day → project.
2
+ #
3
+ # Round 2 of the retro feature. Structure comes from the deterministic core
4
+ # (`clast-plumbing retro --json --bodies`); the model only condenses each
5
+ # session's body into a few retro bullets. Summaries are cached per session_id
6
+ # (content-fingerprinted) under the journal dir, so re-runs are free unless a
7
+ # session changed or --refresh is passed. See .wip/initiatives/clast-retro/.
8
+ # shellcheck shell=bash
9
+
10
+ _clast_retrosum_usage() {
11
+ cat <<'EOF'
12
+ Usage: clast retro [--from DATE] [--to DATE] [--all]
13
+ [--window work-days|file-dates] [--refresh] [--json]
14
+
15
+ Condense the work retrospective (grouped by actual work day → project) into
16
+ model-written bullets per session. Structure is deterministic; only the prose
17
+ condensation calls the LLM. Summaries are cached per session under
18
+ <journal>/.retro-summaries/ and reused until the session content changes.
19
+
20
+ With no window flags, defaults to the last 7 days (one model call per new
21
+ session, so an unbounded run over the whole corpus can be slow and costly).
22
+
23
+ Flags:
24
+ --from DATE Start of the window (inclusive). Default: 7 days ago.
25
+ --to DATE End of the window (inclusive). Default: corpus end.
26
+ --all Cover the whole corpus (overrides the 7-day default).
27
+ --window WHICH work-days (default) | file-dates. See `clast-plumbing retro`.
28
+ --refresh Ignore cached summaries and re-summarize (rewrites the cache).
29
+ --json Emit the manifest with a `summary` per session (no render).
30
+ -h, --help Print this usage and exit.
31
+
32
+ DATE accepts ISO (YYYY-MM-DD), `today`, `yesterday`, `last-week`, `-Nd`, `-Nw`.
33
+
34
+ Requires the CLAST_LLM_* env vars (see `clast --help`).
35
+ EOF
36
+ }
37
+
38
+ # _clast_retro_progress <msg>
39
+ # Emit a progress line to stderr so stdout (render or --json) stays clean.
40
+ # Silent when CLAST_QUIET is set or stderr is not a TTY; CLAST_RETRO_PROGRESS=always
41
+ # forces it on (used by tests, which have no tty).
42
+ _clast_retro_progress() {
43
+ [[ -n "${CLAST_QUIET:-}" ]] && return 0
44
+ if [[ "${CLAST_RETRO_PROGRESS:-}" == "always" || -t 2 ]]; then
45
+ printf 'clast: %s\n' "$*" >&2
46
+ fi
47
+ }
48
+
49
+ # _clast_retro_now — epoch seconds with fraction (GNU date). Falls back to whole
50
+ # seconds where `%N` is unsupported (e.g. BSD/macOS date echoes a literal N).
51
+ _clast_retro_now() {
52
+ local t
53
+ t="$(date +%s.%N 2>/dev/null)" || t="$(date +%s)"
54
+ [[ "$t" == *N* ]] && t="$(date +%s)"
55
+ printf '%s' "$t"
56
+ }
57
+
58
+ # _clast_retro_elapsed <start> <end> — seconds between two _clast_retro_now
59
+ # readings, one decimal place (never negative).
60
+ _clast_retro_elapsed() {
61
+ awk -v a="$1" -v b="$2" 'BEGIN { d = b - a; if (d < 0) d = 0; printf "%.1f", d }'
62
+ }
63
+
64
+ # _clast_retrosum_fingerprint (stdin → short hex/cksum on stdout)
65
+ # Content fingerprint of a session body; changes invalidate the cache.
66
+ _clast_retrosum_fingerprint() {
67
+ if command -v sha256sum >/dev/null 2>&1; then
68
+ sha256sum | cut -c1-16
69
+ else
70
+ cksum | awk '{print $1}'
71
+ fi
72
+ }
73
+
74
+ # _clast_retrosum_build_user <project> <work_day> <session_id> <body>
75
+ _clast_retrosum_build_user() {
76
+ local project="$1" work_day="$2" sid="$3" body="$4"
77
+ local tf tpl
78
+ tf="$(clast_porcelain_user_prompt_file retro-summary-user)"
79
+ if [[ -n "$tf" ]]; then
80
+ tpl="$(cat "$tf")"
81
+ tpl="${tpl//\{\{project\}\}/$project}"
82
+ tpl="${tpl//\{\{work_day\}\}/$work_day}"
83
+ tpl="${tpl//\{\{session_id\}\}/$sid}"
84
+ tpl="${tpl//\{\{body\}\}/$body}"
85
+ printf '%s' "$tpl"
86
+ else
87
+ printf 'Project: %s\nWork day: %s\nSession id: %s\n\nEntry body:\n%s\n' \
88
+ "$project" "$work_day" "$sid" "$body"
89
+ fi
90
+ }
91
+
92
+ # _clast_retrosum_summary <project> <work_day> <session_id> <body> <cache_dir> <refresh>
93
+ # Echo the condensed summary. Return code tells the caller what happened so it
94
+ # can time only the calls that hit the model:
95
+ # 0 — served from cache (no model call)
96
+ # 3 — fresh summary via a model call (success)
97
+ # 1 — model call failed
98
+ _clast_retrosum_summary() {
99
+ local project="$1" work_day="$2" sid="$3" body="$4" cache_dir="$5" refresh="$6"
100
+ local fp cache_file cached_fp
101
+ fp="$(printf '%s' "$body" | _clast_retrosum_fingerprint)"
102
+ cache_file="$cache_dir/$sid.json"
103
+
104
+ if (( ! refresh )) && [[ -r "$cache_file" ]]; then
105
+ cached_fp="$(jq -r '.fingerprint // empty' "$cache_file" 2>/dev/null)"
106
+ if [[ -n "$cached_fp" && "$cached_fp" == "$fp" ]]; then
107
+ jq -r '.summary // empty' "$cache_file"
108
+ return 0
109
+ fi
110
+ fi
111
+
112
+ _clast_retro_progress " querying model..."
113
+ local system user summary
114
+ system="$(clast_porcelain_load_system_prompt retro-summary-system)"
115
+ user="$(_clast_retrosum_build_user "$project" "$work_day" "$sid" "$body")"
116
+ if ! summary="$(clast_porcelain_llm_chat "$system" "$user")"; then
117
+ return 1
118
+ fi
119
+
120
+ if mkdir -p "$cache_dir" 2>/dev/null; then
121
+ jq -n --arg fp "$fp" --arg s "$summary" '{fingerprint:$fp, summary:$s}' \
122
+ >"$cache_file" 2>/dev/null || clast_porcelain_warn "failed to cache summary for $sid"
123
+ fi
124
+ printf '%s' "$summary"
125
+ return 3
126
+ }
127
+
128
+ # _clast_retrosum_journal_dir — resolve the journal dir (for the cache).
129
+ _clast_retrosum_journal_dir() {
130
+ if [[ -n "${CLAST_JOURNAL_DIR:-}" ]]; then
131
+ printf '%s' "$CLAST_JOURNAL_DIR"
132
+ return
133
+ fi
134
+ local jd
135
+ jd="$(clast-plumbing whereami 2>/dev/null | awk '/^journal_dir:/{print $2}')" || true
136
+ printf '%s' "${jd:-$HOME/.claude/journal}"
137
+ }
138
+
139
+ clast_cmd_retro() {
140
+ local from="" to="" window="work-days" refresh=0 as_json=0 all=0
141
+
142
+ while [[ $# -gt 0 ]]; do
143
+ case "$1" in
144
+ --from) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --from requires a value"; return 2; }; from="$2"; shift 2 ;;
145
+ --from=*) from="${1#*=}"; shift ;;
146
+ --to) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --to requires a value"; return 2; }; to="$2"; shift 2 ;;
147
+ --to=*) to="${1#*=}"; shift ;;
148
+ --all) all=1; shift ;;
149
+ --window) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --window requires a value"; return 2; }; window="$2"; shift 2 ;;
150
+ --window=*) window="${1#*=}"; shift ;;
151
+ --refresh) refresh=1; shift ;;
152
+ --json) as_json=1; shift ;;
153
+ -h|--help) _clast_retrosum_usage; return 0 ;;
154
+ --) shift; break ;;
155
+ *) clast_porcelain_log_error "retro: unknown argument '$1'"; return 2 ;;
156
+ esac
157
+ done
158
+
159
+ # An unbounded retro means one model call per session across the whole corpus
160
+ # (slow and costly). Default to the last 7 days unless the caller bounded the
161
+ # window themselves (--from/--to) or opted into everything with --all.
162
+ if (( ! all )) && [[ -z "$from" && -z "$to" ]]; then
163
+ from="-7d"
164
+ fi
165
+
166
+ clast_porcelain_preflight_llm
167
+
168
+ # Structure + per-session bodies from the deterministic core.
169
+ _clast_retro_progress "building work-day manifest..."
170
+ local -a pl=(--json retro --bodies --window "$window")
171
+ [[ -n "$from" ]] && pl+=(--from "$from")
172
+ [[ -n "$to" ]] && pl+=(--to "$to")
173
+ local manifest
174
+ if ! manifest="$(clast-plumbing "${pl[@]}" 2>/dev/null)"; then
175
+ local msg
176
+ msg="$(jq -r '.error // empty' <<<"$manifest" 2>/dev/null || true)"
177
+ clast_porcelain_die "retro: ${msg:-failed to build manifest}" 2
178
+ fi
179
+
180
+ # Report the resolved window + discovered work days before the slow summarize
181
+ # loop — the "dates it resolved to" the user wants to see up front.
182
+ local pf pt pw ndays nsess daylist
183
+ pf="$(jq -r '.from // "(start)"' <<<"$manifest")"
184
+ pt="$(jq -r '.to // "(end)"' <<<"$manifest")"
185
+ pw="$(jq -r '.window' <<<"$manifest")"
186
+ ndays="$(jq '.days | length' <<<"$manifest")"
187
+ nsess="$(jq '[.days[].projects[].sessions[]] | length' <<<"$manifest")"
188
+ daylist="$(jq -r '[.days[].day] | join(", ")' <<<"$manifest")"
189
+ _clast_retro_progress "window: $pf -> $pt ($pw)"
190
+ _clast_retro_progress "resolved $nsess session(s) across $ndays day(s): $daylist"
191
+
192
+ local cache_dir
193
+ cache_dir="$(_clast_retrosum_journal_dir)/.retro-summaries"
194
+
195
+ # Summarize each session; collect key → summary.
196
+ local -a summary_pairs=()
197
+ local sess sid key cache_id project work_day body title summary pname
198
+ local t0 t1 dt rc
199
+ local idx=0 total="$nsess" queried=0 model_total="0.0"
200
+ while IFS= read -r sess; do
201
+ [[ -z "$sess" ]] && continue
202
+ sid="$(jq -r '.session_id // ""' <<<"$sess")"
203
+ # Fold key: session_id, or the unique first entry path for id-less sessions
204
+ # (must match the plumbing manifest's key so summaries fold back correctly
205
+ # and two id-less sessions don't collide on a shared "null" key).
206
+ key="$(jq -r '.session_id // .entries[0]' <<<"$sess")"
207
+ # Cache filename must be unique and filesystem-safe even without an id.
208
+ if [[ -n "$sid" ]]; then
209
+ cache_id="$sid"
210
+ else
211
+ cache_id="noid-$(printf '%s' "$key" | _clast_retrosum_fingerprint)"
212
+ fi
213
+ work_day="$(jq -r '.work_day' <<<"$sess")"
214
+ body="$(jq -r '.body // ""' <<<"$sess")"
215
+ project="$(jq -r '.project_path // "(no project)"' <<<"$sess")"
216
+ idx=$(( idx + 1 ))
217
+ pname="$(jq -r '.progress_project // .project_path // "(no project)"' <<<"$sess")"
218
+ title="$(jq -r '.title // ""' <<<"$sess")"
219
+ _clast_retro_progress "[$idx/$total] $work_day $pname ${title:-${sid:0:8}}"
220
+ if [[ -z "$body" ]]; then
221
+ summary="(no body to summarize)"
222
+ else
223
+ # Time the call so we can report how long the model took. rc distinguishes
224
+ # a cache hit (0, instant) from a fresh model call (3) from a failure.
225
+ rc=0
226
+ t0="$(_clast_retro_now)"
227
+ summary="$(_clast_retrosum_summary "$project" "$work_day" "$cache_id" "$body" "$cache_dir" "$refresh")" || rc=$?
228
+ t1="$(_clast_retro_now)"
229
+ case "$rc" in
230
+ 0) : ;; # served from cache — no model call, nothing to time
231
+ 3) dt="$(_clast_retro_elapsed "$t0" "$t1")"
232
+ queried=$(( queried + 1 ))
233
+ model_total="$(awk -v a="$model_total" -v d="$dt" 'BEGIN { printf "%.1f", a + d }')"
234
+ _clast_retro_progress " done in ${dt}s (model total ${model_total}s)" ;;
235
+ *) clast_porcelain_warn "summary failed for session ${sid:-<no id>} — leaving it unsummarized"
236
+ summary="(summary unavailable)" ;;
237
+ esac
238
+ fi
239
+ summary_pairs+=("$(jq -cn --arg k "$key" --arg v "$summary" '{key:$k, summary:$v}')")
240
+ done < <(jq -c '.days[].projects[] | .project_name as $pn | .sessions[] | . + {progress_project: $pn}' <<<"$manifest")
241
+
242
+ if (( total > 0 )); then
243
+ _clast_retro_progress "summarized $total session(s); $queried queried the model (${model_total}s)"
244
+ fi
245
+
246
+ # Fold summaries back into the manifest and drop the raw bodies. The manifest
247
+ # carries --bodies only as summarizer input; neither the JSON nor the render
248
+ # path consumes .body, so strip it here to keep --json condensed and
249
+ # consistent with the plumbing's lean-by-default output (raw bodies remain
250
+ # available via `clast-plumbing --json retro --bodies`). The manifest can
251
+ # exceed MAX_ARG_STRLEN — feed it via stdin and the summary pairs via
252
+ # --slurpfile, never argv.
253
+ local enriched="$manifest"
254
+ if (( ${#summary_pairs[@]} > 0 )); then
255
+ # Key by `session_id // .entries[0]` (always a non-null string), matching
256
+ # the pairs above: this can't abort jq on a null id and keeps two id-less
257
+ # sessions distinct instead of collapsing them onto a shared key.
258
+ enriched="$(printf '%s' "$manifest" | jq -c --slurpfile pairs <(printf '%s\n' "${summary_pairs[@]}") '
259
+ (reduce $pairs[] as $p ({}; .[$p.key] = $p.summary)) as $sum
260
+ | .days |= map(.projects |= map(.sessions |= map(
261
+ del(.body) + {summary: ($sum[(.session_id // .entries[0])] // null)})))
262
+ ')"
263
+ fi
264
+
265
+ if (( as_json )); then
266
+ printf '%s\n' "$enriched"
267
+ return 0
268
+ fi
269
+
270
+ _clast_retrosum_render "$enriched"
271
+ }
272
+
273
+ # _clast_retrosum_render <manifest-with-summaries>
274
+ _clast_retrosum_render() {
275
+ local manifest="$1"
276
+ local from to window
277
+ from="$(jq -r '.from // "(start)"' <<<"$manifest")"
278
+ to="$(jq -r '.to // "(end)"' <<<"$manifest")"
279
+ window="$(jq -r '.window' <<<"$manifest")"
280
+ printf 'Retro: %s -> %s (%s)\n' "$from" "$to" "$window"
281
+
282
+ local nd
283
+ nd="$(jq '.days | length' <<<"$manifest")"
284
+ if (( nd == 0 )); then
285
+ printf '\n(no sessions in range)\n'
286
+ return 0
287
+ fi
288
+
289
+ local di pj si day np project ns sess sid shortsid title summary note flag
290
+ for (( di = 0; di < nd; di++ )); do
291
+ day="$(jq -r ".days[$di].day" <<<"$manifest")"
292
+ printf '\n== %s ==\n' "$day"
293
+ note="$(jq -r --arg d "$day" '.days['"$di"'].curation_dates // [] |
294
+ if . == [] or . == [$d] then empty
295
+ else " (filed " + (join(", ")) + "; work day reconstructed from session snapshots)"
296
+ end' <<<"$manifest")"
297
+ [[ -n "$note" ]] && printf '%s\n' "$note"
298
+ np="$(jq ".days[$di].projects | length" <<<"$manifest")"
299
+ for (( pj = 0; pj < np; pj++ )); do
300
+ project="$(jq -r ".days[$di].projects[$pj].project_name // .days[$di].projects[$pj].project_path // \"(no project)\"" <<<"$manifest")"
301
+ printf '\n[%s]\n' "$project"
302
+ ns="$(jq ".days[$di].projects[$pj].sessions | length" <<<"$manifest")"
303
+ for (( si = 0; si < ns; si++ )); do
304
+ sess="$(jq -c ".days[$di].projects[$pj].sessions[$si]" <<<"$manifest")"
305
+ sid="$(jq -r '.session_id' <<<"$sess")"
306
+ shortsid="${sid:0:8}"
307
+ title="$(jq -r '.title // "(untitled)"' <<<"$sess")"
308
+ [[ -z "$title" || "$title" == "null" ]] && title="(untitled)"
309
+ summary="$(jq -r '.summary // "(no summary)"' <<<"$sess")"
310
+ flag=""
311
+ [[ "$(jq -r '.interrupted // false' <<<"$sess")" == "true" ]] && flag=" [interrupted]"
312
+ printf '\n * %s (%s)%s\n' "$title" "$shortsid" "$flag"
313
+ printf '%s\n' "$summary"
314
+ done
315
+ done
316
+ done
317
+ }
@@ -0,0 +1,27 @@
1
+ # clast undismiss — restore session(s) dismissed during `clast wake`.
2
+ #
3
+ # A convenience verb over `clast-plumbing sessions undismiss`: `clast wake`
4
+ # now prints each session's id, so recovering an accidental [d] is a direct
5
+ # `clast undismiss <id>` without dropping down to the plumbing surface.
6
+ # shellcheck shell=bash
7
+
8
+ clast_cmd_undismiss() {
9
+ case "${1:-}" in
10
+ -h|--help)
11
+ cat <<'EOF'
12
+ Usage: clast undismiss <session-id> [<session-id>...]
13
+
14
+ Restore session(s) previously dismissed (e.g. an accidental [d] in
15
+ `clast wake`). Session ids are shown in the `clast wake` review header.
16
+ EOF
17
+ return 0
18
+ ;;
19
+ esac
20
+
21
+ if ! command -v clast-plumbing >/dev/null 2>&1; then
22
+ clast_porcelain_die "required tool not found: clast-plumbing"
23
+ fi
24
+
25
+ # Thin passthrough: plumbing owns UUID validation and JSON/human output.
26
+ clast-plumbing sessions undismiss "$@"
27
+ }