@sandgarden/miranda 0.0.0-stage → 0.1.75
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/collector/stub.sh +463 -0
- package/index.js +1 -0
- package/miranda.js +114 -0
- package/package.json +16 -4
- package/skills/setting-up-usage-tracking/SKILL.md +299 -0
- package/skills/setting-up-usage-tracking/USING-MIRANDA.md +298 -0
- package/README.md +0 -3
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# gloria collector download stub
|
|
3
|
+
# (docs/plans/2026-08-21-collector-shell-bootstrap-design.md, superseding the
|
|
4
|
+
# node stub of docs/plans/2026-07-10-collector-binary-distribution-design.md)
|
|
5
|
+
#
|
|
6
|
+
# This single self-contained file ships verbatim into the published plugin as
|
|
7
|
+
# plugins/<name>/collector/stub.sh. It is POSIX `sh` on purpose: every plugin
|
|
8
|
+
# hook command is ALREADY a shell-form string that Claude Code evaluates with
|
|
9
|
+
# `sh -c` on macOS/Linux and Git Bash on Windows, so `sh` is not a new
|
|
10
|
+
# prerequisite — it is what runs the hook line today. The previous stub was
|
|
11
|
+
# node, which made a JS runtime a hard host requirement and silently disabled
|
|
12
|
+
# usage tracking on machines without one (#761).
|
|
13
|
+
#
|
|
14
|
+
# It downloads the compiled collector binary for this platform once per build
|
|
15
|
+
# version from the published repo's GitHub Release, verifies it against the
|
|
16
|
+
# SHA-256 checksums stamped below at publish time, caches it under the
|
|
17
|
+
# collector's state directory (`bin/`), and runs it with argv + stdin passed
|
|
18
|
+
# through, mirroring its exit code.
|
|
19
|
+
#
|
|
20
|
+
# Contract (it runs inside the agent loop, like the hooks it fronts):
|
|
21
|
+
# - ANY failure — offline, GitHub down, unsupported platform, checksum
|
|
22
|
+
# mismatch, no curl, no hashing tool — logs ONE line to the state
|
|
23
|
+
# directory's collector.log and exits 0. The next hook fire retries. It must
|
|
24
|
+
# never break an agent session.
|
|
25
|
+
# - Checksum mismatch NEVER executes the downloaded bytes: the stamped
|
|
26
|
+
# checksums are the trust anchor (a substituted release asset fails closed),
|
|
27
|
+
# and unverified bytes are deleted rather than renamed into the cache.
|
|
28
|
+
# - GLORIA_COLLECTOR_BIN=<path> bypasses download entirely (local builds,
|
|
29
|
+
# air-gapped installs, corp mirrors).
|
|
30
|
+
#
|
|
31
|
+
# Host tools: sh, curl, and one of sha256sum / shasum / openssl / certutil,
|
|
32
|
+
# plus the usual coreutils. There is deliberately no wget fallback: wget cannot
|
|
33
|
+
# report the effective final URL, so a wget path would silently drop the
|
|
34
|
+
# final-host allowlist below.
|
|
35
|
+
|
|
36
|
+
# Stamped by scripts/publish-marketplace/stage.mjs (run by
|
|
37
|
+
# .github/workflows/publish-marketplace.yml) at publish time — the source tree
|
|
38
|
+
# always carries the placeholder values (same mechanism as check-version.mjs's
|
|
39
|
+
# INSTALLED_VERSION). Each placeholder appears exactly once so the workflow's
|
|
40
|
+
# sed + grep verification can't miss.
|
|
41
|
+
#
|
|
42
|
+
# RELEASE_REPO and ASSET_PREFIX are stamped at publish time
|
|
43
|
+
# (scripts/publish-marketplace/stage.mjs): every plugin that bundles this stub
|
|
44
|
+
# now downloads the same release from "sandgardenhq/plugins" with the
|
|
45
|
+
# "miranda-collector" prefix (#813). They stay placeholders rather than
|
|
46
|
+
# literals so the release host can move without editing this file.
|
|
47
|
+
BUILD_VERSION="a1f67fd00034"
|
|
48
|
+
RELEASE_TAG="collector-a1f67fd00034"
|
|
49
|
+
RELEASE_REPO="sandgardenhq/plugins"
|
|
50
|
+
ASSET_PREFIX="miranda-collector"
|
|
51
|
+
CHECKSUM_DARWIN_ARM64="6f45d8c3bc3140d834f80801da6898865cd5ee943f1d8baf9a9d29d2f4615d63"
|
|
52
|
+
CHECKSUM_DARWIN_X64="040c2fb941b000703ea29e9712e33765445eaa91fc328f59bd113548e1e8e10d"
|
|
53
|
+
CHECKSUM_LINUX_X64="a5d0a38637cb9ca3942ff16a4d19fef42865e2e0eaa9a80817895869222a613e"
|
|
54
|
+
CHECKSUM_LINUX_ARM64="29c6ec818a15ae698ee421a2ef3b1d348711f55a64df10094d1a36d8cf43dca8"
|
|
55
|
+
CHECKSUM_WINDOWS_X64="79acbbcdacf1c5f547015dd3ab58366acd8b99c195b5911520e49ca28967d369"
|
|
56
|
+
|
|
57
|
+
# A download lock older than this is a downloader that died mid-run: take it
|
|
58
|
+
# over (mirrors the collector's sweep-lock staleness cutoff).
|
|
59
|
+
LOCK_STALE_MINUTES=10
|
|
60
|
+
# A *.download-* temp older than this is a download that was SIGKILLed
|
|
61
|
+
# mid-write (the cleanup never ran): safe to sweep — a live download finishes
|
|
62
|
+
# in well under an hour or its lock has long gone stale.
|
|
63
|
+
DOWNLOAD_TEMP_STALE_MINUTES=60
|
|
64
|
+
LOG_MAX_BYTES=1048576
|
|
65
|
+
CURL_MAX_SECONDS=600
|
|
66
|
+
|
|
67
|
+
# What `hook-session-start` tells the agent when no collector could be handed
|
|
68
|
+
# to it (#1568). The binary is the thing that is missing, so nothing downstream
|
|
69
|
+
# can say so; this is the one place that can. The wording is a copy of
|
|
70
|
+
# COLLECTOR_NOT_INSTALLED_NUDGE in hooks.ts (a test pins the two together) and
|
|
71
|
+
# of the fallback in the Miranda plugin's hooks.json. No quote characters in the
|
|
72
|
+
# text, so it needs no JSON escaping here.
|
|
73
|
+
NOT_INSTALLED_NUDGE='{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"The usage collector is not installed or could not be set up on this machine, so no token usage is being recorded. Tell the user now: reinstall or update the Miranda plugin and start a new session; the cause is logged in collector.log under ~/.config/sandgarden."}}'
|
|
74
|
+
|
|
75
|
+
# The pre-#783 state directory, kept only as a migration source.
|
|
76
|
+
LEGACY_HOME_DIR_NAME=".gloria"
|
|
77
|
+
|
|
78
|
+
# Shells expand a leading "~" before we ever see it — hook configs and env
|
|
79
|
+
# files don't, so we have to.
|
|
80
|
+
expand_tilde() {
|
|
81
|
+
case "$1" in
|
|
82
|
+
"~/"*) printf '%s' "$HOME/${1#\~/}" ;;
|
|
83
|
+
*) printf '%s' "$1" ;;
|
|
84
|
+
esac
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# The explicit full-path override in effect; returns 1 (printing nothing) when
|
|
88
|
+
# there is none. GLORIA_HOME is the deprecated alias of SANDGARDEN_HOME — it
|
|
89
|
+
# names one of the two products sharing this directory, which is exactly what
|
|
90
|
+
# #783 fixes, but a user who set it deliberately chose that path and must not
|
|
91
|
+
# have their state silently relocated. Empty reads as unset.
|
|
92
|
+
home_override() {
|
|
93
|
+
if [ -n "${SANDGARDEN_HOME-}" ]; then
|
|
94
|
+
expand_tilde "$SANDGARDEN_HOME"
|
|
95
|
+
return 0
|
|
96
|
+
fi
|
|
97
|
+
if [ -n "${GLORIA_HOME-}" ]; then
|
|
98
|
+
expand_tilde "$GLORIA_HOME"
|
|
99
|
+
return 0
|
|
100
|
+
fi
|
|
101
|
+
return 1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Mirrors state.ts's collectorHome(), which this file has to duplicate rather
|
|
105
|
+
# than share: the directory must be resolved before the binary that would
|
|
106
|
+
# resolve it exists. Precedence:
|
|
107
|
+
# SANDGARDEN_HOME | GLORIA_HOME | $XDG_CONFIG_HOME/sandgarden | ~/.config/sandgarden
|
|
108
|
+
collector_home() {
|
|
109
|
+
if ch_override="$(home_override)"; then
|
|
110
|
+
printf '%s' "$ch_override"
|
|
111
|
+
return 0
|
|
112
|
+
fi
|
|
113
|
+
if [ -n "${XDG_CONFIG_HOME-}" ]; then
|
|
114
|
+
printf '%s/sandgarden' "$(expand_tilde "$XDG_CONFIG_HOME")"
|
|
115
|
+
else
|
|
116
|
+
printf '%s/.config/sandgarden' "$HOME"
|
|
117
|
+
fi
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Append one line to the state directory's collector.log (rotating once past
|
|
121
|
+
# 1 MB, like the collector's own logger). Logging must never fail — exit 0
|
|
122
|
+
# still holds.
|
|
123
|
+
log_error() {
|
|
124
|
+
le_home="$(collector_home)"
|
|
125
|
+
mkdir -p "$le_home" 2>/dev/null || return 0
|
|
126
|
+
le_log="$le_home/collector.log"
|
|
127
|
+
if [ -f "$le_log" ]; then
|
|
128
|
+
le_size="$(wc -c <"$le_log" 2>/dev/null | tr -d ' ')"
|
|
129
|
+
case "$le_size" in
|
|
130
|
+
'' | *[!0-9]*) le_size=0 ;;
|
|
131
|
+
esac
|
|
132
|
+
if [ "$le_size" -gt "$LOG_MAX_BYTES" ]; then
|
|
133
|
+
mv -f "$le_log" "$le_log.1" 2>/dev/null || : # rename overwrites the old .1
|
|
134
|
+
fi
|
|
135
|
+
fi
|
|
136
|
+
le_stamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)"
|
|
137
|
+
printf '%s collector-stub error: %s\n' "$le_stamp" "$1" >>"$le_log" 2>/dev/null || :
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Map uname's platform/arch to the release asset. Sets ASSET_KEY, ASSET_EXT and
|
|
142
|
+
# ASSET_NAME; returns 1 (leaving the pair in PLATFORM_LABEL) when unsupported.
|
|
143
|
+
# MINGW/MSYS/CYGWIN are how Git Bash — the shell Claude Code runs hooks in on
|
|
144
|
+
# Windows — reports itself.
|
|
145
|
+
resolve_asset() {
|
|
146
|
+
ra_sys="$(uname -s 2>/dev/null || printf 'unknown')"
|
|
147
|
+
ra_machine="$(uname -m 2>/dev/null || printf 'unknown')"
|
|
148
|
+
case "$ra_sys" in
|
|
149
|
+
Darwin) ra_platform=darwin ;;
|
|
150
|
+
Linux) ra_platform=linux ;;
|
|
151
|
+
MINGW* | MSYS* | CYGWIN* | Windows_NT) ra_platform=windows ;;
|
|
152
|
+
*) ra_platform="$ra_sys" ;;
|
|
153
|
+
esac
|
|
154
|
+
case "$ra_machine" in
|
|
155
|
+
arm64 | aarch64) ra_arch=arm64 ;;
|
|
156
|
+
x86_64 | amd64) ra_arch=x64 ;;
|
|
157
|
+
*) ra_arch="$ra_machine" ;;
|
|
158
|
+
esac
|
|
159
|
+
PLATFORM_LABEL="$ra_platform-$ra_arch"
|
|
160
|
+
ASSET_EXT=""
|
|
161
|
+
case "$PLATFORM_LABEL" in
|
|
162
|
+
darwin-arm64 | darwin-x64 | linux-x64 | linux-arm64) ASSET_KEY="$PLATFORM_LABEL" ;;
|
|
163
|
+
windows-x64)
|
|
164
|
+
ASSET_KEY="windows-x64"
|
|
165
|
+
ASSET_EXT=".exe"
|
|
166
|
+
;;
|
|
167
|
+
*) return 1 ;;
|
|
168
|
+
esac
|
|
169
|
+
ASSET_NAME="$ASSET_PREFIX-$ASSET_KEY$ASSET_EXT"
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# The stamped checksum for an asset key. POSIX sh has no associative arrays,
|
|
174
|
+
# and one variable per platform is also what keeps every checksum placeholder
|
|
175
|
+
# appearing exactly once for the publish workflow's sed (which has no /g, and
|
|
176
|
+
# whose grep verification would otherwise pass on a half-stamped file).
|
|
177
|
+
checksum_for() {
|
|
178
|
+
case "$1" in
|
|
179
|
+
darwin-arm64) printf '%s' "$CHECKSUM_DARWIN_ARM64" ;;
|
|
180
|
+
darwin-x64) printf '%s' "$CHECKSUM_DARWIN_X64" ;;
|
|
181
|
+
linux-x64) printf '%s' "$CHECKSUM_LINUX_X64" ;;
|
|
182
|
+
linux-arm64) printf '%s' "$CHECKSUM_LINUX_ARM64" ;;
|
|
183
|
+
windows-x64) printf '%s' "$CHECKSUM_WINDOWS_X64" ;;
|
|
184
|
+
esac
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Lowercase hex SHA-256 of a file, using whichever tool this host has.
|
|
188
|
+
# certutil is the Windows built-in fallback for a Git Bash without coreutils.
|
|
189
|
+
# Returns 1 (and prints nothing) when the host has NO usable tool, which the
|
|
190
|
+
# caller reports differently from a tool that ran and failed.
|
|
191
|
+
sha256_of() {
|
|
192
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
193
|
+
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
|
194
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
195
|
+
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
|
|
196
|
+
elif command -v openssl >/dev/null 2>&1; then
|
|
197
|
+
openssl dgst -sha256 "$1" 2>/dev/null | awk '{print $NF}'
|
|
198
|
+
elif command -v certutil >/dev/null 2>&1; then
|
|
199
|
+
certutil -hashfile "$1" SHA256 2>/dev/null | sed -n '2p' | tr -d ' \r' | tr 'A-F' 'a-f'
|
|
200
|
+
else
|
|
201
|
+
return 1
|
|
202
|
+
fi
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Print the not-installed nudge, for `hook-session-start` only — the one hook
|
|
206
|
+
# whose stdout reaches the agent's context. Every other subcommand stays quiet.
|
|
207
|
+
# Under `--enrolled-only` (#1566, the gloria plugin) it also stays quiet unless
|
|
208
|
+
# this machine has a config.json: "reinstall the Miranda plugin" is a Miranda
|
|
209
|
+
# prompt, and gloria must never show one to someone who never opted in. Only
|
|
210
|
+
# existence is checked — the binary that could validate the file is the thing
|
|
211
|
+
# that is missing. Always returns 0: the exit-0 contract above still holds.
|
|
212
|
+
nudge_not_installed() {
|
|
213
|
+
[ "${1-}" = "hook-session-start" ] || return 0
|
|
214
|
+
for ni_arg in "$@"; do
|
|
215
|
+
if [ "$ni_arg" = "--enrolled-only" ]; then
|
|
216
|
+
[ -f "$(collector_home)/config.json" ] || return 0
|
|
217
|
+
fi
|
|
218
|
+
done
|
|
219
|
+
printf '%s\n' "$NOT_INSTALLED_NUDGE"
|
|
220
|
+
return 0
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Take the download lock. `mkdir` is the portable atomic create-or-fail. A LIVE
|
|
224
|
+
# lock (younger than LOCK_STALE_MINUTES) returns 1 — the loser exits 0 and lets
|
|
225
|
+
# the winner finish. A stale lock is taken over by rename, not by rm: exactly
|
|
226
|
+
# one contender can win a rename, whereas an rm-based takeover would let a
|
|
227
|
+
# loser delete the winner's freshly-created lock. Mirrors the collector's
|
|
228
|
+
# acquireSweepLock.
|
|
229
|
+
acquire_download_lock() {
|
|
230
|
+
al_lock="$1"
|
|
231
|
+
al_attempt=0
|
|
232
|
+
while [ "$al_attempt" -lt 3 ]; do
|
|
233
|
+
al_attempt=$((al_attempt + 1))
|
|
234
|
+
if mkdir "$al_lock" 2>/dev/null; then
|
|
235
|
+
return 0
|
|
236
|
+
fi
|
|
237
|
+
# `find -mmin` is absent only on exotic finds; when it can't tell, the lock
|
|
238
|
+
# reads as live and this run defers — the next hook fire retries.
|
|
239
|
+
if [ -z "$(find "$al_lock" -maxdepth 0 -mmin "+$LOCK_STALE_MINUTES" 2>/dev/null)" ]; then
|
|
240
|
+
return 1
|
|
241
|
+
fi
|
|
242
|
+
al_takeover="$al_lock.takeover-$$-$al_attempt"
|
|
243
|
+
if mv "$al_lock" "$al_takeover" 2>/dev/null; then
|
|
244
|
+
rm -rf "$al_takeover" 2>/dev/null || :
|
|
245
|
+
continue
|
|
246
|
+
fi
|
|
247
|
+
# The holder released it between our mkdir and our rename: retry the mkdir.
|
|
248
|
+
[ -e "$al_lock" ] || continue
|
|
249
|
+
return 1 # another contender won the takeover rename first
|
|
250
|
+
done
|
|
251
|
+
return 1
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Reuse the binary a pre-#783 install already cached under ~/.gloria/bin,
|
|
255
|
+
# instead of re-downloading it (#783) — an offline machine has to keep working
|
|
256
|
+
# across the rename. Returns 0 when $1 is ready to execute.
|
|
257
|
+
#
|
|
258
|
+
# Only THIS copy's own stamped asset name is considered, so an entry cached by
|
|
259
|
+
# a differently-stamped stub sharing the legacy bin/ (gloria's and Miranda's
|
|
260
|
+
# plugins both installed) is left exactly where its owner expects it. Copy
|
|
261
|
+
# rather than move, for the same reason: a sibling plugin that hasn't updated
|
|
262
|
+
# yet may still be running from it. Skipped when the directory was pinned
|
|
263
|
+
# explicitly — that path was chosen deliberately. Best-effort and silent: any
|
|
264
|
+
# failure just falls through to the normal download.
|
|
265
|
+
migrate_cached_binary() {
|
|
266
|
+
mcb_bin="$1"
|
|
267
|
+
home_override >/dev/null && return 1
|
|
268
|
+
mcb_legacy="$HOME/$LEGACY_HOME_DIR_NAME/bin/${mcb_bin##*/}"
|
|
269
|
+
[ -f "$mcb_legacy" ] || return 1
|
|
270
|
+
mkdir -p "${mcb_bin%/*}" 2>/dev/null || return 1
|
|
271
|
+
mcb_tmp="$mcb_bin.migrate-$$"
|
|
272
|
+
if cp "$mcb_legacy" "$mcb_tmp" 2>/dev/null &&
|
|
273
|
+
chmod 755 "$mcb_tmp" 2>/dev/null &&
|
|
274
|
+
mv -f "$mcb_tmp" "$mcb_bin" 2>/dev/null; then # atomic: no partial file is ever executed
|
|
275
|
+
return 0
|
|
276
|
+
fi
|
|
277
|
+
rm -f "$mcb_tmp" 2>/dev/null || :
|
|
278
|
+
return 1
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# Download the asset to a temp file, verify its SHA-256 against the stamped
|
|
282
|
+
# checksum, then atomically rename into place. Returns 0 when $1 is ready to
|
|
283
|
+
# execute; 1 (after logging) otherwise, or 2 on a silent lock deferral. The
|
|
284
|
+
# temp file and the lock are always cleaned up, and unverified bytes are never
|
|
285
|
+
# left executable at the cache path.
|
|
286
|
+
download_binary() {
|
|
287
|
+
db_bin="$1"
|
|
288
|
+
db_dir="${db_bin%/*}"
|
|
289
|
+
if ! mkdir -p "$db_dir" 2>/dev/null; then
|
|
290
|
+
log_error "cannot create cache directory $db_dir"
|
|
291
|
+
return 1
|
|
292
|
+
fi
|
|
293
|
+
db_lock="$db_dir/.download.lock"
|
|
294
|
+
if ! acquire_download_lock "$db_lock"; then
|
|
295
|
+
return 2 # a concurrent session is downloading — silently defer, no nudge
|
|
296
|
+
fi
|
|
297
|
+
db_tmp="$db_bin.download-$$-$(date -u '+%s' 2>/dev/null || printf '0')"
|
|
298
|
+
db_expected="$(checksum_for "$ASSET_KEY")"
|
|
299
|
+
db_url="https://github.com/$RELEASE_REPO/releases/download/$RELEASE_TAG/$ASSET_NAME"
|
|
300
|
+
db_status=1
|
|
301
|
+
|
|
302
|
+
if ! command -v curl >/dev/null 2>&1; then
|
|
303
|
+
log_error "curl not found; cannot download $ASSET_NAME"
|
|
304
|
+
else
|
|
305
|
+
# --proto/--proto-redir '=https' refuse a plain-http hop outright rather
|
|
306
|
+
# than letting one be detected after the bytes have already moved;
|
|
307
|
+
# --fail turns HTTP >= 400 into a non-zero exit; --write-out reports the
|
|
308
|
+
# URL the transfer actually landed on, for the host allowlist below.
|
|
309
|
+
db_effective="$(
|
|
310
|
+
curl --fail --location --silent \
|
|
311
|
+
--proto '=https' --proto-redir '=https' \
|
|
312
|
+
--max-redirs 5 --max-time "$CURL_MAX_SECONDS" \
|
|
313
|
+
--write-out '%{url_effective}' \
|
|
314
|
+
--output "$db_tmp" "$db_url" 2>/dev/null
|
|
315
|
+
)"
|
|
316
|
+
db_curl_status=$?
|
|
317
|
+
if [ "$db_curl_status" -ne 0 ]; then
|
|
318
|
+
log_error "download of $ASSET_NAME failed: curl exit $db_curl_status"
|
|
319
|
+
else
|
|
320
|
+
# GitHub redirects release-asset downloads to a GitHub-owned object
|
|
321
|
+
# host; it serves them from both objects. and release-assets.. Requiring
|
|
322
|
+
# a literal "/" after the host is what makes github.com.evil.example
|
|
323
|
+
# fail to match. Defense-in-depth on top of the checksum.
|
|
324
|
+
case "$db_effective" in
|
|
325
|
+
https://github.com/* | https://objects.githubusercontent.com/* | https://release-assets.githubusercontent.com/*)
|
|
326
|
+
db_host_ok=yes
|
|
327
|
+
;;
|
|
328
|
+
*) db_host_ok=no ;;
|
|
329
|
+
esac
|
|
330
|
+
if [ "$db_host_ok" = no ]; then
|
|
331
|
+
log_error "download resolved to disallowed URL $db_effective; refusing"
|
|
332
|
+
elif [ ! -s "$db_tmp" ]; then
|
|
333
|
+
log_error "download of $ASSET_NAME returned no body"
|
|
334
|
+
elif ! db_actual="$(sha256_of "$db_tmp")"; then
|
|
335
|
+
log_error "no SHA-256 tool found (sha256sum, shasum, openssl, or certutil); refusing to execute $ASSET_NAME"
|
|
336
|
+
elif [ -z "$db_actual" ]; then
|
|
337
|
+
log_error "failed to compute the SHA-256 of $ASSET_NAME; refusing to execute"
|
|
338
|
+
elif [ "$db_actual" != "$db_expected" ]; then
|
|
339
|
+
log_error "checksum mismatch for $ASSET_NAME: expected $db_expected, got $db_actual; refusing to execute"
|
|
340
|
+
elif ! chmod 755 "$db_tmp" 2>/dev/null; then
|
|
341
|
+
log_error "cannot make $ASSET_NAME executable; refusing to install"
|
|
342
|
+
elif ! mv -f "$db_tmp" "$db_bin" 2>/dev/null; then
|
|
343
|
+
# atomic: readers never see a partial file
|
|
344
|
+
log_error "cannot install $ASSET_NAME into $db_bin"
|
|
345
|
+
else
|
|
346
|
+
db_status=0
|
|
347
|
+
fi
|
|
348
|
+
fi
|
|
349
|
+
fi
|
|
350
|
+
|
|
351
|
+
rm -f "$db_tmp" 2>/dev/null || : # no-op after a successful rename
|
|
352
|
+
rm -rf "$db_lock" 2>/dev/null || :
|
|
353
|
+
return "$db_status"
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Run the collector binary with argv + stdin passed through and echo its exit
|
|
357
|
+
# code. Not `exec`, because the cache still has to be pruned afterwards.
|
|
358
|
+
# 126/127 are the shell's "couldn't execute that" codes — the equivalent of the
|
|
359
|
+
# node stub's spawn `error` event — so they log and become 0 rather than
|
|
360
|
+
# leaking out as a hook failure. The collector itself only ever exits 0 or 1.
|
|
361
|
+
run_binary() {
|
|
362
|
+
rb_bin="$1"
|
|
363
|
+
shift
|
|
364
|
+
if [ ! -x "$rb_bin" ]; then
|
|
365
|
+
log_error "spawn of $rb_bin failed: not executable"
|
|
366
|
+
nudge_not_installed "$@"
|
|
367
|
+
return 0
|
|
368
|
+
fi
|
|
369
|
+
"$rb_bin" "$@"
|
|
370
|
+
rb_status=$?
|
|
371
|
+
if [ "$rb_status" -eq 126 ] || [ "$rb_status" -eq 127 ]; then
|
|
372
|
+
log_error "spawn of $rb_bin failed: exit $rb_status"
|
|
373
|
+
nudge_not_installed "$@"
|
|
374
|
+
return 0
|
|
375
|
+
fi
|
|
376
|
+
return "$rb_status"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Delete cached collector binaries beyond the 2 most recent (by mtime), never
|
|
380
|
+
# the one just run, plus any stranded *.download-* temp file older than an
|
|
381
|
+
# hour. Best-effort: pruning must not fail the hook. Only files carrying THIS
|
|
382
|
+
# copy's stamped asset prefix are considered, so a binary cached by a
|
|
383
|
+
# differently-stamped copy sharing the same bin/ (e.g. gloria's and Miranda's
|
|
384
|
+
# plugins both installed) is never touched.
|
|
385
|
+
prune_cache() {
|
|
386
|
+
pc_dir="$1"
|
|
387
|
+
pc_keep="$2"
|
|
388
|
+
[ -d "$pc_dir" ] || return 0
|
|
389
|
+
find "$pc_dir" -maxdepth 1 \( -name '*.download-*' -o -name '*.migrate-*' \) \
|
|
390
|
+
-mmin "+$DOWNLOAD_TEMP_STALE_MINUTES" -exec rm -f {} + 2>/dev/null || :
|
|
391
|
+
pc_kept=0
|
|
392
|
+
# Cache entries are <prefix>-<hex> — no whitespace — so word splitting on
|
|
393
|
+
# `ls -t` (newest first) is safe here, and it is the portable way to get
|
|
394
|
+
# mtime order without a second sort tool.
|
|
395
|
+
# shellcheck disable=SC2045
|
|
396
|
+
for pc_name in $(ls -t "$pc_dir" 2>/dev/null); do
|
|
397
|
+
case "$pc_name" in
|
|
398
|
+
"$ASSET_PREFIX-"*) ;;
|
|
399
|
+
*) continue ;;
|
|
400
|
+
esac
|
|
401
|
+
case "$pc_name" in
|
|
402
|
+
*.download-* | *.migrate-*) continue ;;
|
|
403
|
+
esac
|
|
404
|
+
if [ "$pc_kept" -lt 2 ]; then
|
|
405
|
+
pc_kept=$((pc_kept + 1))
|
|
406
|
+
continue
|
|
407
|
+
fi
|
|
408
|
+
pc_path="$pc_dir/$pc_name"
|
|
409
|
+
[ "$pc_path" = "$pc_keep" ] && continue
|
|
410
|
+
rm -f "$pc_path" 2>/dev/null || :
|
|
411
|
+
done
|
|
412
|
+
return 0
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# The whole stub: override | unstamped | resolve → (cache | download+verify) →
|
|
416
|
+
# run → prune. Every failure path exits 0.
|
|
417
|
+
main() {
|
|
418
|
+
if [ -n "${GLORIA_COLLECTOR_BIN-}" ]; then
|
|
419
|
+
if [ ! -x "$GLORIA_COLLECTOR_BIN" ]; then
|
|
420
|
+
log_error "GLORIA_COLLECTOR_BIN=$GLORIA_COLLECTOR_BIN is not an executable file; skipping"
|
|
421
|
+
exit 0
|
|
422
|
+
fi
|
|
423
|
+
# Nothing to prune on this path, so exec straight through: argv, stdin and
|
|
424
|
+
# the exit code all pass to the override untouched.
|
|
425
|
+
exec "$GLORIA_COLLECTOR_BIN" "$@"
|
|
426
|
+
fi
|
|
427
|
+
|
|
428
|
+
# Dev tree: the publish workflow never stamped this copy (the placeholders
|
|
429
|
+
# are still in place). Local dev runs `bun src/cli.ts …` or sets
|
|
430
|
+
# GLORIA_COLLECTOR_BIN instead.
|
|
431
|
+
case "$BUILD_VERSION" in
|
|
432
|
+
__*)
|
|
433
|
+
log_error "stub is unstamped (dev checkout?); set GLORIA_COLLECTOR_BIN or install the published plugin"
|
|
434
|
+
exit 0
|
|
435
|
+
;;
|
|
436
|
+
esac
|
|
437
|
+
|
|
438
|
+
if ! resolve_asset; then
|
|
439
|
+
log_error "unsupported platform $PLATFORM_LABEL; skipping"
|
|
440
|
+
nudge_not_installed "$@"
|
|
441
|
+
exit 0
|
|
442
|
+
fi
|
|
443
|
+
|
|
444
|
+
m_bin_dir="$(collector_home)/bin"
|
|
445
|
+
m_bin="$m_bin_dir/$ASSET_PREFIX-$BUILD_VERSION$ASSET_EXT"
|
|
446
|
+
if [ ! -f "$m_bin" ]; then
|
|
447
|
+
if ! migrate_cached_binary "$m_bin"; then
|
|
448
|
+
download_binary "$m_bin"
|
|
449
|
+
m_download=$?
|
|
450
|
+
if [ "$m_download" -ne 0 ]; then
|
|
451
|
+
# Logged inside. 2 is a lock deferral — the winner is installing it.
|
|
452
|
+
[ "$m_download" -eq 2 ] || nudge_not_installed "$@"
|
|
453
|
+
exit 0
|
|
454
|
+
fi
|
|
455
|
+
fi
|
|
456
|
+
fi
|
|
457
|
+
run_binary "$m_bin" "$@"
|
|
458
|
+
m_status=$?
|
|
459
|
+
prune_cache "$m_bin_dir" "$m_bin"
|
|
460
|
+
exit "$m_status"
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
main "$@"
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./miranda.js"
|
package/miranda.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
// Published as the npm package @sandgarden/miranda (#813): the bundled skills
|
|
9
|
+
// and the collector download stub ship inside the package, beside this file —
|
|
10
|
+
// scripts/publish-marketplace/stage.mjs assembles it. In the monorepo neither
|
|
11
|
+
// exists here, so spawnCollector's existsSync guard silently no-ops during
|
|
12
|
+
// local dev.
|
|
13
|
+
const skillsDir = path.resolve(__dirname, "skills")
|
|
14
|
+
const collectorStubPath = path.resolve(__dirname, "collector/stub.sh")
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mutate an OpenCode config object to wire up Miranda: register the bundled
|
|
18
|
+
* skills directory and the scoped /miranda MCP endpoint (identity + cost
|
|
19
|
+
* tools only — see packages/mcp/src/index.ts). Idempotent and
|
|
20
|
+
* non-clobbering — a user-defined miranda MCP entry wins.
|
|
21
|
+
*/
|
|
22
|
+
export function applyMirandaConfig(config, dir = skillsDir) {
|
|
23
|
+
config.skills = config.skills || {}
|
|
24
|
+
config.skills.paths = config.skills.paths || []
|
|
25
|
+
if (!config.skills.paths.includes(dir)) config.skills.paths.push(dir)
|
|
26
|
+
config.mcp = config.mcp || {}
|
|
27
|
+
config.mcp.miranda = config.mcp.miranda || {
|
|
28
|
+
type: "remote",
|
|
29
|
+
url: "https://mcp.gloria.dev/miranda",
|
|
30
|
+
enabled: true,
|
|
31
|
+
}
|
|
32
|
+
return config
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fire-and-forget call into the collector, via the download stub.
|
|
37
|
+
*
|
|
38
|
+
* This runs IN-PROCESS with OpenCode (unlike Claude Code's spawned hook
|
|
39
|
+
* process), and a first run can download a ~50 MB collector binary — so the
|
|
40
|
+
* child is detached and unref'd rather than awaited, and every failure
|
|
41
|
+
* (missing stub, spawn error) is swallowed: a collector bug must never block
|
|
42
|
+
* or slow an OpenCode session.
|
|
43
|
+
*
|
|
44
|
+
* The stub is POSIX sh, not node (#761) — the collector must not require a
|
|
45
|
+
* JS runtime on the host. On Windows that needs `sh` on PATH (Git for
|
|
46
|
+
* Windows' Unix tools); without it the spawn errors and is swallowed, the
|
|
47
|
+
* same silent no-op a missing `node` produced before.
|
|
48
|
+
*/
|
|
49
|
+
function spawnCollector(args, spawnImpl, stubPath) {
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.existsSync(stubPath)) return
|
|
52
|
+
const child = spawnImpl("sh", [stubPath, ...args], {
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: "ignore",
|
|
55
|
+
})
|
|
56
|
+
child.on("error", () => {})
|
|
57
|
+
child.unref()
|
|
58
|
+
} catch {
|
|
59
|
+
// Must never block or crash the session.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Session start: the same `hook-session-start` entrypoint Claude Code's
|
|
65
|
+
* SessionStart hook uses. Since #847 that entrypoint does not sweep — it makes
|
|
66
|
+
* sure this machine has a collector DAEMON, installing the per-user service
|
|
67
|
+
* the first time, and the daemon's own watcher on OpenCode's storage directory
|
|
68
|
+
* is what ingests the session.
|
|
69
|
+
*
|
|
70
|
+
* OpenCode keeps its sessions in SQLite rather than in a transcript file, so
|
|
71
|
+
* there is no path a spool trigger could name; the watcher is the whole of the
|
|
72
|
+
* handoff here.
|
|
73
|
+
*/
|
|
74
|
+
export function startCollectorSession(spawnImpl = spawn, stubPath = collectorStubPath) {
|
|
75
|
+
spawnCollector(["hook-session-start"], spawnImpl, stubPath)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Per-turn: `daemon ensure` and nothing else.
|
|
80
|
+
*
|
|
81
|
+
* `session.idle` fires once per TURN, and the session-start entrypoint above
|
|
82
|
+
* runs a whole bootstrap — a service-registration probe (a subprocess on Linux
|
|
83
|
+
* and Windows), a config load, the nudges. That is a session-start's worth of
|
|
84
|
+
* work to pay for every turn. All this handler actually owes the collector is
|
|
85
|
+
* "is a daemon up?", which is one lock-file read when the answer is yes.
|
|
86
|
+
*/
|
|
87
|
+
export function ensureCollectorDaemon(spawnImpl = spawn, stubPath = collectorStubPath) {
|
|
88
|
+
spawnCollector(["daemon", "ensure"], spawnImpl, stubPath)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// OpenCode plugin entry. The `config` hook receives OpenCode's config
|
|
92
|
+
// singleton; mutations here are visible when skills and MCP servers are
|
|
93
|
+
// resolved later. Unlike the gloria plugin, there is no session.created
|
|
94
|
+
// version-nudge check — Miranda ships no version-nudge hook at all (mirrors
|
|
95
|
+
// the doc-holiday precedent: no dedicated plugin-version endpoint to poll).
|
|
96
|
+
// `spawnImpl` and `stubPath` exist for test injection only — production
|
|
97
|
+
// callers never pass them, so they default to the real child_process.spawn
|
|
98
|
+
// and the shared collector stub path.
|
|
99
|
+
export const miranda = async ({ spawnImpl = spawn, stubPath = collectorStubPath } = {}) => ({
|
|
100
|
+
config: async (config) => {
|
|
101
|
+
applyMirandaConfig(config)
|
|
102
|
+
},
|
|
103
|
+
"session.created": async () => {
|
|
104
|
+
startCollectorSession(spawnImpl, stubPath)
|
|
105
|
+
},
|
|
106
|
+
// Closest OpenCode analog to Claude Code's per-turn Stop hook: fires once
|
|
107
|
+
// the agent loop finishes a turn and is waiting on the user again. Cheap on
|
|
108
|
+
// purpose — see ensureCollectorDaemon.
|
|
109
|
+
"session.idle": async () => {
|
|
110
|
+
ensureCollectorDaemon(spawnImpl, stubPath)
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
export default miranda
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandgarden/miranda",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.75",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Miranda OpenCode plugin — token-usage tracking skill + the gloria.dev MCP server's scoped /miranda tools.",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"miranda.js",
|
|
10
|
+
"skills",
|
|
11
|
+
"collector"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://miranda.co",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: setting-up-usage-tracking
|
|
3
|
+
description: Use when installing the Miranda plugin into a repo ("set up token tracking", "enable usage tracking", "install miranda"), when a repo is missing .miranda/USING-MIRANDA.md or the miranda section in CLAUDE.md/AGENTS.md, or after a Miranda plugin update to refresh a stale doc — copies the canonical USING-MIRANDA.md agent doc into the repo, wires an evergreen Miranda section into the agent instruction files (with the user's permission), and enrols this machine for usage collection. Idempotent; safe to re-run.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Setting Up Usage Tracking
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Wire Miranda into the current repo so every coding agent that works here
|
|
11
|
+
declares the GitHub issue it's working on and this machine's token usage
|
|
12
|
+
gets collected. Two files carry that:
|
|
13
|
+
|
|
14
|
+
1. **`.miranda/USING-MIRANDA.md`** — the agent playbook, copied verbatim
|
|
15
|
+
from the `USING-MIRANDA.md` that sits next to this SKILL.md (the skill's
|
|
16
|
+
base directory). Committed to the repo.
|
|
17
|
+
2. **A marked section in the repo's agent instruction files** (`CLAUDE.md`
|
|
18
|
+
and/or `AGENTS.md`) that points unconditionally at the doc. The section is
|
|
19
|
+
evergreen — it names no features, so it never changes across Miranda
|
|
20
|
+
releases; only the doc does.
|
|
21
|
+
|
|
22
|
+
Then, once those are wired, enrol this machine so the collector daemon the
|
|
23
|
+
Miranda plugin installs actually starts reporting usage.
|
|
24
|
+
|
|
25
|
+
Never modify the user's files without showing them exactly what will change
|
|
26
|
+
and getting a yes first.
|
|
27
|
+
|
|
28
|
+
## What the collector reports
|
|
29
|
+
|
|
30
|
+
Tell the user this when you propose enrolling a machine (step 2). It is what
|
|
31
|
+
they are consenting to.
|
|
32
|
+
|
|
33
|
+
- **Always:** token usage per model request (model, token counts,
|
|
34
|
+
timestamps, session and request ids), a random per-machine id, and this
|
|
35
|
+
machine's hostname, OS, and architecture. Never message content.
|
|
36
|
+
- **Only if the organization turns it on:** the workflow events below.
|
|
37
|
+
|
|
38
|
+
**Workflow events — off by default.** Your organization admin can turn workflow collection on for the whole organization in Miranda's settings; until they do, the collector reports token usage exactly as described here and nothing more. When it is on, the collector also reports the **structure, not content**, of each session it already reads: sizes, names, and flags, never a prompt, a reply, a file body, a tool's output, or a full shell command (only its first word and its class). These are the event kinds:
|
|
39
|
+
|
|
40
|
+
- `session_start`: branch, repository, entrypoint; the directory as a hash
|
|
41
|
+
- `session_end`: why it ended and how long it ran
|
|
42
|
+
- `human_turn`: prompt size and slash-command name, never the prompt
|
|
43
|
+
- `assistant_turn`: model, effort, stop reason and size, never the reply
|
|
44
|
+
- `model_request`: the token counts already reported today
|
|
45
|
+
- `tool_call`: tool name, argument names, repo-relative file path, and a shell command's first word only, plus the names of any wrappers a hook added to it before it ran (such as `rtk`)
|
|
46
|
+
- `tool_result`: error flag, size, exit code and test counts, never the output
|
|
47
|
+
- `project_facts`: manifest and instruction file names, sizes and hashes
|
|
48
|
+
- `skill_invoked`: skill name and whether it came from a plugin, the project, or the user
|
|
49
|
+
- `subagent_spawned`: subagent type, model override and whether it ran in the background, never its prompt
|
|
50
|
+
- `plan_mode`: entering or leaving plan mode, never the plan
|
|
51
|
+
- `compaction`: whether a context compaction was automatic or manual, and the context size before it
|
|
52
|
+
- `vcs_event`: commit, push, PR create or merge, or rebase, with PR and issue numbers only, never a branch, message or remote
|
|
53
|
+
- `hook_fired`: hook name, the first word of the hook's own command (such as `rtk`), and whether it blocked, never its arguments or output
|
|
54
|
+
- `mcp_tool_call`: MCP server and tool name, never arguments or results
|
|
55
|
+
|
|
56
|
+
## The instruction-file section
|
|
57
|
+
|
|
58
|
+
Insert this text exactly, markers included. The markers are how re-runs find
|
|
59
|
+
and replace the section instead of duplicating it.
|
|
60
|
+
|
|
61
|
+
```markdown
|
|
62
|
+
<!-- miranda:start -->
|
|
63
|
+
|
|
64
|
+
## Miranda
|
|
65
|
+
|
|
66
|
+
This project uses [Miranda](https://miranda.co) to track coding-agent
|
|
67
|
+
token cost and attribute it to the GitHub issues the work was for.
|
|
68
|
+
|
|
69
|
+
Before starting work in this repo, read `.miranda/USING-MIRANDA.md`. It
|
|
70
|
+
explains the one thing required at the start of every session — declaring
|
|
71
|
+
the work item — plus the Miranda MCP tools available to you. Treat it as a
|
|
72
|
+
commitment the team has made, not a suggestion.
|
|
73
|
+
|
|
74
|
+
<!-- miranda:end -->
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Workflow
|
|
78
|
+
|
|
79
|
+
### 1. Detect state
|
|
80
|
+
|
|
81
|
+
- Find the repo root (`git rev-parse --show-toplevel`; fall back to the
|
|
82
|
+
working directory if not a git repo).
|
|
83
|
+
- Locate the shipped doc: the `USING-MIRANDA.md` in this skill's base
|
|
84
|
+
directory. If it is missing, stop and tell the user the plugin install
|
|
85
|
+
looks broken — do not synthesize the doc from memory.
|
|
86
|
+
- Check what already exists at the repo root:
|
|
87
|
+
- `.miranda/USING-MIRANDA.md` — present?
|
|
88
|
+
- `CLAUDE.md`, `AGENTS.md` — which exist, and does each already contain a
|
|
89
|
+
`<!-- miranda:start -->` ... `<!-- miranda:end -->` block?
|
|
90
|
+
- **Is this machine already enrolled?** Look for the collector's
|
|
91
|
+
`config.json`, in **both** places, in this order:
|
|
92
|
+
|
|
93
|
+
1. `$XDG_CONFIG_HOME/sandgarden/config.json`, defaulting to
|
|
94
|
+
`~/.config/sandgarden/config.json` when `XDG_CONFIG_HOME` is unset;
|
|
95
|
+
2. `~/.gloria/config.json` — the pre-rename location. A machine set up
|
|
96
|
+
before the rename still has its credential here; the collector
|
|
97
|
+
migrates it automatically on its next run, so this counts as
|
|
98
|
+
"already enabled" too.
|
|
99
|
+
|
|
100
|
+
Either one present means usage tracking is already on for this machine
|
|
101
|
+
(per-machine, not per-repo) and step 5 is a no-op — say so rather than
|
|
102
|
+
re-minting a key.
|
|
103
|
+
|
|
104
|
+
**Richer answer, when the collector binary is reachable:**
|
|
105
|
+
`daemon status` additionally reports which service supervises the
|
|
106
|
+
collector and whether a daemon is running. **The binary is not on
|
|
107
|
+
`PATH`** — nothing puts it there. The plugin's download stub caches it
|
|
108
|
+
inside the state directory, so invoke it by path, or through the stub:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
# the stub, which is what every hook uses (works even before the first
|
|
112
|
+
# download); <plugin> is the installed plugin's root directory
|
|
113
|
+
sh <plugin>/collector/stub.sh daemon status
|
|
114
|
+
|
|
115
|
+
# or the cached binary directly, if one is there already
|
|
116
|
+
ls ~/.config/sandgarden/bin/ # miranda-collector, or miranda-collector-<version>
|
|
117
|
+
~/.config/sandgarden/bin/miranda-collector daemon status
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Substitute whichever path `collectorHome()` resolves to (`SANDGARDEN_HOME`
|
|
121
|
+
→ the deprecated `GLORIA_HOME` → `$XDG_CONFIG_HOME/sandgarden` →
|
|
122
|
+
`~/.config/sandgarden`). `daemon status` always exits 0, so "not
|
|
123
|
+
enrolled" is not an error. Treat this as extra detail for the report in
|
|
124
|
+
step 6, not as the enrolment test — the `config.json` check above is the
|
|
125
|
+
one that has to work on every machine.
|
|
126
|
+
|
|
127
|
+
### 2. Propose and ask permission — once
|
|
128
|
+
|
|
129
|
+
Tell the user precisely what will happen, in one message, and ask a single
|
|
130
|
+
yes/no. Cover only the actions actually needed, e.g.:
|
|
131
|
+
|
|
132
|
+
- create (or replace) `.miranda/USING-MIRANDA.md`;
|
|
133
|
+
- insert the Miranda section into `CLAUDE.md` and `AGENTS.md` (or "replace
|
|
134
|
+
the existing Miranda section in ...");
|
|
135
|
+
- create `AGENTS.md` containing the section, when neither instruction file
|
|
136
|
+
exists;
|
|
137
|
+
- enrol this machine for usage tracking, if it is not already, saying what
|
|
138
|
+
the collector reports (see "What the collector reports").
|
|
139
|
+
|
|
140
|
+
If everything is already current, say so and skip to step 6. If the user
|
|
141
|
+
declines the file edits, stop — do not partially apply.
|
|
142
|
+
|
|
143
|
+
### 3. Write the doc
|
|
144
|
+
|
|
145
|
+
Copy the shipped `USING-MIRANDA.md` to `.miranda/USING-MIRANDA.md`
|
|
146
|
+
**verbatim** (create the `.miranda/` directory if needed). Do not edit,
|
|
147
|
+
reformat, or "improve" the content — refreshes must stay a pure file copy.
|
|
148
|
+
|
|
149
|
+
### 4. Wire the instruction files
|
|
150
|
+
|
|
151
|
+
For each of `CLAUDE.md` and `AGENTS.md` that exists at the repo root:
|
|
152
|
+
|
|
153
|
+
- If it contains a marker block, replace everything from
|
|
154
|
+
`<!-- miranda:start -->` through `<!-- miranda:end -->` (inclusive) with
|
|
155
|
+
the section above.
|
|
156
|
+
- Otherwise append the section at the end of the file, preceded by one blank
|
|
157
|
+
line.
|
|
158
|
+
|
|
159
|
+
If neither file exists, create `AGENTS.md` containing only the section
|
|
160
|
+
(AGENTS.md is the cross-agent standard; Claude Code reads it too).
|
|
161
|
+
|
|
162
|
+
### 5. Enable token-usage tracking
|
|
163
|
+
|
|
164
|
+
Only when step 1 found this machine **unenrolled**.
|
|
165
|
+
|
|
166
|
+
**Prefer browser enrolment.** Both paths below hand the machine its
|
|
167
|
+
credential without this session ever seeing a secret, which is why they come
|
|
168
|
+
first:
|
|
169
|
+
|
|
170
|
+
- **Approve the collector's own sign-in prompt.** A daemon running without a
|
|
171
|
+
credential surfaces a native "Sign in to Miranda" dialog on its first idle
|
|
172
|
+
start (and at most once a day after that); approving it completes the
|
|
173
|
+
browser handshake and writes `config.json` itself. Nothing for you to do but
|
|
174
|
+
tell the user it is waiting for them.
|
|
175
|
+
- **Run the collector's `setup` verb** (by path or through the stub, per step 1
|
|
176
|
+
— it is not on `PATH`). It prints a miranda.co URL with a short one-time
|
|
177
|
+
code, opens the browser, and writes the same `config.json` once the user
|
|
178
|
+
approves the machine while signed in. It also registers the daemon's service
|
|
179
|
+
if the machine has none.
|
|
180
|
+
|
|
181
|
+
**Fall back to minting through MCP** — the steps below — for an agent that
|
|
182
|
+
cannot open a browser or drive an interactive terminal, and for a headless
|
|
183
|
+
machine. On a headless machine you can also hand the minted key straight to
|
|
184
|
+
the collector with `setup --token <ingestToken>`, which writes the same
|
|
185
|
+
`config.json` for you rather than you writing it by hand.
|
|
186
|
+
|
|
187
|
+
**Check before offering one**: `daemon status` (invoked as step 1 describes) is
|
|
188
|
+
how you find out what this build of the collector can do, and a collector
|
|
189
|
+
predating #1227 answers an unrecognised `setup` verb with usage and a non-zero
|
|
190
|
+
exit — go straight to the fallback if so. Whichever path runs, the result is
|
|
191
|
+
the same `config.json` — never do two of them.
|
|
192
|
+
|
|
193
|
+
#### Fallback: minting the credential through MCP
|
|
194
|
+
|
|
195
|
+
1. Call the `miranda` MCP tool **`enable_usage_tracking`**, always passing a
|
|
196
|
+
`machineLabel` — never omit it. Clerk key names must be unique per org,
|
|
197
|
+
and omitting `machineLabel` falls back to a non-unique default name, so a
|
|
198
|
+
second machine (or a re-run after a partial failure) collides with an
|
|
199
|
+
already-minted key and the call fails with a Clerk `409 token_creation_conflict`.
|
|
200
|
+
Generate a default that's unique per machine, e.g. the hostname plus a
|
|
201
|
+
short random or timestamp suffix (`<hostname>-<4 random hex chars>`), and
|
|
202
|
+
use that unless the user asks for a friendlier label — if they supply
|
|
203
|
+
one, keep it unique the same way (append a short suffix) rather than
|
|
204
|
+
passing their label bare. It mints a write-only, org-scoped Clerk API key
|
|
205
|
+
and returns `{ apiBaseUrl, ingestToken }` plus write instructions.
|
|
206
|
+
2. Write `$XDG_CONFIG_HOME/sandgarden/config.json` — `~/.config/sandgarden/config.json`
|
|
207
|
+
when `XDG_CONFIG_HOME` is unset — directly from the tool result, merging
|
|
208
|
+
with any existing keys in the file. The directory is per-machine, not
|
|
209
|
+
per-repo, and shared with the gloria plugin if that's also installed:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"ingestToken": "<ingestToken from the tool result>"
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Leave the tool's `apiBaseUrl` out of the file: the collector defaults to
|
|
218
|
+
`https://gloria.dev`, the only production host. Write it only when it is
|
|
219
|
+
something else, i.e. a local or dev stack.
|
|
220
|
+
|
|
221
|
+
**Never echo the `ingestToken` into the conversation, logs, or any other
|
|
222
|
+
file** — write it straight to the config file. The secret is shown
|
|
223
|
+
exactly once; a compromised or lost key is revoked from the Clerk
|
|
224
|
+
organization settings, and re-running the tool mints a fresh one.
|
|
225
|
+
|
|
226
|
+
Work-item cost attribution needs no config beyond this: the collector
|
|
227
|
+
resolves which gloria/Miranda project a session belongs to itself, per
|
|
228
|
+
session, from that session's own `git remote` — never from a value in
|
|
229
|
+
this file, so one machine works correctly across as many
|
|
230
|
+
gloria-registered repos as the developer has checked out. The collector
|
|
231
|
+
needs no runtime install: the plugin's first hook fire downloads a
|
|
232
|
+
compiled, checksum-verified collector binary for this platform (~50 MB,
|
|
233
|
+
once per collector release) and caches it under `bin/` in that same
|
|
234
|
+
directory.
|
|
235
|
+
|
|
236
|
+
**Manual fallback (MCP not connected on this machine):** any org member
|
|
237
|
+
can mint the key from a machine that _does_ have the `miranda` MCP server
|
|
238
|
+
connected (the credential is per-machine, so mint one per machine), or an
|
|
239
|
+
org admin can create an API key with scope `usage:ingest` for the
|
|
240
|
+
organization in Clerk and supply it the same way. Have the user write the
|
|
241
|
+
file themselves — never ask them to paste the secret into the chat.
|
|
242
|
+
|
|
243
|
+
3. **Verify the write landed — never assume it did.** Read `config.json`
|
|
244
|
+
back and confirm it parses and carries both keys. A sandboxed agent
|
|
245
|
+
session can have this write refused: on Codex under Windows the sandbox is
|
|
246
|
+
process-wide and inherited, and the collector's state directory is outside
|
|
247
|
+
any workspace root. If the file is absent or unparseable, **do not report
|
|
248
|
+
tracking as enabled.** Tell the user what to add to
|
|
249
|
+
`%USERPROFILE%\.codex\config.toml`:
|
|
250
|
+
|
|
251
|
+
```toml
|
|
252
|
+
[sandbox_workspace_write]
|
|
253
|
+
writable_roots = ['C:\Users\<you>\.config\sandgarden']
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
substituting whichever path `collectorHome()` resolves to for them
|
|
257
|
+
(`SANDGARDEN_HOME` → `GLORIA_HOME` → `%XDG_CONFIG_HOME%\sandgarden` →
|
|
258
|
+
`C:\Users\<you>\.config\sandgarden`). You cannot apply this edit
|
|
259
|
+
yourself from a sandboxed session — `~/.codex` is a protected control
|
|
260
|
+
directory, read-only to the agent even inside a writable root.
|
|
261
|
+
|
|
262
|
+
Then say plainly that **the minted credential is gone**: `ingestToken` is
|
|
263
|
+
shown exactly once, and it was never persisted. After they fix the config,
|
|
264
|
+
re-run `enable_usage_tracking` with a **fresh, different** `machineLabel` —
|
|
265
|
+
reusing the previous one collides with the already-minted key and fails
|
|
266
|
+
with a Clerk `409 token_creation_conflict`.
|
|
267
|
+
|
|
268
|
+
Collection starts within a minute of that file appearing — the collector
|
|
269
|
+
daemon idles while it is missing and begins the moment it shows up, with no
|
|
270
|
+
restart. The daemon collects **Claude Code, Codex and OpenCode** usage from
|
|
271
|
+
this machine's local session stores whether or not any agent plugin fires, so
|
|
272
|
+
no further wiring is needed. See `USING-MIRANDA.md`'s "Codex, OpenCode, and
|
|
273
|
+
Cursor" section for per-agent notes.
|
|
274
|
+
|
|
275
|
+
### 6. Report
|
|
276
|
+
|
|
277
|
+
Summarize what changed (files created/updated, section inserted where,
|
|
278
|
+
usage tracking configured or already present) and suggest committing the
|
|
279
|
+
changes, e.g. `chore: wire miranda agent doc into instruction files`.
|
|
280
|
+
|
|
281
|
+
## Idempotency rules
|
|
282
|
+
|
|
283
|
+
- Re-running with identical content is a no-op; say "already current".
|
|
284
|
+
- Marker blocks are always replaced in place, never duplicated. If a file
|
|
285
|
+
somehow contains multiple marker blocks, replace the first and remove the
|
|
286
|
+
rest, and mention it.
|
|
287
|
+
- Never touch anything outside the marker block in an instruction file, and
|
|
288
|
+
never edit any other file.
|
|
289
|
+
- Never re-mint a usage-tracking credential when step 1 found a `config.json`
|
|
290
|
+
in either location (including the pre-rename `~/.gloria/config.json`). Offer
|
|
291
|
+
to add `machineLabel` or rotate only if the user asks.
|
|
292
|
+
|
|
293
|
+
## When to suggest this skill proactively
|
|
294
|
+
|
|
295
|
+
- The user just installed the Miranda plugin in a repo with no
|
|
296
|
+
`.miranda/USING-MIRANDA.md` → offer initial setup.
|
|
297
|
+
- The user asks about token cost, usage tracking, or per-issue cost
|
|
298
|
+
attribution and this machine has no collector `config.json` in either
|
|
299
|
+
location → offer to set it up.
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# Using Miranda
|
|
2
|
+
|
|
3
|
+
[Miranda](https://miranda.co) tracks coding-agent token cost and
|
|
4
|
+
attributes it to the GitHub issues the work was actually for. This project
|
|
5
|
+
has adopted Miranda: the triggers below are commitments the team has made,
|
|
6
|
+
not suggestions. When a trigger matches the task you are doing, take the
|
|
7
|
+
corresponding action.
|
|
8
|
+
|
|
9
|
+
Miranda is available two ways, and both apply to you:
|
|
10
|
+
|
|
11
|
+
- **MCP tools** on the `miranda` server (`tag_session_work_item`,
|
|
12
|
+
`enable_usage_tracking`, `get_my_spend`, `get_my_issue_spend`,
|
|
13
|
+
`get_my_effectiveness`, `get_my_workflow`, `get_my_tracking_status`,
|
|
14
|
+
`assign_my_session_work_item`, the managers-only `get_team_effectiveness`,
|
|
15
|
+
plus the shared
|
|
16
|
+
`get_info`/`register_project`/`put_project_github_info` — the same rows the
|
|
17
|
+
`gloria` server exposes, since Miranda and gloria share one database).
|
|
18
|
+
- **The usage-collector hooks** installed with the Miranda plugin (Claude
|
|
19
|
+
Code, Codex, OpenCode) — they run automatically once the collector's
|
|
20
|
+
`config.json` exists (in `$XDG_CONFIG_HOME/sandgarden`, defaulting to
|
|
21
|
+
`~/.config/sandgarden`); see "Setting up usage tracking" below.
|
|
22
|
+
|
|
23
|
+
## Start of every session
|
|
24
|
+
|
|
25
|
+
One cheap check, done as early as possible — before other work, and
|
|
26
|
+
regardless of what the task turns out to be:
|
|
27
|
+
|
|
28
|
+
**Declare the work item, and keep it current.** Identify the GitHub issue
|
|
29
|
+
this session is working on and call `tag_session_work_item` with it before
|
|
30
|
+
you start working. Do this silently — do not ask the user to confirm a match
|
|
31
|
+
you are confident in. Work down this list and stop at the first hit:
|
|
32
|
+
|
|
33
|
+
1. an issue number in the user's request (`#391`, "issue 391", an issue URL);
|
|
34
|
+
2. an issue named earlier in this conversation as the thing being worked on;
|
|
35
|
+
3. an issue number in the current branch name (`feature/foo-123`, `123-foo`,
|
|
36
|
+
`issue-123`) — the usual case in Conductor-style setups, where each
|
|
37
|
+
workspace and branch is scoped to one issue;
|
|
38
|
+
4. the issue closed by an open PR for the current branch
|
|
39
|
+
(`gh pr view --json closingIssuesReferences`) — only worth the round-trip
|
|
40
|
+
when 1-3 found nothing.
|
|
41
|
+
|
|
42
|
+
Skip it only when no issue genuinely applies (pure exploration, chores with
|
|
43
|
+
no tracked issue). Never invent a ref just to have one.
|
|
44
|
+
|
|
45
|
+
**Then declare again whenever the answer could have changed.** This is a
|
|
46
|
+
standing obligation for the whole session, not a one-time step at the start.
|
|
47
|
+
Re-call `tag_session_work_item` when the branch is renamed, when the user
|
|
48
|
+
names a different issue, when a PR is opened against a different issue, or
|
|
49
|
+
when you discover mid-session that the real work item is another one. Your
|
|
50
|
+
declaration outranks every signal collected automatically, so it is the
|
|
51
|
+
**only** thing that can correct a session already credited to the wrong
|
|
52
|
+
issue — a stale declaration is not neutral, it silently keeps the session's
|
|
53
|
+
entire cost on the wrong issue. The call is session-local (transcript only,
|
|
54
|
+
no database round-trip), so calling it again costs nothing, and the most
|
|
55
|
+
recent call always wins.
|
|
56
|
+
|
|
57
|
+
## When cost comes up
|
|
58
|
+
|
|
59
|
+
The other standing trigger, and the one most sessions will never fire. When
|
|
60
|
+
the user asks what something cost — their own spend, this issue's cost,
|
|
61
|
+
whether the work is getting cheaper — answer from the tools rather than
|
|
62
|
+
estimating, and pick by the question:
|
|
63
|
+
|
|
64
|
+
- **"What have I been spending?"** → `get_my_spend`. One window (`range`:
|
|
65
|
+
`7d`, `30d`, `90d` or `month`, `30d` by default), with the prior period and
|
|
66
|
+
its delta and ranked breakdowns by project, AI tool and model.
|
|
67
|
+
- **"What has this issue cost?"** → `get_my_issue_spend`, with `projectSlug`
|
|
68
|
+
and the same issue you declared as `workItemRef`. That answer is the
|
|
69
|
+
issue's **lifetime** cost, not a window's, set against the median of your
|
|
70
|
+
own closed issues of the same type. Omit `workItemRef` to rank your
|
|
71
|
+
costliest issues in that project for the range instead.
|
|
72
|
+
- **"Is my work getting cheaper, or better attributed?"** →
|
|
73
|
+
`get_my_effectiveness` (`30d` or `90d`): cost per closed issue and median
|
|
74
|
+
cycle time per GitHub issue type, how much of your spend reached an issue
|
|
75
|
+
at all, spend per coding agent, how much of the spend still booked to a
|
|
76
|
+
pull request actually merged, and — when the org has turned workflow
|
|
77
|
+
collection on — your own workflow medians and practice adoption per type.
|
|
78
|
+
- **"How do I work with agents, compared with my team?"** →
|
|
79
|
+
`get_my_workflow` (`30d` or `90d`): per GitHub issue type, your settled
|
|
80
|
+
sessions' medians (human prompts, autonomy, interrupts, active time, tool
|
|
81
|
+
calls and errors) and how often each practice held, against the team's band
|
|
82
|
+
for the same type. It names nobody: no band below 3 other developers, and
|
|
83
|
+
"not enough closed work" below 5 of your closed items. If the org has not
|
|
84
|
+
turned workflow collection on, it says so and returns no figures — an org
|
|
85
|
+
admin with `settings:manage` can turn it on in Settings.
|
|
86
|
+
- **"How is my team doing, per kind of work?"** (managers only) →
|
|
87
|
+
`get_team_effectiveness` (`30d` or `90d`): the same scoreboard as Miranda's
|
|
88
|
+
Effectiveness page, naming each developer — per issue type, closed work,
|
|
89
|
+
cost per closed, cycle time, prompts and sessions per closed, attributed
|
|
90
|
+
share and practices adopted, each banded against the team median (never a
|
|
91
|
+
decimal ratio), "not enough closed work" below 5 closed items, and greyed
|
|
92
|
+
below 60% attributed share. It needs `settings:manage`; anyone else gets
|
|
93
|
+
"You do not have access." Off for the org, it says collection is not
|
|
94
|
+
enabled. For coaching and tooling choices, not a performance rating.
|
|
95
|
+
- **"Is any of this even being recorded?"** → `get_my_tracking_status`: every
|
|
96
|
+
machine you own and whether its collector is live, stale or silent, plus
|
|
97
|
+
your recent sessions with no confirmed work item.
|
|
98
|
+
|
|
99
|
+
Every one of these but `get_team_effectiveness` answers about **you**, the
|
|
100
|
+
authenticated caller. None takes a user argument and none reports the org's
|
|
101
|
+
numbers or anyone else's — the one team figure, `get_my_workflow`'s band, is
|
|
102
|
+
an aggregate that never names or counts anyone — so they are safe to call
|
|
103
|
+
without asking permission. `get_team_effectiveness` is the exception: it
|
|
104
|
+
names developers, so it answers only a caller with `settings:manage`; relay
|
|
105
|
+
it to the manager who asked, not to the team. Any other team or project-wide
|
|
106
|
+
view is the Miranda dashboard's job, not an MCP tool's — point the user there
|
|
107
|
+
instead of trying to assemble one.
|
|
108
|
+
|
|
109
|
+
**Repair attribution before quoting any of it.** `get_my_tracking_status`
|
|
110
|
+
lists your unattributed and ambiguous sessions, each naming the `projectSlug`
|
|
111
|
+
it needs; call `assign_my_session_work_item` (`projectSlug`, `sessionId`,
|
|
112
|
+
`workItemRef`) on each one, then re-read. You are the only person who can —
|
|
113
|
+
it refuses anyone else's session, an admin's included — and every per-issue
|
|
114
|
+
figure above is only as good as the attribution behind it. A session that has
|
|
115
|
+
already resolved to an issue is reported back as skipped, never overwritten.
|
|
116
|
+
|
|
117
|
+
**None of them tells you what THIS session has cost so far.** They read what
|
|
118
|
+
the collector has already reported, and the session you are in is still in
|
|
119
|
+
flight. The collector's `gloria-usage session` subcommand will answer that
|
|
120
|
+
locally, with no round-trip, once it ships; until then, say the current
|
|
121
|
+
session's cost isn't available yet rather than estimating it.
|
|
122
|
+
|
|
123
|
+
## Setting up usage tracking
|
|
124
|
+
|
|
125
|
+
The Miranda plugin ships hooks (Claude Code, Codex, OpenCode) that transmit
|
|
126
|
+
**token usage only** (model names, token counts, timestamps, session ids, a
|
|
127
|
+
locally-minted random machine UUID, and this machine's hostname, OS and
|
|
128
|
+
architecture so machines are distinguishable in the dashboard — never message
|
|
129
|
+
content, prompts, or code). Cursor's hooks are wired too but are currently a no-op — see the
|
|
130
|
+
Cursor note below. They are inert until that `config.json` exists, so offer
|
|
131
|
+
to set it up if it doesn't — the `setting-up-usage-tracking` skill drives
|
|
132
|
+
this end to end.
|
|
133
|
+
|
|
134
|
+
## MCP tools reference
|
|
135
|
+
|
|
136
|
+
All tools run as the authenticated user against their active organization.
|
|
137
|
+
Reads need `inventory:read` (any member); writes need `inventory:write`.
|
|
138
|
+
|
|
139
|
+
- `get_info` — org id/name/slug. Cheap; call it first when you need org
|
|
140
|
+
context or to check the `miranda` MCP server is reachable.
|
|
141
|
+
- `register_project`, `put_project_github_info` — register this repo as a
|
|
142
|
+
gloria.dev/Miranda project (the same rows either server writes).
|
|
143
|
+
- `tag_session_work_item` — declare the GitHub issue this session is working
|
|
144
|
+
on (bare issue number, `gh:482`, or a full issue URL). Session-local — it
|
|
145
|
+
writes only to this session's own transcript, never the database — the
|
|
146
|
+
local usage collector reads it back out and reports it for per-issue token
|
|
147
|
+
cost attribution. Call it as soon as the issue is known, and **again**
|
|
148
|
+
every time the work item changes; the most recent declaration wins and
|
|
149
|
+
outranks every automatically-collected signal. See "Start of every
|
|
150
|
+
session".
|
|
151
|
+
- `enable_usage_tracking` — mint a write-only, org-scoped Clerk API key and
|
|
152
|
+
return it as the `ingestToken` for this machine's collector. The collector
|
|
153
|
+
needs no base URL; it defaults to `https://gloria.dev`. See
|
|
154
|
+
"Setting up usage tracking".
|
|
155
|
+
- `get_my_spend` — your own spend for a window (`range`:
|
|
156
|
+
`7d`/`30d`/`90d`/`month`, default `30d`): actual spend with its
|
|
157
|
+
plans-vs-tokens split, the prior period and its delta, how many days you
|
|
158
|
+
ran an agent and your average on those days, your heaviest day, and ranked
|
|
159
|
+
breakdowns by project, AI tool and model. Takes no user argument.
|
|
160
|
+
- `get_my_issue_spend` — one issue's lifetime cost, your own share of it, how
|
|
161
|
+
many sessions were booked to it, and GitHub's own state, type and
|
|
162
|
+
timestamps, against the median cost of your own closed issues of that type.
|
|
163
|
+
Requires `projectSlug`. Pass `workItemRef` for a single issue — `range` is
|
|
164
|
+
ignored, because an issue's cost is the whole of its life — or omit it to
|
|
165
|
+
rank your costliest issues in that project for the range.
|
|
166
|
+
- `get_my_effectiveness` — your own effectiveness for `30d` or `90d`,
|
|
167
|
+
org-wide across every project: per GitHub issue type, issues closed and
|
|
168
|
+
still open, cost per closed issue and median cycle time; your attribution
|
|
169
|
+
split (unattributed, ambiguous, or still booked to a pull request rather
|
|
170
|
+
than an issue); spend, share and requests per coding agent; of the
|
|
171
|
+
pull-request-booked spend, what merged versus closed unmerged, the cost per
|
|
172
|
+
merged pull request, and review comments per merged pull request; and a
|
|
173
|
+
`workflow` section — your own settled sessions' profile medians and
|
|
174
|
+
practice adoption per issue type, as `get_my_workflow` reports them but
|
|
175
|
+
without the team band. `workflow` is null, with a not-enabled message, when
|
|
176
|
+
the org has not turned workflow collection on; every other figure still
|
|
177
|
+
works. One window governs every figure, so it is a within-period rate — use
|
|
178
|
+
`get_my_issue_spend` for an issue's lifetime cost.
|
|
179
|
+
- `get_my_workflow` — your own workflow for `30d` or `90d`, per GitHub issue
|
|
180
|
+
type: the medians of your settled sessions' profile (human prompts, tokens
|
|
181
|
+
per prompt, assistant turns per prompt, interrupts, active seconds, tool
|
|
182
|
+
calls, tool error rate) and your practice adoption, each against the team
|
|
183
|
+
band (p25, median, p75) for the same type, with a ready sentence per
|
|
184
|
+
figure. Below 5 of your closed items a type shows "not enough closed work";
|
|
185
|
+
below 3 other developers the band is omitted. Off for the org, it returns
|
|
186
|
+
only a not-enabled message.
|
|
187
|
+
- `get_team_effectiveness` — managers only (`settings:manage`): the org's
|
|
188
|
+
effectiveness scoreboard for `30d` or `90d`, the same rows, gates, bands and
|
|
189
|
+
greying as Miranda's Effectiveness page. Per developer (named) × GitHub
|
|
190
|
+
issue type: closed items and merged pull requests, cost per closed, median
|
|
191
|
+
cycle time, human prompts, sessions and active hours per closed, review
|
|
192
|
+
comments per merged pull request, merged and attributed share, and the
|
|
193
|
+
practices adopted, each per-unit figure banded (well below, below,
|
|
194
|
+
around, above, well above) against the team median beside the team row it
|
|
195
|
+
was drawn from. Below 5
|
|
196
|
+
closed items a row shows "not enough closed work"; under 60% attributed
|
|
197
|
+
share it is greyed with the reason. Off for the org, it returns only a
|
|
198
|
+
not-enabled message.
|
|
199
|
+
- `get_my_tracking_status` — takes no arguments. Your machines with what each
|
|
200
|
+
has cost and whether its collector is live, stale or has never sent a
|
|
201
|
+
heartbeat, plus your recent sessions with no confirmed work item — their
|
|
202
|
+
cost, branch, working directory, start time, and the `projectSlug` needed
|
|
203
|
+
to repair each one.
|
|
204
|
+
- `assign_my_session_work_item` — attach one of your own past sessions to a
|
|
205
|
+
GitHub issue: `projectSlug`, `sessionId` (as `get_my_tracking_status`
|
|
206
|
+
reports it) and `workItemRef` (the same three forms
|
|
207
|
+
`tag_session_work_item` accepts). Self-service only — anyone else's session
|
|
208
|
+
is refused, an admin's included.
|
|
209
|
+
|
|
210
|
+
## First-time and recovery
|
|
211
|
+
|
|
212
|
+
- **No work item applies to this session** — skip `tag_session_work_item`;
|
|
213
|
+
its cost simply won't attribute to an issue. Don't guess a ref just to have
|
|
214
|
+
one. If one becomes clear later, declare it then — a late declaration
|
|
215
|
+
still attributes the session's whole cost, including what was already
|
|
216
|
+
spent.
|
|
217
|
+
- **You declared the wrong issue** — call `tag_session_work_item` again with
|
|
218
|
+
the right one. The later declaration supersedes the earlier one and moves
|
|
219
|
+
the already-credited cost with it. There is no way to clear a declaration
|
|
220
|
+
other than replacing it, and no lower-ranked signal (a branch rename, a
|
|
221
|
+
PR's `Closes #N`) can override one — so correcting it is on you.
|
|
222
|
+
- **MCP auth fails** — the user must log in to the `miranda` server: Claude
|
|
223
|
+
Code `/mcp` → miranda → authenticate; Codex `codex mcp login miranda`;
|
|
224
|
+
OpenCode follows its MCP auth flow. Until then, `tag_session_work_item`
|
|
225
|
+
and the other tools above are unavailable — mention it and continue
|
|
226
|
+
without them.
|
|
227
|
+
- **This file is missing or contradicts the tools you see** — trust the live
|
|
228
|
+
MCP server, and offer to re-run `setting-up-usage-tracking`.
|
|
229
|
+
|
|
230
|
+
## Codex, OpenCode, and Cursor
|
|
231
|
+
|
|
232
|
+
**Codex:** the Codex plugin manifest (`.codex-plugin/plugin.json`) declares
|
|
233
|
+
the same `Stop`/`SessionStart` hooks Claude Code uses, pointing at the same
|
|
234
|
+
collector. Codex's own hooks documentation describes `Stop` as a genuine
|
|
235
|
+
turn-level event, distinct from `SessionStart`, with a payload shape
|
|
236
|
+
(`session_id`/`transcript_path`/`cwd`) that matches Claude Code's — so once
|
|
237
|
+
that plugin path fires, `hook-stop` handles it correctly: it names the
|
|
238
|
+
rollout file in the trigger it writes, and the daemon detects a Codex rollout
|
|
239
|
+
by name and parses it with the Codex parser instead of Claude Code's. This is
|
|
240
|
+
still not empirically confirmed against a live Codex install, so treat it as
|
|
241
|
+
expected-but-unverified rather than a guarantee.
|
|
242
|
+
|
|
243
|
+
**Windows — required, or nothing is recorded.** Codex's sandbox is selected
|
|
244
|
+
process-wide on Windows (`windows.sandbox`) and child processes inherit it,
|
|
245
|
+
unlike macOS/Linux where Codex sandboxes each command individually. Hook
|
|
246
|
+
commands therefore inherit the restriction, and the collector's state
|
|
247
|
+
directory sits outside any workspace root — so its writes are blocked, and so
|
|
248
|
+
is this skill's own `config.json` write. The failure is silent: hook
|
|
249
|
+
subcommands never exit non-zero by design. The user must add that directory
|
|
250
|
+
as a writable root in `%USERPROFILE%\.codex\config.toml` **before** setup
|
|
251
|
+
runs:
|
|
252
|
+
|
|
253
|
+
```toml
|
|
254
|
+
[sandbox_workspace_write]
|
|
255
|
+
writable_roots = ['C:\Users\<you>\.config\sandgarden']
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
TOML single quotes are literal strings, so backslashes stay as typed; double
|
|
259
|
+
quotes would need each one escaped. `C:\Users\<you>\.config\sandgarden` is
|
|
260
|
+
the default — substitute `SANDGARDEN_HOME`, the deprecated `GLORIA_HOME`, or
|
|
261
|
+
`%XDG_CONFIG_HOME%\sandgarden` if any is set, in that precedence order. You
|
|
262
|
+
cannot make this edit on the user's behalf from inside a sandboxed session:
|
|
263
|
+
`~/.codex` is a protected control directory, read-only to the agent even when
|
|
264
|
+
its parent is a writable root.
|
|
265
|
+
|
|
266
|
+
As a manual fallback (or on a Codex-only machine that hasn't installed the
|
|
267
|
+
plugin), point `notify` in `~/.codex/config.toml` at the collector download
|
|
268
|
+
stub directly:
|
|
269
|
+
|
|
270
|
+
```toml
|
|
271
|
+
notify = ["sh", "/path/to/plugins/miranda/collector/stub.sh", "hook-notify"]
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`notify` fires on every turn completion and carries a `thread-id` +
|
|
275
|
+
`cwd` JSON payload. `hook-notify` resolves the one rollout file for that
|
|
276
|
+
`thread-id` and tells the collector daemon to ingest it now — the same
|
|
277
|
+
lightweight, single-file trigger `hook-stop` gives Claude Code. A malformed
|
|
278
|
+
payload, or a `thread-id` with no matching rollout file yet (e.g. the very
|
|
279
|
+
first turn of a brand new session), simply triggers nothing: the daemon
|
|
280
|
+
watches Codex's rollout directories itself, so that turn is still collected
|
|
281
|
+
seconds later. Pointing `notify` at `hook-session-start` instead also works
|
|
282
|
+
— it just makes sure a daemon is running without naming a file.
|
|
283
|
+
|
|
284
|
+
**OpenCode:** the Miranda OpenCode plugin (`.opencode/plugins/miranda.js`)
|
|
285
|
+
wires `session.created` and `session.idle` to make sure this machine's
|
|
286
|
+
collector daemon is running — this ships automatically with the plugin, no
|
|
287
|
+
manual step needed. OpenCode keeps its sessions in SQLite rather than in a
|
|
288
|
+
transcript file, so there is nothing per-session to name; the daemon watches
|
|
289
|
+
OpenCode's storage directory and collects from it directly.
|
|
290
|
+
|
|
291
|
+
**Cursor:** the Cursor plugin wires `stop`/`sessionStart`/`sessionEnd` hooks
|
|
292
|
+
too, but they call the collector's `hook-cursor` entrypoint, which is a
|
|
293
|
+
**deliberate no-op**. Cursor hook payloads carry no token usage or cost data,
|
|
294
|
+
and Cursor's own local session storage is unreliable for it (missing cache
|
|
295
|
+
tokens, mostly-zeroed counts on current versions) — the accurate source is
|
|
296
|
+
the Team/Enterprise Admin API, which has no collector adapter yet. Be honest
|
|
297
|
+
about this status: Cursor sessions do not contribute usage data today, even
|
|
298
|
+
though the hooks are wired.
|
package/README.md
DELETED