diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f289317..9733e8d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -44,6 +44,12 @@ }, "description": "CI/CD, deployment, issue tracking, and Coolify integration", "version": "0.1.0" + }, + { + "name": "devops", + "source": "./claude/devops", + "description": "Agent workflow utilities — install binaries, merge workspaces, update deps, clean queues", + "version": "0.1.0" } ] } diff --git a/claude/devops/.claude-plugin/plugin.json b/claude/devops/.claude-plugin/plugin.json new file mode 100644 index 0000000..91a09f8 --- /dev/null +++ b/claude/devops/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "devops", + "version": "0.1.0", + "description": "DevOps utilities for the Core ecosystem — build, install, deploy.", + "author": { + "name": "Lethean", + "email": "virgil@lethean.io" + } +} diff --git a/claude/devops/skills/clean-workspaces/SKILL.md b/claude/devops/skills/clean-workspaces/SKILL.md new file mode 100644 index 0000000..64e2073 --- /dev/null +++ b/claude/devops/skills/clean-workspaces/SKILL.md @@ -0,0 +1,44 @@ +--- +name: clean-workspaces +description: This skill should be used when the user asks to "clean workspaces", "clean up old agents", "remove stale workspaces", "nuke completed workspaces", or needs to remove finished/failed agent workspaces from the dispatch queue. +argument-hint: [--all | --completed | --failed | --blocked] +allowed-tools: ["Bash"] +--- + +# Clean Agent Workspaces + +Remove stale agent workspaces from the dispatch system. + +## Steps + +1. List all workspaces with their status: + ```bash + for ws in /Users/snider/Code/.core/workspace/*/status.json; do + dir=$(dirname "$ws") + name=$(basename "$dir") + status=$(python3 -c "import json; print(json.load(open('$ws'))['status'])" 2>/dev/null || echo "unknown") + echo "$status $name" + done | sort + ``` + +2. Based on the argument: + - `--completed` — remove workspaces with status "completed" + - `--failed` — remove workspaces with status "failed" + - `--blocked` — remove workspaces with status "blocked" + - `--all` — remove completed + failed + blocked (NOT running) + - No argument — show the list and ask the user what to remove + +3. Show the user what will be removed and get confirmation BEFORE deleting. + +4. Remove confirmed workspaces: + ```bash + rm -rf /Users/snider/Code/.core/workspace// + ``` + +5. Report how many were removed. + +## Important + +- NEVER remove workspaces with status "running" — they have active processes +- ALWAYS ask for confirmation before removing +- Show the count and names before removing diff --git a/claude/devops/skills/install-core-agent/SKILL.md b/claude/devops/skills/install-core-agent/SKILL.md new file mode 100644 index 0000000..53205c5 --- /dev/null +++ b/claude/devops/skills/install-core-agent/SKILL.md @@ -0,0 +1,35 @@ +--- +name: install-core-agent +description: This skill should be used when the user asks to "install core-agent", "rebuild core-agent", "update the agent binary", or needs to compile and install the core-agent MCP server binary. Runs the correct go install from the right directory with the right path. +argument-hint: (no arguments needed) +allowed-tools: ["Bash"] +--- + +# Install core-agent + +Build and install the core-agent binary from source. + +## Steps + +1. Run from the core/agent repo directory: + +```bash +cd /Users/snider/Code/core/agent && go install ./cmd/core-agent/ +``` + +2. Verify the binary is installed: + +```bash +which core-agent +``` + +3. Tell the user to restart core-agent (it runs as an MCP server — the process needs restarting to pick up the new binary). + +## Important + +- The entry point is `./cmd/core-agent/main.go` — NOT `./cmd/` or `.` +- `go install ./cmd/core-agent/` produces a binary named `core-agent` automatically +- Do NOT use `go install .`, `go install ./cmd/`, or `go build` with manual `-o` flags +- Do NOT move, copy, or rename binaries +- Do NOT touch `~/go/bin/` or `~/.local/bin/` directly +- If the install fails, report the error to the user — do not attempt alternatives diff --git a/claude/devops/skills/merge-workspace/SKILL.md b/claude/devops/skills/merge-workspace/SKILL.md new file mode 100644 index 0000000..f7c74c1 --- /dev/null +++ b/claude/devops/skills/merge-workspace/SKILL.md @@ -0,0 +1,56 @@ +--- +name: merge-workspace +description: This skill should be used when the user asks to "merge workspace", "bring over the changes", "review and merge agent work", "pull in the agent's diff", or needs to take a completed agent workspace and merge its changes into the dev branch. Checks the diff, verifies the build, and applies changes. +argument-hint: +allowed-tools: ["Bash", "Read"] +--- + +# Merge Agent Workspace + +Take a completed agent workspace and merge its changes into the repo's dev branch. + +## Steps + +1. Find the workspace. The argument is the workspace name (e.g., `agent-1774177216443021000`). The full path is: + ``` + /Users/snider/Code/.core/workspace// + ``` + +2. Check status: + ```bash + cat /Users/snider/Code/.core/workspace//status.json + ``` + Only proceed if status is `completed` or `ready-for-review`. + +3. Show the diff from the workspace's src/ directory: + ```bash + cd /Users/snider/Code/.core/workspace//src && git diff HEAD + ``` + Present a summary of what changed to the user. + +4. Ask the user if the changes look good before proceeding. + +5. Find the original repo path from status.json (`repo` field). The repo lives at: + ``` + /Users/snider/Code/core// + ``` + +6. Cherry-pick or apply the changes. Use `git diff` to create a patch and apply it: + ```bash + cd /Users/snider/Code/.core/workspace//src && git diff HEAD > /tmp/agent-patch.diff + cd /Users/snider/Code/core// && git apply /tmp/agent-patch.diff + ``` + +7. Verify the build passes: + ```bash + cd /Users/snider/Code/core// && go build ./... + ``` + +8. Report results. Do NOT commit — let the user decide when to commit. + +## Important + +- Always show the diff BEFORE applying +- Always verify the build AFTER applying +- Never commit — the user commits when ready +- If the patch fails to apply, show the conflict and stop diff --git a/claude/devops/skills/repair-core-agent/SKILL.md b/claude/devops/skills/repair-core-agent/SKILL.md new file mode 100644 index 0000000..458e40a --- /dev/null +++ b/claude/devops/skills/repair-core-agent/SKILL.md @@ -0,0 +1,68 @@ +--- +name: repair-core-agent +description: This skill should be used when core-agent is broken, MCP tools aren't responding, dispatch fails, the agent binary is stale, or the user says "fix core-agent", "repair the agent", "core-agent is broken", "MCP not working", "dispatch broken". Diagnoses and guides repair of the core-agent MCP server. +argument-hint: (no arguments needed) +allowed-tools: ["Bash", "Read"] +--- + +# Repair core-agent + +Diagnose and fix core-agent when it's broken. + +## Diagnosis Steps + +Run these in order, stop at the first failure: + +### 1. Is the binary installed? +```bash +which core-agent +``` +Should be at `~/.local/bin/core-agent` or `~/go/bin/core-agent`. If BOTH exist, the wrong one might take precedence — check PATH order. + +### 2. Does it compile? +```bash +cd /Users/snider/Code/core/agent && go build ./cmd/core-agent/ +``` +If this fails, there's a code error. Report it and stop. + +### 3. Is a stale process running? +```bash +ps aux | grep core-agent | grep -v grep +``` +If yes, the user needs to restart it to pick up the new binary. + +### 4. Can it start? +```bash +core-agent mcp 2>&1 | head -5 +``` +Should show subsystem loading messages. + +### 5. Are workspaces clean? +```bash +ls /Users/snider/Code/.core/workspace/ 2>/dev/null +``` +Should only have `events.jsonl`. Stale workspaces with "running" status but dead PIDs cause phantom slot usage. + +### 6. Is agents.yaml readable? +```bash +cat /Users/snider/Code/.core/agents.yaml +``` +Check concurrency settings, agent definitions. + +## Common Fixes + +| Symptom | Fix | +|---------|-----| +| Wrong binary running | Remove stale binary, user reinstalls | +| MCP tools not found | Restart core-agent process | +| Dispatch always queued | Check concurrency limits in agents.yaml | +| Workspaces not prepping | Check workspace template: `ls pkg/lib/workspace/default/` | +| go.work missing from workspace | Rebuild core-agent — template was updated | +| Codex can't find core.Env | Core dep too old — run update-deps skill | + +## Important + +- Do NOT run `go install` — tell the user to do it +- Do NOT kill processes without asking +- Do NOT delete workspaces without asking +- Report what's wrong, suggest the fix, let the user decide diff --git a/claude/devops/skills/update-deps/SKILL.md b/claude/devops/skills/update-deps/SKILL.md new file mode 100644 index 0000000..e2ea49c --- /dev/null +++ b/claude/devops/skills/update-deps/SKILL.md @@ -0,0 +1,55 @@ +--- +name: update-deps +description: This skill should be used when the user asks to "update deps", "bump core", "update go.mod", "upgrade dependencies", or needs to update dappco.re/go/core or other Go module dependencies in a core ecosystem repo. Uses go get properly — never manual go.mod editing. +argument-hint: [repo-name] [module@version] +allowed-tools: ["Bash"] +--- + +# Update Go Module Dependencies + +Properly update dependencies in a Core ecosystem Go module. + +## Steps + +1. Determine the repo. If an argument is given, use it. Otherwise use the current working directory. + ``` + /Users/snider/Code/core// + ``` + +2. Check current dependency versions: + ```bash + grep 'dappco.re' go.mod + ``` + +3. Update the dependency using `go get`. Examples: + ```bash + # Update core to latest + GONOSUMDB='dappco.re/*' GONOSUMCHECK='dappco.re/*' GOPROXY=direct go get dappco.re/go/core@latest + + # Update to specific version + GONOSUMDB='dappco.re/*' GONOSUMCHECK='dappco.re/*' GOPROXY=direct go get dappco.re/go/core@v0.6.0 + + # Update all dappco.re deps + GONOSUMDB='dappco.re/*' GONOSUMCHECK='dappco.re/*' GOPROXY=direct go get -u dappco.re/... + ``` + +4. Tidy: + ```bash + go mod tidy + ``` + +5. Verify: + ```bash + go build ./... + ``` + +6. Report what changed in go.mod. + +## Important + +- ALWAYS use `go get` — NEVER manually edit go.mod +- ALWAYS set `GONOSUMDB` and `GONOSUMCHECK` for dappco.re modules +- ALWAYS set `GOPROXY=direct` to bypass proxy cache for private modules +- ALWAYS run `go mod tidy` after updating +- ALWAYS verify with `go build ./...` +- If a version doesn't resolve, check if the tag has been pushed to GitHub (dappco.re vanity imports resolve through GitHub) diff --git a/cmd/core-agent/main.go b/cmd/core-agent/main.go index f0d71e4..261d6e0 100644 --- a/cmd/core-agent/main.go +++ b/cmd/core-agent/main.go @@ -11,6 +11,7 @@ import ( "dappco.re/go/agent/pkg/agentic" "dappco.re/go/agent/pkg/brain" + "dappco.re/go/agent/pkg/lib" "dappco.re/go/agent/pkg/monitor" "forge.lthn.ai/core/mcp/pkg/mcp" ) @@ -21,6 +22,108 @@ func main() { }) c.App().Version = "0.2.0" + // version — print version and build info + c.Command("version", core.Command{ + Description: "Print version and build info", + Action: func(opts core.Options) core.Result { + core.Print(nil, "core-agent %s", c.App().Version) + core.Print(nil, " go: %s", core.Env("GO")) + core.Print(nil, " os: %s/%s", core.Env("OS"), core.Env("ARCH")) + core.Print(nil, " home: %s", core.Env("DIR_HOME")) + core.Print(nil, " hostname: %s", core.Env("HOSTNAME")) + core.Print(nil, " pid: %s", core.Env("PID")) + return core.Result{OK: true} + }, + }) + + // check — verify workspace, deps, and config are healthy + c.Command("check", core.Command{ + Description: "Verify workspace, deps, and config", + Action: func(opts core.Options) core.Result { + fs := c.Fs() + + core.Print(nil, "core-agent %s health check", c.App().Version) + core.Print(nil, "") + + // Binary location + core.Print(nil, " binary: %s", os.Args[0]) + + // Agents config + agentsPath := core.Path("Code", ".core", "agents.yaml") + if fs.IsFile(agentsPath) { + core.Print(nil, " agents: %s (ok)", agentsPath) + } else { + core.Print(nil, " agents: %s (MISSING)", agentsPath) + } + + // Workspace dir + wsRoot := core.Path("Code", ".core", "workspace") + if fs.IsDir(wsRoot) { + r := fs.List(wsRoot) + count := 0 + if r.OK { + count = len(r.Value.([]os.DirEntry)) + } + core.Print(nil, " workspace: %s (%d entries)", wsRoot, count) + } else { + core.Print(nil, " workspace: %s (MISSING)", wsRoot) + } + + // Core dep version + core.Print(nil, " core: dappco.re/go/core@v%s", c.App().Version) + + // Env keys + core.Print(nil, " env keys: %d loaded", len(core.EnvKeys())) + + core.Print(nil, "") + core.Print(nil, "ok") + return core.Result{OK: true} + }, + }) + + // extract — test workspace template extraction + c.Command("extract", core.Command{ + Description: "Extract a workspace template to a directory", + Action: func(opts core.Options) core.Result { + tmpl := opts.String("_arg") + if tmpl == "" { + tmpl = "default" + } + target := opts.String("target") + if target == "" { + target = core.Path("Code", ".core", "workspace", "test-extract") + } + + data := &lib.WorkspaceData{ + Repo: "test-repo", + Branch: "dev", + Task: "test extraction", + Agent: "codex", + } + + core.Print(nil, "extracting template %q to %s", tmpl, target) + if err := lib.ExtractWorkspace(tmpl, target, data); err != nil { + return core.Result{Value: err, OK: false} + } + + // List what was created + fs := &core.Fs{} + r := fs.List(target) + if r.OK { + for _, e := range r.Value.([]os.DirEntry) { + marker := " " + if e.IsDir() { + marker = "/" + } + core.Print(nil, " %s%s", e.Name(), marker) + } + } + + core.Print(nil, "done") + return core.Result{OK: true} + }, + }) + // Shared setup — creates MCP service with all subsystems wired initServices := func() (*mcp.Service, *monitor.Subsystem, error) { procFactory := process.NewService(process.Options{}) @@ -76,18 +179,17 @@ func main() { return core.Result{Value: err, OK: false} } - addr := os.Getenv("MCP_HTTP_ADDR") + addr := core.Env("MCP_HTTP_ADDR") if addr == "" { addr = "0.0.0.0:9101" } - healthAddr := os.Getenv("HEALTH_ADDR") + healthAddr := core.Env("HEALTH_ADDR") if healthAddr == "" { healthAddr = "0.0.0.0:9102" } - home, _ := os.UserHomeDir() - pidFile := core.Concat(home, "/.core/core-agent.pid") + pidFile := core.Path(".core", "core-agent.pid") daemon := process.NewDaemon(process.DaemonOptions{ PIDFile: pidFile, diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index 5ff978d..5429cdb 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -68,11 +68,24 @@ func agentCommand(agent, prompt string) (string, []string, error) { case "codex": if model == "review" { // Codex review mode — non-interactive code review - // Note: --base and prompt are mutually exclusive in codex CLI - return "codex", []string{"review", "--base", "HEAD~1"}, nil + return "codex", []string{ + "review", "--base", "HEAD~1", + }, nil } - // Codex agent mode — autonomous coding - return "codex", []string{"exec", "--full-auto", prompt}, nil + // Codex agent mode — workspace root is not a git repo (src/ is), + // so --skip-git-repo-check is required. --full-auto gives + // workspace-write sandbox with on-request approval. + args := []string{ + "exec", + "--full-auto", + "--skip-git-repo-check", + "-o", "agent-codex.log", + prompt, + } + if model != "" { + args = append(args[:3], append([]string{"--model", model}, args[3:]...)...) + } + return "codex", args, nil case "claude": args := []string{ "-p", prompt, @@ -127,7 +140,7 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st proc, err := process.StartWithOptions(context.Background(), process.RunOptions{ Command: command, Args: args, - Dir: srcDir, + Dir: wsDir, Env: []string{"TERM=dumb", "NO_COLOR=1", "CI=true", "GOWORK=off"}, Detach: true, }) diff --git a/pkg/agentic/mirror.go b/pkg/agentic/mirror.go index 5723389..2da4c7b 100644 --- a/pkg/agentic/mirror.go +++ b/pkg/agentic/mirror.go @@ -4,9 +4,9 @@ package agentic import ( "context" + "encoding/json" "os" "os/exec" - "strings" core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -265,16 +265,24 @@ func (s *PrepSubsystem) listLocalRepos(basePath string) []string { // extractJSONField extracts a simple string field from JSON array output. func extractJSONField(jsonStr, field string) string { - // Quick and dirty — works for gh CLI output like [{"url":"https://..."}] - key := core.Sprintf(`"%s":"`, field) - idx := strings.Index(jsonStr, key) - if idx < 0 { + if jsonStr == "" || field == "" { return "" } - start := idx + len(key) - end := strings.Index(jsonStr[start:], `"`) - if end < 0 { + + var list []map[string]any + if err := json.Unmarshal([]byte(jsonStr), &list); err == nil { + for _, item := range list { + if value, ok := item[field].(string); ok { + return value + } + } + } + + var item map[string]any + if err := json.Unmarshal([]byte(jsonStr), &item); err != nil { return "" } - return jsonStr[start : start+end] + + value, _ := item[field].(string) + return value } diff --git a/pkg/agentic/paths_test.go b/pkg/agentic/paths_test.go index 4735d5b..1bf8216 100644 --- a/pkg/agentic/paths_test.go +++ b/pkg/agentic/paths_test.go @@ -111,6 +111,16 @@ func TestExtractJSONField_Good(t *testing.T) { assert.Equal(t, "https://github.com/dAppCore/go-io/pull/1", extractJSONField(json, "url")) } +func TestExtractJSONField_Good_Object(t *testing.T) { + json := `{"url":"https://github.com/dAppCore/go-io/pull/2"}` + assert.Equal(t, "https://github.com/dAppCore/go-io/pull/2", extractJSONField(json, "url")) +} + +func TestExtractJSONField_Good_PrettyPrinted(t *testing.T) { + json := "[\n {\n \"url\": \"https://github.com/dAppCore/go-io/pull/3\"\n }\n]" + assert.Equal(t, "https://github.com/dAppCore/go-io/pull/3", extractJSONField(json, "url")) +} + func TestExtractJSONField_Bad_Missing(t *testing.T) { assert.Equal(t, "", extractJSONField(`{"name":"test"}`, "url")) assert.Equal(t, "", extractJSONField("", "url")) diff --git a/pkg/agentic/plan.go b/pkg/agentic/plan.go index 31bf276..f3931b5 100644 --- a/pkg/agentic/plan.go +++ b/pkg/agentic/plan.go @@ -8,7 +8,6 @@ import ( "encoding/hex" "encoding/json" "os" - "strings" "time" core "dappco.re/go/core" @@ -348,28 +347,7 @@ func planPath(dir, id string) string { } func generatePlanID(title string) string { - slug := strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' { - return r - } - if r >= 'A' && r <= 'Z' { - return r + 32 - } - if r == ' ' { - return '-' - } - return -1 - }, title) - - // Trim consecutive dashes and cap length - for core.Contains(slug, "--") { - slug = core.Replace(slug, "--", "-") - } - slug = strings.Trim(slug, "-") - if len(slug) > 30 { - slug = slug[:30] - } - slug = strings.TrimRight(slug, "-") + slug := sanitisePlanSlug(title) // Append short random suffix for uniqueness b := make([]byte, 3) diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 9de94ad..13f99a5 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -11,7 +11,6 @@ import ( goio "io" "net/http" "os/exec" - "strings" "sync" "time" @@ -204,19 +203,7 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques } // Create feature branch - taskSlug := strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' { - return r - } - if r >= 'A' && r <= 'Z' { - return r + 32 // lowercase - } - return '-' - }, input.Task) - if len(taskSlug) > 40 { - taskSlug = taskSlug[:40] - } - taskSlug = strings.Trim(taskSlug, "-") + taskSlug := sanitiseBranchSlug(input.Task, 40) if taskSlug == "" { // Fallback for issue-only dispatches with no task text taskSlug = core.Sprintf("issue-%d", input.Issue) @@ -234,17 +221,17 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques out.Branch = branchName // Create context dirs inside src/ - fs.EnsureDir(core.JoinPath(srcDir, "kb")) - fs.EnsureDir(core.JoinPath(srcDir, "specs")) + fs.EnsureDir(core.JoinPath(wsDir, "kb")) + fs.EnsureDir(core.JoinPath(wsDir, "specs")) // Remote stays as local clone origin — agent cannot push to forge. // Reviewer pulls changes from workspace and pushes after verification. - // 2. Extract workspace template - wsTmpl := "default" + // 2. Extract workspace template — default first, then overlay + wsTmpl := "" if input.Template == "security" { wsTmpl = "security" - } else if input.Template == "verify" || input.Template == "conventions" { + } else if input.Template == "review" || input.Template == "verify" || input.Template == "conventions" { wsTmpl = "review" } @@ -276,18 +263,9 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques TestCmd: detectTestCmd(repoPath), } - lib.ExtractWorkspace(wsTmpl, srcDir, wsData) - out.ClaudeMd = true - - // Copy repo's own CLAUDE.md over template if it exists - claudeMdPath := core.JoinPath(repoPath, "CLAUDE.md") - if r := fs.Read(claudeMdPath); r.OK { - fs.Write(core.JoinPath(srcDir, "CLAUDE.md"), r.Value.(string)) - } - // Copy GEMINI.md from core/agent (ethics framework for all agents) - agentGeminiMd := core.JoinPath(s.codePath, "core", "agent", "GEMINI.md") - if r := fs.Read(agentGeminiMd); r.OK { - fs.Write(core.JoinPath(srcDir, "GEMINI.md"), r.Value.(string)) + lib.ExtractWorkspace("default", wsDir, wsData) + if wsTmpl != "" { + lib.ExtractWorkspace(wsTmpl, wsDir, wsData) } // 3. Generate TODO.md from issue (overrides template) @@ -469,12 +447,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i } content, _ := base64.StdEncoding.DecodeString(pageData.ContentBase64) - filename := strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { - return r - } - return '-' - }, page.Title) + ".md" + filename := sanitiseFilename(page.Title) + ".md" fs.Write(core.JoinPath(wsDir, "src", "kb", filename), string(content)) count++ diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index e8de33b..fc6cac5 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -127,6 +127,19 @@ func TestDetectTestCmd_Good_DefaultsToGo(t *testing.T) { assert.Equal(t, "go test ./...", detectTestCmd(dir)) } +func TestSanitiseBranchSlug_Good(t *testing.T) { + assert.Equal(t, "fix-login-bug", sanitiseBranchSlug("Fix login bug!", 40)) + assert.Equal(t, "trim-me", sanitiseBranchSlug("---Trim Me---", 40)) +} + +func TestSanitiseBranchSlug_Good_Truncates(t *testing.T) { + assert.Equal(t, "feature", sanitiseBranchSlug("feature--extra", 7)) +} + +func TestSanitiseFilename_Good(t *testing.T) { + assert.Equal(t, "Core---Agent-Notes", sanitiseFilename("Core / Agent:Notes")) +} + func TestNewPrep_Good_Defaults(t *testing.T) { t.Setenv("FORGE_TOKEN", "") t.Setenv("GITEA_TOKEN", "") diff --git a/pkg/agentic/sanitise.go b/pkg/agentic/sanitise.go new file mode 100644 index 0000000..5a90d76 --- /dev/null +++ b/pkg/agentic/sanitise.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +func sanitiseBranchSlug(text string, max int) string { + out := make([]rune, 0, len(text)) + for _, r := range text { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-': + out = append(out, r) + case r >= 'A' && r <= 'Z': + out = append(out, r+32) + default: + out = append(out, '-') + } + + if max > 0 && len(out) >= max { + break + } + } + + return trimRuneEdges(string(out), '-') +} + +func sanitisePlanSlug(text string) string { + out := make([]rune, 0, len(text)) + for _, r := range text { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-': + out = append(out, r) + case r >= 'A' && r <= 'Z': + out = append(out, r+32) + case r == ' ': + out = append(out, '-') + } + } + + slug := collapseRepeatedRune(string(out), '-') + slug = trimRuneEdges(slug, '-') + if len(slug) > 30 { + slug = slug[:30] + } + + return trimRuneEdges(slug, '-') +} + +func sanitiseFilename(text string) string { + out := make([]rune, 0, len(text)) + for _, r := range text { + switch { + case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.': + out = append(out, r) + default: + out = append(out, '-') + } + } + + return string(out) +} + +func collapseRepeatedRune(text string, target rune) string { + runes := []rune(text) + out := make([]rune, 0, len(runes)) + lastWasTarget := false + + for _, r := range runes { + if r == target { + if lastWasTarget { + continue + } + lastWasTarget = true + } else { + lastWasTarget = false + } + + out = append(out, r) + } + + return string(out) +} + +func trimRuneEdges(text string, target rune) string { + runes := []rune(text) + start := 0 + end := len(runes) + + for start < end && runes[start] == target { + start++ + } + + for end > start && runes[end-1] == target { + end-- + } + + return string(runes[start:end]) +} diff --git a/pkg/lib/lib_test.go b/pkg/lib/lib_test.go index 32fc1c9..5141d41 100644 --- a/pkg/lib/lib_test.go +++ b/pkg/lib/lib_test.go @@ -206,7 +206,7 @@ func TestExtractWorkspace_CreatesFiles(t *testing.T) { t.Fatalf("ExtractWorkspace failed: %v", err) } - for _, name := range []string{"CODEX.md", "CLAUDE.md", "PROMPT.md", "TODO.md", "CONTEXT.md"} { + for _, name := range []string{"CODEX.md", "CLAUDE.md", "PROMPT.md", "TODO.md", "CONTEXT.md", "go.work"} { path := filepath.Join(dir, name) if _, err := os.Stat(path); os.IsNotExist(err) { t.Errorf("expected %s to exist", name) diff --git a/pkg/lib/workspace/default/.gitignore.tmpl b/pkg/lib/workspace/default/.gitignore.tmpl deleted file mode 100644 index cd276bb..0000000 --- a/pkg/lib/workspace/default/.gitignore.tmpl +++ /dev/null @@ -1,4 +0,0 @@ -.idea/ -.vscode/ -*.log -.core/workspace diff --git a/pkg/lib/workspace/default/CODEX.md.tmpl b/pkg/lib/workspace/default/CODEX.md.tmpl index f3ba5f0..8e1bdf2 100644 --- a/pkg/lib/workspace/default/CODEX.md.tmpl +++ b/pkg/lib/workspace/default/CODEX.md.tmpl @@ -30,6 +30,7 @@ c := core.New(core.Options{ | `c.Cli()` | `*Cli` | CLI surface (command tree → terminal) | | `c.IPC()` | `*Ipc` | Message bus (Action/Query/Task) | | `c.I18n()` | `*I18n` | Internationalisation + locale collection | +| `c.Env("key")` | `string` | Read-only system/environment info | | `c.Options()` | `*Options` | Input configuration used to create Core | | `c.Context()` | `context.Context` | Application context (cancelled on shutdown) | @@ -96,6 +97,64 @@ c.RegisterTask(func(c *core.Core, t core.Task) core.Result { }) ``` +### Environment — use `core.Env()`, never `os.Getenv` for standard dirs + +Env is environment (read-only system facts). Config is ours (mutable app settings). + +```go +// System +core.Env("OS") // "darwin", "linux", "windows" +core.Env("ARCH") // "arm64", "amd64" +core.Env("DS") // "/" or "\" (directory separator) +core.Env("HOSTNAME") // machine name +core.Env("USER") // current user + +// Directories +core.Env("DIR_HOME") // home dir (overridable via CORE_HOME env var) +core.Env("DIR_CONFIG") // OS config dir +core.Env("DIR_CACHE") // OS cache dir +core.Env("DIR_DATA") // OS data dir (platform-specific) +core.Env("DIR_TMP") // temp dir +core.Env("DIR_CWD") // working directory at startup +core.Env("DIR_CODE") // ~/Code +core.Env("DIR_DOWNLOADS") // ~/Downloads + +// Timestamps +core.Env("CORE_START") // RFC3339 UTC boot timestamp +``` + +### Paths — use `core.Path()`, never `filepath.Join` or raw concatenation + +Path() is the single point of responsibility for filesystem paths. Every path goes through it — security fixes happen in one place. + +```go +// WRONG +home, _ := os.UserHomeDir() +configPath := filepath.Join(home, ".config", "app.yaml") +base := filepath.Base(configPath) + +// CORRECT +configPath := core.Path(".config", "app.yaml") // anchored to DIR_HOME +base := core.PathBase(configPath) +``` + +```go +// Relative → anchored to DIR_HOME +core.Path("Code", ".core") // "/Users/snider/Code/.core" +core.Path(".config", "app.yaml") // "/Users/snider/.config/app.yaml" + +// Absolute → pass through (cleaned) +core.Path("/tmp", "workspace") // "/tmp/workspace" + +// No args → DIR_HOME +core.Path() // "/Users/snider" + +// Component helpers +core.PathBase("/a/b/c") // "c" +core.PathDir("/a/b/c") // "/a/b" +core.PathExt("main.go") // ".go" +``` + ## Mandatory Patterns ### Errors — use `core.E()`, never `fmt.Errorf` or `errors.New` @@ -122,16 +181,35 @@ core.Info("starting server", "addr", addr) core.Error("operation failed", "err", err) ``` -### Filesystem — use `core.Fs{}`, never `os.ReadFile/WriteFile/MkdirAll` +### Filesystem — use `core.Fs{}` + `core.Path()`, never `os.*` or `filepath.*` ```go var fs = &core.Fs{} -r := fs.Read(path) // returns core.Result + +// Build paths with Path() — never raw concatenation +configPath := core.Path(".config", "app.yaml") + +// Read/write through Fs — never os.ReadFile/WriteFile +r := fs.Read(configPath) if !r.OK { return r } content := r.Value.(string) -fs.Write(path, content) // returns core.Result -fs.EnsureDir(dir) // returns core.Result +fs.Write(configPath, content) +fs.EnsureDir(core.Path(".config")) + +// File checks — never os.Stat +fs.Exists(path) // bool +fs.IsFile(path) // bool +fs.IsDir(path) // bool + +// Directory listing — never os.ReadDir +r := fs.List(dir) // Result{[]os.DirEntry, true} + +// Append — never os.OpenFile +r := fs.Append(logPath) // Result{*os.File, true} + +// Delete — never os.Remove +fs.Delete(path) // Result ``` ### Returns — use `core.Result`, never `(value, error)` @@ -392,10 +470,15 @@ c.I18n().SetLanguage("en-GB") | `log` | `core.Error`, `core.Info` | | `strings` | `core.Contains`, `core.Split` etc | | `errors` | `core.E`, `core.Wrap` | +| `path/filepath` | `core.Path`, `core.PathBase`, `core.PathDir`, `core.PathExt` | | `io/ioutil` | `core.Fs{}` | | `os` (file ops) | `core.Fs{}` | +| `os.UserHomeDir` | `core.Env("DIR_HOME")` | +| `os.Getenv` (standard dirs) | `core.Env("DIR_CONFIG")` etc | +| `runtime.GOOS` | `core.Env("OS")` | +| `runtime.GOARCH` | `core.Env("ARCH")` | -Acceptable stdlib: `os.Getenv`, `os.Exit`, `os.Stderr`, `os.UserHomeDir`, `context`, `sync`, `time`, `net/http`, `encoding/json`, `path/filepath`. +Acceptable stdlib: `os.Exit`, `os.Stderr`, `os.Getenv` (non-standard keys), `context`, `sync`, `time`, `net/http`, `encoding/json`. ## Build & Test diff --git a/pkg/lib/workspace/default/go.work.tmpl b/pkg/lib/workspace/default/go.work.tmpl index 957f5b9..3ed003a 100644 --- a/pkg/lib/workspace/default/go.work.tmpl +++ b/pkg/lib/workspace/default/go.work.tmpl @@ -1,3 +1,3 @@ go 1.26.0 -use . +use ./src diff --git a/pkg/lib/workspace/review/PROMPT.md.tmpl b/pkg/lib/workspace/review/PROMPT.md.tmpl new file mode 100644 index 0000000..8e1a6ad --- /dev/null +++ b/pkg/lib/workspace/review/PROMPT.md.tmpl @@ -0,0 +1,14 @@ +Read CLAUDE.md for review instructions. Read CODEX.md for the AX conventions to audit against. + +## Mode: AUDIT ONLY + +You are a reviewer. You do NOT fix code. You do NOT commit. + +1. Read every .go file in cmd/ and pkg/ +2. Check each against the patterns in CODEX.md +3. Report findings to stdout with: severity (critical/high/medium/low), file:line, one-sentence description +4. Group by package + +If you find merge conflicts, report them as critical findings — do not resolve them. + +Do NOT run the closeout sequence. Do NOT use code-review agents. Do NOT commit. Report and stop. diff --git a/pkg/monitor/harvest.go b/pkg/monitor/harvest.go index 8f6fad3..dabd064 100644 --- a/pkg/monitor/harvest.go +++ b/pkg/monitor/harvest.go @@ -13,6 +13,7 @@ import ( "context" "encoding/json" "os/exec" + "path/filepath" "strconv" "dappco.re/go/agent/pkg/agentic" @@ -31,12 +32,15 @@ type harvestResult struct { // branches back to the source repos. Returns a summary message. func (m *Subsystem) harvestCompleted() string { wsRoot := agentic.WorkspaceRoot() - entries := core.PathGlob(workspaceStatusGlob(wsRoot)) + entries, err := filepath.Glob(workspaceStatusGlob(wsRoot)) + if err != nil { + return "" + } var harvested []harvestResult for _, entry := range entries { - wsDir := core.PathDir(monitorPath(entry)) + wsDir := filepath.Dir(entry) result := m.harvestWorkspace(wsDir) if result != nil { harvested = append(harvested, *result) @@ -98,7 +102,7 @@ func (m *Subsystem) harvestWorkspace(wsDir string) *harvestResult { } srcDir := core.Concat(wsDir, "/src") - if !fs.Exists(srcDir) || fs.IsFile(srcDir) { + if !fs.IsDir(srcDir) { return nil } @@ -200,8 +204,12 @@ func countUnpushed(srcDir, branch string) int { // Checks ALL changed files (added, modified, renamed), not just new. // Fails closed: if git diff fails, rejects the workspace. func checkSafety(srcDir string) string { - files, ok := changedFilesSinceDefault(srcDir) - if !ok { + // Check all changed files — added, modified, renamed + base := defaultBranch(srcDir) + cmd := exec.Command("git", "diff", "--name-only", core.Concat(base, "...HEAD")) + cmd.Dir = srcDir + out, err := cmd.Output() + if err != nil { return "safety check failed: git diff error" } @@ -215,18 +223,21 @@ func checkSafety(srcDir string) string { ".db": true, ".sqlite": true, ".sqlite3": true, } - for _, file := range files { - ext := core.Lower(core.PathExt(file)) + for _, file := range core.Split(core.Trim(string(out)), "\n") { + if file == "" { + continue + } + ext := core.Lower(filepath.Ext(file)) if binaryExts[ext] { return core.Sprintf("binary file added: %s", file) } // Check file size (reject > 1MB) fullPath := core.Concat(srcDir, "/", file) - r := fs.Stat(fullPath) - size, ok := resultFileSize(r) - if ok && size > 1024*1024 { - return core.Sprintf("large file: %s (%d bytes)", file, size) + if stat := fs.Stat(fullPath); stat.OK { + if info, ok := stat.Value.(interface{ Size() int64 }); ok && info.Size() > 1024*1024 { + return core.Sprintf("large file: %s (%d bytes)", file, info.Size()) + } } } @@ -235,26 +246,18 @@ func checkSafety(srcDir string) string { // countChangedFiles returns the number of files changed vs the default branch. func countChangedFiles(srcDir string) int { - files, ok := changedFilesSinceDefault(srcDir) - if !ok { - return 0 - } - return len(files) -} - -func changedFilesSinceDefault(srcDir string) ([]string, bool) { base := defaultBranch(srcDir) cmd := exec.Command("git", "diff", "--name-only", core.Concat(base, "...HEAD")) cmd.Dir = srcDir out, err := cmd.Output() if err != nil { - return nil, false + return 0 } lines := core.Split(core.Trim(string(out)), "\n") if len(lines) == 1 && lines[0] == "" { - return nil, true + return 0 } - return lines, true + return len(lines) } // pushBranch pushes the agent's branch to origin. @@ -268,19 +271,6 @@ func pushBranch(srcDir, branch string) error { return nil } -func resultFileSize(r core.Result) (int64, bool) { - type sizable interface { - Size() int64 - } - - switch value := r.Value.(type) { - case sizable: - return value.Size(), true - default: - return 0, false - } -} - // updateStatus updates the workspace status.json. func updateStatus(wsDir, status, question string) { r := fs.Read(workspaceStatusPath(wsDir)) diff --git a/pkg/setup/config.go b/pkg/setup/config.go index 6fc7b55..bdd16b1 100644 --- a/pkg/setup/config.go +++ b/pkg/setup/config.go @@ -3,9 +3,9 @@ package setup import ( + neturl "net/url" "os/exec" "path/filepath" - "strings" core "dappco.re/go/core" "gopkg.in/yaml.v3" @@ -136,7 +136,7 @@ func GenerateTestConfig(projType ProjectType) (string, error) { } func renderConfig(comment string, sections []configSection) (string, error) { - var builder strings.Builder + builder := core.NewBuilder() if comment != "" { builder.WriteString("# ") @@ -157,7 +157,7 @@ func renderConfig(comment string, sections []configSection) (string, error) { builder.WriteString(" ") builder.WriteString(value.Key) builder.WriteString(": ") - builder.WriteString(strings.TrimSpace(string(scalar))) + builder.WriteString(core.Trim(string(scalar))) builder.WriteString("\n") } @@ -177,32 +177,31 @@ func detectGitRemote(path string) string { if err != nil { return "" } - url := strings.TrimSpace(string(output)) + return parseGitRemote(core.Trim(string(output))) +} - // SSH: git@github.com:owner/repo.git or ssh://git@forge.lthn.ai:2223/core/agent.git - if strings.Contains(url, ":") { - parts := strings.SplitN(url, ":", 2) - if len(parts) == 2 { - repo := parts[1] - repo = strings.TrimSuffix(repo, ".git") - // Handle port in SSH URL (ssh://git@host:port/path) - if strings.Contains(repo, "/") { - segments := strings.SplitN(repo, "/", 2) - if len(segments) == 2 && strings.ContainsAny(segments[0], "0123456789") { - repo = segments[1] - } - } - return repo - } +func parseGitRemote(remote string) string { + if remote == "" { + return "" } - // HTTPS: https://github.com/owner/repo.git - for _, host := range []string{"github.com/", "forge.lthn.ai/"} { - if idx := strings.Index(url, host); idx >= 0 { - repo := url[idx+len(host):] - return strings.TrimSuffix(repo, ".git") - } + if parsed, err := neturl.Parse(remote); err == nil && parsed.Host != "" { + return trimRemotePath(parsed.Path) + } + + parts := core.SplitN(remote, ":", 2) + if len(parts) == 2 && core.Contains(parts[0], "@") { + return trimRemotePath(parts[1]) + } + + if core.Contains(remote, "/") { + return trimRemotePath(remote) } return "" } + +func trimRemotePath(remote string) string { + trimmed := core.TrimPrefix(remote, "/") + return core.TrimSuffix(trimmed, ".git") +} diff --git a/pkg/setup/setup.go b/pkg/setup/setup.go index c25520b..a4e3cc2 100644 --- a/pkg/setup/setup.go +++ b/pkg/setup/setup.go @@ -3,10 +3,8 @@ package setup import ( - "fmt" "os" "path/filepath" - "strings" "dappco.re/go/agent/pkg/lib" core "dappco.re/go/core" @@ -106,7 +104,7 @@ func scaffoldTemplate(opts Options, projType ProjectType) error { data := &lib.WorkspaceData{ Repo: filepath.Base(opts.Path), Branch: "main", - Task: fmt.Sprintf("Initialise %s project tooling.", projType), + Task: core.Sprintf("Initialise %s project tooling.", projType), Agent: "setup", Language: string(projType), Prompt: "This workspace was scaffolded by pkg/setup. Review the repository and continue from the generated context files.", @@ -209,7 +207,7 @@ func defaultTestCommand(projType ProjectType) string { } func formatFlow(projType ProjectType) string { - var builder strings.Builder + builder := core.NewBuilder() builder.WriteString("- Build: `") builder.WriteString(defaultBuildCommand(projType)) builder.WriteString("`\n") diff --git a/pkg/setup/setup_test.go b/pkg/setup/setup_test.go index 2772348..4e60eb4 100644 --- a/pkg/setup/setup_test.go +++ b/pkg/setup/setup_test.go @@ -30,6 +30,26 @@ func TestGenerateBuildConfig_Good(t *testing.T) { assert.Contains(t, cfg, "cgo: false") } +func TestParseGitRemote_Good(t *testing.T) { + tests := map[string]string{ + "https://github.com/dAppCore/go-io.git": "dAppCore/go-io", + "git@github.com:dAppCore/go-io.git": "dAppCore/go-io", + "ssh://git@forge.lthn.ai:2223/core/agent.git": "core/agent", + "ssh://git@forge.lthn.ai:2223/core/agent": "core/agent", + "git@forge.lthn.ai:core/agent.git": "core/agent", + "/srv/git/core/agent.git": "srv/git/core/agent", + } + + for remote, want := range tests { + assert.Equal(t, want, parseGitRemote(remote), remote) + } +} + +func TestParseGitRemote_Bad(t *testing.T) { + assert.Equal(t, "", parseGitRemote("")) + assert.Equal(t, "", parseGitRemote("origin")) +} + func TestRun_Good(t *testing.T) { dir := t.TempDir() require.True(t, fs.WriteMode(filepath.Join(dir, "go.mod"), "module example.com/test\n", 0644).OK)