From 4024c47ea63ab26c546fb3e60da16c8ef14f730d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 20 Apr 2026 11:00:47 -0500 Subject: [PATCH] Implement missions plugin advance loop --- packages/plugins/plugin-missions/README.md | 16 +- packages/plugins/plugin-missions/package.json | 1 + .../plugins/plugin-missions/src/decompose.ts | 544 ++++++ .../plugins/plugin-missions/src/manifest.ts | 4 + .../plugin-missions/src/mission-documents.ts | 14 +- .../src/mission-initialization.ts | 632 +++++++ .../plugin-missions/src/mission-runtime.ts | 798 +++++++++ .../plugin-missions/src/mission-service.ts | 1587 +++++++++++++++++ .../plugins/plugin-missions/src/ui/index.tsx | 190 +- .../plugins/plugin-missions/src/worker.ts | 449 +++-- .../plugin-missions/tests/plugin.spec.ts | 629 ++++--- pnpm-lock.yaml | 43 + 12 files changed, 4401 insertions(+), 506 deletions(-) create mode 100644 packages/plugins/plugin-missions/src/decompose.ts create mode 100644 packages/plugins/plugin-missions/src/mission-initialization.ts create mode 100644 packages/plugins/plugin-missions/src/mission-runtime.ts create mode 100644 packages/plugins/plugin-missions/src/mission-service.ts diff --git a/packages/plugins/plugin-missions/README.md b/packages/plugins/plugin-missions/README.md index ed000ab2567..f50e1ff5ba0 100644 --- a/packages/plugins/plugin-missions/README.md +++ b/packages/plugins/plugin-missions/README.md @@ -4,24 +4,26 @@ First-party Missions plugin package for the current Paperclip alpha plugin runti This package now carries the installable mission workflow surface: -- manifest id `paperclip.missions` +- manifest id `paperclipai.plugin-missions` - scoped plugin API routes under `/api/plugins/:pluginId/api/*` - namespace migrations for mission-owned tables -- worker handlers for mission initialization, decomposition, summary, advance, and waivers +- worker handlers for mission initialization, draft summary reads, and placeholder follow-up workflow routes - package-local typecheck, test, and build verification ## Declared Surfaces - API routes - `POST /issues/:issueId/missions/init` - - `POST /issues/:issueId/decompose` - - `GET /issues/:issueId/mission/summary` - - `POST /issues/:issueId/mission/advance` - - `POST /issues/:issueId/mission/findings/:findingId/waive` + - `GET /issues/:issueId/missions/summary` + - `POST /issues/:issueId/missions/decompose` + - `POST /issues/:issueId/missions/advance` + - `POST /issues/:issueId/missions/findings/:findingKey/waive` + - `GET /missions` - UI slots + - plugin `page` - issue `taskDetailView` - issue `toolbarButton` - - plugin `dashboardWidget` + - plugin `settingsPage` ## Verify diff --git a/packages/plugins/plugin-missions/package.json b/packages/plugins/plugin-missions/package.json index 3c45be3a410..67b3b5a4325 100644 --- a/packages/plugins/plugin-missions/package.json +++ b/packages/plugins/plugin-missions/package.json @@ -32,6 +32,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@paperclipai/shared": "workspace:*", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-typescript": "^12.1.2", "@types/node": "^24.6.0", diff --git a/packages/plugins/plugin-missions/src/decompose.ts b/packages/plugins/plugin-missions/src/decompose.ts new file mode 100644 index 00000000000..9b7162a5531 --- /dev/null +++ b/packages/plugins/plugin-missions/src/decompose.ts @@ -0,0 +1,544 @@ +import type { Issue, PluginContext } from "@paperclipai/plugin-sdk"; +import { + parseMissionFeaturesDocument, + parseMissionValidationContractDocument, + type MissionFeature, + type MissionMilestone, +} from "./mission-documents.js"; + +type GeneratedIssueKind = "milestone" | "feature" | "validation"; + +type DecomposeActor = { + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; +}; + +type GeneratedIssueSpec = { + kind: GeneratedIssueKind; + key: string; + title: string; + description: string; + parentId: string; + originKind: `plugin:${string}`; + originId: string; +}; + +type DecomposedIssueResult = { + kind: GeneratedIssueKind; + key: string; + issueId: string | null; + identifier: string | null; + title: string; + created: boolean; + blockedByIssueIds: string[]; +}; + +export type MissionDecompositionResult = { + missionIssueId: string; + milestoneCount: number; + featureCount: number; + validationCount: number; + createdIssueIds: string[]; + updatedIssueIds: string[]; + issues: DecomposedIssueResult[]; +}; + +type MissionDocumentBundle = { + validationContract: IssueDocumentLike; + features: IssueDocumentLike; + missionBrief: IssueDocumentLike | null; + workerGuidelines: IssueDocumentLike | null; + services: IssueDocumentLike | null; + knowledgeBase: IssueDocumentLike | null; +}; + +type IssueDocumentLike = NonNullable>>; + +function issueReference(issue: Pick) { + if (!issue.identifier) return `\`${issue.id}\``; + const prefix = issue.identifier.split("-")[0] || "PAP"; + return `[${issue.identifier}](/${prefix}/issues/${issue.identifier})`; +} + +function billingCodeForMission(issue: Pick) { + return issue.billingCode?.trim() || `mission:${issue.identifier ?? issue.id}`; +} + +function docContextLines(docs: MissionDocumentBundle) { + return [ + docs.missionBrief ? "- Read `mission-brief` before execution." : null, + docs.workerGuidelines ? "- Follow `worker-guidelines` for execution and handoff." : null, + docs.services ? "- Use `services` for local commands and environment setup." : null, + docs.knowledgeBase ? "- Check `knowledge-base` for prior discoveries and constraints." : null, + ].filter((line): line is string => Boolean(line)); +} + +function summaryLines(summary: string) { + return [summary.trim()].filter(Boolean); +} + +function buildMilestoneDescription(input: { + mission: Pick; + milestone: MissionMilestone; + docs: MissionDocumentBundle; +}) { + const { mission, milestone, docs } = input; + return [ + `Mission milestone generated from ${issueReference(mission)}.`, + "", + `Mission key: \`${milestone.id}\``, + "", + "Summary:", + ...summaryLines(milestone.summary), + "", + "Features in scope:", + ...milestone.features.map((feature) => `- \`${feature.id}\` ${feature.title}`), + ...(milestone.depends_on.length > 0 + ? ["", "Milestone dependencies:", ...milestone.depends_on.map((id) => `- \`${id}\``)] + : []), + ...(docContextLines(docs).length > 0 ? ["", "Context documents:", ...docContextLines(docs)] : []), + ].join("\n"); +} + +function buildFeatureDescription(input: { + mission: Pick; + milestone: MissionMilestone; + feature: MissionFeature; + docs: MissionDocumentBundle; +}) { + const { mission, milestone, feature, docs } = input; + return [ + `Mission feature generated from ${issueReference(mission)}.`, + "", + `Mission key: \`${feature.id}\``, + `Milestone: \`${milestone.id}\` ${milestone.title}`, + `Kind: \`${feature.kind}\``, + "", + "Summary:", + ...summaryLines(feature.summary), + "", + "Claimed validation assertions:", + ...feature.claimed_assertion_ids.map((id) => `- \`${id}\``), + "", + "Acceptance criteria:", + ...feature.acceptance_criteria.map((criterion) => `- ${criterion}`), + ...(feature.depends_on.length > 0 + ? ["", "Depends on features:", ...feature.depends_on.map((id) => `- \`${id}\``)] + : []), + ...(docContextLines(docs).length > 0 ? ["", "Context documents:", ...docContextLines(docs)] : []), + ].join("\n"); +} + +function buildValidationDescription(input: { + mission: Pick; + milestone: MissionMilestone; + docs: MissionDocumentBundle; +}) { + const { mission, milestone, docs } = input; + const assertionIds = [...new Set(milestone.features.flatMap((feature) => feature.claimed_assertion_ids))]; + return [ + `Mission validation gate generated from ${issueReference(mission)}.`, + "", + `Mission key: \`${milestone.id}:validation\``, + `Milestone: \`${milestone.id}\` ${milestone.title}`, + "", + "Validate the milestone after every feature dependency is complete.", + "", + "Assertions in scope:", + ...assertionIds.map((assertionId) => `- \`${assertionId}\``), + ...(docContextLines(docs).length > 0 ? ["", "Context documents:", ...docContextLines(docs)] : []), + ].join("\n"); +} + +function featureOriginId(missionIssueId: string, featureId: string) { + return `${missionIssueId}:feature:${featureId}`; +} + +function milestoneOriginId(missionIssueId: string, milestoneId: string) { + return `${missionIssueId}:milestone:${milestoneId}`; +} + +function validationOriginId(missionIssueId: string, milestoneId: string) { + return `${missionIssueId}:validation:${milestoneId}`; +} + +function generatedOriginKind(ctx: PluginContext, kind: GeneratedIssueKind): `plugin:${string}` { + return `plugin:${ctx.manifest.id}:${kind}` as `plugin:${string}`; +} + +async function getMissionDocuments(ctx: PluginContext, issueId: string, companyId: string): Promise { + const [ + validationContract, + features, + missionBrief, + workerGuidelines, + services, + knowledgeBase, + ] = await Promise.all([ + ctx.issues.documents.get(issueId, "validation-contract", companyId), + ctx.issues.documents.get(issueId, "features", companyId), + ctx.issues.documents.get(issueId, "mission-brief", companyId), + ctx.issues.documents.get(issueId, "worker-guidelines", companyId), + ctx.issues.documents.get(issueId, "services", companyId), + ctx.issues.documents.get(issueId, "knowledge-base", companyId), + ]); + + if (!validationContract) throw new Error("Mission requires a validation-contract document before decomposition"); + if (!features) throw new Error("Mission requires a features document before decomposition"); + + return { + validationContract, + features, + missionBrief, + workerGuidelines, + services, + knowledgeBase, + }; +} + +async function maybeAssertCheckoutOwner(ctx: PluginContext, mission: Issue, companyId: string, actor: DecomposeActor) { + if (!actor.actorAgentId || !actor.actorRunId) return; + if (mission.status !== "in_progress") return; + await ctx.issues.assertCheckoutOwner({ + issueId: mission.id, + companyId, + actorAgentId: actor.actorAgentId, + actorRunId: actor.actorRunId, + }); +} + +async function findGeneratedIssue( + ctx: PluginContext, + companyId: string, + originKind: GeneratedIssueSpec["originKind"], + originId: string, +) { + const existing = await ctx.issues.list({ + companyId, + originKind, + originId, + limit: 1, + }); + return existing[0] ?? null; +} + +async function upsertGeneratedIssue( + ctx: PluginContext, + mission: Issue, + companyId: string, + actor: DecomposeActor, + spec: GeneratedIssueSpec, +) { + const existing = await findGeneratedIssue(ctx, companyId, spec.originKind, spec.originId); + const updatePatch = { + title: spec.title, + description: spec.description, + priority: "medium" as const, + billingCode: billingCodeForMission(mission), + originKind: spec.originKind, + originId: spec.originId, + executionWorkspaceId: mission.executionWorkspaceId ?? undefined, + executionWorkspacePreference: mission.executionWorkspacePreference ?? undefined, + executionWorkspaceSettings: + (mission.executionWorkspaceSettings as Record | null | undefined) ?? undefined, + }; + + if (existing) { + const updated = await ctx.issues.update(existing.id, updatePatch, companyId, actor); + return { issue: updated, created: false }; + } + + const created = await ctx.issues.create({ + companyId, + projectId: mission.projectId ?? undefined, + goalId: mission.goalId ?? undefined, + parentId: spec.parentId, + inheritExecutionWorkspaceFromIssueId: mission.id, + title: spec.title, + description: spec.description, + status: "todo", + priority: "medium", + billingCode: billingCodeForMission(mission), + originKind: spec.originKind, + originId: spec.originId, + executionWorkspaceId: mission.executionWorkspaceId ?? undefined, + executionWorkspacePreference: mission.executionWorkspacePreference ?? undefined, + executionWorkspaceSettings: + (mission.executionWorkspaceSettings as Record | null | undefined) ?? undefined, + actor, + }); + return { issue: created, created: true }; +} + +async function recordLink( + ctx: PluginContext, + missionIssueId: string, + spec: GeneratedIssueSpec, + generatedIssueId: string, +) { + await ctx.db.execute( + `INSERT INTO ${ctx.db.namespace}.mission_issue_links ( + mission_issue_id, + generated_issue_id, + generated_kind, + generated_key, + origin_kind, + origin_id + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (mission_issue_id, generated_kind, generated_key) DO UPDATE SET + generated_issue_id = EXCLUDED.generated_issue_id, + origin_kind = EXCLUDED.origin_kind, + origin_id = EXCLUDED.origin_id, + updated_at = now()`, + [missionIssueId, generatedIssueId, spec.kind, spec.key, spec.originKind, spec.originId], + ); +} + +export async function decomposeMission( + ctx: PluginContext, + input: { + issueId: string; + companyId: string; + dryRun?: boolean; + actor?: DecomposeActor; + }, +): Promise { + const actor = input.actor ?? {}; + const mission = await ctx.issues.get(input.issueId, input.companyId); + if (!mission) throw new Error(`Mission issue not found: ${input.issueId}`); + await maybeAssertCheckoutOwner(ctx, mission, input.companyId, actor); + + const docs = await getMissionDocuments(ctx, mission.id, input.companyId); + const validationContract = parseMissionValidationContractDocument(docs.validationContract.body ?? ""); + const featurePlan = parseMissionFeaturesDocument(docs.features.body ?? ""); + const assertionIds = new Set(validationContract.assertions.map((assertion) => assertion.id)); + + for (const milestone of featurePlan.milestones) { + for (const dependencyId of milestone.depends_on) { + if (!featurePlan.milestones.some((candidate) => candidate.id === dependencyId)) { + throw new Error(`Milestone ${milestone.id} depends on unknown milestone ${dependencyId}`); + } + } + + for (const feature of milestone.features) { + for (const assertionId of feature.claimed_assertion_ids) { + if (!assertionIds.has(assertionId)) { + throw new Error(`Feature ${feature.id} claims unknown validation assertion ${assertionId}`); + } + } + + for (const dependencyId of feature.depends_on) { + const dependencyExists = featurePlan.milestones.some((candidateMilestone) => + candidateMilestone.features.some((candidateFeature) => candidateFeature.id === dependencyId), + ); + if (!dependencyExists) { + throw new Error(`Feature ${feature.id} depends on unknown feature ${dependencyId}`); + } + } + } + } + + const milestoneCount = featurePlan.milestones.length; + const featureCount = featurePlan.milestones.reduce((count, milestone) => count + milestone.features.length, 0); + const validationCount = featurePlan.milestones.length; + + const milestoneSpecs = featurePlan.milestones.map((milestone): GeneratedIssueSpec => ({ + kind: "milestone", + key: milestone.id, + title: `Mission milestone: ${milestone.title}`, + description: buildMilestoneDescription({ mission, milestone, docs }), + parentId: mission.id, + originKind: generatedOriginKind(ctx, "milestone"), + originId: milestoneOriginId(mission.id, milestone.id), + })); + + const featureSpecs = featurePlan.milestones.flatMap((milestone) => + milestone.features.map((feature): GeneratedIssueSpec => ({ + kind: "feature", + key: feature.id, + title: `Mission feature: ${feature.title}`, + description: buildFeatureDescription({ mission, milestone, feature, docs }), + parentId: "", + originKind: generatedOriginKind(ctx, "feature"), + originId: featureOriginId(mission.id, feature.id), + })), + ); + + const validationSpecs = featurePlan.milestones.map((milestone): GeneratedIssueSpec => ({ + kind: "validation", + key: `${milestone.id}:validation`, + title: `Mission validation: ${milestone.title}`, + description: buildValidationDescription({ mission, milestone, docs }), + parentId: "", + originKind: generatedOriginKind(ctx, "validation"), + originId: validationOriginId(mission.id, milestone.id), + })); + + if (input.dryRun) { + return { + missionIssueId: mission.id, + milestoneCount, + featureCount, + validationCount, + createdIssueIds: [], + updatedIssueIds: [], + issues: [...milestoneSpecs, ...featureSpecs, ...validationSpecs].map((spec) => ({ + kind: spec.kind, + key: spec.key, + issueId: null, + identifier: null, + title: spec.title, + created: false, + blockedByIssueIds: [], + })), + }; + } + + const milestoneIssueIds = new Map(); + const featureIssueIds = new Map(); + const validationIssueIds = new Map(); + const resultIssues: DecomposedIssueResult[] = []; + const createdIssueIds: string[] = []; + const updatedIssueIds: string[] = []; + + for (const spec of milestoneSpecs) { + const { issue, created } = await upsertGeneratedIssue(ctx, mission, input.companyId, actor, spec); + await recordLink(ctx, mission.id, spec, issue.id); + milestoneIssueIds.set(spec.key, issue.id); + if (created) createdIssueIds.push(issue.id); + else updatedIssueIds.push(issue.id); + resultIssues.push({ + kind: spec.kind, + key: spec.key, + issueId: issue.id, + identifier: issue.identifier ?? null, + title: issue.title, + created, + blockedByIssueIds: [], + }); + } + + for (const milestone of featurePlan.milestones) { + const parentId = milestoneIssueIds.get(milestone.id); + if (!parentId) throw new Error(`Missing generated milestone issue for ${milestone.id}`); + + for (const feature of milestone.features) { + const spec = featureSpecs.find((candidate) => candidate.key === feature.id); + if (!spec) throw new Error(`Missing generated feature spec for ${feature.id}`); + const { issue, created } = await upsertGeneratedIssue( + ctx, + mission, + input.companyId, + actor, + { ...spec, parentId }, + ); + await recordLink(ctx, mission.id, { ...spec, parentId }, issue.id); + featureIssueIds.set(feature.id, issue.id); + if (created) createdIssueIds.push(issue.id); + else updatedIssueIds.push(issue.id); + resultIssues.push({ + kind: "feature", + key: feature.id, + issueId: issue.id, + identifier: issue.identifier ?? null, + title: issue.title, + created, + blockedByIssueIds: [], + }); + } + + const validationSpec = validationSpecs.find((candidate) => candidate.key === `${milestone.id}:validation`); + if (!validationSpec) throw new Error(`Missing validation spec for ${milestone.id}`); + const { issue, created } = await upsertGeneratedIssue( + ctx, + mission, + input.companyId, + actor, + { ...validationSpec, parentId }, + ); + await recordLink(ctx, mission.id, { ...validationSpec, parentId }, issue.id); + validationIssueIds.set(milestone.id, issue.id); + if (created) createdIssueIds.push(issue.id); + else updatedIssueIds.push(issue.id); + resultIssues.push({ + kind: "validation", + key: `${milestone.id}:validation`, + issueId: issue.id, + identifier: issue.identifier ?? null, + title: issue.title, + created, + blockedByIssueIds: [], + }); + } + + const blockersByIssueId = new Map(); + + for (const milestone of featurePlan.milestones) { + const milestoneIssueId = milestoneIssueIds.get(milestone.id); + const validationIssueId = validationIssueIds.get(milestone.id); + if (!milestoneIssueId || !validationIssueId) { + throw new Error(`Missing generated milestone or validation issue for ${milestone.id}`); + } + + const dependencyValidationIds = milestone.depends_on + .map((dependencyId) => validationIssueIds.get(dependencyId)) + .filter((value): value is string => Boolean(value)); + blockersByIssueId.set(milestoneIssueId, [...new Set([validationIssueId, ...dependencyValidationIds])]); + + const milestoneFeatureIds = milestone.features + .map((feature) => featureIssueIds.get(feature.id)) + .filter((value): value is string => Boolean(value)); + blockersByIssueId.set(validationIssueId, [...new Set(milestoneFeatureIds)]); + + for (const feature of milestone.features) { + const featureIssueId = featureIssueIds.get(feature.id); + if (!featureIssueId) throw new Error(`Missing generated feature issue for ${feature.id}`); + + const dependencyFeatureIds = feature.depends_on + .map((dependencyId) => featureIssueIds.get(dependencyId)) + .filter((value): value is string => Boolean(value)); + blockersByIssueId.set(featureIssueId, [...new Set([...dependencyFeatureIds, ...dependencyValidationIds])]); + } + } + + for (const resultIssue of resultIssues) { + if (!resultIssue.issueId) continue; + const blockedByIssueIds = blockersByIssueId.get(resultIssue.issueId) ?? []; + await ctx.issues.relations.setBlockedBy(resultIssue.issueId, blockedByIssueIds, input.companyId, actor); + await ctx.issues.update( + resultIssue.issueId, + { status: blockedByIssueIds.length > 0 ? "blocked" : "todo" }, + input.companyId, + actor, + ); + resultIssue.blockedByIssueIds = blockedByIssueIds; + } + + if (createdIssueIds.length > 0) { + const body = [ + "", + "Mission decomposition synced by the Missions plugin.", + "", + `- Created issues: ${createdIssueIds.length}`, + `- Updated issues: ${updatedIssueIds.length}`, + `- Milestones: ${milestoneCount}`, + `- Features: ${featureCount}`, + `- Validations: ${validationCount}`, + ].join("\n"); + await ctx.issues.createComment(mission.id, body, input.companyId, { + authorAgentId: actor.actorAgentId ?? undefined, + }); + } + + return { + missionIssueId: mission.id, + milestoneCount, + featureCount, + validationCount, + createdIssueIds, + updatedIssueIds: [...new Set(updatedIssueIds.filter((issueId) => !createdIssueIds.includes(issueId)))], + issues: resultIssues, + }; +} diff --git a/packages/plugins/plugin-missions/src/manifest.ts b/packages/plugins/plugin-missions/src/manifest.ts index 3488fa8ea55..8e0151d52ca 100644 --- a/packages/plugins/plugin-missions/src/manifest.ts +++ b/packages/plugins/plugin-missions/src/manifest.ts @@ -29,17 +29,21 @@ const manifest: PaperclipPluginManifestV1 = { "database.namespace.migrate", "database.namespace.read", "database.namespace.write", + "plugin.state.read", + "plugin.state.write", "issues.read", "issues.create", "issues.update", "issues.checkout", "issues.wakeup", "issues.orchestration.read", + "issue.comments.create", "issue.relations.read", "issue.relations.write", "issue.documents.read", "issue.documents.write", "issue.subtree.read", + "agents.read", "ui.page.register", "ui.detailTab.register", "ui.action.register", diff --git a/packages/plugins/plugin-missions/src/mission-documents.ts b/packages/plugins/plugin-missions/src/mission-documents.ts index e123ed325d0..62dadc51499 100644 --- a/packages/plugins/plugin-missions/src/mission-documents.ts +++ b/packages/plugins/plugin-missions/src/mission-documents.ts @@ -1,7 +1,6 @@ export { MISSION_FINDING_WAIVER_MARKER_PREFIX, MISSION_REQUIRED_DOCUMENT_KEYS, - buildMissionFindingWaiverEntry, parseMissionFeaturesDocument, parseMissionValidationContractDocument, parseMissionValidationReportDocument, @@ -16,6 +15,7 @@ export { export { isMissionRequiredDocumentKey } from "./mission/index.js"; import { + buildMissionFindingWaiverEntry as buildMissionFindingWaiverEntryBase, isMissionValidationReportKey, missionFindingWaiverMarker, parseMissionFindingWaivers as parseMissionFindingWaiversBase, @@ -77,6 +77,18 @@ export function buildMissionEventEntry(input: { }; } +export function buildMissionFindingWaiverEntry(input: { + findingId: string; + rationale: string; + actorLabel: string; + createdAt: Date | string; +}) { + return { + marker: missionFindingWaiverMarker(input.findingId), + body: buildMissionFindingWaiverEntryBase(input), + }; +} + export function appendDecisionLogEntry( existingBody: string | null | undefined, entry: { marker: string; body: string }, diff --git a/packages/plugins/plugin-missions/src/mission-initialization.ts b/packages/plugins/plugin-missions/src/mission-initialization.ts new file mode 100644 index 00000000000..83028bf10ca --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission-initialization.ts @@ -0,0 +1,632 @@ +import type { Issue, PluginContext } from "@paperclipai/plugin-sdk"; +import { + MISSION_REQUIRED_DOCUMENT_KEYS, + isValidationReportDocumentKey, + parseMissionFeaturesDocument, + parseMissionValidationContractDocument, + parseMissionValidationReportDocument, + parseValidationReportRound, + type MissionRequiredDocumentKey, + type MissionState, +} from "./mission-documents.js"; + +type MissionInitializationActor = { + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; +}; + +type PersistedMissionRecord = { + missionIssueId: string; + companyId: string; + state: MissionState; + billingCode: string | null; + rootOriginKind: string | null; + rootOriginId: string | null; + settings: Record; + initializedAt: string; + updatedAt: string; + hasRootLink: boolean; + hasInitializationEvent: boolean; +}; + +export type MissionParseProblem = { + issueId: string; + key: string; + message: string; +}; + +export type MissionDocumentChecklistItem = { + key: MissionRequiredDocumentKey; + present: boolean; + title: string | null; +}; + +export type MissionValidationReportSummary = { + key: string; + title: string | null; + round: number; + summary: string; + findingCount: number; + updatedAt: string; +}; + +export type MissionIssueSummary = { + issueId: string; + isMission: boolean; + canInitialize: boolean; + initializeDisabledReason: string | null; + state: MissionState | null; + nextAction: string; + documentChecklist: MissionDocumentChecklistItem[]; + missingRequiredDocumentKeys: MissionRequiredDocumentKey[]; + parseProblems: MissionParseProblem[]; + validationReports: MissionValidationReportSummary[]; + openFindingCount: number; + settings: { + billingCode: string | null; + rootOriginKind: string | null; + rootOriginId: string | null; + databaseNamespace: string; + requiredDocumentKeys: readonly MissionRequiredDocumentKey[]; + }; + persistence: PersistedMissionRecord | null; +}; + +export type MissionInitializationResult = { + created: boolean; + issueId: string; + createdDocumentKeys: MissionRequiredDocumentKey[]; + summary: MissionIssueSummary; +}; + +const PERSISTENCE_STATE_NAMESPACE = "mission-initialization"; +const PERSISTENCE_STATE_KEY = "persisted"; +const DEFAULT_MISSION_STATE: MissionState = "draft"; + +const REQUIRED_DOCUMENT_TITLES: Record = { + plan: "Mission Plan", + "mission-brief": "Mission Brief", + "validation-contract": "Validation Contract", + features: "Features", + "worker-guidelines": "Worker Guidelines", + services: "Services", + "knowledge-base": "Knowledge Base", + "decision-log": "Decision Log", +}; + +function pluginOriginPrefix(ctx: PluginContext) { + return `plugin:${ctx.manifest.id}`; +} + +function stringifyError(error: unknown) { + if (error instanceof Error) return error.message; + return String(error); +} + +function normalizeTimestamp(value: unknown) { + if (typeof value === "string") return value; + if (value instanceof Date) return value.toISOString(); + return new Date().toISOString(); +} + +async function getPersistedMissionFallback(ctx: PluginContext, issueId: string) { + const fallback = await ctx.state.get({ + scopeKind: "issue", + scopeId: issueId, + namespace: PERSISTENCE_STATE_NAMESPACE, + stateKey: PERSISTENCE_STATE_KEY, + }); + return fallback && typeof fallback === "object" ? fallback as PersistedMissionRecord : null; +} + +async function setPersistedMissionFallback(ctx: PluginContext, issueId: string, record: PersistedMissionRecord) { + await ctx.state.set( + { + scopeKind: "issue", + scopeId: issueId, + namespace: PERSISTENCE_STATE_NAMESPACE, + stateKey: PERSISTENCE_STATE_KEY, + }, + record, + ); +} + +async function getMissionByRootIssue(ctx: PluginContext, companyId: string, issueId: string) { + const namespace = ctx.db.namespace; + const rows = await ctx.db.query<{ + mission_issue_id: string; + company_id: string; + state: string; + billing_code: string | null; + root_origin_kind: string | null; + root_origin_id: string | null; + settings_json_text: string; + initialized_at: string | Date; + updated_at: string | Date; + has_root_link: boolean; + has_initialization_event: boolean; + }>( + `SELECT + m.mission_issue_id, + m.company_id, + m.state, + m.billing_code, + m.root_origin_kind, + m.root_origin_id, + m.settings_json::text AS settings_json_text, + m.initialized_at, + m.updated_at, + EXISTS( + SELECT 1 + FROM ${namespace}.mission_issue_links l + WHERE l.mission_issue_id = m.mission_issue_id + AND l.generated_issue_id = m.mission_issue_id + AND l.generated_kind = 'root' + AND l.generated_key = 'root' + ) AS has_root_link, + EXISTS( + SELECT 1 + FROM ${namespace}.mission_events e + WHERE e.mission_issue_id = m.mission_issue_id + AND e.event_key = 'initialized' + ) AS has_initialization_event + FROM ${namespace}.missions m + WHERE m.company_id = $1 + AND m.mission_issue_id = $2 + LIMIT 1`, + [companyId, issueId], + ); + + const row = rows[0]; + if (!row) return getPersistedMissionFallback(ctx, issueId); + + const record: PersistedMissionRecord = { + missionIssueId: row.mission_issue_id, + companyId: row.company_id, + state: (row.state as MissionState) ?? DEFAULT_MISSION_STATE, + billingCode: row.billing_code, + rootOriginKind: row.root_origin_kind, + rootOriginId: row.root_origin_id, + settings: JSON.parse(row.settings_json_text || "{}") as Record, + initializedAt: normalizeTimestamp(row.initialized_at), + updatedAt: normalizeTimestamp(row.updated_at), + hasRootLink: Boolean(row.has_root_link), + hasInitializationEvent: Boolean(row.has_initialization_event), + }; + await setPersistedMissionFallback(ctx, issueId, record); + return record; +} + +function buildDocumentBody(key: MissionRequiredDocumentKey, issue: Issue) { + switch (key) { + case "plan": + return [ + "# Mission Plan", + "", + "## Objective", + issue.title, + "", + "## Scope", + issue.description?.trim() || "Capture the concrete operator scope for this mission.", + "", + "## Risks", + "- Document the main execution risks before decomposition.", + ].join("\n"); + case "mission-brief": + return [ + "# Mission Brief", + "", + `- Root issue: ${issue.title}`, + `- Identifier: ${issue.identifier ?? issue.id}`, + `- Billing code: ${issue.billingCode ?? `mission:${issue.id}`}`, + "", + "## Desired outcome", + "Describe the operator-visible outcome this mission must deliver.", + ].join("\n"); + case "validation-contract": + return JSON.stringify( + { + assertions: [ + { + id: "VAL-MISSION-001", + title: "Mission outcome is validated", + user_value: "The mission solves the root issue for the operator.", + scope: "Root mission issue", + setup: "Replace this placeholder with the setup needed to validate the mission.", + steps: ["Replace this placeholder with concrete validation steps."], + oracle: "Reference the evidence that proves the mission outcome worked.", + tooling: ["manual_review"], + evidence: [ + { + kind: "primary", + description: "Attach the operator-facing proof artifact for the completed mission.", + required: true, + }, + ], + claimed_by: [], + status: "draft", + }, + ], + }, + null, + 2, + ); + case "features": + return JSON.stringify( + { + milestones: [ + { + id: "MILESTONE-MISSION-001", + title: "Initial mission slice", + summary: "Replace this placeholder with the first bounded milestone.", + features: [ + { + id: "FEAT-MISSION-001", + title: "Draft feature slice", + kind: "original", + summary: "Replace this placeholder with the first concrete feature or task slice.", + acceptance_criteria: ["Replace this placeholder with concrete acceptance criteria."], + claimed_assertion_ids: [], + status: "planned", + }, + ], + }, + ], + }, + null, + 2, + ); + case "worker-guidelines": + return [ + "# Worker Guidelines", + "", + "- Keep changes scoped to the root mission goal.", + "- Leave operator-readable progress in comments and documents.", + "- Capture verification evidence in the validation artifacts.", + ].join("\n"); + case "services": + return [ + "# Services", + "", + "- Record external services, APIs, and environments this mission depends on.", + ].join("\n"); + case "knowledge-base": + return [ + "# Knowledge Base", + "", + "- Capture durable discoveries, links, and references for this mission here.", + ].join("\n"); + case "decision-log": + return [ + "# Decision Log", + "", + "- Record mission-level decisions, tradeoffs, and approvals here.", + ].join("\n"); + } +} + +async function ensureMissionDocuments(ctx: PluginContext, companyId: string, issue: Issue) { + const existing = await ctx.issues.documents.list(issue.id, companyId); + const existingKeys = new Set(existing.map((document) => document.key)); + const createdDocumentKeys: MissionRequiredDocumentKey[] = []; + + for (const key of MISSION_REQUIRED_DOCUMENT_KEYS) { + if (existingKeys.has(key)) continue; + await ctx.issues.documents.upsert({ + issueId: issue.id, + companyId, + key, + title: REQUIRED_DOCUMENT_TITLES[key], + body: buildDocumentBody(key, issue), + changeSummary: "Initialized mission document bundle", + }); + createdDocumentKeys.push(key); + } + + return createdDocumentKeys; +} + +async function ensureRootIssueMetadata( + ctx: PluginContext, + companyId: string, + issue: Issue, + actor: MissionInitializationActor, +) { + const originPrefix = pluginOriginPrefix(ctx); + const originKind = issue.originKind ?? "manual"; + const canAdoptOrigin = originKind === "manual" || originKind === originPrefix || originKind.startsWith(`${originPrefix}:`); + if (!canAdoptOrigin) { + throw new Error(`Issue origin '${originKind}' is already owned by another workflow`); + } + + const patch: Parameters[1] = {}; + const finalOriginKind = originKind === "manual" ? originPrefix : originKind; + const finalOriginId = issue.originId ?? `mission:${issue.id}`; + const finalBillingCode = issue.billingCode ?? `mission:${issue.id}`; + + if (issue.originKind !== finalOriginKind) patch.originKind = finalOriginKind as Issue["originKind"]; + if (issue.originId !== finalOriginId) patch.originId = finalOriginId; + if (issue.billingCode !== finalBillingCode) patch.billingCode = finalBillingCode; + + if (Object.keys(patch).length === 0) { + return { + issue, + finalOriginKind, + finalOriginId, + finalBillingCode, + }; + } + + const updated = await ctx.issues.update(issue.id, patch, companyId, actor); + return { + issue: updated, + finalOriginKind, + finalOriginId, + finalBillingCode, + }; +} + +function buildPersistedSettings(ctx: PluginContext, issue: Issue, billingCode: string) { + return { + pluginId: ctx.manifest.id, + databaseNamespace: ctx.db.namespace, + requiredDocumentKeys: [...MISSION_REQUIRED_DOCUMENT_KEYS], + initializedFromStatus: issue.status, + rootIdentifier: issue.identifier ?? issue.id, + billingCode, + }; +} + +async function persistMissionInitialization( + ctx: PluginContext, + input: { + companyId: string; + issue: Issue; + rootOriginKind: string; + rootOriginId: string; + billingCode: string; + createdDocumentKeys: MissionRequiredDocumentKey[]; + actor: MissionInitializationActor; + }, +) { + const namespace = ctx.db.namespace; + const settings = buildPersistedSettings(ctx, input.issue, input.billingCode); + + await ctx.db.execute( + `INSERT INTO ${namespace}.missions ( + mission_issue_id, + company_id, + state, + billing_code, + root_origin_kind, + root_origin_id, + settings_json + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + ON CONFLICT (mission_issue_id) DO UPDATE SET + billing_code = EXCLUDED.billing_code, + root_origin_kind = EXCLUDED.root_origin_kind, + root_origin_id = EXCLUDED.root_origin_id, + updated_at = now()`, + [ + input.issue.id, + input.companyId, + DEFAULT_MISSION_STATE, + input.billingCode, + input.rootOriginKind, + input.rootOriginId, + JSON.stringify(settings), + ], + ); + + await ctx.db.execute( + `INSERT INTO ${namespace}.mission_issue_links ( + mission_issue_id, + generated_issue_id, + generated_kind, + generated_key, + origin_kind, + origin_id + ) VALUES ($1, $2, 'root', 'root', $3, $4) + ON CONFLICT (mission_issue_id, generated_kind, generated_key) DO UPDATE SET + origin_kind = EXCLUDED.origin_kind, + origin_id = EXCLUDED.origin_id, + updated_at = now()`, + [input.issue.id, input.issue.id, input.rootOriginKind, input.rootOriginId], + ); + + const payload = { + actorAgentId: input.actor.actorAgentId ?? null, + actorUserId: input.actor.actorUserId ?? null, + actorRunId: input.actor.actorRunId ?? null, + createdDocumentKeys: input.createdDocumentKeys, + }; + await ctx.db.execute( + `INSERT INTO ${namespace}.mission_events ( + mission_issue_id, + company_id, + event_key, + event_type, + payload_json + ) VALUES ($1, $2, 'initialized', 'mission_initialized', $3::jsonb) + ON CONFLICT (mission_issue_id, event_key) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = now()`, + [input.issue.id, input.companyId, JSON.stringify(payload)], + ); + + const record: PersistedMissionRecord = { + missionIssueId: input.issue.id, + companyId: input.companyId, + state: DEFAULT_MISSION_STATE, + billingCode: input.billingCode, + rootOriginKind: input.rootOriginKind, + rootOriginId: input.rootOriginId, + settings, + initializedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + hasRootLink: true, + hasInitializationEvent: true, + }; + await setPersistedMissionFallback(ctx, input.issue.id, record); +} + +export async function buildMissionIssueSummary(ctx: PluginContext, companyId: string, issueId: string): Promise { + const issue = await ctx.issues.get(issueId, companyId); + if (!issue) throw new Error(`Issue not found: ${issueId}`); + + const persisted = await getMissionByRootIssue(ctx, companyId, issueId); + const documents = await ctx.issues.documents.list(issueId, companyId); + const checklist: MissionDocumentChecklistItem[] = MISSION_REQUIRED_DOCUMENT_KEYS.map((key) => { + const document = documents.find((candidate) => candidate.key === key); + return { + key, + present: Boolean(document), + title: document?.title ?? null, + }; + }); + const missingRequiredDocumentKeys = checklist.filter((item) => !item.present).map((item) => item.key); + const parseProblems: MissionParseProblem[] = []; + const validationReports: MissionValidationReportSummary[] = []; + let openFindingCount = 0; + + const relevantKeys = new Set( + documents + .map((document) => document.key) + .filter((key) => key === "validation-contract" || key === "features" || isValidationReportDocumentKey(key)), + ); + + for (const key of relevantKeys) { + const document = await ctx.issues.documents.get(issueId, key, companyId); + if (!document) continue; + try { + if (key === "validation-contract") { + parseMissionValidationContractDocument(document.body); + continue; + } + if (key === "features") { + parseMissionFeaturesDocument(document.body); + continue; + } + if (!isValidationReportDocumentKey(key)) continue; + const parsed = parseMissionValidationReportDocument(document.body, { + round: parseValidationReportRound(key) ?? undefined, + }); + validationReports.push({ + key, + title: document.title, + round: parsed.round, + summary: parsed.summary, + findingCount: parsed.findings.length, + updatedAt: normalizeTimestamp(document.updatedAt), + }); + openFindingCount += parsed.findings.filter((finding) => finding.status === "open").length; + } catch (error) { + parseProblems.push({ + issueId, + key, + message: stringifyError(error), + }); + } + } + + validationReports.sort((left, right) => left.round - right.round); + + const originPrefix = pluginOriginPrefix(ctx); + const issueOriginKind = issue.originKind ?? null; + const initializeDisabledReason = persisted + ? "This issue is already initialized as a mission." + : issueOriginKind === "manual" || + issueOriginKind === originPrefix || + (issueOriginKind !== null && issueOriginKind.startsWith(`${originPrefix}:`)) + ? null + : `Issue origin '${issue.originKind}' is already owned by another workflow.`; + + const nextAction = !persisted + ? "Initialize this issue as a mission to create the namespace rows and document bundle." + : missingRequiredDocumentKeys.length > 0 + ? "Complete the missing mission documents before decomposition." + : parseProblems.length > 0 + ? "Repair invalid mission documents before decomposition." + : persisted.state === "draft" + ? "Review the mission brief, validation contract, and feature plan before decomposition." + : "Mission initialization is complete."; + + return { + issueId, + isMission: Boolean(persisted), + canInitialize: !persisted && initializeDisabledReason === null, + initializeDisabledReason, + state: persisted?.state ?? null, + nextAction, + documentChecklist: checklist, + missingRequiredDocumentKeys, + parseProblems, + validationReports, + openFindingCount, + settings: { + billingCode: persisted?.billingCode ?? issue.billingCode ?? null, + rootOriginKind: persisted?.rootOriginKind ?? issue.originKind ?? null, + rootOriginId: persisted?.rootOriginId ?? issue.originId ?? null, + databaseNamespace: ctx.db.namespace, + requiredDocumentKeys: MISSION_REQUIRED_DOCUMENT_KEYS, + }, + persistence: persisted, + }; +} + +export async function initializeMission( + ctx: PluginContext, + input: { + companyId: string; + issueId: string; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }, +): Promise { + const actor = { + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + }; + const issue = await ctx.issues.get(input.issueId, input.companyId); + if (!issue) { + throw new Error(`Issue not found: ${input.issueId}`); + } + + if (actor.actorAgentId && actor.actorRunId && issue.status === "in_progress" && issue.assigneeAgentId === actor.actorAgentId) { + await ctx.issues.assertCheckoutOwner({ + issueId: issue.id, + companyId: input.companyId, + actorAgentId: actor.actorAgentId, + actorRunId: actor.actorRunId, + }); + } + + const persistedBefore = await getMissionByRootIssue(ctx, input.companyId, issue.id); + const { + issue: updatedIssue, + finalOriginKind, + finalOriginId, + finalBillingCode, + } = await ensureRootIssueMetadata(ctx, input.companyId, issue, actor); + + const createdDocumentKeys = await ensureMissionDocuments(ctx, input.companyId, updatedIssue); + await persistMissionInitialization(ctx, { + companyId: input.companyId, + issue: updatedIssue, + rootOriginKind: finalOriginKind, + rootOriginId: finalOriginId, + billingCode: finalBillingCode, + createdDocumentKeys, + actor, + }); + + return { + created: !persistedBefore, + issueId: updatedIssue.id, + createdDocumentKeys, + summary: await buildMissionIssueSummary(ctx, input.companyId, updatedIssue.id), + }; +} diff --git a/packages/plugins/plugin-missions/src/mission-runtime.ts b/packages/plugins/plugin-missions/src/mission-runtime.ts new file mode 100644 index 00000000000..763949e6723 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission-runtime.ts @@ -0,0 +1,798 @@ +import type { Issue, PluginContext } from "@paperclipai/plugin-sdk"; +import { + MISSION_REQUIRED_DOCUMENT_KEYS, + appendDecisionLogEntry, + buildMissionEventEntry, + buildMissionFindingWaiverEntry, + isMissionRequiredDocumentKey, + isValidationReportDocumentKey, + parseMissionFeaturesDocument, + parseMissionFindingWaivers, + parseMissionValidationContractDocument, + parseMissionValidationReportDocument, + parseValidationReportRound, + type MissionFeaturesDocument, + type MissionFinding, + type MissionFindingWaiverRecord, + type MissionRequiredDocumentKey, + type MissionState, + type MissionValidationContract, + type MissionValidationReport, +} from "./mission-documents.js"; + +export type MissionIssueKind = "mission" | "milestone" | "feature" | "validation" | "fix" | "fix_loop" | "other"; +export type MissionStopReason = + | "approval_required" + | "budget_incident" + | "invocation_block" + | "unresolved_blockers" + | "missing_assignee" + | "paused_assignee" + | "parse_error" + | "max_validation_rounds"; + +export interface MissionDocumentChecklistItem { + key: MissionRequiredDocumentKey; + present: boolean; + title: string | null; +} + +export interface MissionParseProblem { + issueId: string; + key: string; + message: string; +} + +export interface MissionWorkItem { + id: string; + title: string; + status: Issue["status"]; + assigneeAgentId: string | null; + kind: MissionIssueKind; + hasUnresolvedBlockers: boolean; + originKind: string | null; + originId: string | null; +} + +export interface MissionValidationReportProjection { + issueId: string; + documentKey: string; + title: string | null; + round: number; + validatorRole: MissionValidationReport["validator_role"]; + summary: string; + updatedAt: string; +} + +export interface MissionFindingProjection extends MissionFinding { + sourceIssueId: string; + sourceReportKey: string; + sourceReportTitle: string | null; + round: number; + validatorRole: MissionValidationReport["validator_role"]; + computedStatus: MissionFinding["status"]; + fixIssueId: string | null; + waiver: MissionFindingWaiverRecord | null; +} + +export interface MissionSummary { + isMission: boolean; + missionIssueId: string; + missionIdentifier: string | null; + state: MissionState | null; + documentChecklist: MissionDocumentChecklistItem[]; + missingRequiredDocumentKeys: MissionRequiredDocumentKey[]; + parseProblems: MissionParseProblem[]; + validationReports: MissionValidationReportProjection[]; + findings: MissionFindingProjection[]; + workItems: MissionWorkItem[]; + wakeableIssueIds: string[]; + stopReasons: MissionStopReason[]; + nextAction: string; + openApprovalCount: number; + openBudgetIncidentCount: number; + invocationBlockCount: number; + latestValidationRound: number; +} + +export interface MissionAdvanceResult { + issueId: string; + outcome: "paused" | "woke_issues" | "created_fixes" | "complete" | "noop"; + stopReason: MissionStopReason | null; + wokenIssueIds: string[]; + createdFixIssueIds: string[]; + details: Record; +} + +export interface MissionWaiveFindingResult { + issueId: string; + findingId: string; + waived: boolean; + rationale: string; +} + +type PluginIssueOriginKindLike = `plugin:${string}`; + +const DEFAULT_MAX_VALIDATION_ROUNDS = 3; +const ACTIVE_AGENT_STATUSES = new Set(["active", "idle", "running"]); +const PAUSED_AGENT_STATUSES = new Set(["paused", "pending_approval", "terminated", "error"]); + +function missionOriginPrefix(ctx: PluginContext) { + return `plugin:${ctx.manifest.id}` as PluginIssueOriginKindLike; +} + +function kindFromOrigin(originKind: string | null | undefined, ctx: PluginContext): MissionIssueKind { + const prefix = missionOriginPrefix(ctx); + if (!originKind) return "other"; + if (originKind === prefix) return "mission"; + if (originKind === `${prefix}:milestone`) return "milestone"; + if (originKind === `${prefix}:feature`) return "feature"; + if (originKind === `${prefix}:validation`) return "validation"; + if (originKind === `${prefix}:fix`) return "fix"; + if (originKind === `${prefix}:fix_loop` || originKind === `${prefix}:fix-loop`) return "fix_loop"; + return "other"; +} + +function isTerminalStatus(status: Issue["status"]) { + return status === "done" || status === "cancelled"; +} + +function isWakeableStatus(status: Issue["status"]) { + return status === "todo" || status === "in_progress"; +} + +function isResolvableApprovalStatus(status: string) { + const normalized = status.trim().toLowerCase(); + return normalized === "approved" || normalized === "denied" || normalized === "cancelled"; +} + +function buildActorLabel(actor: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null }) { + if (actor.actorUserId) return `user:${actor.actorUserId}`; + if (actor.actorAgentId) return `agent:${actor.actorAgentId}`; + if (actor.actorRunId) return `run:${actor.actorRunId}`; + return "plugin"; +} + +function stringifyError(error: unknown) { + if (error instanceof Error) return error.message; + return String(error); +} + +function unique(values: T[]) { + return [...new Set(values)]; +} + +async function loadRelevantDocuments(ctx: PluginContext, companyId: string, issueId: string) { + const documents = await ctx.issues.documents.list(issueId, companyId); + const relevant = documents.filter( + (document) => isMissionRequiredDocumentKey(document.key) || isValidationReportDocumentKey(document.key), + ); + + const entries = await Promise.all( + relevant.map(async (document) => ({ + summary: document, + document: await ctx.issues.documents.get(issueId, document.key, companyId), + })), + ); + + return entries.filter((entry): entry is { summary: typeof relevant[number]; document: NonNullable } => { + return entry.document !== null; + }); +} + +async function parseMissionDocuments( + ctx: PluginContext, + companyId: string, + issueId: string, + subtreeIssues: Issue[], +) { + const checklist: MissionDocumentChecklistItem[] = MISSION_REQUIRED_DOCUMENT_KEYS.map((key) => ({ + key, + present: false, + title: null, + })); + const parseProblems: MissionParseProblem[] = []; + const validationReports: Array<{ + issueId: string; + key: string; + title: string | null; + updatedAt: string; + parsed: MissionValidationReport; + }> = []; + + let validationContract: MissionValidationContract | null = null; + let featuresDocument: MissionFeaturesDocument | null = null; + let decisionLogBody = ""; + + for (const issue of subtreeIssues) { + const documents = await loadRelevantDocuments(ctx, companyId, issue.id); + for (const { summary, document } of documents) { + if (isMissionRequiredDocumentKey(summary.key) && issue.id === issueId) { + const item = checklist.find((candidate) => candidate.key === summary.key); + if (item) { + item.present = true; + item.title = summary.title; + } + + try { + if (summary.key === "validation-contract") { + validationContract = parseMissionValidationContractDocument(document.body); + } else if (summary.key === "features") { + featuresDocument = parseMissionFeaturesDocument(document.body); + } else if (summary.key === "decision-log") { + decisionLogBody = document.body; + } + } catch (error) { + parseProblems.push({ + issueId, + key: summary.key, + message: stringifyError(error), + }); + } + continue; + } + + if (!isValidationReportDocumentKey(summary.key)) continue; + + try { + const round = parseValidationReportRound(summary.key) ?? undefined; + validationReports.push({ + issueId: issue.id, + key: summary.key, + title: summary.title, + updatedAt: summary.updatedAt.toISOString(), + parsed: parseMissionValidationReportDocument(document.body, { round }), + }); + } catch (error) { + parseProblems.push({ + issueId: issue.id, + key: summary.key, + message: stringifyError(error), + }); + } + } + } + + validationReports.sort((a, b) => a.parsed.round - b.parsed.round); + + return { + checklist, + parseProblems, + validationContract, + featuresDocument, + validationReports, + decisionLogBody, + }; +} + +function buildFindingFixOriginId(missionIssueId: string, findingId: string) { + return `${missionIssueId}:fix:${findingId}`; +} + +async function buildSummaryInternal(ctx: PluginContext, companyId: string, issueId: string): Promise<{ + mission: Issue; + summary: MissionSummary; + validationContract: MissionValidationContract | null; + featuresDocument: MissionFeaturesDocument | null; + decisionLogBody: string; +}> { + const mission = await ctx.issues.get(issueId, companyId); + if (!mission) { + throw new Error(`Mission issue not found: ${issueId}`); + } + + const subtree = await ctx.issues.getSubtree(issueId, companyId, { includeRelations: false }); + const orchestration = await ctx.issues.summaries.getOrchestration({ + issueId, + companyId, + includeSubtree: true, + billingCode: mission.billingCode ?? null, + }); + const parsedDocuments = await parseMissionDocuments(ctx, companyId, issueId, subtree.issues); + const waivers = parseMissionFindingWaivers(parsedDocuments.decisionLogBody); + + const issueMap = new Map(subtree.issues.map((issue) => [issue.id, issue])); + const unresolvedBlockersByIssueId = new Map(); + for (const [relatedIssueId, relation] of Object.entries(orchestration.relations)) { + unresolvedBlockersByIssueId.set( + relatedIssueId, + relation.blockedBy.filter((blocker) => blocker.status !== "done").map((blocker) => blocker.id), + ); + } + + const fixIssues = subtree.issues.filter((issue) => kindFromOrigin(issue.originKind, ctx) === "fix"); + const fixIssueByOriginId = new Map( + fixIssues.map((issue) => [issue.originId ?? buildFindingFixOriginId(issueId, issue.id), issue]), + ); + + const findings: MissionFindingProjection[] = parsedDocuments.validationReports.flatMap((report) => + report.parsed.findings.map((finding) => { + const waiver = waivers.get(finding.id) ?? null; + const fixIssue = fixIssueByOriginId.get(buildFindingFixOriginId(issueId, finding.id)) ?? null; + let computedStatus: MissionFinding["status"] = finding.status; + if (waiver) computedStatus = "waived"; + else if (fixIssue?.status === "done") computedStatus = "resolved"; + else if (fixIssue) computedStatus = "fix_created"; + + return { + ...finding, + computedStatus, + sourceIssueId: report.issueId, + sourceReportKey: report.key, + sourceReportTitle: report.title, + round: report.parsed.round, + validatorRole: report.parsed.validator_role, + fixIssueId: fixIssue?.id ?? null, + waiver, + }; + }), + ); + + const relevantKinds = new Set(["feature", "validation", "fix", "fix_loop"]); + const assigneeCache = new Map>>(); + const workItems: MissionWorkItem[] = []; + const wakeableIssueIds: string[] = []; + let missingAssignee = false; + let pausedAssignee = false; + + for (const issue of subtree.issues) { + const kind = kindFromOrigin(issue.originKind, ctx); + if (!relevantKinds.has(kind)) continue; + + const unresolvedBlockers = unresolvedBlockersByIssueId.get(issue.id) ?? []; + const item: MissionWorkItem = { + id: issue.id, + title: issue.title, + status: issue.status, + assigneeAgentId: issue.assigneeAgentId, + kind, + hasUnresolvedBlockers: unresolvedBlockers.length > 0, + originKind: issue.originKind ?? null, + originId: issue.originId ?? null, + }; + workItems.push(item); + + if (!isWakeableStatus(issue.status) || item.hasUnresolvedBlockers) continue; + if (!issue.assigneeAgentId) { + missingAssignee = true; + continue; + } + + if (!assigneeCache.has(issue.assigneeAgentId)) { + assigneeCache.set(issue.assigneeAgentId, await ctx.agents.get(issue.assigneeAgentId, companyId)); + } + const assignee = assigneeCache.get(issue.assigneeAgentId) ?? null; + if (!assignee || PAUSED_AGENT_STATUSES.has(assignee.status)) { + pausedAssignee = true; + continue; + } + if (!ACTIVE_AGENT_STATUSES.has(assignee.status)) continue; + wakeableIssueIds.push(issue.id); + } + + const blockingFindings = findings.filter( + (finding) => finding.severity === "blocking" && !["resolved", "waived"].includes(finding.computedStatus), + ); + const activeFeatureOrFixIssues = workItems.filter( + (item) => + (item.kind === "feature" || item.kind === "fix" || item.kind === "fix_loop") && + !isTerminalStatus(item.status), + ); + const activeValidationIssues = workItems.filter((item) => item.kind === "validation" && !isTerminalStatus(item.status)); + const missionBlockers = unresolvedBlockersByIssueId.get(issueId) ?? []; + const openApprovals = orchestration.approvals.filter((approval) => !isResolvableApprovalStatus(approval.status)); + + const stopReasons: MissionStopReason[] = []; + const missingRequiredDocumentKeys = parsedDocuments.checklist + .filter((item) => !item.present) + .map((item) => item.key); + + if (parsedDocuments.parseProblems.length > 0) stopReasons.push("parse_error"); + if (orchestration.openBudgetIncidents.length > 0) stopReasons.push("budget_incident"); + if (orchestration.invocationBlocks.length > 0) stopReasons.push("invocation_block"); + if (openApprovals.length > 0) stopReasons.push("approval_required"); + if (missionBlockers.length > 0) stopReasons.push("unresolved_blockers"); + if (missingAssignee) stopReasons.push("missing_assignee"); + if (pausedAssignee) stopReasons.push("paused_assignee"); + + const latestValidationRound = parsedDocuments.validationReports.reduce( + (max, report) => Math.max(max, report.parsed.round), + 0, + ); + + let state: MissionState | null = null; + const missionOriginKind = mission.originKind ?? null; + if ( + missionOriginKind === missionOriginPrefix(ctx) || + (missionOriginKind !== null && missionOriginKind.startsWith(`${missionOriginPrefix(ctx)}:`)) || + missingRequiredDocumentKeys.length > 0 || + parsedDocuments.validationReports.length > 0 + ) { + if (missingRequiredDocumentKeys.length > 0 || parsedDocuments.parseProblems.length > 0) state = "draft"; + else if (blockingFindings.length > 0 && fixIssues.some((issue) => !isTerminalStatus(issue.status))) state = "fixing"; + else if (activeValidationIssues.length > 0) state = "validating"; + else if (stopReasons.length > 0) state = "paused"; + else if ( + parsedDocuments.validationReports.length > 0 && + blockingFindings.length === 0 && + activeFeatureOrFixIssues.length === 0 && + activeValidationIssues.length === 0 + ) { + state = "complete"; + } else if (activeFeatureOrFixIssues.length > 0 || wakeableIssueIds.length > 0) { + state = "working"; + } else { + state = "ready"; + } + } + + const nextAction = (() => { + if (state === null) return "Initialize this issue as a mission before advancing it."; + if (missingRequiredDocumentKeys.length > 0) return "Complete the required mission documents before advancing."; + if (parsedDocuments.parseProblems.length > 0) return "Repair invalid mission documents before advancing."; + if (orchestration.openBudgetIncidents.length > 0) return "Resolve open budget incidents before advancing mission work."; + if (orchestration.invocationBlocks.length > 0) return "Resolve invocation blocks before advancing mission work."; + if (openApprovals.length > 0) return "Resolve linked approvals before advancing mission work."; + if (missionBlockers.length > 0) return "Resolve root mission blockers before advancing mission work."; + if (missingAssignee) return "Assign all wakeable mission issues before advancing."; + if (pausedAssignee) return "Resume or reassign paused mission assignees before advancing."; + if (blockingFindings.length > 0) return "Advance the mission to create or wake bounded fix issues."; + if (activeValidationIssues.length > 0) return "Wake assigned validation issues and review their findings."; + if (wakeableIssueIds.length > 0) return "Wake the next assigned mission issues."; + if (state === "complete") return "Record the final mission report and close the root issue when ready."; + return "Decompose the mission into milestone, feature, and validation issues."; + })(); + + return { + mission, + summary: { + isMission: state !== null, + missionIssueId: mission.id, + missionIdentifier: mission.identifier, + state, + documentChecklist: parsedDocuments.checklist, + missingRequiredDocumentKeys, + parseProblems: parsedDocuments.parseProblems, + validationReports: parsedDocuments.validationReports.map((report) => ({ + issueId: report.issueId, + documentKey: report.key, + title: report.title, + round: report.parsed.round, + validatorRole: report.parsed.validator_role, + summary: report.parsed.summary, + updatedAt: report.updatedAt, + })), + findings, + workItems, + wakeableIssueIds: unique(wakeableIssueIds), + stopReasons: unique(stopReasons), + nextAction, + openApprovalCount: openApprovals.length, + openBudgetIncidentCount: orchestration.openBudgetIncidents.length, + invocationBlockCount: orchestration.invocationBlocks.length, + latestValidationRound, + }, + validationContract: parsedDocuments.validationContract, + featuresDocument: parsedDocuments.featuresDocument, + decisionLogBody: parsedDocuments.decisionLogBody, + }; +} + +export async function buildMissionSummary(ctx: PluginContext, companyId: string, issueId: string) { + const { summary } = await buildSummaryInternal(ctx, companyId, issueId); + return summary; +} + +async function appendDecisionLog( + ctx: PluginContext, + companyId: string, + issueId: string, + existingBody: string, + entry: { marker: string; body: string }, +) { + const nextBody = appendDecisionLogEntry(existingBody, entry); + if (nextBody === existingBody.trim()) return nextBody; + await ctx.issues.documents.upsert({ + issueId, + companyId, + key: "decision-log", + title: "Decision Log", + body: nextBody, + changeSummary: "Recorded mission decision", + }); + return nextBody; +} + +async function ensureFixIssue( + ctx: PluginContext, + input: { + companyId: string; + mission: Issue; + featuresDocument: MissionFeaturesDocument | null; + finding: MissionFindingProjection; + actor: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null }; + }, +) { + const originKind = `${missionOriginPrefix(ctx)}:fix` as PluginIssueOriginKindLike; + const originId = buildFindingFixOriginId(input.mission.id, input.finding.id); + const existing = await ctx.issues.list({ + companyId: input.companyId, + originKind, + originId, + }); + if (existing[0]) return existing[0]; + + let parentId = input.mission.id; + const milestoneId = input.featuresDocument?.milestones.find((milestone) => + milestone.features.some((feature) => feature.claimed_assertion_ids.includes(input.finding.assertion_id ?? "")), + )?.id; + if (milestoneId) { + const milestoneIssues = await ctx.issues.list({ + companyId: input.companyId, + originKind: `${missionOriginPrefix(ctx)}:milestone` as PluginIssueOriginKindLike, + }); + const milestoneIssue = milestoneIssues.find((issue) => issue.originId?.includes(milestoneId)); + if (milestoneIssue) parentId = milestoneIssue.id; + } + + return ctx.issues.create({ + companyId: input.companyId, + projectId: input.mission.projectId ?? undefined, + goalId: input.mission.goalId ?? undefined, + parentId, + inheritExecutionWorkspaceFromIssueId: input.mission.id, + title: `Fix ${input.finding.id}: ${input.finding.title}`, + description: [ + `# Fix ${input.finding.id}`, + "", + `Source report: ${input.finding.sourceReportKey}`, + `Assertion: ${input.finding.assertion_id ?? "none"}`, + "", + "## Expected", + input.finding.expected, + "", + "## Actual", + input.finding.actual, + "", + "## Reproduction", + ...input.finding.repro_steps.map((step) => `- ${step}`), + "", + "## Evidence", + ...input.finding.evidence.map((evidence) => `- ${evidence}`), + "", + "## Recommended Scope", + input.finding.recommended_fix_scope ?? "Fix the bounded behavior described above and leave validation evidence.", + ].join("\n"), + status: "todo", + priority: "high", + assigneeAgentId: input.mission.assigneeAgentId ?? undefined, + billingCode: input.mission.billingCode ?? `mission:${input.mission.id}`, + originKind, + originId, + actor: input.actor, + }); +} + +export async function advanceMission( + ctx: PluginContext, + input: { + companyId: string; + issueId: string; + maxValidationRounds?: number; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }, +): Promise { + const actor = { + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + }; + const actorLabel = buildActorLabel(actor); + const { mission, summary, featuresDocument, decisionLogBody } = await buildSummaryInternal( + ctx, + input.companyId, + input.issueId, + ); + + if (!summary.isMission) { + throw new Error("Issue is not initialized as a mission"); + } + + const stopReason = + summary.stopReasons[0] ?? + (summary.latestValidationRound >= (input.maxValidationRounds ?? DEFAULT_MAX_VALIDATION_ROUNDS) && + summary.findings.some( + (finding) => finding.severity === "blocking" && !["resolved", "waived"].includes(finding.computedStatus), + ) + ? "max_validation_rounds" + : null); + + if (stopReason) { + const result: MissionAdvanceResult = { + issueId: input.issueId, + outcome: "paused", + stopReason, + wokenIssueIds: [], + createdFixIssueIds: [], + details: { + stopReasons: summary.stopReasons, + latestValidationRound: summary.latestValidationRound, + }, + }; + + await appendDecisionLog( + ctx, + input.companyId, + input.issueId, + decisionLogBody, + buildMissionEventEntry({ + markerKey: `advance:paused:${stopReason}:${summary.latestValidationRound}`, + title: `Advance Paused ${summary.missionIdentifier ?? input.issueId}`, + lines: [ + `Outcome: paused`, + `Stop reason: ${stopReason}`, + `Actor: ${actorLabel}`, + `At: ${new Date().toISOString()}`, + ], + }), + ); + await ctx.state.set({ scopeKind: "issue", scopeId: input.issueId, stateKey: "last-advance-result" }, result); + return result; + } + + const createdFixIssues: Issue[] = []; + for (const finding of summary.findings) { + if (finding.severity !== "blocking" || ["resolved", "waived"].includes(finding.computedStatus)) continue; + const fixIssue = await ensureFixIssue(ctx, { + companyId: input.companyId, + mission, + featuresDocument, + finding, + actor, + }); + createdFixIssues.push(fixIssue); + } + + let decisionLogBodyAfterFixes = decisionLogBody; + if (createdFixIssues.length > 0) { + decisionLogBodyAfterFixes = await appendDecisionLog( + ctx, + input.companyId, + input.issueId, + decisionLogBodyAfterFixes, + buildMissionEventEntry({ + markerKey: `advance:fixes:${createdFixIssues.map((issue) => issue.id).join(",")}`, + title: `Advance Created Fixes ${summary.missionIdentifier ?? input.issueId}`, + lines: [ + `Outcome: created fixes`, + `Actor: ${actorLabel}`, + `Created issue ids: ${createdFixIssues.map((issue) => issue.id).join(", ")}`, + `At: ${new Date().toISOString()}`, + ], + }), + ); + } + + const wakeableIssueIds = unique([...summary.wakeableIssueIds, ...createdFixIssues.map((issue) => issue.id)]); + if (wakeableIssueIds.length > 0) { + const wakeups = await ctx.issues.requestWakeups(wakeableIssueIds, input.companyId, { + reason: "mission_advance", + contextSource: "missions.advance", + idempotencyKeyPrefix: `mission:${input.issueId}:advance`, + ...actor, + }); + const wokenIssueIds = wakeups.filter((result) => result.queued).map((result) => result.issueId); + const result: MissionAdvanceResult = { + issueId: input.issueId, + outcome: "woke_issues", + stopReason: null, + wokenIssueIds, + createdFixIssueIds: createdFixIssues.map((issue) => issue.id), + details: { + requestedIssueIds: wakeableIssueIds, + }, + }; + + await appendDecisionLog( + ctx, + input.companyId, + input.issueId, + decisionLogBodyAfterFixes, + buildMissionEventEntry({ + markerKey: `advance:wakeup:${wokenIssueIds.join(",")}`, + title: `Advance Woke Issues ${summary.missionIdentifier ?? input.issueId}`, + lines: [ + `Outcome: woke issues`, + `Actor: ${actorLabel}`, + `Wakeups: ${wokenIssueIds.join(", ")}`, + `At: ${new Date().toISOString()}`, + ], + }), + ); + await ctx.state.set({ scopeKind: "issue", scopeId: input.issueId, stateKey: "last-advance-result" }, result); + return result; + } + + if ( + summary.validationReports.length > 0 && + summary.findings.every((finding) => ["resolved", "waived"].includes(finding.computedStatus)) + ) { + const result: MissionAdvanceResult = { + issueId: input.issueId, + outcome: "complete", + stopReason: null, + wokenIssueIds: [], + createdFixIssueIds: createdFixIssues.map((issue) => issue.id), + details: { + latestValidationRound: summary.latestValidationRound, + }, + }; + await ctx.state.set({ scopeKind: "issue", scopeId: input.issueId, stateKey: "last-advance-result" }, result); + return result; + } + + const result: MissionAdvanceResult = { + issueId: input.issueId, + outcome: createdFixIssues.length > 0 ? "created_fixes" : "noop", + stopReason: null, + wokenIssueIds: [], + createdFixIssueIds: createdFixIssues.map((issue) => issue.id), + details: { + nextAction: summary.nextAction, + }, + }; + await ctx.state.set({ scopeKind: "issue", scopeId: input.issueId, stateKey: "last-advance-result" }, result); + return result; +} + +export async function waiveMissionFinding( + ctx: PluginContext, + input: { + companyId: string; + issueId: string; + findingId: string; + rationale: string; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }, +): Promise { + const actor = { + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + }; + const actorLabel = buildActorLabel(actor); + const { summary, decisionLogBody } = await buildSummaryInternal(ctx, input.companyId, input.issueId); + const finding = summary.findings.find((candidate) => candidate.id === input.findingId); + if (!finding) { + throw new Error(`Mission finding not found: ${input.findingId}`); + } + + const createdAt = new Date().toISOString(); + const waiverEntry = buildMissionFindingWaiverEntry({ + findingId: input.findingId, + rationale: input.rationale, + actorLabel, + createdAt, + }); + await appendDecisionLog(ctx, input.companyId, input.issueId, decisionLogBody, waiverEntry); + await ctx.state.set( + { scopeKind: "issue", scopeId: input.issueId, stateKey: `waiver:${input.findingId}` }, + { + findingId: input.findingId, + rationale: input.rationale, + actorLabel, + createdAt, + }, + ); + + return { + issueId: input.issueId, + findingId: input.findingId, + waived: true, + rationale: input.rationale, + }; +} diff --git a/packages/plugins/plugin-missions/src/mission-service.ts b/packages/plugins/plugin-missions/src/mission-service.ts new file mode 100644 index 00000000000..abfa0015109 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission-service.ts @@ -0,0 +1,1587 @@ +import type { + Issue, + IssueDocument, + IssueDocumentSummary, + PluginContext, + PluginIssueApprovalSummary, + PluginIssueInvocationBlockSummary, + PluginIssueOrchestrationSummary, + PluginIssueRelationSummary, + PluginIssueRunSummary, +} from "@paperclipai/plugin-sdk"; + +export const REQUIRED_DOCUMENT_KEYS = [ + "plan", + "mission-brief", + "validation-contract", + "features", + "worker-guidelines", + "services", + "knowledge-base", + "decision-log", +] as const; + +export type RequiredDocumentKey = (typeof REQUIRED_DOCUMENT_KEYS)[number]; + +export type MissionState = + | "draft" + | "planning" + | "running" + | "validating" + | "fixing" + | "blocked" + | "complete"; + +export type MissionSeverity = "blocking" | "non_blocking" | "suggestion"; +export type MissionFindingStatus = "open" | "fix_created" | "waived" | "resolved"; + +export type MissionSettings = { + maxValidationRounds: number; + requireBlackBoxValidation: boolean; + defaultWorkerAgentId: string | null; + defaultValidatorAgentId: string | null; + defaultBillingCodePolicy: "mission-issue" | "stable-prefix"; + autoAdvance: boolean; +}; + +export const DEFAULT_SETTINGS: MissionSettings = { + maxValidationRounds: 2, + requireBlackBoxValidation: true, + defaultWorkerAgentId: null, + defaultValidatorAgentId: null, + defaultBillingCodePolicy: "mission-issue", + autoAdvance: false, +}; + +export type MissionIssueLite = { + id: string; + identifier: string | null; + title: string; + status: Issue["status"]; + priority: Issue["priority"]; + originKind: string | null; + originId: string | null; + assigneeAgentId: string | null; + assigneeUserId: string | null; + blockedBy: PluginIssueRelationSummary["blockedBy"]; +}; + +export type MissionDocumentChecklistItem = { + key: RequiredDocumentKey; + title: string | null; + present: boolean; + latestRevisionNumber: number | null; + updatedAt: string | null; +}; + +export type MissionDocumentError = { + key: string; + message: string; +}; + +export type MissionBlockedWorkItem = { + issue: MissionIssueLite; + blockers: PluginIssueRelationSummary["blockedBy"]; +}; + +export type MissionMilestoneProjection = { + key: string; + title: string; + summary: string | null; + issue: MissionIssueLite | null; + features: MissionIssueLite[]; + validations: MissionIssueLite[]; + fixLoops: MissionIssueLite[]; + blockers: MissionBlockedWorkItem[]; +}; + +export type MissionValidationReportProjection = { + round: number; + validatorRole: "scrutiny_validator" | "user_testing_validator"; + summary: string; + findings: MissionFindingProjection[]; + documentKey: string; + documentTitle: string | null; + updatedAt: string | null; +}; + +export type MissionFindingProjection = { + id: string; + severity: MissionSeverity; + assertionId: string | null; + title: string; + evidence: string[]; + reproSteps: string[]; + expected: string; + actual: string; + suspectedArea: string | null; + recommendedFixScope: string | null; + status: MissionFindingStatus; + sourceReportKey: string; + sourceReportTitle: string | null; + round: number; + validatorRole: "scrutiny_validator" | "user_testing_validator"; + computedStatus: MissionFindingStatus; + fixIssue: MissionIssueLite | null; + waiverRationale: string | null; +}; + +export type MissionValidationSummary = { + reports: MissionValidationReportProjection[]; + findings: MissionFindingProjection[]; + counts: { + total: number; + bySeverity: Record; + byStatus: Record; + }; + openBlockingFindingCount: number; +}; + +export type MissionRunSummary = { + total: number; + active: number; + latestRunId: string | null; + latestRunStatus: string | null; +}; + +export type MissionCostSummary = { + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + billingCode: string | null; +}; + +export type MissionGovernanceStop = + | { + kind: "approval"; + id: string; + label: string; + detail: string; + issueId: string; + createdAt: string; + } + | { + kind: "budget"; + id: string; + label: string; + detail: string; + createdAt: string; + } + | { + kind: "invocation_block"; + id: string; + label: string; + detail: string; + createdAt: string | null; + issueId: string; + }; + +export type MissionCommand = { + key: "initialize" | "decompose" | "advance" | "waive"; + label: string; + enabled: boolean; + reason: string | null; +}; + +export type MissionSummary = { + missionIssueId: string; + missionIdentifier: string | null; + missionTitle: string; + state: MissionState; + documentChecklist: MissionDocumentChecklistItem[]; + missingDocumentKeys: RequiredDocumentKey[]; + documentErrors: MissionDocumentError[]; + milestones: MissionMilestoneProjection[]; + blockers: MissionBlockedWorkItem[]; + activeWork: MissionIssueLite[]; + validationSummary: MissionValidationSummary; + runSummary: MissionRunSummary; + costSummary: MissionCostSummary; + approvals: PluginIssueApprovalSummary[]; + governanceStops: MissionGovernanceStop[]; + nextAction: string; + availableCommands: MissionCommand[]; +}; + +export type MissionListItem = { + missionIssueId: string; + missionIdentifier: string | null; + missionTitle: string; + state: MissionState; + nextAction: string; + activeWorkCount: number; + blockerCount: number; + milestoneCount: number; + featureCount: number; + validationFindingCount: number; + governanceStopCount: number; + documentHealth: { + present: number; + total: number; + errors: number; + }; + costCents: number; + latestRunStatus: string | null; + updatedAt: string; +}; + +export type MissionPanelData = + | { + mode: "not_mission"; + issue: MissionIssueLite; + availableCommands: MissionCommand[]; + } + | { + mode: "mission"; + currentIssueId: string; + currentIssueIdentifier: string | null; + currentIssueTitle: string; + missionRootIssueId: string; + missionRootIdentifier: string | null; + summary: MissionSummary; + }; + +type ValidationAssertion = { + id: string; + title: string; + user_value: string; + scope: string; + setup: string; + steps: string[]; + oracle: string; + tooling: string[]; + evidence: Array<{ kind: string; description: string; required: boolean }>; + claimed_by: string[]; + status: string; +}; + +type ValidationContract = { + assertions: ValidationAssertion[]; +}; + +type FeaturePlan = { + milestones: Array<{ + id: string; + title: string; + summary: string; + features: Array<{ + id: string; + title: string; + kind: "original" | "fix"; + summary: string; + acceptance_criteria: string[]; + claimed_assertion_ids: string[]; + status: string; + source_finding_id?: string | null; + }>; + }>; +}; + +type ValidationReport = { + round: number; + validator_role: "scrutiny_validator" | "user_testing_validator"; + summary: string; + findings: Array<{ + id: string; + severity: MissionSeverity; + assertion_id?: string | null; + title: string; + evidence: string[]; + repro_steps: string[]; + expected: string; + actual: string; + suspected_area?: string | null; + recommended_fix_scope?: string | null; + status: MissionFindingStatus; + }>; +}; + +type BuildMissionSummaryInput = { + pluginId: string; + rootIssue: Issue; + subtreeIssues: Issue[]; + relations: Record; + orchestration: PluginIssueOrchestrationSummary; + documentSummaries: IssueDocumentSummary[]; + requiredDocuments: Partial>; + validationReports: IssueDocument[]; + decisionLog: IssueDocument | null; +}; + +type LoadMissionSummaryInput = { + ctx: PluginContext; + companyId: string; + missionRootIssueId: string; +}; + +const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]); +const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); + +const VALIDATION_ID_RE = /\bVAL-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}\b/gi; +const FEATURE_ID_RE = /\bFEAT-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}\b/gi; +const MILESTONE_ID_RE = /\bMILESTONE-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}\b/gi; +const FINDING_ID_RE = /\bFINDING-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}\b/gi; + +function normalizeId(value: string) { + return value.trim().toUpperCase(); +} + +function rootMissionOrigin(pluginId: string) { + return `plugin:${pluginId}`; +} + +function childMissionOrigin(pluginId: string, kind: "milestone" | "feature" | "validation" | "fix_loop") { + return `plugin:${pluginId}:${kind}`; +} + +export function isMissionOrigin(pluginId: string, originKind: string | null | undefined) { + if (!originKind) return false; + return originKind === rootMissionOrigin(pluginId) || originKind.startsWith(`${rootMissionOrigin(pluginId)}:`); +} + +function isRootMission(pluginId: string, issue: Pick) { + return issue.originKind === rootMissionOrigin(pluginId); +} + +function issueReference(issue: Pick) { + return issue.identifier ?? issue.id; +} + +function missionBillingCode(issue: Pick) { + return issue.billingCode ?? `mission:${issue.identifier ?? issue.id}`; +} + +function defaultMissionDocument(input: { + issue: Issue; + key: RequiredDocumentKey; +}): { title: string; body: string } { + const { issue, key } = input; + const ref = issueReference(issue); + const issueTitle = issue.title.trim(); + switch (key) { + case "plan": + return { + title: "Plan", + body: [ + "# Plan", + "", + `Mission issue: ${ref}`, + "", + "## Objective", + "", + issue.description?.trim() || issueTitle, + "", + "## Milestones", + "", + "- TODO: Decompose the mission into bounded milestones.", + "", + "## Verification", + "", + "- TODO: Link validation assertions from `validation-contract` before implementation starts.", + ].join("\n"), + }; + case "mission-brief": + return { + title: "Mission Brief", + body: [ + "# Mission Brief", + "", + `Mission issue: ${ref}`, + `Current status: \`${issue.status}\``, + `Billing code: \`${missionBillingCode(issue)}\``, + "", + "## Goal", + "", + issueTitle, + "", + "## Scope", + "", + "- TODO: Define the work that is in scope.", + "", + "## Non-Goals", + "", + "- TODO: Define what this mission will not do.", + "", + "## Assumptions", + "", + "- TODO: Record assumptions that need validation.", + ].join("\n"), + }; + case "validation-contract": + return { + title: "Validation Contract", + body: [ + "# Validation Contract", + "", + "Define finite, testable assertions before feature decomposition.", + "", + "```json", + JSON.stringify({ assertions: [] }, null, 2), + "```", + ].join("\n"), + }; + case "features": + return { + title: "Features", + body: [ + "# Features", + "", + "Group implementation features by milestone after the validation contract is written.", + "", + "```json", + JSON.stringify({ milestones: [] }, null, 2), + "```", + ].join("\n"), + }; + case "worker-guidelines": + return { + title: "Worker Guidelines", + body: [ + "# Worker Guidelines", + "", + "- Work only on the assigned child issue.", + "- Preserve normal checkout, ownership, testing, and handoff rules.", + "- Do not decide final correctness; validators judge against the validation contract.", + "- Leave concise evidence in comments, work products, or attachments.", + ].join("\n"), + }; + case "services": + return { + title: "Services", + body: [ + "# Services", + "", + "## Commands", + "", + "- TODO: Document local server, test, and preview commands.", + "", + "## Environment", + "", + "- TODO: Document required accounts, seeded data, secrets, and setup costs.", + ].join("\n"), + }; + case "knowledge-base": + return { + title: "Knowledge Base", + body: [ + "# Knowledge Base", + "", + "- TODO: Add concise discoveries that future workers or validators need.", + ].join("\n"), + }; + case "decision-log": + return { + title: "Decision Log", + body: [ + "# Decision Log", + "", + "- Mission initialized from existing issue state.", + ].join("\n"), + }; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function parseJsonDocument(document: IssueDocument | null): T | null { + if (!document) return null; + const trimmed = document.body.trim(); + if (!trimmed) return null; + const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed); + const candidate = fenced?.[1]?.trim() ?? trimmed; + if (!candidate.startsWith("{") && !candidate.startsWith("[")) return null; + return JSON.parse(candidate) as T; +} + +function parseHeading(line: string) { + const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line.trim()); + if (!match) return null; + return { level: match[1]!.length, text: match[2]!.trim() }; +} + +function parseField(line: string): { key: string; value: string } | null { + const match = /^[-*]\s+([A-Za-z][A-Za-z0-9 _/-]{1,48}):\s*(.*?)\s*$/.exec(line.trim()); + if (!match) return null; + return { + key: match[1]!.trim().toLowerCase().replace(/[\s/-]+/g, "_"), + value: match[2]!.trim(), + }; +} + +function uniqueIds(values: string[]) { + return [...new Set(values.map(normalizeId))]; +} + +function extractIds(value: string, re: RegExp) { + return uniqueIds(value.match(re) ?? []); +} + +function parseKeyedHeading(text: string, re: RegExp) { + const [rawId] = text.match(re) ?? []; + if (!rawId) return null; + const id = normalizeId(rawId); + const escaped = rawId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const title = text + .replace(new RegExp(`\\b${escaped}\\b`, "i"), "") + .replace(/^[\s:.-]+/, "") + .trim(); + return { id, title: title || id }; +} + +function splitList(value: string) { + return value + .split(/(?:\s*;\s*|\s*,\s*)/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function normalizeEnumValue(value: string) { + return value.trim().toLowerCase().replace(/[\s-]+/g, "_"); +} + +export function parseMissionValidationContractDocument(document: IssueDocument | null): ValidationContract { + const parsedJson = parseJsonDocument(document); + if (parsedJson) { + const record = asRecord(parsedJson); + const assertions = Array.isArray(record?.assertions) ? record!.assertions : []; + return { + assertions: assertions + .map((item) => asRecord(item)) + .filter((item): item is Record => Boolean(item)) + .map((item) => ({ + id: typeof item.id === "string" ? normalizeId(item.id) : "VAL-MISSION-001", + title: typeof item.title === "string" ? item.title : "Untitled assertion", + user_value: typeof item.user_value === "string" ? item.user_value : "", + scope: typeof item.scope === "string" ? item.scope : "", + setup: typeof item.setup === "string" ? item.setup : "", + steps: stringList(item.steps), + oracle: typeof item.oracle === "string" ? item.oracle : "", + tooling: stringList(item.tooling), + evidence: Array.isArray(item.evidence) + ? item.evidence.map((entry, index) => { + const recordEntry = asRecord(entry); + return { + kind: typeof recordEntry?.kind === "string" ? recordEntry.kind : `evidence-${index + 1}`, + description: typeof recordEntry?.description === "string" ? recordEntry.description : "", + required: recordEntry?.required !== false, + }; + }) + : [], + claimed_by: stringList(item.claimed_by).map(normalizeId), + status: typeof item.status === "string" ? item.status : "unclaimed", + })), + }; + } + + const assertions: ValidationAssertion[] = []; + let current: ValidationAssertion | null = null; + for (const line of (document?.body ?? "").split(/\r?\n/)) { + const heading = parseHeading(line); + if (heading) { + const parsed = parseKeyedHeading(heading.text, VALIDATION_ID_RE); + if (parsed) { + current = { + id: parsed.id, + title: parsed.title, + user_value: "", + scope: "", + setup: "", + steps: [], + oracle: "", + tooling: ["manual_review"], + evidence: [], + claimed_by: [], + status: "unclaimed", + }; + assertions.push(current); + continue; + } + } + if (!current) continue; + const field = parseField(line); + if (!field) continue; + if (field.key === "user_value") current.user_value = field.value; + if (field.key === "scope") current.scope = field.value; + if (field.key === "setup") current.setup = field.value; + if (field.key === "steps") current.steps = splitList(field.value); + if (field.key === "oracle") current.oracle = field.value; + if (field.key === "tooling") current.tooling = splitList(field.value).map(normalizeEnumValue); + if (field.key === "evidence") { + current.evidence = splitList(field.value).map((description, index) => ({ + kind: index === 0 ? "primary" : `supporting-${index}`, + description, + required: true, + })); + } + if (["claimed_by", "claimed", "claims", "features"].includes(field.key)) { + current.claimed_by = uniqueIds([...current.claimed_by, ...extractIds(field.value, FEATURE_ID_RE)]); + } + if (field.key === "status") current.status = normalizeEnumValue(field.value); + } + return { assertions }; +} + +export function parseMissionFeaturesDocument(document: IssueDocument | null): FeaturePlan { + const parsedJson = parseJsonDocument(document); + if (parsedJson) { + const record = asRecord(parsedJson); + const milestones = Array.isArray(record?.milestones) ? record!.milestones : []; + return { + milestones: milestones + .map((milestone) => asRecord(milestone)) + .filter((milestone): milestone is Record => Boolean(milestone)) + .map((milestone, milestoneIndex) => ({ + id: typeof milestone.id === "string" ? normalizeId(milestone.id) : `MILESTONE-MISSION-${milestoneIndex + 1}`, + title: typeof milestone.title === "string" ? milestone.title : `Milestone ${milestoneIndex + 1}`, + summary: typeof milestone.summary === "string" ? milestone.summary : "", + features: Array.isArray(milestone.features) + ? milestone.features + .map((feature) => asRecord(feature)) + .filter((feature): feature is Record => Boolean(feature)) + .map((feature, featureIndex) => ({ + id: typeof feature.id === "string" ? normalizeId(feature.id) : `FEAT-MISSION-${featureIndex + 1}`, + title: typeof feature.title === "string" ? feature.title : `Feature ${featureIndex + 1}`, + kind: feature.kind === "fix" ? "fix" : "original", + summary: typeof feature.summary === "string" ? feature.summary : "", + acceptance_criteria: stringList(feature.acceptance_criteria), + claimed_assertion_ids: stringList(feature.claimed_assertion_ids).map(normalizeId), + status: typeof feature.status === "string" ? feature.status : "planned", + source_finding_id: + typeof feature.source_finding_id === "string" ? normalizeId(feature.source_finding_id) : null, + })) + : [], + })), + }; + } + + const milestoneById = new Map(); + let currentMilestone: FeaturePlan["milestones"][number] | null = null; + let currentFeature: FeaturePlan["milestones"][number]["features"][number] | null = null; + + function ensureMilestone(id: string, title: string) { + const normalizedId = normalizeId(id); + const existing = milestoneById.get(normalizedId); + if (existing) return existing; + const milestone = { + id: normalizedId, + title: title.trim() || normalizedId, + summary: title.trim() || normalizedId, + features: [], + }; + milestoneById.set(normalizedId, milestone); + return milestone; + } + + for (const line of (document?.body ?? "").split(/\r?\n/)) { + const heading = parseHeading(line); + if (heading) { + const featureHeading = parseKeyedHeading(heading.text, FEATURE_ID_RE); + if (featureHeading) { + currentMilestone ??= ensureMilestone("MILESTONE-MISSION-001", "Mission milestone"); + currentFeature = { + id: featureHeading.id, + title: featureHeading.title, + kind: "original", + summary: "", + acceptance_criteria: [], + claimed_assertion_ids: [], + status: "planned", + source_finding_id: null, + }; + currentMilestone.features.push(currentFeature); + continue; + } + const milestoneHeading = parseKeyedHeading(heading.text, MILESTONE_ID_RE); + if (milestoneHeading) { + currentMilestone = ensureMilestone(milestoneHeading.id, milestoneHeading.title); + currentFeature = null; + continue; + } + } + const field = parseField(line); + if (!field) continue; + if (currentFeature) { + if (field.key === "kind") currentFeature.kind = field.value.toLowerCase() === "fix" ? "fix" : "original"; + if (["summary", "description"].includes(field.key)) currentFeature.summary = field.value; + if (["acceptance_criteria", "success_criteria"].includes(field.key)) { + currentFeature.acceptance_criteria = splitList(field.value); + } + if (["claimed_assertion_ids", "claimed_by", "claims", "assertions", "validation"].includes(field.key)) { + currentFeature.claimed_assertion_ids = uniqueIds([ + ...currentFeature.claimed_assertion_ids, + ...extractIds(field.value, VALIDATION_ID_RE), + ]); + } + if (field.key === "source_finding_id") currentFeature.source_finding_id = field.value || null; + if (field.key === "status") currentFeature.status = normalizeEnumValue(field.value); + continue; + } + if (currentMilestone && ["summary", "description"].includes(field.key)) { + currentMilestone.summary = field.value; + } + } + + return { milestones: [...milestoneById.values()] }; +} + +export function validationReportRoundFromKey(key: string) { + const match = /^validation-report-round-([1-9][0-9]*)$/.exec(key); + return match ? Number.parseInt(match[1]!, 10) : null; +} + +export function isMissionValidationReportKey(key: string) { + return validationReportRoundFromKey(key) !== null; +} + +export function parseMissionValidationReportDocument(document: IssueDocument): ValidationReport { + const parsedJson = parseJsonDocument(document); + if (parsedJson) { + const record = asRecord(parsedJson); + return { + round: typeof record?.round === "number" ? record.round : validationReportRoundFromKey(document.key) ?? 1, + validator_role: + record?.validator_role === "user_testing_validator" ? "user_testing_validator" : "scrutiny_validator", + summary: typeof record?.summary === "string" ? record.summary : "", + findings: Array.isArray(record?.findings) + ? record!.findings + .map((finding) => asRecord(finding)) + .filter((finding): finding is Record => Boolean(finding)) + .map((finding, index) => ({ + id: typeof finding.id === "string" ? normalizeId(finding.id) : `FINDING-MISSION-${index + 1}`, + severity: + finding.severity === "blocking" || finding.severity === "non_blocking" ? finding.severity : "suggestion", + assertion_id: + typeof finding.assertion_id === "string" ? normalizeId(finding.assertion_id) : null, + title: typeof finding.title === "string" ? finding.title : `Finding ${index + 1}`, + evidence: stringList(finding.evidence), + repro_steps: stringList(finding.repro_steps), + expected: typeof finding.expected === "string" ? finding.expected : "", + actual: typeof finding.actual === "string" ? finding.actual : "", + suspected_area: typeof finding.suspected_area === "string" ? finding.suspected_area : null, + recommended_fix_scope: + typeof finding.recommended_fix_scope === "string" ? finding.recommended_fix_scope : null, + status: + finding.status === "fix_created" || finding.status === "waived" || finding.status === "resolved" + ? finding.status + : "open", + })) + : [], + }; + } + + const report: ValidationReport = { + round: validationReportRoundFromKey(document.key) ?? 1, + validator_role: "scrutiny_validator", + summary: "", + findings: [], + }; + let currentFinding: ValidationReport["findings"][number] | null = null; + + for (const line of document.body.split(/\r?\n/)) { + const heading = parseHeading(line); + if (heading) { + const findingHeading = parseKeyedHeading(heading.text, FINDING_ID_RE); + if (findingHeading) { + currentFinding = { + id: findingHeading.id, + severity: "suggestion", + assertion_id: null, + title: findingHeading.title, + evidence: [], + repro_steps: [], + expected: "", + actual: "", + suspected_area: null, + recommended_fix_scope: null, + status: "open", + }; + report.findings.push(currentFinding); + continue; + } + } + const field = parseField(line); + if (!field) continue; + if (!currentFinding) { + if (field.key === "round") report.round = Number.parseInt(field.value, 10) || report.round; + if (["validator_role", "role"].includes(field.key)) { + report.validator_role = + normalizeEnumValue(field.value) === "user_testing_validator" + ? "user_testing_validator" + : "scrutiny_validator"; + } + if (field.key === "summary") report.summary = field.value; + continue; + } + if (field.key === "severity") { + const severity = normalizeEnumValue(field.value); + currentFinding.severity = severity === "blocking" || severity === "non_blocking" ? severity : "suggestion"; + } + if (["assertion_id", "assertion", "validation"].includes(field.key)) { + currentFinding.assertion_id = extractIds(field.value, VALIDATION_ID_RE)[0] ?? null; + } + if (field.key === "title") currentFinding.title = field.value; + if (field.key === "evidence") currentFinding.evidence = splitList(field.value); + if (["repro_steps", "steps", "reproduction"].includes(field.key)) currentFinding.repro_steps = splitList(field.value); + if (field.key === "expected") currentFinding.expected = field.value; + if (field.key === "actual") currentFinding.actual = field.value; + if (field.key === "suspected_area") currentFinding.suspected_area = field.value || null; + if (field.key === "recommended_fix_scope") currentFinding.recommended_fix_scope = field.value || null; + if (field.key === "status") { + const next = normalizeEnumValue(field.value); + currentFinding.status = + next === "fix_created" || next === "waived" || next === "resolved" ? next : "open"; + } + } + + return report; +} + +function parseMissionFindingWaivers(decisionLogBody: string | null | undefined) { + const waivers = new Map(); + if (!decisionLogBody) return waivers; + const markerRe = //g; + const matches = [...decisionLogBody.matchAll(markerRe)]; + for (let index = 0; index < matches.length; index += 1) { + const match = matches[index]!; + const findingId = match[1]!; + const start = match.index! + match[0].length; + const end = matches[index + 1]?.index ?? decisionLogBody.length; + const block = decisionLogBody.slice(start, end); + const rationale = + /^\s*-\s+Rationale:\s*(.+?)\s*$/im.exec(block)?.[1]?.trim() ?? + block.trim().split(/\r?\n/).find((line) => line.trim())?.trim() ?? + ""; + waivers.set(findingId, rationale); + } + return waivers; +} + +function toIssueLite(issue: Issue, relation: PluginIssueRelationSummary | undefined): MissionIssueLite { + return { + id: issue.id, + identifier: issue.identifier ?? null, + title: issue.title, + status: issue.status, + priority: issue.priority, + originKind: issue.originKind ?? null, + originId: issue.originId ?? null, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + blockedBy: relation?.blockedBy ?? [], + }; +} + +function isTerminalIssue(status: string) { + return TERMINAL_ISSUE_STATUSES.has(status); +} + +function rootKindFor(pluginId: string, kind: "milestone" | "feature" | "validation" | "fix_loop") { + return childMissionOrigin(pluginId, kind); +} + +function originKey(issue: Pick, kind: string) { + const value = issue.originId ?? ""; + const prefix = `${kind}:`; + const last = value.split(prefix); + return last.length > 1 ? last[last.length - 1] ?? null : null; +} + +function buildMilestones(input: { + pluginId: string; + rootIssue: Issue; + descendants: Issue[]; + relations: Record; + featurePlan: FeaturePlan | null; +}): MissionMilestoneProjection[] { + const milestoneByKey = new Map(); + const milestoneKeyByIssueId = new Map(); + const milestoneKeyByFeatureKey = new Map(); + const summaryByIssueId = new Map(); + + for (const planned of input.featurePlan?.milestones ?? []) { + milestoneByKey.set(planned.id, { + key: planned.id, + title: planned.title, + summary: planned.summary, + issue: null, + features: [], + validations: [], + fixLoops: [], + blockers: [], + }); + for (const feature of planned.features) { + milestoneKeyByFeatureKey.set(feature.id, planned.id); + } + } + + for (const issue of input.descendants) { + summaryByIssueId.set(issue.id, toIssueLite(issue, input.relations[issue.id])); + } + + for (const issue of input.descendants.filter((candidate) => candidate.originKind === rootKindFor(input.pluginId, "milestone"))) { + const key = originKey(issue, "milestone") ?? issue.id; + const existing = milestoneByKey.get(key); + const summaryIssue = summaryByIssueId.get(issue.id) ?? toIssueLite(issue, input.relations[issue.id]); + const next = existing ?? { + key, + title: issue.title, + summary: issue.description ?? null, + issue: null, + features: [], + validations: [], + fixLoops: [], + blockers: [], + }; + next.issue = summaryIssue; + milestoneByKey.set(key, next); + milestoneKeyByIssueId.set(issue.id, key); + } + + function ensureUngrouped() { + const key = "ungrouped"; + const existing = milestoneByKey.get(key); + if (existing) return existing; + const projection: MissionMilestoneProjection = { + key, + title: "Ungrouped mission work", + summary: "Generated mission work that is not tied to a parsed milestone.", + issue: null, + features: [], + validations: [], + fixLoops: [], + blockers: [], + }; + milestoneByKey.set(key, projection); + return projection; + } + + for (const issue of input.descendants) { + const lite = summaryByIssueId.get(issue.id); + if (!lite) continue; + const featureKey = + issue.originKind === rootKindFor(input.pluginId, "feature") ? originKey(issue, "feature") : null; + const validationKey = + issue.originKind === rootKindFor(input.pluginId, "validation") ? originKey(issue, "validation") : null; + const fixLoopKey = + issue.originKind === rootKindFor(input.pluginId, "fix_loop") ? originKey(issue, "fix_loop") : null; + const milestoneKey = + (featureKey ? milestoneKeyByFeatureKey.get(featureKey) : null) ?? + (validationKey ? validationKey.replace(/:round-[1-9][0-9]*$/, "") : null) ?? + fixLoopKey ?? + (issue.parentId ? milestoneKeyByIssueId.get(issue.parentId) : null); + const milestone = milestoneKey ? milestoneByKey.get(milestoneKey) ?? ensureUngrouped() : ensureUngrouped(); + + if (issue.originKind === rootKindFor(input.pluginId, "feature")) milestone.features.push(lite); + if (issue.originKind === rootKindFor(input.pluginId, "validation")) milestone.validations.push(lite); + if (issue.originKind === rootKindFor(input.pluginId, "fix_loop")) milestone.fixLoops.push(lite); + } + + for (const milestone of milestoneByKey.values()) { + const work = [ + ...(milestone.issue ? [milestone.issue] : []), + ...milestone.features, + ...milestone.validations, + ...milestone.fixLoops, + ]; + const blocked = work + .map((issue) => ({ + issue, + blockers: issue.blockedBy.filter((blocker) => !isTerminalIssue(blocker.status)), + })) + .filter((item) => item.issue.status === "blocked" || item.blockers.length > 0); + const seen = new Set(); + milestone.blockers = blocked.filter((item) => { + if (seen.has(item.issue.id)) return false; + seen.add(item.issue.id); + return true; + }); + } + + return [...milestoneByKey.values()]; +} + +function buildValidationSummary(input: { + descendants: Issue[]; + relations: Record; + reportDocuments: IssueDocument[]; + decisionLog: IssueDocument | null; +}) : MissionValidationSummary { + const byIssueId = new Map(); + for (const issue of input.descendants) { + byIssueId.set(issue.id, toIssueLite(issue, input.relations[issue.id])); + } + + const fixIssueByFindingId = new Map(); + for (const issue of input.descendants) { + const match = /fix:(FINDING-[A-Z0-9][A-Z0-9-]*-[0-9]{3,})/.exec(issue.originId ?? ""); + if (match) { + const lite = byIssueId.get(issue.id); + if (lite) fixIssueByFindingId.set(match[1]!, lite); + } + } + + const waivers = parseMissionFindingWaivers(input.decisionLog?.body); + const reports: MissionValidationReportProjection[] = []; + const findings: MissionFindingProjection[] = []; + + for (const document of input.reportDocuments) { + const report = parseMissionValidationReportDocument(document); + const reportProjection: MissionValidationReportProjection = { + round: report.round, + validatorRole: report.validator_role, + summary: report.summary, + findings: [], + documentKey: document.key, + documentTitle: document.title ?? null, + updatedAt: document.updatedAt?.toISOString?.() ?? String(document.updatedAt ?? ""), + }; + + for (const finding of report.findings) { + const fixIssue = fixIssueByFindingId.get(finding.id) ?? null; + const waiverRationale = waivers.get(finding.id) ?? null; + const computedStatus: MissionFindingStatus = waiverRationale + ? "waived" + : fixIssue + ? fixIssue.status === "done" + ? "resolved" + : "fix_created" + : finding.status; + const projection: MissionFindingProjection = { + id: finding.id, + severity: finding.severity, + assertionId: finding.assertion_id ?? null, + title: finding.title, + evidence: finding.evidence, + reproSteps: finding.repro_steps, + expected: finding.expected, + actual: finding.actual, + suspectedArea: finding.suspected_area ?? null, + recommendedFixScope: finding.recommended_fix_scope ?? null, + status: finding.status, + sourceReportKey: document.key, + sourceReportTitle: document.title ?? null, + round: report.round, + validatorRole: report.validator_role, + computedStatus, + fixIssue, + waiverRationale, + }; + reportProjection.findings.push(projection); + findings.push(projection); + } + + reports.push(reportProjection); + } + + const counts = { + total: findings.length, + bySeverity: { blocking: 0, non_blocking: 0, suggestion: 0 } as Record, + byStatus: { open: 0, fix_created: 0, waived: 0, resolved: 0 } as Record, + }; + for (const finding of findings) { + counts.bySeverity[finding.severity] += 1; + counts.byStatus[finding.computedStatus] += 1; + } + + return { + reports, + findings, + counts, + openBlockingFindingCount: findings.filter( + (finding) => finding.severity === "blocking" && finding.computedStatus === "open", + ).length, + }; +} + +function buildGovernanceStops(input: { + approvals: PluginIssueApprovalSummary[]; + openBudgetIncidents: PluginIssueOrchestrationSummary["openBudgetIncidents"]; + invocationBlocks: PluginIssueInvocationBlockSummary[]; +}): MissionGovernanceStop[] { + const approvalStops = input.approvals + .filter((approval) => approval.status !== "approved") + .map((approval) => ({ + kind: "approval", + id: approval.id, + label: "Approval pending", + detail: `${approval.type} is ${approval.status}.`, + issueId: approval.issueId, + createdAt: approval.createdAt, + })); + const budgetStops = input.openBudgetIncidents.map((incident) => ({ + kind: "budget", + id: incident.id, + label: "Budget stop", + detail: `${incident.scopeType} ${incident.thresholdType} ${incident.metric} limit hit (${incident.amountObserved}/${incident.amountLimit}).`, + createdAt: incident.createdAt, + })); + const invocationStops = input.invocationBlocks.map((block) => ({ + kind: "invocation_block", + id: `${block.issueId}:${block.scopeId}`, + label: "Invocation blocked", + detail: block.reason, + createdAt: null, + issueId: block.issueId, + })); + return [...approvalStops, ...budgetStops, ...invocationStops]; +} + +function deriveMissionState(input: { + rootIssue: Issue; + presentDocumentKeys: string[]; + activeWork: MissionIssueLite[]; + blockers: MissionBlockedWorkItem[]; + validationSummary: MissionValidationSummary; + governanceStops: MissionGovernanceStop[]; + hasFinalReport: boolean; +}) : MissionState { + if (input.rootIssue.status === "done" || input.hasFinalReport) return "complete"; + if (input.rootIssue.status === "blocked" || input.governanceStops.length > 0 || input.blockers.length > 0) return "blocked"; + const present = new Set(input.presentDocumentKeys); + if (!present.has("mission-brief") || !present.has("validation-contract")) return "draft"; + if (!present.has("features")) return "planning"; + if (input.activeWork.some((issue) => issue.originKind?.endsWith(":fix_loop"))) return "fixing"; + if (input.activeWork.some((issue) => issue.originKind?.endsWith(":validation"))) return "validating"; + if (input.validationSummary.openBlockingFindingCount > 0) return "blocked"; + if (input.activeWork.some((issue) => issue.originKind?.endsWith(":feature"))) return "running"; + return "planning"; +} + +function deriveNextAction(input: { + missingDocumentKeys: RequiredDocumentKey[]; + documentErrors: MissionDocumentError[]; + blockers: MissionBlockedWorkItem[]; + activeWork: MissionIssueLite[]; + validationSummary: MissionValidationSummary; + hasGeneratedWork: boolean; + hasFinalReport: boolean; + governanceStops: MissionGovernanceStop[]; +}) { + if (input.governanceStops.length > 0) return "Clear mission governance stops before advancing work."; + if (input.documentErrors.length > 0) return "Fix mission document parsing errors."; + if (input.missingDocumentKeys.length > 0) return `Complete required mission documents: ${input.missingDocumentKeys.join(", ")}.`; + if (input.blockers.length > 0) return "Resolve blocking issues before advancing mission work."; + if (input.activeWork.some((issue) => issue.originKind?.endsWith(":validation"))) { + return "Review active validation work and capture findings."; + } + if (input.validationSummary.openBlockingFindingCount > 0) { + return "Create bounded fix issues for open blocking validation findings."; + } + if (input.validationSummary.counts.byStatus.open > 0) { + return "Triage open validation findings or record waivers."; + } + if (input.activeWork.some((issue) => issue.originKind?.endsWith(":fix_loop"))) { + return "Drive fix work to completion and re-run validation."; + } + if (input.activeWork.some((issue) => issue.originKind?.endsWith(":feature"))) { + return "Continue active feature work and collect implementation evidence."; + } + if (!input.hasGeneratedWork) return "Decompose the mission into milestone and feature issues."; + if (!input.hasFinalReport) return "Write the final mission report."; + return "Review mission state and choose the next controlled transition."; +} + +function buildCommands(input: { + isMission: boolean; + missingDocumentKeys: RequiredDocumentKey[]; + documentErrors: MissionDocumentError[]; + hasGeneratedWork: boolean; + validationSummary: MissionValidationSummary; +}) : MissionCommand[] { + if (!input.isMission) { + return [{ key: "initialize", label: "Initialize mission", enabled: true, reason: null }]; + } + const planningHealthy = input.missingDocumentKeys.length === 0 && input.documentErrors.length === 0; + return [ + { + key: "initialize", + label: "Initialize mission", + enabled: false, + reason: "This issue is already initialized as a mission.", + }, + { + key: "decompose", + label: input.hasGeneratedWork ? "Sync plan" : "Decompose", + enabled: false, + reason: planningHealthy ? "Decomposition wiring lands in PAP-1687." : "Fix document health before decomposing.", + }, + { + key: "advance", + label: "Advance", + enabled: false, + reason: planningHealthy ? "Advance loop lands in PAP-1689." : "Fix document health before advancing.", + }, + { + key: "waive", + label: "Waive finding", + enabled: false, + reason: input.validationSummary.findings.length > 0 ? "Finding waivers land in PAP-1689." : "No findings available.", + }, + ]; +} + +export function buildMissionSummary(input: BuildMissionSummaryInput): MissionSummary { + const rootDocuments = input.documentSummaries; + const documentChecklist = REQUIRED_DOCUMENT_KEYS.map((key) => { + const document = rootDocuments.find((candidate) => candidate.key === key); + return { + key, + title: document?.title ?? null, + present: Boolean(document), + latestRevisionNumber: document?.latestRevisionNumber ?? null, + updatedAt: document?.updatedAt?.toISOString?.() ?? (document?.updatedAt ? String(document.updatedAt) : null), + }; + }); + const missingDocumentKeys = REQUIRED_DOCUMENT_KEYS.filter( + (key) => !rootDocuments.some((document) => document.key === key), + ); + const documentErrors: MissionDocumentError[] = []; + + try { + parseMissionValidationContractDocument(input.requiredDocuments["validation-contract"] ?? null); + } catch (error) { + documentErrors.push({ key: "validation-contract", message: error instanceof Error ? error.message : "Invalid document." }); + } + + let featurePlan: FeaturePlan | null = null; + try { + featurePlan = parseMissionFeaturesDocument(input.requiredDocuments.features ?? null); + } catch (error) { + documentErrors.push({ key: "features", message: error instanceof Error ? error.message : "Invalid document." }); + } + + const descendants = input.subtreeIssues.filter((issue) => issue.id !== input.rootIssue.id); + const rootLite = toIssueLite(input.rootIssue, input.relations[input.rootIssue.id]); + const descendantLites = descendants.map((issue) => toIssueLite(issue, input.relations[issue.id])); + const activeWork = descendantLites.filter((issue) => !isTerminalIssue(issue.status)); + const blockers = [rootLite, ...descendantLites] + .map((issue) => ({ + issue, + blockers: issue.blockedBy.filter((blocker) => !isTerminalIssue(blocker.status)), + })) + .filter((item) => item.issue.status === "blocked" || item.blockers.length > 0); + const dedupedBlockers: MissionBlockedWorkItem[] = []; + const seenBlockers = new Set(); + for (const item of blockers) { + if (seenBlockers.has(item.issue.id)) continue; + seenBlockers.add(item.issue.id); + dedupedBlockers.push(item); + } + + const validationSummary = buildValidationSummary({ + descendants, + relations: input.relations, + reportDocuments: input.validationReports, + decisionLog: input.decisionLog, + }); + const milestones = buildMilestones({ + pluginId: input.pluginId, + rootIssue: input.rootIssue, + descendants, + relations: input.relations, + featurePlan, + }); + const governanceStops = buildGovernanceStops({ + approvals: input.orchestration.approvals, + openBudgetIncidents: input.orchestration.openBudgetIncidents, + invocationBlocks: input.orchestration.invocationBlocks, + }); + const state = deriveMissionState({ + rootIssue: input.rootIssue, + presentDocumentKeys: rootDocuments.map((document) => document.key), + activeWork, + blockers: dedupedBlockers, + validationSummary, + governanceStops, + hasFinalReport: rootDocuments.some((document) => document.key === "mission-final-report"), + }); + const hasGeneratedWork = descendants.some((issue) => isMissionOrigin(input.pluginId, issue.originKind)); + const nextAction = deriveNextAction({ + missingDocumentKeys, + documentErrors, + blockers: dedupedBlockers, + activeWork, + validationSummary, + hasGeneratedWork, + hasFinalReport: rootDocuments.some((document) => document.key === "mission-final-report"), + governanceStops, + }); + const sortedRuns = [...input.orchestration.runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + return { + missionIssueId: input.rootIssue.id, + missionIdentifier: input.rootIssue.identifier ?? null, + missionTitle: input.rootIssue.title, + state, + documentChecklist, + missingDocumentKeys, + documentErrors, + milestones, + blockers: dedupedBlockers, + activeWork, + validationSummary, + runSummary: { + total: input.orchestration.runs.length, + active: input.orchestration.runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status)).length, + latestRunId: sortedRuns[0]?.id ?? null, + latestRunStatus: sortedRuns[0]?.status ?? null, + }, + costSummary: { + costCents: input.orchestration.costs.costCents, + inputTokens: input.orchestration.costs.inputTokens, + cachedInputTokens: input.orchestration.costs.cachedInputTokens, + outputTokens: input.orchestration.costs.outputTokens, + billingCode: input.orchestration.costs.billingCode, + }, + approvals: input.orchestration.approvals, + governanceStops, + nextAction, + availableCommands: buildCommands({ + isMission: true, + missingDocumentKeys, + documentErrors, + hasGeneratedWork, + validationSummary, + }), + }; +} + +async function getRootDocuments(ctx: PluginContext, companyId: string, issueId: string) { + const summaries = await ctx.issues.documents.list(issueId, companyId); + const relevantKeys = summaries + .map((document) => document.key) + .filter((key) => (REQUIRED_DOCUMENT_KEYS as readonly string[]).includes(key) || isMissionValidationReportKey(key) || key === "mission-final-report"); + const docs = await Promise.all( + relevantKeys.map(async (key) => [key, await ctx.issues.documents.get(issueId, key, companyId)] as const), + ); + const docMap = new Map(); + for (const [key, document] of docs) { + if (document) docMap.set(key, document); + } + return { + summaries, + documents: docMap, + decisionLog: (await ctx.issues.documents.get(issueId, "decision-log", companyId)) ?? null, + }; +} + +export async function loadMissionSummary(input: LoadMissionSummaryInput): Promise { + const rootIssue = await input.ctx.issues.get(input.missionRootIssueId, input.companyId); + if (!rootIssue) { + throw new Error(`Mission root issue not found: ${input.missionRootIssueId}`); + } + const [subtree, orchestration, rootDocuments] = await Promise.all([ + input.ctx.issues.getSubtree(rootIssue.id, input.companyId, { + includeRelations: true, + includeDocuments: true, + includeActiveRuns: true, + includeAssignees: true, + }), + input.ctx.issues.summaries.getOrchestration({ + issueId: rootIssue.id, + companyId: input.companyId, + includeSubtree: true, + billingCode: rootIssue.billingCode ?? null, + }), + getRootDocuments(input.ctx, input.companyId, rootIssue.id), + ]); + + return buildMissionSummary({ + pluginId: input.ctx.manifest.id, + rootIssue, + subtreeIssues: subtree.issues, + relations: subtree.relations ?? {}, + orchestration, + documentSummaries: rootDocuments.summaries, + requiredDocuments: { + plan: rootDocuments.documents.get("plan") ?? null, + "mission-brief": rootDocuments.documents.get("mission-brief") ?? null, + "validation-contract": rootDocuments.documents.get("validation-contract") ?? null, + features: rootDocuments.documents.get("features") ?? null, + "worker-guidelines": rootDocuments.documents.get("worker-guidelines") ?? null, + services: rootDocuments.documents.get("services") ?? null, + "knowledge-base": rootDocuments.documents.get("knowledge-base") ?? null, + "decision-log": rootDocuments.documents.get("decision-log") ?? null, + }, + validationReports: [...rootDocuments.documents.values()].filter((document) => isMissionValidationReportKey(document.key)), + decisionLog: rootDocuments.decisionLog, + }); +} + +export async function readMissionSettings(ctx: PluginContext, companyId: string): Promise { + const stored = await ctx.state.get({ scopeKind: "company", scopeId: companyId, stateKey: "mission-settings" }); + const record = asRecord(stored); + return { + maxValidationRounds: + typeof record?.maxValidationRounds === "number" && record.maxValidationRounds > 0 + ? Math.floor(record.maxValidationRounds) + : DEFAULT_SETTINGS.maxValidationRounds, + requireBlackBoxValidation: + typeof record?.requireBlackBoxValidation === "boolean" + ? record.requireBlackBoxValidation + : DEFAULT_SETTINGS.requireBlackBoxValidation, + defaultWorkerAgentId: + typeof record?.defaultWorkerAgentId === "string" && record.defaultWorkerAgentId.trim() + ? record.defaultWorkerAgentId + : null, + defaultValidatorAgentId: + typeof record?.defaultValidatorAgentId === "string" && record.defaultValidatorAgentId.trim() + ? record.defaultValidatorAgentId + : null, + defaultBillingCodePolicy: + record?.defaultBillingCodePolicy === "stable-prefix" ? "stable-prefix" : DEFAULT_SETTINGS.defaultBillingCodePolicy, + autoAdvance: typeof record?.autoAdvance === "boolean" ? record.autoAdvance : DEFAULT_SETTINGS.autoAdvance, + }; +} + +export async function writeMissionSettings( + ctx: PluginContext, + companyId: string, + patch: Partial, +): Promise { + const next = { ...(await readMissionSettings(ctx, companyId)), ...patch }; + await ctx.state.set({ scopeKind: "company", scopeId: companyId, stateKey: "mission-settings" }, next); + return next; +} + +export async function initializeMission( + ctx: PluginContext, + companyId: string, + issueId: string, +): Promise { + const issue = await ctx.issues.get(issueId, companyId); + if (!issue) throw new Error("Issue not found"); + + const existing = await ctx.issues.documents.list(issue.id, companyId); + const existingKeys = new Set(existing.map((document) => document.key)); + for (const key of REQUIRED_DOCUMENT_KEYS) { + if (existingKeys.has(key)) continue; + const template = defaultMissionDocument({ issue, key }); + await ctx.issues.documents.upsert({ + issueId: issue.id, + key, + companyId, + title: template.title, + format: "markdown", + body: template.body, + changeSummary: "Initialize mission document bundle", + }); + } + + const nextOriginKind = rootMissionOrigin(ctx.manifest.id); + const nextOriginId = issue.originId ?? issue.identifier ?? issue.id; + const nextBillingCode = missionBillingCode(issue); + if (issue.originKind !== nextOriginKind || issue.originId !== nextOriginId || issue.billingCode !== nextBillingCode) { + await ctx.issues.update( + issue.id, + { + originKind: nextOriginKind, + originId: nextOriginId, + billingCode: nextBillingCode, + }, + companyId, + ); + } + + return loadMissionPanelData(ctx, companyId, issueId); +} + +export async function resolveMissionRootIssue( + ctx: PluginContext, + companyId: string, + issueId: string, +): Promise { + let current = await ctx.issues.get(issueId, companyId); + while (current) { + if (isRootMission(ctx.manifest.id, current)) return current; + if (!current.parentId) return null; + current = await ctx.issues.get(current.parentId, companyId); + } + return null; +} + +export async function loadMissionPanelData( + ctx: PluginContext, + companyId: string, + issueId: string, +): Promise { + const currentIssue = await ctx.issues.get(issueId, companyId); + if (!currentIssue) throw new Error("Issue not found"); + const missionRoot = await resolveMissionRootIssue(ctx, companyId, issueId); + if (!missionRoot) { + return { + mode: "not_mission", + issue: toIssueLite(currentIssue, { blockedBy: [], blocks: [] }), + availableCommands: buildCommands({ + isMission: false, + missingDocumentKeys: REQUIRED_DOCUMENT_KEYS, + documentErrors: [], + hasGeneratedWork: false, + validationSummary: { + reports: [], + findings: [], + counts: { + total: 0, + bySeverity: { blocking: 0, non_blocking: 0, suggestion: 0 }, + byStatus: { open: 0, fix_created: 0, waived: 0, resolved: 0 }, + }, + openBlockingFindingCount: 0, + }, + }), + }; + } + return { + mode: "mission", + currentIssueId: currentIssue.id, + currentIssueIdentifier: currentIssue.identifier ?? null, + currentIssueTitle: currentIssue.title, + missionRootIssueId: missionRoot.id, + missionRootIdentifier: missionRoot.identifier ?? null, + summary: await loadMissionSummary({ ctx, companyId, missionRootIssueId: missionRoot.id }), + }; +} + +export async function listMissionSummaries(ctx: PluginContext, companyId: string): Promise { + const roots = await ctx.issues.list({ + companyId, + originKind: rootMissionOrigin(ctx.manifest.id), + limit: 200, + }); + const summaries = await Promise.all( + roots.map(async (root) => ({ + root, + summary: await loadMissionSummary({ ctx, companyId, missionRootIssueId: root.id }), + })), + ); + return summaries + .map(({ root, summary }) => ({ + missionIssueId: summary.missionIssueId, + missionIdentifier: summary.missionIdentifier, + missionTitle: summary.missionTitle, + state: summary.state, + nextAction: summary.nextAction, + activeWorkCount: summary.activeWork.length, + blockerCount: summary.blockers.length, + milestoneCount: summary.milestones.length, + featureCount: summary.milestones.reduce((total, milestone) => total + milestone.features.length, 0), + validationFindingCount: summary.validationSummary.counts.total, + governanceStopCount: summary.governanceStops.length, + documentHealth: { + present: summary.documentChecklist.filter((item) => item.present).length, + total: summary.documentChecklist.length, + errors: summary.documentErrors.length, + }, + costCents: summary.costSummary.costCents, + latestRunStatus: summary.runSummary.latestRunStatus, + updatedAt: root.updatedAt.toISOString(), + })) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} diff --git a/packages/plugins/plugin-missions/src/ui/index.tsx b/packages/plugins/plugin-missions/src/ui/index.tsx index cf25541cdfa..d83e00a5992 100644 --- a/packages/plugins/plugin-missions/src/ui/index.tsx +++ b/packages/plugins/plugin-missions/src/ui/index.tsx @@ -7,6 +7,8 @@ import { type PluginSettingsPageProps, } from "@paperclipai/plugin-sdk/ui"; import type React from "react"; +import { useState } from "react"; +import type { MissionInitializationResult, MissionIssueSummary } from "../mission-initialization.js"; type EntitySlotProps = { context: PluginHostContext & { @@ -26,24 +28,7 @@ type SurfaceStatus = { message: string; }; -type MissionSummary = { - status: "not_configured"; - companyId: string; - issueId: string; - missionIssueId: string; - missionIdentifier: string | null; - missionTitle: string; - childIssueCount: number; - documentCount: number; - blockerCount: number; - routeKeys: string[]; - uiSlotIds: string[]; - databaseNamespace: string; - message: string; -}; - type MissionsList = { - status: "not_configured"; companyId: string; missions: Array<{ issueId: string; @@ -94,33 +79,71 @@ const secondaryButtonStyle = { color: "#111827", } satisfies React.CSSProperties; -function IssueScopedPanel({ - data, - onAdvance, -}: { - data: MissionSummary; - onAdvance: () => Promise; -}) { +function SummaryRows({ data }: { data: MissionIssueSummary }) { return ( -
-
- Mission Scaffold - -
-
-
Issue{data.missionIdentifier ?? data.issueId}
-
Children{data.childIssueCount}
-
Documents{data.documentCount}
-
Blockers{data.blockerCount}
-
Namespace{data.databaseNamespace}
-
-
{data.message}
+
+
State{data.state ?? "not-initialized"}
+
Billing{data.settings.billingCode ?? "unset"}
+
Namespace{data.settings.databaseNamespace}
+
Open Findings{data.openFindingCount}
); } +function Checklist({ data }: { data: MissionIssueSummary }) { + return ( +
+ {data.documentChecklist.map((item) => ( +
+ {item.key} + {item.present ? item.title ?? "present" : "missing"} +
+ ))} +
+ ); +} + +function InitializeMissionButton({ + companyId, + issueId, + disabledReason, + onDone, +}: { + companyId: string; + issueId: string; + disabledReason: string | null; + onDone?: (result: MissionInitializationResult) => Promise | void; +}) { + const initializeMission = usePluginAction("initialize-mission"); + const toast = usePluginToast(); + const [pending, setPending] = useState(false); + + return ( + + ); +} + export function MissionsPage({ context }: PluginPageProps) { const companyId = context.companyId; const { data, loading, error, refresh } = usePluginData("missions-list", { @@ -128,7 +151,7 @@ export function MissionsPage({ context }: PluginPageProps) { }); if (!companyId) return
Open a company to view missions.
; - if (loading) return
Loading missions scaffold...
; + if (loading) return
Loading missions...
; if (error) return
Missions page error: {error.message}
; if (!data) return null; @@ -142,7 +165,7 @@ export function MissionsPage({ context }: PluginPageProps) {
{data.message}
{data.missions.length === 0 ? ( -
No mission issues are registered yet.
+
No root mission issues have been initialized yet.
) : (
{data.missions.map((mission) => ( @@ -158,56 +181,77 @@ export function MissionsPage({ context }: PluginPageProps) { } export function MissionIssuePanel({ context }: EntitySlotProps) { - const { data, loading, error, refresh } = usePluginData("mission-summary", { + const { data, loading, error, refresh } = usePluginData("mission-summary", { companyId: context.companyId, issueId: context.entityId, }); - const advanceMission = usePluginAction("advance-mission"); - const toast = usePluginToast(); if (!context.companyId || !context.entityId) { return
Mission controls need an issue context.
; } - if (loading) return
Loading mission scaffold...
; + if (loading) return
Loading mission summary...
; if (error) return
Mission panel error: {error.message}
; if (!data) return null; + if (!data.isMission) { + return ( +
+ Mission +
This issue has not been initialized as a mission yet.
+
{data.nextAction}
+ { + await refresh(); + }} + /> +
+ ); + } + return ( - { - await advanceMission({ companyId: context.companyId, issueId: context.entityId }); - toast({ - title: "Mission route reserved", - body: "Advance is wired, but runtime behavior lands in a follow-up issue.", - tone: "info", - }); - refresh(); - }} - /> +
+
+ Mission + +
+ +
Next action: {data.nextAction}
+ + {data.parseProblems.length > 0 ? ( +
+ {data.parseProblems.map((problem) => ( +
+ {problem.key}: {problem.message} +
+ ))} +
+ ) : null} +
); } export function MissionToolbarButton({ context }: EntitySlotProps) { - const initializeMission = usePluginAction("initialize-mission"); - const toast = usePluginToast(); + const { data, loading, refresh } = usePluginData("mission-summary", { + companyId: context.companyId, + issueId: context.entityId, + }); + + if (!context.companyId || !context.entityId || loading || !data || data.isMission) return null; return ( - + /> ); } diff --git a/packages/plugins/plugin-missions/src/worker.ts b/packages/plugins/plugin-missions/src/worker.ts index ec377d7599d..d05735aeffb 100644 --- a/packages/plugins/plugin-missions/src/worker.ts +++ b/packages/plugins/plugin-missions/src/worker.ts @@ -1,16 +1,52 @@ -import type { Issue, PluginApiRequestInput, PluginContext } from "@paperclipai/plugin-sdk"; -import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; +import { definePlugin, runWorker, type PluginApiRequestInput } from "@paperclipai/plugin-sdk"; +import { decomposeMission, type MissionDecompositionResult } from "./decompose.js"; +import { initializeMission, type MissionInitializationResult } from "./mission-initialization.js"; +import { + advanceMission, + buildMissionSummary, + waiveMissionFinding, + type MissionAdvanceResult, + type MissionSummary, + type MissionWaiveFindingResult, +} from "./mission-runtime.js"; import manifest, { MISSIONS_API_ROUTE_KEYS, MISSIONS_UI_SLOT_IDS } from "./manifest.js"; const PLUGIN_ORIGIN = `plugin:${manifest.id}` as const; -type PlaceholderResponse = { - status: "not_configured"; - routeKey: string; +type InitializeInput = { companyId: string; - issueId: string | null; - findingKey: string | null; - message: string; + issueId: string; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; +}; + +type DecomposeInput = { + companyId: string; + issueId: string; + dryRun?: boolean; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; +}; + +type AdvanceInput = { + companyId: string; + issueId: string; + maxValidationRounds?: number; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; +}; + +type WaiveInput = { + companyId: string; + issueId: string; + findingId: string; + rationale: string; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; }; type SurfaceStatus = { @@ -21,118 +57,91 @@ type SurfaceStatus = { routeKeys: string[]; uiSlotIds: string[]; pluginId: string; - message: string; -}; - -type MissionSummary = { - status: "not_configured"; - companyId: string; - issueId: string; - missionIssueId: string; - missionIdentifier: string | null; - missionTitle: string; - childIssueCount: number; - documentCount: number; - blockerCount: number; - routeKeys: string[]; - uiSlotIds: string[]; - databaseNamespace: string; - message: string; -}; - -type MissionsListItem = { - issueId: string; - identifier: string | null; - title: string; - status: Issue["status"]; }; type MissionsList = { - status: "not_configured"; + status: "ok"; companyId: string; - missions: MissionsListItem[]; - routeKeys: string[]; - pageRoute: string; - message: string; + missions: Array<{ + issueId: string; + identifier: string | null; + title: string; + status: string; + }>; }; -let getSurfaceStatus: ((companyId: string | null) => Promise) | null = null; -let getMissionSummary: ((companyId: string, issueId: string) => Promise) | null = null; -let getMissionsList: ((companyId: string) => Promise) | null = null; +let runInitialize: ((input: InitializeInput) => Promise) | null = null; +let runDecompose: ((input: DecomposeInput) => Promise) | null = null; +let loadMissionSummary: ((companyId: string, issueId: string) => Promise) | null = null; +let runAdvance: ((input: AdvanceInput) => Promise) | null = null; +let runWaive: ((input: WaiveInput) => Promise) | null = null; +let listMissions: ((companyId: string) => Promise) | null = null; -function requireString(value: unknown, field: string): string { - if (typeof value !== "string" || value.trim().length === 0) { - throw new Error(`${field} is required`); - } - return value.trim(); +function stringField(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -async function requireIssue(ctx: PluginContext, companyId: string, issueId: string) { - const issue = await ctx.issues.get(issueId, companyId); - if (!issue) throw new Error(`Issue not found: ${issueId}`); - return issue; +function booleanField(value: unknown) { + return value === true || value === "true"; } -function buildPlaceholderResponse(input: { - routeKey: string; - companyId: string; - issueId?: string | null; - findingKey?: string | null; -}): PlaceholderResponse { - return { - status: "not_configured", - routeKey: input.routeKey, - companyId: input.companyId, - issueId: input.issueId ?? null, - findingKey: input.findingKey ?? null, - message: "Missions Phase 0 scaffold is installed. Runtime behavior lands in follow-up issues.", - }; +function numberField(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; } const plugin = definePlugin({ async setup(ctx) { - getSurfaceStatus = async (companyId) => ({ - status: "ok", - checkedAt: new Date().toISOString(), - companyId, - databaseNamespace: ctx.db.namespace, - routeKeys: [...MISSIONS_API_ROUTE_KEYS], - uiSlotIds: [...MISSIONS_UI_SLOT_IDS], - pluginId: ctx.manifest.id, - message: "Missions scaffold loaded successfully.", - }); + runInitialize = async (input: InitializeInput) => + initializeMission(ctx, { + companyId: input.companyId, + issueId: input.issueId, + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + }); - getMissionSummary = async (companyId, issueId) => { - const issue = await requireIssue(ctx, companyId, issueId); - const subtree = await ctx.issues.getSubtree(issueId, companyId, { includeRoot: false }); - const documents = await ctx.issues.documents.list(issueId, companyId); - const relations = await ctx.issues.relations.get(issueId, companyId); + runDecompose = async (input: DecomposeInput) => + decomposeMission(ctx, { + companyId: input.companyId, + issueId: input.issueId, + dryRun: input.dryRun, + actor: { + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + }, + }); - return { - status: "not_configured", - companyId, - issueId, - missionIssueId: issue.id, - missionIdentifier: issue.identifier, - missionTitle: issue.title, - childIssueCount: subtree.issueIds.length, - documentCount: documents.length, - blockerCount: relations.blockedBy.length, - routeKeys: [...MISSIONS_API_ROUTE_KEYS], - uiSlotIds: [...MISSIONS_UI_SLOT_IDS], - databaseNamespace: ctx.db.namespace, - message: "Mission summary is a scaffold response until the runtime issues land.", - }; - }; + loadMissionSummary = async (companyId: string, issueId: string) => buildMissionSummary(ctx, companyId, issueId); - getMissionsList = async (companyId) => { + runAdvance = async (input: AdvanceInput) => + advanceMission(ctx, { + companyId: input.companyId, + issueId: input.issueId, + maxValidationRounds: input.maxValidationRounds, + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + }); + + runWaive = async (input: WaiveInput) => + waiveMissionFinding(ctx, { + companyId: input.companyId, + issueId: input.issueId, + findingId: input.findingId, + rationale: input.rationale, + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + }); + + listMissions = async (companyId: string) => { const issues = await ctx.issues.list({ companyId, originKind: PLUGIN_ORIGIN, }); - return { - status: "not_configured", + status: "ok", companyId, missions: issues.map((issue) => ({ issueId: issue.id, @@ -140,145 +149,205 @@ const plugin = definePlugin({ title: issue.title, status: issue.status, })), - routeKeys: [...MISSIONS_API_ROUTE_KEYS], - pageRoute: "missions", - message: "Mission list wiring is present. Mission lifecycle behavior is not implemented in Phase 0.", }; }; - ctx.data.register("surface-status", async (params) => { - if (!getSurfaceStatus) throw new Error("Surface status is not ready"); - const companyId = typeof params.companyId === "string" ? params.companyId : null; - return getSurfaceStatus(companyId); - }); + ctx.data.register("surface-status", async (params) => ({ + status: "ok", + checkedAt: new Date().toISOString(), + companyId: stringField(params.companyId), + databaseNamespace: ctx.db.namespace, + routeKeys: [...MISSIONS_API_ROUTE_KEYS], + uiSlotIds: [...MISSIONS_UI_SLOT_IDS], + pluginId: manifest.id, + } satisfies SurfaceStatus)); ctx.data.register("mission-summary", async (params) => { - const companyId = requireString(params.companyId, "companyId"); - const issueId = requireString(params.issueId, "issueId"); - if (!getMissionSummary) throw new Error("Mission summary is not ready"); - return getMissionSummary(companyId, issueId); + const companyId = stringField(params.companyId); + const issueId = stringField(params.issueId); + if (!companyId || !issueId) throw new Error("companyId and issueId are required"); + if (!loadMissionSummary) throw new Error("Mission summary is not ready"); + return loadMissionSummary(companyId, issueId); }); ctx.data.register("missions-list", async (params) => { - const companyId = requireString(params.companyId, "companyId"); - if (!getMissionsList) throw new Error("Missions list is not ready"); - return getMissionsList(companyId); + const companyId = stringField(params.companyId); + if (!companyId) throw new Error("companyId is required"); + if (!listMissions) throw new Error("Mission list is not ready"); + return listMissions(companyId); }); ctx.actions.register("initialize-mission", async (params) => { - const companyId = requireString(params.companyId, "companyId"); - const issueId = requireString(params.issueId, "issueId"); - await requireIssue(ctx, companyId, issueId); - return buildPlaceholderResponse({ routeKey: "initialize-mission", companyId, issueId }); + const companyId = stringField(params.companyId); + const issueId = stringField(params.issueId); + if (!companyId || !issueId) throw new Error("companyId and issueId are required"); + if (!runInitialize) throw new Error("Mission initialization is not ready"); + return runInitialize({ + companyId, + issueId, + actorAgentId: stringField(params.actorAgentId), + actorUserId: stringField(params.actorUserId), + actorRunId: stringField(params.actorRunId), + }); }); ctx.actions.register("decompose-mission", async (params) => { - const companyId = requireString(params.companyId, "companyId"); - const issueId = requireString(params.issueId, "issueId"); - await requireIssue(ctx, companyId, issueId); - return buildPlaceholderResponse({ routeKey: "decompose-mission", companyId, issueId }); + const companyId = stringField(params.companyId); + const issueId = stringField(params.issueId); + if (!companyId || !issueId) throw new Error("companyId and issueId are required"); + if (!runDecompose) throw new Error("Mission decomposition is not ready"); + return runDecompose({ + companyId, + issueId, + dryRun: booleanField(params.dryRun), + actorAgentId: stringField(params.actorAgentId), + actorUserId: stringField(params.actorUserId), + actorRunId: stringField(params.actorRunId), + }); }); ctx.actions.register("advance-mission", async (params) => { - const companyId = requireString(params.companyId, "companyId"); - const issueId = requireString(params.issueId, "issueId"); - await requireIssue(ctx, companyId, issueId); - return buildPlaceholderResponse({ routeKey: "advance-mission", companyId, issueId }); + const companyId = stringField(params.companyId); + const issueId = stringField(params.issueId); + if (!companyId || !issueId) throw new Error("companyId and issueId are required"); + if (!runAdvance) throw new Error("Mission advance is not ready"); + return runAdvance({ + companyId, + issueId, + maxValidationRounds: numberField(params.maxValidationRounds), + actorAgentId: stringField(params.actorAgentId), + actorUserId: stringField(params.actorUserId), + actorRunId: stringField(params.actorRunId), + }); }); ctx.actions.register("waive-mission-finding", async (params) => { - const companyId = requireString(params.companyId, "companyId"); - const issueId = requireString(params.issueId, "issueId"); - const findingKey = requireString(params.findingKey, "findingKey"); - requireString(params.rationale, "rationale"); - await requireIssue(ctx, companyId, issueId); - return buildPlaceholderResponse({ - routeKey: "waive-mission-finding", + const companyId = stringField(params.companyId); + const issueId = stringField(params.issueId); + const findingId = stringField(params.findingId) ?? stringField(params.findingKey); + const rationale = stringField(params.rationale); + if (!companyId || !issueId || !findingId || !rationale) { + throw new Error("companyId, issueId, findingId, and rationale are required"); + } + if (!runWaive) throw new Error("Mission waiver flow is not ready"); + return runWaive({ companyId, issueId, - findingKey, + findingId, + rationale, + actorAgentId: stringField(params.actorAgentId), + actorUserId: stringField(params.actorUserId), + actorRunId: stringField(params.actorRunId), }); }); }, async onApiRequest(input: PluginApiRequestInput) { - switch (input.routeKey) { - case "initialize-mission": - return { - status: 202, - body: buildPlaceholderResponse({ - routeKey: input.routeKey, - companyId: input.companyId, - issueId: input.params.issueId, - }), - }; + if (input.routeKey === "initialize-mission") { + if (!runInitialize) throw new Error("Mission initialization is not ready"); + const result = await runInitialize({ + companyId: input.companyId, + issueId: input.params.issueId, + actorAgentId: input.actor.agentId ?? null, + actorUserId: input.actor.userId ?? null, + actorRunId: input.actor.runId ?? null, + }); + return { + status: result.created ? 201 : 200, + body: result, + }; + } - case "mission-summary": - if (!getMissionSummary) throw new Error("Mission summary is not ready"); - return { - status: 200, - body: await getMissionSummary(input.companyId, input.params.issueId), - }; + if (input.routeKey === "mission-summary") { + if (!loadMissionSummary) throw new Error("Mission summary is not ready"); + return { + status: 200, + body: await loadMissionSummary(input.companyId, input.params.issueId), + }; + } - case "decompose-mission": - case "advance-mission": - return { - status: 202, - body: buildPlaceholderResponse({ - routeKey: input.routeKey, - companyId: input.companyId, - issueId: input.params.issueId, - }), - }; + if (input.routeKey === "decompose-mission") { + if (!runDecompose) throw new Error("Mission decomposition is not ready"); + const body = input.body as Record | null; + return { + status: 200, + body: await runDecompose({ + companyId: input.companyId, + issueId: input.params.issueId, + dryRun: booleanField(body?.dryRun), + actorAgentId: input.actor.agentId ?? null, + actorUserId: input.actor.userId ?? null, + actorRunId: input.actor.runId ?? null, + }), + }; + } - case "waive-mission-finding": { - const rationale = (input.body as Record | null)?.rationale; - if (typeof rationale !== "string" || rationale.trim().length === 0) { - return { - status: 422, - body: { error: "rationale is required" }, - }; - } + if (input.routeKey === "advance-mission") { + if (!runAdvance) throw new Error("Mission advance is not ready"); + const body = input.body as Record | null; + return { + status: 200, + body: await runAdvance({ + companyId: input.companyId, + issueId: input.params.issueId, + maxValidationRounds: numberField(body?.maxValidationRounds), + actorAgentId: input.actor.agentId ?? null, + actorUserId: input.actor.userId ?? null, + actorRunId: input.actor.runId ?? null, + }), + }; + } + + if (input.routeKey === "waive-mission-finding") { + if (!runWaive) throw new Error("Mission waiver flow is not ready"); + const body = input.body as Record | null; + const rationale = stringField(body?.rationale); + const findingId = input.params.findingKey ?? input.params.findingId; + if (!rationale) { return { - status: 202, - body: buildPlaceholderResponse({ - routeKey: input.routeKey, - companyId: input.companyId, - issueId: input.params.issueId, - findingKey: input.params.findingKey, - }), + status: 422, + body: { error: "rationale is required" }, }; } - - case "missions-list": - if (!getMissionsList) throw new Error("Missions list is not ready"); - return { - status: 200, - body: await getMissionsList(input.companyId), - }; - - default: - return { - status: 404, - body: { error: `Unknown missions route: ${input.routeKey}` }, - }; + return { + status: 200, + body: await runWaive({ + companyId: input.companyId, + issueId: input.params.issueId, + findingId, + rationale, + actorAgentId: input.actor.agentId ?? null, + actorUserId: input.actor.userId ?? null, + actorRunId: input.actor.runId ?? null, + }), + }; } + + if (input.routeKey === "missions-list") { + if (!listMissions) throw new Error("Mission list is not ready"); + return { + status: 200, + body: await listMissions(input.companyId), + }; + } + + return { + status: 404, + body: { error: `Unknown missions route: ${input.routeKey}` }, + }; }, async onHealth() { return { status: "ok", - message: "Missions plugin scaffold is running", + message: "Missions plugin worker is running", details: { - pluginId: manifest.id, routeKeys: [...MISSIONS_API_ROUTE_KEYS], uiSlotIds: [...MISSIONS_UI_SLOT_IDS], - hasDatabaseNamespace: Boolean(manifest.database), }, }; }, }); export default plugin; - runWorker(plugin, import.meta.url); diff --git a/packages/plugins/plugin-missions/tests/plugin.spec.ts b/packages/plugins/plugin-missions/tests/plugin.spec.ts index 7af9e11271f..669eef5d605 100644 --- a/packages/plugins/plugin-missions/tests/plugin.spec.ts +++ b/packages/plugins/plugin-missions/tests/plugin.spec.ts @@ -1,10 +1,8 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; -import type { Issue } from "@paperclipai/plugin-sdk"; +import type { Agent, Issue } from "@paperclipai/plugin-sdk"; import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; -import manifest, { MISSIONS_API_ROUTE_KEYS, MISSIONS_UI_SLOT_IDS } from "../src/manifest.js"; +import manifest from "../src/manifest.js"; import plugin from "../src/worker.js"; const PLUGIN_ORIGIN = `plugin:${manifest.id}` as const; @@ -52,252 +50,411 @@ function issue(input: Partial & Pick }; } -describe("missions plugin scaffold", () => { - it("declares the phase 0 routes, slots, and namespace migration contract", () => { +function agent(input: Partial & Pick): Agent { + const now = new Date(); + const { id, companyId, name, status, ...rest } = input; + return { + id, + companyId, + name, + urlKey: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), + role: "engineer", + title: null, + icon: null, + status, + reportsTo: null, + capabilities: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 100000, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: now, + updatedAt: now, + ...rest, + }; +} + +async function upsertMissionDocuments( + harness: ReturnType, + companyId: string, + issueId: string, +) { + const docs = new Map([ + ["plan", "# Mission Plan\n"], + ["mission-brief", "# Mission Brief\n"], + [ + "validation-contract", + JSON.stringify( + { + assertions: [ + { + id: "VAL-MISSION-001", + title: "Mission validation", + user_value: "Advance loop works", + scope: "Mission runtime", + setup: "Plugin test harness", + steps: ["Advance the mission"], + oracle: "Only valid issues are woken", + tooling: ["manual_review"], + evidence: [{ kind: "primary", description: "Mission summary", required: true }], + claimed_by: ["FEAT-MISSION-001"], + status: "unclaimed", + }, + ], + }, + null, + 2, + ), + ], + [ + "features", + JSON.stringify( + { + milestones: [ + { + id: "MILESTONE-MISSION-001", + title: "Core Mission Flow", + summary: "Advance and validation loop", + features: [ + { + id: "FEAT-MISSION-001", + title: "Advance mission", + kind: "original", + summary: "Drive assigned work forward", + acceptance_criteria: ["Wake assigned and unblocked work"], + claimed_assertion_ids: ["VAL-MISSION-001"], + status: "planned", + }, + ], + }, + ], + }, + null, + 2, + ), + ], + ["worker-guidelines", "# Worker Guidelines\n"], + ["services", "# Services\n"], + ["knowledge-base", "# Knowledge Base\n"], + ["decision-log", "# Decision Log\n"], + ]); + + for (const [key, body] of docs) { + await harness.ctx.issues.documents.upsert({ + issueId, + companyId, + key, + title: key, + body, + changeSummary: `Seeded ${key}`, + }); + } +} + +describe("missions plugin package", () => { + it("declares mission initialization surfaces", () => { expect(manifest).toMatchObject({ id: "paperclipai.plugin-missions", - apiVersion: 1, database: { namespaceSlug: "missions", migrationsDir: "migrations", coreReadTables: ["issues"], }, }); - - expect(manifest.apiRoutes?.map((route) => route.routeKey)).toEqual(MISSIONS_API_ROUTE_KEYS); - expect(manifest.ui?.slots?.map((slot) => slot.id)).toEqual(MISSIONS_UI_SLOT_IDS); + expect(manifest.apiRoutes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ routeKey: "initialize-mission", path: "/issues/:issueId/missions/init" }), + expect.objectContaining({ routeKey: "mission-summary", path: "/issues/:issueId/missions/summary" }), + ]), + ); expect(manifest.ui?.slots).toEqual( expect.arrayContaining([ - expect.objectContaining({ type: "page", routePath: "missions" }), - expect.objectContaining({ type: "taskDetailView", entityTypes: ["issue"] }), - expect.objectContaining({ type: "toolbarButton", entityTypes: ["issue"] }), - expect.objectContaining({ type: "settingsPage" }), + expect.objectContaining({ type: "page", exportName: "MissionsPage" }), + expect.objectContaining({ type: "taskDetailView", exportName: "MissionIssuePanel" }), + expect.objectContaining({ type: "toolbarButton", exportName: "MissionToolbarButton" }), + ]), + ); + }); + + it("initializes a root mission, creates required documents, and returns a draft summary", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + const harness = createTestHarness({ manifest }); + harness.seed({ + issues: [ + issue({ + id: rootIssueId, + companyId, + title: "Initialize the mission root", + identifier: "TST-1", + }), + ], + }); + await plugin.definition.setup(harness.ctx); + + const result = await harness.performAction<{ + created: boolean; + createdDocumentKeys: string[]; + summary: { + isMission: boolean; + state: string | null; + persistence: { hasRootLink: boolean; hasInitializationEvent: boolean } | null; + }; + }>("initialize-mission", { + companyId, + issueId: rootIssueId, + }); + + expect(result.created).toBe(true); + expect(result.createdDocumentKeys).toHaveLength(8); + expect(result.summary).toMatchObject({ + isMission: true, + state: "draft", + persistence: { + hasRootLink: true, + hasInitializationEvent: true, + }, + }); + + const rootIssue = await harness.ctx.issues.get(rootIssueId, companyId); + expect(rootIssue).toMatchObject({ + originKind: "plugin:paperclipai.plugin-missions", + originId: `mission:${rootIssueId}`, + billingCode: `mission:${rootIssueId}`, + }); + + const docs = await harness.ctx.issues.documents.list(rootIssueId, companyId); + expect(new Set(docs.map((document) => document.key))).toEqual( + new Set([ + "plan", + "mission-brief", + "validation-contract", + "features", + "worker-guidelines", + "services", + "knowledge-base", + "decision-log", ]), ); - const migrationsDir = path.resolve(import.meta.dirname, "../migrations"); - const firstMigration = readFileSync(path.join(migrationsDir, "001_mission_issue_links.sql"), "utf8"); - const secondMigration = readFileSync(path.join(migrationsDir, "002_missions.sql"), "utf8"); - expect(firstMigration).toContain("plugin_missions_8ceb7cd69c.mission_issue_links"); - expect(secondMigration).toContain("plugin_missions_8ceb7cd69c.missions"); - expect(secondMigration).toContain("plugin_missions_8ceb7cd69c.mission_findings"); - expect(secondMigration).toContain("plugin_missions_8ceb7cd69c.mission_events"); + expect(harness.dbExecutes.some((entry) => entry.sql.includes(".missions"))).toBe(true); + expect(harness.dbExecutes.some((entry) => entry.sql.includes(".mission_issue_links"))).toBe(true); + expect(harness.dbExecutes.some((entry) => entry.sql.includes(".mission_events"))).toBe(true); }); - it("registers placeholder data and action handlers backed by the worker shell", async () => { + it("keeps initialization idempotent and surfaces parse errors in the summary", async () => { const companyId = randomUUID(); - const missionIssueId = randomUUID(); - const childIssueId = randomUUID(); - const blockerIssueId = randomUUID(); + const rootIssueId = randomUUID(); const harness = createTestHarness({ manifest }); - harness.seed({ issues: [ issue({ - id: missionIssueId, + id: rootIssueId, companyId, - title: "Mission root", - identifier: "PAP-1684", - originKind: PLUGIN_ORIGIN, - }), - issue({ - id: childIssueId, - companyId, - parentId: missionIssueId, - title: "Mission child", - originKind: `${PLUGIN_ORIGIN}:feature`, - }), - issue({ - id: blockerIssueId, - companyId, - title: "Upstream blocker", + title: "Mission with parser errors", }), ], }); + await plugin.definition.setup(harness.ctx); + + await harness.performAction("initialize-mission", { companyId, issueId: rootIssueId }); + const second = await harness.performAction<{ + created: boolean; + createdDocumentKeys: string[]; + summary: { isMission: boolean; state: string | null }; + }>("initialize-mission", { companyId, issueId: rootIssueId }); + expect(second.created).toBe(false); + expect(second.createdDocumentKeys).toEqual([]); + expect(second.summary).toMatchObject({ + isMission: true, + state: "draft", + }); + await harness.ctx.issues.documents.upsert({ - issueId: missionIssueId, + issueId: rootIssueId, companyId, - key: "mission-brief", - title: "Mission Brief", - body: "# Mission Brief\n", - changeSummary: "Seed mission brief", + key: "validation-contract", + title: "Validation Contract", + body: "{ not-valid-json }", }); - await harness.ctx.issues.relations.setBlockedBy(missionIssueId, [blockerIssueId], companyId); - await plugin.definition.setup(harness.ctx); - - const surfaceStatus = await harness.getData<{ - pluginId: string; - routeKeys: string[]; - uiSlotIds: string[]; - databaseNamespace: string; - }>("surface-status", { companyId }); - expect(surfaceStatus).toMatchObject({ - pluginId: manifest.id, - routeKeys: [...MISSIONS_API_ROUTE_KEYS], - uiSlotIds: [...MISSIONS_UI_SLOT_IDS], - }); - expect(surfaceStatus.databaseNamespace).toContain("test_paperclipai_plugin_missions"); - const summary = await harness.getData<{ - missionIssueId: string; - childIssueCount: number; - documentCount: number; - blockerCount: number; - status: string; - }>("mission-summary", { companyId, issueId: missionIssueId }); - expect(summary).toMatchObject({ - missionIssueId, - childIssueCount: 1, - documentCount: 1, - blockerCount: 1, - status: "not_configured", - }); - - const missionsList = await harness.getData("missions-list", { companyId }); - expect(missionsList).toMatchObject({ + isMission: boolean; + parseProblems: Array<{ key: string }>; + }>("mission-summary", { companyId, - pageRoute: "missions", - status: "not_configured", - routeKeys: [...MISSIONS_API_ROUTE_KEYS], - message: "Mission list wiring is present. Mission lifecycle behavior is not implemented in Phase 0.", - }); - expect(missionsList.missions).toEqual([ - { - issueId: missionIssueId, - identifier: "PAP-1684", - title: "Mission root", - status: "todo", - }, - ]); - - await expect( - harness.performAction("initialize-mission", { companyId, issueId: missionIssueId }), - ).resolves.toMatchObject({ - routeKey: "initialize-mission", - issueId: missionIssueId, - status: "not_configured", - }); - - await expect(plugin.definition.onHealth?.()).resolves.toMatchObject({ - status: "ok", - details: { - pluginId: manifest.id, - routeKeys: [...MISSIONS_API_ROUTE_KEYS], - uiSlotIds: [...MISSIONS_UI_SLOT_IDS], - }, + issueId: rootIssueId, }); + expect(summary.isMission).toBe(true); + expect(summary.parseProblems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "validation-contract" }), + ]), + ); }); - it("dispatches scoped api routes with placeholder bodies and validates waiver input", async () => { + it("wakes assigned unblocked work and creates a single fix issue for blocking findings", async () => { const companyId = randomUUID(); - const missionIssueId = randomUUID(); + const rootIssueId = randomUUID(); + const readyFeatureId = randomUUID(); + const validationIssueId = randomUUID(); + const agentId = randomUUID(); const harness = createTestHarness({ manifest }); - harness.seed({ + agents: [agent({ id: agentId, companyId, name: "Worker", status: "active" })], issues: [ issue({ - id: missionIssueId, + id: rootIssueId, companyId, title: "Mission root", - identifier: "PAP-1684", + assigneeAgentId: agentId, originKind: PLUGIN_ORIGIN, }), + issue({ + id: readyFeatureId, + companyId, + parentId: rootIssueId, + title: "Ready feature", + assigneeAgentId: agentId, + originKind: `${PLUGIN_ORIGIN}:feature`, + originId: "FEAT-MISSION-001", + }), + issue({ + id: validationIssueId, + companyId, + parentId: rootIssueId, + title: "Validation round", + status: "done", + assigneeAgentId: agentId, + originKind: `${PLUGIN_ORIGIN}:validation`, + originId: "VAL-ROUND-001", + }), ], }); await plugin.definition.setup(harness.ctx); - - await expect( - plugin.definition.onApiRequest?.({ - routeKey: "initialize-mission", - method: "POST", - path: `/issues/${missionIssueId}/missions/init`, - params: { issueId: missionIssueId }, - query: {}, - body: {}, - actor: { - actorType: "user", - actorId: "board", - userId: "board", - agentId: null, - runId: null, - }, - companyId, - headers: {}, - }), - ).resolves.toMatchObject({ - status: 202, - body: { - routeKey: "initialize-mission", - issueId: missionIssueId, - status: "not_configured", - }, - }); - - await expect( - plugin.definition.onApiRequest?.({ - routeKey: "mission-summary", - method: "GET", - path: `/issues/${missionIssueId}/missions/summary`, - params: { issueId: missionIssueId }, - query: {}, - body: null, - actor: { - actorType: "user", - actorId: "board", - userId: "board", - agentId: null, - runId: null, - }, - companyId, - headers: {}, - }), - ).resolves.toMatchObject({ - status: 200, - body: { - missionIssueId, - status: "not_configured", - }, - }); - - await expect( - plugin.definition.onApiRequest?.({ - routeKey: "missions-list", - method: "GET", - path: "/missions", - params: {}, - query: { companyId }, - body: null, - actor: { - actorType: "user", - actorId: "board", - userId: "board", - agentId: null, - runId: null, - }, - companyId, - headers: {}, - }), - ).resolves.toMatchObject({ - status: 200, - body: { - missions: [ + await upsertMissionDocuments(harness, companyId, rootIssueId); + await harness.ctx.issues.documents.upsert({ + issueId: validationIssueId, + companyId, + key: "validation-report-round-1", + title: "Validation Report Round 1", + body: JSON.stringify({ + round: 1, + validator_role: "scrutiny_validator", + summary: "Found one blocking issue", + findings: [ { - issueId: missionIssueId, - identifier: "PAP-1684", - title: "Mission root", - status: "todo", + id: "FINDING-MISSION-001", + severity: "blocking", + assertion_id: "VAL-MISSION-001", + title: "Advance skips fix loop", + evidence: ["Validation report"], + repro_steps: ["Run advance mission"], + expected: "Blocking findings create one fix issue", + actual: "No fix issue existed", + status: "open", }, ], - status: "not_configured", - }, + }), + changeSummary: "Seeded validation findings", + }); + + const firstAdvance = await harness.performAction<{ + outcome: string; + createdFixIssueIds: string[]; + wokenIssueIds: string[]; + }>("advance-mission", { + companyId, + issueId: rootIssueId, + actorAgentId: agentId, + actorRunId: "run_1", + }); + const secondAdvance = await harness.performAction<{ createdFixIssueIds: string[] }>("advance-mission", { + companyId, + issueId: rootIssueId, + actorAgentId: agentId, + actorRunId: "run_2", + }); + + const fixIssues = await harness.ctx.issues.list({ + companyId, + originKind: `${PLUGIN_ORIGIN}:fix`, + }); + + expect(firstAdvance.outcome).toBe("woke_issues"); + expect(firstAdvance.wokenIssueIds).toEqual(expect.arrayContaining([readyFeatureId])); + expect(firstAdvance.createdFixIssueIds).toHaveLength(1); + expect(secondAdvance.createdFixIssueIds).toEqual(firstAdvance.createdFixIssueIds); + expect(fixIssues).toHaveLength(1); + }); + + it("requires waiver rationale and records approved waivers", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + const validationIssueId = randomUUID(); + const agentId = randomUUID(); + const harness = createTestHarness({ manifest }); + harness.seed({ + agents: [agent({ id: agentId, companyId, name: "Worker", status: "active" })], + issues: [ + issue({ + id: rootIssueId, + companyId, + title: "Mission root", + assigneeAgentId: agentId, + originKind: PLUGIN_ORIGIN, + }), + issue({ + id: validationIssueId, + companyId, + parentId: rootIssueId, + title: "Validation round", + status: "done", + assigneeAgentId: agentId, + originKind: `${PLUGIN_ORIGIN}:validation`, + originId: "VAL-ROUND-001", + }), + ], + }); + await plugin.definition.setup(harness.ctx); + await upsertMissionDocuments(harness, companyId, rootIssueId); + await harness.ctx.issues.documents.upsert({ + issueId: validationIssueId, + companyId, + key: "validation-report-round-1", + title: "Validation Report Round 1", + body: JSON.stringify({ + round: 1, + validator_role: "scrutiny_validator", + summary: "Found one blocking issue", + findings: [ + { + id: "FINDING-MISSION-001", + severity: "blocking", + assertion_id: "VAL-MISSION-001", + title: "Advance skips fix loop", + evidence: ["Validation report"], + repro_steps: ["Run advance mission"], + expected: "Blocking findings create one fix issue", + actual: "No fix issue existed", + status: "open", + }, + ], + }), + changeSummary: "Seeded validation findings", }); await expect( plugin.definition.onApiRequest?.({ routeKey: "waive-mission-finding", method: "POST", - path: `/issues/${missionIssueId}/missions/findings/FINDING-001/waive`, - params: { issueId: missionIssueId, findingKey: "FINDING-001" }, + path: `/issues/${rootIssueId}/missions/findings/FINDING-MISSION-001/waive`, + params: { issueId: rootIssueId, findingKey: "FINDING-MISSION-001" }, query: {}, body: {}, actor: { @@ -319,10 +476,10 @@ describe("missions plugin scaffold", () => { plugin.definition.onApiRequest?.({ routeKey: "waive-mission-finding", method: "POST", - path: `/issues/${missionIssueId}/missions/findings/FINDING-001/waive`, - params: { issueId: missionIssueId, findingKey: "FINDING-001" }, + path: `/issues/${rootIssueId}/missions/findings/FINDING-MISSION-001/waive`, + params: { issueId: rootIssueId, findingKey: "FINDING-MISSION-001" }, query: {}, - body: { rationale: "Defer to follow-up issue" }, + body: { rationale: "Known acceptable risk for this mission" }, actor: { actorType: "user", actorId: "board", @@ -334,50 +491,52 @@ describe("missions plugin scaffold", () => { headers: {}, }), ).resolves.toMatchObject({ - status: 202, - body: { - routeKey: "waive-mission-finding", - issueId: missionIssueId, - findingKey: "FINDING-001", - status: "not_configured", - }, + status: 200, + body: expect.objectContaining({ + waived: true, + findingId: "FINDING-MISSION-001", + }), }); + }); + + it("enforces checkout ownership for agent-triggered initialization on in-progress issues", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const harness = createTestHarness({ manifest }); + harness.seed({ + issues: [ + issue({ + id: rootIssueId, + companyId, + title: "Checked out mission root", + status: "in_progress", + assigneeAgentId: agentId, + checkoutRunId: runId, + }), + ], + }); + await plugin.definition.setup(harness.ctx); await expect( plugin.definition.onApiRequest?.({ - routeKey: "unknown-route", - method: "GET", - path: "/unknown", - params: {}, + routeKey: "initialize-mission", + method: "POST", + path: `/issues/${rootIssueId}/missions/init`, + params: { issueId: rootIssueId }, query: {}, - body: null, + body: {}, actor: { - actorType: "user", - actorId: "board", - userId: "board", - agentId: null, - runId: null, + actorType: "agent", + actorId: agentId, + agentId, + userId: null, + runId: randomUUID(), }, companyId, headers: {}, }), - ).resolves.toMatchObject({ - status: 404, - body: { error: "Unknown missions route: unknown-route" }, - }); + ).rejects.toThrow("Issue run ownership conflict"); }); }); - -type MissionsListResult = { - status: string; - companyId: string; - missions: Array<{ - issueId: string; - identifier: string | null; - title: string; - status: string; - }>; - routeKeys: string[]; - pageRoute: string; - message: string; -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc4d9924810..974b09e3b4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -428,6 +428,49 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/plugins/plugin-missions: + dependencies: + '@paperclipai/plugin-sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@rollup/plugin-node-resolve': + specifier: ^16.0.1 + version: 16.0.3(rollup@4.60.1) + '@rollup/plugin-typescript': + specifier: ^12.1.2 + version: 12.3.0(rollup@4.60.1)(tslib@2.8.1)(typescript@5.9.3) + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.2.3(@types/react@19.2.14) + esbuild: + specifier: ^0.27.3 + version: 0.27.3 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + rollup: + specifier: '>=4.59.0' + version: 4.60.1 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) + packages/plugins/sdk: dependencies: '@paperclipai/shared':