Claude Skill

update-gaia

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 GAI

LLM Mart · 0 points · 15 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download gaia-react-gaia-.claude_skills_update-gaia-e186a33.zip · 30 KB
Part of gaia-react/gaia — 26 skills

Install

skills CLI npx skills add https://github.com/gaia-react/gaia/tree/main/.claude/skills/update-gaia
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gaia-react-gaia@llmmart
Git git clone https://github.com/gaia-react/gaia.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole gaia-react/gaia collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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/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:
    • First, detect an interrupted prior run. If .gaia/VERSION differs from the last commit (git diff --quiet HEAD -- .gaia/VERSION exits non-zero, this catches a staged or unstaged bump), a previous /update-gaia already 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 to v$LATEST is already applied to the working tree but not committed. Review git diff and commit it (Step 10 guidance), or run git checkout -- .gaia/VERSION to discard the bump and re-run /update-gaia to start over. Exit.
    • Otherwise 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

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 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/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 regions key 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 an id already 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 as regions.malformedDeclarations[].
  • The off switch. GAIA_UPDATE_NO_REGIONS=1 set 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.json and no flag on the command.
  • Dropped declarations. Any id the 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 as regions.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_DIR does 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.

  1. 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's regen-regions runner. An adopter whose installed binary predates the subcommand cannot reach it any other way, and that is the only reason the rule exists.
  2. A regeneration program named by a declaration's argv is 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.
  3. 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.bailed is true → reason malformed-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.bailed is false and some side reports "scan": "absent" → reason absent-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>.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.
  • pnpmWorkspace: populated by Step 7b. Same shape and handling as packageJson, detailed in .gaia-merge/pnpm-workspace.yaml.notes.
  • auditCiYml: populated by Step 7c. Same shape and handling as packageJson, 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, minimumReleaseAgeExclude, publicHoistPattern, savePrefix, strictPeerDependencies) and adopter-extensible maps (overrides, allowBuilds). pnpm 11 reads dependency overrides and build approvals only from here, so any adopter who adds a single override drifts the file and eats a full-file conflict patch on every release that touches it. Merge it at YAML-key / map-entry granularity instead, acting only on the genuine upstream delta B → L.

Let A = working-tree pnpm-workspace.yaml, B = $BASELINE_DIR/pnpm-workspace.yaml, L = $LATEST_DIR/pnpm-workspace.yaml.

Presence triage first (older baselines predate pnpm 11 and have no pnpm-workspace.yaml). Match the first row that applies; only the last row runs the field-aware merge:

Condition Action
L missing Upstream dropped the file; fold into the Step 7 deletion sweep (delete[]). Skip 7b.
A missing and B missing Genuinely new; copy L → pnpm-workspace.yaml. Record in add[]. Skip 7b.
A missing and B exists Adopter deleted it; respect the deletion, leave absent. Record in removed[]. Skip 7b.
A exists and B missing No baseline to field-merge against; diff -u A L > .gaia-merge/pnpm-workspace.yaml.patch. Surface as a conflict. Skip 7b.
A, B, L all exist Run the field-aware merge below.

Compute the per-key / per-entry verdicts with the bundled CLI (it parses all three files with js-yaml and never writes the YAML):

.gaia/cli/gaia update merge-workspace \
  --baseline "$BASELINE_DIR/pnpm-workspace.yaml" \
  --latest "$LATEST_DIR/pnpm-workspace.yaml" \
  --current pnpm-workspace.yaml \
  --json

The command exits non-zero with a structured error if any file is missing or not valid YAML (for example the adopter introduced a syntax error). On a non-zero exit, fall back to a whole-file conflict patch (diff -u A L > .gaia-merge/pnpm-workspace.yaml.patch) and surface it as a conflict; do not proceed with the JSON path.

The JSON report is { applied, conflicts, suggestions }. Each item is { kind: 'key' | 'entry', section?, key, baseline?, latest?, adopter?, reason? }. The CLI iterates only keys(B) ∪ keys(L) per managed key and per overrides / allowBuilds entry, so an adopter-only override or build approval is never visited, never clobbered. The GAIA-managed keys listed above are compared whole-value; the two map sections are compared per entry. Both use the identical verdict table as Step 7a (apply / conflict / suggest-add / suggest-removed).

Apply clean changes (applied[]): for each item, edit the working-tree pnpm-workspace.yaml so the key's (or entry's) value becomes latest, using the Edit tool. Preserve the file's comments, key order, and quote style; change only the value text. Do not reserialize the file (js-yaml dump strips every comment). A whole-value list change replaces the list block; a scalar or map-entry change edits the single line.

