Commit Graph
45 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi 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.
2026-08-01 23:50:53 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 23:47:55 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 23:39:12 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 23:25:22 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 23:21:46 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 23:18:59 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 23:09:03 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 23:08:49 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 22:09:14 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 22:00:33 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 21:54:28 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 21:25:44 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 21:21:04 +06:00
Claude Opus 5andbdeshi 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 &lt;b&gt; while the <span> beside it renders.

core 2793/2800, ext 1077/2000, 34 gates green, 0 warnings.
2026-08-01 21:08:06 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 20:52:17 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 20:38:59 +06:00
Claude Opus 5andbdeshi 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 8905686 is the commit that had to name
the survivor afterwards.

Git already knows when each file last changed. The gate now compares the last
commit touching docs/state.md against the last touching a .go file, and the
line is gone. Same intent, nothing to maintain, and no rewrite can invalidate it.

Also records two commit rules the human stated this session: state.md never gets
a commit of its own, and a minor change asked for just after a commit is amended
into it while it is unpushed rather than accumulating as noise.
2026-08-01 10:54:30 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 10:54:30 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 10:54:30 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 02:46:30 +06:00
Claude Opus 5andbdeshi 7796b0d919 make context a budget the harness enforces
Adopts ideas/token-conservation.md, parked 2026-07-28, plus the disciplines
the human added: read the compressed form first, discover by mechanism,
shrink output at the source, never pay twice for the same bytes.

docs/context-economy.md owns all of it and leads with a floor, because
every cheap failure mode is also a token saving — skipping the owning doc,
guessing a signature, reporting from a diff, thinning a test — and each has
already cost this repo a defect. Frugality is for presentation and
discovery, never for the artifact or the evidence.

Mechanical, not remembered:

- scripts/surface.sh generates docs/surface.md — every top-level
  declaration with its line, 261 lines standing for 3757 of source. The
  pre-commit hook regenerates and stages it, so it cannot be stale, and
  verify.sh compares independently for a clone that never set
  core.hooksPath. The hook refuses a commit with unstaged .go changes,
  since what it generated describes the working tree, not the commit.
- verify.sh --quiet: 48 lines of gate output become 1. The hook uses it.
- CLAUDE_LOC_MAX=150, the only budget billed per turn rather than per read.

Both new gates were watched failing before being kept: a doctored
surface.md, and CLAUDE_LOC_MAX temporarily set to 5.

state.md's inventory loses its LOC column. It had already drifted on six
files (content.go 381→450, render.go 447→454, web.go 206→217, check
216→223, watch 129→137, chrome 105→110) which is what a number written in
two places does; the generated file owns sizes now, the table owns purpose.
The subagent question is recorded there as the one open decision, with the
case for and against written out in the idea file.
2026-08-01 02:16:11 +06:00
Claude Opus 5andbdeshi 8256c72180 commit as part of the loop, and announce it when unasked
The loop gains a seventh step. Work was being committed all session on a
verbal instruction, which means it was a property of one conversation
rather than of the harness: a fresh session would have left everything in
the working tree and reported success.

Why automatic: a commit is one `git revert` from undone, and work that
only ever existed in the working tree is not recoverable — this session
already lost uncommitted work to a `git checkout` while signing was
broken. Why announced: the safety argument covers the loss, not the
surprise, so a session that did not ask for this gets one prominent line
before the first commit and the off switch, once.

Ownership split so nothing is stated twice: conventions.md "Git" owns
what one commit contains — now one *revertible* unit rather than one
feature, which is the human's correction to the "never bundle two passes"
reading — and CLAUDE.md §4 owns when a commit happens. ADR-0052 records
both, including that no gate can check this: no script can see whether a
sentence was said.

Docs 6 files, +72/-6 lines. No code, no counters moved.
2026-08-01 01:41:11 +06:00
Claude Opus 5andbdeshi de1ce73430 track the demo as a real site in examples/, gated like the docs
The human asked for a demo extensive enough to review by hand, tracked as files,
and kept current the way docs are. Generated filler cannot be reviewed — you cannot
read a Go function and see what a reader sees — so the generator is deleted and
`examples/demo-site/` is an ordinary site: 33 Markdown files, six pictures,
site.yaml, a template override and static files.

My reason for generating it was ADR-0011, and I had that rule wrong: it puts the
*author's site root* outside this repository, not fixtures. conventions.md already
keeps golden files in testdata/, and this is the same category one size up. Keeping
both a generator and files would have been two sources of truth, so one had to go.

