update-gaia
SkillFiles & storagePull the latest GAIA release into this project without clobbering customizations. Three-way merge per file using .gaia/manifest.json classes. Trigger when the user clicks the statusline `Run /update-gaia` indicator or asks "update GAIA", "pull the latest GAIA", "apply the new GAIA release".
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the update-gaia skill
What this skill tells your AI
The instructions your AI receives, as published by gaia-react/gaia in .claude/skills/update-gaia/SKILL.md and read by ahel’s review.
Pull the latest GAIA release into this project without clobbering customizations. Does a three-way comparison per file (adopter / baseline / latest) and respects explicit classes in .gaia/manifest.json:
owned: GAIA controls fully.shared: GAIA seeds, you customize.wiki-owned: GAIA-seeded concept/decision/module wiki pages.- adopter-owned (implicit): anything not in the manifest, plus sentinels like
wiki/hot.md,wiki/log.md,CHANGELOG.md,.gaia/VERSION,.gaia/manifest.json. Never touched.
The first three take the same Step 7 rows. The class changes only two of them: which bucket a clean overwrite reports under (owned → overwrite[], the other two → merge[]), and what happens when the release newly owns a path the adopter already has (owned backs up and overwrites, the other two fall through to the ordinary rows). Step 7 is authoritative.
Backups land in .gaia-backup/<timestamp>/. Conflict patches land in .gaia-merge/.
Pre-flight: Worktree check
This wrapper changes .gaia/VERSION 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 version state from main so the user knows whether a GAIA update is even pending.
Detection (run this first, before anything else):
. .gaia/scripts/main-only-lib.sh
gaia_update_gaia_state_line() {
local cache_file="$1"
[ -f "$cache_file" ] && command -v jq >/dev/null 2>&1 || return 0
local gaia_current gaia_latest gaia_has_update
gaia_current="$(jq -r '.gaiaCurrent // ""' "$cache_file" 2>/dev/null)"
gaia_latest="$(jq -r '.gaiaLatest // ""' "$cache_file" 2>/dev/null)"
gaia_has_update="$(jq -r '.gaiaHasUpdate // false' "$cache_file" 2>/dev/null)"
[ -n "$gaia_current" ] && [ -n "$gaia_latest" ] || return 0
local update_phrase="not-available"
[ "$gaia_has_update" = "true" ] && update_phrase="available"
printf 'Cached on main: GAIA %s installed; latest %s (update %s).\n' "$gaia_current" "$gaia_latest" "$update_phrase"
}
gaia_refuse_if_worktree "/update-gaia" gaia_update_gaia_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, set a flag (SHOULD_CREATE_BRANCH=true) but do not create the branch yet, creation is deferred until after the Step 4 "Proceed" confirmation. Steps 1-4 can exit early (already up to date, or the user aborts); branching before then leaves an orphan chore/update-gaia-* branch when there was nothing to update.
Otherwise set SHOULD_CREATE_BRANCH=false and proceed on the current branch.
Step 1: Read baseline version
cat .gaia/VERSION 2>/dev/null || echo MISSING
If the file is missing, stop and tell the user:
"No
.gaia/VERSIONfound, this project was not scaffolded from GAIA, or the marker was deleted. Run/gaia-initon a freshcreate-gaiascaffold first."
Persist the trimmed version as BASELINE (e.g., 1.0.0).
Step 2: Resolve latest release
gh release list --repo gaia-react/gaia --limit 1 --json tagName --jq '.[0].tagName'
Persist as LATEST_TAG (e.g., v1.0.1) and LATEST (strip leading v).
If gh is unavailable, fall back to:
curl -fsSL https://api.github.com/repos/gaia-react/gaia/releases/latest | jq -r .tag_name
If both fail, stop and ask the user to supply the target version explicitly.
Step 3: Compare versions
- If
LATEST == BASELINE:- First, detect an interrupted prior run. If
.gaia/VERSIONdiffers from the last commit (git diff --quiet HEAD -- .gaia/VERSIONexits non-zero, this catches a staged or unstaged bump), a previous/update-gaiaalready bumped the version but the update was never committed. Do not print "up to date", the bumped VERSION makes every re-run look current, so saying it dead-ends the user. Instead read the committed baseline (git show HEAD:.gaia/VERSION) for context and tell the user: the update tov$LATESTis already applied to the working tree but not committed. Reviewgit diffand commit it (Step 10 guidance), or rungit checkout -- .gaia/VERSIONto discard the bump and re-run/update-gaiato start over. Exit. - Otherwise print "You are up to date on GAIA v$BASELINE." and exit.
- First, detect an interrupted prior run. If
- If
semver(LATEST) < semver(BASELINE)→ print a warning that the installed version is ahead of the latest release and exit. Never downgrade.
Step 4: Show the release notes and confirm
Show the human the full baseline-to-latest CHANGELOG range, not just the single latest tag's GitHub body, an adopter several versions behind needs every intervening entry. Read GAIA's own CHANGELOG.md at $LATEST_TAG (a plain markdown file, fetched no-auth from the raw URL, with a gh fallback) and extract every ## [x.y.z] section strictly newer than $BASELINE through $LATEST:
changelog="$(curl -fsSL "https://raw.githubusercontent.com/gaia-react/gaia/$LATEST_TAG/CHANGELOG.md" 2>/dev/null)"
if [ -z "$changelog" ] && command -v gh >/dev/null 2>&1; then
changelog="$(gh api "repos/gaia-react/gaia/contents/CHANGELOG.md?ref=$LATEST_TAG" \
-H "Accept: application/vnd.github.raw" 2>/dev/null)"
fi
range="$(printf '%s\n' "$changelog" | awk -v baseline="$BASELINE" -v latest="$LATEST" '
function vcmp(a,b, x,y,i){split(a,x,".");split(b,y,".");for(i=1;i<=3;i++){if((x[i]+0)>(y[i]+0))return 1;if((x[i]+0)<(y[i]+0))return -1}return 0}
/^## \[Unreleased\]/ {printing=0; next}
/^\[[^][]+\]:[[:space:]]*http/ {printing=0; next}
/^## \[[0-9]+\.[0-9]+\.[0-9]+\]/ {
v=$0; sub(/^## \[/,"",v); sub(/\].*/,"",v)
printing=(vcmp(v,baseline)>0 && vcmp(v,latest)<=0)
}
printing {print}
')"
if [ -n "$range" ]; then
printf '%s\n' "$range"
else
# Fetch failed (offline, private, missing file): fall back to the single-tag
# GitHub release body so the gate still has context.
gh release view "$LATEST_TAG" --repo gaia-react/gaia --json body --jq .body
fi
The awk walks the version headers newest-first, prints the contiguous block from $LATEST down to (but not including) $BASELINE, and drops the [Unreleased] block and the bottom link-reference list. Print the range to the user. Then use AskUserQuestion:
- Question: "Update GAIA from v$BASELINE to $LATEST_TAG?"
- Options:
Proceed/Abort.
On Abort, exit cleanly with no filesystem changes.
If SHOULD_CREATE_BRANCH=true, create and switch to the branch now that the user has confirmed:
git checkout -b chore/update-gaia-$(date +%Y-%m-%d-%H-%M)
Otherwise stay on the current branch.
Step 4b: Prune prior-run artifacts
Three gitignored directories accumulate across updates: .gaia-backup/, .gaia/local/cache/shared/update-gaia/, and .gaia-merge/. Prune the prior runs' leftovers here, at the start of a confirmed update and before this run creates any of its own artifacts (Step 5 populates the cache, Step 7 creates $BACKUP_DIR), so the current run's fresh safety net is never touched. This runs only after the Step 4 Proceed, so an abort, an already-up-to-date exit, and the interrupted-prior-run case Step 3 surfaces (whose backups and patches are still in flight) never reach it.
# .gaia-backup/: prior runs' pre-overwrite copies. Once an update is committed,
# git history is the durable recovery, so prior backups are redundant. This run
# creates its own $BACKUP_DIR in Step 7.
rm -rf .gaia-backup
# .gaia/local/cache/shared/update-gaia/: keep the baseline tarball (v$BASELINE
# is this run's baseline, reused by Step 5 instead of re-downloading). Delete
# every other cached tag dir. The loop only ever touches tag dirs here,
# update-check.json and serena-guard/ live one level up at shared/,
# structurally outside this glob.
if [ -d .gaia/local/cache/shared/update-gaia ]; then
for d in .gaia/local/cache/shared/update-gaia/*/; do
[ -d "$d" ] || continue
[ "$(basename "$d")" = "v$BASELINE" ] && continue
rm -rf "$d"
done
fi
# .gaia-merge/: conflict patches + .notes the operator resolves by hand (Step
# 11). Remove only when empty; a populated dir holds unresolved action items, so
# never delete it, warn and name the leftovers instead.
if [ -d .gaia-merge ]; then
if [ -n "$(ls -A .gaia-merge 2>/dev/null)" ]; then
echo "Heads up: .gaia-merge/ still holds unresolved patches from a prior run, NOT deleted:"
ls -A .gaia-merge
echo "Resolve or delete them by hand, then re-run /update-gaia."
else
rmdir .gaia-merge
fi
fi
Model selection
After the user confirms, determine the model for the execution agent:
- Compare
LATESTmajor vsBASELINEmajor (leading integer). - Major bump → spawn an Opus agent (
model: "opus"). - Minor or patch bump → spawn a Sonnet agent (
model: "sonnet").
Spawn the agent for Steps 5–10, passing BASELINE, LATEST, and LATEST_TAG as context.
Steps 5–10 (execution agent)
Step 5: Fetch baseline and latest tarballs
Cache under .gaia/local/cache/shared/update-gaia/ (gitignored) so repeated runs don't redownload:
mkdir -p .gaia/local/cache/shared/update-gaia
for tag in "v$BASELINE" "$LATEST_TAG"; do
dir=".gaia/local/cache/shared/update-gaia/$tag"
[ -d "$dir" ] && continue
mkdir -p "$dir"
if ! gh release download "$tag" \
--repo gaia-react/gaia \
--pattern "gaia-${tag}.tar.gz" \
--dir "$dir" \
|| ! tar -xzf "$dir/gaia-${tag}.tar.gz" -C "$dir" --strip-components=1; then
rm -rf "$dir"
echo "FETCH_FAILED $tag"
fi
done
BASELINE_DIR=".gaia/local/cache/shared/update-gaia/v$BASELINE", LATEST_DIR=".gaia/local/cache/shared/update-gaia/$LATEST_TAG".
The block prints FETCH_FAILED <tag> for any tag whose download or extraction did not complete, and removes the partial cache dir so a re-run retries cleanly. On any FETCH_FAILED, stop, do not proceed to Step 6:
FETCH_FAILED $LATEST_TAG: the latest release is unreachable (network, auth, or a missing release asset). Tell the user, then re-run once it is reachable.FETCH_FAILED v$BASELINE: the baseline tarball is unavailable (older release, pre-manifest). The three-way merge needs a baseline, so stop and explain the adopter can manually cherry-pick changes by comparing their project to$LATEST_DIR.
Step 6: Load the latest manifest
LATEST_MANIFEST="$LATEST_DIR/.gaia/manifest.json"
Iterate keys of .files. For each <path>, <class> entry, apply the decision table below. Track counts per outcome for the summary.
Load the region declarations. A few shipped files carry a marker-delimited region whose body is machine-generated: a shipped command rewrites it, so an adopter who runs that command diverges from the release copy without ever hand-editing the file. The manifest declares each one under an optional top-level regions key, and Step 7 compares a declared path with its region masked out instead of whole-file.
REGION_AWARE=true
if [ "${GAIA_UPDATE_NO_REGIONS:-}" = "1" ]; then
REGION_AWARE=false
fi
REGION_DECLS='[]'
BASELINE_REGION_DECLS='[]'
if [ "$REGION_AWARE" = true ]; then
REGION_DECLS="$(jq -c 'if type == "object" and has("regions") then .regions else [] end' \
"$LATEST_MANIFEST" 2>/dev/null || echo '[]')"
BASELINE_REGION_DECLS="$(jq -c 'if type == "object" and has("regions") then .regions else [] end' \
"$BASELINE_DIR/.gaia/manifest.json" 2>/dev/null || echo '[]')"
fi
has("regions"), not .regions // []. jq's // fires on false and null as well as on absent, so "regions": null and "regions": false would collapse to [] here, and Step 7d's [ "$REGION_DECLS" != "[]" ] gate would then skip the runner entirely, leaving the wrong-typed key to render as Regions: none declared by this release. That is precisely the adopter-misleading outcome the kind: 'manifest' refusal exists to prevent, and null is the likeliest wrong shape a broken generator emits. Testing for the key's presence instead lets every wrong-typed value of that key through to the runner, which is the one component that classifies it.
One manifest shape does not reach the runner. The type == "object" half of the same guard absorbs a manifest whose top level is not an object at all: it yields [], so the Step 7d gate skips the runner and no kind: 'manifest' refusal is ever produced for it. That shape is not a region problem in the first place, because the Step 7 merge walk iterates this same manifest's .files and finds nothing there either, so the whole update, not just its region rows, is already reading a manifest it cannot use. Do not describe a non-object manifest to the adopter as a refused region; the update itself has failed by then.
Each declaration is {id, startMarker, endMarker, paths[], regenerate: {interpreter, operand, args[]}}. Build a lookup of declared path to declaration so the Step 7 walk can test each path in one step, and track the region bucket described in Step 7 as you go.
- Parse defensively. There is no manifest validation on the adopter side; this flow reads raw JSON and iterates the file map. A
regionskey that is absent or an empty list means the same thing: zero declarations, no oracle call, no regeneration, and every file classified by the unmodified whole-file comparison exactly as it is without region awareness. A key that is present but not an array loads zero declarations too but is not the same thing: it is a manifest this flow could not read, and Step 7d's runner refuses it by name (refused[],kind: 'manifest'). Step 9 owns how that refusal is rendered. A manifest whose top level is not an object takes the separate path described under the Step 6 guard above and never reaches the runner. - Ignore a malformed declaration, do not abort. A declaration that is not an object, is missing
id/startMarker/endMarker/regenerate/paths, carries an empty or whitespace-only marker, or repeats anidalready seen, is skipped: its paths take the unmodified whole-file comparison, no regeneration runs for it, and it is recorded for the Step 9 summary. Track these asregions.malformedDeclarations[]. - The off switch.
GAIA_UPDATE_NO_REGIONS=1set in the environment for one run makes the flow load zero declarations. Step 9 states that region awareness was off, and the update otherwise behaves exactly as it does without it. This is the adopter-facing remedy for a bad declaration or an oracle bug in the field: it needs no edit to the write-blocked.gaia/manifest.jsonand no flag on the command. - Dropped declarations. Any
idthe baseline manifest declared that the latest manifest does not is a dropped declaration. Its paths return to the unmodified whole-file comparison, so a conflict that region awareness had been absorbing comes back. Step 9 must name it, so the return is announced rather than discovered. Track asregions.droppedDeclarations[]. - Region awareness governs the next update, not this one. The merge walk is prose the execution agent holds from the adopter's installed copy of this file, and the walk overwrites that copy partway through the run. Nothing re-reads instruction prose out of the staged release. So the first update that installs region awareness still runs the walk that predates it, and a declared path the adopter has already regenerated still lands in
conflicts[]on that one run. Resolving the two subcommands from$LATEST_DIRdoes not shorten the lag; it only makes a newly shipped subcommand reachable at all. The release CHANGELOG announces this with a one-time regeneration the adopter runs by hand.
Step 7: Three-way merge
Apply the decision table directly, there is no CLI for this step.
Design-system sentinel check (runs before the manifest walk):
Read the established field from the working-tree wiki/concepts/Design System.md frontmatter:
design_established=false
if [ -f "wiki/concepts/Design System.md" ] && grep -qE '^established:[[:space:]]*true' "wiki/concepts/Design System.md"; then
design_established=true
fi
If design_established=true, the adopter has committed their design system. Both wiki/concepts/Design System.md and .claude/rules/design-baseline.md are effectively adopter-owned from this point forward. Add both paths to skip[] and exclude them from the manifest walk entirely: no overwrite, no conflict patch, no backup. The adopter's content is the source of truth.
If design_established=false, apply the normal decision table to both files as their manifest class dictates.
Setup:
BACKUP_DIR=".gaia-backup/$(date +%Y%m%d-%H%M%S)"
mkdir -p .gaia-merge "$BACKUP_DIR"
# Snapshot whether the installed audit-ci.yml already declares default_mode,
# captured BEFORE the Step 7c merge can write the key. The Step 10 opt-in nudge
# reads this; gating on the post-merge file state would let the merge pre-silence
# the nudge on the very run that should surface it.
had_default_mode_before_merge=false
if [ -f .gaia/audit-ci.yml ] && grep -qE '^[[:space:]]*default_mode[[:space:]]*:' .gaia/audit-ci.yml; then
had_default_mode_before_merge=true
fi
Persist had_default_mode_before_merge for Step 10.
Track seven lists plus a package.json sub-report internally (UpdateMergeReport):
{
overwrite: string[]; // owned files overwritten with latest
skip: string[]; // no change needed; left alone
merge: string[]; // clean shared/wiki-owned merges written into the working tree
add: string[]; // new files copied from latest
removed: string[]; // adopter deleted a baseline file; deletion respected, left absent
delete: string[]; // files removed upstream; surfaced but NOT auto-deleted
adopterActions: Array<{ // Step 9: documented, opt-in follow-ups the merge leaves
subject: string; // to the adopter (a dep GAIA dropped that you still have,
command?: string; // a delete[] file still present), recovered from the
changelog: string; // release CHANGELOG's adopter-action convention. Advisory.
}>;
conflicts: Array<{
path: string;
class: 'owned' | 'shared' | 'wiki-owned';
patch_path: string; // .gaia-merge/<path>.patch
}>;
packageJson: { // field-aware result for package.json (Step 7a)
applied: string[]; // managed keys GAIA changed that the adopter still tracked at the baseline pin, written to the working tree
conflicts: string[]; // managed keys GAIA changed but the adopter independently re-pinned, left as the adopter's, noted
suggestions: string[]; // managed keys GAIA added, or changed but the adopter had removed, surfaced opt-in, never applied
notes_path?: string; // .gaia-merge/package.json.notes when conflicts or suggestions exist
};
pnpmWorkspace: { // field-aware result for pnpm-workspace.yaml (Step 7b)
applied: string[]; // managed keys / overrides+allowBuilds entries GAIA changed that the adopter still tracked, written to the working tree
conflicts: string[]; // managed keys / entries GAIA changed but the adopter independently re-pinned, left as the adopter's, noted
suggestions: string[]; // managed keys / entries GAIA added, or changed but the adopter had removed, surfaced opt-in, never applied
notes_path?: string; // .gaia-merge/pnpm-workspace.yaml.notes when conflicts or suggestions exist
};
auditCiYml: { // field-aware result for .gaia/audit-ci.yml (Step 7c)
applied: string[]; // managed scalar knobs / audit_authors entries GAIA changed that the adopter still tracked, PLUS any auditors roster member GAIA added or changed that the adopter hasn't diverged (a roster addition is applied here, not suggested, see Step 7c), written to the working tree
conflicts: string[]; // knobs / entries / roster members GAIA changed but the adopter independently diverged, left as the adopter's, noted
suggestions: string[]; // scalar knobs / audit_authors entries GAIA added, or changed but the adopter had removed, surfaced opt-in, never applied
notes_path?: string; // .gaia-merge/audit-ci.yml.notes when conflicts or suggestions exist
};
regions: { // declared generated regions (Step 6 load, Step 7 oracle, Step 7d regeneration)
// A distinct bucket, NOT an extension of adopterActions[]. That array's
// `changelog` field is mandatory and is populated only from
// convention-anchored CHANGELOG bullets; a regeneration failure has no
// changelog source, so it does not fit. Do not merge the two.
awarenessOff: boolean; // GAIA_UPDATE_NO_REGIONS=1 was set for this run
declarationsLoaded: number;
droppedDeclarations: string[]; // region ids the baseline declared and latest does not
fallbacks: Array<{ // declared paths region awareness did not normalize as intended
path: string;
reason: 'absent-markers' | 'malformed-markers' | 'oracle-failed';
}>;
malformedDeclarations: Array<{index: number; reason: string}>;
regen?: RegenRegionsReport; // absent when Step 7d did not run
rewrittenPaths: string[]; // regen.ran[].rewrote, flattened
supersededPatches: string[]; // pre-existing .gaia-merge patches for declared paths
unregeneratedPaths: string[]; // every declared path of a skipped / refused / failed region
};
}
Iterate every <path>: <class> entry in $LATEST_MANIFEST's .files object, except package.json, pnpm-workspace.yaml, and .gaia/audit-ci.yml, all three are handled field-aware below (package.json in Step 7a, pnpm-workspace.yaml in Step 7b, .gaia/audit-ci.yml in Step 7c). A whole-file cmp/diff can't separate adopter identity and intentional removals from the real upstream delta; pnpm-workspace.yaml is a mixed file (GAIA-authored supply-chain / resolution settings plus adopter-extensible overrides and allowBuilds maps) that drifts the moment an adopter adds one override; and .gaia/audit-ci.yml is a mixed file (GAIA-authored scalar knobs, the adopter-extensible audit_authors login=mode string, and the auditors roster list, which is GAIA-authored and adopter-extensible at once) that drifts the moment a developer commits one per-author entry or a roster member is added on either side. Skip all three during this walk.
Let A = working-tree <path>, B = $BASELINE_DIR/<path>, L = $LATEST_DIR/<path>. Use cmp -s for equality; mkdir -p before writing.
Match in declared order, first matching row wins. Baseline presence (B) is the discriminator for a missing working-tree file: A missing with B also missing means the file is genuinely new in the latest release and gets added; A missing with B present means the adopter deliberately deleted a file that shipped in their baseline, so the deletion is respected and the file is left absent. The B ≅ L row (no upstream change) short-circuits every class before any conflict is declared, an adopter-drifted file the release never touched has nothing to merge, so it stays as-is and emits no patch.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 23
- Forks
- 4
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
update-gaia- Source
- github.com/gaia-react/gaia