A discussion leaves behind a directory of markdown somewhere outside this repo, and the only durable home for it is the Gitea wiki. Getting it there by hand means re-deriving the same three things every time: what each file should be called, where it goes, and whether the page already exists. Two skills wrap that, along the split the repo already uses. `skills/page` is domain, offline, stdlib-only, and knows nothing about Gitea. It imports a directory into a space under `tmp/wiki/`, titles every file, records the result in `.pages.json`, and writes the index. `skills/wiki` is the bridge — `wikimap.py` translates, and the transport is `_gitea.py`, the same one the issue side uses. There is no second transport, and `tea` has no wiki subcommand to offer one. The Gitea wiki is flat, and that fact shapes everything There are no directories. A title of `a/b` is stored as one file named `a%2Fb.md`, and Gitea escapes it by rules of its own: space becomes `-`, `/` becomes `%2F`, and a literal `-` forces a trailing `.-` marker so the two stay distinct. `Chain decisions — DC` under two levels of prefix comes back as `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC`. So `sub_url` is the identity, it is read back from whatever the API returned, and it is never constructed. One built by hand that is almost right does not fail — it creates a second page and abandons the first. And a real subdirectory committed into a wiki's git repository is a ghost: the file exists, the API and the web UI do not see it. `folder/page.md` in this repo's own wiki is one. Nothing here clones a wiki repo. A title is a decision, not a derivation Titles come from the first heading, because there is no mechanical route from `03-q-01-do-we-know-the-chain-participant-by-name.md` to `Q-01. Do We Know the Chain Participant by Name`. But they are derived exactly once. A re-import replaces bodies and keeps titles, so editing a heading cannot rename a published page — which would not rename it, it would publish a second one. `--retitle` opts in. It finds the prior entry by `source` rather than by path, because the path is derived from the title and a retitle moves it; looked up by path the page would read as new and the next push would duplicate it. The old file goes, `sub_url` comes along, and `pushed` is cleared — a rename can leave the body byte-identical, and push decides by body hash alone, so a stale hash would skip the rename forever. Ordering is a `NN-` file-name prefix and never reaches the title. `00-` means "this is the directory's own page", and that page is named for the directory, not for its own heading: a child's title has to extend its parent's exactly, and `ideas/00-intro.md` opens with "Ideas for chain business requirements". Path collisions are reported and never resolved. Picking a winner is how a discussion loses a document. The index is navigation, not decoration Nothing draws a tree from flat titles. `page_index.py` writes one as an ordinary page, nested by title depth rather than by manifest path order — those disagree, since on disk `Top/System.md` sorts before `Top/Ideas/Scale.md` while in the hierarchy System is a child and Scale a grandchild. A parent with no page of its own still gets a node, so its children are not hidden. Links use `sub_url` when there is one and Gitea's `[[Title|label]]` syntax when there is not, so the order is push, rebuild, push. The same stances as the issue store, for the same reasons Pull overwrites, push is additive and never deletes, change detection is one hash and there is no drift model. A page with no `sub_url` has never been published, and that is a durable state. Issues gain a `wiki:` field holding page titles — titles, not URLs, so the reference stays in the domain. It already round-trips as a foreign key; this documents it. Verified against a live Gitea 1.26.1: create, update with a message, unchanged-skip, prefix-filtered pull, byte-identical round trip, and the per-page revision history carrying the operator's own words. The probe pages were deleted afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.6 KiB
The page-tree format
Canonical. Everything about how a discussion's artifacts become named, ordered, navigable pages lives here. The scripts implement this document; when they disagree, this document is right.
The one fact that shapes everything: the wiki is flat
Gitea's wiki has no directories. It has a list of pages, each stored as one file whose name Gitea escapes from the title:
| title | file Gitea writes | sub_url |
|---|---|---|
Abstract Issue |
Abstract-Issue.md |
Abstract-Issue |
zz-probe/child |
zz-probe%2Fchild.-.md |
zz-probe%2Fchild.- |
Simple Chains/Parked/Chain decisions — DC |
Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC.md |
same, minus .md |
Three rules are visible in that table, and all three are Gitea's to change:
space becomes -; / becomes %2F; a literal - in the title forces a
trailing .- marker so it stays distinguishable from a space.
Two consequences run through the whole design.
Hierarchy lives in the title and nowhere else. / inside a title is the
only nesting there is. A real subdirectory committed into the wiki's git
repository — folder/page.md — is invisible to the API and to the web UI. It
is a ghost file. Never create one.
sub_url is identity and is never constructed. It is read back from
whatever the API returned and stored in the manifest. A hand-built one that is
almost right does not fail loudly; it creates a second page and abandons the
first.
The space
tmp/wiki/claude-skills/tea/ a SPACE
.pages.json the manifest
Simple-Chains.md title: Simple Chains (the index)
Simple-Chains/
Ideas.md title: Simple Chains/Ideas
Ideas/
Chain-core.md title: Simple Chains/Ideas/Chain core
A space is a directory holding a page tree and one manifest. Its name is
normally the owner/repo it syncs with, and to the domain layer that is an
opaque relative path — --space docs and --space a/b/c are equally valid.
The path is <repo root>/tmp/wiki, resolved from page.py's own location and
not from the working directory. --out overrides it and is used exactly as
typed. Nothing creates a space as a side effect of a write: the scripts say so
on stderr when they make one.
The manifest
.pages.json, one entry per page, keyed by the file's path inside the space.
{
"space": "claude-skills/tea",
"pages": {
"Simple-Chains/Ideas/Chain-core.md": {
"title": "Simple Chains/Ideas/Chain core",
"order": 2,
"pushed": "9a1ab2e3bfd45f7c7ba323d9d8cd59642d6f0540",
"remote-updated": "2026-08-10T11:15:39Z",
"sha": "fc8ec1779d910850f49bfef60dd5a0e737bbdc8a",
"sub_url": "Simple-Chains%2FIdeas%2FChain-core",
"synced": "2026-08-10T11:15:39Z",
"url": "https://git.noodles.cam/…/wiki/Simple-Chains%2FIdeas%2FChain-core"
}
}
}
| key | owner | meaning |
|---|---|---|
title |
domain | the page's name; / is hierarchy |
order |
domain | sort key from a NN- file-name prefix; absent when there was none |
sub_url |
wiki | Gitea's address for the page — the identity |
pushed |
wiki | sha1 of the bytes last published; the whole of change detection |
sha |
wiki | the wiki commit the local copy came from |
synced |
wiki | when this copy was fetched or pushed |
url |
wiki | browser link |
remote-updated |
wiki | the page's last commit date in the wiki |
The domain layer writes title and order, carries everything else through
load and save verbatim, and never reads it. A page with no sub_url has never
been published — a complete state, not a pending one, exactly as origin: local
is for an issue.
How a source file gets its title
Applied at import, once. Three rules, in order:
order 0, or a file literally namedindex/readme, is the page for the directory it sits in.ideas/00-intro.mdbecomes…/Ideas, not a child of it. Its title comes from the directory name, never from its own heading — a child's title has to extend its parent's exactly, and that file opens with "Ideas for chain business requirements", which no child would ever be prefixed by.- Otherwise the file's first markdown heading, sanitized. It is what a
human wrote for a human: there is no mechanical route from
03-q-01-do-we-know-the-chain-participant-by-name.mdtoQ-01. Do We Know the Chain Participant by Name. - No heading: the file name, made readable —
NN-stripped,-and_to spaces, first letter raised. Only the first letter: title-casing would wreckQ-01,sqlc, andAPNs.
Sanitizing a title drops markdown markup (`, *, _ — a page list does
not render markdown) and turns / into -, because a slash inside a heading
would silently invent a level of hierarchy the author did not ask for.
A title is a decision, not a derivation
Once a page is in the manifest its title stays put. Re-importing replaces the
body and leaves the title alone, so editing a heading cannot rename a page —
which matters because renaming a published page does not move it, it
creates a second one and orphans the first. --retitle opts into that
explicitly.
The reverse direction does not exist. A path is derived from a title; a title
is never derived from a path. 02-chain-core proves why: those dashes are
real, and undoing "space became dash" would eat them.
Ordering
A leading NN- on a file name is sort order and nothing else — it never
reaches the title. 00 is special and means "this is the directory's own
page". Pages with an order sort before pages without one: an explicit NN- is
a decision, its absence is not.
The wiki cannot hold ordering, so order is local-only and survives a pull.
Paths
A path is one component per title segment, spaces to -, with characters a
shell has to quote dropped — apostrophes and quotes and commas. Don't send to this one keeps its apostrophe in the title and loses it in
Dont-send-to-this-one.md.
Two titles can land on one path. That is reported and never resolved automatically: picking a winner is how a discussion loses a document. Rename a source, or rename the page in the wiki, and run it again.
The index page
The wiki will not draw a tree from titles, so an index page is the navigation,
not a nicety. page_index.py writes one as an ordinary page in the space — it
is pushed by the same command as everything else.
Nesting follows the titles, not the manifest's path order; those two
disagree, because on disk Simple-Chains/System.md sorts before
Simple-Chains/Ideas/Scale.md while in the hierarchy System is a child and
Scale a grandchild. A parent with no page of its own still gets a node, so its
children are not hidden.
Links: a published page is linked by its sub_url, the only address Gitea
guarantees. A page that has never been pushed gets Gitea's [[Title|label]]
wiki-link syntax, which resolves the escaping on the server at render time.
Rebuilding the index after a push upgrades those links to exact ones — so the
order is push, rebuild the index, push again.
What the sync does not do
- No merge. A pull overwrites the local body.
syncedtells you how old your copy is; re-pull when it matters. - No drift tracking.
pushedanswers one question — is the local file different from what was published — and answers it with a hash. - No deletes. Pushing is additive. A page removed locally stays in the
wiki; removing a published page is an explicit act, done in the web UI or
with a
DELETEthrough/tea:use.