From 3b22fc39e61add9a3bfb8d2565d27c94acefc678 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 19 Mar 2026 17:21:05 -0700 Subject: [PATCH] feat: opt-in usage telemetry + community intelligence platform (v0.8.6) (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add gstack-telemetry-log and gstack-analytics scripts Local telemetry infrastructure for gstack usage tracking. gstack-telemetry-log appends JSONL events with skill name, duration, outcome, session ID, and platform info. Supports off/anonymous/community privacy tiers. gstack-analytics renders a personal usage dashboard from local data. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add telemetry preamble injection + opt-in prompt + epilogue Extends generatePreamble() with telemetry start block (config read, timer, session ID, .pending marker), opt-in prompt (gated by .telemetry-prompted), and epilogue instructions for Claude to log events after skill completion. Adds 5 telemetry tests. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate all SKILL.md files with telemetry blocks Automated regeneration from gen-skill-docs.ts changes. All skills now include telemetry start block, opt-in prompt, and epilogue. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add Supabase schema, edge functions, and SQL views Telemetry backend infrastructure: telemetry_events table with RLS (insert-only), installations table for retention tracking, update_checks for install pings. Edge functions for update-check (version + ping), telemetry-ingest (batch insert), and community-pulse (weekly active count). SQL views for crash clustering and skill co-occurrence sequences. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add telemetry-sync, community-dashboard, and integration tests gstack-telemetry-sync: fire-and-forget JSONL → Supabase sync with privacy tier field stripping, batch limits, and cursor tracking. gstack-community-dashboard: CLI tool querying Supabase for skill popularity, crash clusters, and version distribution. 19 integration tests covering all telemetry scripts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: session-specific .pending markers + crash_clusters view fix Addresses Codex review findings: - .pending race condition: use .pending-$SESSION_ID instead of shared .pending file to prevent concurrent session interference - crash_clusters view: add total_occurrences and anonymous_occurrences columns since anonymous tier has no installation_id - Added test: own session pending marker is not finalized Co-Authored-By: Claude Opus 4.6 (1M context) * feat: dual-attempt update check with Supabase install ping Fires a parallel background curl to Supabase during the slow-path version fetch. Logs upgrade_prompted event only on fresh fetches (not cached replays) to avoid overcounting. GitHub remains the primary version source — Supabase ping is fire-and-forget. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: integrate telemetry usage stats into /retro output Retro now reads ~/.gstack/analytics/skill-usage.jsonl and includes gstack usage metrics (skill run counts, top skills, success rate) in the weekly retrospective output. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: move 'Skill usage telemetry' to Completed in TODOS.md Implemented in this branch: local JSONL logging, opt-in prompt, privacy tiers, Supabase backend, community dashboard, /retro integration. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: wire Supabase credentials and expose tables via Data API Add supabase/config.sh with project URL and publishable key (safe to commit — RLS restricts to INSERT only). Update telemetry-sync, community-dashboard, and update-check to source the config and include proper auth headers for the Supabase REST API. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add SELECT RLS policies to migration for community dashboard reads All telemetry data is anonymous (no PII), so public reads via the publishable key are safe. Needed for the community dashboard to query skill popularity, crash clusters, and version distribution. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: bump version and changelog (v0.8.6) Co-Authored-By: Claude Opus 4.6 * fix: analytics backward-compatible with old JSONL format Handle old-format events (no event_type field) alongside new format. Skip hook_fire events. Fix grep -c whitespace issues and unbound variable errors. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: map JSONL field names to Postgres columns in telemetry-sync Local JSONL uses short names (v, ts, sessions) but the Supabase table expects full names (schema_version, event_timestamp, concurrent_sessions). Add sed mapping during field stripping. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Codex adversarial findings — cursor, opt-out, queries - Sync cursor now advances on HTTP 2xx (not grep for "inserted") - Update-check respects telemetry opt-out before pinging Supabase - Dashboard queries use correct view column names (total_occurrences) - Sync strips old-format "repo" field to prevent privacy leak Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add Privacy & Telemetry section to README Transparent disclosure of what telemetry collects, what it never sends, how to opt out, and a link to the schema so users can verify. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 13 + README.md | 14 + SKILL.md | 49 ++++ VERSION | 2 +- bin/gstack-analytics | 191 +++++++++++++ bin/gstack-community-dashboard | 113 ++++++++ bin/gstack-telemetry-log | 158 +++++++++++ bin/gstack-telemetry-sync | 127 +++++++++ bin/gstack-update-check | 32 +++ browse/SKILL.md | 49 ++++ codex/SKILL.md | 49 ++++ design-consultation/SKILL.md | 49 ++++ design-review/SKILL.md | 49 ++++ document-release/SKILL.md | 49 ++++ investigate/SKILL.md | 49 ++++ office-hours/SKILL.md | 49 ++++ plan-ceo-review/SKILL.md | 49 ++++ plan-design-review/SKILL.md | 49 ++++ plan-eng-review/SKILL.md | 49 ++++ qa-only/SKILL.md | 49 ++++ qa/SKILL.md | 49 ++++ retro/SKILL.md | 52 ++++ retro/SKILL.md.tmpl | 3 + review/SKILL.md | 49 ++++ scripts/gen-skill-docs.ts | 51 +++- setup-browser-cookies/SKILL.md | 49 ++++ ship/SKILL.md | 49 ++++ supabase/config.sh | 10 + supabase/functions/community-pulse/index.ts | 59 ++++ supabase/functions/telemetry-ingest/index.ts | 135 +++++++++ supabase/functions/update-check/index.ts | 37 +++ supabase/migrations/001_telemetry.sql | 89 ++++++ test/gen-skill-docs.test.ts | 47 ++++ test/telemetry.test.ts | 278 +++++++++++++++++++ 34 files changed, 2193 insertions(+), 2 deletions(-) create mode 100755 bin/gstack-analytics create mode 100755 bin/gstack-community-dashboard create mode 100755 bin/gstack-telemetry-log create mode 100755 bin/gstack-telemetry-sync create mode 100644 supabase/config.sh create mode 100644 supabase/functions/community-pulse/index.ts create mode 100644 supabase/functions/telemetry-ingest/index.ts create mode 100644 supabase/functions/update-check/index.ts create mode 100644 supabase/migrations/001_telemetry.sql create mode 100644 test/telemetry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e88d1beccfc01b75a387ed4d1af0b776a1e7b9..aca05eeb73eee3aa43b5569813f07c75875962b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.8.6] - 2026-03-19 + +### Added + +- **You can now see how you use gstack.** Run `gstack-analytics` to see a personal usage dashboard — which skills you use most, how long they take, your success rate. All data stays local on your machine. +- **Opt-in community telemetry.** On first run, gstack asks if you want to share anonymous usage data (skill names, duration, crash info — never code or file paths). Choose "yes" and you're part of the community pulse. Change anytime with `gstack-config set telemetry off`. +- **Community health dashboard.** Run `gstack-community-dashboard` to see what the gstack community is building — most popular skills, crash clusters, version distribution. All powered by Supabase. +- **Install base tracking via update check.** When telemetry is enabled, gstack fires a parallel ping to Supabase during update checks — giving us an install-base count without adding any latency. Respects your telemetry setting (default off). GitHub remains the primary version source. +- **Crash clustering.** Errors are automatically grouped by type and version in the Supabase backend, so the most impactful bugs surface first. +- **Upgrade funnel tracking.** We can now see how many people see upgrade prompts vs actually upgrade — helps us ship better releases. +- **/retro now shows your gstack usage.** Weekly retrospectives include skill usage stats (which skills you used, how often, success rate) alongside your commit history. +- **Session-specific pending markers.** If a skill crashes mid-run, the next invocation correctly finalizes only that session — no more race conditions between concurrent gstack sessions. + ## [0.8.5] - 2026-03-19 ### Fixed diff --git a/README.md b/README.md index 252fe18a3ed5da09660510b68938bdf544e12d28..5134a4800424cfd98c6097756d6c7bcc58b2fb75 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,20 @@ Fifteen specialists and six power tools. All slash commands. All Markdown. All f | [Contributing](CONTRIBUTING.md) | Dev setup, testing, contributor mode, and dev mode | | [Changelog](CHANGELOG.md) | What's new in every version | +## Privacy & Telemetry + +gstack includes **opt-in** usage telemetry to help improve the project. Here's exactly what happens: + +- **Default is off.** Nothing is sent anywhere unless you explicitly say yes. +- **On first run,** gstack asks if you want to share anonymous usage data. You can say no. +- **What's sent (if you opt in):** skill name, duration, success/fail, gstack version, OS. That's it. +- **What's never sent:** code, file paths, repo names, branch names, prompts, or any user-generated content. +- **Change anytime:** `gstack-config set telemetry off` disables everything instantly. + +Data is stored in [Supabase](https://supabase.com) (open source Firebase alternative). The schema is in [`supabase/migrations/001_telemetry.sql`](supabase/migrations/001_telemetry.sql) — you can verify exactly what's collected. The Supabase publishable key in the repo is a public key (like a Firebase API key) — row-level security policies restrict it to insert-only access. + +**Local analytics are always available.** Run `gstack-analytics` to see your personal usage dashboard from the local JSONL file — no remote data needed. + ## Troubleshooting **Skill not showing up?** `cd ~/.claude/skills/gstack && ./setup` diff --git a/SKILL.md b/SKILL.md index 29aeb03dd92296b805a412f559ce44c9c8d6e746..1f1085e6692e6045aa255885484dbb6095ce9602 100644 --- a/SKILL.md +++ b/SKILL.md @@ -64,8 +64,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"gstack","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -85,6 +92,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -184,6 +212,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + If `PROACTIVE` is `false`: do NOT proactively suggest other gstack skills during this session. Only run skills the user explicitly invokes. This preference persists across sessions via `gstack-config`. diff --git a/VERSION b/VERSION index 7ada0d303f3e7e49c3f18bfa9dcfa73a1895b28b..7fc2521fd745b54057892273fa61ae99ed9fc122 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.5 +0.8.6 diff --git a/bin/gstack-analytics b/bin/gstack-analytics new file mode 100755 index 0000000000000000000000000000000000000000..ad06edd167d3d15ff58de5881d382747f486b985 --- /dev/null +++ b/bin/gstack-analytics @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# gstack-analytics — personal usage dashboard from local JSONL +# +# Usage: +# gstack-analytics # default: last 7 days +# gstack-analytics 7d # last 7 days +# gstack-analytics 30d # last 30 days +# gstack-analytics all # all time +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +set -uo pipefail + +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" + +# ─── Parse time window ─────────────────────────────────────── +WINDOW="${1:-7d}" +case "$WINDOW" in + 7d) DAYS=7; LABEL="last 7 days" ;; + 30d) DAYS=30; LABEL="last 30 days" ;; + all) DAYS=0; LABEL="all time" ;; + *) DAYS=7; LABEL="last 7 days" ;; +esac + +# ─── Check for data ────────────────────────────────────────── +if [ ! -f "$JSONL_FILE" ]; then + echo "gstack usage — no data yet" + echo "" + echo "Usage data will appear here after you use gstack skills" + echo "with telemetry enabled (gstack-config set telemetry anonymous)." + exit 0 +fi + +TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' ')" +if [ "$TOTAL_LINES" = "0" ]; then + echo "gstack usage — no data yet" + exit 0 +fi + +# ─── Filter by time window ─────────────────────────────────── +if [ "$DAYS" -gt 0 ] 2>/dev/null; then + # Calculate cutoff date + if date -v-1d +%Y-%m-%d >/dev/null 2>&1; then + # macOS date + CUTOFF="$(date -v-${DAYS}d -u +%Y-%m-%dT%H:%M:%SZ)" + else + # GNU date + CUTOFF="$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2000-01-01T00:00:00Z")" + fi + # Filter: skill_run events (new format) OR basic skill events (old format, no event_type) + # Old format: {"skill":"X","ts":"Y","repo":"Z"} (no event_type field) + # New format: {"event_type":"skill_run","skill":"X","ts":"Y",...} + FILTERED="$(awk -F'"' -v cutoff="$CUTOFF" ' + /"ts":"/ { + # Skip hook_fire events + if (/"event":"hook_fire"/) next + # Skip non-skill_run new-format events + if (/"event_type":"/ && !/"event_type":"skill_run"/) next + for (i=1; i<=NF; i++) { + if ($i == "ts" && $(i+1) ~ /^:/) { + ts = $(i+2) + if (ts >= cutoff) { print; break } + } + } + } + ' "$JSONL_FILE")" +else + # All time: include skill_run events + old-format basic events, exclude hook_fire + FILTERED="$(awk '/"ts":"/ && !/"event":"hook_fire"/' "$JSONL_FILE" | grep -v '"event_type":"upgrade_' 2>/dev/null || true)" +fi + +if [ -z "$FILTERED" ]; then + echo "gstack usage ($LABEL) — no skill runs found" + exit 0 +fi + +# ─── Aggregate by skill ────────────────────────────────────── +# Extract skill names and count +SKILL_COUNTS="$(echo "$FILTERED" | awk -F'"' ' + /"skill":"/ { + for (i=1; i<=NF; i++) { + if ($i == "skill" && $(i+1) ~ /^:/) { + skill = $(i+2) + counts[skill]++ + break + } + } + } + END { + for (s in counts) print counts[s], s + } +' | sort -rn)" + +# Count outcomes +TOTAL="$(echo "$FILTERED" | wc -l | tr -d ' ')" +SUCCESS="$(echo "$FILTERED" | grep -c '"outcome":"success"' || true)" +SUCCESS="${SUCCESS:-0}"; SUCCESS="$(echo "$SUCCESS" | tr -d ' \n\r\t')" +ERRORS="$(echo "$FILTERED" | grep -c '"outcome":"error"' || true)" +ERRORS="${ERRORS:-0}"; ERRORS="$(echo "$ERRORS" | tr -d ' \n\r\t')" +# Old format events have no outcome field — count them as successful +NO_OUTCOME="$(echo "$FILTERED" | grep -vc '"outcome":' || true)" +NO_OUTCOME="${NO_OUTCOME:-0}"; NO_OUTCOME="$(echo "$NO_OUTCOME" | tr -d ' \n\r\t')" +SUCCESS=$(( SUCCESS + NO_OUTCOME )) + +# Calculate success rate +if [ "$TOTAL" -gt 0 ] 2>/dev/null; then + SUCCESS_RATE=$(( SUCCESS * 100 / TOTAL )) +else + SUCCESS_RATE=100 +fi + +# ─── Calculate total duration ──────────────────────────────── +TOTAL_DURATION="$(echo "$FILTERED" | awk -F'[:,]' ' + /"duration_s"/ { + for (i=1; i<=NF; i++) { + if ($i ~ /"duration_s"/) { + val = $(i+1) + gsub(/[^0-9.]/, "", val) + if (val+0 > 0) total += val + } + } + } + END { printf "%.0f", total } +')" + +# Format duration +TOTAL_DURATION="${TOTAL_DURATION:-0}" +if [ "$TOTAL_DURATION" -ge 3600 ] 2>/dev/null; then + HOURS=$(( TOTAL_DURATION / 3600 )) + MINS=$(( (TOTAL_DURATION % 3600) / 60 )) + DUR_DISPLAY="${HOURS}h ${MINS}m" +elif [ "$TOTAL_DURATION" -ge 60 ] 2>/dev/null; then + MINS=$(( TOTAL_DURATION / 60 )) + DUR_DISPLAY="${MINS}m" +else + DUR_DISPLAY="${TOTAL_DURATION}s" +fi + +# ─── Render output ─────────────────────────────────────────── +echo "gstack usage ($LABEL)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Find max count for bar scaling +MAX_COUNT="$(echo "$SKILL_COUNTS" | head -1 | awk '{print $1}')" +BAR_WIDTH=20 + +echo "$SKILL_COUNTS" | while read -r COUNT SKILL; do + # Scale bar + if [ "$MAX_COUNT" -gt 0 ] 2>/dev/null; then + BAR_LEN=$(( COUNT * BAR_WIDTH / MAX_COUNT )) + else + BAR_LEN=1 + fi + [ "$BAR_LEN" -lt 1 ] && BAR_LEN=1 + + # Build bar + BAR="" + i=0 + while [ "$i" -lt "$BAR_LEN" ]; do + BAR="${BAR}█" + i=$(( i + 1 )) + done + + # Calculate avg duration for this skill + AVG_DUR="$(echo "$FILTERED" | awk -v skill="$SKILL" ' + index($0, "\"skill\":\"" skill "\"") > 0 { + # Extract duration_s value using split on "duration_s": + n = split($0, parts, "\"duration_s\":") + if (n >= 2) { + # parts[2] starts with the value, e.g. "142," + gsub(/[^0-9.].*/, "", parts[2]) + if (parts[2]+0 > 0) { total += parts[2]; count++ } + } + } + END { if (count > 0) printf "%.0f", total/count; else print "0" } + ')" + + # Format avg duration + if [ "$AVG_DUR" -ge 60 ] 2>/dev/null; then + AVG_DISPLAY="$(( AVG_DUR / 60 ))m" + else + AVG_DISPLAY="${AVG_DUR}s" + fi + + printf " /%-20s %s %d runs (avg %s)\n" "$SKILL" "$BAR" "$COUNT" "$AVG_DISPLAY" +done + +echo "" +echo "Success rate: ${SUCCESS_RATE}% | Errors: ${ERRORS} | Total time: ${DUR_DISPLAY}" +echo "Events: ${TOTAL} skill runs" diff --git a/bin/gstack-community-dashboard b/bin/gstack-community-dashboard new file mode 100755 index 0000000000000000000000000000000000000000..5b7fc7ecf7ed033466f356bc52a78185dfe44fa0 --- /dev/null +++ b/bin/gstack-community-dashboard @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# gstack-community-dashboard — community usage stats from Supabase +# +# Queries the Supabase REST API to show community-wide gstack usage: +# skill popularity, crash clusters, version distribution, retention. +# +# Env overrides (for testing): +# GSTACK_DIR — override auto-detected gstack root +# GSTACK_SUPABASE_URL — override Supabase project URL +# GSTACK_SUPABASE_ANON_KEY — override Supabase anon key +set -uo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" + +# Source Supabase config if not overridden by env +if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then + . "$GSTACK_DIR/supabase/config.sh" +fi +SUPABASE_URL="${GSTACK_SUPABASE_URL:-}" +ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" + +if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then + echo "gstack community dashboard" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Supabase not configured yet. The community dashboard will be" + echo "available once the gstack Supabase project is set up." + echo "" + echo "For local analytics, run: gstack-analytics" + exit 0 +fi + +# ─── Helper: query Supabase REST API ───────────────────────── +query() { + local table="$1" + local params="${2:-}" + curl -sf --max-time 10 \ + "${SUPABASE_URL}/rest/v1/${table}?${params}" \ + -H "apikey: ${ANON_KEY}" \ + -H "Authorization: Bearer ${ANON_KEY}" \ + 2>/dev/null || echo "[]" +} + +echo "gstack community dashboard" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +# ─── Weekly active installs ────────────────────────────────── +WEEK_AGO="$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")" +if [ -n "$WEEK_AGO" ]; then + PULSE="$(curl -sf --max-time 10 \ + "${SUPABASE_URL}/functions/v1/community-pulse" \ + -H "Authorization: Bearer ${ANON_KEY}" \ + 2>/dev/null || echo '{"weekly_active":0}')" + + WEEKLY="$(echo "$PULSE" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" + CHANGE="$(echo "$PULSE" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")" + + echo "Weekly active installs: ${WEEKLY}" + if [ "$CHANGE" -gt 0 ] 2>/dev/null; then + echo " Change: +${CHANGE}%" + elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then + echo " Change: ${CHANGE}%" + fi + echo "" +fi + +# ─── Skill popularity (top 10) ─────────────────────────────── +echo "Top skills (last 7 days)" +echo "────────────────────────" + +# Query telemetry_events, group by skill +EVENTS="$(query "telemetry_events" "select=skill,gstack_version&event_type=eq.skill_run&event_timestamp=gte.${WEEK_AGO}&limit=1000" 2>/dev/null || echo "[]")" + +if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then + echo "$EVENTS" | grep -o '"skill":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -10 | while read -r COUNT SKILL; do + printf " /%-20s %d runs\n" "$SKILL" "$COUNT" + done +else + echo " No data yet" +fi +echo "" + +# ─── Crash clusters ────────────────────────────────────────── +echo "Top crash clusters" +echo "──────────────────" + +CRASHES="$(query "crash_clusters" "select=error_class,gstack_version,total_occurrences,identified_users&limit=5" 2>/dev/null || echo "[]")" + +if [ "$CRASHES" != "[]" ] && [ -n "$CRASHES" ]; then + echo "$CRASHES" | grep -o '"error_class":"[^"]*"' | awk -F'"' '{print $4}' | head -5 | while read -r ERR; do + C="$(echo "$CRASHES" | grep -o "\"error_class\":\"$ERR\"[^}]*\"total_occurrences\":[0-9]*" | grep -o '"total_occurrences":[0-9]*' | head -1 | grep -o '[0-9]*')" + printf " %-30s %s occurrences\n" "$ERR" "${C:-?}" + done +else + echo " No crashes reported" +fi +echo "" + +# ─── Version distribution ──────────────────────────────────── +echo "Version distribution (last 7 days)" +echo "───────────────────────────────────" + +if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then + echo "$EVENTS" | grep -o '"gstack_version":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -5 | while read -r COUNT VER; do + printf " v%-15s %d events\n" "$VER" "$COUNT" + done +else + echo " No data yet" +fi + +echo "" +echo "For local analytics: gstack-analytics" diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log new file mode 100755 index 0000000000000000000000000000000000000000..edcbdbabfb6e79edd4a2453a3547d68493849579 --- /dev/null +++ b/bin/gstack-telemetry-log @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# gstack-telemetry-log — append a telemetry event to local JSONL +# +# Data flow: +# preamble (start) ──▶ .pending marker +# preamble (epilogue) ──▶ gstack-telemetry-log ──▶ skill-usage.jsonl +# └──▶ gstack-telemetry-sync (bg) +# +# Usage: +# gstack-telemetry-log --skill qa --duration 142 --outcome success \ +# --used-browse true --session-id "12345-1710756600" +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_DIR — override auto-detected gstack root +# +# NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero +set -uo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +ANALYTICS_DIR="$STATE_DIR/analytics" +JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" +PENDING_DIR="$ANALYTICS_DIR" # .pending-* files live here +CONFIG_CMD="$GSTACK_DIR/bin/gstack-config" +VERSION_FILE="$GSTACK_DIR/VERSION" + +# ─── Parse flags ───────────────────────────────────────────── +SKILL="" +DURATION="" +OUTCOME="unknown" +USED_BROWSE="false" +SESSION_ID="" +ERROR_CLASS="" +EVENT_TYPE="skill_run" + +while [ $# -gt 0 ]; do + case "$1" in + --skill) SKILL="$2"; shift 2 ;; + --duration) DURATION="$2"; shift 2 ;; + --outcome) OUTCOME="$2"; shift 2 ;; + --used-browse) USED_BROWSE="$2"; shift 2 ;; + --session-id) SESSION_ID="$2"; shift 2 ;; + --error-class) ERROR_CLASS="$2"; shift 2 ;; + --event-type) EVENT_TYPE="$2"; shift 2 ;; + *) shift ;; + esac +done + +# ─── Read telemetry tier ───────────────────────────────────── +TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)" +TIER="${TIER:-off}" + +# Validate tier +case "$TIER" in + off|anonymous|community) ;; + *) TIER="off" ;; # invalid value → default to off +esac + +if [ "$TIER" = "off" ]; then + # Still clear pending markers for this session even if telemetry is off + [ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true + exit 0 +fi + +# ─── Finalize stale .pending markers ──────────────────────── +# Each session gets its own .pending-$SESSION_ID file to avoid races +# between concurrent sessions. Finalize any that don't match our session. +for PFILE in "$PENDING_DIR"/.pending-*; do + [ -f "$PFILE" ] || continue + # Skip our own session's marker (it's still in-flight) + PFILE_BASE="$(basename "$PFILE")" + PFILE_SID="${PFILE_BASE#.pending-}" + [ "$PFILE_SID" = "$SESSION_ID" ] && continue + + PENDING_DATA="$(cat "$PFILE" 2>/dev/null || true)" + rm -f "$PFILE" 2>/dev/null || true + if [ -n "$PENDING_DATA" ]; then + # Extract fields from pending marker using grep -o + awk + P_SKILL="$(echo "$PENDING_DATA" | grep -o '"skill":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_TS="$(echo "$PENDING_DATA" | grep -o '"ts":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_SID="$(echo "$PENDING_DATA" | grep -o '"session_id":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_VER="$(echo "$PENDING_DATA" | grep -o '"gstack_version":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + P_ARCH="$(uname -m)" + + # Write the stale event as outcome: unknown + mkdir -p "$ANALYTICS_DIR" + printf '{"v":1,"ts":"%s","event_type":"skill_run","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":null,"outcome":"unknown","error_class":null,"used_browse":false,"sessions":1}\n' \ + "$P_TS" "$P_SKILL" "$P_SID" "$P_VER" "$P_OS" "$P_ARCH" >> "$JSONL_FILE" 2>/dev/null || true + fi +done + +# Clear our own session's pending marker (we're about to log the real event) +[ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true + +# ─── Collect metadata ──────────────────────────────────────── +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%S 2>/dev/null || echo "")" +GSTACK_VERSION="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]' || echo "unknown")" +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +SESSIONS="1" +if [ -d "$STATE_DIR/sessions" ]; then + _SC="$(find "$STATE_DIR/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' \n\r\t')" + [ -n "$_SC" ] && [ "$_SC" -gt 0 ] 2>/dev/null && SESSIONS="$_SC" +fi + +# Generate installation_id for community tier +INSTALL_ID="" +if [ "$TIER" = "community" ]; then + HOST="$(hostname 2>/dev/null || echo "unknown")" + USER="$(whoami 2>/dev/null || echo "unknown")" + if command -v shasum >/dev/null 2>&1; then + INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | shasum -a 256 | awk '{print $1}')" + elif command -v sha256sum >/dev/null 2>&1; then + INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | sha256sum | awk '{print $1}')" + elif command -v openssl >/dev/null 2>&1; then + INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | openssl dgst -sha256 | awk '{print $NF}')" + fi + # If no SHA-256 command available, install_id stays empty +fi + +# Local-only fields (never sent remotely) +REPO_SLUG="" +BRANCH="" +if command -v git >/dev/null 2>&1; then + REPO_SLUG="$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' 2>/dev/null || true)" + BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +fi + +# ─── Construct and append JSON ─────────────────────────────── +mkdir -p "$ANALYTICS_DIR" + +# Escape null fields +ERR_FIELD="null" +[ -n "$ERROR_CLASS" ] && ERR_FIELD="\"$ERROR_CLASS\"" + +DUR_FIELD="null" +[ -n "$DURATION" ] && DUR_FIELD="$DURATION" + +INSTALL_FIELD="null" +[ -n "$INSTALL_ID" ] && INSTALL_FIELD="\"$INSTALL_ID\"" + +BROWSE_BOOL="false" +[ "$USED_BROWSE" = "true" ] && BROWSE_BOOL="true" + +printf '{"v":1,"ts":"%s","event_type":"%s","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":%s,"outcome":"%s","error_class":%s,"used_browse":%s,"sessions":%s,"installation_id":%s,"_repo_slug":"%s","_branch":"%s"}\n' \ + "$TS" "$EVENT_TYPE" "$SKILL" "$SESSION_ID" "$GSTACK_VERSION" "$OS" "$ARCH" \ + "$DUR_FIELD" "$OUTCOME" "$ERR_FIELD" "$BROWSE_BOOL" "${SESSIONS:-1}" \ + "$INSTALL_FIELD" "$REPO_SLUG" "$BRANCH" >> "$JSONL_FILE" 2>/dev/null || true + +# ─── Trigger sync if tier is not off ───────────────────────── +SYNC_CMD="$GSTACK_DIR/bin/gstack-telemetry-sync" +if [ -x "$SYNC_CMD" ]; then + "$SYNC_CMD" 2>/dev/null & +fi + +exit 0 diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync new file mode 100755 index 0000000000000000000000000000000000000000..90e372439cba5935e8a942220b272b2b437820b3 --- /dev/null +++ b/bin/gstack-telemetry-sync @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# gstack-telemetry-sync — sync local JSONL events to Supabase +# +# Fire-and-forget, backgrounded, rate-limited to once per 5 minutes. +# Strips local-only fields before sending. Respects privacy tiers. +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_DIR — override auto-detected gstack root +# GSTACK_TELEMETRY_ENDPOINT — override Supabase endpoint URL +set -uo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +ANALYTICS_DIR="$STATE_DIR/analytics" +JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" +CURSOR_FILE="$ANALYTICS_DIR/.last-sync-line" +RATE_FILE="$ANALYTICS_DIR/.last-sync-time" +CONFIG_CMD="$GSTACK_DIR/bin/gstack-config" + +# Source Supabase config if not overridden by env +if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then + . "$GSTACK_DIR/supabase/config.sh" +fi +ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" +ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" + +# ─── Pre-checks ────────────────────────────────────────────── +# No endpoint configured yet → exit silently +[ -z "$ENDPOINT" ] && exit 0 + +# No JSONL file → nothing to sync +[ -f "$JSONL_FILE" ] || exit 0 + +# Rate limit: once per 5 minutes +if [ -f "$RATE_FILE" ]; then + STALE=$(find "$RATE_FILE" -mmin +5 2>/dev/null || true) + [ -z "$STALE" ] && exit 0 +fi + +# ─── Read tier ─────────────────────────────────────────────── +TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)" +TIER="${TIER:-off}" +[ "$TIER" = "off" ] && exit 0 + +# ─── Read cursor ───────────────────────────────────────────── +CURSOR=0 +if [ -f "$CURSOR_FILE" ]; then + CURSOR="$(cat "$CURSOR_FILE" 2>/dev/null | tr -d ' \n\r\t')" + # Validate: must be a non-negative integer + case "$CURSOR" in *[!0-9]*) CURSOR=0 ;; esac +fi + +# Safety: if cursor exceeds file length, reset +TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' \n\r\t')" +if [ "$CURSOR" -gt "$TOTAL_LINES" ] 2>/dev/null; then + CURSOR=0 +fi + +# Nothing new to sync +[ "$CURSOR" -ge "$TOTAL_LINES" ] 2>/dev/null && exit 0 + +# ─── Read unsent lines ─────────────────────────────────────── +SKIP=$(( CURSOR + 1 )) +UNSENT="$(tail -n "+$SKIP" "$JSONL_FILE" 2>/dev/null || true)" +[ -z "$UNSENT" ] && exit 0 + +# ─── Strip local-only fields and build batch ───────────────── +BATCH="[" +FIRST=true +COUNT=0 + +while IFS= read -r LINE; do + # Skip empty or malformed lines + [ -z "$LINE" ] && continue + echo "$LINE" | grep -q '^{' || continue + + # Strip local-only fields + map JSONL field names to Postgres column names + CLEAN="$(echo "$LINE" | sed \ + -e 's/,"_repo_slug":"[^"]*"//g' \ + -e 's/,"_branch":"[^"]*"//g' \ + -e 's/"v":/"schema_version":/g' \ + -e 's/"ts":/"event_timestamp":/g' \ + -e 's/"sessions":/"concurrent_sessions":/g' \ + -e 's/,"repo":"[^"]*"//g')" + + # If anonymous tier, strip installation_id + if [ "$TIER" = "anonymous" ]; then + CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')" + fi + + if [ "$FIRST" = "true" ]; then + FIRST=false + else + BATCH="$BATCH," + fi + BATCH="$BATCH$CLEAN" + COUNT=$(( COUNT + 1 )) + + # Batch size limit + [ "$COUNT" -ge 100 ] && break +done <<< "$UNSENT" + +BATCH="$BATCH]" + +# Nothing to send after filtering +[ "$COUNT" -eq 0 ] && exit 0 + +# ─── POST to Supabase ──────────────────────────────────────── +HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ + -X POST "${ENDPOINT}/telemetry_events" \ + -H "Content-Type: application/json" \ + -H "apikey: ${ANON_KEY}" \ + -H "Authorization: Bearer ${ANON_KEY}" \ + -H "Prefer: return=minimal" \ + -d "$BATCH" 2>/dev/null || echo "000")" + +# ─── Update cursor on success (2xx) ───────────────────────── +case "$HTTP_CODE" in + 2*) NEW_CURSOR=$(( CURSOR + COUNT )) + echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true ;; +esac + +# Update rate limit marker +touch "$RATE_FILE" 2>/dev/null || true + +exit 0 diff --git a/bin/gstack-update-check b/bin/gstack-update-check index d44c7e0f27fea3556d987fae17603a4f282f62e2..d0d0f1f158f27c2450c512058fb3b04f8939715c 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -140,6 +140,30 @@ fi # ─── Step 4: Slow path — fetch remote version ──────────────── mkdir -p "$STATE_DIR" +# Fire Supabase install ping in background (parallel, non-blocking) +# This logs an update check event for community health metrics. +# If the endpoint isn't configured or Supabase is down, this is a no-op. +# Source Supabase config for install ping +if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then + . "$GSTACK_DIR/supabase/config.sh" +fi +_SUPA_ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" +_SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" +# Respect telemetry opt-out — don't ping Supabase if user set telemetry: off +_TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)" +if [ -n "$_SUPA_ENDPOINT" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then + _OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + curl -sf --max-time 5 \ + -X POST "${_SUPA_ENDPOINT}/update_checks" \ + -H "Content-Type: application/json" \ + -H "apikey: ${_SUPA_KEY}" \ + -H "Authorization: Bearer ${_SUPA_KEY}" \ + -H "Prefer: return=minimal" \ + -d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\"}" \ + >/dev/null 2>&1 & +fi + +# GitHub raw fetch (primary, always reliable) REMOTE="" REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)" REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')" @@ -161,4 +185,12 @@ echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE" if check_snooze "$REMOTE"; then exit 0 # snoozed — stay quiet fi + +# Log upgrade_prompted event (only on slow-path fetch, not cached replays) +TEL_CMD="$GSTACK_DIR/bin/gstack-telemetry-log" +if [ -x "$TEL_CMD" ]; then + "$TEL_CMD" --event-type upgrade_prompted --skill "" --duration 0 \ + --outcome success --session-id "update-$$-$(date +%s)" 2>/dev/null & +fi + echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" diff --git a/browse/SKILL.md b/browse/SKILL.md index 2c827aa6d580c2d714548c0279de6ce8dfd93962..e8ae44500fc50dd83db3fb46d8681a01df5135da 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -33,8 +33,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -54,6 +61,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -153,6 +181,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + # browse: QA Testing & Dogfooding Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command. diff --git a/codex/SKILL.md b/codex/SKILL.md index d5d7273d22cb68ef21c1dcb8fa3a669088243332..c67acc2ce1f78e1b3ece6e53e344f97a0addac43 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -34,8 +34,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"codex","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -55,6 +62,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -154,6 +182,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + ## Step 0: Detect base branch Determine which branch this PR targets. Use the result as "the base branch" in all subsequent steps. diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index 3fc231e5c62ee5f7681d4b332f3fc79d8b1e70d0..5d70420ad6ead3bd66d1f120c5debdabd7d0ddd4 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -38,8 +38,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"design-consultation","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -59,6 +66,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -158,6 +186,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + # /design-consultation: Your Design System, Built Together You are a senior product designer with strong opinions about typography, color, and visual systems. You don't present menus — you listen, think, research, and propose. You're opinionated but not dogmatic. You explain your reasoning and welcome pushback. diff --git a/design-review/SKILL.md b/design-review/SKILL.md index 5a3054c483d678828270ad207a0a83a06570f82c..5fdaa9897cedef1703fb094e0ebca6a6ac6b5d19 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -38,8 +38,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -59,6 +66,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -158,6 +186,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + # /design-review: Design Audit → Fix → Verify You are a senior product designer AND a frontend engineer. Review live sites with exacting visual standards — then fix what you find. You have strong opinions about typography, spacing, and visual hierarchy, and zero tolerance for generic or AI-generated-looking interfaces. diff --git a/document-release/SKILL.md b/document-release/SKILL.md index ad081f97c9e07d7f32a113bc13fda41323f2fc47..cf8656e453c78f1b5e04f218e8808ea8f3ed1f59 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -35,8 +35,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"document-release","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -56,6 +63,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -155,6 +183,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + ## Step 0: Detect base branch Determine which branch this PR targets. Use the result as "the base branch" in all subsequent steps. diff --git a/investigate/SKILL.md b/investigate/SKILL.md index 5bce8d2f4168d36f77a339f17d4c3354fa2210e8..17ef4a8b0abef689570e2a6d4805f017051e3311 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -48,8 +48,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"investigate","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -69,6 +76,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -168,6 +196,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + # Systematic Debugging ## Iron Law diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index ad983d4eb55f3b70ed16ce7f14c4ba9a76d3bf70..0a2bb310439ad7e66e92c69ba44acf54c8b616fb 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -39,8 +39,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -60,6 +67,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -159,6 +187,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + # YC Office Hours You are a **YC office hours partner**. Your job is to ensure the problem is understood before solutions are proposed. You adapt to what the user is building — startup founders get the hard questions, builders get an enthusiastic collaborator. This skill produces design docs, not code. diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index ca31769b43afd08b75acc43558f2905a8fe1a754..d684ac79d4885a5f4118a5bf5048a16e19ccab20 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -36,8 +36,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"plan-ceo-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -57,6 +64,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -156,6 +184,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + ## Step 0: Detect base branch Determine which branch this PR targets. Use the result as "the base branch" in all subsequent steps. diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index f9bfd0b62df2440c67501d5d9853e3ee04b5ae0c..d2b4fe76cb55487536f728fa11fd6960b6afcc70 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -36,8 +36,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"plan-design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -57,6 +64,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -156,6 +184,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + ## Step 0: Detect base branch Determine which branch this PR targets. Use the result as "the base branch" in all subsequent steps. diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index bff1e3d356e26d5d38bc29a522fd3981b8542b50..d22ee433d05bbeb446b8decbaa5073833d378c94 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -35,8 +35,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"plan-eng-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -56,6 +63,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -155,6 +183,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + # Plan Review Mode Review this plan thoroughly before making any code changes. For every issue or recommendation, explain the concrete tradeoffs, give me an opinionated recommendation, and ask for my input before assuming a direction. diff --git a/qa-only/SKILL.md b/qa-only/SKILL.md index 324913b87962bddbd07201a87408c6363c44f419..4242f626dcafdf1fa8a16261927438cbe870e7bc 100644 --- a/qa-only/SKILL.md +++ b/qa-only/SKILL.md @@ -32,8 +32,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"qa-only","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -53,6 +60,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -152,6 +180,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + # /qa-only: Report-Only QA Testing You are a QA engineer. Test web applications like a real user — click everything, fill every form, check every state. Produce a structured report with evidence. **NEVER fix anything.** diff --git a/qa/SKILL.md b/qa/SKILL.md index 0b8b32a766c2b9afa56b72ff737e1fc0627f4062..17066366839f0592e61d5c429fa9e331ebcd9e0c 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -39,8 +39,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"qa","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -60,6 +67,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -159,6 +187,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + ## Step 0: Detect base branch Determine which branch this PR targets. Use the result as "the base branch" in all subsequent steps. diff --git a/retro/SKILL.md b/retro/SKILL.md index 25126ac3f59178084647731f7d4fe33e8e16d140..7cbdd6d4c5d0dd1df8057a7e9c049ab86a150297 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -33,8 +33,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"retro","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -54,6 +61,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -153,6 +181,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + ## Detect default branch Before gathering data, detect the repo's default branch name: @@ -245,6 +294,9 @@ find . -name '*.test.*' -o -name '*.spec.*' -o -name '*_test.*' -o -name '*_spec # 11. Regression test commits in window git log origin/ --since="" --oneline --grep="test(qa):" --grep="test(design):" --grep="test: coverage" +# 12. gstack skill usage telemetry (if available) +cat ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true + # 12. Test files changed in window git log origin/ --since="" --format="" --name-only | grep -E '\.(test|spec)\.' | sort -u | wc -l ``` diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 70647625777a550baef3f65fa4bb396131043540..a918e24a942a4ca08977096d7087b37acad846df 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -109,6 +109,9 @@ find . -name '*.test.*' -o -name '*.spec.*' -o -name '*_test.*' -o -name '*_spec # 11. Regression test commits in window git log origin/ --since="" --oneline --grep="test(qa):" --grep="test(design):" --grep="test: coverage" +# 12. gstack skill usage telemetry (if available) +cat ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true + # 12. Test files changed in window git log origin/ --since="" --format="" --name-only | grep -E '\.(test|spec)\.' | sort -u | wc -l ``` diff --git a/review/SKILL.md b/review/SKILL.md index d1901dd9795e281c3fc7e06e48db022be10252f2..86c7c768bb8d1525cb6b966bb2f25931f8c75115 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -34,8 +34,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -55,6 +62,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -154,6 +182,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + ## Step 0: Detect base branch Determine which branch this PR targets. Use the result as "the base branch" in all subsequent steps. diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 3bb363ae0b28318b5d13ac1611a782bfad2d3c5d..b746bf2aba174771f61bbb747639b59bf3732720 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -118,8 +118,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: \${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"${ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done \`\`\` If \`PROACTIVE\` is \`"false"\`, do not proactively suggest gstack skills — only invoke @@ -139,6 +146,27 @@ touch ~/.gstack/.completeness-intro-seen Only run \`open\` if the user says yes. Always run \`touch\` to mark as seen. This only happens once. +If \`TEL_PROMPTED\` is \`no\` AND \`LAKE_INTRO\` is \`yes\`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with \`gstack-config set telemetry off\`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run \`~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous\` +If B: run \`~/.claude/skills/gstack/bin/gstack-config set telemetry off\` + +Always run: +\`\`\`bash +touch ~/.gstack/.telemetry-prompted +\`\`\` + +This only happens once. If \`TEL_PROMPTED\` is \`yes\`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -236,7 +264,28 @@ STATUS: BLOCKED | NEEDS_CONTEXT REASON: [1-2 sentences] ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] -\`\`\``; +\`\`\` + +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the \`name:\` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +\`\`\`bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \\ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \\ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +\`\`\` + +Replace \`SKILL_NAME\` with the actual skill name from frontmatter, \`OUTCOME\` with +success/error/abort, and \`USED_BROWSE\` with true/false based on whether \`$B\` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user.`; } function generateBrowseSetup(_ctx: TemplateContext): string { diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index 083a5a3874800fb7e49a2e2a7bae3e63ef54f4e5..864c6c31d641f7ec8a4d132c7926e29b14db3e65 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -30,8 +30,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"setup-browser-cookies","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -51,6 +58,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -150,6 +178,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + # Setup Browser Cookies Import logged-in sessions from your real Chromium browser into the headless browse session. diff --git a/ship/SKILL.md b/ship/SKILL.md index 0a6eaaed9a4e3aac7e9deada187b28c5f65636d9..be1b628cfab7ce11a59c32181dcb9f9ec6637162 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -33,8 +33,15 @@ echo "BRANCH: $_BRANCH" echo "PROACTIVE: $_PROACTIVE" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" mkdir -p ~/.gstack/analytics echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done ``` If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke @@ -54,6 +61,27 @@ touch ~/.gstack/.completeness-intro-seen Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> gstack can share anonymous usage data (which skills you use, how long they take, crash info) +> to help improve the project. No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Yes, share anonymous data (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + ## AskUserQuestion Format **ALWAYS follow this structure for every AskUserQuestion call:** @@ -153,6 +181,27 @@ ATTEMPTED: [what you tried] RECOMMENDATION: [what the user should do next] ``` +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + ## Step 0: Detect base branch Determine which branch this PR targets. Use the result as "the base branch" in all subsequent steps. diff --git a/supabase/config.sh b/supabase/config.sh new file mode 100644 index 0000000000000000000000000000000000000000..b10aef6b7e8ababeb7f4ceea0156c7a9df34e908 --- /dev/null +++ b/supabase/config.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Supabase project config for gstack telemetry +# These are PUBLIC keys — safe to commit (like Firebase public config). +# RLS policies restrict what the anon/publishable key can do (INSERT only). + +GSTACK_SUPABASE_URL="https://frugpmstpnojnhfyimgv.supabase.co" +GSTACK_SUPABASE_ANON_KEY="sb_publishable_tR4i6cyMIrYTE3s6OyHGHw_ppx2p6WK" + +# Telemetry ingest endpoint (Data API) +GSTACK_TELEMETRY_ENDPOINT="${GSTACK_SUPABASE_URL}/rest/v1" diff --git a/supabase/functions/community-pulse/index.ts b/supabase/functions/community-pulse/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..23e30202d482094a12759c4fbb134cbcaf32c44e --- /dev/null +++ b/supabase/functions/community-pulse/index.ts @@ -0,0 +1,59 @@ +// gstack community-pulse edge function +// Returns weekly active installation count for preamble display. +// Cached for 1 hour via Cache-Control header. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +Deno.serve(async () => { + const supabase = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "" + ); + + try { + // Count unique update checks in the last 7 days (install base proxy) + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(); + + // This week's active + const { count: thisWeek } = await supabase + .from("update_checks") + .select("*", { count: "exact", head: true }) + .gte("checked_at", weekAgo); + + // Last week's active (for change %) + const { count: lastWeek } = await supabase + .from("update_checks") + .select("*", { count: "exact", head: true }) + .gte("checked_at", twoWeeksAgo) + .lt("checked_at", weekAgo); + + const current = thisWeek ?? 0; + const previous = lastWeek ?? 0; + const changePct = previous > 0 + ? Math.round(((current - previous) / previous) * 100) + : 0; + + return new Response( + JSON.stringify({ + weekly_active: current, + change_pct: changePct, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", // 1 hour cache + }, + } + ); + } catch { + return new Response( + JSON.stringify({ weekly_active: 0, change_pct: 0 }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } +}); diff --git a/supabase/functions/telemetry-ingest/index.ts b/supabase/functions/telemetry-ingest/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..07d65d364821eaa2cd9c5fc6b430ea6465955b73 --- /dev/null +++ b/supabase/functions/telemetry-ingest/index.ts @@ -0,0 +1,135 @@ +// gstack telemetry-ingest edge function +// Validates and inserts a batch of telemetry events. +// Called by bin/gstack-telemetry-sync. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +interface TelemetryEvent { + v: number; + ts: string; + event_type: string; + skill: string; + session_id?: string; + gstack_version: string; + os: string; + arch?: string; + duration_s?: number; + outcome: string; + error_class?: string; + used_browse?: boolean; + sessions?: number; + installation_id?: string; +} + +const MAX_BATCH_SIZE = 100; +const MAX_PAYLOAD_BYTES = 50_000; // 50KB + +Deno.serve(async (req) => { + if (req.method !== "POST") { + return new Response("POST required", { status: 405 }); + } + + // Check payload size + const contentLength = parseInt(req.headers.get("content-length") || "0"); + if (contentLength > MAX_PAYLOAD_BYTES) { + return new Response("Payload too large", { status: 413 }); + } + + try { + const body = await req.json(); + const events: TelemetryEvent[] = Array.isArray(body) ? body : [body]; + + if (events.length > MAX_BATCH_SIZE) { + return new Response(`Batch too large (max ${MAX_BATCH_SIZE})`, { status: 400 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "" + ); + + // Validate and transform events + const rows = []; + const installationUpserts: Map = new Map(); + + for (const event of events) { + // Required fields + if (!event.ts || !event.gstack_version || !event.os || !event.outcome) { + continue; // skip malformed + } + + // Validate schema version + if (event.v !== 1) continue; + + // Validate event_type + const validTypes = ["skill_run", "upgrade_prompted", "upgrade_completed"]; + if (!validTypes.includes(event.event_type)) continue; + + rows.push({ + schema_version: event.v, + event_type: event.event_type, + gstack_version: String(event.gstack_version).slice(0, 20), + os: String(event.os).slice(0, 20), + arch: event.arch ? String(event.arch).slice(0, 20) : null, + event_timestamp: event.ts, + skill: event.skill ? String(event.skill).slice(0, 50) : null, + session_id: event.session_id ? String(event.session_id).slice(0, 50) : null, + duration_s: typeof event.duration_s === "number" ? event.duration_s : null, + outcome: String(event.outcome).slice(0, 20), + error_class: event.error_class ? String(event.error_class).slice(0, 100) : null, + used_browse: event.used_browse === true, + concurrent_sessions: typeof event.sessions === "number" ? event.sessions : 1, + installation_id: event.installation_id ? String(event.installation_id).slice(0, 64) : null, + }); + + // Track installations for upsert + if (event.installation_id) { + installationUpserts.set(event.installation_id, { + version: event.gstack_version, + os: event.os, + }); + } + } + + if (rows.length === 0) { + return new Response(JSON.stringify({ inserted: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Insert events + const { error: insertError } = await supabase + .from("telemetry_events") + .insert(rows); + + if (insertError) { + return new Response(JSON.stringify({ error: insertError.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // Upsert installations (update last_seen) + for (const [id, data] of installationUpserts) { + await supabase + .from("installations") + .upsert( + { + installation_id: id, + last_seen: new Date().toISOString(), + gstack_version: data.version, + os: data.os, + }, + { onConflict: "installation_id" } + ); + } + + return new Response(JSON.stringify({ inserted: rows.length }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch { + return new Response("Invalid request", { status: 400 }); + } +}); diff --git a/supabase/functions/update-check/index.ts b/supabase/functions/update-check/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f25efed8aa0bf9bd9319595e41f23494ce525d19 --- /dev/null +++ b/supabase/functions/update-check/index.ts @@ -0,0 +1,37 @@ +// gstack update-check edge function +// Logs an install ping and returns the current latest version. +// Called by bin/gstack-update-check as a parallel background request. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const CURRENT_VERSION = Deno.env.get("GSTACK_CURRENT_VERSION") || "0.6.4.1"; + +Deno.serve(async (req) => { + if (req.method !== "POST") { + return new Response(CURRENT_VERSION, { status: 200 }); + } + + try { + const { version, os } = await req.json(); + + if (!version || !os) { + return new Response(CURRENT_VERSION, { status: 200 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "" + ); + + // Log the update check (fire-and-forget) + await supabase.from("update_checks").insert({ + gstack_version: String(version).slice(0, 20), + os: String(os).slice(0, 20), + }); + + return new Response(CURRENT_VERSION, { status: 200 }); + } catch { + // Always return the version, even if logging fails + return new Response(CURRENT_VERSION, { status: 200 }); + } +}); diff --git a/supabase/migrations/001_telemetry.sql b/supabase/migrations/001_telemetry.sql new file mode 100644 index 0000000000000000000000000000000000000000..ab26f36fefd53a9c866ec0be75de7e570b194b0f --- /dev/null +++ b/supabase/migrations/001_telemetry.sql @@ -0,0 +1,89 @@ +-- gstack telemetry schema +-- Tables for tracking usage, installations, and update checks. + +-- Main telemetry events (skill runs, upgrades) +CREATE TABLE telemetry_events ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + received_at TIMESTAMPTZ DEFAULT now(), + schema_version INTEGER NOT NULL DEFAULT 1, + event_type TEXT NOT NULL DEFAULT 'skill_run', + gstack_version TEXT NOT NULL, + os TEXT NOT NULL, + arch TEXT, + event_timestamp TIMESTAMPTZ NOT NULL, + skill TEXT, + session_id TEXT, + duration_s NUMERIC, + outcome TEXT NOT NULL, + error_class TEXT, + used_browse BOOLEAN DEFAULT false, + concurrent_sessions INTEGER DEFAULT 1, + installation_id TEXT -- nullable, only for "community" tier +); + +-- Index for skill_sequences view performance +CREATE INDEX idx_telemetry_session_ts ON telemetry_events (session_id, event_timestamp); +-- Index for crash clustering +CREATE INDEX idx_telemetry_error ON telemetry_events (error_class, gstack_version) WHERE outcome = 'error'; + +-- Retention tracking per installation +CREATE TABLE installations ( + installation_id TEXT PRIMARY KEY, + first_seen TIMESTAMPTZ DEFAULT now(), + last_seen TIMESTAMPTZ DEFAULT now(), + gstack_version TEXT, + os TEXT +); + +-- Install pings from update checks +CREATE TABLE update_checks ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + checked_at TIMESTAMPTZ DEFAULT now(), + gstack_version TEXT NOT NULL, + os TEXT NOT NULL +); + +-- RLS: anon key can INSERT and SELECT (all telemetry data is anonymous) +ALTER TABLE telemetry_events ENABLE ROW LEVEL SECURITY; +CREATE POLICY "anon_insert_only" ON telemetry_events FOR INSERT WITH CHECK (true); +CREATE POLICY "anon_select" ON telemetry_events FOR SELECT USING (true); + +ALTER TABLE installations ENABLE ROW LEVEL SECURITY; +CREATE POLICY "anon_insert_only" ON installations FOR INSERT WITH CHECK (true); +CREATE POLICY "anon_select" ON installations FOR SELECT USING (true); +-- Allow upsert (update last_seen) +CREATE POLICY "anon_update_last_seen" ON installations FOR UPDATE USING (true) WITH CHECK (true); + +ALTER TABLE update_checks ENABLE ROW LEVEL SECURITY; +CREATE POLICY "anon_insert_only" ON update_checks FOR INSERT WITH CHECK (true); +CREATE POLICY "anon_select" ON update_checks FOR SELECT USING (true); + +-- Crash clustering view +CREATE VIEW crash_clusters AS +SELECT + error_class, + gstack_version, + COUNT(*) as total_occurrences, + COUNT(DISTINCT installation_id) as identified_users, -- community tier only + COUNT(*) - COUNT(installation_id) as anonymous_occurrences, -- events without installation_id + MIN(event_timestamp) as first_seen, + MAX(event_timestamp) as last_seen +FROM telemetry_events +WHERE outcome = 'error' AND error_class IS NOT NULL +GROUP BY error_class, gstack_version +ORDER BY total_occurrences DESC; + +-- Skill sequence co-occurrence view +CREATE VIEW skill_sequences AS +SELECT + a.skill as skill_a, + b.skill as skill_b, + COUNT(DISTINCT a.session_id) as co_occurrences +FROM telemetry_events a +JOIN telemetry_events b ON a.session_id = b.session_id + AND a.skill != b.skill + AND a.event_timestamp < b.event_timestamp +WHERE a.event_type = 'skill_run' AND b.event_type = 'skill_run' +GROUP BY a.skill, b.skill +HAVING COUNT(DISTINCT a.session_id) >= 10 +ORDER BY co_occurrences DESC; diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 32d1ad8158b06f4e4269a4bc3eeeffab22bb1a3f..0664f3612a849a2529330ddc08694b2bcd754a3f 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -422,3 +422,50 @@ describe('REVIEW_DASHBOARD resolver', () => { expect(content).not.toContain('Review Chaining'); }); }); + +describe('telemetry', () => { + test('generated SKILL.md contains telemetry start block', () => { + const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); + expect(content).toContain('_TEL_START'); + expect(content).toContain('_SESSION_ID'); + expect(content).toContain('TELEMETRY:'); + expect(content).toContain('TEL_PROMPTED:'); + expect(content).toContain('gstack-config get telemetry'); + }); + + test('generated SKILL.md contains telemetry opt-in prompt', () => { + const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); + expect(content).toContain('.telemetry-prompted'); + expect(content).toContain('anonymous usage data'); + expect(content).toContain('gstack-config set telemetry anonymous'); + expect(content).toContain('gstack-config set telemetry off'); + }); + + test('generated SKILL.md contains telemetry epilogue', () => { + const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); + expect(content).toContain('Telemetry (run last)'); + expect(content).toContain('gstack-telemetry-log'); + expect(content).toContain('_TEL_END'); + expect(content).toContain('_TEL_DUR'); + expect(content).toContain('SKILL_NAME'); + expect(content).toContain('OUTCOME'); + }); + + test('generated SKILL.md contains pending marker handling', () => { + const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); + expect(content).toContain('.pending'); + expect(content).toContain('_pending_finalize'); + }); + + test('telemetry blocks appear in all skill files that use PREAMBLE', () => { + const skills = ['qa', 'ship', 'review', 'plan-ceo-review', 'plan-eng-review', 'retro']; + for (const skill of skills) { + const skillPath = path.join(ROOT, skill, 'SKILL.md'); + if (fs.existsSync(skillPath)) { + const content = fs.readFileSync(skillPath, 'utf-8'); + expect(content).toContain('_TEL_START'); + expect(content).toContain('Telemetry (run last)'); + } + } + }); +}); diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4dc79b29a790d1efe0c9ba945a974b7af0553c9e --- /dev/null +++ b/test/telemetry.test.ts @@ -0,0 +1,278 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin'); + +// Each test gets a fresh temp directory for GSTACK_STATE_DIR +let tmpDir: string; + +function run(cmd: string, env: Record = {}): string { + return execSync(cmd, { + cwd: ROOT, + env: { ...process.env, GSTACK_STATE_DIR: tmpDir, GSTACK_DIR: ROOT, ...env }, + encoding: 'utf-8', + timeout: 10000, + }).trim(); +} + +function setConfig(key: string, value: string) { + run(`${BIN}/gstack-config set ${key} ${value}`); +} + +function readJsonl(): string[] { + const file = path.join(tmpDir, 'analytics', 'skill-usage.jsonl'); + if (!fs.existsSync(file)) return []; + return fs.readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean); +} + +function parseJsonl(): any[] { + return readJsonl().map(line => JSON.parse(line)); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-tel-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('gstack-telemetry-log', () => { + test('appends valid JSONL when tier=anonymous', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 142 --outcome success --session-id test-123`); + + const events = parseJsonl(); + expect(events).toHaveLength(1); + expect(events[0].v).toBe(1); + expect(events[0].skill).toBe('qa'); + expect(events[0].duration_s).toBe(142); + expect(events[0].outcome).toBe('success'); + expect(events[0].session_id).toBe('test-123'); + expect(events[0].event_type).toBe('skill_run'); + expect(events[0].os).toBeTruthy(); + expect(events[0].gstack_version).toBeTruthy(); + }); + + test('produces no output when tier=off', () => { + setConfig('telemetry', 'off'); + run(`${BIN}/gstack-telemetry-log --skill ship --duration 30 --outcome success --session-id test-456`); + + expect(readJsonl()).toHaveLength(0); + }); + + test('defaults to off for invalid tier value', () => { + setConfig('telemetry', 'invalid_value'); + run(`${BIN}/gstack-telemetry-log --skill ship --duration 30 --outcome success --session-id test-789`); + + expect(readJsonl()).toHaveLength(0); + }); + + test('includes installation_id for community tier', () => { + setConfig('telemetry', 'community'); + run(`${BIN}/gstack-telemetry-log --skill review --duration 100 --outcome success --session-id comm-123`); + + const events = parseJsonl(); + expect(events).toHaveLength(1); + // installation_id should be a SHA-256 hash (64 hex chars) + expect(events[0].installation_id).toMatch(/^[a-f0-9]{64}$/); + }); + + test('installation_id is null for anonymous tier', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id anon-123`); + + const events = parseJsonl(); + expect(events[0].installation_id).toBeNull(); + }); + + test('includes error_class when provided', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill browse --duration 10 --outcome error --error-class timeout --session-id err-123`); + + const events = parseJsonl(); + expect(events[0].error_class).toBe('timeout'); + expect(events[0].outcome).toBe('error'); + }); + + test('handles missing duration gracefully', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --outcome success --session-id nodur-123`); + + const events = parseJsonl(); + expect(events[0].duration_s).toBeNull(); + }); + + test('supports event_type flag', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --event-type upgrade_prompted --skill "" --outcome success --session-id up-123`); + + const events = parseJsonl(); + expect(events[0].event_type).toBe('upgrade_prompted'); + }); + + test('includes local-only fields (_repo_slug, _branch)', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id local-123`); + + const events = parseJsonl(); + // These should be present in local JSONL + expect(events[0]).toHaveProperty('_repo_slug'); + expect(events[0]).toHaveProperty('_branch'); + }); + + test('creates analytics directory if missing', () => { + // Remove analytics dir + const analyticsDir = path.join(tmpDir, 'analytics'); + if (fs.existsSync(analyticsDir)) fs.rmSync(analyticsDir, { recursive: true }); + + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id mkdir-123`); + + expect(fs.existsSync(analyticsDir)).toBe(true); + expect(readJsonl()).toHaveLength(1); + }); +}); + +describe('.pending marker', () => { + test('finalizes stale .pending from another session as outcome:unknown', () => { + setConfig('telemetry', 'anonymous'); + + // Write a fake .pending marker from a different session + const analyticsDir = path.join(tmpDir, 'analytics'); + fs.mkdirSync(analyticsDir, { recursive: true }); + fs.writeFileSync( + path.join(analyticsDir, '.pending-old-123'), + '{"skill":"old-skill","ts":"2026-03-18T00:00:00Z","session_id":"old-123","gstack_version":"0.6.4"}' + ); + + // Run telemetry-log with a DIFFERENT session — should finalize the old pending marker + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id new-456`); + + const events = parseJsonl(); + expect(events).toHaveLength(2); + + // First event: finalized pending + expect(events[0].skill).toBe('old-skill'); + expect(events[0].outcome).toBe('unknown'); + expect(events[0].session_id).toBe('old-123'); + + // Second event: new event + expect(events[1].skill).toBe('qa'); + expect(events[1].outcome).toBe('success'); + }); + + test('.pending-SESSION file is removed after finalization', () => { + setConfig('telemetry', 'anonymous'); + + const analyticsDir = path.join(tmpDir, 'analytics'); + fs.mkdirSync(analyticsDir, { recursive: true }); + const pendingPath = path.join(analyticsDir, '.pending-stale-session'); + fs.writeFileSync(pendingPath, '{"skill":"stale","ts":"2026-03-18T00:00:00Z","session_id":"stale-session","gstack_version":"v"}'); + + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id new-456`); + + expect(fs.existsSync(pendingPath)).toBe(false); + }); + + test('does not finalize own session pending marker', () => { + setConfig('telemetry', 'anonymous'); + + const analyticsDir = path.join(tmpDir, 'analytics'); + fs.mkdirSync(analyticsDir, { recursive: true }); + // Create pending for same session ID we'll use + const pendingPath = path.join(analyticsDir, '.pending-same-session'); + fs.writeFileSync(pendingPath, '{"skill":"in-flight","ts":"2026-03-18T00:00:00Z","session_id":"same-session","gstack_version":"v"}'); + + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id same-session`); + + // Should only have 1 event (the new one), not finalize own pending + const events = parseJsonl(); + expect(events).toHaveLength(1); + expect(events[0].skill).toBe('qa'); + }); + + test('tier=off still clears own session pending', () => { + setConfig('telemetry', 'off'); + + const analyticsDir = path.join(tmpDir, 'analytics'); + fs.mkdirSync(analyticsDir, { recursive: true }); + const pendingPath = path.join(analyticsDir, '.pending-off-123'); + fs.writeFileSync(pendingPath, '{"skill":"stale","ts":"2026-03-18T00:00:00Z","session_id":"off-123","gstack_version":"v"}'); + + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id off-123`); + + expect(fs.existsSync(pendingPath)).toBe(false); + // But no JSONL entries since tier=off + expect(readJsonl()).toHaveLength(0); + }); +}); + +describe('gstack-analytics', () => { + test('shows "no data" for empty JSONL', () => { + const output = run(`${BIN}/gstack-analytics`); + expect(output).toContain('no data'); + }); + + test('renders usage dashboard with events', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 120 --outcome success --session-id a-1`); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 60 --outcome success --session-id a-2`); + run(`${BIN}/gstack-telemetry-log --skill ship --duration 30 --outcome error --error-class timeout --session-id a-3`); + + const output = run(`${BIN}/gstack-analytics all`); + expect(output).toContain('/qa'); + expect(output).toContain('/ship'); + expect(output).toContain('2 runs'); + expect(output).toContain('1 runs'); + expect(output).toContain('Success rate: 66%'); + expect(output).toContain('Errors: 1'); + }); + + test('filters by time window', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 60 --outcome success --session-id t-1`); + + const output7d = run(`${BIN}/gstack-analytics 7d`); + expect(output7d).toContain('/qa'); + expect(output7d).toContain('last 7 days'); + }); +}); + +describe('gstack-telemetry-sync', () => { + test('exits silently with no endpoint configured', () => { + // Default: GSTACK_TELEMETRY_ENDPOINT is not set → exit 0 + const result = run(`${BIN}/gstack-telemetry-sync`); + expect(result).toBe(''); + }); + + test('exits silently with no JSONL file', () => { + const result = run(`${BIN}/gstack-telemetry-sync`, { GSTACK_TELEMETRY_ENDPOINT: 'http://localhost:9999' }); + expect(result).toBe(''); + }); +}); + +describe('gstack-community-dashboard', () => { + test('shows unconfigured message when no Supabase config available', () => { + // Use a fake GSTACK_DIR with no supabase/config.sh + const output = run(`${BIN}/gstack-community-dashboard`, { + GSTACK_DIR: tmpDir, + GSTACK_SUPABASE_URL: '', + GSTACK_SUPABASE_ANON_KEY: '', + }); + expect(output).toContain('Supabase not configured'); + expect(output).toContain('gstack-analytics'); + }); + + test('connects to Supabase when config exists', () => { + // Use the real GSTACK_DIR which has supabase/config.sh + const output = run(`${BIN}/gstack-community-dashboard`); + expect(output).toContain('gstack community dashboard'); + // Should not show "not configured" since config.sh exists + expect(output).not.toContain('Supabase not configured'); + }); +});