FN-5675: add mission branch strategy and planning breakdown controls

Add mission-level branch strategy support and expose planning breakdown controls across mission flows.

- Extend mission/core data models and store logic to track and persist base-branch strategy metadata.
- Update mission API routes and legacy adapters to read/write new mission branch strategy fields.
- Enhance MissionManager and PlanningModeModal UX to configure branch strategy and planning breakdown behavior.
- Add/adjust coverage across core, dashboard, and roadmap plugin tests for mission branch and planning flow behavior.
- Add a changeset and mission docs updates for the new mission branch strategy behavior.

Files changed:
 .changeset/fn-5675-mission-branch-strategy.md      |   9 +
 docs/missions.md                                   |  13 ++
 packages/core/src/__tests__/db-migrate.test.ts     |  12 +-
 .../src/__tests__/db-mission-base-branch.test.ts   |  14 +-
 packages/core/src/__tests__/db.test.ts             |  34 ++--
 packages/core/src/__tests__/goals-schema.test.ts   |   2 +-
 packages/core/src/__tests__/insight-store.test.ts  |  10 +-
 packages/core/src/__tests__/mission-store.test.ts  | 148 +++++++++++++-
 packages/core/src/__tests__/run-audit.test.ts      |   2 +-
 packages/core/src/__tests__/secrets-schema.test.ts |   6 +-
 .../core/src/__tests__/store-merge-queue.test.ts   |   2 +-
 packages/core/src/__tests__/task-documents.test.ts |   2 +-
 packages/core/src/db.ts                            |   9 +-
 packages/core/src/index.ts                         |   1 +
 packages/core/src/mission-store.ts                 |  64 ++++--
 packages/core/src/mission-types.ts                 |   9 +
 packages/dashboard/app/api/legacy.ts               |  23 ++-
 .../dashboard/app/components/MissionManager.tsx    | 226 +++++++++++++++++++--
 .../dashboard/app/components/PlanningModeModal.tsx |   9 +-
 .../components/__tests__/MissionManager.test.tsx   | 100 +++++++++
 .../PlanningModeModal.planning-flow.test.tsx       |  91 +++++++++
 packages/dashboard/app/components/mission-types.ts |   4 +
 .../dashboard/src/__tests__/mission-e2e.test.ts    |  49 ++++-
 packages/dashboard/src/mission-routes.ts           |  41 +++-
 .../src/store/__tests__/roadmap-store.test.ts      |   4 +-
 25 files changed, 808 insertions(+), 76 deletions(-)

Fusion-Task-Id: FN-5675

Fusion-Task-Lineage: cfd13c13-9eff-4b82-90c3-88505d51bfb2
This commit is contained in:
gsxdsm
2026-05-29 15:25:47 -07:00
parent dae4c0ea5d
commit 0605d13ab5
25 changed files with 808 additions and 76 deletions

View File

@@ -715,7 +715,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
db.close();
});
@@ -748,7 +748,7 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
db.close();
});
@@ -798,7 +798,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
db.close();
});
@@ -827,7 +827,7 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
db.close();
});
@@ -868,7 +868,7 @@ describe("schema migration", () => {
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
db.close();
});
@@ -902,7 +902,7 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
db.close();
});

View File

