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. Overwrites silently if unchanged from baseline; prompts if drifted.shared— GAIA seeds, you customize. Emits a.gaia-merge/patch for manual resolution on drift.wiki-owned— GAIA-seeded concept/decision/module wiki pages. Same drift handling asshared.- 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.
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):
common_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
if [ -n "$common_dir" ]; then
case "$common_dir" in
/*) absolute_common_dir="$common_dir" ;;
*) absolute_common_dir="$(pwd)/$common_dir" ;;
esac
main_root="$(cd "$(dirname "$absolute_common_dir")" 2>/dev/null && pwd)"
current_root="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -n "$main_root" ] && [ -n "$current_root" ] && [ "$main_root" != "$current_root" ]; then
cached_line="Cached state unavailable on main; symlinks may be broken — run \`gaia setup link-worktree\` to repair."
cache_file="$main_root/.gaia/cache/update-check.json"
if [ -f "$cache_file" ] && command -v jq >/dev/null 2>&1; then
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)"
if [ -n "$gaia_current" ] && [ -n "$gaia_latest" ]; then
update_phrase="not-available"
if [ "$gaia_has_update" = "true" ]; then update_phrase="available"; fi
cached_line="Cached on main: GAIA $gaia_current installed; latest $gaia_latest (update $update_phrase)."
fi
fi
cat <<EOF
/update-gaia must run from the main checkout, not a worktree.
Worktree: $current_root
Main checkout: $main_root
$cached_line
Run \`cd $main_root\` then re-invoke /update-gaia.
EOF
exit 1
fi
fi
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, create and switch to a new branch:
git checkout -b chore/update-gaia-$(date +%Y-%m-%d-%H-%M)
Otherwise 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→ print "You are up to date on GAIA v$BASELINE." and exit. - 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
Fetch the release body for LATEST_TAG:
gh release view "$LATEST_TAG" --repo gaia-react/gaia --json body --jq .body
Print the notes 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.
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/cache/ (gitignored) so repeated runs don't redownload:
mkdir -p .gaia/cache
for tag in "v$BASELINE" "$LATEST_TAG"; do
dir=".gaia/cache/$tag"
if [ ! -d "$dir" ]; then
mkdir -p "$dir"
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
fi
done
BASELINE_DIR=".gaia/cache/v$BASELINE", LATEST_DIR=".gaia/cache/$LATEST_TAG".
If the baseline tarball is unavailable (older release, pre-manifest), stop and explain — the adopter can manually cherry-pick changes by comparing their project to the $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.
Step 7: Three-way merge
Apply the decision table directly — there is no CLI for this step.
Setup:
BACKUP_DIR=".gaia-backup/$(date +%Y%m%d-%H%M%S)"
mkdir -p .gaia-merge "$BACKUP_DIR"
Track six lists 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
delete: string[]; // files removed upstream; surfaced but NOT auto-deleted
conflicts: Array<{
path: string;
class: 'owned' | 'shared' | 'wiki-owned';
patch_path: string; // .gaia-merge/<path>.patch
}>;
}
Iterate every <path>: <class> entry in $LATEST_MANIFEST's .files object:
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. The first row applies to every class and runs before any class-specific check; it closes the silent-skip gap where the manifest declares a file but the adopter's tree never had it (in particular, the shared / wiki-owned B ≅ L row below would otherwise short-circuit and leave the file missing on disk).
| Class | Condition | Action | List |
|---|---|---|---|
| any | A missing and L exists | Copy L → <path> | add[] |
owned | B missing (new file) | Copy L → <path> | add[] |
owned | A ≅ B (no adopter drift) | Back up A to $BACKUP_DIR/<path>; copy L → <path> | overwrite[] |
owned | A ≅ L (adopter already current) | No-op | skip[] |
owned | A ≠ B and A ≠ L | diff -u "$A" "$L" > .gaia-merge/<path>.patch | conflicts[] |
shared / wiki-owned | B ≅ L (no upstream change) | No-op | skip[] |
shared / wiki-owned | A ≅ B (no adopter drift) | Back up A to $BACKUP_DIR/<path>; copy L → <path> | merge[] |
shared / wiki-owned | A ≅ L (adopter already at latest) | No-op | skip[] |
shared / `wiki- |