update-deps

SkillMonitoring & ops

Dependency remediation in place of Dependabot version and security updates, discover outdated packages and open security advisories, resolve advisories (in-range refresh, chain-head bump, security-floor override, or alert dismissal), audit overrides, apply migrations for major bumps, run the quality

Use update-deps in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add update-deps and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the update-deps skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

update-depsStart free

What this skill tells your AI

The instructions your AI receives, as published by gaia-react/gaia in .claude/skills/update-deps/SKILL.md and read by Ahel’s review.

Superpowered Dependabot. Auto-discover all outdated packages, preview them grouped by severity so you can snooze any you are not ready for, audit overrides, apply codebase migrations for major bumps, resolve dependency conflicts, refresh transitive dependencies within range, report any advisory the refresh could not clear, and run the quality gate. In CI it runs unattended (no preview); interactively it shows the preview first. On a main/master run it opens the PR and merges it once checks are green, then cleans up locally; on any other branch it pushes and leaves the PR to you.

Pre-flight: Worktree check

This wrapper writes a new pnpm-lock.yaml and opens a PR, both belong on the main checkout, not a per-SPEC worktree branch. If invoked from a linked worktree, reject hard with a message that surfaces the cached state from main so the user knows whether action is even pending.

Detection (run this first, before anything else):

. .gaia/scripts/main-only-lib.sh
gaia_update_deps_state_line() {
  local cache_file="$1"
  [ -f "$cache_file" ] && command -v jq >/dev/null 2>&1 || return 0
  local outdated_count checked_at
  outdated_count="$(jq -r '.outdatedCount // 0' "$cache_file" 2>/dev/null)"
  checked_at="$(jq -r '.checkedAt // 0' "$cache_file" 2>/dev/null)"
  [ -n "$outdated_count" ] && [ -n "$checked_at" ] && [ "$checked_at" != "0" ] || return 0
  local now age ago_unit ago_value
  now=$(date +%s)
  age=$((now - checked_at))
  # Format age as <Nm ago> / <Nh ago> / <Nd ago>.
  ago_unit="s"; ago_value="$age"
  if [ "$age" -ge 86400 ]; then ago_unit="d"; ago_value=$((age / 86400));
  elif [ "$age" -ge 3600 ]; then ago_unit="h"; ago_value=$((age / 3600));
  elif [ "$age" -ge 60 ]; then ago_unit="m"; ago_value=$((age / 60));
  fi
  printf 'Cached on main: %s packages outdated (last checked %s%s ago).\n' "$outdated_count" "$ago_value" "$ago_unit"
}
gaia_refuse_if_worktree "/update-deps" gaia_update_deps_state_line || exit 1

If the detection does not fire, fall through to the existing ## Pre-flight: Branch check section.

Pre-flight: Branch check

git branch --show-current

If the current branch is main or master and not running in CI, set a flag (SHOULD_CREATE_BRANCH=true) but do not create the branch yet, branch creation is deferred until the run has confirmed work: after Phase 1 when the apply set is non-empty, or after Phase 5b lands on a refresh-only run (see Branch creation). Creating a branch when there is nothing to update pollutes the branch list.

In CI (CI=true, set by GitHub Actions, GitLab CI, CircleCI, and most CI providers), skip branch creation, the workflow owns branch management and pre-creates the appropriate branch before this skill runs.

Otherwise set SHOULD_CREATE_BRANCH=false and proceed on the current branch.

Composition: --scope <group-name>

When invoked with --scope <group-name> (e.g. /update-deps --scope react-router):

  • Skip Phase 0 (override audit), out of scope for a single-group run.
  • Skip the discovery + preview phase, no preview runs in --scope; the group's members are known from the companion-group table.
  • Skip wave classification, the run is implicitly a single group; treat it as Wave A if all members are minor/patch, else Wave B.
  • Wave A / Wave B still apply, scoped to the named group's members in root package.json.
  • Skip Phase 5b (transitive refresh), a single-group run does not re-resolve the whole tree.
  • Quality gate, return value, and final report still run.

