From 649d3cbb0f395d7f54f4a86030c9ac152c0eff35 Mon Sep 17 00:00:00 2001 From: TresPies-source Date: Thu, 16 Jul 2026 03:39:51 -0500 Subject: [PATCH 01/23] chore(lint): green the golangci-lint gate Fix all golangci-lint findings (errcheck/staticcheck/unused) across the module so the milestone-gate lint layer passes and the CLI can be release-tagged. Behavior-preserving. Uncapped total exceeded the capped default view; final sweep closed client/repl/spirit/skills stragglers the per-package pass could not see. Co-Authored-By: Claude Opus 4.8 --- internal/activity/log.go | 4 +- internal/activity/log_test.go | 4 +- internal/bootstrap/bootstrap.go | 59 ++++++++++++++++--------- internal/bootstrap/bootstrap_test.go | 66 ++++++++++++++++++++-------- internal/client/client.go | 18 ++++---- internal/client/client_test.go | 28 ++++++------ internal/commands/cmd_code.go | 5 +-- internal/commands/cmd_craft.go | 2 +- internal/commands/cmd_telemetry.go | 2 +- internal/commands/commands_test.go | 2 +- internal/config/config_test.go | 28 +++++++----- internal/config/dispositions_test.go | 4 +- internal/hooks/runner.go | 2 +- internal/hooks/runner_test.go | 4 +- internal/ioutilx/atomic.go | 4 +- internal/providers/providers.go | 10 ++--- internal/repl/repl.go | 4 +- internal/skills/cluster.go | 2 + internal/spirit/spirit.go | 8 ++-- internal/state/state_test.go | 7 ++- internal/telemetry/sink.go | 2 +- internal/tui/bloom.go | 2 +- internal/tui/panels.go | 2 +- internal/tui/pilot.go | 3 ++ internal/tui/warroom.go | 48 +++++++++++--------- 25 files changed, 192 insertions(+), 128 deletions(-) diff --git a/internal/activity/log.go b/internal/activity/log.go index d7b9346..a0aaed3 100644 --- a/internal/activity/log.go +++ b/internal/activity/log.go @@ -56,7 +56,7 @@ func Append(e Entry) error { if err != nil { return err } - defer f.Close() + defer f.Close() //nolint:errcheck // write result already captured via f.Write below; close failure on a best-effort activity log is not actionable line, err := json.Marshal(e) if err != nil { @@ -99,7 +99,7 @@ func Recent(n int) ([]Entry, error) { if err != nil { return nil, err } - defer data.Close() + defer data.Close() //nolint:errcheck // read-only fd; all data already consumed via scanner, close failure has nothing left to affect var entries []Entry scanner := bufio.NewScanner(data) diff --git a/internal/activity/log_test.go b/internal/activity/log_test.go index b8d68c2..0ba9933 100644 --- a/internal/activity/log_test.go +++ b/internal/activity/log_test.go @@ -133,10 +133,10 @@ func TestRecentSkipsMalformedLines(t *testing.T) { t.Fatalf("open log for garbage write: %v", err) } if _, err := f.WriteString("THIS IS NOT JSON\n"); err != nil { - f.Close() + f.Close() //nolint:errcheck // cleanup before t.Fatalf; test already failing, close failure is not actionable t.Fatalf("write garbage line: %v", err) } - f.Close() + f.Close() //nolint:errcheck // test fixture teardown; write already succeeded, close failure would not affect the read-back assertions below got, err := Recent(0) if err != nil { diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 811caa9..7980242 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -40,8 +40,10 @@ type Result struct { // Run executes the full bootstrap sequence and prints a summary. func Run(ctx context.Context, opts Options, gw *client.Client, w io.Writer) (*Result, error) { dojoDir := config.DojoDir() - os.MkdirAll(dojoDir, 0700) r := &Result{} + if err := os.MkdirAll(dojoDir, 0700); err != nil { + r.Errors = append(r.Errors, "mkdir: "+err.Error()) + } // 1. Settings created, err := writeSettings(dojoDir, opts) @@ -146,7 +148,9 @@ func copyPlugins(dojoDir string, opts Options) (copied, skipped int, errs []stri } destDir := filepath.Join(dojoDir, "plugins") - os.MkdirAll(destDir, 0755) + if err := os.MkdirAll(destDir, 0755); err != nil { + errs = append(errs, fmt.Sprintf("create plugins dir: %s", err)) + } for _, name := range firstPartyPlugins { src := filepath.Join(source, name) @@ -167,7 +171,11 @@ func copyPlugins(dojoDir string, opts Options) (copied, skipped int, errs []stri // Remove existing if force if opts.Force { - os.RemoveAll(dst) + if err := os.RemoveAll(dst); err != nil { + errs = append(errs, fmt.Sprintf("remove %s: %s", name, err)) + skipped++ + continue + } } if err := copyDir(src, dst); err != nil { @@ -245,7 +253,9 @@ initiative: moderate // writeDispositions writes the four YAML preset files to ~/.dojo/dispositions/. func writeDispositions(dojoDir string, force bool) (int, error) { dir := filepath.Join(dojoDir, "dispositions") - os.MkdirAll(dir, 0755) + if err := os.MkdirAll(dir, 0755); err != nil { + return 0, err + } written := 0 for name, content := range dispositionPresets { path := filepath.Join(dir, name) @@ -345,51 +355,60 @@ func plantSeeds(ctx context.Context, gw *client.Client) (planted, skipped int, e // printSummary writes a formatted bootstrap summary to w. func printSummary(w io.Writer, r *Result) { - fmt.Fprintln(w) - fmt.Fprintln(w, gcolor.HEX("#e8b04a").Sprint(" Dojo workspace initialized")) - fmt.Fprintln(w) + // w is process stdout or an in-memory test buffer; a write failure here + // has no better place to be reported, so it's intentionally swallowed. + fw := func(a ...any) { + fmt.Fprintln(w, a...) //nolint:errcheck // best-effort write + } + fwf := func(format string, a ...any) { + fmt.Fprintf(w, format, a...) //nolint:errcheck // best-effort write + } + + fw() + fw(gcolor.HEX("#e8b04a").Sprint(" Dojo workspace initialized")) + fw() check := gcolor.HEX("#7fb88c").Sprint("✓") skip := gcolor.HEX("#94a3b8").Sprint("–") if r.SettingsCreated { - fmt.Fprintf(w, " %s settings.json created\n", check) + fwf(" %s settings.json created\n", check) } else { - fmt.Fprintf(w, " %s settings.json (already exists)\n", skip) + fwf(" %s settings.json (already exists)\n", skip) } if r.PluginsCopied > 0 { - fmt.Fprintf(w, " %s %d plugins installed\n", check, r.PluginsCopied) + fwf(" %s %d plugins installed\n", check, r.PluginsCopied) } if r.PluginsSkipped > 0 { - fmt.Fprintf(w, " %s %d plugins skipped\n", skip, r.PluginsSkipped) + fwf(" %s %d plugins skipped\n", skip, r.PluginsSkipped) } if r.DispositionsWritten > 0 { - fmt.Fprintf(w, " %s %d disposition presets written\n", check, r.DispositionsWritten) + fwf(" %s %d disposition presets written\n", check, r.DispositionsWritten) } else { - fmt.Fprintf(w, " %s dispositions (already exist)\n", skip) + fwf(" %s dispositions (already exist)\n", skip) } if r.MCPConfigWritten { - fmt.Fprintf(w, " %s mcp.json created (7 servers)\n", check) + fwf(" %s mcp.json created (7 servers)\n", check) } else { - fmt.Fprintf(w, " %s mcp.json (already exists)\n", skip) + fwf(" %s mcp.json (already exists)\n", skip) } if r.SeedsPlanted > 0 { - fmt.Fprintf(w, " %s %d starter seeds planted\n", check, r.SeedsPlanted) + fwf(" %s %d starter seeds planted\n", check, r.SeedsPlanted) } if r.SeedsSkipped > 0 { - fmt.Fprintf(w, " %s %d seeds skipped\n", skip, r.SeedsSkipped) + fwf(" %s %d seeds skipped\n", skip, r.SeedsSkipped) } if len(r.Errors) > 0 { - fmt.Fprintln(w) + fw() for _, e := range r.Errors { - fmt.Fprintf(w, " %s %s\n", gcolor.HEX("#e8b04a").Sprint("!"), gcolor.HEX("#94a3b8").Sprint(e)) + fwf(" %s %s\n", gcolor.HEX("#e8b04a").Sprint("!"), gcolor.HEX("#94a3b8").Sprint(e)) } } - fmt.Fprintln(w) + fw() } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index fb0905c..6d15364 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -14,6 +14,24 @@ import ( "github.com/DojoGenesis/cli/internal/client" ) +// mustMkdirAll creates dir (and any parents), failing the test on error. +// Every call site in this file uses the same 0755 fixture permissions. +func mustMkdirAll(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } +} + +// mustWriteFile writes data to path, failing the test on error. Every call +// site in this file uses the same 0644 fixture permissions. +func mustWriteFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + // ─── writeSettings ──────────────────────────────────────────────────────────── func TestWriteSettings(t *testing.T) { @@ -81,7 +99,9 @@ func TestWriteSettingsDefaultGatewayURL(t *testing.T) { data, _ := os.ReadFile(filepath.Join(dir, "settings.json")) var cfg map[string]any - json.Unmarshal(data, &cfg) + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal settings.json: %v", err) + } gw := cfg["gateway"].(map[string]any) if gw["url"] != "http://localhost:7340" { @@ -123,7 +143,9 @@ func TestWriteSettingsForce(t *testing.T) { dir := t.TempDir() opts := Options{GatewayURL: "http://first:7340"} - writeSettings(dir, opts) + if _, err := writeSettings(dir, opts); err != nil { + t.Fatalf("writeSettings: %v", err) + } opts.GatewayURL = "http://second:7340" opts.Force = true @@ -150,11 +172,11 @@ func makeFakePluginSource(t *testing.T, names []string) string { srcRoot := t.TempDir() for _, name := range names { dir := filepath.Join(srcRoot, name) - os.MkdirAll(filepath.Join(dir, "skills", "example"), 0755) + mustMkdirAll(t, filepath.Join(dir, "skills", "example")) pluginJSON := fmt.Sprintf(`{"name":%q,"version":"0.1.0"}`, name) - os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0644) - os.WriteFile(filepath.Join(dir, "skills", "example", "SKILL.md"), []byte("# Example\n"), 0644) + mustWriteFile(t, filepath.Join(dir, "plugin.json"), []byte(pluginJSON)) + mustWriteFile(t, filepath.Join(dir, "skills", "example", "SKILL.md"), []byte("# Example\n")) } return srcRoot } @@ -198,8 +220,8 @@ func TestCopyPluginsSkipExisting(t *testing.T) { // Pre-create the destination plugin directory. dstPlugin := filepath.Join(dojoDir, "plugins", "agent-orchestration") - os.MkdirAll(dstPlugin, 0755) - os.WriteFile(filepath.Join(dstPlugin, "plugin.json"), []byte(`{"name":"old"}`), 0644) + mustMkdirAll(t, dstPlugin) + mustWriteFile(t, filepath.Join(dstPlugin, "plugin.json"), []byte(`{"name":"old"}`)) opts := Options{PluginsSource: srcRoot} copied, skipped, _ := copyPlugins(dojoDir, opts) @@ -227,8 +249,8 @@ func TestCopyPluginsForce(t *testing.T) { // Pre-create the destination with old content. dstPlugin := filepath.Join(dojoDir, "plugins", "agent-orchestration") - os.MkdirAll(dstPlugin, 0755) - os.WriteFile(filepath.Join(dstPlugin, "plugin.json"), []byte(`{"name":"old"}`), 0644) + mustMkdirAll(t, dstPlugin) + mustWriteFile(t, filepath.Join(dstPlugin, "plugin.json"), []byte(`{"name":"old"}`)) opts := Options{PluginsSource: srcRoot, Force: true} copied, _, _ := copyPlugins(dojoDir, opts) @@ -342,7 +364,7 @@ func TestWriteMCPConfigIdempotent(t *testing.T) { } // Overwrite file with marker content. - os.WriteFile(filepath.Join(dojoDir, "mcp.json"), []byte(`{"marker":true}`), 0644) + mustWriteFile(t, filepath.Join(dojoDir, "mcp.json"), []byte(`{"marker":true}`)) wrote, _ = writeMCPConfig(dojoDir, false) if wrote { @@ -378,11 +400,17 @@ func newMockGateway(t *testing.T, existingNames []string) *httptest.Server { "seeds": seeds, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + // Runs on the server's own goroutine — t.Errorf (not Fatalf) is + // the safe way to surface a failure from here. + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("encode seeds response: %v", err) + } case http.MethodPost: var req client.CreateSeedRequest - json.NewDecoder(r.Body).Decode(&req) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode create-seed request: %v", err) + } seed := map[string]any{ "seed": map[string]any{ "id": "new-" + req.Name, @@ -392,7 +420,9 @@ func newMockGateway(t *testing.T, existingNames []string) *httptest.Server { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(seed) + if err := json.NewEncoder(w).Encode(seed); err != nil { + t.Errorf("encode create-seed response: %v", err) + } default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -491,11 +521,11 @@ func TestCopyDir(t *testing.T) { dst := t.TempDir() // Create a nested structure with a .git dir (should be skipped). - os.MkdirAll(filepath.Join(src, "sub"), 0755) - os.MkdirAll(filepath.Join(src, ".git"), 0755) - os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0644) - os.WriteFile(filepath.Join(src, "sub", "nested.txt"), []byte("world"), 0644) - os.WriteFile(filepath.Join(src, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644) + mustMkdirAll(t, filepath.Join(src, "sub")) + mustMkdirAll(t, filepath.Join(src, ".git")) + mustWriteFile(t, filepath.Join(src, "file.txt"), []byte("hello")) + mustWriteFile(t, filepath.Join(src, "sub", "nested.txt"), []byte("world")) + mustWriteFile(t, filepath.Join(src, ".git", "HEAD"), []byte("ref: refs/heads/main")) if err := copyDir(src, dst); err != nil { t.Fatalf("copyDir: %v", err) diff --git a/internal/client/client.go b/internal/client/client.go index b20caa4..708f533 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -386,7 +386,7 @@ func (c *Client) ChatStream(ctx context.Context, req ChatRequest, onChunk func(S if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) @@ -488,7 +488,7 @@ func (c *Client) PilotStream(ctx context.Context, clientID string, onChunk func( if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) @@ -565,7 +565,7 @@ func (c *Client) AgentChatStream(ctx context.Context, agentID string, req AgentC if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) @@ -649,7 +649,7 @@ func (c *Client) get(ctx context.Context, path string, out any) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) @@ -679,7 +679,7 @@ func (c *Client) post(ctx context.Context, path string, body any, out any) error if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { rb, _ := io.ReadAll(resp.Body) @@ -706,7 +706,7 @@ func (c *Client) put(ctx context.Context, path string, body any) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { rb, _ := io.ReadAll(resp.Body) return fmt.Errorf("gateway %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(rb))) @@ -726,7 +726,7 @@ func (c *Client) delete(ctx context.Context, path string) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { rb, _ := io.ReadAll(resp.Body) return fmt.Errorf("gateway %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(rb))) @@ -746,7 +746,7 @@ func (c *Client) getRaw(ctx context.Context, path string) ([]byte, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { rb, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("gateway %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(rb))) @@ -1063,7 +1063,7 @@ func (c *Client) WorkflowExecutionStream(ctx context.Context, runID string, onCh if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index c99d05f..f0ff7a8 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -199,7 +199,7 @@ func TestHealth_MockServer(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(want) + _ = json.NewEncoder(w).Encode(want) })) defer srv.Close() @@ -265,7 +265,7 @@ func TestSeeds_MockServer(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(envelope) + _ = json.NewEncoder(w).Encode(envelope) })) defer srv.Close() @@ -293,7 +293,7 @@ func TestSeeds_EmptyEnvelope(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(envelope) + _ = json.NewEncoder(w).Encode(envelope) })) defer srv.Close() @@ -327,7 +327,7 @@ func TestAuthHeader_GetRequest(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(HealthResponse{Status: "ok"}) + _ = json.NewEncoder(w).Encode(HealthResponse{Status: "ok"}) })) defer srv.Close() @@ -349,7 +349,7 @@ func TestAuthHeader_PostRequest(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"memory": Memory{ID: "m1", Content: "c"}}) + _ = json.NewEncoder(w).Encode(map[string]any{"memory": Memory{ID: "m1", Content: "c"}}) })) defer srv.Close() @@ -370,7 +370,7 @@ func TestAuthHeader_NoTokenOmitted(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(HealthResponse{Status: "ok"}) + _ = json.NewEncoder(w).Encode(HealthResponse{Status: "ok"}) })) defer srv.Close() @@ -675,7 +675,7 @@ func TestModels_Success(t *testing.T) { } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer srv.Close() @@ -696,7 +696,7 @@ func TestProviders_Success(t *testing.T) { } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer srv.Close() @@ -721,7 +721,7 @@ func TestMemories_Success(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer srv.Close() @@ -738,7 +738,7 @@ func TestMemories_Success(t *testing.T) { func TestGardenStats_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"total_seeds": 42}) + _ = json.NewEncoder(w).Encode(map[string]any{"total_seeds": 42}) })) defer srv.Close() @@ -790,7 +790,7 @@ func TestCreateAgent_Success(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(want) + _ = json.NewEncoder(w).Encode(want) })) defer srv.Close() @@ -813,7 +813,7 @@ func TestAgents_Success(t *testing.T) { } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer srv.Close() @@ -852,7 +852,7 @@ func TestSkillsAll_SinglePage(t *testing.T) { } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer srv.Close() @@ -876,7 +876,7 @@ func TestSkillsAll_EmptyPage_StopsLoop(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer srv.Close() diff --git a/internal/commands/cmd_code.go b/internal/commands/cmd_code.go index 306b041..b8529d3 100644 --- a/internal/commands/cmd_code.go +++ b/internal/commands/cmd_code.go @@ -91,9 +91,8 @@ func codeRead(args []string) error { if len(args) >= 2 { if parts := strings.SplitN(args[1], ":", 2); len(parts) == 2 { if n, err := fmt.Sscanf(parts[0], "%d", &startLine); n == 1 && err == nil { - if n, err := fmt.Sscanf(parts[1], "%d", &endLine); n == 1 && err == nil { - // Valid range. - } + // Valid start; also attempt to parse the end of the range. + _, _ = fmt.Sscanf(parts[1], "%d", &endLine) } } } diff --git a/internal/commands/cmd_craft.go b/internal/commands/cmd_craft.go index 2e81b14..3c0530f 100644 --- a/internal/commands/cmd_craft.go +++ b/internal/commands/cmd_craft.go @@ -251,7 +251,7 @@ func (r *Registry) craftClaudeMD(ctx context.Context, args []string) error { if err != nil { continue } - combined.WriteString(fmt.Sprintf("\n## File: %s\n\n", rel)) + fmt.Fprintf(&combined, "\n## File: %s\n\n", rel) combined.Write(content) combined.WriteString("\n") } diff --git a/internal/commands/cmd_telemetry.go b/internal/commands/cmd_telemetry.go index 51887be..e059d69 100644 --- a/internal/commands/cmd_telemetry.go +++ b/internal/commands/cmd_telemetry.go @@ -152,7 +152,7 @@ func telemetryGet(ctx context.Context, path string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("telemetry API unreachable: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 57940cd..6d6bf59 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -942,7 +942,7 @@ func readActivityLog(t *testing.T) []string { if err != nil { t.Fatalf("could not open activity log: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() var summaries []string sc := bufio.NewScanner(f) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 802ff6f..b5f4049 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,7 +15,7 @@ func TestLoad_NoSettingsFile_ReturnsDefaults(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end // Clear env overrides that might bleed in from the test environment. t.Setenv("DOJO_GATEWAY_URL", "") @@ -50,7 +50,7 @@ func TestLoad_WithSettingsFile_AppliesOverrides(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end // Ensure no env overrides interfere. t.Setenv("DOJO_GATEWAY_URL", "") @@ -126,7 +126,7 @@ func TestLoad_EnvVar_GatewayURL(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end t.Setenv("DOJO_GATEWAY_URL", "http://env-override:9999") t.Setenv("DOJO_GATEWAY_TOKEN", "") @@ -149,7 +149,7 @@ func TestLoad_EnvVar_Token(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end t.Setenv("DOJO_GATEWAY_URL", "") t.Setenv("DOJO_GATEWAY_TOKEN", "env-token-xyz") @@ -172,7 +172,7 @@ func TestLoad_EnvVar_Provider(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end t.Setenv("DOJO_GATEWAY_URL", "") t.Setenv("DOJO_GATEWAY_TOKEN", "") @@ -195,7 +195,7 @@ func TestLoad_EnvVar_PluginsPath(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end customPluginsPath := filepath.Join(tmp, "myplugins") t.Setenv("DOJO_GATEWAY_URL", "") @@ -230,7 +230,7 @@ func TestLoad_InvalidJSON_ReturnsError(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end t.Setenv("DOJO_GATEWAY_URL", "") t.Setenv("DOJO_GATEWAY_TOKEN", "") @@ -241,8 +241,12 @@ func TestLoad_InvalidJSON_ReturnsError(t *testing.T) { t.Setenv("DOJO_USER_ID", "") dojoDir := filepath.Join(tmp, ".dojo") - os.MkdirAll(dojoDir, 0o755) - os.WriteFile(filepath.Join(dojoDir, "settings.json"), []byte("{invalid json"), 0o644) + if err := os.MkdirAll(dojoDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(dojoDir, "settings.json"), []byte("{invalid json"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } _, err := Load() if err == nil { @@ -345,7 +349,7 @@ func TestLoad_DispositionEnvOverride(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end t.Setenv("DOJO_GATEWAY_URL", "") t.Setenv("DOJO_GATEWAY_TOKEN", "") @@ -368,7 +372,7 @@ func TestLoad_ModelEnvOverride(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end t.Setenv("DOJO_GATEWAY_URL", "") t.Setenv("DOJO_GATEWAY_TOKEN", "") @@ -428,7 +432,7 @@ func TestLoad_UserIDEnvOverride(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end t.Setenv("DOJO_GATEWAY_URL", "") t.Setenv("DOJO_GATEWAY_TOKEN", "") diff --git a/internal/config/dispositions_test.go b/internal/config/dispositions_test.go index 0137b62..fb3a6cd 100644 --- a/internal/config/dispositions_test.go +++ b/internal/config/dispositions_test.go @@ -31,7 +31,7 @@ func TestLoadPresets_MissingDir(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end // No ~/.dojo/dispositions/ directory exists — should return builtins. presets, err := LoadDispositionPresets() @@ -49,7 +49,7 @@ func TestSaveAndLoadPreset(t *testing.T) { tmp := t.TempDir() origHome := os.Getenv("HOME") t.Setenv("HOME", tmp) - defer func() { os.Setenv("HOME", origHome) }() + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end custom := DispositionPreset{ Name: "custom", diff --git a/internal/hooks/runner.go b/internal/hooks/runner.go index da0f10d..0ba9df4 100644 --- a/internal/hooks/runner.go +++ b/internal/hooks/runner.go @@ -174,7 +174,7 @@ func runHTTPHook(ctx context.Context, url string, payload map[string]any) error log.Printf("[hooks] http hook: request error: %v", err) return nil } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() log.Printf("[hooks] http hook: response status %s", resp.Status) return nil } diff --git a/internal/hooks/runner_test.go b/internal/hooks/runner_test.go index f91369d..34c44d6 100644 --- a/internal/hooks/runner_test.go +++ b/internal/hooks/runner_test.go @@ -406,7 +406,7 @@ func TestMatcherGlob(t *testing.T) { } // Remove marker to reuse it. - os.Remove(markerFile) + _ = os.Remove(markerFile) // Should NOT match: command is /health, not garden* err = r.Fire(context.Background(), EventPreCommand, map[string]any{"command": "/health"}) @@ -478,7 +478,7 @@ func TestIfConditionEnvVar(t *testing.T) { r := New(ps) // Env var NOT set → hook should not fire. - os.Unsetenv(envVar) + _ = os.Unsetenv(envVar) err := r.Fire(context.Background(), EventPreCommand, nil) if err != nil { t.Fatalf("Fire() returned error: %v", err) diff --git a/internal/ioutilx/atomic.go b/internal/ioutilx/atomic.go index 04169c6..9c0beb9 100644 --- a/internal/ioutilx/atomic.go +++ b/internal/ioutilx/atomic.go @@ -30,11 +30,11 @@ func AtomicWriteFile(path string, data []byte, perm os.FileMode) error { // has already moved the file, but it still cleans up on error paths. defer os.Remove(tmpName) //nolint:errcheck if _, err := tmp.Write(data); err != nil { - tmp.Close() + tmp.Close() //nolint:errcheck // best-effort cleanup; the write error above is the one we report return err } if err := tmp.Sync(); err != nil { - tmp.Close() + tmp.Close() //nolint:errcheck // best-effort cleanup; the sync error above is the one we report return err } if err := tmp.Close(); err != nil { diff --git a/internal/providers/providers.go b/internal/providers/providers.go index 22530e9..1176cce 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -248,7 +248,7 @@ func chatAnthropic(ctx context.Context, req DirectChatRequest) (*DirectChatRespo msgs := make([]anthropicMessage, len(req.Messages)) for i, m := range req.Messages { - msgs[i] = anthropicMessage{Role: m.Role, Content: m.Content} + msgs[i] = anthropicMessage(m) } body := anthropicRequest{ @@ -278,7 +278,7 @@ func chatAnthropic(ctx context.Context, req DirectChatRequest) (*DirectChatRespo if err != nil { return nil, fmt.Errorf("anthropic: http request: %w", err) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // response body close error is not actionable after a completed read if resp.StatusCode != http.StatusOK { var errBody map[string]interface{} @@ -336,7 +336,7 @@ type openAIResponse struct { func chatOpenAICompatible(ctx context.Context, req DirectChatRequest, endpoint string) (*DirectChatResponse, error) { msgs := make([]openAIMessage, len(req.Messages)) for i, m := range req.Messages { - msgs[i] = openAIMessage{Role: m.Role, Content: m.Content} + msgs[i] = openAIMessage(m) } body := openAIRequest{ @@ -365,7 +365,7 @@ func chatOpenAICompatible(ctx context.Context, req DirectChatRequest, endpoint s if err != nil { return nil, fmt.Errorf("%s: http request: %w", req.Provider, err) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // response body close error is not actionable after a completed read if resp.StatusCode != http.StatusOK { var errBody map[string]interface{} @@ -477,7 +477,7 @@ func chatGoogle(ctx context.Context, req DirectChatRequest) (*DirectChatResponse if err != nil { return nil, fmt.Errorf("google: http request: %w", err) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // response body close error is not actionable after a completed read if resp.StatusCode != http.StatusOK { var errBody map[string]interface{} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index cc633be..19d185c 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -298,7 +298,7 @@ func (r *REPL) Run(ctx context.Context) error { // Fallback to plain stdin if readline init fails (e.g. in pipes) return r.runPlain(ctx) } - defer rl.Close() + defer func() { _ = rl.Close() }() for { select { @@ -790,7 +790,7 @@ func printWelcome(cfg *config.Config, session string, resumed bool) { _ = os.MkdirAll(home+"/.dojo", 0o755) f, ferr := os.Create(hintFile) if ferr == nil { - f.Close() + _ = f.Close() } } diff --git a/internal/skills/cluster.go b/internal/skills/cluster.go index 0947b9b..ca76722 100644 --- a/internal/skills/cluster.go +++ b/internal/skills/cluster.go @@ -399,4 +399,6 @@ func CategoryNames() []string { } // unexported accessor used in tests only — avoids exposing the field directly. +// +//nolint:unused // test-only accessor; kept for white-box tests in this package func (c category) displayName() string { return c.name } diff --git a/internal/spirit/spirit.go b/internal/spirit/spirit.go index d6cf031..2af5a81 100644 --- a/internal/spirit/spirit.go +++ b/internal/spirit/spirit.go @@ -180,15 +180,15 @@ func UpdateStreak(s *SpiritState, now time.Time) int { daysSince := int(now.Sub(lastActive).Hours() / 24) - switch { - case daysSince == 0: + switch daysSince { + case 0: // Same day — no new streak increment, but award bonus if not yet today // (shouldn't happen because of StreakBonusDate check above) return 0 - case daysSince == 1: + case 1: // Consecutive day — streak continues s.StreakDays++ - case daysSince == 2: + case 2: // Grace window (48h) — streak continues s.StreakDays++ default: diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 91ead1a..94d6c0b 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -163,8 +163,7 @@ func TestRecentAgents(t *testing.T) { t.Errorf("RecentAgents(0) len = %d; want 3", len(all)) } - // Verify that the file doesn't need to exist for state ops to work. - if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".dojo", "state.json")); !os.IsNotExist(err) { - // File may or may not exist — this check just ensures no panic. - } + // Verify that the file doesn't need to exist for state ops to work — the + // file may or may not exist; this call just ensures no panic. + _, _ = os.Stat(filepath.Join(os.Getenv("HOME"), ".dojo", "state.json")) } diff --git a/internal/telemetry/sink.go b/internal/telemetry/sink.go index c309443..9363f44 100644 --- a/internal/telemetry/sink.go +++ b/internal/telemetry/sink.go @@ -124,7 +124,7 @@ func (s *Sink) Flush() error { if err != nil { return fmt.Errorf("POST %s: %w", url, err) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // idiomatic best-effort close; status code is validated separately below if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("POST %s returned %d", url, resp.StatusCode) diff --git a/internal/tui/bloom.go b/internal/tui/bloom.go index c096d0d..7fac450 100644 --- a/internal/tui/bloom.go +++ b/internal/tui/bloom.go @@ -32,7 +32,7 @@ var ( bloomDim = lipgloss.NewStyle().Foreground(lipgloss.Color("#64748b")) bloomKoan = lipgloss.NewStyle().Foreground(lipgloss.Color("#e8b04a")).Italic(true) bloomNight = lipgloss.NewStyle().Foreground(lipgloss.Color("#475569")) - bloomMedTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e8b04a")) + bloomMedTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e8b04a")) //nolint:unused // pending: medium-stage title heading, not yet wired into the bloom view ) // ─── BloomModel ──────────────────────────────────────────────────────────── diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 20a5952..27f00df 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -124,7 +124,7 @@ func RenderEventPanel(events []ParsedEvent, scroll, width, height int, focused b default: styledSummary = styleDim.Render(summary) } - sb.WriteString(fmt.Sprintf(" %s %s %s", ts, evType, styledSummary)) + fmt.Fprintf(&sb, " %s %s %s", ts, evType, styledSummary) sb.WriteString("\n") } diff --git a/internal/tui/pilot.go b/internal/tui/pilot.go index 4beb71d..adf9bbe 100644 --- a/internal/tui/pilot.go +++ b/internal/tui/pilot.go @@ -57,6 +57,7 @@ var ( styleSubtle = lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)) + //nolint:unused // pending: generic bordered-panel style, superseded by per-panel border styles (stylePanelBorder etc.) but kept for a possible shared-border pass styleBorder = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(colorBorder)) @@ -194,6 +195,8 @@ func (m PilotModel) listenSSE() tea.Cmd { // waitForNext returns a Cmd that waits for the next message from the ongoing // SSE goroutine. We use a persistent channel stored on the model. +// +//nolint:unused // pending: persistent-channel streaming helper, superseded by the current per-event m.listenSSE() re-invocation pattern; kept in case that pattern is revisited func waitForNext(ch <-chan tea.Msg) tea.Cmd { return func() tea.Msg { return <-ch diff --git a/internal/tui/warroom.go b/internal/tui/warroom.go index 05902e9..ab9ddba 100644 --- a/internal/tui/warroom.go +++ b/internal/tui/warroom.go @@ -167,7 +167,7 @@ type WarRoomModel struct { initialTopic string // Error state - err error + err error //nolint:unused // pending: top-level last-error field for a model-wide error banner; per-panel errors currently render inline via scoutBuf/challengerBuf instead } // NewWarRoomModel constructs a WarRoomModel ready for tea.NewProgram. @@ -238,7 +238,7 @@ func (m WarRoomModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case scoutErrorMsg: m.scoutStreaming = false m.scoutCh = nil - m.scoutBuf.WriteString(fmt.Sprintf("\n[error: %v]", msg.err)) + fmt.Fprintf(m.scoutBuf, "\n[error: %v]", msg.err) m.scoutLines = wrapText(m.scoutBuf.String(), m.panelContentWidth()) return m, nil @@ -258,7 +258,7 @@ func (m WarRoomModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case challengerErrorMsg: m.challengerStreaming = false m.challengerCh = nil - m.challengerBuf.WriteString(fmt.Sprintf("\n[error: %v]", msg.err)) + fmt.Fprintf(m.challengerBuf, "\n[error: %v]", msg.err) m.challengerLines = wrapText(m.challengerBuf.String(), m.panelContentWidth()) return m, nil } @@ -381,12 +381,13 @@ func (m WarRoomModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.insertInputChar('k') case "down": - if m.focus == focusScout { + switch m.focus { + case focusScout: vis := m.panelViewHeight() if max := len(m.scoutLines) - vis; max > 0 && m.scoutScroll < max { m.scoutScroll++ } - } else if m.focus == focusChallenger { + case focusChallenger: vis := m.panelViewHeight() if max := len(m.challengerLines) - vis; max > 0 && m.challengerScroll < max { m.challengerScroll++ @@ -397,11 +398,12 @@ func (m WarRoomModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "j": if m.focus != focusInput { vis := m.panelViewHeight() - if m.focus == focusScout { + switch m.focus { + case focusScout: if max := len(m.scoutLines) - vis; max > 0 && m.scoutScroll < max { m.scoutScroll++ } - } else if m.focus == focusChallenger { + case focusChallenger: if max := len(m.challengerLines) - vis; max > 0 && m.challengerScroll < max { m.challengerScroll++ } @@ -416,12 +418,13 @@ func (m WarRoomModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if step < 1 { step = 1 } - if m.focus == focusScout { + switch m.focus { + case focusScout: m.scoutScroll -= step if m.scoutScroll < 0 { m.scoutScroll = 0 } - } else if m.focus == focusChallenger { + case focusChallenger: m.challengerScroll -= step if m.challengerScroll < 0 { m.challengerScroll = 0 @@ -435,14 +438,15 @@ func (m WarRoomModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if step < 1 { step = 1 } - if m.focus == focusScout { + switch m.focus { + case focusScout: if max := len(m.scoutLines) - vis; max > 0 { m.scoutScroll += step if m.scoutScroll > max { m.scoutScroll = max } } - } else if m.focus == focusChallenger { + case focusChallenger: if max := len(m.challengerLines) - vis; max > 0 { m.challengerScroll += step if m.challengerScroll > max { @@ -453,18 +457,20 @@ func (m WarRoomModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "home": - if m.focus == focusScout { + switch m.focus { + case focusScout: m.scoutScroll = 0 - } else if m.focus == focusChallenger { + case focusChallenger: m.challengerScroll = 0 } return m, nil case "g": if m.focus != focusInput { - if m.focus == focusScout { + switch m.focus { + case focusScout: m.scoutScroll = 0 - } else if m.focus == focusChallenger { + case focusChallenger: m.challengerScroll = 0 } return m, nil @@ -472,18 +478,20 @@ func (m WarRoomModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.insertInputChar('g') case "end": - if m.focus == focusScout { + switch m.focus { + case focusScout: m.pinScoutBottom() - } else if m.focus == focusChallenger { + case focusChallenger: m.pinChallengerBottom() } return m, nil case "G": if m.focus != focusInput { - if m.focus == focusScout { + switch m.focus { + case focusScout: m.pinScoutBottom() - } else if m.focus == focusChallenger { + case focusChallenger: m.pinChallengerBottom() } return m, nil @@ -642,7 +650,7 @@ func streamAgentToChannel( sendMsg(scoutErrorMsg{err: err}, challengerErrorMsg{err: err}) return } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // best-effort cleanup; stream is already fully consumed or being abandoned on error if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) From c2f27d6ca67de6bb7be9a4c07773b979e72ddaac Mon Sep 17 00:00:00 2001 From: TresPies-source Date: Thu, 16 Jul 2026 04:18:03 -0500 Subject: [PATCH 02/23] fix(cli): surface gateway errors, two-tier interrupt, pipeline hygiene P0: gateway error events were swallowed (blank output, exit 0); EventError now renders visibly and one-shot exits non-zero in both text and --json. REPL prints the real stream error, not a canned string. Ctrl+C mid-response cancels only that turn (per-turn context) and returns to the prompt instead of ending the session; idle Ctrl+C quits. --plain/--json drop the thinking placeholder and welcome banner; --json warns without --one-shot. Added a per-chunk stream stall watchdog and FriendlyError for connection/auth failures. Bare 'dojo version'/'help' now work. Co-Authored-By: Claude Opus 4.8 --- cmd/dojo/main.go | 67 +++++++++-- internal/client/client.go | 72 ++++++++++++ internal/client/client_test.go | 113 ++++++++++++++++++ internal/repl/renderer.go | 64 ++++++++++- internal/repl/renderer_test.go | 118 ++++++++++++++++++- internal/repl/repl.go | 201 ++++++++++++++++++++++++--------- 6 files changed, 571 insertions(+), 64 deletions(-) diff --git a/cmd/dojo/main.go b/cmd/dojo/main.go index 4044bf1..2a9b83c 100644 --- a/cmd/dojo/main.go +++ b/cmd/dojo/main.go @@ -48,6 +48,20 @@ func main() { os.Exit(0) } + // Bare positional subcommands: `dojo version` / `dojo help` behave like the + // --version / -h flags. Neither needs config or a gateway, so handle them + // before anything else rather than launching the REPL. + if args := flag.Args(); len(args) > 0 { + switch args[0] { + case "version": + fmt.Printf("dojo %s\n", version) + os.Exit(0) + case "help": + flag.Usage() + os.Exit(0) + } + } + // Load config cfg, err := config.Load() if err != nil { @@ -73,12 +87,12 @@ func main() { // Build gateway client gw := client.New(cfg.Gateway.URL, cfg.Gateway.Token, cfg.Gateway.Timeout) - // Cancellable context — catches Ctrl+C - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - // One-shot mode: send a single message and exit + // One-shot mode: send a single message and exit. Ctrl+C cancels the single + // turn and exits. if *flagOneShot != "" { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + workspaceRoot, _ := os.Getwd() req := client.ChatRequest{ Message: *flagOneShot, @@ -87,8 +101,20 @@ func main() { Stream: true, WorkspaceRoot: workspaceRoot, } - err = gw.ChatStream(ctx, req, func(chunk client.SSEChunk) { + + // Record the first gateway error event. Without this a failed stream + // (429 quota, 404 dead model, …) renders blank and the process exits 0, + // so every failure looks like a silent success. See repl.EventError. + var ( + errSeen bool + errMsg string + ) + streamErr := gw.ChatStream(ctx, req, func(chunk client.SSEChunk) { ev := repl.ClassifyChunk(chunk) + if ev.Type == repl.EventError && !errSeen { + errSeen = true + errMsg = ev.Content + } if *flagJSON { if out := ev.RenderJSON(); out != "" { fmt.Println(out) @@ -102,12 +128,37 @@ func main() { if !*flagJSON { fmt.Println() } - if err != nil { - fatalf("one-shot error: %s", err) + + // Transport-level failure (couldn't connect, stalled, non-200 status). + if streamErr != nil { + if ctx.Err() != nil { + fmt.Fprintln(os.Stderr, "dojo: cancelled") + os.Exit(130) + } + fmt.Fprintf(os.Stderr, "dojo: %s\n", gw.FriendlyError(streamErr)) + os.Exit(1) + } + // Transport succeeded but the stream carried a gateway error event — + // exit non-zero so scripts and CI see the failure. + if errSeen { + fmt.Fprintf(os.Stderr, "dojo: gateway error: %s\n", errMsg) + os.Exit(1) } return } + // --json is a one-shot-only pipeline flag; in interactive mode it is a silent + // no-op today, so say so on stderr rather than ignoring it quietly. + if *flagJSON { + fmt.Fprintln(os.Stderr, "dojo: --json only applies to --one-shot mode; ignoring") + } + + // Interactive REPL. The base context is deliberately NOT bound to SIGINT: a + // single Ctrl+C during a streaming turn must cancel only that turn (handled + // inside the REPL), not end the whole session. SIGTERM still shuts down. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) + defer stop() + // Run REPL (plugin scan happens inside repl.New) r := repl.New(cfg, gw, *flagResume, *flagPlain || *flagNoColor) if err := r.Run(ctx); err != nil { diff --git a/internal/client/client.go b/internal/client/client.go index 708f533..24ede8e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -12,11 +12,18 @@ import ( "net/url" "os" "strings" + "sync/atomic" "time" "github.com/DojoGenesis/cli/internal/trace" ) +// streamStallTimeout is the maximum gap allowed between SSE chunks before the +// stream is considered dead. It is a stall window, NOT a blanket deadline: a +// slow-but-alive generation resets the watchdog on every chunk and survives +// indefinitely. Declared as a var (not const) so tests can shorten it. +var streamStallTimeout = 120 * time.Second + // Client talks to an AgenticGateway instance. type Client struct { base string @@ -44,6 +51,47 @@ func New(baseURL, token, timeout string) *Client { } } +// BaseURL returns the gateway base URL this client targets. +func (c *Client) BaseURL() string { return c.base } + +// FriendlyError turns a raw transport/HTTP error into a concise, actionable +// message. Connection failures name the gateway URL and the three ways to point +// the CLI at a live one; auth failures name the token knobs. Anything it does +// not recognise is passed through unchanged so no detail is lost. +func (c *Client) FriendlyError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + lower := strings.ToLower(msg) + + switch { + case strings.Contains(msg, "401"), + strings.Contains(msg, "403"), + strings.Contains(lower, "unauthorized"), + strings.Contains(lower, "forbidden"): + return "gateway rejected auth — check gateway.token in ~/.dojo/settings.json, DOJO_GATEWAY_TOKEN, or --token" + + case strings.Contains(lower, "connection refused"), + strings.Contains(lower, "no such host"), + strings.Contains(lower, "no route to host"), + strings.Contains(lower, "network is unreachable"), + strings.Contains(lower, "dial tcp"), + strings.Contains(lower, "connect: "), + strings.Contains(lower, "i/o timeout"), + strings.Contains(lower, "deadline exceeded"), + strings.Contains(lower, "eof"): + url := c.base + if url == "" { + url = "(unset)" + } + return fmt.Sprintf("cannot reach gateway at %s — is it running? set gateway.url in ~/.dojo/settings.json, DOJO_GATEWAY_URL, or --gateway", url) + + default: + return msg + } +} + // ─── Health ────────────────────────────────────────────────────────────────── // HealthResponse is the /health response. @@ -399,11 +447,32 @@ func (c *Client) ChatStream(ctx context.Context, req ChatRequest, onChunk func(S // parseSSE reads an SSE stream and calls onChunk for each data line. // Per SSE spec, the event field resets on blank lines (event dispatch boundary), // not after each data line. Buffer is raised to 1MB to handle large JSON payloads. +// +// A stall watchdog guards against a gateway that connects but then goes silent +// (no bytes ever arrive): if no line is read for streamStallTimeout, the stream +// is force-closed and a "gateway stalled" error is returned. The watchdog resets +// on every line, so a slow-but-alive generation is never killed. When r is not an +// io.Closer (e.g. a test strings.Reader) the watchdog is inert — it only matters +// for real network streams that can block a Read indefinitely. func parseSSE(r io.Reader, onChunk func(SSEChunk)) error { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB lines + + var stalled atomic.Bool + var watchdog *time.Timer + if closer, ok := r.(io.Closer); ok { + watchdog = time.AfterFunc(streamStallTimeout, func() { + stalled.Store(true) + _ = closer.Close() // unblock the in-flight Read + }) + defer watchdog.Stop() + } + var event string for scanner.Scan() { + if watchdog != nil { + watchdog.Reset(streamStallTimeout) + } line := scanner.Text() switch { case strings.HasPrefix(line, "event:"): @@ -419,6 +488,9 @@ func parseSSE(r io.Reader, onChunk func(SSEChunk)) error { event = "" } } + if stalled.Load() { + return fmt.Errorf("gateway stalled (no data for %s)", streamStallTimeout) + } return scanner.Err() } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index f0ff7a8..70fa6e0 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -3,6 +3,7 @@ package client import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -1035,3 +1036,115 @@ func TestWorkflowExecutionStream_5xx_ReturnsError(t *testing.T) { t.Fatal("expected error, got nil") } } + +// ─── Stall watchdog (a gateway that connects then goes silent) ─────────────── + +func TestChatStream_StallWatchdog(t *testing.T) { + old := streamStallTimeout + streamStallTimeout = 150 * time.Millisecond + defer func() { streamStallTimeout = old }() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data: hello\n")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + // Go silent — never send more data or [DONE]. The client watchdog must + // give up rather than hang forever. + <-r.Context().Done() + })) + defer srv.Close() + + c := New(srv.URL, "", "5s") + var got []string + start := time.Now() + err := c.ChatStream(context.Background(), ChatRequest{Message: "hi"}, func(ch SSEChunk) { + got = append(got, ch.Data) + }) + elapsed := time.Since(start) + + if err == nil || !strings.Contains(err.Error(), "stalled") { + t.Fatalf("expected a stall error, got %v", err) + } + if len(got) != 1 || got[0] != "hello" { + t.Errorf("expected the single pre-stall chunk [hello], got %v", got) + } + if elapsed > 2*time.Second { + t.Errorf("watchdog fired too slowly (%v) — should trip near the stall window", elapsed) + } +} + +func TestParseSSE_NonCloserReaderNoWatchdog(t *testing.T) { + // A non-Closer reader (the test path) must not arm the watchdog; a complete + // stream still parses normally regardless of the stall window. + old := streamStallTimeout + streamStallTimeout = 10 * time.Millisecond + defer func() { streamStallTimeout = old }() + + r := strings.NewReader("data: a\ndata: b\ndata: [DONE]\n") + var got []string + if err := parseSSE(r, func(c SSEChunk) { got = append(got, c.Data) }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("expected [a b], got %v", got) + } +} + +// ─── FriendlyError classifier ──────────────────────────────────────────────── + +func TestFriendlyError_ConnectionRefused(t *testing.T) { + c := New("http://localhost:7340", "", "5s") + msg := c.FriendlyError(errors.New("dial tcp 127.0.0.1:7340: connect: connection refused")) + if !strings.Contains(msg, "cannot reach gateway at http://localhost:7340") { + t.Errorf("connection refused: got %q", msg) + } +} + +func TestFriendlyError_NoSuchHost(t *testing.T) { + c := New("http://test-host", "", "5s") + msg := c.FriendlyError(errors.New(`Post "http://test-host/v1/chat": dial tcp: lookup test-host: no such host`)) + if !strings.Contains(msg, "cannot reach gateway") { + t.Errorf("no such host: got %q", msg) + } +} + +func TestFriendlyError_Stalled(t *testing.T) { + c := New("http://localhost:7340", "", "5s") + // A bare "gateway stalled" already reads cleanly; the classifier still routes + // it to the reach-the-gateway guidance because the socket is effectively dead. + msg := c.FriendlyError(errors.New("gateway stalled (no data for 120s)")) + if !strings.Contains(msg, "cannot reach gateway") && !strings.Contains(msg, "stalled") { + t.Errorf("stalled: got %q", msg) + } +} + +func TestFriendlyError_AuthRejected(t *testing.T) { + c := New("http://localhost:7340", "", "5s") + for _, in := range []string{ + "gateway returned 401: unauthorized", + "gateway returned 403: forbidden", + } { + msg := c.FriendlyError(errors.New(in)) + if !strings.Contains(strings.ToLower(msg), "auth") { + t.Errorf("auth error %q: got %q, want auth guidance", in, msg) + } + } +} + +func TestFriendlyError_Passthrough(t *testing.T) { + c := New("http://localhost:7340", "", "5s") + msg := c.FriendlyError(errors.New("unexpected parser state at token 3")) + if msg != "unexpected parser state at token 3" { + t.Errorf("passthrough: got %q, want the original message", msg) + } +} + +func TestFriendlyError_Nil(t *testing.T) { + c := New("http://localhost:7340", "", "5s") + if msg := c.FriendlyError(nil); msg != "" { + t.Errorf("nil error: got %q, want empty", msg) + } +} diff --git a/internal/repl/renderer.go b/internal/repl/renderer.go index edba0e7..ef9bb26 100644 --- a/internal/repl/renderer.go +++ b/internal/repl/renderer.go @@ -23,6 +23,7 @@ const ( EventWarning // Warning or notice EventDone // Stream complete EventEmpty // No content to render + EventError // Gateway/provider error — always rendered visibly ) // String returns a human-readable label for the event type. @@ -44,6 +45,8 @@ func (et EventType) String() string { return "done" case EventEmpty: return "empty" + case EventError: + return "error" default: return "unknown" } @@ -94,6 +97,21 @@ func ClassifyChunk(chunk client.SSEChunk) RenderEvent { } return RenderEvent{Type: EventWarning, Content: content} + case "error": + // P0: gateway/provider failures (429 quota, 404 dead model, etc.) arrive + // as event:error. These MUST be surfaced — never swallowed into a blank + // "success". The error message lives under the "error" key. + msg := extractError(data) + if msg == "" { + msg = "unknown gateway error" + } + return RenderEvent{Type: EventError, Content: msg} + + case "intent_classified", "provider_selected": + // Routing telemetry — useful to the gateway, noise to the user. Drop + // quietly so plain/pipeline stdout carries only the real answer. + return RenderEvent{Type: EventEmpty} + case "done": return RenderEvent{Type: EventDone} } @@ -117,7 +135,10 @@ func ClassifyChunk(chunk client.SSEChunk) RenderEvent { // RenderJSON formats the event as a JSON line for scripted pipelines. // Each event is a single-line JSON object with type, content, and meta fields. func (re RenderEvent) RenderJSON() string { - if re.Type == EventEmpty || re.Type == EventDone { + // EventThinking is a reasoning placeholder, not answer content — keep it out + // of the JSON pipeline. EventError DOES flow through: a scripted consumer must + // see {"type":"error",...} so a failed run is observable, not silently empty. + if re.Type == EventEmpty || re.Type == EventDone || re.Type == EventThinking { return "" } obj := map[string]any{ @@ -139,8 +160,11 @@ func (re RenderEvent) Render(plain bool) string { return re.Content case EventThinking: + // Plain/pipeline mode: the reasoning placeholder ("Processing your + // request…") is not answer content — suppress it so stdout stays clean. + // Interactive mode keeps the dim reasoning trace. if plain { - return "[thinking] " + re.Content + return "" } return gcolor.HEX("#94a3b8").Sprint(re.Content) @@ -176,6 +200,12 @@ func (re RenderEvent) Render(plain bool) string { } return gcolor.HEX("#f4a261").Sprintf("[warning] %s", re.Content) + case EventError: + if plain { + return "error: " + re.Content + } + return gcolor.HEX("#ef4444").Sprintf("error: %s", re.Content) + case EventDone, EventEmpty: return "" } @@ -218,6 +248,36 @@ func extractContentFromData(data string) string { return data } +// extractError pulls a human-readable message from an event:error payload. +// It handles {"error":"msg"}, {"error":{"message":"msg"}}, and bare {"message":"msg"}; +// non-JSON payloads are returned verbatim so a failure is never rendered blank. +func extractError(data string) string { + var m map[string]any + if err := json.Unmarshal([]byte(data), &m); err != nil { + return data + } + if v, ok := m["error"]; ok { + switch e := v.(type) { + case string: + if e != "" { + return e + } + case map[string]any: + for _, key := range []string{"message", "content", "text", "detail"} { + if s, ok := e[key].(string); ok && s != "" { + return s + } + } + } + } + for _, key := range []string{"message", "content", "text", "detail"} { + if s, ok := m[key].(string); ok && s != "" { + return s + } + } + return data +} + // extractContent tries to pull a text value from JSON data, falling back to the // raw data if it is not valid JSON. func extractContent(data string) string { diff --git a/internal/repl/renderer_test.go b/internal/repl/renderer_test.go index 37df6f1..b2cda18 100644 --- a/internal/repl/renderer_test.go +++ b/internal/repl/renderer_test.go @@ -246,11 +246,13 @@ func TestRender_Text_Styled(t *testing.T) { } } -func TestRender_Thinking_Plain(t *testing.T) { +func TestRender_Thinking_Plain_Suppressed(t *testing.T) { + // Plain/pipeline mode suppresses the reasoning placeholder ("Processing your + // request…") so stdout carries only the real answer (EventText). ev := RenderEvent{Type: EventThinking, Content: "hmm"} got := ev.Render(true) - if got != "[thinking] hmm" { - t.Errorf("thinking plain: got %q, want %q", got, "[thinking] hmm") + if got != "" { + t.Errorf("thinking plain: got %q, want %q (suppressed)", got, "") } } @@ -375,6 +377,7 @@ func TestEventType_String(t *testing.T) { {EventWarning, "warning"}, {EventDone, "done"}, {EventEmpty, "empty"}, + {EventError, "error"}, {EventType(99), "unknown"}, } for _, tt := range tests { @@ -416,3 +419,112 @@ func TestClassifyChunk_MatchesExtractText(t *testing.T) { }) } } + +// ─── Error events (P0: failures must be observable, never blank successes) ──── + +func TestClassifyChunk_EventError_MapsToEventError(t *testing.T) { + // The failing-gateway repro: event:error / data:{"error":"...429..."}. + chunk := client.SSEChunk{Event: "error", Data: `{"error":"openai: 429 insufficient_quota"}`} + ev := ClassifyChunk(chunk) + if ev.Type != EventError { + t.Fatalf("event:error: got type %s, want error", ev.Type) + } + if ev.Content != "openai: 429 insufficient_quota" { + t.Errorf("event:error: got content %q, want the extracted error string", ev.Content) + } +} + +func TestClassifyChunk_EventError_NestedMessage(t *testing.T) { + chunk := client.SSEChunk{Event: "error", Data: `{"error":{"message":"model not found (404)","code":"dead_model"}}`} + ev := ClassifyChunk(chunk) + if ev.Type != EventError { + t.Fatalf("nested error: got type %s, want error", ev.Type) + } + if ev.Content != "model not found (404)" { + t.Errorf("nested error: got content %q, want %q", ev.Content, "model not found (404)") + } +} + +func TestClassifyChunk_EventError_NonJSONRaw(t *testing.T) { + chunk := client.SSEChunk{Event: "error", Data: "upstream exploded"} + ev := ClassifyChunk(chunk) + if ev.Type != EventError { + t.Fatalf("raw error: got type %s, want error", ev.Type) + } + if ev.Content != "upstream exploded" { + t.Errorf("raw error: got content %q, want raw passthrough", ev.Content) + } +} + +func TestClassifyChunk_EventError_EmptyNeverBlank(t *testing.T) { + // An error event with no usable message must still classify as EventError + // with a non-empty, visible message — never a silent EventEmpty. + chunk := client.SSEChunk{Event: "error", Data: `{"error":""}`} + ev := ClassifyChunk(chunk) + if ev.Type != EventError { + t.Fatalf("empty error: got type %s, want error", ev.Type) + } + if ev.Content == "" { + t.Error("empty error: content must never be blank") + } +} + +func TestRender_Error_Plain(t *testing.T) { + ev := RenderEvent{Type: EventError, Content: "429 quota exceeded"} + got := ev.Render(true) + if got != "error: 429 quota exceeded" { + t.Errorf("error plain: got %q, want %q", got, "error: 429 quota exceeded") + } +} + +func TestRender_Error_Styled(t *testing.T) { + ev := RenderEvent{Type: EventError, Content: "429 quota exceeded"} + got := ev.Render(false) + if !strings.Contains(got, "error: 429 quota exceeded") { + t.Errorf("error styled: output %q does not contain the message", got) + } +} + +func TestRenderJSON_Error_Emitted(t *testing.T) { + ev := RenderEvent{Type: EventError, Content: "boom"} + got := ev.RenderJSON() + if !strings.Contains(got, `"type":"error"`) || !strings.Contains(got, `"content":"boom"`) { + t.Errorf("error JSON: got %q, want a {type:error, content:boom} object", got) + } +} + +func TestRenderJSON_Thinking_Suppressed(t *testing.T) { + // Reasoning placeholder must not pollute the --json pipeline. + ev := RenderEvent{Type: EventThinking, Content: "Processing your request..."} + if got := ev.RenderJSON(); got != "" { + t.Errorf("thinking JSON: got %q, want empty (suppressed)", got) + } +} + +func TestClassifyChunk_RoutingTelemetry_Dropped(t *testing.T) { + for _, name := range []string{"intent_classified", "provider_selected"} { + chunk := client.SSEChunk{Event: name, Data: `{"intent":"chat","provider":"openai"}`} + ev := ClassifyChunk(chunk) + if ev.Type != EventEmpty { + t.Errorf("%s: got type %s, want empty (dropped as noise)", name, ev.Type) + } + } +} + +func TestExtractError_Variants(t *testing.T) { + cases := []struct { + name string + data string + want string + }{ + {"string error", `{"error":"boom"}`, "boom"}, + {"nested message", `{"error":{"message":"nested boom"}}`, "nested boom"}, + {"bare message", `{"message":"bare boom"}`, "bare boom"}, + {"non-json", "plain boom", "plain boom"}, + } + for _, tc := range cases { + if got := extractError(tc.data); got != tc.want { + t.Errorf("extractError(%q) = %q, want %q", tc.data, got, tc.want) + } + } +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 19d185c..9f3feca 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -9,7 +9,9 @@ import ( "io" "log" "os" + "os/signal" "strings" + "sync" "time" "github.com/DojoGenesis/cli/internal/art" @@ -36,6 +38,34 @@ type REPL struct { turns int // number of successful chat turns resumed bool // true when session was restored via --resume or /session resume plain bool // true when --plain or --no-color is set; uses unstyled renderer output + + mu sync.Mutex // guards turnCancel + turnCancel context.CancelFunc // cancels the in-flight streaming turn; nil when idle +} + +// beginTurn registers the cancel func for the streaming turn about to start so a +// SIGINT can cancel just that turn. The returned func clears the registration. +func (r *REPL) beginTurn(cancel context.CancelFunc) func() { + r.mu.Lock() + r.turnCancel = cancel + r.mu.Unlock() + return func() { + r.mu.Lock() + r.turnCancel = nil + r.mu.Unlock() + } +} + +// cancelActiveTurn cancels the in-flight streaming turn, if any. Returns true +// when a turn was actually cancelled. Called from the SIGINT watcher. +func (r *REPL) cancelActiveTurn() bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.turnCancel != nil { + r.turnCancel() + return true + } + return false } // New creates a REPL bound to the given config and gateway client. @@ -195,7 +225,7 @@ func (r *REPL) syncProviderKeys(ctx context.Context) { // Run starts the interactive loop. Returns when the user exits. func (r *REPL) Run(ctx context.Context) error { - printWelcome(r.cfg, r.session, r.resumed) + printWelcome(r.cfg, r.session, r.resumed, r.plain) // Spirit: streak + session XP if spiritSt, spiritErr := state.Load(); spiritErr == nil { @@ -233,38 +263,43 @@ func (r *REPL) Run(ctx context.Context) error { _ = spiritSt.Save() - // Display streak if > 1 - if spiritSt.Spirit.StreakDays > 1 { - fmt.Printf(" %s%s\n", - gcolor.HEX("#94a3b8").Sprintf("%-16s", "streak:"), - gcolor.HEX("#ffd166").Sprintf("%d days", spiritSt.Spirit.StreakDays), - ) - } + // Spirit visuals (streak, belt, promotions, achievements) are decorative — + // suppress them in plain/pipeline mode so stdout stays clean. XP state above + // is still recorded either way. + if !r.plain { + // Display streak if > 1 + if spiritSt.Spirit.StreakDays > 1 { + fmt.Printf(" %s%s\n", + gcolor.HEX("#94a3b8").Sprintf("%-16s", "streak:"), + gcolor.HEX("#ffd166").Sprintf("%d days", spiritSt.Spirit.StreakDays), + ) + } - // Display belt in welcome - belt := spirit.CurrentBelt(spiritSt.Spirit.XP) - if spiritSt.Spirit.XP > 0 { - fmt.Printf(" %s%s\n", - gcolor.HEX("#94a3b8").Sprintf("%-16s", "belt:"), - gcolor.HEX(belt.Color).Sprintf("%s %s (%d XP)", belt.Name, belt.Title, spiritSt.Spirit.XP), - ) - } + // Display belt in welcome + belt := spirit.CurrentBelt(spiritSt.Spirit.XP) + if spiritSt.Spirit.XP > 0 { + fmt.Printf(" %s%s\n", + gcolor.HEX("#94a3b8").Sprintf("%-16s", "belt:"), + gcolor.HEX(belt.Color).Sprintf("%s %s (%d XP)", belt.Name, belt.Title, spiritSt.Spirit.XP), + ) + } - if beltedUp { - fmt.Println() - fmt.Printf(" %s\n", gcolor.HEX("#ffd166").Sprint("BELT PROMOTION")) - fmt.Printf(" You are now: %s\n", gcolor.HEX(newBelt.Color).Sprintf("%s %s", newBelt.Name, newBelt.Title)) - fmt.Printf(" %s\n", gcolor.HEX("#94a3b8").Sprintf("\"%s\"", spirit.BeltQuote(newBelt.Rank))) + if beltedUp { + fmt.Println() + fmt.Printf(" %s\n", gcolor.HEX("#ffd166").Sprint("BELT PROMOTION")) + fmt.Printf(" You are now: %s\n", gcolor.HEX(newBelt.Color).Sprintf("%s %s", newBelt.Name, newBelt.Title)) + fmt.Printf(" %s\n", gcolor.HEX("#94a3b8").Sprintf("\"%s\"", spirit.BeltQuote(newBelt.Rank))) + fmt.Println() + } + for _, a := range newAchievements { + fmt.Printf(" %s %s %s\n", + gcolor.HEX("#ffd166").Sprint("Achievement:"), + gcolor.HEX("#f4a261").Sprint(a.Icon), + gcolor.HEX("#e8b04a").Sprint(a.Name), + ) + } fmt.Println() } - for _, a := range newAchievements { - fmt.Printf(" %s %s %s\n", - gcolor.HEX("#ffd166").Sprint("Achievement:"), - gcolor.HEX("#f4a261").Sprint(a.Icon), - gcolor.HEX("#e8b04a").Sprint(a.Name), - ) - } - fmt.Println() } // Push local API keys to the gateway so cloud providers get registered. @@ -293,6 +328,25 @@ func (r *REPL) Run(ctx context.Context) error { _ = st.Save() } + // Two-tier interrupt: a SIGINT that arrives while a response is streaming + // cancels ONLY that turn (the base ctx from main is not SIGINT-bound, so the + // session survives and the loop returns to the prompt). At an idle prompt the + // terminal is in raw mode and Ctrl+C is delivered to readline as a byte — + // handled below as ErrInterrupt — so this watcher fires only mid-stream. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-sigCh: + r.cancelActiveTurn() + } + } + }() + rl, err := newReadline(r.turns) if err != nil { // Fallback to plain stdin if readline init fails (e.g. in pipes) @@ -313,6 +367,13 @@ func (r *REPL) Run(ctx context.Context) error { line, err := rl.Readline() if err != nil { if err == readline.ErrInterrupt { + // Ctrl+C at the prompt clears a partial line; on an empty line + // (idle, or a second Ctrl+C right after cancelling a response) + // it quits cleanly. + if strings.TrimSpace(line) == "" { + fmt.Println("goodbye") + return nil + } fmt.Println() continue } @@ -467,12 +528,25 @@ func (r *REPL) chat(ctx context.Context, message string) error { WorkspaceRoot: workspaceRoot, } + // Derive a per-turn context so a SIGINT cancels THIS response only (the + // watcher in Run calls cancelActiveTurn); the session's base ctx is untouched. + turnCtx, cancel := context.WithCancel(ctx) + clearTurn := r.beginTurn(cancel) + defer func() { + cancel() + clearTurn() + }() + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" dojo ")) var fullText strings.Builder - err := r.gw.ChatStream(ctx, req, func(chunk client.SSEChunk) { + var sawError bool + err := r.gw.ChatStream(turnCtx, req, func(chunk client.SSEChunk) { ev := ClassifyChunk(chunk) + if ev.Type == EventError { + sawError = true + } rendered := ev.Render(r.plain) if rendered != "" { fmt.Print(rendered) @@ -484,14 +558,26 @@ func (r *REPL) chat(ctx context.Context, message string) error { fmt.Println() if err != nil { - if ctx.Err() != nil { - // User interrupted with Ctrl+C during streaming — not an error - fmt.Println(gcolor.HEX("#94a3b8").Sprint(" [interrupted]")) + switch { + case ctx.Err() != nil: + // Base context ended (SIGTERM / shutdown) — let the loop exit. + return nil + case turnCtx.Err() != nil: + // Ctrl+C cancelled just this response; return to the prompt. + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" [cancelled]")) + return nil + default: + // Real failure (conn refused, DNS, auth, 5xx, stall) — show WHY, + // not a canned "stream interrupted". Combined with the error-event + // surfacing, the user now sees the actual cause. + gcolor.Red.Printf(" error: %s\n", r.gw.FriendlyError(err)) return nil } - // Stream dropped unexpectedly - fmt.Println(gcolor.HEX("#e8b04a").Sprint(" [stream interrupted — response may be incomplete]")) - r.turns++ // still count it — partial response was shown + } + + if sawError { + // Stream ended cleanly but carried a gateway error event; it was already + // rendered inline. Don't count it as a successful turn or award XP. return nil } @@ -560,6 +646,11 @@ func (r *REPL) runPlain(ctx context.Context) error { // Note: printWelcome is already called by Run() before fallback here. scanner := bufio.NewScanner(os.Stdin) for { + select { + case <-ctx.Done(): + return nil + default: + } fmt.Print("> ") if !scanner.Scan() { return nil @@ -739,14 +830,18 @@ func historyPath() string { // ─── Welcome banner ────────────────────────────────────────────────────────── -func printWelcome(cfg *config.Config, session string, resumed bool) { +func printWelcome(cfg *config.Config, session string, resumed bool, plain bool) { fmt.Println() - // Bonsai sigil — zen visual anchor - fmt.Print(art.SmallBonsaiString()) + // Decorative banner (bonsai + gradient wordmark) is interactive-only — + // piped/CI consumers get the plain session/gateway lines below, nothing more. + if !plain { + // Bonsai sigil — zen visual anchor + fmt.Print(art.SmallBonsaiString()) - // Sunset gradient wordmark - fmt.Println(sunsetWordmark(" Dojo CLI")) + // Sunset gradient wordmark + fmt.Println(sunsetWordmark(" Dojo CLI")) + } // Session line: label in cloud-gray, value in warm-amber, "(resumed)" tag if applicable if resumed { @@ -781,16 +876,20 @@ func printWelcome(cfg *config.Config, session string, resumed bool) { } } - // JetBrains Mono one-time tip - home, _ := os.UserHomeDir() - hintFile := home + "/.dojo/.mono-hint" - if _, err := os.Stat(hintFile); os.IsNotExist(err) { - gcolor.HEX("#94a3b8").Println(" tip: set terminal font to JetBrains Mono for best rendering") - // Create the marker file so the tip never shows again - _ = os.MkdirAll(home+"/.dojo", 0o755) - f, ferr := os.Create(hintFile) - if ferr == nil { - _ = f.Close() + // JetBrains Mono one-time tip — decorative, interactive-only. Skipping it in + // plain mode also leaves the marker uncreated, so it still shows on the next + // interactive run. + if !plain { + home, _ := os.UserHomeDir() + hintFile := home + "/.dojo/.mono-hint" + if _, err := os.Stat(hintFile); os.IsNotExist(err) { + gcolor.HEX("#94a3b8").Println(" tip: set terminal font to JetBrains Mono for best rendering") + // Create the marker file so the tip never shows again + _ = os.MkdirAll(home+"/.dojo", 0o755) + f, ferr := os.Create(hintFile) + if ferr == nil { + _ = f.Close() + } } } From 79ec839de107aba8eb254342fcae3415f00708ba Mon Sep 17 00:00:00 2001 From: TresPies-source Date: Thu, 16 Jul 2026 04:18:03 -0500 Subject: [PATCH 03/23] fix(commands): complete /help, correct gateway error labels, command hygiene /help rebuilt into 13 grouped sections covering all 34 commands (previously omitted /craft, /guide, /tools and many subcommands). /health and /home label 401/403 as auth-failed and 5xx as gateway-error instead of always 'unreachable'. Removed stale --trace guidance, the dead bloom 'garden' alias, and the phantom /garden harvest verb; /projects documents its no-arg behavior; /session no longer overclaims. Co-Authored-By: Claude Opus 4.8 --- internal/commands/cmd_bloom.go | 8 +++- internal/commands/cmd_garden.go | 9 ++-- internal/commands/cmd_help.go | 81 ++++++++++++++++++++++---------- internal/commands/cmd_project.go | 16 ++++++- internal/commands/cmd_session.go | 32 ++++++++++++- internal/commands/cmd_system.go | 48 +++++++++++++++++-- 6 files changed, 157 insertions(+), 37 deletions(-) diff --git a/internal/commands/cmd_bloom.go b/internal/commands/cmd_bloom.go index 5affb14..707b0b6 100644 --- a/internal/commands/cmd_bloom.go +++ b/internal/commands/cmd_bloom.go @@ -16,8 +16,12 @@ import ( // bloomCmd returns the /bloom command. func (r *Registry) bloomCmd() Command { return Command{ - Name: "bloom", - Aliases: []string{"tree", "garden", "zen"}, + Name: "bloom", + // "garden" is deliberately NOT an alias here: /garden is itself a + // registered command name, and Registry.Dispatch checks exact command + // names before scanning aliases — so a "garden" alias on this command + // would always lose to the real /garden and could never be reached. + Aliases: []string{"tree", "zen"}, Usage: "/bloom", Short: "Watch your bonsai grow — animated zen garden", Run: func(ctx context.Context, args []string) error { diff --git a/internal/commands/cmd_garden.go b/internal/commands/cmd_garden.go index 9d22612..5339216 100644 --- a/internal/commands/cmd_garden.go +++ b/internal/commands/cmd_garden.go @@ -18,7 +18,7 @@ func (r *Registry) gardenCmd() Command { return Command{ Name: "garden", Aliases: []string{"seeds", "memory"}, - Usage: "/garden [ls|stats|plant |harvest]", + Usage: "/garden [ls|stats|plant |search |rm ]", Short: "Memory garden — list seeds, show stats, or plant new seeds", Run: func(ctx context.Context, args []string) error { sub := "ls" @@ -103,9 +103,10 @@ func (r *Registry) gardenCmd() Command { fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Seed deleted")) fmt.Println() - case "harvest": // alias for ls - fallthrough - default: // ls + default: // ls — also catches unrecognized subcommands (e.g. the + // former "harvest" verb, which was a silent no-op alias for + // this same branch; dropped rather than documented as if it + // were a distinct, intentional action). seeds, err := r.gw.Seeds(ctx) if err != nil { return fmt.Errorf("could not fetch seeds: %w", err) diff --git a/internal/commands/cmd_help.go b/internal/commands/cmd_help.go index afd29e5..5359b9f 100644 --- a/internal/commands/cmd_help.go +++ b/internal/commands/cmd_help.go @@ -31,16 +31,17 @@ func (r *Registry) helpCmd() Command { {"Chat", []helpEntry{ {"/help", "show this message", ""}, {"/model [ls]", "list available models and providers", ""}, - {"/model set ", "switch to a different model", ""}, + {"/model set ", "switch to a different model", ""}, {"/model direct ", "direct API call (bypass gateway)", ""}, {"/session", "show active session ID", ""}, {"/session new", "start a fresh session", ""}, {"/session resume", "resume the most recent session", ""}, - {"/session ", "resume a prior session by ID", ""}, + {"/session ", "switch to a session by ID (not verified against gateway)", ""}, {"/disposition", "show current disposition preset", ""}, {"/disposition ls", "list all disposition presets", ""}, {"/disposition set ", "switch to a named preset", ""}, - {"/disposition create ...", "create custom preset", "beta"}, + {"/disposition show ", "show a preset's pacing/depth/tone/initiative", ""}, + {"/disposition create ...", "create a custom preset", "beta"}, }}, {"Agents", []helpEntry{ {"/agent ls", "list agents registered in the gateway", ""}, @@ -51,7 +52,7 @@ func (r *Registry) helpCmd() Command { {"/agent bind ", "bind an agent to a channel", "beta"}, {"/agent unbind ", "unbind an agent from a channel", "beta"}, }}, - {"Memory", []helpEntry{ + {"Memory/Garden", []helpEntry{ {"/garden ls", "list memory seeds", ""}, {"/garden stats", "memory garden statistics", ""}, {"/garden plant ", "plant a new seed", ""}, @@ -59,12 +60,17 @@ func (r *Registry) helpCmd() Command { {"/garden rm ", "delete a seed", ""}, {"/trail", "show memory timeline", ""}, {"/trail add ", "store a memory entry", ""}, + {"/trail rm ", "delete a memory entry", ""}, {"/trail search ", "search memories", ""}, - {"/snapshot", "list/save/restore/export memory snapshots", "beta"}, + {"/snapshot", "list memory snapshots", "beta"}, + {"/snapshot save", "save a snapshot of the active session", "beta"}, + {"/snapshot restore ", "restore a snapshot", "beta"}, + {"/snapshot export ", "export a snapshot", "beta"}, + {"/snapshot rm ", "delete a snapshot", "beta"}, }}, {"Workspace", []helpEntry{ - {"/home", "workspace state overview", ""}, - {"/projects ls", "local workspace view", ""}, + {"/home [plain]", "workspace state overview (add 'plain' for text-only)", ""}, + {"/projects", "local workspace view — cwd, plugins, session (no arguments)", ""}, {"/project init ", "create a new project", ""}, {"/project status", "show active project phase", ""}, {"/project switch ", "change active project", ""}, @@ -72,58 +78,85 @@ func (r *Registry) helpCmd() Command { {"/project archive ", "archive a project", ""}, {"/project phase ", "set phase manually", "beta"}, {"/project track add ", "add a parallel track", "beta"}, + {"/project track set ", "update a track's status", "beta"}, {"/project decision ", "record a project decision", "beta"}, + {"/project artifact ", "save a project artifact", ""}, }}, {"Orchestration", []helpEntry{ {"/run ", "submit multi-step orchestration plan", ""}, {"/workflow [json]", "execute a workflow", ""}, {"/warroom [topic]", "split-panel debate: Scout vs Challenger", "beta"}, - {"/pilot", "live SSE event stream (Ctrl+C to stop)", ""}, + {"/pilot [plain]", "live SSE event stream, Ctrl+C to stop (add 'plain' for text-only)", ""}, {"/apps", "list running MCP apps", ""}, {"/apps launch ", "launch an MCP app", ""}, {"/apps close ", "stop an MCP app", ""}, {"/apps status", "MCP app connection status", ""}, {"/apps call ", "invoke a tool on an MCP app", ""}, - {"/skill ls [filter]", "list skills", ""}, + }}, + {"Skills", []helpEntry{ + {"/skill ls [filter]", "list skills, grouped by category", ""}, {"/skill search ", "search skills by keyword", ""}, {"/skill get ", "fetch skill content", ""}, {"/skill inspect ", "fetch raw CAS content", ""}, {"/skill tags", "list CAS skill tags", ""}, {"/skill package-all ", "package SKILL.md files into CAS", ""}, - {"/doc ", "fetch and display a document", "beta"}, + }}, + {"Plugins", []helpEntry{ {"/plugin ls", "list installed plugins", ""}, - {"/plugin install ", "install a plugin from git URL", ""}, + {"/plugin install ", "install a plugin from a git URL", ""}, {"/plugin rm ", "remove an installed plugin", ""}, }}, {"Code", []helpEntry{ - {"/code read ", "display file contents in REPL", ""}, + {"/code read ", "display file contents in the REPL", ""}, {"/code diff [file]", "show git diff (staged + unstaged)", ""}, {"/code test [pkg]", "run go test for a package", ""}, {"/code build", "run go build ./...", ""}, {"/code vet", "run go vet ./...", ""}, {"/code gate", "run build + test + vet (full gate)", ""}, }}, + {"Practice", []helpEntry{ + {"/practice", "daily reflection prompts (rotates by day of week)", ""}, + {"/guide ls", "list available tutorials", ""}, + {"/guide start ", "begin a guide", ""}, + {"/guide status", "show current step", ""}, + {"/guide stop", "stop the active guide", ""}, + }}, + {"Spirit", []helpEntry{ + {"/card", "show your dojo profile card", ""}, + {"/sensei", "receive a koan from the sensei", ""}, + {"/bloom", "animated bonsai garden meditation", ""}, + }}, + {"Telemetry", []helpEntry{ + {"/telemetry sessions", "recent sessions with cost/token/error data", ""}, + {"/telemetry costs", "cost breakdown by provider + 7-day trend", ""}, + {"/telemetry tools", "tool call stats: count, latency, success rate", ""}, + {"/telemetry summary", "combined overview of all telemetry data", ""}, + }}, {"System", []helpEntry{ {"/health", "gateway health + stats", ""}, {"/settings", "show config and active settings", ""}, + {"/settings effective", "show merged file + env + flag config", ""}, {"/settings providers", "show provider configuration", ""}, + {"/settings set ", "store a provider API key", ""}, + {"/settings profile [ls|set|show|create]", "manage disposition profiles (shared with /disposition)", ""}, {"/hooks ls", "list loaded hook rules", ""}, {"/hooks fire ", "manually fire a hook event", ""}, {"/init", "set up workspace with plugins, dispositions, seeds", ""}, - {"/trace ", "inspect execution trace", "beta"}, + {"/trace ", "inspect an execution trace by ID", "beta"}, {"/activity [n]", "show recent activity log entries", ""}, - {"/practice", "daily reflection prompts", ""}, - }}, - {"Telemetry", []helpEntry{ - {"/telemetry sessions", "recent sessions with cost/token/error data", ""}, - {"/telemetry costs", "cost breakdown by provider + 7-day trend", ""}, - {"/telemetry tools", "tool call stats: count, latency, success rate", ""}, - {"/telemetry summary", "combined overview of all telemetry data", ""}, + {"/activity clear", "clear the activity log", ""}, + {"/tools", "list registered MCP tools, grouped by namespace", ""}, + {"/doc ", "fetch and display a document", "beta"}, }}, - {"Spirit", []helpEntry{ - {"/card", "show your dojo profile card", ""}, - {"/sensei", "receive wisdom from the sensei", ""}, - {"/bloom", "animated bonsai garden meditation", ""}, + {"DojoCraft", []helpEntry{ + {"/craft adr ", "write an ADR via the gateway", ""}, + {"/craft scout <tension>", "tension → routes → synthesis → decision", ""}, + {"/craft claude-md [--fix]", "analyze CLAUDE.md files; --fix suggests rewrites", ""}, + {"/craft memory <ls|add|rm|prune|search>", "manage Gateway memory entries", ""}, + {"/craft seed <ls|plant|harvest|search|elevate>", "manage memory garden seeds", ""}, + {"/craft view [path]", "codebase overview — tree, entry points, test coverage", ""}, + {"/craft scaffold <template>", "create a project from a template layout", ""}, + {"/craft converge", "git + memory health report: RED/YELLOW/GREEN", ""}, }}, } diff --git a/internal/commands/cmd_project.go b/internal/commands/cmd_project.go index ffc445f..194b674 100644 --- a/internal/commands/cmd_project.go +++ b/internal/commands/cmd_project.go @@ -18,14 +18,26 @@ import ( func (r *Registry) projectsCmd() Command { return Command{ Name: "projects", - Usage: "/projects ls", - Short: "Local workspace view — cwd, plugins, session", + Usage: "/projects", + Short: "Shows the local workspace view — cwd, plugins, session (takes no arguments)", Run: func(ctx context.Context, args []string) error { fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Projects — local workspace")) fmt.Println() fmt.Println() + // /projects takes no arguments — earlier versions silently ignored + // anything passed here (e.g. "/projects ls" and "/projects xyz" were + // identical to bare "/projects"). Say so instead of pretending the + // argument did something. + if len(args) > 0 { + fmt.Println(gcolor.HEX("#94a3b8").Sprintf( + " note: /projects takes no arguments — ignoring %q. Showing the local workspace view below.", + strings.Join(args, " "), + )) + fmt.Println() + } + // Current working directory name as the project cwd, err := os.Getwd() if err != nil { diff --git a/internal/commands/cmd_session.go b/internal/commands/cmd_session.go index 14752db..65b86f6 100644 --- a/internal/commands/cmd_session.go +++ b/internal/commands/cmd_session.go @@ -5,6 +5,7 @@ package commands import ( "context" "fmt" + "strings" "time" "github.com/DojoGenesis/cli/internal/state" @@ -44,9 +45,16 @@ func (r *Registry) sessionCmd() Command { printKV("session", *r.session) fmt.Println() default: - *r.session = args[0] + // /session <id> switches the client's local pointer to whatever + // string was typed — it is never checked against the gateway, so + // "resumed" would overclaim success. Say what actually happened. + id := args[0] + *r.session = id fmt.Println() - fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Session resumed")) + if warning := sessionIDMalformedWarning(id); warning != "" { + fmt.Println(gcolor.HEX("#e8b04a").Sprintf(" warning: %s", warning)) + } + fmt.Println(gcolor.HEX("#7fb88c").Sprintf(" switched to session %s (not verified against gateway)", id)) printKV("session", *r.session) fmt.Println() state.SaveSession(*r.session) @@ -55,3 +63,23 @@ func (r *Registry) sessionCmd() Command { }, } } + +// sessionIDMalformedWarning does a cheap, best-effort sanity check on a +// user-supplied session ID and returns a human-readable warning if it looks +// obviously wrong (e.g. way too short, or full of characters no session ID +// would plausibly contain — like a pasted shell fragment). Returns "" when +// the ID looks plausible. This is NOT validation — /session <id> never +// confirms the session exists on the gateway; see the caller. +func sessionIDMalformedWarning(id string) string { + trimmed := strings.TrimSpace(id) + switch { + case trimmed == "": + return "empty session id" + case len(trimmed) < 3: + return fmt.Sprintf("%q is unusually short for a session id", id) + case strings.ContainsAny(trimmed, "\"'<>{}|\\^`"): + return fmt.Sprintf("%q contains characters that don't look like a session id", id) + default: + return "" + } +} diff --git a/internal/commands/cmd_system.go b/internal/commands/cmd_system.go index b5a51bd..a2f2e37 100644 --- a/internal/commands/cmd_system.go +++ b/internal/commands/cmd_system.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -17,6 +18,47 @@ import ( gcolor "github.com/gookit/color" ) +// gatewayHTTPStatus extracts the HTTP status code from a gateway client +// error's text, if present. The client formats non-2xx responses as +// "gateway <path> returned <code>: <body>" (see internal/client/client.go's +// get/post helpers). Returns 0 when no status code is present at all — e.g. a +// connection-level failure (refused, timeout, DNS) that never got an HTTP +// response, which is the one case that is genuinely "unreachable". +func gatewayHTTPStatus(err error) int { + const marker = "returned " + msg := err.Error() + idx := strings.Index(msg, marker) + if idx == -1 { + return 0 + } + rest := msg[idx+len(marker):] + end := strings.IndexByte(rest, ':') + if end == -1 { + end = len(rest) + } + code, convErr := strconv.Atoi(strings.TrimSpace(rest[:end])) + if convErr != nil { + return 0 + } + return code +} + +// wrapGatewayErr labels a gateway call failure by what actually happened +// instead of always saying "unreachable": a 401/403 means the gateway is up +// but rejected our credentials, a 5xx means it's up but erroring internally, +// and anything else (including no status code at all — refused/timeout/DNS) +// keeps the original "unreachable" label since we never got a real response. +func wrapGatewayErr(err error) error { + switch code := gatewayHTTPStatus(err); { + case code == 401 || code == 403: + return fmt.Errorf("gateway auth failed (401/403): %w", err) + case code >= 500 && code <= 599: + return fmt.Errorf("gateway error (5xx): %w", err) + default: + return fmt.Errorf("gateway unreachable: %w", err) + } +} + // ─── /health ──────────────────────────────────────────────────────────────── func (r *Registry) healthCmd() Command { @@ -28,7 +70,7 @@ func (r *Registry) healthCmd() Command { Run: func(ctx context.Context, args []string) error { h, err := r.gw.Health(ctx) if err != nil { - return fmt.Errorf("gateway unreachable: %w", err) + return wrapGatewayErr(err) } fmt.Println() printKV("status", colorStatus(h.Status)) @@ -71,7 +113,7 @@ func (r *Registry) homeCmd() Command { func (r *Registry) homePlain(ctx context.Context) error { h, err := r.gw.Health(ctx) if err != nil { - return fmt.Errorf("gateway unreachable: %w", err) + return wrapGatewayErr(err) } agents, agentErr := r.gw.Agents(ctx) seeds, seedErr := r.gw.Seeds(ctx) @@ -297,7 +339,7 @@ func (r *Registry) traceCmd() Command { fmt.Println() fmt.Println() fmt.Println(gcolor.HEX("#457b9d").Sprint(" Trace follows the active session's decision and tool-use history.")) - fmt.Println(gcolor.HEX("#457b9d").Sprint(" Connect the gateway with --trace to enable full trace output.")) + fmt.Println(gcolor.HEX("#457b9d").Sprint(" There is no --trace startup flag. Run /trace <id> with a real trace ID to fetch it from the gateway.")) fmt.Println() printKV("gateway", r.cfg.Gateway.URL) fmt.Println(gcolor.HEX("#94a3b8").Sprint(" hint: /trace <id> — provide a trace ID to inspect")) From 0e848be4fe668391e89e519a1aabaf67f3021906 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 04:18:04 -0500 Subject: [PATCH 04/23] feat(craft): make /craft stubs real (write/prune/elevate) /craft adr and claude-md --fix capture the streamed content and write files (with confirmation); memory prune actually deletes; seed elevate stores to memory; scaffold confirms before writing; harvest is honest about being a list. craftStream now surfaces gateway event:error instead of silently producing nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/cmd_craft.go | 445 +++++++++++++++++++++++++++++---- 1 file changed, 400 insertions(+), 45 deletions(-) diff --git a/internal/commands/cmd_craft.go b/internal/commands/cmd_craft.go index 3c0530f..885c80f 100644 --- a/internal/commands/cmd_craft.go +++ b/internal/commands/cmd_craft.go @@ -4,12 +4,14 @@ package commands // Subcommands: adr, scout, claude-md, memory, seed, view, scaffold, converge import ( + "bufio" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "time" @@ -127,7 +129,46 @@ Be concise and precise. Focus on the decision, not the technology overview.`, ne Stream: true, } - return r.craftStream(ctx, systemPrompt, req) + content, streamErr := r.craftStream(ctx, systemPrompt, req) + fmt.Println() + if streamErr != nil { + return fmt.Errorf("gateway error — ADR not written: %w", streamErr) + } + content = strings.TrimSpace(content) + if content == "" { + return fmt.Errorf("gateway returned no content (model unavailable?) — ADR not written") + } + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("could not get cwd: %w", err) + } + dir := filepath.Join(cwd, "decisions") + path := filepath.Join(dir, fmt.Sprintf("%03d-%s.md", nextNum, craftSlug(title))) + + fmt.Println() + gcolor.HEX("#94a3b8").Printf(" target: %s\n\n", path) + if !craftConfirm(fmt.Sprintf("Write ADR to %s?", path)) { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Cancelled — ADR not written.")) + fmt.Println() + return nil + } + + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("could not create decisions/ directory: %w", err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("could not write ADR: %w", err) + } + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#7fb88c").Sprintf(" ADR written to %s", path)) + fmt.Println() + fmt.Println() + return nil } // craftNextADRNumber scans decisions/ in CWD for the highest ADR number. @@ -191,7 +232,12 @@ Be direct. Prefer action over exploration. Assume the operator has limited atten Stream: true, } - return r.craftStream(ctx, systemPrompt, req) + _, err := r.craftStream(ctx, systemPrompt, req) + fmt.Println() + if err != nil { + return fmt.Errorf("gateway error: %w", err) + } + return nil } // ─── /craft claude-md ──────────────────────────────────────────────────────── @@ -272,12 +318,17 @@ Rules that reference outdated patterns or approaches. ## Improvement Suggestions Specific rewrites for the 3 highest-value improvements. Show old → new. -If --fix mode: emit corrected versions of each file after the report, fenced by triple backticks and the file path. +If --fix mode: after the report, for each file that needs changes emit one block +in exactly this shape — a line "FILE: <relative-path>" using the path exactly as +given in the "## File: <path>" headers above, immediately followed by the full +corrected file content inside a fenced code block (nothing else on the FILE +line, one block per file, no commentary between blocks). + Be specific. Do not suggest generic "add more documentation".` message := combined.String() if fix { - message += "\n\n---\nPlease also output fixed versions of each file." + message += "\n\n---\nPlease also output fixed versions of each file, using the FILE: block format described above." } req := client.ChatRequest{ @@ -286,7 +337,64 @@ Be specific. Do not suggest generic "add more documentation".` Stream: true, } - return r.craftStream(ctx, systemPrompt, req) + content, err := r.craftStream(ctx, systemPrompt, req) + fmt.Println() + if err != nil { + return fmt.Errorf("gateway error — no files modified: %w", err) + } + + if !fix { + return nil + } + + content = strings.TrimSpace(content) + if content == "" { + return fmt.Errorf("gateway returned no content (model unavailable?) — no files modified") + } + + fixes := craftParseFixedFiles(content) + if len(fixes) == 0 { + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" --fix requested but no fixed-file blocks were found in the response — nothing written.")) + fmt.Println() + return nil + } + + relPaths := make([]string, 0, len(fixes)) + for p := range fixes { + relPaths = append(relPaths, p) + } + sort.Strings(relPaths) + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Proposed fixes")) + fmt.Println() + fmt.Println() + for _, p := range relPaths { + fmt.Printf(" %s\n", gcolor.HEX("#f4a261").Sprint(p)) + } + fmt.Println() + + if !craftConfirm(fmt.Sprintf("Write %d fixed file(s)?", len(relPaths))) { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Cancelled — no files modified.")) + fmt.Println() + return nil + } + + fmt.Println() + for _, p := range relPaths { + target := p + if !filepath.IsAbs(target) { + target = filepath.Join(cwd, p) + } + if err := os.WriteFile(target, []byte(fixes[p]), 0644); err != nil { + fmt.Printf(" %s %s\n", gcolor.HEX("#ef4444").Sprint("fail "), gcolor.HEX("#94a3b8").Sprintf("%s: %v", p, err)) + continue + } + fmt.Printf(" %s %s\n", gcolor.HEX("#7fb88c").Sprint("write"), gcolor.White.Sprint(p)) + } + fmt.Println() + return nil } // ─── /craft memory ─────────────────────────────────────────────────────────── @@ -348,17 +456,76 @@ func (r *Registry) craftMemory(ctx context.Context, args []string) error { fmt.Println() case "prune": - // List first, then report how many exist — pruning logic deferred to Gateway. memories, err := r.gw.Memories(ctx) if err != nil { return fmt.Errorf("could not fetch memories: %w", err) } + + // Optional criterion: /craft memory prune <type> restricts the target + // set to memories whose Type matches (case-insensitive). No criterion + // given → target is every memory currently stored. + var criterion string + if len(args) > 1 { + criterion = strings.ToLower(args[1]) + } + var target []client.Memory + for _, m := range memories { + if criterion == "" || strings.ToLower(m.Type) == criterion { + target = append(target, m) + } + } + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Memory Prune")) + fmt.Println() fmt.Println() - gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Memory Prune Preview")) + if criterion != "" { + gcolor.HEX("#94a3b8").Printf(" criterion: type = %q\n", criterion) + } else { + gcolor.HEX("#94a3b8").Println(" criterion: all memories") + } + gcolor.HEX("#94a3b8").Printf(" %d of %d memories match\n\n", len(target), len(memories)) + + if len(target) == 0 { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Nothing to prune.")) + fmt.Println() + return nil + } + + for _, m := range target { + fmt.Printf(" %s %s\n", + gcolor.HEX("#f4a261").Sprintf("%-36s", m.ID), + gcolor.White.Sprint(truncate(m.Content, 60)), + ) + } + fmt.Println() + + if !craftConfirm(fmt.Sprintf("Delete these %d memory entries?", len(target))) { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Cancelled — nothing pruned.")) + fmt.Println() + return nil + } + + deleted := 0 + var failures []string + for _, m := range target { + if delErr := r.gw.DeleteMemory(ctx, m.ID); delErr != nil { + failures = append(failures, fmt.Sprintf("%s: %v", m.ID, delErr)) + continue + } + deleted++ + } + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#7fb88c").Sprintf(" Pruned %d/%d memories", deleted, len(target))) fmt.Println() - gcolor.HEX("#94a3b8").Printf(" %d memories total\n", len(memories)) - fmt.Println(gcolor.HEX("#457b9d").Sprint(" hint: use /craft memory rm <id> to delete individual entries")) + if len(failures) > 0 { + fmt.Println() + gcolor.HEX("#ef4444").Println(" Failures:") + for _, f := range failures { + fmt.Printf(" %s\n", gcolor.HEX("#94a3b8").Sprint(f)) + } + } fmt.Println() case "search": @@ -400,25 +567,28 @@ func (r *Registry) craftSeed(ctx context.Context, args []string) error { } switch op { - case "ls", "list", "harvest": + case "ls", "list": seeds, err := r.gw.Seeds(ctx) if err != nil { return fmt.Errorf("could not fetch seeds: %w", err) } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Seeds (%d)\n\n", len(seeds))) - if len(seeds) == 0 { - fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Garden is empty. Use /craft seed plant <text> to add a seed.")) - fmt.Println() - return nil - } - for _, s := range seeds { - fmt.Printf(" %s %s\n", - gcolor.HEX("#f4a261").Sprintf("%-44s", s.Name), - gcolor.HEX("#94a3b8").Sprint(truncate(s.Content, 50)), - ) + craftPrintSeedList(seeds) + + case "harvest": + // Honest label: there is no curation logic distinct from `ls` yet. + // Previously this was a silent, undocumented alias for ls — say so + // explicitly instead of pretending harvest does something different. + seeds, err := r.gw.Seeds(ctx) + if err != nil { + return fmt.Errorf("could not fetch seeds: %w", err) } fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Seeds (%d)\n\n", len(seeds))) + gcolor.HEX("#94a3b8").Println(" (harvest currently = ls; no distinct curation logic yet)") + fmt.Println() + craftPrintSeedList(seeds) case "plant": if len(args) < 2 { @@ -476,29 +646,65 @@ func (r *Registry) craftSeed(ctx context.Context, args []string) error { fmt.Println() case "elevate": - // Elevate seeds to memory: list seeds, display guidance. - seeds, err := r.gw.Seeds(ctx) - if err != nil { - return fmt.Errorf("could not fetch seeds: %w", err) + if len(args) < 2 { + // No target given — show what's available and how to elevate it. + seeds, err := r.gw.Seeds(ctx) + if err != nil { + return fmt.Errorf("could not fetch seeds: %w", err) + } + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Seed Elevation")) + fmt.Println() + fmt.Println() + gcolor.HEX("#94a3b8").Printf(" %d seeds in garden\n\n", len(seeds)) + if len(seeds) == 0 { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" No seeds to elevate.")) + fmt.Println() + return nil + } + fmt.Println(gcolor.HEX("#457b9d").Sprint(" usage: /craft seed elevate <seed-id-or-text>")) + fmt.Println() + craftPrintSeedList(seeds) + return nil + } + + target := strings.Join(args[1:], " ") + + // If target matches an existing seed's ID, elevate that seed's content. + // Otherwise treat target as literal freeform text to elevate directly. + elevateText := target + elevateSource := "text" + if seeds, seedErr := r.gw.Seeds(ctx); seedErr == nil { + for _, s := range seeds { + if s.ID == target { + elevateText = s.Content + elevateSource = fmt.Sprintf("seed %q", s.Name) + break + } + } } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Seed Elevation")) fmt.Println() fmt.Println() - gcolor.HEX("#94a3b8").Printf(" %d seeds in garden\n\n", len(seeds)) - if len(seeds) == 0 { - fmt.Println(gcolor.HEX("#94a3b8").Sprint(" No seeds to elevate.")) + gcolor.HEX("#94a3b8").Printf(" source: %s\n", elevateSource) + fmt.Printf(" %s\n\n", gcolor.White.Sprint(truncate(elevateText, 100))) + + if !craftConfirm("Store this as durable memory?") { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Cancelled — nothing elevated.")) fmt.Println() return nil } - fmt.Println(gcolor.HEX("#457b9d").Sprint(" To elevate a seed to durable memory:")) - fmt.Println(gcolor.HEX("#457b9d").Sprint(" /craft memory add <text from seed>")) + + mem, err := r.gw.StoreMemory(ctx, client.StoreMemoryRequest{Content: elevateText}) + if err != nil { + return fmt.Errorf("could not elevate seed: %w", err) + } fmt.Println() - for _, s := range seeds { - fmt.Printf(" %s %s\n", - gcolor.HEX("#f4a261").Sprintf("%-44s", s.Name), - gcolor.HEX("#94a3b8").Sprint(truncate(s.Content, 50)), - ) + fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Seed elevated to memory")) + if mem != nil { + printKV("id", mem.ID) } fmt.Println() @@ -696,14 +902,14 @@ func craftScaffold(args []string) error { case "go-service": dirs = []string{"cmd/server", "internal/handlers", "internal/config"} files = map[string]string{ - "go.mod": "module github.com/example/service\n\ngo 1.24.0\n", - "Makefile": "build:\n\tgo build ./...\n\ntest:\n\tgo test ./...\n\n.PHONY: build test\n", + "go.mod": "module github.com/example/service\n\ngo 1.24.0\n", + "Makefile": "build:\n\tgo build ./...\n\ntest:\n\tgo test ./...\n\n.PHONY: build test\n", "cmd/server/main.go": "package main\n\nfunc main() {\n}\n", } case "fullstack": dirs = []string{"server/cmd", "server/internal", "frontend/src"} files = map[string]string{ - "Makefile": "build:\n\tgo build ./server/...\n\n.PHONY: build\n", + "Makefile": "build:\n\tgo build ./server/...\n\n.PHONY: build\n", "server/cmd/main.go": "package main\n\nfunc main() {\n}\n", } case "orchestration": @@ -714,8 +920,8 @@ func craftScaffold(args []string) error { case "plugin": dirs = []string{"commands", "skills/example"} files = map[string]string{ - "plugin.json": "{\n \"name\": \"my-plugin\",\n \"version\": \"0.1.0\",\n \"description\": \"\"\n}\n", - ".mcp.json": "{\n \"mcpServers\": {}\n}\n", + "plugin.json": "{\n \"name\": \"my-plugin\",\n \"version\": \"0.1.0\",\n \"description\": \"\"\n}\n", + ".mcp.json": "{\n \"mcpServers\": {}\n}\n", "skills/example/SKILL.md": "---\nname: example\nversion: 0.1.0\n---\n\n# Example Skill\n", } case "minimal": @@ -728,7 +934,30 @@ func craftScaffold(args []string) error { } fmt.Println() - gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Scaffolding: %s\n\n", template)) + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Scaffold: %s\n\n", template)) + + fileNames := make([]string, 0, len(files)) + for name := range files { + fileNames = append(fileNames, name) + } + sort.Strings(fileNames) + + gcolor.HEX("#94a3b8").Println(" This will create:") + for _, d := range dirs { + fmt.Printf(" %s %s\n", gcolor.HEX("#7fb88c").Sprint("dir "), gcolor.White.Sprint(d+"/")) + } + for _, name := range fileNames { + fmt.Printf(" %s %s\n", gcolor.HEX("#7fb88c").Sprint("file"), gcolor.White.Sprint(name)) + } + fmt.Println() + gcolor.HEX("#94a3b8").Printf(" target: %s\n\n", cwd) + + if !craftConfirm(fmt.Sprintf("Create %d dir(s) and %d file(s) in %s?", len(dirs), len(files), cwd)) { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Cancelled — nothing created.")) + fmt.Println() + return nil + } + fmt.Println() for _, d := range dirs { path := filepath.Join(cwd, d) @@ -738,7 +967,8 @@ func craftScaffold(args []string) error { fmt.Printf(" %s %s\n", gcolor.HEX("#7fb88c").Sprint("mkdir"), gcolor.White.Sprint(d)) } - for name, content := range files { + for _, name := range fileNames { + content := files[name] path := filepath.Join(cwd, name) // Don't overwrite existing files. if _, err := os.Stat(path); err == nil { @@ -860,29 +1090,154 @@ func (r *Registry) craftConverge(ctx context.Context) error { return nil } +// ─── shared helpers (confirmation, fs, parsing) ────────────────────────────── + +// craftConfirm prints a y/N prompt to stderr and blocks for a line of input on +// stdin, returning true only for an explicit "y" or "yes" (case-insensitive). +// Mirrors the confirmation style of plugins.InstallConfirmed (see cmd_plugin.go +// / internal/plugins/installer.go) but stays file-local: every /craft +// write/delete path below needs the same guard, and cmd_craft.go is the only +// file this session is allowed to touch. +func craftConfirm(prompt string) bool { + fmt.Fprintf(os.Stderr, " %s [y/N]: ", prompt) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "y" || answer == "yes" +} + +// craftSlug converts a title into a filesystem-safe slug for ADR filenames: +// lowercase, alphanumeric runs joined by single hyphens, no leading/trailing +// hyphens. Falls back to "untitled" for a title with no alphanumeric content. +func craftSlug(title string) string { + var b strings.Builder + lastHyphen := true // suppress a leading hyphen + for _, r := range strings.ToLower(title) { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': + b.WriteRune(r) + lastHyphen = false + default: + if !lastHyphen { + b.WriteByte('-') + lastHyphen = true + } + } + } + slug := strings.TrimSuffix(b.String(), "-") + if slug == "" { + return "untitled" + } + return slug +} + +// craftPrintSeedList prints a seed listing in the shared /craft seed table +// format, or an empty-garden hint when there are none. Shared by `seed ls` +// and `seed harvest`, which — per the fix below — is now an honestly-labelled +// alias for ls rather than a silent one. +func craftPrintSeedList(seeds []client.Seed) { + if len(seeds) == 0 { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Garden is empty. Use /craft seed plant <text> to add a seed.")) + fmt.Println() + return + } + for _, s := range seeds { + fmt.Printf(" %s %s\n", + gcolor.HEX("#f4a261").Sprintf("%-44s", s.Name), + gcolor.HEX("#94a3b8").Sprint(truncate(s.Content, 50)), + ) + } + fmt.Println() +} + +// craftParseFixedFiles extracts "FILE: <path>" + fenced-code-block pairs from +// an LLM response, per the format dictated to the model in craftClaudeMD's +// --fix system prompt. Malformed or unterminated blocks are handled leniently +// (skipped, or truncated at end-of-input) rather than erroring — a partial +// response still yields whatever complete blocks it has, and the caller +// already refuses to write anything if this returns empty. +func craftParseFixedFiles(content string) map[string]string { + out := make(map[string]string) + lines := strings.Split(content, "\n") + i := 0 + for i < len(lines) { + line := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(line, "FILE:") { + i++ + continue + } + path := strings.TrimSpace(strings.TrimPrefix(line, "FILE:")) + i++ + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + if i >= len(lines) || !strings.HasPrefix(strings.TrimSpace(lines[i]), "```") { + continue // no fence found — malformed block, resume scanning from here + } + i++ // past opening fence + start := i + for i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```") { + i++ + } + body := strings.Join(lines[start:i], "\n") + if i < len(lines) { + i++ // past closing fence + } + if path != "" { + if !strings.HasSuffix(body, "\n") { + body += "\n" + } + out[path] = body + } + } + return out +} + // ─── streaming helper ──────────────────────────────────────────────────────── // craftStream sends a chat request with a system prompt injected as the first user -// message prefix and streams the response to stdout. +// message prefix, streams the response to stdout, and returns the full accumulated +// text alongside any error. Callers that only care about live display (e.g. /craft +// scout) can discard the returned string; callers that need to act on the result +// (e.g. /craft adr writing a file) use it directly. // The gateway /v1/chat endpoint does not have a separate system_prompt field, // so we prepend it inline. -func (r *Registry) craftStream(ctx context.Context, systemPrompt string, req client.ChatRequest) error { +// +// A mid-stream "error" SSE event (e.g. the gateway surfacing an upstream model +// failure — openai 429, anthropic 404, etc. — as an event rather than a non-200 +// HTTP status) is captured and, if no other content arrived, turned into a +// returned error rather than being silently dropped. This is what lets callers +// tell "gateway down" apart from "gateway returned nothing" and avoid writing +// an empty file in either case. +func (r *Registry) craftStream(ctx context.Context, systemPrompt string, req client.ChatRequest) (string, error) { req.Message = systemPrompt + "\n\n---\n\n" + req.Message - return r.gw.ChatStream(ctx, req, func(chunk client.SSEChunk) { + var out strings.Builder + var streamErrMsg string + err := r.gw.ChatStream(ctx, req, func(chunk client.SSEChunk) { if chunk.Data == "" { return } + if chunk.Event == "error" { + streamErrMsg = chunk.Data + return + } // Parse delta payload using encoding/json for correct Unicode/escape handling. var delta struct { Delta string `json:"delta"` } - if err := json.Unmarshal([]byte(chunk.Data), &delta); err == nil && delta.Delta != "" { + if jsonErr := json.Unmarshal([]byte(chunk.Data), &delta); jsonErr == nil && delta.Delta != "" { fmt.Print(delta.Delta) + out.WriteString(delta.Delta) return } // Fallback: print raw data for non-delta events (content, etc.). if chunk.Event == "" || chunk.Event == "message" || chunk.Event == "delta" { fmt.Print(chunk.Data) + out.WriteString(chunk.Data) } }) + if err == nil && streamErrMsg != "" && out.Len() == 0 { + err = fmt.Errorf("gateway stream error: %s", streamErrMsg) + } + return out.String(), err } From 40b9ed8187c58c92facb5caadd399f05e97d7a79 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 04:18:04 -0500 Subject: [PATCH 05/23] fix(security): Gemini key leak, artifact path traversal, package-all panic Move the Gemini API key from the URL query string to the x-goog-api-key header so it cannot leak into Go's url.Error text on network failures. Contain /project artifact filenames within the artifacts root (a traversal also created intermediate dirs via AtomicWriteFile's MkdirAll). Guard ref[:12] against short refs that panicked /skill package-all. Dedup the /run DAG poll loop with a clean cancel message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/artifacts/store.go | 40 +++++++++- internal/artifacts/store_test.go | 123 +++++++++++++++++++++++++++++ internal/commands/cmd_workflow.go | 84 ++++++++++++-------- internal/commands/commands_test.go | 97 +++++++++++++++++++++++ internal/commands/project_cmds.go | 42 +++++++--- internal/providers/providers.go | 11 ++- 6 files changed, 347 insertions(+), 50 deletions(-) diff --git a/internal/artifacts/store.go b/internal/artifacts/store.go index 7436710..bd46d3d 100644 --- a/internal/artifacts/store.go +++ b/internal/artifacts/store.go @@ -62,6 +62,31 @@ func ensureMDExt(filename string) string { return filename + ".md" } +// safeArtifactPath joins filename onto dir and guarantees the result cannot +// escape dir via ".." traversal or a symlinked dir — the same root-boundary +// pattern used in internal/commands/cmd_code.go (Clean/EvalSymlinks + prefix +// check), adapted for a filename that may not exist yet (Save creates new +// files, so only the already-created dir — not the leaf filename — can be +// resolved through EvalSymlinks). +// +// filename is caller/agent-supplied (e.g. via "/project artifact <type> +// <file> <content>"); without this check, a filename like "../../../x" +// escapes the ~/.dojo/projects/<projectID>/<artifactType> sandbox and can +// create or overwrite arbitrary files the process can write to (AtomicWriteFile +// even MkdirAll's the escaped target's parent directory). +func safeArtifactPath(dir, filename string) (string, error) { + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return "", fmt.Errorf("artifacts: resolve dir %s: %w", dir, err) + } + + joined := filepath.Clean(filepath.Join(resolvedDir, filename)) + if joined != resolvedDir && !strings.HasPrefix(joined, resolvedDir+string(os.PathSeparator)) { + return "", fmt.Errorf("artifacts: filename %q escapes the artifacts root", filename) + } + return joined, nil +} + // Save writes content to ~/.dojo/projects/<projectID>/<artifactType>/<filename>. // Ensures .md extension. Creates directories as needed. // Returns the absolute path of the saved file. @@ -71,7 +96,10 @@ func Save(projectID string, at ArtifactType, filename, content string) (string, if err := os.MkdirAll(dir, 0700); err != nil { return "", fmt.Errorf("artifacts: create directory %s: %w", dir, err) } - path := filepath.Join(dir, filename) + path, err := safeArtifactPath(dir, filename) + if err != nil { + return "", err + } if err := ioutilx.AtomicWriteFile(path, []byte(content), 0600); err != nil { return "", fmt.Errorf("artifacts: write %s: %w", path, err) } @@ -151,7 +179,10 @@ func ListAll(projectID string) ([]ArtifactMeta, error) { // Read returns the content of a specific artifact file. func Read(projectID string, at ArtifactType, filename string) (string, error) { filename = ensureMDExt(filename) - path := filepath.Join(Dir(projectID, at), filename) + path, err := safeArtifactPath(Dir(projectID, at), filename) + if err != nil { + return "", err + } data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("artifacts: read %s: %w", path, err) @@ -162,7 +193,10 @@ func Read(projectID string, at ArtifactType, filename string) (string, error) { // Delete removes an artifact file. func Delete(projectID string, at ArtifactType, filename string) error { filename = ensureMDExt(filename) - path := filepath.Join(Dir(projectID, at), filename) + path, err := safeArtifactPath(Dir(projectID, at), filename) + if err != nil { + return err + } if err := os.Remove(path); err != nil { return fmt.Errorf("artifacts: delete %s: %w", path, err) } diff --git a/internal/artifacts/store_test.go b/internal/artifacts/store_test.go index 54c39a0..7cddec5 100644 --- a/internal/artifacts/store_test.go +++ b/internal/artifacts/store_test.go @@ -8,6 +8,7 @@ package artifacts import ( "os" + "path/filepath" "strings" "testing" "time" @@ -71,6 +72,128 @@ func TestDir(t *testing.T) { } } +// --------------------------------------------------------------------------- +// safeArtifactPath — path-traversal containment (security regression) +// --------------------------------------------------------------------------- + +func TestSafeArtifactPathRejectsTraversal(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir := Dir("proj-escape", TypeGeneric) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("setup mkdir: %v", err) + } + + // The exact example from the audit: "../../../x" escaping the + // ~/.dojo/projects/<id>/<type> sandbox. + _, err := safeArtifactPath(dir, "../../../x") + if err == nil { + t.Fatal("safeArtifactPath should reject a '../../../x' traversal filename; got nil error") + } + if !strings.Contains(err.Error(), "escapes") { + t.Errorf("error = %v; want it to mention the path escaping the artifacts root", err) + } +} + +func TestSafeArtifactPathRejectsDeepTraversal(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir := Dir("proj-escape-deep", TypeGeneric) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("setup mkdir: %v", err) + } + + // Enough ".." segments to walk past HOME entirely, not just past the + // artifact-type directory — proves the check holds regardless of how + // far the traversal reaches. + _, err := safeArtifactPath(dir, strings.Repeat("../", 12)+"etc/passwd") + if err == nil { + t.Fatal("safeArtifactPath should reject a deep traversal filename; got nil error") + } +} + +func TestSafeArtifactPathAcceptsNormalFilename(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir := Dir("proj-ok", TypeGeneric) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("setup mkdir: %v", err) + } + + got, err := safeArtifactPath(dir, "my-file.md") + if err != nil { + t.Fatalf("safeArtifactPath rejected a normal filename: %v", err) + } + if !strings.HasSuffix(got, "my-file.md") { + t.Errorf("safeArtifactPath = %q; want it to end in my-file.md", got) + } + // Use Contains rather than a HasPrefix(got, dir) check: on macOS dir may + // be spelled via the /var/folders symlink while safeArtifactPath returns + // the EvalSymlinks-resolved /private/var/folders form of the same path. + if !strings.Contains(got, "proj-ok") { + t.Errorf("safeArtifactPath = %q; want it rooted under the proj-ok artifacts dir", got) + } +} + +func TestSafeArtifactPathAcceptsNestedSubdirFilename(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir := Dir("proj-nested", TypeGeneric) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("setup mkdir: %v", err) + } + + // Legitimate nested filenames (no "..") must still be allowed — this + // mirrors ensureMDExt's own test expectation ("sub/path/file"). + got, err := safeArtifactPath(dir, "sub/path/file.md") + if err != nil { + t.Fatalf("safeArtifactPath rejected a nested filename: %v", err) + } + if !strings.HasSuffix(got, filepath.Join("sub", "path", "file.md")) { + t.Errorf("safeArtifactPath = %q; want it to end in sub/path/file.md", got) + } +} + +// --------------------------------------------------------------------------- +// Save / Read / Delete — path-traversal rejection through the public API +// --------------------------------------------------------------------------- + +func TestSaveRejectsPathTraversal(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + _, err := Save("proj-escape-save", TypeGeneric, "../../../x", "malicious content") + if err == nil { + t.Fatal("Save with a '../../../x' filename should return an error; got nil") + } +} + +func TestReadRejectsPathTraversal(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // Seed a legitimate artifact so the project directory exists. + if _, err := Save("proj-escape-read", TypeGeneric, "legit", "content"); err != nil { + t.Fatalf("setup Save: %v", err) + } + + _, err := Read("proj-escape-read", TypeGeneric, "../../../etc/passwd") + if err == nil { + t.Fatal("Read with a path-traversal filename should return an error; got nil") + } +} + +func TestDeleteRejectsPathTraversal(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + if _, err := Save("proj-escape-delete", TypeGeneric, "legit", "content"); err != nil { + t.Fatalf("setup Save: %v", err) + } + + err := Delete("proj-escape-delete", TypeGeneric, "../../../etc/passwd") + if err == nil { + t.Fatal("Delete with a path-traversal filename should return an error; got nil") + } +} + // --------------------------------------------------------------------------- // Save + Read round-trip // --------------------------------------------------------------------------- diff --git a/internal/commands/cmd_workflow.go b/internal/commands/cmd_workflow.go index a943047..6feabac 100644 --- a/internal/commands/cmd_workflow.go +++ b/internal/commands/cmd_workflow.go @@ -165,23 +165,7 @@ func (r *Registry) runCmd() Command { printKV("status", colorStatus(status.Status)) fmt.Println() - // Poll DAG until terminal state. - for { - dag, pollErr := r.gw.OrchestrationDAG(ctx, status.ExecutionID) - if pollErr != nil { - fmt.Println(gcolor.HEX("#ef4444").Sprintf(" poll error: %v", pollErr)) - break - } - r.printDAGNodes(dag.Nodes) - if dag.Status == "completed" || dag.Status == "failed" { - fmt.Println() - printKV("result", colorStatus(dag.Status)) - fmt.Println() - return nil - } - time.Sleep(800 * time.Millisecond) - } - return nil + return r.pollDAGUntilTerminal(ctx, status.ExecutionID) } // Orchestration failed — fall through to ChatStream MVP. fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" orchestration unavailable (%v), falling back to chat", err)) @@ -207,23 +191,7 @@ func (r *Registry) runCmd() Command { printKV("status", colorStatus(status.Status)) fmt.Println() - // Poll DAG until terminal state. - for { - dag, pollErr := r.gw.OrchestrationDAG(ctx, status.ExecutionID) - if pollErr != nil { - fmt.Println(gcolor.HEX("#ef4444").Sprintf(" poll error: %v", pollErr)) - break - } - r.printDAGNodes(dag.Nodes) - if dag.Status == "completed" || dag.Status == "failed" { - fmt.Println() - printKV("result", colorStatus(dag.Status)) - fmt.Println() - return nil - } - time.Sleep(800 * time.Millisecond) - } - return nil + return r.pollDAGUntilTerminal(ctx, status.ExecutionID) } // Orchestration failed — fall through to ChatStream MVP. fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" orchestration unavailable (%v), falling back to chat", err)) @@ -282,6 +250,40 @@ func (r *Registry) runCmd() Command { } } +// pollDAGUntilTerminal polls the orchestration DAG for executionID, printing +// node status after every poll, until the DAG reaches a terminal state +// ("completed" or "failed") or a poll fails. It is shared by both /run DAG +// paths (--dag NL-parse and client-side template match), which previously +// duplicated this loop verbatim. +// +// On a poll error this prints a message and returns nil — mirroring the +// pre-extraction behavior of both call sites, which treated "stop polling" +// as "fall through to success" rather than propagating the error. A poll +// error caused by context cancellation (ctx.Err() != nil — e.g. the user hit +// Ctrl+C) prints a clean "[cancelled]" instead of the raw +// "poll error: context canceled" text. +func (r *Registry) pollDAGUntilTerminal(ctx context.Context, executionID string) error { + for { + dag, pollErr := r.gw.OrchestrationDAG(ctx, executionID) + if pollErr != nil { + if ctx.Err() != nil { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" [cancelled]")) + } else { + fmt.Println(gcolor.HEX("#ef4444").Sprintf(" poll error: %v", pollErr)) + } + return nil + } + r.printDAGNodes(dag.Nodes) + if dag.Status == "completed" || dag.Status == "failed" { + fmt.Println() + printKV("result", colorStatus(dag.Status)) + fmt.Println() + return nil + } + time.Sleep(800 * time.Millisecond) + } +} + // printDAGNodes renders DAG node status with icons. func (r *Registry) printDAGNodes(nodes []map[string]any) { for _, n := range nodes { @@ -652,7 +654,7 @@ func (r *Registry) skillCmd() Command { } time.Sleep(250 * time.Millisecond) - gcolor.HEX("#22c55e").Printf(" [OK] %s → %s\n", skillName, ref[:12]) + gcolor.HEX("#22c55e").Printf(" [OK] %s → %s\n", skillName, shortRef(ref)) succeeded++ } @@ -714,6 +716,18 @@ func (r *Registry) skillCmd() Command { const skillPageSize = 30 +// shortRef returns the first 12 characters of a CAS ref for compact display, +// or ref unchanged if it is shorter than that. ref comes straight from the +// gateway response (CASPutContent) with no length guarantee — an unguarded +// ref[:12] slice panics (and previously crashed the whole CLI mid +// /skill package-all) whenever the gateway returns a short ref. +func shortRef(ref string) string { + if len(ref) >= 12 { + return ref[:12] + } + return ref +} + // parseSkillLsArgs extracts (filter, showAll, page) from the args slice. // Recognises: "all", "p<N>" or plain integers as page numbers, everything else // is joined as the filter term. diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 6d6bf59..b5abd07 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -135,6 +135,43 @@ func TestTruncateUnicode(t *testing.T) { } } +// ─── shortRef (crash regression: ref[:12] on a gateway-returned string) ─────── + +func TestShortRefTruncatesLongRef(t *testing.T) { + got := shortRef("abcdefghijklmnopqrstuvwxyz") + if got != "abcdefghijkl" { + t.Errorf("shortRef(long) = %q; want first 12 chars 'abcdefghijkl'", got) + } + if len(got) != 12 { + t.Errorf("shortRef(long) length = %d; want 12", len(got)) + } +} + +func TestShortRefExactly12(t *testing.T) { + ref := "abcdefghijkl" // exactly 12 chars + got := shortRef(ref) + if got != ref { + t.Errorf("shortRef(exactly 12 chars) = %q; want unchanged %q", got, ref) + } +} + +// TestShortRefShorterThan12DoesNotPanic is the direct crash regression test: +// a gateway-returned ref shorter than 12 characters must not panic on an +// unguarded ref[:12] slice. +func TestShortRefShorterThan12DoesNotPanic(t *testing.T) { + got := shortRef("abc") + if got != "abc" { + t.Errorf("shortRef('abc') = %q; want 'abc' unchanged", got) + } +} + +func TestShortRefEmpty(t *testing.T) { + got := shortRef("") + if got != "" { + t.Errorf("shortRef('') = %q; want empty string", got) + } +} + // ─── colorStatus ────────────────────────────────────────────────────────────── func TestColorStatusOk(t *testing.T) { @@ -1156,6 +1193,66 @@ func TestDispatchProjectArtifactTooFewArgs(t *testing.T) { } } +// TestDispatchProjectArtifactPathTraversalRejected is an end-to-end security +// regression test: a "/project artifact" filename containing "../" segments +// must be rejected at the command boundary rather than reaching +// artifacts.Save and escaping the ~/.dojo/projects/<id> sandbox. project.Create +// and project.Switch are purely local-state (no gateway calls), so this is +// safe to drive through Dispatch in a hermetic unit test. +func TestDispatchProjectArtifactPathTraversalRejected(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + r, _ := testRegistry() + if err := r.Dispatch(context.Background(), "project init trav-test"); err != nil { + t.Fatalf("project init: %v", err) + } + + err := r.Dispatch(context.Background(), "project artifact artifacts ../../../etc/passwd pwned") + if err == nil { + t.Fatal("expected error for a path-traversal filename in 'project artifact', got nil") + } + if !strings.Contains(err.Error(), "..") { + t.Errorf("error = %v; want it to reference the rejected \"..\" segment", err) + } +} + +func TestDispatchProjectArtifactAbsolutePathRejected(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + r, _ := testRegistry() + if err := r.Dispatch(context.Background(), "project init trav-test-abs"); err != nil { + t.Fatalf("project init: %v", err) + } + + err := r.Dispatch(context.Background(), "project artifact artifacts /etc/passwd pwned") + if err == nil { + t.Fatal("expected error for an absolute-path filename in 'project artifact', got nil") + } +} + +// ─── hasParentTraversal ───────────────────────────────────────────────────────── + +func TestHasParentTraversalDetectsSegment(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"../../../etc/passwd", true}, + {"foo/../bar", true}, + {"..", true}, + {"my-file.md", false}, + {"sub/path/file.md", false}, + {"v1..2-notes", false}, // ".." substring but not a path segment — must NOT be rejected + {"", false}, + } + for _, tc := range cases { + got := hasParentTraversal(tc.path) + if got != tc.want { + t.Errorf("hasParentTraversal(%q) = %v; want %v", tc.path, got, tc.want) + } + } +} + // ─── /code error paths (no external tools needed) ──────────────────────────── func TestDispatchCodeNoArgs(t *testing.T) { diff --git a/internal/commands/project_cmds.go b/internal/commands/project_cmds.go index 887b07c..471b8e7 100644 --- a/internal/commands/project_cmds.go +++ b/internal/commands/project_cmds.go @@ -7,6 +7,7 @@ package commands import ( "context" "fmt" + "path/filepath" "strconv" "strings" @@ -18,16 +19,16 @@ import ( // projectCmd returns the /project command with all subcommands. // -// /project init <name> [--desc "..."] — create a new project, set active -// /project status [@name] — phase indicator, tracks, activity, suggestion -// /project switch <name> — change active project -// /project list [--all] — list all projects with phase indicators -// /project archive <name> — archive a completed project -// /project phase <phase> — set phase manually -// /project track add <name> [--dep N,...] — add a track to the active project -// /project track set <id> <status> — update track status -// /project decision <text> — record a decision -// /project artifact <type> <file> <content> — save an artifact +// /project init <name> [--desc "..."] — create a new project, set active +// /project status [@name] — phase indicator, tracks, activity, suggestion +// /project switch <name> — change active project +// /project list [--all] — list all projects with phase indicators +// /project archive <name> — archive a completed project +// /project phase <phase> — set phase manually +// /project track add <name> [--dep N,...] — add a track to the active project +// /project track set <id> <status> — update track status +// /project decision <text> — record a decision +// /project artifact <type> <file> <content> — save an artifact func (r *Registry) projectCmd() Command { return Command{ Name: "project", @@ -556,6 +557,15 @@ func projectArtifact(args []string) error { filename := args[1] content := strings.Join(args[2:], " ") + // Reject path traversal / absolute paths at the command boundary so the + // user gets a clear, specific error immediately. artifacts.Save enforces + // the same containment itself (defense-in-depth — this call site is not + // the only path into Save), so this check is a fast, friendlier front + // door rather than the sole guard. + if filepath.IsAbs(filename) || hasParentTraversal(filename) { + return fmt.Errorf("project artifact: filename %q must be a relative path with no \"..\" segments", filename) + } + path, err := artifacts.Save(p.ID, artifactType, filename, content) if err != nil { return fmt.Errorf("project artifact: %w", err) @@ -581,6 +591,18 @@ func projectArtifact(args []string) error { // ─── Helpers ────────────────────────────────────────────────────────────────── +// hasParentTraversal reports whether p contains a literal ".." path segment +// (as opposed to merely containing the substring ".." inside a longer name, +// e.g. "v1..2-notes", which is a legal filename and must not be rejected). +func hasParentTraversal(p string) bool { + for _, seg := range strings.Split(filepath.ToSlash(p), "/") { + if seg == ".." { + return true + } + } + return false +} + // requireActiveProject returns the active project or a clear error if none is set. func requireActiveProject() (*project.Project, error) { p, err := project.ActiveProject() diff --git a/internal/providers/providers.go b/internal/providers/providers.go index 1176cce..a9a402d 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -462,8 +462,8 @@ func chatGoogle(ctx context.Context, req DirectChatRequest) (*DirectChatResponse } url := fmt.Sprintf( - "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", - model, req.APIKey, + "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", + model, ) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) @@ -471,6 +471,13 @@ func chatGoogle(ctx context.Context, req DirectChatRequest) (*DirectChatResponse return nil, fmt.Errorf("google: build request: %w", err) } httpReq.Header.Set("content-type", "application/json") + // The API key travels in a header, not the URL query string: net/http + // embeds the full request URL — including query params — in the *url.Error + // it returns from Client.Do on a network/TLS failure, which would + // otherwise leak the live key to terminal output, logs, or CI on any + // transient error. x-goog-api-key is Google's documented header-auth + // alternative to "?key=" for the Generative Language API. + httpReq.Header.Set("x-goog-api-key", req.APIKey) client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(httpReq) From c22b3bc63e7190f400efc422a23c69444f22a541 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 04:18:04 -0500 Subject: [PATCH 06/23] fix(cli): un-brick /disposition set, telemetry opt-out, Windows hook shell /disposition set validates against the same merged preset source /disposition ls shows; config.Load degrades an invalid disposition to the default (with a warning) instead of refusing to start (a typo or file-preset name previously bricked every launch). Gateway wiring errors still hard-fail. DOJO_TELEMETRY_DISABLED disables the telemetry sink, which now discloses its endpoint once. Hook exec uses cmd /C on Windows; invalid matcher globs are logged, not silently dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/cmd_disposition.go | 10 ++ internal/config/config.go | 92 +++++++++++++-- internal/config/config_test.go | 169 +++++++++++++++++++++++++++ internal/hooks/runner.go | 32 ++++- internal/hooks/runner_test.go | 48 ++++++++ internal/telemetry/sink.go | 34 +++++- internal/telemetry/sink_test.go | 95 +++++++++++++++ 7 files changed, 466 insertions(+), 14 deletions(-) diff --git a/internal/commands/cmd_disposition.go b/internal/commands/cmd_disposition.go index ec55109..50806fc 100644 --- a/internal/commands/cmd_disposition.go +++ b/internal/commands/cmd_disposition.go @@ -80,6 +80,16 @@ func (r *Registry) dispositionList() error { } func (r *Registry) dispositionSet(name string) error { + // Validate against the same merged set /disposition ls shows (builtins + + // config profiles + file-based presets under ~/.dojo/dispositions/*.json) + // before saving. Without this, a typo'd or stale name saved fine here and + // only surfaced on the NEXT startup, when config.Load()'s stricter check + // (which doesn't know about file-based presets) rejected it and refused + // to start. + if !config.IsKnownDisposition(name, r.cfg.DispositionProfiles) { + return fmt.Errorf("unknown disposition %q — run /disposition ls to see available presets", name) + } + r.cfg.Defaults.Disposition = name _ = r.cfg.Save() activity.Log(activity.CommandRun, "disposition -> "+name) diff --git a/internal/config/config.go b/internal/config/config.go index 8f31318..ae19555 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,11 +18,11 @@ const DefaultGatewayURL = "http://localhost:7340" // Config is the dojo CLI configuration, loaded from ~/.dojo/settings.json. type Config struct { - Gateway GatewayConfig `json:"gateway"` - Plugins PluginsConfig `json:"plugins"` - Defaults DefaultsConfig `json:"defaults"` - Auth AuthConfig `json:"auth,omitempty"` - DispositionProfiles map[string]DispositionPreset `json:"disposition_profiles,omitempty"` + Gateway GatewayConfig `json:"gateway"` + Plugins PluginsConfig `json:"plugins"` + Defaults DefaultsConfig `json:"defaults"` + Auth AuthConfig `json:"auth,omitempty"` + DispositionProfiles map[string]DispositionPreset `json:"disposition_profiles,omitempty"` } type AuthConfig struct { @@ -81,13 +81,37 @@ func Load() (*Config, error) { cfg.Auth.UserID = v } - if err := cfg.Validate(); err != nil { + // Gateway settings are load-bearing for connectivity — a malformed URL + // or timeout is a genuine configuration error, so it still stops + // startup (fixing it requires hand-editing settings.json regardless). + if err := cfg.validateGateway(); err != nil { return nil, err } + // A saved disposition must NEVER brick startup — see validateDisposition + // for why Validate() alone isn't enough here. If it doesn't check out, + // try the fuller merged set (builtins + config profiles + file-based + // presets under ~/.dojo/dispositions/*.json — the same set /disposition + // ls shows) before giving up; only degrade to the default if the name is + // unknown there too. This closes the brick: setting a listed file-preset + // name used to save fine via /disposition set, then fail right here on + // the very next startup because Validate() only knows about builtins + + // DispositionProfiles, not the file-based presets /disposition ls reads. + if err := cfg.validateDisposition(); err != nil { + if !IsKnownDisposition(cfg.Defaults.Disposition, cfg.DispositionProfiles) { + fmt.Fprintf(os.Stderr, "dojo: warning: %s — falling back to default disposition %q\n", err, DefaultDisposition) + cfg.Defaults.Disposition = DefaultDisposition + } + } + return cfg, nil } +// DefaultDisposition is the disposition Load() falls back to when the saved +// value can't be resolved against any known preset. Named so defaults() and +// the degrade path in Load() can't drift apart. +const DefaultDisposition = "balanced" + // validDispositions lists the allowed values for Defaults.Disposition. var validDispositions = map[string]bool{ "": true, @@ -97,9 +121,50 @@ var validDispositions = map[string]bool{ "deliberate": true, } +// IsKnownDisposition reports whether name resolves to something real: a +// builtin, a config-resident profile in configProfiles, or a file-based +// preset under DispositionsDir(). This is the same merged set /disposition +// ls displays (LoadDispositionPresets + MergeConfigProfiles) — it is the +// single source of truth callers should use before trusting a disposition +// name. Validate() alone only knows about builtins + configProfiles and +// deliberately skips the disk read that checking file-based presets needs. +// +// A file-read error while checking presets is treated as "not known" so +// callers (Load, in particular) degrade safely instead of propagating an +// unrelated I/O error. +func IsKnownDisposition(name string, configProfiles map[string]DispositionPreset) bool { + if validDispositions[name] { + return true + } + if _, ok := configProfiles[name]; ok { + return true + } + filePresets, err := LoadDispositionPresets() + if err != nil { + return false + } + for _, p := range filePresets { + if p.Name == name { + return true + } + } + return false +} + // Validate checks that the config values are well-formed. // It returns a descriptive error for the first invalid field found. func (c *Config) Validate() error { + if err := c.validateGateway(); err != nil { + return err + } + if err := c.validateDisposition(); err != nil { + return err + } + return nil +} + +// validateGateway checks that Gateway.URL and Gateway.Timeout are well-formed. +func (c *Config) validateGateway() error { // Gateway URL must be parseable. if c.Gateway.URL != "" { if _, err := url.ParseRequestURI(c.Gateway.URL); err != nil { @@ -113,8 +178,18 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid gateway.timeout %q: %w", c.Gateway.Timeout, err) } } + return nil +} - // Disposition must be a builtin OR a known custom profile (or empty). +// validateDisposition checks that Defaults.Disposition is a builtin or a +// known config-resident custom profile (or empty). It does NOT check +// file-based presets under DispositionsDir() — that needs a disk read, which +// this function intentionally avoids so Validate() stays a pure, fast check +// that's safe to call from anywhere (including tests with no ~/.dojo on +// disk). Callers that need the fuller picture before giving up — namely +// Load(), which must never brick startup over a disposition name — should +// use IsKnownDisposition instead. +func (c *Config) validateDisposition() error { if !validDispositions[c.Defaults.Disposition] { if _, ok := c.DispositionProfiles[c.Defaults.Disposition]; !ok { return fmt.Errorf( @@ -123,7 +198,6 @@ func (c *Config) Validate() error { ) } } - return nil } @@ -206,7 +280,7 @@ func defaults() *Config { }, Defaults: DefaultsConfig{ Provider: "", - Disposition: "balanced", + Disposition: DefaultDisposition, Model: "", }, } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b5f4049..d066c36 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -391,6 +391,175 @@ func TestLoad_ModelEnvOverride(t *testing.T) { } } +// ─── Load() disposition graceful degradation (never brick startup) ────────── + +func TestLoad_UnknownDisposition_DegradesToDefault(t *testing.T) { + tmp := t.TempDir() + origHome := os.Getenv("HOME") + t.Setenv("HOME", tmp) + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end + + t.Setenv("DOJO_GATEWAY_URL", "") + t.Setenv("DOJO_GATEWAY_TOKEN", "") + t.Setenv("DOJO_PLUGINS_PATH", "") + t.Setenv("DOJO_PROVIDER", "") + t.Setenv("DOJO_DISPOSITION", "") + t.Setenv("DOJO_MODEL", "") + t.Setenv("DOJO_USER_ID", "") + + dojoDir := filepath.Join(tmp, ".dojo") + if err := os.MkdirAll(dojoDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // A disposition that resolves nowhere: not a builtin, not a config + // profile, and (no dispositions dir exists) not a file preset either. + // Before the fix, this made Load() return an error — bricking every + // subsequent `dojo` invocation until settings.json was hand-edited. + settings := map[string]any{ + "defaults": map[string]any{"disposition": "typo-d-name"}, + } + data, _ := json.Marshal(settings) + if err := os.WriteFile(filepath.Join(dojoDir, "settings.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() should degrade, not error, on an unknown disposition: %v", err) + } + if cfg.Defaults.Disposition != DefaultDisposition { + t.Errorf("Defaults.Disposition after degrade: got %q, want %q", cfg.Defaults.Disposition, DefaultDisposition) + } +} + +func TestLoad_FilePresetDisposition_ResolvesWithoutReset(t *testing.T) { + tmp := t.TempDir() + origHome := os.Getenv("HOME") + t.Setenv("HOME", tmp) + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end + + t.Setenv("DOJO_GATEWAY_URL", "") + t.Setenv("DOJO_GATEWAY_TOKEN", "") + t.Setenv("DOJO_PLUGINS_PATH", "") + t.Setenv("DOJO_PROVIDER", "") + t.Setenv("DOJO_DISPOSITION", "") + t.Setenv("DOJO_MODEL", "") + t.Setenv("DOJO_USER_ID", "") + + // A disposition saved only as a file-based preset (~/.dojo/dispositions/*.json) + // — NOT a builtin, NOT in DispositionProfiles. validateDisposition() alone + // would reject this; Load() must accept it via IsKnownDisposition instead + // of silently overwriting it with the default. This is the other half of + // the brick fix: a legitimate file-preset name must resolve cleanly, not + // just avoid a hard error. + if err := SaveDispositionPreset(DispositionPreset{ + Name: "custom-file-preset", Pacing: "swift", Depth: "concise", Tone: "direct", Initiative: "reactive", + }); err != nil { + t.Fatalf("SaveDispositionPreset() error: %v", err) + } + + dojoDir := filepath.Join(tmp, ".dojo") + settings := map[string]any{ + "defaults": map[string]any{"disposition": "custom-file-preset"}, + } + data, _ := json.Marshal(settings) + if err := os.WriteFile(filepath.Join(dojoDir, "settings.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error for a valid file-preset disposition: %v", err) + } + if cfg.Defaults.Disposition != "custom-file-preset" { + t.Errorf("Defaults.Disposition: got %q, want %q (should not have been reset to the default)", cfg.Defaults.Disposition, "custom-file-preset") + } +} + +func TestLoad_BadGatewayURL_StillHardFails(t *testing.T) { + // Gateway errors are real wiring problems and must still stop startup — + // the degrade-gracefully behavior is specific to disposition. + tmp := t.TempDir() + origHome := os.Getenv("HOME") + t.Setenv("HOME", tmp) + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end + + t.Setenv("DOJO_GATEWAY_URL", "") + t.Setenv("DOJO_GATEWAY_TOKEN", "") + t.Setenv("DOJO_PLUGINS_PATH", "") + t.Setenv("DOJO_PROVIDER", "") + t.Setenv("DOJO_DISPOSITION", "") + t.Setenv("DOJO_MODEL", "") + t.Setenv("DOJO_USER_ID", "") + + dojoDir := filepath.Join(tmp, ".dojo") + if err := os.MkdirAll(dojoDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + settings := map[string]any{ + "gateway": map[string]any{"url": "not a url"}, + } + data, _ := json.Marshal(settings) + if err := os.WriteFile(filepath.Join(dojoDir, "settings.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, err := Load(); err == nil { + t.Fatal("Load() should still fail on a malformed gateway.url — only disposition degrades") + } +} + +// ─── IsKnownDisposition ─────────────────────────────────────────────────────── + +func TestIsKnownDisposition_Builtin(t *testing.T) { + if !IsKnownDisposition("balanced", nil) { + t.Error(`IsKnownDisposition("balanced", nil) should be true — it's a builtin`) + } +} + +func TestIsKnownDisposition_Empty(t *testing.T) { + if !IsKnownDisposition("", nil) { + t.Error(`IsKnownDisposition("", nil) should be true — empty means "unset"`) + } +} + +func TestIsKnownDisposition_ConfigProfile(t *testing.T) { + profiles := map[string]DispositionPreset{ + "sprint": {Name: "sprint", Pacing: "swift", Depth: "concise", Tone: "direct", Initiative: "reactive"}, + } + if !IsKnownDisposition("sprint", profiles) { + t.Error("IsKnownDisposition should recognize a config-resident profile") + } +} + +func TestIsKnownDisposition_FilePreset(t *testing.T) { + tmp := t.TempDir() + origHome := os.Getenv("HOME") + t.Setenv("HOME", tmp) + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end + + if err := SaveDispositionPreset(DispositionPreset{ + Name: "file-only", Pacing: "measured", Depth: "thorough", Tone: "warm", Initiative: "proactive", + }); err != nil { + t.Fatalf("SaveDispositionPreset() error: %v", err) + } + + if !IsKnownDisposition("file-only", nil) { + t.Error("IsKnownDisposition should recognize a file-based preset under ~/.dojo/dispositions/") + } +} + +func TestIsKnownDisposition_Unknown(t *testing.T) { + tmp := t.TempDir() + origHome := os.Getenv("HOME") + t.Setenv("HOME", tmp) + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end + + if IsKnownDisposition("totally-made-up", nil) { + t.Error("IsKnownDisposition should reject a name that matches nothing") + } +} + // ─── EffectiveString ───────────────────────────────────────────────────────── func TestEffectiveString_ContainsAllFields(t *testing.T) { diff --git a/internal/hooks/runner.go b/internal/hooks/runner.go index 0ba9df4..dfe27ed 100644 --- a/internal/hooks/runner.go +++ b/internal/hooks/runner.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path" + "runtime" "strings" "time" @@ -107,7 +108,16 @@ func matcherMatches(matcher string, payload map[string]any) bool { if idx := strings.IndexByte(cmd, ' '); idx >= 0 { cmd = cmd[:idx] } - matched, _ := path.Match(matcher, cmd) + matched, err := path.Match(matcher, cmd) + if err != nil { + // A malformed glob (e.g. an unbalanced "[") used to fail silently + // here and the hook would just never fire, with no signal to the + // plugin author about why. Surface it instead — still treated as + // "no match" so behavior otherwise stays the same, it's just no + // longer silent. + log.Printf("[hooks] invalid matcher %q: %v", matcher, err) + return false + } return matched } @@ -179,11 +189,25 @@ func runHTTPHook(ctx context.Context, url string, payload map[string]any) error return nil } +// shellFor returns the shell executable and its "run this string" flag for +// the given GOOS. Stock Windows has no `sh` on PATH, so hook commands there +// need `cmd /C`; every other supported platform (darwin, linux, ...) uses +// the POSIX `sh -c`. Taking goos as a parameter — instead of reading +// runtime.GOOS inline — keeps the branch a pure, table-testable function. +func shellFor(goos string) (exe string, flag string) { + if goos == "windows" { + return "cmd", "/C" + } + return "sh", "-c" +} + // runCommand executes a shell command string with CLAUDE_PLUGIN_ROOT set to -// the plugin's directory. The command is passed to sh -c so it can use -// shell expansion (e.g. variable substitution, quoting). +// the plugin's directory. The command is run through sh -c (cmd /C on +// Windows — see shellFor) so it can use shell expansion (e.g. variable +// substitution, quoting). func runCommand(ctx context.Context, command, pluginRoot string) error { - cmd := exec.CommandContext(ctx, "sh", "-c", command) + exe, flag := shellFor(runtime.GOOS) + cmd := exec.CommandContext(ctx, exe, flag, command) cmd.Env = append(cmd.Environ(), "CLAUDE_PLUGIN_ROOT="+pluginRoot, ) diff --git a/internal/hooks/runner_test.go b/internal/hooks/runner_test.go index 34c44d6..f0afc91 100644 --- a/internal/hooks/runner_test.go +++ b/internal/hooks/runner_test.go @@ -451,6 +451,54 @@ func TestIfConditionFalse(t *testing.T) { } } +// ─── shellFor (Windows vs POSIX shell selection) ────────────────────────────── + +func TestShellFor(t *testing.T) { + cases := []struct { + goos string + wantExe string + wantFlag string + }{ + {"windows", "cmd", "/C"}, + {"linux", "sh", "-c"}, + {"darwin", "sh", "-c"}, + {"freebsd", "sh", "-c"}, + } + for _, tc := range cases { + exe, flag := shellFor(tc.goos) + if exe != tc.wantExe || flag != tc.wantFlag { + t.Errorf("shellFor(%q) = (%q, %q), want (%q, %q)", tc.goos, exe, flag, tc.wantExe, tc.wantFlag) + } + } +} + +// ─── matcherMatches: malformed glob is handled, not silently swallowed ─────── + +func TestMatcherMatches_BadPattern_ReturnsFalseNotPanic(t *testing.T) { + // "[" is an unterminated character class — path.Match returns + // ErrBadPattern. Before the fix this was swallowed via + // `matched, _ := path.Match(...)`, so a malformed matcher just never + // fired with zero signal to the plugin author. The observable contract + // (no match, no panic) is unchanged by the fix — what changed is that + // it's now logged instead of silently disappearing; this test pins the + // safe-default behavior since matcherMatches has no injectable logger + // to assert the log line itself against. + if matcherMatches("[", map[string]any{"command": "/garden ls"}) { + t.Error("matcherMatches with a malformed glob should return false, not match") + } +} + +func TestMatcherMatches_ValidPattern_StillWorks(t *testing.T) { + // Sanity check alongside the bad-pattern test: a well-formed glob is + // unaffected by checking the path.Match error. + if !matcherMatches("garden*", map[string]any{"command": "/garden ls"}) { + t.Error("matcherMatches(\"garden*\", .../garden ls) should match") + } + if matcherMatches("garden*", map[string]any{"command": "/health"}) { + t.Error("matcherMatches(\"garden*\", .../health) should not match") + } +} + // ─── "if" condition: env var ────────────────────────────────────────────────── func TestIfConditionEnvVar(t *testing.T) { diff --git a/internal/telemetry/sink.go b/internal/telemetry/sink.go index 9363f44..6035d43 100644 --- a/internal/telemetry/sink.go +++ b/internal/telemetry/sink.go @@ -37,11 +37,23 @@ type Sink struct { mu sync.Mutex client *http.Client done chan struct{} + disabled bool } +// telemetryNoticeOnce ensures the activation notice printed by Start (below) +// fires at most once per process, no matter how many Sinks get created or +// started — e.g. /pilot can be entered and exited more than once per session. +var telemetryNoticeOnce sync.Once + // New creates a Sink that will POST events for the given session ID. // The telemetry base URL is read from DOJO_TELEMETRY_URL or defaults to // the production worker endpoint. +// +// If DOJO_TELEMETRY_DISABLED is set (to any non-empty value), the returned +// Sink is inert: Ingest, Start, and Flush all become no-ops and nothing is +// ever sent over the network. This is the opt-out for the SSE event data +// /pilot otherwise POSTs every 5s with no other disclosure — see Start for +// the one-time notice printed when telemetry does activate. func New(sessionID string) *Sink { base := os.Getenv("DOJO_TELEMETRY_URL") if base == "" { @@ -55,12 +67,17 @@ func New(sessionID string) *Sink { buffer: make([]TelemetryEvent, 0, 64), client: &http.Client{Timeout: 10 * time.Second}, done: make(chan struct{}), + disabled: os.Getenv("DOJO_TELEMETRY_DISABLED") != "", } } // Ingest appends a telemetry event to the buffer. It never blocks the caller -// beyond the mutex acquisition. +// beyond the mutex acquisition. A no-op when telemetry is disabled — there's +// no point buffering events that Flush will never send. func (s *Sink) Ingest(eventType string, ts int64, data map[string]any) { + if s.disabled { + return + } s.mu.Lock() s.buffer = append(s.buffer, TelemetryEvent{ Type: eventType, @@ -72,7 +89,18 @@ func (s *Sink) Ingest(eventType string, ts int64, data map[string]any) { // Start launches a background goroutine that flushes the event buffer every // 5 seconds. It stops when ctx is cancelled or Close is called. +// +// If telemetry is disabled, Start is a no-op — no goroutine is launched and +// nothing is ever POSTed. Otherwise, the first time any Sink actually +// activates in this process, a one-line disclosure notice is printed to +// stderr so the periodic POSTing isn't silent. func (s *Sink) Start(ctx context.Context) { + if s.disabled { + return + } + telemetryNoticeOnce.Do(func() { + fmt.Fprintf(os.Stderr, "telemetry -> %s (DOJO_TELEMETRY_DISABLED=1 to opt out)\n", s.baseURL) + }) go func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() @@ -93,7 +121,11 @@ func (s *Sink) Start(ctx context.Context) { // Flush drains the buffer and POSTs all buffered events to the ingest // endpoint. On HTTP or network errors it logs a warning but never panics. +// A no-op returning nil when telemetry is disabled. func (s *Sink) Flush() error { + if s.disabled { + return nil + } // Swap buffer under lock so Ingest() isn't blocked during the POST. s.mu.Lock() if len(s.buffer) == 0 { diff --git a/internal/telemetry/sink_test.go b/internal/telemetry/sink_test.go index 1045a0b..cf42c8e 100644 --- a/internal/telemetry/sink_test.go +++ b/internal/telemetry/sink_test.go @@ -352,6 +352,101 @@ func TestStart_ContextCancel_Exits(t *testing.T) { } } +// ---- DOJO_TELEMETRY_DISABLED kill switch ------------------------------------- + +func TestNew_DisabledEnv_SetsDisabledField(t *testing.T) { + t.Setenv("DOJO_TELEMETRY_URL", "http://unused") + t.Setenv("DOJO_TELEMETRY_DISABLED", "1") + s := New("s-disabled") + if !s.disabled { + t.Error("New() with DOJO_TELEMETRY_DISABLED set should produce a disabled Sink") + } +} + +func TestNew_DisabledEnvUnset_NotDisabled(t *testing.T) { + t.Setenv("DOJO_TELEMETRY_URL", "http://unused") + t.Setenv("DOJO_TELEMETRY_DISABLED", "") + s := New("s-enabled") + if s.disabled { + t.Error("New() without DOJO_TELEMETRY_DISABLED should not produce a disabled Sink") + } +} + +func TestIngest_Disabled_DoesNotBuffer(t *testing.T) { + t.Setenv("DOJO_TELEMETRY_URL", "http://unused") + t.Setenv("DOJO_TELEMETRY_DISABLED", "1") + s := New("s-disabled-ingest") + + s.Ingest("should.not.buffer", 1, nil) + + s.mu.Lock() + n := len(s.buffer) + s.mu.Unlock() + if n != 0 { + t.Errorf("Ingest() on a disabled Sink should not buffer events, got %d buffered", n) + } +} + +func TestFlush_Disabled_NeverPosts(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + t.Setenv("DOJO_TELEMETRY_URL", srv.URL) + t.Setenv("DOJO_TELEMETRY_DISABLED", "1") + s := New("s-disabled-flush") + + // Populate the buffer directly (bypassing Ingest's own no-op) so this + // test isolates Flush()'s no-op as the backstop, not Ingest()'s. + s.mu.Lock() + s.buffer = append(s.buffer, TelemetryEvent{Type: "manual", Ts: 1}) + s.mu.Unlock() + + if err := s.Flush(); err != nil { + t.Errorf("Flush() on a disabled Sink should return nil, got %v", err) + } + if calls != 0 { + t.Errorf("Flush() on a disabled Sink should never POST; server got %d calls", calls) + } +} + +func TestStart_Disabled_NoGoroutineNoPost(t *testing.T) { + var ( + mu sync.Mutex + calls int + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + t.Setenv("DOJO_TELEMETRY_URL", srv.URL) + t.Setenv("DOJO_TELEMETRY_DISABLED", "1") + s := New("s-disabled-start") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s.Start(ctx) + s.Ingest("ignored", 1, nil) + + // Give a disabled ticker (if one were mistakenly started anyway) a + // window to fire before asserting nothing happened. + time.Sleep(50 * time.Millisecond) + + mu.Lock() + got := calls + mu.Unlock() + if got != 0 { + t.Errorf("Start() on a disabled Sink should never POST; server got %d calls", got) + } +} + func TestStart_ManualFlushAfterStart(t *testing.T) { var ( mu sync.Mutex From a22902f0cdb506279302662c0e1c861d3418b48b Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 04:40:20 -0500 Subject: [PATCH 07/23] feat(protocol): inject genius protocol by default + ship kata-harness at init New internal/protocol package embeds an L0 operating-discipline doc (the workspace's 8 gates as tells) and injects it once per session into chat and one-shot requests: inline-prepend for immediate effect plus a new ChatRequest.system_prompt field for when the gateway supports it. On by default; override with project ./DOJO.md or DOJO_PROTOCOL_DISABLED=1 / DOJO_PROTOCOL_PATH. /init installs kata-harness (ratified) + bring-loop/community-skills/dojo-craft and writes an overridable ~/.dojo/DOJO.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- cmd/dojo/main.go | 4 + internal/bootstrap/bootstrap.go | 51 ++++- internal/bootstrap/bootstrap_test.go | 25 +- internal/client/client.go | 6 + internal/client/client_test.go | 31 +++ internal/config/config.go | 42 ++++ internal/config/config_test.go | 122 ++++++++++ internal/protocol/protocol.go | 148 ++++++++++++ internal/protocol/protocol.md | 14 ++ internal/protocol/protocol_test.go | 331 +++++++++++++++++++++++++++ internal/repl/repl.go | 25 +- 11 files changed, 774 insertions(+), 25 deletions(-) create mode 100644 internal/protocol/protocol.go create mode 100644 internal/protocol/protocol.md create mode 100644 internal/protocol/protocol_test.go diff --git a/cmd/dojo/main.go b/cmd/dojo/main.go index 2a9b83c..d5e6ef4 100644 --- a/cmd/dojo/main.go +++ b/cmd/dojo/main.go @@ -12,6 +12,7 @@ import ( "github.com/DojoGenesis/cli/internal/client" "github.com/DojoGenesis/cli/internal/config" + "github.com/DojoGenesis/cli/internal/protocol" "github.com/DojoGenesis/cli/internal/repl" gcolor "github.com/gookit/color" ) @@ -101,6 +102,9 @@ func main() { Stream: true, WorkspaceRoot: workspaceRoot, } + // Carry the genius protocol on this single turn (prepends req.Message and + // sets req.SystemPrompt when enabled; inert under DOJO_PROTOCOL_DISABLED). + protocol.NewInjector(cfg).Apply(&req) // Record the first gateway error event. Without this a failed stream // (429 quota, 404 dead model, …) renders blank and the process exits 0, diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 7980242..a6e4989 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -13,6 +13,7 @@ import ( "github.com/DojoGenesis/cli/internal/client" "github.com/DojoGenesis/cli/internal/config" "github.com/DojoGenesis/cli/internal/ioutilx" + "github.com/DojoGenesis/cli/internal/protocol" "github.com/DojoGenesis/cli/internal/state" gcolor "github.com/gookit/color" ) @@ -27,14 +28,15 @@ type Options struct { // Result summarises what was created or skipped. type Result struct { - SettingsCreated bool - PluginsCopied int - PluginsSkipped int - DispositionsWritten int - MCPConfigWritten bool - SeedsPlanted int - SeedsSkipped int - Errors []string + SettingsCreated bool + PluginsCopied int + PluginsSkipped int + DispositionsWritten int + MCPConfigWritten bool + ProtocolOverlayWritten bool + SeedsPlanted int + SeedsSkipped int + Errors []string } // Run executes the full bootstrap sequence and prints a summary. @@ -72,6 +74,19 @@ func Run(ctx context.Context, opts Options, gw *client.Client, w io.Writer) (*Re } r.MCPConfigWritten = mcpWritten + // 4b. Protocol overlay — drop an editable ~/.dojo/DOJO.md from the embedded + // default so the operator can see and override the genius protocol. NEVER + // clobbers an existing overlay (WriteDefaultOverlay is a no-op when present), + // so we stat first to report whether this run actually wrote it. + overlayPath := filepath.Join(dojoDir, "DOJO.md") + _, overlayStatErr := os.Stat(overlayPath) + overlayExisted := overlayStatErr == nil + if err := protocol.WriteDefaultOverlay(dojoDir); err != nil { + r.Errors = append(r.Errors, "protocol overlay: "+err.Error()) + } else { + r.ProtocolOverlayWritten = !overlayExisted + } + // 5. Seeds (if gateway available) if !opts.SkipSeeds && gw != nil { planted, seedSkipped, seedErrs := plantSeeds(ctx, gw) @@ -123,10 +138,18 @@ func writeSettings(dojoDir string, opts Options) (bool, error) { return true, ioutilx.AtomicWriteFile(path, data, 0600) } -// firstPartyPlugins is the canonical list of first-party Dojo plugins. +// firstPartyPlugins is the canonical list of first-party Dojo plugins installed +// by /init. kata-harness is the ONLY ratified harness (kata-harness RATIFIED +// 2026-07-13) and must ship by default; bring-loop, community-skills, and +// dojo-craft are first-party companions that were previously omitted. All four +// live alongside the originals in CoworkPluginsByDojoGenesis/plugins/. var firstPartyPlugins = []string{ "agent-orchestration", + "bring-loop", + "community-skills", "continuous-learning", + "dojo-craft", + "kata-harness", "pretext-pdf", "skill-forge", "specification-driven-development", @@ -396,6 +419,16 @@ func printSummary(w io.Writer, r *Result) { fwf(" %s mcp.json (already exists)\n", skip) } + // Protocol is on by default and carried onto every chat/agent turn. Say so, + // and name both override paths in one line so the operator never has to hunt. + if r.ProtocolOverlayWritten { + fwf(" %s DOJO.md protocol overlay created\n", check) + } else { + fwf(" %s DOJO.md protocol overlay (already exists)\n", skip) + } + fwf(" %s genius protocol active — override with project ./DOJO.md, or DOJO_PROTOCOL_DISABLED=1 to disable\n", + gcolor.HEX("#7fb88c").Sprint("›")) + if r.SeedsPlanted > 0 { fwf(" %s %d starter seeds planted\n", check, r.SeedsPlanted) } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 6d15364..c3571ac 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -188,17 +188,19 @@ func TestCopyPlugins(t *testing.T) { opts := Options{PluginsSource: srcRoot} copied, skipped, errs := copyPlugins(dojoDir, opts) - // 2 found, 6 missing (logged as skipped with errors) - if copied != 2 { - t.Errorf("expected copied=2, got %d", copied) + // 2 provided in source; the rest of firstPartyPlugins are missing and get + // logged as skipped-with-errors. Derive the counts from the canonical list + // so adding a first-party plugin never silently breaks this test. + const provided = 2 // agent-orchestration, skill-forge + missing := len(firstPartyPlugins) - provided + if copied != provided { + t.Errorf("expected copied=%d, got %d", provided, copied) } - // 6 plugins not present in source → skipped - if skipped != 6 { - t.Errorf("expected skipped=6 (missing sources), got %d", skipped) + if skipped != missing { + t.Errorf("expected skipped=%d (missing sources), got %d", missing, skipped) } - // 6 errors about missing sources - if len(errs) != 6 { - t.Errorf("expected 6 source-not-found errors, got %d: %v", len(errs), errs) + if len(errs) != missing { + t.Errorf("expected %d source-not-found errors, got %d: %v", missing, len(errs), errs) } // Verify the two copied plugins landed correctly. @@ -230,8 +232,9 @@ func TestCopyPluginsSkipExisting(t *testing.T) { if copied != 0 { t.Errorf("expected copied=0, got %d", copied) } - // 1 skipped (exists) + 7 missing from source - expectedSkipped := 8 + // 1 skipped (dst exists) + the remaining firstPartyPlugins missing from + // source = the whole list. Derive from the canonical list, not a literal. + expectedSkipped := len(firstPartyPlugins) if skipped != expectedSkipped { t.Errorf("expected skipped=%d, got %d", expectedSkipped, skipped) } diff --git a/internal/client/client.go b/internal/client/client.go index 24ede8e..178bb91 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -402,6 +402,12 @@ type ChatRequest struct { UserID string `json:"user_id,omitempty"` ProjectID string `json:"project_id,omitempty"` WorkspaceRoot string `json:"workspace_root,omitempty"` // User's CWD for file tool path resolution + // SystemPrompt carries session-level operating context (e.g. the genius + // protocol). Forward-compat: today's gateway /v1/chat has no system_prompt + // field and ignores it, so callers that need immediate effect also inline + // the context into the first Message (see internal/protocol.Injector). Once + // the gateway honors this field, the inline prepend can be retired. + SystemPrompt string `json:"system_prompt,omitempty"` } // SSEChunk is a parsed line from the SSE stream. diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 70fa6e0..da98342 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -1148,3 +1148,34 @@ func TestFriendlyError_Nil(t *testing.T) { t.Errorf("nil error: got %q, want empty", msg) } } + +// ─── ChatRequest.SystemPrompt wire field ───────────────────────────────────── + +func TestChatRequest_SystemPrompt_Marshals(t *testing.T) { + req := ChatRequest{ + Message: "hello", + SessionID: "sess-1", + SystemPrompt: "GENIUS PROTOCOL DOC", + } + b, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got := string(b) + if !strings.Contains(got, `"system_prompt":"GENIUS PROTOCOL DOC"`) { + t.Errorf("expected system_prompt in wire body, got: %s", got) + } +} + +func TestChatRequest_SystemPrompt_OmittedWhenEmpty(t *testing.T) { + // omitempty: a request that carries no protocol context must not emit the + // field, so existing callers and the current gateway see no change. + req := ChatRequest{Message: "hello", SessionID: "sess-1"} + b, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(b), "system_prompt") { + t.Errorf("empty SystemPrompt should be omitted, got: %s", string(b)) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index ae19555..19d20da 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,10 +21,23 @@ type Config struct { Gateway GatewayConfig `json:"gateway"` Plugins PluginsConfig `json:"plugins"` Defaults DefaultsConfig `json:"defaults"` + Protocol ProtocolConfig `json:"protocol"` Auth AuthConfig `json:"auth,omitempty"` DispositionProfiles map[string]DispositionPreset `json:"disposition_profiles,omitempty"` } +// ProtocolConfig controls the workspace "genius protocol" carried onto every +// chat/agent turn. Enabled defaults to TRUE (set in defaults(), merged over by +// any settings.json value): because defaults() pre-seeds true and json.Unmarshal +// only overwrites keys that are present, a settings.json that omits the whole +// block — or omits just "enabled" — keeps the protocol on. Path optionally +// points at an explicit override doc; empty means "resolve ./DOJO.md, then +// ~/.dojo/DOJO.md, then the embedded default". +type ProtocolConfig struct { + Enabled bool `json:"enabled"` + Path string `json:"path,omitempty"` +} + type AuthConfig struct { UserID string `json:"user_id,omitempty"` } @@ -80,6 +93,14 @@ func Load() (*Config, error) { if v := os.Getenv("DOJO_USER_ID"); v != "" { cfg.Auth.UserID = v } + // DOJO_PROTOCOL_DISABLED: any non-empty value turns the protocol off. This + // is the escape hatch that must work even when settings.json says enabled. + if v := os.Getenv("DOJO_PROTOCOL_DISABLED"); v != "" { + cfg.Protocol.Enabled = false + } + if v := os.Getenv("DOJO_PROTOCOL_PATH"); v != "" { + cfg.Protocol.Path = v + } // Gateway settings are load-bearing for connectivity — a malformed URL // or timeout is a genuine configuration error, so it still stops @@ -160,6 +181,18 @@ func (c *Config) Validate() error { if err := c.validateDisposition(); err != nil { return err } + if err := c.validateProtocol(); err != nil { + return err + } + return nil +} + +// validateProtocol is intentionally lenient: the protocol has no fail-hard +// conditions. A missing or unreadable override Path degrades to the embedded +// default at build-context time (see protocol.BuildSystemContext) rather than +// bricking startup — mirroring the disposition degrade philosophy. Kept as a +// hook so future hard constraints have a home without re-touching Validate. +func (c *Config) validateProtocol() error { return nil } @@ -216,6 +249,8 @@ func (c *Config) EffectiveString() string { fmt.Fprintf(&b, "defaults.model = %s\n", defaultIfEmpty(c.Defaults.Model, "(not set)")) fmt.Fprintf(&b, "defaults.disposition = %s\n", defaultIfEmpty(c.Defaults.Disposition, "(not set)")) fmt.Fprintf(&b, "plugins.path = %s\n", c.Plugins.Path) + fmt.Fprintf(&b, "protocol.enabled = %t\n", c.Protocol.Enabled) + fmt.Fprintf(&b, "protocol.path = %s\n", defaultIfEmpty(c.Protocol.Path, "(embedded default)")) fmt.Fprintf(&b, "auth.user_id = %s\n", defaultIfEmpty(c.Auth.UserID, "(not set)")) return b.String() } @@ -283,5 +318,12 @@ func defaults() *Config { Disposition: DefaultDisposition, Model: "", }, + // Protocol on by default. Pre-seeding true here is what makes the + // "absent block / absent enabled key stays enabled" merge behavior work + // (see ProtocolConfig doc) — a settings.json must say enabled:false, or + // DOJO_PROTOCOL_DISABLED must be set, to turn it off. + Protocol: ProtocolConfig{ + Enabled: true, + }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d066c36..1168546 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -595,6 +595,128 @@ func TestEffectiveString_ContainsAllFields(t *testing.T) { } } +// ─── Protocol block ────────────────────────────────────────────────────────── + +// clearProtocolEnv wipes every DOJO_* knob so a stray shell value can't skew a +// Load() under test, and points HOME at a fresh temp so there is no settings.json. +func clearProtocolEnv(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + for _, k := range []string{ + "DOJO_GATEWAY_URL", "DOJO_GATEWAY_TOKEN", "DOJO_PLUGINS_PATH", + "DOJO_PROVIDER", "DOJO_DISPOSITION", "DOJO_MODEL", "DOJO_USER_ID", + "DOJO_PROTOCOL_DISABLED", "DOJO_PROTOCOL_PATH", + } { + t.Setenv(k, "") + } +} + +func TestLoad_Protocol_EnabledByDefault(t *testing.T) { + clearProtocolEnv(t) + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if !cfg.Protocol.Enabled { + t.Error("Protocol.Enabled should default to true when settings.json omits the block") + } + if cfg.Protocol.Path != "" { + t.Errorf("Protocol.Path should default empty, got %q", cfg.Protocol.Path) + } +} + +func TestLoad_Protocol_EnvDisables(t *testing.T) { + clearProtocolEnv(t) + t.Setenv("DOJO_PROTOCOL_DISABLED", "1") + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.Enabled { + t.Error("DOJO_PROTOCOL_DISABLED=1 should disable the protocol") + } +} + +func TestLoad_Protocol_EnvPathOverride(t *testing.T) { + clearProtocolEnv(t) + t.Setenv("DOJO_PROTOCOL_PATH", "/tmp/custom-protocol.md") + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.Path != "/tmp/custom-protocol.md" { + t.Errorf("Protocol.Path: got %q, want %q", cfg.Protocol.Path, "/tmp/custom-protocol.md") + } + // Path override alone must not flip Enabled off. + if !cfg.Protocol.Enabled { + t.Error("setting DOJO_PROTOCOL_PATH should leave the protocol enabled") + } +} + +// A settings.json that omits "enabled" inside a present "protocol" block must +// still resolve to enabled — the defaults()+merge contract that ProtocolConfig +// depends on. This is the subtle case that a plain zero-value bool would break. +func TestLoad_Protocol_PresentBlockWithoutEnabled_StaysOn(t *testing.T) { + clearProtocolEnv(t) + dojoDir := filepath.Join(os.Getenv("HOME"), ".dojo") + if err := os.MkdirAll(dojoDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + settings := map[string]any{ + "protocol": map[string]any{"path": "/some/where.md"}, // no "enabled" key + } + data, _ := json.Marshal(settings) + if err := os.WriteFile(filepath.Join(dojoDir, "settings.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if !cfg.Protocol.Enabled { + t.Error("a protocol block without an 'enabled' key must keep the default (true)") + } + if cfg.Protocol.Path != "/some/where.md" { + t.Errorf("Protocol.Path from settings: got %q", cfg.Protocol.Path) + } +} + +func TestLoad_Protocol_SettingsDisables(t *testing.T) { + clearProtocolEnv(t) + dojoDir := filepath.Join(os.Getenv("HOME"), ".dojo") + if err := os.MkdirAll(dojoDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + settings := map[string]any{ + "protocol": map[string]any{"enabled": false}, + } + data, _ := json.Marshal(settings) + if err := os.WriteFile(filepath.Join(dojoDir, "settings.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.Enabled { + t.Error(`settings.json "protocol":{"enabled":false} should disable`) + } +} + +func TestEffectiveString_IncludesProtocol(t *testing.T) { + cfg := &Config{ + Gateway: GatewayConfig{URL: "http://localhost:7340", Timeout: "60s"}, + Defaults: DefaultsConfig{Disposition: "balanced"}, + Protocol: ProtocolConfig{Enabled: true}, + } + out := cfg.EffectiveString() + for _, want := range []string{"protocol.enabled = true", "protocol.path = (embedded default)"} { + if !strings.Contains(out, want) { + t.Errorf("EffectiveString() missing %q\ngot:\n%s", want, out) + } + } +} + // ─── Auth.UserID env override ─────────────────────────────────────────────── func TestLoad_UserIDEnvOverride(t *testing.T) { diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..67c93ce --- /dev/null +++ b/internal/protocol/protocol.go @@ -0,0 +1,148 @@ +// Package protocol carries the workspace's "genius protocol" — a compact, +// behavior-changing operating doctrine — onto every dojo chat/agent turn by +// default, overridably, without prompt bloat. +// +// The L0 doc (protocol.md) is embedded at build time and served by DefaultDoc. +// An operator can override it per project (./DOJO.md), per machine +// (~/.dojo/DOJO.md), or by an explicit path, and can disable it entirely via +// config or DOJO_PROTOCOL_DISABLED. Injection is done once per session: the doc +// leads the first turn's message (for immediate effect against a gateway that +// has no system_prompt field) and is also set on ChatRequest.SystemPrompt for +// forward-compat once the gateway grows the field. +package protocol + +import ( + "bytes" + _ "embed" + "fmt" + "os" + "path/filepath" + + "github.com/DojoGenesis/cli/internal/client" + "github.com/DojoGenesis/cli/internal/config" +) + +// defaultDoc is the embedded L0 genius-protocol doc. Authored in protocol.md so +// it can be edited as prose and refined without touching Go; go:embed folds it +// into the binary at build time so the CLI never depends on the file at runtime. +// +//go:embed protocol.md +var defaultDoc string + +// overlayFilename is the basename of a protocol override file, looked up in the +// project cwd first, then the ~/.dojo dir. +const overlayFilename = "DOJO.md" + +// injectDelimiter separates the prepended protocol context from the user's +// message on the first turn. It mirrors the inline-prepend style used by +// /craft (cmd_craft.go) so the gateway sees a consistent shape. +const injectDelimiter = "\n\n---\n\n" + +// DefaultDoc returns the embedded L0 genius-protocol doc. +func DefaultDoc() string { + return defaultDoc +} + +// LoadOverlay resolves the active protocol doc by precedence: +// +// project ./DOJO.md > dojoDir/DOJO.md > DefaultDoc() +// +// It returns the doc text and a human-readable source label (the file path, or +// "(embedded default)"). An empty or whitespace-only override file is treated +// as absent so a stray empty DOJO.md can never blank out the protocol. A cwd or +// dojoDir of "" is skipped rather than joined, so callers may pass "" to opt a +// tier out of the search. +func LoadOverlay(cwd, dojoDir string) (doc string, source string) { + for _, dir := range []string{cwd, dojoDir} { + if dir == "" { + continue + } + path := filepath.Join(dir, overlayFilename) + if b, err := os.ReadFile(path); err == nil && len(bytes.TrimSpace(b)) > 0 { + return string(b), path + } + } + return DefaultDoc(), "(embedded default)" +} + +// BuildSystemContext returns the protocol context that should lead a session, +// or "" when the protocol is disabled (cfg nil or Protocol.Enabled false) — an +// empty return is the signal to inject nothing. +// +// Precedence when enabled: an explicit cfg.Protocol.Path (readable, non-empty) +// wins outright; otherwise LoadOverlay resolves project ./DOJO.md, then +// ~/.dojo/DOJO.md, then the embedded default. An unreadable explicit path falls +// through to overlay resolution rather than erroring — the protocol degrades to +// the default instead of bricking a turn. +func BuildSystemContext(cfg *config.Config) string { + if cfg == nil || !cfg.Protocol.Enabled { + return "" + } + if p := cfg.Protocol.Path; p != "" { + if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 { + return string(b) + } + } + cwd, _ := os.Getwd() + doc, _ := LoadOverlay(cwd, config.DojoDir()) + return doc +} + +// WriteDefaultOverlay writes dojoDir/DOJO.md from DefaultDoc() only if the file +// does not already exist — it NEVER clobbers an operator's edited overlay. A +// nil error with no write happens when the file is already present. Called by +// /init so a fresh workspace gets a visible, editable copy of the protocol. +func WriteDefaultOverlay(dojoDir string) error { + if dojoDir == "" { + return fmt.Errorf("protocol: WriteDefaultOverlay: empty dojoDir") + } + path := filepath.Join(dojoDir, overlayFilename) + if _, err := os.Stat(path); err == nil { + return nil // already exists — never clobber + } else if !os.IsNotExist(err) { + return err // a real stat error (permissions, etc.) — surface it + } + if err := os.MkdirAll(dojoDir, 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(DefaultDoc()), 0o644) +} + +// Injector stamps the protocol onto outgoing ChatRequests exactly once per +// session. Construct one per session with NewInjector; call Apply on every turn +// — it is a no-op after the first stamp and when the protocol is disabled. +type Injector struct { + context string + injected bool +} + +// NewInjector builds a session Injector from config. When the protocol is +// disabled the resulting Injector's context is empty and Apply is inert. +func NewInjector(cfg *config.Config) *Injector { + return &Injector{context: BuildSystemContext(cfg)} +} + +// Enabled reports whether Apply would inject anything (protocol on and context +// resolved). False once the single stamp has been spent. +func (in *Injector) Enabled() bool { + return in != nil && in.context != "" && !in.injected +} + +// Apply stamps the protocol onto req exactly once. On the first call with a +// non-empty context it prepends the doc to req.Message (immediate effect — the +// gateway /v1/chat endpoint has no system_prompt field yet) AND sets +// req.SystemPrompt (forward-compat, harmless if the gateway ignores it). It +// returns true only on the call that actually stamped. +// +// Later turns are deliberately left untouched: the gateway threads session +// context by SessionID, so the protocol only needs to lead the first turn — +// re-prepending every turn would be pure prompt bloat. +func (in *Injector) Apply(req *client.ChatRequest) bool { + if in == nil || in.context == "" || in.injected || req == nil { + return false + } + req.SystemPrompt = in.context + req.Message = in.context + injectDelimiter + req.Message + in.injected = true + return true +} diff --git a/internal/protocol/protocol.md b/internal/protocol/protocol.md new file mode 100644 index 0000000..d7f8dfa --- /dev/null +++ b/internal/protocol/protocol.md @@ -0,0 +1,14 @@ +# Dojo Genius Protocol + +You operate under this discipline by default, every session — the workspace's hard-won contract for turning plausible work into verified work. Project `./DOJO.md` overrides it; `DOJO_PROTOCOL_DISABLED=1` turns it off. Each line names a *tell* — the signature of a failure already forming. See the tell, apply the discipline. + +1. **Done means verified.** After any change or dispatch, run the build and tests and diff the result against what was asked. "Done" and a green test are claims, not proof — never report done on a diff you haven't exercised. +2. **Debug by disproof.** Before any fix, state the causal chain and name the cheapest experiment that toggles the bug on and off. Can't state it → observe, don't patch. A fix you can't explain is a guess, not a fix. +3. **Config or code?** Total + input-independent failure (fails every time, at a boundary — 401, ECONNREFUSED, nil-on-startup) → wiring: check settings / env / `.env` FIRST. Partial + input-dependent (works for X, fails for Y) → logic. Config errors present as code errors — the discriminator is which one you're looking at. +4. **3+ files → orchestrate.** Multi-file work goes to parallel agents: define the interface contracts first, dispatch, and gate every wave on a clean build before integrating. Never two agents writing one file. +5. **Bookend the session.** Open by recalling relevant memory; close by storing one insight per memory, seeding any reusable pattern, and reflecting finished work back to the tracker. A session that leaves no trace repeats itself. +6. **Match the channel to the size.** Routine findings return inline to the caller — never a `report.md` sink. Huge or reusable deliverables go to a FILE plus a one-line path in chat. +7. **Trust memory content, not its count.** Store one insight per memory. Search is lexical: an empty result can mean wrong words, not "never stored" — retry with the target's own vocabulary. A hit's score always reads 1.0; read the content, never the number. +8. **Arrest drift first.** When uncommitted state piles up, converge — commit, checkpoint, or discard — before starting new features. Drift compounds silently. + +Full doctrine: workspace `CLAUDE.md` (Debugging Protocol · Operating Gates · Harness Rails). To drill a discipline under load, run a **kata-harness** roll. diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go new file mode 100644 index 0000000..7405baa --- /dev/null +++ b/internal/protocol/protocol_test.go @@ -0,0 +1,331 @@ +package protocol + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DojoGenesis/cli/internal/client" + "github.com/DojoGenesis/cli/internal/config" +) + +// hermetic isolates a test from the developer's real HOME and cwd so overlay +// resolution sees an empty world: HOME points at a fresh temp (so +// config.DojoDir()/DOJO.md never exists) and cwd is a fresh temp (so ./DOJO.md +// never exists unless the test writes one). Returns the cwd temp dir. t.Chdir +// and t.Setenv both auto-restore at test end, so this cannot be run in parallel. +func hermetic(t *testing.T) string { + t.Helper() + t.Setenv("HOME", t.TempDir()) + cwd := t.TempDir() + t.Chdir(cwd) + return cwd +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// ─── DefaultDoc (embed) ─────────────────────────────────────────────────────── + +func TestDefaultDoc_ContainsCoreTells(t *testing.T) { + doc := DefaultDoc() + if strings.TrimSpace(doc) == "" { + t.Fatal("DefaultDoc() is empty — go:embed of protocol.md failed") + } + for _, want := range []string{ + "Dojo Genius Protocol", // heading + "Done means verified", // element 1 + "Debug by disproof", // element 2 + "kata-harness", // ratified harness pointer + "DOJO_PROTOCOL_DISABLED", // override hint + } { + if !strings.Contains(doc, want) { + t.Errorf("DefaultDoc() missing %q", want) + } + } +} + +// ─── LoadOverlay precedence ─────────────────────────────────────────────────── + +func TestLoadOverlay_Precedence_ProjectOverDojoOverDefault(t *testing.T) { + proj := t.TempDir() + dojo := t.TempDir() + + // Neither present → embedded default. + doc, src := LoadOverlay(proj, dojo) + if doc != DefaultDoc() { + t.Errorf("no overlay: expected default doc, got %d bytes", len(doc)) + } + if src != "(embedded default)" { + t.Errorf("no overlay: expected source '(embedded default)', got %q", src) + } + + // dojoDir overlay present → beats default. + dojoDoc := "# Machine Overlay\nmachine rules" + writeFile(t, filepath.Join(dojo, "DOJO.md"), dojoDoc) + doc, src = LoadOverlay(proj, dojo) + if doc != dojoDoc { + t.Errorf("dojoDir overlay: got %q, want %q", doc, dojoDoc) + } + if src != filepath.Join(dojo, "DOJO.md") { + t.Errorf("dojoDir overlay: source label %q", src) + } + + // project overlay present → beats dojoDir. + projDoc := "# Project Overlay\nproject rules" + writeFile(t, filepath.Join(proj, "DOJO.md"), projDoc) + doc, src = LoadOverlay(proj, dojo) + if doc != projDoc { + t.Errorf("project overlay should win: got %q, want %q", doc, projDoc) + } + if src != filepath.Join(proj, "DOJO.md") { + t.Errorf("project overlay: source label %q", src) + } +} + +func TestLoadOverlay_EmptyFileIgnored(t *testing.T) { + proj := t.TempDir() + // A whitespace-only overlay must be treated as absent — a stray empty + // DOJO.md can never blank out the protocol. + writeFile(t, filepath.Join(proj, "DOJO.md"), " \n\t\n") + doc, _ := LoadOverlay(proj, "") + if doc != DefaultDoc() { + t.Error("whitespace-only overlay should be ignored, falling back to default") + } +} + +// ─── BuildSystemContext ─────────────────────────────────────────────────────── + +func TestBuildSystemContext_DisabledReturnsEmpty(t *testing.T) { + cfg := &config.Config{Protocol: config.ProtocolConfig{Enabled: false}} + if got := BuildSystemContext(cfg); got != "" { + t.Errorf("disabled config: expected empty, got %d bytes", len(got)) + } + if got := BuildSystemContext(nil); got != "" { + t.Errorf("nil config: expected empty, got %d bytes", len(got)) + } +} + +func TestBuildSystemContext_EnabledReturnsDefault(t *testing.T) { + hermetic(t) // empty HOME + cwd → no overlay files anywhere + cfg := &config.Config{Protocol: config.ProtocolConfig{Enabled: true}} + if got := BuildSystemContext(cfg); got != DefaultDoc() { + t.Error("enabled with no overlay should return the embedded default") + } +} + +func TestBuildSystemContext_ExplicitPathWins(t *testing.T) { + cwd := hermetic(t) + // A cwd overlay exists but must be BEATEN by an explicit Path. + writeFile(t, filepath.Join(cwd, "DOJO.md"), "# cwd overlay (should lose)") + + pathDir := t.TempDir() + explicit := filepath.Join(pathDir, "custom-protocol.md") + writeFile(t, explicit, "# Explicit Path Wins") + + cfg := &config.Config{Protocol: config.ProtocolConfig{Enabled: true, Path: explicit}} + if got := BuildSystemContext(cfg); got != "# Explicit Path Wins" { + t.Errorf("explicit Path should win over cwd overlay, got %q", got) + } +} + +func TestBuildSystemContext_UnreadablePathFallsThrough(t *testing.T) { + cwd := hermetic(t) + projDoc := "# Project Overlay Fallback" + writeFile(t, filepath.Join(cwd, "DOJO.md"), projDoc) + + // Path points at a file that does not exist → must degrade to overlay + // resolution (project DOJO.md), not error or blank. + cfg := &config.Config{Protocol: config.ProtocolConfig{ + Enabled: true, + Path: filepath.Join(t.TempDir(), "nope.md"), + }} + if got := BuildSystemContext(cfg); got != projDoc { + t.Errorf("unreadable Path should fall through to project overlay, got %q", got) + } +} + +// ─── WriteDefaultOverlay ────────────────────────────────────────────────────── + +func TestWriteDefaultOverlay_WritesThenDoesNotClobber(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "DOJO.md") + + // First call writes the embedded default. + if err := WriteDefaultOverlay(dir); err != nil { + t.Fatalf("first WriteDefaultOverlay: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read overlay after write: %v", err) + } + if string(got) != DefaultDoc() { + t.Fatal("first write should equal DefaultDoc()") + } + + // Operator edits it. + edited := "# My Edited Protocol\nmine, not yours" + writeFile(t, path, edited) + + // Second call must NOT clobber the edit. + if err := WriteDefaultOverlay(dir); err != nil { + t.Fatalf("second WriteDefaultOverlay: %v", err) + } + got2, _ := os.ReadFile(path) + if string(got2) != edited { + t.Error("WriteDefaultOverlay clobbered an existing operator overlay") + } +} + +func TestWriteDefaultOverlay_EmptyDirErrors(t *testing.T) { + if err := WriteDefaultOverlay(""); err == nil { + t.Error("expected an error for empty dojoDir") + } +} + +// ─── Injector: the request-builder proof (no live model) ────────────────────── + +func TestInjector_Apply_StampsOnceWhenEnabled(t *testing.T) { + hermetic(t) + cfg := &config.Config{Protocol: config.ProtocolConfig{Enabled: true}} + inj := NewInjector(cfg) + if !inj.Enabled() { + t.Fatal("expected injector to be enabled") + } + + // First turn: the outgoing ChatRequest must carry the protocol in BOTH + // Message (immediate effect) and SystemPrompt (forward-compat), with the + // user's original message preserved at the tail. + req := client.ChatRequest{Message: "hello world"} + if !inj.Apply(&req) { + t.Fatal("expected first Apply to stamp") + } + if !strings.Contains(req.Message, "Dojo Genius Protocol") { + t.Error("first-turn Message should contain the protocol text") + } + if !strings.HasSuffix(req.Message, "hello world") { + t.Error("first-turn Message should end with the original user message") + } + if req.SystemPrompt != DefaultDoc() { + t.Error("first-turn SystemPrompt should equal the resolved protocol doc") + } + + // Second turn: no-op. The gateway threads session context by SessionID, so + // re-prepending would be pure bloat. + req2 := client.ChatRequest{Message: "second message"} + if inj.Apply(&req2) { + t.Fatal("expected second Apply to be a no-op") + } + if req2.Message != "second message" { + t.Errorf("second-turn Message should be untouched, got %q", req2.Message) + } + if req2.SystemPrompt != "" { + t.Error("second-turn SystemPrompt should stay empty") + } + if inj.Enabled() { + t.Error("Enabled() should be false after the single stamp is spent") + } +} + +func TestInjector_Apply_NoopWhenDisabled(t *testing.T) { + cfg := &config.Config{Protocol: config.ProtocolConfig{Enabled: false}} + inj := NewInjector(cfg) + if inj.Enabled() { + t.Fatal("expected injector disabled") + } + req := client.ChatRequest{Message: "hello"} + if inj.Apply(&req) { + t.Fatal("disabled injector must not stamp") + } + if req.Message != "hello" { + t.Errorf("Message should be untouched when disabled, got %q", req.Message) + } + if req.SystemPrompt != "" { + t.Error("SystemPrompt should stay empty when disabled") + } +} + +// TestInjector_CwdOverlayOverridesDefault proves a cwd ./DOJO.md overrides the +// embedded default in the actually-injected request — the override path that +// matters for real sessions. +func TestInjector_CwdOverlayOverridesDefault(t *testing.T) { + cwd := hermetic(t) + custom := "# Custom Project Protocol\nDo the custom thing." + writeFile(t, filepath.Join(cwd, "DOJO.md"), custom) + + cfg := &config.Config{Protocol: config.ProtocolConfig{Enabled: true}} + req := client.ChatRequest{Message: "hi"} + if !NewInjector(cfg).Apply(&req) { + t.Fatal("expected stamp") + } + if req.SystemPrompt != custom { + t.Errorf("cwd overlay should win: SystemPrompt = %q", req.SystemPrompt) + } + if !strings.Contains(req.Message, "Custom Project Protocol") { + t.Error("Message should carry the cwd overlay text") + } + if strings.Contains(req.Message, "Done means verified") { + t.Error("embedded default must not appear once the cwd overlay overrides it") + } +} + +// TestBuildSystemContext_EnvDisabled_Empty ties the DOJO_PROTOCOL_DISABLED env +// override (resolved by config.Load) to an empty injection context — proving the +// escape hatch reaches all the way through the request builder. +func TestBuildSystemContext_EnvDisabled_Empty(t *testing.T) { + hermetic(t) + // Clear the other DOJO_* knobs so a stray shell value can't skew Load(). + for _, k := range []string{ + "DOJO_GATEWAY_URL", "DOJO_GATEWAY_TOKEN", "DOJO_PLUGINS_PATH", + "DOJO_PROVIDER", "DOJO_DISPOSITION", "DOJO_MODEL", "DOJO_USER_ID", + "DOJO_PROTOCOL_PATH", + } { + t.Setenv(k, "") + } + t.Setenv("DOJO_PROTOCOL_DISABLED", "1") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("config.Load: %v", err) + } + if cfg.Protocol.Enabled { + t.Fatal("DOJO_PROTOCOL_DISABLED=1 should disable the protocol") + } + if got := BuildSystemContext(cfg); got != "" { + t.Errorf("disabled via env: expected empty context, got %d bytes", len(got)) + } + // And the injector built from it is inert. + req := client.ChatRequest{Message: "x"} + if NewInjector(cfg).Apply(&req) || req.Message != "x" { + t.Error("env-disabled injector should be inert") + } +} + +// TestBuildSystemContext_EnabledByDefault confirms the default-ON posture: a +// config with no protocol block set at all (as when settings.json omits it and +// defaults() pre-seeds Enabled) still injects. +func TestBuildSystemContext_EnabledByDefaultViaLoad(t *testing.T) { + hermetic(t) + for _, k := range []string{ + "DOJO_GATEWAY_URL", "DOJO_GATEWAY_TOKEN", "DOJO_PLUGINS_PATH", + "DOJO_PROVIDER", "DOJO_DISPOSITION", "DOJO_MODEL", "DOJO_USER_ID", + "DOJO_PROTOCOL_DISABLED", "DOJO_PROTOCOL_PATH", + } { + t.Setenv(k, "") + } + cfg, err := config.Load() // no settings.json in the temp HOME → pure defaults + if err != nil { + t.Fatalf("config.Load: %v", err) + } + if !cfg.Protocol.Enabled { + t.Fatal("protocol should be enabled by default") + } + if got := BuildSystemContext(cfg); got != DefaultDoc() { + t.Error("default-enabled config should inject the embedded default doc") + } +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 9f3feca..5a41a7d 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -21,6 +21,7 @@ import ( "github.com/DojoGenesis/cli/internal/guide" "github.com/DojoGenesis/cli/internal/hooks" "github.com/DojoGenesis/cli/internal/plugins" + "github.com/DojoGenesis/cli/internal/protocol" "github.com/DojoGenesis/cli/internal/providers" "github.com/DojoGenesis/cli/internal/spirit" "github.com/DojoGenesis/cli/internal/state" @@ -39,6 +40,12 @@ type REPL struct { resumed bool // true when session was restored via --resume or /session resume plain bool // true when --plain or --no-color is set; uses unstyled renderer output + // protocol injects the workspace genius protocol into the first chat turn of + // the session (and sets ChatRequest.SystemPrompt). Once-per-session, request + // context only — never rendered into output. nil-safe: Apply is inert when + // the protocol is disabled. + protocol *protocol.Injector + mu sync.Mutex // guards turnCancel turnCancel context.CancelFunc // cancels the in-flight streaming turn; nil when idle } @@ -86,11 +93,14 @@ func New(cfg *config.Config, gw *client.Client, resume bool, plain bool) *REPL { } r := &REPL{ - cfg: cfg, - gw: gw, - turns: 0, - resumed: false, - plain: plain, + cfg: cfg, + gw: gw, + turns: 0, + resumed: false, + plain: plain, + // Resolve the protocol context once at session start (project ./DOJO.md + // > ~/.dojo/DOJO.md > embedded default; empty when disabled). + protocol: protocol.NewInjector(cfg), } if resume { @@ -527,6 +537,11 @@ func (r *REPL) chat(ctx context.Context, message string) error { Stream: true, WorkspaceRoot: workspaceRoot, } + // Carry the genius protocol on the FIRST turn only (once-per-session guard + // lives in the Injector). This mutates req.Message (immediate effect) and + // sets req.SystemPrompt (forward-compat) before the request goes out; it is + // request context, never echoed into the rendered response. + r.protocol.Apply(&req) // Derive a per-turn context so a SIGINT cancels THIS response only (the // watcher in Run calls cancelActiveTurn); the session's base ctx is untouched. From 7534fc9aebfa4e1c8beb2fd2f9f5be78dfdbc810 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 04:40:20 -0500 Subject: [PATCH 08/23] fix(plugins): parse Claude-Code hook schema so harness hooks load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hooks.json using the Claude-Code wrapper shape ({hooks:{...}}) — e.g. kata-harness — previously threw a swallowed unmarshal error and loaded zero rules. Now both wrapper and flat schemas parse; CC event names map to dojo's five (PreToolUse->PreCommand, PostToolUse->PostCommand, SubagentStop->PostAgent, SessionEnd->SessionEnd); events with no dojo equivalent are skipped with a logged note, not guessed to the wrong lifecycle point. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/plugins/scanner.go | 129 +++++++++++++++++++-- internal/plugins/scanner_test.go | 192 +++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 7 deletions(-) diff --git a/internal/plugins/scanner.go b/internal/plugins/scanner.go index a35b955..eee390f 100644 --- a/internal/plugins/scanner.go +++ b/internal/plugins/scanner.go @@ -6,6 +6,7 @@ import ( "log" "os" "path/filepath" + "strings" ) // Plugin holds the metadata and hook rules discovered for a single plugin. @@ -130,8 +131,41 @@ func loadPluginMeta(dir string) (pluginMeta, bool) { return pluginMeta{}, false } -// loadHooks reads hooks/hooks.json and converts the map-of-event format into []HookRule. -// Format: { "EventName": [ { "matcher": "...", "if": "...", "hooks": [...] } ] } +// Dojo-cli's own hook event names, duplicated here (not imported) because +// internal/hooks already imports internal/plugins — importing the other +// direction would create a cycle. Keep these in sync with the Event* +// constants in internal/hooks/runner.go. +const ( + dojoEventPreCommand = "PreCommand" + dojoEventPostCommand = "PostCommand" + dojoEventPostSkill = "PostSkill" + dojoEventPostAgent = "PostAgent" + dojoEventSessionEnd = "SessionEnd" +) + +// loadHooks reads hooks/hooks.json and converts it into []HookRule. +// +// Two schemas are supported: +// +// - Flat (dojo-native, back-compat): {"<event>": [{matcher,if,hooks}, ...], ...} +// directly at the top level. Event names are used verbatim — unchanged +// from this function's original, wrapper-unaware behavior. +// +// - Wrapped (Claude-Code-native): {"hooks": {"<event>": [...], ...}} — a +// single top-level "hooks" key whose value is itself an event-name-keyed +// object. This is the shape Claude Code's own hook files use (see e.g. +// kata-harness's plugin/hooks/hooks.json), so it's what plugin authors +// porting an existing Claude Code hooks.json actually hand us. Event +// names found here are translated via ccEventToDojo; a Claude-Code +// event with no honest dojo equivalent is skipped (logged) rather than +// mismapped or bolted on as a literal "hooks" pseudo-event. +// +// Before this function grew wrapper support, feeding it a wrapped file +// meant unmarshalling {"hooks": {...}} (an object) into map[string][]hookEntry +// (expects every value to be an array) — a hard type-mismatch error, so +// loadHooks logged a warning and returned zero rules. The plugin's hooks +// were silently inert either way; this rewrite makes the wrapped shape a +// first-class input instead of a parse failure. func loadHooks(pluginDir, pluginName string) []HookRule { hooksPath := filepath.Join(pluginDir, "hooks", "hooks.json") data, err := os.ReadFile(hooksPath) @@ -139,18 +173,36 @@ func loadHooks(pluginDir, pluginName string) []HookRule { return nil } - // hooks.json is an object keyed by event name. - var raw map[string][]hookEntry - if err := json.Unmarshal(data, &raw); err != nil { + eventMap, wrapped, err := parseHooksJSON(data) + if err != nil { log.Printf("[plugins] warning: skipping hooks for plugin at %s — failed to parse hooks.json: %v", pluginDir, err) return nil } var rules []HookRule - for event, entries := range raw { + for event, entries := range eventMap { + ruleEvent := event + if wrapped { + dojoEvent, ok := ccEventToDojo(event) + if !ok { + log.Printf("[plugins] note: plugin %q hooks.json — Claude-Code event %q has no dojo equivalent, skipping %d hook(s)", pluginName, event, len(entries)) + continue + } + ruleEvent = dojoEvent + } + if strings.EqualFold(ruleEvent, "hooks") { + // Defense in depth: a literal top-level "hooks" key that isn't + // the wrapper (e.g. the degenerate flat shape {"hooks": [...]}) + // would otherwise slip through as a pseudo-event that can never + // match any real dojo event — exactly the trap that made + // wrapped hooks.json files silently inert before this fix. + // Refuse to reproduce it under any input shape. + log.Printf("[plugins] warning: plugin %q hooks.json has a top-level %q key that isn't a real event — skipping %d hook(s); wrapped Claude-Code hooks belong under {\"hooks\": {\"<Event>\": [...]}}", pluginName, event, len(entries)) + continue + } for _, entry := range entries { rules = append(rules, HookRule{ - Event: event, + Event: ruleEvent, Matcher: entry.Matcher, If: entry.If, Hooks: entry.Hooks, @@ -160,6 +212,69 @@ func loadHooks(pluginDir, pluginName string) []HookRule { return rules } +// parseHooksJSON parses the raw bytes of a hooks.json file, trying the +// Claude-Code wrapper schema first and falling back to the flat dojo-native +// schema — see loadHooks for the shape of each. Returns the parsed +// event->entries map, whether the wrapped schema matched (so the caller +// knows to run Claude-Code -> dojo event translation), and any parse error. +func parseHooksJSON(data []byte) (eventMap map[string][]hookEntry, wrapped bool, err error) { + var probe struct { + Hooks json.RawMessage `json:"hooks"` + } + if probeErr := json.Unmarshal(data, &probe); probeErr == nil && len(probe.Hooks) > 0 { + var inner map[string][]hookEntry + if innerErr := json.Unmarshal(probe.Hooks, &inner); innerErr == nil { + return inner, true, nil + } + // "hooks" was present but wasn't an event-name-keyed object (e.g. + // some other shape entirely) — fall through and try the flat schema + // below; if that also fails, its error is what gets reported. + } + + var flat map[string][]hookEntry + if flatErr := json.Unmarshal(data, &flat); flatErr != nil { + return nil, false, flatErr + } + return flat, false, nil +} + +// ccEventToDojo translates a hook event name found inside the Claude-Code +// wrapper schema to its dojo-cli equivalent. Two kinds of input are +// accepted: dojo's own event names (identity passthrough, in case a wrapped +// hooks.json already uses dojo's vocabulary) and Claude Code's hook event +// names, mapped to their closest dojo counterpart: +// +// PreToolUse -> PreCommand (about to run a tool/command) +// PostToolUse -> PostCommand (a tool/command just finished) +// SubagentStop -> PostAgent (a dispatched subagent finished) +// SessionEnd -> SessionEnd (same concept, dojo has it natively — +// covered by the identity case below) +// +// Deliberately NOT mapped: SessionStart. dojo-cli has no "beginning of +// session" event among its 5 (PreCommand/PostCommand/PostSkill/PostAgent/ +// SessionEnd) — SessionEnd is a false friend for it, not "the closest": +// mapping SessionStart -> SessionEnd would fire a startup hook (e.g. +// kata-harness's roll-status-injector, whose entire job is injecting status +// at session start) at the END of the session instead — wrong, not just +// imprecise. Also left unmapped for the same reason (no honest dojo +// lifecycle counterpart): Notification, UserPromptSubmit, Stop, PreCompact. +// Callers report these as "no dojo equivalent" and skip them rather than +// silently mismapping them. +func ccEventToDojo(event string) (string, bool) { + switch event { + case dojoEventPreCommand, dojoEventPostCommand, dojoEventPostSkill, dojoEventPostAgent, dojoEventSessionEnd: + return event, true + case "PreToolUse": + return dojoEventPreCommand, true + case "PostToolUse": + return dojoEventPostCommand, true + case "SubagentStop": + return dojoEventPostAgent, true + default: + return "", false + } +} + // countFiles counts files matching a glob pattern inside dir. func countFiles(dir, pattern string) int { matches, err := filepath.Glob(filepath.Join(dir, pattern)) diff --git a/internal/plugins/scanner_test.go b/internal/plugins/scanner_test.go index db6b8bd..46f88bb 100644 --- a/internal/plugins/scanner_test.go +++ b/internal/plugins/scanner_test.go @@ -198,6 +198,198 @@ func TestScan_MultipleSkills_Counted(t *testing.T) { } } +// ─── hooks.json: wrapped Claude-Code schema ({"hooks": {"<event>": [...]}}) ── + +// TestScan_WrappedClaudeCodeHooksSchema_MapsToDojoEventNames feeds a +// {"hooks": {...}}-shaped hooks.json — the shape Claude Code's own hook +// files use, and the shape kata-harness's plugin/hooks/hooks.json is +// written in — through the full Scan() pipeline, using Claude-Code event +// names that DO have a dojo equivalent. Before the wrapper-schema fix, the +// flat-only parser either failed outright on this shape (object where an +// array was expected) or, for adjacent shapes, could produce a pseudo-rule +// literally named "hooks" that can never match any real dojo event +// (PreCommand/PostCommand/PostSkill/PostAgent/SessionEnd) — i.e. the hook +// loads but never fires. This asserts the resulting rules carry real, +// correct dojo event names instead. +func TestScan_WrappedClaudeCodeHooksSchema_MapsToDojoEventNames(t *testing.T) { + root := t.TempDir() + pluginDir := filepath.Join(root, "cc-shaped") + mustMkdir(t, pluginDir) + writeJSON(t, filepath.Join(pluginDir, "plugin.json"), map[string]any{ + "name": "cc-shaped", + "version": "1.0", + }) + + mustMkdir(t, filepath.Join(pluginDir, "hooks")) + hooksData := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []map[string]any{ + {"hooks": []map[string]any{{"type": "command", "command": "echo pre"}}}, + }, + "PostToolUse": []map[string]any{ + {"hooks": []map[string]any{{"type": "command", "command": "echo post"}}}, + }, + "SubagentStop": []map[string]any{ + {"hooks": []map[string]any{{"type": "command", "command": "echo subagent"}}}, + }, + }, + } + writeJSON(t, filepath.Join(pluginDir, "hooks", "hooks.json"), hooksData) + + plugins, err := Scan(root) + if err != nil { + t.Fatalf("Scan() returned error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + + rules := plugins[0].HookRules + if len(rules) != 3 { + t.Fatalf("expected 3 hook rules, got %d: %+v", len(rules), rules) + } + + gotEvents := map[string]bool{} + for _, r := range rules { + if r.Event == "hooks" { + t.Fatalf("rule has pseudo-event Event==%q — exactly the wrapper-schema trap this fix closes: %+v", r.Event, r) + } + gotEvents[r.Event] = true + } + + wantEvents := map[string]bool{ + "PreCommand": true, // mapped from PreToolUse + "PostCommand": true, // mapped from PostToolUse + "PostAgent": true, // mapped from SubagentStop + } + if len(gotEvents) != len(wantEvents) { + t.Errorf("event set size mismatch: got %v, want %v", gotEvents, wantEvents) + } + for ev := range wantEvents { + if !gotEvents[ev] { + t.Errorf("missing expected dojo event %q in rules; got events: %v", ev, gotEvents) + } + } +} + +// TestScan_WrappedSchema_KataHarnessShape_NoDojoEquivalent_SkippedNotPseudoRule +// mirrors kata-harness's actual plugin/hooks/hooks.json byte-for-byte in +// shape: the wrapper schema with a single SessionStart hook. dojo-cli has +// no beginning-of-session event among its 5, so SessionStart has no honest +// dojo equivalent (see ccEventToDojo's doc comment for why it must NOT be +// mismapped to SessionEnd). The correct outcome is zero rules — never a +// rule with Event=="hooks", and never a rule that fires at the wrong +// lifecycle point. +func TestScan_WrappedSchema_KataHarnessShape_NoDojoEquivalent_SkippedNotPseudoRule(t *testing.T) { + root := t.TempDir() + pluginDir := filepath.Join(root, "kata-harness-shaped") + mustMkdir(t, pluginDir) + writeJSON(t, filepath.Join(pluginDir, "plugin.json"), map[string]any{ + "name": "kata-harness-shaped", + "version": "1.0", + }) + + mustMkdir(t, filepath.Join(pluginDir, "hooks")) + hooksJSON := `{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/roll-status-injector.py\"", + "timeout": 10, + "statusMessage": "Checking roll status" + } + ] + } + ] + } +}` + writeFile(t, filepath.Join(pluginDir, "hooks", "hooks.json"), hooksJSON) + + plugins, err := Scan(root) + if err != nil { + t.Fatalf("Scan() returned error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + + rules := plugins[0].HookRules + for _, r := range rules { + if r.Event == "hooks" { + t.Fatalf("got pseudo-rule with Event==\"hooks\" — this is exactly the bug the wrapper-schema fix must prevent: %+v", r) + } + } + if len(rules) != 0 { + t.Errorf("expected 0 rules (SessionStart has no dojo equivalent, so it should be skipped with a logged note), got %d: %+v", len(rules), rules) + } +} + +// TestParseHooksJSON_FlatSchema_BackCompat asserts the flat, dojo-native +// schema still parses exactly as before the wrapper-schema fix: wrapped +// reports false, and event-name keys — including ones that aren't +// Claude-Code names and wouldn't map to anything — pass through verbatim, +// with no translation applied. +func TestParseHooksJSON_FlatSchema_BackCompat(t *testing.T) { + data := []byte(`{ + "PostCommand": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "echo hook"}]} + ], + "SomeCustomEventName": [ + {"hooks": [{"type": "command", "command": "echo custom"}]} + ] +}`) + + eventMap, wrapped, err := parseHooksJSON(data) + if err != nil { + t.Fatalf("parseHooksJSON() error: %v", err) + } + if wrapped { + t.Errorf("wrapped = true, want false for a flat-schema document") + } + if len(eventMap) != 2 { + t.Fatalf("expected 2 event keys, got %d: %v", len(eventMap), eventMap) + } + if _, ok := eventMap["PostCommand"]; !ok { + t.Errorf("expected flat key %q preserved verbatim, got keys: %v", "PostCommand", eventMap) + } + if _, ok := eventMap["SomeCustomEventName"]; !ok { + t.Errorf("expected flat key %q preserved verbatim (no translation on the flat path), got keys: %v", "SomeCustomEventName", eventMap) + } +} + +// TestCcEventToDojo table-tests the Claude-Code -> dojo event translation: +// mappable CC names, dojo-native identity passthrough, and CC names with no +// honest dojo equivalent (must report ok=false, never a guessed mapping). +func TestCcEventToDojo(t *testing.T) { + cases := []struct { + in string + want string + wantOK bool + }{ + {"PreToolUse", "PreCommand", true}, + {"PostToolUse", "PostCommand", true}, + {"SubagentStop", "PostAgent", true}, + {"SessionEnd", "SessionEnd", true}, + {"PreCommand", "PreCommand", true}, // dojo-native identity passthrough + {"PostSkill", "PostSkill", true}, // dojo-native identity passthrough + {"SessionStart", "", false}, // no dojo equivalent — see doc comment + {"Notification", "", false}, + {"UserPromptSubmit", "", false}, + {"Stop", "", false}, + {"PreCompact", "", false}, + {"hooks", "", false}, // must never validate as a real event name + } + for _, tc := range cases { + got, ok := ccEventToDojo(tc.in) + if ok != tc.wantOK || got != tc.want { + t.Errorf("ccEventToDojo(%q) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK) + } + } +} + // ─── helpers ───────────────────────────────────────────────────────────────── func mustMkdir(t *testing.T, path string) { From 86d0f6e96366e6f71d85c52b794f5e862bb9baea Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 04:40:20 -0500 Subject: [PATCH 09/23] feat(commands): add /doctor for gateway/provider/config visibility Read-only diagnostic printing OK/WARN for gateway reachability, per-provider status (surfacing the 'gateway healthy but a provider is 429/404' blind spot), config basics, and plugin/hook counts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/cmd_doctor.go | 191 ++++++++++++++++++++++++++++++++ internal/commands/commands.go | 1 + 2 files changed, 192 insertions(+) create mode 100644 internal/commands/cmd_doctor.go diff --git a/internal/commands/cmd_doctor.go b/internal/commands/cmd_doctor.go new file mode 100644 index 0000000..f8ff935 --- /dev/null +++ b/internal/commands/cmd_doctor.go @@ -0,0 +1,191 @@ +package commands + +// cmd_doctor.go — /doctor: a read-only, one-screen diagnostic over gateway +// reachability, per-provider status, active config, and loaded plugins/hooks. +// No mutations — this command never writes config, never calls a gateway +// write endpoint, and never changes state. +// +// Self-contained by design: it only depends on symbols that already exist +// elsewhere in this package/tree as of this writing (Registry.cfg/gw/plgs, +// client.Client.Health/Providers/FriendlyError, config.SettingsPath/ +// IsKnownDisposition/DefaultDisposition, and the package's existing +// orDefault helper). It deliberately does NOT reference any Protocol config +// fields — those are landing in config.go on a concurrent track. + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/DojoGenesis/cli/internal/config" + gcolor "github.com/gookit/color" +) + +// ─── /doctor ──────────────────────────────────────────────────────────────── + +func (r *Registry) doctorCmd() Command { + return Command{ + Name: "doctor", + Usage: "/doctor", + Short: "Read-only diagnostic: gateway, providers, config, plugins/hooks", + Run: func(ctx context.Context, args []string) error { + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Dojo Doctor")) + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" " + r.cfg.Gateway.URL)) + fmt.Println() + + warnCount := 0 + check := func(ok bool, detail string) { + if !ok { + warnCount++ + } + fmt.Printf(" %s %s\n", doctorTag(ok), detail) + } + section := func(name string) { + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#f4a261").Sprint(" " + name)) + fmt.Println() + } + + section("GATEWAY") + r.doctorGateway(ctx, check) + + section("PROVIDERS") + r.doctorProviders(ctx, check) + + section("CONFIG") + r.doctorConfig(check) + + section("PLUGINS/HOOKS") + r.doctorPlugins(check) + + fmt.Println() + if warnCount == 0 { + fmt.Println(gcolor.HEX("#7fb88c").Sprint(" All checks OK")) + } else { + plural := "s" + if warnCount == 1 { + plural = "" + } + fmt.Println(gcolor.HEX("#e63946").Sprintf(" %d warning%s — see above", warnCount, plural)) + } + fmt.Println() + return nil + }, + } +} + +// doctorCheck emits one OK/WARN line for a /doctor section and tallies the +// running warning count in its caller's closure. +type doctorCheck func(ok bool, detail string) + +// doctorTag renders the OK/WARN prefix. Both branches are exactly 4 visible +// runes ("OK " / "WARN"), so callers can print it directly ahead of the +// detail text without needing ANSI-aware column padding. +func doctorTag(ok bool) string { + if ok { + return gcolor.HEX("#7fb88c").Sprint("OK ") + } + return gcolor.HEX("#e63946").Sprint("WARN") +} + +// doctorHealthy reports whether a gateway/provider status string counts as +// good. Mirrors colorStatus's "good" set (cmd_help.go) so /doctor's OK/WARN +// calls agree with how /health colors the same values. Duplicated here as a +// small literal set — rather than calling colorStatus, which only colors a +// string and doesn't expose a boolean classification — to keep this file +// self-contained. +func doctorHealthy(s string) bool { + switch strings.ToLower(s) { + case "ok", "healthy", "active", "running", "ready", "completed": + return true + default: + return false + } +} + +// ─── GATEWAY ──────────────────────────────────────────────────────────────── + +// doctorGateway checks gateway reachability via gw.Health, the same call +// healthCmd (cmd_system.go) uses. Unreachable gets WARN with the client's +// built-in friendly hint (the same one main.go / repl.go print on a failed +// call) instead of a raw error. +func (r *Registry) doctorGateway(ctx context.Context, check doctorCheck) { + h, err := r.gw.Health(ctx) + if err != nil { + check(false, r.gw.FriendlyError(err)) + return + } + detail := fmt.Sprintf("reachable — status=%s version=%s uptime=%ds", + h.Status, orDefault(h.Version, "unknown"), h.UptimeSeconds) + check(doctorHealthy(h.Status), detail) +} + +// ─── PROVIDERS ────────────────────────────────────────────────────────────── + +// doctorProviders lists each provider's own status via GET /v1/providers. +// This is the whole point of the section: the gateway's overall /health can +// report "healthy" while an individual provider is failing underneath it +// (observed: openai 429, anthropic 404) — that failure is invisible unless +// each provider is checked on its own. +func (r *Registry) doctorProviders(ctx context.Context, check doctorCheck) { + providers, err := r.gw.Providers(ctx) + if err != nil { + check(false, "could not fetch provider list — "+r.gw.FriendlyError(err)) + return + } + if len(providers) == 0 { + check(false, "gateway reports no providers configured") + return + } + for _, p := range providers { + detail := fmt.Sprintf("%s: %s", p.Name, orDefault(p.Status, "unknown")) + if p.Error != "" { + detail += " — " + p.Error + } + check(doctorHealthy(p.Status), detail) + } +} + +// ─── CONFIG ───────────────────────────────────────────────────────────────── + +// doctorConfig surfaces gateway.url, the active disposition, and the config +// file path. Deliberately does not touch any Protocol config fields — see +// the file-level comment. +func (r *Registry) doctorConfig(check doctorCheck) { + url := r.cfg.Gateway.URL + if url == "" { + check(false, "gateway.url is not set") + } else { + check(true, "gateway.url = "+url) + } + + disp := orDefault(r.cfg.Defaults.Disposition, config.DefaultDisposition) + if config.IsKnownDisposition(disp, r.cfg.DispositionProfiles) { + check(true, "disposition = "+disp) + } else { + check(false, fmt.Sprintf("disposition %q is not a recognized preset", disp)) + } + + path := config.SettingsPath() + if _, statErr := os.Stat(path); statErr != nil { + check(false, path+" not found — running on defaults/env/flags (this is not fatal)") + } else { + check(true, "config file = "+path) + } +} + +// ─── PLUGINS/HOOKS ────────────────────────────────────────────────────────── + +// doctorPlugins reports how many plugins are loaded and how many hook rules +// they registered, reading the same Registry.plgs field hooksCmd (cmd_system.go) +// sums over. Always in-process data — no network call, so always OK. +func (r *Registry) doctorPlugins(check doctorCheck) { + hookRules := 0 + for _, p := range r.plgs { + hookRules += len(p.HookRules) + } + check(true, fmt.Sprintf("%d plugin(s) loaded, %d hook rule(s) registered", len(r.plgs), hookRules)) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 80abca2..53833f3 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -132,6 +132,7 @@ func (r *Registry) add(cmd Command) { func (r *Registry) register() { r.add(r.helpCmd()) r.add(r.healthCmd()) + r.add(r.doctorCmd()) r.add(r.homeCmd()) r.add(r.modelCmd()) r.add(r.toolsCmd()) From aae8dbc3ce2b3a006b8423a797b4a8d93d429e11 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 05:00:55 -0500 Subject: [PATCH 10/23] feat(hooks): add SessionStart lifecycle event so harness startup hooks fire dojo had no session-start hook event, so an installed harness's SessionStart hook (e.g. kata-harness) parsed but could never fire. Add EventSessionStart, fire it at REPL start with SessionEnd deferred across all exit paths, and map Claude-Code SessionStart to it in the plugin scanner. One-shot stays unfired to protect the --json contract. Also gate the --plain 'Last session:' hint. Verified live: a real kata-shaped plugin's hooks execute on a run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- cmd/dojo/main.go | 9 +++ internal/hooks/runner.go | 11 ++-- internal/hooks/runner_test.go | 39 +++++++++++++ internal/plugins/scanner.go | 35 +++++++----- internal/plugins/scanner_test.go | 33 ++++++----- internal/repl/repl.go | 50 +++++++++++++++-- internal/repl/repl_test.go | 94 ++++++++++++++++++++++++++++++++ 7 files changed, 233 insertions(+), 38 deletions(-) diff --git a/cmd/dojo/main.go b/cmd/dojo/main.go index d5e6ef4..2744f48 100644 --- a/cmd/dojo/main.go +++ b/cmd/dojo/main.go @@ -90,6 +90,15 @@ func main() { // One-shot mode: send a single message and exit. Ctrl+C cancels the single // turn and exits. + // + // NOTE (SessionStart/SessionEnd, W4-LIFECYCLE): deliberately NOT fired + // here. It would need its own plugin scan + hooks.Runner (this path never + // builds a repl.REPL, so there's nothing to reuse), but the actual + // blocker is that "prompt"/"agent" type hooks write straight to stdout + // via fmt.Printf regardless of --json (see runHook in + // internal/hooks/runner.go) — that would corrupt the JSON-lines contract + // --json promises to scripted/CI consumers of --one-shot. Revisit if/when + // hook stdout output is made --json-aware. if *flagOneShot != "" { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/internal/hooks/runner.go b/internal/hooks/runner.go index dfe27ed..5cad226 100644 --- a/internal/hooks/runner.go +++ b/internal/hooks/runner.go @@ -20,11 +20,12 @@ import ( // Event names that dojo-cli fires. const ( - EventPreCommand = "PreCommand" - EventPostCommand = "PostCommand" - EventPostSkill = "PostSkill" - EventPostAgent = "PostAgent" - EventSessionEnd = "SessionEnd" + EventPreCommand = "PreCommand" + EventPostCommand = "PostCommand" + EventPostSkill = "PostSkill" + EventPostAgent = "PostAgent" + EventSessionStart = "SessionStart" + EventSessionEnd = "SessionEnd" ) // Runner executes hook rules for a given event. diff --git a/internal/hooks/runner_test.go b/internal/hooks/runner_test.go index f0afc91..c61f6bc 100644 --- a/internal/hooks/runner_test.go +++ b/internal/hooks/runner_test.go @@ -105,6 +105,45 @@ func TestFire_CommandHook_ExecutesScript(t *testing.T) { } } +// ─── Fire() with SessionStart event (W4-LIFECYCLE) ──────────────────────────── + +// TestFire_SessionStartHook_ExecutesScript proves the new EventSessionStart +// constant flows through Runner.Fire() exactly like the pre-existing events +// (matching is purely string-based in Fire(); nothing event-specific is +// hardcoded there). The REPL fires this event once at startup — see +// internal/repl.REPL.fireSessionStart — so this is the mechanism a +// kata-harness-shaped SessionStart hook now relies on to actually run. +func TestFire_SessionStartHook_ExecutesScript(t *testing.T) { + tmp := t.TempDir() + markerFile := filepath.Join(tmp, "session-start-ran.txt") + + ps := []plugins.Plugin{ + { + Name: "session-start-plugin", + Version: "1.0", + Path: tmp, + HookRules: []plugins.HookRule{ + { + Event: EventSessionStart, + Hooks: []plugins.HookDef{ + {Type: "command", Command: "touch " + markerFile}, + }, + }, + }, + }, + } + + r := New(ps) + err := r.Fire(context.Background(), EventSessionStart, map[string]any{"session": "dojo-cli-test", "resumed": false}) + if err != nil { + t.Fatalf("Fire() returned error: %v", err) + } + + if _, statErr := os.Stat(markerFile); os.IsNotExist(statErr) { + t.Errorf("SessionStart hook did not run: marker file %q was not created", markerFile) + } +} + // ─── Fire() with async hook ─────────────────────────────────────────────────── func TestFire_AsyncHook_ReturnsBeforeCompletion(t *testing.T) { diff --git a/internal/plugins/scanner.go b/internal/plugins/scanner.go index eee390f..1a60250 100644 --- a/internal/plugins/scanner.go +++ b/internal/plugins/scanner.go @@ -136,11 +136,12 @@ func loadPluginMeta(dir string) (pluginMeta, bool) { // direction would create a cycle. Keep these in sync with the Event* // constants in internal/hooks/runner.go. const ( - dojoEventPreCommand = "PreCommand" - dojoEventPostCommand = "PostCommand" - dojoEventPostSkill = "PostSkill" - dojoEventPostAgent = "PostAgent" - dojoEventSessionEnd = "SessionEnd" + dojoEventPreCommand = "PreCommand" + dojoEventPostCommand = "PostCommand" + dojoEventPostSkill = "PostSkill" + dojoEventPostAgent = "PostAgent" + dojoEventSessionStart = "SessionStart" + dojoEventSessionEnd = "SessionEnd" ) // loadHooks reads hooks/hooks.json and converts it into []HookRule. @@ -247,22 +248,28 @@ func parseHooksJSON(data []byte) (eventMap map[string][]hookEntry, wrapped bool, // PreToolUse -> PreCommand (about to run a tool/command) // PostToolUse -> PostCommand (a tool/command just finished) // SubagentStop -> PostAgent (a dispatched subagent finished) +// SessionStart -> SessionStart (same concept, dojo has it natively — +// covered by the identity case below) // SessionEnd -> SessionEnd (same concept, dojo has it natively — // covered by the identity case below) // -// Deliberately NOT mapped: SessionStart. dojo-cli has no "beginning of -// session" event among its 5 (PreCommand/PostCommand/PostSkill/PostAgent/ -// SessionEnd) — SessionEnd is a false friend for it, not "the closest": -// mapping SessionStart -> SessionEnd would fire a startup hook (e.g. +// SessionStart used to be deliberately unmapped: dojo-cli had no "beginning +// of session" event among its 5 (PreCommand/PostCommand/PostSkill/PostAgent/ +// SessionEnd), and SessionEnd was a false friend for it, not "the closest" — +// mapping SessionStart -> SessionEnd would have fired a startup hook (e.g. // kata-harness's roll-status-injector, whose entire job is injecting status // at session start) at the END of the session instead — wrong, not just -// imprecise. Also left unmapped for the same reason (no honest dojo -// lifecycle counterpart): Notification, UserPromptSubmit, Stop, PreCompact. -// Callers report these as "no dojo equivalent" and skip them rather than -// silently mismapping them. +// imprecise. Now that dojo-cli fires its own EventSessionStart at REPL +// startup (see internal/repl.REPL.fireSessionStart), the identity mapping +// below is honest, not a guess. +// +// Still deliberately NOT mapped, for the same reason SessionStart used to be +// (no honest dojo lifecycle counterpart): Notification, UserPromptSubmit, +// Stop, PreCompact. Callers report these as "no dojo equivalent" and skip +// them rather than silently mismapping them. func ccEventToDojo(event string) (string, bool) { switch event { - case dojoEventPreCommand, dojoEventPostCommand, dojoEventPostSkill, dojoEventPostAgent, dojoEventSessionEnd: + case dojoEventPreCommand, dojoEventPostCommand, dojoEventPostSkill, dojoEventPostAgent, dojoEventSessionStart, dojoEventSessionEnd: return event, true case "PreToolUse": return dojoEventPreCommand, true diff --git a/internal/plugins/scanner_test.go b/internal/plugins/scanner_test.go index 46f88bb..f551f1b 100644 --- a/internal/plugins/scanner_test.go +++ b/internal/plugins/scanner_test.go @@ -272,15 +272,16 @@ func TestScan_WrappedClaudeCodeHooksSchema_MapsToDojoEventNames(t *testing.T) { } } -// TestScan_WrappedSchema_KataHarnessShape_NoDojoEquivalent_SkippedNotPseudoRule +// TestScan_WrappedSchema_KataHarnessShape_SessionStart_MapsToEventSessionStart // mirrors kata-harness's actual plugin/hooks/hooks.json byte-for-byte in -// shape: the wrapper schema with a single SessionStart hook. dojo-cli has -// no beginning-of-session event among its 5, so SessionStart has no honest -// dojo equivalent (see ccEventToDojo's doc comment for why it must NOT be -// mismapped to SessionEnd). The correct outcome is zero rules — never a -// rule with Event=="hooks", and never a rule that fires at the wrong -// lifecycle point. -func TestScan_WrappedSchema_KataHarnessShape_NoDojoEquivalent_SkippedNotPseudoRule(t *testing.T) { +// shape: the wrapper schema with a single SessionStart hook. Before +// W4-LIFECYCLE, dojo-cli had no beginning-of-session event, so this hook +// parsed but could never fire (see the superseded test name this replaces). +// Now that dojo-cli fires EventSessionStart at REPL startup, this same +// kata-harness-shaped hooks.json must resolve to exactly one rule with +// Event=="SessionStart" — never a pseudo-rule with Event=="hooks", and never +// zero rules (that would mean the fix regressed). +func TestScan_WrappedSchema_KataHarnessShape_SessionStart_MapsToEventSessionStart(t *testing.T) { root := t.TempDir() pluginDir := filepath.Join(root, "kata-harness-shaped") mustMkdir(t, pluginDir) @@ -322,8 +323,14 @@ func TestScan_WrappedSchema_KataHarnessShape_NoDojoEquivalent_SkippedNotPseudoRu t.Fatalf("got pseudo-rule with Event==\"hooks\" — this is exactly the bug the wrapper-schema fix must prevent: %+v", r) } } - if len(rules) != 0 { - t.Errorf("expected 0 rules (SessionStart has no dojo equivalent, so it should be skipped with a logged note), got %d: %+v", len(rules), rules) + if len(rules) != 1 { + t.Fatalf("expected 1 rule (SessionStart now maps to EventSessionStart), got %d: %+v", len(rules), rules) + } + if rules[0].Event != "SessionStart" { + t.Errorf("HookRules[0].Event: got %q, want %q — a kata-harness-shaped SessionStart hook must resolve to dojo's SessionStart event to ever fire", rules[0].Event, "SessionStart") + } + if len(rules[0].Hooks) != 1 || rules[0].Hooks[0].Type != "command" { + t.Errorf("HookRules[0].Hooks: got %+v, want a single command-type hook (the roll-status-injector)", rules[0].Hooks) } } @@ -373,9 +380,9 @@ func TestCcEventToDojo(t *testing.T) { {"PostToolUse", "PostCommand", true}, {"SubagentStop", "PostAgent", true}, {"SessionEnd", "SessionEnd", true}, - {"PreCommand", "PreCommand", true}, // dojo-native identity passthrough - {"PostSkill", "PostSkill", true}, // dojo-native identity passthrough - {"SessionStart", "", false}, // no dojo equivalent — see doc comment + {"PreCommand", "PreCommand", true}, // dojo-native identity passthrough + {"PostSkill", "PostSkill", true}, // dojo-native identity passthrough + {"SessionStart", "SessionStart", true}, // W4-LIFECYCLE: now mapped — dojo-cli fires it at REPL startup {"Notification", "", false}, {"UserPromptSubmit", "", false}, {"Stop", "", false}, diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 5a41a7d..afd499c 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -116,12 +116,16 @@ func New(cfg *config.Config, gw *client.Client, resume bool, plain bool) *REPL { } } else { r.session = fmt.Sprintf("dojo-cli-%s", time.Now().Format("20060102-150405")) - // Show last session hint (cosmetic only) when not resuming - if st, loadErr := state.Load(); loadErr == nil && st.LastSessionID != "" { - fmt.Printf("\n %s %s\n", - gcolor.HEX("#94a3b8").Sprint("Last session:"), - gcolor.HEX("#e8b04a").Sprint(st.LastSessionID), - ) + // Show last session hint (cosmetic only) when not resuming. Gated + // behind !plain like the welcome banner in printWelcome — piped/CI + // consumers under --plain get no decorative lines here either. + if !plain { + if st, loadErr := state.Load(); loadErr == nil && st.LastSessionID != "" { + fmt.Printf("\n %s %s\n", + gcolor.HEX("#94a3b8").Sprint("Last session:"), + gcolor.HEX("#e8b04a").Sprint(st.LastSessionID), + ) + } } } @@ -233,6 +237,34 @@ func (r *REPL) syncProviderKeys(ctx context.Context) { } } +// fireSessionStart fires the SessionStart hooks. Called once from Run(), +// after session/config setup completes and before the read loop begins, so +// a plugin's startup hook (e.g. kata-harness's roll-status-injector, whose +// entire job is injecting status at session start) sees a fully-initialized +// session. Hook errors are logged, not fatal — same idiom as PreCommand/ +// PostCommand in handle(). +func (r *REPL) fireSessionStart(ctx context.Context) { + payload := map[string]any{"session": r.session, "resumed": r.resumed} + if err := r.runner.Fire(ctx, hooks.EventSessionStart, payload); err != nil { + log.Printf("[hooks] SessionStart error: %v", err) + } +} + +// fireSessionEnd fires the SessionEnd hooks. Called via defer from Run() so +// it runs on every exit path — normal exit, SIGTERM, a readline EOF/error, +// or falling through to runPlain. Deliberately uses context.Background() +// rather than Run()'s ctx: by the time Run() returns because ctx itself was +// cancelled (SIGTERM), firing a "command" hook with that same cancelled ctx +// would race exec.CommandContext's kill-watcher goroutine (see runCommand in +// internal/hooks/runner.go) and could truncate or skip the hook entirely — +// SessionEnd should still get a chance to complete. +func (r *REPL) fireSessionEnd() { + payload := map[string]any{"session": r.session, "resumed": r.resumed} + if err := r.runner.Fire(context.Background(), hooks.EventSessionEnd, payload); err != nil { + log.Printf("[hooks] SessionEnd error: %v", err) + } +} + // Run starts the interactive loop. Returns when the user exits. func (r *REPL) Run(ctx context.Context) error { printWelcome(r.cfg, r.session, r.resumed, r.plain) @@ -338,6 +370,12 @@ func (r *REPL) Run(ctx context.Context) error { _ = st.Save() } + // Session/config setup is complete as of the state save above — fire + // SessionStart now, before the read loop starts. SessionEnd fires via + // defer so it covers every return path out of Run() below. + r.fireSessionStart(ctx) + defer r.fireSessionEnd() + // Two-tier interrupt: a SIGINT that arrives while a response is streaming // cancels ONLY that turn (the base ctx from main is not SIGINT-bound, so the // session survives and the loop returns to the prompt). At an idle prompt the diff --git a/internal/repl/repl_test.go b/internal/repl/repl_test.go index 5384138..e313d23 100644 --- a/internal/repl/repl_test.go +++ b/internal/repl/repl_test.go @@ -1,10 +1,15 @@ package repl import ( + "context" + "os" + "path/filepath" "strings" "testing" "github.com/DojoGenesis/cli/internal/client" + "github.com/DojoGenesis/cli/internal/hooks" + "github.com/DojoGenesis/cli/internal/plugins" ) // ─── vitalityPrompt ────────────────────────────────────────────────────────── @@ -207,3 +212,92 @@ func TestExtractText_ChoiceTextFallback(t *testing.T) { t.Errorf("extractText choices[0].text: got %q, want %q", got, "non-streaming text") } } + +// ─── fireSessionStart / fireSessionEnd (W4-LIFECYCLE) ───────────────────────── +// +// Run() itself is not unit-tested here — it owns signal handling, a +// blocking readline loop, a background SSE goroutine, and real ~/.dojo state +// writes, none of which are hermetic. Instead these tests exercise the exact +// two methods Run() calls at the exact points described in their doc +// comments (fireSessionStart after session/config setup and before the read +// loop; fireSessionEnd via defer on every exit path), by constructing a bare +// REPL with only the fields those methods touch (runner, session, resumed). + +func TestFireSessionStart_FiresConfiguredHook(t *testing.T) { + tmp := t.TempDir() + marker := filepath.Join(tmp, "session-start-ran.txt") + + ps := []plugins.Plugin{ + { + Name: "kata-harness-shaped", + Path: tmp, + HookRules: []plugins.HookRule{ + { + Event: hooks.EventSessionStart, + Hooks: []plugins.HookDef{ + {Type: "command", Command: "touch " + marker}, + }, + }, + }, + }, + } + + r := &REPL{ + runner: hooks.New(ps), + session: "dojo-cli-test-session", + resumed: true, + } + r.fireSessionStart(context.Background()) + + if _, err := os.Stat(marker); os.IsNotExist(err) { + t.Errorf("fireSessionStart did not run the configured SessionStart hook; marker %q was not created", marker) + } +} + +func TestFireSessionStart_NoMatchingHooks_NoPanic(t *testing.T) { + // A REPL whose runner has no SessionStart rules (the common case — most + // plugins don't define one) must be a silent no-op, not a panic. + r := &REPL{runner: hooks.New(nil), session: "dojo-cli-test-session"} + r.fireSessionStart(context.Background()) +} + +func TestFireSessionEnd_FiresConfiguredHook(t *testing.T) { + tmp := t.TempDir() + marker := filepath.Join(tmp, "session-end-ran.txt") + + ps := []plugins.Plugin{ + { + Name: "cleanup-plugin", + Path: tmp, + HookRules: []plugins.HookRule{ + { + Event: hooks.EventSessionEnd, + Hooks: []plugins.HookDef{ + {Type: "command", Command: "touch " + marker}, + }, + }, + }, + }, + } + + r := &REPL{ + runner: hooks.New(ps), + session: "dojo-cli-test-session", + } + r.fireSessionEnd() + + if _, err := os.Stat(marker); os.IsNotExist(err) { + t.Errorf("fireSessionEnd did not run the configured SessionEnd hook; marker %q was not created", marker) + } +} + +func TestFireSessionEnd_NoMatchingHooks_NoPanic(t *testing.T) { + // Symmetric with TestFireSessionStart_NoMatchingHooks_NoPanic: a REPL + // whose runner has no SessionEnd rules must be a silent no-op. Also + // exercises fireSessionEnd's no-ctx-parameter signature — it always + // fires against context.Background() internally (see its doc comment: + // this is deliberate so it still runs a hook to completion even when + // Run()'s own ctx was already cancelled, e.g. the SIGTERM-shutdown case). + r := &REPL{runner: hooks.New(nil), session: "dojo-cli-test-session"} + r.fireSessionEnd() +} From b89b9c5c45533cb2bb411eb06d3317f546529475 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 05:00:55 -0500 Subject: [PATCH 11/23] fix(config): stop env overrides persisting into settings.json; disposition discipline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Env overrides (DOJO_PROTOCOL_DISABLED, DOJO_GATEWAY_URL, etc.) were applied to the same fields Save() serializes, so a later Save() (/model set, /disposition set) baked the transient value in — this silently disabled the protocol on disk during testing. Load() now records env overrides in an unexported (unmarshaled) slice; Save() restores the pre-env file/default value for any field still holding its env value, keeping fields the user explicitly changed. Adds a 5th DispositionPreset field (Discipline). Regression-tested against a reverted naive Save(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/config/config.go | 119 +++++++++++++-- internal/config/config_test.go | 208 +++++++++++++++++++++++++++ internal/config/dispositions.go | 35 ++++- internal/config/dispositions_test.go | 100 ++++++++++++- 4 files changed, 446 insertions(+), 16 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 19d20da..14c6659 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,14 @@ type Config struct { Protocol ProtocolConfig `json:"protocol"` Auth AuthConfig `json:"auth,omitempty"` DispositionProfiles map[string]DispositionPreset `json:"disposition_profiles,omitempty"` + + // envOverrides records which fields Load() populated from an environment + // variable (DOJO_GATEWAY_URL, DOJO_PROTOCOL_DISABLED, etc.), and what each + // field held immediately before that override was applied. Unexported so + // encoding/json never serializes it — Save() reads it to keep env-transient + // overrides out of settings.json. See envOverride and Save() for why this + // exists and noteEnvOverride() for how it's populated. + envOverrides []envOverride } // ProtocolConfig controls the workspace "genius protocol" carried onto every @@ -58,6 +66,41 @@ type DefaultsConfig struct { Model string `json:"model"` } +// envOverride records one Config field that Load() populated from an +// environment variable: get/set read and write the field, preEnv is the +// value the field held immediately before the override was applied (from +// settings.json or defaults()), and envVal is the value the override wrote. +// +// Save() compares get(c) against envVal to tell "still env-controlled" +// (nothing has touched the field since Load(), so persist preEnv instead) +// apart from "explicitly changed since Load()" (e.g. /model set mutating +// Defaults.Model directly — a real edit, so persist the new value as-is). +// See Save() for the full rationale: this is what stops a one-off env var +// like DOJO_PROTOCOL_DISABLED=1 from getting permanently baked into +// settings.json the next time anything calls Save(). +type envOverride struct { + get func(*Config) any + set func(*Config, any) + preEnv any + envVal any +} + +// noteEnvOverride applies newVal to a Config field via set, after +// snapshotting the field's current (pre-override) value via get, and +// records both on cfg.envOverrides for Save() to consult later. Call this +// instead of assigning the field directly whenever Load() applies an env +// var override — see envOverride and Save(). +func noteEnvOverride[T comparable](cfg *Config, get func(*Config) T, set func(*Config, T), newVal T) { + pre := get(cfg) + set(cfg, newVal) + cfg.envOverrides = append(cfg.envOverrides, envOverride{ + get: func(c *Config) any { return get(c) }, + set: func(c *Config, v any) { set(c, v.(T)) }, + preEnv: pre, + envVal: newVal, + }) +} + // Load reads ~/.dojo/settings.json, applying environment variable overrides. // Missing file is not an error — defaults are returned. func Load() (*Config, error) { @@ -71,35 +114,65 @@ func Load() (*Config, error) { } } - // Environment overrides + // Environment overrides. Each is applied via noteEnvOverride (not a plain + // field assignment) so Save() can keep these transient, run-scoped values + // out of settings.json instead of silently baking them in permanently — + // see envOverride and Save(). if v := os.Getenv("DOJO_GATEWAY_URL"); v != "" { - cfg.Gateway.URL = v + noteEnvOverride(cfg, + func(c *Config) string { return c.Gateway.URL }, + func(c *Config, v string) { c.Gateway.URL = v }, + v) } if v := os.Getenv("DOJO_GATEWAY_TOKEN"); v != "" { - cfg.Gateway.Token = v + noteEnvOverride(cfg, + func(c *Config) string { return c.Gateway.Token }, + func(c *Config, v string) { c.Gateway.Token = v }, + v) } if v := os.Getenv("DOJO_PLUGINS_PATH"); v != "" { - cfg.Plugins.Path = v + noteEnvOverride(cfg, + func(c *Config) string { return c.Plugins.Path }, + func(c *Config, v string) { c.Plugins.Path = v }, + v) } if v := os.Getenv("DOJO_PROVIDER"); v != "" { - cfg.Defaults.Provider = v + noteEnvOverride(cfg, + func(c *Config) string { return c.Defaults.Provider }, + func(c *Config, v string) { c.Defaults.Provider = v }, + v) } if v := os.Getenv("DOJO_DISPOSITION"); v != "" { - cfg.Defaults.Disposition = v + noteEnvOverride(cfg, + func(c *Config) string { return c.Defaults.Disposition }, + func(c *Config, v string) { c.Defaults.Disposition = v }, + v) } if v := os.Getenv("DOJO_MODEL"); v != "" { - cfg.Defaults.Model = v + noteEnvOverride(cfg, + func(c *Config) string { return c.Defaults.Model }, + func(c *Config, v string) { c.Defaults.Model = v }, + v) } if v := os.Getenv("DOJO_USER_ID"); v != "" { - cfg.Auth.UserID = v + noteEnvOverride(cfg, + func(c *Config) string { return c.Auth.UserID }, + func(c *Config, v string) { c.Auth.UserID = v }, + v) } // DOJO_PROTOCOL_DISABLED: any non-empty value turns the protocol off. This // is the escape hatch that must work even when settings.json says enabled. if v := os.Getenv("DOJO_PROTOCOL_DISABLED"); v != "" { - cfg.Protocol.Enabled = false + noteEnvOverride(cfg, + func(c *Config) bool { return c.Protocol.Enabled }, + func(c *Config, v bool) { c.Protocol.Enabled = v }, + false) } if v := os.Getenv("DOJO_PROTOCOL_PATH"); v != "" { - cfg.Protocol.Path = v + noteEnvOverride(cfg, + func(c *Config) string { return c.Protocol.Path }, + func(c *Config, v string) { c.Protocol.Path = v }, + v) } // Gateway settings are load-bearing for connectivity — a malformed URL @@ -275,8 +348,32 @@ func SettingsPath() string { } // Save writes the current config to ~/.dojo/settings.json atomically. +// +// Fields Load() populated from an environment variable (DOJO_GATEWAY_URL, +// DOJO_PROTOCOL_DISABLED, ...) are transient by design — they override +// settings.json for the current run, not rewrite it. Without this guard, any +// such env var combined with any command that saves config (/model set, +// /disposition set) would silently and permanently bake the override into +// settings.json. This actually happened: a run with DOJO_PROTOCOL_DISABLED=1 +// that also triggered a save wrote protocol.enabled:false to disk, disabling +// the protocol on every later run even with the env var unset. +// +// So for each field Load() env-overrode (tracked in c.envOverrides), Save() +// checks whether the field still holds the exact value the override wrote: +// if so, nothing has touched it since Load(), and the pre-override value +// (from the file, or the default) is written instead of the transient one. +// If the field has since been explicitly changed by application code (e.g. +// /model set mutating Defaults.Model directly), that's a real user edit and +// is kept as-is — this only strips values that are still purely env-sourced. func (c *Config) Save() error { - data, err := json.MarshalIndent(c, "", " ") + out := *c + for _, ov := range c.envOverrides { + if ov.get(c) == ov.envVal { + ov.set(&out, ov.preEnv) + } + } + + data, err := json.MarshalIndent(&out, "", " ") if err != nil { return err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1168546..a6f3a1a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -780,3 +780,211 @@ func TestEffectiveString_AuthNotSet(t *testing.T) { t.Errorf("EffectiveString() missing %q\ngot:\n%s", want, out) } } + +// ─── Save() strips transient env overrides ─────────────────────────────────── +// +// Regression coverage for a real incident: Load() applies env var overrides +// directly onto the runtime *Config it returns. Several commands (/model +// set, /disposition set) later call cfg.Save() on that same *Config for an +// unrelated reason. Before this fix, Save() serialized whatever the struct +// held — so a purely run-scoped override like DOJO_PROTOCOL_DISABLED=1 got +// baked into settings.json permanently the moment any save happened, +// silently disabling the protocol on every later run even with the env var +// unset. See envOverride and Config.Save() for the fix. + +func clearAllConfigEnv(t *testing.T) { + t.Helper() + for _, k := range []string{ + "DOJO_GATEWAY_URL", "DOJO_GATEWAY_TOKEN", "DOJO_PLUGINS_PATH", + "DOJO_PROVIDER", "DOJO_DISPOSITION", "DOJO_MODEL", "DOJO_USER_ID", + "DOJO_PROTOCOL_DISABLED", "DOJO_PROTOCOL_PATH", + } { + t.Setenv(k, "") + } +} + +func TestSave_StripsEnvOverride_ProtocolDisabled(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + clearAllConfigEnv(t) + t.Setenv("DOJO_PROTOCOL_DISABLED", "1") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.Enabled { + t.Fatal("sanity check failed: DOJO_PROTOCOL_DISABLED=1 should have disabled the protocol") + } + + // Simulate a command that saves config for an unrelated reason — this is + // the actual trigger that leaked the env override to disk in the field + // (e.g. /model set calling cfg.Save() after changing Defaults.Model). + cfg.Defaults.Model = "claude-sonnet-4-6" + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error: %v", err) + } + + // Reload with the SAME settings.json but WITHOUT the env var. Before the + // fix this came back false — permanently stuck disabled. + t.Setenv("DOJO_PROTOCOL_DISABLED", "") + reloaded, err := Load() + if err != nil { + t.Fatalf("reload error: %v", err) + } + if !reloaded.Protocol.Enabled { + t.Error("Protocol.Enabled should be back to true after reload without DOJO_PROTOCOL_DISABLED — Save() must not persist a still-in-effect env override") + } + // The unrelated explicit change made before Save() must still have + // persisted normally. + if reloaded.Defaults.Model != "claude-sonnet-4-6" { + t.Errorf("Defaults.Model should have been saved: got %q, want %q", reloaded.Defaults.Model, "claude-sonnet-4-6") + } +} + +func TestSave_StripsEnvOverride_GatewayURL(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + clearAllConfigEnv(t) + t.Setenv("DOJO_GATEWAY_URL", "http://env-transient:9999") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Gateway.URL != "http://env-transient:9999" { + t.Fatalf("sanity check failed: Gateway.URL = %q, want the env override", cfg.Gateway.URL) + } + + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error: %v", err) + } + + t.Setenv("DOJO_GATEWAY_URL", "") + reloaded, err := Load() + if err != nil { + t.Fatalf("reload error: %v", err) + } + if reloaded.Gateway.URL != DefaultGatewayURL { + t.Errorf("Gateway.URL after reload: got %q, want default %q — Save() must not persist a still-in-effect env override", reloaded.Gateway.URL, DefaultGatewayURL) + } +} + +// The realistic version of the incident: settings.json already holds a real, +// non-default value on disk. Save() must restore THAT value, not just the +// compiled-in default, when stripping a still-in-effect env override. +func TestSave_StripsEnvOverride_RevertsToFileValueNotJustDefault(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + clearAllConfigEnv(t) + + dojoDir := filepath.Join(tmp, ".dojo") + if err := os.MkdirAll(dojoDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + seeded := map[string]any{"gateway": map[string]any{"url": "http://my-real-gateway:7340", "timeout": "60s"}} + data, _ := json.Marshal(seeded) + if err := os.WriteFile(filepath.Join(dojoDir, "settings.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // A one-off run overrides the gateway via env (e.g. pointing at a local + // test instance)... + t.Setenv("DOJO_GATEWAY_URL", "http://one-off-test-gateway:1234") + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Gateway.URL != "http://one-off-test-gateway:1234" { + t.Fatalf("sanity check failed: Gateway.URL = %q", cfg.Gateway.URL) + } + + // ...and that same run happens to also save config for an unrelated + // reason (/disposition set). + cfg.Defaults.Disposition = "focused" + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error: %v", err) + } + + // Next run, without the env var: must see the REAL gateway from the + // file, not the one-off test value. + t.Setenv("DOJO_GATEWAY_URL", "") + reloaded, err := Load() + if err != nil { + t.Fatalf("reload error: %v", err) + } + if reloaded.Gateway.URL != "http://my-real-gateway:7340" { + t.Errorf("Gateway.URL after reload: got %q, want the pre-existing file value %q", reloaded.Gateway.URL, "http://my-real-gateway:7340") + } + if reloaded.Defaults.Disposition != "focused" { + t.Errorf("Defaults.Disposition should have persisted the explicit change: got %q", reloaded.Defaults.Disposition) + } +} + +// The other half of the fix: an explicit change to the SAME field an env var +// overrode must win, not get reverted. This is what /model set does — set +// Defaults.Model directly, then Save() — and it must not be treated as +// "still env-controlled" just because DOJO_MODEL happened to be set too. +func TestSave_ExplicitChangeToEnvOverriddenField_Wins(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + clearAllConfigEnv(t) + t.Setenv("DOJO_MODEL", "env-model") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Defaults.Model != "env-model" { + t.Fatalf("sanity check failed: Defaults.Model = %q", cfg.Defaults.Model) + } + + // Exactly what cmd_model.go's /model set does: mutate the same field the + // env var had overridden, then Save(). + cfg.Defaults.Model = "explicitly-chosen-model" + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error: %v", err) + } + + t.Setenv("DOJO_MODEL", "") + reloaded, err := Load() + if err != nil { + t.Fatalf("reload error: %v", err) + } + if reloaded.Defaults.Model != "explicitly-chosen-model" { + t.Errorf("Defaults.Model: got %q, want the explicitly-set value %q — an explicit change to an env-overridden field must survive Save(), not get reverted", reloaded.Defaults.Model, "explicitly-chosen-model") + } +} + +// A Config built directly (not via Load()) has no envOverrides recorded at +// all — Save() must be a plain, unmodified round-trip in that case, exactly +// as it always was. Guards against the fix accidentally touching fields it +// has no override record for. +func TestSave_NoEnvOverrides_PlainRoundTrip(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + cfg := &Config{ + Gateway: GatewayConfig{URL: "http://literal:7340", Timeout: "60s"}, + Defaults: DefaultsConfig{Disposition: "balanced", Model: "literal-model"}, + Protocol: ProtocolConfig{Enabled: false}, + } + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error: %v", err) + } + + clearAllConfigEnv(t) + reloaded, err := Load() + if err != nil { + t.Fatalf("reload error: %v", err) + } + if reloaded.Gateway.URL != "http://literal:7340" { + t.Errorf("Gateway.URL: got %q, want %q", reloaded.Gateway.URL, "http://literal:7340") + } + if reloaded.Defaults.Model != "literal-model" { + t.Errorf("Defaults.Model: got %q, want %q", reloaded.Defaults.Model, "literal-model") + } + if reloaded.Protocol.Enabled { + t.Error("Protocol.Enabled: got true, want false (the literal value written) — no env override was ever in play") + } +} diff --git a/internal/config/dispositions.go b/internal/config/dispositions.go index 04a93c1..743d1af 100644 --- a/internal/config/dispositions.go +++ b/internal/config/dispositions.go @@ -9,21 +9,48 @@ import ( ) // DispositionPreset defines an ADA disposition configuration. +// +// Pacing/Depth/Tone/Initiative are pure interaction style — how fast, how +// deep, how warm, how eagerly the agent acts. Discipline is a separate axis: +// a short note on which cognitive gate(s) this preset leans on harder or +// lighter than the default (orchestrator-binding, output-channel discipline, +// the debugging gate, etc. — see the workspace CLAUDE.md's Operating Gates). +// Style says how it talks; discipline says what it's stricter or looser +// about while doing so. +// +// Discipline is additive: a preset loaded from JSON/YAML written before this +// field existed simply unmarshals it to "" (Go's zero value for string), so +// old file-based presets under ~/.dojo/dispositions/*.json keep loading +// exactly as before — an empty Discipline just means "no note; behave like +// the default gate set." type DispositionPreset struct { Name string `json:"name"` Pacing string `json:"pacing"` Depth string `json:"depth"` Tone string `json:"tone"` Initiative string `json:"initiative"` + Discipline string `json:"discipline,omitempty"` } // BuiltinPresets returns the four canonical disposition presets. func BuiltinPresets() []DispositionPreset { return []DispositionPreset{ - {Name: "focused", Pacing: "swift", Depth: "concise", Tone: "direct", Initiative: "reactive"}, - {Name: "balanced", Pacing: "measured", Depth: "thorough", Tone: "balanced", Initiative: "proactive"}, - {Name: "exploratory", Pacing: "measured", Depth: "exhaustive", Tone: "warm", Initiative: "autonomous"}, - {Name: "deliberate", Pacing: "deliberate", Depth: "exhaustive", Tone: "direct", Initiative: "proactive"}, + { + Name: "focused", Pacing: "swift", Depth: "concise", Tone: "direct", Initiative: "reactive", + Discipline: "tighten output-channel discipline: route big or reusable output to a file with a short status+path, keep chat lean, no exploratory detours", + }, + { + Name: "balanced", Pacing: "measured", Depth: "thorough", Tone: "balanced", Initiative: "proactive", + Discipline: "default gates: apply the standard operating gates as written, no loosening or tightening", + }, + { + Name: "exploratory", Pacing: "measured", Depth: "exhaustive", Tone: "warm", Initiative: "autonomous", + Discipline: "relax orchestrator-binding: widen search, favor breadth and inline investigation over immediately dispatching agents", + }, + { + Name: "deliberate", Pacing: "deliberate", Depth: "exhaustive", Tone: "direct", Initiative: "proactive", + Discipline: "enforce the debugging gate hard: no fix lands without a stated causal chain and a repro that toggles the bug on and off", + }, } } diff --git a/internal/config/dispositions_test.go b/internal/config/dispositions_test.go index fb3a6cd..63653a3 100644 --- a/internal/config/dispositions_test.go +++ b/internal/config/dispositions_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -182,7 +183,7 @@ func TestConfig_DispositionProfiles_RoundTrip(t *testing.T) { func TestConfig_Validate_CustomProfileAllowed(t *testing.T) { cfg := &Config{ - Gateway: GatewayConfig{URL: DefaultGatewayURL, Timeout: "60s"}, + Gateway: GatewayConfig{URL: DefaultGatewayURL, Timeout: "60s"}, Defaults: DefaultsConfig{Disposition: "myprofile"}, DispositionProfiles: map[string]DispositionPreset{ "myprofile": {Name: "myprofile", Pacing: "measured", Depth: "thorough", Tone: "warm", Initiative: "proactive"}, @@ -228,3 +229,100 @@ func TestMergeBuiltins(t *testing.T) { } t.Error("focused preset missing from merged results") } + +// ─── Discipline field ───────────────────────────────────────────────────────── +// +// Discipline is a 5th DispositionPreset field carrying cognitive discipline +// (which operating gate this preset leans harder or lighter on), separate +// from Pacing/Depth/Tone/Initiative which are pure interaction style. It +// must be additive: existing file-based presets written before this field +// existed still load fine, with Discipline defaulting to "". + +func TestBuiltinPresets_HaveDiscipline(t *testing.T) { + presets := BuiltinPresets() + byName := make(map[string]DispositionPreset, len(presets)) + for _, p := range presets { + byName[p.Name] = p + } + + for _, name := range []string{"focused", "balanced", "exploratory", "deliberate"} { + p, ok := byName[name] + if !ok { + t.Fatalf("BuiltinPresets() missing preset %q", name) + } + if p.Discipline == "" { + t.Errorf("preset %q has empty Discipline; every builtin should carry a discipline note", name) + } + } +} + +// Each builtin's Discipline should actually describe that preset's intended +// lean, not just be any non-empty string — pin the specific gate each names. +func TestBuiltinPresets_DisciplineNamesExpectedGate(t *testing.T) { + byName := make(map[string]DispositionPreset) + for _, p := range BuiltinPresets() { + byName[p.Name] = p + } + cases := map[string]string{ + "focused": "output-channel", + "balanced": "default gates", + "exploratory": "orchestrator-binding", + "deliberate": "debugging gate", + } + for name, substr := range cases { + p, ok := byName[name] + if !ok { + t.Fatalf("BuiltinPresets() missing preset %q", name) + } + if !strings.Contains(p.Discipline, substr) { + t.Errorf("preset %q Discipline = %q; want it to mention %q", name, p.Discipline, substr) + } + } +} + +func TestDispositionPreset_MissingDiscipline_LoadsAsZeroValue(t *testing.T) { + // A preset file written before the Discipline field existed — must still + // unmarshal cleanly, with Discipline defaulting to the zero value. + data := []byte(`{"name":"legacy","pacing":"swift","depth":"concise","tone":"direct","initiative":"reactive"}`) + var p DispositionPreset + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("Unmarshal() of a pre-Discipline preset failed: %v", err) + } + if p.Discipline != "" { + t.Errorf(`Discipline should default to "" when absent from JSON, got %q`, p.Discipline) + } + // The rest of the preset must still be intact — the new field must not + // disturb existing decoding. + if p.Name != "legacy" || p.Pacing != "swift" || p.Depth != "concise" || p.Tone != "direct" || p.Initiative != "reactive" { + t.Errorf("preset fields corrupted by a missing discipline key: %+v", p) + } +} + +func TestDispositionPreset_Discipline_RoundTrips(t *testing.T) { + tmp := t.TempDir() + origHome := os.Getenv("HOME") + t.Setenv("HOME", tmp) + defer func() { _ = os.Setenv("HOME", origHome) }() // test cleanup; restore is best-effort, t.Setenv already restores on test end + + p := DispositionPreset{ + Name: "with-discipline", Pacing: "swift", Depth: "concise", Tone: "direct", Initiative: "reactive", + Discipline: "custom discipline note", + } + if err := SaveDispositionPreset(p); err != nil { + t.Fatalf("SaveDispositionPreset() error: %v", err) + } + + presets, err := LoadDispositionPresets() + if err != nil { + t.Fatalf("LoadDispositionPresets() error: %v", err) + } + for _, loaded := range presets { + if loaded.Name == "with-discipline" { + if loaded.Discipline != "custom discipline note" { + t.Errorf("Discipline did not round-trip: got %q", loaded.Discipline) + } + return + } + } + t.Fatal("saved preset with-discipline not found after LoadDispositionPresets()") +} From 8728b77048dbae2bc721a02fe31ed929606bc622 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 05:00:55 -0500 Subject: [PATCH 12/23] feat(doctor): surface protocol + harness state in /doctor Add PROTOCOL (enabled + source; WARN when silenced) and HARNESSES (kata-harness installed?) sections so a disabled protocol or missing harness is visible rather than silent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/cmd_doctor.go | 96 ++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/internal/commands/cmd_doctor.go b/internal/commands/cmd_doctor.go index f8ff935..61d3268 100644 --- a/internal/commands/cmd_doctor.go +++ b/internal/commands/cmd_doctor.go @@ -1,7 +1,8 @@ package commands // cmd_doctor.go — /doctor: a read-only, one-screen diagnostic over gateway -// reachability, per-provider status, active config, and loaded plugins/hooks. +// reachability, per-provider status, active config, protocol state, and +// loaded plugins/hooks/harnesses. // No mutations — this command never writes config, never calls a gateway // write endpoint, and never changes state. // @@ -9,13 +10,16 @@ package commands // elsewhere in this package/tree as of this writing (Registry.cfg/gw/plgs, // client.Client.Health/Providers/FriendlyError, config.SettingsPath/ // IsKnownDisposition/DefaultDisposition, and the package's existing -// orDefault helper). It deliberately does NOT reference any Protocol config -// fields — those are landing in config.go on a concurrent track. +// orDefault helper). Protocol config (cfg.Protocol.Enabled/Path, landed on a +// concurrent track) is now referenced by the PROTOCOL section below — the +// "overrides must be visible" rule means a silenced protocol must show up +// as a WARN here, not go invisible. import ( "context" "fmt" "os" + "path/filepath" "strings" "github.com/DojoGenesis/cli/internal/config" @@ -28,7 +32,7 @@ func (r *Registry) doctorCmd() Command { return Command{ Name: "doctor", Usage: "/doctor", - Short: "Read-only diagnostic: gateway, providers, config, plugins/hooks", + Short: "Read-only diagnostic: gateway, providers, config, plugins/hooks, protocol, harnesses", Run: func(ctx context.Context, args []string) error { fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Dojo Doctor")) @@ -61,6 +65,12 @@ func (r *Registry) doctorCmd() Command { section("PLUGINS/HOOKS") r.doctorPlugins(check) + section("PROTOCOL") + r.doctorProtocol(check) + + section("HARNESSES") + r.doctorHarnesses(check) + fmt.Println() if warnCount == 0 { fmt.Println(gcolor.HEX("#7fb88c").Sprint(" All checks OK")) @@ -152,8 +162,10 @@ func (r *Registry) doctorProviders(ctx context.Context, check doctorCheck) { // ─── CONFIG ───────────────────────────────────────────────────────────────── // doctorConfig surfaces gateway.url, the active disposition, and the config -// file path. Deliberately does not touch any Protocol config fields — see -// the file-level comment. +// file path. Deliberately does not touch any Protocol config fields — those +// get their own visibility in the PROTOCOL section below (doctorProtocol), +// so a disabled protocol WARNs on its own line instead of hiding inside a +// generic config dump. func (r *Registry) doctorConfig(check doctorCheck) { url := r.cfg.Gateway.URL if url == "" { @@ -189,3 +201,75 @@ func (r *Registry) doctorPlugins(check doctorCheck) { } check(true, fmt.Sprintf("%d plugin(s) loaded, %d hook rule(s) registered", len(r.plgs), hookRules)) } + +// ─── PROTOCOL ─────────────────────────────────────────────────────────────── + +// doctorProtocol surfaces whether the workspace "genius protocol" (see +// internal/protocol) is live for this session, and where its doc resolves +// from. Enabled defaults to true and can be silenced two ways — a +// settings.json "protocol": {"enabled": false}, or DOJO_PROTOCOL_DISABLED at +// runtime — so per the "overrides must be visible" rule, a silenced +// protocol must WARN here rather than disappear into a quiet default. +// +// The source check is a deliberately cheap approximation of +// protocol.LoadOverlay's real precedence (project ./DOJO.md > +// ~/.dojo/DOJO.md > embedded default): an explicit cfg.Protocol.Path always +// wins outright (mirrors protocol.BuildSystemContext), so the cwd-override +// note only applies — and is only checked — when Path is unset. It is a +// bare os.Stat, not a content read, so an empty/whitespace-only DOJO.md +// (which protocol.LoadOverlay treats as absent) is still reported here as +// an override; that's an accepted gap for the cost of staying read-only and +// dependency-free. +func (r *Registry) doctorProtocol(check doctorCheck) { + if r.cfg.Protocol.Enabled { + check(true, "protocol.enabled = true") + } else { + check(false, "protocol disabled — set protocol.enabled or unset DOJO_PROTOCOL_DISABLED") + } + + source := r.cfg.Protocol.Path + if source == "" { + source = "embedded default" + if cwd, err := os.Getwd(); err == nil { + if _, statErr := os.Stat(filepath.Join(cwd, "DOJO.md")); statErr == nil { + source = "embedded default — overridden by project ./DOJO.md" + } + } + } + check(true, "source = "+source) +} + +// ─── HARNESSES ────────────────────────────────────────────────────────────── + +// harnessSuffix is the naming convention KE-fleet harness plugins share +// (kata-harness today; build-dag-harness, convergence-harness, and +// memory-garden-harness once their ADRs ratify and they ship as plugins — +// see the Harness Rails table in the workspace CLAUDE.md). Recognizing any +// loaded plugin by this suffix avoids hardcoding a name list that goes +// stale the moment the next harness ratifies. +const harnessSuffix = "-harness" + +// doctorHarnesses checks whether kata-harness — the only ratified harness as +// of this writing (see firstPartyPlugins in internal/bootstrap/bootstrap.go) +// — is installed under the plugins path, and reports how many of the +// currently loaded plugins (r.plgs) are recognized as harnesses by name. +// Absence isn't a hard requirement (kata-harness ships via /init, nothing +// forces it into an existing ~/.dojo), but it must WARN rather than pass +// silently — the same "genius protocol by default" visibility this doctor +// enhancement exists to surface. +func (r *Registry) doctorHarnesses(check doctorCheck) { + kataPath := filepath.Join(r.cfg.Plugins.Path, "kata-harness") + if info, err := os.Stat(kataPath); err == nil && info.IsDir() { + check(true, "kata-harness installed at "+kataPath) + } else { + check(false, "kata-harness not installed — run /init") + } + + recognized := 0 + for _, p := range r.plgs { + if strings.HasSuffix(p.Name, harnessSuffix) { + recognized++ + } + } + check(true, fmt.Sprintf("%d of %d loaded plugin(s) recognized as harnesses", recognized, len(r.plgs))) +} From 3a7cd64a826996c5b06641268999e83a57651792 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 05:09:05 -0500 Subject: [PATCH 13/23] test(commands): isolate HOME so tests stop clobbering ~/.dojo/settings.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package TestMain now points HOME at a temp dir. Several commands call cfg.Save() (e.g. /disposition set); unisolated tests were overwriting the developer's real settings.json on every 'go test ./...' — writing gateway.url=http://test:7340 and protocol.enabled=false, the source of the spurious 'lookup test: no such host' failures seen while dogfooding. Fixed at the package level so any future Save-triggering test is safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/commands_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index b5abd07..fc0b5a6 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -17,6 +17,25 @@ import ( "github.com/DojoGenesis/cli/internal/plugins" ) +// TestMain isolates every commands-package test from the real ~/.dojo. Several +// commands (/disposition set, /model set, /settings set) call cfg.Save(), which +// writes settings.json under $HOME. Without this, `go test ./...` clobbered the +// developer's live config — it wrote gateway.url=http://test:7340 and +// protocol.enabled=false over a real settings.json, which is what produced the +// spurious "dial tcp: lookup test: no such host" failures during dogfooding. +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "dojo-commands-test-home") + if err != nil { + panic(err) + } + if err := os.Setenv("HOME", tmp); err != nil { + panic(err) + } + code := m.Run() + _ = os.RemoveAll(tmp) + os.Exit(code) +} + // testRegistry builds a minimal Registry suitable for tests that do not call gw. // gw is nil — only commands that are purely client-side (session, practice, help, // settings, trace, hooks ls, projects) are safe to dispatch in unit tests. From e5f1b54885c5191e89d8261f221b593bd6d73164 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 08:51:58 -0500 Subject: [PATCH 14/23] feat(repl): add glamour markdown renderer (RenderMarkdown) RenderMarkdown(full, plain) styles assistant markdown (code fences, lists, headings) width-aware when interactive, raw passthrough under --plain/--json. Live streaming and Render/RenderJSON/ClassifyChunk are unchanged; end-of-turn wiring follows separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- go.mod | 29 +++++++++----- go.sum | 70 +++++++++++++++++++++++++--------- internal/repl/renderer.go | 53 +++++++++++++++++++++++++ internal/repl/renderer_test.go | 60 +++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 6137271..90f1531 100644 --- a/go.mod +++ b/go.mod @@ -4,24 +4,31 @@ go 1.24.0 require ( github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/chzyer/readline v1.5.1 github.com/gookit/color v1.6.0 github.com/wailsapp/wails/v2 v2.12.0 + golang.org/x/term v0.36.0 ) require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/labstack/echo/v4 v4.13.3 // indirect @@ -30,13 +37,15 @@ require ( github.com/leaanthony/gosod v1.0.4 // indirect github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/u v1.1.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect @@ -48,8 +57,10 @@ require ( github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.22.0 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect ) diff --git a/go.sum b/go.sum index 73ad01e..f66ffa7 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,35 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= @@ -24,6 +40,8 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -36,8 +54,12 @@ github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= @@ -54,8 +76,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/ github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= @@ -66,12 +88,17 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -80,6 +107,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -101,13 +129,17 @@ github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozH github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -116,12 +148,14 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/repl/renderer.go b/internal/repl/renderer.go index ef9bb26..d17af3a 100644 --- a/internal/repl/renderer.go +++ b/internal/repl/renderer.go @@ -5,10 +5,13 @@ package repl import ( "encoding/json" "fmt" + "os" "strings" "github.com/DojoGenesis/cli/internal/client" + "github.com/charmbracelet/glamour" gcolor "github.com/gookit/color" + "golang.org/x/term" ) // EventType classifies SSE chunks for rendering. @@ -213,6 +216,56 @@ func (re RenderEvent) Render(plain bool) string { return re.Content } +// RenderMarkdown renders a FULLY ASSEMBLED assistant message as styled +// markdown for terminal display (fenced code blocks, headings, lists, etc.). +// +// Callers must pass the complete message text, not an individual EventText +// chunk. Assistant text arrives from the gateway as streaming chunks, and +// glamour parses markdown structurally — it cannot render a partial +// document — so this is deliberately NOT wired into the per-chunk Render() +// path above; wiring it there would regress live streaming. The intended +// call site is end-of-turn, once every EventText chunk for a response has +// been concatenated into one string. +// +// When plain is true, full is returned verbatim: --plain/--json/piped +// consumers must always see raw markdown text, never ANSI escapes. +func RenderMarkdown(full string, plain bool) string { + if plain { + return full + } + + mdRenderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(terminalWidth()), + ) + if err != nil { + // Styling failure must never eat the response — fall back to raw text. + return full + } + + out, err := mdRenderer.Render(full) + if err != nil { + return full + } + + // glamour's document style brackets output in a blank-line block_prefix/ + // suffix plus a left margin; trim the outer blank lines so the result + // composes cleanly with the caller's own spacing. + return strings.TrimSpace(out) +} + +// terminalWidth returns the current terminal's column width for glamour's +// word-wrap, falling back to a sane default when stdout isn't a TTY (piped +// output, CI, or `go test`). +func terminalWidth() int { + const fallbackWidth = 80 + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w <= 0 { + return fallbackWidth + } + return w +} + // ─── internal content extraction ───────────────────────────────────────────── // extractContentFromData pulls readable text from a raw SSE data string. diff --git a/internal/repl/renderer_test.go b/internal/repl/renderer_test.go index b2cda18..788e916 100644 --- a/internal/repl/renderer_test.go +++ b/internal/repl/renderer_test.go @@ -528,3 +528,63 @@ func TestExtractError_Variants(t *testing.T) { } } } + +// ─── RenderMarkdown ───────────────────────────────────────────────────────── +// +// RenderMarkdown takes the FULL assembled message (not a stream chunk) and, +// when !plain, runs it through glamour. These tests exercise the exported +// contract only: plain mode is a byte-for-byte passthrough (pipeline +// safety), and styled mode actually parses the markdown structure rather +// than just reformatting whitespace around it. + +const testMarkdownDoc = "# Heading\n\nSome text.\n\n```go\nfmt.Println(\"hi\")\n```\n" + +func TestRenderMarkdown_Plain_ReturnsRawVerbatim(t *testing.T) { + got := RenderMarkdown(testMarkdownDoc, true) + if got != testMarkdownDoc { + t.Errorf("RenderMarkdown(plain=true) = %q, want raw input unchanged %q", got, testMarkdownDoc) + } +} + +func TestRenderMarkdown_Plain_EmptyInput(t *testing.T) { + if got := RenderMarkdown("", true); got != "" { + t.Errorf("RenderMarkdown(\"\", true) = %q, want empty", got) + } +} + +func TestRenderMarkdown_Styled_DiffersFromRawAndPreservesContent(t *testing.T) { + got := RenderMarkdown(testMarkdownDoc, false) + + if got == "" { + t.Fatal("RenderMarkdown(plain=false) returned empty output for a non-empty document") + } + if got == testMarkdownDoc { + t.Fatal("RenderMarkdown(plain=false) returned the raw markdown unchanged — styling did not run") + } + + // The heading text and fenced code-block content must survive styling. + if !strings.Contains(got, "Heading") { + t.Errorf("styled output missing heading text %q:\n%s", "Heading", got) + } + if !strings.Contains(got, `fmt.Println("hi")`) { + t.Errorf("styled output missing code-block content %q:\n%s", `fmt.Println("hi")`, got) + } + + // A real markdown render (not just a whitespace/ANSI wrap of the raw + // bytes) parses the fence into a code block and drops the ``` markers — + // their absence is positive evidence that glamour actually ran. + if strings.Contains(got, "```") { + t.Errorf("styled output still contains raw fence markers, glamour did not parse the code block:\n%s", got) + } +} + +func TestRenderMarkdown_Styled_HeadingOnly(t *testing.T) { + const headingOnly = "# Just A Heading\n" + got := RenderMarkdown(headingOnly, false) + if got == "" || got == headingOnly { + t.Errorf("RenderMarkdown(plain=false) on heading-only input = %q, want non-empty and different from raw %q", got, headingOnly) + } + if !strings.Contains(got, "Just A Heading") { + t.Errorf("styled output missing heading text:\n%s", got) + } +} From 432ec973cd7ec8de56c5499922e8967e9e2975bb Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 08:51:58 -0500 Subject: [PATCH 15/23] =?UTF-8?q?feat(session):=20real=20resume=20?= =?UTF-8?q?=E2=80=94=20history,=20/session=20ls,=20resume-by-id,=20--sessi?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state keeps a bounded (20) de-duplicated session history recorded transparently on Save(). /session ls lists recent sessions (active marked); /session resume <id> switches to a specific id (warn-but-allow); new --session <id> launch flag resumes via the existing resume path with no repl.New signature change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- cmd/dojo/main.go | 19 +- internal/commands/cmd_session.go | 116 ++++++++++-- internal/commands/cmd_session_test.go | 246 ++++++++++++++++++++++++++ internal/state/state.go | 64 ++++++- internal/state/state_test.go | 201 +++++++++++++++++++++ 5 files changed, 631 insertions(+), 15 deletions(-) create mode 100644 internal/commands/cmd_session_test.go diff --git a/cmd/dojo/main.go b/cmd/dojo/main.go index 2744f48..226fe98 100644 --- a/cmd/dojo/main.go +++ b/cmd/dojo/main.go @@ -14,6 +14,7 @@ import ( "github.com/DojoGenesis/cli/internal/config" "github.com/DojoGenesis/cli/internal/protocol" "github.com/DojoGenesis/cli/internal/repl" + "github.com/DojoGenesis/cli/internal/state" gcolor "github.com/gookit/color" ) @@ -29,6 +30,7 @@ func main() { flagOneShot = flag.String("one-shot", "", "Execute a single message and exit (non-interactive)") flagCompletion = flag.String("completion", "", "Generate shell completions (bash|zsh|fish)") flagResume = flag.Bool("resume", false, "Resume the most recent session instead of starting fresh") + flagSession = flag.String("session", "", "Resume a specific session ID instead of the most recent one (implies --resume; see /session ls)") flagJSON = flag.Bool("json", false, "Output JSON lines in one-shot mode (for scripted pipelines)") flagPlain = flag.Bool("plain", false, "Plain text output (no ANSI colors, for piped/CI usage)") ) @@ -172,8 +174,23 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop() + // --session <id> resumes one SPECIFIC session (Claude-Code-style + // resume-by-id), vs. --resume's "whatever was last active". repl.New's + // signature is unchanged (owned by a sibling change — see + // internal/repl/repl.go): its resume bool contract is "load + // state.LastSessionID and restore it verbatim". --session piggybacks on + // that exact contract instead of widening it: persist the requested id + // as the last session BEFORE constructing the REPL, then force + // resume=true so repl.New picks it up. Bare --resume (no --session) is + // untouched — same bool passthrough as before. + resume := *flagResume + if *flagSession != "" { + state.SaveSession(*flagSession) + resume = true + } + // Run REPL (plugin scan happens inside repl.New) - r := repl.New(cfg, gw, *flagResume, *flagPlain || *flagNoColor) + r := repl.New(cfg, gw, resume, *flagPlain || *flagNoColor) if err := r.Run(ctx); err != nil { fatalf("repl error: %s", err) } diff --git a/internal/commands/cmd_session.go b/internal/commands/cmd_session.go index 65b86f6..c07a095 100644 --- a/internal/commands/cmd_session.go +++ b/internal/commands/cmd_session.go @@ -17,8 +17,8 @@ import ( func (r *Registry) sessionCmd() Command { return Command{ Name: "session", - Usage: "/session [new|resume|<id>]", - Short: "Show or change the active session ID", + Usage: "/session [new|ls|resume [id]|<id>]", + Short: "Show, list, or change the active session ID", Run: func(ctx context.Context, args []string) error { if len(args) == 0 { fmt.Println() @@ -34,16 +34,16 @@ func (r *Registry) sessionCmd() Command { printKV("session", *r.session) fmt.Println() state.SaveSession(*r.session) + case "ls": + return sessionLs(*r.session) case "resume": - st, err := state.Load() - if err != nil || st.LastSessionID == "" { - return fmt.Errorf("no prior session to resume") + // Bare `/session resume` restores whatever was last active — unchanged + // from before session history existed. `/session resume <id>` targets + // one specific session by ID (Claude-Code-style resume-a-past-session). + if len(args) >= 2 { + return sessionResumeByID(r.session, args[1]) } - *r.session = st.LastSessionID - fmt.Println() - fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Session resumed")) - printKV("session", *r.session) - fmt.Println() + return sessionResumeLast(r.session) default: // /session <id> switches the client's local pointer to whatever // string was typed — it is never checked against the gateway, so @@ -64,6 +64,102 @@ func (r *Registry) sessionCmd() Command { } } +// sessionResumeLast restores the most recently active session — the original +// bare `/session resume` behavior, unchanged now that history exists +// alongside LastSessionID. +func sessionResumeLast(session *string) error { + st, err := state.Load() + if err != nil || st.LastSessionID == "" { + return fmt.Errorf("no prior session to resume") + } + *session = st.LastSessionID + fmt.Println() + fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Session resumed")) + printKV("session", *session) + fmt.Println() + // LastSessionID isn't changing, but re-saving still bumps this session + // back to the front of history / refreshes its timestamp — see + // State.Save's RecordSession hook in internal/state/state.go. + state.SaveSession(*session) + return nil +} + +// sessionResumeByID implements `/session resume <id>` — switches directly to +// one specific session, unlike bare resume's "whatever was last active". +// There is no gateway round-trip to confirm the id is real (same limitation +// as the bare `/session <id>` switch above), so this can only warn — never +// block — when the id isn't one dojo has seen before locally. +func sessionResumeByID(session *string, id string) error { + st, loadErr := state.Load() + var hist []state.SessionEntry + if loadErr == nil { + hist = st.History() + } + + fmt.Println() + if warning := sessionResumeWarning(id, hist); warning != "" { + fmt.Println(gcolor.HEX("#e8b04a").Sprintf(" warning: %s — switching anyway (not verified against gateway)", warning)) + } + + *session = id + fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Session resumed")) + printKV("session", *session) + fmt.Println() + state.SaveSession(*session) + return nil +} + +// sessionResumeWarning returns a human-readable warning for a resume-by-id +// request, or "" when id looks fine and is present in hist. Pure and +// side-effect-free (no disk I/O) so it's directly unit-testable. Checks the +// same malformed-ID heuristic as the bare `/session <id>` switch first, then +// falls back to a "not found locally" check against history. +func sessionResumeWarning(id string, hist []state.SessionEntry) string { + if w := sessionIDMalformedWarning(id); w != "" { + return w + } + for _, e := range hist { + if e.ID == id { + return "" + } + } + return fmt.Sprintf("%q not found in local session history", id) +} + +// sessionLs lists recent sessions from local history, most-recent-first, +// marking whichever one is currently active in this REPL. Claude-Code-style +// `/session ls`. +func sessionLs(active string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("could not load session history: %w", err) + } + hist := st.History() + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Sessions (%d)\n\n", len(hist))) + if len(hist) == 0 { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" no session history yet — /session new or /session resume <id> to start one")) + fmt.Println() + return nil + } + for _, entry := range hist { + marker := " " + idColor := "#f4a261" + activeTag := "" + if entry.ID == active { + marker = gcolor.HEX("#7fb88c").Sprint("* ") + idColor = "#7fb88c" + activeTag = gcolor.HEX("#94a3b8").Sprint(" (active)") + } + idField := gcolor.HEX(idColor).Sprintf("%-40s", entry.ID) + agoField := gcolor.HEX("#94a3b8").Sprint(fmtAgo(entry.SavedAt)) + fmt.Printf(" %s%s %s%s\n", marker, idField, agoField, activeTag) + } + fmt.Println() + return nil +} + // sessionIDMalformedWarning does a cheap, best-effort sanity check on a // user-supplied session ID and returns a human-readable warning if it looks // obviously wrong (e.g. way too short, or full of characters no session ID diff --git a/internal/commands/cmd_session_test.go b/internal/commands/cmd_session_test.go new file mode 100644 index 0000000..77a492b --- /dev/null +++ b/internal/commands/cmd_session_test.go @@ -0,0 +1,246 @@ +package commands + +// cmd_session_test.go — unit tests for W5-RESUME: /session ls, +// /session resume <id>, and the pure resume-by-id warning logic they share. +// +// commands_test.go's TestMain already points $HOME at a shared temp dir for +// the whole test binary, and its existing /session tests (TestSessionNew, +// TestSessionResume, TestSessionResumeNoPrior) deliberately tolerate +// order-dependent state.json content ("no panic" rather than exact +// assertions). The tests below make precise assertions about history +// contents and ordering, so each one gets its own isolated $HOME via +// t.Setenv — same pattern internal/state/state_test.go uses, and safe here +// because none of these call t.Parallel(). + +import ( + "context" + "path/filepath" + "testing" + + "github.com/DojoGenesis/cli/internal/state" +) + +// overrideSessionStateDir points ~/.dojo/state.json at a fresh temp dir for +// the duration of one test. t.Setenv restores the previous $HOME +// automatically on cleanup. +func overrideSessionStateDir(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + return filepath.Join(tmp, ".dojo", "state.json") +} + +// ─── sessionResumeWarning (pure logic — no disk I/O) ──────────────────────── + +func TestSessionResumeWarningMalformed(t *testing.T) { + cases := []struct { + name string + id string + }{ + {"empty", ""}, + {"whitespace-only", " "}, + {"too-short", "ab"}, + {"shell-fragment", "$(rm -rf /)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sessionResumeWarning(tc.id, nil) + if got == "" { + t.Errorf("sessionResumeWarning(%q, nil) = \"\"; want a malformed-id warning", tc.id) + } + }) + } +} + +func TestSessionResumeWarningNotFound(t *testing.T) { + hist := []state.SessionEntry{ + {ID: "dojo-cli-20260101-000000", SavedAt: "2026-01-01T00:00:00Z"}, + {ID: "dojo-cli-20260102-000000", SavedAt: "2026-01-02T00:00:00Z"}, + } + got := sessionResumeWarning("dojo-cli-99999999-999999", hist) + want := `"dojo-cli-99999999-999999" not found in local session history` + if got != want { + t.Errorf("sessionResumeWarning() = %q; want %q", got, want) + } +} + +func TestSessionResumeWarningFound(t *testing.T) { + hist := []state.SessionEntry{ + {ID: "dojo-cli-20260101-000000", SavedAt: "2026-01-01T00:00:00Z"}, + {ID: "dojo-cli-20260102-000000", SavedAt: "2026-01-02T00:00:00Z"}, + } + got := sessionResumeWarning("dojo-cli-20260102-000000", hist) + if got != "" { + t.Errorf("sessionResumeWarning() = %q; want \"\" for an id present in history", got) + } +} + +func TestSessionResumeWarningEmptyHistory(t *testing.T) { + got := sessionResumeWarning("dojo-cli-20260102-000000", nil) + if got == "" { + t.Error("sessionResumeWarning() = \"\"; want a not-found warning against empty/nil history") + } +} + +// ─── sessionResumeByID (I/O path: warn-but-never-block) ───────────────────── + +// TestSessionResumeByIDSwitchesEvenWhenUnknown verifies /session resume <id> +// never blocks — it warns but still switches — for an id absent from history. +func TestSessionResumeByIDSwitchesEvenWhenUnknown(t *testing.T) { + overrideSessionStateDir(t) + state.SaveSession("dojo-cli-known-session") + + session := "dojo-cli-known-session" + if err := sessionResumeByID(&session, "dojo-cli-unknown-session"); err != nil { + t.Fatalf("sessionResumeByID() error = %v; want nil (never blocks)", err) + } + if session != "dojo-cli-unknown-session" { + t.Errorf("session = %q; want dojo-cli-unknown-session (switched despite not being in history)", session) + } +} + +// TestSessionResumeByIDKnownID verifies resuming an id that IS in history +// switches cleanly and persists as the new LastSessionID (so a subsequent +// bare --resume picks up exactly this session, not whatever was active +// before the resume-by-id call). +func TestSessionResumeByIDKnownID(t *testing.T) { + overrideSessionStateDir(t) + state.SaveSession("dojo-cli-first") + state.SaveSession("dojo-cli-second") + + session := "dojo-cli-second" + if err := sessionResumeByID(&session, "dojo-cli-first"); err != nil { + t.Fatalf("sessionResumeByID() error = %v; want nil", err) + } + if session != "dojo-cli-first" { + t.Errorf("session = %q; want dojo-cli-first", session) + } + + st, err := state.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if st.LastSessionID != "dojo-cli-first" { + t.Errorf("LastSessionID = %q; want dojo-cli-first", st.LastSessionID) + } +} + +// TestSessionResumeByIDEmptyState verifies resume-by-id still works (warns, +// switches, never errors) against a completely fresh workspace with no +// state.json at all. +func TestSessionResumeByIDEmptyState(t *testing.T) { + overrideSessionStateDir(t) + + session := "dojo-cli-current" + if err := sessionResumeByID(&session, "dojo-cli-brand-new"); err != nil { + t.Fatalf("sessionResumeByID() error = %v; want nil", err) + } + if session != "dojo-cli-brand-new" { + t.Errorf("session = %q; want dojo-cli-brand-new", session) + } +} + +// ─── sessionResumeLast (bare `/session resume`, unchanged behavior) ──────── + +// TestSessionResumeLastNoPriorSession verifies bare /session resume still +// errors when there's nothing to resume — same contract as before history +// existed. +func TestSessionResumeLastNoPriorSession(t *testing.T) { + overrideSessionStateDir(t) + + session := "dojo-cli-current" + if err := sessionResumeLast(&session); err == nil { + t.Fatal("sessionResumeLast() error = nil; want an error when no prior session exists") + } +} + +// TestSessionResumeLastRestoresLastSessionID verifies bare /session resume +// restores whatever LastSessionID currently holds. +func TestSessionResumeLastRestoresLastSessionID(t *testing.T) { + overrideSessionStateDir(t) + state.SaveSession("dojo-cli-prior") + + session := "dojo-cli-current" + if err := sessionResumeLast(&session); err != nil { + t.Fatalf("sessionResumeLast() error = %v; want nil", err) + } + if session != "dojo-cli-prior" { + t.Errorf("session = %q; want dojo-cli-prior", session) + } +} + +// ─── sessionLs ─────────────────────────────────────────────────────────────── + +// TestSessionLsEmptyHistory verifies /session ls doesn't error on a fresh +// workspace with no recorded sessions. +func TestSessionLsEmptyHistory(t *testing.T) { + overrideSessionStateDir(t) + + if err := sessionLs("dojo-cli-current"); err != nil { + t.Fatalf("sessionLs() error = %v; want nil for empty history", err) + } +} + +// TestSessionLsPopulatedHistory verifies /session ls doesn't error once +// sessions have been recorded, including when one of them is the active one. +func TestSessionLsPopulatedHistory(t *testing.T) { + overrideSessionStateDir(t) + state.SaveSession("dojo-cli-one") + state.SaveSession("dojo-cli-two") + + if err := sessionLs("dojo-cli-two"); err != nil { + t.Fatalf("sessionLs() error = %v; want nil", err) + } +} + +// ─── /session ls and /session resume <id> via full dispatch ──────────────── +// +// testRegistry (defined in commands_test.go, same package) builds a +// Registry with gw=nil; /session's subcommands are purely client-side so +// they're safe to dispatch without a gateway, matching that file's existing +// "sessionCmd integration" tests. + +// TestDispatchSessionLs verifies `/session ls` reaches sessionLs through the +// full command-dispatch path, not just the extracted helper directly. +func TestDispatchSessionLs(t *testing.T) { + overrideSessionStateDir(t) + r, session := testRegistry() + state.SaveSession(*session) + + if err := r.Dispatch(context.Background(), "session ls"); err != nil { + t.Fatalf("Dispatch(session ls) error = %v; want nil", err) + } +} + +// TestDispatchSessionResumeByID verifies `/session resume <id>` reaches +// sessionResumeByID through the full command-dispatch path and updates the +// Registry's session pointer. +func TestDispatchSessionResumeByID(t *testing.T) { + overrideSessionStateDir(t) + r, session := testRegistry() + state.SaveSession("dojo-cli-target") + + if err := r.Dispatch(context.Background(), "session resume dojo-cli-target"); err != nil { + t.Fatalf("Dispatch(session resume <id>) error = %v; want nil", err) + } + if *session != "dojo-cli-target" { + t.Errorf("session = %q; want dojo-cli-target", *session) + } +} + +// TestDispatchSessionResumeBareStillWorks verifies bare `/session resume` +// (no id) still reaches sessionResumeLast — the pre-existing behavior — not +// the new resume-by-id path, when dispatched through the switch in +// sessionCmd's Run closure. +func TestDispatchSessionResumeBareStillWorks(t *testing.T) { + overrideSessionStateDir(t) + r, session := testRegistry() + state.SaveSession("dojo-cli-last-active") + + if err := r.Dispatch(context.Background(), "session resume"); err != nil { + t.Fatalf("Dispatch(session resume) error = %v; want nil", err) + } + if *session != "dojo-cli-last-active" { + t.Errorf("session = %q; want dojo-cli-last-active", *session) + } +} diff --git a/internal/state/state.go b/internal/state/state.go index 279090a..aa833bb 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -20,13 +20,29 @@ type GuideProgress struct { Completed []string `json:"completed,omitempty"` // IDs of finished guides } +// SessionEntry records one session's ID and when it was last active. Powers +// `/session ls` and the friendly (non-blocking) validation on +// `/session resume <id>` — see internal/commands/cmd_session.go. +type SessionEntry struct { + ID string `json:"id"` + SavedAt string `json:"saved_at"` // RFC3339, UTC +} + +// maxSessionHistory bounds State.SessionHistory. Oldest entries are dropped +// once the cap is exceeded — see RecordSession. +const maxSessionHistory = 20 + // State persists across REPL sessions at ~/.dojo/state.json. type State struct { - LastSessionID string `json:"last_session_id,omitempty"` - SetupComplete bool `json:"setup_complete,omitempty"` - Agents map[string]Agent `json:"agents,omitempty"` // keyed by agent_id + LastSessionID string `json:"last_session_id,omitempty"` + SetupComplete bool `json:"setup_complete,omitempty"` + Agents map[string]Agent `json:"agents,omitempty"` // keyed by agent_id Spirit spirit.SpiritState `json:"spirit,omitempty"` - Guide GuideProgress `json:"guide,omitempty"` + Guide GuideProgress `json:"guide,omitempty"` + // SessionHistory holds recent sessions, most-recent-first. A state.json + // written before this field existed simply has no "history" key, which + // decodes to a nil slice — empty, never an error. See RecordSession/History. + SessionHistory []SessionEntry `json:"history,omitempty"` } // Agent holds metadata about a known agent. @@ -67,7 +83,15 @@ func Load() (*State, error) { } // Save writes the state to ~/.dojo/state.json atomically with 0600 permissions. +// Before marshalling it folds LastSessionID into the session history (see +// RecordSession) so callers that set LastSessionID directly rather than going +// through SaveSession — e.g. repl.go's post-startup +// `st.LastSessionID = r.session; st.Save()` — still produce/refresh a history +// entry without this package needing repl.go to call RecordSession itself. func (s *State) Save() error { + if s.LastSessionID != "" { + s.RecordSession(s.LastSessionID) + } data, err := json.MarshalIndent(s, "", " ") if err != nil { return err @@ -104,6 +128,38 @@ func SaveSession(sessionID string) { _ = st.Save() } +// RecordSession records sessionID as the most-recently-active session. An +// existing entry for the same ID is moved to the front (with a refreshed +// timestamp) rather than duplicated, and the list is capped at +// maxSessionHistory entries, dropping the oldest first. A no-op for an empty +// ID. Called automatically from Save whenever LastSessionID is set, so most +// callers never need to call this directly. +func (s *State) RecordSession(sessionID string) { + if sessionID == "" { + return + } + now := time.Now().UTC().Format(time.RFC3339) + + entries := make([]SessionEntry, 0, len(s.SessionHistory)+1) + entries = append(entries, SessionEntry{ID: sessionID, SavedAt: now}) + for _, e := range s.SessionHistory { + if e.ID != sessionID { + entries = append(entries, e) + } + } + if len(entries) > maxSessionHistory { + entries = entries[:maxSessionHistory] + } + s.SessionHistory = entries +} + +// History returns the recorded session history, most-recent-first. Empty (a +// nil slice, safe to range over) when state.json predates this field or no +// session has been recorded yet — never an error. +func (s *State) History() []SessionEntry { + return s.SessionHistory +} + // RecentAgents returns agents sorted by last_used (newest first), max n. func (s *State) RecentAgents(n int) []Agent { agents := make([]Agent, 0, len(s.Agents)) diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 94d6c0b..e975267 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -1,6 +1,7 @@ package state import ( + "fmt" "os" "path/filepath" "testing" @@ -167,3 +168,203 @@ func TestRecentAgents(t *testing.T) { // file may or may not exist; this call just ensures no panic. _, _ = os.Stat(filepath.Join(os.Getenv("HOME"), ".dojo", "state.json")) } + +// ─── Session history (W5-RESUME) ──────────────────────────────────────────── +// +// These tests cover State.RecordSession / State.History directly (append, +// cap, de-dup, ordering) plus the two integration points the rest of the +// resume feature depends on: Save()'s auto-record hook (so repl.go's direct +// `st.LastSessionID = id; st.Save()` — untouched by this change — still +// produces history) and SaveSession's wiring (what cmd/dojo/main.go's +// --session flag and internal/commands/cmd_session.go both call through). + +// TestHistoryEmptyByDefault verifies a freshly loaded state (no file, no +// sessions recorded yet) reports an empty history, not an error. +func TestHistoryEmptyByDefault(t *testing.T) { + overrideStateDir(t) + + s, err := Load() + if err != nil { + t.Fatalf("Load() error = %v; want nil", err) + } + if got := s.History(); len(got) != 0 { + t.Errorf("History() len = %d; want 0, got %+v", len(got), got) + } +} + +// TestRecordSessionAppendsMostRecentFirst verifies recording distinct +// session IDs builds a most-recent-first list with valid RFC3339 timestamps. +func TestRecordSessionAppendsMostRecentFirst(t *testing.T) { + s := &State{} + s.RecordSession("sess-1") + s.RecordSession("sess-2") + s.RecordSession("sess-3") + + hist := s.History() + if len(hist) != 3 { + t.Fatalf("History() len = %d; want 3", len(hist)) + } + wantOrder := []string{"sess-3", "sess-2", "sess-1"} + for i, want := range wantOrder { + if hist[i].ID != want { + t.Errorf("History()[%d].ID = %q; want %q", i, hist[i].ID, want) + } + } + for i, e := range hist { + if _, err := time.Parse(time.RFC3339, e.SavedAt); err != nil { + t.Errorf("History()[%d].SavedAt = %q not RFC3339: %v", i, e.SavedAt, err) + } + } +} + +// TestRecordSessionDedup verifies re-recording an existing ID moves it to +// the front instead of duplicating it. +func TestRecordSessionDedup(t *testing.T) { + s := &State{} + s.RecordSession("sess-a") + s.RecordSession("sess-b") + s.RecordSession("sess-c") + + // sess-a becomes active again — should move to front, not duplicate. + s.RecordSession("sess-a") + + hist := s.History() + if len(hist) != 3 { + t.Fatalf("History() len = %d; want 3 (no duplicate entries)", len(hist)) + } + if hist[0].ID != "sess-a" { + t.Errorf("History()[0].ID = %q; want sess-a (moved to front)", hist[0].ID) + } + seen := 0 + for _, e := range hist { + if e.ID == "sess-a" { + seen++ + } + } + if seen != 1 { + t.Errorf("sess-a appears %d times in History(); want exactly 1", seen) + } +} + +// TestRecordSessionCap verifies the history is capped at maxSessionHistory, +// dropping the oldest entries first. +func TestRecordSessionCap(t *testing.T) { + s := &State{} + const total = maxSessionHistory + 5 + for i := 0; i < total; i++ { + s.RecordSession(fmt.Sprintf("sess-%02d", i)) + } + + hist := s.History() + if len(hist) != maxSessionHistory { + t.Fatalf("History() len = %d; want %d (capped)", len(hist), maxSessionHistory) + } + wantNewest := fmt.Sprintf("sess-%02d", total-1) + if hist[0].ID != wantNewest { + t.Errorf("History()[0].ID = %q; want %q (most recently recorded)", hist[0].ID, wantNewest) + } + for _, e := range hist { + if e.ID == "sess-00" || e.ID == "sess-04" { + t.Errorf("History() still contains %q; want it evicted (oldest, over cap)", e.ID) + } + } +} + +// TestRecordSessionEmptyIDNoop verifies an empty session ID is ignored +// rather than recorded as a blank history entry. +func TestRecordSessionEmptyIDNoop(t *testing.T) { + s := &State{} + s.RecordSession("") + if got := s.History(); len(got) != 0 { + t.Errorf("History() len = %d after RecordSession(\"\"); want 0", len(got)) + } +} + +// TestSaveRecordsHistoryFromLastSessionID verifies Save() alone — with no +// explicit RecordSession call — still populates history from whatever +// LastSessionID is set to. This is the hook that lets repl.go's direct +// `st.LastSessionID = id; st.Save()` pattern (internal/repl/repl.go, out of +// scope for this change) participate in history without repl.go needing to +// call RecordSession itself. +func TestSaveRecordsHistoryFromLastSessionID(t *testing.T) { + overrideStateDir(t) + + s := &State{LastSessionID: "sess-direct-assign"} + if err := s.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + hist := loaded.History() + if len(hist) != 1 || hist[0].ID != "sess-direct-assign" { + t.Fatalf("History() = %+v; want single entry sess-direct-assign", hist) + } +} + +// TestHistoryBackwardCompatibleMissingKey verifies a pre-history state.json +// (written before SessionHistory existed, so no "history" key at all) loads +// cleanly with an empty History() — no error, exactly the contract W5-RESUME +// requires. +func TestHistoryBackwardCompatibleMissingKey(t *testing.T) { + path := overrideStateDir(t) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("setup mkdir: %v", err) + } + oldFormat := `{"last_session_id":"pre-history-session","setup_complete":true,"agents":{}}` + if err := os.WriteFile(path, []byte(oldFormat), 0600); err != nil { + t.Fatalf("setup write: %v", err) + } + + s, err := Load() + if err != nil { + t.Fatalf("Load() error = %v; want nil for a pre-history state.json", err) + } + if s.LastSessionID != "pre-history-session" { + t.Errorf("LastSessionID = %q; want pre-history-session", s.LastSessionID) + } + if got := s.History(); len(got) != 0 { + t.Errorf("History() len = %d; want 0 for a pre-history state.json, got %+v", len(got), got) + } +} + +// TestSaveSessionWiring verifies the mechanism cmd/dojo/main.go's --session +// flag relies on: SaveSession(id) persists id as LastSessionID (and records +// it in history), so a subsequent Load() — exactly what repl.New(resume=true) +// performs internally — returns that same id. This is what lets --session +// <id> resume the SPECIFIC session requested rather than whatever was +// previously last active, without repl.New's signature changing. +func TestSaveSessionWiring(t *testing.T) { + overrideStateDir(t) + + // An existing "last active" session before --session is used. + SaveSession("previously-active-session") + + // Simulates `dojo --session targeted-session`: main.go calls SaveSession + // with the requested id before constructing the REPL. + SaveSession("targeted-session") + + // Simulates repl.New(resume=true)'s own load-and-restore. + loaded, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LastSessionID != "targeted-session" { + t.Errorf("LastSessionID = %q; want targeted-session", loaded.LastSessionID) + } + hist := loaded.History() + if len(hist) == 0 || hist[0].ID != "targeted-session" { + t.Fatalf("History()[0] = %+v; want targeted-session at front", hist) + } + found := false + for _, e := range hist { + if e.ID == "previously-active-session" { + found = true + } + } + if !found { + t.Errorf("History() = %+v; want previously-active-session still retained (demoted, not dropped)", hist) + } +} From 233e5d953ec940918319da0b2bae9982dc3da06f Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 08:51:58 -0500 Subject: [PATCH 16/23] feat(repl): complete tab-completion + per-turn token readout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completer now covers /craft, all guide IDs (from guide.All so it can't drift), /doctor, /protocol, /session ls, /code undo, /model direct, /settings effective|profile, /skill search|package-all, /home plain; removed stale /garden harvest and /projects ls. Adds a dim interactive-only per-turn token/latency footer that reads usage in three shapes — a graceful no-op until the gateway attaches usage data. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/repl/repl.go | 188 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 177 insertions(+), 11 deletions(-) diff --git a/internal/repl/repl.go b/internal/repl/repl.go index afd499c..341b801 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -593,8 +593,11 @@ func (r *REPL) chat(ctx context.Context, message string) error { fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" dojo ")) + turnStart := time.Now() var fullText strings.Builder var sawError bool + var tokensIn, tokensOut int + var sawUsage bool err := r.gw.ChatStream(turnCtx, req, func(chunk client.SSEChunk) { ev := ClassifyChunk(chunk) if ev.Type == EventError { @@ -605,6 +608,16 @@ func (r *REPL) chat(ctx context.Context, message string) error { fmt.Print(rendered) fullText.WriteString(ev.Content) } + // Per-turn cost/token readout: opportunistically check every chunk for + // a usage payload (see extractUsage) rather than gating on a specific + // event name — in practice the gateway, if it sends usage at all, only + // attaches it to the terminal "done" event, but checking every chunk + // costs one cheap failed json.Unmarshal when absent and does not affect + // what gets rendered or how the stream proceeds. + if in, out, ok := extractUsage(chunk.Data); ok { + tokensIn, tokensOut = in, out + sawUsage = true + } }) fmt.Println() @@ -638,6 +651,27 @@ func (r *REPL) chat(ctx context.Context, message string) error { fmt.Println(gcolor.HEX("#94a3b8").Sprint(" [no response — the gateway may have encountered an internal error]")) } + // Per-turn cost/token readout — dim, interactive-only footer. Suppressed + // under --plain, which also covers --json: --json is a one-shot-only flag + // (cmd/dojo/main.go) that never reaches the REPL's chat() path at all, so + // gating on r.plain alone is sufficient here. + // + // As of this writing the gateway's /v1/chat stream never actually carries + // usage data: renderer.go's ClassifyChunk discards the "done" event's + // payload entirely, and both TestClassifyChunk_EventDone* fixtures in + // renderer_test.go model "done" data as either empty or a plain human + // string, never JSON. So sawUsage never flips true today and this block is + // a graceful no-op. It activates automatically — no further REPL changes + // needed — the moment the gateway attaches usage to any chunk, since + // extractUsage already recognizes every usage shape used elsewhere against + // this same gateway/providers (see extractUsage's doc comment). + if sawUsage && !r.plain { + total := tokensIn + tokensOut + fmt.Printf(" %s\n", + gcolor.HEX("#64748b").Sprintf("%s tokens · %.1fs", formatTokenCount(total), time.Since(turnStart).Seconds()), + ) + } + // Spirit: award chat XP if spiritSt, spiritErr := state.Load(); spiritErr == nil { spirit.AwardXP(&spiritSt.Spirit, spirit.XPForAction("chat_message")) @@ -694,6 +728,85 @@ func extractText(chunk client.SSEChunk) string { return data } +// extractUsage looks for token-usage counters in a raw SSE chunk payload +// (client.SSEChunk.Data). client.SSEChunk carries no typed usage field — +// Data is opaque JSON (or, per renderer_test.go's "done" fixtures, sometimes +// not JSON at all) — so this is a best-effort, forward-compatible parse +// rather than a decode against a known schema. +// +// It recognizes every usage shape already in use elsewhere against this same +// gateway/providers, in this priority order: +// - {"usage": {"tokens_in": N, "tokens_out": N}} — the Dojo gateway's own +// naming, confirmed live on the /events "complete" entries consumed by +// internal/tui/pilot.go and the /api/telemetry/* rows in +// internal/commands/cmd_telemetry.go. +// - {"usage": {"input_tokens": N, "output_tokens": N}} — Anthropic's +// naming, used by the direct-API path in internal/providers/providers.go. +// - {"usage": {"prompt_tokens": N, "completion_tokens": N}} — OpenAI's +// naming, also used in internal/providers/providers.go. +// +// The same three key-pairs are also checked at the top level (no "usage" +// wrapper), in case a future /v1/chat event flattens them. Returns +// ok == false — a no-op for the caller — when data is empty, non-JSON, or +// JSON that matches none of the above; that is the case for every "done" +// payload seen in this codebase's tests today. +func extractUsage(data string) (tokensIn, tokensOut int, ok bool) { + data = strings.TrimSpace(data) + if data == "" || data == "[DONE]" { + return 0, 0, false + } + + var m map[string]any + if err := json.Unmarshal([]byte(data), &m); err != nil { + return 0, 0, false + } + + usage := m + if nested, isMap := m["usage"].(map[string]any); isMap { + usage = nested + } + + pairs := [][2]string{ + {"tokens_in", "tokens_out"}, + {"input_tokens", "output_tokens"}, + {"prompt_tokens", "completion_tokens"}, + } + for _, p := range pairs { + in, inOK := numField(usage, p[0]) + out, outOK := numField(usage, p[1]) + if inOK || outOK { + return in, out, true + } + } + return 0, 0, false +} + +// numField reads a numeric field out of a decoded-JSON object. encoding/json +// decodes every JSON number into float64 when the target is map[string]any, +// so this centralizes the float64→int conversion instead of repeating the +// type assertion at each call site in extractUsage. +func numField(m map[string]any, key string) (int, bool) { + v, ok := m[key].(float64) + if !ok { + return 0, false + } + return int(v), true +} + +// formatTokenCount renders a token count as a compact, human-scannable +// string for the per-turn footer. Under 1000 it prints the exact count +// ("342"); at or above 1000 it prints a tilde-prefixed one-decimal "k" +// figure ("~1.2k") — the tilde signals "approximate" without repeating the +// word. A trailing ".0" is dropped so an even thousand reads "~1k", not +// "~1.0k". +func formatTokenCount(n int) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + k := strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1000.0), ".0") + return "~" + k + "k" +} + // runPlain is the fallback when readline is unavailable (piped input, CI). func (r *REPL) runPlain(ctx context.Context) error { // Note: printWelcome is already called by Run() before fallback here. @@ -724,13 +837,27 @@ func (r *REPL) runPlain(ctx context.Context) error { // ─── readline setup ────────────────────────────────────────────────────────── func newReadline(turns int) (*readline.Instance, error) { + // /guide start's children are generated from guide.All (internal/guide) + // instead of hand-maintained: guide.All has grown to 17 guides over time + // but this completer only ever offered the original 4 (welcome, spirit, + // agents, memory), so a static list would just drift stale again the next + // time a guide is added. Reading the catalogue here keeps it correct by + // construction — internal/guide itself is untouched. + guideStartItems := make([]readline.PrefixCompleterInterface, 0, len(guide.All)) + for _, g := range guide.All { + guideStartItems = append(guideStartItems, readline.PcItem(g.ID)) + } + completer := readline.NewPrefixCompleter( readline.PcItem("/help"), readline.PcItem("/health"), - readline.PcItem("/home"), + readline.PcItem("/home", + readline.PcItem("plain"), + ), readline.PcItem("/model", readline.PcItem("ls"), readline.PcItem("set"), + readline.PcItem("direct"), ), readline.PcItem("/tools"), readline.PcItem("/agent", @@ -756,13 +883,16 @@ func newReadline(turns int) (*readline.Instance, error) { readline.PcItem("/workflow"), readline.PcItem("/skill", readline.PcItem("ls"), + readline.PcItem("search"), readline.PcItem("get"), readline.PcItem("inspect"), readline.PcItem("tags"), + readline.PcItem("package-all"), ), readline.PcItem("/doc"), readline.PcItem("/session", readline.PcItem("new"), + readline.PcItem("ls"), readline.PcItem("resume"), ), readline.PcItem("/run"), @@ -770,7 +900,6 @@ func newReadline(turns int) (*readline.Instance, error) { readline.PcItem("ls"), readline.PcItem("stats"), readline.PcItem("plant"), - readline.PcItem("harvest"), readline.PcItem("search"), readline.PcItem("rm"), ), @@ -794,8 +923,15 @@ func newReadline(turns int) (*readline.Instance, error) { readline.PcItem("fire"), ), readline.PcItem("/settings", + readline.PcItem("effective"), readline.PcItem("providers"), readline.PcItem("set"), + readline.PcItem("profile", + readline.PcItem("ls"), + readline.PcItem("set"), + readline.PcItem("show"), + readline.PcItem("create"), + ), ), readline.PcItem("/init", readline.PcItem("--force"), @@ -804,14 +940,17 @@ func newReadline(turns int) (*readline.Instance, error) { readline.PcItem("--skip-seeds"), ), readline.PcItem("/practice"), - readline.PcItem("/projects", - readline.PcItem("ls"), - ), + readline.PcItem("/projects"), readline.PcItem("/plugin", readline.PcItem("ls"), readline.PcItem("install"), readline.PcItem("rm"), ), + readline.PcItem("/protocol", + readline.PcItem("status"), + readline.PcItem("harnesses"), + readline.PcItem("install"), + ), readline.PcItem("/disposition", readline.PcItem("ls"), readline.PcItem("set"), @@ -822,12 +961,7 @@ func newReadline(turns int) (*readline.Instance, error) { readline.PcItem("/card"), readline.PcItem("/guide", readline.PcItem("ls"), - readline.PcItem("start", - readline.PcItem("welcome"), - readline.PcItem("spirit"), - readline.PcItem("agents"), - readline.PcItem("memory"), - ), + readline.PcItem("start", guideStartItems...), readline.PcItem("status"), readline.PcItem("stop"), ), @@ -863,7 +997,39 @@ func newReadline(turns int) (*readline.Instance, error) { readline.PcItem("build"), readline.PcItem("vet"), readline.PcItem("gate"), + readline.PcItem("undo"), + ), + readline.PcItem("/craft", + readline.PcItem("adr"), + readline.PcItem("scout"), + readline.PcItem("claude-md", + readline.PcItem("--fix"), + ), + readline.PcItem("memory", + readline.PcItem("ls"), + readline.PcItem("add"), + readline.PcItem("rm"), + readline.PcItem("prune"), + readline.PcItem("search"), + ), + readline.PcItem("seed", + readline.PcItem("ls"), + readline.PcItem("plant"), + readline.PcItem("harvest"), + readline.PcItem("search"), + readline.PcItem("elevate"), + ), + readline.PcItem("view"), + readline.PcItem("scaffold", + readline.PcItem("go-service"), + readline.PcItem("fullstack"), + readline.PcItem("orchestration"), + readline.PcItem("plugin"), + readline.PcItem("minimal"), + ), + readline.PcItem("converge"), ), + readline.PcItem("/doctor"), readline.PcItem("exit"), ) From e717943cdb1e66309d925f9de0b7ad6d35b3e489 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 08:51:58 -0500 Subject: [PATCH 17/23] feat(code): add /code undo to revert uncommitted working-tree changes git restore of unstaged changes to tracked files within the project root, after showing the diff and confirming. Staged content, history, and untracked files are structurally untouched; clean errors when git is missing or not a repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/cmd_code.go | 75 ++++++++++- internal/commands/cmd_code_test.go | 207 ++++++++++++++++++++++++++++- 2 files changed, 277 insertions(+), 5 deletions(-) diff --git a/internal/commands/cmd_code.go b/internal/commands/cmd_code.go index b8529d3..da69d2b 100644 --- a/internal/commands/cmd_code.go +++ b/internal/commands/cmd_code.go @@ -18,11 +18,11 @@ func (r *Registry) codeCmd() Command { return Command{ Name: "code", Aliases: []string{"c"}, - Usage: "/code [read <file>|diff [file]|test [pkg]|build|vet|gate]", + Usage: "/code [read <file>|diff [file]|test [pkg]|build|vet|gate|undo]", Short: "File operations and build tooling for self-build workflows", Run: func(ctx context.Context, args []string) error { if len(args) == 0 { - return fmt.Errorf("usage: /code [read <file>|diff [file]|test [pkg]|build|vet|gate]") + return fmt.Errorf("usage: /code [read <file>|diff [file]|test [pkg]|build|vet|gate|undo]") } sub := strings.ToLower(args[0]) @@ -39,8 +39,10 @@ func (r *Registry) codeCmd() Command { return codeVet() case "gate": return codeGate() + case "undo": + return codeUndo() default: - return fmt.Errorf("unknown subcommand %q — try: read, diff, test, build, vet, gate", sub) + return fmt.Errorf("unknown subcommand %q — try: read, diff, test, build, vet, gate, undo", sub) } }, } @@ -177,6 +179,73 @@ func codeGate() error { return nil } +// codeUndo previews unstaged working-tree changes to tracked files and, +// after explicit confirmation, reverts them. It is the git-backed safety +// net for a self-build session — undoing a bad edit without touching +// staged content, commit history, or untracked files. +// +// Preview and revert both use the "." pathspec, so they act on the same +// scope: the current directory and below (the "project root" boundary +// codeRead enforces for file reads). Neither step ever leaves that scope +// or the enclosing git repository. +func codeUndo() error { + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git not found in PATH — /code undo requires git") + } + if err := exec.Command("git", "rev-parse", "--is-inside-work-tree").Run(); err != nil { + return fmt.Errorf("not a git repository (or any parent up to /)") + } + + // Capture (rather than stream) the diff so we can decide whether there's + // anything to do, and so the confirmation prompt sits directly below the + // exact change-set it describes. + stat, err := exec.Command("git", "diff", "--stat", "--", ".").Output() + if err != nil { + return fmt.Errorf("could not read git diff: %w", err) + } + + if len(strings.TrimSpace(string(stat))) == 0 { + fmt.Println() + gcolor.HEX("#94a3b8").Print(" Nothing to revert — no unstaged changes to tracked files.") + fmt.Println() + fmt.Println() + return nil + } + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" /code undo — would revert these unstaged changes:")) + fmt.Println() + fmt.Println() + fmt.Print(string(stat)) + fmt.Println() + gcolor.HEX("#94a3b8").Print(" Staged changes and commit history are untouched; untracked files are left alone.") + fmt.Println() + fmt.Println() + + // Reuses the same y/N stdin idiom as plugins.InstallConfirmed (see + // cmd_plugin.go) — craftConfirm lives in cmd_craft.go but is + // package-visible, so no duplicate confirm helper is needed here. + if !craftConfirm("Revert these unstaged changes?") { + gcolor.HEX("#94a3b8").Print(" Cancelled — no changes made.") + fmt.Println() + fmt.Println() + return nil + } + + // git restore (no --staged) rewrites the worktree from the index only: + // staged content and history are structurally untouched, and untracked + // files are structurally out of scope (nothing to restore them from). + if err := runGitCmd("restore", "--", "."); err != nil { + return fmt.Errorf("git restore failed: %w", err) + } + + fmt.Println() + gcolor.HEX("#22c55e").Print(" Reverted. Working tree now matches the index.") + fmt.Println() + fmt.Println() + return nil +} + // runGoCmd executes a go command and streams output. func runGoCmd(args ...string) error { // Find go.mod to determine project root. diff --git a/internal/commands/cmd_code_test.go b/internal/commands/cmd_code_test.go index 1099d25..5920d98 100644 --- a/internal/commands/cmd_code_test.go +++ b/internal/commands/cmd_code_test.go @@ -1,16 +1,27 @@ package commands -// cmd_code_test.go — regression tests for path-traversal fix in codeRead. +// cmd_code_test.go — regression tests for path-traversal fix in codeRead, +// plus coverage for the /code undo safety command. // -// Security invariants tested: +// Security invariants tested (codeRead): // 1. Relative path inside cwd → succeeds // 2. Relative ../ escape → "outside project root" error // 3. Absolute path outside root → "outside project root" error // 4. Symlink inside root → succeeds (monorepo-safe) // 5. Symlink escaping root → "outside project root" error +// +// Safety invariants tested (codeUndo): +// 1. Not a git repo → clean error, nothing touched +// 2. git missing from PATH → clean error, nothing touched +// 3. Clean tree → no-op, no prompt read (would hang if it tried) +// 4. Confirmed ("y") → unstaged tracked edit reverted; +// staged content + untracked files survive +// 5. Declined ("n") → working tree left exactly as-is import ( + "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -149,3 +160,195 @@ func TestCodeReadDeepRelativePath(t *testing.T) { t.Errorf("codeRead deep relative path: unexpected error: %v", err) } } + +// ─── /code undo ─────────────────────────────────────────────────────────── + +// initTempGitRepo creates an isolated temp dir (via withTempCwd), chdirs into +// it, and initializes a git repo with repo-local (non-global) user config so +// commits succeed hermetically regardless of the host's git identity setup. +func initTempGitRepo(t *testing.T) string { + t.Helper() + root := withTempCwd(t) + + runGitSetup(t, "init", "-q") + runGitSetup(t, "config", "user.email", "test@example.com") + runGitSetup(t, "config", "user.name", "Test") + + return root +} + +// runGitSetup runs a git command in the current cwd for test fixture setup, +// failing the test immediately (with output) on error. +func runGitSetup(t *testing.T, args ...string) { + t.Helper() + out, err := exec.Command("git", args...).CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } +} + +// withStdin temporarily replaces os.Stdin with a pipe pre-loaded with input, +// restoring the original on cleanup. Drives craftConfirm's y/N prompt without +// a real terminal. +func withStdin(t *testing.T, input string) { + t.Helper() + orig := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("could not create stdin pipe: %v", err) + } + if _, err := w.WriteString(input); err != nil { + t.Fatalf("could not write stdin pipe: %v", err) + } + _ = w.Close() + os.Stdin = r + t.Cleanup(func() { os.Stdin = orig }) +} + +// TestCodeUndoNotGitRepo — codeUndo outside any git repo errors cleanly and +// never reaches the confirmation prompt. +func TestCodeUndoNotGitRepo(t *testing.T) { + withTempCwd(t) + + err := codeUndo() + if err == nil { + t.Fatal("expected error outside a git repository, got nil") + } + if !strings.Contains(err.Error(), "not a git repository") { + t.Errorf("expected 'not a git repository'; got: %v", err) + } +} + +// TestCodeUndoGitMissing — codeUndo errors cleanly when git isn't in PATH. +func TestCodeUndoGitMissing(t *testing.T) { + withTempCwd(t) + t.Setenv("PATH", "") + + err := codeUndo() + if err == nil { + t.Fatal("expected error when git is missing, got nil") + } + if !strings.Contains(err.Error(), "git not found") { + t.Errorf("expected 'git not found'; got: %v", err) + } +} + +// TestCodeUndoNoChanges — a clean working tree is a no-op: nil error, and +// critically, no attempt to read a confirmation from stdin. os.Stdin is left +// at its default here; if codeUndo mistakenly tried to prompt, the read +// would block on empty stdin and this test would hang until timeout — the +// absence of a hang is itself part of the assertion. +func TestCodeUndoNoChanges(t *testing.T) { + initTempGitRepo(t) + + if err := os.WriteFile("committed.txt", []byte("v1\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + runGitSetup(t, "add", "committed.txt") + runGitSetup(t, "commit", "-q", "-m", "initial") + + if err := codeUndo(); err != nil { + t.Errorf("codeUndo on a clean tree: unexpected error: %v", err) + } +} + +// TestCodeUndoRevertsUnstagedChanges — the money path: an unstaged edit to a +// tracked file is reverted on "y". A staged addition and an untracked file +// both survive untouched, proving the safety guards actually hold. +func TestCodeUndoRevertsUnstagedChanges(t *testing.T) { + initTempGitRepo(t) + + // Tracked file, committed at "v1" — then edited unstaged. This is the + // only change /code undo should touch. + if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o600); err != nil { + t.Fatalf("setup tracked.txt: %v", err) + } + runGitSetup(t, "add", "tracked.txt") + runGitSetup(t, "commit", "-q", "-m", "initial") + if err := os.WriteFile("tracked.txt", []byte("v2 unstaged\n"), 0o600); err != nil { + t.Fatalf("unstaged edit: %v", err) + } + + // Staged addition — must survive undo untouched (undo never touches the index). + if err := os.WriteFile("staged.txt", []byte("staged content\n"), 0o600); err != nil { + t.Fatalf("setup staged.txt: %v", err) + } + runGitSetup(t, "add", "staged.txt") + + // Untracked file — must survive undo untouched (nothing to restore it from). + if err := os.WriteFile("untracked.txt", []byte("untracked content\n"), 0o600); err != nil { + t.Fatalf("setup untracked.txt: %v", err) + } + + withStdin(t, "y\n") + if err := codeUndo(); err != nil { + t.Fatalf("codeUndo: unexpected error: %v", err) + } + + got, err := os.ReadFile("tracked.txt") + if err != nil { + t.Fatalf("reading tracked.txt after undo: %v", err) + } + if string(got) != "v1\n" { + t.Errorf("tracked.txt = %q after undo, want reverted to %q", got, "v1\n") + } + + if _, err := os.Stat("staged.txt"); err != nil { + t.Errorf("staged.txt should survive undo untouched: %v", err) + } + if _, err := os.Stat("untracked.txt"); err != nil { + t.Errorf("untracked.txt should survive undo untouched: %v", err) + } + + statusOut, err := exec.Command("git", "status", "--porcelain").Output() + if err != nil { + t.Fatalf("git status: %v", err) + } + if !strings.Contains(string(statusOut), "A staged.txt") { + t.Errorf("expected staged.txt still staged (A ), got status:\n%s", statusOut) + } + if !strings.Contains(string(statusOut), "?? untracked.txt") { + t.Errorf("expected untracked.txt still untracked (??), got status:\n%s", statusOut) + } +} + +// TestCodeUndoCancelled — answering "n" leaves the working tree exactly as-is. +func TestCodeUndoCancelled(t *testing.T) { + initTempGitRepo(t) + + if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o600); err != nil { + t.Fatalf("setup tracked.txt: %v", err) + } + runGitSetup(t, "add", "tracked.txt") + runGitSetup(t, "commit", "-q", "-m", "initial") + if err := os.WriteFile("tracked.txt", []byte("v2 unstaged\n"), 0o600); err != nil { + t.Fatalf("unstaged edit: %v", err) + } + + withStdin(t, "n\n") + if err := codeUndo(); err != nil { + t.Fatalf("codeUndo with cancellation: unexpected error: %v", err) + } + + got, err := os.ReadFile("tracked.txt") + if err != nil { + t.Fatalf("reading tracked.txt after cancelled undo: %v", err) + } + if string(got) != "v2 unstaged\n" { + t.Errorf("tracked.txt = %q after cancel, want unchanged %q", got, "v2 unstaged\n") + } +} + +// TestCodeCmdDispatchesUndo — /code undo reaches codeUndo via the registry +// dispatch, not just via a direct function call. +func TestCodeCmdDispatchesUndo(t *testing.T) { + initTempGitRepo(t) + + r := &Registry{} + cmd := r.codeCmd() + + // Clean tree → codeUndo's no-op path → nil error, no stdin read. + if err := cmd.Run(context.Background(), []string{"undo"}); err != nil { + t.Errorf("/code undo dispatch on clean tree: unexpected error: %v", err) + } +} From 66019191606e0c3197a8851523e6b34c274b52d1 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 08:51:58 -0500 Subject: [PATCH 18/23] feat(protocol): add /protocol command for harness discovery + install /protocol status shows protocol + kata-harness state; /protocol harnesses lists the KE catalog (portable: KE_CATALOG_PATH -> home-relative -> embedded snapshot); /protocol install <name> copies a ratified, locally-available harness plugin into ~/.dojo/plugins with confirmation. Never runs operator-gated ke promote/publish. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/cmd_protocol.go | 504 +++++++++++++++++++++++++ internal/commands/cmd_protocol_test.go | 330 ++++++++++++++++ internal/commands/commands.go | 1 + 3 files changed, 835 insertions(+) create mode 100644 internal/commands/cmd_protocol.go create mode 100644 internal/commands/cmd_protocol_test.go diff --git a/internal/commands/cmd_protocol.go b/internal/commands/cmd_protocol.go new file mode 100644 index 0000000..f0bf7e4 --- /dev/null +++ b/internal/commands/cmd_protocol.go @@ -0,0 +1,504 @@ +package commands + +// cmd_protocol.go — /protocol: make the KE harness ecosystem discoverable and +// installable from the CLI, and surface the genius-protocol state, without ever +// bypassing ke's operator-gated publish pipeline. +// +// /protocol — focused status: protocol enabled/source + is kata-harness installed +// /protocol status — same as above +// /protocol harnesses — list the KE catalog: name · status · ratified/draft · installed? +// /protocol install <name> [--yes] +// — copy a ratified, locally-available harness plugin into the +// plugins path (with confirmation). Draft/unratified or +// not-locally-available harnesses print operator guidance. +// +// SAFETY model (why this file is read-mostly): +// - The ONLY write is copying an already-local, ratified plugin directory into +// cfg.Plugins.Path on an explicit install + confirmation. Nothing else mutates +// state, calls a network endpoint, or shells out. +// - No operator-gated action is ever taken: this command never runs `ke promote` +// or `ke publish`, never shells out to bin/ke. Unratified or not-yet-pulled +// harnesses route to printed instructions, per "the store discovers, ke installs". +// - No machine-specific hardcoded path is the ONLY source. The catalog resolves +// via $KE_CATALOG_PATH → a home-relative workspace default → an embedded +// snapshot; plugin sources resolve via $KE_HARNESS_SOURCE / $DOJO_PLUGINS_SOURCE +// / home-relative candidates. It works on ANY machine, even with no checkout. + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/DojoGenesis/cli/internal/activity" + "github.com/DojoGenesis/cli/internal/plugins" + gcolor "github.com/gookit/color" +) + +// harnessInfo is one row of the KE harness ecosystem: enough to discover a +// harness, decide whether it is installable (ratified + locally available), +// and show whether it is already installed under the plugins path. +type harnessInfo struct { + ID string + Name string + Kind string // "harness" | "edition" + Status string // "published" | "draft" + Ratified bool + Desc string +} + +// embeddedHarnesses is the portable fallback the command uses when no workspace +// catalog.json is reachable — the five known KE catalog entries as of +// 2026-07-16. Hardcoded (not go:embed) so the CLI needs no adjacent file and +// works on ANY machine. kata-harness is the only ratified harness; the design +// records of the other three harnesses are not yet operator-ratified, and +// dojo-tirada is a published edition. Ratified here mirrors the catalog's +// design_record.ratified (with a published edition treated as live). +var embeddedHarnesses = []harnessInfo{ + {ID: "dojo-tirada", Name: "Dojo Tirada", Kind: "edition", Status: "published", Ratified: true, + Desc: "Orchestration & Memory — Issue 1"}, + {ID: "kata-harness", Name: "Kata Harness", Kind: "harness", Status: "draft", Ratified: true, + Desc: "Run your work as timed rolls — surface, stage, resolve, advance"}, + {ID: "dojo-build-dag", Name: "Build-DAG Harness", Kind: "harness", Status: "draft", Ratified: false, + Desc: "Gate → ownership-partitioned build → integrate → audit"}, + {ID: "dojo-convergence", Name: "Convergence Harness", Kind: "harness", Status: "draft", Ratified: false, + Desc: "Parallel read-only audits → adversarial verify → fix tracks"}, + {ID: "memory-garden", Name: "Memory-Garden Harness", Kind: "harness", Status: "draft", Ratified: false, + Desc: "Don't-forget memory: seeds, index budget, compression ritual"}, +} + +// catalogRow is the subset of a ke catalog.json entry this command reads. +type catalogRow struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Status string `json:"status"` + Tagline string `json:"tagline"` + DesignRecord *struct { + Ratified bool `json:"ratified"` + } `json:"design_record"` +} + +// ratified derives the installable-gate signal for a catalog row: an +// operator-ratified design record, or a published edition (live even without a +// design_record block). A draft harness whose design record is unratified is +// operator-gated and returns false. +func (row catalogRow) ratified() bool { + if row.DesignRecord != nil && row.DesignRecord.Ratified { + return true + } + return strings.EqualFold(row.Status, "published") +} + +// keCatalogPath resolves the catalog.json location portably, without ever +// treating a machine-specific absolute path as the only source: +// 1. $KE_CATALOG_PATH — explicit override (returned as-is so a missing override +// still names itself in the fallback message rather than being silently ignored). +// 2. ~/ZenflowProjects/dojo-ke/public/catalog.json — the workspace default, +// mirroring bootstrap.copyPlugins' home-relative convention. +// +// Returns "" when neither yields an on-disk file; the caller falls back to the +// embedded snapshot. +func keCatalogPath() string { + if v := strings.TrimSpace(os.Getenv("KE_CATALOG_PATH")); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + def := filepath.Join(home, "ZenflowProjects", "dojo-ke", "public", "catalog.json") + if _, statErr := os.Stat(def); statErr == nil { + return def + } + return "" +} + +// loadHarnesses returns the harness ecosystem plus a human-readable description +// of where the data came from. It reads the resolved catalog path; on ANY +// failure (no path, unreadable, malformed, empty) it falls back to the embedded +// snapshot so the command always works. +func loadHarnesses() (list []harnessInfo, source string) { + path := keCatalogPath() + if path == "" { + return embeddedHarnesses, "embedded snapshot — no workspace catalog found (set KE_CATALOG_PATH to override)" + } + data, err := os.ReadFile(path) //nolint:gosec // path is env/home-derived, read-only + if err != nil { + return embeddedHarnesses, fmt.Sprintf("embedded snapshot — %s unreadable (%v)", path, err) + } + var rows []catalogRow + if err := json.Unmarshal(data, &rows); err != nil { + return embeddedHarnesses, fmt.Sprintf("embedded snapshot — %s malformed (%v)", path, err) + } + if len(rows) == 0 { + return embeddedHarnesses, fmt.Sprintf("embedded snapshot — %s had no entries", path) + } + for _, row := range rows { + list = append(list, harnessInfo{ + ID: row.ID, + Name: row.Name, + Kind: row.Kind, + Status: row.Status, + Ratified: row.ratified(), + Desc: row.Tagline, + }) + } + return list, "ke catalog: " + path +} + +// harnessInstalled reports whether a harness id has a directory under the +// plugins path — the same check /doctor's HARNESSES section uses. +func (r *Registry) harnessInstalled(id string) bool { + info, err := os.Stat(filepath.Join(r.cfg.Plugins.Path, id)) + return err == nil && info.IsDir() +} + +// harnessSourceCandidates returns the ordered local directories that might hold +// a harness's installable plugin, all derived portably from an env override or +// $HOME — never a single hardcoded machine-specific path (SAFETY). Order: +// explicit KE_HARNESS_SOURCE, the bootstrap plugin source (DOJO_PLUGINS_SOURCE), +// the home-relative CoworkPlugins source, then the standalone harness repo's +// plugin/ dir (e.g. ~/ZenflowProjects/kata-harness/plugin). +func harnessSourceCandidates(id string) []string { + var cands []string + add := func(p string) { + if p != "" { + cands = append(cands, p) + } + } + if v := strings.TrimSpace(os.Getenv("KE_HARNESS_SOURCE")); v != "" { + add(filepath.Join(v, id)) // a directory holding many harness plugins by id + add(v) // or the harness plugin directory itself + } + if v := strings.TrimSpace(os.Getenv("DOJO_PLUGINS_SOURCE")); v != "" { + add(filepath.Join(v, id)) // reuse bootstrap's plugin-source env + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + add(filepath.Join(home, "ZenflowProjects", "CoworkPluginsByDojoGenesis", "plugins", id)) + add(filepath.Join(home, "ZenflowProjects", id, "plugin")) + } + return cands +} + +// isPluginDir reports whether dir holds a plugin manifest, checking the same +// two locations plugins.scanOne does (.claude-plugin/plugin.json, then root). +func isPluginDir(dir string) bool { + for _, m := range []string{ + filepath.Join(dir, ".claude-plugin", "plugin.json"), + filepath.Join(dir, "plugin.json"), + } { + if info, err := os.Stat(m); err == nil && !info.IsDir() { + return true + } + } + return false +} + +// resolveHarnessSource returns the first local plugin source for id, or "" when +// nothing on this machine looks like the harness's plugin. +func resolveHarnessSource(id string) string { + for _, c := range harnessSourceCandidates(id) { + if isPluginDir(c) { + return c + } + } + return "" +} + +// copyPluginTree recursively copies a plugin directory, skipping VCS/cache cruft +// with the same filter and permissions bootstrap.copyDir uses. Re-implemented +// here (not imported) to keep this command self-contained, the way cmd_doctor.go +// is. This is the command's only write to disk. +func copyPluginTree(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + name := d.Name() + if name == ".git" || name == ".DS_Store" || name == "__pycache__" { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + rel, relErr := filepath.Rel(src, path) + if relErr != nil { + return relErr + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + data, readErr := os.ReadFile(path) //nolint:gosec // path walks a local plugin source + if readErr != nil { + return readErr + } + return os.WriteFile(target, data, 0644) + }) +} + +// ─── /protocol ────────────────────────────────────────────────────────────── + +func (r *Registry) protocolCmd() Command { + return Command{ + Name: "protocol", + Usage: "/protocol [status|harnesses|install <name> [--yes]]", + Short: "Discover + install KE harnesses and show genius-protocol state", + Run: func(ctx context.Context, args []string) error { + if len(args) == 0 { + return r.protocolStatus() + } + switch strings.ToLower(args[0]) { + case "status": + return r.protocolStatus() + case "harnesses", "harness", "ls", "list": + return r.protocolHarnesses() + case "install", "add": + if len(args) < 2 { + return fmt.Errorf("usage: /protocol install <name> [--yes]") + } + noConfirm := false + for _, a := range args[2:] { + if a == "--yes" || a == "-y" { + noConfirm = true + } + } + return r.protocolInstall(ctx, args[1], noConfirm) + default: + return fmt.Errorf("unknown subcommand %q — use status, harnesses, or install <name>", args[0]) + } + }, + } +} + +// ─── status ───────────────────────────────────────────────────────────────── + +// protocolStatus renders the focused view: protocol enabled/source (the same +// "overrides must be visible" rule /doctor's PROTOCOL section enforces — a +// silenced protocol WARNs), and whether kata-harness is installed. +func (r *Registry) protocolStatus() error { + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Genius Protocol")) + fmt.Println() + fmt.Println() + + gcolor.Bold.Print(gcolor.HEX("#f4a261").Sprint(" PROTOCOL")) + fmt.Println() + if r.cfg.Protocol.Enabled { + fmt.Printf(" %s protocol.enabled = true\n", doctorTag(true)) + } else { + fmt.Printf(" %s protocol disabled — set protocol.enabled or unset DOJO_PROTOCOL_DISABLED\n", doctorTag(false)) + } + source := r.cfg.Protocol.Path + if source == "" { + source = "embedded default" + if cwd, err := os.Getwd(); err == nil { + if _, statErr := os.Stat(filepath.Join(cwd, "DOJO.md")); statErr == nil { + source = "embedded default — overridden by project ./DOJO.md" + } + } + } + fmt.Printf(" %s source = %s\n", doctorTag(true), source) + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#f4a261").Sprint(" HARNESSES")) + fmt.Println() + kataPath := filepath.Join(r.cfg.Plugins.Path, "kata-harness") + if info, err := os.Stat(kataPath); err == nil && info.IsDir() { + fmt.Printf(" %s kata-harness installed at %s\n", doctorTag(true), kataPath) + } else { + fmt.Printf(" %s kata-harness not installed — run /protocol install kata-harness\n", doctorTag(false)) + } + + list, _ := loadHarnesses() + total, installed := 0, 0 + for _, h := range list { + if !strings.EqualFold(h.Kind, "harness") { + continue + } + total++ + if r.harnessInstalled(h.ID) { + installed++ + } + } + fmt.Printf(" %s %d of %d harness(es) installed — /protocol harnesses for the full list\n", + doctorTag(true), installed, total) + fmt.Println() + return nil +} + +// ─── harnesses ──────────────────────────────────────────────────────────────── + +// protocolHarnesses lists the KE catalog: name, status, ratified/draft, and +// whether each is installed under the plugins path. +func (r *Registry) protocolHarnesses() error { + list, source := loadHarnesses() + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" KE Harnesses")) + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" " + source)) + fmt.Println() + + fmt.Printf(" %s%s%s%s\n", + gcolor.HEX("#94a3b8").Sprintf("%-20s", "name"), + gcolor.HEX("#94a3b8").Sprintf("%-12s", "status"), + gcolor.HEX("#94a3b8").Sprintf("%-13s", "ratified"), + gcolor.HEX("#94a3b8").Sprint("installed"), + ) + for _, h := range list { + fmt.Printf(" %s%s%s%s\n", + gcolor.HEX("#f4a261").Sprintf("%-20s", h.ID), + protocolStatusCell(h.Status), + protocolRatifiedCell(h.Ratified), + protocolInstalledCell(r.harnessInstalled(h.ID)), + ) + if h.Desc != "" { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" " + h.Desc)) + } + } + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" install a ratified, locally-available harness: /protocol install <name>")) + fmt.Println() + return nil +} + +// protocolStatusCell colors a status token: published sage, draft amber. +func protocolStatusCell(status string) string { + c := "#e8b04a" // draft = warm-amber + if strings.EqualFold(status, "published") { + c = "#7fb88c" // published = soft-sage + } + return gcolor.HEX(c).Sprintf("%-12s", orDefault(status, "unknown")) +} + +// protocolRatifiedCell colors the ratified/unratified token. +func protocolRatifiedCell(ratified bool) string { + if ratified { + return gcolor.HEX("#7fb88c").Sprintf("%-13s", "ratified") + } + return gcolor.HEX("#94a3b8").Sprintf("%-13s", "unratified") +} + +// protocolInstalledCell colors the installed/not-installed token. +func protocolInstalledCell(installed bool) string { + if installed { + return gcolor.HEX("#7fb88c").Sprint("installed") + } + return gcolor.HEX("#94a3b8").Sprint("not installed") +} + +// ─── install ────────────────────────────────────────────────────────────────── + +// protocolInstall copies a ratified, locally-available harness plugin into the +// plugins path after confirmation. Draft/unratified or not-locally-available +// harnesses print operator guidance and return nil — this command never runs +// ke promote/publish and never shells out to bin/ke. +// +// ctx is accepted for signature parity with the other install commands; the +// install is a local filesystem copy with no gateway call, so it is unused. +func (r *Registry) protocolInstall(ctx context.Context, name string, noConfirm bool) error { + list, _ := loadHarnesses() + var target *harnessInfo + for i := range list { + if strings.EqualFold(list[i].ID, name) || strings.EqualFold(list[i].Name, name) { + target = &list[i] + break + } + } + if target == nil { + names := make([]string, 0, len(list)) + for _, h := range list { + names = append(names, h.ID) + } + return fmt.Errorf("unknown harness %q — known: %s", name, strings.Join(names, ", ")) + } + + // Gate 1 — must be ratified. Draft/unratified harnesses are operator-gated. + if !target.Ratified { + printOperatorGate(target, "it is not yet ratified") + return nil + } + + // Gate 2 — must be locally available as a plugin. A ratified harness with no + // local plugin (an edition, or a harness not yet pulled to this machine) + // also routes to guidance: the store discovers, ke installs. + src := resolveHarnessSource(target.ID) + if src == "" { + printOperatorGate(target, "no local plugin was found on this machine") + return nil + } + + dst := filepath.Join(r.cfg.Plugins.Path, target.ID) + if info, err := os.Stat(dst); err == nil && info.IsDir() { + fmt.Println() + fmt.Println(gcolor.HEX("#e8b04a").Sprintf(" %s already installed at %s — remove it first to reinstall", target.ID, dst)) + fmt.Println() + return nil + } + + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" Install harness %q", target.ID)) + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" from: " + src)) + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" to: " + dst)) + if !noConfirm { + fmt.Print(gcolor.HEX("#e8b04a").Sprint("\n Continue? [y/N]: ")) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Install cancelled.")) + fmt.Println() + return nil + } + } + + if err := copyPluginTree(src, dst); err != nil { + return fmt.Errorf("install %s: %w", target.ID, err) + } + + activity.Log(activity.CommandRun, fmt.Sprintf("protocol harness installed: %s → %s", target.ID, dst)) + + // Rescan so /protocol status and /doctor reflect the new install immediately. + if plgs, scanErr := plugins.Scan(r.cfg.Plugins.Path); scanErr == nil { + r.plgs = plgs + } + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#7fb88c").Sprintf(" Installed %s → %s", target.ID, dst)) + fmt.Println() + fmt.Println() + return nil +} + +// printOperatorGate explains why a harness cannot be installed from the CLI and +// what the operator must do — ratify, then publish through ke — without this +// command ever taking those operator-gated actions itself. +func printOperatorGate(h *harnessInfo, reason string) { + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" %s is operator-gated", h.ID)) + fmt.Println() + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" Not installable here — %s (%s).", reason, protocolStatusLabel(h))) + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Harnesses ship through the ke pipeline, not this CLI. To make it installable:")) + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" 1. Ratify the design record — the operator writes RATIFIED.md.")) + fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" 2. Package + publish it: ke publish <YYYY.NN> --line %s", h.ID)) + fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" 3. Re-run /protocol install %s once its plugin is local.", h.ID)) + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" This command never runs ke promote/publish for you — that stays an operator action.")) + fmt.Println() +} + +// protocolStatusLabel is a compact "status · ratified" tag for guidance text. +func protocolStatusLabel(h *harnessInfo) string { + rat := "unratified" + if h.Ratified { + rat = "ratified" + } + return fmt.Sprintf("%s · %s", orDefault(h.Status, "unknown"), rat) +} diff --git a/internal/commands/cmd_protocol_test.go b/internal/commands/cmd_protocol_test.go new file mode 100644 index 0000000..e72e590 --- /dev/null +++ b/internal/commands/cmd_protocol_test.go @@ -0,0 +1,330 @@ +package commands + +// cmd_protocol_test.go — tests for /protocol: portable catalog resolution with +// an embedded fallback, status/harnesses rendering, and the install safety +// gates (unratified and not-locally-available both route to operator guidance, +// never a crash and never a ke shell-out). +// +// HOME is isolated per-test (in addition to the package-wide TestMain) so the +// home-relative catalog/plugin candidates can never resolve to a real workspace +// checkout on the developer's machine. + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + gcolor "github.com/gookit/color" +) + +// captureProtocolStdout redirects both os.Stdout (for fmt.*) and the +// gookit/color writer (for gcolor.*.Print, which writes to the library's own +// cached output, not os.Stdout) for the duration of fn, and returns everything +// written. +func captureProtocolStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + old := os.Stdout + rp, wp, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = wp + gcolor.SetOutput(wp) + runErr := fn() + gcolor.ResetOutput() + os.Stdout = old + _ = wp.Close() + data, _ := io.ReadAll(rp) + _ = rp.Close() + return string(data), runErr +} + +func protoWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// clearProtocolEnv neutralizes every env var the command reads so a test starts +// from a known "no override, no workspace" baseline. +func clearProtocolEnv(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("KE_CATALOG_PATH", "") + t.Setenv("KE_HARNESS_SOURCE", "") + t.Setenv("DOJO_PLUGINS_SOURCE", "") +} + +// ─── portable catalog resolution ────────────────────────────────────────────── + +func TestProtocolLoadHarnessesEmbeddedFallback(t *testing.T) { + clearProtocolEnv(t) // KE_CATALOG_PATH unset + HOME has no dojo-ke checkout + + list, source := loadHarnesses() + if len(list) != len(embeddedHarnesses) { + t.Fatalf("expected %d embedded harnesses, got %d", len(embeddedHarnesses), len(list)) + } + if !strings.Contains(source, "embedded snapshot") { + t.Errorf("expected embedded-snapshot source, got %q", source) + } + + var kata *harnessInfo + for i := range list { + if list[i].ID == "kata-harness" { + kata = &list[i] + } + } + if kata == nil { + t.Fatal("kata-harness missing from embedded snapshot") + } + if !kata.Ratified { + t.Error("kata-harness must be ratified in the embedded snapshot") + } +} + +func TestProtocolLoadHarnessesMissingOverrideFallsBack(t *testing.T) { + clearProtocolEnv(t) + // Point the override at a file that does not exist → still fall back. + t.Setenv("KE_CATALOG_PATH", filepath.Join(t.TempDir(), "does-not-exist.json")) + + list, source := loadHarnesses() + if len(list) != len(embeddedHarnesses) { + t.Fatalf("expected embedded fallback (%d), got %d", len(embeddedHarnesses), len(list)) + } + if !strings.Contains(source, "embedded snapshot") { + t.Errorf("expected embedded snapshot on a missing override, got %q", source) + } +} + +func TestProtocolLoadHarnessesReadsCatalogOverride(t *testing.T) { + clearProtocolEnv(t) + dir := t.TempDir() + path := filepath.Join(dir, "catalog.json") + protoWriteFile(t, path, `[ + {"id":"kata-harness","name":"Kata Harness","kind":"harness","status":"draft","tagline":"timed rolls","design_record":{"ratified":true}}, + {"id":"dojo-build-dag","name":"Build-DAG","kind":"harness","status":"draft","tagline":"gate build","design_record":{"ratified":false}}, + {"id":"dojo-tirada","name":"Dojo Tirada","kind":"edition","status":"published","tagline":"issue 1"} + ]`) + t.Setenv("KE_CATALOG_PATH", path) + + list, source := loadHarnesses() + if len(list) != 3 { + t.Fatalf("expected 3 rows parsed from catalog, got %d", len(list)) + } + if !strings.Contains(source, path) { + t.Errorf("source should name the catalog path, got %q", source) + } + // design_record.ratified drives the flag; a published edition is ratified too. + byID := map[string]harnessInfo{} + for _, h := range list { + byID[h.ID] = h + } + if !byID["kata-harness"].Ratified { + t.Error("kata-harness (design_record.ratified=true) should be ratified") + } + if byID["dojo-build-dag"].Ratified { + t.Error("dojo-build-dag (design_record.ratified=false) should be unratified") + } + if !byID["dojo-tirada"].Ratified { + t.Error("published edition dojo-tirada should count as ratified") + } +} + +func TestProtocolLoadHarnessesMalformedCatalogFallsBack(t *testing.T) { + clearProtocolEnv(t) + path := filepath.Join(t.TempDir(), "catalog.json") + protoWriteFile(t, path, `{ this is not valid json `) + t.Setenv("KE_CATALOG_PATH", path) + + list, source := loadHarnesses() + if len(list) != len(embeddedHarnesses) { + t.Fatalf("malformed catalog should fall back to embedded (%d), got %d", len(embeddedHarnesses), len(list)) + } + if !strings.Contains(source, "malformed") { + t.Errorf("expected a 'malformed' note in the source, got %q", source) + } +} + +// ─── status / harnesses rendering ───────────────────────────────────────────── + +func TestProtocolStatusRenders(t *testing.T) { + clearProtocolEnv(t) + r, _ := testRegistry() + r.cfg.Plugins.Path = filepath.Join(t.TempDir(), "plugins") + r.cfg.Protocol.Enabled = true + + out, err := captureProtocolStdout(t, r.protocolStatus) + if err != nil { + t.Fatalf("/protocol status returned error: %v", err) + } + if !strings.Contains(out, "kata-harness") { + t.Errorf("status output should mention kata-harness; got:\n%s", out) + } + // Route through Dispatch too, to prove registration + the default subcommand. + if err := r.Dispatch(context.Background(), "protocol"); err != nil { + t.Fatalf("/protocol dispatch returned error: %v", err) + } + if err := r.Dispatch(context.Background(), "protocol status"); err != nil { + t.Fatalf("/protocol status dispatch returned error: %v", err) + } +} + +func TestProtocolHarnessesRenders(t *testing.T) { + clearProtocolEnv(t) + r, _ := testRegistry() + r.cfg.Plugins.Path = filepath.Join(t.TempDir(), "plugins") + + out, err := captureProtocolStdout(t, r.protocolHarnesses) + if err != nil { + t.Fatalf("/protocol harnesses returned error: %v", err) + } + for _, want := range []string{"kata-harness", "dojo-build-dag", "not installed"} { + if !strings.Contains(out, want) { + t.Errorf("harnesses output missing %q; got:\n%s", want, out) + } + } + if err := r.Dispatch(context.Background(), "protocol harnesses"); err != nil { + t.Fatalf("/protocol harnesses dispatch returned error: %v", err) + } +} + +// ─── install safety gates ───────────────────────────────────────────────────── + +func TestProtocolInstallUnratifiedGivesGuidance(t *testing.T) { + clearProtocolEnv(t) + r, _ := testRegistry() + r.cfg.Plugins.Path = filepath.Join(t.TempDir(), "plugins") + + // dojo-build-dag is draft + unratified in the embedded snapshot. + out, err := captureProtocolStdout(t, func() error { + return r.protocolInstall(context.Background(), "dojo-build-dag", true) + }) + if err != nil { + t.Fatalf("unratified install should print guidance, not error: %v", err) + } + if !strings.Contains(out, "operator-gated") { + t.Errorf("expected operator-gated guidance; got:\n%s", out) + } + if strings.Contains(out, "Installed") { + t.Errorf("unratified harness must not report a successful install; got:\n%s", out) + } +} + +func TestProtocolInstallRatifiedButNoLocalPluginGivesGuidance(t *testing.T) { + clearProtocolEnv(t) // HOME temp + no source envs → nothing resolves locally + r, _ := testRegistry() + r.cfg.Plugins.Path = filepath.Join(t.TempDir(), "plugins") + + // kata-harness is ratified, but no plugin exists on this synthetic machine. + out, err := captureProtocolStdout(t, func() error { + return r.protocolInstall(context.Background(), "kata-harness", true) + }) + if err != nil { + t.Fatalf("ratified-but-not-local install should give guidance, not error: %v", err) + } + if !strings.Contains(out, "operator-gated") || !strings.Contains(out, "no local plugin") { + t.Errorf("expected 'no local plugin' operator guidance; got:\n%s", out) + } +} + +func TestProtocolInstallUnknownHarnessErrors(t *testing.T) { + clearProtocolEnv(t) + r, _ := testRegistry() + r.cfg.Plugins.Path = filepath.Join(t.TempDir(), "plugins") + + err := r.Dispatch(context.Background(), "protocol install nope-not-real --yes") + if err == nil { + t.Fatal("expected an error for an unknown harness") + } + if !strings.Contains(err.Error(), "unknown harness") { + t.Errorf("expected 'unknown harness' error, got %v", err) + } +} + +func TestProtocolInstallCopiesLocalRatifiedPlugin(t *testing.T) { + clearProtocolEnv(t) + + // Build a fake, local, ratified kata-harness plugin the resolver will find + // via KE_HARNESS_SOURCE (candidate = <src>/<id>). + srcRoot := t.TempDir() + pluginDir := filepath.Join(srcRoot, "kata-harness") + protoWriteFile(t, filepath.Join(pluginDir, ".claude-plugin", "plugin.json"), + `{"name":"kata-harness","version":"0.1.0"}`) + protoWriteFile(t, filepath.Join(pluginDir, "skills", "roll", "SKILL.md"), "# roll") + // A .git dir that must be skipped by the copy. + protoWriteFile(t, filepath.Join(pluginDir, ".git", "config"), "[core]") + t.Setenv("KE_HARNESS_SOURCE", srcRoot) + + r, _ := testRegistry() + r.cfg.Plugins.Path = filepath.Join(t.TempDir(), "plugins") + + out, err := captureProtocolStdout(t, func() error { + return r.protocolInstall(context.Background(), "kata-harness", true) // --yes + }) + if err != nil { + t.Fatalf("install of a ratified local plugin should succeed: %v", err) + } + if !strings.Contains(out, "Installed") { + t.Errorf("expected an install-success line; got:\n%s", out) + } + + // Manifest + nested content copied; .git skipped. + dst := filepath.Join(r.cfg.Plugins.Path, "kata-harness") + if _, statErr := os.Stat(filepath.Join(dst, ".claude-plugin", "plugin.json")); statErr != nil { + t.Fatalf("expected copied manifest under %s: %v", dst, statErr) + } + if _, statErr := os.Stat(filepath.Join(dst, "skills", "roll", "SKILL.md")); statErr != nil { + t.Errorf("expected nested skill file to be copied: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(dst, ".git")); statErr == nil { + t.Error(".git should have been skipped by the copy, but it exists in the destination") + } + + // Now /protocol status must observe the install. + if !r.harnessInstalled("kata-harness") { + t.Error("harnessInstalled should report kata-harness installed after copy") + } + + // A second install attempt should refuse to clobber, not re-copy. + out2, err2 := captureProtocolStdout(t, func() error { + return r.protocolInstall(context.Background(), "kata-harness", true) + }) + if err2 != nil { + t.Fatalf("second install should be a no-op, not an error: %v", err2) + } + if !strings.Contains(out2, "already installed") { + t.Errorf("expected an 'already installed' message on re-install; got:\n%s", out2) + } +} + +// isPluginDir must accept both manifest locations and reject a bare directory. +func TestProtocolIsPluginDir(t *testing.T) { + root := t.TempDir() + + nested := filepath.Join(root, "nested") + protoWriteFile(t, filepath.Join(nested, ".claude-plugin", "plugin.json"), `{"name":"x"}`) + if !isPluginDir(nested) { + t.Error("expected isPluginDir true for a .claude-plugin/plugin.json layout") + } + + rooted := filepath.Join(root, "rooted") + protoWriteFile(t, filepath.Join(rooted, "plugin.json"), `{"name":"y"}`) + if !isPluginDir(rooted) { + t.Error("expected isPluginDir true for a root plugin.json layout") + } + + bare := filepath.Join(root, "bare") + if err := os.MkdirAll(bare, 0o755); err != nil { + t.Fatal(err) + } + if isPluginDir(bare) { + t.Error("expected isPluginDir false for a directory with no manifest") + } +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 53833f3..3820217 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -156,6 +156,7 @@ func (r *Registry) register() { r.add(r.projectCmd()) r.add(r.activityCmd()) r.add(r.pluginCmd()) + r.add(r.protocolCmd()) r.add(r.dispositionCmd()) r.add(r.telemetryCmd()) r.add(r.senseiCmd()) From 2859ffaf679fdbcd2927c2d1ef8b31e00dea95c5 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 09:16:04 -0500 Subject: [PATCH 19/23] refactor(render): extract internal/mdrender; render markdown in /skill get and /doc Moves RenderMarkdown out of package repl into a new internal/mdrender package (gated on gcolor.Enable, no plain param) so package commands can use it without an import cycle. /skill get and /doc now glamour-render fetched markdown when color is enabled, raw under --plain/--no-color. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/cmd_workflow.go | 17 +++++- internal/mdrender/mdrender.go | 66 ++++++++++++++++++++++++ internal/mdrender/mdrender_test.go | 83 ++++++++++++++++++++++++++++++ internal/repl/renderer.go | 53 ------------------- internal/repl/renderer_test.go | 60 --------------------- 5 files changed, 165 insertions(+), 114 deletions(-) create mode 100644 internal/mdrender/mdrender.go create mode 100644 internal/mdrender/mdrender_test.go diff --git a/internal/commands/cmd_workflow.go b/internal/commands/cmd_workflow.go index 6feabac..7f7b857 100644 --- a/internal/commands/cmd_workflow.go +++ b/internal/commands/cmd_workflow.go @@ -17,6 +17,7 @@ import ( "github.com/DojoGenesis/cli/internal/art" "github.com/DojoGenesis/cli/internal/client" + "github.com/DojoGenesis/cli/internal/mdrender" "github.com/DojoGenesis/cli/internal/orchestration" "github.com/DojoGenesis/cli/internal/skills" "github.com/DojoGenesis/cli/internal/tui" @@ -330,6 +331,20 @@ func (r *Registry) docCmd() Command { fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Document: %s\n\n", id)) for k, v := range doc { + // "content" is the conventional document-body field (same name + // CASGetContent uses for a skill body above) — render it as + // markdown instead of a raw dump so headings, lists, and code + // fences display properly. Every other field is metadata and + // keeps the plain key-value / JSON rendering below. + if k == "content" { + if s, ok := v.(string); ok { + fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" %-24s", k)) + fmt.Println() + fmt.Println(mdrender.RenderMarkdown(s)) + fmt.Println() + continue + } + } switch val := v.(type) { case map[string]any, []any: b, jsonErr := json.MarshalIndent(val, " ", " ") @@ -535,7 +550,7 @@ func (r *Registry) skillCmd() Command { gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Skill: %s @ %s\n\n", tag.Name, tag.Version)) printKV("ref", tag.Ref) fmt.Println() - fmt.Println(gcolor.White.Sprint(string(content))) + fmt.Println(mdrender.RenderMarkdown(string(content))) fmt.Println() return nil diff --git a/internal/mdrender/mdrender.go b/internal/mdrender/mdrender.go new file mode 100644 index 0000000..c4401ef --- /dev/null +++ b/internal/mdrender/mdrender.go @@ -0,0 +1,66 @@ +// Package mdrender renders complete markdown documents for terminal display. +// +// It exists to break an import cycle: this logic originally lived in package +// repl (Wave 5), but the natural static-document call sites — /skill get and +// /doc — live in internal/commands, and commands cannot import repl (repl +// already imports commands). mdrender imports nothing from repl or commands, +// so both can depend on it without a cycle. +package mdrender + +import ( + "os" + "strings" + + "github.com/charmbracelet/glamour" + gcolor "github.com/gookit/color" + "golang.org/x/term" +) + +// RenderMarkdown renders a FULLY ASSEMBLED markdown document as styled +// terminal output (fenced code blocks, headings, lists, etc.). +// +// Callers must pass the complete document text, not a partial chunk — +// glamour parses markdown structurally and cannot render a partial document. +// This makes RenderMarkdown a fit for static, fully-fetched content (a +// SKILL.md body, a /doc response) rather than per-chunk streaming output. +// +// Styling is gated on gookit/color's package-level gcolor.Enable, which the +// CLI's entrypoint sets to false for --no-color, --plain, or NO_COLOR. When +// Enable is false, full is returned verbatim so piped/CI/scripted consumers +// always see raw markdown text, never ANSI escapes. +func RenderMarkdown(full string) string { + if !gcolor.Enable { + return full + } + + mdRenderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(terminalWidth()), + ) + if err != nil { + // Styling failure must never eat the response — fall back to raw text. + return full + } + + out, err := mdRenderer.Render(full) + if err != nil { + return full + } + + // glamour's document style brackets output in a blank-line block_prefix/ + // suffix plus a left margin; trim the outer blank lines so the result + // composes cleanly with the caller's own spacing. + return strings.TrimSpace(out) +} + +// terminalWidth returns the current terminal's column width for glamour's +// word-wrap, falling back to a sane default when stdout isn't a TTY (piped +// output, CI, or `go test`). +func terminalWidth() int { + const fallbackWidth = 80 + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w <= 0 { + return fallbackWidth + } + return w +} diff --git a/internal/mdrender/mdrender_test.go b/internal/mdrender/mdrender_test.go new file mode 100644 index 0000000..d16c130 --- /dev/null +++ b/internal/mdrender/mdrender_test.go @@ -0,0 +1,83 @@ +package mdrender + +import ( + "strings" + "testing" + + gcolor "github.com/gookit/color" +) + +// ─── RenderMarkdown ───────────────────────────────────────────────────────── +// +// RenderMarkdown takes the FULL assembled document (not a stream chunk) and, +// when gcolor.Enable is true, runs it through glamour. These tests exercise +// the exported contract only: with color disabled it's a byte-for-byte +// passthrough (pipeline safety), and with color enabled it actually parses +// the markdown structure rather than just reformatting whitespace around it. + +const testMarkdownDoc = "# Heading\n\nSome text.\n\n```go\nfmt.Println(\"hi\")\n```\n" + +// withColorEnabled sets gcolor.Enable for the duration of a test and restores +// the previous value afterward — Enable is gookit/color package-level global +// state, not a per-call parameter, so tests that flip it must clean up after +// themselves to avoid bleeding into other tests. +func withColorEnabled(t *testing.T, enabled bool) { + t.Helper() + prev := gcolor.Enable + gcolor.Enable = enabled + t.Cleanup(func() { gcolor.Enable = prev }) +} + +func TestRenderMarkdown_ColorDisabled_ReturnsRawVerbatim(t *testing.T) { + withColorEnabled(t, false) + got := RenderMarkdown(testMarkdownDoc) + if got != testMarkdownDoc { + t.Errorf("RenderMarkdown(gcolor.Enable=false) = %q, want raw input unchanged %q", got, testMarkdownDoc) + } +} + +func TestRenderMarkdown_ColorDisabled_EmptyInput(t *testing.T) { + withColorEnabled(t, false) + if got := RenderMarkdown(""); got != "" { + t.Errorf("RenderMarkdown(\"\") with gcolor.Enable=false = %q, want empty", got) + } +} + +func TestRenderMarkdown_ColorEnabled_DiffersFromRawAndPreservesContent(t *testing.T) { + withColorEnabled(t, true) + got := RenderMarkdown(testMarkdownDoc) + + if got == "" { + t.Fatal("RenderMarkdown(gcolor.Enable=true) returned empty output for a non-empty document") + } + if got == testMarkdownDoc { + t.Fatal("RenderMarkdown(gcolor.Enable=true) returned the raw markdown unchanged — styling did not run") + } + + // The heading text and fenced code-block content must survive styling. + if !strings.Contains(got, "Heading") { + t.Errorf("styled output missing heading text %q:\n%s", "Heading", got) + } + if !strings.Contains(got, `fmt.Println("hi")`) { + t.Errorf("styled output missing code-block content %q:\n%s", `fmt.Println("hi")`, got) + } + + // A real markdown render (not just a whitespace/ANSI wrap of the raw + // bytes) parses the fence into a code block and drops the ``` markers — + // their absence is positive evidence that glamour actually ran. + if strings.Contains(got, "```") { + t.Errorf("styled output still contains raw fence markers, glamour did not parse the code block:\n%s", got) + } +} + +func TestRenderMarkdown_ColorEnabled_HeadingOnly(t *testing.T) { + withColorEnabled(t, true) + const headingOnly = "# Just A Heading\n" + got := RenderMarkdown(headingOnly) + if got == "" || got == headingOnly { + t.Errorf("RenderMarkdown(gcolor.Enable=true) on heading-only input = %q, want non-empty and different from raw %q", got, headingOnly) + } + if !strings.Contains(got, "Just A Heading") { + t.Errorf("styled output missing heading text:\n%s", got) + } +} diff --git a/internal/repl/renderer.go b/internal/repl/renderer.go index d17af3a..ef9bb26 100644 --- a/internal/repl/renderer.go +++ b/internal/repl/renderer.go @@ -5,13 +5,10 @@ package repl import ( "encoding/json" "fmt" - "os" "strings" "github.com/DojoGenesis/cli/internal/client" - "github.com/charmbracelet/glamour" gcolor "github.com/gookit/color" - "golang.org/x/term" ) // EventType classifies SSE chunks for rendering. @@ -216,56 +213,6 @@ func (re RenderEvent) Render(plain bool) string { return re.Content } -// RenderMarkdown renders a FULLY ASSEMBLED assistant message as styled -// markdown for terminal display (fenced code blocks, headings, lists, etc.). -// -// Callers must pass the complete message text, not an individual EventText -// chunk. Assistant text arrives from the gateway as streaming chunks, and -// glamour parses markdown structurally — it cannot render a partial -// document — so this is deliberately NOT wired into the per-chunk Render() -// path above; wiring it there would regress live streaming. The intended -// call site is end-of-turn, once every EventText chunk for a response has -// been concatenated into one string. -// -// When plain is true, full is returned verbatim: --plain/--json/piped -// consumers must always see raw markdown text, never ANSI escapes. -func RenderMarkdown(full string, plain bool) string { - if plain { - return full - } - - mdRenderer, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(terminalWidth()), - ) - if err != nil { - // Styling failure must never eat the response — fall back to raw text. - return full - } - - out, err := mdRenderer.Render(full) - if err != nil { - return full - } - - // glamour's document style brackets output in a blank-line block_prefix/ - // suffix plus a left margin; trim the outer blank lines so the result - // composes cleanly with the caller's own spacing. - return strings.TrimSpace(out) -} - -// terminalWidth returns the current terminal's column width for glamour's -// word-wrap, falling back to a sane default when stdout isn't a TTY (piped -// output, CI, or `go test`). -func terminalWidth() int { - const fallbackWidth = 80 - w, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || w <= 0 { - return fallbackWidth - } - return w -} - // ─── internal content extraction ───────────────────────────────────────────── // extractContentFromData pulls readable text from a raw SSE data string. diff --git a/internal/repl/renderer_test.go b/internal/repl/renderer_test.go index 788e916..b2cda18 100644 --- a/internal/repl/renderer_test.go +++ b/internal/repl/renderer_test.go @@ -528,63 +528,3 @@ func TestExtractError_Variants(t *testing.T) { } } } - -// ─── RenderMarkdown ───────────────────────────────────────────────────────── -// -// RenderMarkdown takes the FULL assembled message (not a stream chunk) and, -// when !plain, runs it through glamour. These tests exercise the exported -// contract only: plain mode is a byte-for-byte passthrough (pipeline -// safety), and styled mode actually parses the markdown structure rather -// than just reformatting whitespace around it. - -const testMarkdownDoc = "# Heading\n\nSome text.\n\n```go\nfmt.Println(\"hi\")\n```\n" - -func TestRenderMarkdown_Plain_ReturnsRawVerbatim(t *testing.T) { - got := RenderMarkdown(testMarkdownDoc, true) - if got != testMarkdownDoc { - t.Errorf("RenderMarkdown(plain=true) = %q, want raw input unchanged %q", got, testMarkdownDoc) - } -} - -func TestRenderMarkdown_Plain_EmptyInput(t *testing.T) { - if got := RenderMarkdown("", true); got != "" { - t.Errorf("RenderMarkdown(\"\", true) = %q, want empty", got) - } -} - -func TestRenderMarkdown_Styled_DiffersFromRawAndPreservesContent(t *testing.T) { - got := RenderMarkdown(testMarkdownDoc, false) - - if got == "" { - t.Fatal("RenderMarkdown(plain=false) returned empty output for a non-empty document") - } - if got == testMarkdownDoc { - t.Fatal("RenderMarkdown(plain=false) returned the raw markdown unchanged — styling did not run") - } - - // The heading text and fenced code-block content must survive styling. - if !strings.Contains(got, "Heading") { - t.Errorf("styled output missing heading text %q:\n%s", "Heading", got) - } - if !strings.Contains(got, `fmt.Println("hi")`) { - t.Errorf("styled output missing code-block content %q:\n%s", `fmt.Println("hi")`, got) - } - - // A real markdown render (not just a whitespace/ANSI wrap of the raw - // bytes) parses the fence into a code block and drops the ``` markers — - // their absence is positive evidence that glamour actually ran. - if strings.Contains(got, "```") { - t.Errorf("styled output still contains raw fence markers, glamour did not parse the code block:\n%s", got) - } -} - -func TestRenderMarkdown_Styled_HeadingOnly(t *testing.T) { - const headingOnly = "# Just A Heading\n" - got := RenderMarkdown(headingOnly, false) - if got == "" || got == headingOnly { - t.Errorf("RenderMarkdown(plain=false) on heading-only input = %q, want non-empty and different from raw %q", got, headingOnly) - } - if !strings.Contains(got, "Just A Heading") { - t.Errorf("styled output missing heading text:\n%s", got) - } -} From f67c0a1b7fd99d656172ddf437401b3850837f5b Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 09:16:04 -0500 Subject: [PATCH 20/23] docs(cli): document /doctor, /protocol, /session ls, /code undo; shell completions /help gains a Protocol section + the new commands; README documents them plus genius-protocol-by-default behavior; zsh/bash/fish completions include /doctor and /protocol. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- README.md | 62 ++++++++++++++++++++++++++++++++--- cmd/dojo/main.go | 6 +++- internal/commands/cmd_help.go | 9 +++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 275f080..5aec7f3 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Settings are loaded from `~/.dojo/settings.json`. A missing file is not an error | `DOJO_PROVIDER` | `defaults.provider` | | `DOJO_TELEMETRY_URL` | telemetry API base | | `DOJO_SKILLS_PATH` | default skill dir for `/skill package-all` | +| `DOJO_PROTOCOL_DISABLED` | any non-empty value disables the genius protocol (`protocol.enabled`) | +| `DOJO_PROTOCOL_PATH` | `protocol.path` — explicit override doc, takes precedence over `./DOJO.md` | ## Requirements @@ -84,6 +86,7 @@ Settings are loaded from `~/.dojo/settings.json`. A missing file is not an error | `--disposition <d>` | ADA disposition preset: `focused`, `balanced`, `exploratory`, `deliberate` | | `--one-shot <msg>` | Execute a single message and exit (non-interactive) | | `--resume` | Resume the most recent session instead of starting fresh | +| `--session <id>` | Resume a specific session ID instead of the most recent one (implies `--resume`) | | `--no-color` | Disable color output | | `--plain` | Plain text output — no ANSI colors (for piped or CI use) | | `--json` | JSON lines output in one-shot mode (for scripted pipelines) | @@ -105,6 +108,12 @@ dojo --one-shot "summarize the last run" --json | jq '.text' dojo --resume ``` +**Resume a specific session:** + +```bash +dojo --session dojo-cli-20260409-142301 +``` + **Shell completions:** ```bash @@ -123,6 +132,7 @@ Type a message without `/` to chat with the gateway. Use slash commands for stru |-----------------------------------|--------------------------------------------------------| | `/help` | Show available commands | | `/health` | Gateway health and uptime stats | +| `/doctor` | Full diagnostic: gateway, providers, config, protocol, harnesses | | `/home` | Workspace state overview (TUI panel) | | `/home plain` | Workspace state in plain text | | `/model [ls]` | List available models and providers | @@ -163,7 +173,10 @@ Type a message without `/` to chat with the gateway. Use slash commands for stru |-------------------------|------------------------------------------| | `/session` | Show active session ID | | `/session new` | Start a fresh session | -| `/session <id>` | Resume a prior session by ID | +| `/session ls` | List recent sessions from local history, most-recent-first | +| `/session resume` | Resume the most recently active session | +| `/session resume <id>` | Resume one specific session by ID (warns if not found in local history) | +| `/session <id>` | Switch directly to a session ID (not verified against gateway) | Session IDs follow the format `dojo-cli-YYYYMMDD-HHmmss` when created via `/session new`. @@ -218,6 +231,16 @@ Track statuses: `pending`, `in-progress`, `completed`, `blocked`. | `/hooks ls` | List loaded hook rules from all plugins | | `/hooks fire <event>` | Manually fire a hook event (for testing) | +### Protocol & Harnesses + +| Command | Description | +|--------------------------------------|-------------------------------------------------------------| +| `/protocol` or `/protocol status` | Genius-protocol enabled/source + kata-harness install state | +| `/protocol harnesses` | List the KE harness catalog: status, ratified, installed | +| `/protocol install <name> [--yes]` | Install a ratified, locally-available harness into `plugins.path` | + +See [Genius Protocol](#genius-protocol) below for how the protocol doc is resolved and overridden. + ### Dispositions | Command | Description | @@ -269,6 +292,7 @@ Track statuses: `pending`, `in-progress`, `completed`, `blocked`. | `/code build` | Run `go build ./...` | | `/code vet` | Run `go vet ./...` | | `/code gate` | Run the full build gate: build + test + vet | +| `/code undo` | Preview unstaged changes to tracked files, then revert on confirmation | ## Directory Structure @@ -333,6 +357,33 @@ Create and save custom presets with `/disposition create`: /disposition create sprint fast shallow assertive high ``` +## Genius Protocol + +A workspace "genius protocol" — a compact operating doctrine — is injected into every session **by default**. The CLI prepends it to the first turn of a chat or `--one-shot` run (and sets it as the system prompt for gateways that support one); later turns rely on the gateway's own session context instead of re-sending it. + +Resolution order (first match wins): + +1. an explicit `protocol.path` in `~/.dojo/settings.json` +2. `./DOJO.md` in the current project directory +3. `~/.dojo/DOJO.md` +4. the embedded default doc + +**Override or disable it:** + +```bash +# Disable for one run +DOJO_PROTOCOL_DISABLED=1 dojo +``` + +```json +// ~/.dojo/settings.json — disable persistently +{ "protocol": { "enabled": false } } +``` + +Or drop a `DOJO.md` file in the project root (or `~/.dojo/DOJO.md` for a machine-wide override) to replace the content instead of turning it off — either is picked up automatically ahead of the embedded default. + +`/init` writes an editable `~/.dojo/DOJO.md` starter copy (never overwriting one that already exists) and installs the ratified **kata-harness** plugin alongside the rest of the first-party plugin set. Check current state anytime with `/doctor` (PROTOCOL + HARNESSES sections) or `/protocol status`; browse and install other KE harnesses with `/protocol harnesses` and `/protocol install <name>` — see [Protocol & Harnesses](#protocol--harnesses) above. + ## Plugin System Plugins extend the CLI with hook rules and skills. Place plugin directories under `~/.dojo/plugins/` (or the path configured in `plugins.path`), or use `/plugin install` to clone from a git URL. @@ -386,15 +437,18 @@ XP is earned through guided tutorials (`/guide`), daily practice sessions, and r ## Session Management -Sessions scope conversation history on the gateway. Each `dojo` invocation generates a session ID automatically. You can rotate or resume sessions mid-session. +Sessions scope conversation history on the gateway. Each `dojo` invocation generates a session ID automatically. You can rotate, list, or resume sessions mid-session. ``` /session # show current session ID /session new # rotate to a fresh session -/session dojo-cli-20260409 # resume a specific session +/session ls # list recent sessions from local history +/session resume # resume the most recently active session +/session resume dojo-cli-20260409-142301 # resume one specific session by ID +/session dojo-cli-20260409-142301 # switch directly (not verified against gateway) ``` -Use `--resume` at startup to continue the most recent session automatically. +Use `--resume` at startup to continue the most recent session automatically, or `--session <id>` to resume one specific session by ID (implies `--resume`). ## Design diff --git a/cmd/dojo/main.go b/cmd/dojo/main.go index 226fe98..abf6135 100644 --- a/cmd/dojo/main.go +++ b/cmd/dojo/main.go @@ -238,6 +238,8 @@ _dojo() { '/card:dojo profile card' '/warroom:scout vs challenger debate' '/craft:DojoCraft practitioner workbench' + '/doctor:read-only diagnostic' + '/protocol:KE harness discovery + install' ) _describe 'command' commands } @@ -245,7 +247,7 @@ compdef _dojo dojo `) case "bash": fmt.Print(`_dojo_completions() { - COMPREPLY=($(compgen -W "/help /health /home /model /tools /agent /skill /session /run /garden /trail /snapshot /trace /pilot /practice /projects /project /hooks /settings /guide /code /bloom /apps /workflow /doc /init /activity /plugin /disposition /telemetry /sensei /card /warroom /craft exit" -- "${COMP_WORDS[COMP_CWORD]}")) + COMPREPLY=($(compgen -W "/help /health /home /model /tools /agent /skill /session /run /garden /trail /snapshot /trace /pilot /practice /projects /project /hooks /settings /guide /code /bloom /apps /workflow /doc /init /activity /plugin /disposition /telemetry /sensei /card /warroom /craft /doctor /protocol exit" -- "${COMP_WORDS[COMP_CWORD]}")) } complete -F _dojo_completions dojo `) @@ -284,6 +286,8 @@ complete -c dojo -f -a "/sensei" -d "koan from the sensei" complete -c dojo -f -a "/card" -d "dojo profile card" complete -c dojo -f -a "/warroom" -d "scout vs challenger debate" complete -c dojo -f -a "/craft" -d "DojoCraft practitioner workbench" +complete -c dojo -f -a "/doctor" -d "read-only diagnostic" +complete -c dojo -f -a "/protocol" -d "KE harness discovery + install" `) default: fmt.Fprintf(os.Stderr, "dojo: unknown shell %q (supported: bash, zsh, fish)\n", shell) diff --git a/internal/commands/cmd_help.go b/internal/commands/cmd_help.go index 5359b9f..20b8dcc 100644 --- a/internal/commands/cmd_help.go +++ b/internal/commands/cmd_help.go @@ -35,6 +35,7 @@ func (r *Registry) helpCmd() Command { {"/model direct <provider> <model> <msg>", "direct API call (bypass gateway)", ""}, {"/session", "show active session ID", ""}, {"/session new", "start a fresh session", ""}, + {"/session ls", "list recent sessions", ""}, {"/session resume", "resume the most recent session", ""}, {"/session <id>", "switch to a session by ID (not verified against gateway)", ""}, {"/disposition", "show current disposition preset", ""}, @@ -113,6 +114,7 @@ func (r *Registry) helpCmd() Command { {"/code build", "run go build ./...", ""}, {"/code vet", "run go vet ./...", ""}, {"/code gate", "run build + test + vet (full gate)", ""}, + {"/code undo", "preview + revert unstaged changes to tracked files", ""}, }}, {"Practice", []helpEntry{ {"/practice", "daily reflection prompts (rotates by day of week)", ""}, @@ -134,6 +136,7 @@ func (r *Registry) helpCmd() Command { }}, {"System", []helpEntry{ {"/health", "gateway health + stats", ""}, + {"/doctor", "diagnostic: gateway, providers, config, plugins/hooks, protocol, harnesses", ""}, {"/settings", "show config and active settings", ""}, {"/settings effective", "show merged file + env + flag config", ""}, {"/settings providers", "show provider configuration", ""}, @@ -148,6 +151,12 @@ func (r *Registry) helpCmd() Command { {"/tools", "list registered MCP tools, grouped by namespace", ""}, {"/doc <id>", "fetch and display a document", "beta"}, }}, + {"Protocol", []helpEntry{ + {"/protocol", "genius-protocol status + kata-harness install state", ""}, + {"/protocol status", "same as /protocol", ""}, + {"/protocol harnesses", "list the KE harness catalog — status, ratified, installed", ""}, + {"/protocol install <name> [--yes]", "install a ratified, locally-available harness", ""}, + }}, {"DojoCraft", []helpEntry{ {"/craft adr <title>", "write an ADR via the gateway", ""}, {"/craft scout <tension>", "tension → routes → synthesis → decision", ""}, From b39c6dc7905f221dd2440e858142698f0b9121ba Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 09:16:04 -0500 Subject: [PATCH 21/23] feat(protocol): auto verify-loop + JIT tell-triggered config-vs-code nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify.AfterAgent (opt-in, env-transient) runs go build + go test after a successful /agent dispatch or /run and prints [verify] PASS/FAIL. On a gateway error whose text matches a wiring/boundary signature (connection refused, 401/403, 404, dial tcp, model-not-found), the REPL surfaces the single relevant config-vs-code gate as a dim [protocol] nudge — once per gate per session, suppressed under --plain and when the protocol is disabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/config/config.go | 43 +++++++++ internal/config/config_test.go | 113 +++++++++++++++++++++- internal/protocol/protocol.go | 84 +++++++++++++++++ internal/protocol/protocol_test.go | 88 ++++++++++++++++++ internal/repl/repl.go | 144 +++++++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 14c6659..16b09e2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { Plugins PluginsConfig `json:"plugins"` Defaults DefaultsConfig `json:"defaults"` Protocol ProtocolConfig `json:"protocol"` + Verify VerifyConfig `json:"verify"` Auth AuthConfig `json:"auth,omitempty"` DispositionProfiles map[string]DispositionPreset `json:"disposition_profiles,omitempty"` @@ -46,6 +47,18 @@ type ProtocolConfig struct { Path string `json:"path,omitempty"` } +// VerifyConfig controls the opt-in "done means verified" gate that runs after a +// successful /agent dispatch or /run in the interactive REPL. AfterAgent +// defaults to FALSE — the loop is strictly opt-in — so a settings.json that +// omits the whole block (or omits just "after_agent") leaves it off: unlike +// ProtocolConfig.Enabled, the intended default here IS the zero value, so +// defaults() need not pre-seed it. DOJO_VERIFY_AFTER_AGENT (any non-empty +// value) flips it on for a single run, resolved in Load like the other env +// knobs. +type VerifyConfig struct { + AfterAgent bool `json:"after_agent"` +} + type AuthConfig struct { UserID string `json:"user_id,omitempty"` } @@ -174,6 +187,16 @@ func Load() (*Config, error) { func(c *Config, v string) { c.Protocol.Path = v }, v) } + // DOJO_VERIFY_AFTER_AGENT: any non-empty value opts into the post-agent + // verify loop for this run. Tracked via noteEnvOverride (not a plain + // assignment) so a one-off export never bakes into settings.json — same + // transient-override contract as DOJO_PROTOCOL_DISABLED; see Save(). + if v := os.Getenv("DOJO_VERIFY_AFTER_AGENT"); v != "" { + noteEnvOverride(cfg, + func(c *Config) bool { return c.Verify.AfterAgent }, + func(c *Config, v bool) { c.Verify.AfterAgent = v }, + true) + } // Gateway settings are load-bearing for connectivity — a malformed URL // or timeout is a genuine configuration error, so it still stops @@ -257,6 +280,9 @@ func (c *Config) Validate() error { if err := c.validateProtocol(); err != nil { return err } + if err := c.validateVerify(); err != nil { + return err + } return nil } @@ -269,6 +295,14 @@ func (c *Config) validateProtocol() error { return nil } +// validateVerify has no fail-hard conditions: AfterAgent is a bool that is +// valid either way, and a missing block is a valid (disabled) config. Kept as a +// hook — symmetric with validateProtocol — so any future verify constraint has +// a home without re-touching Validate. +func (c *Config) validateVerify() error { + return nil +} + // validateGateway checks that Gateway.URL and Gateway.Timeout are well-formed. func (c *Config) validateGateway() error { // Gateway URL must be parseable. @@ -324,6 +358,7 @@ func (c *Config) EffectiveString() string { fmt.Fprintf(&b, "plugins.path = %s\n", c.Plugins.Path) fmt.Fprintf(&b, "protocol.enabled = %t\n", c.Protocol.Enabled) fmt.Fprintf(&b, "protocol.path = %s\n", defaultIfEmpty(c.Protocol.Path, "(embedded default)")) + fmt.Fprintf(&b, "verify.after_agent = %t\n", c.Verify.AfterAgent) fmt.Fprintf(&b, "auth.user_id = %s\n", defaultIfEmpty(c.Auth.UserID, "(not set)")) return b.String() } @@ -422,5 +457,13 @@ func defaults() *Config { Protocol: ProtocolConfig{ Enabled: true, }, + // Verify is opt-in and OFF by default: the zero value (AfterAgent:false) + // is the intended default, so an omitted block keeps the post-agent + // verify loop disabled. Stated explicitly for symmetry and to document + // intent at the defaults site; DOJO_VERIFY_AFTER_AGENT flips it on per + // run (see Load). + Verify: VerifyConfig{ + AfterAgent: false, + }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a6f3a1a..699a331 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -605,7 +605,7 @@ func clearProtocolEnv(t *testing.T) { for _, k := range []string{ "DOJO_GATEWAY_URL", "DOJO_GATEWAY_TOKEN", "DOJO_PLUGINS_PATH", "DOJO_PROVIDER", "DOJO_DISPOSITION", "DOJO_MODEL", "DOJO_USER_ID", - "DOJO_PROTOCOL_DISABLED", "DOJO_PROTOCOL_PATH", + "DOJO_PROTOCOL_DISABLED", "DOJO_PROTOCOL_PATH", "DOJO_VERIFY_AFTER_AGENT", } { t.Setenv(k, "") } @@ -717,6 +717,115 @@ func TestEffectiveString_IncludesProtocol(t *testing.T) { } } +// ─── Verify block (W6-PROTOCOL-ADV) ────────────────────────────────────────── + +// TestLoad_Verify_DefaultsFalse proves the opt-in verify loop is OFF by default: +// a settings.json that omits the whole block leaves Verify.AfterAgent false. +func TestLoad_Verify_DefaultsFalse(t *testing.T) { + clearProtocolEnv(t) // fresh HOME (no settings.json) + all DOJO_* knobs cleared + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Verify.AfterAgent { + t.Error("Verify.AfterAgent should default to false when settings.json omits the block") + } +} + +// TestLoad_Verify_MissingBlockStaysFalse proves backward compatibility: an +// existing settings.json written before the verify block existed (no "verify" +// key at all) loads with the loop disabled, not with some accidental default. +func TestLoad_Verify_MissingBlockStaysFalse(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + clearAllConfigEnv(t) + + // A pre-existing config with no "verify" key. + settings := map[string]any{ + "gateway": map[string]any{"url": "http://localhost:7340", "timeout": "60s"}, + } + data, _ := json.Marshal(settings) + dojoDir := filepath.Join(tmp, ".dojo") + if err := os.MkdirAll(dojoDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(dojoDir, "settings.json"), data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Verify.AfterAgent { + t.Error("a settings.json with no verify block should load AfterAgent=false") + } +} + +// TestLoad_Verify_EnvEnables proves DOJO_VERIFY_AFTER_AGENT (any non-empty +// value) flips the loop on for the run. +func TestLoad_Verify_EnvEnables(t *testing.T) { + clearProtocolEnv(t) + t.Setenv("DOJO_VERIFY_AFTER_AGENT", "1") + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if !cfg.Verify.AfterAgent { + t.Error("DOJO_VERIFY_AFTER_AGENT=1 should enable the post-agent verify loop") + } +} + +// TestSave_StripsEnvOverride_VerifyAfterAgent proves the run-scoped env override +// is not baked into settings.json — same transient-override contract that +// protects DOJO_PROTOCOL_DISABLED (see envOverride and Config.Save()). +func TestSave_StripsEnvOverride_VerifyAfterAgent(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + clearAllConfigEnv(t) + t.Setenv("DOJO_VERIFY_AFTER_AGENT", "1") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if !cfg.Verify.AfterAgent { + t.Fatal("sanity check failed: DOJO_VERIFY_AFTER_AGENT=1 should have enabled the verify loop") + } + + // A command saves config for an unrelated reason (e.g. /model set). + cfg.Defaults.Model = "claude-sonnet-4-6" + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error: %v", err) + } + + // Re-load WITHOUT the env var: the transient override must not have persisted. + t.Setenv("DOJO_VERIFY_AFTER_AGENT", "") + reloaded, err := Load() + if err != nil { + t.Fatalf("reload Load() error: %v", err) + } + if reloaded.Verify.AfterAgent { + t.Error("DOJO_VERIFY_AFTER_AGENT leaked into settings.json — Save() should have stripped the run-scoped override") + } + if reloaded.Defaults.Model != "claude-sonnet-4-6" { + t.Errorf("the real edit should persist: Defaults.Model = %q, want %q", reloaded.Defaults.Model, "claude-sonnet-4-6") + } +} + +// TestEffectiveString_IncludesVerify proves the verify state shows up in the +// /settings effective dump alongside the other blocks. +func TestEffectiveString_IncludesVerify(t *testing.T) { + cfg := &Config{ + Gateway: GatewayConfig{URL: "http://localhost:7340", Timeout: "60s"}, + Defaults: DefaultsConfig{Disposition: "balanced"}, + Verify: VerifyConfig{AfterAgent: true}, + } + if want := "verify.after_agent = true"; !strings.Contains(cfg.EffectiveString(), want) { + t.Errorf("EffectiveString() missing %q\ngot:\n%s", want, cfg.EffectiveString()) + } +} + // ─── Auth.UserID env override ─────────────────────────────────────────────── func TestLoad_UserIDEnvOverride(t *testing.T) { @@ -797,7 +906,7 @@ func clearAllConfigEnv(t *testing.T) { for _, k := range []string{ "DOJO_GATEWAY_URL", "DOJO_GATEWAY_TOKEN", "DOJO_PLUGINS_PATH", "DOJO_PROVIDER", "DOJO_DISPOSITION", "DOJO_MODEL", "DOJO_USER_ID", - "DOJO_PROTOCOL_DISABLED", "DOJO_PROTOCOL_PATH", + "DOJO_PROTOCOL_DISABLED", "DOJO_PROTOCOL_PATH", "DOJO_VERIFY_AFTER_AGENT", } { t.Setenv(k, "") } diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index 67c93ce..1923856 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -17,6 +17,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/DojoGenesis/cli/internal/client" "github.com/DojoGenesis/cli/internal/config" @@ -146,3 +147,86 @@ func (in *Injector) Apply(req *client.ChatRequest) bool { in.injected = true return true } + +// ─── JIT tell-triggered injection ───────────────────────────────────────────── +// +// The protocol above leads a session once, up front. This section is the +// complementary move: surface ONE situated gate at the exact moment a tell +// appears, rather than reciting the whole doctrine. The only tell the CLI can +// observe today is a failed chat turn's error text, and the one gate worth +// interrupting for at that moment is the config-vs-code discriminator. + +// configVsCodeGate is the one-line config-vs-code discriminator — the single +// most relevant operating gate when a turn fails at a boundary. It mirrors the +// workspace CLAUDE.md "Config or code?" debugging gate: a total, +// input-independent failure points at wiring (config/env/state), so settings +// and env are the first place to look, not the code path. Surfaced verbatim by +// TellFor. +const configVsCodeGate = "Config or code? Total+input-independent failure → wiring: check settings/env FIRST." + +// wiringTells are lowercased substrings that mark an error as a boundary/wiring +// failure — connection, DNS, auth, and dead-route signatures. Any match routes +// to configVsCodeGate. Kept deliberately small and literal (no regex, no +// scoring): this is a situated nudge, not a diagnosis engine. The compound +// "model … not found" case is handled separately in TellFor so a bare +// "not found" from some unrelated error can't trip the gate. +var wiringTells = []string{ + "connection refused", // TCP connect rejected + "no such host", // DNS lookup failure + "no route to host", // network path down + "network is unreachable", // network path down + "dial tcp", // generic transport dial failure + "401", // unauthorized + "403", // forbidden + "unauthorized", + "forbidden", + "404", // dead route / missing endpoint +} + +// TellFor maps a failed turn's error text to the single most relevant protocol +// gate, or ("", false) when the text carries no recognized tell. Today the only +// mapping is the config-vs-code discriminator (configVsCodeGate), triggered by +// connection, auth, and model/route signatures — the errors that most often +// look like a code bug but are really wiring. The match is case-insensitive and +// substring-based; unrelated text returns ok=false so the caller stays silent. +func TellFor(errText string) (gate string, ok bool) { + lower := strings.ToLower(errText) + for _, tell := range wiringTells { + if strings.Contains(lower, tell) { + return configVsCodeGate, true + } + } + // A dead/unknown model endpoint reads like a code error ("model X not + // found") but is a config choice — route it to the same gate. Required to + // co-occur with "model" so a generic "not found" doesn't false-positive. + if strings.Contains(lower, "model") && strings.Contains(lower, "not found") { + return configVsCodeGate, true + } + return "", false +} + +// Nudger tracks which protocol gates have already been surfaced this session so +// a JIT nudge fires at most once per distinct gate — a recurring error never +// nags. The zero value is ready to use; a caller (the REPL) holds one for the +// life of the session. Not safe for concurrent use: the REPL drives it from its +// single read loop. +type Nudger struct { + shown map[string]bool +} + +// NudgeFor returns the gate line to surface for errText and true when it should +// be shown now — i.e. errText matches a tell (TellFor) AND that gate has not +// been shown before. On a true return it records the gate as shown, so any +// later error mapping to the same gate returns ok=false (the fire-once-per-gate +// guarantee). Errors with no tell always return ("", false) and record nothing. +func (n *Nudger) NudgeFor(errText string) (gate string, ok bool) { + g, matched := TellFor(errText) + if !matched || n.shown[g] { + return "", false + } + if n.shown == nil { + n.shown = make(map[string]bool) + } + n.shown[g] = true + return g, true +} diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go index 7405baa..8803b8d 100644 --- a/internal/protocol/protocol_test.go +++ b/internal/protocol/protocol_test.go @@ -274,6 +274,94 @@ func TestInjector_CwdOverlayOverridesDefault(t *testing.T) { } } +// ─── JIT tell-triggered injection: TellFor ──────────────────────────────────── + +// TestTellFor_WiringSignaturesMapToConfigVsCodeGate proves every boundary/ +// wiring error class the CLI can observe routes to the one config-vs-code gate. +func TestTellFor_WiringSignaturesMapToConfigVsCodeGate(t *testing.T) { + cases := []struct { + name string + err string + }{ + {"connection refused", `Post "http://localhost:9999/v1/chat": dial tcp 127.0.0.1:9999: connect: connection refused`}, + {"no such host", `dial tcp: lookup nope.invalid: no such host`}, + {"dial tcp bare", "dial tcp 10.0.0.1:7340: i/o timeout"}, + {"auth 401", "gateway returned status 401 unauthorized"}, + {"auth 403", "403 Forbidden"}, + {"dead route 404", "404 page not found"}, + {"model not found", `model "claude-x-9" not found`}, + {"case-insensitive", "CONNECTION REFUSED"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gate, ok := TellFor(tc.err) + if !ok { + t.Fatalf("TellFor(%q) ok=false, want true", tc.err) + } + if gate != configVsCodeGate { + t.Errorf("TellFor(%q) gate = %q, want the config-vs-code gate %q", tc.err, gate, configVsCodeGate) + } + }) + } +} + +// TestTellFor_UnrelatedTextReturnsFalse proves ordinary, non-boundary error +// text (or empty text) never trips the gate — the nudge stays silent. +func TestTellFor_UnrelatedTextReturnsFalse(t *testing.T) { + for _, s := range []string{ + "", + "the model produced an incomplete sentence", // has "model", not "not found" + "rate limit exceeded, please retry shortly", + "invalid JSON in your message payload", + "something unexpected happened while thinking", + } { + if gate, ok := TellFor(s); ok { + t.Errorf("TellFor(%q) = (%q, true), want ok=false", s, gate) + } + } +} + +// ─── JIT tell-triggered injection: Nudger de-dupe ───────────────────────────── + +// TestNudger_FiresOncePerDistinctGate proves the fire-once-per-gate-per-session +// guarantee: the first matching error surfaces the gate, and every later error +// mapping to the SAME gate — even a different wiring signature — is suppressed. +func TestNudger_FiresOncePerDistinctGate(t *testing.T) { + var n Nudger + + gate, ok := n.NudgeFor(`dial tcp 127.0.0.1:9999: connect: connection refused`) + if !ok { + t.Fatal("first NudgeFor should fire for a wiring error") + } + if gate != configVsCodeGate { + t.Fatalf("first NudgeFor gate = %q, want %q", gate, configVsCodeGate) + } + + // Same error again → deduped. + if g, ok := n.NudgeFor(`dial tcp 127.0.0.1:9999: connect: connection refused`); ok { + t.Errorf("second NudgeFor for the same error should dedupe, got (%q, true)", g) + } + // A DIFFERENT wiring error that maps to the same gate → also deduped + // (dedupe is per gate, not per error string). + if g, ok := n.NudgeFor("gateway returned 401 unauthorized"); ok { + t.Errorf("a different error mapping to an already-shown gate should dedupe, got (%q, true)", g) + } +} + +// TestNudger_NoTellNoFire proves a Nudger stays silent — and records nothing — +// for error text that carries no tell. +func TestNudger_NoTellNoFire(t *testing.T) { + var n Nudger + if g, ok := n.NudgeFor("just a normal sentence with no boundary tell"); ok { + t.Errorf("NudgeFor should not fire without a tell, got (%q, true)", g) + } + // And a real tell afterwards must still fire — the no-tell call left no + // residue that would suppress it. + if _, ok := n.NudgeFor("connection refused"); !ok { + t.Error("NudgeFor should still fire for a real tell after a no-tell call") + } +} + // TestBuildSystemContext_EnvDisabled_Empty ties the DOJO_PROTOCOL_DISABLED env // override (resolved by config.Load) to an empty injection context — proving the // escape hatch reaches all the way through the request builder. diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 341b801..e90c337 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -9,7 +9,9 @@ import ( "io" "log" "os" + "os/exec" "os/signal" + "path/filepath" "strings" "sync" "time" @@ -46,6 +48,12 @@ type REPL struct { // the protocol is disabled. protocol *protocol.Injector + // nudger tracks which JIT protocol gates have already been surfaced this + // session so the tell-triggered nudge (maybeProtocolNudge) fires at most once + // per distinct gate. Zero value is ready to use; driven only from the single + // read-loop goroutine, so it needs no lock. + nudger protocol.Nudger + mu sync.Mutex // guards turnCancel turnCancel context.CancelFunc // cancels the in-flight streaming turn; nil when idle } @@ -551,6 +559,13 @@ func (r *REPL) handle(ctx context.Context, line string) error { _ = spiritSt.Save() } + + // Done-means-verified: after a successful /agent dispatch or /run, + // run the opt-in build+test gate (Verify.AfterAgent, OFF by default). + // Best-effort — a red gate reports but never turns this into a failed + // command. REPL-only by construction: the one-shot path in + // cmd/dojo/main.go never calls handle(), so this can't fire there. + r.maybeVerifyAfterAgent(ctx, strings.ToLower(line)) } return cmdErr } @@ -596,12 +611,14 @@ func (r *REPL) chat(ctx context.Context, message string) error { turnStart := time.Now() var fullText strings.Builder var sawError bool + var lastErrText string var tokensIn, tokensOut int var sawUsage bool err := r.gw.ChatStream(turnCtx, req, func(chunk client.SSEChunk) { ev := ClassifyChunk(chunk) if ev.Type == EventError { sawError = true + lastErrText = ev.Content } rendered := ev.Render(r.plain) if rendered != "" { @@ -637,6 +654,10 @@ func (r *REPL) chat(ctx context.Context, message string) error { // not a canned "stream interrupted". Combined with the error-event // surfacing, the user now sees the actual cause. gcolor.Red.Printf(" error: %s\n", r.gw.FriendlyError(err)) + // JIT protocol nudge — pass the RAW error text, not FriendlyError's + // rewrite: FriendlyError collapses a "dial tcp … connection refused" + // into a URL-naming message that no longer carries the wiring tell. + r.maybeProtocolNudge(err.Error()) return nil } } @@ -644,6 +665,7 @@ func (r *REPL) chat(ctx context.Context, message string) error { if sawError { // Stream ended cleanly but carried a gateway error event; it was already // rendered inline. Don't count it as a successful turn or award XP. + r.maybeProtocolNudge(lastErrText) return nil } @@ -682,6 +704,128 @@ func (r *REPL) chat(ctx context.Context, message string) error { return nil } +// ─── JIT tell-triggered protocol nudge ──────────────────────────────────────── + +// maybeProtocolNudge surfaces the single most relevant protocol gate as a dim +// one-line "[protocol] …" nudge when a failed chat turn's error text matches a +// wiring/boundary tell (protocol.TellFor). It is JIT by design — situated to the +// moment of failure rather than recited up front — and deliberately quiet: +// - skipped when the protocol is disabled (Protocol.Enabled false, which +// already reflects DOJO_PROTOCOL_DISABLED via config.Load), +// - skipped under --plain/--no-color (r.plain) so piped/CI stdout stays clean +// (--json is one-shot-only and never reaches this REPL chat path), +// - fired at most once per distinct gate per session (r.nudger) so a recurring +// error never nags. +func (r *REPL) maybeProtocolNudge(errText string) { + if r.plain || r.cfg == nil || !r.cfg.Protocol.Enabled { + return + } + if gate, ok := r.nudger.NudgeFor(errText); ok { + fmt.Println(gcolor.HEX("#64748b").Sprintf(" [protocol] %s", gate)) + } +} + +// ─── Verify loop (done-means-verified) ──────────────────────────────────────── + +// maybeVerifyAfterAgent runs the opt-in verify gate after a successful /agent +// dispatch or /run, printing a single clearly-labeled "[verify] PASS" or +// "[verify] FAIL: …" line. Gated on Verify.AfterAgent (OFF by default; +// DOJO_VERIFY_AFTER_AGENT flips it on) and on the command being a verify +// trigger. Deliberately best-effort and non-blocking: a FAIL is reported, never +// returned, so a red gate never aborts the session. +func (r *REPL) maybeVerifyAfterAgent(ctx context.Context, lowerLine string) { + if r.cfg == nil || !r.cfg.Verify.AfterAgent || !isVerifyTrigger(lowerLine) { + return + } + line, passed := verifyGate(ctx) + if r.plain { + fmt.Printf(" %s\n", line) + return + } + hex := "#22c55e" // green for PASS + if !passed { + hex = "#ef4444" // red for FAIL + } + fmt.Println(gcolor.HEX(hex).Sprintf(" %s", line)) +} + +// isVerifyTrigger reports whether a successfully-dispatched slash-command line +// is one whose work may have changed code — a /agent dispatch or a /run — and +// so should be followed by the verify gate. It matches the canonical forms plus +// the /agents alias, on the already-lowercased, field-split line, so extra +// spacing never defeats it. Bare "/agent" (no dispatch) does not trigger. +func isVerifyTrigger(lowerLine string) bool { + f := strings.Fields(lowerLine) + if len(f) == 0 { + return false + } + if f[0] == "/run" { + return true + } + return (f[0] == "/agent" || f[0] == "/agents") && len(f) >= 2 && f[1] == "dispatch" +} + +// verifyGate runs the build+test gate — go build ./... then go test ./... at the +// module root — and returns a single PASS/FAIL summary line plus whether it +// passed. It is best-effort: a missing go.mod, an absent go toolchain, or a +// failing step all resolve to a "[verify] FAIL: …" line rather than a returned +// error. Steps run under ctx so a session shutdown cancels them. This mirrors +// the /code gate's exec pattern (internal/commands/cmd_code.go); that package's +// runGoCmd/findGoModRoot are unexported and unreachable from here, so the small +// amount of exec logic is reproduced locally. +func verifyGate(ctx context.Context) (line string, passed bool) { + root, err := goModRoot() + if err != nil { + return "[verify] FAIL: " + err.Error(), false + } + for _, args := range [][]string{ + {"build", "./..."}, + {"test", "./..."}, + } { + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = root + if out, cmdErr := cmd.CombinedOutput(); cmdErr != nil { + return fmt.Sprintf("[verify] FAIL: go %s — %s", strings.Join(args, " "), verifyFailDetail(out, cmdErr)), false + } + } + return "[verify] PASS", true +} + +// goModRoot walks up from the current working directory to the nearest +// directory containing a go.mod — the module root the verify gate builds and +// tests from. Mirrors internal/commands' findGoModRoot, which is unexported and +// so not reachable from this package. +func goModRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("no go.mod found from %s upward", dir) + } + dir = parent + } +} + +// verifyFailDetail condenses a failed gate's combined output into one concise +// line for the FAIL summary: the first non-empty output line (where the go +// toolchain prints the actual error), falling back to the process error when +// there is no captured output. Truncated so a wall of build errors never floods +// the prompt. +func verifyFailDetail(out []byte, err error) string { + for _, ln := range strings.Split(string(out), "\n") { + if s := strings.TrimSpace(ln); s != "" { + return truncateSSE(s, 200) + } + } + return err.Error() +} + // truncateSSE truncates a string to max characters for log output. func truncateSSE(s string, max int) string { if len(s) <= max { From 103cad14d22fb6ab21ed3c7575e86dc171a47619 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 09:35:00 -0500 Subject: [PATCH 22/23] feat(protocol): escalate JIT nudge to the debugging gate on repeated/build/test failures TellFor now also maps build/test/panic/compile signatures to the debugging gate (state the causal chain + cheapest on/off experiment), with stable per-gate keys so wiring and debugging de-dupe independently; wiring wins on ties. The REPL tracks a normalized error signature and, after the same failure recurs 3x, surfaces the debugging gate once. Suppression under --plain and when protocol is disabled still holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/protocol/protocol.go | 117 ++++++++++++++++++---- internal/protocol/protocol_test.go | 112 ++++++++++++++++++++++ internal/repl/repl.go | 85 ++++++++++++++++ internal/repl/repl_test.go | 149 +++++++++++++++++++++++++++++ 4 files changed, 445 insertions(+), 18 deletions(-) diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index 1923856..44a812d 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -164,6 +164,22 @@ func (in *Injector) Apply(req *client.ChatRequest) bool { // TellFor. const configVsCodeGate = "Config or code? Total+input-independent failure → wiring: check settings/env FIRST." +// debuggingGate is the one-line "debug by disproof" gate — the most relevant +// move the moment an error signals a build/test/logic failure rather than a +// wiring boundary. It mirrors the workspace CLAUDE.md debugging protocol: state +// the causal chain and name the cheapest experiment that toggles the bug before +// touching a fix. Surfaced verbatim by TellFor for the build/test tell class. +const debuggingGate = "Debug by disproof: state the causal chain and name the cheapest experiment that toggles the bug on and off before any fix." + +// Stable gate keys. The Nudger de-dupes on these — not on the display line — so +// a gate fires at most once per session regardless of which error string (or +// which surface: TellFor vs the REPL's repeated-failure detector) triggered it, +// and the two classes stay independently fire-able because their keys differ. +const ( + gateKeyConfigVsCode = "config-vs-code" + gateKeyDebugging = "debugging" +) + // wiringTells are lowercased substrings that mark an error as a boundary/wiring // failure — connection, DNS, auth, and dead-route signatures. Any match routes // to configVsCodeGate. Kept deliberately small and literal (no regex, no @@ -183,26 +199,67 @@ var wiringTells = []string{ "404", // dead route / missing endpoint } +// debuggingTells are lowercased substrings that mark an error as a build, test, +// or compile/logic failure — the class where the right move is to form a +// disprovable theory, not to check config. Any match routes to debuggingGate. +// Kept small and literal like wiringTells. Several are the exact shapes the Go +// toolchain emits — "panic:", "undefined:", and the "--- fail" / "fail\t" +// markers `go test` prints — so ordinary prose that merely contains the word +// "fail" does not trip the gate. "cannot use"/"compile"/"assertion" carry a +// little more false-positive surface than the tokened forms, so they are the +// last resort here (checked after the wiring class in tellFor) and stay literal. +var debuggingTells = []string{ + "panic:", // Go runtime panic header + "--- fail", // `go test` per-test failure marker (from "--- FAIL") + "fail\t", // `go test` summary line "FAIL\t<pkg>" (tab-delimited) + "test failed", // generic test-runner phrasing + "build failed", + "undefined:", // Go compiler: undefined symbol + "cannot use", // Go compiler: type mismatch ("cannot use x as y") + "compile", // "compile error" / "does not compile" / "compilation failed" + "assertion", // failed assertion (test frameworks, runtime checks) +} + // TellFor maps a failed turn's error text to the single most relevant protocol -// gate, or ("", false) when the text carries no recognized tell. Today the only -// mapping is the config-vs-code discriminator (configVsCodeGate), triggered by -// connection, auth, and model/route signatures — the errors that most often -// look like a code bug but are really wiring. The match is case-insensitive and -// substring-based; unrelated text returns ok=false so the caller stays silent. +// gate line, or ("", false) when the text carries no recognized tell. Two tell +// classes are recognized: wiring/boundary signatures (connection, auth, dead +// route, unknown model) map to the config-vs-code discriminator, and build/ +// test/compile signatures map to the debug-by-disproof gate. The match is +// case-insensitive and substring-based; unrelated text returns ok=false so the +// caller stays silent. Wiring is checked first, so a boundary failure keeps its +// config-vs-code framing even in the rare event its text also mentions a build. func TellFor(errText string) (gate string, ok bool) { + _, gate, ok = tellFor(errText) + return gate, ok +} + +// tellFor is the shared matcher behind TellFor and Nudger.NudgeFor. Beyond the +// display line it returns a STABLE gate key (gateKeyConfigVsCode / +// gateKeyDebugging) so the Nudger can de-dupe on gate identity rather than +// wording. Wiring/boundary tells win over build/test tells when both are +// present, preserving TellFor's original config-vs-code precedence. +func tellFor(errText string) (key, gate string, ok bool) { lower := strings.ToLower(errText) for _, tell := range wiringTells { if strings.Contains(lower, tell) { - return configVsCodeGate, true + return gateKeyConfigVsCode, configVsCodeGate, true } } // A dead/unknown model endpoint reads like a code error ("model X not - // found") but is a config choice — route it to the same gate. Required to - // co-occur with "model" so a generic "not found" doesn't false-positive. + // found") but is a config choice — route it to the config-vs-code gate. + // Required to co-occur with "model" so a generic "not found" doesn't + // false-positive. if strings.Contains(lower, "model") && strings.Contains(lower, "not found") { - return configVsCodeGate, true + return gateKeyConfigVsCode, configVsCodeGate, true + } + // Build/test/compile signatures — the debug-by-disproof class. Checked last + // so the wiring class always wins a tie. + for _, tell := range debuggingTells { + if strings.Contains(lower, tell) { + return gateKeyDebugging, debuggingGate, true + } } - return "", false + return "", "", false } // Nudger tracks which protocol gates have already been surfaced this session so @@ -215,18 +272,42 @@ type Nudger struct { } // NudgeFor returns the gate line to surface for errText and true when it should -// be shown now — i.e. errText matches a tell (TellFor) AND that gate has not -// been shown before. On a true return it records the gate as shown, so any -// later error mapping to the same gate returns ok=false (the fire-once-per-gate -// guarantee). Errors with no tell always return ("", false) and record nothing. +// be shown now — i.e. errText matches a tell (tellFor) AND that gate has not +// been shown before. On a true return it records the gate (by its stable key) +// as shown, so any later error mapping to the same gate returns ok=false (the +// fire-once-per-gate guarantee). Errors with no tell always return ("", false) +// and record nothing. func (n *Nudger) NudgeFor(errText string) (gate string, ok bool) { - g, matched := TellFor(errText) - if !matched || n.shown[g] { + key, g, matched := tellFor(errText) + if !matched || n.shown[key] { return "", false } + n.markShown(key) + return g, true +} + +// NudgeDebugging surfaces the debug-by-disproof gate on demand, de-duped through +// the SAME per-session ledger as NudgeFor. It returns (debuggingGate, true) only +// the first time the debugging gate is requested this session — whether that +// first request came from build/test error text via NudgeFor or from a caller +// that has independently decided the gate applies (the REPL's repeated-failure +// detector) — and ("", false) every time thereafter. This is the entry point +// for "I already know the debugging gate is what's needed"; NudgeFor is the +// entry point for "decide from the error text". +func (n *Nudger) NudgeDebugging() (gate string, ok bool) { + if n.shown[gateKeyDebugging] { + return "", false + } + n.markShown(gateKeyDebugging) + return debuggingGate, true +} + +// markShown records a gate key as surfaced, lazily creating the ledger. Shared +// by NudgeFor and NudgeDebugging so the fire-once-per-gate bookkeeping lives in +// one place. +func (n *Nudger) markShown(key string) { if n.shown == nil { n.shown = make(map[string]bool) } - n.shown[g] = true - return g, true + n.shown[key] = true } diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go index 8803b8d..1b440db 100644 --- a/internal/protocol/protocol_test.go +++ b/internal/protocol/protocol_test.go @@ -417,3 +417,115 @@ func TestBuildSystemContext_EnabledByDefaultViaLoad(t *testing.T) { t.Error("default-enabled config should inject the embedded default doc") } } + +// ─── JIT tell-triggered injection: TellFor debugging class ──────────────────── + +// TestTellFor_BuildTestSignaturesMapToDebuggingGate proves build/test/compile/ +// panic error text routes to the debug-by-disproof gate — the second tell class. +func TestTellFor_BuildTestSignaturesMapToDebuggingGate(t *testing.T) { + cases := []struct { + name string + err string + }{ + {"go test per-test marker", "--- FAIL: TestFoo (0.00s)"}, + {"go test summary line", "FAIL\tgithub.com/DojoGenesis/cli/internal/repl\t0.412s"}, + {"panic header", "panic: runtime error: index out of range [3] with length 2"}, + {"test failed prose", "1 test failed in package ./internal/protocol"}, + {"build failed prose", "build failed: two errors"}, + {"undefined symbol", "./repl.go:12:9: undefined: fooBar"}, + {"cannot use type mismatch", "cannot use x (variable of type int) as string value in assignment"}, + {"compile", "internal compiler error: could not compile package"}, + {"assertion", "assertion failed: expected three, got four"}, + {"case-insensitive", "PANIC: nil pointer dereference"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gate, ok := TellFor(tc.err) + if !ok { + t.Fatalf("TellFor(%q) ok=false, want true", tc.err) + } + if gate != debuggingGate { + t.Errorf("TellFor(%q) gate = %q, want the debugging gate %q", tc.err, gate, debuggingGate) + } + }) + } +} + +// TestTellFor_DebuggingNearMissesStaySilent guards the build/test class against +// overreach: prose that merely contains "fail" (but none of the tokened shapes) +// must NOT trip the gate. +func TestTellFor_DebuggingNearMissesStaySilent(t *testing.T) { + for _, s := range []string{ + "the request failed for an unknown reason", // "failed", but not "test failed" / "fail\t" / "--- fail" + "your prompt failed our safety filter", + "we could not fulfill that ask", + } { + if gate, ok := TellFor(s); ok { + t.Errorf("TellFor(%q) = (%q, true), want ok=false", s, gate) + } + } +} + +// TestTellFor_WiringBeatsDebuggingWhenBothPresent proves the precedence: if an +// error text somehow carries both a boundary signature and a build/test one, it +// keeps the config-vs-code framing (wiring is checked first), so a connection +// failure is never re-labeled a logic bug. +func TestTellFor_WiringBeatsDebuggingWhenBothPresent(t *testing.T) { + gate, ok := TellFor("build failed: dial tcp 127.0.0.1:7340: connection refused") + if !ok { + t.Fatal("expected a tell to match") + } + if gate != configVsCodeGate { + t.Errorf("gate = %q, want config-vs-code (wiring precedence)", gate) + } +} + +// TestNudger_DebuggingGateDeDupesAndIsIndependent proves the debugging gate, like +// the config-vs-code gate, surfaces at most once — and that the two are +// independent: showing one does not suppress the other. +func TestNudger_DebuggingGateDeDupesAndIsIndependent(t *testing.T) { + var n Nudger + + // A wiring error fires config-vs-code. + if g, ok := n.NudgeFor("connection refused"); !ok || g != configVsCodeGate { + t.Fatalf("wiring NudgeFor = (%q,%v), want config-vs-code,true", g, ok) + } + // A build error still fires — debugging is a distinct gate/key. + if g, ok := n.NudgeFor("panic: boom"); !ok || g != debuggingGate { + t.Fatalf("build NudgeFor = (%q,%v), want debugging,true", g, ok) + } + // A second, differently-worded build error is deduped (per gate, not string). + if g, ok := n.NudgeFor("--- FAIL: TestX"); ok { + t.Errorf("second debugging-class error should dedupe, got (%q,true)", g) + } +} + +// TestNudger_NudgeDebugging_SharesLedgerWithNudgeFor proves the direct +// NudgeDebugging entry point de-dupes against the text-driven NudgeFor path: +// whichever fires first spends the single debugging-gate slot for the session. +func TestNudger_NudgeDebugging_SharesLedgerWithNudgeFor(t *testing.T) { + // Direct-first: NudgeDebugging fires, then a build-tell NudgeFor is deduped. + var a Nudger + if g, ok := a.NudgeDebugging(); !ok || g != debuggingGate { + t.Fatalf("first NudgeDebugging = (%q,%v), want debugging,true", g, ok) + } + if g, ok := a.NudgeDebugging(); ok { + t.Errorf("second NudgeDebugging should dedupe, got (%q,true)", g) + } + if g, ok := a.NudgeFor("panic: later"); ok { + t.Errorf("NudgeFor debugging class after NudgeDebugging should dedupe, got (%q,true)", g) + } + + // Text-first: a build-tell NudgeFor fires, then NudgeDebugging is deduped. + var b Nudger + if g, ok := b.NudgeFor("build failed"); !ok || g != debuggingGate { + t.Fatalf("build NudgeFor = (%q,%v), want debugging,true", g, ok) + } + if g, ok := b.NudgeDebugging(); ok { + t.Errorf("NudgeDebugging after a debugging-class NudgeFor should dedupe, got (%q,true)", g) + } + // The config-vs-code gate is still available on b — independence holds. + if g, ok := b.NudgeFor("connection refused"); !ok || g != configVsCodeGate { + t.Errorf("config-vs-code gate should remain available, got (%q,%v)", g, ok) + } +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index e90c337..90cc14f 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -12,6 +12,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "regexp" "strings" "sync" "time" @@ -54,6 +55,16 @@ type REPL struct { // read-loop goroutine, so it needs no lock. nudger protocol.Nudger + // Repeated-failure detection (the "Nth retry of the same thing" tell): + // lastErrSig is the normalized signature of the most recent failed chat turn + // and errRepeat is how many times in a row that same signature has recurred. + // Once the run reaches repeatFailureThreshold the debugging gate is surfaced + // through nudger — so it still de-dupes and still respects --plain and the + // protocol-enabled flag. A different error resets the run. Same read-loop + // goroutine as nudger, so no lock. + lastErrSig string + errRepeat int + mu sync.Mutex // guards turnCancel turnCancel context.CancelFunc // cancels the in-flight streaming turn; nil when idle } @@ -658,6 +669,10 @@ func (r *REPL) chat(ctx context.Context, message string) error { // rewrite: FriendlyError collapses a "dial tcp … connection refused" // into a URL-naming message that no longer carries the wiring tell. r.maybeProtocolNudge(err.Error()) + // Repeated-failure tell: the same error recurring across turns + // escalates to the debugging gate even when a single instance carried + // no situated tell. Same raw text, for the same reason as above. + r.maybeRepeatFailureNudge(err.Error()) return nil } } @@ -666,6 +681,7 @@ func (r *REPL) chat(ctx context.Context, message string) error { // Stream ended cleanly but carried a gateway error event; it was already // rendered inline. Don't count it as a successful turn or award XP. r.maybeProtocolNudge(lastErrText) + r.maybeRepeatFailureNudge(lastErrText) return nil } @@ -725,6 +741,75 @@ func (r *REPL) maybeProtocolNudge(errText string) { } } +// ─── Repeated-failure tell ("Nth retry of the same thing") ──────────────────── + +// repeatFailureThreshold is how many times the SAME chat error (by normalized +// signature) must recur before the repeated-failure detector surfaces the +// debugging gate. Three: the point at which retrying the same thing has visibly +// stopped working and the move is to form a theory, not retry a fourth time. +const repeatFailureThreshold = 3 + +// errSigDigits matches runs of digits. normalizeErrSignature collapses them to a +// single "#" so volatile numerics — ports, IP octets, line:col positions, +// request IDs, backoff timings — don't split what is really one recurring error +// into distinct signatures. +var errSigDigits = regexp.MustCompile(`\d+`) + +// normalizeErrSignature reduces a raw error string to a stable signature for +// repeated-failure detection: lowercase, digit-runs collapsed to "#", and all +// whitespace (including the tabs `go test` emits) squeezed to single spaces. It +// erases only the volatile numerics and keeps the error's words — the +// class-carrying part — so two attempts at the same failing thing share a +// signature while genuinely different errors do not. Deliberately measured: it +// neither over-collapses distinct classes (their words still differ) nor splits +// one class on incidental number or spacing noise. +func normalizeErrSignature(s string) string { + s = strings.ToLower(s) + s = errSigDigits.ReplaceAllString(s, "#") + return strings.Join(strings.Fields(s), " ") +} + +// recordFailure feeds one failed chat turn into the repeated-failure detector +// and reports whether the debugging gate should surface now. It always updates +// the (signature, count) tracker so the count stays accurate, then returns +// (gate, true) only when ALL hold: the same error has now recurred +// repeatFailureThreshold times; the protocol is enabled and not suppressed by +// --plain; and the Nudger has not already shown the debugging gate this session +// (fire-once-per-gate, shared with maybeProtocolNudge). Otherwise ("", false). +// Empty/whitespace error text is ignored outright — it neither advances nor +// resets the run, since a blank error is not a distinct failure worth counting. +func (r *REPL) recordFailure(errText string) (gate string, ok bool) { + sig := normalizeErrSignature(errText) + if sig == "" { + return "", false + } + if sig == r.lastErrSig { + r.errRepeat++ + } else { + r.lastErrSig = sig + r.errRepeat = 1 + } + if r.errRepeat < repeatFailureThreshold { + return "", false + } + if r.plain || r.cfg == nil || !r.cfg.Protocol.Enabled { + return "", false + } + return r.nudger.NudgeDebugging() +} + +// maybeRepeatFailureNudge surfaces the debugging gate as a dim one-line +// "[protocol] …" nudge when recordFailure reports the same error has recurred +// enough times to warrant it. It shares maybeProtocolNudge's suppression rules +// (checked inside recordFailure) and the same Nudger ledger, so the debugging +// gate still fires at most once per session whether it was reached by a build/ +// test error's situated tell or by this repeated-failure path. +func (r *REPL) maybeRepeatFailureNudge(errText string) { + if gate, ok := r.recordFailure(errText); ok { + fmt.Println(gcolor.HEX("#64748b").Sprintf(" [protocol] %s", gate)) + } +} + // ─── Verify loop (done-means-verified) ──────────────────────────────────────── // maybeVerifyAfterAgent runs the opt-in verify gate after a successful /agent diff --git a/internal/repl/repl_test.go b/internal/repl/repl_test.go index e313d23..6a3d2ac 100644 --- a/internal/repl/repl_test.go +++ b/internal/repl/repl_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/DojoGenesis/cli/internal/client" + "github.com/DojoGenesis/cli/internal/config" "github.com/DojoGenesis/cli/internal/hooks" "github.com/DojoGenesis/cli/internal/plugins" ) @@ -301,3 +302,151 @@ func TestFireSessionEnd_NoMatchingHooks_NoPanic(t *testing.T) { r := &REPL{runner: hooks.New(nil), session: "dojo-cli-test-session"} r.fireSessionEnd() } + +// ─── normalizeErrSignature ──────────────────────────────────────────────────── + +func TestNormalizeErrSignature(t *testing.T) { + cases := []struct{ in, want string }{ + {"Connection Refused", "connection refused"}, + {"dial tcp 127.0.0.1:7340: connection refused", "dial tcp #.#.#.#:#: connection refused"}, + {"FAIL\tpkg/path\t0.41s", "fail pkg/path #.#s"}, + {" extra spaces\tand\ttabs ", "extra spaces and tabs"}, + {"", ""}, + {" \t\n ", ""}, + } + for _, c := range cases { + if got := normalizeErrSignature(c.in); got != c.want { + t.Errorf("normalizeErrSignature(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// ─── Repeated-failure tell (recordFailure) ──────────────────────────────────── + +// enabledREPL builds the minimal REPL recordFailure touches: a non-plain session +// with the protocol enabled. The Nudger and failure tracker are zero-valued. +func enabledREPL() *REPL { + return &REPL{cfg: &config.Config{Protocol: config.ProtocolConfig{Enabled: true}}} +} + +// TestRecordFailure_FiresDebuggingGateOnceAtThreshold proves the repeated-failure +// detector stays silent for the first repeatFailureThreshold-1 identical +// failures, fires the debugging gate exactly on the Nth, and dedupes after. +func TestRecordFailure_FiresDebuggingGateOnceAtThreshold(t *testing.T) { + r := enabledREPL() + const errText = "rate limit exceeded" // no situated tell — pure repetition drives it + + for i := 1; i < repeatFailureThreshold; i++ { + if g, ok := r.recordFailure(errText); ok { + t.Fatalf("recordFailure #%d fired early: (%q, true)", i, g) + } + } + g, ok := r.recordFailure(errText) // the Nth + if !ok { + t.Fatalf("recordFailure #%d should fire the debugging gate", repeatFailureThreshold) + } + if !strings.Contains(g, "Debug by disproof") { + t.Errorf("fired gate = %q, want the debug-by-disproof gate", g) + } + // N+1 and beyond: deduped by the Nudger's fire-once-per-gate ledger. + if g, ok := r.recordFailure(errText); ok { + t.Errorf("recordFailure after firing should dedupe, got (%q, true)", g) + } +} + +// TestRecordFailure_DifferentErrorResetsRun proves a different error resets the +// streak: two of error A then a switch to error B restarts the count at 1, so B +// must recur its own full threshold before anything fires. +func TestRecordFailure_DifferentErrorResetsRun(t *testing.T) { + r := enabledREPL() + + _, _ = r.recordFailure("connection refused") // A, count 1 + _, _ = r.recordFailure("connection refused") // A, count 2 + if r.errRepeat != 2 { + t.Fatalf("after two identical failures errRepeat = %d, want 2", r.errRepeat) + } + _, _ = r.recordFailure("undefined: fooBar") // B — different, resets + if r.errRepeat != 1 { + t.Fatalf("a different error should reset errRepeat to 1, got %d", r.errRepeat) + } + // B must reach the threshold on its own to fire. + if _, ok := r.recordFailure("undefined: fooBar"); ok { // B count 2 + t.Fatal("B fired before reaching the threshold") + } + if _, ok := r.recordFailure("undefined: fooBar"); !ok { // B count 3 + t.Fatal("B should fire once it reaches the threshold") + } +} + +// TestRecordFailure_NormalizesTriviallyDifferentErrors proves errors that differ +// only in volatile numerics (ports, IPs) share a signature and so count toward +// the same run — one recurring failure, not three distinct ones. +func TestRecordFailure_NormalizesTriviallyDifferentErrors(t *testing.T) { + r := enabledREPL() + msgs := []string{ + `dial tcp 127.0.0.1:7340: connect: connection refused`, + `dial tcp 10.0.0.1:9999: connect: connection refused`, + `dial tcp 192.168.1.5:8080: connect: connection refused`, + } + var fired bool + for i, m := range msgs { + if _, ok := r.recordFailure(m); ok { + if i < len(msgs)-1 { + t.Fatalf("fired early at attempt %d", i+1) + } + fired = true + } + } + if !fired { + t.Error("three trivially-different-but-same-class errors should reach the threshold together") + } + if r.errRepeat < repeatFailureThreshold { + t.Errorf("errRepeat = %d, want >= %d (signatures should have collapsed)", r.errRepeat, repeatFailureThreshold) + } +} + +// TestRecordFailure_SuppressedUnderPlainAndDisabled proves the surface +// suppression rules hold: --plain, a disabled protocol, and a nil cfg all keep +// the gate silent even past the threshold — while --plain still COUNTS the run, +// so the detector is correct the instant suppression is lifted. +func TestRecordFailure_SuppressedUnderPlainAndDisabled(t *testing.T) { + // --plain: enabled protocol but plain output → never fires. + plain := &REPL{cfg: &config.Config{Protocol: config.ProtocolConfig{Enabled: true}}, plain: true} + for i := 0; i < repeatFailureThreshold+2; i++ { + if _, ok := plain.recordFailure("build failed"); ok { + t.Fatal("recordFailure must stay silent under --plain") + } + } + if plain.errRepeat < repeatFailureThreshold { + t.Errorf("plain mode should still COUNT failures; errRepeat = %d", plain.errRepeat) + } + + // Protocol disabled → never fires. + off := &REPL{cfg: &config.Config{Protocol: config.ProtocolConfig{Enabled: false}}} + for i := 0; i < repeatFailureThreshold+2; i++ { + if _, ok := off.recordFailure("build failed"); ok { + t.Fatal("recordFailure must stay silent when the protocol is disabled") + } + } + + // nil cfg → never fires (defensive). + nilcfg := &REPL{} + for i := 0; i < repeatFailureThreshold+2; i++ { + if _, ok := nilcfg.recordFailure("build failed"); ok { + t.Fatal("recordFailure must stay silent with nil cfg") + } + } +} + +// TestRecordFailure_EmptyErrorIgnored proves blank error text neither advances +// nor resets the run — it is not a distinct failure to count. +func TestRecordFailure_EmptyErrorIgnored(t *testing.T) { + r := enabledREPL() + _, _ = r.recordFailure("build failed") // count 1 + if _, ok := r.recordFailure(" \t\n "); ok { + t.Fatal("whitespace-only error should be ignored") + } + if r.errRepeat != 1 || r.lastErrSig == "" { + t.Errorf("empty error must not touch the tracker; errRepeat=%d lastErrSig=%q", r.errRepeat, r.lastErrSig) + } +} From 2e74d524e85524d5b95cdc744298b6b478053e00 Mon Sep 17 00:00:00 2001 From: TresPies-source <cruz@trespiesdesign.com> Date: Thu, 16 Jul 2026 09:35:00 -0500 Subject: [PATCH 23/23] feat(protocol): add /protocol show and /protocol edit /protocol show prints the currently-effective protocol doc (project ./DOJO.md > ~/.dojo/DOJO.md > embedded default) glamour-rendered, with a WARN when the protocol is disabled. /protocol edit opens the writable overlay in $EDITOR/$VISUAL (creating it from the default if absent, never clobbering; safe argv handling; guidance fallback when no editor is set). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/commands/cmd_protocol.go | 145 ++++++++++- internal/commands/cmd_protocol_test.go | 325 +++++++++++++++++++++++++ 2 files changed, 462 insertions(+), 8 deletions(-) diff --git a/internal/commands/cmd_protocol.go b/internal/commands/cmd_protocol.go index f0bf7e4..1aeef74 100644 --- a/internal/commands/cmd_protocol.go +++ b/internal/commands/cmd_protocol.go @@ -1,11 +1,15 @@ package commands // cmd_protocol.go — /protocol: make the KE harness ecosystem discoverable and -// installable from the CLI, and surface the genius-protocol state, without ever -// bypassing ke's operator-gated publish pipeline. +// installable from the CLI, and surface + customize the genius-protocol state, +// without ever bypassing ke's operator-gated publish pipeline. // // /protocol — focused status: protocol enabled/source + is kata-harness installed // /protocol status — same as above +// /protocol show — print the CURRENTLY EFFECTIVE protocol doc (rendered markdown), +// via internal/protocol.LoadOverlay's own precedence +// /protocol edit — open the writable overlay (project ./DOJO.md, else +// ~/.dojo/DOJO.md) in $EDITOR/$VISUAL so it can be customized // /protocol harnesses — list the KE catalog: name · status · ratified/draft · installed? // /protocol install <name> [--yes] // — copy a ratified, locally-available harness plugin into the @@ -13,9 +17,14 @@ package commands // not-locally-available harnesses print operator guidance. // // SAFETY model (why this file is read-mostly): -// - The ONLY write is copying an already-local, ratified plugin directory into -// cfg.Plugins.Path on an explicit install + confirmation. Nothing else mutates -// state, calls a network endpoint, or shells out. +// - The ONLY writes are: (1) copying an already-local, ratified plugin directory +// into cfg.Plugins.Path on an explicit install + confirmation, and (2) /protocol +// edit creating dojoDir/DOJO.md from the embedded default on first touch (via +// protocol.WriteDefaultOverlay, which never clobbers a file that's already +// there) before handing it to the user's own $EDITOR/$VISUAL. Nothing else +// mutates state, calls a network endpoint, or shells out beyond that editor +// launch — and the editor launch never runs with a shell, so the resolved +// path is never subject to shell interpolation. // - No operator-gated action is ever taken: this command never runs `ke promote` // or `ke publish`, never shells out to bin/ke. Unratified or not-yet-pulled // harnesses route to printed instructions, per "the store discovers, ke installs". @@ -31,11 +40,15 @@ import ( "fmt" "io/fs" "os" + "os/exec" "path/filepath" "strings" "github.com/DojoGenesis/cli/internal/activity" + "github.com/DojoGenesis/cli/internal/config" + "github.com/DojoGenesis/cli/internal/mdrender" "github.com/DojoGenesis/cli/internal/plugins" + "github.com/DojoGenesis/cli/internal/protocol" gcolor "github.com/gookit/color" ) @@ -247,8 +260,8 @@ func copyPluginTree(src, dst string) error { func (r *Registry) protocolCmd() Command { return Command{ Name: "protocol", - Usage: "/protocol [status|harnesses|install <name> [--yes]]", - Short: "Discover + install KE harnesses and show genius-protocol state", + Usage: "/protocol [status|show|edit|harnesses|install <name> [--yes]]", + Short: "Discover/install KE harnesses; show or edit the genius-protocol doc", Run: func(ctx context.Context, args []string) error { if len(args) == 0 { return r.protocolStatus() @@ -256,6 +269,10 @@ func (r *Registry) protocolCmd() Command { switch strings.ToLower(args[0]) { case "status": return r.protocolStatus() + case "show": + return r.protocolShow() + case "edit": + return r.protocolEdit(ctx) case "harnesses", "harness", "ls", "list": return r.protocolHarnesses() case "install", "add": @@ -270,7 +287,7 @@ func (r *Registry) protocolCmd() Command { } return r.protocolInstall(ctx, args[1], noConfirm) default: - return fmt.Errorf("unknown subcommand %q — use status, harnesses, or install <name>", args[0]) + return fmt.Errorf("unknown subcommand %q — use status, show, edit, harnesses, or install <name>", args[0]) } }, } @@ -329,6 +346,118 @@ func (r *Registry) protocolStatus() error { fmt.Printf(" %s %d of %d harness(es) installed — /protocol harnesses for the full list\n", doctorTag(true), installed, total) fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" view the effective doc: /protocol show")) + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" customize it: /protocol edit")) + fmt.Println() + return nil +} + +// ─── show ─────────────────────────────────────────────────────────────────── + +// protocolShow prints the currently effective protocol doc — the exact text +// LoadOverlay resolves at session start (project ./DOJO.md > ~/.dojo/DOJO.md > +// embedded default) — rendered as markdown, with the active source labeled so +// it's obvious at a glance which file, if any, is driving the injected +// protocol. +func (r *Registry) protocolShow() error { + cwd, _ := os.Getwd() + doc, source := protocol.LoadOverlay(cwd, config.DojoDir()) + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Genius Protocol — effective doc")) + fmt.Println() + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" source: " + protocolSourceLabel(source))) + if !r.cfg.Protocol.Enabled { + fmt.Println(gcolor.HEX("#e63946").Sprint(" protocol.enabled = false — this doc is NOT currently injected into sessions")) + } + fmt.Println() + fmt.Println(mdrender.RenderMarkdown(doc)) + fmt.Println() + return nil +} + +// protocolSourceLabel normalizes LoadOverlay's source marker for display: +// its literal "(embedded default)" reads as plain prose here, matching the +// "embedded default" wording /protocol status already uses. +func protocolSourceLabel(source string) string { + if source == "(embedded default)" { + return "embedded default" + } + return source +} + +// ─── edit ─────────────────────────────────────────────────────────────────── + +// protocolResolveEditTarget picks the writable overlay path an operator +// should customize, mirroring LoadOverlay's precedence (project ./DOJO.md +// wins when present) but returning a path to open rather than resolved +// content. Unlike LoadOverlay it never falls through to an in-memory embedded +// default — editing needs a real file on disk, so when neither override +// exists yet it creates dojoDir/DOJO.md from the embedded default via +// protocol.WriteDefaultOverlay, which never clobbers a file that's already +// there. +func protocolResolveEditTarget(cwd, dojoDir string) (string, error) { + if cwd != "" { + p := filepath.Join(cwd, "DOJO.md") + if info, err := os.Stat(p); err == nil && !info.IsDir() { + return p, nil + } + } + if err := protocol.WriteDefaultOverlay(dojoDir); err != nil { + return "", fmt.Errorf("prepare %s: %w", filepath.Join(dojoDir, "DOJO.md"), err) + } + return filepath.Join(dojoDir, "DOJO.md"), nil +} + +// protocolEdit opens the writable protocol overlay in the user's editor so +// they can customize the injected genius protocol. Target resolution is +// protocolResolveEditTarget: project ./DOJO.md if it already exists, else +// ~/.dojo/DOJO.md (created from the embedded default on first touch). +// +// $EDITOR is tried first, then $VISUAL. Neither set is NOT an error — the +// overlay is still resolved/created either way, so this prints the path and +// guidance instead of failing; the CLI never blocks a session on an editor it +// can't find. +func (r *Registry) protocolEdit(ctx context.Context) error { + cwd, _ := os.Getwd() + target, err := protocolResolveEditTarget(cwd, config.DojoDir()) + if err != nil { + return err + } + + editor := strings.TrimSpace(os.Getenv("EDITOR")) + if editor == "" { + editor = strings.TrimSpace(os.Getenv("VISUAL")) + } + if editor == "" { + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Overlay ready at " + target)) + fmt.Println(gcolor.HEX("#e8b04a").Sprint(" set $EDITOR (or $VISUAL) to edit it, e.g. export EDITOR=vim")) + fmt.Println() + return nil + } + + // $EDITOR/$VISUAL may carry arguments (e.g. "code --wait") — split on + // whitespace rather than treating the whole string as one binary name, the + // way a shell would locate the program vs. its flags. No shell is + // invoked, so target is passed as a single argv element and is never + // subject to shell interpolation. + fields := strings.Fields(editor) + bin, extraArgs := fields[0], fields[1:] + cmdArgs := append(append([]string{}, extraArgs...), target) + + cmd := exec.CommandContext(ctx, bin, cmdArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if runErr := cmd.Run(); runErr != nil { + return fmt.Errorf("launch editor %q on %s: %w", editor, target, runErr) + } + + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Changes take effect next session — the protocol resolves once at session start.")) + fmt.Println() return nil } diff --git a/internal/commands/cmd_protocol_test.go b/internal/commands/cmd_protocol_test.go index e72e590..fe88118 100644 --- a/internal/commands/cmd_protocol_test.go +++ b/internal/commands/cmd_protocol_test.go @@ -14,9 +14,12 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "testing" + "github.com/DojoGenesis/cli/internal/config" + "github.com/DojoGenesis/cli/internal/protocol" gcolor "github.com/gookit/color" ) @@ -328,3 +331,325 @@ func TestProtocolIsPluginDir(t *testing.T) { t.Error("expected isPluginDir false for a directory with no manifest") } } + +// ─── /protocol show + /protocol edit test helpers ───────────────────────────── + +// protocolHermeticCwd composes clearProtocolEnv with a fresh, empty cwd so +// /protocol show and /protocol edit resolution sees no project ./DOJO.md and +// no ~/.dojo/DOJO.md unless the test writes one. Returns the (unresolved) +// temp cwd. t.Chdir auto-restores at test end (same contract as t.Setenv), so +// — like every other test in this file — this cannot run in parallel. +func protocolHermeticCwd(t *testing.T) string { + t.Helper() + clearProtocolEnv(t) + cwd := t.TempDir() + t.Chdir(cwd) + return cwd +} + +// withColorDisabled forces gcolor.Enable off for the duration of a test so +// mdrender.RenderMarkdown returns raw markdown verbatim instead of +// glamour-styled ANSI output (see internal/mdrender/mdrender_test.go), +// letting assertions match literal doc text. Restores the previous value on +// cleanup since gcolor.Enable is shared package-level state. +func withColorDisabled(t *testing.T) { + t.Helper() + prev := gcolor.Enable + gcolor.Enable = false + t.Cleanup(func() { gcolor.Enable = prev }) +} + +// stubEditorScript writes a POSIX shell script to dir that appends every +// argument it receives, one per line, to markerPath — a minimal stand-in for +// a real $EDITOR that lets a test observe exactly what protocolEdit invoked +// it with, without opening a real interactive program. exitCode controls the +// script's own exit status so failure propagation can be exercised too. +func stubEditorScript(t *testing.T, dir, markerPath string, exitCode int) string { + t.Helper() + script := filepath.Join(dir, "fake-editor.sh") + body := "#!/bin/sh\n" + + "for a in \"$@\"; do printf '%s\\n' \"$a\" >> \"" + markerPath + "\"; done\n" + + "exit " + strconv.Itoa(exitCode) + "\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatalf("write stub editor script: %v", err) + } + return script +} + +// ─── /protocol show ──────────────────────────────────────────────────────────── + +func TestProtocolShowEmbeddedDefault(t *testing.T) { + protocolHermeticCwd(t) // empty HOME + empty cwd → no overlay anywhere + withColorDisabled(t) + r, _ := testRegistry() + r.cfg.Protocol.Enabled = true + + out, err := captureProtocolStdout(t, r.protocolShow) + if err != nil { + t.Fatalf("/protocol show returned error: %v", err) + } + if !strings.Contains(out, "embedded default") { + t.Errorf("expected an 'embedded default' source label; got:\n%s", out) + } + if !strings.Contains(out, "Dojo Genius Protocol") { + t.Errorf("expected the embedded doc's heading to be rendered; got:\n%s", out) + } + if !strings.Contains(out, protocol.DefaultDoc()) { + t.Errorf("expected the raw embedded doc body verbatim (color disabled); got:\n%s", out) + } +} + +func TestProtocolShowProjectOverlay(t *testing.T) { + cwd := protocolHermeticCwd(t) + withColorDisabled(t) + const custom = "# My Custom Protocol\n\nDo the thing my way.\n" + protoWriteFile(t, filepath.Join(cwd, "DOJO.md"), custom) + + r, _ := testRegistry() + out, err := captureProtocolStdout(t, r.protocolShow) + if err != nil { + t.Fatalf("/protocol show returned error: %v", err) + } + + // os.Getwd() (called inside protocolShow right after t.Chdir(cwd)) returns + // the same string t.TempDir() produced here — verified empirically; Go's + // Getwd on this platform does not re-resolve the /var vs /private/var + // symlink once the process has already chdir'd into it. + wantPath := filepath.Join(cwd, "DOJO.md") + if !strings.Contains(out, wantPath) { + t.Errorf("expected source line to name %q; got:\n%s", wantPath, out) + } + if !strings.Contains(out, "My Custom Protocol") { + t.Errorf("expected the project overlay's own content, not the embedded default; got:\n%s", out) + } + if strings.Contains(out, "Dojo Genius Protocol") { + t.Errorf("project overlay should shadow the embedded default entirely; got:\n%s", out) + } +} + +func TestProtocolShowDisabledNotesInjection(t *testing.T) { + protocolHermeticCwd(t) + withColorDisabled(t) + r, _ := testRegistry() + r.cfg.Protocol.Enabled = false + + out, err := captureProtocolStdout(t, r.protocolShow) + if err != nil { + t.Fatalf("/protocol show returned error: %v", err) + } + if !strings.Contains(out, "protocol.enabled = false") { + t.Errorf("expected a disabled-protocol note; got:\n%s", out) + } +} + +func TestProtocolShowDispatch(t *testing.T) { + protocolHermeticCwd(t) + withColorDisabled(t) + r, _ := testRegistry() + + if err := r.Dispatch(context.Background(), "protocol show"); err != nil { + t.Fatalf("/protocol show dispatch returned error: %v", err) + } +} + +// ─── protocolResolveEditTarget ───────────────────────────────────────────────── + +func TestProtocolResolveEditTargetProjectOverlayWins(t *testing.T) { + cwd := protocolHermeticCwd(t) + projectPath := filepath.Join(cwd, "DOJO.md") + protoWriteFile(t, projectPath, "# Project override\n") + dojoDir := filepath.Join(t.TempDir(), ".dojo") + + got, err := protocolResolveEditTarget(cwd, dojoDir) + if err != nil { + t.Fatalf("protocolResolveEditTarget returned error: %v", err) + } + if got != projectPath { + t.Errorf("got %q; want project overlay %q", got, projectPath) + } + if _, statErr := os.Stat(filepath.Join(dojoDir, "DOJO.md")); statErr == nil { + t.Error("dojoDir/DOJO.md should not have been created when the project overlay already wins") + } +} + +func TestProtocolResolveEditTargetCreatesDefaultOverlay(t *testing.T) { + cwd := protocolHermeticCwd(t) // no ./DOJO.md in cwd + dojoDir := filepath.Join(t.TempDir(), ".dojo") + + got, err := protocolResolveEditTarget(cwd, dojoDir) + if err != nil { + t.Fatalf("protocolResolveEditTarget returned error: %v", err) + } + want := filepath.Join(dojoDir, "DOJO.md") + if got != want { + t.Errorf("got %q; want %q", got, want) + } + data, readErr := os.ReadFile(want) + if readErr != nil { + t.Fatalf("expected %s to have been created: %v", want, readErr) + } + if string(data) != protocol.DefaultDoc() { + t.Error("newly created overlay should contain the embedded default doc verbatim") + } +} + +func TestProtocolResolveEditTargetPreservesExistingDojoOverlay(t *testing.T) { + cwd := protocolHermeticCwd(t) // no ./DOJO.md in cwd + dojoDir := filepath.Join(t.TempDir(), ".dojo") + dojoOverlay := filepath.Join(dojoDir, "DOJO.md") + const custom = "# Already customized\n" + protoWriteFile(t, dojoOverlay, custom) + + got, err := protocolResolveEditTarget(cwd, dojoDir) + if err != nil { + t.Fatalf("protocolResolveEditTarget returned error: %v", err) + } + if got != dojoOverlay { + t.Errorf("got %q; want %q", got, dojoOverlay) + } + data, readErr := os.ReadFile(dojoOverlay) + if readErr != nil { + t.Fatalf("re-reading %s: %v", dojoOverlay, readErr) + } + if string(data) != custom { + t.Errorf("existing overlay must never be clobbered; got %q, want %q", string(data), custom) + } +} + +// ─── /protocol edit ───────────────────────────────────────────────────────────── + +func TestProtocolEditNoEditorPrintsGuidance(t *testing.T) { + protocolHermeticCwd(t) + t.Setenv("EDITOR", "") + t.Setenv("VISUAL", "") + r, _ := testRegistry() + + out, err := captureProtocolStdout(t, func() error { + return r.protocolEdit(context.Background()) + }) + if err != nil { + t.Fatalf("no $EDITOR/$VISUAL should print guidance, not error: %v", err) + } + if !strings.Contains(out, "set $EDITOR") { + t.Errorf("expected guidance to set $EDITOR; got:\n%s", out) + } + // dojoDir is HOME-derived (os.Getenv passthrough, no symlink resolution), + // so — unlike the cwd/os.Getwd() case in TestProtocolShowProjectOverlay — + // this string is guaranteed to match byte-for-byte with what protocolEdit + // printed. + wantPath := filepath.Join(config.DojoDir(), "DOJO.md") + if !strings.Contains(out, wantPath) { + t.Errorf("expected the resolved overlay path %q in guidance; got:\n%s", wantPath, out) + } + if _, statErr := os.Stat(wantPath); statErr != nil { + t.Errorf("expected the overlay to have been created even without an editor: %v", statErr) + } +} + +func TestProtocolEditDispatchNoEditor(t *testing.T) { + protocolHermeticCwd(t) + t.Setenv("EDITOR", "") + t.Setenv("VISUAL", "") + r, _ := testRegistry() + + if err := r.Dispatch(context.Background(), "protocol edit"); err != nil { + t.Fatalf("/protocol edit dispatch with no editor set should not error: %v", err) + } +} + +func TestProtocolEditLaunchesEditorWithResolvedTarget(t *testing.T) { + protocolHermeticCwd(t) + marker := filepath.Join(t.TempDir(), "editor-args.log") + script := stubEditorScript(t, t.TempDir(), marker, 0) + t.Setenv("EDITOR", script) + t.Setenv("VISUAL", "") + + r, _ := testRegistry() + out, err := captureProtocolStdout(t, func() error { + return r.protocolEdit(context.Background()) + }) + if err != nil { + t.Fatalf("protocolEdit with a working stub editor should not error: %v", err) + } + if !strings.Contains(out, "next session") { + t.Errorf("expected a 'takes effect next session' note; got:\n%s", out) + } + + data, readErr := os.ReadFile(marker) + if readErr != nil { + t.Fatalf("expected the stub editor to have run and logged its args: %v", readErr) + } + gotArgs := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + wantTarget := filepath.Join(config.DojoDir(), "DOJO.md") + if len(gotArgs) != 1 || gotArgs[0] != wantTarget { + t.Errorf("stub editor args = %v; want exactly [%s]", gotArgs, wantTarget) + } +} + +func TestProtocolEditSplitsMultiWordEditor(t *testing.T) { + protocolHermeticCwd(t) + marker := filepath.Join(t.TempDir(), "editor-args.log") + script := stubEditorScript(t, t.TempDir(), marker, 0) + // A common real-world shape ("code --wait") — the binary and its flags + // must be split apart from the target path, not passed as one argv token. + t.Setenv("EDITOR", script+" --wait --flag2") + t.Setenv("VISUAL", "") + + r, _ := testRegistry() + if _, err := captureProtocolStdout(t, func() error { + return r.protocolEdit(context.Background()) + }); err != nil { + t.Fatalf("protocolEdit with a multi-word $EDITOR should not error: %v", err) + } + + data, readErr := os.ReadFile(marker) + if readErr != nil { + t.Fatalf("expected the stub editor to have run: %v", readErr) + } + gotArgs := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + wantTarget := filepath.Join(config.DojoDir(), "DOJO.md") + wantArgs := []string{"--wait", "--flag2", wantTarget} + if len(gotArgs) != len(wantArgs) { + t.Fatalf("stub editor args = %v; want %v", gotArgs, wantArgs) + } + for i := range wantArgs { + if gotArgs[i] != wantArgs[i] { + t.Errorf("arg[%d] = %q; want %q", i, gotArgs[i], wantArgs[i]) + } + } +} + +func TestProtocolEditEditorFailurePropagates(t *testing.T) { + protocolHermeticCwd(t) + marker := filepath.Join(t.TempDir(), "editor-args.log") + script := stubEditorScript(t, t.TempDir(), marker, 7) + t.Setenv("EDITOR", script) + t.Setenv("VISUAL", "") + + r, _ := testRegistry() + _, err := captureProtocolStdout(t, func() error { + return r.protocolEdit(context.Background()) + }) + if err == nil { + t.Fatal("expected protocolEdit to surface a nonzero-exit editor as an error") + } + if !strings.Contains(err.Error(), "launch editor") { + t.Errorf("expected a 'launch editor' error; got: %v", err) + } +} + +func TestProtocolEditDispatchWithStubEditor(t *testing.T) { + protocolHermeticCwd(t) + marker := filepath.Join(t.TempDir(), "editor-args.log") + script := stubEditorScript(t, t.TempDir(), marker, 0) + t.Setenv("EDITOR", script) + t.Setenv("VISUAL", "") + + r, _ := testRegistry() + if err := r.Dispatch(context.Background(), "protocol edit"); err != nil { + t.Fatalf("/protocol edit dispatch with a working stub editor should not error: %v", err) + } + if _, statErr := os.Stat(marker); statErr != nil { + t.Errorf("expected the stub editor to have run via Dispatch: %v", statErr) + } +}