@@ -2,93 +2,82 @@ export * as TodoTool from "./todo.js"
22
33import { ToolFailure } from "@opencode/ai"
44import type { Context } from "@opencode/plugin/effect/plugin"
5- import { SessionGoal as SessionGoalSchema } from "@opencode/schema/session-goal"
65import { 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- */
1821export 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
2825export 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
4643export 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