mirror of
https://github.com/Klipper4FlashForge/firmware.git
synced 2026-10-22 05:12:16 -05:00
master
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
80a4d64ffd |
Prepare next firmware release
Add Fluidd and custom boot scripts, migrate DC24V control, update the web UIs and filament macros, improve startup status reporting, and expand upgrade and replica coverage. |
||
|
|
5718603f1c |
Document how a spool's material reaches OrcaSlicer (#25)
OrcaSlicer 2.4.0's Moonraker agent offers to map a project's filaments onto the printer's slots, and it learns those slots from the `lane_data` namespace in Moonraker's database. Nothing in the tree said where that data comes from, so the question "what does this need to work?" had to be answered by reading three codebases: ff_toolchange's status surface, HelixScreen's AMS backend and override store, and MoonrakerPrinterAgent.cpp. The note is that answer as one diagram plus the reasoning around it. Klipper supplies the slot count and nothing else -- it cannot sense what filament is in a tool; the operator supplies the identity through HelixScreen; Moonraker's database is the only durable record; OrcaSlicer reads it once at connect and never writes back. It also records the two things that surprised us. The mapping is not applied at print time -- start_local_print() ignores ams_mapping, so it selects presets and temperatures at slice time and does not rewrite tool numbers. And the reason this does not work on our build yet is not a missing feature but a missing merge: AmsBackendToolChanger was the last AMS backend without a FilamentSlotOverrideStore, upstream gave it one on 2026-08-24, and our last catch-up from upstream is four days older than that. Claude-Session: https://claude.ai/code/session_01PU5nCo85xuFQuub4kJzWt9 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fc137ec59d | Prepare beta release documentation | ||
|
|
7c74f025ae |
Document pressure-advance calibration (#15)
* Document automatic pressure-advance calibration FF_PA_CALIBRATE/FF_PA_PROBE and HelixScreen's calibration-screen integration shipped without owner/experienced-user documentation -- only the reverse-engineering notes covered it. Adds a table row and full section to how-calibration-works.md, and links back to it from the notes page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147AQiPYgtafuy1x5NjqJuy * Add a simple pressure-advance calibration page Owner-facing procedure alongside XYZ tool calibration and bed mesh -- FF_PA_CALIBRATE with a temp and tool number, what the output means, and how to save it. how-calibration-works.md's mechanism section now links here instead of duplicating the command walkthrough. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147AQiPYgtafuy1x5NjqJuy * Point pressure-advance results at the slicer, not printer.cfg PA is a filament property, not a printer one -- a value baked into printer.cfg is wrong the moment you switch material. Recommend the slicer's per-filament Pressure Advance field instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147AQiPYgtafuy1x5NjqJuy * Correct pressure-advance page: there is no factory value Unlike tool offsets and bed mesh, PA calibration doesn't import a factory number on first boot -- FF_PA_CALIBRATE only measures and reports one. Say that instead of implying it's pre-populated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147AQiPYgtafuy1x5NjqJuy --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
e5d6e5025a |
Merge pull request #12 from Klipper4FlashForge/relax-max-residual
Relax max_residual to 0.5 mm |
||
|
|
b55461cd6b |
Cancelling an object leaves the SD reader with nothing to pace it
EXCLUDE_OBJECT installs the ExcludeObject move transform, and inside a cancelled object _ignore_move updates Python state without ever calling next_transform.move(). Nothing reaches the toolhead, so the lookahead buffer that normally blocks gcode.run_script never fills, and virtual_sdcard.work_handler runs on its own yields alone -- one reactor.pause per 8192-byte read, about 330 lines apart. The eboard's soft-PWM refresh misses its deadline and the MCU shuts down with "Timer too close". Measured on a Creator 5 Pro: 37 s from "Excluding object" to the shutdown, with 1 Hz status queries answered in 1.30-1.78 s throughout while the main MCU sat at mcu_awake=0.000 and gcodein=0. EXCLUDE_OBJECT_DEFINE is not the cost. The line costs 115 ms through the live klippy and nothing thereafter; three prints of the same file without a cancel complete with bytes_retransmit=0. Removing it removes the object there is to cancel, not the work. tools/gcode-bench holds the harnesses, including the shlex fast path that takes that DEFINE from 91.6 ms to 5.9 ms on the printer -- worth having for large plates, but not this bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AVUMfbNuKUWWV32PBcydwb |
||
|
|
f8b925e48f |
Replace opkg with apk, and sign the feed
docs/notes/85-packaging.md chose opkg in 2026-08 on one argument: every package
is cross-built here anyway, so the winner is whichever format is cheapest to
PRODUCE -- and .ipk had an upstream tool where .apk meant abuild, a keypair and
an Alpine host. Two lines of that comparison were wrong.
apk mkpkg is an applet of apk itself: -I key:value metadata, -F directory, no
keypair, no abuild, no Alpine host. The "making a package" row described
apk-tools 2. And the on-device client was counted a wash when it is better than
one -- openssl and zlib were already cross-built for CPython, and libarchive
existed in this tree only for opkg, so adopting apk REMOVES a cross-build.
What made it possible at all is that apk resolves its database relative to
--root and uses the same root as the file destination, while / here is a
read-only squashfs -- measured: neither /lib/apk nor /etc/apk can be created.
pkgs/3rdparty/apk-tools/prefix.patch splits the two. Upstream already carries
root_fd and dest_fd separately and merely aliases them in context.c, so the
patch defaults the database root to $MODDIR and leaves files extracting at /.
That is opkg's compile-time VARDIR plus `dest root /`, in apk's spelling.
THE FEED IS SIGNED NOW, which it never was: opkg signed the index and
bin/build-packages.sh signed nothing, so nothing was verified on a printer.
apk verifies with raw PEM public keys, not X.509 -- measured on the machine,
where every certificate directory is empty. ./bin/apk-keygen.sh makes an RSA
pair, APK_SIGN_KEY in config.env points at the private half, and the public
half both seeds the USB stick (so the first install can verify) and ships in
anvil-core (so the printer keeps trusting). An empty APK_SIGN_KEY still builds
an unsigned feed, deliberately: `make packages` has to run on a bare checkout
or the packaging lane stops being a CI gate.
WHAT IT COST is a carried patch, against an internal with one consumer, on a
branch mid v2->v3 transition, on an architecture with no upstream apk port to
share the rebasing. That is the standing risk and it does not go away.
Four bugs this shook out, all silent, all found by a gate rather than by
reading:
* The prune deleted seventeen packages it had just built. The all -> noarch
mapping went into the emitter and not into pkg_pkgfile, so the prune looked
for a name nothing wrote. `make packages` exited 0.
* Whitespace-separated Depends dropped nineteen packages. opkg's pkg_parse.c
splits that field on ',' and nothing else, so the whole list became one
dependency whose name contained spaces and resolved to nothing -- and opkg
neither warned nor failed. `make build` shipped a payload with no Tornado
in it. test_the_payload_is_dependency_closed is what catches that now.
* Ownership was recorded as `nobody`. apk resolves an owner to a NAME against
<--root>/etc/passwd and the build lane runs --user with no passwd entry, so
every file in every package would have installed as uid 65534, with no
error at build or install time. build-packages.sh writes a two-line
synthetic passwd naming the build user root and points --root at it.
* Four recipes carried versions apk cannot parse -- two spelled a commit
+git<sha> where the grammar's hash slot is ~, x264 had a snapshot name
whose -2245 reads as a malformed release, and helixscreen had
0.99.115.creator5.3 where apk allows one trailing letter and then only a
suffix from a closed set of nine words. All four were valid opkg versions,
which is why nothing had noticed. pkg_version_ok gates every recipe now.
A v3 .apk begins with ADBd magic and is not a tar stream, so there is no apk
equivalent of the `ar -p | gunzip | tar` that hand-unpacked anvil-opkg: you
cannot open a package without a package manager. bin/build-payload.py puts the
cross-built binary on the stick at /mnt/apk instead. It is the same build
anvil-apk-tools contains, that package installs normally, and the bootstrap
copy never lands.
Two gates retired rather than moved. apk's installed database is plain text
(F: sets a directory, R: names a file in it), so the payload-ownership check
reads one file where it used to glob info/*.list. And it carries no clock at
all -- apk records no install time and mkpkg sets no build-time -- so the
Installed-Time normalisation case-build-payload.sh did on every build is gone,
and the test asserts the ABSENCE of a t: line instead. Clock-free by
construction rather than by repair.
Also removed: PKG_SECTION, which 45 recipes declared and nothing read once
apk's schema had no field for it, and the `make apk-host` target -- the
packager is built on demand by bin/build-packages.sh and stamped on the pin,
the way pkg_toolchain and pkg_buildpython already work.
Verified from a cold work/host and work/packages: 47 packages built and signed,
`make build` installs 35 and verifies them against the shipped public key,
`make qa-static` 195 passed, `make qa-replica` 135 passed -- the printer
installing off a simulated USB stick and booting. The installed set was checked
against the declared closure of the eleven roots: 35 expected, 35 installed,
nothing extra and nothing missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rbKEyLXrzSE6wm3iNApVp
|
||
|
|
6a4067e863 |
VFA calibration is one import away, and that import stops klippy booting
The stock app's "magic" VFA calibration is not app-side math at all. Traced
from CommMgr::calibration_vfa (0x0075e694), it sends five G-code strings and
the last one is STEPPER_RESONANCE_FACTORY_CALIBRATE -- a macro in FlashForge's
printer.vibration.cfg over klippy/extras/stepper_resonance_tester.py, which
searches amplitude and phase for the motor torque-ripple compensation the
closed-loop driver applies at 1x/2x/4x the electrical frequency. Every piece is
already here: the module is byte-identical in our vendored fork, the mclib
sections are in our printer.base.cfg, and mclib_set_resonance_damp is in the
stock mainBoardGD dictionary we never rebuild.
WHAT THE TRACE TURNED UP ON THE WAY. Two comments in this repo said the numpy
gap was a small item because the module guards its own import. It does not:
line 1 of stepper_resonance_tester.py is a bare `import numpy as np`, and
klippy.py:122 loads EVERY config section through a load_object that does not
catch ImportError -- the default=None argument only covers a missing file.
printer.base.cfg:19 includes printer.vibration.cfg, which declares
[stepper_resonance_tester]. So on the CPython 3.13 klippy has run on since
|
||
|
|
42d876d5eb |
The auto-PA sweep is host code; only the per-line verdict is in the eBoard
firmwareExe runs a real PA calibration at print start, inside the nozzle clean. CommMgr::paTestMgr @0x791640 prints one slow-fast-slow line per candidate at Y = 50 + 5*i for seven fixed PA values, asks the eBoard to score each line, keeps the smallest that scores 9, repeats until three sweeps have produced a winner and averages those three. All of that is host code and is recovered here in full. Only the verdict crosses into the closed toolhead MCU, over two commands that pa_adjust.py already forwards -- and pa_adjust.py is in our tree byte-identical to FlashForge's, with [pa_adjust] already in printer.base.cfg. So PA_ACTION and PA_GET should answer on a Reforge machine today; that is step one of the port. This supersedes "sensor unknown" in 60-background.md and "callable, not reproducible" in 25-app-vs-klipper-ownership.md. What stays unknown is narrower: which sensor the eBoard samples, what ACTION=11 and PC=666 select, and what the non-9 verdicts mean. Two findings that shape the port. PA_GET only respond_info()s its value, so the sweep cannot be a G-code macro -- it needs an extra that queries the eBoard directly. And PA_ACTION is an immediate MCU command, so FlashForge never synchronises the measurement window with motion; the port must copy that timing rather than improve it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kw2rehvQJ7LSc28hPikfqk |
||
|
|
f2e0e5fcb2 |
The manifest protected three files, and none of them needed it
Proposal: on update, rm -rf $MODDIR and extract into the empty directory. No manifest, no selective delete, no compatibility branch. The audit is the note. Against a full wipe .install-manifest was protecting backup/stock, .prev-root-hash and config-installed, and each one falls: nothing restores from backup/stock and the documented recovery is a stock reflash; .prev-root-hash is removed by the password branch; config-installed is a twelve-file snapshot serving one md5, for a compare whose stated rationale -- "there is no include-and-override seam" -- stopped being true when moonraker-custom.conf got included last, and which inverts its own purpose by keeping the webcam block and API lockdown off any printer that trips it. So moonraker.conf joins ff-*.cfg and is simply overwritten, owner- dropped files under $MODDIR become an accepted loss, and phase 2's on-printer half in 85-packaging.md is cancelled rather than owed: a printer that never deletes selectively needs no file database for it. Phases 1 and 2 in 80-s6-migration.md and 85-packaging.md point here. No code has moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXKk3zz8LAUPUDZkVEZ9fR |
||
|
|
4850be432c |
Docs: say what is there now, not what the last three commits removed
Deleting test/ and anvil.conf left claims behind in nine files. Every one of
these was present tense about something that no longer exists.
THE CHELPER SYMBOL GATE, in three places that each described it as live:
notes/20-klipper-fork.md said pkgs/klipper gates the .so on "both the ELF
flags and test-chelper.py's symbol check". It
gates on neither now: the ABI is asked once of the
installed filesystem in qa/replica/test_abi.py,
and the single-source rule -- the .so is compiled
from the chelper sources of the tree it ships -- is
the whole defence. The page says that instead.
notes/85-packaging.md described the KLIPPER_FORK fallback the script no
longer had; the script itself is gone too.
qa-migration.md "What survives in test/" listed both the 28 unit
tests and test-chelper.py as surviving. It is
"What survived in test/, and then did not", and it
names what went uncovered rather than leaving that
to be discovered.
ANVIL.CONF, in the two notes that still read it at runtime:
notes/80-s6-migration.md is a dated plan, so it keeps its phases and gains
a "Where this ended up" note: anvil.conf and every
MOD_* switch were removed, so the phases that
source it describe an intermediate state, and the
case-*.sh gates they name are qa/replica/ modules.
notes/85-packaging.md predicted seed/ emptying via a postinst that seeds
anvil.conf.default. It emptied by anvil.conf being
deleted instead. Its payload allowlist said six
entries including anvil.conf and bin/busybox; the
real one in test_ipk.py has five and neither.
THE TWO STANDALONE TREES under tools/, stale before any of this and now fixed:
tools/python/README.md and tools/supervisor/README.md both told the reader to
run their gate with `test/integration/printer-exec.py` over a case-*.sh, and
one offered a `make test-python` target. None of the three has existed for
several commits. Both now point at qa/replica/, which installs the real package
and asks more than hand-feeding a tarball to a case script could. The four
places tools/python/README.md narrates what case-python.sh proves are past
tense, since that is now a record of how the measurements were taken.
versions.env's sqlite3 note pointed at the same dead target.
NAV: every notes/ page was listed except 85-packaging.md, and qa-migration.md
was reachable only by following a link out of Testing, Building or How it
works. Both are in the nav now, which also clears the two "not included in the
nav" lines from every build. .venv/ is gitignored -- docs/requirements.txt
tells you to make one right there in the tree.
Verified: `mkdocs build --strict` is clean and no longer reports pages outside
the nav. qa/static is 123 passed, its 110 errors the same missing
vendor/opkg-utils as before. No .md file is read by any lane, so the check that
matters here is the build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bLGYpVAeVzAX6NeAxcU42
|
||
|
|
133e43e05e |
The layout that took twenty rounds to find was written down nowhere.
The docs were restructured into three sections yesterday. The reasoning lived in one conversation: which section a page belongs to, what may appear in it, and which things were removed on purpose. The next session would have opened mkdocs.yml, seen a nav, and guessed. .claude/skills/reforge-docs is the map. Three audiences with the test that decides between them, what belongs in each section and what must never appear there, and where a new page goes. The rules in it are the ones review actually produced: an owner page assumes the machine works, says when a step is optional, and names a file only when the reader has to type it -- which is why [ff_legacy], SAVE_CONFIG, dropbear, config inventories, theory sections, expected-output dumps and symptom tables all came out of pages they had been written into. It also records what cost time rather than only what was decided. A gain claim has to be checked against notes/25-app-vs-klipper-ownership.md, because tool calibration and input shaping are the touchscreen's own sequences reimplemented and were credited to the mod anyway. --strict validates markdown links and nothing else, which is how three render bugs reached master through raw HTML, images and task lists. The README hook is imported once, so mkdocs serve keeps running stale code after it is edited. Edits anchor on unique strings, because a file rewritten earlier in a session invalidates every line number -- that one was a real bug, not a hypothetical. And docs get written against the branch whose code ships: pages assembled on qa-suite-bridgehead described s6-rc services that master does not have. docs-writer.md comes back with it. It was written at the start of that session, never committed, and swept into a stash by `git stash -u` during a branch move; it holds the generic craft -- who the reader is, the voice, claim nothing you have not checked -- and now defers to the skill for the layout instead of describing a two-section nav that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3XZh4SqPdXAowVndz6tMr |
||
|
|
65a7528e09 |
The manual assumed a printer that arrives broken. It arrives calibrated.
The pages written on top of the old README inherited its premise: that after flashing you face a gauntlet -- go/no-go checks, a safety net, a mandatory calibration -- before the machine can be trusted with a model. That is not what happens. The factory numbers import themselves on the first boot, a bed mesh loads at the start of every print, and the mod is built to run the files the stock firmware ran. A printer that printed before the flash prints after it. "Your first print" now says so in its first line, and everything under it is optional, ordered by what you would actually reach for: check the offsets are applied, and calibrate only if that disagrees with the machine. Three audiences now, because owner and contributor was not the real split. "For experienced users" sits between them: How a print runs, and How calibration works. Someone who knows Klipper and wants to change what happens around their file has a place to go that is neither a walkthrough nor the build system. What was cut is most of what changed. Pages that were cryptic to the reader they were aimed at -- Printing, Upgrading -- are out of the nav and kept on disk to be rewritten rather than deleted. Within the pages that stayed: Expected output, Probe sampling, What is being measured, the symptom table, the dark-screen diagnosis, TOOL_Z_ADJUST, PLATE_CHECK, the ff-*.cfg inventory, [ff_legacy] and SAVE_CONFIG in a paragraph about factory calibration. Filenames and internals earn their place on an owner page only when the reader has to type them or look at them; the rest moved to the experienced pages or to Internals, where the audience wants them. Calibration became a section rather than a page, because tool offsets are not the only calibration: bed mesh has its own page now, and its own gotcha written down -- BED_MESH_CALIBRATE writes `default`, a print loads `MESH_DATA`, so a mesh probed by hand is a mesh no print will use. Input shaping is described but has no procedure page yet. Several claims came out because they were not true. "Is this for you" listed calibration and input shaping as things the mod gives you; both are the touchscreen's own sequences, recovered and reimplemented, and the notes say so plainly. What the mod actually gives you is that anything speaking Moonraker can drive the machine -- on stock, FlashForge's Klipper does not toolchange at all, it raises a flag and waits for an application that is not watching a Mainsail print, and the print hangs at the first T2. The page now opens by telling most owners not to flash it. Contributors are still deferred. Their pages are regrouped, not rewritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3XZh4SqPdXAowVndz6tMr |
||
|
|
0af15a549a |
The front page shipped with broken images and literal [x] boxes.
Three defects, all in the site machinery rather than the prose, all visible on the first page anyone opens. `r"\1=\""` is not an escaped quote. In a raw string that backslash is a character, so every `src="docs/…"` the README hook rewrote came out as `src=\"helixscreen-print-status.webp"` and the browser asked for a file whose name began with a quote. Both screenshots on the front page 404'd. `- [x]` is python-markdown's plain list syntax unless pymdownx.tasklist is enabled, so the Status checkboxes -- the list this project leads with, and the thing a reader decides on -- rendered as the literal text `[x]`. Enabled, with custom_checkbox so they get the theme's own mark. The checklists in is-this-for-you.md and after-the-flash.md were the same bug. And mkdocs rewrites markdown links but not raw HTML attributes, which is invisible until a page with an `<img>` in it is not the front page: with directory URLs, is-this-for-you.md is served from /is-this-for-you/, one level below the image it names. The hook now rewrites relative src/href in raw HTML too, prefixing `../` per level of the page's own URL, so an `<img>` works from wherever the page ends up -- and it points at a file that must exist, so a typo stays a broken link the build can still catch. Found by serving the site and looking at it, which is the only way any of these three surface: --strict validates markdown links, and every one of these was in HTML or in text it had no reason to read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3XZh4SqPdXAowVndz6tMr |
||
|
|
a6098c7f64 |
The README was the manual. It is a front door now, and the manual is nine pages.
Fourteen sections, install through troubleshooting, in the one file that existed when there was nowhere else to put them. The site gave the manual a home; this moves it there and orders it by what the reader is doing rather than by which subsystem owns the answer. Someone whose printer will not boot should not have to know that the symptom table lives in a file called hardware-testing.md. Three topics were documented two and three times over, and each copy was free to drift: install in README and toolchange.md, calibration in README, calibration.md and toolchange.md, slicer setup in README and toolchange.md, "before you start" in all three. Each is now in one place. The owner path is Is this for you -> Installing -> After the flash -> Your first print -> Nozzle-offset calibration -> Printing -> Upgrading -> When something goes wrong, with error codes, versions and how-it-works behind a Reference group. The README keeps what a front door keeps: what this is, the status checkboxes, what you give up, which file to download, and where to go next. toolchange.md was four documents in one 38 KB file. The owner half -- slicer setup, filament, runout, the offset check before a first print -- is in Printing and Your first print; the design notes, input shaper, roadmap, HelixScreen and reverse-engineering notes stay, and the page moves to Internals. hardware-testing.md was split down the middle by audience the same way: the go/no-go checks and the symptom table were the owner's, and only keeping the two verification files honest as the macros change is ours. Two things this fixes rather than moves. The error-code table was notes/70-error-codes.md, an internals note the README sent owners to; it is docs/error-codes.md now, a page. And toolchange.md still opened "Not yet run on hardware" while the README's checkboxes described a Creator 5 Pro that has been printing for weeks -- the two pages have disagreed since the first hardware run. Contributors are deferred, as agreed: those pages are regrouped, not rewritten. The nav says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3XZh4SqPdXAowVndz6tMr |
||
|
|
7347f56418 |
docs/ rendered on GitHub and nowhere else. It is a built, versioned site now.
The alternative was a wiki, and a wiki is a separate git repo: a page would stop moving in the same commit and the same review as the code it describes, with nothing able to catch the drift, and there would be exactly one live version of it. This project ships a package per tag and tells owners which checkboxes have touched hardware; the docs for the build someone flashed have to be readable, so Read the Docs builds this repo per tag and `latest` off master, with the version switcher that follows from that. Nothing moved. The audience split that the pages already have -- owner pages against build-and-test pages -- is the nav in mkdocs.yml, so a reader lands in their own section and never falls out of it, while the 30+ relative links between README.md, docs/*.md and docs/notes/ keep working in the repo view and the links people have already shared keep resolving. notes/70-error-codes.md sits in the owner section because README.md was already sending owners there. Two things had to hold for that to be true, and hooks/mkdocs_readme.py is both. Links out of docs/ -- `../payload/klipper/extras/ff_tool.py` and 18 others in toolchange.md -- are written relative because that is what works where a contributor reads them, in the repo; they become GitHub URLs at build time, and a link whose target does not exist is left alone so the build still fails on it. README.md is the front door and stays it: docs/index.md is generated from it on every build and gitignored, rather than being a second front page free to drift from the first. Anchors use GitHub's slug rules for the same reason. `#runout--clog` is what GitHub makes of `## Runout / clog`, python-markdown would have made `#runout-clog`, and toolchange.md:340 already pointed at the first. CI builds it with --strict, which is the cheap half of keeping docs true: a rename in payload/ now breaks the build instead of quietly leaving a page pointing at nothing. It cannot check that the prose is still right. Only a reader can. The site is not up yet -- the project has to be created on readthedocs.org and its versions activated -- so nothing links to it from README.md. The slug is free; mkdocs.yml claims it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3XZh4SqPdXAowVndz6tMr |