Agent-native tatara tools + memory robustness - 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: Let the spawned agent use tatara's own MCP tools natively (headless auth + MCP registration + operator URL), and harden the per-project memory against a postgres blip.
Architecture: tatara-cli gains a headless OIDC client-credentials fallback (env-driven) so tatara raw/tatara mcp work with no tatara login. The operator injects TATARA_OPERATOR_URL and clears the stale MemoryNotReady condition. The wrapper runs tatara mcp-config /workspace at bootstrap to register the server. tatara-memory waits for postgres at startup instead of crashlooping.
Tech Stack: Go (all repos); controller-runtime + envtest (operator); testify.
Spec: docs/superpowers/specs/2026-06-09-agent-native-tatara-tools-design.md
Branch flow (every repo): worktree off main -> TDD -> lint/gofmt/test green -> merge to main. The parent owns image builds + deploy.
Shared env contract (operator emits, CLI reads): OIDC_ISSUER, CLI_OIDC_CLIENT_ID, CLI_OIDC_CLIENT_SECRET (auth); TATARA_MEMORY_URL (memory REST), TATARA_OPERATOR_URL (operator REST).
tatara-cli (A1: headless auth)¶
Repo: /Users/szymonri/Documents/tatara/tatara-cli
Task C1: client-credentials token source¶
Files: - Create: internal/auth/clientcreds.go - Test: internal/auth/clientcreds_test.go - Read first: internal/auth/token.go (how the stored token is loaded + the Token/error shape).
- Step 1: Write the failing test (clientcreds_test.go) - a fake OIDC server with a discovery doc + token endpoint; assert a client_credentials grant returns the token and parses expiry:
func TestClientCredentialsToken(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"token_endpoint":"http://%s/token"}`, r.Host)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
require.Equal(t, "client_credentials", r.Form.Get("grant_type"))
require.Equal(t, "cid", r.Form.Get("client_id"))
require.Equal(t, "secret", r.Form.Get("client_secret"))
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"access_token":"tok-123","expires_in":300,"token_type":"Bearer"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
tok, exp, err := auth.ClientCredentialsToken(context.Background(), srv.URL, "cid", "secret")
require.NoError(t, err)
require.Equal(t, "tok-123", tok)
require.WithinDuration(t, time.Now().Add(300*time.Second), exp, 5*time.Second)
}
-
Step 2: Run, verify fail.
go test ./internal/auth/ -run TestClientCredentialsToken. FAIL (undefined). -
Step 3: Implement (clientcreds.go):
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// ClientCredentialsToken performs an OIDC client_credentials grant against the
// issuer's discovered token endpoint and returns the access token + expiry.
func ClientCredentialsToken(ctx context.Context, issuer, clientID, clientSecret string) (string, time.Time, error) {
disco := strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration"
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, disco, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf("oidc discovery: %w", err)
}
defer resp.Body.Close()
var meta struct {
TokenEndpoint string `json:"token_endpoint"`
}
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil || meta.TokenEndpoint == "" {
return "", time.Time{}, fmt.Errorf("oidc discovery: no token_endpoint")
}
form := url.Values{"grant_type": {"client_credentials"}, "client_id": {clientID}, "client_secret": {clientSecret}}
treq, _ := http.NewRequestWithContext(ctx, http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
treq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
tresp, err := http.DefaultClient.Do(treq)
if err != nil {
return "", time.Time{}, fmt.Errorf("token request: %w", err)
}
defer tresp.Body.Close()
if tresp.StatusCode != http.StatusOK {
return "", time.Time{}, fmt.Errorf("token request: status %d", tresp.StatusCode)
}
var tr struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(tresp.Body).Decode(&tr); err != nil {
return "", time.Time{}, fmt.Errorf("token decode: %w", err)
}
return tr.AccessToken, time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second), nil
}
-
Step 4: Run, verify pass.
go test ./internal/auth/ -run TestClientCredentialsToken. PASS. -
Step 5: Commit
git add internal/auth/clientcreds.go internal/auth/clientcreds_test.go && git commit -m "feat(auth): OIDC client_credentials token source"
Task C2: wire the headless fallback into token acquisition¶
Files: - Modify: internal/auth/token.go (the function that returns the token for HTTP calls) - Test: internal/auth/clientcreds_test.go
-
Step 1: Read
internal/auth/token.goto find the exported "load token" function used byclient(e.g.Load()/Token()), and howErrNoTokenis returned. -
Step 2: Write the failing test - with no stored token but env set, the loader returns the client-creds token; a cached token is reused until near expiry:
func TestTokenFallsBackToClientCreds(t *testing.T) {
// fake issuer as in C1
srv := newFakeIssuer(t) // helper returning a server that issues "tok-123"
defer srv.Close()
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no stored token
t.Setenv("OIDC_ISSUER", srv.URL)
t.Setenv("CLI_OIDC_CLIENT_ID", "cid")
t.Setenv("CLI_OIDC_CLIENT_SECRET", "secret")
auth.ResetTokenCache() // test helper to clear the package cache
tok, err := auth.AccessToken(context.Background())
require.NoError(t, err)
require.Equal(t, "tok-123", tok)
}
func TestTokenNoEnvStillErrNoToken(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("OIDC_ISSUER", "")
auth.ResetTokenCache()
_, err := auth.AccessToken(context.Background())
require.ErrorIs(t, err, auth.ErrNoToken)
}
newFakeIssuer helper.) -
Step 3: Run, verify fail.
-
Step 4: Implement - add an
AccessToken(ctx)(or fold into the existing accessor) that: returns the stored token if present; else ifOIDC_ISSUER+CLI_OIDC_CLIENT_ID+CLI_OIDC_CLIENT_SECRETare set, returns a package-cached client-creds token (mutex-guarded; re-mint whentime.Now()is within 30s of expiry); elseErrNoToken. AddResetTokenCache()for tests. Update the HTTP client (internal/client) to callAccessTokenwhere it currently loads the stored token.
var (
ccMu sync.Mutex
ccTok string
ccExp time.Time
)
func AccessToken(ctx context.Context) (string, error) {
if tok, err := loadStoredToken(); err == nil && tok != "" {
return tok, nil
}
issuer, id, secret := os.Getenv("OIDC_ISSUER"), os.Getenv("CLI_OIDC_CLIENT_ID"), os.Getenv("CLI_OIDC_CLIENT_SECRET")
if issuer == "" || id == "" || secret == "" {
return "", ErrNoToken
}
ccMu.Lock()
defer ccMu.Unlock()
if ccTok != "" && time.Now().Before(ccExp.Add(-30*time.Second)) {
return ccTok, nil
}
tok, exp, err := ClientCredentialsToken(ctx, issuer, id, secret)
if err != nil {
return "", err
}
ccTok, ccExp = tok, exp
return tok, nil
}
func ResetTokenCache() { ccMu.Lock(); ccTok, ccExp = "", time.Time{}; ccMu.Unlock() }
loadStoredToken to the real stored-token accessor in token.go.) -
Step 5: Run, verify pass + full module.
go test ./.... PASS. -
Step 6: Commit
git commit -am "feat(auth): headless client-credentials fallback for raw + mcp"
Task C3: ship tatara-cli¶
-
gofmt/golangci-lint/go test ./...green; requesting-code-review; bump no chart (CLI has none); merge to main. (Image rebuild owned by parent: the wrapper image bakes tatara-cli atTATARA_CLI_VERSION.)
tatara-operator (A2 + B2)¶
Repo: /Users/szymonri/Documents/tatara/tatara-operator Envtest: export KUBEBUILDER_ASSETS="/Users/szymonri/Library/Application Support/io.kubebuilder.envtest/k8s/1.36.0-darwin-arm64"
Task O1: TATARA_OPERATOR_URL on the agent pod¶
Files: - Modify: internal/agent/pod.go (PodConfig + env), internal/config/config.go (OPERATOR_URL), internal/controller/*wire*.go (populate PodConfig) - Test: internal/agent/pod_test.go
- Step 1: Failing test - add to the env-check map in
TestBuildPod_PlainEnv(and setcfg.OperatorURLinsampleInputs/testCfg):
-
Step 2: Run, verify fail.
KUBEBUILDER_ASSETS=... go test ./internal/agent/ -run TestBuildPod_PlainEnv. FAIL (env missing). -
Step 3: Implement - add
OperatorURL stringtoagent.PodConfig; inBuildPodenv add{Name: "TATARA_OPERATOR_URL", Value: cfg.OperatorURL}. Ininternal/config/config.goaddOperatorURL stringfromos.Getenv("OPERATOR_URL")(defaulthttp://tatara-operator.tatara.svc:8080); thread it into thePodConfigbuilt in the manager wiring. UpdatetestCfg()/sampleInputs()to set OperatorURL. -
Step 4: Run, verify pass. Update any other PodConfig construction sites.
-
Step 5: Commit
git commit -am "feat(agent): inject TATARA_OPERATOR_URL into the agent pod"
Task O2: clear stale MemoryNotReady when memory is Ready¶
Files: - Modify: internal/controller/repository_controller.go (where MemoryNotReady is set) - Test: internal/controller/repository_controller_test.go (envtest)
-
Step 1: Read the reconcile path that sets
MemoryNotReady(searchMemoryNotReady). It is set whenproject.Status.Memory == nil || phase != "Ready". -
Step 2: Failing test - a Repository whose Project has
memory.phase=Readyends reconcile withMemoryNotReadyconditionFalse:
func TestRepository_ClearsMemoryNotReadyWhenReady(t *testing.T) {
// seed Project with Status.Memory.Phase = "Ready" + a Repository;
// reconcile; assert findCond(repo.Status.Conditions,"MemoryNotReady").Status == False
}
-
Step 3: Run, verify fail.
-
Step 4: Implement - in the reconcile branch where memory IS Ready (before/at ingest), set the condition False:
meta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{
Type: "MemoryNotReady", Status: metav1.ConditionFalse,
Reason: "MemoryReady", Message: "project memory stack is Ready",
ObservedGeneration: repo.Generation,
})
r.Status().Update. -
Step 5: Run, verify pass + full suite.
KUBEBUILDER_ASSETS=... go test ./.... -
Step 6: Commit
git commit -am "fix(repository): clear MemoryNotReady once project memory is Ready"
Task O3: ship operator¶
- Bump chart
0.2.10->0.2.11; MEMORY entry; full suite + lint green; requesting-code-review; merge to main.
tatara-claude-code-wrapper (A3)¶
Repo: /Users/szymonri/Documents/tatara/tatara-claude-code-wrapper
Task W1: run tatara mcp-config after bootstrap¶
Files: - Modify: cmd/wrapper/app.go (after bootstrap.Render, register MCP), internal/bootstrap/bootstrap.go (optional RegisterTataraMCP helper) - Test: internal/bootstrap/enforce_test.go or a new mcp_register_test.go
- Step 1: Failing test - a
RegisterTataraMCP(workspace, run)helper invokestatara mcp-config <workspace>via an injected runner, only when enabled:
func TestRegisterTataraMCP_RunsMcpConfig(t *testing.T) {
var calls [][]string
run := func(name string, args ...string) error { calls = append(calls, append([]string{name}, args...)); return nil }
require.NoError(t, bootstrap.RegisterTataraMCP("/workspace", run))
require.Len(t, calls, 1)
require.Equal(t, []string{"tatara", "mcp-config", "/workspace"}, calls[0])
}
-
Step 2: Run, verify fail.
-
Step 3: Implement - add to bootstrap:
// CmdRunner runs an external command; injected for testability.
type CmdRunner func(name string, args ...string) error
// RegisterTataraMCP merges the tatara MCP server into the workspace .mcp.json
// via the tatara CLI's own mcp-config command.
func RegisterTataraMCP(workspace string, run CmdRunner) error {
return run("tatara", "mcp-config", workspace)
}
app.go, after bootstrap.Render(...), when os.Getenv("TATARA_MEMORY_URL") != "" and a tatara binary is on PATH (exec.LookPath("tatara") succeeds), call bootstrap.RegisterTataraMCP(cfg.Workspace, execRunner) where execRunner runs the command with the pod env and logs (non-fatal on error). Skip otherwise. -
Step 4: Run, verify pass + full module.
go test ./.... -
Step 5: Commit
git commit -am "feat(bootstrap): register the tatara MCP server via mcp-config at boot"
Task W2: ship wrapper¶
- Bump chart appVersion
0.1.5->0.1.6, version0.1.6->0.1.7; MEMORY entry; gofmt/lint/test green; merge to main.
tatara-memory (B1)¶
Repo: /Users/szymonri/Documents/tatara/tatara-memory
Task M1: wait for postgres at startup¶
Files: - Modify: cmd/tatara-memory/app.go (the DB-open path in newAppWithDeps/run) - Test: cmd/tatara-memory/app_test.go
-
Step 1: Read
cmd/tatara-memory/app.goopenDB/ where the pool is opened, and confirm whether a failedPingcurrently aborts startup. -
Step 2: Failing test - a
waitForDB(ctx, ping, timeout, interval)retries a ping that fails N times then succeeds, and returns nil; gives up after timeout:
func TestWaitForDB_RetriesThenSucceeds(t *testing.T) {
n := 0
ping := func(context.Context) error { n++; if n < 3 { return errors.New("refused") }; return nil }
require.NoError(t, waitForDB(context.Background(), ping, time.Second, 5*time.Millisecond))
require.GreaterOrEqual(t, n, 3)
}
func TestWaitForDB_TimesOut(t *testing.T) {
ping := func(context.Context) error { return errors.New("refused") }
require.Error(t, waitForDB(context.Background(), ping, 20*time.Millisecond, 5*time.Millisecond))
}
-
Step 3: Run, verify fail.
-
Step 4: Implement
waitForDB:
func waitForDB(ctx context.Context, ping func(context.Context) error, timeout, interval time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if err := ping(ctx); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("database not reachable within %s", timeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
}
waitForDB(ctx, db.PingContext, 60*time.Second, 2*time.Second) before migrate/serve, so a transient pg outage retries instead of exiting. Keep pool.Resume errors non-fatal (already WARN-logged). -
Step 5: Run, verify pass + full module.
go test ./.... -
Step 6: Commit
git commit -am "fix(startup): wait for postgres with backoff instead of crashlooping"
Task M2: ship tatara-memory¶
- Bump chart
0.2.3->0.2.4appVersion/version; MEMORY entry; gofmt/lint/test green; merge to main.
Integration (parent-owned)¶
- Terraform verify (A1 dependency): inspect
infra/terraform/keycloak/tatara_clients.tf- confirm thetatara-cli-oidcconfidential client has service accounts enabled + audience mappers fortatara-memoryANDtatara-operator. If missing, add them (gatedterraform plan-> apply). - Build+push images:
tatara-cli:0.4.1(or rebuild wrapper with it),tatara-claude-code-wrapper:0.1.6(--build-arg TATARA_CLI_VERSION=<cli>),tatara-operator:0.2.11+ chart,tatara-memory:0.2.4. - Bump per-Project
memoryImage(0.2.4) + operator chart/image (0.2.11) in infra; patch Projectagent.image-> wrapper 0.1.6; apply; infra MRs. - Validate: re-run issue #10-style "check all MCP tools" - confirm the agent's report now shows the tatara MCP server REGISTERED and tools CALLABLE (not "shelled out"); confirm a multi-step issue produces real Subtask CRs; confirm
MemoryNotReadyis cleared on repos and/queriesanswers.
Self-review notes¶
- Spec coverage: A1 (C1+C2), A2 (O1), A3 (W1), B1 (M1), B2 (O2), terraform verify (Integration), /queries recheck (Integration validate). All covered.
- Type consistency:
AccessToken(ctx)/ClientCredentialsToken/ResetTokenCache(cli);PodConfig.OperatorURL+TATARA_OPERATOR_URL(operator);RegisterTataraMCP/CmdRunner(wrapper);waitForDB(memory) used consistently. - Env names match the shared contract in both producers (operator) and consumer (cli):
OIDC_ISSUER,CLI_OIDC_CLIENT_ID/SECRET,TATARA_MEMORY_URL,TATARA_OPERATOR_URL.