From 5c480ce90fdc1e6329acfc4f2068156ded12fc9a Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:52:48 +0530 Subject: [PATCH] feat: add missions actor, routes, features, workflow registry updates, and DB schema migration --- drizzle/0005_mission_registry.sql | 15 ++ drizzle/meta/_journal.json | 7 + src/actors/grow/grow-actor.ts | 57 ++++ src/actors/grow/types.ts | 3 + .../missions/career-transition-actor.ts | 7 + .../missions/career-transition/SKILL.md | 7 + src/actors/missions/index.ts | 6 + .../missions/interview-to-offer-actor.ts | 242 +++++++++++++++++ .../missions/interview-to-offer/SKILL.md | 41 +++ src/actors/missions/mission-actor-factory.ts | 249 ++++++++++++++++++ ...personal-brand-opportunity-engine-actor.ts | 7 + .../SKILL.md | 6 + .../missions/promotion-readiness-actor.ts | 7 + .../missions/promotion-readiness/SKILL.md | 7 + .../salary-negotiation-war-room-actor.ts | 7 + .../salary-negotiation-war-room/SKILL.md | 7 + src/actors/missions/types.ts | 109 ++++++++ src/actors/registry.ts | 12 + src/db/schema.ts | 22 ++ src/features/registry.ts | 95 +++++++ src/index.ts | 2 + src/missions/postgres-registry.ts | 56 ++++ src/missions/registry.ts | 52 ++++ src/routes/conversations.ts | 166 ++++++++++-- src/routes/missions.ts | 216 +++++++++++++++ src/workflows/registry.ts | 22 +- src/workflows/service-capabilities.ts | 25 +- 27 files changed, 1412 insertions(+), 40 deletions(-) create mode 100644 drizzle/0005_mission_registry.sql create mode 100644 src/actors/missions/career-transition-actor.ts create mode 100644 src/actors/missions/career-transition/SKILL.md create mode 100644 src/actors/missions/index.ts create mode 100644 src/actors/missions/interview-to-offer-actor.ts create mode 100644 src/actors/missions/interview-to-offer/SKILL.md create mode 100644 src/actors/missions/mission-actor-factory.ts create mode 100644 src/actors/missions/personal-brand-opportunity-engine-actor.ts create mode 100644 src/actors/missions/personal-brand-opportunity-engine/SKILL.md create mode 100644 src/actors/missions/promotion-readiness-actor.ts create mode 100644 src/actors/missions/promotion-readiness/SKILL.md create mode 100644 src/actors/missions/salary-negotiation-war-room-actor.ts create mode 100644 src/actors/missions/salary-negotiation-war-room/SKILL.md create mode 100644 src/actors/missions/types.ts create mode 100644 src/features/registry.ts create mode 100644 src/missions/postgres-registry.ts create mode 100644 src/missions/registry.ts create mode 100644 src/routes/missions.ts diff --git a/drizzle/0005_mission_registry.sql b/drizzle/0005_mission_registry.sql new file mode 100644 index 0000000..924f808 --- /dev/null +++ b/drizzle/0005_mission_registry.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS "mission_registry" ( + "id" text PRIMARY KEY NOT NULL, + "version" text NOT NULL, + "title" text NOT NULL, + "short_title" text NOT NULL, + "actor_type" text, + "actor_backed" boolean DEFAULT false NOT NULL, + "skill_path" text NOT NULL, + "display_order" integer NOT NULL, + "definition" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "mission_registry_display_idx" ON "mission_registry" ("display_order"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a8a3c65..1498a05 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1780306900000, "tag": "0004_qscore_snapshots", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1780307000000, + "tag": "0005_mission_registry", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/actors/grow/grow-actor.ts b/src/actors/grow/grow-actor.ts index ed2342e..983889b 100644 --- a/src/actors/grow/grow-actor.ts +++ b/src/actors/grow/grow-actor.ts @@ -1,5 +1,6 @@ import { actor, event } from "rivetkit"; import type { CreateConversationInput, GrowActorState, GrowConversation, SetupGrowInput } from "./types.js"; +import type { GrowActiveMission } from "../missions/types.js"; const buildId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -14,6 +15,14 @@ function ensureInitialized(state: GrowActorState) { if (!state.userId) throw new Error("Grow actor is not initialized"); } +function normalizeState(state: GrowActorState) { + state.conversations ??= []; + state.activeConversationId ??= null; + state.activeMissions ??= []; + state.createdAt ??= now(); + state.updatedAt ??= now(); +} + function createConversationRecord(state: GrowActorState, input: CreateConversationInput = {}): GrowConversation { const timestamp = now(); return { @@ -29,6 +38,7 @@ export const growActor = actor({ userId: "", conversations: [], activeConversationId: null, + activeMissions: [], createdAt: Date.now(), updatedAt: Date.now(), } as GrowActorState, @@ -37,6 +47,8 @@ export const growActor = actor({ ready: event<{ userId: string; conversationId: string }>(), conversationCreated: event(), conversationReset: event(), + missionActivated: event(), + missionUpdated: event(), }, actions: { @@ -45,6 +57,7 @@ export const growActor = actor({ throw new Error("Grow actor already bound to a different user"); } + normalizeState(c.state); c.state.userId = input.userId; if (!c.state.conversations.length) { const conversation = createConversationRecord(c.state, { title: "Talk to Me" }); @@ -68,19 +81,23 @@ export const growActor = actor({ userId: c.state.userId, activeConversationId: c.state.activeConversationId, conversations: c.state.conversations, + activeMissions: c.state.activeMissions, }; }, getState: async (c) => { + normalizeState(c.state); ensureInitialized(c.state); return { userId: c.state.userId, activeConversationId: c.state.activeConversationId, conversations: c.state.conversations, + activeMissions: c.state.activeMissions, }; }, createConversation: async (c, input: CreateConversationInput = {}) => { + normalizeState(c.state); ensureInitialized(c.state); const conversation = createConversationRecord(c.state, input); c.state.conversations.unshift(conversation); @@ -91,6 +108,7 @@ export const growActor = actor({ }, listConversations: async (c) => { + normalizeState(c.state); ensureInitialized(c.state); return c.state.conversations; }, @@ -132,5 +150,44 @@ export const growActor = actor({ c.broadcast("conversationReset", conversation); return conversation; }, + + registerActiveMission: async (c, input: GrowActiveMission) => { + normalizeState(c.state); + ensureInitialized(c.state); + const existingIndex = c.state.activeMissions.findIndex((mission) => mission.instanceId === input.instanceId); + const record = { ...input, updatedAt: now() }; + if (existingIndex >= 0) { + c.state.activeMissions[existingIndex] = record; + c.broadcast("missionUpdated", record); + } else { + c.state.activeMissions.unshift(record); + c.broadcast("missionActivated", record); + } + c.state.updatedAt = record.updatedAt; + return record; + }, + + updateActiveMission: async (c, input: Pick & Partial) => { + normalizeState(c.state); + ensureInitialized(c.state); + const mission = c.state.activeMissions.find((item) => item.instanceId === input.instanceId); + if (!mission) throw new Error(`Unknown active mission: ${input.instanceId}`); + Object.assign(mission, input, { updatedAt: now() }); + c.state.updatedAt = mission.updatedAt; + c.broadcast("missionUpdated", mission); + return mission; + }, + + listActiveMissions: async (c) => { + normalizeState(c.state); + ensureInitialized(c.state); + return c.state.activeMissions; + }, + + getActiveMission: async (c, input: { instanceId: string }) => { + normalizeState(c.state); + ensureInitialized(c.state); + return c.state.activeMissions.find((mission) => mission.instanceId === input.instanceId) ?? null; + }, }, }); diff --git a/src/actors/grow/types.ts b/src/actors/grow/types.ts index 0dea28c..a56ff69 100644 --- a/src/actors/grow/types.ts +++ b/src/actors/grow/types.ts @@ -1,3 +1,5 @@ +import type { GrowActiveMission } from "../missions/types.js"; + export type GrowConversation = { id: string; title: string; @@ -9,6 +11,7 @@ export type GrowActorState = { userId: string; conversations: GrowConversation[]; activeConversationId: string | null; + activeMissions: GrowActiveMission[]; createdAt: number; updatedAt: number; }; diff --git a/src/actors/missions/career-transition-actor.ts b/src/actors/missions/career-transition-actor.ts new file mode 100644 index 0000000..98bc743 --- /dev/null +++ b/src/actors/missions/career-transition-actor.ts @@ -0,0 +1,7 @@ +import { createMissionActor } from "./mission-actor-factory.js"; + +export const careerTransitionMissionActor = createMissionActor({ + missionId: "career-transition", + name: "Career Transition Mission", + icon: "route", +}); diff --git a/src/actors/missions/career-transition/SKILL.md b/src/actors/missions/career-transition/SKILL.md new file mode 100644 index 0000000..da9a0da --- /dev/null +++ b/src/actors/missions/career-transition/SKILL.md @@ -0,0 +1,7 @@ +# Career Transition Sprint Mission + +Durable state holder for the `career-transition` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events. + +## Stages +- Transition map +- Resume fit scan diff --git a/src/actors/missions/index.ts b/src/actors/missions/index.ts new file mode 100644 index 0000000..f728b9e --- /dev/null +++ b/src/actors/missions/index.ts @@ -0,0 +1,6 @@ +export { interviewToOfferMissionActor } from "./interview-to-offer-actor.js"; +export { careerTransitionMissionActor } from "./career-transition-actor.js"; +export { salaryNegotiationWarRoomMissionActor } from "./salary-negotiation-war-room-actor.js"; +export { promotionReadinessMissionActor } from "./promotion-readiness-actor.js"; +export { personalBrandOpportunityEngineMissionActor } from "./personal-brand-opportunity-engine-actor.js"; +export type * from "./types.js"; diff --git a/src/actors/missions/interview-to-offer-actor.ts b/src/actors/missions/interview-to-offer-actor.ts new file mode 100644 index 0000000..3ffb418 --- /dev/null +++ b/src/actors/missions/interview-to-offer-actor.ts @@ -0,0 +1,242 @@ +import { actor, event } from "rivetkit"; +import { getMissionDefinition } from "../../missions/registry.js"; +import type { + MissionArtifact, + MissionEvent, + MissionSnapshot, + MissionStage, + MissionStartInput, +} from "./types.js"; + +const nowIso = () => new Date().toISOString(); +const eventId = () => `mission-event-${Date.now()}-${Math.random().toString(16).slice(2)}`; +const artifactId = () => `artifact-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +function buildInitialStages(): MissionStage[] { + const def = getMissionDefinition("interview-to-offer"); + if (!def) throw new Error("interview-to-offer mission definition is missing"); + + return def.modules.map((module, index) => ({ + id: module.id, + title: module.title, + role: module.role, + description: module.description, + status: index === 0 ? "ready" : "locked", + progressPercent: 0, + })); +} + +function createStartedEvent(goal?: string): MissionEvent { + return { + id: eventId(), + type: "mission.started", + message: goal + ? `Interview-to-Offer mission started for: ${goal}` + : "Interview-to-Offer mission started.", + payload: goal ? { goal } : {}, + createdAt: nowIso(), + }; +} + +function ensureInitialized(state: MissionSnapshot) { + if (!state.userId || !state.instanceId) { + throw new Error("Mission actor is not initialized"); + } +} + +function summarize(state: MissionSnapshot) { + return { + instanceId: state.instanceId, + missionId: state.missionId, + workflowId: state.workflowId, + title: state.title, + shortTitle: state.shortTitle, + status: state.status, + progressPercent: state.progressPercent, + currentStageId: state.currentStageId, + goal: state.goal, + updatedAt: state.updatedAt, + }; +} + +export const interviewToOfferMissionActor = actor({ + options: { + name: "Interview-to-Offer Mission", + icon: "briefcase-business", + noSleep: true, + }, + + state: { + instanceId: "", + missionId: "interview-to-offer", + workflowId: "interview-to-offer", + userId: "", + title: "Interview-to-Offer Accelerator", + shortTitle: "Interview to Offer", + promise: "Prepare for this specific interview and convert it into an offer.", + status: "draft", + input: {}, + progressPercent: 0, + stages: [], + artifacts: [], + events: [], + skillVersion: "1.0.0", + workflowVersion: "1.0.0", + createdAt: nowIso(), + updatedAt: nowIso(), + } as MissionSnapshot, + + events: { + updated: event(), + eventAdded: event(), + artifactAdded: event(), + }, + + actions: { + init: (c, input: MissionStartInput) => { + if (input.missionId !== "interview-to-offer") { + throw new Error(`Unsupported mission for interview actor: ${input.missionId}`); + } + + if (c.state.userId && (c.state.userId !== input.userId || c.state.instanceId !== input.instanceId)) { + throw new Error("Mission actor already initialized for a different user or instance"); + } + + const def = getMissionDefinition("interview-to-offer"); + if (!def) throw new Error("interview-to-offer mission definition is missing"); + + const timestamp = nowIso(); + const firstEvent = createStartedEvent(input.goal); + Object.assign(c.state, { + instanceId: input.instanceId, + missionId: "interview-to-offer", + workflowId: def.id, + userId: input.userId, + title: def.title, + shortTitle: def.shortTitle, + promise: def.promise, + status: "active", + goal: input.goal, + input: input.input ?? {}, + progressPercent: 0, + currentStageId: def.modules[0]?.id, + stages: buildInitialStages(), + artifacts: [], + events: [firstEvent], + skillVersion: def.skillVersion, + workflowVersion: def.version, + createdAt: timestamp, + updatedAt: timestamp, + }); + + c.broadcast("eventAdded", firstEvent); + c.broadcast("updated", c.state); + return c.state; + }, + + getState: (c) => { + ensureInitialized(c.state); + return c.state; + }, + + getSummary: (c) => { + ensureInitialized(c.state); + return summarize(c.state); + }, + + recordEvent: (c, input: { type: string; message: string; payload?: Record }) => { + ensureInitialized(c.state); + const entry: MissionEvent = { + id: eventId(), + type: input.type, + message: input.message, + payload: input.payload ?? {}, + createdAt: nowIso(), + }; + c.state.events.unshift(entry); + c.state.updatedAt = entry.createdAt; + c.broadcast("eventAdded", entry); + c.broadcast("updated", c.state); + return entry; + }, + + 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); + if (!stage) throw new Error(`Unknown stage: ${input.stageId}`); + + const timestamp = nowIso(); + const previousStatus = stage.status; + if (input.status) stage.status = input.status; + if (typeof input.progressPercent === "number") { + stage.progressPercent = Math.max(0, Math.min(100, Math.round(input.progressPercent))); + } + if (input.outputSummary) stage.outputSummary = input.outputSummary; + if (stage.status === "in_progress" && previousStatus !== "in_progress") stage.startedAt = timestamp; + if (stage.status === "done") { + stage.completedAt = timestamp; + stage.progressPercent = 100; + const next = c.state.stages[c.state.stages.findIndex((item) => item.id === stage.id) + 1]; + if (next && next.status === "locked") next.status = "ready"; + } + + c.state.currentStageId = c.state.stages.find((item) => ["ready", "in_progress", "blocked"].includes(item.status))?.id; + c.state.progressPercent = Math.round( + c.state.stages.reduce((sum, item) => sum + item.progressPercent, 0) / Math.max(1, c.state.stages.length), + ); + c.state.updatedAt = timestamp; + c.broadcast("updated", c.state); + return c.state; + }, + + addArtifact: (c, input: Omit & { id?: string }) => { + ensureInitialized(c.state); + const timestamp = nowIso(); + const artifact: MissionArtifact = { + id: input.id ?? artifactId(), + type: input.type, + title: input.title, + status: input.status, + summary: input.summary, + contentMd: input.contentMd, + metadata: input.metadata, + createdAt: timestamp, + updatedAt: timestamp, + }; + c.state.artifacts.unshift(artifact); + c.state.updatedAt = timestamp; + c.broadcast("artifactAdded", artifact); + c.broadcast("updated", c.state); + return artifact; + }, + + pause: (c) => { + ensureInitialized(c.state); + c.state.status = "paused"; + c.state.updatedAt = nowIso(); + c.broadcast("updated", c.state); + return c.state; + }, + + resume: (c) => { + ensureInitialized(c.state); + c.state.status = "active"; + c.state.updatedAt = nowIso(); + c.broadcast("updated", c.state); + return c.state; + }, + + complete: (c, input: { qscoreAfter?: Record } = {}) => { + ensureInitialized(c.state); + const timestamp = nowIso(); + c.state.status = "completed"; + c.state.progressPercent = 100; + c.state.currentStageId = undefined; + c.state.qscoreAfter = input.qscoreAfter; + c.state.completedAt = timestamp; + c.state.updatedAt = timestamp; + c.broadcast("updated", c.state); + return c.state; + }, + }, +}); diff --git a/src/actors/missions/interview-to-offer/SKILL.md b/src/actors/missions/interview-to-offer/SKILL.md new file mode 100644 index 0000000..bcadc45 --- /dev/null +++ b/src/actors/missions/interview-to-offer/SKILL.md @@ -0,0 +1,41 @@ +--- +name: interview-to-offer +displayName: Interview-to-Offer Accelerator +version: 1.0.0 +actor: interviewToOfferMissionActor +--- + +# Interview-to-Offer Accelerator + +## Promise +Prepare the user for a specific interview and help convert it into an offer. + +## Mission Contract +This mission is not an autonomous agent. The conversation layer (Grow) owns the coaching interaction. This actor owns only durable mission state: stages, progress, artifacts, and event history. + +## Inputs +- Target company and role +- Interview date or urgency window +- Resume/profile context +- Biggest concern or blocker + +## Stages +1. Resume fit scan +2. Interview prep plan +3. Mock interview session +4. Communication roleplay +5. Final readiness Q Score + +## Artifacts +- Interview prep plan +- Likely questions +- Behavioral/story bank +- Resume-based talking points +- Weakness diagnosis +- Final readiness score + +## Conversation Handoff Rules +- Grow can read the mission state before answering next-step questions. +- Grow can update stages only when a user action, specialist output, or artifact justifies it. +- Grow should ask for approval before marking a major artifact approved. +- Grow should keep the tone warm and practical. diff --git a/src/actors/missions/mission-actor-factory.ts b/src/actors/missions/mission-actor-factory.ts new file mode 100644 index 0000000..4cef1eb --- /dev/null +++ b/src/actors/missions/mission-actor-factory.ts @@ -0,0 +1,249 @@ +import { actor, event } from "rivetkit"; +import { getMissionDefinition } from "../../missions/registry.js"; +import type { + MissionArtifact, + MissionEvent, + MissionId, + MissionSnapshot, + MissionStage, + MissionStartInput, +} from "./types.js"; + +const nowIso = () => new Date().toISOString(); +const eventId = () => `mission-event-${Date.now()}-${Math.random().toString(16).slice(2)}`; +const artifactId = () => `artifact-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +function buildInitialStages(missionId: MissionId): MissionStage[] { + const def = getMissionDefinition(missionId); + if (!def) throw new Error(`${missionId} mission definition is missing`); + + return def.modules.map((module, index) => ({ + id: module.id, + title: module.title, + role: module.role, + description: module.description, + status: index === 0 ? "ready" : "locked", + progressPercent: 0, + })); +} + +function createStartedEvent(title: string, goal?: string): MissionEvent { + return { + id: eventId(), + type: "mission.started", + message: goal ? `${title} mission started for: ${goal}` : `${title} mission started.`, + payload: goal ? { goal } : {}, + createdAt: nowIso(), + }; +} + +function ensureInitialized(state: MissionSnapshot) { + if (!state.userId || !state.instanceId) { + throw new Error("Mission actor is not initialized"); + } +} + +function summarize(state: MissionSnapshot) { + return { + instanceId: state.instanceId, + missionId: state.missionId, + workflowId: state.workflowId, + title: state.title, + shortTitle: state.shortTitle, + status: state.status, + progressPercent: state.progressPercent, + currentStageId: state.currentStageId, + goal: state.goal, + updatedAt: state.updatedAt, + }; +} + +export function createMissionActor(options: { + missionId: MissionId; + name: string; + icon: string; +}) { + const def = getMissionDefinition(options.missionId); + + return actor({ + options: { + name: options.name, + icon: options.icon, + noSleep: true, + }, + + state: { + instanceId: "", + missionId: options.missionId, + workflowId: options.missionId, + userId: "", + title: def?.title ?? options.name, + shortTitle: def?.shortTitle ?? options.name, + promise: def?.promise ?? "", + status: "draft", + input: {}, + progressPercent: 0, + stages: [], + artifacts: [], + events: [], + skillVersion: def?.skillVersion ?? "1.0.0", + workflowVersion: def?.version ?? "1.0.0", + createdAt: nowIso(), + updatedAt: nowIso(), + } as MissionSnapshot, + + events: { + updated: event(), + eventAdded: event(), + artifactAdded: event(), + }, + + actions: { + init: (c, input: MissionStartInput) => { + if (input.missionId !== options.missionId) { + throw new Error(`Unsupported mission for ${options.missionId} actor: ${input.missionId}`); + } + + if (c.state.userId && (c.state.userId !== input.userId || c.state.instanceId !== input.instanceId)) { + throw new Error("Mission actor already initialized for a different user or instance"); + } + + const missionDef = getMissionDefinition(options.missionId); + if (!missionDef) throw new Error(`${options.missionId} mission definition is missing`); + + const timestamp = nowIso(); + const firstEvent = createStartedEvent(missionDef.shortTitle, input.goal); + Object.assign(c.state, { + instanceId: input.instanceId, + missionId: options.missionId, + workflowId: missionDef.id, + userId: input.userId, + title: missionDef.title, + shortTitle: missionDef.shortTitle, + promise: missionDef.promise, + status: "active", + goal: input.goal, + input: input.input ?? {}, + progressPercent: 0, + currentStageId: missionDef.modules[0]?.id, + stages: buildInitialStages(options.missionId), + artifacts: [], + events: [firstEvent], + skillVersion: missionDef.skillVersion, + workflowVersion: missionDef.version, + createdAt: timestamp, + updatedAt: timestamp, + }); + + c.broadcast("eventAdded", firstEvent); + c.broadcast("updated", c.state); + return c.state; + }, + + getState: (c) => { + ensureInitialized(c.state); + return c.state; + }, + + getSummary: (c) => { + ensureInitialized(c.state); + return summarize(c.state); + }, + + recordEvent: (c, input: { type: string; message: string; payload?: Record }) => { + ensureInitialized(c.state); + const entry: MissionEvent = { + id: eventId(), + type: input.type, + message: input.message, + payload: input.payload ?? {}, + createdAt: nowIso(), + }; + c.state.events.unshift(entry); + c.state.updatedAt = entry.createdAt; + c.broadcast("eventAdded", entry); + c.broadcast("updated", c.state); + return entry; + }, + + 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); + if (!stage) throw new Error(`Unknown stage: ${input.stageId}`); + + const timestamp = nowIso(); + const previousStatus = stage.status; + if (input.status) stage.status = input.status; + if (typeof input.progressPercent === "number") { + stage.progressPercent = Math.max(0, Math.min(100, Math.round(input.progressPercent))); + } + if (input.outputSummary) stage.outputSummary = input.outputSummary; + if (stage.status === "in_progress" && previousStatus !== "in_progress") stage.startedAt = timestamp; + if (stage.status === "done") { + stage.completedAt = timestamp; + stage.progressPercent = 100; + const next = c.state.stages[c.state.stages.findIndex((item) => item.id === stage.id) + 1]; + if (next && next.status === "locked") next.status = "ready"; + } + + c.state.currentStageId = c.state.stages.find((item) => ["ready", "in_progress", "blocked"].includes(item.status))?.id; + c.state.progressPercent = Math.round( + c.state.stages.reduce((sum, item) => sum + item.progressPercent, 0) / Math.max(1, c.state.stages.length), + ); + c.state.updatedAt = timestamp; + c.broadcast("updated", c.state); + return c.state; + }, + + addArtifact: (c, input: Omit & { id?: string }) => { + ensureInitialized(c.state); + const timestamp = nowIso(); + const artifact: MissionArtifact = { + id: input.id ?? artifactId(), + type: input.type, + title: input.title, + status: input.status, + summary: input.summary, + contentMd: input.contentMd, + metadata: input.metadata, + createdAt: timestamp, + updatedAt: timestamp, + }; + c.state.artifacts.unshift(artifact); + c.state.updatedAt = timestamp; + c.broadcast("artifactAdded", artifact); + c.broadcast("updated", c.state); + return artifact; + }, + + pause: (c) => { + ensureInitialized(c.state); + c.state.status = "paused"; + c.state.updatedAt = nowIso(); + c.broadcast("updated", c.state); + return c.state; + }, + + resume: (c) => { + ensureInitialized(c.state); + c.state.status = "active"; + c.state.updatedAt = nowIso(); + c.broadcast("updated", c.state); + return c.state; + }, + + complete: (c, input: { qscoreAfter?: Record } = {}) => { + ensureInitialized(c.state); + const timestamp = nowIso(); + c.state.status = "completed"; + c.state.progressPercent = 100; + c.state.currentStageId = undefined; + c.state.qscoreAfter = input.qscoreAfter; + c.state.completedAt = timestamp; + c.state.updatedAt = timestamp; + c.broadcast("updated", c.state); + return c.state; + }, + }, + }); +} diff --git a/src/actors/missions/personal-brand-opportunity-engine-actor.ts b/src/actors/missions/personal-brand-opportunity-engine-actor.ts new file mode 100644 index 0000000..17e70cc --- /dev/null +++ b/src/actors/missions/personal-brand-opportunity-engine-actor.ts @@ -0,0 +1,7 @@ +import { createMissionActor } from "./mission-actor-factory.js"; + +export const personalBrandOpportunityEngineMissionActor = createMissionActor({ + missionId: "personal-brand-opportunity-engine", + name: "Personal Brand Opportunity Engine Mission", + icon: "sparkles", +}); diff --git a/src/actors/missions/personal-brand-opportunity-engine/SKILL.md b/src/actors/missions/personal-brand-opportunity-engine/SKILL.md new file mode 100644 index 0000000..6280dea --- /dev/null +++ b/src/actors/missions/personal-brand-opportunity-engine/SKILL.md @@ -0,0 +1,6 @@ +# Personal Brand Opportunity Engine Mission + +Durable state holder for the `personal-brand-opportunity-engine` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events. + +## Stages +- Profile rewrite diff --git a/src/actors/missions/promotion-readiness-actor.ts b/src/actors/missions/promotion-readiness-actor.ts new file mode 100644 index 0000000..54f2a7a --- /dev/null +++ b/src/actors/missions/promotion-readiness-actor.ts @@ -0,0 +1,7 @@ +import { createMissionActor } from "./mission-actor-factory.js"; + +export const promotionReadinessMissionActor = createMissionActor({ + missionId: "promotion-readiness", + name: "Promotion Readiness Mission", + icon: "trending-up", +}); diff --git a/src/actors/missions/promotion-readiness/SKILL.md b/src/actors/missions/promotion-readiness/SKILL.md new file mode 100644 index 0000000..db93866 --- /dev/null +++ b/src/actors/missions/promotion-readiness/SKILL.md @@ -0,0 +1,7 @@ +# Promotion Readiness Packet Mission + +Durable state holder for the `promotion-readiness` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events. + +## Stages +- Evidence packet +- Manager conversation roleplay diff --git a/src/actors/missions/salary-negotiation-war-room-actor.ts b/src/actors/missions/salary-negotiation-war-room-actor.ts new file mode 100644 index 0000000..6041363 --- /dev/null +++ b/src/actors/missions/salary-negotiation-war-room-actor.ts @@ -0,0 +1,7 @@ +import { createMissionActor } from "./mission-actor-factory.js"; + +export const salaryNegotiationWarRoomMissionActor = createMissionActor({ + missionId: "salary-negotiation-war-room", + name: "Salary Negotiation War Room Mission", + icon: "badge-dollar-sign", +}); diff --git a/src/actors/missions/salary-negotiation-war-room/SKILL.md b/src/actors/missions/salary-negotiation-war-room/SKILL.md new file mode 100644 index 0000000..d0e18ce --- /dev/null +++ b/src/actors/missions/salary-negotiation-war-room/SKILL.md @@ -0,0 +1,7 @@ +# Salary Negotiation War Room Mission + +Durable state holder for the `salary-negotiation-war-room` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events. + +## Stages +- Negotiation script +- Negotiation roleplay diff --git a/src/actors/missions/types.ts b/src/actors/missions/types.ts new file mode 100644 index 0000000..21dc8bc --- /dev/null +++ b/src/actors/missions/types.ts @@ -0,0 +1,109 @@ +import type { WorkflowDefinition } from "../../workflows/types.js"; + +export type MissionId = + | "interview-to-offer" + | "career-transition" + | "salary-negotiation-war-room" + | "promotion-readiness" + | "personal-brand-opportunity-engine"; + +export type MissionInstanceStatus = "draft" | "active" | "paused" | "completed" | "cancelled"; +export type MissionStageStatus = "locked" | "ready" | "in_progress" | "blocked" | "done"; +export type MissionArtifactStatus = "draft" | "ready" | "approved" | "archived"; + +export type MissionStage = { + id: string; + title: string; + role: string; + description: string; + status: MissionStageStatus; + progressPercent: number; + startedAt?: string; + completedAt?: string; + outputSummary?: string; +}; + +export type MissionArtifact = { + id: string; + type: string; + title: string; + status: MissionArtifactStatus; + summary?: string; + contentMd?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +}; + +export type MissionEvent = { + id: string; + type: string; + message: string; + payload?: Record; + createdAt: string; +}; + +export type MissionSnapshot = { + instanceId: string; + missionId: MissionId; + workflowId: string; + userId: string; + title: string; + shortTitle: string; + promise: string; + status: MissionInstanceStatus; + goal?: string; + input: Record; + progressPercent: number; + currentStageId?: string; + stages: MissionStage[]; + artifacts: MissionArtifact[]; + events: MissionEvent[]; + qscoreBefore?: Record; + qscoreAfter?: Record; + skillVersion: string; + workflowVersion: string; + createdAt: string; + updatedAt: string; + completedAt?: string; +}; + +export type MissionStartInput = { + userId: string; + instanceId: string; + missionId: MissionId; + goal?: string; + input?: Record; +}; + +export type MissionActorType = + | "interviewToOfferMissionActor" + | "careerTransitionMissionActor" + | "salaryNegotiationWarRoomMissionActor" + | "promotionReadinessMissionActor" + | "personalBrandOpportunityEngineMissionActor"; + +export type MissionRegistryEntry = WorkflowDefinition & { + missionId: MissionId; + kind: "mission"; + actorType?: MissionActorType; + actorBacked: boolean; + skillVersion: string; + skillPath: string; + displayOrder: number; +}; + +export type GrowActiveMission = { + instanceId: string; + missionId: MissionId; + workflowId: string; + title: string; + shortTitle: string; + status: MissionInstanceStatus; + progressPercent: number; + currentStageId?: string; + goal?: string; + actorType?: MissionActorType; + createdAt: number; + updatedAt: number; +}; diff --git a/src/actors/registry.ts b/src/actors/registry.ts index e745432..7b28804 100644 --- a/src/actors/registry.ts +++ b/src/actors/registry.ts @@ -5,12 +5,24 @@ import { interviewServiceActor, resumeServiceActor, roleplayServiceActor } from import { conversationActor } from "./conversation/index.js"; import { memoryActor } from "./memory/index.js"; import { growActor } from "./grow/index.js"; +import { + careerTransitionMissionActor, + interviewToOfferMissionActor, + personalBrandOpportunityEngineMissionActor, + promotionReadinessMissionActor, + salaryNegotiationWarRoomMissionActor, +} from "./missions/index.js"; export const registry = setup({ use: { growActor, conversationActor, memoryActor, + interviewToOfferMissionActor, + careerTransitionMissionActor, + salaryNegotiationWarRoomMissionActor, + promotionReadinessMissionActor, + personalBrandOpportunityEngineMissionActor, userActor, workflowRunActor, interviewServiceActor, diff --git a/src/db/schema.ts b/src/db/schema.ts index 7dd4578..4193dc8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -4,6 +4,7 @@ import { text, timestamp, integer, + boolean, jsonb, uniqueIndex, index, @@ -171,6 +172,27 @@ export type UserStack = typeof userStacks.$inferSelect; export type NewUserStack = typeof userStacks.$inferInsert; export type ActorRow = typeof actors.$inferSelect; export type RepoRow = typeof repos.$inferSelect; + +export const missionRegistry = pgTable( + "mission_registry", + { + id: text("id").primaryKey(), + version: text("version").notNull(), + title: text("title").notNull(), + shortTitle: text("short_title").notNull(), + actorType: text("actor_type"), + actorBacked: boolean("actor_backed").notNull().default(false), + skillPath: text("skill_path").notNull(), + displayOrder: integer("display_order").notNull(), + definition: jsonb("definition").$type>().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ displayIdx: index("mission_registry_display_idx").on(t.displayOrder) }), +); + +export type MissionRegistryRow = typeof missionRegistry.$inferSelect; + export const workflowRuns = pgTable( "workflow_runs", { diff --git a/src/features/registry.ts b/src/features/registry.ts new file mode 100644 index 0000000..adc6074 --- /dev/null +++ b/src/features/registry.ts @@ -0,0 +1,95 @@ +import { config } from "../config.js"; + +export type GrowServiceId = "resume-service" | "interview-service" | "roleplay-service" | "qscore-service"; +export type GrowFeatureId = "resume-building" | "mock-interview" | "mock-roleplay" | "q-score"; + +export type GrowFeatureDefinition = { + id: GrowFeatureId; + serviceId: GrowServiceId; + title: string; + skillLabel: string; + description: string; + promptModulePath: string; + enabled: boolean; + internalUrl?: string; + publicUrl?: string; + operations: string[]; +}; + +export const featureDefinitions: GrowFeatureDefinition[] = [ + { + id: "resume-building", + serviceId: "resume-service", + title: "Resume Building", + skillLabel: "Resume Building", + description: "Build, tailor, analyze, and improve resumes for role fit and ATS readiness.", + promptModulePath: "agents/resume.md", + enabled: Boolean(config.resumeServiceUrl), + internalUrl: config.resumeServiceUrl, + publicUrl: config.resumePublicUrl, + operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"], + }, + { + id: "mock-interview", + serviceId: "interview-service", + title: "Mock Interview", + skillLabel: "Mock Interview", + description: "Configure, practice, review, and score interview sessions.", + promptModulePath: "agents/sara.md", + enabled: Boolean(config.interviewServiceUrl), + internalUrl: config.interviewServiceUrl, + publicUrl: config.interviewPublicUrl, + operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"], + }, + { + id: "mock-roleplay", + serviceId: "roleplay-service", + title: "Mock Roleplay", + skillLabel: "Mock Roleplay", + description: "Practice negotiations, recruiter calls, manager conversations, and stakeholder roleplays.", + promptModulePath: "agents/emily.md", + enabled: Boolean(config.roleplayServiceUrl), + internalUrl: config.roleplayServiceUrl, + publicUrl: config.roleplayPublicUrl, + operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"], + }, + { + id: "q-score", + serviceId: "qscore-service", + title: "Q Score", + skillLabel: "Q Score", + description: "Analyze overall job-market readiness and convert signals into improvement priorities.", + promptModulePath: "agents/qscore.md", + enabled: Boolean(config.qscoreServiceUrl), + internalUrl: config.qscoreServiceUrl, + operations: ["qscore.ingest", "qscore.compute"], + }, +]; + +export const internalWorkflowSkills = [ + { + id: "mission-planning", + title: "Mission Planning", + skillLabel: "Mission Planning", + description: "Internal planning and artifact drafting for a mission. This is not a user-facing feature service.", + execution: "opencode" as const, + }, +]; + +export function listFeatureDefinitions() { + return featureDefinitions; +} + +export function getFeatureByServiceId(serviceId: string) { + return featureDefinitions.find((feature) => feature.serviceId === serviceId); +} + +export function displayLabelForService(serviceId: string | undefined) { + if (!serviceId) return undefined; + return getFeatureByServiceId(serviceId)?.skillLabel; +} + +export function displayLabelForExecution(execution: string) { + if (execution === "opencode") return "Mission Planning"; + return undefined; +} diff --git a/src/index.ts b/src/index.ts index 5044312..2a2418b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { chatRoutes } from "./routes/chat.js"; import { serviceRoutes } from "./routes/services.js"; import { conversationRoutes } from "./routes/conversations.js"; import { growRoutes } from "./routes/grow.js"; +import { missionRoutes } from "./routes/missions.js"; import { db } from "./db/client.js"; import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js"; import { initCatalog } from "./agents/catalog.js"; @@ -79,6 +80,7 @@ async function main() { app.route("/workflow-runs", workflowRunRoutes()); app.route("/actors", actorRoutes()); app.route("/grow", growRoutes()); + app.route("/missions", missionRoutes()); app.route("/conversations", conversationRoutes()); app.route("/opencode", opencodeRoutes()); app.route("/git", gitRoutes()); diff --git a/src/missions/postgres-registry.ts b/src/missions/postgres-registry.ts new file mode 100644 index 0000000..60eca51 --- /dev/null +++ b/src/missions/postgres-registry.ts @@ -0,0 +1,56 @@ +import { asc, eq } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { missionRegistry } from "../db/schema.js"; +import type { MissionRegistryEntry } from "../actors/missions/types.js"; +import { getMissionDefinition, listMissionDefinitions } from "./registry.js"; + +function toRow(mission: MissionRegistryEntry) { + return { + id: mission.missionId, + version: mission.version, + title: mission.title, + shortTitle: mission.shortTitle, + actorType: mission.actorType, + actorBacked: mission.actorBacked, + skillPath: mission.skillPath, + displayOrder: mission.displayOrder, + definition: mission as unknown as Record, + updatedAt: new Date(), + }; +} + +export async function seedMissionRegistry() { + const missions = listMissionDefinitions(); + for (const mission of missions) { + const row = toRow(mission); + await db + .insert(missionRegistry) + .values(row) + .onConflictDoUpdate({ + target: missionRegistry.id, + set: { + version: row.version, + title: row.title, + shortTitle: row.shortTitle, + actorType: row.actorType, + actorBacked: row.actorBacked, + skillPath: row.skillPath, + displayOrder: row.displayOrder, + definition: row.definition, + updatedAt: row.updatedAt, + }, + }); + } +} + +export async function listPersistedMissionDefinitions(): Promise { + await seedMissionRegistry(); + const rows = await db.select().from(missionRegistry).orderBy(asc(missionRegistry.displayOrder)); + return rows.map((row) => row.definition as unknown as MissionRegistryEntry); +} + +export async function getPersistedMissionDefinition(missionId: string): Promise { + await seedMissionRegistry(); + const [row] = await db.select().from(missionRegistry).where(eq(missionRegistry.id, missionId)).limit(1); + return row ? (row.definition as unknown as MissionRegistryEntry) : getMissionDefinition(missionId); +} diff --git a/src/missions/registry.ts b/src/missions/registry.ts new file mode 100644 index 0000000..06e43ab --- /dev/null +++ b/src/missions/registry.ts @@ -0,0 +1,52 @@ +import { getWorkflowDefinition, listWorkflowDefinitions } from "../workflows/registry.js"; +import type { MissionActorType, MissionId, MissionRegistryEntry } from "../actors/missions/types.js"; + +const MISSION_ORDER: MissionId[] = [ + "interview-to-offer", + "career-transition", + "salary-negotiation-war-room", + "promotion-readiness", + "personal-brand-opportunity-engine", +]; + +const ACTOR_BACKED_MISSIONS: Record = { + "interview-to-offer": "interviewToOfferMissionActor", + "career-transition": "careerTransitionMissionActor", + "salary-negotiation-war-room": "salaryNegotiationWarRoomMissionActor", + "promotion-readiness": "promotionReadinessMissionActor", + "personal-brand-opportunity-engine": "personalBrandOpportunityEngineMissionActor", +}; + +export function listMissionDefinitions(): MissionRegistryEntry[] { + return MISSION_ORDER.map((missionId, index) => { + const workflow = getWorkflowDefinition(missionId); + if (!workflow) throw new Error(`Mission workflow definition not found: ${missionId}`); + const actorType = ACTOR_BACKED_MISSIONS[missionId]; + return { + ...workflow, + kind: "mission", + missionId, + actorType, + actorBacked: Boolean(actorType), + skillVersion: workflow.version, + skillPath: `src/actors/missions/${missionId}/SKILL.md`, + displayOrder: index + 1, + }; + }); +} + +export function getMissionDefinition(missionId: string): MissionRegistryEntry | undefined { + return listMissionDefinitions().find((mission) => mission.missionId === missionId); +} + +export function listAvailableMissionDefinitions(): MissionRegistryEntry[] { + const ids = new Set(MISSION_ORDER); + return listWorkflowDefinitions() + .filter((workflow) => ids.has(workflow.id as MissionId)) + .map((workflow) => getMissionDefinition(workflow.id)) + .filter((mission): mission is MissionRegistryEntry => Boolean(mission)); +} + +export function isActorBackedMission(missionId: string): missionId is MissionId { + return Boolean(ACTOR_BACKED_MISSIONS[missionId as MissionId]); +} diff --git a/src/routes/conversations.ts b/src/routes/conversations.ts index cfc5b41..3c27bb7 100644 --- a/src/routes/conversations.ts +++ b/src/routes/conversations.ts @@ -6,7 +6,8 @@ import { config } from "../config.js"; import { requireUser, type AuthContext } from "../auth/clerk.js"; import type { Registry } from "../actors/registry.js"; import { getConversationModel } from "../actors/conversation/agent.js"; -import { listWorkflowDefinitions } from "../workflows/registry.js"; +import { getMissionDefinition, isActorBackedMission, listMissionDefinitions } from "../missions/registry.js"; +import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js"; import { getSubAgentModules } from "../lib/prompt-loader.js"; let _client: Client | null = null; @@ -26,8 +27,20 @@ function memoryFor(userId: string) { return getClient().memoryActor.getOrCreate([userId]); } -function workflowFor(userId: string) { - return getClient().userActor.getOrCreate([userId]); +function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) { + const client = getClient(); + switch (actorType) { + case "interviewToOfferMissionActor": + return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]); + case "careerTransitionMissionActor": + return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]); + case "salaryNegotiationWarRoomMissionActor": + return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]); + case "promotionReadinessMissionActor": + return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]); + case "personalBrandOpportunityEngineMissionActor": + return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]); + } } const createConversationSchema = z.object({ title: z.string().optional() }); @@ -36,6 +49,38 @@ const streamSchema = z.object({ conversationId: z.string().optional(), }); +const createMissionInstanceId = (missionId: string) => + `${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +function actorTypeForMission(missionId: string): MissionActorType | undefined { + if (missionId === "interview-to-offer") return "interviewToOfferMissionActor"; + if (missionId === "career-transition") return "careerTransitionMissionActor"; + if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor"; + if (missionId === "promotion-readiness") return "promotionReadinessMissionActor"; + if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor"; + return undefined; +} + +function activeMissionFromSnapshot(snapshot: MissionSnapshot): GrowActiveMission { + const actorType = actorTypeForMission(snapshot.missionId); + if (!actorType) throw new Error(`Mission actor not registered for: ${snapshot.missionId}`); + + return { + instanceId: snapshot.instanceId, + missionId: snapshot.missionId, + workflowId: snapshot.workflowId, + title: snapshot.title, + shortTitle: snapshot.shortTitle, + status: snapshot.status, + progressPercent: snapshot.progressPercent, + currentStageId: snapshot.currentStageId, + goal: snapshot.goal, + actorType, + createdAt: new Date(snapshot.createdAt).getTime(), + updatedAt: new Date(snapshot.updatedAt).getTime(), + }; +} + function textFromMessage(message: UIMessage | undefined) { if (!message) return ""; return message.parts @@ -45,6 +90,30 @@ function textFromMessage(message: UIMessage | undefined) { .trim(); } +function forcedToolForPrompt(text: string) { + const lower = text.toLowerCase(); + + // Keep high-value dashboard UI predictable: when the user explicitly asks to + // see/discover/find workflows, always produce workflow cards instead of a + // text-only answer. + if ( + /\b(discover|find|show|list|recommend|suggest|compare|what|which)\b/.test(lower) && + /\b(workflow|workflows|plan|plans|program|programs)\b/.test(lower) + ) { + return "discoverWorkflows" as const; + } + + if (/\b(mission|missions|next actions|next steps|what should i do today)\b/.test(lower)) { + return "showMissions" as const; + } + + if (/\b(agent registry|specialists|subagents|coaches|who can help)\b/.test(lower)) { + return "listAgentRegistry" as const; + } + + return undefined; +} + function buildSystemPrompt() { return `You are Grow, the warm, encouraging career companion inside GrowQR. You are part coach, part strategist, and part cheerleader. @@ -68,11 +137,19 @@ How to help: 5. SPECIALISTS — Use listAgentRegistry to show available specialists, then askSubAgent to route focused questions to Sara (interview), Emily (roleplay), Quinn (Q Score), or Rhea (resume). 6. BE HONEST — If you don't know something, say so. If a tool fails, acknowledge it and try a different approach. +Visual-first product behavior: +- GrowQR users prefer visual cards and guided choices over long text. They are often scanning, not studying. +- For product objects — missions/workflows, next actions, active workflow state, specialists/coaches, memory lists/searches — prefer calling the relevant UI tool so the dashboard can render cards. +- After a tool call, add only a short human summary: one warm sentence or 1-3 bullets. Let the visual UI carry most of the information. +- Do not use tools for casual conversation, emotional support, simple explanations, or when direct text is clearly better. + Tool usage rules: -- Only call tools when they genuinely help the user. Don't call tools for every response. -- If the user asks about their memory, use searchMemory or readMemory first. -- If the user asks about missions, use discoverWorkflows or inspectWorkflowRegistry. -- If the user wants interview prep, salary negotiation, or resume help, route to the right specialist via askSubAgent. +- Use tools when they materially improve comprehension, navigation, or action-taking. +- If the user asks to see/find/discover/recommend/compare missions or workflows, call discoverWorkflows first, then summarize briefly. +- If the user asks "what should I do next", "next steps", "today", or "missions", call showMissions. +- If the user asks about their memory, use searchMemory, readMemory, listMemory, or memoryStats first. +- If the user asks about specialists, coaches, interview prep, roleplay, Q Score, or resume help, use listAgentRegistry and/or askSubAgent when helpful. +- Only start missions/workflows with startWorkflow when the user clearly agrees to start one. - When you write memory, confirm what you saved: "Got it — I've saved that you're targeting PM roles at Stripe and Notion." - When you start a mission, celebrate: "🎉 Let's do this! Starting your Interview-to-Offer mission now."`; } @@ -138,14 +215,14 @@ Return compact, bullet-heavy markdown.`, function buildConversationTools(userId: string) { return { discoverWorkflows: tool({ - description: "Return sellable GrowQR missions as UI-ready discovery cards.", + description: "Return sellable GrowQR missions as rich UI-ready discovery cards. Use whenever the user asks to see, find, discover, compare, choose, recommend, or understand missions/workflows.", inputSchema: z.object({ segment: z.string().optional().describe("Optional user segment or goal to filter/rank by."), }), execute: async ({ segment }) => ({ kind: "workflow-discovery", segment, - workflows: listWorkflowDefinitions().map((workflow) => ({ + workflows: listMissionDefinitions().map((workflow) => ({ id: workflow.id, title: workflow.title, shortTitle: workflow.shortTitle, @@ -165,12 +242,12 @@ function buildConversationTools(userId: string) { }), inspectWorkflowRegistry: tool({ - description: "Inspect the GrowQR mission registry with module-level detail.", + description: "Inspect the GrowQR mission registry with module-level detail as UI cards. Use for deeper mission/module questions after discovery or when the user asks what a mission includes.", inputSchema: z.object({ workflowId: z.string().optional(), }), execute: async ({ workflowId }) => { - const workflows = listWorkflowDefinitions() + const workflows = listMissionDefinitions() .filter((workflow) => !workflowId || workflow.id === workflowId) .map((workflow) => ({ id: workflow.id, @@ -231,7 +308,7 @@ function buildConversationTools(userId: string) { }), showMissions: tool({ - description: "Return UI-ready career missions for the user's dashboard.", + description: "Return UI-ready career mission cards for the user's dashboard. Use when the user asks what to do next, asks for priorities, asks for missions, or seems stuck.", inputSchema: z.object({ focus: z.string().optional(), }), @@ -267,22 +344,47 @@ function buildConversationTools(userId: string) { startWorkflow: tool({ description: "Start one of the GrowQR missions for this user. Only call this when the user explicitly agrees to start a mission.", inputSchema: z.object({ - workflowId: z.string().describe("Workflow id, e.g. interview-to-offer."), + workflowId: z.string().describe("Mission/workflow id, e.g. interview-to-offer."), goal: z.string().optional().describe("Optional goal text to personalize the mission."), }), execute: async ({ workflowId, goal }) => { try { - const handle = workflowFor(userId); - await handle.init({ userId }); - const state = await handle.startWorkflow({ workflowId, goal }); - const workflow = listWorkflowDefinitions().find((w) => w.id === workflowId); + const mission = getMissionDefinition(workflowId); + if (!mission) throw new Error(`Unknown mission: ${workflowId}`); + if (!isActorBackedMission(workflowId)) { + return { + kind: "workflow-started", + workflowId, + goal, + status: "coming_soon", + title: mission.title, + error: "This mission is in the registry but its actor is not implemented yet.", + }; + } + + if (!mission.actorType) throw new Error(`Mission actor not registered for: ${workflowId}`); + + const instanceId = createMissionInstanceId(workflowId); + const snapshot = await missionActorFor(userId, instanceId, mission.actorType).init({ + userId, + instanceId, + missionId: workflowId, + goal, + input: {}, + }); + const grow = growFor(userId); + await grow.setup({ userId }); + const activeMission = await grow.registerActiveMission(activeMissionFromSnapshot(snapshot)); return { kind: "workflow-started", workflowId, + missionId: workflowId, + instanceId, goal, - status: state.workflowStatus, - runId: state.workflowRunId, - title: workflow?.title ?? workflowId, + status: activeMission.status, + progressPercent: activeMission.progressPercent, + title: mission.title, + snapshot, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to start mission"; @@ -292,20 +394,32 @@ function buildConversationTools(userId: string) { goal, status: "error", error: errorMessage, - title: listWorkflowDefinitions().find((w) => w.id === workflowId)?.title ?? workflowId, + title: listMissionDefinitions().find((w) => w.id === workflowId)?.title ?? workflowId, }; } }, }), getActiveWorkflow: tool({ - description: "Return the active mission snapshot for this user. Use this when the user asks 'what am I working on' or 'what's my current mission'.", + description: "Return the user's active mission snapshots. Use this when the user asks 'what am I working on' or 'what's my current mission'.", inputSchema: z.object({}), execute: async () => { try { - const handle = workflowFor(userId); - await handle.init({ userId }); - return { kind: "active-workflow", workflow: await handle.getWorkflowStatus() }; + const grow = growFor(userId); + await grow.setup({ userId }); + const missions = await grow.listActiveMissions(); + const snapshots = await Promise.all( + missions.map(async (mission) => { + if (!mission.actorType) return null; + return missionActorFor(userId, mission.instanceId, mission.actorType).getState(); + }), + ); + return { + kind: "active-workflow", + workflow: snapshots.find(Boolean) ?? null, + missions, + snapshots: snapshots.filter(Boolean), + }; } catch (err) { return { kind: "active-workflow", workflow: null, error: err instanceof Error ? err.message : "Could not fetch workflow" }; } @@ -432,11 +546,13 @@ export function conversationRoutes() { await conversation.addMessage({ role: "user", content: latestUserText, sender: "User" }); } + const visualTool = forcedToolForPrompt(latestUserText); const result = streamText({ model: getConversationModel(), system: buildSystemPrompt(), messages: await convertToModelMessages(body.messages), tools: buildConversationTools(userId), + toolChoice: visualTool ? { type: "tool", toolName: visualTool } : "auto", stopWhen: stepCountIs(5), }); diff --git a/src/routes/missions.ts b/src/routes/missions.ts new file mode 100644 index 0000000..c7f3525 --- /dev/null +++ b/src/routes/missions.ts @@ -0,0 +1,216 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { createClient, type Client } from "rivetkit/client"; +import { config } from "../config.js"; +import { requireUser, type AuthContext } from "../auth/clerk.js"; +import type { Registry } from "../actors/registry.js"; +import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js"; +import { isActorBackedMission } from "../missions/registry.js"; +import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-registry.js"; + +let _client: Client | null = null; +function getClient(): Client { + return (_client ??= createClient(config.rivetClientEndpoint)); +} + +function growFor(userId: string) { + return getClient().growActor.getOrCreate([userId]); +} + +function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) { + const client = getClient(); + switch (actorType) { + case "interviewToOfferMissionActor": + return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]); + case "careerTransitionMissionActor": + return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]); + case "salaryNegotiationWarRoomMissionActor": + return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]); + case "promotionReadinessMissionActor": + return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]); + case "personalBrandOpportunityEngineMissionActor": + return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]); + } +} + +const startMissionSchema = z.object({ + goal: z.string().min(1).optional(), + input: z.record(z.unknown()).optional(), +}); + +const updateStageSchema = z.object({ + status: z.enum(["locked", "ready", "in_progress", "blocked", "done"]).optional(), + progressPercent: z.number().min(0).max(100).optional(), + outputSummary: z.string().optional(), +}); + +const addArtifactSchema = z.object({ + type: z.string().min(1), + title: z.string().min(1), + status: z.enum(["draft", "ready", "approved", "archived"]).default("ready"), + summary: z.string().optional(), + contentMd: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +const createInstanceId = (missionId: string) => + `${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +function activeMissionFromSnapshot(snapshot: MissionSnapshot): GrowActiveMission { + const actorType = getPersistedActorType(snapshot.missionId); + if (!actorType) throw new Error(`Mission actor not registered for: ${snapshot.missionId}`); + + return { + instanceId: snapshot.instanceId, + missionId: snapshot.missionId, + workflowId: snapshot.workflowId, + title: snapshot.title, + shortTitle: snapshot.shortTitle, + status: snapshot.status, + progressPercent: snapshot.progressPercent, + currentStageId: snapshot.currentStageId, + goal: snapshot.goal, + actorType, + createdAt: new Date(snapshot.createdAt).getTime(), + updatedAt: new Date(snapshot.updatedAt).getTime(), + }; +} + +function getPersistedActorType(missionId: string): MissionActorType | undefined { + if (missionId === "interview-to-offer") return "interviewToOfferMissionActor"; + if (missionId === "career-transition") return "careerTransitionMissionActor"; + if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor"; + if (missionId === "promotion-readiness") return "promotionReadinessMissionActor"; + if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor"; + return undefined; +} + +async function getMissionSnapshot(userId: string, active: GrowActiveMission): Promise { + if (!active.actorType) return null; + return missionActorFor(userId, active.instanceId, active.actorType).getState(); +} + +export function missionRoutes() { + const app = new Hono(); + app.use("*", requireUser); + + app.get("/available", async (c) => { + const missions = await listPersistedMissionDefinitions(); + return c.json({ + missions: missions.map((mission) => ({ + ...mission, + startable: mission.actorBacked, + actorStatus: mission.actorBacked ? "ready" : "coming_soon", + })), + }); + }); + + app.get("/active", async (c) => { + const userId = c.get("userId"); + const grow = growFor(userId); + await grow.setup({ userId }); + const activeMissions = await grow.listActiveMissions(); + const snapshots = await Promise.all(activeMissions.map((mission) => getMissionSnapshot(userId, mission))); + return c.json({ + missions: activeMissions, + snapshots: snapshots.filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)), + }); + }); + + app.get("/active/:instanceId", async (c) => { + const userId = c.get("userId"); + const grow = growFor(userId); + await grow.setup({ userId }); + const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") }); + if (!active) return c.json({ error: "mission_not_found" }, 404); + const snapshot = await getMissionSnapshot(userId, active); + if (!snapshot) return c.json({ error: "mission_actor_not_available" }, 404); + return c.json({ mission: active, snapshot }); + }); + + app.post("/:missionId/start", async (c) => { + const userId = c.get("userId"); + const missionId = c.req.param("missionId"); + const mission = await getPersistedMissionDefinition(missionId); + if (!mission) return c.json({ error: "mission_not_found" }, 404); + if (!isActorBackedMission(missionId)) { + return c.json({ error: "mission_actor_not_implemented", mission }, 409); + } + + const body = startMissionSchema.parse(await c.req.json().catch(() => ({}))); + const instanceId = createInstanceId(missionId); + if (!mission.actorType) return c.json({ error: "mission_actor_not_available", mission }, 409); + const actor = missionActorFor(userId, instanceId, mission.actorType); + const snapshot = await actor.init({ + userId, + instanceId, + missionId, + goal: body.goal, + input: body.input ?? {}, + }); + + const grow = growFor(userId); + await grow.setup({ userId }); + const activeMission = await grow.registerActiveMission(activeMissionFromSnapshot(snapshot)); + return c.json({ mission: activeMission, snapshot }, 201); + }); + + app.post("/active/:instanceId/stages/:stageId", async (c) => { + const userId = c.get("userId"); + const grow = growFor(userId); + await grow.setup({ userId }); + const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") }); + if (!active) return c.json({ error: "mission_not_found" }, 404); + if (!active.actorType) return c.json({ error: "mission_actor_not_available" }, 404); + + const body = updateStageSchema.parse(await c.req.json().catch(() => ({}))); + const snapshot = await missionActorFor(userId, active.instanceId, active.actorType).updateStage({ + stageId: c.req.param("stageId"), + ...body, + }); + await grow.updateActiveMission(activeMissionFromSnapshot(snapshot)); + return c.json({ snapshot }); + }); + + app.post("/active/:instanceId/artifacts", async (c) => { + const userId = c.get("userId"); + const grow = growFor(userId); + await grow.setup({ userId }); + const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") }); + if (!active) return c.json({ error: "mission_not_found" }, 404); + if (!active.actorType) return c.json({ error: "mission_actor_not_available" }, 404); + + const body = addArtifactSchema.parse(await c.req.json()); + const actor = missionActorFor(userId, active.instanceId, active.actorType); + const artifact = await actor.addArtifact(body); + const snapshot = await actor.getState(); + await grow.updateActiveMission(activeMissionFromSnapshot(snapshot)); + return c.json({ artifact, snapshot }, 201); + }); + + app.post("/active/:instanceId/pause", async (c) => { + const userId = c.get("userId"); + const grow = growFor(userId); + await grow.setup({ userId }); + const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") }); + if (!active) return c.json({ error: "mission_not_found" }, 404); + if (!active.actorType) return c.json({ error: "mission_actor_not_available" }, 404); + const snapshot = await missionActorFor(userId, active.instanceId, active.actorType).pause(); + await grow.updateActiveMission(activeMissionFromSnapshot(snapshot)); + return c.json({ snapshot }); + }); + + app.post("/active/:instanceId/resume", async (c) => { + const userId = c.get("userId"); + const grow = growFor(userId); + await grow.setup({ userId }); + const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") }); + if (!active) return c.json({ error: "mission_not_found" }, 404); + if (!active.actorType) return c.json({ error: "mission_actor_not_available" }, 404); + const snapshot = await missionActorFor(userId, active.instanceId, active.actorType).resume(); + await grow.updateActiveMission(activeMissionFromSnapshot(snapshot)); + return c.json({ snapshot }); + }); + + return app; +} diff --git a/src/workflows/registry.ts b/src/workflows/registry.ts index 53e335f..d05af22 100644 --- a/src/workflows/registry.ts +++ b/src/workflows/registry.ts @@ -1,4 +1,8 @@ import type { WorkflowDefinition } from "./types.js"; +import { displayLabelForExecution, displayLabelForService } from "../features/registry.js"; + +const serviceSkill = (serviceId: string) => displayLabelForService(serviceId) ?? serviceId; +const planningSkill = displayLabelForExecution("opencode") ?? "Mission Planning"; const commonInputs = [ { id: "goal", label: "Target outcome", type: "text", required: true }, @@ -11,29 +15,29 @@ export const workflowDefinitions: WorkflowDefinition[] = [ 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: ["sara", "emily", "qscore"] }, requiredInputs: commonInputs, modules: [ - { id: "resume", title: "Resume fit scan", role: "Resume Agent", description: "Analyze resume readiness for the target role.", execution: "service", service: "resume-service" }, - { id: "interview-plan", title: "Interview prep plan", role: "OpenCode", 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: "sara", title: "Mock interview", role: "Sara", description: "Create a real interview practice session.", execution: "service", service: "interview-service" }, - { id: "emily", title: "Communication roleplay", role: "Emily", description: "Create a realistic roleplay session.", execution: "service", service: "roleplay-service" }, - { id: "qscore", title: "Readiness Q Score", role: "Quinn", description: "Compute readiness score.", execution: "service", service: "qscore-service" }, + { id: "resume", title: "Resume fit scan", role: serviceSkill("resume-service"), description: "Analyze resume readiness for the target role.", execution: "service", service: "resume-service" }, + { id: "interview-plan", title: "Interview prep plan", role: planningSkill, 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: "sara", title: "Mock interview", role: serviceSkill("interview-service"), description: "Create a real interview practice session.", execution: "service", service: "interview-service" }, + { id: "emily", title: "Communication roleplay", role: serviceSkill("roleplay-service"), description: "Create a realistic roleplay session.", execution: "service", service: "roleplay-service" }, + { id: "qscore", title: "Readiness Q Score", role: serviceSkill("qscore-service"), description: "Compute readiness score.", 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 }], }, { id: "career-transition", version: "1.0.0", title: "Career Transition Sprint", shortTitle: "Career Transition", promise: "Map transferable skills 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, - modules: [{ id: "transition-map", title: "Transition map", role: "OpenCode", 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: "Resume Agent", description: "Analyze resume for target path.", execution: "service", service: "resume-service" }], outputs: [{ id: "transition_map", type: "markdown", title: "Transition map" }], qscoreDimensions: ["positioning", "skills", "confidence"], approvalGates: [], + modules: [{ id: "transition-map", title: "Transition map", role: planningSkill, 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: serviceSkill("resume-service"), description: "Analyze resume for target path.", execution: "service", service: "resume-service" }], outputs: [{ id: "transition_map", type: "markdown", title: "Transition map" }], qscoreDimensions: ["positioning", "skills", "confidence"], approvalGates: [], }, { id: "salary-negotiation-war-room", version: "1.0.0", title: "Salary Negotiation War Room", shortTitle: "Negotiation", 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: ["emily"] }, requiredInputs: commonInputs, - modules: [{ id: "negotiation-script", title: "Negotiation script", role: "OpenCode", description: "Generate negotiation strategy and scripts.", execution: "opencode", promptPath: "prompts/workflows/salary-negotiation-war-room/orchestrator.md", artifactTypes: ["negotiation_script"] }, { id: "emily", title: "Negotiation roleplay", role: "Emily", description: "Create offer negotiation roleplay.", execution: "service", service: "roleplay-service" }], outputs: [{ id: "negotiation_script", type: "markdown", title: "Negotiation script" }], qscoreDimensions: ["voice", "confidence", "strategy"], approvalGates: [], + modules: [{ id: "negotiation-script", title: "Negotiation script", role: planningSkill, description: "Generate negotiation strategy and scripts.", execution: "opencode", promptPath: "prompts/workflows/salary-negotiation-war-room/orchestrator.md", artifactTypes: ["negotiation_script"] }, { id: "emily", title: "Negotiation roleplay", role: serviceSkill("roleplay-service"), description: "Create offer negotiation roleplay.", execution: "service", service: "roleplay-service" }], outputs: [{ id: "negotiation_script", type: "markdown", title: "Negotiation script" }], qscoreDimensions: ["voice", "confidence", "strategy"], approvalGates: [], }, { id: "promotion-readiness", version: "1.0.0", title: "Promotion Readiness Packet", shortTitle: "Promotion", 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: ["emily", "qscore"] }, requiredInputs: commonInputs, - modules: [{ id: "evidence-packet", title: "Evidence packet", role: "OpenCode", description: "Generate promotion evidence packet.", execution: "opencode", promptPath: "prompts/workflows/promotion-readiness/orchestrator.md", artifactTypes: ["promotion_packet"] }, { id: "emily", title: "Manager conversation roleplay", role: "Emily", description: "Practice the promotion conversation.", execution: "service", service: "roleplay-service" }], outputs: [{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" }], qscoreDimensions: ["impact", "leadership", "communication"], approvalGates: [], + modules: [{ id: "evidence-packet", title: "Evidence packet", role: planningSkill, description: "Generate promotion evidence packet.", execution: "opencode", promptPath: "prompts/workflows/promotion-readiness/orchestrator.md", artifactTypes: ["promotion_packet"] }, { id: "emily", title: "Manager conversation roleplay", role: serviceSkill("roleplay-service"), description: "Practice the promotion conversation.", execution: "service", service: "roleplay-service" }], outputs: [{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" }], qscoreDimensions: ["impact", "leadership", "communication"], approvalGates: [], }, { id: "personal-brand-opportunity-engine", version: "1.0.0", title: "Personal Brand Opportunity Engine", shortTitle: "Brand Engine", 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, - modules: [{ id: "profile-rewrite", title: "Profile rewrite", role: "OpenCode", description: "Generate LinkedIn/profile rewrite draft.", execution: "opencode", promptPath: "prompts/workflows/personal-brand-opportunity-engine/orchestrator.md", artifactTypes: ["profile_rewrite", "content_plan"] }], outputs: [{ id: "profile_rewrite", type: "markdown", title: "Profile rewrite" }, { id: "content_plan", type: "markdown", title: "Weekly content plan" }], qscoreDimensions: ["visibility", "network", "voice"], approvalGates: [], + modules: [{ id: "profile-rewrite", title: "Profile rewrite", role: planningSkill, description: "Generate LinkedIn/profile rewrite draft.", execution: "opencode", promptPath: "prompts/workflows/personal-brand-opportunity-engine/orchestrator.md", artifactTypes: ["profile_rewrite", "content_plan"] }], outputs: [{ id: "profile_rewrite", type: "markdown", title: "Profile rewrite" }, { id: "content_plan", type: "markdown", title: "Weekly content plan" }], qscoreDimensions: ["visibility", "network", "voice"], approvalGates: [], }, ]; diff --git a/src/workflows/service-capabilities.ts b/src/workflows/service-capabilities.ts index e9fe89d..ed82c45 100644 --- a/src/workflows/service-capabilities.ts +++ b/src/workflows/service-capabilities.ts @@ -1,4 +1,4 @@ -import { config } from "../config.js"; +import { listFeatureDefinitions, internalWorkflowSkills } from "../features/registry.js"; export type ServiceCapability = { id: string; @@ -7,14 +7,27 @@ export type ServiceCapability = { internalUrl?: string; publicUrl?: string; operations: string[]; + featureId?: string; + promptModulePath?: string; }; export function listServiceCapabilities(): ServiceCapability[] { return [ - { id: "resume-service", name: "Resume Agent", enabled: Boolean(config.resumeServiceUrl), internalUrl: config.resumeServiceUrl, publicUrl: config.resumePublicUrl, operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"] }, - { id: "interview-service", name: "Sara Interview", enabled: Boolean(config.interviewServiceUrl), internalUrl: config.interviewServiceUrl, publicUrl: config.interviewPublicUrl, operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"] }, - { id: "roleplay-service", name: "Emily Roleplay", enabled: Boolean(config.roleplayServiceUrl), internalUrl: config.roleplayServiceUrl, publicUrl: config.roleplayPublicUrl, operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"] }, - { id: "qscore-service", name: "Quinn Q Score", enabled: Boolean(config.qscoreServiceUrl), internalUrl: config.qscoreServiceUrl, operations: ["qscore.ingest", "qscore.compute"] }, - { id: "opencode", name: "OpenCode Artifact Executor", enabled: true, operations: ["artifact.prepare", "artifact.generate"] }, + ...listFeatureDefinitions().map((feature) => ({ + id: feature.serviceId, + name: feature.title, + enabled: feature.enabled, + internalUrl: feature.internalUrl, + publicUrl: feature.publicUrl, + operations: feature.operations, + featureId: feature.id, + promptModulePath: feature.promptModulePath, + })), + ...internalWorkflowSkills.map((skill) => ({ + id: skill.id, + name: skill.title, + enabled: true, + operations: ["artifact.prepare", "artifact.generate"], + })), ]; }