@@ -10,7 +10,7 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-db-mission-base-branch-"));
}
describe("mission baseBranch persistence", () => {
describe("mission branch strategy persistence", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
@@ -29,21 +29,29 @@ describe("mission baseBranch persistence", () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("creates, reads, and updates mission baseBranch", () => {
it("creates, reads, and updates mission baseBranch and branchStrategy", () => {
const created = store.createMission({
title: "Mission",
baseBranch: "develop",
branchStrategy: { mode: "existing", branchName: "release/shared" },
});
expect(created.baseBranch).toBe("develop");
expect(created.branchStrategy).toEqual({ mode: "existing", branchName: "release/shared" });
const fetched = store.getMission(created.id);
expect(fetched?.baseBranch).toBe("develop");
expect(fetched?.branchStrategy).toEqual({ mode: "existing", branchName: "release/shared" });
const updated = store.updateMission(created.id, { baseBranch: "release/1.0" });
const updated = store.updateMission(created.id, {
baseBranch: "release/1.0",
branchStrategy: { mode: "auto-per-task" },
});
expect(updated.baseBranch).toBe("release/1.0");
expect(updated.branchStrategy).toEqual({ mode: "auto-per-task" });
const refetched = store.getMission(created.id);
expect(refetched?.baseBranch).toBe("release/1.0");
expect(refetched?.branchStrategy).toEqual({ mode: "auto-per-task" });
});
});

View File

@@ -330,7 +330,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -389,7 +389,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1459,7 +1459,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1484,11 +1484,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
db.close();
});
@@ -1523,7 +1523,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1564,7 +1564,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1636,7 +1636,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1876,7 +1876,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1950,7 +1950,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1974,7 +1974,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2078,7 +2078,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2297,7 +2297,7 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(97);
expect(localDb.getSchemaVersion()).toBe(98);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2608,7 +2608,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2762,7 +2762,7 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(97);
expect(migrated.getSchemaVersion()).toBe(98);
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2808,7 +2808,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(97);
expect(migrated.getSchemaVersion()).toBe(98);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2835,7 +2835,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(97);
expect(fresh.getSchemaVersion()).toBe(98);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 92", () => {
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
});
});

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(97);
expect(db1.getSchemaVersion()).toBe(98);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(97);
expect(db3.getSchemaVersion()).toBe(98);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(97);
expect(db1.getSchemaVersion()).toBe(98);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(97);
expect(db2.getSchemaVersion()).toBe(98);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(97);
expect(db1.getSchemaVersion()).toBe(98);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -97,6 +97,35 @@ describe("MissionStore", () => {
expect(list[2].id).toBe(m1.id);
});
it("round-trips mission branchStrategy on create", () => {
const mission = store.createMission({
title: "Branch strategy",
branchStrategy: { mode: "custom-new", branchName: "feature/mission" },
});
const fetched = store.getMission(mission.id);
expect(fetched?.branchStrategy).toEqual({ mode: "custom-new", branchName: "feature/mission" });
});
it("updates mission branchStrategy", () => {
const mission = store.createMission({ title: "Original" });
const updated = store.updateMission(mission.id, {
branchStrategy: { mode: "auto-per-task" },
});
expect(updated.branchStrategy).toEqual({ mode: "auto-per-task" });
expect(store.getMission(mission.id)?.branchStrategy).toEqual({ mode: "auto-per-task" });
});
it("reads undefined branchStrategy for legacy and corrupt rows", () => {
const mission = store.createMission({ title: "Legacy row" });
db.prepare("UPDATE missions SET branchStrategy = NULL WHERE id = ?").run(mission.id);
expect(store.getMission(mission.id)?.branchStrategy).toBeUndefined();
db.prepare("UPDATE missions SET branchStrategy = ? WHERE id = ?").run("{not-json", mission.id);
expect(store.getMission(mission.id)?.branchStrategy).toBeUndefined();
});
it("updates a mission", async () => {
const mission = store.createMission({ title: "Original" });
await new Promise((r) => setTimeout(r, 5)); // Ensure timestamp difference
@@ -1901,6 +1930,62 @@ describe("MissionStore", () => {
expect(task?.baseBranch).toBe("release/1.0");
});
it("uses mission branchStrategy auto-per-task when branch options are omitted", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission", branchStrategy: { mode: "auto-per-task" } });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Original" });
const triaged = await msWithTs.triageFeature(feature.id);
const task = await ts.getTask(triaged.taskId!);
expect(task?.branchContext?.assignmentMode).toBe("per-task-derived");
});
it("uses mission branchStrategy existing branch when branch options are omitted", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({
title: "Mission",
branchStrategy: { mode: "existing", branchName: "release/shared" },
});
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Original" });
const triaged = await msWithTs.triageFeature(feature.id);
const task = await ts.getTask(triaged.taskId!);
expect(task?.branch).toBe("release/shared");
expect(task?.branchContext?.assignmentMode).toBe("shared");
});
it("explicit branch options override mission branchStrategy defaults", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission", branchStrategy: { mode: "auto-per-task" } });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Original" });
const triaged = await msWithTs.triageFeature(feature.id, undefined, undefined, {
branch: "hotfix/shared",
assignmentMode: "shared",
});
const task = await ts.getTask(triaged.taskId!);
expect(task?.branch).toBe("hotfix/shared");
expect(task?.branchContext?.assignmentMode).toBe("shared");
});
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
@@ -2080,6 +2165,52 @@ describe("MissionStore", () => {
expect(task?.baseBranch).toBe("develop");
});
it("triageSlice uses mission auto-per-task branchStrategy defaults", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({
title: "Mission",
branchStrategy: { mode: "auto-per-task" },
});
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const triaged = await msWithTs.triageSlice(slice.id);
const task = await ts.getTask(triaged[0].taskId!);
expect(triaged[0].id).toBe(f1.id);
expect(task?.branchContext?.assignmentMode).toBe("per-task-derived");
});
it("triageSlice respects explicit branch options over mission strategy defaults", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({
title: "Mission",
baseBranch: "develop",
branchStrategy: { mode: "auto-per-task" },
});
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
msWithTs.addFeature(slice.id, { title: "Feature 1" });
const triaged = await msWithTs.triageSlice(slice.id, {
branch: "feature/manual",
assignmentMode: "shared",
baseBranch: "release",
});
const task = await ts.getTask(triaged[0].taskId!);
expect(task?.branch).toBe("feature/manual");
expect(task?.baseBranch).toBe("release");
expect(task?.branchContext?.assignmentMode).toBe("shared");
});
it("triageSlice does not inject baseBranch when mission has none", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
@@ -2159,6 +2290,21 @@ describe("MissionStore", () => {
expect(task2!.missionId).toBe(mission.id);
});
it("auto-triage uses mission branchStrategy defaults", async () => {
const { ts, ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission", branchStrategy: { mode: "auto-per-task" } });
ms.updateMission(mission.id, { autoAdvance: true });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature 1" });
await ms.activateSlice(slice.id);
const task = await ts.getTask(ms.getFeature(feature.id)!.taskId!);
expect(task?.branchContext?.assignmentMode).toBe("per-task-derived");
});
it("does not triage features when autoAdvance is false", async () => {
const { ms } = await createStoreWithTaskStore();
@@ -3036,7 +3182,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
});
it("mission_features table has loop state columns", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
});
});
});

