From 0394dfbca0a554d232be9f709c756e170037bf29 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Thu, 2 Jul 2026 13:18:49 +0000 Subject: [PATCH] Verify static curator handoff registry --- package.json | 1 + scripts/curator-static-registry.test.ts | 112 +++++++++++++++++++++ src/v1/curator/curator-actor.ts | 2 +- src/v1/curator/curator-routes.ts | 5 +- src/v1/curator/curator-store.ts | 123 ++++++++++++++---------- 5 files changed, 189 insertions(+), 54 deletions(-) create mode 100644 scripts/curator-static-registry.test.ts diff --git a/package.json b/package.json index f8002c8..4ee2daa 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "test:onboarding": "tsx scripts/onboarding-ledger.test.ts", "test:missions": "tsx scripts/mission-lifecycle.test.ts", "test:passive-actions": "tsx scripts/passive-actions.test.ts", + "test:curator-static": "tsx scripts/curator-static-registry.test.ts", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "workflows:smoke": "tsx src/workflows/smoke-test.ts", diff --git a/scripts/curator-static-registry.test.ts b/scripts/curator-static-registry.test.ts new file mode 100644 index 0000000..48cb5f9 --- /dev/null +++ b/scripts/curator-static-registry.test.ts @@ -0,0 +1,112 @@ +import { buildServiceCurationPreview } from "../src/v1/curator/curator-store.js"; +import type { CuratorIcpId } from "../src/v1/curator/curator-icp-playbooks.js"; + +const ICP_IDS: CuratorIcpId[] = [ + "student_recent_grad", + "intern", + "fresher_early_professional", + "experienced_professional", + "freelancer", + "founder", + "enterprise", +]; + +const EXPECTED_SERVICES = new Set([ + "courses-service", + "resume-service", + "interview-service", + "roleplay-service", +]); + +const EXPECTED_ROUTE_PREFIX: Record = { + "courses-service": "/agents/courses", + "resume-service": "/agents/resume", + "interview-service": "/agents/interview/preview", + "roleplay-service": "/agents/roleplay/builder", +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function routeFor(route: string) { + return new URL(route, "https://app.sai-onchain.me"); +} + +async function main() { + const startDate = "2026-07-02"; + const failures: string[] = []; + + for (const icpId of ICP_IDS) { + const preview = await buildServiceCurationPreview({ + userId: `curator-static-registry-test-${icpId}`, + startDate, + icpId, + userContext: { targetRole: "Product Manager" }, + }); + + try { + assert(preview.version === "icp-v7-static", `${icpId}: expected static plan version`); + assert(preview.idempotency.promptHash === "static-task-registry", `${icpId}: prompt hash should prove static registry mode`); + assert(preview.plan.days.length === 30, `${icpId}: expected 30 generated days`); + assert(preview.plan.calendarWeeks.length === 5, `${icpId}: expected 5 sprint-relative calendar weeks`); + + const firstSevenFocus = preview.plan.days.slice(0, 7).map((day) => day.focus); + const secondSevenFocus = preview.plan.days.slice(7, 14).map((day) => day.focus); + assert(JSON.stringify(firstSevenFocus) === JSON.stringify(secondSevenFocus), `${icpId}: day-wise static sprint should repeat predictably after day 7`); + + for (const day of preview.plan.days) { + assert(day.generationStatus === "seeded", `${icpId} day ${day.dayIndex}: generation status must stay seeded`); + assert(!day.adaptationReason, `${icpId} day ${day.dayIndex}: adaptation reason should be absent`); + assert(day.tasks.length === 4, `${icpId} day ${day.dayIndex}: expected exactly 4 task objects`); + + const services = new Set(day.tasks.map((task) => task.serviceId)); + for (const serviceId of EXPECTED_SERVICES) { + assert(services.has(serviceId), `${icpId} day ${day.dayIndex}: missing ${serviceId}`); + } + assert(services.size === 4, `${icpId} day ${day.dayIndex}: expected exactly four unique services`); + + const taskIds = new Set(day.tasks.map((task) => task.id)); + const stageIds = new Set(day.tasks.map((task) => task.stageId)); + assert(taskIds.size === 4, `${icpId} day ${day.dayIndex}: task IDs must be unique`); + assert(stageIds.size === 4, `${icpId} day ${day.dayIndex}: stage IDs must be unique`); + + for (const task of day.tasks) { + assert(task.missionId === "curator-sprint", `${icpId} ${task.id}: missionId mismatch`); + assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v7-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`); + assert(task.stageId?.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: stageId should include service segment`); + assert(task.id.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: taskId should include service segment`); + assert(task.actorName !== "Vera" && task.actorName !== "Kai" && task.actorName !== "Mira" && task.actorName !== "Lyra", `${icpId} ${task.id}: agent persona leaked into actorName`); + + const url = routeFor(task.route); + const expectedPrefix = task.serviceId ? EXPECTED_ROUTE_PREFIX[task.serviceId] : undefined; + assert(expectedPrefix && url.pathname === expectedPrefix, `${icpId} ${task.id}: route ${url.pathname} does not match ${expectedPrefix}`); + assert(url.searchParams.get("source") === "curator-v1", `${icpId} ${task.id}: route missing curator source`); + assert(url.searchParams.get("curatorTaskId") === task.id, `${icpId} ${task.id}: route curatorTaskId mismatch`); + assert(url.searchParams.get("missionId") === task.missionId, `${icpId} ${task.id}: route missionId mismatch`); + assert(url.searchParams.get("missionInstanceId") === task.missionInstanceId, `${icpId} ${task.id}: route missionInstanceId mismatch`); + assert(url.searchParams.get("stageId") === task.stageId, `${icpId} ${task.id}: route stageId mismatch`); + + if (task.serviceId === "interview-service" || task.serviceId === "roleplay-service") { + assert(url.searchParams.get("role") === "Product Manager", `${icpId} ${task.id}: practice route should preserve target role`); + assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: practice route should carry duration`); + } + } + } + } catch (err) { + failures.push(err instanceof Error ? err.message : String(err)); + } + } + + if (failures.length) { + console.error(failures.join("\n")); + process.exit(1); + } + + console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 30 days each, 4 service handoffs per day`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/v1/curator/curator-actor.ts b/src/v1/curator/curator-actor.ts index e6dbf30..c6ff322 100644 --- a/src/v1/curator/curator-actor.ts +++ b/src/v1/curator/curator-actor.ts @@ -25,7 +25,7 @@ function addDaysIsoLocal(startDate: string, days: number) { } function dateFromTaskId(taskId: string) { - const match = /:icp-v\d+:(\d{4}-\d{2}-\d{2}):day-(\d+):/.exec(taskId); + const match = /:icp-v[^:]+:(\d{4}-\d{2}-\d{2}):day-(\d+):/.exec(taskId); if (!match) return undefined; const startDate = match[1]; const daySegment = match[2]; diff --git a/src/v1/curator/curator-routes.ts b/src/v1/curator/curator-routes.ts index 5f67de6..cc281f4 100644 --- a/src/v1/curator/curator-routes.ts +++ b/src/v1/curator/curator-routes.ts @@ -4,6 +4,7 @@ import { createClient, type Client } from "rivetkit/client"; import { requireUser, type AuthContext } from "../../auth/clerk.js"; import { config } from "../../config.js"; import type { Registry } from "../../actors/registry.js"; +import { curatorService } from "./curator-actor.js"; let _client: Client | null = null; function getClient(): Client { @@ -25,7 +26,7 @@ const chatSchema = z.object({ const curationPreviewSchema = z.object({ startDate: z.string().optional(), - icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional"]).optional(), + icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional", "freelancer", "founder", "enterprise"]).optional(), goals: z.array(z.string()).optional(), userContext: z.record(z.unknown()).optional(), }); @@ -67,7 +68,7 @@ export function v1CuratorRoutes() { app.post("/curation/preview", async (c) => { const userId = c.get("userId"); const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({}))); - return c.json(await getCuratorActor(userId).previewCuration({ userId, ...body })); + return c.json(await curatorService.previewCuration({ userId, ...body })); }); app.post("/onboarding/run", async (c) => { diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index 9f163da..4cc5209 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -70,6 +70,7 @@ type WeekTemplate = { measurement: TaskSeed; proof: TaskSeed; practice: TaskSeed; + roleplay?: TaskSeed; }>; }; @@ -946,8 +947,10 @@ type StaticSprintDay = { outcome: string; resumeTitle: string; resumeTask: string; - practiceTitle: string; - practiceTask: string; + interviewTitle: string; + interviewTask: string; + roleplayTitle: string; + roleplayTask: string; learningTitle: string; learningTask: string; }; @@ -957,34 +960,39 @@ function day( outcome: string, resumeTitle: string, resumeTask: string, - practiceTitle: string, - practiceTask: string, - learningTitle: string, - learningTask: string, + interviewTitle: string, + interviewTask: string, + roleplayTitleOrLearningTitle: string, + roleplayTaskOrLearningTask: string, + learningTitle?: string, + learningTask?: string, ): StaticSprintDay { - return { focus, outcome, resumeTitle, resumeTask, practiceTitle, practiceTask, learningTitle, learningTask }; -} - -function practiceServiceFor(title: string, task: string): CuratorServiceId { - const text = `${title} ${task}`.toLowerCase(); - if ( - text.includes("manager") || - text.includes("client") || - text.includes("conflict") || - text.includes("negotiat") || - text.includes("team") || - text.includes("lead the room") || - text.includes("high stakes") || - text.includes("workplace") || - text.includes("customer") || - text.includes("stakeholder") || - text.includes("boardroom") || - text.includes("pricing") || - text.includes("contract") - ) { - return "roleplay-service"; + if (learningTitle && learningTask) { + return { + focus, + outcome, + resumeTitle, + resumeTask, + interviewTitle, + interviewTask, + roleplayTitle: roleplayTitleOrLearningTitle, + roleplayTask: roleplayTaskOrLearningTask, + learningTitle, + learningTask, + }; } - return "interview-service"; + return { + focus, + outcome, + resumeTitle, + resumeTask, + interviewTitle, + interviewTask, + roleplayTitle: "Manager Mode", + roleplayTask: "Practice one workplace conversation related to today's sprint focus.", + learningTitle: roleplayTitleOrLearningTitle, + learningTask: roleplayTaskOrLearningTask, + }; } function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate { @@ -992,7 +1000,6 @@ function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate { theme: "Static 7-Day CareerSprint", summary: "Static day-wise sprint tasks sourced from the GrowQR task library and 7-day sprint document.", days: days.map((item) => { - const practiceService = practiceServiceFor(item.practiceTitle, item.practiceTask); return { measurement: seed( "measurement", @@ -1016,13 +1023,23 @@ function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate { ), practice: seed( "practice", - practiceService, - item.practiceTitle, - item.practiceTask, + "interview-service", + item.interviewTitle, + item.interviewTask, "10 min", - practiceService === "roleplay-service" ? "+9 projected" : "+10 projected", - practiceService === "roleplay-service" ? "Open roleplay preview" : "Open interview preview", - ["static practice", item.practiceTitle.toLowerCase()], + "+10 projected", + "Open interview preview", + ["static interview", item.interviewTitle.toLowerCase()], + ), + roleplay: seed( + "practice", + "roleplay-service", + item.roleplayTitle, + item.roleplayTask, + "10 min", + "+9 projected", + "Open roleplay preview", + ["static roleplay", item.roleplayTitle.toLowerCase()], ), }; }), @@ -1136,12 +1153,14 @@ function sprintIdFor(startDate: string) { return `curator-sprint:${CURATOR_PLAN_VERSION}:${startDate}`; } -function taskIdFor(startDate: string, dayIndex: number, taskType: CuratorTaskType) { - return `${sprintIdFor(startDate)}:day-${dayIndex}:${taskType}`; +function taskIdFor(startDate: string, dayIndex: number, taskType: CuratorTaskType, serviceId?: CuratorServiceId) { + const serviceSegment = serviceId ? `:${serviceId.replace("-service", "")}` : ""; + return `${sprintIdFor(startDate)}:day-${dayIndex}:${taskType}${serviceSegment}`; } -function stageIdFor(dayIndex: number, weekIndex: number, taskType: CuratorTaskType) { - return `wk-${weekIndex}:day-${dayIndex}:${taskType}`; +function stageIdFor(dayIndex: number, weekIndex: number, taskType: CuratorTaskType, serviceId?: CuratorServiceId) { + const serviceSegment = serviceId ? `:${serviceId.replace("-service", "")}` : ""; + return `wk-${weekIndex}:day-${dayIndex}:${taskType}${serviceSegment}`; } function dateFromIso(value: string) { @@ -1311,11 +1330,13 @@ function fallbackCarryForwardWeek(templateSet: CuratorIcpTemplateSet): WeekTempl measurement: seed("measurement", "qscore-service", "Review your 30-day movement", "Check which readiness signals moved most over the sprint and what still needs work.", "5 min", "+5 projected", "Review Q Score", ["30 day review", "signal movement"]), proof: seed("proof", "resume-service", "Package your strongest proof into one asset", "Turn the best sprint outcome into a reusable bullet, story, or artifact.", "10 min", "+7 projected", "Open resume workspace", ["proof packaging", "story asset"]), practice: seed("practice", "interview-service", "Run a final closeout interview rep", "Use one realistic question to prove the sprint improved your clarity and confidence.", "10 min", "+10 projected", "Open interview preview", ["closeout rep", "confidence"]), + roleplay: seed("practice", "roleplay-service", "Run a final conversation rep", "Use one realistic conversation to prove the sprint improved your confidence under pressure.", "10 min", "+9 projected", "Open roleplay preview", ["closeout roleplay", "confidence"]), }, { measurement: seed("measurement", "qscore-service", "Choose the next readiness driver", "Pick the one signal that should define the next 30-day push.", "5 min", "+5 projected", "Review Q Score", ["next sprint", "readiness driver"]), proof: seed("proof", "social-branding-service", "Turn sprint movement into a visible update", "Package your strongest improvement into a public-safe profile or positioning update.", "10 min", "+6 projected", "Open social profile flow", ["visibility update", "carry forward"]), practice: seed("practice", "matchmaking-service", "Review the next opportunities to act on", "Use the stronger profile and practice signals to shortlist the next roles or pathways.", "10 min", "+5 projected", "Open pathways", ["next opportunities", "carry forward"]), + roleplay: seed("practice", "roleplay-service", "Practice the next high-value conversation", "Rehearse the conversation most likely to unlock the next sprint move.", "10 min", "+9 projected", "Open roleplay preview", ["next conversation", "carry forward"]), }, ...Array.from({ length: 5 }, () => lastWeek.days[lastWeek.days.length - 1] ?? lastWeek.days[0]!), ], @@ -1324,10 +1345,9 @@ function fallbackCarryForwardWeek(templateSet: CuratorIcpTemplateSet): WeekTempl function planSeedsForVariant(templateSet: CuratorIcpTemplateSet, startDate: string): PlanDaySeed[] { const carryForwardWeek = fallbackCarryForwardWeek(templateSet); - const weekTemplates = [ - ...templateSet.weeks, - ...Array.from({ length: MAX_PLAN_WEEK_COUNT - templateSet.weeks.length }, () => carryForwardWeek), - ]; + const weekTemplates = Array.from({ length: MAX_PLAN_WEEK_COUNT }, (_, index) => ( + templateSet.weeks[index % templateSet.weeks.length] ?? carryForwardWeek + )); const planDays: PlanDaySeed[] = []; for (let index = 0; index < SPRINT_DURATION_DAYS; index += 1) { @@ -1347,6 +1367,7 @@ function planSeedsForVariant(templateSet: CuratorIcpTemplateSet, startDate: stri makePlannedTask(dayTemplate.measurement, weekTemplate.theme, weekTemplate.summary), makePlannedTask(dayTemplate.proof, weekTemplate.theme, weekTemplate.summary), makePlannedTask(dayTemplate.practice, weekTemplate.theme, weekTemplate.summary), + makePlannedTask(dayTemplate.roleplay ?? seed("practice", "roleplay-service", "Manager Mode", "Practice one workplace conversation related to today’s sprint focus.", "10 min", "+9 projected", "Open roleplay preview", ["static roleplay", "manager mode"]), weekTemplate.theme, weekTemplate.summary), ], generationStatus: "seeded", }); @@ -1696,16 +1717,16 @@ function adaptCurrentDayPlan( return plan; } const previousCompleted = previous.plannedTasks.filter((task) => ( - taskCompletedByTaskId( - taskIdFor(sprintStartDate, previous.dayIndex, task.taskType), - completionEventsForService(task.serviceId), - completionRows, - ) + taskCompletedByTaskId( + taskIdFor(sprintStartDate, previous.dayIndex, task.taskType, task.serviceId), + completionEventsForService(task.serviceId), + completionRows, + ) )).length; if (previousCompleted < 3) { const previousClassifications = previous.plannedTasks.map((task) => { - const taskId = taskIdFor(sprintStartDate, previous.dayIndex, task.taskType); + const taskId = taskIdFor(sprintStartDate, previous.dayIndex, task.taskType, task.serviceId); const taskRows = rowsForTask(taskId, recentRows); return { taskId, @@ -1784,9 +1805,9 @@ function buildTask( ): CuratorTask { seedTask = normalizeActiveCuratorTaskSeed(seedTask); const dayOfWeek = dayIndexInWeek(sprintStartDate, dayIndex); - const id = taskIdFor(sprintStartDate, dayIndex, seedTask.taskType); + const id = taskIdFor(sprintStartDate, dayIndex, seedTask.taskType, seedTask.serviceId); const missionInstanceId = sprintIdFor(sprintStartDate); - const stageId = stageIdFor(dayIndex, weekIndex, seedTask.taskType); + const stageId = stageIdFor(dayIndex, weekIndex, seedTask.taskType, seedTask.serviceId); const task: CuratorTask = { id, date,