From 1d396d22878ad10edaf231e635e10458d0340d57 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 07:11:16 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(factory):=20software=20factory=20pipel?= =?UTF-8?q?ine=20=E2=80=94=20ready-for-agent=20=E2=86=92=20scope=20?= =?UTF-8?q?=E2=86=92=20team=20spawn=20=E2=86=92=20implement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the complete software factory pipeline so issues marked `ready-for-agent` flow automatically through scoping, team spawning, and implementation: - writeRemoteFile IPC: renderer/agents can now create/update Linear issues - ready-for-agent 4th band in Attention Inbox (expanded by default) - spawnTeamForIssue: spawns codex-impl + claude-review pairs with task prompts - issue-scoping module: detectRepo, suggestTeamSize, labelIssueWithRepo - board-steward persona: proactive agent that watches and drives the pipeline Co-Authored-By: Claude Opus 4.6 --- .../workforce/personas/board-steward.json | 32 ++++++++ src/main/integrations.ts | 18 +++++ src/main/ipc-handlers.ts | 4 + src/preload/index.ts | 2 + .../src/components/issues/AttentionInbox.tsx | 75 ++++++++++++++++++- src/renderer/src/lib/ipc-mock.ts | 49 ++++++++++++ src/renderer/src/lib/issue-scoping.ts | 52 +++++++++++++ src/renderer/src/lib/spawn-agent.ts | 37 +++++++++ src/renderer/src/stores/issues-store.ts | 20 +++-- src/shared/types/ipc.ts | 1 + 10 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 .agentworkforce/workforce/personas/board-steward.json create mode 100644 src/renderer/src/lib/issue-scoping.ts diff --git a/.agentworkforce/workforce/personas/board-steward.json b/.agentworkforce/workforce/personas/board-steward.json new file mode 100644 index 00000000..030d4672 --- /dev/null +++ b/.agentworkforce/workforce/personas/board-steward.json @@ -0,0 +1,32 @@ +{ + "id": "board-steward", + "intent": "automated-issue-triage-and-team-dispatch", + "tags": [ + "proactive", + "triage", + "factory", + "linear", + "issues", + "orchestration", + "pipeline" + ], + "description": "Proactive board steward that watches for ready-for-agent Linear issues, scopes them (detects repo, estimates complexity), spawns codex-impl + claude-review agent pairs, and moves cards through the pipeline. The automation glue of the software factory.", + "skills": [], + "inputs": { + "TASK_DESCRIPTION": { + "description": "Override for the board steward's task. Default watches for ready-for-agent issues.", + "default": "You are the board steward. Watch for Linear issues with ready-for-agent status and drive them through the software factory pipeline." + } + }, + "mcpServers": {}, + "permissions": { + "mode": "bypassPermissions" + }, + "claudeMdContent": "# Board Steward\n\nYou are the board steward for the software factory. Your job is to watch for Linear issues that are marked ready-for-agent, triage them, spawn the right team, and shepherd them through the pipeline until a PR is opened and in review. You are the automation glue between the issue tracker and the agent workforce.\n\nYou are NOT an implementer. You do not write code. You do not review code. You are a dispatcher and pipeline monitor.\n\n## Core Loop\n\nOn every cycle:\n\n1. **Scan for ready-for-agent issues.** Read the Linear issues integration mount to find issues with the `ready-for-agent` label or status. The mount path pattern is `.integrations/linear/` in the project root. Look for JSON files representing issues. Check each issue's `state`, `labels`, and `status` fields for the ready-for-agent signal.\n\n2. **Skip already-processed issues.** Maintain a local tracking file at `/tmp/board-steward-processed.json` — a JSON array of issue IDs you have already dispatched. On each scan, skip any issue whose ID appears in this file. Write to this file after every successful dispatch.\n\n3. **Skip issues with agents already assigned.** If an issue's JSON has an `assignee` field that references an agent, or if the issue's labels include any `Agent: ` label, skip it. Never double-assign.\n\n4. **Detect the target repo.** Analyze the issue title and description to determine which repository the work belongs to. Use keyword scoring:\n\n - **relay** (agent-relay / broker / backend): relay, agent-relay, broker, mcp, workspace, webhook, mount, relayfile, server, api, queue, ingestion, provider, integration, nango, sst, cloudflare, worker, durable-object, kv, d1, r2, wrangler\n - **pear** (Electron desktop app): pear, electron, renderer, terminal, ui, inbox, sidebar, dialog, ipc, xterm, webgl, menu, tray, window, notification, theme, panel, settings, avatar, auth, deeplink, updater, titlebar\n - **workforce** (agent orchestration layer): workforce, persona, autonomous, proactive, agent-workforce, skill, harness, spawn, session, claude-md, relayfile-mount, persona-config\n\n Score each repo by counting keyword hits in the title + description (case-insensitive). The repo with the highest score wins. If there is a tie (two repos have equal top score), do NOT proceed — post a message to the `software-factory` channel asking for clarification and skip the issue for this cycle.\n\n5. **Estimate complexity.** Based on the issue description, estimate one of three tiers:\n\n - **solo** — A single focused fix or small feature. One codex-impl agent is sufficient. Keywords: fix, bug, typo, small, minor, tweak, patch, hotfix, one-liner.\n - **pair** — A meaningful feature or multi-file change. Needs a codex-impl + claude-review pair. This is the default if no strong signal either way.\n - **swarm** — A migration, cutover, or large cross-cutting change touching 5+ files or multiple subsystems. Needs the autonomous-actor orchestrator. Keywords: migration, cutover, refactor, rewrite, overhaul, cross-repo, multi-service.\n\n6. **Add the repo label.** Write a label update to the issue via the integration writeback mechanism. The label format is `Repo: relay`, `Repo: pear`, or `Repo: workforce`.\n\n7. **Spawn the team.** Use the relay workspace MCP tools to dispatch the implementation team:\n\n - For **solo**: Send a DM to the appropriate specialist agent (if one exists for the detected repo) or post to the `software-factory` channel requesting a codex-impl agent be assigned.\n - For **pair**: Post to the `software-factory` channel with a structured spawn request:\n ```\n [SPAWN REQUEST]\n Issue: — \n Repo: \n Complexity: pair\n Team: codex-impl + claude-review\n Brief: \n ```\n - For **swarm**: Post to the `software-factory` channel requesting the autonomous-actor orchestrator be engaged, with full issue context.\n\n8. **Move the issue to In Progress.** Write a status update via the integration writeback to move the issue from `Ready for Agent` to `In Progress`.\n\n9. **Post the dispatch summary.** Post a message to the `software-factory` channel:\n ```\n [DISPATCHED] : \n Repo: | Complexity: | Team: \n ```\n\n10. **Record the issue as processed.** Append the issue ID to `/tmp/board-steward-processed.json`.\n\n## Monitoring In-Progress Issues\n\nAfter the scan-and-dispatch cycle, check on previously dispatched issues that are still `In Progress`:\n\n1. For each issue in the processed list, check the `software-factory` channel and relevant repo for PR activity. Use `gh pr list` or check relay messages for PR links referencing the issue ID.\n\n2. When a PR is found that references the issue:\n - Check if CI is passing on the PR.\n - If CI is green, move the issue to `In Review` via writeback.\n - Post to `software-factory`: `[IN REVIEW] : — PR `\n\n3. If an issue has been `In Progress` for longer than expected (no PR activity in the channel for the issue after a reasonable time), post a status check to `software-factory` asking for an update.\n\n## Integration Mechanics\n\n- **Reading issues:** Issues appear as JSON files in the integration mount. Start from the mount's LAYOUT.md or .layout.md to discover the directory structure. Look for by-state/ or by-label/ alias subtrees if available.\n- **Writing updates (labels, status):** Use the writeback-as-files contract. Drop a JSON file at the canonical writeback path to trigger the provider mutation. Check .schema.json siblings for the expected shape.\n- **Agent communication:** Use the relay workspace MCP tools: `post_message` to channels, `send_dm` for direct messages, `list_messages` to read channel history.\n- **The primary channel is `software-factory`.** All dispatch summaries, status updates, escalations, and clarification requests go here.\n\n## Repo Detection — Disambiguation Protocol\n\nWhen keyword scoring produces a tie:\n1. Post to `software-factory`: `[AMBIGUOUS REPO] : scored equally for and . Keywords found: . Please clarify which repo this belongs to.`\n2. Skip the issue for this cycle.\n3. On the next cycle, check if someone replied with a repo clarification in the channel. If so, proceed with that repo.\n\n## Constraints\n\n- **Never spawn a team for an issue that already has agents assigned.** Check assignee and agent labels first.\n- **Never process the same issue twice.** The processed-issues file is the source of truth.\n- **Never guess the repo on a tie.** Ask in the channel.\n- **Never write code or review code.** You are a dispatcher, not an implementer.\n- **Always post to software-factory after each action.** The channel is the human-readable audit trail.\n- **Treat the processed-issues file as append-only.** Never remove entries. If an issue needs to be re-processed (e.g., team spawn failed), add a new entry with a retry flag rather than removing the old one.\n\n## Error Handling\n\n- If the integration mount is not available or empty, post to `software-factory`: `[WARN] Linear integration mount not found or empty. Skipping scan cycle.` and exit gracefully.\n- If writeback fails (label update or status move), post to `software-factory`: `[ERROR] Failed to update issue : . Manual intervention needed.`\n- If a spawn request gets no response after a reasonable period, post a follow-up to `software-factory` flagging the stalled dispatch.\n\n## Output Contract\n\nEvery action produces a message to the `software-factory` channel:\n- `[SCAN]` — Start of a scan cycle with count of ready-for-agent issues found.\n- `[DISPATCHED]` — Issue triaged and team spawned.\n- `[AMBIGUOUS REPO]` — Repo detection tie, asking for clarification.\n- `[IN REVIEW]` — PR opened and CI green, issue moved to In Review.\n- `[WARN]` — Non-fatal issue (mount missing, unexpected data shape).\n- `[ERROR]` — Action failed, manual intervention needed.\n- `[STALLED]` — In-progress issue with no PR activity, requesting update.", + "harness": "claude", + "model": "claude-sonnet-4-6", + "systemPrompt": "$TASK_DESCRIPTION", + "harnessSettings": { + "timeoutSeconds": 1800 + } +} diff --git a/src/main/integrations.ts b/src/main/integrations.ts index cd1b0403..571135fa 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -1103,6 +1103,24 @@ export class IntegrationsManager { } } + async writeRemoteFile(projectId: string, remotePath: string, content: string): Promise { + if (!this.findProject(projectId)) throw new Error(`Project not found: ${projectId}`) + const path = normalizeRemoteDirectoryPath(remotePath) + if (!path || path === '/') throw new Error('Integration remote file path is required') + const mountPaths = this.listableRemoteMountPaths(projectId) + const allowSlackDms = this.slackDmListingEnabledForProject(projectId) + const withinScope = + mountPaths.some((mountPath) => isRelayfilePathWithinRoot(mountPath, path)) || + (allowSlackDms && isSlackDmListablePath(path)) + if (!withinScope) { + throw new Error('Integration remote file is outside this project integration scope') + } + + await this.withIntegrationRemoteHandle(async (handle) => { + await handle.client().writeFile(handle.workspaceId, path, content) + }) + } + async listRemoteDirectory(projectId: string, remotePath: string): Promise { if (!this.findProject(projectId)) throw new Error(`Project not found: ${projectId}`) const path = normalizeRemoteDirectoryPath(remotePath) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index c6220bde..e8ffb29b 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -685,6 +685,10 @@ export function registerIpcHandlers(): void { return integrationsManager.readRemoteFile(projectId, remotePath) }) + ipcMain.handle('integrations:write-remote-file', async (_, projectId: string, remotePath: string, content: string) => { + return integrationsManager.writeRemoteFile(projectId, remotePath, content) + }) + ipcMain.handle('integrations:list-options', async (_, projectId: string, provider: string, resource: string) => { return integrationsManager.listOptions(projectId, provider, resource) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 8bac91b3..1e3cfbc0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -396,6 +396,8 @@ const api = { invoke('integrations:list-remote-dir', projectId, remotePath), readRemoteFile: (projectId: string, remotePath: string) => invoke('integrations:read-remote-file', projectId, remotePath), + writeRemoteFile: (projectId: string, remotePath: string, content: string) => + invoke('integrations:write-remote-file', projectId, remotePath, content), readMountPreview: (projectId: string, integrationId: string, filePath: string) => invoke('integrations:read-mount-preview', projectId, integrationId, filePath), listOptions: (projectId: string, provider: string, resource: string) => diff --git a/src/renderer/src/components/issues/AttentionInbox.tsx b/src/renderer/src/components/issues/AttentionInbox.tsx index ec00753e..42a4f1d4 100644 --- a/src/renderer/src/components/issues/AttentionInbox.tsx +++ b/src/renderer/src/components/issues/AttentionInbox.tsx @@ -18,7 +18,9 @@ import { ThumbsUp, X } from 'lucide-react' +import { detectRepo } from '@/lib/issue-scoping' import { jumpToIssueWork } from '@/lib/issue-navigation' +import { spawnTeamForIssue, type TeamComposition } from '@/lib/spawn-agent' import { useAgentStore, type ChatMessage } from '@/stores/agent-store' import { useIssuesStore, type IssueBand, type IssueGithubLink, type IssueViewModel } from '@/stores/issues-store' import { useProjectStore } from '@/stores/project-store' @@ -28,12 +30,14 @@ const isWebMock = import.meta.env.VITE_PEAR_MOCK_IPC === 'true' const BAND_TITLES: Record = { 'needs-you': 'Needs you', + 'ready-for-agent': 'Ready for agent', 'in-motion': 'In motion', settled: 'Settled' } const BAND_SUBTITLES: Record = { 'needs-you': 'Only humans can clear these', + 'ready-for-agent': 'Waiting for automated agent pickup', 'in-motion': 'Ambient live agent work', settled: 'Recently merged or done' } @@ -52,6 +56,7 @@ function orderStages(stages: string[]): string[] { function bandIcon(band: IssueBand): React.ReactNode { if (band === 'needs-you') return + if (band === 'ready-for-agent') return if (band === 'in-motion') return return } @@ -154,7 +159,8 @@ function IssueOverviewCard({ expandedChat, narration, onOpen, - onToggleChat + onToggleChat, + onSpawnTeam }: { issue: IssueViewModel active: boolean @@ -162,7 +168,10 @@ function IssueOverviewCard({ narration: string onOpen: () => void onToggleChat: () => void + onSpawnTeam?: () => void }): React.ReactNode { + const detectedRepo = issue.band === 'ready-for-agent' ? detectRepo(issue.title, issue.description) : null + function handleKeyDown(event: React.KeyboardEvent): void { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() @@ -187,6 +196,11 @@ function IssueOverviewCard({ {issue.identifier} + {detectedRepo && ( + + {detectedRepo} + + )} {issue.title} @@ -236,6 +250,23 @@ function IssueOverviewCard({ )} + {issue.band === 'ready-for-agent' && onSpawnTeam && ( +
+ +
+ )} ) @@ -250,7 +281,8 @@ function BandSection({ narrations, onToggleExpanded, onSelectIssue, - onToggleChat + onToggleChat, + onSpawnTeam }: { band: IssueBand issues: IssueViewModel[] @@ -261,6 +293,7 @@ function BandSection({ onToggleExpanded: () => void onSelectIssue: (issueId: string) => void onToggleChat: (issueId: string) => void + onSpawnTeam?: (issue: IssueViewModel) => void }): React.ReactNode { const title = BAND_TITLES[band] const subtitle = BAND_SUBTITLES[band] @@ -295,7 +328,7 @@ function BandSection({
{issues.length === 0 ? (
- {band === 'needs-you' ? 'All clear.' : 'Nothing here.'} + {band === 'needs-you' ? 'All clear.' : band === 'ready-for-agent' ? 'No issues queued for agents.' : 'Nothing here.'}
) : ( issues.map((issue) => ( @@ -307,6 +340,7 @@ function BandSection({ narration={narrations.get(issue.id) || issue.agentNarration || ''} onOpen={() => onSelectIssue(issue.id)} onToggleChat={() => onToggleChat(issue.id)} + onSpawnTeam={onSpawnTeam ? () => onSpawnTeam(issue) : undefined} /> )) )} @@ -469,6 +503,7 @@ export function AttentionInbox({ const subscribe = useIssuesStore((s) => s.subscribe) const [expandedBands, setExpandedBands] = useState>({ 'needs-you': true, + 'ready-for-agent': true, 'in-motion': false, settled: false }) @@ -513,6 +548,7 @@ export function AttentionInbox({ const selectedIssue = issues.find((issue) => issue.id === selectedIssueId) || null const needsYou = issuesByBand(visibleIssues, 'needs-you') + const readyForAgent = issuesByBand(visibleIssues, 'ready-for-agent') const inMotion = issuesByBand(visibleIssues, 'in-motion') const settled = issuesByBand(visibleIssues, 'settled') @@ -534,6 +570,25 @@ export function AttentionInbox({ setNavNotice(result.message) } + async function handleSpawnTeam(issue: IssueViewModel): Promise { + const project = useProjectStore.getState().getActiveProject() + if (!project) return + try { + const repo = detectRepo(issue.title, issue.description) ?? undefined + const composition: TeamComposition = { + issueId: issue.id, + issueIdentifier: issue.identifier, + issueTitle: issue.title, + issueDescription: issue.description, + repo + } + const { implName, reviewName } = await spawnTeamForIssue(project, composition) + setNavNotice(`Spawned ${implName} + ${reviewName}`) + } catch { + setNavNotice('Failed to spawn team') + } + } + return (
@@ -559,6 +614,8 @@ export function AttentionInbox({
{needsYou.length} need you · + {readyForAgent.length} ready for agent + · {inMotion.length} in motion · {settled.length} settled @@ -606,6 +663,18 @@ export function AttentionInbox({ onSelectIssue={setSelectedIssueId} onToggleChat={toggleChat} /> + toggleBand('ready-for-agent')} + onSelectIssue={setSelectedIssueId} + onToggleChat={toggleChat} + onSpawnTeam={handleSpawnTeam} + /> > = [ ] } }, + { + id: 'lin-pear-160', + identifier: 'PEAR-160', + title: 'Implement broker webhook retry queue for writeback failures', + description: 'Build a relay-side retry queue that re-delivers failed webhook writeback commands after transient broker errors clear.', + priority: 2, + url: 'https://linear.app/agent-workforce/issue/PEAR-160/writeback-retry-queue', + stateId: 'state-ready-for-agent', + state: { id: 'state-ready-for-agent', name: 'Ready for Agent', type: 'unstarted', color: '#c9a7ff' }, + teamId: 'team-pear', + team: { id: 'team-pear', key: 'PEAR', name: 'Pear' }, + projectId: 'project-control-center', + project: { id: 'project-control-center', name: 'Issue Control Center' }, + labels: [ + { id: 'label-agent', name: 'agent', color: '#6bd4bc' }, + { id: 'label-ready-for-agent', name: 'ready-for-agent', color: '#c9a7ff' } + ], + labelIds: ['label-agent', 'label-ready-for-agent'], + syncedWith: [], + updatedAt: isoMinutesAgo(15), + agentSession: {} + }, + { + id: 'lin-pear-162', + identifier: 'PEAR-162', + title: 'Add schema validation for mount writeback payloads', + description: 'Validate every writeback payload against the provider schema before queueing, rejecting malformed writes at the edge.', + priority: 1, + url: 'https://linear.app/agent-workforce/issue/PEAR-162/schema-validation-writeback', + stateId: 'state-to-do', + state: { id: 'state-to-do', name: 'To do', type: 'unstarted', color: '#94cbef' }, + teamId: 'team-pear', + team: { id: 'team-pear', key: 'PEAR', name: 'Pear' }, + projectId: 'project-control-center', + project: { id: 'project-control-center', name: 'Issue Control Center' }, + labels: [ + { id: 'label-agent', name: 'agent', color: '#6bd4bc' }, + { id: 'label-ready-for-agent', name: 'ready-for-agent', color: '#c9a7ff' } + ], + labelIds: ['label-agent', 'label-ready-for-agent'], + syncedWith: [], + updatedAt: isoMinutesAgo(20), + agentSession: {} + }, { id: 'lin-pear-148', identifier: 'PEAR-148', @@ -1040,6 +1084,11 @@ export const pearMock: PearAPI = { const content = JSON.stringify(record, null, 2) return { kind: 'text', content, size: content.length } }, + writeRemoteFile: async (_projectId: string, remotePath: string, content: string): Promise => { + const normalized = normalizeMockRemotePath(remotePath) + console.log('[ipc-mock] writeRemoteFile', normalized, content.length, 'bytes') + mockRemoteFiles[normalized] = JSON.parse(content) + }, readMountPreview: async (): Promise => ({ kind: 'missing', content: '', size: 0 }), listOptions: async (): Promise => [], startConnect: async (_projectId: string, provider: string): Promise => ({ sessionId: `mock-${provider}`, provider, status: 'completed' }), diff --git a/src/renderer/src/lib/issue-scoping.ts b/src/renderer/src/lib/issue-scoping.ts new file mode 100644 index 00000000..d6866595 --- /dev/null +++ b/src/renderer/src/lib/issue-scoping.ts @@ -0,0 +1,52 @@ +import { pear } from '@/lib/ipc' + +const REPO_KEYWORDS: Record = { + relay: ['relay', 'agent-relay', 'broker', 'mcp', 'workspace', 'webhook', 'mount', 'relayfile'], + pear: ['pear', 'electron', 'renderer', 'terminal', 'ui', 'inbox', 'sidebar', 'dialog', 'ipc'], + workforce: ['workforce', 'persona', 'autonomous', 'proactive', 'agent-workforce', 'skill'], +} + +export function detectRepo(title: string, description: string): string | null { + const text = `${title} ${description}`.toLowerCase() + let bestRepo: string | null = null + let bestScore = 0 + + for (const [repo, keywords] of Object.entries(REPO_KEYWORDS)) { + const score = keywords.filter((kw) => text.includes(kw)).length + if (score > bestScore) { + bestScore = score + bestRepo = repo + } + } + + return bestRepo +} + +export function suggestTeamSize(title: string, description: string): 'solo' | 'pair' | 'swarm' { + const text = `${title} ${description}`.toLowerCase() + const length = description.length + + if (text.includes('migration') || text.includes('refactor') || text.includes('rewrite') || length > 2000) { + return 'swarm' + } + if (text.includes('fix') || text.includes('bug') || text.includes('typo') || length < 200) { + return 'solo' + } + return 'pair' +} + +export async function labelIssueWithRepo( + projectId: string, + issueId: string, + repo: string +): Promise { + const labelPayload = JSON.stringify({ + issueId, + labels: { add: [`Repo: ${repo}`] } + }) + await pear.integrations.writeRemoteFile( + projectId, + `/linear/issues/${issueId}/labels.json`, + labelPayload + ) +} diff --git a/src/renderer/src/lib/spawn-agent.ts b/src/renderer/src/lib/spawn-agent.ts index 05cb52b7..0a911b43 100644 --- a/src/renderer/src/lib/spawn-agent.ts +++ b/src/renderer/src/lib/spawn-agent.ts @@ -119,6 +119,43 @@ export async function listProjectPersonas(project: Project, rootOverride?: Proje return pear.broker.listPersonas(project.id, root.path) } +export type TeamComposition = { + issueId: string + issueIdentifier: string + issueTitle: string + issueDescription: string + repo?: string +} + +export async function spawnTeamForIssue( + project: Project, + composition: TeamComposition, + rootOverride?: ProjectRoot +): Promise<{ implName: string; reviewName: string }> { + const implName = await spawnProjectAgent(project, 'codex', `${composition.issueIdentifier}-impl`, rootOverride) + const reviewName = await spawnProjectAgent(project, 'claude', `${composition.issueIdentifier}-review`, rootOverride) + + const implPrompt = [ + 'Implement this issue and open a PR when done.', + '', + `Issue: ${composition.issueIdentifier} — ${composition.issueTitle}`, + composition.issueDescription, + composition.repo ? `Repository: ${composition.repo}` : '' + ].filter(Boolean).join('\n') + + const reviewPrompt = [ + `Review the implementation by ${implName} for this issue. Watch for correctness, security, and test coverage.`, + '', + `Issue: ${composition.issueIdentifier} — ${composition.issueTitle}`, + composition.issueDescription + ].join('\n') + + await pear.broker.sendMessage(project.id, { to: implName, text: implPrompt }) + await pear.broker.sendMessage(project.id, { to: reviewName, text: reviewPrompt }) + + return { implName, reviewName } +} + export async function spawnProjectPersona(project: Project, personaId: string, rootOverride?: ProjectRoot): Promise { if (rootOverride && !rootOverride.pathExists) { throw new Error(`Project root not found: ${rootOverride.path || project.rootPath}`) diff --git a/src/renderer/src/stores/issues-store.ts b/src/renderer/src/stores/issues-store.ts index e572fc39..15a154b5 100644 --- a/src/renderer/src/stores/issues-store.ts +++ b/src/renderer/src/stores/issues-store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { pear, type FsDirEntry, type IntegrationsEvent } from '@/lib/ipc' -export type IssueBand = 'needs-you' | 'in-motion' | 'settled' +export type IssueBand = 'needs-you' | 'ready-for-agent' | 'in-motion' | 'settled' export interface IssueGithubLink { owner: string @@ -53,8 +53,9 @@ interface IssuesState { const STAGE_ORDER = ['Backlog', 'Planning', 'To do', 'In Progress', 'In review', 'Merged', 'Done'] const BAND_ORDER: Record = { 'needs-you': 0, - 'in-motion': 1, - settled: 2 + 'ready-for-agent': 1, + 'in-motion': 2, + settled: 3 } const INTEGRATION_REFRESH_DEBOUNCE_MS = 250 @@ -128,14 +129,21 @@ function classifyIssue( labels: string[], attention: Record ): IssueBand { - // Settled: explicit Merged/Done names OR Linear's completed/canceled state types. if (stage === 'Merged' || stage === 'Done' || stageType === 'completed' || stageType === 'canceled') { return 'settled' } const normalizedLabels = labels.map((label) => label.toLowerCase()) - // Needs-you: codified attention/labels when present, OR a review-stage name - // (the human review gate) derived from the real Linear state. + + if ( + stage.toLowerCase() === 'ready for agent' || + stage.toLowerCase() === 'ready-for-agent' || + normalizedLabels.includes('ready-for-agent') || + normalizedLabels.includes('ready for agent') + ) { + return 'ready-for-agent' + } + if ( readString(attention.kind) || normalizedLabels.includes('human') || diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index c5e10f38..b18eb5a2 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -979,6 +979,7 @@ export interface PearAPI { listMountDir: (projectId: string, integrationId: string, dirPath: string) => Promise listRemoteDir: (projectId: string, remotePath: string) => Promise readRemoteFile: (projectId: string, remotePath: string) => Promise + writeRemoteFile: (projectId: string, remotePath: string, content: string) => Promise readMountPreview: (projectId: string, integrationId: string, filePath: string) => Promise listOptions: (projectId: string, provider: string, resource: string) => Promise startConnect: (projectId: string, provider: string) => Promise From 7d4f324b834c735b7567693a324f98f1a98da73a Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Thu, 11 Jun 2026 05:28:51 +0000 Subject: [PATCH 2/6] chore: apply pr-reviewer fixes for #222 --- src/main/integrations.test.ts | 33 ++++++++++++++++++++++ src/main/integrations.ts | 2 +- src/renderer/src/lib/issue-scoping.test.ts | 16 +++++++++++ src/renderer/src/lib/issue-scoping.ts | 24 ++++------------ 4 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/lib/issue-scoping.test.ts diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index 257d2f87..c2e33bf7 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -37,6 +37,7 @@ const mock = vi.hoisted(() => { } let mountReconcilePromise: Promise = Promise.resolve() const readFileCalls: Array<{ workspaceId: string; path: string }> = [] + const writeFileCalls: Array<{ workspaceId: string; path: string; baseRevision: string; content: string }> = [] const relayClient = { readFile: vi.fn(async (workspaceId: string, path: string) => { readFileCalls.push({ workspaceId, path }) @@ -56,6 +57,10 @@ const mock = vi.hoisted(() => { }), encoding: 'utf-8' } + }), + writeFile: vi.fn(async (input: { workspaceId: string; path: string; baseRevision: string; content: string }) => { + writeFileCalls.push(input) + return { opId: 'op-1', status: 'queued' } }) } const shellOpenExternal = vi.fn(async () => undefined) @@ -191,6 +196,7 @@ const mock = vi.hoisted(() => { updateMountPaths: vi.fn(async () => undefined) }, readFileCalls, + writeFileCalls, relayClient, relayWorkspaceManager, workspaceHandle, @@ -319,7 +325,9 @@ describe('IntegrationsManager', () => { mock.integrationEventBridge.closeAllExcept.mockClear() mock.cloudAgentManager.updateMountPaths.mockClear() mock.readFileCalls.splice(0) + mock.writeFileCalls.splice(0) mock.relayClient.readFile.mockClear() + mock.relayClient.writeFile.mockClear() mock.shellOpenExternal.mockClear() mock.workspaceHandle.requestJson.mockReset() mock.workspaceHandle.requestJson.mockImplementation(async (_request: { path: string }) => { @@ -371,6 +379,31 @@ describe('IntegrationsManager', () => { ) }) + it('writes remote files through Relayfile using the configured project scope', async () => { + const manager = new IntegrationsManager() + + await expect(manager.writeRemoteFile('project-1', '/slack/channels/C123/messages/draft.json', '{"text":"hello"}')) + .resolves.toBeUndefined() + + expect(mock.writeFileCalls).toEqual([ + { + workspaceId: 'account-workspace-id', + path: '/slack/channels/C123/messages/draft.json', + baseRevision: '0', + content: '{"text":"hello"}' + } + ]) + }) + + it('rejects remote writes outside the configured project scope', async () => { + const manager = new IntegrationsManager() + + await expect(manager.writeRemoteFile('project-1', '/github/repos/acme/widgets/issues/1.json', '{}')) + .rejects.toThrow('Integration remote file is outside this project integration scope') + + expect(mock.relayClient.writeFile).not.toHaveBeenCalled() + }) + it('returns local settings integrations and sets recovery when signed in before the account workspace exists', async () => { mock.getAccountWorkspaceId.mockRejectedValueOnce(new Error('account-workspace-required')) const manager = new IntegrationsManager() diff --git a/src/main/integrations.ts b/src/main/integrations.ts index 571135fa..95abd00f 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -1117,7 +1117,7 @@ export class IntegrationsManager { } await this.withIntegrationRemoteHandle(async (handle) => { - await handle.client().writeFile(handle.workspaceId, path, content) + await handle.client().writeFile({ workspaceId: handle.workspaceId, path, baseRevision: '0', content }) }) } diff --git a/src/renderer/src/lib/issue-scoping.test.ts b/src/renderer/src/lib/issue-scoping.test.ts new file mode 100644 index 00000000..21996bf2 --- /dev/null +++ b/src/renderer/src/lib/issue-scoping.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest' +import { detectRepo } from './issue-scoping' + +describe('detectRepo', () => { + test('returns the highest scoring repo', () => { + expect(detectRepo('Broker webhook retry queue', 'relay-side mount writeback')).toBe('relay') + }) + + test('does not guess when top repo scores are tied', () => { + expect(detectRepo('Broker IPC bridge', 'route webhook results into the renderer')).toBeNull() + }) + + test('returns null when no repo keywords match', () => { + expect(detectRepo('Improve onboarding copy', 'clarify the first-run checklist')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/issue-scoping.ts b/src/renderer/src/lib/issue-scoping.ts index d6866595..b2a3d3ee 100644 --- a/src/renderer/src/lib/issue-scoping.ts +++ b/src/renderer/src/lib/issue-scoping.ts @@ -1,5 +1,3 @@ -import { pear } from '@/lib/ipc' - const REPO_KEYWORDS: Record = { relay: ['relay', 'agent-relay', 'broker', 'mcp', 'workspace', 'webhook', 'mount', 'relayfile'], pear: ['pear', 'electron', 'renderer', 'terminal', 'ui', 'inbox', 'sidebar', 'dialog', 'ipc'], @@ -10,16 +8,20 @@ export function detectRepo(title: string, description: string): string | null { const text = `${title} ${description}`.toLowerCase() let bestRepo: string | null = null let bestScore = 0 + let tied = false for (const [repo, keywords] of Object.entries(REPO_KEYWORDS)) { const score = keywords.filter((kw) => text.includes(kw)).length if (score > bestScore) { bestScore = score bestRepo = repo + tied = false + } else if (score > 0 && score === bestScore) { + tied = true } } - return bestRepo + return tied ? null : bestRepo } export function suggestTeamSize(title: string, description: string): 'solo' | 'pair' | 'swarm' { @@ -34,19 +36,3 @@ export function suggestTeamSize(title: string, description: string): 'solo' | 'p } return 'pair' } - -export async function labelIssueWithRepo( - projectId: string, - issueId: string, - repo: string -): Promise { - const labelPayload = JSON.stringify({ - issueId, - labels: { add: [`Repo: ${repo}`] } - }) - await pear.integrations.writeRemoteFile( - projectId, - `/linear/issues/${issueId}/labels.json`, - labelPayload - ) -} From e4e2a4014a139928dd332faeee59b49bb91134d7 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Thu, 11 Jun 2026 06:09:19 +0000 Subject: [PATCH 3/6] chore: apply pr-reviewer fixes for #222 --- .../workforce/personas/board-steward.json | 4 +- src/main/integrations.test.ts | 19 ++- src/main/integrations.ts | 77 +++++++++-- .../src/components/issues/AttentionInbox.tsx | 7 +- src/renderer/src/lib/ipc-mock.ts | 6 +- src/renderer/src/lib/issue-scoping.test.ts | 22 +++- src/renderer/src/lib/issue-scoping.ts | 26 +++- src/renderer/src/lib/spawn-agent.test.ts | 92 +++++++++++++ src/renderer/src/lib/spawn-agent.ts | 121 ++++++++++++++---- 9 files changed, 324 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/lib/spawn-agent.test.ts diff --git a/.agentworkforce/workforce/personas/board-steward.json b/.agentworkforce/workforce/personas/board-steward.json index 030d4672..0144962f 100644 --- a/.agentworkforce/workforce/personas/board-steward.json +++ b/.agentworkforce/workforce/personas/board-steward.json @@ -10,7 +10,7 @@ "orchestration", "pipeline" ], - "description": "Proactive board steward that watches for ready-for-agent Linear issues, scopes them (detects repo, estimates complexity), spawns codex-impl + claude-review agent pairs, and moves cards through the pipeline. The automation glue of the software factory.", + "description": "Proactive board steward that watches for ready-for-agent Linear issues, scopes them (detects repo, estimates complexity), requests codex-impl + claude-review agent pairs, and moves cards through the pipeline. The automation glue of the software factory.", "skills": [], "inputs": { "TASK_DESCRIPTION": { @@ -22,7 +22,7 @@ "permissions": { "mode": "bypassPermissions" }, - "claudeMdContent": "# Board Steward\n\nYou are the board steward for the software factory. Your job is to watch for Linear issues that are marked ready-for-agent, triage them, spawn the right team, and shepherd them through the pipeline until a PR is opened and in review. You are the automation glue between the issue tracker and the agent workforce.\n\nYou are NOT an implementer. You do not write code. You do not review code. You are a dispatcher and pipeline monitor.\n\n## Core Loop\n\nOn every cycle:\n\n1. **Scan for ready-for-agent issues.** Read the Linear issues integration mount to find issues with the `ready-for-agent` label or status. The mount path pattern is `.integrations/linear/` in the project root. Look for JSON files representing issues. Check each issue's `state`, `labels`, and `status` fields for the ready-for-agent signal.\n\n2. **Skip already-processed issues.** Maintain a local tracking file at `/tmp/board-steward-processed.json` — a JSON array of issue IDs you have already dispatched. On each scan, skip any issue whose ID appears in this file. Write to this file after every successful dispatch.\n\n3. **Skip issues with agents already assigned.** If an issue's JSON has an `assignee` field that references an agent, or if the issue's labels include any `Agent: ` label, skip it. Never double-assign.\n\n4. **Detect the target repo.** Analyze the issue title and description to determine which repository the work belongs to. Use keyword scoring:\n\n - **relay** (agent-relay / broker / backend): relay, agent-relay, broker, mcp, workspace, webhook, mount, relayfile, server, api, queue, ingestion, provider, integration, nango, sst, cloudflare, worker, durable-object, kv, d1, r2, wrangler\n - **pear** (Electron desktop app): pear, electron, renderer, terminal, ui, inbox, sidebar, dialog, ipc, xterm, webgl, menu, tray, window, notification, theme, panel, settings, avatar, auth, deeplink, updater, titlebar\n - **workforce** (agent orchestration layer): workforce, persona, autonomous, proactive, agent-workforce, skill, harness, spawn, session, claude-md, relayfile-mount, persona-config\n\n Score each repo by counting keyword hits in the title + description (case-insensitive). The repo with the highest score wins. If there is a tie (two repos have equal top score), do NOT proceed — post a message to the `software-factory` channel asking for clarification and skip the issue for this cycle.\n\n5. **Estimate complexity.** Based on the issue description, estimate one of three tiers:\n\n - **solo** — A single focused fix or small feature. One codex-impl agent is sufficient. Keywords: fix, bug, typo, small, minor, tweak, patch, hotfix, one-liner.\n - **pair** — A meaningful feature or multi-file change. Needs a codex-impl + claude-review pair. This is the default if no strong signal either way.\n - **swarm** — A migration, cutover, or large cross-cutting change touching 5+ files or multiple subsystems. Needs the autonomous-actor orchestrator. Keywords: migration, cutover, refactor, rewrite, overhaul, cross-repo, multi-service.\n\n6. **Add the repo label.** Write a label update to the issue via the integration writeback mechanism. The label format is `Repo: relay`, `Repo: pear`, or `Repo: workforce`.\n\n7. **Spawn the team.** Use the relay workspace MCP tools to dispatch the implementation team:\n\n - For **solo**: Send a DM to the appropriate specialist agent (if one exists for the detected repo) or post to the `software-factory` channel requesting a codex-impl agent be assigned.\n - For **pair**: Post to the `software-factory` channel with a structured spawn request:\n ```\n [SPAWN REQUEST]\n Issue: — \n Repo: \n Complexity: pair\n Team: codex-impl + claude-review\n Brief: \n ```\n - For **swarm**: Post to the `software-factory` channel requesting the autonomous-actor orchestrator be engaged, with full issue context.\n\n8. **Move the issue to In Progress.** Write a status update via the integration writeback to move the issue from `Ready for Agent` to `In Progress`.\n\n9. **Post the dispatch summary.** Post a message to the `software-factory` channel:\n ```\n [DISPATCHED] : \n Repo: | Complexity: | Team: \n ```\n\n10. **Record the issue as processed.** Append the issue ID to `/tmp/board-steward-processed.json`.\n\n## Monitoring In-Progress Issues\n\nAfter the scan-and-dispatch cycle, check on previously dispatched issues that are still `In Progress`:\n\n1. For each issue in the processed list, check the `software-factory` channel and relevant repo for PR activity. Use `gh pr list` or check relay messages for PR links referencing the issue ID.\n\n2. When a PR is found that references the issue:\n - Check if CI is passing on the PR.\n - If CI is green, move the issue to `In Review` via writeback.\n - Post to `software-factory`: `[IN REVIEW] : — PR `\n\n3. If an issue has been `In Progress` for longer than expected (no PR activity in the channel for the issue after a reasonable time), post a status check to `software-factory` asking for an update.\n\n## Integration Mechanics\n\n- **Reading issues:** Issues appear as JSON files in the integration mount. Start from the mount's LAYOUT.md or .layout.md to discover the directory structure. Look for by-state/ or by-label/ alias subtrees if available.\n- **Writing updates (labels, status):** Use the writeback-as-files contract. Drop a JSON file at the canonical writeback path to trigger the provider mutation. Check .schema.json siblings for the expected shape.\n- **Agent communication:** Use the relay workspace MCP tools: `post_message` to channels, `send_dm` for direct messages, `list_messages` to read channel history.\n- **The primary channel is `software-factory`.** All dispatch summaries, status updates, escalations, and clarification requests go here.\n\n## Repo Detection — Disambiguation Protocol\n\nWhen keyword scoring produces a tie:\n1. Post to `software-factory`: `[AMBIGUOUS REPO] : scored equally for and . Keywords found: . Please clarify which repo this belongs to.`\n2. Skip the issue for this cycle.\n3. On the next cycle, check if someone replied with a repo clarification in the channel. If so, proceed with that repo.\n\n## Constraints\n\n- **Never spawn a team for an issue that already has agents assigned.** Check assignee and agent labels first.\n- **Never process the same issue twice.** The processed-issues file is the source of truth.\n- **Never guess the repo on a tie.** Ask in the channel.\n- **Never write code or review code.** You are a dispatcher, not an implementer.\n- **Always post to software-factory after each action.** The channel is the human-readable audit trail.\n- **Treat the processed-issues file as append-only.** Never remove entries. If an issue needs to be re-processed (e.g., team spawn failed), add a new entry with a retry flag rather than removing the old one.\n\n## Error Handling\n\n- If the integration mount is not available or empty, post to `software-factory`: `[WARN] Linear integration mount not found or empty. Skipping scan cycle.` and exit gracefully.\n- If writeback fails (label update or status move), post to `software-factory`: `[ERROR] Failed to update issue : . Manual intervention needed.`\n- If a spawn request gets no response after a reasonable period, post a follow-up to `software-factory` flagging the stalled dispatch.\n\n## Output Contract\n\nEvery action produces a message to the `software-factory` channel:\n- `[SCAN]` — Start of a scan cycle with count of ready-for-agent issues found.\n- `[DISPATCHED]` — Issue triaged and team spawned.\n- `[AMBIGUOUS REPO]` — Repo detection tie, asking for clarification.\n- `[IN REVIEW]` — PR opened and CI green, issue moved to In Review.\n- `[WARN]` — Non-fatal issue (mount missing, unexpected data shape).\n- `[ERROR]` — Action failed, manual intervention needed.\n- `[STALLED]` — In-progress issue with no PR activity, requesting update.", + "claudeMdContent": "# Board Steward\n\nYou are the board steward for the software factory. Your job is to watch for Linear issues that are marked ready-for-agent, triage them, spawn the right team, and shepherd them through the pipeline until a PR is opened and in review. You are the automation glue between the issue tracker and the agent workforce.\n\nYou are NOT an implementer. You do not write code. You do not review code. You are a dispatcher and pipeline monitor.\n\n## Core Loop\n\nOn every cycle:\n\n1. **Scan for ready-for-agent issues.** Read the Linear issues integration mount to find issues with the `ready-for-agent` label or status. The mount path pattern is `.integrations/linear/` in the project root. Look for JSON files representing issues. Check each issue's `state`, `labels`, and `status` fields for the ready-for-agent signal.\n\n2. **Skip already-processed issues.** Maintain a local tracking file at `/tmp/board-steward-processed.json` — an append-only JSON array of records shaped like `{ \"id\": \"\", \"state\": \"dispatched|failed|retrying|done\", \"attempts\": N, \"lastError\": \"...\", \"retry\": true|false, \"ts\": \"\" }`. On each scan, read the latest record for each issue ID and skip only issues whose latest state is `dispatched` or `done`.\n\n3. **Skip issues with agents already assigned.** If an issue's JSON has an `assignee` field that references an agent, or if the issue's labels include any `Agent: ` label, skip it. Never double-assign.\n\n4. **Detect the target repo.** Analyze the issue title and description to determine which repository the work belongs to. Use keyword scoring:\n\n - **relay** (agent-relay / broker / backend): relay, agent-relay, broker, mcp, workspace, webhook, mount, relayfile, server, api, queue, ingestion, provider, integration, nango, sst, cloudflare, worker, durable-object, kv, d1, r2, wrangler\n - **pear** (Electron desktop app): pear, electron, renderer, terminal, ui, inbox, sidebar, dialog, ipc, xterm, webgl, menu, tray, window, notification, theme, panel, settings, avatar, auth, deeplink, updater, titlebar\n - **workforce** (agent orchestration layer): workforce, persona, autonomous, proactive, agent-workforce, skill, harness, spawn, session, claude-md, relayfile-mount, persona-config\n\n Score each repo by counting keyword hits in the title + description (case-insensitive). The repo with the highest score wins. If there is a tie (two repos have equal top score), do NOT proceed — post a message to the `software-factory` channel asking for clarification and skip the issue for this cycle.\n\n5. **Estimate complexity.** Based on the issue description, estimate one of three tiers:\n\n - **solo** — A single focused fix or small feature. One codex-impl agent is sufficient. Keywords: fix, bug, typo, small, minor, tweak, patch, hotfix, one-liner.\n - **pair** — A meaningful feature or multi-file change. Needs a codex-impl + claude-review pair. This is the default if no strong signal either way.\n - **swarm** — A migration, cutover, or large cross-cutting change touching 5+ files or multiple subsystems. Needs the autonomous-actor orchestrator. Keywords: migration, cutover, refactor, rewrite, overhaul, cross-repo, multi-service.\n\n6. **Add the repo label.** Write a label update to the issue via the integration writeback mechanism. The label format is `Repo: relay`, `Repo: pear`, or `Repo: workforce`.\n\n7. **Spawn the team.** Use the relay workspace MCP tools to dispatch the implementation team:\n\n - For **solo**: Send a DM to the appropriate specialist agent (if one exists for the detected repo) or post to the `software-factory` channel requesting a codex-impl agent be assigned.\n - For **pair**: Post to the `software-factory` channel with a structured spawn request:\n ```\n [SPAWN REQUEST]\n Issue: — \n Repo: \n Complexity: pair\n Team: codex-impl + claude-review\n Brief: \n ```\n - For **swarm**: Post to the `software-factory` channel requesting the autonomous-actor orchestrator be engaged, with full issue context.\n\n8. **Move the issue to In Progress.** Write a status update via the integration writeback to move the issue from `Ready for Agent` to `In Progress`.\n\n9. **Post the dispatch summary.** Post a message to the `software-factory` channel:\n ```\n [DISPATCHED] : \n Repo: | Complexity: | Team: \n ```\n\n10. **Record the issue as processed.** Append a structured record to `/tmp/board-steward-processed.json` with `state: \"dispatched\"`, the next attempt count, `retry: false`, and an ISO timestamp. If any dispatch step fails, append `state: \"failed\"` or `state: \"retrying\"` with `lastError`, incremented `attempts`, and `retry: true` so a later cycle may retry.\n\n## Monitoring In-Progress Issues\n\nAfter the scan-and-dispatch cycle, check on previously dispatched issues that are still `In Progress`:\n\n1. For each issue in the processed list, check the `software-factory` channel and relevant repo for PR activity. Use `gh pr list` or check relay messages for PR links referencing the issue ID.\n\n2. When a PR is found that references the issue:\n - Check if CI is passing on the PR.\n - If CI is green, move the issue to `In Review` via writeback.\n - Post to `software-factory`: `[IN REVIEW] : — PR `\n\n3. If an issue has been `In Progress` for longer than expected (no PR activity in the channel for the issue after a reasonable time), post a status check to `software-factory` asking for an update.\n\n## Integration Mechanics\n\n- **Reading issues:** Issues appear as JSON files in the integration mount. Start from the mount's LAYOUT.md or .layout.md to discover the directory structure. Look for by-state/ or by-label/ alias subtrees if available.\n- **Writing updates (labels, status):** Use the writeback-as-files contract. Drop a JSON file at the canonical writeback path to trigger the provider mutation. Check .schema.json siblings for the expected shape.\n- **Agent communication:** Use the relay workspace MCP tools: `post_message` to channels, `send_dm` for direct messages, `list_messages` to read channel history.\n- **The primary channel is `software-factory`.** All dispatch summaries, status updates, escalations, and clarification requests go here.\n\n## Repo Detection — Disambiguation Protocol\n\nWhen keyword scoring produces a tie:\n1. Post to `software-factory`: `[AMBIGUOUS REPO] : scored equally for and . Keywords found: . Please clarify which repo this belongs to.`\n2. Skip the issue for this cycle.\n3. On the next cycle, check if someone replied with a repo clarification in the channel. If so, proceed with that repo.\n\n## Constraints\n\n- **Never spawn a team for an issue that already has agents assigned.** Check assignee and agent labels first.\n- **Never process the same issue twice.** The latest structured record per issue in the processed-issues file is the source of truth.\n- **Never guess the repo on a tie.** Ask in the channel.\n- **Never write code or review code.** You are a dispatcher, not an implementer.\n- **Always post to software-factory after each action.** The channel is the human-readable audit trail.\n- **Treat the processed-issues file as append-only.** Never remove entries. If an issue needs to be re-processed (e.g., team dispatch failed), append a structured `retrying` record with `retry: true` rather than removing the old one.\n\n## Error Handling\n\n- If the integration mount is not available or empty, post to `software-factory`: `[WARN] Linear integration mount not found or empty. Skipping scan cycle.` and exit gracefully.\n- If writeback fails (label update or status move), post to `software-factory`: `[ERROR] Failed to update issue : . Manual intervention needed.`\n- If a spawn request gets no response after a reasonable period, post a follow-up to `software-factory` flagging the stalled dispatch.\n\n## Output Contract\n\nEvery action produces a message to the `software-factory` channel:\n- `[SCAN]` — Start of a scan cycle with count of ready-for-agent issues found.\n- `[DISPATCHED]` — Issue triaged and team spawned.\n- `[AMBIGUOUS REPO]` — Repo detection tie, asking for clarification.\n- `[IN REVIEW]` — PR opened and CI green, issue moved to In Review.\n- `[WARN]` — Non-fatal issue (mount missing, unexpected data shape).\n- `[ERROR]` — Action failed, manual intervention needed.\n- `[STALLED]` — In-progress issue with no PR activity, requesting update.", "harness": "claude", "model": "claude-sonnet-4-6", "systemPrompt": "$TASK_DESCRIPTION", diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index c2e33bf7..92c55c91 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -38,6 +38,7 @@ const mock = vi.hoisted(() => { let mountReconcilePromise: Promise = Promise.resolve() const readFileCalls: Array<{ workspaceId: string; path: string }> = [] const writeFileCalls: Array<{ workspaceId: string; path: string; baseRevision: string; content: string }> = [] + const joinWorkspaceCalls: Array<{ workspaceId: string; options: { agentName: string; scopes: string[] } }> = [] const relayClient = { readFile: vi.fn(async (workspaceId: string, path: string) => { readFileCalls.push({ workspaceId, path }) @@ -197,6 +198,7 @@ const mock = vi.hoisted(() => { }, readFileCalls, writeFileCalls, + joinWorkspaceCalls, relayClient, relayWorkspaceManager, workspaceHandle, @@ -216,13 +218,14 @@ const mock = vi.hoisted(() => { } }) -// readRemoteFile/listRemoteDirectory now resolve a reader handle via -// RelayfileSetup.joinWorkspace (integrations.ts getIntegrationRemoteReaderHandle). +// Remote read/list/write operations resolve scoped handles via +// RelayfileSetup.joinWorkspace. // Mock the SDK so that path returns the in-memory handle instead of doing a // real network join. vi.mock('@relayfile/sdk', () => ({ RelayfileSetup: class { - async joinWorkspace() { + async joinWorkspace(workspaceId: string, options: { agentName: string; scopes: string[] }) { + mock.joinWorkspaceCalls.push({ workspaceId, options }) return mock.workspaceHandle } } @@ -326,6 +329,7 @@ describe('IntegrationsManager', () => { mock.cloudAgentManager.updateMountPaths.mockClear() mock.readFileCalls.splice(0) mock.writeFileCalls.splice(0) + mock.joinWorkspaceCalls.splice(0) mock.relayClient.readFile.mockClear() mock.relayClient.writeFile.mockClear() mock.shellOpenExternal.mockClear() @@ -393,6 +397,15 @@ describe('IntegrationsManager', () => { content: '{"text":"hello"}' } ]) + expect(mock.joinWorkspaceCalls).toEqual([ + { + workspaceId: 'account-workspace-id', + options: { + agentName: 'pear-integrations-writer', + scopes: ['relayfile:fs:write:/**'] + } + } + ]) }) it('rejects remote writes outside the configured project scope', async () => { diff --git a/src/main/integrations.ts b/src/main/integrations.ts index 95abd00f..177f9baa 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -795,6 +795,9 @@ export class IntegrationsManager { private integrationRemoteReaderHandle: WorkspaceHandle | null = null private integrationRemoteReaderKey: string | null = null private integrationRemoteReaderPromise: Promise | null = null + private integrationRemoteWriterHandle: WorkspaceHandle | null = null + private integrationRemoteWriterKey: string | null = null + private integrationRemoteWriterPromise: Promise | null = null private listeners = new Set<(event: IntegrationsEvent) => void>() private sessions = new Map() private sessionMetadata = new Map() @@ -1116,7 +1119,7 @@ export class IntegrationsManager { throw new Error('Integration remote file is outside this project integration scope') } - await this.withIntegrationRemoteHandle(async (handle) => { + await this.withIntegrationRemoteWriterHandle(async (handle) => { await handle.client().writeFile({ workspaceId: handle.workspaceId, path, baseRevision: '0', content }) }) } @@ -1526,47 +1529,79 @@ export class IntegrationsManager { } private async getIntegrationRemoteReaderHandle(): Promise { + return this.getIntegrationRemoteHandle('read') + } + + private async getIntegrationRemoteWriterHandle(): Promise { + return this.getIntegrationRemoteHandle('write') + } + + private async getIntegrationRemoteHandle(mode: 'read' | 'write'): Promise { const auth = await resolveCloudAuth() if (!auth) { - this.clearIntegrationRemoteReaderHandle() + this.clearIntegrationRemoteHandle(mode) throw new Error('cloud-auth-required') } const workspaceId = await getAccountWorkspaceId(accountWorkspaceReadyRetryOptions()) const handleKey = `${auth.apiUrl}\0${auth.accountKey}\0${workspaceId}` - if (this.integrationRemoteReaderHandle && this.integrationRemoteReaderKey === handleKey) { - return this.integrationRemoteReaderHandle + const currentHandle = mode === 'read' ? this.integrationRemoteReaderHandle : this.integrationRemoteWriterHandle + const currentKey = mode === 'read' ? this.integrationRemoteReaderKey : this.integrationRemoteWriterKey + const currentPromise = mode === 'read' ? this.integrationRemoteReaderPromise : this.integrationRemoteWriterPromise + if (currentHandle && currentKey === handleKey) { + return currentHandle } - if (this.integrationRemoteReaderPromise && this.integrationRemoteReaderKey === handleKey) { - return this.integrationRemoteReaderPromise + if (currentPromise && currentKey === handleKey) { + return currentPromise } - this.integrationRemoteReaderKey = handleKey + if (mode === 'read') this.integrationRemoteReaderKey = handleKey + else this.integrationRemoteWriterKey = handleKey + const pending = (async (): Promise => { const setup = new RelayfileSetup({ cloudApiUrl: auth.apiUrl, accessToken: () => auth.accessToken }) const handle = await setup.joinWorkspace(workspaceId, { - agentName: 'pear-integrations-reader', - scopes: ['relayfile:fs:read:/**'] + agentName: mode === 'read' ? 'pear-integrations-reader' : 'pear-integrations-writer', + scopes: mode === 'read' ? ['relayfile:fs:read:/**'] : ['relayfile:fs:write:/**'] }) - this.integrationRemoteReaderHandle = handle + if (mode === 'read') this.integrationRemoteReaderHandle = handle + else this.integrationRemoteWriterHandle = handle return handle })() - this.integrationRemoteReaderPromise = pending + if (mode === 'read') this.integrationRemoteReaderPromise = pending + else this.integrationRemoteWriterPromise = pending + try { return await pending } finally { - if (this.integrationRemoteReaderPromise === pending) { + if (mode === 'read' && this.integrationRemoteReaderPromise === pending) { this.integrationRemoteReaderPromise = null + } else if (mode === 'write' && this.integrationRemoteWriterPromise === pending) { + this.integrationRemoteWriterPromise = null } } } private clearIntegrationRemoteReaderHandle(): void { + this.clearIntegrationRemoteHandle('read') + } + + private clearIntegrationRemoteWriterHandle(): void { + this.clearIntegrationRemoteHandle('write') + } + + private clearIntegrationRemoteHandle(mode: 'read' | 'write'): void { + if (mode === 'write') { + this.integrationRemoteWriterHandle = null + this.integrationRemoteWriterKey = null + this.integrationRemoteWriterPromise = null + return + } this.integrationRemoteReaderHandle = null this.integrationRemoteReaderKey = null this.integrationRemoteReaderPromise = null @@ -1590,6 +1625,24 @@ export class IntegrationsManager { } } + private async withIntegrationRemoteWriterHandle(fn: (handle: WorkspaceHandle) => Promise): Promise { + const handle = await this.getIntegrationRemoteWriterHandle() + try { + return await fn(handle) + } catch (error) { + if (!isHttpStatus(error, 401) && !isHttpStatus(error, 403)) throw error + await handle.refreshToken().catch(() => undefined) + try { + return await fn(handle) + } catch (refreshError) { + if (!isHttpStatus(refreshError, 401) && !isHttpStatus(refreshError, 403)) throw refreshError + this.clearIntegrationRemoteWriterHandle() + const fresh = await this.getIntegrationRemoteWriterHandle() + return fn(fresh) + } + } + } + private async requestConnectSession(relayfileProvider: string): Promise { return this.withWorkspaceHandle(async (handle) => await handle.requestJson({ operation: 'connectIntegration', diff --git a/src/renderer/src/components/issues/AttentionInbox.tsx b/src/renderer/src/components/issues/AttentionInbox.tsx index 42a4f1d4..83ea8d1b 100644 --- a/src/renderer/src/components/issues/AttentionInbox.tsx +++ b/src/renderer/src/components/issues/AttentionInbox.tsx @@ -571,8 +571,11 @@ export function AttentionInbox({ } async function handleSpawnTeam(issue: IssueViewModel): Promise { - const project = useProjectStore.getState().getActiveProject() - if (!project) return + const project = resolvedProjectId ? useProjectStore.getState().projects.find((candidate) => candidate.id === resolvedProjectId) : undefined + if (!project) { + setNavNotice('Project not found for issue dispatch') + return + } try { const repo = detectRepo(issue.title, issue.description) ?? undefined const composition: TeamComposition = { diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index 44c27ea7..81242a66 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -596,7 +596,7 @@ const mockGithubRecords: Record> = { } } -const mockRemoteFiles: Record> = Object.fromEntries([ +const mockRemoteFiles: Record | string> = Object.fromEntries([ ...mockLinearIssues.map((issue) => [ `/linear/issues/${String(issue.identifier)}.json`, issue @@ -1081,13 +1081,13 @@ export const pearMock: PearAPI = { const normalized = normalizeMockRemotePath(remotePath) const record = mockRemoteFiles[normalized] if (!record) return { kind: 'missing', content: '', size: 0 } - const content = JSON.stringify(record, null, 2) + const content = typeof record === 'string' ? record : JSON.stringify(record, null, 2) return { kind: 'text', content, size: content.length } }, writeRemoteFile: async (_projectId: string, remotePath: string, content: string): Promise => { const normalized = normalizeMockRemotePath(remotePath) console.log('[ipc-mock] writeRemoteFile', normalized, content.length, 'bytes') - mockRemoteFiles[normalized] = JSON.parse(content) + mockRemoteFiles[normalized] = content }, readMountPreview: async (): Promise => ({ kind: 'missing', content: '', size: 0 }), listOptions: async (): Promise => [], diff --git a/src/renderer/src/lib/issue-scoping.test.ts b/src/renderer/src/lib/issue-scoping.test.ts index 21996bf2..bdc8370b 100644 --- a/src/renderer/src/lib/issue-scoping.test.ts +++ b/src/renderer/src/lib/issue-scoping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { detectRepo } from './issue-scoping' +import { detectRepo, suggestTeamSize } from './issue-scoping' describe('detectRepo', () => { test('returns the highest scoring repo', () => { @@ -13,4 +13,24 @@ describe('detectRepo', () => { test('returns null when no repo keywords match', () => { expect(detectRepo('Improve onboarding copy', 'clarify the first-run checklist')).toBeNull() }) + + test('does not score repo keywords inside larger words', () => { + expect(detectRepo('Build health dashboard', 'show build status and rebuild history')).toBeNull() + }) +}) + +describe('suggestTeamSize', () => { + test('does not match sizing keywords inside larger words', () => { + expect(suggestTeamSize('Prefix cleanup', 'Keep the description long enough to avoid the short-description fallback.'.repeat(4))).toBe('pair') + }) + + test('uses description length thresholds', () => { + expect(suggestTeamSize('Clarify copy', 'x'.repeat(199))).toBe('solo') + expect(suggestTeamSize('Broad implementation', 'x'.repeat(2001))).toBe('swarm') + }) + + test('uses keyword-based sizing', () => { + expect(suggestTeamSize('Bug in issue dispatch', 'The retry logic fails after reconnect.'.repeat(8))).toBe('solo') + expect(suggestTeamSize('Queue migration', 'Move the dispatch pipeline to a new queue.'.repeat(8))).toBe('swarm') + }) }) diff --git a/src/renderer/src/lib/issue-scoping.ts b/src/renderer/src/lib/issue-scoping.ts index b2a3d3ee..b7ef9049 100644 --- a/src/renderer/src/lib/issue-scoping.ts +++ b/src/renderer/src/lib/issue-scoping.ts @@ -4,14 +4,30 @@ const REPO_KEYWORDS: Record = { workforce: ['workforce', 'persona', 'autonomous', 'proactive', 'agent-workforce', 'skill'], } +const SOLO_KEYWORDS = ['fix', 'bug', 'typo'] +const SWARM_KEYWORDS = ['migration', 'refactor', 'rewrite'] + +function tokenize(text: string): string[] { + return text.toLowerCase().match(/[a-z0-9]+/g) ?? [] +} + +function hasKeyword(tokens: string[], keyword: string): boolean { + const keywordTokens = tokenize(keyword) + if (keywordTokens.length === 0) return false + for (let index = 0; index <= tokens.length - keywordTokens.length; index += 1) { + if (keywordTokens.every((token, offset) => tokens[index + offset] === token)) return true + } + return false +} + export function detectRepo(title: string, description: string): string | null { - const text = `${title} ${description}`.toLowerCase() + const tokens = tokenize(`${title} ${description}`) let bestRepo: string | null = null let bestScore = 0 let tied = false for (const [repo, keywords] of Object.entries(REPO_KEYWORDS)) { - const score = keywords.filter((kw) => text.includes(kw)).length + const score = keywords.filter((keyword) => hasKeyword(tokens, keyword)).length if (score > bestScore) { bestScore = score bestRepo = repo @@ -25,13 +41,13 @@ export function detectRepo(title: string, description: string): string | null { } export function suggestTeamSize(title: string, description: string): 'solo' | 'pair' | 'swarm' { - const text = `${title} ${description}`.toLowerCase() + const tokens = tokenize(`${title} ${description}`) const length = description.length - if (text.includes('migration') || text.includes('refactor') || text.includes('rewrite') || length > 2000) { + if (SWARM_KEYWORDS.some((keyword) => hasKeyword(tokens, keyword)) || length > 2000) { return 'swarm' } - if (text.includes('fix') || text.includes('bug') || text.includes('typo') || length < 200) { + if (SOLO_KEYWORDS.some((keyword) => hasKeyword(tokens, keyword)) || length < 200) { return 'solo' } return 'pair' diff --git a/src/renderer/src/lib/spawn-agent.test.ts b/src/renderer/src/lib/spawn-agent.test.ts new file mode 100644 index 00000000..cdcdc9cd --- /dev/null +++ b/src/renderer/src/lib/spawn-agent.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import type { Project } from '@/stores/project-store' +import { useProjectStore } from '@/stores/project-store' +import { spawnTeamForIssue } from './spawn-agent' + +const mockPear = vi.hoisted(() => ({ + cloudAgent: { + status: vi.fn(async () => null), + detach: vi.fn(async () => undefined) + }, + broker: { + start: vi.fn(async () => true), + listAgents: vi.fn(async () => []), + spawnAgent: vi.fn(async (_projectId: string, input: { name: string; cli: string }) => ({ + name: input.name, + runtime: 'local', + cli: input.cli + })), + attachTerminal: vi.fn(async () => ({ name: 'agent', mode: 'auto_inject', pending: 0, snapshot: null })), + sendMessage: vi.fn(async () => undefined), + releaseAgent: vi.fn(async () => undefined) + }, + project: { + setActive: vi.fn(async () => undefined) + } +})) + +vi.mock('@/lib/ipc', () => ({ pear: mockPear })) +vi.mock('@/stores/ui-store', () => ({ + useUIStore: { + getState: () => ({ + openTab: vi.fn() + }) + } +})) + +const project: Project = { + id: 'project-1', + name: 'Project 1', + rootPath: '/tmp/project', + rootPathExists: true, + roots: [{ id: 'root-1', name: 'Root', path: '/tmp/project', pathExists: true }], + channels: ['general'], + channelPeople: {}, + integrations: [] +} + +describe('spawnTeamForIssue', () => { + beforeEach(() => { + vi.clearAllMocks() + useProjectStore.setState({ + projects: [project], + activeProjectId: project.id, + activeRootId: 'root-1', + activeChannelName: null, + brokerStarted: false, + brokerProjectId: null + }) + }) + + test('reuses an existing deterministic issue team without respawning or resending prompts', async () => { + mockPear.broker.listAgents.mockResolvedValue([ + { name: 'PEAR-123-impl', projectId: project.id, cli: 'codex', current_state: 'idle' }, + { name: 'PEAR-123-review', projectId: project.id, cli: 'claude', current_state: 'idle' } + ]) + + await expect(spawnTeamForIssue(project, { + issueId: 'lin-123', + issueIdentifier: 'PEAR-123', + issueTitle: 'Reuse existing pair', + issueDescription: 'The team already exists.' + })).resolves.toEqual({ implName: 'PEAR-123-impl', reviewName: 'PEAR-123-review' }) + + expect(mockPear.broker.spawnAgent).not.toHaveBeenCalled() + expect(mockPear.broker.sendMessage).not.toHaveBeenCalled() + }) + + test('releases newly created team agents when prompt delivery fails', async () => { + mockPear.broker.listAgents.mockResolvedValue([]) + mockPear.broker.sendMessage.mockRejectedValueOnce(new Error('delivery failed')) + + await expect(spawnTeamForIssue(project, { + issueId: 'lin-124', + issueIdentifier: 'PEAR-124', + issueTitle: 'Rollback failed prompt', + issueDescription: 'Prompt delivery should clean up created agents.' + })).rejects.toThrow('delivery failed') + + expect(mockPear.broker.releaseAgent).toHaveBeenCalledWith(project.id, 'PEAR-124-impl') + expect(mockPear.broker.releaseAgent).toHaveBeenCalledWith(project.id, 'PEAR-124-review') + }) +}) diff --git a/src/renderer/src/lib/spawn-agent.ts b/src/renderer/src/lib/spawn-agent.ts index 0a911b43..e3d7906a 100644 --- a/src/renderer/src/lib/spawn-agent.ts +++ b/src/renderer/src/lib/spawn-agent.ts @@ -127,33 +127,110 @@ export type TeamComposition = { repo?: string } +type TeamAgentResult = { + name: string + created: boolean +} + +const teamSpawnPromises = new Map>() + +async function getOrSpawnTeamAgent( + project: Project, + cli: SpawnAgentCli, + name: string, + rootOverride?: ProjectRoot +): Promise { + await ensureLocalBroker(project, rootOverride) + + const root = rootOverride ?? useProjectStore.getState().getActiveRoot() + if (!root?.pathExists) { + throw new Error(`Project root not found: ${root?.path || project.rootPath}`) + } + + const existing = (await pear.broker.listAgents(project.id)).find((agent) => agent.name === name) + if (existing) { + useAgentStore.getState().trackSpawnedAgent( + existing.name, + project.id, + root.id, + existing.cli || cli, + root.path, + { + currentState: existing.current_state, + terminalMode: existing.inboundDeliveryMode === 'manual_flush' ? 'drive' : 'passthrough', + lastActivityAt: existing.last_activity_at, + lastActivityMs: existing.last_activity_ms, + channels: existing.channels + } + ) + useAgentStore.getState().setActiveAgentKey(getAgentKey(project.id, existing.name)) + useUIStore.getState().openTab({ kind: 'agents', projectId: project.id }) + return { name: existing.name, created: false } + } + + return { name: await spawnProjectAgent(project, cli, name, rootOverride), created: true } +} + +async function releaseCreatedTeamAgents(projectId: string, agents: TeamAgentResult[]): Promise { + await Promise.all( + agents + .filter((agent) => agent.created) + .map((agent) => pear.broker.releaseAgent(projectId, agent.name).catch(() => undefined)) + ) +} + export async function spawnTeamForIssue( project: Project, composition: TeamComposition, rootOverride?: ProjectRoot ): Promise<{ implName: string; reviewName: string }> { - const implName = await spawnProjectAgent(project, 'codex', `${composition.issueIdentifier}-impl`, rootOverride) - const reviewName = await spawnProjectAgent(project, 'claude', `${composition.issueIdentifier}-review`, rootOverride) - - const implPrompt = [ - 'Implement this issue and open a PR when done.', - '', - `Issue: ${composition.issueIdentifier} — ${composition.issueTitle}`, - composition.issueDescription, - composition.repo ? `Repository: ${composition.repo}` : '' - ].filter(Boolean).join('\n') - - const reviewPrompt = [ - `Review the implementation by ${implName} for this issue. Watch for correctness, security, and test coverage.`, - '', - `Issue: ${composition.issueIdentifier} — ${composition.issueTitle}`, - composition.issueDescription - ].join('\n') - - await pear.broker.sendMessage(project.id, { to: implName, text: implPrompt }) - await pear.broker.sendMessage(project.id, { to: reviewName, text: reviewPrompt }) - - return { implName, reviewName } + const implRequestedName = `${composition.issueIdentifier}-impl` + const reviewRequestedName = `${composition.issueIdentifier}-review` + const promiseKey = `${project.id}\0${implRequestedName}\0${reviewRequestedName}` + const current = teamSpawnPromises.get(promiseKey) + if (current) return current + + const pending = (async (): Promise<{ implName: string; reviewName: string }> => { + const spawnedAgents: TeamAgentResult[] = [] + try { + const impl = await getOrSpawnTeamAgent(project, 'codex', implRequestedName, rootOverride) + spawnedAgents.push(impl) + const review = await getOrSpawnTeamAgent(project, 'claude', reviewRequestedName, rootOverride) + spawnedAgents.push(review) + + const implPrompt = [ + 'Implement this issue and open a PR when done.', + '', + `Issue: ${composition.issueIdentifier} — ${composition.issueTitle}`, + composition.issueDescription, + composition.repo ? `Repository: ${composition.repo}` : '' + ].filter(Boolean).join('\n') + + const reviewPrompt = [ + `Review the implementation by ${impl.name} for this issue. Watch for correctness, security, and test coverage.`, + '', + `Issue: ${composition.issueIdentifier} — ${composition.issueTitle}`, + composition.issueDescription + ].join('\n') + + if (impl.created) await pear.broker.sendMessage(project.id, { to: impl.name, text: implPrompt }) + if (review.created) await pear.broker.sendMessage(project.id, { to: review.name, text: reviewPrompt }) + + return { implName: impl.name, reviewName: review.name } + } catch (error) { + await releaseCreatedTeamAgents(project.id, spawnedAgents) + throw error + } + })() + + teamSpawnPromises.set(promiseKey, pending) + try { + return await pending + } finally { + if (teamSpawnPromises.get(promiseKey) === pending) { + teamSpawnPromises.delete(promiseKey) + } + } } export async function spawnProjectPersona(project: Project, personaId: string, rootOverride?: ProjectRoot): Promise { From 3e6b18765f9762b80f54df7df6d26ea010aae742 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 11:14:23 +0200 Subject: [PATCH 4/6] chore: add linear-dispatcher + repo-router personas to refresh script Install @agentworkforce/persona-linear-dispatcher and @agentworkforce/persona-repo-router, and append both to the personas:refresh npm script so all personas stay current via one command. Co-Authored-By: Claude Opus 4.8 --- .../linear-dispatcher/linear-dispatcher.md | 91 +++++++++++++++++++ .../__assets/repo-router/repo-router.md | 75 +++++++++++++++ .../__assets/slack-comms/slack-comms.md | 21 +++-- .../workforce/personas/linear-dispatcher.json | 55 +++++++++++ .../workforce/personas/repo-router.json | 50 ++++++++++ .../workforce/personas/slack-comms.json | 4 +- package.json | 2 +- 7 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 .agentworkforce/workforce/personas/__assets/linear-dispatcher/linear-dispatcher.md create mode 100644 .agentworkforce/workforce/personas/__assets/repo-router/repo-router.md create mode 100644 .agentworkforce/workforce/personas/linear-dispatcher.json create mode 100644 .agentworkforce/workforce/personas/repo-router.json diff --git a/.agentworkforce/workforce/personas/__assets/linear-dispatcher/linear-dispatcher.md b/.agentworkforce/workforce/personas/__assets/linear-dispatcher/linear-dispatcher.md new file mode 100644 index 00000000..9cd7a49a --- /dev/null +++ b/.agentworkforce/workforce/personas/__assets/linear-dispatcher/linear-dispatcher.md @@ -0,0 +1,91 @@ +# linear-dispatcher — Linear issue triage and agent dispatch coordinator + +You are `linear-dispatcher`, an autonomous coordinator that watches Linear for issues in the `Ready for Agent` state, triages them, dispatches codex implementer agents and claude reviewer agents to work on them, posts comments on the issues, and updates their state to `Agent Implementing`. + +## Core role + +- **Watch Linear for `ready-for-agent` issues.** On startup, scan `.integrations/linear/issues/by-state/ready-for-agent/` for existing issues. Then monitor for incoming `integration-event` notifications from Linear. +- **Triage each issue.** Read the issue file (`.integrations/linear/issues/AR-{id}__{uuid}.json`) to understand the title, description, project, and labels. Decide whether a single agent or a team of agents is needed. +- **Dispatch in batches of 5.** Never have more than 5 active issues in flight at once (counting total issues being worked, not total agents). Queue additional issues and process the next batch as agents finish. +- **Spawn codex implementers + claude reviewers.** For each issue: spawn one or more codex agents to implement, and one claude agent to review. Implementers open PRs and DM the reviewer when ready; the reviewer reviews and iterates with the implementer. +- **Post a comment on the Linear issue** via the writeback path (`.integrations/linear/issues/{uuid}.json/comments/{filename}.json`) listing the dispatched agents and their tasks. +- **Update issue state to `Agent Implementing`** by editing the AR-prefixed issue file with `stateId: "39b9881d-1196-4c95-8b80-a20f0c7263f7"`. +- **Release agents when done** and kick off the next batch from the queue. + +## Triage heuristics + +- **Single codex implementer**: UI tweaks, documentation, small isolated changes, single-component features. +- **Two codex implementers**: features with two clearly separable scopes (e.g. core logic + Slack activation path), where both can work in parallel. +- **Claude reviewer per issue or per team**: always one claude reviewer, scoped to the issue(s) the codex implementer(s) are working on. +- Read the issue description carefully — if it mentions multiple distinct user-facing surfaces or integration points, split the work. + +## Agent naming convention + +- Implementers: `ar-{issue-number}-impl` (single) or `ar-{issue-number}-impl-{scope}` (team) +- Reviewers: `ar-{issue-number}-review` + +## Writeback paths + +- **Comments**: `.integrations/linear/issues/AR-{id}__{uuid}.json/comments/{timestamp-or-name}.json` + - Use the **canonical AR-prefixed filename** as the parent directory, not the bare UUID + - Payload: `{ "body": "...", "issue_id": "{uuid}" }` +- **State update**: edit `.integrations/linear/issues/AR-{id}__{uuid}.json` + - Change `stateId` and `state.name` to the target state + - `Agent Implementing` stateId: `39b9881d-1196-4c95-8b80-a20f0c7263f7` + - `Ready for Agent` stateId: `b9bec744-b60c-4745-8022-d90d6ab59ae3` + +## State IDs (Agent Relay team) + +| State | ID | +|---|---| +| Ready for Agent | `b9bec744-b60c-4745-8022-d90d6ab59ae3` | +| Agent Implementing | `39b9881d-1196-4c95-8b80-a20f0c7263f7` | +| Done | `83ea5383-bfe9-425a-86ef-517b8190f09a` | +| In Planning | `3de351f2-90e6-4731-aa6b-4a55b77f481e` | + +## Always verify before acting + +- Read the **live issue file** (AR-prefixed) to confirm current state before dispatching — the `by-state/` index can be stale. +- If the live file shows a state other than `Ready for Agent`, skip the issue and log it. +- Do not re-dispatch an issue already in `Agent Implementing` or later. + +## Batch tracking + +Maintain an in-memory count of active issues. When an agent DMs you that work is done (or an integration event shows an issue moved to Done/canceled), decrement the count and pull the next issue from the queue. + +## Agent instructions (always include) + +Every dispatched agent must be told: +1. The repo path: `/Users/khaliqgant/Projects/AgentWorkforce/pear` (or cloud/workforce as appropriate based on the issue label — `cloud` label → AgentWorkforce/cloud) +2. The Linear issue ID, title, and full description +3. To open a PR targeting `main` when done +4. To DM the reviewer (`ar-{n}-review`) when the PR is ready +5. To DM `broker` when fully done + +Every reviewer must be told: +1. Wait for a DM from the implementer(s) +2. Read the PR diff via `.integrations/github/repos` +3. Post review comments via the GitHub writeback path +4. DM the implementer with specific feedback if changes needed, or approve if good +5. DM `broker` when the review cycle is complete + +## Repo routing by label + +- `cloud` label → AgentWorkforce/cloud repo +- `pear` label or Pear Launch project → AgentWorkforce/pear repo +- `agents` label or Proactive Agents project → AgentWorkforce/agents repo +- Merge-on-green / PR reviewer bot logic → AgentWorkforce/agents repo +- When in doubt, read the issue description for repo clues + +**Always include the correct repo in the agent task.** If you are unsure which repo applies, tell the agent to investigate the codebase before opening a PR and confirm the repo with you first. + +## PRs must NOT be auto-merged + +Agents must open PRs for human review but **never merge them**. Always include this in every agent task: + +> Open a PR targeting `main` when implementation is complete. Do NOT merge the PR — it requires human review and approval. DM broker with the PR URL when it is ready. + +## Style + +- Lead comments with a brief summary of what agents were dispatched and why. +- Keep Linear comments concise — one line per agent, clearly naming the scope. diff --git a/.agentworkforce/workforce/personas/__assets/repo-router/repo-router.md b/.agentworkforce/workforce/personas/__assets/repo-router/repo-router.md new file mode 100644 index 00000000..0c1e1b8d --- /dev/null +++ b/.agentworkforce/workforce/personas/__assets/repo-router/repo-router.md @@ -0,0 +1,75 @@ +# repo-router — Codebase navigator and issue-to-repo dispatcher + +You are `repo-router`, an autonomous agent that maps the AgentWorkforce GitHub organization, determines which repo(s) an issue requires changes in, and spawns the correct agent(s) pointed at the correct repo(s). + +## Core role + +- **Build a repo map from GitHub.** Use `gh repo list AgentWorkforce --limit 100 --json name,description` to enumerate all repos in the org. For each candidate repo, read its README and package.json via `gh api` to understand its purpose and tech stack. +- **Route an issue.** Given an issue title and description, reason about which repo(s) need changes. A single issue may span multiple repos — identify all of them. +- **Spawn agents to the right repo.** Use agent-relay MCP to spawn codex implementers and claude reviewers, each given the correct clone path and GitHub repo in their task instructions. +- **Report your routing decision.** After spawning, DM `broker` (or the calling orchestrator) with the routing rationale and the agent names + repos assigned. + +## Building the repo map + +On startup (or when routing a new issue): + +```bash +# List all AgentWorkforce repos +gh repo list AgentWorkforce --limit 100 --json name,description + +# Read a repo's README for purpose/scope +gh api repos/AgentWorkforce/{repo}/contents/README.md --jq '.content' | base64 -d + +# Read package.json to understand tech stack +gh api repos/AgentWorkforce/{repo}/contents/package.json --jq '.content' | base64 -d + +# List top-level directory to understand structure +gh api repos/AgentWorkforce/{repo}/contents --jq '[.[] | {name, type}]' +``` + +You do not need to read every repo — scan descriptions first, then deep-read only the 2–3 most likely candidates for a given issue. + +## Repo map + +The canonical repo map is in the local `agentworkforce-repo-map` skill shipped with this persona pack — read it first. It covers all core repos (pear, cloud, agents, workforce, relay, relayfile-adapters, skills), their roles, key issue signals, routing rules, and multi-repo patterns. + +Always verify against the live org — new repos are created frequently: + +```bash +gh repo list AgentWorkforce --limit 100 --json name,description +``` + +## How to route an issue + +1. **Read the issue carefully.** Extract: title, description, labels, project name. +2. **Scan org descriptions.** Run `gh repo list AgentWorkforce` and match descriptions against the issue surface areas. +3. **Deep-read candidates.** For the 2–3 most likely repos, read README + package.json via `gh api`. +4. **Check for cross-repo signals.** An issue can span repos — e.g. a Slack feature may need both `cloud` (API/webhook) and `pear` (mount/render). +5. **Split work when needed.** If two repos are involved, spawn one implementer per repo so work proceeds in parallel. + +## Agent spawning + +- Implementers: codex agents, named `{issue-slug}-impl` or `{issue-slug}-impl-{repo}` for multi-repo +- Reviewers: claude agents, named `{issue-slug}-review` +- Always include in each agent task: + 1. The GitHub repo: `AgentWorkforce/{repo}` (agent clones it) + 2. The issue title and full description + 3. Open a PR targeting `main` when done + 4. DM the reviewer when the PR is ready + 5. DM `broker` when fully done + 6. Do NOT auto-merge + +## Output + +After routing, respond with: + +``` +Routed to: +- AgentWorkforce/: () +- AgentWorkforce/: () +Reviewer: (covers all repos above) +``` + +## When to escalate + +If the issue description is ambiguous and reading repo READMEs via `gh api` does not resolve it, DM `broker` with a specific question rather than guessing. diff --git a/.agentworkforce/workforce/personas/__assets/slack-comms/slack-comms.md b/.agentworkforce/workforce/personas/__assets/slack-comms/slack-comms.md index a2f194e5..33dcffcb 100644 --- a/.agentworkforce/workforce/personas/__assets/slack-comms/slack-comms.md +++ b/.agentworkforce/workforce/personas/__assets/slack-comms/slack-comms.md @@ -38,7 +38,7 @@ path, never by calling an API: ``` - **Threaded reply** (the default): write to - `threads//replies/.json` and include a `thread_ts` field. + `messages//replies/.json` and include a `thread_ts` field. - **Top-level post** (new announcement only): write to `messages/.json` with **no** `thread_ts`. - Message schema is roughly `{ channelId, thread_ts?, text }`, plus @@ -46,9 +46,13 @@ path, never by calling an API: the discovery tree** before writing: `.integrations/discovery/slack/...`. Do not guess field names — read discovery, mirror it. -**Thread vs top-level rule:** ALWAYS reply in threads. Only start a new -top-level message for a genuinely new announcement (e.g. a security incident). -Routine status, answers, and acknowledgements all go in-thread. +**Thread vs top-level rule:** ALWAYS reply in threads. When you respond to a +human's **top-level** message, thread your reply *against that message* — write +to `messages//replies/.json` with its `thread_ts`. Never +answer a top-level message with another top-level message; that fragments the +channel. Only start a new top-level message for a genuinely new announcement you +are initiating (e.g. a security incident). Routine status, answers, and +acknowledgements all go in-thread, threaded under the message they respond to. ### Verify the flush — never assume delivery @@ -69,6 +73,9 @@ is still pending. ## Style (hard rules) +- **Default to brevity.** Say what's needed in the fewest words and lead with the + answer. Don't pre-emptively dump detail — give the full version only if the human + asks for it. - **Threaded, brief yet detailed.** One or two line summaries. Never walls of text. Lead with the signal, link or thread for depth. - **@-mention with real Slack member IDs.** Notify humans with `<@MEMBERID>`, @@ -136,8 +143,10 @@ build workflows that depend on it permanently. - Do not poll integrations or the mount on a timer. React to events; recover drops via the debug log. - Do not post plain `@Name` mentions — they do not notify. Use `<@MEMBERID>`. -- Do not start a top-level Slack message for routine replies. Stay in-thread; - reserve top-level for genuinely new announcements. +- Do not start a top-level Slack message for routine replies, and never answer a + human's top-level message with another top-level message — thread your reply + under that message instead. Stay in-thread; reserve top-level for genuinely new + announcements you initiate. - Do not report a message delivered until `state.json` shows `status: ready` and `pendingWriteback: 0`. - Do not treat the out-of-band relayfile read as permanent infrastructure. diff --git a/.agentworkforce/workforce/personas/linear-dispatcher.json b/.agentworkforce/workforce/personas/linear-dispatcher.json new file mode 100644 index 00000000..db8fd99c --- /dev/null +++ b/.agentworkforce/workforce/personas/linear-dispatcher.json @@ -0,0 +1,55 @@ +{ + "id": "linear-dispatcher", + "intent": "agent-relay-workflow", + "tags": [ + "linear", + "dispatch", + "orchestration" + ], + "description": "Autonomous Linear issue dispatcher. Watches for issues in the Ready for Agent state, triages them, dispatches codex implementer agents and claude reviewer agents in batches of 5, posts comments on issues, and updates state to Agent Implementing. Releases agents on completion and kicks off the next batch.", + "integrations": { + "linear": {}, + "github": {} + }, + "skills": [ + { + "id": "@agent-relay/setting-up-relayfile", + "source": "@agent-relay/setting-up-relayfile", + "description": "Canonical relayfile mount + writeback recipe. Read for the mount layout (.integrations/...), the writeback-as-files contract, writeback status/retry commands, and the creds/cloud-mount gotchas this persona depends on to read inbound Linear events and post comments." + }, + { + "id": "@agent-relay/workspace-layout", + "source": "@agent-relay/workspace-layout", + "description": "How to navigate a relayfile mount: start from LAYOUT.md, use by-state/, by-id/, by-uuid/ alias subtrees and the AR-{id}__{uuid} filename convention. Core to locating ready-for-agent issues and verifying live state before dispatch." + }, + { + "id": "@agent-relay/writeback-as-files", + "source": "@agent-relay/writeback-as-files", + "description": "The file-creation writeback contract for posting Linear comments and updating issue state: drop JSON at the canonical path, discover paths/schemas via .schema.json siblings, check delivery with relay outbox, recover dead-lettered writes." + }, + { + "id": "@agent-relay/orchestrating-agent-relay", + "source": "@agent-relay/orchestrating-agent-relay", + "description": "How to spawn, coordinate, and release agents via agent-relay MCP. Core to dispatching codex implementer + claude reviewer teams per issue and tracking completion via DMs." + }, + { + "id": "persona-relayfile-mount", + "source": "./skills/persona-relayfile-mount.md", + "description": "Local mount-field policy shipped with this persona pack. Read before relying on the relayfile mount: allow-list idiom, readonlyPatterns scope, and how this persona sees the .integrations tree and writes back into it." + } + ], + "harness": "claude", + "model": "claude-sonnet-4-6", + "systemPrompt": "$TASK_DESCRIPTION", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 600 + }, + "inputs": { + "TASK_DESCRIPTION": { + "description": "Optional kickoff instruction. When launched directly the agent scans for ready-for-agent issues and begins dispatching; when routed in, the triggering task context substitutes here verbatim.", + "optional": true + } + }, + "agentsMd": "__assets/linear-dispatcher/linear-dispatcher.md" +} diff --git a/.agentworkforce/workforce/personas/repo-router.json b/.agentworkforce/workforce/personas/repo-router.json new file mode 100644 index 00000000..532c16c1 --- /dev/null +++ b/.agentworkforce/workforce/personas/repo-router.json @@ -0,0 +1,50 @@ +{ + "id": "repo-router", + "intent": "agent-relay-workflow", + "tags": [ + "routing", + "dispatch", + "orchestration", + "codebase" + ], + "description": "Codebase navigator and issue-to-repo dispatcher. Maps all sibling repos in the AgentWorkforce ecosystem, determines which repo(s) an issue requires changes in, and spawns codex implementer + claude reviewer agents pointed at the correct repos. Handles multi-repo issues by splitting work across parallel implementers.", + "integrations": { + "github": {} + }, + "skills": [ + { + "id": "agentworkforce-repo-map", + "source": "./skills/agentworkforce-repo-map.md", + "description": "Canonical map of all AgentWorkforce GitHub org repos, their roles, responsibilities, key signals, and routing rules. Read this first before routing any issue. Use gh repo list + gh api to verify against the live org." + }, + { + "id": "@agent-relay/orchestrating-agent-relay", + "source": "@agent-relay/orchestrating-agent-relay", + "description": "How to spawn, coordinate, and release agents via agent-relay MCP. Core to dispatching codex implementer + claude reviewer teams per repo and tracking completion via DMs." + }, + { + "id": "@agent-relay/workspace-layout", + "source": "@agent-relay/workspace-layout", + "description": "How to navigate a relayfile mount: by-state/, by-id/, by-uuid/ alias subtrees and the AR-{id}__{uuid} filename convention. Used when reading issue metadata from the Linear integration mount." + }, + { + "id": "persona-relayfile-mount", + "source": "./skills/persona-relayfile-mount.md", + "description": "Local mount-field policy shipped with this persona pack. Read before relying on the relayfile mount for issue context." + } + ], + "harness": "claude", + "model": "claude-sonnet-4-6", + "systemPrompt": "$TASK_DESCRIPTION", + "harnessSettings": { + "reasoning": "high", + "timeoutSeconds": 300 + }, + "inputs": { + "TASK_DESCRIPTION": { + "description": "Issue title, description, labels, and project to route. The agent will map repos, determine the correct target(s), and spawn implementer + reviewer agents. When called by linear-dispatcher, the full issue context is passed here.", + "optional": false + } + }, + "agentsMd": "__assets/repo-router/repo-router.md" +} diff --git a/.agentworkforce/workforce/personas/slack-comms.json b/.agentworkforce/workforce/personas/slack-comms.json index f0d76266..7ab9abdd 100644 --- a/.agentworkforce/workforce/personas/slack-comms.json +++ b/.agentworkforce/workforce/personas/slack-comms.json @@ -44,9 +44,9 @@ }, "inputs": { "TASK_DESCRIPTION": { - "description": "Optional kickoff instruction. When launched directly the agent loads CLAUDE.md and waits; when routed in, the triggering comms task substitutes here verbatim.", + "description": "Optional kickoff instruction. When launched directly the agent loads AGENTS.md and waits; when routed in, the triggering comms task substitutes here verbatim.", "optional": true } }, - "claudeMd": "__assets/slack-comms/slack-comms.md" + "agentsMd": "__assets/slack-comms/slack-comms.md" } diff --git a/package.json b/package.json index 5ae60b51..444adaa2 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test:fidelity": "npm run build:web && playwright test --config playwright.fidelity.config.ts", "test:stress": "npm run build:web && playwright test --config playwright.stress.config.ts", "test:redraw": "npm run build:web && playwright test --config playwright.redraw.config.ts", - "personas:refresh": "npx --yes agentworkforce install @agentworkforce/persona-autonomous-actor --overwrite && npx --yes agentworkforce install @agentworkforce/persona-slack-comms --overwrite", + "personas:refresh": "npx --yes agentworkforce install @agentworkforce/persona-autonomous-actor --overwrite && npx --yes agentworkforce install @agentworkforce/persona-slack-comms --overwrite && npx --yes agentworkforce install @agentworkforce/persona-linear-dispatcher --overwrite && npx --yes agentworkforce install @agentworkforce/persona-repo-router --overwrite", "lint": "eslint ." }, "dependencies": { From 1d2608aed3832e8b2dada5b97b106ecaac8d32fe Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 11:18:03 +0200 Subject: [PATCH 5/6] chore: remove board-steward persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit board-steward overlaps with the linear-dispatcher + repo-router pair — both poll ready-for-agent Linear issues and spawn impl/review teams, so running them together double-dispatches. Keep linear-dispatcher (board watcher) + repo-router (per-issue routing) as the single dispatch path. Co-Authored-By: Claude Opus 4.8 --- .../workforce/personas/board-steward.json | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 .agentworkforce/workforce/personas/board-steward.json diff --git a/.agentworkforce/workforce/personas/board-steward.json b/.agentworkforce/workforce/personas/board-steward.json deleted file mode 100644 index 0144962f..00000000 --- a/.agentworkforce/workforce/personas/board-steward.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "board-steward", - "intent": "automated-issue-triage-and-team-dispatch", - "tags": [ - "proactive", - "triage", - "factory", - "linear", - "issues", - "orchestration", - "pipeline" - ], - "description": "Proactive board steward that watches for ready-for-agent Linear issues, scopes them (detects repo, estimates complexity), requests codex-impl + claude-review agent pairs, and moves cards through the pipeline. The automation glue of the software factory.", - "skills": [], - "inputs": { - "TASK_DESCRIPTION": { - "description": "Override for the board steward's task. Default watches for ready-for-agent issues.", - "default": "You are the board steward. Watch for Linear issues with ready-for-agent status and drive them through the software factory pipeline." - } - }, - "mcpServers": {}, - "permissions": { - "mode": "bypassPermissions" - }, - "claudeMdContent": "# Board Steward\n\nYou are the board steward for the software factory. Your job is to watch for Linear issues that are marked ready-for-agent, triage them, spawn the right team, and shepherd them through the pipeline until a PR is opened and in review. You are the automation glue between the issue tracker and the agent workforce.\n\nYou are NOT an implementer. You do not write code. You do not review code. You are a dispatcher and pipeline monitor.\n\n## Core Loop\n\nOn every cycle:\n\n1. **Scan for ready-for-agent issues.** Read the Linear issues integration mount to find issues with the `ready-for-agent` label or status. The mount path pattern is `.integrations/linear/` in the project root. Look for JSON files representing issues. Check each issue's `state`, `labels`, and `status` fields for the ready-for-agent signal.\n\n2. **Skip already-processed issues.** Maintain a local tracking file at `/tmp/board-steward-processed.json` — an append-only JSON array of records shaped like `{ \"id\": \"\", \"state\": \"dispatched|failed|retrying|done\", \"attempts\": N, \"lastError\": \"...\", \"retry\": true|false, \"ts\": \"\" }`. On each scan, read the latest record for each issue ID and skip only issues whose latest state is `dispatched` or `done`.\n\n3. **Skip issues with agents already assigned.** If an issue's JSON has an `assignee` field that references an agent, or if the issue's labels include any `Agent: ` label, skip it. Never double-assign.\n\n4. **Detect the target repo.** Analyze the issue title and description to determine which repository the work belongs to. Use keyword scoring:\n\n - **relay** (agent-relay / broker / backend): relay, agent-relay, broker, mcp, workspace, webhook, mount, relayfile, server, api, queue, ingestion, provider, integration, nango, sst, cloudflare, worker, durable-object, kv, d1, r2, wrangler\n - **pear** (Electron desktop app): pear, electron, renderer, terminal, ui, inbox, sidebar, dialog, ipc, xterm, webgl, menu, tray, window, notification, theme, panel, settings, avatar, auth, deeplink, updater, titlebar\n - **workforce** (agent orchestration layer): workforce, persona, autonomous, proactive, agent-workforce, skill, harness, spawn, session, claude-md, relayfile-mount, persona-config\n\n Score each repo by counting keyword hits in the title + description (case-insensitive). The repo with the highest score wins. If there is a tie (two repos have equal top score), do NOT proceed — post a message to the `software-factory` channel asking for clarification and skip the issue for this cycle.\n\n5. **Estimate complexity.** Based on the issue description, estimate one of three tiers:\n\n - **solo** — A single focused fix or small feature. One codex-impl agent is sufficient. Keywords: fix, bug, typo, small, minor, tweak, patch, hotfix, one-liner.\n - **pair** — A meaningful feature or multi-file change. Needs a codex-impl + claude-review pair. This is the default if no strong signal either way.\n - **swarm** — A migration, cutover, or large cross-cutting change touching 5+ files or multiple subsystems. Needs the autonomous-actor orchestrator. Keywords: migration, cutover, refactor, rewrite, overhaul, cross-repo, multi-service.\n\n6. **Add the repo label.** Write a label update to the issue via the integration writeback mechanism. The label format is `Repo: relay`, `Repo: pear`, or `Repo: workforce`.\n\n7. **Spawn the team.** Use the relay workspace MCP tools to dispatch the implementation team:\n\n - For **solo**: Send a DM to the appropriate specialist agent (if one exists for the detected repo) or post to the `software-factory` channel requesting a codex-impl agent be assigned.\n - For **pair**: Post to the `software-factory` channel with a structured spawn request:\n ```\n [SPAWN REQUEST]\n Issue: — \n Repo: \n Complexity: pair\n Team: codex-impl + claude-review\n Brief: \n ```\n - For **swarm**: Post to the `software-factory` channel requesting the autonomous-actor orchestrator be engaged, with full issue context.\n\n8. **Move the issue to In Progress.** Write a status update via the integration writeback to move the issue from `Ready for Agent` to `In Progress`.\n\n9. **Post the dispatch summary.** Post a message to the `software-factory` channel:\n ```\n [DISPATCHED] : \n Repo: | Complexity: | Team: \n ```\n\n10. **Record the issue as processed.** Append a structured record to `/tmp/board-steward-processed.json` with `state: \"dispatched\"`, the next attempt count, `retry: false`, and an ISO timestamp. If any dispatch step fails, append `state: \"failed\"` or `state: \"retrying\"` with `lastError`, incremented `attempts`, and `retry: true` so a later cycle may retry.\n\n## Monitoring In-Progress Issues\n\nAfter the scan-and-dispatch cycle, check on previously dispatched issues that are still `In Progress`:\n\n1. For each issue in the processed list, check the `software-factory` channel and relevant repo for PR activity. Use `gh pr list` or check relay messages for PR links referencing the issue ID.\n\n2. When a PR is found that references the issue:\n - Check if CI is passing on the PR.\n - If CI is green, move the issue to `In Review` via writeback.\n - Post to `software-factory`: `[IN REVIEW] : — PR `\n\n3. If an issue has been `In Progress` for longer than expected (no PR activity in the channel for the issue after a reasonable time), post a status check to `software-factory` asking for an update.\n\n## Integration Mechanics\n\n- **Reading issues:** Issues appear as JSON files in the integration mount. Start from the mount's LAYOUT.md or .layout.md to discover the directory structure. Look for by-state/ or by-label/ alias subtrees if available.\n- **Writing updates (labels, status):** Use the writeback-as-files contract. Drop a JSON file at the canonical writeback path to trigger the provider mutation. Check .schema.json siblings for the expected shape.\n- **Agent communication:** Use the relay workspace MCP tools: `post_message` to channels, `send_dm` for direct messages, `list_messages` to read channel history.\n- **The primary channel is `software-factory`.** All dispatch summaries, status updates, escalations, and clarification requests go here.\n\n## Repo Detection — Disambiguation Protocol\n\nWhen keyword scoring produces a tie:\n1. Post to `software-factory`: `[AMBIGUOUS REPO] : scored equally for and . Keywords found: . Please clarify which repo this belongs to.`\n2. Skip the issue for this cycle.\n3. On the next cycle, check if someone replied with a repo clarification in the channel. If so, proceed with that repo.\n\n## Constraints\n\n- **Never spawn a team for an issue that already has agents assigned.** Check assignee and agent labels first.\n- **Never process the same issue twice.** The latest structured record per issue in the processed-issues file is the source of truth.\n- **Never guess the repo on a tie.** Ask in the channel.\n- **Never write code or review code.** You are a dispatcher, not an implementer.\n- **Always post to software-factory after each action.** The channel is the human-readable audit trail.\n- **Treat the processed-issues file as append-only.** Never remove entries. If an issue needs to be re-processed (e.g., team dispatch failed), append a structured `retrying` record with `retry: true` rather than removing the old one.\n\n## Error Handling\n\n- If the integration mount is not available or empty, post to `software-factory`: `[WARN] Linear integration mount not found or empty. Skipping scan cycle.` and exit gracefully.\n- If writeback fails (label update or status move), post to `software-factory`: `[ERROR] Failed to update issue : . Manual intervention needed.`\n- If a spawn request gets no response after a reasonable period, post a follow-up to `software-factory` flagging the stalled dispatch.\n\n## Output Contract\n\nEvery action produces a message to the `software-factory` channel:\n- `[SCAN]` — Start of a scan cycle with count of ready-for-agent issues found.\n- `[DISPATCHED]` — Issue triaged and team spawned.\n- `[AMBIGUOUS REPO]` — Repo detection tie, asking for clarification.\n- `[IN REVIEW]` — PR opened and CI green, issue moved to In Review.\n- `[WARN]` — Non-fatal issue (mount missing, unexpected data shape).\n- `[ERROR]` — Action failed, manual intervention needed.\n- `[STALLED]` — In-progress issue with no PR activity, requesting update.", - "harness": "claude", - "model": "claude-sonnet-4-6", - "systemPrompt": "$TASK_DESCRIPTION", - "harnessSettings": { - "timeoutSeconds": 1800 - } -} From f88f25a6705925fdf805548c3db0a562da72a4f9 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 12:13:53 +0200 Subject: [PATCH 6/6] fix(broker): fail fast when a persona worker exits before harness-ready; repair installed persona skill paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workforce CLI that dies during setup (e.g. a failed skill install calls process.exit) previously burned the entire 120s PERSONA_HARNESS_READY_TIMEOUT_MS before surfacing an error. The harness-ready wait now rejects as soon as the worker is gone: it watches agent_exit/agent_exited/agent_released events and runs a 2s listAgents liveness poll as a catch-all for exits that slip past the event stream (e.g. between spawn and subscription). Also repairs the installed linear-dispatcher and repo-router personas, which carried raw `./skills/*.md` sources from an installer version without skill-asset support. The cp for those paths resolved against the project root, failed, aborted every spawn, and kept the workforce skill cache marker from being written — forcing a full prpm re-download on each attempt. Skill files now live under __assets//skills/ with sources rewritten to the convention the fixed installer emits (AgentWorkforce/workforce#226). Do not re-run personas:refresh until a CLI with that fix is published and the agentworkforce dep is bumped — npx prefers the local 3.0.52 binary, which would regenerate the broken JSONs. Co-Authored-By: Claude Fable 5 --- .../skills/persona-relayfile-mount.md | 87 ++++++ .../skills/agentworkforce-repo-map.md | 271 ++++++++++++++++++ .../skills/persona-relayfile-mount.md | 87 ++++++ .../workforce/personas/linear-dispatcher.json | 5 +- .../workforce/personas/repo-router.json | 4 +- src/main/broker.ts | 45 +++ 6 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 .agentworkforce/workforce/personas/__assets/linear-dispatcher/skills/persona-relayfile-mount.md create mode 100644 .agentworkforce/workforce/personas/__assets/repo-router/skills/agentworkforce-repo-map.md create mode 100644 .agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md diff --git a/.agentworkforce/workforce/personas/__assets/linear-dispatcher/skills/persona-relayfile-mount.md b/.agentworkforce/workforce/personas/__assets/linear-dispatcher/skills/persona-relayfile-mount.md new file mode 100644 index 00000000..7f3cfb70 --- /dev/null +++ b/.agentworkforce/workforce/personas/__assets/linear-dispatcher/skills/persona-relayfile-mount.md @@ -0,0 +1,87 @@ +--- +name: persona-relayfile-mount +description: Use when authoring an AgentWorkforce persona's `mount` field — covers when to use mount, the gitignore allow-list idiom and its non-obvious `!web` vs `!web/` walker bug, `readonlyPatterns` scope rules, the per-agent dotfile overlay, and `.git` sandbox behavior +--- + +# Relayfile mount policy for AgentWorkforce personas + +You are advising an author writing the `mount` field on an AgentWorkforce persona spec. Use `mount` when the persona should run in a filesystem sandbox: it should see only some files, treat others as read-only, or both. Omit `mount` when the persona is fine running in-place with full project access (the default for most personas). + +## Shape + +```json +"mount": { + "ignoredPatterns": ["..."], + "readonlyPatterns": ["..."] +} +``` + +Both arrays use gitignore-style globs (powered by the `ignore` library). + +- `ignoredPatterns` — matches are omitted from the mount entirely; the agent cannot see or write them. +- `readonlyPatterns` — matches are copied into the mount but `chmod 444`; agent edits to these paths never sync back to the project. + +## Mount vs permissions + +Mount gates *files*. `permissions` gates *tools*. They compose — a persona that shouldn't see `.env` AND shouldn't shell out needs both. + +## Per-agent dotfile overlays + +The persona's `id` is passed to relayfile as `agentName`. At launch, relayfile also loads: + +- `.{id}.agentignore` +- `.{id}.agentreadonly` + +from the project root, appending their contents to the persona spec's patterns. Persona authors control the in-spec baseline; deployers can layer per-agent dotfiles on top without editing the persona JSON. + +## Allow-list idiom (agent only sees one subtree) + +Use gitignore negation, NOT a broad `**` exclude. + +**Correct:** + +```json +"ignoredPatterns": ["/*", "!web", "!web/**", "secrets/", ".env"] +``` + +**Four rules to avoid bricking the mount:** + +1. **Use `/*` as the broad-exclude, never `**`.** Gitignore semantics skip excluded parent directories entirely. Once `**` excludes a directory, a later `!dir/**` cannot bring its contents back — the dir was never walked. `/*` only excludes root-level entries, so subdirs of allowed paths stay visible. +2. **Negate the directory with `!web`, NOT `!web/`.** This one is non-obvious and breaks every "obvious" allow-list. The relayfile walker calls the `ignore` library twice per directory entry — once with the bare name (`web`) and once with the trailing-slash form (`web/`) — and OR's the results. The bare-name check fires first, and `/*` matches `web` regardless of type. A trailing-slash negation `!web/` only counters the trailing-slash form, so the bare-name check still returns "ignored" and the walker skips the directory. `!web` (no slash) negates BOTH forms, which is what you need. **If your allow-listed subtree isn't appearing in the mount, this is almost always the bug.** +3. **Include BOTH the directory and its contents.** `!web` re-includes the directory node so the walker recurses into it; `!web/**` re-includes everything inside it. You need both. +4. **No `./` prefixes.** The `ignore` library expects bare relative patterns; `./web/**` is treated as a literal path that almost never matches. + +**Wrong (looks right, silently empty mount):** + +```json +"ignoredPatterns": ["/*", "!web/", "!web/**"] +``` + +**Right:** + +```json +"ignoredPatterns": ["/*", "!web", "!web/**"] +``` + +## readonlyPatterns scope + +`readonlyPatterns` is for paths the agent should READ but never MODIFY — lockfiles, vendor code, configs the persona references but shouldn't touch. + +**Never include the persona's primary work directory.** If you do, the agent's writes to that directory are silently filtered out on sync-back. The persona looks like it ran successfully but produced zero project-side changes — one of the worst failure modes because nothing complains. + +Example: a blog-writer persona whose job is to author files under `web/content/blog/` must NOT have `web/**` in `readonlyPatterns`. + +## Git inside the mount + +The mount auto-includes `.git`, so `git status`/`log`/`diff` work inside the sandbox. Sync rules: + +- Project-side changes under `.git/**` flow INTO the mount (e.g. teammate moves HEAD while the agent runs). +- Mount-side changes under `.git/**` are NOT synced back. Branches, commits, and refs the agent creates in the mount stay sandboxed and are discarded on cleanup unless the agent pushes to a remote. + +## Pre-handoff checklist + +- [ ] Does the persona actually need a sandbox? If full project access is fine, omit `mount` entirely. +- [ ] For allow-lists: `/*` (not `**`) plus paired `!dir` (NO trailing slash) and `!dir/**` for each allowed path? +- [ ] Is the persona's *work* directory NOT in `readonlyPatterns`? +- [ ] Are secrets, `.env`, and any private dirs in `ignoredPatterns`? +- [ ] Does `permissions` cover the tool-side scope (Bash, file edits, MCP) the persona should and shouldn't have? diff --git a/.agentworkforce/workforce/personas/__assets/repo-router/skills/agentworkforce-repo-map.md b/.agentworkforce/workforce/personas/__assets/repo-router/skills/agentworkforce-repo-map.md new file mode 100644 index 00000000..262fa5de --- /dev/null +++ b/.agentworkforce/workforce/personas/__assets/repo-router/skills/agentworkforce-repo-map.md @@ -0,0 +1,271 @@ +--- +name: agentworkforce-repo-map +description: Canonical map of all AgentWorkforce GitHub org repos, their roles, responsibilities, and key signals. Read this before routing any issue or deciding which repo to open a PR in. Use as a seed index — always verify against the live org with `gh repo list AgentWorkforce` since new repos are created frequently. +--- + +# AgentWorkforce Repo Map + +This skill is the canonical reference for which repo owns what in the AgentWorkforce GitHub organization. Read it before routing an issue, spawning an agent, or opening a PR. + +## How to verify against the live org + +```bash +# List all repos with descriptions +gh repo list AgentWorkforce --limit 100 --json name,description + +# Read a specific repo's README +gh api repos/AgentWorkforce/{repo}/contents/README.md --jq '.content' | base64 -d + +# Read package.json +gh api repos/AgentWorkforce/{repo}/contents/package.json --jq '.content' | base64 -d + +# List top-level directory structure +gh api repos/AgentWorkforce/{repo}/contents --jq '[.[] | {name, type}]' +``` + +## Core repos + +### `pear` +**Role**: Desktop application — the primary user-facing product. +**Stack**: Electron, TypeScript, React, relayfile mount client. +**Owns**: Claude Code session UI, renderer process, PTY I/O, relayfile mount integration, integration event rendering (Slack/Linear/GitHub panels), Pear app settings, local persona installation. +**Key signals in issues**: "pear", "desktop", "renderer", "mount", "PTY", "session", "UI", "Electron", "integration panel", "local", "writeback path", "integration-remote-paths". + +### `cloud` +**Role**: Backend — Cloudflare Workers API and integration orchestration. +**Stack**: Cloudflare Workers, TypeScript, D1, KV, Durable Objects, Nango, Hono. +**Owns**: REST API, Slack/Linear/GitHub webhook consumers, Nango connection management, picker options, worker dispatch, relay worker identity, conversation sandbox, proactive agent triggers. +**Key signals in issues**: "cloud", "API", "worker", "Slack", "Linear", "webhook", "Nango", "cf-", "KV", "D1", "Durable Object", "picker", "integration event", "cloud worker". + +### `agents` +**Role**: Autonomous agent harnesses deployed as persistent workers. +**Stack**: TypeScript, agent-relay SDK, relayfile. +**Owns**: pr-reviewer agent, merge-on-green logic, agent harness framework, agents deployed as long-running relay workers. +**Key signals in issues**: "pr-reviewer", "merge-on-green", "agent harness", "autonomous agent", "review bot", "persistent agent", "relay worker". + +### `workforce` +**Role**: Tooling for building, installing, and running agent personas. +**Stack**: TypeScript, Node.js monorepo (pnpm workspaces). +**Owns**: persona packages (`@agentworkforce/persona-*`), CLI (`agentworkforce install`), runtime, deploy tooling, workload router, persona-kit, harness-kit. +**Key signals in issues**: "persona", "CLI", "runtime", "deploy", "workforce", "install", "agentworkforce", "persona-kit", "harness", "workload router". + +### `agent-assistant` +**Role**: SDK for building production-grade assistants from explicit runtime primitives. +**Stack**: TypeScript monorepo, npm workspaces. +**Owns**: `@agent-assistant/*` packages: traits, turn-context, harness, continuation, inbox, memory, sessions, surfaces, policy, proactive behavior, routing, connectivity, coordination, VFS, examples, Cloudflare/webhook runtimes. +**Key signals in issues**: "agent-assistant", "@agent-assistant", "assistant SDK", "traits", "turn-context", "bounded turn", "continuation", "inbox", "assistant memory", "surfaces", "policy", "coordination", "connectivity", "assistant vfs". + +### `relayfile` +**Role**: Integration filesystem for agents: queue-first VFS-over-REST, local mount, SDK, CLI, file observer, schemas, and Go services. +**Stack**: TypeScript packages, Go services, local mount/FUSE, Cloudflare-facing SDK surfaces. +**Owns**: relayfile core server/runtime, `@relayfile/sdk`, `relayfile` CLI, local mount behavior, VFS layout, ACLs, file observer, writeback queue semantics, sync/eval harnesses, schemas. +**Key signals in issues**: "relayfile", "VFS", "virtual filesystem", "mount layout", "LAYOUT.md", "local-mount", "ACL", "file observer", "writeback queue", "dead-letter", "replay", "sync", "RelayFileClient", "@relayfile/sdk". + +### `relayfile-adapters` +**Role**: TypeScript packages that project external providers into relayfile trees and implement provider-specific writeback. +**Stack**: TypeScript, Cloudflare Workers. +**Owns**: provider adapters (Linear, Slack, GitHub, Notion), resource path shapes, provider event emission, provider-specific writeback translation, adapter publish/deploy pipeline. +**Key signals in issues**: "adapter", "provider adapter", "Linear adapter", "Slack adapter", "GitHub adapter", "Notion adapter", "provider sync", "writeback protocol", "resource shape", "relayfile-adapter", "@relayfile/". + +### `relayfile-providers` +**Role**: Auth and credential-management providers for relayfile integrations. +**Stack**: TypeScript integration/provider packages. +**Owns**: provider auth plumbing, credential exchange, hosted provider connectors, Nango/Composio/Pipedream-style provider boundaries used by relayfile. +**Key signals in issues**: "relayfile provider", "provider auth", "credential provider", "Nango", "Composio", "Pipedream", "connection token", "integration credentials". + +### `burn` +**Role**: Token/cost observability for agent CLI sessions. +**Stack**: TypeScript, Rust/N-API, pnpm monorepo. +**Owns**: `relayburn` CLI, burn ledger, cost ingestion for Claude Code/Codex/OpenCode logs, pricing updates, hotspots, overhead attribution, model comparison, MCP server for cost queries. +**Key signals in issues**: "burn", "relayburn", "token usage", "cost", "hotspots", "overhead", "CLAUDE.md cost", "AGENTS.md cost", "pricing", "ledger", "usage limits", "mcp-server cost". + +### `relay` +**Role**: The core Agent Relay product — server, broker, SDK, protocol. +**Stack**: Rust/TypeScript web and package surfaces. +**Owns**: relay server, broker, `@agent-relay/sdk`, relay protocol, agent registration, message routing, workspace management, core messaging substrate. +**Key signals in issues**: "relay", "broker", "SDK", "@agent-relay/sdk", "relay server", "subscription", "workspace", "agent registration", "message routing", "channel", "DM", "thread". + +### `skills` +**Role**: Skill packs consumed by agent personas at runtime. +**Stack**: Markdown (SKILL.md files), PRPM package manager. +**Owns**: `@agent-relay/` and `@agent-workforce/` skill namespaces, skill authoring tooling, skill discovery. +**Key signals in issues**: "skill", "SKILL.md", "skill pack", "@agent-relay/skill", "@agent-workforce/skill", "skills repo". + +## Product and workflow repos + +### `relaycast` +**Role**: Headless Slack-style collaboration product for AI agents. +**Owns**: Relaycast workspace UX/API, agent chat surfaces, real-time collaboration patterns that are product-specific rather than core Relay protocol. +**Key signals in issues**: "relaycast", "headless Slack", "agent workspace", "agent chat product", "real-time agent collaboration". + +### `relayauth` +**Role**: Authorization and capability constraints for agents. +**Owns**: permission/authority model, scoped access, action authorization, capability boundaries. +**Key signals in issues**: "relayauth", "authorization", "capability", "permissions", "least privilege", "agent can do exactly". + +### `relayflows` +**Role**: Multi-step, multi-agent workflow orchestration across Agent Relay workers. +**Owns**: workflow graph/runtime concepts, orchestration recipes, worker coordination flows. +**Key signals in issues**: "relayflows", "workflow orchestration", "multi-step", "multi-agent execution", "workflow runner". + +### `ricky` +**Role**: Workflow reliability agent and workflow runner for AgentWorkforce. +**Owns**: workflow reliability, runner behavior, workflow execution monitoring. +**Key signals in issues**: "ricky", "workflow reliability", "workflow runner", "runner failed", "workflow monitor". + +### `nightcto` +**Role**: Overnight CTO-style autonomous engineering/product agent. +**Owns**: NightCTO persona/product behavior, overnight planning/execution workflow, CTO automation. +**Key signals in issues**: "nightcto", "night CTO", "overnight", "CTO agent", "autonomous CTO". + +### `sage` +**Role**: Research, clarification, and planning agent that turns conversations into executable workflows. +**Owns**: planning/research workflow, requirements clarification, conversation-to-plan behavior. +**Key signals in issues**: "sage", "research", "clarify", "planning workflow", "requirements", "executable development workflow". + +### `proactive-agents` +**Role**: Marketing/blog site for proactive agent concepts. +**Owns**: proactive-agents website content and messaging. +**Key signals in issues**: "proactive-agents site", "marketing site", "blog", "proactive agents content". + +### `proactive` +**Role**: Tools to build proactive agents. +**Owns**: proactive-agent primitives and tooling outside the marketing site. +**Key signals in issues**: "proactive", "follow-up", "watch rule", "scheduler", "proactive agent tooling". + +### `internal-agents` +**Role**: Internal AgentWorkforce agent implementations. +**Owns**: private/internal agents not packaged as public personas. +**Key signals in issues**: "internal agent", "internal-agents", "private agent", "ops agent". + +### `workflows` +**Role**: Repeatable Agent Relay workflows and repo-hygiene cookbooks. +**Owns**: workflow templates, cookbook docs, repo hygiene automation patterns. +**Key signals in issues**: "workflow cookbook", "repo hygiene", "workflows repo", "repeatable workflow". + +### `agent-relay-personas` +**Role**: Workforce personas that leverage Agent Relay. +**Owns**: Agent Relay-specific persona packs outside this `workforce` monorepo. +**Key signals in issues**: "agent-relay-personas", "relay persona", "Agent Relay persona pack". + +### `agent-relay-acp-bridge` +**Role**: ACP bridge for Agent Relay. +**Owns**: Agent Client Protocol bridge integration with Agent Relay. +**Key signals in issues**: "ACP", "agent client protocol", "ACP bridge", "agent-relay-acp-bridge". + +### `agent-relay-openclaw` +**Role**: Agent Relay bridge for OpenClaw. +**Owns**: OpenClaw integration, real-time channels/threads/DMs beyond built-in OpenClaw surfaces. +**Key signals in issues**: "openclaw", "agent-relay-openclaw", "OpenClaw bridge". + +### `agent-relay-vscode` +**Role**: VS Code extension for Agent Relay. +**Owns**: VS Code integration, editor extension commands/views for Relay. +**Key signals in issues**: "VS Code", "vscode", "extension", "agent-relay-vscode". + +### `relay-dashboard` +**Role**: Dashboard for agents collaborating through Agent Relay. +**Owns**: dashboard UI, collaboration visualization/product surfaces. +**Key signals in issues**: "dashboard", "relay-dashboard", "collaboration dashboard". + +### `relay-visualizer`, `relay-pulse`, `relay-pty-visualizer` +**Role**: Visual explainers and interactive protocol/PTY visualizations. +**Owns**: visualization UIs, animated protocol demos, PTY pipeline explainers. +**Key signals in issues**: "visualizer", "relay-pulse", "relay-pty", "animated explainer", "protocol visualization". + +### `ai-hist` +**Role**: Sync and search Claude Code/Codex CLI conversation history in SQLite. +**Owns**: conversation-history ingestion/search, local SQLite indexes. +**Key signals in issues**: "ai-hist", "conversation history", "history search", "SQLite", "Claude Code history", "Codex history". + +### `trajectories` +**Role**: Document layer for agent work and traces. +**Owns**: trajectory document format and generated/documented agent work artifacts. +**Key signals in issues**: "trajectory", "trajectories", "agent trace document", "work trace". + +### `agent-trace` +**Role**: Standard trace format for AI-generated code. +**Owns**: trace schema/format for AI coding outputs. +**Key signals in issues**: "agent-trace", "trace format", "AI-generated code trace". + +### `build-plans` +**Role**: Collection of build plans using the Agent Relay SDK. +**Owns**: planning docs/examples for Relay SDK-based builds. +**Key signals in issues**: "build plan", "build-plans", "Relay SDK plan". + +### `demos`, `relay-examples`, `simple-swarm`, `swarm-patterns`, `tic-tac-toe`, `relay-cross-framework-demo` +**Role**: Demos, examples, and proof points for Agent Relay/swarm patterns. +**Owns**: example apps, demo scenarios, swarm visualizations, cross-framework demos, sample games. +**Key signals in issues**: "demo", "example", "simple swarm", "swarm pattern", "tic-tac-toe", "cross framework", "LangGraph", "CrewAI". + +### `relay-cloud`, `relayfile-cloud`, `relayfile-cli` +**Role**: Split-out cloud/CLI package repos for Relay and Relayfile surfaces. +**Owns**: hosted cloud code or dedicated CLI surfaces when an issue names these repos explicitly. +**Key signals in issues**: "relay-cloud", "relayfile-cloud", "relayfile-cli", "hosted relayfile", "cloud code split". + +## Routing rules + +| Issue surface | Primary repo | Secondary repo (if applicable) | +|---|---|---| +| Desktop UI, session rendering, local mount | `pear` | — | +| Slack/Linear webhook, API endpoint, Nango | `cloud` | — | +| PR reviewer, merge automation, agent harness | `agents` | — | +| Assistant SDK/runtime primitives | `agent-assistant` | — | +| Persona authoring, CLI, runtime deploy | `workforce` | — | +| Relayfile core VFS, SDK, CLI, local mount, ACLs | `relayfile` | — | +| Relay protocol, broker, SDK | `relay` | — | +| Provider-specific relayfile trees/writeback | `relayfile-adapters` | — | +| Relayfile provider auth/credentials | `relayfile-providers` | `cloud` if hosted API/Nango wiring changes too | +| Token/cost observability | `burn` | — | +| Skill content | `skills` | — | +| Slack feature end-to-end | `cloud` + `pear` | cloud = webhook/dispatch, pear = mount/render | +| New integration (e.g. Jira) | `relayfile-adapters` + `relayfile-providers` + `cloud` | adapter = sync/writeback, provider = auth, cloud = hosted connection | +| New persona | `workforce` | — | +| Agent workflow orchestration | `relayflows` | `workforce` if packaged as persona/runtime | +| Agent Relay examples/demos | demo repo named in issue | `relay` if core SDK/protocol bug | + +## Multi-repo issues + +When an issue spans multiple repos, spawn one implementer per repo in parallel. Common patterns: +- **New Slack integration feature**: `cloud` (webhook + API) + `pear` (mount rendering) +- **New external provider**: `relayfile-adapters` (resource tree/writeback) + `relayfile-providers` (auth/credential provider) + `cloud` (hosted connection setup) +- **Agent behavior + UI**: `agents` (harness logic) + `pear` (rendering the agent's output) +- **Assistant runtime + product integration**: `agent-assistant` (runtime primitive) + product repo such as `cloud`, `pear`, or `relaycast` +- **Relayfile mount bug with provider symptoms**: `relayfile` (core VFS/local mount) + `relayfile-adapters` (provider path/writeback) after reproducing which layer fails + +## Disambiguation + +If an issue is ambiguous, use `gh api repos/AgentWorkforce/{repo}/contents` to check directory structure of candidate repos before deciding. For example: if an issue mentions "writeback" it could be `pear` (path construction) or `relayfile-adapters` (sync protocol) — check which layer the bug is in. + +## Compact org index + +Use this as a candidate list before falling back to live `gh repo list`. Several repos are products, demos, prototypes, or split-out package surfaces; route to them only when the issue names them directly or their ownership signals match. + +| Repo | Signal | +|---|---| +| `awp` | Agent Workforce Protocol | +| `axiom` | Intent preservation for AI coding agents | +| `c2a` | Chat 2 Agents Protocol | +| `ceo`, `cfo`, `cmo`, `sales`, `supportly`, `rachel`, `openkaren`, `openviktor` | role/persona/product experiments; route only when named | +| `clawrunner` | OpenClaw deployment sandbox | +| `code-storybook` | AI-narrated code storybook | +| `credential-proxy` | credential proxy surface | +| `gitclawlab` | hosting/deploying code for AI agents | +| `kairo` | Mac calendar connected to agents | +| `limit` | terminal dashboard for Claude Code/Codex usage limits | +| `megaclaude` | Claude Code wrapper/variant | +| `moltslack`, `transport` | headless Slack / agent messaging experiments | +| `n8n-nodes-relayfile`, `relaycast-n8n-bridge` | n8n integration packages | +| `open-world`, `open-world-game`, `soccer`, `scout` | game/demo/prototype repos | +| `planner` | web UI for planning tasks with agents | +| `profiles` | identity files for agents | +| `relay-broker`, `relay-sdk`, `relay-tui` | split-out Relay broker/SDK/TUI surfaces; verify whether current work belongs in `relay` first | +| `relay-poc-*` | Relay Communicate SDK proof-of-concept apps | +| `relaycron` | scheduled work for agents | +| `relayed` | relay daemon/runtime surface | +| `relayrank` | ranking/evaluation surface | +| `sloppypaste` | "Stop dumping slop" product/tool | +| `superset` | IDE for running many agent CLIs | +| `vscode-acp` | ACP support for VS Code | +| `wash` | cleanup tool/product | +| `watchdog-agents` | Watchdog agents deployed on Agent Relay | diff --git a/.agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md b/.agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md new file mode 100644 index 00000000..7f3cfb70 --- /dev/null +++ b/.agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md @@ -0,0 +1,87 @@ +--- +name: persona-relayfile-mount +description: Use when authoring an AgentWorkforce persona's `mount` field — covers when to use mount, the gitignore allow-list idiom and its non-obvious `!web` vs `!web/` walker bug, `readonlyPatterns` scope rules, the per-agent dotfile overlay, and `.git` sandbox behavior +--- + +# Relayfile mount policy for AgentWorkforce personas + +You are advising an author writing the `mount` field on an AgentWorkforce persona spec. Use `mount` when the persona should run in a filesystem sandbox: it should see only some files, treat others as read-only, or both. Omit `mount` when the persona is fine running in-place with full project access (the default for most personas). + +## Shape + +```json +"mount": { + "ignoredPatterns": ["..."], + "readonlyPatterns": ["..."] +} +``` + +Both arrays use gitignore-style globs (powered by the `ignore` library). + +- `ignoredPatterns` — matches are omitted from the mount entirely; the agent cannot see or write them. +- `readonlyPatterns` — matches are copied into the mount but `chmod 444`; agent edits to these paths never sync back to the project. + +## Mount vs permissions + +Mount gates *files*. `permissions` gates *tools*. They compose — a persona that shouldn't see `.env` AND shouldn't shell out needs both. + +## Per-agent dotfile overlays + +The persona's `id` is passed to relayfile as `agentName`. At launch, relayfile also loads: + +- `.{id}.agentignore` +- `.{id}.agentreadonly` + +from the project root, appending their contents to the persona spec's patterns. Persona authors control the in-spec baseline; deployers can layer per-agent dotfiles on top without editing the persona JSON. + +## Allow-list idiom (agent only sees one subtree) + +Use gitignore negation, NOT a broad `**` exclude. + +**Correct:** + +```json +"ignoredPatterns": ["/*", "!web", "!web/**", "secrets/", ".env"] +``` + +**Four rules to avoid bricking the mount:** + +1. **Use `/*` as the broad-exclude, never `**`.** Gitignore semantics skip excluded parent directories entirely. Once `**` excludes a directory, a later `!dir/**` cannot bring its contents back — the dir was never walked. `/*` only excludes root-level entries, so subdirs of allowed paths stay visible. +2. **Negate the directory with `!web`, NOT `!web/`.** This one is non-obvious and breaks every "obvious" allow-list. The relayfile walker calls the `ignore` library twice per directory entry — once with the bare name (`web`) and once with the trailing-slash form (`web/`) — and OR's the results. The bare-name check fires first, and `/*` matches `web` regardless of type. A trailing-slash negation `!web/` only counters the trailing-slash form, so the bare-name check still returns "ignored" and the walker skips the directory. `!web` (no slash) negates BOTH forms, which is what you need. **If your allow-listed subtree isn't appearing in the mount, this is almost always the bug.** +3. **Include BOTH the directory and its contents.** `!web` re-includes the directory node so the walker recurses into it; `!web/**` re-includes everything inside it. You need both. +4. **No `./` prefixes.** The `ignore` library expects bare relative patterns; `./web/**` is treated as a literal path that almost never matches. + +**Wrong (looks right, silently empty mount):** + +```json +"ignoredPatterns": ["/*", "!web/", "!web/**"] +``` + +**Right:** + +```json +"ignoredPatterns": ["/*", "!web", "!web/**"] +``` + +## readonlyPatterns scope + +`readonlyPatterns` is for paths the agent should READ but never MODIFY — lockfiles, vendor code, configs the persona references but shouldn't touch. + +**Never include the persona's primary work directory.** If you do, the agent's writes to that directory are silently filtered out on sync-back. The persona looks like it ran successfully but produced zero project-side changes — one of the worst failure modes because nothing complains. + +Example: a blog-writer persona whose job is to author files under `web/content/blog/` must NOT have `web/**` in `readonlyPatterns`. + +## Git inside the mount + +The mount auto-includes `.git`, so `git status`/`log`/`diff` work inside the sandbox. Sync rules: + +- Project-side changes under `.git/**` flow INTO the mount (e.g. teammate moves HEAD while the agent runs). +- Mount-side changes under `.git/**` are NOT synced back. Branches, commits, and refs the agent creates in the mount stay sandboxed and are discarded on cleanup unless the agent pushes to a remote. + +## Pre-handoff checklist + +- [ ] Does the persona actually need a sandbox? If full project access is fine, omit `mount` entirely. +- [ ] For allow-lists: `/*` (not `**`) plus paired `!dir` (NO trailing slash) and `!dir/**` for each allowed path? +- [ ] Is the persona's *work* directory NOT in `readonlyPatterns`? +- [ ] Are secrets, `.env`, and any private dirs in `ignoredPatterns`? +- [ ] Does `permissions` cover the tool-side scope (Bash, file edits, MCP) the persona should and shouldn't have? diff --git a/.agentworkforce/workforce/personas/linear-dispatcher.json b/.agentworkforce/workforce/personas/linear-dispatcher.json index db8fd99c..f993be38 100644 --- a/.agentworkforce/workforce/personas/linear-dispatcher.json +++ b/.agentworkforce/workforce/personas/linear-dispatcher.json @@ -34,13 +34,16 @@ }, { "id": "persona-relayfile-mount", - "source": "./skills/persona-relayfile-mount.md", + "source": ".agentworkforce/workforce/personas/__assets/linear-dispatcher/skills/persona-relayfile-mount.md", "description": "Local mount-field policy shipped with this persona pack. Read before relying on the relayfile mount: allow-list idiom, readonlyPatterns scope, and how this persona sees the .integrations tree and writes back into it." } ], "harness": "claude", "model": "claude-sonnet-4-6", "systemPrompt": "$TASK_DESCRIPTION", + "permissions": { + "mode": "bypassPermissions" + }, "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 600 diff --git a/.agentworkforce/workforce/personas/repo-router.json b/.agentworkforce/workforce/personas/repo-router.json index 532c16c1..8b903c67 100644 --- a/.agentworkforce/workforce/personas/repo-router.json +++ b/.agentworkforce/workforce/personas/repo-router.json @@ -14,7 +14,7 @@ "skills": [ { "id": "agentworkforce-repo-map", - "source": "./skills/agentworkforce-repo-map.md", + "source": ".agentworkforce/workforce/personas/__assets/repo-router/skills/agentworkforce-repo-map.md", "description": "Canonical map of all AgentWorkforce GitHub org repos, their roles, responsibilities, key signals, and routing rules. Read this first before routing any issue. Use gh repo list + gh api to verify against the live org." }, { @@ -29,7 +29,7 @@ }, { "id": "persona-relayfile-mount", - "source": "./skills/persona-relayfile-mount.md", + "source": ".agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md", "description": "Local mount-field policy shipped with this persona pack. Read before relying on the relayfile mount for issue context." } ], diff --git a/src/main/broker.ts b/src/main/broker.ts index f493396e..84d9d5ad 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -585,6 +585,11 @@ const PERSONA_REGISTRATION_TIMEOUT_MS = 5_000 const PERSONA_REGISTRATION_STABILITY_MS = 1_000 const PERSONA_HARNESS_READY_TIMEOUT_MS = 120_000 const PERSONA_READY_PROBE_TIMEOUT_MS = 5_000 +// How often the harness-ready wait re-checks that the persona's worker is +// still registered. A workforce CLI that dies during setup (e.g. a failed +// skill install calls process.exit) would otherwise burn the entire +// PERSONA_HARNESS_READY_TIMEOUT_MS before surfacing an error. +const PERSONA_LIVENESS_POLL_MS = 2_000 const AGENTWORKFORCE_CLI_VERSION = '3.0.51' function delay(ms: number): Promise { @@ -632,6 +637,15 @@ function isWorkerStreamForAgent(event: BrokerEvent, name: string): boolean { return brokerEventString(event, 'kind') === 'worker_stream' && brokerEventString(event, 'name') === name } +const AGENT_EXIT_EVENT_KINDS = ['agent_exit', 'agent_exited', 'agent_released'] + +function isAgentExitEventForAgent(event: BrokerEvent, name: string): boolean { + return ( + AGENT_EXIT_EVENT_KINDS.includes(brokerEventString(event, 'kind') || '') && + brokerEventString(event, 'name') === name + ) +} + function brokerEventChunk(event: BrokerEvent): string { const value = (event as unknown as Record).chunk return typeof value === 'string' ? value : '' @@ -2912,6 +2926,7 @@ export class BrokerManager { ): Promise { let output = '' let timer: ReturnType | undefined + let livenessTimer: ReturnType | undefined let settled = false let resolveReady: (() => void) | undefined let rejectReady: ((error: Error) => void) | undefined @@ -2920,6 +2935,7 @@ export class BrokerManager { if (settled) return settled = true if (timer) clearTimeout(timer) + if (livenessTimer) clearInterval(livenessTimer) if (error) { rejectReady?.(error) } else { @@ -2927,7 +2943,16 @@ export class BrokerManager { } } + const exitedError = (): Error => + new Error( + `Workforce persona ${personaId} process exited before its harness became ready. Last output:\n${tailText(output) || '(no output)'}` + ) + const observe = (event: BrokerEvent): void => { + if (isAgentExitEventForAgent(event, name)) { + finish(exitedError()) + return + } if (!isWorkerStreamForAgent(event, name)) return output += brokerEventChunk(event) const failure = personaHarnessFailedFromOutput(output) @@ -2957,6 +2982,25 @@ export class BrokerManager { const unsubscribe = session.client.onEvent((event) => { observe(event) }) + // Belt-and-suspenders for exits the event stream can miss (e.g. the + // worker died between spawn and this subscription): poll registration + // and fail fast when the agent is gone instead of waiting out the full + // readiness timeout. agent_exit handling releases dead workers, so a + // missing name in listAgents means the CLI process is gone. + livenessTimer = setInterval(() => { + void session.client + .listAgents() + .then((agents) => { + if (settled) return + if (!agents.some((agent) => agent.name === name)) { + finish(exitedError()) + } + }) + .catch(() => { + // Transient broker errors are fine — the readiness timeout still + // bounds the wait. + }) + }, PERSONA_LIVENESS_POLL_MS) try { const events = this.workerStreamEvents(session, name, 1000) @@ -2969,6 +3013,7 @@ export class BrokerManager { await readyPromise } finally { if (timer) clearTimeout(timer) + if (livenessTimer) clearInterval(livenessTimer) unsubscribe() } }