View File

@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
const version = db
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(version.value).toBe("97");
expect(version.value).toBe("98");
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
const version = db
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(version.value).toBe("97");
expect(version.value).toBe("98");
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(projectVersion.value).toBe("97");
expect(projectVersion.value).toBe("98");
expect(centralVersion.value).toBe("13");
} finally {
projectDb.close();

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(97);
expect(store.getDatabase().getSchemaVersion()).toBe(98);
});
it("migrates a legacy v88 database and preserves task rows", async () => {

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(97);
expect(db.getSchemaVersion()).toBe(98);
const index = db
.prepare(

View File

@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 97;
const SCHEMA_VERSION = 98;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -758,6 +758,7 @@ CREATE TABLE IF NOT EXISTS missions (
status TEXT NOT NULL,
interviewState TEXT NOT NULL,
baseBranch TEXT,
branchStrategy TEXT,
autoAdvance INTEGER DEFAULT 0,
autoMerge INTEGER,
createdAt TEXT NOT NULL,
@@ -3719,6 +3720,12 @@ export class Database {
});
}
if (version < 98) {
this.applyMigration(98, () => {
this.addColumnIfMissing("missions", "branchStrategy", "TEXT");
});
}
}
/**

View File

@@ -675,6 +675,7 @@ export type {
MissionEventType,
AutopilotStatus,
Mission,
MissionBranchStrategy,
Milestone,
Slice,
MissionFeature,

View File

@@ -16,6 +16,7 @@ import type { Database } from "./db.js";
import { fromJson, toJson, toJsonNullable } from "./db.js";
import type {
Mission,
MissionBranchStrategy,
Milestone,
Slice,
MissionFeature,
@@ -63,6 +64,22 @@ import { reconcileDeterministicDuplicate, runDeterministicDuplicateGuard } from
*/
const DEFAULT_IMPLEMENTATION_RETRY_BUDGET = 3;
function missionBranchStrategyDefaults(strategy?: MissionBranchStrategy): {
branch?: string;
assignmentMode: "shared" | "per-task-derived";
} {
if (!strategy) {
return { assignmentMode: "shared" };
}
if (strategy.mode === "auto-per-task") {
return { assignmentMode: "per-task-derived" };
}
if ((strategy.mode === "existing" || strategy.mode === "custom-new") && strategy.branchName?.trim()) {
return { branch: strategy.branchName.trim(), assignmentMode: "shared" };
}
return { assignmentMode: "shared" };
}
export function deriveMilestoneAcceptanceCriteriaFromFeatures(features: MissionFeature[]): string | undefined {
const lines = features
.map((feature) => {
@@ -189,6 +206,7 @@ interface MissionRow {
status: string;
interviewState: string;
baseBranch: string | null;
branchStrategy: string | null;
autoMerge: number | null;
autoAdvance: number;
autopilotEnabled: number;
@@ -356,6 +374,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* Convert a database row to a Mission object.
*/
private rowToMission(row: MissionRow): Mission {
let branchStrategy: MissionBranchStrategy | undefined;
if (row.branchStrategy) {
try {
branchStrategy = JSON.parse(row.branchStrategy) as MissionBranchStrategy;
} catch {
branchStrategy = undefined;
}
}
return {
id: row.id,
title: row.title,
@@ -363,6 +390,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status: row.status as MissionStatus,
interviewState: row.interviewState as InterviewState,
baseBranch: row.baseBranch || undefined,
branchStrategy,
autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge),
autoAdvance: Boolean(row.autoAdvance),
autopilotEnabled: Boolean(row.autopilotEnabled),
@@ -555,6 +583,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status: "planning",
interviewState: "not_started",
baseBranch: input.baseBranch,
branchStrategy: input.branchStrategy,
autoMerge: input.autoMerge,
autoAdvance: false,
autopilotEnabled: input.autopilotEnabled ?? false,
@@ -564,8 +593,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
};
this.db.prepare(`
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
mission.id,
mission.title,
@@ -573,6 +602,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
mission.status,
mission.interviewState,
mission.baseBranch ?? null,
mission.branchStrategy ? JSON.stringify(mission.branchStrategy) : null,
mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0),
mission.autoAdvance ? 1 : 0,
mission.autopilotEnabled ? 1 : 0,
@@ -1126,6 +1156,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status = ?,
interviewState = ?,
baseBranch = ?,
branchStrategy = ?,
autoMerge = ?,
autoAdvance = ?,
autopilotEnabled = ?,
@@ -1139,6 +1170,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
updated.status,
updated.interviewState,
updated.baseBranch ?? null,
updated.branchStrategy ? JSON.stringify(updated.branchStrategy) : null,
updated.autoMerge === undefined ? null : (updated.autoMerge ? 1 : 0),
updated.autoAdvance ? 1 : 0,
updated.autopilotEnabled ? 1 : 0,
@@ -3305,7 +3337,10 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const milestone = slice ? this.getMilestone(slice.milestoneId) : undefined;
const missionId = milestone?.missionId;
const mission = missionId ? this.getMission(missionId) : undefined;
const strategyDefaults = missionBranchStrategyDefaults(mission?.branchStrategy);
const resolvedBaseBranch = branchOptions?.baseBranch ?? mission?.baseBranch;
const resolvedBranch = branchOptions?.branch ?? strategyDefaults.branch;
const resolvedAssignmentMode = branchOptions?.assignmentMode ?? strategyDefaults.assignmentMode;
const lockScope = missionId ? `mission:${missionId}` : `mission-store:${this.taskStore.getRootDir()}`;
const guard = await runDeterministicDuplicateGuard(this.taskStore, {
@@ -3321,14 +3356,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const createdTask = await this.taskStore.createTask({
title: taskTitle || feature.title,
description,
branch: branchOptions?.branch,
branch: resolvedBranch,
baseBranch: resolvedBaseBranch,
...(missionId
? {
branchContext: {
groupId: `mission:${missionId}`,
source: "mission" as const,
assignmentMode: branchOptions?.assignmentMode ?? "shared",
assignmentMode: resolvedAssignmentMode,
inheritedBaseBranch: resolvedBaseBranch,
},
}
@@ -3389,17 +3424,21 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const definedFeatures = features.filter((f) => f.status === "defined");
const milestone = this.getMilestone(slice.milestoneId);
const mission = milestone ? this.getMission(milestone.missionId) : undefined;
const strategyDefaults = missionBranchStrategyDefaults(mission?.branchStrategy);
const resolvedBaseBranch = branchOptions?.baseBranch ?? mission?.baseBranch;
const resolvedAssignmentMode = branchOptions?.assignmentMode ?? strategyDefaults.assignmentMode;
const resolvedBranch = branchOptions?.branch ?? strategyDefaults.branch;
const triaged: MissionFeature[] = [];
for (const feature of definedFeatures) {
const branch = branchOptions?.assignmentMode === "per-task-derived"
? (branchOptions?.branch ? `${branchOptions.branch}/${feature.id.toLowerCase()}` : undefined)
: branchOptions?.branch;
const strategyBranch = resolvedAssignmentMode === "per-task-derived"
? (resolvedBranch ? `${resolvedBranch}/${feature.id.toLowerCase()}` : undefined)
: resolvedBranch;
const updated = await this.triageFeature(feature.id, undefined, undefined, {
...branchOptions,
branch,
branch: strategyBranch,
baseBranch: resolvedBaseBranch,
assignmentMode: resolvedAssignmentMode,
...branchOptions,
});
triaged.push(updated);
}
@@ -3589,13 +3628,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
let applied = 0;
for (const mission of snapshot.payload.missions) {
this.db.prepare(`INSERT INTO missions (id, title, description, status, interviewState, autoMerge, autoAdvance, autopilotEnabled, autopilotState, lastAutopilotActivityAt, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
this.db.prepare(`INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, autoMerge, autoAdvance, autopilotEnabled, autopilotState, lastAutopilotActivityAt, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title=excluded.title, description=excluded.description, status=excluded.status, interviewState=excluded.interviewState,
autoMerge=excluded.autoMerge, autoAdvance=excluded.autoAdvance, autopilotEnabled=excluded.autopilotEnabled, autopilotState=excluded.autopilotState,
baseBranch=excluded.baseBranch, branchStrategy=excluded.branchStrategy, autoMerge=excluded.autoMerge, autoAdvance=excluded.autoAdvance, autopilotEnabled=excluded.autopilotEnabled, autopilotState=excluded.autopilotState,
lastAutopilotActivityAt=excluded.lastAutopilotActivityAt, updatedAt=excluded.updatedAt`).run(
mission.id, mission.title, mission.description ?? null, mission.status, mission.interviewState,
mission.baseBranch ?? null, mission.branchStrategy ? JSON.stringify(mission.branchStrategy) : null,
mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0), mission.autoAdvance ? 1 : 0,
mission.autopilotEnabled ? 1 : 0, mission.autopilotState, mission.lastAutopilotActivityAt ?? null, mission.createdAt, mission.updatedAt,
);

View File

@@ -113,6 +113,11 @@ export interface MissionHealth {
* A Mission represents a high-level objective or project.
* Missions contain milestones that break down the work into phases.
*/
export type MissionBranchStrategy = {
mode: "project-default" | "existing" | "custom-new" | "auto-per-task";
branchName?: string;
};
export interface Mission {
/** Unique identifier (e.g., "M-LZ7DN0-A2B5") */
id: string;
@@ -124,6 +129,8 @@ export interface Mission {
status: MissionStatus;
/** Optional integration base branch inherited by triaged feature tasks */
baseBranch?: string;
/** Mission triage branch strategy: auto-per-task => assignmentMode "per-task-derived"; existing/custom-new => shared branchName; project-default/absent => shared default behavior. */
branchStrategy?: MissionBranchStrategy;
/** State of the AI specification interview process */
interviewState: InterviewState;
/**
@@ -375,6 +382,8 @@ export interface MissionCreateInput {
description?: string;
/** Optional integration base branch for tasks created from this mission */
baseBranch?: string;
/** Optional branch strategy applied as the default for mission triage operations. */
branchStrategy?: MissionBranchStrategy;
/** Optional mission-level auto-merge override for linked task branches. */
autoMerge?: boolean;
}

View File

@@ -3426,10 +3426,25 @@ export function createTasksFromPlanning(
planningSessionId: string,
subtasks: PlanningSubtaskDraft[],
projectId?: string,
options?: {
branchSelection?: {
mode: "project-default" | "auto-new" | "existing" | "custom-new";
branchName?: string;
baseBranch?: string;
};
branchAssignment?: {
mode: "shared" | "per-task-derived";
};
},
): Promise<{ tasks: Task[] }> {
return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), {
method: "POST",
body: JSON.stringify({ planningSessionId, subtasks }),
body: JSON.stringify({
planningSessionId,
subtasks,
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}),
}),
});
}
@@ -6803,6 +6818,10 @@ export interface Mission {
title: string;
description?: string;
baseBranch?: string;
branchStrategy?: {
mode: "project-default" | "existing" | "custom-new" | "auto-per-task";
branchName?: string;
};
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
@@ -6890,7 +6909,7 @@ export function fetchMissions(projectId?: string): Promise<MissionWithSummary[]>
}
/** Create a new mission */
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; baseBranch?: string }, projectId?: string): Promise<Mission> {
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId("/missions", projectId), {
method: "POST",
body: JSON.stringify(input),

View File

@@ -250,12 +250,20 @@ function formatValidationState(state?: string): string {
}
// Form types
type MissionBranchStrategyMode = "project-default" | "existing" | "custom-new" | "auto-per-task";
interface MissionBranchStrategy {
mode: MissionBranchStrategyMode;
branchName?: string;
}
interface MissionFormData {
title: string;
description: string;
status: MissionStatus;
autopilotEnabled: boolean;
baseBranch: string;
branchStrategy: MissionBranchStrategy;
}
interface MilestoneFormData {
@@ -285,6 +293,9 @@ const EMPTY_MISSION_FORM: MissionFormData = {
status: "planning",
autopilotEnabled: false,
baseBranch: "",
branchStrategy: {
mode: "project-default",
},
};
const EMPTY_MILESTONE_FORM: MilestoneFormData = {
@@ -308,6 +319,53 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
status: "defined",
};
function normalizeMissionBranchStrategy(strategy?: Mission["branchStrategy"]): MissionBranchStrategy {
if (!strategy) {
return { mode: "project-default" };
}
if (strategy.mode === "existing" || strategy.mode === "custom-new") {
return {
mode: strategy.mode,
branchName: strategy.branchName ?? "",
};
}
if (strategy.mode === "auto-per-task") {
return { mode: "auto-per-task" };
}
return { mode: "project-default" };
}
function toMissionBranchOptions(mission?: Mission): Parameters<typeof triageFeature>[4] | undefined {
if (!mission?.baseBranch && !mission?.branchStrategy) {
return undefined;
}
const strategy = mission.branchStrategy;
const branchSelection: NonNullable<Parameters<typeof triageFeature>[4]>["branchSelection"] = {
mode: "project-default",
...(mission.baseBranch ? { baseBranch: mission.baseBranch } : {}),
};
const options: NonNullable<Parameters<typeof triageFeature>[4]> = { branchSelection };
if (strategy?.mode === "existing" || strategy?.mode === "custom-new") {
const branchName = strategy.branchName?.trim();
if (branchName) {
options.branchSelection = {
mode: strategy.mode,
branchName,
...(mission.baseBranch ? { baseBranch: mission.baseBranch } : {}),
};
}
} else if (strategy?.mode === "auto-per-task") {
options.branchAssignment = { mode: "per-task-derived" };
}
return options;
}
type MissionHealthState = "healthy" | "warning" | "error";
const HOUR_MS = 60 * 60 * 1000;
@@ -1420,6 +1478,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
status: mission.status,
autopilotEnabled: mission.autopilotEnabled ?? false,
baseBranch: mission.baseBranch ?? "",
branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy),
});
}, []);
@@ -1435,6 +1494,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
return;
}
const branchNameRequired =
missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new";
const branchName = missionForm.branchStrategy.branchName?.trim() ?? "";
if (branchNameRequired && !branchName) {
addToast("Branch name is required for selected branch strategy", "error");
return;
}
const branchStrategy: Mission["branchStrategy"] =
missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new"
? { mode: missionForm.branchStrategy.mode, branchName }
: { mode: missionForm.branchStrategy.mode };
try {
setSaving(true);
if (isCreatingMission) {
@@ -1442,6 +1514,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
title: missionForm.title.trim(),
description: missionForm.description.trim() || undefined,
autopilotEnabled: missionForm.autopilotEnabled,
baseBranch: missionForm.baseBranch.trim() || undefined,
branchStrategy,
}, projectId);
addToast("Mission created", "success");
} else if (editingMissionId) {
@@ -1453,6 +1527,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
status: missionForm.status,
autopilotEnabled: missionForm.autopilotEnabled,
baseBranch: missionForm.baseBranch.trim() || "",
branchStrategy,
};
if (missionForm.autopilotEnabled) {
updates.autoAdvance = true;
@@ -1778,14 +1853,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const handleTriageFeature = useCallback(async (featureId: string) => {
try {
setSaving(true);
await triageFeature(featureId, undefined, undefined, projectId, selectedMission?.baseBranch
? {
branchSelection: {
mode: "project-default",
baseBranch: selectedMission.baseBranch,
},
}
: undefined);
await triageFeature(featureId, undefined, undefined, projectId, toMissionBranchOptions(selectedMission ?? undefined));
addToast("Feature triaged — task created", "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
@@ -1825,14 +1893,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const handleTriageAllSliceFeatures = useCallback(async (sliceId: string) => {
try {
setSaving(true);
const result = await triageAllSliceFeatures(sliceId, projectId, selectedMission?.baseBranch
? {
branchSelection: {
mode: "project-default",
baseBranch: selectedMission.baseBranch,
},
}
: undefined);
const result = await triageAllSliceFeatures(sliceId, projectId, toMissionBranchOptions(selectedMission ?? undefined));
addToast(`Triaged ${result.count} feature${result.count !== 1 ? "s" : ""}`, "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
@@ -2460,6 +2521,47 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-label="Mission target branch"
/>
</label>
<label>
Branch strategy
<select
value={missionForm.branchStrategy.mode}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
mode: e.target.value as MissionBranchStrategyMode,
branchName: missionForm.branchStrategy.branchName,
},
})
}
aria-label="Mission branch strategy"
>
<option value="project-default">Use project/default branch</option>
<option value="auto-per-task">Auto-name a branch per task (from details)</option>
<option value="existing">Use existing branch</option>
<option value="custom-new">Create custom branch</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
Branch name
<input
type="text"
placeholder="e.g. feature/mission-work"
value={missionForm.branchStrategy.branchName ?? ""}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
...missionForm.branchStrategy,
branchName: e.target.value,
},
})
}
aria-label="Mission branch name"
/>
</label>
)}
<div className="mission-form-card__row">
<select
value={missionForm.status}
@@ -4056,6 +4158,57 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
onChange={(e) => setMissionForm({ ...missionForm, description: e.target.value })}
rows={2}
/>
<label>
Target branch
<input
type="text"
placeholder="e.g. main"
value={missionForm.baseBranch}
onChange={(e) => setMissionForm({ ...missionForm, baseBranch: e.target.value })}
aria-label="Mission target branch"
/>
</label>
<label>
Branch strategy
<select
value={missionForm.branchStrategy.mode}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
mode: e.target.value as MissionBranchStrategyMode,
branchName: missionForm.branchStrategy.branchName,
},
})
}
aria-label="Mission branch strategy"
>
<option value="project-default">Use project/default branch</option>
<option value="auto-per-task">Auto-name a branch per task (from details)</option>
<option value="existing">Use existing branch</option>
<option value="custom-new">Create custom branch</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
Branch name
<input
type="text"
placeholder="e.g. feature/mission-work"
value={missionForm.branchStrategy.branchName ?? ""}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
...missionForm.branchStrategy,
branchName: e.target.value,
},
})
}
aria-label="Mission branch name"
/>
</label>
)}
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMission} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
@@ -4121,6 +4274,47 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-label="Mission target branch"
/>
</label>
<label>
Branch strategy
<select
value={missionForm.branchStrategy.mode}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
mode: e.target.value as MissionBranchStrategyMode,
branchName: missionForm.branchStrategy.branchName,
},
})
}
aria-label="Mission branch strategy"
>
<option value="project-default">Use project/default branch</option>
<option value="auto-per-task">Auto-name a branch per task (from details)</option>
<option value="existing">Use existing branch</option>
<option value="custom-new">Create custom branch</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
Branch name
<input
type="text"
placeholder="e.g. feature/mission-work"
value={missionForm.branchStrategy.branchName ?? ""}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
...missionForm.branchStrategy,
branchName: e.target.value,
},
})
}
aria-label="Mission branch name"
/>
</label>
)}
<div className="mission-form-card__row">
<select
value={missionForm.status}

