aeon-update
GitHub自动将上游 Aeon 框架更新拉取至实例,通过 3-way merge 生成 PR。保护用户配置不被覆盖,支持同步与报告模式,确保实例保持最新且安全。
触发场景
安装
npx skills add aeonfun/aeon --skill aeon-update -g -y
SKILL.md
Frontmatter
{
"name": "aeon-update",
"metadata": {
"var": "",
"mode": "write",
"tags": [
"dev",
"meta"
],
"title": "Aeon Update",
"category": "core"
},
"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."
}
${var} — mode selector; space-separated tokens, order-independent, all optional:
- mode (
sync|report, defaultsync) —syncopens a PR with the framework changes;reportcomputes the delta and notifies, mutating nothing (dry run).repo=owner/name— override the upstream source repo (else auto-resolved from this instance'sparent, falling back toaeonfun/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.jsonand 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-file3-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 only into the PR branch commit (never main, never the run log), so the watermark only moves when the operator merges the PR, and one sync PR is open at a time. Unresolved conflicts are tracked separately so advancing the baseline can never silently drop them.
Steps
Every command below is one Bash call: run it, read the value it prints, and type that value into the next command (shown as <UPSTREAM>, <HEAD_SHA>, ...). Never assign shell variables (X=$(...)), chain with &&/;, redirect into a file, or write a script and run it with bash: the permission layer refuses all of those in this run. Create or change files with Write/Edit.
S0. Bootstrap + load state
If memory/topics/aeon-update-state.json does not exist, create it with Write:
{"baseline_sha": null, "upstream": null, "last_run": null, "last_pr": null, "pending_conflicts": []}
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=reportif the tokenreport(ordry) is present, elsesync.REPO_OVERRIDE= value of arepo=owner/nametoken, if any.RESET= value of areset=token, if any (fork-pointor a 7-40 char SHA).
S2. Resolve the upstream source
gh repo view --json nameWithOwner -q .nameWithOwner # -> SELF
gh api "repos/<SELF>" --jq '.parent.full_name // empty' # -> UPSTREAM (skip when REPO_OVERRIDE is set)
UPSTREAM = REPO_OVERRIDE, else the parent, else aeonfun/aeon when the parent is empty.
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.
gh api "repos/<UPSTREAM>" --jq '.default_branch' # -> UP_DEFAULT
gh api "repos/<UPSTREAM>/commits/<UP_DEFAULT>" --jq '.sha' # -> HEAD_SHA (HEAD7 = first 7 chars)
S3. Establish / reset the baseline
-
reset=fork-point→ rungit rev-parse HEAD(-> LOCAL_HEAD), thengh api "repos/<UPSTREAM>/compare/<HEAD_SHA>...<LOCAL_HEAD>" --jq '.merge_base_commit.sha'->BASELINE.reset=<sha>→BASELINE=<sha>. Persist it to the state file with Edit, then continue. -
First run (
BASELINEnull and noreset): a fresh instance already carries all of canon from fork time, so there is no delta to apply - just anchor the watermark. Setbaseline_sha = HEAD_SHA, write state, logAEON_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 withreset=fork-point.) -
BASELINE==HEAD_SHA: in sync. Run the S4plancommand anyway: it returnsAEON_UPDATE_IN_SYNCwithpending_conflictsre-verified against HEAD. If that list differs fromPENDING(a prior conflict was resolved), write it into the state file with Edit. LogAEON_UPDATE_IN_SYNC, notify nothing, stop. -
A sync PR is still open (sync mode): the state file on
mainstill holds the old baseline until that PR merges, so a second PR from it would carry the same commits again and two branches would each move the watermark. Check before doing any work, including before persisting areset=:gh pr list --repo "<SELF>" --state open --base "<UP_DEFAULT>" --limit 100 --json number,headRefName,url --jq '[.[] | select(.headRefName | startswith("aeon-update/sync-"))] | sort_by(.number) | last // empty | "\(.number) \(.headRefName) \(.url)"'If it prints a PR (
OPEN_PR): write nothing (no state file, no branch, no PR) and logAEON_UPDATE_PR_OPENwith the PR and its range. If its branch already ends inHEAD_SHA's first 7 chars, stay silent; if upstream has moved past it, send one line (PR #N still open; upstream is M commits past it, merge or close it and the next run syncs the rest,--mute-key "aeon-update:${HEAD7}"). Stop.reportmode skips this check and notes the open PR in its report.
S4-S6. Plan: compare, partition, 3-way classify
All of S4-S8's file work is done by scripts/aeon_update.py, in single commands. Do not re-implement it in the shell: helper functions, loops, redirects into files and bash <script> are all refused in this run, so a hand-written classify script ends the run with nothing synced (miroshark-aeon, 2026-10-21). Run:
python3 scripts/aeon_update.py plan --upstream "<UPSTREAM>" --baseline "<BASELINE>" --head "<HEAD_SHA>"
It prints a JSON summary and mutates nothing in the repo (its scratch goes to ${RUNNER_TEMP:-/tmp}/aeon-update/). Read status:
AEON_UPDATE_IN_SYNC: handle as S3's in-sync case.AEON_UPDATE_BASELINE_UNREACHABLE(exit 3; the baseline is not an ancestor of HEAD - history rewrite or unrelated repo): stop with that status and notify the operator to re-run withreset=fork-pointorreset=<sha>.AEON_UPDATE_ERROR(exit 2): stop, log theerror, and notify it. Never fall back to classifying by hand.AEON_UPDATE_PLANNED: the summary hascounts, thecleanpaths per disposition,conflicts(path, reason, in-rangeupstream_commits, and adifffile with upstream's BASELINE..HEAD diff),operator(OPERATOR-owned paths upstream changed, each with adifffile),unreadable, the recomputedpending_conflicts, and thecommitsin range. Read thedifffiles you need with the Read tool.
What the plan decides, so you can explain it in the PR:
Changed paths are every path whose entry differs between upstream's BASELINE and HEAD trees (no 300-file compare cap). A rename is a delete of the old path plus an add of the new one.
OPERATOR-owned (surfaced, never written):
aeon.yml STRATEGY.md soul/** memory/**
output/** .mcp.json .env* aeon.db
skills.lock eyebrowlock.json .claude/** (except .claude/skills/aeon/**)
catalog/*.json (except catalog/skill-packs.json)
apps/dashboard/outputs/** .github/dependabot.yml
Everything else is OWNED: skills/**, scripts/**, bin/**, harness-adapter/**, .github/**, apps/**, docs/**, CLAUDE.md, AGENTS.md, CHANGELOG.md, .gitignore, eyebrow.policy.json, catalog/skill-packs.json, root helpers. catalog/*.json and eyebrowlock.json are never copied from upstream: apply regenerates them from the synced sources.
catalog/skill-packs.json is synced, not regenerated (it has no generator and is hand-maintained with the Listed packs table in docs/community-skill-packs.md; ci-skill-packs fails when they disagree, aeon-agent#232). Its pack-registry unit (catalog/skill-packs.json, docs/community-skill-packs.md, scripts/validate-skill-packs.mjs, scripts/tests/test_validate_skill_packs.sh, .github/workflows/ci-skill-packs.yml) is classified together and is all-or-nothing: one CONFLICT member demotes the rest to CONFLICT skill-packs-unit. A local registry that lists no pack upstream HEAD lacks is a frozen copy, not an edit, and is CLEAN-UPDATEd (stale-copy).
Dispositions (local copy vs upstream@BASELINE vs upstream@HEAD; equal = same content and exec bit):
| Upstream | Local | Disposition |
|---|---|---|
| unchanged | any | SKIP |
| added | absent / same as HEAD / different | CLEAN-ADD / SKIP / CONFLICT local-collision |
| modified | same as HEAD | SKIP |
| modified | same as BASELINE | CLEAN-UPDATE |
| modified | absent | CONFLICT deleted-locally |
| modified | customized | git merge-file --diff3: disjoint hunks -> CLEAN-MERGE (both edits kept), overlap -> CONFLICT operator-customized |
| removed | absent / same as BASELINE / edited | SKIP / CLEAN-DELETE / CONFLICT removed-upstream-local-edited |
A removed path of a skill upstream retired (skills/<name>/SKILL.md gone from HEAD) whose <name> is enabled: true in the operator's aeon.yml is never deleted: CONFLICT enabled-skill-removed-upstream, its own loud PR-body section (S9). Fork-only skills are never touched (only upstream paths are ever in the change set). A blob the API cannot return is UNREADABLE: list it and continue with a partial sync.
In report mode stop after the plan and go to S10.
S7-S8. Apply on a branch (sync mode only)
git checkout -b "aeon-update/sync-<HEAD7>"
python3 scripts/aeon_update.py apply
apply refuses to run off an aeon-update/* branch. It re-checks each file against the plan (a file that changed since is CONFLICT changed-since-plan), then:
- Writes every CLEAN-ADD / CLEAN-UPDATE / CLEAN-MERGE (with upstream's exec bit) and
git rms every CLEAN-DELETE. A written.json/.ymlthat does not parse is put back and becomes CONFLICTparse-failed. - Runs
node scripts/validate-skill-packs.mjswhen a pack-registry member was applied; on failure it puts the unit back as CONFLICTskill-packs-parityand returns the validator output asskill_packs_validator. - Eyebrow lock. When any
skills/**path was applied, it fetches theeyebrowbinary at the exact versionci-skill-integrity.ymlruns (the step'sversion:input), checks it against the release'schecksums.txt, and rescanseyebrowlock.jsonwith a scrubbed env (eyebrow: "scanned"). If the binary is unavailable or the scan fails, the fail-safe never ships a skill against a stale lock entry: for a new skill, an appliedSKILL.mdwhose lock entry has findings (an edit that moves lines relocates a finding, whicheyebrow verify --cireports as capability expansion), or a skill whose upstream lockcapabilitieschanged between BASELINE and HEAD (miroshark-aeon#183), it carries upstream's HEAD lock entry when the skill's tree is byte-identical to HEAD (eyebrow_carried), else puts the skill back as CONFLICTneeds-eyebrowlock-scan(eyebrow_held). - Copies each new skill's glyph from upstream's
catalog/skill-icons.json(hand-authored, so the generator cannot invent it;glyphs_carried), then regeneratesbin/generate-skills-json,bin/generate-packs-json,bin/generate-skill-iconsandnode scripts/gen-agents-md.jsfrom the final tree. Failures are listed incatalog_errors. - Runs
node scripts/validate-config.js aeon.yml; its output, if it flagged anything, isvalidate_config(may be pre-existing drift: note it, do not abort). - Recomputes
pending_conflicts: this run's conflicts plus each prior entry that is still divergent (local differs from HEAD). Prior entries applied this run (CLEAN-UPDATE or CLEAN-MERGE) are resolved; anenabled-skill-removed-upstreamentry resolves once the skill is disabled or upstream re-adds it. - Writes
memory/topics/aeon-update-state.jsonin the branch:baseline_sha= HEAD,upstream,last_run,last_pr: null,appliedcounts andpending_conflicts(other keys kept). It must reachmainonly through the PR merge: if the new baseline landed onmainwhile the PR is open, the PR would conflict on the state file, and closing it unmerged would make the next run skip its commits.
Its JSON result (status, the final clean lists, conflicts, operator, pending_conflicts, and the notes above) is also saved as result.json in its work dir. Statuses:
AEON_UPDATE_APPLIED: go to S9.AEON_UPDATE_MANUAL_ONLY(nothing clean was left to apply): open no PR. Rungit checkout -- ., thengit checkout <UP_DEFAULT>andgit branch -D aeon-update/sync-<HEAD7>, logAEON_UPDATE_MANUAL_ONLY, and go to S10 (notify that the sync needs a manual merge).AEON_UPDATE_ERROR: same cleanup, log the error, notify it.
S9. Commit + open the PR (sync mode; skip in report mode)
Stage the synced files and the state file only. Never memory/logs/, token-usage.csv, output/ or the dashboard feed: the run writes those again after this commit, and the post-run commit moves them to main, so a copy in the branch makes the PR conflict with main and drags the branch's state file onto main with it.
Write /tmp/aeon-update-commit.txt and /tmp/aeon-update-pr-body.md (below) with the Write tool, then run each line as its own call (notify, notify-jsonrender and secretcurl are the run's generated helpers):
git add -A -- . ':(exclude)memory' ':(exclude)output' ':(exclude)apps/dashboard/outputs' ':(exclude)notify' ':(exclude)notify-jsonrender' ':(exclude)secretcurl'
git add -- memory/topics/aeon-update-state.json
git commit -F /tmp/aeon-update-commit.txt
git push -u origin "aeon-update/sync-<HEAD7>"
gh pr create --repo "<SELF>" --base "<UP_DEFAULT>" --title "aeon-update: sync <N_COMMITS> upstream commits (<BASE7>..<HEAD7>)" --body-file /tmp/aeon-update-pr-body.md
Never inline the commit message with -m (backticks or $() in it get shell-substituted).
/tmp/aeon-update-commit.txt:
aeon-update: sync upstream ${BASE7}..${HEAD7}
${N_COMMITS} upstream commits from ${UPSTREAM}. ${N_APPLIED} files applied cleanly, ${N_CONFLICT} need manual review. Baseline advanced to ${HEAD7}.
PR body (/tmp/aeon-update-pr-body.md) - only include sections that have content:
## Upstream sync: `${UPSTREAM}` `${BASE7}..${HEAD7}`
**${N_COMMITS} commits** ({earliest date} → {latest date}) · **${N_APPLIED} applied** · **${N_CONFLICT} manual** · baseline → `${HEAD7}`.
### Applied cleanly
- **New skills:** `foo`, `bar` _(regenerated catalogs + agents.md + skill-icons)_
- **Modified skills:** `baz`
- **Scripts / harness:** `scripts/notify.sh`, ...
- **Workflows:** `.github/workflows/...`
- **Auto-merged (3-way):** `.github/workflows/aeon.yml`, ... _(your local customization kept; upstream's disjoint changes applied - review the merged hunks)_
- **Docs / other:** `docs/...`, `CLAUDE.md`, ...
### ⚠️ Currently-enabled skills removed upstream
For each CONFLICT with reason `enabled-skill-removed-upstream`:
- `verdikta-hunter` — enabled in your `aeon.yml`, deleted upstream in {commit(s)}. **Not deleted here** so nothing breaks. Pick one: keep it as a fork-only skill going forward (nothing else to do), or disable it in `aeon.yml` to match upstream's current default set.
### Needs manual review (conflicts - your local copy diverges from upstream)
For each *other* conflict reason (`enabled-skill-removed-upstream` is covered above, don't duplicate it here): what upstream changed and why it wasn't auto-applied.
- `scripts/foo.sh` — you customized this locally; upstream changed it in {upstream_commits}. Upstream diff (from the conflict's `diff` file, trimmed):
```diff
{short upstream base..head diff for the file}
Operator config changed upstream (not auto-applied - reconcile by hand)
aeon.yml— upstream added skills / changed defaults: {summary}. Merge the new entries you want (keep your enable/schedule/model choices).soul/…,STRATEGY.md— {summary, if changed}
Upstream commits
| SHA | Summary |
|---|---|
| abc1234 | ... |
Capture the PR URL; write it into `last_pr` in the branch's state file with Edit, then `git add -- memory/topics/aeon-update-state.json` and `git commit --amend --no-edit` (two calls; the post-run step pushes the amended branch) so the merged watermark records its own PR.
### S10. Log + notify
Stay on the PR branch and do not commit from here on: the post-run commit pushes the branch and records the log and other run state on `main`, while the state file stays in the branch. Do not check out `main` or touch the state file again.
Append to `memory/logs/${today}.md` under `### aeon-update`: status, `UPSTREAM`, `${BASE7}..${HEAD7}`, applied/conflict counts, PR URL (or `report`/`manual-only`), and `pending_conflicts` count.
**Notify only on signal** - match `soul/` voice if present. Send when there is a PR, a report with changes, or a manual-only situation; stay silent for `IN_SYNC` / `BASELINE_SET`-with-nothing. Keep it ≤4000 chars:
aeon-update — ${today} {verdict: "synced N commits → PR" | "N changes need manual merge" | "report: N commits behind"}
Upstream ${UPSTREAM} is ${AHEAD} commits ahead. Applied ${N_APPLIED} cleanly, ${N_CONFLICT} need review.
{Top applied highlight: e.g. "new skill: token-radar; harness fix in run-harness"}
{If conflicts: "Manual: aeon.yml (new skills), scripts/foo.sh (local edit)"}
PR: {url} (or "dry run — nothing changed")
Pass `--mute-key "aeon-update:${HEAD7}"` so a muted sync doesn't re-ping for the same upstream HEAD.
## Exit taxonomy
| Code | When | Notify |
|------|------|--------|
| `AEON_UPDATE_OK` | PR opened with ≥1 clean file (conflicts, if any, listed in it) | Yes - PR link |
| `AEON_UPDATE_MANUAL_ONLY` | Upstream changed only operator-owned / conflicting files - no clean apply, no PR | Yes - manual call-out |
| `AEON_UPDATE_REPORT` | `report` mode - delta computed, nothing mutated | Yes - dry-run summary |
| `AEON_UPDATE_IN_SYNC` | Baseline already == upstream HEAD | No (log only) |
| `AEON_UPDATE_PR_OPEN` | An earlier `aeon-update/sync-*` PR is still open - nothing written | Only if upstream moved past it (one line) |
| `AEON_UPDATE_BASELINE_SET` | First run - watermark anchored, nothing applied | One-line notify |
| `AEON_UPDATE_IS_UPSTREAM` | This instance is the upstream repo itself | No (log only) |
| `AEON_UPDATE_BASELINE_UNREACHABLE` | Baseline is not an ancestor of HEAD (history rewrite) | Yes - asks for `reset=` |
| `AEON_UPDATE_ERROR` | `scripts/aeon_update.py` could not read upstream or the repo - nothing applied | Yes - the error |
## Constraints
- **Never** push to `main` or auto-write an OPERATOR-owned path (`aeon.yml`, `soul/`, `memory/`, `STRATEGY.md`, `.mcp.json`, `output/`).
- **Never** delete a fork-only skill, and **never** copy `catalog/*.json` / `eyebrowlock.json` from upstream - regenerate them. Two exceptions: `catalog/skill-packs.json` has no generator and is synced as part of the pack-registry unit (S5), and a single skill's lock entry may be carried from upstream when that skill's tree matches upstream HEAD exactly (S7 fail-safe).
- **Never** advance `baseline_sha` without carrying unresolved conflicts forward in `pending_conflicts`.
- Cross-repo compare caps at 300 files - note `files_truncated=true` and let a follow-up run pick up the rest.
- A clean, in-sync run is **correct**, not a failure - it notifies nothing.
## Network note
Every network call is `gh api`, which authenticates via `GITHUB_TOKEN` automatically - no `curl`, no `./secretcurl`, no `$SECRET` on the command line for the Bash permission layer to refuse, and no secret beyond the default `GITHUB_TOKEN`. There are no irreversible side-effects: the skill's only mutation is a PR against this instance's own repo, which the operator reviews and merges. Retry policy: on `403` with `X-RateLimit-Remaining: 0`, sleep 60s and retry once; on a persistent contents-API failure for one file, mark it `UNREADABLE` in the report and continue with a partial sync rather than aborting the whole run.
版本历史
-
ec7a50c
当前 2026-11-02 13:45
重构为 Python 脚本以绕过权限限制;修复基线状态未隔离导致的主分支冲突问题;移除文件比较数量限制。
-
9df7482
2026-10-18 21:18
将.github/dependabot.yml归类为算子自有文件,防止被上游更改覆盖。
-
f8ff65c
2026-09-22 04:12
修复eyebrow版本硬编码导致的CI验证失败问题;修正changelog格式化遗漏及文档矛盾说明;优化现有技能编辑时的防漂移机制。
-
c648040
2026-08-27 12:26
本次提交主要增强了 aeon-update skill 的安全性:对 eyebrow 下载资源进行 SHA256 哈希校验以防止篡改,并限制其运行环境以隔离敏感信息;同时修复了可能错误执行 tarball 文件的潜在 bug。Skill 核心功能描述未变。
-
2ee61dc
2026-08-19 14:43
修复上游移除已启用技能时的静默删除问题,将其降级为冲突并高亮显示;增强三向合并对自有文件的冲突处理与安全机制。
- fc05537 2026-08-05 22:12


