main
113
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
30c26bd1ac
|
resolve relative links against the disk, serve them as addresses
Item 2 of the order of work. An author writes `../day-01.en.md` — the path an editor preview resolves — and the engine emits `/posts/day-01/`. The larger effect is durability. Resolution goes through key → route, and a slug moves the route while never moving the key (ADR-0035), so a relative link survives a rename that a hand-written /posts/a-better-name/ does not. The demo proves it: `../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote the filename and got the slugged address. This is the engine altering authored markup, which ADR-0045 polices, so the test that matters is what it declines to touch. Fourteen cases must survive exactly as written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative path, a bare fragment, a bare query, a name climbing out of content/, and every relative path whose extension is not .md. That last line is what keeps cover.jpg working — a bundle's assets already resolve because its URL mirrors its directory, so rewriting them would break what works. Nine rewrite cases sit beside them. Key derivation goes through content.KeyFromName, exported for this: the language-suffix rule is the part that would drift between two copies, so it lives in one place while the five lines of joining are duplicated in check. khosra check now reports a relative .md link resolving to no bundle, as fatal — verified by mistyping one and watching exit 1. Only the .md form: an extensionless relative path may be an asset, and a checker that calls a working link broken gets ignored wholesale. Two debts this change paid rather than deferred. render.go reached the file-length advisory, so theme parsing moved to theme.go — 414 and 105 lines, one topic each, since parsing runs per rebuild and rendering runs per request. Not a _helpers.go shard. And the demo's coverage test bound its renderer with a *copy* of the rebuilder's wiring, so it missed this feature entirely while the real binary served it correctly. Navigation had already drifted the same way. Both now call one bind(), which is exactly what ADR-0072 was written about — and the test failing is the only reason the copy was found. Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is in ext where it belongs. 18 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c3125e7ad2
|
log every request, and make error mean something again
Item 1 of the order of work, and two gaps rather than one polish item. There was no access log. Nothing recorded that a request happened, and the Dockerfile ships the binary alone with the site root mounted (ADR-0010), so a bare deployment produced none at all — which mattered because offline log analysis is this project's answer to analytics: no counter on the read path, no third-party script. web.Logged now writes one Info line per request with method, path, status, bytes and duration. And slog was never configured. conventions.md makes importing `log` instead of log/slog a hard failure while nothing ever set a level, a handler or a format. Two flags now do, rejected at startup if unusable, because a logger quietly less verbose than asked for hides exactly the lines somebody changed the flag to see. JSON is the half that matters: it is what makes a log parseable. The re-levelling was the larger half. Counts were 38 error, 7 warn, 4 info, 0 debug; they are now 7, 40, 6, 0. Almost every one of those errors was ADR-0029's "logged, not fatal" category — a misspelled directive, an asset path climbing out of its bundle, an unreadable picture — where the engine coped and the reader still got a good page. Error used as "somebody should see this" means an operator cannot tell a broken build from a typo. The seven that remain are the five requests that answer 500 and the two inside fatal. A successful rebuild now says so. It swapped silently before, so an operator could see a failed rebuild and never a successful one, which leaves the failures with nothing to be read against. No wrapper package: log/slog is the module, and a layer over it would be an abstraction with one caller. Duration comes from content.Now, since the clock is confined to one file and verify.sh enforces it by filename. The recorder does not forward Flusher or ReaderFrom — nothing here streams, so the cost is one io.Copy fast path on static files, and implementing interfaces no caller needs is the speculation rule 6 forbids. Two things this change owed and paid. content.go's comment still said content problems were "logged at error level", which the re-levelling made false. And the new flags pushed runServe past the function-length advisory, so the site-opening block became opened() — a warning that fires on correct code gets acted on, not tolerated, and that is the whole reason the advisory exists. Evidence, demo site, JSON: rebuilt bundles=31, then serving, then one request line each for a 200, a 404 and robots.txt with real byte counts and durations. At -log-level warn, request lines disappear. An invalid level exits with the reason. 12 files. Core 2913 → 2959 of 3400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
69a7eb4733
|
move robots and sitemap out of core, and raise the ceiling on purpose
Item 0 of the roadmap's order of work, and it blocked everything after it: core
sat at 2965 of 3000 while the review scheduled four core-bound items, the first
of which — logging — wanted the whole remainder.
/robots.txt and /sitemap.xml are exact paths somebody else's software asks for by
name. They own no core concept and pass every test the architecture applies to a
feature; they lived in internal/web only because a feature could not own a route
until ADR-0081. internal/ext/discover/ now holds them. Core 2965 → 2913.
The seam gained one parameter to make it possible: a func() *content.Site, since a
sitemap must list what is served now and the index is swapped whole on every
rebuild (ADR-0077). A captured pointer would have frozen the site at startup —
which is the kind of bug that only shows up after a rebuild, in production.
The ceiling rises to 3400 as well as the move, because the move alone could not buy
the room. feed.go and web/extras.go cannot follow discover out: a feed lives at
/{section}/feed.xml and extras under a bundle's own URL, so both are resolver cases
while the seam mounts exact paths only. Raising by the minimum that unblocks one
item produces a ceiling nobody believes, so 3400 fits the View cluster with
headroom. HARNESS.md asks that a raise be read as evidence something belongs in
ext before evidence the number was small; both readings were true, so both actions
were taken.
web no longer reserves those two paths, so a clash between features is wire.go's:
it merges route maps in declaration order, keeps the earlier claim, logs the loser.
Verified — a site shipping root/robots.txt starts, serves the engine's robots.txt,
and logs the passthrough claim, where an unguarded mux.Handle would have panicked.
Evidence: robots.txt and sitemap.xml are byte-identical before and after the move
against the demo site (67 and 2701 bytes, cmp clean), and the sitemap keeps its
application/xml type.
One real cost, recorded in both places rather than hidden. internal/web's
visibility test asserted that a listing, a feed *and* a sitemap all hide
unpublished bundles — one property, one test, because all three share a Query. The
sitemap half moved to the feature instead of a web test importing ext, which would
invert the one-way layering the architecture gate enforces. That property is now
asserted twice, once per package owning a surface.
Three gates caught real mistakes on the way: the staged-tree check found a partial
stage where git rm had staged a deletion while the caller edits were unstaged, the
coupling gates demanded state.md and HARNESS.md, and the nesting advisory rejected
a closure that put the merge loop one level too deep — fixed by making it a plain
function rather than tolerated.
Extensions 6 → 7. Routing cases unmoved: exact paths are mux entries, never
resolver cases, which is what that counter's exclusion column already said.
13 files. Core 2913/3400, ext 2495/3500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b313b729d9
|
review 130 feature ideas, and delete the lists that held them
One idea at a time: definition, necessity, priority, layer, recommendation. Every row is now shipped, scheduled, parked with a trigger, or dropped with a reason — so both tracking files are gone rather than left as a parallel backlog. Roughly a third already shipped. A quarter needed only a theme fragment or CSS, including several the list assumed were features: arbitrary `theme.*` frontmatter attributes already reach templates through Extra (verified), native lazy loading already ships and is deliberately absent on lead figures, and in-page timelines are a theme-defined container. roadmap.md gains an "Order of work" — nine bodies of work in sequence, because the sections this review added had accumulated without one. Item 0 is a decision, not work: core sits at ~2965 of 3000 and logging alone wants the remainder, so discover.go moves out *and* the ceiling rises with an ADR. feed.go and web/extras.go cannot move; they are resolver cases, and three parked items now wait on that same seam decision. Two ADRs came out of it. ADR-0083: the dependency rule described a practice it forbade. "Stdlib first, always" and "usually 40 lines of stdlib" would, read literally, have argued against all five modules here — every one is a format or algorithm somebody else specified, and their allowlist comments say so. Surfaced when this agent proposed hand-rolling Reed–Solomon for QR encoding. The test is now whether you can verify it, not whether you can write it, with compute-versus-hold as the counterweight. conventions.md and allowed-deps.txt contradicted it and were realigned. ADR-0084: the Effect primitive said every Effect is "idempotent and re-runnable from scratch" — true of derivatives, false of anything outbound, since a sent message cannot be recalled. Split into artifact-producing and outbound, the latter idempotent only against a delivery ledger, at-least-once with a dedupe key, and ledgers in a -state directory distinct from the disposable cache. Asked for as forward-looking design so webmentions need no redesign; recorded as a shape rather than built, the way extensions.md records the Extension struct. Also fixed a STATUS claiming Effects were "not buildable yet" when the derivative pass has inhabited them since ADR-0042. Findings that were defects rather than ideas: the Atom feed emits no <author>, which RFC 4287 requires. HTML comments in content are published verbatim — verified — so anything commented out is already public, and a check warning is scheduled rather than the engine deleting authored bytes. `Page` has no Date, so a theme cannot mark up dt-published or show an article's own date. A frontmatter naming rule after this agent proposed `archive` alongside the accepted `archived`: no near-homograph keys, and name the benefit rather than the vendor. reference/microformats-and-indieweb.md is new and is the reference asked for — microformats2 properties and IndieWeb rel values mapped to where a theme puts them, with spec URLs, marked scheduled. rel="me" is the highest-value lowest-cost item in that space and needs no endpoint at all. Four things this agent got wrong and the human caught: asserting "the engine never fetches" from a sentence scoped to the content repo, twice; inventing a data-sovereignty argument for an item he had filed as "a cool geeky thing", when the raw Markdown in his git already is the sovereignty; the archive naming; and claiming microformats could be fully implemented today. 15 files, +556/-385. No code changed. Nothing was scheduled that this agent could not name a consumer for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c6c3f0aa3f
|
record the core-budget squeeze and why only part of it can move out
Held on the human's call: the code move waits for the idea review, since how much
core room is needed depends on what that review places in core.
Worth recording rather than rediscovering. Core sits at ~35 lines of headroom with
at least four core-bound items before the Arc 2 freeze — the View layer, the Stage
pipeline (roadmap.md Arc 2 item 2, which I had been treating as optional), a
minimal settings cascade the Views counter couples to view selection, and declared
content types.
The relief I recommended was overstated and is corrected here. discover.go (~71
lines) can leave core once ADR-0081's seam passes the live index. feed.go and
web/extras.go cannot: a feed lives at /{section}/feed.xml and extras under a
bundle's own URL, so both are resolver cases while the seam mounts exact paths
only — the limit that ADR's own "revisit if" line predicted and I did not check
before recommending ~196 lines of relief.
So the open choice is extending the seam to resolver participation versus raising
CORE_LOC_MAX with an ADR, and it is the review's to settle.
2 files. No rule or threshold moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
949ce34224
|
give pending work four owners instead of six scattered lists
Ideas were spread across six places with real duplication and one list that was about to be lost. Consolidated by merging the duplication, not the categories. The 130-item feature-list triage lived in .scratch/, which is gitignored — 130 verified verdicts a scratchpad cleanup would have deleted. It is now ideas/feature-list-triage.md with the header the ideas gates require, indexed, and out of agent context by default like everything there. exploration.md's Verdicts table was empty and designed for exactly this; it now carries one row per body of work with a pointer, rather than 130 rows nobody could skim. Deferred triggers had no single owner: continue.md restated them and drifted, still listing the extension registry as pending after ADR-0081 built it. deferred-decisions.md now holds the only trigger table, covering the two it already described plus math, corrections, write routes and the registry's remaining fields. continue.md links to it. Two rows record couplings worth not rediscovering: the View layer may pull a minimal settings cascade in with it, because the Views counter says selection resolves *through* the cascade; and ADR-0081's seam mounts GET only, so Arc 3's first write endpoint extends it. Corrections per post is dropped in its cheap form and parked in its real one. A hand-maintained frontmatter list was rejected for an authoring reason rather than a technical one — a half-remembered list of corrections misleads where none would not, because two entries imply those were the only two. Deriving it from repository history is the shape worth waiting for, and the file records what must be settled first: whether the engine may read local git history at all, that it needs a dependency or a subprocess, and that not every commit is a correction. Not merged, on purpose: latent items stay in state.md because they describe shipped code and are gated against .go commits; arcs stay in roadmap.md because they are sequencing decisions. One file would have flattened four levels of authority and made pending work cheap to load, which is the opposite of why ideas/ sits outside harness/ at all. harness/README.md now says which list owns what, in both the topic table and the single-source table. 7 files. No rule or threshold moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
16fde5ee68
|
lift the skill's question cap, which CLAUDE.md lifted and it did not
SKILL.md said "maximum three" clarifying questions while CLAUDE.md §4 said
"there is no cap" — lifted in
|
||
|
|
078ec8eedf
|
check bare paths in scripts/, and record why one file has no .sh
Two answers to "add .sh to sh files". Exactly one file lacks the suffix: scripts/hooks/pre-commit. git locates a hook by exact filename, so renaming it would leave a gate that looks present and does nothing — verified in a throwaway repository, where hooks/pre-commit printed and hooks/pre-commit.sh was ignored while the commit succeeded regardless. The name belongs to git, so the file keeps it and conventions.md now states the exception rather than leaving it as an inconsistency someone will try to tidy again. The extension was never the defect anyway. The dangling-path gate had only ever matched backticked citations, so `git add docs/surface.md` in the hook — an argument, not a citation — survived the docs/ rename and surfaced as a fatal inside a commit that otherwise succeeded. That gate now also reads paths in scripts/ unquoted, which is the check that would have caught it. Proven both ways: restoring the exact bug fails the gate with "reference to a path that does not exist: docs/surface.md", and a working tree passes. The first attempt did not catch it — docs had been dropped from the alternation because the directory no longer exists, which is precisely the class of stale reference worth failing on, so docs is in the bare pattern on purpose. Backslashes are stripped before comparing, so a path written as a regex — \.claude/settings\.json — is checked as the file it means rather than flagged as the file it is not. Prose is still checked only inside backticks: a sentence saying "under harness/" makes a point no filesystem can verify, while a script naming a path either has it right or is broken. One gate label changed with it. `pass "harness/HARNESS.md coupling"` read fine while the directory was docs/ and now parses as a filename, which the new check duly flagged; it is "harness and HARNESS.md move together". No doc cited the old label. 4 files. No rule or threshold moved — one gate widened, one convention written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
07cf655d09
|
fix the pre-commit hook's path, missed by the rename
ADR-0082 moved docs/ to harness/ and left scripts/hooks/pre-commit staging docs/surface.md. The hook runs on every commit, so the very commit that did the rename hit it: `git add docs/surface.md` failed with a fatal, the commit otherwise succeeded, and surface.md went in only because it had been staged by hand beforehand. The sweep missed it because the file has no extension and the grep that found every other reference was filtered by --include='*.sh'. HARNESS.md now says so where the hook is described, since the next rename will make the same mistake for the same reason. Verified by this commit: the hook ran, regenerated harness/surface.md, and staged it without error. 2 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ec6e9838f0
|
rename docs/ to harness/, and reserve docs/ for the reader
docs/content-model.md opens with "Engine specification". It is also where the rule lives that a leading underscore makes a file unaddressable — and the human who owns this site did not know that rule, because nothing in this repository is addressed to an author. Twelve documents named docs/ while being exclusively about building the parser is a signpost pointing at the wrong room. Naming the directory for its audience makes the gap visible instead of hiding it. docs/ is now reserved and deliberately absent: an empty docs/ is an honest statement that end-user documentation does not exist, where docs/ full of parser specs was a claim that it did. HARNESS.md stays at the root. Root holds the three entry points — README.md for a human, CLAUDE.md for an agent, HARNESS.md for whoever maintains the machine — and harness/README.md is the map of the directory, so moving the guide inside would have collided with it for nothing. Mechanical and wide: 100 path references across 24 files. Every verify.sh gate that names a doc by path, the directory lists the dangling-path and ADR-number gates scan, surface.sh's output target, the Makefile, CLAUDE.md's read order, the skill, four commands, and two Go package comments. A first pass with a shell loop silently edited only four files and the rest still said docs/; the fix was to write the file list out and check the remaining count was zero rather than trust the loop's exit status. No rule, threshold, gate or obligation moved — this is a rename, and the gates demonstrated it twice: they stayed green on the new paths, and the ADR-number gate caught ADR-0082 before the entry existed. Deferred, both on the human's call: the end-user documentation site itself, which wants its own decision about where it lives and whether its claims are gated; and moving examples/ under docs/, since demo-site is a live site root that verify.sh, the coverage test and make demo all point at, and moving it would couple a rename to a design nobody has made. 31 files, +146/-106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9349c54d2e
|
let a feature own a route, and serve the site's own files at exact paths
Addresses like /.well-known/security.txt are fixed by somebody else's spec. None is a bundle, none belongs under /static/, and core had no way to serve one. This is the trigger the extension registry has been held for, in those words: ADR-0042 called core's generic derived-file route "the seam to revisit when a second feature wants output of its own", and state.md's counter note said to build the registry "when a feature wants a route". Raw passthrough is that feature, so the seam is built rather than worked around. Only Routes, not the seven-field Extension struct extensions.md describes. Five of the other six fields have no implementor and building them would be the speculation rule 6 forbids. It also kept the change inside the core budget, which had 65 lines left: the seam is ~30 core lines and the feature's own code lands in internal/ext/, where there is room. Core is 2965/3000. A feature returns map[string]http.Handler; core mounts each as an exact pattern and learns nothing about who owns it. A path core already answers is skipped with a warning, not overridden — http.ServeMux panics on a duplicate pattern, so a site shipping root/robots.txt would otherwise take the server down at startup. Verified: server alive, engine keeps /robots.txt, warning logged, zero panics. Templating is opt-in by filename. A .tmpl suffix is stripped from the URL and the file is rendered with text/template — never html/template, which would turn an ampersand in a contact address into & and a JSON quote into ". Opt-in by name rather than by sniffing the type, because a key or a signature may contain anything and a pass choosing for itself which files to rewrite would eventually eat one. The data is the site's own declarations and nothing more, which is the point: a security.txt naming its canonical URL should not repeat what site.yaml already says. Headers come from root/_headers.yaml, exact paths only. Globs are a second-use feature and the concrete need is a handful of .well-known names. The manifest is not served, by the leading-underscore rule that already means "not addressable" everywhere else — no special case was added for it. A manifest that will not parse is logged and ignored; the files still serve. Found while counting: the Extensions row read 4 while five packages existed. notation landed in ADR-0061/0062 and was never counted, though the prose beside the number already named all five. Corrected to 6. That is the latent item about counters having no mechanical check, demonstrating itself. Not done, and logged as latent: khosra check cannot report a root/ file shadowing an engine path, because verify.sh fails a feature that imports a sibling and the reserved paths live in passthrough. The startup warning fires on every boot, which is louder than a check finding. Evidence against the demo with a fresh binary: /pubkey answers with its declared text/plain despite having no extension; /.well-known/security.txt answers with Canonical filled from site.yaml's base, plus the declared CORS header; /humans.txt gets a derived type; /_headers.yaml is 404; / and a bundle page are untouched. Eight unit tests cover layout, absence, interpolation, non-escaping, declared and derived headers, a broken template, and a broken manifest. 24 files, +514/-46. Extensions 4 (miscounted) → 6. Routing cases unmoved: exact paths are mux entries, not resolver cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
64e53f28c8
|
give a page the assets its own content asked for, and write down the rule
Two decisions and one mechanism. The human wants demos, games and runnable
embeds to carry real CSS and JS while every ordinary page stays scriptless, and
wants adding an asset to be theme work rather than a rebuild.
The mechanism reuses what already had that property. A theme defines
`assets:<name>` beside its other fragments; shortcodes record their own name as
they are opened; after conversion the engine renders each matching fragment once
into Page.Assets. So a gallery calling one shortcode forty times carries its
stylesheet once, and a page that called nothing carries nothing. Frontmatter
`use:` reaches the same fragments without a call.
Considered and rejected: templates/assets.yaml, which reads more declaratively
and buys a parser, a contract shape and a rebuild for conditional markup; and a
table in Go mapping shortcode to files, which would hardcode exactly what was
deliberately made data-driven.
Collection is parse-phase, so no transform counter moves — goldmark's extender
list is already the ordered pipeline for parse work, which state.md's counter
says in its "does not count" column.
Separately, styles/scripts are lifted at last. They sat in content-model.md's
table unread, and the theme contract listed them under "what the engine
provides", which was aspirational rather than true. Both are bundle-relative: a
name with .. or a leading / is dropped and logged, the refusal ::include and a
code block's file= already make. The engine builds the URLs because a theme must
not construct an address.
ADR-0080 writes the antifeature list down, with its single exception inside it.
An antifeature nobody recorded does not bind anything, and each of these dies to
one reasonable-looking request at a time. The exception is author-invoked and
cannot fire by accident.
The reference theme emits the stylesheets and no script element at all. That was
the human's correction to a first attempt which had page.html emitting the tag
and verify.sh narrowed to permit it — narrowing the gate to fit the code was
backwards, and the narrowing was also wrong, passing a probe with a hardcoded src
because it filtered whole lines and every line carries {{define}}. verify.sh is
untouched. examples/demo-site redefines the head block instead, so the JavaScript
half is demonstrated by a site rather than built into the binary, which is a
better demonstration and a stronger property.
Evidence, against the demo site with a freshly built binary: the sandbox page
carries its own css and js at bundle-relative URLs; colophon calls ::tally twice
and carries tally.css once with zero scripts; about calls it never and carries
neither; listings unaffected. Plus a table test for the escape refusal, which
until now had only the running server behind it.
19 files, +355/-86. No counter moves. Demo is 31 bundles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
69f43e5791
|
enable task lists, superseding the decision that excluded them
ADR-0058 kept task lists out as "a note-taking affordance, not a publishing one". That reasoning measured the wrong axis: a checklist inside a published technical piece — setup steps, a runbook, a what-I-tried list — is publishing, and nothing else in the dialect expresses "this item is done" without the author hand-writing an entity. ADR-0078 supersedes that half and records why. The half of ADR-0058 that mattered is untouched: extension.GFM stays refused, because the bundle drags linkify in with the tables it is wanted for, and linkify rewrites an author's plain text into markup — the line ADR-0034 draws. One named extension is not a bundle, and wire.go now says so where the temptation to reach for GFM will next appear. ADR-0058's Status line names its successor, so a reader arriving there learns the task-list sentence no longer holds. Same in-place Status annotation the mutability rule allows, Decision text untouched. Checkboxes render disabled: static markup, nothing clickable, nothing stored. A reader with scripting off sees the same page, which is the property the whole theme is built on. goldmark adds no class to the list, so theme.css finds it with :has rather than the engine inventing markup to be styled by. Demo carries the case ADR-0051 requires — a colophon checklist of what this build does and does not do, including the unticked "ship a single byte of JavaScript", which is true of that page and asserted by the test's absent list. No counter moves: state.md's counters are explicit that an upstream extension enabled in the list is dialect, not a feature of this engine — only a package under internal/ext/ counts. 8 files, +55/-10. 1 line of engine code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4fff6fcd43
|
stop the harness deferring to a marker system it deleted
/refresh-docs step 4 told the agent to check content-model.md's `[spec]` versus
`[live]` markers. The `[spec]`/`[arc1]` system was deleted in
|
||
|
|
be92b4b957
|
correct five claims about behaviour this engine does not have
All five were found by checking content-model.md against the parser rather than by any gate, which is the point: verify.sh catches a dangling path and a missing ADR number, never a sentence that is merely untrue. The Scaffolding section documented `khosra demo <empty dir>` writing a generated site. ADR-0051 deleted that generator eleven commits ago in favour of the tracked site in examples/, so the paragraph described a subcommand main.go never dispatched — and justified itself with "nothing in the engine repository is content", which the committed demo site contradicts. Deleted rather than rewritten: what the demo is belongs to state.md's inventory, and restating it here would have broken the single-source rule to fix a smaller problem. ADR-0050 is where that claim originated and it still read "Status: accepted", so a reader arriving there had no way to learn the generator was gone. Its Status line now names ADR-0051 as superseding that half. This deviates from decisions.md being append-only, so the exception is recorded in the mutability column where the convention lives: a Status line may be annotated in place, the Decision text never. "The parser does not read `slug` yet" was false and contradicted by the frontmatter table twelve lines above it in the same file. Deleted. The Sequences section and the Sequence doc comment both said `draft` is not honoured "because no bundle carries it yet". Two errors: the demo site carries one, and draft is honoured — membership resolves through Lookup, which is the single place ADR-0024 hides unpublished bundles. The doc now says what actually happens, including that prev/next closes over the gap. state.md said draft "lands in Extra unread". It is lifted to Bundle.Draft and deleted from Extra, and content_test.go already asserted it. Evidence: a three-chapter series with a draft middle, served by a freshly built binary. The landing lists First Rain and Third Rain; chapter one's next points at three/, not two/; the draft's own URL is 404. That absence has no test, so it is now a latent item with the reason it is safe by construction. 6 files, +25/-25. No counter moves. surface.md regenerated: the comment gained a line and shifted ten declaration numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f5d479f06
|
stop the handoff outranking the log, and gate the pointers into it
A session picked up work from .scratch/continue.md and offered the human a doc trim that was already in HEAD. The handoff was written at 01:06 saying the trim awaited a yes; the commit containing the trim was amended at 01:12, underneath it. Five defects made that possible, and four of them are the harness's. The read order inverted trust: docs/README.md sent the next session to the handoff "if present, otherwise git log" — consulting the one ungated file instead of the record that cannot lie, which is backwards from every other rule here, where the code beats state.md and generated surface.md beats memory. The handoff is now step 4 of CLAUDE.md's read order, explicitly after the harness and never instead of it, and anything it calls pending is checked with git log -- <path> first. The tick fired too early. "A planned item completed → tick it in continue.md" sat in the Document step, so it recorded the plan's version of events while the commit could still move; an amend, a squash or a late fix moves it. Reconciling now happens after the commit exists, and a completed item is deleted rather than ticked, because a ticked item still reads as an item. HARNESS.md authorised the drift outright — it said the file holds "what is done", which is exactly what continue.md's own header promises it never records. It is now described as what it is: a temporary handoff, uncommitted, ungated, discardable, holding the continuation point and the carried findings. state.md still pointed at .scratch/build-queue.md, replaced two commits earlier. The dangling-path gate missed it because .scratch was absent from its alternation, so no pointer into the handoff directory was ever checked. It is included now, guarded on the directory existing — a fresh clone has no handoff and must stay green. Both directions were run: present with a stale pointer fails, absent with docs naming it passes. The gate then flagged its own explanatory comment, which is why that one path is written without backticks. No gate can compare an uncommitted file against anything, so the file's job is narrowed instead of enforced, and HARNESS.md now says which mechanism stands in for the missing gate rather than implying one exists. 6 files, +47/-9. No rule, threshold or counter moved. CLAUDE.md 138 → 142 of 150. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1510a5ff7a
|
compact the harness: no change history, no fact stated twice, no plans in the contract
HARNESS.md described how the ceilings got where they are — a paragraph of changelog that grew with each raise. The ADR log is where history belongs, so the section now states what the two ceilings are for, where the values live, and how to read a raise. Any doc that narrates its own edits will do this again. theme-contract.md explained parse order three times: once in the fragments section, once under the stability rule, once under overriding. Once now, with the other two pointing at it. content-model.md carried a marker system — `[arc1]` build now, `[spec]` recorded intent, plus a standing instruction never to build from a `[spec]` section. The better answer than a stricter marker is no marker: the doc now describes only what the parser accepts, and the shapes nobody has asked for moved to ideas/exploration.md, which is storage and out of context by default. There is nothing left to build speculatively from, so nothing needs to say so. 518 lines to 469, and rule 6 plus the leaf/trunk test already cover the rest. A sweep for filler phrasing found almost none — the prose was already tight — so that part is two rhetorical tics rather than the cull expected. Reporting it honestly matters more than manufacturing a diff. No rule, gate, threshold or obligation moved. |
||
|
|
2609f69a87
|
record what this session established, with how it was established
Three reference files, each stating whether a fact was measured, read from source, or asserted — an unattributed number is a rumour. math-on-the-web: MathML is the only no-JS route, the one pure-Go TeX→MathML library is an untagged 2023 commit, and writing MathML directly costs nothing since raw HTML renders. syntax-highlighting-choices: chroma is two modules and ~5MB, every alternative a search returns is JavaScript, and custom lexers load from XML at runtime without a rebuild. goldmark-behaviours: the seven surprises that caused or nearly caused defects — strikethrough claiming a single tilde, delimiter runs pairing across whitespace, per-parse heading id counters, footnote id prefixes, raw HTML being dropped rather than escaped, the language class already in the output, and ParseFS globbing. |
||
|
|
77c658bf98
|
move the feature catalogue out of docs, and replace the queue with a plan
Two structural changes, both about what an agent may pull into context. exploration.md catalogues engine features nobody has asked for. That is storage, not working material, so it moves to ideas/ where nothing sweeps it and it is opened only when named — the same rule the other parked material already follows. Six references repointed; the ideas gates then demanded an index line and a status, and both were supplied rather than exempted. build-queue.md was 516 lines, nearly all of it entries 0-23 finished months of work ago, with the plan buried at the top. It becomes .scratch/continue.md at 49: where the code is, what is planned, and the findings worth carrying that no doc owns — chiefly that silent damage to prose is this engine's recurring failure mode, and that three defects this arc were invisible to curl. Docs and HARNESS point at the new names. No rule, gate or threshold changed. |
||
|
|
25d7045133
|
uncap the clarifying questions, and stop the harness asserting numbers
The cap was arbitrary and the wrong lever: what matters is that a question's answer changes the code, not how many such questions a request happens to carry. A request with six real forks now gets six, batched into one turn — splitting them to look brisk costs the human more than asking once. The instruction to ask is unchanged and still gated by "each with a default so silence answers". The rest is numbers the harness had no business holding. Three kinds, swept across every harness doc, and none of them wanted machinery. Restated values were the real fault: HARNESS.md carried the ceiling figures, which docs/README.md's single-source table says live in scripts/budgets.env and nowhere else. Prose repeating a value is a copy waiting to go stale, which is exactly the rule I was quoting at everything else. The ADRs keep old and new, as an append-only log should; the harness names the concept and points. Illustrative figures — how long verify.sh prints, surface.md against the source, how much the binary grew — drift every commit and carry no decision, so they now say "a line per gate", "an order of magnitude smaller", "roughly a third". Counts that restate their own list are the same fault in miniature: "the three gates people skip", "six commands", "three pieces of it are mechanical", "two sibling folders", "three cases, no fourth". Each is a number that goes wrong the day the list beneath it changes, and none of them was doing any work. Left alone deliberately: values with no other home — the ~50% overrun, the 72-character subject, 40 lines of stdlib over a dependency, toolchain versions. Those are the source, not a copy of one. No gate for this. It would have to guess which four-digit number is a budget rather than a year, an ADR, an HTTP status or a Go version, and a gate that fires on correct prose is a defect. |
||
|
|
b8c62d38fd
|
swap the index and the theme as one snapshot
Two stores meant a request landing between them saw a new theme with the previous index: each half coherent, the pair a state that never existed on disk. Microseconds, which is why it waited — it closes now because the fix removes machinery instead of adding it. web.Snapshot holds both behind one atomic.Pointer that a rebuild stores once. A Renderer never changes after New, so Refresh and the atomic inside the renderer are gone; an immutable renderer is the simpler object, and the one place a change is applied is now the one place it is observed. web.Handler lost its renderer argument and twenty test construction sites moved with it. Live reload verified on the real binary through the new path: a template edit appeared and reverted, two rebuilds for two edits. Also answers the session's open question: no subagents for fan-out reads. A verdict arriving without the reading behind it cannot be audited, which is the thing this harness exists to make possible. Latent list: 6. |
||
|
|
aa9bd2a489
|
make merging the default include model
Composing a page from several files is why includes exist, and one endnote list at its end is what that page wants. ADR-0066 kept the old default only so existing content would not re-render; both models have now been seen and the human chose. `include: embed` still asks for the other thing. One-level needed enforcing on the new path: a spliced fragment's own include line would otherwise be parsed as a call and expanded a second time, which a test caught. It is dropped during the splice, matching what embed already did. |
||
|
|
67defae912
|
highlight code server-side, and let a block quote a file
chroma at render time, emitting CSS classes rather than inline colour, handed to a `code` theme fragment. Highlighting works with scripting off, in a feed reader, in a browser that never runs JavaScript. No lighter pure-Go option exists — every "alternative to chroma" is JavaScript, which the reference theme is gated against. A fence's info string carries the rest: title, numbers, start, hl=3,7-9, and file=name lines=A-B, which reads the snippet out of a file beside the bundle and numbers it by that file's own lines. So a post quotes several parts of one program without the copies drifting from it, and a reader can find what they are looking at. Verified on the real binary: the same file at lines 5-10 and 12-14, each numbered as it really is, with different lines tinted. Not a new package: a new one could not import the key=value parser this repo already has, because ADR-0069 forbids a feature importing its sibling, and a second parser for the same syntax is what §6 stops. Two costs, both stated in the ADR rather than buried. The binary goes from ~15MB to 19MB, for a project whose story is one small binary. And the reference theme now carries a token palette — the first thing in it that is a taste rather than a demonstration — kept to eight classes for that reason. The demo quotes a shell file, not a Go one: a .go file under examples/ joins the module and has to compile, which the build gate caught before it shipped. 6 of 9 modules, ext 2188/3500. |
||
|
|
4c5bda98ab
|
raise the ceilings for a list of features, and say which kind of raise it is
core 2850 -> 3000, ext 2000 -> 3500, modules 6 -> 9. ext was the binding one: 1975 of 2000, with syntax highlighting still to write, so for that ceiling this is not anticipation but the difference between building the next feature and not. The other two are bought ahead of a list of features the human has signed for. DEPS_MAX 9 leaves room for chroma and its regexp2 — which fill 6 exactly — plus two more. Worth naming rather than glossing: a ceiling raised on evidence is a measurement, and one raised on intent is a budget. The first two raises were the first kind; this is the second, which is weaker. ADR-0041's test still stands — if core approaches 3000 the question is again what belongs in ext, and the answer is still feed.go and discover.go, waiting on a feature being able to own a route. Invariant 9 is measured by a number that has now moved three times. It says something only because ext rose 75% where core rose 7%. FILE_LOC_WARN and FUNC_LOC_WARN are untouched: they are about one file being readable, and nothing about the plan changes that. |
||
|
|
f27193a036
|
retire the trigger that pointed at a dropped queue entry
G4 — keyboard and swipe navigation — is out of the plan rather than deferred: it needs client JS, and the pages already carry prev/next links. The queue says so, and the one latent row whose trigger named it now names something that will actually happen: the next edit to base.html. |
||
|
|
6447a995d1
|
bound the picture memo, evicting the least recently used
The memo held one entry per picture ever rendered, for the life of the process. Correct for one author's laptop, wrong for what this engine is meant to be: a server that runs for months and serves a whole site to many readers. Least-recently-used rather than clearing when full, because a site has pages nobody opens for months and a front page opened every minute — discarding wholesale throws away exactly the entries about to be asked for again. A map into a recency-ordered list: reads promote, evictions take the back, both constant time. 1024 entries is a few hundred kilobytes. Generous enough that a normal site never evicts, bounded enough that no site can grow the process without limit. The number is a constant and not a setting, because a knob with one user is a knob nobody asked for. Eviction, replacement and the bound are tested, including under -race, since requests are concurrent and the store is shared. Latent item cleared. ext 1975/2000. |
||
|
|
751ab9c06f
|
move the demo's coverage test beside the wiring it proves
The test rebuilt the feature list by hand, because a package cannot import a main, and it drifted three times in one session — the dialect, notation, Compose — each caught by a failing case rather than by the copy. The obvious fix was a composition package under internal/ext, and it was the wrong one: ADR-0069 forbids a feature importing its sibling, so that package would have failed the gate on its first build and the invariant would have been weakened one commit after becoming mechanical. Moving the test is smaller and points the other way — cmd stays the only place a feature is named, which is what extensions.md asks for, and wire_test.go was already here for the same reason. runServe and the test now call one function for the renderer, so there is no list to keep in step. Four files named a feature before; three do now, all of them package main, plus a benchmark that deliberately wires one extension to measure the render path and never claimed to be the shipped list. The core ceiling paid for it rather than being raised a third time: `given` was a helper with one caller and is now inlined into the only function that used it. core 2842/2850. Latent item cleared — the one that was marked due. |
||
|
|
1b898fdfc3
|
split the theme's fragments into a directory, keeping the file form
One file held every fragment, and it gained one per feature all session: figure, gallery, icon, three admonitions, details, aside, contents. A theme author overriding one had to copy the file or redefine into it, and a diff of the theme became a diff of everything. Both forms are supported, because a small theme is happier with one file and the contract should not force a directory on it. Parse order is embedded file, embedded directory, site file, site directory, and the last definition wins — so the directory overrides the file within one source and a site overrides the binary either way. Verified with a site root using both at once: its shortcodes.html supplied `icon`, its shortcodes/note.html supplied `note`, the embedded directory supplied the rest, and with the same name in both the directory won. The embedded theme ships the directory only, seven files, so nothing is defined twice. parseSet takes globs now and lost a branch doing it. Its old guard — a literal embedded name must exist — had to go, since shortcodes.html is deliberately absent; the replacement is stronger, failing at startup when a set matches nothing anywhere, which also catches a renamed base.html. |
||
|
|
4f706f1c35
|
delete date from Extra, like every other lifted key
Extra holds what the parser does not name (ADR-0002), and title, aliases, tags, order, slug and draft are all deleted once lifted. date was the one exception, so a template reading .Extra.date got the raw YAML value sitting beside the parsed time — two spellings of the same fact, which is how they drift. One line, and a test that names every lifted key rather than just this one, so the next field added to Bundle has somewhere to fail if it forgets. Latent item cleared. It had been waiting on "whatever next reads Extra generically"; nothing does yet, but the fix was smaller than the row describing it. |
||
|
|
482a862ff5
|
require a counter row to say what does not count
Four of these counters were re-scoped the first time anything tested them, and every re-scoping was a sentence about what had been wrongly included: transforms were counting parse-phase work goldmark already orders, views were counting output formats, effects nearly counted an in-memory swap, extensions counts packages rather than goldmark's own extensions. So the fix is not another counter but a required shape. The table gains a fifth column and verify.sh fails on a row that leaves it empty — watched naming the offending row. Checked by shape rather than by wording, because a gate that demands a phrase gets the phrase and not the thinking. Writing the exclusion up front is the cheapest way to find out whether a counter measures a mechanism or a symptom, and all eight rows could state one, which is the first evidence that the counters are now scoped right. Queue entry G6. |
||
|
|
8a4718d775
|
state invariant 7 as a rule the gate can hold
"Every feature is a leaf" was enforced by listing the pairs that happen to exist today: content, render and web may not import ext, plus a separate check for siblings. A list only forbids what is already there — a core package added next month could import a feature and pass, which is how an invariant rots while staying technically true. One positive rule now: only cmd/ may import internal/ext/…. It covers packages that do not exist yet and catches a sibling import in the same breath, since a feature importing another feature is the other way one stops being deletable. Four rules become one. Watched rejecting both kinds before keeping it — a core package importing a feature, and a feature importing its sibling — and watched passing the tree as it stands. Test files are excluded, which is right: the demo's own test wires features on purpose. Queue entry G5. It was the last architecture invariant held only by hand. |
||
|
|
ccce537ae4
|
tell the browser how wide a picture will be
srcset without sizes means a browser assumes 100vw, so a gallery thumbnail in a 16rem column was fetching the 1600px original — the derivative pass was costing bandwidth on the page it exists to save it on. It now picks the 480w variant. sizes is the one part of responsive images the engine cannot supply: it states how wide the picture will *be*, which is a fact about the layout and therefore the theme's. The reference theme states its own measure and nothing more. Lazy loading goes on gallery entries and not on a figure, because a figure is often the first thing on the page and deferring it delays what the reader came for. Both have demo cases, including that the figure is *not* deferred. Queue entry G2, which had been sitting unfixed through the whole Markdown arc. |
||
|
|
7136f6e2d9
|
let a shortcode fragment speak the reader's language
Three fragments added this session needed a word the author did not write: an untitled :::warn told the reader nothing about being a warning, an untitled panel fell back to whatever the browser calls <details>, and the contents list had no label at all — an accessibility gap as much as an untranslated one. None of them could be fixed, because `t` needs a language and Fragment had none, so those words could only ever have been English on a site that serves Bengali. Fragment gains Lang, captured on each call at parse time — a node renderer never receives the parse context, the same constraint that put pictures and headings on the node. Five phrase keys follow, and a Bengali page now reads সূচিপত্র, সতর্কতা and বিস্তারিত where an English one reads Contents, Warning and Details. The demo's own list.html was the better example of the problem and now shows the answer: a site's own sentences are not in the engine's phrase table, so a template needing its own words branches on the language it was given. That is what theme-contract.md has always told a theme to do, demonstrated rather than asserted, and a case proves the Bengali listing carries no English. Two files crossed the size advisory on the way. render.go shed the contract types to view.go, where state.md already claimed they lived and where the file's own header said they belonged; shortcodes_test.go split to mirror its sources, which the one-file-per-source convention already asked for. Both are pure moves. |
||
|
|
b5be77498e
|
give the author three controls the engine was deciding alone
`include: merge` in frontmatter splices a bundle's includes before the parse, so
a page assembled from several files is one document: one footnote list at its
end, numbered straight through, and an abbreviation defined anywhere reaching
every part. Moving the rendered block afterwards would have meant editing
goldmark's own markup; handing the parser one source gets the right answer from
it instead. Without the flag nothing changes — each fragment stays its own
document with namespaced ids, so no existing content re-renders.
Heading ids are unique under either model. Merging gets that free, because one
parse means one id set, but embedding did not: three `## Description`s across a
page and its fragments produced three identical anchors, and every link to them
landed on the first. A nested parse now shares the parent's id set, so the
second becomes #description-1 — goldmark's own suffixing, reaching across files
because they finally share the set it counts in.
Auditing for other policies the author could not reach found two more.
A heading may declare its anchor: `## Title {#stable-anchor}`. This is the one
that mattered most and nobody had asked for it — a derived id changes when the
text does, so rewording a heading silently broke every link to that anchor,
which is indefensible in an engine whose first value is that published addresses
are permanent.
`::toc{depth=2}` shortens a contents list, because a theme cannot know per page
how deep is useful and the author can.
Deliberately not added: a typographer toggle, a per-picture "do not resample",
icon overrides. No second user for any of them.
The hand-copied wiring in example_test.go drifted for the third time this
session — Compose this time, after the dialect and notation — each caught by a
demo case rather than by the copy. The latent row is now marked due, with what
moving the list would require.
|
||
|
|
d1c9179d6c
|
frame the contents list, and give the highlight its own colours
Two defects only a screenshot could find, both mine. The table of contents did not look like one: list-style:none stripped the markers and nothing replaced them, so the entries read as two loose links in the prose. Hairlines above and below make it a block again. <mark> was using the browser default, black on yellow, which fights a dark page. It has explicit colours now, and a muted pair in dark mode. Also records a third, which is not a defect but reads like one: an included file's footnotes render where the include sits, so the page appears to end and restart halfway down. That is ADR-0038 working as documented — a fragment is converted on its own bytes — and the ids are correctly namespaced. Only the placement is unfortunate, so it goes on the latent list with the trigger that would justify changing it. Verified in the browser this time rather than by curl: the aside floats at 1280px and flows inline at 375px, grouped panels close each other with no script, and dark mode is readable throughout. |
||
|
|
91c733a0e0
|
spread the demo's features across the pages that would carry them
notes-on-water had become a kitchen sink: every feature from the last eight loops on one page, which demonstrates the engine and misrepresents the site. Now each lands where a real site would put it. The text marks, the abbreviation, authored <kbd> and the margin note go to writing/typography, which is already the page about what the engine does to words. Icons and the grouped panels go to pages/colophon, which is the page about the theme — install instructions are what tabs are actually for. about gets a lone expando, the-flood gets a chapter note, and notes-on-water keeps what it is really for: the include and its namespaced footnotes, the gauge table, the glossary and a contents list over its own headings. Seven bundles carry features now instead of two, and the demo cases moved with them, so each still names the page it proves. |
||
|
|
fdc76ba1b9
|
add panels and margin notes, with no engine code at all
Expandable sections, tabs and asides — three of the things parked earlier — turn out to be two theme fragments and nine lines of CSS. That is what the container mechanism bought: they needed no Go. Tabs without script were the only real problem, and containers do not nest, so :::tabs wrapping :::tab was never available. Sibling <details> elements sharing a name attribute are natively mutually exclusive, which is what tabs are, so grouping is one argument on the same fragment an expando already uses. A browser too old for grouping opens them independently — the content is never hidden, which is the failure mode worth caring about. The aside is beside the text where the viewport has room and in the flow where it does not, in one media query. No JS anywhere, and the demo case asserts the page contains no <script> at all. No ADR: nothing here is expensive to reverse, and the contract grew additively as its stability rule allows. |
||
|
|
8bfacc7e98
|
build a table of contents from the document's headings
`::toc` renders through a `toc` fragment receiving level, text and the id goldmark already assigns. The engine collects, the theme decides whether that is a list, a sidebar or nothing — the reference theme emits a flat <ol> with a level class per entry, so indentation is CSS rather than markup. A theme cannot enumerate headings, because templates cannot parse HTML, so the engine is the only thing that can supply them. Collection lives in ext; only the Heading type and one Fragment field are core. That is what needed the ceiling: CORE_LOC_MAX 2800 -> 2850, the second raise. ADR-0041 said to read a second raise as evidence something belongs in ext, and the test was applied rather than waived. feed.go and discover.go are the features that should leave core, and they cannot, because an ext feature cannot own a route until the extension registry exists — which the counter says buys nothing yet. The thing that should move is blocked on a different decision, so the honest fix was the ceiling and an ADR saying exactly that. When routes become ownable, they leave and this comes back down. Entry text is the heading's words with markup stripped: a link inside a link is not markup a browser accepts. An entry whose heading has no id is skipped rather than linked nowhere, and a page with no headings renders no nav at all. core 2804/2850, ext 1825/2000, 34 gates green, 0 warnings. |
||
|
|
78b8c51dff
|
add container directives, and admonitions as their first user
`:::name{…}`, a body of Markdown, then `:::`. This spends the form reserved by
ADR-0059 rather than leaving it a promise — and building it back then would have
been a mechanism with no user, which is what the reserve was avoiding.
The body renders first and reaches the theme fragment as .Body, already HTML, so
emphasis, links, subscripts and icons all work inside an admonition. That is one
addition to the theme contract, additive as the stability rule requires, and two
lines of core — which is what the remaining budget allowed.
Rendered by a transformer rather than the node renderer, for the same reason an
include is: rendering a subtree needs the document, and a node renderer never
gets one.
When the theme has no template for a kind, the engine writes the body out
unwrapped. Same principle as an unknown icon keeping its text, and it matters
more here: a theme not knowing one name must never cost an author paragraphs,
and an unstyled aside is a far smaller failure than a missing one.
The leaf parser was parameterised by prefix rather than copied — a second copy of
parsing logic is a stop condition, and the two forms differ by one colon.
Containers do not nest: a `:::` inside closes the one it is in, the same limit an
include carries. Stated in the ADR and the contract rather than left to be found.
core 2796/2800, ext 1743/2000, 34 gates green, 0 warnings.
|
||
|
|
df9335df33
|
parse :name: as an icon, and let the theme decide what one is
The engine's half is one call: parse the name, hand it to a single `icon` fragment, decide nothing else. No icon table in Go, ever. The surveyed engines split three ways — Unicode (Hugo, Pandoc), a remote image per icon (Jekyll's jemoji, which fails the sovereignty test outright), and inlined SVG from a bundled set (MkDocs Material) — and all three are decisions about markup, which ADR-0036 puts in the theme. A theme wanting Font Awesome ships a sprite in its own base.html and redefines one fragment: no webfont, no request, no script. The boundary rules are the whole difficulty, because the colon is the commonest punctuation in technical prose. A name must start with a letter, hold only letters, digits, hyphens and underscores, and neither colon may touch an alphanumeric. Verified on the real binary that 10:30:15, key:value:pair, "Note: this", a URL and a code span all come through untouched — each of them would otherwise be a silent edit to someone's sentence. The literal fallback closes the same hole from the other side: when the theme renders nothing, the engine writes the author's `:name:` back, so an unrecognised icon is never deleted from the middle of a paragraph. An icon needed its own inline node rather than the block one — goldmark distinguishes the two by type — which also avoids the type switch CLAUDE.md §6 forbids: two kinds, two renderer functions. The reference theme maps six names to Unicode and ships no sprite, font or asset. core 2794/2800, ext 1615/2000, 34 gates green. |
||
|
|
661fb469e8
|
expand abbreviations from a definition line
`*[TERM]: expansion` on its own line, PHP Markdown Extra's form, and every whole-word use in the document becomes <abbr title="…">. A transformer rather than an inline parser, because a definition may appear after the use it explains and a parser only ever sees what it has already read. A block parser rather than a pattern found later, because the line has to stop being content — an author would notice that going wrong before anything else. Whole-word matching is the part that would have bitten: without it a definition of HTML quietly rewrites HTMLish and xHTML too, so both have cases. Code spans, autolinks, raw HTML and an already-expanded term are skipped, the longest definition wins where two could match, and the expansion is escaped into the attribute so a quoted phrase cannot end it. Definitions are document-scoped. A term defined in a page does not reach an included fragment, which is parsed on its own bytes exactly as footnotes are — stated in content-model.md rather than left to be discovered. The nesting gate caught firstMatch four levels deep; the inner search is its own function now, which reads better than it did before the warning. core 2794/2800, ext 1483/2000, 34 gates green, 0 warnings. |
||
|
|
53e9ef473a
|
add inline notation, and take the tilde back from strikethrough
~sub~, ^sup^, ==mark==, and ~~strike~~ moved in from goldmark. Not a preference: goldmark's strikethrough claims a single tilde as well as a double, so with it enabled H~2~O rendered as H<del>2</del>O — measured before the change. Two features cannot share a byte and both be correct, so notation owns it and the authored syntax stays exactly as ADR-0058 documented. The second failure was worse and only showed up under test. Under delimiter rules `x^2 + y^2 = z^2` pairs its carets across the whole expression and renders x<sup>2 + y</sup>2 — prose silently becoming markup, in exactly the content this engine is for. So a single run is scanned rather than paired, and may not cross whitespace: a subscript holds a formula, never a phrase. Pandoc draws the same line. The cost is that a single run takes its content literally, so there is no emphasis inside a subscript, which the ADR states rather than leaving to be discovered. New package under internal/ext, which is a stop condition and was asked. It takes the extensions counter to 4, past its threshold, and the answer is still no: four features attach in three unrelated ways, and two goldmark extenders compose in goldmark's own extender list, which is already the registry for that shape. The example site's hand-copied extender list drifted, exactly as the latent row added last loop predicted — the demo case failed and named it. Both are now in step again. core 2794/2800, ext 1236/2000, 34 gates green, 0 warnings. |
||
|
|
1f168d973b
|
render the HTML an author writes, and narrow the gate to one call site
Dropping raw HTML was silently destructive. H<sub>2</sub>O rendered as "H2O",
10<sup>6</sup> as "106", <kbd>Ctrl</kbd> as "Ctrl", and khosra check reported
nothing — an author lost meaning with no signal anywhere. Measured on the real
binary before and after.
Invariant 2 already says content from the site root is trusted, so the old gate
was defending the half of the boundary that was never in question while the
untrusted half has no code to defend yet. Chemistry, units, exponents and
keystrokes are what a hard-science site needs and what no Markdown dialect
expresses, so html.WithUnsafe() goes on in internal/render/render.go.
The gate does not disappear; it narrows. verify.sh used to fail on WithUnsafe
appearing anywhere and now fails unless it appears in exactly that one file —
watched doing both, accepting one call site and naming both files when a second
appears. A second pipeline trusting its input is the failure ADR-0003 exists to
prevent, and when comments arrive they get their own goldmark without it. The
gate is the reminder that the split has to be built rather than assumed.
The security test that asserted "raw HTML must still be dropped" now asserts the
property that actually holds and matters more: a shortcode argument stays data
whatever the page around it is allowed to do. ::figure{alt=<b>bold</b>} still
arrives as <b> while the <span> beside it renders.
core 2793/2800, ext 1077/2000, 34 gates green, 0 warnings.
|
||
|
|
a893ab1821
|
replace the shortcode syntax with generic directives
`::name{key=value}` alone on a line, quotes only where a value has spaces,
braces omitted when there are none. The old form cost eleven characters of
punctuation per call and could not carry a body, which admonitions will need.
Generic directives are an existing convention — remark-directive, MyST,
Docusaurus — so this is a syntax authors and tools already know rather than one
more invention, and it reserves `:::name` for containers and `:name[…]` for the
inline dynamic calls that come later.
Retired outright rather than aliased: two syntaxes is two parsers and two test
sets forever. `khosra check` reports every leftover call as fatal and names the
replacement, so migrating a site root is running it until it exits zero — proven
on an unmigrated root, which exits 1 with the file and the fix.
The trigger byte moves from `{` to `:`, which prose uses constantly, so the
parser refuses `3::4`, `: a definition` and `:::note`, each with a case. The
definition-list parser sits at priority 101 and this one at 100, so it gets
first refusal and everything it rejects falls through.
Two things the syntax change would have broken silently. check's alt-text regex
still matched the old form, so the one accessibility check the engine has would
have stopped finding anything — it moves with the syntax and keeps its case. And
a test asserted that hostile arguments fail at the syntax because quotes cannot
be expressed; unquoted values are legal now, so it asserts the property that
actually holds: the fragment escapes them.
Demo migrated. core 2790/2800, ext 1077/2000, 34 gates green, 0 warnings.
|
||
|
|
0465785e81
|
settle the Markdown dialect, and namespace an include's footnotes
Tables, footnotes, definition lists, strikethrough and automatic heading ids. Which dialect a site is written against is permanent, so ADR-0058 names the whole set at once — including the four refused, each for a reason rather than a taste: task lists publish nothing, linkify rewrites plain text into markup that ADR-0034 forbids the engine to touch, CJK is the wrong script family for a Bengali site, and the GFM bundle is a package deal for the first two. Footnotes collided with includes, as the queue predicted but worse. An include converts its file on its own bytes (ADR-0038), so goldmark numbered its notes from one again and the page carried two id="fn:1"s — the parent's reference jumped to the fragment's note. shortcodes.FootnotePrefix stamps the file name on the nested document and hands it to goldmark's id-prefix function, so the fragment gets _method-fn:1 and the page keeps fn:1. Two things nothing tested before. The extender list ships from cmd/khosra, which no package can import, so the dialect had never been rendered through the list the binary actually uses — cmd/khosra/wire_test.go now does exactly that, including that the typographer no longer eats a table's delimiter row. And the demo carries the dialect and the footnote namespacing as cases, which caught auto heading ids changing markup in three existing assertions. The reference theme gains five lines: a rule under each table row, an indent for definitions, smaller footnotes. core 2790/2800, ext 1058/2000, 34 gates green. |
||
|
|
7f9ac3c412
|
compare state.md's currency instead of declaring it
The verified-against sha could only ever be wrong. A commit cannot name itself,
so the line had to be written after the commit it described, which forced a
trailing `state:` commit every time — against conventions.md, which has always
said code, test, state.md row and ADR belong in one commit. Folding those
trailing commits away then left the sha naming a commit that no longer existed.
backup/pre-fold shows the pattern, and
|
||
|
|
b5ec3bfc9d
|
serve one theme snapshot to the whole site, and honour -poll
Per-render reparsing could not keep the site coherent, and did not. Only two of the four render methods called fresh() — Bundle and Tag never did — so under -dev on a listing served an edited template while a bundle served the old one. Verified on the pre-G1 binary: /posts/ answered V2 while /posts/hello/ answered V1, permanently, not for a window. Adding the two missing calls would have left four places that must each remember, and Partial runs during a page's Markdown conversion, so one page could still mix two themes. So the per-render path is deleted instead: Renderer.reload, Reload() and fresh() are gone, Refresh is the only thing that replaces a theme, and -dev on gets its promptness from polling every 250ms. Coherence is now structural rather than a discipline four methods share. -poll arrives as ADR-0022 specified it and never delivered: it sets the interval, and 0 stops watching for an immutable deployment. Watch takes the interval and settle window as arguments, so the Interval and Settle package variables are gone and no test mutates package state to control timing. The theme is reparsed in the watcher's callback rather than inside rebuilder, so startup parses it exactly once, in New — there is one call site and it is not on the startup path. The two swaps it leaves are not one transaction; state.md's latent list carries that gap and its trigger. Measured on the real binary: bundle, section listing and tag listing all moved V1 -> V9 together within 1s of editing two templates; -poll 0 served and then ignored an edit; -dev on -poll 3s kept 3s. core 2780/2800, ext 1027/2000. |
||
|
|
633debf743
|
apply a template edit without a restart
The watcher fingerprinted templates/ but a rebuild only re-scanned content, so editing a template fired a rebuild that changed nothing. ADR-0022 already promised the opposite — "a template edit in the site root invalidates through the same path as content" — which makes this a defect against a recorded decision rather than a missing feature. ADR-0055 records the fix and supersedes ADR-0048's narrower clause. The parsed sets and the stylesheet become one parsedTheme behind an atomic.Pointer, swapped by Refresh once per rebuild instead of per request. A parse failure keeps the theme that was working, so a typo cannot take the site down. The swap also retires the in-place field mutation -dev was doing, which was a data race with every in-flight render. site.yaml goes the other way and leaves the fingerprint: the settings are copied by value into the renderer, the handler, the feeds and the sitemap, so applying a change to some of them is worse than applying it to none. It is restart-only. Corrects the Effects counter row while proving it did not move: it still said startup was the only change signal "until queue 21", but queue 21 shipped as ADR-0048 and put the derivative pass inside rebuilder, so that has been wrong since. The row now also answers the question ADR-0055 invites — an in-memory swap is not an Effect, because it writes no artifact and calls nothing outbound. Measured on the real binary: a template edit went live in ~2s; a typo logged "keeping the previous theme" and kept answering 200 with the last good markup; a site.yaml edit now fires no rebuild at all. core 2766/2800, ext 1030/2000, 34 gates green, 0 warnings. |
||
|
|
36194a16d8
|
point the editor preview at the demo, and shorten the skill name
Four small things from one sitting, none of which would be reverted without the others: - .claude/launch.json describes the only dev server this repo has: make demo on localhost:8080. Deliberately not autoPort — examples/demo-site declares base: http://localhost:8080, so canonical, hreflang, OpenGraph and sitemap URLs are built from that port, and a reassigned one would make the absolute URLs on the page wrong while still rendering fine. - The skill is .claude/skills/feature-loop/, without the khosra- prefix. ADR-0054 records it, because ADR-0030 had named the prefixed form while settling the project name and decisions.md is append-only. - Makefile .PHONY was missing quiet and surface, added two commits ago. A file of either name in the repo root would have silently shadowed the target. - A latent row: the root listing's title reads "A Khosra Demo · A Khosra Demo", because base.html joins page title and site title unconditionally and at the root they are the same string. Found by looking at the served page — no test asserts a title. Theme layer, one if, and it waits for Phase G4. |
||
|
|
aad7d64270
|
record who authored a commit, and who merely asked for it
Authorship named the human on commits the agent wrote start to finish, with a Co-Authored-By trailer as the only trace of who did the work. That is backwards: directing a change is not writing it, and a log that cannot tell the two apart cannot answer "how much of this did the agent write" — a question worth being able to ask honestly about a repository built this way. Three cases, no fourth: agent alone is agent-authored with no trailer, since the author field already says it; both is the human's with the agent as co-author; the human alone names only the human. The committer and the GPG signature stay the human's throughout — the key attests to taking responsibility for a commit, not to having typed it. Applied to the whole history in the same sitting: 62 of 63 commits are now agent-authored, init is the human's with a co-author trailer, and every tree, message, parent and author date is byte-identical to before. Committer dates were restored from backup/pre-fold, since the earlier fold had reset all 62 of them to the moment of the replay. The state.md sha rides along rather than taking its own commit: rewriting the history is what invalidated it, and the verify.sh advisory that caught it is the same one that caught the previous rewrite. |