From 19d77698ead9b6a7cb7d0e9f686f7dc91c405f4e Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 17:30:12 +0000 Subject: [PATCH 01/11] feat(go): port release-contract to native Go (#94) First command of the full sumctl Python->Go port: read-only, no subprocess/state dependencies, so it is a clean starting slice. Differential test runs the real Python bin/sumctl release-contract and asserts byte-identical stdout against the Go implementation. --- go/internal/cli/release_contract_test.go | 37 ++++++++++++++++++++++ go/internal/cli/root.go | 19 +++++++++++ go/internal/contract/contract.go | 40 ++++++++++++++++++++++++ go/internal/contract/contract_test.go | 36 +++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 go/internal/cli/release_contract_test.go create mode 100644 go/internal/contract/contract.go create mode 100644 go/internal/contract/contract_test.go diff --git a/go/internal/cli/release_contract_test.go b/go/internal/cli/release_contract_test.go new file mode 100644 index 0000000..c260ab7 --- /dev/null +++ b/go/internal/cli/release_contract_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "bytes" + "context" + "os/exec" + "path/filepath" + "testing" +) + +func TestReleaseContract_matchesThePythonReferenceByteForByte(t *testing.T) { + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := exec.LookPath("python3"); statErr != nil { + t.Skip("python3 not on PATH") + } + + home := t.TempDir() + want, err := exec.Command(reference, "--home", home, "release-contract").Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs([]string{"--home", home, "release-contract"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go release-contract failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } +} diff --git a/go/internal/cli/root.go b/go/internal/cli/root.go index b77241f..216e013 100644 --- a/go/internal/cli/root.go +++ b/go/internal/cli/root.go @@ -2,11 +2,13 @@ package cli import ( "context" + "encoding/json" "fmt" "io" "os" "os/exec" + "github.com/douglasjarquin/sum/go/internal/contract" "github.com/spf13/cobra" ) @@ -71,6 +73,14 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { _, _ = io.WriteString(cmd.OutOrStdout(), cmd.UsageString()) }) + root.AddCommand(&cobra.Command{ + Use: "release-contract", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return emitJSON(cmd.OutOrStdout(), contract.BuildRelease()) + }, + }) + for _, name := range compatibilityCommands { command := &cobra.Command{ Use: name, @@ -111,6 +121,15 @@ func (o *rootOptions) compat(ctx context.Context, args []string) error { return nil } +func emitJSON(out io.Writer, value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(encoded)) + return err +} + func normalizeHome(args []string, home string, homeSet bool) []string { var prefix []string if homeSet { diff --git a/go/internal/contract/contract.go b/go/internal/contract/contract.go new file mode 100644 index 0000000..3f0df05 --- /dev/null +++ b/go/internal/contract/contract.go @@ -0,0 +1,40 @@ +package contract + +const ( + SumVersion = "0.1.0" + StateSchema = 1 + BriefSchema = 1 + HerdrCLI = "0.9.0" +) + +var MCP = MCPContract{Server: "herdr-mesh-sum", Version: SumVersion, Tools: 10} + +type MCPContract struct { + Server string `json:"server"` + Version string `json:"version"` + Tools int `json:"tools"` +} + +type Contracts struct { + HerdrCLI string `json:"herdr_cli"` + MCP MCPContract `json:"mcp"` +} + +type Supports struct { + StateSchema []int `json:"state_schema"` + BriefSchema []int `json:"brief_schema"` +} + +type Release struct { + SumVersion string `json:"sum_version"` + Contracts Contracts `json:"contracts"` + Supports Supports `json:"supports"` +} + +func BuildRelease() Release { + return Release{ + SumVersion: SumVersion, + Contracts: Contracts{HerdrCLI: HerdrCLI, MCP: MCP}, + Supports: Supports{StateSchema: []int{StateSchema}, BriefSchema: []int{BriefSchema}}, + } +} diff --git a/go/internal/contract/contract_test.go b/go/internal/contract/contract_test.go new file mode 100644 index 0000000..8bb0e27 --- /dev/null +++ b/go/internal/contract/contract_test.go @@ -0,0 +1,36 @@ +package contract + +import ( + "encoding/json" + "testing" +) + +func TestBuildRelease_matchesThePythonReferenceShape(t *testing.T) { + release := BuildRelease() + encoded, err := json.MarshalIndent(release, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{ + "sum_version": "0.1.0", + "contracts": { + "herdr_cli": "0.9.0", + "mcp": { + "server": "herdr-mesh-sum", + "version": "0.1.0", + "tools": 10 + } + }, + "supports": { + "state_schema": [ + 1 + ], + "brief_schema": [ + 1 + ] + } +}` + if string(encoded) != want { + t.Fatalf("release contract JSON =\n%s\nwant\n%s", encoded, want) + } +} From 6588b9ccf07bf732368e380ac09f5909d28c434e Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 18:06:30 +0000 Subject: [PATCH 02/11] feat(go): port ordered-JSON I/O and the Store task-record foundation (#94) Most remaining sumctl commands read/write task.json, state.json, and settings via lib/sumctl.py's atomic_json/read_json/Store. Go's encoding/json can't preserve Python dict insertion order on round-trip, so add ordjson.Object (order-preserving JSON object) with an encoder matching lib/sumctl.py's exact `json.dumps(value, indent=2, ensure_ascii=True)` output (2-space indent, empty containers stay inline, non-ASCII escaped to \uXXXX with surrogate pairs) and atomic file writes matching atomic_json (temp file in the same dir, fsync, rename, fsync directory). store.Store ports Store's __init__/init/lock/path/read/save/all methods (task ID validation, symlink refusal, flock-based exclusive locking, sorted task listing). Differential-tested: ordjson.WriteFile against the real Python atomic_json via a small reference script that loads lib/sumctl.py with importlib, for a payload covering nesting, arrays, and non-ASCII/control-character escaping. Session/registration/designated-owner methods are not yet ported; they aren't needed until doctor/status land. --- go/internal/ordjson/differential_test.go | 61 +++++ go/internal/ordjson/io.go | 61 +++++ go/internal/ordjson/ordjson.go | 233 ++++++++++++++++++ go/internal/ordjson/ordjson_test.go | 80 ++++++ .../ordjson/testdata/atomic_json_ref.py | 11 + go/internal/store/store.go | 208 ++++++++++++++++ go/internal/store/store_test.go | 147 +++++++++++ 7 files changed, 801 insertions(+) create mode 100644 go/internal/ordjson/differential_test.go create mode 100644 go/internal/ordjson/io.go create mode 100644 go/internal/ordjson/ordjson.go create mode 100644 go/internal/ordjson/ordjson_test.go create mode 100644 go/internal/ordjson/testdata/atomic_json_ref.py create mode 100644 go/internal/store/store.go create mode 100644 go/internal/store/store_test.go diff --git a/go/internal/ordjson/differential_test.go b/go/internal/ordjson/differential_test.go new file mode 100644 index 0000000..a97eb09 --- /dev/null +++ b/go/internal/ordjson/differential_test.go @@ -0,0 +1,61 @@ +package ordjson + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestWriteFile_matchesPythonAtomicJSONByteForByte(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + libPath := filepath.Join(repoRoot, "lib", "sumctl.py") + if _, err := os.Stat(libPath); err != nil { + t.Skipf("reference lib/sumctl.py not found: %v", err) + } + + eAcute := string(rune(0x00e9)) + musicalSymbol := string(rune(0x1d11e)) + stringValue := strings.Join([]string{ + "caf" + eAcute, + `\n`, + `\"`, + `\\`, + musicalSymbol, + }, " ") + input := `{"b": 1, "a": {"nested": true, "list": [1, 2, "x"]}, "s": "` + stringValue + `"}` + + pythonOut := filepath.Join(t.TempDir(), "python.json") + cmd := exec.Command("python3", "testdata/atomic_json_ref.py", libPath, pythonOut, input) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("python reference failed: %v\n%s", err, out) + } + want, err := os.ReadFile(pythonOut) + if err != nil { + t.Fatal(err) + } + + value, err := Decode([]byte(input)) + if err != nil { + t.Fatalf("decode: %v", err) + } + goOut := filepath.Join(t.TempDir(), "go.json") + if err := WriteFile(goOut, value); err != nil { + t.Fatalf("write: %v", err) + } + got, err := os.ReadFile(goOut) + if err != nil { + t.Fatal(err) + } + + if string(got) != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", got, want) + } +} diff --git a/go/internal/ordjson/io.go b/go/internal/ordjson/io.go new file mode 100644 index 0000000..de2d4a9 --- /dev/null +++ b/go/internal/ordjson/io.go @@ -0,0 +1,61 @@ +package ordjson + +import ( + "fmt" + "os" + "path/filepath" +) + +func ReadFile(path string) (any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("cannot read %s: %w", path, err) + } + value, err := Decode(data) + if err != nil { + return nil, fmt.Errorf("cannot read %s: %w", path, err) + } + return value, nil +} + +func WriteFile(path string, value any) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + encoded, err := MarshalIndent(value) + if err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".write-") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(encoded); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write([]byte("\n")); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + directory, err := os.Open(dir) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} diff --git a/go/internal/ordjson/ordjson.go b/go/internal/ordjson/ordjson.go new file mode 100644 index 0000000..d4d747b --- /dev/null +++ b/go/internal/ordjson/ordjson.go @@ -0,0 +1,233 @@ +package ordjson + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" +) + +type Object struct { + keys []string + values map[string]any +} + +func NewObject() *Object { + return &Object{values: map[string]any{}} +} + +func (o *Object) Set(key string, value any) { + if o.values == nil { + o.values = map[string]any{} + } + if _, exists := o.values[key]; !exists { + o.keys = append(o.keys, key) + } + o.values[key] = value +} + +func (o *Object) Get(key string) (any, bool) { + v, ok := o.values[key] + return v, ok +} + +func (o *Object) Delete(key string) { + if _, exists := o.values[key]; !exists { + return + } + delete(o.values, key) + for i, k := range o.keys { + if k == key { + o.keys = append(o.keys[:i], o.keys[i+1:]...) + break + } + } +} + +func (o *Object) Keys() []string { + return append([]string(nil), o.keys...) +} + +func (o *Object) Len() int { + return len(o.keys) +} + +func Decode(data []byte) (any, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + value, err := decodeValue(dec) + if err != nil { + return nil, err + } + if dec.More() { + return nil, fmt.Errorf("trailing data after JSON value") + } + return value, nil +} + +func decodeValue(dec *json.Decoder) (any, error) { + tok, err := dec.Token() + if err != nil { + return nil, err + } + return decodeToken(dec, tok) +} + +func decodeToken(dec *json.Decoder, tok json.Token) (any, error) { + delim, ok := tok.(json.Delim) + if !ok { + return tok, nil + } + switch delim { + case '{': + obj := NewObject() + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + key, ok := keyTok.(string) + if !ok { + return nil, fmt.Errorf("expected string object key") + } + val, err := decodeValue(dec) + if err != nil { + return nil, err + } + obj.Set(key, val) + } + if _, err := dec.Token(); err != nil { + return nil, err + } + return obj, nil + case '[': + arr := []any{} + for dec.More() { + val, err := decodeValue(dec) + if err != nil { + return nil, err + } + arr = append(arr, val) + } + if _, err := dec.Token(); err != nil { + return nil, err + } + return arr, nil + default: + return nil, fmt.Errorf("unexpected delimiter %v", delim) + } +} + +const indentUnit = " " + +func MarshalIndent(value any) ([]byte, error) { + var buf bytes.Buffer + if err := encodeIndent(&buf, value, 0); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeIndent(buf *bytes.Buffer, level int) { + for range level { + buf.WriteString(indentUnit) + } +} + +func encodeIndent(buf *bytes.Buffer, value any, level int) error { + switch v := value.(type) { + case *Object: + if v.Len() == 0 { + buf.WriteString("{}") + return nil + } + buf.WriteString("{\n") + for i, k := range v.keys { + writeIndent(buf, level+1) + encodeString(buf, k) + buf.WriteString(": ") + if err := encodeIndent(buf, v.values[k], level+1); err != nil { + return err + } + if i < len(v.keys)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + writeIndent(buf, level) + buf.WriteByte('}') + case []any: + if len(v) == 0 { + buf.WriteString("[]") + return nil + } + buf.WriteString("[\n") + for i, item := range v { + writeIndent(buf, level+1) + if err := encodeIndent(buf, item, level+1); err != nil { + return err + } + if i < len(v)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + writeIndent(buf, level) + buf.WriteByte(']') + case string: + encodeString(buf, v) + case json.Number: + buf.WriteString(string(v)) + case bool: + if v { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case nil: + buf.WriteString("null") + case int: + buf.WriteString(strconv.Itoa(v)) + case int64: + buf.WriteString(strconv.FormatInt(v, 10)) + case float64: + buf.WriteString(strconv.FormatFloat(v, 'g', -1, 64)) + default: + return fmt.Errorf("ordjson: unsupported type %T", value) + } + return nil +} + +func encodeString(buf *bytes.Buffer, s string) { + buf.WriteByte('"') + for _, r := range s { + switch { + case r == '\\': + buf.WriteString(`\\`) + case r == '"': + buf.WriteString(`\"`) + case r == '\b': + buf.WriteString(`\b`) + case r == '\f': + buf.WriteString(`\f`) + case r == '\n': + buf.WriteString(`\n`) + case r == '\r': + buf.WriteString(`\r`) + case r == '\t': + buf.WriteString(`\t`) + case r < 0x20: + fmt.Fprintf(buf, `\u%04x`, r) + case r >= 0x20 && r <= 0x7e: + buf.WriteRune(r) + case r < 0x10000: + fmt.Fprintf(buf, `\u%04x`, r) + default: + r2 := r - 0x10000 + hi := 0xd800 | ((r2 >> 10) & 0x3ff) + lo := 0xdc00 | (r2 & 0x3ff) + fmt.Fprintf(buf, `\u%04x\u%04x`, hi, lo) + } + } + buf.WriteByte('"') +} diff --git a/go/internal/ordjson/ordjson_test.go b/go/internal/ordjson/ordjson_test.go new file mode 100644 index 0000000..4abc39b --- /dev/null +++ b/go/internal/ordjson/ordjson_test.go @@ -0,0 +1,80 @@ +package ordjson + +import "testing" + +func TestDecodeThenMarshalIndent_preservesInsertionOrder(t *testing.T) { + input := `{"b": 1, "a": 2, "c": {"z": 1, "y": 2}}` + value, err := Decode([]byte(input)) + if err != nil { + t.Fatalf("decode: %v", err) + } + obj, ok := value.(*Object) + if !ok { + t.Fatalf("value = %T, want *Object", value) + } + if got, want := obj.Keys(), []string{"b", "a", "c"}; !equalStrings(got, want) { + t.Fatalf("keys = %v, want %v", got, want) + } + encoded, err := MarshalIndent(obj) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "{\n \"b\": 1,\n \"a\": 2,\n \"c\": {\n \"z\": 1,\n \"y\": 2\n }\n}" + if string(encoded) != want { + t.Fatalf("encoded =\n%s\nwant\n%s", encoded, want) + } +} + +func TestMarshalIndent_emptyContainersStayInline(t *testing.T) { + obj := NewObject() + obj.Set("list", []any{}) + obj.Set("obj", NewObject()) + encoded, err := MarshalIndent(obj) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "{\n \"list\": [],\n \"obj\": {}\n}" + if string(encoded) != want { + t.Fatalf("encoded =\n%s\nwant\n%s", encoded, want) + } +} + +func TestMarshalIndent_escapesNonASCIILikePythonEnsureASCII(t *testing.T) { + obj := NewObject() + input := "caf" + string(rune(0xe9)) + " " + string(rune(0x01)) + " \n \" \\ " + string(rune(0x1d11e)) + obj.Set("s", input) + encoded, err := MarshalIndent(obj) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "{\n \"s\": \"caf\\u00e9 \\u0001 \\n \\\" \\\\ \\ud834\\udd1e\"\n}" + if string(encoded) != want { + t.Fatalf("encoded =\n%s\nwant\n%s", encoded, want) + } +} + +func TestSet_updatesInPlaceWithoutReordering(t *testing.T) { + obj := NewObject() + obj.Set("a", 1) + obj.Set("b", 2) + obj.Set("a", 3) + if got, want := obj.Keys(), []string{"a", "b"}; !equalStrings(got, want) { + t.Fatalf("keys = %v, want %v", got, want) + } + value, _ := obj.Get("a") + if value != 3 { + t.Fatalf("a = %v, want 3", value) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/go/internal/ordjson/testdata/atomic_json_ref.py b/go/internal/ordjson/testdata/atomic_json_ref.py new file mode 100644 index 0000000..2006f5c --- /dev/null +++ b/go/internal/ordjson/testdata/atomic_json_ref.py @@ -0,0 +1,11 @@ +import importlib.util +import json +import sys + +spec = importlib.util.spec_from_file_location("sumctl_ref", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +output_path = sys.argv[2] +value = json.loads(sys.argv[3]) +module.atomic_json(output_path, value) diff --git a/go/internal/store/store.go b/go/internal/store/store.go new file mode 100644 index 0000000..2de1f4d --- /dev/null +++ b/go/internal/store/store.go @@ -0,0 +1,208 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "syscall" + "time" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +const Schema = 1 + +const SumVersion = "0.1.0" + +var taskIDPattern = regexp.MustCompile(`^t-[a-f0-9]{12}$`) + +type Store struct { + Home string + Tasks string +} + +func Open(home string) (*Store, error) { + resolved, err := resolveHome(home) + if err != nil { + return nil, err + } + s := &Store{Home: resolved, Tasks: filepath.Join(resolved, "tasks")} + statePath := filepath.Join(resolved, "state.json") + if info, statErr := os.Stat(statePath); statErr == nil && !info.IsDir() { + value, readErr := ordjson.ReadFile(statePath) + if readErr != nil { + return nil, readErr + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("state.json is not a JSON object") + } + if !schemaMatches(obj) { + return nil, fmt.Errorf("unsupported state schema. Preserve the original; use the matching sum release. No in-place migration") + } + } + return s, nil +} + +func schemaMatches(state *ordjson.Object) bool { + value, ok := state.Get("schema") + if !ok { + return false + } + number, ok := value.(json.Number) + if !ok { + return false + } + n, err := number.Int64() + return err == nil && n == Schema +} + +func resolveHome(home string) (string, error) { + expanded, err := expandUser(home) + if err != nil { + return "", err + } + absolute, err := filepath.Abs(expanded) + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + return resolved, nil + } + return absolute, nil +} + +func expandUser(path string) (string, error) { + if path != "~" && !hasHomePrefix(path) { + return path, nil + } + dir, err := os.UserHomeDir() + if err != nil { + return "", err + } + if path == "~" { + return dir, nil + } + return filepath.Join(dir, path[2:]), nil +} + +func hasHomePrefix(path string) bool { + return len(path) >= 2 && path[0] == '~' && path[1] == filepath.Separator +} + +func (s *Store) Init() error { + if err := os.MkdirAll(s.Home, 0o700); err != nil { + return err + } + if err := os.MkdirAll(s.Tasks, 0o700); err != nil { + return err + } + statePath := filepath.Join(s.Home, "state.json") + if _, err := os.Stat(statePath); os.IsNotExist(err) { + state := ordjson.NewObject() + state.Set("schema", json.Number(fmt.Sprint(Schema))) + state.Set("sum_version", SumVersion) + state.Set("created_at", Now()) + return ordjson.WriteFile(statePath, state) + } + return nil +} + +func (s *Store) Lock() (func() error, error) { + if err := s.Init(); err != nil { + return nil, err + } + handle, err := os.OpenFile(filepath.Join(s.Home, ".lock"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(handle.Fd()), syscall.LOCK_EX); err != nil { + handle.Close() + return nil, err + } + return func() error { + unlockErr := syscall.Flock(int(handle.Fd()), syscall.LOCK_UN) + closeErr := handle.Close() + if unlockErr != nil { + return unlockErr + } + return closeErr + }, nil +} + +func (s *Store) TaskPath(taskID string) (string, error) { + if !taskIDPattern.MatchString(taskID) { + return "", fmt.Errorf("invalid task ID") + } + path := filepath.Join(s.Tasks, taskID) + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("task directories must not be symlinks") + } + return path, nil +} + +func (s *Store) ReadTask(taskID string) (*ordjson.Object, error) { + path, err := s.TaskPath(taskID) + if err != nil { + return nil, err + } + value, err := ordjson.ReadFile(filepath.Join(path, "task.json")) + if err != nil { + return nil, err + } + task, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("task.json is not a JSON object") + } + schema, _ := task.Get("schema") + id, _ := task.Get("id") + number, isNumber := schema.(json.Number) + schemaOK := isNumber + if isNumber { + n, convErr := number.Int64() + schemaOK = convErr == nil && n == Schema + } + if !schemaOK || id != taskID { + return nil, fmt.Errorf("task identity/schema mismatch") + } + return task, nil +} + +func (s *Store) SaveTask(task *ordjson.Object) error { + id, ok := task.Get("id") + idString, isString := id.(string) + if !ok || !isString { + return fmt.Errorf("task has no string id") + } + path, err := s.TaskPath(idString) + if err != nil { + return err + } + task.Set("updated_at", Now()) + return ordjson.WriteFile(filepath.Join(path, "task.json"), task) +} + +func (s *Store) AllTasks() ([]*ordjson.Object, error) { + entries, err := filepath.Glob(filepath.Join(s.Tasks, "t-*", "task.json")) + if err != nil { + return nil, err + } + sort.Strings(entries) + tasks := make([]*ordjson.Object, 0, len(entries)) + for _, entry := range entries { + id := filepath.Base(filepath.Dir(entry)) + task, err := s.ReadTask(id) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, nil +} + +func Now() string { + return time.Now().UTC().Format("2006-01-02T15:04:05+00:00") +} diff --git a/go/internal/store/store_test.go b/go/internal/store/store_test.go new file mode 100644 index 0000000..363f55e --- /dev/null +++ b/go/internal/store/store_test.go @@ -0,0 +1,147 @@ +package store + +import ( + "encoding/json" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +func jsonInt(n int) json.Number { + return json.Number(strconv.Itoa(n)) +} + +func TestOpen_initializesEmptyStoreWithoutWriting(t *testing.T) { + home := filepath.Join(t.TempDir(), "state") + s, err := Open(home) + if err != nil { + t.Fatalf("open: %v", err) + } + if s.Home != home { + t.Fatalf("home = %q, want %q", s.Home, home) + } + if _, statErr := ordjson.ReadFile(filepath.Join(home, "state.json")); statErr == nil { + t.Fatal("Open must not create state.json") + } +} + +func TestInitThenSaveThenReadTask_roundTripsAndBumpsUpdatedAt(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + taskID := "t-0123456789ab" + task := ordjson.NewObject() + task.Set("schema", jsonInt(Schema)) + task.Set("id", taskID) + task.Set("status", "prepared") + if err := s.SaveTask(task); err != nil { + t.Fatalf("save: %v", err) + } + if _, ok := task.Get("updated_at"); !ok { + t.Fatal("save did not set updated_at") + } + + read, err := s.ReadTask(taskID) + if err != nil { + t.Fatalf("read: %v", err) + } + status, _ := read.Get("status") + if status != "prepared" { + t.Fatalf("status = %v, want prepared", status) + } +} + +func TestTaskPath_rejectsInvalidTaskID(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := s.TaskPath("not-an-id"); err == nil { + t.Fatal("expected invalid task ID to be rejected") + } +} + +func TestAllTasks_returnsTasksSortedByID(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + for _, id := range []string{"t-bbbbbbbbbbbb", "t-aaaaaaaaaaaa"} { + task := ordjson.NewObject() + task.Set("schema", jsonInt(Schema)) + task.Set("id", id) + if err := s.SaveTask(task); err != nil { + t.Fatalf("save %s: %v", id, err) + } + } + tasks, err := s.AllTasks() + if err != nil { + t.Fatalf("all: %v", err) + } + if len(tasks) != 2 { + t.Fatalf("len(tasks) = %d, want 2", len(tasks)) + } + first, _ := tasks[0].Get("id") + if first != "t-aaaaaaaaaaaa" { + t.Fatalf("first task id = %v, want t-aaaaaaaaaaaa", first) + } +} + +func TestLock_excludesASecondLockerUntilReleased(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + unlock, err := s.Lock() + if err != nil { + t.Fatalf("lock: %v", err) + } + + acquired := make(chan error, 1) + go func() { + second, err := Open(s.Home) + if err != nil { + acquired <- err + return + } + secondUnlock, err := second.Lock() + if err != nil { + acquired <- err + return + } + defer secondUnlock() + acquired <- nil + }() + + select { + case err := <-acquired: + t.Fatalf("second locker acquired the lock while the first still held it (err=%v)", err) + case <-time.After(150 * time.Millisecond): + } + + if err := unlock(); err != nil { + t.Fatalf("unlock: %v", err) + } + + select { + case err := <-acquired: + if err != nil { + t.Fatalf("second locker failed after release: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("second locker never acquired the lock after release") + } +} From 65a43e3913b4d706c21b4ab8ee2bbb53ff24f4c0 Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 18:43:03 +0000 Subject: [PATCH 03/11] feat(go): port settings show to native Go (#94) Ports lib/sumctl.py's capacity_view/load_settings and the validators it depends on (capacity, worker, presets, reviewer, launch-value adapters) so `settings show` runs natively instead of shelling to Python. Validation error text is byte-identical, including Python repr()-formatted values in messages (unknown keys, invalid capacity, launch-value/adapter mismatches, preset-reference lookups) via a new pyrepr package. Native handling only kicks in for exactly `settings show` with an explicit --home; `settings set`/`preset`/anything else, or `settings show` without --home, still falls through to the Python reference unchanged (no attempt yet at replicating sum's default --home resolution for native commands). Differential-tested against the real Python bin/sumctl across seven scenarios: empty store, populated settings, invalid capacity value, unknown preset reference, unknown capacity key, a preset arg conflicting with its own resolved model flag, and occupancy counting across active/archived tasks. --- go/internal/cli/root.go | 34 +- go/internal/cli/settings_fallback_test.go | 46 ++ go/internal/cli/settings_show_test.go | 108 +++++ go/internal/ordjson/io.go | 4 +- go/internal/pyrepr/pyrepr.go | 78 ++++ go/internal/pyrepr/pyrepr_test.go | 36 ++ go/internal/settings/settings.go | 525 ++++++++++++++++++++++ 7 files changed, 828 insertions(+), 3 deletions(-) create mode 100644 go/internal/cli/settings_fallback_test.go create mode 100644 go/internal/cli/settings_show_test.go create mode 100644 go/internal/pyrepr/pyrepr.go create mode 100644 go/internal/pyrepr/pyrepr_test.go create mode 100644 go/internal/settings/settings.go diff --git a/go/internal/cli/root.go b/go/internal/cli/root.go index 216e013..540a43d 100644 --- a/go/internal/cli/root.go +++ b/go/internal/cli/root.go @@ -9,6 +9,9 @@ import ( "os/exec" "github.com/douglasjarquin/sum/go/internal/contract" + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/settings" + "github.com/douglasjarquin/sum/go/internal/store" "github.com/spf13/cobra" ) @@ -81,6 +84,26 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { }, }) + root.AddCommand(&cobra.Command{ + Use: "settings", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 && args[0] == "show" && opts.homeSet { + st, err := store.Open(opts.home) + if err != nil { + return err + } + view, err := settings.CapacityView(st) + if err != nil { + return err + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + return opts.compat(cmd.Context(), append([]string{"settings"}, args...)) + }, + }) + for _, name := range compatibilityCommands { command := &cobra.Command{ Use: name, @@ -96,7 +119,7 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { } var compatibilityCommands = []string{ - "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "metadata", "attention", "bind", "backup", "settings", "preset", "project", "herdr", "graph", "dev", "brief", "refresh", "release", "update", + "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "metadata", "attention", "bind", "backup", "preset", "project", "herdr", "graph", "dev", "brief", "refresh", "release", "update", } func (o *rootOptions) compat(ctx context.Context, args []string) error { @@ -130,6 +153,15 @@ func emitJSON(out io.Writer, value any) error { return err } +func emitOrdjson(out io.Writer, value any) error { + encoded, err := ordjson.MarshalIndent(value) + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(encoded)) + return err +} + func normalizeHome(args []string, home string, homeSet bool) []string { var prefix []string if homeSet { diff --git a/go/internal/cli/settings_fallback_test.go b/go/internal/cli/settings_fallback_test.go new file mode 100644 index 0000000..7cbceb7 --- /dev/null +++ b/go/internal/cli/settings_fallback_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +func TestSettings_fallsBackToReferenceWhenNotShowOrHomeUnset(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + reference := filepath.Join(dir, "reference.sh") + if err := os.WriteFile(reference, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$SUM_GO_ARGS_FILE\"\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("SUM_GO_ARGS_FILE", argsFile) + + cases := []struct { + name string + args []string + want string + }{ + {name: "show without --home", args: []string{"settings", "show"}, want: "settings\nshow\n"}, + {name: "set with --home", args: []string{"--home", filepath.Join(dir, "state"), "settings", "set", "--global", "3"}, want: "--home\n" + filepath.Join(dir, "state") + "\nsettings\nset\n--global\n3\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(tc.args) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("execute: %v (stderr=%s)", err, stderr.String()) + } + got, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Fatalf("reference argv = %q, want %q", got, tc.want) + } + os.Remove(argsFile) + }) + } +} diff --git a/go/internal/cli/settings_show_test.go b/go/internal/cli/settings_show_test.go new file mode 100644 index 0000000..f926bc0 --- /dev/null +++ b/go/internal/cli/settings_show_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func runPythonSettingsShow(t *testing.T, reference, home string) []byte { + t.Helper() + out, err := exec.Command(reference, "--home", home, "settings", "show").Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + return out +} + +func runGoSettingsShow(t *testing.T, reference, home string) (string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs([]string{"--home", home, "settings", "show"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go settings show failed: %v (stderr=%s)", err, stderr.String()) + } + return stdout.String(), stderr.String() +} + +func TestSettingsShow_matchesThePythonReferenceAcrossScenarios(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + cases := []struct { + name string + files map[string]string + }{ + {name: "fresh store, no settings.json"}, + { + name: "populated settings", + files: map[string]string{ + "settings.json": `{"schema": 1, "capacity": {"global": 3}, "worker": {"harness": "claude", "model": "sonnet"}, "presets": {"fast": {"harness": "codex", "revision": 2}}, "reviewer": {"preset": "fast"}}`, + }, + }, + { + name: "invalid capacity value", + files: map[string]string{ + "settings.json": `{"schema": 1, "capacity": {"global": 0}}`, + }, + }, + { + name: "unknown preset reference", + files: map[string]string{ + "settings.json": `{"schema": 1, "worker": {"preset": "missing"}}`, + }, + }, + { + name: "unknown capacity key", + files: map[string]string{ + "settings.json": `{"schema": 1, "capacity": {"global": 5, "bogus": 1}}`, + }, + }, + { + name: "preset arg conflicts with resolved model", + files: map[string]string{ + "settings.json": `{"schema": 1, "presets": {"fast": {"harness": "claude", "model": "sonnet", "args": ["--model", "haiku"]}}}`, + }, + }, + { + name: "occupancy across active and archived tasks", + files: map[string]string{ + "tasks/t-aaaaaaaaaaaa/task.json": `{"schema": 1, "id": "t-aaaaaaaaaaaa", "status": "running", "repository": "owner/repo"}`, + "tasks/t-bbbbbbbbbbbb/task.json": `{"schema": 1, "id": "t-bbbbbbbbbbbb", "status": "archived", "repository": "owner/repo"}`, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for relPath, content := range tc.files { + full := filepath.Join(home, relPath) + if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + want := runPythonSettingsShow(t, reference, home) + got, _ := runGoSettingsShow(t, reference, home) + if got != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", got, want) + } + }) + } +} diff --git a/go/internal/ordjson/io.go b/go/internal/ordjson/io.go index de2d4a9..0664df6 100644 --- a/go/internal/ordjson/io.go +++ b/go/internal/ordjson/io.go @@ -9,11 +9,11 @@ import ( func ReadFile(path string) (any, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("cannot read %s: %w", path, err) + return nil, fmt.Errorf("Cannot read %s: %w", path, err) } value, err := Decode(data) if err != nil { - return nil, fmt.Errorf("cannot read %s: %w", path, err) + return nil, fmt.Errorf("Cannot read %s: %w", path, err) } return value, nil } diff --git a/go/internal/pyrepr/pyrepr.go b/go/internal/pyrepr/pyrepr.go new file mode 100644 index 0000000..9fe67aa --- /dev/null +++ b/go/internal/pyrepr/pyrepr.go @@ -0,0 +1,78 @@ +package pyrepr + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +func Repr(value any) string { + switch v := value.(type) { + case nil: + return "None" + case bool: + if v { + return "True" + } + return "False" + case json.Number: + return string(v) + case string: + return reprString(v) + case []any: + parts := make([]string, len(v)) + for i, item := range v { + parts[i] = Repr(item) + } + return "[" + strings.Join(parts, ", ") + "]" + case *ordjson.Object: + parts := make([]string, 0, v.Len()) + for _, k := range v.Keys() { + val, _ := v.Get(k) + parts = append(parts, reprString(k)+": "+Repr(val)) + } + return "{" + strings.Join(parts, ", ") + "}" + default: + return fmt.Sprintf("%v", v) + } +} + +func StrList(items []string) string { + parts := make([]string, len(items)) + for i, s := range items { + parts[i] = reprString(s) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +func reprString(s string) string { + quote := byte('\'') + if strings.Contains(s, "'") && !strings.Contains(s, `"`) { + quote = '"' + } + var b strings.Builder + b.WriteByte(quote) + for _, r := range s { + switch { + case byte(r) == quote && r < 128: + b.WriteByte('\\') + b.WriteRune(r) + case r == '\\': + b.WriteString(`\\`) + case r == '\n': + b.WriteString(`\n`) + case r == '\r': + b.WriteString(`\r`) + case r == '\t': + b.WriteString(`\t`) + case r < 0x20 || r == 0x7f: + fmt.Fprintf(&b, `\x%02x`, r) + default: + b.WriteRune(r) + } + } + b.WriteByte(quote) + return b.String() +} diff --git a/go/internal/pyrepr/pyrepr_test.go b/go/internal/pyrepr/pyrepr_test.go new file mode 100644 index 0000000..e525382 --- /dev/null +++ b/go/internal/pyrepr/pyrepr_test.go @@ -0,0 +1,36 @@ +package pyrepr + +import ( + "encoding/json" + "testing" +) + +func TestRepr_matchesPythonReprForCommonTypes(t *testing.T) { + cases := []struct { + value any + want string + }{ + {nil, "None"}, + {true, "True"}, + {false, "False"}, + {json.Number("0"), "0"}, + {json.Number("1.5"), "1.5"}, + {"plain", "'plain'"}, + {"has'quote", `"has'quote"`}, + {[]any{json.Number("1"), "two"}, "[1, 'two']"}, + } + for _, tc := range cases { + if got := Repr(tc.value); got != tc.want { + t.Errorf("Repr(%#v) = %s, want %s", tc.value, got, tc.want) + } + } +} + +func TestStrList_matchesPythonListReprOfStrings(t *testing.T) { + if got, want := StrList([]string{"a", "b"}), "['a', 'b']"; got != want { + t.Fatalf("StrList = %s, want %s", got, want) + } + if got, want := StrList(nil), "[]"; got != want { + t.Fatalf("StrList(nil) = %s, want %s", got, want) + } +} diff --git a/go/internal/settings/settings.go b/go/internal/settings/settings.go new file mode 100644 index 0000000..4b39d4f --- /dev/null +++ b/go/internal/settings/settings.go @@ -0,0 +1,525 @@ +package settings + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/pyrepr" + "github.com/douglasjarquin/sum/go/internal/store" +) + +const ( + File = "settings.json" + Schema = 1 + CapacityMax = 64 + presetMax = 32 + defaultGlobal = 2 + defaultPerRepository = 1 +) + +var ( + presetNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) + harnessKindPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) + launchValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@,=\[\]-]{0,127}$`) + settingsKeys = map[string]bool{"schema": true, "capacity": true, "worker": true, "presets": true, "reviewer": true} + defaultCapacity = map[string]int{"global": defaultGlobal, "per_repository": defaultPerRepository} +) + +type adapter struct { + Model []string + Reasoning []string +} + +var adapters = map[string]adapter{ + "codex": {Model: []string{"-m"}, Reasoning: []string{"-c", "model_reasoning_effort="}}, + "claude": {Model: []string{"--model"}, Reasoning: []string{"--effort"}}, + "grok": {Model: []string{"-m"}, Reasoning: []string{"--reasoning-effort"}}, + "copilot": {Model: []string{"--model"}, Reasoning: []string{"--effort"}}, + "cursor": {Model: []string{"--model"}}, + "pi": {Model: []string{"--model"}}, + "omp": {Model: []string{"--model="}}, +} + +func adapterPrefix(harness, field string) ([]string, bool) { + a, ok := adapters[harness] + if !ok { + return nil, false + } + switch field { + case "model": + if a.Model == nil { + return nil, false + } + return a.Model, true + case "reasoning": + if a.Reasoning == nil { + return nil, false + } + return a.Reasoning, true + } + return nil, false +} + +func knownHarnessesForField(field string) []string { + var names []string + for name := range adapters { + if _, ok := adapterPrefix(name, field); ok { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +func argvConflicts(harness, field string, extra []string) bool { + prefix, ok := adapterPrefix(harness, field) + if !ok || len(prefix) == 0 { + return false + } + head := prefix[0] + flag := strings.TrimSuffix(head, "=") + if len(prefix) == 2 && strings.HasSuffix(prefix[1], "=") { + key := prefix[1] + for _, a := range extra { + if strings.HasPrefix(a, key) || a == flag+"="+key || strings.HasPrefix(a, flag+"="+key) { + return true + } + } + return false + } + for _, a := range extra { + if a == flag || strings.HasPrefix(a, flag+"=") { + return true + } + } + return false +} + +type Settings struct { + Capacity *ordjson.Object + Worker *ordjson.Object + Presets map[string]*ordjson.Object + Reviewer *ordjson.Object + Source string + Path string +} + +func LoadSettings(s *store.Store) (*Settings, error) { + path := filepath.Join(s.Home, File) + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("%s must not be a symlink", path) + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return &Settings{Presets: map[string]*ordjson.Object{}, Source: "unlimited", Path: path}, nil + } + settings, err := loadSettingsFile(path) + if err != nil { + return nil, fmt.Errorf("Invalid %s: %s. Fix or remove the file; nothing was admitted or changed, and existing tasks keep running. Only an explicit capacity block configures admission.", path, err) + } + return settings, nil +} + +func loadSettingsFile(path string) (*Settings, error) { + raw, err := ordjson.ReadFile(path) + if err != nil { + return nil, err + } + obj, ok := raw.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("top level must be an object") + } + schemaValue, _ := obj.Get("schema") + if !isSchema(schemaValue, Schema) { + return nil, fmt.Errorf("schema must be %d", Schema) + } + var unknown []string + for _, k := range obj.Keys() { + if !settingsKeys[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + allowed := []string{"capacity", "presets", "reviewer", "schema", "worker"} + return nil, fmt.Errorf("unknown keys %s; allowed: %s", pyrepr.StrList(unknown), pyrepr.StrList(allowed)) + } + var capacity *ordjson.Object + if capacityValue, has := obj.Get("capacity"); has { + validated, err := validateCapacity(capacityValue) + if err != nil { + return nil, err + } + capacity = validated + } + presetsValue, _ := obj.Get("presets") + presets, err := validatePresets(presetsValue) + if err != nil { + return nil, err + } + workerValue, _ := obj.Get("worker") + worker, err := validateWorker(workerValue, presets) + if err != nil { + return nil, err + } + reviewerValue, _ := obj.Get("reviewer") + reviewer, err := validateReviewer(reviewerValue, presets) + if err != nil { + return nil, err + } + return &Settings{Capacity: capacity, Worker: worker, Presets: presets, Reviewer: reviewer, Source: "settings.json", Path: path}, nil +} + +func isSchema(value any, want int) bool { + number, ok := value.(json.Number) + if !ok { + return false + } + n, err := number.Int64() + return err == nil && n == int64(want) +} + +func asInt(value any) (int, bool) { + number, ok := value.(json.Number) + if !ok { + return 0, false + } + n, err := number.Int64() + if err != nil { + return 0, false + } + return int(n), true +} + +func validateCapacity(value any) (*ordjson.Object, error) { + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("capacity must be an object") + } + var unknown []string + for _, k := range obj.Keys() { + if _, isDefault := defaultCapacity[k]; !isDefault { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, fmt.Errorf("unknown capacity keys %s; allowed: %s", pyrepr.StrList(unknown), pyrepr.StrList([]string{"global", "per_repository"})) + } + result := map[string]int{"global": defaultCapacity["global"], "per_repository": defaultCapacity["per_repository"]} + for _, key := range obj.Keys() { + raw, _ := obj.Get(key) + n, ok := asInt(raw) + if !ok || n < 1 || n > CapacityMax { + return nil, fmt.Errorf("capacity.%s must be an integer between 1 and %d, got %s", key, CapacityMax, pyrepr.Repr(raw)) + } + result[key] = n + } + if result["per_repository"] > result["global"] { + return nil, fmt.Errorf("capacity.per_repository (%d) exceeds capacity.global (%d)", result["per_repository"], result["global"]) + } + canonical := ordjson.NewObject() + canonical.Set("global", json.Number(fmt.Sprint(result["global"]))) + canonical.Set("per_repository", json.Number(fmt.Sprint(result["per_repository"]))) + return canonical, nil +} + +func validateLaunchValue(harness, field string, value any) (string, error) { + str, ok := value.(string) + if !ok || !launchValuePattern.MatchString(str) { + return "", fmt.Errorf("%s must be one plain CLI value (letters, digits, . _ : / @ , = [ ] -), got %s", field, pyrepr.Repr(value)) + } + if _, hasAdapter := adapterPrefix(harness, field); !hasAdapter { + known := knownHarnessesForField(field) + return "", fmt.Errorf("No verified %s flag for harness %s; sum passes only mappings confirmed from an installed CLI's help (%s). Pass the native argument yourself with --arg, or choose a supported harness.", field, pyrepr.Repr(harness), strings.Join(known, ", ")) + } + return str, nil +} + +func validatePresetReference(field string, name any, presets map[string]*ordjson.Object) error { + str, ok := name.(string) + if !ok || !presetNamePattern.MatchString(str) { + return fmt.Errorf("%s must name a preset (lowercase letters, digits, _ -), got %s", field, pyrepr.Repr(name)) + } + if presets != nil { + if _, exists := presets[str]; !exists { + keys := make([]string, 0, len(presets)) + for k := range presets { + keys = append(keys, k) + } + sort.Strings(keys) + list := "none" + if len(keys) > 0 { + list = pyrepr.StrList(keys) + } + return fmt.Errorf("%s names unknown preset %s; saved presets: %s. Create it with `preset set %s --harness ...` or point the default elsewhere.", field, pyrepr.Repr(str), list, str) + } + } + return nil +} + +func validateWorker(value any, presets map[string]*ordjson.Object) (*ordjson.Object, error) { + if value == nil { + return nil, nil + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("worker must be an object") + } + if presetValue, hasPreset := obj.Get("preset"); hasPreset { + if obj.Len() != 1 { + return nil, fmt.Errorf("worker is either {'preset': NAME} or a harness/model/reasoning block, not both") + } + if err := validatePresetReference("worker.preset", presetValue, presets); err != nil { + return nil, err + } + result := ordjson.NewObject() + result.Set("preset", presetValue) + return result, nil + } + allowed := map[string]bool{"harness": true, "model": true, "reasoning": true} + var unknown []string + for _, k := range obj.Keys() { + if !allowed[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, fmt.Errorf("unknown worker keys %s; allowed: ['harness', 'model', 'reasoning'] or ['preset']", pyrepr.StrList(unknown)) + } + harnessValue, _ := obj.Get("harness") + harness, ok := harnessValue.(string) + if !ok || !harnessKindPattern.MatchString(harness) { + return nil, fmt.Errorf("worker.harness must be a Herdr integration kind such as codex or claude") + } + result := ordjson.NewObject() + result.Set("harness", harness) + for _, field := range []string{"model", "reasoning"} { + if fieldValue, has := obj.Get(field); has && fieldValue != nil { + validated, err := validateLaunchValue(harness, field, fieldValue) + if err != nil { + return nil, err + } + result.Set(field, validated) + } + } + return result, nil +} + +func validateReviewer(value any, presets map[string]*ordjson.Object) (*ordjson.Object, error) { + if value == nil { + return nil, nil + } + obj, ok := value.(*ordjson.Object) + if !ok || obj.Len() != 1 { + return nil, fmt.Errorf("reviewer must be {'preset': NAME}") + } + presetValue, hasPreset := obj.Get("preset") + if !hasPreset { + return nil, fmt.Errorf("reviewer must be {'preset': NAME}") + } + if err := validatePresetReference("reviewer.preset", presetValue, presets); err != nil { + return nil, err + } + result := ordjson.NewObject() + result.Set("preset", presetValue) + return result, nil +} + +func validatePreset(name string, value any) (*ordjson.Object, error) { + if !presetNamePattern.MatchString(name) { + return nil, fmt.Errorf("preset name must be lowercase letters, digits, _ or - (up to 32 characters), got %s", pyrepr.Repr(name)) + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("presets.%s must be an object", name) + } + allowed := map[string]bool{"harness": true, "model": true, "reasoning": true, "args": true, "revision": true} + var unknown []string + for _, k := range obj.Keys() { + if !allowed[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, fmt.Errorf("unknown keys %s in presets.%s; allowed: %s", pyrepr.StrList(unknown), name, pyrepr.StrList([]string{"harness", "model", "reasoning", "args", "revision"})) + } + harnessValue, _ := obj.Get("harness") + harness, ok := harnessValue.(string) + if !ok || !harnessKindPattern.MatchString(harness) { + return nil, fmt.Errorf("presets.%s.harness must be a Herdr integration kind such as codex or claude", name) + } + result := ordjson.NewObject() + result.Set("harness", harness) + for _, field := range []string{"model", "reasoning"} { + if fieldValue, has := obj.Get(field); has && fieldValue != nil { + validated, err := validateLaunchValue(harness, field, fieldValue) + if err != nil { + return nil, err + } + result.Set(field, validated) + } + } + var args []string + if rawArgs, has := obj.Get("args"); has && rawArgs != nil { + list, isList := rawArgs.([]any) + if !isList { + return nil, fmt.Errorf("presets.%s.args must be a list of plain non-empty strings", name) + } + for _, item := range list { + s, ok := item.(string) + if !ok || s == "" || strings.Contains(s, "\x00") { + return nil, fmt.Errorf("presets.%s.args must be a list of plain non-empty strings", name) + } + args = append(args, s) + } + } + for _, field := range []string{"model", "reasoning"} { + if fieldValue, has := result.Get(field); has { + if s, isStr := fieldValue.(string); isStr && argvConflicts(harness, field, args) { + return nil, fmt.Errorf("presets.%s: %s %s and an entry of args both set the %s %s flag. Give one.", name, field, pyrepr.Repr(s), harness, field) + } + } + } + if len(args) > 0 { + argsAny := make([]any, len(args)) + for i, a := range args { + argsAny[i] = a + } + result.Set("args", argsAny) + } + revision := 1 + if revisionValue, has := obj.Get("revision"); has { + n, ok := asInt(revisionValue) + if !ok || n < 1 { + return nil, fmt.Errorf("presets.%s.revision must be a positive integer", name) + } + revision = n + } + result.Set("revision", json.Number(fmt.Sprint(revision))) + return result, nil +} + +func validatePresets(value any) (map[string]*ordjson.Object, error) { + if value == nil { + return map[string]*ordjson.Object{}, nil + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("presets must be an object keyed by preset name") + } + if obj.Len() > presetMax { + return nil, fmt.Errorf("at most %d presets are supported", presetMax) + } + result := map[string]*ordjson.Object{} + for _, name := range obj.Keys() { + spec, _ := obj.Get(name) + validated, err := validatePreset(name, spec) + if err != nil { + return nil, err + } + result[name] = validated + } + return result, nil +} + +func presetSummary(presets map[string]*ordjson.Object) *ordjson.Object { + names := make([]string, 0, len(presets)) + for name := range presets { + names = append(names, name) + } + sort.Strings(names) + result := ordjson.NewObject() + for _, name := range names { + spec := presets[name] + entry := ordjson.NewObject() + harness, _ := spec.Get("harness") + entry.Set("harness", harness) + model, hasModel := spec.Get("model") + if !hasModel { + model = nil + } + entry.Set("model", model) + reasoning, hasReasoning := spec.Get("reasoning") + if !hasReasoning { + reasoning = nil + } + entry.Set("reasoning", reasoning) + args, hasArgs := spec.Get("args") + if !hasArgs { + args = []any{} + } + entry.Set("args", args) + revision, _ := spec.Get("revision") + entry.Set("revision", revision) + result.Set(name, entry) + } + return result +} + +func occupancy(tasks []*ordjson.Object) *ordjson.Object { + byRepo := ordjson.NewObject() + holderCount := 0 + for _, t := range tasks { + status, _ := t.Get("status") + if status == "archived" { + continue + } + holderCount++ + repoValue, _ := t.Get("repository") + repo, _ := repoValue.(string) + idValue, _ := t.Get("id") + var list []any + if existing, has := byRepo.Get(repo); has { + list, _ = existing.([]any) + } + list = append(list, idValue) + byRepo.Set(repo, list) + } + result := ordjson.NewObject() + result.Set("global", json.Number(fmt.Sprint(holderCount))) + result.Set("by_repository", byRepo) + return result +} + +func orNil(obj *ordjson.Object) any { + if obj == nil { + return nil + } + return obj +} + +func CapacityView(s *store.Store) (*ordjson.Object, error) { + tasks, err := s.AllTasks() + if err != nil { + return nil, err + } + loaded, err := LoadSettings(s) + if err != nil { + result := ordjson.NewObject() + result.Set("limits", nil) + result.Set("worker", nil) + result.Set("source", "invalid") + result.Set("error", err.Error()) + result.Set("occupied", occupancy(tasks)) + result.Set("note", "Admission is refused until settings.json is fixed; every recorded task keeps its slot and callbacks.") + return result, nil + } + result := ordjson.NewObject() + result.Set("limits", orNil(loaded.Capacity)) + result.Set("worker", orNil(loaded.Worker)) + result.Set("source", loaded.Source) + result.Set("occupied", occupancy(tasks)) + result.Set("presets", presetSummary(loaded.Presets)) + result.Set("reviewer", orNil(loaded.Reviewer)) + result.Set("worker_note", "Saved worker defaults apply to future dispatches only; absent means the worker runs the coordinator's harness. A task prompt overrides them without changing them.") + result.Set("note", "A slot is held by every non-archived task and released only by `archive --acknowledge`; idle, reported, or unobservable workers keep theirs.") + return result, nil +} From 46990f0b4e685c7aa7039187cc4b5892ee43fca0 Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 19:22:45 +0000 Subject: [PATCH 04/11] fix(go): match Python's exact error-JSON separators and escaping (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while differential-testing preset show's error path: the compiled entrypoint's top-level error handler used encoding/json's compact defaults (no space after ':'/','), while lib/sumctl.py's matching path is plain json.dumps({"error": str(exc)}) — the default (non-indent) separators are (', ', ': '), and ensure_ascii is implied throughout the module. A script comparing {"error": ...} byte-for-byte would see `{"error":"x"}` from Go and `{"error": "x"}` from Python. Adds ordjson.MarshalCompact for this shape (object/array/string/number encoding matching MarshalIndent, just without newlines/indentation) and switches the entrypoint to it. Updates the existing cancellation test's expected substring and adds a differential test against the real Python reference's stderr for an error path. --- go/cmd/sumctl-go/error_format_test.go | 52 ++++++++++++++++++++++++ go/cmd/sumctl-go/main.go | 8 ++-- go/cmd/sumctl-go/main_test.go | 2 +- go/internal/ordjson/ordjson.go | 58 +++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 go/cmd/sumctl-go/error_format_test.go diff --git a/go/cmd/sumctl-go/error_format_test.go b/go/cmd/sumctl-go/error_format_test.go new file mode 100644 index 0000000..cb29224 --- /dev/null +++ b/go/cmd/sumctl-go/error_format_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCompiledEntrypoint_errorJSONMatchesPythonSeparatorsAndEscaping(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + dir := t.TempDir() + binary := filepath.Join(dir, "sumctl-go") + build := exec.Command("go", "build", "-trimpath", "-buildvcs=false", "-o", binary, ".") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build failed: %v\n%s", err, output) + } + + home := t.TempDir() + args := []string{"--home", home, "preset", "show", "nope"} + + pythonCombined, _ := exec.Command(reference, args...).CombinedOutput() + wantStderr := extractLast(string(pythonCombined)) + + goCmd := exec.Command(binary, args...) + goCombined, _ := goCmd.CombinedOutput() + gotStderr := extractLast(string(goCombined)) + + if gotStderr != wantStderr { + t.Fatalf("go stderr = %q, want (python reference) %q", gotStderr, wantStderr) + } + if !strings.HasPrefix(gotStderr, `{"error": `) { + t.Fatalf("go stderr does not use Python's default json.dumps separators: %q", gotStderr) + } +} + +func extractLast(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + return lines[len(lines)-1] +} diff --git a/go/cmd/sumctl-go/main.go b/go/cmd/sumctl-go/main.go index 3b83729..ce5c9ae 100644 --- a/go/cmd/sumctl-go/main.go +++ b/go/cmd/sumctl-go/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -11,6 +10,7 @@ import ( "syscall" "github.com/douglasjarquin/sum/go/internal/cli" + "github.com/douglasjarquin/sum/go/internal/ordjson" ) func main() { @@ -23,9 +23,11 @@ func main() { if errors.As(err, &exitErr) { os.Exit(exitErr.Code) } - payload, marshalErr := json.Marshal(map[string]string{"error": err.Error()}) + errorValue := ordjson.NewObject() + errorValue.Set("error", err.Error()) + payload, marshalErr := ordjson.MarshalCompact(errorValue) if marshalErr != nil { - fmt.Fprintln(os.Stderr, `{"error":"sumctl-go failed"}`) + fmt.Fprintln(os.Stderr, `{"error": "sumctl-go failed"}`) } else { fmt.Fprintln(os.Stderr, string(payload)) } diff --git a/go/cmd/sumctl-go/main_test.go b/go/cmd/sumctl-go/main_test.go index 88a36d6..e4fb7ea 100644 --- a/go/cmd/sumctl-go/main_test.go +++ b/go/cmd/sumctl-go/main_test.go @@ -53,7 +53,7 @@ func TestCompiledEntrypointCancellationExitsOnce(t *testing.T) { if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { t.Fatalf("compiled exit = %v, want status 1", err) } - if stdout.Len() != 0 || !strings.Contains(stderr.String(), `"error":"context canceled"`) { + if stdout.Len() != 0 || !strings.Contains(stderr.String(), `"error": "context canceled"`) { t.Fatalf("compiled cancellation output stdout=%q stderr=%q", stdout.String(), stderr.String()) } } diff --git a/go/internal/ordjson/ordjson.go b/go/internal/ordjson/ordjson.go index d4d747b..0c7d2d0 100644 --- a/go/internal/ordjson/ordjson.go +++ b/go/internal/ordjson/ordjson.go @@ -128,6 +128,64 @@ func MarshalIndent(value any) ([]byte, error) { return buf.Bytes(), nil } +func MarshalCompact(value any) ([]byte, error) { + var buf bytes.Buffer + if err := encodeCompact(&buf, value); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func encodeCompact(buf *bytes.Buffer, value any) error { + switch v := value.(type) { + case *Object: + buf.WriteByte('{') + for i, k := range v.keys { + if i > 0 { + buf.WriteString(", ") + } + encodeString(buf, k) + buf.WriteString(": ") + if err := encodeCompact(buf, v.values[k]); err != nil { + return err + } + } + buf.WriteByte('}') + case []any: + buf.WriteByte('[') + for i, item := range v { + if i > 0 { + buf.WriteString(", ") + } + if err := encodeCompact(buf, item); err != nil { + return err + } + } + buf.WriteByte(']') + case string: + encodeString(buf, v) + case json.Number: + buf.WriteString(string(v)) + case bool: + if v { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case nil: + buf.WriteString("null") + case int: + buf.WriteString(strconv.Itoa(v)) + case int64: + buf.WriteString(strconv.FormatInt(v, 10)) + case float64: + buf.WriteString(strconv.FormatFloat(v, 'g', -1, 64)) + default: + return fmt.Errorf("ordjson: unsupported type %T", value) + } + return nil +} + func writeIndent(buf *bytes.Buffer, level int) { for range level { buf.WriteString(indentUnit) From bbbfa63c4bd67aac7586f9881921d0b153245a65 Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 19:22:45 +0000 Subject: [PATCH 05/11] feat(go): port preset list/show to native Go (#94) Ports lib/sumctl.py's preset_list/preset_show, reusing the settings package's already-ported load_settings/validate_preset_reference: preset_launch (the exact argv `dispatch --preset` would append, including adapter-flag construction) and preset_references (worker/ reviewer default lookups). Native handling only kicks in for exactly `preset list` or `preset show NAME` with an explicit --home; `preset set`/`delete`, anything else, or missing --home, still falls through to the Python reference unchanged. Differential-tested against the real Python bin/sumctl: list on an empty store, list with multiple presets (one referenced as the worker default), show a preset with model/reasoning/args, and show a bare preset. --- go/internal/cli/preset_show_test.go | 65 +++++++++++++++++ go/internal/cli/root.go | 31 +++++++- go/internal/settings/settings.go | 108 ++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 go/internal/cli/preset_show_test.go diff --git a/go/internal/cli/preset_show_test.go b/go/internal/cli/preset_show_test.go new file mode 100644 index 0000000..082057c --- /dev/null +++ b/go/internal/cli/preset_show_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestPresetListAndShow_matchThePythonReferenceStdout(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + settingsJSON := `{"schema": 1, "worker": {"preset": "fast"}, "presets": {"fast": {"harness": "codex", "model": "gpt-5", "reasoning": "high", "args": ["--flag"], "revision": 3}, "slow": {"harness": "claude", "revision": 1}}}` + + cases := []struct { + name string + writeSettings bool + args []string + }{ + {name: "list on empty store", args: []string{"preset", "list"}}, + {name: "list with presets", writeSettings: true, args: []string{"preset", "list"}}, + {name: "show a preset with model/reasoning/args", writeSettings: true, args: []string{"preset", "show", "fast"}}, + {name: "show a bare preset", writeSettings: true, args: []string{"preset", "show", "slow"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + if tc.writeSettings { + if err := os.WriteFile(filepath.Join(home, "settings.json"), []byte(settingsJSON), 0o600); err != nil { + t.Fatal(err) + } + } + fullArgs := append([]string{"--home", home}, tc.args...) + + want, err := exec.Command(reference, fullArgs...).Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(fullArgs) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } + }) + } +} diff --git a/go/internal/cli/root.go b/go/internal/cli/root.go index 540a43d..0aa66b2 100644 --- a/go/internal/cli/root.go +++ b/go/internal/cli/root.go @@ -104,6 +104,35 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { }, }) + root.AddCommand(&cobra.Command{ + Use: "preset", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet { + st, err := store.Open(opts.home) + if err != nil { + return err + } + switch { + case len(args) == 1 && args[0] == "list": + view, err := settings.PresetList(st) + if err != nil { + return err + } + return emitOrdjson(cmd.OutOrStdout(), view) + case len(args) == 2 && args[0] == "show": + view, err := settings.PresetShow(st, args[1]) + if err != nil { + return err + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + } + return opts.compat(cmd.Context(), append([]string{"preset"}, args...)) + }, + }) + for _, name := range compatibilityCommands { command := &cobra.Command{ Use: name, @@ -119,7 +148,7 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { } var compatibilityCommands = []string{ - "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "metadata", "attention", "bind", "backup", "preset", "project", "herdr", "graph", "dev", "brief", "refresh", "release", "update", + "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "metadata", "attention", "bind", "backup", "project", "herdr", "graph", "dev", "brief", "refresh", "release", "update", } func (o *rootOptions) compat(ctx context.Context, args []string) error { diff --git a/go/internal/settings/settings.go b/go/internal/settings/settings.go index 4b39d4f..951cf28 100644 --- a/go/internal/settings/settings.go +++ b/go/internal/settings/settings.go @@ -523,3 +523,111 @@ func CapacityView(s *store.Store) (*ordjson.Object, error) { result.Set("note", "A slot is held by every non-archived task and released only by `archive --acknowledge`; idle, reported, or unobservable workers keep theirs.") return result, nil } + +func adapterArgv(harness, field string, value string) []string { + prefix, _ := adapterPrefix(harness, field) + if len(prefix) == 0 { + return nil + } + last := prefix[len(prefix)-1] + if strings.HasSuffix(last, "=") { + argv := append([]string{}, prefix[:len(prefix)-1]...) + return append(argv, last+value) + } + return append(append([]string{}, prefix...), value) +} + +func presetLaunch(spec *ordjson.Object) *ordjson.Object { + harnessValue, _ := spec.Get("harness") + harness, _ := harnessValue.(string) + var argv []string + for _, field := range []string{"model", "reasoning"} { + if value, has := spec.Get(field); has { + if s, ok := value.(string); ok { + argv = append(argv, adapterArgv(harness, field, s)...) + } + } + } + if rawArgs, has := spec.Get("args"); has { + if list, ok := rawArgs.([]any); ok { + for _, a := range list { + if s, ok := a.(string); ok { + argv = append(argv, s) + } + } + } + } + result := ordjson.NewObject() + result.Set("harness", harness) + model, hasModel := spec.Get("model") + if !hasModel { + model = nil + } + result.Set("model", model) + reasoning, hasReasoning := spec.Get("reasoning") + if !hasReasoning { + reasoning = nil + } + result.Set("reasoning", reasoning) + argvAny := make([]any, len(argv)) + for i, a := range argv { + argvAny[i] = a + } + result.Set("argv", argvAny) + return result +} + +func presetReferences(worker, reviewer *ordjson.Object, name string) []string { + var refs []string + if worker != nil { + if presetValue, has := worker.Get("preset"); has && presetValue == name { + refs = append(refs, "worker default") + } + } + if reviewer != nil { + if presetValue, has := reviewer.Get("preset"); has && presetValue == name { + refs = append(refs, "reviewer default") + } + } + return refs +} + +func PresetList(s *store.Store) (*ordjson.Object, error) { + loaded, err := LoadSettings(s) + if err != nil { + return nil, err + } + result := ordjson.NewObject() + result.Set("presets", presetSummary(loaded.Presets)) + result.Set("worker", orNil(loaded.Worker)) + result.Set("reviewer", orNil(loaded.Reviewer)) + result.Set("source", loaded.Source) + result.Set("path", loaded.Path) + result.Set("note", "Presets are dispatch shortcuts expanded at prepare; each task keeps the specification it was prepared with. Nothing here is a running agent, a role, or a default until you say so.") + return result, nil +} + +func PresetShow(s *store.Store, name string) (*ordjson.Object, error) { + loaded, err := LoadSettings(s) + if err != nil { + return nil, err + } + if err := validatePresetReference("preset", name, loaded.Presets); err != nil { + return nil, err + } + spec := loaded.Presets[name] + revision, _ := spec.Get("revision") + usedBy := presetReferences(loaded.Worker, loaded.Reviewer, name) + usedByAny := make([]any, len(usedBy)) + for i, u := range usedBy { + usedByAny[i] = u + } + result := ordjson.NewObject() + result.Set("name", name) + result.Set("revision", revision) + result.Set("preset", spec) + result.Set("launch", presetLaunch(spec)) + result.Set("used_by", usedByAny) + result.Set("note", "`launch.argv` is exactly what `dispatch --preset` appends after the harness executable; a model here is CLI-requested, never runtime-verified.") + return result, nil +} From 2aecf5fcee541621b5e451f69032b1b14e7ba378 Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 20:12:50 +0000 Subject: [PATCH 06/11] feat(go): port graph config to native Go (#94) Ports lib/sumctl.py's graph_config/graph_tool (go/internal/graph): locate the pinned codegraph binary (SUM_CODEGRAPH_BIN override, else /.local/bin/codegraph derived from the reference helper's own path, matching lib/sumctl.py's RUNTIME resolution without needing the full ROOT/SUM_INSTALL_ROOT machinery), probe its --version, and print the per-harness MCP snippet (claude/cursor/opencode as JSON via ordjson, codex as TOML) plus --raw's bare-snippet-only output. `graph status`/`graph init` are unrelated (task-scoped, need a real task record) and stay on the Python reference; `graph` only goes native for the exact `config --harness NAME [--raw]` shape recognized by parseGraphConfigArgs, falling back to compat for anything else (including an invalid --harness, so Python's own argparse choice validation/usage text stays exact). Adds ordjson.QuoteString (exported single-value JSON-string encoding, reused for the codex TOML snippet's command path) and reuses the existing MarshalIndent path for the JSON-format snippets so their formatting is guaranteed to match the outer emit() convention. Differential-tested against the real Python bin/sumctl and the pinned codegraph release already staged in this installation: all four harnesses, --raw, and the tool-unavailable error path (via the compiled binary, since that error surfaces through the top-level stderr handler fixed in 46990f0). --- go/internal/cli/graph_config_test.go | 135 ++++++++++++++++++++++ go/internal/cli/root.go | 74 +++++++++++- go/internal/graph/graph.go | 164 +++++++++++++++++++++++++++ go/internal/ordjson/ordjson.go | 6 + 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 go/internal/cli/graph_config_test.go create mode 100644 go/internal/graph/graph.go diff --git a/go/internal/cli/graph_config_test.go b/go/internal/cli/graph_config_test.go new file mode 100644 index 0000000..c9f6890 --- /dev/null +++ b/go/internal/cli/graph_config_test.go @@ -0,0 +1,135 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestGraphConfig_matchesThePythonReferenceAcrossScenarios(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + installationReleases := filepath.Join(repoRoot, "..", "..", "..", ".local", "releases") + pinned := findPinnedCodegraph(t, installationReleases) + if pinned == "" { + t.Skip("no pinned codegraph release found to exercise the available-tool path") + } + + cases := []struct { + name string + env []string + args []string + expect string + }{ + {name: "claude snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "claude"}}, + {name: "codex snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "codex"}}, + {name: "cursor snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "cursor"}}, + {name: "opencode snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "opencode"}}, + {name: "raw snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "claude", "--raw"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + fullArgs := append([]string{"--home", home}, tc.args...) + + pythonCmd := exec.Command(reference, fullArgs...) + pythonCmd.Env = append(os.Environ(), tc.env...) + want, err := pythonCmd.Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + for _, kv := range tc.env { + parts := splitEnv(kv) + t.Setenv(parts[0], parts[1]) + } + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(fullArgs) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } + }) + } +} + +func TestGraphConfig_missingToolErrorMatchesPythonReference(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + home := t.TempDir() + args := []string{"--home", home, "graph", "config", "--harness", "claude"} + + pythonCombined, _ := exec.Command(reference, args...).CombinedOutput() + + dir := t.TempDir() + binary := filepath.Join(dir, "sumctl-go") + build := exec.Command("go", "build", "-trimpath", "-buildvcs=false", "-o", binary, "../../cmd/sumctl-go") + build.Dir = "." + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build failed: %v\n%s", err, output) + } + goCmd := exec.Command(binary, args...) + goCmd.Env = append(os.Environ(), "SUM_PYTHON_HELPER="+reference) + goCombined, _ := goCmd.CombinedOutput() + + if string(goCombined) != string(pythonCombined) { + t.Fatalf("go combined output = %q, want (python reference) %q", goCombined, pythonCombined) + } +} + +func findPinnedCodegraph(t *testing.T, releasesGlobRoot string) string { + t.Helper() + matches, err := filepath.Glob(filepath.Join(releasesGlobRoot, "*", ".local", "bin", "codegraph")) + if err != nil || len(matches) == 0 { + return "" + } + for _, m := range matches { + if info, statErr := os.Lstat(m); statErr == nil && info.Mode()&os.ModeSymlink != 0 { + if _, resolveErr := filepath.EvalSymlinks(m); resolveErr == nil { + return m + } + continue + } + if _, statErr := os.Stat(m); statErr == nil { + return m + } + } + return "" +} + +func splitEnv(kv string) [2]string { + for i := 0; i < len(kv); i++ { + if kv[i] == '=' { + return [2]string{kv[:i], kv[i+1:]} + } + } + return [2]string{kv, ""} +} diff --git a/go/internal/cli/root.go b/go/internal/cli/root.go index 0aa66b2..0f2c9e8 100644 --- a/go/internal/cli/root.go +++ b/go/internal/cli/root.go @@ -7,8 +7,11 @@ import ( "io" "os" "os/exec" + "path/filepath" + "strings" "github.com/douglasjarquin/sum/go/internal/contract" + "github.com/douglasjarquin/sum/go/internal/graph" "github.com/douglasjarquin/sum/go/internal/ordjson" "github.com/douglasjarquin/sum/go/internal/settings" "github.com/douglasjarquin/sum/go/internal/store" @@ -133,6 +136,38 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { }, }) + root.AddCommand(&cobra.Command{ + Use: "graph", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet && len(args) >= 1 && args[0] == "config" { + if harness, raw, ok := parseGraphConfigArgs(args[1:]); ok && graph.IsValidHarness(harness) { + if runtimeRoot := runtimeRootFromReference(opts.reference); runtimeRoot != "" { + if _, err := store.Open(opts.home); err != nil { + return err + } + view, err := graph.Config(runtimeRoot, harness) + if err != nil { + return err + } + if raw { + snippetValue, _ := view.Get("snippet") + snippet, _ := snippetValue.(string) + if !strings.HasSuffix(snippet, "\n") { + snippet += "\n" + } + _, writeErr := io.WriteString(cmd.OutOrStdout(), snippet) + return writeErr + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + } + } + return opts.compat(cmd.Context(), append([]string{"graph"}, args...)) + }, + }) + for _, name := range compatibilityCommands { command := &cobra.Command{ Use: name, @@ -148,7 +183,44 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { } var compatibilityCommands = []string{ - "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "metadata", "attention", "bind", "backup", "project", "herdr", "graph", "dev", "brief", "refresh", "release", "update", + "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "metadata", "attention", "bind", "backup", "project", "herdr", "dev", "brief", "refresh", "release", "update", +} + +func parseGraphConfigArgs(tokens []string) (harness string, raw bool, ok bool) { + for i := 0; i < len(tokens); i++ { + token := tokens[i] + switch { + case token == "--harness": + if harness != "" || i+1 >= len(tokens) { + return "", false, false + } + i++ + harness = tokens[i] + case strings.HasPrefix(token, "--harness="): + if harness != "" { + return "", false, false + } + harness = strings.TrimPrefix(token, "--harness=") + case token == "--raw": + if raw { + return "", false, false + } + raw = true + default: + return "", false, false + } + } + if harness == "" { + return "", false, false + } + return harness, raw, true +} + +func runtimeRootFromReference(reference string) string { + if reference == "" { + return "" + } + return filepath.Dir(filepath.Dir(reference)) } func (o *rootOptions) compat(ctx context.Context, args []string) error { diff --git a/go/internal/graph/graph.go b/go/internal/graph/graph.go new file mode 100644 index 0000000..ff2ec02 --- /dev/null +++ b/go/internal/graph/graph.go @@ -0,0 +1,164 @@ +package graph + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +const ( + CodegraphVersion = "1.5.0" + CodegraphPackage = "@colbymchenry/codegraph" +) + +var HarnessChoices = []string{"claude", "codex", "cursor", "opencode"} + +func IsValidHarness(harness string) bool { + for _, h := range HarnessChoices { + if h == harness { + return true + } + } + return false +} + +func binPath(runtimeRoot string) string { + if override := os.Getenv("SUM_CODEGRAPH_BIN"); override != "" { + return override + } + return filepath.Join(runtimeRoot, ".local", "bin", "codegraph") +} + +func Tool(runtimeRoot string) *ordjson.Object { + path := binPath(runtimeRoot) + row := ordjson.NewObject() + row.Set("pinned", CodegraphVersion) + row.Set("path", path) + row.Set("available", false) + row.Set("version", nil) + row.Set("reason", nil) + + info, statErr := os.Stat(path) + if statErr != nil || info.IsDir() { + row.Set("reason", fmt.Sprintf("codegraph is not installed in this runtime (%s). `mise run setup` or a staged release links the pinned %s@%s; a global or floating installation is never used.", path, CodegraphPackage, CodegraphVersion)) + return row + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, path, "--version") + cmd.Env = append(os.Environ(), "CODEGRAPH_NO_DAEMON=1", "CODEGRAPH_NO_DOWNLOAD=1", "NO_COLOR=1") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + + var lines []string + for _, line := range strings.Split(stdout.String(), "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + lines = append(lines, trimmed) + } + } + var version string + if len(lines) > 0 { + version = lines[len(lines)-1] + row.Set("version", version) + } + + exitCode := 0 + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + row.Set("reason", fmt.Sprintf("codegraph did not answer `--version`: %s", runErr)) + return row + } + } + if exitCode != 0 || version == "" { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if len(detail) > 300 { + detail = detail[len(detail)-300:] + } + row.Set("reason", fmt.Sprintf("codegraph at %s exited %d without a version: %s", path, exitCode, detail)) + return row + } + if version != CodegraphVersion { + row.Set("reason", fmt.Sprintf("codegraph %s at %s is not the tested pin %s; only the pinned release is used, nothing is upgraded or downgraded", version, path, CodegraphVersion)) + return row + } + row.Set("available", true) + return row +} + +func Config(runtimeRoot, harness string) (*ordjson.Object, error) { + tool := Tool(runtimeRoot) + available, _ := tool.Get("available") + if available != true { + reasonValue, _ := tool.Get("reason") + reason, _ := reasonValue.(string) + return nil, fmt.Errorf("No snippet: %s", reason) + } + commandValue, _ := tool.Get("path") + command, _ := commandValue.(string) + + var target, format, snippet string + switch harness { + case "claude": + target, format = ".mcp.json in the checkout (project scope)", "json" + snippet = mcpServersSnippet(command, []any{"serve", "--mcp"}) + case "codex": + target, format = ".codex/config.toml in the checkout", "toml" + snippet = fmt.Sprintf("[mcp_servers.codegraph]\ncommand = %s\nargs = [\"serve\", \"--mcp\"]\n", ordjson.QuoteString(command)) + case "cursor": + target, format = ".cursor/mcp.json in the checkout", "json" + snippet = mcpServersSnippet(command, []any{"serve", "--mcp", "--path", "${workspaceFolder}"}) + default: + target, format = "opencode.json in the checkout", "json" + snippet = opencodeSnippet(command) + } + + result := ordjson.NewObject() + result.Set("harness", harness) + result.Set("format", format) + result.Set("target", target) + result.Set("snippet", snippet) + result.Set("tool", tool) + result.Set("note", "Printed only; sum wrote no file, changed no permission list, and did not run `codegraph install`. The server this starts watches only the project it is started in; it is that harness session's process, and cleanup reports it as an occupant of the checkout until the session exits.") + return result, nil +} + +func mcpServersSnippet(command string, args []any) string { + inner := ordjson.NewObject() + inner.Set("type", "stdio") + inner.Set("command", command) + inner.Set("args", args) + servers := ordjson.NewObject() + servers.Set("codegraph", inner) + root := ordjson.NewObject() + root.Set("mcpServers", servers) + encoded, _ := ordjson.MarshalIndent(root) + return string(encoded) +} + +func opencodeSnippet(command string) string { + inner := ordjson.NewObject() + inner.Set("type", "local") + inner.Set("command", []any{command, "serve", "--mcp"}) + inner.Set("enabled", true) + mcp := ordjson.NewObject() + mcp.Set("codegraph", inner) + root := ordjson.NewObject() + root.Set("mcp", mcp) + encoded, _ := ordjson.MarshalIndent(root) + return string(encoded) +} diff --git a/go/internal/ordjson/ordjson.go b/go/internal/ordjson/ordjson.go index 0c7d2d0..e6d2ae9 100644 --- a/go/internal/ordjson/ordjson.go +++ b/go/internal/ordjson/ordjson.go @@ -52,6 +52,12 @@ func (o *Object) Len() int { return len(o.keys) } +func QuoteString(s string) string { + var buf bytes.Buffer + encodeString(&buf, s) + return buf.String() +} + func Decode(data []byte) (any, error) { dec := json.NewDecoder(bytes.NewReader(data)) dec.UseNumber() From c0f459cbd7bad853f6c132375acc11b5eeef0044 Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 20:34:23 +0000 Subject: [PATCH 07/11] feat(go): port metadata snippet to native Go (#94) Ports lib/sumctl.py's metadata_snippet: the static config.toml text a user merges to render sum's sum_* sidebar tokens, plus the token/state name lists and the exact enable/inbox commands to run next (command_for(store, "metadata", ...)). command_for shells out through shlex.join for reuse as a copy-pasted shell command, so this adds go/internal/shquote (Quote/Join matching Python's shlex.quote/join exactly: word/@%+=:,./- chars pass through unquoted, anything else gets single-quoted with '"'"' escaping embedded quotes) rather than hand-formatting paths that might contain spaces. `metadata` only goes native for exactly `snippet` or `snippet --raw` with an explicit --home; `enable`/`disable`/`status`/`sync`/`inbox` and anything else still shell out to the Python reference unchanged. Differential-tested against the real Python bin/sumctl: plain output, --raw, and a --home path containing spaces (exercises the shlex quoting in the emitted enable/inbox commands). --- go/internal/cli/metadata_snippet_test.go | 63 ++++++++++++++++++++++++ go/internal/cli/root.go | 29 ++++++++++- go/internal/metadata/metadata.go | 57 +++++++++++++++++++++ go/internal/shquote/shquote.go | 30 +++++++++++ go/internal/shquote/shquote_test.go | 29 +++++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 go/internal/cli/metadata_snippet_test.go create mode 100644 go/internal/metadata/metadata.go create mode 100644 go/internal/shquote/shquote.go create mode 100644 go/internal/shquote/shquote_test.go diff --git a/go/internal/cli/metadata_snippet_test.go b/go/internal/cli/metadata_snippet_test.go new file mode 100644 index 0000000..13e6d16 --- /dev/null +++ b/go/internal/cli/metadata_snippet_test.go @@ -0,0 +1,63 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestMetadataSnippet_matchesThePythonReferenceAcrossScenarios(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + cases := []struct { + name string + home func(t *testing.T) string + args []string + }{ + {name: "plain", home: func(t *testing.T) string { return t.TempDir() }, args: []string{"metadata", "snippet"}}, + {name: "raw", home: func(t *testing.T) string { return t.TempDir() }, args: []string{"metadata", "snippet", "--raw"}}, + {name: "home path contains spaces (exercises shlex quoting)", home: func(t *testing.T) string { + dir := filepath.Join(t.TempDir(), "dir with spaces") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + return dir + }, args: []string{"metadata", "snippet"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := tc.home(t) + fullArgs := append([]string{"--home", home}, tc.args...) + + want, err := exec.Command(reference, fullArgs...).Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(fullArgs) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } + }) + } +} diff --git a/go/internal/cli/root.go b/go/internal/cli/root.go index 0f2c9e8..850b2fb 100644 --- a/go/internal/cli/root.go +++ b/go/internal/cli/root.go @@ -12,6 +12,7 @@ import ( "github.com/douglasjarquin/sum/go/internal/contract" "github.com/douglasjarquin/sum/go/internal/graph" + "github.com/douglasjarquin/sum/go/internal/metadata" "github.com/douglasjarquin/sum/go/internal/ordjson" "github.com/douglasjarquin/sum/go/internal/settings" "github.com/douglasjarquin/sum/go/internal/store" @@ -168,6 +169,32 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { }, }) + root.AddCommand(&cobra.Command{ + Use: "metadata", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet && opts.reference != "" { + raw := len(args) == 2 && args[0] == "snippet" && args[1] == "--raw" + plain := len(args) == 1 && args[0] == "snippet" + if raw || plain { + if _, err := store.Open(opts.home); err != nil { + return err + } + view := metadata.Snippet(opts.reference, opts.home) + if raw { + tomlValue, _ := view.Get("toml") + toml, _ := tomlValue.(string) + _, writeErr := io.WriteString(cmd.OutOrStdout(), toml) + return writeErr + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + } + return opts.compat(cmd.Context(), append([]string{"metadata"}, args...)) + }, + }) + for _, name := range compatibilityCommands { command := &cobra.Command{ Use: name, @@ -183,7 +210,7 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { } var compatibilityCommands = []string{ - "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "metadata", "attention", "bind", "backup", "project", "herdr", "dev", "brief", "refresh", "release", "update", + "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "attention", "bind", "backup", "project", "herdr", "dev", "brief", "refresh", "release", "update", } func parseGraphConfigArgs(tokens []string) (harness string, raw bool, ok bool) { diff --git a/go/internal/metadata/metadata.go b/go/internal/metadata/metadata.go new file mode 100644 index 0000000..7c417cd --- /dev/null +++ b/go/internal/metadata/metadata.go @@ -0,0 +1,57 @@ +package metadata + +import ( + "strings" + + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/shquote" +) + +var TaskTokens = []string{"sum_state", "sum_task", "sum_repo", "sum_rev", "sum_pr"} + +var RootTokens = []string{"sum_inbox", "sum_tasks"} + +var SumStates = []string{ + "needs-attention", "needs-decision", "merged-cleanup-pending", "review-ready", "attention-blocked", "attention-exited", + "attention-closed", "attention-idle", "instruction-refresh-pending", "answer-pending", "pr-open", "verified", "preparing", "running", +} + +func snippetTOML() string { + return strings.Join([]string{ + "# sum: optional sidebar rows that render sum's task tokens. Merge into ~/.config/herdr/config.toml, then run", + "# `herdr server reload-config`. `rows` replaces the whole layout, so keep the built-in tokens you already use.", + "[ui.sidebar.agents]", + `rows = [["state_icon", "workspace", "tab"], ["agent", "$sum_state"], ["$sum_task", "$sum_inbox"]]`, + "", + "[ui.sidebar.spaces]", + `rows = [["state_icon", "workspace"], ["branch", "git_status"], ["$sum_state", "$sum_task"]]`, + "", + "# Optional: pop-up notifications for sum transitions need a toast delivery; sum sends them only after `metadata enable --notify`.", + "# [ui.toast]", + `# delivery = "herdr"`, + "", + }, "\n") +} + +func Snippet(sumctlPath, home string) *ordjson.Object { + tokens := ordjson.NewObject() + tokens.Set("task", toAny(TaskTokens)) + tokens.Set("coordinator", toAny(RootTokens)) + + result := ordjson.NewObject() + result.Set("toml", snippetTOML()) + result.Set("tokens", tokens) + result.Set("states", toAny(SumStates)) + result.Set("enable", shquote.CommandFor(sumctlPath, home, "metadata", "enable")) + result.Set("inbox", shquote.CommandFor(sumctlPath, home, "metadata", "inbox")) + result.Set("note", "Nothing here is written by sum: the snippet is text for the user to merge. Without these rows the tokens exist but stay out of sight; every other setting (theme, keybindings, labels, toast delivery) is the user's.") + return result +} + +func toAny(values []string) []any { + out := make([]any, len(values)) + for i, v := range values { + out[i] = v + } + return out +} diff --git a/go/internal/shquote/shquote.go b/go/internal/shquote/shquote.go new file mode 100644 index 0000000..b676d8b --- /dev/null +++ b/go/internal/shquote/shquote.go @@ -0,0 +1,30 @@ +package shquote + +import ( + "regexp" + "strings" +) + +var unsafe = regexp.MustCompile(`[^\w@%+=:,./-]`) + +func Quote(s string) string { + if s == "" { + return "''" + } + if !unsafe.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" +} + +func Join(parts []string) string { + quoted := make([]string, len(parts)) + for i, p := range parts { + quoted[i] = Quote(p) + } + return strings.Join(quoted, " ") +} + +func CommandFor(sumctlPath, home string, args ...string) string { + return Join(append([]string{sumctlPath, "--home", home}, args...)) +} diff --git a/go/internal/shquote/shquote_test.go b/go/internal/shquote/shquote_test.go new file mode 100644 index 0000000..630fb9a --- /dev/null +++ b/go/internal/shquote/shquote_test.go @@ -0,0 +1,29 @@ +package shquote + +import "testing" + +func TestQuote_matchesPythonShlexQuote(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", "''"}, + {"plain", "plain"}, + {"a/b-c.d_e:f,g@h%i+j=k", "a/b-c.d_e:f,g@h%i+j=k"}, + {"has space", "'has space'"}, + {"has'quote", `'has'"'"'quote'`}, + } + for _, tc := range cases { + if got := Quote(tc.in); got != tc.want { + t.Errorf("Quote(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCommandFor_joinsWithHomeFlag(t *testing.T) { + got := CommandFor("/bin/sumctl", "/tmp/dir with spaces", "metadata", "enable") + want := "/bin/sumctl --home '/tmp/dir with spaces' metadata enable" + if got != want { + t.Fatalf("CommandFor = %q, want %q", got, want) + } +} From f279121b3b9ba1ba8767608c867038b95fce437b Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 20:56:08 +0000 Subject: [PATCH 08/11] feat(go): port Store.designated/owner/registration to native Go (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Store port (go/internal/store) with the session/ registration methods every registration-aware command needs: Designated (state.json present, dev.json absent), Owner (context.json if present), Registration/Register/Registrations (registration_key's sha256-of-machine/session/pane hash, identity mismatch detection, registered_at preserved across re-registration, the exact mcp/ sum_version/schema shape Register writes). Not wired into any CLI command yet — doctor/status/init need context() (Herdr pane env detection) ported first, which is a separate unit. registrationKey verified against the real Python registration_key() for a fixed input; the rest is covered by Go unit tests (no existing CLI surface exposes these methods' output directly to differential- test against yet). --- go/internal/store/registration_test.go | 139 ++++++++++++++++++++++ go/internal/store/store.go | 152 ++++++++++++++++++++++++- 2 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 go/internal/store/registration_test.go diff --git a/go/internal/store/registration_test.go b/go/internal/store/registration_test.go new file mode 100644 index 0000000..9b0b02b --- /dev/null +++ b/go/internal/store/registration_test.go @@ -0,0 +1,139 @@ +package store + +import ( + "os" + "testing" +) + +func TestRegistrationKey_matchesThePythonReferenceHash(t *testing.T) { + got := registrationKey(Endpoint{Machine: "m1", Session: "s1", Pane: "p1"}) + want := "293c980b4e6c2341" + if got != want { + t.Fatalf("registrationKey = %q, want %q", got, want) + } +} + +func TestDesignated_falseUntilInitializedAndFalseForADevCheckout(t *testing.T) { + home := t.TempDir() + s, err := Open(home) + if err != nil { + t.Fatalf("open: %v", err) + } + if s.Designated() { + t.Fatal("empty store must not be designated") + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + if !s.Designated() { + t.Fatal("initialized store must be designated") + } + if err := os.WriteFile(home+"/dev.json", []byte(`{"schema":1}`), 0o600); err != nil { + t.Fatal(err) + } + if s.Designated() { + t.Fatal("a store with dev.json must never be designated") + } +} + +func TestOwner_nilWhenAbsent(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + owner, err := s.Owner() + if err != nil { + t.Fatalf("owner: %v", err) + } + if owner != nil { + t.Fatalf("owner = %v, want nil", owner) + } +} + +func TestRegisterThenRegistration_roundTripsAndPreservesRegisteredAt(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + endpoint := Endpoint{Machine: "m1", Session: "s1", Pane: "p1", Cwd: "/tmp/x"} + + first, err := s.Register(endpoint, "developer", nil) + if err != nil { + t.Fatalf("register: %v", err) + } + role, _ := first.Get("role") + if role != "developer" { + t.Fatalf("role = %v, want developer", role) + } + firstRegisteredAt, _ := first.Get("registered_at") + + read, err := s.Registration(endpoint) + if err != nil { + t.Fatalf("registration: %v", err) + } + if read == nil { + t.Fatal("registration = nil, want the record just written") + } + + second, err := s.Register(endpoint, "worker", "t-0123456789ab") + if err != nil { + t.Fatalf("re-register: %v", err) + } + secondRegisteredAt, _ := second.Get("registered_at") + if secondRegisteredAt != firstRegisteredAt { + t.Fatalf("registered_at changed on re-registration: %v -> %v", firstRegisteredAt, secondRegisteredAt) + } + role, _ = second.Get("role") + if role != "worker" { + t.Fatalf("role after re-register = %v, want worker", role) + } +} + +func TestRegistration_rejectsIdentityMismatch(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + endpoint := Endpoint{Machine: "m1", Session: "s1", Pane: "p1"} + if _, err := s.Register(endpoint, "developer", nil); err != nil { + t.Fatalf("register: %v", err) + } + + path := s.Sessions + "/" + registrationKey(endpoint) + ".json" + corrupted := `{"schema":1,"key":"x","role":"developer","task":null,"machine":"other","session":"s1","pane":"p1"}` + if err := os.WriteFile(path, []byte(corrupted), 0o600); err != nil { + t.Fatal(err) + } + if _, err := s.Registration(endpoint); err == nil { + t.Fatal("expected an identity mismatch error") + } +} + +func TestRegistrations_returnsAllSortedByFilename(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := s.Register(Endpoint{Machine: "m1", Session: "s1", Pane: "p1"}, "developer", nil); err != nil { + t.Fatalf("register 1: %v", err) + } + if _, err := s.Register(Endpoint{Machine: "m2", Session: "s2", Pane: "p2"}, "worker", nil); err != nil { + t.Fatalf("register 2: %v", err) + } + all, err := s.Registrations() + if err != nil { + t.Fatalf("registrations: %v", err) + } + if len(all) != 2 { + t.Fatalf("len(registrations) = %d, want 2", len(all)) + } +} diff --git a/go/internal/store/store.go b/go/internal/store/store.go index 2de1f4d..4dd85d0 100644 --- a/go/internal/store/store.go +++ b/go/internal/store/store.go @@ -1,15 +1,19 @@ package store import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" + "strings" "syscall" "time" + "github.com/douglasjarquin/sum/go/internal/contract" "github.com/douglasjarquin/sum/go/internal/ordjson" ) @@ -20,8 +24,9 @@ const SumVersion = "0.1.0" var taskIDPattern = regexp.MustCompile(`^t-[a-f0-9]{12}$`) type Store struct { - Home string - Tasks string + Home string + Tasks string + Sessions string } func Open(home string) (*Store, error) { @@ -29,7 +34,7 @@ func Open(home string) (*Store, error) { if err != nil { return nil, err } - s := &Store{Home: resolved, Tasks: filepath.Join(resolved, "tasks")} + s := &Store{Home: resolved, Tasks: filepath.Join(resolved, "tasks"), Sessions: filepath.Join(resolved, "sessions")} statePath := filepath.Join(resolved, "state.json") if info, statErr := os.Stat(statePath); statErr == nil && !info.IsDir() { value, readErr := ordjson.ReadFile(statePath) @@ -206,3 +211,144 @@ func (s *Store) AllTasks() ([]*ordjson.Object, error) { func Now() string { return time.Now().UTC().Format("2006-01-02T15:04:05+00:00") } + +type Endpoint struct { + Machine string + Session string + Pane string + Cwd string +} + +func registrationKey(e Endpoint) string { + sum := sha256.Sum256([]byte(strings.Join([]string{e.Machine, e.Session, e.Pane}, "\n"))) + return hex.EncodeToString(sum[:])[:16] +} + +func identityMatches(value *ordjson.Object, e Endpoint) bool { + machine, _ := value.Get("machine") + session, _ := value.Get("session") + pane, _ := value.Get("pane") + return machine == e.Machine && session == e.Session && pane == e.Pane +} + +func (s *Store) Designated() bool { + if info, err := os.Stat(filepath.Join(s.Home, "state.json")); err != nil || info.IsDir() { + return false + } + if info, err := os.Stat(filepath.Join(s.Home, "dev.json")); err == nil && !info.IsDir() { + return false + } + return true +} + +func (s *Store) Owner() (*ordjson.Object, error) { + path := filepath.Join(s.Home, "context.json") + if info, err := os.Stat(path); err != nil || info.IsDir() { + return nil, nil + } + value, err := ordjson.ReadFile(path) + if err != nil { + return nil, err + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("context.json is not a JSON object") + } + return obj, nil +} + +func (s *Store) Registration(endpoint Endpoint) (*ordjson.Object, error) { + path := filepath.Join(s.Sessions, registrationKey(endpoint)+".json") + if info, err := os.Stat(path); err != nil || info.IsDir() { + return nil, nil + } + value, err := ordjson.ReadFile(path) + if err != nil { + return nil, err + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("session registration is not a JSON object") + } + if !identityMatches(obj, endpoint) { + return nil, fmt.Errorf("session registration identity mismatch; inspect the sessions directory") + } + return obj, nil +} + +func (s *Store) Register(endpoint Endpoint, role string, task any) (*ordjson.Object, error) { + stateValue, err := ordjson.ReadFile(filepath.Join(s.Home, "state.json")) + if err != nil { + return nil, err + } + state, ok := stateValue.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("state.json is not a JSON object") + } + previous, err := s.Registration(endpoint) + if err != nil { + return nil, err + } + instance, _ := state.Get("instance") + registeredAt := Now() + if previous != nil { + if v, ok := previous.Get("registered_at"); ok { + if s, isString := v.(string); isString { + registeredAt = s + } + } + } + + key := registrationKey(endpoint) + value := ordjson.NewObject() + value.Set("schema", json.Number(fmt.Sprint(Schema))) + value.Set("key", key) + value.Set("role", role) + value.Set("task", task) + value.Set("machine", endpoint.Machine) + value.Set("session", endpoint.Session) + value.Set("pane", endpoint.Pane) + value.Set("cwd", endpointCwd(endpoint)) + value.Set("instance", instance) + value.Set("sum_version", contract.SumVersion) + mcp := ordjson.NewObject() + mcp.Set("server", contract.MCP.Server) + mcp.Set("version", contract.MCP.Version) + mcp.Set("tools", json.Number(fmt.Sprint(contract.MCP.Tools))) + value.Set("mcp", mcp) + value.Set("registered_at", registeredAt) + value.Set("updated_at", Now()) + + if err := ordjson.WriteFile(filepath.Join(s.Sessions, key+".json"), value); err != nil { + return nil, err + } + return value, nil +} + +func endpointCwd(e Endpoint) any { + if e.Cwd == "" { + return nil + } + return e.Cwd +} + +func (s *Store) Registrations() ([]*ordjson.Object, error) { + entries, err := filepath.Glob(filepath.Join(s.Sessions, "*.json")) + if err != nil { + return nil, err + } + sort.Strings(entries) + registrations := make([]*ordjson.Object, 0, len(entries)) + for _, entry := range entries { + value, err := ordjson.ReadFile(entry) + if err != nil { + return nil, err + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("%s is not a JSON object", entry) + } + registrations = append(registrations, obj) + } + return registrations, nil +} From ada8a60646e3b912b5b5292ba1f466e3d4a8dd7c Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 21:40:57 +0000 Subject: [PATCH 09/11] docs: add ATTRIBUTIONS.md and link it from README/CONTRIBUTING (#56) One page crediting the projects that shaped sum, per the owner's required credit list and editorial constraints: conceptual inspiration vs. direct dependency vs. adapted code vs. historical lineage vs. evaluated-not-installed integrations are kept distinct, and the four must-find mappings (Firstmate -> agent distro, Oh My Pi -> builtins, Solo -> meta-harness, Unpeel -> MCP pane/session management) are called out explicitly at both ends of the page. README's prior one-line "License and inspiration" blurb now points here instead of duplicating a shorter version of the same credit. CONTRIBUTING's existing attribution checklist item now names this file directly. Verified every referenced GitHub repo and file path resolves via `gh api`, and fetched the plain product pages (Solo, Delta, Herdr, mise) to confirm they're live; unpeel.com blocks scraping (403) but resolves as a live domain, not a dead link. --- ATTRIBUTIONS.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- README.md | 2 +- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 ATTRIBUTIONS.md diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md new file mode 100644 index 0000000..f25209b --- /dev/null +++ b/ATTRIBUTIONS.md @@ -0,0 +1,63 @@ +# Attributions + +I am building sum for myself, to the highest standard I can achieve. I am not trying to grow a user base for it, and I would rather you use the projects below than adopt sum. They shaped this project in concrete ways and deserve the credit. Please explore them, use the ones that fit your work, and support their maintainers. + +This page distinguishes conceptual inspiration, direct dependency, adapted code, historical lineage, and integrations still being evaluated. Replacing a dependency later does not erase the credit recorded here. It supplements, and never replaces, the license and source notices carried alongside any copied material. + +## Where sum comes from + +**[Firstmate](https://github.com/kunchenguid/firstmate)** is the origin of **the agent-distro concept that inspired sum**: an ordinary coding harness, portable instructions/skills/helpers, and one liaison coordinating workers. That shape is sum's lineage, not a claim that Firstmate invented agent orchestration generally. If the idea of one coordinator delegating to disposable workers appeals to you, try Firstmate itself first. + +**[Consigliere](https://github.com/douglasjarquin/consigliere)** was the author's own earlier, Firstmate-derived experiment: delegation conventions and the light mafia identity sum still carries came from there. It is respectful lineage, not a story about something that didn't work. + +## Terminal and session runtime + +**[Herdr](https://github.com/herdrdev/herdr)** ([site](https://herdr.dev/)) is the terminal runtime sum is native to: pane, session, and worktree operations are Herdr's own mechanics, and sum deliberately lets an existing runtime own them instead of reimplementing process/session management. + +**[Herdr Mesh](https://github.com/runchr-works/herdr-mesh)** supplied the early, concrete MCP bridge and the small shared pane/agent tool surface that sum's own Mesh builtin now implements natively. That credit stands even after internalizing ownership of the implementation. + +**[Unpeel](https://unpeel.com/)** is a conceptual inspiration for **MCP pane/session management** and cross-harness coordination — not a claim that any implementation here was copied from it. Worth exploring if you want a more general take on coordinating multiple harnesses through MCP. + +**[Solo](https://soloterm.com/)** ([meta-harness explanation](https://soloterm.com/blog/the-agentic-metaharness)) is where the **meta-harness framing** sum uses comes from, along with wakeups, presets, handoffs, selective context, environment awareness, and visibility as ideas worth having regardless of which terminal you run them in. If you want a polished desktop application built around this idea rather than a small CLI helper, Solo is that product. + +**[Delta](https://delta.dev/)** shaped how sum thinks about durable, thread-centered work: keeping the conversation and code context attached to a unit of work, and carrying that through handoff and review. + +**[Oh My Pi](https://github.com/can1357/oh-my-pi#09--unapologetically-native-even-on-windows)** is the inspiration behind sum's preference for **builtins and reducing avoidable process boundaries** — running logic in-process instead of shelling out where it is practical to do so. This is inspiration, not a claim of an identical architecture or of matching benchmark results. + +## Provider quota and evidence + +**[quota-axi](https://github.com/kunchenguid/quota-axi)** shaped how sum thinks about provider evidence: freshness and uncertainty made explicit, compact output, and separating collection from policy. It is also the original dependency and inspiration for the author's independent Go sister project, **[Remainder](https://github.com/douglasjarquin/remainder)**. Sum and Pinchos each consume Remainder independently; neither treats it as a sum builtin. Credit to quota-axi stands on its own, without an unmeasured performance comparison. + +## Verification and evidence + +**[Atlas verification example](https://github.com/poteto/verification-skill-example/blob/main/.cursor/skills/verify-atlas/SKILL.md)** demonstrates feature-driven, real-user-path verification backed by observable evidence — the pattern sum's own verification skills follow. It is one worked example, not a supplied universal recorder or driver. + +**[Cursor pstack skills](https://github.com/cursor/plugins/tree/main/pstack/skills)**, in particular [create-verification-skill](https://github.com/cursor/plugins/blob/main/pstack/skills/create-verification-skill/SKILL.md) and [maintain-verification-skill](https://github.com/cursor/plugins/blob/main/pstack/skills/maintain-verification-skill/SKILL.md), shaped how sum creates and maintains project verification contracts and feature maps, and how it selects useful upstream skills at all. Individual imported-source attribution is added here as specific skill imports actually land. + +**[before-and-after](https://github.com/vercel-labs/before-and-after)** shaped how sum presents and publishes before/after media in a pull request — the presentation and publication step, distinct from the capture itself. Any adapted material keeps its original notices. + +## Code exploration + +**[codegraph](https://github.com/colbymchenry/codegraph)** provides the structural code context and worktree-local graph exploration every checkout sum creates gets its own index of. Credit reflects the current pinned-binary, per-checkout-index relationship, not earlier planning language. + +## Verification companions + +**[MADE](https://github.com/douglasjarquin/made)** is the author's existing, independent candidate-bound verification and review companion. It is a separate project with its own relationship to No Mistakes; sum does not conflate the two. + +## Bot deployment + +**[Grok Ship](https://github.com/kunchenguid/grok-ship)** and the [native Firstmate/Bot template](https://x.ai/bot/__4FfrkUdvpdMk6-LKg5r) it distributes shaped sum's single user-facing Bot / project-Bot deployment model. Where a successor replaces one of these, the historical reference stays. + +**[Grok Ship Steward](https://github.com/douglasjarquin/grok-ship-steward)** inspired square, sum's scoped backup/recovery stewardship companion. Any upstream credit Grok Ship Steward itself carries is retained where its material is adapted. + +## Tooling and supporting ecosystem + +**[mise](https://mise.jdx.dev/)** underlies sum's tool/version/task setup and its portable verification foundations. + +**[herdr-mirror](https://github.com/nikok6/herdr-mirror)** was evaluated as a related remote-visibility reference during remote planning. It is labeled evaluated, not installed: sum does not depend on it. + +Beyond the named projects above, sum also rests on the broader Agent Skills convention, Git, and the coding harnesses it launches — none of them sum's own work, all of them worth understanding on their own terms. + +## The four you'll see most + +If you read nothing else on this page: **Firstmate** shaped the agent-distro concept, **Oh My Pi** shaped the preference for builtins, **Solo** shaped the meta-harness framing, and **Unpeel** shaped MCP pane/session management. Go look at all four. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0651db..2482b13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Use an isolated development checkout and keep the installation serving live work - [ ] Run `MISE_ENABLE_TOOLS=go,python,node python3 .agents/skills/verify/scripts/verify_run.py --base ` once for your role and retain its candidate-bound record. - [ ] Run `mise run test-live` separately only when a real Herdr smoke test applies and is available; otherwise leave that manual scenario accurately not-run. - [ ] Describe acceptance results and evidence paths in the handoff, including failures or manual scenarios not run. -- [ ] Include contributor attribution when applicable and state deployment or release impact when relevant. +- [ ] Include contributor attribution when applicable, adding an entry to `ATTRIBUTIONS.md` when new material or inspiration enters, and state deployment or release impact when relevant. - [ ] Leave branch-protection and merge actions to their owner; a human reviews and merges changes. The recommended order is scope and prerequisites, implementation, canonical verification, applicable live verification, review, then human merge. diff --git a/README.md b/README.md index 49086ab..3d2da94 100644 --- a/README.md +++ b/README.md @@ -395,4 +395,4 @@ The optional Git bundle preserves the bootstrap commit. To use it instead: `git ## License and inspiration -MIT. Inspired by Firstmate and Consigliere. Uses [Herdr](https://github.com/herdrdev/herdr), [Herdr Mesh](https://github.com/runchr-works/herdr-mesh), and [quota-axi](https://github.com/kunchenguid/quota-axi) rather than replacing them. +MIT. sum stands on projects it did not write — Firstmate, Herdr, Oh My Pi, Solo, Unpeel, and others. See [ATTRIBUTIONS.md](ATTRIBUTIONS.md) for the complete credit and what each one shaped. From 544fdea04b84492b3c98b97a1c37256cc0db5710 Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 21:41:09 +0000 Subject: [PATCH 10/11] feat(go): port sumctl init's non-designated branch to native Go (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports context()/session_from_env() (go/internal/store/context.go) and init()'s non-designated path (go/internal/roleinit) — installation_hint (is this checkout a linked worktree of a real sum installation?), matching_task (are we a dispatched worker's own pane?), and development_marker (.sum/dev.json), assembled into the exact role/home/task/installation_home/note shape the Python reference returns for a developer or dispatched-worker pane. The designated/coordinator branch — live pane verification via Herdr, coordinator claim/reclaim, contract state, cleanup_pending, pump, hook/metadata summaries — is NOT ported. It is sum's most safety-critical path (a bug could misfire a coordinator claim or lose a registration) and is explicitly out of scope here; `init` only goes native when the store is confirmed not designated, and falls back to the Python reference for every other shape (unrecognized --role, designated store, anything init's own argparse would need to reject). Differential-tested against the real Python bin/sumctl init in this very dev checkout (safe: the non-designated branch never writes — no store.lock, no atomic_json — same read-only observation `init` already performs here on every invocation this session), plus Go unit tests for the worker-task-matching and linked-worktree-detection logic using throwaway git fixtures. --- go/internal/cli/init_test.go | 75 ++++++++ go/internal/cli/root.go | 72 +++++++- go/internal/roleinit/roleinit.go | 172 ++++++++++++++++++ go/internal/roleinit/roleinit_test.go | 244 ++++++++++++++++++++++++++ go/internal/store/context.go | 66 +++++++ 5 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 go/internal/cli/init_test.go create mode 100644 go/internal/roleinit/roleinit.go create mode 100644 go/internal/roleinit/roleinit_test.go create mode 100644 go/internal/store/context.go diff --git a/go/internal/cli/init_test.go b/go/internal/cli/init_test.go new file mode 100644 index 0000000..39dc4de --- /dev/null +++ b/go/internal/cli/init_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Safe against the live installation: the non-designated branch of `init` +// never writes (no store.lock, no atomic_json) — same read-only observation +// `./bin/sumctl init` already performs on every ordinary invocation here. +func TestInit_matchesThePythonReferenceInThisDevCheckout(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + if os.Getenv("HERDR_ENV") != "1" || os.Getenv("HERDR_PANE_ID") == "" { + t.Skip("not running inside a live Herdr pane") + } + + home := filepath.Join(repoRoot, ".sum") + args := []string{"--home", home, "init"} + + want, err := exec.Command(reference, args...).Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(args) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } +} + +func TestInit_fallsBackToReferenceWhenRequestedRoleUnrecognized(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + reference := filepath.Join(dir, "reference.sh") + if err := os.WriteFile(reference, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$SUM_GO_ARGS_FILE\"\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("SUM_GO_ARGS_FILE", argsFile) + + home := filepath.Join(dir, "state") + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs([]string{"--home", home, "init", "--role", "bogus"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("execute: %v (stderr=%s)", err, stderr.String()) + } + got, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + want := "--home\n" + home + "\ninit\n--role\nbogus\n" + if string(got) != want { + t.Fatalf("reference argv = %q, want %q", got, want) + } +} diff --git a/go/internal/cli/root.go b/go/internal/cli/root.go index 850b2fb..7d1145e 100644 --- a/go/internal/cli/root.go +++ b/go/internal/cli/root.go @@ -14,6 +14,7 @@ import ( "github.com/douglasjarquin/sum/go/internal/graph" "github.com/douglasjarquin/sum/go/internal/metadata" "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/roleinit" "github.com/douglasjarquin/sum/go/internal/settings" "github.com/douglasjarquin/sum/go/internal/store" "github.com/spf13/cobra" @@ -195,6 +196,32 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { }, }) + root.AddCommand(&cobra.Command{ + Use: "init", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet && opts.reference != "" { + if role, task, ok := parseInitArgs(args); ok { + st, err := store.Open(opts.home) + if err == nil && !st.Designated() { + root := runtimeRootFromReference(opts.reference) + ctx, ctxErr := store.Context(root) + if ctxErr != nil { + return ctxErr + } + view, initErr := roleinit.Init(root, st, ctx, role, task) + if initErr != nil { + return initErr + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + } + } + return opts.compat(cmd.Context(), append([]string{"init"}, args...)) + }, + }) + for _, name := range compatibilityCommands { command := &cobra.Command{ Use: name, @@ -210,7 +237,50 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { } var compatibilityCommands = []string{ - "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "attention", "bind", "backup", "project", "herdr", "dev", "brief", "refresh", "release", "update", + "doctor", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "attention", "bind", "backup", "project", "herdr", "dev", "brief", "refresh", "release", "update", +} + +func parseInitArgs(tokens []string) (role, task string, ok bool) { + roles := map[string]bool{"coordinator": true, "worker": true, "developer": true} + reclaimSeen := false + for i := 0; i < len(tokens); i++ { + token := tokens[i] + switch { + case token == "--role": + if role != "" || i+1 >= len(tokens) { + return "", "", false + } + i++ + role = tokens[i] + case strings.HasPrefix(token, "--role="): + if role != "" { + return "", "", false + } + role = strings.TrimPrefix(token, "--role=") + case token == "--task": + if task != "" || i+1 >= len(tokens) { + return "", "", false + } + i++ + task = tokens[i] + case strings.HasPrefix(token, "--task="): + if task != "" { + return "", "", false + } + task = strings.TrimPrefix(token, "--task=") + case token == "--reclaim": + if reclaimSeen { + return "", "", false + } + reclaimSeen = true + default: + return "", "", false + } + } + if role != "" && !roles[role] { + return "", "", false + } + return role, task, true } func parseGraphConfigArgs(tokens []string) (harness string, raw bool, ok bool) { diff --git a/go/internal/roleinit/roleinit.go b/go/internal/roleinit/roleinit.go new file mode 100644 index 0000000..265e2f3 --- /dev/null +++ b/go/internal/roleinit/roleinit.go @@ -0,0 +1,172 @@ +package roleinit + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/store" +) + +var ErrDesignated = fmt.Errorf("designated installation: init must run through the Python reference") + +func Init(root string, s *store.Store, ctx *ordjson.Object, requestedRole, requestedTask string) (*ordjson.Object, error) { + if requestedRole == "worker" && requestedTask == "" { + return nil, fmt.Errorf("--role worker needs --task TASK_ID") + } + if s.Designated() { + return nil, ErrDesignated + } + + endpoint := store.EndpointFromContext(ctx) + hint, err := installationHint(root) + if err != nil { + return nil, err + } + + var task *ordjson.Object + if hint != "" { + if hintStore, openErr := store.Open(hint); openErr == nil { + task, err = matchingTask(hintStore, endpoint) + if err != nil { + return nil, err + } + } + } + + role := "developer" + var taskID any + if task != nil { + role = "worker" + taskID, _ = task.Get("id") + } + if requestedRole == "coordinator" { + return nil, fmt.Errorf("%s is not a sum installation (no state.json from setup). A checkout alone grants no coordinator authority; run mise run setup in the designated installation", s.Home) + } + + marker, err := developmentMarker(root) + if err != nil { + return nil, err + } + + result := ordjson.NewObject() + result.Set("role", role) + result.Set("home", s.Home) + result.Set("installation", false) + result.Set("task", taskID) + if hint != "" { + result.Set("installation_home", hint) + } else { + result.Set("installation_home", nil) + } + result.Set("registered", false) + result.Set("endpoint", ctx) + if marker != nil { + result.Set("development", marker) + } else { + result.Set("development", nil) + } + result.Set("note", note(task, marker)) + return result, nil +} + +func note(task, marker *ordjson.Object) string { + if task != nil { + return "Dispatched worker checkout: follow your brief; do not initialize a coordinator." + } + base := "Development checkout: modify and test sum here only. No coordinator initialization, dispatch, production setup, or instance-wide updates." + if marker == nil { + return base + } + installationValue, _ := marker.Get("installation") + installation, _ := installationValue.(string) + return base + " Tests use temporary --home state and a named lab Herdr session; the installed helper at " + + filepath.Join(installation, "bin", "sumctl") + " owns any parent-task callbacks." +} + +func installationHint(root string) (string, error) { + out, err := exec.Command("git", "-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir").Output() + if err != nil { + return "", nil + } + common := strings.TrimSpace(string(out)) + if filepath.Base(common) != ".git" { + return "", nil + } + parent := filepath.Dir(common) + if resolveOrSelf(parent) == resolveOrSelf(root) { + return "", nil + } + home := filepath.Join(parent, ".sum") + if info, statErr := os.Stat(filepath.Join(home, "state.json")); statErr != nil || info.IsDir() { + return "", nil + } + return home, nil +} + +func resolveOrSelf(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return path + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + return abs +} + +func matchingTask(s *store.Store, endpoint store.Endpoint) (*ordjson.Object, error) { + tasks, err := s.AllTasks() + if err != nil { + return nil, err + } + for _, task := range tasks { + paneValue, hasPane := task.Get("pane") + pane, _ := paneValue.(string) + if !hasPane || pane == "" { + continue + } + status, _ := task.Get("status") + if status == "archived" { + continue + } + machineValue, _ := task.Get("machine") + sessionValue, _ := task.Get("session") + if machineValue == endpoint.Machine && sessionValue == endpoint.Session && pane == endpoint.Pane { + return task, nil + } + } + return nil, nil +} + +func developmentMarker(root string) (*ordjson.Object, error) { + path := filepath.Join(root, ".sum", "dev.json") + if info, statErr := os.Stat(path); statErr != nil || info.IsDir() { + return nil, nil + } + value, err := ordjson.ReadFile(path) + if err != nil { + return nil, err + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("%s is not a JSON object", path) + } + schemaOK := false + if schema, has := obj.Get("schema"); has { + if number, isNum := schema.(json.Number); isNum { + if n, convErr := number.Int64(); convErr == nil && n == store.Schema { + schemaOK = true + } + } + } + kind, _ := obj.Get("kind") + if !schemaOK || kind != "development" { + return nil, fmt.Errorf("unrecognized development marker %s; inspect it before continuing", path) + } + return obj, nil +} diff --git a/go/internal/roleinit/roleinit_test.go b/go/internal/roleinit/roleinit_test.go new file mode 100644 index 0000000..e1f0a53 --- /dev/null +++ b/go/internal/roleinit/roleinit_test.go @@ -0,0 +1,244 @@ +package roleinit + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/store" +) + +func jsonInt(n int) json.Number { + return json.Number(strconv.Itoa(n)) +} + +func fakeContext(machine, session, pane, cwd string) *ordjson.Object { + ctx := ordjson.NewObject() + ctx.Set("session", session) + ctx.Set("pane", pane) + ctx.Set("machine", machine) + ctx.Set("cwd", cwd) + ctx.Set("at", "2026-09-10T00:00:00+00:00") + return ctx +} + +func TestInit_requiresTaskForRoleWorker(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + _, err = Init(root, s, fakeContext("m", "s", "p", root), "worker", "") + if err == nil || err.Error() != "--role worker needs --task TASK_ID" { + t.Fatalf("err = %v, want the worker/task requirement message", err) + } +} + +func TestInit_refusesCoordinatorWhenNotDesignated(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + _, err = Init(root, s, fakeContext("m", "s", "p", root), "coordinator", "") + if err == nil { + t.Fatal("expected an error refusing coordinator role") + } +} + +func TestInit_returnsErrDesignated(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("store init: %v", err) + } + _, err = Init(root, s, fakeContext("m", "s", "p", root), "", "") + if err != ErrDesignated { + t.Fatalf("err = %v, want ErrDesignated", err) + } +} + +func TestInit_plainDeveloperWithNoHintOrMarker(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + view, err := Init(root, s, fakeContext("m", "s", "p", root), "", "") + if err != nil { + t.Fatalf("init: %v", err) + } + role, _ := view.Get("role") + if role != "developer" { + t.Fatalf("role = %v, want developer", role) + } + noteValue, _ := view.Get("note") + want := "Development checkout: modify and test sum here only. No coordinator initialization, dispatch, production setup, or instance-wide updates." + if noteValue != want { + t.Fatalf("note = %q, want %q", noteValue, want) + } + installationHome, _ := view.Get("installation_home") + if installationHome != nil { + t.Fatalf("installation_home = %v, want nil (no linked worktree here)", installationHome) + } +} + +func TestInit_developmentMarkerAppendsCallbackSentence(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + marker := ordjson.NewObject() + marker.Set("schema", jsonInt(store.Schema)) + marker.Set("kind", "development") + marker.Set("installation", "/installations/sum") + if err := ordjson.WriteFile(filepath.Join(root, ".sum", "dev.json"), marker); err != nil { + t.Fatal(err) + } + + view, err := Init(root, s, fakeContext("m", "s", "p", root), "", "") + if err != nil { + t.Fatalf("init: %v", err) + } + noteValue, _ := view.Get("note") + want := "Development checkout: modify and test sum here only. No coordinator initialization, dispatch, production setup, or instance-wide updates. " + + "Tests use temporary --home state and a named lab Herdr session; the installed helper at /installations/sum/bin/sumctl owns any parent-task callbacks." + if noteValue != want { + t.Fatalf("note = %q, want %q", noteValue, want) + } +} + +func TestMatchingTask_findsAPaneMatchingNonArchivedTask(t *testing.T) { + hintHome := t.TempDir() + hintStore, err := store.Open(hintHome) + if err != nil { + t.Fatalf("open hint store: %v", err) + } + if err := hintStore.Init(); err != nil { + t.Fatalf("hint store init: %v", err) + } + + endpoint := store.Endpoint{Machine: "m1", Session: "s1", Pane: "p1"} + + archived := ordjson.NewObject() + archived.Set("schema", jsonInt(store.Schema)) + archived.Set("id", "t-aaaaaaaaaaaa") + archived.Set("status", "archived") + archived.Set("pane", "p1") + archived.Set("session", "s1") + archived.Set("machine", "m1") + if err := hintStore.SaveTask(archived); err != nil { + t.Fatalf("save archived task: %v", err) + } + + running := ordjson.NewObject() + running.Set("schema", jsonInt(store.Schema)) + running.Set("id", "t-bbbbbbbbbbbb") + running.Set("status", "running") + running.Set("pane", "p1") + running.Set("session", "s1") + running.Set("machine", "m1") + if err := hintStore.SaveTask(running); err != nil { + t.Fatalf("save running task: %v", err) + } + + task, err := matchingTask(hintStore, endpoint) + if err != nil { + t.Fatalf("matchingTask: %v", err) + } + if task == nil { + t.Fatal("expected a matching task, got nil") + } + id, _ := task.Get("id") + if id != "t-bbbbbbbbbbbb" { + t.Fatalf("matched task id = %v, want t-bbbbbbbbbbbb (the archived one must be skipped)", id) + } +} + +func TestMatchingTask_nilWhenNoIdentityMatches(t *testing.T) { + hintHome := t.TempDir() + hintStore, err := store.Open(hintHome) + if err != nil { + t.Fatalf("open hint store: %v", err) + } + if err := hintStore.Init(); err != nil { + t.Fatalf("hint store init: %v", err) + } + task, err := matchingTask(hintStore, store.Endpoint{Machine: "m1", Session: "s1", Pane: "p1"}) + if err != nil { + t.Fatalf("matchingTask: %v", err) + } + if task != nil { + t.Fatalf("task = %v, want nil", task) + } +} + +func TestInstallationHint_findsTheLinkedWorktreesInstallation(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + base := t.TempDir() + installation := filepath.Join(base, "installation") + if err := os.MkdirAll(installation, 0o700); err != nil { + t.Fatal(err) + } + runGit(t, installation, "init", "-q") + runGit(t, installation, "config", "user.email", "test@example.com") + runGit(t, installation, "config", "user.name", "test") + runGit(t, installation, "commit", "--allow-empty", "-q", "-m", "root") + if err := os.MkdirAll(filepath.Join(installation, ".sum"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installation, ".sum", "state.json"), []byte(`{"schema":1}`), 0o600); err != nil { + t.Fatal(err) + } + + worktree := filepath.Join(base, "worktree") + runGit(t, installation, "worktree", "add", worktree, "-b", "wt") + + hint, err := installationHint(worktree) + if err != nil { + t.Fatalf("installationHint: %v", err) + } + wantSuffix := filepath.Join(installation, ".sum") + if hint == "" { + t.Fatal("hint is empty, want the installation's .sum") + } + resolvedHint, _ := filepath.EvalSymlinks(hint) + resolvedWant, _ := filepath.EvalSymlinks(wantSuffix) + if resolvedHint != resolvedWant { + t.Fatalf("hint = %s, want %s", hint, wantSuffix) + } +} + +func TestInstallationHint_emptyForAnOrdinaryRepoRoot(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + root := t.TempDir() + runGit(t, root, "init", "-q") + hint, err := installationHint(root) + if err != nil { + t.Fatalf("installationHint: %v", err) + } + if hint != "" { + t.Fatalf("hint = %q, want empty for a plain (non-worktree) repo root", hint) + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} diff --git a/go/internal/store/context.go b/go/internal/store/context.go new file mode 100644 index 0000000..57ed359 --- /dev/null +++ b/go/internal/store/context.go @@ -0,0 +1,66 @@ +package store + +import ( + "fmt" + "os" + "regexp" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +var ( + sessionNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + socketSessionPattern = regexp.MustCompile(`/sessions/([^/]+)/herdr\.sock$`) +) + +func SessionFromEnv() (string, error) { + value := os.Getenv("SUM_SESSION") + if value == "" { + value = os.Getenv("HERDR_SESSION") + } + if value == "" { + if m := socketSessionPattern.FindStringSubmatch(os.Getenv("HERDR_SOCKET_PATH")); m != nil { + value = m[1] + } + } + if value == "" { + value = "default" + } + if !sessionNamePattern.MatchString(value) { + return "", fmt.Errorf("cannot identify the Herdr session. Set SUM_SESSION to its explicit name") + } + return value, nil +} + +func Context(root string) (*ordjson.Object, error) { + if os.Getenv("HERDR_ENV") != "1" || os.Getenv("HERDR_PANE_ID") == "" { + return nil, fmt.Errorf("run this command inside a Herdr pane (HERDR_ENV=1 and HERDR_PANE_ID are required)") + } + session, err := SessionFromEnv() + if err != nil { + return nil, err + } + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + ctx := ordjson.NewObject() + ctx.Set("session", session) + ctx.Set("pane", os.Getenv("HERDR_PANE_ID")) + ctx.Set("machine", hostname) + ctx.Set("cwd", root) + ctx.Set("at", Now()) + return ctx, nil +} + +func EndpointFromContext(ctx *ordjson.Object) Endpoint { + machine, _ := ctx.Get("machine") + session, _ := ctx.Get("session") + pane, _ := ctx.Get("pane") + cwd, _ := ctx.Get("cwd") + m, _ := machine.(string) + s, _ := session.(string) + p, _ := pane.(string) + c, _ := cwd.(string) + return Endpoint{Machine: m, Session: s, Pane: p, Cwd: c} +} From b437af26bb894a5fd0770db7bc59879be6b7aafc Mon Sep 17 00:00:00 2001 From: cs-test-runner Date: Thu, 10 Sep 2026 22:06:22 +0000 Subject: [PATCH 11/11] feat(go): port sumctl doctor to native Go (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports doctor(store) in full: tool() lookups for python3/node/git/gh/ herdr/quota-axi/lsof (go/internal/toolpath: SUM__BIN override, then the pinned /.local/bin/, then PATH), herdr_version/ ensure_version and the herdr-context pane-get probe (go/internal/ herdrclient: a small JSON-RPC-style wrapper around the herdr binary matching lib/sumctl.py's herdr()/run() error text for timeouts, nonzero exit, and non-JSON output), the gh --attach probe, harness detection over the same HARNESSES table, the Mesh-installed marker file check, and the codegraph check via the already-ported graph.Tool. Reuses Store.Designated/Owner/Registration for the role check. doctor is fully read-only ("never binds") so it needed no scope reduction like init did; every sub-probe here is observation only. Exit code now follows value["ok"] (0 when every check passed, 1 otherwise) rather than the default always-0, matching the Python dispatcher's special-cased handling of this one command. Differential-tested end to end against the real Python bin/sumctl doctor, live, in this dev checkout (byte-identical stdout AND matching exit code) — safe, since every check here is read-only, the same observation `doctor` already performs on every ordinary invocation. Falls back to the Python reference for any unexpected argument (doctor takes none). Added Go unit tests for toolpath's env-override/pinned- local/PATH precedence. --- go/internal/cli/doctor_test.go | 89 ++++++++++ go/internal/cli/root.go | 24 ++- go/internal/doctor/doctor.go | 227 +++++++++++++++++++++++++ go/internal/herdrclient/herdrclient.go | 112 ++++++++++++ go/internal/toolpath/toolpath.go | 24 +++ go/internal/toolpath/toolpath_test.go | 58 +++++++ 6 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 go/internal/cli/doctor_test.go create mode 100644 go/internal/doctor/doctor.go create mode 100644 go/internal/herdrclient/herdrclient.go create mode 100644 go/internal/toolpath/toolpath.go create mode 100644 go/internal/toolpath/toolpath_test.go diff --git a/go/internal/cli/doctor_test.go b/go/internal/cli/doctor_test.go new file mode 100644 index 0000000..6dab99b --- /dev/null +++ b/go/internal/cli/doctor_test.go @@ -0,0 +1,89 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Safe against the live installation: doctor is documented "never binds" and +// this port only reads (tool lookups, a herdr pane-get, file existence +// checks) — the same observation `./bin/sumctl doctor` already performs here. +func TestDoctor_matchesThePythonReferenceInThisDevCheckout(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + if os.Getenv("HERDR_ENV") != "1" || os.Getenv("HERDR_PANE_ID") == "" { + t.Skip("not running inside a live Herdr pane") + } + + home := filepath.Join(repoRoot, ".sum") + args := []string{"--home", home, "doctor"} + + pythonCmd := exec.Command(reference, args...) + want, pythonErr := pythonCmd.Output() + pythonExit := 0 + if pythonErr != nil { + if exitErr, ok := pythonErr.(*exec.ExitError); ok { + pythonExit = exitErr.ExitCode() + } else { + t.Fatalf("python reference failed: %v", pythonErr) + } + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(args) + goExit := 0 + if err := root.ExecuteContext(context.Background()); err != nil { + if exitErr, ok := err.(*ExitError); ok { + goExit = exitErr.Code + } else { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } + if goExit != pythonExit { + t.Fatalf("go exit = %d, want (python reference) %d", goExit, pythonExit) + } +} + +func TestDoctor_fallsBackToReferenceWithExtraArgs(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + reference := filepath.Join(dir, "reference.sh") + if err := os.WriteFile(reference, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$SUM_GO_ARGS_FILE\"\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("SUM_GO_ARGS_FILE", argsFile) + + home := filepath.Join(dir, "state") + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs([]string{"--home", home, "doctor", "--unexpected"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("execute: %v (stderr=%s)", err, stderr.String()) + } + got, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + want := "--home\n" + home + "\ndoctor\n--unexpected\n" + if string(got) != want { + t.Fatalf("reference argv = %q, want %q", got, want) + } +} diff --git a/go/internal/cli/root.go b/go/internal/cli/root.go index 7d1145e..1db83a8 100644 --- a/go/internal/cli/root.go +++ b/go/internal/cli/root.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/douglasjarquin/sum/go/internal/contract" + "github.com/douglasjarquin/sum/go/internal/doctor" "github.com/douglasjarquin/sum/go/internal/graph" "github.com/douglasjarquin/sum/go/internal/metadata" "github.com/douglasjarquin/sum/go/internal/ordjson" @@ -196,6 +197,27 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { }, }) + root.AddCommand(&cobra.Command{ + Use: "doctor", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet && opts.reference != "" && len(args) == 0 { + if st, err := store.Open(opts.home); err == nil { + view := doctor.Doctor(runtimeRootFromReference(opts.reference), st) + if emitErr := emitOrdjson(cmd.OutOrStdout(), view); emitErr != nil { + return emitErr + } + if okValue, _ := view.Get("ok"); okValue != true { + return &ExitError{Code: 1} + } + return nil + } + } + return opts.compat(cmd.Context(), append([]string{"doctor"}, args...)) + }, + }) + root.AddCommand(&cobra.Command{ Use: "init", DisableFlagParsing: true, @@ -237,7 +259,7 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { } var compatibilityCommands = []string{ - "doctor", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "attention", "bind", "backup", "project", "herdr", "dev", "brief", "refresh", "release", "update", + "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "attention", "bind", "backup", "project", "herdr", "dev", "brief", "refresh", "release", "update", } func parseInitArgs(tokens []string) (role, task string, ok bool) { diff --git a/go/internal/doctor/doctor.go b/go/internal/doctor/doctor.go new file mode 100644 index 0000000..42b6264 --- /dev/null +++ b/go/internal/doctor/doctor.go @@ -0,0 +1,227 @@ +package doctor + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/douglasjarquin/sum/go/internal/contract" + "github.com/douglasjarquin/sum/go/internal/graph" + "github.com/douglasjarquin/sum/go/internal/herdrclient" + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/store" + "github.com/douglasjarquin/sum/go/internal/toolpath" +) + +const herdrVersionPin = "0.9.0" + +var toolNames = []string{"python3", "node", "git", "gh", "herdr", "quota-axi", "lsof"} + +var harnessExecutables = []struct { + Kind string + Exe string +}{ + {"codex", "codex"}, + {"claude", "claude"}, + {"grok", "grok"}, + {"cursor", "cursor-agent"}, + {"pi", "pi"}, + {"opencode", "opencode"}, + {"gemini", "gemini"}, + {"omp", "omp"}, + {"copilot", "copilot"}, +} + +func Doctor(runtimeRoot string, s *store.Store) *ordjson.Object { + rows := make([]any, 0, 12) + + for _, name := range toolNames { + row := ordjson.NewObject() + row.Set("tool", name) + if path, err := toolpath.Find(runtimeRoot, name); err == nil { + row.Set("path", path) + row.Set("ok", true) + } else { + row.Set("ok", false) + row.Set("detail", err.Error()) + } + rows = append(rows, row) + } + + herdrVersionRow := ordjson.NewObject() + herdrVersionRow.Set("tool", "herdr-version") + herdrPath, herdrPathErr := toolpath.Find(runtimeRoot, "herdr") + if herdrPathErr == nil { + if found, err := herdrclient.EnsureVersion(herdrPath, herdrVersionPin); err == nil { + herdrVersionRow.Set("ok", true) + herdrVersionRow.Set("detail", found) + } else { + herdrVersionRow.Set("ok", false) + herdrVersionRow.Set("detail", err.Error()) + } + } else { + herdrVersionRow.Set("ok", false) + herdrVersionRow.Set("detail", herdrPathErr.Error()) + } + rows = append(rows, herdrVersionRow) + + rows = append(rows, ghAttachCheck(runtimeRoot)) + + role := ordjson.NewObject() + role.Set("tool", "role") + role.Set("ok", true) + designated := s.Designated() + role.Set("installation", designated) + if designated { + owner, _ := s.Owner() + if owner != nil { + role.Set("coordinator", owner) + } else { + role.Set("coordinator", nil) + } + } else { + role.Set("coordinator", nil) + } + + herdrContextRow := ordjson.NewObject() + herdrContextRow.Set("tool", "herdr-context") + ctx, ctxErr := store.Context(runtimeRoot) + if ctxErr == nil { + if herdrPathErr == nil { + pane, _ := ctx.Get("pane") + session, _ := ctx.Get("session") + paneStr, _ := pane.(string) + sessionStr, _ := session.(string) + _, callErr := herdrclient.Call(herdrPath, sessionStr, 10*time.Second, "pane", "get", paneStr) + if callErr == nil { + herdrContextRow.Set("ok", true) + herdrContextRow.Set("detail", ctx) + var registration *ordjson.Object + if designated { + registration, _ = s.Registration(store.EndpointFromContext(ctx)) + } + if registration != nil { + roleValue, _ := registration.Get("role") + roleStr, _ := roleValue.(string) + role.Set("registered", roleStr) + role.Set("detail", "Registered as "+roleStr+".") + } else { + role.Set("registered", nil) + role.Set("detail", "This pane is not registered. Run ./bin/sumctl init to register explicitly; doctor never binds.") + } + } else { + herdrContextRow.Set("ok", false) + herdrContextRow.Set("detail", callErr.Error()) + } + } else { + herdrContextRow.Set("ok", false) + herdrContextRow.Set("detail", herdrPathErr.Error()) + } + } else { + herdrContextRow.Set("ok", false) + herdrContextRow.Set("detail", ctxErr.Error()) + } + rows = append(rows, herdrContextRow) + rows = append(rows, role) + + installed := ordjson.NewObject() + anyInstalled := false + for _, h := range harnessExecutables { + if path := findExecutable(runtimeRoot, h.Exe); path != "" { + installed.Set(h.Kind, path) + anyInstalled = true + } + } + harnessRow := ordjson.NewObject() + harnessRow.Set("tool", "harness") + harnessRow.Set("ok", anyInstalled) + harnessRow.Set("installed", installed) + rows = append(rows, harnessRow) + + meshRow := ordjson.NewObject() + meshRow.Set("tool", "mesh") + meshRow.Set("ok", isFile(filepath.Join(runtimeRoot, ".deps", "herdr-mesh", ".sum-patched"))) + rows = append(rows, meshRow) + + graphTool := graph.Tool(runtimeRoot) + available, _ := graphTool.Get("available") + pinned, _ := graphTool.Get("pinned") + version, _ := graphTool.Get("version") + path, _ := graphTool.Get("path") + reason, _ := graphTool.Get("reason") + codegraphRow := ordjson.NewObject() + codegraphRow.Set("tool", "codegraph") + codegraphRow.Set("ok", true) + codegraphRow.Set("available", available) + codegraphRow.Set("pinned", pinned) + codegraphRow.Set("version", version) + codegraphRow.Set("path", path) + if available == true { + codegraphRow.Set("detail", "pinned codegraph available; new checkouts get a local index") + } else { + reasonStr, _ := reason.(string) + codegraphRow.Set("detail", "graph optional and unavailable: "+reasonStr) + } + rows = append(rows, codegraphRow) + + allOK := true + for _, r := range rows { + obj := r.(*ordjson.Object) + if ok, _ := obj.Get("ok"); ok != true { + allOK = false + break + } + } + + result := ordjson.NewObject() + result.Set("version", contract.SumVersion) + result.Set("home", s.Home) + result.Set("runtime", runtimeRoot) + result.Set("installation", runtimeRoot) + result.Set("checks", rows) + result.Set("ok", allOK) + result.Set("note", "Observation only: nothing was bound or written. No auth changes or permission bypasses. Authenticate the chosen harness and gh separately.") + return result +} + +func ghAttachCheck(runtimeRoot string) *ordjson.Object { + row := ordjson.NewObject() + row.Set("tool", "gh-attach") + ghPath, err := toolpath.Find(runtimeRoot, "gh") + if err != nil { + row.Set("ok", true) + row.Set("supported", false) + row.Set("detail", err.Error()) + return row + } + out, _ := exec.Command(ghPath, "pr", "edit", "--help").Output() + attach := strings.Contains(string(out), "--attach") + row.Set("ok", true) + row.Set("supported", attach) + if attach { + row.Set("detail", "gh pr edit --attach available; `pr evidence` can publish") + } else { + row.Set("detail", "this runtime's gh has no --attach (GitHub CLI 2.99+); evidence publication defers until a release with the current pin is active") + } + return row +} + +func findExecutable(runtimeRoot, exe string) string { + if path, err := exec.LookPath(exe); err == nil { + return path + } + local := filepath.Join(runtimeRoot, ".local", "bin", exe) + if isFile(local) { + return local + } + return "" +} + +func isFile(path string) bool { + if info, err := os.Stat(path); err == nil { + return !info.IsDir() + } + return false +} diff --git a/go/internal/herdrclient/herdrclient.go b/go/internal/herdrclient/herdrclient.go new file mode 100644 index 0000000..4100cb9 --- /dev/null +++ b/go/internal/herdrclient/herdrclient.go @@ -0,0 +1,112 @@ +package herdrclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +var sessionNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +func run(herdrPath string, timeout time.Duration, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, herdrPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + name := filepath.Base(herdrPath) + if ctxErr := ctx.Err(); ctxErr == context.DeadlineExceeded { + return "", fmt.Errorf("%s: timed out after %s; its effect is unknown", name, timeout) + } + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if len(detail) > 4000 { + detail = detail[len(detail)-4000:] + } + return "", fmt.Errorf("%s exited %d: %s", name, exitErr.ExitCode(), detail) + } + return "", fmt.Errorf("%s: %s", name, err) + } + return stdout.String(), nil +} + +func Call(herdrPath, session string, timeout time.Duration, args ...string) (any, error) { + if !sessionNamePattern.MatchString(session) { + return nil, fmt.Errorf("invalid session name") + } + options := args + for i, a := range args { + if a == "--" { + options = args[:i] + break + } + } + for _, a := range options { + if a == "--session" || strings.HasPrefix(a, "--session=") { + return nil, fmt.Errorf("do not override sum's explicit Herdr session inside command arguments") + } + } + fullArgs := append([]string{"--session", session}, args...) + stdout, err := run(herdrPath, timeout, fullArgs...) + if err != nil { + return nil, err + } + value, err := ordjson.Decode([]byte(stdout)) + if err != nil { + preview := stdout + if len(preview) > 300 { + preview = preview[:300] + } + return nil, fmt.Errorf("Herdr did not return JSON: %s", preview) + } + if obj, ok := value.(*ordjson.Object); ok { + if errValue, has := obj.Get("error"); has && errValue != nil && errValue != false { + encoded, marshalErr := json.Marshal(errValue) + if marshalErr == nil { + return nil, fmt.Errorf("Herdr: %s", encoded) + } + } + if resultValue, has := obj.Get("result"); has { + return resultValue, nil + } + } + return value, nil +} + +func Version(herdrPath string) (string, string, error) { + stdout, err := run(herdrPath, 20*time.Second, "--version") + if err != nil { + return "", "", err + } + found := strings.TrimSpace(stdout) + matched := regexp.MustCompile(`(?i)^herdr[ \t]+(\d+\.\d+\.\d+)$`).FindStringSubmatch(found) + if matched == nil { + return "", "", fmt.Errorf("Herdr did not report one exact stable semantic version; found %q. Run mise run setup; do not silently mix CLI contracts.", found) + } + return matched[1], found, nil +} + +func EnsureVersion(herdrPath, pinned string) (string, error) { + version, found, err := Version(herdrPath) + if err != nil { + return "", err + } + if version != pinned { + return "", fmt.Errorf("This MVP is pinned to Herdr %s; found %q. Run mise run setup; do not silently mix CLI contracts.", pinned, found) + } + return found, nil +} diff --git a/go/internal/toolpath/toolpath.go b/go/internal/toolpath/toolpath.go new file mode 100644 index 0000000..120a500 --- /dev/null +++ b/go/internal/toolpath/toolpath.go @@ -0,0 +1,24 @@ +package toolpath + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func Find(runtimeRoot, name string) (string, error) { + envName := "SUM_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + "_BIN" + if override := os.Getenv(envName); override != "" { + return override, nil + } + local := filepath.Join(runtimeRoot, ".local", "bin", name) + if info, err := os.Stat(local); err == nil && !info.IsDir() { + return local, nil + } + if found, err := exec.LookPath(name); err == nil { + return found, nil + } + return "", fmt.Errorf("Missing %s. Run mise run setup.", name) +} diff --git a/go/internal/toolpath/toolpath_test.go b/go/internal/toolpath/toolpath_test.go new file mode 100644 index 0000000..f5af369 --- /dev/null +++ b/go/internal/toolpath/toolpath_test.go @@ -0,0 +1,58 @@ +package toolpath + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFind_honorsEnvOverrideFirst(t *testing.T) { + t.Setenv("SUM_HERDR_BIN", "/somewhere/herdr") + got, err := Find(t.TempDir(), "herdr") + if err != nil { + t.Fatalf("find: %v", err) + } + if got != "/somewhere/herdr" { + t.Fatalf("got %q, want the env override", got) + } +} + +func TestFind_honorsHyphenatedNameEnvVar(t *testing.T) { + t.Setenv("SUM_QUOTA_AXI_BIN", "/somewhere/quota-axi") + got, err := Find(t.TempDir(), "quota-axi") + if err != nil { + t.Fatalf("find: %v", err) + } + if got != "/somewhere/quota-axi" { + t.Fatalf("got %q, want the env override", got) + } +} + +func TestFind_prefersPinnedLocalOverPath(t *testing.T) { + root := t.TempDir() + local := filepath.Join(root, ".local", "bin", "widget") + if err := os.MkdirAll(filepath.Dir(local), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(local, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Find(root, "widget") + if err != nil { + t.Fatalf("find: %v", err) + } + if got != local { + t.Fatalf("got %q, want the pinned local %q", got, local) + } +} + +func TestFind_errorsWhenNowhereToBeFound(t *testing.T) { + _, err := Find(t.TempDir(), "definitely-not-a-real-tool-xyz") + if err == nil { + t.Fatal("expected an error for a missing tool") + } + want := "Missing definitely-not-a-real-tool-xyz. Run mise run setup." + if err.Error() != want { + t.Fatalf("err = %q, want %q", err.Error(), want) + } +}