Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/core/src/__tests__/mission-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,24 @@ describe("MissionStore", () => {
expect(store.getMission(mission.id)?.branchStrategy).toBeUndefined();
});

it("round-trips mission taskPrefix on create", () => {
const mission = store.createMission({ title: "Prefixed", taskPrefix: "ERR" });
expect(store.getMission(mission.id)?.taskPrefix).toBe("ERR");
});

it("updates mission taskPrefix", () => {
const mission = store.createMission({ title: "Original" });
const updated = store.updateMission(mission.id, { taskPrefix: "BUG" });
expect(updated.taskPrefix).toBe("BUG");
expect(store.getMission(mission.id)?.taskPrefix).toBe("BUG");
});

it("reads undefined taskPrefix for legacy rows", () => {
const mission = store.createMission({ title: "Legacy row" });
db.prepare("UPDATE missions SET taskPrefix = NULL WHERE id = ?").run(mission.id);
expect(store.getMission(mission.id)?.taskPrefix).toBeUndefined();
});

// FNXC:CoreTests 2026-06-25-21:50: real-sleep removed (FN-5048); advance the
// fake clock between create and update so updatedAt > createdAt deterministically.
it("updates a mission", () => {
Expand Down Expand Up @@ -2351,6 +2369,20 @@ describe("MissionStore", () => {
expect(task!.missionId).toBe(mission.id);
});

it("mints the triaged task id with the mission's taskPrefix", 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", taskPrefix: "ERR" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Feature" });

const triaged = await msWithTs.triageFeature(feature.id);
expect(triaged.taskId).toMatch(/^ERR-\d+$/);
});

