@procrastivity/clast 0.0.3 → 0.0.4

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.
@@ -1,16 +1,16 @@
1
1
  # clast cron sample for unattended transcript capture.
2
2
  # Install with: crontab -l | { cat; cat examples/cron/crontab.sample; } | crontab -
3
- # Edit the clast path if you installed somewhere other than /usr/local/bin.
3
+ # Edit the clast-plumbing path if you installed somewhere other than /usr/local/bin.
4
4
 
5
5
  # Every hour at :05, capture any new transcripts.
6
6
  # Idempotent and silent on no-op, so safe to run frequently.
7
- 5 * * * * /usr/local/bin/clast snapshot >/dev/null 2>&1
7
+ 5 * * * * /usr/local/bin/clast-plumbing snapshot >/dev/null 2>&1
8
8
 
9
9
  # Alternative: every 15 minutes for active users moving between many sessions.
10
- # */15 * * * * /usr/local/bin/clast snapshot >/dev/null 2>&1
10
+ # */15 * * * * /usr/local/bin/clast-plumbing snapshot >/dev/null 2>&1
11
11
 
12
12
  # Alternative: once daily for hands-off archival.
13
- # 10 4 * * * /usr/local/bin/clast snapshot >/dev/null 2>&1
13
+ # 10 4 * * * /usr/local/bin/clast-plumbing snapshot >/dev/null 2>&1
14
14
 
15
15
  # Alternative: no cron. Rely on the SessionStart hook installed with the plugin.
16
16
  # The hook covers most use cases already; cron is only useful when you want capture without ever opening Claude.
@@ -6,8 +6,8 @@ and `infra-tools` checked a flaky shell lint issue.
6
6
 
7
7
  ```bash
8
8
  # User: /day-wakeup
9
- clast snapshot
10
- clast --json sessions --day yesterday
9
+ clast-plumbing snapshot
10
+ clast-plumbing --json sessions --day yesterday
11
11
  ```
12
12
 
13
13
  It filters to `curated: false`, groups by project, and shows:
@@ -23,8 +23,8 @@ xesapps 09:12 feature/field-normalize 58 messages
23
23
  The skill reads session details and breadcrumbs:
24
24
 
25
25
  ```bash
26
- clast --json show 33333333-3333-4333-8333-333333333333 --full --turns 8
27
- clast breadcrumb --read --project infra-tools --day yesterday
26
+ clast-plumbing --json show 33333333-3333-4333-8333-333333333333 --full --turns 8
27
+ clast-plumbing breadcrumb --read --project infra-tools --day yesterday
28
28
  ```
29
29
 
30
30
  It drafts a short entry:
@@ -50,8 +50,8 @@ uncurated and can be revisited in a later `/day-wakeup`.
50
50
  The skill reads the next transcript:
51
51
 
52
52
  ```bash
53
- clast --json show 22222222-2222-4222-8222-222222222222 --full --turns 8
54
- clast breadcrumb --read --project notes --day yesterday
53
+ clast-plumbing --json show 22222222-2222-4222-8222-222222222222 --full --turns 8
54
+ clast-plumbing breadcrumb --read --project notes --day yesterday
55
55
  ```
56
56
 
57
57
  The user asks for a revision:
@@ -76,7 +76,7 @@ Tighten the note-entry template before using it for project briefings.
76
76
  The skill writes through stdin:
77
77
 
78
78
  ```bash
79
- clast entries write \
79
+ clast-plumbing entries write \
80
80
  --session 22222222-2222-4222-8222-222222222222 \
81
81
  --slug entry-template-cleanup \
82
82
  --tags notes,template \
@@ -89,8 +89,8 @@ clast entries write \
89
89
  The skill reads the session and breadcrumbs:
90
90
 
91
91
  ```bash
92
- clast --json show 11111111-1111-4111-8111-111111111111 --full --turns 8
93
- clast breadcrumb --read --project xesapps --day yesterday
92
+ clast-plumbing --json show 11111111-1111-4111-8111-111111111111 --full --turns 8
93
+ clast-plumbing breadcrumb --read --project xesapps --day yesterday
94
94
  ```
95
95
 
96
96
  The user chooses `Accept + promote decision` for this draft:
@@ -112,7 +112,7 @@ items are folded into the entry body rather than written to separate decision,
112
112
  common-issue, or workflow files.
113
113
 
114
114
  ```bash
