diff --git a/bin/gstack-doctor b/bin/gstack-doctor new file mode 100755 index 000000000..fc7141d99 --- /dev/null +++ b/bin/gstack-doctor @@ -0,0 +1,392 @@ +#!/usr/bin/env bash +# gstack-doctor — readiness report without starting a skill. +# +# Usage: +# gstack-doctor print one row per check; spends nothing +# gstack-doctor --live also run the paid Codex model probe (one short call) +# +# Rows: install root, state root, bun, hooks, codex, codex probe, artifacts +# sync, browse bundle, claude code, session journal, guard log. Each row is +# `ok`, `warn`, `not configured` or `fail`; a row that is not ok carries a fix +# line, and every gstack helper in a fix line is an absolute path under this +# install root. Exit 1 when any row is `fail`, 2 on a usage error, else 0. +# +# Read-only. Each row reuses the owner's check instead of new probe logic: the +# install registry rows behind `./setup --status`, bin/gstack-bun-version.sh, +# bin/gstack-hook-check, bin/gstack-codex-probe's subcommands (the paid +# `probe-model` only with --live; otherwise the cached result and its age), +# the status file bin/gstack-brain-sync writes, and setup's browse build +# outputs. The sync row prints only a clamped status code and counts, never +# the status message or file paths: the message can quote a matched secret. +# Bash 3.2 plus POSIX tools; it still reports when bun or python3 is missing. + +# Heredoc delivery guard, as in bin/gstack-brain-sync: compat level 50 keeps +# heredoc bodies on the tempfile path, so a small pipe buffer cannot hang them. +BASH_COMPAT=50 + +set -u + +usage() { + sed -n '3,6p' "$0" | sed 's/^# \{0,1\}//' +} + +LIVE=0 +for _arg in "$@"; do + case "$_arg" in + --live) LIVE=1 ;; + -h|--help) usage; exit 0 ;; + *) echo "gstack-doctor: unknown argument '$_arg'" >&2; usage >&2; exit 2 ;; + esac +done + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" || { echo "gstack-doctor: cannot resolve the install root" >&2; exit 1; } +ROOT_REAL="$(cd "$ROOT" && pwd -P)" +BIN="$ROOT/bin" +SRC="$ROOT_REAL" +DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md" +CODEX_FEATURES="outside reviews in /review, /ship, /autoplan and /codex" +N_OK=0 N_WARN=0 N_NC=0 N_FAIL=0 + +row() { + printf '%-15s %-15s %s\n' "$1" "$2" "$3" + [ -n "${4:-}" ] && printf '%32sfix: %s\n' '' "$4" + case "$1" in + ok) N_OK=$((N_OK + 1)) ;; + warn) N_WARN=$((N_WARN + 1)) ;; + 'not configured') N_NC=$((N_NC + 1)) ;; + *) N_FAIL=$((N_FAIL + 1)) ;; + esac +} + +# clamp CHARSET MAX — stdin's first line reduced to CHARSET, at most MAX chars. +clamp() { head -1 | tr -cd "$1" | cut -c1-"$2"; } + +# epoch_of VALUE — epoch seconds for an epoch or an ISO-8601 UTC time; "" otherwise. +epoch_of() { + local v + v=$(printf '%s' "$1" | tr -cd '0-9TZ:.+-') + case "$v" in + '') return 0 ;; + *[!0-9]*) date -u -d "$v" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$v" +%s 2>/dev/null || true ;; + *) printf '%s' "$v" ;; + esac +} + +# json_field KEY JSON — a top-level string or number value from one-line JSON. +# A key quoted inside a string value is escaped (\"key\"), so it never matches. +json_field() { + local v + v=$(printf '%s' "$2" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"\\\\]*\)\".*/\1/p") + [ -n "$v" ] || v=$(printf '%s' "$2" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p") + printf '%s' "$v" +} + +check_state_root() { + if [ -d "$STATE_ROOT" ]; then + row ok "state root" "$STATE_ROOT (selected by $_gstack_sr_var)" + else + row warn "state root" "$STATE_ROOT (selected by $_gstack_sr_var) does not exist yet" "cd $SRC && ./setup, or run any gstack skill" + fi +} + +check_install() { + local rows host scope src dest reg cur result note real installed="" bad="" badnote="" other="" total=0 + if ! . "$BIN/gstack-install-registry.sh" 2>/dev/null; then + row fail "install root" "$BIN/gstack-install-registry.sh is missing" "reinstall gstack from a fresh checkout of https://github.com/garrytan/gstack, then run its ./setup" + return + fi + rows="$(_gstack_status_rows)" + while IFS=' ' read -r host scope src dest reg cur result note; do + [ -n "$host" ] || continue + total=$((total + 1)) + real="$(cd "$src" 2>/dev/null && pwd -P)" + if [ "$real" != "$ROOT_REAL" ] && [ "$(cd "$dest/gstack" 2>/dev/null && pwd -P)" != "$ROOT_REAL" ]; then + [ -n "$other" ] || other="$src" + continue + fi + [ -n "$real" ] && SRC="$real" + case "$result" in + current) installed="${installed:+$installed, }$host $scope current ($cur)" ;; + *) bad="${bad:+$bad, }$host $scope $result"; [ -n "$badnote" ] || badnote="$note" ;; + esac + done </dev/null; then + row fail bun "$BIN/gstack-bun-version.sh is missing" "reinstall gstack from a fresh checkout of https://github.com/garrytan/gstack" + return + fi + if ! where=$(command -v bun 2>/dev/null); then + row fail bun "not found on PATH; gstack's skills, hooks and builds need it" "install Bun $GSTACK_BUN_TESTED (https://bun.sh), then cd $SRC && ./setup" + return + fi + found=$(bun --version /dev/null | clamp 'A-Za-z0-9.+-' 40) + case "$(gstack_bun_status "$found")" in + ok) row ok bun "$found at $where (floor $GSTACK_BUN_FLOOR, tested $GSTACK_BUN_TESTED)" ;; + untested) row warn bun "$found at $where is below the tested $GSTACK_BUN_TESTED" "bun upgrade ($DOCS#bun-too-old)" ;; + too-old) row fail bun "$found at $where is below the security floor $GSTACK_BUN_FLOOR" "bun upgrade, then cd $SRC && ./setup ($DOCS#bun-too-old)" ;; + *) row warn bun "could not read the version of $where" "bun upgrade ($DOCS#bun-too-old)" ;; + esac +} + +check_hooks() { + local out rc checked missing first + out=$("$BIN/gstack-hook-check" "$SRC" &1) + rc=$? + checked=$(printf '%s\n' "$out" | grep -c '^ok ') + missing=$(printf '%s\n' "$out" | grep -c '^missing ') + case "$rc" in + 0) if [ "$missing" -gt 0 ]; then row ok hooks "$checked hook(s) parse; $missing not installed"; else row ok hooks "$checked hook(s) parse"; fi ;; + 1) first=$(printf '%s\n' "$out" | grep '^fail ' | head -1 | cut -c6-300) + row fail hooks "$first" "repair the file (git -C $SRC status shows half-applied edits), then cd $SRC && ./setup ($DOCS#setup-hook-does-not-parse)" ;; + 3) row warn hooks "TypeScript hook entries not checked: bun not found" "install Bun (see the bun row), then rerun $BIN/gstack-doctor" ;; + *) row fail hooks "the hook check could not run (exit $rc)" "cd $SRC && ./setup" ;; + esac +} + +# check_codex — CODEX_MODE from the free checks; sets CODEX_READY=1 when the +# probe row should follow. +check_codex() { + local reviews probe="$BIN/gstack-codex-probe" sel rc model src + CODEX_READY=0 + reviews=$("$BIN/gstack-config" get codex_reviews 2>/dev/null || echo enabled) + if [ "$reviews" = disabled ]; then + row "not configured" codex "codex_reviews disabled; $CODEX_FEATURES skip Codex" "$BIN/gstack-config set codex_reviews enabled" + return + fi + if ! command -v codex >/dev/null 2>&1; then + row "not configured" codex "Codex CLI not installed; $CODEX_FEATURES have no Codex coverage" "npm install -g @openai/codex, then codex login" + return + fi + sel=$("$probe" select-model exec &1) + rc=$? + model=$(printf '%s\n' "$sel" | sed -n 's/^CODEX_SEL: //p' | clamp 'A-Za-z0-9._:/-' 80) + if [ "$rc" -ne 0 ] || [ -z "$model" ]; then + [ "$rc" -ne 0 ] && rc="exited $rc" || rc="printed no CODEX_SEL line" + row fail codex "CODEX_MODE helper_unavailable: select-model $rc (the probe could not locate its scripts, or the requested model id is invalid)" "run $probe select-model exec to see the cause; reinstall with cd $SRC && ./setup" + return + fi + src=$(printf '%s\n' "$sel" | sed -n 's/^CODEX_MODEL: .*source: \(.*\))$/\1/p' | clamp 'A-Za-z0-9 ._:/=-' 80) + gstack_codex_version + if ! "$probe" check-auth /dev/null 2>&1; then + row warn codex "CODEX_MODE not_authed; model $model (source: ${src:-unknown})" "codex login, or export CODEX_API_KEY" + return + fi + if ! "$probe" check-sandbox /dev/null 2>&1; then + row warn codex "CODEX_MODE sandbox_unavailable; no paid call runs" "run $probe check-sandbox for the reason and fix" + return + fi + if "$probe" check-version &1 | grep -q '^WARN'; then + row warn codex "${_gcx_version:-version unknown} has a known problem; model $model (source: ${src:-unknown})" "run $probe check-version for the fix" + else + row ok codex "${_gcx_version:-version unknown}; self-locate ok; model $model (source: ${src:-unknown})" + fi + CODEX_READY=1 +} + +check_codex_probe() { + local out rc state probe="$BIN/gstack-codex-probe" model_fix="set GSTACK_CODEX_MODEL= or model in config.toml, then $BIN/gstack-doctor --live" + if [ "$LIVE" -eq 1 ]; then + out=$("$probe" probe-model exec &1) + rc=$? + state=$(printf '%s\n' "$out" | sed -n 's/^CODEX_PROBE_STATE: //p' | clamp 'a-z_' 40) + case "$rc" in + 0) case "$state" in + inconclusive|rate_limited) row warn "codex probe" "live probe: CODEX_MODE unverified ($state)" "rerun $BIN/gstack-doctor --live later" ;; + *) row ok "codex probe" "live probe: ok" ;; + esac ;; + 1) row warn "codex probe" "live probe: model_unusable; $CODEX_FEATURES skip Codex" "$model_fix" ;; + 2) row fail "codex probe" "live probe: broken_install (the Codex CLI cannot execute)" "npm install -g @openai/codex" ;; + 4) row warn "codex probe" "live probe: quota_exhausted (the account's Codex usage limit)" "wait for the reset time Codex names: $probe probe-model exec" ;; + *) row warn "codex probe" "live probe: exit $rc${state:+ ($state)}" "rerun $BIN/gstack-doctor --live" ;; + esac + return + fi + gstack_codex_cache_read "$STATE_ROOT" + local age + age=$(gstack_codex_age_text "$_gcx_age") + case "$_gcx_probe" in + '') row warn "codex probe" "not probed; Codex readiness is unverified until a skill or --live runs it" "$BIN/gstack-doctor --live (one short paid Codex call)" ;; + MODEL_OK) row ok "codex probe" "cached MODEL_OK, $age (not re-run; --live probes now)" ;; + MODEL_UNUSABLE) row warn "codex probe" "cached MODEL_UNUSABLE, $age; $CODEX_FEATURES skip Codex" "$model_fix" ;; + MODEL_QUOTA_EXHAUSTED) row warn "codex probe" "cached MODEL_QUOTA_EXHAUSTED, $age (the account's Codex usage limit)" "wait for the reset, then $BIN/gstack-doctor --live" ;; + *) row warn "codex probe" "cached entry unrecognized, $age" "$BIN/gstack-doctor --live" ;; + esac +} + +check_sync() { + local mode file json status held drainable drain push queue=0 now f detail fix="$BIN/gstack-brain-sync --status" + mode=$("$BIN/gstack-config" get artifacts_sync_mode 2>/dev/null || echo off) + if [ ! -d "$STATE_ROOT/.git" ]; then + row "not configured" "artifacts sync" "off; cross-machine artifacts sync is unavailable" "$BIN/gstack-artifacts-init" + return + fi + if [ "$mode" = off ]; then + row "not configured" "artifacts sync" "artifacts_sync_mode off; cross-machine artifacts sync is unavailable" "$BIN/gstack-config set artifacts_sync_mode artifacts-only" + return + fi + for f in "$STATE_ROOT/.brain-queue.d"/*.json; do [ -f "$f" ] && queue=$((queue + 1)); done + for f in "$STATE_ROOT/.brain-queue.jsonl" "$STATE_ROOT/.brain-queue.jsonl.migrating"; do + [ -f "$f" ] && queue=$((queue + $(wc -l < "$f" | tr -d ' '))) + done + file="$STATE_ROOT/.brain-sync-status.json" + if [ ! -f "$file" ]; then + if [ "$queue" -gt 0 ]; then row warn "artifacts sync" "no drain recorded while $queue record(s) are queued" "$fix" + else row ok "artifacts sync" "mode $mode; no drain yet, queue empty"; fi + return + fi + json=$(head -c 16384 "$file" 2>/dev/null | tr '\n\r' ' ') + status=$(json_field status "$json") + case "$status" in ok|idle|held|blocked|push_failed|error) ;; *) status=unknown ;; esac + held=$(json_field held_count "$json" | tr -cd '0-9') + drainable=$(json_field drainable "$json" | tr -cd '0-9') + drain=$(epoch_of "$(json_field last_drain_at "$json")") + push=$(epoch_of "$(json_field last_push_at "$json")") + [ -n "$push" ] || push=$(epoch_of "$(head -1 "$STATE_ROOT/.brain-last-push" 2>/dev/null)") + now=$(date +%s) + case "$status" in + held) row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix"; return ;; + blocked) row warn "artifacts sync" "status blocked (a re-scan still flags staged content)" "$fix"; return ;; + push_failed|error|unknown) row warn "artifacts sync" "status $status" "$fix"; return ;; + esac + if [ "${drainable:-0}" -gt 0 ] && { [ -z "$push" ] || [ $((now - push)) -gt 86400 ]; }; then + row warn "artifacts sync" "status $status; no push for 24h while $drainable record(s) wait" "$fix" + elif [ "$queue" -gt 0 ] && [ -n "$drain" ] && [ $((now - drain)) -gt 86400 ]; then + row warn "artifacts sync" "status $status; no drain for 24h while $queue record(s) are queued" "$fix" + else + detail="status $status (mode $mode; queue $queue)" + row ok "artifacts sync" "$detail" + fi +} + +check_browse() { + local exe="" missing="" f + case "${OSTYPE:-}" in msys*|cygwin*|win32*) exe=".exe" ;; esac + if [ ! -x "$ROOT/browse/dist/browse$exe" ]; then + row warn "browse bundle" "not built; the gstack browser fallback for /browse, /qa, /qa-only, /design-review, /make-pdf, /pair-agent is unavailable (the Aside browser is unaffected)" "cd $SRC && ./setup" + return + fi + for f in server-node.mjs .build-complete; do [ -e "$ROOT/browse/dist/$f" ] || missing="${missing:+$missing, }$f"; done + if [ -n "$missing" ]; then + row warn "browse bundle" "incomplete build (missing browse/dist: $missing)" "cd $SRC && ./setup" + else + row ok "browse bundle" "browse/dist built" + fi +} + +check_claude() { + local override cmd raw ver rc a + local args=() + override="${GSTACK_CLAUDE_BIN:-${CLAUDE_BIN:-}}" + override="${override#\"}"; override="${override%\"}" + if [ -n "$override" ]; then + case "$override" in /*|[A-Za-z]:*) cmd="$override" ;; *) cmd=$(command -v "$override" 2>/dev/null) ;; esac + raw="${GSTACK_CLAUDE_BIN_ARGS:-${CLAUDE_BIN_ARGS:-}}" + case "$raw" in + '') ;; + '['*) raw="${raw#[}"; raw="${raw%]}" + while IFS= read -r a; do + a=$(printf '%s' "$a" | sed 's/^[[:space:]]*"\{0,1\}//; s/"\{0,1\}[[:space:]]*$//') + [ -n "$a" ] && args+=("$a") + done </dev/null) + fi + if [ -z "${cmd:-}" ]; then + row "not configured" "claude code" "Claude Code CLI not found; the Claude Code host and Claude outside reviews are unavailable" "install Claude Code (https://code.claude.com), or set GSTACK_CLAUDE_BIN" + return + fi + ver=$("$cmd" ${args[@]+"${args[@]}"} --version /dev/null) + rc=$? + ver=$(printf '%s\n' "$ver" | clamp 'A-Za-z0-9 ._()+-' 60) + if [ "$rc" -ne 0 ]; then + row warn "claude code" "$cmd --version failed (exit $rc)" "reinstall Claude Code (https://code.claude.com)" + else + row ok "claude code" "${ver:-version unknown} at $cmd" + fi +} + +check_journal() { + local cwd dir f size largest=0 count=0 + case "${OSTYPE:-}" in msys*|cygwin*) cwd=$(pwd -W 2>/dev/null || pwd -P) ;; *) cwd=$(pwd -P) ;; esac + dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')" + for f in "$dir"/*.jsonl; do + [ -f "$f" ] || continue + count=$((count + 1)) + size=$(wc -c < "$f" | tr -d ' ') + [ "$size" -gt "$largest" ] && largest="$size" + done + if [ "$count" -eq 0 ]; then + row ok "session journal" "no session journals for this project" + return + fi + row ok "session journal" "largest of $count: $(awk -v b="$largest" 'BEGIN { if (b >= 1048576) printf "%.1f MiB", b / 1048576; else if (b >= 1024) printf "%.1f KiB", b / 1024; else printf "%d B", b }')" +} + +check_guard_log() { + local log="$STATE_ROOT/analytics/autoplan-guard.jsonl" summary + if [ ! -f "$log" ]; then + row ok "guard log" "no guard decisions logged" + return + fi + summary=$(awk ' + !/^\{/ || !match($0, /"schema"[[:space:]]*:[[:space:]]*1[,}[:space:]]/) { next } + match($0, /"decision"[[:space:]]*:[[:space:]]*"(allow|deny)"/) { + n++; if (substr($0, RSTART, RLENGTH) ~ /deny/) d++ + } + match($0, /"reason_code"[[:space:]]*:[[:space:]]*"[a-z0-9_]+"/) { + c = substr($0, RSTART, RLENGTH); sub(/^[^:]*:[[:space:]]*"/, "", c); sub(/"$/, "", c) + codes[k++ % 5] = c + } + END { + out = (n + 0) " decisions (" (d + 0) " denied)" + if (k > 0) { + out = out "; last codes:"; sep = " " + for (i = (k > 5 ? k - 5 : 0); i < k; i++) { out = out sep codes[i % 5]; sep = ", " } + } + print out + }' "$log" 2>/dev/null) + row ok "guard log" "${summary:-0 decisions (0 denied)}" +} + +if ! . "$BIN/gstack-state-root.sh" 2>/dev/null; then + row fail "state root" "$BIN/gstack-state-root.sh is missing" "reinstall gstack from a fresh checkout of https://github.com/garrytan/gstack, then run its ./setup" + exit 1 +fi +gstack_state_root_select +STATE_ROOT="$_gstack_sr_root" +GSTACK_STATE_ROOT="$STATE_ROOT" +. "$BIN/gstack-codex-status.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-codex-status.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; } + +echo "gstack doctor $(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)" +check_install +check_state_root +check_bun +check_hooks +check_codex +[ "$CODEX_READY" -eq 1 ] && check_codex_probe +check_sync +check_browse +check_claude +check_journal +check_guard_log +echo "$N_OK ok, $N_WARN warn, $N_NC not configured, $N_FAIL fail" +[ "$N_FAIL" -eq 0 ] diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts new file mode 100644 index 000000000..127a3d615 --- /dev/null +++ b/test/gstack-doctor.test.ts @@ -0,0 +1,359 @@ +/** + * gstack-doctor: one row per readiness check, each ok / warn / not configured + * / fail with a fix line when not ok; only fail makes the exit non-zero; fix + * lines name helpers by absolute install-root path; the paid Codex model probe + * runs only with --live. Also pins the `./setup --status` Codex row and its + * trailing doctor path. Every run uses a fixture install root (copies of the + * real helpers plus a stub gstack-codex-probe), a fixture state root, stub + * bun/codex/claude on PATH and an explicit HOME. Scripts run through explicit + * bash argv, so the file is in the curated Windows lane. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const REPO = path.resolve(import.meta.dir, '..'); +const COPIED = [ + 'setup', 'VERSION', + 'bin/gstack-doctor', 'bin/gstack-codex-status.sh', 'bin/gstack-state-root.sh', 'bin/gstack-install-registry.sh', + 'bin/gstack-render-claude.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-hook-check', 'bin/gstack-config', +]; +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); + +const write = (file: string, text: string, mode = 0o644) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, { mode }); +}; +const script = (file: string, lines: string[]) => write(file, ['#!/usr/bin/env bash', ...lines].join('\n') + '\n', 0o755); + +/** The fixture's PATH entries minus every directory holding a real bun, codex or claude. */ +function hostPath(base: string): string { + const names = ['bun', 'codex', 'claude']; + const exts = process.platform === 'win32' ? ['', '.exe', '.cmd', '.ps1'] : ['']; + return (process.env.PATH ?? process.env.Path ?? '').split(path.delimiter).filter(Boolean).map((dir, i) => { + if (!names.some(n => exts.some(e => fs.existsSync(path.join(dir, n + e))))) return dir; + const shim = path.join(base, `host-${i}`); + fs.mkdirSync(shim, { recursive: true }); + for (const name of fs.readdirSync(dir)) { + if (names.some(n => exts.some(e => name === n + e))) continue; + try { fs.symlinkSync(path.join(dir, name), path.join(shim, name)); } catch { /* unreadable entry */ } + } + return shim; + }).join(path.delimiter); +} + +interface Fixture { base: string; root: string; state: string; home: string; work: string; stub: string; probeLog: string; env: Record } + +function makeFixture(): Fixture { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-doctor-'))); + bases.push(base); + const root = path.join(base, 'gstack'); + const state = path.join(base, 'state'); + const home = path.join(base, 'home'); + const work = path.join(base, 'work'); + const stub = path.join(base, 'stub'); + const probeLog = path.join(base, 'probe-calls'); + for (const dir of [state, home, work, stub]) fs.mkdirSync(dir, { recursive: true }); + for (const rel of COPIED) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.copyFileSync(path.join(REPO, rel), path.join(root, rel)); + fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode); + } + script(path.join(root, 'bin/gstack-codex-probe'), [ + 'echo "$*" >> "$DOCTOR_PROBE_LOG"', + 'case "$1" in', + ' select-model) [ "${STUB_SELECT_FAIL:-}" = 1 ] && { echo "gstack-codex-probe: cannot find its scripts dir" >&2; exit 1; }', + ' echo "CODEX_SEL: gpt-stub-5"; echo "CODEX_SEL_KIND: $2"; echo "CODEX_MODEL: gpt-stub-5 ($2; source: codex config.toml)" >&2 ;;', + ' check-auth) [ "${STUB_AUTH_FAIL:-}" = 1 ] && { echo AUTH_FAILED; exit 1; }; echo AUTH_OK ;;', + ' show-sandbox) echo "CODEX_SANDBOX: read-only" ;;', + ' check-sandbox|check-version) ;;', + ' probe-model) echo "CODEX_PROBE_STATE: ${STUB_PROBE_STATE:-ok}"; exit "${STUB_PROBE_EXIT:-0}" ;;', + ' *) exit 64 ;;', + 'esac', + ]); + const hooks = spawnSync('bash', [path.join(root, 'bin/gstack-hook-check'), '--list', root], { encoding: 'utf8', timeout: 20_000 }).stdout.trim().split('\n'); + for (const hook of hooks) write(path.join(root, hook), '#!/bin/sh\nexit 0\n', 0o755); + for (const rel of ['browse/dist/browse', 'browse/dist/server-node.mjs', 'browse/dist/.build-complete']) write(path.join(root, rel), 'built\n', 0o755); + const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim(); + write(path.join(state, 'installs.tsv'), + ['claude', 'global', '-', path.join(home, '.claude/skills'), root, root, version, 'false', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n'); + script(path.join(stub, 'bun'), ['[ "$1" = --version ] && { echo "${STUB_BUN_VERSION:-1.4.2}"; exit 0; }', 'exit 0']); + script(path.join(stub, 'codex'), ['[ "$1" = --version ] && { echo "codex-cli 0.130.0"; exit 0; }', 'echo "unexpected codex call: $*" >> "$DOCTOR_PROBE_LOG"', 'exit 9']); + script(path.join(stub, 'claude'), ['[ "$1" = --version ] && { echo "2.1.292 (Claude Code)"; exit 0; }', 'exit 9']); + const env: Record = { + PATH: [stub, hostPath(base)].join(path.delimiter), + HOME: home, GSTACK_STATE_ROOT: state, TMPDIR: base, DOCTOR_PROBE_LOG: probeLog, + }; + if (process.env.SYSTEMROOT) env.SYSTEMROOT = process.env.SYSTEMROOT; + return { base, root, state, home, work, stub, probeLog, env }; +} + +function doctor(f: Fixture, args: string[] = [], extra: Record = {}) { + const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-doctor'), ...args], { cwd: f.work, encoding: 'utf8', env: { ...f.env, ...extra }, timeout: 60_000 }); + const out = `${r.stdout}${r.stderr}`; + return { status: r.status, out, row: (name: string) => rowOf(r.stdout, name) }; +} + +/** The named row and its fix line: { state, detail, fix }. */ +function rowOf(stdout: string, name: string) { + const lines = stdout.split('\n'); + const i = lines.findIndex(l => l.slice(16, 32).trim() === name); + if (i < 0) throw new Error(`no row "${name}" in:\n${stdout}`); + const fix = lines[i + 1]?.startsWith(' '.repeat(32) + 'fix: ') ? lines[i + 1].slice(37) : ''; + return { state: lines[i].slice(0, 16).trim(), detail: lines[i].slice(32), fix }; +} + +/** How bash spells a directory (Git Bash on Windows prints /c/...). */ +const bashPath = (dir: string) => spawnSync('bash', ['-c', 'cd "$1" && pwd', '_', dir], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); +const probeCalls = (f: Fixture) => fs.existsSync(f.probeLog) ? fs.readFileSync(f.probeLog, 'utf8') : ''; +const iso = (secondsAgo: number) => new Date(Date.now() - secondsAgo * 1000).toISOString().replace(/\.\d+Z$/, 'Z'); +const syncOn = (f: Fixture, status: Record) => { + fs.mkdirSync(path.join(f.state, '.git'), { recursive: true }); + write(path.join(f.state, 'config.yaml'), 'artifacts_sync_mode: artifacts-only\n'); + write(path.join(f.state, '.brain-sync-status.json'), JSON.stringify(status).replace(/":/g, '": ').replace(/,"/g, ', "') + '\n'); +}; + +describe('gstack-doctor', () => { + test('a healthy fixture: every core row ok, exit 0, and no paid probe', () => { + const f = makeFixture(); + const r = doctor(f); + expect(r.status, r.out).toBe(0); + for (const name of ['install root', 'state root', 'bun', 'hooks', 'browse bundle']) expect(r.row(name).state, `${name}\n${r.out}`).toBe('ok'); + expect(r.row('install root').detail).toContain('claude global current'); + expect(r.row('state root').detail).toContain('GSTACK_STATE_ROOT'); + expect(r.row('bun').detail).toContain('1.4.2'); + expect(r.row('codex').state).toBe('ok'); + expect(r.row('codex').detail).toContain('gpt-stub-5 (source: codex config.toml)'); + expect(r.row('codex probe').state).toBe('warn'); + expect(r.row('codex probe').detail).toContain('not probed'); + expect(r.row('codex probe').fix).toBe(`${bashPath(f.root)}/bin/gstack-doctor --live (one short paid Codex call)`); + expect(r.row('artifacts sync').state).toBe('not configured'); + expect(r.row('claude code').detail).toContain('2.1.292'); + expect(r.row('guard log').detail).toBe('no guard decisions logged'); + expect(probeCalls(f)).not.toContain('probe-model'); + expect(probeCalls(f)).toContain('select-model exec'); + expect(r.out).toMatch(/\d+ ok, \d+ warn, \d+ not configured, 0 fail/); + }); + + test('each DX-8 state appears, and only fail makes the exit non-zero', () => { + const f = makeFixture(); + const warn = doctor(f, [], { STUB_BUN_VERSION: '1.4.0' }); + expect(warn.row('bun').state).toBe('warn'); + expect(warn.row('bun').fix).toContain('bun upgrade'); + expect(warn.status, warn.out).toBe(0); + + write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n'); + const off = doctor(f); + expect(off.row('codex').state).toBe('not configured'); + expect(off.row('codex').detail).toContain('outside reviews in /review, /ship, /autoplan and /codex'); + expect(off.row('codex').fix).toBe(`${bashPath(f.root)}/bin/gstack-config set codex_reviews enabled`); + expect(off.status, off.out).toBe(0); + expect(probeCalls(f)).not.toContain('probe-model'); + + const fail = doctor(f, [], { STUB_BUN_VERSION: '1.3.2' }); + expect(fail.row('bun').state).toBe('fail'); + expect(fail.row('bun').fix).toContain('bun upgrade'); + expect(fail.status, fail.out).toBe(1); + expect(fail.out).toMatch(/, 1 fail/); + }); + + test('Codex: absent CLI is not configured, unauthenticated warns, a failed self-locate fails', () => { + const f = makeFixture(); + fs.rmSync(path.join(f.stub, 'codex')); + const absent = doctor(f); + expect(absent.row('codex').state).toBe('not configured'); + expect(absent.row('codex').detail).toContain('Codex CLI not installed'); + expect(absent.row('codex').fix).toContain('npm install -g @openai/codex'); + expect(absent.status).toBe(0); + + const g = makeFixture(); + const unauthed = doctor(g, [], { STUB_AUTH_FAIL: '1' }); + expect(unauthed.row('codex').state).toBe('warn'); + expect(unauthed.row('codex').detail).toContain('not_authed'); + expect(unauthed.row('codex').fix).toContain('codex login'); + expect(unauthed.status).toBe(0); + + const broken = doctor(g, [], { STUB_SELECT_FAIL: '1' }); + expect(broken.row('codex').state).toBe('fail'); + expect(broken.row('codex').detail).toContain('helper_unavailable'); + expect(broken.row('codex').fix).toContain(`${bashPath(g.root)}/bin/gstack-codex-probe select-model exec`); + expect(broken.status).toBe(1); + }); + + test('the cached probe is reported with its age; only --live runs probe-model', () => { + const f = makeFixture(); + const now = Math.floor(Date.now() / 1000); + write(path.join(f.state, '.codex-model-probe'), `MODEL_OK ${now - 7300} 123456789\n`); + const cached = doctor(f); + expect(cached.row('codex probe').state).toBe('ok'); + expect(cached.row('codex probe').detail).toBe('cached MODEL_OK, 2h ago (not re-run; --live probes now)'); + write(path.join(f.state, '.codex-model-probe'), `MODEL_QUOTA_EXHAUSTED ${now - 125} 123456789\nCodex says: try again at 5pm sk-should-not-print\n`); + const quota = doctor(f); + expect(quota.row('codex probe').state).toBe('warn'); + expect(quota.row('codex probe').detail).toContain('cached MODEL_QUOTA_EXHAUSTED, 2m ago'); + expect(quota.out).not.toContain('sk-should-not-print'); + write(path.join(f.state, '.codex-model-probe'), `$(rm -rf ~) ${now} x\n`); + expect(doctor(f).row('codex probe').detail).toContain('unrecognized'); + expect(probeCalls(f)).not.toContain('probe-model'); + + const live = doctor(f, ['--live']); + expect(probeCalls(f).match(/probe-model exec/g)?.length).toBe(1); + expect(live.row('codex probe').state).toBe('ok'); + expect(live.row('codex probe').detail).toContain('live probe: ok'); + const unusable = doctor(f, ['--live'], { STUB_PROBE_EXIT: '1', STUB_PROBE_STATE: 'model_unusable' }); + expect(unusable.row('codex probe').state).toBe('warn'); + expect(unusable.row('codex probe').detail).toContain('model_unusable'); + const brokenInstall = doctor(f, ['--live'], { STUB_PROBE_EXIT: '2', STUB_PROBE_STATE: 'broken_install' }); + expect(brokenInstall.row('codex probe').state).toBe('fail'); + expect(brokenInstall.status).toBe(1); + }); + + test('a hook shim that does not parse fails the hooks row', () => { + const f = makeFixture(); + write(path.join(f.root, 'autoplan/bin/phase-publication-hook'), '#!/bin/sh\nif then\n', 0o755); + const r = doctor(f); + expect(r.row('hooks').state).toBe('fail'); + expect(r.row('hooks').detail).toContain('autoplan/bin/phase-publication-hook'); + expect(r.row('hooks').fix).toContain('./setup'); + expect(r.status).toBe(1); + }); + + test('artifacts sync: a hostile status clamps to unknown and is never echoed', () => { + const f = makeFixture(); + syncOn(f, { status: 'AKIAIOSFODNN7EXAMPLE leaked', message: 'blocked: AKIAIOSFODNN7EXAMPLE in /secret/path.md', ts: iso(10) }); + const r = doctor(f); + expect(r.row('artifacts sync').state).toBe('warn'); + expect(r.row('artifacts sync').detail).toBe('status unknown'); + expect(r.row('artifacts sync').fix).toBe(`${bashPath(f.root)}/bin/gstack-brain-sync --status`); + expect(r.out).not.toContain('AKIA'); + expect(r.out).not.toContain('/secret/path.md'); + expect(r.out).not.toContain('.brain-sync-status.json'); + }); + + test('artifacts sync: held, ok, stale push and stale drain', () => { + const f = makeFixture(); + syncOn(f, { status: 'held', message: 'held 2 file(s): sk-ant-secret', held_count: 2, held: [{ path: 'projects/p/notes.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }, { path: 'projects/p/b.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); + const held = doctor(f); + expect(held.row('artifacts sync').state).toBe('warn'); + expect(held.row('artifacts sync').detail).toBe('status held (2 file(s) held by the secret scanner)'); + expect(held.out).not.toContain('sk-ant'); + + syncOn(f, { status: 'ok', message: 'pushed 3 file(s)', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); + expect(doctor(f).row('artifacts sync').state).toBe('ok'); + + syncOn(f, { status: 'ok', message: 'x', drainable: 4, last_drain_at: iso(60), last_push_at: iso(90_000) }); + const stalePush = doctor(f).row('artifacts sync'); + expect(stalePush.state).toBe('warn'); + expect(stalePush.detail).toContain('no push for 24h while 4 record(s) wait'); + + write(path.join(f.state, '.brain-queue.d/1-1-a.json'), '{}\n'); + syncOn(f, { status: 'idle', message: 'x', drainable: 0, last_drain_at: iso(200_000), last_push_at: iso(60) }); + const staleDrain = doctor(f).row('artifacts sync'); + expect(staleDrain.state).toBe('warn'); + expect(staleDrain.detail).toContain('no drain for 24h while 1 record(s) are queued'); + + syncOn(f, { status: 'ok', message: 'pushed' }); + expect(doctor(f).row('artifacts sync').state).toBe('ok'); + }); + + test('triage rows: Claude Code absent, largest journal, last five schema-1 guard codes', () => { + const f = makeFixture(); + fs.rmSync(path.join(f.stub, 'claude')); + expect(doctor(f).row('claude code').state).toBe('not configured'); + expect(doctor(f).row('session journal').detail).toBe('no session journals for this project'); + + const cwd = process.platform === 'win32' + ? spawnSync('bash', ['-c', 'pwd -W'], { cwd: f.work, encoding: 'utf8', timeout: 10_000 }).stdout.trim() + : f.work; + const project = path.join(f.home, '.claude/projects', cwd.replace(/[^A-Za-z0-9]/g, '-')); + write(path.join(project, 'a.jsonl'), 'x'.repeat(1000)); + write(path.join(project, 'b.jsonl'), 'x'.repeat(3 * 1024 * 1024)); + const journal = doctor(f).row('session journal'); + expect(journal.state).toBe('ok'); + expect(journal.detail).toBe('largest of 2: 3.0 MiB'); + + const line = (schema: unknown, code: string | null, decision = 'deny') => + JSON.stringify({ schema, ts: '2026-10-07T00:00:00Z', decision, ...(code ? { reason_code: code } : {}), path: 'journal', cc_version: '2.1.292', disposition: 'hard' }); + write(path.join(f.state, 'analytics/autoplan-guard.jsonl'), [ + line(1, 'code_one'), line(2, 'future_schema'), line(1, 'code_two'), line(1, null, 'allow'), 'not json', + line(1, 'code_three'), line(1, 'code_four'), line('1', 'string_schema'), line(1, 'code_five'), line(1, 'code_six'), + ].join('\n') + '\n'); + const guard = doctor(f).row('guard log'); + expect(guard.state).toBe('ok'); + expect(guard.detail).toBe('7 decisions (6 denied); last codes: code_two, code_three, code_four, code_five, code_six'); + }); + + test('every fix line names gstack helpers by absolute install-root path', () => { + const f = makeFixture(); + write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n'); + fs.rmSync(path.join(f.root, 'browse/dist/browse')); + const other = path.join(f.base, 'other'); + write(path.join(f.state, 'installs.tsv'), ['codex', 'global', '-', path.join(f.home, '.codex/skills'), other, other, '1', 'false', 'committed', 'x'].join('\t') + '\n'); + const r = doctor(f, [], { STUB_BUN_VERSION: '1.3.2' }); + const fixes = r.out.split('\n').filter(l => l.trimStart().startsWith('fix: ')); + expect(fixes.length).toBeGreaterThanOrEqual(5); + expect(r.row('install root').fix).toContain(`${other}/bin/gstack-doctor`); + const bin = `${bashPath(f.root)}/bin/`; + for (const fix of fixes) { + const scrubbed = fix.split(bin).join('/').split(`${other}/bin/`).join('/').split(bashPath(f.base)).join(''); + for (const m of scrubbed.matchAll(/gstack-[a-z]/g)) expect(/<(BIN|OTHER)>\/$/.test(scrubbed.slice(0, m.index)), fix).toBe(true); + } + expect(r.row('browse bundle').state).toBe('warn'); + expect(r.row('browse bundle').detail).toContain('/browse, /qa, /qa-only, /design-review, /make-pdf, /pair-agent'); + }); + + test('usage errors exit 2', () => { + const f = makeFixture(); + expect(doctor(f, ['--bogus']).status).toBe(2); + const help = doctor(f, ['--help']); + expect(help.status).toBe(0); + expect(help.out).toContain('--live'); + }); +}); + +describe('./setup --status Codex row', () => { + const status = (f: Fixture) => spawnSync('bash', [path.join(f.root, 'setup'), '--status'], { cwd: f.work, encoding: 'utf8', env: f.env, timeout: 60_000 }); + + test('reports install, version, codex_reviews and the cached probe, then ends with the doctor path', () => { + const f = makeFixture(); + write(path.join(f.state, '.codex-model-probe'), `MODEL_UNUSABLE ${Math.floor(Date.now() / 1000) - 600} 1\n`); + const r = status(f); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain('Codex: installed (codex-cli 0.130.0); codex_reviews=enabled; model probe: MODEL_UNUSABLE 10m ago'); + expect(r.stdout.trimEnd().split('\n').at(-1)).toBe(`Readiness check: ${bashPath(f.root)}/bin/gstack-doctor`); + expect(probeCalls(f)).toBe(''); + }); + + test('without Codex the row says so, and nothing is written', () => { + const f = makeFixture(); + fs.rmSync(path.join(f.stub, 'codex')); + write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n'); + const before = fs.readdirSync(f.state).sort(); + const r = status(f); + expect(r.stdout).toContain('Codex: not installed (codex_reviews=disabled;'); + expect(fs.readdirSync(f.state).sort()).toEqual(before); + }); +}); + +describe('bug-report template and README (DX-2, DX-12)', () => { + const template = fs.readFileSync(path.join(REPO, '.github/ISSUE_TEMPLATE/bug_report.md'), 'utf8'); + + test('the template asks for the doctor output with the Claude Code path and a lookup for other hosts', () => { + expect(template).toContain('~/.claude/skills/gstack/bin/gstack-doctor'); + expect(template).toContain('./setup --status'); + expect(template).toContain('/bin/gstack-paths --get GSTACK_STATE_ROOT'); + }); + + test('the template names no bare gstack- command', () => { + for (const m of template.matchAll(/gstack-[a-z]/g)) expect(template.slice(0, m.index).endsWith('/bin/'), template.slice(m.index! - 40, m.index! + 20)).toBe(true); + }); + + test('README troubleshooting points at the doctor by absolute path', () => { + const readme = fs.readFileSync(path.join(REPO, 'README.md'), 'utf8'); + const section = readme.slice(readme.indexOf('## Troubleshooting')); + expect(section).toContain('`~/.claude/skills/gstack/bin/gstack-doctor`'); + }); +});