feat: add the kettle CLI, replacing the plugin's Python scripts

The plugin resolved its issue store from `__file__`, which put it inside a
versioned plugin cache: issues written from one project were invisible from the
next, and `origin: local` files — the only copy of that work by definition —
were stranded a version bump at a time. The walk that answers "which directory
is the project" was written three times over, and in a linked worktree the three
disagreed. Both are runtime failures rather than logic ones, so the fix is a
compiled binary: one walk, imported rather than re-derived, and a layering rule
the build graph enforces instead of a grep.

Seven packages, knowledge flowing one way. `project` answers which directory is
the project and depends on nothing. `issue` is the domain — format, taxonomy,
validation, checkboxes, dependency graph, the store, eviction — offline, with no
tracker in it. `wire` holds the protocol shapes. `gitea` is the transport,
`mapping` the bridge, `config` the credentials, `cmd` the command tree. Four
tests hold the boundaries, each failing on a real mistake rather than a naming
convention.

The marker moves to `.kettle/` and the login pin moves out of the harness's
settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens
live in one file per machine, mode 0600, outside every working tree. That
retires the PreToolUse guard hook entirely — the binary holds its own
credentials, so a command running under a login nobody chose is not expressible
rather than caught.

`kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a
move: a store left behind at an old path is one somebody edits by accident
months later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-11 19:05:39 +05:00
parent fb5445915f
commit 9480e48312
83 changed files with 23894 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
package issue
import "strings"
// Four namespaces classify an issue. type/* is mandatory and exclusive,
// severity/* is optional and exclusive, tech/* and comp/* are free-form.
//
// Colors are NOT here — a hex code is how a tracker paints a chip, which makes
// it the sync layer's business.
// Types are the kinds of work, and the order is the order they are offered in.
var Types = []struct{ Name, Meaning string }{
{"bug", "Something behaves incorrectly in existing code"},
{"task", "Implementation of new functionality"},
{"refactor", "Internal restructuring; behavior must not change"},
{"test", "Writing or fixing tests"},
{"feature", "Container: several issues delivering one unit of business value"},
{"draft", "Idea captured for later; not ready for work"},
}
// Severities are the business-impact levels, ascending.
var Severities = []string{"low", "medium", "high", "showstopper", "critical"}
// Section headers are fixed English literals in a fixed order; only body prose
// is Russian.
const (
SummarySection = "## Summary"
SpecSection = "## Spec"
ACSection = "## Acceptance criteria"
DependsSection = "## Depends on"
IssuesSection = "## Issues"
)
// RequiredSections must be present in every type. type/draft is exempt from
// acceptance criteria and only from that.
var RequiredSections = []string{SummarySection, SpecSection}
// DepSections both name what an issue depends on, so both are edge sources and
// both point the same way. In a type/feature that reads container -> child:
// "the container is closed when its children are closed" IS a dependency.
// "a child belongs to a feature" is membership, and membership has no place in
// a dependency graph — which is why a child never names its container back.
var DepSections = []string{DependsSection, IssuesSection}
// ExpectedSections are the per-type sections from the templates. Absence is a
// warning, not a stop.
var ExpectedSections = map[string][]string{
"bug": {"## Steps to reproduce", "## Expected", "## Actual", "## Environment"},
"task": {"## Motivation"},
"refactor": {"## Motivation", "## Invariants"},
"test": {"## Motivation", "## Test cases"},
"feature": {"## Motivation", IssuesSection},
"draft": {"## Notes"},
}
// KnownType reports whether name is one of Types.
func KnownType(name string) bool {
for _, t := range Types {
if t.Name == name {
return true
}
}
return false
}
// KnownSeverity reports whether name is one of Severities.
func KnownSeverity(name string) bool {
for _, s := range Severities {
if s == name {
return true
}
}
return false
}
// TypeNames lists the type slugs, for error messages and completion.
func TypeNames() []string {
out := make([]string, len(Types))
for i, t := range Types {
out[i] = t.Name
}
return out
}
// CanonicalLabels is the label set a tracker needs before a push can attach
// anything: the two exclusive namespaces in full. tech/* and comp/* are
// project-specific and have no preset.
func CanonicalLabels() []string {
out := make([]string, 0, len(Types)+len(Severities))
for _, t := range Types {
out = append(out, "type/"+t.Name)
}
for _, s := range Severities {
out = append(out, "severity/"+s)
}
return out
}
// SectionBody is the text under header, up to the next `## ` heading.
func SectionBody(body, header string) string {
var out []string
active := false
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "## ") {
if active {
break
}
active = strings.TrimSpace(line) == header
continue
}
if active {
out = append(out, line)
}
}
return strings.TrimSpace(strings.Join(out, "\n"))
}