@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.
- package/README.md +9 -9
- package/bin/clast +13 -2
- package/bin/clast-plumbing +7 -0
- package/examples/workflows/morning-briefing.md +6 -6
- package/lib/clast/clast-classify-lib.bash +68 -0
- package/lib/clast/clast-dismissed-lib.bash +70 -0
- package/lib/clast/clast-lib.bash +88 -6
- package/lib/clast/clast-manifest-lib.bash +35 -5
- package/lib/clast/clast-porcelain-lib.bash +29 -16
- package/lib/clast/clast-porcelain-subcommands/brief.bash +90 -21
- package/lib/clast/clast-porcelain-subcommands/retro.bash +317 -0
- package/lib/clast/clast-porcelain-subcommands/undismiss.bash +27 -0
- package/lib/clast/clast-porcelain-subcommands/wake.bash +78 -13
- package/lib/clast/clast-registry-lib.bash +132 -27
- 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 +9 -19
- 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 +104 -4
- package/lib/clast/clast-subcommands/show.bash +54 -8
- package/lib/clast/clast-subcommands/snapshot.bash +18 -10
- 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 +2 -1
- package/{.claude-plugin/skills/wakeup → skills/brief}/SKILL.md +9 -9
- package/{.claude-plugin/skills/day-wakeup → skills/wake}/SKILL.md +35 -12
- /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
|
-
#
|
|
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
|
|
@@ -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
|
|
179
|
+
if line="$(clast_registry_line_for_path "$seg" 2>/dev/null)" && [[ -n "$line" ]]; then
|
|
176
180
|
registered=true
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
}
|