@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 +300 -155
- package/package.json +1 -1
- package/scripts/lib/bridge.rb +4 -3
- package/scripts/lib/doctor_core.rb +36 -7
- package/scripts/lib/hook_registry.rb +20 -0
- package/scripts/scaffold-intent +2 -2
package/hooks/statusline
CHANGED
|
@@ -1,57 +1,258 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# plastic-hook-version:
|
|
2
|
+
# plastic-hook-version: 4.0.0
|
|
3
3
|
# StatusLine hook - Plastic owns the full statusline (no chaining).
|
|
4
|
-
# Renders: {model} -
|
|
5
|
-
#
|
|
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
|
|
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'
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
# ---
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
# ---
|
|
31
|
-
|
|
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
|
|
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" ]
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
# ---
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
# ---
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
# ---
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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:
|
|
198
|
-
# values last (version, path) so the line does
|
|
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
|
-
|
|
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
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
|
41
|
-
#
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
293
|
+
event_commands(hooks[event]).any? { |cmd| HookRegistry.claude_current_command?(cmd) }
|
|
294
|
+
end
|
|
276
295
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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:
|
|
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
|
package/scripts/scaffold-intent
CHANGED
|
@@ -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
|
|
35
|
-
#
|
|
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
|
|