From 0cf71f93a265d7f624977a217ee12f365c51b6d8 Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
Date: Tue, 16 Jun 2026 21:39:24 +1000
Subject: [PATCH] feat: upgrade-command

---
 README.md               |   1 +
 devloop                 | 173 ++++++++++++++++++++++++++++++++++++++++
 scripts/devloop_test.sh | 169 ++++++++++++++++++++++++++++++++++++++-
 3 files changed, 342 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 9474f9e..decc350 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ Uninstall with `./scripts/uninstall.sh` (`--dry-run` to preview).
 | `devloop spec "..."` | Have an agent interview you and write a spec |
 | `devloop <spec.md>` | Run a spec |
 | `devloop --create-pr <spec.md>` | Run a spec and maintain a draft PR (requires `gh`) |
+| `devloop upgrade` | Install the latest released Devloop |
 | `devloop continue` | Resume a tracked run |
 | `devloop status` | Show run status |
 | `devloop clean` | Remove run artifacts |
diff --git a/devloop b/devloop
index 3a79a23..5002664 100755
--- a/devloop
+++ b/devloop
@@ -35,6 +35,7 @@ UI_DIM_COLOR="244"
 UI_BORDER_COLOR="141"
 UI_BACK=false
 UI_NOTICE=""
+DEVLOOP_UPGRADE_PROMPTED=false
 
 EVENT_IDS=()
 EVENT_TITLES=()
