@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
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 (`/
|
|
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 `/
|
|
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
|
-
### `/
|
|
147
|
+
### `/wake`
|
|
148
148
|
|
|
149
|
-
At the start of each day, run `/
|
|
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-
|
|
153
|
+
[`docs/reference/plugin.md#skill-1-wake`](./docs/reference/plugin.md#skill-1-wake).
|
|
154
154
|
|
|
155
|
-
### `/
|
|
155
|
+
### `/brief`
|
|
156
156
|
|
|
157
|
-
When starting work on a specific project, run `/
|
|
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. `/
|
|
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-
|
|
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
|
|
4
|
-
#
|
|
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
|
package/bin/clast-plumbing
CHANGED
|
@@ -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 `/
|
|
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: /
|
|
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 `/
|
|
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
|
-
|
|
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 `/
|
|
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 `/
|
|
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
|
+
}
|
package/lib/clast/clast-lib.bash
CHANGED
|
@@ -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
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
32
|
-
clast_log_error "clast_manifest_append: expected
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
56
|
-
brief Project briefing (standalone equivalent of /
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
-
|
|
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")"
|