--- id: D-004 kind: decision status: accepted title: Serve thumbnails from the CDN, not the app created: 2026-09-03 asserted_by: person:ren ---
id · kind · status · title · created · asserted_by
Docs · Builder's guide
Everything the four pages before this one deliberately skip: what outranks what, the contract every record obeys, the grammar evidence is written in, how a packet is assembled, which files are not yours to edit — and an honest list of what is not built.
Four pages come before this one and this page repeats none of them. Read them first if you are new; come back here once the loop is habit and you need the contract underneath it.
Written against the code at 0.10.0, not against a guide beside it. Everything here is built and tested unless it appears under not built yet near the end — which is honest about the gaps rather than quiet about them.
None of this is advisory. When a record and the code disagree, the code is right and the record is stale. When a generated view and a record disagree, the view is stale and there is nothing to reconcile.
| Rank | Layer | Authority |
|---|---|---|
| 01 | Primary artifacts | The code, the commit, the pull request, the test result. Highest for any factual claim. |
| 02 | Curated records | NOW.md, DECISIONS.md, LEARNINGS.md and the detail records behind them. Canonical for continuity: what is in flight, what is settled, what was already paid for. |
| 03 | The pushed set | global/ and blueprint/, authored by a Project Hub owner and read-only here. Constraints you did not write and cannot change locally. |
| 04 | Proposed knowledge | Anything still proposed or open. Visible on purpose, and not settled — the packet lists it as a link rather than loading it. |
| 05 | Agent instructions | The managed block and SKILL.md. They say how to use the context; they are never a source of project fact. |
| 06 | Derived views | The generated registry index tables, and any wiki or summary built on top of them. Never authority. |
If an index and a record disagree, the index is stale: run context_index.py and the disagreement is gone. Editing one by hand is worse than having none — a hand-edited index is trusted, and then overwritten.
Detail records in decisions/, questions/, tasks/ and inbox/ carry YAML frontmatter. The registries carry none — NOW.md, PLAN.md, DECISIONS.md, LEARNINGS.md and QUESTIONS.md stay plain Markdown a person can read in a pull request.
--- id: D-004 kind: decision status: accepted title: Serve thumbnails from the CDN, not the app created: 2026-09-03 asserted_by: person:ren ---
id · kind · status · title · created · asserted_by
approved_by, supersedes, superseded_by, evidence, files, valid_at, invalid_at, session, harness and model are validated only when present. Absent means absent: the doctor warns on a generated_at, confidence or aliases key, and on an empty supersedes: [] carried forward from the retired three-block format.
project-context/1, recorded in project-context/.project-context.json. The marker also names the product that wrote it, so a reader never compares one product's version number against the other's.
Read from that product's VERSION file, and from nowhere else. TEMPLATE_VERSION and SCAFFOLD_VERSION are retired: a product does not version its templates or its scaffold separately from itself.
D-001, L-003, Q-002 and T-012 for authored records; C-2026-09-03-a1b2 for a capsule, whose suffix is derived from its own text. Anything else is reported as invalid-record-id.
When something turns out wrong, supersede it or add a correcting record. Never rewrite the meaning of a record someone may have acted on — the history of a decision is worth more than its tidiness.
Each kind has exactly one status vocabulary, and the doctor checks a status against its own kind rather than a permissive union of all of them. A union would let two people write questions two different ways with nothing to catch it, which is the failure a single vocabulary exists to prevent.
| Kind | States |
|---|---|
| decision · learning · capsule | proposed → accepted → superseded | rejected |
| question | open → answered → superseded |
| task | proposed → active → done | dropped |
candidate and approved are gone: read proposed and accepted. The doctor reports either as retired-status and names its replacement, whatever the kind. The approved_by field keeps its name — it records who accepted.
A state that belongs to another kind is as wrong as one that belongs to no kind: accepted on a question and answered on a decision are both errors. One exception is not a status at all — the activity column in NOW.md is prose about work, and stays as it is.
One grammar wherever a reference appears — a frontmatter evidence: list, an Evidence: line, a body. The doctor checks that a token claiming a scheme keeps that scheme's shape and stops there: nothing asks whether the commit, ticket or page on the other end exists. A token whose prefix is not one of these is ordinary prose and is left alone.
| Scheme | Cites |
|---|---|
| session:<harness>:<id> | The session that asserted it. |
| commit:<binding>:<sha> | A commit, by repository binding and 7 to 40 hex characters. |
| pr:<binding>#<number> | A pull request. |
| review:<binding>#<pr>/<comment-id> | One review comment on one pull request. |
| ticket:<tracker>:<key> | An issue in whatever tracker the project already uses. |
| doc:<binding>:<path>@<commit> | A document at the state it was read at. |
| url:https://… | Anything outside the repository; the scheme must be http or https. |
| capsule:<id> | A capsule in inbox/, by its C-… ID. |
They mark a metavariable. A URL reference is written url:https://example.com/x, not url:<https://example.com/x> — and a token with the brackets left in fails the shape check as invalid-reference.
Inside the repository the short form is enough: pin a path to the state it cites, src/auth/session.py@a1b2c3d. A reference to a moving file is a reference to nothing, and the doctor reports evidence-drift once the cited path has changed since the commit it was pinned at. Evidence anchors, in full, on the records page →
A decision worth recording almost always surfaces mid-task, and stopping to write a registry entry with an ID, a rationale and consequences is exactly the interruption you decline — so the decision goes unrecorded and the reason is lost. Capture has to be cheap enough to happen during the work.
It writes one file. It never edits a registry, never touches a record, and never reaches a network. The judgement — decision, learning, or nothing — is deferred to promotion, where it is cheap.
python3 .agents/skills/project-context/scripts/context_capture.py --kind decision --text "We standardise on pnpm; npm workspaces could not hoist the native deps." --apply
--kind is one of decision, learning, question, assumption, constraint or proposal. It is not the record kind: everything in inbox/ is kind: capsule. A proposal is how you ask for a change to something the Hub pushed, since you cannot edit those.
The actor comes from your git identity unless --actor person:<name> or agent:<name> says otherwise, plus --session, --harness and --model where the harness knows them — and the current commit is written in as a commit: evidence reference. Anything unknown is omitted rather than recorded as unknown.
A capsule is at most 200 words and a longer one is refused, because anything longer is the record it should become. And the same text on the same day writes once — the ID is derived from the text, so a Stop hook that fires twice does not leave two identical notes to triage.
Write the registry entry the capsule earns and set its status to accepted with a link to what it became, or rejected when it belongs nowhere. Either is a resolution; leaving it proposed is the only outcome that is not.
The cost of a staging area is capsules nobody promotes — which is why the standing review reports an ageing one, by name and by age. The cost of not having one is decisions nobody records, and that cost is silent.
Before substantial work, assemble the packet instead of reading the folder. What comes back is ordered rather than ranked, and the order is the point.
python3 .agents/skills/project-context/scripts/context_packet.py context --task "add rate limiting" --files src/api/gateway.py
SUMMARY.md, IDENTITY.md and GUARDRAILS.md from global/, where a Hub has pushed one. A file the owner published blank is skipped: it carries no information and costs budget.
blueprint/EPIC.md in every mode, because the epic is the constraint in every mode. ARCHITECTURE.md joins it under --mode plan and --mode review only — an implement packet carrying it would spend a quarter of the budget on a document the task at hand is not allowed to change.
NOW.md, and the active items of PLAN.md.
The decisions, learnings and questions whose evidence anchors share a path prefix with the paths you named in --files.
A token overlap against the --task line, after the anchored ones. A packet that led with your own notes would bury the constraint that was not negotiable.
Matching is a path-prefix comparison and a token overlap over a few hundred small Markdown files. There is nothing to keep warm and nothing to rebuild after a pull. The signal that decides relevance is already written down — a decision cites the files it constrains, and a task names the files it touches. This is a deliberate boundary, not a stage on the way to something bigger.
Only settled records are loaded: accepted and answered, plus active and done for a task. Proposed ones are listed as links, so you can see they exist without being told they are settled, and --verified-only drops even the list. Whatever does not fit the budget — --budget, 4,000 estimated tokens by default — comes back as a link rather than being dropped, so the packet never implies that what it left out does not exist.
| Invocation | When |
|---|---|
| context --mode implement | The default: anchored records first, then the ones that share the task line's vocabulary. |
| context --mode plan | Writing a plan. The packet leads with blueprint/, architecture included. |
| context --mode review --diff | Reviewing. The file set is taken from the working tree's own changes rather than from --files. |
| onboard | A first session in a repository. There is no task yet, so nothing task-specific — and this is what the SessionStart hook emits. |
| --format json | A machine is reading the packet instead of a session. |
The doctor answers “is this correct?”. These answer three others: what is waiting on a person, whether the derived tables are current, and whether the plan still serves the epic.
Proposed records, questions open past --open-days (14 by default), unpromoted capsules, assumptions nobody confirmed, drifted evidence anchors, a stale NOW.md, and a pushed snapshot older than --snapshot-days (90). Oldest first, because latency is the failure this system is exposed to: a five-week-old question matters more than a fresh one whatever their subjects.
python3 .agents/skills/project-context/scripts/context_review.py --target . --open-days 14
The review exits zero whatever it finds. A proposed decision is a decision working as intended, and an unanswered question is the discuss step doing its job — they become a problem only by ageing. CI that broke on an open question would teach everyone to stop filing them.
DECISIONS.md and LEARNINGS.md grow without bound and get read end to end. The generator rebuilds a marked table at the top of each, so a reader can answer “does anything here constrain what I am about to do?” without paying for the whole file. They are derived: a hand-edited one is overwritten by exactly the generator that owns it, and --check exits non-zero when they are stale, so CI can hold the line.
python3 .agents/skills/project-context/scripts/context_index.py --check
Where a Hub owner has pushed blueprint/EPIC.md into your repository, each ## M-NNN: item in your PLAN.md names the epic item it advances:
## M-001: Ship the search endpoint - Status: `active` - Serves: E-002
The project is spending effort the epic does not ask for, and the fix is to anchor it or raise a question. An item already dropped is exempt — work that is not happening does not owe the epic an anchor.
An epic is allowed to run ahead of the milestone in front of you; erroring there would force a project to plan the whole epic at once. The review lists it so it is visible without blocking anything.
A repository with no blueprint/ has nothing to conform to, and PLAN.md stands alone with nothing checked against it. Project Context is a complete product without a Hub.
If your organisation runs a Project Hub, two folders arrive in your repository from it. Everything else under project-context/ is authored here and is yours. A repository with no Hub simply has neither folder, and nothing about that is degraded.
The organisation's summary, guardrails, workflows, shared skills and shared records. Owner-authored elsewhere, copied in, read-only here.
EPIC.md, the high-level plan this project serves, and ARCHITECTURE.md, the shape it has to keep. Both are inputs to your packet; neither is yours to change.
A pushed file stays clean Markdown with no injected metadata. .project-context.json records each one's sha256, its source commit and the time it was pushed; the doctor recomputes the digest and compares. A mismatch is pushed-file-modified, an error that names where the change belongs. A file under a pushed prefix with no stamp at all is only a warning — a hand-added file is worth naming but is not a broken install.
Your edit is not quietly reverted, either. The Hub recorded that file's hash when it sent it, so the next push sees the change and treats it as a conflict — which stops that push to your repository entirely, not only for the file you touched. The edit blocks the owner rather than persuading them, and they still never learn what you disagreed with. Raise a question in your own project-context/, or capture a proposal: it reaches the owner the next time they pull, and it arrives with the project context that explains it.
Install is create-only for everything, which is right for your records and means it carries nothing forward — re-running it upgrades nothing. update is the other half, and it is local only: nothing in it reaches a network.
project-context update --dry-run
project-context update --apply
Read the dry run by authorship. Every line names an action and a path, and the action tells you whose file it is:
| Action | Whose | What it means |
|---|---|---|
| refresh | ours | SKILL.md, the installed skill and its scripts, the managed blocks and the marker's own fields, brought to the current release. Differing from the release is exactly what a stale copy does. |
| regenerate_index | ours | A registry index rebuilt. Only the block between the index markers is replaced; the entries it is built from are the untouched part of the same file. |
| unchanged | ours | Already matches this release. Running update twice changes nothing the second time. |
| create | yours | A scaffold file your install predates, added now. It is a new empty record, never a replacement for one. |
| preserve_existing | yours | A record you wrote, left exactly as it is. Seeing one is the command working correctly. |
Update never writes to global/ or blueprint/. Each copy is verified against its stamp and reported: a pushed-file-modified entry means someone edited a file the Hub sent, and the fix is a question, not an edit. The marker is preserved rather than rewritten, so push stamps and any key a later release wrote survive the upgrade — and the doctor runs at the end and reports what it found.
Four gaps, current against the code rather than against a wish list. Two are simply not written yet; two are deliberate and are not coming.
Writing a capsule automatically at the end of a session that produced none. capture is manual today: the Stop hook runs the trigger gate and offers it, rather than doing it for you.
Promotion is editing — write the registry entry, then set the capsule's status. There is no one-liner for it yet, and the standing review is what keeps an unpromoted capsule visible in the meantime.
The generator rebuilds tables for DECISIONS.md and LEARNINGS.md only. Questions are surfaced by the standing review instead, which is the right instrument: a question matters by its age, not by its position in a table.
Assembling context across several projects at once. That belongs to the Hub side and is deliberately not on the repository side: your packet sees this project and the pushed global snapshot, which is the correct blast radius.
Not preferences, and not a current release's behaviour. These are the constraints the product is built under, and a change to any of them would be a different product.
Every command takes --dry-run and --apply, and the dry run prints the exact plan. Nothing overwrites a record. A file you may have edited is skipped, and the skip is reported rather than swallowed.
Correction happens through status and supersession links. Completed records are preserved, and history stays readable as historical evidence.
Zero runtime dependencies, in any script, ever. A repository adopting this must not acquire a dependency by doing so — and nothing here reaches a network, creates a remote, pushes, or invites anyone.
The managed block between <!-- project-context:start --> and <!-- project-context:end --> is ours. The rest of your CLAUDE.md or AGENTS.md is none of our business, and an install that rewrote it would be a bug however good the intent.
This page is the contract. The loop is what turns it into a habit, and the Hub is the other half for organisations that run one.
Clarity comes with context.