@@ -87,6 +88,12 @@ main() {
     return 0
   fi
 
+  if [ "${1:-}" = "upgrade" ]; then
+    shift
+    upgrade_command "$@"
+    return $?
+  fi
+
   if [ "${1:-}" = "spec" ]; then
     shift
     spec_command "$@"
@@ -95,6 +102,7 @@ main() {
 
   if [ "${1:-}" = "menu" ]; then
     shift
+    if [ "$#" -eq 0 ]; then maybe_prompt_upgrade; fi
     interactive_menu "$@"
     return $?
   fi
@@ -135,6 +143,7 @@ main() {
 
   if [ "$#" -eq 0 ] || has_arg "-h" "$@" || has_arg "--help" "$@"; then
     if [ "$#" -eq 0 ] && [ "$USE_TUI" = true ]; then
+      maybe_prompt_upgrade
       interactive_menu
       return $?
     fi
@@ -172,6 +181,7 @@ Usage:
 
 Common commands:
   devloop doctor
+  devloop upgrade
   devloop reports
   devloop status
   devloop clean
@@ -210,6 +220,7 @@ welcome_tui() {
   printf '  devloop [options] <spec.md> [max=5]\n\n'
   gum style --foreground "$UI_ACCENT_COLOR" --bold "Common commands"
   printf '  %-42s %s\n' "devloop doctor" "check required tools"
+  printf '  %-42s %s\n' "devloop upgrade" "install the latest release"
   printf '  %-42s %s\n' "devloop reports" "open previous run reports"
   printf '  %-42s %s\n' "devloop status" "summarize tracked runs"
   printf '  %-42s %s\n' "devloop clean" "show safe cleanup candidates"
@@ -276,6 +287,168 @@ version_args_only() {
   [ "$saw_version" = true ]
 }
 
+devloop_normalize_version() {
+  local version="$1"
+  local semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
+  version="${version#v}"
+  if ! [[ "$version" =~ $semver_re ]]; then
+    printf 'invalid version: %s\n' "$1" >&2
+    return 1
+  fi
+  printf '%s\n' "$version"
+}
+
+devloop_version_gt() {
+  local left right left_main right_main left_pre="" right_pre=""
+  local left_parts right_parts left_ids right_ids
+  local i left_value right_value left_id right_id left_numeric right_numeric
+  left="$(devloop_normalize_version "$1")" || return 1
+  right="$(devloop_normalize_version "$2")" || return 1
+  left="${left%%+*}"
+  right="${right%%+*}"
+  left_main="${left%%-*}"
+  right_main="${right%%-*}"
+  if [ "$left" != "$left_main" ]; then left_pre="${left#*-}"; fi
+  if [ "$right" != "$right_main" ]; then right_pre="${right#*-}"; fi
+
+  IFS=. read -r -a left_parts <<< "$left_main"
+  IFS=. read -r -a right_parts <<< "$right_main"
+  i=0
+  while [ "$i" -lt 3 ]; do
+    left_value="${left_parts[$i]}"
+    right_value="${right_parts[$i]}"
+    if [ "$((10#$left_value))" -gt "$((10#$right_value))" ]; then return 0; fi
+    if [ "$((10#$left_value))" -lt "$((10#$right_value))" ]; then return 1; fi
+    i=$((i + 1))
+  done
+
+  if [ -z "$left_pre" ] && [ -n "$right_pre" ]; then return 0; fi
+  if [ -n "$left_pre" ] && [ -z "$right_pre" ]; then return 1; fi
+  if [ -z "$left_pre" ] && [ -z "$right_pre" ]; then return 1; fi
+
+  IFS=. read -r -a left_ids <<< "$left_pre"
+  IFS=. read -r -a right_ids <<< "$right_pre"
+  i=0
+  while [ "$i" -lt "${#left_ids[@]}" ] || [ "$i" -lt "${#right_ids[@]}" ]; do
+    if [ "$i" -ge "${#left_ids[@]}" ]; then return 1; fi
+    if [ "$i" -ge "${#right_ids[@]}" ]; then return 0; fi
+    left_id="${left_ids[$i]}"
+    right_id="${right_ids[$i]}"
+    left_numeric=false
+    right_numeric=false
+    if [[ "$left_id" =~ ^[0-9]+$ ]]; then left_numeric=true; fi
+    if [[ "$right_id" =~ ^[0-9]+$ ]]; then right_numeric=true; fi
+    if [ "$left_numeric" = true ] && [ "$right_numeric" = true ]; then
+      if [ "$((10#$left_id))" -gt "$((10#$right_id))" ]; then return 0; fi
+      if [ "$((10#$left_id))" -lt "$((10#$right_id))" ]; then return 1; fi
+    elif [ "$left_numeric" = true ]; then
+      return 1
+    elif [ "$right_numeric" = true ]; then
+      return 0
+    else
+      if [[ "$left_id" > "$right_id" ]]; then return 0; fi
+      if [[ "$left_id" < "$right_id" ]]; then return 1; fi
+    fi
+    i=$((i + 1))
+  done
+  return 1
+}
+
+devloop_resolve_latest_version() {
+  local github_repo github_api_url response source_path version
+  github_repo="${DEVLOOP_GITHUB_REPO:-satyaborg/devloop}"
+  github_api_url="${DEVLOOP_GITHUB_API_URL:-https://api.github.com/repos/$github_repo/releases/latest}"
+  case "$github_api_url" in
+    file://*)
+      source_path="${github_api_url#file://}"
+      if [ ! -f "$source_path" ]; then
+        printf 'missing latest release fixture: %s\n' "$source_path" >&2
+        return 1
+      fi
+      response="$(<"$source_path")"
+      ;;
+    *)
+      if ! command -v curl >/dev/null 2>&1; then
+        printf 'missing curl; cannot resolve latest Devloop release\n' >&2
+        return 1
+      fi
+      response="$(curl -fsSL "$github_api_url")" || {
+        printf 'failed to resolve latest Devloop release\n' >&2
+        return 1
+      }
+      ;;
+  esac
+  version="$(printf '%s\n' "$response" | sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v?([^"]+)".*/\1/p' | head -n 1)"
+  if [ -z "$version" ]; then
+    printf 'latest release response did not include tag_name\n' >&2
+    return 1
+  fi
+  devloop_normalize_version "$version"
+}
+
+devloop_upgrade_install() {
+  local latest="$1"
+  local current="$2"
+  local installer="$ROOT_DIR/scripts/install.remote.sh"
+  if [ ! -f "$installer" ]; then
+    printf 'error: remote installer not found: %s\n' "$installer" >&2
+    return 1
+  fi
+  printf 'upgrading devloop %s -> %s\n' "$current" "$latest"
+  bash "$installer" --yes --version "$latest"
+}
+
+upgrade_command() {
+  local resolved latest current
+  if [ "$#" -gt 0 ]; then
+    case "$1" in
+      -h|--help)
+        printf '%s\n' "usage: devloop upgrade"
+        return 0
+        ;;
+      *)
+        printf '%s\n' "usage: devloop upgrade" >&2
+        return 2
+        ;;
+    esac
+  fi
+  current="$(devloop_normalize_version "$DEVLOOP_VERSION" 2>/dev/null || printf '%s\n' "$DEVLOOP_VERSION")"
+  if ! resolved="$(devloop_resolve_latest_version 2>&1)"; then
+    printf '%s\n' "error: could not resolve latest Devloop release" >&2
+    if [ -n "$resolved" ]; then printf '%s\n' "$resolved" >&2; fi
+    return 1
+  fi
+  latest="$resolved"
+  if ! devloop_version_gt "$latest" "$current"; then
+    printf 'devloop %s is already latest\n' "$current"
+    return 0
+  fi
+  devloop_upgrade_install "$latest" "$current"
+}
+
+devloop_prompt_tty_ready() {
+  [ -t 0 ] && [ -t 1 ]
+}
+
+maybe_prompt_upgrade() {
+  local resolved latest current
+  if [ "$DEVLOOP_UPGRADE_PROMPTED" = true ]; then return 0; fi
+  DEVLOOP_UPGRADE_PROMPTED=true
+  devloop_prompt_tty_ready || return 0
+  current="$(devloop_normalize_version "$DEVLOOP_VERSION" 2>/dev/null || printf '%s\n' "$DEVLOOP_VERSION")"
+  resolved="$(devloop_resolve_latest_version 2>/dev/null)" || return 0
+  latest="$resolved"
+  devloop_version_gt "$latest" "$current" || return 0
+  printf 'upgrade available: devloop %s -> %s\n' "$current" "$latest" >&2
+  if ui_confirm "Upgrade Devloop to $latest now?"; then
+    if ! devloop_upgrade_install "$latest" "$current"; then
+      UI_NOTICE="upgrade failed; continuing without upgrade"
+    fi
+  else
+    UI_NOTICE="upgrade skipped"
+  fi
+}
+
 trim_string() {
   printf '%s\n' "$1" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//'
 }
