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>
20 KiB
name, description
| name | description |
|---|---|
| kettle-issue | Work with this project's issues as units of work — create, read, grep, validate, tick checkboxes, evict closed ones, and walk their dependency graph, with the `kettle` binary's offline commands (new, check, ac, tree, index, evict). Entirely offline; issues are local markdown files in `.kettle/issues/` and need no tracker, no login and no network. Load when the user asks to file or create an issue, read or find issues, check one against the format, or see what depends on what. Pushing to or pulling from Gitea is /kettle:sync. |
/kettle:issue — issues as units of work
An issue is a markdown file in <project>/.kettle/issues/. This skill covers
everything you do with an issue: writing one, reading one, checking it
against the canonical format, ticking its boxes, and walking the dependency
graph.
Nothing here touches the network. No tracker, no login, no token. An issue
that lives only on this machine is a first-class issue, not a draft waiting to
be uploaded. Synchronizing with a tracker is a separate, optional layer —
/kettle:sync.
Read references/format.md before creating or editing
an issue. It is the single source of truth for identity, metadata, types,
labels, templates, and language rules.
No kettle on PATH? command not found: kettle is the whole story — the
Python scripts this plugin used to ship are gone and no other CLI is a
substitute.
Stop and tell the operator to install it: cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle in the marketplace repository (go.mod requires
go 1.26), or go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest.
Identity: the slug
The file name is the id and the id is a slug —
.kettle/issues/wire-sqlc-appclick.md. It never changes: not when the title
changes, not when the issue is pushed somewhere. Tracker numbers live in a
metadata field (gitea: owner/repo#42), never in a file name and never in
depends:.
Consequence worth internalizing: #42 means nothing in this layer. Refer to
issues by id.
.kettle/issues/INDEX.md table of every issue — read this first
.kettle/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
.kettle/issues/wire-sqlc.comments.md comment thread (written by /kettle:sync only)
.kettle/issues/tree-<id>.md saved graph (kettle tree --write)
Where the store is
<project root>/.kettle/issues — not .kettle/issues relative to wherever
you are standing. The project root is the nearest directory up from where you
are that holds a .kettle/ marker: the binary walks up from
$CLAUDE_PROJECT_DIR, then from the working directory, and out of a linked
worktree to its main checkout. Every command sees one store no matter which
subdirectory it runs in, and a cd into a different project correctly answers
with that project's issues.
A project has a store because an operator ran /kettle:init in it. The
marker is never inferred from the tree — .git is in every clone. With no
marker anywhere, every command stops and names the directories it searched. It
does not fall back to a plausible directory. If you see that, either you are not
in the project you think you are, or nobody has initialized it: tell the operator
to run /kettle:init. It is theirs to run, and it carries the worktree and
migration-clash rules a bare kettle init does not.
--out overrides all of it and is taken literally: an absolute path as
given, a relative one relative to the working directory. kettle config prints
every path this directory resolved to and is the fastest way to explain a run
that went somewhere unexpected.
Two things follow, both deliberate: a store that is not there reports does not exist while a store with nothing in it reports is empty — different problems —
and nothing conjures a store as a side effect of a write.
Reading: grep, don't parse
Metadata is one field per line with inline lists precisely so plain grep
works. INDEX.md first, then the files:
grep -l 'labels:.*type/bug' .kettle/issues/*.md # all bugs
grep -l 'origin: local' .kettle/issues/*.md # never pushed anywhere
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md # who depends on it
grep -A3 '## Acceptance criteria' .kettle/issues/wire-*.md
grep -c '^- \[ \]' .kettle/issues/wire-sqlc-appclick.md # open checkboxes
Read whole files only for the issues the task actually needs.
Creating an issue
- Read the format:
references/format.md. - Pick the type —
bug,task,refactor,test,feature(a container for several issues with one business value), ordraft(an idea not ready for work). If it is not obvious from the request, ask the user; one question. - Scaffold it with
kettle new— English imperative title, no type prefix,--dependstakes ids. - Fill the sections with Edit — every section of the template present and
in order, headers English, prose Russian.
## Specgets a repo path, a URL, or the literalnone; ask the user if you cannot determine which. - Check it with
kettle check <id>.
One file = one issue. Several related issues = several files, linked through
depends:.
The issue is real and complete the moment the file exists. origin: local is a
finished state, not a draft — and while it says local, that file is the only
copy of the work. Publishing it to Gitea is a separate decision
(/kettle:sync) and it ends that state: a push hands the issue over and deletes
the file.
Editing an issue
Edit the file. Change state: to close it, edit labels:, add ids to
depends:. Re-run kettle check afterwards, and kettle index to refresh the
table. Checkboxes are the exception — use kettle ac.
If the issue is synced (origin: names a tracker), the file is a working copy:
your edit is local until kettle push --update, and that push deletes the
file once the tracker has it. Closing one of those is kettle close — it moves
the state on both sides in one run, where editing state: here alone would only
ever tell this machine. Get the file back with kettle pull <n>; the slug does
not change.
Ticking checkboxes
A checkbox is the one part of a body that is state and not prose, so it has
a command of its own. Never rewrite a body to tick a box: the rewrite re-flows
lines and re-words sentences, and the issue's diff swells around a change that
means one character. kettle ac <id> lists them numbered with their state,
--check / --uncheck take a number or a substring.
- Every checkbox in the body counts, not just
## Acceptance criteria. Atype/featurekeeps its children as checkboxes under## Issuesand they are in the same numbering. - A substring must match exactly one item. Two matches is an error listing both; pick by number. It never guesses.
- Exactly one character of the file changes. Wording, wrapping and trailing
whitespace come back byte for byte, so both
git diffand the tracker's diff show the tick and nothing else. - Examples inside a ``` fence are markup, not state — they are skipped.
- Whether a criterion is actually met is a judgement about content. Tick what the caller named, never what looks done.
Getting the tick to the tracker is a separate step — kettle push --update.
Writing a proper description
Issues get filed on the run — "comments aren't pulled", "the guard broke". That
is a request, not a statement of work: no reproduction steps, no
path/file:line, acceptance criteria nobody can check. Rewriting one into the
canonical format is a procedure, not improvisation.
-
Read the issue whole, and everything it points at — the ids in
depends:, the## Spectarget, the files it names. -
Determine the type and its template. The
type/*label selects one of the templates inreferences/format.md, and that template's section list is the shape you are aiming at. If the label is missing or wrong, decide it now and fixlabels:; promoting atype/draftto a concrete type is this same step. -
Locate the anchor points in the code. Grep the repo for every file, symbol, command and error string the issue mentions, until you can name lines. Work that does not exist yet still has anchor points — the files the change will land in, and the ones that will call it.
-
Gather the missing context. What has to be there when you are done:
- code references in the
path/file.ext:lineform, for every place the change lands; - reproduction steps — exact commands and their real output (
type/bugsplits them across## Steps to reproduce/## Expected/## Actual); - acceptance criteria that are objectively checkable: a command that exits 0, a file that exists, a section that is present — not aspirations;
- a real value for
## Spec— a repo path, a URL, or the literalnone.
A missing fact is either found in the repository or becomes a question to the user. Inventing one is forbidden. Ask in one batch, and keep
nonein## Specas the legitimate answer it is — never a plausible-looking link. - code references in the
-
Rewrite the sections with Edit: every section of the template, in the template's order, English headers and Russian prose. Replace the body; do not append a second telling of the same issue below the old one.
-
Check it with
kettle check <id>. Errors mean malformed, warnings mean the type's template is not fully filled in. Re-runkettle indexif the labels changed.
The procedure is identical for a local issue and a synced one — it works on
.kettle/issues/<id>.md and this layer does not know the difference. Getting the
rewritten body into the tracker is kettle push --update and is no part of this.
Evicting closed issues
The store is a working set, not an archive. A closed issue is not a unit of work
any more, and kettle evict takes it out — no rm, no rebuilding INDEX.md by
hand. Two conditions, both read off the file, and the second is the whole safety
argument:
state: |
origin: |
what eviction does |
|---|---|---|
closed |
a tracker | removes <id>.md and every sidecar under that slug |
closed |
local |
keeps it, always, and says why |
open |
anything | keeps it |
origin: local is never evicted, in any state, not even when you name it on
the command line. That file is the issue; there is no copy to fetch back.
Only a file whose own metadata says the work lives somewhere else may go — the
same trade a push makes when it drops a file the tracker just confirmed.
.remote.json is deliberately not pruned: it is the number → slug ledger and
its entries are supposed to outlive the files they name, which is what makes a
later kettle pull <n> land on the same slug. And eviction is not a one-off
migration — a pull by number fetches an issue in any state, so a closed issue
pulled after an eviction lands on disk again. Evict it again when you are done.
This command decides from state: in the file, which is only as fresh as the
last pull. To have the tracker's answer instead — an issue closed in the web UI
five minutes ago — use kettle sync-evict from /kettle:sync, which refreshes
state: first and then makes exactly this decision.
Dependency graph
depends: is the authoritative edge list; the body's ## Depends on section is
prose for humans, and kettle check warns when they disagree. kettle tree
draws downwards — what an issue depends on. The other direction is a grep, not a
flag:
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md
A type/feature plus its children read as one document: draw the tree once for
the shape, then grep the files.
Layering rule
Everything below is offline. No command in this skill opens a socket, reads a
token, or knows what an issue number is — that is /kettle:sync, and the domain
would not notice if the tracker did not exist. If you find yourself wanting a
tracker concept here — a number, a login, an HTTP call, a label colour — it
belongs on the other side of that line.
The commands themselves follow. Their usage lines, flags, defaults and examples
are generated from the binary's own command registry, so they cannot disagree
with the binary; kettle help <command> prints the same text. Editing them here
changes nothing.
Generated from the kettle command registry by kettle gen scaffold. Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
kettle ac <id>
list and tick an issue's checkboxes
A checkbox is the one part of a body that is state and not prose. Everything else is written once; boxes get ticked as the work goes, and the only other ways to tick one are a human with an editor or a model rewriting the whole body — the second worse than the first, because the rewrite re-flows the text and the issue's diff swells around a change of one character. This changes that one character and nothing else.
Named after ## Acceptance criteria, where most boxes live, but every checkbox in
the body is listed and tickable: a type/feature keeps its children under
## Issues, and binding this to one heading would silently lose half of them.
A substring picks an item only when it picks exactly one. Two matches is an error listing both — a coin flip would tick the wrong box and look like it worked.
Delivering the changed body to a tracker is not part of this; that is
kettle push --update.
| flag | default | what it does |
|---|---|---|
--check |
— | tick one item: number or substring |
--out |
— | store root (default: /.kettle/issues) |
--uncheck |
— | untick one item: number or substring |
kettle ac wire-sqlc-appclick # numbered list with state
kettle ac wire-sqlc-appclick --check 3 # tick by number
kettle ac wire-sqlc-appclick --check регресс # tick by substring
kettle ac wire-sqlc-appclick --uncheck 3 # untick it again
kettle check [<id>…]
validate issues against the canonical format
The same check the sync layer runs before it pushes anything, available on its own so a local-only issue can be held to the format without a tracker being involved.
Errors mean malformed; warnings mean it deviates from its type's template or its graph looks suspect. An unticked checkbox is neither: work not done yet is the normal state of a perfectly well-formed issue.
Exit status is 1 when anything has errors, which is what makes this usable in a hook or a CI step.
| flag | default | what it does |
|---|---|---|
--out |
— | store root (default: /.kettle/issues) |
--quiet |
false |
exit status only, print nothing |
--strict |
false |
treat warnings as errors |
kettle check # every issue in the store
kettle check wire-sqlc-appclick # one issue
kettle check --quiet # exit status only
kettle check --strict # treat warnings as errors
kettle evict [<id>…]
remove closed issues from the local store
The store is a working set, not an archive. What is evicted is two conditions, both read off the file:
state: closed the work is done
origin: <tracker> the work is somewhere else too
THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. origin: local means this
file IS the issue — there is no other copy and deleting it deletes the work. It
is never evicted, in any state, not even when named explicitly on the command
line: a closed local issue is reported and kept.
Eviction asks the file rather than the tracker, because state and origin are
domain fields and the answer is already in the store — which is why this needs
no network and no login. kettle sync-evict is the variant that refreshes state
from the tracker first and then makes the same decision.
Not a one-off migration: a pull by number fetches an issue in any state, so a closed issue pulled after an eviction lands on disk again. Evict it again when you are done with it.
INDEX.md is rebuilt, because it IS a view of the directory. The number -> slug ledger is deliberately not pruned: its entries outlive the files they name, and that is what makes a pull land on the same slug afterwards.
| flag | default | what it does |
|---|---|---|
--dry-run |
false |
print what would be removed; touch nothing |
--out |
— | store root (default: /.kettle/issues) |
kettle evict # every closed issue that is not origin: local
kettle evict old-thing another-thing # only these
kettle evict --dry-run # print what would go; touch nothing
kettle index
rebuild INDEX.md from what is on disk
A map of the local store, nothing else. The origin column is the only place
the index acknowledges that a tracker exists: local means the issue has never
left this machine, anything else names the tracker it also lives in. Both are
ordinary issues here.
progress counts the body's checkboxes, ticked over total, and is read off the
body at build time rather than stored — a second copy of that state in a
metadata field would be wrong by the next edit.
An existing store with nothing in it is a legitimate thing to index and gets an "empty" table. A store that is not there is an error, not a directory to create.
| flag | default | what it does |
|---|---|---|
--out |
— | store root (default: /.kettle/issues) |
kettle index # rebuild the index for this project
kettle new
create a local issue from its type template
The issue is real the moment this writes the file. Nothing is pending, nothing
is a draft awaiting a tracker: origin: local is a complete state and pushing it
later is optional.
While it says local, this file is the ONLY copy of the work — the store, not a cache of anything. That is what a push changes: it hands the issue to the tracker and removes the file.
Writes .kettle/issues/.md prefilled with the type's template, prints the
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
kettle check <id>.
Body prose is Russian, section headers and the title are English.
| flag | default | what it does |
|---|---|---|
--assignee |
— | assignee login; repeat |
--depends |
— | id this issue depends on; repeat |
--id |
— | slug (default: derived from the title) |
--label |
— | extra label, e.g. tech/sql; repeat |
--milestone |
— | milestone title |
--out |
— | store root (default: /.kettle/issues) |
--severity |
— | severity/* label, one of: low, medium, high, showstopper, critical |
--title |
— | English, imperative, no type prefix |
--type |
— | issue type, one of: bug, task, refactor, test, feature, draft (becomes the exclusive type/* label) |
kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick # a task with two free-form labels
kettle new --type bug --title "Fix the index rebuild on an empty store" --depends wire-sqlc-appclick --milestone v0.2 # a bug that is blocked by another issue
kettle tree [<id>…]
draw the dependency graph of the local store
Edges come from the depends: metadata, which is the authoritative edge list;
prose in the body is never walked. Because the graph is slugs all the way down,
this works identically for issues that were never pushed anywhere.
Downwards is what this draws — what an issue depends on. The other direction is a grep, not a flag:
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md
| flag | default | what it does |
|---|---|---|
--depth |
6 |
maximum depth |
--out |
— | store root (default: /.kettle/issues) |
--write |
false |
also write /tree-.md |
kettle tree # every root (nothing depends on it)
kettle tree wire-sqlc-appclick # one subtree
kettle tree --depth 2 --write # shallow, and saved beside the issues