@loompad/cli 0.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +96 -11
- package/CHANGELOG.md +1042 -0
- package/README.md +1473 -44
- package/THIRD_PARTY_NOTICES.md +275 -0
- package/dist/adapters/antigravity-cli.d.ts +62 -0
- package/dist/adapters/antigravity-cli.js +258 -0
- package/dist/adapters/antigravity-cli.js.map +1 -0
- package/dist/adapters/base.d.ts +61 -9
- package/dist/adapters/base.js +266 -15
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/bridges/antigravity.d.ts +9 -12
- package/dist/adapters/bridges/antigravity.js +9 -12
- package/dist/adapters/bridges/antigravity.js.map +1 -1
- package/dist/adapters/echo.d.ts +1 -0
- package/dist/adapters/echo.js +59 -3
- package/dist/adapters/echo.js.map +1 -1
- package/dist/adapters/grok.d.ts +7 -0
- package/dist/adapters/grok.js +20 -10
- package/dist/adapters/grok.js.map +1 -1
- package/dist/adapters/index.d.ts +5 -1
- package/dist/adapters/index.js +53 -7
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/model.d.ts +137 -0
- package/dist/adapters/model.js +527 -0
- package/dist/adapters/model.js.map +1 -0
- package/dist/adapters/opencode.d.ts +101 -1
- package/dist/adapters/opencode.js +874 -98
- package/dist/adapters/opencode.js.map +1 -1
- package/dist/cli/doctor.d.ts +32 -0
- package/dist/cli/doctor.js +89 -4
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +2651 -87
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/tui-model.js +3 -1
- package/dist/cli/tui-model.js.map +1 -1
- package/dist/cli/tui.js +44 -1
- package/dist/cli/tui.js.map +1 -1
- package/dist/cli/ui.d.ts +2 -0
- package/dist/cli/ui.js +13 -0
- package/dist/cli/ui.js.map +1 -1
- package/dist/core/ades.d.ts +32 -16
- package/dist/core/ades.js +57 -20
- package/dist/core/ades.js.map +1 -1
- package/dist/core/agent-state.d.ts +14 -0
- package/dist/core/agent-state.js +32 -0
- package/dist/core/agent-state.js.map +1 -0
- package/dist/core/approvals.d.ts +33 -0
- package/dist/core/approvals.js +29 -0
- package/dist/core/approvals.js.map +1 -0
- package/dist/core/backup.d.ts +19 -0
- package/dist/core/backup.js +86 -0
- package/dist/core/backup.js.map +1 -0
- package/dist/core/baton.d.ts +2 -2
- package/dist/core/baton.js.map +1 -1
- package/dist/core/brain-extract.d.ts +17 -0
- package/dist/core/brain-extract.js +28 -2
- package/dist/core/brain-extract.js.map +1 -1
- package/dist/core/brain-index.d.ts +71 -0
- package/dist/core/brain-index.js +204 -5
- package/dist/core/brain-index.js.map +1 -1
- package/dist/core/brain.d.ts +58 -2
- package/dist/core/brain.js +79 -1
- package/dist/core/brain.js.map +1 -1
- package/dist/core/chat-import.d.ts +71 -0
- package/dist/core/chat-import.js +419 -0
- package/dist/core/chat-import.js.map +1 -0
- package/dist/core/checkpoint.d.ts +107 -0
- package/dist/core/checkpoint.js +259 -0
- package/dist/core/checkpoint.js.map +1 -0
- package/dist/core/claude-cli.js +1 -1
- package/dist/core/claude-cli.js.map +1 -1
- package/dist/core/continuity/artifacts.d.ts +14 -0
- package/dist/core/continuity/artifacts.js +118 -0
- package/dist/core/continuity/artifacts.js.map +1 -0
- package/dist/core/continuity/capabilities.d.ts +65 -0
- package/dist/core/continuity/capabilities.js +129 -0
- package/dist/core/continuity/capabilities.js.map +1 -0
- package/dist/core/continuity/contracts.d.ts +352 -0
- package/dist/core/continuity/contracts.js +117 -0
- package/dist/core/continuity/contracts.js.map +1 -0
- package/dist/core/continuity/engine.d.ts +55 -0
- package/dist/core/continuity/engine.js +504 -0
- package/dist/core/continuity/engine.js.map +1 -0
- package/dist/core/continuity/store.d.ts +73 -0
- package/dist/core/continuity/store.js +438 -0
- package/dist/core/continuity/store.js.map +1 -0
- package/dist/core/conversations.d.ts +19 -0
- package/dist/core/conversations.js +87 -0
- package/dist/core/conversations.js.map +1 -0
- package/dist/core/crew-coordination.d.ts +6 -0
- package/dist/core/crew-coordination.js +24 -0
- package/dist/core/crew-coordination.js.map +1 -0
- package/dist/core/crew-policy.d.ts +18 -0
- package/dist/core/crew-policy.js +40 -0
- package/dist/core/crew-policy.js.map +1 -0
- package/dist/core/crew-protocol.d.ts +64 -0
- package/dist/core/crew-protocol.js +146 -0
- package/dist/core/crew-protocol.js.map +1 -0
- package/dist/core/crew-requests.d.ts +14 -0
- package/dist/core/crew-requests.js +22 -0
- package/dist/core/crew-requests.js.map +1 -0
- package/dist/core/crew.d.ts +395 -0
- package/dist/core/crew.js +1857 -0
- package/dist/core/crew.js.map +1 -0
- package/dist/core/digest.d.ts +40 -0
- package/dist/core/digest.js +94 -0
- package/dist/core/digest.js.map +1 -0
- package/dist/core/distill.js +3 -5
- package/dist/core/distill.js.map +1 -1
- package/dist/core/eventlog.d.ts +27 -25
- package/dist/core/eventlog.js +97 -150
- package/dist/core/eventlog.js.map +1 -1
- package/dist/core/events/contracts.d.ts +51 -0
- package/dist/core/events/contracts.js +2 -0
- package/dist/core/events/contracts.js.map +1 -0
- package/dist/core/events/jsonl-store.d.ts +23 -0
- package/dist/core/events/jsonl-store.js +90 -0
- package/dist/core/events/jsonl-store.js.map +1 -0
- package/dist/core/events/sqlite-store.d.ts +37 -0
- package/dist/core/events/sqlite-store.js +215 -0
- package/dist/core/events/sqlite-store.js.map +1 -0
- package/dist/core/forecast.d.ts +44 -0
- package/dist/core/forecast.js +46 -0
- package/dist/core/forecast.js.map +1 -0
- package/dist/core/free-pool.d.ts +65 -0
- package/dist/core/free-pool.js +171 -0
- package/dist/core/free-pool.js.map +1 -0
- package/dist/core/git.d.ts +30 -0
- package/dist/core/git.js +50 -2
- package/dist/core/git.js.map +1 -1
- package/dist/core/github-events.d.ts +53 -0
- package/dist/core/github-events.js +227 -0
- package/dist/core/github-events.js.map +1 -0
- package/dist/core/goal-lanes.d.ts +53 -0
- package/dist/core/goal-lanes.js +99 -0
- package/dist/core/goal-lanes.js.map +1 -0
- package/dist/core/hosted.d.ts +27 -0
- package/dist/core/hosted.js +50 -0
- package/dist/core/hosted.js.map +1 -0
- package/dist/core/http-server.d.ts +5 -0
- package/dist/core/http-server.js +8 -0
- package/dist/core/http-server.js.map +1 -0
- package/dist/core/invite-link.d.ts +31 -0
- package/dist/core/invite-link.js +45 -0
- package/dist/core/invite-link.js.map +1 -0
- package/dist/core/loops.d.ts +37 -0
- package/dist/core/loops.js +81 -0
- package/dist/core/loops.js.map +1 -0
- package/dist/core/mcp-catalog.d.ts +182 -0
- package/dist/core/mcp-catalog.js +431 -0
- package/dist/core/mcp-catalog.js.map +1 -0
- package/dist/core/mcp.d.ts +87 -0
- package/dist/core/mcp.js +181 -0
- package/dist/core/mcp.js.map +1 -0
- package/dist/core/memory.d.ts +1 -0
- package/dist/core/memory.js +5 -2
- package/dist/core/memory.js.map +1 -1
- package/dist/core/model-tools.d.ts +97 -0
- package/dist/core/model-tools.js +388 -0
- package/dist/core/model-tools.js.map +1 -0
- package/dist/core/notify.js +19 -1
- package/dist/core/notify.js.map +1 -1
- package/dist/core/orchestra.d.ts +635 -0
- package/dist/core/orchestra.js +2120 -0
- package/dist/core/orchestra.js.map +1 -0
- package/dist/core/permissions.d.ts +56 -0
- package/dist/core/permissions.js +122 -0
- package/dist/core/permissions.js.map +1 -0
- package/dist/core/preview-proxy.d.ts +70 -0
- package/dist/core/preview-proxy.js +563 -0
- package/dist/core/preview-proxy.js.map +1 -0
- package/dist/core/preview-shot.d.ts +46 -0
- package/dist/core/preview-shot.js +109 -0
- package/dist/core/preview-shot.js.map +1 -0
- package/dist/core/process.d.ts +36 -0
- package/dist/core/process.js +168 -0
- package/dist/core/process.js.map +1 -0
- package/dist/core/prompt-queue.d.ts +136 -0
- package/dist/core/prompt-queue.js +269 -0
- package/dist/core/prompt-queue.js.map +1 -0
- package/dist/core/prompts.d.ts +53 -0
- package/dist/core/prompts.js +127 -0
- package/dist/core/prompts.js.map +1 -0
- package/dist/core/proof.d.ts +82 -0
- package/dist/core/proof.js +216 -0
- package/dist/core/proof.js.map +1 -0
- package/dist/core/provider-text.d.ts +23 -0
- package/dist/core/provider-text.js +66 -0
- package/dist/core/provider-text.js.map +1 -0
- package/dist/core/providers.d.ts +152 -0
- package/dist/core/providers.js +343 -0
- package/dist/core/providers.js.map +1 -0
- package/dist/core/quota.d.ts +33 -0
- package/dist/core/quota.js +74 -0
- package/dist/core/quota.js.map +1 -0
- package/dist/core/recipes.d.ts +50 -0
- package/dist/core/recipes.js +99 -0
- package/dist/core/recipes.js.map +1 -0
- package/dist/core/registry.d.ts +54 -0
- package/dist/core/registry.js +62 -5
- package/dist/core/registry.js.map +1 -1
- package/dist/core/relay-client.d.ts +67 -0
- package/dist/core/relay-client.js +184 -0
- package/dist/core/relay-client.js.map +1 -0
- package/dist/core/relay-protocol.d.ts +140 -0
- package/dist/core/relay-protocol.js +189 -0
- package/dist/core/relay-protocol.js.map +1 -0
- package/dist/core/replay.d.ts +18 -0
- package/dist/core/replay.js +191 -0
- package/dist/core/replay.js.map +1 -0
- package/dist/core/review-triage.d.ts +41 -0
- package/dist/core/review-triage.js +124 -0
- package/dist/core/review-triage.js.map +1 -0
- package/dist/core/router.js +2 -2
- package/dist/core/router.js.map +1 -1
- package/dist/core/routes.d.ts +39 -3
- package/dist/core/routes.js +137 -4
- package/dist/core/routes.js.map +1 -1
- package/dist/core/run-command.d.ts +14 -0
- package/dist/core/run-command.js +45 -0
- package/dist/core/run-command.js.map +1 -0
- package/dist/core/runner-setup.d.ts +36 -0
- package/dist/core/runner-setup.js +114 -0
- package/dist/core/runner-setup.js.map +1 -0
- package/dist/core/search.js +0 -0
- package/dist/core/search.js.map +1 -1
- package/dist/core/semantic.d.ts +92 -0
- package/dist/core/semantic.js +227 -0
- package/dist/core/semantic.js.map +1 -0
- package/dist/core/servers.d.ts +115 -0
- package/dist/core/servers.js +305 -0
- package/dist/core/servers.js.map +1 -0
- package/dist/core/setup.js +64 -6
- package/dist/core/setup.js.map +1 -1
- package/dist/core/shield.d.ts +17 -0
- package/dist/core/shield.js +81 -0
- package/dist/core/shield.js.map +1 -0
- package/dist/core/shift.d.ts +69 -0
- package/dist/core/shift.js +120 -0
- package/dist/core/shift.js.map +1 -0
- package/dist/core/skill-install.d.ts +125 -0
- package/dist/core/skill-install.js +328 -0
- package/dist/core/skill-install.js.map +1 -0
- package/dist/core/skills.d.ts +116 -0
- package/dist/core/skills.js +223 -0
- package/dist/core/skills.js.map +1 -0
- package/dist/core/step-conditions.d.ts +65 -0
- package/dist/core/step-conditions.js +102 -0
- package/dist/core/step-conditions.js.map +1 -0
- package/dist/core/subagents.d.ts +45 -0
- package/dist/core/subagents.js +90 -0
- package/dist/core/subagents.js.map +1 -0
- package/dist/core/team-canon.d.ts +42 -0
- package/dist/core/team-canon.js +121 -0
- package/dist/core/team-canon.js.map +1 -0
- package/dist/core/team-crypto.d.ts +97 -0
- package/dist/core/team-crypto.js +175 -0
- package/dist/core/team-crypto.js.map +1 -0
- package/dist/core/team-hub.d.ts +514 -0
- package/dist/core/team-hub.js +964 -0
- package/dist/core/team-hub.js.map +1 -0
- package/dist/core/team-landing.d.ts +239 -0
- package/dist/core/team-landing.js +504 -0
- package/dist/core/team-landing.js.map +1 -0
- package/dist/core/team-leases.d.ts +44 -0
- package/dist/core/team-leases.js +145 -0
- package/dist/core/team-leases.js.map +1 -0
- package/dist/core/team-memory.d.ts +42 -0
- package/dist/core/team-memory.js +84 -0
- package/dist/core/team-memory.js.map +1 -0
- package/dist/core/team-policy.d.ts +85 -0
- package/dist/core/team-policy.js +191 -0
- package/dist/core/team-policy.js.map +1 -0
- package/dist/core/thread-md.d.ts +6 -0
- package/dist/core/thread-md.js +42 -0
- package/dist/core/thread-md.js.map +1 -0
- package/dist/core/turn-stats.d.ts +61 -0
- package/dist/core/turn-stats.js +124 -0
- package/dist/core/turn-stats.js.map +1 -0
- package/dist/core/updater.d.ts +73 -0
- package/dist/core/updater.js +153 -0
- package/dist/core/updater.js.map +1 -0
- package/dist/core/usage.d.ts +75 -0
- package/dist/core/usage.js +163 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/core/windows-jobs.d.ts +15 -0
- package/dist/core/windows-jobs.js +170 -0
- package/dist/core/windows-jobs.js.map +1 -0
- package/dist/core/worktree-merge.d.ts +57 -0
- package/dist/core/worktree-merge.js +123 -0
- package/dist/core/worktree-merge.js.map +1 -0
- package/dist/core/worktree.d.ts +5 -0
- package/dist/core/worktree.js +45 -5
- package/dist/core/worktree.js.map +1 -1
- package/dist/core/worktrees.d.ts +6 -0
- package/dist/core/worktrees.js +21 -0
- package/dist/core/worktrees.js.map +1 -0
- package/dist/daemon/app-page.d.ts +0 -17
- package/dist/daemon/app-page.js +10 -6346
- package/dist/daemon/app-page.js.map +1 -1
- package/dist/daemon/auth.d.ts +24 -3
- package/dist/daemon/auth.js +66 -7
- package/dist/daemon/auth.js.map +1 -1
- package/dist/daemon/board.d.ts +7 -0
- package/dist/daemon/board.js +12 -1
- package/dist/daemon/board.js.map +1 -1
- package/dist/daemon/brand-icons.d.ts +6 -1
- package/dist/daemon/brand-icons.js +11 -3
- package/dist/daemon/brand-icons.js.map +1 -1
- package/dist/daemon/client.d.ts +665 -1
- package/dist/daemon/client.js +381 -3
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/delivery.d.ts +34 -0
- package/dist/daemon/delivery.js +63 -0
- package/dist/daemon/delivery.js.map +1 -0
- package/dist/daemon/deploys.d.ts +62 -0
- package/dist/daemon/deploys.js +159 -0
- package/dist/daemon/deploys.js.map +1 -0
- package/dist/daemon/landing.d.ts +192 -0
- package/dist/daemon/landing.js +992 -0
- package/dist/daemon/landing.js.map +1 -0
- package/dist/daemon/onboard.d.ts +150 -0
- package/dist/daemon/onboard.js +390 -0
- package/dist/daemon/onboard.js.map +1 -0
- package/dist/daemon/proof.d.ts +70 -0
- package/dist/daemon/proof.js +172 -0
- package/dist/daemon/proof.js.map +1 -0
- package/dist/daemon/push.d.ts +19 -1
- package/dist/daemon/push.js +90 -3
- package/dist/daemon/push.js.map +1 -1
- package/dist/daemon/relay.d.ts +82 -0
- package/dist/daemon/relay.js +351 -0
- package/dist/daemon/relay.js.map +1 -0
- package/dist/daemon/routes/agent-discovery.d.ts +4 -0
- package/dist/daemon/routes/agent-discovery.js +29 -0
- package/dist/daemon/routes/agent-discovery.js.map +1 -0
- package/dist/daemon/routes/agent-settings.d.ts +4 -0
- package/dist/daemon/routes/agent-settings.js +94 -0
- package/dist/daemon/routes/agent-settings.js.map +1 -0
- package/dist/daemon/routes/agents.d.ts +4 -0
- package/dist/daemon/routes/agents.js +105 -0
- package/dist/daemon/routes/agents.js.map +1 -0
- package/dist/daemon/routes/alerts.d.ts +4 -0
- package/dist/daemon/routes/alerts.js +137 -0
- package/dist/daemon/routes/alerts.js.map +1 -0
- package/dist/daemon/routes/approvals.d.ts +4 -0
- package/dist/daemon/routes/approvals.js +40 -0
- package/dist/daemon/routes/approvals.js.map +1 -0
- package/dist/daemon/routes/assets.d.ts +3 -0
- package/dist/daemon/routes/assets.js +64 -0
- package/dist/daemon/routes/assets.js.map +1 -0
- package/dist/daemon/routes/board.d.ts +4 -0
- package/dist/daemon/routes/board.js +147 -0
- package/dist/daemon/routes/board.js.map +1 -0
- package/dist/daemon/routes/brain.d.ts +4 -0
- package/dist/daemon/routes/brain.js +270 -0
- package/dist/daemon/routes/brain.js.map +1 -0
- package/dist/daemon/routes/chats.d.ts +4 -0
- package/dist/daemon/routes/chats.js +249 -0
- package/dist/daemon/routes/chats.js.map +1 -0
- package/dist/daemon/routes/clients.d.ts +4 -0
- package/dist/daemon/routes/clients.js +49 -0
- package/dist/daemon/routes/clients.js.map +1 -0
- package/dist/daemon/routes/cloud.d.ts +4 -0
- package/dist/daemon/routes/cloud.js +46 -0
- package/dist/daemon/routes/cloud.js.map +1 -0
- package/dist/daemon/routes/config.d.ts +4 -0
- package/dist/daemon/routes/config.js +35 -0
- package/dist/daemon/routes/config.js.map +1 -0
- package/dist/daemon/routes/context.d.ts +51 -0
- package/dist/daemon/routes/context.js +2 -0
- package/dist/daemon/routes/context.js.map +1 -0
- package/dist/daemon/routes/crews.d.ts +4 -0
- package/dist/daemon/routes/crews.js +149 -0
- package/dist/daemon/routes/crews.js.map +1 -0
- package/dist/daemon/routes/files.d.ts +3 -0
- package/dist/daemon/routes/files.js +253 -0
- package/dist/daemon/routes/files.js.map +1 -0
- package/dist/daemon/routes/git.d.ts +4 -0
- package/dist/daemon/routes/git.js +96 -0
- package/dist/daemon/routes/git.js.map +1 -0
- package/dist/daemon/routes/history.d.ts +4 -0
- package/dist/daemon/routes/history.js +101 -0
- package/dist/daemon/routes/history.js.map +1 -0
- package/dist/daemon/routes/integrations-project.d.ts +3 -0
- package/dist/daemon/routes/integrations-project.js +103 -0
- package/dist/daemon/routes/integrations-project.js.map +1 -0
- package/dist/daemon/routes/integrations.d.ts +3 -0
- package/dist/daemon/routes/integrations.js +111 -0
- package/dist/daemon/routes/integrations.js.map +1 -0
- package/dist/daemon/routes/logs.d.ts +3 -0
- package/dist/daemon/routes/logs.js +40 -0
- package/dist/daemon/routes/logs.js.map +1 -0
- package/dist/daemon/routes/messages.d.ts +4 -0
- package/dist/daemon/routes/messages.js +60 -0
- package/dist/daemon/routes/messages.js.map +1 -0
- package/dist/daemon/routes/observability.d.ts +4 -0
- package/dist/daemon/routes/observability.js +247 -0
- package/dist/daemon/routes/observability.js.map +1 -0
- package/dist/daemon/routes/onboard.d.ts +12 -0
- package/dist/daemon/routes/onboard.js +67 -0
- package/dist/daemon/routes/onboard.js.map +1 -0
- package/dist/daemon/routes/orchestra.d.ts +4 -0
- package/dist/daemon/routes/orchestra.js +110 -0
- package/dist/daemon/routes/orchestra.js.map +1 -0
- package/dist/daemon/routes/pairing.d.ts +4 -0
- package/dist/daemon/routes/pairing.js +155 -0
- package/dist/daemon/routes/pairing.js.map +1 -0
- package/dist/daemon/routes/preview.d.ts +24 -0
- package/dist/daemon/routes/preview.js +122 -0
- package/dist/daemon/routes/preview.js.map +1 -0
- package/dist/daemon/routes/project-actions.d.ts +4 -0
- package/dist/daemon/routes/project-actions.js +53 -0
- package/dist/daemon/routes/project-actions.js.map +1 -0
- package/dist/daemon/routes/project-team.d.ts +4 -0
- package/dist/daemon/routes/project-team.js +201 -0
- package/dist/daemon/routes/project-team.js.map +1 -0
- package/dist/daemon/routes/projects.d.ts +14 -0
- package/dist/daemon/routes/projects.js +138 -0
- package/dist/daemon/routes/projects.js.map +1 -0
- package/dist/daemon/routes/prompts.d.ts +4 -0
- package/dist/daemon/routes/prompts.js +44 -0
- package/dist/daemon/routes/prompts.js.map +1 -0
- package/dist/daemon/routes/proof.d.ts +4 -0
- package/dist/daemon/routes/proof.js +22 -0
- package/dist/daemon/routes/proof.js.map +1 -0
- package/dist/daemon/routes/providers.d.ts +3 -0
- package/dist/daemon/routes/providers.js +60 -0
- package/dist/daemon/routes/providers.js.map +1 -0
- package/dist/daemon/routes/public.d.ts +4 -0
- package/dist/daemon/routes/public.js +64 -0
- package/dist/daemon/routes/public.js.map +1 -0
- package/dist/daemon/routes/queue.d.ts +4 -0
- package/dist/daemon/routes/queue.js +140 -0
- package/dist/daemon/routes/queue.js.map +1 -0
- package/dist/daemon/routes/routing.d.ts +4 -0
- package/dist/daemon/routes/routing.js +61 -0
- package/dist/daemon/routes/routing.js.map +1 -0
- package/dist/daemon/routes/runtime-handler.d.ts +2 -0
- package/dist/daemon/routes/runtime-handler.js +63 -0
- package/dist/daemon/routes/runtime-handler.js.map +1 -0
- package/dist/daemon/routes/search.d.ts +4 -0
- package/dist/daemon/routes/search.js +17 -0
- package/dist/daemon/routes/search.js.map +1 -0
- package/dist/daemon/routes/servers.d.ts +4 -0
- package/dist/daemon/routes/servers.js +133 -0
- package/dist/daemon/routes/servers.js.map +1 -0
- package/dist/daemon/routes/setup.d.ts +3 -0
- package/dist/daemon/routes/setup.js +50 -0
- package/dist/daemon/routes/setup.js.map +1 -0
- package/dist/daemon/routes/shift.d.ts +4 -0
- package/dist/daemon/routes/shift.js +30 -0
- package/dist/daemon/routes/shift.js.map +1 -0
- package/dist/daemon/routes/specs.d.ts +4 -0
- package/dist/daemon/routes/specs.js +57 -0
- package/dist/daemon/routes/specs.js.map +1 -0
- package/dist/daemon/routes/task-delivery.d.ts +4 -0
- package/dist/daemon/routes/task-delivery.js +38 -0
- package/dist/daemon/routes/task-delivery.js.map +1 -0
- package/dist/daemon/routes/team.d.ts +4 -0
- package/dist/daemon/routes/team.js +193 -0
- package/dist/daemon/routes/team.js.map +1 -0
- package/dist/daemon/routes/terminals.d.ts +4 -0
- package/dist/daemon/routes/terminals.js +87 -0
- package/dist/daemon/routes/terminals.js.map +1 -0
- package/dist/daemon/routes/tools.d.ts +4 -0
- package/dist/daemon/routes/tools.js +180 -0
- package/dist/daemon/routes/tools.js.map +1 -0
- package/dist/daemon/routes/updates.d.ts +4 -0
- package/dist/daemon/routes/updates.js +89 -0
- package/dist/daemon/routes/updates.js.map +1 -0
- package/dist/daemon/routes/voice.d.ts +4 -0
- package/dist/daemon/routes/voice.js +53 -0
- package/dist/daemon/routes/voice.js.map +1 -0
- package/dist/daemon/runner.d.ts +186 -0
- package/dist/daemon/runner.js +612 -0
- package/dist/daemon/runner.js.map +1 -0
- package/dist/daemon/runtime/accounting.d.ts +130 -0
- package/dist/daemon/runtime/accounting.js +303 -0
- package/dist/daemon/runtime/accounting.js.map +1 -0
- package/dist/daemon/runtime/agents.d.ts +16 -0
- package/dist/daemon/runtime/agents.js +71 -0
- package/dist/daemon/runtime/agents.js.map +1 -0
- package/dist/daemon/runtime/briefings.d.ts +134 -0
- package/dist/daemon/runtime/briefings.js +329 -0
- package/dist/daemon/runtime/briefings.js.map +1 -0
- package/dist/daemon/runtime/native-usage.d.ts +21 -0
- package/dist/daemon/runtime/native-usage.js +71 -0
- package/dist/daemon/runtime/native-usage.js.map +1 -0
- package/dist/daemon/runtime/queue.d.ts +94 -0
- package/dist/daemon/runtime/queue.js +222 -0
- package/dist/daemon/runtime/queue.js.map +1 -0
- package/dist/daemon/runtime/relay.d.ts +93 -0
- package/dist/daemon/runtime/relay.js +240 -0
- package/dist/daemon/runtime/relay.js.map +1 -0
- package/dist/daemon/runtime/turns.d.ts +202 -0
- package/dist/daemon/runtime/turns.js +663 -0
- package/dist/daemon/runtime/turns.js.map +1 -0
- package/dist/daemon/runtime-support.d.ts +76 -0
- package/dist/daemon/runtime-support.js +134 -0
- package/dist/daemon/runtime-support.js.map +1 -0
- package/dist/daemon/runtime.d.ts +739 -70
- package/dist/daemon/runtime.js +2751 -470
- package/dist/daemon/runtime.js.map +1 -1
- package/dist/daemon/server.d.ts +82 -55
- package/dist/daemon/server.js +674 -1435
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/shift.d.ts +70 -0
- package/dist/daemon/shift.js +170 -0
- package/dist/daemon/shift.js.map +1 -0
- package/dist/daemon/specs.d.ts +92 -0
- package/dist/daemon/specs.js +285 -0
- package/dist/daemon/specs.js.map +1 -0
- package/dist/daemon/system.d.ts +208 -0
- package/dist/daemon/system.js +557 -0
- package/dist/daemon/system.js.map +1 -0
- package/dist/daemon/tasks.js +1 -1
- package/dist/daemon/team-brain.d.ts +136 -0
- package/dist/daemon/team-brain.js +659 -0
- package/dist/daemon/team-brain.js.map +1 -0
- package/dist/daemon/team-coordinator.d.ts +78 -0
- package/dist/daemon/team-coordinator.js +408 -0
- package/dist/daemon/team-coordinator.js.map +1 -0
- package/dist/daemon/team.d.ts +339 -0
- package/dist/daemon/team.js +1433 -0
- package/dist/daemon/team.js.map +1 -0
- package/dist/daemon/terminals.d.ts +22 -3
- package/dist/daemon/terminals.js +236 -16
- package/dist/daemon/terminals.js.map +1 -1
- package/dist/hub/client.d.ts +120 -0
- package/dist/hub/client.js +228 -0
- package/dist/hub/client.js.map +1 -0
- package/dist/hub/server.d.ts +32 -0
- package/dist/hub/server.js +180 -0
- package/dist/hub/server.js.map +1 -0
- package/dist/hub/supabase-client.d.ts +220 -0
- package/dist/hub/supabase-client.js +843 -0
- package/dist/hub/supabase-client.js.map +1 -0
- package/dist/observability/ask.d.ts +91 -0
- package/dist/observability/ask.js +179 -0
- package/dist/observability/ask.js.map +1 -0
- package/dist/observability/clickhouse.d.ts +33 -0
- package/dist/observability/clickhouse.js +48 -0
- package/dist/observability/clickhouse.js.map +1 -0
- package/dist/observability/decisions.d.ts +176 -0
- package/dist/observability/decisions.js +377 -0
- package/dist/observability/decisions.js.map +1 -0
- package/dist/observability/index.d.ts +89 -0
- package/dist/observability/index.js +348 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/insights.d.ts +151 -0
- package/dist/observability/insights.js +319 -0
- package/dist/observability/insights.js.map +1 -0
- package/dist/observability/logs-query.d.ts +82 -0
- package/dist/observability/logs-query.js +102 -0
- package/dist/observability/logs-query.js.map +1 -0
- package/dist/observability/logs.d.ts +72 -0
- package/dist/observability/logs.js +295 -0
- package/dist/observability/logs.js.map +1 -0
- package/dist/observability/metrics.d.ts +86 -0
- package/dist/observability/metrics.js +303 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/observability/otlp.d.ts +127 -0
- package/dist/observability/otlp.js +242 -0
- package/dist/observability/otlp.js.map +1 -0
- package/dist/observability/snapshots.d.ts +43 -0
- package/dist/observability/snapshots.js +129 -0
- package/dist/observability/snapshots.js.map +1 -0
- package/dist/observability/triage.d.ts +49 -0
- package/dist/observability/triage.js +284 -0
- package/dist/observability/triage.js.map +1 -0
- package/dist/providers/adapter.d.ts +50 -0
- package/dist/providers/adapter.js +38 -0
- package/dist/providers/adapter.js.map +1 -0
- package/dist/providers/agent.d.ts +110 -0
- package/dist/providers/agent.js +487 -0
- package/dist/providers/agent.js.map +1 -0
- package/dist/providers/approvals.d.ts +24 -0
- package/dist/providers/approvals.js +78 -0
- package/dist/providers/approvals.js.map +1 -0
- package/dist/providers/attachments.d.ts +20 -0
- package/dist/providers/attachments.js +51 -0
- package/dist/providers/attachments.js.map +1 -0
- package/dist/providers/claude/adapter.d.ts +99 -0
- package/dist/providers/claude/adapter.js +790 -0
- package/dist/providers/claude/adapter.js.map +1 -0
- package/dist/providers/codex/adapter.d.ts +113 -0
- package/dist/providers/codex/adapter.js +881 -0
- package/dist/providers/codex/adapter.js.map +1 -0
- package/dist/providers/codex/instructions.d.ts +11 -0
- package/dist/providers/codex/instructions.js +154 -0
- package/dist/providers/codex/instructions.js.map +1 -0
- package/dist/providers/codex/rpc.d.ts +36 -0
- package/dist/providers/codex/rpc.js +104 -0
- package/dist/providers/codex/rpc.js.map +1 -0
- package/dist/providers/contracts.d.ts +339 -0
- package/dist/providers/contracts.js +19 -0
- package/dist/providers/contracts.js.map +1 -0
- package/dist/providers/directory.d.ts +70 -0
- package/dist/providers/directory.js +95 -0
- package/dist/providers/directory.js.map +1 -0
- package/dist/providers/errors.d.ts +47 -0
- package/dist/providers/errors.js +24 -0
- package/dist/providers/errors.js.map +1 -0
- package/dist/providers/index.d.ts +18 -0
- package/dist/providers/index.js +19 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/ingestion.d.ts +80 -0
- package/dist/providers/ingestion.js +330 -0
- package/dist/providers/ingestion.js.map +1 -0
- package/dist/providers/live.d.ts +22 -0
- package/dist/providers/live.js +56 -0
- package/dist/providers/live.js.map +1 -0
- package/dist/providers/process.d.ts +29 -0
- package/dist/providers/process.js +71 -0
- package/dist/providers/process.js.map +1 -0
- package/dist/providers/reaper.d.ts +33 -0
- package/dist/providers/reaper.js +65 -0
- package/dist/providers/reaper.js.map +1 -0
- package/dist/providers/service.d.ts +87 -0
- package/dist/providers/service.js +256 -0
- package/dist/providers/service.js.map +1 -0
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +1 -1
- package/dist/sdk.js.map +1 -1
- package/dist/types.d.ts +385 -6
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +7 -0
- package/dist/version.js +16 -0
- package/dist/version.js.map +1 -0
- package/dist/web/app.css +4643 -0
- package/dist/web/app.js +22640 -0
- package/dist/web/app.js.map +7 -0
- package/dist/web/shell.html +39 -0
- package/docs/README.md +21 -0
- package/docs/SETUP.md +10 -0
- package/docs/adapters.md +21 -8
- package/docs/brain-continuity.md +186 -0
- package/docs/cloud.md +108 -0
- package/docs/crews.md +62 -0
- package/docs/design-system.md +31 -2
- package/docs/integration-notes.md +128 -20
- package/docs/loompad.md +105 -0
- package/docs/observability.md +220 -0
- package/docs/play-store.md +123 -0
- package/docs/proposals/agent-teams.md +249 -0
- package/docs/proposals/brain-continuity-implementation.md +468 -0
- package/docs/proposals/brain-session-continuity.md +623 -0
- package/docs/proposals/t3code-port.md +81 -0
- package/docs/proposals/t3code-reference.md +95 -0
- package/docs/refactoring/AGENT-TEAMS-PROGRESS.md +88 -0
- package/docs/refactoring/BOUNDARIES.md +120 -0
- package/docs/refactoring/BRAIN-AUDIT.md +176 -0
- package/docs/refactoring/BRAIN-NOTES.md +390 -0
- package/docs/refactoring/BRAIN-TODO.md +214 -0
- package/docs/refactoring/NOTES.md +129 -0
- package/docs/refactoring/T3-PORT-NOTES.md +165 -0
- package/docs/refactoring/T3-PORT-TODO.md +123 -0
- package/docs/refactoring/TODO.md +46 -0
- package/docs/refactoring/brain-benchmark-2026-09-28.json +48 -0
- package/docs/refactoring/brain-benchmark-audit-2026-09-28.json +48 -0
- package/docs/release-notes/v0.2.0.md +35 -0
- package/docs/release-notes/v0.2.1.md +59 -0
- package/docs/release-notes/v0.2.3.md +77 -0
- package/docs/release-notes/v0.2.4.md +128 -0
- package/docs/release-notes/v0.2.5.md +153 -0
- package/docs/release-notes/v0.2.6.md +62 -0
- package/docs/release-notes/v1.0.0.md +120 -0
- package/docs/release-notes/v1.0.1.md +77 -0
- package/docs/release-notes/v1.1.0.md +71 -0
- package/docs/release-notes/v1.1.1.md +38 -0
- package/docs/teams-architecture.md +697 -0
- package/docs/teams.md +171 -0
- package/docs/windows.md +85 -0
- package/package.json +22 -9
- package/dist/adapters/claude-code.d.ts +0 -26
- package/dist/adapters/claude-code.js +0 -209
- package/dist/adapters/claude-code.js.map +0 -1
- package/dist/adapters/codex.d.ts +0 -51
- package/dist/adapters/codex.js +0 -291
- package/dist/adapters/codex.js.map +0 -1
- package/docs/img/board-linear.png +0 -0
- package/docs/img/board-projects.png +0 -0
- package/docs/img/brain.png +0 -0
- package/docs/img/settings.png +0 -0
- package/docs/img/workspace.png +0 -0
|
@@ -0,0 +1,2120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestra — one orchestrator agent, many worker agents, in parallel.
|
|
3
|
+
*
|
|
4
|
+
* A route passes one baton down a line; a subtask fans out but never reports
|
|
5
|
+
* back. Orchestra is the missing shape: an orchestrator agent (any adapter —
|
|
6
|
+
* Claude Code, Codex, Antigravity, Grok, OpenCode) turns a goal into a task
|
|
7
|
+
* graph, Loom runs every ready task at once on whichever worker agents the
|
|
8
|
+
* plan names, each in its own thread and its own git worktree, merges the
|
|
9
|
+
* finished work into one integration branch, and hands the orchestrator the
|
|
10
|
+
* results to review — accept, follow up, spawn more, or finish.
|
|
11
|
+
*
|
|
12
|
+
* Design lineage, credited: the orchestrator/worker split, the "coordinate,
|
|
13
|
+
* never implement" orchestrator rules and the worktree-per-worker isolation
|
|
14
|
+
* follow Agent Orchestrator (github.com/Untrivial-ai/agent-orchestrator,
|
|
15
|
+
* Apache-2.0). What differs, on purpose:
|
|
16
|
+
*
|
|
17
|
+
* - **The orchestrator never needs a shell tool.** AO's orchestrator drives a
|
|
18
|
+
* CLI (`ao spawn`, `ao send`) from its terminal, which fails for agents that
|
|
19
|
+
* run sandboxed or can't execute commands headless. Here it answers in a
|
|
20
|
+
* fenced ```loom block of JSON actions that Loom parses — so ANY agent can
|
|
21
|
+
* orchestrate, including ones with no tools at all.
|
|
22
|
+
* - **A real task graph.** Tasks declare `dependsOn`; a task starts only when
|
|
23
|
+
* its dependencies merged, and it branches from the integration tip so it
|
|
24
|
+
* sees their work.
|
|
25
|
+
* - **Mixed fleets.** Each task names its worker; three Codex tasks and two
|
|
26
|
+
* Antigravity tasks run side by side as separate CLI sessions.
|
|
27
|
+
*
|
|
28
|
+
* Everything is observable: every step is an `orchestra` event in the project
|
|
29
|
+
* log (so the web app, the phone and the CLI all watch the same run), and each
|
|
30
|
+
* task's agent output streams into that task's own chat.
|
|
31
|
+
*/
|
|
32
|
+
import { execFile } from "./process.js";
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { createWorktree } from "./worktrees.js";
|
|
36
|
+
import { loomHome } from "./registry.js";
|
|
37
|
+
import { blockedBy } from "./goal-lanes.js";
|
|
38
|
+
import { cutStack } from "./team-landing.js";
|
|
39
|
+
/** How much of a goal's budget may go before it says something. */
|
|
40
|
+
export const BUDGET_WARN_AT = 0.8;
|
|
41
|
+
/**
|
|
42
|
+
* Pull the actions out of an orchestrator reply.
|
|
43
|
+
*
|
|
44
|
+
* Accepts the last ```loom fence (the documented form), falling back to the
|
|
45
|
+
* last ```json fence holding an `actions` array, then to a bare object — models
|
|
46
|
+
* drift, and a plan lost to a fence label is a wasted round. Returns null when
|
|
47
|
+
* nothing parses, so the caller can ask again rather than guess.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* How much of an agent's turn is kept in memory, and why cutting it is delicate.
|
|
51
|
+
*
|
|
52
|
+
* The orchestrator's answer is parsed from this text, and the part that matters
|
|
53
|
+
* — the ```loom block — sits at the END of a reply whose prose can be long. The
|
|
54
|
+
* old cap kept the last 20,000 characters, which on any real plan threw away
|
|
55
|
+
* the OPENING fence while keeping the closing one. The parser then saw an
|
|
56
|
+
* unterminated block, reported that the reply had no actions, and the whole
|
|
57
|
+
* plan was silently discarded (#104). Measured on one real run: seven
|
|
58
|
+
* consecutive plans of 6, 5, 5, 5, 5, 8 and 8 tasks, every one of them parsed
|
|
59
|
+
* perfectly from the logged event and every one destroyed by the cap. The
|
|
60
|
+
* better the orchestrator planned, the more certainly its plan was binned.
|
|
61
|
+
*
|
|
62
|
+
* So two changes. The budget is large enough that no plausible reply reaches
|
|
63
|
+
* it — a turn is bounded by the model's own output limit long before this.
|
|
64
|
+
* And when it IS reached, the cut is made at the start of the last actions
|
|
65
|
+
* block rather than blindly at a character offset, so the payload can never be
|
|
66
|
+
* decapitated by the thing that is meant to be protecting memory.
|
|
67
|
+
*/
|
|
68
|
+
export const TURN_TEXT_LIMIT = 400_000;
|
|
69
|
+
export function capTurnText(text, limit = TURN_TEXT_LIMIT) {
|
|
70
|
+
if (text.length <= limit)
|
|
71
|
+
return text;
|
|
72
|
+
// Where the parseable payload begins. Keeping from here guarantees the
|
|
73
|
+
// opening fence survives, whatever that costs us in preceding prose.
|
|
74
|
+
const starts = [text.lastIndexOf("```loom"), text.lastIndexOf('{"actions"')].filter((i) => i >= 0);
|
|
75
|
+
const keepFrom = starts.length ? Math.min(...starts) : -1;
|
|
76
|
+
// Only honour it when it actually saves the block: a payload so large it
|
|
77
|
+
// exceeds the budget on its own is already lost, and falling back to the
|
|
78
|
+
// plain tail at least bounds memory rather than keeping everything.
|
|
79
|
+
if (keepFrom >= 0 && text.length - keepFrom <= limit)
|
|
80
|
+
return text.slice(keepFrom);
|
|
81
|
+
return text.slice(-limit);
|
|
82
|
+
}
|
|
83
|
+
export function parseOrchestraActions(text) {
|
|
84
|
+
const fences = [...text.matchAll(/```([a-zA-Z-]*)\s*\n([\s\S]*?)```/g)];
|
|
85
|
+
const candidates = [];
|
|
86
|
+
for (const f of fences.reverse()) {
|
|
87
|
+
if (f[1] === "loom")
|
|
88
|
+
candidates.push(f[2]);
|
|
89
|
+
}
|
|
90
|
+
for (const f of fences) {
|
|
91
|
+
if (f[1] !== "loom" && f[2].includes('"actions"'))
|
|
92
|
+
candidates.push(f[2]);
|
|
93
|
+
}
|
|
94
|
+
const bare = text.lastIndexOf('{"actions"');
|
|
95
|
+
if (bare >= 0)
|
|
96
|
+
candidates.push(text.slice(bare));
|
|
97
|
+
for (const raw of candidates) {
|
|
98
|
+
const parsed = tryJson(raw.trim());
|
|
99
|
+
const list = Array.isArray(parsed)
|
|
100
|
+
? parsed
|
|
101
|
+
: parsed && typeof parsed === "object" && Array.isArray(parsed.actions)
|
|
102
|
+
? parsed.actions
|
|
103
|
+
: null;
|
|
104
|
+
if (!list)
|
|
105
|
+
continue;
|
|
106
|
+
const actions = list.map(normalizeAction).filter((a) => a !== null);
|
|
107
|
+
if (actions.length || list.length === 0)
|
|
108
|
+
return actions;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function tryJson(raw) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// A bare object followed by prose: cut at the matching close brace.
|
|
118
|
+
let depth = 0;
|
|
119
|
+
let inStr = false;
|
|
120
|
+
for (let i = 0; i < raw.length; i++) {
|
|
121
|
+
const c = raw[i];
|
|
122
|
+
if (inStr) {
|
|
123
|
+
if (c === "\\")
|
|
124
|
+
i++;
|
|
125
|
+
else if (c === '"')
|
|
126
|
+
inStr = false;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (c === '"')
|
|
130
|
+
inStr = true;
|
|
131
|
+
else if (c === "{" || c === "[")
|
|
132
|
+
depth++;
|
|
133
|
+
else if (c === "}" || c === "]") {
|
|
134
|
+
depth--;
|
|
135
|
+
if (depth === 0) {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(raw.slice(0, i + 1));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function str(v) {
|
|
149
|
+
return typeof v === "string" ? v.trim() : "";
|
|
150
|
+
}
|
|
151
|
+
function normalizeAction(v) {
|
|
152
|
+
if (!v || typeof v !== "object")
|
|
153
|
+
return null;
|
|
154
|
+
const a = v;
|
|
155
|
+
const type = str(a.type ?? a.action).toLowerCase();
|
|
156
|
+
switch (type) {
|
|
157
|
+
case "spawn":
|
|
158
|
+
case "task":
|
|
159
|
+
case "assign": {
|
|
160
|
+
const prompt = str(a.prompt ?? a.instructions ?? a.task);
|
|
161
|
+
const title = str(a.title ?? a.name) || prompt.slice(0, 60);
|
|
162
|
+
const agent = str(a.agent ?? a.worker);
|
|
163
|
+
if (!prompt || !agent)
|
|
164
|
+
return null;
|
|
165
|
+
const deps = Array.isArray(a.dependsOn ?? a.depends_on ?? a.after)
|
|
166
|
+
? (a.dependsOn ?? a.depends_on ?? a.after).map(str).filter(Boolean)
|
|
167
|
+
: [];
|
|
168
|
+
const id = str(a.id);
|
|
169
|
+
const touches = Array.isArray(a.touches ?? a.files)
|
|
170
|
+
? (a.touches ?? a.files).map(str).filter(Boolean).slice(0, 50)
|
|
171
|
+
: [];
|
|
172
|
+
const overlapDecision = str(a.overlap);
|
|
173
|
+
return {
|
|
174
|
+
type: "spawn",
|
|
175
|
+
...(id ? { id } : {}),
|
|
176
|
+
title,
|
|
177
|
+
agent,
|
|
178
|
+
prompt,
|
|
179
|
+
dependsOn: deps,
|
|
180
|
+
...(touches.length ? { touches } : {}),
|
|
181
|
+
...(overlapDecision ? { overlap: overlapDecision } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
case "send":
|
|
185
|
+
case "followup":
|
|
186
|
+
case "follow_up": {
|
|
187
|
+
const task = str(a.task ?? a.id);
|
|
188
|
+
const message = str(a.message ?? a.prompt);
|
|
189
|
+
return task && message ? { type: "send", task, message } : null;
|
|
190
|
+
}
|
|
191
|
+
case "cancel":
|
|
192
|
+
case "kill": {
|
|
193
|
+
const task = str(a.task ?? a.id);
|
|
194
|
+
return task ? { type: "cancel", task } : null;
|
|
195
|
+
}
|
|
196
|
+
case "ask":
|
|
197
|
+
case "question": {
|
|
198
|
+
const question = str(a.question ?? a.message);
|
|
199
|
+
return question ? { type: "ask", question } : null;
|
|
200
|
+
}
|
|
201
|
+
case "done":
|
|
202
|
+
case "finish":
|
|
203
|
+
case "complete": {
|
|
204
|
+
return { type: "done", summary: str(a.summary ?? a.message) || "Done." };
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** The orchestrator's standing instructions, sent with its first turn. */
|
|
211
|
+
export function orchestratorBriefing(opts) {
|
|
212
|
+
const roster = opts.workers
|
|
213
|
+
.map((w) => `- "${w.id}" — ${KIND_BLURBS[w.kind] ?? w.kind}${w.role && w.role !== w.id ? ` (role: ${w.role})` : ""}`)
|
|
214
|
+
.join("\n");
|
|
215
|
+
return [
|
|
216
|
+
`[Loom Orchestra] You are the ORCHESTRATOR for project "${opts.project}".`,
|
|
217
|
+
"Your job is to coordinate, not to implement. Break the goal into concrete, independently",
|
|
218
|
+
"verifiable tasks and assign each to a worker agent. Workers run IN PARALLEL, each in its own",
|
|
219
|
+
`git worktree; finished work is merged into the integration branch "${opts.branch}", which is`,
|
|
220
|
+
"the directory you are running in — read it to review results.",
|
|
221
|
+
"",
|
|
222
|
+
"Rules:",
|
|
223
|
+
"- Never edit files yourself. Every implementation, fix, or test goes to a worker.",
|
|
224
|
+
"- Ground every task in the real repository: look at the code first; never guess file names.",
|
|
225
|
+
"- Write each task prompt so a worker with NO other context can finish it: the outcome, the",
|
|
226
|
+
" files involved, constraints, and how to verify.",
|
|
227
|
+
"- Prefer tasks that touch different files so parallel work merges cleanly. When one task needs",
|
|
228
|
+
" another's output, list it in dependsOn — it will start from a branch that already has it.",
|
|
229
|
+
`- At most ${opts.maxParallel} tasks run at once; queue more and they start as slots free up.`,
|
|
230
|
+
"- Match tasks to agents' strengths, and spread work across agents when it helps.",
|
|
231
|
+
'- Give each task "touches": the file globs it will change. Teammates\' agents see them to avoid collisions.',
|
|
232
|
+
"",
|
|
233
|
+
"Available workers:",
|
|
234
|
+
roster,
|
|
235
|
+
"",
|
|
236
|
+
"End EVERY reply with exactly one fenced block tagged loom containing your actions:",
|
|
237
|
+
"```loom",
|
|
238
|
+
'{"actions": [',
|
|
239
|
+
' {"type": "spawn", "id": "t1", "title": "short label", "agent": "<worker id>", "prompt": "full task", "dependsOn": [], "touches": ["src/auth/**"]},',
|
|
240
|
+
' {"type": "send", "task": "t1", "message": "follow-up for a finished/failed task (same worker, same worktree)"},',
|
|
241
|
+
' {"type": "cancel", "task": "t2"},',
|
|
242
|
+
' {"type": "ask", "question": "only when a human decision is truly required"},',
|
|
243
|
+
' {"type": "done", "summary": "what was delivered and how it was verified"}',
|
|
244
|
+
"]}",
|
|
245
|
+
"```",
|
|
246
|
+
"After you reply, Loom runs the tasks and comes back to you with each result. Review them:",
|
|
247
|
+
"send follow-ups for anything incomplete or wrong, spawn new tasks if needed, and emit done",
|
|
248
|
+
"only when the goal is met. An empty actions list means: keep waiting for running tasks.",
|
|
249
|
+
"",
|
|
250
|
+
...(opts.planDir
|
|
251
|
+
? [
|
|
252
|
+
"",
|
|
253
|
+
`PLAN MODE is on. Every task prompt you write becomes a durable spec file at ${opts.planDir}/<id>.md,`,
|
|
254
|
+
`and the whole plan is written to ${opts.planDir}/PLAN.md, committed to the integration branch. Other`,
|
|
255
|
+
"agents and teammates will pick these up without you, so write each task prompt as a complete spec",
|
|
256
|
+
"with these headings: Goal, Context (the files and code involved, by path), Steps, Acceptance criteria,",
|
|
257
|
+
"Verification (exact commands). Plan the whole goal up front in your first reply.",
|
|
258
|
+
]
|
|
259
|
+
: []),
|
|
260
|
+
"",
|
|
261
|
+
`The goal: ${opts.goal}`,
|
|
262
|
+
].join("\n");
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Plan files — the plan as markdown other agents can read
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
function yamlStr(v) {
|
|
268
|
+
return JSON.stringify(v);
|
|
269
|
+
}
|
|
270
|
+
/** plans/<run>/PLAN.md — the index: goal, task table, how to use it, results. */
|
|
271
|
+
export function renderPlanIndex(run) {
|
|
272
|
+
const rows = run.tasks
|
|
273
|
+
.map((t) => `| [${t.id}](${t.id}.md) | ${t.title.replace(/\|/g, "\\|")} | ${t.agent} | ${t.dependsOn.join(", ") || "—"} | ${t.status} |`)
|
|
274
|
+
.join("\n");
|
|
275
|
+
return [
|
|
276
|
+
"---",
|
|
277
|
+
"loom-plan: 1",
|
|
278
|
+
`run: ${run.id}`,
|
|
279
|
+
`goal: ${yamlStr(run.goal)}`,
|
|
280
|
+
`orchestrator: ${run.orchestrator.agent}`,
|
|
281
|
+
`workers: [${run.workers.join(", ")}]`,
|
|
282
|
+
`status: ${run.status}`,
|
|
283
|
+
`branch: ${run.branch}`,
|
|
284
|
+
`created: ${new Date(run.createdAt).toISOString()}`,
|
|
285
|
+
"---",
|
|
286
|
+
"",
|
|
287
|
+
`# ${run.goal.split("\n")[0].slice(0, 100)}`,
|
|
288
|
+
"",
|
|
289
|
+
"## Goal",
|
|
290
|
+
"",
|
|
291
|
+
run.goal,
|
|
292
|
+
"",
|
|
293
|
+
"## Tasks",
|
|
294
|
+
"",
|
|
295
|
+
"| id | task | agent | depends on | status |",
|
|
296
|
+
"|---|---|---|---|---|",
|
|
297
|
+
rows || "| — | (no tasks yet) | | | |",
|
|
298
|
+
"",
|
|
299
|
+
"## How to use this plan",
|
|
300
|
+
"",
|
|
301
|
+
"Each task file in this folder is a self-contained spec: any coding agent (Claude Code, Codex,",
|
|
302
|
+
"Antigravity, Grok, OpenCode…) or a teammate can pick one up by reading it. Tasks whose",
|
|
303
|
+
"`depends_on` are all `done` are ready. Loom keeps the `status` fields current; don't edit them by hand",
|
|
304
|
+
"while a run is live.",
|
|
305
|
+
"",
|
|
306
|
+
...(run.summary || run.error
|
|
307
|
+
? ["## Result", "", run.summary ?? `Stopped: ${run.error}`, "", `Integration branch: \`${run.branch}\``, ""]
|
|
308
|
+
: []),
|
|
309
|
+
].join("\n");
|
|
310
|
+
}
|
|
311
|
+
/** plans/<run>/<task>.md — one task, readable by any agent with no other context. */
|
|
312
|
+
export function renderTaskFile(run, t) {
|
|
313
|
+
return [
|
|
314
|
+
"---",
|
|
315
|
+
`id: ${t.id}`,
|
|
316
|
+
`title: ${yamlStr(t.title)}`,
|
|
317
|
+
`agent: ${t.agent}`,
|
|
318
|
+
`depends_on: [${t.dependsOn.join(", ")}]`,
|
|
319
|
+
`status: ${t.status}`,
|
|
320
|
+
`plan: PLAN.md`,
|
|
321
|
+
"---",
|
|
322
|
+
"",
|
|
323
|
+
`# ${t.id} · ${t.title}`,
|
|
324
|
+
"",
|
|
325
|
+
`Part of: ${run.goal.split("\n")[0].slice(0, 120)} (see [PLAN.md](PLAN.md))`,
|
|
326
|
+
"",
|
|
327
|
+
...(t.dependsOn.length
|
|
328
|
+
? ["## Depends on", "", ...t.dependsOn.map((d) => `- [${d}](${d}.md)`), ""]
|
|
329
|
+
: []),
|
|
330
|
+
"## Spec",
|
|
331
|
+
"",
|
|
332
|
+
t.prompt.trim(),
|
|
333
|
+
"",
|
|
334
|
+
"## Result",
|
|
335
|
+
"",
|
|
336
|
+
t.result && t.status !== "pending" && t.status !== "running"
|
|
337
|
+
? [t.result.trim(), "", t.files?.length ? `Files changed: ${t.files.map((f) => `\`${f}\``).join(", ")}` : ""].join("\n")
|
|
338
|
+
: "_Filled in by Loom when the task finishes._",
|
|
339
|
+
"",
|
|
340
|
+
].join("\n");
|
|
341
|
+
}
|
|
342
|
+
const KIND_BLURBS = {
|
|
343
|
+
"claude-code": "Claude Code (Anthropic): strong at multi-file changes, refactors, careful reasoning",
|
|
344
|
+
codex: "Codex (OpenAI / ChatGPT): strong at implementation and running tests",
|
|
345
|
+
"antigravity-cli": "Antigravity (Google Gemini): fast implementation, broad knowledge",
|
|
346
|
+
"grok-code": "Grok Code (xAI): quick edits and scripts",
|
|
347
|
+
opencode: "OpenCode: open-model agent, good for well-specified tasks",
|
|
348
|
+
echo: "Echo (test double — repeats the prompt)",
|
|
349
|
+
};
|
|
350
|
+
/** What a worker is told, alongside its task. */
|
|
351
|
+
export function workerBriefing(opts) {
|
|
352
|
+
return [
|
|
353
|
+
`[Loom Orchestra] You are a WORKER in project "${opts.project}", running task ${opts.task.id}: ` +
|
|
354
|
+
`"${opts.task.title}".`,
|
|
355
|
+
`The overall goal (for context only — do just YOUR task): ${opts.runGoal}`,
|
|
356
|
+
`You are in your own git worktree on branch "${opts.branch}". Other workers are editing other`,
|
|
357
|
+
"worktrees at the same time; Loom merges your work when you finish.",
|
|
358
|
+
"- Inspect the relevant code before editing. Keep changes scoped to the task.",
|
|
359
|
+
"- Verify what you touched (run the relevant tests/build when there are any).",
|
|
360
|
+
"- Do not commit, push, or open PRs — Loom commits and merges for you.",
|
|
361
|
+
"- Finish with a short report: what you changed, how you verified it, anything left undone.",
|
|
362
|
+
"- If you cannot proceed without a decision, end your reply with the question.",
|
|
363
|
+
...(opts.planFile
|
|
364
|
+
? [
|
|
365
|
+
`- Your task's spec is ${opts.planFile} (the whole plan is next to it in PLAN.md). Read it first.`,
|
|
366
|
+
" Don't edit files under plans/ — Loom updates them.",
|
|
367
|
+
]
|
|
368
|
+
: []),
|
|
369
|
+
opts.brain,
|
|
370
|
+
]
|
|
371
|
+
.filter(Boolean)
|
|
372
|
+
.join("\n");
|
|
373
|
+
}
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// git
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
function git(args, cwd) {
|
|
378
|
+
return new Promise((resolve, reject) => {
|
|
379
|
+
execFile("git", args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
380
|
+
if (err)
|
|
381
|
+
reject(new Error((stderr || `${err.message} (exit ${err.code ?? "unknown"}, signal ${err.signal ?? "none"})`).trim(), { cause: err }));
|
|
382
|
+
else
|
|
383
|
+
resolve(stdout);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const gitOk = (args, cwd) => git(args, cwd).then(() => true, () => false);
|
|
388
|
+
/**
|
|
389
|
+
* Stage everything a worker changed, minus Loom's own files.
|
|
390
|
+
*
|
|
391
|
+
* Adapters keep session state in `<worktree>/.loom/`, which must never ride a
|
|
392
|
+
* commit into the project. An exclude pathspec looked right and wasn't: when
|
|
393
|
+
* the project already gitignores `.loom/` (most do), naming the ignored path
|
|
394
|
+
* makes `git add` exit 1 ("paths are ignored"), which failed every real Codex
|
|
395
|
+
* task. So: add all, then unstage `.loom` — a no-op when it's ignored.
|
|
396
|
+
*/
|
|
397
|
+
async function commitAll(dir, message) {
|
|
398
|
+
await git(["add", "-A"], dir);
|
|
399
|
+
await git(["rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".loom"], dir).catch(() => { });
|
|
400
|
+
const staged = (await git(["diff", "--cached", "--name-only"], dir)).trim();
|
|
401
|
+
if (!staged)
|
|
402
|
+
return false;
|
|
403
|
+
await git(["-c", "user.name=Loom Orchestra", "-c", "user.email=orchestra@loom.local", "commit", "-q", "--no-verify", "-m", message], dir);
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* A worker's commit, traceable to its goal, task and agent from `git log`
|
|
408
|
+
* alone (docs/teams-architecture.md §7). GitHub reads Co-Authored-By; the
|
|
409
|
+
* Loom-* trailers are for people and tools (`git log --grep 'Loom-Goal: o7x'`).
|
|
410
|
+
*/
|
|
411
|
+
export function taskCommitMessage(run, task, member) {
|
|
412
|
+
return [
|
|
413
|
+
`${task.title}`.slice(0, 72),
|
|
414
|
+
"",
|
|
415
|
+
`Task ${task.id} of orchestra ${run.id}: ${run.goal.split("\n")[0].slice(0, 120)}`,
|
|
416
|
+
"",
|
|
417
|
+
`Co-Authored-By: ${task.agent} <${task.agent}@loom.local>`,
|
|
418
|
+
...(member ? [`Loom-Member: ${member}`] : []),
|
|
419
|
+
`Loom-Goal: ${run.id}`,
|
|
420
|
+
`Loom-Task: ${task.id}`,
|
|
421
|
+
`Loom-Agent: ${task.kind}`,
|
|
422
|
+
].join("\n");
|
|
423
|
+
}
|
|
424
|
+
/** Where a run's plan lives inside the repo. */
|
|
425
|
+
export function planDir(run) {
|
|
426
|
+
return `plans/${run.id}`;
|
|
427
|
+
}
|
|
428
|
+
function gh(args, cwd) {
|
|
429
|
+
return new Promise((resolve, reject) => {
|
|
430
|
+
execFile("gh", args, { cwd, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
431
|
+
if (err)
|
|
432
|
+
reject(new Error((stderr || err.message).trim()));
|
|
433
|
+
else
|
|
434
|
+
resolve(stdout);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
/** Serialize git mutations: worktree add/merge on one repo race on its locks. */
|
|
439
|
+
class Mutex {
|
|
440
|
+
tail = Promise.resolve();
|
|
441
|
+
run(fn) {
|
|
442
|
+
const next = this.tail.then(fn, fn);
|
|
443
|
+
this.tail = next.catch(() => { });
|
|
444
|
+
return next;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// The engine
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
const DEFAULT_MAX_PARALLEL = 4;
|
|
451
|
+
const DEFAULT_MAX_ROUNDS = 10;
|
|
452
|
+
const HARD_MAX_PARALLEL = 12;
|
|
453
|
+
const RESULT_CHARS = 4000;
|
|
454
|
+
/** How often a task waiting on the team (a PR, a zone, capacity) is re-admitted. */
|
|
455
|
+
const RECHECK_MS = 15_000;
|
|
456
|
+
const PICK_ONE = "compare them and pick one to apply.";
|
|
457
|
+
/** A race's summary once you've picked: which take went in, not the instruction to pick. */
|
|
458
|
+
function raceApplied(run) {
|
|
459
|
+
const done = run.tasks.filter((t) => t.status === "done").length;
|
|
460
|
+
const pick = run.tasks.find((t) => t.id === run.applied?.task);
|
|
461
|
+
return `${done} of ${run.tasks.length} finished — ${pick ? `${pick.agent}'s take` : "one take"} was applied.`;
|
|
462
|
+
}
|
|
463
|
+
export class OrchestraEngine {
|
|
464
|
+
host;
|
|
465
|
+
runs = new Map();
|
|
466
|
+
live = new Map(); // `${runId}/${taskId|orch}` → adapter
|
|
467
|
+
/** A worker's or orchestrator's live instance by its id, for answering its questions. */
|
|
468
|
+
liveAgent(instanceId) {
|
|
469
|
+
return [...this.live.values()].find((a) => a.id === instanceId);
|
|
470
|
+
}
|
|
471
|
+
gitLock = new Mutex();
|
|
472
|
+
turnText = new Map();
|
|
473
|
+
orchestratorBusy = new Set();
|
|
474
|
+
constructor(host) {
|
|
475
|
+
this.host = host;
|
|
476
|
+
for (const run of this.loadRuns())
|
|
477
|
+
this.runs.set(run.id, run);
|
|
478
|
+
// A daemon restart kills every CLI a run was driving. Say so honestly
|
|
479
|
+
// instead of showing tasks "running" that nothing runs.
|
|
480
|
+
for (const run of this.runs.values()) {
|
|
481
|
+
if (!isTerminal(run.status) && run.status !== "waiting_human") {
|
|
482
|
+
run.status = "failed";
|
|
483
|
+
run.error = "the daemon restarted while this run was active";
|
|
484
|
+
for (const t of run.tasks) {
|
|
485
|
+
if (t.status === "running" || t.status === "pending") {
|
|
486
|
+
t.status = "cancelled";
|
|
487
|
+
t.error = "daemon restarted";
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
this.save(run);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// ── persistence ──
|
|
495
|
+
runsDir() {
|
|
496
|
+
return path.join(this.host.projectDir, ".loom", "orchestra");
|
|
497
|
+
}
|
|
498
|
+
loadRuns() {
|
|
499
|
+
const dir = this.runsDir();
|
|
500
|
+
if (!fs.existsSync(dir))
|
|
501
|
+
return [];
|
|
502
|
+
const out = [];
|
|
503
|
+
for (const f of fs.readdirSync(dir)) {
|
|
504
|
+
if (!f.endsWith(".json"))
|
|
505
|
+
continue;
|
|
506
|
+
try {
|
|
507
|
+
const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
508
|
+
// a race applied before its summary was rewritten on apply still says "pick one"
|
|
509
|
+
if (run.race && run.applied?.task && run.summary?.endsWith(PICK_ONE))
|
|
510
|
+
run.summary = raceApplied(run);
|
|
511
|
+
out.push(run);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
/* a torn file is one lost run, not a dead project */
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return out.sort((a, b) => a.createdAt - b.createdAt);
|
|
518
|
+
}
|
|
519
|
+
save(run) {
|
|
520
|
+
run.updatedAt = Date.now();
|
|
521
|
+
fs.mkdirSync(this.runsDir(), { recursive: true });
|
|
522
|
+
const file = path.join(this.runsDir(), `${run.id}.json`);
|
|
523
|
+
fs.writeFileSync(file + ".tmp", JSON.stringify(run, null, 2));
|
|
524
|
+
fs.renameSync(file + ".tmp", file);
|
|
525
|
+
}
|
|
526
|
+
list() {
|
|
527
|
+
return [...this.runs.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
528
|
+
}
|
|
529
|
+
get(id) {
|
|
530
|
+
return this.runs.get(id);
|
|
531
|
+
}
|
|
532
|
+
active() {
|
|
533
|
+
return this.list().find((r) => !isTerminal(r.status));
|
|
534
|
+
}
|
|
535
|
+
/** Every goal in flight, with the paths it expects to touch. */
|
|
536
|
+
runningScopes() {
|
|
537
|
+
return this.list()
|
|
538
|
+
.filter((r) => !isTerminal(r.status))
|
|
539
|
+
.map((r) => ({
|
|
540
|
+
runId: r.id,
|
|
541
|
+
goal: r.goal,
|
|
542
|
+
// A goal's scope is what it said it would touch, once it has planned.
|
|
543
|
+
// Before that it's unknown, which counts as everything.
|
|
544
|
+
paths: r.tasks.flatMap((t) => t.touches ?? []),
|
|
545
|
+
}));
|
|
546
|
+
}
|
|
547
|
+
emit(run, phase, extra = {}, chat) {
|
|
548
|
+
this.host.append({
|
|
549
|
+
kind: "orchestra",
|
|
550
|
+
chat: chat ?? run.chat,
|
|
551
|
+
payload: { phase, runId: run.id, status: run.status, ...extra },
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
// ── agents ──
|
|
555
|
+
/** Resolve a name the orchestrator (or user) gave into a runnable config. */
|
|
556
|
+
resolveAgent(name) {
|
|
557
|
+
const roster = this.host.roster();
|
|
558
|
+
const n = name.trim().toLowerCase();
|
|
559
|
+
const byId = roster.find((a) => a.id.toLowerCase() === n);
|
|
560
|
+
if (byId)
|
|
561
|
+
return byId;
|
|
562
|
+
const alias = {
|
|
563
|
+
claude: "claude-code",
|
|
564
|
+
"claude code": "claude-code",
|
|
565
|
+
chatgpt: "codex",
|
|
566
|
+
gpt: "codex",
|
|
567
|
+
openai: "codex",
|
|
568
|
+
antigravity: "antigravity-cli",
|
|
569
|
+
agy: "antigravity-cli",
|
|
570
|
+
gemini: "antigravity-cli",
|
|
571
|
+
grok: "grok-code",
|
|
572
|
+
};
|
|
573
|
+
const kind = alias[n] ?? n;
|
|
574
|
+
const byKind = roster.find((a) => a.kind === kind);
|
|
575
|
+
if (byKind)
|
|
576
|
+
return byKind;
|
|
577
|
+
if (this.host.installedKinds().includes(kind))
|
|
578
|
+
return { id: kind, kind, role: kind };
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
// ── lifecycle ──
|
|
582
|
+
async start(opts) {
|
|
583
|
+
const goal = opts.goal?.trim();
|
|
584
|
+
if (!goal)
|
|
585
|
+
throw new Error("an orchestra run needs a goal");
|
|
586
|
+
// One goal at a time unless the project says otherwise — and then only
|
|
587
|
+
// when the paths can't collide (core/goal-lanes.ts, D-lanes locally).
|
|
588
|
+
const running = this.runningScopes();
|
|
589
|
+
const allowed = Math.max(1, this.host.maxConcurrentGoals?.() ?? 1);
|
|
590
|
+
if (running.length) {
|
|
591
|
+
const blocked = blockedBy({ runId: "new", goal, paths: opts.touches ?? [] }, running, allowed);
|
|
592
|
+
if (blocked) {
|
|
593
|
+
const busy = this.active();
|
|
594
|
+
throw new Error(allowed === 1
|
|
595
|
+
? `orchestra run ${busy.id} is still ${busy.status} — abort it or wait`
|
|
596
|
+
: blocked);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const roster = this.host.roster();
|
|
600
|
+
const orchCfg = opts.orchestrator
|
|
601
|
+
? this.resolveAgent(opts.orchestrator)
|
|
602
|
+
: (roster.find((a) => a.kind === "claude-code") ?? roster[0] ?? null);
|
|
603
|
+
if (!orchCfg)
|
|
604
|
+
throw new Error(`no agent "${opts.orchestrator ?? ""}" can orchestrate here — add one first`);
|
|
605
|
+
const workerNames = opts.workers?.length ? opts.workers : roster.map((a) => a.id);
|
|
606
|
+
const workers = [];
|
|
607
|
+
for (const w of workerNames) {
|
|
608
|
+
const cfg = this.resolveAgent(w);
|
|
609
|
+
if (!cfg)
|
|
610
|
+
throw new Error(`unknown worker agent "${w}"`);
|
|
611
|
+
if (!workers.some((x) => x.id === cfg.id))
|
|
612
|
+
workers.push(cfg);
|
|
613
|
+
}
|
|
614
|
+
if (!workers.length)
|
|
615
|
+
throw new Error("an orchestra needs at least one worker agent");
|
|
616
|
+
if (opts.race && workers.length < 2)
|
|
617
|
+
throw new Error("a race needs at least two agents");
|
|
618
|
+
this.host.gate(orchCfg.id);
|
|
619
|
+
const blocked = this.host.coordinator?.()?.canStart?.();
|
|
620
|
+
if (blocked)
|
|
621
|
+
throw new Error(blocked);
|
|
622
|
+
const dir = this.host.projectDir;
|
|
623
|
+
if (!(await gitOk(["rev-parse", "--is-inside-work-tree"], dir))) {
|
|
624
|
+
throw new Error("orchestra runs need a git repository — initialise one (git init) first");
|
|
625
|
+
}
|
|
626
|
+
// Worktrees branch from a commit. A repo with none gets an empty root
|
|
627
|
+
// commit rather than a cryptic "invalid reference: HEAD".
|
|
628
|
+
if (!(await gitOk(["rev-parse", "--verify", "HEAD"], dir))) {
|
|
629
|
+
await git(["-c", "user.name=Loom", "-c", "user.email=loom@loom.local", "commit", "-q", "--allow-empty", "-m", "Initial commit"], dir);
|
|
630
|
+
}
|
|
631
|
+
let baseCommit = (await git(["rev-parse", "HEAD"], dir)).trim();
|
|
632
|
+
let baseBranch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], dir).catch(() => "")).trim() || null;
|
|
633
|
+
if (opts.from) {
|
|
634
|
+
// Adopting a teammate's goal: work starts from their pushed branch (D63).
|
|
635
|
+
await git(["fetch", "-q", "origin", `+refs/heads/${opts.from.branch}:refs/remotes/origin/${opts.from.branch}`], dir);
|
|
636
|
+
baseCommit = (await git(["rev-parse", `origin/${opts.from.branch}`], dir)).trim();
|
|
637
|
+
baseBranch = null;
|
|
638
|
+
}
|
|
639
|
+
// Loom's own state (.loom/: the log, the brain, run files) changes on every
|
|
640
|
+
// turn and is never the user's work; counting it warned "uncommitted
|
|
641
|
+
// changes" on every run in a project that doesn't gitignore it.
|
|
642
|
+
const dirty = (await git(["status", "--porcelain", "--", ".", ":(exclude).loom"], dir)).trim().length > 0;
|
|
643
|
+
const id = `o${Date.now().toString(36)}`;
|
|
644
|
+
// Integration and task branches are siblings under one prefix: git can't
|
|
645
|
+
// hold both `a/b` and `a/b/c` as refs.
|
|
646
|
+
const branch = `loom/orchestra/${id}/main`;
|
|
647
|
+
const wtRoot = path.join(loomHome(), "orchestra", this.host.projectId, id);
|
|
648
|
+
const integration = path.join(wtRoot, "integration");
|
|
649
|
+
fs.mkdirSync(wtRoot, { recursive: true });
|
|
650
|
+
await this.gitLock.run(() => createWorktree(dir, integration, branch, baseCommit));
|
|
651
|
+
// Orchestrating from a thread used to abandon it: the run opened a thread
|
|
652
|
+
// of its own and the UI walked you into it, so the goal you typed in Main
|
|
653
|
+
// and every word of its answer lived somewhere else (#100). When the
|
|
654
|
+
// caller names the thread it was asked in, the orchestrator answers there.
|
|
655
|
+
const inPlace = opts.chat && (this.host.chatExists?.(opts.chat) ?? false) ? opts.chat : null;
|
|
656
|
+
const chat = inPlace ? { id: inPlace } : this.host.createChat(`🎼 ${goal.slice(0, 50)}`);
|
|
657
|
+
const run = {
|
|
658
|
+
id,
|
|
659
|
+
goal,
|
|
660
|
+
orchestrator: { agent: orchCfg.id, kind: orchCfg.kind },
|
|
661
|
+
workers: workers.map((w) => w.id),
|
|
662
|
+
status: "planning",
|
|
663
|
+
chat: chat.id,
|
|
664
|
+
...(inPlace ? { inPlace: true } : {}),
|
|
665
|
+
baseBranch,
|
|
666
|
+
baseCommit,
|
|
667
|
+
branch,
|
|
668
|
+
dir: integration,
|
|
669
|
+
tasks: [],
|
|
670
|
+
round: 0,
|
|
671
|
+
maxRounds: clamp(opts.maxRounds ?? DEFAULT_MAX_ROUNDS, 1, 50),
|
|
672
|
+
maxParallel: Math.min(clamp(opts.maxParallel ?? DEFAULT_MAX_PARALLEL, 1, HARD_MAX_PARALLEL), this.host.coordinator?.()?.maxParallel?.() ?? HARD_MAX_PARALLEL),
|
|
673
|
+
...(opts.plan ? { plan: true } : {}),
|
|
674
|
+
...(opts.from ? { from: opts.from, landing: newLanding(opts.from.pr, opts.from.url) } : {}),
|
|
675
|
+
// A cap for this goal: what was asked for, else the project's default.
|
|
676
|
+
...(opts.maxUsd && opts.maxUsd > 0 ? { budgetUsd: opts.maxUsd } : {}),
|
|
677
|
+
...(opts.race ? { race: true } : {}),
|
|
678
|
+
costUsd: 0,
|
|
679
|
+
createdAt: Date.now(),
|
|
680
|
+
updatedAt: Date.now(),
|
|
681
|
+
};
|
|
682
|
+
this.workerCfgs.set(run.id, workers);
|
|
683
|
+
this.runs.set(id, run);
|
|
684
|
+
this.save(run);
|
|
685
|
+
// A new thread starts empty, so the goal is replayed into it. The thread
|
|
686
|
+
// you typed it in already has it — replaying would say it twice.
|
|
687
|
+
if (!inPlace)
|
|
688
|
+
this.host.append({ kind: "message", chat: run.chat, payload: { text: goal, author: "user" } });
|
|
689
|
+
this.emit(run, "started", {
|
|
690
|
+
goal,
|
|
691
|
+
orchestrator: run.orchestrator,
|
|
692
|
+
workers: run.workers,
|
|
693
|
+
branch,
|
|
694
|
+
maxParallel: run.maxParallel,
|
|
695
|
+
...(run.race ? { race: true } : {}),
|
|
696
|
+
...(dirty ? { note: "uncommitted changes in the project are not visible to workers — they start from the last commit" } : {}),
|
|
697
|
+
});
|
|
698
|
+
if (run.race) {
|
|
699
|
+
// No plan and no orchestrator: every entrant gets the goal as it was
|
|
700
|
+
// typed, in its own worktree off the same commit, all at once.
|
|
701
|
+
run.maxParallel = Math.max(run.maxParallel, workers.length);
|
|
702
|
+
for (const w of workers) {
|
|
703
|
+
const id = `race-${w.id}`.replace(/[^\w-]+/g, "-").slice(0, 40);
|
|
704
|
+
const chatInfo = this.host.createChat(`🏁 ${w.id} · ${goal}`.slice(0, 60), { agentId: w.id });
|
|
705
|
+
const task = {
|
|
706
|
+
id,
|
|
707
|
+
title: `${w.id}'s take`,
|
|
708
|
+
prompt: goal,
|
|
709
|
+
agent: w.id,
|
|
710
|
+
kind: w.kind,
|
|
711
|
+
dependsOn: [],
|
|
712
|
+
status: "pending",
|
|
713
|
+
chat: chatInfo.id,
|
|
714
|
+
attempts: 0,
|
|
715
|
+
queued: [],
|
|
716
|
+
};
|
|
717
|
+
run.tasks.push(task);
|
|
718
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
719
|
+
}
|
|
720
|
+
run.status = "running";
|
|
721
|
+
this.save(run);
|
|
722
|
+
this.schedule(run);
|
|
723
|
+
return run;
|
|
724
|
+
}
|
|
725
|
+
const briefing = orchestratorBriefing({
|
|
726
|
+
project: this.host.projectName,
|
|
727
|
+
goal,
|
|
728
|
+
workers: workers.map((w) => ({ id: w.id, kind: w.kind, role: w.role })),
|
|
729
|
+
maxParallel: run.maxParallel,
|
|
730
|
+
branch,
|
|
731
|
+
...(run.plan ? { planDir: planDir(run) } : {}),
|
|
732
|
+
});
|
|
733
|
+
void this.orchestratorTurn(run, "Plan the goal into tasks and spawn them now.", briefing);
|
|
734
|
+
return run;
|
|
735
|
+
}
|
|
736
|
+
workerCfgs = new Map();
|
|
737
|
+
workersFor(run) {
|
|
738
|
+
const cached = this.workerCfgs.get(run.id);
|
|
739
|
+
if (cached)
|
|
740
|
+
return cached;
|
|
741
|
+
const cfgs = run.workers.map((w) => this.resolveAgent(w)).filter((c) => !!c);
|
|
742
|
+
this.workerCfgs.set(run.id, cfgs);
|
|
743
|
+
return cfgs;
|
|
744
|
+
}
|
|
745
|
+
async abort(runId, reason = "aborted by the user") {
|
|
746
|
+
const run = this.mustGet(runId);
|
|
747
|
+
if (isTerminal(run.status))
|
|
748
|
+
return run;
|
|
749
|
+
run.status = "aborted";
|
|
750
|
+
run.error = reason;
|
|
751
|
+
for (const t of run.tasks) {
|
|
752
|
+
if (t.status === "running" || t.status === "pending") {
|
|
753
|
+
t.status = "cancelled";
|
|
754
|
+
t.error = reason;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
this.save(run);
|
|
758
|
+
await this.stopAll(run);
|
|
759
|
+
this.forgetRun(run.id);
|
|
760
|
+
this.emit(run, "aborted", { reason });
|
|
761
|
+
this.host.coordinator?.()?.onRunEnd?.(run);
|
|
762
|
+
return run;
|
|
763
|
+
}
|
|
764
|
+
/** The human answers an orchestrator's question (or adds direction mid-run). */
|
|
765
|
+
async reply(runId, text) {
|
|
766
|
+
const run = this.mustGet(runId);
|
|
767
|
+
const msg = text.trim();
|
|
768
|
+
if (!msg)
|
|
769
|
+
throw new Error("empty reply");
|
|
770
|
+
if (isTerminal(run.status) && run.status !== "completed") {
|
|
771
|
+
throw new Error(`run ${run.id} is ${run.status}`);
|
|
772
|
+
}
|
|
773
|
+
this.host.append({ kind: "message", chat: run.chat, payload: { text: msg, author: "user" } });
|
|
774
|
+
run.question = undefined;
|
|
775
|
+
// Over its budget (D64): a human saying continue raises the cap by one more budget.
|
|
776
|
+
const cap = this.goalCap(run);
|
|
777
|
+
if (cap !== null && run.costUsd >= cap)
|
|
778
|
+
run.budgetUsd = run.costUsd + (this.host.coordinator?.()?.goalBudgetUsd?.() ?? cap);
|
|
779
|
+
if (run.status === "completed") {
|
|
780
|
+
// Reopen: more work on the same integration branch.
|
|
781
|
+
run.status = "reviewing";
|
|
782
|
+
run.summary = undefined;
|
|
783
|
+
}
|
|
784
|
+
else if (run.status === "waiting_human") {
|
|
785
|
+
// Answered: the orchestrator is working on it now. Leaving the status at
|
|
786
|
+
// waiting_human showed "waiting on you" for the whole turn, and any
|
|
787
|
+
// client polling for the next wait returned at once (found by e2e F5).
|
|
788
|
+
run.status = "reviewing";
|
|
789
|
+
this.emit(run, "reviewing", { round: run.round + 1 });
|
|
790
|
+
}
|
|
791
|
+
this.save(run);
|
|
792
|
+
if (this.orchestratorBusy.has(run.id)) {
|
|
793
|
+
this.pendingHuman.set(run.id, [...(this.pendingHuman.get(run.id) ?? []), msg]);
|
|
794
|
+
return run;
|
|
795
|
+
}
|
|
796
|
+
void this.orchestratorTurn(run, `The human says: ${msg}\n\n${this.statusReport(run)}`);
|
|
797
|
+
return run;
|
|
798
|
+
}
|
|
799
|
+
pendingHuman = new Map();
|
|
800
|
+
/**
|
|
801
|
+
* Put a completed goal back to work (Phase 4): a failed check, a high review
|
|
802
|
+
* finding, a conflict with fresh main. Same integration branch, same task
|
|
803
|
+
* worktrees; the orchestrator gets the problem and a fresh round budget.
|
|
804
|
+
* Whatever landed on the pushed branch meanwhile (an adopter's fix) comes in first.
|
|
805
|
+
*/
|
|
806
|
+
async reopen(runId, text, why) {
|
|
807
|
+
const run = this.mustGet(runId);
|
|
808
|
+
if (run.status !== "completed")
|
|
809
|
+
throw new Error(`run ${run.id} is ${run.status} — only a completed goal can be reopened`);
|
|
810
|
+
run.status = "reviewing";
|
|
811
|
+
run.summary = undefined;
|
|
812
|
+
run.maxRounds = run.round + 10;
|
|
813
|
+
this.save(run);
|
|
814
|
+
this.emit(run, "reopened", { why });
|
|
815
|
+
await this.pullPushed(run).catch(() => { });
|
|
816
|
+
this.host.append({ kind: "message", chat: run.chat, payload: { text, author: "loom", orchestra: { runId: run.id, reopened: why } } });
|
|
817
|
+
void this.orchestratorTurn(run, `${text}\n\n${this.statusReport(run)}`, this.briefingOf(run));
|
|
818
|
+
return run;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Hand a goal to another machine (Phase 5, D75): stop starting anything,
|
|
822
|
+
* let running turns finish (interrupting them after `graceMs`), commit every
|
|
823
|
+
* worktree, and push the integration and task branches to hidden refs. The
|
|
824
|
+
* returned record is what the other side needs to rebuild the run; this
|
|
825
|
+
* copy becomes "moved" and read-only.
|
|
826
|
+
*/
|
|
827
|
+
async moveOut(runId, where, opts = {}) {
|
|
828
|
+
const run = this.mustGet(runId);
|
|
829
|
+
if (run.status === "moved")
|
|
830
|
+
throw new Error(`run ${run.id} has already moved to ${run.movedTo?.where ?? "another machine"}`);
|
|
831
|
+
if (run.status === "failed" || run.status === "aborted")
|
|
832
|
+
throw new Error(`run ${run.id} is ${run.status} — nothing to move`);
|
|
833
|
+
run.moving = true;
|
|
834
|
+
this.save(run);
|
|
835
|
+
this.emit(run, "moving", { to: where });
|
|
836
|
+
const grace = opts.graceMs ?? 120_000;
|
|
837
|
+
const busy = () => this.orchestratorBusy.has(run.id) || run.tasks.some((t) => t.status === "running");
|
|
838
|
+
const deadline = Date.now() + grace;
|
|
839
|
+
while (busy() && Date.now() < deadline)
|
|
840
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
841
|
+
// Out of patience: stop what's still going; each such task resumes where it left off.
|
|
842
|
+
for (const t of run.tasks) {
|
|
843
|
+
if (t.status !== "running")
|
|
844
|
+
continue;
|
|
845
|
+
t.status = "pending";
|
|
846
|
+
t.queued.unshift("You were moved to another machine mid-task. Your work so far is committed in this worktree — continue your task where you left off.");
|
|
847
|
+
}
|
|
848
|
+
await this.stopAll(run);
|
|
849
|
+
this.orchestratorBusy.delete(run.id);
|
|
850
|
+
const refs = [];
|
|
851
|
+
await this.gitLock.run(async () => {
|
|
852
|
+
await commitAll(run.dir, `orchestra ${run.id}: moved to ${where}`).catch(() => false);
|
|
853
|
+
await git(["push", "-q", "-f", "origin", `${run.branch}:${runRef(run.id, "main")}`], run.dir);
|
|
854
|
+
refs.push(runRef(run.id, "main"));
|
|
855
|
+
for (const t of run.tasks) {
|
|
856
|
+
if (!t.dir || !t.branch || !fs.existsSync(t.dir))
|
|
857
|
+
continue;
|
|
858
|
+
await commitAll(t.dir, `orchestra ${run.id}/${t.id}: work in progress, moved to ${where}`).catch(() => false);
|
|
859
|
+
await git(["push", "-q", "-f", "origin", `${t.branch}:${runRef(run.id, t.id)}`], t.dir);
|
|
860
|
+
refs.push(runRef(run.id, t.id));
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
const record = JSON.parse(JSON.stringify({ ...run, moving: undefined, movedTo: undefined }));
|
|
864
|
+
run.moving = undefined;
|
|
865
|
+
run.status = "moved";
|
|
866
|
+
run.movedTo = { where, at: Date.now() };
|
|
867
|
+
this.save(run);
|
|
868
|
+
this.emit(run, "moved", { to: where, refs });
|
|
869
|
+
return { record, refs };
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Rebuild a moved goal here (a runner taking it, or bringing it back):
|
|
873
|
+
* fetch its hidden refs, recreate the integration and task worktrees, and
|
|
874
|
+
* give the orchestrator a review turn to carry on (D75, D76).
|
|
875
|
+
*/
|
|
876
|
+
async importRun(record, opts = {}) {
|
|
877
|
+
const dir = this.host.projectDir;
|
|
878
|
+
const id = record.id;
|
|
879
|
+
const existing = this.runs.get(id);
|
|
880
|
+
if (existing && !isTerminal(existing.status))
|
|
881
|
+
throw new Error(`run ${id} is active here already`);
|
|
882
|
+
await git(["fetch", "-q", "origin", `+refs/loom/run/${id}/*:refs/remotes/loom-run/${id}/*`], dir);
|
|
883
|
+
const wtRoot = path.join(loomHome(), "orchestra", this.host.projectId, id);
|
|
884
|
+
// a copy left here by an earlier move goes first
|
|
885
|
+
if (existing) {
|
|
886
|
+
const old = [existing.dir, ...existing.tasks.map((t) => t.dir).filter((d) => !!d)];
|
|
887
|
+
await this.gitLock.run(async () => {
|
|
888
|
+
for (const d of old)
|
|
889
|
+
await git(["worktree", "remove", "--force", d], dir).catch(() => { });
|
|
890
|
+
await git(["worktree", "prune"], dir).catch(() => { });
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
fs.mkdirSync(wtRoot, { recursive: true });
|
|
894
|
+
const integration = path.join(wtRoot, "integration");
|
|
895
|
+
// The other machine's agents may not exist here: same name, else same kind,
|
|
896
|
+
// else this machine's own orchestrator / first worker — and say so.
|
|
897
|
+
const roster = this.host.roster();
|
|
898
|
+
const swaps = [];
|
|
899
|
+
const local = (agent, kind, fallback) => {
|
|
900
|
+
const cfg = this.resolveAgent(agent) ?? this.resolveAgent(kind) ?? fallback ?? null;
|
|
901
|
+
if (cfg && cfg.kind !== kind)
|
|
902
|
+
swaps.push(`${agent} (${kind}) → ${cfg.id} (${cfg.kind})`);
|
|
903
|
+
return cfg;
|
|
904
|
+
};
|
|
905
|
+
const orchCfg = local(record.orchestrator.agent, record.orchestrator.kind, roster.find((a) => a.role === "orchestrator") ?? roster[0]);
|
|
906
|
+
if (!orchCfg)
|
|
907
|
+
throw new Error("no agent here can orchestrate this goal");
|
|
908
|
+
const workerCfgs = record.workers.map((w) => this.resolveAgent(w)).filter((c) => !!c);
|
|
909
|
+
const firstWorker = workerCfgs[0] ?? roster.find((a) => a.role !== "orchestrator") ?? roster[0];
|
|
910
|
+
const tasks = [];
|
|
911
|
+
await this.gitLock.run(async () => {
|
|
912
|
+
await git(["worktree", "add", "-q", "-B", record.branch, integration, `refs/remotes/loom-run/${id}/main`], dir);
|
|
913
|
+
for (const t of record.tasks) {
|
|
914
|
+
const copy = { ...t, queued: [...t.queued] };
|
|
915
|
+
const wcfg = local(t.agent, t.kind, firstWorker);
|
|
916
|
+
if (wcfg) {
|
|
917
|
+
copy.agent = wcfg.id;
|
|
918
|
+
copy.kind = wcfg.kind;
|
|
919
|
+
if (!workerCfgs.some((w) => w.id === wcfg.id))
|
|
920
|
+
workerCfgs.push(wcfg);
|
|
921
|
+
}
|
|
922
|
+
const ref = `refs/remotes/loom-run/${id}/${t.id}`;
|
|
923
|
+
if (t.branch && (await gitOk(["rev-parse", "--verify", ref], dir))) {
|
|
924
|
+
const tdir = path.join(wtRoot, t.id);
|
|
925
|
+
await git(["worktree", "add", "-q", "-B", t.branch, tdir, ref], dir);
|
|
926
|
+
copy.dir = tdir;
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
delete copy.dir;
|
|
930
|
+
delete copy.branch;
|
|
931
|
+
}
|
|
932
|
+
if (copy.status === "running")
|
|
933
|
+
copy.status = "pending";
|
|
934
|
+
copy.chat = this.host.createChat(`${t.id} · ${t.title}`.slice(0, 60)).id;
|
|
935
|
+
copy.reported = false;
|
|
936
|
+
tasks.push(copy);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
if (!workerCfgs.length && firstWorker)
|
|
940
|
+
workerCfgs.push(firstWorker);
|
|
941
|
+
const run = {
|
|
942
|
+
...record,
|
|
943
|
+
orchestrator: { agent: orchCfg.id, kind: orchCfg.kind },
|
|
944
|
+
workers: workerCfgs.map((w) => w.id),
|
|
945
|
+
...(swaps.length ? { notes: [...(record.notes ?? []), `Agents changed on the move: ${[...new Set(swaps)].join("; ")}`] } : {}),
|
|
946
|
+
dir: integration,
|
|
947
|
+
tasks,
|
|
948
|
+
chat: this.host.createChat(`🎼 ${record.goal.slice(0, 50)}`).id,
|
|
949
|
+
status: isTerminal(record.status) || record.status === "waiting_human" ? record.status : "reviewing",
|
|
950
|
+
maxRounds: record.round + 20,
|
|
951
|
+
moving: undefined,
|
|
952
|
+
movedTo: undefined,
|
|
953
|
+
updatedAt: Date.now(),
|
|
954
|
+
};
|
|
955
|
+
this.runs.set(id, run);
|
|
956
|
+
this.workerCfgs.delete(id);
|
|
957
|
+
this.save(run);
|
|
958
|
+
this.host.append({ kind: "message", chat: run.chat, payload: { text: run.goal, author: "user" } });
|
|
959
|
+
this.emit(run, "imported", { from: opts.from ?? "another machine", tasks: run.tasks.length });
|
|
960
|
+
if (opts.resume !== false && run.status === "reviewing") {
|
|
961
|
+
void this.orchestratorTurn(run, `This goal was moved here from ${opts.from ?? "another machine"}. Every task's work so far is on its branch; interrupted tasks resume where they left off.\n\n${this.statusReport(run)}`, this.briefingOf(run));
|
|
962
|
+
}
|
|
963
|
+
return run;
|
|
964
|
+
}
|
|
965
|
+
/** The orchestrator's standing instructions, for a fresh orchestrator session. */
|
|
966
|
+
briefingOf(run) {
|
|
967
|
+
return orchestratorBriefing({
|
|
968
|
+
project: this.host.projectName,
|
|
969
|
+
goal: run.goal,
|
|
970
|
+
workers: this.workersFor(run).map((w) => ({ id: w.id, kind: w.kind, role: w.role })),
|
|
971
|
+
maxParallel: run.maxParallel,
|
|
972
|
+
branch: run.branch,
|
|
973
|
+
...(run.plan ? { planDir: planDir(run) } : {}),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
/** The branch this goal's PR is on. */
|
|
977
|
+
prBranch(run) {
|
|
978
|
+
return run.from?.branch ?? run.branch;
|
|
979
|
+
}
|
|
980
|
+
/** Bring commits pushed to the goal's PR branch by someone else into the integration branch. */
|
|
981
|
+
async pullPushed(run) {
|
|
982
|
+
if (!run.delivered?.prUrl && !run.from)
|
|
983
|
+
return;
|
|
984
|
+
const b = this.prBranch(run);
|
|
985
|
+
await this.gitLock.run(async () => {
|
|
986
|
+
await git(["fetch", "-q", "origin", `+refs/heads/${b}:refs/remotes/origin/${b}`], run.dir);
|
|
987
|
+
await commitAll(run.dir, `orchestra ${run.id}: orchestrator edits`).catch(() => false);
|
|
988
|
+
try {
|
|
989
|
+
await git(["-c", "user.name=Loom Orchestra", "-c", "user.email=orchestra@loom.local", "merge", "--no-edit", "-q", `origin/${b}`], run.dir);
|
|
990
|
+
}
|
|
991
|
+
catch (err) {
|
|
992
|
+
await git(["merge", "--abort"], run.dir).catch(() => { });
|
|
993
|
+
throw err;
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
/** Record where a goal's PR stands (the landing manager owns the transitions). */
|
|
998
|
+
setLanding(runId, patch) {
|
|
999
|
+
const run = this.runs.get(runId);
|
|
1000
|
+
if (!run?.landing && !(patch.pr && patch.url))
|
|
1001
|
+
return undefined;
|
|
1002
|
+
if (!run)
|
|
1003
|
+
return undefined;
|
|
1004
|
+
run.landing = { ...(run.landing ?? newLanding(patch.pr, patch.url)), ...patch, updatedAt: Date.now() };
|
|
1005
|
+
this.save(run);
|
|
1006
|
+
this.emit(run, "landing", { landing: run.landing });
|
|
1007
|
+
return run.landing;
|
|
1008
|
+
}
|
|
1009
|
+
goalCap(run) {
|
|
1010
|
+
return run.budgetUsd ?? this.host.goalBudgetUsd?.() ?? this.host.coordinator?.()?.goalBudgetUsd?.() ?? null;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* A word before the stop.
|
|
1014
|
+
*
|
|
1015
|
+
* Hearing about a budget for the first time when the goal halts is a bad way
|
|
1016
|
+
* to learn it exists — especially now that a queue can start goals while
|
|
1017
|
+
* you're elsewhere. Said once per goal.
|
|
1018
|
+
*/
|
|
1019
|
+
warnNearBudget(run) {
|
|
1020
|
+
const cap = this.goalCap(run);
|
|
1021
|
+
if (cap === null || run.budgetWarned || run.costUsd < cap * BUDGET_WARN_AT)
|
|
1022
|
+
return;
|
|
1023
|
+
run.budgetWarned = true;
|
|
1024
|
+
this.save(run);
|
|
1025
|
+
this.emit(run, "alert", {
|
|
1026
|
+
text: `this goal has spent $${run.costUsd.toFixed(2)} of its $${cap.toFixed(2)} budget`,
|
|
1027
|
+
budget: { spent: run.costUsd, cap },
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
/** D64: a goal over its cap stops asking for more work until a human says continue. */
|
|
1031
|
+
overBudget(run) {
|
|
1032
|
+
const cap = this.goalCap(run);
|
|
1033
|
+
this.warnNearBudget(run);
|
|
1034
|
+
if (cap === null || run.costUsd < cap)
|
|
1035
|
+
return false;
|
|
1036
|
+
run.status = "waiting_human";
|
|
1037
|
+
run.question =
|
|
1038
|
+
`This goal has spent $${run.costUsd.toFixed(2)} of its $${cap.toFixed(2)} budget. ` +
|
|
1039
|
+
"Running work finishes its turn; nothing new starts. Reply to continue — that allows one more budget's worth.";
|
|
1040
|
+
this.save(run);
|
|
1041
|
+
this.emit(run, "waiting", { question: run.question, budget: { spent: run.costUsd, cap } });
|
|
1042
|
+
return true;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Merge the integration branch into the project's working tree.
|
|
1046
|
+
*
|
|
1047
|
+
* Explicit, never automatic: the run's work lands on its own branch, and the
|
|
1048
|
+
* human decides when it reaches theirs.
|
|
1049
|
+
*/
|
|
1050
|
+
async apply(runId, taskId) {
|
|
1051
|
+
const run = this.mustGet(runId);
|
|
1052
|
+
if (run.status !== "completed" && run.status !== "waiting_human" && run.status !== "failed" && run.status !== "aborted") {
|
|
1053
|
+
throw new Error(`run ${run.id} is still ${run.status} — wait for it to finish`);
|
|
1054
|
+
}
|
|
1055
|
+
const dir = this.host.projectDir;
|
|
1056
|
+
const into = (await git(["rev-parse", "--abbrev-ref", "HEAD"], dir)).trim();
|
|
1057
|
+
if (run.race) {
|
|
1058
|
+
// A race applies the one entrant you picked, never all of them.
|
|
1059
|
+
const pick = run.tasks.find((t) => t.id === taskId);
|
|
1060
|
+
if (!pick)
|
|
1061
|
+
throw new Error(`pick an entrant to apply: ${run.tasks.map((t) => t.id).join(", ")}`);
|
|
1062
|
+
if (pick.status !== "done" || !pick.branch)
|
|
1063
|
+
throw new Error(`${pick.id} didn't finish, so there's nothing of it to apply`);
|
|
1064
|
+
if (run.applied)
|
|
1065
|
+
throw new Error(`${run.applied.task ?? "an entrant"} was already applied from this race`);
|
|
1066
|
+
await this.gitLock.run(async () => {
|
|
1067
|
+
try {
|
|
1068
|
+
await git(["-c", "user.name=Loom Orchestra", "-c", "user.email=orchestra@loom.local", "merge", "--no-ff", "--no-edit", "-m", `Race ${run.id}: ${pick.agent}'s take on ${run.goal.slice(0, 50)}`, pick.branch], dir);
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
await git(["merge", "--abort"], dir).catch(() => { });
|
|
1072
|
+
throw new Error(`merge into ${into} failed — ${err.message}`);
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
run.applied = { at: Date.now(), into, task: pick.id };
|
|
1076
|
+
run.summary = raceApplied(run);
|
|
1077
|
+
this.save(run);
|
|
1078
|
+
this.emit(run, "applied", { into, task: pick.id, agent: pick.agent });
|
|
1079
|
+
return { merged: pick.branch, into };
|
|
1080
|
+
}
|
|
1081
|
+
await this.gitLock.run(async () => {
|
|
1082
|
+
await commitAll(run.dir, `orchestra ${run.id}: final edits`).catch(() => false);
|
|
1083
|
+
try {
|
|
1084
|
+
await git(["-c", "user.name=Loom Orchestra", "-c", "user.email=orchestra@loom.local", "merge", "--no-ff", "--no-edit", "-m", `Merge orchestra run ${run.id}: ${run.goal.slice(0, 60)}`, run.branch], dir);
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
await git(["merge", "--abort"], dir).catch(() => { });
|
|
1088
|
+
throw new Error(`merge into ${into} failed — ${err.message}`);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
run.applied = { at: Date.now(), into };
|
|
1092
|
+
this.save(run);
|
|
1093
|
+
this.emit(run, "applied", { into });
|
|
1094
|
+
return { merged: run.branch, into };
|
|
1095
|
+
}
|
|
1096
|
+
/** What one task changed on its branch since the run began — a race entrant's diff. */
|
|
1097
|
+
async taskDiff(runId, taskId) {
|
|
1098
|
+
const run = this.mustGet(runId);
|
|
1099
|
+
const task = run.tasks.find((t) => t.id === taskId);
|
|
1100
|
+
if (!task)
|
|
1101
|
+
throw new Error(`no task "${taskId}" in run ${run.id}`);
|
|
1102
|
+
if (!task.branch)
|
|
1103
|
+
return "";
|
|
1104
|
+
const patch = await git(["diff", `${run.baseCommit}...${task.branch}`], this.host.projectDir).catch(() => "");
|
|
1105
|
+
return patch.length > 400_000 ? `${patch.slice(0, 400_000)}\n… (truncated)` : patch;
|
|
1106
|
+
}
|
|
1107
|
+
/** Remove a finished run's worktrees (the branch stays for the record). */
|
|
1108
|
+
async cleanup(runId) {
|
|
1109
|
+
const run = this.mustGet(runId);
|
|
1110
|
+
if (!isTerminal(run.status) && run.status !== "waiting_human")
|
|
1111
|
+
throw new Error("run is still active");
|
|
1112
|
+
await this.stopAll(run);
|
|
1113
|
+
const dirs = [run.dir, ...run.tasks.map((t) => t.dir).filter((d) => !!d)];
|
|
1114
|
+
await this.gitLock.run(async () => {
|
|
1115
|
+
for (const d of dirs)
|
|
1116
|
+
await git(["worktree", "remove", "--force", d], this.host.projectDir).catch(() => { });
|
|
1117
|
+
await git(["worktree", "prune"], this.host.projectDir).catch(() => { });
|
|
1118
|
+
});
|
|
1119
|
+
this.emit(run, "cleaned");
|
|
1120
|
+
}
|
|
1121
|
+
async shutdown() {
|
|
1122
|
+
for (const run of this.runs.values()) {
|
|
1123
|
+
if (isTerminal(run.status))
|
|
1124
|
+
continue;
|
|
1125
|
+
run.interrupted = {
|
|
1126
|
+
at: Date.now(),
|
|
1127
|
+
tasks: run.tasks.filter((t) => t.status === "running" || t.status === "pending").map((t) => t.id),
|
|
1128
|
+
phase: run.status,
|
|
1129
|
+
};
|
|
1130
|
+
await this.abort(run.id, "Loom stopped while this run was going (a restart or shutdown) — what finished is on its branch, and Resume carries on from there");
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Carry on a run Loom stopped by restarting. The tasks that were in flight
|
|
1135
|
+
* go back to pending in the worktrees they left (their work so far is
|
|
1136
|
+
* there); the orchestrator — a fresh session, so it's briefed again —
|
|
1137
|
+
* picks up at review when nothing was in flight.
|
|
1138
|
+
*/
|
|
1139
|
+
async resume(runId) {
|
|
1140
|
+
const run = this.mustGet(runId);
|
|
1141
|
+
if (run.status !== "aborted" || !run.interrupted) {
|
|
1142
|
+
throw new Error(`run ${run.id} wasn't stopped by a restart — start the goal again instead`);
|
|
1143
|
+
}
|
|
1144
|
+
if (!run.dir || !fs.existsSync(run.dir))
|
|
1145
|
+
throw new Error("its worktrees were cleaned up — start the goal again");
|
|
1146
|
+
const again = new Set(run.interrupted.tasks);
|
|
1147
|
+
let pending = 0;
|
|
1148
|
+
for (const t of run.tasks) {
|
|
1149
|
+
if (!again.has(t.id) || t.status !== "cancelled")
|
|
1150
|
+
continue;
|
|
1151
|
+
t.status = "pending";
|
|
1152
|
+
delete t.error;
|
|
1153
|
+
t.reported = false;
|
|
1154
|
+
pending++;
|
|
1155
|
+
}
|
|
1156
|
+
delete run.interrupted;
|
|
1157
|
+
delete run.error;
|
|
1158
|
+
run.maxRounds = Math.max(run.maxRounds, run.round + 10);
|
|
1159
|
+
run.updatedAt = Date.now();
|
|
1160
|
+
this.rebrief.add(run.id);
|
|
1161
|
+
this.emit(run, "resumed", { tasks: pending });
|
|
1162
|
+
if (pending) {
|
|
1163
|
+
run.status = "running";
|
|
1164
|
+
this.save(run);
|
|
1165
|
+
this.schedule(run);
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
run.status = "reviewing";
|
|
1169
|
+
this.save(run);
|
|
1170
|
+
void this.orchestratorTurn(run, `Loom restarted while this run was going, and it's carrying on now.\n\n${this.statusReport(run)}`);
|
|
1171
|
+
}
|
|
1172
|
+
return run;
|
|
1173
|
+
}
|
|
1174
|
+
/** Runs whose orchestrator restarts as a fresh session (resume) and needs its brief again. */
|
|
1175
|
+
rebrief = new Set();
|
|
1176
|
+
mustGet(id) {
|
|
1177
|
+
const run = this.runs.get(id);
|
|
1178
|
+
if (!run)
|
|
1179
|
+
throw new Error(`no orchestra run "${id}"`);
|
|
1180
|
+
return run;
|
|
1181
|
+
}
|
|
1182
|
+
async stopAll(run) {
|
|
1183
|
+
for (const [key, agent] of [...this.live.entries()]) {
|
|
1184
|
+
if (!key.startsWith(`${run.id}/`))
|
|
1185
|
+
continue;
|
|
1186
|
+
this.live.delete(key);
|
|
1187
|
+
if (agent.busy())
|
|
1188
|
+
await agent.interrupt().catch(() => { });
|
|
1189
|
+
await agent.stop().catch(() => { });
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// ── the orchestrator ──
|
|
1193
|
+
orchestratorAgent(run) {
|
|
1194
|
+
const key = `${run.id}/orch`;
|
|
1195
|
+
let agent = this.live.get(key);
|
|
1196
|
+
if (agent)
|
|
1197
|
+
return agent;
|
|
1198
|
+
const cfg = this.resolveAgent(run.orchestrator.agent) ?? {
|
|
1199
|
+
id: run.orchestrator.agent,
|
|
1200
|
+
kind: run.orchestrator.kind,
|
|
1201
|
+
role: "orchestrator",
|
|
1202
|
+
};
|
|
1203
|
+
agent = this.host.makeAgent({ ...cfg, role: "orchestrator", options: orchestratorOptions(cfg) }, run.dir);
|
|
1204
|
+
this.live.set(key, agent);
|
|
1205
|
+
this.wire(run, agent, cfg.id, run.chat, key);
|
|
1206
|
+
return agent;
|
|
1207
|
+
}
|
|
1208
|
+
async orchestratorTurn(run, text, briefing, retry = 0) {
|
|
1209
|
+
if (isTerminal(run.status) || run.moving)
|
|
1210
|
+
return;
|
|
1211
|
+
// a resumed run's orchestrator is a fresh session: its first turn carries the instructions again
|
|
1212
|
+
if (!briefing && this.rebrief.delete(run.id))
|
|
1213
|
+
briefing = this.briefingOf(run);
|
|
1214
|
+
if (this.overBudget(run))
|
|
1215
|
+
return;
|
|
1216
|
+
if (run.round >= run.maxRounds) {
|
|
1217
|
+
return this.finish(run, "failed", `stopped after ${run.maxRounds} orchestrator rounds without "done"`);
|
|
1218
|
+
}
|
|
1219
|
+
run.round++;
|
|
1220
|
+
this.orchestratorBusy.add(run.id);
|
|
1221
|
+
this.save(run);
|
|
1222
|
+
const key = `${run.id}/orch`;
|
|
1223
|
+
this.turnText.set(key, "");
|
|
1224
|
+
let reply = "";
|
|
1225
|
+
try {
|
|
1226
|
+
this.host.gate(run.orchestrator.agent);
|
|
1227
|
+
const agent = this.orchestratorAgent(run);
|
|
1228
|
+
await agent.start();
|
|
1229
|
+
this.host.append({
|
|
1230
|
+
kind: "message",
|
|
1231
|
+
chat: run.chat,
|
|
1232
|
+
payload: { text, author: "loom", orchestra: { runId: run.id, to: "orchestrator" } },
|
|
1233
|
+
});
|
|
1234
|
+
await agent.send({ text, ...(briefing ? { briefing } : {}) });
|
|
1235
|
+
reply = this.turnText.get(key) ?? "";
|
|
1236
|
+
this.turnText.delete(key); // read once per turn; reset before the next
|
|
1237
|
+
}
|
|
1238
|
+
catch (err) {
|
|
1239
|
+
this.orchestratorBusy.delete(run.id);
|
|
1240
|
+
return this.finish(run, "failed", `orchestrator failed: ${err.message}`);
|
|
1241
|
+
}
|
|
1242
|
+
this.orchestratorBusy.delete(run.id);
|
|
1243
|
+
if (isTerminal(run.status))
|
|
1244
|
+
return;
|
|
1245
|
+
const actions = parseOrchestraActions(reply);
|
|
1246
|
+
if (actions === null) {
|
|
1247
|
+
if (retry < 1) {
|
|
1248
|
+
return this.orchestratorTurn(run, "Your reply had no ```loom actions block, so nothing ran. Reply again ending with exactly one " +
|
|
1249
|
+
'```loom {"actions": [...]} ``` block (see your instructions).', undefined, retry + 1);
|
|
1250
|
+
}
|
|
1251
|
+
run.status = "waiting_human";
|
|
1252
|
+
run.question = "The orchestrator's reply had no actions Loom could read. Reply with direction to continue.";
|
|
1253
|
+
this.save(run);
|
|
1254
|
+
this.emit(run, "waiting", { question: run.question });
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const human = this.pendingHuman.get(run.id);
|
|
1258
|
+
if (human?.length) {
|
|
1259
|
+
this.pendingHuman.delete(run.id);
|
|
1260
|
+
this.applyActions(run, actions);
|
|
1261
|
+
return this.orchestratorTurn(run, `The human says: ${human.join("\n")}\n\n${this.statusReport(run)}`);
|
|
1262
|
+
}
|
|
1263
|
+
this.applyActions(run, actions);
|
|
1264
|
+
}
|
|
1265
|
+
applyActions(run, actions) {
|
|
1266
|
+
const notes = [];
|
|
1267
|
+
let finished = null;
|
|
1268
|
+
let question = null;
|
|
1269
|
+
for (const a of actions) {
|
|
1270
|
+
try {
|
|
1271
|
+
if (a.type === "spawn")
|
|
1272
|
+
this.addTask(run, a);
|
|
1273
|
+
else if (a.type === "send")
|
|
1274
|
+
this.followUp(run, a.task, a.message);
|
|
1275
|
+
else if (a.type === "cancel")
|
|
1276
|
+
this.cancelTask(run, a.task);
|
|
1277
|
+
else if (a.type === "ask")
|
|
1278
|
+
question = a.question;
|
|
1279
|
+
else if (a.type === "done")
|
|
1280
|
+
finished = { summary: a.summary };
|
|
1281
|
+
}
|
|
1282
|
+
catch (err) {
|
|
1283
|
+
notes.push(`${a.type}: ${err.message}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
this.save(run);
|
|
1287
|
+
this.emit(run, "plan", {
|
|
1288
|
+
round: run.round,
|
|
1289
|
+
actions: actions.map((a) => a.type),
|
|
1290
|
+
tasks: run.tasks.map(taskSummary),
|
|
1291
|
+
...(notes.length ? { rejected: notes } : {}),
|
|
1292
|
+
});
|
|
1293
|
+
if (question) {
|
|
1294
|
+
run.status = "waiting_human";
|
|
1295
|
+
run.question = question;
|
|
1296
|
+
this.save(run);
|
|
1297
|
+
this.emit(run, "waiting", { question });
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const outstanding = run.tasks.some((t) => t.status === "running" || t.status === "pending");
|
|
1301
|
+
// Nothing running, nothing asked, nothing new: asking again would just loop
|
|
1302
|
+
// to maxRounds (a scripted or real model that says "keep waiting" when there
|
|
1303
|
+
// is nothing to wait for). Stop and let a human steer.
|
|
1304
|
+
if (!actions.length && !outstanding) {
|
|
1305
|
+
run.status = "waiting_human";
|
|
1306
|
+
run.question = "Nothing is running and the orchestrator gave no next step. Reply to steer it, or tell it to finish.";
|
|
1307
|
+
this.save(run);
|
|
1308
|
+
this.emit(run, "waiting", { question: run.question });
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (finished && !outstanding) {
|
|
1312
|
+
return void this.finish(run, "completed", undefined, finished.summary);
|
|
1313
|
+
}
|
|
1314
|
+
if (finished && outstanding) {
|
|
1315
|
+
notes.push("done ignored: tasks are still running — review their results first");
|
|
1316
|
+
}
|
|
1317
|
+
if (notes.length && !outstanding) {
|
|
1318
|
+
// Nothing to wait for and something went wrong: tell the orchestrator now.
|
|
1319
|
+
void this.orchestratorTurn(run, `Some actions were rejected:\n- ${notes.join("\n- ")}\n\n${this.statusReport(run)}`);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
run.status = "running";
|
|
1323
|
+
this.save(run);
|
|
1324
|
+
// Plan mode: the specs land on the integration branch BEFORE any task
|
|
1325
|
+
// branches from it, so every worker's worktree already has its file.
|
|
1326
|
+
if (run.plan && actions.some((a) => a.type === "spawn")) {
|
|
1327
|
+
void this.writePlan(run, "plan").then(() => this.schedule(run));
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
this.schedule(run);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
/** Write (or refresh) plans/<run>/ on the integration branch and commit it. */
|
|
1334
|
+
async writePlan(run, why) {
|
|
1335
|
+
try {
|
|
1336
|
+
await this.gitLock.run(async () => {
|
|
1337
|
+
const dir = path.join(run.dir, planDir(run));
|
|
1338
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1339
|
+
fs.writeFileSync(path.join(dir, "PLAN.md"), renderPlanIndex(run));
|
|
1340
|
+
for (const t of run.tasks)
|
|
1341
|
+
fs.writeFileSync(path.join(dir, `${t.id}.md`), renderTaskFile(run, t));
|
|
1342
|
+
await git(["add", "--", planDir(run)], run.dir);
|
|
1343
|
+
const staged = (await git(["diff", "--cached", "--name-only"], run.dir)).trim();
|
|
1344
|
+
if (staged) {
|
|
1345
|
+
await git(["-c", "user.name=Loom Orchestra", "-c", "user.email=orchestra@loom.local", "commit", "-q", "--no-verify", "-m",
|
|
1346
|
+
why === "plan" ? `plan: ${run.goal.split("\n")[0].slice(0, 60)}` : `plan: results of orchestra ${run.id}`], run.dir);
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
this.emit(run, "plan_written", { dir: planDir(run), files: run.tasks.length + 1, final: why === "final" });
|
|
1350
|
+
}
|
|
1351
|
+
catch (err) {
|
|
1352
|
+
this.emit(run, "plan_failed", { error: err.message.slice(0, 300) });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
addTask(run, a) {
|
|
1356
|
+
const allowed = this.workersFor(run);
|
|
1357
|
+
const cfg = allowed.find((w) => w.id.toLowerCase() === a.agent.toLowerCase()) ??
|
|
1358
|
+
allowed.find((w) => w.kind === this.resolveAgent(a.agent)?.kind);
|
|
1359
|
+
if (!cfg) {
|
|
1360
|
+
throw new Error(`agent "${a.agent}" is not one of this run's workers (${run.workers.join(", ")})`);
|
|
1361
|
+
}
|
|
1362
|
+
let id = (a.id || "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 16);
|
|
1363
|
+
// Re-sending a spawn for a task that hasn't started revises it: that's how
|
|
1364
|
+
// the orchestrator answers a hold (an overlap decision, narrower touches,
|
|
1365
|
+
// another agent) without inventing a new task.
|
|
1366
|
+
const unstarted = run.tasks.find((t) => t.id === id && t.attempts === 0 && t.status === "pending");
|
|
1367
|
+
if (unstarted) {
|
|
1368
|
+
unstarted.title = a.title.slice(0, 120);
|
|
1369
|
+
unstarted.prompt = a.prompt;
|
|
1370
|
+
unstarted.agent = cfg.id;
|
|
1371
|
+
unstarted.kind = cfg.kind;
|
|
1372
|
+
if (a.touches?.length)
|
|
1373
|
+
unstarted.touches = a.touches;
|
|
1374
|
+
if (a.overlap)
|
|
1375
|
+
unstarted.overlap = a.overlap;
|
|
1376
|
+
if (a.dependsOn)
|
|
1377
|
+
unstarted.dependsOn = a.dependsOn.filter((d) => d !== id && run.tasks.some((t) => t.id === d));
|
|
1378
|
+
unstarted.hold = undefined;
|
|
1379
|
+
this.emit(run, "task", { task: taskSummary(unstarted) });
|
|
1380
|
+
return unstarted;
|
|
1381
|
+
}
|
|
1382
|
+
if (!id || run.tasks.some((t) => t.id === id))
|
|
1383
|
+
id = `t${run.tasks.length + 1}`;
|
|
1384
|
+
while (run.tasks.some((t) => t.id === id))
|
|
1385
|
+
id = `${id}x`;
|
|
1386
|
+
const deps = (a.dependsOn ?? []).filter((d) => run.tasks.some((t) => t.id === d));
|
|
1387
|
+
const chat = this.host.createChat(`${id} · ${a.title}`.slice(0, 60), { agentId: cfg.id });
|
|
1388
|
+
const task = {
|
|
1389
|
+
id,
|
|
1390
|
+
title: a.title.slice(0, 120),
|
|
1391
|
+
prompt: a.prompt,
|
|
1392
|
+
agent: cfg.id,
|
|
1393
|
+
kind: cfg.kind,
|
|
1394
|
+
dependsOn: deps,
|
|
1395
|
+
...(a.touches?.length ? { touches: a.touches } : {}),
|
|
1396
|
+
...(a.overlap ? { overlap: a.overlap } : {}),
|
|
1397
|
+
status: "pending",
|
|
1398
|
+
chat: chat.id,
|
|
1399
|
+
attempts: 0,
|
|
1400
|
+
queued: [],
|
|
1401
|
+
};
|
|
1402
|
+
run.tasks.push(task);
|
|
1403
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1404
|
+
return task;
|
|
1405
|
+
}
|
|
1406
|
+
followUp(run, taskId, message) {
|
|
1407
|
+
const task = run.tasks.find((t) => t.id === taskId);
|
|
1408
|
+
if (!task)
|
|
1409
|
+
throw new Error(`no task "${taskId}"`);
|
|
1410
|
+
if (task.status === "cancelled")
|
|
1411
|
+
throw new Error(`task ${taskId} was cancelled`);
|
|
1412
|
+
task.queued.push(message);
|
|
1413
|
+
task.reported = false;
|
|
1414
|
+
if (task.status !== "running")
|
|
1415
|
+
task.status = "pending";
|
|
1416
|
+
}
|
|
1417
|
+
cancelTask(run, taskId) {
|
|
1418
|
+
const task = run.tasks.find((t) => t.id === taskId);
|
|
1419
|
+
if (!task)
|
|
1420
|
+
throw new Error(`no task "${taskId}"`);
|
|
1421
|
+
if (task.status === "done")
|
|
1422
|
+
throw new Error(`task ${taskId} already merged`);
|
|
1423
|
+
task.status = "cancelled";
|
|
1424
|
+
task.reported = true;
|
|
1425
|
+
const live = this.live.get(`${run.id}/${task.id}`);
|
|
1426
|
+
if (live?.busy())
|
|
1427
|
+
void live.interrupt().catch(() => { });
|
|
1428
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1429
|
+
}
|
|
1430
|
+
// ── scheduling ──
|
|
1431
|
+
admitting = new Set(); // `${run}/${task}` mid-admission
|
|
1432
|
+
recheckTimers = new Map();
|
|
1433
|
+
schedule(run) {
|
|
1434
|
+
if (run.status !== "running" || run.moving)
|
|
1435
|
+
return;
|
|
1436
|
+
if (this.overBudget(run))
|
|
1437
|
+
return;
|
|
1438
|
+
const inFlight = run.tasks.filter((t) => t.status === "running" || this.admitting.has(`${run.id}/${t.id}`)).length;
|
|
1439
|
+
let slots = run.maxParallel - inFlight;
|
|
1440
|
+
let waiting = false;
|
|
1441
|
+
for (const task of run.tasks) {
|
|
1442
|
+
if (slots <= 0)
|
|
1443
|
+
break;
|
|
1444
|
+
if (task.status !== "pending")
|
|
1445
|
+
continue;
|
|
1446
|
+
if (this.admitting.has(`${run.id}/${task.id}`))
|
|
1447
|
+
continue;
|
|
1448
|
+
// "decide" holds wait for the orchestrator; the rest are re-checked, not hammered
|
|
1449
|
+
if (task.hold?.kind === "decide")
|
|
1450
|
+
continue;
|
|
1451
|
+
if (task.hold && Date.now() - (task.hold.checkedAt ?? task.hold.since) < RECHECK_MS) {
|
|
1452
|
+
waiting = true;
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const deps = task.dependsOn.map((d) => run.tasks.find((t) => t.id === d));
|
|
1456
|
+
if (deps.some((d) => d && (d.status === "failed" || d.status === "cancelled"))) {
|
|
1457
|
+
task.status = "failed";
|
|
1458
|
+
task.error = "a dependency failed or was cancelled";
|
|
1459
|
+
task.reported = false;
|
|
1460
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (!deps.every((d) => !d || d.status === "done"))
|
|
1464
|
+
continue;
|
|
1465
|
+
slots--;
|
|
1466
|
+
void this.admitAndRun(run, task);
|
|
1467
|
+
}
|
|
1468
|
+
this.save(run);
|
|
1469
|
+
if (waiting || run.tasks.some((t) => t.status === "pending" && t.hold && t.hold.kind !== "decide"))
|
|
1470
|
+
this.armRecheck(run);
|
|
1471
|
+
this.maybeReview(run);
|
|
1472
|
+
}
|
|
1473
|
+
armRecheck(run) {
|
|
1474
|
+
if (this.recheckTimers.has(run.id))
|
|
1475
|
+
return;
|
|
1476
|
+
const t = setTimeout(() => {
|
|
1477
|
+
this.recheckTimers.delete(run.id);
|
|
1478
|
+
if (!isTerminal(run.status))
|
|
1479
|
+
this.schedule(run);
|
|
1480
|
+
}, RECHECK_MS);
|
|
1481
|
+
t.unref?.();
|
|
1482
|
+
this.recheckTimers.set(run.id, t);
|
|
1483
|
+
}
|
|
1484
|
+
/** Something the team coordinator watches changed (a lease freed, a PR merged): look again now. */
|
|
1485
|
+
recheck(runId) {
|
|
1486
|
+
const run = this.runs.get(runId);
|
|
1487
|
+
if (!run || run.status !== "running")
|
|
1488
|
+
return;
|
|
1489
|
+
for (const t of run.tasks)
|
|
1490
|
+
if (t.hold && t.hold.kind !== "decide")
|
|
1491
|
+
t.hold.checkedAt = 0;
|
|
1492
|
+
this.schedule(run);
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Stop a running task and hold it (D33: drift into someone's hard zone).
|
|
1496
|
+
* It keeps its worktree and session; when the hold clears it resumes with a
|
|
1497
|
+
* "continue" follow-up instead of starting over.
|
|
1498
|
+
*/
|
|
1499
|
+
pauseTask(runId, taskId, hold) {
|
|
1500
|
+
const run = this.runs.get(runId);
|
|
1501
|
+
const task = run?.tasks.find((t) => t.id === taskId);
|
|
1502
|
+
if (!run || !task || task.status !== "running")
|
|
1503
|
+
return;
|
|
1504
|
+
task.status = "pending";
|
|
1505
|
+
task.hold = hold;
|
|
1506
|
+
task.queued.unshift(`You were paused: ${hold.reason}. It's clear now — continue your task where you left off, and stay out of the area you were paused for unless you must.`);
|
|
1507
|
+
const live = this.live.get(`${run.id}/${task.id}`);
|
|
1508
|
+
if (live?.busy())
|
|
1509
|
+
void live.interrupt().catch(() => { });
|
|
1510
|
+
this.save(run);
|
|
1511
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1512
|
+
this.emit(run, "task_held", { taskId: task.id, hold }, task.chat);
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* The owner says stop waiting (D32): a `wait:` hold is released and the task
|
|
1516
|
+
* proceeds alongside the other goal, with the reason on record.
|
|
1517
|
+
*/
|
|
1518
|
+
stopWaiting(runId, taskId) {
|
|
1519
|
+
const run = this.mustGet(runId);
|
|
1520
|
+
const task = run.tasks.find((t) => t.id === taskId);
|
|
1521
|
+
if (!task)
|
|
1522
|
+
throw new Error(`no task "${taskId}"`);
|
|
1523
|
+
if (task.hold?.kind !== "wait")
|
|
1524
|
+
throw new Error(`task ${taskId} isn't waiting on another goal`);
|
|
1525
|
+
task.overlap = `proceed:the owner stopped waiting on ${task.hold.runId ?? "the other goal"}`;
|
|
1526
|
+
task.hold = undefined;
|
|
1527
|
+
this.save(run);
|
|
1528
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1529
|
+
this.schedule(run);
|
|
1530
|
+
return task;
|
|
1531
|
+
}
|
|
1532
|
+
/** A team fact for the orchestrator's next review (D33, D34). */
|
|
1533
|
+
addNote(runId, note) {
|
|
1534
|
+
const run = this.runs.get(runId);
|
|
1535
|
+
if (!run || isTerminal(run.status))
|
|
1536
|
+
return;
|
|
1537
|
+
run.notes = [...(run.notes ?? []), note.slice(0, 1000)].slice(-20);
|
|
1538
|
+
this.save(run);
|
|
1539
|
+
}
|
|
1540
|
+
/** Ask the team coordinator (if any) whether this task may start, then start it. */
|
|
1541
|
+
async admitAndRun(run, task) {
|
|
1542
|
+
const coord = this.host.coordinator?.();
|
|
1543
|
+
if (!coord)
|
|
1544
|
+
return this.runTask(run, task);
|
|
1545
|
+
const key = `${run.id}/${task.id}`;
|
|
1546
|
+
this.admitting.add(key);
|
|
1547
|
+
let a;
|
|
1548
|
+
try {
|
|
1549
|
+
a = await coord.admit(run, task);
|
|
1550
|
+
}
|
|
1551
|
+
catch (err) {
|
|
1552
|
+
a = { go: false, hold: { kind: "capacity", reason: `team hub unreachable: ${err.message}`, since: Date.now() } };
|
|
1553
|
+
}
|
|
1554
|
+
this.admitting.delete(key);
|
|
1555
|
+
if (isTerminal(run.status) || task.status !== "pending")
|
|
1556
|
+
return;
|
|
1557
|
+
if (!a.go) {
|
|
1558
|
+
const was = task.hold?.reason;
|
|
1559
|
+
task.hold = { ...a.hold, since: task.hold?.since ?? a.hold.since, checkedAt: Date.now() };
|
|
1560
|
+
task.reported = false;
|
|
1561
|
+
this.save(run);
|
|
1562
|
+
if (was !== task.hold.reason) {
|
|
1563
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1564
|
+
this.emit(run, "task_held", { taskId: task.id, hold: task.hold }, task.chat);
|
|
1565
|
+
}
|
|
1566
|
+
this.schedule(run);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
task.hold = undefined;
|
|
1570
|
+
if (a.note)
|
|
1571
|
+
this.addNote(run.id, a.note);
|
|
1572
|
+
if (a.rebase) {
|
|
1573
|
+
try {
|
|
1574
|
+
await this.syncWithBase(run);
|
|
1575
|
+
}
|
|
1576
|
+
catch (err) {
|
|
1577
|
+
task.hold = { kind: "decide", reason: `couldn't bring fresh ${run.baseBranch ?? "main"} into the goal before starting: ${err.message}`, since: Date.now() };
|
|
1578
|
+
this.save(run);
|
|
1579
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1580
|
+
this.maybeReview(run);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return this.runTask(run, task);
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Bring the base branch's latest into the integration branch (D30): a task
|
|
1588
|
+
* that waited for a teammate's PR must build on the merged code.
|
|
1589
|
+
*/
|
|
1590
|
+
async syncWithBase(run) {
|
|
1591
|
+
const base = run.baseBranch;
|
|
1592
|
+
if (!base)
|
|
1593
|
+
return;
|
|
1594
|
+
await this.gitLock.run(async () => {
|
|
1595
|
+
await git(["fetch", "-q", "origin", base], run.dir).catch(() => { });
|
|
1596
|
+
const ref = (await gitOk(["rev-parse", "--verify", `origin/${base}`], run.dir)) ? `origin/${base}` : base;
|
|
1597
|
+
await commitAll(run.dir, `orchestra ${run.id}: orchestrator edits`);
|
|
1598
|
+
try {
|
|
1599
|
+
await git(["-c", "user.name=Loom Orchestra", "-c", "user.email=orchestra@loom.local", "merge", "--no-edit", "-q", ref], run.dir);
|
|
1600
|
+
}
|
|
1601
|
+
catch (err) {
|
|
1602
|
+
await git(["merge", "--abort"], run.dir).catch(() => { });
|
|
1603
|
+
throw err;
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
this.emit(run, "synced", { with: base });
|
|
1607
|
+
}
|
|
1608
|
+
/** When nothing can move without the orchestrator, give it a turn. */
|
|
1609
|
+
maybeReview(run) {
|
|
1610
|
+
if (run.status !== "running" || this.orchestratorBusy.has(run.id))
|
|
1611
|
+
return;
|
|
1612
|
+
if (run.tasks.some((t) => t.status === "running" || this.admitting.has(`${run.id}/${t.id}`)))
|
|
1613
|
+
return;
|
|
1614
|
+
if (run.race) {
|
|
1615
|
+
// A race has no reviewer: it's over when every entrant has finished.
|
|
1616
|
+
if (run.tasks.some((t) => t.status === "pending"))
|
|
1617
|
+
return;
|
|
1618
|
+
const done = run.tasks.filter((t) => t.status === "done").length;
|
|
1619
|
+
void this.finish(run, "completed", undefined, `${done} of ${run.tasks.length} finished — ${PICK_ONE}`);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const ready = (t) => t.status === "pending" && t.dependsOn.every((d) => run.tasks.find((x) => x.id === d)?.status === "done");
|
|
1623
|
+
if (run.tasks.some((t) => ready(t) && !t.hold))
|
|
1624
|
+
return;
|
|
1625
|
+
// Waiting on teammates (a PR, a zone, capacity) is not a reason to wake
|
|
1626
|
+
// the orchestrator — unless something needs its decision.
|
|
1627
|
+
const needsDecision = run.tasks.some((t) => ready(t) && t.hold?.kind === "decide");
|
|
1628
|
+
if (!needsDecision && run.tasks.some((t) => ready(t) && t.hold))
|
|
1629
|
+
return;
|
|
1630
|
+
run.status = "reviewing";
|
|
1631
|
+
this.save(run);
|
|
1632
|
+
this.emit(run, "reviewing", { round: run.round + 1 });
|
|
1633
|
+
void this.orchestratorTurn(run, this.statusReport(run));
|
|
1634
|
+
}
|
|
1635
|
+
/** The report the orchestrator reviews: every task's outcome since last time. */
|
|
1636
|
+
statusReport(run) {
|
|
1637
|
+
const lines = [`Status of orchestra run ${run.id} (integration branch ${run.branch}):`];
|
|
1638
|
+
for (const t of run.tasks) {
|
|
1639
|
+
const head = `\n### ${t.id} · ${t.title} — ${t.status.toUpperCase()} (worker: ${t.agent}, attempts: ${t.attempts})`;
|
|
1640
|
+
lines.push(head);
|
|
1641
|
+
if (t.reported && (t.status === "done" || t.status === "cancelled")) {
|
|
1642
|
+
lines.push("(already reviewed)");
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
if (t.files?.length)
|
|
1646
|
+
lines.push(`Files changed: ${t.files.slice(0, 30).join(", ")}${t.files.length > 30 ? " …" : ""}`);
|
|
1647
|
+
if (t.status === "conflict") {
|
|
1648
|
+
lines.push(`Its branch ${t.branch} conflicts with the integration branch. To fix, send it a follow-up asking it to ` +
|
|
1649
|
+
`run \`git merge ${run.branch}\` in its worktree, resolve the conflicts, and verify.`);
|
|
1650
|
+
}
|
|
1651
|
+
if (t.hold) {
|
|
1652
|
+
lines.push(`On hold (${t.hold.kind}): ${t.hold.reason}`);
|
|
1653
|
+
if (t.hold.kind === "decide") {
|
|
1654
|
+
lines.push(`Answer by re-sending the spawn for ${t.id} (same id) with "overlap": "wait:<goal id>" (start after that ` +
|
|
1655
|
+
`teammate's PR merges), "narrow" (with revised, non-overlapping "touches"), or "proceed:<why it's safe>"; ` +
|
|
1656
|
+
`or give it "touches" / another agent as the reason says.`);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
if (t.status === "needs_input") {
|
|
1660
|
+
lines.push(`${t.id} is waiting on an answer. Reply to it with a \`send\` action (task "${t.id}") — it resumes in the same worktree — ` +
|
|
1661
|
+
`or, if only the human can answer, use \`ask\`.`);
|
|
1662
|
+
}
|
|
1663
|
+
if (t.error)
|
|
1664
|
+
lines.push(`Error: ${t.error}`);
|
|
1665
|
+
if (t.result)
|
|
1666
|
+
lines.push(`Worker report:\n${t.result}`);
|
|
1667
|
+
t.reported = true;
|
|
1668
|
+
}
|
|
1669
|
+
if (run.notes?.length) {
|
|
1670
|
+
lines.push("\n## From your team");
|
|
1671
|
+
for (const n of run.notes)
|
|
1672
|
+
lines.push(`- ${n}`);
|
|
1673
|
+
run.notes = [];
|
|
1674
|
+
}
|
|
1675
|
+
lines.push("\nReview the results (the integration branch in your working directory has every merged task). " +
|
|
1676
|
+
"Reply with your next actions — follow-ups, new tasks, or done.");
|
|
1677
|
+
this.save(run);
|
|
1678
|
+
return lines.join("\n");
|
|
1679
|
+
}
|
|
1680
|
+
async runTask(run, task) {
|
|
1681
|
+
task.status = "running";
|
|
1682
|
+
task.attempts++;
|
|
1683
|
+
task.startedAt ??= Date.now();
|
|
1684
|
+
task.error = undefined;
|
|
1685
|
+
this.save(run);
|
|
1686
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1687
|
+
this.emit(run, "task_started", { taskId: task.id, agent: task.agent, title: task.title }, task.chat);
|
|
1688
|
+
const key = `${run.id}/${task.id}`;
|
|
1689
|
+
try {
|
|
1690
|
+
this.host.gate(task.agent);
|
|
1691
|
+
if (!task.dir) {
|
|
1692
|
+
const slug = task.id;
|
|
1693
|
+
const branch = `${run.branch.replace(/\/main$/, "")}/${slug}`;
|
|
1694
|
+
const dir = path.join(path.dirname(run.dir), slug);
|
|
1695
|
+
await this.gitLock.run(async () => {
|
|
1696
|
+
// Branch from the integration tip, so dependencies' work is there.
|
|
1697
|
+
const tip = (await git(["rev-parse", "HEAD"], run.dir)).trim();
|
|
1698
|
+
await createWorktree(this.host.projectDir, dir, branch, tip);
|
|
1699
|
+
});
|
|
1700
|
+
task.branch = branch;
|
|
1701
|
+
task.dir = dir;
|
|
1702
|
+
this.save(run);
|
|
1703
|
+
}
|
|
1704
|
+
let agent = this.live.get(key);
|
|
1705
|
+
const freshSession = !agent;
|
|
1706
|
+
if (!agent) {
|
|
1707
|
+
const cfg = this.resolveAgent(task.agent) ?? { id: task.agent, kind: task.kind, role: "worker" };
|
|
1708
|
+
agent = this.host.makeAgent({ ...cfg, role: "worker" }, task.dir);
|
|
1709
|
+
this.live.set(key, agent);
|
|
1710
|
+
this.wire(run, agent, cfg.id, task.chat, key);
|
|
1711
|
+
await agent.start();
|
|
1712
|
+
}
|
|
1713
|
+
// The first attempt always sends the task itself — a follow-up the
|
|
1714
|
+
// orchestrator queued in the same breath waits its turn behind it.
|
|
1715
|
+
const first = task.attempts === 1;
|
|
1716
|
+
// A later attempt sends the orchestrator's next follow-up. With none
|
|
1717
|
+
// queued, this is a task picking back up after Loom restarted (resume,
|
|
1718
|
+
// or a goal moved here): say so, and give it the task again — its
|
|
1719
|
+
// session is new, its worktree still has everything it did.
|
|
1720
|
+
const followUp = first ? undefined : task.queued.shift();
|
|
1721
|
+
const text = first
|
|
1722
|
+
? task.prompt
|
|
1723
|
+
: (followUp ??
|
|
1724
|
+
`Loom was restarted while you were on this task (${task.title}). Everything you'd done is still in this worktree. ` +
|
|
1725
|
+
`Check where it stands, finish the task, and report back.\n\nThe task, again:\n${task.prompt}`);
|
|
1726
|
+
this.host.append({
|
|
1727
|
+
kind: "message",
|
|
1728
|
+
chat: task.chat,
|
|
1729
|
+
payload: { text, author: first ? "orchestrator" : "orchestrator", orchestra: { runId: run.id, taskId: task.id } },
|
|
1730
|
+
});
|
|
1731
|
+
this.turnText.set(key, "");
|
|
1732
|
+
const briefing = first || freshSession
|
|
1733
|
+
? workerBriefing({
|
|
1734
|
+
project: this.host.projectName,
|
|
1735
|
+
runGoal: run.goal,
|
|
1736
|
+
task,
|
|
1737
|
+
branch: task.branch,
|
|
1738
|
+
brain: await this.host.briefingFor(task.prompt, task.agent, task.touches ?? task.files ?? []),
|
|
1739
|
+
...(run.plan ? { planFile: `${planDir(run)}/${task.id}.md` } : {}),
|
|
1740
|
+
})
|
|
1741
|
+
: undefined;
|
|
1742
|
+
await agent.send({ text, ...(briefing ? { briefing } : {}) });
|
|
1743
|
+
if (task.status === "cancelled" || isTerminal(run.status))
|
|
1744
|
+
return;
|
|
1745
|
+
// paused mid-turn (pauseTask): not finished, nothing to integrate yet
|
|
1746
|
+
if (task.status === "pending") {
|
|
1747
|
+
this.schedule(run);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
const reply = (this.turnText.get(key) ?? "").trim();
|
|
1751
|
+
this.turnText.delete(key); // kept on the task (task.result), not here, once the turn is read
|
|
1752
|
+
task.result = reply.length > RESULT_CHARS ? `${reply.slice(0, RESULT_CHARS)}\n… (truncated)` : reply || "(no report)";
|
|
1753
|
+
const turnError = this.lastError.get(key);
|
|
1754
|
+
this.lastError.delete(key);
|
|
1755
|
+
const asked = this.workerQuestion.get(key);
|
|
1756
|
+
this.workerQuestion.delete(key);
|
|
1757
|
+
if (asked) {
|
|
1758
|
+
// Its work so far stays in the worktree, unmerged; the orchestrator
|
|
1759
|
+
// answers with a `send` (the task resumes there) or asks the human.
|
|
1760
|
+
task.status = "needs_input";
|
|
1761
|
+
task.result = `The worker stopped to ask: ${asked}${reply ? `\n\nWhat it said before asking:\n${task.result}` : ""}`;
|
|
1762
|
+
task.finishedAt = Date.now();
|
|
1763
|
+
task.reported = false;
|
|
1764
|
+
this.save(run);
|
|
1765
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1766
|
+
this.emit(run, "task_finished", { taskId: task.id, status: task.status, files: task.files ?? [] }, task.chat);
|
|
1767
|
+
this.schedule(run);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (turnError)
|
|
1771
|
+
task.error = turnError;
|
|
1772
|
+
// A turn that errored and said nothing died — it is not "done", and a
|
|
1773
|
+
// task depending on it must not start on top of it. (An error event
|
|
1774
|
+
// alongside a real report is a warning the orchestrator gets to read.)
|
|
1775
|
+
if (turnError && !reply) {
|
|
1776
|
+
task.status = "failed";
|
|
1777
|
+
}
|
|
1778
|
+
else if (task.queued.length) {
|
|
1779
|
+
// The orchestrator already queued a follow-up: keep going on the same
|
|
1780
|
+
// worktree before merging.
|
|
1781
|
+
task.status = "pending";
|
|
1782
|
+
}
|
|
1783
|
+
else {
|
|
1784
|
+
await this.integrate(run, task);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
catch (err) {
|
|
1788
|
+
if (task.status !== "cancelled") {
|
|
1789
|
+
task.status = "failed";
|
|
1790
|
+
task.error = err.message.slice(0, 1000);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
task.finishedAt = Date.now();
|
|
1794
|
+
task.reported = false;
|
|
1795
|
+
this.save(run);
|
|
1796
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1797
|
+
this.emit(run, "task_finished", { taskId: task.id, status: task.status, files: task.files ?? [] }, task.chat);
|
|
1798
|
+
if (task.status === "done")
|
|
1799
|
+
this.host.coordinator?.()?.onTaskDone?.(run, task);
|
|
1800
|
+
this.schedule(run);
|
|
1801
|
+
}
|
|
1802
|
+
lastError = new Map();
|
|
1803
|
+
/** `${run}/${task}` → the question a worker stopped to ask mid-turn. */
|
|
1804
|
+
workerQuestion = new Map();
|
|
1805
|
+
async integrate(run, task) {
|
|
1806
|
+
const dir = task.dir;
|
|
1807
|
+
if (run.race) {
|
|
1808
|
+
// An entrant's work stays on its own branch, to be compared, not merged.
|
|
1809
|
+
await this.gitLock.run(async () => {
|
|
1810
|
+
await commitAll(dir, taskCommitMessage(run, task, this.host.member?.() ?? null));
|
|
1811
|
+
task.files = (await git(["diff", "--name-only", `${run.baseCommit}...HEAD`], dir).catch(() => "")).split("\n").filter(Boolean);
|
|
1812
|
+
const stat = await git(["diff", "--shortstat", `${run.baseCommit}...HEAD`], dir).catch(() => "");
|
|
1813
|
+
task.lines = [...stat.matchAll(/(\d+) (?:insertion|deletion)/g)].reduce((n, m) => n + Number(m[1]), 0);
|
|
1814
|
+
task.status = "done";
|
|
1815
|
+
});
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
await this.gitLock.run(async () => {
|
|
1819
|
+
await commitAll(dir, taskCommitMessage(run, task, this.host.member?.() ?? null));
|
|
1820
|
+
const files = (await git(["diff", "--name-only", `${run.baseCommit}...HEAD`], dir).catch(() => ""))
|
|
1821
|
+
.split("\n")
|
|
1822
|
+
.filter(Boolean);
|
|
1823
|
+
task.files = files;
|
|
1824
|
+
// Anything the orchestrator left lying in the integration tree would
|
|
1825
|
+
// block the merge; record it rather than lose it.
|
|
1826
|
+
await commitAll(run.dir, `orchestra ${run.id}: orchestrator edits`);
|
|
1827
|
+
try {
|
|
1828
|
+
await git(["-c", "user.name=Loom Orchestra", "-c", "user.email=orchestra@loom.local", "merge", "--no-ff", "--no-edit", "-m", `orchestra ${task.id}: ${task.title}`, task.branch], run.dir);
|
|
1829
|
+
task.status = "done";
|
|
1830
|
+
task.mergeCommit = (await git(["rev-parse", "HEAD"], run.dir)).trim();
|
|
1831
|
+
const stat = await git(["diff", "--shortstat", `${task.mergeCommit}^1`, task.mergeCommit], run.dir).catch(() => "");
|
|
1832
|
+
task.lines = [...stat.matchAll(/(\d+) (?:insertion|deletion)/g)].reduce((n, m) => n + Number(m[1]), 0);
|
|
1833
|
+
}
|
|
1834
|
+
catch (err) {
|
|
1835
|
+
await git(["merge", "--abort"], run.dir).catch(() => { });
|
|
1836
|
+
task.status = "conflict";
|
|
1837
|
+
task.error = `merge conflict: ${err.message.slice(0, 400)}`;
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
if (task.status === "needs_input")
|
|
1841
|
+
return;
|
|
1842
|
+
if (task.status === "done" && /\?\s*$/.test(task.result ?? "") && !task.files?.length) {
|
|
1843
|
+
// Ended on a question and changed nothing: it's asking, not done.
|
|
1844
|
+
task.status = "needs_input";
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
// ── event wiring ──
|
|
1848
|
+
wire(run, agent, agentId, chat, key) {
|
|
1849
|
+
agent.onStream?.((d) => this.host.stream?.({ agentId, chat, ...d }));
|
|
1850
|
+
agent.onEvent((e) => {
|
|
1851
|
+
const p = e.payload;
|
|
1852
|
+
if (e.kind === "message" && !p.reasoning && p.role !== "user") {
|
|
1853
|
+
const prev = this.turnText.get(key) ?? "";
|
|
1854
|
+
this.turnText.set(key, capTurnText(`${prev}\n${String(p.text ?? "")}`));
|
|
1855
|
+
}
|
|
1856
|
+
if (e.kind === "error")
|
|
1857
|
+
this.lastError.set(key, String(p.message ?? "error"));
|
|
1858
|
+
// A worker that stops mid-turn to ask (opencode's question tool, a CLI's
|
|
1859
|
+
// permission prompt) would wait forever for an answer nobody gives — the
|
|
1860
|
+
// goal hung (found by the real-product e2e run). Take the question up to
|
|
1861
|
+
// the orchestrator: stop the turn, and the task ends as needs_input.
|
|
1862
|
+
if (e.kind === "needs_input" && !key.endsWith("/orch") && !this.workerQuestion.has(key)) {
|
|
1863
|
+
this.workerQuestion.set(key, String(p.question ?? "the worker needs input").slice(0, 500));
|
|
1864
|
+
void agent.interrupt().catch(() => { });
|
|
1865
|
+
}
|
|
1866
|
+
if (e.kind === "file_edit" && !key.endsWith("/orch") && typeof p.path === "string") {
|
|
1867
|
+
const task = run.tasks.find((t) => `${run.id}/${t.id}` === key);
|
|
1868
|
+
if (task)
|
|
1869
|
+
this.host.coordinator?.()?.onEdit?.(run, task, p.path);
|
|
1870
|
+
}
|
|
1871
|
+
const event = this.host.append({
|
|
1872
|
+
kind: e.kind,
|
|
1873
|
+
agentId,
|
|
1874
|
+
chat,
|
|
1875
|
+
payload: { ...p, orchestra: { runId: run.id, ...(key.endsWith("/orch") ? { role: "orchestrator" } : { taskId: key.split("/")[1] }) } },
|
|
1876
|
+
});
|
|
1877
|
+
if (e.kind === "status" && p.state === "turn_cost") {
|
|
1878
|
+
const usd = Number(p.costUsd ?? 0);
|
|
1879
|
+
if (usd > 0) {
|
|
1880
|
+
run.costUsd += usd;
|
|
1881
|
+
const task = run.tasks.find((t) => `${run.id}/${t.id}` === key);
|
|
1882
|
+
if (task)
|
|
1883
|
+
task.costUsd = (task.costUsd ?? 0) + usd;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
this.host.observe(event);
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
/** A finished or aborted run's per-turn scratch (reply text, errors, questions) goes with it. */
|
|
1890
|
+
forgetRun(runId) {
|
|
1891
|
+
const prefix = `${runId}/`;
|
|
1892
|
+
for (const map of [this.turnText, this.lastError, this.workerQuestion])
|
|
1893
|
+
for (const k of [...map.keys()])
|
|
1894
|
+
if (k.startsWith(prefix))
|
|
1895
|
+
map.delete(k);
|
|
1896
|
+
}
|
|
1897
|
+
async finish(run, status, error, summary) {
|
|
1898
|
+
// Do the slow parts first, then flip the status and announce it in one
|
|
1899
|
+
// synchronous step: a client that sees "completed" must also find the
|
|
1900
|
+
// completed event (polling status in the gap raced this).
|
|
1901
|
+
await this.stopAll(run);
|
|
1902
|
+
this.forgetRun(run.id);
|
|
1903
|
+
if (run.plan) {
|
|
1904
|
+
// The plan's final state — statuses and results — rides the branch too.
|
|
1905
|
+
const final = { ...run, status, ...(error ? { error } : {}), ...(summary ? { summary } : {}) };
|
|
1906
|
+
await this.writePlan(final, "final");
|
|
1907
|
+
}
|
|
1908
|
+
const commits = await git(["rev-list", "--count", `${run.baseCommit}..${run.branch}`], this.host.projectDir).catch(() => "0");
|
|
1909
|
+
// Aborted while we were finishing up: the human's abort stands.
|
|
1910
|
+
if (run.status === "aborted")
|
|
1911
|
+
return;
|
|
1912
|
+
run.status = status;
|
|
1913
|
+
if (error)
|
|
1914
|
+
run.error = error;
|
|
1915
|
+
if (summary)
|
|
1916
|
+
run.summary = summary;
|
|
1917
|
+
this.save(run);
|
|
1918
|
+
this.emit(run, status, {
|
|
1919
|
+
...(summary ? { summary } : {}),
|
|
1920
|
+
...(error ? { error } : {}),
|
|
1921
|
+
branch: run.branch,
|
|
1922
|
+
commits: Number(commits.trim()) || 0,
|
|
1923
|
+
tasks: run.tasks.map(taskSummary),
|
|
1924
|
+
costUsd: run.costUsd,
|
|
1925
|
+
...(run.race ? { race: true } : {}),
|
|
1926
|
+
});
|
|
1927
|
+
if (summary && run.race) {
|
|
1928
|
+
// nobody led a race, so Loom says how it ended
|
|
1929
|
+
this.host.append({
|
|
1930
|
+
kind: "message",
|
|
1931
|
+
chat: run.chat,
|
|
1932
|
+
payload: { text: `🏁 Race finished — ${summary} They're side by side in the Orchestra tab.`, author: "loom", orchestra: { runId: run.id, role: "summary" } },
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
else if (summary) {
|
|
1936
|
+
this.host.append({
|
|
1937
|
+
kind: "message",
|
|
1938
|
+
agentId: run.orchestrator.agent,
|
|
1939
|
+
chat: run.chat,
|
|
1940
|
+
payload: { text: `**Orchestra complete.** ${summary}\n\nAll work is on branch \`${run.branch}\` — apply it to merge into your branch.`, orchestra: { runId: run.id, role: "summary" } },
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
this.host.coordinator?.()?.onRunEnd?.(run);
|
|
1944
|
+
// A goal that already has a PR (a fix round, an adopted goal) always goes back to that PR.
|
|
1945
|
+
// A race's integration branch is empty until you pick an entrant.
|
|
1946
|
+
if (status === "completed" && !run.race)
|
|
1947
|
+
await this.deliver(run, run.from || run.delivered?.prUrl ? "pr" : undefined);
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Do what the project's git delivery policy says with a completed run:
|
|
1951
|
+
* nothing, merge it in, merge and push, or push its branch and open a PR.
|
|
1952
|
+
* A failure here never un-completes the run — the work is safe on its
|
|
1953
|
+
* branch — it's reported, and the manual Apply is still there.
|
|
1954
|
+
*/
|
|
1955
|
+
/**
|
|
1956
|
+
* D59: where a big goal's stack is cut — tasks in the order they merged into
|
|
1957
|
+
* the integration branch, sliced by size (cutStack). Null when unknowable.
|
|
1958
|
+
*/
|
|
1959
|
+
async stackSlices(run) {
|
|
1960
|
+
const order = (await git(["rev-list", "--first-parent", "--reverse", `${run.baseCommit}..${run.branch}`], run.dir).catch(() => ""))
|
|
1961
|
+
.split("\n")
|
|
1962
|
+
.filter(Boolean);
|
|
1963
|
+
const merged = run.tasks
|
|
1964
|
+
.filter((t) => t.status === "done" && t.mergeCommit && order.includes(t.mergeCommit))
|
|
1965
|
+
.sort((a, b) => order.indexOf(a.mergeCommit) - order.indexOf(b.mergeCommit))
|
|
1966
|
+
.map((t) => ({ id: t.id, commit: t.mergeCommit, lines: t.lines ?? 0, dependsOn: t.dependsOn }));
|
|
1967
|
+
return merged.length ? cutStack(merged) : null;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Push one branch per slice and open a PR for each, each based on the one
|
|
1971
|
+
* below; the top slice is the goal's own branch (so fixes land on it). The
|
|
1972
|
+
* landing manager lands them bottom-up with the merge method.
|
|
1973
|
+
*/
|
|
1974
|
+
async deliverStack(run, slices) {
|
|
1975
|
+
const prefix = run.branch.replace(/\/main$/, "");
|
|
1976
|
+
const base = run.baseBranch ?? "main";
|
|
1977
|
+
const stack = [];
|
|
1978
|
+
let below = base;
|
|
1979
|
+
for (let i = 0; i < slices.length; i++) {
|
|
1980
|
+
const top = i === slices.length - 1;
|
|
1981
|
+
const branch = top ? run.branch : `${prefix}/stack-${i + 1}`;
|
|
1982
|
+
if (!top)
|
|
1983
|
+
await git(["push", "-q", "-f", "origin", `${slices[i].at(-1).commit}:refs/heads/${branch}`], run.dir);
|
|
1984
|
+
const ids = slices[i].map((t) => t.id);
|
|
1985
|
+
const out = await gh(["pr", "create", "--head", branch, "--base", below,
|
|
1986
|
+
"--title", `${run.goal.split("\n")[0].slice(0, 60)} (${i + 1}/${slices.length})`,
|
|
1987
|
+
"--body", prBody(run, [`Part ${i + 1} of ${slices.length} of a stacked goal — tasks ${ids.join(", ")}. Land bottom-up.`, ""])], this.host.projectDir);
|
|
1988
|
+
const url = out.match(/https:\/\/\S+/)?.[0] ?? "";
|
|
1989
|
+
stack.push({ pr: Number(/\/pull\/(\d+)/.exec(url)?.[1] ?? 0), url, branch, base: below });
|
|
1990
|
+
below = branch;
|
|
1991
|
+
}
|
|
1992
|
+
const topPr = stack.at(-1);
|
|
1993
|
+
run.landing = { ...newLanding(topPr.pr, topPr.url), stack };
|
|
1994
|
+
this.emit(run, "stacked", { stack });
|
|
1995
|
+
return topPr.url;
|
|
1996
|
+
}
|
|
1997
|
+
async deliver(run, mode = this.host.gitDelivery?.() ?? "none") {
|
|
1998
|
+
if (mode === "none")
|
|
1999
|
+
return;
|
|
2000
|
+
// D38: a protected branch only receives PRs, whatever the project setting says.
|
|
2001
|
+
const intoBranch = run.baseBranch ?? "";
|
|
2002
|
+
if ((mode === "commit" || mode === "push") && intoBranch && this.host.coordinator?.()?.isProtected?.(intoBranch)) {
|
|
2003
|
+
this.emit(run, "delivery_policy", { from: mode, to: "pr", branch: intoBranch });
|
|
2004
|
+
mode = "pr";
|
|
2005
|
+
}
|
|
2006
|
+
try {
|
|
2007
|
+
if (mode === "commit" || mode === "push") {
|
|
2008
|
+
const { into } = await this.apply(run.id);
|
|
2009
|
+
let pushed;
|
|
2010
|
+
if (mode === "push") {
|
|
2011
|
+
const up = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], this.host.projectDir).catch(() => "");
|
|
2012
|
+
await git(up.trim() ? ["push", "-q"] : ["push", "-q", "-u", "origin", into], this.host.projectDir);
|
|
2013
|
+
pushed = into;
|
|
2014
|
+
}
|
|
2015
|
+
run.delivered = { mode, into, ...(pushed ? { pushed } : {}), at: Date.now() };
|
|
2016
|
+
}
|
|
2017
|
+
else if (mode === "pr") {
|
|
2018
|
+
await this.gitLock.run(() => commitAll(run.dir, `orchestra ${run.id}: final edits`).catch(() => false));
|
|
2019
|
+
const target = this.prBranch(run);
|
|
2020
|
+
await git(["push", "-q", "-u", "origin", `${run.branch}:${target}`], run.dir);
|
|
2021
|
+
if (run.delivered?.prUrl || run.from) {
|
|
2022
|
+
// A fix to a PR that already exists (Phase 4): the push is the delivery.
|
|
2023
|
+
const prUrl = run.delivered?.prUrl ?? run.from.url;
|
|
2024
|
+
run.delivered = { mode, pushed: target, prUrl, at: Date.now() };
|
|
2025
|
+
}
|
|
2026
|
+
else {
|
|
2027
|
+
const stack = this.host.coordinator?.()?.stackMode?.() === "auto" ? await this.stackSlices(run) : null;
|
|
2028
|
+
if (stack && stack.length > 1) {
|
|
2029
|
+
run.delivered = { mode, pushed: target, prUrl: await this.deliverStack(run, stack), at: Date.now() };
|
|
2030
|
+
}
|
|
2031
|
+
else {
|
|
2032
|
+
const out = await gh(["pr", "create", "--head", run.branch, ...(run.baseBranch ? ["--base", run.baseBranch] : []),
|
|
2033
|
+
"--title", run.goal.split("\n")[0].slice(0, 70), "--body", prBody(run)], this.host.projectDir);
|
|
2034
|
+
const prUrl = out.match(/https:\/\/\S+/)?.[0];
|
|
2035
|
+
run.delivered = { mode, pushed: run.branch, ...(prUrl ? { prUrl } : {}), at: Date.now() };
|
|
2036
|
+
const n = Number(/\/pull\/(\d+)/.exec(prUrl ?? "")?.[1]);
|
|
2037
|
+
if (prUrl && n)
|
|
2038
|
+
run.landing = newLanding(n, prUrl);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
run.deliveryError = undefined;
|
|
2043
|
+
this.save(run);
|
|
2044
|
+
this.emit(run, "delivered", { ...run.delivered });
|
|
2045
|
+
}
|
|
2046
|
+
catch (err) {
|
|
2047
|
+
run.deliveryError = err.message.slice(0, 500);
|
|
2048
|
+
this.save(run);
|
|
2049
|
+
this.emit(run, "delivery_failed", { mode, error: run.deliveryError });
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
/** A new PR's landing record. */
|
|
2054
|
+
export function newLanding(pr, url) {
|
|
2055
|
+
return { pr, url, state: "open", fixAttempts: 0, reruns: [], flaky: [], reviews: 0, updatedAt: Date.now() };
|
|
2056
|
+
}
|
|
2057
|
+
/** The goal PR's description: the summary, the task table, the plan. */
|
|
2058
|
+
export function prBody(run, extra = []) {
|
|
2059
|
+
return [
|
|
2060
|
+
run.summary ?? "",
|
|
2061
|
+
"",
|
|
2062
|
+
...extra,
|
|
2063
|
+
"| task | agent | status | files |",
|
|
2064
|
+
"|---|---|---|---|",
|
|
2065
|
+
...run.tasks.map((t) => `| ${t.id} · ${t.title} | ${t.agent} | ${t.status} | ${t.files?.length ?? 0} |`),
|
|
2066
|
+
"",
|
|
2067
|
+
...(run.plan ? [`Plan: \`${planDir(run)}/PLAN.md\``, ""] : []),
|
|
2068
|
+
`Orchestrated by Loom · ${run.orchestrator.agent} with ${run.workers.join(", ")}`,
|
|
2069
|
+
].join("\n");
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Verification commands a Claude orchestrator may run while reviewing.
|
|
2073
|
+
*
|
|
2074
|
+
* Headless Claude in acceptEdits mode can't run any shell command, so a Claude
|
|
2075
|
+
* orchestrator could only read the workers' code and take their word that the
|
|
2076
|
+
* tests passed (seen on a real run: "could not run node --test, approval not
|
|
2077
|
+
* granted"). Codex, sandboxed, just ran them. These are the checks a reviewer
|
|
2078
|
+
* runs — tests, typecheck, build, and read-only git — and nothing else.
|
|
2079
|
+
*/
|
|
2080
|
+
export const ORCHESTRATOR_VERIFY_TOOLS = [
|
|
2081
|
+
"npm test", "npm run test", "npm run build", "npm run typecheck", "npm run lint",
|
|
2082
|
+
"pnpm test", "yarn test", "bun test", "node --test", "npx vitest run", "npx tsc --noEmit",
|
|
2083
|
+
"cargo test", "cargo check", "go test", "go build", "pytest", "python -m pytest",
|
|
2084
|
+
"git log", "git diff", "git status", "git show",
|
|
2085
|
+
].map((c) => `Bash(${c}:*)`);
|
|
2086
|
+
function orchestratorOptions(cfg) {
|
|
2087
|
+
if (cfg.kind !== "claude-code")
|
|
2088
|
+
return cfg.options;
|
|
2089
|
+
const extra = Array.isArray(cfg.options?.extraArgs) ? cfg.options.extraArgs : [];
|
|
2090
|
+
return { ...(cfg.options ?? {}), extraArgs: [...extra, "--allowedTools", ORCHESTRATOR_VERIFY_TOOLS.join(",")] };
|
|
2091
|
+
}
|
|
2092
|
+
export function taskSummary(t) {
|
|
2093
|
+
return {
|
|
2094
|
+
id: t.id,
|
|
2095
|
+
title: t.title,
|
|
2096
|
+
agent: t.agent,
|
|
2097
|
+
kind: t.kind,
|
|
2098
|
+
status: t.status,
|
|
2099
|
+
dependsOn: t.dependsOn,
|
|
2100
|
+
chat: t.chat,
|
|
2101
|
+
attempts: t.attempts,
|
|
2102
|
+
files: t.files ?? [],
|
|
2103
|
+
...(t.touches?.length ? { touches: t.touches } : {}),
|
|
2104
|
+
...(t.overlap ? { overlap: t.overlap } : {}),
|
|
2105
|
+
...(t.hold ? { hold: t.hold } : {}),
|
|
2106
|
+
...(t.error ? { error: t.error } : {}),
|
|
2107
|
+
...(t.costUsd ? { costUsd: t.costUsd } : {}),
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
export function isTerminal(s) {
|
|
2111
|
+
return s === "completed" || s === "failed" || s === "aborted" || s === "moved";
|
|
2112
|
+
}
|
|
2113
|
+
/** Where a moved goal's branches travel: hidden refs, like WIP refs (D11, D75). */
|
|
2114
|
+
export function runRef(runId, name) {
|
|
2115
|
+
return `refs/loom/run/${runId}/${name}`;
|
|
2116
|
+
}
|
|
2117
|
+
function clamp(n, lo, hi) {
|
|
2118
|
+
return Math.max(lo, Math.min(hi, Math.floor(Number(n) || lo)));
|
|
2119
|
+
}
|
|
2120
|
+
//# sourceMappingURL=orchestra.js.map
|