diff --git a/drizzle/0010_mission_actions.sql b/drizzle/0010_mission_actions.sql new file mode 100644 index 0000000..38b3e0c --- /dev/null +++ b/drizzle/0010_mission_actions.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS "mission_actions" ( + "id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade, + "mission_id" text NOT NULL, + "stage_id" text, + "agent_id" text NOT NULL, + "agent_name" text NOT NULL, + "base_agent" text, + "service_id" text, + "tool_name" text, + "mode" text NOT NULL, + "status" text DEFAULT 'queued' NOT NULL, + "title" text NOT NULL, + "body" text NOT NULL, + "prompt" text, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "result" jsonb, + "error" text, + "source_event_id" text REFERENCES "grow_events"("id") ON DELETE set null, + "idempotency_key" text, + "priority" integer DEFAULT 0 NOT NULL, + "urgency" text DEFAULT 'calm' NOT NULL, + "due_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "resolved_at" timestamp with time zone +); + +CREATE INDEX IF NOT EXISTS "mission_actions_mission_idx" ON "mission_actions" ("user_id", "mission_instance_id", "status", "priority"); +CREATE INDEX IF NOT EXISTS "mission_actions_user_idx" ON "mission_actions" ("user_id", "status", "updated_at"); +CREATE INDEX IF NOT EXISTS "mission_actions_source_idx" ON "mission_actions" ("source_event_id"); +CREATE INDEX IF NOT EXISTS "mission_actions_due_idx" ON "mission_actions" ("due_at"); +CREATE UNIQUE INDEX IF NOT EXISTS "mission_actions_idempotency_idx" ON "mission_actions" ("idempotency_key"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9dbb06a..5790c00 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1780481400000, "tag": "0009_mission_suggestions", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1780481500000, + "tag": "0010_mission_actions", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/actors/events/user-event-actor.ts b/src/actors/events/user-event-actor.ts index 5e29f16..181fc5c 100644 --- a/src/actors/events/user-event-actor.ts +++ b/src/actors/events/user-event-actor.ts @@ -11,6 +11,7 @@ import { getProjectionInsight } from "../../events/projectors/projection-agent.j import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../../events/record-grow-event.js"; import { reducersForMission } from "../../missions/event-reducers.js"; import type { MissionArtifactPatch, MissionStagePatch } from "../../missions/reducer-types.js"; +import { createMissionActionsFromPatches } from "../../missions/actions.js"; export type UserEventCommand = { userId: string; @@ -165,20 +166,21 @@ export const userEventActor = actor({ const client = loopCtx.client(); for (const active of activeRows) { const mission = active.mission; + const actorHandle = missionActorHandle(client, cmd.userId, mission); + if (!actorHandle) continue; + await actorHandle.ingestEvent({ eventId: row.id }).catch(() => undefined); + const reducers = reducersForMission(mission.missionId); if (!reducers.length) continue; for (const reducer of reducers) { const reduceCtx = { userId: cmd.userId, activeMission: mission, event: row, qscoreSignals: qscoreResult.signals, insight }; if (!reducer.accepts(reduceCtx)) continue; const reduction = reducer.reduce(reduceCtx); - if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.eventMessage) continue; - - const actorHandle = missionActorHandle(client, cmd.userId, mission); - if (!actorHandle) continue; + if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.actions.length && !reduction.eventMessage) continue; if (reduction.eventMessage) { await actorHandle.recordEvent({ type: row.type, message: reduction.eventMessage, payload: { sourceEventId: row.id } }); } - let snapshot: MissionSnapshot | undefined = reduction.stagePatches.length ? (await applyStagePatches(actorHandle, reduction.stagePatches) ?? undefined) : undefined; + if (reduction.stagePatches.length) await applyStagePatches(actorHandle, reduction.stagePatches); await applyArtifactPatches({ actorHandle, userId: cmd.userId, @@ -188,6 +190,14 @@ export const userEventActor = actor({ externalId: typeof row.correlation?.sessionId === "string" ? row.correlation.sessionId : undefined, patches: reduction.artifacts, }); + if (reduction.actions.length) { + await createMissionActionsFromPatches({ + userId: cmd.userId, + mission, + eventId: row.id, + patches: reduction.actions, + }); + } const finalSnapshot = (await actorHandle.getState()) as MissionSnapshot; await upsertActiveMissionPg(cmd.userId, summarizeMissionSnapshot(finalSnapshot), finalSnapshot); } diff --git a/src/actors/missions/interview-to-offer-actor.ts b/src/actors/missions/interview-to-offer-actor.ts index 3ffb418..dc8ae3e 100644 --- a/src/actors/missions/interview-to-offer-actor.ts +++ b/src/actors/missions/interview-to-offer-actor.ts @@ -160,6 +160,66 @@ export const interviewToOfferMissionActor = actor({ return entry; }, + ingestEvent: (c, input: { eventId: string }) => { + ensureInitialized(c.state); + const entry: MissionEvent = { + id: eventId(), + type: "mission.event_ingested", + message: `Event ${input.eventId} ingested by mission runtime.`, + payload: { eventId: input.eventId }, + createdAt: nowIso(), + }; + c.state.events.unshift(entry); + c.state.updatedAt = entry.createdAt; + c.broadcast("eventAdded", entry); + c.broadcast("updated", c.state); + return c.state; + }, + + planNextActions: (c, input: { reason?: string } = {}) => { + ensureInitialized(c.state); + const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked"); + return { + missionInstanceId: c.state.instanceId, + missionId: c.state.missionId, + currentStageId: active?.id, + reason: input.reason ?? "manual", + recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.", + }; + }, + + runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => { + ensureInitialized(c.state); + const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked"); + const entry: MissionEvent = { + id: eventId(), + type: "mission.daily_scrum.completed", + message: active ? `Daily scrum: next focus is ${active.title}.` : "Daily scrum: mission has no blocked action right now.", + payload: { trigger: input.trigger ?? "manual", currentStageId: active?.id }, + createdAt: nowIso(), + }; + c.state.events.unshift(entry); + c.state.updatedAt = entry.createdAt; + c.broadcast("eventAdded", entry); + c.broadcast("updated", c.state); + return { snapshot: c.state, summary: entry.message }; + }, + + queueAction: (c, input: { actionId: string; title?: string }) => { + ensureInitialized(c.state); + return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title }; + }, + + runAction: (c, input: { actionId: string }) => { + ensureInitialized(c.state); + return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId }; + }, + + resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record }) => { + ensureInitialized(c.state); + return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId }; + }, + updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => { ensureInitialized(c.state); const stage = c.state.stages.find((item) => item.id === input.stageId); diff --git a/src/actors/missions/mission-actor-factory.ts b/src/actors/missions/mission-actor-factory.ts index 4cef1eb..2a44c6c 100644 --- a/src/actors/missions/mission-actor-factory.ts +++ b/src/actors/missions/mission-actor-factory.ts @@ -166,6 +166,67 @@ export function createMissionActor(options: { return entry; }, + ingestEvent: (c, input: { eventId: string }) => { + ensureInitialized(c.state); + const entry: MissionEvent = { + id: eventId(), + type: "mission.event_ingested", + message: `Event ${input.eventId} ingested by mission runtime.`, + payload: { eventId: input.eventId }, + createdAt: nowIso(), + }; + c.state.events.unshift(entry); + c.state.updatedAt = entry.createdAt; + c.broadcast("eventAdded", entry); + c.broadcast("updated", c.state); + return c.state; + }, + + planNextActions: (c, input: { reason?: string } = {}) => { + ensureInitialized(c.state); + const blocked = c.state.stages.find((stage) => stage.status === "blocked"); + const active = blocked ?? c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress"); + return { + missionInstanceId: c.state.instanceId, + missionId: c.state.missionId, + currentStageId: active?.id, + reason: input.reason ?? "manual", + recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.", + }; + }, + + runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => { + ensureInitialized(c.state); + const recommendation = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked"); + const entry: MissionEvent = { + id: eventId(), + type: "mission.daily_scrum.completed", + message: recommendation ? `Daily scrum: next focus is ${recommendation.title}.` : "Daily scrum: mission has no blocked action right now.", + payload: { trigger: input.trigger ?? "manual", currentStageId: recommendation?.id }, + createdAt: nowIso(), + }; + c.state.events.unshift(entry); + c.state.updatedAt = entry.createdAt; + c.broadcast("eventAdded", entry); + c.broadcast("updated", c.state); + return { snapshot: c.state, summary: entry.message }; + }, + + queueAction: (c, input: { actionId: string; title?: string }) => { + ensureInitialized(c.state); + return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title }; + }, + + runAction: (c, input: { actionId: string }) => { + ensureInitialized(c.state); + return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId }; + }, + + resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record }) => { + ensureInitialized(c.state); + return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId }; + }, + updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => { ensureInitialized(c.state); const stage = c.state.stages.find((item) => item.id === input.stageId); diff --git a/src/db/schema.ts b/src/db/schema.ts index 6d27f24..0b62abf 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -458,6 +458,52 @@ export const growQscoreProjectionState = pgTable("grow_qscore_projection_state", updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); +export const missionActions = pgTable( + "mission_actions", + { + id: text("id").primaryKey().default(sql`gen_random_uuid()::text`), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }), + missionId: text("mission_id").notNull(), + stageId: text("stage_id"), + + agentId: text("agent_id").notNull(), + agentName: text("agent_name").notNull(), + baseAgent: text("base_agent"), + serviceId: text("service_id"), + toolName: text("tool_name"), + + mode: text("mode", { enum: ["autonomous", "approval_required", "user_input_required", "suggestion"] }).notNull(), + status: text("status", { + enum: ["queued", "running", "waiting_approval", "waiting_user_input", "done", "failed", "dismissed", "snoozed"], + }).notNull().default("queued"), + + title: text("title").notNull(), + body: text("body").notNull(), + prompt: text("prompt"), + payload: jsonb("payload").$type>().notNull().default(sql`'{}'::jsonb`), + result: jsonb("result").$type>(), + error: text("error"), + + sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }), + idempotencyKey: text("idempotency_key"), + priority: integer("priority").notNull().default(0), + urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"), + dueAt: timestamp("due_at", { withTimezone: true }), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + }, + (t) => ({ + missionIdx: index("mission_actions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority), + userIdx: index("mission_actions_user_idx").on(t.userId, t.status, t.updatedAt), + sourceIdx: index("mission_actions_source_idx").on(t.sourceEventId), + dueIdx: index("mission_actions_due_idx").on(t.dueAt), + idempotencyIdx: uniqueIndex("mission_actions_idempotency_idx").on(t.idempotencyKey), + }), +); + export const missionSuggestions = pgTable( "mission_suggestions", { @@ -544,6 +590,8 @@ export const growHomeNotifications = pgTable( export type GrowEventRow = typeof growEvents.$inferSelect; export type NewGrowEvent = typeof growEvents.$inferInsert; +export type MissionActionRow = typeof missionActions.$inferSelect; +export type NewMissionAction = typeof missionActions.$inferInsert; export type MissionSuggestionRow = typeof missionSuggestions.$inferSelect; export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert; export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect; diff --git a/src/home/types.ts b/src/home/types.ts index c1c7b7a..f8cae38 100644 --- a/src/home/types.ts +++ b/src/home/types.ts @@ -40,15 +40,15 @@ export type HomeFeedResponse = { }; export const MODULE_META: Record> = { - suggestions: { id: "suggestions", label: "Suggestions", href: "/suggestions", accent: "orange" }, + suggestions: { id: "suggestions", label: "Today's Queue", href: "/suggestions", accent: "orange" }, missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" }, social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" }, pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" }, - productivity: { id: "productivity", label: "Productivity", href: "/productivity", accent: "orange" }, + productivity: { id: "productivity", label: "Interview · Roleplay · Resume", href: "/agents", accent: "orange" }, rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" }, }; -export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "social", "pathways", "productivity", "rewards"]; +export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "productivity"]; export const ALLOWED_NOTIFICATION_HREFS = new Set([ "/suggestions", diff --git a/src/missions/action-types.ts b/src/missions/action-types.ts new file mode 100644 index 0000000..dd82f65 --- /dev/null +++ b/src/missions/action-types.ts @@ -0,0 +1,80 @@ +import type { InferSelectModel } from "drizzle-orm"; +import type { missionActions } from "../db/schema.js"; + +export type MissionActionMode = "autonomous" | "approval_required" | "user_input_required" | "suggestion"; +export type MissionActionStatus = + | "queued" + | "running" + | "waiting_approval" + | "waiting_user_input" + | "done" + | "failed" + | "dismissed" + | "snoozed"; +export type MissionActionUrgency = "now" | "today" | "soon" | "calm"; + +export type MissionActionRow = InferSelectModel; + +export type MissionActionDto = { + id: string; + userId: string; + missionInstanceId: string; + missionId: string; + stageId?: string; + agentId: string; + agentName: string; + baseAgent?: string; + serviceId?: string; + toolName?: string; + mode: MissionActionMode; + status: MissionActionStatus; + title: string; + body: string; + prompt?: string; + payload: Record; + result?: Record; + error?: string; + sourceEventId?: string; + idempotencyKey?: string; + priority: number; + urgency: MissionActionUrgency; + dueAt?: string; + createdAt: string; + updatedAt: string; + resolvedAt?: string; +}; + +export type NewMissionActionInput = { + userId: string; + missionInstanceId: string; + missionId: string; + stageId?: string; + agentId: string; + agentName: string; + baseAgent?: string; + serviceId?: string; + toolName?: string; + mode: MissionActionMode; + status?: MissionActionStatus; + title: string; + body: string; + prompt?: string; + payload?: Record; + result?: Record; + error?: string; + sourceEventId?: string; + idempotencyKey?: string; + priority?: number; + urgency?: MissionActionUrgency; + dueAt?: Date | string; +}; + +export function defaultMissionActionStatus(mode: MissionActionMode): MissionActionStatus { + if (mode === "approval_required") return "waiting_approval"; + if (mode === "user_input_required") return "waiting_user_input"; + return "queued"; +} + +export function isOpenMissionActionStatus(status: MissionActionStatus) { + return status === "queued" || status === "running" || status === "waiting_approval" || status === "waiting_user_input" || status === "failed"; +} diff --git a/src/missions/actions.ts b/src/missions/actions.ts new file mode 100644 index 0000000..4fb77c7 --- /dev/null +++ b/src/missions/actions.ts @@ -0,0 +1,190 @@ +import { and, desc, eq, inArray } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { missionActions, missionSuggestions } from "../db/schema.js"; +import type { GrowActiveMission } from "../actors/missions/types.js"; +import type { MissionActionPatch } from "./reducer-types.js"; +import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js"; + +const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"]; +const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"]; + +function toDate(value?: Date | string) { + if (!value) return undefined; + return value instanceof Date ? value : new Date(value); +} + +export function actionToDto(row: MissionActionRow): MissionActionDto { + return { + id: row.id, + userId: row.userId, + missionInstanceId: row.missionInstanceId, + missionId: row.missionId, + stageId: row.stageId ?? undefined, + agentId: row.agentId, + agentName: row.agentName, + baseAgent: row.baseAgent ?? undefined, + serviceId: row.serviceId ?? undefined, + toolName: row.toolName ?? undefined, + mode: row.mode, + status: row.status, + title: row.title, + body: row.body, + prompt: row.prompt ?? undefined, + payload: row.payload ?? {}, + result: row.result ?? undefined, + error: row.error ?? undefined, + sourceEventId: row.sourceEventId ?? undefined, + idempotencyKey: row.idempotencyKey ?? undefined, + priority: row.priority, + urgency: row.urgency, + dueAt: row.dueAt?.toISOString(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + resolvedAt: row.resolvedAt?.toISOString(), + }; +} + +function ctaForAction(action: MissionActionRow | NewMissionActionInput) { + const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record : {}; + const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined; + const serviceId = action.serviceId ?? ""; + const missionHref = `/missions/active?missionInstanceId=${encodeURIComponent(action.missionInstanceId)}`; + const href = hrefFromPayload ?? + (serviceId.includes("interview") ? `/agents/interview/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` : + serviceId.includes("roleplay") ? `/agents/roleplay/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` : + serviceId.includes("resume") ? `/agents/resume?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` : missionHref); + + if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref }; + if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref }; + if (serviceId.includes("interview")) return { ctaLabel: "Start mock", ctaHref: href }; + if (serviceId.includes("roleplay")) return { ctaLabel: "Run drill", ctaHref: href }; + if (serviceId.includes("resume")) return { ctaLabel: "Open resume", ctaHref: href }; + return { ctaLabel: "Open", ctaHref: href }; +} + +function suggestionTypeForAction(action: MissionActionRow | NewMissionActionInput) { + if (action.mode === "user_input_required") return "blocked" as const; + if (action.mode === "approval_required") return "review" as const; + if ((action.serviceId ?? "").includes("interview") || (action.serviceId ?? "").includes("roleplay")) return "practice" as const; + if ((action.serviceId ?? "").includes("resume")) return "artifact" as const; + return "action" as const; +} + +async function refreshSuggestionForAction(row: MissionActionRow) { + const active = OPEN_STATUSES.includes(row.status); + const { ctaLabel, ctaHref } = ctaForAction(row); + const status = active ? "active" : row.status === "done" ? "done" : "dismissed"; + + await db.insert(missionSuggestions).values({ + id: `suggestion:${row.id}`, + userId: row.userId, + missionInstanceId: row.missionInstanceId, + missionId: row.missionId, + stageId: row.stageId, + role: row.agentName, + type: suggestionTypeForAction(row), + title: row.title, + body: row.body, + reason: row.prompt, + priority: row.priority, + urgency: row.urgency, + status, + ctaLabel, + ctaHref, + sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName }, + generatedBy: "agent", + updatedAt: new Date(), + }).onConflictDoUpdate({ + target: missionSuggestions.id, + set: { + stageId: row.stageId, + role: row.agentName, + type: suggestionTypeForAction(row), + title: row.title, + body: row.body, + reason: row.prompt, + priority: row.priority, + urgency: row.urgency, + status, + ctaLabel, + ctaHref, + sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName }, + generatedBy: "agent", + updatedAt: new Date(), + }, + }); +} + +export async function createMissionAction(input: NewMissionActionInput) { + const [row] = await db.insert(missionActions).values({ + ...input, + status: input.status ?? defaultMissionActionStatus(input.mode), + priority: input.priority ?? 0, + urgency: input.urgency ?? "calm", + payload: input.payload ?? {}, + result: input.result, + dueAt: toDate(input.dueAt), + updatedAt: new Date(), + }).onConflictDoNothing().returning(); + if (!row) return null; + await refreshSuggestionForAction(row); + return actionToDto(row); +} + +export async function createMissionActionsFromPatches(input: { userId: string; mission: GrowActiveMission; eventId: string; patches: MissionActionPatch[] }) { + const created: MissionActionDto[] = []; + for (const patch of input.patches) { + const action = await createMissionAction({ + userId: input.userId, + missionInstanceId: input.mission.instanceId, + missionId: input.mission.missionId, + stageId: patch.stageId, + agentId: patch.agentId, + agentName: patch.agentName, + baseAgent: patch.baseAgent, + serviceId: patch.serviceId, + toolName: patch.toolName, + mode: patch.mode, + status: patch.status, + title: patch.title, + body: patch.body, + prompt: patch.prompt, + payload: patch.payload ?? {}, + sourceEventId: patch.sourceEventId ?? input.eventId, + idempotencyKey: patch.idempotencyKey, + priority: patch.priority, + urgency: patch.urgency, + dueAt: patch.dueAt, + }); + if (action) created.push(action); + } + return created; +} + +export async function listMissionActions(userId: string, opts: { missionInstanceId?: string; openOnly?: boolean } = {}) { + const conditions = [eq(missionActions.userId, userId)]; + if (opts.missionInstanceId) conditions.push(eq(missionActions.missionInstanceId, opts.missionInstanceId)); + if (opts.openOnly ?? true) conditions.push(inArray(missionActions.status, OPEN_STATUSES)); + const rows = await db.select().from(missionActions).where(and(...conditions)).orderBy(desc(missionActions.priority), desc(missionActions.updatedAt)); + return rows.map(actionToDto); +} + +export async function getMissionAction(userId: string, actionId: string) { + const [row] = await db.select().from(missionActions).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).limit(1); + return row ?? null; +} + +export async function updateMissionActionStatus(userId: string, actionId: string, patch: { status: MissionActionStatus; result?: Record; error?: string; payload?: Record }) { + const resolvedAt = DONE_STATUSES.includes(patch.status) ? new Date() : undefined; + const [row] = await db.update(missionActions).set({ + status: patch.status, + result: patch.result, + error: patch.error, + payload: patch.payload, + resolvedAt, + updatedAt: new Date(), + }).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).returning(); + if (!row) return null; + await refreshSuggestionForAction(row); + return actionToDto(row); +} diff --git a/src/missions/career-transition/reducer.ts b/src/missions/career-transition/reducer.ts new file mode 100644 index 0000000..05e4588 --- /dev/null +++ b/src/missions/career-transition/reducer.ts @@ -0,0 +1,89 @@ +import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; +import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js"; + +export const careerTransitionReducer: MissionReducer = { + missionId: "career-transition", + accepts(ctx) { + return ctx.activeMission.missionId === "career-transition" && + (missionExplicitlyMatches(ctx.event.mission, "career-transition") || isRelevantServiceEvent(ctx.event.source, ctx.event.type)); + }, + reduce(ctx): MissionReduction { + const { event, activeMission } = ctx; + const type = event.type; + const payload = event.payload ?? {}; + const stagePatches: MissionStagePatch[] = []; + const artifacts: MissionReduction["artifacts"] = []; + const actions: MissionReduction["actions"] = []; + let eventMessage = ctx.insight.summary; + + if (!activeMission.goal && (type === "mission.started" || type.startsWith("mission."))) { + actions.push(actionForAgent("career-transition", "planner", { + stageId: "clarify-target", + mode: "user_input_required", + title: "Choose the target role for your transition", + body: "Career transition needs a clear adjacent role before resume repositioning or practice will be useful.", + prompt: "What role are you exploring next, and what role/background are you moving from?", + payload: { fields: ["current_role", "target_role", "constraints"] }, + idempotencyKey: `${activeMission.instanceId}:clarify-target-role`, + priority: 100, + urgency: "now", + })); + } + + if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) { + const signals = extractResumeSignals(payload); + stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Transferable skills mapped from resume evidence." }); + stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Validate the transition story in an adjacent-role mock interview." }); + artifacts.push({ type: "transferable_skills_map", title: "Transferable skills map", stageId: "resume", summary: signals[0] ?? "Resume proof was mapped into transition evidence.", metadata: { sourceEventId: event.id, signals } }); + actions.push(actionForAgent("career-transition", "resume", { + stageId: "resume", + serviceId: "resume-service", + toolName: "resume.create_version_prompt_draft", + mode: "approval_required", + title: "Draft a repositioned resume for the target role?", + body: "Approve a Resume Agent draft that reframes your transferable skills for the career switch.", + payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:resume-transition-draft:${event.id}`, + priority: 95, + urgency: "today", + })); + eventMessage = "Transferable skills map created; repositioned resume action is ready."; + } + + if (isInterviewEvent(event.source, type) && type.includes("configured")) { + stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 40, outputSummary: "Adjacent-role mock interview configured." }); + eventMessage = "Adjacent-role interview practice started."; + } + + if (isInterviewEvent(event.source, type) && type.includes("review_completed")) { + const weakAreas = extractWeakAreas(payload); + stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Adjacent-role credibility checked." }); + stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the 'why I am switching' narrative next." }); + artifacts.push({ type: "transition_interview_diagnosis", title: "Adjacent-role credibility diagnosis", stageId: "interview", summary: weakAreas.length ? `Needs work: ${weakAreas.join(", ")}` : "Interview review completed for transition credibility.", metadata: { sourceEventId: event.id, weakAreas } }); + actions.push(actionForAgent("career-transition", "roleplay", { + stageId: "roleplay", + serviceId: "roleplay-service", + toolName: "roleplay.configure_practice", + mode: "suggestion", + title: "Practice your 'why I am switching' pitch", + body: "Turn the adjacent-role feedback into a confident transition narrative before real conversations.", + payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:transition-pitch-roleplay:${event.id}`, + priority: 92, + urgency: "today", + })); + eventMessage = "Career transition interview feedback produced the next pitch-practice action."; + } + + if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Transition pitch practice reviewed." }); + stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Transition confidence signals updated." }); + eventMessage = "Transition narrative practice completed."; + } + + if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Transition readiness signals updated." }); + return { stagePatches, artifacts, actions, eventMessage }; + }, +}; diff --git a/src/missions/event-reducers.ts b/src/missions/event-reducers.ts index 74becfe..81ad463 100644 --- a/src/missions/event-reducers.ts +++ b/src/missions/event-reducers.ts @@ -1,7 +1,17 @@ import type { MissionReducer } from "./reducer-types.js"; import { interviewToOfferReducer } from "./interview-to-offer/reducer.js"; +import { careerTransitionReducer } from "./career-transition/reducer.js"; +import { salaryNegotiationReducer } from "./salary-negotiation-war-room/reducer.js"; +import { promotionReadinessReducer } from "./promotion-readiness/reducer.js"; +import { personalBrandOpportunityReducer } from "./personal-brand-opportunity-engine/reducer.js"; -export const missionEventReducers: MissionReducer[] = [interviewToOfferReducer]; +export const missionEventReducers: MissionReducer[] = [ + interviewToOfferReducer, + careerTransitionReducer, + salaryNegotiationReducer, + promotionReadinessReducer, + personalBrandOpportunityReducer, +]; export function reducersForMission(missionId: string) { return missionEventReducers.filter((reducer) => reducer.missionId === missionId); diff --git a/src/missions/interview-to-offer/reducer.ts b/src/missions/interview-to-offer/reducer.ts index 600ff0e..3a77e54 100644 --- a/src/missions/interview-to-offer/reducer.ts +++ b/src/missions/interview-to-offer/reducer.ts @@ -1,99 +1,147 @@ -import { asRecord, getNumber, getString } from "../../events/envelope.js"; -import type { MissionArtifactPatch, MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; +import { getString } from "../../events/envelope.js"; +import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; +import { + actionForAgent, + extractOverallScore, + extractResumeSignals, + extractWeakAreas, + isInterviewEvent, + isQscoreEvent, + isRelevantServiceEvent, + isResumeEvent, + isRoleplayEvent, + missionExplicitlyMatches, + serviceHref, +} from "../reducer-helpers.js"; -function eventMatchesSource(source: string, type: string) { - return ( - source.includes("resume") || - source.includes("interview") || - source.includes("qscore") || - type.startsWith("resume.") || - type.startsWith("interview.") || - type.startsWith("qscore.") || - type.startsWith("mission.interview_to_offer") - ); +function acceptsMission(ctx: Parameters[0]) { + return ctx.activeMission.missionId === "interview-to-offer" && + (missionExplicitlyMatches(ctx.event.mission, "interview-to-offer") || isRelevantServiceEvent(ctx.event.source, ctx.event.type)); } function reviewSummary(payload: Record) { - const review = asRecord(payload.review ?? payload.result ?? payload); - const overall = getNumber(review.overall_score ?? review.overallScore); - const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback); - if (overall !== undefined && summary) return `Mock interview review completed with score ${overall}. ${summary}`; - if (overall !== undefined) return `Mock interview review completed with score ${overall}.`; + const score = extractOverallScore(payload); + const summary = getString(payload.summary ?? payload.feedback_summary ?? payload.overall_feedback); + if (score !== undefined && summary) return `Mock interview review completed with score ${score}. ${summary}`; + if (score !== undefined) return `Mock interview review completed with score ${score}.`; return summary ?? "Mock interview review completed."; } export const interviewToOfferReducer: MissionReducer = { missionId: "interview-to-offer", - - accepts(ctx) { - if (ctx.activeMission.missionId !== "interview-to-offer") return false; - const mission = asRecord(ctx.event.mission); - const explicitMissionId = getString(mission.missionId ?? mission.mission_id); - return explicitMissionId === "interview-to-offer" || eventMatchesSource(ctx.event.source.toLowerCase(), ctx.event.type); - }, + accepts: acceptsMission, reduce(ctx): MissionReduction { - const type = ctx.event.type; - const payload = ctx.event.payload ?? {}; + const { event, activeMission } = ctx; + const type = event.type; + const payload = event.payload ?? {}; const stagePatches: MissionStagePatch[] = [...ctx.insight.missionStageHints.map((hint) => ({ stageId: hint.stageId, status: hint.status, progressPercent: hint.progressPercent, outputSummary: hint.reason, }))]; - const artifacts: MissionArtifactPatch[] = []; + const artifacts: MissionReduction["artifacts"] = []; + const actions: MissionReduction["actions"] = []; let eventMessage = ctx.insight.summary; - if (type.startsWith("resume.") && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) { - stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume fit scan completed." }); - stagePatches.push({ stageId: "interview-plan", status: "ready", progressPercent: 0 }); + if (isResumeEvent(event.source, type) && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) { + const signals = extractResumeSignals(payload); + stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume talking points and fit scan are ready." }); + stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0 }); artifacts.push({ - type: "resume_fit_scan", - title: "Resume Fit Scan", + type: "resume_talking_points", + title: "Resume-based talking points", stageId: "resume", - summary: getString(payload.summary) ?? "Resume analysis completed and readiness signals were updated.", - metadata: { sourceEventId: ctx.event.id, payload }, + summary: signals[0] ?? "Resume analysis completed and role-fit talking points are ready.", + metadata: { sourceEventId: event.id, signals, payload }, }); - eventMessage = "Resume fit scan completed for Interview-to-Offer."; + actions.push(actionForAgent("interview-to-offer", "interview", { + stageId: "interview", + serviceId: "interview-service", + toolName: "interview.configure_practice", + mode: "suggestion", + title: "Start the first mock interview", + body: "Your resume proof is ready. Run a targeted mock interview for the scheduled role now.", + payload: { href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:resume-analysis:start-interview:${event.id}`, + priority: 92, + urgency: "today", + })); + eventMessage = "Resume fit scan completed; mock interview is ready to run."; } - if (type.startsWith("interview.") && (type.includes("configured") || type.includes("created"))) { + if (isInterviewEvent(event.source, type) && (type.includes("configured") || type.includes("created"))) { stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 35, outputSummary: "Mock interview session configured." }); eventMessage = "Mock interview session configured."; } - if (type.startsWith("interview.") && (type.includes("session_completed") || type.includes("session.completed"))) { + if (isInterviewEvent(event.source, type) && (type.includes("session_completed") || type.includes("session.completed"))) { stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 75, outputSummary: "Interview completed; review is being prepared." }); eventMessage = "Mock interview completed; waiting for review."; } - if (type.startsWith("interview.") && (type.includes("review_completed") || type.includes("review.completed"))) { + if (isInterviewEvent(event.source, type) && (type.includes("review_completed") || type.includes("review.completed"))) { + const weakAreas = extractWeakAreas(payload); stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: reviewSummary(payload) }); + stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the communication gaps surfaced by interview feedback." }); stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 60, outputSummary: "Readiness signals updated from interview review." }); artifacts.push({ type: "mock_interview_review", - title: "Mock Interview Review", + title: "Weakness diagnosis", stageId: "interview", summary: reviewSummary(payload), - metadata: { sourceEventId: ctx.event.id, payload }, + metadata: { sourceEventId: event.id, weakAreas, payload }, }); - eventMessage = "Mock interview review completed and mission readiness was updated."; + actions.push(actionForAgent("interview-to-offer", "resume", { + stageId: "resume", + serviceId: "resume-service", + toolName: "resume.create_version_prompt_draft", + mode: "approval_required", + title: "Create a tailored resume version from this interview feedback?", + body: weakAreas.length + ? `The interview exposed ${weakAreas.slice(0, 3).join(", ")}. Approve a Resume Agent draft that turns this feedback into targeted bullets and talking points.` + : "Approve a Resume Agent draft that turns the interview feedback into targeted bullets and talking points.", + payload: { weakAreas, sourceReviewEventId: event.id, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:interview-review:tailor-resume:${event.id}`, + priority: 100, + urgency: "now", + })); + if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) { + actions.push(actionForAgent("interview-to-offer", "roleplay", { + stageId: "roleplay", + serviceId: "roleplay-service", + toolName: "roleplay.configure_practice", + mode: "suggestion", + title: "Run a communication recovery drill", + body: "Practice the exact communication weakness from the mock interview before the real interview.", + payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:interview-review:roleplay:${event.id}`, + priority: 94, + urgency: "today", + })); + } + eventMessage = "Interview review completed; resume and roleplay next actions were created."; } - if (ctx.qscoreSignals.length > 0) { + if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Communication drill reviewed." }); + stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Communication readiness updated." }); + eventMessage = "Roleplay review improved interview communication readiness."; + } + + if (ctx.qscoreSignals.length > 0 || (isQscoreEvent(event.source, type) && type.includes("updated"))) { stagePatches.push({ stageId: "qscore", - status: "in_progress", - progressPercent: Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)), - outputSummary: `${ctx.qscoreSignals.length} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`, + status: type.startsWith("qscore.") ? "done" : "in_progress", + progressPercent: type.startsWith("qscore.") ? 100 : Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)), + outputSummary: `${ctx.qscoreSignals.length || 1} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`, }); } - if (type.startsWith("qscore.") && (type.includes("snapshot") || type.includes("computed") || type.includes("updated"))) { - stagePatches.push({ stageId: "qscore", status: "done", progressPercent: 100, outputSummary: "Readiness Q Score updated." }); - eventMessage = "Readiness Q Score updated."; - } - - return { stagePatches, artifacts, eventMessage }; + return { stagePatches, artifacts, actions, eventMessage }; }, }; diff --git a/src/missions/personal-brand-opportunity-engine/reducer.ts b/src/missions/personal-brand-opportunity-engine/reducer.ts new file mode 100644 index 0000000..36be3bb --- /dev/null +++ b/src/missions/personal-brand-opportunity-engine/reducer.ts @@ -0,0 +1,83 @@ +import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; +import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js"; + +export const personalBrandOpportunityReducer: MissionReducer = { + missionId: "personal-brand-opportunity-engine", + accepts(ctx) { + return ctx.activeMission.missionId === "personal-brand-opportunity-engine" && + (missionExplicitlyMatches(ctx.event.mission, "personal-brand-opportunity-engine") || isRelevantServiceEvent(ctx.event.source, ctx.event.type)); + }, + reduce(ctx): MissionReduction { + const { event, activeMission } = ctx; + const type = event.type; + const payload = event.payload ?? {}; + const stagePatches: MissionStagePatch[] = []; + const artifacts: MissionReduction["artifacts"] = []; + const actions: MissionReduction["actions"] = []; + let eventMessage = ctx.insight.summary; + + if (type === "mission.started" || type.startsWith("mission.")) { + actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", { + stageId: "positioning", + mode: "user_input_required", + title: "Pick the audience you want to be known by", + body: "Personal brand only works when the target audience and credibility theme are explicit.", + prompt: "Who should notice you, and what do you want to be known for?", + payload: { fields: ["target_audience", "positioning_theme", "proof_points"] }, + idempotencyKey: `${activeMission.instanceId}:brand-positioning`, + priority: 96, + urgency: "today", + })); + } + + if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) { + const signals = extractResumeSignals(payload); + stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Proof points extracted for positioning." }); + stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice networking pitch next." }); + artifacts.push({ type: "positioning_statement", title: "Positioning statement draft", stageId: "resume", summary: signals[0] ?? "Strongest proof points were extracted from resume evidence.", metadata: { sourceEventId: event.id, signals } }); + actions.push(actionForAgent("personal-brand-opportunity-engine", "resume", { + stageId: "resume", + serviceId: "resume-service", + toolName: "resume.create_version_prompt_draft", + mode: "approval_required", + title: "Draft your profile positioning statement?", + body: "Approve a draft that turns your strongest resume proof into a clear LinkedIn/profile positioning statement.", + payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:brand-positioning-draft:${event.id}`, + priority: 92, + urgency: "today", + })); + eventMessage = "Resume proof points created a profile positioning action."; + } + + if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + const weakAreas = extractWeakAreas(payload); + stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Networking pitch reviewed." }); + stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Brand voice/readiness signals updated." }); + artifacts.push({ type: "networking_scripts", title: "Networking script improvements", stageId: "roleplay", summary: weakAreas.length ? `Improve: ${weakAreas.join(", ")}` : "Networking pitch practice completed.", metadata: { sourceEventId: event.id, weakAreas } }); + actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", { + stageId: "positioning", + mode: "suggestion", + title: "Turn this pitch into weekly content pillars", + body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.", + payload: { weakAreas, href: `/missions/active?missionInstanceId=${encodeURIComponent(activeMission.instanceId)}` }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`, + priority: 82, + urgency: "soon", + })); + eventMessage = "Networking pitch review created brand content next steps."; + } + + if (isInterviewEvent(event.source, type) && type.includes("review_completed")) { + const weakAreas = extractWeakAreas(payload); + stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Credibility signals mined from interview review." }); + artifacts.push({ type: "credibility_signal_map", title: "Credibility signal map", stageId: "interview", summary: weakAreas.length ? `Recurring gaps/themes: ${weakAreas.join(", ")}` : "Interview review mined for positioning signals.", metadata: { sourceEventId: event.id, weakAreas } }); + eventMessage = "Interview feedback was mined for brand positioning signals."; + } + + if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Brand growth/readiness signals updated." }); + return { stagePatches, artifacts, actions, eventMessage }; + }, +}; diff --git a/src/missions/promotion-readiness/reducer.ts b/src/missions/promotion-readiness/reducer.ts new file mode 100644 index 0000000..405fdad --- /dev/null +++ b/src/missions/promotion-readiness/reducer.ts @@ -0,0 +1,86 @@ +import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; +import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js"; + +export const promotionReadinessReducer: MissionReducer = { + missionId: "promotion-readiness", + accepts(ctx) { + return ctx.activeMission.missionId === "promotion-readiness" && + (missionExplicitlyMatches(ctx.event.mission, "promotion-readiness") || isRelevantServiceEvent(ctx.event.source, ctx.event.type)); + }, + reduce(ctx): MissionReduction { + const { event, activeMission } = ctx; + const type = event.type; + const payload = event.payload ?? {}; + const stagePatches: MissionStagePatch[] = []; + const artifacts: MissionReduction["artifacts"] = []; + const actions: MissionReduction["actions"] = []; + let eventMessage = ctx.insight.summary; + + if (type === "mission.started" || type.startsWith("mission.")) { + actions.push(actionForAgent("promotion-readiness", "planner", { + stageId: "promotion-context", + mode: "user_input_required", + title: "Clarify your promotion target", + body: "Promotion readiness needs the desired level, timeline, manager context, and stakeholder map.", + prompt: "What role/level are you targeting, by when, and what does your manager care about most?", + payload: { fields: ["current_role", "desired_role", "timeline", "manager_context", "stakeholders"] }, + idempotencyKey: `${activeMission.instanceId}:promotion-context`, + priority: 100, + urgency: "today", + })); + } + + if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) { + const signals = extractResumeSignals(payload); + stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Promotion evidence extracted from achievement history." }); + stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Manager conversation practice is ready." }); + artifacts.push({ type: "promotion_evidence_packet", title: "Promotion evidence packet", stageId: "resume", summary: signals[0] ?? "Achievement evidence extracted for promotion case.", metadata: { sourceEventId: event.id, signals } }); + actions.push(actionForAgent("promotion-readiness", "roleplay", { + stageId: "roleplay", + serviceId: "roleplay-service", + toolName: "roleplay.configure_practice", + mode: "suggestion", + title: "Practice the manager promotion conversation", + body: "Use your achievement evidence in a realistic manager conversation drill.", + payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:promotion-manager-roleplay:${event.id}`, + priority: 94, + urgency: "today", + })); + eventMessage = "Promotion evidence packet is ready; manager conversation practice is next."; + } + + if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + const weakAreas = extractWeakAreas(payload); + stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Manager conversation drill reviewed." }); + stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Practice leadership narratives next if gaps remain." }); + artifacts.push({ type: "manager_conversation_script", title: "Manager conversation script", stageId: "roleplay", summary: weakAreas.length ? `Follow-up focus: ${weakAreas.join(", ")}` : "Manager conversation review completed.", metadata: { sourceEventId: event.id, weakAreas } }); + actions.push(actionForAgent("promotion-readiness", "interview", { + stageId: "interview", + serviceId: "interview-service", + toolName: "interview.configure_practice", + mode: "suggestion", + title: "Practice leadership stories", + body: "Run a leadership-style mock interview to tighten the promotion case.", + payload: { weakAreas, href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:promotion-leadership-interview:${event.id}`, + priority: 86, + urgency: "soon", + })); + eventMessage = "Manager conversation review updated promotion readiness."; + } + + if (isInterviewEvent(event.source, type) && type.includes("review_completed")) { + const weakAreas = extractWeakAreas(payload); + stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Leadership communication gap check completed." }); + stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Leadership readiness signals updated." }); + artifacts.push({ type: "leadership_gap_map", title: "Leadership gap map", stageId: "interview", summary: weakAreas.length ? weakAreas.join(", ") : "Leadership practice review completed.", metadata: { sourceEventId: event.id, weakAreas } }); + eventMessage = "Leadership practice review updated the promotion gap map."; + } + + if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Promotion readiness signals updated." }); + return { stagePatches, artifacts, actions, eventMessage }; + }, +}; diff --git a/src/missions/reducer-helpers.ts b/src/missions/reducer-helpers.ts new file mode 100644 index 0000000..ae7f391 --- /dev/null +++ b/src/missions/reducer-helpers.ts @@ -0,0 +1,143 @@ +import { asRecord, getNumber, getString } from "../events/envelope.js"; +import type { MissionActionPatch } from "./reducer-types.js"; + +export function isResumeEvent(source: string, type: string) { + const value = source.toLowerCase(); + return value.includes("resume") || type.startsWith("resume."); +} + +export function isInterviewEvent(source: string, type: string) { + const value = source.toLowerCase(); + return value.includes("interview") || type.startsWith("interview."); +} + +export function isRoleplayEvent(source: string, type: string) { + const value = source.toLowerCase(); + return value.includes("roleplay") || type.startsWith("roleplay."); +} + +export function isQscoreEvent(source: string, type: string) { + const value = source.toLowerCase(); + return value.includes("qscore") || type.startsWith("qscore."); +} + +export function reviewRecord(payload: Record) { + return asRecord(payload.review ?? payload.result ?? payload.data ?? payload); +} + +export function extractOverallScore(payload: Record) { + const review = reviewRecord(payload); + return getNumber(review.overall_score ?? review.overallScore ?? review.score ?? payload.overall_score); +} + +export function extractWeakAreas(payload: Record): string[] { + const review = reviewRecord(payload); + const candidates = [ + review.weak_areas, + review.weakAreas, + review.improvement_areas, + review.improvementAreas, + review.recommendations, + review.gaps, + ]; + const areas: string[] = []; + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + for (const item of candidate) { + if (typeof item === "string" && item.trim()) areas.push(item.trim()); + else if (item && typeof item === "object" && !Array.isArray(item)) { + const row = item as Record; + const text = getString(row.title ?? row.area ?? row.name ?? row.label ?? row.summary); + if (text) areas.push(text); + } + } + } else if (typeof candidate === "string" && candidate.trim()) { + areas.push(...candidate.split(/[;,]/).map((part) => part.trim()).filter(Boolean)); + } + } + const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback); + if (!areas.length && summary) { + const lower = summary.toLowerCase(); + if (lower.includes("communication") || lower.includes("clarity") || lower.includes("story")) areas.push("communication clarity"); + if (lower.includes("technical") || lower.includes("role")) areas.push("role-fit depth"); + if (lower.includes("confidence") || lower.includes("concise")) areas.push("confidence and concision"); + } + return Array.from(new Set(areas)).slice(0, 5); +} + +export function extractResumeSignals(payload: Record): string[] { + const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload); + const signals: string[] = []; + const summary = getString(analysis.summary ?? analysis.overall_feedback ?? payload.summary); + if (summary) signals.push(summary); + for (const key of ["strengths", "gaps", "recommendations", "missing_keywords", "keyword_gaps"]) { + const value = analysis[key]; + if (Array.isArray(value)) { + for (const item of value.slice(0, 4)) if (typeof item === "string") signals.push(item); + } + } + return signals.slice(0, 8); +} + +export function missionExplicitlyMatches(eventMission: unknown, missionId: string) { + const mission = asRecord(eventMission); + const explicit = getString(mission.missionId ?? mission.mission_id); + return explicit === missionId; +} + +export function isRelevantServiceEvent(source: string, type: string) { + return isResumeEvent(source, type) || isInterviewEvent(source, type) || isRoleplayEvent(source, type) || isQscoreEvent(source, type); +} + +const AGENT_NAMES: Record> = { + "interview-to-offer": { + planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Offer Strategist" }, + resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Resume Fit Agent" }, + interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Mock Interviewer" }, + roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Communication Coach" }, + qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Readiness Analyst" }, + }, + "career-transition": { + planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Transition Strategist" }, + resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Transferable Skills Agent" }, + interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Adjacent Role Interviewer" }, + roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Transition Pitch Coach" }, + qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Transition Readiness Analyst" }, + }, + "salary-negotiation-war-room": { + planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Negotiation Strategist" }, + resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Value Evidence Agent" }, + interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Confidence Coach" }, + roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Negotiation Drill Coach" }, + qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Confidence Analyst" }, + }, + "promotion-readiness": { + planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Promotion Strategist" }, + resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Achievement Evidence Agent" }, + interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Leadership Interview Coach" }, + roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Manager Conversation Coach" }, + qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Leadership Readiness Analyst" }, + }, + "personal-brand-opportunity-engine": { + planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Brand Strategist" }, + resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Proof Point Agent" }, + interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Credibility Coach" }, + roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Networking Pitch Coach" }, + qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Visibility Analyst" }, + }, +}; + +export function actionForAgent(missionId: string, agent: "planner" | "resume" | "interview" | "roleplay" | "qscore", patch: Omit): MissionActionPatch { + const fallback = AGENT_NAMES["interview-to-offer"]?.[agent] ?? { agentId: agent, baseAgent: agent, agentName: agent }; + const spec = AGENT_NAMES[missionId]?.[agent] ?? fallback; + return { ...spec, ...patch }; +} + +export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) { + const params = new URLSearchParams({ source: "mission", missionInstanceId, missionId }); + if (stageId) params.set("stageId", stageId); + if (service === "interview") return `/agents/interview/setup?${params.toString()}`; + if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`; + if (service === "resume") return `/agents/resume?${params.toString()}`; + return `/agents/qscore?${params.toString()}`; +} diff --git a/src/missions/reducer-types.ts b/src/missions/reducer-types.ts index 2f74ac5..b1f00bf 100644 --- a/src/missions/reducer-types.ts +++ b/src/missions/reducer-types.ts @@ -2,6 +2,7 @@ import type { GrowEventRow } from "../db/schema.js"; import type { ProjectionInsight } from "../events/projectors/projection-agent.js"; import type { QscoreSignal } from "../events/envelope.js"; import type { GrowActiveMission, MissionStageStatus } from "../actors/missions/types.js"; +import type { MissionActionMode, MissionActionStatus, MissionActionUrgency } from "./action-types.js"; export type MissionReducerContext = { userId: string; @@ -27,9 +28,30 @@ export type MissionStagePatch = { outputSummary?: string; }; +export type MissionActionPatch = { + stageId?: string; + agentId: string; + agentName: string; + baseAgent?: string; + serviceId?: string; + toolName?: string; + mode: MissionActionMode; + status?: MissionActionStatus; + title: string; + body: string; + prompt?: string; + payload?: Record; + sourceEventId?: string; + idempotencyKey?: string; + priority?: number; + urgency?: MissionActionUrgency; + dueAt?: string; +}; + export type MissionReduction = { stagePatches: MissionStagePatch[]; artifacts: MissionArtifactPatch[]; + actions: MissionActionPatch[]; eventMessage?: string; }; diff --git a/src/missions/salary-negotiation-war-room/reducer.ts b/src/missions/salary-negotiation-war-room/reducer.ts new file mode 100644 index 0000000..6aec1f3 --- /dev/null +++ b/src/missions/salary-negotiation-war-room/reducer.ts @@ -0,0 +1,88 @@ +import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; +import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js"; + +export const salaryNegotiationReducer: MissionReducer = { + missionId: "salary-negotiation-war-room", + accepts(ctx) { + return ctx.activeMission.missionId === "salary-negotiation-war-room" && + (missionExplicitlyMatches(ctx.event.mission, "salary-negotiation-war-room") || isRelevantServiceEvent(ctx.event.source, ctx.event.type)); + }, + reduce(ctx): MissionReduction { + const { event, activeMission } = ctx; + const type = event.type; + const payload = event.payload ?? {}; + const stagePatches: MissionStagePatch[] = []; + const artifacts: MissionReduction["artifacts"] = []; + const actions: MissionReduction["actions"] = []; + let eventMessage = ctx.insight.summary; + + if (type === "mission.started" || type.startsWith("mission.")) { + actions.push(actionForAgent("salary-negotiation-war-room", "planner", { + stageId: "offer-context", + mode: "user_input_required", + title: "Add your offer and negotiation constraints", + body: "The war room needs your current offer, target range, deadline, and leverage before scripts or drills are accurate.", + prompt: "What is the current offer/raise, your target, deadline, and any competing leverage?", + payload: { fields: ["current_offer", "target_range", "deadline", "competing_offers", "constraints"] }, + idempotencyKey: `${activeMission.instanceId}:offer-context`, + priority: 100, + urgency: "now", + })); + } + + if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) { + const signals = extractResumeSignals(payload); + stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Value evidence extracted from resume proof." }); + stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Use value evidence in negotiation roleplay." }); + artifacts.push({ type: "value_evidence_map", title: "Value evidence map", stageId: "resume", summary: signals[0] ?? "Impact proof extracted for negotiation leverage.", metadata: { sourceEventId: event.id, signals } }); + actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", { + stageId: "roleplay", + serviceId: "roleplay-service", + toolName: "roleplay.configure_practice", + mode: "suggestion", + title: "Run the first counteroffer drill", + body: "Practice anchoring your number and defending value with the evidence now extracted.", + payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:value-evidence-roleplay:${event.id}`, + priority: 96, + urgency: "today", + })); + eventMessage = "Value evidence is ready for negotiation practice."; + } + + if (isRoleplayEvent(event.source, type) && type.includes("configured")) { + stagePatches.push({ stageId: "roleplay", status: "in_progress", progressPercent: 45, outputSummary: "Negotiation drill configured." }); + eventMessage = "Negotiation drill started."; + } + + if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + const weakAreas = extractWeakAreas(payload); + stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Negotiation drill reviewed." }); + stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Confidence signals updated." }); + artifacts.push({ type: "negotiation_objection_map", title: "Objection handling map", stageId: "roleplay", summary: weakAreas.length ? `Practice objections: ${weakAreas.join(", ")}` : "Negotiation practice review completed.", metadata: { sourceEventId: event.id, weakAreas } }); + actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", { + stageId: "roleplay", + serviceId: "roleplay-service", + toolName: "roleplay.configure_practice", + mode: "approval_required", + title: "Run one more objection-handling drill?", + body: weakAreas.length ? `Recommended focus: ${weakAreas.slice(0, 3).join(", ")}. Approve another drill before the live negotiation.` : "Approve one more objection-handling drill before the live negotiation.", + payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:negotiation-followup:${event.id}`, + priority: 94, + urgency: "today", + })); + eventMessage = "Negotiation drill review created the next objection-handling action."; + } + + if (isInterviewEvent(event.source, type) && type.includes("review_completed")) { + stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Communication confidence signal captured from interview review." }); + eventMessage = "Interview feedback updated negotiation confidence signals."; + } + + if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Negotiation confidence signals updated." }); + return { stagePatches, artifacts, actions, eventMessage }; + }, +}; diff --git a/src/routes/conversations.ts b/src/routes/conversations.ts index a722316..703a95e 100644 --- a/src/routes/conversations.ts +++ b/src/routes/conversations.ts @@ -10,6 +10,7 @@ import { getMissionDefinition, isActorBackedMission, listMissionDefinitions } fr import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js"; import { getSubAgentModules } from "../lib/prompt-loader.js"; import { addMessagePg, createConversationPg, ensureConversation, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js"; +import { getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js"; let _client: Client | null = null; function getClient(): Client { @@ -443,6 +444,49 @@ function buildConversationTools(userId: string) { }, }), + listMissionActions: tool({ + description: "List open mission queue actions/approvals/questions for the user. Use for: what should I do today, what are my agents doing, why is my mission blocked, show approvals.", + inputSchema: z.object({ missionInstanceId: z.string().optional() }), + execute: async ({ missionInstanceId }) => ({ + kind: "mission-actions", + actions: await listMissionActions(userId, { missionInstanceId }), + }), + }), + + approveMissionAction: tool({ + description: "Approve a mission action by id. Only use when the user explicitly asks to approve a specific action.", + inputSchema: z.object({ actionId: z.string() }), + execute: async ({ actionId }) => { + const action = await getMissionAction(userId, actionId); + if (!action) return { kind: "mission-action-approved", actionId, error: "Action not found" }; + return { kind: "mission-action-approved", action: await updateMissionActionStatus(userId, actionId, { status: "queued", result: { approvedAt: new Date().toISOString() } }) }; + }, + }), + + rejectMissionAction: tool({ + description: "Reject/dismiss a mission action by id. Only use when the user explicitly asks to reject or dismiss a specific action.", + inputSchema: z.object({ actionId: z.string() }), + execute: async ({ actionId }) => ({ + kind: "mission-action-rejected", + action: await updateMissionActionStatus(userId, actionId, { status: "dismissed", result: { rejectedAt: new Date().toISOString() } }), + }), + }), + + explainMissionProgress: tool({ + description: "Explain active mission progress using snapshots and open actions.", + inputSchema: z.object({ missionInstanceId: z.string().optional() }), + execute: async ({ missionInstanceId }) => { + const persisted = await listActiveMissionsPg(userId); + const selected = missionInstanceId ? persisted.filter((item) => item.mission.instanceId === missionInstanceId) : persisted; + return { + kind: "mission-progress", + missions: selected.map((item) => item.mission), + snapshots: selected.map((item) => item.snapshot).filter(Boolean), + actions: await listMissionActions(userId, { missionInstanceId }), + }; + }, + }), + listMemory: tool({ description: "List memory files for this user by path prefix.", inputSchema: z.object({ prefix: z.string().optional() }), diff --git a/src/routes/missions.ts b/src/routes/missions.ts index fbea73e..72ed43c 100644 --- a/src/routes/missions.ts +++ b/src/routes/missions.ts @@ -9,6 +9,9 @@ import { isActorBackedMission } from "../missions/registry.js"; import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-registry.js"; import { completeMissionCoachRunPg, createMissionCoachRunPg, getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg, replaceMissionSuggestionsPg, upsertActiveMissionPg } from "../grow/persistence.js"; import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js"; +import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js"; +import { recordGrowEvent } from "../events/record-grow-event.js"; +import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js"; let _client: Client | null = null; function getClient(): Client { @@ -55,6 +58,15 @@ const addArtifactSchema = z.object({ metadata: z.record(z.unknown()).optional(), }); +const answerActionSchema = z.object({ + input: z.record(z.unknown()).optional(), + answer: z.string().optional(), +}); + +const snoozeActionSchema = z.object({ + until: z.string().datetime().optional(), +}); + const createInstanceId = (missionId: string) => `${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -126,10 +138,15 @@ export function missionRoutes() { await Promise.all(persisted.map(async (item) => { suggestionsByMission[item.mission.instanceId] = await listMissionSuggestionsPg(userId, item.mission.instanceId); })); + const actionsByMission: Record = {}; + await Promise.all(persisted.map(async (item) => { + actionsByMission[item.mission.instanceId] = await listMissionActions(userId, { missionInstanceId: item.mission.instanceId }); + })); return c.json({ missions: persisted.map((item) => item.mission), snapshots: persisted.map((item) => item.snapshot).filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)), suggestionsByMission, + actionsByMission, }); }); @@ -138,7 +155,24 @@ export function missionRoutes() { const active = await getActiveMissionPg(userId, c.req.param("instanceId")); if (!active) return c.json({ error: "mission_not_found" }, 404); const suggestions = await listMissionSuggestionsPg(userId, active.mission.instanceId); - return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions }); + const actions = await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }); + return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions, actions }); + }); + + app.get("/active/:instanceId/actions", async (c) => { + const userId = c.get("userId"); + const active = await getActiveMissionPg(userId, c.req.param("instanceId")); + if (!active) return c.json({ error: "mission_not_found" }, 404); + return c.json({ actions: await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }) }); + }); + + app.post("/active/:instanceId/scrum/run", async (c) => { + const userId = c.get("userId"); + const active = await getActiveMissionPg(userId, c.req.param("instanceId")); + if (!active?.mission.actorType) return c.json({ error: "mission_not_found" }, 404); + const result = await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runDailyScrum({ trigger: "manual" }); + if (result.snapshot) await upsertActiveMissionPg(userId, activeMissionFromSnapshot(result.snapshot), result.snapshot); + return c.json({ summary: result.summary, snapshot: result.snapshot }); }); app.post("/active/:instanceId/coach/run", async (c) => { @@ -190,6 +224,77 @@ export function missionRoutes() { return c.json({ coachRunId: run.id, summary, suggestions }); }); + app.post("/actions/:actionId/approve", async (c) => { + const userId = c.get("userId"); + const action = await getMissionAction(userId, c.req.param("actionId")); + if (!action) return c.json({ error: "action_not_found" }, 404); + const updated = await updateMissionActionStatus(userId, action.id, { + status: "queued", + result: { approvedAt: new Date().toISOString() }, + payload: { ...(action.payload ?? {}), approved: true }, + }); + const active = await getActiveMissionPg(userId, action.missionInstanceId); + if (active?.mission.actorType) { + await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: action.id, resolution: "approved" }).catch(() => undefined); + } + return c.json({ action: updated }); + }); + + app.post("/actions/:actionId/reject", async (c) => { + const userId = c.get("userId"); + const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "dismissed", result: { rejectedAt: new Date().toISOString() } }); + if (!action) return c.json({ error: "action_not_found" }, 404); + return c.json({ action }); + }); + + app.post("/actions/:actionId/run", async (c) => { + const userId = c.get("userId"); + const existing = await getMissionAction(userId, c.req.param("actionId")); + if (!existing) return c.json({ error: "action_not_found" }, 404); + const active = await getActiveMissionPg(userId, existing.missionInstanceId); + if (active?.mission.actorType) { + await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined); + } + const href = typeof existing.payload?.href === "string" ? existing.payload.href : `/missions/active?missionInstanceId=${encodeURIComponent(existing.missionInstanceId)}`; + const action = await updateMissionActionStatus(userId, existing.id, { + status: "done", + result: { + ranAt: new Date().toISOString(), + message: existing.toolName?.startsWith("resume.") + ? "Resume Agent prepared the draft brief. Open Resume Builder to apply it." + : "Action marked complete. Continue from the linked GrowQR surface.", + href, + }, + }); + return c.json({ action }); + }); + + app.post("/actions/:actionId/answer", async (c) => { + const userId = c.get("userId"); + const existing = await getMissionAction(userId, c.req.param("actionId")); + if (!existing) return c.json({ error: "action_not_found" }, 404); + const body = answerActionSchema.parse(await c.req.json().catch(() => ({}))); + const input = body.input ?? (body.answer ? { answer: body.answer } : {}); + const action = await updateMissionActionStatus(userId, existing.id, { + status: "done", + result: { answeredAt: new Date().toISOString(), input }, + payload: { ...(existing.payload ?? {}), userInput: input }, + }); + const active = await getActiveMissionPg(userId, existing.missionInstanceId); + if (active?.mission.actorType) { + await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: existing.id, resolution: "answered", input }).catch(() => undefined); + } + return c.json({ action }); + }); + + app.post("/actions/:actionId/snooze", async (c) => { + const userId = c.get("userId"); + const body = snoozeActionSchema.parse(await c.req.json().catch(() => ({}))); + const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "snoozed", result: { snoozedAt: new Date().toISOString(), until: body.until } }); + if (!action) return c.json({ error: "action_not_found" }, 404); + return c.json({ action }); + }); + app.post("/:missionId/start", async (c) => { const userId = c.get("userId"); const missionId = c.req.param("missionId"); @@ -215,6 +320,16 @@ export function missionRoutes() { await upsertActiveMissionPg(userId, activeMission, snapshot); const grow = growFor(userId); grow.setup({ userId }).then(() => grow.registerActiveMission(activeMission)).catch((err) => console.warn("growActor mission mirror failed", err)); + recordGrowEvent({ + source: "growqr-backend:missions", + type: "mission.started", + category: "mission", + userId, + occurredAt: new Date().toISOString(), + mission: { instanceId, missionId, stageId: snapshot.currentStageId }, + payload: { goal: body.goal, input: body.input ?? {}, title: snapshot.title }, + dedupeKey: `mission-started:${instanceId}`, + }).then((event) => routeGrowEventToUserActor(event)).catch((err) => console.warn("mission start event routing failed", err)); return c.json({ mission: activeMission, snapshot }, 201); }); diff --git a/src/routes/services.ts b/src/routes/services.ts index af302e0..299ef48 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -12,15 +12,9 @@ import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js"; import { log } from "../log.js"; const LANDING_AGENTS = [ - { id: "resume", title: "The Resume Expert", agent: "AI Resume Expert", description: "Writes resumes and cover letters that get past ATS and into human hands.", route: "/agents/resume" }, - { id: "interview", title: "The Interviewer", agent: "AI Interviewer", description: "Puts you through real interviews before the real one. Brutal feedback included.", route: "/agents/interview" }, - { id: "roleplay", title: "The Roleplay Coach", agent: "AI Roleplay Coach", description: "Practises the hard conversations with you — so you're never caught off guard.", route: "/agents/roleplay" }, - { id: "assessments", title: "The Skill Trainer", agent: "AI Skill Trainer", description: "Finds exactly what's holding you back — then builds a plan to close it.", route: "/agents/assessments" }, - { id: "social-branding", title: "The Brand Voice", agent: "AI Brand Voice", description: "Makes you impossible to ignore online — posts, profiles, and positioning sorted.", route: "/agents/social-branding" }, - { id: "pathways-report", title: "The Pathway Guide", agent: "AI Pathway Guide", description: "Maps career options, pivots, and what-fits-next at every stage of your journey.", route: "/agents/pathways-report" }, - { id: "courses", title: "The Learning Curator", agent: "AI Learning Curator", description: "Picks only what's worth your time — courses and paths that move you forward.", route: "/agents/courses" }, - { id: "jobs", title: "The Opportunity Finder", agent: "AI Job Finder", description: "Daily, agent-sourced shortlist of roles matched to your QX and goals.", route: "/agents/jobs" }, - { id: "presence", title: "The Presence Coach", agent: "AI Presence Coach", description: "Tells you what to wear, how to speak, and how to carry yourself in every room.", route: "/agents/presence" }, + { id: "resume", title: "Resume", agent: "Resume Strategist", description: "Resume proof, versions, parsing, analysis, and mission artifacts.", route: "/agents/resume" }, + { id: "interview", title: "Interview", agent: "Interview Coach", description: "Mock interviews, reviews, weakness diagnosis, and readiness signals.", route: "/agents/interview" }, + { id: "roleplay", title: "Roleplay", agent: "Roleplay Coach", description: "Negotiation, promotion, transition, and communication drills.", route: "/agents/roleplay" }, ] as const; const DEFAULT_QSCORE = { @@ -85,6 +79,26 @@ function eventTypeForReview(prefix: "interview" | "roleplay", result: Record log.warn({ err, path: normalizedRest }, "failed to record resume gateway event")); + + return new Response(responseBuffer, { status: res.status, statusText: res.statusText, headers: res.headers, diff --git a/src/workflows/registry.ts b/src/workflows/registry.ts index fbdb979..80bef4f 100644 --- a/src/workflows/registry.ts +++ b/src/workflows/registry.ts @@ -2,7 +2,7 @@ import type { WorkflowDefinition } from "./types.js"; import { displayLabelForExecution, displayLabelForService } from "../features/registry.js"; const serviceLabel = (serviceId: string) => displayLabelForService(serviceId) ?? serviceId; -const planningLabel = displayLabelForExecution("opencode") ?? "Mission Planning"; +const planningLabel = displayLabelForExecution("manual") ?? "Mission Planner"; const commonInputs = [ { id: "goal", label: "Target outcome", type: "text", required: true }, @@ -11,53 +11,151 @@ const commonInputs = [ export const workflowDefinitions: WorkflowDefinition[] = [ { - id: "interview-to-offer", version: "1.0.0", title: "Interview to Offer", shortTitle: "Interview to Offer", - promise: "Turn a scheduled interview into a focused prep plan, practice sessions, and a readiness report.", segment: ["job-seekers", "interviewing"], urgency: "high", estimatedDuration: "2-5 days", priceTier: "starter", sku: "workflow_interview_to_offer", isPurchasable: true, isFreePreview: true, - visual: { icon: "briefcase-business", color: "emerald", mascotAgentIds: ["interview", "roleplay", "qscore"] }, requiredInputs: commonInputs, + id: "interview-to-offer", + version: "2.0.0", + title: "Interview-to-Offer Accelerator", + shortTitle: "Interview to Offer", + promise: "Prepare me for this specific interview and help me convert it into an offer.", + segment: ["job-seekers", "interviewing", "urgent"], + urgency: "high", + estimatedDuration: "2-5 days", + priceTier: "starter", + sku: "workflow_interview_to_offer", + isPurchasable: true, + isFreePreview: true, + visual: { icon: "briefcase-business", color: "emerald", mascotAgentIds: ["interview", "roleplay", "resume", "qscore"] }, + requiredInputs: commonInputs, modules: [ - { id: "resume", title: "Resume fit scan", role: serviceLabel("resume-service"), description: "Analyze resume readiness for the target role.", execution: "service", service: "resume-service" }, - { id: "interview-plan", title: "Interview prep plan", role: planningLabel, description: "Generate a prep plan and likely questions artifact.", execution: "opencode", promptPath: "prompts/workflows/interview-to-offer/interview-plan.md", artifactTypes: ["interview_plan"], approvalGateAfter: "review-plan" }, - { id: "interview", title: "Mock interview", role: serviceLabel("interview-service"), description: "Create a real interview practice session.", execution: "service", service: "interview-service" }, - { id: "roleplay", title: "Communication roleplay", role: serviceLabel("roleplay-service"), description: "Create a realistic roleplay session.", execution: "service", service: "roleplay-service" }, - { id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Optimize LinkedIn and professional presence.", execution: "service", service: "social-branding-service" }, - { id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors and opportunities.", execution: "service", service: "matchmaking-service" }, - { id: "qscore", title: "Readiness Q Score", role: serviceLabel("qscore-service"), description: "Compute readiness score.", execution: "service", service: "qscore-service" }, + { id: "plan", title: "Interview prep plan", role: planningLabel, description: "Clarify target role, company context, likely questions, and prep priorities.", execution: "manual" }, + { id: "resume", title: "Resume-based talking points", role: serviceLabel("resume-service"), description: "Extract role-fit proof, gaps, and tailored talking points from the resume.", execution: "service", service: "resume-service" }, + { id: "interview", title: "Mock interview sessions", role: serviceLabel("interview-service"), description: "Run interview practice and collect weakness diagnosis.", execution: "service", service: "interview-service" }, + { id: "roleplay", title: "Behavioral/story bank practice", role: serviceLabel("roleplay-service"), description: "Practice concise stories, objections, and communication recovery.", execution: "service", service: "roleplay-service" }, + { id: "qscore", title: "Final readiness score", role: serviceLabel("qscore-service"), description: "Continuously score readiness from resume, interview, and roleplay signals.", execution: "service", service: "qscore-service" }, ], - outputs: [{ id: "interview_plan", type: "markdown", title: "Interview prep plan", path: "artifacts/interview-to-offer/interview-plan.md" }], qscoreDimensions: ["clarity", "communication", "role_fit"], approvalGates: [{ id: "review-plan", title: "Review prep plan", description: "User reviews generated plan before practice.", required: false }], + outputs: [ + { id: "interview_prep_plan", type: "markdown", title: "Interview prep plan" }, + { id: "story_bank", type: "markdown", title: "Behavioral/story bank" }, + { id: "readiness_report", type: "scorecard", title: "Final readiness score" }, + ], + qscoreDimensions: ["role_fit", "communication", "confidence"], + approvalGates: [], }, { - id: "career-transition", version: "1.0.0", title: "Switch Careers", shortTitle: "Switch Careers", promise: "Map transferable abilities and produce a transition narrative.", segment: ["career-changers"], urgency: "medium", estimatedDuration: "1-2 weeks", priceTier: "starter", sku: "workflow_career_transition", isPurchasable: true, isFreePreview: true, visual: { icon: "route", color: "blue", mascotAgentIds: ["resume", "qscore"] }, requiredInputs: commonInputs, + id: "career-transition", + version: "2.0.0", + title: "Career Transition Accelerator", + shortTitle: "Career Transition", + promise: "Help me reposition from my current career into a better-fit role.", + segment: ["career-changers"], + urgency: "medium", + estimatedDuration: "1-2 weeks", + priceTier: "starter", + sku: "workflow_career_transition", + isPurchasable: true, + isFreePreview: true, + visual: { icon: "route", color: "blue", mascotAgentIds: ["resume", "interview", "roleplay", "qscore"] }, + requiredInputs: commonInputs, modules: [ - { id: "transition-map", title: "Transition map", role: planningLabel, description: "Generate skills map and positioning narrative.", execution: "opencode", promptPath: "prompts/workflows/career-transition/orchestrator.md", artifactTypes: ["transition_map"] }, - { id: "resume", title: "Resume fit scan", role: serviceLabel("resume-service"), description: "Analyze resume for target path.", execution: "service", service: "resume-service" }, - { id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Build transition narrative on LinkedIn.", execution: "service", service: "social-branding-service" }, - { id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors in the target field.", execution: "service", service: "matchmaking-service" }, - ], outputs: [{ id: "transition_map", type: "markdown", title: "Transition map" }], qscoreDimensions: ["positioning", "skills", "confidence"], approvalGates: [], + { id: "clarify-target", title: "Target role recommendation", role: planningLabel, description: "Clarify current role, target role, constraints, and transition thesis.", execution: "manual" }, + { id: "resume", title: "Transferable skills map", role: serviceLabel("resume-service"), description: "Map transferable skills and reposition resume proof for the target lane.", execution: "service", service: "resume-service" }, + { id: "interview", title: "Adjacent-role interview narrative", role: serviceLabel("interview-service"), description: "Validate credibility for the new role through mock interview practice.", execution: "service", service: "interview-service" }, + { id: "roleplay", title: "Why I am switching practice", role: serviceLabel("roleplay-service"), description: "Practice the transition pitch and hard follow-up questions.", execution: "service", service: "roleplay-service" }, + { id: "qscore", title: "Transition readiness delta", role: serviceLabel("qscore-service"), description: "Track readiness gains and the next missing proof.", execution: "service", service: "qscore-service" }, + ], + outputs: [ + { id: "target_role_recommendation", type: "markdown", title: "Target role recommendation" }, + { id: "transition_plan", type: "markdown", title: "30/60/90 transition plan" }, + ], + qscoreDimensions: ["positioning", "transferable_skills", "confidence"], + approvalGates: [], }, { - id: "salary-negotiation-war-room", version: "1.0.0", title: "Negotiate Salary", shortTitle: "Negotiate Salary", promise: "Prepare scripts, ranges, and roleplay for an offer conversation.", segment: ["offer-stage"], urgency: "high", estimatedDuration: "24-72 hours", priceTier: "premium", sku: "workflow_salary_negotiation", isPurchasable: true, isFreePreview: false, visual: { icon: "badge-dollar-sign", color: "amber", mascotAgentIds: ["roleplay"] }, requiredInputs: commonInputs, + id: "salary-negotiation-war-room", + version: "2.0.0", + title: "Salary / Offer Negotiation War Room", + shortTitle: "Negotiation War Room", + promise: "Help me negotiate my offer, raise, or promotion conversation.", + segment: ["offer-stage", "employed"], + urgency: "high", + estimatedDuration: "24-72 hours", + priceTier: "premium", + sku: "workflow_salary_negotiation", + isPurchasable: true, + isFreePreview: false, + visual: { icon: "badge-dollar-sign", color: "amber", mascotAgentIds: ["roleplay", "resume", "qscore"] }, + requiredInputs: commonInputs, modules: [ - { id: "negotiation-script", title: "Negotiation script", role: planningLabel, description: "Generate negotiation strategy and scripts.", execution: "opencode", promptPath: "prompts/workflows/salary-negotiation-war-room/orchestrator.md", artifactTypes: ["negotiation_script"] }, - { id: "roleplay", title: "Negotiation roleplay", role: serviceLabel("roleplay-service"), description: "Create offer negotiation roleplay.", execution: "service", service: "roleplay-service" }, - { id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors who navigated similar negotiations.", execution: "service", service: "matchmaking-service" }, - ], outputs: [{ id: "negotiation_script", type: "markdown", title: "Negotiation script" }], qscoreDimensions: ["voice", "confidence", "strategy"], approvalGates: [], + { id: "offer-context", title: "Offer analysis", role: planningLabel, description: "Capture offer, target range, leverage, constraints, and deadline.", execution: "manual" }, + { id: "resume", title: "Value evidence map", role: serviceLabel("resume-service"), description: "Extract measurable impact and proof points from resume history.", execution: "service", service: "resume-service" }, + { id: "roleplay", title: "Live-call negotiation practice", role: serviceLabel("roleplay-service"), description: "Practice counteroffer, objection handling, and calm assertiveness.", execution: "service", service: "roleplay-service" }, + { id: "interview", title: "Confidence signal check", role: serviceLabel("interview-service"), description: "Use interview signals when available to assess communication confidence.", execution: "service", service: "interview-service" }, + { id: "qscore", title: "Confidence score", role: serviceLabel("qscore-service"), description: "Track negotiation confidence and communication readiness.", execution: "service", service: "qscore-service" }, + ], + outputs: [ + { id: "counteroffer_script", type: "markdown", title: "Counteroffer script" }, + { id: "objection_map", type: "markdown", title: "Objection handling map" }, + ], + qscoreDimensions: ["confidence", "value_evidence", "communication"], + approvalGates: [], }, { - id: "promotion-readiness", version: "1.0.0", title: "Get Promoted", shortTitle: "Get Promoted", promise: "Build an evidence packet and manager conversation plan.", segment: ["employed"], urgency: "medium", estimatedDuration: "1 week", priceTier: "starter", sku: "workflow_promotion_readiness", isPurchasable: true, isFreePreview: true, visual: { icon: "trending-up", color: "purple", mascotAgentIds: ["roleplay", "qscore"] }, requiredInputs: commonInputs, + id: "promotion-readiness", + version: "2.0.0", + title: "Promotion & Leadership Readiness System", + shortTitle: "Promotion Readiness", + promise: "Help me become promotion-ready and make a strong case for my next level.", + segment: ["employed"], + urgency: "medium", + estimatedDuration: "1 week", + priceTier: "starter", + sku: "workflow_promotion_readiness", + isPurchasable: true, + isFreePreview: true, + visual: { icon: "trending-up", color: "purple", mascotAgentIds: ["roleplay", "resume", "interview", "qscore"] }, + requiredInputs: commonInputs, modules: [ - { id: "evidence-packet", title: "Evidence packet", role: planningLabel, description: "Generate promotion evidence packet.", execution: "opencode", promptPath: "prompts/workflows/promotion-readiness/orchestrator.md", artifactTypes: ["promotion_packet"] }, - { id: "roleplay", title: "Manager conversation roleplay", role: serviceLabel("roleplay-service"), description: "Practice the promotion conversation.", execution: "service", service: "roleplay-service" }, - { id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Showcase promotion-worthy impact on LinkedIn.", execution: "service", service: "social-branding-service" }, - { id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with senior leaders for sponsorship.", execution: "service", service: "matchmaking-service" }, - ], outputs: [{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" }], qscoreDimensions: ["impact", "leadership", "communication"], approvalGates: [], + { id: "promotion-context", title: "Promotion target", role: planningLabel, description: "Clarify desired level, timeline, stakeholders, and manager context.", execution: "manual" }, + { id: "resume", title: "Achievement evidence packet", role: serviceLabel("resume-service"), description: "Extract impact bullets and leadership proof from resume history.", execution: "service", service: "resume-service" }, + { id: "roleplay", title: "Manager conversation script", role: serviceLabel("roleplay-service"), description: "Practice the promotion conversation with objections and follow-ups.", execution: "service", service: "roleplay-service" }, + { id: "interview", title: "Leadership gap practice", role: serviceLabel("interview-service"), description: "Practice leadership narratives and detect communication gaps.", execution: "service", service: "interview-service" }, + { id: "qscore", title: "Leadership readiness score", role: serviceLabel("qscore-service"), description: "Track readiness and confidence trend.", execution: "service", service: "qscore-service" }, + ], + outputs: [ + { id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" }, + { id: "manager_script", type: "markdown", title: "Manager conversation script" }, + ], + qscoreDimensions: ["impact", "leadership", "communication"], + approvalGates: [], }, { - id: "personal-brand-opportunity-engine", version: "1.0.0", title: "Build Your Brand", shortTitle: "Build Your Brand", promise: "Draft profile positioning and a weekly opportunity/content plan.", segment: ["networking", "creators"], urgency: "low", estimatedDuration: "1 week", priceTier: "starter", sku: "workflow_brand_engine", isPurchasable: true, isFreePreview: true, visual: { icon: "sparkles", color: "pink", mascotAgentIds: ["qscore"] }, requiredInputs: commonInputs, + id: "personal-brand-opportunity-engine", + version: "2.0.0", + title: "Personal Brand & Opportunity Engine", + shortTitle: "Brand Engine", + promise: "Make me visible and credible so better opportunities come to me.", + segment: ["networking", "creators", "job-seekers"], + urgency: "low", + estimatedDuration: "1 week", + priceTier: "starter", + sku: "workflow_brand_engine", + isPurchasable: true, + isFreePreview: true, + visual: { icon: "sparkles", color: "pink", mascotAgentIds: ["resume", "roleplay", "qscore"] }, + requiredInputs: commonInputs, modules: [ - { id: "profile-rewrite", title: "Profile rewrite", role: planningLabel, description: "Generate LinkedIn/profile rewrite draft.", execution: "opencode", promptPath: "prompts/workflows/personal-brand-opportunity-engine/orchestrator.md", artifactTypes: ["profile_rewrite", "content_plan"] }, - { id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Optimize profile and content strategy.", execution: "service", service: "social-branding-service" }, - { id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with collaborators and brand amplifiers.", execution: "service", service: "matchmaking-service" }, - ], outputs: [{ id: "profile_rewrite", type: "markdown", title: "Profile rewrite" }, { id: "content_plan", type: "markdown", title: "Weekly content plan" }], qscoreDimensions: ["visibility", "network", "voice"], approvalGates: [], + { id: "positioning", title: "Positioning statement", role: planningLabel, description: "Clarify target audience, positioning, and credibility theme.", execution: "manual" }, + { id: "resume", title: "Proof point extraction", role: serviceLabel("resume-service"), description: "Turn resume proof into brand themes and profile claims.", execution: "service", service: "resume-service" }, + { id: "roleplay", title: "Networking scripts", role: serviceLabel("roleplay-service"), description: "Practice intros, outreach, and opportunity conversations.", execution: "service", service: "roleplay-service" }, + { id: "interview", title: "Credibility signal mining", role: serviceLabel("interview-service"), description: "Use interview strengths and gaps as brand signal when available.", execution: "service", service: "interview-service" }, + { id: "qscore", title: "Brand growth score", role: serviceLabel("qscore-service"), description: "Track visibility/readiness signals from practice and profile proof.", execution: "service", service: "qscore-service" }, + ], + outputs: [ + { id: "profile_rewrite", type: "markdown", title: "LinkedIn/profile rewrite" }, + { id: "content_pillars", type: "markdown", title: "Content pillars" }, + { id: "weekly_post_drafts", type: "markdown", title: "Weekly post drafts" }, + ], + qscoreDimensions: ["visibility", "credibility", "voice"], + approvalGates: [], }, ];