@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,236 @@
1
+ # clast brief — LLM-powered project briefing.
2
+ #
3
+ # Replicates the /brief plugin skill using an OpenAI-compatible chat
4
+ # completions endpoint. Reads curated entries, breadcrumbs, and today's
5
+ # sessions for a project (via clast-plumbing), then synthesizes a briefing.
6
+ #
7
+ # Usage: clast brief [<project-slug>]
8
+ # If no slug is given, resolves from the current working directory.
9
+ # shellcheck shell=bash
10
+
11
+ # --- Resolve project ---------------------------------------------------------
12
+
13
+ _clast_brief_resolve_project() {
14
+ local slug="${1:-}"
15
+
16
+ if [[ -n "$slug" ]]; then
17
+ printf '%s' "$slug"
18
+ return
19
+ fi
20
+
21
+ local resolved
22
+ resolved="$(clast-plumbing registry resolve "$(pwd)" 2>/dev/null)" || true
23
+ if [[ -n "$resolved" ]]; then
24
+ printf '%s' "$resolved"
25
+ return
26
+ fi
27
+
28
+ clast_porcelain_die "Not in a registered project. Run \`clast-plumbing registry add .\` first, or invoke as \`clast brief <slug>\`."
29
+ }
30
+
31
+ # --- Gather data -------------------------------------------------------------
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.
36
+ _clast_brief_gather_entries() {
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.
43
+ local entries_json
44
+ entries_json="$(clast-plumbing --json entries --project "$project" 2>/dev/null)" || true
45
+
46
+ if [[ -z "$entries_json" ]] || [[ "$(jq 'length' <<<"$entries_json" 2>/dev/null)" == "0" ]]; then
47
+ return
48
+ fi
49
+
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}
90
+
91
+ ---
92
+
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}"
113
+ fi
114
+
115
+ if [[ -n "$result" ]]; then
116
+ result="${result}
117
+
118
+ "
119
+ fi
120
+ result="${result}${block}"
121
+ done
122
+
123
+ printf '%s' "$result"
124
+ }
125
+
126
+ _clast_brief_gather_breadcrumbs() {
127
+ local project="$1"
128
+ clast-plumbing breadcrumb --read --project "$project" --day today 2>/dev/null || true
129
+ }
130
+
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.
135
+ local project="$1"
136
+ local sessions_json
137
+ sessions_json="$(clast-plumbing --json sessions --day today --project "$project" 2>/dev/null)" || true
138
+
139
+ if [[ -z "$sessions_json" ]] || [[ "$(jq 'length' <<<"$sessions_json" 2>/dev/null)" == "0" ]]; then
140
+ return
141
+ fi
142
+
143
+ local n
144
+ n="$(jq 'length' <<<"$sessions_json")"
145
+
146
+ if (( n > 5 )); then
147
+ local latest_start
148
+ latest_start="$(jq -r 'sort_by(.start) | last | .start // ""' <<<"$sessions_json")"
149
+ printf 'Worked %d sessions today, most recent at %s.' "$n" "${latest_start:11:5}"
150
+ return
151
+ fi
152
+
153
+ jq -r '
154
+ sort_by(.start) | .[] |
155
+ "\(.start[11:16]) start: \(if .branch and .branch != "null" then .branch else "no branch" end), \(.msg_count_approx) messages"
156
+ ' <<<"$sessions_json" 2>/dev/null || true
157
+ }
158
+
159
+ # --- User prompt -------------------------------------------------------------
160
+
161
+ _clast_brief_build_user_prompt() {
162
+ local project="$1" entries="$2" breadcrumbs="$3" sessions="$4" current_label="$5"
163
+
164
+ local template_file template
165
+ template_file="$(clast_porcelain_user_prompt_file brief-user)"
166
+
167
+ if [[ -n "$template_file" ]]; then
168
+ template="$(cat "$template_file")"
169
+ template="${template//\{\{project\}\}/${project}}"
170
+ template="${template//\{\{current_label\}\}/${current_label:-unknown}}"
171
+ template="${template//\{\{entries\}\}/${entries:-None.}}"
172
+ template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
173
+ template="${template//\{\{sessions\}\}/${sessions:-None.}}"
174
+ printf '%s' "$template"
175
+ else
176
+ clast_porcelain_warn "user prompt template not found: brief-user.md — using inline fallback"
177
+ cat <<EOF
178
+ Project: ${project}
179
+ Current workspace (the directory you are in now): ${current_label:-unknown}
180
+
181
+ Recent curated entries, grouped by workspace (newest first):
182
+ ${entries:-None.}
183
+
184
+ Today's breadcrumbs for this project:
185
+ ${breadcrumbs:-None.}
186
+
187
+ Today's session activity for this project:
188
+ ${sessions:-None.}
189
+ EOF
190
+ fi
191
+ }
192
+
193
+ # --- Main --------------------------------------------------------------------
194
+
195
+ clast_cmd_brief() {
196
+ clast_porcelain_preflight_llm
197
+
198
+ local project
199
+ project="$(_clast_brief_resolve_project "${1:-}")"
200
+ clast_porcelain_info "Briefing for project: $project"
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
+
210
+ clast_porcelain_info "Gathering context..."
211
+
212
+ local entries breadcrumbs sessions
213
+ entries="$(_clast_brief_gather_entries "$project" "$current_label")"
214
+ breadcrumbs="$(_clast_brief_gather_breadcrumbs "$project")"
215
+ sessions="$(_clast_brief_gather_sessions "$project")"
216
+
217
+ if [[ -z "$entries" && -z "$breadcrumbs" && -z "$sessions" ]]; then
218
+ clast_porcelain_info "No curated entries, breadcrumbs, or sessions for \`$project\`."
219
+ clast_porcelain_info "Run \`clast wake\` to curate recent sessions first, or run \`clast-plumbing sessions --project $project\` to see what's available."
220
+ return 0
221
+ fi
222
+
223
+ local system_prompt user_prompt
224
+ system_prompt="$(clast_porcelain_load_system_prompt brief-system)"
225
+ user_prompt="$(_clast_brief_build_user_prompt "$project" "$entries" "$breadcrumbs" "$sessions" "$current_label")"
226
+
227
+ clast_porcelain_info "Synthesizing briefing..."
228
+ printf '\n'
229
+
230
+ local briefing
231
+ if ! briefing="$(clast_porcelain_llm_chat "$system_prompt" "$user_prompt")"; then
232
+ clast_porcelain_die "LLM call failed"
233
+ fi
234
+
235
+ printf '%s\n' "$briefing"
236
+ }
@@ -0,0 +1,236 @@
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] [--window work-days|file-dates]
13
+ [--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
+ 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) | file-dates. See `clast-plumbing retro`.
24
+ --refresh Ignore cached summaries and re-summarize (rewrites the cache).
25
+ --json Emit the manifest with a `summary` per session (no render).
26
+ -h, --help Print this usage and exit.
27
+
28
+ Requires the CLAST_LLM_* env vars (see `clast --help`).
29
+ EOF
30
+ }
31
+
32
+ # _clast_retrosum_fingerprint (stdin → short hex/cksum on stdout)
33
+ # Content fingerprint of a session body; changes invalidate the cache.
34
+ _clast_retrosum_fingerprint() {
35
+ if command -v sha256sum >/dev/null 2>&1; then
36
+ sha256sum | cut -c1-16
37
+ else
38
+ cksum | awk '{print $1}'
39
+ fi
40
+ }
41
+
42
+ # _clast_retrosum_build_user <project> <work_day> <session_id> <body>
43
+ _clast_retrosum_build_user() {
44
+ local project="$1" work_day="$2" sid="$3" body="$4"
45
+ local tf tpl
46
+ tf="$(clast_porcelain_user_prompt_file retro-summary-user)"
47
+ if [[ -n "$tf" ]]; then
48
+ tpl="$(cat "$tf")"
49
+ tpl="${tpl//\{\{project\}\}/$project}"
50
+ tpl="${tpl//\{\{work_day\}\}/$work_day}"
51
+ tpl="${tpl//\{\{session_id\}\}/$sid}"
52
+ tpl="${tpl//\{\{body\}\}/$body}"
53
+ printf '%s' "$tpl"
54
+ else
55
+ printf 'Project: %s\nWork day: %s\nSession id: %s\n\nEntry body:\n%s\n' \
56
+ "$project" "$work_day" "$sid" "$body"
57
+ fi
58
+ }
59
+
60
+ # _clast_retrosum_summary <project> <work_day> <session_id> <body> <cache_dir> <refresh>
61
+ # Echo the condensed summary. Cache hit (matching fingerprint) reuses; miss
62
+ # calls the LLM and writes the cache. Returns nonzero on LLM failure.
63
+ _clast_retrosum_summary() {
64
+ local project="$1" work_day="$2" sid="$3" body="$4" cache_dir="$5" refresh="$6"
65
+ local fp cache_file cached_fp
66
+ fp="$(printf '%s' "$body" | _clast_retrosum_fingerprint)"
67
+ cache_file="$cache_dir/$sid.json"
68
+
69
+ if (( ! refresh )) && [[ -r "$cache_file" ]]; then
70
+ cached_fp="$(jq -r '.fingerprint // empty' "$cache_file" 2>/dev/null)"
71
+ if [[ -n "$cached_fp" && "$cached_fp" == "$fp" ]]; then
72
+ jq -r '.summary // empty' "$cache_file"
73
+ return 0
74
+ fi
75
+ fi
76
+
77
+ local system user summary
78
+ system="$(clast_porcelain_load_system_prompt retro-summary-system)"
79
+ user="$(_clast_retrosum_build_user "$project" "$work_day" "$sid" "$body")"
80
+ if ! summary="$(clast_porcelain_llm_chat "$system" "$user")"; then
81
+ return 1
82
+ fi
83
+
84
+ if mkdir -p "$cache_dir" 2>/dev/null; then
85
+ jq -n --arg fp "$fp" --arg s "$summary" '{fingerprint:$fp, summary:$s}' \
86
+ >"$cache_file" 2>/dev/null || clast_porcelain_warn "failed to cache summary for $sid"
87
+ fi
88
+ printf '%s' "$summary"
89
+ }
90
+
91
+ # _clast_retrosum_journal_dir — resolve the journal dir (for the cache).
92
+ _clast_retrosum_journal_dir() {
93
+ if [[ -n "${CLAST_JOURNAL_DIR:-}" ]]; then
94
+ printf '%s' "$CLAST_JOURNAL_DIR"
95
+ return
96
+ fi
97
+ local jd
98
+ jd="$(clast-plumbing whereami 2>/dev/null | awk '/^journal_dir:/{print $2}')" || true
99
+ printf '%s' "${jd:-$HOME/.claude/journal}"
100
+ }
101
+
102
+ clast_cmd_retro() {
103
+ local from="" to="" window="work-days" refresh=0 as_json=0
104
+
105
+ while [[ $# -gt 0 ]]; do
106
+ case "$1" in
107
+ --from) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --from requires a value"; return 2; }; from="$2"; shift 2 ;;
108
+ --from=*) from="${1#*=}"; shift ;;
109
+ --to) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --to requires a value"; return 2; }; to="$2"; shift 2 ;;
110
+ --to=*) to="${1#*=}"; shift ;;
111
+ --window) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --window requires a value"; return 2; }; window="$2"; shift 2 ;;
112
+ --window=*) window="${1#*=}"; shift ;;
113
+ --refresh) refresh=1; shift ;;
114
+ --json) as_json=1; shift ;;
115
+ -h|--help) _clast_retrosum_usage; return 0 ;;
116
+ --) shift; break ;;
117
+ *) clast_porcelain_log_error "retro: unknown argument '$1'"; return 2 ;;
118
+ esac
119
+ done
120
+
121
+ clast_porcelain_preflight_llm
122
+
123
+ # Structure + per-session bodies from the deterministic core.
124
+ local -a pl=(--json retro --bodies --window "$window")
125
+ [[ -n "$from" ]] && pl+=(--from "$from")
126
+ [[ -n "$to" ]] && pl+=(--to "$to")
127
+ local manifest
128
+ if ! manifest="$(clast-plumbing "${pl[@]}" 2>/dev/null)"; then
129
+ local msg
130
+ msg="$(jq -r '.error // empty' <<<"$manifest" 2>/dev/null || true)"
131
+ clast_porcelain_die "retro: ${msg:-failed to build manifest}" 2
132
+ fi
133
+
134
+ local cache_dir
135
+ cache_dir="$(_clast_retrosum_journal_dir)/.retro-summaries"
136
+
137
+ # Summarize each session; collect key → summary.
138
+ local -a summary_pairs=()
139
+ local sess sid key cache_id project work_day body title summary
140
+ while IFS= read -r sess; do
141
+ [[ -z "$sess" ]] && continue
142
+ sid="$(jq -r '.session_id // ""' <<<"$sess")"
143
+ # Fold key: session_id, or the unique first entry path for id-less sessions
144
+ # (must match the plumbing manifest's key so summaries fold back correctly
145
+ # and two id-less sessions don't collide on a shared "null" key).
146
+ key="$(jq -r '.session_id // .entries[0]' <<<"$sess")"
147
+ # Cache filename must be unique and filesystem-safe even without an id.
148
+ if [[ -n "$sid" ]]; then
149
+ cache_id="$sid"
150
+ else
151
+ cache_id="noid-$(printf '%s' "$key" | _clast_retrosum_fingerprint)"
152
+ fi
153
+ work_day="$(jq -r '.work_day' <<<"$sess")"
154
+ body="$(jq -r '.body // ""' <<<"$sess")"
155
+ project="$(jq -r '.project_path // "(no project)"' <<<"$sess")"
156
+ if [[ -z "$body" ]]; then
157
+ summary="(no body to summarize)"
158
+ elif ! summary="$(_clast_retrosum_summary "$project" "$work_day" "$cache_id" "$body" "$cache_dir" "$refresh")"; then
159
+ clast_porcelain_warn "summary failed for session ${sid:-<no id>} — leaving it unsummarized"
160
+ summary="(summary unavailable)"
161
+ fi
162
+ summary_pairs+=("$(jq -cn --arg k "$key" --arg v "$summary" '{key:$k, summary:$v}')")
163
+ done < <(jq -c '.days[].projects[].sessions[]' <<<"$manifest")
164
+
165
+ # Fold summaries back into the manifest and drop the raw bodies. The manifest
166
+ # carries --bodies only as summarizer input; neither the JSON nor the render
167
+ # path consumes .body, so strip it here to keep --json condensed and
168
+ # consistent with the plumbing's lean-by-default output (raw bodies remain
169
+ # available via `clast-plumbing --json retro --bodies`). The manifest can
170
+ # exceed MAX_ARG_STRLEN — feed it via stdin and the summary pairs via
171
+ # --slurpfile, never argv.
172
+ local enriched="$manifest"
173
+ if (( ${#summary_pairs[@]} > 0 )); then
174
+ # Key by `session_id // .entries[0]` (always a non-null string), matching
175
+ # the pairs above: this can't abort jq on a null id and keeps two id-less
176
+ # sessions distinct instead of collapsing them onto a shared key.
177
+ enriched="$(printf '%s' "$manifest" | jq -c --slurpfile pairs <(printf '%s\n' "${summary_pairs[@]}") '
178
+ (reduce $pairs[] as $p ({}; .[$p.key] = $p.summary)) as $sum
179
+ | .days |= map(.projects |= map(.sessions |= map(
180
+ del(.body) + {summary: ($sum[(.session_id // .entries[0])] // null)})))
181
+ ')"
182
+ fi
183
+
184
+ if (( as_json )); then
185
+ printf '%s\n' "$enriched"
186
+ return 0
187
+ fi
188
+
189
+ _clast_retrosum_render "$enriched"
190
+ }
191
+
192
+ # _clast_retrosum_render <manifest-with-summaries>
193
+ _clast_retrosum_render() {
194
+ local manifest="$1"
195
+ local from to window
196
+ from="$(jq -r '.from // "(start)"' <<<"$manifest")"
197
+ to="$(jq -r '.to // "(end)"' <<<"$manifest")"
198
+ window="$(jq -r '.window' <<<"$manifest")"
199
+ printf 'Retro: %s -> %s (%s)\n' "$from" "$to" "$window"
200
+
201
+ local nd
202
+ nd="$(jq '.days | length' <<<"$manifest")"
203
+ if (( nd == 0 )); then
204
+ printf '\n(no sessions in range)\n'
205
+ return 0
206
+ fi
207
+
208
+ local di pj si day np project ns sess sid shortsid title summary note flag
209
+ for (( di = 0; di < nd; di++ )); do
210
+ day="$(jq -r ".days[$di].day" <<<"$manifest")"
211
+ printf '\n== %s ==\n' "$day"
212
+ note="$(jq -r --arg d "$day" '.days['"$di"'].curation_dates // [] |
213
+ if . == [] or . == [$d] then empty
214
+ else " (filed " + (join(", ")) + "; work day reconstructed from session snapshots)"
215
+ end' <<<"$manifest")"
216
+ [[ -n "$note" ]] && printf '%s\n' "$note"
217
+ np="$(jq ".days[$di].projects | length" <<<"$manifest")"
218
+ for (( pj = 0; pj < np; pj++ )); do
219
+ project="$(jq -r ".days[$di].projects[$pj].project_name // .days[$di].projects[$pj].project_path // \"(no project)\"" <<<"$manifest")"
220
+ printf '\n[%s]\n' "$project"
221
+ ns="$(jq ".days[$di].projects[$pj].sessions | length" <<<"$manifest")"
222
+ for (( si = 0; si < ns; si++ )); do
223
+ sess="$(jq -c ".days[$di].projects[$pj].sessions[$si]" <<<"$manifest")"
224
+ sid="$(jq -r '.session_id' <<<"$sess")"
225
+ shortsid="${sid:0:8}"
226
+ title="$(jq -r '.title // "(untitled)"' <<<"$sess")"
227
+ [[ -z "$title" || "$title" == "null" ]] && title="(untitled)"
228
+ summary="$(jq -r '.summary // "(no summary)"' <<<"$sess")"
229
+ flag=""
230
+ [[ "$(jq -r '.interrupted // false' <<<"$sess")" == "true" ]] && flag=" [interrupted]"
231
+ printf '\n * %s (%s)%s\n' "$title" "$shortsid" "$flag"
232
+ printf '%s\n' "$summary"
233
+ done
234
+ done
235
+ done
236
+ }
@@ -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
+ }