Kept true by two gates rather than by good intentions. A table-driven test in
internal/web serves the directory through the real handler with one case per
feature — thirty-five of them, each naming what it proves — and verify.sh runs
`khosra check` over it and fails on anything fatal. Adding a feature now means
adding it to the demo and adding its case, and the build says so if you don't.

Extensive on purpose: eleven dated posts so a section actually paginates, a
four-chapter series so first/last are not the neighbours, a gallery with three
JPEGs and an SVG so both the resampled and the untouched paths show, a Bengali-only
bundle, a titleless status, a draft, a future date, an include, a nested extras
tree, and a typography page that demonstrates what the engine will not do.

Two expectations of mine were wrong and the demo corrected them: the site's own
list template overrides *tag* listings too, so there are no group headings there —
which turns out to be the better demonstration of ADR-0046, since the theme picking
the flat shape is exactly the point. And template literal text is not escaped, so
an apostrophe stays an apostrophe.
2026-07-31 20:08:28 +06:00
Claude Opus 5andbdeshi 4396303771 add khosra demo, and give the site a front page
The demo writes a whole site root that exercises every feature: two languages with
a fallback, a series with ordered chapters, a gallery, a figure, an include,
extras, tags across sections, a slug with an alias, an undated page, a draft, a
template override, static files and site.yaml. It generates its filler rather than
copying stored files, because nothing in this repository is content (ADR-0011) — and
that makes it a test of the engine rather than a fixture: anything khosra can do
that the demo cannot express is a gap.

Two things found by generating and then serving it, which is the whole point:

`khosra check` reported the demo's own series as mixing ordered and unordered
members. It was right — the chapter bodies *described* `order: 10` while the
frontmatter never carried it. The checker caught its own author.

And `/` was a **404**. ADR-0008 leaves the root engine-owned, which is right, but
"engine-owned" was never given an answer, so a visitor to the site's own address got
nothing. The root now lists every bundle, newest first, paginated like any other
listing, and 404s only when nothing is published. A hand-written home page stays a
separate decision, recorded as such.

Verified end to end: 23 files written, 12 bundles, 12 derivatives, `check` clean,
and every URL the demo promises answers — including the alias redirecting, the draft
hidden, and the front page rendering through the site's *own* template override.
2026-07-31 19:54:23 +06:00
Claude Opus 5andbdeshi 96313e4eda finish the reference theme, and the contract holes it found
The theme was supposed to need no engine work. It needed four things, which is
exactly what the audit said would happen if the contract had gaps rather than the
theme (ADR-0046) — a reader could not reach, from any page: another section, the
tags on the page they were reading, the extras beside it, or the same page in
another language.

So the contract grew three fields, all additive: `.Sections` for navigation,
`.Tags` with each term's listing URL, and `.ExtrasURL`, empty when a bundle has
none so a theme never links a 404. Sections arrive through `Renderer.Navigation`, a
callback, because sections change when content does and a copy would go stale — the
nav updates on a rebuild along with everything else.

One flaw only visible by looking at a rendered page: the language switcher pointed
at the canonical host, because `.Alternates` went absolute for hreflang. Those are
two needs, so an Alternate now carries `.URL` (absolute, for machines) and `.Path`
(relative, for a link a person clicks).

The theme now demonstrates every field it is given, including `.First`/`.Last`,
which existed and were never rendered. Still no JavaScript, still one stylesheet.

A test that asserted a page had no sequence nav was matching the inlined
stylesheet rather than the markup, and now matches the element. That is the third
time a loose assertion has passed for the wrong reason.
2026-07-31 19:48:32 +06:00
Claude Opus 5andbdeshi 9100ce4876 notice content changes and rebuild without a restart
Polling lives in internal/ext/watch, per the human's call to keep core under its
ceiling rather than raise it a second time — which is what ADR-0041 said a second
raise would mean. It is a poller, deletable without trace, and core stayed at
2671/2800.

A settled change calls the same `rebuilder` that startup calls, because a reload
path that differs from the startup path is a reload path that drifts. The index is
an atomic.Pointer swapped whole, so a request reads the site that was current when
it arrived instead of one being rebuilt underneath it — the alternative, mutating in
place, is a data race with every in-flight request.

