Skip to content

Commit 3de3d31

Browse files
committed
fix(core): keep the upstream todowrite tool alongside the goal todo
1 parent 34495b2 commit 3de3d31

7 files changed

Lines changed: 231 additions & 83 deletions

File tree

‎packages/core/src/plugin/internal.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import { PatchTool } from "../tool/plugin/patch.js"
7777
import { EditTool } from "../tool/plugin/edit.js"
7878
import { GlobTool } from "../tool/plugin/glob.js"
7979
import { GoalTool } from "../tool/plugin/goal.js"
80+
import { GoalTodoTool } from "../tool/plugin/goal-todo.js"
8081
import { GrepTool } from "../tool/plugin/grep.js"
8182
import { McpResourceTools } from "../tool/plugin/mcp-resource.js"
8283
import { OpenCodeTools } from "../tool/plugin/opencode.js"
@@ -255,6 +256,7 @@ const pre = [
255256
EditTool.Plugin,
256257
GlobTool.Plugin,
257258
GoalTool.Plugin,
259+
GoalTodoTool.Plugin,
258260
GrepTool.Plugin,
259261
OpenCodeTools.Plugin,
260262
McpResourceTools.Plugin,

‎packages/core/src/session/goal-driver.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,11 @@ const renderRoundPrompt = (state: SessionGoalSchema.State) => {
179179
unmet.length === 0
180180
? "The verifier has not rejected anything yet."
181181
: `The verifier did not accept these items:\n${unmet.join("\n")}`,
182-
"Keep the list accurate with the todo tool. Only the verifier marks an item completed, so finish the work rather than the list.",
182+
"Keep the list accurate with the goal_todo tool. Only the verifier marks an item completed, so finish the work rather than the list.",
183183
].join("\n\n")
184184
}
185185

186186
const renderTodo = (todo: ReadonlyArray<SessionGoalSchema.TodoItem>) =>
187187
todo.length === 0
188-
? "(the list is empty; write it with the todo tool)"
188+
? "(the list is empty; write it with the goal_todo tool)"
189189
: todo.map((item) => `- [${item.position}] ${item.status}: ${item.content}`).join("\n")

