Ion is a steward bot for a GitHub organisation. It watches every repo, spots drift from your policy, and proposes structural changes (merge, split, archive, rename, move code). It never changes structure without a maintainer's approval. Apache-2.0.
Website: aska-digital.github.io/ion, an infographic abstract with a working model of the approval gate, plus a guide to running ion. The site source lives in site/ and deploys with .github/workflows/pages.yml.
See Why ion for the name.
collect -> analyze -> propose -> approve -> execute -> report
- collect: one batched GraphQL query for metadata, plus bare git mirrors under
~/.ion/cachefor contents. No REST contents calls. - analyze (deterministic, no LLM): per-repo health, policy drift, pairwise coupling, in-repo cohesion, staged lifecycle, tag boundaries.
- propose: scores, hysteresis, cooldown, an optional LLM pass that writes the text, one tracking issue per proposal, each with a plan hash.
- approve: a maintainer applies a label, sends a bus message or ticks a dashboard box. Ion checks who did it and which plan they saw.
- execute: the only layer that writes, and only through typed, capped, logged write requests. Dry run by default. Ledgered, resumable, behind a circuit breaker.
- report: the "Org health" dashboard issue, a markdown report, a short digest to your notifiers.
- Typed write requests with per-run caps. Every GitHub write is a validated request of a known type; each type has a cap per run (5 PRs, 1 archive, ...). Over the cap, work waits for the next run.
- Circuit breaker. A run that would touch more than a quarter of the org, archive more than 2 repos, lower branch protection anywhere, or make too many writes is aborted before it writes.
- Approval bound to a plan hash. The issue shows
plan-sha256: ...; an approval counts only for that hash; any re-plan takes the label off and asks again. - Action level per policy (
log,issue,fix) and opt-in or opt-out per repo. - Co-change hygiene. Huge commits, bots and Ion's own identity are ignored; minimum revision counts; the shared-name-prefix signal is down-weighted and never counts alone.
- Exact verification of moves. Tree equality plus the filter-repo commit map, and a
Ion-Origin: org/repo@shatrailer on every moved commit. - Org health dashboard issue with approve and hold checkboxes, where the actor is still verified.
- Staged lifecycle (active, stale, archive-proposed) with a frozen exemption. Archiving is never automatic.
ion plan-configpreviews what a config change would do before you use it.- Ordered changesets: multi-repo proposals list their PRs in order, with dependencies and live states.
- Boundary rules from repo tags (GitHub topics): violations are findings, cross-scope merges are blocked or flagged.
ion grepover every repo's mirror, andion autofixfor fleet changes (preview diffs, then a proposal; PRs only after approval).
Needs Python 3.12+, uv, git (with git subtree), the GitHub CLI gh (logged in), and for splits git-filter-repo.
git clone <this repo> && cd ion
uv sync # add --extra graph for networkx (Louvain clustering)
uv run ion --config ion.yml doctordoctor checks gh auth, git and subtree, filter-repo, the LLM backend, the bus (if used) and the config.
collect -> analyze -> propose -> approve -> execute -> report
| ^ |
boundaries, label, bus, typed write requests
lifecycle, dashboard -> caps -> circuit breaker
coupling box -> GitHub / git push
(bound to the plan hash)
Source layout: ion/collect (GraphQL, mirrors, snapshots), ion/analyze (health, policy, coupling, cohesion, lifecycle, boundaries), ion/propose (scoring, LLM text, engine), ion/approve (label, bus, state machine), ion/execute (one executor per proposal kind, provenance.py for verification), ion/writes.py (typed requests, caps, breaker), ion/changesets.py, ion/report (markdown, digest, orghealth.py for the dashboard issue), ion/fleet.py (grep, autofix), ion/planconfig.py. The full design is in SPEC.md.
Copy ion.example.yml to ion.yml and edit. Pass it with --config, or set ION_CONFIG, or keep it at ./ion.yml or ~/.ion/ion.yml. State lives in ~/.ion/state.db (ION_HOME overrides). Per-repo overrides go in <repo>/.github/ion.yml (keys: maintainers, ignore, enabled, policy, policy_actions).
Keys worth knowing:
| key | meaning |
|---|---|
org |
the organisation to steward |
maintainers |
GitHub logins whose approval counts |
tracker_repo |
repo where proposals are filed as issues. Keep it private. Proposal bodies name your private repos and quote their metadata. Default ion-tracker. |
approval_sources |
github_label (default), divan_bus, dashboard (checkboxes on the Org health issue) |
notifiers |
list of stdout, file, divan_bus, github_issue, webhook |
llm.backend |
none (default), claude_cli, anthropic_api |
policy_actions |
per policy class (branch_protection, dependabot_alerts, secret_scanning, required_files): log (record only), issue (propose, needs approval; the default) or fix (apply unattended). auto_apply is the old spelling of fix. |
policy_strategy |
opt-out (default: every repo is governed unless it sets enabled: false) or opt-in |
guards |
circuit breaker: max_repo_fraction, min_repos, max_archives, max_writes, forbid_protection_lowering |
writes.caps |
per-run cap by write type, for example {open_pr: 5, archive_repo: 1} |
coupling |
co-change filters: max_changeset_size, min_revs, min_shared_revs, prefix_weight, bot_authors |
lifecycle |
dormant_days, frozen (repos), frozen_topic |
boundaries |
tag rules [{from, allow}], scope_prefix, block_cross_scope_merge |
report.org_health_issue |
publish the Org health dashboard issue |
thresholds |
merge_coupling, split_cohesion_gap, stale_days, persist_runs, cooldown_days, ... |
quorum, quorum_by_kind |
approvals required per proposal |
approval_ttl_days |
approvals expire (default 14) |
budget.graphql_points_per_run |
collection stops cleanly when reached (default 500) |
git_identity |
author of commits Ion makes. No AI trailer unless ai_attribution: true. |
Deployment configs (with your org's maintainers, bus addresses and paths) belong outside this repo, for example in ~/.ion/ion.yml or in your private tracker repo.
Each notifier is an entry in notifiers:
stdout: prints the digest.file: appends topath.divan_bus: posts through a bus client subprocess (bus.client,bus.python,bus.url,bus.seat, andtoorbus.notify).github_issue: replaces the body of issuenumberin the tracker repo.webhook: POSTs{"text": ..., "content": ...}(Slack and Discord both accept it) tourlor the env var named byurl_env.
A failed delivery is queued in the database and retried on the next run. A digest is sent only when something changed.
- Ion files an issue
[P-0007] merge a into bin the tracker repo, labelledion:proposal,kind:<k>,state:<s>and, while it waits, the org-wideneeds decisionlabel. That label comes off when the proposal leavesawaiting-approval, so a dashboard can treat it as the live decision queue. The body holds the evidence, the changesets in order, the exact commands the executor will run, the rollback, the blast radius andplan-sha256: <hash>. Ion edits the body in place and never comments. Pull requests ion opens also carryneeds decision, because they wait for a maintainer to merge them. - A maintainer decides in one of three ways:
- adds
ion:approved,ion:rejectedorion:hold. Ion reads the issue timeline and takes the actor of the label event. A decision label from anyone who is not a maintainer (for the repos involved) is recorded as ignored and taken off again (one API call, no comment); - sends a Divan Bus message
ion approve|reject|hold P-0007(optionallyplan-sha256:<prefix>) from a seat inbus.maintainer_seats, after its signature is verified. Ion mirrors the decision onto the issue as the label, so the record lives on GitHub; - ticks the approve or hold box on the Org health issue (
approval_sources: [dashboard]). Ion reads that issue's edit history and accepts the tick only if every edit since its own last write was made by a maintainer; an edit it cannot attribute fails closed.
- adds
- An approval counts only for the plan the approver saw. Ion records which plan hash was on display just before each decision. If the plan changes (different commands, or the score moves by 0.15 or more) the hash changes, the recorded approvals stop counting,
ion:approvedandion:holdcome off the issue, and approval is asked again. A decision made before any plan was displayed never counts. - Approvals expire after
approval_ttl_days(the label comes off too).quorumdistinct approvers are needed (per kind if set). - Rejected proposals are suppressed for
cooldown_daysunless the score rises by 0.15. - Archiving is never automatic: it always needs its own approval, whatever the policy actions say.
A candidate must persist across persist_runs collection snapshots before it becomes a proposal.
Per repo pair: cross-references (manifests, imports, git URLs, README links), duplicated code (identical blobs plus MinHash near-duplicates over normalised lines, ignoring vendored, lock and generated files), co-change, shared dependency set, shared name prefix. They combine as a noisy-or with weights 0.6, 0.6, 0.6, 0.3, 0.05. One saturated signal alone reaches exactly 0.6, the merge threshold. Public/private pairs are flagged as a visibility decision.
- Co-change is shared revisions divided by the average number of revisions of the two repos. A revision is a day with a kept commit. Commits touching more than
max_changeset_sizefiles, commits by bots and commits by Ion's own git identity are left out; a repo needsmin_revsrevisions and a pairmin_shared_revsshared ones. Co-change is half-weighted unless a content signal agrees, because one person working on several repos on the same days proves little. - Name prefix counts only when another signal is at least 0.15, with a small weight. Repos named
protean-*share a prefix because of a naming scheme.
ion collect | analyze | propose [--dry-run] | sync-approvals | execute [--apply] [P-0007]
ion report | run [--apply] | status | explain P-0007 | pause | resume | doctor
ion grep PATTERN [--path GLOB] [--repo GLOB] [-i] [-F]
ion autofix [NAME] [--repo GLOB] [--propose]
ion plan-config NEW.yml (or --set key=value ...)
ion dashboard [--print]
run is the whole pipeline and what the scheduler calls. Executors dry-run unless --apply is given.
ion grepsearches the default branch of every mirrored repo withgit grep. No API calls.ion autofixlists the autofixers;ion autofix NAMEprints the diffs it would make (nothing is written anywhere);--proposefiles one proposal for them. PRs are opened only by the executor, after a maintainer approves exactly those diffs. Write your own by subclassingion.fleet.Autofixer, callingregister, and naming the module underautofix.pluginsin the config.ion plan-configcompares the current config with a new one over the same snapshot: candidates gained and lost, policy drift, repos that would opt out, lifecycle changes, boundary violations, open proposals that would be re-planned. It writes nothing.
- Approval before structure. Every executor's
applycalls one gate first (also for dry runs). It refuses unless the proposal has a verified approval for its current plan hash, or is apolicy_fixorhealth_fixwhose class has action levelfixin a governed repo. A status flag in the database is not approval: the gate re-derives it from recorded, maintainer-verified approvals, the epoch, the plan hash, quorum and TTL. Archive, merge, split and the rest can never be unattended. - Typed writes with caps. Nothing but
ion/writes.pycalls a mutating GitHub API (a test enforces this). Each request is schema-validated, counted against a per-type cap for the run, checked by the breaker and logged inwrite_log. A request over its cap is deferred to the next run, not failed. Pushes must targetion/*branches, never the default branch. - Circuit breaker. Before an
--applyrun writes anything, a preflight adds up its blast radius; every write is checked again in flight. More than a quarter of the repos, more than 2 archives, any branch-protection change that is weaker than the current rule, or more than 150 writes: the run stops, says so in the digest, and records the trip (ion status). - Kill switch.
ion pause(creates~/.ion/PAUSED) orpaused: truestops every executor and all tracker writes. Reads still work. - Nothing irreversible. No deletes of repos, branches or history. Archive instead of delete. No force push, ever: a guard rejects
--force,-f,--force-with-lease,+refspec,:refdeletes and branch deletion in any git call the executors make. - History kept and verified exactly. Merge uses
git subtree addwithout--squashand checks that the target'sHEAD:<prefix>tree equals the source tip's tree and that the tip is an ancestor of the result. Split and move usegit filter-repoon a fresh clone and check the commit map (every source commit touching the moved paths has a counterpart) and tree equality before anything is removed from the source. Counting commits is not a check. Rewritten commits carryIon-Origin: <org>/<repo>@<sha>; the merge commit carriesIon-ProposalandIon-Origin. - Ordered changesets. A dependent step (the pointer PR, the removal PR, the archive) refuses to run until the PR or step before it has landed.
- Archive is a separate approval after a merge (
archive_requires_second_approval, default true). - Ledger. Every step writes a
beforeand anafterrow, so a failed run resumes without repeating finished steps (ion explain P-0007shows it). - Low noise. Issues are edited in place. Ion never comments on other repos and opens PRs only for real changes. A maintainer merges them; Ion never merges its own PRs.
- Gentle on the API. Metadata is GraphQL with a points budget per run. Contents come from local mirrors.
- Untrusted text. Repo names, descriptions and file contents given to an LLM are marked as data. LLM output is schema-validated, may only lower confidence, cannot add repos, and the plan shown to maintainers always comes from the executor, not the model.
- Tokens come from
ghand are never put on a command line or in logs; git uses a credential helper that callsgh auth tokenat run time. - Licences.
scripts/check_licenses.pyfails if any dependency is outside MIT, BSD, Apache-2.0, ISC, PSF or MPL-2.0. No GPL dependency, ever (pygit2 is refused by name).
uv run pytest -qFully offline: fixture git repos in temp dirs, recorded GraphQL JSON, a stubbed LLM, a fake GitHub that records writes, a fake bus client. Merge, split, move and fleet autofix run end to end against local bare repos.
uv run python scripts/check_licenses.py # the licence allowlist; also run by CI.github/workflows/ci.yml runs both on Ubuntu with Python 3.12.
Phase 1 is a LaunchAgent on a Mac. deploy/install.sh renders deploy/ai.aska.ion.plist into ~/Library/LaunchAgents and does not load it:
deploy/install.sh ~/.ion/ion.yml
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.aska.ion.plistIt runs ion run daily at 06:30 and logs to ~/.ion/ion.log. The shipped plist runs executors as dry runs; add --apply after run once you trust the proposals. Keep the source outside ~/Documents (macOS privacy prompts).
Phase 2 (when cost or uptime justify it): a GitHub App identity and a GitHub Actions schedule with llm.backend: anthropic_api. Only config changes.
Ion's designs stand on published work by other projects. No code was copied from any of them; the ideas were re-implemented from their documentation and described behaviour. Nothing is vendored (see THIRD_PARTY.md). Names and marks belong to their owners, and none is used as a Ion feature name.
| Project | Licence | Idea taken |
|---|---|---|
| gh-aw (GitHub Agentic Workflows) | MIT | typed "safe outputs": the agent only proposes writes, a separate step validates them and enforces per-type limits |
| actions/stale | MIT | a hard cap on API writes per run (operations-per-run) |
| Peribolos (Prow) | Apache-2.0 | abort when a run would change too much (--maximum-removal-delta, minimum admins) |
| palantir/policy-bot | Apache-2.0 | approvals invalidated when the thing approved changes; edited approvals are not trusted |
| Prow (approve, OWNERS, lifecycle, branchprotector) | Apache-2.0 | a notifier edited in place; staged stale-then-rotten lifecycle with a frozen exemption; branch protection from central config |
| ossf/allstar | Apache-2.0 | per-policy action level (log, issue, fix) and opt-in or opt-out strategy |
| ossf/scorecard | Apache-2.0 | separating atomic findings from aggregate scores |
| todogroup/repolinter | Apache-2.0 | rules with levels and optional fixes |
| github/safe-settings, probot/settings | ISC | dry-run preview of a settings change; per-repo overrides of org settings |
| Renovate | AGPL-3.0 (design only) | the dashboard issue layout: one issue regenerated each run, with sections and checkboxes |
| Sourcegraph batch changes, Skyscanner/turbolift | Apache-2.0 (src-cli); Apache-2.0 | preview before apply; a campaign tracks its changesets |
| lindell/multi-gitter | Apache-2.0 | run a change per repo with a dry-run that shows diffs |
| asottile/all-repos | MIT | grep over local clones; the find-repos plus apply autofixer shape |
| github-community-projects/evergreen | MIT | detecting ecosystems to generate a Dependabot configuration |
| Zuul | Apache-2.0 | cross-repo Depends-On ordering of changes |
| Copybara | Apache-2.0 | a provenance label on every migrated commit |
| josh, splitsh/lite | MIT | a split is a deterministic function of history, so it can be re-verified |
| newren/git-filter-repo | MIT | the commit map (.git/filter-repo/commit-map) used for exact verification; the tool itself is run as a separate program |
| bors-ng | Apache-2.0 | verify the combined result before declaring success |
| code-maat and Adam Tornhill's change-coupling method | GPL-3.0 (method only) | coupling as shared revisions over average revisions, with minimum counts and a maximum changeset size |
| CodeScene | proprietary (ideas only) | temporal coupling and knowledge-loss as signals worth building towards |
| Nx, Turborepo, Bazel | MIT, MIT, Apache-2.0 | tag-based allowed-dependency rules (module boundaries, visibility) |
| Mergify | open engine formerly Apache-2.0; now closed | a config simulator to test a rule before committing it |
| PR-Agent, SWE-agent | MIT | self-check of generated text; a record of model calls (planned, see SPEC.md section 8) |
Licences were checked on 2026-10-13 against each project's LICENSE file. If you think a credit is missing or wrong, open an issue.
Contributions are welcome. Read CONTRIBUTING.md first; the short version:
- Ion is Apache-2.0. Do not copy code from other projects. Re-implement ideas in your own words and credit them in the table above. If you ever vendor code, it must be MIT, BSD, Apache-2.0 or ISC, recorded in
THIRD_PARTY.mdwith the upstream commit and the licence text. - Never add a GPL, LGPL or AGPL dependency (
scripts/check_licenses.pyfails the build). - Every change needs tests (
uv run pytest -q), and anything that writes to GitHub must go through a typed write request. - Report security problems privately, see
SECURITY.md. Be kind, seeCODE_OF_CONDUCT.md.
Copyright 2026 ASKA Business Solutions and the Ion contributors. Apache-2.0, see LICENSE and NOTICE. Dependencies are listed in THIRD_PARTY.md.
- The word. Michael Faraday coined "ion" in 1834 from the Greek ἰόν, "going", for the charged particles that move through a solution toward an electrode. An ion is something that goes. ion moves through every repository in the org and carries a signal from one place to another.
- Lattices. In a crystal, ions sit on the points of a lattice, and the pull between them holds the structure together. ion's job is the same: keep the org's structure sound. It watches how repos relate, proposes merges and splits, and holds policy steady. It also fits Exo's lattice theme: the incentre lattice, the exosphere, x0.
- Charge, applied with care. An ion carries charge, and charge only does work along a path. ion acts only through approved paths: the
ion:approvedlabel or a signed bus message. It makes no silent changes. - The amp team. Current is ions in motion, measured in amperes. ion is reviewed, and likely powered, by the amp team, whose specialty is reviewing.