Names, sizes and modification times, not contents: reading every file to detect a
change costs more than the rebuild it triggers. Editor droppings are excluded,
because saving in vim writes a swap file, a backup and the number 4913, and each
would otherwise look like a change. A change must hold still for a moment first,
since one save is often several operations.

Verified against the running binary: a page 404s, the file appears, and five seconds
later it serves — one "site root changed" in the log. Then three droppings written
at once produced no rebuild at all.

Two warnings fired and were fixed rather than silenced: `runServe` gave up the
rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to
`record`, where three exclusions read as a list instead of as nesting.

The Dockerfile ships the binary alone. The site root arrives as a volume and is
never copied in — it is somebody's content repository with its own history
(ADR-0011), so the image is the same for every site.
2026-07-31 14:00:53 +06:00
Claude Opus 5andbdeshi 669a94a26a publish a bundle's extras as a browsable tree
Re-adopts the parked extras entry as ADR-0047: `extras/` inside a bundle is skipped
by the scanner entirely, so a `.md` in there is an asset with no identity and no URL
of its own. The engine enumerates the tree, sorts it by path, classifies by
extension, renders markdown and text, and offers everything else as bytes. One route
with two behaviours — `…/extras/{path}` selects an entry, `?raw` returns the file.

Almost everything it needed already existed, which is the sign the model was right:
the scanner had a directory exclusion, `Assets()` knew which bundles own a
directory, and `Lookup` already decided visibility — so a draft hides its extras
with no new check. A test proves that, including `?raw`.

Two deviations from the parked shape, both because the shape was written before the
code. The directory name is fixed rather than a cascade key, since nothing reads a
section-level setting yet. And an entry is resolved against the *enumeration* rather
than the filesystem: not being in the listing is a stronger answer than os.Root
refusing a path, and cheaper.

Selecting is a link and a full page. No JavaScript is involved, and a
sidebar-and-pane layout is the theme's business — which is the layer rule applied
before writing the feature rather than after.

Three size warnings fired as a result and were fixed by splitting at seams, not by
sharding: render.go gave up its type declarations to view.go, which is the theme
contract in Go and nothing else; serve() split into a dispatcher and serveBundle;
resolve() gave up its language-prefix step to cutLang.
2026-07-31 13:18:34 +06:00
Claude Opus 5andbdeshi bc8c26d8f4 audit every feature against the layer test
Deleting the widows feature answered one question; the human asked the general one.
So every feature built so far is now audited, with the verdicts as a table in
ADR-0046, and each remaining queue entry carries a layer note before anyone writes
code for it.

The codebase turned out to be otherwise clean, and I checked rather than remembered:
no Go file writes a tag, a class or a style. ADR-0036's rule that all markup comes
from a fragment had already forced that.

One real finding. A tag listing received only `.Groups`, so the engine had decided
that a tag listing *looks* grouped — ADR-0032's own reasoning was "so a busy term
stays readable", which is a readability judgement. It now receives both shapes: the
partition, because a template cannot group for itself, and the flat list, because
choosing between them is markup. `Item` gained `.Section` so a flat listing can still
say where an entry came from.

Two judgement calls recorded rather than left implicit. The typographer stays: turning
`--` into an en dash is a character transformation no stylesheet can express. Chrome
strings stay: translations are data, and the alternative is every theme hardcoding
Bengali month names. The inlined stylesheet is accepted with its cost written down —
bytes per page, no caching — and a trigger for revisiting it.

Two patterns worth reusing came out of this: offer the shape rather than choosing it,
and a split feature is normal — search will be an engine-built index queried by the
browser, not one or the other.
2026-07-31 12:51:31 +06:00
Claude Opus 5andbdeshi b574800adb delete the widows feature; line breaking is CSS
The human asked whether widow prevention belonged in the backend at all. It did
not, and it broke two rules already written down: the theme contract says the
engine decides nothing about how something looks, and ADR-0034 says authored body
text is the author's — while this inserted U+00A0 into that text.

The practical harm follows from the layer error rather than from a coding mistake.
The engine cannot see the line box, so joining the last two words is a guess that
can overflow a narrow viewport, and a reader copying the paragraph gets a
non-breaking space in their clipboard. `text-wrap: pretty` and `text-wrap: balance`
in the reference stylesheet know the line box and need no bytes in the content.

108 lines of engine deleted for one CSS declaration. The typographer stays: turning
`--` into an en dash is a text transformation no stylesheet can express, which is
exactly the distinction the new layer test draws.

