Compare commits

...

3 Commits

Author SHA1 Message Date
hal9000botagent
91e6c779d6 feat(squad): surface member skills in leader briefing roster (#4363)
When an issue is assigned to a squad, only the leader is triggered. The
leader briefing's Squad Roster listed each member's name, type, role, and
mention link — but not the member's assigned skills, so the leader had to
infer capability from the free-text role label when deciding who to
delegate to.

renderMemberRow now loads each agent member's assigned skills via
ListAgentSkillSummaries and formatRosterRow renders them as
"skills: a, b" (or "no skills assigned" when the agent has none). Builtin
multica-* skills are excluded (they live outside agent_skill); human
members carry no skills segment; a skill-lookup error degrades to the
prior name+role row rather than asserting a misleading "no skills".
Operating-protocol step 1 now tells the leader to match the task to each
member's listed skills.

Updates the multica-squads builtin skill and its source map to document
the new roster content, and adds
TestBuildSquadLeaderBriefing_MemberSkillsInRoster.

Co-authored-by: hal9000botagent <hal9000botagent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:33:12 +08:00
Stiliyan Monev
9d7060caf1 fix(auth): autofocus OTP input on verification step (#4344)
* fix(auth): autofocus OTP input on verification step

The email-verification step renders the OTP input without focus, so
users must click the field before typing the code. This is friction on
every login, especially when switching accounts.

Add `autoFocus` to the InputOTP so the cursor lands in the field as
soon as the step mounts. Mirrors the existing email-step input and the
mobile OTP component, both of which already autofocus.

* test(web): polyfill document.elementFromPoint for input-otp in jsdom

Autofocusing the OTP input makes input-otp run its focus-time DOM
measurement, which calls document.elementFromPoint. jsdom doesn't
implement it, so the web login test threw an unhandled error.

packages/views/test/setup.ts already stubs this for the same reason;
mirror the stub in the web test setup (which already stubs
ResizeObserver for input-otp).

* test(auth): assert OTP input autofocuses on verification step

Guards the autofocus behavior: the test fails if the autoFocus prop is
removed from the verification-step InputOTP. Lives in packages/views
since it covers shared component behavior, mocking @multica/core.
2026-06-22 10:00:57 +08:00
Naiyuan Qing
6d0e875dbb feat: add opt-in react-grab dev element inspector (web + desktop) (#4381)
* feat(web): add opt-in react-grab dev element inspector

Loads the react-grab overlay (hold ⌘C / Ctrl+C + click to copy an
element's source path + component stack) only when REACT_GRAB is set in
a local, gitignored apps/web/.env.local. Both the NODE_ENV and REACT_GRAB
guards are evaluated server-side in the root layout, so the <Script> tag
is omitted from the HTML for anyone who hasn't opted in — no effect on
other developers or production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(desktop): add opt-in react-grab dev element inspector

Mirrors the web wiring for the Electron renderer: injects the react-grab
overlay (hold ⌘C / Ctrl+C + click to copy an element's source path +
component stack) only when VITE_REACT_GRAB is set in a local, gitignored
apps/desktop/.env.development.local. Guarded by import.meta.env.DEV so the
branch is tree-shaken out of production builds; never activates for other
developers. No CSP/sandbox blocks the unpkg script (webSecurity is off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): unify react-grab opt-in var to VITE_REACT_GRAB

Use the same env var name as the desktop renderer so one variable name
controls both apps. The desktop renderer is bundled by Vite, which only
exposes VITE_-prefixed vars to client code, so the shared name must carry
the VITE_ prefix; web reads it server-side where the name is unconstrained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:49:24 +08:00
9 changed files with 253 additions and 97 deletions

View File

@@ -13,4 +13,18 @@ import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";
// react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click any
// element to copy its source path + line + component stack for pasting to an AI.
// Opt-in per developer: only loads when VITE_REACT_GRAB is set in a local,
// gitignored apps/desktop/.env.development.local — it never activates for anyone
// else, and the whole branch is tree-shaken out of production builds. The web app
// wires the same tool via next/script in apps/web/app/layout.tsx.
// See https://www.react-grab.com/
if (import.meta.env.DEV && import.meta.env.VITE_REACT_GRAB) {
const grab = document.createElement("script");
grab.src = "//unpkg.com/react-grab/dist/index.global.js";
grab.crossOrigin = "anonymous";
document.head.appendChild(grab);
}
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
@@ -116,6 +117,24 @@ export default async function RootLayout({
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
>
<body className="h-full overflow-hidden">
{/*
react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click
any element to copy its source path + line + component stack for pasting
to an AI. Opt-in per developer: only loads when VITE_REACT_GRAB is set in
a local, gitignored apps/web/.env.local — it never activates for anyone
else. Both guards are read server-side, so the <Script> is omitted from
the HTML entirely unless you opted in. The VITE_ prefix is shared with the
desktop renderer (apps/desktop/src/renderer/src/main.tsx), where Vite only
exposes VITE_-prefixed vars to client code, so one var name covers both
apps. See https://www.react-grab.com/
*/}
{process.env.NODE_ENV === "development" && process.env.VITE_REACT_GRAB && (
<Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
)}
<ThemeProvider>
<WebProviders locale={locale} resources={resources}>
{children}

View File

@@ -11,6 +11,11 @@ if (typeof globalThis.ResizeObserver === "undefined") {
} as unknown as typeof ResizeObserver;
}
// jsdom doesn't implement elementFromPoint; input-otp uses it internally.
if (typeof document.elementFromPoint !== "function") {
document.elementFromPoint = () => null;
}
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
// Create a proper localStorage mock if methods are missing.
if (

View File

@@ -198,6 +198,23 @@ describe("LoginPage", () => {
expect(screen.getByText(/test@example.com/)).toBeInTheDocument();
});
it("autofocuses the OTP input when the code step opens", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
renderWithI18n(<LoginPage onSuccess={onSuccess} />);
const user = userEvent.setup();
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(screen.getByText(/check your email/i)).toBeInTheDocument();
});
// The OTP field should be focused on mount so the user can type the code
// without clicking it first — important when repeatedly switching accounts.
expect(getOTPInput()).toHaveFocus();
});
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Rate limited"));
renderWithI18n(<LoginPage onSuccess={onSuccess} />);

View File

@@ -349,6 +349,7 @@ export function LoginPage({
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<InputOTP
autoFocus
maxLength={6}
value={code}
onChange={(value) => {

View File

@@ -4,6 +4,7 @@ import (
"context"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -25,6 +26,8 @@ Your responsibilities, in order:
1. **Read the issue** (title, description, latest comments, acceptance
criteria) and decide which squad member is best suited to do the work.
Match the task to each member's listed **skills** and role in the Squad
Roster below — prefer the member whose skills cover the work.
2. **Delegate by @mention.** Post a single comment on this issue that
@mentions the chosen member(s) and tells them what to do.
- **Be terse.** Every Multica agent already has full context of the
@@ -178,7 +181,9 @@ func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember) strin
if ag.ArchivedAt.Valid {
return ""
}
return formatRosterRow(ag.Name, "agent", role, formatMention(ag.Name, "agent", id))
// Agents carry skills; surfacing them lets the leader delegate by
// capability instead of guessing from the free-text role label.
return formatRosterRow(ag.Name, "agent", role, agentSkillsRosterSegment(ctx, q, m.MemberID), formatMention(ag.Name, "agent", id))
case "member":
user, err := q.GetUser(ctx, m.MemberID)
if err != nil {
@@ -186,14 +191,38 @@ func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember) strin
}
// Mention syntax for humans uses the user_id (matches the rest of
// the product — see util.MentionRe and frontend mention payloads).
// Humans have no Multica skills, so no skills segment is rendered.
userID := util.UUIDToString(m.MemberID)
return formatRosterRow(user.Name, "member (human)", role, formatMention(user.Name, "member", userID))
return formatRosterRow(user.Name, "member (human)", role, "", formatMention(user.Name, "member", userID))
default:
return ""
}
}
func formatRosterRow(name, kind, role, mention string) string {
// agentSkillsRosterSegment returns the roster segment describing an agent
// member's assigned skills. "skills: a, b" when the agent has skills (the
// names are pre-sorted by ListAgentSkillSummaries), "no skills assigned" when
// it has none so the leader knows the capability is genuinely absent, and ""
// only when the lookup fails — a transient DB error degrades to the prior
// name+role row rather than asserting a misleading "no skills". Builtin
// multica-* skills are added at runtime (not in agent_skill) and are
// deliberately omitted; the leader cares about the configured capabilities.
func agentSkillsRosterSegment(ctx context.Context, q *db.Queries, agentID pgtype.UUID) string {
skills, err := q.ListAgentSkillSummaries(ctx, agentID)
if err != nil {
return ""
}
if len(skills) == 0 {
return "no skills assigned"
}
names := make([]string, 0, len(skills))
for _, s := range skills {
names = append(names, s.Name)
}
return "skills: " + strings.Join(names, ", ")
}
func formatRosterRow(name, kind, role, skills, mention string) string {
var sb strings.Builder
sb.WriteString("- ")
sb.WriteString(name)
@@ -204,6 +233,10 @@ func formatRosterRow(name, kind, role, mention string) string {
sb.WriteString(role)
sb.WriteString(`"`)
}
if skills != "" {
sb.WriteString(" — ")
sb.WriteString(skills)
}
sb.WriteString(" — `")
sb.WriteString(mention)
sb.WriteString("`\n")

View File

@@ -150,6 +150,64 @@ func TestBuildSquadLeaderBriefing_FullSquad(t *testing.T) {
}
}
// assignSkillToAgent creates a workspace skill and attaches it to the agent,
// registering cleanup for the skill row (agent_skill cascades on skill delete).
func assignSkillToAgent(t *testing.T, agentID, skillName string) {
t.Helper()
ctx := context.Background()
var skillID string
if err := testPool.QueryRow(ctx, `
INSERT INTO skill (workspace_id, name, description, content, created_by)
VALUES ($1, $2, '', '', $3)
RETURNING id
`, testWorkspaceID, skillName, testUserID).Scan(&skillID); err != nil {
t.Fatalf("create skill %s: %v", skillName, err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM skill WHERE id = $1`, skillID) })
if _, err := testPool.Exec(ctx,
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
agentID, skillID,
); err != nil {
t.Fatalf("assign skill %s to agent: %v", skillName, err)
}
}
// TestBuildSquadLeaderBriefing_MemberSkillsInRoster locks in the delegation
// fix: an agent member's assigned skills appear in the leader roster so the
// leader can route by capability. Agents with no skills get an explicit
// marker; human members never carry a skills segment.
func TestBuildSquadLeaderBriefing_MemberSkillsInRoster(t *testing.T) {
ctx := context.Background()
leaderID, _ := seededLeaderAgent(t)
squad := seedSquadForBriefing(t, leaderID, "Skilled Squad", "")
skilled := createHandlerTestAgent(t, "Skilled Bot", []byte("[]"))
addAgentMember(t, squad.ID, skilled, "backend")
// ListAgentSkillSummaries orders by name ASC → "polars" before "stat…".
assignSkillToAgent(t, skilled, "polars")
assignSkillToAgent(t, skilled, "statistical-analysis")
plain := createHandlerTestAgent(t, "Plain Bot", []byte("[]"))
addAgentMember(t, squad.ID, plain, "")
memberRowID, userID, userName := seededHumanMember(t)
_ = memberRowID
addHumanMember(t, squad.ID, userID, "reviewer")
out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad)
if !strings.Contains(out, "skills: polars, statistical-analysis") {
t.Errorf("expected skilled member skills in roster, got:\n%s", out)
}
if !strings.Contains(out, "Plain Bot — agent — no skills assigned") {
t.Errorf("expected no-skills marker for skill-less agent, got:\n%s", out)
}
if strings.Contains(out, userName+" — member (human), role: \"reviewer\" — skills:") ||
strings.Contains(out, userName+" — member (human), role: \"reviewer\" — no skills") {
t.Errorf("human member must not render a skills segment, got:\n%s", out)
}
}
func TestBuildSquadLeaderBriefing_OnlyLeader(t *testing.T) {
ctx := context.Background()
leaderID, _ := seededLeaderAgent(t)
@@ -226,34 +284,34 @@ func TestBuildSquadLeaderBriefing_MentionsRoundTrip(t *testing.T) {
// claimAndDecodeAgent runs ClaimTaskByRuntime for the given runtime and
// returns the agent block of the response. Fails the test on non-200.
func claimAndDecodeAgent(t *testing.T, runtimeID string) *TaskAgentData {
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
Agent *TaskAgentData `json:"agent"`
} `json:"task"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Task == nil || resp.Task.Agent == nil {
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
}
return resp.Task.Agent
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
Agent *TaskAgentData `json:"agent"`
} `json:"task"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Task == nil || resp.Task.Agent == nil {
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
}
return resp.Task.Agent
}
// queueSquadIssueTaskFor creates an issue assigned to the squad and a queued
// task for the given (agentID, runtimeID). Returns the issue + task IDs.
func queueSquadIssueTaskFor(t *testing.T, squadID, agentID, runtimeID string, issueNumber int) (issueID, taskID string) {
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_id, creator_type,
assignee_type, assignee_id, number, position
@@ -261,101 +319,101 @@ assignee_type, assignee_id, number, position
'squad', $3, $4, 0)
RETURNING id
`, testWorkspaceID, testUserID, squadID, issueNumber).Scan(&issueID); err != nil {
t.Fatalf("create squad-assigned issue: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
t.Fatalf("create squad-assigned issue: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
if err := testPool.QueryRow(ctx, `
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
VALUES ($1, $2, $3, 'queued', 0)
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
t.Fatalf("queue task: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
return
t.Fatalf("queue task: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
return
}
// TestClaimTask_LeaderGetsBriefing — when the squad leader claims a task on
// a squad-assigned issue, the response's agent.instructions must include
// the Operating Protocol + Roster + user instructions.
func TestClaimTask_LeaderGetsBriefing(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var leaderID, runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID, &runtimeID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
var leaderID, runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID, &runtimeID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
addAgentMember(t, squad.ID, helper, "implementer")
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
addAgentMember(t, squad.ID, helper, "implementer")
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
agent := claimAndDecodeAgent(t, runtimeID)
for _, want := range []string{
"## Squad Operating Protocol",
"## Squad Roster",
"Leader (you):",
"## Squad Instructions (Briefing Claim Squad)",
"Be terse.",
"`[@Briefing Helper](mention://agent/" + helper + ")`",
} {
if !strings.Contains(agent.Instructions, want) {
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
}
}
agent := claimAndDecodeAgent(t, runtimeID)
for _, want := range []string{
"## Squad Operating Protocol",
"## Squad Roster",
"Leader (you):",
"## Squad Instructions (Briefing Claim Squad)",
"Be terse.",
"`[@Briefing Helper](mention://agent/" + helper + ")`",
} {
if !strings.Contains(agent.Instructions, want) {
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
}
}
}
// TestClaimTask_NonLeaderGetsNoBriefing — when a non-leader squad member
// claims a task on a squad-assigned issue, NO briefing is injected.
func TestClaimTask_NonLeaderGetsNoBriefing(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var leaderID string
if err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
var leaderID string
if err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
// Create a second agent (NOT the leader) with its own runtime so the
// claim path picks its task without ambiguity.
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
addAgentMember(t, squad.ID, helperID, "")
var helperRuntime string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
).Scan(&helperRuntime); err != nil {
t.Fatalf("get helper runtime: %v", err)
}
// Create a second agent (NOT the leader) with its own runtime so the
// claim path picks its task without ambiguity.
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
addAgentMember(t, squad.ID, helperID, "")
var helperRuntime string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
).Scan(&helperRuntime); err != nil {
t.Fatalf("get helper runtime: %v", err)
}
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
agent := claimAndDecodeAgent(t, helperRuntime)
for _, mustNot := range []string{
"Squad Operating Protocol",
"Squad Roster",
"Squad Instructions (Non-Leader Squad)",
} {
if strings.Contains(agent.Instructions, mustNot) {
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
}
}
agent := claimAndDecodeAgent(t, helperRuntime)
for _, mustNot := range []string{
"Squad Operating Protocol",
"Squad Roster",
"Squad Instructions (Non-Leader Squad)",
} {
if strings.Contains(agent.Instructions, mustNot) {
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
}
}
}
// Avoid "imported and not used: pgtype" if helpers above are the only users.

View File

@@ -138,7 +138,12 @@ agent instructions. The briefing includes:
- Squad Instructions, only when `instructions` is non-empty.
Roster entries include member name, member type, mention markdown, and non-empty
role. Archived agent members are skipped from the briefing roster.
role. For agent members the roster also lists their assigned skills
(`skills: a, b`, or `no skills assigned` when the agent has none) so the leader
can delegate by capability instead of guessing from the role label; human
members carry no skills segment. Builtin `multica-*` skills are not listed —
only the workspace skills explicitly attached to the agent. Archived agent
members are skipped from the briefing roster.
## Issue assignment behavior

View File

@@ -81,7 +81,7 @@ Contracts:
Source:
```text
server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~104, buildSquadRoster ~121, renderMemberRow ~169
server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~104, buildSquadRoster ~121, renderMemberRow ~169, agentSkillsRosterSegment, formatRosterRow
server/internal/handler/daemon.go # briefing injection ~1187, ~1530
```
@@ -93,6 +93,10 @@ Contracts:
(squad_briefing.go:104-117);
- `instructions` section appears only when non-empty (squad_briefing.go:110-112);
- archived agent members are skipped from roster (squad_briefing.go:178-179);
- agent member roster rows list assigned workspace skills via
`agentSkillsRosterSegment` (ListAgentSkillSummaries) — "skills: a, b" or
"no skills assigned"; builtin multica-* skills are excluded and human
members carry no skills segment (squad_briefing.go renderMemberRow);
- no traced behavior injects `instructions` into every squad member.
## Issue Assignment