@zalom/plastic 1.11.0 → 1.13.0

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/hooks/statusline CHANGED
@@ -1,57 +1,258 @@
1
1
  #!/bin/bash
2
- # plastic-hook-version: 3.0.0
2
+ # plastic-hook-version: 4.0.0
3
3
  # StatusLine hook - Plastic owns the full statusline (no chaining).
4
- # Renders: {model} - Plastic {version}[ -> {next}] - {path} - [{project}: {id} {title}]
5
- # Pure bash (macOS 3.2 compatible). No ruby, no jq. Reads stdin once; small file reads only.
4
+ # Renders: {model} - ctx {pct}% ({used}/{size}) - 5h {p}% - 7d {p}% - ${cost}
5
+ # - claudish {n} rw / {tok} - Plastic {version}[ -> {next}] - {path}
6
+ # Every segment but claudish and the Plastic version comes from the stdin payload.
7
+ # Pure bash (macOS 3.2 compatible). No ruby, no jq. Reads stdin once; small file reads
8
+ # only. The stdin JSON is walked in a single awk pass (below); nothing else in this
9
+ # script forks a second process per field, which is what keeps a render cheap.
6
10
 
7
- INPUT=$(cat)
11
+ IFS= read -r -d '' INPUT
8
12
 
9
13
  # --- Colors (ANSI-C quoting; bash 3.2 supports $'\033') ---
10
- C_MODEL=$'\033[38;2;90;140;220m' # navy blue - model (lightened for legibility)
11
- C_VER=$'\033[37m' # white - "Plastic {version}" and update arrow
12
- C_NEXT=$'\033[33m' # yellow - next version
13
- C_PATH=$'\033[38;2;128;134;144m' # gray - path (matches dim UI text)
14
- C_BRACK=$'\033[33m' # yellow - work-unit brackets
15
- C_PROJ=$'\033[38;2;249;140;30m' # orange - project name
16
- C_ID=$'\033[38;2;45;212;191m' # teal - intent id (accent between orange + yellow)
17
- C_INTENT=$'\033[33m' # yellow - intent title
18
- C_SEP=$'\033[38;2;128;134;144m' # gray - separators
14
+ C_MODEL=$'\033[38;2;90;140;220m' # navy blue - model
15
+ C_CTX=$'\033[38;2;45;212;191m' # teal - context window
16
+ C_OK='' # default - meter below 70%
17
+ C_WARN=$'\033[33m' # yellow - meter at 70% and above
18
+ C_CRIT=$'\033[38;2;231;76;60m' # red - meter at 90% and above
19
+ C_COST=$'\033[37m' # white - session cost
20
+ C_CLAUDISH=$'\033[38;2;249;140;30m' # orange - claudish rewrites
21
+ C_VER=$'\033[37m' # white - "Plastic {version}" and update arrow
22
+ C_NEXT=$'\033[33m' # yellow - next version
23
+ C_PATH=$'\033[38;2;128;134;144m' # gray - path (matches dim UI text)
24
+ C_SEP=$'\033[38;2;128;134;144m' # gray - separators
19
25
  RST=$'\033[0m'
20
26
  SEP="${C_SEP} "$'\302\267'" ${RST}" # " . " middot, dim
21
27
 
22
- # --- Extract a flat JSON string field from stdin: json_str <key> ---
23
- json_str() {
24
- printf '%s' "$INPUT" \
25
- | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" \
26
- | head -1 \
27
- | sed "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"//;s/\"$//"
28
+ # --- Truncate a JSON number to an integer ("42.5" -> "42"); empty stays empty ---
29
+ to_int() {
30
+ case "$1" in
31
+ ''|*[!0-9.]*) return ;;
32
+ esac
33
+ _i=${1%%.*}
34
+ [ -z "$_i" ] && _i=0
35
+ printf '%s' "$_i"
28
36
  }
29
37
 