Also worth recording: this took the Extensions counter from 3 back to 2. A threshold
reached by a feature that should not have existed was never a threshold.
2026-07-31 12:47:03 +06:00
Claude Opus 5andbdeshi b061f4590f measure the render path, then remember pictures instead of caching pages
The entry said to measure first and put the number in the commit, so: a plain page
renders in 14µs, a twelve-picture gallery in 1.23ms. Of that, ~102µs per picture
was reading, hashing and decoding bytes the previous request had already read.

Remembering that one fact — keyed by path, size and modification time — brings the
same gallery to 63µs. 19.5× faster, 21× fewer bytes allocated, twenty-odd lines.
After which nothing is slow enough to justify caching whole pages, so ADR-0044
declines the page cache and leaves the parked validity model parked, now with a
measurement rather than an intuition behind its trigger.

That parked model has five axes and was written before any code existed. The
problem it would have been built for turned out to be one repeated file read.

Benchmarks live in internal/web so they measure through the real handler, which is
also what conventions.md wants before any cache goes in the render path. The
invalidation risk has its own test: an edited picture is a different key, so the
memo cannot serve yesterday's dimensions. Everything runs clean under -race, since
the map is read by concurrent requests.
2026-07-31 11:00:37 +06:00
Claude Opus 5andbdeshi 57f5520de8 serve Atom feeds for the site, a section and a tag
Membership is a publication date, not a type declaration (ADR-0043). The parked
feed shape said "every type declared primary", which would have made feeds wait on
declared types a third time — but the thing that distinguishes a feed item is
already on disk. Pages and section landings drop out because they have no date,
which is the right reason. The parked idea stays parked with a sharper trigger:
someone wanting a *dated* bundle kept out.

Built with encoding/xml from typed structs, never a template: XML in html/template
is escaping for the wrong grammar, and that is a correctness trap rather than a
matter of taste.

A bug the evidence found, older than feeds: content.URL("", lang) built "//", so a
whole-site feed's id and alternate link were https://khosra.example// — every
entry identity wrong in every reader. The root is "/" now, with a test, and the
hand-built "/" the resolver carried for the same reason can follow later.

Two counters re-scoped rather than incremented, the same way transforms was:

Views now counts *per-bundle selection* — the thing architecture.md means by the
View layer, still at zero consumers. Output formats are not it: HTML, sitemap XML
and Atom are three functions with nothing to share, so an interface over them
would have one member and no leverage.

Effects stays at 1. A feed is generated per request like the sitemap, so it is not
a second Effect and the runner is not yet due — the next thing that writes files
off the request path is.
2026-07-31 10:54:31 +06:00
Claude Opus 5andbdeshi 282093fb55 generate sized derivatives ahead of the request
A pass over the content at startup writes three widths per picture into a cache
outside the site root, named by the source's content hash and the width (ADR-0042).
Idempotent by construction: a rerun stats and skips, an edited picture takes a new
name, and nothing stale can be served under an old one. Restarting the evidence
site made 0 derivatives the second time, as it should.

Ahead of the request rather than during it, because resampling is felt and there is
no page cache yet to hide it. Outside the site root, because the engine reads that
directory and must not leave generated files in somebody's content git — a lost
cache costs one startup pass and no correctness.

Markup now carries the original as src, the derivatives as srcset closed by the
original at its own width, and width/height from the original — which retires most
of the latent row about the output floor; only a gallery's alt is still empty, and
a filename cannot supply that.

Two things the work itself decided:

`Fragment.Items` became `Fragment.Pictures`, ADR-0037's own revisit trigger. Items
had one consumer, so widening it beat adding a second list beside it.

"A browser can show it" and "we can resample it" are different questions, and
conflating them nearly deleted content: an SVG has no decoder here, so a single
predicate would have dropped SVGs from galleries silently. Undecodable and
unsupported pictures are now rendered as they are, without a size or a srcset.
2026-07-31 03:57:52 +06:00
Claude Opus 5andbdeshi 98d0e52936 approve x/image and re-cost the core ceiling
ADR-0040 puts the dependency approval on the record, as hard rule 2 requires: the
standard library decodes JPEG, PNG and GIF but cannot resize, and image/draw
scales only by nearest neighbour, which is visibly wrong on the photographic
downscales this site is made of. Four modules against a cap of six, no transitive
dependencies, and WebP decoding comes along. AVIF still has no decoder anywhere,
so it will pass through untouched.