diff --git a/scripts/devloop_test.sh b/scripts/devloop_test.sh
index 8f8f71f..a289c16 100755
--- a/scripts/devloop_test.sh
+++ b/scripts/devloop_test.sh
@@ -56,6 +56,7 @@ contains "$help" "devloop doctor" "help"
 contains "$help" "devloop reports" "help"
 contains "$help" "devloop status" "help"
 contains "$help" "devloop clean" "help"
+contains "$help" "devloop upgrade" "help"
 contains "$help" "--create-pr" "help"
 contains "$help" "draft PR during the loop" "help"
 contains "$help" "--no-shell" "help"
@@ -79,6 +80,7 @@ contains "$readme_text" "curl -fsSL https://devloop.sh/install | bash" "README r
 contains "$readme_text" "git clone https://github.com/satyaborg/devloop.git" "README source install"
 contains "$readme_text" "cd devloop" "README source install"
 contains "$readme_text" "./scripts/install.sh" "README source install"
+contains "$readme_text" "\`devloop upgrade\`" "README command table"
 ok "README install docs"
 
 skill_path="$("$REPO_ROOT/devloop" spec --skill-path)"
@@ -279,6 +281,7 @@ make_remote_release() {
   mkdir -p "$fixture/devloop-$version/scripts" "$release_dir"
   cp "$REPO_ROOT/devloop" "$fixture/devloop-$version/devloop"
   cp "$SCRIPTS_DIR/skill_helpers.sh" "$fixture/devloop-$version/scripts/skill_helpers.sh"
+  cp "$REMOTE_INSTALLER" "$fixture/devloop-$version/scripts/install.remote.sh"
   cp -R "$REPO_ROOT/skills" "$fixture/devloop-$version/skills"
   printf '%s\n' "$version" > "$fixture/devloop-$version/VERSION"
   tar -C "$fixture" -czf "$archive" "devloop-$version"
@@ -334,7 +337,8 @@ USE_TUI="$old_use_tui"
 gum() { return 0; }
 old_use_tui="$USE_TUI"
 USE_TUI=true
-welcome_tui >/dev/null
+tui_help="$(welcome_tui)"
+contains "$tui_help" "devloop upgrade" "TUI help"
 USE_TUI="$old_use_tui"
 unset -f gum
 
@@ -419,6 +423,17 @@ equals "$(agent_choice_value "Claude Code")" "claude" "agent_choice_value"
 equals "$(parse_bool yes)" "true" "parse_bool true"
 equals "$(parse_bool 0)" "false" "parse_bool false"
 if parse_bool maybe >/dev/null 2>&1; then fail "parse_bool accepted invalid value"; fi
+equals "$(devloop_normalize_version v1.2.3)" "1.2.3" "devloop version normalize"
+equals "$(devloop_normalize_version 1.2.3-alpha.1+build.7)" "1.2.3-alpha.1+build.7" "devloop version normalize prerelease"
+if devloop_normalize_version 01.2.3 >/dev/null 2>&1; then fail "devloop version accepted leading zero"; fi
+devloop_version_gt 1.2.4 1.2.3 || fail "devloop version comparison rejected newer patch"
+devloop_version_gt 1.3.0 1.2.9 || fail "devloop version comparison rejected newer minor"
+devloop_version_gt 2.0.0 1.9.9 || fail "devloop version comparison rejected newer major"
+devloop_version_gt 1.2.3 1.2.3-alpha.1 || fail "devloop version comparison rejected release over prerelease"
+devloop_version_gt 1.2.3-alpha.2 1.2.3-alpha.1 || fail "devloop version comparison rejected prerelease identifier"
+if devloop_version_gt 1.2.3 1.2.3; then fail "devloop version comparison accepted equal version"; fi
+if devloop_version_gt 1.2.3-alpha.1 1.2.3; then fail "devloop version comparison accepted prerelease over release"; fi
+if devloop_prompt_tty_ready; then fail "upgrade prompt tty check accepted non-tty test shell"; fi
 
 frontmatter_text=$'---\ntype: fix!\nslug: "Chat Retry"\nbreaking: true\nempty: null\n---\n# Title'
 equals "$(frontmatter_value type "$frontmatter_text")" "fix!" "frontmatter type"
@@ -621,6 +636,27 @@ if ! menu_default_output="$(
   interactive_menu
 )"; then fail "menu default choice failed"; fi
 equals "$menu_default_output" "create" "menu starts with create spec"
