FN-8295: add persisted ideation mission handoffs
Persist bounded ideation sessions through agent tools, the Command Center, and atomic Mission convergence. - Store ideation sessions and divergent candidates in PostgreSQL with async APIs and migration support. - Expose gated ideation tools, chat routes, and agent lifecycle integration. - Add the Ideation panel, documentation, release metadata, and regression coverage. Files changed: .changeset/fn-8295-ideation-diverge-converge.md | 7 ++ docs/ideation/persisted-diverge-converge.md | 22 ++++ docs/missions.md | 4 + .../__tests__/postgres/ideation-store.pg.test.ts | 57 +++++++++ packages/core/src/async-ideation-store-queries.ts | 117 +++++++++++++++++ packages/core/src/async-ideation-store.ts | 138 +++++++++++++++++++++ packages/core/src/async-mission-store.ts | 27 ++-- packages/core/src/ideation-types.ts | 69 +++++++++++ packages/core/src/index.ts | 3 + .../core/src/postgres/migrations/0022_ideation.sql | 67 ++++++++++ packages/core/src/postgres/schema-applier.ts | 18 ++- packages/core/src/postgres/schema/project.ts | 49 +++++++- packages/core/src/store.ts | 8 +- packages/core/src/task-store/remaining-ops-8.ts | 14 +++ .../components/command-center/CommandCenter.tsx | 7 +- .../components/command-center/IdeationPanel.css | 18 +++ .../components/command-center/IdeationPanel.tsx | 58 +++++++++ .../__tests__/CommandCenter.test.tsx | 6 +- .../dashboard/src/__tests__/chat-manager.test.ts | 1 + packages/dashboard/src/__tests__/chat.test.ts | 1 + .../__tests__/ideation-tool-route-parity.test.ts | 29 +++++ packages/dashboard/src/chat.ts | 4 + packages/dashboard/src/ideation-routes.ts | 50 ++++++++ .../src/routes/register-integrated-routers.ts | 2 + .../src/__tests__/agent-ideation-tools.test.ts | 40 ++++++ .../src/__tests__/gating-classifications.test.ts | 16 +++ .../src/__tests__/permanent-agent-gating.test.ts | 2 + packages/engine/src/agent-heartbeat.ts | 4 +- packages/engine/src/agent-tools.ts | 67 ++++++++++ packages/engine/src/executor.ts | 2 + packages/engine/src/gating-classifications.ts | 8 ++ packages/engine/src/index.ts | 1 + packages/engine/src/triage.ts | 2 + 33 files changed, 897 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-8295 Fusion-Task-Lineage: 1b8b0752-22bd-4b2f-aebd-4305c63abcf9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8295-ideation-diverge-converge.md
Normal file
7
.changeset/fn-8295-ideation-diverge-converge.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add persisted ideation sessions with atomic Mission handoff.
|
||||
category: feature
|
||||
dev: Adds fn_ideation tools, dashboard Command Center access, and PostgreSQL-backed linkage.
|
||||
22
docs/ideation/persisted-diverge-converge.md
Normal file
22
docs/ideation/persisted-diverge-converge.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Persisted ideation
|
||||
|
||||
Fusion ideation is a project-scoped, bounded operation rather than a free-form document.
|
||||
|
||||
1. Start a session with `fn_ideation_start` or Command Center → **Ideation**.
|
||||
2. Record alternatives with `fn_ideation_diverge`; each candidate records an `agent`, `human`, or `research` origin and optional source reference.
|
||||
3. Inspect sessions with `fn_ideation_list` and `fn_ideation_show`.
|
||||
4. Converge an explicit candidate using `fn_ideation_converge` or the Command Center action.
|
||||
|
||||
Convergence creates a canonical Mission by default, or attaches to a supplied `targetMissionId`. The selected candidate and session persist the Mission (and optional Feature) linkage. It never writes an orphan ideation document as the handoff.
|
||||
|
||||
## Atomic handoff
|
||||
|
||||
The ideation store opens one `AsyncDataLayer.transactionImmediate` transaction. Mission creation or validation, candidate selection, session convergence, and linkage persistence all run inside it. If Mission handoff fails, the transaction rolls back: the session remains open, no candidate is selected, and no partial Mission/linkage is retained.
|
||||
|
||||
## Access and policy
|
||||
|
||||
Engine executor, triage, and heartbeat lanes receive the shared `fn_ideation_*` factory. Listing and showing sessions are positively read-only. Start, diverge, and converge are `task_agent_mutation` operations and require both action-gate and permanent-agent policy recognition.
|
||||
|
||||
Dashboard chat always exposes read tools. It exposes mutations only for a bound non-ephemeral agent that has the same durable action/permanent-agent gate contexts as Mission chat writes; unbound chat does not receive ideation mutations.
|
||||
|
||||
The dashboard REST route and Command Center panel delegate to the same project-scoped `TaskStore.getIdeationStore()` operation as the tools.
|
||||
@@ -674,3 +674,7 @@ Mission hierarchy operations are available with the same project-scoped `Mission
|
||||
`fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools.
|
||||
|
||||
For example, activate a ready work unit with `fn_slice_activate({ id: "SL-…" })`. Link it to live work with `fn_feature_link_task({ featureId: "F-…", taskId: "FN-…" })`. Linking delegates to `MissionStore.linkFeatureToTask()`: it verifies the task is a live row in the same project, changes the feature to `triaged`, and records the mission/slice linkage on the task. Archived, deleted, missing, and other-project tasks are rejected.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { pgDescribe, createSharedPgTaskStoreTestHarness, type SharedPgTaskStoreHarness } from "../../__test-utils__/pg-test-harness.js";
|
||||
import { AsyncIdeationStore, AsyncMissionStore } from "@fusion/core";
|
||||
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
pgTest("AsyncIdeationStore", () => {
|
||||
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_ideation_store" });
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
const ideation = (): AsyncIdeationStore => h.store().getIdeationStore();
|
||||
|
||||
it("persists divergent provenance and atomically creates a linked canonical mission", async () => {
|
||||
const session = await ideation().createSession({ title: "Ideas", prompt: "Improve onboarding" });
|
||||
await ideation().addCandidate(session.id, { content: "First", origin: "human", sourceRef: "workshop" });
|
||||
const selected = await ideation().addCandidate(session.id, { content: "Second", origin: "agent" });
|
||||
const converged = await ideation().convergeSession(session.id, selected.id);
|
||||
|
||||
expect(converged.status).toBe("converged");
|
||||
expect(converged.targetMissionId).toMatch(/^M-/);
|
||||
expect(converged.candidates.find((candidate) => candidate.id === selected.id)).toMatchObject({ selected: true, linkedMissionId: converged.targetMissionId });
|
||||
expect(await (h.store().getMissionStore() as AsyncMissionStore).getMission(converged.targetMissionId!)).toMatchObject({ id: converged.targetMissionId });
|
||||
});
|
||||
|
||||
it("attaches to an existing mission and rejects foreign candidates", async () => {
|
||||
const mission = await (h.store().getMissionStore() as AsyncMissionStore).createMission({ title: "Existing" });
|
||||
const first = await ideation().createSession({ title: "First" });
|
||||
const second = await ideation().createSession({ title: "Second" });
|
||||
const candidate = await ideation().addCandidate(first.id, { content: "Candidate", origin: "research" });
|
||||
await expect(ideation().convergeSession(second.id, candidate.id, { targetMissionId: mission.id })).rejects.toThrow("does not belong");
|
||||
const converged = await ideation().convergeSession(first.id, candidate.id, { targetMissionId: mission.id });
|
||||
expect(converged.targetMissionId).toBe(mission.id);
|
||||
});
|
||||
|
||||
it("rolls back session selection and mission creation when canonical handoff fails", async () => {
|
||||
const session = await ideation().createSession({ title: "Rollback" });
|
||||
const candidate = await ideation().addCandidate(session.id, { content: "Never persisted", origin: "agent" });
|
||||
const missionStore = h.store().getMissionStore() as AsyncMissionStore;
|
||||
const create = vi.spyOn(missionStore, "createMission").mockRejectedValueOnce(new Error("induced handoff failure"));
|
||||
await expect(ideation().convergeSession(session.id, candidate.id)).rejects.toThrow("induced handoff failure");
|
||||
create.mockRestore();
|
||||
const stored = await ideation().getSessionWithCandidates(session.id);
|
||||
expect(stored).toMatchObject({ status: "open", targetMissionId: undefined });
|
||||
expect(stored!.candidates[0]).toMatchObject({ selected: false, linkedMissionId: undefined });
|
||||
});
|
||||
|
||||
it("cascades candidates when deleting a session", async () => {
|
||||
const session = await ideation().createSession({ title: "Disposable" });
|
||||
await ideation().addCandidate(session.id, { content: "Disposable", origin: "human" });
|
||||
await ideation().deleteSession(session.id);
|
||||
expect(await ideation().getSession(session.id)).toBeUndefined();
|
||||
expect(await ideation().listCandidates(session.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
117
packages/core/src/async-ideation-store-queries.ts
Normal file
117
packages/core/src/async-ideation-store-queries.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { and, asc, eq, sql, type AnyColumn, type SQL } from "drizzle-orm";
|
||||
import * as schema from "./postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
|
||||
import type { IdeationCandidate, IdeationSession } from "./ideation-types.js";
|
||||
|
||||
export type IdeationQueryHandle = AsyncDataLayer["db"] | DbTransaction;
|
||||
|
||||
function projectPartition(): SQL<string> {
|
||||
return sql<string>`COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__')`;
|
||||
}
|
||||
|
||||
function projectScope(column: AnyColumn): SQL {
|
||||
return eq(column, projectPartition());
|
||||
}
|
||||
|
||||
function rowToSession(row: typeof schema.project.ideationSessions.$inferSelect): IdeationSession {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
prompt: row.prompt ?? undefined,
|
||||
status: row.status as IdeationSession["status"],
|
||||
targetMissionId: row.targetMissionId ?? undefined,
|
||||
targetFeatureId: row.targetFeatureId ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
convergedAt: row.convergedAt ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToCandidate(row: typeof schema.project.ideationCandidates.$inferSelect): IdeationCandidate {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.sessionId,
|
||||
content: row.content,
|
||||
origin: row.origin as IdeationCandidate["origin"],
|
||||
sourceRef: row.sourceRef ?? undefined,
|
||||
selected: row.selected === 1,
|
||||
linkedMissionId: row.linkedMissionId ?? undefined,
|
||||
linkedFeatureId: row.linkedFeatureId ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createIdeationSession(handle: IdeationQueryHandle, session: IdeationSession): Promise<IdeationSession> {
|
||||
await handle.insert(schema.project.ideationSessions).values({
|
||||
id: session.id, title: session.title, prompt: session.prompt ?? null, status: session.status,
|
||||
targetMissionId: session.targetMissionId ?? null, targetFeatureId: session.targetFeatureId ?? null,
|
||||
createdAt: session.createdAt, updatedAt: session.updatedAt, convergedAt: session.convergedAt ?? null,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getIdeationSession(handle: IdeationQueryHandle, id: string): Promise<IdeationSession | undefined> {
|
||||
const rows = await handle.select().from(schema.project.ideationSessions)
|
||||
.where(and(projectScope(schema.project.ideationSessions.projectId), eq(schema.project.ideationSessions.id, id))).limit(1);
|
||||
return rows[0] ? rowToSession(rows[0]) : undefined;
|
||||
}
|
||||
|
||||
export async function listIdeationSessions(handle: IdeationQueryHandle): Promise<IdeationSession[]> {
|
||||
const rows = await handle.select().from(schema.project.ideationSessions)
|
||||
.where(projectScope(schema.project.ideationSessions.projectId))
|
||||
.orderBy(asc(schema.project.ideationSessions.createdAt), asc(schema.project.ideationSessions.id));
|
||||
return rows.map(rowToSession);
|
||||
}
|
||||
|
||||
export async function createIdeationCandidate(handle: IdeationQueryHandle, candidate: IdeationCandidate): Promise<IdeationCandidate> {
|
||||
await handle.insert(schema.project.ideationCandidates).values({
|
||||
id: candidate.id, sessionId: candidate.sessionId, content: candidate.content, origin: candidate.origin,
|
||||
sourceRef: candidate.sourceRef ?? null, selected: candidate.selected ? 1 : 0,
|
||||
linkedMissionId: candidate.linkedMissionId ?? null, linkedFeatureId: candidate.linkedFeatureId ?? null,
|
||||
createdAt: candidate.createdAt, updatedAt: candidate.updatedAt,
|
||||
});
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function getIdeationCandidate(handle: IdeationQueryHandle, id: string): Promise<IdeationCandidate | undefined> {
|
||||
const rows = await handle.select().from(schema.project.ideationCandidates)
|
||||
.where(and(projectScope(schema.project.ideationCandidates.projectId), eq(schema.project.ideationCandidates.id, id))).limit(1);
|
||||
return rows[0] ? rowToCandidate(rows[0]) : undefined;
|
||||
}
|
||||
|
||||
export async function listIdeationCandidates(handle: IdeationQueryHandle, sessionId: string): Promise<IdeationCandidate[]> {
|
||||
const rows = await handle.select().from(schema.project.ideationCandidates)
|
||||
.where(and(projectScope(schema.project.ideationCandidates.projectId), eq(schema.project.ideationCandidates.sessionId, sessionId)))
|
||||
.orderBy(asc(schema.project.ideationCandidates.createdAt), asc(schema.project.ideationCandidates.id));
|
||||
return rows.map(rowToCandidate);
|
||||
}
|
||||
|
||||
export async function updateIdeationCandidate(handle: IdeationQueryHandle, candidate: IdeationCandidate): Promise<IdeationCandidate> {
|
||||
const rows = await handle.update(schema.project.ideationCandidates).set({
|
||||
content: candidate.content, origin: candidate.origin, sourceRef: candidate.sourceRef ?? null,
|
||||
selected: candidate.selected ? 1 : 0, linkedMissionId: candidate.linkedMissionId ?? null,
|
||||
linkedFeatureId: candidate.linkedFeatureId ?? null, updatedAt: candidate.updatedAt,
|
||||
}).where(and(projectScope(schema.project.ideationCandidates.projectId), eq(schema.project.ideationCandidates.id, candidate.id))).returning();
|
||||
if (!rows[0]) throw new Error(`Ideation candidate ${candidate.id} not found`);
|
||||
return rowToCandidate(rows[0]);
|
||||
}
|
||||
|
||||
export async function persistIdeationConvergence(handle: IdeationQueryHandle, session: IdeationSession, candidate: IdeationCandidate): Promise<void> {
|
||||
await handle.update(schema.project.ideationSessions).set({ status: session.status, targetMissionId: session.targetMissionId ?? null,
|
||||
targetFeatureId: session.targetFeatureId ?? null, updatedAt: session.updatedAt, convergedAt: session.convergedAt ?? null,
|
||||
}).where(and(projectScope(schema.project.ideationSessions.projectId), eq(schema.project.ideationSessions.id, session.id)));
|
||||
await updateIdeationCandidate(handle, candidate);
|
||||
}
|
||||
|
||||
export async function archiveIdeationSession(handle: IdeationQueryHandle, session: IdeationSession): Promise<IdeationSession> {
|
||||
const rows = await handle.update(schema.project.ideationSessions).set({ status: session.status, updatedAt: session.updatedAt })
|
||||
.where(and(projectScope(schema.project.ideationSessions.projectId), eq(schema.project.ideationSessions.id, session.id))).returning();
|
||||
if (!rows[0]) throw new Error(`Ideation session ${session.id} not found`);
|
||||
return rowToSession(rows[0]);
|
||||
}
|
||||
|
||||
export async function deleteIdeationSession(handle: IdeationQueryHandle, id: string): Promise<void> {
|
||||
await handle.delete(schema.project.ideationSessions)
|
||||
.where(and(projectScope(schema.project.ideationSessions.projectId), eq(schema.project.ideationSessions.id, id)));
|
||||
}
|
||||
138
packages/core/src/async-ideation-store.ts
Normal file
138
packages/core/src/async-ideation-store.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
|
||||
import { AsyncMissionStore } from "./async-mission-store.js";
|
||||
import { getFeature, getMilestone, getMission, getSlice } from "./async-mission-store-queries.js";
|
||||
import type {
|
||||
IdeationCandidate, IdeationCandidateCreateInput, IdeationCandidateUpdateInput,
|
||||
IdeationConvergeInput, IdeationSession, IdeationSessionCreateInput, IdeationSessionWithCandidates,
|
||||
} from "./ideation-types.js";
|
||||
import {
|
||||
archiveIdeationSession, createIdeationCandidate, createIdeationSession, deleteIdeationSession,
|
||||
getIdeationCandidate, getIdeationSession, listIdeationCandidates, listIdeationSessions,
|
||||
persistIdeationConvergence, updateIdeationCandidate,
|
||||
} from "./async-ideation-store-queries.js";
|
||||
|
||||
export interface IdeationStoreEvents {
|
||||
"session:created": [IdeationSession];
|
||||
"session:converged": [IdeationSession, IdeationCandidate];
|
||||
"session:archived": [IdeationSession];
|
||||
"candidate:created": [IdeationCandidate];
|
||||
"candidate:updated": [IdeationCandidate];
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL-backed bounded ideation sessions.
|
||||
*
|
||||
* FNXC:Ideation 2026-07-30-15:30:
|
||||
* Convergence is deliberately one `transactionImmediate` spanning the canonical
|
||||
* MissionStore handoff and ideation selection/linkage writes. Throwing from any
|
||||
* handoff step rolls back both domains, preventing an orphan Mission or a
|
||||
* falsely converged session.
|
||||
*/
|
||||
export class AsyncIdeationStore extends EventEmitter<IdeationStoreEvents> {
|
||||
private idSequence = 0;
|
||||
|
||||
constructor(private readonly layer: AsyncDataLayer, private readonly missionStore: AsyncMissionStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
private generateId(prefix: "IS" | "IC"): string {
|
||||
this.idSequence += 1;
|
||||
return `${prefix}-${Date.now().toString(36).toUpperCase()}-${this.idSequence.toString(36).toUpperCase().padStart(4, "0")}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
async createSession(input: IdeationSessionCreateInput): Promise<IdeationSession> {
|
||||
const title = input.title.trim();
|
||||
if (!title) throw new Error("Ideation session title is required");
|
||||
const now = new Date().toISOString();
|
||||
const session: IdeationSession = { id: this.generateId("IS"), title, prompt: input.prompt?.trim() || undefined, status: "open", createdAt: now, updatedAt: now };
|
||||
await this.layer.transactionImmediate(async (tx) => createIdeationSession(tx, session));
|
||||
this.emit("session:created", session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async getSession(id: string): Promise<IdeationSession | undefined> { return getIdeationSession(this.layer.db, id); }
|
||||
async listSessions(): Promise<IdeationSession[]> { return listIdeationSessions(this.layer.db); }
|
||||
|
||||
async getSessionWithCandidates(id: string): Promise<IdeationSessionWithCandidates | undefined> {
|
||||
const session = await this.getSession(id);
|
||||
return session ? { ...session, candidates: await this.listCandidates(id) } : undefined;
|
||||
}
|
||||
|
||||
async addCandidate(sessionId: string, input: IdeationCandidateCreateInput): Promise<IdeationCandidate> {
|
||||
const session = await this.getSession(sessionId);
|
||||
if (!session) throw new Error(`Ideation session ${sessionId} not found`);
|
||||
if (session.status !== "open") throw new Error(`Ideation session ${sessionId} is not open`);
|
||||
const content = input.content.trim();
|
||||
if (!content) throw new Error("Ideation candidate content is required");
|
||||
const now = new Date().toISOString();
|
||||
const candidate: IdeationCandidate = { id: this.generateId("IC"), sessionId, content, origin: input.origin, sourceRef: input.sourceRef?.trim() || undefined, selected: false, createdAt: now, updatedAt: now };
|
||||
await this.layer.transactionImmediate(async (tx) => createIdeationCandidate(tx, candidate));
|
||||
this.emit("candidate:created", candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async listCandidates(sessionId: string): Promise<IdeationCandidate[]> { return listIdeationCandidates(this.layer.db, sessionId); }
|
||||
|
||||
async updateCandidate(id: string, input: IdeationCandidateUpdateInput): Promise<IdeationCandidate> {
|
||||
const current = await getIdeationCandidate(this.layer.db, id);
|
||||
if (!current) throw new Error(`Ideation candidate ${id} not found`);
|
||||
const session = await this.getSession(current.sessionId);
|
||||
if (!session || session.status !== "open") throw new Error(`Ideation candidate ${id} cannot be changed after convergence`);
|
||||
const candidate: IdeationCandidate = { ...current, ...input, content: input.content === undefined ? current.content : input.content.trim(), sourceRef: input.sourceRef === undefined ? current.sourceRef : input.sourceRef?.trim() || undefined, updatedAt: new Date().toISOString() };
|
||||
if (!candidate.content) throw new Error("Ideation candidate content is required");
|
||||
const updated = await this.layer.transactionImmediate(async (tx) => updateIdeationCandidate(tx, candidate));
|
||||
this.emit("candidate:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async convergeSession(sessionId: string, candidateId: string, input: IdeationConvergeInput = {}): Promise<IdeationSessionWithCandidates> {
|
||||
const result = await this.layer.transactionImmediate(async (tx) => {
|
||||
const session = await getIdeationSession(tx, sessionId);
|
||||
if (!session) throw new Error(`Ideation session ${sessionId} not found`);
|
||||
if (session.status !== "open") throw new Error(`Ideation session ${sessionId} is already ${session.status}`);
|
||||
const candidate = await getIdeationCandidate(tx, candidateId);
|
||||
if (!candidate || candidate.sessionId !== sessionId) throw new Error(`Ideation candidate ${candidateId} does not belong to session ${sessionId}`);
|
||||
|
||||
let missionId = input.targetMissionId;
|
||||
if (missionId) {
|
||||
const mission = await getMission(tx, missionId);
|
||||
if (!mission) throw new Error(`Mission ${missionId} not found`);
|
||||
} else {
|
||||
const mission = await this.missionStore.createMission({ title: candidate.content.slice(0, 200), description: session.prompt ? `${session.prompt}\n\n${candidate.content}` : candidate.content }, tx);
|
||||
missionId = mission.id;
|
||||
}
|
||||
|
||||
if (input.targetFeatureId) {
|
||||
const feature = await getFeature(tx, input.targetFeatureId);
|
||||
if (!feature) throw new Error(`Feature ${input.targetFeatureId} not found`);
|
||||
const slice = await getSlice(tx, feature.sliceId);
|
||||
const milestone = slice ? await getMilestone(tx, slice.milestoneId) : undefined;
|
||||
if (!milestone || milestone.missionId !== missionId) throw new Error(`Feature ${input.targetFeatureId} does not belong to mission ${missionId}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const converged: IdeationSession = { ...session, status: "converged", targetMissionId: missionId, targetFeatureId: input.targetFeatureId, convergedAt: now, updatedAt: now };
|
||||
const selected: IdeationCandidate = { ...candidate, selected: true, linkedMissionId: missionId, linkedFeatureId: input.targetFeatureId, updatedAt: now };
|
||||
await persistIdeationConvergence(tx, converged, selected);
|
||||
return { session: converged, candidate: selected };
|
||||
});
|
||||
this.emit("session:converged", result.session, result.candidate);
|
||||
return { ...result.session, candidates: await this.listCandidates(sessionId) };
|
||||
}
|
||||
|
||||
async archiveSession(id: string): Promise<IdeationSession> {
|
||||
const session = await this.getSession(id);
|
||||
if (!session) throw new Error(`Ideation session ${id} not found`);
|
||||
const archived = await this.layer.transactionImmediate(async (tx) => archiveIdeationSession(tx, { ...session, status: "archived", updatedAt: new Date().toISOString() }));
|
||||
this.emit("session:archived", archived);
|
||||
return archived;
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
await this.layer.transactionImmediate(async (tx) => deleteIdeationSession(tx, id));
|
||||
}
|
||||
|
||||
/** Allows future canonical operations to join this store's write boundary. */
|
||||
async transactionImmediate<T>(callback: (tx: DbTransaction) => Promise<T>): Promise<T> { return this.layer.transactionImmediate(callback); }
|
||||
}
|
||||
@@ -203,9 +203,10 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
|
||||
// ════════════════ MISSION CRUD ════════════════
|
||||
async createMission(input: MissionCreateInput & { autopilotEnabled?: boolean }): Promise<Mission> {
|
||||
/* FNXC:Ideation 2026-07-30-15:30: Accept a caller transaction so ideation convergence and canonical Mission creation commit or roll back together. */
|
||||
async createMission(input: MissionCreateInput & { autopilotEnabled?: boolean }, handle: QueryHandle = this.db): Promise<Mission> {
|
||||
const now = new Date().toISOString();
|
||||
const mission = await createMission(this.db, {
|
||||
const mission = await createMission(handle, {
|
||||
id: this.generateId("M"),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
@@ -592,11 +593,11 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
|
||||
// ════════════════ MILESTONE OPS ════════════════
|
||||
async addMilestone(missionId: string, input: MilestoneCreateInput): Promise<Milestone> {
|
||||
const mission = await getMission(this.db, missionId);
|
||||
async addMilestone(missionId: string, input: MilestoneCreateInput, handle: QueryHandle = this.db): Promise<Milestone> {
|
||||
const mission = await getMission(handle, missionId);
|
||||
if (!mission) throw new Error(`Mission ${missionId} not found`);
|
||||
const now = new Date().toISOString();
|
||||
const existing = await listMilestones(this.db, missionId);
|
||||
const existing = await listMilestones(handle, missionId);
|
||||
const orderIndex = existing.length > 0 ? Math.max(...existing.map((m) => m.orderIndex)) + 1 : 0;
|
||||
const milestone: Milestone = {
|
||||
id: this.generateId("MS"),
|
||||
@@ -614,7 +615,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const created = await createMilestone(this.db, milestone);
|
||||
const created = await createMilestone(handle, milestone);
|
||||
this.emit("milestone:created", created);
|
||||
return created;
|
||||
}
|
||||
@@ -693,11 +694,11 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
|
||||
// ════════════════ SLICE OPS ════════════════
|
||||
async addSlice(milestoneId: string, input: SliceCreateInput): Promise<Slice> {
|
||||
const milestone = await getMilestone(this.db, milestoneId);
|
||||
async addSlice(milestoneId: string, input: SliceCreateInput, handle: QueryHandle = this.db): Promise<Slice> {
|
||||
const milestone = await getMilestone(handle, milestoneId);
|
||||
if (!milestone) throw new Error(`Milestone ${milestoneId} not found`);
|
||||
const now = new Date().toISOString();
|
||||
const existing = await listSlices(this.db, milestoneId);
|
||||
const existing = await listSlices(handle, milestoneId);
|
||||
const orderIndex = existing.length > 0 ? Math.max(...existing.map((s) => s.orderIndex)) + 1 : 0;
|
||||
const slice: Slice = {
|
||||
id: this.generateId("SL"),
|
||||
@@ -712,7 +713,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const created = await createSlice(this.db, slice);
|
||||
const created = await createSlice(handle, slice);
|
||||
this.emit("slice:created", created);
|
||||
return created;
|
||||
}
|
||||
@@ -802,8 +803,8 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
|
||||
// ════════════════ FEATURE OPS ════════════════
|
||||
async addFeature(sliceId: string, input: FeatureCreateInput): Promise<MissionFeature> {
|
||||
const slice = await getSlice(this.db, sliceId);
|
||||
async addFeature(sliceId: string, input: FeatureCreateInput, handle: QueryHandle = this.db): Promise<MissionFeature> {
|
||||
const slice = await getSlice(handle, sliceId);
|
||||
if (!slice) throw new Error(`Slice ${sliceId} not found`);
|
||||
const now = new Date().toISOString();
|
||||
const feature: MissionFeature = {
|
||||
@@ -819,7 +820,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
implementationAttemptCount: 0,
|
||||
validatorAttemptCount: 0,
|
||||
};
|
||||
const created = await createFeature(this.db, feature);
|
||||
const created = await createFeature(handle, feature);
|
||||
this.emit("feature:created", created);
|
||||
await this.recomputeSliceStatus(sliceId);
|
||||
await this.applyDerivedMilestoneAcceptanceCriteria(slice.milestoneId);
|
||||
|
||||
69
packages/core/src/ideation-types.ts
Normal file
69
packages/core/src/ideation-types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Persisted ideation domain types.
|
||||
*
|
||||
* FNXC:Ideation 2026-07-30-15:30:
|
||||
* An idea must remain a bounded, project-scoped session until an explicitly
|
||||
* selected candidate converges into the canonical Mission hierarchy. Persisted
|
||||
* linkage prevents a successful handoff from degrading into orphan prose.
|
||||
*/
|
||||
|
||||
export const IDEATION_SESSION_STATUSES = ["open", "converged", "archived"] as const;
|
||||
export type IdeationSessionStatus = (typeof IDEATION_SESSION_STATUSES)[number];
|
||||
|
||||
export const IDEATION_CANDIDATE_ORIGINS = ["agent", "human", "research"] as const;
|
||||
export type IdeationCandidateOrigin = (typeof IDEATION_CANDIDATE_ORIGINS)[number];
|
||||
|
||||
export interface IdeationSession {
|
||||
id: string;
|
||||
title: string;
|
||||
prompt?: string;
|
||||
status: IdeationSessionStatus;
|
||||
/** Canonical Mission selected or created when the session converges. */
|
||||
targetMissionId?: string;
|
||||
/** Optional canonical Feature selected as the handoff destination. */
|
||||
targetFeatureId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
convergedAt?: string;
|
||||
}
|
||||
|
||||
export interface IdeationCandidate {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
content: string;
|
||||
origin: IdeationCandidateOrigin;
|
||||
sourceRef?: string;
|
||||
selected: boolean;
|
||||
linkedMissionId?: string;
|
||||
linkedFeatureId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IdeationSessionCreateInput {
|
||||
title: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface IdeationCandidateCreateInput {
|
||||
content: string;
|
||||
origin: IdeationCandidateOrigin;
|
||||
sourceRef?: string;
|
||||
}
|
||||
|
||||
export interface IdeationCandidateUpdateInput {
|
||||
content?: string;
|
||||
origin?: IdeationCandidateOrigin;
|
||||
sourceRef?: string;
|
||||
}
|
||||
|
||||
export interface IdeationConvergeInput {
|
||||
/** Attach to an existing mission instead of creating a new Mission. */
|
||||
targetMissionId?: string;
|
||||
/** Optional existing Feature within the target mission. */
|
||||
targetFeatureId?: string;
|
||||
}
|
||||
|
||||
export interface IdeationSessionWithCandidates extends IdeationSession {
|
||||
candidates: IdeationCandidate[];
|
||||
}
|
||||
@@ -1625,6 +1625,9 @@ export type {
|
||||
export { MissionStore } from "./mission-store.js";
|
||||
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
|
||||
export { AsyncMissionStore } from "./async-mission-store.js";
|
||||
export { AsyncIdeationStore } from "./async-ideation-store.js";
|
||||
export { IDEATION_SESSION_STATUSES, IDEATION_CANDIDATE_ORIGINS } from "./ideation-types.js";
|
||||
export type { IdeationSessionStatus, IdeationCandidateOrigin, IdeationSession, IdeationCandidate, IdeationSessionCreateInput, IdeationCandidateCreateInput, IdeationCandidateUpdateInput, IdeationConvergeInput, IdeationSessionWithCandidates } from "./ideation-types.js";
|
||||
export { ACTIVE_GOAL_LIMIT, ActiveGoalLimitExceededError } from "./goal-types.js";
|
||||
export type { Goal, GoalCreateInput, GoalListFilter, GoalStatus, GoalUpdateInput } from "./goal-types.js";
|
||||
export { GoalStore } from "./goal-store.js";
|
||||
|
||||
67
packages/core/src/postgres/migrations/0022_ideation.sql
Normal file
67
packages/core/src/postgres/migrations/0022_ideation.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
FNXC:Ideation 2026-07-30-15:30:
|
||||
The universal project-ownership migration has already run on upgrades. These
|
||||
new tables therefore install its RLS/default/trigger contract locally, ensuring
|
||||
an ideation convergence can only link to Mission records in its own partition.
|
||||
*/
|
||||
CREATE TABLE IF NOT EXISTS project.ideation_sessions (
|
||||
project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true),
|
||||
id text NOT NULL,
|
||||
title text NOT NULL,
|
||||
prompt text,
|
||||
status text NOT NULL DEFAULT 'open',
|
||||
target_mission_id text,
|
||||
target_feature_id text,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
converged_at text,
|
||||
PRIMARY KEY (project_id, id),
|
||||
CONSTRAINT ideation_sessions_status_check CHECK (status IN ('open','converged','archived')),
|
||||
CONSTRAINT ideation_sessions_mission_fk FOREIGN KEY (project_id, target_mission_id)
|
||||
REFERENCES project.missions(project_id, id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ideation_sessions_feature_fk FOREIGN KEY (project_id, target_feature_id)
|
||||
REFERENCES project.mission_features(project_id, id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project.ideation_candidates (
|
||||
project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true),
|
||||
id text NOT NULL,
|
||||
session_id text NOT NULL,
|
||||
content text NOT NULL,
|
||||
origin text NOT NULL,
|
||||
source_ref text,
|
||||
selected integer NOT NULL DEFAULT 0,
|
||||
linked_mission_id text,
|
||||
linked_feature_id text,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
PRIMARY KEY (project_id, id),
|
||||
CONSTRAINT ideation_candidates_session_fk FOREIGN KEY (project_id, session_id)
|
||||
REFERENCES project.ideation_sessions(project_id, id) ON DELETE CASCADE,
|
||||
CONSTRAINT ideation_candidates_mission_fk FOREIGN KEY (project_id, linked_mission_id)
|
||||
REFERENCES project.missions(project_id, id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ideation_candidates_feature_fk FOREIGN KEY (project_id, linked_feature_id)
|
||||
REFERENCES project.mission_features(project_id, id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ideation_candidates_origin_check CHECK (origin IN ('agent','human','research')),
|
||||
CONSTRAINT ideation_candidates_selected_check CHECK (selected IN (0,1))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ideation_candidates_session ON project.ideation_candidates(project_id, session_id);
|
||||
|
||||
ALTER TABLE project.ideation_sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project.ideation_sessions FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project.ideation_candidates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project.ideation_candidates FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS fusion_project_isolation ON project.ideation_sessions;
|
||||
CREATE POLICY fusion_project_isolation ON project.ideation_sessions
|
||||
USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true))
|
||||
WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true));
|
||||
DROP POLICY IF EXISTS fusion_project_isolation ON project.ideation_candidates;
|
||||
CREATE POLICY fusion_project_isolation ON project.ideation_candidates
|
||||
USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true))
|
||||
WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true));
|
||||
DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.ideation_sessions;
|
||||
CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id ON project.ideation_sessions
|
||||
FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id();
|
||||
DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.ideation_candidates;
|
||||
CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id ON project.ideation_candidates
|
||||
FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id();
|
||||
@@ -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 = "0021";
|
||||
export const SCHEMA_BASELINE_VERSION = "0022";
|
||||
const INITIAL_SCHEMA_VERSION = "0000";
|
||||
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
|
||||
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
|
||||
@@ -107,6 +107,8 @@ export const BULK_COMPLETION_REFUSAL_AT_VERSION = "0018";
|
||||
export const TASK_PROPOSAL_CLAIM_VERSION = "0020";
|
||||
/** FNXC:ConfigVersioning 2026-07-18-00:00: existing clusters need immutable configuration history before write paths use it. */
|
||||
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";
|
||||
|
||||
/** Bookkeeping table for the fresh Drizzle migration history. */
|
||||
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
|
||||
@@ -215,6 +217,7 @@ const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0017_task_me
|
||||
const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0018_bulk_completion_refusal_at.sql");
|
||||
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");
|
||||
|
||||
/**
|
||||
* Ensure the migration bookkeeping table exists. Lives in the public schema so
|
||||
@@ -305,6 +308,7 @@ export async function applySchemaBaseline(
|
||||
const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION);
|
||||
const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION);
|
||||
const configurationRevisionsAlreadyApplied = applied.includes(CONFIGURATION_REVISIONS_VERSION);
|
||||
const ideationAlreadyApplied = applied.includes(IDEATION_SCHEMA_VERSION);
|
||||
let schemaChanged = false;
|
||||
|
||||
if (!baselineAlreadyApplied) {
|
||||
@@ -647,6 +651,18 @@ export async function applySchemaBaseline(
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Ideation 2026-07-30-15:30:
|
||||
Register the ideation migration explicitly. New SQL files are never auto-discovered,
|
||||
and upgrades must receive project-scoped session/candidate tables before the store opens.
|
||||
*/
|
||||
if (!ideationAlreadyApplied) {
|
||||
const migrationSql = await readFile(IDEATION_MIGRATION_PATH, "utf8");
|
||||
await tx.execute(sql.raw(migrationSql));
|
||||
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IDEATION_SCHEMA_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));
|
||||
|
||||
@@ -1404,6 +1404,53 @@ export const missionFeatures = projectSchema.table("mission_features", {
|
||||
foreignKey({ columns: [t.projectId, t.taskId], foreignColumns: [tasks.projectId, tasks.id] }).onDelete("set null"),
|
||||
]);
|
||||
|
||||
/*
|
||||
FNXC:Ideation 2026-07-30-15:30:
|
||||
Persist sessions and candidates under the same project partition as Missions.
|
||||
Composite project FKs make it impossible for convergence linkage to point into a
|
||||
foreign project's roadmap, while candidate cascade deletion keeps a deleted
|
||||
session from leaving unbounded brainstorm artifacts behind.
|
||||
*/
|
||||
export const ideationSessions = projectSchema.table("ideation_sessions", {
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
title: text("title").notNull(),
|
||||
prompt: text("prompt"),
|
||||
status: text("status").notNull().default("open"),
|
||||
targetMissionId: text("target_mission_id"),
|
||||
targetFeatureId: text("target_feature_id"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
convergedAt: text("converged_at"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
foreignKey({ columns: [t.projectId, t.targetMissionId], foreignColumns: [missions.projectId, missions.id] }).onDelete("restrict"),
|
||||
foreignKey({ columns: [t.projectId, t.targetFeatureId], foreignColumns: [missionFeatures.projectId, missionFeatures.id] }).onDelete("restrict"),
|
||||
check("ideation_sessions_status_check", sql`${t.status} IN ('open','converged','archived')`),
|
||||
]);
|
||||
|
||||
export const ideationCandidates = projectSchema.table("ideation_candidates", {
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
sessionId: text("session_id").notNull(),
|
||||
content: text("content").notNull(),
|
||||
origin: text("origin").notNull(),
|
||||
sourceRef: text("source_ref"),
|
||||
selected: integer("selected").notNull().default(0),
|
||||
linkedMissionId: text("linked_mission_id"),
|
||||
linkedFeatureId: text("linked_feature_id"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
foreignKey({ columns: [t.projectId, t.sessionId], foreignColumns: [ideationSessions.projectId, ideationSessions.id] }).onDelete("cascade"),
|
||||
foreignKey({ columns: [t.projectId, t.linkedMissionId], foreignColumns: [missions.projectId, missions.id] }).onDelete("restrict"),
|
||||
foreignKey({ columns: [t.projectId, t.linkedFeatureId], foreignColumns: [missionFeatures.projectId, missionFeatures.id] }).onDelete("restrict"),
|
||||
check("ideation_candidates_origin_check", sql`${t.origin} IN ('agent','human','research')`),
|
||||
check("ideation_candidates_selected_check", sql`${t.selected} IN (0, 1)`),
|
||||
index("idxIdeationCandidatesSession").on(t.projectId, t.sessionId),
|
||||
]);
|
||||
|
||||
export const missionEvents = projectSchema.table("mission_events", {
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
@@ -2089,7 +2136,7 @@ export const projectTableNames = [
|
||||
"experiment_session_records", "eval_runs", "eval_task_results", "eval_run_events",
|
||||
"secrets", "__meta", "missions", "branch_groups", "pull_requests",
|
||||
"pull_request_thread_state", "goals", "mission_goals", "goal_citations",
|
||||
"milestones", "slices", "mission_features", "mission_events", "plugins",
|
||||
"milestones", "slices", "mission_features", "ideation_sessions", "ideation_candidates", "mission_events", "plugins",
|
||||
"routines", "project_insights", "project_insight_runs", "project_insight_run_events",
|
||||
"todo_lists", "todo_items", "usage_events", "plugin_activations",
|
||||
"knowledge_pages", "deployments", "incidents", "ai_sessions", "messages",
|
||||
|
||||
@@ -68,6 +68,7 @@ import { ArchiveDatabase } from "./archive-db.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
|
||||
import { MissionStore } from "./mission-store.js";
|
||||
import { AsyncMissionStore } from "./async-mission-store.js";
|
||||
import { AsyncIdeationStore } from "./async-ideation-store.js";
|
||||
import { reconcileSoftDeletedColumnDriftAsync } from "./task-store/async-self-healing.js";
|
||||
import { PluginStore } from "./plugin-store.js";
|
||||
import { InsightStore } from "./insight-store.js";
|
||||
@@ -100,7 +101,7 @@ import { recordGoalCitationsImpl, insertTaskWithFtsRecoveryImpl2, assertTaskIdAv
|
||||
import { applyLegacyWorkflowStepOverridesImpl, archiveDbImpl, assertNoDependencyCycleImpl, atomicCreateTaskJsonImpl, buildActiveTaskDependencyLookupImpl, buildArchivedAgentLogFieldsImpl, buildTaskIdIntegrityFallbackReportImpl, createBranchGroupImpl, dbImpl, detectAndCacheTaskIdIntegrityReportImpl, findLiveDependentsImpl, findLiveLineageChildrenImpl, getLegacyWorkflowStepSnapshotImpl, getMalformedTaskMetadataReasonImpl, getMergeQueuedTaskIdsAsyncImpl, insertRunAuditEventRowImpl, insertTaskImpl, invokeTaskCreatedHookImpl, isTaskArchivedImpl, isTaskIdPresentInArchivedTasksTableImpl, logTaskCreateConflictImpl, maybeResolveTombstonedTaskIdImpl, mergeTaskIdIntegrityReportsImpl, optionalGroupIdSetImpl, patchTaskRowInTransactionImpl, readConfigFastImpl, readConfigImpl, readPromptForArchiveImpl, readTaskFromDbImpl, reconcileDistributedTaskIdStateOnOpenImpl, recordActivityFromListenerImpl, recordDependencyCycleRejectedAuditImpl, refreshTaskIdIntegrityReportImpl, resolveLocalNodeIdForTaskAllocationImpl, runTaskFtsWriteWithRecoveryImpl, scanAndRecordCitationsImpl, taskIdExistsAnywhereImpl, throwSoftDeletedWriteBlockedImpl, toBuiltInWorkflowStepImpl, trackDeferredTaskCreatedWorkImpl, upsertTaskImpl, withConfigLockImpl, withTaskLockImpl, withWorktreeAllocationLockImpl } from "./task-store/remaining-ops-5.js";
|
||||
import { claimNextToolFailureRetryImpl, clearNearDuplicateReferencesToFailSoftImpl, clearWorkflowRunStepInstancesAsyncImpl, clearWorkflowRunStepInstancesImpl, computeMovedSettingsTargetWorkflowIdsImpl, ensureBranchGroupForSourceImpl, ensurePrEntityForSourceImpl, findRecentTasksByContentFingerprintImpl, getActiveMergingTaskImpl, getActivePrEntityBySourceImpl, getBranchGroupByBranchNameImpl, getBranchGroupBySourceImpl, getBranchGroupImpl, getBranchProgressByTaskImpl, getMutationsForRunImpl, getPrEntityByNumberImpl, getPrEntityImpl, getPrThreadStateImpl, getTasksByAssignedAgentImpl, getWorkflowPromptOverridesAsyncImpl, getWorkflowSettingValuesAsyncImpl, getWorkflowSettingValuesImpl, getWorkflowSettingsProjectIdImpl, getWorkflowWorkItemImpl, insertCompletionHandoffWorkflowWorkAuditImpl, listActivePrEntitiesImpl, listBranchGroupsImpl, listPrThreadStatesImpl, listTasksByBranchGroupImpl, listWorkflowSettingValuesForProjectImpl, loadWorkflowRunBranchesImpl, loadWorkflowRunStepInstancesAsyncImpl, loadWorkflowRunStepInstancesImpl, markToolFailureRetryExhaustedAuditImpl, mergeCustomFieldPatchImpl, normalizeMergeRequestStateImpl, normalizeWorkflowWorkItemKindImpl, normalizeWorkflowWorkItemStateImpl, parseWorkflowPromptOverrideJsonImpl, recordPrThreadOutcomeImpl, resetAllStepsToPendingImpl, resetPromptCheckboxesImpl, resolveWorkflowMoveActorImpl, resolveWorkflowSettingDeclarationsImpl, saveWorkflowRunStepInstanceAsyncImpl, saveWorkflowRunStepInstanceImpl, transitionMergeRequestStateImpl, transitionWorkflowWorkItemSyncImpl, updateTaskImpl, updateWorkflowPromptOverridesImpl, upsertMergeRequestRecordImpl, workflowStateForMergeRequestStateImpl } from "./task-store/remaining-ops-6.js";
|
||||
import { addPrInfoImpl, addSteeringCommentImpl, archiveAllDoneImpl, cleanupStaleMergeQueueRowsImpl, clearCompletionHandoffAcceptedMarkerImpl, clearDoneTransientFieldsImpl, clearStaleExecutionStartBranchReferencesImpl, computeWorkflowColumnsGraduationReportImpl, deleteTaskCommentImpl, deleteTaskDocumentImpl, emitUsageEventImpl, enqueueMergeQueueImpl, getAgentLogCountImpl, getAgentLogsImpl, getArtifactImpl, getArtifactsImpl, getAttachmentImpl, getCompletionHandoffAcceptedMarkerImpl, getTaskDocumentImpl, getTaskDocumentRevisionsImpl, getTaskDocumentsImpl, insertArtifactRowImpl, linkGithubIssueImpl, listWorkflowWorkItemsForTaskSyncImpl, moveToDoneImpl, parseDependenciesFromPromptImpl, parseFileScopeFromPromptImpl, parseStepsFromPromptImpl, peekMergeQueueHeadImpl, peekMergeQueueImpl, readPreArchiveColumnFromTaskFileImpl, recordPluginActivationImpl, recordRunAuditEventBackendImpl, removePrInfoByNumberImpl, resolvePrimaryPrInfoImpl, resolveUnarchiveTargetColumnImpl, rewriteLineageChildrenForRemovalImpl, runGitCommandImpl, stopWatchingImpl, syncAgentTaskLinkOnReassignmentImpl, updateArtifactImpl, updateGithubTrackingImpl, updatePrInfoByNumberImpl, updateTaskCommentImpl, upsertPrInfoByNumberImpl, writeArtifactDataImpl } from "./task-store/remaining-ops-7.js";
|
||||
import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedMaterializedStepsImpl, consumePluginGateVerdictsImpl, getAgentLogsByTimeRangeImpl, getDatabaseHealthImpl, getDistributedTaskIdAllocatorImpl, getExperimentSessionStoreImpl, getInReviewDurationEventsImpl, getMissionStoreImpl, getPluginStoreImpl, getSecretsStoreImpl, getSettingsSyncImpl, getTaskMergedTaskIdsImpl, getTaskWorkflowSelectionImpl, getImportTranslationImpl, recordImportTranslationImpl, pruneImportTranslationsImpl, type ImportTranslationCacheKey, type ImportTranslationCacheEntry, getVerificationCacheHitImpl, getWorkflowDefinitionImpl, healthCheckImpl, importLegacyAgentLogsOnceImpl, insertWorkflowDefinitionSyncImpl, isCliAutonomyApprovedImpl, isPluginInstalledImpl, isWorkflowCliCommandApprovedImpl, listWorkflowDefinitionsImpl, materializeExplicitWorkflowStepsImpl, materializeWorkflowStepsImpl, migrateActiveArchivedTasksToArchiveDbImpl, migrateLegacyArchiveEntriesToArchiveDbImpl, nextWorkflowDefinitionIdImpl, occupantsByColumnForWorkflowImpl, parseWorkflowLayoutImpl, pruneAgentLogFilesImpl, purgeTaskWorkflowSelectionRowsImpl, readAllWorkflowDefinitionsImpl, readRawProjectSettingsImpl, recordPluginGateVerdictImpl, recordVerificationCachePassImpl, removeMaterializedSelectionImpl, resolvePluginWorkflowStepImpl, resolveTaskWorkflowIrSyncImpl, revokeCliAutonomyImpl, selectTaskWorkflowAndReconcileImpl, writeTaskWorkflowSelectionImpl, getTaskWorkflowSelectionAsyncImpl, } from "./task-store/remaining-ops-8.js";
|
||||
import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedMaterializedStepsImpl, consumePluginGateVerdictsImpl, getAgentLogsByTimeRangeImpl, getDatabaseHealthImpl, getDistributedTaskIdAllocatorImpl, getExperimentSessionStoreImpl, getInReviewDurationEventsImpl, getMissionStoreImpl, getIdeationStoreImpl, getPluginStoreImpl, getSecretsStoreImpl, getSettingsSyncImpl, getTaskMergedTaskIdsImpl, getTaskWorkflowSelectionImpl, getImportTranslationImpl, recordImportTranslationImpl, pruneImportTranslationsImpl, type ImportTranslationCacheKey, type ImportTranslationCacheEntry, getVerificationCacheHitImpl, getWorkflowDefinitionImpl, healthCheckImpl, importLegacyAgentLogsOnceImpl, insertWorkflowDefinitionSyncImpl, isCliAutonomyApprovedImpl, isPluginInstalledImpl, isWorkflowCliCommandApprovedImpl, listWorkflowDefinitionsImpl, materializeExplicitWorkflowStepsImpl, materializeWorkflowStepsImpl, migrateActiveArchivedTasksToArchiveDbImpl, migrateLegacyArchiveEntriesToArchiveDbImpl, nextWorkflowDefinitionIdImpl, occupantsByColumnForWorkflowImpl, parseWorkflowLayoutImpl, pruneAgentLogFilesImpl, purgeTaskWorkflowSelectionRowsImpl, readAllWorkflowDefinitionsImpl, readRawProjectSettingsImpl, recordPluginGateVerdictImpl, recordVerificationCachePassImpl, removeMaterializedSelectionImpl, resolvePluginWorkflowStepImpl, resolveTaskWorkflowIrSyncImpl, revokeCliAutonomyImpl, selectTaskWorkflowAndReconcileImpl, writeTaskWorkflowSelectionImpl, getTaskWorkflowSelectionAsyncImpl, } from "./task-store/remaining-ops-8.js";
|
||||
import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/task-commit-associations.js";
|
||||
import { addTaskCommentImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthImpl, resolveEffectiveWorkflowIdSyncImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/task-store-helpers.js";
|
||||
import { getTaskSelectClauseImpl2, createTaskPersistSerializationContextImpl, getTaskPersistValuesImpl, getTaskPatchDescriptorsImpl, normalizeTaskFromDiskImpl, writeTaskJsonFileImpl, rowToPrEntityImpl, generatePrEntityIdImpl, readTaskForMoveImpl, rowToMergeQueueEntryImpl, rowToMergeRequestRecordImpl, rowToCompletionHandoffMarkerImpl, rowToWorkflowWorkItemImpl, rowToRunAuditEventImpl } from "./task-store/task-row-mappers.js";
|
||||
@@ -389,6 +390,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return this.watcher !== null || this.pollInterval !== null;
|
||||
}
|
||||
public missionStore: MissionStore | AsyncMissionStore | null = null;
|
||||
public ideationStore: AsyncIdeationStore | null = null;
|
||||
public pluginStore: PluginStore | null = null;
|
||||
public insightStore: InsightStore | AsyncInsightStore | null = null;
|
||||
public researchStore: ResearchStore | AsyncResearchStore | null = null;
|
||||
@@ -2601,6 +2603,10 @@ Issue #2149 requires read-only type filtering to occur in the file-store before
|
||||
getMissionStore(): MissionStore | AsyncMissionStore {
|
||||
return getMissionStoreImpl(this);
|
||||
}
|
||||
|
||||
getIdeationStore(): AsyncIdeationStore {
|
||||
return getIdeationStoreImpl(this);
|
||||
}
|
||||
getPluginStore(): PluginStore {
|
||||
return getPluginStoreImpl(this);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ExperimentSessionStore } from "../experiment-session-store.js";
|
||||
import { MasterKeyManager } from "../master-key.js";
|
||||
import { MissionStore } from "../mission-store.js";
|
||||
import { AsyncMissionStore } from "../async-mission-store.js";
|
||||
import { AsyncIdeationStore } from "../async-ideation-store.js";
|
||||
import { type PluginGateVerdict } from "../plugin-gate-verdict.js";
|
||||
import { PluginStore } from "../plugin-store.js";
|
||||
import { SecretsStore } from "../secrets-store.js";
|
||||
@@ -969,6 +970,19 @@ export function getMissionStoreImpl(store: TaskStore): MissionStore | AsyncMissi
|
||||
return store.missionStore;
|
||||
}
|
||||
|
||||
export function getIdeationStoreImpl(store: TaskStore): AsyncIdeationStore {
|
||||
if (!store.ideationStore) {
|
||||
const layer = store.getAsyncLayer();
|
||||
if (!layer) throw new Error("IdeationStore is only available with the PostgreSQL AsyncDataLayer");
|
||||
const missionStore = store.getMissionStore();
|
||||
if (!(missionStore instanceof AsyncMissionStore)) {
|
||||
throw new Error("IdeationStore requires the PostgreSQL AsyncMissionStore");
|
||||
}
|
||||
store.ideationStore = new AsyncIdeationStore(layer, missionStore);
|
||||
}
|
||||
return store.ideationStore;
|
||||
}
|
||||
|
||||
export function getPluginStoreImpl(store: TaskStore): PluginStore {
|
||||
if (!store.pluginStore) {
|
||||
// PluginStore persists install/state rows in central DB, so it must use
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SystemStatsArea } from "./areas/SystemStatsArea";
|
||||
import { SystemControlsArea } from "./areas/SystemControlsArea";
|
||||
import { PluginManager } from "../PluginManager";
|
||||
import { MissionControlPanel } from "./MissionControlPanel";
|
||||
import { IdeationPanel } from "./IdeationPanel";
|
||||
import { CommandCenterControls } from "./CommandCenterControls";
|
||||
import { ReliabilityView } from "../ReliabilityView";
|
||||
import { NodesView } from "../NodesView";
|
||||
@@ -52,7 +53,8 @@ type SubViewId =
|
||||
| "plugins"
|
||||
| "nodes"
|
||||
| "reliability"
|
||||
| "mission-control";
|
||||
| "mission-control"
|
||||
| "ideation";
|
||||
|
||||
interface SubView {
|
||||
id: SubViewId;
|
||||
@@ -98,6 +100,7 @@ function useSubViews(nodesEnabled: boolean): SubView[] {
|
||||
...(nodesEnabled ? [{ id: "nodes" as const, label: t("commandCenter.tabs.nodes", "Nodes") }] : []),
|
||||
{ id: "reliability", label: t("commandCenter.tabs.reliability", "Reliability") },
|
||||
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
|
||||
{ id: "ideation", label: t("commandCenter.tabs.ideation", "Ideation") },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -669,6 +672,8 @@ export function CommandCenter({
|
||||
return <ReliabilityView projectId={projectId} />;
|
||||
case "mission-control":
|
||||
return <MissionControlPanel projectId={projectId} />;
|
||||
case "ideation":
|
||||
return <IdeationPanel projectId={projectId} />;
|
||||
default:
|
||||
return <PlaceholderTab tabId={activeTab} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.ideation-panel { display: flex; flex-direction: column; gap: var(--space-lg); }
|
||||
.ideation-panel__header { display: flex; align-items: flex-start; gap: var(--space-md); }
|
||||
.ideation-panel__header svg { color: var(--todo); }
|
||||
.ideation-panel__header h2, .ideation-panel__header p, .ideation-panel__detail h3 { margin: 0; }
|
||||
.ideation-panel__header p, .ideation-panel__sessions p, .ideation-panel__detail > p { color: var(--text-muted); }
|
||||
.ideation-panel__form { display: flex; gap: var(--space-sm); }
|
||||
.ideation-panel__form .input { flex: 1; }
|
||||
.ideation-panel__body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: var(--space-lg); }
|
||||
.ideation-panel__sessions, .ideation-panel__detail, .ideation-panel__candidates { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.ideation-panel__session { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-xs); color: var(--text); cursor: pointer; text-align: left; }
|
||||
.ideation-panel__session span, .ideation-panel__candidates small { color: var(--text-muted); }
|
||||
.ideation-panel__session.is-selected { outline: solid var(--accent); }
|
||||
.ideation-panel__candidates { margin: 0; padding: 0; list-style: none; }
|
||||
.ideation-panel__candidates li { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-sm); padding: var(--space-md); }
|
||||
.ideation-panel__candidates p { margin: 0; }
|
||||
.ideation-panel__handoff { color: var(--color-success); }
|
||||
.ideation-panel__error { color: var(--color-error); }
|
||||
@media (max-width: 768px) { .ideation-panel__body { grid-template-columns: minmax(0, 1fr); } .ideation-panel__form { flex-direction: column; } .ideation-panel__form .btn { inline-size: 100%; } }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Lightbulb } from "lucide-react";
|
||||
import type { IdeationCandidate, IdeationSessionWithCandidates } from "@fusion/core";
|
||||
import { withProjectId } from "../../api/legacy";
|
||||
import "./IdeationPanel.css";
|
||||
|
||||
async function ideationRequest<T>(path: string, projectId: string | undefined, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(withProjectId(`/api/ideation${path}`, projectId), {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text() || "Ideation request failed");
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Ideation 2026-07-30-15:30:
|
||||
Command Center gives humans the same bounded session → candidates → canonical
|
||||
Mission convergence operation agents use. The visible Mission ID is persisted
|
||||
handoff evidence, not a copied document or a separate dashboard-only roadmap.
|
||||
*/
|
||||
export function IdeationPanel({ projectId }: { projectId?: string }) {
|
||||
const [sessions, setSessions] = useState<IdeationSessionWithCandidates[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [title, setTitle] = useState("");
|
||||
const [candidate, setCandidate] = useState("");
|
||||
const [error, setError] = useState<string>();
|
||||
const selected = sessions.find((session) => session.id === selectedId);
|
||||
const refresh = async () => {
|
||||
const listed = await ideationRequest<Array<IdeationSessionWithCandidates>>("/", projectId);
|
||||
const hydrated = await Promise.all(listed.map((session) => ideationRequest<IdeationSessionWithCandidates>(`/${encodeURIComponent(session.id)}`, projectId)));
|
||||
setSessions(hydrated);
|
||||
setSelectedId((current) => current && hydrated.some((session) => session.id === current) ? current : hydrated[0]?.id);
|
||||
};
|
||||
useEffect(() => { void refresh().catch((reason) => setError(reason instanceof Error ? reason.message : String(reason))); }, [projectId]);
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault(); setError(undefined);
|
||||
try { const created = await ideationRequest<IdeationSessionWithCandidates>("/", projectId, { method: "POST", body: JSON.stringify({ title }) }); setTitle(""); await refresh(); setSelectedId(created.id); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
|
||||
};
|
||||
const addCandidate = async (event: FormEvent) => {
|
||||
event.preventDefault(); if (!selected || !candidate.trim()) return; setError(undefined);
|
||||
try { await ideationRequest(`/${encodeURIComponent(selected.id)}/candidates`, projectId, { method: "POST", body: JSON.stringify({ content: candidate, origin: "human" }) }); setCandidate(""); await refresh(); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
|
||||
};
|
||||
const converge = async (item: IdeationCandidate) => {
|
||||
if (!selected) return; setError(undefined);
|
||||
try { await ideationRequest(`/${encodeURIComponent(selected.id)}/converge`, projectId, { method: "POST", body: JSON.stringify({ candidateId: item.id }) }); await refresh(); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
|
||||
};
|
||||
return <section className="ideation-panel" aria-label="Persisted ideation">
|
||||
<header className="ideation-panel__header"><Lightbulb /><div><h2>Ideation</h2><p>Capture alternatives, then converge one into the Mission hierarchy.</p></div></header>
|
||||
<form className="ideation-panel__form" onSubmit={submit}><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Session title" aria-label="Session title" required /><button className="btn" type="submit">Start session</button></form>
|
||||
{error && <p className="ideation-panel__error" role="alert">{error}</p>}
|
||||
<div className="ideation-panel__body"><aside className="ideation-panel__sessions">{sessions.length ? sessions.map((session) => <button className={`card ideation-panel__session ${session.id === selected?.id ? "is-selected" : ""}`} type="button" onClick={() => setSelectedId(session.id)} key={session.id}>{session.title}<span>{session.status}</span></button>) : <p>No sessions yet.</p>}</aside>
|
||||
<div className="ideation-panel__detail">{selected ? <><h3>{selected.title}</h3>{selected.targetMissionId && <p className="ideation-panel__handoff">Converged to Mission <strong>{selected.targetMissionId}</strong></p>}{selected.status === "open" && <form className="ideation-panel__form" onSubmit={addCandidate}><input className="input" value={candidate} onChange={(event) => setCandidate(event.target.value)} placeholder="Divergent candidate" aria-label="Divergent candidate" required /><button className="btn" type="submit">Add candidate</button></form>}<ul className="ideation-panel__candidates">{selected.candidates.map((item) => <li className="card" key={item.id}><p>{item.content}</p><small>{item.origin}{item.sourceRef ? ` · ${item.sourceRef}` : ""}</small>{selected.status === "open" && <button className="btn" type="button" onClick={() => void converge(item)}>Converge</button>}</li>)}</ul></> : <p>Select or start a session.</p>}</div></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -1039,8 +1039,8 @@ describe("CommandCenter shell", () => {
|
||||
render(<CommandCenter />);
|
||||
const tablist = screen.getByRole("tablist");
|
||||
const tabs = within(tablist).getAllByRole("tab");
|
||||
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, GitLab, Signals, System, Plugins, Reliability, Mission Control.
|
||||
expect(tabs.length).toBe(15);
|
||||
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, GitLab, Signals, System, Plugins, Reliability, Mission Control, Ideation.
|
||||
expect(tabs.length).toBe(17);
|
||||
expect(screen.queryByTestId("command-center-tab-nodes")).toBeNull();
|
||||
// roving tabindex: exactly one tab is focusable.
|
||||
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
|
||||
@@ -1294,7 +1294,7 @@ describe("CommandCenter shell", () => {
|
||||
const overviewTab = screen.getByTestId("command-center-tab-overview");
|
||||
overviewTab.focus();
|
||||
fireEvent.keyDown(overviewTab, { key: "ArrowLeft" });
|
||||
const last = screen.getByTestId("command-center-tab-mission-control");
|
||||
const last = screen.getByTestId("command-center-tab-ideation");
|
||||
expect(last.getAttribute("aria-selected")).toBe("true");
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
@@ -879,6 +879,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
permissionPolicy: { rules: { task_agent_mutation: "block" } },
|
||||
});
|
||||
expect(createOptions.customTools.map((tool: { name: string }) => tool.name)).toContain("fn_mission_create");
|
||||
expect(createOptions.customTools.map((tool: { name: string }) => tool.name)).toContain("fn_ideation_converge");
|
||||
});
|
||||
|
||||
it("exposes fn_task_document_* tools to the chat agent when a task store is present", async () => {
|
||||
|
||||
@@ -94,6 +94,7 @@ vi.mock("@fusion/engine", () => ({
|
||||
createGetAgentConfigTool: vi.fn(() => ({})),
|
||||
createWebFetchTool: vi.fn(() => ({})),
|
||||
createGoalRetrievalTools: vi.fn(() => []),
|
||||
createIdeationTools: vi.fn(() => []),
|
||||
createMemoryTools: vi.fn(() => []),
|
||||
createResearchTools: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
import { createIdeationRouter } from "../ideation-routes.js";
|
||||
import { createIdeationTools } from "@fusion/engine";
|
||||
|
||||
/*
|
||||
FNXC:Ideation 2026-07-30-15:30:
|
||||
Route/tool parity is delegation parity: both surfaces must send converge to the
|
||||
same store operation, whose transaction owns Mission creation plus persisted
|
||||
selection/linkage. This prevents a dashboard-only handoff implementation.
|
||||
*/
|
||||
describe("ideation route/tool parity", () => {
|
||||
it("uses the same persisted convergence operation and returns its linkage", async () => {
|
||||
const session = { id: "IS-1", title: "Ideas", status: "converged", targetMissionId: "M-1", candidates: [{ id: "IC-1", selected: true, linkedMissionId: "M-1" }] };
|
||||
const ideation = { listSessions: vi.fn().mockResolvedValue([session]), getSessionWithCandidates: vi.fn().mockResolvedValue(session), createSession: vi.fn(), addCandidate: vi.fn(), convergeSession: vi.fn().mockResolvedValue(session) };
|
||||
const store = { getIdeationStore: () => ideation } as never;
|
||||
const app = express(); app.use(express.json()); app.use(createIdeationRouter(store));
|
||||
const route = await performRequest(app, "POST", "/IS-1/converge", JSON.stringify({ candidateId: "IC-1" }), { "content-type": "application/json" });
|
||||
const tool = createIdeationTools(store).find((item) => item.name === "fn_ideation_converge")!;
|
||||
const toolResult = await tool.execute("call", { sessionId: "IS-1", candidateId: "IC-1" });
|
||||
expect(route.status).toBe(200);
|
||||
expect(route.body).toMatchObject({ status: "converged", targetMissionId: "M-1" });
|
||||
expect(toolResult.details).toMatchObject({ targetMissionId: "M-1", session: { status: "converged" } });
|
||||
expect(ideation.convergeSession).toHaveBeenNthCalledWith(1, "IS-1", "IC-1", { targetMissionId: undefined, targetFeatureId: undefined });
|
||||
expect(ideation.convergeSession).toHaveBeenNthCalledWith(2, "IS-1", "IC-1", { targetMissionId: undefined, targetFeatureId: undefined });
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
createWebFetchTool,
|
||||
createGoalRetrievalTools,
|
||||
createMissionTools,
|
||||
createIdeationTools,
|
||||
createMemoryTools,
|
||||
createResearchTools,
|
||||
resolveMcpServersForStore,
|
||||
@@ -360,6 +361,7 @@ export interface ChatFusionToolsetOptions {
|
||||
}
|
||||
|
||||
const CHAT_MISSION_READ_TOOL_NAMES = new Set(["fn_mission_list", "fn_mission_show"]);
|
||||
const CHAT_IDEATION_READ_TOOL_NAMES = new Set(["fn_ideation_list", "fn_ideation_show"]);
|
||||
|
||||
async function createChatMissionGateContexts(
|
||||
taskStore: TaskStore | undefined,
|
||||
@@ -463,6 +465,8 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
|
||||
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: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),
|
||||
/* FNXC:ChatAgentTools 2026-07-15-00:00: Chat exposes memory retrieval only and respects the workspace memory-enabled setting; prompt-triggered persistent writes stay excluded without an action-gate context. */
|
||||
...createMemoryTools(rootDir, settings).filter((tool) => tool.name !== "fn_memory_append"),
|
||||
|
||||
50
packages/dashboard/src/ideation-routes.ts
Normal file
50
packages/dashboard/src/ideation-routes.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { Router } from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { badRequest, catchHandler, notFound } from "./api-error.js";
|
||||
import { getScopedStore as resolveScopedRequestStore } from "./routes/context.js";
|
||||
|
||||
/*
|
||||
FNXC:Ideation 2026-07-30-15:30:
|
||||
The human dashboard route delegates every operation to TaskStore's one persisted
|
||||
ideation store. Convergence therefore shares the same atomic Mission handoff as
|
||||
the engine tools instead of becoming a weaker UI-only persistence path.
|
||||
*/
|
||||
export function createIdeationRouter(store: TaskStore, options?: ServerOptions): Router {
|
||||
const router = Router();
|
||||
const requestContext = new AsyncLocalStorage<TaskStore>();
|
||||
const ideation = () => (requestContext.getStore() ?? store).getIdeationStore();
|
||||
|
||||
router.use(async (req, _res, next) => {
|
||||
try { requestContext.run(await resolveScopedRequestStore(req, store, options), next); }
|
||||
catch (error) { next(error); }
|
||||
});
|
||||
|
||||
router.get("/", catchHandler(async (_req, res) => res.json(await ideation().listSessions())));
|
||||
router.get("/:id", catchHandler(async (req, res) => {
|
||||
const session = await ideation().getSessionWithCandidates(String(req.params.id));
|
||||
if (!session) throw notFound("Ideation session not found");
|
||||
res.json(session);
|
||||
}));
|
||||
router.post("/", catchHandler(async (req, res) => {
|
||||
const { title, prompt } = req.body ?? {};
|
||||
if (typeof title !== "string") throw badRequest("title must be a non-empty string");
|
||||
if (prompt !== undefined && typeof prompt !== "string") throw badRequest("prompt must be a string");
|
||||
res.status(201).json(await ideation().createSession({ title, prompt }));
|
||||
}));
|
||||
router.post("/:id/candidates", catchHandler(async (req, res) => {
|
||||
const { content, origin, sourceRef } = req.body ?? {};
|
||||
if (typeof content !== "string" || !["agent", "human", "research"].includes(origin)) throw badRequest("content and a valid origin are required");
|
||||
if (sourceRef !== undefined && typeof sourceRef !== "string") throw badRequest("sourceRef must be a string");
|
||||
res.status(201).json(await ideation().addCandidate(String(req.params.id), { content, origin, sourceRef }));
|
||||
}));
|
||||
router.post("/:id/converge", catchHandler(async (req, res) => {
|
||||
const { candidateId, targetMissionId, targetFeatureId } = req.body ?? {};
|
||||
if (typeof candidateId !== "string") throw badRequest("candidateId is required");
|
||||
if (targetMissionId !== undefined && typeof targetMissionId !== "string") throw badRequest("targetMissionId must be a string");
|
||||
if (targetFeatureId !== undefined && typeof targetFeatureId !== "string") throw badRequest("targetFeatureId must be a string");
|
||||
res.json(await ideation().convergeSession(String(req.params.id), candidateId, { targetMissionId, targetFeatureId }));
|
||||
}));
|
||||
return router;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Router } from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import type { ServerOptions } from "../server.js";
|
||||
import { createMissionRouter } from "../mission-routes.js";
|
||||
import { createIdeationRouter } from "../ideation-routes.js";
|
||||
import { createInsightsRouter } from "../insights-routes.js";
|
||||
import { createEvalsRouter } from "../evals-routes.js";
|
||||
import { createResearchRouter } from "../research-routes.js";
|
||||
@@ -46,6 +47,7 @@ export function registerIntegratedRouters({
|
||||
// middleware resolves an explicit central-registry project id (request id →
|
||||
// registered launch project id) via the shared seam instead of the implicit
|
||||
// raw launch-dir store fallback.
|
||||
router.use("/ideation", createIdeationRouter(store, options));
|
||||
router.use("/insights", createInsightsRouter(store, options));
|
||||
router.use("/evals", createEvalsRouter(store, options));
|
||||
router.use("/research", createResearchRouter(store, options));
|
||||
|
||||
40
packages/engine/src/__tests__/agent-ideation-tools.test.ts
Normal file
40
packages/engine/src/__tests__/agent-ideation-tools.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createIdeationTools } from "../agent-tools.js";
|
||||
|
||||
const candidate = { id: "IC-1", sessionId: "IS-1", content: "Candidate", origin: "agent", selected: false };
|
||||
|
||||
describe("createIdeationTools", () => {
|
||||
it("exposes read, divergence, and atomic convergence operations", () => {
|
||||
const store = { getIdeationStore: vi.fn() } as never;
|
||||
expect(createIdeationTools(store).map((tool) => tool.name)).toEqual([
|
||||
"fn_ideation_list", "fn_ideation_show", "fn_ideation_start", "fn_ideation_diverge", "fn_ideation_converge",
|
||||
]);
|
||||
});
|
||||
|
||||
it("delegates convergence to the single persisted operation and returns linkage", async () => {
|
||||
const convergeSession = vi.fn().mockResolvedValue({ id: "IS-1", status: "converged", targetMissionId: "M-1", candidates: [{ ...candidate, selected: true, linkedMissionId: "M-1" }] });
|
||||
const store = { getIdeationStore: () => ({ convergeSession }) } as never;
|
||||
const tool = createIdeationTools(store).find((item) => item.name === "fn_ideation_converge")!;
|
||||
const result = await tool.execute("call", { sessionId: "IS-1", candidateId: "IC-1" });
|
||||
expect(convergeSession).toHaveBeenCalledWith("IS-1", "IC-1", { targetMissionId: undefined, targetFeatureId: undefined });
|
||||
expect(result.details).toMatchObject({ targetMissionId: "M-1", session: { status: "converged" } });
|
||||
});
|
||||
|
||||
it("records all divergent candidates with provenance", async () => {
|
||||
const addCandidate = vi.fn().mockResolvedValue(candidate);
|
||||
const store = { getIdeationStore: () => ({ addCandidate }) } as never;
|
||||
const tool = createIdeationTools(store).find((item) => item.name === "fn_ideation_diverge")!;
|
||||
const result = await tool.execute("call", { sessionId: "IS-1", candidates: [candidate, { ...candidate, id: "IC-2", origin: "research", sourceRef: "R-1" }] });
|
||||
expect(addCandidate).toHaveBeenCalledTimes(2);
|
||||
expect(result.details).toMatchObject({ candidates: [candidate, candidate] });
|
||||
});
|
||||
|
||||
it("returns structured failures for an empty or already-converged session", async () => {
|
||||
const convergeSession = vi.fn().mockRejectedValue(new Error("Ideation session IS-1 is already converged"));
|
||||
const store = { getIdeationStore: () => ({ convergeSession }) } as never;
|
||||
const tool = createIdeationTools(store).find((item) => item.name === "fn_ideation_converge")!;
|
||||
const result = await tool.execute("call", { sessionId: "IS-1", candidateId: "IC-missing" });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.details).toMatchObject({ error: expect.stringContaining("already converged") });
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,8 @@ describe("gating-classifications parity", () => {
|
||||
"fn_goal_list",
|
||||
"fn_goal_show",
|
||||
"fn_heartbeat_done",
|
||||
"fn_ideation_list",
|
||||
"fn_ideation_show",
|
||||
"fn_list_agents",
|
||||
"fn_memory_append",
|
||||
"fn_memory_get",
|
||||
@@ -202,6 +204,20 @@ describe("gating-classifications parity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies ideation reads and mutations in both policy paths", () => {
|
||||
for (const toolName of ["fn_ideation_list", "fn_ideation_show"]) {
|
||||
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
|
||||
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes(toolName)).toBe(true);
|
||||
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
|
||||
}
|
||||
for (const toolName of ["fn_ideation_start", "fn_ideation_diverge", "fn_ideation_converge"]) {
|
||||
expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(toolName)).toBe(true);
|
||||
expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has(toolName)).toBe(true);
|
||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: blockedPolicy })).toMatchObject({ category: "task_agent_mutation", disposition: "block" });
|
||||
expect(resolvePermanentAgentToolDecision({ toolName, args: {}, gating: { permissionPolicy: blockedPolicy } })).toMatchObject({ category: "task_agent_mutation", disposition: "block", recognized: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("governs fn_task_create as task_agent_mutation in both gate paths", () => {
|
||||
expect(READONLY_FN_TOOLS.has("fn_task_create")).toBe(false);
|
||||
expect(TASK_AGENT_MUTATION_TOOLS.has("fn_task_create")).toBe(true);
|
||||
|
||||
@@ -113,6 +113,8 @@ describe("permanent-agent-gating", () => {
|
||||
expect(classifyPermanentAgentToolCall("fn_task_assign").category).toBe("task_agent_mutation");
|
||||
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
|
||||
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
|
||||
expect(classifyPermanentAgentToolCall("fn_ideation_list")).toEqual({ category: "none", recognized: true });
|
||||
expect(classifyPermanentAgentToolCall("fn_ideation_converge")).toEqual({ category: "task_agent_mutation", recognized: true });
|
||||
expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true });
|
||||
expect(classifyPermanentAgentToolCall("fn_ask_question")).toEqual({ category: "none", recognized: true });
|
||||
expect(classifyPermanentAgentToolCall("fn_send_message")).toEqual({ category: "none", recognized: true });
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||
import { Type, type Static } from "@earendil-works/pi-ai";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createTaskAssignTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createMissionTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createTaskAssignTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createMissionTools, createIdeationTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import {
|
||||
resolveAgentInstructionsWithRatings,
|
||||
@@ -2512,6 +2512,7 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
|
||||
heartbeatTools.push(...createMissionTools(taskStore));
|
||||
heartbeatTools.push(...createIdeationTools(taskStore));
|
||||
heartbeatTools.push(...createGoalRetrievalTools(taskStore, { runContext }));
|
||||
heartbeatTools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
|
||||
heartbeatTools.push(createUpdateIdentityTool(this.store, agentId));
|
||||
@@ -3775,6 +3776,7 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
|
||||
tools.push(...createMissionTools(taskStore));
|
||||
tools.push(...createIdeationTools(taskStore));
|
||||
tools.push(...createGoalRetrievalTools(taskStore, { runContext, taskId }));
|
||||
tools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
|
||||
tools.push(createUpdateIdentityTool(this.store, agentId));
|
||||
|
||||
@@ -3495,6 +3495,73 @@ export function createMissionTools(store: TaskStore): ToolDefinition[] {
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Ideation 2026-07-30-15:30:
|
||||
These tools are the single agent-facing contract for bounded divergence and
|
||||
atomic convergence. The store owns the shared transaction with MissionStore;
|
||||
tools never recreate a Mission or persist a parallel prose handoff themselves.
|
||||
*/
|
||||
export const ideationStartParams = Type.Object({
|
||||
title: Type.String({ minLength: 1, description: "Short title for this bounded ideation session" }),
|
||||
prompt: Type.Optional(Type.String({ description: "Optional problem statement or framing prompt" })),
|
||||
});
|
||||
export const ideationDivergeParams = Type.Object({
|
||||
sessionId: Type.String({ description: "Ideation session ID" }),
|
||||
candidates: Type.Array(Type.Object({
|
||||
content: Type.String({ minLength: 1, description: "Candidate idea content" }),
|
||||
origin: Type.Union([Type.Literal("agent"), Type.Literal("human"), Type.Literal("research")]),
|
||||
sourceRef: Type.Optional(Type.String({ description: "Optional provenance reference" })),
|
||||
}), { minItems: 1, description: "One or more divergent candidates" }),
|
||||
});
|
||||
export const ideationShowParams = Type.Object({ id: Type.String({ description: "Ideation session ID" }) });
|
||||
export const ideationConvergeParams = Type.Object({
|
||||
sessionId: Type.String({ description: "Open ideation session ID" }),
|
||||
candidateId: Type.String({ description: "Explicitly selected candidate ID" }),
|
||||
targetMissionId: Type.Optional(Type.String({ description: "Existing Mission to attach to; omit to create one" })),
|
||||
targetFeatureId: Type.Optional(Type.String({ description: "Optional Feature in the target Mission" })),
|
||||
});
|
||||
|
||||
const ideationToolResult = (text: string, details: Record<string, unknown>, isError = false) => ({
|
||||
content: [{ type: "text" as const, text }], details, ...(isError ? { isError: true } : {}),
|
||||
});
|
||||
|
||||
/** Create the persisted ideation surface shared by executor, triage, heartbeat, and chat. */
|
||||
export function createIdeationTools(store: TaskStore): ToolDefinition[] {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const tool = (name: string, label: string, description: string, parameters: any, execute: (params: any) => Promise<ReturnType<typeof ideationToolResult>>): ToolDefinition => ({
|
||||
name, label, description, parameters,
|
||||
execute: async (_id, params: any) => {
|
||||
try { return await execute(params); }
|
||||
catch (error) { const message = error instanceof Error ? error.message : String(error); return ideationToolResult(`ERROR: ${message}`, { error: message }, true); }
|
||||
},
|
||||
});
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
return [
|
||||
tool("fn_ideation_list", "List Ideation Sessions", "List persisted ideation sessions.", Type.Object({}), async () => {
|
||||
const sessions = await store.getIdeationStore().listSessions();
|
||||
return ideationToolResult(sessions.length ? `Ideation sessions (${sessions.length})\n${sessions.map((session) => `- ${session.id}: ${session.title} (${session.status})`).join("\n")}` : "No ideation sessions yet.", { sessions, count: sessions.length });
|
||||
}),
|
||||
tool("fn_ideation_show", "Show Ideation Session", "Show one ideation session and its divergent candidates.", ideationShowParams, async ({ id }) => {
|
||||
const session = await store.getIdeationStore().getSessionWithCandidates(id);
|
||||
return session ? ideationToolResult(`${session.id}: ${session.title}`, { session }) : ideationToolResult(`Ideation session ${id} not found`, { code: "IDEATION_SESSION_NOT_FOUND", sessionId: id }, true);
|
||||
}),
|
||||
tool("fn_ideation_start", "Start Ideation", "Create a bounded persisted ideation session.", ideationStartParams, async ({ title, prompt }) => {
|
||||
const session = await store.getIdeationStore().createSession({ title, prompt });
|
||||
return ideationToolResult(`Started ${session.id}: ${session.title}`, { session });
|
||||
}),
|
||||
tool("fn_ideation_diverge", "Record Divergent Candidates", "Record one or more divergent candidates with provenance.", ideationDivergeParams, async ({ sessionId, candidates }) => {
|
||||
const ideation = store.getIdeationStore();
|
||||
const created = [];
|
||||
for (const candidate of candidates) created.push(await ideation.addCandidate(sessionId, candidate));
|
||||
return ideationToolResult(`Recorded ${created.length} candidate${created.length === 1 ? "" : "s"}`, { candidates: created });
|
||||
}),
|
||||
tool("fn_ideation_converge", "Converge Ideation", "Select a candidate and atomically create or attach its canonical Mission handoff.", ideationConvergeParams, async ({ sessionId, candidateId, targetMissionId, targetFeatureId }) => {
|
||||
const session = await store.getIdeationStore().convergeSession(sessionId, candidateId, { targetMissionId, targetFeatureId });
|
||||
return ideationToolResult(`Converged ${session.id} into Mission ${session.targetMissionId}`, { session, targetMissionId: session.targetMissionId, targetFeatureId: session.targetFeatureId });
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_reflect_on_performance` tool that asks the reflection service to
|
||||
* analyze recent agent performance and return actionable insights.
|
||||
|
||||
@@ -230,6 +230,7 @@ import {
|
||||
createMemoryTools,
|
||||
createGoalRetrievalTools,
|
||||
createMissionTools,
|
||||
createIdeationTools,
|
||||
createWebFetchTool,
|
||||
createReadMessagesTool,
|
||||
createReflectOnPerformanceTool,
|
||||
@@ -11670,6 +11671,7 @@ export class TaskExecutor {
|
||||
})
|
||||
: []),
|
||||
...createMissionTools(this.store),
|
||||
...createIdeationTools(this.store),
|
||||
...createGoalRetrievalTools(this.store, {
|
||||
runContext: {
|
||||
runId: engineRunContext.runId,
|
||||
|
||||
@@ -81,6 +81,10 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
|
||||
"fn_feature_link_task",
|
||||
"fn_feature_update",
|
||||
"fn_milestone_update",
|
||||
/* FNXC:Ideation 2026-07-30-15:30: Persisted divergence/convergence writes require both action and permanent-agent policy recognition. */
|
||||
"fn_ideation_start",
|
||||
"fn_ideation_diverge",
|
||||
"fn_ideation_converge",
|
||||
"fn_agent_stop",
|
||||
"fn_agent_start",
|
||||
] as const;
|
||||
@@ -177,6 +181,8 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([
|
||||
"fn_trait_list",
|
||||
"fn_mission_list",
|
||||
"fn_mission_show",
|
||||
"fn_ideation_list",
|
||||
"fn_ideation_show",
|
||||
"fn_list_agents",
|
||||
"fn_agent_show",
|
||||
"fn_agent_org_chart",
|
||||
@@ -235,6 +241,8 @@ export const COORDINATION_EXEMPT_TOOLS = [
|
||||
// FNXC:MissionToolGating 2026-07-30-10:31: Mission reads are safe coordination, but must be registered here as well as READONLY_FN_TOOLS so the action gate recognizes rather than silently defaulting them.
|
||||
"fn_mission_list",
|
||||
"fn_mission_show",
|
||||
"fn_ideation_list",
|
||||
"fn_ideation_show",
|
||||
"fn_list_agents",
|
||||
"fn_agent_show",
|
||||
"fn_agent_org_chart",
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
createWebFetchTool,
|
||||
createGoalRetrievalTools,
|
||||
createMissionTools,
|
||||
createIdeationTools,
|
||||
createMemoryTools,
|
||||
createResearchTools,
|
||||
createArtifactListTool,
|
||||
|
||||
@@ -172,6 +172,7 @@ import {
|
||||
createMemoryTools,
|
||||
createGoalRetrievalTools,
|
||||
createMissionTools,
|
||||
createIdeationTools,
|
||||
createResearchTools,
|
||||
createWebFetchTool,
|
||||
createTaskDocumentReadTool,
|
||||
@@ -1199,6 +1200,7 @@ export class TriageProcessor {
|
||||
})
|
||||
: []),
|
||||
...createMissionTools(this.store),
|
||||
...createIdeationTools(this.store),
|
||||
...createGoalRetrievalTools(this.store, {
|
||||
runContext: {
|
||||
runId: triageRunContext.runId,
|
||||
|
||||
Reference in New Issue
Block a user