ADR-0041 raises CORE_LOC_MAX 2000 → 2800. The old figure was costed before any
code existed, for the spine and the render path, and never budgeted what cmd/ will
hold: `check` ~250, `new` ~100, `-dev` ~100, change detection ~100, page cache
~200. On a core already at 1870 that is ~2620 before anything optional, so the gate
was going to fail work nobody would call excess. A costing error corrected, not
discipline loosened.

The ADR and HARNESS.md both say what a *second* raise would mean: that something
belongs in internal/ext/. Two ceilings exist so "core stops growing, ext rises" is
observable, and that stops being true the moment leaves are let into core.
2026-07-31 02:48:52 +06:00
Claude Opus 5andbdeshi cd09af3d0b record ADR-0039: site.yaml, and only its site level
The human asked for YAML site config alongside the -base flag, which is the
trigger the parked settings cascade was waiting for — but only for its top level.
So this adopts the site level and leaves section and bundle resolution parked,
with their trigger unchanged: the first section-level override with a reader.
The parked idea now says so, rather than reading as untouched.

Absolute URLs are the forcing function. A canonical link, an hreflang, an
OpenGraph tag and a sitemap entry all need the site's own origin, which the engine
cannot infer from a request it may be serving behind any proxy.

Precedence stated in the ADR: -base beats the file, so a staging host needs no
edit to content.
2026-07-31 02:17:53 +06:00
Claude Opus 5andbdeshi bad1b36f77 record ADR-0038: an included file cannot itself include
The human chose non-recursive includes over textual splicing, and this amends the
phase table that promised otherwise: includes turn out to be parse-phase, not
load-phase.

The reason is in the ADR rather than lost in a commit: splicing an included file's
AST into the page is invalid, not just buggy, because goldmark nodes hold offsets
into their own source. Converting separately is what remains, and once conversion
is separate, nesting costs a read and a parse per level with a crash at the end of
any cycle. Forbidding nesting removes that failure mode rather than bounding it.

Names the declined option and its trigger, so textual splicing can arrive with the
Stage pipeline's load phase if composing partials ever becomes a real need.
2026-07-31 02:00:22 +06:00
Claude Opus 5andbdeshi 6ed3f62765 record ADR-0037: a fragment receives Fragment{Args, Items}
gallery is the second fragment and needs a list of filenames, which the current
map[string]string cannot carry. The theme contract says fields are added but never
renamed, so widening the shape costs one commit today and a contract version once
a theme exists — this is the last cheap moment.

Naming the argument map also stops arguments and gathered data colliding: a call
with a src argument beside a feature-supplied src would otherwise silently pick
one. figure becomes .Args.src.
2026-07-30 10:36:16 +06:00
Claude Opus 5andbdeshi 34b1b18012 record ADR-0036: shortcodes render through theme templates
Two things already recorded settle the shape. The theme contract says the engine
decides nothing about how content looks, "including how media is embedded", so a
<figure> assembled in Go would be the engine dressing content. And invariant 2
wants the trusted/untrusted split as real code, not goldmark's default.

Both are satisfied by the same design: parse the shortcode into an AST node, then
render it by executing a theme template of that name. Raw HTML stays disabled, so
every byte of HTML on a page came from a template the site owns, and an author's
text survives only as arguments that html/template escapes.

Recorded before the code because the syntax authors type is a disk contract, and
because a feature that cannot render itself needs a partial-rendering function
passed in at wiring time — a consequence worth agreeing to in advance.
2026-07-30 10:21:48 +06:00
Claude Opus 5andbdeshi 374a4a6e99 record ADR-0035: a slug belongs to the bundle, not to a variant
Confirms ADR-0009's "same path" clause against the alternative that was on the
table: a Bengali variant at its own Bengali address. Chosen: one path per bundle,
prefixed per language, so identity stays stable across translations (invariant 3)
and the resolver keeps one lookup direction.

The declined option is named in the ADR so it is not re-proposed as new. Cost is
stated too — a Bengali reader sees a Latin address, and reversing this later needs
an alias for every published path.

No code moves: `slug` is still unread. Recorded now because nothing is published
yet, which is the only cheap moment a permalink decision has.
2026-07-30 10:12:18 +06:00
Claude Opus 5andbdeshi 50b7764f3a record ADR-0034: chrome text is the engine's, body text is the author's
Draws the line by who wrote the words. Labels, counts, month names and digits
come from an engine table keyed by (key, language) so no template hardcodes
English; authored prose is never localised and never rewritten beyond goldmark's
typographer. Machine-readable output stays ASCII in every locale.

