From 6ccb060bea7d37e7f303722149708ad35761a355 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 25 Aug 2026 16:20:53 -0500 Subject: [PATCH] =?UTF-8?q?feat(second-brain):=20open=20self-hosted=20Obsi?= =?UTF-8?q?dian=20for=20crews=20=E2=80=94=20harness-agnostic=20v0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP create-backlog-item for natural language Plan/Epic/Task authoring - Worker non-code: optional worktree + per-harness allowlist + doc_review→published - Harness registry: harness_providers/role_harness_routes, WorkerAdapter (minimal|crewai|langgraph|remote-ag-ui), AG-UI POST /api/ag-ui, Settings → Harnesses with Test - Plans tree: backlog.listTree + createPlan/Epic/Task + Start→Convoy, web CRUD without CLI - Fleet: /fleet rollup across workspaces + SSE /api/fleet/events Decision: docs/decisions/2026-08-25-second-brain-os-pivot.md Convoy: .convoys/second-brain-os-v01 Co-authored-by: orchestrator --- .convoys/second-brain-os-v01.md | 69 + .../brief-1-mcp-create-backlog-item.md | 38 + .../brief-2-worker-non-code.md | 40 + .../brief-3-harness-registry-adapter.md | 44 + .../second-brain-os-v01/brief-4-plans-tree.md | 44 + .../brief-5-fleet-rollup.md | 38 + .cursor/mcp.json | 11 +- .env.example | 1 + .../src/lib/resolve-backlog-item.ts | 123 + apps/mcp-server/src/tools/claim-task.ts | 162 +- .../src/tools/create-backlog-item.ts | 362 ++ .../mcp-server/src/tools/find-backlog-item.ts | 70 + apps/mcp-server/src/tools/index.ts | 6 + apps/orchestrator/src/db/convoy.ts | 60 +- apps/orchestrator/src/index.ts | 26 +- .../app/(app)/[workspaceSlug]/plans/page.tsx | 655 +++ .../settings/harnesses/page.tsx | 165 + apps/web/app/(app)/fleet/page.tsx | 190 + apps/web/app/(marketing)/landing/page.tsx | 5 +- .../app/(marketing)/landing/pricing/page.tsx | 120 +- apps/web/app/api/ag-ui/[workspace]/route.ts | 105 + apps/web/app/api/fleet/events/route.ts | 165 + .../components/fleet/fleet-activity-feed.tsx | 101 + .../components/marketing/border-glow-card.tsx | 22 - .../marketing/comparison-section.tsx | 78 - apps/web/components/marketing/cta-section.tsx | 29 +- apps/web/components/marketing/dot-field.tsx | 21 - .../components/marketing/features-section.tsx | 127 +- .../web/components/marketing/hero-section.tsx | 83 +- .../marketing/how-it-works-section.tsx | 96 +- .../components/marketing/manifest-section.tsx | 57 + .../components/marketing/marquee-strip.tsx | 49 + .../web/components/marketing/product-demo.tsx | 75 +- .../components/marketing/scramble-text.tsx | 64 + apps/web/components/marketing/site-footer.tsx | 19 +- apps/web/components/marketing/site-header.tsx | 15 +- .../mission-control/convoy-detail-panel.tsx | 13 + apps/web/package.json | 1 + apps/web/server/lib/convoy/convoy-service.ts | 27 +- apps/web/server/root.ts | 2 + apps/web/server/routers/backlog.ts | 886 +++- apps/web/server/routers/convoys.ts | 110 +- apps/web/server/routers/harnesses.ts | 256 + apps/web/styles/marketing.css | 179 +- apps/worker/src/db/checkpoints.ts | 143 + apps/worker/src/db/convoy.ts | 3 + apps/worker/src/db/harness.ts | 53 + apps/worker/src/db/task-lifecycle.ts | 3 + apps/worker/src/env.ts | 7 + apps/worker/src/git/worktree.ts | 61 +- apps/worker/src/handle-job.test.ts | 286 ++ apps/worker/src/handle-job.ts | 322 +- apps/worker/src/harness.ts | 94 + apps/worker/src/index.dry-run.test.ts | 19 + apps/worker/src/session/minimal-session.ts | 133 +- apps/worker/src/tools/allowlist.ts | 9 +- apps/worker/src/tools/runner.ts | 8 +- docs/convoy-lifecycle.md | 6 +- .../2026-08-25-second-brain-os-pivot.md | 86 + docs/worker.md | 20 +- .../0014_ancient_lenny_balinger.sql | 22 + .../migrations/0015_curved_iron_fist.sql | 35 + .../migrations/meta/0014_snapshot.json | 3954 +++++++++++++++ .../migrations/meta/0015_snapshot.json | 4245 +++++++++++++++++ .../database/migrations/meta/_journal.json | 14 + packages/database/src/schema/agent_runs.ts | 1 + packages/database/src/schema/convoy_events.ts | 1 + .../database/src/schema/harness_providers.ts | 69 + packages/database/src/schema/index.ts | 2 + packages/database/src/schema/relations.ts | 22 + .../database/src/schema/worker_checkpoints.ts | 64 + packages/shared/package.json | 1 + .../shared/src/convoy-lifecycle/kanban.ts | 12 + .../shared/src/convoy-lifecycle/schemas.ts | 7 + .../src/convoy-lifecycle/state-machine.ts | 44 +- .../shared/src/orchestrator/convoy-driver.ts | 2 + packages/shared/src/orchestrator/job.ts | 3 + packages/shared/src/worker/checkpoint.test.ts | 42 + packages/shared/src/worker/checkpoint.ts | 49 + packages/shared/src/worker/executor.ts | 140 + packages/shared/src/worker/index.ts | 24 + .../Task-worker-checkpoint-resume.md | 20 +- .../Task-worker-minimal-tool-loop.md | 6 +- .../Plan-automation-foundation.md | 105 +- pnpm-lock.yaml | 3 + 85 files changed, 14145 insertions(+), 804 deletions(-) create mode 100644 .convoys/second-brain-os-v01.md create mode 100644 .convoys/second-brain-os-v01/brief-1-mcp-create-backlog-item.md create mode 100644 .convoys/second-brain-os-v01/brief-2-worker-non-code.md create mode 100644 .convoys/second-brain-os-v01/brief-3-harness-registry-adapter.md create mode 100644 .convoys/second-brain-os-v01/brief-4-plans-tree.md create mode 100644 .convoys/second-brain-os-v01/brief-5-fleet-rollup.md create mode 100644 apps/mcp-server/src/lib/resolve-backlog-item.ts create mode 100644 apps/mcp-server/src/tools/create-backlog-item.ts create mode 100644 apps/mcp-server/src/tools/find-backlog-item.ts create mode 100644 apps/web/app/(app)/[workspaceSlug]/plans/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx create mode 100644 apps/web/app/(app)/fleet/page.tsx create mode 100644 apps/web/app/api/ag-ui/[workspace]/route.ts create mode 100644 apps/web/app/api/fleet/events/route.ts create mode 100644 apps/web/components/fleet/fleet-activity-feed.tsx delete mode 100644 apps/web/components/marketing/border-glow-card.tsx delete mode 100644 apps/web/components/marketing/comparison-section.tsx delete mode 100644 apps/web/components/marketing/dot-field.tsx create mode 100644 apps/web/components/marketing/manifest-section.tsx create mode 100644 apps/web/components/marketing/marquee-strip.tsx create mode 100644 apps/web/components/marketing/scramble-text.tsx create mode 100644 apps/web/server/routers/harnesses.ts create mode 100644 apps/worker/src/db/checkpoints.ts create mode 100644 apps/worker/src/db/harness.ts create mode 100644 apps/worker/src/handle-job.test.ts create mode 100644 apps/worker/src/harness.ts create mode 100644 apps/worker/src/index.dry-run.test.ts create mode 100644 docs/decisions/2026-08-25-second-brain-os-pivot.md create mode 100644 packages/database/migrations/0014_ancient_lenny_balinger.sql create mode 100644 packages/database/migrations/0015_curved_iron_fist.sql create mode 100644 packages/database/migrations/meta/0014_snapshot.json create mode 100644 packages/database/migrations/meta/0015_snapshot.json create mode 100644 packages/database/src/schema/harness_providers.ts create mode 100644 packages/database/src/schema/worker_checkpoints.ts create mode 100644 packages/shared/src/worker/checkpoint.test.ts create mode 100644 packages/shared/src/worker/checkpoint.ts create mode 100644 packages/shared/src/worker/executor.ts diff --git a/.convoys/second-brain-os-v01.md b/.convoys/second-brain-os-v01.md new file mode 100644 index 0000000..9ea5e8b --- /dev/null +++ b/.convoys/second-brain-os-v01.md @@ -0,0 +1,69 @@ +--- +name: second-brain-os-v01 +classification: server-only +success_metric: Web-first second brain OS publishable via docker compose up — any agent (OpenCode/Cursor) can create Plan/Epic/Task via MCP, Plans tree CRUD works without CLI, Fleet shows all workspaces, harness-agnostic worker runs doc + code tasks with BYO LLMs +skip: + - ia + - ux + - design + - a11y + - visual + - smoke +status: in-progress +created: 2026-08-25 +model_policy: + roles: + role-conductor: claude-planning + role-architect: claude-planning + role-implementer: qwen-coder + role-reviewer: qwen-coder + escalate_to: claude-planning +--- + +# Second Brain OS v0.1 — open self-hosted Obsidian for crews + +## Why + +Echodo pivots per `docs/decisions/2026-08-25-second-brain-os-pivot.md` from closed code-cockpit to open "Obsidian for agent crews": `docker compose up`, BYO LLMs (Ollama 11434 / MLX 8000 / NIM), any harness via AG-UI/MCP. Current `plans/` is hand-edited markdown + CLI import; Fleet is per-workspace kanban only; worker is code-only with hard worktree + 4-tool allowlist. This convoy makes the homelab dogfoodable + publishable for stars. + +See `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 order (2026-08-25 pivot). + +## Scope + +1. MCP `create-backlog-item` — natural language `Plan/Epic/Task` creation (0.5d) +2. Worker non-code: optional worktree + per-harness allowlist + `doc_review→published` (1d) +3. Harness registry + WorkerAdapter: `harness_providers` + `role_harness_routes`, `AG-UI /api/ag-ui`, `Settings → Harnesses` (2-3d) +4. Plans tree: `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` + `backlog.createPlan/createEpic/createTask` + `Start→Convoy` (1w) — Epic 10 +5. Fleet rollup: `/fleet` + SSE `convoy_events` feed (3-4d) — Epic 9 + +Out of scope: Epic 8 webhooks, Epic 11 artifacts, Epic 12 copilot (post-v0.1); LiteLLM required, per-user overrides. + +## Roles invoked + +Conductor → Architect (briefs = 5 above, 1:1 to roadmap) → Implementer per brief (sequential, each needs prior) → Reviewer (PR). + +## Todos + +- [ ] Brief 1 MCP create +- [ ] Brief 2 worker non-code +- [ ] Brief 3 harness registry — depends on 2 +- [ ] Brief 4 Plans tree — depends on 1,3 +- [ ] Brief 5 Fleet — depends on 3 + +## IA + +Skipped (`ia` in skip). No new IA — `plans/` hierarchy `docs/Glossary.md:50` + `objects` `packages/database/src/schema/objects.ts:18` unchanged. + +## UX + +Skipped (`ux` in skip). Reuses existing `Mission control` kanban `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `Settings` patterns. + +## Architecture + +See decision log + `docs/adr-model-routing.md`. Key new: +- `packages/database/src/schema/harness_providers.ts` (mirror `model_providers` `docs/adr-model-routing.md:78`) +- `packages/shared/src/worker/executor.ts` `WorkerAdapter` +- `apps/worker/src/handle-job.ts:40` dispatch via adapter +- `apps/web/app/api/ag-ui/[workspace]/route.ts` AG-UI +- `apps/mcp-server/src/tools/create-backlog-item.ts` +- `apps/web/server/routers/backlog.ts:29` add CRUD for `plans/*.md` diff --git a/.convoys/second-brain-os-v01/brief-1-mcp-create-backlog-item.md b/.convoys/second-brain-os-v01/brief-1-mcp-create-backlog-item.md new file mode 100644 index 0000000..9c5a8f5 --- /dev/null +++ b/.convoys/second-brain-os-v01/brief-1-mcp-create-backlog-item.md @@ -0,0 +1,38 @@ +--- +brief: 1 +convoy: second-brain-os-v01 +role: role-implementer +title: MCP create-backlog-item tool for natural language Plan/Epic/Task authoring +status: ready +priority: P0 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +workspace_id: global +--- + +# Brief 1 — MCP `create-backlog-item` (0.5d) + +## Goal + +Any agent (OpenCode/Cursor/Codex) via MCP can say "create an Epic for offline mode with 3 tasks" and it writes `plans/Plan-*/Epic-*/Task-*.md` + re-imports to `markdown_backlog_items` `packages/database/src/markdown-backlog/sync.ts:38`. + +## Files + +- `apps/mcp-server/src/tools/create-backlog-item.ts` NEW — zod schema `kind: plan|epic|task`, `slug`, `title`, `planSlug`, `epicSlug`, `status`, `priority`, `bodyMarkdown`, `agent_prompt`; validates frontmatter per `docs/templates/`, writes file via `exportBacklogItemToMarkdown` inverse, calls `syncMarkdownBacklogScan` or direct DB insert + `contentHash` `packages/database/src/markdown-backlog/parse.ts:1`. +- `apps/mcp-server/src/tools/index.ts` register +- `packages/database/src/markdown-backlog/parse.test.ts` add case + +## Acceptance + +- `mcp call create-backlog-item {workspace:"maya-workspace", kind:"task", slug:"research-prompt", planSlug:"clarity", epicSlug:"ai-tags", title:"Research prompt", bodyMarkdown:"..."}` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` with YAML frontmatter + body, row appears in `markdown_backlog_items` without manual `pnpm import`. +- Duplicate slug under same epic → error `CONFLICT`. +- `pnpm lint && pnpm type-check && pnpm --filter @tasks/mcp-server test` (if exists) pass; `apps/mcp-server` still builds Docker. + +## Prompt for implementer + +You are `role-implementer` for `second-brain-os-v01` brief 1. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/mcp-server/src/tools/claim-task.ts:1` as reference. Implement exactly this brief, keep changes small, run `pnpm lint && pnpm type-check`. + +## Dogfood + +`claim_task` on the matching `plans/Plan-automation-foundation/.../Task-*.md` if exists (or ad-hoc) → `complete_task outcome:succeeded tokensTotal:... modelAlias:qwen-coder`. diff --git a/.convoys/second-brain-os-v01/brief-2-worker-non-code.md b/.convoys/second-brain-os-v01/brief-2-worker-non-code.md new file mode 100644 index 0000000..08f059a --- /dev/null +++ b/.convoys/second-brain-os-v01/brief-2-worker-non-code.md @@ -0,0 +1,40 @@ +--- +brief: 2 +convoy: second-brain-os-v01 +role: role-implementer +title: Worker non-code — optional worktree + per-harness allowlist + doc_review→published +status: ready +priority: P0 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 2 — Worker non-code (1d) + +## Goal + +`book/course/research/SOPs` docs don't need git worktree or `pnpm`. Worker must handle doc tasks writing straight to `docs/`/`plans/` no branch, with allowlist per harness. + +## Files + +- `apps/worker/src/git/worktree.ts:1` — add `needsWorktree: boolean` param; when false skip `ensureWorktree`, use `monorepoRoot` directly. Keep existing path for code briefs `job.briefId` present. +- `apps/worker/src/tools/allowlist.ts:1` — make `allowlist` configurable per harness `harness.config.allowedCommands` (or `minimal: [read,write,grep]` only). Pass `allowedCommands` into `runCommandTool`. +- `packages/shared/src/convoy-lifecycle/state-machine.ts:1` — add `doc_review → published` transition (mirror `pr_review → merged`), allow `human-gate-2` analogue `doc-approver`. +- `apps/worker/src/handle-job.ts:40` — thread `harness.needsWorktree` + `harness.allowlist` through; log `harnessAlias` to `convoy_events`. +- `docs/worker.md:1` document optional worktree. + +## Acceptance + +- `Task-write-manifesto.md` (doc task) with `harness: doc-writer` creates `docs/manifesto.md` via `write_file` tool without worktree directory under `/tmp/echodo-worker-worktrees`. +- Code task `Task-fix-lint` still creates worktree per prior behavior. +- `transition_convoy_status` `doc_review → published` succeeds; existing `pr_review → merged` still works. +- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass. + +## Prompt + +You are `role-implementer` brief 2. Read `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/worker/src/handle-job.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Implement small, gated. + +## Depends + +None — can run in parallel with brief 1. diff --git a/.convoys/second-brain-os-v01/brief-3-harness-registry-adapter.md b/.convoys/second-brain-os-v01/brief-3-harness-registry-adapter.md new file mode 100644 index 0000000..2143f6d --- /dev/null +++ b/.convoys/second-brain-os-v01/brief-3-harness-registry-adapter.md @@ -0,0 +1,44 @@ +--- +brief: 3 +convoy: second-brain-os-v01 +role: role-implementer +title: Harness registry + WorkerAdapter + AG-UI + Settings → Harnesses +status: ready +priority: P0 +slice_dependencies: [1, 2] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 3 — Harness registry + WorkerAdapter (2-3d) — THE big one + +## Goal + +Workspace admins CRUD harness providers (`minimal|crewai|langgraph|remote-ag-ui`) + map `role_id → harness_alias`, worker dispatches via adapter, any harness via `AG-UI /api/ag-ui/[workspace]`, test button proves reachability. + +## Files + +- `packages/database/src/schema/harness_providers.ts` NEW — mirror `model_providers` `docs/adr-model-routing.md:78`: `id, workspace_id fk, alias unique per workspace, display_name, harness_type enum, endpoint_url nullable, auth_env_var varchar nullable, config jsonb, enabled bool, created_at/updated_at`. + `role_harness_routes` `workspace_id, role_id varchar, harness_provider_id fk, enabled, sort_order`, unique `(workspace_id, role_id)`. +- `packages/database/migrations/0015_*` via `pnpm db:generate`. +- `packages/shared/src/worker/executor.ts` NEW — `WorkerExecutor {id, run(ctx, model):Promise<...>}`, `resolveHarnessForRole()` pure like `resolveModel` `packages/shared/src/model-routing/index.ts`. +- `apps/worker/src/handle-job.ts:40` — replace hard `createMinimalSession` with `registry.get(harnessAlias).run(...)`; keep `MinimalSession` as `minimal` adapter. +- `apps/web/server/routers/harnesses.ts` NEW — `list/create/update/delete/testHarness` (admin only, never return `auth` value, `test` does fetch `health` on endpoint with `Authorization: Bearer ${process.env[AUTH_ENV_VAR]}`). +- `apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx` NEW — table + add/edit dialog + test. +- `apps/web/app/api/ag-ui/[workspace]/route.ts` NEW — POST ag-ui envelope, validate `workspace_id`, write `convoy_events`/`agent_runs`, enforce tenant fence. +- `packages/database/src/schema/index.ts` exports. + +## Acceptance + +- Migration applies `pnpm db:generate && pnpm db:migrate` locally (no drift). +- `Settings → Harnesses` CRUD works; `Test` on `remote-ag-ui https://crew.example.com/ag-ui` with `HARNESS_AUTH_TOKEN` env shows latency or error detail, never leaks token to browser. +- `resolveHarnessForRole(workspaceId, "role-implementer")` returns `crewai` when mapped, `minimal` fallback. +- Worker dispatched `role-implementer` via `crewai` adapter still `claim_task`/`complete_task` and writes `harness_alias` to `convoy_events.harness_alias` + `agent_runs`. +- `pnpm lint && pnpm type-check && pnpm test` pass. + +## Prompt + +You are `role-implementer` brief 3. Read `docs/adr-model-routing.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/schema/model_providers.ts`, `packages/shared/src/model-routing/index.ts`. Keep diff minimal, follow existing Drizzle/tRPC patterns in `server/routers/ai-models.ts`. + +## Depends + +Briefs 1,2 — needs optional worktree done. diff --git a/.convoys/second-brain-os-v01/brief-4-plans-tree.md b/.convoys/second-brain-os-v01/brief-4-plans-tree.md new file mode 100644 index 0000000..c6fed21 --- /dev/null +++ b/.convoys/second-brain-os-v01/brief-4-plans-tree.md @@ -0,0 +1,44 @@ +--- +brief: 4 +convoy: second-brain-os-v01 +role: role-implementer +title: Plans tree — web CRUD for plans/ without CLI, Start→Convoy +status: ready +priority: P0 +slice_dependencies: [1, 3] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 4 — Plans tree (1w) — Epic 10 + +## Goal + +Web `Plans` is Obsidian-like tree — read/write `plans/Plan-*/Epic-*/Task-*.md` without `pnpm import:markdown-backlog` CLI; click `Start → Convoy`. + +## Files + +- `apps/web/server/routers/backlog.ts:29` — add `listTree` (returns hierarchical `Plan → Epic → Task` with `status/priority/repoPath`), `createPlan/createEpic/createTask` (`slug/title/bodyMarkdown/agent_prompt` → writes `.md` via `join(repoRoot, repoPath)` atomically `.tmp→rename`, then direct DB upsert or `syncMarkdownBacklogScan`), `updateTaskBody`, `getBacklogItem` already exists. Enforce slug `^[a-z0-9-]+$`, `workspace_id` fence, `updated_at` bump. +- `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` NEW — tree component (collapsible `Radix Collapsible` + `ScrollArea`), status chips, search, `New Plan/Epic/Task` dialogs, `Start` button → calls `convoys.create` `apps/web/server/routers/convoys.ts:173` with `planSlug/epicSlug`. +- Reuse `resolveWorkflowPrompt` preview in task detail drawer. +- `docs/Glossary.md:50` hierarchy respected; `config/CursorSync.md:33` export allow-list preserved (web writes go through same export path). + +## Acceptance + +- `Plans` page shows `Plan-clarity → Epic-ai-tags → Task-local-tag-prompt [ready]` from DB without CLI after DB seed. +- `New Task` dialog `title: Research prompt` `slug: research-prompt` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` on disk + row in `markdown_backlog_items` visible after refresh without manual import. +- `Start` on a `ready` task creates convoy `clarity-research-prompt` at `draft` and links `briefFiles: [repoPath]`. +- `MCP create-backlog-item` brief 1 and web create produce same file format (round-trip parse test). +- `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 4. Read `plans/README.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/web/server/routers/backlog.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Follow existing tRPC patterns `ai-models.ts`. Keep UI to `components/ui/*` + Tailwind. + +## Depends + +Briefs 1,3 — needs MCP tool shape + harness registry for Start. + +## Out of scope + +Full Notion drag-n-drop, breadcrumb perf — keep tree simple. diff --git a/.convoys/second-brain-os-v01/brief-5-fleet-rollup.md b/.convoys/second-brain-os-v01/brief-5-fleet-rollup.md new file mode 100644 index 0000000..77aecc6 --- /dev/null +++ b/.convoys/second-brain-os-v01/brief-5-fleet-rollup.md @@ -0,0 +1,38 @@ +--- +brief: 5 +convoy: second-brain-os-v01 +role: role-implementer +title: Fleet rollup /fleet + SSE convoy_events feed +status: ready +priority: P1 +slice_dependencies: [3] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 5 — Fleet rollup `/fleet` + SSE (3-4d) — Epic 9 + +## Goal + +`echodo.stillwell.cloud/fleet` shows *all* workspaces + `connected_repos` projects needing you — the "land and see everything" you asked for. Live `convoy_events` tail without refresh. + +## Files + +- `apps/web/server/routers/convoys.ts:119` — add `fleet` procedure (no `workspace` arg, uses `ctx.session.user.id` to gather all `workspaceMembers` workspaces → `listConvoyProjects` across them, return `byWorkspace, byStatus, gateInboxAcross`). Reuse `GATE_INBOX_STATUSES`. +- `apps/web/app/(app)/fleet/page.tsx` NEW — top `Needs you` list, grid `byWorkspace` cards, reuse `ConvoyKanban` `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `ConvoyDetailPanel` but workspace-agnostic. +- `apps/web/app/api/fleet/events/route.ts` NEW — SSE `text/event-stream` tailing `convoy_events` `packages/database/src/schema/convoy_events.ts:1` filtered to user's workspaces, `occurredAt desc` 30 rows + live publish via `pg_notify` or polling 5s fallback (reuse orchestrator poll). +- `apps/web/components/fleet/fleet-activity-feed.tsx` NEW — auto-append without refresh. + +## Acceptance + +- `/fleet` lists convoys from `maya-workspace` + `studio-internal` + `bakery-coop` together; `Needs you` shows `× pr_review 2d` count. +- Clicking fleet card opens detail with correct `workspaceSlug` context for `gateAction`. +- SSE feed shows `role_complete` event within 5s of worker `logConvoyEvent` `apps/mcp-server/src/tools/log-convoy-event.ts`. + +## Prompt + +You are `role-implementer` brief 5. Read `apps/web/server/routers/convoys.ts:173`, `apps/web/server/lib/convoy/convoy-service.ts`, `plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep polling fallback simple, no Redis pubsub v1. + +## Depends + +Brief 3 — needs harness field in events for display. diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 2025e50..e0dddd6 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -2,8 +2,15 @@ "_comment": "Echodo's own MCP server. Spawned per Cursor session over stdio. The 'mcp' script in apps/mcp-server/package.json invokes tsx with --env-file=../../.env so DATABASE_URL is read from the repo's .env without leaking it into this committed file. The -s/--silent flag on pnpm suppresses its progress output that would otherwise corrupt the MCP stdio protocol. If you fork the repo, copy .env.example → .env and fill in DATABASE_URL.", "mcpServers": { "echodo": { - "command": "pnpm", - "args": ["-s", "--filter", "@tasks/mcp-server", "mcp"] + "command": "/Users/rstillw/.nvm/versions/node/v22.14.0/bin/pnpm", + "args": [ + "-s", + "-C", + "/Users/rstillw/Documents/Personal Coding Projects/tasks", + "--filter", + "@tasks/mcp-server", + "mcp" + ] } } } diff --git a/.env.example b/.env.example index 9b0336a..1eccec9 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,7 @@ REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11" # WORKER_DRY_RUN=1 skips Redis dequeue loop # WORKER_BLPOP_TIMEOUT_S=5 # WORKER_MAX_TOOL_STEPS=12 +# WORKER_CHECKPOINT_EVERY_N_STEPS=1 # WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up) # WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees diff --git a/apps/mcp-server/src/lib/resolve-backlog-item.ts b/apps/mcp-server/src/lib/resolve-backlog-item.ts new file mode 100644 index 0000000..8ee015e --- /dev/null +++ b/apps/mcp-server/src/lib/resolve-backlog-item.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { and, eq, isNull } from "../drizzle.js"; +import { db } from "../db.js"; +import { markdownBacklogItems } from "../schema.js"; + +export const backlogKindSchema = z.enum(["plan", "epic", "task"]); + +export const backlogItemLookupFieldsSchema = z.object({ + repoPath: z + .string() + .min(1) + .optional() + .describe( + "Repo-relative path under plans/, e.g. plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-minimal-tool-loop.md", + ), + slug: z.string().min(1).optional(), + planSlug: z.string().min(1).optional(), + epicSlug: z.string().min(1).optional(), + kind: backlogKindSchema.optional(), +}); + +export const backlogItemLookupSchema = backlogItemLookupFieldsSchema.superRefine( + (input, ctx) => { + const hasRepoPath = Boolean(input.repoPath?.trim()); + const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim()); + if (!hasRepoPath && !hasSlugKeys) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).", + }); + } + }, +); + +export type BacklogItemLookup = z.infer; + +export type ResolvedBacklogItem = { + id: string; + workspaceId: string; + kind: string; + slug: string; + planSlug: string; + epicSlug: string | null; + repoPath: string; + title: string; + bodyMarkdown: string; + status: string | null; + priority: string | null; +}; + +export const BACKLOG_ITEM_NOT_FOUND_MESSAGE = + "Backlog item not found. Run `pnpm --filter @tasks/database import:markdown-backlog` (with MARKDOWN_BACKLOG_WORKSPACE_ID set) if the plans/ file exists but has not been imported yet."; + +export function normalizeRepoPath(repoPath: string): string { + return repoPath.trim().replace(/^\.\/+/, ""); +} + +function buildLookupConditions( + workspaceId: string, + lookup: BacklogItemLookup, +) { + const conditions = [ + eq(markdownBacklogItems.workspaceId, workspaceId), + isNull(markdownBacklogItems.archivedAt), + ]; + + if (lookup.repoPath?.trim()) { + conditions.push( + eq(markdownBacklogItems.repoPath, normalizeRepoPath(lookup.repoPath)), + ); + return conditions; + } + + conditions.push(eq(markdownBacklogItems.slug, lookup.slug!.trim())); + conditions.push(eq(markdownBacklogItems.planSlug, lookup.planSlug!.trim())); + if (lookup.epicSlug?.trim()) { + conditions.push(eq(markdownBacklogItems.epicSlug, lookup.epicSlug.trim())); + } + if (lookup.kind) { + conditions.push(eq(markdownBacklogItems.kind, lookup.kind)); + } + + return conditions; +} + +const backlogItemSelect = { + id: markdownBacklogItems.id, + workspaceId: markdownBacklogItems.workspaceId, + kind: markdownBacklogItems.kind, + slug: markdownBacklogItems.slug, + planSlug: markdownBacklogItems.planSlug, + epicSlug: markdownBacklogItems.epicSlug, + repoPath: markdownBacklogItems.repoPath, + title: markdownBacklogItems.title, + bodyMarkdown: markdownBacklogItems.bodyMarkdown, + status: markdownBacklogItems.status, + priority: markdownBacklogItems.priority, +}; + +export async function resolveBacklogItemById( + backlogItemId: string, +): Promise { + const [row] = await db + .select(backlogItemSelect) + .from(markdownBacklogItems) + .where(eq(markdownBacklogItems.id, backlogItemId)) + .limit(1); + + return row ?? null; +} + +export async function resolveBacklogItemsByLookup( + workspaceId: string, + lookup: BacklogItemLookup, +): Promise { + return db + .select(backlogItemSelect) + .from(markdownBacklogItems) + .where(and(...buildLookupConditions(workspaceId, lookup))) + .limit(5); +} diff --git a/apps/mcp-server/src/tools/claim-task.ts b/apps/mcp-server/src/tools/claim-task.ts index 96d383c..f2a7063 100644 --- a/apps/mcp-server/src/tools/claim-task.ts +++ b/apps/mcp-server/src/tools/claim-task.ts @@ -8,6 +8,12 @@ import { auditLog, markdownBacklogItems, } from "../schema.js"; +import { + BACKLOG_ITEM_NOT_FOUND_MESSAGE, + backlogItemLookupFieldsSchema, + resolveBacklogItemById, + resolveBacklogItemsByLookup, +} from "../lib/resolve-backlog-item.js"; import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts"; import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts"; @@ -40,45 +46,62 @@ import { toolCatch, toolErr, toolOk } from "./tool-result.js"; * `agent_runs` separately. */ -const claimTaskInputSchema = z.object({ - workspace: z - .string() - .min(1) - .describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"), - backlogItemId: z - .string() - .uuid() - .describe("UUID of the markdown_backlog_items row being claimed."), - actorUserId: z - .string() - .uuid() - .nullable() - .optional() - .describe( - "Optional UUID of the human user the agent is acting on behalf of. Null = anonymous dev session.", - ), - notes: z - .string() - .max(2_000) - .optional() - .describe("Short opener text attached to the run (e.g. session intent)."), - client: z - .string() - .min(1) - .max(50) - .optional() - .describe( - "Caller identifier — e.g. 'cursor-ide', 'cursor-cloud', 'codex', 'echodo-orchestrator'. Persisted in agent_runs.metadata so the runs UI can slice by client.", - ), - model: z - .string() - .min(1) - .max(100) - .optional() - .describe( - "Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.", - ), -}); +const claimTaskInputSchema = z + .object({ + workspace: z + .string() + .min(1) + .describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"), + backlogItemId: z + .string() + .uuid() + .optional() + .describe( + "UUID of the markdown_backlog_items row being claimed. Optional when repoPath or slug keys are provided.", + ), + actorUserId: z + .string() + .uuid() + .nullable() + .optional() + .describe( + "Optional UUID of the human user the agent is acting on behalf of. Null = anonymous dev session.", + ), + notes: z + .string() + .max(2_000) + .optional() + .describe("Short opener text attached to the run (e.g. session intent)."), + client: z + .string() + .min(1) + .max(50) + .optional() + .describe( + "Caller identifier — e.g. 'cursor-ide', 'cursor-cloud', 'codex', 'echodo-orchestrator'. Persisted in agent_runs.metadata so the runs UI can slice by client.", + ), + model: z + .string() + .min(1) + .max(100) + .optional() + .describe( + "Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.", + ), + }) + .merge(backlogItemLookupFieldsSchema) + .superRefine((input, ctx) => { + const hasId = Boolean(input.backlogItemId); + const hasRepoPath = Boolean(input.repoPath?.trim()); + const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim()); + if (!hasId && !hasRepoPath && !hasSlugKeys) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Provide backlogItemId, repoPath, or slug + planSlug (add epicSlug for tasks/epics).", + }); + } + }); /** * Build the metadata object stored on agent_runs at claim time. Returns @@ -100,7 +123,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { "claim_task", { description: - "Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session.", + "Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session. Identify the item by backlogItemId, repoPath, or slug + planSlug.", inputSchema: claimTaskInputSchema, }, async (args) => { @@ -108,27 +131,42 @@ export function registerClaimTaskTool(mcp: McpServer): void { const input = claimTaskInputSchema.parse(args); const ws = await resolveWorkspaceHandle(input.workspace); - const [item] = await db - .select({ - id: markdownBacklogItems.id, - workspaceId: markdownBacklogItems.workspaceId, - title: markdownBacklogItems.title, - bodyMarkdown: markdownBacklogItems.bodyMarkdown, - status: markdownBacklogItems.status, - }) - .from(markdownBacklogItems) - .where(eq(markdownBacklogItems.id, input.backlogItemId)) - .limit(1); + let item; + let backlogItemId: string; - if (!item) { - return toolErr(`Backlog item ${input.backlogItemId} not found`); + if (input.backlogItemId) { + const found = await resolveBacklogItemById(input.backlogItemId); + if (!found) { + return toolErr(`Backlog item ${input.backlogItemId} not found`); + } + item = found; + backlogItemId = found.id; + } else { + const matches = await resolveBacklogItemsByLookup(ws.id, { + repoPath: input.repoPath, + slug: input.slug, + planSlug: input.planSlug, + epicSlug: input.epicSlug, + kind: input.kind, + }); + if (matches.length === 0) { + return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE); + } + if (matches.length > 1) { + return toolErr( + `Ambiguous backlog lookup (${matches.length} matches). Add epicSlug/kind or use repoPath.`, + ); + } + item = matches[0]!; + backlogItemId = item.id; } + if (item.workspaceId !== ws.id) { // Tenancy fence: the backlog item exists but in a different // workspace than the resolved handle. Refuse rather than 404 so // the caller can't fish for cross-tenant existence. return toolErr( - `Backlog item ${input.backlogItemId} does not belong to workspace ${ws.slug}`, + `Backlog item ${backlogItemId} does not belong to workspace ${ws.slug}`, ); } @@ -150,7 +188,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { .from(agentRuns) .where( and( - eq(agentRuns.backlogItemId, input.backlogItemId), + eq(agentRuns.backlogItemId, backlogItemId), isNull(agentRuns.finishedAt), ), ) @@ -190,7 +228,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { const resolved = await resolveWorkflowPrompt(db, { workspaceId: ws.id, - backlogItemId: input.backlogItemId, + backlogItemId, }); return toolOk({ @@ -203,6 +241,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { title: item.title, bodyMarkdown: item.bodyMarkdown, status: item.status, + repoPath: item.repoPath, }, workspace: { id: ws.id, slug: ws.slug, name: ws.name }, }); @@ -219,7 +258,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { .insert(agentRuns) .values({ workspaceId: ws.id, - backlogItemId: input.backlogItemId, + backlogItemId, actorUserId, notes: input.notes ?? null, metadata: claimMeta, @@ -230,7 +269,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { await tx .update(markdownBacklogItems) .set({ status: "in_progress", updatedAt: new Date() }) - .where(eq(markdownBacklogItems.id, input.backlogItemId)); + .where(eq(markdownBacklogItems.id, backlogItemId)); } await tx.insert(auditLog).values({ @@ -240,7 +279,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { targetType: "agent_run", targetId: created.id, metadata: { - backlog_item_id: input.backlogItemId, + backlog_item_id: backlogItemId, prior_status: priorStatus, transitioned: shouldTransition, ...(claimMeta ?? {}), @@ -253,7 +292,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { const resolved = await resolveWorkflowPrompt(db, { workspaceId: ws.id, - backlogItemId: input.backlogItemId, + backlogItemId, }); // Only export when the row's status actually changed. On a @@ -269,7 +308,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { try { const exportResult = await exportBacklogItemToMarkdown(db, { workspaceId: ws.id, - backlogItemId: input.backlogItemId, + backlogItemId, repoRootAbs: repoRoot, }); if (exportResult.ok) { @@ -302,6 +341,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { title: item.title, bodyMarkdown: item.bodyMarkdown, status: shouldTransition ? "in_progress" : priorStatus, + repoPath: item.repoPath, }, workspace: { id: ws.id, slug: ws.slug, name: ws.name }, export: exportSummary, diff --git a/apps/mcp-server/src/tools/create-backlog-item.ts b/apps/mcp-server/src/tools/create-backlog-item.ts new file mode 100644 index 0000000..b536291 --- /dev/null +++ b/apps/mcp-server/src/tools/create-backlog-item.ts @@ -0,0 +1,362 @@ +import { mkdir, writeFile, rename, access } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { parseDocument, Scalar } from "yaml"; + +import { eq, and } from "../drizzle.js"; +import { db } from "../db.js"; +import { markdownBacklogItems } from "../schema.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; +import { resolveRepoRoot } from "../lib/repo-root.js"; +import { parentRepoPath } from "../../../../packages/database/src/markdown-backlog/paths.ts"; +import { hashFileContents } from "../../../../packages/database/src/markdown-backlog/parse.ts"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const slugRe = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +const kindSchema = z.enum(["plan", "epic", "task"]); +const statusSchema = z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]); +const prioritySchema = z.enum(["P0", "P1", "P2", "P3"]); + +const createBacklogItemInputSchema = z + .object({ + workspace: z.string().min(1).describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"), + kind: kindSchema, + slug: z + .string() + .min(1) + .max(200) + .regex(slugRe, "slug must be lowercase-hyphenated (a-z, 0-9, hyphen)"), + title: z.string().min(1).max(500), + planSlug: z + .string() + .min(1) + .max(200) + .regex(slugRe, "planSlug must be lowercase-hyphenated") + .optional(), + epicSlug: z + .string() + .min(1) + .max(200) + .regex(slugRe, "epicSlug must be lowercase-hyphenated") + .optional(), + status: statusSchema.optional(), + priority: prioritySchema.optional(), + bodyMarkdown: z.string().optional(), + agent_prompt: z.string().nullable().optional().describe("Optional per-item agent prompt (multiline supported)"), + }) + .superRefine((input, ctx) => { + if (input.kind === "plan") { + if (input.epicSlug) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "epicSlug must not be provided for kind=plan", + path: ["epicSlug"], + }); + } + if (input.planSlug && input.planSlug !== input.slug) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "planSlug for kind=plan must equal slug or be omitted", + path: ["planSlug"], + }); + } + } + if (input.kind === "epic") { + if (!input.planSlug?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "planSlug is required for kind=epic", + path: ["planSlug"], + }); + } + } + if (input.kind === "task") { + if (!input.planSlug?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "planSlug is required for kind=task", + path: ["planSlug"], + }); + } + if (!input.epicSlug?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "epicSlug is required for kind=task", + path: ["epicSlug"], + }); + } + } + }); + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +function defaultStatusForKind(kind: z.infer): string { + if (kind === "task") return "ready"; + return "draft"; +} + +function defaultBodyForKind(kind: string, title: string): string { + if (kind === "plan") { + return `# ${title}\n\nShort summary of the initiative and why it exists.\n`; + } + if (kind === "epic") { + return `# ${title}\n\nOne paragraph: what milestone this epic delivers.\n`; + } + return `# ${title}\n\nOne or two sentences.\n`; +} + +function repoPathFor(input: { + kind: z.infer; + slug: string; + planSlug?: string; + epicSlug?: string; +}): string { + if (input.kind === "plan") { + return `plans/Plan-${input.slug}/Plan-${input.slug}.md`; + } + if (input.kind === "epic") { + return `plans/Plan-${input.planSlug}/Epic-${input.slug}/Epic-${input.slug}.md`; + } + return `plans/Plan-${input.planSlug}/Epic-${input.epicSlug}/Task-${input.slug}.md`; +} + +function buildFrontmatterYaml(input: { + kind: string; + slug: string; + title: string; + planSlug: string | null; + epicSlug: string | null; + status: string; + priority: string; + agentPrompt: string | null; +}): string { + const doc = parseDocument(""); + // Ensure we start from empty map + if (!doc.contents) { + // parseDocument("") yields empty; ensure contents is mapping + } + doc.set("kind", input.kind); + doc.set("slug", input.slug); + doc.set("title", input.title); + if (input.planSlug) doc.set("plan_slug", input.planSlug); + if (input.epicSlug) doc.set("epic_slug", input.epicSlug); + doc.set("status", input.status); + doc.set("priority", input.priority); + doc.set("updated_at", todayIso()); + if (input.agentPrompt !== null && input.agentPrompt.trim() !== "") { + const isMultiline = input.agentPrompt.includes("\n"); + const node = doc.createNode(input.agentPrompt); + if (node instanceof Scalar) { + node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN; + } + doc.set("agent_prompt", node); + } + const yamlText = String(doc).replace(/\n$/, ""); + return yamlText; +} + +async function fileExists(absPath: string): Promise { + try { + await access(absPath); + return true; + } catch { + return false; + } +} + +async function atomicWrite(absPath: string, contents: string): Promise { + const dir = dirname(absPath); + await mkdir(dir, { recursive: true }); + const tmp = join(dir, `.${randomUUID()}.tmp`); + await writeFile(tmp, contents, "utf8"); + await rename(tmp, absPath); +} + +export function registerCreateBacklogItemTool(mcp: McpServer): void { + mcp.registerTool( + "create-backlog-item", + { + description: + "Create a new Plan/Epic/Task markdown file under plans/ and upsert markdown_backlog_items. Validates frontmatter per docs/templates/. Duplicate slug under same epic → CONFLICT.", + inputSchema: createBacklogItemInputSchema, + }, + async (args) => { + try { + const input = createBacklogItemInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); + + const effectivePlanSlug = + input.kind === "plan" ? input.slug : (input.planSlug?.trim() ?? null); + const effectiveEpicSlug = + input.kind === "task" + ? (input.epicSlug?.trim() ?? null) + : input.kind === "epic" + ? null + : null; + + const status = input.status ?? defaultStatusForKind(input.kind); + const priority = input.priority ?? "P2"; + const agentPrompt = + input.agent_prompt !== undefined && input.agent_prompt !== null + ? input.agent_prompt + : null; + const bodyMarkdown = input.bodyMarkdown ?? defaultBodyForKind(input.kind, input.title); + + const repoPath = repoPathFor({ + kind: input.kind, + slug: input.slug, + planSlug: effectivePlanSlug ?? undefined, + epicSlug: effectiveEpicSlug ?? undefined, + }); + + const repoRoot = resolveRepoRoot(); + if (!repoRoot) { + return toolErr("MARKDOWN_BACKLOG_REPO_ROOT is disabled; cannot write plans/ file"); + } + const absPath = join(repoRoot, ...repoPath.split("/")); + + // Duplicate check: file exists + if (await fileExists(absPath)) { + return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`); + } + + // Duplicate check: DB row exists for same workspace+repoPath + const [existingRow] = await db + .select({ id: markdownBacklogItems.id }) + .from(markdownBacklogItems) + .where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, repoPath))) + .limit(1); + if (existingRow) { + return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`); + } + + // Build frontmatter + raw markdown (exportBacklogItemToMarkdown inverse) + // Plan files per template do not carry plan_slug/epic_slug + const yamlPlanSlug = input.kind === "plan" ? null : effectivePlanSlug; + const yamlEpicSlug = input.kind === "task" ? effectiveEpicSlug : null; + const yamlText = buildFrontmatterYaml({ + kind: input.kind, + slug: input.slug, + title: input.title, + planSlug: yamlPlanSlug, + epicSlug: yamlEpicSlug, + status, + priority, + agentPrompt, + }); + + const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`; + // Ensure body ends with newline + const normalizedBody = body.endsWith("\n") ? body : `${body}\n`; + const raw = `---\n${yamlText}\n---${normalizedBody}`; + + const contentHash = hashFileContents(raw); + + // Frontmatter stored in DB (mirrors sync's parsed frontmatter) + const frontmatter: Record = { + kind: input.kind, + slug: input.slug, + title: input.title, + status, + priority, + updated_at: todayIso(), + }; + if (yamlPlanSlug) frontmatter.plan_slug = yamlPlanSlug; + if (yamlEpicSlug) frontmatter.epic_slug = yamlEpicSlug; + if (agentPrompt !== null && agentPrompt.trim() !== "") { + frontmatter.agent_prompt = agentPrompt; + } + + // Atomic write .tmp → rename + await atomicWrite(absPath, raw); + + // Upsert markdown_backlog_items with contentHash + // Compute parentId by looking up parent repo path if any + const parentPath = parentRepoPath(repoPath); + let parentId: string | null = null; + if (parentPath) { + const [parentRow] = await db + .select({ id: markdownBacklogItems.id }) + .from(markdownBacklogItems) + .where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath))) + .limit(1); + parentId = parentRow?.id ?? null; + } + + let insertedId: string | null = null; + try { + const [inserted] = await db + .insert(markdownBacklogItems) + .values({ + workspaceId: ws.id, + kind: input.kind, + slug: input.slug, + planSlug: effectivePlanSlug ?? input.slug, + epicSlug: effectiveEpicSlug, + parentId, + repoPath, + title: input.title, + status, + priority, + owner: null, + frontmatter, + bodyMarkdown: normalizedBody.replace(/^\n/, ""), + contentHash, + workflowPrompt: agentPrompt?.trim() ? agentPrompt.trim() : null, + }) + .returning({ id: markdownBacklogItems.id }); + insertedId = inserted?.id ?? null; + } catch (e) { + // Unique violation → CONFLICT (race with concurrent writer) + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("duplicate") || msg.includes("unique") || msg.includes("CONFLICT")) { + return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`); + } + throw e; + } + + // If we had a parent but it was inserted concurrently after our lookup, + // refresh parentId (best-effort) + if (parentPath && !parentId) { + const [parentRow] = await db + .select({ id: markdownBacklogItems.id }) + .from(markdownBacklogItems) + .where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath))) + .limit(1); + if (parentRow) { + await db + .update(markdownBacklogItems) + .set({ parentId: parentRow.id }) + .where(eq(markdownBacklogItems.id, insertedId!)); + parentId = parentRow.id; + } + } + + return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + repoPath, + absPath, + contentHash, + backlogItem: { + id: insertedId, + kind: input.kind, + slug: input.slug, + planSlug: effectivePlanSlug, + epicSlug: effectiveEpicSlug, + title: input.title, + status, + priority, + parentId, + }, + }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/find-backlog-item.ts b/apps/mcp-server/src/tools/find-backlog-item.ts new file mode 100644 index 0000000..0a38bc4 --- /dev/null +++ b/apps/mcp-server/src/tools/find-backlog-item.ts @@ -0,0 +1,70 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { + BACKLOG_ITEM_NOT_FOUND_MESSAGE, + backlogItemLookupFieldsSchema, + resolveBacklogItemsByLookup, +} from "../lib/resolve-backlog-item.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const findBacklogItemInputSchema = z + .object({ + workspace: z + .string() + .min(1) + .describe("Workspace UUID or slug (e.g. 'convoys-tasks')."), + }) + .merge(backlogItemLookupFieldsSchema) + .superRefine((input, ctx) => { + const hasRepoPath = Boolean(input.repoPath?.trim()); + const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim()); + if (!hasRepoPath && !hasSlugKeys) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).", + }); + } + }); + +export function registerFindBacklogItemTool(mcp: McpServer): void { + mcp.registerTool( + "find_backlog_item", + { + description: + "Resolve a markdown backlog item to its backlogItemId by repo path or slug keys. Optional when claim_task is called with repoPath directly.", + inputSchema: findBacklogItemInputSchema, + }, + async (args) => { + try { + const input = findBacklogItemInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); + const rows = await resolveBacklogItemsByLookup(ws.id, input); + + if (rows.length === 0) { + return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE); + } + + if (rows.length > 1) { + return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + ambiguous: true, + matches: rows, + count: rows.length, + }); + } + + const item = rows[0]!; + return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + backlogItem: item, + backlogItemId: item.id, + }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/index.ts b/apps/mcp-server/src/tools/index.ts index ce29082..3056526 100644 --- a/apps/mcp-server/src/tools/index.ts +++ b/apps/mcp-server/src/tools/index.ts @@ -6,6 +6,8 @@ import { registerSearchObjectsTool } from "./search-objects.js"; import { registerUpdateObjectTool } from "./update-object.js"; import { registerClaimTaskTool } from "./claim-task.js"; import { registerCompleteTaskTool } from "./complete-task.js"; +import { registerCreateBacklogItemTool } from "./create-backlog-item.js"; +import { registerFindBacklogItemTool } from "./find-backlog-item.js"; import { registerCreateConvoyTool } from "./create-convoy.js"; import { registerCreateBriefTool } from "./create-brief.js"; import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js"; @@ -21,6 +23,8 @@ export function registerTools(mcp: McpServer): void { registerManageObjectTool(mcp); registerClaimTaskTool(mcp); registerCompleteTaskTool(mcp); + registerCreateBacklogItemTool(mcp); + registerFindBacklogItemTool(mcp); registerCreateConvoyTool(mcp); registerCreateBriefTool(mcp); registerTransitionConvoyStatusTool(mcp); @@ -36,6 +40,8 @@ export { registerListObjectsTool } from "./list-objects.js"; export { registerManageObjectTool } from "./manage-object.js"; export { registerClaimTaskTool } from "./claim-task.js"; export { registerCompleteTaskTool } from "./complete-task.js"; +export { registerCreateBacklogItemTool } from "./create-backlog-item.js"; +export { registerFindBacklogItemTool } from "./find-backlog-item.js"; export { registerCreateConvoyTool } from "./create-convoy.js"; export { registerCreateBriefTool } from "./create-brief.js"; export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js"; diff --git a/apps/orchestrator/src/db/convoy.ts b/apps/orchestrator/src/db/convoy.ts index 8d5c2ea..d471997 100644 --- a/apps/orchestrator/src/db/convoy.ts +++ b/apps/orchestrator/src/db/convoy.ts @@ -1,7 +1,7 @@ import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm"; import type { db as dbInstance } from "@tasks/database"; -import { agentRuns, convoyEvents, objects } from "@tasks/database/schema"; +import { agentRuns, convoyEvents, objects, workerCheckpoints } from "@tasks/database/schema"; import { briefObjectContentSchema, convoyObjectContentSchema, @@ -145,10 +145,28 @@ export async function transitionConvoyInDb( }); } -export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise { +export type StalledRunDispatch = { + workspaceId: string; + agentRunId: string; + backlogItemId: string; + convoyId: string; + roleId: string; + briefId?: string; +}; + +/** + * Open agent runs past the stall threshold that have a workspace-scoped + * checkpoint (enough context to re-dispatch with resume_from_checkpoint). + */ +export async function detectStalledRunsForResume( + db: Db, + stallMinutes: number, +): Promise { const cutoff = new Date(Date.now() - stallMinutes * 60_000); const openRuns = await db .select({ + id: agentRuns.id, + workspaceId: agentRuns.workspaceId, backlogItemId: agentRuns.backlogItemId, startedAt: agentRuns.startedAt, }) @@ -158,5 +176,41 @@ export async function detectStalledConvoys(db: Db, stallMinutes: number): Promis if (openRuns.length === 0) return []; - return openRuns.map((run) => run.backlogItemId); + const dispatches: StalledRunDispatch[] = []; + for (const run of openRuns) { + const [checkpoint] = await db + .select({ + convoyId: workerCheckpoints.convoyId, + briefId: workerCheckpoints.briefId, + roleId: workerCheckpoints.roleId, + }) + .from(workerCheckpoints) + .where( + and( + eq(workerCheckpoints.workspaceId, run.workspaceId), + eq(workerCheckpoints.agentRunId, run.id), + ), + ) + .orderBy(desc(workerCheckpoints.checkpointSeq)) + .limit(1); + + if (!checkpoint) continue; + + dispatches.push({ + workspaceId: run.workspaceId, + agentRunId: run.id, + backlogItemId: run.backlogItemId, + convoyId: checkpoint.convoyId, + roleId: checkpoint.roleId, + briefId: checkpoint.briefId ?? undefined, + }); + } + + return dispatches; +} + +/** @deprecated Prefer detectStalledRunsForResume — kept for log-only callers. */ +export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise { + const stalled = await detectStalledRunsForResume(db, stallMinutes); + return stalled.map((run) => run.backlogItemId); } diff --git a/apps/orchestrator/src/index.ts b/apps/orchestrator/src/index.ts index 9b3d371..6dafdec 100644 --- a/apps/orchestrator/src/index.ts +++ b/apps/orchestrator/src/index.ts @@ -1,8 +1,9 @@ import { db } from "@tasks/database"; import IoRedis from "ioredis"; -import { detectStalledConvoys } from "./db/convoy.js"; +import { detectStalledRunsForResume } from "./db/convoy.js"; import { loadEnv } from "./env.js"; +import { enqueueJob } from "./queue/redis-queue.js"; import { runOrchestratorTick } from "./tick.js"; async function main(): Promise { @@ -24,11 +25,32 @@ async function main(): Promise { ORCHESTRATOR_DRY_RUN: env.ORCHESTRATOR_DRY_RUN || !redis, }); - const stalled = await detectStalledConvoys(db, env.ORCHESTRATOR_STALL_MINUTES); + const stalled = await detectStalledRunsForResume(db, env.ORCHESTRATOR_STALL_MINUTES); if (stalled.length > 0) { console.warn( `[orchestrator] stall detector: ${stalled.length} open agent_runs older than ${env.ORCHESTRATOR_STALL_MINUTES}m`, ); + + for (const run of stalled) { + if (env.ORCHESTRATOR_DRY_RUN || !redis) { + console.info( + `[orchestrator] DRY-RUN resume_from_checkpoint convoy=${run.convoyId} role=${run.roleId} run=${run.agentRunId}`, + ); + continue; + } + + const job = await enqueueJob(redis, { + type: "role_dispatch", + workspaceId: run.workspaceId, + convoyId: run.convoyId, + roleId: run.roleId, + briefId: run.briefId, + resumeFromCheckpoint: true, + }); + console.info( + `[orchestrator] re-dispatched job=${job.id} resume_from_checkpoint run=${run.agentRunId}`, + ); + } } if (results.length === 0) { diff --git a/apps/web/app/(app)/[workspaceSlug]/plans/page.tsx b/apps/web/app/(app)/[workspaceSlug]/plans/page.tsx new file mode 100644 index 0000000..d566b2b --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/plans/page.tsx @@ -0,0 +1,655 @@ +"use client"; + +import * as React from "react"; +import { useParams, useRouter } from "next/navigation"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; +import { ChevronDown, ChevronRight, FileText, Folder, FolderKanban, Loader2, Plus, Search, Rocket, X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; + +const STATUS_VARIANT: Record = { + draft: "bg-muted text-muted-foreground", + ready: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", + in_progress: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20", + blocked: "bg-destructive/10 text-destructive border-destructive/20", + done: "bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/20", + cancelled: "bg-muted text-muted-foreground", +}; + +function StatusChip({ status }: { status: string | null }) { + const s = status ?? "unknown"; + const cls = STATUS_VARIANT[s] ?? STATUS_VARIANT.draft; + return ( + {s} + ); +} + +function slugify(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/--+/g, "-"); +} + +type DialogState = + | { kind: "plan" } + | { kind: "epic"; planSlug: string } + | { kind: "task"; planSlug: string; epicSlug: string } + | null; + +export default function PlansPage() { + const params = useParams(); + const router = useRouter(); + const workspaceSlug = params?.workspaceSlug as string | undefined; + + const [search, setSearch] = React.useState(""); + const [expandedPlans, setExpandedPlans] = React.useState>(new Set()); + const [expandedEpics, setExpandedEpics] = React.useState>(new Set()); + const [selectedId, setSelectedId] = React.useState(null); + const [dialog, setDialog] = React.useState(null); + const [formSlug, setFormSlug] = React.useState(""); + const [formTitle, setFormTitle] = React.useState(""); + const [formBody, setFormBody] = React.useState(""); + const [formAgentPrompt, setFormAgentPrompt] = React.useState(""); + const [formError, setFormError] = React.useState(null); + + const treeQuery = api.backlog.listTree.useQuery({ workspace: workspaceSlug! }, { enabled: Boolean(workspaceSlug) }); + const detailQuery = api.backlog.getBacklogItem.useQuery( + { workspace: workspaceSlug!, backlogItemId: selectedId! }, + { enabled: Boolean(workspaceSlug && selectedId) }, + ); + const promptQuery = api.backlog.getWorkflowPrompt.useQuery( + { workspace: workspaceSlug!, backlogItemId: selectedId! }, + { enabled: Boolean(workspaceSlug && selectedId) }, + ); + + const utils = api.useUtils(); + + const createPlan = api.backlog.createPlan.useMutation({ + onSuccess: () => { + void utils.backlog.listTree.invalidate(); + closeDialog(); + }, + onError: (e) => setFormError(e.message), + }); + const createEpic = api.backlog.createEpic.useMutation({ + onSuccess: () => { + void utils.backlog.listTree.invalidate(); + closeDialog(); + }, + onError: (e) => setFormError(e.message), + }); + const createTask = api.backlog.createTask.useMutation({ + onSuccess: () => { + void utils.backlog.listTree.invalidate(); + closeDialog(); + }, + onError: (e) => setFormError(e.message), + }); + const startConvoy = api.backlog.startConvoy.useMutation({ + onSuccess: () => { + void utils.convoys.kanban.invalidate(); + if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`); + }, + onError: (e) => setFormError(e.message), + }); + // Direct convoy.create path required by brief 4 — Start → Convoy via convoys.create with planSlug/epicSlug + const createConvoyDirect = api.convoys.create.useMutation({ + onSuccess: () => { + void utils.convoys.kanban.invalidate(); + if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`); + }, + onError: (e) => setFormError(e.message), + }); + + function closeDialog() { + setDialog(null); + setFormSlug(""); + setFormTitle(""); + setFormBody(""); + setFormAgentPrompt(""); + setFormError(null); + } + + function openPlanDialog() { + setFormError(null); + setFormSlug(""); + setFormTitle(""); + setFormBody(""); + setFormAgentPrompt(""); + setDialog({ kind: "plan" }); + } + function openEpicDialog(planSlug: string) { + setFormError(null); + setFormSlug(""); + setFormTitle(""); + setFormBody(""); + setFormAgentPrompt(""); + setDialog({ kind: "epic", planSlug }); + } + function openTaskDialog(planSlug: string, epicSlug: string) { + setFormError(null); + setFormSlug(""); + setFormTitle(""); + setFormBody(""); + setFormAgentPrompt(""); + setDialog({ kind: "task", planSlug, epicSlug }); + } + + const tree = React.useMemo(() => treeQuery.data?.plans ?? [], [treeQuery.data?.plans]); + + const filtered = React.useMemo(() => { + if (!search.trim()) return tree; + const q = search.trim().toLowerCase(); + return tree + .map((plan) => { + const planMatches = plan.title.toLowerCase().includes(q) || plan.slug.toLowerCase().includes(q); + const epics = plan.epics + .map((epic) => { + const epicMatches = epic.title.toLowerCase().includes(q) || epic.slug.toLowerCase().includes(q); + const tasks = epic.tasks.filter( + (t) => + t.title.toLowerCase().includes(q) || + t.slug.toLowerCase().includes(q) || + (t.status ?? "").toLowerCase().includes(q), + ); + if (epicMatches || tasks.length > 0 || planMatches) { + return { ...epic, tasks: epicMatches && tasks.length === 0 ? epic.tasks : tasks }; + } + return null; + }) + .filter(Boolean) as typeof plan.epics; + if (planMatches) return { ...plan, epics: epics.length ? epics : plan.epics }; + if (epics.length) return { ...plan, epics }; + return null; + }) + .filter(Boolean) as typeof tree; + }, [tree, search]); + + React.useEffect(() => { + if (tree.length && expandedPlans.size === 0) { + setExpandedPlans(new Set(tree.slice(0, 2).map((p) => p.slug))); + } + }, [tree, expandedPlans.size]); + + if (!workspaceSlug) return null; + + const isMutating = createPlan.isPending || createEpic.isPending || createTask.isPending; + + return ( +
+
+
+
+ +
+
+

