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/linear-dispatcher/skills/persona-relayfile-mount.md b/.agentworkforce/workforce/personas/__assets/linear-dispatcher/skills/persona-relayfile-mount.md new file mode 100644 index 00000000..7f3cfb70 --- /dev/null +++ b/.agentworkforce/workforce/personas/__assets/linear-dispatcher/skills/persona-relayfile-mount.md @@ -0,0 +1,87 @@ +--- +name: persona-relayfile-mount +description: Use when authoring an AgentWorkforce persona's `mount` field — covers when to use mount, the gitignore allow-list idiom and its non-obvious `!web` vs `!web/` walker bug, `readonlyPatterns` scope rules, the per-agent dotfile overlay, and `.git` sandbox behavior +--- + +# Relayfile mount policy for AgentWorkforce personas + +You are advising an author writing the `mount` field on an AgentWorkforce persona spec. Use `mount` when the persona should run in a filesystem sandbox: it should see only some files, treat others as read-only, or both. Omit `mount` when the persona is fine running in-place with full project access (the default for most personas). + +## Shape + +```json +"mount": { + "ignoredPatterns": ["..."], + "readonlyPatterns": ["..."] +} +``` + +Both arrays use gitignore-style globs (powered by the `ignore` library). + +- `ignoredPatterns` — matches are omitted from the mount entirely; the agent cannot see or write them. +- `readonlyPatterns` — matches are copied into the mount but `chmod 444`; agent edits to these paths never sync back to the project. + +## Mount vs permissions + +Mount gates *files*. `permissions` gates *tools*. They compose — a persona that shouldn't see `.env` AND shouldn't shell out needs both. + +## Per-agent dotfile overlays + +The persona's `id` is passed to relayfile as `agentName`. At launch, relayfile also loads: + +- `.{id}.agentignore` +- `.{id}.agentreadonly` + +from the project root, appending their contents to the persona spec's patterns. Persona authors control the in-spec baseline; deployers can layer per-agent dotfiles on top without editing the persona JSON. + +## Allow-list idiom (agent only sees one subtree) + +Use gitignore negation, NOT a broad `**` exclude. + +**Correct:** + +```json +"ignoredPatterns": ["/*", "!web", "!web/**", "secrets/", ".env"] +``` + +**Four rules to avoid bricking the mount:** + +1. **Use `/*` as the broad-exclude, never `**`.** Gitignore semantics skip excluded parent directories entirely. Once `**` excludes a directory, a later `!dir/**` cannot bring its contents back — the dir was never walked. `/*` only excludes root-level entries, so subdirs of allowed paths stay visible. +2. **Negate the directory with `!web`, NOT `!web/`.** This one is non-obvious and breaks every "obvious" allow-list. The relayfile walker calls the `ignore` library twice per directory entry — once with the bare name (`web`) and once with the trailing-slash form (`web/`) — and OR's the results. The bare-name check fires first, and `/*` matches `web` regardless of type. A trailing-slash negation `!web/` only counters the trailing-slash form, so the bare-name check still returns "ignored" and the walker skips the directory. `!web` (no slash) negates BOTH forms, which is what you need. **If your allow-listed subtree isn't appearing in the mount, this is almost always the bug.** +3. **Include BOTH the directory and its contents.** `!web` re-includes the directory node so the walker recurses into it; `!web/**` re-includes everything inside it. You need both. +4. **No `./` prefixes.** The `ignore` library expects bare relative patterns; `./web/**` is treated as a literal path that almost never matches. + +**Wrong (looks right, silently empty mount):** + +```json +"ignoredPatterns": ["/*", "!web/", "!web/**"] +``` + +**Right:** + +```json +"ignoredPatterns": ["/*", "!web", "!web/**"] +``` + +## readonlyPatterns scope + +`readonlyPatterns` is for paths the agent should READ but never MODIFY — lockfiles, vendor code, configs the persona references but shouldn't touch. + +**Never include the persona's primary work directory.** If you do, the agent's writes to that directory are silently filtered out on sync-back. The persona looks like it ran successfully but produced zero project-side changes — one of the worst failure modes because nothing complains. + +Example: a blog-writer persona whose job is to author files under `web/content/blog/` must NOT have `web/**` in `readonlyPatterns`. + +## Git inside the mount + +The mount auto-includes `.git`, so `git status`/`log`/`diff` work inside the sandbox. Sync rules: + +- Project-side changes under `.git/**` flow INTO the mount (e.g. teammate moves HEAD while the agent runs). +- Mount-side changes under `.git/**` are NOT synced back. Branches, commits, and refs the agent creates in the mount stay sandboxed and are discarded on cleanup unless the agent pushes to a remote. + +## Pre-handoff checklist + +- [ ] Does the persona actually need a sandbox? If full project access is fine, omit `mount` entirely. +- [ ] For allow-lists: `/*` (not `**`) plus paired `!dir` (NO trailing slash) and `!dir/**` for each allowed path? +- [ ] Is the persona's *work* directory NOT in `readonlyPatterns`? +- [ ] Are secrets, `.env`, and any private dirs in `ignoredPatterns`? +- [ ] Does `permissions` cover the tool-side scope (Bash, file edits, MCP) the persona should and shouldn't have? diff --git a/.agentworkforce/workforce/personas/__assets/repo-router/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/repo-router/skills/agentworkforce-repo-map.md b/.agentworkforce/workforce/personas/__assets/repo-router/skills/agentworkforce-repo-map.md new file mode 100644 index 00000000..262fa5de --- /dev/null +++ b/.agentworkforce/workforce/personas/__assets/repo-router/skills/agentworkforce-repo-map.md @@ -0,0 +1,271 @@ +--- +name: agentworkforce-repo-map +description: Canonical map of all AgentWorkforce GitHub org repos, their roles, responsibilities, and key signals. Read this before routing any issue or deciding which repo to open a PR in. Use as a seed index — always verify against the live org with `gh repo list AgentWorkforce` since new repos are created frequently. +--- + +# AgentWorkforce Repo Map + +This skill is the canonical reference for which repo owns what in the AgentWorkforce GitHub organization. Read it before routing an issue, spawning an agent, or opening a PR. + +## How to verify against the live org + +```bash +# List all repos with descriptions +gh repo list AgentWorkforce --limit 100 --json name,description + +# Read a specific repo's README +gh api repos/AgentWorkforce/{repo}/contents/README.md --jq '.content' | base64 -d + +# Read package.json +gh api repos/AgentWorkforce/{repo}/contents/package.json --jq '.content' | base64 -d + +# List top-level directory structure +gh api repos/AgentWorkforce/{repo}/contents --jq '[.[] | {name, type}]' +``` + +## Core repos + +### `pear` +**Role**: Desktop application — the primary user-facing product. +**Stack**: Electron, TypeScript, React, relayfile mount client. +**Owns**: Claude Code session UI, renderer process, PTY I/O, relayfile mount integration, integration event rendering (Slack/Linear/GitHub panels), Pear app settings, local persona installation. +**Key signals in issues**: "pear", "desktop", "renderer", "mount", "PTY", "session", "UI", "Electron", "integration panel", "local", "writeback path", "integration-remote-paths". + +### `cloud` +**Role**: Backend — Cloudflare Workers API and integration orchestration. +**Stack**: Cloudflare Workers, TypeScript, D1, KV, Durable Objects, Nango, Hono. +**Owns**: REST API, Slack/Linear/GitHub webhook consumers, Nango connection management, picker options, worker dispatch, relay worker identity, conversation sandbox, proactive agent triggers. +**Key signals in issues**: "cloud", "API", "worker", "Slack", "Linear", "webhook", "Nango", "cf-", "KV", "D1", "Durable Object", "picker", "integration event", "cloud worker". + +### `agents` +**Role**: Autonomous agent harnesses deployed as persistent workers. +**Stack**: TypeScript, agent-relay SDK, relayfile. +**Owns**: pr-reviewer agent, merge-on-green logic, agent harness framework, agents deployed as long-running relay workers. +**Key signals in issues**: "pr-reviewer", "merge-on-green", "agent harness", "autonomous agent", "review bot", "persistent agent", "relay worker". + +### `workforce` +**Role**: Tooling for building, installing, and running agent personas. +**Stack**: TypeScript, Node.js monorepo (pnpm workspaces). +**Owns**: persona packages (`@agentworkforce/persona-*`), CLI (`agentworkforce install`), runtime, deploy tooling, workload router, persona-kit, harness-kit. +**Key signals in issues**: "persona", "CLI", "runtime", "deploy", "workforce", "install", "agentworkforce", "persona-kit", "harness", "workload router". + +### `agent-assistant` +**Role**: SDK for building production-grade assistants from explicit runtime primitives. +**Stack**: TypeScript monorepo, npm workspaces. +**Owns**: `@agent-assistant/*` packages: traits, turn-context, harness, continuation, inbox, memory, sessions, surfaces, policy, proactive behavior, routing, connectivity, coordination, VFS, examples, Cloudflare/webhook runtimes. +**Key signals in issues**: "agent-assistant", "@agent-assistant", "assistant SDK", "traits", "turn-context", "bounded turn", "continuation", "inbox", "assistant memory", "surfaces", "policy", "coordination", "connectivity", "assistant vfs". + +### `relayfile` +**Role**: Integration filesystem for agents: queue-first VFS-over-REST, local mount, SDK, CLI, file observer, schemas, and Go services. +**Stack**: TypeScript packages, Go services, local mount/FUSE, Cloudflare-facing SDK surfaces. +**Owns**: relayfile core server/runtime, `@relayfile/sdk`, `relayfile` CLI, local mount behavior, VFS layout, ACLs, file observer, writeback queue semantics, sync/eval harnesses, schemas. +**Key signals in issues**: "relayfile", "VFS", "virtual filesystem", "mount layout", "LAYOUT.md", "local-mount", "ACL", "file observer", "writeback queue", "dead-letter", "replay", "sync", "RelayFileClient", "@relayfile/sdk". + +### `relayfile-adapters` +**Role**: TypeScript packages that project external providers into relayfile trees and implement provider-specific writeback. +**Stack**: TypeScript, Cloudflare Workers. +**Owns**: provider adapters (Linear, Slack, GitHub, Notion), resource path shapes, provider event emission, provider-specific writeback translation, adapter publish/deploy pipeline. +**Key signals in issues**: "adapter", "provider adapter", "Linear adapter", "Slack adapter", "GitHub adapter", "Notion adapter", "provider sync", "writeback protocol", "resource shape", "relayfile-adapter", "@relayfile/". + +### `relayfile-providers` +**Role**: Auth and credential-management providers for relayfile integrations. +**Stack**: TypeScript integration/provider packages. +**Owns**: provider auth plumbing, credential exchange, hosted provider connectors, Nango/Composio/Pipedream-style provider boundaries used by relayfile. +**Key signals in issues**: "relayfile provider", "provider auth", "credential provider", "Nango", "Composio", "Pipedream", "connection token", "integration credentials". + +### `burn` +**Role**: Token/cost observability for agent CLI sessions. +**Stack**: TypeScript, Rust/N-API, pnpm monorepo. +**Owns**: `relayburn` CLI, burn ledger, cost ingestion for Claude Code/Codex/OpenCode logs, pricing updates, hotspots, overhead attribution, model comparison, MCP server for cost queries. +**Key signals in issues**: "burn", "relayburn", "token usage", "cost", "hotspots", "overhead", "CLAUDE.md cost", "AGENTS.md cost", "pricing", "ledger", "usage limits", "mcp-server cost". + +### `relay` +**Role**: The core Agent Relay product — server, broker, SDK, protocol. +**Stack**: Rust/TypeScript web and package surfaces. +**Owns**: relay server, broker, `@agent-relay/sdk`, relay protocol, agent registration, message routing, workspace management, core messaging substrate. +**Key signals in issues**: "relay", "broker", "SDK", "@agent-relay/sdk", "relay server", "subscription", "workspace", "agent registration", "message routing", "channel", "DM", "thread". + +### `skills` +**Role**: Skill packs consumed by agent personas at runtime. +**Stack**: Markdown (SKILL.md files), PRPM package manager. +**Owns**: `@agent-relay/` and `@agent-workforce/` skill namespaces, skill authoring tooling, skill discovery. +**Key signals in issues**: "skill", "SKILL.md", "skill pack", "@agent-relay/skill", "@agent-workforce/skill", "skills repo". + +## Product and workflow repos + +### `relaycast` +**Role**: Headless Slack-style collaboration product for AI agents. +**Owns**: Relaycast workspace UX/API, agent chat surfaces, real-time collaboration patterns that are product-specific rather than core Relay protocol. +**Key signals in issues**: "relaycast", "headless Slack", "agent workspace", "agent chat product", "real-time agent collaboration". + +### `relayauth` +**Role**: Authorization and capability constraints for agents. +**Owns**: permission/authority model, scoped access, action authorization, capability boundaries. +**Key signals in issues**: "relayauth", "authorization", "capability", "permissions", "least privilege", "agent can do exactly". + +### `relayflows` +**Role**: Multi-step, multi-agent workflow orchestration across Agent Relay workers. +**Owns**: workflow graph/runtime concepts, orchestration recipes, worker coordination flows. +**Key signals in issues**: "relayflows", "workflow orchestration", "multi-step", "multi-agent execution", "workflow runner". + +### `ricky` +**Role**: Workflow reliability agent and workflow runner for AgentWorkforce. +**Owns**: workflow reliability, runner behavior, workflow execution monitoring. +**Key signals in issues**: "ricky", "workflow reliability", "workflow runner", "runner failed", "workflow monitor". + +### `nightcto` +**Role**: Overnight CTO-style autonomous engineering/product agent. +**Owns**: NightCTO persona/product behavior, overnight planning/execution workflow, CTO automation. +**Key signals in issues**: "nightcto", "night CTO", "overnight", "CTO agent", "autonomous CTO". + +### `sage` +**Role**: Research, clarification, and planning agent that turns conversations into executable workflows. +**Owns**: planning/research workflow, requirements clarification, conversation-to-plan behavior. +**Key signals in issues**: "sage", "research", "clarify", "planning workflow", "requirements", "executable development workflow". + +### `proactive-agents` +**Role**: Marketing/blog site for proactive agent concepts. +**Owns**: proactive-agents website content and messaging. +**Key signals in issues**: "proactive-agents site", "marketing site", "blog", "proactive agents content". + +### `proactive` +**Role**: Tools to build proactive agents. +**Owns**: proactive-agent primitives and tooling outside the marketing site. +**Key signals in issues**: "proactive", "follow-up", "watch rule", "scheduler", "proactive agent tooling". + +### `internal-agents` +**Role**: Internal AgentWorkforce agent implementations. +**Owns**: private/internal agents not packaged as public personas. +**Key signals in issues**: "internal agent", "internal-agents", "private agent", "ops agent". + +### `workflows` +**Role**: Repeatable Agent Relay workflows and repo-hygiene cookbooks. +**Owns**: workflow templates, cookbook docs, repo hygiene automation patterns. +**Key signals in issues**: "workflow cookbook", "repo hygiene", "workflows repo", "repeatable workflow". + +### `agent-relay-personas` +**Role**: Workforce personas that leverage Agent Relay. +**Owns**: Agent Relay-specific persona packs outside this `workforce` monorepo. +**Key signals in issues**: "agent-relay-personas", "relay persona", "Agent Relay persona pack". + +### `agent-relay-acp-bridge` +**Role**: ACP bridge for Agent Relay. +**Owns**: Agent Client Protocol bridge integration with Agent Relay. +**Key signals in issues**: "ACP", "agent client protocol", "ACP bridge", "agent-relay-acp-bridge". + +### `agent-relay-openclaw` +**Role**: Agent Relay bridge for OpenClaw. +**Owns**: OpenClaw integration, real-time channels/threads/DMs beyond built-in OpenClaw surfaces. +**Key signals in issues**: "openclaw", "agent-relay-openclaw", "OpenClaw bridge". + +### `agent-relay-vscode` +**Role**: VS Code extension for Agent Relay. +**Owns**: VS Code integration, editor extension commands/views for Relay. +**Key signals in issues**: "VS Code", "vscode", "extension", "agent-relay-vscode". + +### `relay-dashboard` +**Role**: Dashboard for agents collaborating through Agent Relay. +**Owns**: dashboard UI, collaboration visualization/product surfaces. +**Key signals in issues**: "dashboard", "relay-dashboard", "collaboration dashboard". + +### `relay-visualizer`, `relay-pulse`, `relay-pty-visualizer` +**Role**: Visual explainers and interactive protocol/PTY visualizations. +**Owns**: visualization UIs, animated protocol demos, PTY pipeline explainers. +**Key signals in issues**: "visualizer", "relay-pulse", "relay-pty", "animated explainer", "protocol visualization". + +### `ai-hist` +**Role**: Sync and search Claude Code/Codex CLI conversation history in SQLite. +**Owns**: conversation-history ingestion/search, local SQLite indexes. +**Key signals in issues**: "ai-hist", "conversation history", "history search", "SQLite", "Claude Code history", "Codex history". + +### `trajectories` +**Role**: Document layer for agent work and traces. +**Owns**: trajectory document format and generated/documented agent work artifacts. +**Key signals in issues**: "trajectory", "trajectories", "agent trace document", "work trace". + +### `agent-trace` +**Role**: Standard trace format for AI-generated code. +**Owns**: trace schema/format for AI coding outputs. +**Key signals in issues**: "agent-trace", "trace format", "AI-generated code trace". + +### `build-plans` +**Role**: Collection of build plans using the Agent Relay SDK. +**Owns**: planning docs/examples for Relay SDK-based builds. +**Key signals in issues**: "build plan", "build-plans", "Relay SDK plan". + +### `demos`, `relay-examples`, `simple-swarm`, `swarm-patterns`, `tic-tac-toe`, `relay-cross-framework-demo` +**Role**: Demos, examples, and proof points for Agent Relay/swarm patterns. +**Owns**: example apps, demo scenarios, swarm visualizations, cross-framework demos, sample games. +**Key signals in issues**: "demo", "example", "simple swarm", "swarm pattern", "tic-tac-toe", "cross framework", "LangGraph", "CrewAI". + +### `relay-cloud`, `relayfile-cloud`, `relayfile-cli` +**Role**: Split-out cloud/CLI package repos for Relay and Relayfile surfaces. +**Owns**: hosted cloud code or dedicated CLI surfaces when an issue names these repos explicitly. +**Key signals in issues**: "relay-cloud", "relayfile-cloud", "relayfile-cli", "hosted relayfile", "cloud code split". + +## Routing rules + +| Issue surface | Primary repo | Secondary repo (if applicable) | +|---|---|---| +| Desktop UI, session rendering, local mount | `pear` | — | +| Slack/Linear webhook, API endpoint, Nango | `cloud` | — | +| PR reviewer, merge automation, agent harness | `agents` | — | +| Assistant SDK/runtime primitives | `agent-assistant` | — | +| Persona authoring, CLI, runtime deploy | `workforce` | — | +| Relayfile core VFS, SDK, CLI, local mount, ACLs | `relayfile` | — | +| Relay protocol, broker, SDK | `relay` | — | +| Provider-specific relayfile trees/writeback | `relayfile-adapters` | — | +| Relayfile provider auth/credentials | `relayfile-providers` | `cloud` if hosted API/Nango wiring changes too | +| Token/cost observability | `burn` | — | +| Skill content | `skills` | — | +| Slack feature end-to-end | `cloud` + `pear` | cloud = webhook/dispatch, pear = mount/render | +| New integration (e.g. Jira) | `relayfile-adapters` + `relayfile-providers` + `cloud` | adapter = sync/writeback, provider = auth, cloud = hosted connection | +| New persona | `workforce` | — | +| Agent workflow orchestration | `relayflows` | `workforce` if packaged as persona/runtime | +| Agent Relay examples/demos | demo repo named in issue | `relay` if core SDK/protocol bug | + +## Multi-repo issues + +When an issue spans multiple repos, spawn one implementer per repo in parallel. Common patterns: +- **New Slack integration feature**: `cloud` (webhook + API) + `pear` (mount rendering) +- **New external provider**: `relayfile-adapters` (resource tree/writeback) + `relayfile-providers` (auth/credential provider) + `cloud` (hosted connection setup) +- **Agent behavior + UI**: `agents` (harness logic) + `pear` (rendering the agent's output) +- **Assistant runtime + product integration**: `agent-assistant` (runtime primitive) + product repo such as `cloud`, `pear`, or `relaycast` +- **Relayfile mount bug with provider symptoms**: `relayfile` (core VFS/local mount) + `relayfile-adapters` (provider path/writeback) after reproducing which layer fails + +## Disambiguation + +If an issue is ambiguous, use `gh api repos/AgentWorkforce/{repo}/contents` to check directory structure of candidate repos before deciding. For example: if an issue mentions "writeback" it could be `pear` (path construction) or `relayfile-adapters` (sync protocol) — check which layer the bug is in. + +## Compact org index + +Use this as a candidate list before falling back to live `gh repo list`. Several repos are products, demos, prototypes, or split-out package surfaces; route to them only when the issue names them directly or their ownership signals match. + +| Repo | Signal | +|---|---| +| `awp` | Agent Workforce Protocol | +| `axiom` | Intent preservation for AI coding agents | +| `c2a` | Chat 2 Agents Protocol | +| `ceo`, `cfo`, `cmo`, `sales`, `supportly`, `rachel`, `openkaren`, `openviktor` | role/persona/product experiments; route only when named | +| `clawrunner` | OpenClaw deployment sandbox | +| `code-storybook` | AI-narrated code storybook | +| `credential-proxy` | credential proxy surface | +| `gitclawlab` | hosting/deploying code for AI agents | +| `kairo` | Mac calendar connected to agents | +| `limit` | terminal dashboard for Claude Code/Codex usage limits | +| `megaclaude` | Claude Code wrapper/variant | +| `moltslack`, `transport` | headless Slack / agent messaging experiments | +| `n8n-nodes-relayfile`, `relaycast-n8n-bridge` | n8n integration packages | +| `open-world`, `open-world-game`, `soccer`, `scout` | game/demo/prototype repos | +| `planner` | web UI for planning tasks with agents | +| `profiles` | identity files for agents | +| `relay-broker`, `relay-sdk`, `relay-tui` | split-out Relay broker/SDK/TUI surfaces; verify whether current work belongs in `relay` first | +| `relay-poc-*` | Relay Communicate SDK proof-of-concept apps | +| `relaycron` | scheduled work for agents | +| `relayed` | relay daemon/runtime surface | +| `relayrank` | ranking/evaluation surface | +| `sloppypaste` | "Stop dumping slop" product/tool | +| `superset` | IDE for running many agent CLIs | +| `vscode-acp` | ACP support for VS Code | +| `wash` | cleanup tool/product | +| `watchdog-agents` | Watchdog agents deployed on Agent Relay | diff --git a/.agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md b/.agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md new file mode 100644 index 00000000..7f3cfb70 --- /dev/null +++ b/.agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md @@ -0,0 +1,87 @@ +--- +name: persona-relayfile-mount +description: Use when authoring an AgentWorkforce persona's `mount` field — covers when to use mount, the gitignore allow-list idiom and its non-obvious `!web` vs `!web/` walker bug, `readonlyPatterns` scope rules, the per-agent dotfile overlay, and `.git` sandbox behavior +--- + +# Relayfile mount policy for AgentWorkforce personas + +You are advising an author writing the `mount` field on an AgentWorkforce persona spec. Use `mount` when the persona should run in a filesystem sandbox: it should see only some files, treat others as read-only, or both. Omit `mount` when the persona is fine running in-place with full project access (the default for most personas). + +## Shape + +```json +"mount": { + "ignoredPatterns": ["..."], + "readonlyPatterns": ["..."] +} +``` + +Both arrays use gitignore-style globs (powered by the `ignore` library). + +- `ignoredPatterns` — matches are omitted from the mount entirely; the agent cannot see or write them. +- `readonlyPatterns` — matches are copied into the mount but `chmod 444`; agent edits to these paths never sync back to the project. + +## Mount vs permissions + +Mount gates *files*. `permissions` gates *tools*. They compose — a persona that shouldn't see `.env` AND shouldn't shell out needs both. + +## Per-agent dotfile overlays + +The persona's `id` is passed to relayfile as `agentName`. At launch, relayfile also loads: + +- `.{id}.agentignore` +- `.{id}.agentreadonly` + +from the project root, appending their contents to the persona spec's patterns. Persona authors control the in-spec baseline; deployers can layer per-agent dotfiles on top without editing the persona JSON. + +## Allow-list idiom (agent only sees one subtree) + +Use gitignore negation, NOT a broad `**` exclude. + +**Correct:** + +```json +"ignoredPatterns": ["/*", "!web", "!web/**", "secrets/", ".env"] +``` + +**Four rules to avoid bricking the mount:** + +1. **Use `/*` as the broad-exclude, never `**`.** Gitignore semantics skip excluded parent directories entirely. Once `**` excludes a directory, a later `!dir/**` cannot bring its contents back — the dir was never walked. `/*` only excludes root-level entries, so subdirs of allowed paths stay visible. +2. **Negate the directory with `!web`, NOT `!web/`.** This one is non-obvious and breaks every "obvious" allow-list. The relayfile walker calls the `ignore` library twice per directory entry — once with the bare name (`web`) and once with the trailing-slash form (`web/`) — and OR's the results. The bare-name check fires first, and `/*` matches `web` regardless of type. A trailing-slash negation `!web/` only counters the trailing-slash form, so the bare-name check still returns "ignored" and the walker skips the directory. `!web` (no slash) negates BOTH forms, which is what you need. **If your allow-listed subtree isn't appearing in the mount, this is almost always the bug.** +3. **Include BOTH the directory and its contents.** `!web` re-includes the directory node so the walker recurses into it; `!web/**` re-includes everything inside it. You need both. +4. **No `./` prefixes.** The `ignore` library expects bare relative patterns; `./web/**` is treated as a literal path that almost never matches. + +**Wrong (looks right, silently empty mount):** + +```json +"ignoredPatterns": ["/*", "!web/", "!web/**"] +``` + +**Right:** + +```json +"ignoredPatterns": ["/*", "!web", "!web/**"] +``` + +## readonlyPatterns scope + +`readonlyPatterns` is for paths the agent should READ but never MODIFY — lockfiles, vendor code, configs the persona references but shouldn't touch. + +**Never include the persona's primary work directory.** If you do, the agent's writes to that directory are silently filtered out on sync-back. The persona looks like it ran successfully but produced zero project-side changes — one of the worst failure modes because nothing complains. + +Example: a blog-writer persona whose job is to author files under `web/content/blog/` must NOT have `web/**` in `readonlyPatterns`. + +## Git inside the mount + +The mount auto-includes `.git`, so `git status`/`log`/`diff` work inside the sandbox. Sync rules: + +- Project-side changes under `.git/**` flow INTO the mount (e.g. teammate moves HEAD while the agent runs). +- Mount-side changes under `.git/**` are NOT synced back. Branches, commits, and refs the agent creates in the mount stay sandboxed and are discarded on cleanup unless the agent pushes to a remote. + +## Pre-handoff checklist + +- [ ] Does the persona actually need a sandbox? If full project access is fine, omit `mount` entirely. +- [ ] For allow-lists: `/*` (not `**`) plus paired `!dir` (NO trailing slash) and `!dir/**` for each allowed path? +- [ ] Is the persona's *work* directory NOT in `readonlyPatterns`? +- [ ] Are secrets, `.env`, and any private dirs in `ignoredPatterns`? +- [ ] Does `permissions` cover the tool-side scope (Bash, file edits, MCP) the persona should and shouldn't have? diff --git a/.agentworkforce/workforce/personas/__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..f993be38 --- /dev/null +++ b/.agentworkforce/workforce/personas/linear-dispatcher.json @@ -0,0 +1,58 @@ +{ + "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": ".agentworkforce/workforce/personas/__assets/linear-dispatcher/skills/persona-relayfile-mount.md", + "description": "Local mount-field policy shipped with this persona pack. Read before relying on the relayfile mount: allow-list idiom, readonlyPatterns scope, and how this persona sees the .integrations tree and writes back into it." + } + ], + "harness": "claude", + "model": "claude-sonnet-4-6", + "systemPrompt": "$TASK_DESCRIPTION", + "permissions": { + "mode": "bypassPermissions" + }, + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 600 + }, + "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..8b903c67 --- /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": ".agentworkforce/workforce/personas/__assets/repo-router/skills/agentworkforce-repo-map.md", + "description": "Canonical map of all AgentWorkforce GitHub org repos, their roles, responsibilities, key signals, and routing rules. Read this first before routing any issue. Use gh repo list + gh api to verify against the live org." + }, + { + "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": ".agentworkforce/workforce/personas/__assets/repo-router/skills/persona-relayfile-mount.md", + "description": "Local mount-field policy shipped with this persona pack. Read before relying on the relayfile mount for issue context." + } + ], + "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": { diff --git a/src/main/broker.ts b/src/main/broker.ts index f493396e..84d9d5ad 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -585,6 +585,11 @@ const PERSONA_REGISTRATION_TIMEOUT_MS = 5_000 const PERSONA_REGISTRATION_STABILITY_MS = 1_000 const PERSONA_HARNESS_READY_TIMEOUT_MS = 120_000 const PERSONA_READY_PROBE_TIMEOUT_MS = 5_000 +// How often the harness-ready wait re-checks that the persona's worker is +// still registered. A workforce CLI that dies during setup (e.g. a failed +// skill install calls process.exit) would otherwise burn the entire +// PERSONA_HARNESS_READY_TIMEOUT_MS before surfacing an error. +const PERSONA_LIVENESS_POLL_MS = 2_000 const AGENTWORKFORCE_CLI_VERSION = '3.0.51' function delay(ms: number): Promise { @@ -632,6 +637,15 @@ function isWorkerStreamForAgent(event: BrokerEvent, name: string): boolean { return brokerEventString(event, 'kind') === 'worker_stream' && brokerEventString(event, 'name') === name } +const AGENT_EXIT_EVENT_KINDS = ['agent_exit', 'agent_exited', 'agent_released'] + +function isAgentExitEventForAgent(event: BrokerEvent, name: string): boolean { + return ( + AGENT_EXIT_EVENT_KINDS.includes(brokerEventString(event, 'kind') || '') && + brokerEventString(event, 'name') === name + ) +} + function brokerEventChunk(event: BrokerEvent): string { const value = (event as unknown as Record).chunk return typeof value === 'string' ? value : '' @@ -2912,6 +2926,7 @@ export class BrokerManager { ): Promise { let output = '' let timer: ReturnType | undefined + let livenessTimer: ReturnType | undefined let settled = false let resolveReady: (() => void) | undefined let rejectReady: ((error: Error) => void) | undefined @@ -2920,6 +2935,7 @@ export class BrokerManager { if (settled) return settled = true if (timer) clearTimeout(timer) + if (livenessTimer) clearInterval(livenessTimer) if (error) { rejectReady?.(error) } else { @@ -2927,7 +2943,16 @@ export class BrokerManager { } } + const exitedError = (): Error => + new Error( + `Workforce persona ${personaId} process exited before its harness became ready. Last output:\n${tailText(output) || '(no output)'}` + ) + const observe = (event: BrokerEvent): void => { + if (isAgentExitEventForAgent(event, name)) { + finish(exitedError()) + return + } if (!isWorkerStreamForAgent(event, name)) return output += brokerEventChunk(event) const failure = personaHarnessFailedFromOutput(output) @@ -2957,6 +2982,25 @@ export class BrokerManager { const unsubscribe = session.client.onEvent((event) => { observe(event) }) + // Belt-and-suspenders for exits the event stream can miss (e.g. the + // worker died between spawn and this subscription): poll registration + // and fail fast when the agent is gone instead of waiting out the full + // readiness timeout. agent_exit handling releases dead workers, so a + // missing name in listAgents means the CLI process is gone. + livenessTimer = setInterval(() => { + void session.client + .listAgents() + .then((agents) => { + if (settled) return + if (!agents.some((agent) => agent.name === name)) { + finish(exitedError()) + } + }) + .catch(() => { + // Transient broker errors are fine — the readiness timeout still + // bounds the wait. + }) + }, PERSONA_LIVENESS_POLL_MS) try { const events = this.workerStreamEvents(session, name, 1000) @@ -2969,6 +3013,7 @@ export class BrokerManager { await readyPromise } finally { if (timer) clearTimeout(timer) + if (livenessTimer) clearInterval(livenessTimer) unsubscribe() } } diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index 9d637cf1..486ab766 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -37,6 +37,8 @@ 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 }) @@ -56,6 +58,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 +197,8 @@ const mock = vi.hoisted(() => { updateMountPaths: vi.fn(async () => undefined) }, readFileCalls, + writeFileCalls, + joinWorkspaceCalls, relayClient, relayWorkspaceManager, workspaceHandle, @@ -210,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 } } @@ -322,7 +331,10 @@ describe('IntegrationsManager', () => { mock.integrationEventBridge.closeAllExcept.mockClear() 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() mock.workspaceHandle.requestJson.mockReset() mock.workspaceHandle.requestJson.mockImplementation(async (_request: { path: string }) => { @@ -374,6 +386,40 @@ 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"}' + } + ]) + 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 () => { + 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 9f6586b8..f79c8404 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -806,6 +806,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() @@ -1136,6 +1139,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.withIntegrationRemoteWriterHandle(async (handle) => { + await handle.client().writeFile({ workspaceId: handle.workspaceId, path, baseRevision: '0', content }) + }) + } + async listRemoteDirectory(projectId: string, remotePath: string): Promise { if (!this.findProject(projectId)) throw new Error(`Project not found: ${projectId}`) const path = normalizeRemoteDirectoryPath(remotePath) @@ -1541,47 +1562,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 @@ -1605,6 +1658,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/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 1e3d11da..e465d0c5 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -691,6 +691,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 037670a9..abb46f77 100644 --- a/src/renderer/src/components/issues/AttentionInbox.tsx +++ b/src/renderer/src/components/issues/AttentionInbox.tsx @@ -19,7 +19,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' @@ -29,12 +31,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' } @@ -53,6 +57,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 } @@ -155,7 +160,8 @@ function IssueOverviewCard({ expandedChat, narration, onOpen, - onToggleChat + onToggleChat, + onSpawnTeam }: { issue: IssueViewModel active: boolean @@ -163,7 +169,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() @@ -188,6 +197,11 @@ function IssueOverviewCard({ {issue.identifier} + {detectedRepo && ( + + {detectedRepo} + + )} {issue.title} @@ -237,6 +251,23 @@ function IssueOverviewCard({ )} + {issue.band === 'ready-for-agent' && onSpawnTeam && ( +
+ +
+ )} ) @@ -255,7 +286,8 @@ function IssueGroupSection({ emptyLabel, onToggleExpanded, onSelectIssue, - onToggleChat + onToggleChat, + onSpawnTeam }: { title: string subtitle?: string @@ -270,6 +302,7 @@ function IssueGroupSection({ onToggleExpanded: () => void onSelectIssue: (issueId: string) => void onToggleChat: (issueId: string) => void + onSpawnTeam?: (issue: IssueViewModel) => void }): React.ReactNode { const Chevron = expanded ? ChevronDown : ChevronRight @@ -314,6 +347,7 @@ function IssueGroupSection({ narration={narrations.get(issue.id) || issue.agentNarration || ''} onOpen={() => onSelectIssue(issue.id)} onToggleChat={() => onToggleChat(issue.id)} + onSpawnTeam={onSpawnTeam ? () => onSpawnTeam(issue) : undefined} /> )) )} @@ -497,6 +531,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 }) @@ -543,6 +578,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') const projectGroups = useMemo( @@ -577,6 +613,28 @@ export function AttentionInbox({ setNavNotice(result.message) } + async function handleSpawnTeam(issue: IssueViewModel): Promise { + 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 = { + 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 (
@@ -602,6 +660,8 @@ export function AttentionInbox({
{needsYou.length} need you · + {readyForAgent.length} ready for agent + · {inMotion.length} in motion · {settled.length} settled @@ -680,6 +740,22 @@ 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', @@ -561,7 +605,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 @@ -1055,9 +1099,14 @@ 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] = 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.test.ts b/src/renderer/src/lib/issue-scoping.test.ts new file mode 100644 index 00000000..bdc8370b --- /dev/null +++ b/src/renderer/src/lib/issue-scoping.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest' +import { detectRepo, suggestTeamSize } 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() + }) + + 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 new file mode 100644 index 00000000..b7ef9049 --- /dev/null +++ b/src/renderer/src/lib/issue-scoping.ts @@ -0,0 +1,54 @@ +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'], +} + +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 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((keyword) => hasKeyword(tokens, keyword)).length + if (score > bestScore) { + bestScore = score + bestRepo = repo + tied = false + } else if (score > 0 && score === bestScore) { + tied = true + } + } + + return tied ? null : bestRepo +} + +export function suggestTeamSize(title: string, description: string): 'solo' | 'pair' | 'swarm' { + const tokens = tokenize(`${title} ${description}`) + const length = description.length + + if (SWARM_KEYWORDS.some((keyword) => hasKeyword(tokens, keyword)) || length > 2000) { + return 'swarm' + } + 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 05cb52b7..e3d7906a 100644 --- a/src/renderer/src/lib/spawn-agent.ts +++ b/src/renderer/src/lib/spawn-agent.ts @@ -119,6 +119,120 @@ 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 +} + +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 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 { 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