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

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:

  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.