Files
msd-core/get-shit-done/workflows/update.md
Tom Boucher 6f2520786d feat(#498): single Package Identity seam for /gsd:update + fix runtime undefined-name bug (#499)
* feat(#498): generated package-identity seam derived from package.json

Introduce a single source for GSD's published-package coordinates:
scripts/generate-package-identity.cjs (pure deriveIdentity + formatManualInstall
+ render) emits the generated get-shit-done/bin/lib/package-identity.cjs with
values baked from package.json at build time. Baking is required because the
installed tree carries only a synthetic {"type":"commonjs"} package.json, so a
runtime require('package.json').name resolves to undefined (#378). Reconciles

Wired into npm run build; a parity test fails CI if the committed file drifts
from package.json.

Refs #498

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

* fix(#498): repoint update worker + check-latest-version at the seam

- check-latest-version.cjs sources PACKAGE_NAME from the package-identity seam
  instead of a re-typed literal (single source; #2992's constant guarantee is
  preserved since the seam bakes from package.json).
- gsd-check-update-worker.js no longer does require('../package.json').name
  (resolved to undefined in the installed tree → background update check
  silently broken, #378). It now delegates the latest-version lookup to
  checkLatestVersion(), collapsing the duplicated npm-view call onto the single
  deterministic adapter and inheriting its typed {ok,version,reason} surface.
- Move the PR #3102 Windows shell-gate contract test onto execNpm (where the
  spawn now lives) and assert the worker no longer spawns npm directly.
- Rewrite the #378 contract: worker must NOT use require(package.json).name and
  must delegate; check-latest-version PACKAGE_NAME is single-sourced from the seam.

Fixes #378-class runtime breakage. Refs #498

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

* chore(#498): changeset for package-identity seam + update-check fix

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

* feat(#498): drift-guard lint — value-check GSD coordinate literals against the seam

scripts/lint-package-identity-drift.cjs scans the runtime/code surface
(bin/, hooks/, scripts/, get-shit-done/) and asserts every GSD package name
and GitHub repo slug literal equals the Package Identity seam's current value.
Passes today; fails the moment a repoint isn't propagated (rename package.json,
regenerate the seam, and stale literals are reported until updated). This is
the second adapter that makes the seam real and a repoint mechanically safe.

Enforced via tests/issue-498-identity-drift-lint.test.cjs (scanRepo === [])
under npm test; also exposed as `npm run check:identity-drift`.

Refs #498

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

* feat(#498): update-context projection — port update.md resolution to a tested seam

Add get-shit-done/bin/lib/update-context.cjs: a pure, injected-fs port of
update.md's ~280-line get_installed_version bash. resolveUpdateContext()
reproduces the full precedence cascade (preferred fast-path -> local probe ->
global probe via env overrides then $HOME -> LOCAL-if-distinct -> scope
cascade -> UNKNOWN) and returns the 4-field contract { installedVersion,
scope, runtime, gsdDir }. The fs is injected so every branch is finally
testable without a live multi-runtime install.

Expose it as `gsd-tools update-context [--config-dir <d>] [--runtime <r>] --json`.
Purely additive — update.md is unchanged in this commit; the workflow swap
follows separately.

Refs #498

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

* feat(#498): swap update.md resolution to the update-context projection

Replace ~280 lines of inline runtime/scope/config-dir bash in update.md's
get_installed_version step with a call to `gsd-tools update-context --json`
(60 lines: derive PREFERRED_* from execution_context, resolve gsd-tools.cjs,
parse the 4-field JSON). Behavior is unchanged — the projection reproduces the
same cascade — but the logic is now tested in update-context.cjs instead of
untestable bash-in-markdown.

Relocate the #3608 antigravity-first-class contract onto the projection
(RUNTIME_DIRS order, inferPreferredRuntime, envRuntimeDirs) plus a behavioral
test; keep the execution_context path-classification assertion on update.md.
Re-point install.test's custom-config-dir assertion (kilo.jsonc/KILO_CONFIG)
to update-context.cjs where that detection now lives.

Full root suite: 2022 pass / 0 fail.

Refs #498

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

* docs(#498): record Update Context Module in CONTEXT.md

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

* fix(#498): CI — avoid bare gsd-tools in update.md; register new CLI modules

- update.md update-context invocation: resolve the PATH gsd-tools shim into a
  variable and call "$GSD_TOOLS" (never a bare `gsd-tools` command) — satisfies
  the #2851 workflow-bare-gsd-tools guard.
- Register package-identity.cjs and update-context.cjs in docs/INVENTORY.md
  (CLI Modules 76 -> 78 + rows) and regenerate docs/INVENTORY-MANIFEST.json,
  fixing inventory-counts and inventory-manifest-sync.

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

* test(#498): make update-context + parity tests OS-agnostic (Windows CI)

Two Windows-only test failures, both test-portability (production code is fine —
the real-fs CLI integration test passed on Windows):

- update-context resolver tests + bug-3608 behavioral test used POSIX path-string
  keys in their fake fs, but the resolver builds lookups via path.join/resolve
  (backslash + drive letter on Windows) → keys never matched → everything
  resolved to UNKNOWN/claude. Normalize fake-fs keys and gsdDir comparisons
  through path.resolve so they match on both platforms.
- package-identity parity test compared render() (LF) to the committed file,
  which Windows git checks out as CRLF (no .gitattributes eol rule). Normalize
  line endings before comparing, matching the repo convention
  (autonomous-decomposition, bug-3707).

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

* fix(#498): update.md backup must use GSD_DIR (adversarial-review finding)

The get_installed_version rewrite emits GSD_DIR but dropped the probe-loop
variables LOCAL_DIR/GLOBAL_DIR. The backup_custom_files step still read those,
so RUNTIME_DIR went empty for every LOCAL/GLOBAL install and detect-custom-files
was skipped — and since the update then runs a clean install that wipes managed
dirs (commands/gsd, get-shit-done), user-added files could be deleted without
the intended backup.

Set RUNTIME_DIR="$GSD_DIR" directly (the resolved config dir; empty for
UNKNOWN scope, which still skips the backup). Add a structural regression
(tests/issue-498-update-backup-runtime-dir.test.cjs).

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

* test(#503): re-point Antigravity .agent detection at the #498 projection

#499 moves the runtime/scope detection cascade out of update.md inline bash
into get-shit-done/bin/lib/update-context.cjs. The #503 regression test asserted
on the inline RUNTIME_DIRS array, which no longer exists, so it would fail
against the projected update.md even though the .agent guarantee is preserved.

Rewrite it to verify the surviving surfaces:
 - behavioral: resolveUpdateContext resolves a LOCAL ./.agent install to the
   antigravity runtime (the original root cause, now covered by adding
   ['antigravity', '.agent'] to the projection RUNTIME_DIRS table)
 - update.md prose classifier still maps /.agent/ -> antigravity
 - the post-update cache-clear for-dir loop still includes .agent

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

* fix(#498): finish de-hardcoding consumers + close adversarial-review parity gaps

Restore the consumer de-hardcoding that is the point of the seam, and close the
parity gaps an adversarial review (codex) found in the update-context projection.

De-hardcode the repo slug + install command in the changeset tooling — #516
only single-sourced the package NAME, leaving 'open-gsd/get-shit-done-redux'
hardcoded in scripts/changeset/cli.cjs and github-release-notes.cjs. Route both
through the seam's repoSlug/packageName so a rename is a regenerate, not a hand
edit. The drift-lint real scan now reports zero divergent coordinate literals.

Projection parity vs the old inline bash, as ONE consistent rule
(trustedVersionAt) applied on every path:
 - expand a leading ~/ in preferredConfigDir before the fast path (the bash ran
   expand_home first; a custom --config-dir ~/foo otherwise fell to UNKNOWN)
 - trust a version only when BOTH VERSION and the update.md marker exist — fast
   path AND LOCAL/GLOBAL cascade; a partial dir falls to 0.0.0 keeping scope
 - apply the same same-path dedup to the 0.0.0 fallback so a partial install
   probed from cwd===home is not misdetected as LOCAL

Adds regression tests for tilde expansion, VERSION-only (cascade + fast path),
and the cwd===home partial-install dedup.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:53:44 -04:00

18 KiB

Check for GSD updates via npm, display changelog for versions between installed and latest, obtain user confirmation, and execute clean installation with cache clearing.

<required_reading> Read all files referenced by the invoking prompt's execution_context before starting. </required_reading>

Detect the installed GSD version, scope, runtime, and config dir.

First, derive PREFERRED_CONFIG_DIR and PREFERRED_RUNTIME from the invoking prompt's execution_context path — this is the one input only the workflow knows:

  • If the path contains /get-shit-done/workflows/update.md, strip that suffix and store the remainder as PREFERRED_CONFIG_DIR.
  • Infer PREFERRED_RUNTIME from the path: /.codex/ -> codex; /.gemini/antigravity-ide/, /.gemini/antigravity-cli/, /.gemini/antigravity/, /.agent/ -> antigravity (.agent is the local Antigravity install dir; see bin/install.js getDirName('antigravity'), #503); /.gemini/ -> gemini; /.config/kilo/ or /.kilo/ -> kilo; /.config/opencode/ or /.opencode/ -> opencode; otherwise claude.

Then resolve the install context via the deterministic projection (#498). Do NOT re-derive scope, runtime, or version by hand — update-context owns that cascade in tested code (get-shit-done/bin/lib/update-context.cjs), the same way check-latest-version owns the package name (#2992):

# Resolve gsd-tools.cjs WITHOUT yet knowing GSD_DIR. The running workflow lives
# at <PREFERRED_CONFIG_DIR>/get-shit-done/workflows/update.md, so its sibling
# bin/gsd-tools.cjs is the authoritative tool for THIS install. Fall back to a
# global copy, then to gsd-tools on PATH.
GSD_TOOLS=""
for cand in \
  "$PREFERRED_CONFIG_DIR/get-shit-done/bin/gsd-tools.cjs" \
  "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs"; do
  if [ -n "$cand" ] && [ -f "$cand" ]; then GSD_TOOLS="$cand"; break; fi
done
# Last resort: the gsd-tools shim on PATH — resolved to its absolute path and
# invoked via the variable (never a bare `gsd-tools` command; see #2851).
if [ -z "$GSD_TOOLS" ] && command -v gsd-tools >/dev/null 2>&1; then
  GSD_TOOLS="$(command -v gsd-tools)"
fi

UC=""
if [ -n "$GSD_TOOLS" ]; then
  case "$GSD_TOOLS" in
    *.cjs) UC="$(node "$GSD_TOOLS" update-context --config-dir "$PREFERRED_CONFIG_DIR" --runtime "$PREFERRED_RUNTIME" --json 2>/dev/null)" ;;
    *)     UC="$("$GSD_TOOLS" update-context --config-dir "$PREFERRED_CONFIG_DIR" --runtime "$PREFERRED_RUNTIME" --json 2>/dev/null)" ;;
  esac
fi

if [ -n "$UC" ]; then
  INSTALLED_VERSION="$(printf '%s' "$UC" | jq -r '.installedVersion')"
  INSTALL_SCOPE="$(printf '%s' "$UC" | jq -r '.scope')"
  TARGET_RUNTIME="$(printf '%s' "$UC" | jq -r '.runtime')"
  GSD_DIR="$(printf '%s' "$UC" | jq -r '.gsdDir')"
else
  # No tool resolvable / projection failed -> treat as a fresh install.
  INSTALLED_VERSION="0.0.0"
  INSTALL_SCOPE="UNKNOWN"
  TARGET_RUNTIME="claude"
  GSD_DIR=""
fi

echo "$INSTALLED_VERSION"
echo "$INSTALL_SCOPE"
echo "$TARGET_RUNTIME"
echo "$GSD_DIR"

Parse output:

  • Line 1 = installed version (0.0.0 means unknown version)
  • Line 2 = install scope (LOCAL, GLOBAL, or UNKNOWN)
  • Line 3 = target runtime (claude, opencode, gemini, kilo, codex, antigravity)
  • Line 4 = resolved GSD config dir (e.g. /Users/me/.claude, /Users/me/.gemini); empty if scope is UNKNOWN. Capture this as GSD_DIR and pass it to subsequent steps so they don't re-derive the runtime path.
  • If scope is UNKNOWN, proceed to install using the --claude --global fallback.

update-context reproduces the previous detection cascade — preferred-config-dir fast path, local-over-global with same-path dedup (so CWD=$HOME does not misdetect as LOCAL), env-var overrides (CLAUDE_CONFIG_DIR, OPENCODE_CONFIG_DIR, KILO_CONFIG, XDG_CONFIG_HOME, CODEX_HOME, …), and semver validation — but as a tested projection rather than ~280 lines of inline bash. Branch coverage lives in tests/issue-498-update-context.test.cjs.

If multiple runtime installs are detected and the invoking runtime cannot be determined from execution_context, ask the user which runtime to update before running install.

If VERSION file missing (version resolves to 0.0.0): report the installed version as Unknown and proceed to install (treated as 0.0.0 for comparison).

Check npm for latest version via the deterministic script. **Do NOT run `npm view` or `npm search` directly** — the package name must come from the script, not from a free choice at execution time. (#2992: LLM-driven prescriptions of npm package names produced wrong-package queries; moving the package name into a script constant closes that gap.)

The GSD_DIR value emitted by get_installed_version (line 4) resolves to the runtime-specific config dir (~/.claude/, ~/.gemini/, ~/.codex/, etc.), so the script invocation works for every runtime — not just Claude. If GSD_DIR is empty (scope UNKNOWN), skip this step and go directly to install.

LATEST_RESULT is a JSON document with the documented shape { ok: bool, version: string, reason: string, detail?: string }. Parse via jq ONLY when the script actually ran. When GSD_DIR is empty (scope UNKNOWN), skip the check entirely and seed the parsed fields with their no-op values so downstream logic does not mistake an unset LATEST_RESULT for a failed network check (#2993 CR feedback):

if [ -z "$GSD_DIR" ]; then
  # No install detected — fall through to install step; version-check is skipped.
  LATEST_RESULT=""
  LATEST_STATUS=0
  LATEST_OK=false
  LATEST_VERSION=""
  LATEST_REASON="no_install_detected"
else
  LATEST_RESULT="$(node "$GSD_DIR/get-shit-done/bin/check-latest-version.cjs" --json 2>/dev/null)"
  LATEST_STATUS=$?
  # #2993 CR: when node is missing or the script doesn't exist, LATEST_RESULT
  # is empty and piping it to `jq` produces a parse error on stderr while
  # leaving LATEST_OK / LATEST_REASON as empty strings. Fail the check with a
  # meaningful reason instead of a blank diagnostic.
  if [ -n "$LATEST_RESULT" ]; then
    LATEST_OK="$(printf '%s' "$LATEST_RESULT" | jq -r '.ok // false')"
    LATEST_VERSION="$(printf '%s' "$LATEST_RESULT" | jq -r '.version // empty')"
    LATEST_REASON="$(printf '%s' "$LATEST_RESULT" | jq -r '.reason // empty')"
  else
    LATEST_OK=false
    LATEST_VERSION=""
    LATEST_REASON="script_not_found_or_node_unavailable"
  fi
fi

If LATEST_OK is not true (or LATEST_STATUS is non-zero):

Couldn't check for updates (reason: {LATEST_REASON}, exit: {LATEST_STATUS}).

To update manually: `npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux --global`

Exit.

Compare installed vs latest:

If installed == latest:

## GSD Update

**Installed:** X.Y.Z
**Latest:** X.Y.Z

You're already on the latest version.

Exit.

If installed > latest:

## GSD Update

**Installed:** X.Y.Z
**Latest:** A.B.C

You're ahead of the latest release — this looks like a dev install.

If you see a "⚠ dev install — re-run installer to sync hooks" warning in
your statusline, your hook files are older than your VERSION file. Fix it
by re-running the local installer from your dev branch:

    node bin/install.js --global --claude

Running /gsd:update would install the npm release (A.B.C) and downgrade
your dev version — do NOT use it to resolve this warning.

Exit.

**If update available**, fetch and show what's new BEFORE updating:
  1. Fetch changelog from GitHub raw URL and save to a temp file, e.g. /tmp/gsd-changelog-$$.md.
  2. Extract entries between installed and latest versions using the deterministic range helper (fix for #3496 — do NOT use ad-hoc grep/awk extraction which silently skips intermediate versions):
CHANGELOG_TMP="/tmp/gsd-changelog-$$.md"
curl -fsSL "https://raw.githubusercontent.com/open-gsd/get-shit-done-redux/main/CHANGELOG.md" -o "$CHANGELOG_TMP" 2>/dev/null \
  || wget -qO "$CHANGELOG_TMP" "https://raw.githubusercontent.com/open-gsd/get-shit-done-redux/main/CHANGELOG.md" 2>/dev/null

EXTRACT_JSON=$(node "$GSD_DIR/get-shit-done/scripts/changeset/cli.cjs" extract \
  --from "$INSTALLED_VERSION" \
  --to "$LATEST_VERSION" \
  --changelog "$CHANGELOG_TMP" \
  --json 2>/dev/null)
EXTRACT_EXIT=$?
rm -f "$CHANGELOG_TMP"

if [ "$EXTRACT_EXIT" -eq 2 ]; then
  # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse)
  CHANGELOG_PREVIEW="No changelog updates between v${INSTALLED_VERSION} and v${LATEST_VERSION}."
elif [ "$EXTRACT_EXIT" -ne 0 ] || [ -z "$EXTRACT_JSON" ]; then
  CHANGELOG_PREVIEW="(Could not extract changelog — update will still proceed)"
else
  # Re-run without --json to get the human-readable markdown for display
  CHANGELOG_PREVIEW=$(node "$GSD_DIR/get-shit-done/scripts/changeset/cli.cjs" extract \
    --from "$INSTALLED_VERSION" \
    --to "$LATEST_VERSION" \
    --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)")
fi
  1. Display preview and ask for confirmation, using $CHANGELOG_PREVIEW from the extract step above:
## GSD Update Available

**Installed:** {INSTALLED_VERSION}
**Latest:** {LATEST_VERSION}

### What's New
────────────────────────────────────────────────────────────

{CHANGELOG_PREVIEW}

────────────────────────────────────────────────────────────

⚠️  **Note:** The installer performs a clean install of GSD folders:
- `commands/gsd/` will be wiped and replaced
- `get-shit-done/` will be wiped and replaced
- `agents/gsd-*` files will be replaced

(Paths are relative to detected runtime install location:
global: `~/.claude/`, `~/.config/opencode/`, `~/.opencode/`, `~/.gemini/`, `~/.config/kilo/`, or `~/.codex/`
local: `./.claude/`, `./.config/opencode/`, `./.opencode/`, `./.gemini/`, `./.kilo/`, or `./.codex/`)

Your custom files in other locations are preserved:
- Custom commands not in `commands/gsd/` ✓
- Custom agents not prefixed with `gsd-` ✓
- Custom hooks ✓
- Your CLAUDE.md files ✓

If you've modified any GSD files directly, they'll be automatically backed up to `gsd-local-patches/` and can be reapplied with `/gsd:update --reapply` after the update.

Text mode (workflow.text_mode: true in config or --text flag): Set TEXT_MODE=true if --text is present in $ARGUMENTS OR text_mode from init JSON is true. When TEXT_MODE is active, replace every AskUserQuestion call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where AskUserQuestion is not available. Use AskUserQuestion:

  • Question: "Proceed with update?"
  • Options:
    • "Yes, update now"
    • "No, cancel"

If user cancels: Exit.

Before running the installer, detect and back up any user-added files inside GSD-managed directories. These are files that exist on disk but are NOT listed in `gsd-file-manifest.json` — i.e., files the user added themselves that the installer does not know about and will delete during the wipe.

Do not use bash path-stripping (${filepath#$RUNTIME_DIR/}) or node -e require() inline — those patterns fail when $RUNTIME_DIR is unset and the stripped relative path may not match manifest key format, which causes CUSTOM_COUNT=0 even when custom files exist (bug #1997). Use gsd-tools.cjs query detect-custom-files or the bundled gsd-tools.cjs detect-custom-files path — both resolve paths reliably with Node.js path.relative().

First, resolve the config directory (RUNTIME_DIR) from the install scope detected in get_installed_version:

# RUNTIME_DIR is the resolved config directory (e.g. ~/.config/opencode, ~/.gemini).
# get_installed_version emits it as GSD_DIR (LOCAL or GLOBAL install dir, or empty
# when scope is UNKNOWN). Empty RUNTIME_DIR skips the backup below.
RUNTIME_DIR="$GSD_DIR"

If RUNTIME_DIR is empty or does not exist, skip this step (no config dir to inspect).

Otherwise run detect-custom-files:

CUSTOM_JSON=''
if [ -f "$GSD_TOOLS" ] && [ -n "$RUNTIME_DIR" ]; then
  CUSTOM_JSON=$(node "$GSD_TOOLS" detect-custom-files --config-dir "$RUNTIME_DIR" 2>/dev/null)
fi
if [ -z "$CUSTOM_JSON" ]; then
  CUSTOM_JSON='{"custom_files":[],"custom_count":0}'
fi
CUSTOM_COUNT=$(echo "$CUSTOM_JSON" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{console.log(JSON.parse(d).custom_count);}catch{console.log(0);}})" 2>/dev/null || echo "0")

If CUSTOM_COUNT > 0:

Back up each custom file to $RUNTIME_DIR/gsd-user-files-backup/ before the installer wipes the directories:

BACKUP_DIR="$RUNTIME_DIR/gsd-user-files-backup"
mkdir -p "$BACKUP_DIR"

# Parse custom_files array from CUSTOM_JSON and copy each file
node - "$RUNTIME_DIR" "$BACKUP_DIR" "$CUSTOM_JSON" <<'JSEOF'
const [,, runtimeDir, backupDir, customJson] = process.argv;
const { custom_files } = JSON.parse(customJson);
const fs = require('fs');
const path = require('path');
for (const relPath of custom_files) {
  const src = path.join(runtimeDir, relPath);
  const dst = path.join(backupDir, relPath);
  if (!fs.existsSync(src)) continue;

  try {
    fs.mkdirSync(path.dirname(dst), { recursive: true });
    fs.copyFileSync(src, dst);
    console.log('  Backed up: ' + relPath);
  } catch (err) {
    const code = err && err.code ? String(err.code) : 'ERROR';
    console.log('  Skipped (non-fatal): ' + relPath + ' [' + code + ']');
  }
}
JSEOF

Then inform the user:

⚠️  Found N custom file(s) inside GSD-managed directories.
    These have been backed up to gsd-user-files-backup/ before the update.
    Restore them after the update if needed.

If CUSTOM_COUNT == 0: No user-added files detected. Continue to install.

Run the update using the install type detected in step 1:

Build runtime flag from step 1:

RUNTIME_FLAG="--$TARGET_RUNTIME"

If LOCAL install:

npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux "$RUNTIME_FLAG" --local

If GLOBAL install:

npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux "$RUNTIME_FLAG" --global

If UNKNOWN install:

npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux --claude --global

Capture output. If install fails, show error and exit.

Clear the update cache so statusline indicator disappears:

expand_home() {
  case "$1" in
    "~/"*) printf '%s/%s\n' "$HOME" "${1#~/}" ;;
    *) printf '%s\n' "$1" ;;
  esac
}

# Clear update cache across preferred, env-derived, and default runtime directories
CACHE_DIRS=()
if [ -n "$PREFERRED_CONFIG_DIR" ]; then
  CACHE_DIRS+=( "$(expand_home "$PREFERRED_CONFIG_DIR")" )
fi
if [ -n "$CLAUDE_CONFIG_DIR" ]; then
  CACHE_DIRS+=( "$(expand_home "$CLAUDE_CONFIG_DIR")" )
fi
if [ -n "$GEMINI_CONFIG_DIR" ]; then
  CACHE_DIRS+=( "$(expand_home "$GEMINI_CONFIG_DIR")" )
fi
if [ -n "$KILO_CONFIG_DIR" ]; then
  CACHE_DIRS+=( "$(expand_home "$KILO_CONFIG_DIR")" )
elif [ -n "$KILO_CONFIG" ]; then
  CACHE_DIRS+=( "$(dirname "$(expand_home "$KILO_CONFIG")")" )
elif [ -n "$XDG_CONFIG_HOME" ]; then
  CACHE_DIRS+=( "$(expand_home "$XDG_CONFIG_HOME")/kilo" )
fi
if [ -n "$OPENCODE_CONFIG_DIR" ]; then
  CACHE_DIRS+=( "$(expand_home "$OPENCODE_CONFIG_DIR")" )
elif [ -n "$OPENCODE_CONFIG" ]; then
  CACHE_DIRS+=( "$(dirname "$(expand_home "$OPENCODE_CONFIG")")" )
elif [ -n "$XDG_CONFIG_HOME" ]; then
  CACHE_DIRS+=( "$(expand_home "$XDG_CONFIG_HOME")/opencode" )
fi
if [ -n "$CODEX_HOME" ]; then
  CACHE_DIRS+=( "$(expand_home "$CODEX_HOME")" )
fi

for dir in "${CACHE_DIRS[@]}"; do
  if [ -n "$dir" ]; then
    rm -f "$dir/cache/gsd-update-check.json"
  fi
done

for dir in .claude .config/opencode .opencode .gemini/antigravity-ide .gemini/antigravity-cli .gemini/antigravity .agent .gemini .config/kilo .kilo .codex; do
  rm -f "./$dir/cache/gsd-update-check.json"
  rm -f "$HOME/$dir/cache/gsd-update-check.json"
done

# Clear the shared tool-agnostic cache written by gsd-check-update.js hook (#2784).
# The hook uses ~/.cache/gsd/gsd-update-check.json regardless of runtime; clear it
# so the statusline stops showing the stale "⬆ /gsd:update" indicator after update.
rm -f "$HOME/.cache/gsd/gsd-update-check.json"

The SessionStart hook (gsd-check-update.js) writes to the detected runtime's cache directory, so preferred/env-derived paths and default paths must all be cleared to prevent stale update indicators.

Format completion message (changelog was already shown in confirmation step):
╔═══════════════════════════════════════════════════════════╗
║  GSD Updated: v1.5.10 → v1.5.15                           ║
╚═══════════════════════════════════════════════════════════╝

⚠️  Restart your runtime to pick up the new commands.

[View full changelog](https://github.com/open-gsd/get-shit-done-redux/blob/main/CHANGELOG.md)
After update completes, check if the installer detected and backed up any locally modified files:

Check for gsd-local-patches/backup-meta.json in the config directory.

If patches found:

Local patches were backed up before the update.
Run `/gsd:update --reapply` to merge your modifications into the new version.

If no patches: Continue normally.

<success_criteria>

  • Installed version read correctly
  • Latest version checked via npm
  • Update skipped if already current
  • Changelog fetched and displayed BEFORE update
  • Clean install warning shown
  • User confirmation obtained
  • Update executed successfully
  • Restart reminder shown </success_criteria>