30
- # --- Model (model.display_name) ---
31
- MODEL=$(json_str "display_name")
38
+ # --- Format a non-negative integer token count with k/M, rounded to the nearest
39
+ # unit; "M" once the count reaches 1,000,000, "k" below it. Empty stays empty. ---
40
+ fmt_tok() {
41
+ case "$1" in
42
+ ''|*[!0-9]*) return ;;
43
+ esac
44
+ if [ "$1" -ge 1000000 ]; then
45
+ printf '%dM' "$(( ($1 + 500000) / 1000000 ))"
46
+ else
47
+ printf '%dk' "$(( ($1 + 500) / 1000 ))"
48
+ fi
49
+ }
50
+
51
+ # --- Round a JSON decimal string to two places ("1.2345" -> "1.23"); empty or
52
+ # non-numeric (including anything with a stray sign or exponent letter) stays
53
+ # empty, matching to_int's guard. Pure bash arithmetic, no awk/printf fork. ---
54
+ fmt_money() {
55
+ case "$1" in
56
+ ''|*[!0-9.]*) return ;;
57
+ esac
58
+ _whole=${1%%.*}
59
+ case "$1" in
60
+ *.*) _frac=${1#*.} ;;
61
+ *) _frac="" ;;
62
+ esac
63
+ [ -z "$_whole" ] && _whole=0
64
+ _frac3="${_frac}000"
65
+ _frac3=${_frac3:0:3}
66
+ _cents=${_frac3:0:2}
67
+ _third=${_frac3:2:1}
68
+ [ -z "$_cents" ] && _cents=00
69
+ _cents=$((10#$_cents))
70
+ if [ "$_third" -ge 5 ]; then
71
+ _cents=$((_cents + 1))
72
+ if [ "$_cents" -ge 100 ]; then
73
+ _cents=0
74
+ _whole=$((_whole + 1))
75
+ fi
76
+ fi
77
+ printf '%d.%02d' "$_whole" "$_cents"
78
+ }
79
+
80
+ # --- Walk the stdin JSON exactly once and emit "path<TAB>value" for every field
81
+ # this script reads. A recursive-descent walker (not a regex or a brace-balanced
82
+ # line range) so nesting, pretty-printing, and single-line payloads all parse the
83
+ # same way, and a value inside an array can never be mistaken for one of the
84
+ # named top-level fields (an array suppresses emission for everything under it).
85
+ # A number token containing "e"/"E" is treated as unsupported and dropped, so
86
+ # scientific-notation input renders as absent rather than a wrong magnitude.
87
+ FIELDS=$(printf '%s' "$INPUT" | awk '
88
+ function skip_ws() {
89
+ while (i <= n) {
90
+ c = substr(buf, i, 1)
91
+ if (c == " " || c == "\t" || c == "\n" || c == "\r") { i++ } else { break }
92
+ }
93
+ }
94
+ function parse_string( c, esc, start, s) {
95
+ i++
96
+ start = i
97
+ esc = 0
98
+ while (i <= n) {
99
+ c = substr(buf, i, 1)
100
+ if (esc) { esc = 0; i++; continue }
101
+ if (c == "\\") { esc = 1; i++; continue }
102
+ if (c == "\"") { break }
103
+ i++
104
+ }
105
+ s = substr(buf, start, i - start)
106
+ i++
107
+ return s
108
+ }
109
+ function parse_number( start, c) {
110
+ start = i
111
+ if (substr(buf, i, 1) == "-") { i++ }
112
+ while (i <= n) {
113
+ c = substr(buf, i, 1)
114
+ if (c ~ /[0-9]/) { i++; continue }
115
+ if (c == "." || c == "e" || c == "E" || c == "+" || c == "-") { i++; continue }
116
+ break
117
+ }
118
+ return substr(buf, start, i - start)
119
+ }
120
+ function parse_value(key, prefix, emit, c, qp, val) {
121
+ skip_ws()
122
+ c = substr(buf, i, 1)
123
+ if (key != "") { qp = (prefix == "" ? key : prefix "." key) } else { qp = "" }
124
+ if (c == "{") {
125
+ parse_object(qp, emit)
126
+ } else if (c == "[") {
127
+ parse_array()
128
+ } else if (c == "\"") {
129
+ val = parse_string()
130
+ if (emit && qp != "" && (qp in wanted)) { print qp "\t" val }
131
+ } else if (c == "-" || (c >= "0" && c <= "9")) {
132
+ val = parse_number()
133
+ if (emit && qp != "" && (qp in wanted) && val !~ /[eE]/) { print qp "\t" val }
134
+ } else if (substr(buf, i, 4) == "true") {
135
+ i += 4
136
+ } else if (substr(buf, i, 5) == "false") {
137
+ i += 5
138
+ } else if (substr(buf, i, 4) == "null") {
139
+ i += 4
140
+ } else {
141
+ i++
142
+ }
143
+ }
144
+ function parse_object(prefix, emit, c, key) {
145
+ i++
146
+ skip_ws()
147
+ if (substr(buf, i, 1) == "}") { i++; return }
148
+ while (i <= n) {
149
+ skip_ws()
150
+ if (substr(buf, i, 1) != "\"") {
151
+ if (substr(buf, i, 1) == "}") { i++; return }
152
+ i++
153
+ continue
154
+ }
155
+ key = parse_string()
156
+ skip_ws()
157
+ if (substr(buf, i, 1) == ":") { i++ }
158
+ parse_value(key, prefix, emit)
159
+ skip_ws()
160
+ c = substr(buf, i, 1)
161
+ if (c == ",") { i++; continue }
162
+ if (c == "}") { i++; return }
163
+ return
164
+ }
165
+ }
166
+ function parse_array( c) {
167
+ i++
168
+ skip_ws()
169
+ if (substr(buf, i, 1) == "]") { i++; return }
170
+ while (i <= n) {
171
+ parse_value("", "", 0)
172
+ skip_ws()
173
+ c = substr(buf, i, 1)
174
+ if (c == ",") { i++; continue }
175
+ if (c == "]") { i++; return }
176
+ return
177
+ }
178
+ }
179
+ BEGIN {
180
+ wanted["model.display_name"] = 1
181
+ wanted["workspace.current_dir"] = 1
182
+ wanted["cwd"] = 1
183
+ wanted["session_id"] = 1
184
+ wanted["context_window.used_percentage"] = 1
185
+ wanted["context_window.context_window_size"] = 1
186
+ wanted["context_window.current_usage.input_tokens"] = 1
187
+ wanted["context_window.current_usage.cache_creation_input_tokens"] = 1
188
+ wanted["context_window.current_usage.cache_read_input_tokens"] = 1
189
+ wanted["cost.total_cost_usd"] = 1
190
+ wanted["rate_limits.five_hour.used_percentage"] = 1
191
+ wanted["rate_limits.seven_day.used_percentage"] = 1
192
+ }
193
+ { buf = buf $0 " " }
194
+ END {
195
+ n = length(buf)
196
+ i = 1
197
+ skip_ws()
198
+ if (substr(buf, i, 1) == "{") { parse_value("", "", 1) }
199
+ }
200
+ ')
201
+
202
+ MODEL=""
203
+ REAL_CWD=""
204
+ CWD_TOP=""
205
+ SID=""
206
+ CW_PCT=""
207
+ CW_SIZE=""
208
+ CU_IN=""
209
+ CU_CC=""
210
+ CU_CR=""
211
+ COST_RAW=""
212
+ M5_PCT=""
213
+ M7_PCT=""
214
+
215
+ while IFS=$'\t' read -r k v; do
216
+ case "$k" in
217
+ model.display_name) MODEL="$v" ;;
218
+ workspace.current_dir) REAL_CWD="$v" ;;
219
+ cwd) CWD_TOP="$v" ;;
220
+ session_id) SID="$v" ;;
221
+ context_window.used_percentage) CW_PCT="$v" ;;
222
+ context_window.context_window_size) CW_SIZE="$v" ;;
223
+ context_window.current_usage.input_tokens) CU_IN="$v" ;;
224
+ context_window.current_usage.cache_creation_input_tokens) CU_CC="$v" ;;
225
+ context_window.current_usage.cache_read_input_tokens) CU_CR="$v" ;;
226
+ cost.total_cost_usd) COST_RAW="$v" ;;
227
+ rate_limits.five_hour.used_percentage) M5_PCT="$v" ;;
228
+ rate_limits.seven_day.used_percentage) M7_PCT="$v" ;;
229
+ esac
230
+ done <<FIELDS_EOF
231
+ $FIELDS
232
+ FIELDS_EOF
32
233
 
