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
+245
View File
@@ -0,0 +1,245 @@
// Package project answers one question: which directory is the project.
//
// Everything that is a fact about the project — the issue store, the request
// payload scratchpad, the tracker the issues belong to — is resolved from the
// answer, and the answer is found by one walk written once. The guard, the
// transport and the store used to each have their own copy of that walk in
// Python, and they disagreed: in a linked worktree `tea` worked while every
// script reported no login pinned.
//
// This package depends on nothing but the standard library, and nothing in it
// resolves from the binary's own location. Where an installation keeps its
// files is a fact about the installation; which issues a tree has is a fact
// about the tree, and a binary installed in one place and pointed at another
// must answer from the one it was pointed at.
package project
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Marker is the directory an operator creates to state "this is a project".
// It is never inferred. `.git` was tried and is in every clone, including this
// repository's own, so a plugin resolved its store inside itself.
const Marker = ".kettle"
// Everything under the marker, each resolved by the same walk so that which
// command wrote a file cannot change where it landed.
var (
storeParts = []string{Marker, "issues"}
payloadParts = []string{Marker, "payload"}
configParts = []string{Marker, "config.yaml"}
)
// Anchors are the directories a root search starts from, in order, first hit
// wins: the project the agent harness was opened on, then the working
// directory. A non-empty start overrides both and exists so resolution can be
// exercised against a scratch tree.
func Anchors(start string) []string {
if start != "" {
abs, err := filepath.Abs(start)
if err != nil {
return nil
}
return []string{abs}
}
var out []string
for _, d := range []string{os.Getenv("CLAUDE_PROJECT_DIR"), cwd()} {
if d == "" || !isDir(d) {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
if !contains(out, abs) {
out = append(out, abs)
}
}
return out
}
// Parents yields start and every ancestor of it, up to the filesystem root.
func Parents(start string) []string {
d, err := filepath.Abs(start)
if err != nil {
return nil
}
var out []string
for {
out = append(out, d)
parent := filepath.Dir(d)
if parent == d {
return out
}
d = parent
}
}
// GitDirOf is the private git directory `d/.git` points at, or "".
//
// Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a directory
// and there is nothing to follow.
func GitDirOf(d string) string {
p := filepath.Join(d, ".git")
fi, err := os.Stat(p)
if err != nil || fi.IsDir() {
return ""
}
head, err := os.ReadFile(p)
if err != nil {
return ""
}
for _, line := range strings.Split(string(head), "\n") {
line = strings.TrimSpace(line)
target, ok := strings.CutPrefix(line, "gitdir:")
if !ok {
continue
}
target = strings.TrimSpace(target)
if target == "" {
return ""
}
if !filepath.IsAbs(target) {
target = filepath.Join(d, target)
}
abs, err := filepath.Abs(target)
if err != nil {
return ""
}
return abs
}
return ""
}
// MainWorktree is the main working tree of d's repository when d is a linked
// worktree, or "".
//
// `<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir` file
// holds a path to `<main>/.git`; the main working tree is its parent. The
// `.git` basename check keeps this to worktrees: a submodule's `.git` is a
// pointer too, but it points into `<super>/.git/modules/…`, and the tree it
// belongs to is already on the parent chain.
func MainWorktree(d string) string {
gitdir := GitDirOf(d)
if gitdir == "" || !isDir(gitdir) {
return ""
}
common := gitdir
if raw, err := os.ReadFile(filepath.Join(gitdir, "commondir")); err == nil {
if rel := strings.TrimSpace(string(raw)); rel != "" {
if abs, err := filepath.Abs(filepath.Join(gitdir, rel)); err == nil {
common = abs
}
}
}
if filepath.Base(common) != ".git" {
return ""
}
root := filepath.Dir(common)
abs, err := filepath.Abs(d)
if err != nil {
return ""
}
if root != "" && isDir(root) && root != abs {
return root
}
return ""
}
// Root is the nearest ancestor of an anchor (inclusive) holding the marker, or
// "" when there is no project.
//
// A marker, not a fixed number of `..` hops: how deep a caller sits below the
// root is an implementation detail of the layout, and the layout is not a
// promise. Walking up means every command sees one store from anywhere inside
// the project — including from inside the store itself — while a cd into a
// DIFFERENT project correctly answers with that project's store.
//
// A linked worktree is the same project on another branch, and the marker is
// gitignored, so it is only ever in the main checkout: the chain is searched
// first and always wins, then the main working tree of any worktree met on it.
func Root(start string) string {
for _, anchor := range Anchors(start) {
var hops []string
for _, d := range Parents(anchor) {
if isDir(filepath.Join(d, Marker)) {
return d
}
if main := MainWorktree(d); main != "" && !contains(hops, main) {
hops = append(hops, main)
}
}
// One level of indirection, never two: a main checkout is not itself a
// linked worktree, so this cannot chain and cannot cycle.
for _, hop := range hops {
for _, d := range Parents(hop) {
if isDir(filepath.Join(d, Marker)) {
return d
}
}
}
}
return ""
}
// StoreRoot is the absolute path of the issue store, or "" with no project.
func StoreRoot(start string) string { return under(start, storeParts) }
// PayloadRoot is the absolute path of the request-payload scratchpad, or "".
//
// A sibling of the store under the same marker, resolved by the same walk, so
// the scratchpad and the store can never end up in two different projects —
// and so a scratchpad can never sit INSIDE a store, where a call that touched
// no issue would still materialize the issue directory.
func PayloadRoot(start string) string { return under(start, payloadParts) }
// ConfigPath is the absolute path of the project's tracker config, or "".
func ConfigPath(start string) string { return under(start, configParts) }
func under(start string, parts []string) string {
root := Root(start)
if root == "" {
return ""
}
return filepath.Join(append([]string{root}, parts...)...)
}
// NotFoundError explains why no project could be resolved, naming every
// directory the search began from.
//
// The anchors, not the whole chain above them: an operator who sees the two
// places the search started knows immediately whether it started where they
// meant it to.
func NotFoundError(start string) error {
dirs := strings.Join(Anchors(start), " and ")
if dirs == "" {
dirs = "nowhere"
}
return fmt.Errorf("no %s/ found — searched up from %s. Run `kettle init` in the project you mean to track issues in", Marker, dirs)
}
func isDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
func cwd() string {
d, err := os.Getwd()
if err != nil {
return ""
}
return d
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}