diff --git a/org-templates/molecule-dev/org.yaml b/org-templates/molecule-dev/org.yaml index 97a9ceb1..a478d79a 100644 --- a/org-templates/molecule-dev/org.yaml +++ b/org-templates/molecule-dev/org.yaml @@ -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 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..61322204 100644 --- a/platform/internal/handlers/org.go +++ b/platform/internal/handlers/org.go @@ -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. diff --git a/platform/internal/handlers/org_test.go b/platform/internal/handlers/org_test.go index df9dd295..a1e133a7 100644 --- a/platform/internal/handlers/org_test.go +++ b/platform/internal/handlers/org_test.go @@ -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).