33
234
  # --- Path (workspace.current_dir, fallback top-level cwd) ---
34
- REAL_CWD=$(json_str "current_dir")
35
- [ -z "$REAL_CWD" ] && REAL_CWD=$(json_str "cwd")
36
- # Abbreviate $HOME -> ~
235
+ [ -z "$REAL_CWD" ] && REAL_CWD="$CWD_TOP"
37
236
  CWD="$REAL_CWD"
38
237
  case "$CWD" in
39
238
  "$HOME") CWD="~" ;;
40
239
  "$HOME"/*) CWD="~${CWD#"$HOME"}" ;;
41
240
  esac
42
241
 
43
- # --- Plastic version ---
242
+ # --- Plastic version (read builtin, no tr fork) ---
44
243
  VERSION=""
45
- [ -f "$HOME/.plastic/VERSION" ] && VERSION=$(tr -d ' \t\n' < "$HOME/.plastic/VERSION")
244
+ if [ -f "$HOME/.plastic/VERSION" ]; then
245
+ IFS= read -r VERSION < "$HOME/.plastic/VERSION"
246
+ fi
46
247
 
47
- # --- Update (next version when one is available) ---
248
+ # --- Update (next version when one is available); pure-bash regex, no fork ---
48
249
  NEXT=""
49
250
  CACHE_FILE="$HOME/.plastic/.cache/update-check.json"
50
251
  if [ -f "$CACHE_FILE" ]; then
51
- if grep -q '"updateAvailable"[[:space:]]*:[[:space:]]*true' "$CACHE_FILE" 2>/dev/null; then
52
- LATEST=$(grep -o '"latest"[[:space:]]*:[[:space:]]*"[^"]*"' "$CACHE_FILE" 2>/dev/null \
53
- | head -1 | sed 's/.*"latest"[[:space:]]*:[[:space:]]*"//;s/"$//')
54
- if [ -n "$LATEST" ]; then
252
+ IFS= read -r -d '' CACHE_JSON < "$CACHE_FILE" || true
253
+ if [[ "$CACHE_JSON" =~ \"updateAvailable\"[[:space:]]*:[[:space:]]*true ]]; then
254
+ if [[ "$CACHE_JSON" =~ \"latest\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
255
+ LATEST="${BASH_REMATCH[1]}"
55
256
  case "$LATEST" in
56
257
  *-*) NEXT="${LATEST##*-}" ;; # 1.0.0-beta.3 -> beta.3
57
258
  *) NEXT="$LATEST" ;;
@@ -60,144 +261,88 @@ if [ -f "$CACHE_FILE" ]; then
60
261
  fi
61
262
  fi
62
263
 
63
- # --- Scope resolution: cwd -> registered project store, else global ---
64
- # Match the longest registered project path that prefixes the real cwd.
65
- PROJECTS_YML="$HOME/.plastic/projects.yml"
66
- SLUG=""
67
- if [ -n "$REAL_CWD" ] && [ -f "$PROJECTS_YML" ]; then
68
- SLUG=$(awk -v cwd="$REAL_CWD" '
69
- /^ [A-Za-z0-9_.-]+:[[:space:]]*$/ { s=$1; sub(/:$/,"",s); next }
70
- /^ path:/ {
71
- p=$0; sub(/^[[:space:]]*path:[[:space:]]*/,"",p); gsub(/"/,"",p);
72
- if (length(p)>0 && (cwd==p || index(cwd, p"/")==1) && length(p)>best) { best=length(p); win=s }
73
- }
74
- END { if (win!="") print win }
75
- ' "$PROJECTS_YML")
264
+ # --- Context window (stdin context_window) ---
265
+ # used_percentage is the documented input-side figure (input + cache creation +
266
+ # cache read), so it wins for the percentage. The token count in parentheses comes
267
+ # from current_usage when it is present. Each derives the other when only one is
268
+ # available; when both are null there is no ctx segment.
269
+ CTX_SEG=""
270
+ CW_SIZE=$(to_int "$CW_SIZE")
271
+ CW_PCT=$(to_int "$CW_PCT")
272
+ CW_USED=""
273
+ U_IN=$(to_int "$CU_IN")
274
+ U_CC=$(to_int "$CU_CC")
275
+ U_CR=$(to_int "$CU_CR")
276
+ if [ -n "$U_IN$U_CC$U_CR" ]; then
277
+ CW_USED=$(( ${U_IN:-0} + ${U_CC:-0} + ${U_CR:-0} ))
76
278
  fi