View File

@@ -1643,6 +1643,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
})),
),
projectId,
{
branchSelection: {
mode: branchMode,
...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}),
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
},
},
);
onTasksCreated(result.tasks);
// Server cleans up the planning session after task creation; mirror that
@@ -1674,7 +1681,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} finally {
setIsCreatingFromBreakdown(false);
}
}, [broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
}, [baseBranch, branchMode, branchName, broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
const handleBack = useCallback(async () => {
if (view.type !== "question" || responseHistory.length === 0) {

View File

@@ -4703,6 +4703,106 @@ describe("MissionManager", () => {
});
});
describe("mission branch strategy controls", () => {
it("renders branch strategy selector and toggles branch-name input", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitForDetailLoaded();
fireEvent.click(screen.getByLabelText("Edit mission"));
const strategySelect = await screen.findByLabelText("Mission branch strategy");
expect(strategySelect).toBeInTheDocument();
expect(screen.queryByLabelText("Mission branch name")).toBeNull();
fireEvent.change(strategySelect, { target: { value: "existing" } });
expect(await screen.findByLabelText("Mission branch name")).toBeInTheDocument();
});
it("sends branch strategy and base branch on mission update", async () => {
const fetchSpy = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/missions/M-001") && init?.method === "PATCH") {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
return createFetchMock()(input, init);
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitForDetailLoaded();
fireEvent.click(screen.getByLabelText("Edit mission"));
fireEvent.change(screen.getByLabelText("Mission target branch"), { target: { value: "release/2026" } });
fireEvent.change(screen.getByLabelText("Mission branch strategy"), { target: { value: "custom-new" } });
fireEvent.change(await screen.findByLabelText("Mission branch name"), { target: { value: "feature/mission-custom" } });
fireEvent.click(screen.getByRole("button", { name: "Update" }));
await waitFor(() => {
const patchCall = fetchSpy.mock.calls.find(([input, init]) =>
String(input).includes("/api/missions/M-001") && init?.method === "PATCH",
);
expect(patchCall).toBeTruthy();
const body = JSON.parse(String(patchCall?.[1]?.body ?? "{}"));
expect(body.baseBranch).toBe("release/2026");
expect(body.branchStrategy).toEqual({ mode: "custom-new", branchName: "feature/mission-custom" });
});
});
it("maps mission branch strategy into triage branch options", async () => {
const triageMission = {
...mockMissionDetail,
baseBranch: "main",
branchStrategy: { mode: "auto-per-task" as const },
};
globalThis.fetch = ((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/missions/M-001") && !url.includes("/milestones") && !url.includes("/events") && !url.includes("/health")) {
return Promise.resolve(mockApiResponse(triageMission));
}
return createFetchMock()(input);
}) as unknown as typeof fetch;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitForDetailLoaded();
mockPreviewEnrichedDescription.mockRejectedValueOnce(new Error("skip preview"));
fireEvent.click(screen.getByTitle("Triage — create task"));
await waitFor(() => {
expect(mockTriageFeature).toHaveBeenCalled();
});
expect(mockTriageFeature).toHaveBeenCalledWith(
"F-001",
undefined,
undefined,
undefined,
{
branchSelection: { mode: "project-default", baseBranch: "main" },
branchAssignment: { mode: "per-task-derived" },
},
);
});
});
describe("MissionManager tokenized sizing regression", () => {
it("does not retain targeted hardcoded px literals in MissionManager selectors", async () => {
const css = await loadAllAppCssBaseOnly();

View File

@@ -1184,6 +1184,93 @@ describe("PlanningModeModal", () => {
expect(screen.getByRole("textbox", { name: "Branch name" })).toBeDefined();
});
it("forwards selected branchSelection when creating tasks from breakdown", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-branch-breakdown",
description: "Recovered summary for branch breakdown",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-branch-breakdown",
type: "planning",
status: "complete",
title: "Resume-branch-breakdown",
inputPayload: JSON.stringify({ initialPlan: "Recover and create breakdown with branch controls" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockStartPlanningBreakdown.mockResolvedValueOnce({
sessionId: "session-branch-breakdown",
subtasks: [
{
id: "subtask-1",
title: "First subtask",
description: "First description",
suggestedSize: "M",
dependsOn: [],
},
],
});
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-branch-breakdown"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
});
const branchStrategy = screen.getByRole("combobox", { name: "Branch strategy" }) as HTMLSelectElement;
fireEvent.change(branchStrategy, { target: { value: "existing" } });
fireEvent.change(screen.getByRole("textbox", { name: "Branch name" }), {
target: { value: "feat/planning-branch" },
});
fireEvent.change(screen.getByRole("textbox", { name: "Merge target / base branch (optional)" }), {
target: { value: "develop" },
});
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
await waitFor(() => {
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
"session-branch-breakdown",
[{ id: "subtask-1" }],
undefined,
{
branchSelection: {
mode: "existing",
branchName: "feat/planning-branch",
baseBranch: "develop",
},
},
);
});
});
it("preserves per-subtask priority selections when creating tasks from breakdown", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-to-breakdown-priority",
@@ -1253,6 +1340,7 @@ describe("PlanningModeModal", () => {
"session-breakdown-priority",
[{ id: "subtask-1", priority: "urgent" }],
undefined,
{ branchSelection: { mode: "project-default" } },
);
});
});
@@ -1342,6 +1430,7 @@ describe("PlanningModeModal", () => {
{ id: "subtask-2", description: "Edited second description" },
],
undefined,
{ branchSelection: { mode: "project-default" } },
);
});
});
@@ -1429,6 +1518,7 @@ describe("PlanningModeModal", () => {
},
],
undefined,
{ branchSelection: { mode: "project-default" } },
);
});
});
@@ -1508,6 +1598,7 @@ describe("PlanningModeModal", () => {
"session-breakdown-remove-subtask",
[{ id: "subtask-1" }],
undefined,
{ branchSelection: { mode: "project-default" } },
);
});
});

