Any seat can be AI-staffed: engineer/designer/analyst atoms + role-aware seat suggestions

The core product thesis made tangible beyond PO/QA:
- Four new golden-tested skill atoms in skills/: code-implementation + bug-diagnosis
  (engineer — output is a reviewable patch/diagnosis artifact; Git write-back stays Phase 2),
  ui-design-spec (designer), requirements-analysis (analyst, also tagged product-owner).
  The catalogue now spans five roles with eight atoms.
- Seat configurator: SuggestedSkills — maps the seat's free-text role name to skill role
  tags and offers the matching set one click ("Use set"). Any role name → staffed with AI.
- AnyRoleSeatTests: an "Backend Engineer" seat (Edison, gated) runs the same pipeline —
  skills assemble, implement-code/Draft parsed, proposal held in the review inbox like any
  governed action. SkillSyncTests updated for the larger catalogue.

Verified: IntegrationTests 44/44, client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 13:57:10 +03:30
parent 4a58018837
commit 4416d99360
7 changed files with 362 additions and 2 deletions
+59 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { KeyRound, Plus, Bot, Wand2 } from 'lucide-react'
import { KeyRound, Plus, Bot, Sparkles, Wand2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
@@ -337,6 +337,14 @@ export function SeatsPage() {
<div className="flex flex-col gap-2">
<Label>Skills</Label>
{selected && (
<SuggestedSkills
roleName={selected.roleName}
skills={skills}
current={agent.skillKeys}
onApply={(keys) => setAgent({ ...agent, skillKeys: keys })}
/>
)}
<div className="flex flex-wrap gap-2">
{skills.map((skill) => (
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)}>
@@ -375,3 +383,53 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
</div>
)
}
/** Maps a free-text seat role name to skill role tags — any role can be AI-staffed. */
function roleTagsFor(roleName: string): string[] {
const n = roleName.toLowerCase()
const tags: string[] = []
if (n.includes('product') || n.includes('owner') || n.includes('pm')) tags.push('product-owner')
if (n.includes('qa') || n.includes('test') || n.includes('quality')) tags.push('qa')
if (n.includes('engineer') || n.includes('dev') || n.includes('programmer') || n.includes('backend') || n.includes('frontend')) tags.push('engineer')
if (n.includes('design') || n.includes('ux') || n.includes('ui')) tags.push('designer')
if (n.includes('analyst') || n.includes('analysis') || n.includes('business')) tags.push('analyst')
return tags
}
/** Suggests the skill set matching the seat's role — one click staffs any role with AI. */
function SuggestedSkills({
roleName,
skills,
current,
onApply,
}: {
roleName: string
skills: { skillKey: string; name: string; roles: string[] }[]
current: string[]
onApply: (keys: string[]) => void
}) {
const tags = roleTagsFor(roleName)
const suggested = skills.filter((s) => s.roles.some((r) => tags.includes(r)))
if (suggested.length === 0) return null
const keys = suggested.map((s) => s.skillKey)
const applied = keys.every((k) => current.includes(k))
return (
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
<Sparkles className="size-3.5 shrink-0 text-primary" />
<span className="min-w-0 truncate">
Suggested for {roleName}: {suggested.map((s) => s.name).join(', ')}
</span>
<Button
variant="outline"
size="sm"
className="ml-auto shrink-0"
disabled={applied}
onClick={() => onApply([...new Set([...current, ...keys])])}
>
{applied ? 'Applied' : 'Use set'}
</Button>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
---
id: bug-diagnosis
name: Bug Diagnosis
version: 1.0.0
summary: From a bug report and code context, find the root cause and propose the fix.
roles: [engineer]
inputs: A bug report (symptoms, repro steps) and any relevant code or logs attached to the task.
outputs: Root-cause analysis, the proposed fix as a patch sketch, and a regression test suggestion.
actions:
- name: diagnose-bug
risk: draft
description: Post the diagnosis + proposed fix as a draft artifact on the task (held for review).
tools: []
context: [house-style, repo-docs]
visibility: public
min_tier: free
golden_tests:
- input: |
Bug: after logout, pressing Back shows the dashboard with stale user data.
Context: the dashboard reads from a client-side cache keyed by user id.
expected: |
Root cause: the client cache is not cleared on logout, so navigation restores stale
state. Fix: clear the cache in logout(); regression test: logout then navigate back
asserts a redirect to /login and an empty cache.
---
# Bug Diagnosis
You are a software engineer on call. Work the bug like a scientist:
1. **Reproduce in your head** — restate the failure path from the symptoms.
2. **Root cause** — the deepest cause the evidence supports, not the first plausible one.
Quote the specific code/log lines that implicate it.
3. **Proposed fix** — a minimal patch sketch at the root cause, not a symptom bandage.
4. **Regression test** — what test would have caught this.
If the evidence is insufficient, list exactly what extra context you need. Never guess silently.
+41
View File
@@ -0,0 +1,41 @@
---
id: code-implementation
name: Code Implementation
version: 1.0.0
summary: Implement a story as a reviewable patch — code with reasoning, ready for human review.
roles: [engineer]
inputs: A story with acceptance criteria, plus any relevant code context attached to the task.
outputs: A unified-diff style patch (or complete new files) with a short implementation note.
actions:
- name: implement-code
risk: draft
description: Produce the patch as a draft artifact on the task (held for review). Direct Git write-back is Phase 2.
tools: []
context: [house-style, repo-docs]
visibility: public
min_tier: free
golden_tests:
- input: |
Story: clicking logout must clear the session and redirect to /login.
Context: React app; auth lives in useAuth() with a logout() action.
expected: |
Patch: header component — add a Logout button calling useAuth().logout() then
navigate('/login'); note: guard the button behind isAuthenticated.
---
# Code Implementation
You are a software engineer. Implement exactly what the story's acceptance criteria require.
Rules:
- Output a **patch**: unified-diff hunks for edited files, or full content for new files,
each preceded by its path.
- Follow the codebase's existing conventions visible in the provided context. No drive-by
refactors — stay inside the story's scope.
- After the patch, add an **implementation note**: what changed, why, and anything the
reviewer should look at closely (edge cases, trade-offs).
- If an acceptance criterion cannot be met with the available context, say so explicitly
instead of inventing APIs.
Your output is reviewed by a human before anything lands — write for that reviewer.
+38
View File
@@ -0,0 +1,38 @@
---
id: requirements-analysis
name: Requirements Analysis
version: 1.0.0
summary: Turn raw stakeholder notes into structured, testable requirements.
roles: [analyst, product-owner]
inputs: Raw notes — meeting minutes, customer feedback, a feature wish, or a vague request.
outputs: Structured requirements — goals, user stories with acceptance criteria, assumptions, and open questions.
actions:
- name: analyze-requirements
risk: draft
description: Produce the requirements document as a draft artifact on the task (held for review).
tools: []
context: [house-style, product-docs]
visibility: public
min_tier: free
golden_tests:
- input: "Customer call: they keep losing work, want some kind of autosave, maybe every minute or so?"
expected: |
Goal: no user loses more than one minute of work.
Story: as an editor, my changes save automatically so a crash loses at most 60s.
Acceptance: edits persist within 60s without manual save; recovery prompt on reopen.
Open question: conflict behaviour when two sessions edit the same document.
---
# Requirements Analysis
You are a business analyst. Extract what the stakeholder actually needs from what they said.
Produce, in order:
- **Goal** — the outcome in one sentence, measurable where possible.
- **User stories** — "as a …, I … so that …", each with verifiable acceptance criteria.
- **Assumptions** — what you inferred that a stakeholder should confirm.
- **Open questions** — ambiguities that block implementation, phrased so a yes/no or short
answer resolves them.
Do not invent scope. Anything not grounded in the input belongs under assumptions or questions.
+38
View File
@@ -0,0 +1,38 @@
---
id: ui-design-spec
name: UI Design Spec
version: 1.0.0
summary: Turn a feature into a concrete screen spec — layout, components, states, and flows.
roles: [designer]
inputs: A feature or story, plus the product's design language notes if attached.
outputs: A screen-by-screen spec — layout, components, interaction states, and the user flow.
actions:
- name: write-design-spec
risk: draft
description: Produce the design spec as a draft artifact on the task (held for review).
tools: []
context: [house-style, design-system]
visibility: public
min_tier: free
golden_tests:
- input: "Feature: users need a way to log out from anywhere in the app."
expected: |
Placement: avatar menu, top-right header, last item "Log out" with icon.
States: confirm none (instant), loading spinner on click, redirect to /login.
Flow: any page → avatar menu → Log out → /login with a "signed out" toast.
---
# UI Design Spec
You are a product designer. Specify the screen(s) so a developer can build them without
guessing.
For each screen or surface:
- **Layout** — regions and hierarchy (what's where, and why).
- **Components** — name them in the product's design system terms where possible.
- **States** — empty, loading, error, success, and permission-restricted variants.
- **Flow** — entry points, the happy path, and exits.
- **Copy** — exact labels for buttons, titles, and empty states.
Stay inside the existing design language; flag any new pattern you introduce and justify it.
@@ -0,0 +1,148 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using TeamUp.Modules.Assembler.Queue;
using TeamUp.Modules.Assembler.Runtime;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// The core product thesis: ANY seat can be AI-staffed — a role is just a name + skill atoms.
/// An "Engineer" seat (not PO, not QA) runs the same pipeline: skills assemble, the model is
/// called, and the implement-code proposal is held for review like any other governed action.
/// </summary>
public sealed class AnyRoleSeatTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record IdResponse(Guid Id);
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
private sealed record SyncResult(int Indexed);
private sealed record RunResponse(
Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status,
string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error);
private sealed record ReviewItemResponse(
Guid Id, Guid OrganizationId, Guid TeamId, Guid AgentRunId, Guid AgentId, Guid WorkItemId,
string ActionKind, string Risk, string Title, string Content, List<string> ChildTitles,
string? Trace, string Status, string? Decision, double? EditDistance, DateTimeOffset CreatedAtUtc);
[Fact]
public async Task An_engineer_seat_runs_the_same_governed_pipeline()
{
var settings = new Dictionary<string, string?>
{
["GitSource:Provider"] = "filesystem",
["GitSource:Root"] = LocateSkillsDirectory(),
};
await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
using var anon = factory.CreateClient();
var owner = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
using var client = Authed(factory, owner.Token);
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
var config = await PostOk<IdResponse>(client, "/api/integrations/api-configs", new
{
organizationId = owner.OrganizationId,
name = "Vertex-Pro",
provider = "stub",
model = "gemini-pro",
apiKey = "sk-demo-key",
});
// The catalogue now carries atoms for engineer/designer/analyst roles too.
var sync = await PostOk<SyncResult>(client, "/api/skills/sync", new { });
Assert.True(sync.Indexed >= 8);
// Staff an ENGINEER seat with AI — same configurator, different atoms.
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Backend Engineer" });
await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new
{
name = "Edison",
monogram = "ED",
autonomy = "Gated",
apiConfigId = config.Id,
skillKeys = new[] { "code-implementation", "bug-diagnosis" },
docs = Array.Empty<string>(),
});
var task = await PostOk<RunTask>(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "Implement the logout endpoint",
description = "POST /logout clears the session.",
type = "Story",
});
var run = await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
await DrainOneJob(factory);
var done = await client.GetFromJsonAsync<RunResponse>($"/api/assembler/runs/{run.Id}");
Assert.Equal("Completed", done!.Status);
Assert.Equal("implement-code", done.ActionType); // the engineer atom's primary action
Assert.Equal("Draft", done.ActionRisk);
Assert.Contains("Code Implementation", done.Prompt); // the skill body assembled in
// Gated engineer output is governed exactly like PO/QA output: held for human review.
var pending = await client.GetFromJsonAsync<List<ReviewItemResponse>>(
$"/api/governance/reviews?organizationId={owner.OrganizationId}");
var held = Assert.Single(pending!);
Assert.Equal("implement-code", held.ActionKind);
Assert.Equal(task.Id, held.WorkItemId);
}
private sealed record RunTask(Guid Id);
private static async Task DrainOneJob(TeamUpWebFactory factory)
{
await using var scope = factory.Services.CreateAsyncScope();
var queue = scope.ServiceProvider.GetRequiredService<JobQueue>();
var job = await queue.ClaimNextAsync("test-worker");
Assert.NotNull(job);
await scope.ServiceProvider.GetRequiredService<AgentRunExecutor>().ProcessAsync(job!);
}
private static HttpClient Authed(TeamUpWebFactory factory, string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
{
var response = await client.PostAsJsonAsync(url, body);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var value = await response.Content.ReadFromJsonAsync<T>();
Assert.NotNull(value);
return value!;
}
private static string LocateSkillsDirectory()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return Path.Combine(dir!.FullName, "skills");
}
}
@@ -49,7 +49,7 @@ public sealed class SkillSyncTests(PostgresFixture postgres) : IClassFixture<Pos
var syncResponse = await client.PostAsync("/api/skills/sync", content: null);
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
var result = await syncResponse.Content.ReadFromJsonAsync<SyncResult>();
Assert.Equal(4, result!.Indexed);
Assert.True(result!.Indexed >= 8, $"expected all atoms indexed, got {result.Indexed}");
var productOwner = await client.GetFromJsonAsync<List<SkillSummary>>("/api/skills/?role=product-owner");
Assert.Contains(productOwner!, s => s.SkillKey == "spec-writing");