Record conflicts + suggestions: if either bucket is non-empty, write a human-readable .gaia-merge/pnpm-workspace.yaml.notes listing, per item: the section (if any), 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:

  • No managed-key delta (overrides / allowBuilds / settings unchanged by the release) → zero applied/conflicts/suggestions → clean skip, no notes file.
  • Settings or override change → only the keys / entries GAIA actually changed (and that the adopter still tracks) are applied; re-pin conflicts and added/removed suggestions go to the notes file, never re-adding a key the adopter removed, never overwriting an adopter override.

Step 7c: Field-aware .gaia/audit-ci.yml merge

.gaia/audit-ci.yml is classed shared, but it is a mixed file like pnpm-workspace.yaml, carrying three kinds of content: GAIA-authored scalar knobs (gate_label, budget_seconds, max_turns, push_fixes, default_mode, override_label, the retrigger_workflows list); the adopter-extensible audit_authors string, a space-separated login=mode list each developer appends their own pair to via /setup-gaia; and the auditors roster list, GAIA-authored and adopter-extensible at once (GAIA ships and updates its own members, an adopter can add their own alongside them). A whole-file three-way merge emits a full-file conflict patch the moment one developer commits an audit_authors entry or an adopter adds their own roster member, so merge it at YAML-key / per-entry granularity instead, acting only on the genuine upstream delta B → L.

Let A = working-tree .gaia/audit-ci.yml, B = $BASELINE_DIR/.gaia/audit-ci.yml, L = $LATEST_DIR/.gaia/audit-ci.yml.

Presence triage first (older baselines predate this file): identical to Step 7b's triage table, substituting .gaia/audit-ci.yml for pnpm-workspace.yaml (its fallback patch is .gaia-merge/audit-ci.yml.patch, and each "Skip 7b" reads "Skip 7c"). Only the last row (A, B, L all exist) runs the field-aware merge below.

Compute the per-key / per-entry verdicts with the bundled CLI (it parses all three files with js-yaml and never writes the YAML):

.gaia/cli/gaia update merge-audit-ci \
  --baseline "$BASELINE_DIR/.gaia/audit-ci.yml" \
  --latest "$LATEST_DIR/.gaia/audit-ci.yml" \
  --current .gaia/audit-ci.yml \
  --json

The command exits non-zero with a structured error if any file is missing or not valid YAML. On a non-zero exit, fall back to a whole-file conflict patch (diff -u A L > .gaia-merge/audit-ci.yml.patch) and surface it as a conflict; do not proceed with the JSON path.

The JSON report is { applied, conflicts, suggestions }. Each item is { kind: 'key' | 'entry', section?, key, baseline?, latest?, adopter?, reason? }. The CLI iterates only keys(B) ∪ keys(L) per managed scalar key, per audit_authors login, and per auditors roster member name, so an adopter-only developer entry or an adopter-added roster member is never visited, never clobbered. The seven managed knobs are compared whole-value; audit_authors is parsed into per-login entries (the login compared case-insensitively, matching the resolver's case-fold) and compared per login; auditors is parsed into per-member entries (the name compared exactly, not case-folded, a member name is an agent filename, not a login) and each member's whole mapping (globs, scope, push_fixes, default) is compared and applied as a unit, never glob-by-glob. All three sections use the identical verdict table (apply / conflict / suggest-add / suggest-removed), with one deliberate exception: for auditors only, a member present in latest and absent from baseline resolves to apply, not suggest-add. Every other section treats that row as an opt-in suggestion the adopter must act on; a roster member is a capability the adopter cannot opt into if it never arrives, so a new GAIA-authored member (e.g. code-audit-github-workflows) is written straight into the adopter's file rather than surfaced as something they might miss. An adopter's own roster member is still never visited, and an adopter's edit to a GAIA-authored member is still a conflict, not silently overwritten.

Apply clean changes (applied[]): for each item, edit the working-tree .gaia/audit-ci.yml so the key's (or entry's) value becomes latest, using the Edit tool. Preserve the file's comments, key order, and quote style; change only the value text. For an audit_authors entry item, edit that login's =mode token inside the existing audit_authors string; do not rewrite the whole string or reorder the other developers' entries. For an auditors roster item: if the member already exists in the working tree, edit its globs: / scope: / push_fixes: / default: fields in place to match latest; if it is a new member (the added-row exception above), append a whole new - name: ... list item to the auditors: list, matching the indentation and key order of its neighbors. Do not reserialize the file.

Record conflicts + suggestions: if either bucket is non-empty, write a human-readable .gaia-merge/audit-ci.yml.notes listing, per item: the section (if any), the key, the adopter / baseline / latest values, and the recommended action. Set notes_path. This file is informational; it is not a diff -u patch and is not added to the file-level conflicts[] bucket.

