mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
545 lines
19 KiB
TypeScript
545 lines
19 KiB
TypeScript
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,
|
|
};
|
|
}
|