Project Hub  Owner's guide

The how, from setup to every gate.

What a Hub is and why it exists is the product page next door. This one is the reference an administrator keeps open: the activation walkthrough, the annotated tree, every command with its real flags and refusals, the exact lists of what travels and what never does, and the budgets that stop a push.

  • init
  • pull
  • push
  • doctor

Project Hub 0.2.0 · Project Context 0.10.0


01 setting one up

Three moves, then one conversation

You make the repository; an agent makes it yours. Nothing here needs a plugin, a service, or an account beyond the Git host you already use.

Make a private repository from the scaffold, and clone it

Fork or copy the public scaffold into a private repository of your own. That repository is your Hub, and it stays private — builders are never invited to it, which is the entire governance model.

Open the folder in the tool you already work in

Claude Code or Codex. Activation is a conversation with an agent that has file access to the folder — no Obsidian, no shell script, no build step.

Ask for it

In Claude Code the scaffold ships the agent at .claude/agents/hub-onboarding.md, so set this up, activate the hub, or onboard me dispatches it. In any other tool, paste the one line below instead — it points at ADAPTER-PROMPT.md, which is the same seven steps.

activation prompt · any tool with file access

Read `ADAPTER-PROMPT.md` at the root of this folder and follow it.

What the activation agent actually does

Seven steps, one pass. It asks at most one question per step, and only where a step calls for one.

Step What it does
1 · Identify the host Works out which tool it is running in, then answers two capability questions in writing: can this host dispatch subagents from files on disk, and can it read and write files in this folder. A chat-only assistant is told to stop and ask for a tool with file access rather than pretending.
2 · Personalise, once Scans for {{TOKEN}} placeholders, asks for every value in one message, and records the answers in HUB-OWNER.md at the root. It lives at the root deliberately: the push allow-list covers global/ and blueprint/ only, so your own details cannot travel into a repository by accident.
3 · Write the host pointer One thin file pointing at AGENTS.mdCLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, .cursor/rules/project-hub.mdc for Cursor. Codex gets nothing, because it reads AGENTS.md directly and that file is already the contract. A pointer never restates a rule.
4 · Seed global/ Walks you through identity, guardrails and workflows in that order, then writes global/SUMMARY.md routing to whichever now has content. A file still carrying <!-- project-hub:unfilled --> is an untouched seed it may write; a file without that line is your work, and it is skipped.
5 · Register the first project Asks for one repository and whether Project Context is already installed there. No install yet means init; already installed means pull, then push when global/ holds something worth sending. Not now is a fine answer — a Hub with no projects is a working Hub.
6 · Offer Obsidian Says first that it is optional, then what it adds. If you say yes it tells you how to open the folder as a vault and install plugins from Obsidian's own browser. It writes no plugin code and creates no .obsidian/plugins/ directory.
7 · Report back Numbers, not adjectives: host and model, subagent availability, tokens replaced, the pointer file written or skipped, each global/ file written or skipped and which still carry the unfilled marker, the project registered, the Obsidian outcome, every path written, every path skipped with its reason, a confirmation that AGENTS.md was not touched, and the one command worth running next.

Running it again is safe. Every step checks before it writes, skips anything you have customised, and counts the skip. A second activation in a second tool on the same folder is an ordinary thing to do.

the honest version of “it never pushes”

The activation agent runs with file tools only — no shell, no network. It can read, write and edit files in this folder and nothing else. So “never runs git init, never adds a remote, never pushes” is a fact about what it is able to do, not a promise it is keeping. Anything touching Git is yours to run, or belongs to the commands below.


02 the tree

Every folder, and who owns it

Three kinds of content live here, and knowing which one you are looking at tells you whether you may write it: authored here and pushed down, copied up and read-only, or yours alone and going nowhere.