+if ! menu_dispatch_output="$(
+  maybe_prompt_upgrade() { printf '%s\n' "prompt"; }
+  interactive_menu() { printf '%s\n' "menu"; }
+  USE_TUI=true
+  main menu
+)"; then fail "menu dispatch upgrade prompt failed"; fi
+equals "$menu_dispatch_output" $'prompt\nmenu' "menu dispatch prompts before menu"
+if ! no_arg_dispatch_output="$(
+  maybe_prompt_upgrade() { printf '%s\n' "prompt"; }
+  interactive_menu() { printf '%s\n' "menu"; }
+  USE_TUI=true
+  main
+)"; then fail "no-arg dispatch upgrade prompt failed"; fi
+equals "$no_arg_dispatch_output" $'prompt\nmenu' "no-arg dispatch prompts before menu"
+if ! explicit_work_output="$(
+  maybe_prompt_upgrade() { printf '%s\n' "prompt"; }
+  run_command() { printf 'run'; }
+  USE_TUI=true
+  main "$criteria_file"
+)"; then fail "explicit work dispatch failed"; fi
+equals "$explicit_work_output" "run" "explicit work skips upgrade prompt"
 if ! create_spec_output="$(
   ui_choose() { printf '%s\n' "Codex"; }
   ui_input() { printf '%s\n' "unexpected input"; return 1; }
@@ -834,6 +870,137 @@ for tool in gum fzf codex claude; do
   chmod +x "$remote_tool_bin/$tool"
 done
 remote_path="$remote_tool_bin:/usr/bin:/bin:/usr/sbin:/sbin"
