From d4140ee24480e1e0fcffbd32725a988d1abcb757 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 14 Apr 2026 14:06:47 -0700 Subject: [PATCH] feat(platform): generic category_routing replaces hardcoded audit dispatch (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a category_routing block to org.yaml schema (defaults + per-workspace, UNION semantics with per-key replace). The merged routing table is rendered into each workspace's config.yaml at import time. PM's system prompt loses the hardcoded security/ui/infra → role mapping from PR #50; instead it reads category_routing from /configs/config.yaml and delegates to whatever roles the org template lists for the incoming audit-summary's category. Future org templates ship their own routing without prompt churn. Tests: 4 new TestCategoryRouting_* cases covering YAML parse, UNION+drop semantics, deterministic config.yaml render, and empty-map handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- org-templates/molecule-dev/org.yaml | 16 +++ .../molecule-dev/pm/system-prompt.md | 17 +-- platform/internal/handlers/org.go | 112 +++++++++++++++- platform/internal/handlers/org_test.go | 121 ++++++++++++++++++ 4 files changed, 251 insertions(+), 15 deletions(-) diff --git a/org-templates/molecule-dev/org.yaml b/org-templates/molecule-dev/org.yaml index 849f8b99..2bd885f0 100644 --- a/org-templates/molecule-dev/org.yaml +++ b/org-templates/molecule-dev/org.yaml @@ -39,6 +39,22 @@ defaults: - molecule-session-context - molecule-skill-cron-learnings - molecule-skill-update-docs + + # Audit-summary routing — generic per-template mapping (issue #51). + # Auditors (Security Auditor, UIUX Designer, QA Engineer) send A2A messages + # with metadata.audit_summary.category set. The receiver (PM) reads this + # table from its own /configs/config.yaml and delegates to each listed role. + # Each org template owns its own mapping — role names are NOT hardcoded in + # prompts, so adding/renaming roles is a config-only change. + category_routing: + security: [Backend Engineer, DevOps Engineer] + ui: [Frontend Engineer] + ux: [Frontend Engineer] + infra: [DevOps Engineer] + qa: [QA Engineer] + performance: [Backend Engineer] + mixed: [Dev Lead] + # workspace_dir: not set by default — each agent gets an isolated Docker volume # Set per-workspace to bind-mount a host directory as /workspace diff --git a/org-templates/molecule-dev/pm/system-prompt.md b/org-templates/molecule-dev/pm/system-prompt.md index 3de160e5..06a551f6 100644 --- a/org-templates/molecule-dev/pm/system-prompt.md +++ b/org-templates/molecule-dev/pm/system-prompt.md @@ -27,19 +27,16 @@ Security Auditor, UIUX Designer, and QA Engineer run hourly/half-daily audit cro - counts by severity (critical / high / medium / low / clean) - **list of GitHub issue numbers filed this cycle** - top recommendation +- **`metadata.audit_summary.category`** on the A2A message (set by the auditor) **Every such arrival with issue numbers is a dispatch trigger, not FYI.** The moment you receive one: -1. For each issue number in the summary, `gh issue view ` to read the full body and category. -2. Route each issue to the right dev agent by category: - - `security(...)`, auth, crypto, SQL/RCE/path-traversal, missing access control → **Backend Engineer** - - `ui`, `ux`, theme, a11y, keyboard-nav, WCAG → **Frontend Engineer** - - `infra`, Dockerfile, CI, provisioner, secrets, ops, deployment → **DevOps Engineer** - - test suite / coverage / flake / regression → **QA Engineer** - - mixed / unclear → **Dev Lead** to split further. -3. Delegate with a specific brief: issue number, proposed fix scope, acceptance criteria (close #N via `Closes #N` in PR, CI green, tests added if applicable, no `main` commits). -4. Use parallel `delegate_task_async` when issues span multiple categories — don't serialize what can be concurrent. -5. Track the fan-out. End of cycle, summary back to memory: "audit dispatched N issues, M still in flight, P landed as PRs #…". +1. **Look up the routing table.** Read `/configs/config.yaml` and find the `category_routing:` block. It maps each `category` (e.g. `security`, `ui`, `infra`) to a list of role names — these are the roles you should delegate to. The mapping is owned by the org template, not by this prompt; do not hardcode role names from memory. +2. For each issue number in the summary, `gh issue view ` to read the full body and category. The issue's `` label / title prefix should match a key in `category_routing`. +3. **Look up the category in your routing table** and `delegate_task` (or parallel `delegate_task_async` for multi-issue summaries) to **every role listed for that category**. If multiple roles are listed, delegate to all of them in parallel — that's the org's policy for that category. +4. **If the category is not in the routing table:** log it (`commit_memory` with key `audit-routing-miss-`), ack the auditor with "no routing rule for category=``; flagging for CEO", and move on. Do not invent a role to send it to. +5. Delegate with a specific brief: issue number, proposed fix scope, acceptance criteria (close #N via `Closes #N` in PR, CI green, tests added if applicable, no `main` commits). +6. Track the fan-out. End of cycle, summary back to memory: "audit dispatched N issues, M still in flight, P landed as PRs #…". **Clean cycles** (audit summary says "clean on SHA X", zero issue numbers) — acknowledge only; no delegation needed. diff --git a/platform/internal/handlers/org.go b/platform/internal/handlers/org.go index 77973edb..b9273c49 100644 --- a/platform/internal/handlers/org.go +++ b/platform/internal/handlers/org.go @@ -58,11 +58,16 @@ type OrgTemplate struct { } type OrgDefaults struct { - Runtime string `yaml:"runtime" json:"runtime"` - Tier int `yaml:"tier" json:"tier"` - Model string `yaml:"model" json:"model"` - Plugins []string `yaml:"plugins" json:"plugins"` - InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"` + Runtime string `yaml:"runtime" json:"runtime"` + Tier int `yaml:"tier" json:"tier"` + Model string `yaml:"model" json:"model"` + Plugins []string `yaml:"plugins" json:"plugins"` + InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"` + // CategoryRouting maps issue/audit category → list of target roles. + // Per-workspace blocks UNION + override per-key with these defaults. + // Rendered into each workspace's config.yaml so agent prompts can read it + // generically (no hardcoded role names in prompts). See issue #51. + CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"` } type OrgSchedule struct { @@ -96,6 +101,9 @@ type OrgWorkspace struct { WorkspaceAccess string `yaml:"workspace_access" json:"workspace_access"` // #65: "none" (default), "read_only", "read_write" Plugins []string `yaml:"plugins" json:"plugins"` InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"` + // CategoryRouting overrides/extends defaults.category_routing per-workspace. + // UNION semantics on the keys (mirroring plugin merge in #68). + CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"` Schedules []OrgSchedule `yaml:"schedules" json:"schedules"` Channels []OrgChannel `yaml:"channels" json:"channels"` External bool `yaml:"external" json:"external"` @@ -369,6 +377,23 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa } } + // Render category_routing into config.yaml so the agent can read its routing + // table at runtime without hardcoded role names in prompts (issue #51). + // Per-workspace block UNIONs + per-key overrides defaults (mirrors plugin merge). + routing := mergeCategoryRouting(defaults.CategoryRouting, ws.CategoryRouting) + if len(routing) > 0 { + if configFiles == nil { + configFiles = map[string][]byte{} + } + block, err := renderCategoryRoutingYAML(routing) + if err != nil { + log.Printf("Org import: failed to render category_routing for %s: %v", ws.Name, err) + } else { + existing := configFiles["config.yaml"] + configFiles["config.yaml"] = append(existing, []byte(block)...) + } + } + // Inject initial_prompt into config.yaml (workspace-level overrides default) initialPrompt := ws.InitialPrompt if initialPrompt == "" { @@ -642,6 +667,83 @@ func parseEnvFile(path string, out map[string]string) { } } +// mergeCategoryRouting unions defaults.category_routing with per-workspace +// category_routing. Workspace-level keys override the default's value for that +// key (the role list is replaced wholesale, not unioned per-key, so a workspace +// can narrow a category — e.g. "infra: [DevOps Only]"). Empty role lists drop +// the category entirely. See issue #51. +func mergeCategoryRouting(defaultRouting, wsRouting map[string][]string) map[string][]string { + out := map[string][]string{} + for k, v := range defaultRouting { + if k == "" || len(v) == 0 { + continue + } + cp := make([]string, len(v)) + copy(cp, v) + out[k] = cp + } + for k, v := range wsRouting { + if k == "" { + continue + } + if len(v) == 0 { + // Empty list = explicit "drop this category for this workspace" + delete(out, k) + continue + } + cp := make([]string, len(v)) + copy(cp, v) + out[k] = cp + } + return out +} + +// renderCategoryRoutingYAML emits a deterministic YAML block of the form: +// +// category_routing: +// security: ["Backend Engineer", "DevOps"] +// ui: ["Frontend Engineer"] +// +// Keys are sorted for stable output (test-friendly + diff-friendly). +func renderCategoryRoutingYAML(routing map[string][]string) (string, error) { + if len(routing) == 0 { + return "", nil + } + // yaml.v3 marshal with sorted keys — Go map iteration is random, so we + // sort and emit by hand for deterministic output. + keys := make([]string, 0, len(routing)) + for k := range routing { + keys = append(keys, k) + } + // simple sort + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && keys[j-1] > keys[j]; j-- { + keys[j-1], keys[j] = keys[j], keys[j-1] + } + } + var b strings.Builder + b.WriteString("category_routing:\n") + for _, k := range keys { + roles := routing[k] + // JSON-marshal each role to handle quoting/escaping safely. + jsonRoles, err := json.Marshal(roles) + if err != nil { + return "", err + } + // Quote the key with json too in case of weird chars. + jsonKey, err := json.Marshal(k) + if err != nil { + return "", err + } + b.WriteString(" ") + b.Write(jsonKey) + b.WriteString(": ") + b.Write(jsonRoles) + b.WriteString("\n") + } + return b.String(), nil +} + // mergePlugins returns the union of defaults and per-workspace plugin lists // (deduplicated, defaults first). A per-workspace entry starting with "!" or // "-" opts that plugin OUT of the union. See issue #68. diff --git a/platform/internal/handlers/org_test.go b/platform/internal/handlers/org_test.go index df9dd295..a57b0f79 100644 --- a/platform/internal/handlers/org_test.go +++ b/platform/internal/handlers/org_test.go @@ -428,6 +428,127 @@ func TestPlugins_OptOutWithDash(t *testing.T) { } } +// ==================== category_routing (issue #51) ==================== + +func TestCategoryRouting_ParsedFromOrgYaml(t *testing.T) { + raw := ` +name: Test Org +defaults: + runtime: claude-code + category_routing: + security: [Backend Engineer, DevOps Engineer] + ui: [Frontend Engineer] + infra: [DevOps Engineer] +workspaces: + - name: PM + role: Project Manager + category_routing: + performance: [Backend Engineer] +` + var tmpl OrgTemplate + if err := yaml.Unmarshal([]byte(raw), &tmpl); err != nil { + t.Fatalf("yaml parse failed: %v", err) + } + if got := tmpl.Defaults.CategoryRouting["security"]; len(got) != 2 || got[0] != "Backend Engineer" { + t.Errorf("defaults.category_routing.security wrong: %v", got) + } + if got := tmpl.Defaults.CategoryRouting["ui"]; len(got) != 1 || got[0] != "Frontend Engineer" { + t.Errorf("defaults.category_routing.ui wrong: %v", got) + } + if len(tmpl.Workspaces) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(tmpl.Workspaces)) + } + if got := tmpl.Workspaces[0].CategoryRouting["performance"]; len(got) != 1 || got[0] != "Backend Engineer" { + t.Errorf("ws.category_routing.performance wrong: %v", got) + } +} + +func TestCategoryRouting_UnionWithDefaults(t *testing.T) { + defaults := map[string][]string{ + "security": {"Backend Engineer", "DevOps"}, + "ui": {"Frontend Engineer"}, + "infra": {"DevOps"}, + } + ws := map[string][]string{ + "performance": {"Backend Engineer"}, // new key, added + "ui": {"Designer"}, // override-replace existing key + "infra": {}, // empty → drop + } + got := mergeCategoryRouting(defaults, ws) + + if v := got["security"]; len(v) != 2 || v[0] != "Backend Engineer" || v[1] != "DevOps" { + t.Errorf("security should be inherited from defaults unchanged, got %v", v) + } + if v := got["ui"]; len(v) != 1 || v[0] != "Designer" { + t.Errorf("ui should be replaced by ws value, got %v", v) + } + if _, ok := got["infra"]; ok { + t.Errorf("infra should be dropped (empty ws list), got %v", got["infra"]) + } + if v := got["performance"]; len(v) != 1 || v[0] != "Backend Engineer" { + t.Errorf("performance should be added from ws, got %v", v) + } +} + +func TestCategoryRouting_RenderedIntoWorkspaceConfig(t *testing.T) { + routing := map[string][]string{ + "security": {"Backend Engineer", "DevOps"}, + "ui": {"Frontend Engineer"}, + } + block, err := renderCategoryRoutingYAML(routing) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if block == "" { + t.Fatal("expected non-empty block") + } + // Must parse as valid YAML when concatenated with a base config + combined := "name: Test\nruntime: claude-code\n" + block + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(combined), &parsed); err != nil { + t.Fatalf("rendered YAML is invalid: %v\n---\n%s", err, combined) + } + cr, ok := parsed["category_routing"].(map[string]interface{}) + if !ok { + t.Fatalf("category_routing not a map in parsed config: %T", parsed["category_routing"]) + } + sec, ok := cr["security"].([]interface{}) + if !ok || len(sec) != 2 { + t.Fatalf("security routing wrong shape: %v", cr["security"]) + } + if sec[0] != "Backend Engineer" || sec[1] != "DevOps" { + t.Errorf("security roles wrong: %v", sec) + } + ui, ok := cr["ui"].([]interface{}) + if !ok || len(ui) != 1 || ui[0] != "Frontend Engineer" { + t.Errorf("ui roles wrong: %v", cr["ui"]) + } + // Output should be deterministic (keys sorted) — security < ui + if !strings.Contains(block, "\"security\":") || !strings.Contains(block, "\"ui\":") { + t.Errorf("expected JSON-quoted keys in block: %s", block) + } + if strings.Index(block, "security") > strings.Index(block, "ui") { + t.Errorf("expected sorted keys (security before ui), got:\n%s", block) + } +} + +func TestCategoryRouting_EmptyRendersNothing(t *testing.T) { + got, err := renderCategoryRoutingYAML(nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "" { + t.Errorf("expected empty render for nil routing, got %q", got) + } + got, err = renderCategoryRoutingYAML(map[string][]string{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "" { + t.Errorf("expected empty render for empty map, got %q", got) + } +} + func TestPlugins_BackwardCompat(t *testing.T) { // Re-listing defaults in per-workspace plugins still yields the same list // (dedupe keeps behavior stable for existing org.yaml files).