your-hub/ · private repository
your-hub/
├── AGENTS.md                     ← the contract. One file, read first, never duplicated
├── CLAUDE.md                     ← a thin pointer to it. Never a copy
├── ADAPTER-PROMPT.md             ← the seven activation steps, for a host with no agent format
├── .claude/agents/
│   └── hub-onboarding.md         ← the same seven steps, dispatchable in Claude Code
├── global/                       ← what you author; the pushed subset reaches repositories
│   ├── SUMMARY.md GUARDRAILS.md WORKFLOWS.md
│   ├── IDENTITY.md GOALS.md RESOURCES.md OWNERS.md
│   └── people/ agents/ skills/ shared/
├── projects/
│   └── <repo-id>/
│       ├── MARK.md               ← remote, host, default branch, visibility, builders, links
│       ├── SUMMARY.md            ← your summary of it, ≤ 150 words
│       ├── blueprint/
│       │   ├── EPIC.md           ← authored here, pushed down, read-only there
│       │   └── ARCHITECTURE.md
│       └── pulled/               ← their records, stamped. Never edit; the next pull overwrites you
├── owners_window/                ← yours. Never pushed, never linted, never pulled into
├── registry.md                   ← every known repository, its mark, its last pull and push
├── guides/                       ← six short guides, one question each
├── skills/                       ← the init, pull and push commands
├── .project-hub.json             ← the marker: schema, version, push allow-list, budgets
└── .obsidian/                    ← configuration only. No plugin code

plain Markdown in Git · no database · no runtime dependency

owners_window/ is not in the read order at all — an agent reads it only when you name it. projects/<id>/pulled/ is read when you need what the builders themselves wrote, and not before.


03 the commands

Four commands, one shape

Run them from inside the Hub. --hub may be any path inside it — the tool walks upward looking for .project-hub.json and refuses to run if it never finds one. Both --hub and --format may be given before or after the subcommand.

the shape of every invocation
python3 skills/project-hub/scripts/project_hub.py [--hub PATH] [--format json|text] <command>
Command What moves Writes to
init <repo> Marks a repository, summarises it, installs Project Context into it, then pushes The Hub, then the repository
pull [repo] [--all] That repository's authored set → projects/<id>/pulled/ The Hub only — read-only against the repository
push [repo] [--all] The shareable global/ subset and projects/<id>/blueprint/ → the repository's project-context/ The repository — on a branch, after you confirm
doctor Nothing. The shared Project Context doctor, run on the Hub Nothing — read-only

Nothing writes by accident

init, pull and push each require exactly one of --dry-run or --apply; the pair is mutually exclusive and neither has a default, so an invocation that names neither is rejected by the parser. doctor takes neither, because it is read-only.

The plan is the report

--dry-run prints exactly what would happen and writes nothing; --apply performs it and prints the same report refreshed. Re-runs are idempotent — a second push with nothing to send reports unchanged and creates no branch at all.

Read the exit code, not the tone

0 done, or nothing to do. 1 declined at the confirmation prompt, or the doctor found errors. 2 refused — a conflict, a blocked precondition, or a bad argument. 3 init got as far as it could and stopped at the install step.


04 push — send your work down

The only write into a repository the Hub does not live in

Which is why it is gated every single time. Read the plan first; the diff you approve on --apply is the one --dry-run already printed.

1 · plan and full diff — nothing is written
python3 skills/project-hub/scripts/project_hub.py push notes-api --dry-run
2 · apply — writes, pushes the branch, opens a pull request
python3 skills/project-hub/scripts/project_hub.py push notes-api --apply

The gate, in the order it runs

Nothing to send stops here

If the repository already matches the Hub, it says so and stops. No branch is created.

The target's tree must be clean, and not the default branch

Uncommitted changes in the repository are a refusal — commit or stash first. The default branch is refused outright. An existing sync branch is reused rather than rejected.

The unified diff is printed, and you are asked

The prompt says plainly that this writes into the repository, pushes the branch to the remote, and opens a pull request against the default branch — and that merging stays yours. --yes skips it; a non-interactive session without --yes is declined, never assumed.

Switch, write, stage only its own paths, commit

It switches to hub-sync, creating it only if absent, writes the files, stages exactly the paths it wrote, and makes one commit carrying Source-Commit: and Project-Id: trailers.

The branch is pushed with --set-upstream

Never the default branch, never --force. If the push fails, the report says the branch is committed locally and hands you the exact command to retry.

A pull request is opened against the default branch

An already-open request for that branch is updated, not duplicated. gh is optional: without it the push has still happened — the part that needs credentials — and the report hands back a compare URL to open the request by hand.

