FN-8297: add research finding promotion to mission features

Bridge completed research findings into durable mission roadmap features.

- Persist stable finding and citation provenance on mission features
- Add idempotent promotion APIs, dashboard controls, and agent tooling
- Document the promotion flow and cover finding identity and feature synchronization

Files changed:
 .changeset/fn-8297-research-mission-bridge.md      |  7 ++++
 docs/missions.md                                   |  4 +++
 docs/research.md                                   |  4 +++
 .../__tests__/research-finding-identity.test.ts    | 15 +++++++++
 packages/core/src/async-mission-store-queries.ts   | 26 +++++++++++++++
 packages/core/src/async-mission-store.ts           | 16 +++++++++
 packages/core/src/index.gate.ts                    |  3 ++
 packages/core/src/index.ts                         |  3 ++
 packages/core/src/mission-types.ts                 | 13 ++++++++
 .../core/src/postgres/migrations/0000_initial.sql  |  4 +++
 .../0023_research_feature_provenance.sql           |  8 +++++
 packages/core/src/postgres/schema-applier.ts       | 13 +++++++-
 packages/core/src/postgres/schema/project.ts       |  5 +++
 packages/core/src/research-feature-promotion.ts    | 38 ++++++++++++++++++++++
 packages/core/src/research-types.ts                | 21 ++++++++++++
 packages/core/src/types.ts                         |  1 +
 packages/dashboard/app/api/legacy.ts               | 21 ++++++++++++
 packages/dashboard/app/components/ResearchView.tsx | 19 ++++++-----
 packages/dashboard/app/hooks/useResearch.ts        |  3 ++
 packages/dashboard/src/chat.ts                     |  4 +--
 packages/dashboard/src/research-routes.ts          | 37 +++++++++++++++++----
 .../src/__tests__/agent-mission-tools.test.ts      | 14 +++++++-
 .../src/__tests__/mission-feature-sync.test.ts     | 20 ++++++++++++
 packages/engine/src/agent-tools.ts                 | 26 +++++++++++++++
 packages/engine/src/mission-feature-sync.ts        |  1 +
 25 files changed, 308 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-8297

Fusion-Task-Lineage: ffef4d26-1372-466a-8d3d-2b9b5cc9a53b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 16:16:14 -07:00
parent de2669fd17
commit c95e08ea21
25 changed files with 308 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Promote completed research findings into mission roadmap features.
category: feature
dev: Persists stable finding and citation provenance with idempotent slice-level promotion.

View File

