M CHANGELOG.md => CHANGELOG.md +22 -0
@@ 1,5 1,27 @@
# Changelog
+## 0.3.4 — 2026-03-13
+
+### Added
+- **Daily update check** — all 9 skills now check for new versions once per day via `bin/gstack-update-check` (pure bash, <5ms cached). Prompts user via AskUserQuestion with option to upgrade or defer 24h.
+- **`/gstack-upgrade` skill** — standalone upgrade command that detects install type (global-git, local-git, vendored), upgrades, and shows a "What's New" summary from CHANGELOG
+- **"Just upgraded" confirmation** — after upgrading, the next skill invocation shows "Running gstack v{new} (just updated!)" via `~/.gstack/just-upgraded-from` marker
+- **`AskUserQuestion` added to 5 skills** — gstack (root), browse, qa, retro, setup-browser-cookies now have AskUserQuestion in allowed-tools for upgrade prompts
+- **`Bash` added to plan-eng-review** — enables the update check preamble to run in plan review sessions
+- `browse/test/gstack-update-check.test.ts` — 10 test cases covering all script branch paths with `GSTACK_REMOTE_URL` env var for test isolation
+- `TODOS.md` for tracking deferred work
+
+### Changed
+- **Version check is now one system** — removed SHA-based `checkVersion()` from `browse/src/find-browse.ts` (~120 lines deleted) and `browse/test/find-browse.test.ts` (~100 lines deleted). Replaced by `bin/gstack-update-check` bash script using semver VERSION comparison with 24h cache.
+- Simplified `qa/SKILL.md` and `setup-browser-cookies/SKILL.md` setup blocks — removed old `BROWSE_OUTPUT`/`META` parsing, now use simple `find-browse` call
+- Updated `browse/bin/find-browse` shim comments to reflect simplified role (binary locator only)
+
+### Removed
+- `checkVersion()`, `readCache()`, `writeCache()`, `fetchRemoteSHA()`, `resolveSkillDir()`, `CacheEntry` interface from `browse/src/find-browse.ts`
+- `META:UPDATE_AVAILABLE` protocol from find-browse output
+- Old META-based upgrade instructions from qa and setup-browser-cookies SKILL.md files
+- Legacy `/tmp/gstack-latest-version` cache file (cleaned up by `setup` script)
+
## Unreleased — 2026-03-14
### Changed
M SKILL.md => SKILL.md +10 -0
@@ 10,11 10,21 @@ description: |
allowed-tools:
- Bash
- Read
+ - AskUserQuestion
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# gstack browse: QA Testing & Dogfooding
Persistent headless Chromium. First call auto-starts (~3s), then ~100-200ms per command.
M SKILL.md.tmpl => SKILL.md.tmpl +10 -0
@@ 10,9 10,19 @@ description: |
allowed-tools:
- Bash
- Read
+ - AskUserQuestion
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# gstack browse: QA Testing & Dogfooding
Persistent headless Chromium. First call auto-starts (~3s), then ~100-200ms per command.
A TODOS.md => TODOS.md +12 -0
@@ 0,0 1,12 @@
+# TODOS
+
+## Auto-upgrade mode (zero-prompt)
+
+**What:** Add a `GSTACK_AUTO_UPGRADE=1` env var or `~/.gstack/config` option that skips the AskUserQuestion prompt and upgrades automatically when a new version is detected.
+
+**Why:** Power users and CI environments may want zero-friction upgrades without being asked every time.
+
+**Context:** The current upgrade system (v0.3.4) always prompts via AskUserQuestion. This TODO adds an opt-in bypass. Implementation is ~10 lines in the preamble instructions: check for the env var/config before calling AskUserQuestion, and if set, go straight to the upgrade flow. Depends on the full upgrade system being stable first — wait for user feedback on the prompt-based flow before adding this.
+
+**Effort:** S (small)
+**Priority:** P3 (nice-to-have, revisit after adoption data)
M VERSION => VERSION +1 -1
@@ 1,1 1,1 @@
-0.3.3
+0.3.4
A bin/gstack-update-check => bin/gstack-update-check +88 -0
@@ 0,0 1,88 @@
+#!/usr/bin/env bash
+# gstack-update-check — daily version check for all skills.
+#
+# Output (one line, or nothing):
+# JUST_UPGRADED <old> <new> — marker found from recent upgrade
+# UPGRADE_AVAILABLE <old> <new> — remote VERSION differs from local
+# (nothing) — up to date or check skipped
+#
+# Env overrides (for testing):
+# GSTACK_DIR — override auto-detected gstack root
+# GSTACK_REMOTE_URL — override remote VERSION URL
+# GSTACK_STATE_DIR — override ~/.gstack state directory
+set -euo pipefail
+
+GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
+STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
+CACHE_FILE="$STATE_DIR/last-update-check"
+MARKER_FILE="$STATE_DIR/just-upgraded-from"
+VERSION_FILE="$GSTACK_DIR/VERSION"
+REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"
+
+# ─── Step 1: Read local version ──────────────────────────────
+LOCAL=""
+if [ -f "$VERSION_FILE" ]; then
+ LOCAL="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]')"
+fi
+if [ -z "$LOCAL" ]; then
+ exit 0 # No VERSION file → skip check
+fi
+
+# ─── Step 2: Check "just upgraded" marker ─────────────────────
+if [ -f "$MARKER_FILE" ]; then
+ OLD="$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]')"
+ rm -f "$MARKER_FILE"
+ mkdir -p "$STATE_DIR"
+ echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
+ if [ -n "$OLD" ]; then
+ echo "JUST_UPGRADED $OLD $LOCAL"
+ fi
+ exit 0
+fi
+
+# ─── Step 3: Check cache freshness (24h = 1440 min) ──────────
+if [ -f "$CACHE_FILE" ]; then
+ # Cache is fresh if modified within 1440 minutes
+ STALE=$(find "$CACHE_FILE" -mmin +1440 2>/dev/null || true)
+ if [ -z "$STALE" ]; then
+ # Cache is fresh — read it
+ CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)"
+ case "$CACHED" in
+ UP_TO_DATE*)
+ exit 0
+ ;;
+ UPGRADE_AVAILABLE*)
+ # Verify local version still matches cached old version
+ CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')"
+ if [ "$CACHED_OLD" = "$LOCAL" ]; then
+ echo "$CACHED"
+ exit 0
+ fi
+ # Local version changed (manual upgrade?) — fall through to re-check
+ ;;
+ esac
+ fi
+fi
+
+# ─── Step 4: Slow path — fetch remote version ────────────────
+mkdir -p "$STATE_DIR"
+
+REMOTE=""
+REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)"
+REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')"
+
+# Validate: must look like a version number (reject HTML error pages)
+if ! echo "$REMOTE" | grep -qE '^[0-9]+\.[0-9.]+$'; then
+ # Invalid or empty response — assume up to date
+ echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
+ exit 0
+fi
+
+if [ "$LOCAL" = "$REMOTE" ]; then
+ echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
+ exit 0
+fi
+
+# Versions differ — upgrade available
+echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE"
+echo "UPGRADE_AVAILABLE $LOCAL $REMOTE"
M browse/SKILL.md => browse/SKILL.md +10 -0
@@ 10,11 10,21 @@ description: |
allowed-tools:
- Bash
- Read
+ - AskUserQuestion
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# browse: QA Testing & Dogfooding
Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command.
M browse/SKILL.md.tmpl => browse/SKILL.md.tmpl +10 -0
@@ 10,9 10,19 @@ description: |
allowed-tools:
- Bash
- Read
+ - AskUserQuestion
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# browse: QA Testing & Dogfooding
Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command.
M browse/bin/find-browse => browse/bin/find-browse +2 -2
@@ 1,11 1,11 @@
#!/bin/bash
# Shim: delegates to compiled find-browse binary, falls back to basic discovery.
-# The compiled binary adds version checking and META signal support.
+# The compiled binary handles git root detection for workspace-local installs.
DIR="$(cd "$(dirname "$0")/.." && pwd)/dist"
if test -x "$DIR/find-browse"; then
exec "$DIR/find-browse" "$@"
fi
-# Fallback: basic discovery (no version check)
+# Fallback: basic discovery
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -n "$ROOT" ] && test -x "$ROOT/.claude/skills/gstack/browse/dist/browse"; then
echo "$ROOT/.claude/skills/gstack/browse/dist/browse"
M browse/src/find-browse.ts => browse/src/find-browse.ts +3 -128
@@ 1,28 1,14 @@
/**
- * find-browse — locate the gstack browse binary + check for updates.
+ * find-browse — locate the gstack browse binary.
*
* Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed).
- *
- * Output protocol:
- * Line 1: /path/to/binary (always present)
- * Line 2+: META:<TYPE> <json-payload> (optional, 0 or more)
- *
- * META types:
- * META:UPDATE_AVAILABLE — local binary is behind origin/main
- *
- * All version checks are best-effort: network failures, missing files, and
- * cache errors degrade gracefully to outputting only the binary path.
+ * Outputs the absolute path to the browse binary on stdout, or exits 1 if not found.
*/
import { existsSync } from 'fs';
-import { readFileSync, writeFileSync } from 'fs';
-import { join, dirname } from 'path';
+import { join } from 'path';
import { homedir } from 'os';
-const REPO_URL = 'https://github.com/garrytan/gstack.git';
-const CACHE_PATH = '/tmp/gstack-latest-version';
-const CACHE_TTL = 14400; // 4 hours in seconds
-
// ─── Binary Discovery ───────────────────────────────────────────
function getGitRoot(): string | null {
@@ 55,114 41,6 @@ export function locateBinary(): string | null {
return null;
}
-// ─── Version Check ──────────────────────────────────────────────
-
-interface CacheEntry {
- sha: string;
- timestamp: number;
-}
-
-function readCache(): CacheEntry | null {
- try {
- const content = readFileSync(CACHE_PATH, 'utf-8').trim();
- const parts = content.split(/\s+/);
- if (parts.length < 2) return null;
- const sha = parts[0];
- const timestamp = parseInt(parts[1], 10);
- if (!sha || isNaN(timestamp)) return null;
- // Validate SHA is hex
- if (!/^[0-9a-f]{40}$/i.test(sha)) return null;
- return { sha, timestamp };
- } catch {
- return null;
- }
-}
-
-function writeCache(sha: string, timestamp: number): void {
- try {
- writeFileSync(CACHE_PATH, `${sha} ${timestamp}\n`);
- } catch {
- // Cache write failure is non-fatal
- }
-}
-
-function fetchRemoteSHA(): string | null {
- try {
- const proc = Bun.spawnSync(['git', 'ls-remote', REPO_URL, 'refs/heads/main'], {
- stdout: 'pipe',
- stderr: 'pipe',
- timeout: 10_000, // 10s timeout
- });
- if (proc.exitCode !== 0) return null;
- const output = proc.stdout.toString().trim();
- const sha = output.split(/\s+/)[0];
- if (!sha || !/^[0-9a-f]{40}$/i.test(sha)) return null;
- return sha;
- } catch {
- return null;
- }
-}
-
-function resolveSkillDir(binaryPath: string): string | null {
- const home = homedir();
- const globalPrefix = join(home, '.claude', 'skills', 'gstack');
- if (binaryPath.startsWith(globalPrefix)) return globalPrefix;
-
- // Workspace-local: binary is at $ROOT/.claude/skills/gstack/browse/dist/browse
- // Skill dir is $ROOT/.claude/skills/gstack
- const parts = binaryPath.split('/.claude/skills/gstack/');
- if (parts.length === 2) return parts[0] + '/.claude/skills/gstack';
-
- return null;
-}
-
-export function checkVersion(binaryDir: string): string | null {
- // Read local version
- const versionFile = join(binaryDir, '.version');
- let localSHA: string;
- try {
- localSHA = readFileSync(versionFile, 'utf-8').trim();
- } catch {
- return null; // No .version file → skip check
- }
- if (!localSHA) return null;
-
- const now = Math.floor(Date.now() / 1000);
-
- // Check cache
- let remoteSHA: string | null = null;
- const cache = readCache();
- if (cache && (now - cache.timestamp) < CACHE_TTL) {
- remoteSHA = cache.sha;
- }
-
- // Fetch from remote if cache miss
- if (!remoteSHA) {
- remoteSHA = fetchRemoteSHA();
- if (remoteSHA) {
- writeCache(remoteSHA, now);
- }
- }
-
- if (!remoteSHA) return null; // Offline or error → skip check
-
- // Compare
- if (localSHA === remoteSHA) return null; // Up to date
-
- // Determine skill directory for update command
- const binaryPath = join(binaryDir, 'browse');
- const skillDir = resolveSkillDir(binaryPath);
- if (!skillDir) return null;
-
- const payload = JSON.stringify({
- current: localSHA.slice(0, 8),
- latest: remoteSHA.slice(0, 8),
- command: `cd ${skillDir} && git stash && git fetch origin && git reset --hard origin/main && ./setup`,
- });
-
- return `META:UPDATE_AVAILABLE ${payload}`;
-}
-
// ─── Main ───────────────────────────────────────────────────────
function main() {
@@ 173,9 51,6 @@ function main() {
}
console.log(bin);
-
- const meta = checkVersion(dirname(bin));
- if (meta) console.log(meta);
}
main();
M browse/test/find-browse.test.ts => browse/test/find-browse.test.ts +4 -124
@@ 1,130 1,10 @@
/**
- * Tests for find-browse version check logic
- *
- * Tests the checkVersion() and locateBinary() functions directly.
- * Uses temp directories with mock .version files and cache files.
+ * Tests for find-browse binary locator.
*/
-import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
-import { checkVersion, locateBinary } from '../src/find-browse';
-import { mkdtempSync, writeFileSync, rmSync, existsSync, mkdirSync } from 'fs';
-import { join } from 'path';
-import { tmpdir } from 'os';
-
-let tempDir: string;
-
-beforeEach(() => {
- tempDir = mkdtempSync(join(tmpdir(), 'find-browse-test-'));
-});
-
-afterEach(() => {
- rmSync(tempDir, { recursive: true, force: true });
- // Clean up test cache
- try { rmSync('/tmp/gstack-latest-version'); } catch {}
-});
-
-describe('checkVersion', () => {
- test('returns null when .version file is missing', () => {
- const result = checkVersion(tempDir);
- expect(result).toBeNull();
- });
-
- test('returns null when .version file is empty', () => {
- writeFileSync(join(tempDir, '.version'), '');
- const result = checkVersion(tempDir);
- expect(result).toBeNull();
- });
-
- test('returns null when .version has only whitespace', () => {
- writeFileSync(join(tempDir, '.version'), ' \n');
- const result = checkVersion(tempDir);
- expect(result).toBeNull();
- });
-
- test('returns null when local SHA matches remote (cache hit)', () => {
- const sha = 'a'.repeat(40);
- writeFileSync(join(tempDir, '.version'), sha);
- // Write cache with same SHA, recent timestamp
- const now = Math.floor(Date.now() / 1000);
- writeFileSync('/tmp/gstack-latest-version', `${sha} ${now}\n`);
-
- const result = checkVersion(tempDir);
- expect(result).toBeNull();
- });
-
- test('returns META:UPDATE_AVAILABLE when SHAs differ (cache hit)', () => {
- const localSha = 'a'.repeat(40);
- const remoteSha = 'b'.repeat(40);
- writeFileSync(join(tempDir, '.version'), localSha);
- // Create a fake browse binary path so resolveSkillDir works
- const browsePath = join(tempDir, 'browse');
- writeFileSync(browsePath, '');
- // Write cache with different SHA, recent timestamp
- const now = Math.floor(Date.now() / 1000);
- writeFileSync('/tmp/gstack-latest-version', `${remoteSha} ${now}\n`);
-
- const result = checkVersion(tempDir);
- // Result may be null if resolveSkillDir can't determine skill dir from temp path
- // That's expected — the META signal requires a known skill dir path
- if (result !== null) {
- expect(result).toStartWith('META:UPDATE_AVAILABLE');
- const jsonStr = result.replace('META:UPDATE_AVAILABLE ', '');
- const payload = JSON.parse(jsonStr);
- expect(payload.current).toBe('a'.repeat(8));
- expect(payload.latest).toBe('b'.repeat(8));
- expect(payload.command).toContain('git stash');
- expect(payload.command).toContain('git reset --hard origin/main');
- expect(payload.command).toContain('./setup');
- }
- });
-
- test('uses cached SHA when cache is fresh (< 4hr)', () => {
- const localSha = 'a'.repeat(40);
- const remoteSha = 'a'.repeat(40);
- writeFileSync(join(tempDir, '.version'), localSha);
- // Cache is 1 hour old — should still be valid
- const oneHourAgo = Math.floor(Date.now() / 1000) - 3600;
- writeFileSync('/tmp/gstack-latest-version', `${remoteSha} ${oneHourAgo}\n`);
-
- const result = checkVersion(tempDir);
- expect(result).toBeNull(); // SHAs match
- });
-
- test('treats expired cache as stale', () => {
- const localSha = 'a'.repeat(40);
- writeFileSync(join(tempDir, '.version'), localSha);
- // Cache is 5 hours old — should be stale
- const fiveHoursAgo = Math.floor(Date.now() / 1000) - 18000;
- writeFileSync('/tmp/gstack-latest-version', `${'b'.repeat(40)} ${fiveHoursAgo}\n`);
-
- // This will try git ls-remote which may fail in test env — that's OK
- // The important thing is it doesn't use the stale cache value
- const result = checkVersion(tempDir);
- // Result depends on whether git ls-remote succeeds in test environment
- // If offline, returns null (graceful degradation)
- expect(result === null || typeof result === 'string').toBe(true);
- });
-
- test('handles corrupt cache file gracefully', () => {
- const localSha = 'a'.repeat(40);
- writeFileSync(join(tempDir, '.version'), localSha);
- writeFileSync('/tmp/gstack-latest-version', 'garbage data here');
-
- // Should not throw, should treat as stale
- const result = checkVersion(tempDir);
- expect(result === null || typeof result === 'string').toBe(true);
- });
-
- test('handles cache with invalid SHA gracefully', () => {
- const localSha = 'a'.repeat(40);
- writeFileSync(join(tempDir, '.version'), localSha);
- writeFileSync('/tmp/gstack-latest-version', `not-a-sha ${Math.floor(Date.now() / 1000)}\n`);
-
- // Invalid SHA should be treated as no cache
- const result = checkVersion(tempDir);
- expect(result === null || typeof result === 'string').toBe(true);
- });
-});
+import { describe, test, expect } from 'bun:test';
+import { locateBinary } from '../src/find-browse';
+import { existsSync } from 'fs';
describe('locateBinary', () => {
test('returns null when no binary exists at known paths', () => {
A browse/test/gstack-update-check.test.ts => browse/test/gstack-update-check.test.ts +188 -0
@@ 0,0 1,188 @@
+/**
+ * Tests for bin/gstack-update-check bash script.
+ *
+ * Uses Bun.spawnSync to invoke the script with temp dirs and
+ * GSTACK_DIR / GSTACK_STATE_DIR / GSTACK_REMOTE_URL env overrides
+ * for full isolation.
+ */
+
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
+import { join } from 'path';
+import { tmpdir } from 'os';
+
+const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-update-check');
+
+let gstackDir: string;
+let stateDir: string;
+
+function run(extraEnv: Record<string, string> = {}) {
+ const result = Bun.spawnSync(['bash', SCRIPT], {
+ env: {
+ ...process.env,
+ GSTACK_DIR: gstackDir,
+ GSTACK_STATE_DIR: stateDir,
+ GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`,
+ ...extraEnv,
+ },
+ stdout: 'pipe',
+ stderr: 'pipe',
+ });
+ return {
+ exitCode: result.exitCode,
+ stdout: result.stdout.toString().trim(),
+ stderr: result.stderr.toString().trim(),
+ };
+}
+
+beforeEach(() => {
+ gstackDir = mkdtempSync(join(tmpdir(), 'gstack-upd-test-'));
+ stateDir = mkdtempSync(join(tmpdir(), 'gstack-state-test-'));
+});
+
+afterEach(() => {
+ rmSync(gstackDir, { recursive: true, force: true });
+ rmSync(stateDir, { recursive: true, force: true });
+});
+
+describe('gstack-update-check', () => {
+ // ─── Path A: No VERSION file ────────────────────────────────
+ test('exits 0 with no output when VERSION file is missing', () => {
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('');
+ });
+
+ // ─── Path B: Empty VERSION file ─────────────────────────────
+ test('exits 0 with no output when VERSION file is empty', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '');
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('');
+ });
+
+ // ─── Path C: Just-upgraded marker ───────────────────────────
+ test('outputs JUST_UPGRADED and deletes marker', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n');
+ writeFileSync(join(stateDir, 'just-upgraded-from'), '0.3.3\n');
+
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('JUST_UPGRADED 0.3.3 0.4.0');
+ // Marker should be deleted
+ expect(existsSync(join(stateDir, 'just-upgraded-from'))).toBe(false);
+ // Cache should be written
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
+ expect(cache).toContain('UP_TO_DATE');
+ });
+
+ // ─── Path D1: Fresh cache, UP_TO_DATE ───────────────────────
+ test('exits silently when cache says UP_TO_DATE and is fresh', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
+ writeFileSync(join(stateDir, 'last-update-check'), 'UP_TO_DATE 0.3.3');
+
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('');
+ });
+
+ // ─── Path D2: Fresh cache, UPGRADE_AVAILABLE ────────────────
+ test('echoes cached UPGRADE_AVAILABLE when cache is fresh', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
+
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
+ });
+
+ // ─── Path D3: Fresh cache, but local version changed ────────
+ test('re-checks when local version does not match cached old version', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n');
+ // Cache says 0.3.3 → 0.4.0 but we're already on 0.4.0
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
+ // Remote also says 0.4.0 — should be up to date
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
+
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe(''); // Up to date after re-check
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
+ expect(cache).toContain('UP_TO_DATE');
+ });
+
+ // ─── Path E: Versions match (remote fetch) ─────────────────
+ test('writes UP_TO_DATE cache when versions match', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n');
+
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('');
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
+ expect(cache).toContain('UP_TO_DATE');
+ });
+
+ // ─── Path F: Versions differ (remote fetch) ─────────────────
+ test('outputs UPGRADE_AVAILABLE when versions differ', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
+
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
+ expect(cache).toContain('UPGRADE_AVAILABLE 0.3.3 0.4.0');
+ });
+
+ // ─── Path G: Invalid remote response ────────────────────────
+ test('treats invalid remote response as up to date', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '<html>404 Not Found</html>\n');
+
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('');
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
+ expect(cache).toContain('UP_TO_DATE');
+ });
+
+ // ─── Path H: Curl fails (bad URL) ──────────────────────────
+ test('exits silently when remote URL is unreachable', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
+
+ const { exitCode, stdout } = run({
+ GSTACK_REMOTE_URL: 'file:///nonexistent/path/VERSION',
+ });
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('');
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
+ expect(cache).toContain('UP_TO_DATE');
+ });
+
+ // ─── Path I: Corrupt cache file ─────────────────────────────
+ test('falls through to remote fetch when cache is corrupt', () => {
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
+ writeFileSync(join(stateDir, 'last-update-check'), 'garbage data here');
+ // Remote says same version — should end up UP_TO_DATE
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n');
+
+ const { exitCode, stdout } = run();
+ expect(exitCode).toBe(0);
+ expect(stdout).toBe('');
+ // Cache should be overwritten with valid content
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
+ expect(cache).toContain('UP_TO_DATE');
+ });
+
+ // ─── State dir creation ─────────────────────────────────────
+ test('creates state dir if it does not exist', () => {
+ const newStateDir = join(stateDir, 'nested', 'dir');
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n');
+
+ const { exitCode } = run({ GSTACK_STATE_DIR: newStateDir });
+ expect(exitCode).toBe(0);
+ expect(existsSync(join(newStateDir, 'last-update-check'))).toBe(true);
+ });
+});
A gstack-upgrade/SKILL.md => gstack-upgrade/SKILL.md +112 -0
@@ 0,0 1,112 @@
+---
+name: gstack-upgrade
+version: 1.0.0
+description: |
+ Upgrade gstack to the latest version. Detects global vs vendored install,
+ runs the upgrade, and shows what's new.
+allowed-tools:
+ - Bash
+ - Read
+ - AskUserQuestion
+---
+
+# /gstack-upgrade
+
+Upgrade gstack to the latest version and show what's new.
+
+## Inline upgrade flow
+
+This section is referenced by all skill preambles when they detect `UPGRADE_AVAILABLE`.
+
+### Step 1: Ask the user
+
+Use AskUserQuestion:
+- Question: "gstack **v{new}** is available (you're on v{old}). Upgrade now? Takes ~10 seconds."
+- Options: ["Yes, upgrade now", "Later (ask again tomorrow)"]
+
+**If "Later":** Run `touch ~/.gstack/last-update-check` to reset the 24h timer and continue with the current skill. Do not mention the upgrade again.
+
+### Step 2: Detect install type
+
+```bash
+if [ -d "$HOME/.claude/skills/gstack/.git" ]; then
+ INSTALL_TYPE="global-git"
+ INSTALL_DIR="$HOME/.claude/skills/gstack"
+elif [ -d ".claude/skills/gstack/.git" ]; then
+ INSTALL_TYPE="local-git"
+ INSTALL_DIR=".claude/skills/gstack"
+elif [ -d ".claude/skills/gstack" ]; then
+ INSTALL_TYPE="vendored"
+ INSTALL_DIR=".claude/skills/gstack"
+elif [ -d "$HOME/.claude/skills/gstack" ]; then
+ INSTALL_TYPE="vendored-global"
+ INSTALL_DIR="$HOME/.claude/skills/gstack"
+else
+ echo "ERROR: gstack not found"
+ exit 1
+fi
+echo "Install type: $INSTALL_TYPE at $INSTALL_DIR"
+```
+
+### Step 3: Save old version
+
+```bash
+OLD_VERSION=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo "unknown")
+```
+
+### Step 4: Upgrade
+
+**For git installs** (global-git, local-git):
+```bash
+cd "$INSTALL_DIR"
+STASH_OUTPUT=$(git stash 2>&1)
+git fetch origin
+git reset --hard origin/main
+./setup
+```
+If `$STASH_OUTPUT` contains "Saved working directory", warn the user: "Note: local changes were stashed. Run `git stash pop` in the skill directory to restore them."
+
+**For vendored installs** (vendored, vendored-global):
+```bash
+PARENT=$(dirname "$INSTALL_DIR")
+TMP_DIR=$(mktemp -d)
+git clone --depth 1 https://github.com/garrytan/gstack.git "$TMP_DIR/gstack"
+mv "$INSTALL_DIR" "$INSTALL_DIR.bak"
+mv "$TMP_DIR/gstack" "$INSTALL_DIR"
+cd "$INSTALL_DIR" && ./setup
+rm -rf "$INSTALL_DIR.bak" "$TMP_DIR"
+```
+
+### Step 5: Write marker + clear cache
+
+```bash
+mkdir -p ~/.gstack
+echo "$OLD_VERSION" > ~/.gstack/just-upgraded-from
+rm -f ~/.gstack/last-update-check
+```
+
+### Step 6: Show What's New
+
+Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant.
+
+Format:
+```
+gstack v{new} — upgraded from v{old}!
+
+What's new:
+- [bullet 1]
+- [bullet 2]
+- ...
+
+Happy shipping!
+```
+
+### Step 7: Continue
+
+After showing What's New, continue with whatever skill the user originally invoked. The upgrade is done — no further action needed.
+
+---
+
+## Standalone usage
+
+When invoked directly as `/gstack-upgrade` (not from a preamble), follow Steps 2-6 above. If already on the latest version, tell the user: "You're already on the latest version (v{version})."
M plan-ceo-review/SKILL.md => plan-ceo-review/SKILL.md +9 -0
@@ 14,6 14,15 @@ allowed-tools:
- AskUserQuestion
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# Mega Plan Review Mode
## Philosophy
M plan-eng-review/SKILL.md => plan-eng-review/SKILL.md +10 -0
@@ 10,8 10,18 @@ allowed-tools:
- Grep
- Glob
- AskUserQuestion
+ - Bash
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# 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/SKILL.md => qa/SKILL.md +19 -9
@@ 11,8 11,18 @@ allowed-tools:
- Bash
- Read
- Write
+ - AskUserQuestion
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# /qa: Systematic 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.
@@ 34,18 44,18 @@ You are a QA engineer. Test web applications like a real user — click everythi
**Find the browse binary:**
```bash
-BROWSE_OUTPUT=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null)
-B=$(echo "$BROWSE_OUTPUT" | head -1)
-META=$(echo "$BROWSE_OUTPUT" | grep "^META:" || true)
-if [ -z "$B" ]; then
- echo "ERROR: browse binary not found"
- exit 1
+B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null)
+if [ -n "$B" ]; then
+ echo "READY: $B"
+else
+ echo "NEEDS_SETUP"
fi
-echo "READY: $B"
-[ -n "$META" ] && echo "$META"
```
-If you see `META:UPDATE_AVAILABLE`: tell the user an update is available, STOP and wait for approval, then run the command from the META payload and re-run the setup check.
+If `NEEDS_SETUP`:
+1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait.
+2. Run: `cd <SKILL_DIR> && ./setup`
+3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash`
**Create output directories:**
M retro/SKILL.md => retro/SKILL.md +10 -0
@@ 10,8 10,18 @@ allowed-tools:
- Read
- Write
- Glob
+ - AskUserQuestion
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# /retro — Weekly Engineering Retrospective
Generates a comprehensive engineering retrospective analyzing commit history, work patterns, and code quality metrics. Team-aware: identifies the user running the command, then analyzes every contributor with per-person praise and growth opportunities. Designed for a senior IC/CTO-level builder using Claude Code as a force multiplier.
M review/SKILL.md => review/SKILL.md +9 -0
@@ 14,6 14,15 @@ allowed-tools:
- AskUserQuestion
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# Pre-Landing PR Review
You are running the `/review` workflow. Analyze the current branch's diff against main for structural issues that tests don't catch.
M setup => setup +7 -0
@@ 85,3 85,10 @@ else
echo " browse: $BROWSE_BIN"
echo " (skipped skill symlinks — not inside .claude/skills/)"
fi
+
+# 4. First-time welcome + legacy cleanup
+if [ ! -d "$HOME/.gstack" ]; then
+ mkdir -p "$HOME/.gstack"
+ echo " Welcome! Run /gstack-upgrade anytime to stay current."
+fi
+rm -f /tmp/gstack-latest-version
M setup-browser-cookies/SKILL.md => setup-browser-cookies/SKILL.md +11 -6
@@ 8,8 8,18 @@ description: |
allowed-tools:
- Bash
- Read
+ - AskUserQuestion
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# Setup Browser Cookies
Import logged-in sessions from your real Chromium browser into the headless browse session.
@@ 26,12 36,9 @@ Import logged-in sessions from your real Chromium browser into the headless brow
### 1. Find the browse binary
```bash
-BROWSE_OUTPUT=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null)
-B=$(echo "$BROWSE_OUTPUT" | head -1)
-META=$(echo "$BROWSE_OUTPUT" | grep "^META:" || true)
+B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null)
if [ -n "$B" ]; then
echo "READY: $B"
- [ -n "$META" ] && echo "$META"
else
echo "NEEDS_SETUP"
fi
@@ 42,8 49,6 @@ If `NEEDS_SETUP`:
2. Run: `cd <SKILL_DIR> && ./setup`
3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash`
-If you see `META:UPDATE_AVAILABLE`: tell the user an update is available, STOP and wait for approval, then run the command from the META payload and re-run the setup check.
-
### 2. Open the cookie picker
```bash
M ship/SKILL.md => ship/SKILL.md +9 -0
@@ 13,6 13,15 @@ allowed-tools:
- AskUserQuestion
---
+## Update Check (run first)
+
+```bash
+_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
+[ -n "$_UPD" ] && echo "$_UPD"
+```
+
+If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
+
# Ship: Fully Automated Ship Workflow
You are running the `/ship` workflow. This is a **non-interactive, fully automated** workflow. Do NOT ask for confirmation at any step. The user said `/ship` which means DO IT. Run straight through and output the PR URL at the end.