From 448e9c192b0b2b73eca3ce56ce625743fa006d4d Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 08:50:31 -0500 Subject: [PATCH] Improve onboarding defaults and issue goal fallback --- pnpm-lock.yaml | 24 ++ .../src/__tests__/issue-goal-fallback.test.ts | 59 +++++ server/src/routes/issues.ts | 15 +- server/src/services/goals.ts | 44 +++- server/src/services/issue-goal-fallback.ts | 30 +++ server/src/services/issues.ts | 16 ++ ui/src/components/OnboardingWizard.tsx | 227 ++++++++++++------ ui/src/lib/onboarding-goal.test.ts | 22 ++ ui/src/lib/onboarding-goal.ts | 18 ++ 9 files changed, 378 insertions(+), 77 deletions(-) create mode 100644 server/src/__tests__/issue-goal-fallback.test.ts create mode 100644 server/src/services/issue-goal-fallback.ts create mode 100644 ui/src/lib/onboarding-goal.test.ts create mode 100644 ui/src/lib/onboarding-goal.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1dd1ddc..dbc06553 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@playwright/test': specifier: ^1.58.2 version: 1.58.2 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -68,6 +71,9 @@ importers: drizzle-orm: specifier: 0.38.4 version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) + embedded-postgres: + specifier: ^18.1.0-beta.16 + version: 18.1.0-beta.16 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -321,6 +327,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -989,6 +998,9 @@ packages: cpu: [x64] os: [win32] + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -3424,6 +3436,11 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6741,6 +6758,8 @@ snapshots: '@embedded-postgres/windows-x64@18.1.0-beta.16': optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -9255,6 +9274,11 @@ snapshots: crelt@1.0.6: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/server/src/__tests__/issue-goal-fallback.test.ts b/server/src/__tests__/issue-goal-fallback.test.ts new file mode 100644 index 00000000..cae1b8ab --- /dev/null +++ b/server/src/__tests__/issue-goal-fallback.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + resolveIssueGoalId, + resolveNextIssueGoalId, +} from "../services/issue-goal-fallback.ts"; + +describe("issue goal fallback", () => { + it("assigns the company goal when creating an issue without project or goal", () => { + expect( + resolveIssueGoalId({ + projectId: null, + goalId: null, + defaultGoalId: "goal-1", + }), + ).toBe("goal-1"); + }); + + it("keeps an explicit goal when creating an issue", () => { + expect( + resolveIssueGoalId({ + projectId: null, + goalId: "goal-2", + defaultGoalId: "goal-1", + }), + ).toBe("goal-2"); + }); + + it("does not force a company goal when the issue belongs to a project", () => { + expect( + resolveIssueGoalId({ + projectId: "project-1", + goalId: null, + defaultGoalId: "goal-1", + }), + ).toBeNull(); + }); + + it("backfills the company goal on update for legacy no-project issues", () => { + expect( + resolveNextIssueGoalId({ + currentProjectId: null, + currentGoalId: null, + defaultGoalId: "goal-1", + }), + ).toBe("goal-1"); + }); + + it("clears the fallback when a project is added later", () => { + expect( + resolveNextIssueGoalId({ + currentProjectId: null, + currentGoalId: "goal-1", + projectId: "project-1", + goalId: null, + defaultGoalId: "goal-1", + }), + ).toBeNull(); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9c91fec4..f02067a6 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -294,13 +294,24 @@ export function issueRoutes(db: Db, storage: StorageService) { const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([ svc.getAncestors(issue.id), issue.projectId ? projectsSvc.getById(issue.projectId) : null, - issue.goalId ? goalsSvc.getById(issue.goalId) : null, + issue.goalId + ? goalsSvc.getById(issue.goalId) + : !issue.projectId + ? goalsSvc.getDefaultCompanyGoal(issue.companyId) + : null, svc.findMentionedProjectIds(issue.id), ]); const mentionedProjects = mentionedProjectIds.length > 0 ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) : []; - res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects }); + res.json({ + ...issue, + goalId: goal?.id ?? issue.goalId, + ancestors, + project: project ?? null, + goal: goal ?? null, + mentionedProjects, + }); }); router.post("/issues/:id/read", async (req, res) => { diff --git a/server/src/services/goals.ts b/server/src/services/goals.ts index 9045ebc1..aa5db493 100644 --- a/server/src/services/goals.ts +++ b/server/src/services/goals.ts @@ -1,7 +1,47 @@ -import { eq } from "drizzle-orm"; +import { and, asc, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { goals } from "@paperclipai/db"; +type GoalReader = Pick; + +export async function getDefaultCompanyGoal(db: GoalReader, companyId: string) { + const activeRootGoal = await db + .select() + .from(goals) + .where( + and( + eq(goals.companyId, companyId), + eq(goals.level, "company"), + eq(goals.status, "active"), + isNull(goals.parentId), + ), + ) + .orderBy(asc(goals.createdAt)) + .then((rows) => rows[0] ?? null); + if (activeRootGoal) return activeRootGoal; + + const anyRootGoal = await db + .select() + .from(goals) + .where( + and( + eq(goals.companyId, companyId), + eq(goals.level, "company"), + isNull(goals.parentId), + ), + ) + .orderBy(asc(goals.createdAt)) + .then((rows) => rows[0] ?? null); + if (anyRootGoal) return anyRootGoal; + + return db + .select() + .from(goals) + .where(and(eq(goals.companyId, companyId), eq(goals.level, "company"))) + .orderBy(asc(goals.createdAt)) + .then((rows) => rows[0] ?? null); +} + export function goalService(db: Db) { return { list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)), @@ -13,6 +53,8 @@ export function goalService(db: Db) { .where(eq(goals.id, id)) .then((rows) => rows[0] ?? null), + getDefaultCompanyGoal: (companyId: string) => getDefaultCompanyGoal(db, companyId), + create: (companyId: string, data: Omit) => db .insert(goals) diff --git a/server/src/services/issue-goal-fallback.ts b/server/src/services/issue-goal-fallback.ts new file mode 100644 index 00000000..fe48f0a1 --- /dev/null +++ b/server/src/services/issue-goal-fallback.ts @@ -0,0 +1,30 @@ +type MaybeId = string | null | undefined; + +export function resolveIssueGoalId(input: { + projectId: MaybeId; + goalId: MaybeId; + defaultGoalId: MaybeId; +}): string | null { + if (!input.projectId && !input.goalId) { + return input.defaultGoalId ?? null; + } + return input.goalId ?? null; +} + +export function resolveNextIssueGoalId(input: { + currentProjectId: MaybeId; + currentGoalId: MaybeId; + projectId?: MaybeId; + goalId?: MaybeId; + defaultGoalId: MaybeId; +}): string | null { + const projectId = + input.projectId !== undefined ? input.projectId : input.currentProjectId; + const goalId = + input.goalId !== undefined ? input.goalId : input.currentGoalId; + + if (!projectId && !goalId) { + return input.defaultGoalId ?? null; + } + return goalId ?? null; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 29995cd4..807a97eb 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -23,6 +23,8 @@ import { parseProjectExecutionWorkspacePolicy, } from "./execution-workspace-policy.js"; import { redactCurrentUserText } from "../log-redaction.js"; +import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js"; +import { getDefaultCompanyGoal } from "./goals.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; @@ -649,6 +651,7 @@ export function issueService(db: Db) { throw unprocessable("in_progress issues require an assignee"); } return db.transaction(async (tx) => { + const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; if (executionWorkspaceSettings == null && issueData.projectId) { @@ -673,6 +676,11 @@ export function issueService(db: Db) { const values = { ...issueData, + goalId: resolveIssueGoalId({ + projectId: issueData.projectId, + goalId: issueData.goalId, + defaultGoalId: defaultCompanyGoal?.id ?? null, + }), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), companyId, issueNumber, @@ -752,6 +760,14 @@ export function issueService(db: Db) { } return db.transaction(async (tx) => { + const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId); + patch.goalId = resolveNextIssueGoalId({ + currentProjectId: existing.projectId, + currentGoalId: existing.goalId, + projectId: issueData.projectId, + goalId: issueData.goalId, + defaultGoalId: defaultCompanyGoal?.id ?? null, + }); const updated = await tx .update(issues) .set(patch) diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 376f78ef..4aa967e2 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -17,9 +17,13 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { cn } from "../lib/utils"; -import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils"; +import { + extractModelName, + extractProviderIdWithFallback +} from "../lib/model-utils"; import { getUIAdapter } from "../adapters"; import { defaultCreateValues } from "./agent-config-defaults"; +import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL @@ -61,11 +65,13 @@ type AdapterType = | "http" | "openclaw_gateway"; -const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md) +const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: -Ensure you have a folder agents/ceo and then download this AGENTS.md as well as the sibling HEARTBEAT.md, SOUL.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file +https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md -And after you've finished that, hire yourself a Founding Engineer agent`; +Ensure you have a folder agents/ceo and then download this AGENTS.md, and sibling HEARTBEAT.md, SOULD.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file + +After that, hire yourself a Founding Engineer agent and then plan the roadmap and tasks for your new company.`; export function OnboardingWizard() { const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); @@ -159,12 +165,11 @@ export function OnboardingWizard() { data: adapterModels, error: adapterModelsError, isLoading: adapterModelsLoading, - isFetching: adapterModelsFetching, + isFetching: adapterModelsFetching } = useQuery({ - queryKey: - createdCompanyId - ? queryKeys.agents.adapterModels(createdCompanyId, adapterType) - : ["agents", "none", "adapter-models", adapterType], + queryKey: createdCompanyId + ? queryKeys.agents.adapterModels(createdCompanyId, adapterType) + : ["agents", "none", "adapter-models", adapterType], queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2 }); @@ -181,10 +186,10 @@ export function OnboardingWizard() { : adapterType === "gemini_local" ? "gemini" : adapterType === "cursor" - ? "agent" - : adapterType === "opencode_local" - ? "opencode" - : "claude"); + ? "agent" + : adapterType === "opencode_local" + ? "opencode" + : "claude"); useEffect(() => { if (step !== 2) return; @@ -219,8 +224,8 @@ export function OnboardingWizard() { return [ { provider: "models", - entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), - }, + entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)) + } ]; } const groups = new Map>(); @@ -234,7 +239,7 @@ export function OnboardingWizard() { .sort(([a], [b]) => a.localeCompare(b)) .map(([provider, entries]) => ({ provider, - entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), + entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)) })); }, [filteredModels, adapterType]); @@ -281,7 +286,7 @@ export function OnboardingWizard() { : adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL : adapterType === "cursor" - ? model || DEFAULT_CURSOR_LOCAL_MODEL + ? model || DEFAULT_CURSOR_LOCAL_MODEL : model, command, args, @@ -347,8 +352,12 @@ export function OnboardingWizard() { queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); if (companyGoal.trim()) { + const parsedGoal = parseOnboardingGoalInput(companyGoal); await goalsApi.create(company.id, { - title: companyGoal.trim(), + title: parsedGoal.title, + ...(parsedGoal.description + ? { description: parsedGoal.description } + : {}), level: "company", status: "active" }); @@ -373,19 +382,23 @@ export function OnboardingWizard() { if (adapterType === "opencode_local") { const selectedModelId = model.trim(); if (!selectedModelId) { - setError("OpenCode requires an explicit model in provider/model format."); + setError( + "OpenCode requires an explicit model in provider/model format." + ); return; } if (adapterModelsError) { setError( adapterModelsError instanceof Error ? adapterModelsError.message - : "Failed to load OpenCode models.", + : "Failed to load OpenCode models." ); return; } if (adapterModelsLoading || adapterModelsFetching) { - setError("OpenCode models are still loading. Please wait and try again."); + setError( + "OpenCode models are still loading. Please wait and try again." + ); return; } const discoveredModels = adapterModels ?? []; @@ -393,7 +406,7 @@ export function OnboardingWizard() { setError( discoveredModels.length === 0 ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." - : `Configured OpenCode model is unavailable: ${selectedModelId}`, + : `Configured OpenCode model is unavailable: ${selectedModelId}` ); return; } @@ -554,19 +567,23 @@ export function OnboardingWizard() { {/* Left half — form */} -
+
{/* Progress tabs */}
- {([ - { step: 1 as Step, label: "Company", icon: Building2 }, - { step: 2 as Step, label: "Agent", icon: Bot }, - { step: 3 as Step, label: "Task", icon: ListTodo }, - { step: 4 as Step, label: "Launch", icon: Rocket }, - ] as const).map(({ step: s, label, icon: Icon }) => ( + {( + [ + { step: 1 as Step, label: "Company", icon: Building2 }, + { step: 2 as Step, label: "Agent", icon: Bot }, + { step: 3 as Step, label: "Task", icon: ListTodo }, + { step: 4 as Step, label: "Launch", icon: Rocket } + ] as const + ).map(({ step: s, label, icon: Icon }) => (
-
-