| name | fork-fleet |
| description | Fork divergence monitor - tracks where the fleet's active forks diverge in CODE (unique commits, new/modified skills) and CONFIG (enable/var/model/schedule vs upstream), gated on real change. |
| metadata | {"category":"core","var":"","tags":["dev","meta"],"cron":"0 10 * * 1"} |
${var} — Divergence scope selector; space-separated tokens, order-independent, all optional:
- scope (
code | config | both, default both) — which divergence dimension to run.
repo=owner/name — override the parent repo whose forks are scanned (else auto-resolved).
fork=owner/name — drill into a single fork (forces code scope; config math needs a fleet).
Empty ⇒ both dimensions over the auto-resolved parent. Examples: `` (both, all forks) · code · config · config repo=octo/aeon · fork=alice/aeon.
Today is ${today}. This is the fleet's divergence monitor. It answers two questions the popularity/liveness skills don't:
- Code divergence — which active forks are building real work (unique commits, new/modified skills) that's worth pulling back upstream?
- Config divergence — where does the configured fleet systematically disagree with upstream's
enabled / var / model / schedule defaults, so the operator can flip a default the fleet has already voted on?
skill-gap ranks what's popular (top 15 by enabled count). This skill's code branch surfaces per-fork unique work; its config branch surfaces where operators disagree with defaults. If 6 of 8 configured forks enable a skill upstream defaults off, upstream is shipping the wrong default; if 5 of 8 disable a skill upstream defaults on, that skill is noise. Both are peer-learning signals.
Operating principles
- Verdict first, catalog second. The operator reads one line and knows if action is needed.
- Silent when nothing changed. Weekly cadence + a dormant/undivergent fleet = a read-once habit to kill. A clean run notifies nothing.
- Per-fork compare is one call, not three.
/compare/{owner}:main...{fork_owner}:main returns ahead/behind/unique commits/files in a single round-trip; the recursive git-tree returns the fork's whole file list in one call.
- Substance ≠ noise. A new
skills/*/SKILL.md is worth 100 cron-time edits in aeon.yml. Score accordingly. On the config side, an untouched template fork is not a "vote" — exclude it from divergence math.
Shared setup (all scopes)
S0. Bootstrap + load state
mkdir -p memory/topics
[ -f memory/instances.json ] || echo '{}' > memory/instances.json
[ -f memory/topics/fork-fleet-state.json ] || echo '{"forks":{},"last_run":null}' > memory/topics/fork-fleet-state.json
[ -f memory/topics/fork-digest-state.json ] || echo '{"last_run":null}' > memory/topics/fork-digest-state.json
Read memory/MEMORY.md for high-level context and scan the last ~3 days of memory/logs/ — drop anything already reported so a weekly signal isn't re-sent.
- Read
memory/instances.json → the set of repo full_names that are managed instances (tagged separately from organic community forks in the report).
- Read
memory/topics/fork-fleet-state.json → prior run's per-fork {pushed_at, ahead_by, default_branch, new_skill_count} keyed by full_name. Used for the code what-changed delta.
- Read
memory/topics/fork-digest-state.json → prior config-divergence snapshot (schema in step B8). Used for the config week-over-week delta.
S1. Parse the scope selector
Parse ${var} into tokens:
SCOPE = code, config, or both (default both if no scope keyword present).
REPO_OVERRIDE = value of a repo=owner/name token, if any.
SINGLE_FORK = value of a fork=owner/name token, if any. If SINGLE_FORK is set, force SCOPE=code (single-fork config divergence is meaningless — the config math needs a fleet of ≥2 configured forks).
S2. Resolve the parent/target repo
Resolve PARENT_REPO in priority order:
REPO_OVERRIDE if the repo= token was given.
- Else auto-resolve from this running instance:
PARENT_REPO=$(gh api repos/$(gh repo view --json nameWithOwner -q .nameWithOwner) --jq '.parent.full_name // .full_name')
- Else fall back to the first non-comment, non-empty line of
memory/watched-repos.md.
If none resolves, write status FORK_DIVERGENCE_NO_TARGET to memory/logs/${today}.md and stop (no notification).
PARENT_NAME="${PARENT_REPO##*/}"
PARENT_OWNER="${PARENT_REPO%%/*}"
PARENT_DEFAULT_BRANCH=$(gh api "repos/${PARENT_REPO}" --jq '.default_branch')
S3. List + classify forks (single call, shared by both branches)
One paginated listing — includes default_branch, archived, disabled, pushed_at, stars, description:
gh api "repos/${PARENT_REPO}/forks" --paginate \
--jq '[.[] | {full_name, owner: .owner.login, default_branch, pushed_at, pushed_at_epoch: (.pushed_at | fromdateiso8601), stargazers_count, open_issues_count, archived, disabled, description}]'
Skip archived=true or disabled=true. Retain the rest as the total fork population (N_TOTAL). Classify by activity window:
- Active =
pushed_at within last 30 days.
- Stale = 30–365 days.
- Dormant = >365 days or never pushed after creation.
The Active set (pushed in the last 30 days) is the shared working set for both branches — this matches the config branch's original 30-day cutoff exactly.
- If
SINGLE_FORK is set: filter to that one fork, treat it as active, and skip the classification math.
- If zero active forks (and
SINGLE_FORK unset): both branches short-circuit. If there is also no code-side state change (no new forks, none flipped active↔stale vs prior fork-fleet-state.json), write status FORK_DIVERGENCE_QUIET to the log, update the state files' last_run, send no notification, and stop.
Cap active-fork deep processing at 50 per run — if more, rank by pushed_at_epoch desc and trim (log truncated_at=50).
Now dispatch: run Branch A if SCOPE ∈ {code, both}, Branch B if SCOPE ∈ {config, both}.
Branch A — Code divergence (runs when SCOPE ∈ {code, both})
A1. Per-fork compare (one call each)
For each active fork, call cross-repo compare using the fork's own default_branch and full_name (absorbs any repo-rename drift):
gh api "repos/${PARENT_REPO}/compare/${PARENT_OWNER}:${PARENT_DEFAULT_BRANCH}...${FORK_OWNER}:${FORK_DEFAULT_BRANCH}" \
--jq '{ahead_by, behind_by, status, files: [.files[]? | {filename, status, additions, deletions}], commits: [.commits[]? | {sha: .sha[0:7], msg: .commit.message | split("\n")[0], author: .commit.author.name, date: .commit.author.date}]}'
On 404 (branch missing / fork emptied): mark fork UNREADABLE and continue.
On 429: sleep 60s, retry once. On 5xx: sleep 10s, retry once. On persistent fail: mark API_FAIL for that fork.
Cross-repo compare returns unique fork commits (commits) and changed files (up to 300) in one shot — no separate /commits calls needed.
A2. Classify divergence signals per fork
From the files array, tag each fork:
- New skills: files with
status=added under skills/*/SKILL.md
- Modified skills:
status=modified under skills/*/SKILL.md
- Custom schedule: any change to
aeon.yml
- Modified dashboard: any change under
apps/dashboard/
- Custom notify: change to
notify or notify-jsonrender
- New content: additions under
output/articles/ or memory/topics/
- Config changes: changes to
CLAUDE.md, .github/, bin/, or root scripts/
- Workflow changes: changes under
.github/workflows/
A3. Score each fork (substance-weighted)
score = 10 × (new skill files)
+ 4 × (modified skill files)
+ 2 × min(unique_commits, 15)
+ 3 × (new content files, capped at 5)
+ 2 × (workflow/config files, capped at 3)
+ 1 × (custom-schedule flag)
+ 1 × stargazers
Sort active forks by score descending. Flag any fork with ≥1 new skill file as a PROMOTE candidate; ≥3 unique commits OR ≥1 modified skill as REVIEW; otherwise NOTE.
A4. Deep-read top upstream candidates
For every PROMOTE fork (capped at 5), fetch each unique skill's SKILL.md from the fork's default branch:
gh api "repos/${FORK_FULL_NAME}/contents/${SKILL_PATH}?ref=${FORK_DEFAULT_BRANCH}" --jq '.content' | base64 -d
On failure fall back to the file-tree listing and note "could not read content". Synthesize each unique skill into a 1–2 sentence description of what it does. Do not deep-read REVIEW or NOTE forks (output stays actionable).
A5. Compute week-over-week delta (code)
Compare the current active-fork set to prior fork-fleet-state.json:
- NEW_FORK: full_name absent from prior state
- NEW_ACTIVE: was stale/dormant, now active
- WENT_STALE: was active, now stale/dormant
- NEW_SKILLS: active in both snapshots,
new_skill_count increased
- GONE: archived / deleted since prior run
A6. Pick the code verdict
One line. Priority order:
NEW UPSTREAM CANDIDATE: {fork} — if ≥1 PROMOTE fork has ≥1 new skill not present in prior state
ACTIVE FLEET: {N} forks building — if ≥3 PROMOTE+REVIEW combined
FLEET STIRRING: {N} new active — if ≥2 NEW_FORK or NEW_ACTIVE
HOLDING PATTERN: {N} active, no new work — active forks present but nothing crossed REVIEW
DORMANT: no active forks — shouldn't reach notify (S3 gates it); included for the log-only path
A7. Build the code-divergence article part
Assemble this block (it becomes Part 1 of the combined article in the final section):
## What changed this week
- **New forks**: [list or "none"]
- **Went active**: [list or "none"]
- **New skills landed**: [fork → skill names, or "none"]
- **Went stale**: [list or "none"]
- **Archived/deleted**: [list or "none"]
(Omit the entire section if every bucket is empty.)
## PROMOTE — upstream contribution candidates
### {fork_full_name} — score N [MANAGED | COMMUNITY]
**Activity:** last pushed YYYY-MM-DD · stars N · +N/-M commits vs upstream
**Unique skills:**
- `skills/foo/SKILL.md` — {one-line synthesis of what it does, from deep-read}
- `skills/bar/SKILL.md` — {synthesis}
**Why promote:** {1–2 sentence take — what this skill does that upstream lacks, and whether it's generalizable}
**Suggested action:** Open a PR cherry-picking `skills/foo/` (or reach out to {owner} to upstream themselves).
(Repeat for each PROMOTE fork, capped at 5. If PROMOTE is empty: "No upstream candidates this week.")
## REVIEW — worth a look
| Fork | Score | Ahead | New/Modified | Notable |
|------|-------|-------|--------------|---------|
| owner/repo | N | +N/-M | 0/2 | dashboard rewrite, custom notify |
(Omit if empty.)
## NOTE — low divergence
Terse one-liner per fork: `owner/repo (+N/-M, schedule tweak only)`. Collapse into a count if >5 entries. Omit if empty.
## Fleet vs community
| Category | Count |
|----------|-------|
| Managed instances | N |
| Community forks | N |
| Stale (30-365d) | N |
| Dormant (>365d) | N |
## Code source status
`forks_list=ok|fail · compare_ok=N/M · deep_read=N/M · rate_limit_retries=N · unreadable=N`
If PROMOTE has >5 forks, keep only the top 5 by score; list the rest in REVIEW.
A8. Update code state
Write memory/topics/fork-fleet-state.json:
{
"last_run": "${today}",
"last_status": "FORK_FLEET_OK",
"parent_repo": "owner/repo",
"forks": {
"owner/repo": {
"pushed_at": "YYYY-MM-DD...",
"default_branch": "main",
"ahead_by": N,
"behind_by": N,
"new_skill_count": N,
"score": N,
"tier": "PROMOTE|REVIEW|NOTE|UNREADABLE|API_FAIL",
"unique_skills": ["skills/foo/SKILL.md", "..."]
}
}
}
A9. Set the code branch status
| Status | Meaning |