@procrastivity/clast 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -38
- package/bin/clast +31 -122
- package/bin/clast-plumbing +180 -0
- package/examples/cron/clast-snapshot.service +2 -2
- package/examples/cron/clast-snapshot.timer +1 -1
- package/examples/cron/crontab.sample +4 -4
- package/examples/workflows/morning-briefing.md +17 -17
- package/hooks/snapshot.sh +5 -5
- package/lib/clast/clast-classify-lib.bash +68 -0
- package/lib/clast/clast-dismissed-lib.bash +76 -5
- package/lib/clast/clast-lib.bash +93 -8
- package/lib/clast/clast-manifest-lib.bash +58 -5
- package/lib/clast/clast-porcelain-lib.bash +230 -0
- package/lib/clast/clast-porcelain-subcommands/brief.bash +236 -0
- package/lib/clast/clast-porcelain-subcommands/retro.bash +236 -0
- package/lib/clast/clast-porcelain-subcommands/undismiss.bash +27 -0
- package/lib/clast/clast-porcelain-subcommands/wake.bash +514 -0
- package/lib/clast/clast-registry-lib.bash +164 -41
- package/lib/clast/clast-retro-lib.bash +397 -0
- package/lib/clast/clast-subcommands/doctor.bash +29 -13
- package/lib/clast/clast-subcommands/entries.bash +40 -50
- package/lib/clast/clast-subcommands/projects.bash +16 -20
- package/lib/clast/clast-subcommands/registry.bash +26 -11
- package/lib/clast/clast-subcommands/retro.bash +217 -0
- package/lib/clast/clast-subcommands/sessions.bash +193 -48
- package/lib/clast/clast-subcommands/show.bash +69 -12
- package/lib/clast/clast-subcommands/snapshot.bash +29 -11
- package/lib/clast/clast-subcommands/stats.bash +7 -2
- package/lib/clast/prompts/brief-system.md +11 -5
- package/lib/clast/prompts/brief-user.md +4 -1
- package/lib/clast/prompts/retro-summary-system.md +14 -0
- package/lib/clast/prompts/retro-summary-user.md +7 -0
- package/lib/clast/prompts/{day-wakeup-draft-system.md → wake-draft-system.md} +1 -1
- package/package.json +3 -3
- package/{.claude-plugin/skills/wakeup → skills/brief}/SKILL.md +22 -20
- package/{.claude-plugin/skills/day-wakeup → skills/wake}/SKILL.md +54 -29
- package/bin/clast-brief +0 -305
- package/bin/clast-wake +0 -579
- /package/lib/clast/prompts/{day-wakeup-draft-user.md → wake-draft-user.md} +0 -0
|
@@ -147,39 +147,53 @@ _clast_doctor_check_registry_validity() {
|
|
|
147
147
|
return 0
|
|
148
148
|
fi
|
|
149
149
|
|
|
150
|
-
#
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
else
|
|
549
|
-
project_slug="$resolved_slug"
|
|
550
|
-
fi
|
|
525
|
+
# Resolve the *specific* registry line for this session's directory (by
|
|
526
|
+
# path), not the first line that shares the slug. A slug may span several
|
|
527
|
+
# directories (clones/worktrees), each with its own path and label;
|
|
528
|
+
# slug-first-match would stamp every entry with the first line's path.
|
|
529
|
+
local project_slug="" project_path="" project_remote="" project_label=""
|
|
530
|
+
local reg_line=""
|
|
531
|
+
if reg_line="$(clast_registry_line_for_path "$seg" 2>/dev/null)" && [[ -n "$reg_line" ]]; then
|
|
532
|
+
project_slug="$(jq -r '.slug // empty' <<<"$reg_line")"
|
|
533
|
+
project_path="$(jq -r '.path // empty' <<<"$reg_line")"
|
|
534
|
+
project_remote="$(jq -r '.remote // empty' <<<"$reg_line")"
|
|
535
|
+
project_label="$(jq -r '.label // empty' <<<"$reg_line")"
|
|
551
536
|
else
|
|
552
537
|
project_slug="$seg"
|
|
553
538
|
local -a decoded=()
|
|
@@ -619,6 +604,11 @@ _clast_entries_write() {
|
|
|
619
604
|
else
|
|
620
605
|
fm+="project_path: null"$'\n'
|
|
621
606
|
fi
|
|
607
|
+
if [[ -n "$project_label" ]]; then
|
|
608
|
+
fm+="label: $(_clast_entries_yaml_string "$project_label")"$'\n'
|
|
609
|
+
else
|
|
610
|
+
fm+="label: null"$'\n'
|
|
611
|
+
fi
|
|
622
612
|
if [[ -n "$project_remote" ]]; then
|
|
623
613
|
fm+="project_remote: $(_clast_entries_yaml_string "$project_remote")"$'\n'
|
|
624
614
|
else
|
|
@@ -145,8 +145,14 @@ clast_cmd_projects() {
|
|
|
145
145
|
|
|
146
146
|
seg_session_count["$seg"]=$(( ${seg_session_count["$seg"]:-0} + 1 ))
|
|
147
147
|
|
|
148
|
+
# Prefer cached msg_count on the manifest line (step 21); fall back to
|
|
149
|
+
# counting transcript lines for legacy lines that predate the cache.
|
|
148
150
|
abs_path="$journal_dir/$snapshot"
|
|
149
|
-
|
|
151
|
+
local cached_msgs
|
|
152
|
+
cached_msgs="$(jq -r '.msg_count // empty' <<<"$line")"
|
|
153
|
+
if [[ -n "$cached_msgs" ]]; then
|
|
154
|
+
msgs="$cached_msgs"
|
|
155
|
+
elif [[ -r "$abs_path" ]]; then
|
|
150
156
|
msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
|
|
151
157
|
[[ -z "$msgs" ]] && msgs=0
|
|
152
158
|
else
|
|
@@ -163,13 +169,18 @@ clast_cmd_projects() {
|
|
|
163
169
|
done
|
|
164
170
|
|
|
165
171
|
# Build per-segment rows as JSON, applying registry + --unregistered.
|
|
172
|
+
# Resolve each segment to its OWN registry line (not slug-first-match) so
|
|
173
|
+
# that a project spanning multiple checkouts under one shared slug reports
|
|
174
|
+
# each segment's own path/remote, instead of collapsing every row onto the
|
|
175
|
+
# first checkout's path.
|
|
166
176
|
local -a rows=()
|
|
167
|
-
local slug path remote registered row decode_rc decoded
|
|
177
|
+
local slug path remote registered row decode_rc decoded line
|
|
168
178
|
for seg in "${!seg_session_count[@]}"; do
|
|
169
|
-
if
|
|
179
|
+
if line="$(clast_registry_line_for_path "$seg" 2>/dev/null)" && [[ -n "$line" ]]; then
|
|
170
180
|
registered=true
|
|
171
|
-
|
|
172
|
-
|
|
181
|
+
slug="$(jq -r '.slug // empty' <<<"$line")"
|
|
182
|
+
path="$(jq -r '.path // empty' <<<"$line")"
|
|
183
|
+
remote="$(jq -r '.remote // empty' <<<"$line")"
|
|
173
184
|
else
|
|
174
185
|
registered=false
|
|
175
186
|
slug=""
|
|
@@ -292,18 +303,3 @@ _clast_projects_window_filter() {
|
|
|
292
303
|
printf '%s' "$joined"
|
|
293
304
|
}
|
|
294
305
|
|
|
295
|
-
# _clast_projects_path_for_slug <slug>
|
|
296
|
-
# First registry path for <slug>, or empty.
|
|
297
|
-
_clast_projects_path_for_slug() {
|
|
298
|
-
local slug="$1"
|
|
299
|
-
clast_registry_list_json \
|
|
300
|
-
| jq -r --arg s "$slug" 'map(select(.slug == $s)) | .[0].path // empty'
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
# _clast_projects_remote_for_slug <slug>
|
|
304
|
-
# First non-empty registry remote for <slug>, or empty.
|
|
305
|
-
_clast_projects_remote_for_slug() {
|
|
306
|
-
local slug="$1"
|
|
307
|
-
clast_registry_list_json \
|
|
308
|
-
| jq -r --arg s "$slug" 'map(select(.slug == $s and (.remote // "") != "")) | .[0].remote // empty'
|
|
309
|
-
}
|
|
@@ -63,15 +63,16 @@ EOF
|
|
|
63
63
|
return 0
|
|
64
64
|
fi
|
|
65
65
|
|
|
66
|
-
printf '%-17s %-33s %-43s %s\n' slug path remote aliases
|
|
67
|
-
local n i slug path remote aliases
|
|
66
|
+
printf '%-17s %-13s %-33s %-43s %s\n' slug label path remote aliases
|
|
67
|
+
local n i slug label path remote aliases
|
|
68
68
|
n="$(jq 'length' <<<"$arr")"
|
|
69
69
|
for (( i = 0; i < n; i++ )); do
|
|
70
70
|
slug="$(jq -r ".[$i].slug // \"\"" <<<"$arr")"
|
|
71
|
+
label="$(jq -r ".[$i].label // \"\" | if . == \"\" then \"(none)\" else . end" <<<"$arr")"
|
|
71
72
|
path="$(jq -r ".[$i].path // \"\"" <<<"$arr")"
|
|
72
73
|
remote="$(jq -r ".[$i].remote // \"\"" <<<"$arr")"
|
|
73
74
|
aliases="$(jq -r ".[$i].aliases // [] | if length == 0 then \"(none)\" else join(\",\") end" <<<"$arr")"
|
|
74
|
-
printf '%-17s %-33s %-43s %s\n' "$slug" "$path" "$remote" "$aliases"
|
|
75
|
+
printf '%-17s %-13s %-33s %-43s %s\n' "$slug" "$label" "$path" "$remote" "$aliases"
|
|
75
76
|
done
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -83,18 +84,18 @@ _clast_registry_op_add() {
|
|
|
83
84
|
--json) json=1; shift ;;
|
|
84
85
|
-h|--help)
|
|
85
86
|
cat <<'EOF'
|
|
86
|
-
Usage: clast registry add <path> [--slug NAME] [--remote URL] [--json]
|
|
87
|
+
Usage: clast registry add <path> [--slug NAME] [--label NAME] [--remote URL] [--json]
|
|
87
88
|
EOF
|
|
88
89
|
return 0
|
|
89
90
|
;;
|
|
90
91
|
# TODO(v1.1): interactive --slug prompt when stdin is a TTY.
|
|
91
|
-
--slug|--remote)
|
|
92
|
+
--slug|--label|--remote)
|
|
92
93
|
if [[ $# -lt 2 ]]; then
|
|
93
94
|
clast_log_error "registry add: $1 requires a value"
|
|
94
95
|
return 2
|
|
95
96
|
fi
|
|
96
97
|
passthrough+=("$1" "$2"); shift 2 ;;
|
|
97
|
-
--slug=*|--remote=*)
|
|
98
|
+
--slug=*|--label=*|--remote=*)
|
|
98
99
|
passthrough+=("$1"); shift ;;
|
|
99
100
|
*)
|
|
100
101
|
passthrough+=("$1"); shift ;;
|
|
@@ -110,10 +111,15 @@ EOF
|
|
|
110
111
|
if [[ -n "$json" ]]; then
|
|
111
112
|
printf '%s\n' "$line"
|
|
112
113
|
else
|
|
113
|
-
local slug path
|
|
114
|
+
local slug label path
|
|
114
115
|
slug="$(jq -r '.slug' <<<"$line")"
|
|
116
|
+
label="$(jq -r '.label // ""' <<<"$line")"
|
|
115
117
|
path="$(jq -r '.path' <<<"$line")"
|
|
116
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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"
|
|
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
|
+
}
|