Files
bdeshiandClaude Opus 5 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>
2026-08-03 16:26:40 +06:00

23 KiB

The khosra harness — human's guide

Scaffolding that makes an agent build khosra the way you want: minimally, surgically, questions before code, docs that stay true.

README.md is a two-line signpost — human here, agent to CLAUDE.md. CLAUDE.md is the constitution (always loaded). harness/ is what the agent needs to build the engine — anything in it may be pulled into context on demand. It is named for its audience, not its format: docs/ is reserved for documentation written for whoever uses khosra, and is absent because that does not exist yet (ADR-0082). An empty docs/ says so honestly; docs/ full of parser specifications said the opposite, and cost its own author a rule he did not know was written down. ideas/ and reference/ sit outside harness/ deliberately: storage, opened only when you name a file, swept by nothing. .claude/ holds the feature-loop skill (skills/feature-loop/), the commands, and launch.json. scripts/ holds the gate. .scratch/continue.md is a temporary handoff — uncommitted, ungated, discardable — holding the continuation point and the findings worth carrying that no doc owns. It is read after the harness rather than instead of it (CLAUDE.md §1), and never records what landed: that is git log's alone, for the reason under "Why the pieces exist".

How you use it

You ask for a feature. The skill fires by itself and runs Clarify → Plan → Implement → Verify → Document → Commit → Report.

This runs for casual requests too — the skill fires on "the default-language files do not need the .en part" exactly as it does on "add tag pages", with a one-line plan instead of a full one. A short sentence is not a small change: that one renames content on disk, makes a suffix optional in code, and contradicts every doc calling it required. The agent walks every surface — code, fixture sites under testdata/, harness docs — and, because it cannot reach your site root, writes down the migration you need to run there rather than pretending to have done it.

  1. You: "Add tag pages."
  2. Agent: the clarifying questions whose answers change the code — as many as there are, batched into one turn, each with a default. Answer or ignore.
  3. Agent: a short plan — goal, primitive, success criteria, files, ±LOC, deps, earn-it check, and what it is deliberately not doing.
  4. You: "go", or edit the plan. "Just do it" skips the gate on small changes.
  5. Agent: implements, runs ./scripts/verify.sh, shows real output, updates docs, commits, reports.

Occasional maintenance, by you:

  • "Park this" → the agent writes ideas/<slug>.md, resumable cold, and indexes it. Name the file later to pick the thread up; it reads these only when named. ideas/exploration.md is the same idea for engine features nobody has asked for — a catalog, deliberately outside harness/ so it adds no weight to the working set.
  • /audit every ~5 features — finds abstractions that never earned their keep.
  • /invariants at arc boundaries, before a freeze, before the first deploy — checks the nine architecture invariants against the code, which no grep can do.

Conflicts come back to you. If a request contradicts a recorded decision — an ADR, an invariant, the permalink shape, the untrusted boundary, a hard budget — the agent stops, quotes the line, and gives you two paths: comply, or change the decision on purpose. It resolves only what is genuinely unambiguous, such as a doc that has fallen behind the code. Repeating the request is a valid answer and is taken as your decision; what it will not do is quietly reverse a choice you made earlier, or split the difference into a compromise nobody picked.

  • /refresh-docs after a burst of work — reconciles docs with reality, reports drift.
  • /verify, /adr, /leaf <topic> as needed.

Three surfaces, one of them yours to edit here. Engine source lives in this repo; content lives in the site root (ADR-0011); the theme is a third surface with its own owner (ADR-0023). A request that spans engine and theme produces a contract extension in harness/theme-contract.md plus a note of what the theme must do — not the theme. verify.sh fails if the embedded reference theme changes without the contract doc changing, because in practice those two drift together — and it fails on a <script> tag in that theme, because a reference theme that grows taste stops being a reference (ADR-0026).

The demo is gated like the docs. examples/demo-site/ is a real site, tracked as files so you can read it, edit it and serve it with make demo. It stays true by two gates: a coverage test in cmd/khosra serves it through the real handler and the real feature list with one case per feature (ADR-0072), and verify.sh runs khosra check over it. A feature added without a case there is a feature the demo does not show, and the build says so (ADR-0051). .claude/launch.json points the editor's preview at that same make demo on localhost:8080, so "look at it" and "test it" are the one site. It is the only dev server this repo has: the engine serves a site root and there is nothing else to run.

The agent names the layer as well as the primitive. Content on disk, engine, theme, browser — and it builds at the outermost layer that can do the job (CLAUDE.md §2, harness/architecture.md). This exists because a whole feature was built at the wrong one: widow prevention as a Markdown transform that inserted a non-breaking space into an author's prose. It worked and it had tests; text-wrap: pretty does it better with no bytes in the content, so the feature was deleted (ADR-0045). If a feature only rearranges how something looks, expect the agent to push back toward CSS or a template.

