@procrastivity/clast 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +9 -9
  2. package/bin/clast +13 -2
  3. package/bin/clast-plumbing +7 -0
  4. package/examples/workflows/morning-briefing.md +6 -6
  5. package/lib/clast/clast-classify-lib.bash +68 -0
  6. package/lib/clast/clast-dismissed-lib.bash +70 -0
  7. package/lib/clast/clast-lib.bash +88 -6
  8. package/lib/clast/clast-manifest-lib.bash +35 -5
  9. package/lib/clast/clast-porcelain-lib.bash +29 -16
  10. package/lib/clast/clast-porcelain-subcommands/brief.bash +90 -21
  11. package/lib/clast/clast-porcelain-subcommands/retro.bash +317 -0
  12. package/lib/clast/clast-porcelain-subcommands/undismiss.bash +27 -0
  13. package/lib/clast/clast-porcelain-subcommands/wake.bash +78 -13
  14. package/lib/clast/clast-registry-lib.bash +132 -27
  15. package/lib/clast/clast-retro-lib.bash +397 -0
  16. package/lib/clast/clast-subcommands/doctor.bash +29 -13
  17. package/lib/clast/clast-subcommands/entries.bash +40 -50
  18. package/lib/clast/clast-subcommands/projects.bash +9 -19
  19. package/lib/clast/clast-subcommands/registry.bash +26 -11
  20. package/lib/clast/clast-subcommands/retro.bash +217 -0
  21. package/lib/clast/clast-subcommands/sessions.bash +104 -4
  22. package/lib/clast/clast-subcommands/show.bash +54 -8
  23. package/lib/clast/clast-subcommands/snapshot.bash +18 -10
  24. package/lib/clast/prompts/brief-system.md +11 -5
  25. package/lib/clast/prompts/brief-user.md +4 -1
  26. package/lib/clast/prompts/retro-summary-system.md +14 -0
  27. package/lib/clast/prompts/retro-summary-user.md +7 -0
  28. package/lib/clast/prompts/{day-wakeup-draft-system.md → wake-draft-system.md} +1 -1
  29. package/package.json +2 -1
  30. package/{.claude-plugin/skills/wakeup → skills/brief}/SKILL.md +9 -9
  31. package/{.claude-plugin/skills/day-wakeup → skills/wake}/SKILL.md +35 -12
  32. /package/lib/clast/prompts/{day-wakeup-draft-user.md → wake-draft-user.md} +0 -0
package/README.md CHANGED
@@ -9,7 +9,7 @@ Capture, curate, and surface Claude Code session history across all your project
9
9
  - **CLI** — two binaries, porcelain over plumbing:
10
10
  - `clast` — porcelain (LLM-aware): `clast wake`, `clast brief`. See [run without Claude Code](./docs/guides/run-without-claude-code.md).
11
11
  - `clast-plumbing` — deterministic core: snapshot, browse, and query your Claude Code session JSONL history.
12
- - **Plugin** — installs skills (`/day-wakeup`, `/wakeup`) that surface recent session context.
12
+ - **Plugin** — installs skills (`/wake`, `/brief`) that surface recent session context.
13
13
  - **SessionStart hook** — quietly snapshots active sessions in the background each time Claude Code starts.
14
14
 
15
15
  ## Capture your sessions
@@ -66,7 +66,7 @@ clast-plumbing breadcrumb --read --project xesapps
66
66
  clast-plumbing breadcrumb --read --global
67
67
  ```
68
68
 
69
- Breadcrumbs are append-only one-line notes for `/wakeup` and `/day-wakeup`.
69
+ Breadcrumbs are append-only one-line notes for `/brief` and `/wake`.
70
70
  See [`docs/reference/cli.md#clast-breadcrumb`](./docs/reference/cli.md#clast-breadcrumb)
71
71
  for the full command contract.
72
72
 
