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:
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
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 { toast } from 'sonner'
|
||||||
import { AppShell } from '@/components/AppShell'
|
import { AppShell } from '@/components/AppShell'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -337,6 +337,14 @@ export function SeatsPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Skills</Label>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{skills.map((skill) => (
|
{skills.map((skill) => (
|
||||||
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)}>
|
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)}>
|
||||||
@@ -375,3 +383,53 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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);
|
var syncResponse = await client.PostAsync("/api/skills/sync", content: null);
|
||||||
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
|
||||||
var result = await syncResponse.Content.ReadFromJsonAsync<SyncResult>();
|
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");
|
var productOwner = await client.GetFromJsonAsync<List<SkillSummary>>("/api/skills/?role=product-owner");
|
||||||
Assert.Contains(productOwner!, s => s.SkillKey == "spec-writing");
|
Assert.Contains(productOwner!, s => s.SkillKey == "spec-writing");
|
||||||
|
|||||||
Reference in New Issue
Block a user