The plugin and the binary shipped on two release cadences and nothing on an operator's machine ever checked that the one they installed described the other. The generated flag block existed precisely so a renamed flag could not ship with documentation recommending the old one — and then shipped one version behind the registry it came from, which is the same bug one hop downstream. So the prose moved into the binary. `internal/scaffold` embeds every document; `kettle init` and `kettle gen scaffold` write them into a project's own `.claude/`. The two cannot disagree because there is one artefact. The namespace survived the move. A project's skills are flat, so the prefix is spelled into the directory name (`kettle-issue`); a project's *commands* take their namespace from a subdirectory, so `commands/kettle/init.md` is still `/kettle:init`. Four of the six command files are thin pointers at a skill, and that is what kept ~1,600 lines of `/kettle:…` cross-references true without a rewrite. `init` and `auth` lost `disable-model-invocation: true` — being a command is that property — and `auth` now restricts `allowed-tools` so a model cannot reach `kettle auth add` at all. `gen scaffold` writes files whole rather than splicing a region. The old refusal protected somebody's hand-written prose around the block; that prose is embedded now, so there is none to protect, and preserving local edits would freeze a project's documentation at whatever version first initialized it. `--check` warns before an upgrade discards one. The plugin's `agents-sync.sh` — 141 lines of Python behind a filename that said `.sh` — became `internal/mirror` and `kettle mirror`. Same seven branches, same refusal to merge two real files that differ, now with a table test per branch and a check that a repair converges in one pass. `--hook` is the PreToolUse form and exits 0 on every path including a panic. It is opt-in per project, which is strictly narrower than the plugin hook that was on for everybody who installed it. `kettle init --interactive` walks a person through the login, the token (read with the echo off, so it lands in no history and no file), the repository, the `.claude/` tree and the mirror hook. It refuses a stdin that is not a terminal and names the flags instead: every question it asks has one, and it performs nothing itself, so an interactive run and a flag run are one code path. Two rules that used to be prose are now the binary's: init refuses a linked worktree and names the main checkout, and writing into an existing `.claude/settings.json` is refused with the snippet printed rather than reformatting a file the operator commits. The scaffold version stamp went to its own `.kettle/scaffold.yaml` rather than into `config.yaml`, because unknown keys there are a hard error and that file may be committed and read by whatever build each machine has. golang.org/x/term becomes a direct dependency; it was already in the tree indirectly, so no module was added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.0 KiB
AGENTS.md — internal/scaffold
The documents kettle writes into a project: the slash commands an operator
invokes, the skills a model loads, and the runner subagent. Embedded with
//go:embed, handed out as bytes.
Imports the standard library and nothing else — and, unusually, is forbidden
os as well. These documents travel inside the binary; one os.ReadFile and
they are back to being files on a machine that may not have them, which is the
entire failure this package exists to make impossible. Two tests hold it, see
internal/AGENTS.md.
| file | what is in it |
|---|---|
scaffold.go |
Marker, File, Files, Groups, PathFor, Dirs, and the generated map |
assets/ |
every document, exactly as it is written out |
scaffold_test.go |
frontmatter, names, the region declaration, determinism |
layering_test.go |
the two tests that keep this package at the bottom |
Why these are not a plugin
They were, and the plugin shipped on its own release cadence. Nothing on an operator's machine ever checked that the plugin they had installed described the binary they had installed — so a renamed flag could ship with documentation recommending the old one, which is the exact failure the generated block was invented to prevent, one hop further downstream.
Prose that travels inside the binary cannot be a version behind it. That is the
whole argument, and everything else here follows from it: the generator writes
files whole rather than splicing a region, kettle init writes the tree, and
kettle config reports which build wrote the one on disk.
What was lost is worth naming. The marketplace was discovery — /plugin is a
shop window and a README URL is not — and a plugin's /kettle:… prefix came free
where a project's does not. The second was recoverable and is recovered below;
the first was not, and the trade was made anyway, because a document that
describes the wrong binary is worse than one nobody found.
The layout, and where the namespace went
commands/kettle/*.md → /kettle:init, /kettle:auth, /kettle:issue, …
skills/kettle-*/SKILL.md → loaded by description, never by name
skills/kettle-issue/references/format.md
agents/kettle-runner.md
A project's skills have no namespace: .claude/skills/ is flat, and a skill
called issue would collide with every other tool's. So the prefix is spelled
into the directory name — kettle-issue, not issue — and the name: in the
frontmatter matches it, because the harness resolves one from the other.
Commands do have a namespace, and it is free. A subdirectory under
.claude/commands/ becomes the prefix, so commands/kettle/init.md is
/kettle:init — the same spelling the plugin had, with no plugin. Every
/kettle:… in the embedded prose is still true because of this, which is why
four of the six command files exist at all: issue, sync, api and project
are three lines each, pointing at the skill that holds the substance. They are
not duplication; they are the reason 1,600 lines of cross-references did not have
to be rewritten.
init and auth are commands with no skill. That is not an omission. They
were skills carrying disable-model-invocation: true — a frontmatter flag saying
"an operator invokes this, not a model". A command is operator-invoked, so the
flag disappeared into the shape of the thing. init in particular must not be
routable: which directory is the project is the one question this binary exists
to have a person answer.
The generated region
Four documents carry <!-- kettle:gen --> markers around a flag table rendered
from the command registry. Which four is declared in the generated map, and
declared rather than derived from the directory name — the group ⇄ skill
correspondence is a decision, and one that has never been one-to-one: project
is a single skill covering five commands, and init and auth are commands
inside it with no skill of their own.
scaffold_test.go asserts the map and the markers agree in both directions, and
internal/cmd asserts every group in the registry is named
here. A group added without a document would have its commands silently written
nowhere — the generator walks the documents, not the registry.
This package does not render. It hands out embedded bytes and says which of
them carry a region; the splice is internal/cmd's, because the registry is. The
split is what keeps the import arrow pointing one way.
Bootstrapping, which is circular and is meant to be
assets/ holds the block already spliced in — the files here are what a project
gets, byte for byte. So:
make check # gen scaffold --check, against assets/
dist/kettle gen scaffold --out internal/scaffold/assets # the fix
A flag change makes the render differ from the embedded copy, --check exits 1,
regenerating updates assets/, and the binary has to be rebuilt to pick the
new bytes up. That loop is the same one the plugin's SKILL.md files were in; what
changed is that its output now ships with the binary rather than beside it.
Editing a document
Edit it here, under assets/, then make check. Never edit the copy in a
project: it is replaced whole on the next kettle gen scaffold, and the run says
so before it does it.
The one line no generator may write is description: — it is what decides
whether a model loads a skill at all, and it is prose tuned against real
failures. scaffold_test.go fails on an empty one; nothing checks that it is
good, and nothing can.
Keeping this file true
- Scope:
scaffold.go, everything underassets/, and the two seams — thegeneratedmap, and the commands ⇄ skills split. - Update it when a document is added or removed, a group gains or loses its file, the output layout changes, or the argument for embedding rather than shipping a plugin stops being the argument.
- Do not restate what any individual document says. Each one is its own procedure and says it in full.