diff --git a/package.json b/package.json index 2d732ac..f8002c8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc -p tsconfig.json", "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", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "workflows:smoke": "tsx src/workflows/smoke-test.ts", diff --git a/scripts/passive-actions.test.ts b/scripts/passive-actions.test.ts new file mode 100644 index 0000000..ea41b07 --- /dev/null +++ b/scripts/passive-actions.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { careerTransitionReducer } from "../src/missions/career-transition/reducer.js"; +import { interviewToOfferReducer } from "../src/missions/interview-to-offer/reducer.js"; +import { personalBrandOpportunityReducer } from "../src/missions/personal-brand-opportunity-engine/reducer.js"; +import { promotionReadinessReducer } from "../src/missions/promotion-readiness/reducer.js"; +import { salaryNegotiationReducer } from "../src/missions/salary-negotiation-war-room/reducer.js"; +import type { GrowActiveMission } from "../src/actors/missions/types.js"; +import type { MissionReducer } from "../src/missions/reducer-types.js"; +import type { MissionReducerContext } from "../src/missions/reducer-types.js"; + +function missionFor(missionId: GrowActiveMission["missionId"], actorType: GrowActiveMission["actorType"]): GrowActiveMission { + return { + instanceId: `mission-${missionId}-test`, + missionId, + workflowId: missionId, + title: missionId, + shortTitle: missionId, + status: "active", + progressPercent: 0, + currentStageId: "resume", + actorType, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +const mission = missionFor("interview-to-offer", "interviewToOfferMissionActor"); + +function ctxWithMission(activeMission: GrowActiveMission, event: Partial & { source: string; type: string; payload?: Record }): MissionReducerContext { + return { + userId: "user_test", + activeMission, + event: { + id: `event-${activeMission.missionId}-${event.type}`, + userId: "user_test", + orgId: null, + source: event.source, + type: event.type, + category: "service", + occurredAt: new Date(), + receivedAt: new Date(), + mission: event.mission, + subject: null, + correlation: null, + payload: event.payload ?? {}, + raw: {}, + dedupeKey: null, + processingStatus: "pending", + processingError: null, + processedAt: null, + }, + qscoreSignals: [], + insight: { + summary: "test insight", + confidence: "low", + recommendedActions: [], + missionStageHints: [], + }, + }; +} + +function ctx(event: Partial & { source: string; type: string; payload?: Record }): MissionReducerContext { + return ctxWithMission(mission, event); +} + +const interviewFeedbackPayload = { + review: { + status: "completed", + overall_score: 72, + weak_areas: ["impact metrics", "ownership clarity"], + proof_gaps: ["no scale numbers"], + story_issues: ["STAR structure is loose"], + summary: "Good direction, but missing measurable proof.", + }, +}; + +const roleplayFeedbackPayload = { + review: { + status: "completed", + weak_areas: ["concision", "objection handling"], + story_gaps: ["needs clearer tradeoff story"], + summary: "Good empathy, but answers need tighter story framing.", + }, +}; + +const reducerCases: Array<{ + name: string; + reducer: MissionReducer; + mission: GrowActiveMission; +}> = [ + { + name: "interview to offer", + reducer: interviewToOfferReducer, + mission, + }, + { + name: "career transition", + reducer: careerTransitionReducer, + mission: missionFor("career-transition", "careerTransitionMissionActor"), + }, + { + name: "promotion readiness", + reducer: promotionReadinessReducer, + mission: missionFor("promotion-readiness", "promotionReadinessMissionActor"), + }, + { + name: "salary negotiation", + reducer: salaryNegotiationReducer, + mission: missionFor("salary-negotiation-war-room", "salaryNegotiationWarRoomMissionActor"), + }, + { + name: "personal brand", + reducer: personalBrandOpportunityReducer, + mission: missionFor("personal-brand-opportunity-engine", "personalBrandOpportunityMissionActor"), + }, +]; + +const resumeResult = interviewToOfferReducer.reduce(ctx({ + source: "resume-builder", + type: "resume.analysis.completed", + payload: { + analysis: { + summary: "Strong backend platform project.", + strengths: ["Built an event-driven backend"], + gaps: ["Add impact metrics"], + missing_keywords: ["Kafka", "AWS"], + }, + }, +})); + +const proofPractice = resumeResult.actions.find((action) => action.payload?.passiveAction === "resume_analysis_to_interview_practice"); +assert.ok(proofPractice, "resume analysis should create an interview practice passive action"); +assert.equal(proofPractice?.serviceId, "interview-service"); +assert.equal(proofPractice?.toolName, "interview.configure_practice"); +assert.match(String(proofPractice?.payload?.prompt), /event-driven backend/i); + +const interviewResult = interviewToOfferReducer.reduce(ctx({ + source: "interview-service", + type: "interview.feedback.generated", + payload: interviewFeedbackPayload, +})); + +const resumeUpgrade = interviewResult.actions.find((action) => action.payload?.passiveAction === "interview_feedback_to_resume_upgrade"); +assert.ok(resumeUpgrade, "interview feedback should create a resume upgrade passive action"); +assert.equal(resumeUpgrade?.mode, "approval_required"); +assert.equal(resumeUpgrade?.serviceId, "resume-service"); +assert.deepEqual(resumeUpgrade?.payload?.missingProof, ["no scale numbers"]); +assert.deepEqual(resumeUpgrade?.payload?.storyIssues, ["STAR structure is loose", "add measurable impact proof"]); + +const roleplayResult = interviewToOfferReducer.reduce(ctx({ + source: "roleplay-service", + type: "roleplay.feedback.generated", + payload: roleplayFeedbackPayload, +})); + +const storyArtifact = roleplayResult.artifacts.find((artifact) => artifact.type === "story_bank_update"); +const communicationDrill = roleplayResult.actions.find((action) => action.payload?.passiveAction === "roleplay_feedback_to_communication_drill"); +assert.ok(storyArtifact, "roleplay feedback should create a story bank artifact"); +assert.ok(communicationDrill, "roleplay feedback should create a communication drill passive action"); +assert.equal(communicationDrill?.serviceId, "interview-service"); +assert.equal(communicationDrill?.toolName, "interview.configure_practice"); +assert.deepEqual(communicationDrill?.payload?.storyIssues, ["needs clearer tradeoff story", "tighten STAR story structure"]); + +for (const testCase of reducerCases) { + const reducerResumeResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, { + source: "resume-builder", + type: "resume.analysis.completed", + payload: { + analysis: { + summary: "Strong backend platform project.", + strengths: ["Built an event-driven backend"], + gaps: ["Add impact metrics"], + missing_keywords: ["Kafka", "AWS"], + }, + }, + })); + assert.ok( + reducerResumeResult.actions.some((action) => action.payload?.passiveAction === "resume_analysis_to_interview_practice"), + `${testCase.name} resume analysis should create an interview practice passive action`, + ); + + const reducerInterviewResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, { + source: "interview-service", + type: "interview.feedback.generated", + payload: interviewFeedbackPayload, + })); + assert.ok( + reducerInterviewResult.actions.some((action) => action.payload?.passiveAction === "interview_feedback_to_resume_upgrade"), + `${testCase.name} interview feedback should create a resume upgrade passive action`, + ); + + const reducerRoleplayResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, { + source: "roleplay-service", + type: "roleplay.feedback.generated", + payload: roleplayFeedbackPayload, + })); + assert.ok( + reducerRoleplayResult.actions.some((action) => action.payload?.passiveAction === "roleplay_feedback_to_communication_drill"), + `${testCase.name} roleplay feedback should create a communication drill passive action`, + ); + assert.ok( + reducerRoleplayResult.artifacts.some((artifact) => artifact.type === "story_bank_update"), + `${testCase.name} roleplay feedback should create a story bank update artifact`, + ); +} + +console.log("passive-actions tests passed"); +process.exit(0); diff --git a/src/missions/career-transition/reducer.ts b/src/missions/career-transition/reducer.ts index 05e4588..5774c4d 100644 --- a/src/missions/career-transition/reducer.ts +++ b/src/missions/career-transition/reducer.ts @@ -1,5 +1,19 @@ import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; -import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js"; +import { + actionForAgent, + extractResumeSignals, + extractWeakAreas, + isFeedbackEvent, + isInterviewEvent, + isRelevantServiceEvent, + isResumeEvent, + isRoleplayEvent, + missionExplicitlyMatches, + passiveInterviewFeedbackResumeUpgrade, + passiveResumeAnalysisInterviewPractice, + passiveRoleplayFeedbackStoryBank, + serviceHref, +} from "../reducer-helpers.js"; export const careerTransitionReducer: MissionReducer = { missionId: "career-transition", @@ -48,6 +62,14 @@ export const careerTransitionReducer: MissionReducer = { priority: 95, urgency: "today", })); + actions.push(passiveResumeAnalysisInterviewPractice({ + missionId: "career-transition", + activeMission, + eventId: event.id, + payload, + stageId: "interview", + priority: 98, + })); eventMessage = "Transferable skills map created; repositioned resume action is ready."; } @@ -56,7 +78,7 @@ export const careerTransitionReducer: MissionReducer = { eventMessage = "Adjacent-role interview practice started."; } - if (isInterviewEvent(event.source, type) && type.includes("review_completed")) { + if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) { const weakAreas = extractWeakAreas(payload); stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Adjacent-role credibility checked." }); stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the 'why I am switching' narrative next." }); @@ -74,12 +96,30 @@ export const careerTransitionReducer: MissionReducer = { priority: 92, urgency: "today", })); + actions.push(passiveInterviewFeedbackResumeUpgrade({ + missionId: "career-transition", + activeMission, + eventId: event.id, + payload, + stageId: "resume", + priority: 104, + })); eventMessage = "Career transition interview feedback produced the next pitch-practice action."; } - if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) { + const passive = passiveRoleplayFeedbackStoryBank({ + missionId: "career-transition", + activeMission, + eventId: event.id, + payload, + stageId: "roleplay", + priority: 94, + }); stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Transition pitch practice reviewed." }); stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Transition confidence signals updated." }); + artifacts.push(passive.artifact); + actions.push(passive.action); eventMessage = "Transition narrative practice completed."; } diff --git a/src/missions/interview-to-offer/reducer.ts b/src/missions/interview-to-offer/reducer.ts index 3a77e54..f30fcd3 100644 --- a/src/missions/interview-to-offer/reducer.ts +++ b/src/missions/interview-to-offer/reducer.ts @@ -2,8 +2,11 @@ import { getString } from "../../events/envelope.js"; import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; import { actionForAgent, + extractMissingProof, extractOverallScore, + extractResumeProofPoints, extractResumeSignals, + extractStoryIssues, extractWeakAreas, isInterviewEvent, isQscoreEvent, @@ -27,6 +30,10 @@ function reviewSummary(payload: Record) { return summary ?? "Mock interview review completed."; } +function isFeedbackEvent(type: string) { + return type.includes("review_completed") || type.includes("review.completed") || type.includes("feedback.generated"); +} + export const interviewToOfferReducer: MissionReducer = { missionId: "interview-to-offer", accepts: acceptsMission, @@ -47,6 +54,7 @@ export const interviewToOfferReducer: MissionReducer = { if (isResumeEvent(event.source, type) && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) { const signals = extractResumeSignals(payload); + const proofPoints = extractResumeProofPoints(payload); stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume talking points and fit scan are ready." }); stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0 }); artifacts.push({ @@ -69,6 +77,29 @@ export const interviewToOfferReducer: MissionReducer = { priority: 92, urgency: "today", })); + actions.push(actionForAgent("interview-to-offer", "interview", { + stageId: "interview", + serviceId: "interview-service", + toolName: "interview.configure_practice", + mode: "suggestion", + title: "Practice explaining your strongest resume proof", + body: proofPoints.strengths.length + ? `Run a mock focused on ${proofPoints.strengths.slice(0, 2).join(" and ")} so your resume turns into interview-ready stories.` + : "Run a resume-led mock interview so your strongest proof turns into interview-ready stories.", + payload: { + passiveAction: "resume_analysis_to_interview_practice", + resumeSignals: signals, + proofPoints, + prompt: proofPoints.strengths[0] + ? `Practice explaining ${proofPoints.strengths[0]} with clear ownership, impact, and tradeoffs.` + : "Practice explaining your strongest resume project with clear ownership, impact, and tradeoffs.", + href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview"), + }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:resume-analysis:proof-interview:${event.id}`, + priority: 98, + urgency: "today", + })); eventMessage = "Resume fit scan completed; mock interview is ready to run."; } @@ -82,8 +113,10 @@ export const interviewToOfferReducer: MissionReducer = { eventMessage = "Mock interview completed; waiting for review."; } - if (isInterviewEvent(event.source, type) && (type.includes("review_completed") || type.includes("review.completed"))) { + if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) { const weakAreas = extractWeakAreas(payload); + const missingProof = extractMissingProof(payload); + const storyIssues = extractStoryIssues(payload); stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: reviewSummary(payload) }); stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the communication gaps surfaced by interview feedback." }); stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 60, outputSummary: "Readiness signals updated from interview review." }); @@ -99,14 +132,27 @@ export const interviewToOfferReducer: MissionReducer = { serviceId: "resume-service", toolName: "resume.create_version_prompt_draft", mode: "approval_required", - title: "Create a tailored resume version from this interview feedback?", - body: weakAreas.length - ? `The interview exposed ${weakAreas.slice(0, 3).join(", ")}. Approve a Resume Agent draft that turns this feedback into targeted bullets and talking points.` - : "Approve a Resume Agent draft that turns the interview feedback into targeted bullets and talking points.", - payload: { weakAreas, sourceReviewEventId: event.id, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") }, + title: "Draft stronger resume bullets from interview feedback?", + body: [...weakAreas, ...missingProof, ...storyIssues].length + ? `Approve a Resume Agent draft that fixes ${[...weakAreas, ...missingProof, ...storyIssues].slice(0, 3).join(", ")} with stronger bullets and proof.` + : "Approve a Resume Agent draft that turns the interview feedback into stronger bullets and talking points.", + prompt: "Create a resume upgrade draft from this interview feedback. Focus on measurable impact, ownership, missing proof, and reusable talking points.", + payload: { + passiveAction: "interview_feedback_to_resume_upgrade", + weakAreas, + missingProof, + storyIssues, + sourceReviewEventId: event.id, + draftInstructions: [ + "Rewrite weak bullets with action, scope, metric, and result.", + "Add proof for any interview gaps that lacked evidence.", + "Create talking points that match the feedback themes.", + ], + href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume"), + }, sourceEventId: event.id, idempotencyKey: `${activeMission.instanceId}:interview-review:tailor-resume:${event.id}`, - priority: 100, + priority: 108, urgency: "now", })); if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) { @@ -127,9 +173,45 @@ export const interviewToOfferReducer: MissionReducer = { eventMessage = "Interview review completed; resume and roleplay next actions were created."; } - if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) { + const weakAreas = extractWeakAreas(payload); + const storyIssues = extractStoryIssues(payload); stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Communication drill reviewed." }); stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Communication readiness updated." }); + artifacts.push({ + type: "story_bank_update", + title: "Story bank updates from roleplay feedback", + stageId: "roleplay", + summary: [...weakAreas, ...storyIssues].length + ? `Turn these into reusable STAR stories: ${[...weakAreas, ...storyIssues].slice(0, 5).join(", ")}` + : "Roleplay feedback captured story bank improvements for future interviews.", + metadata: { sourceEventId: event.id, weakAreas, storyIssues, payload }, + }); + actions.push(actionForAgent("interview-to-offer", "interview", { + stageId: "interview", + serviceId: "interview-service", + toolName: "interview.configure_practice", + mode: "suggestion", + title: "Run a story-bank recovery mock", + body: [...weakAreas, ...storyIssues].length + ? `Practice the communication gaps from roleplay: ${[...weakAreas, ...storyIssues].slice(0, 3).join(", ")}.` + : "Run a targeted mock interview that converts roleplay feedback into reusable STAR stories.", + payload: { + passiveAction: "roleplay_feedback_to_communication_drill", + weakAreas, + storyIssues, + storyBankInstructions: [ + "Convert each weak communication moment into a STAR story prompt.", + "Practice the answer in an interview setting.", + "Save the strongest version as reusable story-bank material.", + ], + href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview"), + }, + sourceEventId: event.id, + idempotencyKey: `${activeMission.instanceId}:roleplay-review:story-bank-interview:${event.id}`, + priority: 96, + urgency: "today", + })); eventMessage = "Roleplay review improved interview communication readiness."; } diff --git a/src/missions/personal-brand-opportunity-engine/reducer.ts b/src/missions/personal-brand-opportunity-engine/reducer.ts index b64b429..295292b 100644 --- a/src/missions/personal-brand-opportunity-engine/reducer.ts +++ b/src/missions/personal-brand-opportunity-engine/reducer.ts @@ -1,5 +1,20 @@ import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; -import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js"; +import { + actionForAgent, + extractResumeSignals, + extractWeakAreas, + isFeedbackEvent, + isInterviewEvent, + isRelevantServiceEvent, + isResumeEvent, + isRoleplayEvent, + missionDetailHref, + missionExplicitlyMatches, + passiveInterviewFeedbackResumeUpgrade, + passiveResumeAnalysisInterviewPractice, + passiveRoleplayFeedbackStoryBank, + serviceHref, +} from "../reducer-helpers.js"; export const personalBrandOpportunityReducer: MissionReducer = { missionId: "personal-brand-opportunity-engine", @@ -48,14 +63,31 @@ export const personalBrandOpportunityReducer: MissionReducer = { priority: 92, urgency: "today", })); + actions.push(passiveResumeAnalysisInterviewPractice({ + missionId: "personal-brand-opportunity-engine", + activeMission, + eventId: event.id, + payload, + stageId: "interview", + priority: 90, + })); eventMessage = "Resume proof points created a profile positioning action."; } - if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) { const weakAreas = extractWeakAreas(payload); + const passive = passiveRoleplayFeedbackStoryBank({ + missionId: "personal-brand-opportunity-engine", + activeMission, + eventId: event.id, + payload, + stageId: "roleplay", + priority: 92, + }); stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Networking pitch reviewed." }); stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Brand voice/readiness signals updated." }); artifacts.push({ type: "networking_scripts", title: "Networking script improvements", stageId: "roleplay", summary: weakAreas.length ? `Improve: ${weakAreas.join(", ")}` : "Networking pitch practice completed.", metadata: { sourceEventId: event.id, weakAreas } }); + artifacts.push(passive.artifact); actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", { stageId: "positioning", mode: "suggestion", @@ -67,13 +99,22 @@ export const personalBrandOpportunityReducer: MissionReducer = { priority: 82, urgency: "soon", })); + actions.push(passive.action); eventMessage = "Networking pitch review created brand content next steps."; } - if (isInterviewEvent(event.source, type) && type.includes("review_completed")) { + if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) { const weakAreas = extractWeakAreas(payload); stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Credibility signals mined from interview review." }); artifacts.push({ type: "credibility_signal_map", title: "Credibility signal map", stageId: "interview", summary: weakAreas.length ? `Recurring gaps/themes: ${weakAreas.join(", ")}` : "Interview review mined for positioning signals.", metadata: { sourceEventId: event.id, weakAreas } }); + actions.push(passiveInterviewFeedbackResumeUpgrade({ + missionId: "personal-brand-opportunity-engine", + activeMission, + eventId: event.id, + payload, + stageId: "resume", + priority: 98, + })); eventMessage = "Interview feedback was mined for brand positioning signals."; } diff --git a/src/missions/promotion-readiness/reducer.ts b/src/missions/promotion-readiness/reducer.ts index 405fdad..f13395e 100644 --- a/src/missions/promotion-readiness/reducer.ts +++ b/src/missions/promotion-readiness/reducer.ts @@ -1,5 +1,19 @@ import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; -import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js"; +import { + actionForAgent, + extractResumeSignals, + extractWeakAreas, + isFeedbackEvent, + isInterviewEvent, + isRelevantServiceEvent, + isResumeEvent, + isRoleplayEvent, + missionExplicitlyMatches, + passiveInterviewFeedbackResumeUpgrade, + passiveResumeAnalysisInterviewPractice, + passiveRoleplayFeedbackStoryBank, + serviceHref, +} from "../reducer-helpers.js"; export const promotionReadinessReducer: MissionReducer = { missionId: "promotion-readiness", @@ -48,14 +62,31 @@ export const promotionReadinessReducer: MissionReducer = { priority: 94, urgency: "today", })); + actions.push(passiveResumeAnalysisInterviewPractice({ + missionId: "promotion-readiness", + activeMission, + eventId: event.id, + payload, + stageId: "interview", + priority: 91, + })); eventMessage = "Promotion evidence packet is ready; manager conversation practice is next."; } - if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) { const weakAreas = extractWeakAreas(payload); + const passive = passiveRoleplayFeedbackStoryBank({ + missionId: "promotion-readiness", + activeMission, + eventId: event.id, + payload, + stageId: "roleplay", + priority: 95, + }); stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Manager conversation drill reviewed." }); stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Practice leadership narratives next if gaps remain." }); artifacts.push({ type: "manager_conversation_script", title: "Manager conversation script", stageId: "roleplay", summary: weakAreas.length ? `Follow-up focus: ${weakAreas.join(", ")}` : "Manager conversation review completed.", metadata: { sourceEventId: event.id, weakAreas } }); + artifacts.push(passive.artifact); actions.push(actionForAgent("promotion-readiness", "interview", { stageId: "interview", serviceId: "interview-service", @@ -69,14 +100,23 @@ export const promotionReadinessReducer: MissionReducer = { priority: 86, urgency: "soon", })); + actions.push(passive.action); eventMessage = "Manager conversation review updated promotion readiness."; } - if (isInterviewEvent(event.source, type) && type.includes("review_completed")) { + if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) { const weakAreas = extractWeakAreas(payload); stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Leadership communication gap check completed." }); stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Leadership readiness signals updated." }); artifacts.push({ type: "leadership_gap_map", title: "Leadership gap map", stageId: "interview", summary: weakAreas.length ? weakAreas.join(", ") : "Leadership practice review completed.", metadata: { sourceEventId: event.id, weakAreas } }); + actions.push(passiveInterviewFeedbackResumeUpgrade({ + missionId: "promotion-readiness", + activeMission, + eventId: event.id, + payload, + stageId: "resume", + priority: 102, + })); eventMessage = "Leadership practice review updated the promotion gap map."; } diff --git a/src/missions/reducer-helpers.ts b/src/missions/reducer-helpers.ts index 4bc1548..8ba9a55 100644 --- a/src/missions/reducer-helpers.ts +++ b/src/missions/reducer-helpers.ts @@ -1,6 +1,7 @@ import { asRecord, getNumber, getString } from "../events/envelope.js"; import { buildServiceLink } from "../services/service-registry.js"; -import type { MissionActionPatch } from "./reducer-types.js"; +import type { GrowActiveMission } from "../actors/missions/types.js"; +import type { MissionActionPatch, MissionArtifactPatch } from "./reducer-types.js"; export function isResumeEvent(source: string, type: string) { const value = source.toLowerCase(); @@ -22,6 +23,10 @@ export function isQscoreEvent(source: string, type: string) { return value.includes("qscore") || type.startsWith("qscore."); } +export function isFeedbackEvent(type: string) { + return type.includes("review_completed") || type.includes("review.completed") || type.includes("feedback.generated"); +} + export function reviewRecord(payload: Record) { return asRecord(payload.review ?? payload.result ?? payload.data ?? payload); } @@ -66,6 +71,64 @@ export function extractWeakAreas(payload: Record): string[] { return Array.from(new Set(areas)).slice(0, 5); } +function extractStringListFromKeys(record: Record, keys: string[]) { + const values: string[] = []; + for (const key of keys) { + const value = record[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === "string" && item.trim()) values.push(item.trim()); + else if (item && typeof item === "object" && !Array.isArray(item)) { + const row = item as Record; + const text = getString(row.title ?? row.name ?? row.label ?? row.summary ?? row.description ?? row.text); + if (text) values.push(text); + } + } + } else if (typeof value === "string" && value.trim()) { + values.push(...value.split(/[;\n]/).map((part) => part.trim()).filter(Boolean)); + } + } + return Array.from(new Set(values)).slice(0, 8); +} + +export function extractMissingProof(payload: Record): string[] { + const review = reviewRecord(payload); + return extractStringListFromKeys(review, [ + "missing_proof", + "missingProof", + "proof_gaps", + "proofGaps", + "evidence_gaps", + "evidenceGaps", + "missing_evidence", + "missingEvidence", + "gaps", + ]); +} + +export function extractStoryIssues(payload: Record): string[] { + const review = reviewRecord(payload); + const values = extractStringListFromKeys(review, [ + "story_issues", + "storyIssues", + "story_gaps", + "storyGaps", + "star_gaps", + "starGaps", + "communication_gaps", + "communicationGaps", + "recommendations", + ]); + const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback); + if (summary) { + const lower = summary.toLowerCase(); + if (lower.includes("star") || lower.includes("story")) values.push("tighten STAR story structure"); + if (lower.includes("metric") || lower.includes("impact") || lower.includes("measurable")) values.push("add measurable impact proof"); + if (lower.includes("ownership")) values.push("clarify ownership and scope"); + } + return Array.from(new Set(values)).slice(0, 8); +} + export function extractResumeSignals(payload: Record): string[] { const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload); const signals: string[] = []; @@ -80,6 +143,14 @@ export function extractResumeSignals(payload: Record): string[] return signals.slice(0, 8); } +export function extractResumeProofPoints(payload: Record) { + const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload); + const strengths = extractStringListFromKeys(analysis, ["strengths", "top_strengths", "strong_projects", "projects", "achievements"]); + const gaps = extractStringListFromKeys(analysis, ["gaps", "recommendations", "missing_keywords", "keyword_gaps", "weak_bullets"]); + const keywords = extractStringListFromKeys(analysis, ["keywords", "matched_keywords", "missing_keywords", "keyword_gaps"]); + return { strengths, gaps, keywords }; +} + export function missionExplicitlyMatches(eventMission: unknown, missionId: string) { const mission = asRecord(eventMission); const explicit = getString(mission.missionId ?? mission.mission_id); @@ -144,3 +215,127 @@ export function serviceHref(service: "resume" | "interview" | "roleplay" | "qsco export function missionDetailHref(missionInstanceId: string) { return `/missions/${encodeURIComponent(missionInstanceId)}`; } + +export function passiveResumeAnalysisInterviewPractice(input: { + missionId: string; + activeMission: GrowActiveMission; + eventId: string; + payload: Record; + stageId?: string; + priority?: number; +}): MissionActionPatch { + const signals = extractResumeSignals(input.payload); + const proofPoints = extractResumeProofPoints(input.payload); + return actionForAgent(input.missionId, "interview", { + stageId: input.stageId ?? "interview", + serviceId: "interview-service", + toolName: "interview.configure_practice", + mode: "suggestion", + title: "Practice explaining your strongest resume proof", + body: proofPoints.strengths.length + ? `Run a mock focused on ${proofPoints.strengths.slice(0, 2).join(" and ")} so your resume turns into interview-ready stories.` + : "Run a resume-led mock interview so your strongest proof turns into interview-ready stories.", + payload: { + passiveAction: "resume_analysis_to_interview_practice", + resumeSignals: signals, + proofPoints, + prompt: proofPoints.strengths[0] + ? `Practice explaining ${proofPoints.strengths[0]} with clear ownership, impact, and tradeoffs.` + : "Practice explaining your strongest resume project with clear ownership, impact, and tradeoffs.", + href: serviceHref("interview", input.activeMission.instanceId, input.activeMission.missionId, input.stageId ?? "interview"), + }, + sourceEventId: input.eventId, + idempotencyKey: `${input.activeMission.instanceId}:resume-analysis:proof-interview:${input.eventId}`, + priority: input.priority ?? 98, + urgency: "today", + }); +} + +export function passiveInterviewFeedbackResumeUpgrade(input: { + missionId: string; + activeMission: GrowActiveMission; + eventId: string; + payload: Record; + stageId?: string; + priority?: number; +}): MissionActionPatch { + const weakAreas = extractWeakAreas(input.payload); + const missingProof = extractMissingProof(input.payload); + const storyIssues = extractStoryIssues(input.payload); + return actionForAgent(input.missionId, "resume", { + stageId: input.stageId ?? "resume", + serviceId: "resume-service", + toolName: "resume.create_version_prompt_draft", + mode: "approval_required", + title: "Draft stronger resume bullets from interview feedback?", + body: [...weakAreas, ...missingProof, ...storyIssues].length + ? `Approve a Resume Agent draft that fixes ${[...weakAreas, ...missingProof, ...storyIssues].slice(0, 3).join(", ")} with stronger bullets and proof.` + : "Approve a Resume Agent draft that turns the interview feedback into stronger bullets and talking points.", + prompt: "Create a resume upgrade draft from this interview feedback. Focus on measurable impact, ownership, missing proof, and reusable talking points.", + payload: { + passiveAction: "interview_feedback_to_resume_upgrade", + weakAreas, + missingProof, + storyIssues, + sourceReviewEventId: input.eventId, + draftInstructions: [ + "Rewrite weak bullets with action, scope, metric, and result.", + "Add proof for any interview gaps that lacked evidence.", + "Create talking points that match the feedback themes.", + ], + href: serviceHref("resume", input.activeMission.instanceId, input.activeMission.missionId, input.stageId ?? "resume"), + }, + sourceEventId: input.eventId, + idempotencyKey: `${input.activeMission.instanceId}:interview-review:tailor-resume:${input.eventId}`, + priority: input.priority ?? 108, + urgency: "now", + }); +} + +export function passiveRoleplayFeedbackStoryBank(input: { + missionId: string; + activeMission: GrowActiveMission; + eventId: string; + payload: Record; + stageId?: string; + priority?: number; +}): { artifact: MissionArtifactPatch; action: MissionActionPatch } { + const weakAreas = extractWeakAreas(input.payload); + const storyIssues = extractStoryIssues(input.payload); + return { + artifact: { + type: "story_bank_update", + title: "Story bank updates from roleplay feedback", + stageId: input.stageId ?? "roleplay", + summary: [...weakAreas, ...storyIssues].length + ? `Turn these into reusable STAR stories: ${[...weakAreas, ...storyIssues].slice(0, 5).join(", ")}` + : "Roleplay feedback captured story bank improvements for future interviews.", + metadata: { sourceEventId: input.eventId, weakAreas, storyIssues, payload: input.payload }, + }, + action: actionForAgent(input.missionId, "interview", { + stageId: "interview", + serviceId: "interview-service", + toolName: "interview.configure_practice", + mode: "suggestion", + title: "Run a story-bank recovery mock", + body: [...weakAreas, ...storyIssues].length + ? `Practice the communication gaps from roleplay: ${[...weakAreas, ...storyIssues].slice(0, 3).join(", ")}.` + : "Run a targeted mock interview that converts roleplay feedback into reusable STAR stories.", + payload: { + passiveAction: "roleplay_feedback_to_communication_drill", + weakAreas, + storyIssues, + storyBankInstructions: [ + "Convert each weak communication moment into a STAR story prompt.", + "Practice the answer in an interview setting.", + "Save the strongest version as reusable story-bank material.", + ], + href: serviceHref("interview", input.activeMission.instanceId, input.activeMission.missionId, "interview"), + }, + sourceEventId: input.eventId, + idempotencyKey: `${input.activeMission.instanceId}:roleplay-review:story-bank-interview:${input.eventId}`, + priority: input.priority ?? 96, + urgency: "today", + }), + }; +} diff --git a/src/missions/salary-negotiation-war-room/reducer.ts b/src/missions/salary-negotiation-war-room/reducer.ts index 6aec1f3..a92c400 100644 --- a/src/missions/salary-negotiation-war-room/reducer.ts +++ b/src/missions/salary-negotiation-war-room/reducer.ts @@ -1,5 +1,19 @@ import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js"; -import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js"; +import { + actionForAgent, + extractResumeSignals, + extractWeakAreas, + isFeedbackEvent, + isInterviewEvent, + isRelevantServiceEvent, + isResumeEvent, + isRoleplayEvent, + missionExplicitlyMatches, + passiveInterviewFeedbackResumeUpgrade, + passiveResumeAnalysisInterviewPractice, + passiveRoleplayFeedbackStoryBank, + serviceHref, +} from "../reducer-helpers.js"; export const salaryNegotiationReducer: MissionReducer = { missionId: "salary-negotiation-war-room", @@ -48,6 +62,14 @@ export const salaryNegotiationReducer: MissionReducer = { priority: 96, urgency: "today", })); + actions.push(passiveResumeAnalysisInterviewPractice({ + missionId: "salary-negotiation-war-room", + activeMission, + eventId: event.id, + payload, + stageId: "interview", + priority: 88, + })); eventMessage = "Value evidence is ready for negotiation practice."; } @@ -56,11 +78,20 @@ export const salaryNegotiationReducer: MissionReducer = { eventMessage = "Negotiation drill started."; } - if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) { + if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) { const weakAreas = extractWeakAreas(payload); + const passive = passiveRoleplayFeedbackStoryBank({ + missionId: "salary-negotiation-war-room", + activeMission, + eventId: event.id, + payload, + stageId: "roleplay", + priority: 93, + }); stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Negotiation drill reviewed." }); stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Confidence signals updated." }); artifacts.push({ type: "negotiation_objection_map", title: "Objection handling map", stageId: "roleplay", summary: weakAreas.length ? `Practice objections: ${weakAreas.join(", ")}` : "Negotiation practice review completed.", metadata: { sourceEventId: event.id, weakAreas } }); + artifacts.push(passive.artifact); actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", { stageId: "roleplay", serviceId: "roleplay-service", @@ -74,11 +105,20 @@ export const salaryNegotiationReducer: MissionReducer = { priority: 94, urgency: "today", })); + actions.push(passive.action); eventMessage = "Negotiation drill review created the next objection-handling action."; } - if (isInterviewEvent(event.source, type) && type.includes("review_completed")) { + if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) { stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Communication confidence signal captured from interview review." }); + actions.push(passiveInterviewFeedbackResumeUpgrade({ + missionId: "salary-negotiation-war-room", + activeMission, + eventId: event.id, + payload, + stageId: "resume", + priority: 99, + })); eventMessage = "Interview feedback updated negotiation confidence signals."; }