@@ -678,3 +678,7 @@ For example, activate a ready work unit with `fn_slice_activate({ id: "SL-…" }
## Ideation handoff
[Persisted ideation](./ideation/persisted-diverge-converge.md) converges a selected candidate into this canonical hierarchy. It atomically creates or attaches a Mission and persists that linkage, rather than maintaining a parallel roadmap document.
## Research-derived features
A completed cited research finding may become a normal Mission Feature. Its feature retains research run, stable finding, and source-URL provenance; optional triage uses the normal feature task flow. Linked task changes reconcile through the existing feature → slice → milestone → mission rollups, and task completion remains subject to assertion validation.

View File

@@ -457,3 +457,7 @@ When all retries are exhausted, the run transitions to `retry_exhausted`.
| All retries exhausted | Persistent provider error | Check provider status; create a fresh run |
| Research view not visible in dashboard | Feature flag disabled | Set `experimentalFeatures.researchView` to `true` |
| Settings modal missing Research sections | Feature flag disabled | Enable `researchView` feature flag first |
## Promoting findings to mission features
Completed findings can be promoted into a destination slice through **Promote to roadmap** or the action-gated `fn_research_promote_finding` tool. Promotion stores the research run ID, a position-independent finding ID, and that finding's cited source URLs on the canonical feature. Repeating the same run/finding promotion in the same slice reuses the existing feature. Pending, failed, cancelled, timed-out, retry-exhausted, disabled, and unconfigured research cannot mutate a mission.

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { resolveResearchFindingId } from "../research-types.js";
describe("resolveResearchFindingId", () => {
it("is independent of result ordering and source ordering", () => {
const finding = { heading: "Latency", content: "Cache responses.", sources: ["https://b.example", "https://a.example"] };
const reorderedResultSet = [{ heading: "Other", content: "Other result", sources: [] }, { ...finding, sources: [...finding.sources].reverse() }];
expect(resolveResearchFindingId(finding)).toBe(resolveResearchFindingId(reorderedResultSet[1]!));
});
it("preserves a provider-persisted identity", () => {
expect(resolveResearchFindingId({ id: "provider-7", heading: "ignored", content: "ignored", sources: [] })).toBe("provider-7");
});
});

View File

@@ -182,6 +182,9 @@ interface FeatureRow {
lastValidatorStatus: string | null;
generatedFromFeatureId: string | null;
generatedFromRunId: string | null;
researchRunId: string | null;
researchFindingId: string | null;
researchSourceUrls: string[] | null;
}
interface MissionEventRow {
@@ -334,6 +337,9 @@ const featureColumns = {
lastValidatorStatus: schema.project.missionFeatures.lastValidatorStatus,
generatedFromFeatureId: schema.project.missionFeatures.generatedFromFeatureId,
generatedFromRunId: schema.project.missionFeatures.generatedFromRunId,
researchRunId: schema.project.missionFeatures.researchRunId,
researchFindingId: schema.project.missionFeatures.researchFindingId,
researchSourceUrls: schema.project.missionFeatures.researchSourceUrls,
};
const eventColumns = {
@@ -490,6 +496,7 @@ function rowToFeature(row: FeatureRow): MissionFeature {
lastValidatorStatus: (row.lastValidatorStatus as ValidatorRunStatus) ?? undefined,
generatedFromFeatureId: row.generatedFromFeatureId ?? undefined,
generatedFromRunId: row.generatedFromRunId ?? undefined,
researchProvenance: row.researchRunId && row.researchFindingId ? { researchRunId: row.researchRunId, findingId: row.researchFindingId, sourceUrls: row.researchSourceUrls ?? [] } : undefined,
};
}
@@ -932,11 +939,24 @@ export async function createFeature(handle: QueryHandle, feature: MissionFeature
lastValidatorStatus: feature.lastValidatorStatus ?? null,
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
generatedFromRunId: feature.generatedFromRunId ?? null,
researchRunId: feature.researchProvenance?.researchRunId ?? null,
researchFindingId: feature.researchProvenance?.findingId ?? null,
researchSourceUrls: feature.researchProvenance?.sourceUrls ?? null,
});
return (await getFeature(handle, feature.id))!;
}
/** Get a single feature by id. */
export async function getFeatureByResearchProvenance(handle: QueryHandle, sliceId: string, researchRunId: string, findingId: string): Promise<MissionFeature | undefined> {
const rows = await handle.select(featureColumns).from(schema.project.missionFeatures).where(and(
missionProjectScope(schema.project.missionFeatures.projectId),
eq(schema.project.missionFeatures.sliceId, sliceId),
eq(schema.project.missionFeatures.researchRunId, researchRunId),
eq(schema.project.missionFeatures.researchFindingId, findingId),
));
return rows[0] ? rowToFeature(rows[0] as FeatureRow) : undefined;
}
export async function getFeature(handle: QueryHandle, id: string): Promise<MissionFeature | undefined> {
const rows = await handle
.select(featureColumns)
@@ -1014,6 +1034,9 @@ export async function updateFeature(handle: QueryHandle, feature: MissionFeature
lastValidatorStatus: feature.lastValidatorStatus ?? null,
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
generatedFromRunId: feature.generatedFromRunId ?? null,
researchRunId: feature.researchProvenance?.researchRunId ?? null,
researchFindingId: feature.researchProvenance?.findingId ?? null,
researchSourceUrls: feature.researchProvenance?.sourceUrls ?? null,
})
.where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.missionFeatures.id, feature.id)));
}
@@ -1869,6 +1892,9 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature
lastValidatorStatus: feature.lastValidatorStatus ?? null,
generatedFromFeatureId: feature.generatedFromFeatureId ?? null,
generatedFromRunId: feature.generatedFromRunId ?? null,
researchRunId: feature.researchProvenance?.researchRunId ?? null,
researchFindingId: feature.researchProvenance?.findingId ?? null,
researchSourceUrls: feature.researchProvenance?.sourceUrls ?? null,
})
.onConflictDoUpdate({
target: [schema.project.missionFeatures.projectId, schema.project.missionFeatures.id],

View File

@@ -22,6 +22,7 @@ import type {
MilestoneCreateInput,
SliceCreateInput,
FeatureCreateInput,
ResearchFeatureCreateInput,
MissionWithHierarchy,
MissionHealth,
MissionEvent,
@@ -89,6 +90,7 @@ import {
reorderSlices,
createFeature,
getFeature,
getFeatureByResearchProvenance,
listFeaturesByIds,
listFeatures,
listFeaturesForMilestone,
@@ -813,6 +815,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
title: input.title,
description: input.description,
acceptanceCriteria: input.acceptanceCriteria,
researchProvenance: (input as Partial<ResearchFeatureCreateInput>).researchProvenance,
status: "defined",
createdAt: now,
updatedAt: now,
@@ -828,6 +831,19 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
return (await getFeature(this.db, feature.id)) ?? feature;
}
/**
* FNXC:ResearchMissionBridge 2026-07-18-12:00:
* Research promotion creates canonical features through this facade and
* reuses a project/slice/run/finding match before writing. The composite
* database index is the concurrent retry backstop for this invariant.
*/
async addResearchFeature(sliceId: string, input: ResearchFeatureCreateInput): Promise<{ feature: MissionFeature; reused: boolean }> {
const existing = await getFeatureByResearchProvenance(this.db, sliceId, input.researchProvenance.researchRunId, input.researchProvenance.findingId);
if (existing) return { feature: existing, reused: true };
const feature = await this.addFeature(sliceId, input);
return { feature, reused: false };
}
async getFeature(id: string): Promise<MissionFeature | undefined> {
return getFeature(this.db, id);
}

View File

@@ -1877,6 +1877,7 @@ export {
RESEARCH_ORCHESTRATION_PHASES,
RESEARCH_ORCHESTRATION_STEP_STATUSES,
RESEARCH_RUN_FAILURE_CLASSES,
resolveResearchFindingId,
} from "./research-types.js";
export type {
ResearchRunStatus,
@@ -2223,3 +2224,5 @@ export {
upsertWorkflowStepResult,
MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS,
} from "./workflow-step-results.js";
export { promoteResearchFinding } from "./research-feature-promotion.js";
export type { ResearchFeaturePromotionInput } from "./research-feature-promotion.js";

View File

@@ -1953,6 +1953,7 @@ export {
RESEARCH_ORCHESTRATION_PHASES,
RESEARCH_ORCHESTRATION_STEP_STATUSES,
RESEARCH_RUN_FAILURE_CLASSES,
resolveResearchFindingId,
} from "./research-types.js";
export type {
ResearchRunStatus,
@@ -2507,3 +2508,5 @@ export {
localeDisplayName,
} from "./detect-content-language.js";
export type { LanguageFamily, DetectedContentLanguage } from "./detect-content-language.js";
export { promoteResearchFinding } from "./research-feature-promotion.js";
export type { ResearchFeaturePromotionInput } from "./research-feature-promotion.js";

View File

@@ -231,6 +231,13 @@ export interface Slice {
* A MissionFeature represents a deliverable within a slice.
* Features can be linked to fn Tasks for implementation.
*/
export interface ResearchFeatureProvenance {
researchRunId: string;
findingId: string;
/** Finding-specific cited source URLs; an empty array explicitly means uncited. */
sourceUrls: string[];
}
export interface MissionFeature {
/** Unique identifier (e.g., "F-J6K9AB-G7H3") */
id: string;
@@ -246,6 +253,8 @@ export interface MissionFeature {
acceptanceCriteria?: string;
/** Current lifecycle status */
status: FeatureStatus;
/** Durable lineage when this canonical feature came from Fusion Research. */
researchProvenance?: ResearchFeatureProvenance;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
@@ -425,6 +434,10 @@ export interface SliceCreateInput {
}
/** Input for creating a new Feature */
export interface ResearchFeatureCreateInput extends FeatureCreateInput {
researchProvenance: ResearchFeatureProvenance;
}
export interface FeatureCreateInput {
/** Display name of the feature (required) */
title: string;

View File

@@ -978,6 +978,9 @@ CREATE TABLE IF NOT EXISTS project.mission_features (
last_validator_status text,
generated_from_feature_id text,
generated_from_run_id text,
research_run_id text,
research_finding_id text,
research_source_urls jsonb,
CONSTRAINT mission_features_slice_id_fkey
FOREIGN KEY (slice_id) REFERENCES project.slices(id) ON DELETE CASCADE,
CONSTRAINT mission_features_task_id_fkey
@@ -1969,3 +1972,4 @@ CREATE INDEX IF NOT EXISTS "idxArchivedTasksCreatedAt"
-- GIN index on the archive search_vector (VAL-SEARCH-005).
CREATE INDEX IF NOT EXISTS "idxArchivedTasksSearchVector"
ON archive.archived_tasks USING gin(search_vector);

View File

@@ -0,0 +1,8 @@
-- FNXC:ResearchMissionBridge 2026-07-18-12:00:
-- Persist finding-level lineage and enforce retry-safe promotion per project/slice.
ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS research_run_id text;
ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS research_finding_id text;
ALTER TABLE project.mission_features ADD COLUMN IF NOT EXISTS research_source_urls jsonb;
CREATE UNIQUE INDEX IF NOT EXISTS mission_features_research_promotion_unique
ON project.mission_features (project_id, slice_id, research_run_id, research_finding_id)
WHERE research_run_id IS NOT NULL AND research_finding_id IS NOT NULL;

View File

@@ -32,7 +32,7 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin
FNXC:GitHubImportTranslate 2026-07-17-23:48:
Advances to 0019 for the import-translation legacy-partition backfill. Per-migration identities above stay fixed; only this latest-version marker moves.
*/
export const SCHEMA_BASELINE_VERSION = "0022";
export const SCHEMA_BASELINE_VERSION = "0023";
const INITIAL_SCHEMA_VERSION = "0000";
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
@@ -109,6 +109,8 @@ export const TASK_PROPOSAL_CLAIM_VERSION = "0020";
export const CONFIGURATION_REVISIONS_VERSION = "0021";
/** FNXC:Ideation 2026-07-30-15:30: Persisted ideation needs its own forward migration because configuration revisions already own 0021. */
export const IDEATION_SCHEMA_VERSION = "0022";
/** FNXC:ResearchMissionBridge 2026-07-18-12:00: forward migration stores stable research finding provenance on canonical features. */
export const RESEARCH_FEATURE_PROVENANCE_VERSION = "0023";
/** Bookkeeping table for the fresh Drizzle migration history. */
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
@@ -218,6 +220,7 @@ const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0018_bul
const TASK_PROPOSAL_CLAIM_MIGRATION_PATH = join(MIGRATIONS_DIR, "0020_task_proposal_claim.sql");
const CONFIGURATION_REVISIONS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0021_configuration_revisions.sql");
const IDEATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0022_ideation.sql");
const RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0023_research_feature_provenance.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -309,6 +312,7 @@ export async function applySchemaBaseline(
const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION);
const configurationRevisionsAlreadyApplied = applied.includes(CONFIGURATION_REVISIONS_VERSION);
const ideationAlreadyApplied = applied.includes(IDEATION_SCHEMA_VERSION);
const researchFeatureProvenanceAlreadyApplied = applied.includes(RESEARCH_FEATURE_PROVENANCE_VERSION);
let schemaChanged = false;
if (!baselineAlreadyApplied) {
@@ -663,6 +667,13 @@ export async function applySchemaBaseline(
schemaChanged = true;
}
if (!researchFeatureProvenanceAlreadyApplied) {
const migrationSql = await readFile(RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${RESEARCH_FEATURE_PROVENANCE_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
if (!importTranslationCacheScopeFixAlreadyApplied) {
const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));

View File

@@ -1398,10 +1398,15 @@ export const missionFeatures = projectSchema.table("mission_features", {
lastValidatorStatus: text("last_validator_status"),
generatedFromFeatureId: text("generated_from_feature_id"),
generatedFromRunId: text("generated_from_run_id"),
// FNXC:ResearchMissionBridge 2026-07-18-12:00: Store run/finding/source lineage as columns so duplicate promotion is project+slice scoped and citations remain queryable.
researchRunId: text("research_run_id"),
researchFindingId: text("research_finding_id"),
researchSourceUrls: jsonb("research_source_urls"),
}, (t) => [
primaryKey({ columns: [t.projectId, t.id] }),
foreignKey({ columns: [t.projectId, t.sliceId], foreignColumns: [slices.projectId, slices.id] }).onDelete("cascade"),
foreignKey({ columns: [t.projectId, t.taskId], foreignColumns: [tasks.projectId, tasks.id] }).onDelete("set null"),
uniqueIndex("mission_features_research_promotion_unique").on(t.projectId, t.sliceId, t.researchRunId, t.researchFindingId),
]);
/*

View File

@@ -0,0 +1,38 @@
import type { AsyncResearchStore } from "./async-research-store.js";
import type { AsyncMissionStore } from "./async-mission-store.js";
import { resolveResearchFindingId } from "./research-types.js";
export type ResearchFeaturePromotionInput = {
runId: string;
findingId: string;
sliceId: string;
title?: string;
description?: string;
acceptanceCriteria?: string;
};
/**
* FNXC:ResearchMissionBridge 2026-07-18-12:00:
* Engine tools and dashboard routes share this completed-run gate so a route
* cannot create roadmap work for a nonterminal or position-shifted finding.
*/
export async function promoteResearchFinding(
researchStore: Pick<AsyncResearchStore, "getRun">,
missionStore: Pick<AsyncMissionStore, "addResearchFeature">,
input: ResearchFeaturePromotionInput,
) {
const run = await researchStore.getRun(input.runId);
if (!run) throw new Error(`Research run ${input.runId} not found`);
if (run.status !== "completed") throw new Error(`Research run ${input.runId} is not completed`);
const finding = (run.results?.findings ?? []).find((candidate) => resolveResearchFindingId(candidate) === input.findingId);
if (!finding) throw new Error(`Finding ${input.findingId} not found`);
const findingId = resolveResearchFindingId(finding);
const sourceUrls = [...new Set((finding.sources ?? []).map((url) => url.trim()).filter(Boolean))];
const promoted = await missionStore.addResearchFeature(input.sliceId, {
title: input.title?.trim() || finding.heading?.trim() || "Research finding",
description: input.description?.trim() || finding.content?.trim() || undefined,
acceptanceCriteria: input.acceptanceCriteria?.trim() || undefined,
researchProvenance: { researchRunId: run.id, findingId, sourceUrls },
});
return { ...promoted, runId: run.id, findingId, citations: sourceUrls };
}

View File

@@ -122,7 +122,28 @@ export interface ResearchEvent {
metadata?: Record<string, unknown>;
}
/**
* FNXC:ResearchMissionBridge 2026-07-18-12:00:
* A finding identity must be stable when providers reorder synthesis output. It
* is intentionally derived from finding-owned content and cited URLs, never an
* array position, so every promotion surface can reuse the same roadmap work.
*/
export function resolveResearchFindingId(finding: Pick<ResearchFinding, "heading" | "content" | "sources"> & { id?: unknown }): string {
const explicitId = typeof finding.id === "string" ? finding.id.trim() : "";
if (explicitId) return explicitId;
const normalize = (value: string) => value.trim().replace(/\s+/g, " ").toLowerCase();
const canonical = [normalize(finding.heading ?? ""), normalize(finding.content ?? ""), ...[...(finding.sources ?? [])].map(normalize).filter(Boolean).sort()].join("\n");
let hash = 0x811c9dc5;
for (let index = 0; index < canonical.length; index += 1) {
hash ^= canonical.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `finding-${(hash >>> 0).toString(16).padStart(8, "0")}`;
}
export interface ResearchFinding {
/** Optional provider-persisted identity; legacy findings use resolveResearchFindingId. */
id?: string;
heading: string;
content: string;
sources: string[];

View File

@@ -7082,6 +7082,7 @@ export {
} from "./model-resolution.js";
export type { ResolvedModelSelection } from "./model-resolution.js";
export { resolveResearchSettings } from "./research-settings.js";
export { resolveResearchFindingId } from "./research-types.js";
export type { ResolvedResearchSettings } from "./research-settings.js";
/*

View File

@@ -10271,3 +10271,24 @@ export function fetchSystemLogs(limit?: number): Promise<{ entries: SystemLogEnt
const suffix = limit ? `?limit=${limit}` : "";
return api<{ entries: SystemLogEntryDto[] }>(`/system/logs${suffix}`);
}
export type ResearchFindingPromotionInput = {
findingId: string;
sliceId: string;
title?: string;
description?: string;
acceptanceCriteria?: string;
triage?: boolean;
taskId?: string;
};
export function promoteResearchFinding(
runId: string,
input: ResearchFindingPromotionInput,
projectId?: string,
): Promise<{ runId: string; findingId: string; feature: { id: string; status: string; taskId?: string }; citations: string[]; reused: boolean }> {
return api(withProjectId(`/research/runs/${encodeURIComponent(runId)}/findings/${encodeURIComponent(input.findingId)}/promote`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { resolveResearchSettings, type Settings } from "@fusion/core";
import { resolveResearchFindingId, resolveResearchSettings, type Settings } from "@fusion/core";
import { Loader2, Search } from "lucide-react";
import { fetchAuthStatus, fetchSettings } from "../api";
import { useResearch } from "../hooks/useResearch";
@@ -81,6 +81,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
exportRun,
createTaskFromRun,
attachRunToTask,
promoteFinding,
statusCounts,
refresh,
uiError,
@@ -93,6 +94,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>([]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [modalState, setModalState] = useState<null | { mode: "create" | "enrich"; findingId: string }>(null);
const [promotionSliceId, setPromotionSliceId] = useState("");
const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS;
const selectedSearchProvider = effectiveSettings.searchProvider;
@@ -418,9 +420,8 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
)}
{Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && (
<div className="research-view__findings">
{selectedRun.results.findings.map((finding, index) => {
const findingRecord = finding as { id?: string };
const findingId = findingRecord.id?.trim() || `finding-${index + 1}`;
{selectedRun.results.findings.map((finding) => {
const findingId = resolveResearchFindingId(finding);
return (
<article key={findingId} className="research-view__finding card">
<h4>{finding.heading}</h4>
@@ -440,6 +441,10 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
>
{t("research.enrichTask", "Enrich Task")}
</button>
<input className="input" aria-label={t("research.destinationSlice", "Destination slice ID")} value={promotionSliceId} onChange={(event) => setPromotionSliceId(event.target.value)} placeholder={t("research.destinationSlice", "Destination slice ID")} />
<button className="btn btn-sm" type="button" disabled={selectedRun.status !== "completed" || !promotionSliceId.trim() || actionLoading === `promote-${findingId}`} onClick={() => void runAction(`promote-${findingId}`, () => promoteFinding(selectedRun.id, { findingId, sliceId: promotionSliceId.trim() }), t("research.findingPromoted", "Finding promoted to roadmap"))}>
{t("research.promoteFinding", "Promote to roadmap")}
</button>
</div>
</article>
);
@@ -478,10 +483,8 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
</>
)}
{selectedRun && modalState && (() => {
const findingIndex = selectedRun.results?.findings?.findIndex((entry, idx) => {
const findingRecord = entry as { id?: string };
const id = findingRecord.id?.trim() || `finding-${idx + 1}`;
return id === modalState.findingId;
const findingIndex = selectedRun.results?.findings?.findIndex((entry) => {
return resolveResearchFindingId(entry) === modalState.findingId;
}) ?? -1;
const finding = findingIndex >= 0 ? selectedRun.results!.findings[findingIndex] : null;
if (!finding) return null;

View File

@@ -6,6 +6,7 @@ import {
cancelResearchRun,
createResearchRun,
createTaskFromResearchRun,
promoteResearchFinding,
exportResearchRun,
getResearchRun,
listResearchRuns,
@@ -307,6 +308,8 @@ export function useResearch(options?: { projectId?: string }) {
) => createTaskFromResearchRun(runId, { title, findingId, description, priority, attachExport }, projectId),
attachRunToTask: (runId: string, taskId: string, findingId?: string, attachExport?: boolean) =>
attachResearchRunToTask(runId, { taskId, findingId, attachExport }, projectId),
promoteFinding: (runId: string, input: { findingId: string; sliceId: string; title?: string; description?: string; acceptanceCriteria?: string; triage?: boolean; taskId?: string }) =>
promoteResearchFinding(runId, input, projectId),
uiError,
runActionState: getRunActionState(selectedRun),
statusCounts: runs.reduce<Record<ResearchRunStatus, number>>(

View File

@@ -463,8 +463,8 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
createTaskShowTool(taskStore),
createTaskSearchTool(taskStore),
createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }),
/* FNXC:MissionToolParity 2026-07-29-15:30: Dashboard chat uses the engine factory, but only bound permanent-agent sessions with both policy contexts receive hierarchy mutations. */
...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)),
/* FNXC:ResearchMissionBridge 2026-07-18-12:00: Promotion is a mission mutation because it creates canonical roadmap work; dashboard chat exposes it only through the same permanent-agent action gate as all hierarchy writes. */
...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)),
/* FNXC:Ideation 2026-07-30-15:30: Unbound or ephemeral chat exposes only positive ideation reads; mutations require the same durable gate context as Mission writes. */
...createIdeationTools(taskStore).filter((tool) => missionMutationGated || CHAT_IDEATION_READ_TOOL_NAMES.has(tool.name)),
...createGoalRetrievalTools(taskStore),

View File

@@ -9,6 +9,8 @@ import {
RESEARCH_EVENT_TYPES,
ResearchLifecycleError,
buildResearchDocumentKey,
resolveResearchFindingId,
promoteResearchFinding,
type ResearchRunListOptions,
type ResearchRunStatus,
} from "@fusion/core";
@@ -57,16 +59,14 @@ function toRunDetail(run: ResearchRun) {
};
}
function getFindingId(finding: NonNullable<ResearchRun["results"]>["findings"][number], index: number): string {
const maybeFinding = finding as { id?: unknown };
const explicitId = typeof maybeFinding.id === "string" ? maybeFinding.id.trim() : "";
return explicitId || `finding-${index + 1}`;
function getFindingId(finding: NonNullable<ResearchRun["results"]>["findings"][number]): string {
return resolveResearchFindingId(finding);
}
function getFindingById(run: ResearchRun, findingId: string) {
const findings = run.results?.findings ?? [];
for (const [index, finding] of findings.entries()) {
if (getFindingId(finding, index) === findingId) {
for (const finding of findings) {
if (getFindingId(finding) === findingId) {
return { finding, findingId };
}
}
@@ -382,6 +382,31 @@ export function createResearchRouter(store: TaskStore, options?: ServerOptions):
}
});
router.post("/runs/:runId/findings/:findingId/promote", async (req, res) => {
try {
const scopedStore = requestContext.getStore();
if (!scopedStore) throw new ApiError(500, "Task store context not available");
const sliceId = typeof req.body?.sliceId === "string" ? req.body.sliceId.trim() : "";
if (!sliceId) throw badRequest("sliceId is required");
const missionStore = scopedStore.getMissionStore();
if (!("addResearchFeature" in missionStore)) throw new ApiError(409, "Research promotion requires the PostgreSQL mission store");
const promoted = await promoteResearchFinding(getStore() as never, missionStore, {
runId: req.params.runId,
findingId: req.params.findingId,
sliceId,
title: typeof req.body?.title === "string" ? req.body.title : undefined,
description: typeof req.body?.description === "string" ? req.body.description : undefined,
acceptanceCriteria: typeof req.body?.acceptanceCriteria === "string" ? req.body.acceptanceCriteria : undefined,
});
let feature = promoted.feature;
if (typeof req.body?.taskId === "string" && req.body.taskId.trim()) feature = await missionStore.linkFeatureToTask(feature.id, req.body.taskId.trim());
if (req.body?.triage === true) feature = await missionStore.triageFeature(feature.id);
res.status(promoted.reused ? 200 : 201).json({ runId: promoted.runId, findingId: promoted.findingId, feature, sliceId, citations: promoted.citations, reused: promoted.reused, taskId: feature.taskId ?? null, status: feature.status });
} catch (error) {
rethrowAsApiError(error, "Failed to promote research finding");
}
});
router.post("/runs/:runId/findings/:findingId/tasks/:taskId/enrich", async (req, res) => {
try {
const scopedStore = requestContext.getStore();

View File

@@ -12,7 +12,7 @@ describe("createMissionTools", () => {
expect(createMissionTools(store).map((tool) => tool.name)).toEqual([
"fn_mission_list", "fn_mission_show", "fn_mission_create", "fn_mission_update", "fn_mission_delete",
"fn_milestone_add", "fn_milestone_update", "fn_milestone_delete", "fn_slice_add", "fn_slice_activate",
"fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_delete", "fn_feature_link_task",
"fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_delete", "fn_feature_link_task", "fn_research_promote_finding",
]);
});
@@ -25,6 +25,18 @@ describe("createMissionTools", () => {
expect(result.details).toMatchObject({ feature: { taskId: "FN-1", status: "triaged" } });
});
it("promotes completed findings through the idempotent mission-store facade", async () => {
const addResearchFeature = vi.fn().mockResolvedValue({ reused: false, feature: { id: "F-1", status: "defined" } });
const store = {
getResearchStore: () => ({ getRun: vi.fn().mockResolvedValue({ id: "R-1", status: "completed", results: { findings: [{ heading: "Finding", content: "Evidence", sources: ["https://source.example"] }] } }) }),
getMissionStore: () => ({ addResearchFeature }),
} as never;
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_research_promote_finding")!;
const result = await tool.execute("call", { runId: "R-1", findingId: "finding-b481c893", sliceId: "SL-1" });
expect(addResearchFeature).toHaveBeenCalledWith("SL-1", expect.objectContaining({ researchProvenance: expect.objectContaining({ researchRunId: "R-1" }) }));
expect(result.details).toMatchObject({ feature: { id: "F-1" }, reused: false });
});
it("returns a structured error for missing hierarchy records", async () => {
const store = { getMissionStore: () => ({ getMissionWithHierarchy: vi.fn().mockResolvedValue(undefined) }) } as never;
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_show")!;

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { reconcileMissionFeatureState } from "../mission-feature-sync.js";
describe("reconcileMissionFeatureState", () => {
it("keeps assertion validation as the completion gate for research-derived features", async () => {
const decision = await reconcileMissionFeatureState(
{ getTask: async () => undefined } as never,
{ id: "FN-1", column: "done", status: "completed" } as never,
{ id: "F-1", status: "in-progress", lastValidatorStatus: "failed" } as never,
{ hasLinkedAssertions: true },
);
expect(decision).toEqual(expect.objectContaining({ kind: "noop" }));
});
it("moves a linked feature through canonical triage and in-progress states", async () => {
const taskStore = { getTask: async () => undefined } as never;
await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "todo", status: "pending" } as never, { id: "F-1", status: "in-progress" } as never)).resolves.toMatchObject({ kind: "update", status: "triaged" });
await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "in-review", status: "in-progress" } as never, { id: "F-1", status: "triaged" } as never)).resolves.toMatchObject({ kind: "update", status: "in-progress" });
});
});

View File

@@ -3511,6 +3511,16 @@ export const featureAddParams = Type.Object({ sliceId: Type.String(), title: Typ
export const featureUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) });
export const featureDeleteParams = Type.Object({ featureId: Type.String(), force: Type.Optional(Type.Boolean()) });
export const featureLinkTaskParams = Type.Object({ featureId: Type.String(), taskId: Type.String() });
export const researchFindingPromoteParams = Type.Object({
runId: Type.String(),
findingId: Type.String(),
sliceId: Type.String(),
title: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
acceptanceCriteria: Type.Optional(Type.String()),
triage: Type.Optional(Type.Boolean()),
taskId: Type.Optional(Type.String()),
});
const missionToolResult = (text: string, details: Record<string, unknown>, isError = false) => ({
content: [{ type: "text" as const, text }], details, ...(isError ? { isError: true } : {}),
@@ -3545,6 +3555,22 @@ export function createMissionTools(store: TaskStore): ToolDefinition[] {
tool("fn_feature_update", "Update Feature", "Partially update a feature.", featureUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description", "acceptanceCriteria"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const feature = await store.getMissionStore().updateFeature(p.id, updates); return missionToolResult(`Updated ${feature.id}`, { feature }); }),
tool("fn_feature_delete", "Delete Feature", "Delete a feature, respecting linked-task guards.", featureDeleteParams, async (p) => { await store.getMissionStore().deleteFeature(p.featureId, p.force ===true); return missionToolResult(`Deleted ${p.featureId}`, { featureId: p.featureId }); }),
tool("fn_feature_link_task", "Link Feature to Task", "Link a feature to a live project-scoped task.", featureLinkTaskParams, async (p) => { const feature = await store.getMissionStore().linkFeatureToTask(p.featureId, p.taskId); return missionToolResult(`Linked ${feature.id} to ${p.taskId}`, { feature }); }),
tool("fn_research_promote_finding", "Promote Research Finding", "Promote a completed research finding into a canonical mission feature.", researchFindingPromoteParams, async (p) => {
const missionStore = store.getMissionStore();
if (!("addResearchFeature" in missionStore)) return missionToolResult("Research promotion requires the PostgreSQL mission store", { code: "POSTGRES_REQUIRED" }, true);
let promoted: Awaited<ReturnType<typeof fusionCore.promoteResearchFinding>>;
try {
promoted = await fusionCore.promoteResearchFinding(store.getResearchStore() as never, missionStore, p);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return missionToolResult(message, { code: message.includes("not completed") ? "RUN_NOT_COMPLETED" : message.includes("not found") ? "FINDING_OR_RUN_NOT_FOUND" : "PROMOTION_FAILED", runId: p.runId, findingId: p.findingId }, true);
}
/* FNXC:ResearchMissionBridge 2026-07-18-12:00: All agent promotion flows use the shared completed-run gate and AsyncMissionStore facade; never create a substitute task. */
let feature = promoted.feature;
if (p.taskId) feature = await store.getMissionStore().linkFeatureToTask(feature.id, p.taskId);
if (p.triage) feature = await store.getMissionStore().triageFeature(feature.id);
return missionToolResult(`${promoted.reused ? "Reused" : "Promoted"} ${promoted.findingId} as ${feature.id}`, { runId: promoted.runId, findingId: promoted.findingId, feature, sliceId: p.sliceId, citations: promoted.citations, reused: promoted.reused, taskId: feature.taskId ?? null, status: feature.status });
}),
];
}

View File

@@ -26,6 +26,7 @@ export async function reconcileMissionFeatureState(
};
}
/* FNXC:ResearchMissionBridge 2026-07-18-12:00: Research-derived features use this same reconciliation decision, so task completion never bypasses assertion validation or parent-roadmap rollups. */
const hasUnvalidatedAssertions = context.hasLinkedAssertions === true
&& feature.lastValidatorStatus !== "passed";