Skip to Content

gtmesh doctor

Lints the health of your mesh — “linting for the mesh.” It runs a registry of deterministic structural checks and prints a report grouped by severity, each finding pointing at the file and knob to fix, ending with the recommended next action.

Within a severity, findings are grouped by check: each leads with a one-line headline naming what and where, followed by the explanation, with the file/knob/docs pointer printed once per check rather than once per finding.

gtmesh doctor [--json]

What it does

doctor reads the plan diagnostics, the desired registry, and the raw keyword pull, then runs each check and reports what’s off. It’s read-only and deterministic — the same inputs always produce the same report. It reads no page body content; the one thing it opens a bundle for is the engine-owned _brief block, to check it is present and in sync with its node.

It begins with an input preflight (the same contracts validate checks): broken inputs are a likely root cause of downstream smells, so they surface first as an error — treat the rest of the report as unreliable until inputs pass.

Checks include: invalid committed inputs, unknown page_types[].when keys (a typo’d match key like class for entity_kind is silently ignored, so the type never matches — see the fold’s failure modes), config keys the engine never reads (dead-config-keys, a warning: the schema accepts a key it has no home for silently, so a typo — or a key an older release read and this one doesn’t — sits in gtmesh.config.yaml looking live; the finding lists every dead path, and gtmesh config unset <path> removes one), page-type imbalance, unresolved keywords that look like real demand, catch-all / hub pollution, duplicate slugs / identity health, thin near-duplicate fragments, zero-demand landings, consumer-intent noise on a B2B mesh, a stale committed state vs. config (you changed config but didn’t re-apply), _brief drift, a head keyword absorbed by another page (absorbed-head), and no-hubs. Every threshold is config-driven (doctor: in gtmesh.config.yaml), so the engine’s checks name no domain.

The _brief drift check (brief-drift, an error) compares the assignment on disk against the one the graph records — _brief minus its id handle is the hashed brief, so the comparison is exact. It fires in two shapes:

  • missing — the node records a brief_hash but the bundle carries no _brief block. The graph believes the writer was handed an assignment that isn’t on disk.
  • stale — the bundle’s _brief hashes to something other than the node’s brief_hash, on a page the writer is about to read (never built, or re-opened by recreate/amend). Demand moved but the on-disk brief still describes the old assignment. A built page’s brief legitimately diverges — it is the assignment its body was written against — and built-drift reports that instead, saying which way it moved.

Either way the provenance chain is broken: built_brief_hash can’t be stamped correctly at seal. Running gtmesh apply re-bakes the derived block and clears it.

The built-drift check reports a brief that lost questions separately, ahead of everything else, because it is the only shape that can destroy content: those questions were in the brief the page was sealed against, the body was written to answer them and still does, and re-authoring against the current brief would drop the answers. It names the pages and the questions, and prescribes amendgtmesh plan’s question-fold advisories name the page each question moved to. page recreate refuses those pages outright unless you pass --drop-answers.

The absorbed head check (absorbed-head) flags a keyword one page targets — as a secondary keyword or a folded question — while a different committed page carries it as its primary_keyword. Two pages then answer to the same term, and the absorbing one is targeting demand that already has a home. It is an exact string match, never a similarity judgement, and it reports rather than re-routes: deciding which page owns a term is yours. Retired owners count, and are called out separately — a tombstoned page’s head records that this mesh once judged the term a topic in its own right, so absorbing it elsewhere reverses that decision. Fix by splitting the phrasing out in overrides/group-edits.yaml, merging the pages, or retiring the one that shouldn’t exist.

The built drift check (built-drift) routes the same signal gtmesh status counts on its drift line — a page that has fallen behind the graph since it was built — naming the pages and the verb that clears each shape. Content drift (a warning: the node’s brief_hash moved after seal stamped it) has two fixes, and the cheap one is usually right. If the body still answers the current brief — a corrected fact, a head keyword the prose already serves — gtmesh page amend <slug…> then seal re-stamps it with no re-write, keeping the body and its review-gate verdict. Only when the assignment genuinely moved does it go back to the writer via gtmesh page recreate <slug…>, which discards the body. Either way seal returns a published page to review, so finish with gtmesh page publish <slug…> — stopping at seal quietly drops those pages from the live render set. Schema drift (an observation: the page type’s fingerprint moved) is resolved by gtmesh apply, which re-validates each body, restamps the ones that still fit and moves the rest to needs_update.