Two ceilings, and raising one is a signature. CORE_LOC_MAX and EXT_LOC_MAX exist so that "core stops growing, ext rises" (invariant 9) is observable rather than asserted. The values live in scripts/budgets.env; raising either needs an ADR stating what moved and why, and the ADR log is where that history belongs, not here. Read a core raise as evidence something belongs in internal/ext/ before reading it as evidence the number was small. ADR-0085 is the worked example: discover left core for internal/ext/ and the ceiling rose to 3400, because the move alone freed 57 lines against four scheduled core-bound items, and feed.go cannot follow it out — a feed is a resolver case, not an exact path.

Context is a budget, and parts of it are mechanical. The limit on this project is how much work fits in a session, so harness/context-economy.md holds the reading, searching and reporting disciplines — read the compressed form first, batch calls, script anything repeatable, never pay twice for the same bytes (ADR-0053). These are enforced rather than trusted: harness/surface.md is regenerated and staged by the pre-commit hook and independently compared by verify.sh, so the map of the code cannot mislead — an order of magnitude smaller than the source it stands for — and no one has to remember to run it; CLAUDE_LOC_MAX bounds the one file re-sent on every turn; and ./scripts/verify.sh --quiet prints a verdict instead of a line per gate, which is what the pre-commit hook now runs. The doc leads with a floor rather than the savings, because every cheap failure mode is also a token saving — not opening the owning doc, guessing a signature, reporting from a diff — and each of those has already cost this repo a defect.

The agent commits by itself, and tells you the first time. Work is committed as part of the loop rather than on request (ADR-0052) — one commit per revertible unit, never a push. In a session where you have not asked for that, the first commit is preceded by a line saying so and naming the off switch ("stop committing"), because a commit appearing in your repository unannounced is a surprise even when it is harmless. The asymmetry is the argument: a commit you did not want is one git revert away, and work that existed only in the working tree when something went wrong is gone. If you want the tree left dirty, say so and the loop stops at Document.

Authorship records who wrote the bytes: a commit the agent wrote is authored by it, one you both wrote is yours with the agent as co-author, one you wrote alone names only you (conventions.md "Git"). You remain the committer and the signature in every case — the key says you take responsibility for the commit, which is not the same claim as having typed it. git log --author=Claude is then a truthful answer to how much of this the agent wrote.

What is staged has to build, not just the working tree. Every other gate looks at your files; this one looks at the commit you are about to make, by building a throwaway checkout of the index. It exists because staging a subset — a changed signature without its caller — produced a commit that did not compile while verify.sh reported green, and a broken commit is something git bisect trips over for as long as the repo lives. It only runs when something is staged.

The injection boundary is a gate now, not a memory. Raw HTML renders for site-root content, because that content is the author's own (ADR-0060) — and the trusted half of that sentence is the whole claim. verify.sh fails unless html.WithUnsafe() appears in exactly one file, internal/render/render.go. A second call site means some other input has been handed the pipeline that trusts its author, which is the failure ADR-0003 exists to prevent; when comments arrive they get their own goldmark without it. A feature wanting to emit HTML still renders a theme template instead (ADR-0036).

Coming back after a long absence

In order, cheapest first:

  1. ./scripts/verify.sh — one command, tells you whether the thing is still coherent and whether harness/state.md has fallen behind the code.
  2. harness/state.md — what exists, the earn-it counters, the latent list. This is the only doc that describes the present, and verify.sh compares its last commit against the last .go one rather than trusting a sha written by hand (ADR-0057). Its companion harness/surface.md is generated: every declaration and its line, so you can find your way around without opening anything.
  3. git log --oneline — one revertible unit per commit, each body saying why (conventions.md). This is the real map of how the code got here.
  4. /refresh-docs — reconciles every doc against the actual code and reports drift, which is exactly the question you have after a year.
  5. harness/decisions.md — the ADR log, when you hit something and think "why on earth is it like this". Each entry names the observation that would overturn it, so you can tell a stale decision from a deliberate one.
  6. harness/toolchain.md — if something is broken rather than merely unfamiliar. It records what this was built against and which agent-tooling contracts it assumes, so a tooling change is diagnosable instead of looking like a harness bug.

Then harness/README.md for whichever topic you are actually here for.

Why the pieces exist

Every counter row says what does not count, and verify.sh fails on a row that leaves that column empty (ADR-0070). Four counters had to be re-scoped the first time anything tested them, and each fix was a sentence about what had been wrongly included — so the sentence is now required up front. A counter that cannot name an exclusion is measuring a symptom.