115
- clast entries write \
115
+ clast-plumbing entries write \
116
116
  --session 11111111-1111-4111-8111-111111111111 \
117
117
  --slug field-normalization-fix \
118
118
  --tags xesapps,fields,regression \
@@ -133,5 +133,5 @@ Run `/wakeup <project>` to start working on a specific project today.
133
133
 
134
134
  What this changes on disk:
135
135
  `entries/2026-05-31-1340-notes-entry-template-cleanup.md` and `entries/2026-05-31-0912-xesapps-field-normalization-fix.md` were written.
136
- `.manifest.jsonl` was already current from `clast snapshot`; curation does not append to it.
136
+ `.manifest.jsonl` was already current from `clast-plumbing snapshot`; curation does not append to it.
137
137
  The skipped `infra-tools` session stays uncurated and remains eligible for a later `/day-wakeup`.
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
@@ -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.
@@ -204,10 +204,10 @@ clast_version() {
204
204
  # to stderr for usage errors.
205
205
  clast_usage() {
206
206
  cat <<'EOF'
207
- clast — Claude Code session journal
207
+ clast-plumbing — Claude Code session journal (deterministic core)
208
208
 
209
209
  Usage:
210
- clast [GLOBAL FLAGS] <subcommand> [ARGS...]
210
+ clast-plumbing [GLOBAL FLAGS] <subcommand> [ARGS...]
211
211
 
212
212
  Subcommands:
213
213
  whereami Show current path, registry, and journal state
@@ -229,5 +229,8 @@ Global flags:
229
229
  -q, --quiet Suppress informational stdout output
230
230
  --journal-dir P Override ~/.claude/journal/ (env: CLAST_JOURNAL_DIR)
231
231
  --projects-dir P Override ~/.claude/projects/ (env: CLAST_PROJECTS_DIR)
232
+
233
+ The user-facing porcelain (LLM-aware) is `clast`. Run `clast --help` for
234
+ the `wake` and `brief` subcommands.
232
235
  EOF
233
236
  }
@@ -26,13 +26,18 @@ _clast_manifest_now_iso() {
26
26
  date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ
27
27
  }
28
28
 
29
- # clast_manifest_append <session-id> <source> <snapshot> <source-mtime> <source-size> <day-bucket>
29
+ # clast_manifest_append <session-id> <source> <snapshot> <source-mtime> <source-size> <day-bucket> <msg-count> <first-ts> <last-ts>
30
30
  clast_manifest_append() {
31
- if [[ $# -ne 6 ]]; then
32
- clast_log_error "clast_manifest_append: expected 6 args, got $#"
31
+ if [[ $# -ne 9 ]]; then
32
+ clast_log_error "clast_manifest_append: expected 9 args, got $#"
33
33
  return 2
34
34
  fi
35
35
  local session_id="$1" source="$2" snapshot="$3" source_mtime="$4" source_size="$5" day_bucket="$6"
36
+ # Cached per-session metadata (step 21): msg_count is an integer line
37
+ # count; first_ts/last_ts are the transcript's first/last line timestamps
38
+ # ("" → stored as JSON null). Readers prefer these over re-reading the
39
+ # snapshot file, falling back to the file when a line predates the cache.
40
+ local msg_count="$7" first_ts="$8" last_ts="$9"
36
41
  local field
37
42
  for field in session_id source snapshot source_mtime source_size day_bucket; do
38
43
  if [[ -z "${!field}" ]]; then
@@ -44,6 +49,10 @@ clast_manifest_append() {
44
49
  clast_log_error "clast_manifest_append: source_size must be a non-negative integer, got '$source_size'"
45
50
  return 2
46
51
  fi
52
+ if ! [[ "$msg_count" =~ ^[0-9]+$ ]]; then
53
+ clast_log_error "clast_manifest_append: msg_count must be a non-negative integer, got '$msg_count'"
54
+ return 2
55
+ fi
47
56
 
48
57
  local journal_dir
49
58
  journal_dir="$(clast_journal_dir)"
@@ -62,7 +71,10 @@ clast_manifest_append() {
62
71
  --arg source_mtime "$source_mtime" \
63
72
  --argjson source_size "$source_size" \
64
73
  --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}')" || {
74
+ --argjson msg_count "$msg_count" \
75
+ --arg first_ts "$first_ts" \
76
+ --arg last_ts "$last_ts" \
77
+ '{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)}')" || {
66
78
  clast_log_error "clast_manifest_append: jq failed to build manifest line"
67
79
  return 1
68
80
  }
@@ -168,6 +180,7 @@ clast_manifest_rebuild_from_disk() {
168
180
  local count=0
169
181
  if [[ -d "$transcripts_root" ]]; then
170
182
  local snapshot day session_id mtime_epoch mtime_iso line
183
+ local msg_count first_ts last_ts
171
184
  # source / source_size are unrecoverable: the snapshot file's size is
172
185
  # the captured copy's size, not the original source's size at capture
173
186
  # time, and the source path is not encoded in the snapshot itself.
@@ -185,13 +198,23 @@ clast_manifest_rebuild_from_disk() {
185
198
  rm -f "$tmp_file"
186
199
  return 1
187
200
  fi
201
+ # source / source_size are unrecoverable, but the cached metadata
202
+ # (step 21) IS recoverable from the snapshot copy — compute it so
203
+ # doctor --fix backfills rather than emitting legacy-shaped lines.
204
+ first_ts="$(head -n1 "$snapshot" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
205
+ last_ts="$(tail -n1 "$snapshot" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
206
+ msg_count="$(wc -l <"$snapshot" 2>/dev/null | tr -d ' ')"
207
+ [[ "$msg_count" =~ ^[0-9]+$ ]] || msg_count=0
188
208
  line="$(jq -c -n \
189
209
  --arg session_id "$session_id" \
190
210
  --arg snapshot "${snapshot#"$journal_dir/"}" \
191
211
  --arg captured_at "$mtime_iso" \
192
212
  --arg source_mtime "$mtime_iso" \
193
213
  --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}')" || {
214
+ --argjson msg_count "$msg_count" \
215
+ --arg first_ts "$first_ts" \
216
+ --arg last_ts "$last_ts" \
217
+ '{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)}')" || {
195
218
  clast_log_error "clast_manifest_rebuild_from_disk: jq failed for '$snapshot'"
196
219
  rm -f "$tmp_file"
197
220
  return 1
@@ -0,0 +1,217 @@
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 /day-wakeup)
56
+ brief Project briefing (standalone equivalent of /wakeup)
57
+
58
+ Global flags:
59
+ -h, --help Print this usage and exit
60
+ --version Print version and exit
61
+
62
+ Env (required by `wake` and `brief`):
63
+ CLAST_LLM_BASE_URL e.g. https://api.openai.com/v1
64
+ CLAST_LLM_API_KEY bearer token
65
+ CLAST_LLM_MODEL e.g. gpt-4o, llama3
66
+
67
+ The deterministic core (whereami / snapshot / sessions / entries / …)
68
+ lives in `clast-plumbing`. Run `clast-plumbing --help` for that surface.
69
+ EOF
70
+ }
71
+
72
+ # --- Preflight ---------------------------------------------------------------
73
+
74
+ # clast_porcelain_preflight_llm — verify curl, jq, and the LLM env vars exist.
75
+ # Subcommands that don't call the LLM (none right now) can skip this.
76
+ clast_porcelain_preflight_llm() {
77
+ for tool in clast-plumbing curl jq; do
78
+ if ! command -v "$tool" >/dev/null 2>&1; then
79
+ clast_porcelain_die "required tool not found: $tool"
80
+ fi
81
+ done
82
+
83
+ local missing=0
84
+ if [[ -z "${CLAST_LLM_BASE_URL:-}" ]]; then
85
+ clast_porcelain_warn "CLAST_LLM_BASE_URL not set"; missing=1
86
+ fi
87
+ if [[ -z "${CLAST_LLM_API_KEY:-}" ]]; then
88
+ clast_porcelain_warn "CLAST_LLM_API_KEY not set"; missing=1
89
+ fi
90
+ if [[ -z "${CLAST_LLM_MODEL:-}" ]]; then
91
+ clast_porcelain_warn "CLAST_LLM_MODEL not set"; missing=1
92
+ fi
93
+
94
+ if (( missing )); then
95
+ cat >&2 <<'EOF'
96
+
97
+ Set these env vars before running clast wake / clast brief:
98
+
99
+ export CLAST_LLM_BASE_URL="https://api.openai.com/v1"
100
+ export CLAST_LLM_API_KEY="sk-..."
101
+ export CLAST_LLM_MODEL="gpt-4o"
102
+
103
+ Or for a local model (ollama, vllm, etc.):
104
+
105
+ export CLAST_LLM_BASE_URL="http://localhost:11434/v1"
106
+ export CLAST_LLM_API_KEY="unused"
107
+ export CLAST_LLM_MODEL="llama3"
108
+ EOF
109
+ exit 1
110
+ fi
111
+ }
112
+
113
+ # --- LLM call ----------------------------------------------------------------
114
+
115
+ # clast_porcelain_llm_chat <system-msg> <user-msg>
116
+ # POST a chat-completions request to $CLAST_LLM_BASE_URL/chat/completions
117
+ # and echo the assistant's text on stdout. Returns nonzero on HTTP error or
118
+ # empty response.
119
+ clast_porcelain_llm_chat() {
120
+ local system_msg="$1"
121
+ local user_msg="$2"
122
+
123
+ local payload
124
+ payload="$(jq -cn \
125
+ --arg model "$CLAST_LLM_MODEL" \
126
+ --arg system "$system_msg" \
127
+ --arg user "$user_msg" \
128
+ '{
129
+ model: $model,
130
+ messages: [
131
+ {role: "system", content: $system},
132
+ {role: "user", content: $user}
133
+ ],
134
+ temperature: 0.3
135
+ }')"
136
+
137
+ local response http_code body
138
+ response="$(curl -s -w '\n%{http_code}' \
139
+ "${CLAST_LLM_BASE_URL}/chat/completions" \
140
+ -H "Authorization: Bearer $CLAST_LLM_API_KEY" \
141
+ -H "Content-Type: application/json" \
142
+ -d "$payload" 2>&1)" || true
143
+
144
+ http_code="$(tail -n1 <<<"$response")"
145
+ body="$(sed '$d' <<<"$response")"
146
+
147
+ if [[ "$http_code" != "200" ]]; then
148
+ clast_porcelain_warn "LLM API returned HTTP $http_code"
149
+ if [[ -n "$body" ]]; then
150
+ local err_msg
151
+ err_msg="$(jq -r '.error.message // .error // .' <<<"$body" 2>/dev/null || echo "$body")"
152
+ clast_porcelain_warn "$err_msg"
153
+ fi
154
+ return 1
155
+ fi
156
+
157
+ local content
158
+ content="$(jq -r '.choices[0].message.content // empty' <<<"$body" 2>/dev/null)" || {
159
+ clast_porcelain_warn "failed to parse LLM response"
160
+ return 1
161
+ }
162
+
163
+ if [[ -z "$content" ]]; then
164
+ clast_porcelain_warn "LLM returned empty content"
165
+ return 1
166
+ fi
167
+
168
+ printf '%s' "$content"
169
+ }
170
+
171
+ # --- Prompt templates --------------------------------------------------------
172
+
173
+ # clast_porcelain_resolve_prompt_dir — echo the prompts dir, search order:
174
+ # 1. $CLAST_LIB/prompts (in-repo or installed alongside the lib)
175
+ # 2. /usr/local/lib/clast/prompts
176
+ # 3. $HOME/.local/lib/clast/prompts
177
+ clast_porcelain_resolve_prompt_dir() {
178
+ if [[ -n "${CLAST_LIB:-}" && -d "$CLAST_LIB/prompts" ]]; then
179
+ printf '%s' "$CLAST_LIB/prompts"
180
+ return
181
+ fi
182
+ local installed
183
+ for installed in /usr/local/lib/clast/prompts "$HOME/.local/lib/clast/prompts"; do
184
+ if [[ -d "$installed" ]]; then
185
+ printf '%s' "$installed"
186
+ return
187
+ fi
188
+ done
189
+ clast_porcelain_die "cannot find prompts directory (checked \$CLAST_LIB/prompts and install paths)"
190
+ }
191
+
192
+ # clast_porcelain_load_system_prompt <basename>
193
+ # Read the named system prompt (without extension) from the resolved
194
+ # prompts dir, e.g. clast_porcelain_load_system_prompt brief-system.
195
+ clast_porcelain_load_system_prompt() {
196
+ local name="$1"
197
+ local prompt_dir file
198
+ prompt_dir="$(clast_porcelain_resolve_prompt_dir)"
199
+ file="$prompt_dir/${name}.md"
200
+ if [[ ! -r "$file" ]]; then
201
+ clast_porcelain_die "system prompt not found: $file"
202
+ fi
203
+ cat "$file"
204
+ }
205
+
206
+ # clast_porcelain_user_prompt_file <basename>
207
+ # Echo the absolute path to the named user-prompt template, or empty string
208
+ # if missing. Callers do their own placeholder substitution.
209
+ clast_porcelain_user_prompt_file() {
210
+ local name="$1"
211
+ local prompt_dir file
212
+ prompt_dir="$(clast_porcelain_resolve_prompt_dir)"
213
+ file="$prompt_dir/${name}.md"
214
+ if [[ -r "$file" ]]; then
215
+ printf '%s' "$file"
216
+ fi
217
+ }
@@ -0,0 +1,167 @@
1
+ # clast brief — LLM-powered project briefing.
2
+ #
3
+ # Replicates the /wakeup plugin skill using an OpenAI-compatible chat
4
+ # completions endpoint. Reads curated entries, breadcrumbs, and today's
5
+ # sessions for a project (via clast-plumbing), then synthesizes a briefing.
6
+ #
7
+ # Usage: clast brief [<project-slug>]
8
+ # If no slug is given, resolves from the current working directory.
9
+ # shellcheck shell=bash
10
+
11
+ # --- Resolve project ---------------------------------------------------------
12
+
13
+ _clast_brief_resolve_project() {
14
+ local slug="${1:-}"
15
+
16
+ if [[ -n "$slug" ]]; then
17
+ printf '%s' "$slug"
18
+ return
19
+ fi
20
+
21
+ local resolved
22
+ resolved="$(clast-plumbing registry resolve "$(pwd)" 2>/dev/null)" || true
23
+ if [[ -n "$resolved" ]]; then
24
+ printf '%s' "$resolved"
25
+ return
26
+ fi
27
+
28
+ clast_porcelain_die "Not in a registered project. Run \`clast-plumbing registry add .\` first, or invoke as \`clast brief <slug>\`."
29
+ }
30
+
31
+ # --- Gather data -------------------------------------------------------------
32
+
33
+ _clast_brief_gather_entries() {
34
+ local project="$1"
35
+ local entries_json
36
+ entries_json="$(clast-plumbing --json entries --project "$project" --limit 5 2>/dev/null)" || true
37
+
38
+ if [[ -z "$entries_json" ]] || [[ "$(jq 'length' <<<"$entries_json" 2>/dev/null)" == "0" ]]; then
39
+ return
40
+ fi
41
+
42
+ local n i entry_meta entry_path entry_body result=""
43
+ n="$(jq 'length' <<<"$entries_json")"
44
+
45
+ for (( i = 0; i < n; i++ )); do
46
+ entry_meta="$(jq -c ".[$i]" <<<"$entries_json")"
47
+ entry_path="$(jq -r '.path' <<<"$entry_meta")"
48
+
49
+ entry_body="$(clast-plumbing entries read "$entry_path" 2>/dev/null)" || true
50
+ if [[ -z "$entry_body" ]]; then
51
+ local title date
52
+ title="$(jq -r '.title // "untitled"' <<<"$entry_meta")"
53
+ date="$(jq -r '.date' <<<"$entry_meta")"
54
+ entry_body="# $date — $title (body not available)"
55
+ fi
56
+
57
+ if [[ -n "$result" ]]; then
58
+ result="${result}
59
+
60
+ ---
61
+
62
+ "
63
+ fi
64
+ result="${result}${entry_body}"
65
+ done
66
+
67
+ printf '%s' "$result"
68
+ }
69
+
70
+ _clast_brief_gather_breadcrumbs() {
71
+ local project="$1"
72
+ clast-plumbing breadcrumb --read --project "$project" --day today 2>/dev/null || true
73
+ }
74
+
75
+ _clast_brief_gather_sessions() {
76
+ local project="$1"
77
+ local sessions_json
78
+ sessions_json="$(clast-plumbing --json sessions --day today --project "$project" 2>/dev/null)" || true
79
+
80
+ if [[ -z "$sessions_json" ]] || [[ "$(jq 'length' <<<"$sessions_json" 2>/dev/null)" == "0" ]]; then
81
+ return
82
+ fi
83
+
84
+ local n
85
+ n="$(jq 'length' <<<"$sessions_json")"
86
+
87
+ if (( n > 5 )); then
88
+ local latest_start
89
+ latest_start="$(jq -r 'sort_by(.start) | last | .start // ""' <<<"$sessions_json")"
90
+ printf 'Worked %d sessions today, most recent at %s.' "$n" "${latest_start:11:5}"
91
+ return
92
+ fi
93
+
94
+ jq -r '
95
+ sort_by(.start) | .[] |
96
+ "\(.start[11:16]) start: \(if .branch and .branch != "null" then .branch else "no branch" end), \(.msg_count_approx) messages"
97
+ ' <<<"$sessions_json" 2>/dev/null || true
98
+ }
99
+
100
+ # --- User prompt -------------------------------------------------------------
101
+
102
+ _clast_brief_build_user_prompt() {
103
+ local project="$1" entries="$2" breadcrumbs="$3" sessions="$4"
104
+
105
+ local template_file template
106
+ template_file="$(clast_porcelain_user_prompt_file brief-user)"
107
+
108
+ if [[ -n "$template_file" ]]; then
109
+ template="$(cat "$template_file")"
110
+ template="${template//\{\{project\}\}/${project}}"
111
+ template="${template//\{\{entries\}\}/${entries:-None.}}"
112
+ template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
113
+ template="${template//\{\{sessions\}\}/${sessions:-None.}}"
114
+ printf '%s' "$template"
115
+ else
116
+ clast_porcelain_warn "user prompt template not found: brief-user.md — using inline fallback"
117
+ cat <<EOF
118
+ Project: ${project}
119
+
120
+ Recent curated entries (newest first):
121
+ ${entries:-None.}
122
+
123
+ Today's breadcrumbs for this project:
124
+ ${breadcrumbs:-None.}
125
+
126
+ Today's session activity for this project:
127
+ ${sessions:-None.}
128
+ EOF
129
+ fi
130
+ }
131
+
132
+ # --- Main --------------------------------------------------------------------
133
+
134
+ clast_cmd_brief() {
135
+ clast_porcelain_preflight_llm
136
+
137
+ local project
138
+ project="$(_clast_brief_resolve_project "${1:-}")"
139
+ clast_porcelain_info "Briefing for project: $project"
140
+
141
+ clast_porcelain_info "Gathering context..."
142
+
143
+ local entries breadcrumbs sessions
144
+ entries="$(_clast_brief_gather_entries "$project")"
145
+ breadcrumbs="$(_clast_brief_gather_breadcrumbs "$project")"
146
+ sessions="$(_clast_brief_gather_sessions "$project")"
147
+
148
+ if [[ -z "$entries" && -z "$breadcrumbs" && -z "$sessions" ]]; then
149
+ clast_porcelain_info "No curated entries, breadcrumbs, or sessions for \`$project\`."
150
+ clast_porcelain_info "Run \`clast wake\` to curate recent sessions first, or run \`clast-plumbing sessions --project $project\` to see what's available."
151
+ return 0
152
+ fi
153
+
154
+ local system_prompt user_prompt
155
+ system_prompt="$(clast_porcelain_load_system_prompt brief-system)"
156
+ user_prompt="$(_clast_brief_build_user_prompt "$project" "$entries" "$breadcrumbs" "$sessions")"
157
+
158
+ clast_porcelain_info "Synthesizing briefing..."
159
+ printf '\n'
160
+
161
+ local briefing
162
+ if ! briefing="$(clast_porcelain_llm_chat "$system_prompt" "$user_prompt")"; then
163
+ clast_porcelain_die "LLM call failed"
164
+ fi
165
+
166
+ printf '%s\n' "$briefing"
167
+ }