- 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
```bash
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
```bash
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.
```bash
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
```bash
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:
```bash
WORK=$(mktemp -d)
fetch() { gh api "repos/${UPSTREAM}/contents/$1?ref=$2" --jq '.content' 2>/dev/null | base64 -d; } # $1=path $2=ref
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:
```bash
fetch "$f" "$BASELINE" > "$WORK/base" # upstream@BASELINE (the common ancestor)
fetch "$f" "$HEAD_SHA" > "$WORK/head" # upstream@HEAD (what to bring in)
cp "$f" "$WORK/local" # operator's current copy (ours)
if git merge-file -p --diff3 "$WORK/local" "$WORK/base" "$WORK/head" > "$WORK/merged.$$" 2>/dev/null; then
disposition=CLEAN-MERGE # exit 0 = disjoint hunks; the merged file carries BOTH edits - write it in S7
else
disposition=CONFLICT # exit >0 = the same lines changed on both sides; surface for a human as before
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:
```bash
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):
```bash
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:
```bash
EYEBROW_OK=0
EB=$(command -v eyebrow || true)
if [ -z "$EB" ]; then
# Use the SAME eyebrow version ci-skill-integrity.yml pins, parsed from the
# workflow's `alexverify/eyebrow/action@<sha> # vX.Y.Z` comment. A hardcoded
# version silently drifts from CI (the lock this writes with an older binary
# then fails CI's newer `eyebrow verify` as drift - the recurring sync red);
# deriving it self-heals across action bumps. Fall back to v0.4.2 if unparsable.
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)
在 GitHub 查看