@procrastivity/clast 0.0.1

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.
@@ -0,0 +1,697 @@
1
+ # clast-subcommands/entries.bash — `clast entries` list/read/write.
2
+ #
3
+ # Curated journal entries live at
4
+ # $(clast_journal_dir)/entries/YYYY-MM-DD-HHMM-<project-slug>-<session-slug>.md
5
+ # as Markdown files with a YAML frontmatter block. See
6
+ # docs/cli-contract.md#clast-entries and docs/cli-contract.md#entry-frontmatter.
7
+ # shellcheck shell=bash
8
+ # shellcheck source=lib/clast/clast-lib.bash
9
+ # shellcheck source=lib/clast/clast-manifest-lib.bash
10
+ # shellcheck source=lib/clast/clast-registry-lib.bash
11
+ # shellcheck source=lib/clast/clast-decode-lib.bash
12
+
13
+ _clast_entries_usage() {
14
+ cat <<'EOF'
15
+ Usage:
16
+ clast entries [--day DATE] [--since DATE] [--until DATE]
17
+ [--project SLUG] [--tag TAG]... [--limit N]
18
+ clast entries read <entry-path-or-basename>
19
+ clast entries write --session SESSION_ID --slug SESSION_SLUG
20
+ [--tags TAG,TAG,...] [--title TITLE]
21
+ (--body-from FILE | --body-stdin)
22
+
23
+ DATE accepts ISO, today, yesterday, last-week, -Nd, -Nw.
24
+ Tags are AND-intersection when --tag is repeated.
25
+ EOF
26
+ }
27
+
28
+ _clast_entries_err() {
29
+ local msg="$1" code="${2:-2}"
30
+ if [[ -n "${CLAST_JSON:-}" ]]; then
31
+ jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
32
+ else
33
+ clast_log_error "entries: $msg"
34
+ fi
35
+ }
36
+
37
+ clast_cmd_entries() {
38
+ local first="${1:-}"
39
+ case "$first" in
40
+ -h|--help)
41
+ _clast_entries_usage
42
+ return 0
43
+ ;;
44
+ read)
45
+ shift
46
+ _clast_entries_read "$@"
47
+ ;;
48
+ write)
49
+ shift
50
+ _clast_entries_write "$@"
51
+ ;;
52
+ list)
53
+ shift
54
+ _clast_entries_list "$@"
55
+ ;;
56
+ ""|-*)
57
+ _clast_entries_list "$@"
58
+ ;;
59
+ *)
60
+ _clast_entries_err "unknown subcommand '$first'"
61
+ return 2
62
+ ;;
63
+ esac
64
+ }
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Frontmatter helpers
68
+ # ---------------------------------------------------------------------------
69
+
70
+ # _clast_entries_read_frontmatter <path>
71
+ # Emit raw frontmatter lines (between the first two `---` fences) to stdout.
72
+ _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"
82
+ }
83
+
84
+ # _clast_entries_extract_title <path>
85
+ # Look for `# Session: <title>` as the first non-blank body line.
86
+ _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"
100
+ }
101
+
102
+ # _clast_entries_unquote <string>
103
+ # Strip surrounding double quotes and unescape \", \\, \n.
104
+ _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"
115
+ }
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # YAML emit helpers
119
+ # ---------------------------------------------------------------------------
120
+
121
+ # _clast_entries_yaml_string <value>
122
+ # Emit a YAML scalar. Bare when safe; double-quoted otherwise.
123
+ _clast_entries_yaml_string() {
124
+ local v="$1"
125
+ if [[ -z "$v" ]]; then
126
+ printf '""'
127
+ return 0
128
+ fi
129
+ if [[ "$v" =~ ^[A-Za-z0-9._/@+-][A-Za-z0-9._/@+-]*$ ]]; then
130
+ printf '%s' "$v"
131
+ return 0
132
+ fi
133
+ local esc="${v//\\/\\\\}"
134
+ esc="${esc//\"/\\\"}"
135
+ esc="${esc//$'\n'/\\n}"
136
+ printf '"%s"' "$esc"
137
+ }
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Time helpers
141
+ # ---------------------------------------------------------------------------
142
+
143
+ _clast_entries_now_hhmm() {
144
+ local epoch
145
+ epoch="$(_clast_now_epoch)"
146
+ date -d "@$epoch" +%H:%M
147
+ }
148
+
149
+ _clast_entries_now_hhmm_compact() {
150
+ local epoch
151
+ epoch="$(_clast_now_epoch)"
152
+ date -d "@$epoch" +%H%M
153
+ }
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # list
157
+ # ---------------------------------------------------------------------------
158
+
159
+ _clast_entries_list() {
160
+ local day_filter="" since_date="" until_date=""
161
+ local project_filter="" limit=""
162
+ local -a tag_filters=()
163
+
164
+ while [[ $# -gt 0 ]]; do
165
+ case "$1" in
166
+ --day)
167
+ if [[ $# -lt 2 ]]; then _clast_entries_err "--day requires a value"; return 2; fi
168
+ if ! day_filter="$(clast_parse_date "$2" 2>/dev/null)"; then
169
+ _clast_entries_err "invalid date '$2'"; return 2
170
+ fi
171
+ shift 2 ;;
172
+ --day=*)
173
+ if ! day_filter="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
174
+ _clast_entries_err "invalid date '${1#*=}'"; return 2
175
+ fi
176
+ shift ;;
177
+ --since)
178
+ if [[ $# -lt 2 ]]; then _clast_entries_err "--since requires a value"; return 2; fi
179
+ if ! since_date="$(clast_parse_date "$2" 2>/dev/null)"; then
180
+ _clast_entries_err "invalid date '$2'"; return 2
181
+ fi
182
+ shift 2 ;;
183
+ --since=*)
184
+ if ! since_date="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
185
+ _clast_entries_err "invalid date '${1#*=}'"; return 2
186
+ fi
187
+ shift ;;
188
+ --until)
189
+ if [[ $# -lt 2 ]]; then _clast_entries_err "--until requires a value"; return 2; fi
190
+ if ! until_date="$(clast_parse_date "$2" 2>/dev/null)"; then
191
+ _clast_entries_err "invalid date '$2'"; return 2
192
+ fi
193
+ shift 2 ;;
194
+ --until=*)
195
+ if ! until_date="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
196
+ _clast_entries_err "invalid date '${1#*=}'"; return 2
197
+ fi
198
+ shift ;;
199
+ --project)
200
+ if [[ $# -lt 2 ]]; then _clast_entries_err "--project requires a value"; return 2; fi
201
+ project_filter="$2"; shift 2 ;;
202
+ --project=*)
203
+ project_filter="${1#*=}"; shift ;;
204
+ --tag)
205
+ if [[ $# -lt 2 ]]; then _clast_entries_err "--tag requires a value"; return 2; fi
206
+ tag_filters+=("$2"); shift 2 ;;
207
+ --tag=*)
208
+ tag_filters+=("${1#*=}"); shift ;;
209
+ --limit)
210
+ if [[ $# -lt 2 ]]; then _clast_entries_err "--limit requires a value"; return 2; fi
211
+ if ! [[ "$2" =~ ^[1-9][0-9]*$ ]]; then
212
+ _clast_entries_err "--limit must be a positive integer"; return 2
213
+ fi
214
+ limit="$2"; shift 2 ;;
215
+ --limit=*)
216
+ local v="${1#*=}"
217
+ if ! [[ "$v" =~ ^[1-9][0-9]*$ ]]; then
218
+ _clast_entries_err "--limit must be a positive integer"; return 2
219
+ fi
220
+ limit="$v"; shift ;;
221
+ -h|--help) _clast_entries_usage; return 0 ;;
222
+ --) shift; break ;;
223
+ -*) _clast_entries_err "unknown flag '$1'"; return 2 ;;
224
+ *) _clast_entries_err "unexpected positional '$1'"; return 2 ;;
225
+ esac
226
+ done
227
+
228
+ if [[ -n "$day_filter" && ( -n "$since_date" || -n "$until_date" ) ]]; then
229
+ _clast_entries_err "--day is mutually exclusive with --since/--until"
230
+ return 2
231
+ fi
232
+
233
+ local journal_dir entries_dir
234
+ journal_dir="$(clast_journal_dir)"
235
+ entries_dir="$journal_dir/entries"
236
+
237
+ local -a rows=()
238
+ if [[ -d "$entries_dir" ]]; then
239
+ local f
240
+ while IFS= read -r f; do
241
+ [[ -z "$f" ]] && continue
242
+ _clast_entries_list_consider "$f" "$day_filter" "$since_date" "$until_date" \
243
+ "$project_filter" "${#tag_filters[@]}" "${tag_filters[@]+"${tag_filters[@]}"}" \
244
+ && rows+=("$_CLAST_ENTRY_ROW_JSON")
245
+ done < <(find "$entries_dir" -mindepth 1 -maxdepth 1 -type f -name '*.md' 2>/dev/null | sort)
246
+ fi
247
+
248
+ local rows_json='[]'
249
+ if (( ${#rows[@]} > 0 )); then
250
+ rows_json="$(printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.date + "T" + .time) | reverse')"
251
+ fi
252
+ if [[ -n "$limit" ]]; then
253
+ rows_json="$(jq -c --argjson n "$limit" '.[0:$n]' <<<"$rows_json")"
254
+ fi
255
+
256
+ if [[ -n "${CLAST_JSON:-}" ]]; then
257
+ printf '%s\n' "$rows_json"
258
+ return 0
259
+ fi
260
+
261
+ if [[ -n "${CLAST_QUIET:-}" ]]; then
262
+ return 0
263
+ fi
264
+
265
+ printf '%-11s %-6s %-17s %-29s %s\n' \
266
+ "date" "time" "project" "slug" "tags"
267
+
268
+ local n i row r_date r_time r_project r_slug r_tags tags_disp
269
+ n="$(jq 'length' <<<"$rows_json")"
270
+ for (( i = 0; i < n; i++ )); do
271
+ row="$(jq -c ".[$i]" <<<"$rows_json")"
272
+ r_date="$(jq -r '.date // ""' <<<"$row")"
273
+ r_time="$(jq -r '.time // ""' <<<"$row")"
274
+ r_project="$(jq -r '.project // ""' <<<"$row")"
275
+ r_slug="$(jq -r '.session_slug // ""' <<<"$row")"
276
+ r_tags="$(jq -r '.tags // [] | join(",")' <<<"$row")"
277
+ tags_disp="$r_tags"
278
+ if (( ${#tags_disp} > 30 )); then
279
+ tags_disp="${tags_disp:0:29}…"
280
+ fi
281
+ printf '%-11s %-6s %-17s %-29s %s\n' \
282
+ "$r_date" "$r_time" "$r_project" "$r_slug" "$tags_disp"
283
+ done
284
+ }
285
+
286
+ # _clast_entries_list_consider <file> <day> <since> <until> <project> <tag_count> <tags...>
287
+ # On match, set _CLAST_ENTRY_ROW_JSON globally and return 0. On miss, return 1.
288
+ _clast_entries_list_consider() {
289
+ local file="$1" day="$2" since="$3" until="$4" project="$5" tag_count="$6"
290
+ shift 6
291
+ local -a wanted_tags=()
292
+ if (( tag_count > 0 )); then
293
+ wanted_tags=("$@")
294
+ fi
295
+
296
+ # Parse frontmatter.
297
+ local fm_date="" fm_time="" fm_day_bucket="" fm_project=""
298
+ local fm_session_id="" fm_session_slug="" fm_branch=""
299
+ local fm_tags_raw=""
300
+ local line key val
301
+ while IFS= read -r line; do
302
+ [[ -z "$line" ]] && continue
303
+ key="${line%%:*}"
304
+ val="${line#*:}"
305
+ # Trim leading whitespace from val.
306
+ val="${val#"${val%%[![:space:]]*}"}"
307
+ # Trim trailing whitespace.
308
+ val="${val%"${val##*[![:space:]]}"}"
309
+ case "$key" in
310
+ date) fm_date="$(_clast_entries_unquote "$val")" ;;
311
+ time) fm_time="$(_clast_entries_unquote "$val")" ;;
312
+ day_bucket) fm_day_bucket="$(_clast_entries_unquote "$val")" ;;
313
+ project) fm_project="$(_clast_entries_unquote "$val")" ;;
314
+ session_id) fm_session_id="$(_clast_entries_unquote "$val")" ;;
315
+ session_slug) fm_session_slug="$(_clast_entries_unquote "$val")" ;;
316
+ branch)
317
+ if [[ "$val" != "null" ]]; then
318
+ fm_branch="$(_clast_entries_unquote "$val")"
319
+ fi
320
+ ;;
321
+ tags) fm_tags_raw="$val" ;;
322
+ esac
323
+ done < <(_clast_entries_read_frontmatter "$file")
324
+
325
+ # Window filter on day_bucket (fall back to date if day_bucket missing).
326
+ local bucket="${fm_day_bucket:-$fm_date}"
327
+ if [[ -n "$day" && "$bucket" != "$day" ]]; then return 1; fi
328
+ if [[ -n "$since" && "$bucket" < "$since" ]]; then return 1; fi
329
+ if [[ -n "$until" && "$bucket" > "$until" ]]; then return 1; fi
330
+
331
+ if [[ -n "$project" && "$project" != "$fm_project" ]]; then return 1; fi
332
+
333
+ # Parse tags array.
334
+ local -a tags_arr=()
335
+ if [[ "$fm_tags_raw" == "["*"]" ]]; then
336
+ local inner="${fm_tags_raw#[}"
337
+ inner="${inner%]}"
338
+ if [[ -n "$inner" ]]; then
339
+ local IFS=','
340
+ read -r -a tags_arr <<<"$inner"
341
+ unset IFS
342
+ local k t
343
+ for k in "${!tags_arr[@]}"; do
344
+ t="${tags_arr[$k]}"
345
+ t="${t#"${t%%[![:space:]]*}"}"
346
+ t="${t%"${t##*[![:space:]]}"}"
347
+ tags_arr[k]="$t"
348
+ done
349
+ fi
350
+ fi
351
+
352
+ # AND-intersection of tag filters.
353
+ if (( ${#wanted_tags[@]} > 0 )); then
354
+ local want have ok
355
+ for want in "${wanted_tags[@]}"; do
356
+ ok=0
357
+ for have in "${tags_arr[@]+"${tags_arr[@]}"}"; do
358
+ if [[ "$have" == "$want" ]]; then ok=1; break; fi
359
+ done
360
+ if (( ok == 0 )); then return 1; fi
361
+ done
362
+ fi
363
+
364
+ local title
365
+ title="$(_clast_entries_extract_title "$file")"
366
+
367
+ # Build the JSON row.
368
+ local tags_json='[]'
369
+ if (( ${#tags_arr[@]} > 0 )); then
370
+ tags_json="$(printf '%s\n' "${tags_arr[@]}" | jq -R . | jq -cs .)"
371
+ fi
372
+
373
+ _CLAST_ENTRY_ROW_JSON="$(jq -cn \
374
+ --arg path "$file" \
375
+ --arg date "$fm_date" \
376
+ --arg time "$fm_time" \
377
+ --arg day_bucket "$bucket" \
378
+ --arg project "$fm_project" \
379
+ --arg session_id "$fm_session_id" \
380
+ --arg session_slug "$fm_session_slug" \
381
+ --arg branch "$fm_branch" \
382
+ --argjson tags "$tags_json" \
383
+ --arg title "$title" \
384
+ '{
385
+ path: $path,
386
+ date: $date,
387
+ time: $time,
388
+ day_bucket: $day_bucket,
389
+ project: $project,
390
+ session_id: $session_id,
391
+ session_slug: $session_slug,
392
+ branch: (if $branch == "" then null else $branch end),
393
+ tags: $tags,
394
+ title: (if $title == "" then null else $title end)
395
+ }')"
396
+ return 0
397
+ }
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # read
401
+ # ---------------------------------------------------------------------------
402
+
403
+ _clast_entries_read() {
404
+ local arg=""
405
+ while [[ $# -gt 0 ]]; do
406
+ case "$1" in
407
+ -h|--help) _clast_entries_usage; return 0 ;;
408
+ --) shift; break ;;
409
+ -*) _clast_entries_err "read: unknown flag '$1'"; return 2 ;;
410
+ *)
411
+ if [[ -n "$arg" ]]; then
412
+ _clast_entries_err "read: unexpected positional '$1'"; return 2
413
+ fi
414
+ arg="$1"; shift ;;
415
+ esac
416
+ done
417
+
418
+ if [[ -z "$arg" ]]; then
419
+ _clast_entries_err "read: missing <entry-path>"
420
+ return 2
421
+ fi
422
+
423
+ local resolved
424
+ if [[ "$arg" == /* && -f "$arg" ]]; then
425
+ resolved="$arg"
426
+ else
427
+ resolved="$(clast_journal_dir)/entries/$arg"
428
+ fi
429
+
430
+ if [[ ! -f "$resolved" ]]; then
431
+ _clast_entries_err "read: not found '$arg'" 1
432
+ return 1
433
+ fi
434
+
435
+ if [[ -n "${CLAST_JSON:-}" ]]; then
436
+ local content
437
+ content="$(cat -- "$resolved")"
438
+ jq -cn --arg path "$resolved" --arg content "$content" \
439
+ '{path:$path, content:$content}'
440
+ return 0
441
+ fi
442
+
443
+ cat -- "$resolved"
444
+ }
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # write
448
+ # ---------------------------------------------------------------------------
449
+
450
+ _clast_entries_write() {
451
+ local session_id="" slug="" tags_csv="" title=""
452
+ local body_from="" body_stdin=0
453
+ local tags_explicit=0
454
+
455
+ while [[ $# -gt 0 ]]; do
456
+ case "$1" in
457
+ --session)
458
+ if [[ $# -lt 2 ]]; then _clast_entries_err "write: --session requires a value"; return 2; fi
459
+ session_id="$2"; shift 2 ;;
460
+ --session=*) session_id="${1#*=}"; shift ;;
461
+ --slug)
462
+ if [[ $# -lt 2 ]]; then _clast_entries_err "write: --slug requires a value"; return 2; fi
463
+ slug="$2"; shift 2 ;;
464
+ --slug=*) slug="${1#*=}"; shift ;;
465
+ --tags)
466
+ if [[ $# -lt 2 ]]; then _clast_entries_err "write: --tags requires a value"; return 2; fi
467
+ tags_csv="$2"; tags_explicit=1; shift 2 ;;
468
+ --tags=*) tags_csv="${1#*=}"; tags_explicit=1; shift ;;
469
+ --title)
470
+ if [[ $# -lt 2 ]]; then _clast_entries_err "write: --title requires a value"; return 2; fi
471
+ title="$2"; shift 2 ;;
472
+ --title=*) title="${1#*=}"; shift ;;
473
+ --body-from)
474
+ if [[ $# -lt 2 ]]; then _clast_entries_err "write: --body-from requires a value"; return 2; fi
475
+ body_from="$2"; shift 2 ;;
476
+ --body-from=*) body_from="${1#*=}"; shift ;;
477
+ --body-stdin) body_stdin=1; shift ;;
478
+ -h|--help) _clast_entries_usage; return 0 ;;
479
+ --) shift; break ;;
480
+ -*) _clast_entries_err "write: unknown flag '$1'"; return 2 ;;
481
+ *) _clast_entries_err "write: unexpected positional '$1'"; return 2 ;;
482
+ esac
483
+ done
484
+
485
+ if [[ -z "$session_id" ]]; then
486
+ _clast_entries_err "write: missing required flag '--session'"; return 2
487
+ fi
488
+ if ! [[ "$session_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
489
+ _clast_entries_err "write: '$session_id' is not a valid UUID"; return 2
490
+ fi
491
+ if [[ -z "$slug" ]]; then
492
+ _clast_entries_err "write: missing required flag '--slug'"; return 2
493
+ fi
494
+ if ! [[ "$slug" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; then
495
+ _clast_entries_err "write: invalid --slug '$slug'"; return 2
496
+ fi
497
+ if [[ -n "$body_from" && $body_stdin -eq 1 ]]; then
498
+ _clast_entries_err "write: --body-from and --body-stdin are mutually exclusive"; return 2
499
+ fi
500
+ if [[ -z "$body_from" && $body_stdin -eq 0 ]]; then
501
+ _clast_entries_err "write: missing required flag '--body-from' or '--body-stdin'"; return 2
502
+ fi
503
+ if [[ -n "$title" && "$title" == *$'\n'* ]]; then
504
+ _clast_entries_err "write: --title must not contain newlines"; return 2
505
+ fi
506
+
507
+ # Parse + validate tags.
508
+ local -a tags=()
509
+ if (( tags_explicit == 1 )) && [[ -n "$tags_csv" ]]; then
510
+ local IFS=','
511
+ read -r -a _raw_tags <<<"$tags_csv"
512
+ unset IFS
513
+ local rt trimmed
514
+ for rt in "${_raw_tags[@]}"; do
515
+ trimmed="${rt#"${rt%%[![:space:]]*}"}"
516
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
517
+ [[ -z "$trimmed" ]] && continue
518
+ if ! [[ "$trimmed" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
519
+ _clast_entries_err "write: invalid tag '$trimmed'"; return 2
520
+ fi
521
+ tags+=("$trimmed")
522
+ done
523
+ fi
524
+
525
+ # Look up manifest entry.
526
+ local manifest_line
527
+ if ! manifest_line="$(clast_manifest_lookup "$session_id" 2>/dev/null)" || [[ -z "$manifest_line" ]]; then
528
+ _clast_entries_err "write: session '$session_id' not found in manifest" 1
529
+ return 1
530
+ fi
531
+
532
+ local snapshot_rel mtime
533
+ snapshot_rel="$(jq -r '.snapshot' <<<"$manifest_line")"
534
+ mtime="$(jq -r '.source_mtime' <<<"$manifest_line")"
535
+ : "$mtime" # unused for now; reserved for future best-effort fields
536
+
537
+ local seg
538
+ seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot_rel")"
539
+
540
+ # Resolve project from registry.
541
+ local project_slug="" project_path="" project_remote=""
542
+ local resolved_slug=""
543
+ if resolved_slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$resolved_slug" ]]; then
544
+ local reg_json reg_match
545
+ reg_json="$(clast_registry_list_json)"
546
+ reg_match="$(jq -c --arg s "$resolved_slug" 'map(select(.slug == $s)) | .[0] // empty' <<<"$reg_json")"
547
+ if [[ -n "$reg_match" ]]; then
548
+ project_slug="$resolved_slug"
549
+ project_path="$(jq -r '.path // empty' <<<"$reg_match")"
550
+ project_remote="$(jq -r '.remote // empty' <<<"$reg_match")"
551
+ else
552
+ project_slug="$resolved_slug"
553
+ fi
554
+ else
555
+ project_slug="$seg"
556
+ local -a decoded=()
557
+ mapfile -t decoded < <(clast_decode_candidates "$seg" 2>/dev/null)
558
+ local d existing=()
559
+ for d in "${decoded[@]+"${decoded[@]}"}"; do
560
+ if [[ -d "$d" ]]; then existing+=("$d"); fi
561
+ done
562
+ if (( ${#existing[@]} == 1 )); then
563
+ project_path="${existing[0]}"
564
+ fi
565
+ clast_log_warn "entries: write: segment '$seg' is not registered; using slug '$project_slug'"
566
+ fi
567
+
568
+ # Best-effort branch from snapshot.
569
+ local journal_dir snapshot_abs branch=""
570
+ journal_dir="$(clast_journal_dir)"
571
+ snapshot_abs="$journal_dir/$snapshot_rel"
572
+ if [[ -r "$snapshot_abs" ]]; then
573
+ branch="$(head -n1 "$snapshot_abs" 2>/dev/null | jq -r '.cwd // .git_branch // empty' 2>/dev/null || true)"
574
+ # `.cwd` isn't a branch, but we keep parity with step 07's TODO: leave best-effort placeholder.
575
+ # If the value looks like a path, drop it.
576
+ case "$branch" in
577
+ /*|"") branch="" ;;
578
+ esac
579
+ fi
580
+
581
+ local author machine
582
+ author="${CLAST_AUTHOR:-${USER:-unknown}}"
583
+ machine="${CLAST_MACHINE:-$(hostname)}"
584
+
585
+ local today hhmm hhmm_compact
586
+ today="$(clast_today)"
587
+ hhmm="$(_clast_entries_now_hhmm)"
588
+ hhmm_compact="$(_clast_entries_now_hhmm_compact)"
589
+
590
+ # Body acquisition.
591
+ local body=""
592
+ if [[ -n "$body_from" ]]; then
593
+ if [[ ! -r "$body_from" ]]; then
594
+ _clast_entries_err "write: --body-from: cannot read '$body_from'" 1
595
+ return 1
596
+ fi
597
+ body="$(cat -- "$body_from")"
598
+ else
599
+ body="$(cat)"
600
+ fi
601
+ # Empty / whitespace-only check.
602
+ local body_stripped="${body//[[:space:]]/}"
603
+ if [[ -z "$body_stripped" ]]; then
604
+ _clast_entries_err "write: body is empty" 1
605
+ return 1
606
+ fi
607
+ # Trim a single trailing newline at most; ensure exactly one trailing newline.
608
+ if [[ "${body: -1}" == $'\n' ]]; then
609
+ body="${body%$'\n'}"
610
+ fi
611
+ body+=$'\n'
612
+
613
+ if [[ -n "$title" ]]; then
614
+ body="# Session: $title"$'\n\n'"$body"
615
+ fi
616
+
617
+ # Compose frontmatter.
618
+ local fm=""
619
+ fm+="date: $(_clast_entries_yaml_string "$today")"$'\n'
620
+ fm+="time: $(_clast_entries_yaml_string "$hhmm")"$'\n'
621
+ fm+="day_bucket: $(_clast_entries_yaml_string "$today")"$'\n'
622
+ fm+="project: $(_clast_entries_yaml_string "$project_slug")"$'\n'
623
+ if [[ -n "$project_path" ]]; then
624
+ fm+="project_path: $(_clast_entries_yaml_string "$project_path")"$'\n'
625
+ else
626
+ fm+="project_path: null"$'\n'
627
+ fi
628
+ if [[ -n "$project_remote" ]]; then
629
+ fm+="project_remote: $(_clast_entries_yaml_string "$project_remote")"$'\n'
630
+ else
631
+ fm+="project_remote: null"$'\n'
632
+ fi
633
+ if [[ -n "$branch" ]]; then
634
+ fm+="branch: $(_clast_entries_yaml_string "$branch")"$'\n'
635
+ else
636
+ fm+="branch: null"$'\n'
637
+ fi
638
+ fm+="author: $(_clast_entries_yaml_string "$author")"$'\n'
639
+ if (( ${#tags[@]} == 0 )); then
640
+ fm+="tags: []"$'\n'
641
+ else
642
+ local joined="" t
643
+ for t in "${tags[@]}"; do
644
+ if [[ -n "$joined" ]]; then joined+=", "; fi
645
+ joined+="$t"
646
+ done
647
+ fm+="tags: [$joined]"$'\n'
648
+ fi
649
+ fm+="session_id: $(_clast_entries_yaml_string "$session_id")"$'\n'
650
+ fm+="session_slug: $(_clast_entries_yaml_string "$slug")"$'\n'
651
+ fm+="snapshot_path: $(_clast_entries_yaml_string "$snapshot_rel")"$'\n'
652
+ fm+="machine: $(_clast_entries_yaml_string "$machine")"$'\n'
653
+
654
+ local composed="---"$'\n'"$fm""---"$'\n\n'"$body"
655
+
656
+ # Resolve target filename with collision suffixing.
657
+ local entries_dir="$journal_dir/entries"
658
+ if ! mkdir -p "$entries_dir"; then
659
+ _clast_entries_err "write: failed to create '$entries_dir'" 1
660
+ return 1
661
+ fi
662
+ local base="${today}-${hhmm_compact}-${project_slug}-${slug}"
663
+ # Collapse leading dashes from segment-derived slug (but not internal `--`).
664
+ base="${base/-\///}" # no-op safety
665
+ local target="$entries_dir/${base}.md"
666
+ if [[ -e "$target" ]]; then
667
+ local i found=""
668
+ for (( i = 2; i <= 99; i++ )); do
669
+ if [[ ! -e "$entries_dir/${base}-${i}.md" ]]; then
670
+ target="$entries_dir/${base}-${i}.md"
671
+ found=1
672
+ break
673
+ fi
674
+ done
675
+ if [[ -z "$found" ]]; then
676
+ _clast_entries_err "write: too many collisions for ${base}.md" 1
677
+ return 1
678
+ fi
679
+ fi
680
+
681
+ if ! clast_atomic_write "$target" "$composed"; then
682
+ _clast_entries_err "write: failed to write '$target'" 1
683
+ return 1
684
+ fi
685
+
686
+ local basename_only
687
+ basename_only="$(basename "$target")"
688
+
689
+ if [[ -n "${CLAST_JSON:-}" ]]; then
690
+ jq -cn --arg path "$target" '{path:$path}'
691
+ return 0
692
+ fi
693
+ if [[ -n "${CLAST_QUIET:-}" ]]; then
694
+ return 0
695
+ fi
696
+ printf 'Wrote entries/%s\n' "$basename_only"
697
+ }