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
+80
View File
@@ -0,0 +1,80 @@
package issue
import (
"regexp"
"sort"
"strings"
)
var titleRe = regexp.MustCompile(`^#[ \t]+(.+?)[ \t]*\n`)
// ParseMeta splits a file into its metadata block, title, and body.
//
// Values come back as the raw text that followed the colon. Lists are not
// unpacked here: a foreign key that happens to look like a list must round
// trip byte for byte, and the domain's own lists are unpacked by their
// accessors. The title is the first `# ` heading below the block and is
// stripped out of the body.
func ParseMeta(text string) (meta map[string]string, title, body string) {
meta = map[string]string{}
rest := text
if strings.HasPrefix(text, "---") {
if end := strings.Index(text[3:], "\n---"); end != -1 {
end += 3
for _, line := range strings.Split(strings.TrimSpace(text[3:end]), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
meta[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
rest = text[end+4:]
}
}
rest = strings.TrimLeft(rest, "\n")
if m := titleRe.FindStringSubmatchIndex(rest); m != nil {
title = strings.TrimSpace(rest[m[2]:m[3]])
rest = strings.TrimLeft(rest[m[1]:], "\n")
}
return meta, title, rest
}
// RenderMeta writes the block back: domain keys in DomainKeys order, foreign
// keys after them, sorted. Lists stay on one line so grep sees them whole.
func RenderMeta(meta map[string]string) string {
var foreign []string
for k := range meta {
if !isDomainKey(k) {
foreign = append(foreign, k)
}
}
sort.Strings(foreign)
lines := []string{"---"}
for _, k := range append(append([]string{}, DomainKeys...), foreign...) {
if v, ok := meta[k]; ok {
lines = append(lines, k+": "+v)
}
}
return strings.Join(append(lines, "---"), "\n")
}
// splitList unpacks the inline `[a, b]` form, and a bare comma-separated value
// too: a hand-written `labels: type/bug` is the same statement as
// `labels: [type/bug]` and the format does not make an operator care.
func splitList(v string) []string {
v = strings.TrimSpace(v)
if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
v = v[1 : len(v)-1]
}
var out []string
for _, part := range strings.Split(v, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
func renderList(xs []string) string { return "[" + strings.Join(xs, ", ") + "]" }