Content drift is reported in two findings, split by provenance, because that decides whether the plan has any standing over the page. A derived page is proposed as rewrite (spec-change) in gtmesh plan. An authored page — one you seeded — is pinned by reconcile (noop (authored-pinned)), so no plan action will ever appear for it and the finding says so: the fix is yours to run.

The stale-state check compares the config the committed graph was last applied under against the current config. Running gtmesh apply clears it — the marker is stamped on every apply. A read-only gtmesh plan does not clear it (it applies nothing), so the flag always reflects what’s actually committed, not what you previewed.

The report closes with the observation checks, which route the operate cadence. Staleness: when a channel’s snapshot ages past its cadence: knob, a finding names the refresh verb — stale-performance-snapshot (cadence.performance_days, default 7 → gtmesh pull performance), stale-links-snapshot (30 → gtmesh pull links), stale-ai-snapshot (30 → gtmesh pull ai); a mesh that never pulled a channel hears nothing about it. Signals: topics-decaying (positions dropped — start the tune lap with gtmesh topic performance <value>), apex-unbuilt-earning (a cluster earning impressions while its home is unbuilt — publish it first: promote it directly, since a parked home never enters the next queue), authority-deficit (a within-reach topic whose apex holds under cadence.apex_share_floor of the cluster’s referring domains — links are the lever), homepage-heavy-authority (the site has referring domains but effectively none reach a topic page — one site-level finding: deep links to the hubs are the growth lever). See the reference for each check’s exact conditions.

doctor is also where “is an upgrade due?” gets answered — on what actually differs, never on the version number (a release often ships no scaffold change, so comparing versions nags meshes for which nothing moved). engine-files-pending (a warning) buckets an upgrade dry run by action. Actionable: engine-owned files you haven’t edited that the bundle has moved on, or that this mesh doesn’t have yet — a plain gtmesh upgrade applies them in place, and gtmesh status repeats the count as a one-line footer. Informational: files you edited, and project-owned files whose engine default moved — those need a hand-merge (gtmesh upgrade --with-defaults writes the engine’s version as *.default beside yours), so they’re reported here and nowhere else, rather than nagging on every command. Since every skill is engine-owned, gtmesh upgrade is how an engine fix reaches your operator and writer.

One engine-owned file changes what a command answers: schemas/common.schema.yaml, the shared $defs base every per-type page schema $refs. When it’s pending, validate, seal and apply each say so once — those results came from a stale contract.

Two working-memory checks close the loop on the operator’s own notes: operator-notes-oversized (a file past doctor.max_notes_lines, default 400, has outgrown a single read — the failure with no other symptom) and legacy-operator-notes (a root NEXT-STEPS.md still mixing rules, decisions and history). Both stay silent on a mesh with no notes. See Working memory.

Exit code 2 if any error-severity finding, else 0 (warnings don’t gate). The report ends with a state-aware next-action footer — what to run next, read from committed state.

Arguments & flags

OptionDescriptionDefault
--jsonMachine-readable findings (global flag)off

The global options (--project, --quiet) also apply.

Reads & writes

  • Consumes: the config, the desired registry, plan diagnostics, and the raw pull.
  • Produces: a report to your terminal (grouped by severity + next action). Read-only.
  • Commits: nothing.

Examples

gtmesh doctor # the full health report + recommended next action gtmesh doctor --json # machine-readable, for the operator skill or CI
  • After init — the patterns doctor flags and how to remedy each.
  • Tuning — the knob-by-knob guide the findings point at.
  • The mesh store — reset via git when experimenting.
  • Reference — the version-exact behaviour.
Last updated on