one long-lived branch, not one per push

hub-sync is reused. Repeated syncs stack a commit on it and update the same pull request — easier to review than a scatter of dated branches, and it is why nothing here force-pushes, so a reviewer's place in an open request survives. --branch <name> overrides for a one-off. Merging stays with the repository; the tool has no path to it, never merges, never creates a remote, and never runs git init.

Refusals, before a byte moves

Condition Why, and what to do
No project-context/.project-context.json in the repository Project Context is not installed there. Run init.
A copy's hash does not match its stamp It was edited where it landed. That is a conflict, not an overwrite — the place to change it is the Hub.
A file is already at the destination with no stamp This push did not put it there, so it will not silently claim it.
The Hub has uncommitted changes in what would be sent A stamp naming a commit that does not contain those bytes is a false provenance record. Commit the Hub, or pass --allow-dirty.
The shared doctor reports errors in the Hub Fix them, or pass --skip-doctor.
Over budget The file is named, with how many words to trim. See the budgets below.
The branch already exists under --branch, or is the default branch Push works on a branch, always. Pass a different --branch <name>.
A destination is a symlink Never followed. Following a link is how a copy escapes the tree it was meant to stay inside.

Options

Option Does
--all Every registered repository with a readable working copy
--branch NAME Use this branch in the target instead of hub-sync
--yes Skip the confirmation prompt
--allow-dirty Push from a Hub with uncommitted changes in the sent set
--skip-doctor Do not run the shared doctor on the Hub first
--doctor PATH Where the shared Project Context doctor lives
--prune Remove copies the Hub no longer publishes

05 pull — bring their records up

Read-only against the repository, and your inbox

It reads the default branch through the git object database — never the working tree, never the index, never a branch. A test asserts the target's HEAD, its status, and every file's modification time are byte-identical before and after.

pull every registered repository
python3 skills/project-hub/scripts/project_hub.py pull --all --apply

The authored set is an allow-list

SUMMARY.md, NOW.md, PLAN.md, DECISIONS.md, LEARNINGS.md, QUESTIONS.md, tasks/, decisions/, questions/, inbox/, indexes/. Everything else is excluded because it is not on the list — which is why sessions/, the marker, the protocol text and the whole pushed set never arrive here. Symlinks and submodules are skipped and reported, never followed.

Every mirror carries a stamp

pulled/STAMP.json records the repository, the branch, the commit, the time, the Hub's version, and a sha256 per file. A pull that finds nothing new rewrites nothing — including the stamp.

It keeps the Hub's own bookkeeping current

It writes projects/<id>/MARK.md if it is absent — a pulled project with no mark is unaddressable — refreshes the managed state line in projects/<id>/SUMMARY.md when that file has one, and updates the registry's last-pull column.

Records that disappeared are reported, not deleted

A mirror file the repository no longer has is reported as stale. --prune deletes those instead. --fetch refreshes remote-tracking refs first and is the only thing here that reaches the network — it writes only remote-tracking refs in your own clone. --branch reads a branch other than the default.

this is also your inbox

Questions and proposals a builder raised arrive here, because it is the only channel they have — they cannot reach the Hub. Its one weakness is latency: nothing moves until you pull. Make pull --all routine.


06 init and doctor

Onboarding a repository, and checking your own

init is for a repository that has no Project Context yet. Five steps, in order, stopping at the first failure.

plan the onboarding — nothing is written
python3 skills/project-hub/scripts/project_hub.py init ../notes-api --dry-run

It derives an id, then writes the mark and the first summary

The id is the remote's repository name slugified, or the directory name; --id overrides it. projects/<id>/MARK.md comes from the template with the remote, host, default branch, visibility, languages and entry points filled in. SUMMARY.md is a generated first draft you edit. Both are create-only — an existing one is reported as preserve_existing and left exactly as it was — and the project is registered in registry.md.

Install and push share one gate, one branch, one confirmation

Steps 1 to 3 touch only the Hub. Steps 4 and 5 both write to someone else's repository, so they run on a single branch — project-hub/init-<id>-<YYYYMMDD> unless --branch names another. Installing on one branch and pushing on another would leave the install uncommitted and the push refusing the tree it had just dirtied. The install is committed as chore(project-context): install Project Context; the push is a second commit on the same branch, and then the ordinary push gate takes over. --no-push stops after the install.

