Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients. Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test. Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native. Refs: D1 Co-authored-by: Cursor <cursoragent@cursor.com>
214 lines
6.7 KiB
Go
214 lines
6.7 KiB
Go
package platform
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Squad is a leader plus agent and human members.
|
|
type Squad struct {
|
|
ID, WorkspaceID, Name, LeaderAgentID, Instructions string
|
|
}
|
|
|
|
// SquadMember is one roster row. Kind is agent or human.
|
|
type SquadMember struct {
|
|
Kind, ID, Name, RoleDescription string
|
|
}
|
|
|
|
func (s *Store) CreateSquad(squad Squad, members []SquadMember) (Squad, error) {
|
|
if squad.ID == "" {
|
|
squad.ID = newID()
|
|
}
|
|
if _, err := s.db.Exec(`INSERT INTO squad(id,workspace_id,name,leader_agent_id,instructions,created_at) VALUES(?,?,?,?,?,?)`,
|
|
squad.ID, squad.WorkspaceID, squad.Name, squad.LeaderAgentID, squad.Instructions, now()); err != nil {
|
|
return Squad{}, err
|
|
}
|
|
for _, m := range members {
|
|
if _, err := s.db.Exec(`INSERT INTO squad_member(squad_id,member_kind,member_id,role_description) VALUES(?,?,?,?)`,
|
|
squad.ID, m.Kind, m.ID, m.RoleDescription); err != nil {
|
|
return Squad{}, err
|
|
}
|
|
}
|
|
return squad, nil
|
|
}
|
|
|
|
// LeaderBrief is the fixed protocol, the roster with mention links, and the instructions.
|
|
func (s *Store) LeaderBrief(squadID string) (string, error) {
|
|
var name, instructions, leader string
|
|
if err := s.db.QueryRow(`SELECT name, instructions, leader_agent_id FROM squad WHERE id=?`, squadID).Scan(&name, &instructions, &leader); err != nil {
|
|
return "", err
|
|
}
|
|
leaderAgent, err := s.GetAgent(leader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("## Squad Operating Protocol\n")
|
|
b.WriteString("Delegate with one short comment that mentions the owner. Record squad activity every turn as action, no_action, or failed. Stop after dispatch. Do not implement the task yourself. Do not both mention a member and create a todo sub-issue for the same work.\n\n")
|
|
b.WriteString("## Squad Roster\n")
|
|
fmt.Fprintf(&b, "- [@%s](mention://agent/%s) leader of %s\n", leaderAgent.Name, leaderAgent.ID, name)
|
|
rows, err := s.db.Query(`SELECT member_kind, member_id, role_description FROM squad_member WHERE squad_id=?`, squadID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
type rosterRow struct{ kind, id, role string }
|
|
var roster []rosterRow
|
|
for rows.Next() {
|
|
var row rosterRow
|
|
if err := rows.Scan(&row.kind, &row.id, &row.role); err != nil {
|
|
rows.Close()
|
|
return "", err
|
|
}
|
|
roster = append(roster, row)
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, row := range roster {
|
|
memberName := row.id
|
|
if row.kind == "agent" {
|
|
if a, err := s.GetAgent(row.id); err == nil {
|
|
memberName = a.Name
|
|
}
|
|
} else {
|
|
_ = s.db.QueryRow(`SELECT name FROM member WHERE id=?`, row.id).Scan(&memberName)
|
|
}
|
|
scheme := "member"
|
|
if row.kind == "agent" {
|
|
scheme = "agent"
|
|
}
|
|
fmt.Fprintf(&b, "- [@%s](mention://%s/%s) %s\n", memberName, scheme, row.id, row.role)
|
|
}
|
|
b.WriteString("\n## Squad Instructions\n")
|
|
b.WriteString(instructions)
|
|
b.WriteString("\n")
|
|
return b.String(), nil
|
|
}
|
|
|
|
// RecordActivity appends one squad activity row. action is action, no_action, or failed.
|
|
func (s *Store) RecordActivity(squadID, issueID, actorID, action, detail string) error {
|
|
switch action {
|
|
case "action", "no_action", "failed":
|
|
default:
|
|
return fmt.Errorf("activity action must be action, no_action, or failed")
|
|
}
|
|
_, err := s.db.Exec(`INSERT INTO activity(id,squad_id,issue_id,actor_id,action,detail,created_at) VALUES(?,?,?,?,?,?,?)`,
|
|
newID(), squadID, issueID, actorID, action, detail, now())
|
|
return err
|
|
}
|
|
|
|
// TouchStatus lets the squad leader move an issue. The first turn sets in_progress.
|
|
// goalMet sets in_review. Other actors are refused.
|
|
func (s *Store) TouchStatus(issueID, actorAgentID string, goalMet bool) error {
|
|
issue, err := s.GetIssue(issueID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if issue.AssigneeKind != "squad" {
|
|
return fmt.Errorf("status changes require a squad assignee")
|
|
}
|
|
var leader string
|
|
if err := s.db.QueryRow(`SELECT leader_agent_id FROM squad WHERE id=?`, issue.AssigneeID).Scan(&leader); err != nil {
|
|
return err
|
|
}
|
|
if actorAgentID != leader {
|
|
return fmt.Errorf("only the squad leader may change issue status")
|
|
}
|
|
next := issue.Status
|
|
if goalMet {
|
|
next = "in_review"
|
|
} else if issue.Status == "todo" || issue.Status == "" {
|
|
next = "in_progress"
|
|
}
|
|
_, err = s.db.Exec(`UPDATE issue SET status=? WHERE id=?`, next, issueID)
|
|
return err
|
|
}
|
|
|
|
// CloseIssue marks a sub-issue done and wakes the leader when every
|
|
// sibling in the same stage is closed. Delegation depth is capped at 4.
|
|
func (s *Store) CloseIssue(issueID string) error {
|
|
issue, err := s.GetIssue(issueID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if depth, err := s.issueDepth(issue); err != nil {
|
|
return err
|
|
} else if depth > 4 {
|
|
return fmt.Errorf("delegation depth cap is 4")
|
|
}
|
|
if _, err := s.db.Exec(`UPDATE issue SET status='done' WHERE id=?`, issueID); err != nil {
|
|
return err
|
|
}
|
|
if issue.ParentID == "" || issue.Stage == "" {
|
|
return nil
|
|
}
|
|
var open int
|
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM issue WHERE parent_id=? AND stage=? AND status NOT IN ('done','cancelled')`, issue.ParentID, issue.Stage).Scan(&open); err != nil {
|
|
return err
|
|
}
|
|
if open > 0 {
|
|
return nil
|
|
}
|
|
parent, err := s.GetIssue(issue.ParentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if parent.AssigneeKind != "squad" {
|
|
return nil
|
|
}
|
|
var leader string
|
|
if err := s.db.QueryRow(`SELECT leader_agent_id FROM squad WHERE id=?`, parent.AssigneeID).Scan(&leader); err != nil {
|
|
return err
|
|
}
|
|
_, err = s.enqueue(parent, leader, true, parent.AssigneeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(`INSERT INTO wakeup(id,task_id,kind,fire_at,created_at) VALUES(?,?,?,?,?)`,
|
|
newID(), parent.ID, "child_done", "", now())
|
|
return err
|
|
}
|
|
|
|
func (s *Store) issueDepth(issue Issue) (int, error) {
|
|
depth := 0
|
|
cur := issue
|
|
for cur.ParentID != "" && depth < 8 {
|
|
depth++
|
|
next, err := s.GetIssue(cur.ParentID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
cur = next
|
|
}
|
|
return depth, nil
|
|
}
|
|
|
|
// PlanStages creates one sub-issue per stage title under parent.
|
|
func (s *Store) PlanStages(parentID string, stages []string) ([]Issue, error) {
|
|
parent, err := s.GetIssue(parentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []Issue
|
|
for i, title := range stages {
|
|
child, err := s.CreateIssue(Issue{
|
|
WorkspaceID: parent.WorkspaceID, Title: title, Body: title,
|
|
Status: "todo", ParentID: parent.ID, Stage: fmt.Sprintf("%d", i+1),
|
|
AssigneeKind: parent.AssigneeKind, AssigneeID: parent.AssigneeID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, child)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DelegateComment posts a mention comment. It does not write a JSON file.
|
|
func (s *Store) DelegateComment(issueID, authorAgentID, mentionedAgentID, body string) (Comment, error) {
|
|
text := body + " [@" + mentionedAgentID + "](mention://agent/" + mentionedAgentID + ")"
|
|
return s.AddComment(Comment{IssueID: issueID, AuthorKind: "agent", AuthorID: authorAgentID, Body: text})
|
|
}
|