Counters in harness/state.md turn "no abstraction before its second use" into arithmetic. Every threshold lives in that one table and nowhere else. The agent cannot argue a pipeline into existence one transform early — it writes the next one inline and lets the count force the extraction. Most load-bearing mechanism here, and the one with least machine enforcement, which is why verify.sh fails any .go change that does not touch state.md. That does not prove the counters are right; it makes forgetting them impossible, which is the real failure mode.

The dependency allowlist names modules that are permitted, not required. Stdlib first means what you can verify by reading, not everything you could technically write (ADR-0083): a format, a grammar or an error-correcting code that somebody else specified gets a maintained module, because the work there is verification rather than authorship and a subtly wrong implementation fails silently. The counterweight is unchanged — a module that computes is walkaway-safe since its output is reproducible, a module that holds data is not, and that is the line rather than size. Every module here is the first case, which is why the rule was amended to describe the practice rather than the practice bent to the rule. The gate also fails an untidy go.mod, because go get marks a module indirect until something imports it — and an indirect entry is not checked against the allowlist, so an unapproved dependency could sit there unnoticed. # starts a comment anywhere on a line, including after an entry — the gate strips those, so an entry may carry the reason it exists. Being listed is permission; DEPS_MAX counts what go.mod actually pulls in. scripts/allowed-deps.txt is the list.

Budgets in scripts/budgets.env. Two hard LOC ceilings, core and extensions, plus a dependency cap. Exceeding one fails verify.sh, so raising it is a deliberate act with an ADR attached rather than a drift. The split makes "the core stops growing after Arc 2" measurable: post-freeze the core figure holds and only ext rises. File and function length are _WARNs, not ceilings — the cheapest way to satisfy a hard per-file limit is sharding a coherent file into a _helpers.go, which is worse code with a greener gate.

The latent list absorbs the urge to refactor mid-feature: a line in state.md instead of a drive-by fix. You decide when latent items become features — but an arc cannot close with an untriaged one, so the list drains instead of becoming a graveyard.

This repo is engine source only. The site root — content/, static/, optional templates/ — lives in its own repository and is passed to the binary with -site (ADR-0011), and validating content is the engine's own job rather than the gate's. The gate skips ideas/, reference/ and the uncommitted .scratch/, so exploratory code parked there never has to compile.

The handoff is the one state-describing file no gate can check, so its job is narrowed instead. state.md's currency is compared against the last .go commit (ADR-0057), surface.md is generated and diffed, the theme contract and this file are coupled to what they describe. .scratch/continue.md can have none of that: it is gitignored, so there is no commit to compare it against. Two things stand in for the gate it cannot have — it holds the plan and the carried findings but never what landed, and the read order puts git log first with the handoff read against it (harness/README.md). The one check that is mechanical now runs: a .scratch/ path named in a doc must exist, guarded on the directory being present so a fresh clone with no handoff still passes. A handoff that recorded what landed was believed for a whole session — it said a doc trim awaited a yes while the commit being amended around it had already done the trim.

The architecture gate. verify.sh enforces the layering in conventions.md from go list output: content imports no sibling, render imports neither web nor ext, web imports no ext, nothing imports cmd. One convenient sibling import is what turns a layered engine into a ball of mud, and it always looks locally reasonable — so it is a hard failure, not a review note.

Feature locality is enforced, not hoped for. A feature is one directory under internal/ext/<name>/ plus one line in cmd/khosra/wire.go. verify.sh fails on a feature importing a sibling and on a feature package without a doc.go — the first because sibling imports make an agent's read set compound, the second because a four-line doc.go turns orientation into a fifteen-line read (ADR-0027).