@@ -144,21 +144,21 @@ current with zero manual effort. The hook is best-effort and silent: if the
144
144
  [`docs/reference/plugin.md#hook-sessionstart`](./docs/reference/plugin.md#hook-sessionstart)
145
145
  for the hook's design rationale.
146
146
 
147
- ### `/day-wakeup`
147
+ ### `/wake`
148
148
 
149
- At the start of each day, run `/day-wakeup` inside any Claude Code session after
149
+ At the start of each day, run `/wake` inside any Claude Code session after
150
150
  the plugin is installed. It performs once-per-day cross-project curation of
151
151
  yesterday's sessions into durable journal entries, walking each uncurated session
152
152
  through a draft you can accept, edit, skip, or mark for in-entry promotion. See
153
- [`docs/reference/plugin.md#skill-1-day-wakeup`](./docs/reference/plugin.md#skill-1-day-wakeup).
153
+ [`docs/reference/plugin.md#skill-1-wake`](./docs/reference/plugin.md#skill-1-wake).
154
154
 
155
- ### `/wakeup`
155
+ ### `/brief`
156
156
 
157
- When starting work on a specific project, run `/wakeup` (or `/wakeup <slug>` from
157
+ When starting work on a specific project, run `/brief` (or `/brief <slug>` from
158
158
  anywhere) to get a per-project read-only briefing synthesized from recent curated entries,
159
- today's breadcrumbs, and any sessions already started today. `/wakeup` never writes — it
159
+ today's breadcrumbs, and any sessions already started today. `/brief` never writes — it
160
160
  only reads. See
161
- [`docs/reference/plugin.md#skill-2-wakeup`](./docs/reference/plugin.md#skill-2-wakeup).
161
+ [`docs/reference/plugin.md#skill-2-brief`](./docs/reference/plugin.md#skill-2-brief).
162
162
 
163
163
  ## Development
164
164
 
package/bin/clast CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # clast — porcelain over clast-plumbing, with an OpenAI-compatible LLM
3
- # provider. Owns the `wake` and `brief` subcommands. Every other verb is a
4
- # clean error the porcelain does not proxy to plumbing.
3
+ # provider. Owns the `wake` and `brief` subcommands, plus `undismiss` as a
4
+ # convenience passthrough to plumbing (recovers an accidental [d] in wake).
5
+ # Every other verb is a clean error — the porcelain does not broadly proxy.
5
6
  #
6
7
  # Subcommand files live in $CLAST_LIB/clast-porcelain-subcommands/<name>.bash
7
8
  # and each defines a single function `clast_cmd_<name>`.
@@ -62,6 +63,16 @@ case "$cmd" in
62
63
  source "$CLAST_LIB/clast-porcelain-subcommands/brief.bash"
63
64
  clast_cmd_brief "$@"
64
65
  ;;
66
+ retro)
67
+ # shellcheck source=lib/clast/clast-porcelain-subcommands/retro.bash
68
+ source "$CLAST_LIB/clast-porcelain-subcommands/retro.bash"
69
+ clast_cmd_retro "$@"
70
+ ;;
71
+ undismiss)
72
+ # shellcheck source=lib/clast/clast-porcelain-subcommands/undismiss.bash
73
+ source "$CLAST_LIB/clast-porcelain-subcommands/undismiss.bash"
74
+ clast_cmd_undismiss "$@"
75
+ ;;
65
76
  *)
66
77
  clast_porcelain_log_error "unknown subcommand '$cmd'"
67
78
  clast_porcelain_usage >&2
@@ -18,6 +18,8 @@ source "$CLAST_LIB/clast-decode-lib.bash"
18
18
  source "$CLAST_LIB/clast-registry-lib.bash"
19
19
  # shellcheck source=lib/clast/clast-manifest-lib.bash
20
20
  source "$CLAST_LIB/clast-manifest-lib.bash"
21
+ # shellcheck source=lib/clast/clast-classify-lib.bash
22
+ source "$CLAST_LIB/clast-classify-lib.bash"
21
23
 
22
24
  # --- Global flag parsing -------------------------------------------------
23
25
  #
@@ -165,6 +167,11 @@ case "$cmd" in
165
167
  source "$CLAST_LIB/clast-subcommands/doctor.bash"
166
168
  clast_cmd_doctor "$@"
167
169
  ;;
170
+ retro)
171
+ # shellcheck source=lib/clast/clast-subcommands/retro.bash
172
+ source "$CLAST_LIB/clast-subcommands/retro.bash"
173
+ clast_cmd_retro "$@"
174
+ ;;
168
175
  *)
169
176
  clast_log_error "unknown subcommand '$cmd'"
170
177
  clast_usage >&2
@@ -1,11 +1,11 @@
1
1
  # Morning Briefing Example
2
2
 
3
- This is a realistic walkthrough of one `/day-wakeup` run. Yesterday, `xesapps`
3
+ This is a realistic walkthrough of one `/wake` run. Yesterday, `xesapps`
4
4
  finished a field-normalization fix, `notes` explored a journal template update,
5
5
  and `infra-tools` checked a flaky shell lint issue.
6
6
 
7
7
  ```bash
8
- # User: /day-wakeup
8
+ # User: /wake
9
9
  clast-plumbing snapshot
10
10
  clast-plumbing --json sessions --day yesterday
11
11
  ```
@@ -43,7 +43,7 @@ AskUserQuestion presents `Accept`, `Accept + promote decision`, `Accept + promot
43
43
  common-issue`, `Accept + promote workflow`, `Edit`, `Skip`, and `Stop here`.
44
44
 
45
45
  The user chooses `Skip`. The skill writes nothing; the session remains
46
- uncurated and can be revisited in a later `/day-wakeup`.
46
+ uncurated and can be revisited in a later `/wake`.
47
47
 
48
48
  ## Session 2: notes
49
49
 
@@ -123,15 +123,15 @@ clast-plumbing entries write \
123
123
  Final summary:
124
124
 
125
125
  ```text
126
- Day wakeup complete.
126
+ Wake complete.
127
127
  Curated: 2 sessions across 2 projects.
128
128
  Skipped: 1 session.
129
129
  Remaining uncurated: 1.
130
130
  Promoted: 1 decision (folded into the accepted entry for v1.0).
131
- Run `/wakeup <project>` to start working on a specific project today.
131
+ Run `/brief <project>` to start working on a specific project today.
132
132
  ```
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
136
  `.manifest.jsonl` was already current from `clast-plumbing snapshot`; curation does not append to it.
137
- The skipped `infra-tools` session stays uncurated and remains eligible for a later `/day-wakeup`.
137
+ The skipped `infra-tools` session stays uncurated and remains eligible for a later `/wake`.
@@ -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
+ }
@@ -72,3 +72,73 @@ clast_dismissed_check() {
72
72
 
73
73
  grep -q "\"session_id\":\"$session_id\"" "$dismissed_file" 2>/dev/null
74
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
@@ -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:
@@ -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,10 +33,10 @@ _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> <msg-count> <first-ts> <last-ts>
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 9 ]]; then
32
- clast_log_error "clast_manifest_append: expected 9 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"
@@ -38,6 +45,11 @@ clast_manifest_append() {
38
45
  # ("" → stored as JSON null). Readers prefer these over re-reading the
39
46
  # snapshot file, falling back to the file when a line predates the cache.
40
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}"
41
53
  local field