It does not carry the initializer

A Hub carries the Project Context protocol skill, not its installer. So init asks, in order: --installer PATH, the PROJECT_CONTEXT_INIT environment variable, tools.project_context_init in .project-hub.json, then project-context on PATH. If none answers it exits 3 having written nothing into your repository — the mark, the summary and the registry row are still there and still correct. Point it at the initializer and re-run; the create-only plan makes the second run safe.

Refused when it should be

When the repository already has project-context/.project-context.jsonpush is the right command then — when the target is not a git repository, when the derived id is not a slug, when the target's working tree is dirty, when the branch would be the default branch or already exists, and when a non-interactive session did not pass --yes. If the initializer itself fails it exits 3: the repository is left on the branch holding whatever the initializer wrote, recoverable with one git switch and one branch delete.

doctor — one doctor, not a second one

A Hub is itself an ordinary Project Context install, so the health check it runs on itself is the same code every repository runs — called, never copied. The module is found at .agents/skills/project-context/scripts/context_doctor.py, or wherever PROJECT_CONTEXT_DOCTOR or --doctor says. If it is missing, the command tells you it is missing rather than guessing. Findings inside owners_window/ are dropped before the count, and the command exits 1 if any error survives.

run the doctor on the Hub · read-only
python3 skills/project-hub/scripts/project_hub.py doctor

07 what travels

What reaches a project repository, and what never does

The decision worth understanding, because it is about exposure rather than convenience. A project repository may have collaborators who are not in your organisation.

Class Files Why
Pushed by default SUMMARY.md · GUARDRAILS.md · WORKFLOWS.md · skills/ · shared/ — plus that project's blueprint/ What a builder needs to do the work correctly. This is push.global_include in .project-hub.json, verbatim.
Opt-in, per project GOALS.md · RESOURCES.md · people/ · agents/ Objectives across every project, internal dashboards and environments, and a roster of people are each more sensitive than the guardrails a builder actually needs. Name one in that project's push.global_include when you want it there.
Never, at any setting IDENTITY.md Identity is the owner's or the organisation's voice and defaults, and it does not belong committed into someone else's checkout. A Hub whose configuration lists it does not get to push it: the entry is dropped from the allow-list and you are told which entry was refused.
Filtered automatically Any README.md, in global/ or a blueprint/; any file still carrying <!-- project-hub:unfilled --> A README explains the Hub, not your organisation. An unfilled seed is a placeholder, and an empty guardrail in front of an agent is worse than no guardrail. Both are skipped and reported, so nothing goes missing quietly.
Not on the list at all global/OWNERS.md · owners_window/ · templates/ · docs/ · skills/ · guides/ OWNERS.md is deliberately excluded: it is a governance record for you and for review. The rest are Hub machinery. None of them needs a rule to stay put.

Push works from an allow-list, never a deny-list. A path not named in push.global_include is not sent — so any folder you add to the Hub later is non-pushed by default and needs no special case to stay put. That asymmetry is the point: forgetting to add a deny rule leaks; forgetting to add an allow rule does not.


08 the budgets

Checked before anything moves, not suggested

The budget exists to keep what an agent loads small. Over budget is a refusal that names the file and how many words to trim — and when the whole subset is over, the report names the three widest files to start with.

Path Words
global/SUMMARY.md 150
any other global/ record 400
the whole pushed global/ subset 2,000
blueprint/EPIC.md 600
blueprint/ARCHITECTURE.md 1,200

What counts as a word. Frontmatter, fenced code blocks and HTML comments are stripped before counting — otherwise a record with one worked example would read as over budget while a wall of prose stayed under it. The numbers live in .project-hub.json under push.budget_words, and any positive integer you put there replaces the default. The identity rule is not overridable that way; the budgets are.


09 owners_window/

Your own room

Where you write about anything: future projects, reflections, ideas, a half-formed argument with yourself. Three negatives define it, and all three are structural rather than remembered.

Never pushed

