From 6e68b30fe3268c729186868897a8ebbd94dc533d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 5 Sep 2026 12:13:42 +0530 Subject: [PATCH 001/116] fix: point the skill index at the published registry release The index URL referenced GrayCodeAI/starling, a repo renamed to graycode-skills whose registry.json is generated and never committed, so the URL 404d under either name. It now reads the rolling release asset published by graycode-skills' publish-registry.yml. Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k --- internal/plugin/registry.go | 99 +++++++++++++++++++++----- internal/plugin/registry_test.go | 115 +++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 17 deletions(-) diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index ed414504..ebff1fb0 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "net/http" "os" "os/exec" @@ -17,7 +18,72 @@ import ( "github.com/GrayCodeAI/graycode-cli/internal/storage" ) -const defaultIndexURL = "https://raw.githubusercontent.com/GrayCodeAI/starling/main/registry.json" +// defaultIndexURL is the rolling release asset published by +// graycode-skills/.github/workflows/publish-registry.yml. The registry is a +// generated 4.3 MB artifact and is deliberately not committed to that repo, +// so a raw.githubusercontent.com URL cannot work. +const defaultIndexURL = "https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json" + +// maxSkillSearchDepth bounds how deep discoverSkillDirs walks below the repo +// root. graycode-skills nests skills at categories///, which +// is depth 3; anything deeper is almost certainly test data or a vendored +// copy. +const maxSkillSearchDepth = 4 + +// discoverSkillDirs finds every directory under root containing a SKILL.md, +// keyed by the directory name. It replaces the previous two hard-coded +// layouts (// and /skills//) so repositories that +// group skills under a category directory are installable too. +// +// On a duplicate skill name the shallowest path wins, so the result does not +// depend on walk order. +func discoverSkillDirs(root string) (map[string]string, error) { + found := map[string]string{} + depthOf := map[string]int{} + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return nil //nolint:nilerr // an unrelatable path is simply skipped + } + if d.IsDir() { + if path == root { + return nil + } + name := d.Name() + if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" { + return filepath.SkipDir + } + if len(strings.Split(filepath.ToSlash(rel), "/")) > maxSkillSearchDepth { + return filepath.SkipDir + } + return nil + } + if d.Name() != "SKILL.md" { + return nil + } + dir := filepath.Dir(path) + if dir == root { + // A top-level SKILL.md documents the repository, not a skill. + return nil + } + name := filepath.Base(dir) + depth := len(strings.Split(filepath.ToSlash(rel), "/")) + if _, ok := found[name]; ok && depthOf[name] <= depth { + return nil + } + found[name] = dir + depthOf[name] = depth + return nil + }) + if err != nil { + return nil, fmt.Errorf("scan skills: %w", err) + } + return found, nil +} // SkillInvocationPolicy controls which callers may invoke a skill. type SkillInvocationPolicy struct { @@ -244,6 +310,10 @@ func (rc *RegistryClient) Install(repo, skillName, scope string) (string, error) } defer func() { _ = os.RemoveAll(tmpDir) }() + // ponytail: whole-repo shallow clone. Installing one skill from + // graycode-skills clones ~127 MB of categories. Switch to git + // sparse-checkout of the skill's indexed path if install latency + // becomes a complaint. url := "https://github.com/" + repo + ".git" cmd := exec.CommandContext(context.Background(), "git", "clone", "--depth", "1", "--single-branch", url, tmpDir) // #nosec G204 -- url is built from a caller-supplied repo slug prefixed with a fixed GitHub URL, consistent with other install paths in this package if out, cloneErr := cmd.CombinedOutput(); cloneErr != nil { @@ -257,12 +327,16 @@ func (rc *RegistryClient) Install(repo, skillName, scope string) (string, error) commitSha = strings.TrimSpace(string(headOut)) } - // Discover skills in the cloned repo. - skillsRoot := tmpDir - // Check for skills/ subdirectory (agentskills.io convention). - if info, statErr := os.Stat(filepath.Join(tmpDir, "skills")); statErr == nil && info.IsDir() { - skillsRoot = filepath.Join(tmpDir, "skills") + // Discover skills in the cloned repo, whatever layout it uses. + discovered, err := discoverSkillDirs(tmpDir) + if err != nil { + return "", err + } + names := make([]string, 0, len(discovered)) + for name := range discovered { + names = append(names, name) } + sort.Strings(names) installed := []string{} blocked := []string{} @@ -272,20 +346,11 @@ func (rc *RegistryClient) Install(repo, skillName, scope string) (string, error) if lockErr != nil { return "", fmt.Errorf("load skills lock: %w", lockErr) } - entries, err := os.ReadDir(skillsRoot) - if err != nil { - return "", fmt.Errorf("read skills: %w", err) - } - - for _, e := range entries { - if !e.IsDir() { - continue - } - name := e.Name() + for _, name := range names { if skillName != "" && !strings.EqualFold(name, skillName) { continue } - srcSkill := filepath.Join(skillsRoot, name, "SKILL.md") + srcSkill := filepath.Join(discovered[name], "SKILL.md") if _, err := os.Stat(srcSkill); err != nil { continue } diff --git a/internal/plugin/registry_test.go b/internal/plugin/registry_test.go index 471d9944..42ab594d 100644 --- a/internal/plugin/registry_test.go +++ b/internal/plugin/registry_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "sort" "strings" "testing" @@ -286,3 +287,117 @@ func TestFormatSkillInfo(t *testing.T) { t.Error("expected source repo") } } + +func TestDefaultIndexURLIsPublished(t *testing.T) { + const want = "https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json" + if defaultIndexURL != want { + t.Fatalf("defaultIndexURL = %q, want %q", defaultIndexURL, want) + } + if strings.Contains(defaultIndexURL, "starling") { + t.Errorf("defaultIndexURL still references the renamed starling repo") + } +} + +func TestFetchIndexParsesGeneratedShape(t *testing.T) { + // Byte-for-byte the shape graycode-skills/tools/update_registry.py emits. + const generated = `{ + "version": 1, + "skills": [ + { + "name": "ab-test-setup", + "description": "Plan and design an A/B test", + "category": "testing", + "tags": ["testing"], + "path": "categories/testing/ab-test-setup", + "repo": "GrayCodeAI/graycode-skills", + "file_count": 1, + "has_scripts": false + } + ] +} +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(generated)) + })) + defer srv.Close() + + rc := &RegistryClient{IndexURL: srv.URL, CacheDir: t.TempDir(), client: srv.Client()} + idx, err := rc.FetchIndex() + if err != nil { + t.Fatalf("FetchIndex: %v", err) + } + if len(idx.Skills) != 1 { + t.Fatalf("skills = %d, want 1", len(idx.Skills)) + } + if idx.Skills[0].Repo != "GrayCodeAI/graycode-skills" { + t.Errorf("Repo = %q, want the slug the installer clones from", idx.Skills[0].Repo) + } +} + +func TestDiscoverSkillDirs(t *testing.T) { + tests := []struct { + name string + layout []string + want []string + }{ + {name: "flat layout", layout: []string{"go-review/SKILL.md"}, want: []string{"go-review"}}, + {name: "agentskills.io skills/ layout", layout: []string{"skills/go-review/SKILL.md"}, want: []string{"go-review"}}, + { + name: "graycode-skills categories layout", + layout: []string{"categories/go/go-review/SKILL.md", "categories/python/pandas/SKILL.md"}, + want: []string{"go-review", "pandas"}, + }, + { + name: "ignores vendored and dot directories", + layout: []string{"go-review/SKILL.md", ".git/hooks/SKILL.md", "node_modules/pkg/SKILL.md"}, + want: []string{"go-review"}, + }, + { + name: "ignores a top-level SKILL.md documenting the repo", + layout: []string{"SKILL.md", "categories/go/go-review/SKILL.md"}, + want: []string{"go-review"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + for _, rel := range tc.layout { + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("---\nname: x\n---\n"), 0o600); err != nil { + t.Fatal(err) + } + } + + got, err := discoverSkillDirs(root) + if err != nil { + t.Fatalf("discoverSkillDirs: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("found %d skills %v, want %d %v", len(got), keysOf(got), len(tc.want), tc.want) + } + for _, name := range tc.want { + dir, ok := got[name] + if !ok { + t.Errorf("missing skill %q; got %v", name, keysOf(got)) + continue + } + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + t.Errorf("skill %q maps to %q which has no SKILL.md", name, dir) + } + } + }) + } +} + +func keysOf(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} From 74f322c0477cc3ccfe686116558edb362c856a9a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 5 Sep 2026 12:13:42 +0530 Subject: [PATCH 002/116] fix: drop the phantom marketplace source plugins-registry.json is generated by nothing in any of the four GrayCode repos; the only reference anywhere was this default source. FetchAll with no sources returns an empty list rather than erroring. Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k --- internal/plugin/marketplace.go | 15 +++++++-------- internal/plugin/marketplace_test.go | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/internal/plugin/marketplace.go b/internal/plugin/marketplace.go index f7409aeb..122e3ee9 100644 --- a/internal/plugin/marketplace.go +++ b/internal/plugin/marketplace.go @@ -40,15 +40,14 @@ type MarketplaceSource struct { URL string `json:"url"` } -// DefaultMarketplaceSources returns built-in sources. -// Official GrayCodeAI plugin index (may 404 until published — callers handle). +// DefaultMarketplaceSources returns the built-in plugin index sources. +// +// There are none. No repository in the GrayCode ecosystem generates a +// plugins-registry.json, so shipping a built-in source only produced a 404 +// on every `graycode plugin marketplace list`. Users register real sources +// with `graycode plugin marketplace add `. func DefaultMarketplaceSources() []MarketplaceSource { - return []MarketplaceSource{ - { - Name: "official", - URL: "https://raw.githubusercontent.com/GrayCodeAI/starling/main/plugins-registry.json", - }, - } + return nil } // MarketplaceClient fetches plugin marketplace indexes and installs packages. diff --git a/internal/plugin/marketplace_test.go b/internal/plugin/marketplace_test.go index 96781449..3b67d2e2 100644 --- a/internal/plugin/marketplace_test.go +++ b/internal/plugin/marketplace_test.go @@ -68,3 +68,22 @@ func TestMarketplaceInstallRejectsSCPStyleURL(t *testing.T) { t.Errorf("error should mention scp-style, got: %v", err) } } + +func TestNoPhantomDefaultMarketplaceSource(t *testing.T) { + for _, src := range DefaultMarketplaceSources() { + if strings.Contains(src.URL, "plugins-registry.json") { + t.Fatalf("default source %q points at plugins-registry.json, which nothing generates", src.Name) + } + } +} + +func TestFetchAllWithNoSourcesReturnsEmptyNotError(t *testing.T) { + mc := &MarketplaceClient{Sources: nil, CacheDir: t.TempDir()} + entries, err := mc.FetchAll() + if err != nil { + t.Fatalf("FetchAll with no sources returned error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("entries = %d, want 0", len(entries)) + } +} From 9c44a5f57a4f6ae3520b5412d84473242b9cbf07 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 5 Sep 2026 12:17:56 +0530 Subject: [PATCH 003/116] docs: state that graycode cloud requires an explicit endpoint The wire contract's servers.url is the browser BFF, which rejects a device token; the worker itself has no route or custom domain. Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index b15fd0b3..56103064 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,11 @@ graycode cloud graph sync graycode cloud graph sync --mission-dir /path/to/mission ``` +Cloud commands require an endpoint. There is no default: pass `--endpoint` or +set `GRAYCODE_CLOUD_URL` to your Graycode Cloud worker URL before running +`graycode cloud login`. `https://api.graycodeai.com` is the browser BFF and +will reject a device token. + The export contains metadata and hashes, not prompts, tool arguments/results, policy reasons, verification evidence, or runtime output. Swift remains available separately as `graycode swift graph export`. Persisted chat sessions From c9fc666899e4333171988a45fadb4c3f976c0a55 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 5 Sep 2026 12:18:37 +0530 Subject: [PATCH 004/116] docs: correct the router boundary guard comment Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k --- scripts/check-graycode-router-engine-boundary.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/check-graycode-router-engine-boundary.sh b/scripts/check-graycode-router-engine-boundary.sh index 9ff97843..402dbeb1 100755 --- a/scripts/check-graycode-router-engine-boundary.sh +++ b/scripts/check-graycode-router-engine-boundary.sh @@ -15,8 +15,9 @@ else '"github\.com/GrayCodeAI/graycode-router/[^\"]+"' . || true )" fi -# Graycode uses the full vendored GraycodeRouter API surface for provider, graph, and -# tooling contracts that the engine facade does not re-export. +# Host contract surface is exactly four packages: engine (facade), llm (DTOs +# and the Provider port), graph (portable graph vocabulary), tools (tool-call +# contracts). See graycode-router/README.md "Ecosystem Boundaries". violations="$(printf '%s\n' "$graycoderouter_imports" | grep -vE '"github\.com/GrayCodeAI/graycode-router/(engine|llm|graph|tools)(/|\")' || true)" if [[ -n "$violations" ]]; then From f5022a109f4b9f3e0c8adb158e09cead86371beb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 5 Sep 2026 12:19:54 +0530 Subject: [PATCH 005/116] fix: repair two boundary guards that could not fail check-support-repo-coupling.sh built its regex from an empty peer list, degenerating to a pattern matching nothing. The engine-boundary script ran in CI but not on pre-push. Both AST tests carried a gateway->credentials exception that no production file uses. Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k --- internal/testaudit/audit_test.go | 8 -------- internal/testaudit/package_boundaries_test.go | 8 -------- lefthook.yml | 3 +++ scripts/check-support-repo-coupling.sh | 8 ++++++++ 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index 09e8f4b3..b8af22b7 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -216,14 +216,6 @@ func TestNoDirectLowerGraycodeRouterImports(t *testing.T) { "github.com/GrayCodeAI/graycode-router/tools": continue } - // The gateway package is Graycode's single GraycodeRouter boundary; it may - // import graycode-router/credentials to declare Graycode's OS keychain service - // name (the host-neutral default would otherwise orphan existing - // secrets). All other production code must use graycode-router/engine only. - if strings.HasPrefix(rel, "internal/provider/gateway/") && - path == "github.com/GrayCodeAI/graycode-router/credentials" { - continue - } pos := pf.FSet.Position(imp.Pos()) t.Fatalf("forbidden lower-level GraycodeRouter import %q at %s:%d; use github.com/GrayCodeAI/graycode-router/engine", path, rel, pos.Line) } diff --git a/internal/testaudit/package_boundaries_test.go b/internal/testaudit/package_boundaries_test.go index 6ff57391..2ed9026d 100644 --- a/internal/testaudit/package_boundaries_test.go +++ b/internal/testaudit/package_boundaries_test.go @@ -55,14 +55,6 @@ func checkGraycodeGraycodeRouterFacade(t *testing.T, root string) { case graycodeRouterModule + "/llm", graycodeRouterModule + "/graph", graycodeRouterModule + "/tools": continue } - // Graycode's gateway declares the credential service name so existing - // keychain entries remain compatible. It is the only non-engine - // production exception. - relFile, relErr := filepath.Rel(root, imp.file) - if relErr == nil && filepath.ToSlash(filepath.Dir(relFile)) == "internal/provider/gateway" && - imp.path == graycodeRouterModule+"/credentials" { - continue - } violations = append(violations, formatImportViolation(root, imp, "use the graycode-router/engine facade")) } } diff --git a/lefthook.yml b/lefthook.yml index e95fd7fc..d08eebaf 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -118,6 +118,9 @@ pre-push: boundary-graycode-router-client: run: bash scripts/check-graycode-router-client-imports.sh + boundary-graycode-router-engine: + run: bash scripts/check-graycode-router-engine-boundary.sh + boundary-support-repo: run: bash scripts/check-support-repo-coupling.sh diff --git a/scripts/check-support-repo-coupling.sh b/scripts/check-support-repo-coupling.sh index 55be27e7..8a802eed 100644 --- a/scripts/check-support-repo-coupling.sh +++ b/scripts/check-support-repo-coupling.sh @@ -27,6 +27,14 @@ scan_dir() { fi done + if [[ ${#peers[@]} -eq 0 ]]; then + # No sibling engines to check against. Building a regex from an empty + # peer list produced 'github\.com/GrayCodeAI/()(/|")', which matches + # nothing meaningful and made this guard silently unfailable. + echo "peer guard: ${owner} has no sibling engines to check" + return + fi + pattern="$(IFS='|'; echo "${peers[*]}")" hits="$( grep -RInE --include='*.go' "github\\.com/GrayCodeAI/(${pattern})(/|\")" "${dir}" || true From 7926c4ba4610af23d942d2b85cb93cfdf48852e1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 5 Sep 2026 12:29:32 +0530 Subject: [PATCH 006/116] docs: align the ecosystem section with ecosystem.yaml Also replaces GrayCodeAI/starling test fixtures with the repo's current name, and fixes an 'execution swift' typo left by an earlier substring rename in the generated GitNexus tables. Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k --- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 8 ++++++++ internal/plugin/registry_test.go | 14 +++++++------- internal/plugin/skillslock_test.go | 2 +- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8daafae7..642f1f94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,7 +228,7 @@ This project is indexed by GitNexus as **graycode** (97743 symbols, 322940 relat | `gitnexus://repo/graycode/context` | Codebase overview, check index freshness | | `gitnexus://repo/graycode/clusters` | All functional areas | | `gitnexus://repo/graycode/processes` | All execution flows | -| `gitnexus://repo/graycode/process/{name}` | Step-by-step execution swift | +| `gitnexus://repo/graycode/process/{name}` | Step-by-step execution trace | ## CLI diff --git a/CLAUDE.md b/CLAUDE.md index 40345896..c3396ac3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ This project is indexed by GitNexus as **graycode** (97743 symbols, 322940 relat | `gitnexus://repo/graycode/context` | Codebase overview, check index freshness | | `gitnexus://repo/graycode/clusters` | All functional areas | | `gitnexus://repo/graycode/processes` | All execution flows | -| `gitnexus://repo/graycode/process/{name}` | Step-by-step execution swift | +| `gitnexus://repo/graycode/process/{name}` | Step-by-step execution trace | ## CLI diff --git a/README.md b/README.md index 56103064..52d489b6 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,14 @@ You may keep a **personal** parent **`go.work`** that lists alternate clones on |---|---|---| | **graycode** | This repo | AI coding agent | | **graycode-router** | [GrayCodeAI/graycode-router](https://github.com/GrayCodeAI/graycode-router) | LLM provider runtime | +| **graycode-skills** | [GrayCodeAI/graycode-skills](https://github.com/GrayCodeAI/graycode-skills) | Community skill registry | +| **graycode-platform** | [GrayCodeAI/graycode-platform](https://github.com/GrayCodeAI/graycode-platform) | Web, BFF, and Graycode Cloud | + +`ecosystem.yaml` is the canonical inventory of repositories cloned as +siblings in this local workspace; tooling reads it rather than carrying its +own repo-name list. `harrier`, `shrike`, `swift`, `kestrel`, `merlin` and +`falcon` above are consumed as pinned `go.mod` module dependencies rather +than local workspace clones, so they are not listed there. For the consolidated repo map and the current-vs-proposed architecture diagrams, see [docs/architecture/graycode-current-vs-proposed.md](docs/architecture/graycode-current-vs-proposed.md). For execution-graph ownership, automatic capture seams, export/sync commands, diff --git a/internal/plugin/registry_test.go b/internal/plugin/registry_test.go index 42ab594d..df2bdd4b 100644 --- a/internal/plugin/registry_test.go +++ b/internal/plugin/registry_test.go @@ -18,8 +18,8 @@ func testIndex() SkillIndex { Version: 1, UpdatedAt: "2026-05-01T00:00:00Z", Skills: []SkillEntry{ - {Name: "api-review", Description: "Reviews API endpoints", Author: "graycode", Repo: "GrayCodeAI/starling", Category: "engineering", Tags: []string{"api", "review"}, Version: "1.0.0", Installs: 342}, - {Name: "security-scan", Description: "Scans for security vulnerabilities", Author: "graycode", Repo: "GrayCodeAI/starling", Category: "security", Tags: []string{"security", "scan"}, Version: "2.1.0", Installs: 891}, + {Name: "api-review", Description: "Reviews API endpoints", Author: "graycode", Repo: "GrayCodeAI/graycode-skills", Category: "engineering", Tags: []string{"api", "review"}, Version: "1.0.0", Installs: 342}, + {Name: "security-scan", Description: "Scans for security vulnerabilities", Author: "graycode", Repo: "GrayCodeAI/graycode-skills", Category: "security", Tags: []string{"security", "scan"}, Version: "2.1.0", Installs: 891}, {Name: "changelog", Description: "Generates changelogs from git commits", Author: "community", Repo: "community/skills", Category: "workflow", Tags: []string{"changelog", "git"}, Version: "1.2.0", Installs: 156}, }, } @@ -165,7 +165,7 @@ license: MIT category: engineering tags: ["api", "review", "rest"] agents: ["graycode", "claude-code"] -source-repo: GrayCodeAI/starling +source-repo: GrayCodeAI/graycode-skills source-ref: v1.2.0 source-installed-at: 2026-05-01T00:00:00Z --- @@ -190,7 +190,7 @@ Review all API endpoints. if len(skill.Agents) != 2 { t.Errorf("expected 2 agents, got %d", len(skill.Agents)) } - if skill.Source.Repo != "GrayCodeAI/starling" { + if skill.Source.Repo != "GrayCodeAI/graycode-skills" { t.Errorf("source repo: got %q", skill.Source.Repo) } if skill.Source.Ref != "v1.2.0" { @@ -248,7 +248,7 @@ func TestFormatSkillEntry(t *testing.T) { Version: "1.0.0", Author: "graycode", Description: "Reviews API endpoints", - Repo: "GrayCodeAI/starling", + Repo: "GrayCodeAI/graycode-skills", Installs: 342, } out := FormatSkillEntry(e) @@ -274,7 +274,7 @@ func TestFormatSkillInfo(t *testing.T) { License: "MIT", Category: "engineering", Tags: []string{"api", "review"}, - Source: SkillSource{Repo: "GrayCodeAI/starling", Ref: "v1.0.0"}, + Source: SkillSource{Repo: "GrayCodeAI/graycode-skills", Ref: "v1.0.0"}, } out := FormatSkillInfo(s, "/path/to/skill") if !strings.Contains(out, "Skill: api-review") { @@ -283,7 +283,7 @@ func TestFormatSkillInfo(t *testing.T) { if !strings.Contains(out, "MIT") { t.Error("expected license") } - if !strings.Contains(out, "GrayCodeAI/starling") { + if !strings.Contains(out, "GrayCodeAI/graycode-skills") { t.Error("expected source repo") } } diff --git a/internal/plugin/skillslock_test.go b/internal/plugin/skillslock_test.go index 390ba148..588725c7 100644 --- a/internal/plugin/skillslock_test.go +++ b/internal/plugin/skillslock_test.go @@ -17,7 +17,7 @@ func TestSkillsLockRoundTrip(t *testing.T) { } lock.Set("go-review", SkillsLockEntry{ - Source: "GrayCodeAI/starling", + Source: "GrayCodeAI/graycode-skills", SourceType: "github", SkillPath: "skills/go-review/SKILL.md", Commit: "abc123", From f8621fd5008b646f7358bd419676c7d4ce176f50 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 21:42:26 +0530 Subject: [PATCH 007/116] fix: resolve code-review findings across engine, memory, tool, daemon - eval list --json: project BenchmarkTask (func fields) to a JSON-safe struct - stream: use a single RawMessages() snapshot (fixes len()+copy reallocation race) - crash_unix: drop unreachable nil-return branch from raiseSignal - tool: replace deprecated strings.Title with x/text cases.Title - daemon: replace no-arg fmt.Sprintf with string literals - fuzzyfind: replace deprecated filepath.HasPrefix with strings.HasPrefix - memory: thread context.Context through MemoryRecaller.Remember so background remember goroutines honor their timeout (fixes goroutine leak from discarded rCtx) - contracts/types: remove deprecated fail-open ParseSeverity (zero callers) --- cmd/eval.go | 23 ++++++++++++- internal/contracts/types/severity.go | 17 ++-------- internal/crash/crash_unix.go | 20 +++++------ internal/daemon/routes_metrics.go | 6 ++-- internal/engine/lifecycle/sleeptime_ops.go | 3 +- internal/engine/memory_service.go | 7 ++-- .../memory_service_test_helpers_test.go | 4 ++- internal/engine/session.go | 11 ++++--- internal/engine/stream.go | 33 +++++++++++-------- internal/fuzzyfind/fuzzyfind_test.go | 2 +- internal/intelligence/memory/auto_capture.go | 16 ++++----- .../intelligence/memory/enhanced_manager.go | 6 ++-- .../intelligence/memory/harrier_bridge.go | 8 +++-- .../memory/harrier_bridge_integration_test.go | 7 ++-- internal/intelligence/memory/manager.go | 7 ++-- internal/intelligence/memory/manager_test.go | 3 +- internal/intelligence/memory/session_diff.go | 6 ++-- internal/tool/core_memory.go | 4 +-- internal/tool/spec_clarify.go | 9 ++++- internal/tool/spec_ground.go | 2 +- internal/tool/spec_testgen.go | 2 +- internal/types/severity.go | 4 +-- 22 files changed, 116 insertions(+), 84 deletions(-) diff --git a/cmd/eval.go b/cmd/eval.go index 61d0bde7..430ff69c 100644 --- a/cmd/eval.go +++ b/cmd/eval.go @@ -287,7 +287,28 @@ func runEvalList(_ *cobra.Command, _ []string) error { } if evalListJSON { - out, err := json.MarshalIndent(tasks, "", " ") + // BenchmarkTask carries func fields (SetupFn/ValidateFn) that + // encoding/json cannot marshal; project to the display-safe fields. + type jsonTask struct { + ID string `json:"id"` + Description string `json:"description"` + Prompt string `json:"prompt"` + TimeLimit float64 `json:"time_limit_seconds"` + Tags []string `json:"tags"` + MaxAttempts int `json:"max_attempts"` + } + view := make([]jsonTask, len(tasks)) + for i, t := range tasks { + view[i] = jsonTask{ + ID: t.ID, + Description: t.Description, + Prompt: t.Prompt, + TimeLimit: t.TimeLimit.Seconds(), + Tags: t.Tags, + MaxAttempts: t.MaxAttempts, + } + } + out, err := json.MarshalIndent(view, "", " ") if err != nil { return fmt.Errorf("marshaling tasks: %w", err) } diff --git a/internal/contracts/types/severity.go b/internal/contracts/types/severity.go index 5eef2182..32654bd0 100644 --- a/internal/contracts/types/severity.go +++ b/internal/contracts/types/severity.go @@ -27,22 +27,11 @@ func (s Severity) String() string { return "unknown" } -// ParseSeverity converts a string to a Severity. -// -// Deprecated: ParseSeverity fails open — unknown input (typos such as -// "critcal", empty strings, arbitrary text) silently maps to SeverityInfo, -// so a malformed value is indistinguishable from a legitimate "info". -// Callers handling untrusted input should use ParseSeverityStrict, which -// reports unknown values as errors instead. -func ParseSeverity(s string) Severity { - sev, _ := ParseSeverityStrict(s) - return sev -} - // ParseSeverityStrict converts a string to a Severity, reporting unknown // values as errors instead of failing open to SeverityInfo. Matching is -// case-insensitive and ignores surrounding whitespace, exactly like -// ParseSeverity; the two accept the same set of valid names. +// case-insensitive and ignores surrounding whitespace; it accepts the same +// set of valid names as the removed fail-open ParseSeverity (which silently +// mapped unknown input to SeverityInfo and was deleted as a footgun). func ParseSeverityStrict(s string) (Severity, error) { switch strings.ToLower(strings.TrimSpace(s)) { case "critical": diff --git a/internal/crash/crash_unix.go b/internal/crash/crash_unix.go index d38b5a1b..d7f96285 100644 --- a/internal/crash/crash_unix.go +++ b/internal/crash/crash_unix.go @@ -4,7 +4,6 @@ package crash import ( - "errors" "fmt" "os" "os/signal" @@ -41,9 +40,7 @@ func installDumpHandler(sig syscall.Signal) { writeSignalReport(sig) signal.Reset(sig) // Restore default disposition and re-raise. - if err := raiseSignal(sig); err != nil { - fmt.Fprintf(os.Stderr, "crash: failed to re-raise %s: %v\n", sig, err) - } + raiseSignal(sig) }() } @@ -66,12 +63,15 @@ func writeSignalReport(sig syscall.Signal) { _, _ = WriteReport(nil, []byte(fmt.Sprintf("signal dump %s — see crash-signal-*.txt", sig))) } -func raiseSignal(sig syscall.Signal) error { +// raiseSignal re-raises sig with the default disposition so the OS produces +// normal termination. It either terminates the process or, if a handler +// swallows the signal, logs the failure and returns (never returns nil — this +// is a diagnostic safety net, so the log is unconditional on reaching here). +func raiseSignal(sig syscall.Signal) { if err := syscall.Kill(os.Getpid(), sig); err != nil { - return fmt.Errorf("kill self: %w", err) + fmt.Fprintf(os.Stderr, "crash: failed to re-raise %s: %v\n", sig, err) + return } - // If kill returns, momentarily restore the default and re-raise. We reach - // here only if a handler caught it above; the re-raise above should have - // terminated. This is a safety net. - return errors.New("re-raise returned without terminating") + // If kill returns, a handler caught it; log that termination did not occur. + fmt.Fprintf(os.Stderr, "crash: re-raise of %s returned without terminating\n", sig) } diff --git a/internal/daemon/routes_metrics.go b/internal/daemon/routes_metrics.go index 7e0d45df..fb10775f 100644 --- a/internal/daemon/routes_metrics.go +++ b/internal/daemon/routes_metrics.go @@ -76,15 +76,15 @@ func (s *Server) emitRuntimeMetrics(sb *strings.Builder) { return true }) - sb.WriteString(fmt.Sprintf("# TYPE graycode_daemon_active_sessions gauge\n")) + sb.WriteString("# TYPE graycode_daemon_active_sessions gauge\n") sb.WriteString(fmt.Sprintf("graycode_daemon_active_sessions %d\n", activeSessions)) // Concurrency slots used - sb.WriteString(fmt.Sprintf("# TYPE graycode_daemon_chat_concurrency_used gauge\n")) + sb.WriteString("# TYPE graycode_daemon_chat_concurrency_used gauge\n") sb.WriteString(fmt.Sprintf("graycode_daemon_chat_concurrency_used %d\n", len(s.concurrencySem))) // Uptime - sb.WriteString(fmt.Sprintf("# TYPE graycode_daemon_uptime_seconds gauge\n")) + sb.WriteString("# TYPE graycode_daemon_uptime_seconds gauge\n") sb.WriteString(fmt.Sprintf("graycode_daemon_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())) } diff --git a/internal/engine/lifecycle/sleeptime_ops.go b/internal/engine/lifecycle/sleeptime_ops.go index aed60906..02ef80c9 100644 --- a/internal/engine/lifecycle/sleeptime_ops.go +++ b/internal/engine/lifecycle/sleeptime_ops.go @@ -1,6 +1,7 @@ package lifecycle import ( + "context" "encoding/json" "errors" "fmt" @@ -41,7 +42,7 @@ func ParseAndApplyMemoryOps(bridge *memory.HarrierBridge, response string) error } switch op.Op { case "add": - if err := bridge.Remember(op.Content, op.Type); err != nil { + if err := bridge.Remember(context.Background(), op.Content, op.Type); err != nil { errs = append(errs, fmt.Errorf("memory ops: remember: %w", err)) } } diff --git a/internal/engine/memory_service.go b/internal/engine/memory_service.go index bf311b51..632769fe 100644 --- a/internal/engine/memory_service.go +++ b/internal/engine/memory_service.go @@ -112,13 +112,12 @@ func (s *MemoryService) RecallContext(_ context.Context, lastUserMsg string, bud // shouldn't fail a turn just because harrier is unavailable). func (s *MemoryService) Remember(ctx context.Context, content, category string) { if s.enhanced != nil { - _ = s.enhanced.Remember(content, category) + _ = s.enhanced.Remember(ctx, content, category) return } if s.memory != nil { - _ = s.memory.Remember(content, category) + _ = s.memory.Remember(ctx, content, category) } - _ = ctx // reserved for future context-aware memory ops } // OnSessionEnd runs the post-session memory bookkeeping. @@ -152,7 +151,7 @@ func (s *MemoryService) Finalize(messages []types.GraycodeRouterMessage, success if !success { summary += " (interrupted)" } - _ = s.memory.Remember(summary, "session") + _ = s.memory.Remember(context.Background(), summary, "session") } } diff --git a/internal/engine/memory_service_test_helpers_test.go b/internal/engine/memory_service_test_helpers_test.go index 8b8be53f..bcd23d21 100644 --- a/internal/engine/memory_service_test_helpers_test.go +++ b/internal/engine/memory_service_test_helpers_test.go @@ -1,5 +1,7 @@ package engine +import "context" + // mockMemoryRecaller is the minimal in-memory backend used by memory-service // tests. It intentionally lives beside those tests rather than in the removed // SessionServices compatibility test. @@ -9,7 +11,7 @@ func (m *mockMemoryRecaller) Recall(query string, tokenBudget int) (string, erro return "recalled: " + query, nil } -func (m *mockMemoryRecaller) Remember(content, category string) error { +func (m *mockMemoryRecaller) Remember(ctx context.Context, content, category string) error { return nil } diff --git a/internal/engine/session.go b/internal/engine/session.go index a2b81049..39e71449 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -32,7 +32,10 @@ import ( // MemoryRecaller abstracts memory recall/remember so engine avoids importing memory directly. type MemoryRecaller interface { Recall(query string, tokenBudget int) (string, error) - Remember(content, category string) error + // Remember persists a content+category pair. The ctx lets background + // callers bound the call so a slow/hung memory backend cannot leak a + // goroutine (the HarrierBridge path honors it for network cancellation). + Remember(ctx context.Context, content, category string) error } // SnapshotTracker abstracts the snapshot system so engine doesn't import snapshot directly. @@ -623,11 +626,11 @@ func (s *Session) AddUser(content string) { if memSvc := s.MemorySvc(); memSvc != nil { if mem := memSvc.Memory(); mem != nil && strings.Contains(strings.ToLower(content), "remember") { go func(c string) { - // Use timeout context so goroutine doesn't hang if backend is slow. + // Bound the call so a slow/hung memory backend cannot leak + // this goroutine; the ctx now propagates to the backend. rCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _ = rCtx // timeout context available if Remember is extended to accept it - if err := mem.Remember(c, "user_explicit"); err != nil { + if err := mem.Remember(rCtx, c, "user_explicit"); err != nil { slog.Warn("background memory remember failed", "error", err) } }(content) diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 51bcf1be..43546d93 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -792,13 +792,12 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { if textContent.Len() > 0 { s.Persistence().AppendAssistantJournaled(types.GraycodeRouterMessage{Role: "assistant", Content: textContent.String()}) // Auto-remember corrections and learnings. Best-effort - // fire-and-forget: the memory backend's Remember does not yet - // accept a context, so this goroutine cannot be cancelled mid-call. - // MemoryService.Remember(ctx, ...) reserves ctx for exactly this - // extension when the backend becomes context-aware. + // fire-and-forget, bounded so a hung backend cannot leak. if s.MemorySvc().Memory() != nil && shouldRemember(textContent.String()) { go func(content string) { - if err := s.MemorySvc().Memory().Remember(content, "assistant_learning"); err != nil { + rCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := s.MemorySvc().Memory().Remember(rCtx, content, "assistant_learning"); err != nil { slog.Warn("background assistant_learning remember failed", "error", err) } }(textContent.String()) @@ -806,9 +805,11 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } // Sleeptime: background memory consolidation if s.MemorySvc().Sleeptime() != nil && s.MemorySvc().Sleeptime().ShouldRun() && s.MemorySvc().Harrier() != nil && s.MemorySvc().Harrier().Ready() { - // Snapshot messages to avoid data race with main loop appending - msgs := make([]types.GraycodeRouterMessage, len(s.Persistence().RawMessages())) - copy(msgs, s.Persistence().RawMessages()) + // Snapshot messages to avoid data race with main loop appending. + // RawMessages already returns a deep clone, so a single call + // yields a stable snapshot (the prior len()+copy double-call + // raced on reallocation between the two reads). + msgs := s.Persistence().RawMessages() go func() { var transcript []string for _, m := range msgs { @@ -835,9 +836,11 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } // Skill distillation: extract reusable skill from multi-turn tasks if s.MemorySvc().SkillDistiller() != nil && toolTurns >= 5 && s.MemorySvc().Harrier() != nil && s.MemorySvc().Harrier().Ready() { - // Snapshot messages to avoid data race with main loop appending - msgs := make([]types.GraycodeRouterMessage, len(s.Persistence().RawMessages())) - copy(msgs, s.Persistence().RawMessages()) + // Snapshot messages to avoid data race with main loop appending. + // RawMessages already returns a deep clone, so a single call + // yields a stable snapshot (the prior len()+copy double-call + // raced on reallocation between the two reads). + msgs := s.Persistence().RawMessages() // Snapshot the tool/file sets too, so the goroutine never // reads the live maps while the main loop writes them on a // later tool turn. @@ -872,7 +875,9 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { return } content, _ := json.Marshal(skill) - if err := s.MemorySvc().Harrier().Remember(string(content), "skill"); err != nil { + rCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := s.MemorySvc().Harrier().Remember(rCtx, string(content), "skill"); err != nil { slog.Warn("background skill remember failed", "error", err) } }() @@ -1111,13 +1116,13 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } if userMsg != "" && assistantMsg != "" { condensed := fmt.Sprintf("Q: %s\nA: %s", truncate(userMsg, 200), truncate(assistantMsg, 300)) - if err := s.MemorySvc().Memory().Remember(condensed, "conversation"); err != nil { + if err := s.MemorySvc().Memory().Remember(ctx, condensed, "conversation"); err != nil { slog.Warn("conversation remember failed", "error", err) } } // Also save insights if the response has learning signals if assistantMsg != "" && shouldRemember(assistantMsg) { - if err := s.MemorySvc().Memory().Remember(truncate(assistantMsg, 500), "insight"); err != nil { + if err := s.MemorySvc().Memory().Remember(ctx, truncate(assistantMsg, 500), "insight"); err != nil { slog.Warn("insight remember failed", "error", err) } } diff --git a/internal/fuzzyfind/fuzzyfind_test.go b/internal/fuzzyfind/fuzzyfind_test.go index 7617a746..f951ba7f 100644 --- a/internal/fuzzyfind/fuzzyfind_test.go +++ b/internal/fuzzyfind/fuzzyfind_test.go @@ -67,7 +67,7 @@ func TestSkipDirsExcluded(t *testing.T) { f, _ := New(root) matches := f.Search("lib", 20) for _, m := range matches { - if filepath.HasPrefix(m.Path, "vendor") { + if strings.HasPrefix(m.Path, "vendor") { t.Fatal("vendor leaked into results") } } diff --git a/internal/intelligence/memory/auto_capture.go b/internal/intelligence/memory/auto_capture.go index 1dc18eec..67712da9 100644 --- a/internal/intelligence/memory/auto_capture.go +++ b/internal/intelligence/memory/auto_capture.go @@ -138,7 +138,7 @@ func (ac *AutoCapture) processFileWrite(job captureJob) { if !ok || path == "" { return } - _ = ac.bridge.Remember( + _ = ac.bridge.Remember(context.Background(), fmt.Sprintf("File modified: %s", path), "file", ) @@ -155,7 +155,7 @@ func (ac *AutoCapture) processBash(job captureJob) { if isTestCommand(cmd) { if job.isErr || containsTestFailure(job.output) { snippet := truncate(job.output, 300) - _ = ac.bridge.Remember( + _ = ac.bridge.Remember(context.Background(), fmt.Sprintf("Test failure: `%s` → %s", truncate(cmd, 100), snippet), "bug", ) @@ -168,7 +168,7 @@ func (ac *AutoCapture) processBash(job captureJob) { if isGitCommit(cmd) && !job.isErr { msg := extractCommitMessage(cmd) if msg != "" { - _ = ac.bridge.Remember( + _ = ac.bridge.Remember(context.Background(), fmt.Sprintf("Commit: %s", msg), "decision", ) @@ -181,7 +181,7 @@ func (ac *AutoCapture) processBash(job captureJob) { if isPackageInstall(cmd) { pkg := extractPackageName(cmd) if pkg != "" { - _ = ac.bridge.Remember( + _ = ac.bridge.Remember(context.Background(), fmt.Sprintf("Dependency added: %s", pkg), "decision", ) @@ -192,7 +192,7 @@ func (ac *AutoCapture) processBash(job captureJob) { // Detect build/deploy commands as conventions if isBuildCommand(cmd) && !job.isErr { - _ = ac.bridge.Remember( + _ = ac.bridge.Remember(context.Background(), fmt.Sprintf("Build command: `%s`", truncate(cmd, 200)), "convention", ) @@ -208,7 +208,7 @@ func (ac *AutoCapture) processRead(job captureJob) { } // Only track significant reads (file structure discovery) if len(job.output) > 500 && isStructuralFile(path) { - _ = ac.bridge.Remember( + _ = ac.bridge.Remember(context.Background(), fmt.Sprintf("Project file: %s", path), "file", ) @@ -223,7 +223,7 @@ func (ac *AutoCapture) processError(job captureJob) { // Extract error patterns that are likely bugs if containsErrorPattern(job.output) { snippet := truncate(job.output, 300) - _ = ac.bridge.Remember( + _ = ac.bridge.Remember(context.Background(), fmt.Sprintf("Error in %s: %s", job.toolName, snippet), "bug", ) @@ -348,7 +348,7 @@ func (ac *AutoCapture) ExtractFromAssistantResponse(ctx context.Context, text st } conventions := ExtractConventions(text) for _, c := range conventions { - _ = ac.bridge.Remember(c, "convention") + _ = ac.bridge.Remember(ctx, c, "convention") ac.metrics.inc("convention") } } diff --git a/internal/intelligence/memory/enhanced_manager.go b/internal/intelligence/memory/enhanced_manager.go index 82aaf024..5f6a3908 100644 --- a/internal/intelligence/memory/enhanced_manager.go +++ b/internal/intelligence/memory/enhanced_manager.go @@ -161,9 +161,9 @@ func (em *EnhancedMemoryManager) Recall(query string, tokenBudget int) (string, } // Remember stores memory and routes through auto-capture pipeline. -// Implements engine.MemoryRecaller interface. -func (em *EnhancedMemoryManager) Remember(content, category string) error { - err := em.MemoryManager.Remember(content, category) +// Implements engine.MemoryRecaller interface. The ctx bounds the harrier path. +func (em *EnhancedMemoryManager) Remember(ctx context.Context, content, category string) error { + err := em.MemoryManager.Remember(ctx, content, category) if err != nil { return err } diff --git a/internal/intelligence/memory/harrier_bridge.go b/internal/intelligence/memory/harrier_bridge.go index 964992f9..b50a4edc 100644 --- a/internal/intelligence/memory/harrier_bridge.go +++ b/internal/intelligence/memory/harrier_bridge.go @@ -193,9 +193,11 @@ func (b *HarrierBridge) notReadyError(op string) error { // Remember stores content into harrier's memory graph under the given category. // Category maps to harrier's node type (e.g., "convention", "decision", "bug", "preference"). -// Returns a BridgeError if harrier is not initialized. -func (b *HarrierBridge) Remember(content, category string) error { - return b.RememberWithContext(context.Background(), content, category) +// Returns a BridgeError if harrier is not initialized. Implements +// engine.MemoryRecaller; the ctx bounds the harrier network call so a hung +// backend cannot leak a caller's goroutine. +func (b *HarrierBridge) Remember(ctx context.Context, content, category string) error { + return b.RememberWithContext(ctx, content, category) } // RememberWithContext is the context-aware version of Remember. diff --git a/internal/intelligence/memory/harrier_bridge_integration_test.go b/internal/intelligence/memory/harrier_bridge_integration_test.go index 1e18d933..44b90bf5 100644 --- a/internal/intelligence/memory/harrier_bridge_integration_test.go +++ b/internal/intelligence/memory/harrier_bridge_integration_test.go @@ -1,6 +1,7 @@ package memory import ( + "context" "encoding/json" "os" "strings" @@ -40,7 +41,7 @@ func TestHarrierBridge_Remember(t *testing.T) { // FIXME: harrier dependency must be available to test remember functionality t.Skip("harrier not available") } - err := b.Remember("test content to remember", "explicit") + err := b.Remember(context.Background(), "test content to remember", "explicit") if err != nil { t.Fatalf("Remember: %v", err) } @@ -54,7 +55,7 @@ func TestHarrierBridge_Recall(t *testing.T) { // FIXME: harrier dependency must be available to test recall functionality t.Skip("harrier not available") } - _ = b.Remember("golang error handling patterns", "convention") + _ = b.Remember(context.Background(), "golang error handling patterns", "convention") result, err := b.Recall("error handling", 500) if err != nil { @@ -73,7 +74,7 @@ func TestHarrierBridgeRecallRecordsPortableContextGraph(t *testing.T) { } defer b.Close() - if err := b.Remember("private graph context about error handling", "decision"); err != nil { + if err := b.Remember(context.Background(), "private graph context about error handling", "decision"); err != nil { t.Fatalf("Remember() error = %v", err) } b.ConfigureGraphObservation( diff --git a/internal/intelligence/memory/manager.go b/internal/intelligence/memory/manager.go index 7cf8112a..b0f72218 100644 --- a/internal/intelligence/memory/manager.go +++ b/internal/intelligence/memory/manager.go @@ -1,6 +1,7 @@ package memory import ( + "context" "strings" ) @@ -97,8 +98,8 @@ func (mm *MemoryManager) Recall(query string, tokenBudget int) (string, error) { } // Remember routes content to the appropriate subsystem based on category. -// Implements engine.MemoryRecaller. -func (mm *MemoryManager) Remember(content, category string) error { +// Implements engine.MemoryRecaller. The ctx bounds the harrier network path. +func (mm *MemoryManager) Remember(ctx context.Context, content, category string) error { switch category { case "guideline", "lesson": mm.Evolving.Learn(content, content, "manager") @@ -118,7 +119,7 @@ func (mm *MemoryManager) Remember(content, category string) error { default: // Default: store in harrier if ready, otherwise fall back to core Memory. if mm.Harrier.Ready() { - return mm.Harrier.Remember(content, category) + return mm.Harrier.RememberWithContext(ctx, content, category) } return Save(&Memory{Content: content, Tags: []string{category}}) } diff --git a/internal/intelligence/memory/manager_test.go b/internal/intelligence/memory/manager_test.go index 69e459f6..e3f2bd20 100644 --- a/internal/intelligence/memory/manager_test.go +++ b/internal/intelligence/memory/manager_test.go @@ -1,6 +1,7 @@ package memory import ( + "context" "testing" ) @@ -29,7 +30,7 @@ func TestMemoryManager_Remember(t *testing.T) { mm := NewMemoryManager(t.TempDir()) categories := []string{"guideline", "core", "procedural", "fact", "session", "other"} for _, cat := range categories { - if err := mm.Remember("test content for "+cat, cat); err != nil { + if err := mm.Remember(context.Background(), "test content for "+cat, cat); err != nil { t.Fatalf("Remember(%q) error: %v", cat, err) } } diff --git a/internal/intelligence/memory/session_diff.go b/internal/intelligence/memory/session_diff.go index 7d7c43b8..92c697cb 100644 --- a/internal/intelligence/memory/session_diff.go +++ b/internal/intelligence/memory/session_diff.go @@ -134,12 +134,12 @@ func (sd *SessionDiffAnalyzer) StoreMemoriesFromDiff(diff *DiffResult) { } else if isConfigFile(f) { content = fmt.Sprintf("Config file: %s (%s)", basename, ext) } - _ = sd.bridge.Remember(content, "file") + _ = sd.bridge.Remember(context.Background(), content, "file") } // New dependencies → remember as decisions for _, dep := range diff.NewDeps { - _ = sd.bridge.Remember( + _ = sd.bridge.Remember(context.Background(), fmt.Sprintf("Dependency added: %s", dep), "decision", ) @@ -154,7 +154,7 @@ func (sd *SessionDiffAnalyzer) StoreMemoriesFromDiff(diff *DiffResult) { // Remove the hash prefix parts := strings.SplitN(commit, " ", 2) if len(parts) > 1 { - _ = sd.bridge.Remember( + _ = sd.bridge.Remember(context.Background(), fmt.Sprintf("Decision: %s", parts[1]), "decision", ) diff --git a/internal/tool/core_memory.go b/internal/tool/core_memory.go index 40de729b..c85369e5 100644 --- a/internal/tool/core_memory.go +++ b/internal/tool/core_memory.go @@ -38,7 +38,7 @@ func (CoreMemoryAppendTool) Execute(ctx context.Context, input json.RawMessage) if tc == nil || tc.HarrierBridge == nil { return "", fmt.Errorf("memory not available") } - if err := tc.HarrierBridge.Remember(p.Content, p.Label); err != nil { + if err := tc.HarrierBridge.Remember(ctx, p.Content, p.Label); err != nil { return "", err } return fmt.Sprintf("Appended to [%s] memory block.", p.Label), nil @@ -138,7 +138,7 @@ func (CoreMemoryRethinkTool) Execute(ctx context.Context, input json.RawMessage) } return fmt.Sprintf("Rewrote [%s] memory block.", p.Label), nil } - if err := tc.HarrierBridge.Remember(p.NewValue, p.Label); err != nil { + if err := tc.HarrierBridge.Remember(ctx, p.NewValue, p.Label); err != nil { return "", err } return fmt.Sprintf("Created new [%s] memory block.", p.Label), nil diff --git a/internal/tool/spec_clarify.go b/internal/tool/spec_clarify.go index 90a74e8a..57ecfa07 100644 --- a/internal/tool/spec_clarify.go +++ b/internal/tool/spec_clarify.go @@ -10,8 +10,15 @@ import ( "strings" "github.com/GrayCodeAI/graycode-cli/internal/spec" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) +// titleCaser replaces the deprecated strings.Title, which mishandles Unicode +// word boundaries. Title case here is applied to ASCII phase/stage/identifier +// names, so language.Und is the correct, dependency-free-of-locale choice. +var titleCaser = cases.Title(language.Und) + type ClarifyTool struct{} func (ClarifyTool) Name() string { return "Clarify" } @@ -164,7 +171,7 @@ func (SpecClarifyTool) Execute(ctx context.Context, input json.RawMessage) (stri } var b strings.Builder - fmt.Fprintf(&b, "## Clarify Phase: %s\n\n", strings.Title(p.Phase)) + fmt.Fprintf(&b, "## Clarify Phase: %s\n\n", titleCaser.String(p.Phase)) fmt.Fprintf(&b, "**%d questions found, %d unresolved**\n\n", len(questions), unresolved) if unresolved > 0 { diff --git a/internal/tool/spec_ground.go b/internal/tool/spec_ground.go index 2d4ee475..7968d80d 100644 --- a/internal/tool/spec_ground.go +++ b/internal/tool/spec_ground.go @@ -62,7 +62,7 @@ func (SpecGroundTool) Execute(ctx context.Context, input json.RawMessage) (strin } var b strings.Builder - fmt.Fprintf(&b, "## Context Grounding: %s Stage\n\n", strings.Title(p.Stage)) + fmt.Fprintf(&b, "## Context Grounding: %s Stage\n\n", titleCaser.String(p.Stage)) switch p.Stage { case "specify": diff --git a/internal/tool/spec_testgen.go b/internal/tool/spec_testgen.go index 5ac0effb..6514d4bc 100644 --- a/internal/tool/spec_testgen.go +++ b/internal/tool/spec_testgen.go @@ -112,7 +112,7 @@ func (SpecTestGenTool) Execute(ctx context.Context, input json.RawMessage) (stri func goTestName(reqID string) string { name := strings.ReplaceAll(reqID, "-", "_") name = strings.ReplaceAll(name, ".", "_") - return strings.Title(name) + return titleCaser.String(name) } func detectLanguageForTests(dir string) string { diff --git a/internal/types/severity.go b/internal/types/severity.go index 1ee3a0cd..3e2e85f2 100644 --- a/internal/types/severity.go +++ b/internal/types/severity.go @@ -18,8 +18,8 @@ const ( SeverityCritical = contracts.SeverityCritical ) -// ParseSeverity converts a string to a Severity. -var ParseSeverity = contracts.ParseSeverity +// ParseSeverityStrict is available directly from internal/contracts/types; +// the deprecated fail-open ParseSeverity alias was removed as a footgun. // TokenSeverity defines rule severity for compression error patterns. type TokenSeverity = contracts.TokenSeverity From 09460f1cb7fa905911bc55d5cd45bdc9892881cf Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:10:20 +0530 Subject: [PATCH 008/116] feat: animate review run and audit with TTY-aware progress Wire the previously-unused ProgressTracker into real command flows via a new CLIProgress helper: - cmd/progress_cli.go: TTY-aware progress renderer. On a terminal it repaints the active step in place with a braille spinner, theme-colored progress bar, and live N/M counter; piped/CI output is one clean static line per step with no ANSI. Completion prints a themed summary with the final bar (errorCoral when any step failed). Glyphs come from internal/ui/icons; block glyphs stay in the audit-permitted U+2500-U+25FF range. - cmd/review_run.go: progress over Building model / Reviewing code / Saving results, silent in background/hook mode. - cmd/audit.go: per-session progress, gated to TTY + text output so --json stays pure and piped output isn't spammed. - cmd/progress_cli_test.go: non-TTY, TTY, fail-step, and multi-step regression tests (the multi-step test covers a fresh-spinner-per-step fix for BrailleSpinner's one-shot Start/Stop panic). --- cmd/audit.go | 27 ++++++- cmd/progress_cli.go | 154 +++++++++++++++++++++++++++++++++++++++ cmd/progress_cli_test.go | 100 +++++++++++++++++++++++++ cmd/review_run.go | 34 +++++++++ 4 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 cmd/progress_cli.go create mode 100644 cmd/progress_cli_test.go diff --git a/cmd/audit.go b/cmd/audit.go index 695235f8..6adc5a24 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -81,14 +81,33 @@ func runAudit(cmd *cobra.Command, args []string) error { return nil } + // Animated per-session progress only on a TTY and only for text output. + // JSON output must stay pure (progress lines would corrupt it) and piped + // text should not be spammed with per-session lines. + var prog *CLIProgress + if auditFormat != "json" && stdoutIsTerminal() { + names := make([]string, len(sessions)) + for i := range sessions { + names[i] = fmt.Sprintf("Scanning session %d/%d", i+1, len(sessions)) + } + prog = NewCLIProgress("Audit", names) + defer prog.Abort() + } + // Run audit detectors on each session detectors := audit.AllDetectors() counts := make(map[string]*AuditCount) totalHits := 0 - for _, sess := range sessions { + for i, sess := range sessions { + if prog != nil { + prog.StartStep(i) + } events, err := loadSessionEvents(sess.Path) if err != nil { + if prog != nil { + prog.FailStep(i, "load failed") + } continue } @@ -125,6 +144,12 @@ func runAudit(cmd *cobra.Command, args []string) error { } } } + if prog != nil { + prog.CompleteStep(i) + } + } + if prog != nil { + prog.Done() } // Count unique projects per detector diff --git a/cmd/progress_cli.go b/cmd/progress_cli.go new file mode 100644 index 00000000..b5434264 --- /dev/null +++ b/cmd/progress_cli.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "fmt" + "image/color" + "io" + "os" + "strings" + "time" + + lipgloss "charm.land/lipgloss/v2" + "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" +) + +// CLIProgress renders a ProgressTracker as live single-line progress for +// non-TUI commands. On a terminal it animates the active step in place with +// the spinner wave; when stdout is piped or CI it emits one clean static line +// per completed step so output stays parseable. Glyphs come from +// internal/ui/icons so the cmd/ no-emoji audit holds. +type CLIProgress struct { + w io.Writer + pt *ProgressTracker + spinner *BrailleSpinner + tty bool +} + +// NewCLIProgress builds a tracker for title with the given steps, writing to +// stdout and animating only when stdout is a terminal. +func NewCLIProgress(title string, steps []string) *CLIProgress { + return newCLIProgress(title, steps, os.Stdout, stdoutIsTerminal()) +} + +// newCLIProgress is the testable core: writer and tty are injected. +func newCLIProgress(title string, steps []string, w io.Writer, tty bool) *CLIProgress { + pt := NewProgressTracker(title) + for _, s := range steps { + pt.AddStep(s) + } + return &CLIProgress{w: w, pt: pt, spinner: NewBrailleSpinner(SpinnerGraycode, ""), tty: tty} +} + +// StartStep marks step i active and, on a TTY, starts animating it in place. +// A fresh BrailleSpinner is created per step because the spinner is one-shot: +// its stop channel is closed on Stop() and cannot be restarted. +func (c *CLIProgress) StartStep(i int) { + c.pt.StartStep(i) + if !c.tty || i < 0 || i >= len(c.pt.Steps) { + return + } + c.spinner = NewBrailleSpinner(SpinnerGraycode, c.pt.Steps[i].Name) + c.spinner.Start(80*time.Millisecond, func(frame string) { + fmt.Fprintf(c.w, "\r%s %s %d/%d\033[K", frame, c.bar(), i+1, len(c.pt.Steps)) + }) +} + +// CompleteStep finalizes step i with its duration and prints a clean line. +func (c *CLIProgress) CompleteStep(i int) { + c.spinner.Stop() + c.pt.CompleteStep(i) + if i < 0 || i >= len(c.pt.Steps) { + return + } + s := c.pt.Steps[i] + c.writeLine(fmt.Sprintf("%s %s (%s)", + c.tint(icons.CheckBold(), doneGreen), + c.tint(s.Name, textPrimary), + c.tint(formatDurationShort(s.Duration), textMuted))) +} + +// FailStep finalizes step i as failed with a reason. +func (c *CLIProgress) FailStep(i int, reason string) { + c.spinner.Stop() + c.pt.FailStep(i, reason) + if i < 0 || i >= len(c.pt.Steps) { + return + } + s := c.pt.Steps[i] + c.writeLine(fmt.Sprintf("%s %s (%s) : %s", + c.tint(icons.CloseThick(), errorCoral), + c.tint(s.Name, textPrimary), + c.tint(formatDurationShort(s.Duration), textMuted), + c.tint(reason, errorCoral))) +} + +// tint applies a theme foreground color in TTY mode only; piped/CI output +// stays plain so scripts never see stray ANSI escapes. +func (c *CLIProgress) tint(s string, color color.Color) string { + if !c.tty || s == "" { + return s + } + return lipgloss.NewStyle().Foreground(color).Render(s) +} + +// bar renders the overall progress as a compact theme-colored bar (filled = +// successTeal, empty = borderDim). Block glyphs (U+2588/U+2591) are in the +// range the cmd/ no-emoji audit permits, matching ProgressTracker.Render. +func (c *CLIProgress) bar() string { + pct := c.pt.overallProgress() + const width = 12 + filled := int(pct * float64(width)) + if filled > width { + filled = width + } + filledStr := strings.Repeat("█", filled) + emptyStr := strings.Repeat("░", width-filled) + if c.tty { + return c.tint(filledStr, successTeal) + c.tint(emptyStr, borderDim) + } + return filledStr + emptyStr +} + +// Done stops any animation and prints a themed completion summary with the +// final progress bar. Uses errorCoral when any step failed. +func (c *CLIProgress) Done() { + c.spinner.Stop() + elapsed := c.pt.GetElapsed() + failures := 0 + for _, s := range c.pt.Steps { + if s.Status == "failed" { + failures++ + } + } + mark := icons.CheckBold() + markColor := doneGreen + verb := "complete" + if failures > 0 { + mark = icons.CloseThick() + markColor = errorCoral + verb = "finished" + } + line := fmt.Sprintf("%s %s %s in %s", + c.tint(mark, markColor), + c.tint(c.pt.Title, textPrimary), + verb, + c.tint(formatDurationShort(elapsed), textMuted)) + if failures > 0 { + line += c.tint(fmt.Sprintf(" (%d failed)", failures), errorCoral) + } + c.writeLine(fmt.Sprintf("%s %s", c.bar(), line)) +} + +// Abort stops any running animation without printing a completion line. Safe +// to call from deferred error paths so a spinner never leaks past a return. +func (c *CLIProgress) Abort() { + c.spinner.Stop() +} + +func (c *CLIProgress) writeLine(line string) { + if c.tty { + fmt.Fprintf(c.w, "\r%s\033[K\n", line) + return + } + fmt.Fprintln(c.w, line) +} diff --git a/cmd/progress_cli_test.go b/cmd/progress_cli_test.go new file mode 100644 index 00000000..9b6bb5dc --- /dev/null +++ b/cmd/progress_cli_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +// TestCLIProgressNonTTY asserts that piped/CI output is clean: one static line +// per completed step, no ANSI escapes, no carriage-return animation. +func TestCLIProgressNonTTY(t *testing.T) { + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review", "Save"}, &buf, false) + + p.StartStep(0) + p.CompleteStep(0) + p.StartStep(1) + p.CompleteStep(1) + p.StartStep(2) + p.CompleteStep(2) + p.Done() + + out := buf.String() + if strings.Contains(out, "\x1b") { + t.Errorf("non-TTY output must not contain ANSI escapes, got: %q", out) + } + if strings.Contains(out, "\r") { + t.Errorf("non-TTY output must not use carriage returns, got: %q", out) + } + for _, name := range []string{"Build", "Review", "Save"} { + if !strings.Contains(out, name) { + t.Errorf("expected step %q in output, got: %q", name, out) + } + } + // Each completed step should appear as its own line. + if got := strings.Count(out, "\n"); got < 3 { + t.Errorf("expected at least 3 completed-step lines, got %d in %q", got, out) + } +} + +// TestCLIProgressTTY asserts the terminal path redraws the current line with a +// leading carriage return and clears trailing glyphs before finalizing. +func TestCLIProgressTTY(t *testing.T) { + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review"}, &buf, true) + + p.StartStep(0) + p.CompleteStep(0) + p.Done() + + out := buf.String() + if !strings.Contains(out, "\r") { + t.Errorf("TTY output should redraw with carriage returns, got: %q", out) + } + if !strings.Contains(out, "Build") { + t.Errorf("expected step name in TTY output, got: %q", out) + } +} + +// TestCLIProgressFailStep marks a step failed and keeps other steps pending. +func TestCLIProgressFailStep(t *testing.T) { + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review"}, &buf, false) + + p.StartStep(0) + p.FailStep(0, "boom") + + if p.pt.Steps[0].Status != "failed" { + t.Errorf("expected step 0 failed, got %q", p.pt.Steps[0].Status) + } + if p.pt.Steps[1].Status != "pending" { + t.Errorf("expected step 1 still pending, got %q", p.pt.Steps[1].Status) + } + if !strings.Contains(buf.String(), "boom") { + t.Errorf("expected fail reason in output, got: %q", buf.String()) + } +} + +// TestCLIProgressTTYMultiStep runs multiple Start/Complete cycles on a TTY. +// Regression: the spinner is one-shot, so each step must get a fresh spinner +// rather than restarting a stopped one (which would panic on a double close). +func TestCLIProgressTTYMultiStep(t *testing.T) { + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review", "Save"}, &buf, true) + + p.StartStep(0) + p.CompleteStep(0) + p.StartStep(1) + p.CompleteStep(1) + p.StartStep(2) + p.CompleteStep(2) + p.Done() + + out := buf.String() + for _, name := range []string{"Build", "Review", "Save"} { + if !strings.Contains(out, name) { + t.Errorf("expected step %q in TTY output, got: %q", name, out) + } + } +} diff --git a/cmd/review_run.go b/cmd/review_run.go index 8bfd30b4..7beb7fd3 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -96,12 +96,38 @@ func runReviewRun(_ *cobra.Command, args []string) error { return nil } + // Live progress for the slow review stages. TTY-aware: animates the active + // step on a terminal, prints clean static lines when piped, and stays + // silent in background/hook mode. The deferred Abort guarantees the + // spinner goroutine never leaks past an error return. + var prog *CLIProgress + if !reviewRunBackground { + prog = NewCLIProgress("Review", []string{"Building model", "Reviewing code", "Saving results"}) + defer prog.Abort() + } + step := func(i int) { + if prog != nil { + prog.StartStep(i) + } + } + done := func(i int) { + if prog != nil { + prog.CompleteStep(i) + } + } + finish := func() { + if prog != nil { + prog.Done() + } + } + // Build the Kestrel bridge through Graycode's GraycodeRouter engine boundary. ctx := context.Background() selection := graycodeconfig.EffectiveSelection(ctx, graycodeconfig.SelectionOptions{ ProviderOverride: strings.TrimSpace(provider), ModelOverride: strings.TrimSpace(reviewRunModel), }) + step(0) chatProvider, providerID, err := engine.BuildChatProvider(ctx, selection, strings.TrimSpace(provider)) if err != nil { if statusErr := store.SetStatus(id, ReviewStatusFailed); statusErr != nil { @@ -136,6 +162,9 @@ func runReviewRun(_ *cobra.Command, args []string) error { defer cancel() } + done(0) + step(1) + // Run review. result, err := bridge.ReviewContracts(ctx, diff) if err != nil { @@ -151,9 +180,14 @@ func runReviewRun(_ *cobra.Command, args []string) error { status = ReviewStatusOpen } + done(1) + step(2) + if err := store.Update(id, status, result); err != nil { return silentErr(err, "store result") } + done(2) + finish() if !reviewRunBackground { printReviewSummary(sha, result) From f7b378a2fdf4b7ed25334b5edaf244adff7ea240 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:13:54 +0530 Subject: [PATCH 009/116] feat: animate harness evaluation and report-writing stages Wire CLIProgress into the harness command over Evaluating workspace / Writing markdown / Writing HTML / Writing JSON. Reports are written to files (not stdout), so progress never corrupts structured output; piped runs get clean static lines, TTY runs get in-place animation. --- cmd/harness.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cmd/harness.go b/cmd/harness.go index 22dd43c0..d6aa5c37 100644 --- a/cmd/harness.go +++ b/cmd/harness.go @@ -34,16 +34,40 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories return fmt.Errorf("failed to get working directory: %w", err) } + // Live progress over the slow evaluation and report-writing stages. + // TTY-aware: animates on a terminal, prints clean static lines when + // piped. Harness writes reports to files (not stdout), so progress + // never corrupts structured output. + prog := NewCLIProgress("Harness", []string{"Evaluating workspace", "Writing markdown", "Writing HTML", "Writing JSON"}) + defer prog.Abort() + step := func(i int) { + if prog != nil { + prog.StartStep(i) + } + } + done := func(i int) { + if prog != nil { + prog.CompleteStep(i) + } + } + finish := func() { + if prog != nil { + prog.Done() + } + } + ctx := context.Background() opts := harness.EvaluateOptions{ TargetPath: targetDir, OutputDir: harnessOutDir, } + step(0) report, err := harness.EvaluateWorkspace(ctx, targetDir, opts) if err != nil { return fmt.Errorf("harness evaluation failed: %w", err) } + done(0) if harnessFix || (len(args) > 0 && args[0] == "fix") { fixResult, fixErr := harness.FixWorkspaceHarness(ctx, targetDir, report) @@ -68,25 +92,31 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories } // Write Markdown report + step(1) mdPath := filepath.Join(outDir, "report.md") mdContent := harness.RenderMarkdown(report) if writeErr := os.WriteFile(mdPath, []byte(mdContent), 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write report.md: %w", writeErr) } + done(1) // Write HTML report + step(2) htmlPath := filepath.Join(outDir, "report.html") htmlContent := harness.RenderHTML(report) if writeErr := os.WriteFile(htmlPath, []byte(htmlContent), 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write report.html: %w", writeErr) } + done(2) // Write JSON report + step(3) jsonPath := filepath.Join(outDir, "findings.json") jsonContent, renderErr := harness.RenderJSON(report) if renderErr != nil { return fmt.Errorf("failed to serialize findings.json: %w", renderErr) } + done(3) if writeErr := os.WriteFile(jsonPath, jsonContent, 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write findings.json: %w", writeErr) } @@ -94,6 +124,7 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories // Journal quality observation to Graycode execution graph _ = harness.JournalHarnessReport(report, "") + finish() fmt.Printf("[GRAYCODE] Graycode Harness Evaluation Complete\n") fmt.Printf(" Overall Score : %d/100 (%s)\n", report.OverallScore, report.OverallStatus) fmt.Printf(" Findings : %d prioritized issues\n", len(report.Findings)) From 5035549ecee34504da8eabefa4b5b1a49c989a31 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:16:50 +0530 Subject: [PATCH 010/116] feat: animate slow project-check phase of verify Run project test/verify commands can be slow with no feedback until each finishes. Show a TTY-only animated indicator while they execute, then clear it before printing the structured [OK]/[FAIL]/[SKIP] results, so piped and structured output stay untouched. --- cmd/verify_cmd.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/verify_cmd.go b/cmd/verify_cmd.go index 1d4b7a89..6be1fd68 100644 --- a/cmd/verify_cmd.go +++ b/cmd/verify_cmd.go @@ -47,7 +47,22 @@ Exits non-zero on the first failed check.`, } // 3. Project test/verify checks discovered from the workspace. - for _, c := range runWorkspaceChecks() { + // Running them can be slow (actual test/verify commands), so show a + // TTY-only animated indicator while they execute; the structured + // [OK]/[FAIL] results are printed only after the animation clears. + checks, detectErr := testrunner.Detect(".") + var prog *CLIProgress + if detectErr == nil && len(checks) > 0 && stdoutIsTerminal() { + prog = NewCLIProgress("Verify", []string{fmt.Sprintf("Running %d project checks", len(checks))}) + defer prog.Abort() + prog.StartStep(0) + } + results := runWorkspaceChecks() + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } + for _, c := range results { if c.Err != nil { ok = false cmd.Printf("[FAIL] %s: %v\n", c.Name, c.Err) From e085a2229fec34a7e6116c84b9b997e135f13dca Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:19:58 +0530 Subject: [PATCH 011/116] feat: show ETA estimate in progress animation Append a live ETA to the animated line once the tracker has completed steps to extrapolate from (ProgressTracker.EstimateRemaining). Hidden until meaningful, so the first step shows no spurious ETA. --- cmd/progress_cli.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/progress_cli.go b/cmd/progress_cli.go index b5434264..75977b1e 100644 --- a/cmd/progress_cli.go +++ b/cmd/progress_cli.go @@ -49,7 +49,11 @@ func (c *CLIProgress) StartStep(i int) { } c.spinner = NewBrailleSpinner(SpinnerGraycode, c.pt.Steps[i].Name) c.spinner.Start(80*time.Millisecond, func(frame string) { - fmt.Fprintf(c.w, "\r%s %s %d/%d\033[K", frame, c.bar(), i+1, len(c.pt.Steps)) + eta := "" + if remaining := c.pt.EstimateRemaining(); remaining > 0 { + eta = fmt.Sprintf(" · ETA %s", formatDurationShort(remaining)) + } + fmt.Fprintf(c.w, "\r%s %s %d/%d%s\033[K", frame, c.bar(), i+1, len(c.pt.Steps), eta) }) } From 0ea8cbe314ff78f32dfc8c448d75090bb4599a07 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:21:45 +0530 Subject: [PATCH 012/116] test: guard audit JSON purity with auditProgressEnabled Extract the per-session progress gate into auditProgressEnabled(format, tty) and cover it: JSON output must stay pure on any terminal, and piped text must not be spammed with per-session lines. --- cmd/audit.go | 9 ++++++++- cmd/audit_progress_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 cmd/audit_progress_test.go diff --git a/cmd/audit.go b/cmd/audit.go index 6adc5a24..b8a3ad4b 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -65,6 +65,13 @@ type AuditResult struct { Detectors []AuditCount `json:"detectors"` } +// auditProgressEnabled reports whether per-session progress should be shown. +// JSON output must stay pure (progress lines would corrupt it) and piped +// text should not be spammed with per-session lines. +func auditProgressEnabled(format string, tty bool) bool { + return format != "json" && tty +} + func runAudit(cmd *cobra.Command, args []string) error { if auditJSON { auditFormat = "json" @@ -85,7 +92,7 @@ func runAudit(cmd *cobra.Command, args []string) error { // JSON output must stay pure (progress lines would corrupt it) and piped // text should not be spammed with per-session lines. var prog *CLIProgress - if auditFormat != "json" && stdoutIsTerminal() { + if auditProgressEnabled(auditFormat, stdoutIsTerminal()) { names := make([]string, len(sessions)) for i := range sessions { names[i] = fmt.Sprintf("Scanning session %d/%d", i+1, len(sessions)) diff --git a/cmd/audit_progress_test.go b/cmd/audit_progress_test.go new file mode 100644 index 00000000..75dc148c --- /dev/null +++ b/cmd/audit_progress_test.go @@ -0,0 +1,26 @@ +package cmd + +import "testing" + +// TestAuditProgressEnabled guards the JSON-purity contract: per-session +// progress must never render when output is JSON (it would corrupt the +// payload) or when stdout is piped (it would spam the report). +func TestAuditProgressEnabled(t *testing.T) { + cases := []struct { + format string + tty bool + want bool + }{ + {"json", true, false}, // JSON output must stay pure even on a TTY + {"json", false, false}, // JSON + piped: never + {"text", false, false}, // piped text: no per-session spam + {"text", true, true}, // interactive text: animate + {"", true, true}, // default format on a TTY: animate + {"", false, false}, // default format piped: no spam + } + for _, c := range cases { + if got := auditProgressEnabled(c.format, c.tty); got != c.want { + t.Errorf("auditProgressEnabled(%q, %v) = %v, want %v", c.format, c.tty, got, c.want) + } + } +} From 9ee240d3e0351ef66948a0cca86bb08bfe4b3bf4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:25:58 +0530 Subject: [PATCH 013/116] feat: theme the audit text report Colorize the report title (brand gold), section headers (infoSky), and severity column (semantic colors: high/critical errorCoral, medium warnAmber, info/low infoSky) on a TTY only. Piped output stays plain. Severity is padded to the column width before colorizing so zero-width ANSI escapes don't break the fixed-width alignment. --- cmd/audit.go | 34 ++++++++++++++++++++++++++++++---- cmd/audit_progress_test.go | 20 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/cmd/audit.go b/cmd/audit.go index b8a3ad4b..20c49c02 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -3,12 +3,14 @@ package cmd import ( "encoding/json" "fmt" + "image/color" "os" "path/filepath" "sort" "strings" "time" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/graycode-cli/internal/hooks/audit" "github.com/GrayCodeAI/graycode-cli/internal/storage" "github.com/spf13/cobra" @@ -282,12 +284,33 @@ func loadSessionEvents(path string) ([]audit.ToolEvent, error) { return events, nil } +// auditTint applies a theme foreground color on a TTY only; piped output +// stays plain so scripts never see stray ANSI escapes. +func auditTint(s string, color color.Color) string { + if !stdoutIsTerminal() || s == "" { + return s + } + return lipgloss.NewStyle().Foreground(color).Render(s) +} + +// auditSeverityColor maps a detector severity to a semantic theme color. +func auditSeverityColor(sev string) color.Color { + switch sev { + case "high", "critical": + return errorCoral + case "medium": + return warnAmber + default: // info, low + return infoSky + } +} + func printAuditText(cmd *cobra.Command, result AuditResult) { w := cmd.OutOrStdout() _, _ = fmt.Fprintf(w, "\n") _, _ = fmt.Fprintf(w, "═══════════════════════════════════════════════════════════════\n") - _, _ = fmt.Fprintf(w, " Graycode Audit Report\n") + _, _ = fmt.Fprintf(w, " %s\n", auditTint("Graycode Audit Report", graycodeColor)) _, _ = fmt.Fprintf(w, "═══════════════════════════════════════════════════════════════\n") _, _ = fmt.Fprintf(w, "\n") _, _ = fmt.Fprintf(w, " Scanned: %d sessions (last %d days)\n", result.Sessions, result.Days) @@ -300,7 +323,7 @@ func printAuditText(cmd *cobra.Command, result AuditResult) { } _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Detected Patterns ───\n\n") + _, _ = fmt.Fprintf(w, "─── %s ───\n\n", auditTint("Detected Patterns", infoSky)) _, _ = fmt.Fprintf(w, " %-30s %6s %8s %s\n", "DETECTOR", "HITS", "SEVERITY", "EXAMPLE") _, _ = fmt.Fprintf(w, " %-30s %6s %8s %s\n", strings.Repeat("─", 30), strings.Repeat("─", 6), strings.Repeat("─", 8), strings.Repeat("─", 30)) @@ -309,11 +332,14 @@ func printAuditText(cmd *cobra.Command, result AuditResult) { if len(d.Examples) > 0 { example = d.Examples[0] } - _, _ = fmt.Fprintf(w, " %-30s %6d %8s %s\n", d.Name, d.Hits, d.Severity, example) + // Pad to the column width first, then colorize, so ANSI escapes + // (zero-width) don't break the fixed-width alignment. + sev := auditTint(fmt.Sprintf("%8s", d.Severity), auditSeverityColor(d.Severity)) + _, _ = fmt.Fprintf(w, " %-30s %6d %s %s\n", d.Name, d.Hits, sev, example) } _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Remediation Tips ───\n\n") + _, _ = fmt.Fprintf(w, "─── %s ───\n\n", auditTint("Remediation Tips", infoSky)) for _, d := range result.Detectors { switch d.Name { diff --git a/cmd/audit_progress_test.go b/cmd/audit_progress_test.go index 75dc148c..6f4a2060 100644 --- a/cmd/audit_progress_test.go +++ b/cmd/audit_progress_test.go @@ -24,3 +24,23 @@ func TestAuditProgressEnabled(t *testing.T) { } } } + +// TestAuditSeverityColor verifies severity maps to the expected semantic +// theme color so the report's severity column reads consistently. +func TestAuditSeverityColor(t *testing.T) { + if auditSeverityColor("high") != errorCoral { + t.Error("high should map to errorCoral") + } + if auditSeverityColor("critical") != errorCoral { + t.Error("critical should map to errorCoral") + } + if auditSeverityColor("medium") != warnAmber { + t.Error("medium should map to warnAmber") + } + if auditSeverityColor("low") != infoSky { + t.Error("low should map to infoSky") + } + if auditSeverityColor("info") != infoSky { + t.Error("info should map to infoSky") + } +} From f4ac16972c0cf733f37c1c9ea8c053f879add394 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:29:12 +0530 Subject: [PATCH 014/116] feat: animate the eval loop agent run Show a TTY-only animated indicator around the slow full agent loop. It clears before the JSON report prints, so piped and structured output stay pure. --- cmd/eval.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/eval.go b/cmd/eval.go index 430ff69c..e13ac0be 100644 --- a/cmd/eval.go +++ b/cmd/eval.go @@ -121,10 +121,19 @@ func runEvalLoop(cmd *cobra.Command, _ []string) error { cfg := evalloop.DefaultConfig() runtime := evalloop.NewSessionRuntime(gw.ChatClient(), "eval", model, tool.NewRegistry(), cfg) + + // The agent loop is the slow part; show a TTY-only animated indicator. + // It clears before the JSON report prints, so piped and structured + // output stay pure. + prog := NewCLIProgress("Eval", []string{"Running agent loop"}) + defer prog.Abort() + prog.StartStep(0) result, err := runtime.Run(ctx, workDir, evalLoopPrompt) if err != nil { return fmt.Errorf("eval loop: %w", err) } + prog.CompleteStep(0) + prog.Done() transcriptPath := "" if len(result.Transcript) > 0 { From 937148cce26769d4c460e410af07e8074e8e6e9a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:39:14 +0530 Subject: [PATCH 015/116] feat: honor --quiet/NO_COLOR/FORCE_COLOR in progress and theme Wire the canonical ShouldColor()/IsQuiet() gates into CLIProgress and the audit report so the new animated/theme output respects the same controls as the rest of the CLI. --quiet now fully suppresses spinners, progress lines, and decoration (the documented contract) and produces plain output; NO_COLOR strips color while keeping the TTY animation; FORCE_COLOR colors piped output. Make --quiet a persistent root flag so it is usable on subcommands (graycode audit --quiet, verify --quiet, eval loop --quiet) instead of only the interactive root. Previously subcommands rejected it with 'unknown flag'. Add tests for quiet suppression, NO_COLOR, and FORCE_COLOR. --- cmd/audit.go | 9 +++--- cmd/progress_cli.go | 21 +++++++------ cmd/progress_cli_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 2 +- 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/cmd/audit.go b/cmd/audit.go index 20c49c02..7dc0ef9d 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -94,7 +94,7 @@ func runAudit(cmd *cobra.Command, args []string) error { // JSON output must stay pure (progress lines would corrupt it) and piped // text should not be spammed with per-session lines. var prog *CLIProgress - if auditProgressEnabled(auditFormat, stdoutIsTerminal()) { + if auditProgressEnabled(auditFormat, stdoutIsTerminal()) && !IsQuiet() { names := make([]string, len(sessions)) for i := range sessions { names[i] = fmt.Sprintf("Scanning session %d/%d", i+1, len(sessions)) @@ -284,10 +284,11 @@ func loadSessionEvents(path string) ([]audit.ToolEvent, error) { return events, nil } -// auditTint applies a theme foreground color on a TTY only; piped output -// stays plain so scripts never see stray ANSI escapes. +// auditTint applies a theme foreground color when color output is appropriate +// (honors --quiet, NO_COLOR, FORCE_COLOR, and TTY state via ShouldColor). +// Piped output stays plain so scripts never see stray ANSI escapes. func auditTint(s string, color color.Color) string { - if !stdoutIsTerminal() || s == "" { + if !ShouldColor() || s == "" { return s } return lipgloss.NewStyle().Foreground(color).Render(s) diff --git a/cmd/progress_cli.go b/cmd/progress_cli.go index 75977b1e..7e3050ad 100644 --- a/cmd/progress_cli.go +++ b/cmd/progress_cli.go @@ -44,7 +44,7 @@ func newCLIProgress(title string, steps []string, w io.Writer, tty bool) *CLIPro // its stop channel is closed on Stop() and cannot be restarted. func (c *CLIProgress) StartStep(i int) { c.pt.StartStep(i) - if !c.tty || i < 0 || i >= len(c.pt.Steps) { + if IsQuiet() || !c.tty || i < 0 || i >= len(c.pt.Steps) { return } c.spinner = NewBrailleSpinner(SpinnerGraycode, c.pt.Steps[i].Name) @@ -61,7 +61,7 @@ func (c *CLIProgress) StartStep(i int) { func (c *CLIProgress) CompleteStep(i int) { c.spinner.Stop() c.pt.CompleteStep(i) - if i < 0 || i >= len(c.pt.Steps) { + if IsQuiet() || i < 0 || i >= len(c.pt.Steps) { return } s := c.pt.Steps[i] @@ -75,7 +75,7 @@ func (c *CLIProgress) CompleteStep(i int) { func (c *CLIProgress) FailStep(i int, reason string) { c.spinner.Stop() c.pt.FailStep(i, reason) - if i < 0 || i >= len(c.pt.Steps) { + if IsQuiet() || i < 0 || i >= len(c.pt.Steps) { return } s := c.pt.Steps[i] @@ -86,10 +86,11 @@ func (c *CLIProgress) FailStep(i int, reason string) { c.tint(reason, errorCoral))) } -// tint applies a theme foreground color in TTY mode only; piped/CI output -// stays plain so scripts never see stray ANSI escapes. +// tint applies a theme foreground color when color output is appropriate +// (honors --quiet, NO_COLOR, FORCE_COLOR, and TTY state via ShouldColor). +// Piped/CI output stays plain so scripts never see stray ANSI escapes. func (c *CLIProgress) tint(s string, color color.Color) string { - if !c.tty || s == "" { + if !ShouldColor() || s == "" { return s } return lipgloss.NewStyle().Foreground(color).Render(s) @@ -107,16 +108,16 @@ func (c *CLIProgress) bar() string { } filledStr := strings.Repeat("█", filled) emptyStr := strings.Repeat("░", width-filled) - if c.tty { - return c.tint(filledStr, successTeal) + c.tint(emptyStr, borderDim) - } - return filledStr + emptyStr + return c.tint(filledStr, successTeal) + c.tint(emptyStr, borderDim) } // Done stops any animation and prints a themed completion summary with the // final progress bar. Uses errorCoral when any step failed. func (c *CLIProgress) Done() { c.spinner.Stop() + if IsQuiet() { + return + } elapsed := c.pt.GetElapsed() failures := 0 for _, s := range c.pt.Steps { diff --git a/cmd/progress_cli_test.go b/cmd/progress_cli_test.go index 9b6bb5dc..2ed2b1b4 100644 --- a/cmd/progress_cli_test.go +++ b/cmd/progress_cli_test.go @@ -98,3 +98,69 @@ func TestCLIProgressTTYMultiStep(t *testing.T) { } } } + +// TestCLIProgressQuiet asserts --quiet fully suppresses progress: no +// animation, no completed-step lines, no completion summary. The flag is +// documented to drop "spinners, progress, decoration" for machine parsing. +func TestCLIProgressQuiet(t *testing.T) { + prev := quietFlag + quietFlag = true + defer func() { quietFlag = prev }() + + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review"}, &buf, true) + + p.StartStep(0) + p.CompleteStep(0) + p.StartStep(1) + p.CompleteStep(1) + p.Done() + + if buf.Len() != 0 { + t.Errorf("quiet mode should emit no progress output, got: %q", buf.String()) + } +} + +// TestCLIProgressNoColor asserts NO_COLOR keeps the TTY animation (carriage +// returns and the \033[K clear-line control) but strips color SGR sequences +// (the \x1b[38 truecolor foregrounds applied by tint). +func TestCLIProgressNoColor(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build"}, &buf, true) + + p.StartStep(0) + p.CompleteStep(0) + p.Done() + + out := buf.String() + if !strings.Contains(out, "\r") { + t.Errorf("NO_COLOR should keep TTY animation, got: %q", out) + } + if strings.Contains(out, "\x1b[38") { + t.Errorf("NO_COLOR must strip color SGR sequences, got: %q", out) + } +} + +// TestCLIProgressForceColor asserts FORCE_COLOR colors output even when stdout +// is piped (no carriage-return animation, but truecolor SGR present). +func TestCLIProgressForceColor(t *testing.T) { + t.Setenv("NO_COLOR", "") // clear ambient NO_COLOR; it wins over FORCE_COLOR + t.Setenv("FORCE_COLOR", "1") + + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build"}, &buf, false) + + p.StartStep(0) + p.CompleteStep(0) + p.Done() + + out := buf.String() + if !strings.Contains(out, "\x1b[38") { + t.Errorf("FORCE_COLOR should color piped output, got: %q", out) + } + if strings.Contains(out, "\r") { + t.Errorf("piped output must not animate with carriage returns, got: %q", out) + } +} diff --git a/cmd/root.go b/cmd/root.go index 2a6aaebb..9dd5bac4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -263,7 +263,7 @@ func init() { rootCmd.Flags().BoolVar(&skipCatalogRefreshFlag, "no-auto-catalog-refresh", false, "disable automatic catalog refresh when cache is missing, empty, or stale") rootCmd.Flags().BoolVar(&recoverFlag, "recover", false, "scan for interrupted sessions and offer to resume") rootCmd.Flags().BoolVar(&startupProfileFlag, "startup-profile", false, "print startup performance profile") - rootCmd.Flags().BoolVarP(&quietFlag, "quiet", "q", false, "suppress non-essential output (spinners, progress, decoration); machine-parseable output only") + rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "suppress non-essential output (spinners, progress, decoration); machine-parseable output only") preflightCmd.Flags().BoolVar(&preflightLiveFlag, "live", false, "verify selected provider connectivity and authentication") preflightCmd.Flags().BoolVar(&preflightJSON, "json", false, "output preflight report as JSON") doctorCmd.Flags().BoolVar(&doctorJSONFlag, "json", false, "output diagnostics as JSON") From 574201d92ad534d9545a3b65e978e3cad5af10fb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:40:44 +0530 Subject: [PATCH 016/116] feat: animate the harness --fix repair step Insert a 'Repairing harness' progress step around FixWorkspaceHarness (and the post-fix re-evaluation) when --fix is used. Report-write steps reindex dynamically so the animation stays correct in both modes. --- cmd/harness.go | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/cmd/harness.go b/cmd/harness.go index d6aa5c37..88dd5ac3 100644 --- a/cmd/harness.go +++ b/cmd/harness.go @@ -37,8 +37,16 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories // Live progress over the slow evaluation and report-writing stages. // TTY-aware: animates on a terminal, prints clean static lines when // piped. Harness writes reports to files (not stdout), so progress - // never corrupts structured output. - prog := NewCLIProgress("Harness", []string{"Evaluating workspace", "Writing markdown", "Writing HTML", "Writing JSON"}) + // never corrupts structured output. --fix inserts a "Repairing + // harness" step between evaluation and the report writes. + fixing := harnessFix || (len(args) > 0 && args[0] == "fix") + steps := []string{"Evaluating workspace", "Writing markdown", "Writing HTML", "Writing JSON"} + reportBase := 1 + if fixing { + steps = []string{"Evaluating workspace", "Repairing harness", "Writing markdown", "Writing HTML", "Writing JSON"} + reportBase = 2 + } + prog := NewCLIProgress("Harness", steps) defer prog.Abort() step := func(i int) { if prog != nil { @@ -69,7 +77,8 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories } done(0) - if harnessFix || (len(args) > 0 && args[0] == "fix") { + if fixing { + step(1) fixResult, fixErr := harness.FixWorkspaceHarness(ctx, targetDir, report) if fixErr != nil { return fmt.Errorf("harness auto-fix failed: %w", fixErr) @@ -80,6 +89,7 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories } // Re-evaluate workspace after fix report, _ = harness.EvaluateWorkspace(ctx, targetDir, opts) + done(1) } outDir := harnessOutDir @@ -92,31 +102,31 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories } // Write Markdown report - step(1) + step(reportBase) mdPath := filepath.Join(outDir, "report.md") mdContent := harness.RenderMarkdown(report) if writeErr := os.WriteFile(mdPath, []byte(mdContent), 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write report.md: %w", writeErr) } - done(1) + done(reportBase) // Write HTML report - step(2) + step(reportBase + 1) htmlPath := filepath.Join(outDir, "report.html") htmlContent := harness.RenderHTML(report) if writeErr := os.WriteFile(htmlPath, []byte(htmlContent), 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write report.html: %w", writeErr) } - done(2) + done(reportBase + 1) // Write JSON report - step(3) + step(reportBase + 2) jsonPath := filepath.Join(outDir, "findings.json") jsonContent, renderErr := harness.RenderJSON(report) if renderErr != nil { return fmt.Errorf("failed to serialize findings.json: %w", renderErr) } - done(3) + done(reportBase + 2) if writeErr := os.WriteFile(jsonPath, jsonContent, 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write findings.json: %w", writeErr) } From 9b998ce32a443d9e27ad1b66a0fa0df9882444aa Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:42:54 +0530 Subject: [PATCH 017/116] feat: per-task progress for eval benchmark suites Add an optional Progress callback to the eval Runner, invoked before each task with its index, total, and ID. runEval wires it to a CLIProgress with one step per benchmark task, closing the previous step and opening the next. Quiet mode suppresses the animation. Backward-compatible: no existing Runner construction breaks (keyed literals), and the callback is nil by default. --- cmd/eval.go | 24 ++++++++++++++++ internal/feature/eval/eval.go | 8 ++++++ internal/feature/eval/eval_test.go | 44 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/cmd/eval.go b/cmd/eval.go index e13ac0be..bbc1b21d 100644 --- a/cmd/eval.go +++ b/cmd/eval.go @@ -232,6 +232,26 @@ func runEval(_ *cobra.Command, _ []string) error { runner.Cache = eval.DefaultCache() } runner.Filters = []eval.Filter{eval.ExtractCodeBlock("go")} + + // Animate one step per benchmark task. The eval runner invokes the + // callback before each task, so we close the previous step and open the + // next. Quiet mode suppresses the animation entirely. + var prog *CLIProgress + if !IsQuiet() { + names := make([]string, len(tasks)) + for i := range tasks { + names[i] = tasks[i].ID + } + prog = NewCLIProgress("Eval", names) + defer prog.Abort() + runner.Progress = func(i, _ int, _ string) { + if i > 0 { + prog.CompleteStep(i - 1) + } + prog.StartStep(i) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() @@ -239,6 +259,10 @@ func runEval(_ *cobra.Command, _ []string) error { if err != nil { return err } + if prog != nil { + prog.CompleteStep(len(tasks) - 1) + prog.Done() + } // Compute reproducibility hash hash := eval.ComputeHash(tasks) diff --git a/internal/feature/eval/eval.go b/internal/feature/eval/eval.go index d761d31a..12c3d272 100644 --- a/internal/feature/eval/eval.go +++ b/internal/feature/eval/eval.go @@ -64,6 +64,10 @@ type Runner struct { Cache *Cache NoCache bool Filters []Filter + // Progress, when non-nil, is invoked before each task runs with the + // zero-based task index, the total task count, and the task ID. It lets + // callers surface live per-task progress for long benchmark suites. + Progress func(i, total int, taskID string) } // LLMClient is the interface for invoking an LLM during evaluation. @@ -102,6 +106,10 @@ func (r *Runner) Run(ctx context.Context, suite *BenchmarkSuite) (*SuiteResult, default: } + if r.Progress != nil { + r.Progress(i, len(suite.Tasks), suite.Tasks[i].ID) + } + taskResult, err := r.RunSingle(ctx, &suite.Tasks[i]) if err != nil { taskResult = &TaskResult{ diff --git a/internal/feature/eval/eval_test.go b/internal/feature/eval/eval_test.go index 55b87cfb..6f822b81 100644 --- a/internal/feature/eval/eval_test.go +++ b/internal/feature/eval/eval_test.go @@ -544,3 +544,47 @@ func TestBenchmarkSuiteStructure(t *testing.T) { } } } + +// TestRunProgressCallback asserts the Progress callback fires once per task +// with the zero-based index, total count, and task ID, in order. +func TestRunProgressCallback(t *testing.T) { + mk := func(id string) BenchmarkTask { + return BenchmarkTask{ + ID: id, + Description: "passes immediately", + SetupFn: func(workDir string) error { return nil }, + ValidateFn: func(workDir string) (bool, string) { return true, "ok" }, + Prompt: "Do nothing", + TimeLimit: 10 * time.Second, + } + } + suite := &BenchmarkSuite{ + Name: "progress", + Tasks: []BenchmarkTask{mk("a"), mk("b"), mk("c")}, + } + + var calls []string + r := NewRunner("test", "test") + r.Progress = func(i, total int, taskID string) { + if total != 3 { + t.Errorf("total = %d, want 3", total) + } + calls = append(calls, taskID) + if i != len(calls)-1 { + t.Errorf("i = %d, want %d", i, len(calls)-1) + } + } + + if _, err := r.Run(context.Background(), suite); err != nil { + t.Fatalf("Run: %v", err) + } + want := []string{"a", "b", "c"} + if len(calls) != len(want) { + t.Fatalf("callback called %d times, want %d (%v)", len(calls), len(want), calls) + } + for i := range want { + if calls[i] != want[i] { + t.Errorf("call %d = %q, want %q", i, calls[i], want[i]) + } + } +} From 4601d1f60df1ae3660a4f2bfa1946dee90f78cee Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:51:23 +0530 Subject: [PATCH 018/116] feat: theme the review run summary Colorize the review summary to match the audit report: green check for a clean review, coral alert with semantic severity colors for findings. Honors ShouldColor() so --quiet/NO_COLOR/FORCE_COLOR behave consistently. --- cmd/review_run.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/cmd/review_run.go b/cmd/review_run.go index 7beb7fd3..0cb48cc5 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "image/color" "os" "os/exec" "strings" @@ -11,6 +12,7 @@ import ( graycodeKestrel "github.com/GrayCodeAI/graycode-cli/internal/bridge/kestrel" graycodeconfig "github.com/GrayCodeAI/graycode-cli/internal/config" reviewcontracts "github.com/GrayCodeAI/graycode-cli/internal/contracts/review" + contracts "github.com/GrayCodeAI/graycode-cli/internal/contracts/types" "github.com/GrayCodeAI/graycode-cli/internal/engine" "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" kestrelLib "github.com/GrayCodeAI/kestrel" @@ -208,14 +210,37 @@ func getCommitDiff(sha string) (string, error) { return string(out), nil } +// reviewSeverityColor maps a review finding's severity to its semantic theme +// color, mirroring the audit report's severity palette. +func reviewSeverityColor(sev contracts.Severity) color.Color { + switch sev { + case contracts.SeverityCritical, contracts.SeverityHigh: + return errorCoral + case contracts.SeverityMedium: + return warnAmber + default: + return infoSky + } +} + func printReviewSummary(sha string, result *reviewcontracts.Result) { if len(result.Findings) == 0 { - fmt.Printf("%s %s — no issues found (%d files reviewed)\n", icons.CheckBold(), sha[:8], result.Stats.FilesReviewed) + fmt.Printf("%s %s — no issues found (%d files reviewed)\n", + auditTint(icons.CheckBold(), doneGreen), + auditTint(sha[:8], textPrimary), + result.Stats.FilesReviewed) return } - fmt.Printf("%s %s — %d findings (max severity: %s)\n", icons.Alert(), sha[:8], len(result.Findings), result.MaxSeverity()) + maxSev := result.MaxSeverity() + fmt.Printf("%s %s — %d findings (max severity: %s)\n", + auditTint(icons.Alert(), errorCoral), + auditTint(sha[:8], textPrimary), + len(result.Findings), + auditTint(maxSev.String(), reviewSeverityColor(maxSev))) for _, f := range result.Findings { - fmt.Printf(" [%s] %s:%d — %s\n", f.Severity, f.File, f.Line, f.Message) + fmt.Printf(" [%s] %s:%d — %s\n", + auditTint(f.Severity.String(), reviewSeverityColor(f.Severity)), + f.File, f.Line, f.Message) } } From 5ff81182088f061fe47ccf8ec51f8f9db626a5d6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:53:08 +0530 Subject: [PATCH 019/116] feat: colorize audit summary block Theme the summary labels (muted) and the total-hits count (green when clean, coral when findings exist); the empty-detectors success line is now green. Consistent with the themed title and severity columns. --- cmd/audit.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cmd/audit.go b/cmd/audit.go index 7dc0ef9d..1b0c2898 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -314,12 +314,19 @@ func printAuditText(cmd *cobra.Command, result AuditResult) { _, _ = fmt.Fprintf(w, " %s\n", auditTint("Graycode Audit Report", graycodeColor)) _, _ = fmt.Fprintf(w, "═══════════════════════════════════════════════════════════════\n") _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, " Scanned: %d sessions (last %d days)\n", result.Sessions, result.Days) - _, _ = fmt.Fprintf(w, " Total hits: %d\n", result.TotalHits) - _, _ = fmt.Fprintf(w, " Scanned at: %s\n", result.ScannedAt) + _, _ = fmt.Fprintf(w, " %s %d sessions (last %d days)\n", + auditTint("Scanned:", textMuted), result.Sessions, result.Days) + hitsColor := doneGreen + if result.TotalHits > 0 { + hitsColor = errorCoral + } + _, _ = fmt.Fprintf(w, " %s %s\n", + auditTint("Total hits:", textMuted), auditTint(fmt.Sprintf("%d", result.TotalHits), hitsColor)) + _, _ = fmt.Fprintf(w, " %s %s\n", + auditTint("Scanned at:", textMuted), auditTint(result.ScannedAt, textPrimary)) if len(result.Detectors) == 0 { - _, _ = fmt.Fprintf(w, "\n No wasteful patterns detected. Great job!\n\n") + _, _ = fmt.Fprintf(w, "\n %s\n\n", auditTint("No wasteful patterns detected. Great job!", doneGreen)) return } From c411d9695e68ef68b47014aca41e8bd283324666 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:54:27 +0530 Subject: [PATCH 020/116] feat: theme the harness final summary Gold completion banner; semantic status color (green EXCELLENT/GOOD, amber NEEDS_IMPROVEMENT, coral POOR); labels in textPrimary. Honors ShouldColor(). --- cmd/harness.go | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/cmd/harness.go b/cmd/harness.go index 88dd5ac3..b119f39e 100644 --- a/cmd/harness.go +++ b/cmd/harness.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "image/color" "os" "path/filepath" @@ -135,12 +136,15 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories _ = harness.JournalHarnessReport(report, "") finish() - fmt.Printf("[GRAYCODE] Graycode Harness Evaluation Complete\n") - fmt.Printf(" Overall Score : %d/100 (%s)\n", report.OverallScore, report.OverallStatus) - fmt.Printf(" Findings : %d prioritized issues\n", len(report.Findings)) - fmt.Printf(" HTML Report : %s\n", htmlPath) - fmt.Printf(" Markdown : %s\n", mdPath) - fmt.Printf(" JSON Findings : %s\n", jsonPath) + fmt.Printf("%s\n", auditTint("[GRAYCODE] Graycode Harness Evaluation Complete", graycodeColor)) + fmt.Printf(" %s : %s (%s)\n", + auditTint("Overall Score", textPrimary), + auditTint(fmt.Sprintf("%d/100", report.OverallScore), textPrimary), + auditTint(report.OverallStatus, harnessStatusColor(report.OverallStatus))) + fmt.Printf(" %s : %d prioritized issues\n", auditTint("Findings", textPrimary), len(report.Findings)) + fmt.Printf(" %s : %s\n", auditTint("HTML Report", textPrimary), htmlPath) + fmt.Printf(" %s : %s\n", auditTint("Markdown", textPrimary), mdPath) + fmt.Printf(" %s : %s\n", auditTint("JSON Findings", textPrimary), jsonPath) return nil }, @@ -151,3 +155,17 @@ func init() { harnessCmd.Flags().StringVar(&harnessFormat, "format", "all", "Report output format (html, markdown, json, all)") harnessCmd.Flags().BoolVar(&harnessFix, "fix", false, "Automatically repair missing harness assets (AGENTS.md, skills, specs)") } + +// harnessStatusColor maps the harness health status to a semantic theme color. +func harnessStatusColor(status string) color.Color { + switch status { + case "EXCELLENT", "GOOD": + return doneGreen + case "NEEDS_IMPROVEMENT": + return warnAmber + case "POOR": + return errorCoral + default: + return textPrimary + } +} From 98441d8976acb07b7bc5b0ecb17d705a57c84dff Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 22:58:41 +0530 Subject: [PATCH 021/116] feat: theme verify check markers Colorize the [OK]/[FAIL]/[SKIP] markers (green/coral/muted) and the 'verification passed' line. Markers are padded to a fixed width before colorizing so ANSI escapes don't break column alignment. Plain when piped via ShouldColor(). --- cmd/verify_cmd.go | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/cmd/verify_cmd.go b/cmd/verify_cmd.go index 6be1fd68..e288e3e6 100644 --- a/cmd/verify_cmd.go +++ b/cmd/verify_cmd.go @@ -24,26 +24,31 @@ var verifyCmd = &cobra.Command{ Exits non-zero on the first failed check.`, RunE: func(cmd *cobra.Command, args []string) error { ok := true + // Themed markers (padded to a fixed width so colorized output keeps + // its column alignment; plain when piped via ShouldColor). + okMark := auditTint("[OK] ", doneGreen) + failMark := auditTint("[FAIL] ", errorCoral) + skipMark := auditTint("[SKIP] ", textMuted) // 1. Security event log chain integrity. dir := securitylog.DefaultDir() count, err := securitylog.Verify(dir) if err != nil { ok = false - cmd.Printf("[FAIL] security event log: %v\n", err) + cmd.Printf("%ssecurity event log: %v\n", failMark, err) } else { - cmd.Printf("[OK] security event log: %d entries verified (%s)\n", count, dir) + cmd.Printf("%ssecurity event log: %d entries verified (%s)\n", okMark, count, dir) } // 2. Managed governance policy validity (only when installed). policyPath := governance.ManagedPolicyPath() if _, statErr := os.Stat(policyPath); statErr != nil { - cmd.Printf("[SKIP] governance policy: not installed (%s)\n", policyPath) + cmd.Printf("%sgovernance policy: not installed (%s)\n", skipMark, policyPath) } else if _, err := governance.LoadLayer("policy", policyPath); err != nil { ok = false - cmd.Printf("[FAIL] governance policy: %v\n", err) + cmd.Printf("%sgovernance policy: %v\n", failMark, err) } else { - cmd.Printf("[OK] governance policy: valid (%s)\n", policyPath) + cmd.Printf("%sgovernance policy: valid (%s)\n", okMark, policyPath) } // 3. Project test/verify checks discovered from the workspace. @@ -65,16 +70,16 @@ Exits non-zero on the first failed check.`, for _, c := range results { if c.Err != nil { ok = false - cmd.Printf("[FAIL] %s: %v\n", c.Name, c.Err) + cmd.Printf("%s%s: %v\n", failMark, c.Name, c.Err) continue } - cmd.Printf("[OK] %s: %s\n", c.Name, c.Detail) + cmd.Printf("%s%s: %s\n", okMark, c.Name, c.Detail) } if !ok { return fmt.Errorf("verification failed — see messages above") } - cmd.Println("verification passed") + cmd.Println(auditTint("verification passed", doneGreen)) return nil }, } From 012dbce952eadfb63c7067666dd806a5936d222c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:02:47 +0530 Subject: [PATCH 022/116] feat: theme the usage report Gold title, muted header/separator, violet cost column (pad-then-colorize to preserve alignment), textPrimary total label. Honors ShouldColor() so piped output stays plain. --- cmd/usage.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/cmd/usage.go b/cmd/usage.go index ff0fbbd0..9c218ea9 100644 --- a/cmd/usage.go +++ b/cmd/usage.go @@ -60,20 +60,24 @@ func runUsage(cmd *cobra.Command, _ []string) error { } if sum.Generations == 0 { - cmd.Println("No usage recorded in the last " + usagePeriod + ".") - cmd.Println("The ledger lives at " + usage.LedgerPath()) + cmd.Println(auditTint("No usage recorded in the last "+usagePeriod+".", textMuted)) + cmd.Println(auditTint("The ledger lives at "+usage.LedgerPath(), textMuted)) return nil } - cmd.Println(fmt.Sprintf("Usage (last %s)", usagePeriod)) - cmd.Println(fmt.Sprintf("%-28s %10s %10s %8s %12s", "model", "in", "out", "gen", "cost")) + cmd.Println(auditTint(fmt.Sprintf("Usage (last %s)", usagePeriod), graycodeColor)) + cmd.Println(auditTint(fmt.Sprintf("%-28s %10s %10s %8s %12s", "model", "in", "out", "gen", "cost"), textMuted)) for _, m := range sum.ByModel { - cmd.Println(fmt.Sprintf("%-28s %10d %10d %8d %10.4f$", - truncateModel(m.Model), m.InputTokens, m.OutputTokens, m.Generations, m.TotalCostUSD)) + // Pad the cost to its column width first, then colorize, so the + // zero-width ANSI escapes don't break the fixed-width alignment. + cost := auditTint(fmt.Sprintf("%10.4f$", m.TotalCostUSD), costViolet) + cmd.Println(fmt.Sprintf("%-28s %10d %10d %8d %s", + truncateModel(m.Model), m.InputTokens, m.OutputTokens, m.Generations, cost)) } - cmd.Println("------------------------------------------------------------") - cmd.Println(fmt.Sprintf("%-28s %10d %10s %8d %10.4f$", - "total", sum.TotalTokens, "", sum.Generations, sum.TotalCostUSD)) + cmd.Println(auditTint("------------------------------------------------------------", textMuted)) + cmd.Println(fmt.Sprintf("%-28s %10d %10s %8d %s", + auditTint("total", textPrimary), sum.TotalTokens, "", sum.Generations, + auditTint(fmt.Sprintf("%10.4f$", sum.TotalCostUSD), costViolet))) return nil } From f776acfe7f551504c0e5ff77533c25fc24253734 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:03:51 +0530 Subject: [PATCH 023/116] feat: theme the usage statistics report Gold title, sky section headers, muted labels/headers, violet cost figures, teal top-tool bars. Consistent with the audit report. Honors ShouldColor(). --- cmd/stats.go | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/cmd/stats.go b/cmd/stats.go index a1df3310..f2717de7 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -180,30 +180,30 @@ func printStatsText(cmd *cobra.Command, out *statsOutput) { _, _ = fmt.Fprintf(w, "\n") _, _ = fmt.Fprintf(w, "══════════════════════════════════════════════════\n") - _, _ = fmt.Fprintf(w, " Graycode Usage Statistics (%s)\n", out.Period) + _, _ = fmt.Fprintf(w, " %s\n", auditTint(fmt.Sprintf("Graycode Usage Statistics (%s)", out.Period), graycodeColor)) _, _ = fmt.Fprintf(w, "══════════════════════════════════════════════════\n") // Overview section _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Overview ───\n") - _, _ = fmt.Fprintf(w, " Sessions: %d\n", out.TotalSessions) - _, _ = fmt.Fprintf(w, " Messages: %d\n", out.TotalMessages) - _, _ = fmt.Fprintf(w, " Tool calls: %d\n", out.TotalToolCalls) - _, _ = fmt.Fprintf(w, " Active days: %d\n", out.ActiveDays) + _, _ = fmt.Fprintf(w, "─── %s ───\n", auditTint("Overview", infoSky)) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint("Sessions:", textMuted), out.TotalSessions) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint("Messages:", textMuted), out.TotalMessages) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint("Tool calls:", textMuted), out.TotalToolCalls) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint("Active days:", textMuted), out.ActiveDays) // Cost section _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Cost ───\n") - _, _ = fmt.Fprintf(w, " Total cost: $%.4f\n", out.TotalCostUSD) - _, _ = fmt.Fprintf(w, " Avg cost/session: $%.4f\n", out.AvgCostPerSession) - _, _ = fmt.Fprintf(w, " Avg cost/day: $%.4f\n", out.AvgCostPerDay) + _, _ = fmt.Fprintf(w, "─── %s ───\n", auditTint("Cost", infoSky)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("Total cost:", textMuted), auditTint(fmt.Sprintf("$%.4f", out.TotalCostUSD), costViolet)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("Avg cost/session:", textMuted), auditTint(fmt.Sprintf("$%.4f", out.AvgCostPerSession), costViolet)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("Avg cost/day:", textMuted), auditTint(fmt.Sprintf("$%.4f", out.AvgCostPerDay), costViolet)) // Models section if statsModels && len(out.Models) > 0 { _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Models ───\n") - _, _ = fmt.Fprintf(w, " %-30s %8s %10s\n", "MODEL", "REQUESTS", "COST") - _, _ = fmt.Fprintf(w, " %-30s %8s %10s\n", strings.Repeat("─", 30), strings.Repeat("─", 8), strings.Repeat("─", 10)) + _, _ = fmt.Fprintf(w, "─── %s ───\n", auditTint("Models", infoSky)) + _, _ = fmt.Fprintf(w, " %s\n", auditTint(fmt.Sprintf("%-30s %8s %10s", "MODEL", "REQUESTS", "COST"), textMuted)) + _, _ = fmt.Fprintf(w, " %s\n", auditTint(fmt.Sprintf("%-30s %8s %10s", strings.Repeat("─", 30), strings.Repeat("─", 8), strings.Repeat("─", 10)), textMuted)) // Sort models by cost descending type modelEntry struct { @@ -219,14 +219,14 @@ func printStatsText(cmd *cobra.Command, out *statsOutput) { }) for _, m := range models { - _, _ = fmt.Fprintf(w, " %-30s %8d %10s\n", m.name, m.stat.Requests, fmt.Sprintf("$%.4f", m.stat.CostUSD)) + _, _ = fmt.Fprintf(w, " %-30s %8d %10s\n", m.name, m.stat.Requests, auditTint(fmt.Sprintf("$%.4f", m.stat.CostUSD), costViolet)) } } // Top Tools section if len(out.TopTools) > 0 { _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Top Tools ───\n") + _, _ = fmt.Fprintf(w, "─── %s ───\n", auditTint("Top Tools", infoSky)) limit := statsTop if limit > len(out.TopTools) { @@ -249,7 +249,7 @@ func printStatsText(cmd *cobra.Command, out *statsOutput) { barLen = 1 } bar := strings.Repeat("█", barLen) - _, _ = fmt.Fprintf(w, " %-20s %s %d\n", t.Name, bar, t.Count) + _, _ = fmt.Fprintf(w, " %-20s %s %d\n", t.Name, auditTint(bar, successTeal), t.Count) } } From 19b19601ea7f94113da6e4aef9430c5d011f3187 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:05:45 +0530 Subject: [PATCH 024/116] feat: theme the doctor health check report Green check / coral close / amber alert statuses, textPrimary names, and status-colored messages. Honors ShouldColor() so piped output stays plain. --- cmd/diagnostics.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go index f47fef38..d534f41b 100644 --- a/cmd/diagnostics.go +++ b/cmd/diagnostics.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "image/color" "os" "os/exec" "path/filepath" @@ -172,13 +173,16 @@ func healthCheckReport(settings graycodeconfig.Settings, provider string) string var b strings.Builder b.WriteString("Health checks:\n") for _, check := range results { - status := icons.CheckBold() + " " + status := auditTint(icons.CheckBold()+" ", doneGreen) + var msgColor color.Color = textMuted if check.Status == health.Unhealthy { - status = icons.CloseThick() + " " + status = auditTint(icons.CloseThick()+" ", errorCoral) + msgColor = errorCoral } else if check.Status == health.Degraded { - status = icons.Alert() + " " + status = auditTint(icons.Alert()+" ", warnAmber) + msgColor = warnAmber } - b.WriteString(fmt.Sprintf(" %s %s: %s\n", status, check.Name, check.Message)) + b.WriteString(fmt.Sprintf(" %s %s: %s\n", status, auditTint(check.Name, textPrimary), auditTint(check.Message, msgColor))) } return strings.TrimRight(b.String(), "\n") } From 62a5f47fd2b7eb8815149c4a9565b53452507d81 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:07:37 +0530 Subject: [PATCH 025/116] feat: theme the status snapshot report Gold title, muted labels, textPrimary values. Honors ShouldColor() so piped/JSON output stays plain. --- cmd/status_snapshot.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/cmd/status_snapshot.go b/cmd/status_snapshot.go index 882d9f78..b3a3bcae 100644 --- a/cmd/status_snapshot.go +++ b/cmd/status_snapshot.go @@ -85,11 +85,23 @@ func formatStatusSnapshot(s status.Snapshot) string { if s.Permission.SandboxBackend != "" { backend = " (" + s.Permission.SandboxBackend + ")" } - return fmt.Sprintf("Graycode status\nSchema: %s\nWorkspace: %s\nGit branch: %s\nProvider: %s\nModel: %s\nAutonomy tier: %s\nSandbox: %s%s\nPermission rules: %d\nMCP: %d configured (%s)\nSkills: %d (%s)\nSecrets redacted: %t\n", - s.SchemaVersion, s.Workspace, s.GitBranch, s.Provider, s.Model, - s.Permission.AutonomyTier, s.Permission.SandboxMode, backend, - s.Permission.EffectiveRules, s.MCP.Configured, s.MCP.State, - s.Skills.Configured, s.Skills.State, s.Permission.SecretRedacted) + line := func(label, val string) string { + return fmt.Sprintf("%s: %s\n", auditTint(label, textMuted), auditTint(val, textPrimary)) + } + var b strings.Builder + b.WriteString(auditTint("Graycode status", graycodeColor) + "\n") + b.WriteString(line("Schema", s.SchemaVersion)) + b.WriteString(line("Workspace", s.Workspace)) + b.WriteString(line("Git branch", s.GitBranch)) + b.WriteString(line("Provider", s.Provider)) + b.WriteString(line("Model", s.Model)) + b.WriteString(line("Autonomy tier", s.Permission.AutonomyTier)) + b.WriteString(line("Sandbox", s.Permission.SandboxMode+backend)) + b.WriteString(line("Permission rules", fmt.Sprintf("%d", s.Permission.EffectiveRules))) + b.WriteString(line("MCP", fmt.Sprintf("%d configured (%s)", s.MCP.Configured, s.MCP.State))) + b.WriteString(line("Skills", fmt.Sprintf("%d (%s)", s.Skills.Configured, s.Skills.State))) + b.WriteString(line("Secrets redacted", fmt.Sprintf("%t", s.Permission.SecretRedacted))) + return strings.TrimRight(b.String(), "\n") } func init() { From c52af3320b65b5389bd90dd6c9da926d0b8dbdee Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:08:20 +0530 Subject: [PATCH 026/116] feat: theme the security log verify result Green OK marker, textPrimary count, muted path. Honors ShouldColor(). --- cmd/securitylog_cmd.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/securitylog_cmd.go b/cmd/securitylog_cmd.go index 3bd32ea9..3a128a33 100644 --- a/cmd/securitylog_cmd.go +++ b/cmd/securitylog_cmd.go @@ -47,7 +47,10 @@ var securitylogVerifyCmd = &cobra.Command{ if err != nil { return fmt.Errorf("security log verification FAILED: %w", err) } - cmd.Printf("security event log OK: %d entries verified (%s)\n", count, dir) + cmd.Printf("%s %s (%s)\n", + auditTint("security event log OK:", doneGreen), + auditTint(fmt.Sprintf("%d entries verified", count), textPrimary), + auditTint(dir, textMuted)) return nil }, } From 28f43b254d9b6df1f7877d9d0cd9055c9c42ee59 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:10:16 +0530 Subject: [PATCH 027/116] feat: theme the review status report Muted label, status-colored counts (open sky, passed green, fixed teal, failed coral), and severity-colored open-review lines. Honors ShouldColor(). --- cmd/review_read.go | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/cmd/review_read.go b/cmd/review_read.go index f9e996db..337cb37c 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + "image/color" "os" "strconv" "strings" @@ -10,6 +11,7 @@ import ( lipgloss "charm.land/lipgloss/v2" "github.com/spf13/cobra" + contracts "github.com/GrayCodeAI/graycode-cli/internal/contracts/types" "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" ) @@ -71,7 +73,7 @@ func runReviewStatus(_ *cobra.Command, _ []string) error { total += v } if total == 0 { - fmt.Println("No reviews yet. Run 'graycode review init' to start.") + fmt.Println(auditTint("No reviews yet. Run 'graycode review init' to start.", textMuted)) return nil } @@ -80,18 +82,18 @@ func runReviewStatus(_ *cobra.Command, _ []string) error { fixed := summary[ReviewStatusFixed] failed := summary[ReviewStatusFailed] - fmt.Printf("Reviews: %d total", total) + fmt.Printf("%s %s", auditTint("Reviews:", textMuted), auditTint(fmt.Sprintf("%d total", total), textPrimary)) if open > 0 { - fmt.Printf(" · %d open", open) + fmt.Printf(" · %s", auditTint(fmt.Sprintf("%d open", open), infoSky)) } if passed > 0 { - fmt.Printf(" · %d passed", passed) + fmt.Printf(" · %s", auditTint(fmt.Sprintf("%d passed", passed), doneGreen)) } if fixed > 0 { - fmt.Printf(" · %d fixed", fixed) + fmt.Printf(" · %s", auditTint(fmt.Sprintf("%d fixed", fixed), successTeal)) } if failed > 0 { - fmt.Printf(" · %d failed", failed) + fmt.Printf(" · %s", auditTint(fmt.Sprintf("%d failed", failed), errorCoral)) } fmt.Println() @@ -100,7 +102,15 @@ func runReviewStatus(_ *cobra.Command, _ []string) error { reviews, _ := store.ListOpen() fmt.Println() for _, r := range reviews { - fmt.Printf(" #%d %s [%s] %d findings\n", r.ID, r.SHA[:8], r.MaxSeverity, len(r.Findings)) + var sev color.Color = textPrimary + if parsed, err := contracts.ParseSeverityStrict(r.MaxSeverity); err == nil { + sev = reviewSeverityColor(parsed) + } + fmt.Printf(" %s %s %s %s\n", + auditTint(fmt.Sprintf("#%d", r.ID), textPrimary), + auditTint(r.SHA[:8], textMuted), + auditTint(fmt.Sprintf("[%s]", r.MaxSeverity), sev), + auditTint(fmt.Sprintf("%d findings", len(r.Findings)), textMuted)) } } return nil From c997d676ff3d6d944fe1818a3d41a2d4ab656a5d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:11:47 +0530 Subject: [PATCH 028/116] feat: theme the plugin status table Muted header, state-colored STATE column (active green, failed coral, disabled dim, discovered/loaded sky). Honors ShouldColor(). --- cmd/plugin_dynamic.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/cmd/plugin_dynamic.go b/cmd/plugin_dynamic.go index 29d2c49c..d6ec290f 100644 --- a/cmd/plugin_dynamic.go +++ b/cmd/plugin_dynamic.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + "image/color" "os" "path/filepath" "text/tabwriter" @@ -14,6 +15,20 @@ import ( var dynamicManager *plugin.DynamicPluginManager +// pluginStateColor maps a plugin lifecycle state to a theme color. +func pluginStateColor(state plugin.PluginState) color.Color { + switch state { + case plugin.StateActive: + return doneGreen + case plugin.StateFailed: + return errorCoral + case plugin.StateDisabled: + return textDisabled + default: // discovered, loaded + return infoSky + } +} + func getDynamicManager() *plugin.DynamicPluginManager { if dynamicManager == nil { dynamicManager = plugin.NewDynamicPluginManager(nil, nil, nil) @@ -75,7 +90,7 @@ var pluginStatusCmd = &cobra.Command{ statuses := dm.Status() if len(statuses) == 0 { - cmd.Println("No plugins discovered. Run 'graycode plugin install' to add plugins.") + cmd.Println(auditTint("No plugins discovered. Run 'graycode plugin install' to add plugins.", textMuted)) return nil } @@ -90,12 +105,12 @@ var pluginStatusCmd = &cobra.Command{ } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintf(w, "NAME\tVERSION\tSTATE\tTOOLS\tHOOKS\n"); err != nil { + if _, err := fmt.Fprintf(w, "%s\n", auditTint("NAME\tVERSION\tSTATE\tTOOLS\tHOOKS", textMuted)); err != nil { return err } for _, s := range statuses { if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\n", - s.Name, s.Version, s.State, s.ToolCount, s.HookCount); err != nil { + s.Name, s.Version, auditTint(string(s.State), pluginStateColor(s.State)), s.ToolCount, s.HookCount); err != nil { return err } } From 34e4ad6d8bde4a99b5557781b76acc7e34b8cc1c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:12:41 +0530 Subject: [PATCH 029/116] feat: theme the daemon status output Green running, muted not-running, amber unknown/stale; muted labels with textPrimary values. JSON output stays pure. --- cmd/daemon.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/daemon.go b/cmd/daemon.go index bce69d3d..cce70cb6 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -463,7 +463,7 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { if daemonJSON { fmt.Println(`{"status":"not running"}`) } else { - fmt.Println("Status: not running") + fmt.Println(auditTint("Status: not running", textMuted)) } return nil } @@ -477,7 +477,7 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { if daemonJSON { fmt.Println(`{"status":"unknown","error":"invalid PID file"}`) } else { - fmt.Println("Status: unknown (invalid PID file)") + fmt.Println(auditTint("Status: unknown (invalid PID file)", warnAmber)) } return nil } @@ -497,7 +497,7 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { if daemonJSON { fmt.Println(`{"status":"not running","error":"stale PID file"}`) } else { - fmt.Println("Status: not running (stale PID file)") + fmt.Println(auditTint("Status: not running (stale PID file)", warnAmber)) } _ = os.Remove(pidFile) return nil @@ -514,9 +514,9 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { return nil } - fmt.Printf("Status: running\n") - fmt.Printf(" PID: %d\n", info.PID) - fmt.Printf(" Address: http://%s\n", info.Addr) - fmt.Printf(" Started: %s\n", info.StartedAt) + fmt.Printf("%s\n", auditTint("Status: running", doneGreen)) + fmt.Printf(" %s %d\n", auditTint("PID:", textMuted), info.PID) + fmt.Printf(" %s %s\n", auditTint("Address:", textMuted), auditTint("http://"+info.Addr, textPrimary)) + fmt.Printf(" %s %s\n", auditTint("Started:", textMuted), auditTint(info.StartedAt, textPrimary)) return nil } From cefeaa86bbea02bef89b169b65386429b3a3acbc Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:15:56 +0530 Subject: [PATCH 030/116] feat: animate the models catalog flows models refresh (slow 60s network discover) and models list --live (live provider fetch) now show a CLIProgress step. JSON/raw list output stays pure. Honors --quiet/NO_COLOR. --- cmd/models.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmd/models.go b/cmd/models.go index a458c3c9..f59a94d0 100644 --- a/cmd/models.go +++ b/cmd/models.go @@ -38,10 +38,16 @@ var modelsRefreshCmd = &cobra.Command{ } ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) defer cancel() + prog := NewCLIProgress("Models", []string{"Discovering catalog"}) + defer prog.Abort() + prog.StartStep(0) summary, err := graycodeconfig.RefreshModelCatalogV1WithSettings(ctx, settings) if err != nil { + prog.FailStep(0, err.Error()) return err } + prog.CompleteStep(0) + prog.Done() cmd.Println(summary) return nil }, @@ -104,6 +110,15 @@ var modelsListCmd = &cobra.Command{ } ctx := cmd.Context() var models []graycodeconfig.EngineModel + // Only the live provider fetch is slow enough to animate, and only when + // the output is a human table (JSON/raw must stay pure). + animate := modelsListLive && !modelsListJSON && !modelsListRaw + var prog *CLIProgress + if animate { + prog = NewCLIProgress("Models", []string{"Fetching live models"}) + defer prog.Abort() + prog.StartStep(0) + } if modelsListLive { if providerName == "" { return fmt.Errorf("provider required with --live (e.g. graycode models list canopywave --live --json)") @@ -113,8 +128,15 @@ var modelsListCmd = &cobra.Command{ models, err = graycodeconfig.FetchModelsForProviderWithSettings(ctx, settings, providerName) } if err != nil { + if prog != nil { + prog.FailStep(0, err.Error()) + } return err } + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } if modelsListJSON || modelsListRaw { out, merr := marshalModelListJSON(models, modelsListRaw, modelsListLive) if merr != nil { From a55787fab8d8d1bad2b6c1e16695a0f2a29f3043 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:16:45 +0530 Subject: [PATCH 031/116] feat: animate doctor and preflight --live doctor (network health checks) and preflight --live (provider verification) now show a CLIProgress step. JSON output stays pure. Honors --quiet. --- cmd/root.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 9dd5bac4..47cd23c7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -562,7 +562,13 @@ var doctorCmd = &cobra.Command{ if doctorJSONFlag { cmd.Println(doctorOutput(settings)) } else { - cmd.Println(doctorReport(settings)) + prog := NewCLIProgress("Doctor", []string{"Running diagnostics"}) + defer prog.Abort() + prog.StartStep(0) + report := doctorReport(settings) + prog.CompleteStep(0) + prog.Done() + cmd.Println(report) } return nil }, @@ -591,7 +597,20 @@ var preflightCmd = &cobra.Command{ ctx, cancel = context.WithTimeout(ctx, limit) defer cancel() } + // Only the live provider verification is slow enough to animate, and + // only when the output is a human report (JSON must stay pure). + animate := preflightLiveFlag && !preflightJSON + var prog *CLIProgress + if animate { + prog = NewCLIProgress("Preflight", []string{"Verifying provider"}) + defer prog.Abort() + prog.StartStep(0) + } r := graycodeconfig.EnginePreflightReportWithSettings(ctx, settings, graycodeconfig.EnginePreflightOptions{VerifyLive: preflightLiveFlag}) + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } if preflightJSON { out, err := json.MarshalIndent(r, "", " ") if err != nil { From bef3b6435e976d2cfd2289295ba100b05a32f327 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:17:58 +0530 Subject: [PATCH 032/116] feat: theme the version line textPrimary name, gold version, muted build date. Honors ShouldColor(). --- cmd/version_display.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/version_display.go b/cmd/version_display.go index af49145e..75405ed9 100644 --- a/cmd/version_display.go +++ b/cmd/version_display.go @@ -13,9 +13,9 @@ func versionLine() string { if ver != "" && !strings.HasPrefix(ver, "v") && !strings.HasPrefix(ver, "V") { ver = "v" + ver } - line := "graycode " + ver + line := auditTint("graycode", textPrimary) + " " + auditTint(ver, graycodeColor) if d := strings.TrimSpace(buildDate); d != "" && d != "unknown" { - line += " (built " + d + ")" + line += auditTint(" (built "+d+")", textMuted) } return line } From e80073c818a51e4542a98d6352542197a22c33ed Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:19:14 +0530 Subject: [PATCH 033/116] feat: theme the governance explain decision Muted labels, textPrimary values, ALLOW green / DENY coral verdict. Honors ShouldColor(). --- cmd/governance_cmd.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/governance_cmd.go b/cmd/governance_cmd.go index 810fd7b8..3dbd647f 100644 --- a/cmd/governance_cmd.go +++ b/cmd/governance_cmd.go @@ -109,24 +109,26 @@ var governanceExplainCmd = &cobra.Command{ scoped = strings.Join(scopeNames(scopes), ", ") } verdict := "DENY" + verdictColor := errorCoral if dec.Allowed { verdict = "ALLOW" + verdictColor = doneGreen } - cmd.Printf("tool: %s\n", toolName) - cmd.Printf("scopes: %s\n", scoped) + cmd.Printf("%s %s\n", auditTint("tool:", textMuted), auditTint(toolName, textPrimary)) + cmd.Printf("%s %s\n", auditTint("scopes:", textMuted), auditTint(scoped, textPrimary)) if summary != "" { - cmd.Printf("summary: %s\n", summary) + cmd.Printf("%s %s\n", auditTint("summary:", textMuted), auditTint(summary, textPrimary)) } - cmd.Printf("decision: %s\n", verdict) - cmd.Printf("source: %s\n", dec.Source) + cmd.Printf("%s %s\n", auditTint("decision:", textMuted), auditTint(verdict, verdictColor)) + cmd.Printf("%s %s\n", auditTint("source:", textMuted), auditTint(dec.Source, textPrimary)) if dec.Scope != "" { - cmd.Printf("scope hit: %s\n", dec.Scope) + cmd.Printf("%s %s\n", auditTint("scope hit:", textMuted), auditTint(string(dec.Scope), textPrimary)) } if dec.Rule != "" { - cmd.Printf("rule: %s\n", dec.Rule) + cmd.Printf("%s %s\n", auditTint("rule:", textMuted), auditTint(dec.Rule, textPrimary)) } if dec.Reason != "" { - cmd.Printf("reason: %s\n", dec.Reason) + cmd.Printf("%s %s\n", auditTint("reason:", textMuted), auditTint(dec.Reason, textPrimary)) } return nil }, From 12d1022abe5dd90e876e7dc1f4d8a4d1b563a985 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:20:39 +0530 Subject: [PATCH 034/116] feat: theme context export and review hook install confirmations Green check + textPrimary message, muted hints. Honors ShouldColor(). --- cmd/review.go | 10 +++++----- cmd/root.go | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/review.go b/cmd/review.go index 2a9e031e..2182895a 100644 --- a/cmd/review.go +++ b/cmd/review.go @@ -61,7 +61,7 @@ func runReviewInit(_ *cobra.Command, _ []string) error { if _, err := os.Stat(hookPath); err == nil && !reviewInitForce { existing, _ := os.ReadFile(hookPath) // #nosec G304 -- hookPath built from internal hooksDir constant, not external input if strings.Contains(string(existing), "graycode review") { - fmt.Println(icons.CheckBold() + " graycode review hook already installed") + fmt.Println(auditTint(icons.CheckBold()+" ", doneGreen) + auditTint("graycode review hook already installed", textPrimary)) return nil } return fmt.Errorf("post-commit hook already exists at %s\nUse --force to overwrite, or manually add:\n %s", hookPath, strings.TrimSpace(hookScript)) @@ -72,10 +72,10 @@ func runReviewInit(_ *cobra.Command, _ []string) error { return fmt.Errorf("write hook: %w", err) } - fmt.Printf("%s Installed post-commit hook at %s\n", icons.CheckBold(), hookPath) - fmt.Println(" Every commit will now be reviewed automatically.") - fmt.Println(" View reviews: graycode review status") - fmt.Println(" Interactive: graycode review tui") + fmt.Println(auditTint(icons.CheckBold()+" ", doneGreen) + auditTint("Installed post-commit hook at ", textPrimary) + auditTint(hookPath, textMuted)) + fmt.Println(auditTint(" Every commit will now be reviewed automatically.", textMuted)) + fmt.Println(auditTint(" View reviews: graycode review status", textMuted)) + fmt.Println(auditTint(" Interactive: graycode review tui", textMuted)) return nil } diff --git a/cmd/root.go b/cmd/root.go index 47cd23c7..49f1d024 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -832,7 +832,7 @@ var contextCmd = &cobra.Command{ if err := ExportContextToFile("", contextFocus, contextOutput); err != nil { return err } - cmd.Println("Context exported to", contextOutput) + cmd.Println(auditTint("Context exported to", doneGreen) + " " + auditTint(contextOutput, textPrimary)) return nil } result, err := ExportContext("", contextFocus) From 8f37fba70b9747c442023dd7ac424895282e6666 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:21:43 +0530 Subject: [PATCH 035/116] feat: theme the learn command output Green check on lesson add, textPrimary store summary, gold category tags, muted labels. Honors ShouldColor(). --- cmd/learn_cmd.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/cmd/learn_cmd.go b/cmd/learn_cmd.go index cc263323..fd19eaa1 100644 --- a/cmd/learn_cmd.go +++ b/cmd/learn_cmd.go @@ -2,10 +2,12 @@ package cmd import ( "fmt" + "strconv" "strings" "time" "github.com/GrayCodeAI/graycode-cli/internal/engine" + "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" "github.com/spf13/cobra" ) @@ -46,7 +48,7 @@ var learnAddCmd = &cobra.Command{ } si := engine.NewSelfImprover() si.Learn(strings.TrimSpace(learnWhat), strings.TrimSpace(learnWhy), strings.TrimSpace(learnLesson), strings.TrimSpace(learnCategory)) - cmd.Printf("lesson added (category: %s)\n", learnCategory) + cmd.Println(auditTint(icons.CheckBold()+" ", doneGreen) + auditTint("lesson added (category: "+learnCategory+")", textPrimary)) return nil }, } @@ -68,11 +70,11 @@ var learnClearCmd = &cobra.Command{ si := engine.NewSelfImprover() n := len(si.Lessons("")) if n == 0 { - cmd.Println("no lessons to clear") + cmd.Println(auditTint("no lessons to clear", textMuted)) return nil } si.Clear() - cmd.Printf("cleared %d lesson(s)\n", n) + cmd.Println(auditTint("cleared "+strconv.Itoa(n)+" lesson(s)", textPrimary)) return nil }, } @@ -94,7 +96,7 @@ func runLearnList(cmd *cobra.Command) error { si := engine.NewSelfImprover() lessons := si.Lessons("") if len(lessons) == 0 { - cmd.Println("No lessons yet. Add one with: graycode learn add --what ... --lesson ...") + cmd.Println(auditTint("No lessons yet. Add one with: graycode learn add --what ... --lesson ...", textMuted)) return nil } @@ -107,7 +109,7 @@ func runLearnList(cmd *cobra.Command) error { for cat, count := range cats { catSummary = append(catSummary, fmt.Sprintf("%s (%d)", cat, count)) } - cmd.Printf("Lesson store: %d lesson(s) — %s\n", len(lessons), strings.Join(catSummary, ", ")) + cmd.Println(auditTint("Lesson store: "+strconv.Itoa(len(lessons))+" lesson(s) — "+strings.Join(catSummary, ", "), textPrimary)) start := 0 if learnLimit > 0 && len(lessons) > learnLimit { @@ -115,12 +117,12 @@ func runLearnList(cmd *cobra.Command) error { } cmd.Println() for _, e := range lessons[start:] { - cmd.Printf("[%s] %s\n", e.Category, e.What) - cmd.Printf(" lesson: %s\n", e.Lesson) + cmd.Printf("%s %s\n", auditTint("["+e.Category+"]", toolGold), auditTint(e.What, textPrimary)) + cmd.Printf("%s %s\n", auditTint(" lesson:", textMuted), auditTint(e.Lesson, textPrimary)) if learnAll && e.Why != "" { - cmd.Printf(" why: %s\n", e.Why) + cmd.Printf("%s %s\n", auditTint(" why:", textMuted), auditTint(e.Why, textPrimary)) } - cmd.Printf(" learned: %s\n", e.Timestamp.Format(time.RFC3339)) + cmd.Printf("%s %s\n", auditTint(" learned:", textMuted), auditTint(e.Timestamp.Format(time.RFC3339), textMuted)) } return nil } From bed35200a3934b5f1839d80b4775d0ea6eb6a884 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:22:32 +0530 Subject: [PATCH 036/116] feat: theme the governance show/validate reports Muted labels, textPrimary values, infoSky Capabilities header, allow green / deny coral action column, green valid marker. Honors ShouldColor(). --- cmd/governance_cmd.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/cmd/governance_cmd.go b/cmd/governance_cmd.go index 3dbd647f..461d6fdc 100644 --- a/cmd/governance_cmd.go +++ b/cmd/governance_cmd.go @@ -41,22 +41,22 @@ var governanceShowCmd = &cobra.Command{ if path == "" { path = governance.ManagedPolicyPath() } - cmd.Printf("Governance layer %q (%s)\n", layer.Name, path) - cmd.Printf("Fail-closed: %t\n", layer.FailClosed) + cmd.Printf("%s %s\n", auditTint("Governance layer", textMuted), auditTint(fmt.Sprintf("%q (%s)", layer.Name, path), textPrimary)) + cmd.Printf("%s %t\n", auditTint("Fail-closed:", textMuted), layer.FailClosed) if len(layer.DeniedTools) > 0 { - cmd.Printf("Denied tools: %s\n", sortedKeys(layer.DeniedTools)) + cmd.Printf("%s %s\n", auditTint("Denied tools:", textMuted), auditTint(sortedKeys(layer.DeniedTools), textPrimary)) } if len(layer.DeniedBash) > 0 { - cmd.Printf("Denied bash patterns: %s\n", strings.Join(layer.DeniedBash, ", ")) + cmd.Printf("%s %s\n", auditTint("Denied bash patterns:", textMuted), auditTint(strings.Join(layer.DeniedBash, ", "), textPrimary)) } if len(layer.SensitivePaths) > 0 { - cmd.Printf("Sensitive paths: %s\n", strings.Join(layer.SensitivePaths, ", ")) + cmd.Printf("%s %s\n", auditTint("Sensitive paths:", textMuted), auditTint(strings.Join(layer.SensitivePaths, ", "), textPrimary)) } if len(layer.Capabilities) == 0 { - cmd.Println("No capability rows.") + cmd.Println(auditTint("No capability rows.", textMuted)) return nil } - cmd.Println("\nCapabilities:") + cmd.Println(auditTint("\nCapabilities:", infoSky)) for _, cap := range layer.Capabilities { pattern := cap.Pattern if pattern == "" { @@ -66,7 +66,15 @@ var governanceShowCmd = &cobra.Command{ if cap.Reason != "" { reason = " (" + cap.Reason + ")" } - cmd.Printf(" %-8s %-20s %-12s %s\n", cap.Action, cap.Scope, pattern, reason) + actionColor := doneGreen + if cap.Action == governance.ActionDeny { + actionColor = errorCoral + } + cmd.Printf(" %s %-20s %-12s %s\n", + auditTint(fmt.Sprintf("%-8s", cap.Action), actionColor), + auditTint(string(cap.Scope), textPrimary), + auditTint(pattern, textMuted), + auditTint(reason, textMuted)) } return nil }, @@ -81,8 +89,9 @@ var governanceValidateCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("valid: %d capability row(s), fail_closed=%t (%s)\n", - len(layer.Capabilities), layer.FailClosed, args[0]) + cmd.Printf("%s %d capability row(s), fail_closed=%t (%s)\n", + auditTint("valid:", doneGreen), + len(layer.Capabilities), layer.FailClosed, auditTint(args[0], textMuted)) return nil }, } From af684b826948ca9e8fb1eecac88d5d29d700a4cd Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:24:06 +0530 Subject: [PATCH 037/116] feat: theme feedback, sandbox, cloud, and cost output Green confirmations, muted empty-states and hints, amber experimental banner. Honors ShouldColor(). --- cmd/cloud.go | 8 ++++---- cmd/cost.go | 18 +++++++++--------- cmd/feedback.go | 4 ++-- cmd/sandbox.go | 14 +++++++------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index b3cee47c..86e46811 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -27,7 +27,7 @@ var cloudConnectCmd = &cobra.Command{ if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { return err } - cmd.Println("Graycode Cloud connected. Usage synchronization is opt-in and fail-open.") + cmd.Println(auditTint("Graycode Cloud connected. Usage synchronization is opt-in and fail-open.", doneGreen)) return nil }, } @@ -81,7 +81,7 @@ var cloudLoginCmd = &cobra.Command{ if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { return err } - cmd.Printf("Graycode Cloud connected for project %s.\n", poll.ProjectID) + cmd.Println(auditTint("Graycode Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) return nil case "expired": return fmt.Errorf("graycode cloud device authorization expired") @@ -97,10 +97,10 @@ var cloudStatusCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, _ []string) error { client, cfg, err := cloud.LoadClient() if err != nil || !client.Enabled() { - cmd.Println("Graycode Cloud is not connected.") + cmd.Println(auditTint("Graycode Cloud is not connected.", textMuted)) return nil } - cmd.Printf("Graycode Cloud connected: %s (device %s, project %s)\n", cfg.Endpoint, cfg.DeviceID, cfg.ProjectID) + cmd.Println(auditTint("Graycode Cloud connected: ", doneGreen) + auditTint(cfg.Endpoint, textPrimary) + auditTint(fmt.Sprintf(" (device %s, project %s)", cfg.DeviceID, cfg.ProjectID), textMuted)) return nil }, } diff --git a/cmd/cost.go b/cmd/cost.go index 0b479fe6..3d24c0e6 100644 --- a/cmd/cost.go +++ b/cmd/cost.go @@ -51,20 +51,20 @@ var costAnalyzeCmd = &cobra.Command{ return nil } - cmd.Println("[Experimental] Cost tracking is not yet fully available.") + cmd.Println(auditTint("[Experimental] Cost tracking is not yet fully available.", warnAmber)) cmd.Println() if report.TotalSpend == 0 { - cmd.Println("No cost data collected in this session.") + cmd.Println(auditTint("No cost data collected in this session.", textMuted)) cmd.Println() - cmd.Println("Once session data integration is complete, the analyzer will support:") - cmd.Println(" - Spend breakdown by model and task type") - cmd.Println(" - Wasted spend detection (expensive models for simple tasks)") - cmd.Println(" - Abandoned output tracking") - cmd.Println(" - Model routing recommendations") - cmd.Println(" - Prompt caching suggestions") + cmd.Println(auditTint("Once session data integration is complete, the analyzer will support:", textMuted)) + cmd.Println(auditTint(" - Spend breakdown by model and task type", textMuted)) + cmd.Println(auditTint(" - Wasted spend detection (expensive models for simple tasks)", textMuted)) + cmd.Println(auditTint(" - Abandoned output tracking", textMuted)) + cmd.Println(auditTint(" - Model routing recommendations", textMuted)) + cmd.Println(auditTint(" - Prompt caching suggestions", textMuted)) cmd.Println() - cmd.Println("To track progress: https://github.com/GrayCodeAI/graycode-cli/issues") + cmd.Println(auditTint("To track progress: https://github.com/GrayCodeAI/graycode-cli/issues", textMuted)) return nil } diff --git a/cmd/feedback.go b/cmd/feedback.go index 93769876..d2b6d165 100644 --- a/cmd/feedback.go +++ b/cmd/feedback.go @@ -118,7 +118,7 @@ func saveFeedbackLocal(report FeedbackReport) error { return fmt.Errorf("write feedback: %w", err) } - fmt.Printf("Feedback saved to %s\n", path) + fmt.Println(auditTint("Feedback saved to ", doneGreen) + auditTint(path, textMuted)) return nil } @@ -153,7 +153,7 @@ func openFeedbackIssue(report FeedbackReport) error { return nil } - fmt.Println("Opened feedback issue in your browser.") + fmt.Println(auditTint("Opened feedback issue in your browser.", doneGreen)) return nil } diff --git a/cmd/sandbox.go b/cmd/sandbox.go index 75943497..6a3777c2 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -52,7 +52,7 @@ var sandboxDiffCmd = &cobra.Command{ sb := getSandbox() d := sb.Diff() if d == "" { - cmd.Println("No pending changes.") + cmd.Println(auditTint("No pending changes.", textMuted)) return } fmt.Print(d) @@ -65,21 +65,21 @@ var sandboxApplyCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { sb := getSandbox() if !sb.HasChanges() { - cmd.Println("No pending changes to apply.") + cmd.Println(auditTint("No pending changes to apply.", textMuted)) return nil } stats := sb.Stats() - cmd.Println(fmt.Sprintf("Applying %d change(s): +%d -%d lines, %d created, %d modified, %d deleted", + cmd.Println(auditTint(fmt.Sprintf("Applying %d change(s): +%d -%d lines, %d created, %d modified, %d deleted", stats.FilesCreated+stats.FilesModified+stats.FilesDeleted, stats.LinesAdded, stats.LinesRemoved, - stats.FilesCreated, stats.FilesModified, stats.FilesDeleted)) + stats.FilesCreated, stats.FilesModified, stats.FilesDeleted), textPrimary)) if err := sb.Apply(); err != nil { return fmt.Errorf("apply failed: %w", err) } - cmd.Println("All changes applied.") + cmd.Println(auditTint("All changes applied.", doneGreen)) return nil }, } @@ -90,11 +90,11 @@ var sandboxDiscardCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { sb := getSandbox() if !sb.HasChanges() { - cmd.Println("No pending changes to discard.") + cmd.Println(auditTint("No pending changes to discard.", textMuted)) return } sb.Discard() - cmd.Println("All pending changes discarded.") + cmd.Println(auditTint("All pending changes discarded.", doneGreen)) }, } From dd73274176722c50c65f422f902809f935e2182e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:24:45 +0530 Subject: [PATCH 038/116] feat: animate cloud login browser approval The device-login poll (up to 10 min waiting for browser approval) now shows a CLIProgress step with ETA. Approved/expired/error all resolve the step cleanly. Honors --quiet. --- cmd/cloud.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmd/cloud.go b/cmd/cloud.go index 86e46811..afc66dd0 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -62,30 +62,41 @@ var cloudLoginCmd = &cobra.Command{ if interval < time.Second { interval = 5 * time.Second } + prog := NewCLIProgress("Cloud", []string{"Waiting for browser approval"}) + defer prog.Abort() + prog.StartStep(0) for { poll, pollErr := client.PollDeviceLogin(ctx, start.DeviceCode) if pollErr != nil { + prog.FailStep(0, pollErr.Error()) return pollErr } switch poll.Status { case "pending": select { case <-ctx.Done(): + prog.FailStep(0, ctx.Err().Error()) return fmt.Errorf("waiting for browser approval: %w", ctx.Err()) case <-time.After(interval): } case "approved": if poll.Token == "" || poll.DeviceID == "" || poll.ProjectID == "" { + prog.FailStep(0, "incomplete device authorization") return fmt.Errorf("graycode cloud returned an incomplete device authorization") } if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { + prog.FailStep(0, err.Error()) return err } + prog.CompleteStep(0) + prog.Done() cmd.Println(auditTint("Graycode Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) return nil case "expired": + prog.FailStep(0, "device authorization expired") return fmt.Errorf("graycode cloud device authorization expired") default: + prog.FailStep(0, fmt.Sprintf("unknown status %q", poll.Status)) return fmt.Errorf("graycode cloud returned unknown device authorization status %q", poll.Status) } } From d7d443b2ccf888d0b8ccc6d3b4ca0463fc610844 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:26:00 +0530 Subject: [PATCH 039/116] feat: theme the update command output Green up-to-date, amber update-available with textPrimary version pair, muted URL, coral failure. Honors ShouldColor(). --- cmd/root.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 49f1d024..410339ae 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -466,7 +466,20 @@ var updateCmd = &cobra.Command{ if ver == "" { ver = "dev" } - cmd.Println(update.Summary(ver)) + release, err := update.Check(ver) + if err != nil { + cmd.Println(auditTint("Update check failed: "+err.Error(), errorCoral)) + return nil + } + if release == nil { + cmd.Println(auditTint("graycode is up to date ("+ver+")", doneGreen)) + return nil + } + cmd.Println(auditTint("Update available: ", warnAmber) + auditTint(ver+" -> "+release.TagName, textPrimary)) + cmd.Println(auditTint(release.URL, textMuted)) + cmd.Println() + cmd.Println(auditTint("Release notes:", textPrimary)) + cmd.Println(release.Body) return nil }, } From c25ed7f38a70d21032dc828c923acf3febdf0ca4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:27:00 +0530 Subject: [PATCH 040/116] feat: theme the recover command output textPrimary resume line with gold session id and muted message count, muted next-step hints. Honors ShouldColor(). --- cmd/root.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 410339ae..3f149404 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -897,8 +897,7 @@ Examples: return err } cmd.Println(note) - cmd.Printf("Resuming session %s (%d messages, %s/%s)\n", - s.ID, len(s.Messages), s.Provider, s.Model) + cmd.Println(auditTint("Resuming session ", textPrimary) + auditTint(s.ID, toolGold) + auditTint(fmt.Sprintf(" (%d messages, %s/%s)", len(s.Messages), s.Provider, s.Model), textMuted)) return resumeRecoveredSession(context.Background(), s.ID) } @@ -907,8 +906,8 @@ Examples: cmd.Println(session.FormatRecoveryCandidates(candidates)) if len(candidates) > 0 { - cmd.Println("Resume with: graycode recover ") - cmd.Println("Or launch TUI with: graycode --recover") + cmd.Println(auditTint("Resume with: graycode recover ", textMuted)) + cmd.Println(auditTint("Or launch TUI with: graycode --recover", textMuted)) } return nil }, From 8ff94f80bd2e1bb2005b0f4323ea90f63da221b4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:27:49 +0530 Subject: [PATCH 041/116] feat: theme the tape status/commit reports Muted labels with textPrimary values, green commit confirmation. JSON status stays pure. Honors ShouldColor(). --- cmd/tape.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/cmd/tape.go b/cmd/tape.go index f42a74d9..d2234335 100644 --- a/cmd/tape.go +++ b/cmd/tape.go @@ -68,17 +68,17 @@ func runTapeStatus(cmd *cobra.Command, args []string) error { } w := cmd.OutOrStdout() - _, _ = fmt.Fprintf(w, "path: %s\n", st.Path) - _, _ = fmt.Fprintf(w, "size: %d bytes\n", st.Size) - _, _ = fmt.Fprintf(w, "terminal: %dx%d\n", st.Cols, st.Rows) - _, _ = fmt.Fprintf(w, "captured: %s\n", time.UnixMilli(st.EpochMS).UTC().Format(time.RFC3339)) - _, _ = fmt.Fprintf(w, "version: %s\n", st.Version) - _, _ = fmt.Fprintf(w, "frames: %d\n", st.FrameCount) - _, _ = fmt.Fprintf(w, "stdout: %d bytes\n", st.StdoutBytes) - _, _ = fmt.Fprintf(w, "duration: %s\n", tapeDuration(st.DurationMS)) + _, _ = fmt.Fprintf(w, "%s %s\n", auditTint("path:", textMuted), auditTint(st.Path, textPrimary)) + _, _ = fmt.Fprintf(w, "%s %d bytes\n", auditTint("size:", textMuted), st.Size) + _, _ = fmt.Fprintf(w, "%s %dx%d\n", auditTint("terminal:", textMuted), st.Cols, st.Rows) + _, _ = fmt.Fprintf(w, "%s %s\n", auditTint("captured:", textMuted), auditTint(time.UnixMilli(st.EpochMS).UTC().Format(time.RFC3339), textPrimary)) + _, _ = fmt.Fprintf(w, "%s %s\n", auditTint("version:", textMuted), auditTint(st.Version, textPrimary)) + _, _ = fmt.Fprintf(w, "%s %d\n", auditTint("frames:", textMuted), st.FrameCount) + _, _ = fmt.Fprintf(w, "%s %d bytes\n", auditTint("stdout:", textMuted), st.StdoutBytes) + _, _ = fmt.Fprintf(w, "%s %s\n", auditTint("duration:", textMuted), auditTint(tapeDuration(st.DurationMS), textPrimary)) for _, k := range []string{"stdout", "stdin", "resize", "sigint", "marker"} { if n := st.Kinds[k]; n > 0 { - _, _ = fmt.Fprintf(w, " %-7s %d\n", k+":", n) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint(k+":", textMuted), n) } } return nil @@ -99,10 +99,10 @@ func runTapeCommit(cmd *cobra.Command, args []string) error { return err } w := cmd.OutOrStdout() - _, _ = fmt.Fprintf(w, "committed %s\n", c.Name) - _, _ = fmt.Fprintf(w, " id: %s\n", c.CommitID) - _, _ = fmt.Fprintf(w, " tape: %s\n", c.Path) - _, _ = fmt.Fprintf(w, " meta: %s\n", c.MetaPath) + _, _ = fmt.Fprintf(w, "%s\n", auditTint("committed "+c.Name, doneGreen)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("id:", textMuted), auditTint(c.CommitID, textPrimary)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("tape:", textMuted), auditTint(c.Path, textPrimary)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("meta:", textMuted), auditTint(c.MetaPath, textPrimary)) return nil } From 72f02b4bbee95d2f16bff3aa3ed1b4736f03cb4e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:28:36 +0530 Subject: [PATCH 042/116] feat: theme the config update confirmations Green updated markers with textPrimary key names. Honors ShouldColor(). --- cmd/root.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 3f149404..350eded3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -670,7 +670,7 @@ var configCmd = &cobra.Command{ if err := graycodeconfig.SetGlobalSetting(args[1], strings.Join(args[2:], " ")); err != nil { return err } - cmd.Println("updated", args[1]) + cmd.Println(auditTint("updated ", doneGreen) + auditTint(args[1], textPrimary)) return nil case "provider": if len(args) < 2 { @@ -679,7 +679,7 @@ var configCmd = &cobra.Command{ if err := graycodeconfig.SetGlobalSetting("provider", strings.Join(args[1:], " ")); err != nil { return err } - cmd.Println("updated provider") + cmd.Println(auditTint("updated provider", doneGreen)) return nil case "model": if len(args) < 2 { @@ -688,7 +688,7 @@ var configCmd = &cobra.Command{ if err := graycodeconfig.SetGlobalSetting("model", strings.Join(args[1:], " ")); err != nil { return err } - cmd.Println("updated model") + cmd.Println(auditTint("updated model", doneGreen)) return nil case "keys": cmd.Println(apiKeyConfigSummary()) From 41b5b620ea632426d9e496eda284eab9ad7d8462 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:29:34 +0530 Subject: [PATCH 043/116] feat: theme the plugin command confirmations Green activate/install/scaffold, textPrimary deactivate/reload/uninstall, muted scaffold file listing. Honors ShouldColor(). --- cmd/plugin_dynamic.go | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd/plugin_dynamic.go b/cmd/plugin_dynamic.go index d6ec290f..46086364 100644 --- a/cmd/plugin_dynamic.go +++ b/cmd/plugin_dynamic.go @@ -47,7 +47,7 @@ var pluginActivateCmd = &cobra.Command{ if err := dm.Activate(name); err != nil { return fmt.Errorf("activate plugin %q: %w", name, err) } - cmd.Printf("Plugin %q activated.\n", name) + cmd.Printf("%s\n", auditTint("Plugin "+name+" activated.", doneGreen)) return nil }, } @@ -62,7 +62,7 @@ var pluginDeactivateCmd = &cobra.Command{ if err := dm.Deactivate(name); err != nil { return fmt.Errorf("deactivate plugin %q: %w", name, err) } - cmd.Printf("Plugin %q deactivated.\n", name) + cmd.Printf("%s\n", auditTint("Plugin "+name+" deactivated.", textPrimary)) return nil }, } @@ -77,7 +77,7 @@ var pluginReloadCmd = &cobra.Command{ if err := dm.Reload(name); err != nil { return fmt.Errorf("reload plugin %q: %w", name, err) } - cmd.Printf("Plugin %q reloaded.\n", name) + cmd.Printf("%s\n", auditTint("Plugin "+name+" reloaded.", textPrimary)) return nil }, } @@ -134,7 +134,7 @@ var pluginInstallDynamicCmd = &cobra.Command{ if err := plugin.Install(source); err != nil { return err } - cmd.Printf("Installed plugin from %s.\n", source) + cmd.Printf("%s\n", auditTint("Installed plugin from "+source+".", doneGreen)) return nil } @@ -143,7 +143,7 @@ var pluginInstallDynamicCmd = &cobra.Command{ if err := dm.InstallFromGitHub(source); err != nil { return err } - cmd.Printf("Installed plugin from %s.\n", source) + cmd.Printf("%s\n", auditTint("Installed plugin from "+source+".", doneGreen)) // Re-discover _ = dm.DiscoverAll() @@ -185,7 +185,7 @@ var pluginUninstallCmd = &cobra.Command{ if err := dm.Uninstall(name); err != nil { return err } - cmd.Printf("Plugin %q uninstalled.\n", name) + cmd.Printf("%s\n", auditTint("Plugin "+name+" uninstalled.", textPrimary)) return nil }, } @@ -329,19 +329,19 @@ See `+"`plugin.json`"+` for the full manifest configuration. // #nosec G306 _ = os.WriteFile(filepath.Join(dir, "mcp.json"), []byte("{\n \"servers\": []\n}\n"), 0o644) - cmd.Printf("Created multi-component plugin scaffold at ./%s/\n", name) - cmd.Printf(" %s/plugin.json - Plugin manifest\n", name) - cmd.Printf(" %s/main.go - Plugin entrypoint\n", name) - cmd.Printf(" %s/skills/ - Bundled skills\n", name) - cmd.Printf(" %s/hooks/ - Hook scripts\n", name) - cmd.Printf(" %s/tools/ - Tool binaries\n", name) - cmd.Printf(" %s/mcp.json - MCP server specs\n", name) - cmd.Printf(" %s/README.md - Documentation\n", name) + cmd.Printf("%s\n", auditTint("Created multi-component plugin scaffold at ./"+name+"/", doneGreen)) + cmd.Printf("%s\n", auditTint(" "+name+"/plugin.json - Plugin manifest", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/main.go - Plugin entrypoint", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/skills/ - Bundled skills", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/hooks/ - Hook scripts", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/tools/ - Tool binaries", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/mcp.json - MCP server specs", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/README.md - Documentation", textMuted)) cmd.Println() - cmd.Printf("Next steps:\n") - cmd.Printf(" cd %s && go mod init %s\n", name, name) - cmd.Printf(" graycode plugin install ./%s\n", name) - cmd.Printf(" graycode plugin activate %s\n", name) + cmd.Printf("%s\n", auditTint("Next steps:", textPrimary)) + cmd.Printf("%s\n", auditTint(" cd "+name+" && go mod init "+name, textMuted)) + cmd.Printf("%s\n", auditTint(" graycode plugin install ./"+name, textMuted)) + cmd.Printf("%s\n", auditTint(" graycode plugin activate "+name, textMuted)) return nil }, } From a46873635157d840d1ee24fa8d80c53c531594e4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:30:19 +0530 Subject: [PATCH 044/116] feat: theme session export and migrate confirmations Green export/migrate markers with textPrimary ids, muted already-current message. Honors ShouldColor(). --- cmd/session_export.go | 2 +- cmd/session_migrate.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/session_export.go b/cmd/session_export.go index b822dc28..ab43a18f 100644 --- a/cmd/session_export.go +++ b/cmd/session_export.go @@ -44,7 +44,7 @@ var sessionExportCmd = &cobra.Command{ if err := os.WriteFile(exportOutput, data, 0o600); err != nil { return fmt.Errorf("write output file: %w", err) } - cmd.Printf("Session exported to %s\n", exportOutput) + cmd.Printf("%s\n", auditTint("Session exported to ", doneGreen)+auditTint(exportOutput, textPrimary)) return nil }, } diff --git a/cmd/session_migrate.go b/cmd/session_migrate.go index 7beebf09..452949ee 100644 --- a/cmd/session_migrate.go +++ b/cmd/session_migrate.go @@ -62,9 +62,9 @@ func runSessionMigrate(cmd *cobra.Command, args []string) error { } if res.FromVersion >= res.ToVersion { - cmd.Println(fmt.Sprintf("Session %s is already at the current format (v%d).", res.ID, res.ToVersion)) + cmd.Println(auditTint(fmt.Sprintf("Session %s is already at the current format (v%d).", res.ID, res.ToVersion), textMuted)) } else { - cmd.Println(fmt.Sprintf("Migrated session %s from v%d to v%d (%d bytes).", res.ID, res.FromVersion, res.ToVersion, res.SizeBytes)) + cmd.Println(auditTint("Migrated session ", doneGreen) + auditTint(res.ID, textPrimary) + auditTint(fmt.Sprintf(" from v%d to v%d (%d bytes).", res.FromVersion, res.ToVersion, res.SizeBytes), textMuted)) } return nil } From fd81c76de71b752b9abe7d247a526667d489d404 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:31:30 +0530 Subject: [PATCH 045/116] feat: theme skills and curator output Muted empty-states and hints, textPrimary skill names, green pinned status and archive count. Honors ShouldColor(). --- cmd/skills_cmd.go | 4 ++-- cmd/skills_curator_cmd.go | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index 49646cea..818c97a6 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -60,7 +60,7 @@ var skillsSearchCmd = &cobra.Command{ return nil } if len(results) == 0 { - fmt.Println("No skills found.") + fmt.Println(auditTint("No skills found.", textMuted)) return nil } for _, e := range results { @@ -99,7 +99,7 @@ var skillsRemoveCmd = &cobra.Command{ if err := plugin.Remove(args[0]); err != nil { return err } - fmt.Printf("Removed skill %q.\n", args[0]) + fmt.Printf("%s\n", auditTint("Removed skill "+args[0]+".", textPrimary)) return nil }, } diff --git a/cmd/skills_curator_cmd.go b/cmd/skills_curator_cmd.go index 858f2c36..af0e24e6 100644 --- a/cmd/skills_curator_cmd.go +++ b/cmd/skills_curator_cmd.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "image/color" "path/filepath" "github.com/GrayCodeAI/graycode-cli/internal/intelligence/skillcurator" @@ -41,7 +42,7 @@ never-used skills, and pinned skills are left alone.`, return err } if len(skills) == 0 { - fmt.Println("No curated skills found.") + fmt.Println(auditTint("No curated skills found.", textMuted)) return nil } for _, s := range skills { @@ -49,7 +50,15 @@ never-used skills, and pinned skills are left alone.`, if !s.LastUsed.IsZero() { last = s.LastUsed.Format("2006-01-02") } - fmt.Printf("%-24s %-9s uses=%-4d last=%s\n", s.Name, s.Status, s.UseCount, last) + var statusColor color.Color = textMuted + if s.Status == "pinned" { + statusColor = doneGreen + } + fmt.Printf("%s %s %s %s\n", + auditTint(fmt.Sprintf("%-24s", s.Name), textPrimary), + auditTint(fmt.Sprintf("%-9s", s.Status), statusColor), + auditTint(fmt.Sprintf("uses=%-4d", s.UseCount), textMuted), + auditTint("last="+last, textMuted)) } return nil case "run": @@ -58,12 +67,12 @@ never-used skills, and pinned skills are left alone.`, return err } if len(archived) == 0 { - fmt.Println("Review complete: nothing to archive.") + fmt.Println(auditTint("Review complete: nothing to archive.", textMuted)) return nil } - fmt.Printf("Archived %d cold skill(s):\n", len(archived)) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Archived %d cold skill(s):", len(archived)), doneGreen)) for _, n := range archived { - fmt.Printf(" - %s (recoverable from .archive/)\n", n) + fmt.Printf("%s\n", auditTint(" - "+n+" (recoverable from .archive/)", textMuted)) } return nil case "pin": From 4e95595a7ccb0a1a9b3fc5fca2730062ab6817cd Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:33:25 +0530 Subject: [PATCH 046/116] feat: theme the features report Gold header, green ENABLED / muted DISABLED status, textPrimary flag names, muted metadata. Honors ShouldColor(). --- cmd/features.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cmd/features.go b/cmd/features.go index aaf94f8b..ba6fbe44 100644 --- a/cmd/features.go +++ b/cmd/features.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "image/color" "sort" "strings" @@ -49,21 +50,23 @@ Show a specific flag: } sort.Strings(names) - fmt.Println("Feature Flags:") + fmt.Println(auditTint("Feature Flags:", graycodeColor)) fmt.Println() for _, name := range names { f, _ := feature.Info(name) val := flags[name] status := "DISABLED" + var statusColor color.Color = textMuted if val { status = "ENABLED" + statusColor = doneGreen } - fmt.Printf(" %s = %v [%s]\n", name, val, status) + fmt.Printf(" %s = %v [%s]\n", auditTint(name, textPrimary), val, auditTint(status, statusColor)) if f != nil { - fmt.Printf(" default: %v\n", f.DefaultValue()) - fmt.Printf(" description: %s\n", f.Description()) + fmt.Printf("%s\n", auditTint(fmt.Sprintf(" default: %v", f.DefaultValue()), textMuted)) + fmt.Printf("%s\n", auditTint(" description: "+f.Description(), textMuted)) envVar := "GRAYCODE_FEATURE_" + strings.ReplaceAll(strings.ToUpper(name), "-", "_") - fmt.Printf(" env: %s\n", envVar) + fmt.Printf("%s\n", auditTint(" env: "+envVar, textMuted)) } fmt.Println() } From 68eeec16764a4fe0befeead62377afbed739a650 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:34:31 +0530 Subject: [PATCH 047/116] feat: theme review analyze/fix/refine output Green success and red failure markers, gold iteration headers, muted empty-states and hints, textPrimary status lines. Honors ShouldColor(). --- cmd/review_analyze.go | 16 ++++++++-------- cmd/review_fix.go | 6 +++--- cmd/review_refine.go | 16 ++++++++-------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cmd/review_analyze.go b/cmd/review_analyze.go index 513f02d2..75df03f0 100644 --- a/cmd/review_analyze.go +++ b/cmd/review_analyze.go @@ -120,7 +120,7 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { return fmt.Errorf("gather files: %w", err) } if content == "" { - fmt.Println("No files matched.") + fmt.Println(auditTint("No files matched.", textMuted)) return nil } @@ -155,7 +155,7 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { // Use the analysis prompt as a "diff" — kestrel will review it. analysisInput := fmt.Sprintf("# Analysis Type: %s\n\n%s\n\n---\n\n%s", analysisType, prompt, content) - fmt.Printf("Analyzing (%s)...\n", analysisType) + fmt.Printf("%s\n", auditTint("Analyzing ("+analysisType+")...", textPrimary)) result, err := bridge.ReviewContracts(ctx, analysisInput) if err != nil { return fmt.Errorf("analysis failed: %w", err) @@ -177,24 +177,24 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { // Print results. if len(result.Findings) == 0 { - fmt.Printf("%s No %s issues found.\n", icons.CheckBold(), analysisType) + fmt.Printf("%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint("No "+analysisType+" issues found.", doneGreen)) return nil } - fmt.Printf("%s %d %s finding(s):\n\n", icons.Alert(), len(result.Findings), analysisType) + fmt.Printf("%s %s\n\n", auditTint(icons.Alert(), warnAmber), auditTint(fmt.Sprintf("%d %s finding(s):", len(result.Findings), analysisType), textPrimary)) for i, f := range result.Findings { sev := severityStyle(f.Severity.String()) - fmt.Printf(" %d. %s %s:%d\n", i+1, sev, f.File, f.Line) - fmt.Printf(" %s\n", f.Message) + fmt.Printf(" %d. %s %s:%d\n", i+1, sev, auditTint(f.File, textPrimary), f.Line) + fmt.Printf(" %s\n", auditTint(f.Message, textMuted)) if f.Fix != "" { - fmt.Printf(" Fix: %s\n", f.Fix) + fmt.Printf(" %s\n", auditTint("Fix: "+f.Fix, textMuted)) } fmt.Println() } // Auto-fix if requested. if analyzeFix && len(result.Findings) > 0 { - fmt.Println("Applying fixes...") + fmt.Println(auditTint("Applying fixes...", textPrimary)) return autoFixAnalysis(result) } diff --git a/cmd/review_fix.go b/cmd/review_fix.go index 67a7a2cc..bbd1e011 100644 --- a/cmd/review_fix.go +++ b/cmd/review_fix.go @@ -60,16 +60,16 @@ func runReviewFix(_ *cobra.Command, args []string) error { } if len(reviews) == 0 { - fmt.Println("No open reviews to fix.") + fmt.Println(auditTint("No open reviews to fix.", textMuted)) return nil } for _, r := range reviews { if err := fixReview(store, r); err != nil { - fmt.Printf("%s Review #%d (%s): %v\n", icons.CloseThick(), r.ID, r.SHA[:8], err) + fmt.Printf("%s %s\n", auditTint(icons.CloseThick(), errorCoral), auditTint(fmt.Sprintf("Review #%d (%s): %v", r.ID, r.SHA[:8], err), errorCoral)) continue } - fmt.Printf("%s Review #%d (%s) fixed\n", icons.CheckBold(), r.ID, r.SHA[:8]) + fmt.Printf("%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("Review #%d (%s) fixed", r.ID, r.SHA[:8]), doneGreen)) } return nil } diff --git a/cmd/review_refine.go b/cmd/review_refine.go index 9bd6431a..bd69d426 100644 --- a/cmd/review_refine.go +++ b/cmd/review_refine.go @@ -62,14 +62,14 @@ func runReviewRefine(_ *cobra.Command, args []string) error { } if len(reviews) == 0 { - fmt.Println("No open reviews to refine.") + fmt.Println(auditTint("No open reviews to refine.", textMuted)) return nil } - fmt.Printf("Refining %d review(s), max %d iterations...\n\n", len(reviews), refineMaxIter) + fmt.Printf("%s\n\n", auditTint(fmt.Sprintf("Refining %d review(s), max %d iterations...", len(reviews), refineMaxIter), textPrimary)) for iter := 1; iter <= refineMaxIter; iter++ { - fmt.Printf("── Iteration %d/%d ──\n", iter, refineMaxIter) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("── Iteration %d/%d ──", iter, refineMaxIter), graycodeColor)) // Fix all open reviews. for _, r := range reviews { @@ -77,22 +77,22 @@ func runReviewRefine(_ *cobra.Command, args []string) error { continue } if err := fixReviewRefine(store, r); err != nil { - fmt.Printf(" %s #%d fix failed: %v\n", icons.CloseThick(), r.ID, err) + fmt.Printf(" %s %s\n", auditTint(icons.CloseThick(), errorCoral), auditTint(fmt.Sprintf("#%d fix failed: %v", r.ID, err), errorCoral)) } else { - fmt.Printf(" %s #%d fix applied\n", icons.CheckBold(), r.ID) + fmt.Printf(" %s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("#%d fix applied", r.ID), doneGreen)) } } // Wait briefly for hook to fire, then re-review the latest commit. latestSHA := getLatestCommitSHA() if latestSHA == "" { - fmt.Println(" Could not determine latest commit.") + fmt.Println(auditTint(" Could not determine latest commit.", textMuted)) break } - fmt.Printf(" Reviewing %s...\n", latestSHA[:8]) + fmt.Printf("%s\n", auditTint(" Reviewing "+latestSHA[:8]+"...", textPrimary)) if err := runReviewOnSHA(store, latestSHA); err != nil { - fmt.Printf(" %s Review failed: %v\n", icons.CloseThick(), err) + fmt.Printf(" %s %s\n", auditTint(icons.CloseThick(), errorCoral), auditTint("Review failed: "+err.Error(), errorCoral)) break } From 74e30dc3c98b83deb259d4468d72adde75dca1d2 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:35:15 +0530 Subject: [PATCH 048/116] feat: theme the daemon stop confirmation Green stopped-daemon confirmation. Honors ShouldColor(). --- cmd/daemon.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/daemon.go b/cmd/daemon.go index cce70cb6..778cf153 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -451,7 +451,7 @@ func runDaemonStop(_ *cobra.Command, _ []string) error { } _ = os.Remove(pidFile) - fmt.Printf("Stopped daemon (PID %d)\n", info.PID) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Stopped daemon (PID %d)", info.PID), doneGreen)) return nil } From cda483c064933fc07a93c6d19c080a1e0cf8326a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:35:56 +0530 Subject: [PATCH 049/116] feat: theme the credentials remove/migrate confirmations Green removed/migrated confirmations, muted already-secure message. Honors ShouldColor(). --- cmd/credentials.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/credentials.go b/cmd/credentials.go index 5ea3f048..a9cac742 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -34,7 +34,7 @@ var credentialsRemoveCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Removed %d key(s) from %s: %s\n", len(removed), graycodeconfig.CredentialStoreName(), strings.Join(removed, ", ")) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Removed %d key(s) from %s: %s", len(removed), graycodeconfig.CredentialStoreName(), strings.Join(removed, ", ")), doneGreen)) return nil }, } @@ -53,9 +53,9 @@ var credentialsMigrateCmd = &cobra.Command{ return err } if n == 0 { - cmd.Println("No plaintext credential files found (already using secure storage).") + cmd.Println(auditTint("No plaintext credential files found (already using secure storage).", textMuted)) } else { - cmd.Printf("Migrated %d key(s) to %s and removed plaintext credential files.\n", n, graycodeconfig.CredentialStoreName()) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Migrated %d key(s) to %s and removed plaintext credential files.", n, graycodeconfig.CredentialStoreName()), doneGreen)) } return nil }, From 37a8d4888f95ec6414100077ad02e63ad88e6f86 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:36:52 +0530 Subject: [PATCH 050/116] feat: theme marketplace and agent command output Green marketplace/agent create/install confirmations, muted empty-states and hints, muted agent show labels with textPrimary values and gold prompt header. Honors ShouldColor(). --- cmd/agent.go | 16 ++++++++-------- cmd/plugin_dynamic.go | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index 4e4c6549..44db7295 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -146,8 +146,8 @@ You are a specialized agent. Complete tasks according to your expertise. return err } - fmt.Printf("Created agent %q at %s\n", name, path) - fmt.Printf("Edit the file to customize the system prompt.\n") + fmt.Printf("%s\n", auditTint("Created agent "+name+" at ", doneGreen)+auditTint(path, textPrimary)) + fmt.Printf("%s\n", auditTint("Edit the file to customize the system prompt.", textMuted)) return nil } @@ -157,15 +157,15 @@ func runAgentShow(_ *cobra.Command, args []string) error { return err } - fmt.Printf("Name: %s\n", a.Name) - fmt.Printf("Description: %s\n", a.Description) + fmt.Printf("%s %s\n", auditTint("Name:", textMuted), auditTint(a.Name, textPrimary)) + fmt.Printf("%s %s\n", auditTint("Description:", textMuted), auditTint(a.Description, textPrimary)) model := a.Model if model == "" { model = "(inherit from session)" } - fmt.Printf("Model: %s\n", model) - fmt.Printf("File: %s\n", a.FilePath) - fmt.Printf("\n--- Prompt ---\n%s\n", a.Prompt) + fmt.Printf("%s %s\n", auditTint("Model:", textMuted), auditTint(model, textPrimary)) + fmt.Printf("%s %s\n", auditTint("File:", textMuted), auditTint(a.FilePath, textPrimary)) + fmt.Printf("\n%s\n%s\n", auditTint("--- Prompt ---", graycodeColor), a.Prompt) return nil } @@ -178,6 +178,6 @@ func runAgentRemove(_ *cobra.Command, args []string) error { if err := os.Remove(a.FilePath); err != nil { return fmt.Errorf("remove %s: %w", a.FilePath, err) } - fmt.Printf("Removed agent %q (%s)\n", a.Name, a.FilePath) + fmt.Printf("%s\n", auditTint("Removed agent "+a.Name+" ("+a.FilePath+")", textPrimary)) return nil } diff --git a/cmd/plugin_dynamic.go b/cmd/plugin_dynamic.go index 46086364..101c9315 100644 --- a/cmd/plugin_dynamic.go +++ b/cmd/plugin_dynamic.go @@ -426,8 +426,8 @@ var pluginMarketplaceListCmd = &cobra.Command{ return fmt.Errorf("fetch marketplace: %w (indexes may be unpublished; add a source with graycode plugin marketplace add)", err) } if len(entries) == 0 { - cmd.Println("No marketplace plugins found.") - cmd.Println("Add a source: graycode plugin marketplace add ") + cmd.Println(auditTint("No marketplace plugins found.", textMuted)) + cmd.Println(auditTint("Add a source: graycode plugin marketplace add ", textMuted)) return nil } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) @@ -460,7 +460,7 @@ var pluginMarketplaceInstallCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Installed %s to %s\n", entry.Name, dir) + cmd.Printf("%s\n", auditTint("Installed "+entry.Name+" to ", doneGreen)+auditTint(dir, textPrimary)) // re-discover _ = getDynamicManager().DiscoverAll() return nil @@ -475,7 +475,7 @@ var pluginMarketplaceAddCmd = &cobra.Command{ if err := plugin.AddSource(args[0], args[1]); err != nil { return err } - cmd.Printf("Added marketplace source %q → %s\n", args[0], args[1]) + cmd.Printf("%s\n", auditTint("Added marketplace source "+args[0]+" → ", doneGreen)+auditTint(args[1], textPrimary)) return nil }, } From d2cf75f08f89606e9122f96a157337a322789e05 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:37:41 +0530 Subject: [PATCH 051/116] feat: theme the plan command output Muted empty-states and hints, textPrimary plan titles, green done progress and task-done confirmation, gold section headers. Honors ShouldColor(). --- cmd/plan.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/plan.go b/cmd/plan.go index ec7496d7..528d8570 100644 --- a/cmd/plan.go +++ b/cmd/plan.go @@ -37,14 +37,14 @@ var planCreateCmd = &cobra.Command{ // Generate the plan prompt (would normally be sent to an LLM). prompt := planner.Generate(description, "") - cmd.Println("Plan prompt generated. Send this to an LLM to produce a plan:") - cmd.Println("--- System ---") + cmd.Println(auditTint("Plan prompt generated. Send this to an LLM to produce a plan:", textMuted)) + cmd.Println(auditTint("--- System ---", graycodeColor)) cmd.Println(prompt.System) cmd.Println() - cmd.Println("--- User ---") + cmd.Println(auditTint("--- User ---", graycodeColor)) cmd.Println(prompt.User) cmd.Println() - cmd.Println("Once you have the LLM response, save it with an explicit output path or import it into Graycode plans.") + cmd.Println(auditTint("Once you have the LLM response, save it with an explicit output path or import it into Graycode plans.", textMuted)) return nil }, } @@ -60,7 +60,7 @@ var planListCmd = &cobra.Command{ if planJSON { fmt.Println("[]") } else { - cmd.Println("No plans found. Create one with: graycode plan create ") + cmd.Println(auditTint("No plans found. Create one with: graycode plan create ", textMuted)) } return nil } @@ -90,7 +90,7 @@ var planListCmd = &cobra.Command{ } if len(plans) == 0 { - cmd.Println("No plans found. Create one with: graycode plan create ") + cmd.Println(auditTint("No plans found. Create one with: graycode plan create ", textMuted)) return nil } @@ -99,9 +99,9 @@ var planListCmd = &cobra.Command{ total := len(plan.Tasks) done := total - pending cmd.Println(fmt.Sprintf( - " %s [%d/%d done] %s", - plan.Title, - done, total, + " %s %s %s", + auditTint(plan.Title, textPrimary), + auditTint(fmt.Sprintf("[%d/%d done]", done, total), doneGreen), plan.Title, )) } @@ -184,7 +184,7 @@ followed by the task ID: graycode plan done `, return fmt.Errorf("write plan: %w", err) } - cmd.Println(fmt.Sprintf("Task %d marked as done.", taskID)) + cmd.Println(auditTint(fmt.Sprintf("Task %d marked as done.", taskID), doneGreen)) return nil }, } From 9b9177185b1d340fd16b848b31745275179f3fa1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 6 Sep 2026 23:38:29 +0530 Subject: [PATCH 052/116] feat: theme the trust command output Green trusted confirmation, textPrimary removed-trust, muted empty-state and enforcement, green/red trusted check value. Honors ShouldColor(). --- cmd/trust.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/cmd/trust.go b/cmd/trust.go index ac29c935..9b09fe9b 100644 --- a/cmd/trust.go +++ b/cmd/trust.go @@ -46,7 +46,7 @@ var trustAddCmd = &cobra.Command{ if err := s.Trust(path, reason); err != nil { return err } - cmd.Printf("Trusted %s\n", path) + cmd.Printf("%s\n", auditTint("Trusted ", doneGreen)+auditTint(path, textPrimary)) return nil }, } @@ -73,7 +73,7 @@ var trustRemoveCmd = &cobra.Command{ if err := s.Untrust(path); err != nil { return err } - cmd.Printf("Removed trust for %s\n", path) + cmd.Printf("%s\n", auditTint("Removed trust for ", textPrimary)+auditTint(path, textMuted)) return nil }, } @@ -93,8 +93,8 @@ var trustListCmd = &cobra.Command{ if trustListJSON { fmt.Println("[]") } else { - cmd.Println("No trusted directories.") - cmd.Printf("Folder trust enforcement: %v (GRAYCODE_Y0_FOLDER_TRUST)\n", flags.FolderTrust()) + cmd.Println(auditTint("No trusted directories.", textMuted)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Folder trust enforcement: %v (GRAYCODE_Y0_FOLDER_TRUST)", flags.FolderTrust()), textMuted)) } return nil } @@ -143,9 +143,13 @@ var trustCheckCmd = &cobra.Command{ } enforced := flags.FolderTrust() trusted := s.IsTrusted(path) - cmd.Printf("path: %s\n", path) - cmd.Printf("trusted: %v\n", trusted) - cmd.Printf("enforcement: %v\n", enforced) + cmd.Printf("%s %s\n", auditTint("path:", textMuted), auditTint(path, textPrimary)) + trustedColor := doneGreen + if !trusted { + trustedColor = errorCoral + } + cmd.Printf("%s %s\n", auditTint("trusted:", textMuted), auditTint(fmt.Sprintf("%v", trusted), trustedColor)) + cmd.Printf("%s %s\n", auditTint("enforcement:", textMuted), auditTint(fmt.Sprintf("%v", enforced), textPrimary)) if enforced && !trusted { return fmt.Errorf("not trusted") } From 8e500f6b9aa7cb6b646beebb115fffc4a28bac34 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:47:41 +0530 Subject: [PATCH 053/116] feat: theme the securitylog show and toolset output Severity-colored security events, muted timestamps/details, textPrimary summary. Honors ShouldColor(). --- cmd/securitylog_cmd.go | 25 ++++++++++++++++--------- cmd/toolset_cmd.go | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/cmd/securitylog_cmd.go b/cmd/securitylog_cmd.go index 3a128a33..aa2feda3 100644 --- a/cmd/securitylog_cmd.go +++ b/cmd/securitylog_cmd.go @@ -86,8 +86,8 @@ func runSecuritylogShow(cmd *cobra.Command, limit int, asJSON bool) error { } if len(events) == 0 { - cmd.Println("No security events recorded yet.") - cmd.Printf("Log location: %s\n", dir) + cmd.Println(auditTint("No security events recorded yet.", textMuted)) + cmd.Printf("%s\n", auditTint("Log location: "+dir, textMuted)) return nil } @@ -95,17 +95,24 @@ func runSecuritylogShow(cmd *cobra.Command, limit int, asJSON bool) error { if limit > 0 && len(events) > limit { start = len(events) - limit } - cmd.Printf("Security event log: %d event(s) at %s\n", len(events), dir) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Security event log: %d event(s) at %s", len(events), dir), textPrimary)) if start > 0 { - cmd.Printf("Showing the most recent %d:\n", len(events)-start) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Showing the most recent %d:", len(events)-start), textMuted)) } for _, ev := range events[start:] { + sevColor := infoSky + switch ev.Severity { + case securitylog.SeverityCritical: + sevColor = errorCoral + case securitylog.SeverityWarning: + sevColor = warnAmber + } cmd.Printf( - "%s %-8s %-20s %s\n", - ev.Timestamp.Format(time.RFC3339), - ev.Severity, - ev.Type, - truncateWithEllipsis(ev.Detail, 60), + "%s %s %s %s\n", + auditTint(ev.Timestamp.Format(time.RFC3339), textMuted), + auditTint(fmt.Sprintf("%-8s", ev.Severity), sevColor), + auditTint(fmt.Sprintf("%-20s", ev.Type), textPrimary), + auditTint(truncateWithEllipsis(ev.Detail, 60), textMuted), ) } return nil diff --git a/cmd/toolset_cmd.go b/cmd/toolset_cmd.go index 2b263b10..dd98a3d6 100644 --- a/cmd/toolset_cmd.go +++ b/cmd/toolset_cmd.go @@ -28,7 +28,7 @@ transitively (cycle-safe) and de-duplicates.`, return err } if len(args) == 0 { - fmt.Println("Available toolsets: " + strings.Join(reg.Names(), ", ")) + fmt.Println(auditTint("Available toolsets: ", textPrimary) + auditTint(strings.Join(reg.Names(), ", "), textMuted)) return nil } name := args[0] From dcfaf3e01f5f0b00d202677b372189354c26f443 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:48:38 +0530 Subject: [PATCH 054/116] feat: theme the harness auto-repair results Amber repair header, green repair items. Honors ShouldColor(). --- cmd/harness.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/harness.go b/cmd/harness.go index b119f39e..8f94e7a9 100644 --- a/cmd/harness.go +++ b/cmd/harness.go @@ -84,9 +84,9 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories if fixErr != nil { return fmt.Errorf("harness auto-fix failed: %w", fixErr) } - fmt.Printf("[FIX] Graycode Harness Auto-Repair Results:\n") + fmt.Printf("%s\n", auditTint("[FIX] Graycode Harness Auto-Repair Results:", warnAmber)) for _, repair := range fixResult.RepairsPerformed { - fmt.Printf(" + %s\n", repair) + fmt.Printf("%s\n", auditTint(" + "+repair, doneGreen)) } // Re-evaluate workspace after fix report, _ = harness.EvaluateWorkspace(ctx, targetDir, opts) From ffc5c86f1d1f9dcdebe8d1100b78dc8fb3f69580 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:49:48 +0530 Subject: [PATCH 055/116] feat: theme the checkpoint save/list/restore/delete output Green save/restore confirmations, muted resume hints and metadata, textPrimary checkpoint names. Honors ShouldColor(). --- cmd/checkpoint.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/cmd/checkpoint.go b/cmd/checkpoint.go index 2a506637..46b87b46 100644 --- a/cmd/checkpoint.go +++ b/cmd/checkpoint.go @@ -48,9 +48,8 @@ var checkpointSaveCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Saved checkpoint %q (session %s, %d messages, %s/%s)\n", - cp.Name, cp.Session.ID, len(cp.Session.Messages), cp.Session.Provider, cp.Session.Model) - cmd.Printf("Resume with: graycode resume %s\n", name) + cmd.Printf("%s\n", auditTint("Saved checkpoint ", doneGreen)+auditTint(cp.Name, textPrimary)+auditTint(fmt.Sprintf(" (session %s, %d messages, %s/%s)", cp.Session.ID, len(cp.Session.Messages), cp.Session.Provider, cp.Session.Model), textMuted)) + cmd.Printf("%s\n", auditTint("Resume with: graycode resume "+name, textMuted)) return nil }, } @@ -75,10 +74,10 @@ var checkpointListCmd = &cobra.Command{ return nil } if len(cps) == 0 { - cmd.Println("No named checkpoints.") + cmd.Println(auditTint("No named checkpoints.", textMuted)) return nil } - cmd.Printf("Named checkpoints (%d):\n", len(cps)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Named checkpoints (%d):", len(cps)), textPrimary)) now := time.Now() for _, cp := range cps { age := now.Sub(cp.CreatedAt).Round(time.Second) @@ -86,7 +85,7 @@ var checkpointListCmd = &cobra.Command{ if cp.Session != nil { msgs = len(cp.Session.Messages) } - cmd.Printf(" %-20s %d msgs (%s ago)\n", cp.Name, msgs, age) + cmd.Printf(" %s %s\n", auditTint(fmt.Sprintf("%-20s", cp.Name), textPrimary), auditTint(fmt.Sprintf("%d msgs (%s ago)", msgs, age), textMuted)) } return nil }, @@ -109,7 +108,7 @@ var checkpointDeleteCmd = &cobra.Command{ if err := session.DeleteNamedCheckpoint(args[0]); err != nil { return err } - cmd.Printf("Deleted checkpoint %q\n", args[0]) + cmd.Printf("%s\n", auditTint("Deleted checkpoint "+args[0], textPrimary)) return nil }, } @@ -142,9 +141,8 @@ func restoreNamedCheckpoint(cmd *cobra.Command, name string) error { if err := session.Save(cp.Session); err != nil { return fmt.Errorf("restore session: %w", err) } - cmd.Printf("Restored checkpoint %q into session %s (%d messages, %s/%s)\n", - cp.Name, cp.Session.ID, len(cp.Session.Messages), cp.Session.Provider, cp.Session.Model) - cmd.Printf("Continue with: graycode --resume %s\n", cp.Session.ID) + cmd.Printf("%s\n", auditTint("Restored checkpoint ", doneGreen)+auditTint(cp.Name, textPrimary)+auditTint(fmt.Sprintf(" into session %s (%d messages, %s/%s)", cp.Session.ID, len(cp.Session.Messages), cp.Session.Provider, cp.Session.Model), textMuted)) + cmd.Printf("%s\n", auditTint("Continue with: graycode --resume "+cp.Session.ID, textMuted)) return nil } From 1311c924b69d148de0e08e4676a3da2bfe4b3848 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:50:46 +0530 Subject: [PATCH 056/116] feat: theme the background session start/attach output Green session-started confirmation, muted hints, textPrimary attach lines with amber non-running status. Honors ShouldColor(). --- cmd/bg_sessions.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/bg_sessions.go b/cmd/bg_sessions.go index af1e0481..8c53ff0b 100644 --- a/cmd/bg_sessions.go +++ b/cmd/bg_sessions.go @@ -206,13 +206,13 @@ Examples: return err } - cmd.Printf("Background session started: %s (PID %d)\n", info.ID, info.PID) - cmd.Printf("View logs: tail -f %s\n", info.LogFile) + cmd.Printf("%s\n", auditTint("Background session started: ", doneGreen)+auditTint(info.ID, textPrimary)+auditTint(fmt.Sprintf(" (PID %d)", info.PID), textMuted)) + cmd.Printf("%s\n", auditTint("View logs: tail -f "+info.LogFile, textMuted)) attachID := info.ID if len(attachID) > 8 { attachID = attachID[:8] } - cmd.Printf("Attach: graycode attach %s\n", attachID) + cmd.Printf("%s\n", auditTint("Attach: graycode attach "+attachID, textMuted)) return nil }, } @@ -245,13 +245,13 @@ var attachCmd = &cobra.Command{ } if target.Status != "running" { - cmd.Printf("Session %s is %s\n", target.ID, target.Status) - cmd.Println("Recent log output:") + cmd.Printf("%s\n", auditTint("Session "+target.ID+" is ", textPrimary)+auditTint(target.Status, warnAmber)) + cmd.Println(auditTint("Recent log output:", textPrimary)) return tailLog(cmd, target.LogFile, 20) } - cmd.Printf("Attaching to session %s (PID %d)\n", target.ID, target.PID) - cmd.Println("Recent output:") + cmd.Printf("%s\n", auditTint("Attaching to session "+target.ID+" (PID "+fmt.Sprint(target.PID)+")", textPrimary)) + cmd.Println(auditTint("Recent output:", textPrimary)) return tailLog(cmd, target.LogFile, 30) }, } From aa37fae9bc3ac4336419bfad47bb39cd7a2c25e5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:51:41 +0530 Subject: [PATCH 057/116] feat: theme the rules detect/import/export output Green import/export confirmations, muted empty-states and format labels, textPrimary paths and rule names. Honors ShouldColor(). --- cmd/rules.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/rules.go b/cmd/rules.go index f63b60ce..927f4c84 100644 --- a/cmd/rules.go +++ b/cmd/rules.go @@ -42,13 +42,13 @@ var rulesDetectCmd = &cobra.Command{ } if len(found) == 0 { - cmd.Println("No AI tool rule files detected.") + cmd.Println(auditTint("No AI tool rule files detected.", textMuted)) return nil } - cmd.Println("Detected AI tool rule files:") + cmd.Println(auditTint("Detected AI tool rule files:", textPrimary)) for format, path := range found { - cmd.Println(fmt.Sprintf(" %-12s %s", format, path)) + cmd.Println(fmt.Sprintf(" %s %s", auditTint(fmt.Sprintf("%-12s", format), textMuted), auditTint(path, textPrimary))) } return nil }, @@ -69,7 +69,7 @@ var rulesImportCmd = &cobra.Command{ } if len(imported) == 0 { - cmd.Println(fmt.Sprintf("No rules found in %s format.", rulesImportFrom)) + cmd.Println(auditTint(fmt.Sprintf("No rules found in %s format.", rulesImportFrom), textMuted)) return nil } @@ -78,9 +78,9 @@ var rulesImportCmd = &cobra.Command{ return fmt.Errorf("export to graycode format failed: %w", err) } - cmd.Println(fmt.Sprintf("Imported %d rule(s) from %s to .agents/rules/.", len(imported), rulesImportFrom)) + cmd.Println(auditTint(fmt.Sprintf("Imported %d rule(s) from %s to .agents/rules/.", len(imported), rulesImportFrom), doneGreen)) for _, r := range imported { - cmd.Println(fmt.Sprintf(" - %s", r.Name)) + cmd.Println(auditTint(" - "+r.Name, textPrimary)) } return nil }, @@ -101,7 +101,7 @@ var rulesExportCmd = &cobra.Command{ } if len(graycodeRules) == 0 { - cmd.Println("No graycode rules found in .agents/rules/. Nothing to export.") + cmd.Println(auditTint("No graycode rules found in .agents/rules/. Nothing to export.", textMuted)) return nil } @@ -110,9 +110,9 @@ var rulesExportCmd = &cobra.Command{ return fmt.Errorf("export to %s format failed: %w", rulesExportTo, err) } - cmd.Println(fmt.Sprintf("Exported %d rule(s) to %s format.", len(graycodeRules), rulesExportTo)) + cmd.Println(auditTint(fmt.Sprintf("Exported %d rule(s) to %s format.", len(graycodeRules), rulesExportTo), doneGreen)) for _, r := range graycodeRules { - cmd.Println(fmt.Sprintf(" - %s", r.Name)) + cmd.Println(auditTint(" - "+r.Name, textPrimary)) } return nil }, From 27eb5b73336a89e69245b80dc70eed27dface870 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:52:31 +0530 Subject: [PATCH 058/116] feat: theme the snapshot list/restore/diff output Green restore confirmation, muted empty-states, status-colored diff rows, textPrimary hashes/files. Honors ShouldColor(). --- cmd/snapshot_cmd.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cmd/snapshot_cmd.go b/cmd/snapshot_cmd.go index 904197bd..86c63314 100644 --- a/cmd/snapshot_cmd.go +++ b/cmd/snapshot_cmd.go @@ -143,7 +143,7 @@ var snapshotListCmd = &cobra.Command{ if snapshotJSON { fmt.Println("[]") } else { - fmt.Println("No snapshots yet.") + fmt.Println(auditTint("No snapshots yet.", textMuted)) } return nil } @@ -156,7 +156,7 @@ var snapshotListCmd = &cobra.Command{ return nil } for _, p := range history { - fmt.Printf("%s %s %s\n", p.Hash, p.Timestamp.Format("2006-01-02 15:04:05"), p.Message) + fmt.Printf("%s %s %s\n", auditTint(p.Hash, textPrimary), auditTint(p.Timestamp.Format("2006-01-02 15:04:05"), textMuted), auditTint(p.Message, textPrimary)) } return nil }, @@ -175,7 +175,7 @@ var snapshotRestoreCmd = &cobra.Command{ if err := t.Restore(args[0]); err != nil { return err } - fmt.Printf("Restored to snapshot %s\n", args[0]) + fmt.Printf("%s\n", auditTint("Restored to snapshot ", doneGreen)+auditTint(args[0], textPrimary)) return nil }, } @@ -195,7 +195,7 @@ var snapshotDiffCmd = &cobra.Command{ if snapshotJSON { fmt.Println("[]") } else { - fmt.Println("No snapshots to diff against.") + fmt.Println(auditTint("No snapshots to diff against.", textMuted)) } return nil } @@ -207,7 +207,7 @@ var snapshotDiffCmd = &cobra.Command{ if snapshotJSON { fmt.Println("[]") } else { - fmt.Println("No changes.") + fmt.Println(auditTint("No changes.", textMuted)) } return nil } @@ -220,7 +220,14 @@ var snapshotDiffCmd = &cobra.Command{ return nil } for _, d := range diffs { - fmt.Printf("%s +%d -%d %s\n", d.Status, d.Additions, d.Deletions, d.File) + statusColor := warnAmber + switch d.Status { + case "added": + statusColor = doneGreen + case "deleted": + statusColor = errorCoral + } + fmt.Printf("%s %s %s\n", auditTint(d.Status, statusColor), auditTint(fmt.Sprintf("+%d -%d", d.Additions, d.Deletions), textMuted), auditTint(d.File, textPrimary)) } return nil }, From f6094a9643922967f1c723c989f03a3d4a470d99 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:53:34 +0530 Subject: [PATCH 059/116] feat: theme the eval command output Green cache-cleared and results-saved, textPrimary run/summary headers, green pass rates. Honors ShouldColor(). --- cmd/eval.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/cmd/eval.go b/cmd/eval.go index bbc1b21d..f60b6700 100644 --- a/cmd/eval.go +++ b/cmd/eval.go @@ -61,7 +61,7 @@ var evalCacheCmd = &cobra.Command{ if err := cache.Clear(); err != nil { return err } - fmt.Println("Cache cleared.") + fmt.Println(auditTint("Cache cleared.", doneGreen)) return nil }, } @@ -223,7 +223,7 @@ func runEval(_ *cobra.Command, _ []string) error { modelName = "default" } - fmt.Printf("Running %d tasks with model %s...\n", len(tasks), modelName) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Running %d tasks with model %s...", len(tasks), modelName), textPrimary)) suite := &eval.BenchmarkSuite{Name: "graycode-eval", Tasks: tasks} runner := eval.NewRunner(modelName, "") @@ -273,7 +273,7 @@ func runEval(_ *cobra.Command, _ []string) error { if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to save results: %v\n", err) } else { - fmt.Printf("Results saved to: %s\n", path) + fmt.Printf("%s\n", auditTint("Results saved to: ", doneGreen)+auditTint(path, textPrimary)) } // Group results @@ -349,7 +349,7 @@ func runEvalList(_ *cobra.Command, _ []string) error { return nil } - fmt.Printf("Available tasks (%d):\n\n", len(tasks)) + fmt.Printf("%s\n\n", auditTint(fmt.Sprintf("Available tasks (%d):", len(tasks)), textPrimary)) fmt.Println("| ID | Description | Tags |") fmt.Println("|----|-------------|------|") for _, t := range tasks { @@ -367,7 +367,7 @@ func runEvalResults(_ *cobra.Command, _ []string) error { return err } if len(files) == 0 { - fmt.Println("No saved results found.") + fmt.Println(auditTint("No saved results found.", textMuted)) return nil } @@ -388,16 +388,17 @@ func runEvalResults(_ *cobra.Command, _ []string) error { return nil } - fmt.Printf("Saved results (%d):\n\n", len(files)) + fmt.Printf("%s\n\n", auditTint(fmt.Sprintf("Saved results (%d):", len(files)), textPrimary)) for _, f := range files { r, err := store.Load(f) if err != nil { continue } - fmt.Printf(" %s %s %s %.0f%% (%d/%d)\n", - r.Timestamp.Format("2006-01-02 15:04"), - r.Model, r.Suite, - r.Summary.PassRate*100, r.Summary.Passed, r.Summary.TotalTasks) + fmt.Printf(" %s %s %s %s\n", + auditTint(r.Timestamp.Format("2006-01-02 15:04"), textMuted), + auditTint(r.Model, textPrimary), + auditTint(r.Suite, textPrimary), + auditTint(fmt.Sprintf("%.0f%% (%d/%d)", r.Summary.PassRate*100, r.Summary.Passed, r.Summary.TotalTasks), doneGreen)) } return nil } From d3ce7dba1f31739abd9416f1b10918e718dda085 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:54:51 +0530 Subject: [PATCH 060/116] feat: theme the cloud login device instructions URL in infoSky, one-time code in gold, muted browser-open fallback. Honors ShouldColor(). --- cmd/cloud.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index afc66dd0..86ff3d2e 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -54,9 +54,9 @@ var cloudLoginCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Open %s and enter code %s\n", start.VerificationURI, start.UserCode) + cmd.Printf("%s\n", auditTint("Open ", textPrimary)+auditTint(start.VerificationURI, infoSky)+auditTint(" and enter code ", textPrimary)+auditTint(start.UserCode, graycodeColor)) if err := openBrowser(start.VerificationURI + "?code=" + start.UserCode); err != nil { - cmd.Printf("Could not open the browser automatically: %v\n", err) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Could not open the browser automatically: %v", err), textMuted)) } interval := time.Duration(start.Interval) * time.Second if interval < time.Second { From 619c7dba5c251ca58b9b5555152329573f85cfcb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:55:45 +0530 Subject: [PATCH 061/116] feat: theme the feedback prompt and browser fallback textPrimary prompt, muted fallback label with infoSky URL. Honors ShouldColor(). --- cmd/feedback.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/feedback.go b/cmd/feedback.go index d2b6d165..3b61506c 100644 --- a/cmd/feedback.go +++ b/cmd/feedback.go @@ -62,7 +62,7 @@ func init() { func runFeedback(_ *cobra.Command, args []string) error { body := strings.Join(args, " ") if body == "" { - fmt.Println("Enter your feedback (press Ctrl+D when done):") + fmt.Println(auditTint("Enter your feedback (press Ctrl+D when done):", textPrimary)) data, err := readFeedbackStdin() if err != nil { return err @@ -149,7 +149,7 @@ func openFeedbackIssue(report FeedbackReport) error { if err := openBrowser(issueURL); err != nil { // Fallback: print the URL. - fmt.Printf("Could not open browser. Please visit:\n%s\n", issueURL) + fmt.Printf("%s\n%s\n", auditTint("Could not open browser. Please visit:", textMuted), auditTint(issueURL, infoSky)) return nil } From f9ab7efa3180bb2a3112ceafa3e9946d3a90c82c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:56:33 +0530 Subject: [PATCH 062/116] feat: theme the daemon stale-pid status line Amber stale-PID status, matching the other status branches. Honors ShouldColor(). --- cmd/daemon.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/daemon.go b/cmd/daemon.go index 778cf153..23c61a04 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -488,7 +488,7 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { if daemonJSON { fmt.Println(`{"status":"not running","error":"stale PID file"}`) } else { - fmt.Println("Status: not running (stale PID file)") + fmt.Println(auditTint("Status: not running (stale PID file)", warnAmber)) } _ = os.Remove(pidFile) return nil From 2459c0ed8d157136e75a27a41b4673497a5b2012 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 06:57:31 +0530 Subject: [PATCH 063/116] feat: theme the interrupted-session recovery notice Amber notice with textPrimary session ID and muted metadata. Honors ShouldColor(). --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 350eded3..81728257 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -195,7 +195,7 @@ Run graycode and use /config to set up your first provider.`, registeredProvider if len(candidates) > 0 { // Auto-resume the most recent interrupted session c := candidates[0] - fmt.Printf("Found interrupted session %s (%s, %d msgs)\n", c.SessionID, c.Interruption, c.MessageCount) + fmt.Printf("%s\n", auditTint("Found interrupted session ", warnAmber)+auditTint(c.SessionID, textPrimary)+auditTint(fmt.Sprintf(" (%s, %d msgs)", c.Interruption, c.MessageCount), textMuted)) resumeID = c.SessionID } } From 2c9e0ea57884f41f12c785ea6b0661e06683d3ad Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:00:23 +0530 Subject: [PATCH 064/116] feat: theme agent, cmdhistory, permissions, pr, issue, mission, plugin, governance, models output Green confirmations for saved/reset/created/posted actions, amber dry-run and not-installed notices, muted labels/empty-states, textPrimary values. Honors ShouldColor(). --- cmd/agent.go | 4 ++-- cmd/bg_sessions.go | 2 +- cmd/cloud.go | 2 +- cmd/cmdhistory_cmd.go | 18 +++++++++--------- cmd/eval_tools.go | 2 +- cmd/features.go | 10 +++++----- cmd/governance_cmd.go | 7 +++---- cmd/issue.go | 8 ++++---- cmd/mission.go | 20 +++++++++++--------- cmd/models.go | 4 ++-- cmd/permissions.go | 10 +++++----- cmd/plugin_dynamic.go | 28 ++++++++++++++-------------- cmd/pr.go | 10 +++++----- 13 files changed, 63 insertions(+), 62 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index 44db7295..c92ac8db 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -81,8 +81,8 @@ func runAgentList(cmd *cobra.Command, _ []string) error { return nil } if len(all) == 0 { - fmt.Printf("No agents found. Create one with: graycode agent create \n") - fmt.Printf("Agent directory: %s\n", agents.DefaultDir()) + fmt.Printf("%s\n", auditTint("No agents found. Create one with: graycode agent create ", textMuted)) + fmt.Printf("%s\n", auditTint("Agent directory: "+agents.DefaultDir(), textPrimary)) return nil } diff --git a/cmd/bg_sessions.go b/cmd/bg_sessions.go index 8c53ff0b..e0031de4 100644 --- a/cmd/bg_sessions.go +++ b/cmd/bg_sessions.go @@ -289,7 +289,7 @@ var sessionsKillCmd = &cobra.Command{ if err := KillBGSession(args[0]); err != nil { return err } - cmd.Println("Session killed:", args[0]) + cmd.Println(auditTint("Session killed: "+args[0], textPrimary)) return nil }, } diff --git a/cmd/cloud.go b/cmd/cloud.go index 86ff3d2e..f4f5347c 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -182,7 +182,7 @@ var cloudContextCmd = &cobra.Command{ event.Deployment = &cloud.DeploymentContext{Provider: contextProvider, ExternalID: deploymentID, Environment: deploymentEnvironment, Status: deploymentStatus} } client.RecordDeliveryContext(cmd.Context(), event) - cmd.Println("Repository context queued for Graycode Cloud.") + cmd.Println(auditTint("Repository context queued for Graycode Cloud.", doneGreen)) return nil }, } diff --git a/cmd/cmdhistory_cmd.go b/cmd/cmdhistory_cmd.go index 5bdc7a68..5265e748 100644 --- a/cmd/cmdhistory_cmd.go +++ b/cmd/cmdhistory_cmd.go @@ -60,7 +60,7 @@ var cmdHistorySearchCmd = &cobra.Command{ } if len(entries) == 0 { - cmd.Println("No matching commands found.") + cmd.Println(auditTint("No matching commands found.", textMuted)) return nil } @@ -97,7 +97,7 @@ var cmdHistoryRecentCmd = &cobra.Command{ } if len(entries) == 0 { - cmd.Println("No command history found.") + cmd.Println(auditTint("No command history found.", textMuted)) return nil } @@ -123,23 +123,23 @@ var cmdHistoryStatsCmd = &cobra.Command{ return fmt.Errorf("stats query failed: %w", err) } - cmd.Println(fmt.Sprintf("Total commands: %d", stats.TotalCommands)) - cmd.Println(fmt.Sprintf("Unique commands: %d", stats.UniqueCommands)) - cmd.Println(fmt.Sprintf("Success rate: %.1f%%", stats.SuccessRate*100)) + cmd.Println(auditTint("Total commands: ", textMuted) + auditTint(fmt.Sprintf("%d", stats.TotalCommands), textPrimary)) + cmd.Println(auditTint("Unique commands: ", textMuted) + auditTint(fmt.Sprintf("%d", stats.UniqueCommands), textPrimary)) + cmd.Println(auditTint("Success rate: ", textMuted) + auditTint(fmt.Sprintf("%.1f%%", stats.SuccessRate*100), textPrimary)) cmd.Println() if len(stats.TopCommands) > 0 { - cmd.Println("Top commands:") + cmd.Println(auditTint("Top commands:", textPrimary)) for _, tc := range stats.TopCommands { - cmd.Println(fmt.Sprintf(" %4d %s", tc.Count, tc.Command)) + cmd.Println(auditTint(fmt.Sprintf(" %4d %s", tc.Count, tc.Command), textMuted)) } cmd.Println() } if len(stats.TopDirectories) > 0 { - cmd.Println("Top directories:") + cmd.Println(auditTint("Top directories:", textPrimary)) for _, td := range stats.TopDirectories { - cmd.Println(fmt.Sprintf(" %4d %s", td.Count, td.Dir)) + cmd.Println(auditTint(fmt.Sprintf(" %4d %s", td.Count, td.Dir), textMuted)) } } diff --git a/cmd/eval_tools.go b/cmd/eval_tools.go index 489ae143..5e1d2c68 100644 --- a/cmd/eval_tools.go +++ b/cmd/eval_tools.go @@ -106,7 +106,7 @@ func runEvalTools(cmd *cobra.Command, _ []string) error { ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) defer cancel() - cmd.Printf("Evaluating tool selection on %d cases with model %s...\n", len(defaultToolUseCases()), model) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Evaluating tool selection on %d cases with model %s...", len(defaultToolUseCases()), model), textPrimary)) report := eval.ScoreToolUse(ctx, defaultToolUseCases(), caller) switch evalToolsOutput { diff --git a/cmd/features.go b/cmd/features.go index ba6fbe44..c3170023 100644 --- a/cmd/features.go +++ b/cmd/features.go @@ -34,12 +34,12 @@ Show a specific flag: if !ok { return fmt.Errorf("unknown feature flag: %s", args[1]) } - fmt.Printf("Name: %s\n", f.Name()) - fmt.Printf("Default: %v\n", f.DefaultValue()) - fmt.Printf("Current: %v\n", feature.EnabledByName(args[1])) - fmt.Printf("Description: %s\n", f.Description()) + fmt.Printf("%s\n", auditTint("Name: ", textMuted)+auditTint(f.Name(), textPrimary)) + fmt.Printf("%s\n", auditTint("Default: ", textMuted)+auditTint(fmt.Sprintf("%v", f.DefaultValue()), textPrimary)) + fmt.Printf("%s\n", auditTint("Current: ", textMuted)+auditTint(fmt.Sprintf("%v", feature.EnabledByName(args[1])), textPrimary)) + fmt.Printf("%s\n", auditTint("Description: ", textMuted)+auditTint(f.Description(), textPrimary)) envVar := "GRAYCODE_FEATURE_" + strings.ReplaceAll(strings.ToUpper(args[1]), "-", "_") - fmt.Printf("Env var: %s\n", envVar) + fmt.Printf("%s\n", auditTint("Env var: ", textMuted)+auditTint(envVar, textPrimary)) return nil } diff --git a/cmd/governance_cmd.go b/cmd/governance_cmd.go index 461d6fdc..a51f33ab 100644 --- a/cmd/governance_cmd.go +++ b/cmd/governance_cmd.go @@ -154,17 +154,16 @@ func init() { func runGovernanceStatus(cmd *cobra.Command) error { path := governance.ManagedPolicyPath() - cmd.Printf("Managed policy path: %s\n", path) + cmd.Printf("%s\n", auditTint("Managed policy path: ", textMuted)+auditTint(path, textPrimary)) if _, err := os.Stat(path); err != nil { - cmd.Println("Status: not installed (governance is fail-open; no ceiling enforced)") + cmd.Println(auditTint("Status: not installed (governance is fail-open; no ceiling enforced)", warnAmber)) return nil } layer, err := governance.LoadLayer("policy", path) if err != nil { return fmt.Errorf("managed policy is invalid: %w", err) } - cmd.Printf("Status: installed — fail_closed=%t, %d capability row(s), %d denied tool(s)\n", - layer.FailClosed, len(layer.Capabilities), len(layer.DeniedTools)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Status: installed — fail_closed=%t, %d capability row(s), %d denied tool(s)", layer.FailClosed, len(layer.Capabilities), len(layer.DeniedTools)), doneGreen)) return nil } diff --git a/cmd/issue.go b/cmd/issue.go index c226377e..bffd34d4 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -73,13 +73,13 @@ func runIssue(cmd *cobra.Command, args []string) error { _, _ = fmt.Fprintln(cmd.OutOrStdout()) return nil } - cmd.Println("Issue preview (dry run — not published)") - cmd.Println("Title: " + title) + cmd.Println(auditTint("Issue preview (dry run — not published)", warnAmber)) + cmd.Println(auditTint("Title: ", textMuted) + auditTint(title, textPrimary)) cmd.Println() cmd.Print(body) if len(issueLabels) > 0 { cmd.Println() - cmd.Println("Labels: " + strings.Join(issueLabels, ", ")) + cmd.Println(auditTint("Labels: ", textMuted) + auditTint(strings.Join(issueLabels, ", "), textPrimary)) } return nil } @@ -102,7 +102,7 @@ func runIssue(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("gh issue create failed: %w", err) } - cmd.Println("Issue created: " + strings.TrimSpace(string(out))) + cmd.Println(auditTint("Issue created: ", doneGreen) + auditTint(strings.TrimSpace(string(out)), textPrimary)) return nil } diff --git a/cmd/mission.go b/cmd/mission.go index d3e9ec5d..578749e7 100644 --- a/cmd/mission.go +++ b/cmd/mission.go @@ -87,7 +87,7 @@ func runMission(_ *cobra.Command, args []string) error { var waves [][]string if missionFromTasks { - fmt.Printf("Mission %s: loading validated task graph...\n", m.ID) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Mission %s: loading validated task graph...", m.ID), textPrimary)) features, taskWaves, err := missionFeaturesFromTasks(tool.GetTaskStore(), m.ID) if err != nil { return fmt.Errorf("task graph: %w", err) @@ -95,7 +95,7 @@ func runMission(_ *cobra.Command, args []string) error { m.Features = features waves = taskWaves } else { - fmt.Printf("Mission %s: planning...\n", m.ID) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Mission %s: planning...", m.ID), textPrimary)) planFn := func(ctx context.Context, p string) ([]mission.Feature, error) { return planWithLLM(ctx, p, effectiveProvider, effectiveModel, settings) } @@ -104,14 +104,14 @@ func runMission(_ *cobra.Command, args []string) error { } } - fmt.Printf("Mission %s: %d features planned\n", m.ID, len(m.Features)) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Mission %s: %d features planned", m.ID, len(m.Features)), textPrimary)) for i, f := range m.Features { - fmt.Printf(" %d. %s\n", i+1, f.Description) + fmt.Printf("%s\n", auditTint(fmt.Sprintf(" %d. %s", i+1, f.Description), textPrimary)) } fmt.Println() if missionDryRun { - fmt.Println("(dry-run: not executing workers)") + fmt.Println(auditTint("(dry-run: not executing workers)", textMuted)) return nil } @@ -124,7 +124,7 @@ func runMission(_ *cobra.Command, args []string) error { workerFn = graphTrackingWorker(tool.GetTaskStore(), workerFn) } - fmt.Printf("Executing with %d parallel workers...\n\n", cfg.MaxWorkers) + fmt.Printf("%s\n\n", auditTint(fmt.Sprintf("Executing with %d parallel workers...", cfg.MaxWorkers), textPrimary)) var runErr error if missionFromTasks { runErr = m.RunStaged(ctx, workerFn, mission.WithExecutionWaves(waves)) @@ -141,20 +141,22 @@ func runMission(_ *cobra.Command, args []string) error { fmt.Println() for _, f := range m.Features { status := icons.CheckBold() + " " + statusColor := doneGreen if f.Status == mission.FeatureFailed { status = icons.CloseThick() + " " + statusColor = errorCoral } branch := f.Branch if f.Handoff != nil && f.Handoff.CommitID != "" { branch += " (" + f.Handoff.CommitID[:7] + ")" } - fmt.Printf(" %s %s — %s\n", status, f.Description, branch) + fmt.Printf(" %s %s\n", auditTint(status, statusColor), auditTint(f.Description, textPrimary)+auditTint(" — "+branch, textMuted)) } if missionFromTasks && len(m.WaveJoins) > 0 { fmt.Println() - fmt.Println("Wave joins:") + fmt.Println(auditTint("Wave joins:", textPrimary)) for _, join := range m.WaveJoins { - fmt.Printf(" %d. %s\n", join.Wave, join.Summary) + fmt.Printf(" %s\n", auditTint(fmt.Sprintf("%d. %s", join.Wave, join.Summary), textMuted)) } } diff --git a/cmd/models.go b/cmd/models.go index f59a94d0..57b4eeca 100644 --- a/cmd/models.go +++ b/cmd/models.go @@ -145,9 +145,9 @@ var modelsListCmd = &cobra.Command{ cmd.Println(string(out)) return nil } - cmd.Printf("%d models", len(models)) + cmd.Printf("%s", auditTint(fmt.Sprintf("%d models", len(models)), textPrimary)) if providerName != "" { - cmd.Printf(" for provider %q", providerName) + cmd.Printf("%s", auditTint(fmt.Sprintf(" for provider %q", providerName), textMuted)) } cmd.Println() rows := make([]modelTableRow, len(models)) diff --git a/cmd/permissions.go b/cmd/permissions.go index 58f3f65f..44ab3ecc 100644 --- a/cmd/permissions.go +++ b/cmd/permissions.go @@ -55,7 +55,7 @@ var permissionsListCmd = &cobra.Command{ return err } if len(out) == 0 { - cmd.Println("No persisted permission rules.") + cmd.Println(auditTint("No persisted permission rules.", textMuted)) return nil } for _, rule := range out { @@ -93,7 +93,7 @@ var permissionsAddCmd = &cobra.Command{ if err := store.Save(); err != nil { return err } - cmd.Printf("Permission rule %d saved.\n", id) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Permission rule %d saved.", id), doneGreen)) return nil }, } @@ -117,7 +117,7 @@ var permissionsRevokeCmd = &cobra.Command{ if err := store.Save(); err != nil { return err } - cmd.Printf("Permission rule %d revoked.\n", id) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Permission rule %d revoked.", id), textPrimary)) return nil }, } @@ -134,13 +134,13 @@ var permissionsResetCmd = &cobra.Command{ return err } if !store.Reset() { - cmd.Println("No persisted permission rules.") + cmd.Println(auditTint("No persisted permission rules.", textMuted)) return nil } if err := store.Save(); err != nil { return err } - cmd.Println("Persisted permission rules reset.") + cmd.Println(auditTint("Persisted permission rules reset.", doneGreen)) return nil }, } diff --git a/cmd/plugin_dynamic.go b/cmd/plugin_dynamic.go index 101c9315..ee62c270 100644 --- a/cmd/plugin_dynamic.go +++ b/cmd/plugin_dynamic.go @@ -374,20 +374,20 @@ var pluginLogsCmd = &cobra.Command{ name := args[0] for _, s := range statuses { if s.Name == name { - cmd.Printf("Plugin: %s\n", s.Name) - cmd.Printf("State: %s\n", s.State) + cmd.Printf("%s\n", auditTint("Plugin: ", textMuted)+auditTint(s.Name, textPrimary)) + cmd.Printf("%s\n", auditTint("State: ", textMuted)+auditTint(string(s.State), pluginStateColor(s.State))) if s.Error != "" { - cmd.Printf("Error: %s\n", s.Error) + cmd.Printf("%s\n", auditTint("Error: ", textMuted)+auditTint(s.Error, errorCoral)) } if !s.ActivatedAt.IsZero() { - cmd.Printf("Activated: %s\n", s.ActivatedAt.Format(time.RFC3339)) + cmd.Printf("%s\n", auditTint("Activated: ", textMuted)+auditTint(s.ActivatedAt.Format(time.RFC3339), textPrimary)) } return nil } } return fmt.Errorf("plugin %q not found", name) } - cmd.Println("No recent plugin events.") + cmd.Println(auditTint("No recent plugin events.", textMuted)) return nil } @@ -510,20 +510,20 @@ var pluginInspectCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Root: %s\n", comp.Root) - cmd.Printf("Components: %s\n", comp.ComponentSummary()) - cmd.Printf("Tools: %v\n", comp.HasTools) - cmd.Printf("Skills (%d):\n", len(comp.Skills)) + cmd.Printf("%s\n", auditTint("Root: ", textMuted)+auditTint(comp.Root, textPrimary)) + cmd.Printf("%s\n", auditTint("Components: ", textMuted)+auditTint(comp.ComponentSummary(), textPrimary)) + cmd.Printf("%s\n", auditTint("Tools: ", textMuted)+auditTint(fmt.Sprintf("%v", comp.HasTools), textPrimary)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Skills (%d):", len(comp.Skills)), textPrimary)) for _, s := range comp.Skills { - cmd.Printf(" - %s\n", s) + cmd.Printf("%s\n", auditTint(" - "+s, textMuted)) } - cmd.Printf("Hooks (%d):\n", len(comp.HookFiles)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Hooks (%d):", len(comp.HookFiles)), textPrimary)) for _, h := range comp.HookFiles { - cmd.Printf(" - %s\n", h) + cmd.Printf("%s\n", auditTint(" - "+h, textMuted)) } - cmd.Printf("MCP servers (%d):\n", len(comp.MCPServers)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("MCP servers (%d):", len(comp.MCPServers)), textPrimary)) for _, m := range comp.MCPServers { - cmd.Printf(" - %s cmd=%s url=%s\n", m.Name, m.Command, m.URL) + cmd.Printf("%s\n", auditTint(fmt.Sprintf(" - %s cmd=%s url=%s", m.Name, m.Command, m.URL), textMuted)) } return nil }, diff --git a/cmd/pr.go b/cmd/pr.go index 106e9534..6c46b732 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -70,7 +70,7 @@ Otherwise, reviews the diff between the base branch and HEAD.`, } if strings.TrimSpace(diff) == "" { - cmd.Println("No changes found.") + cmd.Println(auditTint("No changes found.", textMuted)) return nil } @@ -81,7 +81,7 @@ Otherwise, reviews the diff between the base branch and HEAD.`, if err := ghPRComment(prNumber, review); err != nil { return fmt.Errorf("failed to post comment: %w", err) } - cmd.Println("\nReview posted as comment on PR #" + strconv.Itoa(prNumber)) + cmd.Println(auditTint("\nReview posted as comment on PR #"+strconv.Itoa(prNumber), doneGreen)) } return nil @@ -138,7 +138,7 @@ then creates a pull request via the GitHub CLI.`, } prURL := strings.TrimSpace(string(out)) - cmd.Println("Pull request created: " + prURL) + cmd.Println(auditTint("Pull request created: ", doneGreen) + auditTint(prURL, textPrimary)) return nil }, } @@ -164,7 +164,7 @@ Use --update to write the description back to the PR.`, return fmt.Errorf("failed to get PR diff: %w", err) } if strings.TrimSpace(diff) == "" { - cmd.Println("No changes found in PR #" + strconv.Itoa(prNumber)) + cmd.Println(auditTint("No changes found in PR #"+strconv.Itoa(prNumber), textMuted)) return nil } @@ -178,7 +178,7 @@ Use --update to write the description back to the PR.`, if err := ghCmd.Run(); err != nil { return fmt.Errorf("failed to update PR description: %w", err) } - cmd.Println("\nPR #" + strconv.Itoa(prNumber) + " description updated.") + cmd.Println(auditTint("\nPR #"+strconv.Itoa(prNumber)+" description updated.", doneGreen)) } return nil From 1391aca3c99c6a06c5a290e23bbed015023fd42d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:01:18 +0530 Subject: [PATCH 065/116] feat: theme audit empty-state, cmdhistory entries, cloud graph sync Muted empty-states, exit-code colored history rows, green graph sync confirmation. Honors ShouldColor(). --- cmd/audit.go | 2 +- cmd/cloud_graph.go | 10 ++++------ cmd/cmdhistory_cmd.go | 17 ++++++++--------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/cmd/audit.go b/cmd/audit.go index 1b0c2898..34bc9f48 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -86,7 +86,7 @@ func runAudit(cmd *cobra.Command, args []string) error { } if len(sessions) == 0 { - cmd.Println("No session transcripts found for the specified time period.") + cmd.Println(auditTint("No session transcripts found for the specified time period.", textMuted)) return nil } diff --git a/cmd/cloud_graph.go b/cmd/cloud_graph.go index 12d514ee..1d7d839d 100644 --- a/cmd/cloud_graph.go +++ b/cmd/cloud_graph.go @@ -68,12 +68,10 @@ execution never depends on cloud synchronization.`, if result.Duplicate { status = "already synchronized" } - cmd.Printf( - "Graph %s: %d facts (digest %s).\n", - status, - prepared.Facts, - result.GraphDigest, - ) + cmd.Printf("%s\n", + auditTint("Graph "+status+": ", doneGreen)+ + auditTint(fmt.Sprintf("%d facts", prepared.Facts), textPrimary)+ + auditTint(fmt.Sprintf(" (digest %s).", result.GraphDigest), textMuted)) return nil }, } diff --git a/cmd/cmdhistory_cmd.go b/cmd/cmdhistory_cmd.go index 5265e748..8038c1f5 100644 --- a/cmd/cmdhistory_cmd.go +++ b/cmd/cmdhistory_cmd.go @@ -175,20 +175,19 @@ func openCmdHistoryStore() (*cmdhistory.Store, error) { func printCmdHistoryEntry(cmd *cobra.Command, e cmdhistory.Entry) { exitLabel := "ok" + exitColor := doneGreen if e.ExitCode != 0 { exitLabel = fmt.Sprintf("exit:%d", e.ExitCode) + exitColor = errorCoral } - cmd.Println(fmt.Sprintf( - "[%s] [%s] [%s] %s", - e.CreatedAt.Format("2006-01-02 15:04:05"), - exitLabel, - e.Duration.Round(1), - e.Command, - )) + cmd.Println(auditTint("["+e.CreatedAt.Format("2006-01-02 15:04:05")+"] ", textMuted) + + auditTint("["+exitLabel+"] ", exitColor) + + auditTint("["+e.Duration.Round(1).String()+"] ", textMuted) + + auditTint(e.Command, textPrimary)) if e.CWD != "" { - cmd.Println(fmt.Sprintf(" cwd: %s", e.CWD)) + cmd.Println(auditTint(" cwd: ", textMuted) + auditTint(e.CWD, textPrimary)) } if e.GitBranch != "" { - cmd.Println(fmt.Sprintf(" branch: %s", e.GitBranch)) + cmd.Println(auditTint(" branch: ", textMuted) + auditTint(e.GitBranch, textPrimary)) } } From 284d1e05449f60b7d17bfcf26412e46ea161508e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:02:44 +0530 Subject: [PATCH 066/116] feat: theme the review close confirmation Green check icon with textPrimary closed-review line. Honors ShouldColor(). --- cmd/review_read.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/review_read.go b/cmd/review_read.go index 337cb37c..54df9dc6 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -166,7 +166,7 @@ func runReviewClose(_ *cobra.Command, args []string) error { if err := store.SetStatus(review.ID, ReviewStatusClosed); err != nil { return err } - fmt.Printf("%s Closed review #%d (%s)\n", icons.CheckBold(), review.ID, review.SHA[:8]) + fmt.Printf("%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("Closed review #%d (%s)", review.ID, review.SHA[:8]), textPrimary)) return nil } From 30504754b80fc4334db6feb817d602efdbf6ccb1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:04:51 +0530 Subject: [PATCH 067/116] feat: theme cost summary, review run/refine/list, search output Amber experimental/alert notices, green all-clean confirmations, status- colored review rows via new reviewStatusColor helper, muted empty-states. Honors ShouldColor(). --- cmd/cost.go | 18 +++++++++--------- cmd/review_read.go | 17 ++++++++++++++++- cmd/review_refine.go | 8 ++++---- cmd/review_run.go | 10 +++++----- cmd/search.go | 2 +- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/cmd/cost.go b/cmd/cost.go index 3d24c0e6..bfb61476 100644 --- a/cmd/cost.go +++ b/cmd/cost.go @@ -89,25 +89,25 @@ var costSummaryCmd = &cobra.Command{ return nil } - cmd.Println("[Experimental] Cost tracking is not yet fully available.") + cmd.Println(auditTint("[Experimental] Cost tracking is not yet fully available.", warnAmber)) cmd.Println() if report.TotalSpend == 0 { - cmd.Println("No cost data collected in this session.") - cmd.Println("Cost tracking will be available once session data integration is complete.") + cmd.Println(auditTint("No cost data collected in this session.", textMuted)) + cmd.Println(auditTint("Cost tracking will be available once session data integration is complete.", textMuted)) return nil } - cmd.Println(fmt.Sprintf("Total spend: $%.4f", report.TotalSpend)) - cmd.Println(fmt.Sprintf("Productive spend: $%.4f", report.ProductiveSpend)) - cmd.Println(fmt.Sprintf("Wasted spend: $%.4f", report.WastedSpend)) - cmd.Println(fmt.Sprintf("Yield rate: %.1f%%", report.YieldRate*100)) + cmd.Println(auditTint("Total spend: ", textMuted) + auditTint(fmt.Sprintf("$%.4f", report.TotalSpend), textPrimary)) + cmd.Println(auditTint("Productive spend: ", textMuted) + auditTint(fmt.Sprintf("$%.4f", report.ProductiveSpend), textPrimary)) + cmd.Println(auditTint("Wasted spend: ", textMuted) + auditTint(fmt.Sprintf("$%.4f", report.WastedSpend), errorCoral)) + cmd.Println(auditTint("Yield rate: ", textMuted) + auditTint(fmt.Sprintf("%.1f%%", report.YieldRate*100), textPrimary)) if len(report.Recommendations) > 0 { cmd.Println() - cmd.Println("Top recommendation:") + cmd.Println(auditTint("Top recommendation:", textPrimary)) rec := report.Recommendations[0] - cmd.Println(fmt.Sprintf(" [%s] %s (est. savings: $%.4f)", rec.Type, rec.Description, rec.Savings)) + cmd.Println(auditTint(fmt.Sprintf(" [%s] %s (est. savings: $%.4f)", rec.Type, rec.Description, rec.Savings), textPrimary)) } return nil }, diff --git a/cmd/review_read.go b/cmd/review_read.go index 54df9dc6..7a871d6b 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -218,7 +218,7 @@ func printReviewDetail(r *ReviewRecord) { header := lipgloss.NewStyle().Bold(true) dim := lipgloss.NewStyle().Faint(true) - fmt.Printf("%s Review #%d — %s\n", statusIcon(r.Status), r.ID, r.SHA[:8]) + fmt.Printf("%s %s\n", auditTint(statusIcon(r.Status), reviewStatusColor(r.Status)), auditTint(fmt.Sprintf("Review #%d — %s", r.ID, r.SHA[:8]), textPrimary)) fmt.Printf("%s\n", dim.Render(fmt.Sprintf("Status: %s · Created: %s · Tokens: %d", r.Status, r.CreatedAt.Format("2006-01-02 15:04"), r.TokensUsed))) fmt.Println() @@ -260,6 +260,21 @@ func statusIcon(s ReviewStatus) string { } } +func reviewStatusColor(s ReviewStatus) color.Color { + switch s { + case ReviewStatusPassed, ReviewStatusFixed: + return doneGreen + case ReviewStatusOpen, ReviewStatusRunning: + return infoSky + case ReviewStatusFailed: + return errorCoral + case ReviewStatusClosed: + return textMuted + default: + return textPrimary + } +} + func severityStyle(sev string) string { switch strings.ToLower(sev) { case "critical": diff --git a/cmd/review_refine.go b/cmd/review_refine.go index bd69d426..11607d74 100644 --- a/cmd/review_refine.go +++ b/cmd/review_refine.go @@ -102,7 +102,7 @@ func runReviewRefine(_ *cobra.Command, args []string) error { return fmt.Errorf("load review for %s: %w", latestSHA[:8], getErr) } if newReview != nil && newReview.Status == ReviewStatusPassed { - fmt.Printf("\n%s All clean after %d iteration(s)!\n", icons.CheckBold(), iter) + fmt.Printf("\n%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("All clean after %d iteration(s)!", iter), textPrimary)) return nil } @@ -116,7 +116,7 @@ func runReviewRefine(_ *cobra.Command, args []string) error { return fmt.Errorf("list open reviews: %w", listErr) } if len(reviews) == 0 { - fmt.Printf("\n%s All reviews resolved after %d iteration(s)!\n", icons.CheckBold(), iter) + fmt.Printf("\n%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("All reviews resolved after %d iteration(s)!", iter), textPrimary)) return nil } } @@ -128,8 +128,8 @@ func runReviewRefine(_ *cobra.Command, args []string) error { return fmt.Errorf("list open reviews: %w", listErr) } if len(remaining) > 0 { - fmt.Printf("\n%s %d review(s) still open after %d iterations.\n", icons.Alert(), len(remaining), refineMaxIter) - fmt.Println(" Run 'graycode review show' to inspect, or increase --max-iterations.") + fmt.Printf("\n%s %s\n", auditTint(icons.Alert(), warnAmber), auditTint(fmt.Sprintf("%d review(s) still open after %d iterations.", len(remaining), refineMaxIter), textPrimary)) + fmt.Println(auditTint(" Run 'graycode review show' to inspect, or increase --max-iterations.", textMuted)) } return nil } diff --git a/cmd/review_run.go b/cmd/review_run.go index 0cb48cc5..ecb23fe0 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -66,7 +66,7 @@ func runReviewRun(_ *cobra.Command, args []string) error { } if existing != nil && existing.Status != ReviewStatusFailed { if !reviewRunBackground { - fmt.Printf("Commit %s already reviewed (status: %s)\n", sha[:8], existing.Status) + fmt.Printf("%s\n", auditTint("Commit "+sha[:8]+" already reviewed (status: ", textMuted)+auditTint(string(existing.Status), reviewStatusColor(existing.Status))+auditTint(")", textMuted)) } return nil } @@ -93,7 +93,7 @@ func runReviewRun(_ *cobra.Command, args []string) error { return silentErr(statusErr, "mark review passed") } if !reviewRunBackground { - fmt.Println("Empty diff — nothing to review.") + fmt.Println(auditTint("Empty diff — nothing to review.", textMuted)) } return nil } @@ -238,9 +238,9 @@ func printReviewSummary(sha string, result *reviewcontracts.Result) { len(result.Findings), auditTint(maxSev.String(), reviewSeverityColor(maxSev))) for _, f := range result.Findings { - fmt.Printf(" [%s] %s:%d — %s\n", - auditTint(f.Severity.String(), reviewSeverityColor(f.Severity)), - f.File, f.Line, f.Message) + fmt.Printf(" %s %s\n", + auditTint(fmt.Sprintf("[%s]", f.Severity.String()), reviewSeverityColor(f.Severity)), + auditTint(fmt.Sprintf("%s:%d", f.File, f.Line), textPrimary)+auditTint(" — "+f.Message, textMuted)) } } diff --git a/cmd/search.go b/cmd/search.go index 489a28d0..2b8fee5e 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -44,7 +44,7 @@ func runSearch(_ *cobra.Command, args []string) error { } if len(results) == 0 { - fmt.Printf("No results for %q\n", query) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("No results for %q", query), textMuted)) return nil } From 14b2245c254b90b7e4269949b6ebf127e5810b54 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:06:32 +0530 Subject: [PATCH 068/116] feat: animate context export to file Spinner during project-context build when writing to --output; stdout stays free so the file gets clean data. Honors IsQuiet(). --- cmd/root.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index 81728257..16f36cfd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -842,9 +842,22 @@ var contextCmd = &cobra.Command{ Short: "Export project context as a single document for use in any LLM", RunE: func(cmd *cobra.Command, args []string) error { if contextOutput != "" { + var prog *CLIProgress + if !IsQuiet() { + prog = NewCLIProgress("Context", []string{"Building project context"}) + defer prog.Abort() + prog.StartStep(0) + } if err := ExportContextToFile("", contextFocus, contextOutput); err != nil { + if prog != nil { + prog.FailStep(0, err.Error()) + } return err } + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } cmd.Println(auditTint("Context exported to", doneGreen) + " " + auditTint(contextOutput, textPrimary)) return nil } From baf6b452549bbf6f0225a120743f291a2d8c220b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:08:17 +0530 Subject: [PATCH 069/116] feat: honor ShouldColor in review show and severity labels Replace raw lipgloss Bold/Faint severity styles with auditTint-based theming via reviewSeverityColor, so review show and severity badges respect NO_COLOR/--quiet in non-TTY/scripted mode. --- cmd/review_read.go | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/cmd/review_read.go b/cmd/review_read.go index 7a871d6b..c2036826 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" - lipgloss "charm.land/lipgloss/v2" "github.com/spf13/cobra" contracts "github.com/GrayCodeAI/graycode-cli/internal/contracts/types" @@ -215,19 +214,16 @@ func resolveReview(store *ReviewStore, ref string) (*ReviewRecord, error) { } func printReviewDetail(r *ReviewRecord) { - header := lipgloss.NewStyle().Bold(true) - dim := lipgloss.NewStyle().Faint(true) - fmt.Printf("%s %s\n", auditTint(statusIcon(r.Status), reviewStatusColor(r.Status)), auditTint(fmt.Sprintf("Review #%d — %s", r.ID, r.SHA[:8]), textPrimary)) - fmt.Printf("%s\n", dim.Render(fmt.Sprintf("Status: %s · Created: %s · Tokens: %d", r.Status, r.CreatedAt.Format("2006-01-02 15:04"), r.TokensUsed))) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Status: %s · Created: %s · Tokens: %d", r.Status, r.CreatedAt.Format("2006-01-02 15:04"), r.TokensUsed), textMuted)) fmt.Println() if len(r.Findings) == 0 { - fmt.Println(header.Render("No findings — clean commit " + icons.CheckBold())) + fmt.Println(auditTint("No findings — clean commit "+icons.CheckBold(), doneGreen)) return } - fmt.Println(header.Render(fmt.Sprintf("%d Findings:", len(r.Findings)))) + fmt.Println(auditTint(fmt.Sprintf("%d Findings:", len(r.Findings)), textPrimary)) fmt.Println() for i, f := range r.Findings { @@ -235,7 +231,7 @@ func printReviewDetail(r *ReviewRecord) { fmt.Printf(" %d. %s %s:%d\n", i+1, sev, f.File, f.Line) fmt.Printf(" %s\n", f.Message) if f.Fix != "" { - fmt.Printf(" %s %s\n", dim.Render("Fix:"), f.Fix) + fmt.Printf(" %s %s\n", auditTint("Fix:", textMuted), f.Fix) } fmt.Println() } @@ -276,16 +272,6 @@ func reviewStatusColor(s ReviewStatus) color.Color { } func severityStyle(sev string) string { - switch strings.ToLower(sev) { - case "critical": - return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true).Render("[CRITICAL]") - case "high": - return lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true).Render("[HIGH]") - case "medium": - return lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render("[MEDIUM]") - case "low": - return lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Render("[LOW]") - default: - return lipgloss.NewStyle().Faint(true).Render("[INFO]") - } + s, _ := contracts.ParseSeverityStrict(sev) + return auditTint("["+strings.ToUpper(sev)+"]", reviewSeverityColor(s)) } From 151b1f8ba2fa3f9c1d35f8d330e6025bf19db0e5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:12:28 +0530 Subject: [PATCH 070/116] feat: modern colorized help output Theme-aware help via a custom usage template: section headers in brand gold, command names in textPrimary, descriptions/flags plain. Pad-then-colorize keeps column alignment; honors ShouldColor() (NO_COLOR/--quiet/non-TTY). Subcommands inherit via rootCmd usage template. --- cmd/help_template.go | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 cmd/help_template.go diff --git a/cmd/help_template.go b/cmd/help_template.go new file mode 100644 index 00000000..7bf3e632 --- /dev/null +++ b/cmd/help_template.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// Modern, theme-aware help output. Section headers render in the brand gold, +// command names in textPrimary, descriptions/flags stay plain so fixed-width +// columns keep their alignment. All color honors ShouldColor() (NO_COLOR, +// --quiet, non-TTY) via auditTint. Pad-then-colorize keeps columns aligned. +func init() { + cobra.AddTemplateFunc("gcHeader", func(s string) string { return auditTint(s, graycodeColor) }) + cobra.AddTemplateFunc("gcCmd", func(s string) string { return auditTint(s, textPrimary) }) + rootCmd.SetUsageTemplate(modernUsageTemplate) +} + +const modernUsageTemplate = `{{gcHeader "Usage:"}}{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +{{gcHeader "Aliases:"}} + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +{{gcHeader "Examples:"}} +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +{{gcHeader "Available Commands:"}}{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{gcCmd (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{gcHeader .Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{gcCmd (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +{{gcHeader "Additional Commands:"}}{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{gcCmd (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +{{gcHeader "Flags:"}} +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +{{gcHeader "Global Flags:"}} +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +{{gcHeader "Additional help topics:"}}{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` From 2b6e93b9214147ea9f8cd25f614cb664d4e413c0 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:14:37 +0530 Subject: [PATCH 071/116] feat: theme review show empty-state and daemon WARNING Muted no-open-reviews message; amber non-localhost security warning in the daemon start banner. Honors ShouldColor(). --- cmd/daemon.go | 2 +- cmd/review_read.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/daemon.go b/cmd/daemon.go index 23c61a04..21391cfb 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -253,7 +253,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { fmt.Printf(" ssh -L %d:127.0.0.1:%d \n", daemonPort, daemonPort) fmt.Printf(" curl http://localhost:%d/v1/health\n", daemonPort) } else { - fmt.Println("\nWARNING: Bound to non-localhost. Ensure TLS is configured for production use.") + fmt.Println(auditTint("\nWARNING: Bound to non-localhost. Ensure TLS is configured for production use.", warnAmber)) } fmt.Println("Press Ctrl+C to stop.") diff --git a/cmd/review_read.go b/cmd/review_read.go index c2036826..efe85a39 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -128,7 +128,7 @@ func runReviewShow(_ *cobra.Command, args []string) error { // Show latest open review. reviews, _ := store.ListOpen() if len(reviews) == 0 { - fmt.Println("No open reviews.") + fmt.Println(auditTint("No open reviews.", textMuted)) return nil } review = reviews[0] From 1d76fd8422784e4ef411f59418a2468d2d9b4618 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:16:55 +0530 Subject: [PATCH 072/116] feat: theme REPL startup banner textPrimary title with muted usage hint on the interactive REPL banner. Honors ShouldColor(). --- cmd/chat_print.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/chat_print.go b/cmd/chat_print.go index 4970e755..234c75f3 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -262,7 +262,7 @@ func saveGraycodeRouterSession(id string, sess *engine.Session) { // runRepl starts an interactive REPL mode for multi-turn conversation without TUI. func runRepl() error { - fmt.Fprintln(os.Stderr, "Graycode REPL — type 'exit' or 'quit' to leave, 'help' for commands") + fmt.Fprintln(os.Stderr, auditTint("Graycode REPL", textPrimary)+auditTint(" — type 'exit' or 'quit' to leave, 'help' for commands", textMuted)) fmt.Fprintln(os.Stderr) systemPrompt, err := buildSystemPrompt() From 9dbea1c1e10626b17ba6e3b79400fcfd7ac3667a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:20:16 +0530 Subject: [PATCH 073/116] feat: theme taste and vibe command output Green taste export/import confirmations, textPrimary reset; colorized vibe iteration status (complete/green, failed/amber). Honors ShouldColor(). --- cmd/taste.go | 6 +++--- cmd/vibe.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/taste.go b/cmd/taste.go index 775c4d0e..989f64ab 100644 --- a/cmd/taste.go +++ b/cmd/taste.go @@ -128,7 +128,7 @@ func runTastePush(_ *cobra.Command, _ []string) error { if err := os.WriteFile(tasteFile, data, 0o600); err != nil { return fmt.Errorf("write file: %w", err) } - fmt.Printf("Taste profile exported to %s\n", tasteFile) + fmt.Printf("%s\n", auditTint("Taste profile exported to ", doneGreen)+auditTint(tasteFile, textPrimary)) } else { fmt.Println(string(data)) } @@ -159,7 +159,7 @@ func runTastePull(_ *cobra.Command, args []string) error { return fmt.Errorf("import profile: %w", err) } - fmt.Println("Taste profile imported successfully.") + fmt.Println(auditTint("Taste profile imported successfully.", doneGreen)) return nil } @@ -174,7 +174,7 @@ func runTasteReset(_ *cobra.Command, _ []string) error { return fmt.Errorf("reset profile: %w", err) } - fmt.Printf("Taste profile for %q has been reset.\n", projectID) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Taste profile for %q has been reset.", projectID), textPrimary)) return nil } diff --git a/cmd/vibe.go b/cmd/vibe.go index 48c20f5b..600f2c03 100644 --- a/cmd/vibe.go +++ b/cmd/vibe.go @@ -106,7 +106,7 @@ func VibeLoop(ctx context.Context, sess *engine.Session, prompt string, config V // Step 3: Run the test/build command if configured if !config.AutoRun || config.RunCommand == "" { - fmt.Printf("[vibe] iteration %d complete (no run command configured)\n", i+1) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("[vibe] iteration %d complete (no run command configured)", i+1), textPrimary)) return nil } @@ -114,12 +114,12 @@ func VibeLoop(ctx context.Context, sess *engine.Session, prompt string, config V // Step 4: If passes, we're done if runErr == nil { - fmt.Printf("[vibe] iteration %d: all good\n", i+1) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("[vibe] iteration %d: all good", i+1), doneGreen)) return nil } // Step 5: If fails, send error back to LLM for fixing - fmt.Printf("[vibe] iteration %d: command failed, asking LLM to fix...\n", i+1) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("[vibe] iteration %d: command failed, asking LLM to fix...", i+1), warnAmber)) currentPrompt = fmt.Sprintf( "The command `%s` failed with the following output:\n\n```\n%s\n```\n\nPlease fix the issues and try again.", config.RunCommand, output, From eeae07a0a81e43e698490ebd044881e43aaee28d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:23:12 +0530 Subject: [PATCH 074/116] feat: colorize top-level CLI errors Top-level failures print in brand error coral on color-capable stderr (NO_COLOR/FORCE_COLOR/TTY detection mirroring cmd.ShouldColor); scripts piping diagnostics still see plain text. --- cmd/graycode/main.go | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/cmd/graycode/main.go b/cmd/graycode/main.go index 45e6e2cf..9e2883a6 100644 --- a/cmd/graycode/main.go +++ b/cmd/graycode/main.go @@ -7,6 +7,8 @@ import ( "os" "time" + "golang.org/x/term" + "github.com/GrayCodeAI/graycode-cli/cmd" "github.com/GrayCodeAI/graycode-cli/internal/crash" "github.com/GrayCodeAI/graycode-cli/internal/graycodeerr" @@ -82,7 +84,7 @@ func main() { mcp.SetClientVersion(Version) if err := cmd.RunWithPanicRecovery(cmd.Execute); err != nil { - fmt.Fprintln(os.Stderr, err) + printError(err) // An explicit ExitCodeError (e.g. a wrapped Bash exit status) wins — // it already carries the intended code. Otherwise classify the failure // into the stable exit-code taxonomy so callers can branch on the @@ -94,3 +96,30 @@ func main() { os.Exit(graycodeerr.ClassifyExitCode(err)) } } + +// errorCoralRGB is the brand error color (#FF6B6B) as an SGR truecolor +// sequence, applied only when stderr is a color-capable terminal so scripts +// piping diagnostics never see raw ANSI. +const errorCoralRGB = "\x1b[38;2;255;107;107m" + +// printError writes a top-level failure to stderr, colorized (error coral) +// when the terminal supports it and NO_COLOR is unset. +func printError(err error) { + msg := err.Error() + if shouldColorErr() { + msg = errorCoralRGB + msg + "\x1b[m" + } + fmt.Fprintln(os.Stderr, msg) +} + +// shouldColorErr mirrors cmd.ShouldColor's detection for stderr: NO_COLOR +// wins, then FORCE_COLOR, else the terminal's color support. +func shouldColorErr() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + if os.Getenv("FORCE_COLOR") != "" { + return true + } + return term.IsTerminal(int(os.Stderr.Fd())) +} From a31aef05af05f53dde3c1e4b87482dd65d01b8aa Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:24:59 +0530 Subject: [PATCH 075/116] feat: theme swift-report confirmations Green save/copy confirmations with textPrimary path and muted redaction hint. Honors ShouldColor(). --- cmd/swift_report.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/swift_report.go b/cmd/swift_report.go index bd24e687..ea184073 100644 --- a/cmd/swift_report.go +++ b/cmd/swift_report.go @@ -87,9 +87,9 @@ func runSwiftReport(cmd *cobra.Command, _ []string) error { // Mirror fx: attempt clipboard copy; on failure print a review-and-redact // notice pointing at the saved path. if !swiftReportNoCopy && swift.TryClipboard(swift.Build(&s)) { - cmd.Println("Swift report copied to clipboard. Saved at " + path + " (review and redact before sharing).") + cmd.Println(auditTint("Swift report copied to clipboard. ", doneGreen) + auditTint("Saved at "+path, textPrimary) + auditTint(" (review and redact before sharing).", textMuted)) } else { - cmd.Println("Swift saved at " + path + ". Review and redact it before sharing.") + cmd.Println(auditTint("Swift saved at ", doneGreen) + auditTint(path, textPrimary) + auditTint(". Review and redact it before sharing.", textMuted)) } return nil } From f5a01eef3c64c10694b3d1fba73c8e153084075b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:28:47 +0530 Subject: [PATCH 076/116] feat: theme review finding rows and skills info Muted no-reviews empty state and finding message bodies; labeled skills info (Skill/Description/Repo/Installs). Honors ShouldColor(). --- cmd/review_read.go | 4 ++-- cmd/skills_cmd.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/review_read.go b/cmd/review_read.go index efe85a39..2341112a 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -182,7 +182,7 @@ func runReviewList(_ *cobra.Command, _ []string) error { return err } if len(reviews) == 0 { - fmt.Println("No reviews yet.") + fmt.Println(auditTint("No reviews yet.", textMuted)) return nil } @@ -229,7 +229,7 @@ func printReviewDetail(r *ReviewRecord) { for i, f := range r.Findings { sev := severityStyle(f.Severity.String()) fmt.Printf(" %d. %s %s:%d\n", i+1, sev, f.File, f.Line) - fmt.Printf(" %s\n", f.Message) + fmt.Printf(" %s\n", auditTint(f.Message, textMuted)) if f.Fix != "" { fmt.Printf(" %s %s\n", auditTint("Fix:", textMuted), f.Fix) } diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index 818c97a6..5854cefb 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -118,11 +118,11 @@ var skillsInfoCmd = &cobra.Command{ if err != nil { return err } - fmt.Printf("Skill: %s (not installed)\n", entry.Name) + fmt.Printf("%s %s\n", auditTint("Skill:", textMuted), auditTint(entry.Name, textPrimary)+auditTint(" (not installed)", textMuted)) if entry.Description != "" { - fmt.Printf("Description: %s\n", entry.Description) + fmt.Printf("%s %s\n", auditTint("Description:", textMuted), auditTint(entry.Description, textPrimary)) } - fmt.Printf("Repo: %s\nInstalls: %d\n", entry.Repo, entry.Installs) + fmt.Printf("%s %s\n%s %d\n", auditTint("Repo:", textMuted), auditTint(entry.Repo, textPrimary), auditTint("Installs:", textMuted), entry.Installs) return nil }, } From de661989f5cb15dcd70c4963d12905440039ba74 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:31:58 +0530 Subject: [PATCH 077/116] feat: theme print-mode tool and countdown indicators stderr tool-use/tool-result names in infoSky and the time-remaining countdown in warnAmber; stdout model output stays byte-clean. Honors ShouldColor(). --- cmd/chat_print.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/chat_print.go b/cmd/chat_print.go index 234c75f3..2cbb6bcb 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -96,7 +96,7 @@ func runPrint(text string) error { // surface the remaining time budget once, on the first content. if countdown && !countdownShown { if rem := lifecycle.RemainingTime(ctx); rem != "" { - fmt.Fprintf(os.Stderr, "[time remaining] %s\n", rem) + fmt.Fprintf(os.Stderr, "%s\n", auditTint("[time remaining] "+rem, warnAmber)) countdownShown = true } } @@ -104,7 +104,7 @@ func runPrint(text string) error { if outputFormat == "stream-json" { writePrintEvent(sessionID, "tool_use", "", ev.ToolName) } else { - _, _ = fmt.Fprintf(os.Stderr, "\n[%s]\n", ev.ToolName) + _, _ = fmt.Fprintf(os.Stderr, "\n%s\n", auditTint("["+ev.ToolName+"]", infoSky)) } case "tool_result": content := ev.Content @@ -115,7 +115,7 @@ func runPrint(text string) error { if outputFormat == "stream-json" { writePrintEvent(sessionID, "tool_result", content, ev.ToolName) } else { - _, _ = fmt.Fprintf(os.Stderr, "[%s] %s\n", ev.ToolName, content) + _, _ = fmt.Fprintf(os.Stderr, "%s %s\n", auditTint("["+ev.ToolName+"]", infoSky), content) } case "usage": if outputFormat == "stream-json" && ev.Usage != nil { From f1372338d0845eecdd9febc1cd88777181726341 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:32:57 +0530 Subject: [PATCH 078/116] feat: theme watch-mode status indicators textPrimary watching banner and directive-processing lines, warnAmber fsnotify fallback, errorCoral initial-run failure. Honors ShouldColor(). --- cmd/chat_print.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/chat_print.go b/cmd/chat_print.go index 2cbb6bcb..cc00b910 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -524,16 +524,16 @@ func runWatch(initialPrompt string) error { // Optional initial run to seed context, matching the prior behaviour. if strings.TrimSpace(initialPrompt) != "" { if err := runPrint(initialPrompt); err != nil { - fmt.Fprintf(os.Stderr, "Initial run failed: %v\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint("Initial run failed: "+err.Error(), errorCoral)) } } root := "." - fmt.Fprintln(os.Stderr, "\n[Watching for AI!/AI? comment directives — press Ctrl+C to stop]") + fmt.Fprintln(os.Stderr, "\n"+auditTint("[Watching for AI!/AI? comment directives — press Ctrl+C to stop]", textPrimary)) // Process any directives already present before the first change event. if n := processAIDirectives(root, watchIgnoreDirs); n > 0 { - fmt.Fprintf(os.Stderr, "[%s] processed %d AI directive(s)\n", time.Now().Format("15:04:05"), n) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("[%s] processed %d AI directive(s)", time.Now().Format("15:04:05"), n), textPrimary)) } // Prefer the fsnotify event-driven backend. The AI!/AI? directive grammar @@ -543,14 +543,14 @@ func runWatch(initialPrompt string) error { watcher := aiwatch.NewAIWatcher(root, nil) watcher.OnChange = func() { if n := processAIDirectives(root, watchIgnoreDirs); n > 0 { - fmt.Fprintf(os.Stderr, "[%s] processed %d AI directive(s)\n", time.Now().Format("15:04:05"), n) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("[%s] processed %d AI directive(s)", time.Now().Format("15:04:05"), n), textPrimary)) } } ctx := context.Background() if err := watcher.StartFsnotify(ctx); err != nil { // fsnotify unavailable — fall back to the polling backstop. - fmt.Fprintf(os.Stderr, "[watch] fsnotify unavailable (%v), using polling fallback\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("[watch] fsnotify unavailable (%v), using polling fallback", err), warnAmber)) return runWatchPolling(root) } return nil @@ -569,7 +569,7 @@ func runWatchPolling(root string) error { if currentMod.After(lastMod) { lastMod = currentMod if n := processAIDirectives(root, watchIgnoreDirs); n > 0 { - fmt.Fprintf(os.Stderr, "[%s] processed %d AI directive(s)\n", time.Now().Format("15:04:05"), n) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("[%s] processed %d AI directive(s)", time.Now().Format("15:04:05"), n), textPrimary)) } } } From 8f810f13676115f0f9bb6d19f901426e05bdb419 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:34:01 +0530 Subject: [PATCH 079/116] feat: theme background-session list Muted empty state, textPrimary header/IDs, status-colored status (running/completed/failed/killed), muted labels. Honors ShouldColor(). --- cmd/bg_sessions.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/cmd/bg_sessions.go b/cmd/bg_sessions.go index e0031de4..878687d7 100644 --- a/cmd/bg_sessions.go +++ b/cmd/bg_sessions.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "image/color" "os" "os/exec" "path/filepath" @@ -160,14 +161,30 @@ func StartBGSession(prompt string, args []string) (*BGSessionInfo, error) { return info, nil } +// bgStatusColor maps a background-session status to a theme color. +func bgStatusColor(status string) color.Color { + switch status { + case "running": + return infoSky + case "completed": + return doneGreen + case "failed": + return errorCoral + case "killed": + return textMuted + default: + return textPrimary + } +} + // FormatBGSessions formats background sessions for display. func FormatBGSessions(sessions []*BGSessionInfo) string { if len(sessions) == 0 { - return "No background sessions." + return auditTint("No background sessions.", textMuted) } var b strings.Builder - b.WriteString(fmt.Sprintf("Background sessions (%d):\n", len(sessions))) + b.WriteString(auditTint(fmt.Sprintf("Background sessions (%d):", len(sessions)), textPrimary) + "\n") b.WriteString(strings.Repeat("─", 60) + "\n") for _, s := range sessions { @@ -180,8 +197,8 @@ func FormatBGSessions(sessions []*BGSessionInfo) string { preview = string(runes[:50]) + "..." } age := time.Since(s.StartedAt).Round(time.Minute) - b.WriteString(fmt.Sprintf(" [%s] %s — %s\n", shortID, s.Status, preview)) - b.WriteString(fmt.Sprintf(" PID: %d · started %s ago · %s\n\n", s.PID, age, s.CWD)) + b.WriteString(fmt.Sprintf(" %s %s %s\n", auditTint("["+shortID+"]", textPrimary), auditTint(s.Status, bgStatusColor(s.Status)), auditTint(preview, textMuted))) + b.WriteString(fmt.Sprintf(" %s %s · %s %s · %s\n\n", auditTint("PID:", textMuted), auditTint(fmt.Sprintf("%d", s.PID), textPrimary), auditTint("started", textMuted), auditTint(age.String()+" ago", textPrimary), auditTint(s.CWD, textMuted))) } return b.String() From fa05f25fd2b3744b4b8e3939fa9011122ac07148 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:35:28 +0530 Subject: [PATCH 080/116] feat: theme stats empty-state Muted 'No session data found' hint. Honors ShouldColor(). --- cmd/stats.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/stats.go b/cmd/stats.go index f2717de7..329890e7 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -84,8 +84,8 @@ func runStats(cmd *cobra.Command, args []string) error { } if len(filtered) == 0 { - cmd.Println("No session data found for the specified time period.") - cmd.Println("Sessions are recorded automatically when you use graycode.") + cmd.Println(auditTint("No session data found for the specified time period.", textMuted)) + cmd.Println(auditTint("Sessions are recorded automatically when you use graycode.", textMuted)) return nil } From 582f5d0908bd0a9713b6d214f90cb116c247c001 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:51:57 +0530 Subject: [PATCH 081/116] feat: theme internal ecosystem panel and credentials status Add a self-contained theme.Tint helper (honors NO_COLOR/FORCE_COLOR/TTY, no cmd dependency) and apply it to the two most user-facing internal status reports: the ecosystem panel (doctor/status) and credentials status. Statuses colorize (green ready, amber not-ready, sky info, muted labels) while staying plain under NO_COLOR so scripted output and existing substring assertions are unaffected. --- internal/config/credentials_store.go | 11 +++--- internal/config/ecosystem_report.go | 26 +++++++------- internal/theme/tint.go | 47 ++++++++++++++++++++++++ internal/theme/tint_test.go | 53 ++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 internal/theme/tint.go create mode 100644 internal/theme/tint_test.go diff --git a/internal/config/credentials_store.go b/internal/config/credentials_store.go index 6df75968..f17d3f1d 100644 --- a/internal/config/credentials_store.go +++ b/internal/config/credentials_store.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GrayCodeAI/graycode-cli/internal/provider/gateway" + "github.com/GrayCodeAI/graycode-cli/internal/theme" ) // PersistAPIKey saves a provider API key via graycode-router (OS secret store). @@ -143,17 +144,17 @@ func FormatCredentialCLIStatus(ctx context.Context) string { } report := CredentialStorageStatus(ctx) var b strings.Builder - fmt.Fprintf(&b, "Credential storage: %s only\n", report.PlatformStore) + fmt.Fprintf(&b, "%s %s only\n", theme.Tint("Credential storage:", theme.ReportMuted), theme.Tint(report.PlatformStore, theme.ReportInfo)) if report.Writable { - b.WriteString(" Keychain: writable\n") + b.WriteString(" " + theme.Tint("Keychain:", theme.ReportMuted) + " " + theme.Tint("writable", theme.ReportSuccess) + "\n") } else { - fmt.Fprintf(&b, " Keychain: %s\n", report.Detail) + fmt.Fprintf(&b, " %s %s\n", theme.Tint("Keychain:", theme.ReportMuted), theme.Tint(report.Detail, theme.ReportWarn)) } providers := ConfiguredCredentialProviders() if len(providers) == 0 { - b.WriteString(" Configured: (none)\n") + b.WriteString(" " + theme.Tint("Configured:", theme.ReportMuted) + " " + theme.Tint("(none)", theme.ReportWarn) + "\n") } else { - fmt.Fprintf(&b, " Configured: %s\n", strings.Join(providers, ", ")) + fmt.Fprintf(&b, " %s %s\n", theme.Tint("Configured:", theme.ReportMuted), theme.Tint(strings.Join(providers, ", "), theme.ReportInfo)) } return strings.TrimRight(b.String(), "\n") } diff --git a/internal/config/ecosystem_report.go b/internal/config/ecosystem_report.go index e841d603..2c772861 100644 --- a/internal/config/ecosystem_report.go +++ b/internal/config/ecosystem_report.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GrayCodeAI/graycode-cli/internal/intelligence/memory" + "github.com/GrayCodeAI/graycode-cli/internal/theme" "github.com/GrayCodeAI/graycode-cli/internal/token" ) @@ -71,30 +72,30 @@ func BuildEcosystemReport(ctx context.Context, provider, model string) Ecosystem // FormatEcosystemPanel summarizes graycode-router, harrier, and shrike integration for doctor and status output. func FormatEcosystemPanel(ctx context.Context, provider, model string) string { var b strings.Builder - b.WriteString("Ecosystem (graycode-router · harrier · shrike):\n") + b.WriteString(theme.Tint("Ecosystem (graycode-router · harrier · shrike):", theme.ReportInfo) + "\n") // graycode-router — LLM provider layer cat := CatalogHealthReport(ctx) - graycodeRouterLine := " graycode-router: " + graycodeRouterLine := " " + theme.Tint("graycode-router:", theme.ReportMuted) + " " if cat.Exists { - graycodeRouterLine += fmt.Sprintf("catalog %d models", cat.Models) + graycodeRouterLine += theme.Tint(fmt.Sprintf("catalog %d models", cat.Models), theme.ReportInfo) } else { - graycodeRouterLine += "catalog missing (run graycode models refresh)" + graycodeRouterLine += theme.Tint("catalog missing (run graycode models refresh)", theme.ReportWarn) } pre := EnginePreflightReport(ctx) if pre.Ready { - graycodeRouterLine += " · locally ready" + graycodeRouterLine += " · " + theme.Tint("locally ready", theme.ReportSuccess) } else { - graycodeRouterLine += " · setup incomplete" + graycodeRouterLine += " · " + theme.Tint("setup incomplete", theme.ReportWarn) } if strings.TrimSpace(provider) != "" && provider != "auto" { - graycodeRouterLine += fmt.Sprintf(" · provider %s", provider) + graycodeRouterLine += " · " + theme.Tint("provider "+provider, theme.ReportInfo) } if dep, err := EngineDeploymentSummary(ctx, model); err == nil { if dep.RoutingStages > 0 { - graycodeRouterLine += fmt.Sprintf(" · routing %s (%d stages)", dep.RoutingSource, dep.RoutingStages) + graycodeRouterLine += " · " + theme.Tint(fmt.Sprintf("routing %s (%d stages)", dep.RoutingSource, dep.RoutingStages), theme.ReportInfo) } else { - graycodeRouterLine += fmt.Sprintf(" · routing %s", dep.RoutingSource) + graycodeRouterLine += " · " + theme.Tint("routing "+dep.RoutingSource, theme.ReportInfo) } } b.WriteString(graycodeRouterLine + "\n") @@ -103,14 +104,13 @@ func FormatEcosystemPanel(ctx context.Context, provider, model string) string { bridge := memory.NewHarrierBridge() if bridge.Ready() { first := strings.Split(memory.HarrierStatus(), "\n")[0] - b.WriteString(" harrier: " + first + " · bridge ready\n") + b.WriteString(" " + theme.Tint("harrier:", theme.ReportMuted) + " " + theme.Tint(first, theme.ReportInfo) + " · " + theme.Tint("bridge ready", theme.ReportSuccess) + "\n") } else { - b.WriteString(" harrier: not initialized · memory ops skipped (~/.harrier/data/)\n") + b.WriteString(" " + theme.Tint("harrier:", theme.ReportMuted) + " " + theme.Tint("not initialized", theme.ReportWarn) + " · memory ops skipped (~/.harrier/data/)\n") } // shrike — token counting and context compression (always embedded) sample := token.CountTokensFast("graycode context compression pipeline") - b.WriteString(fmt.Sprintf(" shrike: embedded · token/compress pipeline OK (sample=%d tokens)\n", sample)) - + b.WriteString(" " + theme.Tint("shrike:", theme.ReportMuted) + " " + theme.Tint("embedded", theme.ReportInfo) + " · " + theme.Tint("token/compress pipeline OK", theme.ReportSuccess) + fmt.Sprintf(" (sample=%d tokens)", sample) + "\n") return strings.TrimRight(b.String(), "\n") } diff --git a/internal/theme/tint.go b/internal/theme/tint.go new file mode 100644 index 00000000..8712432d --- /dev/null +++ b/internal/theme/tint.go @@ -0,0 +1,47 @@ +// tint.go — Plain-text colorization for non-TUI output (CLI reports, status). +// +// Unlike the TUI, CLI reports are rendered as plain strings and later printed +// by the command layer. This helper lets internal report formatters colorize +// labels and statuses without depending on the cmd package, honoring the same +// NO_COLOR / FORCE_COLOR / terminal-detection contract as cmd's ShouldColor. + +package theme + +import ( + "image/color" + "os" + + lipgloss "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// ColorEnabled reports whether ANSI color should be emitted. NO_COLOR wins, +// then FORCE_COLOR, then terminal detection on stdout. +func ColorEnabled() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + if os.Getenv("FORCE_COLOR") != "" { + return true + } + return term.IsTerminal(int(os.Stdout.Fd())) +} + +// Tint colors s for terminal display, honoring ColorEnabled. Returns s +// unchanged when color is disabled or s is empty. +func Tint(s string, c color.Color) string { + if !ColorEnabled() || s == "" { + return s + } + return lipgloss.NewStyle().Foreground(c).Render(s) +} + +// Semantic report colors — fixed brand values, legible on both light and dark +// terminals. Used by internal report formatters that colorize statuses. +var ( + ReportSuccess = lipgloss.Color("#4CAF50") // green + ReportWarn = lipgloss.Color("#FFB347") // amber + ReportError = lipgloss.Color("#FF6B6B") // coral + ReportInfo = lipgloss.Color("#75B1E2") // sky + ReportMuted = lipgloss.Color("#9E9E9E") // gray +) diff --git a/internal/theme/tint_test.go b/internal/theme/tint_test.go new file mode 100644 index 00000000..47df9ba6 --- /dev/null +++ b/internal/theme/tint_test.go @@ -0,0 +1,53 @@ +package theme + +import ( + "strings" + "testing" +) + +func TestColorEnabled_RespectsEnv(t *testing.T) { + t.Run("NO_COLOR disables", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("FORCE_COLOR", "") + if ColorEnabled() { + t.Fatal("ColorEnabled() = true with NO_COLOR set") + } + }) + t.Run("FORCE_COLOR enables despite non-TTY", func(t *testing.T) { + t.Setenv("NO_COLOR", "") + t.Setenv("FORCE_COLOR", "1") + if !ColorEnabled() { + t.Fatal("ColorEnabled() = false with FORCE_COLOR set") + } + }) + t.Run("NO_COLOR wins over FORCE_COLOR", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("FORCE_COLOR", "1") + if ColorEnabled() { + t.Fatal("ColorEnabled() = true when both NO_COLOR and FORCE_COLOR set") + } + }) +} + +func TestTint(t *testing.T) { + t.Run("plain when disabled", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + if got := Tint("hello", ReportSuccess); got != "hello" { + t.Fatalf("Tint = %q, want %q", got, "hello") + } + }) + t.Run("empty stays empty", func(t *testing.T) { + t.Setenv("FORCE_COLOR", "1") + if got := Tint("", ReportSuccess); got != "" { + t.Fatalf("Tint(\"\") = %q, want empty", got) + } + }) + t.Run("wraps in ANSI when enabled", func(t *testing.T) { + t.Setenv("NO_COLOR", "") + t.Setenv("FORCE_COLOR", "1") + got := Tint("ready", ReportSuccess) + if !strings.Contains(got, "\x1b[") || !strings.Contains(got, "ready") { + t.Fatalf("Tint = %q, want ANSI-wrapped text", got) + } + }) +} From 49511fb7caefd5f7071397ad7ddbfb0650874105 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:56:09 +0530 Subject: [PATCH 082/116] feat: theme catalog health and config summary reports Theme the model catalog health report (doctor) and the config summary family (mcp/sessions/tools summaries, api key config). Labels muted, statuses colorized (green set/ready, amber stale/not-ready, muted empty) while staying plain under NO_COLOR for scripted output. --- cmd/chat_welcome.go | 27 ++++++++++++++++++++++++--- cmd/diagnostics.go | 24 ++++++++++++------------ internal/config/catalog_health.go | 18 ++++++++++-------- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index cce02727..eef53b40 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "image/color" "sort" "strings" @@ -436,7 +437,7 @@ Model catalog and routing live in graycode-router — graycode is the UI only.`, } func apiKeyConfigSummary() string { - return "API keys (" + graycodeconfig.CredentialStoreName() + ")\n" + indentedAPIKeyLines() + return auditTint("API keys ("+graycodeconfig.CredentialStoreName()+")", textPrimary) + "\n" + indentedAPIKeyLines() } func configuredKeyList() string { @@ -456,9 +457,29 @@ func configuredKeyList() string { func indentedAPIKeyLines() string { lines := apiKeyStatusLines() if len(lines) == 0 { - return " (empty)" + return " " + auditTint("(empty)", textMuted) + } + var b strings.Builder + for _, line := range lines { + name, status, ok := strings.Cut(line, ": ") + if !ok { + b.WriteString(" " + line + "\n") + continue + } + b.WriteString(" " + auditTint(name, textPrimary) + ": " + auditTint(status, apiKeyStatusColor(status)) + "\n") + } + return strings.TrimRight(b.String(), "\n") +} + +func apiKeyStatusColor(status string) color.Color { + switch status { + case "set": + return doneGreen + case "local": + return infoSky + default: + return textMuted } - return " " + strings.Join(lines, "\n ") } func apiKeyStatusLines() []string { diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go index d534f41b..23998c5b 100644 --- a/cmd/diagnostics.go +++ b/cmd/diagnostics.go @@ -232,16 +232,16 @@ func settingsSummary(settings graycodeconfig.Settings) string { func mcpConfigSummary(settings graycodeconfig.Settings) string { if len(settings.MCPServers) == 0 && len(mcpServers) == 0 { - return "No MCP servers configured." + return auditTint("No MCP servers configured.", textMuted) } var b strings.Builder - b.WriteString("MCP servers:\n") + b.WriteString(auditTint("MCP servers:", textPrimary) + "\n") for _, cfg := range settings.MCPServers { name := cfg.Name if name == "" { name = cfg.Command } - b.WriteString(fmt.Sprintf(" %s: %s %s\n", name, cfg.Command, strings.Join(cfg.Args, " "))) + b.WriteString(fmt.Sprintf(" %s: %s %s\n", auditTint(name, textPrimary), cfg.Command, strings.Join(cfg.Args, " "))) } for _, cmd := range mcpServers { b.WriteString(" cli: " + cmd + "\n") @@ -252,16 +252,16 @@ func mcpConfigSummary(settings graycodeconfig.Settings) string { func sessionsSummary() string { entries, err := session.List() if err != nil || len(entries) == 0 { - return "No saved sessions." + return auditTint("No saved sessions.", textMuted) } var b strings.Builder - b.WriteString("Saved sessions:\n") + b.WriteString(auditTint("Saved sessions:", textPrimary) + "\n") for _, e := range entries { cwd := e.CWD if cwd == "" { cwd = "-" } - b.WriteString(fmt.Sprintf(" %s %s %s %s\n", e.ID, e.UpdatedAt.Format("2006-01-02 15:04"), cwd, e.Preview)) + b.WriteString(fmt.Sprintf(" %s %s %s %s\n", auditTint(e.ID, textPrimary), e.UpdatedAt.Format("2006-01-02 15:04"), cwd, e.Preview)) } return strings.TrimRight(b.String(), "\n") } @@ -270,16 +270,16 @@ func builtInToolsSummary() string { essential := essentialTools() optional := optionalTools() var b strings.Builder - b.WriteString(fmt.Sprintf("Built-in tools (%d total: %d essential, %d optional):\n", len(essential)+len(optional), len(essential), len(optional))) - b.WriteString(" Essential (loaded at startup):\n") + b.WriteString(auditTint(fmt.Sprintf("Built-in tools (%d total: %d essential, %d optional):", len(essential)+len(optional), len(essential), len(optional)), textPrimary) + "\n") + b.WriteString(" " + auditTint("Essential (loaded at startup):", textMuted) + "\n") for _, t := range essential { - b.WriteString(fmt.Sprintf(" %s - %s\n", t.Name(), t.Description())) + b.WriteString(fmt.Sprintf(" %s - %s\n", auditTint(t.Name(), textPrimary), t.Description())) } - b.WriteString(" Optional (lazy-loaded):\n") + b.WriteString(" " + auditTint("Optional (lazy-loaded):", textMuted) + "\n") for _, t := range optional { - b.WriteString(fmt.Sprintf(" %s - %s\n", t.Name(), t.Description())) + b.WriteString(fmt.Sprintf(" %s - %s\n", auditTint(t.Name(), textPrimary), t.Description())) } - b.WriteString("\nIntent bundles:\n") + b.WriteString("\n" + auditTint("Intent bundles:", textMuted) + "\n") for _, summary := range tool.IntentBundleSummary() { b.WriteString(" " + summary + "\n") } diff --git a/internal/config/catalog_health.go b/internal/config/catalog_health.go index 92735cde..3c13c5db 100644 --- a/internal/config/catalog_health.go +++ b/internal/config/catalog_health.go @@ -6,6 +6,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/graycode-cli/internal/theme" ) var ( @@ -79,21 +81,21 @@ func catalogHealthReportUncached(ctx context.Context) CatalogHealth { // FormatCatalogHealth returns human-readable catalog status for graycode doctor. func FormatCatalogHealth(h CatalogHealth) string { var b strings.Builder - b.WriteString("Model catalog (graycode-router):\n") - b.WriteString(fmt.Sprintf(" path: %s\n", h.CachePath)) + b.WriteString(theme.Tint("Model catalog (graycode-router):", theme.ReportInfo) + "\n") + b.WriteString(" " + theme.Tint("path:", theme.ReportMuted) + " " + theme.Tint(h.CachePath, theme.ReportInfo) + "\n") if h.Error != "" { - b.WriteString(fmt.Sprintf(" status: %s\n", h.Error)) + b.WriteString(" " + theme.Tint("status:", theme.ReportMuted) + " " + theme.Tint(h.Error, theme.ReportError) + "\n") return strings.TrimRight(b.String(), "\n") } - b.WriteString(fmt.Sprintf(" modified: %s (%d bytes)\n", h.Modified.UTC().Format(time.RFC3339), h.SizeBytes)) + b.WriteString(" " + theme.Tint("modified:", theme.ReportMuted) + " " + theme.Tint(h.Modified.UTC().Format(time.RFC3339), theme.ReportInfo) + fmt.Sprintf(" (%d bytes)", h.SizeBytes) + "\n") if h.Source != "" { - b.WriteString(fmt.Sprintf(" source: %s\n", h.Source)) + b.WriteString(" " + theme.Tint("source:", theme.ReportMuted) + " " + theme.Tint(h.Source, theme.ReportInfo) + "\n") } - b.WriteString(fmt.Sprintf(" models: %d deployments: %d offerings: %d\n", h.Models, h.Deployments, h.Offerings)) + b.WriteString(" " + theme.Tint("models:", theme.ReportMuted) + " " + theme.Tint(fmt.Sprintf("%d", h.Models), theme.ReportInfo) + " " + theme.Tint("deployments:", theme.ReportMuted) + " " + theme.Tint(fmt.Sprintf("%d", h.Deployments), theme.ReportInfo) + " " + theme.Tint("offerings:", theme.ReportMuted) + " " + theme.Tint(fmt.Sprintf("%d", h.Offerings), theme.ReportInfo) + "\n") if h.Stale { - b.WriteString(fmt.Sprintf(" stale: yes (after %s) — graycode refreshes automatically on start\n", h.StaleAfter.UTC().Format(time.RFC3339))) + b.WriteString(" " + theme.Tint("stale:", theme.ReportMuted) + " " + theme.Tint("yes", theme.ReportWarn) + fmt.Sprintf(" (after %s) — graycode refreshes automatically on start\n", h.StaleAfter.UTC().Format(time.RFC3339))) } else if !h.StaleAfter.IsZero() { - b.WriteString(fmt.Sprintf(" stale: no (until %s)\n", h.StaleAfter.UTC().Format(time.RFC3339))) + b.WriteString(" " + theme.Tint("stale:", theme.ReportMuted) + " " + theme.Tint("no", theme.ReportSuccess) + fmt.Sprintf(" (until %s)\n", h.StaleAfter.UTC().Format(time.RFC3339))) } return strings.TrimRight(b.String(), "\n") } From 14e928d587c6773947656116c1407c7703ea2e34 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 07:57:36 +0530 Subject: [PATCH 083/116] feat: theme config summary report Theme the config command's primary output: textPrimary headers (Setup/Current), muted labels, sky provider/model values, and muted (none) keys. Stays plain under NO_COLOR for scripted output. --- cmd/chat_welcome.go | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index eef53b40..edb316cd 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -422,18 +422,28 @@ func configCommandSummary(settings graycodeconfig.Settings) string { _ = settings providerName := displayConfigValue(graycodeconfig.ActiveProvider(context.Background())) modelName := displayConfigValue(graycodeconfig.ActiveModel(context.Background())) - return fmt.Sprintf(`Setup (graycode-router) + keys := configuredKeyList() + keysColor := infoSky + if keys == "(none)" { + keysColor = textMuted + } + return fmt.Sprintf(`%s /config → paste API key (OS keychain) + pick model /path → verify readiness in TUI graycode path (CLI) -Current: - provider: %s - model: %s - keys: %s - -Model catalog and routing live in graycode-router — graycode is the UI only.`, providerName, modelName, configuredKeyList()) +%s: + %s %s + %s %s + %s %s + +Model catalog and routing live in graycode-router — graycode is the UI only.`, + auditTint("Setup (graycode-router)", textPrimary), + auditTint("Current", textPrimary), + auditTint("provider:", textMuted), auditTint(providerName, infoSky), + auditTint("model:", textMuted), auditTint(modelName, infoSky), + auditTint("keys:", textMuted), auditTint(keys, keysColor)) } func apiKeyConfigSummary() string { From 04e64425aded63fd2b5230fabde23ab2d4507cca Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:00:30 +0530 Subject: [PATCH 084/116] fix: plan list shows plan name instead of duplicate title The plan list printed the plan title twice (a pre-existing bug carried through theming). Capture each plan's file name and show it muted in place of the duplicate title, giving users the identifier used by 'graycode plan show '. --- cmd/plan.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/plan.go b/cmd/plan.go index 528d8570..579851cb 100644 --- a/cmd/plan.go +++ b/cmd/plan.go @@ -68,6 +68,7 @@ var planListCmd = &cobra.Command{ } var plans []planner.Plan + var planNames []string for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".json" { continue @@ -78,6 +79,7 @@ var planListCmd = &cobra.Command{ continue } plans = append(plans, *plan) + planNames = append(planNames, strings.TrimSuffix(e.Name(), ".json")) } if planJSON { @@ -94,15 +96,19 @@ var planListCmd = &cobra.Command{ return nil } - for _, plan := range plans { + for i, plan := range plans { pending := len(planner.PendingTasks(&plan)) total := len(plan.Tasks) done := total - pending + name := "" + if i < len(planNames) { + name = planNames[i] + } cmd.Println(fmt.Sprintf( " %s %s %s", auditTint(plan.Title, textPrimary), auditTint(fmt.Sprintf("[%d/%d done]", done, total), doneGreen), - plan.Title, + auditTint(name, textMuted), )) } From 794fa7e278623768d3b8ce1e6a0c03aca5ce7891 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:05:11 +0530 Subject: [PATCH 085/116] fix: cap verify workspace checks at 10m graycode verify ran each discovered project test/verify command with no timeout, so a hanging test suite would block verify forever. Wrap each check in a 10-minute context deadline and report a timed-out check as a clear failure instead. --- cmd/verify_cmd.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/verify_cmd.go b/cmd/verify_cmd.go index e288e3e6..e80a8742 100644 --- a/cmd/verify_cmd.go +++ b/cmd/verify_cmd.go @@ -1,10 +1,12 @@ package cmd import ( + "context" "fmt" "os" "os/exec" "strings" + "time" "github.com/GrayCodeAI/graycode-cli/internal/governance" "github.com/GrayCodeAI/graycode-cli/internal/securitylog" @@ -101,11 +103,20 @@ func runWorkspaceChecks() []workspaceCheckResult { } var results []workspaceCheckResult for _, c := range checks { - run := exec.Command(c.Command[0], c.Command[1:]...) // #nosec G204 -- discovered from project manifests + // Guard against a hanging project test/verify command: cap each check + // at 10 minutes and report a timed-out check as a failure with a clear + // message instead of blocking verify forever. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + run := exec.CommandContext(ctx, c.Command[0], c.Command[1:]...) // #nosec G204 -- discovered from project manifests var stdout, stderr strings.Builder run.Stdout = &stdout run.Stderr = &stderr runErr := run.Run() + cancel() + if ctx.Err() == context.DeadlineExceeded { + results = append(results, workspaceCheckResult{Name: c.Name, Err: fmt.Errorf("timed out after 10m: %s", strings.TrimSpace(stderr.String()))}) + continue + } summary := testrunner.ParseSummary(c, stdout.String(), stderr.String()) if runErr != nil && summary == nil { results = append(results, workspaceCheckResult{Name: c.Name, Err: fmt.Errorf("%w: %s", runErr, strings.TrimSpace(stderr.String()))}) From 404d2c2b1d28af205c772764a606d835fdb20f7f Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:07:57 +0530 Subject: [PATCH 086/116] fix: doctor --json now emits real JSON graycode doctor --json claimed to 'output diagnostics as JSON' but printed the plain-text doctorOutput report instead. Add a structured doctorJSON that mirrors the report fields as machine-parseable JSON and wire the flag to it. Add a regression test asserting valid JSON with the expected fields. --- cmd/dx.go | 143 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/dx_test.go | 34 ++++++++++++ cmd/root.go | 2 +- 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/cmd/dx.go b/cmd/dx.go index ea87949a..fb890042 100644 --- a/cmd/dx.go +++ b/cmd/dx.go @@ -144,6 +144,149 @@ func doctorOutput(settings graycodeconfig.Settings) string { return strings.TrimRight(b.String(), "\n") } +// doctorJSON returns the doctor diagnostics as indented JSON, mirroring the +// fields of doctorOutput but as machine-parseable structured data. +func doctorJSON(settings graycodeconfig.Settings) string { + type sessionDirInfo struct { + Path string `json:"path"` + Status string `json:"status"` + Writable bool `json:"writable"` + Files int `json:"files"` + } + type gitInfo struct { + Repository bool `json:"repository"` + Branch string `json:"branch,omitempty"` + Head string `json:"head,omitempty"` + Clean bool `json:"clean"` + Modified int `json:"modified"` + } + + effectiveProvider := strings.TrimSpace(settings.Provider) + if effectiveProvider == "" { + effectiveProvider = "(not configured)" + } + effectiveModel := strings.TrimSpace(graycodeconfig.ActiveModel(context.Background())) + if effectiveModel == "" { + effectiveModel = "(not configured)" + } + + shell := os.Getenv("SHELL") + if shell == "" { + shell = "(not set)" + } + termVal := os.Getenv("TERM") + if termVal == "" { + termVal = "(not set)" + } + colorTerm := os.Getenv("COLORTERM") + if colorTerm == "" { + colorTerm = "(not set)" + } + + v := version + if v == "" { + v = "(dev)" + } + + var sessDir *sessionDirInfo + if dir := storage.SessionsDir(); dir != "" { + info := &sessionDirInfo{Path: dir} + if st, err := os.Stat(dir); err != nil { + info.Status = "missing" + } else if !st.IsDir() { + info.Status = "not a directory" + } else { + testFile := filepath.Join(dir, ".dx_write_test") + // #nosec G304 -- testFile built from internal sessions directory path + writable := true + if f, err := os.Create(testFile); err != nil { + writable = false + } else { + _ = f.Close() + _ = os.Remove(testFile) + } + entries, _ := os.ReadDir(dir) + info.Status = "exists" + info.Writable = writable + info.Files = len(entries) + } + sessDir = info + } + + mcpCount := len(settings.MCPServers) + len(mcpServers) + plugins := 0 + if manifests, err := plugin.List(); err == nil { + plugins = len(manifests) + } + + agentsMD := graycodeconfig.LoadAgentsMD() + agentsState := "not found" + if agentsMD != "" { + agentsState = "found" + } + + var git *gitInfo + if branch, err := gitOutput("rev-parse", "--abbrev-ref", "HEAD"); err == nil && branch != "" { + g := &gitInfo{Repository: true, Branch: branch} + if head, err := gitOutput("rev-parse", "--short", "HEAD"); err == nil { + g.Head = head + } + if status, err := gitOutput("status", "--short"); err == nil { + if status == "" { + g.Clean = true + } else { + g.Modified = len(strings.Split(status, "\n")) + } + } + git = g + } + + buildDate := buildDate + if buildDate == "unknown" { + buildDate = "" + } + + d := struct { + GoVersion string `json:"go_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Shell string `json:"shell"` + Term string `json:"term"` + ColorTerm string `json:"colorterm"` + Version string `json:"version"` + BuildDate string `json:"build_date,omitempty"` + Provider string `json:"provider"` + APIKey string `json:"api_key"` + Model string `json:"model"` + SessionDir *sessionDirInfo `json:"session_directory,omitempty"` + MCPServers int `json:"mcp_servers"` + Plugins int `json:"plugins"` + AgentsMD string `json:"agents_md"` + Git *gitInfo `json:"git,omitempty"` + Disk string `json:"disk"` + }{ + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Shell: shell, + Term: termVal, + ColorTerm: colorTerm, + Version: v, + BuildDate: buildDate, + Provider: effectiveProvider, + APIKey: maskedKeyStatus(graycodeconfig.ActiveProvider(context.Background())), + Model: effectiveModel, + SessionDir: sessDir, + MCPServers: mcpCount, + Plugins: plugins, + AgentsMD: agentsState, + Git: git, + Disk: diskSpaceInfo(), + } + out, _ := json.MarshalIndent(d, "", " ") + return string(out) +} + // maskedKeyStatus returns the API key status for a provider, masking the actual key. func maskedKeyStatus(provider string) string { provider = strings.TrimSpace(provider) diff --git a/cmd/dx_test.go b/cmd/dx_test.go index 28ef4f14..5c9a07f8 100644 --- a/cmd/dx_test.go +++ b/cmd/dx_test.go @@ -64,6 +64,40 @@ func TestDoctorOutputWithMCPServers(t *testing.T) { } } +func TestDoctorJSONIsValidStructuredOutput(t *testing.T) { + preserveCLICompilerVersionState(t) + version = "test-dx-version" + settings := graycodeconfig.Settings{ + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + MCPServers: []graycodeconfig.MCPServerConfig{ + {Name: "test-mcp", Command: "test-cmd"}, + }, + } + + var d struct { + Version string `json:"version"` + Provider string `json:"provider"` + MCPServers int `json:"mcp_servers"` + GoVersion string `json:"go_version"` + } + if err := json.Unmarshal([]byte(doctorJSON(settings)), &d); err != nil { + t.Fatalf("doctorJSON produced invalid JSON: %v", err) + } + if d.Version != "test-dx-version" { + t.Errorf("expected version %q, got %q", "test-dx-version", d.Version) + } + if d.Provider != "anthropic" { + t.Errorf("expected provider %q, got %q", "anthropic", d.Provider) + } + if d.MCPServers != 1 { + t.Errorf("expected mcp_servers 1, got %d", d.MCPServers) + } + if d.GoVersion == "" { + t.Error("expected go_version to be populated") + } +} + func TestDebugOutputHasMemoryStats(t *testing.T) { sess := engine.NewSession("openai", "gpt-4o", "test system", tool.NewRegistry()) sess.AddUser("hello") diff --git a/cmd/root.go b/cmd/root.go index 16f36cfd..4b9d63d1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -573,7 +573,7 @@ var doctorCmd = &cobra.Command{ return err } if doctorJSONFlag { - cmd.Println(doctorOutput(settings)) + cmd.Println(doctorJSON(settings)) } else { prog := NewCLIProgress("Doctor", []string{"Running diagnostics"}) defer prog.Abort() From 0441bccf13f041143168ff163a67c379ce4e15e2 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:12:52 +0530 Subject: [PATCH 087/116] feat: show active step name in CLIProgress animation frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-place TTY animation previously showed only the spinner, progress bar, step counter, and ETA — the user could not tell which step of a multi-step flow was currently running. Render the active step name (tinted textPrimary) in the frame so long-running steps are identifiable at a glance. --- cmd/progress_cli.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/progress_cli.go b/cmd/progress_cli.go index 7e3050ad..d5d20c22 100644 --- a/cmd/progress_cli.go +++ b/cmd/progress_cli.go @@ -53,7 +53,8 @@ func (c *CLIProgress) StartStep(i int) { if remaining := c.pt.EstimateRemaining(); remaining > 0 { eta = fmt.Sprintf(" · ETA %s", formatDurationShort(remaining)) } - fmt.Fprintf(c.w, "\r%s %s %d/%d%s\033[K", frame, c.bar(), i+1, len(c.pt.Steps), eta) + name := c.tint(c.pt.Steps[i].Name, textPrimary) + fmt.Fprintf(c.w, "\r%s %s %s %d/%d%s\033[K", frame, c.bar(), name, i+1, len(c.pt.Steps), eta) }) } From 04257706ba864c4a14c5f73fee8cff5d68ae1a61 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:17:57 +0530 Subject: [PATCH 088/116] feat: theme the developer path readiness report graycode path printed a plain-text readiness report while every other internal status report (ecosystem panel, credentials, catalog health) was themed. Colorize the title, status, section headers, per-check glyphs, and fix hints using the semantic theme colors, matching the rest of the CLI's visual identity. Honors NO_COLOR/FORCE_COLOR/TTY via theme.Tint. --- internal/config/developer_path.go | 37 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/internal/config/developer_path.go b/internal/config/developer_path.go index 5617a20e..07713c6b 100644 --- a/internal/config/developer_path.go +++ b/internal/config/developer_path.go @@ -3,6 +3,7 @@ package config import ( "context" "fmt" + "image/color" "os" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/GrayCodeAI/graycode-cli/internal/intelligence/memory" "github.com/GrayCodeAI/graycode-cli/internal/provider/gateway" "github.com/GrayCodeAI/graycode-cli/internal/sandbox" + "github.com/GrayCodeAI/graycode-cli/internal/theme" "github.com/GrayCodeAI/graycode-cli/internal/token" "github.com/GrayCodeAI/graycode-cli/internal/tool" @@ -258,40 +260,61 @@ func developerPathNextStep(r DeveloperPathReport, setup SetupState) string { return "Run graycode preflight for details, then /config if needed" } +// pathStatusColor maps a readiness status to a semantic report color. +func pathStatusColor(s PathCheckStatus) color.Color { + switch s { + case PathPass: + return theme.ReportSuccess + case PathWarn: + return theme.ReportWarn + case PathFail: + return theme.ReportError + default: + return theme.ReportMuted + } +} + // FormatDeveloperPathReport renders the developer path readiness report for CLI/TUI. func FormatDeveloperPathReport(ctx context.Context) string { r := EvaluateDeveloperPath(ctx) var b strings.Builder - b.WriteString("Developer path (graycode · graycode-router · shrike · harrier)\n\n") + b.WriteString(theme.Tint("Developer path (graycode · graycode-router · shrike · harrier)", theme.ReportInfo) + "\n\n") status := "NEEDS SETUP" + var statusColor color.Color = theme.ReportWarn switch { case r.Ready: status = "READY" + statusColor = theme.ReportSuccess case r.ChatReady && !r.SecureReady: status = "SECURITY FIX NEEDED" + statusColor = theme.ReportError case r.SecureReady && !r.ChatReady: status = "ALMOST READY" + statusColor = theme.ReportWarn } - b.WriteString("Status: " + status + "\n\n") + b.WriteString(theme.Tint("Status:", theme.ReportMuted) + " " + theme.Tint(status, statusColor) + "\n\n") sections := []string{"Setup", "Security", "Sandbox", "Ecosystem"} for _, sec := range sections { - b.WriteString(sec + "\n") + b.WriteString(theme.Tint(sec, theme.ReportInfo) + "\n") for _, c := range r.Checks { if c.Section != sec { continue } - b.WriteString(fmt.Sprintf(" %s %s — %s\n", pathStatusGlyph(c.Status), c.Name, c.Detail)) + b.WriteString(fmt.Sprintf(" %s %s — %s\n", + theme.Tint(pathStatusGlyph(c.Status), pathStatusColor(c.Status)), + theme.Tint(c.Name, theme.ReportMuted), + theme.Tint(c.Detail, theme.ReportInfo))) if c.FixHint != "" && c.Status != PathPass { - b.WriteString(" → " + c.FixHint + "\n") + b.WriteString(" " + theme.Tint("→ "+c.FixHint, theme.ReportWarn) + "\n") } } b.WriteByte('\n') } - b.WriteString("Next: " + r.NextStep + "\n") - b.WriteString("\nDocs: docs/DEVELOPER-PATH.md · docs/SECURITY-DEVELOPER.md · graycode doctor · graycode preflight\n") + b.WriteString(theme.Tint("Next:", theme.ReportMuted) + " " + r.NextStep + "\n") + b.WriteString("\n" + theme.Tint("Docs: docs/DEVELOPER-PATH.md · docs/SECURITY-DEVELOPER.md · graycode doctor · graycode preflight", theme.ReportMuted) + "\n") return strings.TrimRight(b.String(), "\n") } From b43333375840646e543bf7d9d58ee52c23577999 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:22:07 +0530 Subject: [PATCH 089/116] feat: honor NO_COLOR in the onboarding wizard The first-run setup wizard and welcome banner emitted hardcoded ANSI escapes unconditionally, breaking the CLI's non-TTY purity (colored output even under NO_COLOR). Gate the color codes on theme.ColorEnabled() and use the theme's brand ANSI for the banner, so scripted or NO_COLOR environments get plain text. Update TestColorConstants to assert the gating behavior. --- internal/onboarding/onboarding.go | 39 +++++++++++++++++++++----- internal/onboarding/onboarding_test.go | 20 +++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/onboarding/onboarding.go b/internal/onboarding/onboarding.go index 507c5c9a..f2c5f293 100644 --- a/internal/onboarding/onboarding.go +++ b/internal/onboarding/onboarding.go @@ -15,17 +15,42 @@ import ( "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" ) -const ( - teal = "\033[38;2;78;205;196m" - dim = "\033[2m" - bold = "\033[1m" - red = "\033[38;2;224;85;85m" - reset = "\033[0m" +var ( + teal string + dim string + bold string + red string + reset string + brand string ) +func init() { + initColorCodes() +} + +// initColorCodes sets the ANSI escape codes, honoring NO_COLOR/FORCE_COLOR/TTY +// so the onboarding wizard stays plain in scripted or NO_COLOR environments. +func initColorCodes() { + teal = "" + dim = "" + bold = "" + red = "" + reset = "" + brand = "" + if !internaltheme.ColorEnabled() { + return + } + teal = "\033[38;2;78;205;196m" + dim = "\033[2m" + bold = "\033[1m" + red = "\033[38;2;224;85;85m" + reset = "\033[0m" + brand = internaltheme.BrandANSI +} + // Welcome prints the graycode welcome banner. func Welcome(version string) { - graycodeC := internaltheme.BrandANSI + graycodeC := brand totalW := 80 if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 40 { diff --git a/internal/onboarding/onboarding_test.go b/internal/onboarding/onboarding_test.go index fdbb693d..a37f55b2 100644 --- a/internal/onboarding/onboarding_test.go +++ b/internal/onboarding/onboarding_test.go @@ -41,13 +41,27 @@ func TestWelcome(t *testing.T) { } func TestColorConstants(t *testing.T) { + // Color codes are gated on the environment: present when color is forced, + // empty when NO_COLOR is set (the wizard stays plain in scripted output). + t.Setenv("NO_COLOR", "") + t.Setenv("FORCE_COLOR", "1") + initColorCodes() if teal == "" { - t.Error("teal color should not be empty") + t.Error("teal color should not be empty when color is enabled") } if reset == "" { - t.Error("reset should not be empty") + t.Error("reset should not be empty when color is enabled") } if bold == "" { - t.Error("bold should not be empty") + t.Error("bold should not be empty when color is enabled") + } + + t.Setenv("NO_COLOR", "1") + initColorCodes() + if teal != "" { + t.Error("teal should be empty when NO_COLOR is set") + } + if reset != "" { + t.Error("reset should be empty when NO_COLOR is set") } } From 6ba1e6baca37d1f105aa3b32307ae9d911f6eb47 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:24:22 +0530 Subject: [PATCH 090/116] feat: theme the mission final summary line graycode mission's per-feature output was themed but the final Mission summary line stayed plain. Colorize it textPrimary to match the rest of the command's visual identity. --- cmd/mission.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/mission.go b/cmd/mission.go index 578749e7..45a128c7 100644 --- a/cmd/mission.go +++ b/cmd/mission.go @@ -137,7 +137,7 @@ func runMission(_ *cobra.Command, args []string) error { // Print results fmt.Println() - fmt.Println(m.Summary()) + fmt.Println(auditTint(m.Summary(), textPrimary)) fmt.Println() for _, f := range m.Features { status := icons.CheckBold() + " " From 5fb858e321cfec6074a2978a2449425abd124598 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:26:06 +0530 Subject: [PATCH 091/116] feat: theme the sandbox status report graycode sandbox status printed a plain multi-line report while every other CLI status report was themed. Colorize the header, per-change type tags, and stats line using the semantic theme colors, honoring NO_COLOR/FORCE_COLOR/TTY via theme.Tint. The Summary method is CLI-only. --- internal/diffsandbox/sandbox.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/diffsandbox/sandbox.go b/internal/diffsandbox/sandbox.go index c0b19d87..b2e09dd3 100644 --- a/internal/diffsandbox/sandbox.go +++ b/internal/diffsandbox/sandbox.go @@ -10,6 +10,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/graycode-cli/internal/theme" ) // ChangeType identifies the kind of file modification. @@ -340,24 +342,26 @@ func (s *Sandbox) Summary() string { defer s.mu.RUnlock() if len(s.changes) == 0 { - return "No pending changes." + return theme.Tint("No pending changes.", theme.ReportMuted) } var b strings.Builder stats := s.statsLocked() - b.WriteString(fmt.Sprintf("Pending changes (%d file(s)):\n", len(s.changes))) + b.WriteString(theme.Tint(fmt.Sprintf("Pending changes (%d file(s)):", len(s.changes)), theme.ReportInfo) + "\n") for _, path := range s.order { c, ok := s.changes[path] if !ok { continue } - b.WriteString(fmt.Sprintf(" [%s] %s\n", c.Type.String(), c.Path)) + b.WriteString(fmt.Sprintf(" %s %s\n", + theme.Tint("["+c.Type.String()+"]", theme.ReportMuted), + theme.Tint(c.Path, theme.ReportInfo))) } - b.WriteString(fmt.Sprintf("Stats: +%d -%d lines | %d created, %d modified, %d deleted\n", + b.WriteString(theme.Tint(fmt.Sprintf("Stats: +%d -%d lines | %d created, %d modified, %d deleted", stats.LinesAdded, stats.LinesRemoved, - stats.FilesCreated, stats.FilesModified, stats.FilesDeleted)) + stats.FilesCreated, stats.FilesModified, stats.FilesDeleted), theme.ReportMuted) + "\n") return b.String() } From 4f17ee375c3542b627ef854f693c61a1e4600c37 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:30:52 +0530 Subject: [PATCH 092/116] feat: theme the shell completion install confirmation The 'Installed completion to ' status line was the last plain confirmation in the CLI. Colorize 'Installed' in doneGreen and the target path in textPrimary, honoring ShouldColor() via auditTint. --- cmd/root.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 4b9d63d1..c7303dd7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -449,7 +449,8 @@ Fish: return fmt.Errorf("cannot write completion script: %w", err) } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Installed %s completion to %s\n", shell, path); err != nil { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s completion to %s\n", + auditTint("Installed", doneGreen), shell, auditTint(path, textPrimary)); err != nil { return fmt.Errorf("cannot write completion message: %w", err) } return nil From 30d2abae71a1a1e011d8dc9a54f034864696f566 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:31:56 +0530 Subject: [PATCH 093/116] feat: theme the taste prompt-fragment header The 'System prompt fragment that would be injected:' heading and its separator were plain. Colorize the heading in textPrimary and the separator in textMuted via auditTint; the injected fragment itself stays plain (it is data). --- cmd/taste.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/taste.go b/cmd/taste.go index 989f64ab..29df55f7 100644 --- a/cmd/taste.go +++ b/cmd/taste.go @@ -104,8 +104,9 @@ func runTasteShow(_ *cobra.Command, _ []string) error { // Also show prompt context if anything is learned. ctx := profile.ToPromptContext() if ctx != "" { - fmt.Println("\nSystem prompt fragment that would be injected:") - fmt.Println(strings.Repeat("-", 50)) + fmt.Println() + fmt.Println(auditTint("System prompt fragment that would be injected:", textPrimary)) + fmt.Println(auditTint(strings.Repeat("-", 50), textMuted)) fmt.Println(ctx) } From 01d024c27106e96a6178e035521ee3308e0b1fd8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:33:22 +0530 Subject: [PATCH 094/116] feat: theme the review list view status and findings The review list row printed the status text and findings bracket in plain text while the status glyph was already colored. Colorize the status via reviewStatusColor and the [maxSeverity] bracket via severityStyle, keeping the row layout intact. --- cmd/review_read.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/review_read.go b/cmd/review_read.go index 2341112a..20f3955f 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -190,9 +190,13 @@ func runReviewList(_ *cobra.Command, _ []string) error { icon := statusIcon(r.Status) findings := "" if len(r.Findings) > 0 { - findings = fmt.Sprintf(" %d findings [%s]", len(r.Findings), r.MaxSeverity) + findings = auditTint(fmt.Sprintf(" %d findings", len(r.Findings)), textMuted) + " " + severityStyle(r.MaxSeverity) } - fmt.Printf("%s #%-3d %s %s%s %s\n", icon, r.ID, r.SHA[:8], r.Status, findings, r.CreatedAt.Format("Jan 02 15:04")) + fmt.Printf("%s #%-3d %s %s%s %s\n", + icon, r.ID, r.SHA[:8], + auditTint(string(r.Status), reviewStatusColor(r.Status)), + findings, + r.CreatedAt.Format("Jan 02 15:04")) } return nil } From a082249b0c562b2e0f2ba91e4645c121b1ce4262 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:34:14 +0530 Subject: [PATCH 095/116] feat: theme the skills trending list number prefix The trending skill list printed plain numeric prefixes. Colorize the index in textMuted via auditTint; the shared FormatSkillEntry body stays plain. --- cmd/skills_cmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index 5854cefb..f2bff432 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -143,7 +143,7 @@ var skillsTrendingCmd = &cobra.Command{ return err } for i, e := range results { - fmt.Printf("%d. %s", i+1, strings.TrimLeft(plugin.FormatSkillEntry(e), " ")) + fmt.Printf("%s. %s", auditTint(fmt.Sprintf("%d", i+1), textMuted), strings.TrimLeft(plugin.FormatSkillEntry(e), " ")) } return nil }, From ea41a196ade64163162c8c14ae4d47a979cda935 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:35:29 +0530 Subject: [PATCH 096/116] feat: theme the review findings detail index and file:line The findings list printed plain indices and file:line locations. Colorize the index in textMuted and the file path in textPrimary via auditTint, matching the themed severity bracket and message lines. --- cmd/review_read.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/review_read.go b/cmd/review_read.go index 20f3955f..9b8c7931 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -232,7 +232,10 @@ func printReviewDetail(r *ReviewRecord) { for i, f := range r.Findings { sev := severityStyle(f.Severity.String()) - fmt.Printf(" %d. %s %s:%d\n", i+1, sev, f.File, f.Line) + fmt.Printf(" %s %s %s:%d\n", + auditTint(fmt.Sprintf("%d.", i+1), textMuted), + sev, + auditTint(f.File, textPrimary), f.Line) fmt.Printf(" %s\n", auditTint(f.Message, textMuted)) if f.Fix != "" { fmt.Printf(" %s %s\n", auditTint("Fix:", textMuted), f.Fix) From 6651658bb7b3fdbd412564fad727d0c957bab06d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:39:59 +0530 Subject: [PATCH 097/116] feat: mute command descriptions in help output The modern help template rendered command descriptions in plain text while headers were gold and names textPrimary. Colorize descriptions in textMuted via a new gcDesc template func for clearer visual hierarchy. Descriptions are the last column, so zero-width ANSI cannot break the padded name alignment; under NO_COLOR auditTint keeps them plain, so the golden help files stay unchanged. --- cmd/help_template.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/help_template.go b/cmd/help_template.go index 7bf3e632..f51b8169 100644 --- a/cmd/help_template.go +++ b/cmd/help_template.go @@ -5,12 +5,14 @@ import ( ) // Modern, theme-aware help output. Section headers render in the brand gold, -// command names in textPrimary, descriptions/flags stay plain so fixed-width -// columns keep their alignment. All color honors ShouldColor() (NO_COLOR, -// --quiet, non-TTY) via auditTint. Pad-then-colorize keeps columns aligned. +// command names in textPrimary, command descriptions in muted. Flags stay +// plain. Pad-then-colorize keeps the name column aligned; descriptions are the +// last column so coloring them (zero-width ANSI) cannot break alignment. All +// color honors ShouldColor() (NO_COLOR, --quiet, non-TTY) via auditTint. func init() { cobra.AddTemplateFunc("gcHeader", func(s string) string { return auditTint(s, graycodeColor) }) cobra.AddTemplateFunc("gcCmd", func(s string) string { return auditTint(s, textPrimary) }) + cobra.AddTemplateFunc("gcDesc", func(s string) string { return auditTint(s, textMuted) }) rootCmd.SetUsageTemplate(modernUsageTemplate) } @@ -25,13 +27,13 @@ const modernUsageTemplate = `{{gcHeader "Usage:"}}{{if .Runnable}} {{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} {{gcHeader "Available Commands:"}}{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} - {{gcCmd (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + {{gcCmd (rpad .Name .NamePadding)}} {{gcDesc .Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} {{gcHeader .Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} - {{gcCmd (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + {{gcCmd (rpad .Name .NamePadding)}} {{gcDesc .Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} {{gcHeader "Additional Commands:"}}{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} - {{gcCmd (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + {{gcCmd (rpad .Name .NamePadding)}} {{gcDesc .Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} {{gcHeader "Flags:"}} {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} From 0027ffc26be7ee42dd22e57fc9bddf28e99bbf8c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:42:04 +0530 Subject: [PATCH 098/116] feat: show a muted usage summary after exec completes After an exec run prints its response, emit a muted token/turn/duration/ model summary to stderr so stdout stays byte-clean for piping and the model output remains the sole data. Gated by --quiet; color honors ShouldColor() via auditTint. --- cmd/exec.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/exec.go b/cmd/exec.go index 665a55b5..cdc44927 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -390,6 +390,12 @@ func runExec(_ *cobra.Command, args []string) error { if !strings.HasSuffix(response.String(), "\n") { fmt.Println() } + if !IsQuiet() { + fmt.Fprintf(os.Stderr, "%s\n", auditTint( + fmt.Sprintf("graycode: %d tokens in / %d out · %d turn(s) · %s · %s", + totalIn, totalOut, turns, time.Since(start).Round(time.Millisecond), effectiveModel), + textMuted)) + } if exitCode != 0 { return fmt.Errorf("exec failed: %s", execErr) } From b97baf085c59ad33eb6a416a0731948454ff7561 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:45:18 +0530 Subject: [PATCH 099/116] fix: bound the gh issue create call with a 60s timeout The issue command ran 'gh issue create' with context.Background() and no deadline, so a stalled GitHub call could hang the CLI indefinitely. Wrap it in a 60s context timeout and report a clear deadline-exceeded error. --- cmd/issue.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/issue.go b/cmd/issue.go index bffd34d4..8e54fde2 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "strings" + "time" "github.com/spf13/cobra" ) @@ -96,10 +97,15 @@ func runIssue(cmd *cobra.Command, args []string) error { ghArgs = append(ghArgs, "--label", l) } - cc := exec.CommandContext(context.Background(), "gh", ghArgs...) // #nosec G204 -- fixed command 'gh' with args; title/body are data arguments, not the executable + gctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cc := exec.CommandContext(gctx, "gh", ghArgs...) // #nosec G204 -- fixed command 'gh' with args; title/body are data arguments, not the executable cc.Stderr = os.Stderr out, err := cc.Output() if err != nil { + if gctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("gh issue create timed out after 60s") + } return fmt.Errorf("gh issue create failed: %w", err) } cmd.Println(auditTint("Issue created: ", doneGreen) + auditTint(strings.TrimSpace(string(out)), textPrimary)) From 7e18d2b606f5088badf673ba8cbadd59a2b1ef56 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:49:14 +0530 Subject: [PATCH 100/116] feat: announce which review is being fixed before running exec review fix iterates open reviews and runs a multi-turn graycode exec for each. It printed only the completion status after each exec, so with --all the user could not tell which review was being worked on. Print a themed 'Fixing review #N (sha)...' line (gold bolt + textPrimary) before each exec run. --- cmd/review_fix.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/review_fix.go b/cmd/review_fix.go index bbd1e011..1417847b 100644 --- a/cmd/review_fix.go +++ b/cmd/review_fix.go @@ -65,6 +65,7 @@ func runReviewFix(_ *cobra.Command, args []string) error { } for _, r := range reviews { + fmt.Printf("%s %s\n", auditTint(icons.Bolt(), toolGold), auditTint(fmt.Sprintf("Fixing review #%d (%s)...", r.ID, r.SHA[:8]), textPrimary)) if err := fixReview(store, r); err != nil { fmt.Printf("%s %s\n", auditTint(icons.CloseThick(), errorCoral), auditTint(fmt.Sprintf("Review #%d (%s): %v", r.ID, r.SHA[:8], err), errorCoral)) continue From 4a622f560c829dada29681eaa6e36c72c89b6a03 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:51:10 +0530 Subject: [PATCH 101/116] feat: animate review analyze model call review analyze ran a single kestrel model call (up to the 3-minute analyze timeout) behind a static 'Analyzing...' line. Replace it with a CLIProgress animation (rainbow spinner + themed bar + ETA) so the wait has live feedback, matching review run. Gated by --quiet; findings output stays clean after the step completes. --- cmd/review_analyze.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cmd/review_analyze.go b/cmd/review_analyze.go index 75df03f0..6f284371 100644 --- a/cmd/review_analyze.go +++ b/cmd/review_analyze.go @@ -155,11 +155,23 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { // Use the analysis prompt as a "diff" — kestrel will review it. analysisInput := fmt.Sprintf("# Analysis Type: %s\n\n%s\n\n---\n\n%s", analysisType, prompt, content) - fmt.Printf("%s\n", auditTint("Analyzing ("+analysisType+")...", textPrimary)) + var prog *CLIProgress + if !IsQuiet() { + prog = NewCLIProgress("Analyze", []string{fmt.Sprintf("Analyzing %s", analysisType)}) + defer prog.Abort() + prog.StartStep(0) + } result, err := bridge.ReviewContracts(ctx, analysisInput) if err != nil { + if prog != nil { + prog.FailStep(0, err.Error()) + } return fmt.Errorf("analysis failed: %w", err) } + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } // Store as a review record. projectDir, _ := os.Getwd() From 1d04c23a501c8fa0a1d5ec1dfe5f43fb9ce033bb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 08:57:16 +0530 Subject: [PATCH 102/116] feat: animate mission execution The mission command ran up to 30 minutes of parallel LLM workers behind a static 'Executing with N parallel workers...' line with no live feedback. Wrap the run in a single-step CLIProgress animation (rainbow spinner + themed bar) showing the feature/worker count. Gated by --quiet; the per-feature results still print cleanly after the step completes. --- cmd/mission.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cmd/mission.go b/cmd/mission.go index 45a128c7..f66a24c0 100644 --- a/cmd/mission.go +++ b/cmd/mission.go @@ -124,7 +124,12 @@ func runMission(_ *cobra.Command, args []string) error { workerFn = graphTrackingWorker(tool.GetTaskStore(), workerFn) } - fmt.Printf("%s\n\n", auditTint(fmt.Sprintf("Executing with %d parallel workers...", cfg.MaxWorkers), textPrimary)) + var prog *CLIProgress + if !IsQuiet() { + prog = NewCLIProgress("Mission", []string{fmt.Sprintf("Executing %d features with %d workers", len(m.Features), cfg.MaxWorkers)}) + defer prog.Abort() + prog.StartStep(0) + } var runErr error if missionFromTasks { runErr = m.RunStaged(ctx, workerFn, mission.WithExecutionWaves(waves)) @@ -132,8 +137,15 @@ func runMission(_ *cobra.Command, args []string) error { runErr = m.Run(ctx, workerFn) } if runErr != nil { + if prog != nil { + prog.FailStep(0, runErr.Error()) + } return runErr } + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } // Print results fmt.Println() From 6ef8c08a4e1daab3fcb8d593c67b7dbffe2e5146 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:06:02 +0530 Subject: [PATCH 103/116] feat: theme exec fanout attempt headers The best-of-N fanout loop printed each attempt header to stderr in plain text. Theme them with the info-sky accent so each attempt reads as a distinct phase, matching the themed exec usage summary. Stderr-only, so the model output on stdout stays byte-clean. --- cmd/exec.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exec.go b/cmd/exec.go index cdc44927..8bf984a9 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -759,7 +759,7 @@ func runExecFanout(prompt string, n int) error { attempts := make([]fanoutAttempt, 0, n) anyOK := false for i := 1; i <= n; i++ { - fmt.Fprintf(os.Stderr, "\n=== fanout attempt %d/%d ===\n", i, n) + fmt.Fprintf(os.Stderr, "\n%s\n", auditTint(fmt.Sprintf("=== fanout attempt %d/%d ===", i, n), infoSky)) att := fanoutAttempt{Attempt: i} branch := fmt.Sprintf("graycode-exec/%d-fanout%d-%s", start.UnixMilli(), i, randomHex(4)) From 8cc777eb35623ed50cc821d87ffc8c4e9bd559b5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:06:25 +0530 Subject: [PATCH 104/116] feat: theme exec fanout comparison report status The best-of-N comparison report printed each attempt's ok/failed status in plain text. Colorize the status (green ok / coral failed, with the error appended in coral) and the report header in the info-sky accent, matching the themed attempt headers. Stderr-only; stdout stays byte-clean. --- cmd/exec.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 8bf984a9..05e28972 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -866,13 +866,13 @@ func fanoutSummaryLines(attempts []fanoutAttempt) string { } func printFanoutReport(attempts []fanoutAttempt) { - fmt.Fprintln(os.Stderr, "\n=== fan-out comparison (worktrees kept for inspection) ===") + fmt.Fprintln(os.Stderr, auditTint("\n=== fan-out comparison (worktrees kept for inspection) ===", infoSky)) for _, a := range attempts { - status := icons.Check() + " ok" + status := auditTint(icons.Check()+" ok", doneGreen) if !a.OK { - status = icons.Close() + " failed" + status = auditTint(icons.Close()+" failed", errorCoral) if a.Error != "" { - status += " — " + a.Error + status += auditTint(" — "+a.Error, errorCoral) } } fmt.Fprintf(os.Stderr, "\n#%d %s\n branch: %s\n worktree: %s\n tokens: in=%d out=%d turns=%d\n duration: %s\n", From 82810072d6a358cd312c8d196963442baaa88040 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:07:19 +0530 Subject: [PATCH 105/116] feat: theme exec stderr diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exec command printed error/hint/warning diagnostics to stderr in plain text. Colorize them with semantic accents — errors in coral, hints in muted, and warnings (untrusted GitHub autonomy cap, session-persist failure) in amber — matching the themed usage summary and fanout headers. Stderr-only; the model output on stdout stays byte-clean. --- cmd/exec.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 05e28972..5ba4ccd7 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -253,9 +253,7 @@ func runExec(_ *cobra.Command, args []string) error { if ghaCtx.Active && !ghaCtx.Trusted { const ceiling = engine.AutonomyBasic if sess.PermSvc().Autonomy() > ceiling { - fmt.Fprintf(os.Stderr, - "graycode: untrusted GitHub event (author_association=%q); capping autonomy at %s\n", - ghaCtx.AuthorAssociation, ceiling) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("graycode: untrusted GitHub event (author_association=%q); capping autonomy at %s", ghaCtx.AuthorAssociation, ceiling), warnAmber)) sess.PermSvc().SetAutonomy(ceiling) } } @@ -322,9 +320,9 @@ func runExec(_ *cobra.Command, args []string) error { case "error": execErr = ev.Content if execOutputFormat == "text" { - _, _ = fmt.Fprintf(os.Stderr, "\nerror: %s\n", ev.Content) + _, _ = fmt.Fprintf(os.Stderr, "\n%s\n", auditTint("error: "+ev.Content, errorCoral)) if h := errhint.CLIHint(errors.New(ev.Content)); h != "" { - _, _ = fmt.Fprintf(os.Stderr, " hint: %s\n", h) + _, _ = fmt.Fprintf(os.Stderr, "%s\n", auditTint(" hint: "+h, textMuted)) } } if execOutputFormat == "stream-json" { @@ -687,7 +685,7 @@ func persistExecSession(id, model, provider, userMsg, assistantMsg string) { }, } if err := session.Save(s); err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to persist exec session %s: %v\n", id, err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("warning: failed to persist exec session %s: %v", id, err), warnAmber)) } } From 969b0d5aad81f1b424bfba7c240f667dae349866 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:08:06 +0530 Subject: [PATCH 106/116] feat: theme ai-comments and chat diagnostics Theme the one-off stderr diagnostics in ai-comments (directive dispatch and token-strip failures in coral) and chat (ignored --session-id notice in muted, plugin load failure warning in amber). Consistent with the themed exec diagnostics; stderr-only. --- cmd/ai_comments.go | 4 ++-- cmd/chat.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/ai_comments.go b/cmd/ai_comments.go index 8cd66252..9cf661f3 100644 --- a/cmd/ai_comments.go +++ b/cmd/ai_comments.go @@ -150,7 +150,7 @@ func processAIDirectives(dir string, ignore []string) int { processed := 0 for _, d := range directives { if err := aiDispatchFn(d); err != nil { - fmt.Fprintf(os.Stderr, "AI directive %s:%d failed: %v\n", d.Path, d.Line, err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("AI directive %s:%d failed: %v", d.Path, d.Line, err), errorCoral)) continue } // Resolve back to an absolute path for removal; scan returns paths @@ -160,7 +160,7 @@ func processAIDirectives(dir string, ignore []string) int { full = filepath.Join(dir, d.Path) } if err := removeAIComment(full, d.Line); err != nil { - fmt.Fprintf(os.Stderr, "AI directive %s:%d: failed to strip token: %v\n", d.Path, d.Line, err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("AI directive %s:%d: failed to strip token: %v", d.Path, d.Line, err), errorCoral)) continue } processed++ diff --git a/cmd/chat.go b/cmd/chat.go index 9ecee297..69e82398 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -72,7 +72,7 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) { } if sessionIDFlag != "" && (resumeID != "" || continueFlag) { // --session-id is ignored when --resume or --continue is also given. - fmt.Fprintf(os.Stderr, "graycode: --session-id ignored during resume/continue\n") + fmt.Fprintf(os.Stderr, "%s\n", auditTint("graycode: --session-id ignored during resume/continue", textMuted)) } if resumeID == "" && !continueFlag { return id, nil, nil @@ -450,7 +450,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings grayco runtime := plugin.NewRuntime() if err := runtime.LoadAll(); err != nil { // Surface plugin load failure so users know plugins are missing. - fmt.Fprintf(os.Stderr, "Warning: failed to load plugins: %v\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Warning: failed to load plugins: %v", err), warnAmber)) return } runtime.RegisterHooks() From 8a599a9b57ea957effa0a934d69b150d3e923b73 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:08:29 +0530 Subject: [PATCH 107/116] feat: theme chat REPL error lines The interactive chat REPL printed errors to stderr in plain text. Colorize them in coral. The REPL's streamed tool output stays plain (data). --- cmd/chat_print.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/chat_print.go b/cmd/chat_print.go index cc00b910..6fefebee 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -354,7 +354,7 @@ func runRepl() error { } if output, handled, builtinErr := replBuiltinResponse(input, sess, settings, sessionID); handled { if builtinErr != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", builtinErr) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Error: %v", builtinErr), errorCoral)) continue } if output != "" { @@ -367,7 +367,7 @@ func runRepl() error { ch, err := sess.Stream(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Error: %v", err), errorCoral)) continue } @@ -413,7 +413,7 @@ func runRepl() error { if outputFormat == "stream-json" { writePrintResult(printed.String(), sessionID, sess, true, []string{ev.Content}) } - fmt.Fprintf(os.Stderr, "Error: %s\n", ev.Content) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Error: %s", ev.Content), errorCoral)) case "done": switch outputFormat { case "text": From 8c5adb550bada9af512e6661d128e12fff9dac00 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:09:22 +0530 Subject: [PATCH 108/116] feat: theme daemon and eval stderr warnings Theme the daemon telemetry/file-logging fallback warnings and the eval results-save warning in amber. Consistent with the themed exec and chat diagnostics; stderr-only. --- cmd/daemon.go | 6 +++--- cmd/eval.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/daemon.go b/cmd/daemon.go index 21391cfb..bfb167cc 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -86,7 +86,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { // Initialize OpenTelemetry telemetry (opt-in via GRAYCODE_ENABLE_TELEMETRY=1). telemetryProviders, telemetryErr := oteltrace.InitTelemetry(oteltrace.DefaultTelemetryConfig()) if telemetryErr != nil { - fmt.Fprintln(os.Stderr, "warning: telemetry initialization failed:", telemetryErr) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("warning: telemetry initialization failed: %v", telemetryErr), warnAmber)) } if telemetryProviders != nil && telemetryErr == nil && telemetryProviders.IsEnabled() { defer func() { @@ -100,7 +100,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { // tracing; failures are non-fatal. logBackend, logBackendErr := otellog.NewBackend(otellog.DefaultConfig()) if logBackendErr != nil { - fmt.Fprintln(os.Stderr, "warning: telemetry log backend initialization failed:", logBackendErr) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("warning: telemetry log backend initialization failed: %v", logBackendErr), warnAmber)) } if logBackend != nil && logBackend.Sharing() != otellog.SharingDisabled { defer func() { @@ -117,7 +117,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { if logErr != nil { // Fall back to stderr if file logging fails. daemonLogger = logger.New(os.Stderr, logLevelFromString(daemonLogLevel)) - fmt.Fprintln(os.Stderr, "warning: daemon file logging failed, falling back to stderr:", logErr) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("warning: daemon file logging failed, falling back to stderr: %v", logErr), warnAmber)) } else { daemonLogger = logger.New(logFile, logLevelFromString(daemonLogLevel)) } diff --git a/cmd/eval.go b/cmd/eval.go index f60b6700..c0add1e7 100644 --- a/cmd/eval.go +++ b/cmd/eval.go @@ -271,7 +271,7 @@ func runEval(_ *cobra.Command, _ []string) error { store := eval.DefaultResultStore() path, err := store.Save(result, model, "", hash) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to save results: %v\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Warning: failed to save results: %v", err), warnAmber)) } else { fmt.Printf("%s\n", auditTint("Results saved to: ", doneGreen)+auditTint(path, textPrimary)) } From 71102b8c757a4bcb1d68addf9ef7d349ff1599ce Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:15:18 +0530 Subject: [PATCH 109/116] feat: show old-to-new transition in config set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config set now reads the current value before writing and, when it changes, prints a modern old → new transition (key: old → new (updated)) instead of the bare 'updated key'. Falls back to the plain confirmation when there is no prior value or the value is unchanged. Settable keys are non-secret (API keys error out before reaching here), so no secret is exposed. Honors NO_COLOR via auditTint. --- cmd/root.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index c7303dd7..e03a58d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -668,10 +668,21 @@ var configCmd = &cobra.Command{ if len(args) < 3 { return fmt.Errorf("usage: graycode config set ") } - if err := graycodeconfig.SetGlobalSetting(args[1], strings.Join(args[2:], " ")); err != nil { + key := args[1] + newVal := strings.Join(args[2:], " ") + settings, err := loadEffectiveSettings() + if err != nil { return err } - cmd.Println(auditTint("updated ", doneGreen) + auditTint(args[1], textPrimary)) + oldVal, hadOld := graycodeconfig.SettingValue(settings, key) + if err := graycodeconfig.SetGlobalSetting(key, newVal); err != nil { + return err + } + if hadOld && oldVal != "" && oldVal != newVal { + cmd.Println(auditTint(key, textPrimary) + auditTint(": ", textMuted) + auditTint(oldVal, textMuted) + auditTint(" → ", graycodeColor) + auditTint(newVal, textPrimary) + auditTint(" (updated)", doneGreen)) + } else { + cmd.Println(auditTint("updated ", doneGreen) + auditTint(key, textPrimary)) + } return nil case "provider": if len(args) < 2 { From 73cdee24084e5caa80239e94a5630625caec1e90 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:16:30 +0530 Subject: [PATCH 110/116] refactor: unify config set/provider/model transition display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the old → new transition rendering into printConfigSetResult and use it for the config set, config provider, and config model paths so all three show the same modern transition (key: old → new (updated)) with the plain fallback when there is no prior value. --- cmd/root.go | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index e03a58d8..be847525 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -644,6 +644,19 @@ var preflightCmd = &cobra.Command{ }, } +// printConfigSetResult renders the result of a successful config write, +// showing a modern old → new transition when the value actually changed. +// Settable keys are non-secret (API keys error out before reaching here), +// so displaying the prior value cannot leak a secret. +func printConfigSetResult(cmd *cobra.Command, key, newVal string, settings graycodeconfig.Settings) { + oldVal, hadOld := graycodeconfig.SettingValue(settings, key) + if hadOld && oldVal != "" && oldVal != newVal { + cmd.Println(auditTint(key, textPrimary) + auditTint(": ", textMuted) + auditTint(oldVal, textMuted) + auditTint(" → ", graycodeColor) + auditTint(newVal, textPrimary) + auditTint(" (updated)", doneGreen)) + return + } + cmd.Println(auditTint("updated ", doneGreen) + auditTint(key, textPrimary)) +} + var configCmd = &cobra.Command{ Use: "config [get|set|provider|model|keys|routing-preview|migrate-deployments]", Short: "Show or update settings", @@ -674,33 +687,38 @@ var configCmd = &cobra.Command{ if err != nil { return err } - oldVal, hadOld := graycodeconfig.SettingValue(settings, key) if err := graycodeconfig.SetGlobalSetting(key, newVal); err != nil { return err } - if hadOld && oldVal != "" && oldVal != newVal { - cmd.Println(auditTint(key, textPrimary) + auditTint(": ", textMuted) + auditTint(oldVal, textMuted) + auditTint(" → ", graycodeColor) + auditTint(newVal, textPrimary) + auditTint(" (updated)", doneGreen)) - } else { - cmd.Println(auditTint("updated ", doneGreen) + auditTint(key, textPrimary)) - } + printConfigSetResult(cmd, key, newVal, settings) return nil case "provider": if len(args) < 2 { return fmt.Errorf("usage: graycode config provider ") } - if err := graycodeconfig.SetGlobalSetting("provider", strings.Join(args[1:], " ")); err != nil { + newVal := strings.Join(args[1:], " ") + settings, err := loadEffectiveSettings() + if err != nil { return err } - cmd.Println(auditTint("updated provider", doneGreen)) + if err := graycodeconfig.SetGlobalSetting("provider", newVal); err != nil { + return err + } + printConfigSetResult(cmd, "provider", newVal, settings) return nil case "model": if len(args) < 2 { return fmt.Errorf("usage: graycode config model ") } - if err := graycodeconfig.SetGlobalSetting("model", strings.Join(args[1:], " ")); err != nil { + newVal := strings.Join(args[1:], " ") + settings, err := loadEffectiveSettings() + if err != nil { + return err + } + if err := graycodeconfig.SetGlobalSetting("model", newVal); err != nil { return err } - cmd.Println(auditTint("updated model", doneGreen)) + printConfigSetResult(cmd, "model", newVal, settings) return nil case "keys": cmd.Println(apiKeyConfigSummary()) From 6aaae621a18872bf212511b21c3b609e28cfe14d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:17:26 +0530 Subject: [PATCH 111/116] feat: show (unset) for unset config values config get printed a blank line when a valid key had no value. Print '(unset)' in muted instead, so an empty result is distinguishable from a missing key (which still errors). --- cmd/root.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index be847525..992312df 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -675,7 +675,11 @@ var configCmd = &cobra.Command{ if !ok { return fmt.Errorf("unsupported setting key %q", args[1]) } - cmd.Println(value) + if value == "" { + cmd.Println(auditTint("(unset)", textMuted)) + } else { + cmd.Println(value) + } return nil case "set": if len(args) < 3 { From aff801a7f665495bd1e3966f0d3c29ace6e5fd28 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:19:06 +0530 Subject: [PATCH 112/116] feat: show elapsed time on verify completion verify now reports how long the check run took (verification passed in Xs), which is genuinely useful in non-TTY mode where no animation shows the elapsed time. The duration is muted so the done-green confirmation stays the visual anchor. --- cmd/verify_cmd.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/verify_cmd.go b/cmd/verify_cmd.go index e80a8742..694bd468 100644 --- a/cmd/verify_cmd.go +++ b/cmd/verify_cmd.go @@ -26,6 +26,7 @@ var verifyCmd = &cobra.Command{ Exits non-zero on the first failed check.`, RunE: func(cmd *cobra.Command, args []string) error { ok := true + start := time.Now() // Themed markers (padded to a fixed width so colorized output keeps // its column alignment; plain when piped via ShouldColor). okMark := auditTint("[OK] ", doneGreen) @@ -81,7 +82,7 @@ Exits non-zero on the first failed check.`, if !ok { return fmt.Errorf("verification failed — see messages above") } - cmd.Println(auditTint("verification passed", doneGreen)) + cmd.Println(auditTint("verification passed", doneGreen) + auditTint(" in "+time.Since(start).Round(time.Millisecond).String(), textMuted)) return nil }, } From 662e832936a20bfd278d8f33e73a1e5d0f3e8e4e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:22:04 +0530 Subject: [PATCH 113/116] docs: record graycode-eco integrity plan --- .../2026-09-05-graycode-eco-integrity.md | 1781 +++++++++++++++++ 1 file changed, 1781 insertions(+) create mode 100644 docs/plans/2026-09-05-graycode-eco-integrity.md diff --git a/docs/plans/2026-09-05-graycode-eco-integrity.md b/docs/plans/2026-09-05-graycode-eco-integrity.md new file mode 100644 index 00000000..3ea3c82c --- /dev/null +++ b/docs/plans/2026-09-05-graycode-eco-integrity.md @@ -0,0 +1,1781 @@ +# GrayCode Ecosystem Integrity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Repair the three broken links between the four graycode repos (skills registry, cloud wire contract, router boundary) and remove legacy `hawk`/`starling`/`eagle` naming from every user-visible surface. + +**Architecture:** Four independent workstreams, each shippable as its own PR stack. A fixes the graycode-cli → graycode-skills registry link at all three broken layers (publishing, JSON shape, install discovery). B renames the cloud wire contract from `hawk` to `graycode` in graycode-platform, which makes the already-correct CLI client work. C aligns the documented router boundary with the enforced one and repairs two defective guards. D is a mechanical naming sweep. + +**Tech Stack:** Go 1.26+ (graycode-cli, graycode-router), Python 3.11 + pytest (graycode-skills), TypeScript + Hono + vitest + Cloudflare D1 (graycode-platform), GitHub Actions. + +**Spec:** This document is self-contained. Its findings were produced by a read-only scouting pass over all four repos on 2026-09-05 and verified by an independent adversarial pass; every claim below carries a `file:line` citation. + +## Global Constraints + +- **Branch discipline (all four repos).** Never commit to `main`. Create a feature branch first, named `feat/`, `fix/` or `chore/`. Open a PR, get CI green, then merge. Source: `graycode-cli/AGENTS.md:16`, `graycode-skills/AGENTS.md:13`, `graycode-platform/AGENTS.md:13`. +- **graycode-cli is currently on branch `chore/compat-matrix-0-0-1`, not `main`.** Branch from `main` for this work, not from the current HEAD. +- **Commits:** Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`). No `Co-authored-by:` trailers in graycode-router; a githook strips them. Source: `graycode-router/AGENTS.md:563`. +- **CHANGELOG:** every repo follows Keep a Changelog. Add entries under `## [Unreleased]` in the repo you touch. graycode-cli uses Keep a Changelog 1.1.0, graycode-router and graycode-skills use 1.0.0. +- **Go:** Go 1.26+, pure Go, no CGO. `gofumpt` formatting is enforced in CI. Table-driven tests. +- **graycode-cli CI gate:** `make ci` runs `tidy fmt vet boundaries lint test-race security api-validate` (`graycode-cli/Makefile:160`). The `boundaries` target aggregates nine guard scripts (`Makefile:136`). +- **graycode-platform CI gate:** `pnpm --filter @graycode/worker check` runs `prettier --check . && tsc --noEmit && vitest run`. Repo is pinned to pnpm 9.15.0 via Corepack. +- **Load-bearing identifiers that must NOT be renamed in any workstream.** These are wire values, storage keys, or third-party contracts, not prose: + - `hwc_` device-token prefix (`graycode-platform/apps/worker/src/domain/tokens.ts:18`) + - `HAWK_CLOUD` service binding and `HawkCloudService` RPC entrypoint (`apps/bff/wrangler.jsonc:21,23`) + - OTel metric names `hawk.window_days`, `hawk.sessions`, `hawk.tokens.total` (`apps/worker/src/routes/analytics.ts:532,540,555`) + - `HAWK_CONFIG_DIR` env fallback (`graycode-router/config/provider_env.go:402`) + - `~/.hawk/{env,.env,.legacy-env-migrated}` migration source paths (`graycode-router/credentials/migrate.go:14,88`) + - `hawk_build` / `hawk_build_concise` ToolNamespace wire values (`graycode-router/tools/versioning.go:114,115`) + - `/hawk:` plugin invoke prefix (`graycode-skills/tools/sync_marketplace.py:65`) and the `hawk` value in `AGENT_ENUM` (`graycode-skills/tools/validate_skill.py:66`) + - `hawk-progressive-disclosure` marker written into existing SKILL.md files (`graycode-skills/tools/migrate_oversized_skills.py:30`) + - GitNexus index names inside `` blocks in every AGENTS.md / CLAUDE.md — these are regenerated by the tool + - Go module paths `github.com/GrayCodeAI/{harrier,kestrel,merlin,shrike,swift,falcon}` — these are **live** dependencies in `graycode-cli/go.mod:15-18,55-57,172`, not dead names + - The `/products/` URL slugs in `graycode-platform/apps/web/lib/products.ts:9` +- **Workstream B is the only one that changes a deployed wire contract.** It requires a D1 migration and a coordinated worker deploy. Do not merge B's worker PR without applying migration `0024` first. + +--- + +# Workstream A — Skills Registry + +**Problem.** `graycode skills search` cannot work today, and `graycode skills install GrayCodeAI/graycode-skills ` cannot work either. The link is broken at three independent layers, and fixing only the URL restores nothing. + +1. **No public URL exists.** `graycode-skills/.github/workflows/publish-registry.yml:55-62` generates `registry.json`, signs it, then uploads it only as a GitHub Actions artifact (90-day retention). There is no release, no Pages, no R2, no commit-back. `registry.json` is gitignored at `graycode-skills/.gitignore:50`, so `raw.githubusercontent.com` returns 404 under both the old `starling` name and the new one. Verified: both URLs return HTTP 404; `GrayCodeAI/starling` 301-redirects to `GrayCodeAI/graycode-skills`; the repo has `has_pages=false` and its only release `v0.1.0` has zero assets. +2. **The JSON shapes do not match.** `tools/update_registry.py:165` emits a bare JSON array. `graycode-cli/internal/plugin/registry.go:73-77` parses an object `{version, updated_at, skills[]}`. `json.Unmarshal` of an array into that struct fails, so `FetchIndex` returns `invalid index` (`registry.go:136-137`) even with a working URL. The emitter also omits `repo`, which `auto_skill.go:175` needs to build the clone URL. +3. **Install cannot find the skills.** `registry.go:261-265` scans only `/*/SKILL.md` or `/skills/*/SKILL.md`. graycode-skills stores skills at `categories///SKILL.md` and has no `skills/` directory, so every install returns `skill not found`. + +**Decision (approved):** publish `registry.json` as an asset on a rolling GitHub Release. Stable URL, no git bloat, no new infrastructure. + +**Also in scope.** `graycode-cli/internal/plugin/marketplace.go:49` points at `plugins-registry.json`, a file that **nothing in any of the four repos generates**. It is a phantom default source and is removed here. + +--- + +### Task A1: Emit the registry shape graycode-cli actually parses + +**Files:** +- Modify: `graycode-skills/tools/update_registry.py:115-124` (add `repo`), `:160-166` (wrap in object) +- Modify: `graycode-skills/tools/registry_schema.py:30-58` (allow `repo`), `:84-87` (`REGISTRY_SCHEMA` becomes an object), `:217-230` (`load_and_validate` reads the object) +- Test: `graycode-skills/tests/test_update_registry.py` + +**Interfaces:** +- Produces: on-disk `registry.json` of the form `{"version": 1, "skills": [entry, ...]}` where each entry gains `"repo": "GrayCodeAI/graycode-skills"`. Task A3 relies on this shape. `build_registry()` still returns the bare `list[dict]` so `tools/skill_graph.py:13,268` is unaffected. + +> **Determinism matters.** `update_registry.py --check` compares generated text against the file on disk and is run in CI. Do **not** add a wall-clock `updated_at`; it would make `--check` fail on every run. The top-level `updated_at` is omitted entirely. `graycode-cli` never reads `idx.Version` or `idx.UpdatedAt` (verified: zero references outside tests), so an absent `updated_at` is harmless. + +- [ ] **Step 1: Write the failing tests** + +Append to `graycode-skills/tests/test_update_registry.py`: + +```python +class TestCanonicalRenderShape: + """registry.json must match the object shape graycode-cli parses.""" + + def test_render_wraps_entries_in_object(self): + from update_registry import render_registry + + doc = json.loads(render_registry([{"name": "a", "description": "d"}])) + assert isinstance(doc, dict), "top level must be an object, not an array" + assert doc["version"] == 1 + assert doc["skills"] == [{"name": "a", "description": "d"}] + + def test_render_omits_updated_at_for_determinism(self): + from update_registry import render_registry + + first = render_registry([{"name": "a"}]) + second = render_registry([{"name": "a"}]) + assert first == second + assert "updated_at" not in json.loads(first) + + def test_entries_carry_repo_slug(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from update_registry import build_registry + + cat = tmp_path / "categories" / "python" / "demo-skill" + cat.mkdir(parents=True) + (cat / "SKILL.md").write_text( + "---\nname: demo-skill\ndescription: A demo skill\n---\n\nBody\n" + ) + monkeypatch.setattr("update_registry.REPO_ROOT", tmp_path) + monkeypatch.setattr("update_registry.CATEGORIES_DIR", tmp_path / "categories") + + entries = build_registry() + assert entries[0]["repo"] == "GrayCodeAI/graycode-skills" + + def test_schema_accepts_repo_field(self): + from registry_schema import validate_registry_entry + + errors = validate_registry_entry( + { + "name": "demo", + "description": "d", + "category": "python", + "tags": ["python"], + "path": "categories/python/demo", + "file_count": 1, + "has_scripts": False, + "repo": "GrayCodeAI/graycode-skills", + }, + path="demo", + ) + assert errors == [] +``` + +Ensure `import json` and `from pathlib import Path` are present at the top of the file. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd graycode-skills && python -m pytest tests/test_update_registry.py -k CanonicalRenderShape -v +``` + +Expected: FAIL. `test_render_wraps_entries_in_object` fails with `assert isinstance(doc, dict)` because `render_registry` currently returns a JSON array. `test_entries_carry_repo_slug` fails with `KeyError: 'repo'`. `test_schema_accepts_repo_field` fails with an `additionalProperties` violation naming `repo`. + +- [ ] **Step 3: Add the `repo` slug to each entry** + +In `graycode-skills/tools/update_registry.py`, add a module constant next to the other module-level paths (near `REGISTRY_PATH`, around line 24): + +```python +# The GitHub slug every skill in this repo is installed from. graycode-cli +# builds its clone URL from this field (internal/plugin/auto_skill.go). +REGISTRY_REPO = "GrayCodeAI/graycode-skills" +``` + +Then extend the entry literal at line 115: + +```python + entry = { + "name": name, + "description": description, + "category": category_name, + "tags": tags, + "path": path, + "repo": REGISTRY_REPO, + "file_count": count_files(skill_dir), + "has_scripts": has_scripts_dir(skill_dir), + } +``` + +- [ ] **Step 4: Wrap the rendered document in the object shape** + +Replace `render_registry` in `graycode-skills/tools/update_registry.py`: + +```python +def render_registry(entries: list[dict]) -> str: + """Render registry entries in the canonical on-disk format. + + The top level is an object, not an array: graycode-cli parses + {version, updated_at, skills[]} (internal/plugin/registry.go). No + timestamp is emitted so that `--check` stays deterministic. + """ + document = {"version": 1, "skills": entries} + return json.dumps(document, indent=2, ensure_ascii=False) + "\n" +``` + +- [ ] **Step 5: Teach the schema about the new shape** + +In `graycode-skills/tools/registry_schema.py`, add `repo` to `REGISTRY_ENTRY_SCHEMA["properties"]` alongside the other optional fields (the block ending at line 78, before `"additionalProperties": False`): + +```python + "repo": { + "type": "string", + "description": "GitHub owner/repo slug the skill is installed from", + }, +``` + +Replace `REGISTRY_SCHEMA` (line 84): + +```python +REGISTRY_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "version": {"type": "integer"}, + "skills": {"type": "array", "items": REGISTRY_ENTRY_SCHEMA}, + }, + "required": ["version", "skills"], + "additionalProperties": False, +} +``` + +Then update `load_and_validate` (line 217) so it validates the `skills` array rather than the whole document as an array. Read the function first and adapt its existing error-collection style; the only change is that the list of entries is now `data["skills"]` instead of `data`, and a non-object top level is itself an error. + +- [ ] **Step 6: Run the tests to verify they pass** + +```bash +cd graycode-skills && python -m pytest tests/test_update_registry.py -v && python -m pytest -q +``` + +Expected: PASS, whole suite green. + +- [ ] **Step 7: Verify the real corpus still generates and validates** + +```bash +cd graycode-skills +python tools/update_registry.py +python -c "import json; d=json.load(open('registry.json')); print(type(d).__name__, d['version'], len(d['skills']), d['skills'][0]['repo'])" +python tools/update_registry.py --check && echo "CHECK CLEAN (deterministic)" +rm registry.json +``` + +Expected: `dict 1 12167 GrayCodeAI/graycode-skills`, then `CHECK CLEAN (deterministic)`. Delete the generated file; it stays gitignored. + +- [ ] **Step 8: Commit** + +```bash +git add tools/update_registry.py tools/registry_schema.py tests/test_update_registry.py +git commit -m "fix: emit registry.json in the object shape graycode-cli parses + +The generator emitted a bare JSON array while graycode-cli parses +{version, updated_at, skills[]}, so FetchIndex failed with 'invalid +index' regardless of URL. Entries now also carry the repo slug that +the installer needs to build a clone URL. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task A2: Publish the registry to a stable public URL + +**Files:** +- Modify: `graycode-skills/.github/workflows/publish-registry.yml:11-14` (add `permissions`), `:55-62` (add the release step after the artifact upload) + +**Interfaces:** +- Produces: `https://github.com/GrayCodeAI/graycode-skills/releases/latest/download/registry.json`. Task A3 hard-codes this URL. + +> **Why a rolling release and not `latest`.** `releases/latest/download/` resolves to the most recent **non-prerelease** release. The existing `v0.1.0` release is currently the latest, so the new rolling release must be created as a normal (non-draft, non-prerelease) release for that URL to resolve to it. Tagging it `registry-latest` and re-uploading with `--clobber` keeps exactly one moving target. + +- [ ] **Step 1: Grant the workflow permission to write releases** + +In `graycode-skills/.github/workflows/publish-registry.yml`, add a `permissions` block to the `build-and-publish` job, directly under `runs-on`: + +```yaml +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: write + steps: +``` + +- [ ] **Step 2: Add the release-publishing step** + +Append to the end of the same file, after the existing `Upload registry artifacts` step: + +```yaml + - name: Publish registry to the rolling release + if: github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # One moving release holds the current registry. The Actions + # artifact above is retained separately for 90-day forensics. + if ! gh release view registry-latest >/dev/null 2>&1; then + gh release create registry-latest \ + --title "Skill registry (rolling)" \ + --notes "Generated registry.json for the current main. Updated automatically; do not delete." \ + --latest=false + fi + gh release upload registry-latest \ + registry.json registry-signature.json --clobber +``` + +`--latest=false` keeps the rolling release from displacing real version tags in the GitHub UI. The download URL used by the CLI in Task A3 addresses the tag directly, so it does not depend on which release is marked latest. + +- [ ] **Step 3: Validate the workflow file parses** + +```bash +cd graycode-skills && python -c "import yaml,sys; d=yaml.safe_load(open('.github/workflows/publish-registry.yml')); j=d['jobs']['build-and-publish']; print('permissions:', j['permissions']); print('steps:', [s['name'] for s in j['steps']])" +``` + +Expected: `permissions: {'contents': 'write'}` and a step list ending with `Publish registry to the rolling release`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/publish-registry.yml +git commit -m "feat: publish registry.json to a rolling GitHub release + +The registry had no public URL: CI only uploaded it as a 90-day +Actions artifact and the file is gitignored, so every raw +githubusercontent URL 404d. A rolling registry-latest release gives +the CLI a stable download target without putting a 4.3 MB generated +file into git history. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +- [ ] **Step 5: After merge, confirm the URL is live** + +```bash +curl -sIL https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json | grep -E '^HTTP' +``` + +Expected: a final `HTTP/2 200`. If the workflow has not run since merge, trigger it with `gh workflow run publish-registry.yml -R GrayCodeAI/graycode-skills` and re-check. **Task A3 cannot be verified end-to-end until this returns 200.** + +--- + +### Task A3: Point graycode-cli at the published registry and drop the phantom marketplace source + +**Files:** +- Modify: `graycode-cli/internal/plugin/registry.go:20` +- Modify: `graycode-cli/internal/plugin/marketplace.go:42-50` +- Test: `graycode-cli/internal/plugin/registry_test.go`, `graycode-cli/internal/plugin/marketplace_test.go` + +**Interfaces:** +- Consumes: the object shape from Task A1 and the URL from Task A2. +- Produces: `defaultIndexURL` pointing at the rolling release; `defaultMarketplaceSources()` returning an empty slice. + +> **Why the marketplace source is removed rather than repointed.** `plugins-registry.json` is generated by nothing in any of the four repos; the only reference anywhere is `marketplace.go:49`. `FetchAll` (`marketplace.go:111-132`) returns `(nil, nil)` when there are no sources and no errors, so removing the dead source turns a confusing fetch failure into a clean empty list. Users add real sources with `graycode plugin marketplace add`. + +- [ ] **Step 1: Write the failing tests** + +Append to `graycode-cli/internal/plugin/registry_test.go`: + +```go +func TestDefaultIndexURLIsPublished(t *testing.T) { + const want = "https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json" + if defaultIndexURL != want { + t.Fatalf("defaultIndexURL = %q, want %q", defaultIndexURL, want) + } + if strings.Contains(defaultIndexURL, "starling") { + t.Errorf("defaultIndexURL still references the renamed starling repo") + } +} + +func TestFetchIndexParsesGeneratedShape(t *testing.T) { + // Byte-for-byte the shape graycode-skills/tools/update_registry.py emits. + const generated = `{ + "version": 1, + "skills": [ + { + "name": "ab-test-setup", + "description": "Plan and design an A/B test", + "category": "testing", + "tags": ["testing"], + "path": "categories/testing/ab-test-setup", + "repo": "GrayCodeAI/graycode-skills", + "file_count": 1, + "has_scripts": false + } + ] +} +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(generated)) + })) + defer srv.Close() + + rc := &RegistryClient{IndexURL: srv.URL, CacheDir: t.TempDir(), client: srv.Client()} + idx, err := rc.FetchIndex() + if err != nil { + t.Fatalf("FetchIndex: %v", err) + } + if len(idx.Skills) != 1 { + t.Fatalf("skills = %d, want 1", len(idx.Skills)) + } + if idx.Skills[0].Repo != "GrayCodeAI/graycode-skills" { + t.Errorf("Repo = %q, want the slug the installer clones from", idx.Skills[0].Repo) + } +} +``` + +Append to `graycode-cli/internal/plugin/marketplace_test.go`: + +```go +func TestNoPhantomDefaultMarketplaceSource(t *testing.T) { + for _, src := range DefaultMarketplaceSources() { + if strings.Contains(src.URL, "plugins-registry.json") { + t.Fatalf("default source %q points at plugins-registry.json, which nothing generates", src.Name) + } + } +} + +func TestFetchAllWithNoSourcesReturnsEmptyNotError(t *testing.T) { + mc := &MarketplaceClient{Sources: nil, CacheDir: t.TempDir()} + entries, err := mc.FetchAll() + if err != nil { + t.Fatalf("FetchAll with no sources returned error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("entries = %d, want 0", len(entries)) + } +} +``` + +Ensure `net/http`, `net/http/httptest` and `strings` are imported in each file. `MarketplaceClient` construction must match the real struct; read `marketplace.go` and adjust the literal if the field set differs. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd graycode-cli && go test ./internal/plugin/ -run 'TestDefaultIndexURLIsPublished|TestFetchIndexParsesGeneratedShape|TestNoPhantomDefaultMarketplaceSource|TestFetchAllWithNoSourcesReturnsEmptyNotError' -v +``` + +Expected: FAIL. The URL test reports the `starling` constant. The shape test passes only if the Go struct already matches, which it does, so it should pass once the URL is fixed; if it fails, the emitter in A1 drifted. The marketplace test reports the `plugins-registry.json` default. + +- [ ] **Step 3: Repoint the registry index URL** + +In `graycode-cli/internal/plugin/registry.go`, replace line 20: + +```go +// defaultIndexURL is the rolling release asset published by +// graycode-skills/.github/workflows/publish-registry.yml. The registry is a +// generated 4.3 MB artifact and is deliberately not committed to that repo, +// so a raw.githubusercontent.com URL cannot work. +const defaultIndexURL = "https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json" +``` + +- [ ] **Step 4: Remove the phantom marketplace source** + +In `graycode-cli/internal/plugin/marketplace.go`, replace the exported `DefaultMarketplaceSources` function at lines 43-52 (keep the name and the exported signature; `NewMarketplaceClient` and the `plugin marketplace` commands already call it): + +```go +// DefaultMarketplaceSources returns the built-in plugin index sources. +// +// There are none. No repository in the GrayCode ecosystem generates a +// plugins-registry.json, so shipping a built-in source only produced a 404 +// on every `graycode plugin marketplace list`. Users register real sources +// with `graycode plugin marketplace add `. +func DefaultMarketplaceSources() []MarketplaceSource { + return nil +} +``` + +Leave `loadUserMarketplaceSources` and `SaveUserSources` untouched. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cd graycode-cli && go test ./internal/plugin/ -v +``` + +Expected: PASS, whole package green. Pre-existing tests that use `"GrayCodeAI/starling"` as row **data** (`registry_test.go:20,21,167,192,250,276,285`, `skillslock_test.go:20`) are fixtures, not URL pins; leave them for Task D4. + +- [ ] **Step 6: Verify against the live registry** + +Only runnable once Task A2's URL returns 200. + +```bash +cd graycode-cli && go build -o /tmp/graycode ./cmd/graycode && /tmp/graycode skills search testing | head -20 +``` + +Expected: a list of matching skills. If it prints a registry error, re-check Step 5 of Task A2. + +- [ ] **Step 7: Commit** + +```bash +git add internal/plugin/registry.go internal/plugin/marketplace.go internal/plugin/registry_test.go internal/plugin/marketplace_test.go +git commit -m "fix: point the skill index at the published registry release + +The index URL referenced GrayCodeAI/starling, a repo renamed to +graycode-skills whose registry.json is generated and never committed, +so the URL 404d under either name. It now reads the rolling release +asset. The built-in marketplace source pointed at a +plugins-registry.json that nothing generates and is removed. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task A4: Make install discover skills at any repository layout + +**Files:** +- Modify: `graycode-cli/internal/plugin/registry.go:258-270` (the discovery block inside `Install`) +- Test: `graycode-cli/internal/plugin/registry_test.go` + +**Interfaces:** +- Consumes: nothing from earlier tasks; independently testable. +- Produces: `discoverSkillDirs(root string) (map[string]string, error)` mapping skill name to the directory containing its `SKILL.md`. `Install` iterates this map instead of `os.ReadDir(skillsRoot)`. + +> **Root cause, not the reported symptom.** The reported failure is "installing from graycode-skills says skill not found". The cause is that discovery hard-codes two layouts (`//` and `/skills//`). Every repo with any other layout fails the same way. One bounded walk fixes all of them, and is a smaller diff than adding a third special case. + +- [ ] **Step 1: Write the failing test** + +Append to `graycode-cli/internal/plugin/registry_test.go`: + +```go +func TestDiscoverSkillDirs(t *testing.T) { + tests := []struct { + name string + layout []string // SKILL.md paths relative to the repo root + want []string // expected skill names + }{ + { + name: "flat layout", + layout: []string{"go-review/SKILL.md"}, + want: []string{"go-review"}, + }, + { + name: "agentskills.io skills/ layout", + layout: []string{"skills/go-review/SKILL.md"}, + want: []string{"go-review"}, + }, + { + name: "graycode-skills categories layout", + layout: []string{"categories/go/go-review/SKILL.md", "categories/python/pandas/SKILL.md"}, + want: []string{"go-review", "pandas"}, + }, + { + name: "ignores vendored and dot directories", + layout: []string{"go-review/SKILL.md", ".git/hooks/SKILL.md", "node_modules/pkg/SKILL.md"}, + want: []string{"go-review"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + for _, rel := range tc.layout { + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("---\nname: x\n---\n"), 0o600); err != nil { + t.Fatal(err) + } + } + + got, err := discoverSkillDirs(root) + if err != nil { + t.Fatalf("discoverSkillDirs: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("found %d skills %v, want %d %v", len(got), keysOf(got), len(tc.want), tc.want) + } + for _, name := range tc.want { + dir, ok := got[name] + if !ok { + t.Errorf("missing skill %q; got %v", name, keysOf(got)) + continue + } + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + t.Errorf("skill %q maps to %q which has no SKILL.md", name, dir) + } + } + }) + } +} + +func keysOf(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} +``` + +Ensure `os`, `path/filepath` and `sort` are imported. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd graycode-cli && go test ./internal/plugin/ -run TestDiscoverSkillDirs -v +``` + +Expected: FAIL to compile with `undefined: discoverSkillDirs`. + +- [ ] **Step 3: Implement bounded discovery** + +Add to `graycode-cli/internal/plugin/registry.go`: + +```go +// maxSkillSearchDepth bounds how deep discoverSkillDirs walks below the repo +// root. graycode-skills nests skills at categories///, which +// is depth 3; anything deeper is almost certainly test data or a vendored +// copy. +const maxSkillSearchDepth = 4 + +// discoverSkillDirs finds every directory under root containing a SKILL.md, +// keyed by the directory name. It replaces the previous two hard-coded +// layouts (// and /skills//) so repositories that +// group skills under a category directory are installable too. +// +// On a duplicate skill name the shallowest path wins; ties keep the first +// lexicographic match so the result is deterministic. +func discoverSkillDirs(root string) (map[string]string, error) { + found := map[string]string{} + depthOf := map[string]int{} + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return nil //nolint:nilerr // an unrelatable path is simply skipped + } + if d.IsDir() { + if path == root { + return nil + } + name := d.Name() + if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" { + return filepath.SkipDir + } + if len(strings.Split(filepath.ToSlash(rel), "/")) > maxSkillSearchDepth { + return filepath.SkipDir + } + return nil + } + if d.Name() != "SKILL.md" { + return nil + } + dir := filepath.Dir(path) + if dir == root { + return nil // a top-level SKILL.md documents the repo, not a skill + } + name := filepath.Base(dir) + depth := len(strings.Split(filepath.ToSlash(rel), "/")) + if prev, ok := found[name]; ok { + if depthOf[name] <= depth { + return nil + } + _ = prev + } + found[name] = dir + depthOf[name] = depth + return nil + }) + if err != nil { + return nil, fmt.Errorf("scan skills: %w", err) + } + return found, nil +} +``` + +Add `"io/fs"` to the imports. + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd graycode-cli && go test ./internal/plugin/ -run TestDiscoverSkillDirs -v +``` + +Expected: PASS, all four subtests. + +- [ ] **Step 5: Wire it into `Install`** + +In `graycode-cli/internal/plugin/registry.go`, replace the discovery block at lines 258-270. Delete the `skillsRoot` computation and the `os.ReadDir(skillsRoot)` call, and drive the existing loop from the map instead: + +```go + // Discover skills in the cloned repo, whatever layout it uses. + discovered, err := discoverSkillDirs(tmpDir) + if err != nil { + return "", err + } + names := make([]string, 0, len(discovered)) + for name := range discovered { + names = append(names, name) + } + sort.Strings(names) +``` + +Then change the loop header from `for _, e := range entries {` to `for _, name := range names {`, delete the `if !e.IsDir() { continue }` guard and the `name := e.Name()` line, and change the `srcSkill` assignment to: + +```go + srcSkill := filepath.Join(discovered[name], "SKILL.md") +``` + +Leave the rest of the loop body, including the `skillName` filter, the trust checks, and the lockfile writes, exactly as they are. Add `"sort"` to the imports if it is not already present. + +> `// ponytail: whole-repo shallow clone. Installing one skill from graycode-skills clones ~127 MB of categories. Switch to git sparse-checkout of the skill's indexed path if install latency becomes a complaint.` + +Add that comment above the `git clone` call at line 247. + +- [ ] **Step 6: Run the full package and the boundary guards** + +```bash +cd graycode-cli && go test ./internal/plugin/ -v && gofumpt -l internal/plugin/ && make boundaries +``` + +Expected: package PASS, `gofumpt -l` prints nothing, all nine guards pass. + +- [ ] **Step 7: Verify a real install end-to-end** + +```bash +cd graycode-cli && go build -o /tmp/graycode ./cmd/graycode && /tmp/graycode skills install GrayCodeAI/graycode-skills ab-test-setup +``` + +Expected: reports the skill installed. Before this change it returned `skill "ab-test-setup" not found`. + +- [ ] **Step 8: Commit** + +```bash +git add internal/plugin/registry.go internal/plugin/registry_test.go +git commit -m "fix: discover skills at any repository layout on install + +Install scanned only //SKILL.md and +/skills//SKILL.md, so every repo grouping skills under a +category directory - graycode-skills included - reported 'skill not +found'. A bounded walk replaces both special cases. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +# Workstream B — Cloud Wire Contract + +**Problem.** Two field names drifted between graycode-cli and graycode-platform. Both schemas on the worker are `.strict()` zod objects, so both calls are rejected outright. + +| Call | CLI sends | Worker requires | Result | +|---|---|---|---| +| `POST /v1/auth/device/start` | `graycodeVersion` (`client.go:105`) | `hawkVersion` (`validation.ts:30`, `auth.ts:32`) | 400, `graycode cloud login` can never succeed | +| `POST /v1/usage` | `capability: "graycode"` (`exec.go:373`) | enum `[hawk, swift, shrike, harrier, merlin, kestrel]` (`contracts/v1.ts:8-15`) | 400, silently discarded because `RecordUsage` is fail-open (`client.go:176-179`) | + +**Decision (approved):** rename the wire to `graycode`. The CLI is already correct, so **graycode-cli needs no production change in this workstream** beyond one test fixture. All edits are in graycode-platform. + +**Deployment order is not optional.** Migration `0024` must be applied to D1 before the worker deploy, or every device read breaks on a missing column. + +> **Note on migration numbering.** `apps/worker/migrations/` already contains a collision: both `0022_graph_ledger.sql` and `0022_identity_ui.sql` exist. The next free prefix is `0024`, since `0023_usage_outbox.sql` is taken. Do not reuse `0022` or `0023`. + +--- + +### Task B1: Migrate the D1 schema and backfill legacy rows + +**Files:** +- Create: `graycode-platform/apps/worker/migrations/0024_graycode_capability_rename.sql` + +**Interfaces:** +- Produces: column `devices.graycode_version` (was `hawk_version`), column `cli_device_authorizations.graycode_version` (was `hawk_version`), and every `capability = 'hawk'` row rewritten to `'graycode'`. Tasks B2 and B3 read these names. + +- [ ] **Step 1: Write the migration** + +Create `graycode-platform/apps/worker/migrations/0024_graycode_capability_rename.sql`: + +```sql +-- Rename the CLI wire fields from the legacy hawk product name to graycode. +-- +-- The CLI has always sent `graycodeVersion` and `capability: "graycode"` +-- (graycode-cli internal/platform/cloud/client.go, cmd/exec.go). The worker's +-- strict zod schemas required `hawkVersion` and rejected the `graycode` +-- capability, so device login returned 400 and every usage event was dropped. +-- The wire contract moves to the name the product actually has. +-- +-- SQLite supports RENAME COLUMN from 3.25; D1 is well past that. + +ALTER TABLE devices RENAME COLUMN hawk_version TO graycode_version; +ALTER TABLE cli_device_authorizations RENAME COLUMN hawk_version TO graycode_version; + +-- Backfill rows written while the enum still said 'hawk'. The capability +-- column is free TEXT, so historical rows would otherwise fail validation on +-- any read path that re-parses them. +UPDATE usage_events SET capability = 'graycode' WHERE capability = 'hawk'; +UPDATE sessions SET capability = 'graycode' WHERE capability = 'hawk'; +``` + +- [ ] **Step 2: Verify the migration applies against a scratch database** + +```bash +cd graycode-platform/apps/worker +npx wrangler d1 migrations list graycode-cloud --local +npx wrangler d1 migrations apply graycode-cloud --local +npx wrangler d1 execute graycode-cloud --local --command "PRAGMA table_info(devices);" | grep -i version +``` + +Expected: the migration list shows `0024_graycode_capability_rename.sql` pending, apply succeeds, and the final command prints `graycode_version` with no `hawk_version` row. + +- [ ] **Step 3: Commit** + +```bash +git add apps/worker/migrations/0024_graycode_capability_rename.sql +git commit -m "feat: migrate device version and capability columns to graycode + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task B2: Rename the wire contract in the worker + +**Files:** +- Modify: `graycode-platform/apps/worker/contracts/v1.ts:8-16,23,55,124` +- Modify: `graycode-platform/apps/worker/src/domain/validation.ts:2,30,40` +- Modify: `graycode-platform/apps/worker/src/routes/auth.ts:32,48,56` +- Modify: `graycode-platform/apps/worker/src/routes/devices.ts:31,38,92` +- Modify: `graycode-platform/apps/worker/src/routes/sessions.ts:19` +- Modify: `graycode-platform/apps/worker/src/routes/organizations.ts:123` +- Modify: `graycode-platform/apps/worker/src/routes/enterprise.ts:596,748` +- Modify: `graycode-platform/apps/worker/src/auth/device-approve.ts:29,36,56,63` +- Test: `graycode-platform/apps/worker/test/{auth,devices,organizations,rate-limit,device-token,sessions,usage-sessions}.test.ts` + +**Interfaces:** +- Consumes: the column names from Task B1. +- Produces: `GRAYCODE_CAPABILITIES` (was `HAWK_CAPABILITIES`) with first element `'graycode'`; type `GraycodeCapability`; request field `graycodeVersion`. + +- [ ] **Step 1: Update the failing tests first** + +In `graycode-platform/apps/worker/test/auth.test.ts`, add a regression test that pins the CLI's actual request body: + +```ts +it('accepts the body graycode-cli actually sends', async () => { + const res = await app.request( + '/v1/auth/device/start', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + // Verbatim from graycode-cli internal/platform/cloud/client.go:105 + body: JSON.stringify({ + label: 'my-laptop', + platform: 'darwin', + graycodeVersion: '0.0.1', + }), + }, + env, + ) + expect(res.status).toBe(201) +}) +``` + +Match the surrounding tests' setup style for `app` and `env`; read the top of the file first. + +In `graycode-platform/apps/worker/test/usage-sessions.test.ts`, add: + +```ts +it('accepts the capability graycode-cli actually sends', async () => { + const res = await app.request( + '/v1/usage', + { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${deviceToken}` }, + // capability verbatim from graycode-cli cmd/exec.go:373 + body: JSON.stringify({ ...validUsageEvent, capability: 'graycode' }), + }, + env, + ) + expect(res.status).toBe(202) +}) +``` + +Reuse whatever fixture the neighbouring tests use in place of `validUsageEvent` and `deviceToken`. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd graycode-platform/apps/worker && npx vitest run test/auth.test.ts test/usage-sessions.test.ts +``` + +Expected: FAIL. The device-start test gets 400 `Invalid device authorization request`. The usage test gets 400 `Invalid usage event`. + +- [ ] **Step 3: Rename the capability constant and type** + +In `graycode-platform/apps/worker/contracts/v1.ts`, replace lines 8-16: + +```ts +export const GRAYCODE_CAPABILITIES = [ + 'graycode', + 'swift', + 'shrike', + 'harrier', + 'merlin', + 'kestrel', +] as const +export type GraycodeCapability = (typeof GRAYCODE_CAPABILITIES)[number] +``` + +Update the two `capability: HawkCapability` fields at lines 23 and 55 to `GraycodeCapability`, and line 124 `hawkVersion: string` to `graycodeVersion: string`. Also reword the file's header comment (lines 1-7) from "Hawk Cloud" and "Hawk clients" to "Graycode Cloud" and "Graycode clients". + +- [ ] **Step 4: Update the validators** + +In `graycode-platform/apps/worker/src/domain/validation.ts`: line 2 imports `GRAYCODE_CAPABILITIES`, line 30 becomes `graycodeVersion: z.string().min(1).max(50),`, line 40 becomes `capability: z.enum(GRAYCODE_CAPABILITIES),`. + +In `graycode-platform/apps/worker/src/routes/auth.ts`: line 32 becomes `graycodeVersion: z.string().min(1).max(50),`; the INSERT at line 48 uses column `graycode_version`; the bound value at line 56 becomes `parsed.data.graycodeVersion`. + +In `graycode-platform/apps/worker/src/routes/sessions.ts`: line 19 becomes `capability: z.enum(GRAYCODE_CAPABILITIES),` with the matching import, replacing the inline literal array. + +- [ ] **Step 5: Update every SQL statement and its alias** + +Apply the same two mechanical substitutions in `src/routes/devices.ts:31,38,92`, `src/routes/organizations.ts:123`, `src/routes/enterprise.ts:596,748`, and `src/auth/device-approve.ts:29,36,56,63`: + +- column `hawk_version` becomes `graycode_version` +- SQL alias and TypeScript property `hawkVersion` becomes `graycodeVersion` + +- [ ] **Step 6: Update the remaining test fixtures** + +Replace `hawkVersion` with `graycodeVersion` at `test/auth.test.ts:116,125,317`, `test/organizations.test.ts:90`, `test/rate-limit.test.ts:153`, `test/devices.test.ts:77,106,118,135,153`. Replace `capability: 'hawk'` with `capability: 'graycode'` at `test/device-token.test.ts:166`, `test/sessions.test.ts:71,351`, `test/usage-sessions.test.ts:58,77,817`. + +```bash +cd graycode-platform/apps/worker +grep -rln 'hawkVersion' test/ | xargs sed -i '' 's/hawkVersion/graycodeVersion/g' +grep -rln "capability: 'hawk'" test/ | xargs sed -i '' "s/capability: 'hawk'/capability: 'graycode'/g" +grep -rn "hawkVersion\|capability: 'hawk'" test/ || echo "TEST FIXTURES CLEAN" +``` + +- [ ] **Step 7: Run the full worker suite** + +```bash +cd graycode-platform/apps/worker && npx tsc --noEmit && npx vitest run +``` + +Expected: typecheck clean, all tests PASS including the two added in Step 1. If `openapi-parity.test.ts` fails, that is expected until Task B4 updates the contract file; note it and continue. + +- [ ] **Step 8: Commit** + +```bash +git add apps/worker/contracts/v1.ts apps/worker/src apps/worker/test +git commit -m "feat!: rename the cloud wire contract from hawk to graycode + +Device login required hawkVersion while the CLI has always sent +graycodeVersion, and the capability enum rejected 'graycode', so +login returned 400 and every usage event was silently dropped by the +fail-open client. The wire now matches the product name. + +Requires migration 0024 to be applied before deploy. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task B3: Rename the capability enum in the BFF + +**Files:** +- Modify: `graycode-platform/apps/bff/src/routes/web-activity.ts:22,94` +- Modify: `graycode-platform/apps/bff/src/routes/web-dashboard.ts:15` + +**Interfaces:** +- Consumes: the capability values from Task B2. The BFF duplicates the enum as an inline literal rather than importing it. + +- [ ] **Step 1: Update both inline enums** + +In `graycode-platform/apps/bff/src/routes/web-activity.ts:94` and `web-dashboard.ts:15`, replace: + +```ts + tool: z.enum(['hawk', 'swift', 'shrike', 'harrier', 'merlin', 'kestrel']), +``` + +with: + +```ts + tool: z.enum(['graycode', 'swift', 'shrike', 'harrier', 'merlin', 'kestrel']), +``` + +Keep `.optional()` on the `web-activity.ts:94` occurrence. + +- [ ] **Step 2: Fix the user-facing achievement string** + +`web-activity.ts:22` reads `description: 'Completed your first hawk session'`. Change it to `'Completed your first graycode session'`. + +- [ ] **Step 3: Typecheck and test** + +```bash +cd graycode-platform/apps/bff && npx tsc --noEmit && npx vitest run +``` + +Expected: clean typecheck, tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/bff/src/routes/web-activity.ts apps/bff/src/routes/web-dashboard.ts +git commit -m "feat: align the BFF capability enum with the graycode wire contract + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task B4: Update the OpenAPI contract and delete the stale third copy + +**Files:** +- Modify: `graycode-platform/api/graycode-cloud-openapi.yaml:3,1196,1230,1234,1322` +- Modify: `graycode-platform/apps/worker/contracts/openapi.yaml:1230,1234` (and the same title/enum lines) +- Delete: `graycode-platform/apps/worker/api/openapi.yaml` + +**Interfaces:** +- Consumes: the field names from Task B2. `test/openapi-parity.test.ts` reads `contracts/openapi.yaml` and must pass after this task. + +> **Three copies exist.** `api/graycode-cloud-openapi.yaml` and `apps/worker/contracts/openapi.yaml` are byte-identical. `apps/worker/api/openapi.yaml` is a stale 1371-line variant whose `servers.url` is the personal dev subdomain `https://graycode-cloud.lakshmanp230.workers.dev` (line 7). Nothing references it: no package script, no CI workflow, no turbo task. Deleting it removes both the drift and the personal subdomain in one step. + +- [ ] **Step 1: Confirm the stale copy is unreferenced before deleting** + +```bash +cd graycode-platform && grep -rn 'apps/worker/api/openapi\|api/openapi.yaml' --exclude-dir=node_modules --exclude-dir=.git . | grep -v '^./apps/worker/api/openapi.yaml' +``` + +Expected: no output. If anything references it, stop and repoint that reference at `apps/worker/contracts/openapi.yaml` instead of deleting. + +- [ ] **Step 2: Apply the renames to both live copies** + +```bash +cd graycode-platform +for f in api/graycode-cloud-openapi.yaml apps/worker/contracts/openapi.yaml; do + sed -i '' 's/hawkVersion/graycodeVersion/g' "$f" + sed -i '' 's/enum: \[hawk, swift/enum: [graycode, swift/' "$f" + sed -i '' 's/^ title: Hawk Cloud API/ title: Graycode Cloud API/' "$f" + sed -i '' 's/Control-plane API for Hawk and its related products/Control-plane API for Graycode and its related products/' "$f" + sed -i '' 's/Project-scoped hwc device token\./Project-scoped device token (`hwc_` prefix)./' "$f" +done +grep -n 'graycodeVersion\|enum: \[graycode\|title: Graycode Cloud' api/graycode-cloud-openapi.yaml +``` + +Expected: the rewritten lines print. The `hwc_` prefix itself stays; only its description changes. + +- [ ] **Step 3: Confirm the two live copies are still identical** + +```bash +cd graycode-platform && diff api/graycode-cloud-openapi.yaml apps/worker/contracts/openapi.yaml && echo "COPIES IN SYNC" +``` + +Expected: `COPIES IN SYNC`. + +- [ ] **Step 4: Delete the stale copy** + +```bash +cd graycode-platform && git rm apps/worker/api/openapi.yaml +``` + +- [ ] **Step 5: Run the parity test** + +```bash +cd graycode-platform/apps/worker && npx vitest run test/openapi-parity.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add api/graycode-cloud-openapi.yaml apps/worker/contracts/openapi.yaml +git commit -m "docs: rename the cloud contract to graycode and drop the stale copy + +apps/worker/api/openapi.yaml was an unreferenced 1371-line variant +still advertising a personal workers.dev subdomain as its server URL. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task B5: Align the CLI's cloud test fixture and document the endpoint + +**Files:** +- Modify: `graycode-cli/internal/platform/cloud/client_test.go:22` +- Modify: `graycode-cli/README.md` (the Portable Execution Graph section, around line 127) + +**Interfaces:** +- Consumes: the capability values from Task B2. No production CLI code changes; `client.go:105` and `exec.go:373` were already correct. + +> **A real gap this plan does not close.** `graycode cloud login` has no default endpoint: it requires `--endpoint` or `GRAYCODE_CLOUD_URL` (`cmd/cloud.go:41-46`). The worker's `wrangler.jsonc` declares no route or custom domain, so it is reachable only at its `workers.dev` address, and the contract's documented `servers.url` of `https://api.graycodeai.com` is the **BFF**, which requires a browser session cookie and returns 401 to a device token (`apps/bff/src/app.ts:54-58`). Choosing and provisioning a public hostname for the worker is an infrastructure decision outside this plan. Step 2 documents the requirement so users are not left guessing. + +- [ ] **Step 1: Fix the test fixture** + +In `graycode-cli/internal/platform/cloud/client_test.go:22`, change `Capability: "graycode"` — it is already correct and matches the renamed enum. Verify rather than edit: + +```bash +cd graycode-cli && grep -n 'Capability:' internal/platform/cloud/client_test.go +``` + +Expected: `Capability: "graycode"`. No edit needed. If it reads `"hawk"`, change it to `"graycode"`. + +- [ ] **Step 2: Document that the cloud endpoint must be supplied** + +In `graycode-cli/README.md`, directly under the `graycode cloud graph sync` code block, add: + +```markdown +Cloud commands require an endpoint. There is no default: pass `--endpoint` or +set `GRAYCODE_CLOUD_URL` to your Graycode Cloud worker URL before running +`graycode cloud login`. `https://api.graycodeai.com` is the browser BFF and +will reject a device token. +``` + +- [ ] **Step 3: Verify and commit** + +```bash +cd graycode-cli && go test ./internal/platform/cloud/ -v +git add internal/platform/cloud/client_test.go README.md +git commit -m "docs: state that graycode cloud requires an explicit endpoint + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +- [ ] **Step 4: Deploy in the correct order** + +After the graycode-platform PRs merge: + +```bash +cd graycode-platform/apps/worker +npx wrangler d1 migrations apply graycode-cloud --remote # 0024 FIRST +cd ../.. && pnpm deploy:worker +pnpm deploy:bff +``` + +Expected: migration reports one statement batch applied, then both workers deploy. Deploying the worker before the migration breaks every device read. + +--- + +# Workstream C — Router Boundary Truth + +**Problem.** This workstream fixes documentation and two defective guards. **There is no import violation to repair: all nine boundary guards pass today.** + +What is actually wrong: + +1. **The documented boundary contradicts the enforced one.** `graycode-router/AGENTS.md:570-572` says graycode-cli must not assemble anything below `engine`. In reality all three enforcement layers deliberately allow four packages — `engine`, `llm`, `graph`, `tools` — and eight non-test files depend on that allowance (`internal/config/catalog_api.go:9`, `internal/engine/client_interface.go:7`, `internal/engine/compact_provider_native.go:8`, `internal/engine/execution_graph_observations.go:16`, `internal/provider/gateway/engine_client.go:16`, `internal/provider/gateway/gateway.go:15`, `internal/session/session.go:23`, `internal/types/client.go:6`). Six symbols they use have no `engine` equivalent at all: `ChatOptions`, `ContinuationConfig`, `StreamResult`/`NewStreamResult`, `ResponseFormat`, `ImageURLPart`, `InputAudioPart`, plus the `ModelClass*` constants and `GatewayRegionOption`. +2. **A guard that can never fail.** `graycode-cli/scripts/check-support-repo-coupling.sh:30-32` builds its regex from the peer list. With `graycode-router` the only engine in `ecosystem.yaml`, the peer list is empty and the pattern degenerates to `github\.com/GrayCodeAI/()(/|")`. Verified with `bash -x`. +3. **A guard missing from pre-push.** `lefthook.yml:115-125` runs three boundary scripts but omits `check-graycode-router-engine-boundary.sh` (verified: zero occurrences in the file). It runs in CI, so violations are caught late instead of before push. +4. **A dead exception.** Both Go AST tests carry a `internal/provider/gateway` → `credentials` exception (`internal/testaudit/audit_test.go:223-226`, `internal/testaudit/package_boundaries_test.go:61-65`). Zero non-test files import `graycode-router/credentials`. + +**Approach.** Document the four-package contract as the real boundary, make the vacuous guard honest, and add the missing hook. Do not attempt to collapse the CLI onto `engine`-only; six required symbols are unexported from the facade and widening `engine` is a graycode-router API change that belongs in its own plan. + +--- + +### Task C1: Make the documented boundary match the enforced one + +**Files:** +- Modify: `graycode-router/AGENTS.md:551,570-572` +- Modify: `graycode-router/README.md:48-53` (the Ecosystem Boundaries section) +- Modify: `graycode-cli/scripts/check-graycode-router-engine-boundary.sh:18-19` (comment only) + +**Interfaces:** +- Produces: one written contract naming exactly four allowed packages, cited by both repos. + +- [ ] **Step 1: State the real contract in graycode-router** + +In `graycode-router/README.md`, replace the Ecosystem Boundaries bullets at lines 48-53: + +```markdown +graycode-router is a Graycode support engine. Keep the dependency edge one-way. + +Hosts may import exactly four packages: + +| Package | Carries | +|---|---| +| `engine` | the stable host-facing facade | +| `llm` | host-facing DTOs and the `Provider` port that `engine` re-exports as aliases | +| `graph` | the portable execution-graph vocabulary | +| `tools` | tool-call and tool-result contracts | + +Everything else is engine-internal: `client`, `catalog`, `config`, +`credentials`, `router`, `runtime`, and their subpackages are not shared +contracts. Enforced by `graycode-cli/scripts/check-graycode-router-engine-boundary.sh` +and two Go AST tests in `graycode-cli/internal/testaudit/`. + +- do not import `graycode-cli/internal/*` +- do not import the removed legacy path `graycode/shared/types` +- do not import other engines (`harrier`, `shrike`, `swift`, `kestrel`, + `merlin`) — engines are peers, not dependencies +``` + +- [ ] **Step 2: Correct the AGENTS.md pitfall** + +In `graycode-router/AGENTS.md`, replace the first Common Pitfalls bullet (lines 570-572): + +```markdown +- `engine`, `llm`, `graph` and `tools` are the host contract surface. Graycode + must not assemble `client`, `catalog`, `config`, `credentials`, `router` or + `runtime`. Six symbols Graycode needs (`ChatOptions`, `ContinuationConfig`, + `StreamResult`, `ResponseFormat`, `ImageURLPart`, `InputAudioPart`) live in + `llm` with no `engine` alias; widening the facade to cover them is a + deliberate API change, not an incidental one. +``` + +- [ ] **Step 3: Correct the misleading guard comment** + +`graycode-cli/scripts/check-graycode-router-engine-boundary.sh:18-19` currently claims Graycode uses "the full vendored GraycodeRouter API surface", which overstates a four-package allowance. Replace both comment lines: + +```bash +# Host contract surface is exactly four packages: engine (facade), llm (DTOs +# and the Provider port), graph (portable graph vocabulary), tools (tool-call +# contracts). See graycode-router/README.md "Ecosystem Boundaries". +``` + +- [ ] **Step 4: Verify the guards still pass** + +```bash +cd graycode-cli && bash ./scripts/check-graycode-router-engine-boundary.sh && echo "EXIT=$?" +``` + +Expected: `graycode-router engine boundary passed (zero lower-level production imports)` then `EXIT=0`. + +- [ ] **Step 5: Commit (two repos, two commits)** + +```bash +cd graycode-router +git add README.md AGENTS.md +git commit -m "docs: document the four-package host contract surface + +AGENTS.md claimed engine-only while all three enforcement layers +deliberately allow engine, llm, graph and tools, and eight production +files depend on that allowance. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" + +cd ../graycode-cli +git add scripts/check-graycode-router-engine-boundary.sh +git commit -m "docs: correct the router boundary guard comment + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task C2: Repair the two defective guards + +**Files:** +- Modify: `graycode-cli/scripts/check-support-repo-coupling.sh:29-33` +- Modify: `graycode-cli/lefthook.yml:118-125` +- Modify: `graycode-cli/internal/testaudit/audit_test.go:223-226` +- Modify: `graycode-cli/internal/testaudit/package_boundaries_test.go:61-65` + +**Interfaces:** +- Produces: a peer guard that reports honestly when it has nothing to check, an engine-boundary check on pre-push, and both AST tests with the dead exception removed. + +- [ ] **Step 1: Make the vacuous guard honest** + +In `graycode-cli/scripts/check-support-repo-coupling.sh`, guard the empty-peer case before building the pattern. Insert immediately after the `pattern=` assignment at line 30: + +```bash + if [[ ${#peers[@]} -eq 0 ]]; then + # No sibling engines to check against. Building a regex from an empty + # peer list produced 'github\.com/GrayCodeAI/()(/|")', which matches + # nothing meaningful and made this guard silently unfailable. + echo "peer guard: ${repo} has no sibling engines to check" + continue + fi +``` + +Confirm `peers` is the array name and `continue` is inside the per-repo loop; read lines 20-40 first and adapt the variable names to what is actually there. + +- [ ] **Step 2: Verify the guard now reports rather than pretending** + +```bash +cd graycode-cli && bash ./scripts/check-support-repo-coupling.sh; echo "EXIT=$?" +``` + +Expected: `peer guard: graycode-router has no sibling engines to check` followed by the existing pass line, `EXIT=0`. + +- [ ] **Step 3: Add the missing pre-push hook** + +In `graycode-cli/lefthook.yml`, add a fourth boundary command alongside the existing three (after the `boundary-graycode-router-client` block ending at line 119): + +```yaml + boundary-graycode-router-engine: + run: bash scripts/check-graycode-router-engine-boundary.sh +``` + +- [ ] **Step 4: Verify lefthook parses and the hook is registered** + +```bash +cd graycode-cli && python3 -c "import yaml; d=yaml.safe_load(open('lefthook.yml')); print(sorted(d['pre-push']['commands']))" +``` + +Expected: the list includes `boundary-graycode-router-engine`. + +- [ ] **Step 5: Remove the dead credentials exception** + +Delete the exception block at `graycode-cli/internal/testaudit/audit_test.go:223-226`: + +```go + if strings.HasPrefix(rel, "internal/provider/gateway/") && path == graycodeRouterModule+"/credentials" { + continue + } +``` + +And at `graycode-cli/internal/testaudit/package_boundaries_test.go:61-65`, the equivalent block keyed on `filepath.ToSlash(filepath.Dir(relFile)) == "internal/provider/gateway"`. + +Verify nothing depended on them first: + +```bash +cd graycode-cli && grep -RInE --include='*.go' --exclude='*_test.go' 'graycode-router/credentials' . ; echo "exit=$? (1 means clean)" +``` + +Expected: no output, `exit=1`. + +- [ ] **Step 6: Run both AST tests and the whole guard set** + +```bash +cd graycode-cli && go test ./internal/testaudit/ -run 'TestNoDirectLowerGraycodeRouterImports|TestPackageDependencyGraph' -count=1 -v && make boundaries +``` + +Expected: both tests PASS, all guards pass. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/check-support-repo-coupling.sh lefthook.yml internal/testaudit/audit_test.go internal/testaudit/package_boundaries_test.go +git commit -m "fix: repair two boundary guards that could not fail + +check-support-repo-coupling.sh built its regex from an empty peer list, +degenerating to a pattern matching nothing. The engine-boundary script +ran in CI but not on pre-push. Both AST tests carried a +gateway->credentials exception that no production file uses. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +# Workstream D — Naming Sweep + +**Scope (approved):** documentation, user-visible strings, and code comments. Roughly 230 sites. `graycode-cli` is already clean: it contains **zero** occurrences of `hawk` outside generated GitNexus blocks. Marketing copy under `graycode-platform/apps/web` is out of scope; it is dated content and product-line branding. + +Every load-bearing identifier listed in Global Constraints stays untouched. When in doubt, a name that appears in a wire payload, a database column, an environment variable, a filesystem path, a Cloudflare binding, a Go module path, or a URL route is load-bearing. + +**Two bonus defects folded in here:** +- A prior substring mass-rename corrupted prose in 15 places: `trace`→`swift` produced "Step-by-step execution swift", `inspect`→`merlin` produced "Merlin configured MCP servers", `tok`→`shrike` produced "ref-shrike". +- `graycode-skills` advertises "12,171+ skills" and "31 categories". The real counts are **12,167 skills** and **27 categories** (verified by `find … -name SKILL.md | wc -l` and `ls categories | wc -l`). + +--- + +### Task D1: graycode-skills — rebrand from starling and correct the counts + +**Files:** +- Modify: `README.md:1,3,7,16,19,22,25,99-102`, `CONTRIBUTING.md:1,3,82,175`, `AGENTS.md:2,7,9,44-48`, `SECURITY.md:1`, `CHANGELOG.md:3`, `api/openapi.yaml:3,5,9,13,15,17,38-39`, `docs/architecture.md:3,5,16,18,25,45`, `pyproject.toml:8`, `tools/init_skill.py:59`, `tools/sign_manifest.py:141`, `scripts/check-consumer-boundaries.sh:10` + +**Interfaces:** +- Produces: no code behavior change. `pyproject.toml` package `name = "starling"` at line 6 is **not** changed here; renaming a published package name is a release decision. + +- [ ] **Step 1: Correct the counts and the CLI name in the README** + +In `graycode-skills/README.md`: +- Line 1: `# hawk Community Skills` → `# Graycode Community Skills` +- Line 3: `[hawk](https://github.com/GrayCodeAI/hawk)` → `[Graycode](https://github.com/GrayCodeAI/graycode-cli)`, and `12,171+` → `12,167`, and `31 categories` → `27 categories` +- Line 7: `that hawk loads` → `that Graycode loads` +- Lines 16, 19, 25: `hawk skills list` → `graycode skills list`, `hawk skills search api-testing` → `graycode skills search api-testing`, `the hawk REPL` → `the graycode REPL` +- Line 22: `hawk skills install python-pandas` → `graycode skills install GrayCodeAI/graycode-skills python-pandas` + +> The install syntax genuinely differs: `graycode skills install` takes ` [skill-name]` (`graycode-cli/cmd/skills_cmd.go:74`), not a bare skill name. Documenting the bare form would keep the command broken even after Workstream A. + +- [ ] **Step 2: Fix the Ecosystem Boundaries block** + +`README.md:99-102` and `AGENTS.md:44-46` list **pre-rename engine names** that no longer exist: `yaad`, `tok`, `trace`, `sight`, `inspect`. Replace with the live names: + +```markdown +- `graycode-skills` extends Graycode through public skill and plugin surfaces. +- Do not reference support engine repos (`graycode-router`, `harrier`, `shrike`, + `swift`, `kestrel`, `merlin`) as direct dependencies. +- Do not reference `graycode-cli/internal/*` or the removed legacy path + `graycode/shared/types`. +- Skills should assume Graycode is the product boundary. +``` + +- [ ] **Step 3: Update the boundary guard's alternation to match** + +`graycode-skills/scripts/check-consumer-boundaries.sh` still forbids the pre-rename engine names. The pattern is inline inside the `grep -RInE` call at lines 7-12, not a variable. Replace the pattern argument on line 10: + +```bash + 'github\.com/GrayCodeAI/(graycode-router|harrier|shrike|swift|kestrel|merlin)(/|")|github\.com/GrayCodeAI/graycode-cli/(internal/|shared/types)' \ +``` + +Also update the two failure messages at lines 16 and 19 so they name Graycode rather than Hawk and graycode-skills rather than starling. + +Verify it still runs clean: + +```bash +cd graycode-skills && bash scripts/check-consumer-boundaries.sh; echo "EXIT=$?" +``` + +Expected: `EXIT=0`. This guard scans `README.md docs api tests tools .claude-plugin .codex-plugin .cursor-plugin`, so it will fail if Step 2's replacement text accidentally reintroduces a forbidden module path. + +- [ ] **Step 4: Sweep the remaining prose files** + +```bash +cd graycode-skills +for f in CONTRIBUTING.md AGENTS.md SECURITY.md CHANGELOG.md api/openapi.yaml docs/architecture.md pyproject.toml tools/init_skill.py tools/sign_manifest.py; do + sed -i '' -e 's/\bstarling\b/graycode-skills/g' -e 's/\bStarling\b/Graycode Skills/g' \ + -e 's/\bhawk-eco\b/graycode-eco/g' -e 's/\bHawk\b/Graycode/g' -e 's/\bhawk\b/graycode/g' "$f" +done +sed -i '' 's/12,171+/12,167/g; s/31 categories/27 categories/g' CONTRIBUTING.md +sed -i '' 's/^name = "graycode-skills"/name = "starling"/' pyproject.toml # restore the package name +grep -rn 'GrayCodeAI/graycode-skills' pyproject.toml || true +``` + +- [ ] **Step 5: Repair what the blunt sweep broke** + +The `sed` above also rewrites the GitNexus block in `AGENTS.md` and any load-bearing identifier. Restore them: + +```bash +cd graycode-skills +git diff --stat +git diff | grep -nE '^\+.*(AGENT_ENUM|progressive-disclosure|/graycode:|sync_marketplace)' || echo "no load-bearing identifiers touched" +``` + +Inspect `git diff` in full. Revert any hunk that changes a value inside a `` block, the `/hawk:` invoke prefix, the `AGENT_ENUM` values, or the `hawk-progressive-disclosure` marker. Those files (`tools/sync_marketplace.py`, `tools/validate_skill.py`, `tools/migrate_oversized_skills.py`) are not in the Step 4 loop, but verify nothing else drifted. + +- [ ] **Step 6: Verify the corpus still validates** + +```bash +cd graycode-skills && python -m pytest -q && python tools/validate_skill.py --all --warning-budget tools/validation_warning_budget.json && ruff check . +``` + +Expected: tests PASS, zero validation warnings, ruff clean. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "docs: rebrand from starling/hawk to graycode-skills/graycode + +Also corrects the advertised counts (12,167 skills across 27 +categories, not 12,171+ across 31), documents the real +'graycode skills install ' syntax, and replaces the +pre-rename engine names in the boundary docs and guard. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task D2: graycode-router — docs, user-facing strings, and comments + +**Files:** +- Modify: `README.md:33,39,42,48,50,52,53,177,305,309`, `AGENTS.md:18,52,58,144`, `CONTRIBUTING.md:4`, `SECURITY.md:10`, `docs/ARCHITECTURE.md:17`, `docs/architecture/HOST-ENGINE-BOUNDARY.md` (21 lines), `docs/guides/DYNAMIC-MODEL-DISCOVERY.md` (24 lines), `docs/guides/CREDENTIAL-SETUP-FLOW.md:1,3,38,53,72`, `docs/design/GRAYCODE-ROUTER-ENTERPRISE.md:61,78` +- Modify (user-facing strings): `catalog/v1.go:633`, `setup/status.go:135`, `runtime/preflight.go:48` +- Modify (comments): `llm/types.go`, `llm/provider.go` and ~140 further comment lines +- Modify: `scripts/test-config-flow.sh:44` +- Modify: `scripts/check-ecosystem-boundaries.sh:10` + +**Interfaces:** +- Produces: no behavior change. Three user-visible error strings change wording only. + +- [ ] **Step 1: Fix the three user-facing strings first** + +These are the only sweep items a user can actually see in the terminal. + +- `graycode-router/catalog/v1.go:633`: `run: hawk models refresh` → `run: graycode models refresh` +- `graycode-router/setup/status.go:135`: `hawk refreshes automatically; use \`hawk models refresh\`` → `graycode refreshes automatically; use \`graycode models refresh\`` +- `graycode-router/runtime/preflight.go:48`: `hawk will discover on /config` → `graycode will discover on /config` + +- [ ] **Step 2: Write a test that pins them** + +Create `graycode-router/setup/naming_test.go`: + +```go +package setup + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestNoLegacyHostNameInUserFacingStrings guards the three call sites that +// print a command for the user to run. They named the old product. +func TestNoLegacyHostNameInUserFacingStrings(t *testing.T) { + files := []string{ + filepath.Join("..", "catalog", "v1.go"), + filepath.Join("..", "setup", "status.go"), + filepath.Join("..", "runtime", "preflight.go"), + } + for _, f := range files { + data, err := os.ReadFile(f) // #nosec G304 -- fixed test fixture paths + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + for i, line := range strings.Split(string(data), "\n") { + if !strings.Contains(line, `"`) { + continue + } + if strings.Contains(line, "hawk models refresh") || strings.Contains(line, "hawk will discover") || strings.Contains(line, "hawk refreshes") { + t.Errorf("%s:%d prints the legacy host name to the user: %s", f, i+1, strings.TrimSpace(line)) + } + } + } +} +``` + +- [ ] **Step 3: Run it** + +```bash +cd graycode-router && go test ./setup/ -run TestNoLegacyHostNameInUserFacingStrings -v +``` + +Expected: PASS after Step 1. Revert one string temporarily to confirm the test can fail, then restore it. + +- [ ] **Step 4: Sweep the documentation** + +```bash +cd graycode-router +for f in README.md AGENTS.md CONTRIBUTING.md SECURITY.md docs/ARCHITECTURE.md \ + docs/architecture/HOST-ENGINE-BOUNDARY.md docs/guides/DYNAMIC-MODEL-DISCOVERY.md \ + docs/guides/CREDENTIAL-SETUP-FLOW.md docs/design/GRAYCODE-ROUTER-ENTERPRISE.md; do + sed -i '' -e 's|GrayCodeAI/hawk|GrayCodeAI/graycode-cli|g' -e 's/\bhawk-eco\b/graycode-eco/g' \ + -e 's/\bHawk\b/Graycode/g' -e 's/\bhawk\b/graycode/g' "$f" +done +grep -rn '\bhawk\b\|\bHawk\b' README.md AGENTS.md docs/ || echo "DOCS CLEAN" +``` + +- [ ] **Step 5: Fix the three doc facts the sweep cannot fix** + +- `README.md:50` says DTOs live in `eagle/llm`. The `eagle` module was removed and vendored. Change to: ``host-facing DTOs and the `Provider` port live in `llm/`; `engine/` re-exports them as aliases``. +- `SECURITY.md:10` and `CONTRIBUTING.md:4` link `VERSIONING.md` at the repo root of a repo that no longer exists. Point both at `https://github.com/GrayCodeAI/graycode-cli/blob/main/docs/versioning.md`. +- `docs/guides/CREDENTIAL-SETUP-FLOW.md:53` cites `hawk/cmd/chat_config_xiaomi.go`. Verify the current filename before writing a replacement: + +```bash +ls ../graycode-cli/cmd/ | grep -i 'chat_config' +``` + +Use whichever file actually exists; if none matches, delete the file reference rather than inventing one. + +- [ ] **Step 6: Remove the hard-coded personal path** + +`graycode-router/scripts/test-config-flow.sh:44` contains an absolute path into a previous working directory. Replace: + +```bash +cd "$(dirname "$0")/.." +``` + +- [ ] **Step 7: Extend the ecosystem guard to the current repo name** + +`graycode-router/scripts/check-ecosystem-boundaries.sh:10` forbids only `github.com/GrayCodeAI/hawk`, a module that no longer exists, while permitting the real host module. Widen the pattern, keeping the variable name `FORBIDDEN_HAWK` because it is read at lines 16 and 20: + +```bash +FORBIDDEN_HAWK='github\.com/GrayCodeAI/(hawk|graycode-cli)(/|")' +``` + +The comment above it at lines 7-9 also claims shared vocabulary "belongs in eagle", a module that was removed and vendored into `graycode-cli/internal/contracts`. Rewrite it: + +```bash +# GraycodeRouter is host-neutral: it must not depend on any Graycode package. +# Shared ecosystem vocabulary lives in graycode-cli/internal/contracts, which +# hosts vendor rather than import from here. +``` + +Verify: + +```bash +cd graycode-router && bash scripts/check-ecosystem-boundaries.sh; echo "EXIT=$?" +``` + +Expected: `EXIT=0`. + +- [ ] **Step 8: Sweep the Go comments** + +```bash +cd graycode-router +grep -rln '\bhawk\b\|\bHawk\b' --include='*.go' . | while read -r f; do + sed -i '' -e 's|// \(.*\)\bHawk\b|// \1Graycode|g' -e 's|// \(.*\)\bhawk\b|// \1graycode|g' "$f" +done +git diff --stat +``` + +Then inspect the diff and revert every hunk touching a **string literal** rather than a comment, and every load-bearing identifier: `HAWK_CONFIG_DIR` (`config/provider_env.go:402`, `config/category.go:122`, `engine/engine.go`), the `~/.hawk` paths (`credentials/migrate.go:14,88`), and `hawk_build` / `hawk_build_concise` (`tools/versioning.go:114,115`). + +```bash +git diff | grep -nE '^\+.*(HAWK_CONFIG_DIR|\.hawk|hawk_build)' && echo "REVERT THESE HUNKS" || echo "no load-bearing identifiers touched" +``` + +- [ ] **Step 9: Build, test, format** + +```bash +cd graycode-router && gofumpt -l . && go build ./... && go test ./... && go vet ./... +``` + +Expected: `gofumpt -l` prints nothing, build and vet clean, all tests PASS. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "docs: rename the host from hawk to graycode across docs and comments + +Also fixes three user-facing strings that told users to run +'hawk models refresh', corrects the removed eagle/llm reference, +repoints dead VERSIONING.md links, removes a hard-coded personal path +from test-config-flow.sh, and extends the ecosystem guard to the +current host module name. + +Keeps HAWK_CONFIG_DIR, ~/.hawk migration paths and hawk_build tool +namespaces: those are compatibility values, not prose. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task D3: graycode-platform — docs and the rename-corruption typos + +**Files:** +- Modify: `README.md:16,20,29,40,56,94,103`, `ARCHITECTURE.md:5,13,254,314`, `AGENTS.md:34,38,70`, `CLAUDE.md:31`, `CONTRIBUTING.md:4`, `SECURITY.md:10`, `CHANGELOG.md:3`, `docs/architecture.md:6`, `apps/worker/README.md:1,70,82`, `apps/bff/README.md:7`, `apps/worker/docs/{ENCRYPTION-KEY-ROTATION,DELIVERY-CONTEXT-FLOW,ARCHITECTURE-IMPLEMENTATION}.md`, `apps/worker/migrations/0003_cli_device_authorization.sql:1` (comment only), `apps/worker/src/app.ts:57`, `scripts/deploy.sh:24,26` + +**Interfaces:** +- Consumes: nothing. Runs independently of Workstream B, though B should land first so the docs describe the shipped contract. + +- [ ] **Step 1: Fix the three rename-corruption typos** + +A prior `trace`→`swift` substring rename corrupted generated GitNexus tables. In `AGENTS.md:70` and `CLAUDE.md:31`: + +```markdown +| `gitnexus://repo/graycode-platform/process/{name}` | Step-by-step execution trace | +``` + +Check the same corruption in the other three repos: + +```bash +cd /Users/lakshmanpatel/Desktop/OSS2026/graycode-eco +grep -rn 'execution swift' --include='*.md' --exclude-dir=node_modules --exclude-dir=.git . +``` + +Fix every hit to `execution trace`. + +- [ ] **Step 2: Fix the stale README warning** + +`graycode-platform/README.md:56` warns about `lakshmanp230.workers.dev` and names `apps/web/public/_headers`, `apps/web/dist/_headers` and `api/graycode-cloud-openapi.yaml`. Those three files are already clean; the only remaining occurrence was `apps/worker/api/openapi.yaml:7`, deleted in Task B4. Replace the whole warning block with an accurate one: + +```markdown +> ⚠️ The Graycode Cloud worker has no route or custom domain in +> `apps/worker/wrangler.jsonc`, so it is reachable only at its generated +> `workers.dev` address. CLI device traffic needs a stable hostname before +> launch; `api.graycodeai.com` is the browser BFF and rejects device tokens. +``` + +- [ ] **Step 3: Fix the architecture diagram's dead host** + +`ARCHITECTURE.md:314` renders a node labelled `graycode-api.workers.dev`, a host that does not exist. Replace with `api.graycodeai.com`. + +- [ ] **Step 4: Sweep the prose** + +```bash +cd graycode-platform +for f in README.md ARCHITECTURE.md AGENTS.md CONTRIBUTING.md SECURITY.md CHANGELOG.md \ + docs/architecture.md apps/worker/README.md apps/bff/README.md \ + apps/worker/docs/ENCRYPTION-KEY-ROTATION.md apps/worker/docs/DELIVERY-CONTEXT-FLOW.md \ + apps/worker/docs/ARCHITECTURE-IMPLEMENTATION.md; do + sed -i '' -e 's/Hawk Cloud/Graycode Cloud/g' -e 's|GrayCodeAI/hawk|GrayCodeAI/graycode-cli|g' \ + -e 's/\bhawk-eco\b/graycode-eco/g' -e 's/\bHawk\b/Graycode/g' -e 's/\bhawk\b/graycode/g' "$f" +done +``` + +- [ ] **Step 5: Restore the identifiers the sweep clobbered** + +```bash +cd graycode-platform +git diff | grep -nE '^\+.*(GraycodeCloudService|GRAYCODE_CLOUD"|graycode\.window_days|graycode\.sessions|graycode\.tokens)' && echo "REVERT THESE" || echo "clean" +``` + +`HawkCloudService` (RPC entrypoint), `HAWK_CLOUD` (service binding) and the `hawk.*` OTel metric names must survive. Revert any hunk that renamed them. In `apps/worker/README.md:70` the sentence should read: "The BFF calls Graycode Cloud through the named `HawkCloudService` RPC entrypoint." + +- [ ] **Step 6: Fix the login command name** + +`apps/worker/README.md:82` and `apps/worker/docs/ENCRYPTION-KEY-ROTATION.md:34` reference `hawk login`. The real command is `graycode cloud login` (`graycode-cli/cmd/cloud.go:35`). The Step 4 sweep produces `graycode login`, which is wrong. Correct both to `graycode cloud login`. + +```bash +cd graycode-platform && grep -rn 'graycode login' apps/ docs/ *.md +``` + +Expected after fixing: no output. + +- [ ] **Step 7: Fix the two remaining strings** + +- `apps/worker/src/app.ts:57`: `'Hawk Cloud request failed'` → `'Graycode Cloud request failed'` +- `scripts/deploy.sh:24,26`: `Deploying Hawk Cloud Worker` and `Hawk Cloud Worker deployed` → `Graycode Cloud Worker` +- `apps/worker/migrations/0003_cli_device_authorization.sql:1`: the comment mentioning `hawk login` → `graycode cloud login`. **Comment only. The filename and the column stay** — the column is renamed by migration `0024`, never by editing an applied migration. + +- [ ] **Step 8: Typecheck, test, format** + +```bash +cd graycode-platform && pnpm --filter @graycode/worker check && pnpm --filter @graycode/bff check +``` + +Expected: prettier clean, typecheck clean, all tests PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "docs: rename Hawk Cloud to Graycode Cloud across platform docs + +Also corrects 'graycode login' to the real 'graycode cloud login' +command, replaces a stale workers.dev warning that named three +already-clean files, fixes a dead graycode-api.workers.dev node in the +architecture diagram, and repairs 'execution swift' typos left by an +earlier substring rename. + +Keeps HAWK_CLOUD, HawkCloudService and the hawk.* OTel metric names. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +### Task D4: graycode-cli — refresh the ecosystem inventory and drop starling fixtures + +**Files:** +- Modify: `graycode-cli/README.md` (the Ecosystem section, around lines 442-490) +- Modify: `graycode-cli/internal/plugin/registry_test.go:20,21,167,192,250,276,285` +- Modify: `graycode-cli/internal/plugin/skillslock_test.go:20` + +**Interfaces:** +- Consumes: the four-package contract from Task C1. + +> `graycode-cli` has zero `hawk` occurrences. The only stale names are `starling` in test fixtures and an ecosystem table that lists repos not present in `ecosystem.yaml`. + +- [ ] **Step 1: Replace the starling fixtures** + +```bash +cd graycode-cli +sed -i '' 's|GrayCodeAI/starling|GrayCodeAI/graycode-skills|g' internal/plugin/registry_test.go internal/plugin/skillslock_test.go +grep -rn 'starling' internal/ cmd/ || echo "CLI CLEAN" +``` + +- [ ] **Step 2: Correct the ecosystem section** + +`README.md:442-490` names `owl`, `falcon` and other repos as ecosystem members, but `ecosystem.yaml` lists exactly four: `graycode-cli`, `graycode-router`, `graycode-skills`, `graycode-platform`. Make the prose match the manifest, and extend the component table at the end of the section: + +```markdown +| Component | Repository | Purpose | +|---|---|---| +| **graycode** | This repo | AI coding agent | +| **graycode-router** | [GrayCodeAI/graycode-router](https://github.com/GrayCodeAI/graycode-router) | LLM provider runtime | +| **graycode-skills** | [GrayCodeAI/graycode-skills](https://github.com/GrayCodeAI/graycode-skills) | Community skill registry | +| **graycode-platform** | [GrayCodeAI/graycode-platform](https://github.com/GrayCodeAI/graycode-platform) | Web, BFF, and Graycode Cloud | + +`ecosystem.yaml` is the canonical inventory; tooling reads it rather than +carrying its own list. Support engines mounted through Go module +dependencies (`harrier`, `shrike`, `swift`, `kestrel`, `merlin`, `falcon`) +live in their own repositories and are not part of this workspace. +``` + +- [ ] **Step 3: Verify the manifest and the prose agree** + +```bash +cd graycode-cli && ./scripts/ecosystem-manifest.sh list && go test ./internal/plugin/ ./internal/testaudit/ -count=1 +``` + +Expected: the manifest lists exactly the four repos named in the table; tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add README.md internal/plugin/registry_test.go internal/plugin/skillslock_test.go +git commit -m "docs: align the ecosystem section with ecosystem.yaml + +Also replaces GrayCodeAI/starling test fixtures with the repo's +current name. + +Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" +``` + +--- + +# Execution Order + +The workstreams are independent, but two ordering constraints are real: + +1. **A1 → A2 → A3.** The CLI cannot be verified until the release URL is live. +2. **B1 → B2/B3/B4 → deploy.** The migration must be applied before the worker deploys. + +Recommended sequence, one PR per repo per workstream: + +| Order | Workstream | Repos | Blocking? | +|---|---|---|---| +| 1 | A (registry) | graycode-skills, then graycode-cli | A2 must merge and run before A3 verifies | +| 2 | B (wire contract) | graycode-platform, graycode-cli | migration before deploy | +| 3 | C (boundary truth) | graycode-router, graycode-cli | none | +| 4 | D (naming) | all four | run last so it sweeps the text A/B/C introduce | + +--- + +# Self-Review + +**Coverage.** Every finding from the scouting pass maps to a task: registry publishing (A2), registry shape (A1), registry consumption (A3), install discovery (A4), device-login field drift (B1-B2), usage capability drift (B1-B2), BFF enum (B3), triplicate OpenAPI plus personal subdomain (B4), missing cloud endpoint documented as a known gap (B5), documented-vs-enforced boundary (C1), vacuous peer guard (C2), missing pre-push hook (C2), dead credentials exception (C2), legacy naming (D1-D4), wrong skill and category counts (D1), rename-corruption typos (D3), hard-coded personal path (D2). + +**Deliberately out of scope, and why.** +- Widening the `engine` facade to re-export `ChatOptions`, `StreamResult`, `ResponseFormat`, `ContinuationConfig`, `ImageURLPart` and `InputAudioPart` so graycode-cli could be `engine`-only. That is a graycode-router public API change and deserves its own plan. +- Provisioning a public hostname for the Graycode Cloud worker. Infrastructure decision, flagged in B5. +- Renaming the `starling` Python package name in `pyproject.toml:6`. A published-name change is a release decision. +- Marketing copy under `apps/web`, including the blog post claiming 12,147 skills across 21 categories. Dated content; correcting it is an editorial call. +- `graycode-cli`'s own `docs/` tree, which mentions competitors and legacy names inside dated design documents. + +**Known risk.** Tasks D1, D2 and D3 use `sed` sweeps followed by a manual diff review. The review step is not optional: each sweep is capable of rewriting a load-bearing identifier, and each of those tasks carries an explicit revert-check step naming the identifiers at risk. If a sweep's diff exceeds what a reviewer can read carefully, split it per file rather than trusting the pattern. From e37ba1f3ea2ee4641654bad1121b102095d8706b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:28:58 +0530 Subject: [PATCH 114/116] fix: gofumpt formatting; drop non-deliverable plan doc from merge - Apply gofumpt to cmd/exec.go and two internal/intelligence/memory files (formatting-only) to satisfy the CI format check. - Remove docs/plans/2026-09-05-graycode-eco-integrity.md from the branch: it is a working planning note (not a CLI deliverable) whose malformed code fences and hard tabs fail the repo's markdownlint gate. Kept locally as untracked. --- cmd/exec.go | 3 +- .../2026-09-05-graycode-eco-integrity.md | 1781 ----------------- internal/intelligence/memory/auto_capture.go | 21 +- internal/intelligence/memory/session_diff.go | 6 +- 4 files changed, 20 insertions(+), 1791 deletions(-) delete mode 100644 docs/plans/2026-09-05-graycode-eco-integrity.md diff --git a/cmd/exec.go b/cmd/exec.go index 5ba4ccd7..d22bccf3 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -392,7 +392,8 @@ func runExec(_ *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "%s\n", auditTint( fmt.Sprintf("graycode: %d tokens in / %d out · %d turn(s) · %s · %s", totalIn, totalOut, turns, time.Since(start).Round(time.Millisecond), effectiveModel), - textMuted)) + textMuted, + )) } if exitCode != 0 { return fmt.Errorf("exec failed: %s", execErr) diff --git a/docs/plans/2026-09-05-graycode-eco-integrity.md b/docs/plans/2026-09-05-graycode-eco-integrity.md deleted file mode 100644 index 3ea3c82c..00000000 --- a/docs/plans/2026-09-05-graycode-eco-integrity.md +++ /dev/null @@ -1,1781 +0,0 @@ -# GrayCode Ecosystem Integrity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Repair the three broken links between the four graycode repos (skills registry, cloud wire contract, router boundary) and remove legacy `hawk`/`starling`/`eagle` naming from every user-visible surface. - -**Architecture:** Four independent workstreams, each shippable as its own PR stack. A fixes the graycode-cli → graycode-skills registry link at all three broken layers (publishing, JSON shape, install discovery). B renames the cloud wire contract from `hawk` to `graycode` in graycode-platform, which makes the already-correct CLI client work. C aligns the documented router boundary with the enforced one and repairs two defective guards. D is a mechanical naming sweep. - -**Tech Stack:** Go 1.26+ (graycode-cli, graycode-router), Python 3.11 + pytest (graycode-skills), TypeScript + Hono + vitest + Cloudflare D1 (graycode-platform), GitHub Actions. - -**Spec:** This document is self-contained. Its findings were produced by a read-only scouting pass over all four repos on 2026-09-05 and verified by an independent adversarial pass; every claim below carries a `file:line` citation. - -## Global Constraints - -- **Branch discipline (all four repos).** Never commit to `main`. Create a feature branch first, named `feat/`, `fix/` or `chore/`. Open a PR, get CI green, then merge. Source: `graycode-cli/AGENTS.md:16`, `graycode-skills/AGENTS.md:13`, `graycode-platform/AGENTS.md:13`. -- **graycode-cli is currently on branch `chore/compat-matrix-0-0-1`, not `main`.** Branch from `main` for this work, not from the current HEAD. -- **Commits:** Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`). No `Co-authored-by:` trailers in graycode-router; a githook strips them. Source: `graycode-router/AGENTS.md:563`. -- **CHANGELOG:** every repo follows Keep a Changelog. Add entries under `## [Unreleased]` in the repo you touch. graycode-cli uses Keep a Changelog 1.1.0, graycode-router and graycode-skills use 1.0.0. -- **Go:** Go 1.26+, pure Go, no CGO. `gofumpt` formatting is enforced in CI. Table-driven tests. -- **graycode-cli CI gate:** `make ci` runs `tidy fmt vet boundaries lint test-race security api-validate` (`graycode-cli/Makefile:160`). The `boundaries` target aggregates nine guard scripts (`Makefile:136`). -- **graycode-platform CI gate:** `pnpm --filter @graycode/worker check` runs `prettier --check . && tsc --noEmit && vitest run`. Repo is pinned to pnpm 9.15.0 via Corepack. -- **Load-bearing identifiers that must NOT be renamed in any workstream.** These are wire values, storage keys, or third-party contracts, not prose: - - `hwc_` device-token prefix (`graycode-platform/apps/worker/src/domain/tokens.ts:18`) - - `HAWK_CLOUD` service binding and `HawkCloudService` RPC entrypoint (`apps/bff/wrangler.jsonc:21,23`) - - OTel metric names `hawk.window_days`, `hawk.sessions`, `hawk.tokens.total` (`apps/worker/src/routes/analytics.ts:532,540,555`) - - `HAWK_CONFIG_DIR` env fallback (`graycode-router/config/provider_env.go:402`) - - `~/.hawk/{env,.env,.legacy-env-migrated}` migration source paths (`graycode-router/credentials/migrate.go:14,88`) - - `hawk_build` / `hawk_build_concise` ToolNamespace wire values (`graycode-router/tools/versioning.go:114,115`) - - `/hawk:` plugin invoke prefix (`graycode-skills/tools/sync_marketplace.py:65`) and the `hawk` value in `AGENT_ENUM` (`graycode-skills/tools/validate_skill.py:66`) - - `hawk-progressive-disclosure` marker written into existing SKILL.md files (`graycode-skills/tools/migrate_oversized_skills.py:30`) - - GitNexus index names inside `` blocks in every AGENTS.md / CLAUDE.md — these are regenerated by the tool - - Go module paths `github.com/GrayCodeAI/{harrier,kestrel,merlin,shrike,swift,falcon}` — these are **live** dependencies in `graycode-cli/go.mod:15-18,55-57,172`, not dead names - - The `/products/` URL slugs in `graycode-platform/apps/web/lib/products.ts:9` -- **Workstream B is the only one that changes a deployed wire contract.** It requires a D1 migration and a coordinated worker deploy. Do not merge B's worker PR without applying migration `0024` first. - ---- - -# Workstream A — Skills Registry - -**Problem.** `graycode skills search` cannot work today, and `graycode skills install GrayCodeAI/graycode-skills ` cannot work either. The link is broken at three independent layers, and fixing only the URL restores nothing. - -1. **No public URL exists.** `graycode-skills/.github/workflows/publish-registry.yml:55-62` generates `registry.json`, signs it, then uploads it only as a GitHub Actions artifact (90-day retention). There is no release, no Pages, no R2, no commit-back. `registry.json` is gitignored at `graycode-skills/.gitignore:50`, so `raw.githubusercontent.com` returns 404 under both the old `starling` name and the new one. Verified: both URLs return HTTP 404; `GrayCodeAI/starling` 301-redirects to `GrayCodeAI/graycode-skills`; the repo has `has_pages=false` and its only release `v0.1.0` has zero assets. -2. **The JSON shapes do not match.** `tools/update_registry.py:165` emits a bare JSON array. `graycode-cli/internal/plugin/registry.go:73-77` parses an object `{version, updated_at, skills[]}`. `json.Unmarshal` of an array into that struct fails, so `FetchIndex` returns `invalid index` (`registry.go:136-137`) even with a working URL. The emitter also omits `repo`, which `auto_skill.go:175` needs to build the clone URL. -3. **Install cannot find the skills.** `registry.go:261-265` scans only `/*/SKILL.md` or `/skills/*/SKILL.md`. graycode-skills stores skills at `categories///SKILL.md` and has no `skills/` directory, so every install returns `skill not found`. - -**Decision (approved):** publish `registry.json` as an asset on a rolling GitHub Release. Stable URL, no git bloat, no new infrastructure. - -**Also in scope.** `graycode-cli/internal/plugin/marketplace.go:49` points at `plugins-registry.json`, a file that **nothing in any of the four repos generates**. It is a phantom default source and is removed here. - ---- - -### Task A1: Emit the registry shape graycode-cli actually parses - -**Files:** -- Modify: `graycode-skills/tools/update_registry.py:115-124` (add `repo`), `:160-166` (wrap in object) -- Modify: `graycode-skills/tools/registry_schema.py:30-58` (allow `repo`), `:84-87` (`REGISTRY_SCHEMA` becomes an object), `:217-230` (`load_and_validate` reads the object) -- Test: `graycode-skills/tests/test_update_registry.py` - -**Interfaces:** -- Produces: on-disk `registry.json` of the form `{"version": 1, "skills": [entry, ...]}` where each entry gains `"repo": "GrayCodeAI/graycode-skills"`. Task A3 relies on this shape. `build_registry()` still returns the bare `list[dict]` so `tools/skill_graph.py:13,268` is unaffected. - -> **Determinism matters.** `update_registry.py --check` compares generated text against the file on disk and is run in CI. Do **not** add a wall-clock `updated_at`; it would make `--check` fail on every run. The top-level `updated_at` is omitted entirely. `graycode-cli` never reads `idx.Version` or `idx.UpdatedAt` (verified: zero references outside tests), so an absent `updated_at` is harmless. - -- [ ] **Step 1: Write the failing tests** - -Append to `graycode-skills/tests/test_update_registry.py`: - -```python -class TestCanonicalRenderShape: - """registry.json must match the object shape graycode-cli parses.""" - - def test_render_wraps_entries_in_object(self): - from update_registry import render_registry - - doc = json.loads(render_registry([{"name": "a", "description": "d"}])) - assert isinstance(doc, dict), "top level must be an object, not an array" - assert doc["version"] == 1 - assert doc["skills"] == [{"name": "a", "description": "d"}] - - def test_render_omits_updated_at_for_determinism(self): - from update_registry import render_registry - - first = render_registry([{"name": "a"}]) - second = render_registry([{"name": "a"}]) - assert first == second - assert "updated_at" not in json.loads(first) - - def test_entries_carry_repo_slug(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - from update_registry import build_registry - - cat = tmp_path / "categories" / "python" / "demo-skill" - cat.mkdir(parents=True) - (cat / "SKILL.md").write_text( - "---\nname: demo-skill\ndescription: A demo skill\n---\n\nBody\n" - ) - monkeypatch.setattr("update_registry.REPO_ROOT", tmp_path) - monkeypatch.setattr("update_registry.CATEGORIES_DIR", tmp_path / "categories") - - entries = build_registry() - assert entries[0]["repo"] == "GrayCodeAI/graycode-skills" - - def test_schema_accepts_repo_field(self): - from registry_schema import validate_registry_entry - - errors = validate_registry_entry( - { - "name": "demo", - "description": "d", - "category": "python", - "tags": ["python"], - "path": "categories/python/demo", - "file_count": 1, - "has_scripts": False, - "repo": "GrayCodeAI/graycode-skills", - }, - path="demo", - ) - assert errors == [] -``` - -Ensure `import json` and `from pathlib import Path` are present at the top of the file. - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -cd graycode-skills && python -m pytest tests/test_update_registry.py -k CanonicalRenderShape -v -``` - -Expected: FAIL. `test_render_wraps_entries_in_object` fails with `assert isinstance(doc, dict)` because `render_registry` currently returns a JSON array. `test_entries_carry_repo_slug` fails with `KeyError: 'repo'`. `test_schema_accepts_repo_field` fails with an `additionalProperties` violation naming `repo`. - -- [ ] **Step 3: Add the `repo` slug to each entry** - -In `graycode-skills/tools/update_registry.py`, add a module constant next to the other module-level paths (near `REGISTRY_PATH`, around line 24): - -```python -# The GitHub slug every skill in this repo is installed from. graycode-cli -# builds its clone URL from this field (internal/plugin/auto_skill.go). -REGISTRY_REPO = "GrayCodeAI/graycode-skills" -``` - -Then extend the entry literal at line 115: - -```python - entry = { - "name": name, - "description": description, - "category": category_name, - "tags": tags, - "path": path, - "repo": REGISTRY_REPO, - "file_count": count_files(skill_dir), - "has_scripts": has_scripts_dir(skill_dir), - } -``` - -- [ ] **Step 4: Wrap the rendered document in the object shape** - -Replace `render_registry` in `graycode-skills/tools/update_registry.py`: - -```python -def render_registry(entries: list[dict]) -> str: - """Render registry entries in the canonical on-disk format. - - The top level is an object, not an array: graycode-cli parses - {version, updated_at, skills[]} (internal/plugin/registry.go). No - timestamp is emitted so that `--check` stays deterministic. - """ - document = {"version": 1, "skills": entries} - return json.dumps(document, indent=2, ensure_ascii=False) + "\n" -``` - -- [ ] **Step 5: Teach the schema about the new shape** - -In `graycode-skills/tools/registry_schema.py`, add `repo` to `REGISTRY_ENTRY_SCHEMA["properties"]` alongside the other optional fields (the block ending at line 78, before `"additionalProperties": False`): - -```python - "repo": { - "type": "string", - "description": "GitHub owner/repo slug the skill is installed from", - }, -``` - -Replace `REGISTRY_SCHEMA` (line 84): - -```python -REGISTRY_SCHEMA: dict[str, Any] = { - "type": "object", - "properties": { - "version": {"type": "integer"}, - "skills": {"type": "array", "items": REGISTRY_ENTRY_SCHEMA}, - }, - "required": ["version", "skills"], - "additionalProperties": False, -} -``` - -Then update `load_and_validate` (line 217) so it validates the `skills` array rather than the whole document as an array. Read the function first and adapt its existing error-collection style; the only change is that the list of entries is now `data["skills"]` instead of `data`, and a non-object top level is itself an error. - -- [ ] **Step 6: Run the tests to verify they pass** - -```bash -cd graycode-skills && python -m pytest tests/test_update_registry.py -v && python -m pytest -q -``` - -Expected: PASS, whole suite green. - -- [ ] **Step 7: Verify the real corpus still generates and validates** - -```bash -cd graycode-skills -python tools/update_registry.py -python -c "import json; d=json.load(open('registry.json')); print(type(d).__name__, d['version'], len(d['skills']), d['skills'][0]['repo'])" -python tools/update_registry.py --check && echo "CHECK CLEAN (deterministic)" -rm registry.json -``` - -Expected: `dict 1 12167 GrayCodeAI/graycode-skills`, then `CHECK CLEAN (deterministic)`. Delete the generated file; it stays gitignored. - -- [ ] **Step 8: Commit** - -```bash -git add tools/update_registry.py tools/registry_schema.py tests/test_update_registry.py -git commit -m "fix: emit registry.json in the object shape graycode-cli parses - -The generator emitted a bare JSON array while graycode-cli parses -{version, updated_at, skills[]}, so FetchIndex failed with 'invalid -index' regardless of URL. Entries now also carry the repo slug that -the installer needs to build a clone URL. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task A2: Publish the registry to a stable public URL - -**Files:** -- Modify: `graycode-skills/.github/workflows/publish-registry.yml:11-14` (add `permissions`), `:55-62` (add the release step after the artifact upload) - -**Interfaces:** -- Produces: `https://github.com/GrayCodeAI/graycode-skills/releases/latest/download/registry.json`. Task A3 hard-codes this URL. - -> **Why a rolling release and not `latest`.** `releases/latest/download/` resolves to the most recent **non-prerelease** release. The existing `v0.1.0` release is currently the latest, so the new rolling release must be created as a normal (non-draft, non-prerelease) release for that URL to resolve to it. Tagging it `registry-latest` and re-uploading with `--clobber` keeps exactly one moving target. - -- [ ] **Step 1: Grant the workflow permission to write releases** - -In `graycode-skills/.github/workflows/publish-registry.yml`, add a `permissions` block to the `build-and-publish` job, directly under `runs-on`: - -```yaml -jobs: - build-and-publish: - runs-on: ubuntu-latest - permissions: - contents: write - steps: -``` - -- [ ] **Step 2: Add the release-publishing step** - -Append to the end of the same file, after the existing `Upload registry artifacts` step: - -```yaml - - name: Publish registry to the rolling release - if: github.event_name != 'pull_request' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # One moving release holds the current registry. The Actions - # artifact above is retained separately for 90-day forensics. - if ! gh release view registry-latest >/dev/null 2>&1; then - gh release create registry-latest \ - --title "Skill registry (rolling)" \ - --notes "Generated registry.json for the current main. Updated automatically; do not delete." \ - --latest=false - fi - gh release upload registry-latest \ - registry.json registry-signature.json --clobber -``` - -`--latest=false` keeps the rolling release from displacing real version tags in the GitHub UI. The download URL used by the CLI in Task A3 addresses the tag directly, so it does not depend on which release is marked latest. - -- [ ] **Step 3: Validate the workflow file parses** - -```bash -cd graycode-skills && python -c "import yaml,sys; d=yaml.safe_load(open('.github/workflows/publish-registry.yml')); j=d['jobs']['build-and-publish']; print('permissions:', j['permissions']); print('steps:', [s['name'] for s in j['steps']])" -``` - -Expected: `permissions: {'contents': 'write'}` and a step list ending with `Publish registry to the rolling release`. - -- [ ] **Step 4: Commit** - -```bash -git add .github/workflows/publish-registry.yml -git commit -m "feat: publish registry.json to a rolling GitHub release - -The registry had no public URL: CI only uploaded it as a 90-day -Actions artifact and the file is gitignored, so every raw -githubusercontent URL 404d. A rolling registry-latest release gives -the CLI a stable download target without putting a 4.3 MB generated -file into git history. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - -- [ ] **Step 5: After merge, confirm the URL is live** - -```bash -curl -sIL https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json | grep -E '^HTTP' -``` - -Expected: a final `HTTP/2 200`. If the workflow has not run since merge, trigger it with `gh workflow run publish-registry.yml -R GrayCodeAI/graycode-skills` and re-check. **Task A3 cannot be verified end-to-end until this returns 200.** - ---- - -### Task A3: Point graycode-cli at the published registry and drop the phantom marketplace source - -**Files:** -- Modify: `graycode-cli/internal/plugin/registry.go:20` -- Modify: `graycode-cli/internal/plugin/marketplace.go:42-50` -- Test: `graycode-cli/internal/plugin/registry_test.go`, `graycode-cli/internal/plugin/marketplace_test.go` - -**Interfaces:** -- Consumes: the object shape from Task A1 and the URL from Task A2. -- Produces: `defaultIndexURL` pointing at the rolling release; `defaultMarketplaceSources()` returning an empty slice. - -> **Why the marketplace source is removed rather than repointed.** `plugins-registry.json` is generated by nothing in any of the four repos; the only reference anywhere is `marketplace.go:49`. `FetchAll` (`marketplace.go:111-132`) returns `(nil, nil)` when there are no sources and no errors, so removing the dead source turns a confusing fetch failure into a clean empty list. Users add real sources with `graycode plugin marketplace add`. - -- [ ] **Step 1: Write the failing tests** - -Append to `graycode-cli/internal/plugin/registry_test.go`: - -```go -func TestDefaultIndexURLIsPublished(t *testing.T) { - const want = "https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json" - if defaultIndexURL != want { - t.Fatalf("defaultIndexURL = %q, want %q", defaultIndexURL, want) - } - if strings.Contains(defaultIndexURL, "starling") { - t.Errorf("defaultIndexURL still references the renamed starling repo") - } -} - -func TestFetchIndexParsesGeneratedShape(t *testing.T) { - // Byte-for-byte the shape graycode-skills/tools/update_registry.py emits. - const generated = `{ - "version": 1, - "skills": [ - { - "name": "ab-test-setup", - "description": "Plan and design an A/B test", - "category": "testing", - "tags": ["testing"], - "path": "categories/testing/ab-test-setup", - "repo": "GrayCodeAI/graycode-skills", - "file_count": 1, - "has_scripts": false - } - ] -} -` - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(generated)) - })) - defer srv.Close() - - rc := &RegistryClient{IndexURL: srv.URL, CacheDir: t.TempDir(), client: srv.Client()} - idx, err := rc.FetchIndex() - if err != nil { - t.Fatalf("FetchIndex: %v", err) - } - if len(idx.Skills) != 1 { - t.Fatalf("skills = %d, want 1", len(idx.Skills)) - } - if idx.Skills[0].Repo != "GrayCodeAI/graycode-skills" { - t.Errorf("Repo = %q, want the slug the installer clones from", idx.Skills[0].Repo) - } -} -``` - -Append to `graycode-cli/internal/plugin/marketplace_test.go`: - -```go -func TestNoPhantomDefaultMarketplaceSource(t *testing.T) { - for _, src := range DefaultMarketplaceSources() { - if strings.Contains(src.URL, "plugins-registry.json") { - t.Fatalf("default source %q points at plugins-registry.json, which nothing generates", src.Name) - } - } -} - -func TestFetchAllWithNoSourcesReturnsEmptyNotError(t *testing.T) { - mc := &MarketplaceClient{Sources: nil, CacheDir: t.TempDir()} - entries, err := mc.FetchAll() - if err != nil { - t.Fatalf("FetchAll with no sources returned error: %v", err) - } - if len(entries) != 0 { - t.Fatalf("entries = %d, want 0", len(entries)) - } -} -``` - -Ensure `net/http`, `net/http/httptest` and `strings` are imported in each file. `MarketplaceClient` construction must match the real struct; read `marketplace.go` and adjust the literal if the field set differs. - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -cd graycode-cli && go test ./internal/plugin/ -run 'TestDefaultIndexURLIsPublished|TestFetchIndexParsesGeneratedShape|TestNoPhantomDefaultMarketplaceSource|TestFetchAllWithNoSourcesReturnsEmptyNotError' -v -``` - -Expected: FAIL. The URL test reports the `starling` constant. The shape test passes only if the Go struct already matches, which it does, so it should pass once the URL is fixed; if it fails, the emitter in A1 drifted. The marketplace test reports the `plugins-registry.json` default. - -- [ ] **Step 3: Repoint the registry index URL** - -In `graycode-cli/internal/plugin/registry.go`, replace line 20: - -```go -// defaultIndexURL is the rolling release asset published by -// graycode-skills/.github/workflows/publish-registry.yml. The registry is a -// generated 4.3 MB artifact and is deliberately not committed to that repo, -// so a raw.githubusercontent.com URL cannot work. -const defaultIndexURL = "https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json" -``` - -- [ ] **Step 4: Remove the phantom marketplace source** - -In `graycode-cli/internal/plugin/marketplace.go`, replace the exported `DefaultMarketplaceSources` function at lines 43-52 (keep the name and the exported signature; `NewMarketplaceClient` and the `plugin marketplace` commands already call it): - -```go -// DefaultMarketplaceSources returns the built-in plugin index sources. -// -// There are none. No repository in the GrayCode ecosystem generates a -// plugins-registry.json, so shipping a built-in source only produced a 404 -// on every `graycode plugin marketplace list`. Users register real sources -// with `graycode plugin marketplace add `. -func DefaultMarketplaceSources() []MarketplaceSource { - return nil -} -``` - -Leave `loadUserMarketplaceSources` and `SaveUserSources` untouched. - -- [ ] **Step 5: Run the tests to verify they pass** - -```bash -cd graycode-cli && go test ./internal/plugin/ -v -``` - -Expected: PASS, whole package green. Pre-existing tests that use `"GrayCodeAI/starling"` as row **data** (`registry_test.go:20,21,167,192,250,276,285`, `skillslock_test.go:20`) are fixtures, not URL pins; leave them for Task D4. - -- [ ] **Step 6: Verify against the live registry** - -Only runnable once Task A2's URL returns 200. - -```bash -cd graycode-cli && go build -o /tmp/graycode ./cmd/graycode && /tmp/graycode skills search testing | head -20 -``` - -Expected: a list of matching skills. If it prints a registry error, re-check Step 5 of Task A2. - -- [ ] **Step 7: Commit** - -```bash -git add internal/plugin/registry.go internal/plugin/marketplace.go internal/plugin/registry_test.go internal/plugin/marketplace_test.go -git commit -m "fix: point the skill index at the published registry release - -The index URL referenced GrayCodeAI/starling, a repo renamed to -graycode-skills whose registry.json is generated and never committed, -so the URL 404d under either name. It now reads the rolling release -asset. The built-in marketplace source pointed at a -plugins-registry.json that nothing generates and is removed. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task A4: Make install discover skills at any repository layout - -**Files:** -- Modify: `graycode-cli/internal/plugin/registry.go:258-270` (the discovery block inside `Install`) -- Test: `graycode-cli/internal/plugin/registry_test.go` - -**Interfaces:** -- Consumes: nothing from earlier tasks; independently testable. -- Produces: `discoverSkillDirs(root string) (map[string]string, error)` mapping skill name to the directory containing its `SKILL.md`. `Install` iterates this map instead of `os.ReadDir(skillsRoot)`. - -> **Root cause, not the reported symptom.** The reported failure is "installing from graycode-skills says skill not found". The cause is that discovery hard-codes two layouts (`//` and `/skills//`). Every repo with any other layout fails the same way. One bounded walk fixes all of them, and is a smaller diff than adding a third special case. - -- [ ] **Step 1: Write the failing test** - -Append to `graycode-cli/internal/plugin/registry_test.go`: - -```go -func TestDiscoverSkillDirs(t *testing.T) { - tests := []struct { - name string - layout []string // SKILL.md paths relative to the repo root - want []string // expected skill names - }{ - { - name: "flat layout", - layout: []string{"go-review/SKILL.md"}, - want: []string{"go-review"}, - }, - { - name: "agentskills.io skills/ layout", - layout: []string{"skills/go-review/SKILL.md"}, - want: []string{"go-review"}, - }, - { - name: "graycode-skills categories layout", - layout: []string{"categories/go/go-review/SKILL.md", "categories/python/pandas/SKILL.md"}, - want: []string{"go-review", "pandas"}, - }, - { - name: "ignores vendored and dot directories", - layout: []string{"go-review/SKILL.md", ".git/hooks/SKILL.md", "node_modules/pkg/SKILL.md"}, - want: []string{"go-review"}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - root := t.TempDir() - for _, rel := range tc.layout { - full := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, []byte("---\nname: x\n---\n"), 0o600); err != nil { - t.Fatal(err) - } - } - - got, err := discoverSkillDirs(root) - if err != nil { - t.Fatalf("discoverSkillDirs: %v", err) - } - if len(got) != len(tc.want) { - t.Fatalf("found %d skills %v, want %d %v", len(got), keysOf(got), len(tc.want), tc.want) - } - for _, name := range tc.want { - dir, ok := got[name] - if !ok { - t.Errorf("missing skill %q; got %v", name, keysOf(got)) - continue - } - if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { - t.Errorf("skill %q maps to %q which has no SKILL.md", name, dir) - } - } - }) - } -} - -func keysOf(m map[string]string) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} -``` - -Ensure `os`, `path/filepath` and `sort` are imported. - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -cd graycode-cli && go test ./internal/plugin/ -run TestDiscoverSkillDirs -v -``` - -Expected: FAIL to compile with `undefined: discoverSkillDirs`. - -- [ ] **Step 3: Implement bounded discovery** - -Add to `graycode-cli/internal/plugin/registry.go`: - -```go -// maxSkillSearchDepth bounds how deep discoverSkillDirs walks below the repo -// root. graycode-skills nests skills at categories///, which -// is depth 3; anything deeper is almost certainly test data or a vendored -// copy. -const maxSkillSearchDepth = 4 - -// discoverSkillDirs finds every directory under root containing a SKILL.md, -// keyed by the directory name. It replaces the previous two hard-coded -// layouts (// and /skills//) so repositories that -// group skills under a category directory are installable too. -// -// On a duplicate skill name the shallowest path wins; ties keep the first -// lexicographic match so the result is deterministic. -func discoverSkillDirs(root string) (map[string]string, error) { - found := map[string]string{} - depthOf := map[string]int{} - - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, relErr := filepath.Rel(root, path) - if relErr != nil { - return nil //nolint:nilerr // an unrelatable path is simply skipped - } - if d.IsDir() { - if path == root { - return nil - } - name := d.Name() - if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" { - return filepath.SkipDir - } - if len(strings.Split(filepath.ToSlash(rel), "/")) > maxSkillSearchDepth { - return filepath.SkipDir - } - return nil - } - if d.Name() != "SKILL.md" { - return nil - } - dir := filepath.Dir(path) - if dir == root { - return nil // a top-level SKILL.md documents the repo, not a skill - } - name := filepath.Base(dir) - depth := len(strings.Split(filepath.ToSlash(rel), "/")) - if prev, ok := found[name]; ok { - if depthOf[name] <= depth { - return nil - } - _ = prev - } - found[name] = dir - depthOf[name] = depth - return nil - }) - if err != nil { - return nil, fmt.Errorf("scan skills: %w", err) - } - return found, nil -} -``` - -Add `"io/fs"` to the imports. - -- [ ] **Step 4: Run the test to verify it passes** - -```bash -cd graycode-cli && go test ./internal/plugin/ -run TestDiscoverSkillDirs -v -``` - -Expected: PASS, all four subtests. - -- [ ] **Step 5: Wire it into `Install`** - -In `graycode-cli/internal/plugin/registry.go`, replace the discovery block at lines 258-270. Delete the `skillsRoot` computation and the `os.ReadDir(skillsRoot)` call, and drive the existing loop from the map instead: - -```go - // Discover skills in the cloned repo, whatever layout it uses. - discovered, err := discoverSkillDirs(tmpDir) - if err != nil { - return "", err - } - names := make([]string, 0, len(discovered)) - for name := range discovered { - names = append(names, name) - } - sort.Strings(names) -``` - -Then change the loop header from `for _, e := range entries {` to `for _, name := range names {`, delete the `if !e.IsDir() { continue }` guard and the `name := e.Name()` line, and change the `srcSkill` assignment to: - -```go - srcSkill := filepath.Join(discovered[name], "SKILL.md") -``` - -Leave the rest of the loop body, including the `skillName` filter, the trust checks, and the lockfile writes, exactly as they are. Add `"sort"` to the imports if it is not already present. - -> `// ponytail: whole-repo shallow clone. Installing one skill from graycode-skills clones ~127 MB of categories. Switch to git sparse-checkout of the skill's indexed path if install latency becomes a complaint.` - -Add that comment above the `git clone` call at line 247. - -- [ ] **Step 6: Run the full package and the boundary guards** - -```bash -cd graycode-cli && go test ./internal/plugin/ -v && gofumpt -l internal/plugin/ && make boundaries -``` - -Expected: package PASS, `gofumpt -l` prints nothing, all nine guards pass. - -- [ ] **Step 7: Verify a real install end-to-end** - -```bash -cd graycode-cli && go build -o /tmp/graycode ./cmd/graycode && /tmp/graycode skills install GrayCodeAI/graycode-skills ab-test-setup -``` - -Expected: reports the skill installed. Before this change it returned `skill "ab-test-setup" not found`. - -- [ ] **Step 8: Commit** - -```bash -git add internal/plugin/registry.go internal/plugin/registry_test.go -git commit -m "fix: discover skills at any repository layout on install - -Install scanned only //SKILL.md and -/skills//SKILL.md, so every repo grouping skills under a -category directory - graycode-skills included - reported 'skill not -found'. A bounded walk replaces both special cases. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -# Workstream B — Cloud Wire Contract - -**Problem.** Two field names drifted between graycode-cli and graycode-platform. Both schemas on the worker are `.strict()` zod objects, so both calls are rejected outright. - -| Call | CLI sends | Worker requires | Result | -|---|---|---|---| -| `POST /v1/auth/device/start` | `graycodeVersion` (`client.go:105`) | `hawkVersion` (`validation.ts:30`, `auth.ts:32`) | 400, `graycode cloud login` can never succeed | -| `POST /v1/usage` | `capability: "graycode"` (`exec.go:373`) | enum `[hawk, swift, shrike, harrier, merlin, kestrel]` (`contracts/v1.ts:8-15`) | 400, silently discarded because `RecordUsage` is fail-open (`client.go:176-179`) | - -**Decision (approved):** rename the wire to `graycode`. The CLI is already correct, so **graycode-cli needs no production change in this workstream** beyond one test fixture. All edits are in graycode-platform. - -**Deployment order is not optional.** Migration `0024` must be applied to D1 before the worker deploy, or every device read breaks on a missing column. - -> **Note on migration numbering.** `apps/worker/migrations/` already contains a collision: both `0022_graph_ledger.sql` and `0022_identity_ui.sql` exist. The next free prefix is `0024`, since `0023_usage_outbox.sql` is taken. Do not reuse `0022` or `0023`. - ---- - -### Task B1: Migrate the D1 schema and backfill legacy rows - -**Files:** -- Create: `graycode-platform/apps/worker/migrations/0024_graycode_capability_rename.sql` - -**Interfaces:** -- Produces: column `devices.graycode_version` (was `hawk_version`), column `cli_device_authorizations.graycode_version` (was `hawk_version`), and every `capability = 'hawk'` row rewritten to `'graycode'`. Tasks B2 and B3 read these names. - -- [ ] **Step 1: Write the migration** - -Create `graycode-platform/apps/worker/migrations/0024_graycode_capability_rename.sql`: - -```sql --- Rename the CLI wire fields from the legacy hawk product name to graycode. --- --- The CLI has always sent `graycodeVersion` and `capability: "graycode"` --- (graycode-cli internal/platform/cloud/client.go, cmd/exec.go). The worker's --- strict zod schemas required `hawkVersion` and rejected the `graycode` --- capability, so device login returned 400 and every usage event was dropped. --- The wire contract moves to the name the product actually has. --- --- SQLite supports RENAME COLUMN from 3.25; D1 is well past that. - -ALTER TABLE devices RENAME COLUMN hawk_version TO graycode_version; -ALTER TABLE cli_device_authorizations RENAME COLUMN hawk_version TO graycode_version; - --- Backfill rows written while the enum still said 'hawk'. The capability --- column is free TEXT, so historical rows would otherwise fail validation on --- any read path that re-parses them. -UPDATE usage_events SET capability = 'graycode' WHERE capability = 'hawk'; -UPDATE sessions SET capability = 'graycode' WHERE capability = 'hawk'; -``` - -- [ ] **Step 2: Verify the migration applies against a scratch database** - -```bash -cd graycode-platform/apps/worker -npx wrangler d1 migrations list graycode-cloud --local -npx wrangler d1 migrations apply graycode-cloud --local -npx wrangler d1 execute graycode-cloud --local --command "PRAGMA table_info(devices);" | grep -i version -``` - -Expected: the migration list shows `0024_graycode_capability_rename.sql` pending, apply succeeds, and the final command prints `graycode_version` with no `hawk_version` row. - -- [ ] **Step 3: Commit** - -```bash -git add apps/worker/migrations/0024_graycode_capability_rename.sql -git commit -m "feat: migrate device version and capability columns to graycode - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task B2: Rename the wire contract in the worker - -**Files:** -- Modify: `graycode-platform/apps/worker/contracts/v1.ts:8-16,23,55,124` -- Modify: `graycode-platform/apps/worker/src/domain/validation.ts:2,30,40` -- Modify: `graycode-platform/apps/worker/src/routes/auth.ts:32,48,56` -- Modify: `graycode-platform/apps/worker/src/routes/devices.ts:31,38,92` -- Modify: `graycode-platform/apps/worker/src/routes/sessions.ts:19` -- Modify: `graycode-platform/apps/worker/src/routes/organizations.ts:123` -- Modify: `graycode-platform/apps/worker/src/routes/enterprise.ts:596,748` -- Modify: `graycode-platform/apps/worker/src/auth/device-approve.ts:29,36,56,63` -- Test: `graycode-platform/apps/worker/test/{auth,devices,organizations,rate-limit,device-token,sessions,usage-sessions}.test.ts` - -**Interfaces:** -- Consumes: the column names from Task B1. -- Produces: `GRAYCODE_CAPABILITIES` (was `HAWK_CAPABILITIES`) with first element `'graycode'`; type `GraycodeCapability`; request field `graycodeVersion`. - -- [ ] **Step 1: Update the failing tests first** - -In `graycode-platform/apps/worker/test/auth.test.ts`, add a regression test that pins the CLI's actual request body: - -```ts -it('accepts the body graycode-cli actually sends', async () => { - const res = await app.request( - '/v1/auth/device/start', - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - // Verbatim from graycode-cli internal/platform/cloud/client.go:105 - body: JSON.stringify({ - label: 'my-laptop', - platform: 'darwin', - graycodeVersion: '0.0.1', - }), - }, - env, - ) - expect(res.status).toBe(201) -}) -``` - -Match the surrounding tests' setup style for `app` and `env`; read the top of the file first. - -In `graycode-platform/apps/worker/test/usage-sessions.test.ts`, add: - -```ts -it('accepts the capability graycode-cli actually sends', async () => { - const res = await app.request( - '/v1/usage', - { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: `Bearer ${deviceToken}` }, - // capability verbatim from graycode-cli cmd/exec.go:373 - body: JSON.stringify({ ...validUsageEvent, capability: 'graycode' }), - }, - env, - ) - expect(res.status).toBe(202) -}) -``` - -Reuse whatever fixture the neighbouring tests use in place of `validUsageEvent` and `deviceToken`. - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -cd graycode-platform/apps/worker && npx vitest run test/auth.test.ts test/usage-sessions.test.ts -``` - -Expected: FAIL. The device-start test gets 400 `Invalid device authorization request`. The usage test gets 400 `Invalid usage event`. - -- [ ] **Step 3: Rename the capability constant and type** - -In `graycode-platform/apps/worker/contracts/v1.ts`, replace lines 8-16: - -```ts -export const GRAYCODE_CAPABILITIES = [ - 'graycode', - 'swift', - 'shrike', - 'harrier', - 'merlin', - 'kestrel', -] as const -export type GraycodeCapability = (typeof GRAYCODE_CAPABILITIES)[number] -``` - -Update the two `capability: HawkCapability` fields at lines 23 and 55 to `GraycodeCapability`, and line 124 `hawkVersion: string` to `graycodeVersion: string`. Also reword the file's header comment (lines 1-7) from "Hawk Cloud" and "Hawk clients" to "Graycode Cloud" and "Graycode clients". - -- [ ] **Step 4: Update the validators** - -In `graycode-platform/apps/worker/src/domain/validation.ts`: line 2 imports `GRAYCODE_CAPABILITIES`, line 30 becomes `graycodeVersion: z.string().min(1).max(50),`, line 40 becomes `capability: z.enum(GRAYCODE_CAPABILITIES),`. - -In `graycode-platform/apps/worker/src/routes/auth.ts`: line 32 becomes `graycodeVersion: z.string().min(1).max(50),`; the INSERT at line 48 uses column `graycode_version`; the bound value at line 56 becomes `parsed.data.graycodeVersion`. - -In `graycode-platform/apps/worker/src/routes/sessions.ts`: line 19 becomes `capability: z.enum(GRAYCODE_CAPABILITIES),` with the matching import, replacing the inline literal array. - -- [ ] **Step 5: Update every SQL statement and its alias** - -Apply the same two mechanical substitutions in `src/routes/devices.ts:31,38,92`, `src/routes/organizations.ts:123`, `src/routes/enterprise.ts:596,748`, and `src/auth/device-approve.ts:29,36,56,63`: - -- column `hawk_version` becomes `graycode_version` -- SQL alias and TypeScript property `hawkVersion` becomes `graycodeVersion` - -- [ ] **Step 6: Update the remaining test fixtures** - -Replace `hawkVersion` with `graycodeVersion` at `test/auth.test.ts:116,125,317`, `test/organizations.test.ts:90`, `test/rate-limit.test.ts:153`, `test/devices.test.ts:77,106,118,135,153`. Replace `capability: 'hawk'` with `capability: 'graycode'` at `test/device-token.test.ts:166`, `test/sessions.test.ts:71,351`, `test/usage-sessions.test.ts:58,77,817`. - -```bash -cd graycode-platform/apps/worker -grep -rln 'hawkVersion' test/ | xargs sed -i '' 's/hawkVersion/graycodeVersion/g' -grep -rln "capability: 'hawk'" test/ | xargs sed -i '' "s/capability: 'hawk'/capability: 'graycode'/g" -grep -rn "hawkVersion\|capability: 'hawk'" test/ || echo "TEST FIXTURES CLEAN" -``` - -- [ ] **Step 7: Run the full worker suite** - -```bash -cd graycode-platform/apps/worker && npx tsc --noEmit && npx vitest run -``` - -Expected: typecheck clean, all tests PASS including the two added in Step 1. If `openapi-parity.test.ts` fails, that is expected until Task B4 updates the contract file; note it and continue. - -- [ ] **Step 8: Commit** - -```bash -git add apps/worker/contracts/v1.ts apps/worker/src apps/worker/test -git commit -m "feat!: rename the cloud wire contract from hawk to graycode - -Device login required hawkVersion while the CLI has always sent -graycodeVersion, and the capability enum rejected 'graycode', so -login returned 400 and every usage event was silently dropped by the -fail-open client. The wire now matches the product name. - -Requires migration 0024 to be applied before deploy. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task B3: Rename the capability enum in the BFF - -**Files:** -- Modify: `graycode-platform/apps/bff/src/routes/web-activity.ts:22,94` -- Modify: `graycode-platform/apps/bff/src/routes/web-dashboard.ts:15` - -**Interfaces:** -- Consumes: the capability values from Task B2. The BFF duplicates the enum as an inline literal rather than importing it. - -- [ ] **Step 1: Update both inline enums** - -In `graycode-platform/apps/bff/src/routes/web-activity.ts:94` and `web-dashboard.ts:15`, replace: - -```ts - tool: z.enum(['hawk', 'swift', 'shrike', 'harrier', 'merlin', 'kestrel']), -``` - -with: - -```ts - tool: z.enum(['graycode', 'swift', 'shrike', 'harrier', 'merlin', 'kestrel']), -``` - -Keep `.optional()` on the `web-activity.ts:94` occurrence. - -- [ ] **Step 2: Fix the user-facing achievement string** - -`web-activity.ts:22` reads `description: 'Completed your first hawk session'`. Change it to `'Completed your first graycode session'`. - -- [ ] **Step 3: Typecheck and test** - -```bash -cd graycode-platform/apps/bff && npx tsc --noEmit && npx vitest run -``` - -Expected: clean typecheck, tests PASS. - -- [ ] **Step 4: Commit** - -```bash -git add apps/bff/src/routes/web-activity.ts apps/bff/src/routes/web-dashboard.ts -git commit -m "feat: align the BFF capability enum with the graycode wire contract - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task B4: Update the OpenAPI contract and delete the stale third copy - -**Files:** -- Modify: `graycode-platform/api/graycode-cloud-openapi.yaml:3,1196,1230,1234,1322` -- Modify: `graycode-platform/apps/worker/contracts/openapi.yaml:1230,1234` (and the same title/enum lines) -- Delete: `graycode-platform/apps/worker/api/openapi.yaml` - -**Interfaces:** -- Consumes: the field names from Task B2. `test/openapi-parity.test.ts` reads `contracts/openapi.yaml` and must pass after this task. - -> **Three copies exist.** `api/graycode-cloud-openapi.yaml` and `apps/worker/contracts/openapi.yaml` are byte-identical. `apps/worker/api/openapi.yaml` is a stale 1371-line variant whose `servers.url` is the personal dev subdomain `https://graycode-cloud.lakshmanp230.workers.dev` (line 7). Nothing references it: no package script, no CI workflow, no turbo task. Deleting it removes both the drift and the personal subdomain in one step. - -- [ ] **Step 1: Confirm the stale copy is unreferenced before deleting** - -```bash -cd graycode-platform && grep -rn 'apps/worker/api/openapi\|api/openapi.yaml' --exclude-dir=node_modules --exclude-dir=.git . | grep -v '^./apps/worker/api/openapi.yaml' -``` - -Expected: no output. If anything references it, stop and repoint that reference at `apps/worker/contracts/openapi.yaml` instead of deleting. - -- [ ] **Step 2: Apply the renames to both live copies** - -```bash -cd graycode-platform -for f in api/graycode-cloud-openapi.yaml apps/worker/contracts/openapi.yaml; do - sed -i '' 's/hawkVersion/graycodeVersion/g' "$f" - sed -i '' 's/enum: \[hawk, swift/enum: [graycode, swift/' "$f" - sed -i '' 's/^ title: Hawk Cloud API/ title: Graycode Cloud API/' "$f" - sed -i '' 's/Control-plane API for Hawk and its related products/Control-plane API for Graycode and its related products/' "$f" - sed -i '' 's/Project-scoped hwc device token\./Project-scoped device token (`hwc_` prefix)./' "$f" -done -grep -n 'graycodeVersion\|enum: \[graycode\|title: Graycode Cloud' api/graycode-cloud-openapi.yaml -``` - -Expected: the rewritten lines print. The `hwc_` prefix itself stays; only its description changes. - -- [ ] **Step 3: Confirm the two live copies are still identical** - -```bash -cd graycode-platform && diff api/graycode-cloud-openapi.yaml apps/worker/contracts/openapi.yaml && echo "COPIES IN SYNC" -``` - -Expected: `COPIES IN SYNC`. - -- [ ] **Step 4: Delete the stale copy** - -```bash -cd graycode-platform && git rm apps/worker/api/openapi.yaml -``` - -- [ ] **Step 5: Run the parity test** - -```bash -cd graycode-platform/apps/worker && npx vitest run test/openapi-parity.test.ts -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add api/graycode-cloud-openapi.yaml apps/worker/contracts/openapi.yaml -git commit -m "docs: rename the cloud contract to graycode and drop the stale copy - -apps/worker/api/openapi.yaml was an unreferenced 1371-line variant -still advertising a personal workers.dev subdomain as its server URL. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task B5: Align the CLI's cloud test fixture and document the endpoint - -**Files:** -- Modify: `graycode-cli/internal/platform/cloud/client_test.go:22` -- Modify: `graycode-cli/README.md` (the Portable Execution Graph section, around line 127) - -**Interfaces:** -- Consumes: the capability values from Task B2. No production CLI code changes; `client.go:105` and `exec.go:373` were already correct. - -> **A real gap this plan does not close.** `graycode cloud login` has no default endpoint: it requires `--endpoint` or `GRAYCODE_CLOUD_URL` (`cmd/cloud.go:41-46`). The worker's `wrangler.jsonc` declares no route or custom domain, so it is reachable only at its `workers.dev` address, and the contract's documented `servers.url` of `https://api.graycodeai.com` is the **BFF**, which requires a browser session cookie and returns 401 to a device token (`apps/bff/src/app.ts:54-58`). Choosing and provisioning a public hostname for the worker is an infrastructure decision outside this plan. Step 2 documents the requirement so users are not left guessing. - -- [ ] **Step 1: Fix the test fixture** - -In `graycode-cli/internal/platform/cloud/client_test.go:22`, change `Capability: "graycode"` — it is already correct and matches the renamed enum. Verify rather than edit: - -```bash -cd graycode-cli && grep -n 'Capability:' internal/platform/cloud/client_test.go -``` - -Expected: `Capability: "graycode"`. No edit needed. If it reads `"hawk"`, change it to `"graycode"`. - -- [ ] **Step 2: Document that the cloud endpoint must be supplied** - -In `graycode-cli/README.md`, directly under the `graycode cloud graph sync` code block, add: - -```markdown -Cloud commands require an endpoint. There is no default: pass `--endpoint` or -set `GRAYCODE_CLOUD_URL` to your Graycode Cloud worker URL before running -`graycode cloud login`. `https://api.graycodeai.com` is the browser BFF and -will reject a device token. -``` - -- [ ] **Step 3: Verify and commit** - -```bash -cd graycode-cli && go test ./internal/platform/cloud/ -v -git add internal/platform/cloud/client_test.go README.md -git commit -m "docs: state that graycode cloud requires an explicit endpoint - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - -- [ ] **Step 4: Deploy in the correct order** - -After the graycode-platform PRs merge: - -```bash -cd graycode-platform/apps/worker -npx wrangler d1 migrations apply graycode-cloud --remote # 0024 FIRST -cd ../.. && pnpm deploy:worker -pnpm deploy:bff -``` - -Expected: migration reports one statement batch applied, then both workers deploy. Deploying the worker before the migration breaks every device read. - ---- - -# Workstream C — Router Boundary Truth - -**Problem.** This workstream fixes documentation and two defective guards. **There is no import violation to repair: all nine boundary guards pass today.** - -What is actually wrong: - -1. **The documented boundary contradicts the enforced one.** `graycode-router/AGENTS.md:570-572` says graycode-cli must not assemble anything below `engine`. In reality all three enforcement layers deliberately allow four packages — `engine`, `llm`, `graph`, `tools` — and eight non-test files depend on that allowance (`internal/config/catalog_api.go:9`, `internal/engine/client_interface.go:7`, `internal/engine/compact_provider_native.go:8`, `internal/engine/execution_graph_observations.go:16`, `internal/provider/gateway/engine_client.go:16`, `internal/provider/gateway/gateway.go:15`, `internal/session/session.go:23`, `internal/types/client.go:6`). Six symbols they use have no `engine` equivalent at all: `ChatOptions`, `ContinuationConfig`, `StreamResult`/`NewStreamResult`, `ResponseFormat`, `ImageURLPart`, `InputAudioPart`, plus the `ModelClass*` constants and `GatewayRegionOption`. -2. **A guard that can never fail.** `graycode-cli/scripts/check-support-repo-coupling.sh:30-32` builds its regex from the peer list. With `graycode-router` the only engine in `ecosystem.yaml`, the peer list is empty and the pattern degenerates to `github\.com/GrayCodeAI/()(/|")`. Verified with `bash -x`. -3. **A guard missing from pre-push.** `lefthook.yml:115-125` runs three boundary scripts but omits `check-graycode-router-engine-boundary.sh` (verified: zero occurrences in the file). It runs in CI, so violations are caught late instead of before push. -4. **A dead exception.** Both Go AST tests carry a `internal/provider/gateway` → `credentials` exception (`internal/testaudit/audit_test.go:223-226`, `internal/testaudit/package_boundaries_test.go:61-65`). Zero non-test files import `graycode-router/credentials`. - -**Approach.** Document the four-package contract as the real boundary, make the vacuous guard honest, and add the missing hook. Do not attempt to collapse the CLI onto `engine`-only; six required symbols are unexported from the facade and widening `engine` is a graycode-router API change that belongs in its own plan. - ---- - -### Task C1: Make the documented boundary match the enforced one - -**Files:** -- Modify: `graycode-router/AGENTS.md:551,570-572` -- Modify: `graycode-router/README.md:48-53` (the Ecosystem Boundaries section) -- Modify: `graycode-cli/scripts/check-graycode-router-engine-boundary.sh:18-19` (comment only) - -**Interfaces:** -- Produces: one written contract naming exactly four allowed packages, cited by both repos. - -- [ ] **Step 1: State the real contract in graycode-router** - -In `graycode-router/README.md`, replace the Ecosystem Boundaries bullets at lines 48-53: - -```markdown -graycode-router is a Graycode support engine. Keep the dependency edge one-way. - -Hosts may import exactly four packages: - -| Package | Carries | -|---|---| -| `engine` | the stable host-facing facade | -| `llm` | host-facing DTOs and the `Provider` port that `engine` re-exports as aliases | -| `graph` | the portable execution-graph vocabulary | -| `tools` | tool-call and tool-result contracts | - -Everything else is engine-internal: `client`, `catalog`, `config`, -`credentials`, `router`, `runtime`, and their subpackages are not shared -contracts. Enforced by `graycode-cli/scripts/check-graycode-router-engine-boundary.sh` -and two Go AST tests in `graycode-cli/internal/testaudit/`. - -- do not import `graycode-cli/internal/*` -- do not import the removed legacy path `graycode/shared/types` -- do not import other engines (`harrier`, `shrike`, `swift`, `kestrel`, - `merlin`) — engines are peers, not dependencies -``` - -- [ ] **Step 2: Correct the AGENTS.md pitfall** - -In `graycode-router/AGENTS.md`, replace the first Common Pitfalls bullet (lines 570-572): - -```markdown -- `engine`, `llm`, `graph` and `tools` are the host contract surface. Graycode - must not assemble `client`, `catalog`, `config`, `credentials`, `router` or - `runtime`. Six symbols Graycode needs (`ChatOptions`, `ContinuationConfig`, - `StreamResult`, `ResponseFormat`, `ImageURLPart`, `InputAudioPart`) live in - `llm` with no `engine` alias; widening the facade to cover them is a - deliberate API change, not an incidental one. -``` - -- [ ] **Step 3: Correct the misleading guard comment** - -`graycode-cli/scripts/check-graycode-router-engine-boundary.sh:18-19` currently claims Graycode uses "the full vendored GraycodeRouter API surface", which overstates a four-package allowance. Replace both comment lines: - -```bash -# Host contract surface is exactly four packages: engine (facade), llm (DTOs -# and the Provider port), graph (portable graph vocabulary), tools (tool-call -# contracts). See graycode-router/README.md "Ecosystem Boundaries". -``` - -- [ ] **Step 4: Verify the guards still pass** - -```bash -cd graycode-cli && bash ./scripts/check-graycode-router-engine-boundary.sh && echo "EXIT=$?" -``` - -Expected: `graycode-router engine boundary passed (zero lower-level production imports)` then `EXIT=0`. - -- [ ] **Step 5: Commit (two repos, two commits)** - -```bash -cd graycode-router -git add README.md AGENTS.md -git commit -m "docs: document the four-package host contract surface - -AGENTS.md claimed engine-only while all three enforcement layers -deliberately allow engine, llm, graph and tools, and eight production -files depend on that allowance. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" - -cd ../graycode-cli -git add scripts/check-graycode-router-engine-boundary.sh -git commit -m "docs: correct the router boundary guard comment - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task C2: Repair the two defective guards - -**Files:** -- Modify: `graycode-cli/scripts/check-support-repo-coupling.sh:29-33` -- Modify: `graycode-cli/lefthook.yml:118-125` -- Modify: `graycode-cli/internal/testaudit/audit_test.go:223-226` -- Modify: `graycode-cli/internal/testaudit/package_boundaries_test.go:61-65` - -**Interfaces:** -- Produces: a peer guard that reports honestly when it has nothing to check, an engine-boundary check on pre-push, and both AST tests with the dead exception removed. - -- [ ] **Step 1: Make the vacuous guard honest** - -In `graycode-cli/scripts/check-support-repo-coupling.sh`, guard the empty-peer case before building the pattern. Insert immediately after the `pattern=` assignment at line 30: - -```bash - if [[ ${#peers[@]} -eq 0 ]]; then - # No sibling engines to check against. Building a regex from an empty - # peer list produced 'github\.com/GrayCodeAI/()(/|")', which matches - # nothing meaningful and made this guard silently unfailable. - echo "peer guard: ${repo} has no sibling engines to check" - continue - fi -``` - -Confirm `peers` is the array name and `continue` is inside the per-repo loop; read lines 20-40 first and adapt the variable names to what is actually there. - -- [ ] **Step 2: Verify the guard now reports rather than pretending** - -```bash -cd graycode-cli && bash ./scripts/check-support-repo-coupling.sh; echo "EXIT=$?" -``` - -Expected: `peer guard: graycode-router has no sibling engines to check` followed by the existing pass line, `EXIT=0`. - -- [ ] **Step 3: Add the missing pre-push hook** - -In `graycode-cli/lefthook.yml`, add a fourth boundary command alongside the existing three (after the `boundary-graycode-router-client` block ending at line 119): - -```yaml - boundary-graycode-router-engine: - run: bash scripts/check-graycode-router-engine-boundary.sh -``` - -- [ ] **Step 4: Verify lefthook parses and the hook is registered** - -```bash -cd graycode-cli && python3 -c "import yaml; d=yaml.safe_load(open('lefthook.yml')); print(sorted(d['pre-push']['commands']))" -``` - -Expected: the list includes `boundary-graycode-router-engine`. - -- [ ] **Step 5: Remove the dead credentials exception** - -Delete the exception block at `graycode-cli/internal/testaudit/audit_test.go:223-226`: - -```go - if strings.HasPrefix(rel, "internal/provider/gateway/") && path == graycodeRouterModule+"/credentials" { - continue - } -``` - -And at `graycode-cli/internal/testaudit/package_boundaries_test.go:61-65`, the equivalent block keyed on `filepath.ToSlash(filepath.Dir(relFile)) == "internal/provider/gateway"`. - -Verify nothing depended on them first: - -```bash -cd graycode-cli && grep -RInE --include='*.go' --exclude='*_test.go' 'graycode-router/credentials' . ; echo "exit=$? (1 means clean)" -``` - -Expected: no output, `exit=1`. - -- [ ] **Step 6: Run both AST tests and the whole guard set** - -```bash -cd graycode-cli && go test ./internal/testaudit/ -run 'TestNoDirectLowerGraycodeRouterImports|TestPackageDependencyGraph' -count=1 -v && make boundaries -``` - -Expected: both tests PASS, all guards pass. - -- [ ] **Step 7: Commit** - -```bash -git add scripts/check-support-repo-coupling.sh lefthook.yml internal/testaudit/audit_test.go internal/testaudit/package_boundaries_test.go -git commit -m "fix: repair two boundary guards that could not fail - -check-support-repo-coupling.sh built its regex from an empty peer list, -degenerating to a pattern matching nothing. The engine-boundary script -ran in CI but not on pre-push. Both AST tests carried a -gateway->credentials exception that no production file uses. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -# Workstream D — Naming Sweep - -**Scope (approved):** documentation, user-visible strings, and code comments. Roughly 230 sites. `graycode-cli` is already clean: it contains **zero** occurrences of `hawk` outside generated GitNexus blocks. Marketing copy under `graycode-platform/apps/web` is out of scope; it is dated content and product-line branding. - -Every load-bearing identifier listed in Global Constraints stays untouched. When in doubt, a name that appears in a wire payload, a database column, an environment variable, a filesystem path, a Cloudflare binding, a Go module path, or a URL route is load-bearing. - -**Two bonus defects folded in here:** -- A prior substring mass-rename corrupted prose in 15 places: `trace`→`swift` produced "Step-by-step execution swift", `inspect`→`merlin` produced "Merlin configured MCP servers", `tok`→`shrike` produced "ref-shrike". -- `graycode-skills` advertises "12,171+ skills" and "31 categories". The real counts are **12,167 skills** and **27 categories** (verified by `find … -name SKILL.md | wc -l` and `ls categories | wc -l`). - ---- - -### Task D1: graycode-skills — rebrand from starling and correct the counts - -**Files:** -- Modify: `README.md:1,3,7,16,19,22,25,99-102`, `CONTRIBUTING.md:1,3,82,175`, `AGENTS.md:2,7,9,44-48`, `SECURITY.md:1`, `CHANGELOG.md:3`, `api/openapi.yaml:3,5,9,13,15,17,38-39`, `docs/architecture.md:3,5,16,18,25,45`, `pyproject.toml:8`, `tools/init_skill.py:59`, `tools/sign_manifest.py:141`, `scripts/check-consumer-boundaries.sh:10` - -**Interfaces:** -- Produces: no code behavior change. `pyproject.toml` package `name = "starling"` at line 6 is **not** changed here; renaming a published package name is a release decision. - -- [ ] **Step 1: Correct the counts and the CLI name in the README** - -In `graycode-skills/README.md`: -- Line 1: `# hawk Community Skills` → `# Graycode Community Skills` -- Line 3: `[hawk](https://github.com/GrayCodeAI/hawk)` → `[Graycode](https://github.com/GrayCodeAI/graycode-cli)`, and `12,171+` → `12,167`, and `31 categories` → `27 categories` -- Line 7: `that hawk loads` → `that Graycode loads` -- Lines 16, 19, 25: `hawk skills list` → `graycode skills list`, `hawk skills search api-testing` → `graycode skills search api-testing`, `the hawk REPL` → `the graycode REPL` -- Line 22: `hawk skills install python-pandas` → `graycode skills install GrayCodeAI/graycode-skills python-pandas` - -> The install syntax genuinely differs: `graycode skills install` takes ` [skill-name]` (`graycode-cli/cmd/skills_cmd.go:74`), not a bare skill name. Documenting the bare form would keep the command broken even after Workstream A. - -- [ ] **Step 2: Fix the Ecosystem Boundaries block** - -`README.md:99-102` and `AGENTS.md:44-46` list **pre-rename engine names** that no longer exist: `yaad`, `tok`, `trace`, `sight`, `inspect`. Replace with the live names: - -```markdown -- `graycode-skills` extends Graycode through public skill and plugin surfaces. -- Do not reference support engine repos (`graycode-router`, `harrier`, `shrike`, - `swift`, `kestrel`, `merlin`) as direct dependencies. -- Do not reference `graycode-cli/internal/*` or the removed legacy path - `graycode/shared/types`. -- Skills should assume Graycode is the product boundary. -``` - -- [ ] **Step 3: Update the boundary guard's alternation to match** - -`graycode-skills/scripts/check-consumer-boundaries.sh` still forbids the pre-rename engine names. The pattern is inline inside the `grep -RInE` call at lines 7-12, not a variable. Replace the pattern argument on line 10: - -```bash - 'github\.com/GrayCodeAI/(graycode-router|harrier|shrike|swift|kestrel|merlin)(/|")|github\.com/GrayCodeAI/graycode-cli/(internal/|shared/types)' \ -``` - -Also update the two failure messages at lines 16 and 19 so they name Graycode rather than Hawk and graycode-skills rather than starling. - -Verify it still runs clean: - -```bash -cd graycode-skills && bash scripts/check-consumer-boundaries.sh; echo "EXIT=$?" -``` - -Expected: `EXIT=0`. This guard scans `README.md docs api tests tools .claude-plugin .codex-plugin .cursor-plugin`, so it will fail if Step 2's replacement text accidentally reintroduces a forbidden module path. - -- [ ] **Step 4: Sweep the remaining prose files** - -```bash -cd graycode-skills -for f in CONTRIBUTING.md AGENTS.md SECURITY.md CHANGELOG.md api/openapi.yaml docs/architecture.md pyproject.toml tools/init_skill.py tools/sign_manifest.py; do - sed -i '' -e 's/\bstarling\b/graycode-skills/g' -e 's/\bStarling\b/Graycode Skills/g' \ - -e 's/\bhawk-eco\b/graycode-eco/g' -e 's/\bHawk\b/Graycode/g' -e 's/\bhawk\b/graycode/g' "$f" -done -sed -i '' 's/12,171+/12,167/g; s/31 categories/27 categories/g' CONTRIBUTING.md -sed -i '' 's/^name = "graycode-skills"/name = "starling"/' pyproject.toml # restore the package name -grep -rn 'GrayCodeAI/graycode-skills' pyproject.toml || true -``` - -- [ ] **Step 5: Repair what the blunt sweep broke** - -The `sed` above also rewrites the GitNexus block in `AGENTS.md` and any load-bearing identifier. Restore them: - -```bash -cd graycode-skills -git diff --stat -git diff | grep -nE '^\+.*(AGENT_ENUM|progressive-disclosure|/graycode:|sync_marketplace)' || echo "no load-bearing identifiers touched" -``` - -Inspect `git diff` in full. Revert any hunk that changes a value inside a `` block, the `/hawk:` invoke prefix, the `AGENT_ENUM` values, or the `hawk-progressive-disclosure` marker. Those files (`tools/sync_marketplace.py`, `tools/validate_skill.py`, `tools/migrate_oversized_skills.py`) are not in the Step 4 loop, but verify nothing else drifted. - -- [ ] **Step 6: Verify the corpus still validates** - -```bash -cd graycode-skills && python -m pytest -q && python tools/validate_skill.py --all --warning-budget tools/validation_warning_budget.json && ruff check . -``` - -Expected: tests PASS, zero validation warnings, ruff clean. - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "docs: rebrand from starling/hawk to graycode-skills/graycode - -Also corrects the advertised counts (12,167 skills across 27 -categories, not 12,171+ across 31), documents the real -'graycode skills install ' syntax, and replaces the -pre-rename engine names in the boundary docs and guard. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task D2: graycode-router — docs, user-facing strings, and comments - -**Files:** -- Modify: `README.md:33,39,42,48,50,52,53,177,305,309`, `AGENTS.md:18,52,58,144`, `CONTRIBUTING.md:4`, `SECURITY.md:10`, `docs/ARCHITECTURE.md:17`, `docs/architecture/HOST-ENGINE-BOUNDARY.md` (21 lines), `docs/guides/DYNAMIC-MODEL-DISCOVERY.md` (24 lines), `docs/guides/CREDENTIAL-SETUP-FLOW.md:1,3,38,53,72`, `docs/design/GRAYCODE-ROUTER-ENTERPRISE.md:61,78` -- Modify (user-facing strings): `catalog/v1.go:633`, `setup/status.go:135`, `runtime/preflight.go:48` -- Modify (comments): `llm/types.go`, `llm/provider.go` and ~140 further comment lines -- Modify: `scripts/test-config-flow.sh:44` -- Modify: `scripts/check-ecosystem-boundaries.sh:10` - -**Interfaces:** -- Produces: no behavior change. Three user-visible error strings change wording only. - -- [ ] **Step 1: Fix the three user-facing strings first** - -These are the only sweep items a user can actually see in the terminal. - -- `graycode-router/catalog/v1.go:633`: `run: hawk models refresh` → `run: graycode models refresh` -- `graycode-router/setup/status.go:135`: `hawk refreshes automatically; use \`hawk models refresh\`` → `graycode refreshes automatically; use \`graycode models refresh\`` -- `graycode-router/runtime/preflight.go:48`: `hawk will discover on /config` → `graycode will discover on /config` - -- [ ] **Step 2: Write a test that pins them** - -Create `graycode-router/setup/naming_test.go`: - -```go -package setup - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// TestNoLegacyHostNameInUserFacingStrings guards the three call sites that -// print a command for the user to run. They named the old product. -func TestNoLegacyHostNameInUserFacingStrings(t *testing.T) { - files := []string{ - filepath.Join("..", "catalog", "v1.go"), - filepath.Join("..", "setup", "status.go"), - filepath.Join("..", "runtime", "preflight.go"), - } - for _, f := range files { - data, err := os.ReadFile(f) // #nosec G304 -- fixed test fixture paths - if err != nil { - t.Fatalf("read %s: %v", f, err) - } - for i, line := range strings.Split(string(data), "\n") { - if !strings.Contains(line, `"`) { - continue - } - if strings.Contains(line, "hawk models refresh") || strings.Contains(line, "hawk will discover") || strings.Contains(line, "hawk refreshes") { - t.Errorf("%s:%d prints the legacy host name to the user: %s", f, i+1, strings.TrimSpace(line)) - } - } - } -} -``` - -- [ ] **Step 3: Run it** - -```bash -cd graycode-router && go test ./setup/ -run TestNoLegacyHostNameInUserFacingStrings -v -``` - -Expected: PASS after Step 1. Revert one string temporarily to confirm the test can fail, then restore it. - -- [ ] **Step 4: Sweep the documentation** - -```bash -cd graycode-router -for f in README.md AGENTS.md CONTRIBUTING.md SECURITY.md docs/ARCHITECTURE.md \ - docs/architecture/HOST-ENGINE-BOUNDARY.md docs/guides/DYNAMIC-MODEL-DISCOVERY.md \ - docs/guides/CREDENTIAL-SETUP-FLOW.md docs/design/GRAYCODE-ROUTER-ENTERPRISE.md; do - sed -i '' -e 's|GrayCodeAI/hawk|GrayCodeAI/graycode-cli|g' -e 's/\bhawk-eco\b/graycode-eco/g' \ - -e 's/\bHawk\b/Graycode/g' -e 's/\bhawk\b/graycode/g' "$f" -done -grep -rn '\bhawk\b\|\bHawk\b' README.md AGENTS.md docs/ || echo "DOCS CLEAN" -``` - -- [ ] **Step 5: Fix the three doc facts the sweep cannot fix** - -- `README.md:50` says DTOs live in `eagle/llm`. The `eagle` module was removed and vendored. Change to: ``host-facing DTOs and the `Provider` port live in `llm/`; `engine/` re-exports them as aliases``. -- `SECURITY.md:10` and `CONTRIBUTING.md:4` link `VERSIONING.md` at the repo root of a repo that no longer exists. Point both at `https://github.com/GrayCodeAI/graycode-cli/blob/main/docs/versioning.md`. -- `docs/guides/CREDENTIAL-SETUP-FLOW.md:53` cites `hawk/cmd/chat_config_xiaomi.go`. Verify the current filename before writing a replacement: - -```bash -ls ../graycode-cli/cmd/ | grep -i 'chat_config' -``` - -Use whichever file actually exists; if none matches, delete the file reference rather than inventing one. - -- [ ] **Step 6: Remove the hard-coded personal path** - -`graycode-router/scripts/test-config-flow.sh:44` contains an absolute path into a previous working directory. Replace: - -```bash -cd "$(dirname "$0")/.." -``` - -- [ ] **Step 7: Extend the ecosystem guard to the current repo name** - -`graycode-router/scripts/check-ecosystem-boundaries.sh:10` forbids only `github.com/GrayCodeAI/hawk`, a module that no longer exists, while permitting the real host module. Widen the pattern, keeping the variable name `FORBIDDEN_HAWK` because it is read at lines 16 and 20: - -```bash -FORBIDDEN_HAWK='github\.com/GrayCodeAI/(hawk|graycode-cli)(/|")' -``` - -The comment above it at lines 7-9 also claims shared vocabulary "belongs in eagle", a module that was removed and vendored into `graycode-cli/internal/contracts`. Rewrite it: - -```bash -# GraycodeRouter is host-neutral: it must not depend on any Graycode package. -# Shared ecosystem vocabulary lives in graycode-cli/internal/contracts, which -# hosts vendor rather than import from here. -``` - -Verify: - -```bash -cd graycode-router && bash scripts/check-ecosystem-boundaries.sh; echo "EXIT=$?" -``` - -Expected: `EXIT=0`. - -- [ ] **Step 8: Sweep the Go comments** - -```bash -cd graycode-router -grep -rln '\bhawk\b\|\bHawk\b' --include='*.go' . | while read -r f; do - sed -i '' -e 's|// \(.*\)\bHawk\b|// \1Graycode|g' -e 's|// \(.*\)\bhawk\b|// \1graycode|g' "$f" -done -git diff --stat -``` - -Then inspect the diff and revert every hunk touching a **string literal** rather than a comment, and every load-bearing identifier: `HAWK_CONFIG_DIR` (`config/provider_env.go:402`, `config/category.go:122`, `engine/engine.go`), the `~/.hawk` paths (`credentials/migrate.go:14,88`), and `hawk_build` / `hawk_build_concise` (`tools/versioning.go:114,115`). - -```bash -git diff | grep -nE '^\+.*(HAWK_CONFIG_DIR|\.hawk|hawk_build)' && echo "REVERT THESE HUNKS" || echo "no load-bearing identifiers touched" -``` - -- [ ] **Step 9: Build, test, format** - -```bash -cd graycode-router && gofumpt -l . && go build ./... && go test ./... && go vet ./... -``` - -Expected: `gofumpt -l` prints nothing, build and vet clean, all tests PASS. - -- [ ] **Step 10: Commit** - -```bash -git add -A -git commit -m "docs: rename the host from hawk to graycode across docs and comments - -Also fixes three user-facing strings that told users to run -'hawk models refresh', corrects the removed eagle/llm reference, -repoints dead VERSIONING.md links, removes a hard-coded personal path -from test-config-flow.sh, and extends the ecosystem guard to the -current host module name. - -Keeps HAWK_CONFIG_DIR, ~/.hawk migration paths and hawk_build tool -namespaces: those are compatibility values, not prose. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task D3: graycode-platform — docs and the rename-corruption typos - -**Files:** -- Modify: `README.md:16,20,29,40,56,94,103`, `ARCHITECTURE.md:5,13,254,314`, `AGENTS.md:34,38,70`, `CLAUDE.md:31`, `CONTRIBUTING.md:4`, `SECURITY.md:10`, `CHANGELOG.md:3`, `docs/architecture.md:6`, `apps/worker/README.md:1,70,82`, `apps/bff/README.md:7`, `apps/worker/docs/{ENCRYPTION-KEY-ROTATION,DELIVERY-CONTEXT-FLOW,ARCHITECTURE-IMPLEMENTATION}.md`, `apps/worker/migrations/0003_cli_device_authorization.sql:1` (comment only), `apps/worker/src/app.ts:57`, `scripts/deploy.sh:24,26` - -**Interfaces:** -- Consumes: nothing. Runs independently of Workstream B, though B should land first so the docs describe the shipped contract. - -- [ ] **Step 1: Fix the three rename-corruption typos** - -A prior `trace`→`swift` substring rename corrupted generated GitNexus tables. In `AGENTS.md:70` and `CLAUDE.md:31`: - -```markdown -| `gitnexus://repo/graycode-platform/process/{name}` | Step-by-step execution trace | -``` - -Check the same corruption in the other three repos: - -```bash -cd /Users/lakshmanpatel/Desktop/OSS2026/graycode-eco -grep -rn 'execution swift' --include='*.md' --exclude-dir=node_modules --exclude-dir=.git . -``` - -Fix every hit to `execution trace`. - -- [ ] **Step 2: Fix the stale README warning** - -`graycode-platform/README.md:56` warns about `lakshmanp230.workers.dev` and names `apps/web/public/_headers`, `apps/web/dist/_headers` and `api/graycode-cloud-openapi.yaml`. Those three files are already clean; the only remaining occurrence was `apps/worker/api/openapi.yaml:7`, deleted in Task B4. Replace the whole warning block with an accurate one: - -```markdown -> ⚠️ The Graycode Cloud worker has no route or custom domain in -> `apps/worker/wrangler.jsonc`, so it is reachable only at its generated -> `workers.dev` address. CLI device traffic needs a stable hostname before -> launch; `api.graycodeai.com` is the browser BFF and rejects device tokens. -``` - -- [ ] **Step 3: Fix the architecture diagram's dead host** - -`ARCHITECTURE.md:314` renders a node labelled `graycode-api.workers.dev`, a host that does not exist. Replace with `api.graycodeai.com`. - -- [ ] **Step 4: Sweep the prose** - -```bash -cd graycode-platform -for f in README.md ARCHITECTURE.md AGENTS.md CONTRIBUTING.md SECURITY.md CHANGELOG.md \ - docs/architecture.md apps/worker/README.md apps/bff/README.md \ - apps/worker/docs/ENCRYPTION-KEY-ROTATION.md apps/worker/docs/DELIVERY-CONTEXT-FLOW.md \ - apps/worker/docs/ARCHITECTURE-IMPLEMENTATION.md; do - sed -i '' -e 's/Hawk Cloud/Graycode Cloud/g' -e 's|GrayCodeAI/hawk|GrayCodeAI/graycode-cli|g' \ - -e 's/\bhawk-eco\b/graycode-eco/g' -e 's/\bHawk\b/Graycode/g' -e 's/\bhawk\b/graycode/g' "$f" -done -``` - -- [ ] **Step 5: Restore the identifiers the sweep clobbered** - -```bash -cd graycode-platform -git diff | grep -nE '^\+.*(GraycodeCloudService|GRAYCODE_CLOUD"|graycode\.window_days|graycode\.sessions|graycode\.tokens)' && echo "REVERT THESE" || echo "clean" -``` - -`HawkCloudService` (RPC entrypoint), `HAWK_CLOUD` (service binding) and the `hawk.*` OTel metric names must survive. Revert any hunk that renamed them. In `apps/worker/README.md:70` the sentence should read: "The BFF calls Graycode Cloud through the named `HawkCloudService` RPC entrypoint." - -- [ ] **Step 6: Fix the login command name** - -`apps/worker/README.md:82` and `apps/worker/docs/ENCRYPTION-KEY-ROTATION.md:34` reference `hawk login`. The real command is `graycode cloud login` (`graycode-cli/cmd/cloud.go:35`). The Step 4 sweep produces `graycode login`, which is wrong. Correct both to `graycode cloud login`. - -```bash -cd graycode-platform && grep -rn 'graycode login' apps/ docs/ *.md -``` - -Expected after fixing: no output. - -- [ ] **Step 7: Fix the two remaining strings** - -- `apps/worker/src/app.ts:57`: `'Hawk Cloud request failed'` → `'Graycode Cloud request failed'` -- `scripts/deploy.sh:24,26`: `Deploying Hawk Cloud Worker` and `Hawk Cloud Worker deployed` → `Graycode Cloud Worker` -- `apps/worker/migrations/0003_cli_device_authorization.sql:1`: the comment mentioning `hawk login` → `graycode cloud login`. **Comment only. The filename and the column stay** — the column is renamed by migration `0024`, never by editing an applied migration. - -- [ ] **Step 8: Typecheck, test, format** - -```bash -cd graycode-platform && pnpm --filter @graycode/worker check && pnpm --filter @graycode/bff check -``` - -Expected: prettier clean, typecheck clean, all tests PASS. - -- [ ] **Step 9: Commit** - -```bash -git add -A -git commit -m "docs: rename Hawk Cloud to Graycode Cloud across platform docs - -Also corrects 'graycode login' to the real 'graycode cloud login' -command, replaces a stale workers.dev warning that named three -already-clean files, fixes a dead graycode-api.workers.dev node in the -architecture diagram, and repairs 'execution swift' typos left by an -earlier substring rename. - -Keeps HAWK_CLOUD, HawkCloudService and the hawk.* OTel metric names. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -### Task D4: graycode-cli — refresh the ecosystem inventory and drop starling fixtures - -**Files:** -- Modify: `graycode-cli/README.md` (the Ecosystem section, around lines 442-490) -- Modify: `graycode-cli/internal/plugin/registry_test.go:20,21,167,192,250,276,285` -- Modify: `graycode-cli/internal/plugin/skillslock_test.go:20` - -**Interfaces:** -- Consumes: the four-package contract from Task C1. - -> `graycode-cli` has zero `hawk` occurrences. The only stale names are `starling` in test fixtures and an ecosystem table that lists repos not present in `ecosystem.yaml`. - -- [ ] **Step 1: Replace the starling fixtures** - -```bash -cd graycode-cli -sed -i '' 's|GrayCodeAI/starling|GrayCodeAI/graycode-skills|g' internal/plugin/registry_test.go internal/plugin/skillslock_test.go -grep -rn 'starling' internal/ cmd/ || echo "CLI CLEAN" -``` - -- [ ] **Step 2: Correct the ecosystem section** - -`README.md:442-490` names `owl`, `falcon` and other repos as ecosystem members, but `ecosystem.yaml` lists exactly four: `graycode-cli`, `graycode-router`, `graycode-skills`, `graycode-platform`. Make the prose match the manifest, and extend the component table at the end of the section: - -```markdown -| Component | Repository | Purpose | -|---|---|---| -| **graycode** | This repo | AI coding agent | -| **graycode-router** | [GrayCodeAI/graycode-router](https://github.com/GrayCodeAI/graycode-router) | LLM provider runtime | -| **graycode-skills** | [GrayCodeAI/graycode-skills](https://github.com/GrayCodeAI/graycode-skills) | Community skill registry | -| **graycode-platform** | [GrayCodeAI/graycode-platform](https://github.com/GrayCodeAI/graycode-platform) | Web, BFF, and Graycode Cloud | - -`ecosystem.yaml` is the canonical inventory; tooling reads it rather than -carrying its own list. Support engines mounted through Go module -dependencies (`harrier`, `shrike`, `swift`, `kestrel`, `merlin`, `falcon`) -live in their own repositories and are not part of this workspace. -``` - -- [ ] **Step 3: Verify the manifest and the prose agree** - -```bash -cd graycode-cli && ./scripts/ecosystem-manifest.sh list && go test ./internal/plugin/ ./internal/testaudit/ -count=1 -``` - -Expected: the manifest lists exactly the four repos named in the table; tests PASS. - -- [ ] **Step 4: Commit** - -```bash -git add README.md internal/plugin/registry_test.go internal/plugin/skillslock_test.go -git commit -m "docs: align the ecosystem section with ecosystem.yaml - -Also replaces GrayCodeAI/starling test fixtures with the repo's -current name. - -Claude-Session: https://claude.ai/code/session_01MTUKadN91fcmuhxGWYVe2k" -``` - ---- - -# Execution Order - -The workstreams are independent, but two ordering constraints are real: - -1. **A1 → A2 → A3.** The CLI cannot be verified until the release URL is live. -2. **B1 → B2/B3/B4 → deploy.** The migration must be applied before the worker deploys. - -Recommended sequence, one PR per repo per workstream: - -| Order | Workstream | Repos | Blocking? | -|---|---|---|---| -| 1 | A (registry) | graycode-skills, then graycode-cli | A2 must merge and run before A3 verifies | -| 2 | B (wire contract) | graycode-platform, graycode-cli | migration before deploy | -| 3 | C (boundary truth) | graycode-router, graycode-cli | none | -| 4 | D (naming) | all four | run last so it sweeps the text A/B/C introduce | - ---- - -# Self-Review - -**Coverage.** Every finding from the scouting pass maps to a task: registry publishing (A2), registry shape (A1), registry consumption (A3), install discovery (A4), device-login field drift (B1-B2), usage capability drift (B1-B2), BFF enum (B3), triplicate OpenAPI plus personal subdomain (B4), missing cloud endpoint documented as a known gap (B5), documented-vs-enforced boundary (C1), vacuous peer guard (C2), missing pre-push hook (C2), dead credentials exception (C2), legacy naming (D1-D4), wrong skill and category counts (D1), rename-corruption typos (D3), hard-coded personal path (D2). - -**Deliberately out of scope, and why.** -- Widening the `engine` facade to re-export `ChatOptions`, `StreamResult`, `ResponseFormat`, `ContinuationConfig`, `ImageURLPart` and `InputAudioPart` so graycode-cli could be `engine`-only. That is a graycode-router public API change and deserves its own plan. -- Provisioning a public hostname for the Graycode Cloud worker. Infrastructure decision, flagged in B5. -- Renaming the `starling` Python package name in `pyproject.toml:6`. A published-name change is a release decision. -- Marketing copy under `apps/web`, including the blog post claiming 12,147 skills across 21 categories. Dated content; correcting it is an editorial call. -- `graycode-cli`'s own `docs/` tree, which mentions competitors and legacy names inside dated design documents. - -**Known risk.** Tasks D1, D2 and D3 use `sed` sweeps followed by a manual diff review. The review step is not optional: each sweep is capable of rewriting a load-bearing identifier, and each of those tasks carries an explicit revert-check step naming the identifiers at risk. If a sweep's diff exceeds what a reviewer can read carefully, split it per file rather than trusting the pattern. diff --git a/internal/intelligence/memory/auto_capture.go b/internal/intelligence/memory/auto_capture.go index 67712da9..8271e5db 100644 --- a/internal/intelligence/memory/auto_capture.go +++ b/internal/intelligence/memory/auto_capture.go @@ -138,7 +138,8 @@ func (ac *AutoCapture) processFileWrite(job captureJob) { if !ok || path == "" { return } - _ = ac.bridge.Remember(context.Background(), + _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("File modified: %s", path), "file", ) @@ -155,7 +156,8 @@ func (ac *AutoCapture) processBash(job captureJob) { if isTestCommand(cmd) { if job.isErr || containsTestFailure(job.output) { snippet := truncate(job.output, 300) - _ = ac.bridge.Remember(context.Background(), + _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Test failure: `%s` → %s", truncate(cmd, 100), snippet), "bug", ) @@ -168,7 +170,8 @@ func (ac *AutoCapture) processBash(job captureJob) { if isGitCommit(cmd) && !job.isErr { msg := extractCommitMessage(cmd) if msg != "" { - _ = ac.bridge.Remember(context.Background(), + _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Commit: %s", msg), "decision", ) @@ -181,7 +184,8 @@ func (ac *AutoCapture) processBash(job captureJob) { if isPackageInstall(cmd) { pkg := extractPackageName(cmd) if pkg != "" { - _ = ac.bridge.Remember(context.Background(), + _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Dependency added: %s", pkg), "decision", ) @@ -192,7 +196,8 @@ func (ac *AutoCapture) processBash(job captureJob) { // Detect build/deploy commands as conventions if isBuildCommand(cmd) && !job.isErr { - _ = ac.bridge.Remember(context.Background(), + _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Build command: `%s`", truncate(cmd, 200)), "convention", ) @@ -208,7 +213,8 @@ func (ac *AutoCapture) processRead(job captureJob) { } // Only track significant reads (file structure discovery) if len(job.output) > 500 && isStructuralFile(path) { - _ = ac.bridge.Remember(context.Background(), + _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Project file: %s", path), "file", ) @@ -223,7 +229,8 @@ func (ac *AutoCapture) processError(job captureJob) { // Extract error patterns that are likely bugs if containsErrorPattern(job.output) { snippet := truncate(job.output, 300) - _ = ac.bridge.Remember(context.Background(), + _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Error in %s: %s", job.toolName, snippet), "bug", ) diff --git a/internal/intelligence/memory/session_diff.go b/internal/intelligence/memory/session_diff.go index 92c697cb..6be117a7 100644 --- a/internal/intelligence/memory/session_diff.go +++ b/internal/intelligence/memory/session_diff.go @@ -139,7 +139,8 @@ func (sd *SessionDiffAnalyzer) StoreMemoriesFromDiff(diff *DiffResult) { // New dependencies → remember as decisions for _, dep := range diff.NewDeps { - _ = sd.bridge.Remember(context.Background(), + _ = sd.bridge.Remember( + context.Background(), fmt.Sprintf("Dependency added: %s", dep), "decision", ) @@ -154,7 +155,8 @@ func (sd *SessionDiffAnalyzer) StoreMemoriesFromDiff(diff *DiffResult) { // Remove the hash prefix parts := strings.SplitN(commit, " ", 2) if len(parts) > 1 { - _ = sd.bridge.Remember(context.Background(), + _ = sd.bridge.Remember( + context.Background(), fmt.Sprintf("Decision: %s", parts[1]), "decision", ) From c0ec80d8346e2f02214896c4d05571574fd1f31b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:44:41 +0530 Subject: [PATCH 115/116] fix: resolve golangci-lint errcheck + staticcheck findings - progress_cli.go: explicitly discard fmt.Fprintf/Fprintln return values (errcheck) in the spinner frame callback and writeLine. - developer_path.go: omit redundant color.Color type annotation (ST1023); theme.ReportWarn is already color.Color and all switch assignments share that type. --- cmd/progress_cli.go | 6 +++--- internal/config/developer_path.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/progress_cli.go b/cmd/progress_cli.go index d5d20c22..6911b9d9 100644 --- a/cmd/progress_cli.go +++ b/cmd/progress_cli.go @@ -54,7 +54,7 @@ func (c *CLIProgress) StartStep(i int) { eta = fmt.Sprintf(" · ETA %s", formatDurationShort(remaining)) } name := c.tint(c.pt.Steps[i].Name, textPrimary) - fmt.Fprintf(c.w, "\r%s %s %s %d/%d%s\033[K", frame, c.bar(), name, i+1, len(c.pt.Steps), eta) + _, _ = fmt.Fprintf(c.w, "\r%s %s %s %d/%d%s\033[K", frame, c.bar(), name, i+1, len(c.pt.Steps), eta) }) } @@ -153,8 +153,8 @@ func (c *CLIProgress) Abort() { func (c *CLIProgress) writeLine(line string) { if c.tty { - fmt.Fprintf(c.w, "\r%s\033[K\n", line) + _, _ = fmt.Fprintf(c.w, "\r%s\033[K\n", line) return } - fmt.Fprintln(c.w, line) + _, _ = fmt.Fprintln(c.w, line) } diff --git a/internal/config/developer_path.go b/internal/config/developer_path.go index 07713c6b..575a0d31 100644 --- a/internal/config/developer_path.go +++ b/internal/config/developer_path.go @@ -281,7 +281,7 @@ func FormatDeveloperPathReport(ctx context.Context) string { b.WriteString(theme.Tint("Developer path (graycode · graycode-router · shrike · harrier)", theme.ReportInfo) + "\n\n") status := "NEEDS SETUP" - var statusColor color.Color = theme.ReportWarn + var statusColor = theme.ReportWarn switch { case r.Ready: status = "READY" From bc3a8f0e4e10ce37faf6393ada305ead0bb33bbb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 09:46:12 +0530 Subject: [PATCH 116/116] fix: gofumpt short declaration for statusColor Use := instead of var for the first declaration of statusColor so the file passes the CI gofumpt gate. --- internal/config/developer_path.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/developer_path.go b/internal/config/developer_path.go index 575a0d31..2c82d4bd 100644 --- a/internal/config/developer_path.go +++ b/internal/config/developer_path.go @@ -281,7 +281,7 @@ func FormatDeveloperPathReport(ctx context.Context) string { b.WriteString(theme.Tint("Developer path (graycode · graycode-router · shrike · harrier)", theme.ReportInfo) + "\n\n") status := "NEEDS SETUP" - var statusColor = theme.ReportWarn + statusColor := theme.ReportWarn switch { case r.Ready: status = "READY"