Files
marketplace/skills/page/references/pages.md
T
naudachu 1815d91cdf feat: discussion artifacts as wiki pages, in two new layers
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>
2026-08-10 16:29:55 +05:00

174 lines
7.6 KiB
Markdown

# 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.
```json
{
"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:
1. **`order 0`, or a file literally named `index` / `readme`, is the page for
the directory it sits in.** `ideas/00-intro.md` becomes `…/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.
2. **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.md` to
`Q-01. Do We Know the Chain Participant by Name`.
3. **No heading: the file name**, made readable — `NN-` stripped, `-` and `_`
to spaces, first letter raised. Only the first letter: title-casing would
wreck `Q-01`, `sqlc`, and `APNs`.
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. `synced` tells you how old
your copy is; re-pull when it matters.
- **No drift tracking.** `pushed` answers 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 `DELETE` through `/tea:use`.