feat(FN-1667): merge fusion/fn-1667 (auto-resolved)

- test(FN-1667): complete Step 4 — verify roadmap model
- feat(FN-1667): complete Step 3 — document roadmap model
- feat(FN-1667): complete Step 2 — add roadmap ordering helpers
- feat(FN-1667): complete Step 1 — define roadmap contracts
This commit is contained in:
gsxdsm
2026-04-13 08:57:48 -07:00
parent 251af54580
commit 8874c0d73a
7 changed files with 930 additions and 1 deletions

View File

@@ -168,6 +168,38 @@ export {
__resetSummarizeState,
} from "./ai-summarize.js";
// ── Standalone Roadmap Model ───────────────────────────────────────────
export type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMilestoneReorderInput,
RoadmapFeatureReorderInput,
RoadmapFeatureMoveInput,
RoadmapFeatureMoveResult,
RoadmapMilestoneWithFeatures,
RoadmapWithHierarchy,
RoadmapExportBundle,
RoadmapFeatureSourceRef,
RoadmapFeatureTaskPlanningHandoff,
RoadmapMissionPlanningMilestoneHandoff,
RoadmapMissionPlanningHandoff,
} from "./roadmap-types.js";
export {
normalizeRoadmapMilestoneOrder,
applyRoadmapMilestoneReorder,
normalizeRoadmapFeatureOrder,
applyRoadmapFeatureReorder,
moveRoadmapFeature,
} from "./roadmap-ordering.js";
// ── Mission Hierarchy Types ────────────────────────────────────────────
export {

View File

@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import {
applyRoadmapFeatureReorder,
applyRoadmapMilestoneReorder,
moveRoadmapFeature,
normalizeRoadmapFeatureOrder,
normalizeRoadmapMilestoneOrder,
} from "./roadmap-ordering.js";
import type { RoadmapFeature, RoadmapMilestone } from "./roadmap-types.js";
function createMilestone(
id: string,
roadmapId: string,
orderIndex: number,
createdAt: string,
): RoadmapMilestone {
return {
id,
roadmapId,
title: id,
description: `${id} description`,
orderIndex,
createdAt,
updatedAt: createdAt,
};
}
function createFeature(
id: string,
milestoneId: string,
orderIndex: number,
createdAt: string,
): RoadmapFeature {
return {
id,
milestoneId,
title: id,
description: `${id} description`,
orderIndex,
createdAt,
updatedAt: createdAt,
};
}
describe("roadmap-ordering", () => {
describe("normalizeRoadmapMilestoneOrder", () => {
it("repairs milestone ordering deterministically using createdAt and id tiebreakers", () => {
const milestones = [
createMilestone("RMS-C", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
createMilestone("RMS-B", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
createMilestone("RMS-A", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
];
const normalized = normalizeRoadmapMilestoneOrder(milestones);
expect(normalized.map((milestone) => milestone.id)).toEqual([
"RMS-A",
"RMS-B",
"RMS-C",
]);
expect(normalized.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
expect(milestones.map((milestone) => milestone.orderIndex)).toEqual([2, 1, 1]);
});
it("rejects mixed-roadmap milestone scopes", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-2", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() => normalizeRoadmapMilestoneOrder(milestones)).toThrow(
"Milestone RMS-2 does not belong to roadmap RM-1",
);
});
});
describe("applyRoadmapMilestoneReorder", () => {
it("reorders milestones and rewrites contiguous order indexes", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
createMilestone("RMS-3", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
];
const reordered = applyRoadmapMilestoneReorder(milestones, {
roadmapId: "RM-1",
orderedMilestoneIds: ["RMS-3", "RMS-1", "RMS-2"],
});
expect(reordered.map((milestone) => milestone.id)).toEqual([
"RMS-3",
"RMS-1",
"RMS-2",
]);
expect(reordered.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
});
it("rejects duplicate milestone ids in reorder input", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
applyRoadmapMilestoneReorder(milestones, {
roadmapId: "RM-1",
orderedMilestoneIds: ["RMS-2", "RMS-2"],
}),
).toThrow("Duplicate milestone id in requested order: RMS-2");
});
});
describe("normalizeRoadmapFeatureOrder", () => {
it("repairs feature ordering deterministically using createdAt and id tiebreakers", () => {
const features = [
createFeature("RF-C", "RMS-1", 3, "2026-04-13T00:00:03.000Z"),
createFeature("RF-B", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-A", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const normalized = normalizeRoadmapFeatureOrder(features);
expect(normalized.map((feature) => feature.id)).toEqual([
"RF-A",
"RF-B",
"RF-C",
]);
expect(normalized.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
});
});
describe("applyRoadmapFeatureReorder", () => {
it("reorders features within a milestone and rewrites contiguous order indexes", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
];
const reordered = applyRoadmapFeatureReorder(features, {
roadmapId: "RM-1",
milestoneId: "RMS-1",
orderedFeatureIds: ["RF-2", "RF-3", "RF-1"],
});
expect(reordered.map((feature) => feature.id)).toEqual([
"RF-2",
"RF-3",
"RF-1",
]);
expect(reordered.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
});
it("rejects partial feature reorder payloads", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
applyRoadmapFeatureReorder(features, {
roadmapId: "RM-1",
milestoneId: "RMS-1",
orderedFeatureIds: ["RF-2"],
}),
).toThrow("Expected 2 feature ids but received 1");
});
});
describe("moveRoadmapFeature", () => {
it("moves a feature across milestones and normalizes both milestone orders", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-TARGET", 0, "2026-04-13T00:00:02.000Z"),
createFeature("RF-4", "RMS-TARGET", 1, "2026-04-13T00:00:03.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 1,
});
expect(result.movedFeature).toMatchObject({
id: "RF-2",
milestoneId: "RMS-TARGET",
orderIndex: 1,
});
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-1",
]);
expect(result.sourceMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([0]);
expect(result.targetMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-3",
"RF-2",
"RF-4",
]);
expect(result.targetMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([
0,
1,
2,
]);
expect(result.affectedFeatures).toHaveLength(4);
});
it("clamps same-milestone moves into range and returns a single normalized list", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: 99,
});
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-2",
"RF-3",
"RF-1",
]);
expect(result.targetMilestoneFeatures).toEqual(result.sourceMilestoneFeatures);
expect(result.movedFeature.orderIndex).toBe(2);
});
it("rejects features outside the affected milestone scope", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-OTHER", 0, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 0,
}),
).toThrow(
"Feature RF-2 is outside the affected milestone scope (RMS-SOURCE → RMS-TARGET)",
);
});
});
});