Invoked manually, one group per run, when a maintainer wants a single major-bump group's own PR rather than the combined Wave A/B run.

Companion groups (reference)

The fixed table mapping each package to its group. gaia update-deps run resolves grouping internally and is the source of truth at runtime; every emitted entry already carries its resolved group. When any member of a group is outdated, all members present in package.json update together, so a group moves as one unit (and snoozes as one unit).

GroupMembers
react-routerreact-router, @react-router/dev, @react-router/node, @react-router/serve, @react-router/fs-routes
reactreact, react-dom, @types/react, @types/react-dom
tailwindcsstailwindcss, @tailwindcss/vite, @tailwindcss/forms, @tailwindcss/typography, prettier-plugin-tailwindcss
storybookstorybook, @storybook/*, eslint-plugin-storybook, msw-storybook-addon, storybook-react-i18next, @vueless/storybook-dark-mode
vitestvitest, @vitest/coverage-v8, @vitest/ui, @vitest/eslint-plugin
playwright@playwright/test, @playwright-testing-library/test
eslinteslint, @eslint/js, @eslint/compat, eslint-config-*, eslint-plugin-* (9.x cap applies)
testing-library@testing-library/dom, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event
i18nexti18next, react-i18next, remix-i18next, i18next-browser-languagedetector
mswmsw, msw-storybook-addon
vitevite, @vitejs/plugin-react
zod-conformzod, @conform-to/react, @conform-to/zod
fontawesome@fortawesome/*
stylelintstylelint, stylelint-config-*, stylelint-order
prettierprettier, eslint-config-prettier, eslint-plugin-prettier
huskyhusky, lint-staged

Packages not matched form singleton groups. typescript is deliberately one of them: a compiler bump and an ambient type-definition bump (@types/node) carry independent risk and independent migration guides, so they never move as a unit.

Phase 1: Discover, preview, decide (orchestrator)

Discover deterministically via the CLI primitive, the single source of truth for grouping, the ESLint 9.x cap, the gaia.updateDepsHold config hold, and the release-age cooldown (all already applied):

updates_json="$(mktemp)"
.gaia/cli/gaia update-deps run --emit-updates "$updates_json"

Read the payload. If total_count is 0, print All packages are up to date. Under --scope, exit (no branch, no changes). Otherwise the apply set is empty and the run is refresh-only (see Decision): in CI it proceeds straight to Phase 5b; interactively, print Transitive refresh: re-resolve every transitive dependency to the newest in-range version past the release-age window; reverted whole if the quality gate fails. and ask with AskUserQuestion (single-select): Refresh transitive dependencies (default, first option) or Skip (exit now, no branch, no changes, no ledger write). Each wave_a[] and wave_b[].packages[] entry carries bucket (patch | minor | major | nonsemver), current, latest, group, is_pinned, and kind. total_count is the genuine-upgrade count; actionable_count is for the statusline only (it already subtracts local snoozes), ignore it here.

snoozed[] lists the groups the human deferred earlier that still match the currently-offered targets. Each entry carries group, targets, snoozed_at, and resurfaces_at (ISO stamp when the snooze lapses). Build snoozedGroups, the set of their group ids, this is what you mark and default-skip below. It is empty in CI and whenever no active snooze matches the current offer.

skipped[] entries with reason: "held" are packages capped by the committed gaia.updateDepsHold map in package.json (a durable version ceiling, distinct from a snooze: it holds in CI too and never lapses until the maintainer lifts it). They are already excluded from both waves, never installed, so nothing to apply. Surface them once (see Preview) so the maintainer remembers a hold is active.

A hold caps only the package it names; a coupled sibling in the same companion group (e.g. @vitejs/plugin-react beside a held vite) still updates on its own line. If a sibling release's peer range demands a version above the ceiling, hold the sibling too by adding it to the map. The quality gate catches a peer conflict before merge regardless.

In CI (CI=true) or with --scope <group>, skip the preview and decision entirely. The apply set is the full payload (CI) or the named group (--scope); jump straight to the apply phases with an empty skip set and no ledger write.

Preview

Group the entries for display into four sections in this order: Major, Non-semver, Minor, Patch. A companion group (any group not prefixed singleton:) renders as ONE block under the section of its most-severe member (severity major > nonsemver > minor > patch) and is a single choice that updates together. Render each row as name current → next; for a companion group, list its members under one labelled block (e.g. "react-router group, updates together").

Mark every group in snoozedGroups with a trailing [snoozed until <date>] (the entry's resurfaces_at, rendered as a plain date). Snoozed groups are default-skipped: they are excluded from the default apply set, so a group the human already deferred is not silently re-applied each run. The human can still choose to refresh them.

If skipped[] has any reason: "held" entries, print one line above the sections listing them, e.g. Held by config (not offered): vite (current 8.0.16, ceiling 8.0). This is informational, held packages are never part of any apply set; the line just keeps the active holds visible.

Below the sections, print one line for the transitive refresh (Phase 5b), which runs by default after the waves: Transitive refresh: re-resolve every transitive dependency to the newest in-range version past the release-age window; reverted whole if the quality gate fails. Name transitive-refresh under "Choose what to skip" to skip it this run.

Then ask with AskUserQuestion (single-select). When snoozedGroups is non-empty, offer these options in this order:

  • Update the rest (default, first option): apply every group EXCEPT the snoozed ones; leave the snoozes intact.
  • Choose what to skip: the human names further package or group names to skip; the snoozed groups stay skipped too.
  • Update everything incl. snoozed: apply every group AND clear the snoozes.
  • Cancel: exit now, no branch, no changes, no ledger write.

When snoozedGroups is empty, offer the original three:

  • Update all (default, first option): apply every group.
  • Choose what to skip: the human names the package or group names to skip; everything else applies.
  • Cancel: exit now, no branch, no changes, no ledger write.

Decision

The skip set is snoozedGroups plus whatever the human names; the apply set is every remaining group. The ledger is full-replace, so any decline --skip call must name every group that should stay snoozed (the pre-existing snoozes AND any newly named), or the omitted ones are dropped from the ledger.

  • Update the rest (snoozed groups present) → apply set = payload minus snoozedGroups. Leave the ledger untouched (the snoozes already match the current targets), no decline call. If the apply set is empty (every outstanding group is snoozed), print Snoozed N group(s); nothing else to update. and continue as a refresh-only run.
  • Update all (no snoozed groups) → clear any prior snoozes, apply everything:
    .gaia/cli/gaia update-deps decline --clear
    
    Apply set = the full payload.
  • Update everything incl. snoozed → same action as Update all: clear the snoozes (decline --clear) and apply the full payload.
  • Choose what to skip → collect the names, union with snoozedGroups, then record the whole skip set:
    .gaia/cli/gaia update-deps decline --source "$updates_json" --skip "<snoozed…,named…>"
    
    Each name expands to its whole companion group (a partial group cannot be skipped); an unknown name errors so you can re-ask. Apply set = the payload minus the skip set. Echo the resulting apply set back for confirmation. If the apply set is now empty (everything is skipped), print Snoozed N group(s); nothing else to update., then continue as a refresh-only run, or exit (no branch) when transitive-refresh was also named. The name transitive-refresh is not a group: strip it from the names before the decline call and skip Phase 5b for this run only, it is never recorded in the ledger. If it was the only name given, handle the choice as Update all (or Update the rest when snoozed groups are present) with Phase 5b skipped.
  • Cancel → stop here.

The snooze ledger (.gaia/local/declined-updates.json) is local-only and gitignored: it suppresses the statusline nudge and default-skips the group in this preview until a newer version ships or 14 days pass. It never HARD-gates a run, the human can always choose to refresh a snoozed group, and CI ignores it entirely (CI is the freshness backstop and keeps opening PRs).

Carry the apply set (filtered wave_a and wave_b) into the phases below, together with whether Phase 5b runs: yes by default and in CI, no when the human skipped transitive-refresh, never under --scope.

A refresh-only run has an empty apply set but still runs Phase 5b: every direct dependency is current, or every outstanding group is snoozed or skipped. It skips the Override audit + Wave A agent and Phase 5, runs Phase 5b first, and creates its branch only when the refresh lands (see Branch creation). Phase 0 does not run, so Phase 6 runs only after a landed refresh and treats every key in the overrides: map as retained.

Override audit + Wave A: Haiku agent

Spawn a Haiku agent (model: "haiku") to run the override audit and the Wave A batch install on the apply set computed above. Pass it these instructions verbatim, substituting the apply set's Wave A entries into the Wave A input:


Phase 0: Override audit

Each key in the top-level overrides: map in pnpm-workspace.yaml exists for one of two reasons: to resolve a peer-dependency conflict, or to enforce a security floor (pin a transitive dependency at or above a patched version to clear a known advisory). The two are detected by different tests, and an override is obsolete only when removing it regresses neither. A peer-dep test alone is blind to security-floor pins (a CVE pin never produces a peer-dep error), so it would wrongly delete every one of them. (pnpm 11 reads overrides here; the package.json pnpm.overrides field is no longer honored.)

Capture the advisory baseline first, with every override still in place:

pnpm audit --json 2>/dev/null | jq -r '.advisories // {} | keys[]?' | sort -u > /tmp/audit-baseline.txt

Each .advisories key is one advisory ID; this file is the set of advisories the current overrides tolerate. (If a future pnpm emits the vulnerabilities shape instead of advisories, read whichever key is present, the goal is a stable ID set to diff against.)

Then, for each override key, one at a time, leaving every other pnpm-workspace.yaml setting untouched:

  1. Temporarily remove that single key from the overrides: map.

  2. Run pnpm dedupe. A bare pnpm install, even pnpm install --force, short-circuits with "Already up to date" when only the overrides: map changed and leaves the lockfile untouched, so the toggle would not re-resolve and the test below would read the stale tree; pnpm dedupe performs a full install that re-resolves and applies the override change. See wiki/dependencies/pnpm-overrides.md. If it exits non-zero, restore the key, note as retained (install error), and move to the next key, do not diagnose the failure or run the tests below for this key.

  3. Peer-dep test: run pnpm ls 2>&1 and scan for peer-dep errors.

  4. Security-floor test: run pnpm audit --json and extract its advisory IDs the same way. Any ID present now but absent from /tmp/audit-baseline.txt means removing this override reintroduced a known vulnerability.

    pnpm audit --json 2>/dev/null | jq -r '.advisories // {} | keys[]?' | sort -u > /tmp/audit-now.txt
    comm -13 /tmp/audit-baseline.txt /tmp/audit-now.txt   # IDs this removal introduced
    
  5. Decide:

    • Peer-dep errors or any newly introduced advisory → the override is load-bearing. Restore the key. Note as retained (record which test failed, and the advisory ID + package if it was the security-floor test).
    • Neither regressed → the override is obsolete. Leave it removed. Note as removed.

The security-floor test is severity-agnostic on purpose: an override is a deliberate maintainer artifact, so any advisory it was silencing, at any severity, is reason to keep it. This is intentionally stricter than the high/critical surfacing floor in the Noise scoping section of wiki/dependencies/pnpm-audit.md: deciding whether to delete a maintainer's pin warrants more caution than deciding whether to surface an advisory for review. A maintainer who wants a pin gone removes it by hand.

After the toggle loop, assert the lockfile matches config. Once every retained key is restored, the lockfile's top-level overrides: block must list exactly the keys present in the overrides: map in pnpm-workspace.yaml. Compare the two; on any drift (a config key missing from the lockfile block, or vice versa) the floor is unapplied, so run pnpm dedupe once more and re-run the quality gate. This assertion guarantees that every retained floor is applied, never silently disabled. It says nothing about whether a floor is current. Note the tradeoff: pnpm dedupe re-optimizes the whole tree, so a single toggle can yield a wider lockfile diff than the one key it touched (it may also drop now-redundant transitives). That broader diff is expected, and correct, the alternative is an unapplied override.

Wave A input

You are given the Wave A apply set: the wave_a entries from the orchestrator's discovery, minus any group the human chose to skip. Each entry carries name, current, latest, is_pinned, and kind (minor or patch). Do not run pnpm outdated or re-discover, the ESLint 9.x cap, the release-age cooldown, and companion-group expansion are already applied. If the Wave A apply set is empty, skip straight to the quality gate (Wave B groups, if any, are handled by the orchestrator).

Wave A (batch minor/patch)

  1. Build install args. For each entry: if is_pinned use the exact target, else use ^<latest>. Example: pnpm add foo@1.2.3 bar@^4.5.0 ....
  2. Run the single pnpm add command.
  3. Run pnpm ls 2>&1. Scan for peer-dep errors.
  4. On error: try one targeted fix in the overrides: map in pnpm-workspace.yaml (e.g. add a parent>child pin), then pnpm dedupe to apply it, a bare pnpm install won't re-resolve an overrides-only change.
  5. If still failing: revert the offending packages (pnpm add <pkg>@<previous>) and log them as skipped with the reason.
  6. Run the quality gate (below). If it fails, revert the entire Wave A batch.

Quality gate

pnpm typecheck
pnpm lint
pnpm test --run
pnpm pw
pnpm build

Return value

Report back to the orchestrator with:

  • Override audit results (removed / retained)
  • Wave A results (updated packages, any skipped)
  • Quality gate results

Branch creation (after discovery)

Phase 1 already exited if the human cancelled, or declined the transitive refresh with nothing else to apply, so reaching here means the apply set is non-empty or the run is refresh-only. With a non-empty apply set, run this section after the Haiku agent returns. On a refresh-only run, run it after Phase 5b returns, and only when it reports landed; on Nothing moved or Reverted (<reason>), skip this section and every phase up to Phase 7 and go straight to the report (no branch exists, and Phase 8's nothing-updated rule skips publish):

  • Updates were confirmed. Immediately bust the update-check cache so the statusline reflects the post-update state on the next session regardless of whether this run completes. Use the Write tool to overwrite .gaia/local/cache/shared/update-check.json: read the existing cache first, then write it back unchanged except outdatedCount set to 0 and checkedAt set to the current Unix timestamp. Carry every other field across verbatim, named here or not. The cache holds fields this step has no reason to know about, and several are observations nothing later can reconstruct (auditMemoryBaseline and the two harden* counts), so dropping one silently disarms the nudge it belongs to instead of causing a visible error. If the cache file does not exist, skip this step. (Snoozed groups are already excluded by the ledger on the next real check.)

  • If SHOULD_CREATE_BRANCH=true, create the branch now and remember that you created it (this determines publish behavior in Phase 8):

git checkout -b "$(bash .gaia/scripts/branch-name-lib.sh name chore update-deps)"
# CREATED_NEW_BRANCH=true, used in Phase 8

Otherwise (SHOULD_CREATE_BRANCH=false), proceed on the current branch and remember that you did NOT create a new branch.

Phase 5: Wave B (per-group major bumps)

Use the Wave B groups from the apply set (the payload's wave_b minus any group the human skipped). If there are none, skip to Phase 5b.

For each Wave B group, classify complexity and assign a model:

Opus (model: "opus"): react-router, react, storybook, singleton:typescript

Sonnet (model: "sonnet"): eslint, all other groups

Spawn one agent per group (or sequentially if resource-constrained), passing it these instructions:


Wave B group instructions

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
23
Forks
4
Last commit
Oct 2026

Ahel review

  • K1binfo
    installs-packages
  • K4blow
    destructive-scoped

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Item type
skill
Key
update-deps
Source
github.com/gaia-react/gaia