diff --git a/.changeset/plan-file-finalize.md b/.changeset/plan-file-finalize.md new file mode 100644 index 00000000000..378f48ea908 --- /dev/null +++ b/.changeset/plan-file-finalize.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Report the plan file that was actually saved in Plan mode: point the "Plan is ready" link, the follow-up prompt, and the new-session handoff at the real file instead of a wrongly generated name, and fail plan_exit with a clear error when no plan was written. diff --git a/packages/opencode/src/kilocode/plan-file.ts b/packages/opencode/src/kilocode/plan-file.ts index 2f10868e6f5..5e21b8eff9b 100644 --- a/packages/opencode/src/kilocode/plan-file.ts +++ b/packages/opencode/src/kilocode/plan-file.ts @@ -1,5 +1,6 @@ import path from "path" import type { MessageV2 } from "@/session/message-v2" +import type { Info as SessionInfo } from "@/session/session" import { containsPath, type InstanceContext } from "@/project/instance-context" import { Filesystem } from "@/util/filesystem" @@ -19,9 +20,70 @@ export namespace PlanFile { const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree const full = path.isAbsolute(file) ? path.normalize(file) : path.resolve(root, file) if (!containsPath(full, ctx)) return + // may not exist yet, but an existing non-file is never the plan + const existing = Filesystem.stat(full) + if (existing && !existing.isFile()) return return full } + // Newest of: the exact target, or a sibling matching the session's generated-name + // pattern. Both compete on mtime so a stale exact-path guess can't beat a fresher + // sibling written in a later refinement round. + async function saved(file: string, info: SessionInfo) { + const dir = path.dirname(file) + const base = `${info.time.created}-` + const siblings = (await Filesystem.isDir(dir)) + ? Array.from(new Bun.Glob(`${base}*.md`).scanSync({ cwd: dir, onlyFiles: true })).map((item) => path.join(dir, item)) + : [] + const items = siblings.includes(file) ? siblings : [...siblings, file] + + const found = items + .flatMap((item) => { + const stat = Filesystem.stat(item) + return stat?.isFile() ? [{ item, stat }] : [] + }) + .sort((a, b) => Number(b.stat.mtimeMs) - Number(a.stat.mtimeMs) || a.item.localeCompare(b.item))[0] + + return found?.item + } + + const PLANNERS = new Set(["plan", "architect"]) + + // Intentionally narrow — a false match here finalizes the wrong file. + function planWrite(part: MessageV2.WithParts["parts"][number]): string | undefined { + if (part.type !== "tool" || part.state.status !== "completed") return + if (part.tool !== "write" && part.tool !== "edit") return + const file = (part.state.input ?? {})["filePath"] + if (typeof file !== "string" || !file.toLowerCase().endsWith(".md")) return + return file + } + + // Newest .md written by a planning agent (or `agent`, covering custom architect slugs). + async function written(messages: MessageV2.WithParts[], target: string, ctx: InstanceContext, agent?: string) { + const dir = path.dirname(target) + const files = messages + .filter((m) => PLANNERS.has(m.info.agent?.toLowerCase() ?? "") || (!!agent && m.info.agent === agent)) + .flatMap((m) => m.parts) + .flatMap((part) => planWrite(part) ?? []) + for (const item of files.reverse()) { + const full = path.isAbsolute(item) ? path.normalize(item) : path.resolve(ctx.directory, item) + // dir check admits the canonical plan dir, outside the worktree for non-git projects + if (!containsPath(full, ctx) && !Filesystem.contains(dir, full)) continue + if (await Filesystem.exists(full)) return full + } + } + + /** The plan file actually on disk: exact target, else generated-name sibling, else last plan write. */ + export async function locate( + target: string, + messages: MessageV2.WithParts[], + info: SessionInfo, + ctx: InstanceContext, + agent?: string, + ) { + return (await saved(target, info)) ?? (await written(messages, target, ctx, agent)) + } + export function display(file: string, ctx: InstanceContext) { const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree if (Filesystem.contains(root, file)) return path.relative(root, file) || file diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 2bc91c18ea6..e65c4cfc1a7 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -223,6 +223,15 @@ export namespace PlanFollowup { return input } + async function locatePlan(sessionID: SessionID, messages: MessageV2.WithParts[]) { + const ctx = Instance.current + const session = await PlanFollowupRuntime.session((svc) => svc.get(sessionID)) + const target = PlanFile.resolve(PlanFile.latest(messages), ctx) ?? Session.plan(session, ctx) + const agent = messages.findLast((m) => m.info.role === "user")?.info.agent + const file = await PlanFile.locate(target, messages, session, ctx, agent) + return { target, file } + } + async function resolvePlan(input: { assistant?: MessageV2.WithParts messages: MessageV2.WithParts[] @@ -243,9 +252,11 @@ export namespace PlanFollowup { if (text) return text // Fall back to plan file on disk - const session = await PlanFollowupRuntime.session((svc) => svc.get(SessionID.make(input.sessionID))) - const file = - PlanFile.resolve(PlanFile.latest(input.messages), Instance.current) ?? Session.plan(session, Instance.current) + const { target, file } = await locatePlan(input.sessionID, input.messages) + if (!file) { + log.warn("resolvePlan: no saved plan file found", { sessionID: input.sessionID, target }) + return "" + } const plan = await Bun.file(file) .text() .catch(() => "") @@ -522,7 +533,7 @@ export namespace PlanFollowup { if (answer === ANSWER_NEW_SESSION) { Telemetry.trackPlanFollowup(input.sessionID, "new_session") const ctx = Instance.current - const file = PlanFile.resolve(PlanFile.latest(input.messages), ctx) + const { file } = await locatePlan(input.sessionID, input.messages) await startNew({ sessionID: input.sessionID, file: file ? PlanFile.display(file, ctx) : undefined, diff --git a/packages/opencode/src/kilocode/tool/plan.ts b/packages/opencode/src/kilocode/tool/plan.ts index 090e7d8f59f..2b6e9d5958f 100644 --- a/packages/opencode/src/kilocode/tool/plan.ts +++ b/packages/opencode/src/kilocode/tool/plan.ts @@ -28,7 +28,25 @@ export const PlanExitTool = Tool.define( Effect.gen(function* () { const instance = yield* InstanceState.context const info = yield* session.get(ctx.sessionID) - const file = PlanFile.resolve(params.path, instance) ?? Session.plan(info, instance) + // resolved may be undefined even for a legit path (e.g. the non-git + // global plans dir), so still fall through to locate()'s recovery. + const resolved = params.path ? PlanFile.resolve(params.path, instance) : undefined + const target = resolved ?? Session.plan(info, instance) + // fetch fresh messages so written() sees a write from this same turn + const messages = yield* session.messages({ sessionID: ctx.sessionID }) + const file = yield* Effect.promise(() => PlanFile.locate(target, messages, info, instance, ctx.agent)) + if (!file) { + const plan = PlanFile.display(target, instance) + const rejected = params.path && !resolved + const hint = rejected + ? `The path "${params.path}" you passed can't be used directly — it's outside the project, or it's a directory rather than a file. ` + : "" + return yield* Effect.fail( + new Error( + `Plan file not found at ${plan}. ${hint}Write the plan file first, or call plan_exit with the exact path of the file you wrote.`, + ), + ) + } const plan = PlanFile.display(file, instance) return { title: "Planning complete", diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 2e0931e780a..184f77438a7 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -23,7 +23,13 @@ export async function isDir(p: string): Promise { } export function stat(p: string): ReturnType | undefined { - return statSync(p, { throwIfNoEntry: false }) ?? undefined + // kilocode_change start - also treat ENOTDIR/EACCES as absent, every caller expects undefined + try { + return statSync(p, { throwIfNoEntry: false }) ?? undefined + } catch { + return undefined + } + // kilocode_change end } export async function statAsync(p: string): Promise | undefined> { diff --git a/packages/opencode/test/kilocode/plan-file.test.ts b/packages/opencode/test/kilocode/plan-file.test.ts index 47124b4bc65..1af4400873f 100644 --- a/packages/opencode/test/kilocode/plan-file.test.ts +++ b/packages/opencode/test/kilocode/plan-file.test.ts @@ -6,7 +6,8 @@ import { PlanFile } from "../../src/kilocode/plan-file" import { Instance } from "../../src/kilocode/instance" import { provideTestInstance } from "../fixture/fixture" import { Session } from "../../src/session/session" -import { MessageID } from "../../src/session/schema" +import { MessageID, PartID } from "../../src/session/schema" +import { ProviderID, ModelID } from "../../src/provider/schema" import { PlanExitTool } from "../../src/tool/plan" import { Tool } from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" @@ -30,6 +31,9 @@ describe("PlanFile", () => { directory: tmp.path, fn: async () => { const session = await rt.runPromise(Session.Service.use((svc) => svc.create({}))) + const file = path.join(Instance.worktree, ".plans", "fix.md") + await Bun.write(file, "Do implementation step 1") + const tool = await init() const result = await rt.runPromise( tool.execute( @@ -52,6 +56,377 @@ describe("PlanFile", () => { }) }) + test("plan_exit recovers generated plan path when omitted", async () => { + await using tmp = await tmpdir({ git: true }) + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const session = await rt.runPromise(Session.Service.use((svc) => svc.create({ title: "wrong-name" }))) + const file = path.join(Instance.worktree, ".kilo", "plans", `${session.time.created}-xy.md`) + await Bun.write(file, "Do implementation step 1") + + const tool = await init() + const result = await rt.runPromise( + tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_generated"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ) + + expect(result.metadata.plan.replaceAll(path.sep, "/")).toBe(`.kilo/plans/${session.time.created}-xy.md`) + }, + }) + }) + + test("plan_exit prefers a newer generated plan over a stale file at the guessed path", async () => { + await using tmp = await tmpdir({ git: true }) + await provideTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const session = await rt.runPromise(Session.Service.use((svc) => svc.create({ title: "refined" }))) + const stale = Session.plan(session, ctx) + await Bun.write(stale, "Stale plan from an earlier round") + + const fresh = path.join(path.dirname(stale), `${session.time.created}-refined-plan.md`) + await new Promise((r) => setTimeout(r, 10)) + await Bun.write(fresh, "Fresh refined plan") + + const tool = await init() + const result = await rt.runPromise( + tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_refined"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ) + + expect(result.metadata.plan).toBe(PlanFile.display(fresh, ctx)) + }, + }) + }) + + test("plan_exit recovers custom-named plan file from write history", async () => { + await using tmp = await tmpdir({ git: true }) + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const file = path.join(Instance.worktree, ".plans", "refactor-notes.md") + await Bun.write(file, "Do implementation step 1") + + const session = await rt.runPromise( + Session.Service.use((svc) => + Effect.gen(function* () { + const info = yield* svc.create({ title: "custom-name" }) + const msg = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + time: { created: Date.now() }, + agent: "plan", + model: { providerID: ProviderID.make("anthropic"), modelID: ModelID.make("claude-sonnet-5") }, + }) + yield* svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: info.id, + type: "tool", + callID: "call_write_plan", + tool: "write", + state: { + status: "completed", + input: { filePath: file, content: "Do implementation step 1" }, + output: "", + title: "write", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + return info + }), + ), + ) + + const tool = await init() + const result = await rt.runPromise( + tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_history"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ) + + expect(result.metadata.plan.replaceAll(path.sep, "/")).toBe(".plans/refactor-notes.md") + }, + }) + }) + + test("plan_exit recovers generated plan path in non-git projects", async () => { + await using tmp = await tmpdir() + await provideTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const session = await rt.runPromise(Session.Service.use((svc) => svc.create({ title: "non-git" }))) + const file = Session.plan(session, ctx) + const named = path.join(path.dirname(file), `${session.time.created}-cache-plan.md`) + await Bun.write(named, "Do implementation step 1") + + const tool = await init() + const result = await rt.runPromise( + tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_nongit"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ) + + expect(result.metadata.plan).toBe(named) + }, + }) + }) + + test("plan_exit recovers plan written by a custom architect-slug agent", async () => { + await using tmp = await tmpdir({ git: true }) + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const file = path.join(Instance.worktree, ".plans", "refactor.md") + await Bun.write(file, "Do implementation step 1") + + const session = await rt.runPromise( + Session.Service.use((svc) => + Effect.gen(function* () { + const info = yield* svc.create({ title: "custom-slug" }) + const msg = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + time: { created: Date.now() }, + agent: "sr-architect", + model: { providerID: ProviderID.make("anthropic"), modelID: ModelID.make("claude-sonnet-5") }, + }) + yield* svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: info.id, + type: "tool", + callID: "call_write_custom", + tool: "write", + state: { + status: "completed", + input: { filePath: file, content: "Do implementation step 1" }, + output: "", + title: "write", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + return info + }), + ), + ) + + const tool = await init() + const result = await rt.runPromise( + tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_custom_agent"), + agent: "sr-architect", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ) + + expect(result.metadata.plan.replaceAll(path.sep, "/")).toBe(".plans/refactor.md") + }, + }) + }) + + test("plan_exit ignores .md files written by non-plan agents", async () => { + await using tmp = await tmpdir({ git: true }) + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const file = path.join(Instance.worktree, "docs", "notes.md") + await Bun.write(file, "Not a plan") + + const session = await rt.runPromise( + Session.Service.use((svc) => + Effect.gen(function* () { + const info = yield* svc.create({ title: "code-md" }) + const msg = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + time: { created: Date.now() }, + agent: "code", + model: { providerID: ProviderID.make("anthropic"), modelID: ModelID.make("claude-sonnet-5") }, + }) + yield* svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: info.id, + type: "tool", + callID: "call_write_docs", + tool: "write", + state: { + status: "completed", + input: { filePath: file, content: "Not a plan" }, + output: "", + title: "write", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + return info + }), + ), + ) + + const tool = await init() + await expect( + rt.runPromise( + tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_code_md"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ), + ).rejects.toThrow("Plan file not found") + }, + }) + }) + + test("plan_exit names the rejected path in its error instead of guessing an unrelated filename", async () => { + await using tmp = await tmpdir({ git: true }) + await using outside = await tmpdir() + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const session = await rt.runPromise(Session.Service.use((svc) => svc.create({ title: "outside-path" }))) + const file = path.join(outside.path, "plan.md") + await Bun.write(file, "Do implementation step 1") + + const tool = await init() + await expect( + rt.runPromise( + tool.execute( + { path: file }, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_outside"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ), + ).rejects.toThrow(`The path "${file}" you passed can't be used directly`) + }, + }) + }) + + test("plan_exit recovers when a rejected path is actually the canonical non-git dir", async () => { + await using tmp = await tmpdir() + await provideTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const session = await rt.runPromise(Session.Service.use((svc) => svc.create({ title: "global-dir-path" }))) + const canonical = Session.plan(session, ctx) + const named = path.join(path.dirname(canonical), `${session.time.created}-my-plan.md`) + await Bun.write(named, "Do implementation step 1") + + const tool = await init() + const result = await rt.runPromise( + tool.execute( + { path: named }, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_global_path"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ) + + expect(result.metadata.plan).toBe(named) + }, + }) + }) + + test("plan_exit fails when the plan file was not written", async () => { + await using tmp = await tmpdir({ git: true }) + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const session = await rt.runPromise(Session.Service.use((svc) => svc.create({ title: "missing-plan" }))) + const tool = await init() + + await expect( + rt.runPromise( + tool.execute( + {}, + { + sessionID: session.id, + messageID: MessageID.make("msg_plan_exit_missing"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ), + ).rejects.toThrow("Plan file not found") + }, + }) + }) + test("rejects custom plan paths outside the worktree", async () => { await using tmp = await tmpdir({ git: true }) await provideTestInstance({