feat(FN-2087): finalize canonical memory path migration
- Remove legacy .fusion/memory.md fallback references and normalize prompts/docs to .fusion/memory/MEMORY.md - Stop legacy mirror writes and fallback reads in core memory backend and project memory flows - Update engine worktree boundary checks and tests for canonical memory file handling - Align dashboard memory/settings surfaces and route tests with canonical memory behavior - Add model-favorites persistence test coverage for mission interview and new agent dialogs
This commit is contained in:
@@ -499,8 +499,8 @@ describe("buildAgentChatPrompt", () => {
|
||||
});
|
||||
|
||||
it("builds an identity-aware prompt with soul, memory, instructions, and project memory", async () => {
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
await writeFile(join(testDir, ".fusion", "memory.md"), "Project preference: avoid force pushes.");
|
||||
await mkdir(join(testDir, ".fusion", "memory"), { recursive: true });
|
||||
await writeFile(join(testDir, ".fusion", "memory", "MEMORY.md"), "Project preference: avoid force pushes.");
|
||||
|
||||
const agent = makeAgent({
|
||||
name: "Avery",
|
||||
|
||||
@@ -2417,7 +2417,7 @@ describe("buildExecutionPrompt", () => {
|
||||
} as any);
|
||||
expect(result).toContain("Execute this task.");
|
||||
expect(result).toContain("## Project Memory");
|
||||
expect(result).toContain(".fusion/memory/");
|
||||
expect(result).toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("excludes memory instructions when memoryEnabled: false", () => {
|
||||
@@ -2434,7 +2434,7 @@ describe("buildExecutionPrompt", () => {
|
||||
const result = buildExecutionPrompt(task, "/project", {} as any);
|
||||
expect(result).toContain("Execute this task.");
|
||||
expect(result).toContain("## Project Memory");
|
||||
expect(result).toContain(".fusion/memory/");
|
||||
expect(result).toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("includes selective memory write instruction for durable learnings at end of execution", () => {
|
||||
@@ -2456,22 +2456,22 @@ describe("buildExecutionPrompt", () => {
|
||||
const result = buildExecutionPrompt(task, "/project", {
|
||||
memoryEnabled: true,
|
||||
} as any);
|
||||
expect(result).toContain(".fusion/memory/");
|
||||
expect(result).toContain("`.fusion/memory/MEMORY.md`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memoryBackendType setting", () => {
|
||||
it("includes .fusion/memory/ guidance for file backend", () => {
|
||||
it("includes .fusion/memory/MEMORY.md for file backend", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/project", {
|
||||
memoryEnabled: true,
|
||||
memoryBackendType: "file",
|
||||
} as any);
|
||||
expect(result).toContain("## Project Memory");
|
||||
// Check that the Project Memory section contains .fusion/memory/ guidance
|
||||
// Check that the Project Memory section contains .fusion/memory/MEMORY.md
|
||||
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
|
||||
expect(memorySectionMatch).toBeTruthy();
|
||||
expect(memorySectionMatch![1]).toContain(".fusion/memory/");
|
||||
expect(memorySectionMatch![1]).toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("includes read-only wording for readonly backend without write directives in memory section", () => {
|
||||
@@ -2488,10 +2488,10 @@ describe("buildExecutionPrompt", () => {
|
||||
// Should NOT contain write/update directives in the memory section
|
||||
expect(memorySection).not.toMatch(/write.*memory|update.*memory/i);
|
||||
// Should NOT contain the specific file path in the memory section
|
||||
expect(memorySection).not.toContain(".fusion/memory.md");
|
||||
expect(memorySection).not.toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("does not include .fusion/memory.md in Project Memory section for qmd backend", () => {
|
||||
it("does not include .fusion/memory/MEMORY.md in Project Memory section for qmd backend", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/project", {
|
||||
memoryEnabled: true,
|
||||
@@ -2502,8 +2502,8 @@ describe("buildExecutionPrompt", () => {
|
||||
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
|
||||
expect(memorySectionMatch).toBeTruthy();
|
||||
const memorySection = memorySectionMatch![1];
|
||||
// QMD should NOT unconditionally reference .fusion/memory.md in the memory section
|
||||
expect(memorySection).not.toContain(".fusion/memory.md");
|
||||
// QMD should NOT unconditionally reference .fusion/memory/MEMORY.md in the memory section
|
||||
expect(memorySection).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(memorySection).toContain("memory_search");
|
||||
expect(memorySection).toContain("memory_get");
|
||||
});
|
||||
@@ -2519,8 +2519,8 @@ describe("buildExecutionPrompt", () => {
|
||||
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
|
||||
expect(memorySectionMatch).toBeTruthy();
|
||||
const memorySection = memorySectionMatch![1];
|
||||
// QMD should NOT contain .fusion/memory.md
|
||||
expect(memorySection).not.toContain(".fusion/memory.md");
|
||||
// QMD should NOT contain .fusion/memory/MEMORY.md
|
||||
expect(memorySection).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(memorySection).toContain("memory_search");
|
||||
// Contains "end of execution" write guidance
|
||||
expect(memorySection).toMatch(/end of execution/i);
|
||||
@@ -10620,7 +10620,7 @@ describe("buildExecutionPrompt", () => {
|
||||
|
||||
const prompt = buildExecutionPrompt(task, "/project");
|
||||
|
||||
expect(prompt).toContain(".fusion/memory/");
|
||||
expect(prompt).toContain(".fusion/memory/MEMORY.md");
|
||||
expect(prompt).toContain("memory");
|
||||
expect(prompt).toContain("durable");
|
||||
});
|
||||
|
||||
@@ -300,7 +300,7 @@ If the task's PROMPT.md includes a "Documentation Requirements" section listing
|
||||
You are running in an **isolated git worktree**. This means:
|
||||
|
||||
- **All code changes must be made inside the current worktree directory.** Do not modify files outside the worktree — the worktree is your isolated execution environment.
|
||||
- **Exception — Project memory:** You MAY read and write to files under .fusion/memory/ at the project root (MEMORY.md for durable learnings, YYYY-MM-DD.md for daily notes) to save durable project learnings.
|
||||
- **Exception — Project memory:** You MAY read and write to .fusion/memory/MEMORY.md at the project root to save durable project learnings (architecture patterns, conventions, pitfalls).
|
||||
- **Exception — Task attachments:** You MAY read files under .fusion/tasks/{taskId}/attachments/ at the project root for context screenshots and documents attached to this task.
|
||||
- **Shell commands** run inside the worktree by default. Avoid using cd to navigate outside the worktree.
|
||||
|
||||
@@ -4477,7 +4477,7 @@ ${reviewLevel >= 3 ? `After tests, also call review_step with type="code" for te
|
||||
You are running in an **isolated git worktree**. This means:
|
||||
|
||||
- **All code changes must be made inside the current worktree directory.** Do not modify files outside the worktree.
|
||||
- **Exception — Project memory:** You MAY read and write to files under \`.fusion/memory/\` at the project root (MEMORY.md for durable learnings, YYYY-MM-DD.md for daily notes).
|
||||
- **Exception — Project memory:** You MAY read and write to \`.fusion/memory/MEMORY.md\` at the project root to save durable project learnings.
|
||||
- **Exception — Task attachments:** You MAY read files under \`.fusion/tasks/{taskId}/attachments/\` at the project root for context.
|
||||
- **Shell commands** run inside the worktree by default. Avoid using \`cd\` to navigate outside the worktree.
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("worktree path boundary helpers", () => {
|
||||
expect(mockReadTool.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows project root .fusion/memory/ paths from worktree session", async () => {
|
||||
it("allows project root .fusion/memory/MEMORY.md from worktree session", async () => {
|
||||
const mockReadTool = {
|
||||
name: "read",
|
||||
label: "Read",
|
||||
@@ -172,10 +172,10 @@ describe("worktree path boundary helpers", () => {
|
||||
"/project",
|
||||
);
|
||||
|
||||
// Reading project root .fusion/memory.md (legacy) should be allowed
|
||||
const legacyResult = await (wrapped[0] as any).execute("call-1", { path: "/project/.fusion/memory.md" });
|
||||
// Reading project root .fusion/memory/MEMORY.md should be allowed
|
||||
const result = await (wrapped[0] as any).execute("call-1", { path: "/project/.fusion/memory/MEMORY.md" });
|
||||
expect(mockReadTool.execute).toHaveBeenCalled();
|
||||
expect(legacyResult).toEqual({ ok: true, content: [{ type: "text", text: "memory content" }] });
|
||||
expect(result).toEqual({ ok: true, content: [{ type: "text", text: "memory content" }] });
|
||||
|
||||
// Reading project root .fusion/memory/MEMORY.md should also be allowed
|
||||
mockReadTool.execute.mockClear();
|
||||
@@ -196,6 +196,28 @@ describe("worktree path boundary helpers", () => {
|
||||
expect(dreamsResult).toEqual({ ok: true, content: [{ type: "text", text: "memory content" }] });
|
||||
});
|
||||
|
||||
it("allows daily memory files under .fusion/memory from worktree session", async () => {
|
||||
const mockReadTool = {
|
||||
name: "read",
|
||||
label: "Read",
|
||||
description: "Read a file",
|
||||
parameters: {},
|
||||
execute: vi.fn().mockResolvedValue({ ok: true, content: [{ type: "text", text: "daily memory" }] }),
|
||||
};
|
||||
|
||||
const { wrapToolsWithBoundary } = await import("./pi.js");
|
||||
|
||||
const wrapped = wrapToolsWithBoundary(
|
||||
[mockReadTool as any],
|
||||
"/project/.worktrees/fn-001",
|
||||
"/project",
|
||||
);
|
||||
|
||||
const result = await (wrapped[0] as any).execute("call-1", { path: "/project/.fusion/memory/2026-04-19.md" });
|
||||
expect(mockReadTool.execute).toHaveBeenCalled();
|
||||
expect(result).toEqual({ ok: true, content: [{ type: "text", text: "daily memory" }] });
|
||||
});
|
||||
|
||||
it("allows task attachments from worktree session", async () => {
|
||||
const mockReadTool = {
|
||||
name: "read",
|
||||
|
||||
@@ -559,7 +559,7 @@ async function assertValidWorktreeSession(cwd: string, projectRoot: string): Pro
|
||||
* Check if a path is allowed to be accessed from a worktree session.
|
||||
* Rules:
|
||||
* - Paths inside the worktree are always allowed
|
||||
* - Project root .fusion/memory/ directory is allowed (MEMORY.md, YYYY-MM-DD.md, DREAMS.md)
|
||||
* - Project root .fusion/memory/* is allowed (for durable project learnings)
|
||||
* - Task attachments under .fusion/tasks/N/attachments/ are allowed (for reading context files)
|
||||
* - All other paths outside the worktree are rejected
|
||||
*
|
||||
@@ -580,9 +580,9 @@ function isWorktreeAllowedPath(worktreePath: string, projectRoot: string, reques
|
||||
return true; // Path is inside the worktree
|
||||
}
|
||||
|
||||
// Exception: project root `.fusion/memory/` directory for durable project learnings
|
||||
const relToProjectRoot = relative(projectRootResolved, requestedResolved);
|
||||
if (relToProjectRoot === ".fusion/memory.md" || relToProjectRoot.startsWith(".fusion/memory/")) {
|
||||
// Exception: project root `.fusion/memory/*` for durable project learnings
|
||||
const relToProjectRoot = relative(projectRootResolved, requestedResolved).replace(/\\/g, "/");
|
||||
if (/^\.fusion\/memory\/[^/]+\.md$/.test(relToProjectRoot)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -640,7 +640,7 @@ export function wrapToolsWithBoundary(
|
||||
ok: false,
|
||||
error: `Path "${relToProject}" is outside the worktree boundary. ` +
|
||||
`Coding agents can only modify files inside the current worktree. ` +
|
||||
`Exception: .fusion/memory/ directory (project root) and .fusion/tasks/*/attachments/* are permitted for reading.`,
|
||||
`Exception: .fusion/memory/* (project root) and .fusion/tasks/*/attachments/* are permitted for reading.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -483,7 +483,7 @@ describe("REVIEWER_SYSTEM_PROMPT", () => {
|
||||
expect(REVIEWER_SYSTEM_PROMPT).toContain("Worktree Boundary Review");
|
||||
expect(REVIEWER_SYSTEM_PROMPT).toContain("assigned task worktree");
|
||||
expect(REVIEWER_SYSTEM_PROMPT).toContain("blocking REVISE");
|
||||
expect(REVIEWER_SYSTEM_PROMPT).toContain(".fusion/memory/");
|
||||
expect(REVIEWER_SYSTEM_PROMPT).toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -633,7 +633,7 @@ describe("reviewStep — user comments in spec review", () => {
|
||||
expect(capturedPrompt).toContain("## Worktree Boundary");
|
||||
expect(capturedPrompt).toContain("Assigned task worktree: `/tmp/project/.worktrees/happy-robin`");
|
||||
expect(capturedPrompt).toContain("primary project checkout");
|
||||
expect(capturedPrompt).toContain(".fusion/memory/");
|
||||
expect(capturedPrompt).toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ For code reviews, verify that implementation changes are in the assigned task
|
||||
worktree. The review request includes the current worktree path. Inspect git
|
||||
state and recent commits from that worktree, and treat changes outside it as a
|
||||
blocking REVISE unless they are expected project-root state such as
|
||||
\`.fusion/memory/\` directory (MEMORY.md, YYYY-MM-DD.md, DREAMS.md), task attachments, or other explicitly documented
|
||||
\`.fusion/memory/MEMORY.md\`, task attachments, or other explicitly documented
|
||||
Fusion metadata. If you see edits or commits in the primary project checkout
|
||||
instead of the task worktree, call that out directly and ask the worker to move
|
||||
the changes into the assigned worktree.
|
||||
@@ -478,7 +478,7 @@ function buildReviewRequest(
|
||||
"",
|
||||
"## Worktree Boundary",
|
||||
`Assigned task worktree: \`${cwd}\``,
|
||||
"Verify that implementation changes are in this worktree. If you find changes or commits in the primary project checkout or any other path, issue REVISE unless the outside path is an expected project-root exception such as .fusion/memory/ (MEMORY.md, daily notes, dreams), task attachments, or explicitly documented Fusion metadata.",
|
||||
"Verify that implementation changes are in this worktree. If you find changes or commits in the primary project checkout or any other path, issue REVISE unless the outside path is an expected project-root exception such as .fusion/memory/MEMORY.md, task attachments, or explicitly documented Fusion metadata.",
|
||||
"",
|
||||
);
|
||||
if (baseline) {
|
||||
|
||||
@@ -320,7 +320,7 @@ describe("buildSpecificationPrompt", () => {
|
||||
});
|
||||
|
||||
describe("memoryBackendType setting", () => {
|
||||
it("includes .fusion/memory.md for file backend", () => {
|
||||
it("includes .fusion/memory/MEMORY.md for file backend", () => {
|
||||
const settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
@@ -336,7 +336,7 @@ describe("buildSpecificationPrompt", () => {
|
||||
settings,
|
||||
);
|
||||
expect(prompt).toContain("## Project Memory");
|
||||
expect(prompt).toContain(".fusion/memory.md");
|
||||
expect(prompt).toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("includes read-only wording for readonly backend without write directives", () => {
|
||||
@@ -358,10 +358,10 @@ describe("buildSpecificationPrompt", () => {
|
||||
// Should NOT contain write/update directives
|
||||
expect(prompt).not.toMatch(/write.*memory|update.*memory/i);
|
||||
// Should NOT contain the specific file path
|
||||
expect(prompt).not.toContain(".fusion/memory.md");
|
||||
expect(prompt).not.toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("does not include .fusion/memory.md for qmd backend", () => {
|
||||
it("does not include .fusion/memory/MEMORY.md for qmd backend", () => {
|
||||
const settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
@@ -377,8 +377,8 @@ describe("buildSpecificationPrompt", () => {
|
||||
settings,
|
||||
);
|
||||
expect(prompt).toContain("## Project Memory");
|
||||
// QMD should NOT unconditionally reference .fusion/memory.md
|
||||
expect(prompt).not.toContain(".fusion/memory.md");
|
||||
// QMD should NOT unconditionally reference .fusion/memory/MEMORY.md
|
||||
expect(prompt).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(prompt).toContain("memory_search");
|
||||
expect(prompt).toContain("memory_get");
|
||||
});
|
||||
@@ -399,8 +399,8 @@ describe("buildSpecificationPrompt", () => {
|
||||
settings,
|
||||
);
|
||||
expect(prompt).toContain("## Project Memory");
|
||||
// QMD should NOT contain .fusion/memory.md
|
||||
expect(prompt).not.toContain(".fusion/memory.md");
|
||||
// QMD should NOT contain .fusion/memory/MEMORY.md
|
||||
expect(prompt).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(prompt).toContain("memory_search");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user