View File

@@ -51,6 +51,10 @@ export interface Mission {
title: string;
description?: string;
baseBranch?: string;
branchStrategy?: {
mode: "project-default" | "existing" | "custom-new" | "auto-per-task";
branchName?: string;
};
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;

View File

@@ -66,11 +66,13 @@ function createMockMissionStore() {
const generateAssertionId = () => `CA-MOCK${(assertionCounter++).toString(36).toUpperCase()}-TST`;
return {
createMission: vi.fn((input: { title: string; description?: string }) => {
createMission: vi.fn((input: { title: string; description?: string; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }) => {
const mission: Mission = {
id: generateMissionId(),
title: input.title,
description: input.description,
baseBranch: input.baseBranch,
branchStrategy: input.branchStrategy,
status: "planning",
interviewState: "not_started",
autoAdvance: false,
@@ -747,6 +749,34 @@ describe("Mission API", () => {
expect(res.body.baseBranch).toBe("develop");
});
it("should persist branchStrategy when provided during creation", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions",
JSON.stringify({ title: "Mission", branchStrategy: { mode: "custom-new", branchName: "feature/mission" } }),
{ "content-type": "application/json" }
);
expect(res.status).toBe(201);
expect(res.body.branchStrategy).toEqual({ mode: "custom-new", branchName: "feature/mission" });
});
it("rejects invalid branchStrategy mode", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions",
JSON.stringify({ title: "Mission", branchStrategy: { mode: "bad-mode" } }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain("branchStrategy.mode");
});
it("should persist auto-advance when provided during creation", async () => {
const { app, missionStore } = buildApp();
@@ -1035,6 +1065,23 @@ describe("Mission API", () => {
expect(updated?.baseBranch).toBe("release/1.0");
});
it("should update mission branchStrategy", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Original Title" });
const res = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ branchStrategy: { mode: "auto-per-task" } }),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.branchStrategy).toEqual({ mode: "auto-per-task" });
expect(missionStore.getMission(mission.id)?.branchStrategy).toEqual({ mode: "auto-per-task" });
});
it("should update mission title with generated-format ID", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Original Title" });

View File

@@ -18,6 +18,7 @@ import { TaskStore, resolvePlanningSettingsModel } from "@fusion/core";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
import type {
Mission,
MissionBranchStrategy,
Milestone,
Slice,
MissionFeature,
@@ -134,6 +135,38 @@ function validateBoolean(value: unknown, fieldName: string): boolean {
return value;
}
function validateMissionBranchStrategy(value: unknown): MissionBranchStrategy | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "object") {
throw new Error("branchStrategy must be an object");
}
const input = value as Record<string, unknown>;
const mode = input.mode;
if (
mode !== "project-default" &&
mode !== "existing" &&
mode !== "custom-new" &&
mode !== "auto-per-task"
) {
throw new Error("branchStrategy.mode must be one of: project-default, existing, custom-new, auto-per-task");
}
const branchName = input.branchName;
if (branchName !== undefined && typeof branchName !== "string") {
throw new Error("branchStrategy.branchName must be a string when provided");
}
const trimmedBranchName = branchName?.trim();
if ((mode === "existing" || mode === "custom-new") && !trimmedBranchName) {
throw new Error("branchStrategy.branchName is required for existing/custom-new");
}
if (mode === "project-default" || mode === "auto-per-task") {
return { mode };
}
return {
mode,
branchName: trimmedBranchName,
};
}
function validateOrderedIds(body: unknown): string[] {
if (!body || typeof body !== "object") {
throw new Error("Request body must contain orderedIds array");
@@ -299,7 +332,7 @@ export function createMissionRouter(
router.post(
"/",
catchTypedHandler(async (req, res) => {
const { title, description, autoAdvance, autopilotEnabled, baseBranch } = req.body;
const { title, description, autoAdvance, autopilotEnabled, baseBranch, branchStrategy } = req.body;
const validatedTitle = validateTitle(title);
const validatedDescription = validateDescription(description);
@@ -308,6 +341,7 @@ export function createMissionRouter(
title: validatedTitle,
description: validatedDescription,
baseBranch: validateDescription(baseBranch),
branchStrategy: validateMissionBranchStrategy(branchStrategy),
};
const mission = missionStore.createMission(input);
@@ -880,7 +914,7 @@ export function createMissionRouter(
"/:missionId",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
const { title, description, status, autoAdvance, autopilotEnabled, baseBranch } = req.body;
const { title, description, status, autoAdvance, autopilotEnabled, baseBranch, branchStrategy } = req.body;
if (!validateMissionId(missionId)) {
throw badRequest("Invalid mission ID format");
@@ -906,6 +940,9 @@ export function createMissionRouter(
if (baseBranch !== undefined) {
updates.baseBranch = validateDescription(baseBranch);
}
if (branchStrategy !== undefined) {
updates.branchStrategy = validateMissionBranchStrategy(branchStrategy);
}
if (Object.keys(updates).length === 0) {
throw badRequest("No valid fields to update");