@procrastivity/clast 0.0.4 → 0.0.6

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 +317 -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
@@ -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.
@@ -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
- }