@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
@@ -147,39 +147,53 @@ _clast_doctor_check_registry_validity() {
147
147
  return 0
148
148
  fi
149
149
 
150
- # Duplicate slug + alias collisions, computed across the parseable subset.
150
+ # Path conflicts + alias collisions, computed across the parseable subset.
151
151
  local entries_json='[]'
152
152
  if (( ${#entries[@]} > 0 )); then
153
153
  entries_json="$(printf '%s\n' "${entries[@]}" | jq -cs .)"
154
154
  fi
155
155
 
156
- local dup_lines
157
- dup_lines="$(jq -r '
158
- [.[] | .slug] | group_by(.)
159
- | map(select(length > 1) | .[0]) | .[]
156
+ # A shared slug across distinct .path lines is the supported way to span
157
+ # multiple directories (worktrees / clones) of one logical project — see
158
+ # data-model.md. So duplicate slugs are NOT a problem. Genuine problems
159
+ # are keyed on path: the same path registered more than once, or one path
160
+ # claimed by more than one slug.
161
+ local path_issues
162
+ path_issues="$(jq -r '
163
+ group_by(.path)
164
+ | map(select(length > 1))
165
+ | .[]
166
+ | ([ .[].slug ] | unique) as $slugs
167
+ | if ($slugs | length) > 1
168
+ then "path conflict: " + .[0].path + " maps to slugs " + ($slugs | join(", "))
169
+ else "duplicate path: " + .[0].path
170
+ end
160
171
  ' <<<"$entries_json")"
161
- local dup_slug
162
- while IFS= read -r dup_slug; do
163
- [[ -z "$dup_slug" ]] && continue
164
- issues+=("duplicate slug: $dup_slug")
165
- done <<<"$dup_lines"
172
+ while IFS= read -r line; do
173
+ [[ -z "$line" ]] && continue
174
+ issues+=("$line")
175
+ done <<<"$path_issues"
166
176
 
177
+ # Alias collisions only matter across *different* slugs: two lines of one
178
+ # slug sharing an alias (or a legacy roll-up) is benign.
167
179
  local collisions
168
180
  collisions="$(jq -r '
169
181
  . as $arr
170
182
  | [
171
- # alias collides with another entry'"'"'s slug
183
+ # alias collides with a *different* entry'"'"'s slug
172
184
  ( range(0; length) as $i
173
185
  | range(0; length) as $j
174
186
  | select($i != $j)
187
+ | select($arr[$i].slug != $arr[$j].slug)
175
188
  | ($arr[$i].aliases // []) as $aliases
176
189
  | select($aliases | index($arr[$j].slug) != null)
177
190
  | "alias collision: " + $arr[$i].slug + " aliases slug " + $arr[$j].slug
178
191
  ),
179
- # alias collides with another entry'"'"'s alias (shared alias across two slugs)
192
+ # two *different* slugs share an alias path
180
193
  ( range(0; length) as $i
181
194
  | range(0; length) as $j
182
195
  | select($i < $j)
196
+ | select($arr[$i].slug != $arr[$j].slug)
183
197
  | ($arr[$i].aliases // []) as $ai
184
198
  | ($arr[$j].aliases // []) as $aj
185
199
  | ($ai | map(select(. as $x | $aj | index($x) != null))) as $shared
@@ -201,8 +215,10 @@ _clast_doctor_check_registry_validity() {
201
215
  "${issues[@]}"
202
216
  return 0
203
217
  fi
218
+ local proj_count
219
+ proj_count="$(jq -r '[.[].slug] | unique | length' <<<"$entries_json")"
204
220
  _clast_doctor_emit "registry_validity" "ok" \
205
- "$valid_count projects, no duplicates"
221
+ "$valid_count line(s), $proj_count project(s), no conflicts"
206
222
  }
207
223
 
208
224
  # Compute the deduped (most-recent per session_id) manifest rows.
@@ -69,49 +69,23 @@ clast_cmd_entries() {
69
69
 
70
70
  # _clast_entries_read_frontmatter <path>
71
71
  # Emit raw frontmatter lines (between the first two `---` fences) to stdout.
72
+ # Thin alias over the shared primitive in clast-lib.bash.
72
73
  _clast_entries_read_frontmatter() {
73
- local path="$1"
74
- awk '
75
- BEGIN { in_fm = 0; seen = 0 }
76
- /^---[[:space:]]*$/ {
77
- if (!seen) { in_fm = 1; seen = 1; next }
78
- if (in_fm) { exit }
79
- }
80
- in_fm { print }
81
- ' "$path"
74
+ clast_read_frontmatter "$1"
82
75
  }
83
76
 
84
77
  # _clast_entries_extract_title <path>
85
78
  # Look for `# Session: <title>` as the first non-blank body line.
79
+ # Thin alias over the shared primitive in clast-lib.bash.
86
80
  _clast_entries_extract_title() {
87
- awk '
88
- BEGIN { in_fm = 0; seen = 0; past = 0 }
89
- /^---[[:space:]]*$/ {
90
- if (!seen) { in_fm = 1; seen = 1; next }
91
- if (in_fm) { in_fm = 0; past = 1; next }
92
- }
93
- in_fm { next }
94
- past {
95
- if ($0 ~ /^[[:space:]]*$/) next
96
- if (sub(/^# Session: /, "")) { print; exit }
97
- exit
98
- }
99
- ' "$1"
81
+ clast_entry_title "$1"
100
82
  }
101
83
 
102
84
  # _clast_entries_unquote <string>
103
85
  # Strip surrounding double quotes and unescape \", \\, \n.
86
+ # Thin alias over the shared primitive in clast-lib.bash.
104
87
  _clast_entries_unquote() {
105
- local v="$1"
106
- if [[ "${v:0:1}" == '"' && "${v: -1}" == '"' && ${#v} -ge 2 ]]; then
107
- v="${v:1:${#v}-2}"
108
- # Process escapes in order: \\ → placeholder, \" → ", \n → LF, placeholder → \
109
- v="${v//\\\\/$'\x01'}"
110
- v="${v//\\\"/\"}"
111
- v="${v//\\n/$'\n'}"
112
- v="${v//$'\x01'/\\}"
113
- fi
114
- printf '%s' "$v"
88
+ clast_yaml_unquote "$1"
115
89
  }
116
90
 
117
91
  # ---------------------------------------------------------------------------
@@ -290,7 +264,7 @@ _clast_entries_list_consider() {
290
264
  fi
291
265
 
292
266
  # Parse frontmatter.
293
- local fm_date="" fm_time="" fm_day_bucket="" fm_project=""
267
+ local fm_date="" fm_time="" fm_day_bucket="" fm_project="" fm_label=""
294
268
  local fm_session_id="" fm_session_slug="" fm_branch=""
295
269
  local fm_tags_raw=""
296
270
  local line key val
@@ -307,6 +281,11 @@ _clast_entries_list_consider() {
307
281
  time) fm_time="$(_clast_entries_unquote "$val")" ;;
308
282
  day_bucket) fm_day_bucket="$(_clast_entries_unquote "$val")" ;;
309
283
  project) fm_project="$(_clast_entries_unquote "$val")" ;;
284
+ label)
285
+ if [[ "$val" != "null" ]]; then
286
+ fm_label="$(_clast_entries_unquote "$val")"
287
+ fi
288
+ ;;
310
289
  session_id) fm_session_id="$(_clast_entries_unquote "$val")" ;;
311
290
  session_slug) fm_session_slug="$(_clast_entries_unquote "$val")" ;;
312
291
  branch)
@@ -372,6 +351,7 @@ _clast_entries_list_consider() {
372
351
  --arg time "$fm_time" \
373
352
  --arg day_bucket "$bucket" \
374
353
  --arg project "$fm_project" \
354
+ --arg label "$fm_label" \
375
355
  --arg session_id "$fm_session_id" \
376
356
  --arg session_slug "$fm_session_slug" \
377
357
  --arg branch "$fm_branch" \
@@ -383,6 +363,7 @@ _clast_entries_list_consider() {
383
363
  time: $time,
384
364
  day_bucket: $day_bucket,
385
365
  project: $project,
366
+ label: (if $label == "" then null else $label end),
386
367
  session_id: $session_id,
387
368
  session_slug: $session_slug,
388
369
  branch: (if $branch == "" then null else $branch end),
@@ -512,9 +493,16 @@ _clast_entries_write() {
512
493
  trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
513
494
  [[ -z "$trimmed" ]] && continue
514
495
  trimmed="${trimmed,,}"
515
- if ! [[ "$trimmed" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
516
- _clast_entries_err "write: invalid tag '$trimmed'"; return 2
517
- fi
496
+ # Normalize to the tag charset rather than rejecting. LLM-suggested tags
497
+ # routinely carry dots or other separators (e.g. "php-8.5"), and failing
498
+ # the whole write would discard a curated entry over a cosmetic tag. Map
499
+ # runs of disallowed characters to a single hyphen, trim edge hyphens,
500
+ # and cap at 32 chars — same shape as _clast_wake_slugify. A tag that
501
+ # normalizes to nothing (all punctuation) is dropped.
502
+ trimmed="$(printf '%s' "$trimmed" | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//')"
503
+ trimmed="${trimmed:0:32}"
504
+ trimmed="${trimmed%-}"
505
+ [[ -z "$trimmed" ]] && continue
518
506
  tags+=("$trimmed")
519
507
  done
520
508
  fi
@@ -534,20 +522,17 @@ _clast_entries_write() {
534
522
  local seg
535
523
  seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot_rel")"
536
524
 
537
- # Resolve project from registry.
538
- local project_slug="" project_path="" project_remote=""
539
- local resolved_slug=""
540
- if resolved_slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$resolved_slug" ]]; then
541
- local reg_json reg_match
542
- reg_json="$(clast_registry_list_json)"
543
- reg_match="$(jq -c --arg s "$resolved_slug" 'map(select(.slug == $s)) | .[0] // empty' <<<"$reg_json")"
544
- if [[ -n "$reg_match" ]]; then
545
- project_slug="$resolved_slug"
546
- project_path="$(jq -r '.path // empty' <<<"$reg_match")"
547
- project_remote="$(jq -r '.remote // empty' <<<"$reg_match")"
548
- else
549
- project_slug="$resolved_slug"
550
- fi
525
+ # Resolve the *specific* registry line for this session's directory (by
526
+ # path), not the first line that shares the slug. A slug may span several
527
+ # directories (clones/worktrees), each with its own path and label;
528
+ # slug-first-match would stamp every entry with the first line's path.
529
+ local project_slug="" project_path="" project_remote="" project_label=""
530
+ local reg_line=""
531
+ if reg_line="$(clast_registry_line_for_path "$seg" 2>/dev/null)" && [[ -n "$reg_line" ]]; then
532
+ project_slug="$(jq -r '.slug // empty' <<<"$reg_line")"
533
+ project_path="$(jq -r '.path // empty' <<<"$reg_line")"
534
+ project_remote="$(jq -r '.remote // empty' <<<"$reg_line")"
535
+ project_label="$(jq -r '.label // empty' <<<"$reg_line")"
551
536
  else
552
537
  project_slug="$seg"
553
538
  local -a decoded=()
@@ -619,6 +604,11 @@ _clast_entries_write() {
619
604
  else
620
605
  fm+="project_path: null"$'\n'
621
606
  fi
607
+ if [[ -n "$project_label" ]]; then
608
+ fm+="label: $(_clast_entries_yaml_string "$project_label")"$'\n'
609
+ else
610
+ fm+="label: null"$'\n'
611
+ fi
622
612
  if [[ -n "$project_remote" ]]; then
623
613
  fm+="project_remote: $(_clast_entries_yaml_string "$project_remote")"$'\n'
624
614
  else
@@ -145,8 +145,14 @@ clast_cmd_projects() {
145
145
 
146
146
  seg_session_count["$seg"]=$(( ${seg_session_count["$seg"]:-0} + 1 ))
147
147
 
148
+ # Prefer cached msg_count on the manifest line (step 21); fall back to
149
+ # counting transcript lines for legacy lines that predate the cache.
148
150
  abs_path="$journal_dir/$snapshot"
149
- if [[ -r "$abs_path" ]]; then
151
+ local cached_msgs
152
+ cached_msgs="$(jq -r '.msg_count // empty' <<<"$line")"
153
+ if [[ -n "$cached_msgs" ]]; then
154
+ msgs="$cached_msgs"
155
+ elif [[ -r "$abs_path" ]]; then
150
156
  msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
151
157
  [[ -z "$msgs" ]] && msgs=0
152
158
  else
@@ -163,13 +169,18 @@ clast_cmd_projects() {
163
169
  done
164
170
 
165
171
  # Build per-segment rows as JSON, applying registry + --unregistered.
172
+ # Resolve each segment to its OWN registry line (not slug-first-match) so
173
+ # that a project spanning multiple checkouts under one shared slug reports
174
+ # each segment's own path/remote, instead of collapsing every row onto the
175
+ # first checkout's path.
166
176
  local -a rows=()
167
- local slug path remote registered row decode_rc decoded
177
+ local slug path remote registered row decode_rc decoded line
168
178
  for seg in "${!seg_session_count[@]}"; do
169
- if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
179
+ if line="$(clast_registry_line_for_path "$seg" 2>/dev/null)" && [[ -n "$line" ]]; then
170
180
  registered=true
171
- path="$(_clast_projects_path_for_slug "$slug")"
172
- remote="$(_clast_projects_remote_for_slug "$slug")"
181
+ slug="$(jq -r '.slug // empty' <<<"$line")"
182
+ path="$(jq -r '.path // empty' <<<"$line")"
183
+ remote="$(jq -r '.remote // empty' <<<"$line")"
173
184
  else
174
185
  registered=false
175
186
  slug=""
@@ -292,18 +303,3 @@ _clast_projects_window_filter() {
292
303
  printf '%s' "$joined"
293
304
  }
294
305
 
295
- # _clast_projects_path_for_slug <slug>
296
- # First registry path for <slug>, or empty.
297
- _clast_projects_path_for_slug() {
298
- local slug="$1"
299
- clast_registry_list_json \
300
- | jq -r --arg s "$slug" 'map(select(.slug == $s)) | .[0].path // empty'
301
- }
302
-
303
- # _clast_projects_remote_for_slug <slug>
304
- # First non-empty registry remote for <slug>, or empty.
305
- _clast_projects_remote_for_slug() {
306
- local slug="$1"
307
- clast_registry_list_json \
308
- | jq -r --arg s "$slug" 'map(select(.slug == $s and (.remote // "") != "")) | .[0].remote // empty'
309
- }
@@ -63,15 +63,16 @@ EOF
63
63
  return 0
64
64
  fi
65
65
 
66
- printf '%-17s %-33s %-43s %s\n' slug path remote aliases
67
- local n i slug path remote aliases
66
+ printf '%-17s %-13s %-33s %-43s %s\n' slug label path remote aliases
67
+ local n i slug label path remote aliases
68
68
  n="$(jq 'length' <<<"$arr")"
69
69
  for (( i = 0; i < n; i++ )); do
70
70
  slug="$(jq -r ".[$i].slug // \"\"" <<<"$arr")"
71
+ label="$(jq -r ".[$i].label // \"\" | if . == \"\" then \"(none)\" else . end" <<<"$arr")"
71
72
  path="$(jq -r ".[$i].path // \"\"" <<<"$arr")"
72
73
  remote="$(jq -r ".[$i].remote // \"\"" <<<"$arr")"
73
74
  aliases="$(jq -r ".[$i].aliases // [] | if length == 0 then \"(none)\" else join(\",\") end" <<<"$arr")"
74
- printf '%-17s %-33s %-43s %s\n' "$slug" "$path" "$remote" "$aliases"
75
+ printf '%-17s %-13s %-33s %-43s %s\n' "$slug" "$label" "$path" "$remote" "$aliases"
75
76
  done
76
77
  }
77
78
 
@@ -83,18 +84,18 @@ _clast_registry_op_add() {
83
84
  --json) json=1; shift ;;
84
85
  -h|--help)
85
86
  cat <<'EOF'
86
- Usage: clast registry add <path> [--slug NAME] [--remote URL] [--json]
87
+ Usage: clast registry add <path> [--slug NAME] [--label NAME] [--remote URL] [--json]
87
88
  EOF
88
89
  return 0
89
90
  ;;
90
91
  # TODO(v1.1): interactive --slug prompt when stdin is a TTY.
91
- --slug|--remote)
92
+ --slug|--label|--remote)
92
93
  if [[ $# -lt 2 ]]; then
93
94
  clast_log_error "registry add: $1 requires a value"
94
95
  return 2
95
96
  fi
96
97
  passthrough+=("$1" "$2"); shift 2 ;;
97
- --slug=*|--remote=*)
98
+ --slug=*|--label=*|--remote=*)
98
99
  passthrough+=("$1"); shift ;;
99
100
  *)
100
101
  passthrough+=("$1"); shift ;;
@@ -110,10 +111,15 @@ EOF
110
111
  if [[ -n "$json" ]]; then
111
112
  printf '%s\n' "$line"
112
113
  else
113
- local slug path
114
+ local slug label path
114
115
  slug="$(jq -r '.slug' <<<"$line")"
116
+ label="$(jq -r '.label // ""' <<<"$line")"
115
117
  path="$(jq -r '.path' <<<"$line")"
116
- printf 'registered %s → %s\n' "$slug" "$path"
118
+ if [[ -n "$label" ]]; then
119
+ printf 'registered %s (%s) → %s\n' "$slug" "$label" "$path"
120
+ else
121
+ printf 'registered %s → %s\n' "$slug" "$path"
122
+ fi
117
123
  fi
118
124
  }
119
125
 
@@ -126,6 +132,9 @@ _clast_registry_op_resolve() {
126
132
  -h|--help)
127
133
  cat <<'EOF'
128
134
  Usage: clast registry resolve <path-or-segment> [--json]
135
+
136
+ Prints the resolved slug. With --json, also includes the directory's
137
+ `label` when the matched line has one.
129
138
  EOF
130
139
  return 0
131
140
  ;;
@@ -144,10 +153,16 @@ EOF
144
153
  return 2
145
154
  fi
146
155
 
147
- local slug
148
- if slug="$(clast_registry_resolve "$input")" && [[ -n "$slug" ]]; then
156
+ # Resolve to the specific line so --json can surface the per-directory
157
+ # label (not just the slug). Human output stays slug-only.
158
+ local line
159
+ if line="$(clast_registry_line_for_path "$input")" && [[ -n "$line" ]]; then
160
+ local slug label
161
+ slug="$(jq -r '.slug // empty' <<<"$line")"
162
+ label="$(jq -r '.label // empty' <<<"$line")"
149
163
  if [[ -n "$json" ]]; then
150
- jq -cn --arg slug "$slug" '{slug: $slug}'
164
+ jq -cn --arg slug "$slug" --arg label "$label" \
165
+ '{slug: $slug} + (if $label == "" then {} else {label: $label} end)'
151
166
  else
152
167
  printf '%s\n' "$slug"
153
168
  fi
@@ -0,0 +1,217 @@
1
+ # clast-subcommands/retro.bash — `clast-plumbing retro`.
2
+ #
3
+ # Round 1 of the retro feature: run the deterministic day→project manifest
4
+ # (clast_retro_manifest) and render it from the raw entry bodies, in work-day
5
+ # order. No LLM. `--json` emits the manifest verbatim; the human render is the
6
+ # deterministic retro document the porcelain `clast retro` will later condense.
7
+ # See .wip/initiatives/clast-retro/.
8
+ # shellcheck shell=bash
9
+ # shellcheck source=lib/clast/clast-lib.bash
10
+ # shellcheck source=lib/clast/clast-retro-lib.bash
11
+ source "$CLAST_LIB/clast-retro-lib.bash"
12
+
13
+ _clast_retro_usage() {
14
+ cat <<'EOF'
15
+ Usage: clast retro [--from DATE] [--to DATE] [--window work-days|file-dates]
16
+
17
+ Summarize work grouped by the day it actually happened → project, from the
18
+ curated journal entries. Deterministic; no model call.
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): keep sessions whose work day is in range.
24
+ file-dates: keep entries whose filename date is in range
25
+ (pulls earlier work days reachable from those files).
26
+ --bodies With --json only: add each session's merged entry `body`.
27
+ -h, --help Print this usage and exit.
28
+
29
+ DATE accepts ISO (YYYY-MM-DD), `today`, `yesterday`, `last-week`, `-Nd`, `-Nw`.
30
+ With --json, emits the raw day→project manifest instead of the rendered report.
31
+ EOF
32
+ }
33
+
34
+ _clast_retro_err() {
35
+ local msg="$1" code="${2:-2}"
36
+ if [[ -n "${CLAST_JSON:-}" ]]; then
37
+ jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
38
+ else
39
+ clast_log_error "retro: $msg"
40
+ fi
41
+ }
42
+
43
+ clast_cmd_retro() {
44
+ local from="" to="" window="work-days" bodies=0
45
+
46
+ while [[ $# -gt 0 ]]; do
47
+ case "$1" in
48
+ --bodies) bodies=1; shift ;;
49
+ --from)
50
+ if [[ $# -lt 2 ]]; then _clast_retro_err "--from requires a value"; return 2; fi
51
+ if ! from="$(clast_parse_date "$2" 2>/dev/null)"; then
52
+ _clast_retro_err "invalid date '$2'"; return 2
53
+ fi
54
+ shift 2 ;;
55
+ --from=*)
56
+ if ! from="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
57
+ _clast_retro_err "invalid date '${1#*=}'"; return 2
58
+ fi
59
+ shift ;;
60
+ --to)
61
+ if [[ $# -lt 2 ]]; then _clast_retro_err "--to requires a value"; return 2; fi
62
+ if ! to="$(clast_parse_date "$2" 2>/dev/null)"; then
63
+ _clast_retro_err "invalid date '$2'"; return 2
64
+ fi
65
+ shift 2 ;;
66
+ --to=*)
67
+ if ! to="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
68
+ _clast_retro_err "invalid date '${1#*=}'"; return 2
69
+ fi
70
+ shift ;;
71
+ --window)
72
+ if [[ $# -lt 2 ]]; then _clast_retro_err "--window requires a value"; return 2; fi
73
+ window="$2"; shift 2 ;;
74
+ --window=*) window="${1#*=}"; shift ;;
75
+ -h|--help) _clast_retro_usage; return 0 ;;
76
+ --) shift; break ;;
77
+ -*) _clast_retro_err "unknown flag '$1'"; return 2 ;;
78
+ *) _clast_retro_err "unexpected positional '$1'"; return 2 ;;
79
+ esac
80
+ done
81
+
82
+ case "$window" in
83
+ work-days|file-dates) ;;
84
+ *) _clast_retro_err "--window must be 'work-days' or 'file-dates'"; return 2 ;;
85
+ esac
86
+
87
+ if [[ -n "$from" && -n "$to" && "$from" > "$to" ]]; then
88
+ _clast_retro_err "--from must be <= --to"; return 2
89
+ fi
90
+
91
+ local manifest
92
+ manifest="$(clast_retro_manifest \
93
+ ${from:+--from "$from"} ${to:+--to "$to"} --window "$window")" || return $?
94
+
95
+ if [[ -n "${CLAST_JSON:-}" ]]; then
96
+ if (( bodies )); then
97
+ manifest="$(_clast_retro_inject_bodies "$manifest")"
98
+ fi
99
+ printf '%s\n' "$manifest"
100
+ return 0
101
+ fi
102
+ if (( bodies )); then
103
+ _clast_retro_err "--bodies is only meaningful with --json"; return 2
104
+ fi
105
+ if [[ -n "${CLAST_QUIET:-}" ]]; then
106
+ return 0
107
+ fi
108
+
109
+ _clast_retro_render "$manifest"
110
+ }
111
+
112
+ # _clast_retro_inject_bodies <manifest-json>
113
+ # Add a `body` string (the merged trimmed entry bodies) to every session,
114
+ # for `--json --bodies`. Reuses clast_retro_session_body so the body matches
115
+ # the human render byte-for-byte.
116
+ _clast_retro_inject_bodies() {
117
+ local manifest="$1"
118
+ local -a pairs=()
119
+ local sess key body entry0 title interrupted
120
+ while IFS= read -r sess; do
121
+ [[ -z "$sess" ]] && continue
122
+ # Key by session_id, falling back to the first entry path when a
123
+ # legacy/hand-curated session has no id (JSON null): entries[0] is unique
124
+ # per session, so id-less sessions don't collide on a shared "null" key.
125
+ key="$(jq -r '.session_id // .entries[0]' <<<"$sess")"
126
+ body="$(clast_retro_session_body "$sess")"
127
+ entry0="$(jq -r '.entries[0]' <<<"$sess")"
128
+ title=""
129
+ [[ -r "$entry0" ]] && title="$(clast_entry_title "$entry0")"
130
+ if printf '%s' "$body" | clast_retro_is_interrupted; then
131
+ interrupted=true
132
+ else
133
+ interrupted=false
134
+ fi
135
+ # Body via --rawfile (process substitution), never argv: a single merged
136
+ # body can exceed MAX_ARG_STRLEN (~128 KiB) and silently fail the exec.
137
+ pairs+=("$(jq -cn --arg k "$key" --rawfile b <(printf '%s' "$body") \
138
+ --arg t "$title" --argjson i "$interrupted" \
139
+ '{key:$k, body:$b, title:$t, interrupted:$i}')")
140
+ done < <(jq -c '.days[].projects[].sessions[]' <<<"$manifest")
141
+
142
+ if (( ${#pairs[@]} == 0 )); then
143
+ printf '%s' "$manifest"
144
+ return 0
145
+ fi
146
+
147
+ # Manifest on stdin and the body-bearing pairs via --slurpfile — both can be
148
+ # large, so neither goes through argv. The `session_id // .entries[0]` key is
149
+ # always a non-null string, so this can't `$x[null]` abort jq ("Cannot index
150
+ # object with null") and id-less sessions don't collide on a shared key.
151
+ printf '%s' "$manifest" | jq -c --slurpfile pairs <(printf '%s\n' "${pairs[@]}") '
152
+ (reduce $pairs[] as $p ({}; .[$p.key] = $p)) as $x
153
+ | .days |= map(.projects |= map(.sessions |= map(
154
+ (.session_id // .entries[0]) as $k
155
+ | . + {body: ($x[$k].body // null),
156
+ title: ($x[$k].title // null),
157
+ interrupted: ($x[$k].interrupted // false)} )))
158
+ '
159
+ }
160
+
161
+ # _clast_retro_render <manifest-json>
162
+ # Render the day→project report to stdout.
163
+ _clast_retro_render() {
164
+ local manifest="$1"
165
+ local from to window
166
+ from="$(jq -r '.from // "(start)"' <<<"$manifest")"
167
+ to="$(jq -r '.to // "(end)"' <<<"$manifest")"
168
+ window="$(jq -r '.window' <<<"$manifest")"
169
+ printf 'Retro: %s -> %s (%s)\n' "$from" "$to" "$window"
170
+
171
+ local nd
172
+ nd="$(jq '.days | length' <<<"$manifest")"
173
+ if (( nd == 0 )); then
174
+ printf '\n(no sessions in range)\n'
175
+ return 0
176
+ fi
177
+
178
+ local di pj si
179
+ local day np project ns sess sid shortsid title entry note body flag
180
+ for (( di = 0; di < nd; di++ )); do
181
+ day="$(jq -r ".days[$di].day" <<<"$manifest")"
182
+ printf '\n== %s ==\n' "$day"
183
+ note="$(_clast_retro_provenance_note "$day" "$(jq -c ".days[$di].curation_dates // []" <<<"$manifest")")"
184
+ [[ -n "$note" ]] && printf '%s\n' "$note"
185
+ np="$(jq ".days[$di].projects | length" <<<"$manifest")"
186
+ for (( pj = 0; pj < np; pj++ )); do
187
+ project="$(jq -r ".days[$di].projects[$pj].project_name // .days[$di].projects[$pj].project_path // \"(no project)\"" <<<"$manifest")"
188
+ printf '\n[%s]\n' "$project"
189
+ ns="$(jq ".days[$di].projects[$pj].sessions | length" <<<"$manifest")"
190
+ for (( si = 0; si < ns; si++ )); do
191
+ sess="$(jq -c ".days[$di].projects[$pj].sessions[$si]" <<<"$manifest")"
192
+ sid="$(jq -r '.session_id' <<<"$sess")"
193
+ shortsid="${sid:0:8}"
194
+ entry="$(jq -r '.entries[0]' <<<"$sess")"
195
+ title=""
196
+ [[ -r "$entry" ]] && title="$(clast_entry_title "$entry")"
197
+ [[ -z "$title" ]] && title="(untitled)"
198
+ body="$(clast_retro_session_body "$sess")"
199
+ flag=""
200
+ printf '%s' "$body" | clast_retro_is_interrupted && flag=" [interrupted]"
201
+ printf '\n * %s (%s)%s\n' "$title" "$shortsid" "$flag"
202
+ printf '%s\n' "$body"
203
+ done
204
+ done
205
+ done
206
+ }
207
+
208
+ # _clast_retro_provenance_note <work-day> <curation_dates-json>
209
+ # One-line note when a day's entries were curated on other date(s) than the
210
+ # work day. Empty when they match (or there's nothing to say).
211
+ _clast_retro_provenance_note() {
212
+ local day="$1" cd_json="$2"
213
+ jq -r --arg d "$day" '
214
+ if . == [] or . == [$d] then empty
215
+ else " (filed " + (join(", ")) + "; work day reconstructed from session snapshots)"
216
+ end' <<<"$cd_json"
217
+ }