@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.
Files changed (39) hide show
  1. package/README.md +41 -38
  2. package/bin/clast +31 -122
  3. package/bin/clast-plumbing +180 -0
  4. package/examples/cron/clast-snapshot.service +2 -2
  5. package/examples/cron/clast-snapshot.timer +1 -1
  6. package/examples/cron/crontab.sample +4 -4
  7. package/examples/workflows/morning-briefing.md +17 -17
  8. package/hooks/snapshot.sh +5 -5
  9. package/lib/clast/clast-classify-lib.bash +68 -0
  10. package/lib/clast/clast-dismissed-lib.bash +76 -5
  11. package/lib/clast/clast-lib.bash +93 -8
  12. package/lib/clast/clast-manifest-lib.bash +58 -5
  13. package/lib/clast/clast-porcelain-lib.bash +230 -0
  14. package/lib/clast/clast-porcelain-subcommands/brief.bash +236 -0
  15. package/lib/clast/clast-porcelain-subcommands/retro.bash +236 -0
  16. package/lib/clast/clast-porcelain-subcommands/undismiss.bash +27 -0
  17. package/lib/clast/clast-porcelain-subcommands/wake.bash +514 -0
  18. package/lib/clast/clast-registry-lib.bash +164 -41
  19. package/lib/clast/clast-retro-lib.bash +397 -0
  20. package/lib/clast/clast-subcommands/doctor.bash +29 -13
  21. package/lib/clast/clast-subcommands/entries.bash +40 -50
  22. package/lib/clast/clast-subcommands/projects.bash +16 -20
  23. package/lib/clast/clast-subcommands/registry.bash +26 -11
  24. package/lib/clast/clast-subcommands/retro.bash +217 -0
  25. package/lib/clast/clast-subcommands/sessions.bash +193 -48
  26. package/lib/clast/clast-subcommands/show.bash +69 -12
  27. package/lib/clast/clast-subcommands/snapshot.bash +29 -11
  28. package/lib/clast/clast-subcommands/stats.bash +7 -2
  29. package/lib/clast/prompts/brief-system.md +11 -5
  30. package/lib/clast/prompts/brief-user.md +4 -1
  31. package/lib/clast/prompts/retro-summary-system.md +14 -0
  32. package/lib/clast/prompts/retro-summary-user.md +7 -0
  33. package/lib/clast/prompts/{day-wakeup-draft-system.md → wake-draft-system.md} +1 -1
  34. package/package.json +3 -3
  35. package/{.claude-plugin/skills/wakeup → skills/brief}/SKILL.md +22 -20
  36. package/{.claude-plugin/skills/day-wakeup → skills/wake}/SKILL.md +54 -29
  37. package/bin/clast-brief +0 -305
  38. package/bin/clast-wake +0 -579
  39. /package/lib/clast/prompts/{day-wakeup-draft-user.md → wake-draft-user.md} +0 -0
package/hooks/snapshot.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # hooks/snapshot.sh — SessionStart hook. Backgrounds `clast snapshot`.
2
+ # hooks/snapshot.sh — SessionStart hook. Backgrounds `clast-plumbing snapshot`.
3
3
  # Idempotent. Best-effort: never propagates a non-zero exit.
4
4
  # shellcheck shell=bash
5
5
 
@@ -11,10 +11,10 @@ _plugin_root="$(cd "$_snap_dir/.." && pwd)"
11
11
  # Prefer the bundled binary (version-matched to this plugin).
12
12
  # Fall back to a system-level install on PATH.
13
13
  _clast=""
14
- if [[ -x "$_plugin_root/bin/clast" ]]; then
15
- _clast="$_plugin_root/bin/clast"
16
- elif command -v clast >/dev/null 2>&1; then
17
- _clast="clast"
14
+ if [[ -x "$_plugin_root/bin/clast-plumbing" ]]; then
15
+ _clast="$_plugin_root/bin/clast-plumbing"
16
+ elif command -v clast-plumbing >/dev/null 2>&1; then
17
+ _clast="clast-plumbing"
18
18
  fi
19
19
 
20
20
  if [[ -n "$_clast" ]]; then
