|
| 1 | +import { and, eq, inArray } from "drizzle-orm"; |
| 2 | +import { z } from "zod"; |
| 3 | +import { db } from "../db/index.js"; |
| 4 | +import { goals, workspaces, workspaceMembers, members } from "../db/schema.js"; |
| 5 | +import { chatJson, plannerEnabled } from "./completion.js"; |
| 6 | +import { createGoal } from "./goals-core.js"; |
| 7 | +import { notify } from "./notifications.js"; |
| 8 | + |
| 9 | +// ───────────────────────────────────────────────────────────────────────── |
| 10 | +// The mission planner — a daily pass that turns the workspace MISSION into |
| 11 | +// fresh goals. Where the goal planner decomposes one goal into tasks, this |
| 12 | +// sits one tier up: it reads the mission, looks at the projects and goals |
| 13 | +// that already exist, and proposes the next few goals worth pursuing — |
| 14 | +// attached to the best-fit existing project. Created goals are ordinary |
| 15 | +// `open` goals, so the existing auto-planner immediately decomposes them |
| 16 | +// into tasks and the board starts moving. |
| 17 | +// ───────────────────────────────────────────────────────────────────────── |
| 18 | + |
| 19 | +// New goals per workspace per run — deliberately small so a mission drips |
| 20 | +// steady work onto the board instead of flooding it. |
| 21 | +const GOALS_PER_RUN = Number(process.env.MISSION_GOALS_PER_RUN ?? 2); |
| 22 | +// Backpressure: skip a workspace that already has this many non-done goals. |
| 23 | +// The mission shouldn't pile new intent onto a board the team can't clear. |
| 24 | +const MAX_OPEN_GOALS = Number(process.env.MISSION_MAX_OPEN_GOALS ?? 12); |
| 25 | + |
| 26 | +const ProposalSchema = z.object({ |
| 27 | + goals: z |
| 28 | + .array( |
| 29 | + z.object({ |
| 30 | + title: z.string().min(1).max(300), |
| 31 | + description: z.string().max(4000).optional().default(""), |
| 32 | + // Exact title of an EXISTING project to file the goal under ("" = none fits). |
| 33 | + project: z.string().max(300).optional().default(""), |
| 34 | + rationale: z.string().max(500).optional().default(""), |
| 35 | + }), |
| 36 | + ) |
| 37 | + .max(10), |
| 38 | +}); |
| 39 | + |
| 40 | +function buildMessages( |
| 41 | + mission: string, |
| 42 | + projects: Array<{ title: string }>, |
| 43 | + existingTitles: string[], |
| 44 | +): Array<{ role: "system" | "user"; content: string }> { |
| 45 | + const system = [ |
| 46 | + "You are the strategy manager for a workspace of AI agents and humans. Once a day you review the workspace MISSION and propose the next goals worth pursuing.", |
| 47 | + `Propose at most ${GOALS_PER_RUN} new goals. Quality over quantity — if the existing goals already cover the mission's next steps, return an empty list.`, |
| 48 | + "Each goal must be a concrete, finishable outcome that advances the mission (not a vague theme, not a task — a goal a small team completes in days).", |
| 49 | + "Never duplicate or trivially rephrase an EXISTING goal. Build on what exists: prefer the natural next step after the goals already there.", |
| 50 | + "If a PROJECT clearly covers the goal, set `project` to that project's exact title; otherwise leave it empty.", |
| 51 | + "Return ONLY a JSON object of this exact shape, no prose, no markdown fence:", |
| 52 | + '{"goals":[{"title":"...","description":"what done looks like","project":"exact project title or empty","rationale":"why this is the next move for the mission"}]}', |
| 53 | + ].join("\n"); |
| 54 | + |
| 55 | + const user = [ |
| 56 | + `WORKSPACE MISSION: ${mission}`, |
| 57 | + "", |
| 58 | + "PROJECTS:", |
| 59 | + projects.length ? projects.map((p) => `- ${p.title}`).join("\n") : "(none yet)", |
| 60 | + "", |
| 61 | + "EXISTING GOALS (do not duplicate):", |
| 62 | + existingTitles.length ? existingTitles.map((t) => `- ${t}`).join("\n") : "(none yet)", |
| 63 | + ].join("\n"); |
| 64 | + |
| 65 | + return [ |
| 66 | + { role: "system", content: system }, |
| 67 | + { role: "user", content: user }, |
| 68 | + ]; |
| 69 | +} |
| 70 | + |
| 71 | +// The workspace's first human admin — the actor the daily goals are created |
| 72 | +// and owned by, so stall/plan-failure notifications have a human target. |
| 73 | +async function findAdminMember(workspaceId: string): Promise<string | null> { |
| 74 | + const admins = await db |
| 75 | + .select({ userId: workspaceMembers.userId }) |
| 76 | + .from(workspaceMembers) |
| 77 | + .where(and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.role, "admin"))); |
| 78 | + if (!admins.length) return null; |
| 79 | + const [m] = await db |
| 80 | + .select({ id: members.id }) |
| 81 | + .from(members) |
| 82 | + .where( |
| 83 | + and( |
| 84 | + eq(members.workspaceId, workspaceId), |
| 85 | + eq(members.kind, "user"), |
| 86 | + inArray( |
| 87 | + members.refId, |
| 88 | + admins.map((a) => a.userId), |
| 89 | + ), |
| 90 | + ), |
| 91 | + ) |
| 92 | + .limit(1); |
| 93 | + return m?.id ?? null; |
| 94 | +} |
| 95 | + |
| 96 | +const norm = (s: string): string => s.trim().toLowerCase().replace(/\s+/g, " "); |
| 97 | + |
| 98 | +// One workspace: mission → up to GOALS_PER_RUN new goals. Returns #created. |
| 99 | +async function planWorkspace(ws: { id: string; mission: string }): Promise<number> { |
| 100 | + const rows = await db |
| 101 | + .select({ id: goals.id, title: goals.title, kind: goals.kind, status: goals.status }) |
| 102 | + .from(goals) |
| 103 | + .where(eq(goals.workspaceId, ws.id)); |
| 104 | + |
| 105 | + const live = rows.filter((g) => g.status !== "archived"); |
| 106 | + const openCount = live.filter((g) => g.kind === "goal" && g.status !== "done").length; |
| 107 | + if (openCount >= MAX_OPEN_GOALS) { |
| 108 | + console.log(`[mission-planner] ${ws.id}: ${openCount} open goals ≥ cap ${MAX_OPEN_GOALS}, skipping`); |
| 109 | + return 0; |
| 110 | + } |
| 111 | + |
| 112 | + const projects = live.filter((g) => g.kind === "project" && g.status !== "done"); |
| 113 | + // Feed every non-archived title (done included) as dedupe context — a goal |
| 114 | + // finished last week shouldn't be re-proposed this week. |
| 115 | + const existingTitles = live.map((g) => g.title).slice(0, 200); |
| 116 | + |
| 117 | + const raw = await chatJson<unknown>(buildMessages(ws.mission, projects, existingTitles), { |
| 118 | + temperature: 0.3, |
| 119 | + maxTokens: 2000, |
| 120 | + timeoutMs: 150_000, |
| 121 | + }); |
| 122 | + const parsed = ProposalSchema.safeParse(raw); |
| 123 | + if (!parsed.success) { |
| 124 | + console.error( |
| 125 | + `[mission-planner] proposal failed for ${ws.id}: ` + |
| 126 | + (raw === null ? "chatJson returned null" : `schema rejected: ${JSON.stringify(raw).slice(0, 200)}`), |
| 127 | + ); |
| 128 | + return 0; |
| 129 | + } |
| 130 | + |
| 131 | + const actor = await findAdminMember(ws.id); |
| 132 | + if (!actor) { |
| 133 | + console.error(`[mission-planner] ${ws.id}: no human admin member, skipping`); |
| 134 | + return 0; |
| 135 | + } |
| 136 | + |
| 137 | + const seen = new Set(live.map((g) => norm(g.title))); |
| 138 | + const projectByTitle = new Map(projects.map((p) => [norm(p.title), p])); |
| 139 | + const createdTitles: string[] = []; |
| 140 | + for (const p of parsed.data.goals.slice(0, GOALS_PER_RUN)) { |
| 141 | + if (seen.has(norm(p.title))) continue; // model ignored the dedupe instruction |
| 142 | + const project = projectByTitle.get(norm(p.project)); |
| 143 | + const body = [p.description, p.rationale ? `_Why now: ${p.rationale}_` : ""] |
| 144 | + .filter(Boolean) |
| 145 | + .join("\n\n"); |
| 146 | + const r = await createGoal( |
| 147 | + { title: p.title, bodyMd: body, parentGoalId: project?.id ?? null, kind: "goal" }, |
| 148 | + actor, |
| 149 | + ws.id, |
| 150 | + ); |
| 151 | + if ("error" in r) { |
| 152 | + console.error(`[mission-planner] create failed for "${p.title}": ${r.error}`); |
| 153 | + continue; |
| 154 | + } |
| 155 | + seen.add(norm(p.title)); |
| 156 | + createdTitles.push(p.title + (project ? ` (→ ${project.title})` : "")); |
| 157 | + } |
| 158 | + |
| 159 | + if (createdTitles.length) { |
| 160 | + await notify({ |
| 161 | + workspaceId: ws.id, |
| 162 | + memberId: actor, |
| 163 | + kind: "system", |
| 164 | + title: `Daily planning added ${createdTitles.length} goal${createdTitles.length > 1 ? "s" : ""} from your mission`, |
| 165 | + body: createdTitles.join(" · "), |
| 166 | + link: `/goals`, |
| 167 | + }).catch(() => {}); |
| 168 | + } |
| 169 | + return createdTitles.length; |
| 170 | +} |
| 171 | + |
| 172 | +// Entry point for the repeatable "mission" job: every auto-planning workspace |
| 173 | +// with a non-empty mission gets its daily goal proposals. |
| 174 | +export async function runMissionPlanning(): Promise<void> { |
| 175 | + if (!plannerEnabled()) return; |
| 176 | + const wss = await db |
| 177 | + .select({ id: workspaces.id, mission: workspaces.mission }) |
| 178 | + .from(workspaces) |
| 179 | + .where(eq(workspaces.autoPlan, "auto")); |
| 180 | + let total = 0; |
| 181 | + for (const ws of wss) { |
| 182 | + if (!ws.mission.trim()) continue; |
| 183 | + total += await planWorkspace(ws).catch((e) => { |
| 184 | + console.error(`[mission-planner] workspace ${ws.id} failed`, e); |
| 185 | + return 0; |
| 186 | + }); |
| 187 | + } |
| 188 | + console.log(`[mission-planner] run complete: ${total} goal(s) created across ${wss.length} workspace(s)`); |
| 189 | +} |
0 commit comments