diff --git a/packages/plugins/plugin-missions/src/index.ts b/packages/plugins/plugin-missions/src/index.ts new file mode 100644 index 00000000000..b1862e59872 --- /dev/null +++ b/packages/plugins/plugin-missions/src/index.ts @@ -0,0 +1 @@ +export * from "./mission/index.js"; diff --git a/packages/plugins/plugin-missions/src/mission-documents.ts b/packages/plugins/plugin-missions/src/mission-documents.ts new file mode 100644 index 00000000000..e123ed325d0 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission-documents.ts @@ -0,0 +1,96 @@ +export { + MISSION_FINDING_WAIVER_MARKER_PREFIX, + MISSION_REQUIRED_DOCUMENT_KEYS, + buildMissionFindingWaiverEntry, + parseMissionFeaturesDocument, + parseMissionValidationContractDocument, + parseMissionValidationReportDocument, + type MissionFeature, + type MissionFeaturesDocument, + type MissionFinding, + type MissionMilestone, + type MissionRequiredDocumentKey, + type MissionValidationContract, + type MissionValidationReport, +} from "./mission/index.js"; +export { isMissionRequiredDocumentKey } from "./mission/index.js"; + +import { + isMissionValidationReportKey, + missionFindingWaiverMarker, + parseMissionFindingWaivers as parseMissionFindingWaiversBase, + validationReportRoundFromKey, + type MissionFindingWaiverModel, +} from "./mission/index.js"; + +export type MissionState = + | "draft" + | "ready" + | "working" + | "validating" + | "fixing" + | "paused" + | "complete"; + +export type MissionFindingWaiverRecord = { + findingId: string; + rationale: string; + actorLabel: string | null; + createdAt: string | null; +}; + +export function isValidationReportDocumentKey(key: string) { + return isMissionValidationReportKey(key); +} + +export function parseValidationReportRound(key: string) { + return validationReportRoundFromKey(key); +} + +export function parseMissionFindingWaivers(decisionLogBody: string | null | undefined): Map { + const waivers = new Map(); + for (const [findingId, waiver] of parseMissionFindingWaiversBase(decisionLogBody)) { + waivers.set(findingId, { + findingId, + rationale: waiver.rationale, + actorLabel: waiver.actorLabel ?? null, + createdAt: toCreatedAt(waiver), + }); + } + return waivers; +} + +export function buildMissionEventEntry(input: { + markerKey: string; + title: string; + lines: string[]; +}) { + const marker = `paperclip:mission-event:${input.markerKey}`; + return { + marker, + body: [ + ``, + `### ${input.title}`, + "", + ...input.lines.map((line) => `- ${line}`), + ].join("\n"), + }; +} + +export function appendDecisionLogEntry( + existingBody: string | null | undefined, + entry: { marker: string; body: string }, +) { + const trimmed = existingBody?.trim() ?? ""; + const header = trimmed.length > 0 ? trimmed : "# Decision Log"; + if (header.includes(entry.marker)) return header; + return [header, entry.body.trim()].filter(Boolean).join("\n\n"); +} + +function toCreatedAt(waiver: MissionFindingWaiverModel) { + return "recordedAt" in waiver ? waiver.recordedAt ?? null : null; +} + +export function missionFindingWaiverCommentMarker(findingId: string) { + return missionFindingWaiverMarker(findingId); +} diff --git a/packages/plugins/plugin-missions/src/mission/constants.ts b/packages/plugins/plugin-missions/src/mission/constants.ts new file mode 100644 index 00000000000..0991830dca9 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission/constants.ts @@ -0,0 +1,94 @@ +export const MISSION_REQUIRED_DOCUMENT_KEYS = [ + "plan", + "mission-brief", + "validation-contract", + "features", + "worker-guidelines", + "services", + "knowledge-base", + "decision-log", +] as const; +export type MissionRequiredDocumentKey = (typeof MISSION_REQUIRED_DOCUMENT_KEYS)[number]; + +export const MISSION_GENERATED_DOCUMENT_KEY_PREFIXES = [ + "validation-report-round-", + "milestone-summary-", + "mission-final-report", +] as const; + +export const MISSION_STATES = [ + "draft", + "planning", + "ready_for_approval", + "running", + "validating", + "fixing", + "blocked", + "paused", + "completed", + "cancelled", +] as const; +export type MissionState = (typeof MISSION_STATES)[number]; + +export const MISSION_ROLE_TYPES = [ + "orchestrator", + "worker", + "scrutiny_validator", + "user_testing_validator", + "board", +] as const; +export type MissionRoleType = (typeof MISSION_ROLE_TYPES)[number]; + +export const MISSION_VALIDATION_ASSERTION_STATUSES = [ + "unclaimed", + "claimed", + "passing", + "failing", + "blocked", + "waived", +] as const; +export type MissionValidationAssertionStatus = (typeof MISSION_VALIDATION_ASSERTION_STATUSES)[number]; + +export const MISSION_VALIDATION_TOOLING = [ + "unit_test", + "api_call", + "browser", + "screenshot", + "log_inspection", + "code_review", + "cli_command", + "manual_review", + "other", +] as const; +export type MissionValidationTooling = (typeof MISSION_VALIDATION_TOOLING)[number]; + +export const MISSION_FEATURE_KINDS = ["original", "fix"] as const; +export type MissionFeatureKind = (typeof MISSION_FEATURE_KINDS)[number]; + +export const MISSION_FEATURE_STATUSES = [ + "planned", + "in_progress", + "implemented", + "validating", + "validated", + "blocked", + "cancelled", +] as const; +export type MissionFeatureStatus = (typeof MISSION_FEATURE_STATUSES)[number]; + +export const MISSION_FINDING_SEVERITIES = ["blocking", "non_blocking", "suggestion"] as const; +export type MissionFindingSeverity = (typeof MISSION_FINDING_SEVERITIES)[number]; + +export const MISSION_FINDING_STATUSES = ["open", "fix_created", "resolved", "waived"] as const; +export type MissionFindingStatus = (typeof MISSION_FINDING_STATUSES)[number]; + +export const MISSION_HOST_ISSUE_STATUSES = [ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "blocked", + "cancelled", +] as const; +export type MissionIssueStatus = (typeof MISSION_HOST_ISSUE_STATUSES)[number]; diff --git a/packages/plugins/plugin-missions/src/mission/fixtures.ts b/packages/plugins/plugin-missions/src/mission/fixtures.ts new file mode 100644 index 00000000000..d2cd1e2a898 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission/fixtures.ts @@ -0,0 +1,148 @@ +import { MISSION_REQUIRED_DOCUMENT_KEYS } from "./constants.js"; +import { buildMissionFindingWaiverEntry } from "./parsers.js"; +import { createDefaultMissionDocumentTemplates } from "./templates.js"; +import type { + MissionFeaturesDocument, + MissionTemplateIssueContext, + MissionValidationContract, + MissionValidationReport, +} from "./types.js"; + +export const missionTemplateIssueFixture: MissionTemplateIssueContext = { + id: "issue-mission-1", + identifier: "PAP-1685", + title: "Port mission document contracts into the missions plugin", + description: "Move the mission document contract into plugin-local modules.", + status: "in_progress", + billingCode: "mission:pap-1685", +}; + +export const missionValidationContractFixture: MissionValidationContract = { + assertions: [ + { + id: "VAL-MISSION-001", + title: "Mission creates issue-backed state", + user_value: "The board can inspect mission work as normal Paperclip work.", + scope: "mission initialization", + setup: "A company with a project and active orchestrator.", + steps: ["Initialize a mission from an existing issue.", "Inspect mission issue documents."], + oracle: "Required documents exist and no unscoped work is created.", + tooling: ["api_call", "manual_review"], + evidence: [ + { + kind: "api-response", + description: "JSON response listing created mission documents.", + required: true, + }, + ], + claimed_by: ["FEAT-MISSION-001"], + status: "claimed", + }, + ], +}; + +export const missionValidationContractMarkdownFixture = ` +### VAL-MISSION-001: Mission creates issue-backed state + +- User value: The board can inspect mission work as normal Paperclip work. +- Scope: mission initialization +- Setup: A company with a project and active orchestrator. +- Steps: Initialize a mission from an existing issue; inspect mission issue documents. +- Oracle: Required documents exist and no unscoped work is created. +- Tooling: api call, manual review +- Evidence: API response listing created mission documents. +- Claimed by: FEAT-MISSION-001 +- Status: claimed +`.trim(); + +export const missionFeaturesFixture: MissionFeaturesDocument = { + milestones: [ + { + id: "MILESTONE-MISSION-001", + title: "Foundation", + summary: "Create the mission state contract.", + depends_on: [], + features: [ + { + id: "FEAT-MISSION-001", + title: "Create mission documents", + kind: "original", + summary: "Add required document contracts.", + acceptance_criteria: ["Plugin-local modules define every required mission document key."], + claimed_assertion_ids: ["VAL-MISSION-001"], + depends_on: [], + status: "planned", + }, + ], + }, + ], +}; + +export const missionFeaturesMarkdownFixture = ` +## MILESTONE-MISSION-001: Foundation + +- Summary: Create the mission state contract. + +### FEAT-MISSION-001: Create mission documents + +- Summary: Add required document contracts. +- Acceptance criteria: Plugin-local modules define every required mission document key. +- Claims: VAL-MISSION-001 +- Status: planned +`.trim(); + +export const missionValidationReportFixture: MissionValidationReport = { + round: 2, + validator_role: "scrutiny_validator", + summary: "One blocking finding found.", + findings: [ + { + id: "FINDING-MISSION-001", + severity: "blocking", + assertion_id: "VAL-MISSION-001", + title: "Required document missing", + evidence: ["API response omitted validation-contract.", "Screenshot attached in issue comment."], + repro_steps: ["Initialize mission.", "List issue documents."], + expected: "validation-contract exists.", + actual: "validation-contract is absent.", + suspected_area: "mission initialization", + recommended_fix_scope: "Create the missing document idempotently.", + status: "open", + }, + ], +}; + +export const missionValidationReportMarkdownFixture = ` +- Round: 2 +- Validator role: scrutiny validator +- Summary: One blocking finding found. + +### FINDING-MISSION-001: Required document missing + +- Severity: blocking +- Assertion: VAL-MISSION-001 +- Evidence: API response omitted validation-contract; screenshot attached in issue comment +- Repro steps: Initialize mission; list issue documents +- Expected: validation-contract exists. +- Actual: validation-contract is absent. +- Suspected area: mission initialization +- Recommended fix scope: Create the missing document idempotently. +- Status: open +`.trim(); + +export const missionFindingWaiverEntryFixture = buildMissionFindingWaiverEntry({ + findingId: "FINDING-MISSION-002", + rationale: "Acceptable for MVP.", + actorLabel: "Board", + createdAt: "2026-04-20T12:00:00.000Z", +}); + +export const missionDecisionLogMarkdownFixture = [ + "# Decision Log", + "", + missionFindingWaiverEntryFixture, +].join("\n"); + +export const missionTemplateFixtures = Object.fromEntries( + createDefaultMissionDocumentTemplates(missionTemplateIssueFixture).map((template) => [template.key, template]), +) as Record<(typeof MISSION_REQUIRED_DOCUMENT_KEYS)[number], ReturnType[number]>; diff --git a/packages/plugins/plugin-missions/src/mission/index.ts b/packages/plugins/plugin-missions/src/mission/index.ts new file mode 100644 index 00000000000..d783cd4912d --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission/index.ts @@ -0,0 +1,115 @@ +export { + MISSION_FEATURE_KINDS, + MISSION_FEATURE_STATUSES, + MISSION_FINDING_SEVERITIES, + MISSION_FINDING_STATUSES, + MISSION_GENERATED_DOCUMENT_KEY_PREFIXES, + MISSION_HOST_ISSUE_STATUSES, + MISSION_REQUIRED_DOCUMENT_KEYS, + MISSION_ROLE_TYPES, + MISSION_STATES, + MISSION_VALIDATION_ASSERTION_STATUSES, + MISSION_VALIDATION_TOOLING, + type MissionFeatureKind, + type MissionFeatureStatus, + type MissionFindingSeverity, + type MissionFindingStatus, + type MissionIssueStatus, + type MissionRequiredDocumentKey, + type MissionRoleType, + type MissionState, + type MissionValidationAssertionStatus, + type MissionValidationTooling, +} from "./constants.js"; +export { + deriveIssueBackedMissionState, + getMissionMilestoneSummaryDocumentKey, + getMissionValidationReportDocumentKey, + isMissionDocumentKey, + isMissionRequiredDocumentKey, + MISSION_FINAL_REPORT_DOCUMENT_KEY, + type MissionDocumentKey, + type MissionStateDerivationInput, +} from "./mission.js"; +export { + advanceMissionSchema, + decomposeMissionSchema, + missionDecisionLogEntrySchema, + missionDecisionLogSchema, + missionDocumentKeySchema, + missionEvidenceRequirementSchema, + missionFeatureKindSchema, + missionFeatureSchema, + missionFeatureStatusSchema, + missionFeaturesDocumentSchema, + missionFindingSchema, + missionFindingSeveritySchema, + missionFindingStatusSchema, + missionFindingWaiverSchema, + missionMilestoneSchema, + missionRequiredDocumentKeySchema, + missionRoleTypeSchema, + missionStateDerivationInputSchema, + missionStateSchema, + missionValidationAssertionSchema, + missionValidationContractSchema, + missionValidationReportSchema, + waiveMissionFindingSchema, + type AdvanceMission, + type DecomposeMission, + type MissionDecisionLog, + type MissionDecisionLogEntry, + type MissionFeature, + type MissionFeaturesDocument, + type MissionFinding, + type MissionFindingWaiver, + type MissionMilestone, + type MissionStateDerivationInput as MissionStateDerivationInputShape, + type MissionValidationAssertion, + type MissionValidationContract, + type MissionValidationReport, + type WaiveMissionFinding, +} from "./schemas.js"; +export { + buildMissionFindingWaiverEntry, + isMissionValidationReportKey, + missionFindingWaiverMarker, + MISSION_FINDING_WAIVER_MARKER_PREFIX, + parseMissionDecisionLogDocument, + parseMissionFeaturesDocument, + parseMissionFindingWaivers, + parseMissionValidationContractDocument, + parseMissionValidationReportDocument, + validationReportRoundFromKey, +} from "./parsers.js"; +export { + missionDecisionLogMarkdownFixture, + missionFeaturesFixture, + missionFeaturesMarkdownFixture, + missionFindingWaiverEntryFixture, + missionTemplateFixtures, + missionTemplateIssueFixture, + missionValidationContractFixture, + missionValidationContractMarkdownFixture, + missionValidationReportFixture, + missionValidationReportMarkdownFixture, +} from "./fixtures.js"; +export { + createDefaultMissionDocumentTemplates, + defaultMissionDocument, +} from "./templates.js"; +export type { + MissionDecisionLog as MissionDecisionLogModel, + MissionDecisionLogEntry as MissionDecisionLogEntryModel, + MissionDocumentTemplate, + MissionEvidenceRequirement, + MissionFeature as MissionFeatureModel, + MissionFeaturesDocument as MissionFeaturesDocumentModel, + MissionFinding as MissionFindingModel, + MissionFindingWaiver as MissionFindingWaiverModel, + MissionMilestone as MissionMilestoneModel, + MissionTemplateIssueContext, + MissionValidationAssertion as MissionValidationAssertionModel, + MissionValidationContract as MissionValidationContractModel, + MissionValidationReport as MissionValidationReportModel, +} from "./types.js"; diff --git a/packages/plugins/plugin-missions/src/mission/mission.ts b/packages/plugins/plugin-missions/src/mission/mission.ts new file mode 100644 index 00000000000..24446afbf43 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission/mission.ts @@ -0,0 +1,73 @@ +import type { MissionIssueStatus, MissionRequiredDocumentKey, MissionState } from "./constants.js"; +import { MISSION_REQUIRED_DOCUMENT_KEYS } from "./constants.js"; + +export const MISSION_FINAL_REPORT_DOCUMENT_KEY = "mission-final-report"; + +export type MissionDocumentKey = + | MissionRequiredDocumentKey + | typeof MISSION_FINAL_REPORT_DOCUMENT_KEY + | `validation-report-round-${number}` + | `milestone-summary-${string}`; + +export function getMissionValidationReportDocumentKey(round: number): `validation-report-round-${number}` { + if (!Number.isInteger(round) || round < 1) { + throw new Error("Mission validation report rounds must be positive integers."); + } + return `validation-report-round-${round}`; +} + +export function getMissionMilestoneSummaryDocumentKey(slug: string): `milestone-summary-${string}` { + const normalized = slug.trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9_-]*$/.test(normalized)) { + throw new Error("Mission milestone summary slugs must be document-key-safe."); + } + return `milestone-summary-${normalized}`; +} + +export function isMissionRequiredDocumentKey(value: string): value is MissionRequiredDocumentKey { + return (MISSION_REQUIRED_DOCUMENT_KEYS as readonly string[]).includes(value); +} + +export function isMissionDocumentKey(value: string): value is MissionDocumentKey { + return ( + isMissionRequiredDocumentKey(value) || + value === MISSION_FINAL_REPORT_DOCUMENT_KEY || + /^validation-report-round-[1-9][0-9]*$/.test(value) || + /^milestone-summary-[a-z0-9][a-z0-9_-]*$/.test(value) + ); +} + +export interface MissionStateDerivationInput { + missionIssueStatus: MissionIssueStatus; + presentDocumentKeys: readonly string[]; + approvalRequired?: boolean; + paused?: boolean; + hasActiveFeatureIssues?: boolean; + hasActiveValidationIssues?: boolean; + hasActiveFixIssues?: boolean; + hasBlockingFindings?: boolean; +} + +export function deriveIssueBackedMissionState(input: MissionStateDerivationInput): MissionState { + if (input.missionIssueStatus === "cancelled") return "cancelled"; + if (input.paused) return "paused"; + if (input.missionIssueStatus === "blocked" || input.hasBlockingFindings) return "blocked"; + + const presentKeys = new Set(input.presentDocumentKeys); + if (input.missionIssueStatus === "done" && presentKeys.has(MISSION_FINAL_REPORT_DOCUMENT_KEY)) { + return "completed"; + } + + const hasMissionBrief = presentKeys.has("mission-brief"); + const hasValidationContract = presentKeys.has("validation-contract"); + const hasFeatures = presentKeys.has("features"); + + if (!hasMissionBrief || !hasValidationContract) return "draft"; + if (input.approvalRequired) return "ready_for_approval"; + if (!hasFeatures) return "planning"; + if (input.hasActiveFixIssues) return "fixing"; + if (input.hasActiveValidationIssues) return "validating"; + if (input.hasActiveFeatureIssues) return "running"; + + return "planning"; +} diff --git a/packages/plugins/plugin-missions/src/mission/parsers.ts b/packages/plugins/plugin-missions/src/mission/parsers.ts new file mode 100644 index 00000000000..c1cc4e5e2dc --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission/parsers.ts @@ -0,0 +1,375 @@ +import { + missionDecisionLogSchema, + missionFeaturesDocumentSchema, + missionValidationContractSchema, + missionValidationReportSchema, +} from "./schemas.js"; +import type { + MissionDecisionLog, + MissionFeaturesDocument, + MissionFindingWaiver, + MissionValidationContract, + MissionValidationReport, +} from "./types.js"; + +const VALIDATION_ID_RE = /\bVAL-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}\b/gi; +const FEATURE_ID_RE = /\bFEAT-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}\b/gi; +const MILESTONE_ID_RE = /\bMILESTONE-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}\b/gi; +const FINDING_ID_RE = /\bFINDING-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}\b/gi; + +export const MISSION_FINDING_WAIVER_MARKER_PREFIX = "paperclip:mission-finding-waiver:"; + +function normalizeId(value: string) { + return value.trim().toUpperCase(); +} + +function uniqueIds(values: string[]) { + return [...new Set(values.map(normalizeId))]; +} + +function extractIds(value: string, re: RegExp) { + return uniqueIds(value.match(re) ?? []); +} + +function extractJsonPayload(markdown: string): unknown | null { + const trimmed = markdown.trim(); + if (!trimmed) return null; + + const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed); + const candidate = fenced?.[1]?.trim() ?? trimmed; + if (!candidate.startsWith("{") && !candidate.startsWith("[")) return null; + + try { + return JSON.parse(candidate); + } catch { + return null; + } +} + +function parseHeading(line: string) { + const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line.trim()); + if (!match) return null; + return { text: match[2].trim() }; +} + +function parseField(line: string): { key: string; value: string } | null { + const match = /^[-*]\s+([A-Za-z][A-Za-z0-9 _/-]{1,48}):\s*(.*?)\s*$/.exec(line.trim()); + if (!match) return null; + return { + key: match[1].trim().toLowerCase().replace(/[\s/-]+/g, "_"), + value: match[2].trim(), + }; +} + +function parseKeyedHeading(text: string, re: RegExp) { + const [rawId] = text.match(re) ?? []; + if (!rawId) return null; + const id = normalizeId(rawId); + const escaped = rawId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const title = text + .replace(new RegExp(`\\b${escaped}\\b`, "i"), "") + .replace(/^[\s:.-]+/, "") + .trim(); + return { id, title: title || id }; +} + +function splitList(value: string) { + return value + .split(/(?:\s*;\s*|\s*,\s*)/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function normalizeEnumValue(value: string) { + return value.trim().toLowerCase().replace(/[\s-]+/g, "_"); +} + +export function validationReportRoundFromKey(key: string) { + const match = /^validation-report-round-([1-9][0-9]*)$/.exec(key); + return match ? Number.parseInt(match[1], 10) : null; +} + +export function isMissionValidationReportKey(key: string) { + return validationReportRoundFromKey(key) !== null; +} + +export function missionFindingWaiverMarker(findingId: string) { + return `${MISSION_FINDING_WAIVER_MARKER_PREFIX}${findingId}`; +} + +export function parseMissionValidationContractDocument(markdown: string): MissionValidationContract { + const json = extractJsonPayload(markdown); + if (json) return missionValidationContractSchema.parse(json); + + const assertions: MissionValidationContract["assertions"] = []; + let current: MissionValidationContract["assertions"][number] | null = null; + + for (const line of markdown.split(/\r?\n/)) { + const heading = parseHeading(line); + if (heading) { + const parsed = parseKeyedHeading(heading.text, VALIDATION_ID_RE); + if (parsed) { + current = { + id: parsed.id, + title: parsed.title, + user_value: "", + scope: "", + setup: "", + steps: [], + oracle: "", + tooling: ["manual_review"], + evidence: [], + claimed_by: [], + status: "unclaimed", + }; + assertions.push(current); + continue; + } + } + + if (!current) continue; + const field = parseField(line); + if (!field) continue; + + if (field.key === "user_value") current.user_value = field.value; + if (field.key === "scope") current.scope = field.value; + if (field.key === "setup") current.setup = field.value; + if (field.key === "steps") current.steps = splitList(field.value); + if (field.key === "oracle") current.oracle = field.value; + if (field.key === "tooling") { + current.tooling = splitList(field.value).map( + (tool) => tool.toLowerCase().replace(/[\s-]+/g, "_"), + ) as typeof current.tooling; + } + if (field.key === "evidence") { + current.evidence = splitList(field.value).map((description, index) => ({ + kind: index === 0 ? "primary" : `supporting-${index}`, + description, + required: true, + })); + } + if (["claimed_by", "claimed", "claims", "features"].includes(field.key)) { + current.claimed_by = uniqueIds([...current.claimed_by, ...extractIds(field.value, FEATURE_ID_RE)]); + } + if (field.key === "status") current.status = normalizeEnumValue(field.value) as typeof current.status; + } + + return missionValidationContractSchema.parse({ assertions }); +} + +export function parseMissionFeaturesDocument(markdown: string): MissionFeaturesDocument { + const json = extractJsonPayload(markdown); + if (json) return missionFeaturesDocumentSchema.parse(json); + + const milestoneById = new Map(); + let currentMilestone: MissionFeaturesDocument["milestones"][number] | null = null; + let currentFeature: MissionFeaturesDocument["milestones"][number]["features"][number] | null = null; + + function ensureMilestone(id: string, title: string) { + const normalizedId = normalizeId(id); + const existing = milestoneById.get(normalizedId); + if (existing) return existing; + const milestone = { + id: normalizedId, + title: title.trim() || normalizedId, + summary: title.trim() || normalizedId, + depends_on: [], + features: [], + }; + milestoneById.set(normalizedId, milestone); + return milestone; + } + + for (const line of markdown.split(/\r?\n/)) { + const heading = parseHeading(line); + if (heading) { + const featureHeading = parseKeyedHeading(heading.text, FEATURE_ID_RE); + if (featureHeading) { + currentMilestone ??= ensureMilestone("MILESTONE-MISSION-001", "Mission milestone"); + currentFeature = { + id: featureHeading.id, + title: featureHeading.title, + kind: "original", + summary: "", + acceptance_criteria: [], + claimed_assertion_ids: [], + depends_on: [], + status: "planned", + }; + currentMilestone.features.push(currentFeature); + continue; + } + + const milestoneHeading = parseKeyedHeading(heading.text, MILESTONE_ID_RE); + if (milestoneHeading) { + currentMilestone = ensureMilestone(milestoneHeading.id, milestoneHeading.title); + currentFeature = null; + continue; + } + } + + const field = parseField(line); + if (!field) continue; + + if (currentFeature) { + if (field.key === "kind") currentFeature.kind = normalizeEnumValue(field.value) === "fix" ? "fix" : "original"; + if (["summary", "description"].includes(field.key)) currentFeature.summary = field.value; + if (["acceptance_criteria", "success_criteria"].includes(field.key)) { + currentFeature.acceptance_criteria = splitList(field.value); + } + if (["depends_on", "dependencies", "blocked_by"].includes(field.key)) { + currentFeature.depends_on = uniqueIds([ + ...currentFeature.depends_on, + ...extractIds(field.value, FEATURE_ID_RE), + ]); + } + if (["claimed_assertion_ids", "claimed_by", "claims", "assertions", "validation"].includes(field.key)) { + currentFeature.claimed_assertion_ids = uniqueIds([ + ...currentFeature.claimed_assertion_ids, + ...extractIds(field.value, VALIDATION_ID_RE), + ]); + } + if (field.key === "source_finding_id") currentFeature.source_finding_id = field.value || null; + if (field.key === "status") currentFeature.status = normalizeEnumValue(field.value) as typeof currentFeature.status; + continue; + } + + if (currentMilestone) { + if (["summary", "description"].includes(field.key)) currentMilestone.summary = field.value; + if (["depends_on", "dependencies", "blocked_by"].includes(field.key)) { + currentMilestone.depends_on = uniqueIds([ + ...currentMilestone.depends_on, + ...extractIds(field.value, MILESTONE_ID_RE), + ]); + } + } + } + + return missionFeaturesDocumentSchema.parse({ milestones: [...milestoneById.values()] }); +} + +export function parseMissionValidationReportDocument( + markdown: string, + options: { round?: number } = {}, +): MissionValidationReport { + const json = extractJsonPayload(markdown); + if (json) return missionValidationReportSchema.parse(json); + + const report: Partial = { + round: options.round, + summary: "", + findings: [], + }; + let currentFinding: MissionValidationReport["findings"][number] | null = null; + + for (const line of markdown.split(/\r?\n/)) { + const heading = parseHeading(line); + if (heading) { + const findingHeading = parseKeyedHeading(heading.text, FINDING_ID_RE); + if (findingHeading) { + currentFinding = { + id: findingHeading.id, + severity: "suggestion", + assertion_id: null, + title: findingHeading.title, + evidence: [], + repro_steps: [], + expected: "", + actual: "", + suspected_area: null, + recommended_fix_scope: null, + status: "open", + }; + report.findings!.push(currentFinding); + continue; + } + } + + const field = parseField(line); + if (!field) continue; + + if (!currentFinding) { + if (field.key === "round") report.round = Number.parseInt(field.value, 10); + if (["validator_role", "role"].includes(field.key)) { + report.validator_role = normalizeEnumValue(field.value) as MissionValidationReport["validator_role"]; + } + if (field.key === "summary") report.summary = field.value; + continue; + } + + if (field.key === "severity") currentFinding.severity = normalizeEnumValue(field.value) as typeof currentFinding.severity; + if (["assertion", "assertion_id"].includes(field.key)) { + currentFinding.assertion_id = extractIds(field.value, VALIDATION_ID_RE)[0] ?? null; + } + if (field.key === "evidence") currentFinding.evidence = splitList(field.value); + if (["repro_steps", "steps"].includes(field.key)) currentFinding.repro_steps = splitList(field.value); + if (field.key === "expected") currentFinding.expected = field.value; + if (field.key === "actual") currentFinding.actual = field.value; + if (field.key === "suspected_area") currentFinding.suspected_area = field.value || null; + if (field.key === "recommended_fix_scope") currentFinding.recommended_fix_scope = field.value || null; + if (field.key === "status") currentFinding.status = normalizeEnumValue(field.value) as typeof currentFinding.status; + } + + return missionValidationReportSchema.parse(report); +} + +export function parseMissionDecisionLogDocument(decisionLogBody: string | null | undefined): MissionDecisionLog { + const entries: MissionDecisionLog["entries"] = []; + if (!decisionLogBody) return missionDecisionLogSchema.parse({ entries }); + + const markerRe = //g; + const matches = [...decisionLogBody.matchAll(markerRe)]; + for (let index = 0; index < matches.length; index += 1) { + const match = matches[index]!; + const findingId = match[1]!; + const start = match.index! + match[0].length; + const end = matches[index + 1]?.index ?? decisionLogBody.length; + const block = decisionLogBody.slice(start, end); + const rationale = + /^\s*-\s+Rationale:\s*(.+?)\s*$/im.exec(block)?.[1]?.trim() ?? + block.trim().split(/\r?\n/).find((line) => line.trim())?.trim() ?? + ""; + const actorLabel = /^\s*-\s+Decision by:\s*(.+?)\s*$/im.exec(block)?.[1]?.trim() ?? null; + const recordedAt = /^\s*-\s+Recorded at:\s*(.+?)\s*$/im.exec(block)?.[1]?.trim() ?? null; + + entries.push({ + kind: "finding_waiver", + findingId, + rationale, + actorLabel, + recordedAt, + }); + } + + return missionDecisionLogSchema.parse({ entries }); +} + +export function parseMissionFindingWaivers(decisionLogBody: string | null | undefined): Map { + const waivers = new Map(); + for (const entry of parseMissionDecisionLogDocument(decisionLogBody).entries) { + waivers.set(entry.findingId, { + findingId: entry.findingId, + rationale: entry.rationale, + actorLabel: entry.actorLabel ?? null, + recordedAt: entry.recordedAt ?? null, + }); + } + return waivers; +} + +export function buildMissionFindingWaiverEntry(input: { + findingId: string; + rationale: string; + actorLabel: string; + createdAt: Date | string; +}) { + const createdAt = input.createdAt instanceof Date ? input.createdAt.toISOString() : input.createdAt; + return [ + ``, + `### Waived ${input.findingId}`, + "", + `- Rationale: ${input.rationale}`, + `- Decision by: ${input.actorLabel}`, + `- Recorded at: ${createdAt}`, + ].join("\n"); +} diff --git a/packages/plugins/plugin-missions/src/mission/schemas.ts b/packages/plugins/plugin-missions/src/mission/schemas.ts new file mode 100644 index 00000000000..4ec8e0c37f0 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission/schemas.ts @@ -0,0 +1,271 @@ +import { z } from "zod"; +import { + MISSION_FEATURE_KINDS, + MISSION_FEATURE_STATUSES, + MISSION_FINDING_SEVERITIES, + MISSION_FINDING_STATUSES, + MISSION_HOST_ISSUE_STATUSES, + MISSION_REQUIRED_DOCUMENT_KEYS, + MISSION_ROLE_TYPES, + MISSION_STATES, + MISSION_VALIDATION_ASSERTION_STATUSES, + MISSION_VALIDATION_TOOLING, +} from "./constants.js"; +import { isMissionDocumentKey } from "./mission.js"; + +const validationAssertionIdSchema = z + .string() + .trim() + .regex(/^VAL-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}$/, "Validation assertion IDs must look like VAL-MISSION-001"); + +const featureIdSchema = z + .string() + .trim() + .regex(/^FEAT-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}$/, "Feature IDs must look like FEAT-MISSION-001"); + +const milestoneIdSchema = z + .string() + .trim() + .regex(/^MILESTONE-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}$/, "Milestone IDs must look like MILESTONE-MISSION-001"); + +const findingIdSchema = z + .string() + .trim() + .regex(/^FINDING-[A-Z0-9][A-Z0-9-]*-[0-9]{3,}$/, "Finding IDs must look like FINDING-MISSION-001"); + +const nonEmptyStringSchema = z.string().trim().min(1); +const nonEmptyStringListSchema = z.array(nonEmptyStringSchema).min(1); + +export const missionStateSchema = z.enum(MISSION_STATES); +export const missionRoleTypeSchema = z.enum(MISSION_ROLE_TYPES); +export const missionRequiredDocumentKeySchema = z.enum(MISSION_REQUIRED_DOCUMENT_KEYS); +export const missionDocumentKeySchema = z + .string() + .trim() + .refine(isMissionDocumentKey, "Unknown mission document key"); + +export const missionEvidenceRequirementSchema = z + .object({ + kind: nonEmptyStringSchema, + description: nonEmptyStringSchema, + required: z.boolean().default(true), + }) + .strict(); + +export const missionValidationAssertionSchema = z + .object({ + id: validationAssertionIdSchema, + title: nonEmptyStringSchema, + user_value: nonEmptyStringSchema, + scope: nonEmptyStringSchema, + setup: nonEmptyStringSchema, + steps: nonEmptyStringListSchema, + oracle: nonEmptyStringSchema, + tooling: z.array(z.enum(MISSION_VALIDATION_TOOLING)).min(1), + evidence: z.array(missionEvidenceRequirementSchema).min(1), + claimed_by: z.array(featureIdSchema).default([]), + status: z.enum(MISSION_VALIDATION_ASSERTION_STATUSES).default("unclaimed"), + }) + .strict(); + +export const missionValidationContractSchema = z + .object({ + assertions: z.array(missionValidationAssertionSchema).min(1), + }) + .strict() + .superRefine((value, ctx) => { + const ids = new Set(); + value.assertions.forEach((assertion, index) => { + if (ids.has(assertion.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate validation assertion id: ${assertion.id}`, + path: ["assertions", index, "id"], + }); + } + ids.add(assertion.id); + }); + }); + +export const missionFeatureKindSchema = z.enum(MISSION_FEATURE_KINDS); +export const missionFeatureStatusSchema = z.enum(MISSION_FEATURE_STATUSES); + +export const missionFeatureSchema = z + .object({ + id: featureIdSchema, + title: nonEmptyStringSchema, + kind: missionFeatureKindSchema.default("original"), + summary: nonEmptyStringSchema, + acceptance_criteria: nonEmptyStringListSchema, + claimed_assertion_ids: z.array(validationAssertionIdSchema).default([]), + depends_on: z.array(featureIdSchema).default([]), + status: missionFeatureStatusSchema.default("planned"), + source_finding_id: findingIdSchema.nullable().optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.kind === "original" && value.claimed_assertion_ids.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Original mission features must claim at least one validation assertion", + path: ["claimed_assertion_ids"], + }); + } + if (value.kind === "fix" && !value.source_finding_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Fix mission features must reference a source finding", + path: ["source_finding_id"], + }); + } + }); + +export const missionMilestoneSchema = z + .object({ + id: milestoneIdSchema, + title: nonEmptyStringSchema, + summary: nonEmptyStringSchema, + depends_on: z.array(milestoneIdSchema).default([]), + features: z.array(missionFeatureSchema).min(1), + }) + .strict(); + +export const missionFeaturesDocumentSchema = z + .object({ + milestones: z.array(missionMilestoneSchema).min(1), + }) + .strict() + .superRefine((value, ctx) => { + const milestoneIds = new Set(); + const featureIds = new Set(); + value.milestones.forEach((milestone, milestoneIndex) => { + if (milestoneIds.has(milestone.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate mission milestone id: ${milestone.id}`, + path: ["milestones", milestoneIndex, "id"], + }); + } + milestoneIds.add(milestone.id); + milestone.features.forEach((feature, featureIndex) => { + if (featureIds.has(feature.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate mission feature id: ${feature.id}`, + path: ["milestones", milestoneIndex, "features", featureIndex, "id"], + }); + } + featureIds.add(feature.id); + }); + }); + }); + +export const missionFindingSeveritySchema = z.enum(MISSION_FINDING_SEVERITIES); +export const missionFindingStatusSchema = z.enum(MISSION_FINDING_STATUSES); + +export const missionFindingSchema = z + .object({ + id: findingIdSchema, + severity: missionFindingSeveritySchema, + assertion_id: validationAssertionIdSchema.nullable().optional(), + title: nonEmptyStringSchema, + evidence: nonEmptyStringListSchema, + repro_steps: nonEmptyStringListSchema, + expected: nonEmptyStringSchema, + actual: nonEmptyStringSchema, + suspected_area: nonEmptyStringSchema.nullable().optional(), + recommended_fix_scope: nonEmptyStringSchema.nullable().optional(), + status: missionFindingStatusSchema.default("open"), + }) + .strict() + .superRefine((value, ctx) => { + if (value.severity === "blocking" && !value.assertion_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Blocking mission findings must reference a validation assertion", + path: ["assertion_id"], + }); + } + if (value.status === "fix_created" && !value.recommended_fix_scope) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Findings with fix_created status must include recommended_fix_scope", + path: ["recommended_fix_scope"], + }); + } + }); + +export const missionValidationReportSchema = z + .object({ + round: z.number().int().positive(), + validator_role: z.enum(["scrutiny_validator", "user_testing_validator"]), + summary: nonEmptyStringSchema, + findings: z.array(missionFindingSchema).default([]), + }) + .strict(); + +export const missionFindingWaiverSchema = z + .object({ + findingId: findingIdSchema, + rationale: nonEmptyStringSchema.max(5000), + actorLabel: nonEmptyStringSchema.nullable().optional(), + recordedAt: nonEmptyStringSchema.nullable().optional(), + }) + .strict(); + +export const missionDecisionLogEntrySchema = missionFindingWaiverSchema.extend({ + kind: z.literal("finding_waiver"), +}).strict(); + +export const missionDecisionLogSchema = z + .object({ + entries: z.array(missionDecisionLogEntrySchema).default([]), + }) + .strict(); + +export const missionStateDerivationInputSchema = z + .object({ + missionIssueStatus: z.enum(MISSION_HOST_ISSUE_STATUSES), + presentDocumentKeys: z.array(missionDocumentKeySchema), + approvalRequired: z.boolean().optional(), + paused: z.boolean().optional(), + hasActiveFeatureIssues: z.boolean().optional(), + hasActiveValidationIssues: z.boolean().optional(), + hasActiveFixIssues: z.boolean().optional(), + hasBlockingFindings: z.boolean().optional(), + }) + .strict(); + +export const decomposeMissionSchema = z + .object({ + dryRun: z.boolean().optional().default(false), + }) + .strict(); + +export const advanceMissionSchema = z + .object({ + budgetLimitCents: z.number().int().positive().optional(), + maxValidationRounds: z.number().int().positive().max(20).optional(), + }) + .strict(); + +export const waiveMissionFindingSchema = z + .object({ + rationale: nonEmptyStringSchema.max(5000), + }) + .strict(); + +export type MissionValidationAssertion = z.infer; +export type MissionValidationContract = z.infer; +export type MissionFeature = z.infer; +export type MissionMilestone = z.infer; +export type MissionFeaturesDocument = z.infer; +export type MissionFinding = z.infer; +export type MissionValidationReport = z.infer; +export type MissionFindingWaiver = z.infer; +export type MissionDecisionLogEntry = z.infer; +export type MissionDecisionLog = z.infer; +export type MissionStateDerivationInput = z.infer; +export type DecomposeMission = z.infer; +export type AdvanceMission = z.infer; +export type WaiveMissionFinding = z.infer; diff --git a/packages/plugins/plugin-missions/src/mission/templates.ts b/packages/plugins/plugin-missions/src/mission/templates.ts new file mode 100644 index 00000000000..91f03bea0e6 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission/templates.ts @@ -0,0 +1,156 @@ +import type { MissionRequiredDocumentKey } from "./constants.js"; +import { MISSION_REQUIRED_DOCUMENT_KEYS } from "./constants.js"; +import type { MissionDocumentTemplate, MissionTemplateIssueContext } from "./types.js"; + +function issueReference(issue: Pick) { + if (!issue.identifier) return `\`${issue.id}\``; + const prefix = issue.identifier.split("-")[0]?.toUpperCase(); + return prefix ? `[${issue.identifier}](/${prefix}/issues/${issue.identifier})` : `\`${issue.id}\``; +} + +function missionBillingCode(issue: Pick) { + return issue.billingCode ?? `mission:${issue.identifier ?? issue.id}`; +} + +export function defaultMissionDocument(input: { + issue: MissionTemplateIssueContext; + key: MissionRequiredDocumentKey; +}): MissionDocumentTemplate { + const { issue, key } = input; + const ref = issueReference(issue); + const issueTitle = issue.title.trim(); + + switch (key) { + case "plan": + return { + key, + title: "Plan", + body: [ + "# Plan", + "", + `Mission issue: ${ref}`, + "", + "## Objective", + "", + issue.description?.trim() || issueTitle, + "", + "## Milestones", + "", + "- TODO: Decompose the mission into bounded milestones.", + "", + "## Verification", + "", + "- TODO: Link validation assertions from `validation-contract` before implementation starts.", + ].join("\n"), + }; + case "mission-brief": + return { + key, + title: "Mission Brief", + body: [ + "# Mission Brief", + "", + `Mission issue: ${ref}`, + `Current status: \`${issue.status}\``, + `Billing code: \`${missionBillingCode(issue)}\``, + "", + "## Goal", + "", + issueTitle, + "", + "## Scope", + "", + "- TODO: Define the work that is in scope.", + "", + "## Non-Goals", + "", + "- TODO: Define what this mission will not do.", + "", + "## Assumptions", + "", + "- TODO: Record assumptions that need validation.", + ].join("\n"), + }; + case "validation-contract": + return { + key, + title: "Validation Contract", + body: [ + "# Validation Contract", + "", + "Define finite, testable assertions before feature decomposition.", + "", + "```json", + JSON.stringify({ assertions: [] }, null, 2), + "```", + ].join("\n"), + }; + case "features": + return { + key, + title: "Features", + body: [ + "# Features", + "", + "Group implementation features by milestone after the validation contract is written.", + "", + "```json", + JSON.stringify({ milestones: [] }, null, 2), + "```", + ].join("\n"), + }; + case "worker-guidelines": + return { + key, + title: "Worker Guidelines", + body: [ + "# Worker Guidelines", + "", + "- Work only on the assigned child issue.", + "- Preserve normal checkout, ownership, testing, and handoff rules.", + "- Do not decide final correctness; validators judge against the validation contract.", + "- Leave concise evidence in comments, work products, or attachments.", + ].join("\n"), + }; + case "services": + return { + key, + title: "Services", + body: [ + "# Services", + "", + "## Commands", + "", + "- TODO: Document local server, test, and preview commands.", + "", + "## Environment", + "", + "- TODO: Document required accounts, seeded data, secrets, and setup costs.", + ].join("\n"), + }; + case "knowledge-base": + return { + key, + title: "Knowledge Base", + body: [ + "# Knowledge Base", + "", + "- TODO: Add concise discoveries that future workers or validators need.", + ].join("\n"), + }; + case "decision-log": + return { + key, + title: "Decision Log", + body: [ + "# Decision Log", + "", + "- Mission initialized from existing issue state.", + ].join("\n"), + }; + } +} + +export function createDefaultMissionDocumentTemplates(issue: MissionTemplateIssueContext): MissionDocumentTemplate[] { + return MISSION_REQUIRED_DOCUMENT_KEYS.map((key) => defaultMissionDocument({ issue, key })); +} diff --git a/packages/plugins/plugin-missions/src/mission/types.ts b/packages/plugins/plugin-missions/src/mission/types.ts new file mode 100644 index 00000000000..6a32e59ec78 --- /dev/null +++ b/packages/plugins/plugin-missions/src/mission/types.ts @@ -0,0 +1,110 @@ +import type { + MissionFeatureKind, + MissionFeatureStatus, + MissionFindingSeverity, + MissionFindingStatus, + MissionIssueStatus, + MissionRequiredDocumentKey, + MissionRoleType, + MissionValidationAssertionStatus, + MissionValidationTooling, +} from "./constants.js"; + +export interface MissionEvidenceRequirement { + kind: string; + description: string; + required: boolean; +} + +export interface MissionValidationAssertion { + id: string; + title: string; + user_value: string; + scope: string; + setup: string; + steps: string[]; + oracle: string; + tooling: MissionValidationTooling[]; + evidence: MissionEvidenceRequirement[]; + claimed_by: string[]; + status: MissionValidationAssertionStatus; +} + +export interface MissionValidationContract { + assertions: MissionValidationAssertion[]; +} + +export interface MissionFeature { + id: string; + title: string; + kind: MissionFeatureKind; + summary: string; + acceptance_criteria: string[]; + claimed_assertion_ids: string[]; + depends_on: string[]; + status: MissionFeatureStatus; + source_finding_id?: string | null; +} + +export interface MissionMilestone { + id: string; + title: string; + summary: string; + depends_on: string[]; + features: MissionFeature[]; +} + +export interface MissionFeaturesDocument { + milestones: MissionMilestone[]; +} + +export interface MissionFinding { + id: string; + severity: MissionFindingSeverity; + assertion_id?: string | null; + title: string; + evidence: string[]; + repro_steps: string[]; + expected: string; + actual: string; + suspected_area?: string | null; + recommended_fix_scope?: string | null; + status: MissionFindingStatus; +} + +export interface MissionValidationReport { + round: number; + validator_role: Extract; + summary: string; + findings: MissionFinding[]; +} + +export interface MissionFindingWaiver { + findingId: string; + rationale: string; + actorLabel?: string | null; + recordedAt?: string | null; +} + +export interface MissionDecisionLogEntry extends MissionFindingWaiver { + kind: "finding_waiver"; +} + +export interface MissionDecisionLog { + entries: MissionDecisionLogEntry[]; +} + +export interface MissionTemplateIssueContext { + id: string; + identifier?: string | null; + title: string; + description?: string | null; + status: MissionIssueStatus; + billingCode?: string | null; +} + +export interface MissionDocumentTemplate { + key: MissionRequiredDocumentKey; + title: string; + body: string; +} diff --git a/packages/plugins/plugin-missions/tests/mission-contract.spec.ts b/packages/plugins/plugin-missions/tests/mission-contract.spec.ts new file mode 100644 index 00000000000..a2759b7a4ae --- /dev/null +++ b/packages/plugins/plugin-missions/tests/mission-contract.spec.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { + advanceMissionSchema, + buildMissionFindingWaiverEntry, + createDefaultMissionDocumentTemplates, + decomposeMissionSchema, + deriveIssueBackedMissionState, + getMissionMilestoneSummaryDocumentKey, + getMissionValidationReportDocumentKey, + isMissionDocumentKey, + missionDecisionLogMarkdownFixture, + missionFeaturesDocumentSchema, + missionFeaturesFixture, + missionFeaturesMarkdownFixture, + missionFindingSchema, + missionStateDerivationInputSchema, + missionTemplateIssueFixture, + missionValidationContractFixture, + missionValidationContractMarkdownFixture, + missionValidationContractSchema, + missionValidationReportFixture, + missionValidationReportMarkdownFixture, + missionValidationReportSchema, + parseMissionDecisionLogDocument, + parseMissionFeaturesDocument, + parseMissionFindingWaivers, + parseMissionValidationContractDocument, + parseMissionValidationReportDocument, +} from "../src/mission/index.js"; + +describe("mission document keys", () => { + it("accepts required and generated mission document keys", () => { + expect(isMissionDocumentKey("validation-contract")).toBe(true); + expect(isMissionDocumentKey("validation-report-round-2")).toBe(true); + expect(isMissionDocumentKey("milestone-summary-foundation")).toBe(true); + expect(isMissionDocumentKey("mission-final-report")).toBe(true); + expect(isMissionDocumentKey("unknown")).toBe(false); + }); + + it("builds generated document keys from canonical helpers", () => { + expect(getMissionValidationReportDocumentKey(3)).toBe("validation-report-round-3"); + expect(getMissionMilestoneSummaryDocumentKey("Foundation_1")).toBe("milestone-summary-foundation_1"); + expect(() => getMissionValidationReportDocumentKey(0)).toThrow("positive integers"); + expect(() => getMissionMilestoneSummaryDocumentKey("not valid")).toThrow("document-key-safe"); + }); +}); + +describe("mission templates", () => { + it("creates the required mission document bundle inside the plugin package", () => { + const templates = createDefaultMissionDocumentTemplates(missionTemplateIssueFixture); + + expect(templates).toHaveLength(8); + expect(templates.map((template) => template.key)).toEqual([ + "plan", + "mission-brief", + "validation-contract", + "features", + "worker-guidelines", + "services", + "knowledge-base", + "decision-log", + ]); + expect(templates.find((template) => template.key === "mission-brief")?.body).toContain("mission:pap-1685"); + }); +}); + +describe("mission validation contract schema", () => { + it("accepts testable assertions with evidence requirements", () => { + const parsed = missionValidationContractSchema.parse(missionValidationContractFixture); + expect(parsed.assertions[0]?.id).toBe("VAL-MISSION-001"); + }); + + it("rejects duplicate assertion IDs", () => { + const result = missionValidationContractSchema.safeParse({ + assertions: [ + missionValidationContractFixture.assertions[0], + missionValidationContractFixture.assertions[0], + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message.includes("Duplicate validation assertion id"))).toBe(true); + } + }); + + it("parses validation assertions from structured markdown", () => { + const parsed = parseMissionValidationContractDocument(missionValidationContractMarkdownFixture); + expect(parsed.assertions[0]?.tooling).toEqual(["api_call", "manual_review"]); + }); +}); + +describe("mission features document schema", () => { + it("accepts milestones with assertion-claiming features", () => { + const parsed = missionFeaturesDocumentSchema.parse(missionFeaturesFixture); + expect(parsed.milestones[0]?.features[0]?.claimed_assertion_ids).toEqual(["VAL-MISSION-001"]); + }); + + it("rejects original features that claim no assertions", () => { + const result = missionFeaturesDocumentSchema.safeParse({ + milestones: [ + { + ...missionFeaturesFixture.milestones[0], + features: [ + { + ...missionFeaturesFixture.milestones[0]!.features[0]!, + claimed_assertion_ids: [], + }, + ], + }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message.includes("must claim"))).toBe(true); + } + }); + + it("parses milestone feature lists from structured markdown", () => { + const parsed = parseMissionFeaturesDocument(missionFeaturesMarkdownFixture); + expect(parsed.milestones[0]?.id).toBe("MILESTONE-MISSION-001"); + expect(parsed.milestones[0]?.features[0]?.id).toBe("FEAT-MISSION-001"); + }); +}); + +describe("mission finding and report schemas", () => { + it("accepts structured validation findings in reports", () => { + const parsed = missionValidationReportSchema.parse(missionValidationReportFixture); + expect(parsed.findings[0]?.severity).toBe("blocking"); + }); + + it("rejects blocking findings without assertion references", () => { + const result = missionFindingSchema.safeParse({ + ...missionValidationReportFixture.findings[0], + assertion_id: null, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message.includes("must reference"))).toBe(true); + } + }); + + it("parses validation reports from structured markdown", () => { + const parsed = parseMissionValidationReportDocument(missionValidationReportMarkdownFixture); + expect(parsed.round).toBe(2); + expect(parsed.findings[0]?.evidence).toHaveLength(2); + }); +}); + +describe("mission waivers and decision logs", () => { + it("parses finding waivers from the decision log", () => { + const parsed = parseMissionDecisionLogDocument(missionDecisionLogMarkdownFixture); + expect(parsed.entries[0]).toEqual({ + kind: "finding_waiver", + findingId: "FINDING-MISSION-002", + rationale: "Acceptable for MVP.", + actorLabel: "Board", + recordedAt: "2026-04-20T12:00:00.000Z", + }); + }); + + it("builds waiver entries that round-trip through the decision log parser", () => { + const entry = buildMissionFindingWaiverEntry({ + findingId: "FINDING-MISSION-003", + rationale: "Known UX tradeoff.", + actorLabel: "CTO", + createdAt: "2026-04-20T13:00:00.000Z", + }); + const waivers = parseMissionFindingWaivers(["# Decision Log", "", entry].join("\n")); + + expect(waivers.get("FINDING-MISSION-003")).toEqual({ + findingId: "FINDING-MISSION-003", + rationale: "Known UX tradeoff.", + actorLabel: "CTO", + recordedAt: "2026-04-20T13:00:00.000Z", + }); + }); +}); + +describe("issue-backed mission state and action schemas", () => { + it("derives draft, active, and terminal mission states from issue/document inputs", () => { + expect( + deriveIssueBackedMissionState({ + missionIssueStatus: "in_progress", + presentDocumentKeys: ["mission-brief"], + }), + ).toBe("draft"); + + expect( + deriveIssueBackedMissionState({ + missionIssueStatus: "in_progress", + presentDocumentKeys: ["mission-brief", "validation-contract", "features"], + hasActiveValidationIssues: true, + }), + ).toBe("validating"); + + expect( + deriveIssueBackedMissionState({ + missionIssueStatus: "done", + presentDocumentKeys: ["mission-brief", "validation-contract", "features", "mission-final-report"], + }), + ).toBe("completed"); + }); + + it("validates derivation inputs and mission action schemas", () => { + expect(() => + missionStateDerivationInputSchema.parse({ + missionIssueStatus: "in_progress", + presentDocumentKeys: ["invalid-key"], + }), + ).toThrow("Unknown mission document key"); + + expect(decomposeMissionSchema.parse({})).toEqual({ dryRun: false }); + expect(advanceMissionSchema.parse({ budgetLimitCents: 100, maxValidationRounds: 2 })).toEqual({ + budgetLimitCents: 100, + maxValidationRounds: 2, + }); + }); +});