Files
multica/server/internal/handler/squad_briefing.go
LinYushen 29082f7cfe feat: implement Squad feature MVP (#2505)
* feat: implement Squad feature MVP

- Add migration 084_squad: squad, squad_member, squad_activity_log tables
- Extend issue.assignee_type to support 'squad'
- Add sqlc queries for squad CRUD, member management, activity logs
- Add Go handler with full Squad API (CRUD, members, activity log)
- Register routes: /api/squads/*, /api/issues/{id}/squad-activity, /api/squad-activity
- Add Squad trigger logic:
  - Assign Squad immediately triggers leader
  - Every external comment on squad-assigned issue triggers leader
  - Anti-loop: squad members' comments don't trigger leader
  - Dedup: skip if leader already has pending task
- Add squad activity log API (方案 B) for leader no-op recording
- Add frontend TypeScript types (Squad, SquadMember, SquadActivityLog)
- Add protocol events: squad:created, squad:updated, squad:deleted

Co-authored-by: multica-agent <github@multica.ai>

* fix: address PR review blocking issues

1. validateAssigneePair now accepts 'squad' assignee_type
2. All squad endpoints validate workspace ownership via GetSquadInWorkspace
3. CreateSquadActivityLog restricted to squad leader agent only
4. AddSquadMember validates member exists in workspace
5. UpdateSquad auto-adds new leader to squad members
6. DeleteSquad transfers assigned issues to leader before deletion
7. IssueAssigneeType includes 'squad' in frontend types

Co-authored-by: multica-agent <github@multica.ai>

* feat: soft-delete squads via archive instead of hard delete

- Add migration 085: archived_at + archived_by columns on squad table
- ListSquads now excludes archived squads (ListAllSquads for admin)
- DeleteSquad → ArchiveSquad (sets archived_at, preserves all records)
- Transfer squad-assigned issues to leader before archiving
- SquadResponse includes archived_at/archived_by fields
- Frontend Squad type updated with nullable archived fields

Co-authored-by: multica-agent <github@multica.ai>

* feat: re-add Squads frontend entry (sidebar nav + pages)

Re-applies the frontend squad entry that was lost during a merge:
- Sidebar nav: Squads item with Users icon
- Paths: squads() and squadDetail() in workspace paths
- Routes: /squads and /squads/[id] pages
- Views: SquadsPage (list) and SquadDetailPage
- i18n: en 'Squads' / zh '小队'
- Reserved slug: 'squads'

Co-authored-by: multica-agent <github@multica.ai>

* fix: fix SquadsPage rendering - use PageHeader children pattern

PageHeader takes children, not title/actions props. The incorrect
usage caused a React rendering error. Now matches the pattern used
by autopilots and agents pages.

Co-authored-by: multica-agent <github@multica.ai>

* fix(squads): add API client methods and package export for squads pages

* feat: complete Squad frontend - create dialog, member management, API methods

- Add CreateSquadModal with name/description/leader selection
- Register 'create-squad' in modal registry
- Wire 'New Squad' button to open the modal
- Add full API client methods: createSquad, updateSquad, deleteSquad,
  addSquadMember, removeSquadMember
- Rewrite SquadDetailPage with:
  - Member list showing resolved names
  - Add/remove member UI
  - Archive squad button
  - Back navigation to squads list

Co-authored-by: multica-agent <github@multica.ai>

* feat: improve Squad UI - match create agent dialog style

- CreateSquadModal: proper Dialog with Header/Description/Footer,
  agent picker with avatars, textarea for description
- SquadDetailPage: centered max-w-2xl layout, ActorAvatar for members,
  Crown badge for leader, textarea for member description,
  improved spacing and visual hierarchy
- Renamed 'role' field label to 'Description' in add member form
  (describes the member's responsibilities in the squad)

Co-authored-by: multica-agent <github@multica.ai>

* feat(squad): add avatar, instructions; drop unique-name constraint

- 086: add squad.avatar_url
- 087: drop unique constraint on squad.name (squads with the same
  name are legitimate across teams; uniqueness was an accidental
  product constraint)
- 088: add squad.instructions (text, default '')
- UpdateSquad now COALESCEs avatar_url + instructions
- handler exposes Instructions in SquadResponse and accepts it in
  UpdateSquad

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(squad): assignable + mention target; trigger leader on assign

- assignee picker and @mention suggestion list squads alongside
  agents and members; renders squad avatar/icon
- creating or updating an issue with assignee_type=squad enqueues
  a task for the squad's current leader (mirrors agent-assignee
  parking-lot rule: skip backlog only)
- workspace queries/hooks expose squads where needed for the
  pickers
- locales updated for new picker copy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(squad): agent-style detail page with members + instructions tabs

- restructure squad detail page to mirror the agent detail page:
  320px inspector (creator, leader, created/updated) + tabbed
  pane (Members | Instructions) with dirty-guard AlertDialog
- inline name + avatar editing on the inspector
- inline description editor (modal textarea)
- members tab: leader + member picker with role descriptions,
  swap leader, edit member roles, remove
- instructions tab: ContentEditor + Save (mirrors agent pattern)
- squads list shows the squad avatar/icon
- core types + api.updateSquad accept avatar_url + instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(squad): inject leader briefing on claim (protocol + roster + instructions)

When a squad's leader agent claims a task on a squad-assigned issue,
append a system-level briefing to the agent's Instructions composed of:

1. Squad Operating Protocol — hard-coded rules: leader is a
   coordinator, dispatch via @mention, stop after dispatching,
   resume on re-trigger, do not work outside the roster.
2. Squad Roster — leader self-row plus one row per non-archived
   member with a literal mention markdown string ([@Name](mention://
   agent|member/<UUID>)) the leader can paste verbatim. Round-trips
   through util.ParseMentions, enforced by a contract test.
3. Squad Instructions — the user-defined squad.instructions block,
   omitted entirely when empty so we do not leave a dangling heading.

Non-leader members claiming the same issue receive no briefing.

Tests cover: full squad with mixed agent/human members, lone leader,
archived agents skipped, empty user instructions, mention round-trip,
and the leader/non-leader claim-handler gate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(squad): tell leader not to restate issue context in dispatch comment

After observing leaders padding their delegation comments with full
re-summaries of the issue body and prior discussion, make the
Operating Protocol explicit:

- assignees on Multica already have the full issue (title,
  description, all comments, attachments) and workspace context;
- delegation comments should add only what cannot be inferred
  (who is picked, why, extra constraints), aim for two or three
  sentences;
- restating context is now an explicit hard rule violation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(squad): unify leader evaluation into activity_log, add CLI command

- Squad member comments now trigger leader (only leader self-excluded)
- Replace squad_activity_log with activity_log (action: squad_leader_evaluated)
- Add CLI: multica squad activity <issue-id> <outcome> --reason
- Add API: POST /api/issues/{id}/squad-evaluated
- Update squad operating protocol to require evaluation recording
- Remove squad_activity_log table from schema and generated code

* feat(cli): add squad list, get, member list commands

* fix(squad): address review findings (P1+P2)

P1 fixes:
- Add 'squads' to reserved_slugs.json (source of truth)
- Add 'create-squad' to ModalType union
- Remove unused leaderOpen/selectedLeader in create-squad modal
- Replace literal JSX strings with i18n selectors (en + zh-Hans)

P2 fixes:
- Add 'squad' to mention regex (MentionRe)
- Fix human member lookup in squad briefing (use GetUser directly)
- Add squads routes to desktop app
- Add squad:created/updated/deleted to WSEventType + invalidation
- Reject archived squads as issue assignees

* fix(squad): restore zh-Hans key, publish activity event, invalidate issues on archive

- Restore create_project.title in zh-Hans modals.json (dropped by prior edit)
- Publish activity:created WS event after squad leader evaluation
- Invalidate issue queries on squad:deleted (archive transfers assignees)
- Add creator info to squad list cards

* fix(squad): realtime sync, rerun support, leader validation

- Use workspaceKeys.squads prefix for detail/member queries (realtime invalidation)
- Publish squad:updated after add/remove/role-change member mutations
- Support rerun for squad-assigned issues (targets leader agent)
- Reject assignment to squads whose leader is archived

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 18:46:20 +08:00

207 lines
8.2 KiB
Go

package handler
import (
"context"
"strings"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// squadOperatingProtocol is the hard-coded system-level briefing prepended to
// every squad-leader claim. It explains the leader's coordinator role, the
// @mention dispatch mechanism, and the stop-after-dispatch contract.
//
// Keep this text English-only (matches existing agent-harness conventions)
// and keep the mention syntax exactly aligned with util.MentionRe — the
// "Squad Roster" block below renders concrete examples that round-trip
// through util.ParseMentions, and the protocol text refers to that format.
const squadOperatingProtocol = `## Squad Operating Protocol
You are the LEADER of a squad. Your job is to **coordinate**, not to execute
the work yourself.
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.
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
issue (title, description, all prior comments, attachments) and
the surrounding workspace. Do NOT restate or summarise the
issue body, prior discussion, or known facts in your delegation
comment — they read it themselves.
- Say only what cannot be inferred from the issue: who you're
picking, why them (one short clause), and any *additional*
constraints, hints, or sequencing you want them to follow.
Two or three sentences is usually plenty.
- Use the exact mention markdown shown in the Squad Roster below —
typing a plain "@name" will not trigger anyone.
3. **Record your evaluation.** After every trigger — whether you delegated,
decided no action is needed, or encountered an error — record it:
` + "`" + `multica squad activity <issue-id> <outcome> --reason "<short reason>"` + "`" + `
Outcome values: ` + "`" + `action` + "`" + ` (you delegated or acted),
` + "`" + `no_action` + "`" + ` (you evaluated and decided nothing is needed),
` + "`" + `failed` + "`" + ` (you hit an error).
This is mandatory on every turn — it records your decision in the
issue timeline so humans can see you evaluated the trigger.
4. **Stop after dispatching.** Once your delegation comment is posted
and evaluation recorded, end your turn. Do not continue working,
do not write code, do not open files. You will be re-triggered
automatically when:
- a delegated member posts an update or asks you a question;
- a delegated member finishes and the issue moves forward;
- someone @mentions you again on this issue.
5. **Re-evaluate on each trigger.** When you wake up again, read the new
activity and decide whether to delegate the next step, escalate to
the human reporter, or close the loop. If no action is needed
(e.g. a member posted a progress update that requires no response),
record ` + "`" + `no_action` + "`" + ` and exit silently.
Hard rules:
- Do NOT restate the issue body or prior comments in your delegation —
the assignee already has them. Repeating context is noise that
buries the actual instruction.
- Do NOT do the implementation work yourself unless the squad has no
other suitable members. The squad exists so work is split — bypassing
it defeats the point.
- Do NOT @mention members who don't appear in the Squad Roster below;
they are not part of this squad.
- One delegation comment per turn is enough. Avoid spamming multiple
near-identical comments.
- If the squad has no member capable of the task, post a comment
explaining the gap (and @mention the issue's reporter if possible)
rather than silently doing the work.
- ALWAYS call ` + "`" + `multica squad activity` + "`" + ` before ending your turn —
even when the outcome is no_action.`
// buildSquadLeaderBriefing composes the full system briefing appended to a
// squad leader's Instructions when it claims a task on a squad-assigned
// issue. The returned string contains three sections:
//
// 1. Squad Operating Protocol (constant, system-level rules).
// 2. Squad Roster (data — leader self-row + members with literal
// `[@Name](mention://<type>/<UUID>)` strings ready to paste).
// 3. Squad Instructions (user-defined `squad.instructions`, omitted when
// empty so we don't leave a dangling heading).
//
// Archived agent members are skipped — there's no point asking the leader
// to delegate to a retired agent. Members whose underlying record can't be
// loaded (deleted user/agent races, FK weirdness) are also skipped silently.
func buildSquadLeaderBriefing(ctx context.Context, q *db.Queries, squad db.Squad) string {
var sb strings.Builder
sb.WriteString(squadOperatingProtocol)
sb.WriteString("\n\n")
sb.WriteString(buildSquadRoster(ctx, q, squad))
if trimmed := strings.TrimSpace(squad.Instructions); trimmed != "" {
sb.WriteString("\n\n## Squad Instructions (")
sb.WriteString(squad.Name)
sb.WriteString(")\n\n")
sb.WriteString(trimmed)
}
return sb.String()
}
// buildSquadRoster renders the "## Squad Roster" section: a leader self-row
// plus one row per non-archived member, with literal mention markdown.
func buildSquadRoster(ctx context.Context, q *db.Queries, squad db.Squad) string {
var sb strings.Builder
sb.WriteString("## Squad Roster\n\n")
// Leader self-row. Leaders are always agents (FK enforced in schema).
leaderName := "Leader"
if leader, err := q.GetAgent(ctx, squad.LeaderID); err == nil {
leaderName = leader.Name
}
sb.WriteString("Leader (you):\n")
sb.WriteString("- ")
sb.WriteString(leaderName)
sb.WriteString(" — agent — `")
sb.WriteString(formatMention(leaderName, "agent", util.UUIDToString(squad.LeaderID)))
sb.WriteString("`\n")
members, err := q.ListSquadMembers(ctx, squad.ID)
if err != nil {
members = nil
}
rows := make([]string, 0, len(members))
for _, m := range members {
// Skip the leader if they happen to also be in the member list —
// they're already shown above and we don't want self-delegation.
if m.MemberType == "agent" && util.UUIDToString(m.MemberID) == util.UUIDToString(squad.LeaderID) {
continue
}
row := renderMemberRow(ctx, q, m)
if row != "" {
rows = append(rows, row)
}
}
if len(rows) == 0 {
sb.WriteString("\nMembers: (none — you are the only member of this squad)\n")
return sb.String()
}
sb.WriteString("\nMembers:\n")
for _, r := range rows {
sb.WriteString(r)
}
return sb.String()
}
// renderMemberRow renders a single roster row, returning "" if the member
// can't be resolved or should be skipped (e.g. archived agent).
func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember) string {
id := util.UUIDToString(m.MemberID)
role := strings.TrimSpace(m.Role)
switch m.MemberType {
case "agent":
ag, err := q.GetAgent(ctx, m.MemberID)
if err != nil {
return ""
}
if ag.ArchivedAt.Valid {
return ""
}
return formatRosterRow(ag.Name, "agent", role, formatMention(ag.Name, "agent", id))
case "member":
user, err := q.GetUser(ctx, m.MemberID)
if err != nil {
return ""
}
// Mention syntax for humans uses the user_id (matches the rest of
// the product — see util.MentionRe and frontend mention payloads).
userID := util.UUIDToString(m.MemberID)
return formatRosterRow(user.Name, "member (human)", role, formatMention(user.Name, "member", userID))
default:
return ""
}
}
func formatRosterRow(name, kind, role, mention string) string {
var sb strings.Builder
sb.WriteString("- ")
sb.WriteString(name)
sb.WriteString(" — ")
sb.WriteString(kind)
if role != "" {
sb.WriteString(`, role: "`)
sb.WriteString(role)
sb.WriteString(`"`)
}
sb.WriteString(" — `")
sb.WriteString(mention)
sb.WriteString("`\n")
return sb.String()
}
// formatMention emits a mention markdown string that round-trips through
// util.ParseMentions. The label is the human display name; the link target
// uses the mention:// scheme with the entity type and UUID.
func formatMention(name, mentionType, id string) string {
return "[@" + name + "](mention://" + mentionType + "/" + id + ")"
}