Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/plan-file-finalize.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions packages/opencode/src/kilocode/plan-file.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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
Expand Down
19 changes: 15 additions & 4 deletions packages/opencode/src/kilocode/plan-followup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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(() => "")
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion packages/opencode/src/kilocode/tool/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ export async function isDir(p: string): Promise<boolean> {
}

export function stat(p: string): ReturnType<typeof statSync> | 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<ReturnType<typeof statSync> | undefined> {
Expand Down
Loading
Loading