Plans

+

Plan → Epic → Task tree from plans/ markdown

+
+
+
+
+ + setSearch(e.target.value)} className="w-64 pl-8" /> +
+ +
+
+ +
+
+ {treeQuery.isLoading ? ( +
+ + Loading plans… +
+ ) : filtered.length === 0 ? ( +
+ +

No plans yet

+

+ {search.trim() + ? "No results for your search." + : "Create your first plan or run pnpm --filter @tasks/database watch:markdown-backlog to import plans/ after seeding."} +

+ {!search.trim() && ( + + )} +
+ ) : ( + +
+ {filtered.map((plan) => { + const planOpen = expandedPlans.has(plan.slug); + return ( + + setExpandedPlans((prev) => { + const next = new Set(prev); + if (open) next.add(plan.slug); + else next.delete(plan.slug); + return next; + }) + } + > +
+ + + + + + + {plan.slug} + +
+ +
+ {plan.epics.length === 0 ? ( +

No epics — add one.

+ ) : ( + plan.epics.map((epic) => { + const epicKey = `${plan.slug}/${epic.slug}`; + const epicOpen = expandedEpics.has(epicKey); + return ( + + setExpandedEpics((prev) => { + const next = new Set(prev); + if (open) next.add(epicKey); + else next.delete(epicKey); + return next; + }) + } + > +
+ + + + + + + {epic.slug} + +
+ +
    + {epic.tasks.length === 0 ? ( +
  • No tasks.
  • + ) : ( + epic.tasks.map((task) => ( +
  • + + + + {task.priority && ( + + {task.priority} + + )} + {task.repoPath} + +
  • + )) + )} +
+
+
+ ); + }) + )} +
+
+
+ ); + })} +
+
+ )} +
+ + !open && setSelectedId(null)}> + + + {detailQuery.data?.title ?? "Details"} + + {detailQuery.data?.repoPath ?? ""} + {detailQuery.data ? ` · ${detailQuery.data.kind}` : ""} + + + +
+ {detailQuery.isLoading ? ( +
+ + Loading… +
+ ) : detailQuery.data ? ( + <> +
+ + {detailQuery.data.priority && {detailQuery.data.priority}} + {detailQuery.data.kind} +
+
+

Repo path

+

{detailQuery.data.repoPath}

+

Slugs

+

+ Plan: {detailQuery.data.planSlug} + {detailQuery.data.epicSlug ? ( + <> + {" "} + Epic: {detailQuery.data.epicSlug} + + ) : null}{" "} + Slug: {detailQuery.data.slug} +

+
+
+

Body

+
+                        {detailQuery.data.bodyMarkdown || "(empty)"}
+                      
+
+
+

Effective prompt

+

Source: {promptQuery.data?.source ?? "—"}

+
+                        {promptQuery.data?.effectivePrompt ?? promptQuery.data?.ownOverride ?? "(no prompt)"}
+                      
+ {promptQuery.data?.ownOverride &&

Own override present

} +
+ {detailQuery.data.kind === "task" && ( +
+ + +
+ )} + {(startConvoy.error || createConvoyDirect.error) && ( +

{(startConvoy.error ?? createConvoyDirect.error)?.message}

+ )} + {(startConvoy.isSuccess || createConvoyDirect.isSuccess) && ( +

+ Convoy {(startConvoy.data?.convoySlug ?? createConvoyDirect.data?.convoyId) ?? ""} created — check Mission Control. +

+ )} + + ) : ( +

Select an item to see details.

+ )} +
+
+
+
+
+ + !open && closeDialog()}> + + + +
+ + {dialog?.kind === "plan" ? "New Plan" : dialog?.kind === "epic" ? "New Epic" : dialog?.kind === "task" ? "New Task" : "New"} + + + + +
+ +
+ {dialog?.kind === "epic" && ( +
+ +
{dialog.planSlug}
+
+ )} + {dialog?.kind === "task" && ( +
+
+ +
{dialog.planSlug}
+
+
+ +
{dialog.epicSlug}
+
+
+ )} + +
+ + { + const v = e.target.value.toLowerCase(); + setFormSlug(v); + if (!formTitle.trim() || slugify(formTitle) === formSlug) { + // keep title in sync helper? no auto + } + }} + /> +

Must match ^[a-z0-9-]+$

+
+
+ + setFormTitle(e.target.value)} + onBlur={() => { + if (!formSlug.trim() && formTitle.trim()) setFormSlug(slugify(formTitle)); + }} + /> +
+
+ +