| name | update-gaia |
| description | Pull 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". |
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 as shared.
- 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/cli/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/VERSION found — this project was not scaffolded from GAIA, or the marker was deleted. Run /gaia-init on a fresh create-gaia scaffold 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
LATEST major vs BASELINE major (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 seven lists plus a package.json sub-report internally (UpdateMergeReport):
{
overwrite: string[];
skip: string[];
merge: string[];
add: string[];
removed: string[];
delete: string[];
conflicts: Array<{
path: string;
class: 'owned' | 'shared' | 'wiki-owned';
patch_path: string;
}>;
packageJson: {
applied: string[];
conflicts: string[];
suggestions: string[];
notes_path?: string;
};
}
Iterate every <path>: <class> entry in $LATEST_MANIFEST's .files object, except package.json — that one path is handled field-aware in Step 7a below (whole-file cmp/diff can't separate adopter identity and intentional dep removals from the real upstream delta). Skip it 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.
| Class | Condition | Action | List |
|---|
| any | A missing and B missing (genuinely new in latest) | Copy L → <path> | add[] |
| any | A missing and B exists (adopter deleted it) | No-op — respect the deletion, leave absent | removed[] |
owned | B missing (A exists; release newly owns this path) | Back up A to $BACKUP_DIR/<path>; copy L → <path> | overwrite[] |
| any | B ≅ L (no upstream change) | No-op | skip[] |
| any | A ≅ B (no adopter drift) | Back up A to $BACKUP_DIR/<path>; copy L → <path> | owned → overwrite[]; shared / wiki-owned → merge[] |
| any | A ≅ L (adopter already at latest) | No-op | skip[] |
owned | A ≠ B and A ≠ L | diff -u "$A" "$L" > .gaia-merge/<path>.patch | conflicts[] |
shared / wiki-owned | A ≠ B and A ≠ L | diff -u "$A" "$L" > .gaia-merge/<path>.patch | conflicts[] |
After iterating the manifest, collect deletions: files present under $BASELINE_DIR that have no corresponding key in $LATEST_MANIFEST's .files. Add each to delete[]. Do not remove them from the working tree.
Handling results:
overwrite[], skip[], merge[], add[], removed[]: report counts only — no per-file narrative. Do not read file bytes.
delete[]: ask the user before removing each path.
conflicts[]: read the patch at .gaia-merge/<path>.patch and walk the user through the decision per file.
packageJson: populated by Step 7a. The applied[] keys are already written to the working tree (report counts only); walk the user through conflicts[] (re-pinned keys) and mention suggestions[] (added / removed-then-changed deps) as opt-in, both detailed in .gaia-merge/package.json.notes.
Step 7a: Field-aware package.json merge
package.json is classed shared, but a whole-file three-way merge produces pure noise for it: every adopter diverges it at init (gaia-init rewrites name / description / author and resets version), and GAIA bumps its own version on every release — so A ≠ B, A ≠ L, and B ≠ L all hold on every release, and the generic table emits a full-file conflict patch dominated by identity fields no adopter wants from GAIA. Merge it at JSON-key granularity instead, acting only on the genuine upstream delta B → L.
Let A = working-tree package.json, B = $BASELINE_DIR/package.json, L = $LATEST_DIR/package.json.
Adopter-owned keys — never compared, merged, or patched. Every top-level key except the managed sections below is the adopter's, left exactly as-is: name, version, description, author, private, type, bin, sideEffects, and anything else. Identity drift is invisible to this step.
Managed sections — three-way merged per entry:
- Object sections, merged per entry key:
dependencies, devDependencies, scripts, engines, pnpm.overrides, top-level overrides.
- Scalar / whole-value keys, merged as a single value:
packageManager, and any non-overrides key under pnpm (e.g. onlyBuiltDependencies).
For each managed entry key k (within its section), with Bk / Lk / Ak its value in baseline / latest / adopter:
Condition on k | Meaning | Action | Bucket |
|---|
in B and L, Bk == Lk | GAIA didn't change it | No-op. The adopter's value stands — kept, re-pinned, or removed. | — |
in B and L, Bk != Lk, adopter has k and Ak == Bk | GAIA changed the pin; adopter still at baseline | Apply Lk to the working tree | applied[] |
in B and L, Bk != Lk, adopter has k and Ak != Bk | GAIA changed it; adopter re-pinned independently | Conflict. Leave Ak; note both pins. Never silently override an adopter pin. | conflicts[] |
in B and L, Bk != Lk, adopter removed k | GAIA changed a dep the adopter dropped | Suggestion. Do not re-add. Note as opt-in. | suggestions[] |
in L, not in B | GAIA added it | Suggestion. Do not auto-insert. Note as opt-in. | suggestions[] |
in B, not in L | GAIA removed it | If the adopter still has k, leave it (adopter's choice). | — |
The load-bearing row is the first one: a dependency the adopter removed (present in B, absent from A) is never re-added unless GAIA itself changed it this release and the adopter opts in. The default everywhere is to respect the adopter's value. This is the JSON-key analog of the file-level "respect adopter deletions" rule the generic table already enforces.
Compute the per-key verdicts with jq (covers the object sections including nested pnpm.overrides):
jq -n \
--slurpfile a package.json \
--slurpfile b "$BASELINE_DIR/package.json" \
--slurpfile l "$LATEST_DIR/package.json" '
($a[0]) as $A | ($b[0]) as $B | ($l[0]) as $L
| [["dependencies"],["devDependencies"],["scripts"],["engines"],["overrides"],["pnpm","overrides"]] as $sections
| [ $sections[] as $sp
| (($B | getpath($sp)) // {}) as $bs
| (($L | getpath($sp)) // {}) as $ls
| (($A | getpath($sp)) // {}) as $as
| (($bs + $ls) | keys_unsorted | unique)[] as $k
| { section: ($sp | join(".")), key: $k, baseline: $bs[$k], latest: $ls[$k], adopter: $as[$k],
verdict:
(if ($bs | has($k)) and ($ls | has($k)) then
(if $bs[$k] == $ls[$k] then "noop"
elif ($as | has($k) | not) then "suggest-removed"
elif $as[$k] == $bs[$k] then "apply"
else "conflict" end)
elif ($ls | has($k)) then "suggest-add"
else "noop" end) }
| select(.verdict != "noop") ]'
Apply the same rule to the scalar packageManager by hand: B == L → no-op; B != L and A == B → apply; B != L and A != B → conflict; in L only → suggest-add; in B only → no-op.
Apply clean changes (applied[]): edit the single line for k in the working-tree package.json so its value becomes Lk, using the Edit tool — preserve the adopter's formatting and key order. Do not reserialize the file with jq write-back; that reorders keys and buries the real change in noise.
Record conflicts + suggestions: if either bucket is non-empty, write a human-readable .gaia-merge/package.json.notes listing, per key: the section, the key, the adopter / baseline / latest values, and the recommended action. Set notes_path. This file is informational — the adopter reconciles re-pin conflicts by hand and accepts or ignores suggestions. It is not a diff -u patch and is not added to the file-level conflicts[] bucket.
Net effect:
- Version-only release (no managed-key delta) → identity ignored, zero applied/conflicts/suggestions → clean skip, no notes file. Fixes the every-release noise.
- Dep-bump release → only the entries GAIA actually changed (and that the adopter still tracks) are applied; re-pin conflicts and added/removed-dep suggestions go to the notes file — never re-adding a dependency the adopter removed, never overwriting an adopter pin.
Step 8: Bump .gaia/VERSION
Only after the full walk completes without errors:
echo "$LATEST" > .gaia/VERSION
If the walk was aborted mid-way (user cancels, disk error), leave .gaia/VERSION at BASELINE so a re-run resumes cleanly. Any files already overwritten are safe — their new state is recorded via .gaia-backup/.
Also copy .gaia/manifest.json from $LATEST_DIR/.gaia/manifest.json into the project so the next /update-gaia has the right baseline.
Then count open PRs whose GAIA-Audit trailer is stamped with the old version — these will be invalidated by the version bump and will re-run the full CI audit on their next push:
INVALIDATED_COUNT=0
if command -v gh >/dev/null 2>&1; then
OLD_VERSION="$BASELINE"
pr_list=$(gh pr list --state open --json number,headRefOid 2>/dev/null || true)
if [ -n "$pr_list" ]; then
while IFS= read -r sha; do
[ -z "$sha" ] && continue
msg=$(git -C "$repo_root" log -1 --format='%B' "$sha" 2>/dev/null || true)
if echo "$msg" | grep -qE "^GAIA-Audit:[[:space:]]+${OLD_VERSION}[[:space:]]+[0-9a-f]{40}"; then
INVALIDATED_COUNT=$((INVALIDATED_COUNT + 1))
fi
done < <(echo "$pr_list" | jq -r '.[].headRefOid' 2>/dev/null || true)
fi
else
INVALIDATED_COUNT="unknown"
fi
Persist INVALIDATED_COUNT for Step 9.
Step 8b: Migrate SPEC artifacts to per-SPEC folders
SPEC artifacts live in per-SPEC folders: .gaia/local/specs/<spec_id>/SPEC.md (archived: .gaia/local/specs/archived/<spec_id>/). .gaia/local/specs/** is adopter-owned data the three-way merge never touches, so the freshly-updated runbooks reference the folder layout while the adopter's existing specs may still be flat files. Run the migration script to fold any flat specs into the folder layout. It is idempotent — a no-op when specs are already foldered or none exist — so run it unconditionally:
spec_folderize_out=$(bash .specify/extensions/gaia/lib/spec-folderize.sh 2>&1)
spec_folderize_rc=$?
Parse the result for the Step 9 summary:
- The script writes
spec-folderize: migrated <n> SPEC artifact(s) ... to stderr on a successful migration. Persist <n> as SPECS_MIGRATED. On a no-op (already foldered or no specs) the script exits 0 with a nothing to migrate line — set SPECS_MIGRATED=0.
- Exit code
4 is a migration conflict: a flat SPEC-<id>.md and a folder <id>/SPEC.md both exist for the same id. The script names both conflicting paths on stderr and changes nothing. Do not swallow this and do not auto-resolve it. Capture the conflicting ids/paths from $spec_folderize_out, set SPECS_MIGRATED="conflict", and surface it in Step 9 as a blocking action item the user must reconcile by hand.
- Any other non-zero exit (
2 usage, 3 unresolvable repo root) is a script-invocation error: surface $spec_folderize_out to the user and treat it as a blocking action item.
Step 9: Summary
Print a table:
GAIA update: v$BASELINE → $LATEST_TAG
Overwritten: <n>
Added: <n>
Removed: <n> (files you deleted; left absent, deletion respected)
Skipped: <n>
Conflicts: <n> (see .gaia-merge/)
Deleted: <n> (removed upstream; surfaced, not auto-deleted)
package.json: <a> applied, <c> conflicts, <s> suggestions (field-aware; see .gaia-merge/package.json.notes)
Backed up: <n> (see .gaia-backup/<timestamp>/)
Specs migrated: <n> (flat .gaia/local/specs files folded into per-SPEC folders)
Trailer invalidations: <n> (open PRs stamped v$BASELINE will re-audit on next push)
When all three package.json counts are zero, render that row as package.json: no managed-key changes (clean skip) and omit the notes reference.
Use SPECS_MIGRATED for the Specs migrated row. If it is "conflict", emit the row as Specs migrated: conflict — see action item below and, after the table, print a blocking action item naming the conflicting ids/paths from $spec_folderize_out:
Action required: SPEC migration could not complete. A flat SPEC-<id>.md and a folder <id>/SPEC.md exist for the same id: . Resolve by hand (keep one, remove the other), then re-run bash .specify/extensions/gaia/lib/spec-folderize.sh to finish the migration. The freshly-updated runbooks reference the folder layout, so leaving this unresolved breaks SPEC tooling.
If INVALIDATED_COUNT is "unknown", emit instead:
Trailer invalidations: unknown (gh unavailable — open PRs with GAIA-Audit stamps may re-audit)
If INVALIDATED_COUNT is greater than 0, also print after the table:
Note: $INVALIDATED_COUNT open PR(s) carry a GAIA-Audit trailer stamped with v$BASELINE. On their next push, CI re-runs the full audit (one extra billing cycle per PR). This is intentional — a newer GAIA agent version may catch issues the prior version missed. To minimize re-audit churn, merge or close these PRs before updating GAIA.
Then bust the update-check cache so the SessionStart prompt reflects the post-update state on the next session. Use the Write tool to overwrite .gaia/cache/update-check.json with gaiaCurrent set to $LATEST, gaiaLatest set to $LATEST, gaiaHasUpdate set to false, outdatedCount set to 0, and checkedAt set to the current Unix timestamp. If the cache file does not exist, skip this step.
The next SessionStart hook fires the background refresher; the session after that sees no GAIA update available.
Step 10: Next steps for the user
Tell the user:
- Review any conflict patches in
.gaia-merge/ and reconcile manually. Delete the patch file once resolved. If .gaia-merge/package.json.notes exists, reconcile the re-pin conflicts and decide on the dep suggestions, then delete it.
- If the
package.json merge applied any dependency or packageManager change, run pnpm install to sync pnpm-lock.yaml before the quality gate.
- Run the quality gate per
wiki/decisions/Quality Gate.md to verify the updated code still passes.
- Inspect the diff (
git diff) before committing.
- When satisfied, commit with
chore: update GAIA to $LATEST_TAG.
Do not auto-commit on behalf of the user — they need to review the changes first.
Step 11: Open a pull request
After the Step 10 commit lands, /update-gaia must not leave the branch stranded — open a PR so the update can be reviewed and merged.
Push the branch and open a PR, but only if it has no open PR already — a re-run of /update-gaia on the same branch updates the existing PR instead of duplicating it:
branch="$(git branch --show-current)"
if [ "$(git rev-list --count main.."$branch" 2>/dev/null || echo 0)" -eq 0 ]; then
echo "Nothing committed ahead of main — commit the update (Step 10) before opening a PR."
elif ! git push -u origin "$branch"; then
echo "Push failed — resolve the push error, then open the PR manually: $branch → main."
else
existing="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty' 2>/dev/null)"
if [ -n "$existing" ]; then
echo "PR #$existing already open for $branch — pushed the new commit to it."
else
gh pr create --base main --head "$branch" \
--title "chore: update GAIA to $LATEST_TAG" \
--body "Pulls GAIA $LATEST_TAG into the project. Per-file outcomes are in the update summary above."
fi
fi
If gh is unavailable or errors, tell the user to open the PR manually: $branch → main.
Step 12: Flag a stale CI audit workflow
.github/workflows/code-review-audit.yml is not synced by /update-gaia — it installs and updates only via /setup-gaia-ci. A project that enabled GAIA CI on an older release therefore keeps whatever audit workflow shipped then, frozen, even after this update pulls a newer template. A stale workflow still audits in-scope PRs correctly, but an older copy may not stamp the GAIA-Audit status on out-of-scope (docs/metadata-only) PRs — which includes the update PR just opened. The merge gate's out-of-scope bypass keeps that PR mergeable regardless, but the workflow is worth refreshing.
Probe for drift and advise only when the installed workflow is behind:
audit_drift="$(.gaia/cli/gaia setup-ci check-audit-drift --json 2>/dev/null \
| jq -r '.state // "unknown"' 2>/dev/null || echo unknown)"
if [ "$audit_drift" = "drifted" ]; then
echo "Heads up: .github/workflows/code-review-audit.yml is out of date vs the $LATEST_TAG template. Run /setup-gaia-ci to refresh it so the CI audit stamps the GAIA-Audit status correctly (including on out-of-scope PRs). The merge gate's out-of-scope bypass keeps this docs-only update PR mergeable in the meantime."
fi
in_sync means nothing to do; missing means GAIA CI is not installed (the merge gate falls back to the local code-review-audit agent, or the out-of-scope bypass for docs/metadata-only PRs), so stay silent. Only drifted warrants the nudge.