@sandgarden/miranda 0.0.0-stage → 0.1.76

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.
@@ -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="81ca1fa7a45e"
48
+ RELEASE_TAG="collector-81ca1fa7a45e"
49
+ RELEASE_REPO="sandgardenhq/plugins"
50
+ ASSET_PREFIX="miranda-collector"
51
+ CHECKSUM_DARWIN_ARM64="93d0a631dfb5d6446254f2a78133d2eba9f007855acfc7889f7f94e9349abd08"
52
+ CHECKSUM_DARWIN_X64="bb8d3cd150f965c6c338411accfbd604c0b2fbce44974770ccc79b3526c8a9b7"
53
+ CHECKSUM_LINUX_X64="fd7cd2a516515b4250e7501beac3e53b856556bf4c4fb07eb691d8af922f563d"
54
+ CHECKSUM_LINUX_ARM64="ecc2f57a8c7568b9d3ba951b6ec2eb0bff56da72d95a87b30d90f5ac022f8b54"
55
+ CHECKSUM_WINDOWS_X64="24bda878b843b6fa7dbf2784fde4947c26191ccff71d1569f9293a2766ec5f46"
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.76",
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.