View File

@@ -0,0 +1,311 @@
import type {
RoadmapFeature,
RoadmapFeatureMoveInput,
RoadmapFeatureMoveResult,
RoadmapFeatureReorderInput,
RoadmapMilestone,
RoadmapMilestoneReorderInput,
} from "./roadmap-types.js";
/**
* Pure ordering helpers for the standalone roadmap model.
*
* Ordering invariants enforced by this module:
* - helpers operate on scoped arrays (single roadmap for milestones, single
* milestone for feature reorders, source+target milestones for feature moves)
* - normalized `orderIndex` values are always contiguous + 0-based
* - when stored order data is conflicting, helpers repair deterministically via
* `orderIndex ASC`, `createdAt ASC`, `id ASC`
* - explicit reorder helpers reject partial or duplicate ID lists instead of
* guessing user intent
*/
interface OrderedEntity {
id: string;
orderIndex: number;
createdAt: string;
}
function compareOrderedEntities<T extends OrderedEntity>(a: T, b: T): number {
if (a.orderIndex !== b.orderIndex) {
return a.orderIndex - b.orderIndex;
}
if (a.createdAt !== b.createdAt) {
return a.createdAt.localeCompare(b.createdAt);
}
return a.id.localeCompare(b.id);
}
function clampInsertionIndex(targetIndex: number, length: number): number {
if (!Number.isFinite(targetIndex)) {
return length;
}
const normalized = Math.trunc(targetIndex);
if (normalized < 0) {
return 0;
}
if (normalized > length) {
return length;
}
return normalized;
}
function assertScopedRoadmapMilestones(
milestones: readonly RoadmapMilestone[],
roadmapId: string,
): void {
for (const milestone of milestones) {
if (milestone.roadmapId !== roadmapId) {
throw new Error(
`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`,
);
}
}
}
function assertScopedMilestoneFeatures(
features: readonly RoadmapFeature[],
milestoneId: string,
): void {
for (const feature of features) {
if (feature.milestoneId !== milestoneId) {
throw new Error(
`Feature ${feature.id} does not belong to milestone ${milestoneId}`,
);
}
}
}
function assertScopedMoveFeatures(
features: readonly RoadmapFeature[],
fromMilestoneId: string,
toMilestoneId: string,
): void {
const validMilestoneIds = new Set([fromMilestoneId, toMilestoneId]);
for (const feature of features) {
if (!validMilestoneIds.has(feature.milestoneId)) {
throw new Error(
`Feature ${feature.id} is outside the affected milestone scope (${fromMilestoneId}${toMilestoneId})`,
);
}
}
}
function assertExactIdSet(
entityLabel: string,
actualIds: readonly string[],
orderedIds: readonly string[],
): void {
const requestedIds = new Set<string>();
for (const id of orderedIds) {
if (requestedIds.has(id)) {
throw new Error(`Duplicate ${entityLabel} id in requested order: ${id}`);
}
requestedIds.add(id);
}
if (actualIds.length !== orderedIds.length) {
throw new Error(
`Expected ${actualIds.length} ${entityLabel} ids but received ${orderedIds.length}`,
);
}
const actualIdSet = new Set(actualIds);
for (const id of orderedIds) {
if (!actualIdSet.has(id)) {
throw new Error(`${capitalize(entityLabel)} ${id} not found in scoped list`);
}
}
for (const id of actualIds) {
if (!requestedIds.has(id)) {
throw new Error(`Missing ${entityLabel} id in requested order: ${id}`);
}
}
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function assignContiguousOrder<T extends OrderedEntity>(items: readonly T[]): T[] {
return items.map((item, orderIndex) => {
if (item.orderIndex === orderIndex) {
return { ...item };
}
return {
...item,
orderIndex,
};
});
}
/**
* Repairs milestone ordering for a single roadmap scope.
*
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
*/
export function normalizeRoadmapMilestoneOrder(
milestones: readonly RoadmapMilestone[],
): RoadmapMilestone[] {
if (milestones.length === 0) {
return [];
}
assertScopedRoadmapMilestones(milestones, milestones[0].roadmapId);
return assignContiguousOrder(
[...milestones].sort(compareOrderedEntities),
);
}
/**
* Applies an explicit milestone reorder for a single roadmap scope.
*
* The caller must provide the complete milestone ID set exactly once. Partial
* or duplicate lists are rejected to keep reorders deterministic.
*/
export function applyRoadmapMilestoneReorder(
milestones: readonly RoadmapMilestone[],
input: RoadmapMilestoneReorderInput,
): RoadmapMilestone[] {
assertScopedRoadmapMilestones(milestones, input.roadmapId);
const normalized = normalizeRoadmapMilestoneOrder(milestones);
const ids = normalized.map((milestone) => milestone.id);
assertExactIdSet("milestone", ids, input.orderedMilestoneIds);
const byId = new Map(normalized.map((milestone) => [milestone.id, milestone]));
return assignContiguousOrder(
input.orderedMilestoneIds.map((id) => byId.get(id)!),
);
}
/**
* Repairs feature ordering for a single milestone scope.
*
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
*/
export function normalizeRoadmapFeatureOrder(
features: readonly RoadmapFeature[],
): RoadmapFeature[] {
if (features.length === 0) {
return [];
}
assertScopedMilestoneFeatures(features, features[0].milestoneId);
return assignContiguousOrder(
[...features].sort(compareOrderedEntities),
);
}
/**
* Applies an explicit feature reorder for a single milestone scope.
*
* The caller must provide the complete feature ID set exactly once. Partial or
* duplicate lists are rejected to keep reorder behavior deterministic.
*/
export function applyRoadmapFeatureReorder(
features: readonly RoadmapFeature[],
input: RoadmapFeatureReorderInput,
): RoadmapFeature[] {
assertScopedMilestoneFeatures(features, input.milestoneId);
const normalized = normalizeRoadmapFeatureOrder(features);
const ids = normalized.map((feature) => feature.id);
assertExactIdSet("feature", ids, input.orderedFeatureIds);
const byId = new Map(normalized.map((feature) => [feature.id, feature]));
return assignContiguousOrder(
input.orderedFeatureIds.map((id) => byId.get(id)!),
);
}
/**
* Moves a feature within the affected milestone scope and deterministically
* normalizes both source and destination order.
*
* For cross-milestone moves, pass the combined feature list from the source and
* target milestones. For within-milestone moves, pass the current milestone's
* feature list. `targetOrderIndex` is clamped into the destination range.
*/
export function moveRoadmapFeature(
features: readonly RoadmapFeature[],
input: RoadmapFeatureMoveInput,
): RoadmapFeatureMoveResult {
assertScopedMoveFeatures(features, input.fromMilestoneId, input.toMilestoneId);
const existingFeature = features.find((feature) => feature.id === input.featureId);
if (!existingFeature) {
throw new Error(`Feature ${input.featureId} not found in affected milestone scope`);
}
if (existingFeature.milestoneId !== input.fromMilestoneId) {
throw new Error(
`Feature ${input.featureId} does not belong to milestone ${input.fromMilestoneId}`,
);
}
const sourceFeatures = normalizeRoadmapFeatureOrder(
features.filter((feature) => feature.milestoneId === input.fromMilestoneId),
);
const sourceWithoutFeature = sourceFeatures.filter(
(feature) => feature.id !== input.featureId,
);
if (input.fromMilestoneId === input.toMilestoneId) {
const insertionIndex = clampInsertionIndex(
input.targetOrderIndex,
sourceWithoutFeature.length,
);
const reordered = [...sourceWithoutFeature];
reordered.splice(insertionIndex, 0, {
...existingFeature,
milestoneId: input.toMilestoneId,
orderIndex: insertionIndex,
});
const normalized = assignContiguousOrder(reordered);
const movedFeature = normalized.find((feature) => feature.id === input.featureId)!;
return {
movedFeature,
affectedFeatures: normalized,
sourceMilestoneFeatures: normalized,
targetMilestoneFeatures: normalized,
};
}
const targetFeatures = normalizeRoadmapFeatureOrder(
features.filter((feature) => feature.milestoneId === input.toMilestoneId),
);
const insertionIndex = clampInsertionIndex(
input.targetOrderIndex,
targetFeatures.length,
);
const targetWithInsertedFeature = [...targetFeatures];
targetWithInsertedFeature.splice(insertionIndex, 0, {
...existingFeature,
milestoneId: input.toMilestoneId,
orderIndex: insertionIndex,
});
const normalizedSource = assignContiguousOrder(sourceWithoutFeature);
const normalizedTarget = assignContiguousOrder(targetWithInsertedFeature);
const movedFeature = normalizedTarget.find((feature) => feature.id === input.featureId)!;
return {
movedFeature,
affectedFeatures: [...normalizedSource, ...normalizedTarget],
sourceMilestoneFeatures: normalizedSource,
targetMilestoneFeatures: normalizedTarget,
};
}

View File

@@ -0,0 +1,310 @@
/**
* Standalone roadmap planning types.
*
* This model is intentionally separate from the mission hierarchy so roadmap
* work can evolve independently of `MissionStore`/`MissionManager`.
*
* Core ordering invariants:
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
* - cross-milestone feature moves must renumber both the source and target
* milestone deterministically after the move
* - whenever stored order data is incomplete or conflicting, consumers should
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
*
* These contracts are persistence-agnostic and UI-agnostic. They define the
* canonical domain surface that downstream storage, API, and dashboard work use.
*
* @module roadmap-types
*/
/**
* A standalone roadmap container.
*
* Roadmaps do not reuse mission lifecycle or mission status concepts. They are
* lightweight planning artifacts that own ordered milestones.
*/
export interface Roadmap {
/** Unique identifier (for example `RM-01HXYZ...`) */
id: string;
/** Display title shown in roadmap lists and detail views */
title: string;
/** Optional long-form planning context for the roadmap */
description?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A milestone within a roadmap.
*
* `orderIndex` is the canonical persisted ordering field. It is always scoped to
* the parent roadmap and must remain contiguous + 0-based after reorder flows.
*/
export interface RoadmapMilestone {
/** Unique identifier (for example `RMS-01HXYZ...`) */
id: string;
/** Parent roadmap ID */
roadmapId: string;
/** Display title for the milestone */
title: string;
/** Optional description of the milestone's goals */
description?: string;
/** 0-based contiguous ordering within the roadmap */
orderIndex: number;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A feature within a roadmap milestone.
*
* `orderIndex` is scoped to the parent milestone. Cross-milestone moves must
* update `milestoneId` and then normalize both affected milestone lists back to
* contiguous 0-based order.
*/
export interface RoadmapFeature {
/** Unique identifier (for example `RF-01HXYZ...`) */
id: string;
/** Parent milestone ID */
milestoneId: string;
/** Display title for the feature */
title: string;
/** Optional description of the feature's intent */
description?: string;
/** 0-based contiguous ordering within the parent milestone */
orderIndex: number;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
// ── CRUD Input Types ────────────────────────────────────────────────
/** Input for creating a roadmap. */
export interface RoadmapCreateInput {
/** Display title of the roadmap (required) */
title: string;
/** Optional roadmap description */
description?: string;
}
/** Input for updating roadmap metadata. Ordering is handled by dedicated move/reorder DTOs. */
export interface RoadmapUpdateInput {
/** Updated display title */
title?: string;
/** Updated roadmap description */
description?: string;
}
/** Input for creating a milestone inside a roadmap. */
export interface RoadmapMilestoneCreateInput {
/** Display title of the milestone (required) */
title: string;
/** Optional milestone description */
description?: string;
}
/** Input for updating milestone metadata. Ordering is handled separately. */
export interface RoadmapMilestoneUpdateInput {
/** Updated milestone title */
title?: string;
/** Updated milestone description */
description?: string;
}
/** Input for creating a feature inside a milestone. */
export interface RoadmapFeatureCreateInput {
/** Display title of the feature (required) */
title: string;
/** Optional feature description */
description?: string;
}
/** Input for updating feature metadata. Ordering is handled separately. */
export interface RoadmapFeatureUpdateInput {
/** Updated feature title */
title?: string;
/** Updated feature description */
description?: string;
}
// ── Ordering / Move Payload Types ───────────────────────────────────
/**
* Explicit reorder payload for milestones within a roadmap.
*
* `orderedMilestoneIds` must contain the full set of milestone IDs for the
* roadmap exactly once. Consumers should reject partial or duplicate lists.
*/
export interface RoadmapMilestoneReorderInput {
/** Roadmap whose milestone sequence is being rewritten */
roadmapId: string;
/** Complete milestone ID sequence in final order */
orderedMilestoneIds: string[];
}
/**
* Explicit reorder payload for features within a single milestone.
*
* `orderedFeatureIds` must contain the full set of feature IDs for the milestone
* exactly once. The resulting `orderIndex` values must be normalized to 0-based
* contiguous order.
*/
export interface RoadmapFeatureReorderInput {
/** Parent roadmap for integrity validation */
roadmapId: string;
/** Milestone whose internal feature ordering is being rewritten */
milestoneId: string;
/** Complete feature ID sequence in final order */
orderedFeatureIds: string[];
}
/**
* Explicit move payload for relocating a feature, including cross-milestone moves.
*
* `targetOrderIndex` is the desired insertion position in the destination
* milestone before final normalization. Consumers should clamp out-of-range
* values and must deterministically renumber both source and destination
* milestones after the move.
*/
export interface RoadmapFeatureMoveInput {
/** Parent roadmap for integrity validation */
roadmapId: string;
/** Feature being moved */
featureId: string;
/** Current milestone that owns the feature */
fromMilestoneId: string;
/** Destination milestone after the move */
toMilestoneId: string;
/** Requested insertion index in the destination milestone */
targetOrderIndex: number;
}
/**
* Result of a feature move operation after deterministic renumbering.
*
* `affectedFeatures` contains the canonical post-move feature records for the
* source and target milestones. When a feature is moved within the same
* milestone, `sourceMilestoneFeatures` and `targetMilestoneFeatures` will be
* the same normalized list.
*/
export interface RoadmapFeatureMoveResult {
/** The moved feature after `milestoneId` and `orderIndex` updates */
movedFeature: RoadmapFeature;
/** Canonical post-move features for the affected milestone scope */
affectedFeatures: RoadmapFeature[];
/** Canonical feature list for the source milestone after the move */
sourceMilestoneFeatures: RoadmapFeature[];
/** Canonical feature list for the destination milestone after the move */
targetMilestoneFeatures: RoadmapFeature[];
}
// ── Composite Read Models ───────────────────────────────────────────
/** Milestone with all of its ordered features loaded. */
export interface RoadmapMilestoneWithFeatures extends RoadmapMilestone {
/** Features belonging to this milestone */
features: RoadmapFeature[];
}
/** Full roadmap hierarchy loaded in roadmap → milestone → feature order. */
export interface RoadmapWithHierarchy extends Roadmap {
/** Ordered milestones with ordered features */
milestones: RoadmapMilestoneWithFeatures[];
}
// ── Export / Handoff Contracts ──────────────────────────────────────
/**
* Flat export payload for persistence, APIs, import/export, and sync jobs.
*
* This shape intentionally keeps entities separate so downstream persistence
* layers can upsert by table/collection without first denormalizing a nested
* hierarchy.
*/
export interface RoadmapExportBundle {
/** Roadmap being exported */
roadmap: Roadmap;
/** Ordered milestones for the roadmap */
milestones: RoadmapMilestone[];
/** Ordered features for the roadmap's milestones */
features: RoadmapFeature[];
}
/**
* Source metadata carried forward when a roadmap feature is converted into a
* task-planning input or other downstream artifact.
*/
export interface RoadmapFeatureSourceRef {
/** Source roadmap ID */
roadmapId: string;
/** Source milestone ID */
milestoneId: string;
/** Source feature ID */
featureId: string;
/** Human-readable roadmap title for prompt context */
roadmapTitle: string;
/** Human-readable milestone title for prompt context */
milestoneTitle: string;
/** Canonical milestone order at handoff time */
milestoneOrderIndex: number;
/** Canonical feature order at handoff time */
featureOrderIndex: number;
}
/**
* Handoff payload for converting a single roadmap feature into task planning
* flows without coupling the task system to roadmap persistence details.
*/
export interface RoadmapFeatureTaskPlanningHandoff {
/** Source lineage and ordering context */
source: RoadmapFeatureSourceRef;
/** Title to seed the downstream task or planning prompt */
title: string;
/** Optional description to seed the downstream task or planning prompt */
description?: string;
}
/** Source-preserving milestone payload used for mission conversion handoffs. */
export interface RoadmapMissionPlanningMilestoneHandoff {
/** Source roadmap milestone ID */
sourceMilestoneId: string;
/** Canonical milestone title */
title: string;
/** Optional milestone description */
description?: string;
/** Canonical milestone ordering within the roadmap */
orderIndex: number;
/** Ordered roadmap features that belong to this milestone */
features: Array<{
/** Source roadmap feature ID */
sourceFeatureId: string;
/** Canonical feature title */
title: string;
/** Optional feature description */
description?: string;
/** Canonical feature ordering within the milestone */
orderIndex: number;
}>;
}
/**
* Handoff payload for converting a standalone roadmap into mission planning
* structures while preserving source IDs and deterministic order.
*/
export interface RoadmapMissionPlanningHandoff {
/** Source roadmap ID */
sourceRoadmapId: string;
/** Canonical roadmap title */
title: string;
/** Optional roadmap description */
description?: string;
/** Ordered milestone breakdown captured at handoff time */
milestones: RoadmapMissionPlanningMilestoneHandoff[];
}