@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: Record an architectural decision in six lines
|
||||
---
|
||||
|
||||
Append an ADR to `docs/decisions.md` using the exact format at the top of that file.
|
||||
|
||||
First, before writing anything: find the doc that owns this topic via the ownership table in
|
||||
`docs/README.md` and read it. If it already carries the rule, do not write an ADR — amend that doc
|
||||
and say that is what you did. An ADR that restates an existing doc is a duplicate, not a decision.
|
||||
|
||||
Rules:
|
||||
- Next sequential number. Never renumber, never rewrite an existing entry.
|
||||
- To reverse a decision, add a new ADR and mark the old one `superseded by ADR-NNNN`.
|
||||
- Six lines. If the reasoning needs more, the decision is not yet made.
|
||||
- `Revisit if:` must name a specific observable event, not "if requirements change".
|
||||
- If the decision adds a dependency, update `scripts/allowed-deps.txt` in the same change.
|
||||
- If the decision raises a budget, update `scripts/budgets.env` in the same change and state
|
||||
the old and new values in the ADR.
|
||||
|
||||
If the argument for `$ARGUMENTS` is thin — no forcing reason, or no consequence you can name —
|
||||
say so and ask one question rather than writing a hollow entry.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: Hunt speculative complexity and propose deletions
|
||||
---
|
||||
|
||||
Read the code and find what has not earned its place. Propose removals; change nothing yet.
|
||||
|
||||
Look for:
|
||||
- Interfaces with one implementation. Type parameters with one instantiation.
|
||||
- Function parameters, struct fields, config knobs, or frontmatter keys with no real caller
|
||||
or no real author using them.
|
||||
- Two implementations of the same idea (parsing, path joining, slugging, date handling).
|
||||
- Abstractions built ahead of their counter: a pipeline at two transforms, a resolver at one
|
||||
route, an extension registry before its counter is due.
|
||||
- Files and functions over the advisory sizes in `scripts/budgets.env`; nesting past 4.
|
||||
- Error handling that logs and returns, or wraps without adding information.
|
||||
- Tests that assert on private helpers, or that need a mock to exist.
|
||||
- Dead code, commented-out code, `TODO`s older than the feature that introduced them.
|
||||
- Comments that narrate control flow.
|
||||
- Anything in `docs/` describing code that no longer exists — excluding `ideas/` and
|
||||
`reference/`, which are never audited against the code and must not be opened here.
|
||||
|
||||
Report as a table: item, location, why it has not earned its place, LOC recovered if deleted,
|
||||
and risk of deleting. Order by LOC recovered, descending. Recommend a top three.
|
||||
|
||||
Then stop. Deletions happen as their own small changes, with a go-ahead each, and each one runs
|
||||
`./scripts/verify.sh`.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: Check the architecture invariants against the code, not against the docs
|
||||
---
|
||||
|
||||
`verify.sh` checks what a grep can check. This pass checks the nine invariants in
|
||||
`docs/architecture.md`, which need reading the code. Run at arc boundaries, before a freeze, and
|
||||
before the first deploy.
|
||||
|
||||
For each invariant, answer **held / violated / not yet applicable**, with a file and line when
|
||||
violated. Do not fix anything in this pass.
|
||||
|
||||
1. **Open page object** — are all fields optional in practice? Find any place a missing field causes
|
||||
a nil deref, an error return, or a template failure that reaches the response.
|
||||
2. **Trusted / untrusted modes** — trace every path by which non-repo bytes could reach shortcode or
|
||||
template evaluation. This is the one invariant where "probably fine" is not an answer: name the
|
||||
check that stops it, or report it violated.
|
||||
3. **Identity is not language** — is the bundle key free of language? Any place a language suffix
|
||||
leaks into a cache key, a URL, or a dependency key is a violation.
|
||||
4. **Request-time render behind a cache** — does export walk the same code path as the server, or has
|
||||
a second path appeared?
|
||||
5. **Permalinks permanent** — does any code derive a URL by a rule other than the one in
|
||||
`content-model.md`? Does every rename path emit an alias plus a permanent redirect?
|
||||
6. **Interactions off the content graph** — can an interaction invalidate more than its own fragment?
|
||||
7. **Every feature is a leaf** — is each `internal/ext/*` package deletable without touching the
|
||||
core? Try naming the diff that removes one.
|
||||
8. **Degrades with external services off** — for each external client, is there a path that still
|
||||
serves correctly, slowly, when it is unreachable?
|
||||
9. **Core stops growing after Arc 2** — compare the `core` figure `verify.sh` prints against the one
|
||||
recorded at the freeze in `state.md`. Post-freeze growth is the finding, not the number.
|
||||
|
||||
Then: report held/violated per invariant, and for anything violated, whether it is a bug to fix now
|
||||
or a latent item with a trigger. A violated invariant is a stop condition (`CLAUDE.md §6`) — say so
|
||||
plainly rather than documenting it as the new normal.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: Decide whether a catalog item is a leaf worth building, or a trunk that waits
|
||||
---
|
||||
|
||||
Evaluate `$ARGUMENTS` against the leaf/trunk test in `docs/exploration.md`.
|
||||
|
||||
Research it only as far as needed to answer accurately — what it actually requires, not what its
|
||||
homepage claims. If it is a protocol or spec, state which parts are mandatory versus optional.
|
||||
|
||||
Answer these, briefly:
|
||||
|
||||
1. **Leaf or trunk?** Per `architecture.md` invariant 7.
|
||||
2. **Reduces to:** which primitive, concretely, with the shape of the implementation in two lines.
|
||||
3. **Prerequisites:** which arc or primitive must exist first. Is that gate open today?
|
||||
4. **Cost:** estimated LOC, new dependencies, new disk fields, ongoing maintenance burden,
|
||||
and anything it makes permanent (URLs, identifiers, published data).
|
||||
5. **Sovereignty test:** apply the one in `roadmap.md` Governors.
|
||||
6. **Fit:** does it serve fiction, webcomics, essays, Bengali-language work, or the
|
||||
low-bandwidth ethos — or is it interesting for its own sake? Interesting-for-its-own-sake is
|
||||
a legitimate answer for a cheap leaf and a disqualifying one for anything expensive.
|
||||
7. **Verdict:** build now / build when gate X opens / trunk, needs an ADR / no.
|
||||
|
||||
Write the verdict as one row in the Verdicts table in `docs/exploration.md`. Do not implement
|
||||
anything in this pass.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: Reconcile the docs against the actual code and report drift
|
||||
---
|
||||
|
||||
Reconcile documentation with reality. The code is the truth; the docs are the suspects.
|
||||
|
||||
1. Inventory the actual Go files, their line counts, and the non-stdlib dependencies in `go.mod`.
|
||||
2. Compare against `docs/state.md`: inventory rows, counters, latent items, `verified against`.
|
||||
Recount the counters **from the code** — number of render transforms, routing cases, views,
|
||||
output formats, extensions — rather than trusting the recorded numbers.
|
||||
3. Check `docs/architecture.md` STATUS lines: has a primitive become real, or is one described
|
||||
as live when it is not built?
|
||||
4. Check `docs/content-model.md` `[spec]` versus `[live]` markers against what the parser
|
||||
actually accepts. Frontmatter fields the code reads but the doc omits are drift; fields the
|
||||
doc promises but the code ignores are worse drift.
|
||||
5. Check `scripts/allowed-deps.txt` against `go.mod`.
|
||||
6. Look for facts stated in two docs. Delete one, link to the other.
|
||||
7. Run `./scripts/verify.sh --list` and check every doc sentence claiming a gate against it. A doc that
|
||||
says "`verify.sh` fails on X" where no such gate exists is the most damaging drift there is: it reads
|
||||
as enforcement and is decoration.
|
||||
8. Check the reverse too — a gate in the list that no doc explains. Either document it or delete it.
|
||||
9. Follow every cross-doc citation of a *section* ("see `roadmap.md` Governors", "`CLAUDE.md` §6") and
|
||||
confirm the heading exists. Whole sections have gone missing while another doc still cited them.
|
||||
|
||||
Scope: the docs listed above and nothing else. **Do not open `ideas/` or `reference/`** —
|
||||
they describe proposals and facts, never the state of the code, so they cannot be drifted against
|
||||
it. `verify.sh` already checks their indexes mechanically.
|
||||
|
||||
Then:
|
||||
- Fix the docs. Doc-only diff; no code changes in this pass, no matter what you find.
|
||||
- Anything in the code that contradicts an ADR: report it, do not silently document it as
|
||||
correct. A drifted invariant is a bug, not a new decision.
|
||||
- Report drift found, drift fixed, and anything that needs a human decision.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
description: Run the objective gates and report evidence, not opinions
|
||||
---
|
||||
|
||||
Run `./scripts/verify.sh` and report the result.
|
||||
|
||||
Then, if the working tree has uncommitted changes, produce feature-specific evidence for what
|
||||
changed: start the server and `curl` the affected URL, run the relevant test by name, or diff
|
||||
the golden file. Show real output.
|
||||
|
||||
If anything fails:
|
||||
1. Report the failure verbatim before interpreting it.
|
||||
2. Diagnose in one paragraph.
|
||||
3. Propose the smallest fix. Do not apply it without a go-ahead unless it is a formatting fix.
|
||||
|
||||
If a budget in `scripts/budgets.env` is exceeded, do not raise the budget. Report which budget,
|
||||
by how much, and which files are responsible. Offer the two options: shrink the code, or an ADR
|
||||
raising the ceiling deliberately.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "default",
|
||||
"allow": [
|
||||
"Bash(gofmt:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(go vet:*)",
|
||||
"Bash(go test:*)",
|
||||
"Bash(go run:*)",
|
||||
"Bash(go doc:*)",
|
||||
"Bash(go list:*)",
|
||||
"Bash(go mod tidy)",
|
||||
"Bash(go mod why:*)",
|
||||
"Bash(./scripts/verify.sh)",
|
||||
"Bash(bash scripts/verify.sh)",
|
||||
"Bash(git rev-parse:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git status)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(curl -s http://localhost:*)",
|
||||
"Bash(curl -s http://127.0.0.1:*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(go get:*)",
|
||||
"Bash(git commit:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push:*)",
|
||||
"Bash(rm -rf:*)",
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)",
|
||||
"Read(./.envrc)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
# The feature loop
|
||||
|
||||
Six steps, in order: **Clarify → Plan → Implement → Verify → Document → Report.**
|
||||
`CLAUDE.md` holds the rules; this file holds the procedure.
|
||||
|
||||
Scale, do not skip. A one-sentence request gets one line of plan and a short report — and still gets
|
||||
the conflict check, propagation, verification, the `state.md` touch and the stale-reference sweep.
|
||||
Casual phrasing is not a smaller change: *"default-language files do not need the `.en` part"* is one
|
||||
unambiguous sentence that rewrites filenames on disk, makes a suffix optional in code, and falsifies
|
||||
every doc calling it required.
|
||||
|
||||
## Propagation — a change is not done until every surface agrees
|
||||
|
||||
One request can touch four surfaces. Name each as done or n/a before reporting; the ones people miss
|
||||
are the first and the last.
|
||||
|
||||
| Surface | What it means |
|
||||
|---|---|
|
||||
| the site root | Out of reach: it lives in its own repo (ADR-0011). A disk-contract change ships a **written migration step** the author runs, plus a note on whether any bundle key or URL moves. Never claim to have migrated files you cannot see. |
|
||||
| code | The engine, plus a test for the new behaviour. |
|
||||
| the theme | Out of reach, like the site root (ADR-0023). This repo ships a **contract extension** in
|
||||
`docs/theme-contract.md` plus a written note of what a theme must do — never the theme itself, and never
|
||||
a layout or markup decision dressed as an engine feature. |
|
||||
| fixtures and emitted output | Fixture sites in `testdata/`, the embedded default templates (a reference implementation of the contract), and anything reading a field or path shape you altered. |
|
||||
| harness docs | Every doc, ADR, marker or example that assumes the old form. An ADR that mandates what you just made optional is not stale, it is **contradicted** — supersede it, do not quietly reword it. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Clarify
|
||||
|
||||
Read `docs/state.md`, then `docs/README.md` — the map, always, before deciding what else to open.
|
||||
Use its topic-ownership table to list the docs owning anything this change asserts a rule about,
|
||||
and read those. That list is a floor: skipping it is how a rule gets written twice and the second
|
||||
copy contradicts the first.
|
||||
|
||||
### Conflict check — every request
|
||||
|
||||
Before anything else, ask whether the request contradicts something already decided. Classify and
|
||||
act; do not average two positions into a compromise nobody chose.
|
||||
|
||||
| Kind | Examples | What to do |
|
||||
|---|---|---|
|
||||
| **Hard** — reverses a deliberate decision | an ADR, an architecture invariant, the permalink shape, the untrusted boundary, a `_MAX` budget, a frozen contract, the dependency policy | **Stop.** Quote the line, name the file, give both paths: comply, or change the decision (new ADR, or supersede the old one). Wait. Never pick for them. |
|
||||
| **Soft** — exceeds a convention or a `[spec]` shape | a `_WARN` threshold, a style-floor preference, an unbuilt `[spec]` section's suggested shape, a latent item's trigger | State it in one line, proceed with the request, record the deviation where the convention lives. Conventions are a floor, not a decision. |
|
||||
| **Stale** — a doc contradicts the code | `state.md` inventory, a STATUS line, an out-of-date example | Auto-resolve: the code wins. Fix the doc in Document, mention it in the report. No question needed. |
|
||||
| **None** — the request refines or extends what is written | asking for something a `[spec]` section already describes | Proceed. Say nothing about it. |
|
||||
|
||||
Resolve intelligently where the answer is genuinely unambiguous — a stale doc, or a request that is a
|
||||
superset of what is written. Surface anything where a reasonable person could have meant either
|
||||
thing, and everything in the Hard row without exception. One conflict is worth one message; a
|
||||
silently reversed decision costs the trust in every other decision.
|
||||
|
||||
Then decide what you genuinely do not know. Ask **only** questions whose answer changes the code or
|
||||
the bytes on disk. Maximum three, one message, up front, each with a **bold** default.
|
||||
|
||||
Never ask about: anything `conventions.md`, `content-model.md`, or an ADR already decides; naming,
|
||||
formatting, file placement, test style; permission to follow the constitution; "would you like me
|
||||
to also…" — that is scope creep wearing a question mark.
|
||||
|
||||
Do ask when: the feature has two plausible disk formats, URL shapes, or authoring ergonomics; it
|
||||
touches an open question in `state.md`; success criteria are not observable from the request;
|
||||
it appears to need a dependency, a new package, or a frozen-contract change.
|
||||
|
||||
No such questions? One line — *"No questions — assuming tag pages live at the section root and
|
||||
reuse the post list template."* — and continue.
|
||||
|
||||
## 2. Plan
|
||||
|
||||
Post this before writing code. Under fifteen lines.
|
||||
|
||||
```
|
||||
Goal: one sentence, observable from outside the program
|
||||
Reduces to: Bundle | Stage | Query | View | Interaction | Effect | bundle-as-program
|
||||
Success criteria: the checks that will prove it works (commands, URLs, expected output)
|
||||
Files: exact paths, marked new / edit, with an estimated ±LOC each
|
||||
New deps: none (anything else needs an ADR first — stop and ask)
|
||||
Earn-it check: which counter this increments, and whether an extraction is due this change
|
||||
Trust check: does any untrusted input reach this code? ("n/a" if not)
|
||||
Docs read: the owning docs you opened ("none" only if the change asserts no rules)
|
||||
Not doing: the two or three adjacent temptations you are declining
|
||||
```
|
||||
|
||||
Wait for a go-ahead unless the user said "just do it" or the change is under ten lines in one file.
|
||||
If the plan reveals a trunk, say so instead of planning and offer the leaf.
|
||||
|
||||
## 3. Implement
|
||||
|
||||
- Smallest code satisfying the success criteria. Nothing for the version after next.
|
||||
- Only the planned files. No renames, reordering, reformatting beyond `gofmt`, or tidying of
|
||||
neighbouring code.
|
||||
- Something wrong outside the plan? One line on the Latent list in `docs/state.md`, keep moving.
|
||||
That is the whole mechanism; use it instead of a drive-by fix.
|
||||
- Reuse the existing shape before inventing one. Copying eight lines twice beats an abstraction at
|
||||
use two; the third use is when it becomes a design.
|
||||
- Halt and ask if any `CLAUDE.md §6` stop condition fires. Halting costs one message.
|
||||
|
||||
## 4. Verify
|
||||
|
||||
Run `./scripts/verify.sh` — doc coupling, format, vet, build, test, dependency allowlist, budgets.
|
||||
|
||||
Then **at least one piece of feature-specific evidence you actually executed**: the new test name
|
||||
and its output, `curl -s localhost:PORT/path | head`, a golden-file diff, the before/after fragment
|
||||
of real generated markup, or benchmark numbers.
|
||||
|
||||
Budget failure: shrink the change, or stop and propose an ADR raising it. Never raise it silently.
|
||||
|
||||
Never write "should work", "this will now…", or your own diff summarised as a result. If you could
|
||||
not run something, say which and why.
|
||||
|
||||
## 5. Document
|
||||
|
||||
Same change, not later. Triggers: `docs/README.md`. Walk the propagation table above — every surface
|
||||
done or explicitly n/a. Minimum:
|
||||
|
||||
- `docs/state.md`: inventory rows, counters, `verified against` line, latent items added/removed.
|
||||
- `docs/decisions.md`: an ADR if the choice is expensive to reverse. Six lines. First open the doc
|
||||
that owns the topic — if it already carries the rule, amend it there; an ADR restating an
|
||||
existing doc is a duplicate, not a decision.
|
||||
- Any other doc **only if the change made it wrong.** Never state one fact in two docs.
|
||||
|
||||
Doc edits are surgical too. Prefer deleting a stale line to appending a corrected one.
|
||||
|
||||
**Then sweep for what you replaced.** Any rule, value, name or path you changed may be described
|
||||
elsewhere in the old terms:
|
||||
|
||||
```
|
||||
grep -rn '<old form>' docs CLAUDE.md HARNESS.md ideas reference .claude scripts
|
||||
```
|
||||
|
||||
`verify.sh` fails on dangling file paths, ADR numbers and `CLAUDE.md` section refs. It cannot detect
|
||||
a sentence that is merely now untrue, or a concept renamed in one place — that sweep is the author's,
|
||||
and skipping it is how a doc ends up contradicting the file it points at.
|
||||
|
||||
## 6. Report
|
||||
|
||||
Short — one line each, no prose unless a conflict or a stop condition needs explaining. Drop the lines
|
||||
that are genuinely n/a rather than padding them.
|
||||
|
||||
```
|
||||
Did: what now works, in the user's terms
|
||||
Evidence: the command you ran and its result
|
||||
Diff: files touched, ±LOC
|
||||
Propagated: content / code / templates / docs — each done or n/a, with content files named
|
||||
Earned: counters after this change; anything now due for extraction
|
||||
Skipped: what you deliberately did not do, and the latent items you logged
|
||||
Conflicts: hard ones surfaced and how they were settled, soft ones deviated from, or "none"
|
||||
Swept: the old form you grepped for after a rename, or "n/a"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Failure modes to catch in yourself
|
||||
|
||||
| Symptom | What it means | Do instead |
|
||||
|---|---|---|
|
||||
| Interface with one implementation | Anticipating, not designing | Concrete type; wait for the second |
|
||||
| A parameter no caller passes | Speculative flexibility | Delete it |
|
||||
| Reaching for a dependency | Usually 40 lines of stdlib | Write the 40 lines, or stop and ask |
|
||||
| Wanting a `switch` on post type in the core | A View or Query is the seam | Move it out |
|
||||
| Needing a mock to test | Wrong seam | `fstest.MapFS` or a `testdata` dir |
|
||||
| Renaming "for clarity" mid-feature | Drive-by refactor | Latent list |
|
||||
| Explaining why the boundary can bend here | It cannot (ADR-0003) | Stop and ask |
|
||||
| Plan grew while implementing | Scope drift | Stop, re-plan, continue |
|
||||
| Writing a rule without reading its owning doc | Guessing where you could look | `docs/README.md` topic table, then amend that doc |
|
||||
| Quietly doing what was asked against a recorded decision | The conflict was real and you hid it | Quote the line, give both paths, wait |
|
||||
| Changed the rule in code, left the docs describing the old one | Propagation stopped at the first surface | Walk all four surfaces |
|
||||
| Made a field optional, left the ADR mandating it | Contradicted, not stale | Supersede the ADR |
|
||||
| Treated a one-sentence request as a narrow change | Articulation size is not blast radius | Walk the propagation table |
|
||||
| Splitting the difference between request and policy | A compromise nobody chose | Surface it; the human picks |
|
||||
@@ -0,0 +1,4 @@
|
||||
/atelier
|
||||
*.test
|
||||
*.out
|
||||
.DS_Store
|
||||
@@ -0,0 +1,111 @@
|
||||
# atelier — agent constitution
|
||||
|
||||
`atelier` is a flat-file personal publishing engine in Go, built solo, one feature at a time.
|
||||
You implement; the human owns scope and taste. This file overrides your defaults.
|
||||
|
||||
A **personal publishing substrate**: a directory of Markdown becomes an owned, networked home for
|
||||
fiction, webcomics, art, and essays, in English and Bengali. Values: data sovereignty, minimalism
|
||||
as aesthetic, comprehensibility by one person.
|
||||
|
||||
---
|
||||
|
||||
## 1. Read order (do not skip, do not exceed)
|
||||
|
||||
1. This file.
|
||||
2. `docs/state.md` — what exists **right now**, plus the earn-it counters.
|
||||
3. `docs/README.md` — always. The map: how you find which doc owns your topic. Not "exceeding".
|
||||
4. Every doc owning a topic your change asserts a rule about (ownership table in `docs/README.md`).
|
||||
5. Only the source files you will edit, plus their direct callers.
|
||||
|
||||
No reading the repo "for context", no speculative greps. Where `docs/state.md` and the code
|
||||
disagree, the code wins — say so, fix the doc in Document.
|
||||
|
||||
**The ceiling has a floor.** "Read less" governs breadth, never the doc that owns what you are
|
||||
writing. Before stating a rule, contract, threshold, or gate, read its owning doc; if it already
|
||||
says it, amend there instead of restating elsewhere.
|
||||
|
||||
`ideas/` and `reference/` are out of context by default, indexes included. Open one only when the
|
||||
human names it. Never sweep, never list, never cite unasked. Storage, not background.
|
||||
|
||||
## 2. The primitives — everything reduces to one
|
||||
|
||||
**Bundle · Stage · Query · View · Interaction · Effect** (+ bundle-as-program).
|
||||
Definitions and STATUS: `docs/architecture.md`.
|
||||
|
||||
Name the primitive before writing code. If it reduces to none it is a **trunk** (wants a permanent
|
||||
service or a core-model change): stop, say so in a paragraph, propose the leaf, wait.
|
||||
|
||||
## 3. Hard rules
|
||||
|
||||
1. **No abstraction before its second concrete use** — pipeline, resolver, interface, generic, config
|
||||
knob, registry. The counters table in `docs/state.md` holds every threshold and is the only place
|
||||
they are written down: read them, increment them, never anticipate them.
|
||||
2. **No new dependency** without an ADR and human approval. Allowlist:
|
||||
`scripts/allowed-deps.txt`. Stdlib first, always.
|
||||
3. **Surgical diffs.** Only the lines the feature needs. No renames, no reformatting beyond
|
||||
`gofmt`, no "while I was in there". Spotted something bad? Latent list in `docs/state.md`.
|
||||
4. **The untrusted boundary is absolute.** Anything not from the site root (comments, webmentions,
|
||||
form input) never reaches shortcode or template evaluation. Crossing it needs a plan callout.
|
||||
5. **Permalinks are permanent.** A published URL never changes meaning; renames add aliases and
|
||||
permanent redirects. The path shape is decided (ADR-0008) and written in `docs/content-model.md` —
|
||||
read it before emitting a URL, and never invent a second shape.
|
||||
6. **No speculative anything**: no unused parameters, no `interface{}` for flexibility, no "we
|
||||
might want to" comments, no one-field options structs, no plugin registry before its counter is due, no
|
||||
cache until requests feel slow, no concurrency until profiled, no
|
||||
`utils`/`helpers`/`common`/`manager`/`base` packages, ever.
|
||||
7. **Delete before you add.** If removing code buys the feature, do that.
|
||||
8. **Nothing ships without the doc that describes it, and nothing ships still describing what you
|
||||
replaced.** Code carries its `state.md` update; harness changes (this file, `scripts/`,
|
||||
`.claude/`) carry their `HARNESS.md` update; both are gated. Changed a rule, value, name or path?
|
||||
Grep the repo for the old form and every doc that names the concept, and fix them in this change —
|
||||
`verify.sh` catches dangling paths and ADR numbers, never a superseded sentence. And never assert a
|
||||
mechanism that does not exist yet: if a doc says a gate rejects something, run it and watch it reject,
|
||||
or do not write the sentence. `./scripts/verify.sh --list` names every gate there actually is. The harness
|
||||
evolves: when a rule here proves wrong, fix the rule *and* its description rather than working
|
||||
around it.
|
||||
9. **Surface conflicts; never resolve them silently.** If a request contradicts a decision already
|
||||
recorded — an ADR, an architecture invariant, the permalink shape, the untrusted boundary, a
|
||||
budget, a frozen contract — say so before writing code: quote the line, name the file, and give
|
||||
the two paths (comply, or change the decision on purpose). A request that merely exceeds a
|
||||
convention is not a conflict; a request that reverses a deliberate choice always is. Kinds and
|
||||
procedure: the Clarify step in `SKILL.md`. Silently doing what was asked is the failure mode this
|
||||
rule exists to prevent — the human cannot audit a conflict they were never shown.
|
||||
|
||||
## 4. The loop (every request, no exceptions)
|
||||
|
||||
`Clarify → Plan → Implement → Verify → Document → Report`.
|
||||
Procedure: `.claude/skills/atelier-feature-loop/SKILL.md`. The two gates people skip:
|
||||
|
||||
**Clarify.** Only questions whose answer changes the code or the bytes on disk. Max three,
|
||||
batched, up front, each with a **bold** default so silence answers. Never about naming, formatting,
|
||||
or anything `docs/conventions.md` decides. None to ask? State assumptions in one line and move on.
|
||||
|
||||
**Verify.** `./scripts/verify.sh` green, plus one piece of feature-specific evidence you actually
|
||||
ran (golden file, `curl`, test name, benchmark). Never report success from reading your own diff.
|
||||
"Should work" is not a result.
|
||||
|
||||
## 5. Definition of done
|
||||
|
||||
- [ ] Plan's success criteria demonstrated with real output.
|
||||
- [ ] `./scripts/verify.sh` green.
|
||||
- [ ] Diff contains nothing outside the planned files.
|
||||
- [ ] `docs/state.md` updated (inventory, counters, latent items, verified-at line).
|
||||
- [ ] ADR in `docs/decisions.md` if a load-bearing choice was made.
|
||||
- [ ] Report states LOC delta, what is now earned, what you deliberately did not do.
|
||||
|
||||
## 6. Stop conditions — halt and ask
|
||||
|
||||
- The change exceeds planned LOC by ~50%, or touches an unplanned file.
|
||||
- You need a new dependency, a new package, or a change to a frozen contract.
|
||||
- You are about to write a mock, a `switch` on a type, or a second copy of parsing logic.
|
||||
- The feature only works if the untrusted boundary bends.
|
||||
- Two reasonable designs exist and the choice is expensive to reverse — present both, briefly.
|
||||
|
||||
Stopping early costs one message. Guessing costs a refactor.
|
||||
|
||||
## 7. Style floor
|
||||
|
||||
Go stdlib idiom, `net/http` + `html/template`, no framework. `%w` wrapping at package boundaries
|
||||
only. `log/slog`. Table-driven tests, golden files in `testdata/`. Explicit wiring in one file, no
|
||||
`init()`. Size thresholds live in `scripts/budgets.env` only. Full rules: `docs/conventions.md` —
|
||||
read them, do not ask.
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# The atelier harness — human's guide
|
||||
|
||||
Scaffolding that makes an agent build `atelier` 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). `docs/` is what the agent needs to build the
|
||||
engine — anything in it may be pulled into context on demand. `ideas/` and `reference/` sit outside
|
||||
`docs/` deliberately: storage, opened only when you name a file, swept by nothing.
|
||||
`.claude/` holds the feature-loop skill and six commands. `scripts/` holds the gate.
|
||||
|
||||
## How you use it
|
||||
|
||||
You ask for a feature. The skill fires by itself and runs
|
||||
`Clarify → Plan → Implement → Verify → Document → 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:** up to three clarifying questions, 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, 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.
|
||||
- `/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 `docs/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).
|
||||
|
||||
## 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
|
||||
`docs/state.md` has fallen behind the code.
|
||||
2. `docs/state.md` — what exists, the earn-it counters, the latent list, the commit it was last verified
|
||||
against. This is the only doc that describes the present.
|
||||
3. `git log --oneline` — one feature 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. `docs/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. `docs/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 `docs/README.md` for whichever topic you are actually here for.
|
||||
|
||||
## Why the pieces exist
|
||||
|
||||
**Counters in `docs/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. 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 `_WARN`s, 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/` and `reference/` as well, so
|
||||
exploratory scratch code parked there never has to compile.
|
||||
|
||||
**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/atelier/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, every exported identifier a doc
|
||||
comment, and a decision cited in code (`// Path shape: ADR-0008.`) must name an ADR that exists. 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 declaring a cache validity window (ADR-0013) would serve staleness invisibly. A rule enforced as a suggestion teaches the agent to read every rule as one.
|
||||
Softer signals — `fmt.Errorf` without `%w`, `interface{}`, nesting past 4, exported-and-referenced-
|
||||
once — stay advisory.
|
||||
|
||||
**STATUS markers in `docs/architecture.md`** give the target shape *and* what is legal today, so
|
||||
the agent can read the endgame without building toward it.
|
||||
|
||||
**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.**
|
||||
|
||||
- `verify.sh` fails when `CLAUDE.md`, `scripts/` or `.claude/` changes without `HARNESS.md`
|
||||
changing. This file is the current description of the machine, not a snapshot of its design.
|
||||
- `verify.sh` fails when a `.go` file changes without `docs/state.md` changing, and when `cmd/` or
|
||||
`internal/` code changes without a `_test.go` changing — behaviour ships with a test.
|
||||
- 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.
|
||||
- `./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.
|
||||
- `docs/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 `docs/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.
|
||||
@@ -0,0 +1,11 @@
|
||||
# atelier
|
||||
|
||||
A flat-file personal publishing engine in Go. Point the binary at a directory of Markdown and it
|
||||
becomes an owned, networked home for fiction, webcomics, art, and essays, in English and Bengali.
|
||||
The site's content lives in its own repository, not this one (ADR-0011).
|
||||
|
||||
**If you are a human:** start at [HARNESS.md](HARNESS.md) — what the scaffolding is, how to use it,
|
||||
and what to decide.
|
||||
|
||||
**If you are an agent:** start at [CLAUDE.md](CLAUDE.md) — the constitution and the read order. It is
|
||||
loaded for you automatically; this file is not a prerequisite and nothing here is a rule.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# Doc map
|
||||
|
||||
Read the one you need. Do not read them all.
|
||||
|
||||
| Doc | Contains | Mutability |
|
||||
|---|---|---|
|
||||
| `architecture.md` | The six primitives, invariants, per-primitive STATUS | Only via an ADR |
|
||||
| `state.md` | What exists now: inventory, earn-it counters, latent items | Every feature |
|
||||
| `decisions.md` | ADR log — one entry per load-bearing choice | Append-only |
|
||||
| `roadmap.md` | Arcs, earn-triggers, the core freeze point | When an arc completes |
|
||||
| `content-model.md` | On-disk layout, frontmatter, post types, permalinks | When the disk format changes |
|
||||
| `conventions.md` | Go style floor, package layout, perf and test rules | Rarely; via ADR if contested |
|
||||
| `extensions.md` | Extension/plugin contract: target shape + earn gates | When earned or frozen |
|
||||
| `exploration.md` | Catalog of possible future *engine features*, leaf/trunk verdicts | When a verdict is reached |
|
||||
| `toolchain.md` | Versions and agent-tooling contracts this was built against | When you deliberately move a version |
|
||||
| `theme-contract.md` | What the engine promises a theme; the only theme-facing obligation | When the contract is extended — additively only |
|
||||
|
||||
Two sibling folders sit **outside** `docs/` because they are storage, not working material:
|
||||
`../ideas/` (parked ideas, any topic) and `../reference/` (durable facts from conversation). Write
|
||||
there when asked to park something; open a file only when the human names it. Nothing sweeps them.
|
||||
|
||||
## Topic ownership — read before you assert
|
||||
|
||||
The inverse of the refresh triggers: those say what to update *after* changing code, this says what
|
||||
to read *before* writing a rule — including when you are only writing an ADR or a comment about it.
|
||||
Nearly always exactly one doc; if two seem to apply, the single-source rule is already broken and
|
||||
that is the finding to report.
|
||||
|
||||
| About to assert something about… | Read first |
|
||||
|---|---|
|
||||
| plugins, extensions, features-as-packages, stage phases | `extensions.md` |
|
||||
| a primitive, an invariant, or what is "earned" | `architecture.md` + counters in `state.md` |
|
||||
| URL paths, permalinks, frontmatter, on-disk layout, post types | `content-model.md` (+ ADR-0008) |
|
||||
| Go style, package layout, file/function size, test shape | `conventions.md` + `scripts/budgets.env` |
|
||||
| a dependency | `scripts/allowed-deps.txt` + ADR-0007 |
|
||||
| a budget or a gate | `scripts/budgets.env` + `scripts/verify.sh` |
|
||||
| the untrusted boundary, comments, webmentions, form input | ADR-0003 + `extensions.md` |
|
||||
| language, translation, fallback | ADR-0004 + ADR-0009 |
|
||||
| deploy, containers, external services | ADR-0010 |
|
||||
| whether a future feature is worth building | `exploration.md` + `roadmap.md` |
|
||||
| how the harness works — a gate, counter, budget, the loop | `HARNESS.md` + `scripts/verify.sh` + `CLAUDE.md` |
|
||||
| a tool version, or why agent tooling stopped working | `toolchain.md` |
|
||||
| templates, layout, presentation, what a theme can rely on | `theme-contract.md` (+ ADR-0019, ADR-0023) |
|
||||
|
||||
## Single-source rule
|
||||
|
||||
Each fact lives in exactly one doc. Need it elsewhere? Link. This binds ADRs too: an ADR restating
|
||||
a rule an owning doc already carries is a duplicate, not a decision — delete it and amend the owner.
|
||||
|
||||
Values that have drifted before, and their one home. Everywhere else names the concept and points
|
||||
here; nothing else may state the value:
|
||||
|
||||
| Fact | Sole home |
|
||||
|---|---|
|
||||
| Earn-it thresholds (transforms, routes, views, extensions) | the counters table in `state.md` |
|
||||
| LOC ceilings, size warnings, dependency cap | `scripts/budgets.env` |
|
||||
| The permalink path shape | `content-model.md` (decision recorded in ADR-0008) |
|
||||
| Which language is at the root, and the prefix form | ADR-0009 |
|
||||
| The leaf/trunk definition | `architecture.md` invariant 7 |
|
||||
| The sovereignty test | `roadmap.md` Governors |
|
||||
| Package layout and the forbidden package names | `conventions.md` |
|
||||
| What exists in the code right now | `state.md` inventory |
|
||||
|
||||
A value restated in two places is not redundancy for safety; it is a future contradiction waiting for
|
||||
whichever copy gets edited alone.
|
||||
|
||||
`state.md` is the only doc describing the present; the rest describe rules, shapes, or intentions.
|
||||
When code and `state.md` disagree, the code wins and `state.md` is wrong.
|
||||
|
||||
## Refresh triggers
|
||||
|
||||
Update in the Document step of the change that caused them. Not later.
|
||||
|
||||
| Change | Update |
|
||||
|---|---|
|
||||
| Any code change at all | `state.md` inventory + `verified against` line |
|
||||
| New transform, route, view, extension, or dependency | the counters table in `state.md` |
|
||||
| A choice expensive to reverse | new ADR in `decisions.md` |
|
||||
| A primitive becomes real (earned) | STATUS line in `architecture.md` + counters |
|
||||
| New frontmatter field, post type, or path shape | `content-model.md` |
|
||||
| New dependency approved | `scripts/allowed-deps.txt` + ADR |
|
||||
| Budget raised | `scripts/budgets.env` + ADR — once the first feature has shipped. While the harness is still being tuned, edit in place. |
|
||||
| A latent item fixed | remove from the Latent list in `state.md` |
|
||||
| Arc finished | `roadmap.md` + a retro line in `state.md` |
|
||||
| Catalog item accepted or rejected | verdict line in `exploration.md` |
|
||||
| `CLAUDE.md`, `scripts/` or `.claude/` changed | `HARNESS.md`, same change. Enforced by `verify.sh`. |
|
||||
| A tool version deliberately moved, or a new external contract relied on | `toolchain.md` |
|
||||
| The theme contract extended, or embedded templates changed | `theme-contract.md`, same change. Enforced by `verify.sh`. |
|
||||
| A request spans engine and theme | contract extension here + a written note of the theme's part; never the theme itself |
|
||||
| A new gate, counter, or budget added | `HARNESS.md` "Why the pieces exist" + a row in one of these tables |
|
||||
| A rule, value, name or path changed | grep the repo for the old form; every doc naming the concept, same change |
|
||||
| The disk contract changed | `content-model.md` + `testdata/` fixtures + a written migration step for the site root, which the engine repo cannot touch |
|
||||
| A doc or ADR mandates what a change made optional | supersede the ADR; do not reword it in place |
|
||||
| A request reversed a recorded decision | new ADR, or supersede the old one — the request does not win by being newer |
|
||||
| A request exceeded a convention on purpose | note the deviation where the convention lives |
|
||||
| An idea floated, not built now | a file in `../ideas/` + its index line. Adopted later: `Status: adopted → <where>` |
|
||||
| A fact worth keeping surfaced in conversation | a file in `../reference/` + its index line, stating how it was established |
|
||||
|
||||
## Staleness
|
||||
|
||||
`/refresh-docs` reconciles docs against code and reports drift. Run it after a burst of work,
|
||||
before a new arc, and any time a doc surprises you.
|
||||
|
||||
## House style
|
||||
|
||||
Present tense. Terse. Written for a reader who has forgotten everything, including their own
|
||||
reasoning. No changelog prose, no "recently we…", no preamble. Delete a line before rewriting it.
|
||||
A doc needing a table of contents is too long.
|
||||
|
||||
## Compression contract
|
||||
|
||||
Shortening a harness doc removes **words, never instructions**. Same rules, gates, thresholds,
|
||||
triggers and table rows before and after; the agent must act identically. Redundancy between
|
||||
`CLAUDE.md` and `SKILL.md` is deliberate — the constitution is always loaded, the skill is not —
|
||||
and is not duplication to be collapsed.
|
||||
|
||||
Deleting a rule, adding one, or narrowing one is a rule change: it needs its own decision and its
|
||||
own reason, and it never rides along in a compression pass.
|
||||
|
||||
Verify before reporting a saving: table rows, headings, list items and checklist boxes must match
|
||||
the pre-pass file (`git diff --stat`, or a snapshot if the change is uncommitted). Report word count,
|
||||
not lines — reflowing prose shrinks lines without saving anything.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Architecture
|
||||
|
||||
Six primitives. Every feature is a composition of them — never a special case, never a plugin slot
|
||||
bolted to the side. This file is the target the code follows.
|
||||
|
||||
STATUS says what is **legal to build now** — a permission, not a report. What actually exists is
|
||||
`state.md`'s job alone, so a STATUS line never needs editing as code lands.
|
||||
|
||||
```
|
||||
request ──► resolver ──► Bundle ──► [Stage…] ──► View ──► bytes
|
||||
│ ▲ │
|
||||
Query ─────────┘ cache
|
||||
Interaction ──► (own path, off the content graph)
|
||||
|
||||
change / clock / command ──► Effect ──► derived artifacts, outbound calls
|
||||
```
|
||||
|
||||
## Bundle — the atom
|
||||
A content folder (or single file): metadata + one or more language bodies + local assets + attached
|
||||
interactions. Identity is the bundle; language is a variant of it.
|
||||
**STATUS: buildable.** Arc 1's spine: single-language, single-file bundles first, then language fallback.
|
||||
|
||||
## Stage — a transform during rendering
|
||||
`func(ctx, *Page) error`, in fixed order. Every rendering feature is a Stage: typography, Bengali
|
||||
numerals, shortcodes, image sizing, dithering. A Stage runs on everything by default and is switched off
|
||||
by the settings cascade (ADR-0017), never by a type predicate compiled into it; a Stage with nothing to
|
||||
do returns early on content shape.
|
||||
**STATUS: deferred.** Called inline in the render path until the transform counter in `state.md`
|
||||
reaches its threshold. Check it; do not guess.
|
||||
|
||||
## Query — filter/sort bundles into a list
|
||||
Returns a list plus a signature doubling as a cache key. All grouping is a Query: sections, tags,
|
||||
series, latest, related, pagination, feeds.
|
||||
**STATUS: deferred until the first collection page.** One-off directory reads are fine before then.
|
||||
|
||||
## View — bundle-or-query → output
|
||||
Overridable per bundle. All presentation. Emits HTML, gemtext, PDF, a program, JSON.
|
||||
**STATUS: deferred until Arc 2.** Until then, templates chosen by a small explicit lookup. Its data
|
||||
contract is frozen from the moment a theme exists, not at Arc 2 — see `theme-contract.md` (ADR-0023).
|
||||
|
||||
## Interaction — external input attached to a bundle
|
||||
Comments, webmentions, likes, via adapters. Off the content dependency graph: own fragment cache,
|
||||
one-page blast radius, hydrated client-side or spliced late.
|
||||
**STATUS: not buildable yet.** Arc 3. Nothing may assume it exists.
|
||||
|
||||
## Effect — work outside the request path
|
||||
Derived artifacts and outbound calls: image derivatives, search index, sitemap and feed files,
|
||||
webmention sending, POSSE, outbound-link archiving, EPUB builds, future-dated publication.
|
||||
|
||||
Three triggers, and one Effect may accept more than one: **on change** (a bundle changed), **on
|
||||
schedule** (a clock), **on demand** (a CLI subcommand). Every Effect is idempotent and re-runnable
|
||||
from scratch, never writes into the site root — that is the author's data (ADR-0011) — logs and
|
||||
retries on failure rather than dying, and leaves the engine correct if it has never run at all: a
|
||||
missing derivative serves the original, a missing index disables search (invariant 8).
|
||||
|
||||
**Not an Effect:** anything computable at render time from the page plus the clock. An "this article is
|
||||
old" banner is a **Stage** reading `date` — right on every request, no job, no staleness, nothing
|
||||
written. Inbound reactions are **Interactions**. If an Effect would need to mutate content to be
|
||||
visible, it is a Stage wearing a disguise.
|
||||
|
||||
**STATUS: not buildable yet.** Earned at the first derived artifact; check the Effects counter in
|
||||
`state.md`. Scheduling is an in-process ticker inside the single binary — no cron container and no
|
||||
queue until ADR-0010's second-service test is actually met.
|
||||
|
||||
**Routing** = URL → (Bundle, View), via a resolver. **STATUS: buildable, inline.** Keep cases inline;
|
||||
extract the resolver when the routing counter in `state.md` is due, not before.
|
||||
|
||||
---
|
||||
|
||||
## Invariants — violating one is a stop condition
|
||||
|
||||
1. **Open page object.** Known fields as struct members plus a `Meta`/`Extra` bag. Absence equals
|
||||
zero value; templates read what exists and never crash on a missing field. Never add a required
|
||||
field. Never make a template failure fatal at request time.
|
||||
2. **Trusted / untrusted pipeline modes.** Content from the site root is trusted; everything else is
|
||||
not, and
|
||||
never reaches shortcode or template evaluation. This is the RCE boundary — the one place where an
|
||||
extra check beats an elegant unification.
|
||||
3. **Identity is not language.** `slug.bn.md` / `slug.en.md` with a defined fallback, even while one
|
||||
language exists. Identity stays stable across translations.
|
||||
4. **Request-time render behind a cache, fixed permalink policy.** The server shape is the superset;
|
||||
static export is cache-warming, never a separate code path.
|
||||
5. **Permalinks are permanent.** Renames add aliases and redirects. URLs are not reused.
|
||||
6. **Interactions are off the content graph.** A comment invalidates one fragment, never a build.
|
||||
7. **Every feature is a leaf** — a View, Stage, Query, Interaction adapter, Effect, or
|
||||
bundle-as-program, deletable without trauma. Anything wanting a permanent service or a core-model
|
||||
change is a **trunk** and waits for a human decision (`exploration.md`).
|
||||
8. **The engine serves correctly with every external service off.** Redis, an index, object storage
|
||||
— each is a shortcut around work the engine can still do itself, slowly. Anything that cannot
|
||||
degrade that way holds canonical state and does not belong there (ADR-0010).
|
||||
9. **Core stops growing after Arc 2.** Post-freeze, features arrive by composition only; core growth
|
||||
is a bug in the plan. Measured, not asserted: `CORE_LOC_MAX` covers `cmd/` +
|
||||
`internal/{content,render,web}`, `EXT_LOC_MAX` covers `internal/ext/`, reported separately by
|
||||
`verify.sh`. An invariant nothing measures is decoration.
|
||||
|
||||
## Re-render and invalidation (target shape, not built)
|
||||
|
||||
One validity record per cached entry, five axes, served only while all hold (ADR-0013):
|
||||
|
||||
| Axis | Invalidated by |
|
||||
|---|---|
|
||||
| content deps | bundle keys plus query signatures, taking `old ∪ new` for membership changes |
|
||||
| interaction fragments | a comment or webmention arriving — the fragment, never the page (invariant 6) |
|
||||
| `valid-until` | the clock passing the minimum window the render's Stages declared |
|
||||
| epoch | an engine or template change, which stales every entry at once |
|
||||
| cacheable | the bundle or route declaring itself uncacheable |
|
||||
|
||||
The record maps onto HTTP: ETag from deps plus epoch, `Expires` from `valid-until`, `no-store` for
|
||||
opt-out — so a proxy in front stays correct without engine code. Stages reach the clock only through an
|
||||
injected accessor, so a window can never be silently forgotten. Defer the inverted index — v1
|
||||
re-renders everything, correct until it is slow. Asset derivatives are content-addressed and therefore
|
||||
immutable: never invalidated, far-future cacheable, a different lifetime from page renders.
|
||||
|
||||
## Dropped on purpose
|
||||
|
||||
Guest authors. The trust model is a clean binary — site-root content trusted, everything else not — and
|
||||
multi-author would smear it. Comments remain the only untrusted path.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Content model
|
||||
|
||||
**Engine specification** — the format the parser accepts and the URLs the engine emits. This is not a
|
||||
rule about how anyone organises their files: the site root belongs to its owner (ADR-0011), and every
|
||||
statement here describes engine behaviour, not an obligation on content.
|
||||
|
||||
The most expensive thing in the engine to change, because published URLs are permanent and the files it
|
||||
reads are somebody's database.
|
||||
|
||||
Markers are build order, not status: `[arc1]` build now · `[spec]` agreed shape, build at first real
|
||||
use. What exists is `state.md`'s job. Never build a `[spec]` section because it is written here.
|
||||
|
||||
## The site root
|
||||
|
||||
The engine is pointed at a site root outside this repository (`-site <dir>`, or `ATELIER_SITE`), with
|
||||
its own git history (ADR-0011). Nothing in the engine repo is content. `templates/` in the site root
|
||||
overrides the defaults the binary embeds, so a bare root still renders.
|
||||
|
||||
```
|
||||
<site>/
|
||||
content/ # bundles — the disk contract below
|
||||
static/ # verbatim, served as-is
|
||||
templates/ # html/template overrides, optional
|
||||
|
||||
<site>/content/
|
||||
posts/ # general blog [arc1]
|
||||
2026-03-hello-world/ # directory bundle
|
||||
index.en.md
|
||||
index.bn.md
|
||||
cover.jpg # local asset, referenced relatively
|
||||
comics/ # webcomics [spec]
|
||||
the-long-monsoon/
|
||||
_index.en.md # the series bundle; also the cascade point for it
|
||||
first-rain/ # slug is the name, never the position (ADR-0016)
|
||||
index.en.md
|
||||
page.png
|
||||
art/ # single images or sets [spec]
|
||||
writing/ # short stories, poems [spec]
|
||||
status/ # short IndieWeb-style notes [spec]
|
||||
2026-07-28-1030.md # single-file bundle, no folder
|
||||
pages/ # about, contact, now [spec]
|
||||
about/index.en.md
|
||||
```
|
||||
|
||||
## Bundle rules
|
||||
|
||||
- A bundle is **either** a directory containing `index.<lang>.md` **or** a single file
|
||||
`<slug>.<lang>.md`. Nothing else. A single file is the degenerate case, not a special case.
|
||||
- The bundle key is its path relative to `<site>/content/`, minus language and extension. It is the cache
|
||||
key, the dependency key, and the identity in ADR-0004. It never changes silently.
|
||||
- The language suffix is optional and its absence means the default locale, always — not only while one
|
||||
language exists (ADR-0021). `about.md` and `about.en.md` are the same variant; the parser accepts
|
||||
either. Fallback chain: requested → default → any → 404.
|
||||
- `_index.<lang>.md` in a directory containing other bundles makes that directory itself a bundle (a
|
||||
section or series landing page) rather than a plain container.
|
||||
- Local assets sit beside the body, referenced relatively. Assets never live in frontmatter. Moving a
|
||||
bundle moves its assets — the entire point of bundles.
|
||||
- A directory starting with `_` other than `_index` is ignored. `draft: true` is excluded from queries
|
||||
and feeds but reachable at its own URL in dev.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
YAML (ADR-0020), minimal, all fields optional except `title`. Unknown keys land in the `Extra` bag and are
|
||||
readable by templates (ADR-0002). Never add a required field.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `title` | string | Only required field |
|
||||
| `date` / `updated` | date | Publication; `updated` drives feeds and `Last-Modified` |
|
||||
| `type` | string | Post type; defaults from the top-level section |
|
||||
| `slug` | string | Overrides the derived slug. The engine serves the new path only; the old one 404s unless it appears in `aliases` |
|
||||
| `aliases` | []string | Additional paths the engine resolves to this bundle, each redirecting permanently to the canonical one (ADR-0008) |
|
||||
| `draft` | bool | Excluded from queries and feeds |
|
||||
| `nocache` | bool | Never cache this bundle's render (ADR-0013). Named so absence means cacheable, per ADR-0002 |
|
||||
| `summary` | string | Explicit summary; otherwise derived |
|
||||
| `tags` | []string | Flat, case-preserved, Unicode |
|
||||
| `series` / `order` | string / int | Series membership and position. Sparse by convention (10, 20, 30) so insertion is one edit; never appears in a URL (ADR-0016) |
|
||||
| `cover` | string | Relative path to the lead image |
|
||||
| `view` | string | Per-bundle View override (Arc 2) |
|
||||
| `styles` / `scripts` | []string | Page-specific assets, relative to the bundle |
|
||||
| `lang` | string | Explicit language when the filename cannot carry it |
|
||||
|
||||
## Post types
|
||||
|
||||
Types are **declarations**, not code paths (ADR-0014). The binary embeds the default set below; a
|
||||
`types:` block in `site.yaml` extends or overrides it, so a seventh type costs a declaration plus a
|
||||
template and no engine change. An incoherent declaration is a loud startup failure.
|
||||
|
||||
Each declaration carries: `section` (default directory), `view` (default View), `order` (date, sequence,
|
||||
or manual), `feeds` (primary, section-only, or none), `required` (frontmatter fields that must exist),
|
||||
`titleless` (whether an empty title is legal), and `taxonomies`.
|
||||
|
||||
| Type | Section | Order | Feeds | Distinguishing need |
|
||||
|---|---|---|---|---|
|
||||
| `post` | `posts/` | date | primary | The baseline |
|
||||
| `comic` | `comics/` | sequence | primary | Ordered within a series; prev/next; image is the content |
|
||||
| `art` | `art/` | date | section | Image-first; gallery membership; caption over body |
|
||||
| `writing` | `writing/` | sequence | primary | Long-form; series-aware; print View eventually |
|
||||
| `page` | `pages/` | manual | none | Standalone, dateless |
|
||||
| `status` | `status/` | date | primary | Titleless, timestamp-slugged, syndication source |
|
||||
|
||||
Titleless is legal for `status`: the derived title is empty and Views must not assume one exists — a
|
||||
direct consequence of the open page object. Nothing in the engine may assume the type list is fixed.
|
||||
|
||||
## Identifiers, normalisation and slugs
|
||||
|
||||
Two separate things (ADR-0015).
|
||||
|
||||
**Normalisation is unconditional.** Every string that acts as an identifier — filename, bundle key,
|
||||
taxonomy term, frontmatter `slug`, request path — is normalised to NFC where it enters the engine, with
|
||||
no opt-out. Bengali conjuncts have several byte encodings for identical-looking text and macOS yields
|
||||
NFD, so without this two visually identical files take different bundle keys and a request never matches
|
||||
the page it names. Unfixable after publication except by accumulating aliases.
|
||||
|
||||
**Derivation is locale-aware and overridable.** Default slug rules come from the site's default locale;
|
||||
Unicode is preserved rather than transliterated. Any derived slug may be replaced by hand:
|
||||
|
||||
- a bundle, with `slug` in frontmatter — the engine then serves that path, and the previous one only if
|
||||
`aliases` lists it
|
||||
- a taxonomy term or section segment, with a term-to-slug mapping in the type declaration, so a Bengali
|
||||
tag can carry a chosen URL form instead of a derived one
|
||||
|
||||
Overrides are normalised like everything else: writing a slug by hand does not exempt it.
|
||||
|
||||
## Permalinks
|
||||
|
||||
`/{section}/{slug}/`, no exceptions (ADR-0008). Section is the content type — the top-level directory
|
||||
under `content/`, including `pages` — and slug comes from the bundle path or a `slug` override. So
|
||||
`pages/about/` serves at `/pages/about/`, and the root stays engine-owned: emitted files and future
|
||||
routes like `/tags/` can never collide with a bundle.
|
||||
|
||||
Trailing slash is canonical, the slashless form redirects permanently — one rule, applied once. A
|
||||
published URL never changes meaning; renames add `aliases` and emit permanent redirects. Moving a
|
||||
bundle between sections changes the URL the engine emits, and the old path resolves only through
|
||||
`aliases` — so a section list is effectively permanent once anything is published.
|
||||
|
||||
Language routing is decided (ADR-0009): English at root, other languages under `/<lang>/` on the same
|
||||
path — `/pages/about/` and `/bn/pages/about/`. `/en/…` permanently redirects to the root form and is
|
||||
never live.
|
||||
Emit `hreflang` and `canonical` from the variants that actually exist.
|
||||
|
||||
## Sequences, galleries, collections `[spec]`
|
||||
|
||||
All of these are a Query over bundle metadata with a stable sort; none justifies a new primitive.
|
||||
|
||||
A **sequence** is the one that needs naming, because comics, serial fiction and multi-part essays all
|
||||
reduce to it (ADR-0016). Membership is `series`; position is whatever the type declares as its `order`
|
||||
rule — date, sequence, or manual. Position lives in frontmatter, sparse, and never in the path, so
|
||||
inserting a chapter between two others is a single edit with no renames, no changed bundle keys and no
|
||||
aliases. Resolution — first, prev, next, last, index, count — is defined once, honours `draft`, and
|
||||
respects the language fallback chain, so a missing Bengali chapter does not break Bengali prev/next.
|
||||
|
||||
A gallery is a Query plus an image View. Related posts are a Query with a scoring function, not a stored
|
||||
graph. Pagination is a Query parameter plus a permalink rule for page 2+, still undecided.
|
||||
|
||||
## Taxonomies `[spec]`
|
||||
|
||||
Two kinds, deliberately (ADR-0018).
|
||||
|
||||
**`tags` are one global namespace** across every type. `/tags/{tag}/` lists everything carrying the tag;
|
||||
`/{section}/tags/{tag}/` narrows it; listing views group results by type so a busy tag stays readable.
|
||||
Cross-type discovery is the point — one tag spanning a comic, a poem and a photo essay is a feature here,
|
||||
not noise. Term slugs are derived by locale and overridable by hand (ADR-0015).
|
||||
|
||||
**Declared taxonomies** are structural: a fixed term set that changes engine behaviour, declared on the
|
||||
type — `series` for ordering, `medium` for art, `genre` for writing. These never enter the tag pool. The
|
||||
test: a tag is free-form and cross-cutting, a declared taxonomy has known terms and drives behaviour.
|
||||
|
||||
Feeds follow the same shape: `/feed.xml` carries every type declared `primary`, `/{section}/feed.xml`
|
||||
carries a section, and `/tags/{tag}/feed.xml` comes free from the same Query.
|
||||
|
||||
## The settings cascade
|
||||
|
||||
Settings resolve site → section → bundle, nearest explicit value winning (ADR-0017):
|
||||
|
||||
| Level | Where it lives |
|
||||
|---|---|
|
||||
| site | `site.yaml` at the site root |
|
||||
| section, and any enclosing section | that directory's `_index.<lang>.md` frontmatter |
|
||||
| bundle | the bundle's own frontmatter |
|
||||
|
||||
The cascade carries stage toggles, `view` selection, taxonomy defaults, cache flags, and metadata
|
||||
defaults — declared keys only, never arbitrary engine internals. Stages run on everything by default and
|
||||
are switched off by a cascade key, not by a predicate inside the stage: a stage that finds nothing to do
|
||||
returns early on content *shape* ("no images here"), which has nothing to do with type.
|
||||
|
||||
This is also how a template is chosen: type default, then section override, then the bundle's `view`
|
||||
(ADR-0019).
|
||||
|
||||
## Extras: enumerated local assets `[spec]`
|
||||
|
||||
A bundle may hold a directory of supporting files — drafts, notes, logs, scans, media. Default `extras/`,
|
||||
renamed by a cascade key so a comic can use `process/` and a story `notes/` (ADR-0025).
|
||||
|
||||
- The bundle scanner **skips it entirely**. A `.md` inside is an asset, not a bundle: no frontmatter, no
|
||||
identity, no language variants.
|
||||
- Enumerated as a tree, sorted by filename — the sparse numeric-prefix convention orders entries without
|
||||
putting numbers in URLs (ADR-0016).
|
||||
- Each entry is classified by extension: `markdown`, `text`, `image`, `pdf`, `audio`, `video`, `other`.
|
||||
Markdown and text are rendered; everything else is served as bytes.
|
||||
- `…/extras/{path}` renders the listing with that entry selected; `?raw` returns the bytes.
|
||||
- Excluded from feeds, queries and search.
|
||||
|
||||
## Visibility of everything inside a bundle
|
||||
|
||||
Every byte served from inside a bundle inherits that bundle's publish status — body, cover image, any local
|
||||
asset, everything under extras (ADR-0024). An unpublished bundle answers **404** for itself and all its
|
||||
assets; 403 would confirm the work exists. `-dev` reveals drafts and future-dated bundles with their
|
||||
assets, defaults off, and is the only thing that changes the answer.
|
||||
|
||||
For a future-dated bundle the 404 carries `valid-until` = its publish time (ADR-0013), so it expires
|
||||
exactly when the bundle becomes public.
|
||||
|
||||
## Includes and shortcodes `[spec]`
|
||||
|
||||
Shortcodes are a Stage running on trusted content only (ADR-0003), never on comments. File inclusion
|
||||
resolves relative to the including bundle and may not escape the site root. Transclusion of another
|
||||
bundle's body is Arc 4 and needs a cycle guard on the first attempt.
|
||||
|
||||
## Images `[spec]`
|
||||
|
||||
Optimisation and sizing are a Stage plus emitted derivative files, content-addressed by source hash
|
||||
and target width so rebuilds are idempotent and cheap. Emit width/height into the markup to prevent
|
||||
layout shift. Never mutate the author's original. Prefer stdlib decoders; a small dependency only
|
||||
with an ADR.
|
||||
|
||||
## Time-dependent presentation `[spec]`
|
||||
|
||||
Anything derivable from a page plus the current clock is computed by a Stage, never stored in content:
|
||||
an "this article is old" banner compares `date` to now, relative dates likewise. Because renders are
|
||||
cached (ADR-0005), such a Stage also declares how long its output stays true — a banner until
|
||||
`date`+threshold, a relative date for a minute — and the entry expires then (ADR-0013). Future-dated
|
||||
publication is the same shape seen from the other side: the page becomes reachable at a moment nobody is
|
||||
requesting it.
|
||||
|
||||
## Metadata output `[spec]`
|
||||
|
||||
OpenGraph, Twitter cards, JSON-LD, microformats2, canonical links, and `hreflang` are Stages reading
|
||||
only fields that already exist on the page. SEO adds no new disk fields; if it seems to need one, the
|
||||
field belongs in the model for its own sake.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Conventions
|
||||
|
||||
The style floor. Do not ask about anything here — read it and comply. Disagreement is legitimate but
|
||||
goes through an ADR, not a diff.
|
||||
|
||||
## Language and dependencies
|
||||
- Go, current stable release. Stdlib first, every time.
|
||||
- `net/http`, `html/template`, `log/slog`, `os`, `io/fs`, `embed`. No web framework, ORM, router
|
||||
library, or config library — flags plus environment variables, parsed in one place. The site root
|
||||
(`-site`, `ATELIER_SITE`) is the only required setting; default templates are `embed`ded so a bare
|
||||
site root renders (ADR-0011).
|
||||
- New dependency = ADR + human approval + `scripts/allowed-deps.txt`. `verify.sh` enforces it.
|
||||
- Prefer 40 lines of obvious code over a dependency doing it in one call — unless the 40 lines would be
|
||||
subtly wrong in cases the author cannot predict, which is why YAML is a dependency (ADR-0020).
|
||||
|
||||
## Package layout
|
||||
```
|
||||
cmd/atelier/ main, flag parsing, explicit wiring — the only place things are assembled
|
||||
internal/content/ bundles, frontmatter, slugs, queries — knows the disk, not HTTP
|
||||
internal/render/ markdown, transforms, templates — knows content, not HTTP
|
||||
internal/web/ handlers, routing, headers, caching — knows both, exposes neither
|
||||
internal/ext/ extensions, one package each — earned at its counter (see extensions.md)
|
||||
```
|
||||
Dependencies point inward. `internal/content` imports nothing from the others; `cmd` imports everything
|
||||
and is imported by nothing. A feature under `internal/ext/` may import `internal/content` and
|
||||
`internal/render`, and must not import `internal/web`, `cmd/`, or **another feature** — sibling imports
|
||||
are what make an agent's read set compound (ADR-0027). Every `internal/ext/*` package carries a `doc.go`;
|
||||
`verify.sh` fails without one. Routes reach `web` by assembly in `cmd/atelier/wire.go`, so `web` never
|
||||
learns features exist. No `utils`, `helpers`, `common`, `shared`, `manager`, `base`,
|
||||
`impl`, `core` — a package name not describing a domain is a smell. Flat until a package exceeds
|
||||
`FILE_LOC_WARN`; do not pre-partition, and a single-file package therefore warns at the same point it
|
||||
wants splitting.
|
||||
|
||||
## Naming and shape
|
||||
- Functions under `FUNC_LOC_WARN`, ideally under 20. Nesting depth under 4. File length is not a
|
||||
target: one coherent file beats two split to satisfy a counter. Values: `scripts/budgets.env`.
|
||||
- No `init()`. No package-level mutable state. No singletons. Wire explicitly in `cmd`.
|
||||
- Accept interfaces only where a second implementation exists; return concrete types.
|
||||
- `ctx context.Context` first when a call can block or be cancelled — not decoratively.
|
||||
- Never call `time.Now()` outside a `clock.go`. Stages read the clock through an injected accessor and
|
||||
declare a validity window with it (ADR-0013); `verify.sh` fails on any other caller, because a
|
||||
forgotten window serves staleness silently.
|
||||
- Comments explain *why*, never *what*. Delete a comment narrating the next line. One stating a
|
||||
non-obvious invariant is worth ten describing control flow.
|
||||
|
||||
## Documentation
|
||||
|
||||
Written for a maintainer working alone, years from now, with no agent to explain anything. `verify.sh`
|
||||
enforces presence; only a human can enforce that it says something.
|
||||
|
||||
- **Every package has a package comment.** What it owns, what it does not, and which packages may import
|
||||
it. For `internal/ext/*` that is the four-line `doc.go` shape in `extensions.md`.
|
||||
- **Every exported identifier has a doc comment.** The exported surface of a 2000-line core is small, and
|
||||
`go doc ./...` is the only navigation tool that still works when nothing else does. A comment that
|
||||
restates the name (`// Load loads.`) is worse than none: say what it returns on absence, what it costs,
|
||||
what it assumes.
|
||||
- **Cite the ADR where the decision lives in the code.** `// Path shape: ADR-0008.` at the point that
|
||||
builds a URL, `// Visibility inherits: ADR-0024.` at the guard. Twenty-seven decisions are unreachable
|
||||
from code otherwise, and the next maintainer changes something whose reasoning they never saw.
|
||||
`verify.sh` fails on a citation naming an ADR that does not exist.
|
||||
- **Comment the trap, not the mechanism.** NFC normalisation, the settle window, parse order for template
|
||||
overrides, the `old ∪ new` membership rule — each is a line of code that looks arbitrary and is not.
|
||||
Those are the comments worth writing.
|
||||
|
||||
## Errors
|
||||
- Wrap with `%w` at package boundaries, with operation and path: `parse %s: %w`.
|
||||
- Never log and return the same error. Handle it, or return it.
|
||||
- Request-time render failure degrades: log, serve what exists, never 500 on a missing field.
|
||||
Startup failure is fatal and loud. Content authoring errors name the file and line.
|
||||
|
||||
## Tests
|
||||
- **Behaviour ships with a test.** A change to `cmd/` or `internal/` carries a `_test.go` change in
|
||||
the same commit; `verify.sh` fails otherwise. Test the observable contract, not coverage for its own
|
||||
sake — one test proving the new behaviour is enough, and a refactor with no behaviour change needs
|
||||
only the existing tests to still pass (touch them or say why not).
|
||||
- Table-driven. Golden files in `testdata/`, regenerated behind a `-update` flag.
|
||||
- Test the observable contract: URL in → bytes out; file on disk → page struct. Do not test private
|
||||
helpers, and do not add a seam solely to make something testable.
|
||||
- Needing a mock means the design is probably wrong — use a `testdata` directory with
|
||||
`os.DirFS`/`fstest.MapFS`.
|
||||
- One end-to-end test per route beats ten unit tests of render internals.
|
||||
|
||||
## Performance
|
||||
Correct and small first; fast where measured. The render path is the only hot path.
|
||||
- Write to `io.Writer`, never build pages by string concatenation.
|
||||
- Parse templates once at startup; never per request.
|
||||
- No `sync.Pool`, caching layer, or goroutines in the render path until a benchmark justifies it and
|
||||
the number goes in the commit message.
|
||||
- No reflection in the hot path. `Extra` lookups are map reads, not reflection.
|
||||
- Benchmarks live next to what they measure, added only when a decision depends on them.
|
||||
|
||||
## Frontend output
|
||||
Semantic HTML working with zero JavaScript. Progressive enhancement only. No build step for CSS.
|
||||
Page-specific styles and scripts come from the bundle (`styles`/`scripts` frontmatter). Respect
|
||||
`prefers-reduced-motion`. Every image gets width, height, and alt. Payload discipline is a feature of
|
||||
this project, not an optimisation.
|
||||
|
||||
## Git
|
||||
One feature per commit. Imperative subject under 72 characters; body says *why*. Doc updates ride in
|
||||
the same commit as the code that made them true.
|
||||
@@ -0,0 +1,396 @@
|
||||
# Decisions (ADR log)
|
||||
|
||||
Append-only. Never rewrite history; supersede with a new entry. Six lines each:
|
||||
|
||||
```
|
||||
## ADR-NNNN — Title
|
||||
Date · Status: accepted | proposed | superseded by ADR-NNNN
|
||||
Decision: one sentence, imperative.
|
||||
Why: the forcing reason, not the full debate.
|
||||
Consequence: what this makes cheap, what it makes expensive.
|
||||
Revisit if: the specific observation that would overturn it.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ADR-0001 — Rendering is an ordered pipeline of stage functions
|
||||
Date: front-loaded · Status: accepted (target shape; see counters before building)
|
||||
Decision: rendering is a sequence of `(ctx, page) → page` transforms, not a monolith.
|
||||
Why: every rendering feature must be addable and deletable without touching a renderer core.
|
||||
Consequence: cheap to add typography, shortcodes, localisation, dithering; requires discipline about stage ordering.
|
||||
Revisit if: ordering constraints between stages become a graph rather than a list.
|
||||
|
||||
## ADR-0002 — The page object is open
|
||||
Date: front-loaded · Status: accepted
|
||||
Decision: known fields as struct members plus a `Meta`/`Extra` bag; absence equals zero value.
|
||||
Why: templates and extensions must survive fields they do not know about, in both directions.
|
||||
Consequence: no schema migrations for new metadata; slightly weaker compile-time safety.
|
||||
Revisit if: silent typos in frontmatter keys start costing real debugging time (then add a lint, not a schema).
|
||||
|
||||
## ADR-0003 — Pipeline has trusted and untrusted modes
|
||||
Date: front-loaded · Status: accepted
|
||||
Decision: untrusted content never receives shortcode or template evaluation.
|
||||
Why: this is the RCE boundary; it is the one asymmetric risk in the whole engine.
|
||||
Consequence: comments render through a strictly narrower path; some unification is permanently off the table.
|
||||
Revisit if: never. Narrow the untrusted path, never widen it.
|
||||
|
||||
## ADR-0004 — Page identity is separate from language variant
|
||||
Date: front-loaded · Status: accepted; the day-one-suffix instruction superseded by ADR-0021
|
||||
Decision: `slug.bn.md` / `slug.en.md` with a fallback chain, adopted from day one at one language.
|
||||
Why: retrofitting identity/variant separation touches routing, caching, feeds, and every URL.
|
||||
Consequence: translations are free later; a tiny amount of ceremony now.
|
||||
Revisit if: never.
|
||||
|
||||
## ADR-0005 — Render at request time behind a cache; static export is cache-warming
|
||||
Date: front-loaded · Status: accepted
|
||||
Decision: the server is the superset; export walks the same code path and writes files.
|
||||
Why: two code paths for the same output diverge, and the divergence always shows up in production.
|
||||
Consequence: dynamic features (comments, search, content negotiation) stay possible; needs a cache eventually.
|
||||
Revisit if: hosting constraints make a running process impossible.
|
||||
|
||||
## ADR-0006 — No guest authors
|
||||
Date: front-loaded · Status: accepted
|
||||
Decision: whoever commits to the site repo is the single trusted author; no multi-author model.
|
||||
Why: keeps the trust model a clean binary, which keeps the untrusted boundary auditable.
|
||||
Consequence: collaboration happens via git; the comment boundary stays the only untrusted path.
|
||||
Revisit if: a real co-author appears — and then reconsider from scratch, not by patching.
|
||||
|
||||
## ADR-0007 — Dependency budget with an allowlist
|
||||
Date: harness · Status: accepted
|
||||
Decision: non-stdlib dependencies live in `scripts/allowed-deps.txt`; additions need an ADR; `verify.sh` enforces it.
|
||||
Why: sovereignty and comprehensibility are the point; dependencies are the usual way both die.
|
||||
Consequence: some features cost more code; the whole engine stays readable in an afternoon.
|
||||
Revisit if: a budget raise is justified in an ADR of its own.
|
||||
|
||||
## ADR-0008 — Permalink policy: `/{section}/{slug}/`
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: every bundle lives at `/{section}/{slug}/` with **no exceptions** — section is the content
|
||||
type, the top-level directory under `content/` (`posts`, `comics`, `art`, `writing`, `status`,
|
||||
`pages`), and slug comes from the bundle path or a `slug` override. `pages/about/` therefore serves at
|
||||
`/pages/about/`. Trailing slash is canonical; the slashless form permanently redirects. Bengali
|
||||
composes with the prefix outermost: `/bn/{section}/{slug}/` (ADR-0009).
|
||||
Why: one rule with no exemption. Root-level pages were considered and rejected: they would give the
|
||||
prettier `/about/`, but they permanently share the root namespace with the engine, so every future
|
||||
root route (`/tags/`, `/search/`, `/feed.xml`) becomes a slug no page may ever use — a growing set of
|
||||
reserved words discovered years after publishing. Uniformity also keeps the resolver at one URL shape.
|
||||
Consequence: cheap — section feeds, indexes and queries map onto a URL prefix; the root stays entirely
|
||||
engine-owned, so emitted files and future routes need no collision check. Expensive — `/pages/about/`
|
||||
is a less handsome URL than `/about/`, and moving a bundle between sections changes its URL and needs
|
||||
an `aliases` entry, so the section list is effectively permanent once anything is published.
|
||||
Revisit if: never. This is the decision every shared URL depends on.
|
||||
|
||||
## ADR-0009 — Language routing: default at root, others prefixed
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: English (the default language) is served at the root; every other language is served
|
||||
under a `/<lang>/` prefix on the **same path** — `/pages/about/` and `/bn/pages/about/`. `/en/…` is never a
|
||||
live URL; it permanently redirects to the root form so it can never fork.
|
||||
Why: English is the front door, and Bengali must still be linkable, cacheable, and shareable as
|
||||
its own URL — which content negotiation on a single URL prevents.
|
||||
Consequence: one redirect rule; `hreflang` plus `canonical` emitted per bundle from the variants
|
||||
that exist; a third language costs nothing. Identity remains the slug (ADR-0004).
|
||||
Revisit if: Bengali becomes the dominant language of the site — and then it is an alias and
|
||||
default-language problem, not a routing rewrite.
|
||||
|
||||
## ADR-0010 — Deploy: self-hosted Docker, external infra permitted for derived state only
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: ship a single binary in a container on a self-hosted server. External infrastructure
|
||||
services (Redis, object storage, a search index) are permitted where they earn their place.
|
||||
Why: the full server shape keeps comments, search, and content negotiation possible, and the
|
||||
container makes the host itself disposable.
|
||||
Consequence: confirms ADR-0005 (server is the superset; export stays available as the same code
|
||||
path, now optional insurance rather than the target). The path-traversal guard becomes a hard
|
||||
pre-deploy blocker. Every infra client is a dependency and counts against `DEPS_MAX`.
|
||||
Constraint: external services may hold **only derived or disposable state** — cache, index,
|
||||
session, queue. Canonical content stays in the site repo. Test before adding one: *if this
|
||||
service vanishes, does a rebuild restore it, or is something lost?*
|
||||
Revisit if: the engine can no longer boot and serve correctly with every external service off.
|
||||
|
||||
## ADR-0011 — The site root is external to the engine repository
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: the engine is pointed at a **site root** — a directory outside this repository, versioned in
|
||||
its own git repo — holding `content/`, `static/`, and optionally `templates/` overriding the defaults
|
||||
the binary embeds. Selected by `-site <dir>` or `ATELIER_SITE`. This repository contains engine source
|
||||
only; no content, ever, not even an example.
|
||||
Why: content is the database, and a database does not live in the application's repo. Mixing them
|
||||
interleaves "fixed a typo in a poem" with "extracted the resolver" in one history, makes every typo a
|
||||
code deploy, and bloats every clone of the engine with image history.
|
||||
Consequence: cheap — content-only publishing without a rebuild, a second site is a second root rather
|
||||
than a fork, fixture sites live in `testdata/`, and the gate needs no content-exclusion rules.
|
||||
Expensive — two repos to track, and a disk-contract change can no longer migrate the author's files:
|
||||
it must ship a documented migration step or a subcommand, because the engine does not own that data.
|
||||
Revisit if: never usefully. The separation only pays better as content grows.
|
||||
|
||||
## ADR-0012 — Effect is the sixth primitive
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: work that happens outside the request path is an **Effect**, triggered on content change, on
|
||||
a schedule, or on demand. It absorbs what the harness previously called an "emitted file" and the
|
||||
`Emitter` slot in the extension contract — one name, not three. Definition and rules:
|
||||
`architecture.md`.
|
||||
Why: half the roadmap is not request-time — image derivatives, search index, sitemap and feed files,
|
||||
webmention *sending*, POSSE, link archiving, EPUB, future-dated publication. None reduce to the other
|
||||
five: `Interaction` is inbound by definition, and a Stage runs per render. Without this, those features
|
||||
are not leaves, so invariant 7 fails and invariant 9's freeze cannot be checked.
|
||||
Consequence: cheap — the Arc 3–4 networked layer becomes composition rather than core growth, and
|
||||
scheduling is an in-process ticker in the one binary. Expensive — a second trigger kind (the clock)
|
||||
means the engine has background work, so every Effect must be idempotent and its absence must degrade
|
||||
rather than break. Anything requiring a separate scheduler process is a trunk under ADR-0010.
|
||||
Revisit if: an Effect cannot be made idempotent, or scheduling genuinely needs a second process.
|
||||
|
||||
## ADR-0013 — Cache validity is one record with five axes
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: a cache entry carries a validity record — content dependencies, interaction fragment refs,
|
||||
`valid-until`, engine+template epoch, and a cacheable flag — and is served only while all five hold.
|
||||
`valid-until` is the minimum of the windows the render's Stages declare; Stages reach the clock through
|
||||
an injected accessor, never `time.Now()`. An optional scheduled Effect may pre-warm entries, but
|
||||
scheduling is never what makes a page correct.
|
||||
Why: invalidation has five causes, not one. Content edits and interactions were already modelled; time
|
||||
(old-article banner, relative dates, future-dated publication), epoch (a template fix silently serving
|
||||
old HTML), and opt-out (search, a random-page route) were not. A scheduled re-render sweep expresses
|
||||
only the time axis, and coarsely — relative dates would need a sweep finer than their own granularity.
|
||||
Consequence: cheap — the record maps onto HTTP semantics (ETag from deps plus epoch, `Expires` from
|
||||
`valid-until`, `no-store` for opt-out), so a reverse proxy or CDN in front is correct with no extra
|
||||
code, and a new time-dependent Stage needs no central registry because windows compose by minimum.
|
||||
Expensive — the Stage contract gains a return channel, and a Stage that reads the clock without
|
||||
declaring a window would serve staleness silently, which is why the clock accessor is enforced by
|
||||
`verify.sh` rather than trusted.
|
||||
Revisit if: windows cannot express some invalidation cause — then add an axis, do not add a mechanism.
|
||||
|
||||
## ADR-0014 — Content types are declared, not coded
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: each content type is a declaration — section, default view, ordering rule, feed membership,
|
||||
required frontmatter, whether titleless is legal, applicable taxonomies. The binary embeds a default set
|
||||
(`post`, `comic`, `art`, `writing`, `page`, `status`); a declaration file in the site root extends or
|
||||
overrides it. Adding a type costs a declaration plus a template and no core change; an unparseable or
|
||||
incoherent declaration is a loud startup failure.
|
||||
Why: six types already carry per-type behaviour, described in prose. Prose becomes either a `switch type`
|
||||
in the core — forbidden, and it would grow with every new type — or unwritten template convention. A
|
||||
declaration is the only form that keeps the seventh type as cheap as the sixth.
|
||||
Consequence: cheap — a new type is a site-repo change with no engine deploy (ADR-0011), and the content
|
||||
`check` command gets its validation rules from the same declarations for free. Expensive — the engine
|
||||
must treat the type set as data, so nothing may assume a fixed list, and per-type behaviour reachable
|
||||
only from code (a bespoke View) still needs a template rather than a branch.
|
||||
Revisit if: a type needs behaviour no declaration can express — then it is a View or an Effect, not a
|
||||
new field on every type.
|
||||
|
||||
## ADR-0015 — Normalise to NFC everywhere; derive slugs by locale, override by hand
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: every string entering the engine as an identifier — filenames, bundle keys, taxonomy terms,
|
||||
frontmatter slugs, request paths — is normalised to NFC at that boundary, unconditionally and with no
|
||||
opt-out. Slug *derivation* is separate and locale-aware: default rules come from the site's default
|
||||
locale, and any derived slug may be overridden by hand — `slug` on a bundle, and a term-to-slug mapping
|
||||
for taxonomy and section segments.
|
||||
Why: Bengali conjuncts have several byte encodings for identical-looking text, macOS hands back NFD, git
|
||||
and editors pass through whatever they are given. Un-normalised, two visually identical files produce
|
||||
different bundle keys and therefore different URLs, and a request never matches the page it names. This
|
||||
is unfixable after publication except by accumulating aliases. Normalisation is correctness and cannot be
|
||||
optional; romanisation and casing are taste and must be overridable.
|
||||
Consequence: cheap — one chokepoint, and comparisons become byte comparisons again. Expensive — adds
|
||||
`golang.org/x/text` (no transitive dependencies), the first entry on the allowlist, and every identifier
|
||||
boundary must route through the normaliser rather than accepting a raw string.
|
||||
Revisit if: never for normalisation. Slug derivation rules change with the default locale.
|
||||
|
||||
## ADR-0016 — Sequence position is metadata and never appears in a URL
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: a bundle's slug is its name, never its position — `comics/the-long-monsoon/the-flood/`, not
|
||||
`.../02-the-flood/`. Position comes from a single declared source per type (`order` in the type
|
||||
declaration: date, sequence, or manual), sparse by convention (10, 20, 30) so inserting between two
|
||||
members is one edit. Sequence resolution — first, prev, next, last, index, count, honouring drafts and
|
||||
language fallback — is defined once and shared by comics, serial fiction and multi-part essays. A slug
|
||||
may therefore contain slashes, which clarifies ADR-0008's single-segment reading.
|
||||
Why: with position in the path, inserting a chapter between 3 and 4 renumbers everything after it, which
|
||||
renames directories, changes bundle keys, changes published URLs, and demands an `aliases` entry for
|
||||
each — one editorial decision becoming a permalink event, against ADR-0008. Two sources of truth
|
||||
(filename prefix and frontmatter field) also drift, so the archive and the prev/next links can disagree.
|
||||
Consequence: cheap — insertion is local and free, and prev/next exists once rather than three times.
|
||||
Expensive — ordering is invisible in a directory listing, so authors read it from frontmatter, and
|
||||
`order` values want leaving gaps.
|
||||
Revisit if: never. Position in a permalink is the mistake this exists to prevent.
|
||||
|
||||
## ADR-0017 — Settings cascade: site → section → bundle
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: settings resolve down a cascade — site declaration, then each enclosing section's `_index`,
|
||||
then the bundle's own frontmatter — with the nearest explicit value winning. The cascade carries stage
|
||||
toggles, view selection, taxonomy defaults, cache flags, and metadata defaults; it carries *declared*
|
||||
keys only, never arbitrary engine internals. Stages apply to everything by default and are switched off
|
||||
by a cascade key, not by a predicate compiled into the stage.
|
||||
Why: stages are mostly no-ops where they do not apply, and their early return is a check on content
|
||||
shape ("no images here") which is type-independent and belongs in the stage anyway. The cases that really
|
||||
need control are narrower and cut across types — dithering wrong on one diagram, autolinking wrong in one
|
||||
poem — which a per-type stage set cannot express and a cascade can. It also unifies with template
|
||||
selection, which wants the same resolution order.
|
||||
Consequence: cheap — one resolution rule covers rendering, presentation and metadata defaults; a section
|
||||
sets a policy once for everything beneath it. Expensive — resolution must be cheap and cached, since it
|
||||
runs per bundle, and the set of cascadable keys must stay declared or it becomes unbounded config.
|
||||
Revisit if: cascade resolution shows up in a render-path profile.
|
||||
|
||||
## ADR-0018 — Global flat tags, declared structural taxonomies
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: `tags` is one global namespace across every type — `/tags/{tag}/` lists everything carrying it,
|
||||
`/{section}/tags/{tag}/` narrows to a section, and listing views group results by type. Structural
|
||||
metadata with known terms that drives behaviour — `series`, `medium`, `genre` — is a *declared* taxonomy
|
||||
on the type (ADR-0014) and never enters the tag pool. Feeds follow the same shape: `/feed.xml` carries
|
||||
every type declared `primary`, `/{section}/feed.xml` carries a section, `/tags/{tag}/feed.xml` falls out
|
||||
of the same Query.
|
||||
Why: cross-type discovery is the point of a single-author site — one tag spanning a comic, a poem and a
|
||||
photo essay is a feature. Per-section tag pools would fragment that for a readability problem better
|
||||
solved by grouping in the View. But a tag is free-form and cross-cutting, while a structural taxonomy has
|
||||
a fixed term set and changes what the engine does; conflating them makes both worse.
|
||||
Consequence: cheap — one Query with an optional section predicate serves tag pages, section tag pages and
|
||||
their feeds. Expensive — tag hygiene is now the author's discipline, since nothing scopes them; the
|
||||
content `check` command should report near-duplicate terms.
|
||||
Revisit if: the tag pool becomes unusable in practice — and then the answer is curation, not namespacing.
|
||||
|
||||
## ADR-0019 — Templates: per-type sets, block-level override, cascade selection
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: one parsed template set per type, each set being base plus partials plus that type's
|
||||
definitions. A site root override is parsed *after* the embedded defaults into the same set, so it may
|
||||
redefine a single named block and inherit everything else. Which set renders a bundle resolves through the
|
||||
cascade (ADR-0017): type default, section override, then the bundle's own `view`.
|
||||
Why: `html/template` has no `extends` — inheritance is "last definition of a name wins in a parsed set",
|
||||
so a global set makes two types defining `main` collide, and per-type sets are the only clean answer.
|
||||
File-level override would force copying a whole template to change one block, after which it stops
|
||||
inheriting engine improvements; block-level costs a few lines of parse ordering and keeps site overrides
|
||||
minimal.
|
||||
Consequence: cheap — a site writes only what it changes, and item-level overrides need no new mechanism.
|
||||
Expensive — parse order becomes load-bearing and must be asserted in a test, since a silently wrong order
|
||||
means an override that quietly does nothing.
|
||||
Revisit if: never cheaply — every template ever written assumes this convention.
|
||||
|
||||
## ADR-0020 — YAML is the author-facing format; allowlist `gopkg.in/yaml.v3`
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: frontmatter and the site declaration (`site.yaml` at the site root) are YAML, parsed by
|
||||
`gopkg.in/yaml.v3` — one dependency, no transitive requirements, one parser for both.
|
||||
Why: frontmatter is the author's primary interface with the engine and must behave exactly as they
|
||||
expect. Go has no stdlib YAML, and the alternatives are worse: JSON is hostile to hand-write, TOML costs
|
||||
a dependency anyway, and a hand-rolled subset would diverge from real YAML in ways only discovered while
|
||||
writing a post. This is the case `conventions.md`'s "40 lines over a dependency" rule does not cover —
|
||||
the 40 lines would be wrong in edge cases the author cannot predict.
|
||||
Consequence: cheap — one parser for frontmatter, the site declaration and the cascade; anchors and
|
||||
multi-line strings work as authors expect. Expensive — the third allowlist entry, and YAML's own traps
|
||||
(the Norway problem, tabs) become the engine's to document rather than to invent.
|
||||
Revisit if: the parser proves a maintenance burden, or the author-facing format changes — and the second
|
||||
requires migrating every existing file.
|
||||
|
||||
|
||||
## ADR-0021 — The default locale's suffix is optional, permanently
|
||||
Date: 2026-07-28 · Status: accepted (supersedes ADR-0004's instruction to adopt `slug.en.md` from day one)
|
||||
Decision: a missing language suffix means the default locale, always — not merely while one language
|
||||
exists. `about.md` and `about.en.md` name the same variant and the parser accepts both. Identity still
|
||||
excludes language (ADR-0004's actual point, unaffected).
|
||||
Why: ADR-0004 told the *author* how to name files, which is not the engine's business — the site root
|
||||
belongs to its owner (ADR-0011). The engine's job is to accept both spellings and derive the same bundle
|
||||
key from either. Requiring a suffix bought nothing: identity is already language-free by construction.
|
||||
Consequence: cheap — nothing in the harness prescribes a filename any more, and a single-language site
|
||||
never types a suffix. Expensive — the parser must treat two spellings as one variant and reject a bundle
|
||||
that supplies both, since that is ambiguous rather than harmless.
|
||||
Revisit if: never. This removes a rule rather than adding one.
|
||||
|
||||
## ADR-0022 — Content change is detected by polling the site root
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: the engine detects change by walking the site root and comparing modification times, on an
|
||||
interval set by one flag (`-poll`, zero to disable for immutable deployments), and acting only after a
|
||||
settle window with no further changes. Stdlib only — no filesystem-watcher dependency. Fetching is not
|
||||
the engine's job: something on the host updates the site root, and the engine notices. The site root is
|
||||
therefore a mounted volume in the container, never baked into the image.
|
||||
Why: the engine already runs against a directory, so the filesystem is the obvious signal, and it needs no
|
||||
inbound endpoint — the untrusted surface stays empty until webmentions or Micropub actually arrive. A
|
||||
watcher would cost a dependency for what a short walk does, and inotify is unreliable on exactly the
|
||||
places this will run: bind mounts, overlay and network filesystems. A poll is boring and works everywhere.
|
||||
Consequence: cheap — change detection is the `on change` trigger from ADR-0012 with nothing new, and a
|
||||
template edit in the site root invalidates through the same path as content. Expensive — a checkout writes
|
||||
many files, so the settle window is required rather than optional; editor droppings and `.DS_Store` must
|
||||
be ignored or the engine re-renders continuously; and detection latency is bounded by the interval.
|
||||
Revisit if: a site grows large enough that walking it costs real time — and then the fix is a cheaper
|
||||
signal, not a watcher dependency.
|
||||
|
||||
## ADR-0023 — Three edit surfaces; this repo is bound to the theme contract, not the theme
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: engine source, content, and theme are three separate surfaces with three separate owners. This
|
||||
repository holds the engine and is bound to the **theme contract** — the data available to templates, the
|
||||
template and block names it looks for, the helpers it provides, the URLs it emits — recorded in
|
||||
`docs/theme-contract.md`. It is not bound to any theme's markup, layout, or styling. Where a theme comes
|
||||
from (a directory in the site root, its own repo checked out into place) is deliberately outside this
|
||||
repo's concern: the engine knows a path and a contract.
|
||||
Why: a request that reads as one feature is often split — "supporting files listed in a sidebar" is a
|
||||
contract change here and a layout decision there. Absorbing the theme half into the engine puts markup
|
||||
and presentation choices into the core, which grows it against invariant 9 and makes the theme
|
||||
unswappable. Keeping the boundary at the contract is what lets the theme change without an engine release
|
||||
and the engine change without rewriting a theme.
|
||||
Consequence: cheap — a split request produces a contract extension plus a written note of what the theme
|
||||
must do, and the theme side is somebody's separate change. Expensive — the contract is now a published
|
||||
interface: fields may be added, never renamed or removed, which is the View-layer freeze in
|
||||
`architecture.md` arriving earlier than Arc 2. Embedded default templates are a reference implementation
|
||||
of the contract, not the contract itself.
|
||||
Revisit if: never usefully. Merging the surfaces is how a publishing engine becomes one site's code.
|
||||
|
||||
## ADR-0024 — Bundle-local assets inherit the bundle's publish status; `-dev` reveals
|
||||
Date: 2026-07-29 · Status: accepted
|
||||
Decision: every byte served from inside a bundle — body, cover image, any local asset, anything under the
|
||||
extras directory — inherits that bundle's publish status, derived per request rather than stored. An
|
||||
unpublished bundle and all its assets answer **404**, never 403. Reaching asset bytes without having
|
||||
resolved the owning bundle is structurally impossible, not merely discouraged: one guard, every route.
|
||||
`-dev` (default off) reveals drafts and future-dated bundles with their assets, and is the only thing that
|
||||
changes the answer.
|
||||
Why: without this, a draft's `cover.jpg` is world-readable while its page is not, and notes about
|
||||
unfinished work leak through the asset path — which looks like static file serving, and static file serving
|
||||
looks like it needs no context. That is the shape this bug always takes. 403 would confirm the work exists,
|
||||
which for drafts is itself the thing worth not leaking.
|
||||
Consequence: cheap — visibility is one derived predicate with no state to keep in sync, and a future-dated
|
||||
bundle's 404 carries `valid-until` = its publish time (ADR-0013), so it expires exactly when it should
|
||||
rather than needing a sweep. Expensive — no route may serve bundle bytes by path alone, so a fast static
|
||||
path for assets is off the table; and `-dev` becomes security-relevant, so it must default off and be
|
||||
obvious when on.
|
||||
Revisit if: never. The generalisation from "extras are public" to "assets inherit visibility" is the whole
|
||||
point of the entry.
|
||||
|
||||
## ADR-0025 — Extras: local assets, enumerated and browsable
|
||||
Date: 2026-07-29 · Status: accepted
|
||||
Decision: a bundle may hold a directory of supporting files — default `extras/`, named by a cascade key —
|
||||
which the bundle scanner **skips entirely**: a `.md` in there is an asset, never a bundle. The engine
|
||||
enumerates it as a tree, classifies each entry by extension, renders the ones it can (markdown, plain
|
||||
text), and serves the rest as bytes behind the ADR-0024 guard. Two behaviours on one route:
|
||||
`…/extras/{path}` renders the listing with that entry selected, `?raw` returns the bytes. Sorted by
|
||||
filename; excluded from feeds, queries and search.
|
||||
Why: drafts, notes and logs are worth publishing as artefacts of the process, and they are not bundles —
|
||||
no frontmatter, no identity, no language variants. `architecture.md` already defines a Bundle as carrying
|
||||
local assets; the only thing missing was enumerating them instead of merely referencing them relatively.
|
||||
Consequence: cheap — no new primitive, and selecting an entry is an ordinary link, so the whole feature
|
||||
works without JavaScript. Expensive — the scanner needs an exclusion rule it did not have, and the extras
|
||||
segment becomes a name no child of a bundle may use, which is why it is a cascade key rather than a
|
||||
constant.
|
||||
Revisit if: extras need per-file metadata — and then they are bundles after all, and this entry was wrong.
|
||||
|
||||
## ADR-0026 — The engine ships a minimal reference theme
|
||||
Date: 2026-07-29 · Status: accepted
|
||||
Decision: the binary embeds a reference theme — templates plus one small stylesheet — sufficient to render
|
||||
every declared type and the extras view, with semantic HTML and no JavaScript. It exists to make the theme
|
||||
contract executable: a bare site root renders, and a golden-file test through it catches contract
|
||||
regressions. It is deliberately not a design: legibility only, no branding, no visual opinions, and it
|
||||
demonstrates every contract feature and nothing more.
|
||||
Why: a contract nobody implements is a contract nobody has tested. Without a reference theme, the first
|
||||
real theme discovers the contract's gaps, and `docs/theme-contract.md` stays aspirational. It also means
|
||||
someone can point the binary at a folder of Markdown and see a site, which is the whole promise.
|
||||
Consequence: cheap — templates and CSS are not Go, so they cost nothing against `CORE_LOC_MAX`, which is
|
||||
the same incentive that pushes presentation out of the core. Expensive — the reference theme is a
|
||||
maintenance obligation that grows with the contract, and if it ever becomes handsome it becomes the theme
|
||||
nobody replaces, which is why "minimal" is a rule and not a preference.
|
||||
Revisit if: it starts accumulating design decisions — then split it into a reference theme and a real one.
|
||||
|
||||
## ADR-0027 — Feature locality: one directory, no sibling imports, a mandatory `doc.go`
|
||||
Date: 2026-07-30 · Status: accepted
|
||||
Decision: a feature is one directory under `internal/ext/<name>/` from its first use, plus one line in
|
||||
`cmd/atelier/wire.go` — the only file that knows every feature. No `internal/ext` package may import
|
||||
another. Every one carries a `doc.go` stating, in four lines: what it contributes, which cascade keys it
|
||||
reads, which theme-contract fields it adds, and what it deliberately does not do. A feature may import
|
||||
`internal/content` and `internal/render`; never `internal/web`, never `cmd/`, never a sibling.
|
||||
Why: the cost that matters when an agent adds a feature is how much of the codebase it must read first.
|
||||
Sibling imports are what make a read set compound — understand one feature, then two, then four. A
|
||||
`doc.go` in a fixed shape turns orientation into a fifteen-line read instead of a two-hundred-line one.
|
||||
Routes reach `web` by being assembled in `wire.go`, so `web` never learns features exist and the
|
||||
dependency arrows hold.
|
||||
Consequence: cheap — adding a feature is a new directory and one wiring line, so the diff is local and
|
||||
the read set is roughly the extension types plus `wire.go`. Shared logic between two features must move
|
||||
inward (earning it under the counters) or stay duplicated until the third use, which is the existing rule
|
||||
rather than a new one. Expensive — `wire.go` absorbs all the coupling on purpose and will look
|
||||
repetitive; and this buys cheap *features*, not cheap spine changes, which still need the core read.
|
||||
Revisit if: `wire.go` becomes hard to read, which means the registry from `extensions.md` is due.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Exploration catalog
|
||||
|
||||
Possible futures. Nothing here is committed. Presence in this list is not permission to build.
|
||||
|
||||
`★` = highest fit-to-effort for this project's identity and values.
|
||||
|
||||
## The leaf/trunk test — apply before any of this gets built
|
||||
|
||||
Definitions: `architecture.md` invariant 7. **Leaf** → buildable when wanted. **Trunk** → waits for an
|
||||
explicit human decision, in an ADR, on purpose.
|
||||
|
||||
Known trunks: ActivityPub, IPFS, Solid, hosted annotation servers, CRDT-backed editing.
|
||||
Sovereignty test for any of them: *if this vanishes, do I lose data, or only convenience?*
|
||||
|
||||
Use `/leaf <topic>` to get a verdict written into this file.
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Item | Verdict | Reduces to | Note |
|
||||
|---|---|---|---|
|
||||
| (empty — filled by `/leaf`) | | | |
|
||||
|
||||
## Catalog
|
||||
|
||||
**IndieWeb** — Micropub★, Microsub, Webmention, WebSub, microformats2, IndieAuth, rel=me,
|
||||
PESOS/POSSE, Bridgy and Bridgy Fed, Vouch.
|
||||
|
||||
**Protocols** — Gemini + gemtext★, Gopher, Nostr (NIP-23 long-form), ActivityPub *(trunk)*,
|
||||
AT Protocol, SSB, Solid *(trunk)*, IPFS *(trunk)*, Hypercore,
|
||||
content negotiation (one URL → many formats).
|
||||
|
||||
**Small web and discovery** — webrings, guestbooks, 88×31 buttons, finger, tildeverse,
|
||||
OPML blogroll★, full-text feeds★ (for indie search indexing: Marginalia, Kagi Small Web,
|
||||
ooh.directory, indieblog.page, feedle, powRSS), random / "surprise me", `/now` pages.
|
||||
|
||||
**Reading and annotation** — Web Annotation + Hypothesis *(hosted = trunk)*, reading progress,
|
||||
interlinear and parallel translation.
|
||||
|
||||
**Permanence** — outbound-link archiving★, WARC self-archive, Memento over git, permalink
|
||||
discipline, exposed revision history.
|
||||
|
||||
**Identity and integrity** — rel=me, Keyoxide, signing (age/minisign/PGP), C2PA, DIDs.
|
||||
|
||||
**Feeds and data** — RSS/Atom, JSON Feed, h-feed, OPDS (publication catalog), JSON-LD and
|
||||
schema.org, OpenGraph, Dublin Core, sitemaps, RSS-to-email.
|
||||
|
||||
**Page as program** — Ink★, Twine, Inform 7, PICO-8/TIC-80, Godot HTML5 embeds, explorable
|
||||
explanations, TiddlyWiki and single-file artifacts.
|
||||
|
||||
**Print** — Typst, Pandoc, EPUB and PDF chapbooks, on-demand anthologies.
|
||||
|
||||
**Client side** — View Transitions★ (palette-shift-as-navigation), Houdini, Canvas, WebGL,
|
||||
WebGPU, modern CSS, WASM, islands, HTMX, Web Components, PWA offline, prefetch,
|
||||
accessibility and `prefers-reduced-motion`.
|
||||
|
||||
**Build time** — SQLite FTS5, Pagefind, sqlite-wasm + OPFS, Djot, Templ, libvips, ThumbHash and
|
||||
BlurHash, content-addressed and Merkle builds, Datasette, CRDTs *(heavy, trunk)*.
|
||||
|
||||
**Aesthetic** — dithering and palette Stage★, ANSI/PETSCII, teletext, demoscene, e-ink View.
|
||||
|
||||
**Values** — `robots.txt` / `ai.txt` (training control), `llms.txt` (low proven impact),
|
||||
sustainable and low-carbon web, privacy-respecting analytics or none at all, sovereignty,
|
||||
sneakernet and QR offline distribution.
|
||||
|
||||
## Standing notes
|
||||
|
||||
- **Bengali tokenisation for search** is the one item here nobody else will solve for you.
|
||||
Treat it as original work, not as a checkbox inside "add search."
|
||||
- **Micropub before an admin panel.** It buys an ecosystem of existing editors for the price of
|
||||
one endpoint, and it composes with git instead of fighting it.
|
||||
- **Gemini output** is nearly free once the View layer exists, and it validates the claim that a
|
||||
View can target something other than HTML. Good early proof, low cost.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Extensions
|
||||
|
||||
The plugin story, and the gate keeping it from arriving early.
|
||||
|
||||
**STATUS: not buildable yet.** A feature is its own directory under `internal/ext/<name>/`, called
|
||||
explicitly from `wire.go` (ADR-0027) — correct and sufficient until the counters say otherwise. This document exists so the eventual shape is known, not
|
||||
so it can be built now.
|
||||
|
||||
## The gate
|
||||
|
||||
| Stage of growth | What a feature looks like | Trigger to advance |
|
||||
|---|---|---|
|
||||
| Now (0–2 features) | Its own directory under `internal/ext/<name>/`, called explicitly from `wire.go` | — |
|
||||
| Transform counter due | Extract the Stage pipeline: an ordered `[]Stage` in one wire file | `state.md` |
|
||||
| Extension counter due | Extract the `Extension` struct below; move each into `internal/ext/<name>` | `state.md` |
|
||||
| After Arc 2 | Composition only; the core no longer grows | Arc 2 closes |
|
||||
|
||||
`state.md` holds the thresholds and is the only place they are written. Do not extract early. Do not
|
||||
"prepare".
|
||||
|
||||
## Target shape
|
||||
|
||||
Compile-time registry. No `plugin.so`, no `init()` side effects, no discovery, no config file listing
|
||||
plugins. One slice, one file, source order — the order *is* the semantics.
|
||||
|
||||
Compile-time is not a preference: Go's `plugin` package forbids a static binary and demands an exact
|
||||
toolchain match, which the container target (ADR-0010) rules out. Dynamic loading buys only
|
||||
extension-without-recompiling, worth nothing to the single author (ADR-0006) holding commit access.
|
||||
The registry costs a few hundred lines that render zero pages — hence real callers before a contract.
|
||||
|
||||
```go
|
||||
// internal/ext/ext.go — the whole contract, once earned.
|
||||
type Extension struct {
|
||||
Name string
|
||||
Stages []Stage // ordered; Phase decides placement
|
||||
Views map[string]View // named, referenced by frontmatter `view:`
|
||||
Shortcodes map[string]Shortcode // trusted content only (ADR-0003)
|
||||
Effects []Effect // derived artifacts and outbound calls, off the request path
|
||||
Adapters []Adapter // Interaction sources, Arc 3
|
||||
Routes []Route // additional URL cases, via the resolver
|
||||
}
|
||||
```
|
||||
|
||||
`cmd/atelier/wire.go` holds the only list of enabled extensions. Enabling or disabling one is a
|
||||
one-line diff and a rebuild. Removing one leaves no trace elsewhere — that property is the test of
|
||||
whether the contract is right.
|
||||
|
||||
## Stage phases
|
||||
|
||||
An ordered list, not a dependency graph. Two stages needing a graph to be correct are one stage
|
||||
wearing a disguise.
|
||||
|
||||
| Phase | Operates on | Examples |
|
||||
|---|---|---|
|
||||
| `PhaseLoad` | raw bytes + frontmatter | includes, translation fallback |
|
||||
| `PhaseParse` | the parsed Markdown tree | shortcodes, transclusion, image derivatives |
|
||||
| `PhaseMarkup` | rendered HTML fragments, code spans skipped | smart quotes, dashes, widows, Bengali numerals |
|
||||
| `PhasePage` | the assembled page object | OpenGraph, JSON-LD, related posts, series nav |
|
||||
| `PhaseOutput` | the final byte stream | minification, dithering, gemtext conversion |
|
||||
|
||||
Every Stage runs on every bundle unless the cascade disables it (ADR-0017), and declares its trust
|
||||
requirement. A Stage evaluating templates or shortcodes runs in trusted mode only, and the pipeline
|
||||
refuses it otherwise — enforced in code, not by convention, and
|
||||
tested with an untrusted-input case.
|
||||
|
||||
## Rules for any extension
|
||||
|
||||
1. Deletable without trauma. Removing the package leaves the engine building and serving.
|
||||
2. Reads only what already exists on the page; adds through the `Extra` bag, never by widening the
|
||||
core struct for its own convenience.
|
||||
3. Owns its output files under a namespaced path, or none.
|
||||
4. No new dependency without an ADR — extensions get no looser budget than the core, and their Go
|
||||
lines count against `EXT_LOC_MAX`. Presentation features (OpenGraph, galleries, series nav,
|
||||
related posts) belong in templates and frontmatter where they cost nothing; reach for Go only when
|
||||
there is real logic.
|
||||
5. Failure degrades: a broken extension logs and is skipped, never takes a request down.
|
||||
6. Needing a permanent external service or a primitive change makes it a **trunk** — see
|
||||
`exploration.md`.
|
||||
7. One directory, no sibling imports, and a `doc.go` in this shape (ADR-0027):
|
||||
|
||||
```go
|
||||
// Package feeds emits RSS and Atom for the primary feed and each section.
|
||||
//
|
||||
// Contributes: Effect (on change).
|
||||
// Cascade keys: feeds.enabled, feeds.limit.
|
||||
// Contract fields: none.
|
||||
// Not doing: JSON Feed, WebSub — separate features if wanted.
|
||||
package feeds
|
||||
```
|
||||
|
||||
## ContentAPI
|
||||
|
||||
A thin internal write path, introduced **with comments** in Arc 3 and not before. It exists so a
|
||||
second client (admin panel, Micropub endpoint) becomes possible without the engine growing a UI.
|
||||
Read paths keep going straight to the filesystem; git remains the source of truth.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Roadmap
|
||||
|
||||
Arcs, not sprints. An arc ends when its earn-triggers have fired, not on a date. Nothing from a later
|
||||
arc gets built early "since we are in the file anyway."
|
||||
|
||||
## Arc 1 — the spine
|
||||
- HTTP server, one binary, no cache.
|
||||
- Bundle with language variants and fallback (ADR-0004).
|
||||
- Permalinks per ADR-0008, including aliases and permanent redirects.
|
||||
- Language routing per ADR-0009: English at root, `/bn/` prefix, `/en/…` redirects to root.
|
||||
- Path traversal guard before anything faces the internet. The target is a self-hosted container
|
||||
(ADR-0010), so "not yet internet-facing" expires at the first deploy.
|
||||
|
||||
**Done when:** a URL reliably maps to a bundle, in two languages, with URLs you are willing to promise
|
||||
are permanent.
|
||||
|
||||
## Arc 2 — earn the primitives at first real use
|
||||
In this order, each at its trigger, never before:
|
||||
1. **Query** — at the first collection page (section index, latest posts).
|
||||
2. **Stage pipeline** — when the transform counter is due.
|
||||
3. **View layer** — per-bundle override; the data contract is already frozen by then (ADR-0023).
|
||||
|
||||
**Done when:** the View layer lands. **The core stops growing here.** Everything after is
|
||||
composition; core growth past this point means the plan was wrong. Record the `core` number
|
||||
`verify.sh` prints on freeze day in the retro line — from then on it is the figure that must not move.
|
||||
|
||||
## Arc 3 — the networked layer
|
||||
- Feeds, JSON-LD, sitemaps, microformats — all Query + Stage + Effect.
|
||||
- Interaction primitive: comments, with the ADR-0003 trust boundary.
|
||||
- A thin internal ContentAPI, introduced with the comment write path — not before.
|
||||
- Access control as exactly one permission check, in one place.
|
||||
|
||||
**Done when:** an external party can react to a page without touching the content graph.
|
||||
|
||||
## Arc 4 — pure composition
|
||||
IndieWeb adapters (Micropub first — it buys an existing editor ecosystem for free), then ActivityPub
|
||||
only if still wanted, then delight: backlinks, transclusion, page-as-program, revision-as-craft.
|
||||
Optional admin panel here, as a second client over the ContentAPI.
|
||||
|
||||
## Cross-cutting — when actually needed
|
||||
Cache (when requests feel slow) — in-process first; Redis only once a second process must share it,
|
||||
and never as the only copy of anything (ADR-0010). Containerisation: one Dockerfile, one binary, no
|
||||
orchestration until a second service exists, and the site root is a mounted volume rather than baked in
|
||||
(ADR-0022). Updating that volume is the host's job; the engine only notices.
|
||||
Admin/editor deferred — git already works, and Micropub may remove the need entirely.
|
||||
|
||||
## Admin/editor position
|
||||
Reframed as a second client over the thin internal ContentAPI. If built: a Markdown editor with live
|
||||
preview, not WYSIWYG. Better: a Micropub endpoint, which buys an ecosystem of existing editor apps and
|
||||
requires no UI. Likely optional forever, given git.
|
||||
|
||||
## Closing an arc
|
||||
Earn-triggers fired, `/invariants` run and clean, latent list triaged (fixed, scheduled, or accepted with
|
||||
a reason), retro line written in `state.md`.
|
||||
|
||||
## Governors
|
||||
- No abstraction before its second concrete use; the counters in `state.md` decide, not intuition.
|
||||
- Own the data; borrow only infrastructure you can walk away from. Test: *if this vanishes, do I lose
|
||||
data or just convenience?*
|
||||
- Every feature is a leaf (`architecture.md` invariant 7). Trunks wait for a human decision.
|
||||
- Typography split: smart quotes, dashes, ellipses, widow prevention = server-side markup-aware Stage
|
||||
after Markdown parse, skipping code spans. Glyph shaping = the browser's job. Ligatures,
|
||||
text-balance, variable fonts = CSS.
|
||||
|
||||
## Bengali and context specifics
|
||||
Unicode slugs, NFC-normalised at every identifier boundary with hand overrides for terms and sections
|
||||
(ADR-0015). Bengali numerals and relative dates are a cheap Stage (`২ ঘণ্টা আগে`) applied to **chrome** —
|
||||
UI strings and server-pulled dynamic fragments — never to authored body text, which stays as written.
|
||||
Bengali tokenisation for search is the genuinely novel problem here — solve it well
|
||||
and deliberately, not as a side effect of adding search. The low-bandwidth, low-carbon ethos is
|
||||
coherent with Gemini output, PWA offline, and no-JS defaults; let that coherence break ties when two
|
||||
designs are otherwise equal.
|
||||
@@ -0,0 +1,54 @@
|
||||
# State
|
||||
|
||||
**Verified against:** `<commit sha>` on `<date>` — update this line every change.
|
||||
If this file disagrees with the code, the code is right and this file is a bug.
|
||||
|
||||
## Inventory
|
||||
|
||||
No Go source, no `go.mod`. The harness is installed; the engine is unwritten. This repo holds engine
|
||||
source only — the site root is external and passed with `-site` (ADR-0011).
|
||||
|
||||
Dependencies: none.
|
||||
|
||||
## Counters — the earn-it authority
|
||||
|
||||
Never anticipate a threshold. Increment when the code lands, then check whether the extraction is *due
|
||||
this change*.
|
||||
|
||||
| Counter | Now | Extraction due at | What it buys |
|
||||
|---|---|---|---|
|
||||
| Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) |
|
||||
| Routing cases | 0 | **2** | Resolver extraction |
|
||||
| Collection pages | 0 | **1** | Query primitive |
|
||||
| Views / output formats | 0 | **2** | View layer (contract per `theme-contract.md`) |
|
||||
| Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) |
|
||||
| Extensions | 0 | **3** | Extension registry + wire file (`extensions.md`) |
|
||||
| Interface implementations | — | **2** | The interface itself |
|
||||
| Non-stdlib dependencies | 0 | budget in `scripts/budgets.env` | — |
|
||||
|
||||
Allowlisted but not yet required: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
|
||||
`gopkg.in/yaml.v3` (frontmatter, ADR-0020).
|
||||
|
||||
## Latent items — known, deliberately unfixed
|
||||
|
||||
Do not fix these mid-feature. They become features when the human says so. An arc does not close
|
||||
with an untriaged item: at each arc boundary every row is fixed, scheduled into an arc, or accepted
|
||||
with a stated reason. A list nothing drains is a graveyard of known defects.
|
||||
|
||||
| Item | Why it waits | Trigger to fix |
|
||||
|---|---|---|
|
||||
| Path traversal guard on URL → file mapping | Not yet internet-facing | **Before first public deploy — hard blocker; the target is a real server (ADR-0010). Nothing mechanical enforces this — `verify.sh` does not read this list. Make it a table-driven test when the first file read lands.** |
|
||||
| No mechanical check that the counters are *correct* | The coupling gate makes forgetting them impossible, which is the real failure mode; checking values needs code to count | 3rd transform or 2nd route |
|
||||
| No mechanical gate on the untrusted boundary (ADR-0003) | Nothing untrusted exists yet | The comment path, Arc 3 — a test that untrusted input reaches no shortcode or template evaluation |
|
||||
|
||||
## Open questions blocking Arc 1
|
||||
|
||||
None. Every decision the engine needs before Arc 1 and before the first deploy is recorded.
|
||||
|
||||
Every ADR in `decisions.md` is accepted; none is open or proposed.
|
||||
|
||||
## Arc retro log
|
||||
|
||||
One line per completed arc: what it cost, what it taught, what it made unnecessary.
|
||||
|
||||
- (empty)
|
||||
@@ -0,0 +1,79 @@
|
||||
# Theme contract
|
||||
|
||||
What the engine promises a theme, and the only thing this repository is bound to (ADR-0023). A theme's
|
||||
markup, layout and styling are not the engine's business; a theme's *inputs* are.
|
||||
|
||||
**STATUS: not built.** This is the shape the contract takes when the first template renders. Everything
|
||||
here is engine obligation, not theme instruction — a theme may ignore any of it.
|
||||
|
||||
## The stability rule
|
||||
|
||||
Fields and names are **added, never renamed or removed**. Absence is always legal: a template reading a
|
||||
field that does not exist gets the zero value and must not crash, and the engine must not make a missing
|
||||
field fatal at request time (ADR-0002, invariant 1). This is the View-layer freeze from
|
||||
`architecture.md`, arriving as soon as a theme exists rather than at Arc 2.
|
||||
|
||||
Breaking the contract is not a feature — it is a new contract version, and it needs an ADR.
|
||||
|
||||
## What the engine provides
|
||||
|
||||
| Provides | Detail |
|
||||
|---|---|
|
||||
| the page object | known fields plus an `Extra` bag carrying unknown frontmatter (ADR-0002) |
|
||||
| the resolved cascade | settings for this bundle after site → section → bundle resolution (ADR-0017) |
|
||||
| queries the page needs | its sequence neighbours, its taxonomy terms, its section's members |
|
||||
| named template lookup | per-type sets; a theme redefines a named block and inherits the rest (ADR-0019) |
|
||||
| URLs | every path the engine emits, so a theme never constructs one by hand |
|
||||
| per-page assets | the `styles` / `scripts` frontmatter lists, resolved relative to the bundle |
|
||||
| chrome strings | looked up by key and language, never hardcoded English in a template |
|
||||
| validity windows | a template that renders time-dependent output declares one (ADR-0013) |
|
||||
|
||||
## Extras view
|
||||
|
||||
For a request under a bundle's extras directory (ADR-0025) the engine additionally provides:
|
||||
|
||||
| Field | Contents |
|
||||
|---|---|
|
||||
| `.Extras.Entries` | the tree: `Name`, `Path`, `URL`, `RawURL`, `Kind`, `Size`, `IsDir`, `ModTime` |
|
||||
| `.Extras.Selected` | nil on the bare listing; otherwise the chosen entry |
|
||||
| `.Extras.Selected.HTML` | rendered output for `markdown` and `text` kinds; empty otherwise |
|
||||
| `.Extras.Selected.RawURL` | always present — for `<img src>`, `<object data>`, or a download link |
|
||||
|
||||
`.Bundle` is the parent, so a theme has its title, language and breadcrumb. Selecting an entry is an
|
||||
ordinary link and a full re-render, so a sidebar-plus-pane layout needs no JavaScript; swapping the pane
|
||||
client-side is a later enhancement over working markup, never a requirement.
|
||||
|
||||
## What the engine does not provide
|
||||
|
||||
Layout, class names, CSS, client-side behaviour, and any choice about how something *looks* — including
|
||||
how media is embedded, how a listing is arranged, and whether something appears in a sidebar. Those are
|
||||
theme decisions, and a request touching them is split: a contract extension here, a change there.
|
||||
|
||||
The engine also does not provide a component library, a CSS build step, or a JavaScript runtime.
|
||||
`conventions.md` holds the output floor the engine itself meets — semantic HTML, zero-JS, every image
|
||||
carrying width, height and alt — and a theme is expected to stay inside it, but the engine does not
|
||||
enforce a theme's markup.
|
||||
|
||||
## The reference theme
|
||||
|
||||
The binary embeds a reference theme — templates plus one small stylesheet — so a bare site root renders
|
||||
(ADR-0026). It exists to make this document executable: it implements every field and block named here and
|
||||
nothing else, and a golden-file test through it catches contract regressions before a real theme does.
|
||||
|
||||
It is not a design. Legibility only, no branding, no visual opinions, no JavaScript — `verify.sh` fails on
|
||||
a `<script` tag in it. If it starts accumulating taste, it has stopped being a reference.
|
||||
|
||||
It demonstrates the contract; it is not the contract. Changing it does not change what a theme may rely on
|
||||
— which is why `verify.sh` also fails when it changes without this document changing, since in practice the
|
||||
two drift together.
|
||||
|
||||
## Splitting a request
|
||||
|
||||
When a feature spans engine and theme, this repo delivers:
|
||||
|
||||
1. the contract extension — new fields, new named blocks, new queries, new URLs;
|
||||
2. a note stating what a theme must do to use it, precise enough to act on without this conversation;
|
||||
3. embedded defaults updated far enough to prove the extension works.
|
||||
|
||||
It does not deliver the theme. Claiming otherwise is the same error as claiming to have migrated content
|
||||
in a site root this repo cannot see.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Toolchain and external contracts
|
||||
|
||||
What this harness was built against, and which of those are contracts it depends on rather than
|
||||
incidentals. Recorded because a failure long after the fact is otherwise indistinguishable from a bug in
|
||||
the harness itself. Update the version line when you deliberately move; leave the rest alone.
|
||||
|
||||
**Verified working:** 2026-07-28
|
||||
|
||||
| Thing | Version then | Harness depends on |
|
||||
|---|---|---|
|
||||
| Go | 1.26.1 (darwin/arm64) | `go list -f`, `go vet`, `go build`, `go test -race`, `gofmt -l`. All stable since 1.11 |
|
||||
| git | 2.53.0 | `status --porcelain -uall`, `rev-list <sha>..HEAD -- '*.go'`, `rev-parse --verify`, `core.hooksPath` (2.9+) |
|
||||
| Claude Code | 2.1.119 | the four formats below |
|
||||
| bash | macOS default (3.2-era) + BSD userland | `verify.sh` avoids bashisms and GNU-only flags; `grep -L`, `wc -l`, `xargs`, `awk` are POSIX use |
|
||||
|
||||
## The four agent-tooling contracts
|
||||
|
||||
These are the ones most likely to move, and the ones whose breakage looks like a harness bug:
|
||||
|
||||
1. **`CLAUDE.md` is loaded automatically** from the repo root on every request. If that stops being true,
|
||||
nothing enforces the constitution and the agent will behave like a stock assistant.
|
||||
2. **`.claude/skills/<name>/SKILL.md`** — a Markdown file in a directory named for the skill, which fires
|
||||
on request content matching its description. The feature loop depends on firing *without* being
|
||||
invoked; if skills come to need explicit invocation, the loop silently stops applying.
|
||||
3. **`.claude/commands/<name>.md`** — Markdown with a YAML frontmatter `description:`, invoked as
|
||||
`/<name>`, `$ARGUMENTS` substituted. Used by `/verify`, `/audit`, `/leaf`, `/refresh-docs`, `/adr`,
|
||||
`/invariants`.
|
||||
4. **`.claude/settings.json`** — a `permissions` object with `allow` / `ask` / `deny` arrays of
|
||||
`Tool(pattern)` strings. Only a convenience: if the schema changes, the harness still works, you just
|
||||
get more prompts.
|
||||
|
||||
## Not depended on
|
||||
|
||||
No CI service, no container registry, no language server, no linter binary, no package manager beyond
|
||||
`go mod`. `verify.sh` runs offline with nothing but Go and git on `PATH`, which is deliberate — the gate
|
||||
must work on a machine you have not configured for two years.
|
||||
|
||||
`VERIFY_DOCKER=1` adds a container build step and needs `docker`; it is opt-in and off by default.
|
||||
|
||||
## If something breaks after a long absence
|
||||
|
||||
Check in this order, cheapest first: `go version` and `git --version` against the table above; then
|
||||
whether `verify.sh` fails in the `documentation` block (a harness-internal problem) or the `go` block (a
|
||||
toolchain or code problem); then whether the agent is still reading `CLAUDE.md` at all — ask it to quote
|
||||
a hard rule, and if it cannot, the problem is contract 1 and nothing downstream will behave.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Ideas — parked, not dropped
|
||||
|
||||
One file per idea, self-contained enough to resume cold. Nothing here is committed to; presence is not
|
||||
permission to build. An idea file exists so picking the thread back up costs **one read instead of one
|
||||
re-derivation** — the whole justification for writing it while the context is still in hand.
|
||||
|
||||
## What goes where
|
||||
|
||||
| It is… | Where |
|
||||
|---|---|
|
||||
| an engine feature you might build (webmentions, gemtext, galleries) | `docs/exploration.md` catalog line, `/leaf` for a verdict |
|
||||
| that same feature, but with a discussion worth not repeating | an idea file here, linked from the catalog line |
|
||||
| the harness, agent workflow, token cost, tooling, process | here — `exploration.md` is engine-only |
|
||||
| a decision already made | `docs/decisions.md` |
|
||||
| a known code flaw deliberately unfixed | Latent list in `docs/state.md` |
|
||||
| a durable fact, number, or link | `reference/` |
|
||||
|
||||
## Format
|
||||
|
||||
Five headings, present tense, terse. Copy an existing file. Required first lines:
|
||||
|
||||
```
|
||||
# <title>
|
||||
Status: parked | active | adopted → <where> | dropped: <one-line why>
|
||||
Raised: YYYY-MM-DD
|
||||
```
|
||||
|
||||
Keep `dropped` files — a one-line reason is the cheapest defence against relitigating it in four
|
||||
months.
|
||||
|
||||
## Reading discipline
|
||||
|
||||
Exploratory scratch code may live here too; `verify.sh` never formats, vets, builds or budgets it, so
|
||||
it does not have to compile.
|
||||
|
||||
Out of agent context by default, **this index included**. A file is opened when the human names that
|
||||
idea, not otherwise: not swept, not listed to see what is here, not cited unasked. This index is for
|
||||
you, not the agent; `verify.sh` keeps it honest without reading it into context.
|
||||
|
||||
## Index
|
||||
|
||||
- [specs-as-secondary-artifacts.md](specs-as-secondary-artifacts.md) — optional per-feature specs, derived by default, plus the named-test convention. **parked**
|
||||
- [engine-design-review.md](engine-design-review.md) — open design decisions for a multi-type site; items graduate to ADRs one at a time. **parked**
|
||||
- [token-conservation.md](token-conservation.md) — cut agent token cost without losing output quality. **parked**
|
||||
@@ -0,0 +1,50 @@
|
||||
# Engine design review — open items
|
||||
|
||||
Status: parked
|
||||
Raised: 2026-07-28
|
||||
|
||||
One review, many decisions. Each item below graduates independently: when one is settled it becomes an
|
||||
ADR (or a change to its owning doc) and is struck from this list. When the list empties, delete the
|
||||
file. Graduated so far: site root external to the engine repo (ADR-0011), Effect as the sixth
|
||||
primitive (ADR-0012), the cache validity model (ADR-0013), declared content types (ADR-0014),
|
||||
NFC normalisation and slug derivation (ADR-0015), sequence position (ADR-0016), the settings cascade
|
||||
(ADR-0017), taxonomies and feeds (ADR-0018), template composition (ADR-0019), feature locality (ADR-0027).
|
||||
|
||||
## Why it came up
|
||||
|
||||
The harness was written for a generic blog engine and then aimed at a site hosting blog posts,
|
||||
webcomics, art, literary writing, standalone pages and IndieWeb status notes, with a featureset meant
|
||||
to keep evolving. Several things that are cheap to decide now get expensive once content exists or the
|
||||
first URL is published.
|
||||
|
||||
## Decide before Arc 1 writes code
|
||||
|
||||
**Pagination URL shape.** Undecided and permanent — `/posts/page/2/` versus a query parameter. ADR-0008
|
||||
settled the bundle shape only, and ADR-0016 settled that a slug may contain slashes; this is the last
|
||||
open piece of the permalink space.
|
||||
|
||||
## Decide during Arc 1–2, as the code arrives
|
||||
|
||||
**UI chrome strings and Bengali collation.** i18n covers content variants, not chrome ("Next chapter",
|
||||
"Read more") — a small message catalog, decided before templates multiply. Separately, byte sort is
|
||||
wrong for Bengali indexes: adopt collation, or decide explicitly that ordering is date-only.
|
||||
|
||||
## Cheap, whenever
|
||||
|
||||
**A `check` command in the engine.** Missing required fields per type, image without alt, broken
|
||||
internal link, dangling `series` reference, orphaned asset, slug collision, non-normalised filename,
|
||||
alias colliding with a live URL. A walk over already-loaded bundles. Explicitly *not* `verify.sh`:
|
||||
that gate answers "is the engine sound", this answers "is my content sound", and it runs against a
|
||||
site root the engine repo cannot see.
|
||||
|
||||
**Search as a static artifact.** A prebuilt index file shipped with the site needs no running service
|
||||
— the most sovereign option available, and it satisfies ADR-0010 trivially because the index is
|
||||
derived. An Effect (ADR-0012) builds it.
|
||||
|
||||
**No-JS reading experience.** Server-rendered prev/next always present; keyboard navigation and
|
||||
prefetch as enhancement only. Decide before the comic reader exists, not after.
|
||||
|
||||
## Dropped
|
||||
|
||||
Binary assets in git. Now purely a decision about the site repo, outside the engine repo entirely, so
|
||||
the harness has no stake in it.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Specs as secondary artifacts, and named tests
|
||||
|
||||
Status: parked
|
||||
Raised: 2026-07-28
|
||||
|
||||
## Why it came up
|
||||
|
||||
The harness is deliberately not an SDD framework: the goal is to maintain content and trust the harness
|
||||
to keep the code minimal and maintainable, rather than to author requirements for every change. But two
|
||||
things are missing that SDD would have given for free — a place to put a well-defined spec when one
|
||||
already exists, and any trace from a code change back to the behaviour it was supposed to produce.
|
||||
|
||||
Proposal: per-feature specs as **secondary** artifacts. ADRs, `state.md` and the engine-wide contracts
|
||||
stay primary. A spec may be written first (true SDD for a well-defined feature) or generated after the
|
||||
fact from what was requested and what was built. Either way it can then be edited, and the edit is
|
||||
implemented — flowing upward into the harness where it touches anything the harness owns.
|
||||
|
||||
## What is already established
|
||||
|
||||
- The harness has no per-feature artifact. A plan lives in the conversation; what survives is an ADR
|
||||
plus a `state.md` delta. There is no way to ask later "which behaviour was this code meant to have".
|
||||
- Tests are mandatory for `cmd/` and `internal/` changes but nothing ties a test to the rule it verifies.
|
||||
`content-model.md` says a bundle key excludes language, and that a bundle supplying both `about.md`
|
||||
and `about.en.md` is rejected — nothing checks a test exists for either.
|
||||
- One fact, one place is enforced throughout (`docs/README.md` authority table). Specs must not become a
|
||||
fourth copy of decisions.
|
||||
|
||||
## The non-overlapping slot
|
||||
|
||||
The only content a spec should hold is what nothing else does:
|
||||
|
||||
| Artifact | Holds |
|
||||
|---|---|
|
||||
| ADR | why a load-bearing choice was made, permanently |
|
||||
| `state.md` | what exists right now |
|
||||
| `architecture.md`, `content-model.md` | engine-wide shape and contract |
|
||||
| **spec** | **per-feature observable behaviour: given X, the engine does Y** |
|
||||
|
||||
Acceptance criteria, in other words — which is also exactly the traceability gap. So the cheapest useful
|
||||
spec is a list of criteria whose names are the test names, and the named-test convention below is the
|
||||
subset of this proposal that can ship on its own.
|
||||
|
||||
## The rule that stops it rotting: two states, one owner each
|
||||
|
||||
A generated spec describes what exists; an edited spec describes what should exist. Flipping between
|
||||
those per edit, with nothing marking which, is how spec systems rot — after three round trips nobody
|
||||
knows whether a clause is a record or a requirement. So each spec carries a status and it cycles:
|
||||
|
||||
- **`derived`** — describes what exists. The code wins; a mismatch is a spec bug, fixed silently.
|
||||
- **`authored`** — a human edited it, or wrote it first. The spec wins; a mismatch is unimplemented work.
|
||||
The next implement pass brings the code into line and flips it back to `derived`.
|
||||
|
||||
`verify.sh` can surface `authored` specs as a warning — pending requirements become visible state rather
|
||||
than a silent backlog. This mirrors the existing "if `state.md` disagrees with the code, the code wins"
|
||||
rule, which works precisely because the direction is never ambiguous.
|
||||
|
||||
## Two things to get right or it is worse than nothing
|
||||
|
||||
**A derived spec must not paraphrase the code.** If it restates the implementation it carries no
|
||||
information, costs tokens every feature, and goes stale instantly. Its value is the half the code cannot
|
||||
hold: the request as made, the behaviour at the edges, what was deliberately not done. Capture the
|
||||
request, not the diff.
|
||||
|
||||
**Not every change earns a spec.** A spec per one-line tweak is overhead. The natural threshold is the
|
||||
one already enforced: a change that must ship a test is a change with observable behaviour worth pinning,
|
||||
and the spec's criteria are that test's names. Changes below that line get an ADR or nothing.
|
||||
|
||||
## Upward flow needs no new machinery
|
||||
|
||||
An edited spec is just another request, so the existing conflict check (`CLAUDE.md` rule 9) applies
|
||||
unchanged: contradicts an ADR or an engine contract → hard conflict, stop and surface; is a load-bearing
|
||||
choice → it produces an ADR; is plain new behaviour → code plus spec, nothing else. "Flows upward" means
|
||||
the spec is an *entry point* to the loop, not a parallel process.
|
||||
|
||||
## Cheapest next step
|
||||
|
||||
Two independent pieces, in order:
|
||||
|
||||
1. **Named tests, alone.** A line in `conventions.md`: a rule stated in an engine contract earns a test
|
||||
named after it — `TestBundleKeyExcludesLanguage`, `TestDuplicateVariantRejected`. Zero
|
||||
infrastructure, most of the traceability, and it makes the spec step later almost free because the
|
||||
criteria already exist as test names.
|
||||
2. **`specs/<feature>.md`**, one file per feature with a `Status:` line, an index like `ideas/`, excluded
|
||||
from the Go gates the way `ideas/` and `reference/` are, read on demand and never swept.
|
||||
|
||||
Open question if this is adopted: whether a spec is ever deleted. Suggest not — a `derived` spec for a
|
||||
shipped feature is the closest thing to living documentation of behaviour, and it is cheap to keep.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Token conservation
|
||||
|
||||
Status: parked
|
||||
Raised: 2026-07-28
|
||||
|
||||
## Why it came up
|
||||
|
||||
Development pace is limited by session and usage limits, not by typing speed. Cutting token cost
|
||||
per feature buys more features per session. The goal is explicitly *conserve tokens without
|
||||
lowering output quality* — not "be terse and worse".
|
||||
|
||||
## What is already established
|
||||
|
||||
Do not re-derive these.
|
||||
|
||||
- **Turn count dominates, not file size.** The whole context is re-sent on every tool call, so a
|
||||
six-call verification sequence costs roughly 6× the context. Batching is the biggest lever.
|
||||
- **`CLAUDE.md` is the most expensive file per byte in the repo** — it is re-sent every turn.
|
||||
- **Agent prose is a large, entirely self-inflicted cost.** The target is density, not brevity: same
|
||||
information, fewer words. Length follows content; padding, restatement, hedging and rhetorical
|
||||
closers do not.
|
||||
- Two accepted rules cost tokens on purpose and are worth keeping: hard rule 8 (docs ship with the
|
||||
change) and the topic-ownership read floor. The ownership table is what keeps the floor cheap —
|
||||
it sends you to one doc instead of a grep sweep.
|
||||
|
||||
## The six mechanisms, ranked by saving
|
||||
|
||||
1. Batch tool calls — one Bash call per phase, not per command.
|
||||
2. Prose density in reports and replies — cut restatement and flourish, keep every finding, number and
|
||||
caveat. Biggest win, costs nothing. Not a length cap: truncating information is not a saving.
|
||||
3. Line ceiling on `CLAUDE.md` in `budgets.env`, gated like any other budget.
|
||||
4. Targeted reads — `grep -n` plus `Read offset/limit`; whole-file reads only for the doc that
|
||||
owns a rule being asserted.
|
||||
5. Evidence proportional to risk — one case for a behaviour change, a full matrix only for a gate
|
||||
or a security boundary.
|
||||
6. Never re-read after `Edit`; run `verify.sh` once per feature, at the end.
|
||||
|
||||
## Open question — needs the human
|
||||
|
||||
**Subagents for fan-out search.** Dispatching "find every place X appears" to a subagent keeps its
|
||||
file dumps out of the main context: you pay for the conclusion, not the search. A real multiplier
|
||||
on exploration-heavy work. Currently disallowed unless explicitly requested, so it is a policy
|
||||
call, not a judgment call.
|
||||
|
||||
## Cheapest next step
|
||||
|
||||
Apply 1–6 as one batched change: `CLAUDE.md` (efficiency rule), `SKILL.md` (report cap, evidence
|
||||
proportionality), `budgets.env` + `verify.sh` (`CLAUDE.md` line ceiling). Trips the `HARNESS.md`
|
||||
coupling gate by design. Estimated one feature-sized change.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Reference — facts worth keeping
|
||||
|
||||
Durable information surfaced in conversation that would otherwise be lost: measured numbers, external
|
||||
constraints, how a tool actually behaves, links worth having. One file per subject.
|
||||
|
||||
Not decisions (`docs/decisions.md`), not proposals (`ideas/`), not the present state of the code
|
||||
(`docs/state.md`). A fact only true today belongs in `state.md`; a rule belongs in the doc owning the
|
||||
topic.
|
||||
|
||||
Each file states **how the fact was established** — measured, read from a spec, or asserted — so a
|
||||
later reader knows whether to trust it or re-check it. An unattributed number is a rumour.
|
||||
|
||||
Scratch code illustrating a fact is welcome; `verify.sh` ignores `.go` here entirely, so it need not
|
||||
compile.
|
||||
|
||||
Out of agent context by default, this index included. Opened only when the human names the topic.
|
||||
|
||||
## Index
|
||||
|
||||
- [agent-session-costs.md](agent-session-costs.md) — how context and token cost accumulate in an agent session
|
||||
@@ -0,0 +1,39 @@
|
||||
# How agent session cost accumulates
|
||||
|
||||
Established: mechanism, asserted from how the tool loop works — not measured in this repo.
|
||||
Recorded: 2026-07-28
|
||||
|
||||
## The model
|
||||
|
||||
An agent turn sends the entire conversation context to the model, every time. A tool call is a
|
||||
turn. So the cost of a task is roughly:
|
||||
|
||||
```
|
||||
total ≈ (number of tool calls) × (average context size)
|
||||
```
|
||||
|
||||
Both factors matter, but the first is the one usually left on the floor. Consequences that follow
|
||||
directly and are easy to get backwards:
|
||||
|
||||
- **Ten small commands cost more than one large one.** Batching independent reads into a single
|
||||
message, or chaining shell commands into one call, cuts turns without cutting information.
|
||||
- **An always-loaded file is multiplied by every turn in the session.** A 100-line constitution over
|
||||
40 turns costs more than a 2000-line doc read once. Per-byte, the always-loaded file is the most
|
||||
expensive in the repo; every other doc is read on demand and paid for once.
|
||||
- **Output length is paid twice** — once as generated tokens, then again as context on every
|
||||
subsequent turn of the session.
|
||||
- **A file read mid-session stays in context.** Reading speculatively is not a one-off cost; it
|
||||
raises the floor for the rest of the session — the mechanical reason behind `CLAUDE.md` §1's read
|
||||
discipline.
|
||||
- **A subagent's context is separate.** Its file dumps never enter the parent context; only its
|
||||
conclusion does. This is why delegating fan-out search is a real multiplier rather than a
|
||||
wash.
|
||||
- **Rework is the most expensive thing available.** A wrong assumption discovered late costs the
|
||||
whole re-derivation plus the original attempt. Clarifying gates and plan-before-code are token
|
||||
optimisations, not just quality ones — which is why "read the owning doc first" saves more than
|
||||
it spends.
|
||||
|
||||
## Practical consequence for this repo
|
||||
|
||||
Terseness in docs and reports is not a stylistic preference here; it is a budget. See
|
||||
`ideas/token-conservation.md` for the open proposal on enforcing it.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Non-stdlib dependency allowlist. One module path per line; # starts a comment.
|
||||
# Adding a line requires an ADR in docs/decisions.md. Stdlib first, always.
|
||||
# Only direct requirements are checked here; the total module count is capped by DEPS_MAX.
|
||||
# Infrastructure clients (Redis, S3, search) are dependencies like any other and get no exemption.
|
||||
|
||||
github.com/yuin/goldmark
|
||||
golang.org/x/text # NFC normalisation (ADR-0015); collation later if earned
|
||||
gopkg.in/yaml.v3 # frontmatter + site declaration (ADR-0020)
|
||||
@@ -0,0 +1,19 @@
|
||||
# Growth budgets. `_MAX` fails verify.sh; `_WARN` prints and moves on.
|
||||
# Raising a _MAX needs an ADR stating old and new values. Growth requires a signature.
|
||||
#
|
||||
# Only whole-system budgets fail. A hard per-file limit is the one gate whose cheapest fix makes the
|
||||
# code worse — sharding a coherent file into a `_helpers.go` turns it green while creating the package
|
||||
# CLAUDE.md rule 3.6 bans. Total mass cannot be gamed by moving code between files; file length can.
|
||||
|
||||
CORE_LOC_MAX=2000 # cmd/ + internal/{content,render,web} + repo root, non-test .go
|
||||
EXT_LOC_MAX=2000 # internal/ext/ — composition, grows after the core freezes
|
||||
FILE_LOC_WARN=500 # any single .go file — advisory
|
||||
FUNC_LOC_WARN=60 # any single function — advisory
|
||||
DEPS_MAX=6 # total modules in go.mod, direct plus indirect
|
||||
|
||||
# Two ceilings because "the core stops growing after Arc 2" (architecture.md invariant 9) is only an
|
||||
# invariant if something measures it: post-freeze CORE holds and only EXT rises.
|
||||
# Costed, not round: spine, bundles, queries, render, routing, templates ~1200-1800 → CORE 2000.
|
||||
# Feeds, sitemap, OpenGraph, shortcodes, image sizing, galleries, paging, indieweb ~900-1200 → EXT
|
||||
# 2000, deliberately loose since that is where growth belongs. Reaching CORE asks what to delete;
|
||||
# reaching EXT asks whether a template would have done it — only .go lines count here.
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Makes the gate non-optional. Enable once per clone:
|
||||
# git config core.hooksPath scripts/hooks
|
||||
# Bypass deliberately with `git commit --no-verify` — and say why in the commit body.
|
||||
exec ./scripts/verify.sh
|
||||
Executable
+365
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env bash
|
||||
# The objective gate. No opinions, only exit codes.
|
||||
# Usage: ./scripts/verify.sh
|
||||
set -uo pipefail
|
||||
|
||||
# --list answers "does this gate actually exist?" without reading the script. Docs that claim a gate are
|
||||
# checkable against it. The pattern is written pas[s] so this grep does not match its own source line.
|
||||
if [ "${1:-}" = "--list" ]; then
|
||||
printf 'gates in %s:\n' "$0"
|
||||
grep -oE 'pas[s] "[^"]+"' "$0" | awk -F'"' '{print $2}' |
|
||||
sed 's/\$([^)]*)/…/g; s/\$[A-Za-z_][A-Za-z_0-9]*/…/g; s/^/ /' | sort -u
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
[ -f scripts/budgets.env ] && . scripts/budgets.env
|
||||
CORE_LOC_MAX=${CORE_LOC_MAX:-2000}
|
||||
EXT_LOC_MAX=${EXT_LOC_MAX:-2000}
|
||||
FILE_LOC_WARN=${FILE_LOC_WARN:-500}
|
||||
FUNC_LOC_WARN=${FUNC_LOC_WARN:-60}
|
||||
DEPS_MAX=${DEPS_MAX:-6}
|
||||
|
||||
fail=0
|
||||
warn=0
|
||||
pass() { printf ' ok %s\n' "$1"; }
|
||||
bad() { printf ' FAIL %s\n' "$1"; fail=1; }
|
||||
note() { printf ' warn %s\n' "$1"; warn=$((warn + 1)); }
|
||||
head_() { printf '\n%s\n' "$1"; }
|
||||
|
||||
if ! command -v go >/dev/null 2>&1 || ! command -v gofmt >/dev/null 2>&1; then
|
||||
echo "FAIL go toolchain not found on PATH — cannot verify anything"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- documentation coupling -------------------------------------------------
|
||||
# Runs before the Go gates so it still applies while the repo is harness-only.
|
||||
# Code and the docs describing it move together, or they drift apart silently.
|
||||
head_ "documentation"
|
||||
if [ -d .git ] && command -v git >/dev/null 2>&1; then
|
||||
# -uall matters: the default collapses untracked directories to "dir/", which would hide a
|
||||
# brand-new internal/ext/feeds/feeds.go from the .go check below.
|
||||
changed=$(git status --porcelain -uall 2>/dev/null | sed 's/^...//' | sed 's/.* -> //')
|
||||
|
||||
if [ -z "$changed" ]; then
|
||||
pass "working tree clean — nothing to couple"
|
||||
else
|
||||
if echo "$changed" | grep -qE '\.go$' && ! echo "$changed" | grep -qx 'docs/state.md'; then
|
||||
bad "*.go changed but docs/state.md did not — inventory, counters and the verified-against line move with the code"
|
||||
else
|
||||
pass "code/state.md coupling"
|
||||
fi
|
||||
|
||||
if echo "$changed" | grep -qE '^(cmd|internal)/.*\.go$' && ! echo "$changed" | grep -qE '_test\.go$'; then
|
||||
bad "cmd/ or internal/ .go changed but no _test.go did — behaviour ships with a test (conventions.md)"
|
||||
else
|
||||
pass "code/test coupling"
|
||||
fi
|
||||
|
||||
if echo "$changed" | grep -qE '^internal/.*templates/.*\.html$' && ! echo "$changed" | grep -qx 'docs/theme-contract.md'; then
|
||||
bad "embedded templates changed but docs/theme-contract.md did not — they drift together (ADR-0023)"
|
||||
else
|
||||
pass "templates/theme-contract coupling"
|
||||
fi
|
||||
|
||||
if echo "$changed" | grep -qE '^(CLAUDE\.md$|scripts/|\.claude/)' && ! echo "$changed" | grep -qx 'HARNESS.md'; then
|
||||
bad "the harness changed (CLAUDE.md, scripts/ or .claude/) but HARNESS.md did not — the guide to the machine is part of the machine"
|
||||
else
|
||||
pass "harness/HARNESS.md coupling"
|
||||
fi
|
||||
fi
|
||||
|
||||
# The reference theme is a contract demonstration, not a design: zero JavaScript (ADR-0026).
|
||||
themefiles=$(find internal -path '*templates*' -name '*.html' 2>/dev/null || true)
|
||||
if [ -n "$themefiles" ]; then
|
||||
scripted=$(echo "$themefiles" | xargs grep -ln '<script' 2>/dev/null || true)
|
||||
if [ -n "$scripted" ]; then
|
||||
bad "<script> in the reference theme — it is a contract demonstration, not a design (ADR-0026): $(echo "$scripted" | tr '\n' ' ')"
|
||||
else
|
||||
pass "reference theme is script-free"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Dangling references. Every one of these found a real stale pointer when run by hand.
|
||||
refs=$(grep -rhoE '`(docs|scripts|ideas|reference|\.claude)/[A-Za-z0-9_./-]+`' \
|
||||
docs CLAUDE.md HARNESS.md ideas reference .claude scripts 2>/dev/null | tr -d '`' | sort -u)
|
||||
dangling=""
|
||||
for f in $refs; do [ -e "$f" ] || dangling="$dangling $f"; done
|
||||
[ -n "$dangling" ] && bad "reference to a path that does not exist:$dangling"
|
||||
|
||||
adrs=$(grep -rhoE 'ADR-[0-9]{4}' docs CLAUDE.md HARNESS.md ideas reference .claude scripts cmd internal 2>/dev/null | sort -u)
|
||||
missingadr=""
|
||||
for a in $adrs; do
|
||||
grep -q "^## $a" docs/decisions.md 2>/dev/null || missingadr="$missingadr $a"
|
||||
done
|
||||
[ -n "$missingadr" ] && bad "reference to an ADR with no entry in decisions.md:$missingadr"
|
||||
|
||||
secs=$(grep -rhoE 'CLAUDE\.md`? §[0-9]+' docs HARNESS.md ideas reference .claude scripts 2>/dev/null |
|
||||
grep -oE '§[0-9]+' | tr -d '§' | sort -u)
|
||||
missingsec=""
|
||||
for s in $secs; do
|
||||
grep -q "^## $s\. " CLAUDE.md || missingsec="$missingsec §$s"
|
||||
done
|
||||
[ -n "$missingsec" ] && bad "reference to a CLAUDE.md section that does not exist:$missingsec"
|
||||
[ -z "$dangling$missingadr$missingsec" ] && pass "references resolve"
|
||||
|
||||
# Index rot: a folder of files nobody lists is a folder nobody reads.
|
||||
for d in ideas reference; do
|
||||
[ -d "$d" ] || continue
|
||||
files=$(find "$d" -name '*.md' -not -name 'README.md' | wc -l | tr -d ' ')
|
||||
listed=$(grep -c '^- \[' "$d/README.md" 2>/dev/null || true)
|
||||
if [ "$files" != "${listed:-0}" ]; then
|
||||
note "$d: $files file(s), $listed index line(s) — index out of date"
|
||||
fi
|
||||
if [ "$d" = "ideas" ]; then
|
||||
unstatused=$(grep -L '^Status:' ideas/*.md 2>/dev/null | grep -v 'README.md' || true)
|
||||
[ -n "$unstatused" ] && note "ideas: no Status line: $(echo "$unstatused" | tr '\n' ' ')"
|
||||
fi
|
||||
done
|
||||
|
||||
# True staleness: has any Go file changed since the commit state.md claims to describe?
|
||||
recorded=$(awk -F'`' '/^\*\*Verified against:\*\*/{print $2; exit}' docs/state.md 2>/dev/null)
|
||||
if git rev-parse --verify -q HEAD >/dev/null 2>&1; then
|
||||
if git rev-parse --verify -q "${recorded:-nonexistent}^{commit}" >/dev/null 2>&1; then
|
||||
behind=$(git rev-list "$recorded..HEAD" -- '*.go' 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "${behind:-0}" -gt 0 ]; then
|
||||
note "docs/state.md describes $recorded; $behind commit(s) have touched .go since"
|
||||
else
|
||||
pass "docs/state.md is current with HEAD"
|
||||
fi
|
||||
else
|
||||
note "docs/state.md 'verified against' does not name a commit this repo knows ($recorded)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
note "not a git repo — doc coupling unenforceable"
|
||||
fi
|
||||
|
||||
result_and_exit() {
|
||||
head_ "result"
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
printf ' PASS %s warning(s)\n\n' "$warn"
|
||||
else
|
||||
printf ' FAIL fix the above before reporting success\n\n'
|
||||
fi
|
||||
exit "$fail"
|
||||
}
|
||||
|
||||
if [ ! -f go.mod ]; then
|
||||
head_ "go"
|
||||
note "no go.mod — nothing to verify yet. Run 'go mod init' as part of the first feature."
|
||||
result_and_exit
|
||||
fi
|
||||
|
||||
mod=$(awk '/^module /{print $2; exit}' go.mod)
|
||||
|
||||
# Engine source only. The site root is outside this repo (ADR-0011); ideas/ and reference/ may hold
|
||||
# exploratory scratch code, which is not the engine and is never formatted, vetted, built or budgeted.
|
||||
gofiles=$(find . -name '*.go' -not -path './vendor/*' -not -path './.git/*' \
|
||||
-not -path './ideas/*' -not -path './reference/*' 2>/dev/null)
|
||||
if [ -z "$gofiles" ]; then
|
||||
head_ "go"
|
||||
note "go.mod present but no .go files yet — nothing to verify."
|
||||
result_and_exit
|
||||
fi
|
||||
|
||||
head_ "correctness"
|
||||
pkgs=$(go list ./... 2>/dev/null | grep -vE "^$mod/(ideas|reference)(/|$)")
|
||||
if [ -z "$pkgs" ]; then
|
||||
head_ "go"; note "no engine packages yet — nothing to verify."
|
||||
result_and_exit
|
||||
fi
|
||||
unformatted=$(echo "$gofiles" | xargs gofmt -l 2>/dev/null || true)
|
||||
if [ -n "$unformatted" ]; then bad "gofmt: $(echo "$unformatted" | tr '\n' ' ')"; else pass "gofmt"; fi
|
||||
|
||||
if go vet $pkgs >/tmp/vet.log 2>&1; then pass "go vet"; else bad "go vet"; sed 's/^/ /' /tmp/vet.log; fi
|
||||
if go build $pkgs >/tmp/build.log 2>&1; then pass "go build"; else bad "go build"; sed 's/^/ /' /tmp/build.log; fi
|
||||
|
||||
if echo "$gofiles" | grep -q '_test\.go$'; then
|
||||
if go test -race $pkgs >/tmp/test.log 2>&1; then
|
||||
pass "go test ($(grep -c '^ok' /tmp/test.log) packages)"
|
||||
else
|
||||
bad "go test"; sed 's/^/ /' /tmp/test.log
|
||||
fi
|
||||
else
|
||||
note "no tests exist yet"
|
||||
fi
|
||||
|
||||
head_ "dependencies"
|
||||
reqs=$(awk '
|
||||
/^require[ \t]*\(/ { inb = 1; next }
|
||||
inb && /^\)/ { inb = 0; next }
|
||||
{
|
||||
ln = $0
|
||||
ind = (ln ~ /\/\/[ \t]*indirect/) ? "indirect" : "direct"
|
||||
sub(/\/\/.*/, "", ln)
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", ln)
|
||||
if (ln == "") next
|
||||
if (!inb) { if (ln !~ /^require[ \t]/) next; sub(/^require[ \t]+/, "", ln) }
|
||||
split(ln, a, /[ \t]+/)
|
||||
if (a[1] != "") print a[1], ind
|
||||
}' go.mod)
|
||||
|
||||
direct=$(echo "$reqs" | awk '$2=="direct" {print $1}' | grep -v '^$' || true)
|
||||
total=$(echo "$reqs" | grep -cv '^$' || true)
|
||||
|
||||
if [ -f scripts/allowed-deps.txt ]; then
|
||||
allowed=$(grep -v '^[[:space:]]*#' scripts/allowed-deps.txt | grep -v '^[[:space:]]*$' || true)
|
||||
unlisted=""
|
||||
for d in $direct; do
|
||||
echo "$allowed" | grep -qxF "$d" || unlisted="$unlisted $d"
|
||||
done
|
||||
if [ -n "$unlisted" ]; then
|
||||
bad "dependency not on allowlist:$unlisted (needs an ADR + scripts/allowed-deps.txt)"
|
||||
else
|
||||
directcount=$(echo "$direct" | grep -c . || true)
|
||||
pass "allowlist ($directcount direct)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$total" -gt "$DEPS_MAX" ]; then
|
||||
bad "module count $total exceeds DEPS_MAX=$DEPS_MAX"
|
||||
else
|
||||
pass "module count $total / $DEPS_MAX"
|
||||
fi
|
||||
|
||||
head_ "architecture"
|
||||
# The layering in conventions.md, enforced. Dependencies point inward; a sibling import here is
|
||||
# what turns a layered engine into a ball of mud, and it always looks locally reasonable.
|
||||
# One line per package: "importpath imp1 imp2 …". go list is authoritative; grepping source is not.
|
||||
pkgimports=$(go list -f '{{.ImportPath}}{{range .Imports}} {{.}}{{end}}' ./... 2>/dev/null)
|
||||
pairs=$(echo "$pkgimports" | awk '{for (i=2; i<=NF; i++) print $1, $i}')
|
||||
layer() { # $1 = importing layer, $2..$n = layers it may not import
|
||||
local from="$1"; shift
|
||||
for to in "$@"; do
|
||||
echo "$pairs" | awk -v f="$mod/internal/$from" -v t="$mod/internal/$to" \
|
||||
'index($1,f)==1 && index($2,t)==1 {print $1" → "$2}'
|
||||
done
|
||||
}
|
||||
violations=$(
|
||||
layer content render web ext
|
||||
layer render web ext
|
||||
layer web ext
|
||||
echo "$pairs" | awk -v c="$mod/cmd" 'index($2,c)==1 && index($1,c)!=1 {print $1" → "$2}'
|
||||
)
|
||||
# Sibling imports between features are what make an agent's read set compound (ADR-0027).
|
||||
siblings=$(echo "$pairs" | awk -v e="$mod/internal/ext/" '
|
||||
index($1,e)==1 && index($2,e)==1 && $1!=$2 {print $1" → "$2}')
|
||||
if [ -n "$violations$siblings" ]; then
|
||||
bad "import boundary violated (conventions.md layering): $(echo "$violations$siblings" | tr '\n' ' ')"
|
||||
else
|
||||
pass "import boundaries"
|
||||
fi
|
||||
|
||||
nopkgdoc=""
|
||||
for dir in $(echo "$gofiles" | grep -v '_test\.go$' | xargs -n1 dirname 2>/dev/null | sort -u); do
|
||||
documented=$(awk 'prev ~ /^\/\// && /^package /{print FILENAME} {prev=$0}' "$dir"/*.go 2>/dev/null | head -1)
|
||||
[ -z "$documented" ] && nopkgdoc="$nopkgdoc $dir"
|
||||
done
|
||||
if [ -n "$nopkgdoc" ]; then
|
||||
bad "package without a package comment (conventions.md Documentation):$nopkgdoc"
|
||||
else
|
||||
pass "every package documented"
|
||||
fi
|
||||
|
||||
undocumented=$(echo "$gofiles" | grep -v '_test\.go$' | xargs awk '
|
||||
/^(func|type|var|const) [A-Z]/ { if (prev !~ /^\/\//) printf "%s:%d ", FILENAME, FNR }
|
||||
{ prev = $0 }' 2>/dev/null)
|
||||
if [ -n "$undocumented" ]; then
|
||||
bad "exported identifier without a doc comment: $undocumented"
|
||||
else
|
||||
pass "exported identifiers documented"
|
||||
fi
|
||||
|
||||
missingdoc=""
|
||||
for dir in $(echo "$gofiles" | grep '^\./internal/ext/' | xargs -n1 dirname 2>/dev/null | sort -u); do
|
||||
[ -f "$dir/doc.go" ] || missingdoc="$missingdoc $dir"
|
||||
done
|
||||
if [ -n "$missingdoc" ]; then
|
||||
bad "internal/ext package without doc.go — contributes / cascade keys / contract fields / not doing (ADR-0027):$missingdoc"
|
||||
else
|
||||
pass "every feature has a doc.go"
|
||||
fi
|
||||
|
||||
head_ "budgets"
|
||||
# Two trees, two ceilings: the core must stop growing after Arc 2, ext is where growth belongs.
|
||||
loc() { [ -z "$1" ] && { echo 0; return; }; echo "$1" | xargs wc -l 2>/dev/null | awk '$2!="total"{t+=$1} END{print t+0}'; }
|
||||
nontest=$(echo "$gofiles" | grep -v '_test\.go$' || true)
|
||||
extfiles=$(echo "$nontest" | grep '^\./internal/ext/' || true)
|
||||
corefiles=$(echo "$nontest" | grep -v '^\./internal/ext/' || true)
|
||||
coreloc=$(loc "$corefiles")
|
||||
extloc=$(loc "$extfiles")
|
||||
|
||||
if [ "$coreloc" -gt "$CORE_LOC_MAX" ]; then
|
||||
bad "core is $coreloc lines, over CORE_LOC_MAX=$CORE_LOC_MAX (shrink it, or raise it in an ADR)"
|
||||
else
|
||||
pass "core $coreloc / $CORE_LOC_MAX lines"
|
||||
fi
|
||||
|
||||
if [ "$extloc" -gt "$EXT_LOC_MAX" ]; then
|
||||
bad "internal/ext is $extloc lines, over EXT_LOC_MAX=$EXT_LOC_MAX (a template may have done it)"
|
||||
else
|
||||
pass "ext $extloc / $EXT_LOC_MAX lines"
|
||||
fi
|
||||
|
||||
oversized=$(echo "$gofiles" | xargs wc -l 2>/dev/null | awk -v m="$FILE_LOC_WARN" '$2!="total" && $1>m {print $2"("$1")"}')
|
||||
if [ -n "$oversized" ]; then
|
||||
note "files over FILE_LOC_WARN=$FILE_LOC_WARN: $(echo "$oversized" | tr '\n' ' ')"
|
||||
else
|
||||
pass "no file over $FILE_LOC_WARN lines"
|
||||
fi
|
||||
|
||||
longfuncs=$(echo "$gofiles" | xargs awk -v m="$FUNC_LOC_WARN" '
|
||||
/^func / { start = FNR; name = $0; inf = 1; next }
|
||||
inf && /^}/ { if (FNR - start > m) printf "%s:%d(%d)\n", FILENAME, start, FNR - start; inf = 0 }
|
||||
' 2>/dev/null)
|
||||
[ -n "$longfuncs" ] && note "functions over $FUNC_LOC_WARN lines: $(echo "$longfuncs" | tr '\n' ' ')"
|
||||
|
||||
if [ -f Dockerfile ] && [ "${VERIFY_DOCKER:-0}" = "1" ]; then
|
||||
head_ "container (VERIFY_DOCKER=1)"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
if docker build -q -t atelier:verify . >/tmp/docker.log 2>&1; then
|
||||
pass "docker build"
|
||||
else
|
||||
bad "docker build"; tail -20 /tmp/docker.log | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
note "docker not on PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
head_ "style floor"
|
||||
# conventions.md states these absolutely, so they fail. A rule enforced as a suggestion teaches
|
||||
# the agent to read every rule as a suggestion.
|
||||
badpkg=$(find ./cmd ./internal -type d 2>/dev/null | grep -Ei '/(utils?|helpers?|common|shared|misc|manager|base|impl|core)$' || true)
|
||||
if [ -n "$badpkg" ]; then bad "package name says nothing (CLAUDE.md rule 3.6): $(echo "$badpkg" | tr '\n' ' ')"; else pass "package names"; fi
|
||||
|
||||
inits=$(echo "$gofiles" | xargs grep -ln '^func init()' 2>/dev/null || true)
|
||||
if [ -n "$inits" ]; then bad "init() found — wire explicitly in cmd/: $(echo "$inits" | tr '\n' ' ')"; else pass "no init()"; fi
|
||||
|
||||
badlog=$(echo "$pairs" | awk '$2=="log" {print $1}' | sort -u)
|
||||
if [ -n "$badlog" ]; then bad "imports log, not log/slog: $(echo "$badlog" | tr '\n' ' ')"; else pass "log/slog only"; fi
|
||||
|
||||
clocks=$(echo "$gofiles" | grep -v '_test\.go$' | grep -v '/clock\.go$' | xargs grep -ln 'time\.Now(' 2>/dev/null || true)
|
||||
if [ -n "$clocks" ]; then bad "time.Now() outside a clock.go — Stages need an injected clock to declare a validity window (ADR-0013): $(echo "$clocks" | tr '\n' ' ')"; else pass "clock accessed through clock.go"; fi
|
||||
|
||||
panics=$(echo "$gofiles" | grep -v '_test\.go$' | grep -v '^\./cmd/' | xargs grep -ln 'panic(' 2>/dev/null || true)
|
||||
if [ -n "$panics" ]; then bad "panic() outside cmd/ — request-time failure degrades (conventions.md): $(echo "$panics" | tr '\n' ' ')"; else pass "no panic outside cmd"; fi
|
||||
|
||||
head_ "smells (advisory)"
|
||||
nowrap=$(echo "$gofiles" | xargs grep -n 'fmt\.Errorf(' 2>/dev/null | grep -v '%w' | wc -l | tr -d ' ')
|
||||
[ "${nowrap:-0}" -gt 0 ] && note "$nowrap fmt.Errorf without %w (wrap at package boundaries)"
|
||||
anyuse=$(echo "$gofiles" | grep -v '_test\.go$' | xargs grep -ln 'interface{}\|\bany\b' 2>/dev/null || true)
|
||||
[ -n "$anyuse" ] && note "interface{} or any present (no empty interface for flexibility): $(echo "$anyuse" | tr '\n' ' ')"
|
||||
deep=$(echo "$gofiles" | xargs awk '/^\t\t\t\t[^\t}]/ {print FILENAME; nextfile}' 2>/dev/null | sort -u || true)
|
||||
[ -n "$deep" ] && note "nesting past 4 (conventions.md): $(echo "$deep" | tr '\n' ' ')"
|
||||
deadexp=$(echo "$gofiles" | grep '^\./internal/' | grep -v '_test\.go$' | xargs grep -hoE '^func [A-Z][A-Za-z0-9_]*' 2>/dev/null | awk '{print $2}' | sort -u |
|
||||
while read -r sym; do
|
||||
n=$(echo "$gofiles" | xargs grep -c "\b$sym\b" 2>/dev/null | awk -F: '{s+=$2} END{print s+0}')
|
||||
[ "${n:-0}" -le 1 ] && printf '%s ' "$sym"
|
||||
done)
|
||||
[ -n "$deadexp" ] && note "exported but referenced once — unexport or delete: $deadexp"
|
||||
todos=$(echo "$gofiles" | xargs grep -c 'TODO\|FIXME\|XXX' 2>/dev/null | awk -F: '{s+=$2} END{print s+0}')
|
||||
[ "${todos:-0}" -gt 0 ] && note "$todos TODO/FIXME markers (latent items belong in docs/state.md)"
|
||||
|
||||
result_and_exit
|
||||
Reference in New Issue
Block a user