Merge pull request #75 from Molecule-AI/feat/issue-51-category-routing

feat(platform): generic category_routing replaces hardcoded audit dispatch (#51)
This commit is contained in:
Hongming Wang 2026-04-14 14:40:51 -07:00 committed by GitHub
commit bdb21a2d70
4 changed files with 307 additions and 17 deletions

View File

@ -37,6 +37,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

@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
@ -58,11 +59,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 +102,11 @@ 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 extends/overrides defaults.category_routing per-workspace.
// Merge semantics: workspace keys replace defaults' value for the same key
// (empty list drops the category entirely); new keys are added. See
// mergeCategoryRouting.
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 +380,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 keys replace defaults per-key (empty list drops the key);
// see mergeCategoryRouting for exact semantics.
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 {
configFiles["config.yaml"] = appendYAMLBlock(configFiles["config.yaml"], block)
}
}
// Inject initial_prompt into config.yaml (workspace-level overrides default)
initialPrompt := ws.InitialPrompt
if initialPrompt == "" {
@ -386,8 +414,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa
lines[i] = strings.TrimRight(line, " \t")
}
indented := strings.Join(lines, "\n ")
existing := configFiles["config.yaml"]
configFiles["config.yaml"] = append(existing, []byte(fmt.Sprintf("initial_prompt: |\n %s\n", indented))...)
configFiles["config.yaml"] = appendYAMLBlock(configFiles["config.yaml"], fmt.Sprintf("initial_prompt: |\n %s\n", indented))
log.Printf("Org import: injected initial_prompt (%d chars) into config.yaml for %s", len(trimmed), ws.Name)
}
@ -642,6 +669,91 @@ 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, test-friendly output. Uses yaml.Node + yaml.Marshal
// so role names containing YAML-reserved characters (colons, quotes, unicode line
// separators, etc.) are escaped by the YAML library — no ad-hoc quoting.
func renderCategoryRoutingYAML(routing map[string][]string) (string, error) {
if len(routing) == 0 {
return "", nil
}
keys := make([]string, 0, len(routing))
for k := range routing {
if k == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
inner := &yaml.Node{Kind: yaml.MappingNode}
for _, k := range keys {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: k}
valNode := &yaml.Node{Kind: yaml.SequenceNode, Style: yaml.FlowStyle}
for _, role := range routing[k] {
valNode.Content = append(valNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: role})
}
inner.Content = append(inner.Content, keyNode, valNode)
}
doc := &yaml.Node{Kind: yaml.MappingNode}
doc.Content = []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "category_routing"},
inner,
}
out, err := yaml.Marshal(doc)
if err != nil {
return "", err
}
return string(out), nil
}
// appendYAMLBlock concatenates a YAML fragment to an existing buffer, guaranteeing
// a newline boundary between them. Upstream code writes config.yaml in fragments
// (base template → category_routing → initial_prompt) and the base isn't
// guaranteed to end in \n, which would merge the last line into the next block.
func appendYAMLBlock(existing []byte, block string) []byte {
if len(existing) > 0 && existing[len(existing)-1] != '\n' {
existing = append(existing, '\n')
}
return append(existing, []byte(block)...)
}
// 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,171 @@ 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.Index(block, "security") > strings.Index(block, "ui") {
t.Errorf("expected sorted keys (security before ui), got:\n%s", block)
}
}
// YAML-reserved characters in role names must be escaped by the YAML library.
// Regression guard for the earlier hand-rolled JSON-as-YAML implementation.
func TestCategoryRouting_EscapesYAMLSpecials(t *testing.T) {
routing := map[string][]string{
"security": {"Role: with colon", `Role "with quotes"`, "Role\nwith newline"},
}
block, err := renderCategoryRoutingYAML(routing)
if err != nil {
t.Fatalf("render failed: %v", err)
}
var parsed map[string]interface{}
if err := yaml.Unmarshal([]byte(block), &parsed); err != nil {
t.Fatalf("rendered YAML is invalid for special chars: %v\n---\n%s", err, block)
}
cr := parsed["category_routing"].(map[string]interface{})
roles := cr["security"].([]interface{})
if len(roles) != 3 || roles[0] != "Role: with colon" {
t.Errorf("special-char roles did not round-trip: %v", roles)
}
}
// appendYAMLBlock must guarantee a newline boundary between existing buffer and
// the new block so downstream parsers see two separate top-level keys.
func TestAppendYAMLBlock_NewlineGuard(t *testing.T) {
cases := []struct {
name string
existing string
block string
}{
{"existing ends without newline", "name: foo", "category_routing:\n a: [b]\n"},
{"existing ends with newline", "name: foo\n", "category_routing:\n a: [b]\n"},
{"empty existing", "", "category_routing:\n a: [b]\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := appendYAMLBlock([]byte(tc.existing), tc.block)
var parsed map[string]interface{}
if err := yaml.Unmarshal(got, &parsed); err != nil {
t.Fatalf("appended YAML invalid: %v\n---\n%s", err, string(got))
}
if _, ok := parsed["category_routing"]; !ok {
t.Errorf("expected top-level category_routing key, got: %v", parsed)
}
})
}
}
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).