42
54
  for field in session_id source snapshot source_mtime source_size day_bucket; do
43
55
  if [[ -z "${!field}" ]]; then
@@ -53,6 +65,14 @@ clast_manifest_append() {
53
65
  clast_log_error "clast_manifest_append: msg_count must be a non-negative integer, got '$msg_count'"
54
66
  return 2
55
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
56
76
 
57
77
  local journal_dir
58
78
  journal_dir="$(clast_journal_dir)"
@@ -74,7 +94,9 @@ clast_manifest_append() {
74
94
  --argjson msg_count "$msg_count" \
75
95
  --arg first_ts "$first_ts" \
76
96
  --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)}')" || {
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}')" || {
78
100
  clast_log_error "clast_manifest_append: jq failed to build manifest line"
79
101
  return 1
80
102
  }
@@ -205,6 +227,12 @@ clast_manifest_rebuild_from_disk() {
205
227
  last_ts="$(tail -n1 "$snapshot" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
206
228
  msg_count="$(wc -l <"$snapshot" 2>/dev/null | tr -d ' ')"
207
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
208
236
  line="$(jq -c -n \
209
237
  --arg session_id "$session_id" \
210
238
  --arg snapshot "${snapshot#"$journal_dir/"}" \
@@ -214,7 +242,9 @@ clast_manifest_rebuild_from_disk() {
214
242
  --argjson msg_count "$msg_count" \
215
243
  --arg first_ts "$first_ts" \
216
244
  --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)}')" || {
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}')" || {
218
248
  clast_log_error "clast_manifest_rebuild_from_disk: jq failed for '$snapshot'"
219
249
  rm -f "$tmp_file"
220
250
  return 1
@@ -52,8 +52,10 @@ Usage:
52
52
  clast [GLOBAL FLAGS] <subcommand> [ARGS...]
53
53
 
54
54
  Subcommands:
55
- wake Interactive day curation (standalone equivalent of /day-wakeup)
56
- brief Project briefing (standalone equivalent of /wakeup)
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)
57
59
 
58
60
  Global flags:
59
61
  -h, --help Print this usage and exit
@@ -120,26 +122,37 @@ clast_porcelain_llm_chat() {
120
122
  local system_msg="$1"
121
123
  local user_msg="$2"
122
124
 
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
- }')"
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
136
148
 
137
149
  local response http_code body
138
150
  response="$(curl -s -w '\n%{http_code}' \
139
151
  "${CLAST_LLM_BASE_URL}/chat/completions" \
140
152
  -H "Authorization: Bearer $CLAST_LLM_API_KEY" \
141
153
  -H "Content-Type: application/json" \
142
- -d "$payload" 2>&1)" || true
154
+ --data-binary @"$payload_file" 2>&1)" || true
155
+ rm -f "$payload_file"
143
156
 
144
157
  http_code="$(tail -n1 <<<"$response")"
145
158
  body="$(sed '$d' <<<"$response")"