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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user