Decided before the code, because the alternative — localising body text — is the
kind of choice that is expensive to walk back once published.
2026-07-30 03:21:19 +06:00
Claude Opus 5andbdeshi 486bc7c3d3 record ADR-0033: series membership is structural
Answers the question that blocked sequences. A chapter belongs to the series it
is nested under, so the engine reads no `series` field; `order` stays, optional,
and a member without one sorts by name after every member carrying one.

This supersedes ADR-0016's membership clause and keeps its position rules — the
point of that ADR was that position never reaches a URL, which still holds. The
directory already states membership unambiguously; a second statement in
frontmatter can only agree or be a typo that silently orphans a chapter.

Cost, stated in the ADR: a series can never span directories, and membership now
rides on the bundle key, which ADR-0008 makes permanent.
2026-07-30 03:03:06 +06:00
Claude Opus 5andbdeshi 60fcb6235d record tags as built: ADR-0032
The queue said to re-adopt the taxonomy decision when tags landed and I did not,
so ideas/deferred-decisions.md claimed no tag pages existed while /tags/ had been
serving for two commits. ADR-0032 records what the code actually does; only the
feed half stays deferred, and it lands with declared types since feed membership is
part of a type declaration.

Folded from a separate state commit:
state: bump verified-against; narrow the declared-types trigger

Sequences do not need a type declaration — membership and ordering come from
frontmatter — so the trigger for declared types is feeds or check, whichever lands
first.
2026-07-30 02:22:28 +06:00
Claude Opus 5andbdeshi f1d31437be content: read a site root into bundles
Bundle loading with no HTTP: walk content/, split YAML frontmatter, derive an
NFC-normalised key and a language from the filename, and lift only title out of
frontmatter so every other key stays readable through Extra (ADR-0002).

Path safety is os.Root rather than a hand-rolled cleaner (ADR-0031). os.DirFS
documents that it does not prevent symlink escape; os.Root refuses any name
resolving outside the root, so the guard is a property of the type instead of a
check to remember at each call site. Test: a symlink to a file above the root
cannot be read. This clears the traversal item off the latent list.

A bundle that will not parse is logged and skipped, never fatal (ADR-0029), as
is a key claimed by two spellings of one variant (ADR-0021).

Bundle carries only Key, Lang, Path, Title, Body and Extra; Date, Slug, Draft
and Aliases arrive with the features that read them.
2026-07-30 01:30:12 +06:00
Claude Opus 5andbdeshi 9d817dcadf rename the project to khosra, initialise the module
Naming is free before a module is published, a URL is shared, or a binary is
deployed; every day it waits costs more. Swept every form: module path, binary,
cmd/ directory, KHOSRA_SITE, the feature-loop skill directory, and the prose in
earlier ADRs — which describe this project under its old name, not a different
project. Recorded as ADR-0030.

go mod init lands here rather than with the first feature because the module
path is what the rename is about. x/text and yaml.v3 are required but not yet
imported, so both are indirect and no direct dependency is claimed yet.
2026-07-30 01:14:01 +06:00
Claude Opus 5andbdeshi 99f8c730b0 harness: withdraw five pre-code ADRs to deferred ideas
Twenty-seven ADRs existed before a line of Go. Five specified the shape of
unbuilt mechanisms — cache validity, declared types, the settings cascade,
taxonomies, extras — which breaks the rule against abstraction before a
second concrete use, in prose where the counters cannot see it. They move to
ideas/deferred-decisions.md as recorded intent and return shaped by whatever
implements them.

Citations retargeted throughout; where one was decoration the rule now stands
on its own reasoning. Type declarations and the cascade drop to [spec] with
the MVP behaviour stated instead, so the first prompts have less to build.
conventions.md names http.ServeMux as the router, closing a hole that invited
hand-rolling a path splitter. The ADR gate now checks a number is registered
in the log rather than headed by an entry, so withdrawals resolve and invented
numbers still fail. Two architecture invariants corrected: identity no longer
implies a required language suffix, and the duplicated permalink clause is gone.
2026-07-30 01:01:38 +06:00
bdeshiandClaude Opus 5 02268f9121 init
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 00:34:18 +06:00