@procrastivity/clast 0.0.4 → 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 +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 +236 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# clast brief — LLM-powered project briefing.
|
|
2
2
|
#
|
|
3
|
-
# Replicates the /
|
|
3
|
+
# Replicates the /brief plugin skill using an OpenAI-compatible chat
|
|
4
4
|
# completions endpoint. Reads curated entries, breadcrumbs, and today's
|
|
5
5
|
# sessions for a project (via clast-plumbing), then synthesizes a briefing.
|
|
6
6
|
#
|
|
@@ -30,38 +30,94 @@ _clast_brief_resolve_project() {
|
|
|
30
30
|
|
|
31
31
|
# --- Gather data -------------------------------------------------------------
|
|
32
32
|
|
|
33
|
+
# Group key for an entry: per-directory label, else branch, else "default".
|
|
34
|
+
# A slug may span several directories; grouping keeps their work distinct
|
|
35
|
+
# instead of letting the single newest entry stand in for the whole project.
|
|
33
36
|
_clast_brief_gather_entries() {
|
|
34
|
-
local project="$1"
|
|
37
|
+
local project="$1" current_label="${2:-}"
|
|
38
|
+
|
|
39
|
+
# Pull the full entries list (no global cap). A global `--limit` here would
|
|
40
|
+
# let one busy clone with N newer entries push the current clone out of the
|
|
41
|
+
# window entirely before we ever see it — even though the per-group/total
|
|
42
|
+
# caps below already keep the prompt within the brief's token budget.
|
|
35
43
|
local entries_json
|
|
36
|
-
entries_json="$(clast-plumbing --json entries --project "$project"
|
|
44
|
+
entries_json="$(clast-plumbing --json entries --project "$project" 2>/dev/null)" || true
|
|
37
45
|
|
|
38
46
|
if [[ -z "$entries_json" ]] || [[ "$(jq 'length' <<<"$entries_json" 2>/dev/null)" == "0" ]]; then
|
|
39
47
|
return
|
|
40
48
|
fi
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
# Ordered, de-duplicated group keys in newest-first order of first
|
|
51
|
+
# appearance (entries list is already sorted newest-first). If a
|
|
52
|
+
# current_label was provided AND it appears in the entries, hoist it to
|
|
53
|
+
# the front so the active thread gets its share before total_cap is hit.
|
|
54
|
+
local -a groups=()
|
|
55
|
+
mapfile -t groups < <(jq -r --arg cur "$current_label" '
|
|
56
|
+
[ .[] | (.label // .branch // "default") ] as $keys
|
|
57
|
+
| (reduce $keys[] as $k ([]; if index($k) then . else . + [$k] end)) as $uniq
|
|
58
|
+
| (if $cur != "" and ($uniq | index($cur)) != null
|
|
59
|
+
then [$cur] + ($uniq - [$cur])
|
|
60
|
+
else $uniq end)
|
|
61
|
+
| .[]
|
|
62
|
+
' <<<"$entries_json")
|
|
63
|
+
|
|
64
|
+
# A single-workspace project renders exactly as before (no group headers),
|
|
65
|
+
# so this change is a strict superset for the common case.
|
|
66
|
+
local single_group=0
|
|
67
|
+
(( ${#groups[@]} <= 1 )) && single_group=1
|
|
68
|
+
|
|
69
|
+
# Per-group and total caps keep the prompt within the brief's token budget.
|
|
70
|
+
local per_group=3 total_cap=8 emitted=0
|
|
71
|
+
local result="" g
|
|
72
|
+
|
|
73
|
+
for g in "${groups[@]}"; do
|
|
74
|
+
(( emitted >= total_cap )) && break
|
|
75
|
+
|
|
76
|
+
local -a paths=()
|
|
77
|
+
mapfile -t paths < <(jq -r --arg g "$g" --argjson per "$per_group" '
|
|
78
|
+
[ .[] | select((.label // .branch // "default") == $g) ][0:$per] | .[].path
|
|
79
|
+
' <<<"$entries_json")
|
|
80
|
+
(( ${#paths[@]} == 0 )) && continue
|
|
81
|
+
|
|
82
|
+
# Build this group's body first, so we can skip the header if nothing reads.
|
|
83
|
+
local group_block="" p entry_body
|
|
84
|
+
for p in "${paths[@]}"; do
|
|
85
|
+
(( emitted >= total_cap )) && break
|
|
86
|
+
entry_body="$(clast-plumbing entries read "$p" 2>/dev/null)" || true
|
|
87
|
+
if [[ -z "$entry_body" ]]; then continue; fi
|
|
88
|
+
if [[ -n "$group_block" ]]; then
|
|
89
|
+
group_block="${group_block}
|
|
44
90
|
|
|
45
|
-
|
|
46
|
-
entry_meta="$(jq -c ".[$i]" <<<"$entries_json")"
|
|
47
|
-
entry_path="$(jq -r '.path' <<<"$entry_meta")"
|
|
91
|
+
---
|
|
48
92
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
93
|
+
"
|
|
94
|
+
fi
|
|
95
|
+
group_block="${group_block}${entry_body}"
|
|
96
|
+
emitted=$(( emitted + 1 ))
|
|
97
|
+
done
|
|
98
|
+
[[ -z "$group_block" ]] && continue
|
|
99
|
+
|
|
100
|
+
local block="$group_block"
|
|
101
|
+
if (( single_group == 0 )); then
|
|
102
|
+
local branch_disp
|
|
103
|
+
branch_disp="$(jq -r --arg g "$g" '
|
|
104
|
+
[ .[] | select((.label // .branch // "default") == $g) ][0].branch // ""
|
|
105
|
+
' <<<"$entries_json")"
|
|
106
|
+
local header="## Workspace: $g"
|
|
107
|
+
if [[ -n "$branch_disp" && "$branch_disp" != "null" ]]; then
|
|
108
|
+
header="$header (branch: $branch_disp)"
|
|
109
|
+
fi
|
|
110
|
+
block="${header}
|
|
111
|
+
|
|
112
|
+
${group_block}"
|
|
55
113
|
fi
|
|
56
114
|
|
|
57
115
|
if [[ -n "$result" ]]; then
|
|
58
116
|
result="${result}
|
|
59
117
|
|
|
60
|
-
---
|
|
61
|
-
|
|
62
118
|
"
|
|
63
119
|
fi
|
|
64
|
-
result="${result}${
|
|
120
|
+
result="${result}${block}"
|
|
65
121
|
done
|
|
66
122
|
|
|
67
123
|
printf '%s' "$result"
|
|
@@ -73,6 +129,9 @@ _clast_brief_gather_breadcrumbs() {
|
|
|
73
129
|
}
|
|
74
130
|
|
|
75
131
|
_clast_brief_gather_sessions() {
|
|
132
|
+
# NOTE: today's sessions come from the manifest, not curated entries, so
|
|
133
|
+
# they are not (yet) grouped by workspace label the way entries are. Per-
|
|
134
|
+
# directory session grouping is a possible follow-up.
|
|
76
135
|
local project="$1"
|
|
77
136
|
local sessions_json
|
|
78
137
|
sessions_json="$(clast-plumbing --json sessions --day today --project "$project" 2>/dev/null)" || true
|
|
@@ -100,7 +159,7 @@ _clast_brief_gather_sessions() {
|
|
|
100
159
|
# --- User prompt -------------------------------------------------------------
|
|
101
160
|
|
|
102
161
|
_clast_brief_build_user_prompt() {
|
|
103
|
-
local project="$1" entries="$2" breadcrumbs="$3" sessions="$4"
|
|
162
|
+
local project="$1" entries="$2" breadcrumbs="$3" sessions="$4" current_label="$5"
|
|
104
163
|
|
|
105
164
|
local template_file template
|
|
106
165
|
template_file="$(clast_porcelain_user_prompt_file brief-user)"
|
|
@@ -108,6 +167,7 @@ _clast_brief_build_user_prompt() {
|
|
|
108
167
|
if [[ -n "$template_file" ]]; then
|
|
109
168
|
template="$(cat "$template_file")"
|
|
110
169
|
template="${template//\{\{project\}\}/${project}}"
|
|
170
|
+
template="${template//\{\{current_label\}\}/${current_label:-unknown}}"
|
|
111
171
|
template="${template//\{\{entries\}\}/${entries:-None.}}"
|
|
112
172
|
template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
|
|
113
173
|
template="${template//\{\{sessions\}\}/${sessions:-None.}}"
|
|
@@ -116,8 +176,9 @@ _clast_brief_build_user_prompt() {
|
|
|
116
176
|
clast_porcelain_warn "user prompt template not found: brief-user.md — using inline fallback"
|
|
117
177
|
cat <<EOF
|
|
118
178
|
Project: ${project}
|
|
179
|
+
Current workspace (the directory you are in now): ${current_label:-unknown}
|
|
119
180
|
|
|
120
|
-
Recent curated entries (newest first):
|
|
181
|
+
Recent curated entries, grouped by workspace (newest first):
|
|
121
182
|
${entries:-None.}
|
|
122
183
|
|
|
123
184
|
Today's breadcrumbs for this project:
|
|
@@ -138,10 +199,18 @@ clast_cmd_brief() {
|
|
|
138
199
|
project="$(_clast_brief_resolve_project "${1:-}")"
|
|
139
200
|
clast_porcelain_info "Briefing for project: $project"
|
|
140
201
|
|
|
202
|
+
# When the project was resolved from the current directory (no positional
|
|
203
|
+
# slug), find that directory's workspace label so the briefing can prefer
|
|
204
|
+
# the active thread for the clone the user is actually in.
|
|
205
|
+
local current_label=""
|
|
206
|
+
if [[ -z "${1:-}" ]]; then
|
|
207
|
+
current_label="$(clast-plumbing registry resolve "$(pwd)" --json 2>/dev/null | jq -r '.label // empty' 2>/dev/null)" || true
|
|
208
|
+
fi
|
|
209
|
+
|
|
141
210
|
clast_porcelain_info "Gathering context..."
|
|
142
211
|
|
|
143
212
|
local entries breadcrumbs sessions
|
|
144
|
-
entries="$(_clast_brief_gather_entries "$project")"
|
|
213
|
+
entries="$(_clast_brief_gather_entries "$project" "$current_label")"
|
|
145
214
|
breadcrumbs="$(_clast_brief_gather_breadcrumbs "$project")"
|
|
146
215
|
sessions="$(_clast_brief_gather_sessions "$project")"
|
|
147
216
|
|
|
@@ -153,7 +222,7 @@ clast_cmd_brief() {
|
|
|
153
222
|
|
|
154
223
|
local system_prompt user_prompt
|
|
155
224
|
system_prompt="$(clast_porcelain_load_system_prompt brief-system)"
|
|
156
|
-
user_prompt="$(_clast_brief_build_user_prompt "$project" "$entries" "$breadcrumbs" "$sessions")"
|
|
225
|
+
user_prompt="$(_clast_brief_build_user_prompt "$project" "$entries" "$breadcrumbs" "$sessions" "$current_label")"
|
|
157
226
|
|
|
158
227
|
clast_porcelain_info "Synthesizing briefing..."
|
|
159
228
|
printf '\n'
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# clast retro — LLM-condensed work retrospective grouped by work day → project.
|
|
2
|
+
#
|
|
3
|
+
# Round 2 of the retro feature. Structure comes from the deterministic core
|
|
4
|
+
# (`clast-plumbing retro --json --bodies`); the model only condenses each
|
|
5
|
+
# session's body into a few retro bullets. Summaries are cached per session_id
|
|
6
|
+
# (content-fingerprinted) under the journal dir, so re-runs are free unless a
|
|
7
|
+
# session changed or --refresh is passed. See .wip/initiatives/clast-retro/.
|
|
8
|
+
# shellcheck shell=bash
|
|
9
|
+
|
|
10
|
+
_clast_retrosum_usage() {
|
|
11
|
+
cat <<'EOF'
|
|
12
|
+
Usage: clast retro [--from DATE] [--to DATE] [--window work-days|file-dates]
|
|
13
|
+
[--refresh] [--json]
|
|
14
|
+
|
|
15
|
+
Condense the work retrospective (grouped by actual work day → project) into
|
|
16
|
+
model-written bullets per session. Structure is deterministic; only the prose
|
|
17
|
+
condensation calls the LLM. Summaries are cached per session under
|
|
18
|
+
<journal>/.retro-summaries/ and reused until the session content changes.
|
|
19
|
+
|
|
20
|
+
Flags:
|
|
21
|
+
--from DATE Start of the window (inclusive). Default: corpus start.
|
|
22
|
+
--to DATE End of the window (inclusive). Default: corpus end.
|
|
23
|
+
--window WHICH work-days (default) | file-dates. See `clast-plumbing retro`.
|
|
24
|
+
--refresh Ignore cached summaries and re-summarize (rewrites the cache).
|
|
25
|
+
--json Emit the manifest with a `summary` per session (no render).
|
|
26
|
+
-h, --help Print this usage and exit.
|
|
27
|
+
|
|
28
|
+
Requires the CLAST_LLM_* env vars (see `clast --help`).
|
|
29
|
+
EOF
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# _clast_retrosum_fingerprint (stdin → short hex/cksum on stdout)
|
|
33
|
+
# Content fingerprint of a session body; changes invalidate the cache.
|
|
34
|
+
_clast_retrosum_fingerprint() {
|
|
35
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
36
|
+
sha256sum | cut -c1-16
|
|
37
|
+
else
|
|
38
|
+
cksum | awk '{print $1}'
|
|
39
|
+
fi
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# _clast_retrosum_build_user <project> <work_day> <session_id> <body>
|
|
43
|
+
_clast_retrosum_build_user() {
|
|
44
|
+
local project="$1" work_day="$2" sid="$3" body="$4"
|
|
45
|
+
local tf tpl
|
|
46
|
+
tf="$(clast_porcelain_user_prompt_file retro-summary-user)"
|
|
47
|
+
if [[ -n "$tf" ]]; then
|
|
48
|
+
tpl="$(cat "$tf")"
|
|
49
|
+
tpl="${tpl//\{\{project\}\}/$project}"
|
|
50
|
+
tpl="${tpl//\{\{work_day\}\}/$work_day}"
|
|
51
|
+
tpl="${tpl//\{\{session_id\}\}/$sid}"
|
|
52
|
+
tpl="${tpl//\{\{body\}\}/$body}"
|
|
53
|
+
printf '%s' "$tpl"
|
|
54
|
+
else
|
|
55
|
+
printf 'Project: %s\nWork day: %s\nSession id: %s\n\nEntry body:\n%s\n' \
|
|
56
|
+
"$project" "$work_day" "$sid" "$body"
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# _clast_retrosum_summary <project> <work_day> <session_id> <body> <cache_dir> <refresh>
|
|
61
|
+
# Echo the condensed summary. Cache hit (matching fingerprint) reuses; miss
|
|
62
|
+
# calls the LLM and writes the cache. Returns nonzero on LLM failure.
|
|
63
|
+
_clast_retrosum_summary() {
|
|
64
|
+
local project="$1" work_day="$2" sid="$3" body="$4" cache_dir="$5" refresh="$6"
|
|
65
|
+
local fp cache_file cached_fp
|
|
66
|
+
fp="$(printf '%s' "$body" | _clast_retrosum_fingerprint)"
|
|
67
|
+
cache_file="$cache_dir/$sid.json"
|
|
68
|
+
|
|
69
|
+
if (( ! refresh )) && [[ -r "$cache_file" ]]; then
|
|
70
|
+
cached_fp="$(jq -r '.fingerprint // empty' "$cache_file" 2>/dev/null)"
|
|
71
|
+
if [[ -n "$cached_fp" && "$cached_fp" == "$fp" ]]; then
|
|
72
|
+
jq -r '.summary // empty' "$cache_file"
|
|
73
|
+
return 0
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
local system user summary
|
|
78
|
+
system="$(clast_porcelain_load_system_prompt retro-summary-system)"
|
|
79
|
+
user="$(_clast_retrosum_build_user "$project" "$work_day" "$sid" "$body")"
|
|
80
|
+
if ! summary="$(clast_porcelain_llm_chat "$system" "$user")"; then
|
|
81
|
+
return 1
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
if mkdir -p "$cache_dir" 2>/dev/null; then
|
|
85
|
+
jq -n --arg fp "$fp" --arg s "$summary" '{fingerprint:$fp, summary:$s}' \
|
|
86
|
+
>"$cache_file" 2>/dev/null || clast_porcelain_warn "failed to cache summary for $sid"
|
|
87
|
+
fi
|
|
88
|
+
printf '%s' "$summary"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# _clast_retrosum_journal_dir — resolve the journal dir (for the cache).
|
|
92
|
+
_clast_retrosum_journal_dir() {
|
|
93
|
+
if [[ -n "${CLAST_JOURNAL_DIR:-}" ]]; then
|
|
94
|
+
printf '%s' "$CLAST_JOURNAL_DIR"
|
|
95
|
+
return
|
|
96
|
+
fi
|
|
97
|
+
local jd
|
|
98
|
+
jd="$(clast-plumbing whereami 2>/dev/null | awk '/^journal_dir:/{print $2}')" || true
|
|
99
|
+
printf '%s' "${jd:-$HOME/.claude/journal}"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
clast_cmd_retro() {
|
|
103
|
+
local from="" to="" window="work-days" refresh=0 as_json=0
|
|
104
|
+
|
|
105
|
+
while [[ $# -gt 0 ]]; do
|
|
106
|
+
case "$1" in
|
|
107
|
+
--from) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --from requires a value"; return 2; }; from="$2"; shift 2 ;;
|
|
108
|
+
--from=*) from="${1#*=}"; shift ;;
|
|
109
|
+
--to) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --to requires a value"; return 2; }; to="$2"; shift 2 ;;
|
|
110
|
+
--to=*) to="${1#*=}"; shift ;;
|
|
111
|
+
--window) [[ $# -lt 2 ]] && { clast_porcelain_log_error "retro: --window requires a value"; return 2; }; window="$2"; shift 2 ;;
|
|
112
|
+
--window=*) window="${1#*=}"; shift ;;
|
|
113
|
+
--refresh) refresh=1; shift ;;
|
|
114
|
+
--json) as_json=1; shift ;;
|
|
115
|
+
-h|--help) _clast_retrosum_usage; return 0 ;;
|
|
116
|
+
--) shift; break ;;
|
|
117
|
+
*) clast_porcelain_log_error "retro: unknown argument '$1'"; return 2 ;;
|
|
118
|
+
esac
|
|
119
|
+
done
|
|
120
|
+
|
|
121
|
+
clast_porcelain_preflight_llm
|
|
122
|
+
|
|
123
|
+
# Structure + per-session bodies from the deterministic core.
|
|
124
|
+
local -a pl=(--json retro --bodies --window "$window")
|
|
125
|
+
[[ -n "$from" ]] && pl+=(--from "$from")
|
|
126
|
+
[[ -n "$to" ]] && pl+=(--to "$to")
|
|
127
|
+
local manifest
|
|
128
|
+
if ! manifest="$(clast-plumbing "${pl[@]}" 2>/dev/null)"; then
|
|
129
|
+
local msg
|
|
130
|
+
msg="$(jq -r '.error // empty' <<<"$manifest" 2>/dev/null || true)"
|
|
131
|
+
clast_porcelain_die "retro: ${msg:-failed to build manifest}" 2
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
local cache_dir
|
|
135
|
+
cache_dir="$(_clast_retrosum_journal_dir)/.retro-summaries"
|
|
136
|
+
|
|
137
|
+
# Summarize each session; collect key → summary.
|
|
138
|
+
local -a summary_pairs=()
|
|
139
|
+
local sess sid key cache_id project work_day body title summary
|
|
140
|
+
while IFS= read -r sess; do
|
|
141
|
+
[[ -z "$sess" ]] && continue
|
|
142
|
+
sid="$(jq -r '.session_id // ""' <<<"$sess")"
|
|
143
|
+
# Fold key: session_id, or the unique first entry path for id-less sessions
|
|
144
|
+
# (must match the plumbing manifest's key so summaries fold back correctly
|
|
145
|
+
# and two id-less sessions don't collide on a shared "null" key).
|
|
146
|
+
key="$(jq -r '.session_id // .entries[0]' <<<"$sess")"
|
|
147
|
+
# Cache filename must be unique and filesystem-safe even without an id.
|
|
148
|
+
if [[ -n "$sid" ]]; then
|
|
149
|
+
cache_id="$sid"
|
|
150
|
+
else
|
|
151
|
+
cache_id="noid-$(printf '%s' "$key" | _clast_retrosum_fingerprint)"
|
|
152
|
+
fi
|
|
153
|
+
work_day="$(jq -r '.work_day' <<<"$sess")"
|
|
154
|
+
body="$(jq -r '.body // ""' <<<"$sess")"
|
|
155
|
+
project="$(jq -r '.project_path // "(no project)"' <<<"$sess")"
|
|
156
|
+
if [[ -z "$body" ]]; then
|
|
157
|
+
summary="(no body to summarize)"
|
|
158
|
+
elif ! summary="$(_clast_retrosum_summary "$project" "$work_day" "$cache_id" "$body" "$cache_dir" "$refresh")"; then
|
|
159
|
+
clast_porcelain_warn "summary failed for session ${sid:-<no id>} — leaving it unsummarized"
|
|
160
|
+
summary="(summary unavailable)"
|
|
161
|
+
fi
|
|
162
|
+
summary_pairs+=("$(jq -cn --arg k "$key" --arg v "$summary" '{key:$k, summary:$v}')")
|
|
163
|
+
done < <(jq -c '.days[].projects[].sessions[]' <<<"$manifest")
|
|
164
|
+
|
|
165
|
+
# Fold summaries back into the manifest and drop the raw bodies. The manifest
|
|
166
|
+
# carries --bodies only as summarizer input; neither the JSON nor the render
|
|
167
|
+
# path consumes .body, so strip it here to keep --json condensed and
|
|
168
|
+
# consistent with the plumbing's lean-by-default output (raw bodies remain
|
|
169
|
+
# available via `clast-plumbing --json retro --bodies`). The manifest can
|
|
170
|
+
# exceed MAX_ARG_STRLEN — feed it via stdin and the summary pairs via
|
|
171
|
+
# --slurpfile, never argv.
|
|
172
|
+
local enriched="$manifest"
|
|
173
|
+
if (( ${#summary_pairs[@]} > 0 )); then
|
|
174
|
+
# Key by `session_id // .entries[0]` (always a non-null string), matching
|
|
175
|
+
# the pairs above: this can't abort jq on a null id and keeps two id-less
|
|
176
|
+
# sessions distinct instead of collapsing them onto a shared key.
|
|
177
|
+
enriched="$(printf '%s' "$manifest" | jq -c --slurpfile pairs <(printf '%s\n' "${summary_pairs[@]}") '
|
|
178
|
+
(reduce $pairs[] as $p ({}; .[$p.key] = $p.summary)) as $sum
|
|
179
|
+
| .days |= map(.projects |= map(.sessions |= map(
|
|
180
|
+
del(.body) + {summary: ($sum[(.session_id // .entries[0])] // null)})))
|
|
181
|
+
')"
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
if (( as_json )); then
|
|
185
|
+
printf '%s\n' "$enriched"
|
|
186
|
+
return 0
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
_clast_retrosum_render "$enriched"
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# _clast_retrosum_render <manifest-with-summaries>
|
|
193
|
+
_clast_retrosum_render() {
|
|
194
|
+
local manifest="$1"
|
|
195
|
+
local from to window
|
|
196
|
+
from="$(jq -r '.from // "(start)"' <<<"$manifest")"
|
|
197
|
+
to="$(jq -r '.to // "(end)"' <<<"$manifest")"
|
|
198
|
+
window="$(jq -r '.window' <<<"$manifest")"
|
|
199
|
+
printf 'Retro: %s -> %s (%s)\n' "$from" "$to" "$window"
|
|
200
|
+
|
|
201
|
+
local nd
|
|
202
|
+
nd="$(jq '.days | length' <<<"$manifest")"
|
|
203
|
+
if (( nd == 0 )); then
|
|
204
|
+
printf '\n(no sessions in range)\n'
|
|
205
|
+
return 0
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
local di pj si day np project ns sess sid shortsid title summary note flag
|
|
209
|
+
for (( di = 0; di < nd; di++ )); do
|
|
210
|
+
day="$(jq -r ".days[$di].day" <<<"$manifest")"
|
|
211
|
+
printf '\n== %s ==\n' "$day"
|
|
212
|
+
note="$(jq -r --arg d "$day" '.days['"$di"'].curation_dates // [] |
|
|
213
|
+
if . == [] or . == [$d] then empty
|
|
214
|
+
else " (filed " + (join(", ")) + "; work day reconstructed from session snapshots)"
|
|
215
|
+
end' <<<"$manifest")"
|
|
216
|
+
[[ -n "$note" ]] && printf '%s\n' "$note"
|
|
217
|
+
np="$(jq ".days[$di].projects | length" <<<"$manifest")"
|
|
218
|
+
for (( pj = 0; pj < np; pj++ )); do
|
|
219
|
+
project="$(jq -r ".days[$di].projects[$pj].project_name // .days[$di].projects[$pj].project_path // \"(no project)\"" <<<"$manifest")"
|
|
220
|
+
printf '\n[%s]\n' "$project"
|
|
221
|
+
ns="$(jq ".days[$di].projects[$pj].sessions | length" <<<"$manifest")"
|
|
222
|
+
for (( si = 0; si < ns; si++ )); do
|
|
223
|
+
sess="$(jq -c ".days[$di].projects[$pj].sessions[$si]" <<<"$manifest")"
|
|
224
|
+
sid="$(jq -r '.session_id' <<<"$sess")"
|
|
225
|
+
shortsid="${sid:0:8}"
|
|
226
|
+
title="$(jq -r '.title // "(untitled)"' <<<"$sess")"
|
|
227
|
+
[[ -z "$title" || "$title" == "null" ]] && title="(untitled)"
|
|
228
|
+
summary="$(jq -r '.summary // "(no summary)"' <<<"$sess")"
|
|
229
|
+
flag=""
|
|
230
|
+
[[ "$(jq -r '.interrupted // false' <<<"$sess")" == "true" ]] && flag=" [interrupted]"
|
|
231
|
+
printf '\n * %s (%s)%s\n' "$title" "$shortsid" "$flag"
|
|
232
|
+
printf '%s\n' "$summary"
|
|
233
|
+
done
|
|
234
|
+
done
|
|
235
|
+
done
|
|
236
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# clast undismiss — restore session(s) dismissed during `clast wake`.
|
|
2
|
+
#
|
|
3
|
+
# A convenience verb over `clast-plumbing sessions undismiss`: `clast wake`
|
|
4
|
+
# now prints each session's id, so recovering an accidental [d] is a direct
|
|
5
|
+
# `clast undismiss <id>` without dropping down to the plumbing surface.
|
|
6
|
+
# shellcheck shell=bash
|
|
7
|
+
|
|
8
|
+
clast_cmd_undismiss() {
|
|
9
|
+
case "${1:-}" in
|
|
10
|
+
-h|--help)
|
|
11
|
+
cat <<'EOF'
|
|
12
|
+
Usage: clast undismiss <session-id> [<session-id>...]
|
|
13
|
+
|
|
14
|
+
Restore session(s) previously dismissed (e.g. an accidental [d] in
|
|
15
|
+
`clast wake`). Session ids are shown in the `clast wake` review header.
|
|
16
|
+
EOF
|
|
17
|
+
return 0
|
|
18
|
+
;;
|
|
19
|
+
esac
|
|
20
|
+
|
|
21
|
+
if ! command -v clast-plumbing >/dev/null 2>&1; then
|
|
22
|
+
clast_porcelain_die "required tool not found: clast-plumbing"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# Thin passthrough: plumbing owns UUID validation and JSON/human output.
|
|
26
|
+
clast-plumbing sessions undismiss "$@"
|
|
27
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# clast wake — LLM-powered interactive day curation.
|
|
2
2
|
#
|
|
3
|
-
# Replicates the /
|
|
3
|
+
# Replicates the /wake plugin skill using an OpenAI-compatible chat
|
|
4
4
|
# completions endpoint. Calls clast-plumbing for data, assembles prompts,
|
|
5
5
|
# calls the LLM via curl, presents drafts interactively.
|
|
6
6
|
#
|
|
@@ -43,7 +43,7 @@ _clast_wake_build_user_prompt() {
|
|
|
43
43
|
local first_turns="$6" last_turns="$7" breadcrumbs="$8"
|
|
44
44
|
|
|
45
45
|
local template_file template
|
|
46
|
-
template_file="$(clast_porcelain_user_prompt_file
|
|
46
|
+
template_file="$(clast_porcelain_user_prompt_file wake-draft-user)"
|
|
47
47
|
|
|
48
48
|
if [[ -n "$template_file" ]]; then
|
|
49
49
|
template="$(cat "$template_file")"
|
|
@@ -57,7 +57,7 @@ _clast_wake_build_user_prompt() {
|
|
|
57
57
|
template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
|
|
58
58
|
printf '%s' "$template"
|
|
59
59
|
else
|
|
60
|
-
clast_porcelain_warn "user prompt template not found:
|
|
60
|
+
clast_porcelain_warn "user prompt template not found: wake-draft-user.md — using inline fallback"
|
|
61
61
|
cat <<EOF
|
|
62
62
|
Session metadata:
|
|
63
63
|
- Project: ${project}
|
|
@@ -255,6 +255,34 @@ clast_cmd_wake() {
|
|
|
255
255
|
clast_porcelain_die "failed to list sessions"
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
# Auto-dismiss no-op sessions before any LLM work: sessions where Claude
|
|
259
|
+
# never replied (substantive == false) — empty sessions, slash-command-only
|
|
260
|
+
# sessions (/clear, /model, /config), and sessions abandoned before any
|
|
261
|
+
# response. This is a deterministic pre-filter — the LLM is never called for
|
|
262
|
+
# them. Sessions driven by a custom slash command still have assistant
|
|
263
|
+
# replies, so they are kept. Reversible via `clast undismiss <id>`. Opt out
|
|
264
|
+
# by setting CLAST_WAKE_AUTODISMISS_NOOP=0.
|
|
265
|
+
local auto_dismissed_count=0
|
|
266
|
+
if [[ "${CLAST_WAKE_AUTODISMISS_NOOP:-1}" != "0" ]]; then
|
|
267
|
+
local noop_ids noop_id
|
|
268
|
+
noop_ids="$(jq -r '
|
|
269
|
+
.[] | select(.substantive == false and .curated == false and .dismissed == false)
|
|
270
|
+
| .session_id
|
|
271
|
+
' <<<"$sessions_json")"
|
|
272
|
+
while IFS= read -r noop_id; do
|
|
273
|
+
[[ -z "$noop_id" ]] && continue
|
|
274
|
+
if clast-plumbing sessions dismiss "$noop_id" \
|
|
275
|
+
--reason "auto: no substantive content (empty / slash-command-only)" >/dev/null 2>&1; then
|
|
276
|
+
auto_dismissed_count=$(( auto_dismissed_count + 1 ))
|
|
277
|
+
fi
|
|
278
|
+
done <<<"$noop_ids"
|
|
279
|
+
if (( auto_dismissed_count > 0 )); then
|
|
280
|
+
clast_porcelain_info "Auto-dismissed $auto_dismissed_count no-op session(s) (empty / slash-command-only)."
|
|
281
|
+
# Drop the just-dismissed rows from the working set so they don't reappear.
|
|
282
|
+
sessions_json="$(jq -c '[.[] | select(.substantive != false or .curated == true)]' <<<"$sessions_json")"
|
|
283
|
+
fi
|
|
284
|
+
fi
|
|
285
|
+
|
|
258
286
|
local uncurated
|
|
259
287
|
uncurated="$(jq -c '[.[] | select(.curated == false or .stale == true)]' <<<"$sessions_json")"
|
|
260
288
|
local total
|
|
@@ -301,37 +329,71 @@ clast_cmd_wake() {
|
|
|
301
329
|
msg_count="$(jq -r '.msg_count_approx' <<<"$session")"
|
|
302
330
|
snapshot_path="$(jq -r '.snapshot_path' <<<"$session")"
|
|
303
331
|
|
|
304
|
-
|
|
305
|
-
|
|
332
|
+
# Recorded date + time range so the reviewer can tell which day's work
|
|
333
|
+
# this is (BDS-54). Render the session's own start/end instant in the
|
|
334
|
+
# local timezone. Deliberately NOT day_bucket: that is clast's
|
|
335
|
+
# cutoff-adjusted *filing* day, which differs from the instant's calendar
|
|
336
|
+
# date for pre-cutoff sessions — pairing it with a clock time (and a "UTC"
|
|
337
|
+
# label) yielded a timestamp wrong by a day. Fall back to the raw UTC
|
|
338
|
+
# substrings if `date` can't parse the timestamp.
|
|
339
|
+
local rec_date start_short end_short tz
|
|
340
|
+
rec_date="$(date -d "$start_ts" +%Y-%m-%d 2>/dev/null)" || rec_date=""
|
|
341
|
+
[[ -z "$rec_date" ]] && rec_date="${start_ts:0:10}"
|
|
342
|
+
start_short="$(date -d "$start_ts" +%H:%M 2>/dev/null)" || start_short=""
|
|
343
|
+
[[ -z "$start_short" ]] && start_short="${start_ts:11:5}"
|
|
344
|
+
end_short="$(date -d "$end_ts" +%H:%M 2>/dev/null)" || end_short=""
|
|
345
|
+
[[ -z "$end_short" ]] && end_short="${end_ts:11:5}"
|
|
346
|
+
tz="$(date -d "$start_ts" +%Z 2>/dev/null)" || tz="UTC"
|
|
347
|
+
[[ -z "$tz" ]] && tz="UTC"
|
|
348
|
+
|
|
349
|
+
local recorded="$rec_date"
|
|
350
|
+
if [[ -n "$start_short" ]]; then
|
|
351
|
+
recorded="$recorded $start_short"
|
|
352
|
+
[[ -n "$end_short" && "$end_short" != "$start_short" ]] && recorded="$recorded–$end_short"
|
|
353
|
+
recorded="$recorded $tz"
|
|
354
|
+
fi
|
|
306
355
|
|
|
307
356
|
local is_stale
|
|
308
357
|
is_stale="$(jq -r '.stale // false' <<<"$session")"
|
|
309
358
|
local label="Session $((i+1))/$total: $project"
|
|
310
359
|
[[ "$is_stale" == "true" ]] && label="$label [STALE]"
|
|
311
|
-
[[ -n "$
|
|
360
|
+
[[ -n "$rec_date" ]] && label="$label ($rec_date $start_short"
|
|
312
361
|
[[ -n "$branch" && "$branch" != "null" ]] && label="$label, $branch"
|
|
313
362
|
label="$label)"
|
|
314
363
|
|
|
315
364
|
_clast_wake_separator "$label"
|
|
365
|
+
# Full session ID + recorded window: identifies exactly which session is
|
|
366
|
+
# being reviewed (e.g. for `clast-plumbing sessions dismiss/undismiss <id>`).
|
|
367
|
+
clast_porcelain_info " id: $sid"
|
|
368
|
+
clast_porcelain_info " recorded: $recorded"
|
|
316
369
|
clast_porcelain_info "Gathering context..."
|
|
317
370
|
|
|
318
371
|
local show_json
|
|
319
372
|
show_json="$(clast-plumbing --json show "$sid" --full --turns 8 2>/dev/null)" || {
|
|
320
|
-
|
|
373
|
+
local rc=$? reason
|
|
374
|
+
reason="$(jq -r '.error // empty' <<<"$show_json" 2>/dev/null || true)"
|
|
375
|
+
[[ -z "$reason" ]] && reason="exit $rc"
|
|
376
|
+
clast_porcelain_warn "failed to read session $sid ($reason) — skipping"
|
|
321
377
|
skipped_count=$(( skipped_count + 1 ))
|
|
322
378
|
i=$(( i + 1 ))
|
|
323
379
|
continue
|
|
324
380
|
}
|
|
325
381
|
|
|
326
|
-
|
|
327
|
-
|
|
382
|
+
# Cap each turn's text: a single pathological turn (e.g. a huge pasted
|
|
383
|
+
# blob or tool dump) would otherwise bloat the prompt — costly and liable
|
|
384
|
+
# to exceed the model's context. show --full keeps the full text; only the
|
|
385
|
+
# LLM-bound copy is bounded.
|
|
386
|
+
local first_turns last_turns turn_cap=2000
|
|
387
|
+
first_turns="$(jq -r --argjson cap "$turn_cap" '
|
|
328
388
|
.first_turns // [] | .[] |
|
|
329
|
-
|
|
389
|
+
(.text // "") as $t | ($t | length) as $n |
|
|
390
|
+
"[\(.role)] \(if $n > $cap then $t[0:$cap] + "… [\($n - $cap) more chars truncated]" else $t end)"
|
|
330
391
|
' <<<"$show_json" 2>/dev/null)" || true
|
|
331
392
|
|
|
332
|
-
last_turns="$(jq -r '
|
|
393
|
+
last_turns="$(jq -r --argjson cap "$turn_cap" '
|
|
333
394
|
.last_turns // [] | .[] |
|
|
334
|
-
|
|
395
|
+
(.text // "") as $t | ($t | length) as $n |
|
|
396
|
+
"[\(.role)] \(if $n > $cap then $t[0:$cap] + "… [\($n - $cap) more chars truncated]" else $t end)"
|
|
335
397
|
' <<<"$show_json" 2>/dev/null)" || true
|
|
336
398
|
|
|
337
399
|
local breadcrumbs=""
|
|
@@ -342,7 +404,7 @@ clast_cmd_wake() {
|
|
|
342
404
|
"$first_turns" "$last_turns" "$breadcrumbs")"
|
|
343
405
|
|
|
344
406
|
local system_prompt
|
|
345
|
-
system_prompt="$(clast_porcelain_load_system_prompt
|
|
407
|
+
system_prompt="$(clast_porcelain_load_system_prompt wake-draft-system)"
|
|
346
408
|
|
|
347
409
|
local draft="" edit_extra=""
|
|
348
410
|
local drafting=1
|
|
@@ -439,6 +501,9 @@ Revisions requested by user: ${edit_extra}"
|
|
|
439
501
|
printf '\n'
|
|
440
502
|
_clast_wake_separator "Summary"
|
|
441
503
|
clast_porcelain_info " Curated: $curated_count session(s) across $unique_projects project(s)"
|
|
504
|
+
if (( auto_dismissed_count > 0 )); then
|
|
505
|
+
clast_porcelain_info " Auto-dismissed (no-op): $auto_dismissed_count session(s)"
|
|
506
|
+
fi
|
|
442
507
|
if (( dismissed_count > 0 )); then
|
|
443
508
|
clast_porcelain_info " Dismissed: $dismissed_count session(s)"
|
|
444
509
|
fi
|