Documentation is gated too. Every package carries a package comment, and a decision cited in code (// Path shape: ADR-0008.) must name an ADR that exists. Doc comments on exported identifiers are warned rather than failed — presence is checkable, usefulness is not, and a hard gate there would buy // Load loads. The point is a codebase still navigable by go doc and git log alone, years from now, with no agent available — verify.sh enforces presence, and only you can enforce that the comment says something.

The style floor fails, it does not warn. Forbidden package names, init(), importing log instead of log/slog, panic() outside cmd/, and time.Now() outside a clock.go are stated absolutely in conventions.md, so they exit non-zero. That last one exists because a Stage that reads the clock without an expiry would serve staleness invisibly. A rule enforced as a suggestion teaches the agent to read every rule as one. A green run has no warnings once code exists. An advisory that fires on correct code gets fixed — either the check or the code — because a warning nobody can act on trains you to skim the ones you can. Two of these have already been narrowed after firing on code the harness itself mandates.

Softer signals stay advisory: fmt.Errorf without %w, nesting past 4, exported-and-referenced-once, and any in an exported signature — only exported, because ADR-0002 mandates an open page object, so unexported code reading frontmatter takes any legitimately and forever.

STATUS markers in harness/architecture.md give the target shape and what is legal today, so the agent can read the endgame without building toward it. They are the only markers of that kind left: content-model.md's [spec] sections were deleted rather than given a stricter "do not build from this" rule, because a marker inside a doc the agent already has open still gets read. Those shapes moved to ideas/, which nothing sweeps. The asymmetry is deliberate — a primitive's endgame is load-bearing for the next decision, an unbuilt disk format is not — so one belongs in a doc that loads and the other in storage.

Six primitives, and everything reduces to one. Effect (ADR-0012) covers work off the request path — derivatives, indexes, feed files, outbound syndication — on content change, on a schedule, or on demand. The test that keeps it honest: if the thing is computable at render time from the page plus the clock, it is a Stage, not an Effect. An "old article" banner is a Stage; it needs no job and can never be stale.

The harness maintains itself

The harness is code and drifts like code. Same rule as the engine: a change ships with the docs that describe it, in the same change.

The CLAUDE.md/SKILL.md redundancy is deliberate and has now drifted twice. The constitution is always loaded and the skill is not, so both state the loop on purpose (harness/README.md, compression contract) — but a rule changed in one and left in the other is a contradiction the agent obeys at random. It happened when the clarifying-question cap was lifted in CLAUDE.md and left at "maximum three" in the skill, and again when [spec] markers were deleted while the skill's conflict table still deferred to them. No gate can catch it: both files are prose, and both are internally consistent. Changing a rule in either means grepping the other, in that change.

  • verify.sh fails when CLAUDE.md, scripts/ or .claude/ changes without HARNESS.md changing — except .claude/settings.json, which is Claude Code's permission list rather than anything about khosra. That exemption is one path: scripts/budgets.env and every other file under scripts/ and .claude/ stays gated, and khosra's own settings are not exempt from anything. This file is the current description of the machine, not a snapshot of its design.
  • Invariant 7 is mechanical now. "Every feature is a leaf" means only cmd/ may import internal/ext/…, and verify.sh states that as one rule rather than a list of the pairs that happen to exist today (ADR-0069) — so a core package added next month cannot quietly import a feature. A feature importing its sibling fails the same rule.
  • verify.sh fails when a .go file changes without harness/state.md changing, warns when state.md's last commit is older than the last .go one — the two together mean the doc ships inside the change, never in a commit trailing it (ADR-0057) — and fails when cmd/ or internal/ code changes without a _test.go changing — behaviour ships with a test. A comment-only or gofmt-only diff is exempt: it ships no behaviour, and failing it would only teach you --no-verify.
  • The gate is not optional. scripts/hooks/pre-commit runs it on every commit; enable once per clone with git config core.hooksPath scripts/hooks. --no-verify bypasses it, and the commit body should say why. The hook also regenerates harness/surface.md and stages it, so a generated file is never something you have to remember — and it refuses a commit with unstaged .go changes, because what it generated describes the working tree rather than what you are committing. It carries no file extension because git locates a hook by exact name — the one exception to "shell scripts end in .sh" (conventions.md), confirmed by renaming it in a throwaway repo and watching a commit succeed with the hook silent. A .sh suffix there would leave a gate that looks present and does nothing.
  • Paths inside scripts/ are checked whether or not they are quoted. 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 ADR-0082's rename and surfaced as a fatal: inside a commit that otherwise succeeded. Backslashes are stripped first, so a path written as a regex is compared as the file it means. Prose is still checked only in backticks: a sentence saying "under harness/" is making a point a filesystem cannot check, while a script naming a path either has it right or is broken.
  • harness/decisions.md registers every ADR number ever used, entries and withdrawals alike, so a citation can resolve to a decision or to a deferral but never to nothing.
  • ./scripts/verify.sh --list names every gate that exists. A doc claiming enforcement is checkable against it in one command, and /refresh-docs checks it in both directions — a claimed gate that is missing, and a real gate nothing explains. Asserting a mechanism before it exists is the drift that reads as enforcement and is decoration; CLAUDE.md rule 8 forbids it.
  • harness/README.md carries three tables: topic → doc to read before asserting a rule, change → doc to update after making one, and an authority table naming the one home of every value. A new mechanism adds a row; nothing outside a value's home may restate it. A number written twice eventually disagrees with itself.

Open

  • No ADR gate blocks Arc 1. One question remains in harness/state.md: the language suffix on the first content file.
  • go.mod does not exist yet — go mod init belongs to the first feature, and the module path is still unchosen.
  • .claude/settings.json denies reading ./.env*, but secrets a parent directory's .envrc exports are in every command's environment regardless. The deny rule is narrower than it looks.