@@ -0,0 +1,68 @@
1
+ # clast-classify-lib.bash — deterministic session classification
2
+ #
3
+ # A "no-op" session is one Claude Code captured but that holds no real work:
4
+ # the user opened a session and only ran slash commands (`/clear`, `/model`,
5
+ # `/config`, …) then quit, or typed a prompt and quit before Claude replied.
6
+ # These are worthless to curate, so `wake` auto-dismisses them without ever
7
+ # calling the LLM (see docs/reference/plugin.md + the wake flows).
8
+ #
9
+ # The classification is two counts computed from the transcript JSONL:
10
+ # user_msg_count real user prompts — user-role messages that are not
11
+ # meta, are non-empty, and are NOT slash-command wrappers.
12
+ # assistant_msg_count assistant-role messages (presence only; a tool-only
13
+ # reply with no text still counts as real work).
14
+ #
15
+ # A session is *substantive* iff assistant_msg_count > 0 — i.e. Claude actually
16
+ # replied. That single test captures both no-op shapes: empty / slash-command-
17
+ # only sessions (/clear, /model, /config) and sessions where the user typed but
18
+ # quit before any response — both have assistant_msg_count == 0. It is
19
+ # deliberately NOT gated on user_msg_count: a custom slash command (e.g.
20
+ # /review) leaves zero prose prompts yet drives real assistant work, and must
21
+ # be kept. user_msg_count is still cached for diagnostics (show, sessions).
22
+ #
23
+ # Counts are computed once at snapshot time and cached on the manifest line
24
+ # (clast-manifest-lib.bash), mirroring msg_count/first_ts/last_ts, so readers
25
+ # never re-open the transcript.
26
+ # shellcheck shell=bash
27
+
28
+ if [[ -n "${_CLAST_CLASSIFY_LIB_SOURCED:-}" ]]; then
29
+ return 0
30
+ fi
31
+ _CLAST_CLASSIFY_LIB_SOURCED=1
32
+
33
+ # Slash-command / local-command marker set. A user-role message whose text
34
+ # contains any of these is Claude Code bookkeeping (e.g. `/clear`, `/model`),
35
+ # not a real prompt. Centralized here so the classifier and show.bash's
36
+ # first_prompt/last_prompt extraction share one definition.
37
+ CLAST_COMMAND_MARKER_RE='<command-name>|<command-message>|<command-args>|<local-command-stdout>|<local-command-stderr>|<local-command-caveat>'
38
+ export CLAST_COMMAND_MARKER_RE
39
+
40
+ # clast_session_msg_counts <transcript-path>
41
+ # Print "<user_msg_count>\t<assistant_msg_count>" (tab-separated) for the
42
+ # given JSONL transcript. Missing/unreadable file prints "0\t0".
43
+ #
44
+ # Streaming (`reduce inputs`), not slurp: a multi-megabyte transcript is
45
+ # read one line at a time, O(1) memory. `fromjson?` silently drops malformed
46
+ # lines, matching the tolerance in clast_manifest_iterate.
47
+ clast_session_msg_counts() {
48
+ local path="$1"
49
+ if [[ -z "$path" || ! -r "$path" ]]; then
50
+ printf '0\t0\n'
51
+ return 0
52
+ fi
53
+ jq -n -R -r --arg cmd_re "$CLAST_COMMAND_MARKER_RE" '
54
+ def role: (.message.role // .role // .type);
55
+ def text_str:
56
+ (.message.content // .content // "")
57
+ | if type == "array" then (map(.text? // "") | join(" ")) else (. | tostring) end;
58
+ reduce (inputs | fromjson?) as $l ({u: 0, a: 0};
59
+ if ($l.isMeta // false) == true then .
60
+ elif ($l | role) == "user" then
61
+ ( ($l | text_str) as $t
62
+ | if ($t | gsub("\\s"; "")) != "" and ($t | test($cmd_re) | not)
63
+ then .u += 1 else . end )
64
+ elif ($l | role) == "assistant" then .a += 1
65
+ else . end
66
+ ) | "\(.u)\t\(.a)"
67
+ ' "$path" 2>/dev/null || printf '0\t0\n'
68
+ }
@@ -51,12 +51,13 @@ clast_dismissed_set() {
51
51
  return 0
52
52
  fi
53
53
 
54
- local line sid
55
- while IFS= read -r line; do
56
- [[ -z "$line" ]] && continue
57
- sid="$(jq -r '.session_id // empty' <<<"$line" 2>/dev/null)" || continue
54
+ # Single jq pass over the whole file. Previously this forked one jq per
55
+ # line, which dominated when the dismissed log grew large. `fromjson?`
56
+ # silently drops malformed lines, matching the old per-line tolerance.
57
+ local sid
58
+ while IFS= read -r sid; do
58
59
  [[ -n "$sid" ]] && _ref["$sid"]=1
59
- done < "$dismissed_file"
60
+ done < <(jq -rR 'fromjson? | .session_id // empty' "$dismissed_file" 2>/dev/null)
60
61
  }
61
62
 
62
63
  # clast_dismissed_check <session-id> — exit 0 if dismissed, 1 if not.
@@ -71,3 +72,73 @@ clast_dismissed_check() {
71
72
 
72
73
  grep -q "\"session_id\":\"$session_id\"" "$dismissed_file" 2>/dev/null
73
74
  }
75
+
76
+ # clast_dismissed_remove <session-id> — drop all records for a session,
77
+ # reversing a dismissal. Prints the number of records removed to stdout.
78
+ # Returns 0 if anything was removed, 1 if the session was not dismissed.
79
+ clast_dismissed_remove() {
80
+ local session_id="$1"
81
+ if [[ -z "$session_id" ]]; then
82
+ clast_log_error "clast_dismissed_remove: empty session_id"
83
+ return 2
84
+ fi
85
+
86
+ local dismissed_file
87
+ dismissed_file="$(clast_dismissed_path)"
88
+
89
+ if [[ ! -f "$dismissed_file" ]]; then
90
+ printf '0\n'
91
+ return 1
92
+ fi
93
+
94
+ # Count matches, then rewrite the log without them. A match is a
95
+ # well-formed JSON *object* whose session_id equals the target;
96
+ # `try fromjson catch null` turns unparseable lines into null so they
97
+ # never match, and the `type == "object"` guard keeps non-object JSON
98
+ # (numbers, strings) from erroring on `.session_id`. This mirrors the
99
+ # tolerant read in clast_dismissed_set.
100
+ local removed
101
+ removed="$(jq -rRn --arg sid "$session_id" '
102
+ [ inputs
103
+ | (try fromjson catch null) as $obj
104
+ | select(($obj | type) == "object" and $obj.session_id == $sid)
105
+ ] | length
106
+ ' "$dismissed_file" 2>/dev/null)" || removed=0
107
+ [[ -z "$removed" ]] && removed=0
108
+
109
+ if (( removed == 0 )); then
110
+ printf '0\n'
111
+ return 1
112
+ fi
113
+
114
+ local tmp
115
+ tmp="$(mktemp "${dismissed_file}.XXXXXX")" || {
116
+ clast_log_error "clast_dismissed_remove: failed to create temp file"
117
+ return 2
118
+ }
119
+ # Emit every line except well-formed objects matching the target id.
120
+ # Malformed lines parse to null (type != "object") and so are preserved
121
+ # verbatim rather than silently dropped. Rewrite goes through a temp file
122
+ # + mv so a crash mid-write can't truncate the log.
123
+ if jq -Rr --arg sid "$session_id" '
124
+ . as $line
125
+ | (try ($line | fromjson) catch null) as $obj
126
+ | if ($obj | type) == "object" and $obj.session_id == $sid
127
+ then empty
128
+ else $line
129
+ end
130
+ ' "$dismissed_file" >"$tmp" 2>/dev/null; then
131
+ if ! mv "$tmp" "$dismissed_file"; then
132
+ rm -f "$tmp"
133
+ clast_log_error "clast_dismissed_remove: failed to replace log"
134
+ return 2
135
+ fi
136
+ else
137
+ rm -f "$tmp"
138
+ clast_log_error "clast_dismissed_remove: rewrite failed"
139
+ return 2
140
+ fi
141
+
142
+ printf '%s\n' "$removed"
143
+ return 0
144
+ }
@@ -55,6 +55,78 @@ clast_json_get() {
55
55
  jq -r "$expr" <<<"$input"
56
56
  }
57
57
 
58
+ # --- Front-matter / YAML -------------------------------------------------
59
+ #
60
+ # Curated journal entries are Markdown with a leading YAML front-matter block
61
+ # fenced by `---`. These two primitives are the single source of truth for
62
+ # reading that block; `entries.bash` and `clast-retro-lib.bash` both build on
63
+ # them.
64
+
65
+ # clast_read_frontmatter <path>
66
+ # Emit the raw front-matter lines (between the first two `---` fences) to
67
+ # stdout. Nothing is emitted for a file without a front-matter block.
68
+ clast_read_frontmatter() {
69
+ local path="$1"
70
+ awk '
71
+ BEGIN { in_fm = 0; seen = 0 }
72
+ /^---[[:space:]]*$/ {
73
+ if (!seen) { in_fm = 1; seen = 1; next }
74
+ if (in_fm) { exit }
75
+ }
76
+ in_fm { print }
77
+ ' "$path"
78
+ }
79
+
80
+ # clast_yaml_unquote <string>
81
+ # Strip surrounding double quotes and unescape \", \\, \n on a YAML scalar.
82
+ # A bare (unquoted) value is returned unchanged.
83
+ clast_yaml_unquote() {
84
+ local v="$1"
85
+ if [[ "${v:0:1}" == '"' && "${v: -1}" == '"' && ${#v} -ge 2 ]]; then
86
+ v="${v:1:${#v}-2}"
87
+ # Process escapes in order: \\ → placeholder, \" → ", \n → LF, placeholder → \
88
+ v="${v//\\\\/$'\x01'}"
89
+ v="${v//\\\"/\"}"
90
+ v="${v//\\n/$'\n'}"
91
+ v="${v//$'\x01'/\\}"
92
+ fi
93
+ printf '%s' "$v"
94
+ }
95
+
96
+ # clast_entry_body <path>
97
+ # Emit the entry body — everything after the closing `---` of the
98
+ # front-matter block. A file with no front-matter is emitted whole.
99
+ clast_entry_body() {
100
+ awk '
101
+ BEGIN { in_fm = 0; seen = 0; past = 0 }
102
+ !past && /^---[[:space:]]*$/ {
103
+ if (!seen) { in_fm = 1; seen = 1; next }
104
+ if (in_fm) { in_fm = 0; past = 1; next }
105
+ }
106
+ past { print; next }
107
+ !seen { print } # no front-matter fence yet seen → plain file, echo through
108
+ ' "$1"
109
+ }
110
+
111
+ # clast_entry_title <path>
112
+ # The session title from `# Session: <title>` (first non-blank body line).
113
+ # Empty if absent.
114
+ clast_entry_title() {
115
+ awk '
116
+ BEGIN { in_fm = 0; seen = 0; past = 0 }
117
+ /^---[[:space:]]*$/ {
118
+ if (!seen) { in_fm = 1; seen = 1; next }
119
+ if (in_fm) { in_fm = 0; past = 1; next }
120
+ }
121
+ in_fm { next }
122
+ past {
123
+ if ($0 ~ /^[[:space:]]*$/) next
124
+ if (sub(/^# Session: /, "")) { print; exit }
125
+ exit
126
+ }
127
+ ' "$1"
128
+ }
129
+
58
130
  # --- Date math -----------------------------------------------------------
59
131
  #
60
132
  # Uses GNU `date -d` for relative-date math. The nix dev shell pulls in
@@ -71,22 +143,31 @@ _clast_now_epoch() {
71
143
  fi
72
144
  }
73
145
 
74
- # clast_today — local YYYY-MM-DD, adjusted by CLAST_DAY_CUTOFF (HH:MM, default 04:00).
75
- # A session starting before today's cutoff belongs to yesterday's bucket.
76
- clast_today() {
146
+ # clast_day_bucket_for_epoch <epoch> — local YYYY-MM-DD for an epoch, adjusted
147
+ # by CLAST_DAY_CUTOFF (HH:MM, default 04:00). Work before the cutoff belongs to
148
+ # the previous day's bucket. Single source of truth for the day-bucket rule;
149
+ # clast_today, `clast snapshot`, and `clast retro` all build on it.
150
+ clast_day_bucket_for_epoch() {
151
+ local epoch="$1"
77
152
  local cutoff="${CLAST_DAY_CUTOFF:-04:00}"
78
- local cutoff_hours cutoff_mins cutoff_secs now adjusted
153
+ local cutoff_hours cutoff_mins cutoff_secs adjusted
79
154
  cutoff_hours="${cutoff%%:*}"
80
155
  cutoff_mins="${cutoff##*:}"
81
156
  # Strip leading zeros so bash arithmetic doesn't treat them as octal.
82
157
  cutoff_hours=$((10#$cutoff_hours))
83
158
  cutoff_mins=$((10#$cutoff_mins))
84
159
  cutoff_secs=$((cutoff_hours * 3600 + cutoff_mins * 60))
85
- now="$(_clast_now_epoch)"
86
- adjusted=$((now - cutoff_secs))
160
+ adjusted=$((epoch - cutoff_secs))
161
+ # GNU `date -d` — BSD date not supported, per overview.md.
87
162
  date -d "@$adjusted" +%Y-%m-%d
88
163
  }
89
164
 
165
+ # clast_today — local YYYY-MM-DD, adjusted by CLAST_DAY_CUTOFF (HH:MM, default 04:00).
166
+ # A session starting before today's cutoff belongs to yesterday's bucket.
167
+ clast_today() {
168
+ clast_day_bucket_for_epoch "$(_clast_now_epoch)"
169
+ }
170
+
90
171
  # clast_parse_date <input> — print YYYY-MM-DD on stdout, exit non-zero on bad input.
91
172
  # Accepts:
92
173
  # - ISO date: 2026-05-30
@@ -204,10 +285,10 @@ clast_version() {
204
285
  # to stderr for usage errors.
205
286
  clast_usage() {
206
287
  cat <<'EOF'
207
- clast — Claude Code session journal
288
+ clast-plumbing — Claude Code session journal (deterministic core)
208
289
 
209
290
  Usage:
210
- clast [GLOBAL FLAGS] <subcommand> [ARGS...]
291
+ clast-plumbing [GLOBAL FLAGS] <subcommand> [ARGS...]
211
292
 
212
293
  Subcommands:
213
294
  whereami Show current path, registry, and journal state
@@ -219,6 +300,7 @@ Subcommands:
219
300
  breadcrumb Append a one-line in-flight hint
220
301
  registry Manage the project registry
221
302
  stats Token/duration/session-count stats
303
+ retro Work summary grouped by actual work day → project
222
304
  doctor Sanity-check the journal
223
305
 
224
306
  Global flags:
@@ -229,5 +311,8 @@ Global flags:
229
311
  -q, --quiet Suppress informational stdout output
230
312
  --journal-dir P Override ~/.claude/journal/ (env: CLAST_JOURNAL_DIR)
231
313
  --projects-dir P Override ~/.claude/projects/ (env: CLAST_PROJECTS_DIR)
314
+
315
+ The user-facing porcelain (LLM-aware) is `clast`. Run `clast --help` for
316
+ the `wake` and `brief` subcommands.
232
317
  EOF
233
318
  }
@@ -13,6 +13,13 @@ if [[ -n "${_CLAST_MANIFEST_LIB_SOURCED:-}" ]]; then
13
13
  fi
14
14
  _CLAST_MANIFEST_LIB_SOURCED=1
15
15
 
16
+ # clast_manifest_rebuild_from_disk backfills the session-classification counts
17
+ # via clast_session_msg_counts. Source the classify lib relative to this file
18
+ # (not via CLAST_LIB) so callers that source manifest-lib directly — e.g. the
19
+ # test suite — get the dependency without extra setup.
20
+ # shellcheck source=lib/clast/clast-classify-lib.bash
21
+ source "${BASH_SOURCE[0]%/*}/clast-classify-lib.bash"
22
+
16
23
  # clast_manifest_path — single chokepoint so CLAST_JOURNAL_DIR override
17
24
  # (via clast_journal_dir) redirects every read/write in this file.
18
25
  clast_manifest_path() {
@@ -26,13 +33,23 @@ _clast_manifest_now_iso() {
26
33
  date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ
27
34
  }
28
35
 
29
- # clast_manifest_append <session-id> <source> <snapshot> <source-mtime> <source-size> <day-bucket>
36
+ # clast_manifest_append <session-id> <source> <snapshot> <source-mtime> <source-size> <day-bucket> <msg-count> <first-ts> <last-ts> <user-msg-count> <assistant-msg-count>
30
37
  clast_manifest_append() {
31
- if [[ $# -ne 6 ]]; then
32
- clast_log_error "clast_manifest_append: expected 6 args, got $#"
38
+ if [[ $# -ne 11 ]]; then
39
+ clast_log_error "clast_manifest_append: expected 11 args, got $#"
33
40
  return 2
34
41
  fi
35
42
  local session_id="$1" source="$2" snapshot="$3" source_mtime="$4" source_size="$5" day_bucket="$6"
43
+ # Cached per-session metadata (step 21): msg_count is an integer line
44
+ # count; first_ts/last_ts are the transcript's first/last line timestamps
45
+ # ("" → stored as JSON null). Readers prefer these over re-reading the
46
+ # snapshot file, falling back to the file when a line predates the cache.
47
+ local msg_count="$7" first_ts="$8" last_ts="$9"
48
+ # Session classification (clast-classify-lib.bash): counts of real user
49
+ # prompts and assistant replies. A session is a no-op (auto-dismissed by
50
+ # wake) when either is 0. Cached here so readers never re-open the
51
+ # transcript; legacy lines lack them and readers recompute on demand.
52
+ local user_msg_count="${10}" assistant_msg_count="${11}"
36
53
  local field
37
54
  for field in session_id source snapshot source_mtime source_size day_bucket; do
38
55
  if [[ -z "${!field}" ]]; then
@@ -44,6 +61,18 @@ clast_manifest_append() {
44
61
  clast_log_error "clast_manifest_append: source_size must be a non-negative integer, got '$source_size'"
45
62
  return 2
46
63
  fi
64
+ if ! [[ "$msg_count" =~ ^[0-9]+$ ]]; then
65
+ clast_log_error "clast_manifest_append: msg_count must be a non-negative integer, got '$msg_count'"
66
+ return 2
67
+ fi
68
+ if ! [[ "$user_msg_count" =~ ^[0-9]+$ ]]; then
69
+ clast_log_error "clast_manifest_append: user_msg_count must be a non-negative integer, got '$user_msg_count'"
70
+ return 2
71
+ fi
72
+ if ! [[ "$assistant_msg_count" =~ ^[0-9]+$ ]]; then
73
+ clast_log_error "clast_manifest_append: assistant_msg_count must be a non-negative integer, got '$assistant_msg_count'"
74
+ return 2
75
+ fi
47
76
 
48
77
  local journal_dir
49
78
  journal_dir="$(clast_journal_dir)"
@@ -62,7 +91,12 @@ clast_manifest_append() {
62
91
  --arg source_mtime "$source_mtime" \
63
92
  --argjson source_size "$source_size" \
64
93
  --arg day_bucket "$day_bucket" \
65
- '{session_id: $session_id, source: $source, snapshot: $snapshot, captured_at: $captured_at, source_mtime: $source_mtime, source_size: $source_size, day_bucket: $day_bucket}')" || {
94
+ --argjson msg_count "$msg_count" \
95
+ --arg first_ts "$first_ts" \
96
+ --arg last_ts "$last_ts" \
97
+ --argjson user_msg_count "$user_msg_count" \
98
+ --argjson assistant_msg_count "$assistant_msg_count" \
99
+ '{session_id: $session_id, source: $source, snapshot: $snapshot, captured_at: $captured_at, source_mtime: $source_mtime, source_size: $source_size, day_bucket: $day_bucket, msg_count: $msg_count, first_ts: (if $first_ts == "" then null else $first_ts end), last_ts: (if $last_ts == "" then null else $last_ts end), user_msg_count: $user_msg_count, assistant_msg_count: $assistant_msg_count}')" || {
66
100
  clast_log_error "clast_manifest_append: jq failed to build manifest line"
67
101
  return 1
68
102
  }
@@ -168,6 +202,7 @@ clast_manifest_rebuild_from_disk() {
168
202
  local count=0
169
203
  if [[ -d "$transcripts_root" ]]; then
170
204
  local snapshot day session_id mtime_epoch mtime_iso line
205
+ local msg_count first_ts last_ts
171
206
  # source / source_size are unrecoverable: the snapshot file's size is
172
207
  # the captured copy's size, not the original source's size at capture
173
208
  # time, and the source path is not encoded in the snapshot itself.
@@ -185,13 +220,31 @@ clast_manifest_rebuild_from_disk() {
185
220
  rm -f "$tmp_file"
186
221
  return 1
187
222
  fi
223
+ # source / source_size are unrecoverable, but the cached metadata
224
+ # (step 21) IS recoverable from the snapshot copy — compute it so
225
+ # doctor --fix backfills rather than emitting legacy-shaped lines.
226
+ first_ts="$(head -n1 "$snapshot" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
227
+ last_ts="$(tail -n1 "$snapshot" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
228
+ msg_count="$(wc -l <"$snapshot" 2>/dev/null | tr -d ' ')"
229
+ [[ "$msg_count" =~ ^[0-9]+$ ]] || msg_count=0
230
+ # Session classification counts are equally recoverable from the copy.
231
+ local user_msg_count assistant_msg_count
232
+ IFS=$'\t' read -r user_msg_count assistant_msg_count \
233
+ < <(clast_session_msg_counts "$snapshot")
234
+ [[ "$user_msg_count" =~ ^[0-9]+$ ]] || user_msg_count=0
235
+ [[ "$assistant_msg_count" =~ ^[0-9]+$ ]] || assistant_msg_count=0
188
236
  line="$(jq -c -n \
189
237
  --arg session_id "$session_id" \
190
238
  --arg snapshot "${snapshot#"$journal_dir/"}" \
191
239
  --arg captured_at "$mtime_iso" \
192
240
  --arg source_mtime "$mtime_iso" \
193
241
  --arg day_bucket "$day" \
194
- '{session_id: $session_id, source: null, snapshot: $snapshot, captured_at: $captured_at, source_mtime: $source_mtime, source_size: 0, day_bucket: $day_bucket}')" || {
242
+ --argjson msg_count "$msg_count" \
243
+ --arg first_ts "$first_ts" \
244
+ --arg last_ts "$last_ts" \
245
+ --argjson user_msg_count "$user_msg_count" \
246
+ --argjson assistant_msg_count "$assistant_msg_count" \
247
+ '{session_id: $session_id, source: null, snapshot: $snapshot, captured_at: $captured_at, source_mtime: $source_mtime, source_size: 0, day_bucket: $day_bucket, msg_count: $msg_count, first_ts: (if $first_ts == "" then null else $first_ts end), last_ts: (if $last_ts == "" then null else $last_ts end), user_msg_count: $user_msg_count, assistant_msg_count: $assistant_msg_count}')" || {
195
248
  clast_log_error "clast_manifest_rebuild_from_disk: jq failed for '$snapshot'"
196
249
  rm -f "$tmp_file"
197
250
  return 1
@@ -0,0 +1,230 @@
1
+ # clast-porcelain-lib.bash — shared helpers for the `clast` porcelain.
2
+ #
3
+ # The porcelain is the LLM-aware, user-facing layer that sits on top of
4
+ # `clast-plumbing`. This file holds helpers reused by every porcelain
5
+ # subcommand: error/info plumbing, an OpenAI-compatible chat call, and the
6
+ # prompt-template loader. Pure bash + jq + curl.
7
+ #
8
+ # Subcommand files live in $CLAST_LIB/clast-porcelain-subcommands/<name>.bash
9
+ # and each defines a single function `clast_cmd_<name>`.
10
+ # shellcheck shell=bash
11
+
12
+ # --- Helpers -----------------------------------------------------------------
13
+
14
+ clast_porcelain_die() {
15
+ printf 'clast: %s\n' "$1" >&2
16
+ exit "${2:-1}"
17
+ }
18
+
19
+ clast_porcelain_warn() { printf 'clast: warning: %s\n' "$1" >&2; }
20
+ clast_porcelain_info() { printf '%s\n' "$1"; }
21
+
22
+ clast_porcelain_log_error() { printf 'clast: error: %s\n' "$1" >&2; }
23
+
24
+ # --- Version / usage ---------------------------------------------------------
25
+
26
+ # Reuses the plumbing's clast_version helper if available (loaded by sourcing
27
+ # clast-lib.bash). When the porcelain is invoked stand-alone we still want a
28
+ # version string, so fall back to reading package.json directly.
29
+ clast_porcelain_version() {
30
+ if declare -F clast_version >/dev/null 2>&1; then
31
+ clast_version
32
+ return
33
+ fi
34
+ local pkg_root pkg
35
+ pkg_root="${CLAST_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
36
+ if [[ -f "$pkg_root/package.json" ]]; then
37
+ pkg="$pkg_root/package.json"
38
+ elif [[ -f "$pkg_root/../../package.json" ]]; then
39
+ pkg="$pkg_root/../../package.json"
40
+ else
41
+ clast_porcelain_log_error "clast_porcelain_version: package.json not found"
42
+ return 1
43
+ fi
44
+ jq -r '.version' "$pkg"
45
+ }
46
+
47
+ clast_porcelain_usage() {
48
+ cat <<'EOF'
49
+ clast — Claude Code session porcelain (LLM-aware)
50
+
51
+ Usage:
52
+ clast [GLOBAL FLAGS] <subcommand> [ARGS...]
53
+
54
+ Subcommands:
55
+ wake Interactive day curation (standalone equivalent of /wake)
56
+ brief Project briefing (standalone equivalent of /brief)
57
+ retro Model-condensed work retrospective by work day → project
58
+ undismiss Restore session(s) dismissed in wake (by session id)
59
+
60
+ Global flags:
61
+ -h, --help Print this usage and exit
62
+ --version Print version and exit
63
+
64
+ Env (required by `wake` and `brief`):
65
+ CLAST_LLM_BASE_URL e.g. https://api.openai.com/v1
66
+ CLAST_LLM_API_KEY bearer token
67
+ CLAST_LLM_MODEL e.g. gpt-4o, llama3
68
+
69
+ The deterministic core (whereami / snapshot / sessions / entries / …)
70
+ lives in `clast-plumbing`. Run `clast-plumbing --help` for that surface.
71
+ EOF
72
+ }
73
+
74
+ # --- Preflight ---------------------------------------------------------------
75
+
76
+ # clast_porcelain_preflight_llm — verify curl, jq, and the LLM env vars exist.
77
+ # Subcommands that don't call the LLM (none right now) can skip this.
78
+ clast_porcelain_preflight_llm() {
79
+ for tool in clast-plumbing curl jq; do
80
+ if ! command -v "$tool" >/dev/null 2>&1; then
81
+ clast_porcelain_die "required tool not found: $tool"
82
+ fi
83
+ done
84
+
85
+ local missing=0
86
+ if [[ -z "${CLAST_LLM_BASE_URL:-}" ]]; then
87
+ clast_porcelain_warn "CLAST_LLM_BASE_URL not set"; missing=1
88
+ fi
89
+ if [[ -z "${CLAST_LLM_API_KEY:-}" ]]; then
90
+ clast_porcelain_warn "CLAST_LLM_API_KEY not set"; missing=1
91
+ fi
92
+ if [[ -z "${CLAST_LLM_MODEL:-}" ]]; then
93
+ clast_porcelain_warn "CLAST_LLM_MODEL not set"; missing=1
94
+ fi
95
+
96
+ if (( missing )); then
97
+ cat >&2 <<'EOF'
98
+
99
+ Set these env vars before running clast wake / clast brief:
100
+
101
+ export CLAST_LLM_BASE_URL="https://api.openai.com/v1"
102
+ export CLAST_LLM_API_KEY="sk-..."
103
+ export CLAST_LLM_MODEL="gpt-4o"
104
+
105
+ Or for a local model (ollama, vllm, etc.):
106
+
107
+ export CLAST_LLM_BASE_URL="http://localhost:11434/v1"
108
+ export CLAST_LLM_API_KEY="unused"
109
+ export CLAST_LLM_MODEL="llama3"
110
+ EOF
111
+ exit 1
112
+ fi
113
+ }
114
+
115
+ # --- LLM call ----------------------------------------------------------------
116
+
117
+ # clast_porcelain_llm_chat <system-msg> <user-msg>
118
+ # POST a chat-completions request to $CLAST_LLM_BASE_URL/chat/completions
119
+ # and echo the assistant's text on stdout. Returns nonzero on HTTP error or
120
+ # empty response.
121
+ clast_porcelain_llm_chat() {
122
+ local system_msg="$1"
123
+ local user_msg="$2"
124
+
125
+ # Build and send the request without ever passing the (possibly hundreds of
126
+ # KB) prompt through argv: a single argument above MAX_ARG_STRLEN (128KB on
127
+ # Linux) fails with "Argument list too long", even well under total ARG_MAX.
128
+ # jq reads the strings via --rawfile (process substitution; printf is a
129
+ # builtin with no argv limit) and curl reads the body from a file with @.
130
+ local payload_file
131
+ payload_file="$(mktemp)" || { clast_porcelain_warn "failed to create temp file"; return 1; }
132
+ if ! jq -cn \
133
+ --arg model "$CLAST_LLM_MODEL" \
134
+ --rawfile system <(printf '%s' "$system_msg") \
135
+ --rawfile user <(printf '%s' "$user_msg") \
136
+ '{
137
+ model: $model,
138
+ messages: [
139
+ {role: "system", content: $system},
140
+ {role: "user", content: $user}
141
+ ],
142
+ temperature: 0.3
143
+ }' >"$payload_file"; then
144
+ rm -f "$payload_file"
145
+ clast_porcelain_warn "failed to build LLM request payload"
146
+ return 1
147
+ fi
148
+
149
+ local response http_code body
150
+ response="$(curl -s -w '\n%{http_code}' \
151
+ "${CLAST_LLM_BASE_URL}/chat/completions" \
152
+ -H "Authorization: Bearer $CLAST_LLM_API_KEY" \
153
+ -H "Content-Type: application/json" \
154
+ --data-binary @"$payload_file" 2>&1)" || true
155
+ rm -f "$payload_file"
156
+
157
+ http_code="$(tail -n1 <<<"$response")"
158
+ body="$(sed '$d' <<<"$response")"
159
+
160
+ if [[ "$http_code" != "200" ]]; then
161
+ clast_porcelain_warn "LLM API returned HTTP $http_code"
162
+ if [[ -n "$body" ]]; then
163
+ local err_msg
164
+ err_msg="$(jq -r '.error.message // .error // .' <<<"$body" 2>/dev/null || echo "$body")"
165
+ clast_porcelain_warn "$err_msg"
166
+ fi
167
+ return 1
168
+ fi
169
+
170
+ local content
171
+ content="$(jq -r '.choices[0].message.content // empty' <<<"$body" 2>/dev/null)" || {
172
+ clast_porcelain_warn "failed to parse LLM response"
173
+ return 1
174
+ }
175
+
176
+ if [[ -z "$content" ]]; then
177
+ clast_porcelain_warn "LLM returned empty content"
178
+ return 1
179
+ fi
180
+
181
+ printf '%s' "$content"
182
+ }
183
+
184
+ # --- Prompt templates --------------------------------------------------------
185
+
186
+ # clast_porcelain_resolve_prompt_dir — echo the prompts dir, search order:
187
+ # 1. $CLAST_LIB/prompts (in-repo or installed alongside the lib)
188
+ # 2. /usr/local/lib/clast/prompts
189
+ # 3. $HOME/.local/lib/clast/prompts
190
+ clast_porcelain_resolve_prompt_dir() {
191
+ if [[ -n "${CLAST_LIB:-}" && -d "$CLAST_LIB/prompts" ]]; then
192
+ printf '%s' "$CLAST_LIB/prompts"
193
+ return
194
+ fi
195
+ local installed
196
+ for installed in /usr/local/lib/clast/prompts "$HOME/.local/lib/clast/prompts"; do
197
+ if [[ -d "$installed" ]]; then
198
+ printf '%s' "$installed"
199
+ return
200
+ fi
201
+ done
202
+ clast_porcelain_die "cannot find prompts directory (checked \$CLAST_LIB/prompts and install paths)"
203
+ }
204
+
205
+ # clast_porcelain_load_system_prompt <basename>
206
+ # Read the named system prompt (without extension) from the resolved
207
+ # prompts dir, e.g. clast_porcelain_load_system_prompt brief-system.
208
+ clast_porcelain_load_system_prompt() {
209
+ local name="$1"
210
+ local prompt_dir file
211
+ prompt_dir="$(clast_porcelain_resolve_prompt_dir)"
212
+ file="$prompt_dir/${name}.md"
213
+ if [[ ! -r "$file" ]]; then
214
+ clast_porcelain_die "system prompt not found: $file"
215
+ fi
216
+ cat "$file"
217
+ }
218
+
219
+ # clast_porcelain_user_prompt_file <basename>
220
+ # Echo the absolute path to the named user-prompt template, or empty string
221
+ # if missing. Callers do their own placeholder substitution.
222
+ clast_porcelain_user_prompt_file() {
223
+ local name="$1"
224
+ local prompt_dir file
225
+ prompt_dir="$(clast_porcelain_resolve_prompt_dir)"
226
+ file="$prompt_dir/${name}.md"
227
+ if [[ -r "$file" ]]; then
228
+ printf '%s' "$file"
229
+ fi
230
+ }