How thirteen repos share one AI context.
Each repo carried its own context file, so the same question got a different answer depending on which folder you had open.
Feedspace is not a monolith. It is a backend, a frontend, an embeddable widget, a marketing website, an MCP server, a Chrome extension, API docs, a help centre, and a few more besides. Thirteen repos in all, once we finished mapping them. We split it that way on purpose, and we would do it again.
The cost of that split shows up the moment you put an AI in front of it. Each repo carried its own context file, written by whoever was in that repo at the time. So the same question got a different answer depending on which folder you had open. Ask about a plan limit in the frontend and you got what the frontend knew. Ask in the backend and you got something else, written by someone else, months apart. Neither file was wrong on purpose. Nothing connected them, so nothing kept them in agreement.
We started Feedspace-Hub on 26 June 2026 to fix that. Sixty-seven days later it is 100 commits and 30 merged pull requests.
Three things had to work, and the rest of this post is how each one was built:
- Load the shared context down into a specific repo, without it ever appearing in that repo's pull requests.
- Update itself automatically when somebody pushes a change to the Hub.
- Let you write a new skill from inside the repo you are working in, without leaving it.
What is in the Hub?
One private git repo holding the Feedspace product context once, so no repo has to hold it twice. Plain Markdown and git. No database, no vendor, nothing to host.
your-hub/
├── skills/ one directory per skill. A skill is a folder of
│ └── <name>/ Markdown with an entry file whose first lines say
│ └── SKILL.md what it covers and when the AI should load it
├── repos/ one card per connected repo: what it is, its stack,
│ └── <repo>.md its git remote, where its own context lives
├── decisions/ one file per decision, with the reasoning
├── conventions/ the rules every repo follows
└── distribution/ install.sh, sync.sh, and the two git hooks
On tooling. We use Claude Code, so the skills are bridged into the folder it scans, .claude/skills/. Nothing else here is specific to it. The mechanism is a git-ignored clone plus symlinks, so pointing it at whatever directory your own tool reads for rules or skills is a one-line change.
One design rule sits under all of it: the Hub connects repos, it does not replace them. How to build and run a repo stays in that repo's own context file. The card in the Hub is a pointer, not a copy. The moment the Hub keeps its own second copy of how the frontend works, that copy starts drifting from the frontend.
What each developer gets out of it. A frontend developer gets the plan tiers, the product vocabulary and the API contract without opening the backend or interrupting the person who wrote it. A backend developer gets the same names for the same things, so a field means one thing across the whole product. Anyone picking up a repo they have never opened gets its card and a router that tells them which files to start in. And support gets answers grounded in every repo at once, which is the change people noticed first.
How the pieces fit together
The read path, from a change in the Hub to the AI in your repo knowing about it:
Feedspace-Hub (one repo, the only copy of the shared context)
|
| change is proposed as a PR, reviewed, merged
v
a developer runs `git pull` in their own repo
|
| the post-merge hook fires automatically
v
sync.sh clones or fast-forwards the Hub into .agent/shared/
| ^
| | git-ignored, so it can never
| | appear in a diff or a PR
v
each Hub skill is symlinked into .claude/skills/shared-<name>
|
v
the AI working in that repo now reads both:
the repo's own context -> how to build and run THIS repo
the shared context -> product facts, cross-repo rules
The write path, from something you just worked out back into the Hub:
you are in a repo, you and the AI settle something worth keeping
|
| you say "save this to the hub"
v
the skill restates it and waits for you to confirm the wording
|
v
it routes the change to the right place (decision, product fact,
convention, repo card, or a whole new skill)
|
v
it writes into a Hub checkout, NOT the repo you are working in
|
v
opens a PR against the Hub -> reviewed -> merged
|
v
everyone else gets it on their next pull
How does the shared context get into a repo?
The reason we do not commit the shared files into each repo is copies. Put the same file into thirteen repos and you have thirteen versions of it within a month. Someone fixes a wrong plan limit in the frontend copy, the other twelve keep the wrong number, and now the AI's answer depends on which repo you asked in, which is the exact problem we were trying to end.
So there is one file, and it lives in one place. Each repo pulls that file in rather than holding its own copy, and any correction is made in the Hub. Fix it once, and every repo has the fix on its next pull. No repo holds its own copy of the content, so the content cannot drift between repos. The delivery machinery is a different story, and we say where below.
That also means nothing the Hub adds ever shows up in a pull request, which matters more than it sounds. If a developer opens a PR and finds files they did not write, they will ask what they are, someone will tell them to ignore it, and within a month somebody turns the whole thing off.
We looked at shipping the skills as a published plugin and decided against it. A plugin means a publish step every time we change a line, a version number to keep straight, and one more tool that has to keep working. Git already does all of that, and every developer already has it. So the Hub is Markdown in a repo, and a repo already knows how to pull another repo.
The whole mechanism is about 113 lines of shell. Here it is, with our names taken out so you can lift it.
The installer. Run once per repo, by the first person to connect it.
#!/usr/bin/env bash
# install.sh - wire one repo to the hub.
# ./install.sh /path/to/your/repo
set -euo pipefail
KIT="$(cd "$(dirname "$0")" && pwd)"
TARGET="${1:?usage: install.sh <path-to-repo>}"
cd "$TARGET"
cd "$(git rev-parse --show-toplevel)"
mkdir -p .agent .githooks .claude/skills
cp "$KIT/sync.sh" .agent/sync.sh
cp "$KIT/githooks/post-merge" .githooks/post-merge
cp "$KIT/githooks/post-checkout" .githooks/post-checkout
chmod +x .agent/sync.sh .githooks/post-merge .githooks/post-checkout
# git-ignore the synced hub and the skill bridges, so they can never
# enter a diff or a pull request. This is the whole point.
ensure_ignore() {
grep -qxF "$1" .gitignore 2>/dev/null || printf '%s\n' "$1" >> .gitignore
}
ensure_ignore '.agent/shared/'
ensure_ignore '.claude/skills/shared-*'
git config core.hooksPath .githooks
.agent/sync.sh
The sync script. Copied into each repo by the installer, and called by the hooks.
#!/usr/bin/env bash
# sync.sh - pull the hub into a git-ignored folder, then bridge its skills.
# Safe to run at any time, from anywhere inside the repo.
set -euo pipefail
HUB_URL="${HUB_URL:-git@github.com:your-org/your-hub.git}"
HUB_REF="${HUB_REF:-main}"
cd "$(git rev-parse --show-toplevel)"
SHARED=".agent/shared"
# 1. Clone the hub, or fast-forward the copy already here. If the hub cannot
# be reached, say so and exit 0. The git hooks call this, so it must never
# fail hard and never disrupt somebody's pull.
if [ -d "$SHARED/.git" ]; then
if git -C "$SHARED" fetch --quiet --depth 1 origin "$HUB_REF" 2>/dev/null; then
git -C "$SHARED" reset --quiet --hard FETCH_HEAD
else
echo "hub unreachable; keeping the last synced copy."
exit 0
fi
else
if ! git clone --quiet --depth 1 --branch "$HUB_REF" "$HUB_URL" "$SHARED" 2>/dev/null; then
rm -rf "$SHARED"
echo "no access to the hub; skipping shared skills. Your git is unaffected."
exit 0
fi
fi
# 2. Point the AI at the hub's skills without copying them. The shared-
# prefix stops them colliding with skills the repo already had, and the
# ../../ is relative to .claude/skills/, so change that path to whatever
# directory your own tool reads.
mkdir -p .claude/skills
find .claude/skills -maxdepth 1 -name 'shared-*' -type l -delete
for s in "$SHARED"/skills/*/; do
name="$(basename "$s")"
ln -sfn "../../$SHARED/skills/$name" ".claude/skills/shared-$name"
done
echo "hub synced; skills bridged into .claude/skills/shared-*"
The two hooks. Both are the same three lines, one named post-merge and one post-checkout.
#!/usr/bin/env bash
exec "$(git rev-parse --show-toplevel)/.agent/sync.sh"
Note the symlink in step 2 of the sync script. The skills are not copied into the repo, they are pointed at, so there is only ever one real copy on the machine. The repo keeps its own skills and its own context; the hub adds a layer on top.
A developer who has never heard of the Hub must notice nothing. That was the second hard requirement, and the one that took longest to get right:
- The sync script exits cleanly when it cannot reach the Hub. No access, or simply offline, gets you one printed line and a working
git pull. - Both hooks run after the git operation, so they structurally cannot abort it.
- Everything is opt-in per person. The wiring sits inert until someone turns it on.
One boundary worth stating, because we got it wrong. Pointing git at a hooks folder replaces that clone's hooks wholesale, so a repo already using husky, lefthook or anything else through the same setting has its existing hooks quietly switched off. Check what a repo already has before you wire it, and merge rather than overwrite. Our own installer copies its hooks in without looking, which is a bug we found writing this.
With that caveat, it is safe to commit the wiring to a repo other people work in.
How does it update itself?
Somebody pushes a change to the Hub, and every wired repo picks it up on that developer's next pull or branch switch. No reinstall, no announcement, no message telling people to update.
That works because the installer drops in two git hooks, one that runs after a merge and one that runs after a branch switch, and each does nothing except call the sync script. Pull the repo you are working on and the shared context refreshes behind you. A skill added to the Hub on Monday is in everyone's repo the next time they pull.
Two honest gaps. Pulling with rebase does not fire the merge hook, and git has no hook for a rebase, so anyone who pulls that way silently gets no refresh until their next ordinary pull. Running the sync by hand fixes it. And the hook files themselves are copied in at install time, so when we improve the machinery we have to re-run the installer in each wired repo. We built automatic updates for the content and not yet for the thing that delivers it.
Can you write a new skill from inside the repo you are in?
Yes, and this is the part that keeps the Hub alive. You are working in the frontend, you and the AI work out how something actually behaves, and you say "save this to the hub". A skill picks it up from there and does four things:
- Confirms the exact wording with you first. It saves only what you approve. A wrong fact in the Hub reaches thirteen repos, so nothing is written on an assumption.
- Decides where it belongs. A decision and its why goes to the decision log. A product fact goes to a reference file. A rule every repo follows goes to the shared conventions. A repeatable task becomes a new skill folder. A fact about how to build one specific repo is refused, because that belongs in that repo.
- Writes it with provenance: the date, the source, and one line on what was confirmed.
- Opens a pull request against the Hub. Never a commit straight to the main branch.
The important detail is where the writing happens. It writes into a real Hub checkout, not the repo you are coding in, so it never touches your working tree, your branch, or your open PR. The synced copy inside your repo is git-ignored and gets overwritten on the next pull, so editing it there would just lose your work.
How does anyone set this up?
Once per repo for the first person, then once per machine for everyone else. The commands below are the shape of it, with the names generic.
First, get access. The Hub is private, so ask to be added and make sure your SSH key is on your GitHub account. Then confirm it. If this prints refs, you are in:
git ls-remote git@github.com:your-org/your-hub.git
Second, clone the Hub wherever you keep your projects, and remember the path.
Third, wire the repo. One person does this once per repo. Run the installer from your Hub clone, pointing it at the repo you want to connect:
# wire one repo now, and repeat the same line for each repo you connect
/path/to/your-hub/distribution/install.sh /path/to/your/repo
# ./install.sh /path/to/your/backend
# ./install.sh /path/to/your/widget
# ...and so on, one line per repo
Then commit and push only the three small things it added: .agent/sync.sh, the .githooks/ folder, and the two new git-ignore lines (.agent/shared/ and the shared-* bridges). Nothing else it created should ever be committed.
The installer deliberately leaves two repo-specific touches to you, because both depend on how that repo is set up. Make sure the repo's own context file loads its instruction file, which for us is a single @AGENTS.md line in CLAUDE.md. Then paste the conflict rule into that instruction file so it lives where people work:
Shared context wins for cross-cutting standards. Local context wins for how to build and run this repo.
Fourth, everyone else runs one command, once, on their own machine. The shared content is not committed, so each person turns it on for themselves. It points git at the hooks folder and runs the first sync:
git config core.hooksPath .githooks && .agent/sync.sh
Fifth, verify it, and do both checks. Confirm the shared skills are listed with the shared- prefix, then confirm git status says nothing about them at all. The second check is the one that matters. If anything shared shows up in git status, stop and fix it before you push.
After that it maintains itself. Force a refresh any time by running the sync script. Re-run the installer in each wired repo when the machinery itself changes.
Committing the wiring is safe for people who have not opted in. Their pulls, branch switches, builds and PRs behave exactly as before, and a teammate without Hub access still gets every line of the repo's code. Only the shared skills are absent, and they are told so in one line.
Which repo does this work belong to?
Work arrives as a URL, not a repo name. Someone reports that a page is broken, and the first question is always which of the thirteen to open. The connector cards could not answer it: they are organised by repo, so they tell you what a repo is, not which repo something is. That mapping lived in people's heads, which is exactly what the Hub exists to stop.
The fix was a second map keyed the way the question actually arrives, from address to repo, with every row read out of a deploy config or a source constant rather than assumed.
Resolving to a repo turned out not to be enough. One of ours serves two entirely different codebases, a modern app and a set of legacy public pages, out of a single checkout. So the map has to say that out loud instead of letting a reader assume a repo is one thing. Facts like that belong to no single repo, which is the gap a hub is for.
What is shared, and what belongs to one repo?
We got this wrong twice, in opposite directions, which is why the rule is now written down.
We had a "no em dashes" writing rule that read like a company standard. It is not. It is a frontend rule that the Hub also adopted for itself. Pushing it into the widget, the MCP server or the docs would have been the Hub overriding repos on something that was never its call.
Design went the other way. We assumed design tokens were a shared standard the Hub should own. On 2 July we reversed that: the app, the marketing site and the widget have genuinely different designs, so there is no single owner. The Hub keeps only the method for writing a design spec, and each surface keeps its own spec in its own repo.
The rule that settles both is one sentence: shared wins for cross-cutting standards, local wins for how to build and run this repo. The follow-on is to keep the shared set small. A convention not actually followed by every repo is not a shared convention, and one false line in the shared layer teaches people to distrust all of it.
A related rule: never delete a repo because you cannot see it. Three were not visible under the GitHub access the map was first built with. We marked them "verify" rather than removing them, because narrower access is not evidence that a repo is gone. That looked pedantic in June and was proved right twice, once when the widget repo turned up under a different name in July, and again when the backend was confirmed on 18 August.
Why every decision carries its reasoning
Because we lost one and it cost us. Setting up an automated build, we hit an edge case and went to check why it had been handled the way it was. The decision was there. The reason was not, and nobody remembered it. So we could not tell whether the old choice was still right or whether we were about to break something on purpose, and we spent the time working it out again from scratch.
That is why the log records the why next to the what. Eight decisions are in it now, each with its date and the reasoning that produced it, including the design reversal above. A decision without its reason cannot be revisited, only obeyed or overwritten, and both of those are worse than reading two sentences.
What we get from it
The frontend is wired to it and in daily use by a developer other than the one who built the Hub, which is the test that actually matters. The clearest win so far is one we did not build it for.
Support answers across the whole product now, without pulling a developer off their work. A customer question usually spans several repos at once: what the plan actually includes, what the backend enforces, what the widget does on the customer's own site. That used to mean finding the backend developer and asking them to explain something for the fourth time, and waiting until they had a spare ten minutes. Now the context for all of it is in one place the AI reads, so the answer comes back straight away and it is grounded in the repos rather than in somebody's memory. The developer keeps their afternoon, and the customer gets a faster reply.
The same thing applies inside the code. A developer starting in a repo they have never opened gets its card, the router that says which files to start in, and the product facts they would otherwise have gone looking for. The time that used to go into asking around, waiting, and re-explaining goes into the work instead.
We are still connecting the remaining repos, and the two gaps in the machinery are still open. Neither of those stops it being useful today, which is the reason the rollout is worth finishing.