Net effect:

  • No managed-key delta (knobs, any shipped audit_authors entries, and the roster all unchanged by the release) → zero applied/conflicts/suggestions → clean skip, no notes file. An adopter whose only divergence is their committed audit_authors entries or their own added roster member never sees a conflict.
  • Reader safe-defaults absent keys: an adopter whose installed file predates these keys is fine, the reader defaults override_label=run-audit and audit_authors= empty; a missing default_mode now falls back to local. The merge adds the keys. This is a real behavior change for the pre-default_mode cohort, their next merge moves them from CI-audited to local-audited; see the Step 10 opt-in nudge for what to tell them.
  • A GAIA-authored roster addition always lands in applied[], not suggestions[]. This is the one section whose added-row verdict diverges from every other merged section (scalar knobs, audit_authors), by design (see above): the alternative would mean a new GAIA-authored auditor never reaches an existing adopter's file at all.

Step 7d: Regenerate declared regions

This step must run after Step 7c and before Step 8, and the ordering is the whole point of the step. A declared region's body is derived from the adopter's own post-merge tree, and for the shipped audit-remit region that source is the auditors roster in .gaia/audit-ci.yml, which Step 7c merges. Regenerating before Step 7c would derive every region from the pre-merge roster, so a GAIA-authored member this release just added would be missing from the region the adopter ends up with, and the roster check would fail on a file this run had supposedly just made current. Running before Step 8 keeps the whole merge, including this write, inside the window .gaia/VERSION still names the baseline, so an interrupted run stays resumable.

if [ "$REGION_AWARE" = true ] && [ "$REGION_DECLS" != "[]" ]; then
  regen_json="$("$LATEST_DIR/.gaia/cli/gaia" update regen-regions \
    --manifest "$LATEST_MANIFEST" \
    --root . \
    --backup-dir "$BACKUP_DIR" \
    ${conflicted_flags} \
    ${absent_path_flags} \
    ${skip_region_flags} \
    --json 2>/dev/null)" || regen_json=''
fi

Both resolutions in that call follow Command resolution in Step 7: the regen-regions subcommand resolves from $LATEST_DIR (rule 1), and --root . points the runner at the adopter's own working tree so it runs the copy of the regeneration program the merge walk just wrote (rule 2).

Build the three repeatable flag groups from this run's own lists:

  • ${conflicted_flags}: one --conflicted <path> per declared path this run placed in conflicts[]. A path left in conflict is not regenerated on this run; the adopter has not resolved it yet, and regenerating would discard whatever they are about to choose. They get the literal command as a Step 9 follow-up instead.
  • ${absent_path_flags}: one --absent-path <path> per declared path this run placed in removed[]. A path the adopter deliberately deleted must not be resurrected, and the runner cannot infer that on its own: mid-run, a deliberately deleted file and an ordinary pre-region absence look identical on disk. Passing them explicitly is what makes "deletions are respected" a property of the mechanism rather than a coincidence of one writer's behavior.
  • ${skip_region_flags}: one --skip-region <id> per region whose inputs this run did not reconcile. Today that means Step 7c fell back to a whole-file conflict patch for .gaia/audit-ci.yml, so the roster the audit-remit region derives from is not the merged one.

Suppression is region-granular. One --conflicted or --absent-path hit suppresses the whole region, not just that path, and the region lands in skipped[] with the reason naming the paths responsible. Its sibling declared paths may already have been overwritten with the release copy by the walk, so they now carry GAIA's version of the region rather than the adopter's. Every declared path of a skipped, refused, or failed region goes into regions.unregeneratedPaths for Step 9, whichever merge-walk list the path itself landed in.

The runner exits 0 for every refusal, skip, spawn failure, and non-zero program exit; only unusable flags or an unusable manifest are a non-zero exit. A failed or refused regeneration never fails the update. If regen_json is empty, record that and continue: the expected cause is a CLI that predates the subcommand, which is exactly the state of the very first region-aware run. Do not stop the update.

Persist the parsed report as regions.regen, and flatten ran[].rewrote into regions.rewrittenPaths for Step 9. The report shape:

type RegenRegionsReport = {
  backedUp: string[];   // declared paths the runner copied into $BACKUP_DIR itself
  confined: Array<{     // entries outside a region's declared paths; Step 9 item 10 is the authority
    action: 'removed' | 'reported' | 'restored';
    path: string;
    regionId: string;
  }>;
  failed: Array<{argv: string[]; cause?: 'external' | 'maxBuffer' | 'timeout'; kind: 'exit' | 'killed' | 'spawn'; message: string; regionId: string; signal?: string; status?: number}>;
  ran: Array<{argv: string[]; regionId: string; rewrote: string[]}>;
  refused: Array<{argv?: string[]; kind: 'declaration' | 'manifest' | 'operand'; reason: string; regionId: string}>;
  skipped: Array<{argv: string[]; reason: string; regionId: string}>;
};

