~cytrogen/gstack

6b69c46a278a583b16aa1297288351c151eaa316 — Garry Tan a month ago a468374
feat: daily update check + /gstack-upgrade skill (v0.3.4) (#42)

* feat: add daily update check script + /gstack-upgrade skill

bin/gstack-update-check: pure bash, checks VERSION against remote once/day,
outputs UPGRADE_AVAILABLE or JUST_UPGRADED. Uses ~/.gstack/ for state.

gstack-upgrade/SKILL.md: new skill with inline upgrade flow for all preambles.
Detects global-git, local-git, vendored installs. Shows What's New from CHANGELOG.

browse/test/gstack-update-check.test.ts: 10 test cases covering all branch paths.

* refactor: remove version check from find-browse, simplify to binary locator

Delete checkVersion(), readCache(), writeCache(), fetchRemoteSHA(),
resolveSkillDir(), CacheEntry interface, REPO_URL/CACHE_PATH/CACHE_TTL
constants, and META output from find-browse.ts.

Version checking is now handled by bin/gstack-update-check (previous commit).

* feat: add update check preamble to all 9 skills

Every skill now runs bin/gstack-update-check on invocation. If an upgrade
is available, reads gstack-upgrade/SKILL.md inline upgrade flow.

Also adds AskUserQuestion to 5 skills that lacked it (gstack root, browse,
qa, retro, setup-browser-cookies) and Bash to plan-eng-review.

Simplifies qa and setup-browser-cookies setup blocks (removes META parsing).

* chore: bump version and changelog (v0.3.4)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused import + add corrupt cache test

Address pre-landing review findings:
- Remove unused mkdirSync import from gstack-update-check.test.ts
- Add Path I test: corrupt cache file falls through to remote fetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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.