Implement missions plugin advance loop

This commit is contained in:
Dotta committed 2026-04-22 11:05:47 -05:00
1 parent 14e52f3ac7
commit 4024c47ea6
12 files changed
+4401 -506

No files matched your search

+9 -7
View File
@@ -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
@@ -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",
@@ -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<Awaited<ReturnType<PluginContext["issues"]["documents"]["get"]>>>;
function issueReference(issue: Pick<Issue, "identifier" | "id">) {
if (!issue.identifier) return `\`${issue.id}\``;
const prefix = issue.identifier.split("-")[0] || "PAP";
return `[${issue.identifier}](/${prefix}/issues/${issue.identifier})`;
}
function billingCodeForMission(issue: Pick<Issue, "billingCode" | "identifier" | "id">) {
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<Issue, "id" | "identifier">;
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<Issue, "id" | "identifier">;
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<Issue, "id" | "identifier">;
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<MissionDocumentBundle> {
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<string, unknown> | 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<string, unknown> | 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<MissionDecompositionResult> {
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<string, string>();
const featureIssueIds = new Map<string, string>();
const validationIssueIds = new Map<string, string>();
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<string, string[]>();
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 = [
"<!-- paperclip:plugin-missions:decompose -->",
"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,
};
}
@@ -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",
@@ -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 },
@@ -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<string, unknown>;
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<MissionRequiredDocumentKey, string> = {
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<string, unknown>,
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<PluginContext["issues"]["update"]>[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<MissionIssueSummary> {
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<MissionInitializationResult> {
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),
};
}
@@ -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<string, unknown>;
}
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<T>(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<typeof entry.document> } => {
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<string, string[]>();
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<MissionIssueKind>(["feature", "validation", "fix", "fix_loop"]);
const assigneeCache = new Map<string, Awaited<ReturnType<PluginContext["agents"]["get"]>>>();
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<MissionAdvanceResult> {
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<MissionWaiveFindingResult> {
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,
};
}
File diff suppressed because it is too large. Load diff
+117 -73
View File
@@ -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<void>;
}) {
function SummaryRows({ data }: { data: MissionIssueSummary }) {
return (
<div style={panelStyle}>
<div style={rowStyle}>
<strong>Mission Scaffold</strong>
<button style={buttonStyle} type="button" onClick={() => void onAdvance()}>
Advance
</button>
</div>
<div style={gridStyle}>
<div style={rowStyle}><span>Issue</span><code>{data.missionIdentifier ?? data.issueId}</code></div>
<div style={rowStyle}><span>Children</span><strong>{data.childIssueCount}</strong></div>
<div style={rowStyle}><span>Documents</span><strong>{data.documentCount}</strong></div>
<div style={rowStyle}><span>Blockers</span><strong>{data.blockerCount}</strong></div>
<div style={rowStyle}><span>Namespace</span><code>{data.databaseNamespace}</code></div>
</div>
<div>{data.message}</div>
<div style={gridStyle}>
<div style={rowStyle}><span>State</span><strong>{data.state ?? "not-initialized"}</strong></div>
<div style={rowStyle}><span>Billing</span><code>{data.settings.billingCode ?? "unset"}</code></div>
<div style={rowStyle}><span>Namespace</span><code>{data.settings.databaseNamespace}</code></div>
<div style={rowStyle}><span>Open Findings</span><strong>{data.openFindingCount}</strong></div>
</div>
);
}
function Checklist({ data }: { data: MissionIssueSummary }) {
return (
<div style={gridStyle}>
{data.documentChecklist.map((item) => (
<div key={item.key} style={rowStyle}>
<code>{item.key}</code>
<strong>{item.present ? item.title ?? "present" : "missing"}</strong>
</div>
))}
</div>
);
}
function InitializeMissionButton({
companyId,
issueId,
disabledReason,
onDone,
}: {
companyId: string;
issueId: string;
disabledReason: string | null;
onDone?: (result: MissionInitializationResult) => Promise<void> | void;
}) {
const initializeMission = usePluginAction("initialize-mission");
const toast = usePluginToast();
const [pending, setPending] = useState(false);
return (
<button
style={buttonStyle}
type="button"
disabled={pending || Boolean(disabledReason)}
title={disabledReason ?? undefined}
onClick={async () => {
setPending(true);
try {
const result = await initializeMission({ companyId, issueId }) as MissionInitializationResult;
toast({
title: result.created ? "Mission initialized" : "Mission already initialized",
body: result.summary.nextAction,
tone: "success",
});
await onDone?.(result);
} finally {
setPending(false);
}
}}
>
{pending ? "Initializing..." : "Init Mission"}
</button>
);
}
export function MissionsPage({ context }: PluginPageProps) {
const companyId = context.companyId;
const { data, loading, error, refresh } = usePluginData<MissionsList>("missions-list", {
@@ -128,7 +151,7 @@ export function MissionsPage({ context }: PluginPageProps) {
});
if (!companyId) return <div style={panelStyle}>Open a company to view missions.</div>;
if (loading) return <div style={panelStyle}>Loading missions scaffold...</div>;
if (loading) return <div style={panelStyle}>Loading missions...</div>;
if (error) return <div style={panelStyle}>Missions page error: {error.message}</div>;
if (!data) return null;
@@ -142,7 +165,7 @@ export function MissionsPage({ context }: PluginPageProps) {
</div>
<div>{data.message}</div>
{data.missions.length === 0 ? (
<div>No mission issues are registered yet.</div>
<div>No root mission issues have been initialized yet.</div>
) : (
<div style={gridStyle}>
{data.missions.map((mission) => (
@@ -158,56 +181,77 @@ export function MissionsPage({ context }: PluginPageProps) {
}
export function MissionIssuePanel({ context }: EntitySlotProps) {
const { data, loading, error, refresh } = usePluginData<MissionSummary>("mission-summary", {
const { data, loading, error, refresh } = usePluginData<MissionIssueSummary>("mission-summary", {
companyId: context.companyId,
issueId: context.entityId,
});
const advanceMission = usePluginAction("advance-mission");
const toast = usePluginToast();
if (!context.companyId || !context.entityId) {
return <div style={panelStyle}>Mission controls need an issue context.</div>;
}
if (loading) return <div style={panelStyle}>Loading mission scaffold...</div>;
if (loading) return <div style={panelStyle}>Loading mission summary...</div>;
if (error) return <div style={panelStyle}>Mission panel error: {error.message}</div>;
if (!data) return null;
if (!data.isMission) {
return (
<div style={panelStyle}>
<strong>Mission</strong>
<div>This issue has not been initialized as a mission yet.</div>
<div>{data.nextAction}</div>
<InitializeMissionButton
companyId={context.companyId}
issueId={context.entityId}
disabledReason={data.initializeDisabledReason}
onDone={async () => {
await refresh();
}}
/>
</div>
);
}
return (
<IssueScopedPanel
data={data}
onAdvance={async () => {
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();
}}
/>
<div style={panelStyle}>
<div style={rowStyle}>
<strong>Mission</strong>
<button style={secondaryButtonStyle} type="button" onClick={() => refresh()}>
Refresh
</button>
</div>
<SummaryRows data={data} />
<div><strong>Next action:</strong> {data.nextAction}</div>
<Checklist data={data} />
{data.parseProblems.length > 0 ? (
<div style={gridStyle}>
{data.parseProblems.map((problem) => (
<div key={`${problem.key}:${problem.message}`} style={{ color: "#b45309" }}>
<code>{problem.key}</code>: {problem.message}
</div>
))}
</div>
) : null}
</div>
);
}
export function MissionToolbarButton({ context }: EntitySlotProps) {
const initializeMission = usePluginAction("initialize-mission");
const toast = usePluginToast();
const { data, loading, refresh } = usePluginData<MissionIssueSummary>("mission-summary", {
companyId: context.companyId,
issueId: context.entityId,
});
if (!context.companyId || !context.entityId || loading || !data || data.isMission) return null;
return (
<button
style={buttonStyle}
type="button"
onClick={async () => {
if (!context.companyId || !context.entityId) return;
await initializeMission({ companyId: context.companyId, issueId: context.entityId });
toast({
title: "Mission scaffold ready",
body: "Initialization is wired to the worker shell.",
tone: "success",
});
<InitializeMissionButton
companyId={context.companyId}
issueId={context.entityId}
disabledReason={data.initializeDisabledReason}
onDone={async () => {
await refresh();
}}
>
Init Mission
</button>
/>
);
}
+259 -190
View File
@@ -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<SurfaceStatus>) | null = null;
let getMissionSummary: ((companyId: string, issueId: string) => Promise<MissionSummary>) | null = null;
let getMissionsList: ((companyId: string) => Promise<MissionsList>) | null = null;
let runInitialize: ((input: InitializeInput) => Promise<MissionInitializationResult>) | null = null;
let runDecompose: ((input: DecomposeInput) => Promise<MissionDecompositionResult>) | null = null;
let loadMissionSummary: ((companyId: string, issueId: string) => Promise<MissionSummary>) | null = null;
let runAdvance: ((input: AdvanceInput) => Promise<MissionAdvanceResult>) | null = null;
let runWaive: ((input: WaiveInput) => Promise<MissionWaiveFindingResult>) | null = null;
let listMissions: ((companyId: string) => Promise<MissionsList>) | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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);
@@ -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<Issue> & Pick<Issue, "id" | "companyId" | "title">
};
}
describe("missions plugin scaffold", () => {
it("declares the phase 0 routes, slots, and namespace migration contract", () => {
function agent(input: Partial<Agent> & Pick<Agent, "id" | "companyId" | "name" | "status">): 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<typeof createTestHarness>,
companyId: string,
issueId: string,
) {
const docs = new Map<string, string>([
["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<MissionsListResult>("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;
};
+43
View File
@@ -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':