diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 1fd5b709b..599f3c045 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -36,6 +36,7 @@ No team exists yet. Build one. - Derive resonance signals from the session and repo context. - Select a universe. Allocate character names from that universe. - Scribe is always "Scribe" โ€” exempt from casting. + - Ralph is always "Ralph" โ€” exempt from casting. 4. Propose the team with their cast names. Example (names will vary per cast): ``` @@ -44,6 +45,7 @@ No team exists yet. Build one. ๐Ÿ”ง {CastName3} โ€” Backend Dev APIs, database, services ๐Ÿงช {CastName4} โ€” Tester Tests, quality, edge cases ๐Ÿ“‹ Scribe โ€” (silent) Memory, decisions, session logs +๐Ÿ”„ Ralph โ€” (monitor) Work queue, backlog, keep-alive ``` 5. Ask: *"Look right? Say **yes**, **add someone**, or **change a role**. (Or just give me a task to start!)"* @@ -204,6 +206,8 @@ The routing table determines **WHO** handles work. After routing, use Response M | Ceremony request ("design meeting", "run a retro") | Run the matching ceremony from `ceremonies.md` (see Ceremonies) | | Issues/backlog request ("pull issues", "show backlog", "work on #N") | Follow GitHub Issues Mode (see that section) | | PRD intake ("here's the PRD", "read the PRD at X", pastes spec) | Follow PRD Mode (see that section) | +| Human member management ("add Brady as PM", routes to human) | Follow Human Team Members (see that section) | +| Ralph commands ("Ralph, go", "keep working", "Ralph, status", "Ralph, idle") | Follow Ralph โ€” Work Monitor (see that section) | | General work request | Check routing.md, spawn best match + any anticipatory agents | | Quick factual question | Answer directly (no spawn) | | Ambiguous | Pick the most likely agent; say who you chose | @@ -773,6 +777,8 @@ prompt: | 6. **Immediately assess:** Does anything from these results trigger follow-up work? If so, launch follow-up agents NOW โ€” don't wait for the user to ask. Keep the pipeline moving. +7. **Ralph check:** If Ralph is active (see Ralph โ€” Work Monitor), after chaining any follow-up work, IMMEDIATELY run Ralph's work-check cycle (Step 1). Do NOT stop. Do NOT wait for user input. Ralph keeps the pipeline moving until the board is clear. + ### Ceremonies Ceremonies are structured team meetings where agents align before or after work. Each squad configures its own ceremonies in `.ai-team/ceremonies.md`. @@ -1023,7 +1029,8 @@ After selecting a universe: 1. Choose character names that imply pressure, function, or consequence โ€” NOT authority or literal role descriptions. 2. Each agent gets a unique name. No reuse within the same repo unless an agent is explicitly retired and archived. 3. **Scribe is always "Scribe"** โ€” exempt from casting. -4. **@copilot is always "@copilot"** โ€” exempt from casting. If the user says "add team member copilot" or "add copilot", this is the GitHub Copilot coding agent. Do NOT cast a name โ€” follow the Copilot Coding Agent Member section instead. +4. **Ralph is always "Ralph"** โ€” exempt from casting. +5. **@copilot is always "@copilot"** โ€” exempt from casting. If the user says "add team member copilot" or "add copilot", this is the GitHub Copilot coding agent. Do NOT cast a name โ€” follow the Copilot Coding Agent Member section instead. 5. Store the mapping in `.ai-team/casting/registry.json`. 5. Record the assignment snapshot in `.ai-team/casting/history.json`. 6. Use the allocated name everywhere: charter.md, history.md, team.md, routing.md, spawn prompts. @@ -1188,6 +1195,120 @@ Before connecting to a GitHub repository, verify that the `gh` CLI is available | "show the backlog" / "what issues are open?" | List issues from connected repo | | "work on issue #N" / "pick up #N" | Route issue to appropriate agent | | "work on all issues" / "start the backlog" | Route all open issues (batched) | + +--- + +## Ralph โ€” Work Monitor + +Ralph is a built-in squad member whose job is keeping tabs on work. Like Scribe tracks decisions, **Ralph tracks and drives the work queue**. Ralph is always on the roster โ€” not cast from a universe โ€” and has one job: make sure the team never sits idle when there's work to do. + +**โšก CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous loop โ€” scan for work, do the work, scan again, repeat โ€” until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep going.** + +### Roster Entry + +Ralph always appears in `team.md`: + +```markdown +| Ralph | Work Monitor | โ€” | ๐Ÿ”„ Monitor | +``` + +### Triggers + +| User says | Action | +|-----------|--------| +| "Ralph, go" / "Ralph, start monitoring" | Activate Ralph's work-check loop | +| "Keep working" / "Work until done" | Activate Ralph | +| "Ralph, status" / "What's on the board?" / "How's the backlog?" | Run one work-check cycle, report results, don't loop | +| "Ralph, idle" / "Take a break" / "Stop monitoring" | Deactivate Ralph, stop looping | +| "Ralph, scope: just issues" / "Ralph, skip CI" | Adjust what Ralph monitors this session | + +### Work-Check Cycle + +When Ralph is active, run this check cycle after every batch of agent work completes (or immediately on activation): + +**Step 1 โ€” Scan for work** (run these in parallel): + +```bash +# Untriaged issues (labeled squad but no squad:{member} sub-label) +gh issue list --label "squad" --state open --json number,title,labels,assignees --limit 20 + +# Member-assigned issues (labeled squad:{member}, still open) +gh issue list --state open --json number,title,labels,assignees --limit 20 | # filter for squad:* labels + +# Open PRs from squad members +gh pr list --state open --json number,title,author,labels,isDraft,reviewDecision --limit 20 + +# Draft PRs (agent work in progress) +gh pr list --state open --draft --json number,title,author,labels,checks --limit 20 +``` + +**Step 2 โ€” Categorize findings:** + +| Category | Signal | Action | +|----------|--------|--------| +| **Untriaged issues** | `squad` label, no `squad:{member}` label | Lead triages: reads issue, assigns `squad:{member}` label | +| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Spawn the assigned agent to pick it up | +| **Draft PRs** | PR in draft from squad member | Check if agent needs to continue; if stalled, nudge | +| **Review feedback** | PR has `CHANGES_REQUESTED` review | Route feedback to PR author agent to address | +| **CI failures** | PR checks failing | Notify assigned agent to fix, or create a fix issue | +| **Approved PRs** | PR approved, CI green, ready to merge | Merge and close related issue | +| **No work found** | All clear | Report: "๐Ÿ“‹ Board is clear. Ralph is idling." | + +**Step 3 โ€” Act on highest-priority item:** +- Process one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) +- Spawn agents as needed, collect results +- **โšก CRITICAL: After results are collected, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop โ€” Ralph keeps cycling until the board is clear or the user says "idle". Each cycle is one "round". +- If multiple items exist in the same category, process them in parallel (spawn multiple agents) + +**Step 4 โ€” Periodic check-in** (every 3-5 rounds): + +After every 3-5 rounds, pause and report before continuing: + +``` +๐Ÿ”„ Ralph: Round {N} complete. + โœ… {X} issues closed, {Y} PRs merged + ๐Ÿ“‹ {Z} items remaining: {brief list} + Continuing... (say "Ralph, idle" to stop) +``` + +**Do NOT ask for permission to continue.** Just report and keep going. The user must explicitly say "idle" or "stop" to break the loop. If the user provides other input during a round, process it and then resume the loop. + +### Ralph State + +Ralph's state is session-scoped (not persisted to disk): +- **Active/idle** โ€” whether the loop is running +- **Round count** โ€” how many check cycles completed +- **Scope** โ€” what categories to monitor (default: all) +- **Stats** โ€” issues closed, PRs merged, items processed this session + +### Ralph on the Board + +When Ralph reports status, use this format: + +``` +๐Ÿ”„ Ralph โ€” Work Monitor +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +๐Ÿ“Š Board Status: + ๐Ÿ”ด Untriaged: 2 issues need triage + ๐ŸŸก In Progress: 3 issues assigned, 1 draft PR + ๐ŸŸข Ready: 1 PR approved, awaiting merge + โœ… Done: 5 issues closed this session + +Next action: Triaging #42 โ€” "Fix auth endpoint timeout" +``` + +### Integration with Follow-Up Work + +After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's work-check cycle. **Do NOT return control to the user.** This creates a continuous pipeline: + +1. User activates Ralph โ†’ work-check cycle runs +2. Work found โ†’ agents spawned โ†’ results collected +3. Follow-up work assessed โ†’ more agents if needed +4. Ralph scans GitHub again (Step 1) โ†’ IMMEDIATELY, no pause +5. More work found โ†’ repeat from step 2 +6. No more work โ†’ "๐Ÿ“‹ Board is clear. Ralph is idling." + +**Ralph does NOT ask "should I continue?" โ€” Ralph KEEPS GOING.** The only things that stop Ralph: the board is clear, the user says "idle"/"stop", or the session ends. | References PR feedback, review comments, or changes requested on a PR | Spawn agent to address PR review feedback | | "merge PR #N" / "merge it" (when a PR was discussed in the last 2-3 turns) | Merge the PR via `gh pr merge` | diff --git a/README.md b/README.md index ab55c36b0..284afbd7f 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ When agents finish, the coordinator immediately chains follow-up work โ€” tests | ๐Ÿ—๏ธ **Lead** | Scope, team roster, first decisions | Architecture trade-offs, risk register | Full project history, tech debt map | | ๐Ÿงช **Tester** | Test framework, first test cases | Integration patterns, edge case catalog | Regression patterns, coverage gaps, CI pipeline | | ๐Ÿ“‹ **Scribe** | First session logged | Cross-team decisions propagated | Full searchable archive of every session and decision | +| ๐Ÿ”„ **Ralph** | Board check after first batch | Auto-triage, CI monitoring | Continuous backlog processing, zero idle time | Each agent's knowledge is personal โ€” stored in its own `history.md`. Team-wide decisions live in `decisions.md`, where every agent reads before working. The more you use Squad, the less context you have to repeat. @@ -106,6 +107,7 @@ graph TB C -->|spawns| R C -->|spawns| T C -.->|silent| S["๐Ÿ“‹ Scribe"] + C -.->|monitors| RL["๐Ÿ”„ Ralph"] subgraph memory [" ๐Ÿง  Shared Memory "] direction LR @@ -234,6 +236,7 @@ The Coordinator enforces this. No self-review of rejected work. - [**Skills System**](docs/features/skills.md) โ€” Earned knowledge with confidence lifecycle - [**Tiered Response Modes**](docs/features/response-modes.md) โ€” Direct/Lightweight/Standard/Full response depth - [**Smart Upgrade**](docs/scenarios/upgrading.md) โ€” Version-aware upgrades with migrations +- [**Ralph โ€” Work Monitor**](docs/features/ralph.md) โ€” Built-in squad member that keeps the team working through backlogs autonomously --- diff --git a/docs/features/ralph.md b/docs/features/ralph.md new file mode 100644 index 000000000..1de2bae15 --- /dev/null +++ b/docs/features/ralph.md @@ -0,0 +1,106 @@ +# Ralph โ€” Work Monitor + +Ralph is a built-in squad member whose job is keeping tabs on work. Like Scribe tracks decisions, **Ralph tracks and drives the work queue**. He's always on the roster โ€” not cast from a universe โ€” and has one job: make sure the team never sits idle when there's work to do. + +## How It Works + +Once activated, Ralph continuously checks for pending work โ€” open issues, draft PRs, review feedback, CI failures โ€” and keeps the squad moving through the backlog without manual nudges. + +### In-Session (Copilot Chat) + +When you're in a Copilot session, Ralph self-chains the coordinator's work loop: + +1. Agents complete a batch of work +2. Ralph checks GitHub for more: untriaged issues, assigned-but-unstarted items, draft PRs, failing CI +3. Work found โ†’ triage, assign, spawn agents +4. Results collected โ†’ Ralph checks again **immediately** โ€” no pause, no asking permission +5. Board clear โ†’ Ralph idles + +**Ralph never stops on his own while work remains.** He keeps cycling through the backlog until every issue is closed, every PR is merged, and CI is green. The only things that stop Ralph: the board is clear, you say "idle", or the session ends. + +### Between Sessions (GitHub Actions Heartbeat) + +When no one is at the keyboard, the `squad-heartbeat.yml` workflow runs on a cron schedule (every 30 minutes by default). It: + +- Finds untriaged `squad`-labeled issues +- Auto-triages based on team roles and issue keywords +- Assigns `squad:{member}` labels +- For `@copilot` (if enabled with auto-assign): assigns `copilot-swe-agent[bot]` so the coding agent picks up work autonomously + +This creates a fully autonomous loop for `@copilot` โ€” heartbeat triages โ†’ assigns โ†’ agent works โ†’ issue closed โ†’ heartbeat finds next issue โ†’ repeat. + +## Talking to Ralph + +| What you say | What happens | +|---|---| +| "Ralph, go" / "Ralph, start monitoring" | Activates the work-check loop | +| "Keep working" / "Work until done" | Activates Ralph | +| "Ralph, status" / "What's on the board?" | Runs one check cycle, reports results | +| "Ralph, idle" / "Take a break" | Stops the loop | +| "Ralph, scope: just issues" | Monitors only issues, skips PRs/CI | + +## What Ralph Monitors + +| Category | Signal | Action | +|---|---|---| +| **Untriaged issues** | `squad` label, no `squad:{member}` label | Lead triages and assigns | +| **Assigned issues** | `squad:{member}` label, no assignee/PR yet | Spawn agent to pick it up | +| **Draft PRs** | Squad member PR still in draft | Check if agent is stalled | +| **Review feedback** | Changes requested on PR | Route to author agent | +| **CI failures** | PR checks failing | Notify agent to fix | +| **Approved PRs** | Ready to merge | Merge and close issue | + +## Periodic Check-In + +Ralph doesn't run silently forever. Every 3-5 rounds, Ralph reports and **keeps going**: + +``` +๐Ÿ”„ Ralph: Round 3 complete. + โœ… 2 issues closed, 1 PR merged + ๐Ÿ“‹ 3 items remaining: #42, #45, PR #12 + Continuing... (say "Ralph, idle" to stop) +``` + +Ralph does **not** ask permission to continue โ€” he keeps working. The only things that stop Ralph: the board is clear, you say "idle"/"stop", or the session ends. + +## Ralph's Board View + +When you ask for status: + +``` +๐Ÿ”„ Ralph โ€” Work Monitor +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +๐Ÿ“Š Board Status: + ๐Ÿ”ด Untriaged: 2 issues need triage + ๐ŸŸก In Progress: 3 issues assigned, 1 draft PR + ๐ŸŸข Ready: 1 PR approved, awaiting merge + โœ… Done: 5 issues closed this session +``` + +## Heartbeat Workflow Setup + +The heartbeat workflow (`squad-heartbeat.yml`) is automatically installed during `init` or `upgrade`. It runs: + +- **On a schedule**: Every 30 minutes (configurable in the workflow file) +- **On issue close**: Checks for next item in backlog +- **On PR merge**: Checks for follow-up work +- **On manual dispatch**: Trigger via GitHub Actions UI + +### Adjusting the Schedule + +Edit `.github/workflows/squad-heartbeat.yml`: + +```yaml +on: + schedule: + - cron: '*/30 * * * *' # Every 30 min (default) + # - cron: '0 * * * *' # Every hour + # - cron: '0 9-17 * * 1-5' # Work hours only (M-F 9am-5pm UTC) +``` + +## Notes + +- Ralph is session-scoped โ€” his state (active/idle, round count, stats) resets each session +- Ralph appears on the roster like Scribe: `| Ralph | Work Monitor | โ€” | ๐Ÿ”„ Monitor |` +- Ralph is exempt from universe casting โ€” always "Ralph" +- The heartbeat workflow is the between-session complement to in-session Ralph diff --git a/index.js b/index.js index 325050a64..ecc2641a7 100644 --- a/index.js +++ b/index.js @@ -548,19 +548,19 @@ if (isUpgrade) { console.log(`${GREEN}โœ“${RESET} ${BOLD}upgraded${RESET} .github/copilot-instructions.md`); } - // Always update squad-owned workflows even when version matches - const workflowsSrcEarly = path.join(root, 'templates', 'workflows'); - const workflowsDestEarly = path.join(dest, '.github', 'workflows'); - if (fs.existsSync(workflowsSrcEarly) && fs.statSync(workflowsSrcEarly).isDirectory()) { - const wfFiles = fs.readdirSync(workflowsSrcEarly).filter(f => f.endsWith('.yml')); - fs.mkdirSync(workflowsDestEarly, { recursive: true }); + // Refresh squad-owned files even when version matches + const workflowsSrc = path.join(root, 'templates', 'workflows'); + const workflowsDest = path.join(dest, '.github', 'workflows'); + if (fs.existsSync(workflowsSrc) && fs.statSync(workflowsSrc).isDirectory()) { + const wfFiles = fs.readdirSync(workflowsSrc).filter(f => f.endsWith('.yml')); + fs.mkdirSync(workflowsDest, { recursive: true }); for (const file of wfFiles) { - fs.copyFileSync(path.join(workflowsSrcEarly, file), path.join(workflowsDestEarly, file)); + fs.copyFileSync(path.join(workflowsSrc, file), path.join(workflowsDest, file)); } console.log(`${GREEN}โœ“${RESET} ${BOLD}upgraded${RESET} squad workflows (${wfFiles.length} files)`); } - // Always refresh squad.agent.md (may have changed on same version via branch) + // Refresh squad.agent.md try { fs.mkdirSync(path.dirname(agentDest), { recursive: true }); fs.copyFileSync(agentSrc, agentDest); diff --git a/templates/roster.md b/templates/roster.md index 9e2a8d5cd..d8fee8526 100644 --- a/templates/roster.md +++ b/templates/roster.md @@ -17,6 +17,7 @@ | {Name} | {Role} | `.ai-team/agents/{name}/charter.md` | โœ… Active | | {Name} | {Role} | `.ai-team/agents/{name}/charter.md` | โœ… Active | | Scribe | Session Logger | `.ai-team/agents/scribe/charter.md` | ๐Ÿ“‹ Silent | +| Ralph | Work Monitor | โ€” | ๐Ÿ”„ Monitor | ## Coding Agent diff --git a/templates/workflows/squad-heartbeat.yml b/templates/workflows/squad-heartbeat.yml new file mode 100644 index 000000000..a76f07f9d --- /dev/null +++ b/templates/workflows/squad-heartbeat.yml @@ -0,0 +1,286 @@ +name: Squad Heartbeat (Ralph) + +on: + schedule: + # Every 30 minutes โ€” adjust or remove if not needed + - cron: '*/30 * * * *' + + # React to completed work + issues: + types: [closed] + pull_request: + types: [closed] + + # React to new squad work + issues: + types: [labeled] + + # Manual trigger + workflow_dispatch: + +permissions: + issues: write + contents: read + pull-requests: read + +jobs: + heartbeat: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Ralph โ€” Check for squad work + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + // Read team roster + const teamFile = '.ai-team/team.md'; + if (!fs.existsSync(teamFile)) { + core.info('No .ai-team/team.md found โ€” Ralph has nothing to monitor'); + return; + } + + const content = fs.readFileSync(teamFile, 'utf8'); + + // Check if Ralph is on the roster + if (!content.includes('Ralph') || !content.includes('๐Ÿ”„')) { + core.info('Ralph not on roster โ€” heartbeat disabled'); + return; + } + + // Parse members from roster + const lines = content.split('\n'); + const members = []; + let inMembersTable = false; + for (const line of lines) { + if (line.startsWith('## Members')) { + inMembersTable = true; + continue; + } + if (inMembersTable && line.startsWith('## ')) break; + if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) { + members.push({ + name: cells[0], + role: cells[1], + label: `squad:${cells[0].toLowerCase()}` + }); + } + } + } + + if (members.length === 0) { + core.info('No squad members found โ€” nothing to monitor'); + return; + } + + // 1. Find untriaged issues (labeled "squad" but no "squad:{member}" label) + const { data: squadIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'squad', + state: 'open', + per_page: 20 + }); + + const memberLabels = members.map(m => m.label); + const untriaged = squadIssues.filter(issue => { + const issueLabels = issue.labels.map(l => l.name); + return !memberLabels.some(ml => issueLabels.includes(ml)); + }); + + // 2. Find assigned but unstarted issues (has squad:{member} label, no assignee) + const unstarted = []; + for (const member of members) { + try { + const { data: memberIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: member.label, + state: 'open', + per_page: 10 + }); + for (const issue of memberIssues) { + if (!issue.assignees || issue.assignees.length === 0) { + unstarted.push({ issue, member }); + } + } + } catch (e) { + // Label may not exist yet + } + } + + // 3. Find open PRs that need attention + const { data: openPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 20 + }); + + const squadPRs = openPRs.filter(pr => + pr.labels.some(l => l.name.startsWith('squad')) + ); + + // Build status summary + const summary = []; + if (untriaged.length > 0) { + summary.push(`๐Ÿ”ด **${untriaged.length} untriaged issue(s)** need triage`); + } + if (unstarted.length > 0) { + summary.push(`๐ŸŸก **${unstarted.length} assigned issue(s)** have no assignee`); + } + if (squadPRs.length > 0) { + const drafts = squadPRs.filter(pr => pr.draft).length; + const ready = squadPRs.length - drafts; + if (drafts > 0) summary.push(`๐ŸŸก **${drafts} draft PR(s)** in progress`); + if (ready > 0) summary.push(`๐ŸŸข **${ready} PR(s)** open for review/merge`); + } + + if (summary.length === 0) { + core.info('๐Ÿ“‹ Board is clear โ€” Ralph found no pending work'); + return; + } + + core.info(`๐Ÿ”„ Ralph found work:\n${summary.join('\n')}`); + + // Auto-triage untriaged issues + for (const issue of untriaged) { + const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); + let assignedMember = null; + let reason = ''; + + // Simple keyword-based routing + for (const member of members) { + const role = member.role.toLowerCase(); + if ((role.includes('frontend') || role.includes('ui')) && + (issueText.includes('ui') || issueText.includes('frontend') || + issueText.includes('css') || issueText.includes('component'))) { + assignedMember = member; + reason = 'Matches frontend/UI domain'; + break; + } + if ((role.includes('backend') || role.includes('api') || role.includes('server')) && + (issueText.includes('api') || issueText.includes('backend') || + issueText.includes('database') || issueText.includes('endpoint'))) { + assignedMember = member; + reason = 'Matches backend/API domain'; + break; + } + if ((role.includes('test') || role.includes('qa')) && + (issueText.includes('test') || issueText.includes('bug') || + issueText.includes('fix') || issueText.includes('regression'))) { + assignedMember = member; + reason = 'Matches testing/QA domain'; + break; + } + } + + // Default to Lead + if (!assignedMember) { + const lead = members.find(m => + m.role.toLowerCase().includes('lead') || + m.role.toLowerCase().includes('architect') + ); + if (lead) { + assignedMember = lead; + reason = 'No domain match โ€” routed to Lead'; + } + } + + if (assignedMember) { + // Add member label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [assignedMember.label] + }); + + // Post triage comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: [ + `### ๐Ÿ”„ Ralph โ€” Auto-Triage`, + '', + `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, + `**Reason:** ${reason}`, + '', + `> Ralph auto-triaged this issue via the squad heartbeat. To reassign, swap the \`squad:*\` label.` + ].join('\n') + }); + + core.info(`Auto-triaged #${issue.number} โ†’ ${assignedMember.name}`); + } + } + + # Copilot auto-assign step (uses PAT if available) + - name: Ralph โ€” Assign @copilot issues + if: success() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + const teamFile = '.ai-team/team.md'; + if (!fs.existsSync(teamFile)) return; + + const content = fs.readFileSync(teamFile, 'utf8'); + + // Check if @copilot is on the team with auto-assign + const hasCopilot = content.includes('๐Ÿค– Coding Agent') || content.includes('@copilot'); + const autoAssign = content.includes(''); + if (!hasCopilot || !autoAssign) return; + + // Find issues labeled squad:copilot with no assignee + try { + const { data: copilotIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'squad:copilot', + state: 'open', + per_page: 5 + }); + + const unassigned = copilotIssues.filter(i => + !i.assignees || i.assignees.length === 0 + ); + + if (unassigned.length === 0) { + core.info('No unassigned squad:copilot issues'); + return; + } + + // Get repo default branch + const { data: repoData } = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo + }); + + for (const issue of unassigned) { + try { + await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: ['copilot-swe-agent[bot]'], + agent_assignment: { + target_repo: `${context.repo.owner}/${context.repo.repo}`, + base_branch: repoData.default_branch, + custom_instructions: `Read .ai-team/team.md for team context and .ai-team/routing.md for routing rules.` + } + }); + core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`); + } catch (e) { + core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`); + } + } + } catch (e) { + core.info(`No squad:copilot label found or error: ${e.message}`); + } diff --git a/test/index.test.js b/test/index.test.js index 48812a8a6..d964af2ef 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -436,6 +436,104 @@ describe('error handling', () => { }); }); +// --- Ralph tests --- + +describe('Ralph โ€” Work Monitor', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-ralph-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('roster template includes Ralph', () => { + const roster = fs.readFileSync(path.join(ROOT, 'templates', 'roster.md'), 'utf8'); + assert.ok(roster.includes('Ralph'), 'roster template should include Ralph'); + assert.ok(roster.includes('Work Monitor'), 'roster template should include Work Monitor role'); + assert.ok(roster.includes('๐Ÿ”„'), 'roster template should include ๐Ÿ”„ badge'); + }); + + it('init creates team.md with Ralph on roster', () => { + runInit(tmpDir); + const teamFile = path.join(tmpDir, '.ai-team', 'team.md'); + // team.md is created from roster template which now includes Ralph + const templateContent = fs.readFileSync(path.join(ROOT, 'templates', 'roster.md'), 'utf8'); + assert.ok(templateContent.includes('Ralph'), 'roster template should have Ralph'); + }); + + it('heartbeat workflow is copied on init', () => { + runInit(tmpDir); + const heartbeatPath = path.join(tmpDir, '.github', 'workflows', 'squad-heartbeat.yml'); + assert.ok(fs.existsSync(heartbeatPath), 'squad-heartbeat.yml should be created on init'); + }); + + it('heartbeat workflow contains Ralph reference', () => { + const heartbeat = fs.readFileSync( + path.join(ROOT, 'templates', 'workflows', 'squad-heartbeat.yml'), 'utf8' + ); + assert.ok(heartbeat.includes('Ralph'), 'heartbeat workflow should reference Ralph'); + assert.ok(heartbeat.includes('Auto-Triage'), 'heartbeat workflow should have auto-triage'); + }); + + it('heartbeat workflow has schedule trigger', () => { + const heartbeat = fs.readFileSync( + path.join(ROOT, 'templates', 'workflows', 'squad-heartbeat.yml'), 'utf8' + ); + assert.ok(heartbeat.includes('schedule'), 'heartbeat should have schedule trigger'); + assert.ok(heartbeat.includes('cron'), 'heartbeat should have cron expression'); + }); + + it('heartbeat workflow has event triggers', () => { + const heartbeat = fs.readFileSync( + path.join(ROOT, 'templates', 'workflows', 'squad-heartbeat.yml'), 'utf8' + ); + assert.ok(heartbeat.includes('issues'), 'heartbeat should trigger on issues'); + assert.ok(heartbeat.includes('pull_request'), 'heartbeat should trigger on PRs'); + assert.ok(heartbeat.includes('workflow_dispatch'), 'heartbeat should support manual trigger'); + }); + + it('upgrade refreshes heartbeat workflow', () => { + runInit(tmpDir); + const heartbeatPath = path.join(tmpDir, '.github', 'workflows', 'squad-heartbeat.yml'); + // Write a dummy to simulate old version + fs.writeFileSync(heartbeatPath, '# old heartbeat'); + runCmd(tmpDir, 'upgrade'); + const content = fs.readFileSync(heartbeatPath, 'utf8'); + assert.ok(content.includes('Ralph'), 'upgrade should refresh heartbeat workflow'); + }); + + it('agent.md includes Ralph section', () => { + const agentMd = fs.readFileSync( + path.join(ROOT, '.github', 'agents', 'squad.agent.md'), 'utf8' + ); + assert.ok(agentMd.includes('Ralph โ€” Work Monitor'), 'agent.md should have Ralph section'); + assert.ok(agentMd.includes('Ralph is always "Ralph"'), 'agent.md should exempt Ralph from casting'); + }); + + it('agent.md includes Ralph in routing signals', () => { + const agentMd = fs.readFileSync( + path.join(ROOT, '.github', 'agents', 'squad.agent.md'), 'utf8' + ); + assert.ok( + agentMd.includes('Ralph commands'), + 'routing signals should include Ralph commands' + ); + }); + + it('agent.md includes Ralph check after follow-up work', () => { + const agentMd = fs.readFileSync( + path.join(ROOT, '.github', 'agents', 'squad.agent.md'), 'utf8' + ); + assert.ok( + agentMd.includes('Ralph check'), + 'agent.md should have Ralph check after agent work completes' + ); + }); +}); + // --- Workflow copy tests --- describe('squad workflow files', () => {