feat(platform): generic category_routing replaces hardcoded audit dispatch (#51)

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-14 14:06:47 -07:00
parent dd61714c55
commit d4140ee244
4 changed files with 251 additions and 15 deletions

View File

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

View File

@ -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 <N>` 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 <X> 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 <N>` to read the full body and category. The issue's `<category>` 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-<category>`), ack the auditor with "no routing rule for category=`<X>`; 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 <X> 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.

View File

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

View File

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