/*
FNXC:MissionWorkflows 2026-06-25-00:00:
MissionStore tests pin the storage invariant: workflowId is applied only to newly created mission-triage tasks, while default inheritance and duplicate-task reuse keep their existing workflow behavior.
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export function isFts5CorruptionError(error: unknown): boolean {

// ── Schema Definition ────────────────────────────────────────────────

const SCHEMA_VERSION = 139;
const SCHEMA_VERSION = 140;

const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
Expand Down Expand Up @@ -956,6 +956,7 @@ CREATE TABLE IF NOT EXISTS missions (
interviewState TEXT NOT NULL,
baseBranch TEXT,
branchStrategy TEXT,
taskPrefix TEXT,
autoAdvance INTEGER DEFAULT 0,
autoMerge INTEGER,
createdAt TEXT NOT NULL,
Expand Down Expand Up @@ -5610,6 +5611,14 @@ export class Database {
});
}

if (version < 140) {
// Per-mission ticket id prefix (e.g. "ERR") for triaged tasks; NULL means
// inherit the project-wide taskPrefix setting. Additive-only, no backfill.
this.applyMigration(140, () => {
this.addColumnIfMissing("missions", "taskPrefix", "TEXT");
});
}

}

/**
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/mission-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ interface MissionRow {
interviewState: string;
baseBranch: string | null;
branchStrategy: string | null;
taskPrefix: string | null;
autoMerge: number | null;
autoAdvance: number;
autopilotEnabled: number;
Expand Down Expand Up @@ -435,6 +436,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
interviewState: row.interviewState as InterviewState,
baseBranch: row.baseBranch || undefined,
branchStrategy,
taskPrefix: row.taskPrefix || undefined,
autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge),
autoAdvance: Boolean(row.autoAdvance),
autopilotEnabled: Boolean(row.autopilotEnabled),
Expand Down Expand Up @@ -661,6 +663,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
interviewState: "not_started",
baseBranch: input.baseBranch,
branchStrategy: input.branchStrategy,
taskPrefix: input.taskPrefix,
autoMerge: input.autoMerge,
autoAdvance: false,
autopilotEnabled: false,
Expand All @@ -670,8 +673,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
};

this.db.prepare(`
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, taskPrefix, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
mission.id,
mission.title,
Expand All @@ -680,6 +683,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
mission.interviewState,
mission.baseBranch ?? null,
mission.branchStrategy ? JSON.stringify(mission.branchStrategy) : null,
mission.taskPrefix ?? null,
mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0),
mission.autoAdvance ? 1 : 0,
mission.autopilotEnabled ? 1 : 0,
Expand Down Expand Up @@ -1276,6 +1280,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
interviewState = ?,
baseBranch = ?,
branchStrategy = ?,
taskPrefix = ?,
autoMerge = ?,
autoAdvance = ?,
autopilotEnabled = ?,
Expand All @@ -1290,6 +1295,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
updated.interviewState,
updated.baseBranch ?? null,
updated.branchStrategy ? JSON.stringify(updated.branchStrategy) : null,
updated.taskPrefix ?? null,
updated.autoMerge === undefined ? null : (updated.autoMerge ? 1 : 0),
updated.autoAdvance ? 1 : 0,
updated.autopilotEnabled ? 1 : 0,
Expand Down Expand Up @@ -3978,6 +3984,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description,
branch: branchAssignment.workingBranch,
baseBranch: resolvedBaseBranch,
taskPrefix: mission?.taskPrefix,
...(missionId
? {
branchContext: {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/mission-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ export interface Mission {
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;
/** Per-mission ticket id prefix for triaged tasks (e.g. "ERR"). Absent => inherit the project-wide taskPrefix setting. */
taskPrefix?: string;
/** State of the AI specification interview process */
interviewState: InterviewState;
/**
Expand Down Expand Up @@ -392,6 +394,8 @@ export interface MissionCreateInput {
baseBranch?: string;
/** Optional branch strategy applied as the default for mission triage operations. */
branchStrategy?: MissionBranchStrategy;
/** Optional per-mission ticket id prefix for triaged tasks (e.g. "ERR"); inherits the project setting when absent. */
taskPrefix?: string;
/** Optional mission-level auto-merge override for linked task branches. */
autoMerge?: boolean;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4451,7 +4451,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
},
): Promise<Task> {
const settings = await this.getSettingsFast();
const prefix = (settings.taskPrefix || "FN").trim().toUpperCase();
// A per-mission taskPrefix (threaded via TaskCreateInput at triage time)
// overrides the project-wide setting for this task's id reservation.
const prefix = (input.taskPrefix?.trim() || settings.taskPrefix || "FN").trim().toUpperCase();
const allocator = this.getDistributedTaskIdAllocator();
const nodeId = await this.resolveLocalNodeIdForTaskAllocation();
const reservation = await allocator.reserveDistributedTaskId({
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2703,6 +2703,9 @@ export interface TaskCreateInput {
branch?: string;
/** Optional planning/mission branch-group metadata carried across related tasks. */
branchContext?: TaskBranchContext;
/** Transient minting hint: overrides the project taskPrefix for this task's id
* reservation (used by mission triage to honor a per-mission prefix). Not persisted. */
taskPrefix?: string;
/** Optional per-task auto-merge override. Undefined means no task-level override. */
autoMerge?: boolean;
/** Durable source provenance for the originating external issue. */
Expand Down
4 changes: 3 additions & 1 deletion packages/dashboard/app/api/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7930,6 +7930,8 @@ export interface Mission {
mode: "project-default" | "existing" | "custom-new" | "auto-per-task";
branchName?: string;
};
/** Per-mission ticket id prefix (e.g. "ERR"); undefined inherits the project prefix. */
taskPrefix?: string;
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
Expand Down Expand Up @@ -8021,7 +8023,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; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise<Mission> {
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"]; taskPrefix?: string }, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId("/missions", projectId), {
method: "POST",
body: JSON.stringify(input),
Expand Down
35 changes: 35 additions & 0 deletions packages/dashboard/app/components/MissionManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ interface MissionFormData {
autopilotEnabled: boolean;
baseBranch: string;
branchStrategy: MissionBranchStrategy;
taskPrefix: string;
}

interface MilestoneFormData {
Expand Down Expand Up @@ -326,6 +327,7 @@ const EMPTY_MISSION_FORM: MissionFormData = {
branchStrategy: {
mode: "project-default",
},
taskPrefix: "",
};

const EMPTY_MILESTONE_FORM: MilestoneFormData = {
Expand Down Expand Up @@ -1611,6 +1613,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
autopilotEnabled: mission.autopilotEnabled ?? false,
baseBranch: mission.baseBranch ?? "",
branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy),
taskPrefix: mission.taskPrefix ?? "",
});
}, []);

Expand Down Expand Up @@ -1648,6 +1651,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
autopilotEnabled: missionForm.autopilotEnabled,
baseBranch: missionForm.baseBranch.trim() || undefined,
branchStrategy,
taskPrefix: missionForm.taskPrefix.trim() || undefined,
}, projectId);
addToast(t("missions.created", "Mission created"), "success");
} else if (editingMissionId) {
Expand All @@ -1660,6 +1664,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
autopilotEnabled: missionForm.autopilotEnabled,
baseBranch: missionForm.baseBranch.trim() || "",
branchStrategy,
taskPrefix: missionForm.taskPrefix.trim() || undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cleared Prefix Is Omitted

When a mission already has taskPrefix set and the edit form is cleared, this line converts the empty input to undefined. JSON.stringify drops that key, so the PATCH route skips taskPrefix and the old prefix remains stored; future triaged tasks keep using the stale mission prefix instead of inheriting the project prefix.

};
if (missionForm.autopilotEnabled) {
updates.autoAdvance = true;
Expand Down Expand Up @@ -2810,6 +2815,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
/>
</label>
<label>
{t("missions.taskPrefix", "Task prefix")}
<input
type="text"
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
value={missionForm.taskPrefix}
onChange={(e) => setMissionForm({ ...missionForm, taskPrefix: e.target.value.toUpperCase() })}
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select
Expand Down Expand Up @@ -4535,6 +4550,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
/>
</label>
<label>
{t("missions.taskPrefix", "Task prefix")}
<input
type="text"
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
value={missionForm.taskPrefix}
onChange={(e) => setMissionForm({ ...missionForm, taskPrefix: e.target.value.toUpperCase() })}
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select
Expand Down Expand Up @@ -4629,6 +4654,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
/>
</label>
<label>
{t("missions.taskPrefix", "Task prefix")}
<input
type="text"
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
value={missionForm.taskPrefix}
onChange={(e) => setMissionForm({ ...missionForm, taskPrefix: e.target.value.toUpperCase() })}
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select
Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/app/components/mission-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface Mission {
mode: "project-default" | "existing" | "custom-new" | "auto-per-task";
branchName?: string;
};
taskPrefix?: string;
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
Expand Down
21 changes: 19 additions & 2 deletions packages/dashboard/src/mission-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,19 @@ function validateMissionBranchStrategy(value: unknown): MissionBranchStrategy |
};
}

