gtmesh page
Everything you do to a page: author it into the mesh, then move it through its states.
gtmesh page add --slug <slug> --keyword <kw> --section <s> --type <t>
gtmesh page rehome <slug> --parent-topic <value>
gtmesh page promote [slugs...] [selectors]
gtmesh page demote [slugs...] [selectors]
gtmesh page validate [slugs...]
gtmesh page seal <slugs...>
gtmesh page publish <slugs...>
gtmesh page amend <slugs...>
gtmesh page recreate [slugs...] [selectors] [--yes]
gtmesh page retire <slugs...>
gtmesh page unretire <slugs...>The group splits in two. add and rehome author — they put a page (or a page’s topical
relationship) into the committed graph, which is what the rest of the engine then reasons over.
Everything else moves a page along the lifecycle — the status path
from catalogued idea to published article, and back again when something needs redoing.
What every verb here has in common
Each one records a decision; none of them builds. A lifecycle verb writes a page’s status and
stops there. The building — scaffolding a page bundle, baking its _brief — happens on the next
apply, so the rhythm is always decide, then apply.
They are writers of committed state. The change lands in graph/nodes.jsonl, so it belongs in
your next commit alongside whatever content it goes with. (validate is the exception — it is
read-only.)
They name pages by slug, and three of them also by selector. The lifecycle verbs take exact
slugs positionally; add names its page with --slug because the page doesn’t exist yet.
promote, demote and recreate additionally take the selector flags below, so you can act on a
section, a topic, or a whole cluster in one move.
The global options (--project, --json, --quiet) apply to
every verb in the group.
The selectors
Field predicates compose with AND, and they compose with any positional slugs you also name.
--under is a walk down the mesh from a hub.
Each flag takes one value, and repeating one is an error rather than a silent last-wins —
--page-type home --page-type index used to match only index, quietly dropping the pages the
first flag named. To cover several values, list the pages as positional slugs or run the command
once per value.
| Selector | Selects |
|---|---|
[slugs...] (positional) | Exact slugs — e.g. just the hub, not the whole prefix below it |
--section <name> | Pages in a section |
--has <id|axis> | Pages holding a relationship to that node (categories/rotary-lobe-pumps, application/cip), or every relationship on an axis (application). Targets are node ids; gtmesh explain <slug> lists a page’s |
--topic <value> | Pages that belong to that topic — its members and its apex. Takes the value (dosing) or the node id (topics/dosing) |
--page-type <id> | Pages of a page type |
--slug-prefix <str> | Pages whose slug starts with this prefix |
--status <state> | Scope to pages currently in this status |
--under <hub-slug> | A hub slug + everything reachable down the mesh from it |
--orphan | (promote/demote only) Pages that belong to no topic — no MEMBER_OF or apex edge — excluding structural nav pages. Pairs with the topically-orphaned doctor check |
--to <status> | (promote/demote only) Target status. Default queued for promote, backlog for demote |
--dry-run | Preview the transition without writing |
Author a page
page add
Authors a fixed (seed) page — a hand-authored input node that persists across plan/apply. It
inserts the node, and the topic the page heads or joins, directly into the committed graph; the next
apply builds it. This is how you stand up a new cluster — see
Add a new category.
| Option | Description |
|---|---|
--slug <slug> | The page URL slug (required) |
--keyword <kw> | The primary keyword (required) |
--section <section> | The section / URL root (required) |
--type <page_type> | The page type (required, e.g. category-hub) |
--parent-topic <t> | The topic the page belongs to. topics/<t> is always ensured — a member joins it, an apex (--role) heads it |
--role <hub|pillar> | Make this page the apex of --parent-topic: it owns the value, minting topics/<t> plus a HUB_OF/PILLAR_OF edge. Omit for a member. One hub and one pillar per topic |
--kind <k> | The entity’s domain kind (category, manufacturer, …). Sets the topic’s axis and makes a new cluster resolvable, so members bind to it |
--label <label> | An extra node label (repeatable) — parity with graph upsert |
--prop <key=value> | An extra node prop, value JSON-parsed (repeatable) — parity with graph upsert |
--intent <intent> | Search intent (e.g. commercial) |
--discriminator <d> | Identity discriminator |
# a category hub that heads its own topic, plus a member that joins it
gtmesh page add --slug /categories/dosing-pumps --keyword "dosing pumps" \
--section categories --type category-hub --parent-topic dosing --role hub --kind category
gtmesh page add --slug /guides/dosing-pump-sizing --keyword "dosing pump sizing" \
--section guides --type guide --parent-topic dosingadd authors a new page and errors if the slug already exists. To give an existing page a
topic, reach for rehome.
page rehome
Homes an existing orphan page into a topic — the inverse of demote --orphan, and the
actuation the curation loop drives over the backlog pool match
recommends against. It repoints a page that is already committed: it authors the page’s topical
edge onto the topic and mints topics/<value> if absent. No new page, no identity churn.
Once homed, the page has a topical edge, so it clears the topic gate and page promote /
next can move it into the build set.
It is idempotent, and says which it did: a membership that isn’t there yet is authored; one that
already exists is left alone and pinned as authored so apply’s rebuild keeps it; one already authored
reports that nothing changed. A page can belong to several topics — rehome adds a membership, it never
replaces one, so use explain to see every topic a page belongs to.
| Argument / Option | Description |
|---|---|
<slug> | The slug of the committed page to home (required, e.g. /glossary/effluent) |
--parent-topic <value> | The topic to home it into (required). topics/<value> is minted if it doesn’t exist |
--role <hub|pillar> | Elect this page the apex of the topic (HUB_OF/PILLAR_OF). Omit to home it as a member (MEMBER_OF). One hub and one pillar per topic |
gtmesh match # the ranked shortlist
gtmesh page rehome /glossary/effluent --parent-topic dosing # enact one of its suggestionsMove a page through the lifecycle
page promote / page demote
promote is the gate between the cheap catalogue loop and the expensive production loop.
Deciding which catalogued pages to actually write is always an explicit, reviewable choice —
intent-based, reversible with demote, and applied by default (--dry-run previews). It writes
only the status of matched pages; the scaffolding happens on the next
apply.
demote is the exact mirror: the same selectors, moving pages backward (default target backlog).
gtmesh page promote /integrations/slack # exactly the hub
gtmesh page promote --page-type comparison # every comparison page
gtmesh page promote --slug-prefix /apps/ # everything under /apps/
gtmesh page promote --under /integrations/slack # a hub and its whole cluster
gtmesh page demote --section blog # park a section back to backlog
gtmesh page demote --orphan --to backlog # park the orphan cleanup setChanging a page’s authority role (apex vs member) is a different thing entirely — that is driven by the apex edge, not by these verbs. See the mesh & link graph.
page validate
The read-only checker the writer leans on. Two passes per page:
- Schema — the page’s structured YAML is validated (via ajv) against its per-type schema in
schemas/. - Editorial lints — the deterministic, rule-based checks (banned words, structural rules, and so on) that don’t need a model.
The lint also enforces symbolic refs in prose: a literal internal URL in a body is an error —
link with ref:page/<id> or ref:links/<scope>/<n> so the link re-resolves on rename or
re-ownership, and an unresolvable ref is an error too. External URLs are untouched. See symbolic
ref in the glossary.
Run it with no slugs and it does one extra thing: it also checks your committed inputs —
reference tables and discovery seeds — against the engine’s column contracts. That catches a
malformed CSV before it ever reaches plan/apply.
| Argument | Description | Default |
|---|---|---|
[slugs…] | Page slugs to validate | Every scaffolded page (and the committed inputs) |
Named slugs are matched against the committed graph, and a missing leading slash is added for you
(as with graph get). A slug that matches no page is a hard
error, not a quiet “0 pages checked” — so a typo can’t masquerade as a pass.
gtmesh page validate # every page + all committed inputs
gtmesh page validate /glossary/water-pump # just one page
gtmesh page validate --json # machine-readable results for CIIt writes nothing. It runs the same gate seal enforces, exposed so you can run it as often as you
like: a page that doesn’t validate cannot seal.
page seal
The checkpoint at the end of writing. seal runs the validation above and, only if the page is
schema-valid AND lint-clean, moves it from writing to review and stamps its provenance hashes
(built_brief_hash / built_schema_hash / body_hash) — the hashes that make later graph-to-graph
diffs precise.
It also requires the bundle to carry its _brief block. built_brief_hash is stamped from the node, so
sealing a bundle whose block was dropped — hand-authored, or a writer that rewrote the whole file —
would claim the body answers an assignment that isn’t on disk. gtmesh apply
bakes a missing block unconditionally and moves no hash or status, so the fix is one command.
| Argument | Description |
|---|---|
<slugs...> | Page slugs to seal (one or more) |
gtmesh page validate /glossary/idempotency # the writer's self-check (no status move)
gtmesh page seal /glossary/idempotency # only if valid AND lint-clean → review
gtmesh page seal slug-a slug-b # several at onceLike validate and apply, seal warns once when the shared
schema base (schemas/common.schema.yaml) is pending an upgrade — the gate then ran against a stale
contract. gtmesh upgrade refreshes it.
page publish
The last deliberate human move in the authoring lifecycle: review (or ready) → published. A
page reaches review once it’s sealed; after the human PR review, publish flips its status.
It sets status only — it builds nothing and rewrites no body. A page with only image briefs (art-direction stubs, no real image files yet) publishes fine: the briefs are schema-valid and your site renders placeholders. Generate the real images whenever you’re ready.
| Argument | Description |
|---|---|
<slugs...> | Page slugs to publish (required) |
gtmesh page publish /glossary/water-pump
gtmesh page publish /apps/slack /apps/notion # several at onceExit code (CI gate). publish takes explicit slugs, so every slug you name is a page you expect
to go live. It exits 0 only when every named slug ends up published, and non-zero as soon
as one doesn’t — the same contract seal holds. Each page that didn’t publish is named
on stderr with its reason (typically: it isn’t in review/ready yet, so seal it first — or the
slug is wrong), and that line survives --quiet.
A page that is already published is the state you asked for, so it counts as success and the run
stays 0 — re-running a publish step doesn’t turn a pipeline red the second time. A mixed batch still
does the work it can: the eligible pages publish, and the run is non-zero because the others didn’t.
So gtmesh page seal <slug> && gtmesh page publish <slug> fails loudly in a script instead of
reporting a silent no-op.
page amend
Handles a body edit that changes the page’s files but not its prose, by moving the page to
needs_update — the status that says “this body changed; re-seal it.”
The canonical case is image placement: the writer
authors art-direction briefs, and the image-director skill later generates and places the real
asset files into the bundle. That’s a body change, so the page has to re-seal — but it isn’t a
rewrite, because the prose is untouched.
| Argument | Description |
|---|---|
<slugs...> | Page slugs to amend (required) |
# (the image-director skill generates and places the asset files, then hands the slugs back)
gtmesh page amend <slug…> # body changed (assets placed) → needs_update
gtmesh page seal <slug…> # re-validates with assets present, re-stamps the body hash → review
gtmesh page publish <slug…> # → published, now with imagesA prose rewrite is a different path — that’s a rewrite action that reruns the writer, and it
keeps placed assets, so image work is never undone. For a full re-author from scratch, see
recreate.
page recreate
Re-opens built pages for a fresh body: status goes back to writing and the page’s content
provenance (built_brief_hash) is cleared, which is what puts it back on the
writer’s worklist. The writer then authors a
fresh body against the current brief. Reach for it when a page’s prose is wrong enough that you’d
rather start over than edit.
Your prose is still on disk. recreate changes the graph, not the page bundle — the existing
body stays in content/<slug>/index.yaml until the writer replaces it, and placed image assets are
preserved. Nothing is moved or deleted, so an accidental recreate costs you a status and a hash,
not your content. (Run git diff after one: only graph/nodes.jsonl changes.)
You don’t need a plan/apply between the recreate and the writer — the page is already on the
worklist.
It refuses when re-authoring would drop answers. A page keeps the _brief it was sealed against,
so recreate can see the questions the brief has since stopped asking — folded onto another page,
graduated to their own, or faded. If the body still answers one of them, the writer working from the
current brief has no reason to write it again, and the answer is lost silently. So recreate refuses,
names the pages and the questions, and stops. page amend → seal
re-stamps them with no re-write; gtmesh plan’s question-fold
advisories name where each question went. Pass --drop-answers to re-author anyway.
It also refuses when the on-disk _brief isn’t the block seal stamped — an older release re-baked
it, or it was hand-edited. Then there’s no record of what the body answers and nothing can be ruled out,
which is the more dangerous case, not the safer one. git log -- content/<id>/index.yaml has the sealed
version if you want to look before deciding.
--dry-run writes nothing, whether you named pages with a selector or listed them explicitly. (It
used to be read only on the selector path, so recreate <slug> --dry-run re-opened the page while
reporting a preview.)
Which pages are eligible. A page needs a body to replace, so planned and backlog pages are
skipped — they’ve never been built. Retired pages are skipped too: a tombstone is a recorded
decision to leave the build set, and reversing it is its own deliberate step (unretire).
Skips are named in the output rather than silently dropped.
recreate takes the selectors minus --orphan and --to (its target is always
writing), plus one flag of its own:
| Flag | Description |
|---|---|
--yes | Actuate a selector match (an explicit slug list needs no confirmation) |
A selector needs --yes. Unlike promote/demote, a recreate isn’t cleanly reversible — the
previous built_brief_hash is gone and a review/published status is lost. An explicit slug list
is already a deliberate act and runs immediately; a selector prints the matched pages and waits for
--yes, so a wider-than-intended match can’t sweep your mesh back into writing.
gtmesh page recreate /glossary/water-pump # one page
gtmesh page recreate --section guides --dry-run # see what a selector would catch
gtmesh page recreate --under /categories/rotary-lobe-pumps --yes
gtmesh page recreate --topic dosing --status published --yes # a topic's cluster, published onlyThen hand the batch to the article-writer skill and close it out as usual — gtmesh page seal →
gtmesh page publish.
page retire / page unretire
retire tombstones a page whose demand has faded or folded away (status → retired) — the
way to actuate a propose-retire from gtmesh plan. It’s distinct
from demote --to backlog: a backlogged page is a live candidate the match/curation loop
should still reconsider; a retired page is deliberately gone. It leaves the build set and the
curation pool, and — crucially — suppresses a re-minting demand draft in plan, so a
still-present keyword can’t silently rebuild it on the next pull. The node is kept, so a redirect
to a successor still resolves.
A tombstone is terminal. Once a page is retired, plan leaves it alone and never proposes its
retirement again, so actuating a propose-retire clears it from the human gate for good.
The decline is recorded on the demand, not on one classification of it: the retired page’s
head keyword is declined across identities, so adding a routing rule can’t re-mint the same
keyword under a fresh identity. Only the exact head is declined, never the secondaries — retiring
"gear pump" doesn’t block "gear pump vs screw pump" — and a head that a live page still owns
is never declined, so retiring a duplicate leaves the survivor fully reconciled.
unretire reverses it: retired → backlog, so the page re-enters the parked pool as a
curation candidate and the re-mint suppression lifts.
| Argument | Description |
|---|---|
<slugs...> | Page slugs to retire, or retired slugs to bring back (required) |
gtmesh page retire /glossary/obsolete-term
gtmesh page unretire /glossary/obsolete-termRetiring a duplicate works, but merge is the verb you actually want — and it doesn’t exist yet.
retire says this demand is gone; a duplicate is this demand belongs to that page instead, which
should redirect the dupe onto the canonical page and fold its keywords in. Until that verb lands,
retire is the right tool and behaves correctly here; the tombstone just records a weaker fact than
the one you meant.
Because the tombstone is committed state, gtmesh ui keeps rendering
it — dimmed and struck in the DB table, hollow and dashed in the Graph — and hides it from the DB
build queue until you select retired in the status filter.
Reads & writes
- Reads: the committed graph — the named or matched pages and their current
status.sealandvalidateadditionally read the page bundles, their per-type schemas inschemas/, and the editorial rules;validatewith no slugs also reads the committed input CSVs. - Writes:
graph/nodes.jsonl— the authored node or edge (add,rehome) or the movedstatus(everything else).sealalso stamps the built hashes;recreateclearsbuilt_brief_hash. Page bundles are never rewritten by this group.validatewrites nothing at all. - Commits: the graph change belongs in your next commit, alongside whatever content it goes with.
Related
- Lifecycle & reconcile — the full status path and who acts at each step.
- Editing a page — when to reach for
amendvs.recreatevs. a re-plan. - Writer & quality gate — how a page earns
review. - The mesh & link graph — authority roles, which these verbs don’t touch.
apply— what actually builds the pages you promoted.- Reference — the version-exact flag list.