ion grep
Search the whole org, free
Runs git grep over the default branch of every local mirror. No API calls, no writes. The fastest way to answer “where else does this pattern live?” before you propose changing it.
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..08542dd --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,42 @@ +name: Deploy site to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time; a newer push replaces a queued one but never cancels one in progress. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Set up Pages + uses: actions/configure-pages@v5 + - name: Assemble site + run: bash site/build.sh _site + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 5cb067b..51657e4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ uv.lock.bak .pytest_out config/ +_site/ diff --git a/README.md b/README.md index a57f635..482e061 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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](https://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](#why-ion) for the name. ``` diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..f8aee9f --- /dev/null +++ b/site/404.html @@ -0,0 +1,38 @@ + + +
+ + +Charge does work only along a path, and this URL is not one. Nothing was changed, which is very much the house style.
+ +Guide
+Ion’s executors dry-run unless you pass --apply. Your first day with it should end with a dashboard full of proposals and an org that looks exactly as it did that morning. Approve later, on purpose, one plan at a time.
You need Python 3.12 or newer, uv, git with git subtree, the GitHub CLI gh logged in to an account that can see the org, and git-filter-repo if you want splits.
git clone https://github.com/aska-digital/ion.git && cd ion +uv sync # add --extra graph for Louvain clustering +cp ion.example.yml ion.yml # then edit: org, maintainers, tracker_repo +uv run ion --config ion.yml doctor+
doctor checks the GitHub login, git and subtree, filter-repo, the language-model backend, the bus if you use one, and the config itself. Fix what it names. Then take the first full pass. It reads, measures, files proposals in the tracker, and writes nothing to any other repo:
uv run ion --config ion.yml run # the whole pipeline, executors dry-run +uv run ion --config ion.yml status # what is waiting, held, done; any breaker trip +uv run ion --config ion.yml dashboard # the Org health issue, or --print to just read it+
State lives in ~/.ion/state.db and repo mirrors in ~/.ion/cache. Set ION_HOME to move both, and ION_CONFIG to set a default config path. Your real config, with maintainer names and local paths, belongs outside any public repo: ~/.ion/ion.yml is a good home.
Keep the tracker repo private. Proposal bodies name your private repos and quote their metadata. The default tracker name is ion-tracker. Create it as a private repo before the first run.
Every repo can override the org config in its own .github/ion.yml: who maintains it, whether ion governs it at all, and its policy action levels. Two settings shape your first month more than any others:
policy_strategy: opt-out (the default) governs every repo unless that repo says enabled: false. Choose opt-in to govern only repos that ask.policy_actions sets, per policy class, what a drift finding does: log records it, issue proposes it and waits for approval, fix applies it unattended. Start everything at issue. Promote a class to fix only after its proposals have bored you for weeks. Structural kinds can never be fix.| Key | Meaning |
|---|---|
org | The organisation to steward |
maintainers | GitHub logins whose approval counts. Per-repo lists refine this |
tracker_repo | Private repo where proposals are filed as issues |
approval_sources | Any of github_label, divan_bus, dashboard |
notifiers | stdout, file, divan_bus, github_issue, webhook. Failed deliveries queue and retry |
llm.backend | none (default), claude_cli or anthropic_api. Words only: the model cannot add repos or raise scores |
guards | The circuit breaker: max_repo_fraction, max_archives, max_writes, forbid_protection_lowering |
writes.caps | Per-run cap by write type, for example {open_pr: 5, archive_repo: 1} |
quorum · approval_ttl_days | Approvers required per proposal, and when approvals expire (14 days) |
lifecycle | dormant_days, plus repos or a topic that freeze the stale clock |
boundaries | Tag rules and scope prefix. Cross-scope merges blocked by default |
budget.graphql_points_per_run | Collection stops cleanly at the budget (500) and resumes next run |
Before adopting any change, preview it. ion plan-config NEW.yml runs both configs over the same snapshot and reports candidates gained and lost, drift changes, repos that would opt out, and open proposals that would be re-planned. It writes nothing.
uv run ion --config ion.yml plan-config candidate.yml +uv run ion --config ion.yml plan-config --set thresholds.merge_coupling=0.7+
[P-0007] merge a into b in the tracker repo, labelled with its kind and state. Ion edits this one issue in place for the proposal’s whole life.plan-sha256: … line is the plan you are about to endorse. Your approval will count for that hash and no other.ion:approved, ion:hold or ion:rejected; or send ion approve P-0007 over the bus; or tick the box on the Org health issue. You must be a maintainer of every repo the plan touches, or your label is removed and logged as ignored.ion execute --apply P-0007 re-derives your approval from the record, checks caps and the breaker, then works through the steps in order. A dependent step waits until the pull request before it has landed.ion/* branches and proves the move before asking. It never merges its own work. If the plan ends with archiving the source repo, that arrives as a separate proposal needing a separate yes.# What did ion do, and why does it believe it may? +uv run ion --config ion.yml explain P-0007 # ledger, approvals, steps, states +uv run ion --config ion.yml status # queue, holds, last breaker trip+
If new evidence changes a plan after you approved it, expect ion to ask again. The label comes off by itself. That is the system working, not a fault to work around.
+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]
+ ion grep
Runs git grep over the default branch of every local mirror. No API calls, no writes. The fastest way to answer “where else does this pattern live?” before you propose changing it.
ion autofix
With no flags it prints the diffs and writes nothing. With --propose it files one proposal whose fingerprint covers exactly those diffs. Pull requests exist only after approval, each recomputed and compared with what was approved. Built in: a readme skeleton and a Dependabot config generator. Write your own by subclassing ion.fleet.Autofixer.
ion plan-config
Compares two configs over the same snapshot: candidates added, removed, rescored or blocked, drift and lifecycle changes, proposals that would re-plan. Tune thresholds with evidence instead of vibes.
ion pause
Creates ~/.ion/PAUSED. Every executor and tracker write stops until ion resume. Reads, grep and reports keep working, so you can investigate with the machine’s hands visibly off the controls.
Phase 1 is a LaunchAgent on a Mac. The installer renders the job file and deliberately does not load it, so you read it first:
+deploy/install.sh ~/.ion/ion.yml +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.aska.ion.plist+
It runs ion run daily at 06:30 and logs to ~/.ion/ion.log. The shipped job keeps executors in dry-run. Add --apply only after a few weeks of proposals you would have approved anyway, and keep the source tree outside ~/Documents to avoid macOS privacy prompts. Any scheduler that can run one command a day works as well.
Phase 2, when cost or uptime justify it, swaps the laptop for a GitHub App identity and a scheduled GitHub Action with llm.backend: anthropic_api. Only config changes. The pipeline, gates and guards do not move.
plan-config until the noise is gone.explain after each.fix. Leave archives manual forever, because ion will.A steward bot for a GitHub organisation
+Ion watches every repository in your organisation. It spots drift from your policy, finds repos that belong together or apart, and writes up each change as an exact plan: the evidence, the commands it would run, the way back, and how much of the org it would touch. Then it stops. A maintainer approves that exact plan, or nothing happens. Only then does ion write, through capped, logged requests, with a circuit breaker that halts any run touching too much at once. Archiving a repo always needs its own yes. Ion never deletes, never force-pushes, and never merges its own pull requests.
+ +Each repository gets care. The organisation around them does not, because no person has the whole map in their head.
+Drift
Branch protection missing here, secret scanning off there, a public repo with no licence. Each gap is small. Together they are the org’s real posture, and nobody chose it.
Duplicates
Code copied between repos, commits landing in both on the same days, readmes pointing at each other. The split made sense once. The evidence that it stopped making sense sits unread in the history.
Stale weight
Experiments and one-off tools stay active for years because archiving feels risky and nobody owns the decision. New people cannot tell what is alive.
Fear of the fix
Merging two repos without losing history, splitting one safely, proving nothing was dropped: doable, fiddly, and easy to get subtly wrong. So it does not happen.
Ion’s answer is a division of labour. The machine does the watching, the measuring and the careful writing. People keep the deciding. The next panel is that bargain as a working model.
+Every ion run walks the same six stages. The first three only read and write words. The fourth belongs to a person. The fifth is the only stage that can change anything, and it re-checks the fourth before it moves. Run the model, then try to break the rules: execute without approving, approve and then change the plan, or widen the blast radius past a quarter of the org.
+ +A teaching model with invented repos and a short stand-in fingerprint. The rules are ion’s real ones: approval binds to the plan hash (SPEC §2.4), the breaker guards are the shipped defaults (guide), and the executor re-derives approval from the record instead of trusting a stored flag.
+ +Collect
Metadata comes from one batched GraphQL query under a points budget. File contents come from bare git mirrors on the machine, never from repeated API calls.
Analyze
Health, policy drift, coupling, cohesion, lifecycle and tag boundaries. All deterministic. A language model may later help word a proposal. It cannot invent one, add a repo, or raise a score.
Report
An “Org health” issue lists what waits, what is queued, what is held and what just finished. Ion edits it in place and never comments. A short digest goes to your notifiers, only when something changed.
Each proposal is one issue in a private tracker repo. Its body holds the evidence, the ordered steps, the exact commands, the rollback, the blast radius, and a fingerprint of the plan: plan-sha256: …, a hash that changes completely if one command changes.
A maintainer can answer in three ways. All three are checked the same way: who acted, whether they maintain every repo involved, and which fingerprint was on display when they acted.
+Channel 1
Add ion:approved, ion:rejected or ion:hold. Ion reads the issue timeline to find who applied it. A decision label from a non-maintainer is recorded as ignored and taken off again, with no comment.
Channel 2
Send ion approve P-0007 over the Divan Bus from an approved seat. The signature is verified, then ion mirrors the decision onto the issue as the label, so the public record stays on GitHub.
Channel 3
Tick the approve or hold box on the Org health issue. Ion checks the edit history: every edit since its own last write must be by a maintainer, or the tick fails closed and counts for nothing.
Change the plan and the fingerprint changes. The old yes stops counting, the label comes off, and ion asks again.
+This reviewer-shaped gate is the heart of ion, and it is why the project sits with the amp team: the work is reviewing. The machine prepares a case worth deciding. A reviewer decides it. See why the name fits.
+Even a fully approved run passes through four more checks before and during writing. Each one can stop it without touching anything.
+Typed writes
One file, ion/writes.py, is allowed to call a mutating GitHub API. A test enforces that. Each request has a known type, a checked shape, and a per-run cap. Over the cap, work waits for the next run instead of failing. Pushes may only target ion/* branches, never a default branch.
5 PRs · 1 archive
Default caps per run for the two loudest write types. Yours to set.
Circuit breaker
Before writing, the run totals its own blast radius. During the run, every write is checked again. Trip any guard and the run aborts, says so in the digest, and records the trip where ion status shows it.
25% · 2 · 150
Defaults: share of repos touched, archives per run, total writes. Any branch-protection weakening also trips it.
Exact verification
A merge is accepted only when the imported tree matches the source tree exactly and the source tip is an ancestor of the result. A split must match the rewrite tool’s commit map, commit for commit, plus tree equality. Every moved commit carries an Ion-Origin: org/repo@sha trailer saying where it came from. Counting commits is not a check, so ion never does it.
Ledger and kill switch
ion pause stops every executor and tracker write. Reads still work. Each step writes a before and after row, so a crashed or interrupted run resumes without repeating finished steps. ion explain P-0007 shows the whole history of one proposal.
Underneath all of it: nothing irreversible. No deleted repos, branches or history. No force push, in any spelling, ever. Archive instead of delete. A maintainer merges ion’s pull requests. Ion never merges its own.
+| Guard | Default | What happens when it trips |
|---|---|---|
| Share of org touched | over 25% | Run aborts before writing. Digest and status record it. |
| Archives in one run | over 2 | Run aborts. Archiving still needs its own approval each time. |
| Total writes in one run | over 150 | Run aborts. Per-type caps usually defer work long before this. |
| Weaker branch protection | any | Run aborts. Ion may strengthen protection. It may not lower it. |
| Kill switch | ion pause | All executors and tracker writes stop. Reads continue. |
Defaults from the shipped guard config. Every value is yours to change in ion.yml, and ion plan-config previews what a change would do before you adopt it.
A candidate must persist across repeated snapshots before it becomes a proposal, so one odd day never files an issue. Structural kinds carry ordered steps: a later step refuses to run until the pull request before it has landed.
+| Kind | What it means | Typical evidence |
|---|---|---|
merge | Fold repo A into repo B, full history kept, then a pointer PR in A | Coupling score and its five signals |
split | Move a directory out into a new repo, history kept, removal PR after proof | Separable clusters inside one repo |
move_code | Move code between existing repos, with the same two exact checks | Imports and co-change across the pair |
archive | Put a finished repo away. Always its own approval | Staged lifecycle: active, stale, archive-proposed |
rename · new_repo | Name and homekeeping for splits and merges | The parent proposal that needs them |
policy_fix | Bring a repo back to policy: protection, alerts, scanning, required files | Drift from ion.yml, with an action level per class |
health_fix | Missing readme, licence, CI or description | Per-repo health checks |
autofix | One small change across many repos, as a single proposal of exact diffs | ion autofix preview diffs over the mirrors |
Five signals per repo pair, combined so that one saturated signal alone just reaches the merge threshold of 0.6. Weak signals need company. The name-prefix signal is deliberately tiny: repos called protean-* share a naming scheme, not a life, and ion’s first live run proved the point with 265 pairs that were mostly that.
| Signal | Weight | Hygiene |
|---|---|---|
| Cross-references | 0.6 | Manifests, imports, git URLs, readme links |
| Duplicated code | 0.6 | Identical files plus near-duplicates. Vendored, lock and generated files ignored |
| Co-change | 0.6 | Shared commit days over average commit days. Huge commits, bots and ion itself excluded. Half-weighted unless a content signal agrees |
| Shared dependencies | 0.3 | Overlap of the two dependency sets |
| Shared name prefix | 0.05 | Counts only when another signal is already at least 0.15 |
Tag rules add boundaries: mark repos with topics such as scope:app, say which scopes may depend on which, and cross-scope merges are blocked or flagged before they are ever proposed. Two fleet tools ride on the same mirrors: ion grep searches every repo with no API calls, and ion autofix previews a change everywhere, then files one proposal for exactly those diffs.
--applyRun the checks yourself: uv run pytest -q and uv run python scripts/check_licenses.py. The licence guard fails the build if any dependency falls outside MIT, BSD, Apache-2.0, ISC, PSF or MPL-2.0. No GPL dependency, ever.
Michael Faraday coined the word in 1834, from the Greek ἰόν, “going”, for charged particles that move through a solution toward an electrode. Four threads tie the name to the work:
+Going
Ion travels the whole org on each run and carries a signal from where the evidence sits to where the decision happens.
Lattices
In a crystal, ions sit on the points of a lattice and the pull between them holds it together. Ion watches how repos relate and keeps that structure sound. The lattice is also home ground: it runs in the same family as the incentre lattice and Exo.
Charge
Charge does work only along a path. Ion’s paths are the three approval channels. Off those paths, no current flows and nothing changes.
The amp team
Current is measured in amperes, and ion is owned and reviewed by the amp team, whose craft is reviewing. That fits the design: ion’s centre of gravity is a review gate. In the live Aska Digital deployment, proposals are prepared for a reviewer’s yes or no, and the reviewer’s word is what turns a plan into current.
Ion’s mechanisms come from other projects’ documented behaviour, re-implemented in its own words. Nothing is vendored. The typed-write broker follows GitHub Agentic Workflows’ safe outputs and actions/stale’s per-run caps. The breaker follows Peribolos. Plan-bound approval follows policy-bot. The dashboard follows Renovate’s layout. Coupling follows Adam Tornhill’s change-coupling method. The full credit table names all twenty-five, each with its licence, checked on 2026-10-13.
+Security problems go through private reporting, described in SECURITY.md. Be kind, per the code of conduct.
+Merges, splits and policy fixes, proposed as exact hashed plans. A maintainer approves the plan. Only then does ion write.
+Cyan the machine reads and proposes.
' +
+ 'Amber a maintainer decides.
Nothing structural moves without both.