77
-
78
- if [ -n "$SLUG" ]; then
79
- INDEX="$HOME/.plastic/projects/$SLUG/INDEX.md"
80
- PROJ_LABEL="$SLUG"
81
- else
82
- INDEX="$HOME/.plastic/INDEX.md"
83
- PROJ_LABEL="global"
279
+ if [ -z "$CW_USED" ] && [ -n "$CW_PCT" ] && [ -n "$CW_SIZE" ] && [ "$CW_SIZE" -gt 0 ]; then
280
+ CW_USED=$(( CW_SIZE * CW_PCT / 100 ))
84
281
  fi
85
- INDEX_DIR=$(dirname "$INDEX")
86
-
87
- # --- Current-session work-unit: the live bridge wins over savepoint recency ---
88
- # The statusline receives the real session_id on stdin. A session can now own
89
- # SEVERAL per-intent bridges at once (intent 131, plastic-{session_id}--{id}.json),
90
- # one per concurrent delivery, so the statusline must pick which of them to show:
91
- # prefer a candidate whose worktree.code (or intent dir) prefixes REAL_CWD, else
92
- # the newest by mtime (ls -t is 3.2-safe); tolerate a legacy single-key
93
- # plastic-{session_id}.json when no per-intent file exists. This beats the shared,
94
- # savepoint-recency heuristic so parallel sessions on one store no longer
95
- # overwrite each other's line. grep/sed/ls only - no jq, no ruby.
96
- WORK=""
97
- SID=$(json_str "session_id")
98
-
99
- if [ -n "$SID" ]; then
100
- BR_TMP="${PLASTIC_TMP:-/tmp}"
101
- BRIDGE=""
102
- CANDIDATES=$(ls -1t "${BR_TMP}"/plastic-"${SID}"--*.json 2>/dev/null)
103
- if [ -n "$CANDIDATES" ]; then
104
- while IFS= read -r cand; do
105
- [ -z "$cand" ] && continue
106
- WT_BLOCK=$(sed -n '/"worktree"[[:space:]]*:[[:space:]]*{/,/}/p' "$cand" 2>/dev/null)
107
- WC=$(printf '%s\n' "$WT_BLOCK" | grep -o '"code"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
108
- | sed 's/.*"code"[[:space:]]*:[[:space:]]*"//;s/"$//')
109
- IN_BLOCK=$(sed -n '/"intent"[[:space:]]*:[[:space:]]*{/,/}/p' "$cand" 2>/dev/null)
110
- ISTORE=$(printf '%s\n' "$IN_BLOCK" | grep -o '"store"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
111
- | sed 's/.*"store"[[:space:]]*:[[:space:]]*"//;s/"$//')
112
- IDIR=$(printf '%s\n' "$IN_BLOCK" | grep -o '"dir"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
113
- | sed 's/.*"dir"[[:space:]]*:[[:space:]]*"//;s/"$//')
114
- MATCH=""
115
- if [ -n "$WC" ] && [ -n "$REAL_CWD" ]; then
116
- case "$REAL_CWD" in "$WC"|"$WC"/*) MATCH=1 ;; esac
117
- fi
118
- if [ -z "$MATCH" ] && [ -n "$ISTORE" ] && [ -n "$IDIR" ] && [ -n "$REAL_CWD" ]; then
119
- IPATH="$ISTORE/$IDIR"
120
- case "$REAL_CWD" in "$IPATH"|"$IPATH"/*) MATCH=1 ;; esac
121
- fi
122
- if [ -n "$MATCH" ]; then
123
- BRIDGE="$cand"
124
- break
125
- fi
126
- done <<BRIDGE_CAND_EOF
127
- $CANDIDATES
128
- BRIDGE_CAND_EOF
129
- # No cwd/worktree signal on any candidate: fail open to the newest (ls -t
130
- # already sorted CANDIDATES newest-first).
131
- [ -z "$BRIDGE" ] && BRIDGE=$(printf '%s\n' "$CANDIDATES" | head -1)
132
- else
133
- # Legacy tolerance: no per-intent bridge for this session, but an old
134
- # single-key file may still exist.
135
- LEGACY="${BR_TMP}/plastic-${SID}.json"
136
- [ -f "$LEGACY" ] && BRIDGE="$LEGACY"
137
- fi
138
-
139
- if [ -n "$BRIDGE" ] && [ -f "$BRIDGE" ]; then
140
- # Bridge JSON is pretty-printed (one field per line). Scope extraction to the
141
- # "intent" object so a sibling top-level "id"/"name" can never win (the intent
142
- # object holds only string fields, so the first "}" closes it). grep/sed only.
143
- INTENT_BLOCK=$(sed -n '/"intent"[[:space:]]*:[[:space:]]*{/,/}/p' "$BRIDGE" 2>/dev/null)
144
- B_ID=$(printf '%s\n' "$INTENT_BLOCK" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
145
- | sed 's/.*"id"[[:space:]]*:[[:space:]]*"//;s/"$//')
146
- B_NAME=$(printf '%s\n' "$INTENT_BLOCK" | grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
147
- | sed 's/.*"name"[[:space:]]*:[[:space:]]*"//;s/"$//')
148
- if [ -n "$B_ID" ] && [ -n "$B_NAME" ]; then
149
- EMDASH=$'\342\200\224'
150
- WORK="${B_ID} ${EMDASH} ${B_NAME}"
151
- fi
282
+ if [ -z "$CW_PCT" ] && [ -n "$CW_USED" ] && [ -n "$CW_SIZE" ] && [ "$CW_SIZE" -gt 0 ]; then
283
+ CW_PCT=$(( CW_USED * 100 / CW_SIZE ))
284
+ fi
285
+ if [ -n "$CW_PCT" ]; then
286
+ CTX_SEG="${C_CTX}ctx ${CW_PCT}%"
287
+ if [ -n "$CW_USED" ] && [ -n "$CW_SIZE" ] && [ "$CW_SIZE" -gt 0 ]; then
288
+ CTX_SEG="${CTX_SEG} ($(fmt_tok "$CW_USED")/$(fmt_tok "$CW_SIZE"))"
152
289
  fi
290
+ CTX_SEG="${CTX_SEG}${RST}"
153
291
  fi
154
292
 
155
- # --- Most-recently-accessed active intent (mirror dashboard: last savepoint ts) ---
156
- # Fallback only: runs when no session bridge resolved a work-unit above.
157
- if [ -z "$WORK" ] && [ -f "$INDEX" ]; then
158
- BEST_TS=""
159
- while IFS= read -r line; do
160
- [ -z "$line" ] && continue
161
- inside=${line#*[}; inside=${inside%%]*} # "{id} - {title}"
162
- dir=${line#*](store/}; dir=${dir%%/*} # intent directory name
163
- [ -z "$dir" ] && continue
164
- sp="$INDEX_DIR/store/$dir/savepoint.md"
165
- ts=""
166
- if [ -f "$sp" ]; then
167
- ts=$(grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' "$sp" 2>/dev/null | tail -1)
168
- fi
169
- # ISO8601 sorts lexicographically; keep the newest. Empty ts sorts lowest.
170
- if [ -z "$BEST_TS" ] || { [ -n "$ts" ] && [ "$ts" \> "$BEST_TS" ]; }; then
171
- BEST_TS="$ts"
172
- WORK="$inside"
173
- fi
174
- done <<EOF
175
- $(sed -n '/^## Active$/,/^## /{/^- \[/p;}' "$INDEX")
176
- EOF
293
+ # --- Subscription meters (stdin rate_limits; absent for API-key sessions) ---
294
+ meter_seg() { # $1 = label, $2 = raw percentage
295
+ _p=$(to_int "$2")
296
+ [ -z "$_p" ] && return
297
+ _c="$C_OK"
298
+ [ "$_p" -ge 70 ] && _c="$C_WARN"
299
+ [ "$_p" -ge 90 ] && _c="$C_CRIT"
300
+ printf '%s' "${_c}$1 ${_p}%${RST}"
301
+ }
302
+ M5=$(meter_seg "5h" "$M5_PCT")
303
+ M7=$(meter_seg "7d" "$M7_PCT")
304
+
305
+ # --- Session cost (stdin cost.total_cost_usd); hidden when it renders as $0.00 ---
306
+ COST_SEG=""
307
+ if [ -n "$COST_RAW" ]; then
308
+ COST_FMT=$(fmt_money "$COST_RAW")
309
+ [ -n "$COST_FMT" ] && [ "$COST_FMT" != "0.00" ] && COST_SEG="${C_COST}\$${COST_FMT}${RST}"
177
310
  fi
178
311
 
179
- # --- Build the work-unit "[{project}: {id} {title}]" with title truncation ---
180
- WORK_SEG=""
181
- if [ -n "$WORK" ]; then
182
- # Split "{id} - {title}" on the em-dash (U+2014).
183
- EMDASH=$'\342\200\224'
184
- id=${WORK%% $EMDASH *}
185
- title=${WORK#* $EMDASH }
186
- # Fallback if the em-dash form is absent: split on first space.
187
- if [ "$id" = "$WORK" ]; then id=${WORK%% *}; title=${WORK#* }; fi
188
- # Truncate long titles (~40 chars).
189
- if [ ${#title} -gt 40 ]; then title="$(printf '%.40s' "$title")..."; fi
190
- WORK_SEG="${C_BRACK}[${RST}${C_PROJ}${PROJ_LABEL}${RST}${C_INTENT}: ${RST}${C_ID}${id}${RST}${C_INTENT} ${title}${RST}${C_BRACK}]${RST}"
312
+ # --- claudish rewrites for THIS session (fork ledger, optional) ---
313
+ # Tab separated, one call per line; column 11 is the session id. Rows written
314
+ # before that column existed have 9 or 10 fields and never match, so an old
315
+ # ledger renders no segment instead of a wrong one.
316
+ CL_SEG=""
317
+ CL_LOG="${CLAUDISH_LOCAL_DIR:-$HOME/.claude/claudish-local}/usage.log"
318
+ if [ -n "$SID" ] && [ -f "$CL_LOG" ]; then
319
+ CL=$(awk -F'\t' -v sid="$SID" '
320
+ NF >= 11 && $11 == sid { n++; t += $5 + $6 }
321
+ END {
322
+ if (n > 0) {
323
+ if (t >= 1000) {
324
+ s = sprintf("%.1f", t / 1000); sub(/\.0$/, "", s); printf "%d %sk", n, s
325
+ } else {
326
+ printf "%d %d", n, t
327
+ }
328
+ }
329
+ }' "$CL_LOG" 2>/dev/null)
330
+ [ -n "$CL" ] && CL_SEG="${C_CLAUDISH}claudish ${CL%% *} rw / ${CL#* }${RST}"
191
331
  fi
192
332
 
193
333
  # --- Assemble segments in order, joined by " . " ---
194
334
  PARTS=""
195
335
  add() { [ -z "$1" ] && return; if [ -z "$PARTS" ]; then PARTS="$1"; else PARTS="$PARTS$SEP$1"; fi; }
196
336
 
197
- # Order: stable values first (model, work-unit), then the volatile-length
198
- # values last (version, path) so the line does not visually jump as they change.
337
+ # Order: identity first (model), then this session's spend (context, meters, cost,
338
+ # rewrites), then the volatile-length values last (version, path) so the line does
339
+ # not visually jump as they change.
199
340
  [ -n "$MODEL" ] && add "${C_MODEL}${MODEL}${RST}"
200
- [ -n "$WORK_SEG" ] && add "$WORK_SEG"
341
+ add "$CTX_SEG"
342
+ add "$M5"
343
+ add "$M7"
344
+ add "$COST_SEG"
345
+ add "$CL_SEG"
201
346
  if [ -n "$VERSION" ]; then
202
347
  vseg="${C_VER}Plastic ${VERSION}${RST}"
203
348
  [ -n "$NEXT" ] && vseg="${vseg}${C_VER} "$'\342\206\222'" ${RST}${C_NEXT}${NEXT}${RST}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {