@zalom/plastic 1.12.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.12.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": {
@@ -37,8 +37,9 @@ module Bridge
37
37
  # `## Active` block); such bridges are purged. An Active intent's bridge is kept
38
38
  # unconditionally, because while the intent is live the bridge is still load-
39
39
  # bearing: it is the continuation signal (a parked or interrupted run resumes
40
- # from it) and the anti-collision lock (it keys the per-session statusline so
41
- # parallel sessions do not overwrite each other). An age window was the wrong
40
+ # from it) and the anti-collision lock for parallel deliveries on one store,
41
+ # keeping each session's gate checks and locks from overwriting another's. An
42
+ # age window was the wrong
42
43
  # axis: it left dead bridges resident for ~2 days AND could reap bridges of
43
44
  # interrupted-but-still-active intents, which are exactly the ones to preserve.
44
45
 
@@ -121,7 +122,7 @@ module Bridge
121
122
  # The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
122
123
  # session id (Claude Code passes session_id on stdin, not via an env var; the
123
124
  # headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
124
- # a derived hash) lets the statusline, which receives that same id on stdin, find
125
+ # a derived hash) lets the gate hooks, which receive that same id on stdin, find
125
126
  # the bridge by direct filename lookup.
126
127
  def self.resolve_session(explicit, intent_id:, store:)
127
128
  return explicit.to_s.strip unless blank?(explicit)
@@ -107,6 +107,19 @@ class Doctor
107
107
  path.sub(Dir.home, "~")
108
108
  end
109
109
 
110
+ # Every hook command string registered under one settings.json event, flattened
111
+ # across that event's matcher groups. Shape-tolerant on purpose: settings.json is
112
+ # hand-editable and may carry anything at all under an event key.
113
+ def event_commands(groups)
114
+ return [] unless groups.is_a?(Array)
115
+
116
+ groups.flat_map do |group|
117
+ next [] unless group.is_a?(Hash) && group["hooks"].is_a?(Array)
118
+
119
+ group["hooks"].map { |h| h.is_a?(Hash) ? h["command"].to_s : "" }
120
+ end
121
+ end
122
+
110
123
  def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
111
124
  result = {
112
125
  category: category,
@@ -270,14 +283,30 @@ class Doctor
270
283
  )
271
284
  else
272
285
  hooks = settings["hooks"] || {}
286
+
287
+ # A live registration is a launcher Plastic ships TODAY (intent 277).
288
+ # claude_purge_command?, which this replaced, answers "was this ever ours":
289
+ # right for the installer's purge, wrong here, because a SessionStart
290
+ # carrying only the retired plastic-lock-gate satisfied the event while
291
+ # nothing shipped to run it.
273
292
  missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
274
- groups = hooks[event]
275
- next false unless groups.is_a?(Array)
293
+ event_commands(hooks[event]).any? { |cmd| HookRegistry.claude_current_command?(cmd) }
294
+ end
276
295
 
277
- groups.any? do |group|
278
- group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
279
- group["hooks"].any? { |h| HookRegistry.claude_purge_command?(h["command"]) }
280
- end
296
+ # Name the launcher when a missing event still carries a Plastic-owned
297
+ # command. Inside a missing event every such command is by construction not
298
+ # a current one, and a bare "SessionStart" reads as "nothing registered" to
299
+ # someone looking at a settings.json that plainly holds a plastic- entry.
300
+ # Events with no Plastic entry keep the bare name: two tests compare details
301
+ # by element equality and by count.
302
+ missing_details = missing_events.map do |event|
303
+ stale = event_commands(hooks[event])
304
+ .flat_map { |cmd| HookRegistry.command_basenames(cmd) }
305
+ .select { |name| HookRegistry.claude_purgeable_launcher_names.include?(name) }
306
+ .uniq
307
+ next event if stale.empty?
308
+
309
+ "#{event} (registered command is not a current Plastic hook: #{stale.join(', ')})"
281
310
  end
282
311
 
283
312
  if missing_events.empty?
@@ -289,7 +318,7 @@ class Doctor
289
318
  checks << check(
290
319
  category: "agent_registration", name: "hooks_registered", status: "fail",
291
320
  message: "#{missing_events.size} hook event(s) not registered in settings.json",
292
- details: missing_events,
321
+ details: missing_details,
293
322
  fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
294
323
  )
295
324
  end
@@ -244,6 +244,26 @@ module HookRegistry
244
244
  command_basenames(cmd).any? { |name| known.include?(name) }
245
245
  end
246
246
 
247
+ # Is this settings.json hook command one Plastic registers TODAY? (intent 277)
248
+ #
249
+ # The narrower twin of claude_purge_command?. The purge asks "was this ever
250
+ # ours", because it has to recognise an old entry in order to remove it. A
251
+ # liveness check asks "is there something here that still runs", and the two
252
+ # sets differ by exactly the entries that make the answers disagree: a
253
+ # SessionStart carrying only plastic-lock-gate satisfied doctor's
254
+ # hooks_registered while no such launcher ships anymore.
255
+ #
256
+ # Sourced from claude_launcher_names alone, so RETIRED_CLAUDE_LAUNCHERS is out
257
+ # (that is the bug) and CLAUDE_NON_HOOK_LAUNCHERS is out too: plastic-statusline
258
+ # is settings["statusLine"], not a hook of any event, so a group whose only
259
+ # Plastic entry is the statusline command has no hook registered in it.
260
+ # Tokenised through command_basenames like the purge predicate, so a quoted
261
+ # path or the legacy `ruby <path>/plastic-<name>.rb` form still resolves.
262
+ def claude_current_command?(cmd)
263
+ known = claude_launcher_names
264
+ command_basenames(cmd).any? { |name| known.include?(name) }
265
+ end
266
+
247
267
  # Is this ~/.codex/hooks.json command one of ours? Every Plastic Codex entry
248
268
  # invokes our dispatcher by path (`"<plastic_home>/scripts/codex-hook" <name>`),
249
269
  # so the dispatcher's filename identifies it. Basename EQUALITY, so a user's
@@ -31,8 +31,8 @@
31
31
  #
32
32
  # outcome is an end-of-Exec step. Run it once the diff and the test summary exist, right
33
33
  # before plastic-intent-ending writes the narrative. Writing outcome.md makes the intent
34
- # read as Done to the statusline and to any stage-derived display (Bridge.derive_stage
35
- # keys on outcome.md's presence). This does NOT purge a bridge:
34
+ # read as Done to any stage-derived display (Bridge.derive_stage keys on outcome.md's
35
+ # presence). This does NOT purge a bridge:
36
36
  # Bridge.purge_done_bridges keys on INDEX Active status plus lock presence, never on the
37
37
  # derived stage.
38
38