function validateTaskPrefix(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") {
throw new Error("taskPrefix must be a string");
}
const trimmed = value.trim().toUpperCase();
if (!trimmed) return undefined; // empty => inherit the project prefix
if (!/^[A-Z][A-Z0-9]*$/.test(trimmed)) {
throw new Error("taskPrefix must start with a letter and contain only letters and digits");
}
return trimmed;
}

function validateOrderedIds(body: unknown): string[] {
if (!body || typeof body !== "object") {
throw new Error("Request body must contain orderedIds array");
Expand Down Expand Up @@ -444,7 +457,7 @@ export function createMissionRouter(
router.post(
"/",
catchTypedHandler(async (req, res) => {
const { title, description, autoAdvance, baseBranch, branchStrategy, goalIds } = req.body;
const { title, description, autoAdvance, baseBranch, branchStrategy, taskPrefix, goalIds } = req.body;

const validatedTitle = validateTitle(title);
const validatedDescription = validateDescription(description);
Expand All @@ -455,6 +468,7 @@ export function createMissionRouter(
description: validatedDescription,
baseBranch: validateDescription(baseBranch),
branchStrategy: validateMissionBranchStrategy(branchStrategy),
taskPrefix: validateTaskPrefix(taskPrefix),
};

const mission = missionStore.createMission(input);
Expand Down Expand Up @@ -1120,7 +1134,7 @@ export function createMissionRouter(
"/:missionId",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
const { title, description, status, autoAdvance, autopilotEnabled, baseBranch, branchStrategy, goalIds } = req.body;
const { title, description, status, autoAdvance, autopilotEnabled, baseBranch, branchStrategy, taskPrefix, goalIds } = req.body;

if (!validateMissionId(missionId)) {
throw badRequest("Invalid mission ID format");
Expand Down Expand Up @@ -1151,6 +1165,9 @@ export function createMissionRouter(
if (branchStrategy !== undefined) {
updates.branchStrategy = validateMissionBranchStrategy(branchStrategy);
}
if (taskPrefix !== undefined) {
updates.taskPrefix = validateTaskPrefix(taskPrefix);
}

if (Object.keys(updates).length === 0 && validatedGoalIds === undefined) {
throw badRequest("No valid fields to update");
Expand Down
9 changes: 9 additions & 0 deletions packages/engine/src/__tests__/worktree-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ describe("worktree-hooks", () => {
expect(hook).toContain("s/^KB-//i");
});

it("derives the strip prefix from the task id, ignoring a mismatched options.taskPrefix", () => {
// A per-mission ticket (ERR-5) whose project-wide options.taskPrefix is
// still "FN" must strip its own ERR- prefix, not "FN-".
const hook = buildCommitMsgTrailerHook("ERR-5", { taskPrefix: "FN" });
expect(hook).toContain('PREFIX="ERR"');
expect(hook).toContain("s/^ERR-//i");
expect(hook).not.toContain('PREFIX="FN"');
});

it("installs metadata and pre-commit + commit-msg hooks in linked worktree", async () => {
const root = mkdtempSync(join(tmpdir(), "wt-hook-root-"));
execFileSync("git", ["init"], { cwd: root });
Expand Down
11 changes: 10 additions & 1 deletion packages/engine/src/worktree-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,16 @@ export function buildCommitMsgTrailerHook(
commitAuthorEmail?: string;
} = {}
): string {
const taskPrefix = (options.taskPrefix ?? "FN").trim() || "FN";
// Derive the strip prefix from the task id itself so per-mission prefixes
// (e.g. ERR-5 while the project taskPrefix is still "FN") strip correctly.
// The fusion-task-id file holds the real id, so PREFIX must match it, not the
// project-wide options.taskPrefix. Falls back to options.taskPrefix / "FN"
// only when taskId isn't a well-formed "<PREFIX>-<n>".
const trimmedTaskId = taskId.trim();
const derivedPrefix = /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmedTaskId)
? trimmedTaskId.replace(/-\d+$/, "").toUpperCase()
: "";
const taskPrefix = (derivedPrefix || options.taskPrefix || "FN").trim() || "FN";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fallback Prefix Is Unescaped

When taskId does not match the new numeric-id regex, this falls back to options.taskPrefix and later interpolates it directly into the generated case pattern and sed regex. A configured prefix containing regex or shell metacharacters can make the hook strip the wrong value or expand shell variables, producing an incorrect Fusion-Task-Id trailer for valid commits.

const trailerName = (options.trailerName ?? "Fusion-Task-Id").trim() || "Fusion-Task-Id";
const commitAuthorName = (options.commitAuthorName ?? DEFAULT_COMMIT_AUTHOR_NAME).trim() || DEFAULT_COMMIT_AUTHOR_NAME;
const commitAuthorEmail = (options.commitAuthorEmail ?? DEFAULT_COMMIT_AUTHOR_EMAIL).trim() || DEFAULT_COMMIT_AUTHOR_EMAIL;
Expand Down
Loading