FN-5851: fix goal tool store resolution

Resolve goal tool lookups to the canonical project store from Fusion worktree directories.

- recognize both legacy .worktrees and .fusion/worktrees paths when resolving the project root for pi extensions
- add CLI coverage proving fn_goal_list and fn_goal_show return dashboard-created goals from a Fusion worktree cwd
- extend core worktree-resolution tests and keep the branch conflict recovery test worktree path isolated in tmpdir
- add a patch changeset for the published CLI fix

Files changed:
 .changeset/fn-5851-goal-store-resolution.md        |   5 +
 packages/cli/src/__tests__/goal-store-resolution.test.ts    | 104 +++++++++++++++++++++
 packages/core/src/__tests__/pi-extensions.test.ts  |  14 ++-
 packages/core/src/pi-extensions.ts                 |  12 ++-
 packages/engine/src/__tests__/branch-conflicts-recovery.test.ts    |   2 +-
 5 files changed, 130 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-5851

Fusion-Task-Lineage: ec4c8239-b953-4df7-9f72-02e17e4b0851
This commit is contained in:
gsxdsm
2026-06-01 23:39:20 -07:00
parent c60dae1cb9
commit 7d1708f023
5 changed files with 130 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `fn_goal_list` and `fn_goal_show` so tool calls made from Fusion worktree directories resolve the canonical project database and return goals created through the dashboard UI.

View File

@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore, getProjectRootFromWorktree } from "@fusion/core";
import kbExtension from "../extension.js";
interface RegisteredTool {
name: string;
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
return {
registerTool(def: RegisteredTool) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
describe("extension goal tools store resolution", () => {
let rootDir: string;
let worktreeCwd: string;
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "kb-goal-resolution-"));
await mkdir(join(rootDir, ".fusion"), { recursive: true });
const worktreeRoot = join(rootDir, ".fusion", "worktrees", "FN-5851");
await mkdir(join(worktreeRoot, ".fusion"), { recursive: true });
worktreeCwd = join(worktreeRoot, "packages", "cli");
await mkdir(worktreeCwd, { recursive: true });
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
it("returns canonical project goals when invoked from a .fusion/worktrees cwd", async () => {
expect(getProjectRootFromWorktree(worktreeCwd)).toBe(rootDir);
const store = new TaskStore(rootDir);
await store.init();
const goal = store.getGoalStore().createGoal({
title: "Canonical goal",
description: "Created in the project root store",
});
const api = createMockAPI();
kbExtension(api);
const listTool = api.tools.get("fn_goal_list");
const showTool = api.tools.get("fn_goal_show");
expect(listTool).toBeDefined();
expect(showTool).toBeDefined();
const listResult = await listTool!.execute(
"goal-list-worktree",
{ status: "active" },
undefined,
undefined,
{ cwd: worktreeCwd },
);
expect(listResult.isError).toBeUndefined();
expect(listResult.details.goals).toEqual([
expect.objectContaining({
id: goal.id,
title: "Canonical goal",
description: "Created in the project root store",
status: "active",
}),
]);
const showResult = await showTool!.execute(
"goal-show-worktree",
{ id: goal.id },
undefined,
undefined,
{ cwd: worktreeCwd },
);
expect(showResult.isError).toBeUndefined();
expect(showResult.details.goal).toMatchObject({
id: goal.id,
title: "Canonical goal",
description: "Created in the project root store",
status: "active",
});
store.close();
});
});

View File

@@ -8,11 +8,15 @@ describe("getProjectRootFromWorktree", () => {
it("detects POSIX worktree paths", () => {
expect(getProjectRootFromWorktree("/repo/.worktrees/fn-001")).toBe("/repo");
expect(getProjectRootFromWorktree("/repo/.worktrees/fn-001/src/file.ts")).toBe("/repo");
expect(getProjectRootFromWorktree("/repo/.fusion/worktrees/fn-001")).toBe("/repo");
expect(getProjectRootFromWorktree("/repo/.fusion/worktrees/fn-001/src/file.ts")).toBe("/repo");
});
it("detects Windows worktree paths", () => {
expect(getProjectRootFromWorktree("C:\\repo\\.worktrees\\fn-001")).toBe("C:\\repo");
expect(getProjectRootFromWorktree("C:\\repo\\.worktrees\\fn-001\\src\\file.ts")).toBe("C:\\repo");
expect(getProjectRootFromWorktree("C:\\repo\\.fusion\\worktrees\\fn-001")).toBe("C:\\repo");
expect(getProjectRootFromWorktree("C:\\repo\\.fusion\\worktrees\\fn-001\\src\\file.ts")).toBe("C:\\repo");
});
it("supports configured candidate worktrees dir paths", () => {
@@ -43,10 +47,14 @@ describe("resolvePiExtensionProjectRoot", () => {
try {
mkdirSync(join(root, ".fusion"), { recursive: true });
mkdirSync(join(root, ".worktrees", "feature", ".fusion"), { recursive: true });
const cwd = join(root, ".worktrees", "feature", "sub");
mkdirSync(cwd, { recursive: true });
mkdirSync(join(root, ".fusion", "worktrees", "feature", ".fusion"), { recursive: true });
const legacyCwd = join(root, ".worktrees", "feature", "sub");
const fusionCwd = join(root, ".fusion", "worktrees", "feature", "sub");
mkdirSync(legacyCwd, { recursive: true });
mkdirSync(fusionCwd, { recursive: true });
expect(resolvePiExtensionProjectRoot(cwd)).toBe(root);
expect(resolvePiExtensionProjectRoot(legacyCwd)).toBe(root);
expect(resolvePiExtensionProjectRoot(fusionCwd)).toBe(root);
} finally {
rmSync(root, { recursive: true, force: true });
}

View File

@@ -40,9 +40,15 @@ export function getProjectRootFromWorktree(
cwd: string,
opts?: { worktreesDirCandidates?: string[] },
): string | null {
const legacyMatch = cwd.match(/^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/]|$)/);
if (legacyMatch) {
return legacyMatch[1]!;
const knownWorktreePatterns = [
/^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/]|$)/,
/^(.+?)[\\/]\.fusion[\\/]worktrees[\\/][^\\/]+(?:[\\/]|$)/,
];
for (const pattern of knownWorktreePatterns) {
const match = cwd.match(pattern);
if (match) {
return match[1]!;
}
}
for (const candidate of opts?.worktreesDirCandidates ?? []) {

View File

@@ -232,7 +232,7 @@ describe("branch contamination recovery classification", () => {
it("reanchors without checkout -B when detached worktree is already at base on bound branch", async () => {
const { repoDir, baseSha } = await setupRepo();
const secondaryWorktree = path.join(repoDir, "../feature-secondary");
const secondaryWorktree = path.join(tmpdir(), `${path.basename(repoDir)}-feature-secondary`);
await run(`git worktree add --detach ${JSON.stringify(secondaryWorktree)} ${baseSha}`, repoDir);
dirs.push(secondaryWorktree);