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 "$(bash .gaia/scripts/branch-name-lib.sh name chore update-gaia)"
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.
| 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[] |
Declared generated regions. A path that appears in one of the Step 6 declarations takes a single oracle call in place of the whole-file cmp -s comparisons, so a divergence confined to the machine-generated region does not read as adopter drift.
Presence triage still runs first, and it is unchanged. The first two rows of the table above (A missing with B missing → add[]; A missing with B present → deletion respected, removed[]) and the owned + B missing row resolve before the oracle is ever consulted. A path the adopter deleted, a path the release no longer ships, and a path absent from the baseline are settled there: no oracle call, and no regeneration in Step 7d either.
For a declared path that survives triage:
region_json="$("$LATEST_DIR/.gaia/cli/gaia" update merge-region \
--baseline "$BASELINE_DIR/<path>" \
--latest "$LATEST_DIR/<path>" \
--current "<path>" \
--start-marker "<declaration startMarker>" \
--end-marker "<declaration endMarker>" \
--json 2>/dev/null)" || region_json=''
Command resolution. Three facts, stated here once. Step 7d restates the rules it applies; Step 9 points here for the reason.
- A CLI subcommand resolves from
$LATEST_DIR, never from the working-tree copy of the CLI. This covers the two region-aware CLI calls: the region oracle above and Step 7d'sregen-regionsrunner. An adopter whose installed binary predates the subcommand cannot reach it any other way, and that is the only reason the rule exists. - A regeneration program named by a declaration's
argvis the exception, and resolves from the adopter's own tree. Step 7d passes--root .precisely so the runner executes the copy of the program the merge walk just wrote. A region's body is derived from the adopter's post-merge tree, so resolving that program from the release copy would be the defect, not the rule. - A CLI invocation is never printed as a follow-up command for the adopter, in either form. The release-resolved form points into the update cache, which a later run's Step 4b prune removes, so it is not a path the adopter can keep; the working-tree form is banned by rule 1. Step 9 item 3 states what it prints instead.
Then read .verdict and take the matching row:
verdict |
Row it takes |
|---|---|
no-upstream-change |
No-op, skip[] |
no-adopter-drift |
Back up A to $BACKUP_DIR/<path>; copy L → <path>. owned → overwrite[], shared / wiki-owned → merge[] |
already-latest |
No-op, skip[] |
conflict |
Write the normalized patch (below), conflicts[] |
These are the same rows the table above produces, in the same order, applied to normalized content instead of raw content. There is no new row.
The normalized conflict patch. The oracle emits the normalized bodies because nothing else in this flow can parse a region. Build the patch from them, never from the raw files:
printf '%s' "$region_json" | jq -r '.normalized.current' > "$tmp_current"
printf '%s' "$region_json" | jq -r '.normalized.latest' > "$tmp_latest"
diff -u -L "<path>" -L "<path> (latest)" "$tmp_current" "$tmp_latest" \
> ".gaia-merge/<path>.patch"
rm -f "$tmp_current" "$tmp_latest"
GAIA's conflict patches are advisory reading: the flow reads them and walks the adopter through the decision per file (see "Handling results" below), so a normalized patch that no longer applies cleanly as a machine patch is not a defect. What the adopter reads is exactly the divergence they caused, and no line of it comes from either side's region body.
Marker anomalies. Read .markers and record a fallback for the Step 9 summary under a reason that keeps two distinct states apart:
.markers.bailedistrue→ reasonmalformed-markers. Some side's marker pair is duplicated, unbalanced, or out of order, so the oracle normalized no side, its verdict is the row the unmodified whole-file comparison produces, and it still exited 0. Report the path..markers.bailedisfalseand some side reports"scan": "absent"→ reasonabsent-markers. That side carries no marker pair at all, which is the expected pre-region state, not a defect. Normalization still applied per side. Report it as informational and keep it distinct from a malformed one.
Oracle failure. When the command exits non-zero (region_json empty: a CLI predating the subcommand, an unreadable file, a missing flag), fall back to the unmodified whole-file comparison for that path. Never fall back to a forced conflict patch. Record reason oracle-failed, and say plainly in Step 9 what it means: that path has returned to its pre-region behavior, which for an adopter carrying a region-only divergence is exactly the conflict region awareness exists to remove. Do not present the fallback as harmless.
Superseded patches. Before the walk, note any .gaia-merge/<declared path>.patch left over from a prior run. Step 4b deliberately never deletes a populated .gaia-merge/, so a stale patch from a pre-region run survives and would send the adopter hand-resolving a region this run handles for them. Record these in regions.supersededPatches[] and name them in Step 9 as superseded.
After iterating the manifest, collect deletions: files present under $BASELINE_DIR with no corresponding key in $LATEST_MANIFEST's .files. Split each by working-tree presence: a file still present in the working tree goes to delete[] (surfaced for the user to confirm, never auto-removed); a file the adopter has already removed (working-tree absent) is already reconciled, so record it in removed[] count-only with no prompt. This mirrors the per-key table's delete vs removed split for upstream-dropped files.
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>.patchand walk the user through the decision per file.packageJson: populated by Step 7a. Theapplied[]keys are already written to the working tree (report counts only); walk the user throughconflicts[](re-pinned keys) and mentionsuggestions[](added / removed-then-changed deps) as opt-in, both detailed in.gaia-merge/package.json.notes.pnpmWorkspace: populated by Step 7b. Same shape and handling aspackageJson, detailed in.gaia-merge/pnpm-workspace.yaml.notes.auditCiYml: populated by Step 7c. Same shape and handling aspackageJson, detailed in.gaia-merge/audit-ci.yml.notes.regions: populated by Step 6 (declarations), this walk (verdicts and fallbacks), and Step 7d (regeneration). Report counts and the named follow-ups in Step 9; there is no notes file and no per-file narrative here beyond what Step 9 prints.
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. - Scalar / whole-value keys, merged as a single value:
packageManager.
Resolution, overrides, and build-approval (allowBuilds) settings live in pnpm-workspace.yaml, merged field-aware in Step 7b, not here. pnpm 11 reads them only from there; the package.json pnpm field and a top-level overrides key are not pnpm-managed package.json sections.
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.
The last row is the load-bearing one for Step 9: GAIA removed k (in B, not in L) but the adopter still has it, so the merge leaves it (the adopter's choice). That no-op is invisible by design, the adopter is never told GAIA dropped the dependency. Step 9 cross-references these GAIA-removed-but-still-present deps against the release CHANGELOG's adopter-action convention and offers an opt-in pnpm remove suggestion. The merge itself never removes the dependency; only the user can.
Compute the per-key verdicts with jq (covers the object sections):
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"]] 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 7b: Field-aware pnpm-workspace.yaml merge
pnpm-workspace.yaml is classed shared, but it is a mixed file, so a whole-file three-way merge produces the same noise package.json does. It carries GAIA-authored settings (minimumReleaseAge, minimumReleaseAgeStrict, trustPolicy, trustPolicyExclude
…(truncated)