M CHANGELOG.md => CHANGELOG.md +13 -0
@@ 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
M README.md => README.md +14 -0
@@ 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`
M SKILL.md => SKILL.md +49 -0
@@ 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`.
M VERSION => VERSION +1 -1
@@ 1,1 1,1 @@
-0.8.5
+0.8.6
A bin/gstack-analytics => bin/gstack-analytics +191 -0
@@ 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"
A => +113 -0
@@ 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"
A bin/gstack-telemetry-log => bin/gstack-telemetry-log +158 -0
@@ 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
A bin/gstack-telemetry-sync => bin/gstack-telemetry-sync +127 -0
@@ 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
M bin/gstack-update-check => bin/gstack-update-check +32 -0
@@ 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"
M browse/SKILL.md => browse/SKILL.md +49 -0
@@ 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.
M codex/SKILL.md => codex/SKILL.md +49 -0
@@ 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.
M design-consultation/SKILL.md => design-consultation/SKILL.md +49 -0
@@ 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.
M design-review/SKILL.md => design-review/SKILL.md +49 -0
@@ 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.
M document-release/SKILL.md => document-release/SKILL.md +49 -0
@@ 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.
M investigate/SKILL.md => investigate/SKILL.md +49 -0
@@ 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
M office-hours/SKILL.md => office-hours/SKILL.md +49 -0
@@ 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.
M plan-ceo-review/SKILL.md => plan-ceo-review/SKILL.md +49 -0
@@ 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.
M plan-design-review/SKILL.md => plan-design-review/SKILL.md +49 -0
@@ 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.
M plan-eng-review/SKILL.md => plan-eng-review/SKILL.md +49 -0
@@ 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.
M qa-only/SKILL.md => qa-only/SKILL.md +49 -0
@@ 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.**
M qa/SKILL.md => qa/SKILL.md +49 -0
@@ 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.
M retro/SKILL.md => retro/SKILL.md +52 -0
@@ 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/<default> --since="<window>" --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/<default> --since="<window>" --format="" --name-only | grep -E '\.(test|spec)\.' | sort -u | wc -l
```
M retro/SKILL.md.tmpl => retro/SKILL.md.tmpl +3 -0
@@ 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/<default> --since="<window>" --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/<default> --since="<window>" --format="" --name-only | grep -E '\.(test|spec)\.' | sort -u | wc -l
```
M review/SKILL.md => review/SKILL.md +49 -0
@@ 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.
M scripts/gen-skill-docs.ts => scripts/gen-skill-docs.ts +50 -1
@@ 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 {
M setup-browser-cookies/SKILL.md => setup-browser-cookies/SKILL.md +49 -0
@@ 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.
M ship/SKILL.md => ship/SKILL.md +49 -0
@@ 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.
A supabase/config.sh => supabase/config.sh +10 -0
@@ 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"
A => +59 -0
@@ 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" },
}
);
}
});
A supabase/functions/telemetry-ingest/index.ts => supabase/functions/telemetry-ingest/index.ts +135 -0
@@ 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<string, { version: string; os: string }> = 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 });
+ }
+});
A supabase/functions/update-check/index.ts => supabase/functions/update-check/index.ts +37 -0
@@ 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 });
+ }
+});
A supabase/migrations/001_telemetry.sql => supabase/migrations/001_telemetry.sql +89 -0
@@ 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;
M test/gen-skill-docs.test.ts => test/gen-skill-docs.test.ts +47 -0
@@ 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)');
+ }
+ }
+ });
+});
A test/telemetry.test.ts => test/telemetry.test.ts +278 -0
@@ 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, string> = {}): 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');
+ });
+});