@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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# shellcheck shell=bash
|
|
7
7
|
# shellcheck source=lib/clast/clast-lib.bash
|
|
8
8
|
# shellcheck source=lib/clast/clast-manifest-lib.bash
|
|
9
|
+
# shellcheck source=lib/clast/clast-classify-lib.bash
|
|
9
10
|
# shellcheck source=lib/clast/clast-registry-lib.bash
|
|
10
11
|
# shellcheck source=lib/clast/clast-decode-lib.bash
|
|
11
12
|
# shellcheck source=lib/clast/clast-dismissed-lib.bash
|
|
@@ -14,11 +15,13 @@ _clast_sessions_usage() {
|
|
|
14
15
|
cat <<'EOF'
|
|
15
16
|
Usage: clast sessions [--day DATE] [--since DATE] [--until DATE] [--project SLUG]
|
|
16
17
|
clast sessions dismiss <session-id> [<session-id>...] [--reason TEXT]
|
|
18
|
+
clast sessions undismiss <session-id> [<session-id>...]
|
|
17
19
|
|
|
18
20
|
List sessions captured in a date window.
|
|
19
21
|
|
|
20
22
|
Subcommands:
|
|
21
23
|
dismiss ID... Mark session(s) as dismissed (excluded from future queries).
|
|
24
|
+
undismiss ID... Restore previously dismissed session(s).
|
|
22
25
|
|
|
23
26
|
Flags:
|
|
24
27
|
--day DATE Single-day window (default: today). Mutually exclusive
|
|
@@ -98,6 +101,54 @@ _clast_sessions_dismiss() {
|
|
|
98
101
|
fi
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
# _clast_sessions_undismiss <id> [<id>...]
|
|
105
|
+
# Reverse a dismissal so the session reappears in queries.
|
|
106
|
+
_clast_sessions_undismiss() {
|
|
107
|
+
source "$CLAST_LIB/clast-dismissed-lib.bash"
|
|
108
|
+
|
|
109
|
+
local -a ids=()
|
|
110
|
+
while [[ $# -gt 0 ]]; do
|
|
111
|
+
case "$1" in
|
|
112
|
+
-h|--help)
|
|
113
|
+
_clast_sessions_usage; return 0 ;;
|
|
114
|
+
-*)
|
|
115
|
+
_clast_sessions_err "undismiss: unknown flag '$1'"; return 2 ;;
|
|
116
|
+
*)
|
|
117
|
+
ids+=("$1"); shift ;;
|
|
118
|
+
esac
|
|
119
|
+
done
|
|
120
|
+
|
|
121
|
+
if [[ ${#ids[@]} -eq 0 ]]; then
|
|
122
|
+
_clast_sessions_err "undismiss requires at least one session ID"
|
|
123
|
+
return 2
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
local id count=0 rc
|
|
127
|
+
for id in "${ids[@]}"; do
|
|
128
|
+
if ! [[ "$id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
|
129
|
+
_clast_sessions_err "undismiss: '$id' is not a valid UUID"
|
|
130
|
+
return 2
|
|
131
|
+
fi
|
|
132
|
+
# `|| rc=$?` keeps the non-zero return from tripping `set -e` (the normal
|
|
133
|
+
# "not dismissed" path returns 1); rc stays 0 on success.
|
|
134
|
+
rc=0
|
|
135
|
+
clast_dismissed_remove "$id" >/dev/null || rc=$?
|
|
136
|
+
case "$rc" in
|
|
137
|
+
0) count=$(( count + 1 )) ;;
|
|
138
|
+
1) [[ -z "${CLAST_QUIET:-}" ]] && clast_log_info "not dismissed: $id" ;;
|
|
139
|
+
# Any other code is a real failure (temp file / rewrite / mv). Don't
|
|
140
|
+
# report success while the session is still dismissed — surface it.
|
|
141
|
+
*) _clast_sessions_err "undismiss: failed to restore $id"; return 1 ;;
|
|
142
|
+
esac
|
|
143
|
+
done
|
|
144
|
+
|
|
145
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
146
|
+
jq -cn --argjson count "$count" '{undismissed: $count}'
|
|
147
|
+
elif [[ -z "${CLAST_QUIET:-}" ]]; then
|
|
148
|
+
clast_log_info "Restored $count session(s)."
|
|
149
|
+
fi
|
|
150
|
+
}
|
|
151
|
+
|
|
101
152
|
clast_cmd_sessions() {
|
|
102
153
|
# Handle "sessions dismiss" sub-action before flag parsing.
|
|
103
154
|
if [[ "${1:-}" == "dismiss" ]]; then
|
|
@@ -106,6 +157,13 @@ clast_cmd_sessions() {
|
|
|
106
157
|
return $?
|
|
107
158
|
fi
|
|
108
159
|
|
|
160
|
+
# Handle "sessions undismiss" sub-action before flag parsing.
|
|
161
|
+
if [[ "${1:-}" == "undismiss" ]]; then
|
|
162
|
+
shift
|
|
163
|
+
_clast_sessions_undismiss "$@"
|
|
164
|
+
return $?
|
|
165
|
+
fi
|
|
166
|
+
|
|
109
167
|
local day_filter="" since_date="" until_date=""
|
|
110
168
|
local project_filter=""
|
|
111
169
|
local include_dismissed=0
|
|
@@ -207,32 +265,61 @@ clast_cmd_sessions() {
|
|
|
207
265
|
| jq -r --arg s "$project_filter" '.[] | select(.slug == $s) | .path')
|
|
208
266
|
fi
|
|
209
267
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
local line sid
|
|
213
|
-
while IFS= read -r line; do
|
|
214
|
-
[[ -z "$line" ]] && continue
|
|
215
|
-
sid="$(jq -r '.session_id' <<<"$line")"
|
|
216
|
-
[[ -z "$sid" || "$sid" == "null" ]] && continue
|
|
217
|
-
# Skip dismissed sessions early to avoid unnecessary work.
|
|
218
|
-
if [[ -n "${dismissed_ids[$sid]:-}" ]]; then
|
|
219
|
-
continue
|
|
220
|
-
fi
|
|
221
|
-
latest_line["$sid"]="$line"
|
|
222
|
-
done < <(clast_manifest_iterate "$filter")
|
|
268
|
+
local manifest_path
|
|
269
|
+
manifest_path="$(clast_manifest_path)"
|
|
223
270
|
|
|
224
|
-
local -a rows=()
|
|
225
|
-
local snapshot day_bucket mtime seg abs_path msgs
|
|
226
|
-
local first_ts last_ts start_ts end_ts curated branch slug
|
|
227
271
|
local entries_dir
|
|
228
272
|
entries_dir="$journal_dir/entries"
|
|
229
273
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
274
|
+
# Curated/stale index: scan every entry file ONCE up front instead of
|
|
275
|
+
# grepping the entries dir per session (the old approach was
|
|
276
|
+
# O(sessions × entries)). `curated_seen[sid]` marks a session as curated;
|
|
277
|
+
# `curated_mtime[sid]` holds the max curated_source_mtime across its
|
|
278
|
+
# entries ("" when none carry one — legacy entries then stay non-stale).
|
|
279
|
+
declare -A curated_seen=() curated_mtime=()
|
|
280
|
+
if [[ -d "$entries_dir" ]] && compgen -G "$entries_dir/*.md" >/dev/null 2>&1; then
|
|
281
|
+
local idx_sid idx_cm
|
|
282
|
+
while IFS=$'\t' read -r idx_sid idx_cm; do
|
|
283
|
+
[[ -z "$idx_sid" ]] && continue
|
|
284
|
+
curated_seen["$idx_sid"]=1
|
|
285
|
+
if [[ -n "$idx_cm" ]]; then
|
|
286
|
+
if [[ -z "${curated_mtime[$idx_sid]:-}" || "$idx_cm" > "${curated_mtime[$idx_sid]}" ]]; then
|
|
287
|
+
curated_mtime["$idx_sid"]="$idx_cm"
|
|
288
|
+
fi
|
|
289
|
+
fi
|
|
290
|
+
done < <(awk '
|
|
291
|
+
function flush() { if (sid != "") { print sid "\t" cm } sid=""; cm="" }
|
|
292
|
+
FNR==1 && NR>1 { flush() }
|
|
293
|
+
/^session_id:/ { v=$0; sub(/^session_id:[[:space:]]*/, "", v); sid=v }
|
|
294
|
+
/^curated_source_mtime:/ { v=$0; sub(/^curated_source_mtime:[[:space:]]*/, "", v); gsub(/"/, "", v); cm=v }
|
|
295
|
+
END { flush() }
|
|
296
|
+
' "$entries_dir"/*.md 2>/dev/null)
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
# Most-recent manifest line per session_id within window, in a SINGLE jq
|
|
300
|
+
# pass. Previously this forked one jq per manifest line plus several more
|
|
301
|
+
# per session; for large manifests that dominated runtime. The reduce keeps
|
|
302
|
+
# the last line seen per session_id (manifest is append-only, so last =
|
|
303
|
+
# most recent). Emits @tsv columns: session_id, snapshot, day_bucket,
|
|
304
|
+
# source_mtime.
|
|
305
|
+
local -a rows=()
|
|
306
|
+
local sid snapshot day_bucket mtime seg abs_path msgs
|
|
307
|
+
local cached_msgs cached_first cached_last cached_user cached_assistant
|
|
308
|
+
local user_count assistant_count substantive
|
|
309
|
+
local first_ts last_ts start_ts end_ts curated stale branch slug
|
|
310
|
+
declare -A seg_slug=()
|
|
311
|
+
|
|
312
|
+
while IFS=$'\t' read -r sid snapshot day_bucket mtime cached_msgs cached_first cached_last cached_user cached_assistant; do
|
|
313
|
+
[[ -z "$sid" ]] && continue
|
|
314
|
+
# Skip dismissed sessions early, before any per-session file work.
|
|
315
|
+
if [[ -n "${dismissed_ids[$sid]:-}" ]]; then
|
|
316
|
+
continue
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
# segment = third path component of transcripts/<day>/<segment>/<uuid>.
|
|
320
|
+
seg="${snapshot#transcripts/}" # <day>/<segment>/<uuid>.jsonl
|
|
321
|
+
seg="${seg#*/}" # <segment>/<uuid>.jsonl
|
|
322
|
+
seg="${seg%%/*}" # <segment>
|
|
236
323
|
|
|
237
324
|
if (( have_project_filter == 1 )); then
|
|
238
325
|
if [[ -z "${allowed_segs[$seg]:-}" ]]; then
|
|
@@ -241,7 +328,15 @@ clast_cmd_sessions() {
|
|
|
241
328
|
fi
|
|
242
329
|
|
|
243
330
|
abs_path="$journal_dir/$snapshot"
|
|
244
|
-
if [[ -
|
|
331
|
+
if [[ -n "$cached_msgs" ]]; then
|
|
332
|
+
# Cache hit (step 21): the manifest line carries msg_count/first_ts/
|
|
333
|
+
# last_ts, so we never open the transcript. Empty cached_first/last
|
|
334
|
+
# means the cached value was JSON null (no first/last timestamp).
|
|
335
|
+
msgs="$cached_msgs"
|
|
336
|
+
first_ts="$cached_first"
|
|
337
|
+
last_ts="$cached_last"
|
|
338
|
+
elif [[ -r "$abs_path" ]]; then
|
|
339
|
+
# Legacy line (pre-cache): fall back to reading the transcript.
|
|
245
340
|
msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
|
|
246
341
|
[[ -z "$msgs" ]] && msgs=0
|
|
247
342
|
first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
@@ -257,35 +352,64 @@ clast_cmd_sessions() {
|
|
|
257
352
|
start_ts="${first_ts:-$mtime}"
|
|
258
353
|
end_ts="${last_ts:-$mtime}"
|
|
259
354
|
|
|
260
|
-
|
|
261
|
-
|
|
355
|
+
# Session classification (clast-classify-lib.bash). Prefer the cached
|
|
356
|
+
# counts on the manifest line; recompute from the transcript for legacy
|
|
357
|
+
# lines that predate them. When neither is available (snapshot missing),
|
|
358
|
+
# leave the counts unknown and fall SAFE — substantive=true — so wake
|
|
359
|
+
# never auto-dismisses a session we couldn't classify. Note: a line may
|
|
360
|
+
# carry cached_msgs (step 21) yet lack these counts, so resolve them
|
|
361
|
+
# independently of the msg_count branch above.
|
|
362
|
+
if [[ -n "$cached_user" ]]; then
|
|
363
|
+
user_count="$cached_user"
|
|
364
|
+
assistant_count="$cached_assistant"
|
|
365
|
+
elif [[ -r "$abs_path" ]]; then
|
|
366
|
+
IFS=$'\t' read -r user_count assistant_count < <(clast_session_msg_counts "$abs_path")
|
|
367
|
+
[[ "$user_count" =~ ^[0-9]+$ ]] || user_count=""
|
|
368
|
+
[[ "$assistant_count" =~ ^[0-9]+$ ]] || assistant_count=""
|
|
369
|
+
else
|
|
370
|
+
user_count=""
|
|
371
|
+
assistant_count=""
|
|
372
|
+
fi
|
|
373
|
+
# substantive iff Claude actually produced a reply. assistant_msg_count==0
|
|
374
|
+
# captures BOTH no-op shapes the feature targets: empty / slash-command-only
|
|
375
|
+
# sessions (/clear, /model, /config) AND sessions where the user typed but
|
|
376
|
+
# quit before any response. Deliberately NOT gated on user_msg_count: a
|
|
377
|
+
# custom slash command (e.g. /review) leaves zero prose prompts yet drives
|
|
378
|
+
# real assistant work — those must be kept. Unknown count → SAFE (true).
|
|
379
|
+
if [[ "$assistant_count" =~ ^[0-9]+$ ]]; then
|
|
380
|
+
if (( assistant_count > 0 )); then
|
|
381
|
+
substantive=true
|
|
382
|
+
else
|
|
383
|
+
substantive=false
|
|
384
|
+
fi
|
|
385
|
+
else
|
|
386
|
+
substantive=true
|
|
387
|
+
fi
|
|
388
|
+
|
|
389
|
+
# Resolve the slug once per unique segment (segments repeat heavily
|
|
390
|
+
# across sessions; each resolve forks several jq calls).
|
|
391
|
+
if [[ -n "${seg_slug[$seg]+x}" ]]; then
|
|
392
|
+
slug="${seg_slug[$seg]}"
|
|
262
393
|
else
|
|
263
|
-
slug="$seg"
|
|
394
|
+
if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
|
|
395
|
+
:
|
|
396
|
+
else
|
|
397
|
+
slug="$seg"
|
|
398
|
+
fi
|
|
399
|
+
seg_slug["$seg"]="$slug"
|
|
264
400
|
fi
|
|
265
401
|
|
|
266
402
|
# TODO(step-10): branch field is best-effort; revisit when stats command lands.
|
|
267
403
|
branch=""
|
|
268
404
|
|
|
405
|
+
# Curated/stale from the prebuilt index (no per-session grep).
|
|
269
406
|
curated=false
|
|
270
|
-
|
|
271
|
-
if [[ -
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if [[ -n "$
|
|
275
|
-
|
|
276
|
-
local best_curated_mtime="" ef cm
|
|
277
|
-
while IFS= read -r ef; do
|
|
278
|
-
[[ -z "$ef" ]] && continue
|
|
279
|
-
cm="$(grep '^curated_source_mtime:' "$ef" 2>/dev/null | head -n1 | sed 's/^curated_source_mtime:[[:space:]]*//')" || true
|
|
280
|
-
cm="${cm#\"}"
|
|
281
|
-
cm="${cm%\"}"
|
|
282
|
-
if [[ -n "$cm" && ( -z "$best_curated_mtime" || "$cm" > "$best_curated_mtime" ) ]]; then
|
|
283
|
-
best_curated_mtime="$cm"
|
|
284
|
-
fi
|
|
285
|
-
done <<<"$entry_matches"
|
|
286
|
-
if [[ -n "$best_curated_mtime" && "$best_curated_mtime" != "$mtime" ]]; then
|
|
287
|
-
stale=true
|
|
288
|
-
fi
|
|
407
|
+
stale=false
|
|
408
|
+
if [[ -n "${curated_seen[$sid]:-}" ]]; then
|
|
409
|
+
curated=true
|
|
410
|
+
local best_curated_mtime="${curated_mtime[$sid]:-}"
|
|
411
|
+
if [[ -n "$best_curated_mtime" && "$best_curated_mtime" != "$mtime" ]]; then
|
|
412
|
+
stale=true
|
|
289
413
|
fi
|
|
290
414
|
fi
|
|
291
415
|
|
|
@@ -307,6 +431,9 @@ clast_cmd_sessions() {
|
|
|
307
431
|
--argjson curated "$curated" \
|
|
308
432
|
--argjson stale "$stale" \
|
|
309
433
|
--argjson dismissed "$is_dismissed" \
|
|
434
|
+
--arg user_msg_count "$user_count" \
|
|
435
|
+
--arg assistant_msg_count "$assistant_count" \
|
|
436
|
+
--argjson substantive "$substantive" \
|
|
310
437
|
'{
|
|
311
438
|
session_id: $session_id,
|
|
312
439
|
project: $project,
|
|
@@ -319,13 +446,31 @@ clast_cmd_sessions() {
|
|
|
319
446
|
day_bucket: $day_bucket,
|
|
320
447
|
curated: $curated,
|
|
321
448
|
stale: $stale,
|
|
322
|
-
dismissed: $dismissed
|
|
449
|
+
dismissed: $dismissed,
|
|
450
|
+
user_msg_count: (if $user_msg_count == "" then null else ($user_msg_count | tonumber) end),
|
|
451
|
+
assistant_msg_count: (if $assistant_msg_count == "" then null else ($assistant_msg_count | tonumber) end),
|
|
452
|
+
substantive: $substantive
|
|
323
453
|
}')")
|
|
324
|
-
done
|
|
454
|
+
done < <(
|
|
455
|
+
if [[ -f "$manifest_path" ]]; then
|
|
456
|
+
jq -rRn '
|
|
457
|
+
reduce (inputs | fromjson?
|
|
458
|
+
| select('"$filter"')
|
|
459
|
+
| select(.session_id != null and .session_id != "")) as $l
|
|
460
|
+
({}; .[$l.session_id] = $l)
|
|
461
|
+
| to_entries[] | .value
|
|
462
|
+
| [.session_id, .snapshot, .day_bucket, .source_mtime,
|
|
463
|
+
.msg_count, .first_ts, .last_ts,
|
|
464
|
+
.user_msg_count, .assistant_msg_count] | @tsv
|
|
465
|
+
' "$manifest_path"
|
|
466
|
+
fi
|
|
467
|
+
)
|
|
325
468
|
|
|
326
469
|
local rows_json='[]'
|
|
327
470
|
if (( ${#rows[@]} > 0 )); then
|
|
328
|
-
|
|
471
|
+
# Tiebreak on session_id so the order is deterministic when sessions
|
|
472
|
+
# share a start timestamp (the old assoc-array iteration order was not).
|
|
473
|
+
rows_json="$(printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.start, .session_id)')"
|
|
329
474
|
fi
|
|
330
475
|
|
|
331
476
|
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# shellcheck shell=bash
|
|
6
6
|
# shellcheck source=lib/clast/clast-lib.bash
|
|
7
7
|
# shellcheck source=lib/clast/clast-manifest-lib.bash
|
|
8
|
+
# shellcheck source=lib/clast/clast-classify-lib.bash
|
|
8
9
|
# shellcheck source=lib/clast/clast-registry-lib.bash
|
|
9
10
|
# shellcheck source=lib/clast/clast-decode-lib.bash
|
|
10
11
|
|
|
@@ -94,22 +95,54 @@ clast_cmd_show() {
|
|
|
94
95
|
slug="$seg"
|
|
95
96
|
fi
|
|
96
97
|
|
|
98
|
+
# Prefer the cached metadata on the manifest line (step 21); fall back to
|
|
99
|
+
# reading the transcript for legacy lines that predate the cache.
|
|
97
100
|
local msgs first_ts last_ts start_ts end_ts
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
local cached_msgs cached_first cached_last
|
|
102
|
+
IFS=$'\t' read -r cached_msgs cached_first cached_last \
|
|
103
|
+
< <(jq -r '[(.msg_count // ""), (.first_ts // ""), (.last_ts // "")] | @tsv' <<<"$line")
|
|
104
|
+
if [[ -n "$cached_msgs" ]]; then
|
|
105
|
+
msgs="$cached_msgs"
|
|
106
|
+
first_ts="$cached_first"
|
|
107
|
+
last_ts="$cached_last"
|
|
108
|
+
else
|
|
109
|
+
msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
|
|
110
|
+
[[ -z "$msgs" ]] && msgs=0
|
|
111
|
+
first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
112
|
+
last_ts="$(tail -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
113
|
+
fi
|
|
102
114
|
start_ts="${first_ts:-$source_mtime}"
|
|
103
115
|
end_ts="${last_ts:-$source_mtime}"
|
|
104
116
|
|
|
117
|
+
# Session classification (clast-classify-lib.bash): prefer cached counts on
|
|
118
|
+
# the manifest line, else recompute from the transcript (always readable
|
|
119
|
+
# here — the -r guard above already returned otherwise). substantive is true
|
|
120
|
+
# iff Claude produced at least one reply (assistant_msg_count > 0) — this is
|
|
121
|
+
# the no-op signal wake keys on; see sessions.bash for the full rationale.
|
|
122
|
+
local user_count assistant_count substantive
|
|
123
|
+
IFS=$'\t' read -r user_count assistant_count \
|
|
124
|
+
< <(jq -r '[(.user_msg_count // ""), (.assistant_msg_count // "")] | @tsv' <<<"$line")
|
|
125
|
+
if ! [[ "$user_count" =~ ^[0-9]+$ && "$assistant_count" =~ ^[0-9]+$ ]]; then
|
|
126
|
+
IFS=$'\t' read -r user_count assistant_count < <(clast_session_msg_counts "$abs_path")
|
|
127
|
+
[[ "$user_count" =~ ^[0-9]+$ ]] || user_count=0
|
|
128
|
+
[[ "$assistant_count" =~ ^[0-9]+$ ]] || assistant_count=0
|
|
129
|
+
fi
|
|
130
|
+
if (( assistant_count > 0 )); then
|
|
131
|
+
substantive=true
|
|
132
|
+
else
|
|
133
|
+
substantive=false
|
|
134
|
+
fi
|
|
135
|
+
|
|
105
136
|
# first_prompt / last_prompt — best-effort scan of user messages.
|
|
106
137
|
# These pipelines must never abort the command: malformed JSONL lines are
|
|
107
138
|
# routine, and the fields are explicitly documented as best-effort.
|
|
108
139
|
local first_prompt last_prompt user_msgs=""
|
|
109
140
|
user_msgs="$(_clast_show_user_messages "$abs_path" 2>/dev/null || true)"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
141
|
+
# Pure parameter expansion — no subshell/pipe, so a >64KB multi-line
|
|
142
|
+
# $user_msgs can never SIGPIPE a `printf | head` and abort under pipefail.
|
|
143
|
+
first_prompt="${user_msgs%%$'\n'*}" # text up to the first newline
|
|
144
|
+
last_prompt="${user_msgs##*$'\n'}" # text after the last newline
|
|
145
|
+
# If the stream was empty the expansions yield '' anyway — keep as ''.
|
|
113
146
|
[[ -z "$user_msgs" ]] && first_prompt="" && last_prompt=""
|
|
114
147
|
first_prompt="$(_clast_show_truncate "$first_prompt")"
|
|
115
148
|
last_prompt="$(_clast_show_truncate "$last_prompt")"
|
|
@@ -163,6 +196,9 @@ clast_cmd_show() {
|
|
|
163
196
|
--argjson curated "$curated" \
|
|
164
197
|
--arg first_prompt "$first_prompt" \
|
|
165
198
|
--arg last_prompt "$last_prompt" \
|
|
199
|
+
--argjson user_msg_count "$user_count" \
|
|
200
|
+
--argjson assistant_msg_count "$assistant_count" \
|
|
201
|
+
--argjson substantive "$substantive" \
|
|
166
202
|
'{
|
|
167
203
|
session_id: $session_id,
|
|
168
204
|
project: $project,
|
|
@@ -176,11 +212,19 @@ clast_cmd_show() {
|
|
|
176
212
|
day_bucket: $day_bucket,
|
|
177
213
|
curated: $curated,
|
|
178
214
|
first_prompt: (if $first_prompt == "" then null else $first_prompt end),
|
|
179
|
-
last_prompt: (if $last_prompt == "" then null else $last_prompt end)
|
|
215
|
+
last_prompt: (if $last_prompt == "" then null else $last_prompt end),
|
|
216
|
+
user_msg_count: $user_msg_count,
|
|
217
|
+
assistant_msg_count: $assistant_msg_count,
|
|
218
|
+
substantive: $substantive
|
|
180
219
|
}')"
|
|
181
220
|
if (( include_turns == 1 )); then
|
|
182
|
-
|
|
183
|
-
|
|
221
|
+
# Feed the (potentially multi-hundred-KB) turn arrays via stdin, not as
|
|
222
|
+
# --argjson argv: a single argument >128KB (MAX_ARG_STRLEN) fails with
|
|
223
|
+
# "Argument list too long" even when total ARG_MAX is far larger. printf
|
|
224
|
+
# is a builtin, so it has no argv size limit; jq reads three JSON values.
|
|
225
|
+
obj="$(printf '%s\n%s\n%s\n' "$obj" "$first_turns_json" "$last_turns_json" \
|
|
226
|
+
| jq -cn 'input as $o | input as $f | input as $l
|
|
227
|
+
| $o + {first_turns:$f, last_turns:$l}')"
|
|
184
228
|
fi
|
|
185
229
|
printf '%s\n' "$obj"
|
|
186
230
|
return 0
|
|
@@ -197,6 +241,13 @@ clast_cmd_show() {
|
|
|
197
241
|
printf 'duration: %s\n' "$duration_str"
|
|
198
242
|
fi
|
|
199
243
|
printf 'msg_count: %s (approx)\n' "$msgs"
|
|
244
|
+
printf 'user_msgs: %s\n' "$user_count"
|
|
245
|
+
printf 'assistant_msgs: %s\n' "$assistant_count"
|
|
246
|
+
if [[ "$substantive" == "true" ]]; then
|
|
247
|
+
printf 'substantive: yes\n'
|
|
248
|
+
else
|
|
249
|
+
printf 'substantive: no (no-op — empty / slash-command-only)\n'
|
|
250
|
+
fi
|
|
200
251
|
printf 'snapshot: %s\n' "$abs_path"
|
|
201
252
|
if [[ "$curated" == "true" ]]; then
|
|
202
253
|
printf 'curated: yes\n'
|
|
@@ -220,14 +271,20 @@ clast_cmd_show() {
|
|
|
220
271
|
}
|
|
221
272
|
|
|
222
273
|
# _clast_show_user_messages <abs_path>
|
|
223
|
-
# Stream user-
|
|
274
|
+
# Stream real user-prompt text content, one per line. Empty lines, meta
|
|
275
|
+
# messages, and slash-command wrappers (`/clear`, `/model`, …) are dropped
|
|
276
|
+
# so first_prompt/last_prompt reflect what the user actually typed — not a
|
|
277
|
+
# `<local-command-caveat>…` marker. Shares CLAST_COMMAND_MARKER_RE with the
|
|
278
|
+
# classifier (clast-classify-lib.bash) so the two can't drift.
|
|
224
279
|
_clast_show_user_messages() {
|
|
225
280
|
local path="$1"
|
|
226
|
-
jq -r '
|
|
281
|
+
jq -r --arg cmd_re "$CLAST_COMMAND_MARKER_RE" '
|
|
227
282
|
select((.role // .message.role // .type) == "user")
|
|
283
|
+
| select((.isMeta // false) != true)
|
|
228
284
|
| (.message.content // .content // empty)
|
|
229
285
|
| if type == "array" then map(.text? // "") | join(" ") else . end
|
|
230
286
|
| select(. != null and . != "")
|
|
287
|
+
| select(test($cmd_re) | not)
|
|
231
288
|
' "$path" 2>/dev/null
|
|
232
289
|
}
|
|
233
290
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
# shellcheck shell=bash
|
|
8
8
|
# shellcheck source=lib/clast/clast-lib.bash
|
|
9
9
|
# shellcheck source=lib/clast/clast-manifest-lib.bash
|
|
10
|
+
# shellcheck source=lib/clast/clast-classify-lib.bash
|
|
10
11
|
# shellcheck source=lib/clast/clast-decode-lib.bash
|
|
11
12
|
# shellcheck source=lib/clast/clast-registry-lib.bash
|
|
12
13
|
|
|
@@ -28,16 +29,10 @@ EOF
|
|
|
28
29
|
|
|
29
30
|
# _clast_snapshot_bucket_for_epoch <epoch>
|
|
30
31
|
# Mirror of clast_today's cutoff math against an arbitrary epoch. Local
|
|
31
|
-
# time per docs/explanation/conventions.md.
|
|
32
|
+
# time per docs/explanation/conventions.md. Thin alias over the shared
|
|
33
|
+
# primitive in clast-lib.bash.
|
|
32
34
|
_clast_snapshot_bucket_for_epoch() {
|
|
33
|
-
|
|
34
|
-
local cutoff="${CLAST_DAY_CUTOFF:-04:00}"
|
|
35
|
-
local h="${cutoff%%:*}" m="${cutoff##*:}"
|
|
36
|
-
h=$((10#$h))
|
|
37
|
-
m=$((10#$m))
|
|
38
|
-
local off=$((h * 3600 + m * 60))
|
|
39
|
-
# GNU `date -d` — BSD date not supported, per overview.md.
|
|
40
|
-
date -d "@$((epoch - off))" +%Y-%m-%d
|
|
35
|
+
clast_day_bucket_for_epoch "$1"
|
|
41
36
|
}
|
|
42
37
|
|
|
43
38
|
clast_cmd_snapshot() {
|
|
@@ -117,7 +112,8 @@ clast_cmd_snapshot() {
|
|
|
117
112
|
# TODO(v1.1): parallel capture across segments.
|
|
118
113
|
if [[ -d "$projects_dir" ]]; then
|
|
119
114
|
local source segment session_id mtime_epoch mtime_iso source_size
|
|
120
|
-
local first_ts ts_epoch day_bucket
|
|
115
|
+
local first_ts ts_epoch day_bucket last_ts msg_count
|
|
116
|
+
local user_msg_count assistant_msg_count
|
|
121
117
|
local dest_rel dest dest_dir tmp
|
|
122
118
|
while IFS= read -r -d '' source; do
|
|
123
119
|
segment="$(basename "$(dirname "$source")")"
|
|
@@ -163,6 +159,16 @@ clast_cmd_snapshot() {
|
|
|
163
159
|
day_bucket="$(_clast_snapshot_bucket_for_epoch "$mtime_epoch")"
|
|
164
160
|
fi
|
|
165
161
|
|
|
162
|
+
# Cache per-session metadata in the manifest (step 21) so readers
|
|
163
|
+
# don't re-open the transcript. msg_count uses wc -l semantics to
|
|
164
|
+
# match the readers' file-read fallback exactly; last_ts mirrors the
|
|
165
|
+
# existing first_ts extraction. Kept as separate reads (not a fused
|
|
166
|
+
# awk pass) to avoid the trailing-newline parsing hazard and because
|
|
167
|
+
# snapshot is a batch path where the extra forks are negligible.
|
|
168
|
+
last_ts="$(tail -n1 "$source" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
169
|
+
msg_count="$(wc -l <"$source" 2>/dev/null | tr -d ' ')"
|
|
170
|
+
[[ "$msg_count" =~ ^[0-9]+$ ]] || msg_count=0
|
|
171
|
+
|
|
166
172
|
# Dedup on (session_id, source_mtime). Mtime — not ctime or size —
|
|
167
173
|
# is the manifest's "most recent line wins" key; Claude Code's
|
|
168
174
|
# writer bumps mtime whenever a session grows.
|
|
@@ -171,6 +177,18 @@ clast_cmd_snapshot() {
|
|
|
171
177
|
continue
|
|
172
178
|
fi
|
|
173
179
|
|
|
180
|
+
# Session classification (clast-classify-lib.bash): count real user
|
|
181
|
+
# prompts and assistant replies so wake can auto-dismiss no-op sessions
|
|
182
|
+
# (empty / slash-command-only) without an LLM call. Cached on the
|
|
183
|
+
# manifest line alongside msg_count so readers never re-open the file.
|
|
184
|
+
# Computed AFTER the dedupe check: unlike the head/tail/wc reads above,
|
|
185
|
+
# this streams the whole transcript through jq, so we must not pay it for
|
|
186
|
+
# already-captured sessions skipped on every wake/hook run.
|
|
187
|
+
IFS=$'\t' read -r user_msg_count assistant_msg_count \
|
|
188
|
+
< <(clast_session_msg_counts "$source")
|
|
189
|
+
[[ "$user_msg_count" =~ ^[0-9]+$ ]] || user_msg_count=0
|
|
190
|
+
[[ "$assistant_msg_count" =~ ^[0-9]+$ ]] || assistant_msg_count=0
|
|
191
|
+
|
|
174
192
|
dest_rel="transcripts/$day_bucket/$segment/$session_id.jsonl"
|
|
175
193
|
dest="$journal_dir/$dest_rel"
|
|
176
194
|
|
|
@@ -199,7 +217,7 @@ clast_cmd_snapshot() {
|
|
|
199
217
|
# Invariant: a manifest line implies the dest file exists. If the
|
|
200
218
|
# append fails the dest is an orphan; doctor (step 10) will reap
|
|
201
219
|
# it, and a re-run of snapshot overwrites + retries the append.
|
|
202
|
-
if ! clast_manifest_append "$session_id" "$source" "$dest_rel" "$mtime_iso" "$source_size" "$day_bucket"; then
|
|
220
|
+
if ! clast_manifest_append "$session_id" "$source" "$dest_rel" "$mtime_iso" "$source_size" "$day_bucket" "$msg_count" "$first_ts" "$last_ts" "$user_msg_count" "$assistant_msg_count"; then
|
|
203
221
|
error_lines+=("$(jq -cn --arg f "$source" --arg r "manifest append failed" '{file:$f,reason:$r}')")
|
|
204
222
|
continue
|
|
205
223
|
fi
|
|
@@ -244,8 +244,13 @@ clast_cmd_stats() {
|
|
|
244
244
|
[[ "$size" =~ ^[0-9]+$ ]] || size=0
|
|
245
245
|
bytes_sum=$((bytes_sum + size))
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
# Prefer cached msg_count on the manifest line (step 21); fall back to
|
|
248
|
+
# counting transcript lines for legacy lines that predate the cache.
|
|
249
|
+
local abs="$journal_dir/$snapshot" m=0 cached_m
|
|
250
|
+
cached_m="$(jq -r '.msg_count // empty' <<<"$row")"
|
|
251
|
+
if [[ -n "$cached_m" ]]; then
|
|
252
|
+
m="$cached_m"
|
|
253
|
+
elif [[ -r "$abs" ]]; then
|
|
249
254
|
m="$(wc -l <"$abs" 2>/dev/null | tr -d ' ')"
|
|
250
255
|
[[ -z "$m" ]] && m=0
|
|
251
256
|
fi
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
You are synthesizing a project briefing for the user. They are about to start work on a project and want a tight summary of where they left off.
|
|
2
2
|
|
|
3
|
+
The recent entries may be grouped under `## Workspace: <label>` headers when
|
|
4
|
+
this project spans more than one directory (separate clones/worktrees of the
|
|
5
|
+
same repo). Treat each workspace as a distinct line of work — do NOT blend
|
|
6
|
+
them. When there are no workspace headers, the project is a single directory;
|
|
7
|
+
render exactly as the structure below.
|
|
8
|
+
|
|
3
9
|
Produce a briefing using this structure (omit any section that has no content):
|
|
4
10
|
|
|
5
11
|
## Wakeup briefing — {project}
|
|
6
12
|
|
|
7
|
-
**Active thread:** one-line from most recent entry's "Open threads", or "None"
|
|
13
|
+
**Active thread:** one-line from the most recent entry's "Open threads", or "None". When the entries are split across workspaces, pick the active thread from the **current workspace** (named in the user prompt) if it has any entries; otherwise from the most recent entry overall. State which workspace it came from, e.g. "(dev)".
|
|
8
14
|
|
|
9
|
-
**Last session:** date, branch, one-line goal
|
|
10
|
-
- Work done: 2-3 bullets condensed from most recent entry
|
|
15
|
+
**Last session:** date, workspace/branch, one-line goal
|
|
16
|
+
- Work done: 2-3 bullets condensed from the most recent entry
|
|
11
17
|
- Open threads: bullets, if any
|
|
12
18
|
- Dead ends to avoid: bullets, if any
|
|
13
19
|
|
|
14
|
-
**Recent sessions:** (up to
|
|
20
|
+
**Recent sessions:** (up to ~8 entries) — when multiple workspaces are present, group these under a short `**<label>:**` subheading per workspace, newest first within each:
|
|
15
21
|
- date [branch] slug: one-line goal
|
|
16
22
|
|
|
17
23
|
**Today's breadcrumbs:** (if any)
|
|
@@ -24,7 +30,7 @@ Produce a briefing using this structure (omit any section that has no content):
|
|
|
24
30
|
|
|
25
31
|
Be concise. Use the user's terminology. Don't repeat content across sections. The total briefing should be 2-5k tokens — if you're approaching that, summarize rather than list verbatim.
|
|
26
32
|
|
|
27
|
-
For the "Suggested next step": prefer the most recent entry's "Open threads" content, then the most recent breadcrumb, then a synthesis of the recent work. If nothing concrete, say "No active thread."
|
|
33
|
+
For the "Suggested next step": prefer the active thread's workspace — its most recent entry's "Open threads" content, then the most recent breadcrumb, then a synthesis of the recent work. If nothing concrete, say "No active thread."
|
|
28
34
|
|
|
29
35
|
End with one of:
|
|
30
36
|
- "Resume? Active thread: '<thread>'. Suggested next step: <step>."
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
Project: {{project}}
|
|
2
|
+
Current workspace (the directory you are in now): {{current_label}}
|
|
2
3
|
|
|
3
|
-
Recent curated entries (newest first)
|
|
4
|
+
Recent curated entries, grouped by workspace (newest first). When a project
|
|
5
|
+
spans more than one directory, entries appear under `## Workspace: <label>`
|
|
6
|
+
headers; a single-workspace project has no headers:
|
|
4
7
|
{{entries}}
|
|
5
8
|
|
|
6
9
|
Today's breadcrumbs for this project:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
You are condensing a single already-curated Claude Code session into a few tight retro bullets. The input is the body of a journal entry (or several merged entries for one session). Your output will appear in a work retrospective grouped by day and project, so it must be skimmable and factual.
|
|
2
|
+
|
|
3
|
+
Condense the session into at most 3-5 bullets, in this order, omitting any that have no content (do not write "N/A" or empty headers):
|
|
4
|
+
|
|
5
|
+
- **Shipped:** what actually got done — features, fixes, files, decisions. One bullet per distinct outcome.
|
|
6
|
+
- **Issues:** notable problems hit and how they were resolved (only if the body records them).
|
|
7
|
+
- **Open:** anything left unfinished, deferred, or flagged for next time.
|
|
8
|
+
|
|
9
|
+
Rules:
|
|
10
|
+
- Use only what the body states. Do not invent, infer motivation, or add detail that is not present.
|
|
11
|
+
- Keep the user's terminology — project names, file paths, branch names, ticket ids — verbatim.
|
|
12
|
+
- Be terse: bullets, not paragraphs. No preamble, no closing summary, no headers other than the bold lead-ins above.
|
|
13
|
+
- If the body is an interrupted session (a goal and open threads but nothing shipped), say so in one **Open:** bullet rather than overstating progress.
|
|
14
|
+
- Output the bullets only — no title line, no tags, no surrounding prose.
|
|
@@ -26,4 +26,4 @@ One sentence describing what this session was trying to accomplish.
|
|
|
26
26
|
Be concise. Prefer bullets over paragraphs. Use the user's terminology (project-specific names, file paths). Do not invent details. If you are uncertain about something, omit it rather than guess.
|
|
27
27
|
|
|
28
28
|
After the entry, add a blank line and then: "Suggested tags: tag1, tag2, tag3"
|
|
29
|
-
Tags must be lowercase kebab-case (regex: `^[a-z0-9][a-z0-9-]{0,31}$`). Examples: `adrs`, `symfony-bot`, `mr-umbrella`, `phase-0`. Never use uppercase letters.
|
|
29
|
+
Tags must be lowercase kebab-case (regex: `^[a-z0-9][a-z0-9-]{0,31}$`). Examples: `adrs`, `symfony-bot`, `mr-umbrella`, `phase-0`. Never use uppercase letters. Use hyphens, not dots or spaces, for separators — write versions like `php-8-5`, not `php-8.5`.
|