Cross-repo agent tasks - 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: Clone every Project repo into the agent workspace and deliver each repo's changes as its own branch + PR, so the agent can handle cross-repo issues.
Architecture: Operator passes the full Project repo set to the wrapper pod as one JSON env (TATARA_REPOS, primary first). The wrapper clones each repo into /workspace/<name>, checks out tatara/task-<task> in each, and on each turn commits+pushes every changed repo. The operator's write-back attempts a PR for tatara/task-<task> on every Project repo; repos without the branch 422 and are skipped; all opened PR links are posted on the issue. Session config moves to /workspace (parent, outside the repos), retiring the .git/info/exclude hack.
Tech Stack: Go 1.26 (operator), Go (wrapper); controller-runtime; testify; envtest (operator controller tests, KUBEBUILDER_ASSETS from setup-envtest).
Spec: docs/superpowers/specs/2026-06-09-cross-repo-agent-tasks-design.md
Shared contract (both repos depend on this exact shape): TATARA_REPOS is a JSON array, primary repo first:
[{"name":"tatara-cli","url":"https://github.com/szymonrychu/tatara-cli","branch":"main"},
{"name":"tatara-memory","url":"https://github.com/szymonrychu/tatara-memory","branch":"main"}]
Branch flow (both repos): worktree off main -> develop -> merge to main -> build/deploy from main. Bump chart appVersion + image; record in MEMORY.
File Structure¶
tatara-claude-code-wrapper: - cmd/wrapper/config.go - parse TATARA_REPOS into []bootstrap.RepoSpec. - internal/bootstrap/bootstrap.go - RepoSpec type; Params.Repos; GitRunner becomes dir-parameterized; Render clones each repo into /workspace/<name> + checkout per repo; drop the exclude call. - internal/bootstrap/repo.go - cloneRepo/configureGit/CommitAndPush take a dir; add CommitAndPushAll(repos, branch, msg, git). - internal/bootstrap/exclude.go - DELETE (config no longer inside a repo). - internal/bootstrap/exclude_test.go cases in enforce_test.go - remove the exclude test; add multi-repo tests. - cmd/wrapper/app.go - gitRunner becomes func(dir string, args ...string) error; OnTurnDone calls CommitAndPushAll.
tatara-operator: - internal/agent/pod.go - BuildPod takes the repo list; sets TATARA_REPOS env (primary first). - internal/controller/task_controller.go - pass all Project repos to BuildPod. - internal/controller/turnloop.go - planTurnText gains the cross-repo + clone-layout instruction. - internal/controller/writeback.go - write-back loops over all Project repos, opening a PR per repo (existing 422 handling skips repos without the branch); collect all PR URLs for the issue comment.
Wrapper Tasks¶
Task W1: TATARA_REPOS contract + parsing¶
Files: - Modify: internal/bootstrap/bootstrap.go (add RepoSpec, Params.Repos) - Modify: cmd/wrapper/config.go (parse env) - Test: cmd/wrapper/config_test.go
- Step 1: Write the failing test (config_test.go)
func TestLoadConfig_ParsesTataraRepos(t *testing.T) {
t.Setenv("TATARA_REPOS", `[{"name":"a","url":"https://h/a","branch":"main"},{"name":"b","url":"https://h/b","branch":"dev"}]`)
cfg, err := loadConfig(nil)
require.NoError(t, err)
require.Len(t, cfg.Repos, 2)
require.Equal(t, "a", cfg.Repos[0].Name)
require.Equal(t, "https://h/b", cfg.Repos[1].URL)
require.Equal(t, "dev", cfg.Repos[1].Branch)
}
- Step 2: Run, verify fail
Run: go test ./cmd/wrapper/ -run TestLoadConfig_ParsesTataraRepos Expected: FAIL (cfg.Repos undefined).
- Step 3: Implement - add to
internal/bootstrap/bootstrap.go:
// RepoSpec is one Project repo to clone into the workspace.
type RepoSpec struct {
Name string `json:"name"`
URL string `json:"url"`
Branch string `json:"branch"`
}
Repos []RepoSpec to Params. In cmd/wrapper/config.go add field Repos []bootstrap.RepoSpec and in loadConfig: if raw := os.Getenv("TATARA_REPOS"); raw != "" {
if err := json.Unmarshal([]byte(raw), &cfg.Repos); err != nil {
return config{}, fmt.Errorf("parse TATARA_REPOS: %w", err)
}
}
encoding/json import) -
Step 4: Run, verify pass. Run:
go test ./cmd/wrapper/ -run TestLoadConfig_ParsesTataraRepos. Expected: PASS. -
Step 5: Commit
git add cmd/wrapper/config.go cmd/wrapper/config_test.go internal/bootstrap/bootstrap.go
git commit -m "feat(config): parse TATARA_REPOS into RepoSpec list"
Task W2: dir-parameterized GitRunner¶
Files: - Modify: internal/bootstrap/bootstrap.go (GitRunner type, configureGit, checkout call) - Modify: internal/bootstrap/repo.go (cloneRepo, CommitAndPush take dir) - Modify: cmd/wrapper/app.go (gitRunner signature) - Test: existing internal/bootstrap/*_test.go updated
-
Step 1: Update the GitRunner type and all signatures. Change in bootstrap.go:
// GitRunner runs a git subcommand in dir; injected for testability. type GitRunner func(dir string, args ...string) errorconfigureGit(p, git)-> callsgit("", "config", "--global", ...)(global is dir-independent; pass "" or workspace).cloneRepo->git(p.Workspace, append([]string{"clone","--depth","1",...}, url, dest)...).CommitAndPush(dir, branch, msg, git)-> all callsgit(dir, ...). -
Step 2: Update existing tests to the new signature: every
func(a ...string) error { ... }fake becomesfunc(dir string, a ...string) error { ... }, recordingdirwhere asserted. Updateapp.go gitRunner:(callers pass the dir;func gitRunner() bootstrap.GitRunner { return func(dir string, args ...string) error { cmd := exec.Command("git", args...) //nolint:gosec cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git -C %s %v: %v: %w", dir, args, string(out), err) } return nil } }bootstrap.Render(params, gitRunner()).) -
Step 3: Run the bootstrap+cmd tests, fix until green.
Run: go test ./internal/bootstrap/ ./cmd/wrapper/ Expected: PASS (after updating fakes).
- Step 4: Commit
git add internal/bootstrap cmd/wrapper
git commit -m "refactor(bootstrap): GitRunner takes a dir (prep for multi-repo)"
Task W3: multi-repo clone + drop exclude; config in /workspace parent¶
Files: - Modify: internal/bootstrap/bootstrap.go (Render clone loop) - Delete: internal/bootstrap/exclude.go - Modify: internal/bootstrap/enforce_test.go (replace exclude test with multi-repo clone test) - Test: internal/bootstrap/enforce_test.go
- Step 1: Write the failing test (enforce_test.go) - replace
TestRender_ExcludesWrapperConfigFromGitwith:
func TestRender_ClonesEachRepoIntoSubdirAndChecksOutBranch(t *testing.T) {
ws := t.TempDir()
var calls [][]string // dir + args
p := bootstrap.Params{
HomeDir: t.TempDir(), Workspace: ws, BaseMCP: []byte(`{"mcpServers":{}}`),
TaskBranch: "tatara/task-x",
Repos: []bootstrap.RepoSpec{
{Name: "a", URL: "https://h/a", Branch: "main"},
{Name: "b", URL: "https://h/b", Branch: "dev"},
},
RepoURL: "https://h/a", HookCommand: "/x", PermissionMode: "bypassPermissions",
}
require.NoError(t, bootstrap.Render(p, func(dir string, a ...string) error {
calls = append(calls, append([]string{dir}, a...)); return nil
}))
joined := func() string { var s []string; for _, c := range calls { s = append(s, strings.Join(c, " ")) }; return strings.Join(s, "|") }()
require.Contains(t, joined, filepath.Join(ws, "a")+" clone")
require.Contains(t, joined, "https://h/a")
require.Contains(t, joined, filepath.Join(ws, "b")+" clone")
require.Contains(t, joined, "https://h/b")
// checkout the task branch inside each repo dir
require.Contains(t, joined, filepath.Join(ws, "a")+" checkout -b tatara/task-x")
require.Contains(t, joined, filepath.Join(ws, "b")+" checkout -b tatara/task-x")
// session config lives in the workspace PARENT, not inside a repo
b, _ := os.ReadFile(filepath.Join(ws, ".mcp.json"))
require.NotEmpty(t, b)
}
-
Step 2: Run, verify fail. Run:
go test ./internal/bootstrap/ -run TestRender_ClonesEachRepoIntoSubdir. Expected: FAIL. -
Step 3: Implement - in
Render, replace the single-repoif p.RepoURL != ""block with:
if len(p.Repos) > 0 {
if err := configureGit(p, git); err != nil { return err } // global creds/identity, once
for _, r := range p.Repos {
dest := filepath.Join(p.Workspace, r.Name)
args := []string{"clone", "--depth", "1"}
if r.Branch != "" { args = append(args, "--branch", r.Branch) }
args = append(args, r.URL, dest)
if err := git(p.Workspace, args...); err != nil {
if r.URL == p.RepoURL { return fmt.Errorf("clone primary repo %s: %w", r.Name, err) }
continue // non-primary clone failure: skip, agent works with the rest
}
if p.TaskBranch != "" {
if err := git(dest, "checkout", "-b", p.TaskBranch); err != nil {
if r.URL == p.RepoURL { return err }
}
}
}
}
internal/bootstrap/exclude.go and remove the excludeWorkspaceConfig call. (Session config writeIfSet(/workspace/CLAUDE.md), mergeMCP -> /workspace/.mcp.json, installSkills -> /workspace/.claude/skills are unchanged; they now sit in the parent, outside every repo.) -
Step 4: Run, verify pass. Run:
go test ./internal/bootstrap/. Expected: PASS (remove the deleted exclude test). -
Step 5: Commit
git rm internal/bootstrap/exclude.go
git add internal/bootstrap
git commit -m "feat(bootstrap): clone each repo into /workspace/<name>; config in parent; drop exclude"
Task W4: per-repo commit+push on each turn¶
Files: - Modify: internal/bootstrap/repo.go (add CommitAndPushAll) - Modify: cmd/wrapper/app.go (OnTurnDone uses it) - Test: internal/bootstrap/enforce_test.go
- Step 1: Write the failing test
func TestCommitAndPushAll_PushesEachRepoOnItsDir(t *testing.T) {
var calls [][]string
git := func(dir string, a ...string) error {
calls = append(calls, append([]string{dir}, a...))
if len(a) >= 3 && a[0] == "diff" && a[1] == "--cached" && a[2] == "--quiet" { return errors.New("changes") }
return nil
}
repos := []bootstrap.RepoSpec{{Name: "a"}, {Name: "b"}}
require.NoError(t, bootstrap.CommitAndPushAll("/ws", repos, "tatara/task-x", "msg", git))
var s []string; for _, c := range calls { s = append(s, strings.Join(c, " ")) }
all := strings.Join(s, "|")
require.Contains(t, all, "/ws/a push -u origin tatara/task-x")
require.Contains(t, all, "/ws/b push -u origin tatara/task-x")
}
-
Step 2: Run, verify fail. Run:
go test ./internal/bootstrap/ -run TestCommitAndPushAll. Expected: FAIL (undefined). -
Step 3: Implement in repo.go:
// CommitAndPushAll runs CommitAndPush in each repo dir under workspace.
func CommitAndPushAll(workspace string, repos []RepoSpec, branch, message string, git GitRunner) error {
for _, r := range repos {
if err := CommitAndPush(filepath.Join(workspace, r.Name), branch, message, git); err != nil {
return fmt.Errorf("commit/push %s: %w", r.Name, err)
}
}
return nil
}
CommitAndPush(dir, branch, msg, git) is the existing logic with git(dir, ...). (add path/filepath, fmt imports if needed.) In app.go OnTurnDone: if len(cfg.Repos) > 0 {
if err := bootstrap.CommitAndPushAll(cfg.Workspace, repos(cfg), cfg.TaskBranch, "tatara agent: "+cfg.TaskBranch, gitRunner()); err != nil {
log.Error("commit/push failed", "error", err)
}
}
repos(cfg) returns cfg.Repos as []bootstrap.RepoSpec (already that type). -
Step 4: Run, verify pass + full module. Run:
go test ./.... Expected: PASS. -
Step 5: Commit
git add internal/bootstrap cmd/wrapper
git commit -m "feat(bootstrap): commit+push every changed repo each turn"
Task W5: build + ship wrapper¶
- Bump
charts/tatara-claude-code-wrapper/Chart.yamlappVersion ->0.1.5, version ->0.1.6; MEMORY entry. -
pre-commit/lint/gofmt/go test ./...green; requesting-code-review; merge to main. - Build+push
tatara-claude-code-wrapper:0.1.5(--build-arg TATARA_CLI_VERSION=0.4.0).
Operator Tasks¶
Task O1: TATARA_REPOS on the agent pod¶
Files: - Modify: internal/agent/pod.go (BuildPod takes repos; sets env) - Modify: internal/controller/task_controller.go (pass project repos) - Test: internal/agent/pod_test.go
- Step 1: Write the failing test (pod_test.go) - assert
TATARA_REPOSenv is the JSON of the passed repos, primary first:
func TestBuildPod_SetsTataraRepos(t *testing.T) {
proj, repo, task, cfg := sampleInputs()
repos := []tatarav1alpha1.Repository{
{ObjectMeta: metav1.ObjectMeta{Name: "repo1"}, Spec: tatarav1alpha1.RepositorySpec{URL: "https://git/acme/repo1", DefaultBranch: "main"}},
{ObjectMeta: metav1.ObjectMeta{Name: "repo2"}, Spec: tatarav1alpha1.RepositorySpec{URL: "https://git/acme/repo2", DefaultBranch: "dev"}},
}
c := agent.BuildPod(proj, repo, task, repos, testMemoryEndpoint, cfg).Spec.Containers[0]
v, ok := envValue(c, "TATARA_REPOS")
require.True(t, ok)
var got []map[string]string
require.NoError(t, json.Unmarshal([]byte(v), &got))
require.Equal(t, "repo1", got[0]["name"]) // primary (the task's repo) first
require.Equal(t, "https://git/acme/repo2", got[1]["url"])
}
-
Step 2: Run, verify fail. Run:
KUBEBUILDER_ASSETS=$(setup-envtest use 1.33.0 -p path) go test ./internal/agent/ -run TestBuildPod_SetsTataraRepos. Expected: FAIL (signature/env). -
Step 3: Implement - change
BuildPodto acceptrepos []tatarav1alpha1.Repository; build JSON with the task's repo first:
type repoEntry struct{ Name, URL, Branch string }
entries := []repoEntry{{repo.Name, repo.Spec.URL, repo.Spec.DefaultBranch}}
for i := range repos {
if repos[i].Name == repo.Name { continue }
entries = append(entries, repoEntry{repos[i].Name, repos[i].Spec.URL, repos[i].Spec.DefaultBranch})
}
buf, _ := json.Marshal(structSliceToJSONTags(entries)) // json tags name/url/branch
// env: {Name: "TATARA_REPOS", Value: string(buf)}
json:"name|url|branch" tags. Update the task_controller.go call: list repos client.MatchingFields/filter by ProjectRef == project.Name, pass to BuildPod.) -
Step 4: Run, verify pass. Expected: PASS. Update other
BuildPod(...)test call sites to the new signature (passnilor a one-repo slice). -
Step 5: Commit
git add internal/agent internal/controller
git commit -m "feat(agent): pass all Project repos to the pod as TATARA_REPOS"
Task O2: cross-repo prompt¶
Files: - Modify: internal/controller/turnloop.go - Test: internal/controller/turnloop_test.go
- Step 1: Failing test
func TestPlanTurnText_MentionsAllReposCloned(t *testing.T) {
txt := planTurnText("do x", "tatara/task-abc", "proj1", "task-abc")
low := strings.ToLower(txt)
require.Contains(t, low, "/workspace/")
require.Contains(t, low, "each repo you")
}
-
Step 2: Run, verify fail.
KUBEBUILDER_ASSETS=... go test ./internal/controller/ -run TestPlanTurnText_MentionsAllReposCloned. FAIL. -
Step 3: Implement - extend
planTurnText's direct-implementation paragraph with: "All Project repos are cloned under/workspace/<name>(primary: this task's repo). Make changes in whatever repos the issue requires; each repo you change is committed and pushed totatara/task-<task>and gets its own PR." -
Step 4: Run, verify pass. Keep the existing planTurnText tests green.
-
Step 5: Commit
git commit -am "feat(turnloop): tell the agent all repos are cloned and each gets a PR"
Task O3: multi-repo write-back¶
Files: - Modify: internal/controller/writeback.go (loop repos, open PR per repo) - Modify: internal/controller/task_controller.go (pass project repos to write-back) - Test: internal/controller/writeback_test.go
- Step 1: Failing test - with a fake SCM that reports a branch on two repos, write-back opens 2 PRs and the issue comment contains both URLs:
func TestWriteback_OpensPRPerRepoWithBranch(t *testing.T) {
// fake writer: OpenChange returns a PR URL for repos "o/r1","o/r2", 422 for "o/r3"
// run writeback for task branch over repos [r1,r2,r3]
// assert: 2 PRs collected; comment body contains both URLs
}
scm.Writer keyed by repo.) -
Step 2: Run, verify fail.
-
Step 3: Implement - in the write-back path, replace the single
OpenChangefor the primary with a loop over all Project repos: for each, attemptwriter.OpenChange(ctx, token, <repo remote>, taskBranch, title, body); a 422 (branch missing / no diff) is skipped (already handled); collect opened URLs. Comment the issue (primarySource.IssueRef) with all URLs joined. Extend Task status to hold[]stringPR URLs (or a newline list in the existing field). -
Step 4: Run, verify pass + full suite.
KUBEBUILDER_ASSETS=... go test ./.... PASS. -
Step 5: Commit
git commit -am "feat(writeback): open a PR per changed Project repo; comment all links"
Task O4: build + ship operator¶
- Bump operator chart
0.2.8->0.2.9; MEMORY entry. - Full suite (
KUBEBUILDER_ASSETS) + lint/gofmt green; requesting-code-review; merge to main. - Build+push
tatara-operator:0.2.9+ push chart0.2.9.
Integration / deploy / validate¶
- Infra MR: bump operator chart pin + image to
0.2.9; apply. - Patch Project
spec.agent.image-> wrapper0.1.5(+ deploy-samples manifest). - Create a cross-repo issue on
tatara-cli(e.g., "add a NOTE to both tatara-cli and tatara-memory READMEs"); confirm: one Task; pod clones all repos under/workspace/<name>; PRs opened on BOTH changed repos ontatara/task-<task>; issue commented with both PR links. - Confirm a single-repo issue still works (only one PR opened).
Self-review notes¶
- Spec coverage: A (W3 layout + parent config + drop exclude), B (O1 env + O2 prompt), C (W2 dir-runner, W3 clone-loop, W4 per-repo push), D (O3 write-back loop), E (no change). Error handling: W3 primary-vs-non-primary clone failure; W4 best-effort per repo; O3 per-repo independent (422 skip). All covered.
- Type consistency:
RepoSpec{Name,URL,Branch}(wrapper) and theTATARA_REPOSJSON tagsname/url/branchmatch O1's marshalled struct.GitRunner func(dir string, args ...string) errorused consistently W2-W4.