The allow-list guarantees it mechanically. owners_window/ is not named in push.global_include, and push sends only what is named — so this is not a rule anyone has to remember to keep.

Never linted

The doctor skips it — lint.exclude in .project-hub.json lists it, and every finding under that prefix is dropped before the error count. No frontmatter, no schema, no word budget. A place to think stops being one the moment it reports errors.

Never pulled into

Nothing from a repository lands here. It is not in the read order either: an agent reads it only when you name it.

the way out

An idea that matures is promoted — a deliberate edit, an ordinary one, turning it into a blueprint/EPIC.md for a new project, a record in global/, or a MARK.md for a repository that does not exist yet. Freeform in, structured out. Nothing promotes itself.


10 the two plans, and the cost

Two records, two authors, two audiences

blueprint/EPIC.md is yours: what the project is for and what must be true when it is done. PLAN.md in the repository is the builders': what this milestone does about it.

A Serves: line connects them

Each ## M-NNN: item in their PLAN.md carries a - Serves: line naming the epic item it advances. Keep an epic under 600 words and it stays the thing people actually read.

A guardrail change means touching every repository

push --all makes it one command, but it is N branches and N pull requests. That is the honest cost, and there is no version of this design where it disappears.

A repository nobody has pushed to lately goes stale

Though “quietly” overstates it: the doctor reads the push stamps and records how old the oldest one is, and the repository-side review raises stale-snapshot once that passes ninety days. The staleness is visible on their side before it is a surprise on yours.

What you buy for it

Builders hold no permission on the Hub — not even read — and a project repository needs no network at all to work. There is no permission system to configure and no approval gate to trust.


11 what your builders gained

Three things that change what reaches you

Your work in the Hub buys the people in the project repositories something concrete — and it changes what you can expect of a project before you pull it.

They can answer a pushed record without being able to edit it

A builder who disagrees with a guardrail, an epic item, or an architecture record runs project-context capture --kind proposal or files a question in QUESTIONS.md. Either sits in their repository until your next pull — which is why pull --all needs to be routine. Nothing moves until you do it.

PLAN.md is checked against the epic you pushed

A plan item that names no epic item is an error in their doctor. An epic item that no plan item serves is only a warning — the asymmetry is deliberate, because your epic is allowed to run ahead of the milestone in front of them. You see the error in pulled/ before you see it in a pull request.

They have a standing review of their own

project-context review lists what is waiting on a person in their repository, oldest first — including the stale-snapshot finding when the global/ you pushed has not been refreshed in ninety days. That one is aimed at you.


12 not built yet

The honest gap list

Everything above this section is built and tested. Everything in it is not, and naming it is cheaper than discovering it.

The Hub's cross-project assembler

Asking the Hub a question that spans every project. Retrieval works inside a repository today, and a builder's packet sees their project and the global snapshot — which is the correct blast radius. Reading across projects/ is the Hub's job, and it is not built.

A Hub-side review

The pending-items list sorted by oldest unanswered, across every project. The repository-side project-context review is built; the Hub does not yet aggregate them, so today you read what pull brought.

A SQLite cache

Deliberately deferred until an automated query proves slow. Never in a project repository — Markdown and Git are the whole storage contract there.



14 optional, and meant it

Obsidian is a way to look at the folder

The Hub is a Git repository full of Markdown. Every command, every stamp and every check works with Obsidian never installed — nothing above depends on it.

What it adds

Backlinks — a guardrail shows you every project record that mentions it. Graph and search across every project at once. And, with the Claudian plugin, an agent inside the vault, so you write and ask in the same window.

Configuration only, no vendored plugin code

The scaffold ships .obsidian/ configuration and nothing else. There is no .obsidian/plugins/ directory and activation will not create one. Plugins are installed from Obsidian's community browser, updated by Obsidian, and licensed to you — not vendored here, where they would go stale and drag their licences into this distribution.

The recommended list is a list, not an install

The plugin IDs this scaffold recommends are named in .obsidian/community-plugins.json, Claudian first. If a configuration file already exists, activation leaves it alone — your own settings win.


15 next

Where to go from here

The argument for the Hub is next door; the scaffold itself is on GitHub; and the other half of the pair belongs to the people in the repositories.

Clarity comes with context.