‎packages/core/src/session/goal.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ const layer = Layer.effect(
311311
yield* activation.disarm(sessionID)
312312
const snapshot = { ...goal, revision: goal.revision + 1, phase: "paused" as const }
313313
yield* publishUpdated(bus, sessionID, snapshot)
314-
if (armed && input.interrupt !== false) yield* Effect.uninterruptible(execution.interrupt(sessionID, { reason: "user" }))
314+
if (armed && input.interrupt !== false) yield* Effect.uninterruptible(execution.interrupt(sessionID))
315315
return snapshot
316316
}
317317
if (input.action === "resume") {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
export * as GoalTodoTool from "./goal-todo.js"
2+
3+
import { ToolFailure } from "@opencode/ai"
4+
import type { Context } from "@opencode/plugin/effect/plugin"
5+
import { SessionGoal as SessionGoalSchema } from "@opencode/schema/session-goal"
6+
import { Effect, Schema } from "effect"
7+
import { SessionGoal } from "../../session/goal.js"
8+
9+
export const name = "goal_todo"
10+
11+
const Acceptance = Schema.String.check(Schema.isMaxLength(200))
12+
13+
/**
14+
* The model's goal todo surface. `completed` is absent: the verifier owns it, so
15+
* the model cannot tick its own list. `acceptance` is how the verifier knows
16+
* what "done" means for an item.
17+
*/
18+
export const Input = Schema.Struct({
19+
todos: Schema.Array(
20+
Schema.Struct({
21+
content: Schema.String,
22+
status: Schema.Literals(["pending", "in_progress", "cancelled"]),
23+
acceptance: Schema.optionalKey(Acceptance),
24+
}),
25+
),
26+
})
27+
28+
export const Output = Schema.Struct({
29+
todos: Schema.Array(SessionGoalSchema.TodoItem),
30+
unmet: Schema.Array(
31+
Schema.Struct({
32+
position: Schema.Number,
33+
reason: Schema.String,
34+
}),
35+
),
36+
})
37+
38+
export const description = [
39+
"Replace the session's whole todo list for the current goal.",
40+
"",
41+
"This is the durable goal list, not `todowrite`. The list is stored on the session, and the goal verifier judges every item against its `acceptance` statement; `todowrite` only echoes a working plan back and stores nothing. Use `todowrite` for a working plan outside a goal, and this tool for the list a goal is verified against.",
42+
"`acceptance` states how the item is judged done; the verifier reads it and nothing else about the item.",
43+
"The verifier alone marks an item `completed`, so use `pending`, `in_progress` and `cancelled` here.",
44+
"The result echoes the verifier's unmet items verbatim: fix those before anything else.",
45+
].join("\n")
46+
47+
export const Plugin = {
48+
id: "opencode.tool.goal-todo",
49+
effect: Effect.fn("GoalTodoTool.Plugin")(function* (ctx: Context) {
50+
const goal = yield* SessionGoal.Service
51+
yield* ctx.tool
52+
.transform((editor) =>
53+
editor.add({
54+
name,
55+
options: { codemode: false },
56+
description,
57+
input: Input,
58+
output: Output,
59+
execute: (input, context) =>
60+
Effect.gen(function* () {
61+
// The verifier's reasons live on the rows this call replaces, so read
62+
// them first: the echo below is where the model sees them.
63+
const before = yield* goal.readTodo(context.sessionID)
64+
const unmet = before.flatMap((item) =>
65+
item.feedback === undefined || item.feedback === ""
66+
? []
67+
: [{ position: item.position, reason: item.feedback }],
68+
)
69+
yield* goal.replaceTodo(context.sessionID, input.todos)
70+
const after = yield* goal.readTodo(context.sessionID)
71+
return { output: { todos: after, unmet }, content: render(after, unmet) }
72+
}).pipe(Effect.mapError((error) => new ToolFailure({ message: error.message }))),
73+
}),
74+
)
75+
.pipe(Effect.orDie)
76+
}),
77+
}
78+
79+
const render = (
80+
todos: ReadonlyArray<SessionGoalSchema.TodoItem>,
81+
unmet: ReadonlyArray<{ readonly position: number; readonly reason: string }>,
82+
) =>
83+
[
84+
todos.length === 0
85+
? "Todo list: (empty)"
86+
: `Todo list:\n${todos
87+
.map(
88+
(item) =>
89+
`- [${item.position}] ${item.status}: ${item.content}${item.acceptance === undefined ? "" : `\n acceptance: ${item.acceptance}`}`,
90+
)
91+
.join("\n")}`,
92+
unmet.length === 0
93+
? "The verifier has not rejected anything."
94+
: `The verifier did not accept these items:\n${unmet.map((item) => `- [${item.position}] ${item.reason}`).join("\n")}`,
95+
].join("\n\n")

‎packages/core/src/tool/plugin/goal.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,6 @@ const render = (snapshot: SessionGoalSchema.Snapshot, state: SessionGoalSchema.S
7878
`Objective: ${snapshot.objective}`,
7979
`Rounds: ${state.rounds}/${state.maxGoalRounds}.`,
8080
state.todo.length === 0
81-
? "Todo list: (empty; write it with the todo tool)"
81+
? "Todo list: (empty; write it with the goal_todo tool)"
8282
: `Todo list:\n${state.todo.map((item) => `- [${item.position}] ${item.status}: ${item.content}`).join("\n")}`,
8383
].join("\n")

‎packages/core/src/tool/plugin/todo.ts‎

Lines changed: 55 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,93 +2,82 @@ export * as TodoTool from "./todo.js"
22

33
import { ToolFailure } from "@opencode/ai"
44
import type { Context } from "@opencode/plugin/effect/plugin"
5-
import { SessionGoal as SessionGoalSchema } from "@opencode/schema/session-goal"
65
import { Effect, Schema } from "effect"
7-
import { SessionGoal } from "../../session/goal.js"
6+
import { Config } from "../../config.js"
7+
import { ConfigEntryObserver } from "../../config/plugin/entry-observer.js"
8+
import { Permission } from "../../permission.js"
9+
import DESCRIPTION from "../todowrite.txt"
810

9-
export const name = "todo"
11+
export const name = "todowrite"
1012

11-
const Acceptance = Schema.String.check(Schema.isMaxLength(200))
13+
export const Info = Schema.Struct({
14+
content: Schema.String.annotate({ description: "Brief description of the task" }),
15+
status: Schema.Literals(["pending", "in_progress", "completed", "cancelled"]).annotate({
16+
description: "Current status of the task: pending, in_progress, completed, cancelled",
17+
}),
18+
})
19+
export type Info = typeof Info.Type
1220

13-
/**
14-
* The model's todo surface. `completed` is absent: the verifier owns it, so
15-
* the model cannot tick its own list. `acceptance` is how the verifier knows
16-
* what "done" means for an item.
17-
*/
1821
export const Input = Schema.Struct({
19-
todos: Schema.Array(
20-
Schema.Struct({
21-
content: Schema.String,
22-
status: Schema.Literals(["pending", "in_progress", "cancelled"]),
23-
acceptance: Schema.optionalKey(Acceptance),
24-
}),
25-
),
22+
todos: Schema.Array(Info).annotate({ description: "The updated todo list" }),
2623
})
2724

2825
export const Output = Schema.Struct({
29-
todos: Schema.Array(SessionGoalSchema.TodoItem),
30-
unmet: Schema.Array(
31-
Schema.Struct({
32-
position: Schema.Number,
33-
reason: Schema.String,
34-
}),
35-
),
26+
todos: Schema.Array(Info),
3627
})
28+
export type Output = typeof Output.Type
3729

38-
export const description = [
39-
"Replace the session's whole todo list for the current goal.",
40-
"",
41-
"`acceptance` states how the item is judged done; the verifier reads it and nothing else about the item.",
42-
"The verifier alone marks an item `completed`, so use `pending`, `in_progress` and `cancelled` here.",
43-
"The result echoes the verifier's unmet items verbatim: fix those before anything else.",
44-
].join("\n")
30+
export const summarize = (todos: ReadonlyArray<Info>) => {
31+
if (todos.length === 0) return "Todos cleared"
32+
const count = (status: Info["status"]) => todos.filter((todo) => todo.status === status).length
33+
const counts = [
34+
`${count("completed")}/${todos.length} completed`,
35+
...(["in_progress", "pending", "cancelled"] as const).flatMap((status) =>
36+
count(status) ? [`${count(status)} ${status.replace("_", " ")}`] : [],
37+
),
38+
].join(", ")
39+
const active = todos.filter((todo) => todo.status === "in_progress").map((todo) => JSON.stringify(todo.content))
40+
return `Todos updated: ${counts}${active.length ? `. In progress: ${active.join(", ")}` : ""}`
41+
}
4542

4643
export const Plugin = {
4744
id: "opencode.tool.todo",
4845
effect: Effect.fn("TodoTool.Plugin")(function* (ctx: Context) {
49-
const goal = yield* SessionGoal.Service
46+
const config = yield* Config.Service
47+
const permission = yield* Permission.Service
48+
// Internal plugins are not re-run on config changes, so reload the tool transform instead.
49+
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.tool.reload())
50+
const enabled = () => Config.latest(loaded.entries, "experimental")?.todo === true
51+
5052
yield* ctx.tool
51-
.transform((editor) =>
53+
.transform((editor) => {
54+
if (!enabled()) return
5255
editor.add({
5356
name,
5457
options: { codemode: false },
55-
description,
58+
description: DESCRIPTION,
5659
input: Input,
5760
output: Output,
5861
execute: (input, context) =>
59-
Effect.gen(function* () {
60-
// The verifier's reasons live on the rows this call replaces, so read
61-
// them first: the echo below is where the model sees them.
62-
const before = yield* goal.readTodo(context.sessionID)
63-
const unmet = before.flatMap((item) =>
64-
item.feedback === undefined || item.feedback === ""
65-
? []
66-
: [{ position: item.position, reason: item.feedback }],
67-
)
68-
yield* goal.replaceTodo(context.sessionID, input.todos)
69-
const after = yield* goal.readTodo(context.sessionID)
70-
return { output: { todos: after, unmet }, content: render(after, unmet) }
71-
}).pipe(Effect.mapError((error) => new ToolFailure({ message: error.message }))),
72-
}),
73-
)
62+
permission
63+
.assert({
64+
action: name,
65+
resources: ["*"],
66+
save: ["*"],
67+
sessionID: context.sessionID,
68+
agent: context.agent,
69+
source: { type: "tool", messageID: context.messageID, id: context.id },
70+
})
71+
.pipe(
72+
Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error })),
73+
Effect.as({
74+
output: { todos: input.todos },
75+
content: summarize(input.todos),
76+
metadata: { todos: input.todos },
77+
}),
78+
),
79+
})
80+
})
7481
.pipe(Effect.orDie)
7582
}),
7683
}
77-
78-
const render = (
79-
todos: ReadonlyArray<SessionGoalSchema.TodoItem>,
80-
unmet: ReadonlyArray<{ readonly position: number; readonly reason: string }>,
81-
) =>
82-
[
83-
todos.length === 0
84-
? "Todo list: (empty)"
85-
: `Todo list:\n${todos
86-
.map(
87-
(item) =>
88-
`- [${item.position}] ${item.status}: ${item.content}${item.acceptance === undefined ? "" : `\n acceptance: ${item.acceptance}`}`,
89-
)
90-
.join("\n")}`,
91-
unmet.length === 0
92-
? "The verifier has not rejected anything."
93-
: `The verifier did not accept these items:\n${unmet.map((item) => `- [${item.position}] ${item.reason}`).join("\n")}`,
94-
].join("\n\n")

0 commit comments

Comments
 (0)