feat: devops plugin, CLI commands, Codex dispatch fixes, AX sweep

DevOps plugin (5 skills):
- install-core-agent, repair-core-agent, merge-workspace,
  update-deps, clean-workspaces

CLI commands: version, check, extract for diagnostics.

Codex dispatch: --skip-git-repo-check, removed broken
--model-reasoning-effort, --sandbox workspace-write via
--full-auto. Workspace template extracts to wsDir not srcDir.

AX sweep (Codex-generated): sanitise.go extracted from prep/plan,
mirror.go JSON parsing via encoding/json, setup/config.go URL
parsing via net/url, strings/fmt imports eliminated from setup.

CODEX.md template updated with Env/Path patterns.
Review workspace template with audit-only PROMPT.md.
Marketplace updated with devops plugin.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-22 13:30:27 +00:00
parent 98baa1b08f
commit 6e37bd22f0
24 changed files with 719 additions and 153 deletions

View file

@ -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"
}
]
}

View file

@ -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"
}
}

View file

@ -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/<name>/
```
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

View file

@ -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

View file

@ -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: <workspace-name>
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/<name>/
```
2. Check status:
```bash
cat /Users/snider/Code/.core/workspace/<name>/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/<name>/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/<repo>/
```
6. Cherry-pick or apply the changes. Use `git diff` to create a patch and apply it:
```bash
cd /Users/snider/Code/.core/workspace/<name>/src && git diff HEAD > /tmp/agent-patch.diff
cd /Users/snider/Code/core/<repo>/ && git apply /tmp/agent-patch.diff
```
7. Verify the build passes:
```bash
cd /Users/snider/Code/core/<repo>/ && 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

View file

@ -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

View file

@ -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/<repo>/
```
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)

View file

@ -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,

View file

@ -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,
})

View file

@ -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
}

View file

@ -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"))

View file

@ -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)

View file

@ -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++

View file

@ -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", "")

96
pkg/agentic/sanitise.go Normal file
View file

@ -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])
}

View file

@ -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)

View file

@ -1,4 +0,0 @@
.idea/
.vscode/
*.log
.core/workspace

View file

@ -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

View file

@ -1,3 +1,3 @@
go 1.26.0
use .
use ./src

View file

@ -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.

View file

@ -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))

View file

@ -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")
}

View file

@ -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")

View file

@ -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)