@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
|
@@ -61,25 +61,24 @@ clast_registry_resolve() {
|
|
|
61
61
|
local arr
|
|
62
62
|
arr="$(clast_registry_list_json)"
|
|
63
63
|
|
|
64
|
-
# Segment input: starts with `-`.
|
|
65
|
-
# segments registered as-is)
|
|
64
|
+
# Segment input: starts with `-`. The candidate set is the raw segment
|
|
65
|
+
# (handles segments registered as-is) followed by every dash-decoded
|
|
66
|
+
# filesystem path. Test them all in ONE jq pass — first candidate in
|
|
67
|
+
# order to match a registry .path/.aliases wins — instead of forking one
|
|
68
|
+
# jq per candidate, which for deep paths meant ~hundreds/thousands of
|
|
69
|
+
# forks per resolve (the dominant cost of `clast wake` startup).
|
|
66
70
|
if [[ "$input" == -* ]]; then
|
|
67
|
-
local
|
|
68
|
-
|
|
71
|
+
local -a candidates=("$input")
|
|
72
|
+
local -a decoded=()
|
|
73
|
+
mapfile -t decoded < <(clast_decode_candidates "$input")
|
|
74
|
+
candidates+=("${decoded[@]}")
|
|
75
|
+
local cands_json slug
|
|
76
|
+
cands_json="$(printf '%s\n' "${candidates[@]}" | jq -Rn '[inputs]')"
|
|
77
|
+
slug="$(_clast_registry_lookup_paths "$cands_json" "$arr" 2>/dev/null)" || true
|
|
69
78
|
if [[ -n "$slug" ]]; then
|
|
70
79
|
printf '%s\n' "$slug"
|
|
71
80
|
return 0
|
|
72
81
|
fi
|
|
73
|
-
local -a candidates=()
|
|
74
|
-
mapfile -t candidates < <(clast_decode_candidates "$input")
|
|
75
|
-
local c
|
|
76
|
-
for c in "${candidates[@]}"; do
|
|
77
|
-
slug="$(_clast_registry_lookup_path "$c" "$arr")" || continue
|
|
78
|
-
if [[ -n "$slug" ]]; then
|
|
79
|
-
printf '%s\n' "$slug"
|
|
80
|
-
return 0
|
|
81
|
-
fi
|
|
82
|
-
done
|
|
83
82
|
return 1
|
|
84
83
|
fi
|
|
85
84
|
|
|
@@ -96,6 +95,58 @@ clast_registry_resolve() {
|
|
|
96
95
|
printf '%s\n' "$slug"
|
|
97
96
|
}
|
|
98
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
|
+
|
|
99
150
|
# _clast_registry_lookup_path <path> <registry-json-array>
|
|
100
151
|
# First match wins: scan .path, then .aliases[]. Print slug or empty.
|
|
101
152
|
_clast_registry_lookup_path() {
|
|
@@ -107,14 +158,49 @@ _clast_registry_lookup_path() {
|
|
|
107
158
|
' <<<"$arr"
|
|
108
159
|
}
|
|
109
160
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
161
|
+
# _clast_registry_lookup_paths <candidates-json-array> <registry-json-array>
|
|
162
|
+
# Batched form of _clast_registry_lookup_path: given an ordered JSON array
|
|
163
|
+
# of candidate paths, return the slug for the FIRST candidate (in order)
|
|
164
|
+
# that matches a registry entry's .path or .aliases — path before alias,
|
|
165
|
+
# matching the single-path helper. Print slug or empty. One jq pass for
|
|
166
|
+
# the whole candidate set.
|
|
167
|
+
_clast_registry_lookup_paths() {
|
|
168
|
+
local cands_json="$1" arr="$2"
|
|
169
|
+
jq -r --argjson cands "$cands_json" '
|
|
170
|
+
. as $arr
|
|
171
|
+
| first(
|
|
172
|
+
$cands[] as $c
|
|
173
|
+
| ( ($arr | map(select(.path == $c)) | .[0].slug)
|
|
174
|
+
// ($arr | map(select((.aliases? // []) | index($c) != null)) | .[0].slug) )
|
|
175
|
+
| select(. != null)
|
|
176
|
+
) // empty
|
|
177
|
+
' <<<"$arr"
|
|
178
|
+
}
|
|
179
|
+
|
|
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.
|
|
115
200
|
# Print the appended JSON on stdout. Exit 2 on bad args, 1 on write fail.
|
|
116
201
|
clast_registry_add() {
|
|
117
|
-
local path="" slug=""
|
|
202
|
+
local path="" slug="" slug_explicit=0 label_raw="" label_explicit=0
|
|
203
|
+
local remote="" remote_explicit=0
|
|
118
204
|
while [[ $# -gt 0 ]]; do
|
|
119
205
|
case "$1" in
|
|
120
206
|
--slug)
|
|
@@ -122,8 +208,25 @@ clast_registry_add() {
|
|
|
122
208
|
clast_log_error "clast_registry_add: --slug requires a value"
|
|
123
209
|
return 2
|
|
124
210
|
fi
|
|
125
|
-
|
|
126
|
-
|
|
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 ;;
|
|
127
230
|
--remote)
|
|
128
231
|
if [[ $# -lt 2 ]]; then
|
|
129
232
|
clast_log_error "clast_registry_add: --remote requires a value"
|
|
@@ -178,30 +281,49 @@ clast_registry_add() {
|
|
|
178
281
|
fi
|
|
179
282
|
fi
|
|
180
283
|
|
|
181
|
-
#
|
|
182
|
-
|
|
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
|
|
183
302
|
slug="$(basename "$canon")"
|
|
184
303
|
fi
|
|
185
304
|
|
|
186
|
-
#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
[ .[] | select(.slug == $s) | [.path] + (.aliases // []) ]
|
|
198
|
-
| add // []
|
|
199
|
-
| map(select(. != $p))
|
|
200
|
-
| unique
|
|
201
|
-
' <<<"$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
|
|
202
316
|
fi
|
|
317
|
+
else
|
|
318
|
+
label="$(_clast_registry_slugify "$(basename "$(dirname "$canon")")")"
|
|
203
319
|
fi
|
|
204
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
|
+
|
|
205
327
|
local journal_dir
|
|
206
328
|
journal_dir="$(clast_journal_dir)"
|
|
207
329
|
if ! mkdir -p "$journal_dir"; then
|
|
@@ -216,10 +338,11 @@ clast_registry_add() {
|
|
|
216
338
|
line="$(jq -c -n \
|
|
217
339
|
--arg path "$canon" \
|
|
218
340
|
--arg slug "$slug" \
|
|
341
|
+
--arg label "$label" \
|
|
219
342
|
--arg remote "$remote" \
|
|
220
343
|
--arg first_seen "$first_seen" \
|
|
221
344
|
--argjson aliases "$aliases_json" \
|
|
222
|
-
'{path: $path, slug: $slug, remote: $remote, first_seen: $first_seen}
|
|
345
|
+
'{path: $path, slug: $slug, label: $label, remote: $remote, first_seen: $first_seen}
|
|
223
346
|
| with_entries(select(.value != null and .value != ""))
|
|
224
347
|
| . + {aliases: $aliases}')" || {
|
|
225
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
|
+
}
|