@procrastivity/clast 0.0.4 → 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 (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 +236 -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
@@ -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
@@ -169,13 +169,18 @@ clast_cmd_projects() {
169
169
  done
170
170
 
171
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.
172
176
  local -a rows=()
173
- local slug path remote registered row decode_rc decoded
177
+ local slug path remote registered row decode_rc decoded line
174
178
  for seg in "${!seg_session_count[@]}"; do
175
- 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
176
180
  registered=true
177
- path="$(_clast_projects_path_for_slug "$slug")"
178
- 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")"
179
184
  else
180
185
  registered=false
181
186
  slug=""
@@ -298,18 +303,3 @@ _clast_projects_window_filter() {
298
303
  printf '%s' "$joined"
299
304
  }
300
305
 
301
- # _clast_projects_path_for_slug <slug>
302
- # First registry path for <slug>, or empty.
303
- _clast_projects_path_for_slug() {
304
- local slug="$1"
305
- clast_registry_list_json \
306
- | jq -r --arg s "$slug" 'map(select(.slug == $s)) | .[0].path // empty'
307
- }
308
-
309
- # _clast_projects_remote_for_slug <slug>
310
- # First non-empty registry remote for <slug>, or empty.
311
- _clast_projects_remote_for_slug() {
312
- local slug="$1"
313
- clast_registry_list_json \
314
- | jq -r --arg s "$slug" 'map(select(.slug == $s and (.remote // "") != "")) | .[0].remote // empty'
315
- }
@@ -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
+ }