| name | aeon-update |
| description | Pull framework updates from the upstream Aeon repo into this instance - 3-way merges canon's new commits into a PR, never clobbering operator config. |
| metadata | {"title":"Aeon Update","category":"core","var":"","tags":["dev","meta"],"cron":"0 11 * * 1","mode":"write"} |
${var} — mode selector; space-separated tokens, order-independent, all optional:
- mode (
sync | report, default sync) — sync opens a PR with the framework changes; report computes the delta and notifies, mutating nothing (dry run).
repo=owner/name — override the upstream source repo (else auto-resolved from this instance's parent, falling back to aeonfun/aeon).
reset=<sha|fork-point> — force the stored baseline to <sha> (or the merge-base with upstream) before running. Recovery / backfill lever.
Empty ⇒ sync from the auto-resolved upstream. Examples: `` · report · repo=aeonfun/aeon · reset=fork-point.
Today is ${today}. This is the fleet's downstream updater - the counterpart to fork-fleet. fork-fleet looks outward from the parent to find work in the forks worth pulling up; this skill runs inside an instance and pulls the parent's shipped framework changes down - new skills, script/harness fixes, workflow and doc updates - and lands them as a reviewable PR. It is how an instance stays current with aeonfun/aeon without a hand-run rsync-overlay rebase.
Operating principles
- PR, never push to
main. Every framework change ships as one reviewable PR. The operator merges.
- Never clobber operator config.
aeon.yml, STRATEGY.md, soul/, memory/, output/, .mcp.json and the git-derived catalogs are instance-owned. Upstream changes to them are surfaced for manual review in the PR body, never written into the tree.
- 3-way, not blind overwrite. A framework file the operator has already customized is auto-merged when its local edits and upstream's edits touch disjoint regions (a real
git merge-file 3-way, S6); it is only listed as a conflict for a human when the same lines changed on both sides. This is what lets a hand-narrowed workflow keep receiving unrelated upstream fixes without a manual merge every run.
- Silent when in sync. Baseline == upstream HEAD ⇒ nothing to do, no notification.
- The baseline is the watermark, and it advances by merge. The new baseline SHA is written into the PR branch, so the watermark only moves when the operator merges the PR. Unresolved conflicts are tracked separately so advancing the baseline can never silently drop them.
Steps
S0. Bootstrap + load state
mkdir -p memory/topics
[ -f memory/topics/aeon-update-state.json ] || echo '{"baseline_sha":null,"upstream":null,"last_run":null,"last_pr":null,"pending_conflicts":[]}' > memory/topics/aeon-update-state.json
Read memory/MEMORY.md for context and scan the last ~3 days of memory/logs/ - drop anything already reported so a repeat run isn't re-sent. Read the state file:
BASELINE = .baseline_sha (the upstream commit this instance was last synced to).
PENDING = .pending_conflicts (files surfaced as conflicts in a prior run, not yet resolved).
S1. Parse ${var}
MODE = report if the token report (or dry) is present, else sync.
REPO_OVERRIDE = value of a repo=owner/name token, if any.
RESET = value of a reset= token, if any (fork-point or a 7-40 char SHA).
S2. Resolve the upstream source
SELF=$(gh repo view --json nameWithOwner -q .nameWithOwner)
UPSTREAM="${REPO_OVERRIDE:-$(gh api "repos/${SELF}" --jq '.parent.full_name // empty')}"
[ -z "$UPSTREAM" ] && UPSTREAM="aeonfun/aeon"
If UPSTREAM == SELF, this instance is canon - there is nothing upstream to pull. Write status AEON_UPDATE_IS_UPSTREAM to memory/logs/${today}.md, send no notification, and stop.
UP_DEFAULT=$(gh api "repos/${UPSTREAM}" --jq '.default_branch')
HEAD_SHA=$(gh api "repos/${UPSTREAM}/commits/${UP_DEFAULT}" --jq '.sha')
S3. Establish / reset the baseline
reset=fork-point → BASELINE=$(gh api "repos/${UPSTREAM}/compare/${HEAD_SHA}...$(git rev-parse HEAD)" --jq '.merge_base_commit.sha'). reset=<sha> → BASELINE=<sha>. Persist immediately to state, then continue.
- First run (
BASELINE null and no reset): a fresh instance already carries all of canon from fork time, so there is no delta to apply - just anchor the watermark. Set baseline_sha = HEAD_SHA, write state, log AEON_UPDATE_BASELINE_SET, send a one-line notify (baseline initialized at <head7>; future runs sync from here), and stop. (To backfill everything since the fork point instead, re-run with reset=fork-point.)
BASELINE == HEAD_SHA: in sync. Re-verify PENDING (S8) in case a prior conflict is now resolved, update state, log AEON_UPDATE_IN_SYNC, notify nothing, stop.
S4. Compare baseline → HEAD
gh api "repos/${UPSTREAM}/compare/${BASELINE}...${HEAD_SHA}" --jq '{
ahead: .ahead_by, behind: .behind_by, status,
commits: [.commits[]? | {sha: .sha[0:7], msg: (.commit.message | split("\n")[0]), date: .commit.author.date}],
files: [.files[]? | {filename, status, previous_filename, additions, deletions}]
}' > /tmp/aeon-update-compare.json
Error handling:
- 404 /
status: "diverged" with no merge base (baseline not an ancestor of HEAD - history rewrite or unrelated repo): stop with AEON_UPDATE_BASELINE_UNREACHABLE; notify the operator to re-run with reset=fork-point or reset=<sha>.
- Cross-repo compare returns at most 300 files; if
.files looks truncated, note files_truncated=true in the report - the operator can run again after merging to pick up the remainder.
S5. Partition changed files
Classify every entry in .files by path. A file is OPERATOR-owned (surfaced, never auto-written) if its path matches any of:
aeon.yml STRATEGY.md soul/** memory/**
output/** .mcp.json .env* aeon.db
skills.lock eyebrowlock.json catalog/*.json .claude/** (except .claude/skills/aeon/**)
apps/dashboard/outputs/**
Everything else is OWNED (a candidate for auto-apply): skills/**, scripts/**, bin/**, harness-adapter/**, .github/**, apps/** (except apps/dashboard/outputs/**), CLAUDE.md, AGENTS.md, docs/**, .github/README.md, LICENSE, CHANGELOG.md, .gitignore, eyebrow.policy.json, and tracked root helpers (aeon, ...).
catalog/*.json and eyebrowlock.json are OPERATOR-owned here only so they are never blindly copied - they are regenerated from the synced sources in S7, which is the correct way to reconcile them.
S6. 3-way classify each OWNED file
Set up a workspace and, for each OWNED file f, fetch upstream's HEAD and BASELINE blobs:
WORK=$(mktemp -d)
fetch() { gh api "repos/${UPSTREAM}/contents/$1?ref=$2" --jq '.content' 2>/dev/null | base64 -d; }
h() { sha256sum 2>/dev/null | cut -d' ' -f1; }
Decide f's disposition from its status and a content 3-way (local-current vs upstream@BASELINE vs upstream@HEAD):
status | Test | Disposition |
|---|
added | path absent locally | CLEAN-ADD (write HEAD blob) |
added | path present locally (collision, e.g. a fork-only skill) | CONFLICT |
modified | sha256(local) == sha256(HEAD blob) | already synced → SKIP |
modified | sha256(local) == sha256(BASELINE blob) (operator never touched it) | CLEAN-UPDATE (write HEAD blob) |
modified | otherwise (operator customized it) | 3-WAY MERGE → CLEAN-MERGE or CONFLICT (see below) |
removed | sha256(local) == sha256(BASELINE blob) | CLEAN-DELETE (git rm) |
removed | local differs or absent | CONFLICT (or already gone → SKIP if absent) |
renamed | treat as removed previous_filename + added filename under the rules above | per-part |
Never CLEAN-DELETE a skills/<name>/ directory whose <name> is not present in upstream's tree - fork-only skills are operator work and are structurally untouched (upstream's compare can only reference paths that exist upstream).
Also never CLEAN-DELETE a skills/<name>/ directory if <name> is currently enabled: true in the operator's aeon.yml (grep -E "^ ${name}: *\{[^}]*enabled: true" aeon.yml) - upstream retiring a skill the operator has actively scheduled is exactly the case validate-config.js's skill-refs check exists to catch, but only after the PR is merged; nothing in the PR review itself would otherwise flag it. Downgrade this case to CONFLICT (reason: enabled-skill-removed-upstream) instead of deleting - the directory stays, and S9 surfaces it as its own loud PR-body section rather than folding it into "Applied cleanly" or the generic conflict list.
3-way content merge (OWNED files only). A modified file that reached the otherwise row means the operator diverged from BASELINE and upstream changed the file too. Do not give up on it - most of the time the two sets of edits are in different parts of the file (e.g. the operator narrowed the workflow's env: secrets block while upstream bumped a timeout and a retry loop elsewhere), and a real 3-way merge combines both losslessly. Attempt it before declaring a conflict:
fetch "$f" "$BASELINE" > "$WORK/base"
fetch "$f" "$HEAD_SHA" > "$WORK/head"
cp "$f" "$WORK/local"
if git merge-file -p --diff3 "$WORK/local" "$WORK/base" "$WORK/head" > "$WORK/merged.$$" 2>/dev/null; then
disposition=CLEAN-MERGE
else
disposition=CONFLICT
fi
git merge-file exits 0 only when the merge is clean (operator and upstream touched disjoint regions); the merged output preserves the operator's customization AND applies upstream's change. A non-zero exit means a genuine overlap - keep it a CONFLICT and list it with the upstream diff (S9). Only OWNED files are ever 3-way-merged; OPERATOR-owned paths are always surfaced, never written. A merged file gets the same S7 YAML/JSON parse-check as any written file - if the merge produced something that no longer parses, abort that file back to CONFLICT rather than committing it.
S7. Apply CLEAN changes on a branch (sync mode only)
If MODE == report, skip to S9. Otherwise:
BR="aeon-update/sync-$(echo "$HEAD_SHA" | cut -c1-7)"
git checkout -b "$BR"
Write every CLEAN-ADD / CLEAN-UPDATE (mkdir -p "$(dirname f)" then write the HEAD blob to f), write every CLEAN-MERGE (the merged file $WORK/merged.$$ from S6, over the existing f), and git rm every CLEAN-DELETE. CONFLICT and OPERATOR files are not touched - they go in the PR body only. (CLEAN-MERGE counts as a clean apply for the "nothing CLEAN applied" test below.)
If any skills/** path was applied, regenerate the derived catalogs from the synced sources (never copy them from upstream):
bin/generate-skills-json && bin/generate-packs-json && bin/generate-skill-icons
node scripts/gen-agents-md.js || true
Refresh the eyebrow integrity lock for any NEWLY-ADDED skill. ci-skill-integrity fails a PR when a present skill's skills/<slug>/SKILL.md has no "discoveredFrom": "skills/<slug>/SKILL.md" entry in eyebrowlock.json. That entry is produced only by the eyebrow binary, which is not preinstalled in this run - so a CLEAN-ADD of a new skill would otherwise land the PR CI-red. Fetch the binary at the exact version ci-skill-integrity.yml pins, parsed from that workflow's alexverify/eyebrow/action@<sha> # vX.Y.Z line so it can never drift from CI - a hardcoded version writes a lock that CI's (newer) eyebrow verify then rejects as drift, which is the recurring cause of red sync PRs. Verify the downloaded tarball against the release's own checksums.txt before running it (the same checksum-verified install the action does), then rescan:
EYEBROW_OK=0
EB=$(command -v eyebrow || true)
if [ -z "$EB" ]; then
EBV=$(grep -oE 'alexverify/eyebrow/action@[0-9a-f]+ *# *v[0-9]+\.[0-9]+\.[0-9]+' .github/workflows/ci-skill-integrity.yml | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1)