+
+upgrade_root="$work/upgrade-root"
+upgrade_bin="$work/upgrade-bin"
+upgrade_home="$work/upgrade-home"
+if ! upgrade_output="$(
+  HOME="$upgrade_home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$upgrade_root" DEVLOOP_BIN_DIR="$upgrade_bin" \
+    main upgrade 2>&1
+)"; then
+  printf '%s\n' "$upgrade_output" >&2
+  fail "devloop upgrade failed"
+fi
+contains "$upgrade_output" "upgrading devloop $version -> $remote_version" "devloop upgrade"
+contains "$upgrade_output" "verified checksum" "devloop upgrade checksum"
+contains "$upgrade_output" "devloop $remote_version installed" "devloop upgrade install"
+[[ -L "$upgrade_bin/devloop" ]] || fail "devloop upgrade did not create symlink"
+equals "$(readlink "$upgrade_bin/devloop")" "$upgrade_root/$remote_version/devloop" "devloop upgrade symlink target"
+equals "$("$upgrade_bin/devloop" --version)" "devloop $remote_version" "devloop upgrade installed version"
+[[ -f "$upgrade_home/.agents/skills/devloop-review/.devloop-checksum" ]] || fail "devloop upgrade did not install Codex skills"
+[[ -f "$upgrade_home/.claude/skills/devloop-review/.devloop-checksum" ]] || fail "devloop upgrade did not install Claude skills"
+ok "devloop upgrade installs newer release"
+
+current_api_file="$work/current-release.json"
+printf '{"tag_name":"v%s"}\n' "$version" > "$current_api_file"
+current_upgrade_root="$work/current-upgrade-root"
+current_upgrade_bin="$work/current-upgrade-bin"
+if ! current_upgrade_output="$(
+  HOME="$work/current-upgrade-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$current_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$current_upgrade_root" DEVLOOP_BIN_DIR="$current_upgrade_bin" \
+    main upgrade 2>&1
+)"; then
+  printf '%s\n' "$current_upgrade_output" >&2
+  fail "devloop upgrade current version failed"
+fi
+contains "$current_upgrade_output" "devloop $version is already latest" "devloop upgrade current"
+[[ ! -e "$current_upgrade_bin/devloop" ]] || fail "devloop upgrade reinstalled already-current version"
+ok "devloop upgrade already current"
+
+if resolver_failure_output="$(
+  HOME="$work/resolver-failure-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$work/missing-latest.json" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$work/resolver-failure-root" DEVLOOP_BIN_DIR="$work/resolver-failure-bin" \
+    main upgrade 2>&1
+)"; then
+  printf '%s\n' "$resolver_failure_output" >&2
+  fail "devloop upgrade accepted missing latest version"
+fi
+contains "$resolver_failure_output" "could not resolve latest Devloop release" "devloop upgrade resolver failure"
+[[ ! -e "$work/resolver-failure-bin/devloop" ]] || fail "resolver failure created devloop symlink"
+ok "devloop upgrade resolver failure"
+
+prompt_accept_root="$work/prompt-accept-root"
+prompt_accept_bin="$work/prompt-accept-bin"
+prompt_accept_home="$work/prompt-accept-home"
+if ! prompt_accept_output="$(
+  devloop_prompt_tty_ready() { return 0; }
+  ui_confirm() { return 0; }
+  interactive_menu() { printf '%s\n' "menu after prompt"; }
+  DEVLOOP_UPGRADE_PROMPTED=false
+  USE_TUI=true
+  HOME="$prompt_accept_home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$prompt_accept_root" DEVLOOP_BIN_DIR="$prompt_accept_bin" \
+    main menu 2>&1
+)"; then
+  printf '%s\n' "$prompt_accept_output" >&2
+  fail "automatic upgrade prompt accept failed"
+fi
+contains "$prompt_accept_output" "upgrade available: devloop $version -> $remote_version" "automatic upgrade prompt accept"
+contains "$prompt_accept_output" "menu after prompt" "automatic upgrade prompt accept menu"
+equals "$(readlink "$prompt_accept_bin/devloop")" "$prompt_accept_root/$remote_version/devloop" "automatic upgrade prompt accept symlink"
+ok "automatic upgrade prompt accept"
+
+prompt_decline_root="$work/prompt-decline-root"
+prompt_decline_bin="$work/prompt-decline-bin"
+mkdir -p "$prompt_decline_bin"
+ln -s "$REPO_ROOT/devloop" "$prompt_decline_bin/devloop"
+prompt_decline_target="$(readlink "$prompt_decline_bin/devloop")"
+if ! prompt_decline_output="$(
+  devloop_prompt_tty_ready() { return 0; }
+  ui_confirm() { return 1; }
+  interactive_menu() { printf '%s\n' "menu after decline"; }
+  DEVLOOP_UPGRADE_PROMPTED=false
+  USE_TUI=true
+  HOME="$work/prompt-decline-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$prompt_decline_root" DEVLOOP_BIN_DIR="$prompt_decline_bin" \
+    main menu 2>&1
+)"; then
+  printf '%s\n' "$prompt_decline_output" >&2
+  fail "automatic upgrade prompt decline failed"
+fi
+contains "$prompt_decline_output" "upgrade available: devloop $version -> $remote_version" "automatic upgrade prompt decline"
+contains "$prompt_decline_output" "menu after decline" "automatic upgrade prompt decline menu"
+equals "$(readlink "$prompt_decline_bin/devloop")" "$prompt_decline_target" "automatic upgrade prompt decline symlink"
+[[ ! -e "$prompt_decline_root/$remote_version" ]] || fail "automatic upgrade prompt decline installed release"
+ok "automatic upgrade prompt decline"
+
+prompt_skip_root="$work/prompt-skip-root"
+prompt_skip_bin="$work/prompt-skip-bin"
+if ! prompt_skip_output="$(
+  DEVLOOP_UPGRADE_PROMPTED=false
+  HOME="$work/prompt-skip-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$prompt_skip_root" DEVLOOP_BIN_DIR="$prompt_skip_bin" \
+    maybe_prompt_upgrade 2>&1
+)"; then
+  printf '%s\n' "$prompt_skip_output" >&2
+  fail "automatic upgrade prompt non-tty skip failed"
+fi
+equals "$prompt_skip_output" "" "automatic upgrade prompt non-tty skip"
+[[ ! -e "$prompt_skip_bin/devloop" ]] || fail "automatic upgrade prompt non-tty skip installed release"
+
+if ! prompt_current_output="$(
+  devloop_prompt_tty_ready() { return 0; }
+  ui_confirm() { printf '%s\n' "unexpected prompt"; return 1; }
+  DEVLOOP_UPGRADE_PROMPTED=false
+  HOME="$work/prompt-current-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$current_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$work/prompt-current-root" DEVLOOP_BIN_DIR="$work/prompt-current-bin" \
+    maybe_prompt_upgrade 2>&1
+)"; then
+  printf '%s\n' "$prompt_current_output" >&2
+  fail "automatic upgrade prompt current skip failed"
+fi
+equals "$prompt_current_output" "" "automatic upgrade prompt current skip"
+
+if ! prompt_failure_output="$(
+  devloop_prompt_tty_ready() { return 0; }
+  ui_confirm() { printf '%s\n' "unexpected prompt"; return 1; }
+  interactive_menu() { printf '%s\n' "menu after failed check"; }
+  DEVLOOP_UPGRADE_PROMPTED=false
+  USE_TUI=true
+  HOME="$work/prompt-failure-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$work/missing-prompt-latest.json" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$work/prompt-failure-root" DEVLOOP_BIN_DIR="$work/prompt-failure-bin" \
+    main menu 2>&1
+)"; then
+  printf '%s\n' "$prompt_failure_output" >&2
+  fail "automatic upgrade prompt resolver skip failed"
+fi
+contains "$prompt_failure_output" "menu after failed check" "automatic upgrade prompt resolver skip"
+not_contains "$prompt_failure_output" "unexpected prompt" "automatic upgrade prompt resolver skip"
+ok "automatic upgrade prompt skip paths"
+
 remote_home="$work/remote-home"
 remote_install_output="$(
   HOME="$remote_home" PATH="$remote_path" bash "$REMOTE_INSTALLER" \