Every bucket Step 9 prints a command for carries its own argv, because the region id alone cannot be turned back into a command. refused declares argv optional because a kind: 'declaration' refusal has none; Step 9 owns what to print in its place. A kind: 'manifest' refusal is about the manifest itself rather than any one region, so it likewise has no argv and carries the sentinel regionId (manifest).

failed[].kind has three values, each with its own remedy. exit is a program that ran and refused, and its message carries the program's own stderr, so the remedy is to read what the program said and fix what it objected to. The other two both arrive without an exit status and must not be conflated: spawn is an interpreter that never launched, so the remedy is to install it or make it executable, while killed is a program that ran and was cut short by a signal (signal names it, e.g. SIGTERM), whose remedy depends on the cause below. Do not describe a killed region to the adopter as one that never ran; its declared paths may hold half-written output.

On a killed entry, cause says which of the runner's own ceilings ended it: timeout (5 minutes) or maxBuffer (32 MiB of output), against external for a kill from anywhere else. Report it, because the remedies differ and only two of the three are GAIA's doing: an adopter whose regeneration command legitimately needs longer, or legitimately says more, otherwise cannot tell that this flow imposed the limit they just hit. Neither ceiling is adjustable from the invocation this flow makes, so for timeout and maxBuffer the remedy is to make the command finish sooner or say less. For external nothing in the update imposed a limit, so the remedy is whatever stopped the command on their machine.

What the step guarantees, and what it does not:

  • The regeneration is authoritative. A declared region's body is machine-authored, so regeneration overwrites whatever sits between the markers, including an adopter's hand edits inside them. That is by design. Step 9 names every path whose region this run rewrote, so the overwrite is stated rather than silent.

  • Writes are confined and backed up. A region's regeneration command legitimately rewrites the paths it declares and nothing else, and scope is what decides how a write outside them is handled. Before the spawn the runner records every path under the region's own directories, the run's snapshot, in which each path's recorded state is its pre-image; anywhere else in the tree it records nothing. A write inside the snapshot is reverted to its pre-image, and a write anywhere else is reported and left where the command put it. Reverting is what the runner attempts inside the snapshot rather than what it always achieves: a path it cannot put back, or cannot establish that putting back would be safe, is reported instead. The runner also copies every declared path it is about to rewrite into $BACKUP_DIR first, unless the merge walk already backed that path up.

    Every way of leaving an undeclared path different counts as a write, not just overwriting it. Inside the snapshot a deletion is reverted from its pre-image, so only the region's own declared paths may be deleted and stay deleted, and a creation, which has no pre-image, is removed. Never tell the adopter a file the regeneration created was removed without saying which side of the snapshot it was on: outside it a creation is reported like any other write, and is still sitting where the command put it.

    Symlinks take the same rule rather than an exception to it, because a link's pre-image is the target string it holds: one the command deletes, retargets, or swaps for a regular file is put back as a link on its original target, and one it creates is removed. Restoring a link writes no content and follows nothing, so whatever it pointed at is never touched.

  • The operand guard is well-formedness, not security. The runner refuses an operand that is absolute, carries a parent-directory segment, resolves through a symlink out of the repository, or is not an exact key of the same manifest's shipped file map. This guards against a stale, corrupt, or hand-edited declaration. It is not a defense against anyone who controls the manifest: the flow already extracts and runs the release tarball's bundled tool, so a manifest that could not be trusted would be the smaller problem. Do not describe it as a security control to the adopter.

Step 8: Count trailer invalidations

The version bump itself is deferred to Step 9 (after the summary prints) so an interrupted run stays resumable, see that step for the rationale. First, while BASELINE still names the installed version, count open PRs whose GAIA-Audit trailer is stamped with it. The upcoming bump invalidates them, they re-run the full CI audit on their next push:

