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:
parent
26622dc8ab
commit
932ada2c59
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user