From 1d396d22878ad10edaf231e635e10458d0340d57 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 07:11:16 +0200 Subject: [PATCH 1/8] =?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/8] 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/8] 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/8] 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/8] 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 cbb6264e0fd8927980286588490d57a4bcf15321 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 11:58:24 +0200 Subject: [PATCH 6/8] fix(terminal): reconcile renderer screen with the broker's authoritative PTY emulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds 1-5 of rendering hardening each closed one corruption *creation* vector, but the class has a nastier property: diff-painting TUIs (Claude Code) skip cells they believe unchanged, so a single grid divergence (e.g. an xterm reflow scroll during a width resize that PTY-side emulators don't perform) is preserved and amplified by every subsequent repaint — stacked prompt panels, stale glyphs bleeding through the spaces of new rows. The broker daemon already maintains the authoritative screen (the attach snapshot / dump-pty emulation, which renders the same byte stream correctly). This adds the missing convergence mechanism: a quiet-time reconciler that compares the xterm viewport against the broker's plain snapshot and repaints from the self-framing ansi snapshot on confirmed divergence. - broker.snapshotTerminal: side-effect-free snapshot read (no input-stream reset, no delivery-mode writes), wedge-recovery wrapped like getPending, degrades to null on every failure. - terminal-reconciler.ts: gating invariants — quiet window, visible window (hidden windows stall the rAF chunk flush), no outstanding predictions, activity-serial recheck across fetches plus a forced staged-chunk flush before the write (a chunk racing the fetch may already be inside the snapshot), exact dimension match, confirm-on-two-checks, repair rate limit. Repairs route through the echo router so they order behind queued engine writes and repair the engine model too. - Repair telemetry: '[terminal] viewport diverged from broker screen' — a firing reconciler is the tripwire that a new creation vector exists. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 4 + src/main/broker.ts | 46 ++++ src/main/ipc-handlers.ts | 4 + src/preload/index.ts | 4 + src/renderer/src/lib/ipc-mock.ts | 3 + .../src/lib/terminal-reconciler.test.ts | 226 ++++++++++++++++++ src/renderer/src/lib/terminal-reconciler.ts | 169 +++++++++++++ .../src/lib/terminal-runtime-registry.ts | 43 ++++ src/shared/types/ipc.ts | 15 ++ 9 files changed, 514 insertions(+) create mode 100644 src/renderer/src/lib/terminal-reconciler.test.ts create mode 100644 src/renderer/src/lib/terminal-reconciler.ts diff --git a/AGENTS.md b/AGENTS.md index d21e150e..1db8bc4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,3 +12,7 @@ Pear broker work must treat duplicate delivery as a normal failure mode. Rendere - Do not post integration or launch metadata on reused broker sessions. Notify agents only after a real broker start, reconnect, or state transition, and make repeated payloads no-ops when possible. - Add regression tests when touching broker start, event streaming, PTY buffering, spawned personas, or integration notifications. Include duplicate/replay cases, not just the happy path. - Add low-noise telemetry for suppressed duplicates and missing event identity so replay issues are visible without flooding the terminal. + +## Terminal Screen Convergence + +The broker daemon's PTY emulation (served as attach snapshots, observable via `agent-relay-broker dump-pty`) is the ground truth for what a worker terminal shows. The renderer's xterm grid can diverge from it (e.g. xterm reflow-scrolls on width resizes; PTY-side emulators don't), and diff-painting TUIs like Claude Code then *preserve* the divergence forever — they skip cells they believe unchanged, so stale glyphs bleed through new rows and stacked repaint frames accumulate. `src/renderer/src/lib/terminal-reconciler.ts` closes the loop: when a terminal is quiet it compares the viewport to the broker's `plain` snapshot and repaints from the self-framing `ansi` snapshot on confirmed divergence. Do not remove it after fixing any individual corruption vector — it is the convergence backstop for the whole class, and its gating invariants (quiet window, activity-serial recheck, dimension match, confirm-twice, rate limit) each guard a real re-corruption path documented in the module header. Repairs log `[terminal] viewport diverged from broker screen` — that line firing is the signal a new creation vector exists and is worth hunting. diff --git a/src/main/broker.ts b/src/main/broker.ts index f493396e..a7b8e874 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -48,6 +48,8 @@ import type { BrokerEventStreamDiagnostic, BrokerReconciledChatMessage, BrokerReconcileMessagesInput, + BrokerTerminalSnapshot, + BrokerTerminalSnapshotFormat, WorkforcePersona } from '../shared/types/ipc' import { @@ -3364,6 +3366,50 @@ export class BrokerManager { } } + // Side-effect-free read of the broker's authoritative PTY screen, used by + // the renderer's quiet-time terminal reconciler (terminal-reconciler.ts). + // Unlike attachTerminal this never resets input streams, never touches the + // delivery mode, and degrades to null on every failure — a skipped + // reconcile check is always safe, a thrown one floods the IPC log on a + // periodic poll. Polled read ⇒ wedge recovery, same as getPending. + async snapshotTerminal( + projectId: string | undefined, + name: string, + format: BrokerTerminalSnapshotFormat + ): Promise { + const trimmedName = name.trim() + if (!trimmedName) return null + let session: BrokerSession + try { + session = this.getSessionForAgent(trimmedName, projectId) + } catch { + return null + } + return this.withWedgeRecovery( + session, + 'snapshotTerminal', + null, + async (current) => { + try { + const snapshot = await current.client.snapshot(trimmedName, format) + return { + rows: snapshot.rows, + cols: snapshot.cols, + cursor: snapshot.cursor, + screen: + format === 'ansi' + ? Buffer.from(snapshot.screen, 'base64').toString('utf-8') + : snapshot.screen + } + } catch (err) { + if (isMissingAgentError(err)) return null + throw err + } + }, + { degradeOnTimeout: true } + ) + } + async sendMessage(projectId: string | undefined, input: SendMessageInput): Promise { const session = input.to.startsWith('#') ? this.getSessionForProject(projectId || '') diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index e465d0c5..da2eff02 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -338,6 +338,10 @@ export function registerIpcHandlers(): void { await brokerManager.resizePty(projectId, name, rows, cols) }) + ipcMain.handle('broker:snapshot-terminal', async (_, projectId: string | undefined, name: string, format: 'plain' | 'ansi') => { + return brokerManager.snapshotTerminal(projectId, name, format) + }) + ipcMain.handle('broker:input-srtt', (_, projectId: string | undefined, name: string) => { return brokerManager.getInputSrtt(projectId, name) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 1e3cfbc0..3f130d65 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,8 @@ import type { BrokerSpawnAgentInput, BrokerSpawnAgentResult, BrokerStatusEvent, + BrokerTerminalSnapshot, + BrokerTerminalSnapshotFormat, BurnAgentBreakdown, BurnAgentInput, BurnAgentSummary, @@ -258,6 +260,8 @@ const api = { invoke<{ flushed: number }>('broker:flush-pending', projectId, name), resizePty: (projectId: string | undefined, name: string, rows: number, cols: number) => invoke('broker:resize-pty', projectId, name, rows, cols), + snapshotTerminal: (projectId: string | undefined, name: string, format: BrokerTerminalSnapshotFormat) => + invoke('broker:snapshot-terminal', projectId, name, format), inputSrtt: (projectId: string | undefined, name: string) => invoke('broker:input-srtt', projectId, name), sendMessage: (projectId: string | undefined, input: BrokerSendMessageInput) => diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index bc5af514..ef6d2f2b 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -897,6 +897,9 @@ export const pearMock: PearAPI = { getPending: async (): Promise => [], flushPending: async () => ({ flushed: 0 }), resizePty: async () => undefined, + // No authoritative PTY emulation behind the mock: the reconciler treats + // null as "skip this check", so it stays dormant in web/harness builds. + snapshotTerminal: async () => null, inputSrtt: async () => mockInputSrttMs, sendMessage: async (projectId: string | undefined, input: BrokerSendMessageInput) => { handleInjectedBrokerEvent({ diff --git a/src/renderer/src/lib/terminal-reconciler.test.ts b/src/renderer/src/lib/terminal-reconciler.test.ts new file mode 100644 index 00000000..2b669d6d --- /dev/null +++ b/src/renderer/src/lib/terminal-reconciler.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createTerminalReconciler, + screensMatch, + RECONCILE_CONFIRM_CHECKS, + RECONCILE_MIN_REPAIR_GAP_MS, + type ReconcileSnapshot, + type ReconcileViewport, + type TerminalReconcilerDeps +} from './terminal-reconciler' + +interface Harness { + deps: TerminalReconcilerDeps + repairs: string[] + state: { + plain: ReconcileSnapshot | null + ansi: ReconcileSnapshot | null + viewport: ReconcileViewport | null + quiet: boolean + serial: number + now: number + flushBumpsSerial: boolean + onFetch?: (format: 'plain' | 'ansi') => void + } +} + +function makeHarness(): Harness { + const repairs: string[] = [] + const state: Harness['state'] = { + plain: { rows: 2, cols: 10, screen: 'row one\nrow two' }, + ansi: { rows: 2, cols: 10, screen: '\x1b[0m\x1b[H\x1b[2J\x1b[1;1Hrow one\x1b[2;1Hrow two' }, + viewport: { rows: 2, cols: 10, lines: ['row one', 'row two'] }, + quiet: true, + serial: 0, + now: 1_000_000, + flushBumpsSerial: false + } + const deps: TerminalReconcilerDeps = { + fetchSnapshot: async (format) => { + state.onFetch?.(format) + return format === 'plain' ? state.plain : state.ansi + }, + readViewport: () => state.viewport, + writeRepair: (ansi) => repairs.push(ansi), + isQuiet: () => state.quiet, + activitySerial: () => state.serial, + flushPending: () => { + if (state.flushBumpsSerial) state.serial += 1 + }, + now: () => state.now + } + return { deps, repairs, state } +} + +function diverge(state: Harness['state']): void { + state.viewport = { rows: 2, cols: 10, lines: ['row one', 'GARBAGE tw'] } +} + +async function confirmCycles(reconciler: { checkNow(): Promise }): Promise { + for (let i = 0; i < RECONCILE_CONFIRM_CHECKS; i += 1) { + await reconciler.checkNow() + } +} + +describe('screensMatch', () => { + it('matches identical screens and ignores trailing whitespace + blank tail rows', () => { + expect(screensMatch('a\nb', ['a', 'b'])).toBe(true) + expect(screensMatch('a \nb', ['a', 'b '])).toBe(true) + expect(screensMatch('a\nb\n\n', ['a', 'b'])).toBe(true) + expect(screensMatch('a\nb', ['a', 'b', '', ''])).toBe(true) + }) + + it('detects divergent rows', () => { + expect(screensMatch('a\nb', ['a', 'x'])).toBe(false) + expect(screensMatch('a\nb', ['a'])).toBe(false) + // Stale glyphs bleeding through spaces — the real-world signature. + expect(screensMatch('Do you want to proceed?', ['Do3youowant to proceed?'])).toBe(false) + }) +}) + +describe('createTerminalReconciler', () => { + it('does not repair when screens match', async () => { + const { deps, repairs } = makeHarness() + const reconciler = createTerminalReconciler(deps) + await confirmCycles(reconciler) + expect(repairs).toEqual([]) + reconciler.dispose() + }) + + it('repairs a divergence after the confirmation streak, exactly once', async () => { + const { deps, repairs, state } = makeHarness() + const reconciler = createTerminalReconciler(deps) + diverge(state) + await reconciler.checkNow() + expect(repairs).toEqual([]) // first sighting: not yet confirmed + await reconciler.checkNow() + expect(repairs).toEqual([state.ansi!.screen]) + expect(reconciler.repairs()).toBe(1) + // Streak reset: the next mismatch sighting starts over. + await reconciler.checkNow() + expect(repairs).toHaveLength(1) + reconciler.dispose() + }) + + it('never fetches while not quiet', async () => { + const { deps, state } = makeHarness() + const fetches: string[] = [] + state.onFetch = (format) => fetches.push(format) + state.quiet = false + const reconciler = createTerminalReconciler(deps) + diverge(state) + await confirmCycles(reconciler) + expect(fetches).toEqual([]) + reconciler.dispose() + }) + + it('aborts when output arrives during the plain fetch', async () => { + const { deps, repairs, state } = makeHarness() + state.onFetch = (format) => { + if (format === 'plain') state.serial += 1 + } + const reconciler = createTerminalReconciler(deps) + diverge(state) + await confirmCycles(reconciler) + expect(repairs).toEqual([]) + reconciler.dispose() + }) + + it('aborts when output arrives during the ansi fetch', async () => { + const { deps, repairs, state } = makeHarness() + const reconciler = createTerminalReconciler(deps) + diverge(state) + await reconciler.checkNow() + state.onFetch = (format) => { + if (format === 'ansi') state.serial += 1 + } + await reconciler.checkNow() + expect(repairs).toEqual([]) + reconciler.dispose() + }) + + it('aborts when the pre-write flush surfaces staged chunks', async () => { + const { deps, repairs, state } = makeHarness() + state.flushBumpsSerial = true + const reconciler = createTerminalReconciler(deps) + diverge(state) + await confirmCycles(reconciler) + expect(repairs).toEqual([]) + reconciler.dispose() + }) + + it('skips while snapshot and grid dimensions disagree (resize in flight)', async () => { + const { deps, repairs, state } = makeHarness() + const reconciler = createTerminalReconciler(deps) + diverge(state) + state.viewport!.rows = 3 + state.viewport!.lines.push('') + await confirmCycles(reconciler) + expect(repairs).toEqual([]) + reconciler.dispose() + }) + + it('rate-limits repairs', async () => { + const { deps, repairs, state } = makeHarness() + const reconciler = createTerminalReconciler(deps) + diverge(state) + await confirmCycles(reconciler) + expect(repairs).toHaveLength(1) + // Still divergent (repair write didn't fix the fake viewport): confirm + // again — inside the gap window nothing happens, past it a second + // repair lands. + await confirmCycles(reconciler) + expect(repairs).toHaveLength(1) + state.now += RECONCILE_MIN_REPAIR_GAP_MS + 1 + await confirmCycles(reconciler) + expect(repairs).toHaveLength(2) + reconciler.dispose() + }) + + it('treats a null snapshot as a skipped check', async () => { + const { deps, repairs, state } = makeHarness() + state.plain = null + const reconciler = createTerminalReconciler(deps) + diverge(state) + await confirmCycles(reconciler) + expect(repairs).toEqual([]) + reconciler.dispose() + }) + + it('stops checking after dispose', async () => { + const { deps, state } = makeHarness() + const fetches: string[] = [] + state.onFetch = (format) => fetches.push(format) + const reconciler = createTerminalReconciler(deps) + reconciler.dispose() + await reconciler.checkNow() + expect(fetches).toEqual([]) + }) + + it('runs on an interval without overlapping checks', async () => { + vi.useFakeTimers() + try { + const { deps, state } = makeHarness() + let inFlight = 0 + let maxInFlight = 0 + let release: (() => void) | null = null + deps.fetchSnapshot = async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((resolve) => { + release = resolve + }) + inFlight -= 1 + return state.plain + } + const reconciler = createTerminalReconciler(deps) + await vi.advanceTimersByTimeAsync(4_000) + await vi.advanceTimersByTimeAsync(4_000) + expect(maxInFlight).toBe(1) + release?.() + reconciler.dispose() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/lib/terminal-reconciler.ts b/src/renderer/src/lib/terminal-reconciler.ts new file mode 100644 index 00000000..b1828943 --- /dev/null +++ b/src/renderer/src/lib/terminal-reconciler.ts @@ -0,0 +1,169 @@ +// Quiet-time screen reconciliation against the broker's authoritative PTY +// emulation. +// +// Why this exists: the rendering pipeline has had repeated rounds of +// corruption fixes, each closing one *creation* vector (dropped chunks, +// resize gating, echo ordering, prediction strands). But the class has a +// nastier property than any single vector: modern TUIs (Claude Code's +// renderer in particular) repaint by DIFFING against their own model of the +// screen — they skip cells they believe unchanged, using cursor-forward +// moves instead of rewriting. So a single divergence between the renderer's +// xterm grid and the PTY-side truth (e.g. an xterm reflow scroll during a +// width resize that the PTY-side emulator doesn't perform) is never healed +// by subsequent output: every diff-repaint preserves the stale cells, and +// the corruption compounds (stacked panels, old glyphs bleeding through the +// spaces of new rows). +// +// The broker daemon maintains its own emulation of every worker PTY — the +// same screen it serves as the attach snapshot, observable via +// `agent-relay-broker dump-pty`. That emulation consumes the byte stream +// in-order, atomically with resizes, and is the ground truth the renderer +// must converge to. This module polls it when the terminal is QUIET and the +// screen is suspect, and repaints the viewport from the broker's ANSI +// reproduction stream when a divergence is confirmed — mosh-style state +// convergence layered over the existing event stream. +// +// Safety invariants (each guards a real re-corruption vector): +// - Only check while quiet: no server output for RECONCILE_QUIET_MS, window +// visible (a hidden window stalls the rAF chunk flush, so "no output" is +// not trustworthy there), no outstanding optimistic-echo predictions. +// - A repair write only lands if the activity serial is unchanged across +// the snapshot fetch AND after a forced flush of staged chunks — a chunk +// that raced the fetch may already be inside the snapshot; replaying it on +// top of the repaired screen would double-apply bytes. +// - Dimensions must match exactly (snapshot rows/cols == grid rows/cols); +// a mismatch means a resize is still propagating and a repaint would be +// framed for the wrong grid. +// - A divergence must be confirmed on two consecutive checks before a +// repair, and repairs are rate-limited; this thing must never flap. +// +// The repair payload is the broker's ANSI snapshot, which is self-framing +// (leading reset + home + erase-display + absolute row addressing + cursor +// restore), so writing it onto a dirty grid fully replaces the viewport +// without touching scrollback. + +export const RECONCILE_CHECK_INTERVAL_MS = 4_000 +export const RECONCILE_QUIET_MS = 1_500 +export const RECONCILE_MIN_REPAIR_GAP_MS = 15_000 +// Confirmations required on consecutive checks before repairing. +export const RECONCILE_CONFIRM_CHECKS = 2 + +export interface ReconcileSnapshot { + rows: number + cols: number + /** Row text for `plain`; ANSI reproduction stream for `ansi`. */ + screen: string +} + +export interface ReconcileViewport { + rows: number + cols: number + /** Right-trimmed text of each viewport row. */ + lines: string[] +} + +export interface TerminalReconcilerDeps { + fetchSnapshot(format: 'plain' | 'ansi'): Promise + readViewport(): ReconcileViewport | null + /** Write the ANSI repair stream to the live terminal (ordered sink). */ + writeRepair(ansi: string): void + /** All quiet-gate conditions: see module doc. */ + isQuiet(): boolean + /** Monotonic counter, bumped once per server-output delivery. */ + activitySerial(): number + /** Force rAF-staged chunks out so the serial reflects everything received. */ + flushPending(): void + log?(message: string): void + now?(): number +} + +export interface TerminalReconciler { + /** Run one check cycle now (the interval calls this internally). */ + checkNow(): Promise + repairs(): number + dispose(): void +} + +export function createTerminalReconciler(deps: TerminalReconcilerDeps): TerminalReconciler { + const now = deps.now ?? Date.now + const log = deps.log ?? ((): void => undefined) + let disposed = false + let checking = false + let mismatchStreak = 0 + let lastRepairAt = 0 + let repairCount = 0 + + const timer = setInterval(() => { + void check() + }, RECONCILE_CHECK_INTERVAL_MS) + + const check = async (): Promise => { + if (disposed || checking) return + if (!deps.isQuiet()) return + checking = true + try { + await checkInner() + } finally { + checking = false + } + } + + const checkInner = async (): Promise => { + const serial = deps.activitySerial() + const plain = await deps.fetchSnapshot('plain') + if (disposed || !plain) return + // Output during the fetch (or a quiet-gate change) invalidates the + // comparison: the two screens were captured at different stream points. + if (deps.activitySerial() !== serial || !deps.isQuiet()) return + const viewport = deps.readViewport() + if (!viewport) return + if (plain.rows !== viewport.rows || plain.cols !== viewport.cols) return + if (screensMatch(plain.screen, viewport.lines)) { + mismatchStreak = 0 + return + } + mismatchStreak += 1 + if (mismatchStreak < RECONCILE_CONFIRM_CHECKS) return + if (now() - lastRepairAt < RECONCILE_MIN_REPAIR_GAP_MS) return + + const ansi = await deps.fetchSnapshot('ansi') + if (disposed || !ansi) return + if (deps.activitySerial() !== serial || !deps.isQuiet()) return + if (ansi.rows !== viewport.rows || ansi.cols !== viewport.cols) return + // Final gate, synchronous with the write: force any staged chunks out. + // If one lands, its bytes may already be inside the snapshot we are + // about to paint — abort and let the next cycle re-verify. + deps.flushPending() + if (disposed || deps.activitySerial() !== serial) return + deps.writeRepair(ansi.screen) + lastRepairAt = now() + mismatchStreak = 0 + repairCount += 1 + log( + `[terminal] viewport diverged from broker screen; repainted from snapshot (repair #${repairCount})` + ) + } + + return { + checkNow: check, + repairs: () => repairCount, + dispose(): void { + disposed = true + clearInterval(timer) + } + } +} + +// Plain snapshots are newline-joined rows; xterm viewport lines come in +// right-trimmed. Compare row-by-row, right-trimmed, treating absent rows as +// blank — trailing blank rows are representational noise, not divergence. +export function screensMatch(plainScreen: string, viewportLines: string[]): boolean { + const snapshotLines = plainScreen.split('\n') + const rows = Math.max(snapshotLines.length, viewportLines.length) + for (let row = 0; row < rows; row += 1) { + const expected = (snapshotLines[row] ?? '').replace(/\s+$/, '') + const actual = (viewportLines[row] ?? '').replace(/\s+$/, '') + if (expected !== actual) return false + } + return true +} diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index add889d2..a6c7c0ff 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -34,6 +34,7 @@ import { recordChunkEchoed } from '@/lib/typing-trace' import { createPredictiveEcho, type PredictiveEchoWithStatus } from '@/lib/predictive-echo' import { createPtySizeSync } from '@/lib/pty-size-sync' import { buildModelSeedFromTerminal, createEchoRouter } from '@/lib/echo-router' +import { createTerminalReconciler, RECONCILE_QUIET_MS } from '@/lib/terminal-reconciler' import type { PredictiveEcho } from '@agent-relay/harness-driver/predictive-echo' import { awaitFontSettle } from '@/lib/font-settle' import type { Theme } from '@/stores/ui-store' @@ -302,6 +303,11 @@ function createRuntime( // attach snapshot was painted. Chunks at or below this total are already // on screen (inside the snapshot); only chunks past it get replayed. let writtenTotal = 0 + // Reconciler activity tracking: serial bumps once per server-output + // delivery; lastOutputAt gates the quiet window. Both are read by the + // quiet-time screen reconciler below. + let activitySerial = 0 + let lastOutputAt = 0 let attachSeeded = false let attachInFlight = false let pendingInitFrame: number | null = null @@ -334,6 +340,40 @@ function createRuntime( isViewportPinned: () => (term ? isViewportPinnedToBottom(term) : false), scrollToBottom: () => term?.scrollToBottom() }) + // Quiet-time convergence to the broker's authoritative screen. Catches the + // divergence class no creation-vector fix can: once the grid and a diffing + // TUI's model disagree (e.g. an xterm reflow scroll during a width + // resize), every subsequent diff-repaint preserves the stale cells. See + // terminal-reconciler.ts for the gating invariants. + const reconciler = createTerminalReconciler({ + fetchSnapshot: (format) => pear.broker.snapshotTerminal(opts.projectId, opts.agentName, format), + readViewport: () => { + if (!term || !opened) return null + const buffer = term.buffer.active + const lines: string[] = [] + for (let row = 0; row < term.rows; row += 1) { + const line = buffer.getLine(buffer.baseY + row) + lines.push(line ? line.translateToString(true) : '') + } + return { rows: term.rows, cols: term.cols, lines } + }, + writeRepair: (ansi) => { + // Through the router so the repair is ordered behind any queued engine + // writes and, on the engine route, also repairs the engine's model. + echoRouter.onServerOutput(ansi) + }, + isQuiet: () => { + if (disposed || !attachSeeded || currentToken === null || !opened) return false + // A hidden window stalls the rAF chunk flush, so "no recent output" + // says nothing about what is actually pending — never reconcile there. + if (document.visibilityState !== 'visible') return false + if (predictiveEcho?.hasPredictions) return false + return Date.now() - lastOutputAt >= RECONCILE_QUIET_MS + }, + activitySerial: () => activitySerial, + flushPending: () => flushPtyChunksNow(key), + log: (message) => console.warn(message) + }) const cancelPendingInit = (): void => { if (pendingInitFrame !== null) { @@ -405,6 +445,8 @@ function createRuntime( const writeChunks = (newChunks: string[]): void => { if (disposed || !term) return if (newChunks.length === 0) return + activitySerial += 1 + lastOutputAt = Date.now() // Optional diagnostic, gated on localStorage.PEAR_DIAG_PTY === '1'. // See pty-buffer-store.ts for the enable instructions. Flag is // cached to avoid a per-batch localStorage read. @@ -642,6 +684,7 @@ function createRuntime( // writeFromBuffer notification triggered by clearPtyBuffer (with []) // runs while the closure is still consistent. cancelPendingInit() + reconciler.dispose() sizeSync.dispose() echoRouter.dispose() clearPtyBuffer(key) diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index b18eb5a2..d4b478c2 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -383,6 +383,16 @@ export interface BrokerSetTerminalModeResult { pending: number } +export type BrokerTerminalSnapshotFormat = 'plain' | 'ansi' + +export interface BrokerTerminalSnapshot { + rows: number + cols: number + cursor: [number, number] + /** Row text for `plain`; decoded ANSI reproduction byte stream for `ansi`. */ + screen: string +} + export interface BrokerSendMessageInput { to: string text: string @@ -877,6 +887,11 @@ export interface PearAPI { getPending: (projectId: string | undefined, name: string) => Promise flushPending: (projectId: string | undefined, name: string) => Promise<{ flushed: number }> resizePty: (projectId: string | undefined, name: string, rows: number, cols: number) => Promise + snapshotTerminal: ( + projectId: string | undefined, + name: string, + format: BrokerTerminalSnapshotFormat + ) => Promise inputSrtt: (projectId: string | undefined, name: string) => Promise sendMessage: (projectId: string | undefined, input: BrokerSendMessageInput) => Promise reconcileMessages: (input: BrokerReconcileMessagesInput) => Promise From 5d050d04936c7aeddb90a61e6bf4d0f9ead072cc Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 12:32:19 +0200 Subject: [PATCH 7/8] store --- .../workforce/personas/slack-comms.json | 3 + .../src/components/issues/AttentionInbox.tsx | 65 +++++++++++++- src/renderer/src/stores/issues-store.ts | 87 ++++++++++++++++++- 3 files changed, 148 insertions(+), 7 deletions(-) diff --git a/.agentworkforce/workforce/personas/slack-comms.json b/.agentworkforce/workforce/personas/slack-comms.json index 7ab9abdd..1b581ee9 100644 --- a/.agentworkforce/workforce/personas/slack-comms.json +++ b/.agentworkforce/workforce/personas/slack-comms.json @@ -38,6 +38,9 @@ "harness": "claude", "model": "claude-sonnet-4-6", "systemPrompt": "$TASK_DESCRIPTION", + "permissions": { + "mode": "bypassPermissions" + }, "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 600 diff --git a/src/renderer/src/components/issues/AttentionInbox.tsx b/src/renderer/src/components/issues/AttentionInbox.tsx index abb46f77..37928e35 100644 --- a/src/renderer/src/components/issues/AttentionInbox.tsx +++ b/src/renderer/src/components/issues/AttentionInbox.tsx @@ -23,7 +23,7 @@ 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 { useIssuesStore, type IssueBand, type IssueGithubLink, type IssueViewModel, type IssueWorkflowState } from '@/stores/issues-store' import { useProjectStore } from '@/stores/project-store' import { useUIStore } from '@/stores/ui-store' @@ -360,11 +360,15 @@ function IssueGroupSection({ function DetailPanel({ issue, narration, - onOpenLiveProject + availableStates, + onOpenLiveProject, + onChangeState }: { issue: IssueViewModel | null narration: string + availableStates: IssueWorkflowState[] onOpenLiveProject: () => void + onChangeState: (issue: IssueViewModel, state: IssueWorkflowState) => void }): React.ReactNode { if (!issue) { return ( @@ -421,7 +425,34 @@ function DetailPanel({
Status detail
-
+
+
Status
+
+ {issue.stateId && issue.issueRemotePath && availableStates.length > 0 ? ( + + ) : ( + + {issue.stage} + + )} +
Owner
{issue.assignedAgentName || issue.assigneeName || 'Unassigned'}
Actor
@@ -529,6 +560,7 @@ export function AttentionInbox({ const lastLoadedAt = useIssuesStore((s) => s.lastLoadedAt) const load = useIssuesStore((s) => s.load) const subscribe = useIssuesStore((s) => s.subscribe) + const setIssueState = useIssuesStore((s) => s.setIssueState) const [expandedBands, setExpandedBands] = useState>({ 'needs-you': true, 'ready-for-agent': true, @@ -571,6 +603,18 @@ export function AttentionInbox({ () => orderStages(Array.from(new Set(issues.map((issue) => issue.stage)))), [issues] ) + // Distinct workflow states that carry a stateId — the only ones we can write back to. + const availableStates = useMemo(() => { + const byId = new Map() + for (const issue of issues) { + if (issue.stateId && !byId.has(issue.stateId)) { + byId.set(issue.stateId, { id: issue.stateId, name: issue.stage, type: issue.stageType }) + } + } + return orderStages(Array.from(byId.values()).map((state) => state.name)).map( + (name) => Array.from(byId.values()).find((state) => state.name === name) as IssueWorkflowState + ) + }, [issues]) const activeStatusFilter = statusFilter && availableStages.includes(statusFilter) ? statusFilter : null const visibleIssues = activeStatusFilter ? issues.filter((issue) => issue.stage === activeStatusFilter) @@ -613,6 +657,19 @@ export function AttentionInbox({ setNavNotice(result.message) } + async function handleChangeState(issue: IssueViewModel, state: IssueWorkflowState): Promise { + if (!resolvedProjectId) { + setNavNotice('No project for status change') + return + } + try { + await setIssueState(resolvedProjectId, issue.id, state) + setNavNotice(`Moved ${issue.identifier} → ${state.name}`) + } catch (error) { + setNavNotice(error instanceof Error ? error.message : 'Failed to change status') + } + } + async function handleSpawnTeam(issue: IssueViewModel): Promise { const project = resolvedProjectId ? useProjectStore.getState().projects.find((candidate) => candidate.id === resolvedProjectId) : undefined if (!project) { @@ -794,7 +851,9 @@ export function AttentionInbox({ selectedIssue && void openLiveProject(selectedIssue)} + onChangeState={handleChangeState} /> {navNotice && ( diff --git a/src/renderer/src/stores/issues-store.ts b/src/renderer/src/stores/issues-store.ts index 15a154b5..feed3395 100644 --- a/src/renderer/src/stores/issues-store.ts +++ b/src/renderer/src/stores/issues-store.ts @@ -22,10 +22,15 @@ export interface IssueViewModel { description: string stage: string stageType?: string + stateId?: string + /** Canonical mount path of the issue file, used for writeback (state changes, comments). */ + issueRemotePath?: string actor: 'agent' | 'pairing' | 'human' | 'unknown' labels: string[] assigneeName?: string assignedAgentName?: string + teamId?: string + teamKey?: string teamName?: string projectName?: string priority?: number @@ -40,6 +45,12 @@ export interface IssueViewModel { githubLinks: IssueGithubLink[] } +export interface IssueWorkflowState { + id: string + name: string + type?: string +} + interface IssuesState { issues: IssueViewModel[] loading: boolean @@ -48,6 +59,12 @@ interface IssuesState { lastLoadedAt: number | null load: (projectId: string, options?: { force?: boolean }) => Promise subscribe: (projectId: string) => () => void + /** + * Change an issue's Linear workflow state via writeback. Writes the target + * `stateId` to the canonical issue file path and optimistically updates the + * local view model; the resulting `relayfile-change` event reconciles it. + */ + setIssueState: (projectId: string, issueId: string, state: IssueWorkflowState) => Promise } const STAGE_ORDER = ['Backlog', 'Planning', 'To do', 'In Progress', 'In review', 'Merged', 'Done'] @@ -221,7 +238,11 @@ async function readGithubLink(projectId: string, sync: Record): } } -async function normalizeIssue(projectId: string, raw: Record): Promise { +async function normalizeIssue( + projectId: string, + raw: Record, + remotePath?: string +): Promise { const issue = payloadRecord(raw) const state = readRecord(issue.state) const attention = readRecord(issue.attention) @@ -247,10 +268,14 @@ async function normalizeIssue(projectId: string, raw: Record): description: readString(issue.description) || '', stage, stageType: readString(state.type), + stateId: readString(state.id) || readString(issue.stateId) || readString(issue.state_id), + issueRemotePath: remotePath, actor: resolveActor(labels, !!assigneeName), labels, assigneeName, assignedAgentName, + teamId: readString(team.id) || readString(issue.teamId) || readString(issue.team_id), + teamKey: readString(team.key), teamName: readString(team.name) || readString(team.key), projectName: readString(project.name), priority: readNumber(issue.priority), @@ -270,6 +295,31 @@ async function normalizeIssue(projectId: string, raw: Record): } } +/** + * Build the writeback payload for an issue state change. Written to the + * canonical issue file path; the Linear adapter interprets `stateId` as a + * workflow-state transition. Only identity + the changed state are included — + * read-only fields (url, timestamps, actor) are deliberately omitted so the + * adapter derives them, mirroring the comment-writeback contract. + */ +function buildStateWritebackPayload(issue: IssueViewModel, state: IssueWorkflowState): Record { + const payload: Record = { + id: issue.id, + identifier: issue.identifier, + stateId: state.id, + state: state.type ? { id: state.id, name: state.name, type: state.type } : { id: state.id, name: state.name } + } + if (issue.teamId) payload.teamId = issue.teamId + if (issue.teamId || issue.teamKey || issue.teamName) { + const team: Record = {} + if (issue.teamId) team.id = issue.teamId + if (issue.teamKey) team.key = issue.teamKey + if (issue.teamName) team.name = issue.teamName + payload.team = team + } + return payload +} + function isIssueFile(entry: FsDirEntry): boolean { return entry.type === 'file' && entry.path.endsWith('.json') && !entry.path.endsWith('/_index.json') } @@ -313,13 +363,14 @@ export const useIssuesStore = create((set, get) => ({ const records = await Promise.all( entries.filter(isIssueFile).map(async (entry) => { const preview = await pear.integrations.readRemoteFile(projectId, entry.path) - return preview.kind === 'text' ? parseJsonPreview(preview.content, entry.path) : null + const record = preview.kind === 'text' ? parseJsonPreview(preview.content, entry.path) : null + return record ? { path: entry.path, record } : null }) ) const issues = await Promise.all( records - .filter((record): record is Record => !!record) - .map((record) => normalizeIssue(projectId, record)) + .filter((entry): entry is { path: string; record: Record } => !!entry) + .map(({ path, record }) => normalizeIssue(projectId, record, path)) ) set({ @@ -370,6 +421,34 @@ export const useIssuesStore = create((set, get) => ({ refreshTimer = null } } + }, + + setIssueState: async (projectId, issueId, state) => { + const issue = get().issues.find((candidate) => candidate.id === issueId) + if (!issue) throw new Error(`Issue ${issueId} not found`) + if (!issue.issueRemotePath) throw new Error('Issue is missing its mount path; cannot change status') + if (!state.id) throw new Error('Target workflow state id is required') + if (state.id === issue.stateId) return + + const payload = buildStateWritebackPayload(issue, state) + await pear.integrations.writeRemoteFile(projectId, issue.issueRemotePath, JSON.stringify(payload, null, 2)) + + // Optimistic update; the resulting relayfile-change event reconciles via load(). + set((current) => ({ + issues: sortIssues( + current.issues.map((candidate) => + candidate.id === issueId + ? { + ...candidate, + stage: state.name, + stageType: state.type ?? candidate.stageType, + stateId: state.id, + band: classifyIssue(state.name, state.type, candidate.labels, {}) + } + : candidate + ) + ) + })) } })) From b083ecb59e01896d2dbdb59d8c500baf3d3c0987 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 13:00:21 +0200 Subject: [PATCH 8/8] feat(factory): align issue state writeback with the Linear adapter PATCH contract - issues-store: load authoritative workflow states from /linear/states and send only the mutable stateId in the writeback payload (the adapter schema is additionalProperties:false; identity/state/team would be rejected) - ipc-mock: add the /linear/states resource and emulate the adapter's PATCH merge semantics for canonical issue records so state moves persist in mock - AttentionInbox: consume the store-provided workflow states Co-Authored-By: Claude Fable 5 --- .../src/components/issues/AttentionInbox.tsx | 9 ++- src/renderer/src/lib/ipc-mock.ts | 39 ++++++++++ src/renderer/src/stores/issues-store.ts | 75 +++++++++++++------ 3 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/issues/AttentionInbox.tsx b/src/renderer/src/components/issues/AttentionInbox.tsx index 37928e35..e5dc0a2b 100644 --- a/src/renderer/src/components/issues/AttentionInbox.tsx +++ b/src/renderer/src/components/issues/AttentionInbox.tsx @@ -561,6 +561,7 @@ export function AttentionInbox({ const load = useIssuesStore((s) => s.load) const subscribe = useIssuesStore((s) => s.subscribe) const setIssueState = useIssuesStore((s) => s.setIssueState) + const workflowStates = useIssuesStore((s) => s.workflowStates) const [expandedBands, setExpandedBands] = useState>({ 'needs-you': true, 'ready-for-agent': true, @@ -603,8 +604,12 @@ export function AttentionInbox({ () => orderStages(Array.from(new Set(issues.map((issue) => issue.stage)))), [issues] ) - // Distinct workflow states that carry a stateId — the only ones we can write back to. + // Prefer the authoritative `/linear/states` list (every workflow state, in + // board order). Fall back to states inferred from loaded issues when the + // states mount isn't live yet — those carry real stateIds too, but only cover + // states currently in use. const availableStates = useMemo(() => { + if (workflowStates.length > 0) return workflowStates const byId = new Map() for (const issue of issues) { if (issue.stateId && !byId.has(issue.stateId)) { @@ -614,7 +619,7 @@ export function AttentionInbox({ return orderStages(Array.from(byId.values()).map((state) => state.name)).map( (name) => Array.from(byId.values()).find((state) => state.name === name) as IssueWorkflowState ) - }, [issues]) + }, [workflowStates, issues]) const activeStatusFilter = statusFilter && availableStages.includes(statusFilter) ? statusFilter : null const visibleIssues = activeStatusFilter ? issues.filter((issue) => issue.stage === activeStatusFilter) diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index ef6d2f2b..dbaa8608 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -605,11 +605,23 @@ const mockGithubRecords: Record> = { } } +// Mirrors the materialized `/linear/states` resource (adapter-linear `states`). +const mockLinearStates: Array> = [ + { id: 'state-planning', name: 'Planning', type: 'unstarted', color: '#9aa0aa', position: 0 }, + { id: 'state-to-do', name: 'To do', type: 'unstarted', color: '#9aa0aa', position: 1 }, + { id: 'state-ready-for-agent', name: 'Ready for Agent', type: 'unstarted', color: '#b083f0', position: 2 }, + { id: 'state-in-progress', name: 'In Progress', type: 'started', color: '#5a8de6', position: 3 }, + { id: 'state-in-review', name: 'In review', type: 'started', color: '#e6d78d', position: 4 }, + { id: 'state-merged', name: 'Merged', type: 'completed', color: '#6cc36c', position: 5 }, + { id: 'state-done', name: 'Done', type: 'completed', color: '#6cc36c', position: 6 } +] + const mockRemoteFiles: Record | string> = Object.fromEntries([ ...mockLinearIssues.map((issue) => [ `/linear/issues/${String(issue.identifier)}.json`, issue ]), + ...mockLinearStates.map((state) => [`/linear/states/${String(state.id)}.json`, state]), ...Object.entries(mockGithubRecords) ]) @@ -1088,6 +1100,14 @@ export const pearMock: PearAPI = { }) } + if (normalized === '/linear/states') { + return mockLinearStates.map((state) => ({ + name: `${String(state.id)}.json`, + path: `/linear/states/${String(state.id)}.json`, + type: 'file' + })) + } + if (normalized === '/github/repos/AgentWorkforce/pear/issues') { return Object.keys(mockGithubRecords).map((path) => ({ name: path.split('/').at(-1) || path, @@ -1108,6 +1128,25 @@ export const pearMock: PearAPI = { writeRemoteFile: async (_projectId: string, remotePath: string, content: string): Promise => { const normalized = normalizeMockRemotePath(remotePath) console.log('[ipc-mock] writeRemoteFile', normalized, content.length, 'bytes') + // Mirror the adapter's Edit/PATCH semantics for canonical issue records: + // merge the included mutable fields (e.g. { stateId }) into the existing + // record and re-derive the embedded `state` so a reload reflects the move. + const existing = mockRemoteFiles[normalized] + const issueMatch = /^\/linear\/issues\/[^/]+\.json$/.test(normalized) + if (issueMatch && existing && typeof existing !== 'string') { + try { + const patch = JSON.parse(content) as Record + const merged = { ...existing, ...patch } + if (typeof patch.stateId === 'string') { + const state = mockLinearStates.find((candidate) => candidate.id === patch.stateId) + if (state) merged.state = state + } + mockRemoteFiles[normalized] = merged + return + } catch { + // fall through to raw write on unparseable payloads + } + } mockRemoteFiles[normalized] = content }, readMountPreview: async (): Promise => ({ kind: 'missing', content: '', size: 0 }), diff --git a/src/renderer/src/stores/issues-store.ts b/src/renderer/src/stores/issues-store.ts index feed3395..ec62b1c1 100644 --- a/src/renderer/src/stores/issues-store.ts +++ b/src/renderer/src/stores/issues-store.ts @@ -53,6 +53,12 @@ export interface IssueWorkflowState { interface IssuesState { issues: IssueViewModel[] + /** + * Authoritative workflow states from the materialized `/linear/states` mount, + * when available. Empty until the states resource is in the project scope and + * synced — callers should fall back to states derived from loaded issues. + */ + workflowStates: IssueWorkflowState[] loading: boolean error: string | null loadedProjectId: string | null @@ -296,28 +302,48 @@ async function normalizeIssue( } /** - * Build the writeback payload for an issue state change. Written to the - * canonical issue file path; the Linear adapter interprets `stateId` as a - * workflow-state transition. Only identity + the changed state are included — - * read-only fields (url, timestamps, actor) are deliberately omitted so the - * adapter derives them, mirroring the comment-writeback contract. + * Build the writeback payload for an issue state change. + * + * The `@relayfile/adapter-linear` `issues` resource (discovery/linear/.adapter.md) + * defines Edit semantics as: "write the resource update payload to the canonical + * resource path; included mutable fields PATCH; fields marked readOnly in + * .schema.json are rejected." The schema is `additionalProperties: false`, so we + * send ONLY the single mutable field we intend to change — `stateId`. Including + * identity/`state`/`team` objects (readOnly or non-schema) would be rejected. */ -function buildStateWritebackPayload(issue: IssueViewModel, state: IssueWorkflowState): Record { - const payload: Record = { - id: issue.id, - identifier: issue.identifier, - stateId: state.id, - state: state.type ? { id: state.id, name: state.name, type: state.type } : { id: state.id, name: state.name } - } - if (issue.teamId) payload.teamId = issue.teamId - if (issue.teamId || issue.teamKey || issue.teamName) { - const team: Record = {} - if (issue.teamId) team.id = issue.teamId - if (issue.teamKey) team.key = issue.teamKey - if (issue.teamName) team.name = issue.teamName - payload.team = team +function buildStateWritebackPayload(state: IssueWorkflowState): Record { + return { stateId: state.id } +} + +/** + * Load the authoritative workflow-state list from the materialized + * `/linear/states` mount (adapter-linear `states` resource). Returns [] if the + * resource is out of scope, not yet synced, or unreadable — callers fall back to + * states derived from loaded issues. + */ +async function loadWorkflowStates(projectId: string): Promise { + try { + const entries = await pear.integrations.listRemoteDir(projectId, '/linear/states') + const parsed: Array<{ id: string; name: string; type?: string; position: number }> = [] + await Promise.all( + entries.filter(isIssueFile).map(async (entry) => { + const preview = await pear.integrations.readRemoteFile(projectId, entry.path) + if (preview.kind !== 'text') return + const record = parseJsonPreview(preview.content, entry.path) + if (!record) return + const data = payloadRecord(record) + const id = readString(data.id) + const name = readString(data.name) + if (!id || !name) return + parsed.push({ id, name, type: readString(data.type), position: readNumber(data.position) ?? Number.MAX_SAFE_INTEGER }) + }) + ) + return parsed + .sort((a, b) => a.position - b.position) + .map(({ id, name, type }) => ({ id, name, type })) + } catch { + return [] } - return payload } function isIssueFile(entry: FsDirEntry): boolean { @@ -346,6 +372,7 @@ function scheduleRefresh(projectId: string, generation: number, load: IssuesStat export const useIssuesStore = create((set, get) => ({ issues: [], + workflowStates: [], loading: false, error: null, loadedProjectId: null, @@ -359,7 +386,10 @@ export const useIssuesStore = create((set, get) => ({ const promise = (async () => { set({ loading: true, error: null }) try { - const entries = await pear.integrations.listRemoteDir(projectId, '/linear/issues') + const [entries, workflowStates] = await Promise.all([ + pear.integrations.listRemoteDir(projectId, '/linear/issues'), + loadWorkflowStates(projectId) + ]) const records = await Promise.all( entries.filter(isIssueFile).map(async (entry) => { const preview = await pear.integrations.readRemoteFile(projectId, entry.path) @@ -375,6 +405,7 @@ export const useIssuesStore = create((set, get) => ({ set({ issues: sortIssues(issues), + workflowStates, loading: false, error: null, loadedProjectId: projectId, @@ -430,7 +461,7 @@ export const useIssuesStore = create((set, get) => ({ if (!state.id) throw new Error('Target workflow state id is required') if (state.id === issue.stateId) return - const payload = buildStateWritebackPayload(issue, state) + const payload = buildStateWritebackPayload(state) await pear.integrations.writeRemoteFile(projectId, issue.issueRemotePath, JSON.stringify(payload, null, 2)) // Optimistic update; the resulting relayfile-change event reconciles via load().