Files (gaia)
  • SKILL.md 92.9 KB
    ---
    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.
    - **`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):
    
    ```bash
    . .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
    
    ```bash
    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
    
    ```bash
    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
    
    ```bash
    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:
    
    ```bash
    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/VERSION` differs from the last commit (`git diff --quiet HEAD -- .gaia/VERSION` exits non-zero, this catches a staged or unstaged bump), a previous `/update-gaia` already 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 to `v$LATEST` is already applied to the working tree but not committed. Review `git diff` and commit it (Step 10 guidance), or run `git checkout -- .gaia/VERSION` to discard the bump and re-run `/update-gaia` to start over. Exit.
      - Otherwise 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
    
    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`:
    
    ```bash
    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:
    
    ```bash
    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.
    
    ```bash
    # .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 `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/local/cache/shared/update-gaia/` (gitignored) so repeated runs don't redownload:
    
    ```bash
    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
    
    ```bash
    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.
    
    ```bash
    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 `regions` key 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 an `id` already 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 as `regions.malformedDeclarations[]`.
    - **The off switch.** `GAIA_UPDATE_NO_REGIONS=1` set 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.json` and no flag on the command.
    - **Dropped declarations.** Any `id` the **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 as `regions.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_DIR` does 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:
    
    ```bash
    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:**
    
    ```bash
    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`):
    
    ```ts
    {
      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:
    
    ```bash
    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.
    
    1. **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's `regen-regions` runner. An adopter whose installed binary predates the subcommand cannot reach it any other way, and that is the only reason the rule exists.
    2. **A regeneration program named by a declaration's `argv` is 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.
    3. **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:
    
    ```bash
    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.bailed` is `true` → reason `malformed-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.bailed` is `false` and some side reports `"scan": "absent"` → reason `absent-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>.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`.
    - `pnpmWorkspace`: populated by **Step 7b**. Same shape and handling as `packageJson`, detailed in `.gaia-merge/pnpm-workspace.yaml.notes`.
    - `auditCiYml`: populated by **Step 7c**. Same shape and handling as `packageJson`, 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):
    
    ```bash
    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`, `minimumReleaseAgeExclude`, `publicHoistPattern`, `savePrefix`, `strictPeerDependencies`) **and** adopter-extensible maps (`overrides`, `allowBuilds`). pnpm 11 reads dependency overrides and build approvals only from here, so any adopter who adds a single override drifts the file and eats a full-file conflict patch on every release that touches it. Merge it at YAML-key / map-entry granularity instead, acting only on the genuine upstream delta `B → L`.
    
    Let `A` = working-tree `pnpm-workspace.yaml`, `B` = `$BASELINE_DIR/pnpm-workspace.yaml`, `L` = `$LATEST_DIR/pnpm-workspace.yaml`.
    
    **Presence triage first** (older baselines predate pnpm 11 and have no `pnpm-workspace.yaml`). Match the first row that applies; only the last row runs the field-aware merge:
    
    | Condition                       | Action                                                                                                  |
    | ------------------------------- | ------------------------------------------------------------------------------------------------------- |
    | `L` missing                     | Upstream dropped the file; fold into the Step 7 deletion sweep (`delete[]`). Skip 7b.                    |
    | `A` missing and `B` missing     | Genuinely new; copy `L` → `pnpm-workspace.yaml`. Record in `add[]`. Skip 7b.                             |
    | `A` missing and `B` exists      | Adopter deleted it; respect the deletion, leave absent. Record in `removed[]`. Skip 7b.                  |
    | `A` exists and `B` missing      | No baseline to field-merge against; `diff -u A L > .gaia-merge/pnpm-workspace.yaml.patch`. Surface as a conflict. Skip 7b. |
    | `A`, `B`, `L` all exist         | Run the field-aware merge below.                                                                        |
    
    **Compute the per-key / per-entry verdicts** with the bundled CLI (it parses all three files with `js-yaml` and never writes the YAML):
    
    ```bash
    .gaia/cli/gaia update merge-workspace \
      --baseline "$BASELINE_DIR/pnpm-workspace.yaml" \
      --latest "$LATEST_DIR/pnpm-workspace.yaml" \
      --current pnpm-workspace.yaml \
      --json
    ```
    
    The command exits non-zero with a structured error if any file is missing or not valid YAML (for example the adopter introduced a syntax error). On a non-zero exit, fall back to a whole-file conflict patch (`diff -u A L > .gaia-merge/pnpm-workspace.yaml.patch`) and surface it as a conflict; do not proceed with the JSON path.
    
    The JSON report is `{ applied, conflicts, suggestions }`. Each item is `{ kind: 'key' | 'entry', section?, key, baseline?, latest?, adopter?, reason? }`. The CLI iterates only `keys(B) ∪ keys(L)` per managed key and per `overrides` / `allowBuilds` entry, so an adopter-only override or build approval is never visited, never clobbered. The GAIA-managed keys listed above are compared whole-value; the two map sections are compared per entry. Both use the identical verdict table as Step 7a (`apply` / `conflict` / `suggest-add` / `suggest-removed`).
    
    **Apply clean changes (`applied[]`):** for each item, edit the working-tree `pnpm-workspace.yaml` so the key's (or entry's) value becomes `latest`, using the **Edit** tool. Preserve the file's comments, key order, and quote style; change only the value text. Do **not** reserialize the file (`js-yaml` `dump` strips every comment). A whole-value list change replaces the list block; a scalar or map-entry change edits the single line.
    
    **Record conflicts + suggestions:** if either bucket is non-empty, write a human-readable `.gaia-merge/pnpm-workspace.yaml.notes` listing, per item: the section (if any), 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:**
    
    - **No managed-key delta** (overrides / allowBuilds / settings unchanged by the release) → zero applied/conflicts/suggestions → **clean skip, no notes file.**
    - **Settings or override change** → only the keys / entries GAIA actually changed (and that the adopter still tracks) are applied; re-pin conflicts and added/removed suggestions go to the notes file, never re-adding a key the adopter removed, never overwriting an adopter override.
    
    ### Step 7c: Field-aware `.gaia/audit-ci.yml` merge
    
    `.gaia/audit-ci.yml` is classed `shared`, but it is a **mixed** file like `pnpm-workspace.yaml`, carrying three kinds of content: GAIA-authored scalar knobs (`gate_label`, `budget_seconds`, `max_turns`, `push_fixes`, `default_mode`, `override_label`, the `retrigger_workflows` list); the adopter-extensible `audit_authors` string, a space-separated `login=mode` list each developer appends their own pair to via `/setup-gaia`; and the `auditors` roster list, GAIA-authored **and** adopter-extensible at once (GAIA ships and updates its own members, an adopter can add their own alongside them). A whole-file three-way merge emits a full-file conflict patch the moment one developer commits an `audit_authors` entry or an adopter adds their own roster member, so merge it at YAML-key / per-entry granularity instead, acting only on the genuine upstream delta `B → L`.
    
    Let `A` = working-tree `.gaia/audit-ci.yml`, `B` = `$BASELINE_DIR/.gaia/audit-ci.yml`, `L` = `$LATEST_DIR/.gaia/audit-ci.yml`.
    
    **Presence triage first** (older baselines predate this file): identical to Step 7b's triage table, substituting `.gaia/audit-ci.yml` for `pnpm-workspace.yaml` (its fallback patch is `.gaia-merge/audit-ci.yml.patch`, and each "Skip 7b" reads "Skip 7c"). Only the last row (`A`, `B`, `L` all exist) runs the field-aware merge below.
    
    **Compute the per-key / per-entry verdicts** with the bundled CLI (it parses all three files with `js-yaml` and never writes the YAML):
    
    ```bash
    .gaia/cli/gaia update merge-audit-ci \
      --baseline "$BASELINE_DIR/.gaia/audit-ci.yml" \
      --latest "$LATEST_DIR/.gaia/audit-ci.yml" \
      --current .gaia/audit-ci.yml \
      --json
    ```
    
    The command exits non-zero with a structured error if any file is missing or not valid YAML. On a non-zero exit, fall back to a whole-file conflict patch (`diff -u A L > .gaia-merge/audit-ci.yml.patch`) and surface it as a conflict; do not proceed with the JSON path.
    
    The JSON report is `{ applied, conflicts, suggestions }`. Each item is `{ kind: 'key' | 'entry', section?, key, baseline?, latest?, adopter?, reason? }`. The CLI iterates only `keys(B) ∪ keys(L)` per managed scalar key, per `audit_authors` login, and per `auditors` roster member name, so an adopter-only developer entry or an adopter-added roster member is never visited, never clobbered. The seven managed knobs are compared whole-value; `audit_authors` is parsed into per-login entries (the login compared case-insensitively, matching the resolver's case-fold) and compared per login; `auditors` is parsed into per-member entries (the name compared exactly, not case-folded, a member name is an agent filename, not a login) and each member's whole mapping (`globs`, `scope`, `push_fixes`, `default`) is compared and applied as a unit, never glob-by-glob. All three sections use the identical verdict table (`apply` / `conflict` / `suggest-add` / `suggest-removed`), **with one deliberate exception**: for `auditors` only, a member present in latest and absent from baseline resolves to `apply`, not `suggest-add`. Every other section treats that row as an opt-in suggestion the adopter must act on; a roster member is a capability the adopter cannot opt into if it never arrives, so a new GAIA-authored member (e.g. `code-audit-github-workflows`) is written straight into the adopter's file rather than surfaced as something they might miss. An adopter's own roster member is still never visited, and an adopter's *edit* to a GAIA-authored member is still a `conflict`, not silently overwritten.
    
    **Apply clean changes (`applied[]`):** for each item, edit the working-tree `.gaia/audit-ci.yml` so the key's (or entry's) value becomes `latest`, using the **Edit** tool. Preserve the file's comments, key order, and quote style; change only the value text. For an `audit_authors` entry item, edit that login's `=mode` token inside the existing `audit_authors` string; do not rewrite the whole string or reorder the other developers' entries. For an `auditors` roster item: if the member already exists in the working tree, edit its `globs:` / `scope:` / `push_fixes:` / `default:` fields in place to match latest; if it is a new member (the added-row exception above), append a whole new `- name: ...` list item to the `auditors:` list, matching the indentation and key order of its neighbors. Do **not** reserialize the file.
    
    **Record conflicts + suggestions:** if either bucket is non-empty, write a human-readable `.gaia-merge/audit-ci.yml.notes` listing, per item: the section (if any), the key, the adopter / baseline / latest values, and the recommended action. Set `notes_path`. This file is informational; it is **not** a `diff -u` patch and is **not** added to the file-level `conflicts[]` bucket.
    
    **Net effect:**
    
    - **No managed-key delta** (knobs, any shipped `audit_authors` entries, and the roster all unchanged by the release) → zero applied/conflicts/suggestions → **clean skip, no notes file.** An adopter whose only divergence is their committed `audit_authors` entries or their own added roster member never sees a conflict.
    - **Reader safe-defaults absent keys:** an adopter whose installed file predates these keys is fine, the reader defaults `override_label=run-audit` and `audit_authors=` empty; a missing `default_mode` now falls back to `local`. The merge adds the keys. This is a real behavior change for the pre-`default_mode` cohort, their next merge moves them from CI-audited to local-audited; see the Step 10 opt-in nudge for what to tell them.
    - **A GAIA-authored roster addition always lands in `applied[]`, not `suggestions[]`.** This is the one section whose added-row verdict diverges from every other merged section (scalar knobs, `audit_authors`), by design (see above): the alternative would mean a new GAIA-authored auditor never reaches an existing adopter's file at all.
    
    ### Step 7d: Regenerate declared regions
    
    **This step must run after Step 7c and before Step 8, and the ordering is the whole point of the step.** A declared region's body is derived from the adopter's own post-merge tree, and for the shipped audit-remit region that source is the `auditors` roster in `.gaia/audit-ci.yml`, which **Step 7c** merges. Regenerating before Step 7c would derive every region from the **pre-merge** roster, so a GAIA-authored member this release just added would be missing from the region the adopter ends up with, and the roster check would fail on a file this run had supposedly just made current. Running before Step 8 keeps the whole merge, including this write, inside the window `.gaia/VERSION` still names the baseline, so an interrupted run stays resumable.
    
    ```bash
    if [ "$REGION_AWARE" = true ] && [ "$REGION_DECLS" != "[]" ]; then
      regen_json="$("$LATEST_DIR/.gaia/cli/gaia" update regen-regions \
        --manifest "$LATEST_MANIFEST" \
        --root . \
        --backup-dir "$BACKUP_DIR" \
        ${conflicted_flags} \
        ${absent_path_flags} \
        ${skip_region_flags} \
        --json 2>/dev/null)" || regen_json=''
    fi
    ```
    
    Both resolutions in that call follow **Command resolution** in Step 7: the `regen-regions` subcommand resolves from `$LATEST_DIR` (rule 1), and `--root .` points the runner at the adopter's own working tree so it runs the copy of the regeneration program the merge walk just wrote (rule 2).
    
    Build the three repeatable flag groups from this run's own lists:
    
    - `${conflicted_flags}`: one `--conflicted <path>` per declared path this run placed in `conflicts[]`. A path left in conflict is **not** regenerated on this run; the adopter has not resolved it yet, and regenerating would discard whatever they are about to choose. They get the literal command as a Step 9 follow-up instead.
    - `${absent_path_flags}`: one `--absent-path <path>` per declared path this run placed in `removed[]`. A path the adopter deliberately deleted must not be resurrected, and the runner cannot infer that on its own: mid-run, a deliberately deleted file and an ordinary pre-region absence look identical on disk. Passing them explicitly is what makes "deletions are respected" a property of the mechanism rather than a coincidence of one writer's behavior.
    - `${skip_region_flags}`: one `--skip-region <id>` per region whose inputs this run did not reconcile. Today that means Step 7c fell back to a whole-file conflict patch for `.gaia/audit-ci.yml`, so the roster the audit-remit region derives from is not the merged one.
    
    **Suppression is region-granular.** One `--conflicted` or `--absent-path` hit suppresses the **whole region**, not just that path, and the region lands in `skipped[]` with the reason naming the paths responsible. Its sibling declared paths may already have been overwritten with the release copy by the walk, so they now carry GAIA's version of the region rather than the adopter's. Every declared path of a skipped, refused, or failed region goes into `regions.unregeneratedPaths` for Step 9, whichever merge-walk list the path itself landed in.
    
    The runner exits `0` for every refusal, skip, spawn failure, and non-zero program exit; only unusable flags or an unusable manifest are a non-zero exit. **A failed or refused regeneration never fails the update.** If `regen_json` is empty, record that and continue: the expected cause is a CLI that predates the subcommand, which is exactly the state of the very first region-aware run. Do not stop the update.
    
    Persist the parsed report as `regions.regen`, and flatten `ran[].rewrote` into `regions.rewrittenPaths` for Step 9. The report shape:
    
    ```ts
    type RegenRegionsReport = {
      backedUp: string[];   // declared paths the runner copied into $BACKUP_DIR itself
      confined: Array<{     // entries outside a region's declared paths; Step 9 item 10 is the authority
        action: 'removed' | 'reported' | 'restored';
        path: string;
        regionId: string;
      }>;
      failed: Array<{argv: string[]; cause?: 'external' | 'maxBuffer' | 'timeout'; kind: 'exit' | 'killed' | 'spawn'; message: string; regionId: string; signal?: string; status?: number}>;
      ran: Array<{argv: string[]; regionId: string; rewrote: string[]}>;
      refused: Array<{argv?: string[]; kind: 'declaration' | 'manifest' | 'operand'; reason: string; regionId: string}>;
      skipped: Array<{argv: string[]; reason: string; regionId: string}>;
    };
    ```
    
    Every bucket Step 9 prints a command for carries its own `argv`, because the region id alone cannot be turned back into a command. `refused` declares `argv` optional because a `kind: 'declaration'` refusal has none; Step 9 owns what to print in its place. A `kind: 'manifest'` refusal is about the manifest itself rather than any one region, so it likewise has no `argv` and carries the sentinel `regionId` `(manifest)`.
    
    `failed[].kind` has three values, each with its own remedy. `exit` is a program that ran and refused, and its `message` carries the program's own stderr, so the remedy is to read what the program said and fix what it objected to. The other two both arrive without an exit status and must not be conflated: `spawn` is an interpreter that never launched, so the remedy is to install it or make it executable, while `killed` is a program that ran and was cut short by a signal (`signal` names it, e.g. `SIGTERM`), whose remedy depends on the `cause` below. Do not describe a `killed` region to the adopter as one that never ran; its declared paths may hold half-written output.
    
    On a `killed` entry, `cause` says which of the runner's own ceilings ended it: `timeout` (5 minutes) or `maxBuffer` (32 MiB of output), against `external` for a kill from anywhere else. Report it, because the remedies differ and only two of the three are GAIA's doing: an adopter whose regeneration command legitimately needs longer, or legitimately says more, otherwise cannot tell that this flow imposed the limit they just hit. Neither ceiling is adjustable from the invocation this flow makes, so for `timeout` and `maxBuffer` the remedy is to make the command finish sooner or say less. For `external` nothing in the update imposed a limit, so the remedy is whatever stopped the command on their machine.
    
    What the step guarantees, and what it does not:
    
    - **The regeneration is authoritative.** A declared region's body is machine-authored, so regeneration overwrites whatever sits between the markers, including an adopter's hand edits inside them. That is by design. Step 9 names every path whose region this run rewrote, so the overwrite is stated rather than silent.
    - **Writes are confined and backed up.** A region's regeneration command legitimately rewrites the paths it declares and nothing else, and **scope is what decides how a write outside them is handled**. Before the spawn the runner records every path under the region's own directories, the run's **snapshot**, in which each path's recorded state is its **pre-image**; anywhere else in the tree it records nothing. A write inside the snapshot is reverted to its pre-image, and a write anywhere else is reported and left where the command put it. Reverting is what the runner attempts inside the snapshot rather than what it always achieves: a path it cannot put back, or cannot establish that putting back would be safe, is reported instead. The runner also copies every declared path it is about to rewrite into `$BACKUP_DIR` first, unless the merge walk already backed that path up.
    
      **Every way of leaving an undeclared path different counts as a write**, not just overwriting it. Inside the snapshot a **deletion** is reverted from its pre-image, so only the region's own declared paths may be deleted and stay deleted, and a **creation**, which has no pre-image, is removed. Never tell the adopter a file the regeneration created was removed without saying which side of the snapshot it was on: outside it a creation is reported like any other write, and is still sitting where the command put it.
    
      Symlinks take the same rule rather than an exception to it, because a link's pre-image is the target string it holds: one the command deletes, retargets, or swaps for a regular file is put back as a link on its original target, and one it creates is removed. Restoring a link writes no content and follows nothing, so whatever it pointed at is never touched.
    - **The operand guard is well-formedness, not security.** The runner refuses an operand that is absolute, carries a parent-directory segment, resolves through a symlink out of the repository, or is not an exact key of the same manifest's shipped file map. This guards against a stale, corrupt, or hand-edited declaration. It is **not** a defense against anyone who controls the manifest: the flow already extracts and runs the release tarball's bundled tool, so a manifest that could not be trusted would be the smaller problem. Do not describe it as a security control to the adopter.
    
    ### Step 8: Count trailer invalidations
    
    The version bump itself is deferred to Step 9 (after the summary prints) so an interrupted run stays resumable, see that step for the rationale. First, while `BASELINE` still names the installed version, count open PRs whose `GAIA-Audit` trailer is stamped with it. The upcoming bump invalidates them, they re-run the full CI audit on their next push:
    
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related