@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
@@ -61,25 +61,24 @@ clast_registry_resolve() {
61
61
  local arr
62
62
  arr="$(clast_registry_list_json)"
63
63
 
64
- # Segment input: starts with `-`. Try raw segment first (handles
65
- # segments registered as-is), then decode to filesystem paths.
64
+ # Segment input: starts with `-`. The candidate set is the raw segment
65
+ # (handles segments registered as-is) followed by every dash-decoded
66
+ # filesystem path. Test them all in ONE jq pass — first candidate in
67
+ # order to match a registry .path/.aliases wins — instead of forking one
68
+ # jq per candidate, which for deep paths meant ~hundreds/thousands of
69
+ # forks per resolve (the dominant cost of `clast wake` startup).
66
70
  if [[ "$input" == -* ]]; then
67
- local slug
68
- slug="$(_clast_registry_lookup_path "$input" "$arr" 2>/dev/null)" || true
71
+ local -a candidates=("$input")
72
+ local -a decoded=()
73
+ mapfile -t decoded < <(clast_decode_candidates "$input")
74
+ candidates+=("${decoded[@]}")
75
+ local cands_json slug
76
+ cands_json="$(printf '%s\n' "${candidates[@]}" | jq -Rn '[inputs]')"
77
+ slug="$(_clast_registry_lookup_paths "$cands_json" "$arr" 2>/dev/null)" || true
69
78
  if [[ -n "$slug" ]]; then
70
79
  printf '%s\n' "$slug"
71
80
  return 0
72
81
  fi
73
- local -a candidates=()
74
- mapfile -t candidates < <(clast_decode_candidates "$input")
75
- local c
76
- for c in "${candidates[@]}"; do
77
- slug="$(_clast_registry_lookup_path "$c" "$arr")" || continue
78
- if [[ -n "$slug" ]]; then
79
- printf '%s\n' "$slug"
80
- return 0
81
- fi
82
- done
83
82
  return 1
84
83
  fi
85
84
 
@@ -96,6 +95,58 @@ clast_registry_resolve() {
96
95
  printf '%s\n' "$slug"
97
96
  }
98
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
+
99
150
  # _clast_registry_lookup_path <path> <registry-json-array>
100
151
  # First match wins: scan .path, then .aliases[]. Print slug or empty.
101
152
  _clast_registry_lookup_path() {
@@ -107,14 +158,49 @@ _clast_registry_lookup_path() {
107
158
  ' <<<"$arr"
108
159
  }
109
160
 
110
- # clast_registry_add <path> [--slug NAME] [--remote URL]
111
- # Append a single JSONL line. Default slug = basename(path). Default
112
- # remote = `git -C <path> remote get-url origin` (or absent). If the
113
- # resolved remote matches an existing entry's remote, append a new line
114
- # carrying that entry's slug and rolling its known paths into aliases.
161
+ # _clast_registry_lookup_paths <candidates-json-array> <registry-json-array>
162
+ # Batched form of _clast_registry_lookup_path: given an ordered JSON array
163
+ # of candidate paths, return the slug for the FIRST candidate (in order)
164
+ # that matches a registry entry's .path or .aliases path before alias,
165
+ # matching the single-path helper. Print slug or empty. One jq pass for
166
+ # the whole candidate set.
167
+ _clast_registry_lookup_paths() {
168
+ local cands_json="$1" arr="$2"
169
+ jq -r --argjson cands "$cands_json" '
170
+ . as $arr
171
+ | first(
172
+ $cands[] as $c
173
+ | ( ($arr | map(select(.path == $c)) | .[0].slug)
174
+ // ($arr | map(select((.aliases? // []) | index($c) != null)) | .[0].slug) )
175
+ | select(. != null)
176
+ ) // empty
177
+ ' <<<"$arr"
178
+ }
179
+
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.
115
200
  # Print the appended JSON on stdout. Exit 2 on bad args, 1 on write fail.
116
201
  clast_registry_add() {
117
- 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
118
204
  while [[ $# -gt 0 ]]; do
119
205
  case "$1" in
120
206
  --slug)
@@ -122,8 +208,25 @@ clast_registry_add() {
122
208
  clast_log_error "clast_registry_add: --slug requires a value"
123
209
  return 2
124
210
  fi
125
- slug="$2"; shift 2 ;;
126
- --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 ;;
127
230
  --remote)
128
231
  if [[ $# -lt 2 ]]; then
129
232
  clast_log_error "clast_registry_add: --remote requires a value"
@@ -178,30 +281,49 @@ clast_registry_add() {
178
281
  fi
179
282
  fi
180
283
 
181
- # Default slug from basename.
182
- 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
183
302
  slug="$(basename "$canon")"
184
303
  fi
185
304
 
186
- # Aliases roll-up only when remote matched an existing entry.
187
- local arr matched_slug
188
- arr="$(clast_registry_list_json)"
189
- local aliases_json='[]'
190
- if [[ -n "$remote" ]]; then
191
- matched_slug="$(jq -r --arg r "$remote" 'map(select(.remote == $r)) | .[0].slug // empty' <<<"$arr")"
192
- if [[ -n "$matched_slug" ]]; then
193
- slug="$matched_slug"
194
- # Collect every known path for this slug (excluding the new one),
195
- # plus their existing aliases.
196
- aliases_json="$(jq -c --arg s "$slug" --arg p "$canon" '
197
- [ .[] | select(.slug == $s) | [.path] + (.aliases // []) ]
198
- | add // []
199
- | map(select(. != $p))
200
- | unique
201
- ' <<<"$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
202
316
  fi
317
+ else
318
+ label="$(_clast_registry_slugify "$(basename "$(dirname "$canon")")")"
203
319
  fi
204
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
+
205
327
  local journal_dir
206
328
  journal_dir="$(clast_journal_dir)"
207
329
  if ! mkdir -p "$journal_dir"; then
@@ -216,10 +338,11 @@ clast_registry_add() {
216
338
  line="$(jq -c -n \
217
339
  --arg path "$canon" \
218
340
  --arg slug "$slug" \
341
+ --arg label "$label" \
219
342
  --arg remote "$remote" \
220
343
  --arg first_seen "$first_seen" \
221
344
  --argjson aliases "$aliases_json" \
222
- '{path: $path, slug: $slug, remote: $remote, first_seen: $first_seen}
345
+ '{path: $path, slug: $slug, label: $label, remote: $remote, first_seen: $first_seen}
223
346
  | with_entries(select(.value != null and .value != ""))
224
347
  | . + {aliases: $aliases}')" || {
225
348
  clast_log_error "clast_registry_add: jq failed to build registry line"
@@ -0,0 +1,397 @@
1
+ # clast-retro-lib.bash — the retro index pass (Round 1, step-01).
2
+ #
3
+ # Reads every curated journal entry's YAML front-matter and emits a per-entry
4
+ # index of the four fields the day→project grouping depends on:
5
+ # session_id, project_path, snapshot_path, curated_source_mtime.
6
+ # Pure code, deterministic, read-only — no bucketing, dedup, render, or LLM
7
+ # (those are step-02 / step-03). The raw `snapshot_path` string is kept intact;
8
+ # parsing its day-bucket dir is step-02's job.
9
+ #
10
+ # Entries live at $(clast_journal_dir)/entries/*.md as Markdown with a leading
11
+ # `---`-fenced front-matter block. See docs/reference/cli.md#entry-frontmatter.
12
+ # shellcheck shell=bash
13
+ # shellcheck source=lib/clast/clast-lib.bash
14
+
15
+ # clast_retro_index [<entries_dir>]
16
+ # Print a JSON array to stdout — one element per *.md file in the entries
17
+ # dir, sorted by absolute path ascending. Each element:
18
+ # { path, session_id, project_path, snapshot_path, curated_source_mtime }
19
+ # An absent, empty, or literal-`null` field is emitted as JSON null. A
20
+ # missing or empty entries dir yields `[]`. Read-only; returns 0.
21
+ clast_retro_index() {
22
+ local entries_dir="${1:-$(clast_journal_dir)/entries}"
23
+
24
+ if [[ ! -d "$entries_dir" ]]; then
25
+ printf '[]\n'
26
+ return 0
27
+ fi
28
+
29
+ local -a rows=()
30
+ local file
31
+ while IFS= read -r file; do
32
+ [[ -z "$file" ]] && continue
33
+ rows+=("$(_clast_retro_index_record "$file")")
34
+ done < <(find "$entries_dir" -mindepth 1 -maxdepth 1 -type f -name '*.md' 2>/dev/null | sort)
35
+
36
+ if (( ${#rows[@]} == 0 )); then
37
+ printf '[]\n'
38
+ return 0
39
+ fi
40
+
41
+ printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.path)'
42
+ }
43
+
44
+ # _clast_retro_index_record <path>
45
+ # Parse one entry's front-matter and emit a single compact JSON object with
46
+ # the indexed fields. Empty / literal-`null` values become JSON null.
47
+ _clast_retro_index_record() {
48
+ local file="$1"
49
+ local fm_session_id="" fm_project_path="" fm_snapshot_path="" fm_mtime=""
50
+ local line key val
51
+ while IFS= read -r line; do
52
+ [[ -z "$line" ]] && continue
53
+ key="${line%%:*}"
54
+ val="${line#*:}"
55
+ # Trim surrounding whitespace from the value.
56
+ val="${val#"${val%%[![:space:]]*}"}"
57
+ val="${val%"${val##*[![:space:]]}"}"
58
+ case "$key" in
59
+ session_id) fm_session_id="$(clast_yaml_unquote "$val")" ;;
60
+ project_path) fm_project_path="$(clast_yaml_unquote "$val")" ;;
61
+ snapshot_path) fm_snapshot_path="$(clast_yaml_unquote "$val")" ;;
62
+ curated_source_mtime) fm_mtime="$(clast_yaml_unquote "$val")" ;;
63
+ esac
64
+ done < <(clast_read_frontmatter "$file")
65
+
66
+ # A literal YAML `null` reads back as the string "null"; collapse it (and any
67
+ # absent/empty field) to the empty marker so jq emits JSON null.
68
+ [[ "$fm_session_id" == "null" ]] && fm_session_id=""
69
+ [[ "$fm_project_path" == "null" ]] && fm_project_path=""
70
+ [[ "$fm_snapshot_path" == "null" ]] && fm_snapshot_path=""
71
+ [[ "$fm_mtime" == "null" ]] && fm_mtime=""
72
+
73
+ jq -cn \
74
+ --arg path "$file" \
75
+ --arg session_id "$fm_session_id" \
76
+ --arg project_path "$fm_project_path" \
77
+ --arg snapshot_path "$fm_snapshot_path" \
78
+ --arg curated_source_mtime "$fm_mtime" \
79
+ '{
80
+ path: $path,
81
+ session_id: (if $session_id == "" then null else $session_id end),
82
+ project_path: (if $project_path == "" then null else $project_path end),
83
+ snapshot_path: (if $snapshot_path == "" then null else $snapshot_path end),
84
+ curated_source_mtime: (if $curated_source_mtime == "" then null else $curated_source_mtime end)
85
+ }'
86
+ }
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # step-02: work-day bucketing + session dedup
90
+ # ---------------------------------------------------------------------------
91
+
92
+ # _clast_retro_work_day <snapshot_path> <curated_source_mtime>
93
+ # Resolve the day work actually happened. Primary: the <day> dir of
94
+ # snapshot_path (transcripts/<day>/<seg>/<sid>.jsonl). Fallback: the local
95
+ # cutoff-adjusted day of curated_source_mtime. Neither → "unknown".
96
+ _clast_retro_work_day() {
97
+ local snapshot_path="$1" mtime="$2"
98
+ local day=""
99
+ if [[ -n "$snapshot_path" ]]; then
100
+ day="$(awk -F/ '{print $2}' <<<"$snapshot_path")"
101
+ if [[ "$day" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
102
+ printf '%s' "$day"
103
+ return 0
104
+ fi
105
+ fi
106
+ if [[ -n "$mtime" ]]; then
107
+ local epoch
108
+ if epoch="$(date -d "$mtime" +%s 2>/dev/null)" && [[ -n "$epoch" ]]; then
109
+ clast_day_bucket_for_epoch "$epoch"
110
+ return 0
111
+ fi
112
+ fi
113
+ printf 'unknown'
114
+ }
115
+
116
+ # _clast_retro_file_date <path>
117
+ # The curation (filename) date: leading YYYY-MM-DD of the basename. Empty if
118
+ # the name does not start with an ISO date.
119
+ _clast_retro_file_date() {
120
+ local base
121
+ base="$(basename "$1")"
122
+ if [[ "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2}) ]]; then
123
+ printf '%s' "${BASH_REMATCH[1]}"
124
+ fi
125
+ }
126
+
127
+ # clast_retro_manifest [--from DATE] [--to DATE] [--window work-days|file-dates]
128
+ # Consume the step-01 index, assign each entry to its work day, dedup by
129
+ # session_id (later day wins; contributing entry paths merged into entries[]),
130
+ # group day → project, and honor the date window under the chosen scope.
131
+ # Prints the manifest JSON to stdout. Read-only; returns 0 (2 on bad args).
132
+ clast_retro_manifest() {
133
+ local from="" to="" window="work-days"
134
+ while [[ $# -gt 0 ]]; do
135
+ case "$1" in
136
+ --from)
137
+ if [[ $# -lt 2 ]]; then clast_log_error "retro: --from requires a value"; return 2; fi
138
+ if ! from="$(clast_parse_date "$2" 2>/dev/null)"; then
139
+ clast_log_error "retro: invalid date '$2'"; return 2
140
+ fi
141
+ shift 2 ;;
142
+ --from=*)
143
+ if ! from="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
144
+ clast_log_error "retro: invalid date '${1#*=}'"; return 2
145
+ fi
146
+ shift ;;
147
+ --to)
148
+ if [[ $# -lt 2 ]]; then clast_log_error "retro: --to requires a value"; return 2; fi
149
+ if ! to="$(clast_parse_date "$2" 2>/dev/null)"; then
150
+ clast_log_error "retro: invalid date '$2'"; return 2
151
+ fi
152
+ shift 2 ;;
153
+ --to=*)
154
+ if ! to="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
155
+ clast_log_error "retro: invalid date '${1#*=}'"; return 2
156
+ fi
157
+ shift ;;
158
+ --window)
159
+ if [[ $# -lt 2 ]]; then clast_log_error "retro: --window requires a value"; return 2; fi
160
+ window="$2"; shift 2 ;;
161
+ --window=*) window="${1#*=}"; shift ;;
162
+ *) clast_log_error "retro: unknown argument '$1'"; return 2 ;;
163
+ esac
164
+ done
165
+
166
+ case "$window" in
167
+ work-days|file-dates) ;;
168
+ *) clast_log_error "retro: --window must be 'work-days' or 'file-dates'"; return 2 ;;
169
+ esac
170
+
171
+ # Enrich each indexed entry with its work day + filename date (bash owns the
172
+ # cutoff/epoch math; jq owns the filter/group/sort below).
173
+ local -a enriched=()
174
+ local rec sp mt path wd fd
175
+ while IFS= read -r rec; do
176
+ [[ -z "$rec" ]] && continue
177
+ sp="$(jq -r '.snapshot_path // ""' <<<"$rec")"
178
+ mt="$(jq -r '.curated_source_mtime // ""' <<<"$rec")"
179
+ path="$(jq -r '.path' <<<"$rec")"
180
+ wd="$(_clast_retro_work_day "$sp" "$mt")"
181
+ fd="$(_clast_retro_file_date "$path")"
182
+ enriched+=("$(jq -c --arg wd "$wd" --arg fd "$fd" \
183
+ '. + {work_day: $wd, file_date: (if $fd == "" then null else $fd end)}' <<<"$rec")")
184
+ done < <(clast_retro_index | jq -c '.[]')
185
+
186
+ if (( ${#enriched[@]} == 0 )); then
187
+ jq -cn --arg from "$from" --arg to "$to" --arg window "$window" \
188
+ '{from: (if $from == "" then null else $from end),
189
+ to: (if $to == "" then null else $to end),
190
+ window: $window, days: []}'
191
+ return 0
192
+ fi
193
+
194
+ local manifest
195
+ manifest="$(printf '%s\n' "${enriched[@]}" | jq -s \
196
+ --arg from "$from" --arg to "$to" --arg window "$window" '
197
+ def within($day): ($from == "" or $day >= $from) and ($to == "" or $day <= $to);
198
+
199
+ # file-dates scope filters entries by filename date *before* dedup.
200
+ (if $window == "file-dates"
201
+ then [ .[] | select(.file_date != null and within(.file_date)) ]
202
+ else . end)
203
+
204
+ # Dedup by session_id: later real day wins; merge contributing paths.
205
+ # Fall back to .path for id-less (legacy/hand-curated) entries so two of
206
+ # them do not collapse under a shared null key into one session; .path is
207
+ # unique, so each stays its own singleton session.
208
+ | [ group_by(.session_id // .path)[]
209
+ | (map(.work_day) | map(select(. != "unknown"))
210
+ | (if length > 0 then max else "unknown" end)) as $wd
211
+ | ((map(select(.work_day == $wd))[0]) // .[0]) as $rep
212
+ | { session_id: .[0].session_id,
213
+ work_day: $wd,
214
+ entries: (map(.path) | sort),
215
+ project_path: ($rep.project_path
216
+ // (map(.project_path) | map(select(. != null))[0])),
217
+ curated_source_mtime: ($rep.curated_source_mtime
218
+ // (map(.curated_source_mtime) | map(select(. != null))[0])) } ]
219
+
220
+ # work-days scope filters resolved sessions by their work day.
221
+ | (if $window == "work-days"
222
+ then [ .[] | select(if .work_day == "unknown"
223
+ then ($from == "" and $to == "")
224
+ else within(.work_day) end) ]
225
+ else . end)
226
+
227
+ # Group day → project, with deterministic ordering.
228
+ | { from: (if $from == "" then null else $from end),
229
+ to: (if $to == "" then null else $to end),
230
+ window: $window,
231
+ days: (
232
+ group_by(.work_day) # ascending; "unknown" sorts last
233
+ | map({
234
+ day: .[0].work_day,
235
+ curation_dates: ([.[].entries[] | sub(".*/"; "") | .[0:10]]
236
+ | map(select(test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")))
237
+ | unique),
238
+ projects: (
239
+ group_by(.project_path)
240
+ | map({
241
+ project_path: .[0].project_path,
242
+ sessions: (sort_by(.session_id // .entries[0])
243
+ | map({session_id, work_day, entries, curated_source_mtime}))
244
+ })
245
+ | sort_by(.project_path == null) # null project group sorts last
246
+ )
247
+ })
248
+ ) }')"
249
+
250
+ _clast_retro_inject_project_names "$manifest"
251
+ }
252
+
253
+ # _clast_retro_inject_project_names <manifest-json>
254
+ # Add a friendly `project_name` to every project group (display polish; the
255
+ # raw project_path is kept). Names are computed in bash (clast_retro_friendly_name
256
+ # needs $HOME + string ops) and merged back via jq, keyed by project_path.
257
+ _clast_retro_inject_project_names() {
258
+ local manifest="$1"
259
+ local -a pairs=()
260
+ local pp name
261
+ while IFS= read -r pp; do
262
+ if [[ "$pp" == "null" ]]; then
263
+ name="$(clast_retro_friendly_name "")"
264
+ pairs+=("$(jq -cn --arg n "$name" '{path: null, name: $n}')")
265
+ else
266
+ name="$(clast_retro_friendly_name "$pp")"
267
+ pairs+=("$(jq -cn --arg p "$pp" --arg n "$name" '{path: $p, name: $n}')")
268
+ fi
269
+ done < <(jq -r '[.days[].projects[].project_path] | unique | .[]
270
+ | if . == null then "null" else . end' <<<"$manifest")
271
+
272
+ if (( ${#pairs[@]} == 0 )); then
273
+ printf '%s\n' "$manifest"
274
+ return 0
275
+ fi
276
+
277
+ # Manifest on stdin (it can be large with many sessions), name pairs via
278
+ # --slurpfile — keep both off argv (MAX_ARG_STRLEN).
279
+ printf '%s' "$manifest" | jq -c --slurpfile pairs <(printf '%s\n' "${pairs[@]}") '
280
+ (reduce $pairs[] as $p ({}; .[($p.path | tostring)] = $p.name)) as $names
281
+ | .days |= map(.projects |= map(
282
+ . + {project_name: ($names[(.project_path | tostring)] // "(no project)")} ))
283
+ '
284
+ }
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Session body assembly (shared by the Round 1 render and the `--bodies` JSON)
288
+ # ---------------------------------------------------------------------------
289
+
290
+ # _clast_retro_trim_body
291
+ # Drop leading blank lines and leading `# Session:` heading(s) from an entry
292
+ # body on stdin; pass the rest through unchanged.
293
+ _clast_retro_trim_body() {
294
+ awk '
295
+ started { print; next }
296
+ /^[[:space:]]*$/ { next }
297
+ /^# Session:/ { next }
298
+ { started = 1; print }
299
+ '
300
+ }
301
+
302
+ # clast_retro_is_interrupted (stdin = session body; exit 0 if interrupted)
303
+ # An interrupted session has a goal and/or open threads but nothing shipped —
304
+ # work was started and left hanging. Flag it rather than overstate or drop it.
305
+ clast_retro_is_interrupted() {
306
+ local body
307
+ body="$(cat)"
308
+ if grep -qiE '^#+ +what shipped' <<<"$body"; then
309
+ return 1
310
+ fi
311
+ grep -qiE '^#+ +(goal|open threads)' <<<"$body"
312
+ }
313
+
314
+ # clast_retro_session_body <session-json>
315
+ # Concatenate the trimmed bodies of a session's entries[] in order. For a
316
+ # merged (multi-entry) session each body is preceded by a "--- <file> ---"
317
+ # marker so the split is visible. Unreadable entries emit a notice line.
318
+ clast_retro_session_body() {
319
+ local sess="$1"
320
+ local ne ei entry
321
+ ne="$(jq '.entries | length' <<<"$sess")"
322
+ for (( ei = 0; ei < ne; ei++ )); do
323
+ entry="$(jq -r ".entries[$ei]" <<<"$sess")"
324
+ if (( ne > 1 )); then
325
+ printf ' --- %s ---\n' "$(basename "$entry")"
326
+ fi
327
+ if [[ -r "$entry" ]]; then
328
+ clast_entry_body "$entry" | _clast_retro_trim_body
329
+ else
330
+ printf ' (entry not readable: %s)\n' "$entry"
331
+ fi
332
+ done
333
+ }
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Friendly project names (step-05)
337
+ # ---------------------------------------------------------------------------
338
+
339
+ # clast_retro_friendly_name <project_path|encoded-segment|empty>
340
+ # A short, readable name for a project group. Path-derived (the registry slug
341
+ # is already dash-joined, so label/slug doesn't yield the wanted form):
342
+ # - empty / "null" -> "(no project)"
343
+ # - a leading-"-" encoded segment -> decoded to a path first
344
+ # - == $HOME -> "~"
345
+ # - under $HOME, >=3 components -> last two (…/Workspaces/dev/xesapps -> dev/xesapps)
346
+ # - under $HOME, otherwise -> "~/<rest>" (~/Code/clast, ~/fix)
347
+ # - elsewhere, >=3 components -> last two
348
+ # - elsewhere, otherwise -> the path verbatim (/tmp/projA)
349
+ clast_retro_friendly_name() {
350
+ local p="$1"
351
+ if [[ -z "$p" || "$p" == "null" ]]; then
352
+ printf '(no project)'
353
+ return 0
354
+ fi
355
+
356
+ # Decode an encoded snapshot segment (…/ -> -, literal - -> --).
357
+ if [[ "$p" == -* ]]; then
358
+ p="${p//--/$'\x01'}"
359
+ p="${p//-//}"
360
+ p="${p//$'\x01'/-}"
361
+ fi
362
+
363
+ p="${p%/}" # drop a trailing slash
364
+
365
+ local home="${HOME%/}"
366
+ if [[ -n "$home" && "$p" == "$home" ]]; then
367
+ printf '~'
368
+ return 0
369
+ fi
370
+
371
+ local rest="" tilde=0
372
+ if [[ -n "$home" && "$p" == "$home/"* ]]; then
373
+ rest="${p#"$home"/}"
374
+ tilde=1
375
+ else
376
+ rest="${p#/}" # strip a single leading slash for component counting
377
+ fi
378
+
379
+ # Count components.
380
+ local -a parts=()
381
+ local IFS='/'
382
+ read -r -a parts <<<"$rest"
383
+ unset IFS
384
+
385
+ if (( ${#parts[@]} >= 3 )); then
386
+ # Last two components, regardless of home/elsewhere.
387
+ printf '%s/%s' "${parts[${#parts[@]}-2]}" "${parts[${#parts[@]}-1]}"
388
+ return 0
389
+ fi
390
+
391
+ if (( tilde )); then
392
+ # shellcheck disable=SC2088 # literal "~" for display, not path expansion
393
+ printf '~/%s' "$rest"
394
+ else
395
+ printf '%s' "$1" # short non-home path: verbatim original (keep leading /)
396
+ fi
397
+ }