@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
@@ -95,6 +95,58 @@ clast_registry_resolve() {
95
95
  printf '%s\n' "$slug"
96
96
  }
97
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
+
98
150
  # _clast_registry_lookup_path <path> <registry-json-array>
99
151
  # First match wins: scan .path, then .aliases[]. Print slug or empty.
100
152
  _clast_registry_lookup_path() {
@@ -125,14 +177,30 @@ _clast_registry_lookup_paths() {
125
177
  ' <<<"$arr"
126
178
  }
127
179
 
128
- # clast_registry_add <path> [--slug NAME] [--remote URL]
129
- # Append a single JSONL line. Default slug = basename(path). Default
130
- # remote = `git -C <path> remote get-url origin` (or absent). If the
131
- # resolved remote matches an existing entry's remote, append a new line
132
- # carrying that entry's slug and rolling its known paths into aliases.
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.
133
200
  # Print the appended JSON on stdout. Exit 2 on bad args, 1 on write fail.
134
201
  clast_registry_add() {
135
- 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
136
204
  while [[ $# -gt 0 ]]; do
137
205
  case "$1" in
138
206
  --slug)
@@ -140,8 +208,25 @@ clast_registry_add() {
140
208
  clast_log_error "clast_registry_add: --slug requires a value"
141
209
  return 2
142
210
  fi
143
- slug="$2"; shift 2 ;;
144
- --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 ;;
145
230
  --remote)
146
231
  if [[ $# -lt 2 ]]; then
147
232
  clast_log_error "clast_registry_add: --remote requires a value"
@@ -196,30 +281,49 @@ clast_registry_add() {
196
281
  fi
197
282
  fi
198
283
 
199
- # Default slug from basename.
200
- 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
201
302
  slug="$(basename "$canon")"
202
303
  fi
203
304
 
204
- # Aliases roll-up only when remote matched an existing entry.
205
- local arr matched_slug
206
- arr="$(clast_registry_list_json)"
207
- local aliases_json='[]'
208
- if [[ -n "$remote" ]]; then
209
- matched_slug="$(jq -r --arg r "$remote" 'map(select(.remote == $r)) | .[0].slug // empty' <<<"$arr")"
210
- if [[ -n "$matched_slug" ]]; then
211
- slug="$matched_slug"
212
- # Collect every known path for this slug (excluding the new one),
213
- # plus their existing aliases.
214
- aliases_json="$(jq -c --arg s "$slug" --arg p "$canon" '
215
- [ .[] | select(.slug == $s) | [.path] + (.aliases // []) ]
216
- | add // []
217
- | map(select(. != $p))
218
- | unique
219
- ' <<<"$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
220
316
  fi
317
+ else
318
+ label="$(_clast_registry_slugify "$(basename "$(dirname "$canon")")")"
221
319
  fi
222
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
+
223
327
  local journal_dir
224
328
  journal_dir="$(clast_journal_dir)"
225
329
  if ! mkdir -p "$journal_dir"; then
@@ -234,10 +338,11 @@ clast_registry_add() {
234
338
  line="$(jq -c -n \
235
339
  --arg path "$canon" \
236
340
  --arg slug "$slug" \
341
+ --arg label "$label" \
237
342
  --arg remote "$remote" \
238
343
  --arg first_seen "$first_seen" \
239
344
  --argjson aliases "$aliases_json" \
240
- '{path: $path, slug: $slug, remote: $remote, first_seen: $first_seen}
345
+ '{path: $path, slug: $slug, label: $label, remote: $remote, first_seen: $first_seen}
241
346
  | with_entries(select(.value != null and .value != ""))
242
347
  | . + {aliases: $aliases}')" || {
243
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
+ }
@@ -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.