@hacky.software/opencode-todo-supervisor 0.0.0-stage → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdirSync, writeFileSync } from "node:fs"
3
+ import type { Plugin } from "@opencode/plugin"
4
+ import { FakeStorage } from "./storage.test"
5
+ import { TodoStore, type TodoItem } from "./storage"
6
+ import { DEFAULTS } from "./options"
7
+ import { MarkdownCache } from "./markdown"
8
+ import {
9
+ makeContextInjector,
10
+ makeTodoRead,
11
+ makeTodoSubscribe,
12
+ makeTodoUnsubscribe,
13
+ makeTodoWrite,
14
+ normalizeItems,
15
+ registerExecuteAfterHook,
16
+ renderList,
17
+ renderSummary,
18
+ renderTodoContext,
19
+ } from "./tools"
20
+
21
+ describe("normalizeItems", () => {
22
+ test("trims, drops empties and dedupes", () => {
23
+ const { items } = normalizeItems([
24
+ { content: " a ", status: "pending" },
25
+ { content: "a", status: "pending" },
26
+ { content: " ", status: "pending" },
27
+ { content: "b", status: "in_progress" },
28
+ ])
29
+ expect(items).toEqual([
30
+ { content: "a", status: "pending" },
31
+ { content: "b", status: "in_progress" },
32
+ ])
33
+ })
34
+
35
+ test("keeps at most one in_progress and notes the demotion", () => {
36
+ const { items, notes } = normalizeItems([
37
+ { content: "a", status: "in_progress" },
38
+ { content: "b", status: "in_progress" },
39
+ { content: "c", status: "in_progress" },
40
+ ])
41
+ expect(items.map((i) => i.status)).toEqual(["in_progress", "pending", "pending"])
42
+ expect(notes.join(" ")).toContain("demoted 2 extra in_progress")
43
+ })
44
+ })
45
+
46
+ describe("renderers", () => {
47
+ test("renderList uses the checklist markers", () => {
48
+ const list: TodoItem[] = [
49
+ { content: "done", status: "completed" },
50
+ { content: "doing", status: "in_progress" },
51
+ { content: "open", status: "pending" },
52
+ { content: "nope", status: "canceled" },
53
+ ]
54
+ expect(renderList(list)).toBe("- [x] done\n- [~] doing\n- [ ] open\n- [–] nope")
55
+ expect(renderList([])).toBe("No todos.")
56
+ })
57
+
58
+ test("renderSummary counts and flags open work", () => {
59
+ const summary = renderSummary([
60
+ { content: "done", status: "completed" },
61
+ { content: "doing", status: "in_progress" },
62
+ { content: "open", status: "pending" },
63
+ ])
64
+ expect(summary).toContain("3 items: 1 in_progress, 1 pending, 1 completed.")
65
+ expect(summary).toContain("2 open")
66
+
67
+ const doneSummary = renderSummary([{ content: "done", status: "completed" }])
68
+ expect(doneSummary).toContain("All items are done.")
69
+ })
70
+ })
71
+
72
+ describe("todo tools round-trip", () => {
73
+ test("write then read returns the same state", async () => {
74
+ const store = new TodoStore(new FakeStorage())
75
+ const changed: string[] = []
76
+ const write = makeTodoWrite(store, { onTodosChanged: (id) => changed.push(id) })
77
+ const read = makeTodoRead(store)
78
+
79
+ const writeResult = await write.execute(
80
+ { todos: [{ content: "step 1", status: "in_progress" }, { content: "step 2", status: "pending" }] },
81
+ { sessionID: "ses_1" },
82
+ )
83
+ expect(writeResult.content[0]?.text).toContain("- [~] step 1")
84
+ expect(changed).toEqual(["ses_1"])
85
+
86
+ const readResult = await read.execute({}, { sessionID: "ses_1" })
87
+ expect(readResult.content[0]?.text).toContain("- [ ] step 2")
88
+ expect(readResult.content[0]?.text).toContain("2 open of 2 items.")
89
+ })
90
+
91
+ test("read without a list is empty", async () => {
92
+ const read = makeTodoRead(new TodoStore(new FakeStorage()))
93
+ const result = await read.execute({}, { sessionID: "ses_none" })
94
+ expect(result.content[0]?.text).toBe("No todos.")
95
+ })
96
+
97
+ test("todo_write storage failure is logged and returned as a tool error", async () => {
98
+ const storage = new FakeStorage()
99
+ storage.set = async () => {
100
+ throw new Error("disk full")
101
+ }
102
+ const logs: string[] = []
103
+ const write = makeTodoWrite(new TodoStore(storage), {
104
+ log: (...args: unknown[]) => void logs.push(args.map(String).join(" ")),
105
+ })
106
+ const result = await write.execute({ todos: [{ content: "x", status: "pending" }] }, { sessionID: "s1" })
107
+ expect(result.content[0]?.text).toContain("todo_write failed")
108
+ expect(String((result.metadata as { error?: string } | undefined)?.error)).toContain("disk full")
109
+ expect(logs.some((line) => line.includes("todo_write failed"))).toBe(true)
110
+ })
111
+ })
112
+
113
+ describe("markdown tools (§5.2, aspect 14)", () => {
114
+ const dir = "/tmp/opencode"
115
+ const file = `${dir}/tools-md-${process.pid}.md`
116
+ const shown = `tools-md-${process.pid}.md`
117
+
118
+ test("todo_subscribe adds files and lifts shadows; unsubscribe shadows config", async () => {
119
+ mkdirSync(dir, { recursive: true })
120
+ writeFileSync(file, "- [ ] one\n- [x] two\n")
121
+ const store = new TodoStore(new FakeStorage())
122
+ const changed: string[] = []
123
+ const hooks = { onTodosChanged: (id: string) => void changed.push(id), log: () => {} }
124
+ const sub = makeTodoSubscribe(store, hooks, dir)
125
+ const unsub = makeTodoUnsubscribe(store, hooks, dir)
126
+
127
+ const result = await sub.execute({ files: [{ path: file }] }, { sessionID: "s1" })
128
+ expect(result.content[0]?.text).toContain("Mark items done")
129
+ expect((await store.loadSubs("s1"))!.files).toEqual([file])
130
+ expect(changed).toEqual(["s1"])
131
+
132
+ await unsub.execute({ files: [{ path: file }] }, { sessionID: "s1" })
133
+ const after = await store.loadSubs("s1")
134
+ expect(after!.files).toEqual([])
135
+ expect(after!.removed).toEqual([file])
136
+
137
+ await sub.execute({ files: [{ path: file }] }, { sessionID: "s1" })
138
+ const back = await store.loadSubs("s1")
139
+ expect(back!.files).toEqual([file])
140
+ expect(back!.removed).toEqual([])
141
+
142
+ const logs: string[] = []
143
+ const bad = makeTodoSubscribe(new TodoStore(new FakeStorage()), {
144
+ log: (...args: unknown[]) => void logs.push(args.map(String).join(" ")),
145
+ }, dir)
146
+ const badResult = await bad.execute({ files: [{ path: "notes.txt" }] }, { sessionID: "s1" })
147
+ expect(badResult.content[0]?.text).toContain("todo_subscribe failed")
148
+ expect(logs.some((line) => line.includes("todo_subscribe failed"))).toBe(true)
149
+ })
150
+
151
+ test("todo_read merges session items and markdown file sections", async () => {
152
+ mkdirSync(dir, { recursive: true })
153
+ writeFileSync(file, "- [ ] file task\n")
154
+ const store = new TodoStore(new FakeStorage())
155
+ await store.setTodos("s1", [{ content: "session task", status: "pending" }])
156
+ await store.setSubs("s1", [file], [])
157
+ const read = makeTodoRead(store, {}, { markdown: new MarkdownCache(), options: { ...DEFAULTS }, directory: dir })
158
+ const result = await read.execute({}, { sessionID: "s1" })
159
+ const text = result.content[0]?.text ?? ""
160
+ expect(text).toContain("2 open of 2 items.")
161
+ expect(text).toContain("- [ ] session task")
162
+ expect(text).toContain("- [ ] file task (" + shown + ":1)")
163
+ })
164
+ })
165
+
166
+ describe("execute.after markdown refresh (§5.8, aspect 14c)", () => {
167
+ test("refreshes subscribed files and notifies; ignores other tools and paths", async () => {
168
+ const dir = "/tmp/opencode"
169
+ mkdirSync(dir, { recursive: true })
170
+ const file = `${dir}/hook-md-${process.pid}.md`
171
+ writeFileSync(file, "- [ ] one\n")
172
+ const markdown = new MarkdownCache()
173
+ markdown.parse(file) // prime the cache
174
+ const store = new TodoStore(new FakeStorage())
175
+ await store.setSubs("s1", [file], [])
176
+
177
+ const changed: string[] = []
178
+ const logs: string[] = []
179
+ let captured: ((event: { tool: string; sessionID: string; input: unknown }) => Promise<void>) | undefined
180
+ const ctx = {
181
+ tool: {
182
+ hook: async (_name: string, callback: typeof captured) => {
183
+ captured = callback
184
+ return { dispose: async () => {} }
185
+ },
186
+ },
187
+ } as unknown as Plugin.Context
188
+ const registration = await registerExecuteAfterHook(ctx, {
189
+ store,
190
+ markdown,
191
+ options: { ...DEFAULTS },
192
+ directory: dir,
193
+ onChanged: (id) => void changed.push(id),
194
+ log: (...args: unknown[]) => void logs.push(args.map(String).join(" ")),
195
+ })
196
+
197
+ writeFileSync(file, "- [ ] one\n- [x] two\n- [ ] three\n")
198
+ await captured!({ tool: "edit", sessionID: "s1", input: { filePath: file } })
199
+ expect(changed).toEqual(["s1"])
200
+ expect(markdown.parse(file).items.length).toBe(3)
201
+ expect(logs.some((line) => line.includes("markdown refreshed"))).toBe(true)
202
+
203
+ await captured!({ tool: "read", sessionID: "s1", input: { filePath: file } })
204
+ await captured!({ tool: "edit", sessionID: "s1", input: { filePath: `${dir}/other-${process.pid}.md` } })
205
+ expect(changed).toEqual(["s1"])
206
+
207
+ await registration.dispose()
208
+ })
209
+ })
210
+
211
+ describe("context injection (§5.5)", () => {
212
+ const storeWith = (items: TodoItem[]) => {
213
+ const cache = new Map<string, TodoItem[]>([["s1", items]])
214
+ return {
215
+ getTodos: (sessionID: string) =>
216
+ cache.has(sessionID)
217
+ ? { version: 1, sessionID, items: cache.get(sessionID)!, updatedAt: 0 }
218
+ : undefined,
219
+ } as unknown as TodoStore
220
+ }
221
+ const noop = () => {}
222
+
223
+ test("injects one marker block with counts, full list and policy", () => {
224
+ const store = storeWith([
225
+ { content: "step 1", status: "completed" },
226
+ { content: "step 2", status: "in_progress", priority: "high" },
227
+ { content: "step 3", status: "pending" },
228
+ ])
229
+ const inject = makeContextInjector(store, { ...DEFAULTS }, noop)
230
+ const event = { sessionID: "s1", system: [] as unknown[] }
231
+ inject(event)
232
+ inject(event) // a duplicate registration must not double-inject
233
+ expect(event.system.length).toBe(1)
234
+ const text = (event.system[0] as { text: string }).text
235
+ expect(text).toContain("[todo-supervisor] Active todo list — 2 of 3 todos left, 1 in progress")
236
+ expect(text).toContain("- [x] step 1")
237
+ expect(text).toContain("- [~] step 2 (high)")
238
+ expect(text).toContain("- [ ] step 3")
239
+ expect(text).toContain("todo_write")
240
+ })
241
+
242
+ test("skips when disabled or the session has no list", () => {
243
+ const store = storeWith([{ content: "step", status: "pending" }])
244
+ const off = makeContextInjector(store, { ...DEFAULTS, injectTodoContext: false }, noop)
245
+ const eventOff = { sessionID: "s1", system: [] as unknown[] }
246
+ off(eventOff)
247
+ expect(eventOff.system.length).toBe(0)
248
+ const inject = makeContextInjector(store, { ...DEFAULTS }, noop)
249
+ const eventNone = { sessionID: "ses_none", system: [] as unknown[] }
250
+ inject(eventNone)
251
+ expect(eventNone.system.length).toBe(0)
252
+ })
253
+
254
+ test("renderTodoContext uses the marker and policy wording", () => {
255
+ const text = renderTodoContext([{ content: "a", status: "pending" }])
256
+ expect(text.startsWith("[todo-supervisor] Active todo list")).toBe(true)
257
+ expect(text).toContain("1 of 1 todo left")
258
+ expect(text).toContain("completed or canceled")
259
+ })
260
+ })
package/src/tools.ts ADDED
@@ -0,0 +1,405 @@
1
+ // todo-supervisor — todo tools + rendering (PLAN.md §5.2, §5.3).
2
+ import { isAbsolute, resolve as resolvePath } from "node:path"
3
+ import type { Plugin } from "@opencode/plugin"
4
+ import { effectiveFiles, MarkdownCache, renderFileSection, resolveMarkdownPath } from "./markdown"
5
+ import type { Options } from "./options"
6
+ import { openItems, type TodoItem, type TodoStore, type TodoStatus } from "./storage"
7
+
8
+ export interface ToolHooks {
9
+ /** Fired after a successful `todo_write` (supervisor reset, RPC notify, …). */
10
+ onTodosChanged?(sessionID: string): void
11
+ /** Logger: storage failures are recorded and returned as tool errors. */
12
+ log?(...args: unknown[]): void
13
+ }
14
+
15
+ const STATUS_ORDER: readonly TodoStatus[] = ["in_progress", "pending", "completed", "canceled"]
16
+
17
+ const MARKERS: Record<TodoStatus, string> = {
18
+ pending: "[ ]",
19
+ in_progress: "[~]",
20
+ completed: "[x]",
21
+ canceled: "[–]",
22
+ }
23
+
24
+ /**
25
+ * Trim content, drop empty entries, dedupe by content, keep at most one
26
+ * `in_progress` (extra ones are demoted to `pending`, noted in the output).
27
+ */
28
+ export function normalizeItems(raw: readonly TodoItem[]): { items: TodoItem[]; notes: string[] } {
29
+ const notes: string[] = []
30
+ const items: TodoItem[] = []
31
+ const seen = new Set<string>()
32
+
33
+ for (const entry of raw) {
34
+ if (!entry || typeof entry.content !== "string") continue
35
+ const content = entry.content.trim()
36
+ if (!content) continue
37
+ if (seen.has(content)) continue
38
+ seen.add(content)
39
+
40
+ let status: TodoStatus = "pending"
41
+ if (entry.status === "in_progress" || entry.status === "completed" || entry.status === "canceled") {
42
+ status = entry.status
43
+ } else if (entry.status !== "pending") {
44
+ notes.push(`unknown status ${JSON.stringify(entry.status)} treated as pending`)
45
+ }
46
+
47
+ const item: TodoItem = { content, status }
48
+ if (entry.priority === "high" || entry.priority === "medium" || entry.priority === "low") {
49
+ item.priority = entry.priority
50
+ }
51
+ items.push(item)
52
+ }
53
+
54
+ const inProgress = items.filter((item) => item.status === "in_progress")
55
+ if (inProgress.length > 1) {
56
+ let kept = false
57
+ for (const item of items) {
58
+ if (item.status !== "in_progress") continue
59
+ if (kept) item.status = "pending"
60
+ kept = true
61
+ }
62
+ notes.push(`demoted ${inProgress.length - 1} extra in_progress item(s) to pending`)
63
+ }
64
+
65
+ return { items, notes }
66
+ }
67
+
68
+ export function renderList(items: readonly TodoItem[]): string {
69
+ if (items.length === 0) return "No todos."
70
+ return items
71
+ .map((item) => `- ${MARKERS[item.status]} ${item.content}${item.priority ? ` (${item.priority})` : ""}`)
72
+ .join("\n")
73
+ }
74
+
75
+ export function renderSummary(items: readonly TodoItem[], notes: readonly string[] = []): string {
76
+ const noteText = notes.length > 0 ? ` ${notes.join("; ")}.` : ""
77
+
78
+ if (items.length === 0) return `Todos updated — the list is empty.${noteText}`
79
+
80
+ const counts = STATUS_ORDER.map((status) => [status, items.filter((item) => item.status === status).length] as const)
81
+ .filter(([, count]) => count > 0)
82
+ .map(([status, count]) => `${count} ${status}`)
83
+
84
+ const open = openItems(items).length
85
+ const openNote =
86
+ open === 0
87
+ ? "All items are done."
88
+ : `${open} open — continue working until every item is completed or canceled.`
89
+
90
+ return `Todos updated — ${items.length} item${items.length === 1 ? "" : "s"}: ${counts.join(", ")}.${noteText} ${openNote}`
91
+ }
92
+
93
+ const TODO_ITEM_SCHEMA = {
94
+ type: "object",
95
+ properties: {
96
+ content: { type: "string" },
97
+ status: { type: "string", enum: ["pending", "in_progress", "completed", "canceled"] },
98
+ priority: { type: "string", enum: ["high", "medium", "low"] },
99
+ },
100
+ required: ["content", "status"],
101
+ additionalProperties: false,
102
+ } as const
103
+
104
+ const WRITE_DESCRIPTION =
105
+ "Replace the session's todo list. Use this to plan multi-step work and keep progress visible: " +
106
+ "send the complete list every time (it replaces the previous one). Keep exactly one item `in_progress`. " +
107
+ "Before ending your turn every item must be `completed` or `canceled` — a supervisor will continue the session while items remain open."
108
+
109
+ const READ_DESCRIPTION =
110
+ "Read the session's current todo list (canonical state as stored by todo_write). " +
111
+ "The list is also injected into your context automatically; use this only to double-check exact state."
112
+
113
+ const SUBSCRIBE_ITEM_SCHEMA = {
114
+ type: "object",
115
+ properties: { path: { type: "string" } },
116
+ required: ["path"],
117
+ additionalProperties: false,
118
+ } as const
119
+
120
+ const SUBSCRIBE_DESCRIPTION =
121
+ "Subscribe this session to markdown files: unchecked GFM checklist items (- [ ]) in them count as todos. " +
122
+ "Mark an item done by editing the file (- [x]); the supervisor keeps nudging while items remain open. " +
123
+ "Paths are relative to the project directory (or absolute) and must end in .md/.markdown."
124
+
125
+ const UNSUBSCRIBE_DESCRIPTION =
126
+ "Stop treating a subscribed markdown file's checklist as todos for this session (this also shadows a config default). " +
127
+ "Paths are relative to the project directory (or absolute) and must end in .md/.markdown."
128
+
129
+ const SUBSCRIBE_INPUT = {
130
+ type: "object",
131
+ properties: { files: { type: "array", items: SUBSCRIBE_ITEM_SCHEMA } },
132
+ required: ["files"],
133
+ additionalProperties: false,
134
+ } as const
135
+
136
+ async function readSubs(store: TodoStore, sessionID: string) {
137
+ return (await store.loadSubs(sessionID)) ?? { files: [] as string[], removed: [] as string[] }
138
+ }
139
+
140
+ type ToolContextLike = { sessionID: string }
141
+
142
+ export function makeTodoWrite(store: TodoStore, hooks: ToolHooks) {
143
+ return {
144
+ name: "todo_write",
145
+ description: WRITE_DESCRIPTION,
146
+ input: {
147
+ type: "object",
148
+ properties: {
149
+ todos: { type: "array", items: TODO_ITEM_SCHEMA },
150
+ },
151
+ required: ["todos"],
152
+ additionalProperties: false,
153
+ },
154
+ options: { codemode: true as const },
155
+ async execute(input: { todos: TodoItem[] }, context: ToolContextLike) {
156
+ try {
157
+ const { items, notes } = normalizeItems(Array.isArray(input?.todos) ? input.todos : [])
158
+ await store.setTodos(context.sessionID, items)
159
+ hooks.onTodosChanged?.(context.sessionID)
160
+ return {
161
+ content: [{ type: "text" as const, text: `${renderSummary(items, notes)}\n\n${renderList(items)}` }],
162
+ metadata: { items: items.length, open: openItems(items).length },
163
+ }
164
+ } catch (err) {
165
+ hooks.log?.(`todo_write failed (${context.sessionID}): ${String(err)}`)
166
+ return {
167
+ content: [{ type: "text" as const, text: `todo_write failed: ${String(err)}` }],
168
+ metadata: { error: String(err) },
169
+ }
170
+ }
171
+ },
172
+ }
173
+ }
174
+
175
+ export type MarkdownDeps = { markdown: MarkdownCache; directory: string }
176
+
177
+ export function makeTodoRead(store: TodoStore, hooks: ToolHooks = {}, deps?: MarkdownDeps & { options: Options }) {
178
+ return {
179
+ name: "todo_read",
180
+ description: READ_DESCRIPTION,
181
+ input: { type: "object", properties: {}, additionalProperties: false },
182
+ options: { codemode: true as const },
183
+ async execute(_input: Record<string, never>, context: ToolContextLike) {
184
+ try {
185
+ const state = store.getTodos(context.sessionID) ?? (await store.loadTodos(context.sessionID))
186
+ const items = state?.items ?? []
187
+ // Markdown mode (§5.8): merged output — session items first, then a
188
+ // section per subscribed file with `path:line` references.
189
+ let files: ReturnType<MarkdownCache["parse"]>[] = []
190
+ if (deps) {
191
+ const subs = await store.loadSubs(context.sessionID)
192
+ files = effectiveFiles(deps.options, deps.directory, subs).map((path) => deps.markdown.parse(path))
193
+ }
194
+ const openMarkdown = files.reduce(
195
+ (sum, file) => sum + file.items.filter((item) => item.state === "open").length,
196
+ 0,
197
+ )
198
+ const total = items.length + files.reduce((sum, file) => sum + file.items.length, 0)
199
+ const open = openItems(items).length + openMarkdown
200
+ const header = total === 0 ? "No todos." : `${open} open of ${total} items.`
201
+ const sections: string[] = []
202
+ if (items.length > 0) sections.push(renderList(items))
203
+ for (const file of files) sections.push(renderFileSection(file, deps!.directory))
204
+ return {
205
+ content: [{ type: "text" as const, text: [header, ...sections].join("\n\n") }],
206
+ metadata: { items: items.length, files: files.length },
207
+ }
208
+ } catch (err) {
209
+ hooks.log?.(`todo_read failed (${context.sessionID}): ${String(err)}`)
210
+ return {
211
+ content: [{ type: "text" as const, text: `todo_read failed: ${String(err)}` }],
212
+ metadata: { error: String(err) },
213
+ }
214
+ }
215
+ },
216
+ }
217
+ }
218
+
219
+ export function makeTodoSubscribe(store: TodoStore, hooks: ToolHooks, directory: string) {
220
+ return {
221
+ name: "todo_subscribe",
222
+ description: SUBSCRIBE_DESCRIPTION,
223
+ input: SUBSCRIBE_INPUT,
224
+ options: { codemode: true as const },
225
+ async execute(input: { files?: Array<{ path?: string }> }, context: ToolContextLike) {
226
+ try {
227
+ const paths = (Array.isArray(input?.files) ? input.files : []).map((entry) =>
228
+ resolveMarkdownPath(directory, String(entry?.path ?? "")),
229
+ )
230
+ const current = await readSubs(store, context.sessionID)
231
+ const files = [...new Set([...current.files, ...paths])]
232
+ const removed = current.removed.filter((entry) => !paths.includes(entry))
233
+ await store.setSubs(context.sessionID, files, removed)
234
+ hooks.onTodosChanged?.(context.sessionID)
235
+ return {
236
+ content: [
237
+ {
238
+ type: "text" as const,
239
+ text:
240
+ `Subscribed (${paths.length} path(s)); ${files.length} active: ${files.join(", ") || "none"}.\n` +
241
+ "Mark items done by editing the file (`- [x]`).",
242
+ },
243
+ ],
244
+ metadata: { files: files.length },
245
+ }
246
+ } catch (err) {
247
+ hooks.log?.(`todo_subscribe failed (${context.sessionID}): ${String(err)}`)
248
+ return {
249
+ content: [{ type: "text" as const, text: `todo_subscribe failed: ${String(err)}` }],
250
+ metadata: { error: String(err) },
251
+ }
252
+ }
253
+ },
254
+ }
255
+ }
256
+
257
+ export function makeTodoUnsubscribe(store: TodoStore, hooks: ToolHooks, directory: string) {
258
+ return {
259
+ name: "todo_unsubscribe",
260
+ description: UNSUBSCRIBE_DESCRIPTION,
261
+ input: SUBSCRIBE_INPUT,
262
+ options: { codemode: true as const },
263
+ async execute(input: { files?: Array<{ path?: string }> }, context: ToolContextLike) {
264
+ try {
265
+ const paths = (Array.isArray(input?.files) ? input.files : []).map((entry) =>
266
+ resolveMarkdownPath(directory, String(entry?.path ?? "")),
267
+ )
268
+ const current = await readSubs(store, context.sessionID)
269
+ const files = current.files.filter((entry) => !paths.includes(entry))
270
+ const removed = [...new Set([...current.removed, ...paths])]
271
+ await store.setSubs(context.sessionID, files, removed)
272
+ hooks.onTodosChanged?.(context.sessionID)
273
+ return {
274
+ content: [
275
+ {
276
+ type: "text" as const,
277
+ text:
278
+ `Unsubscribed (${paths.length} path(s)); ${files.length} active: ${files.join(", ") || "none"}.\n` +
279
+ "Config defaults are shadowed until re-subscribed.",
280
+ },
281
+ ],
282
+ metadata: { files: files.length },
283
+ }
284
+ } catch (err) {
285
+ hooks.log?.(`todo_unsubscribe failed (${context.sessionID}): ${String(err)}`)
286
+ return {
287
+ content: [{ type: "text" as const, text: `todo_unsubscribe failed: ${String(err)}` }],
288
+ metadata: { error: String(err) },
289
+ }
290
+ }
291
+ },
292
+ }
293
+ }
294
+
295
+ export async function registerTools(
296
+ ctx: Plugin.Context,
297
+ store: TodoStore,
298
+ hooks: ToolHooks,
299
+ options: Options,
300
+ markdownDeps: MarkdownDeps,
301
+ ): Promise<{ dispose: () => Promise<void> }> {
302
+ const registration = await ctx.tool.transform((editor) => {
303
+ editor.add(makeTodoWrite(store, hooks))
304
+ editor.add(makeTodoRead(store, hooks, { ...markdownDeps, options }))
305
+ editor.add(makeTodoSubscribe(store, hooks, markdownDeps.directory))
306
+ editor.add(makeTodoUnsubscribe(store, hooks, markdownDeps.directory))
307
+ })
308
+ return { dispose: () => registration.dispose() }
309
+ }
310
+
311
+ // ---- execute.after refresh (§5.8, aspect 14c) -------------------------------
312
+
313
+ export type ExecuteAfterDeps = {
314
+ store: TodoStore
315
+ markdown: MarkdownCache
316
+ options: Options
317
+ directory: string
318
+ onChanged?: (sessionID: string) => void
319
+ log?: (...args: unknown[]) => void
320
+ }
321
+
322
+ const FILE_TOOLS = new Set(["edit", "write", "patch", "multiedit"])
323
+
324
+ function inputPath(input: unknown): string | undefined {
325
+ const record = input && typeof input === "object" ? (input as Record<string, unknown>) : undefined
326
+ if (!record) return undefined
327
+ for (const key of ["filePath", "file_path", "path", "file"]) {
328
+ const value = record[key]
329
+ if (typeof value === "string" && value.length > 0) return value
330
+ }
331
+ return undefined
332
+ }
333
+
334
+ /**
335
+ * `execute.after` refresh (§5.8): when an `edit`/`write`/`patch` touches a
336
+ * subscribed markdown file, re-parse it immediately and notify (RPC `updated`
337
+ * + supervisor re-evaluation). No filesystem watcher — other edits are picked
338
+ * up at the next evaluation or UI read.
339
+ */
340
+ export async function registerExecuteAfterHook(
341
+ ctx: Plugin.Context,
342
+ deps: ExecuteAfterDeps,
343
+ ): Promise<{ dispose: () => Promise<void> }> {
344
+ const registration = await ctx.tool.hook("execute.after", async (event) => {
345
+ try {
346
+ if (!FILE_TOOLS.has(event.tool)) return
347
+ const sessionID = event.sessionID
348
+ if (!sessionID) return
349
+ const raw = inputPath(event.input)
350
+ if (!raw) return
351
+ const abs = isAbsolute(raw) ? resolvePath(raw) : resolvePath(deps.directory, raw)
352
+ const subs = await deps.store.loadSubs(sessionID)
353
+ if (!effectiveFiles(deps.options, deps.directory, subs).includes(abs)) return
354
+ deps.markdown.invalidate(abs)
355
+ deps.markdown.parse(abs)
356
+ deps.onChanged?.(sessionID)
357
+ deps.log?.(`markdown refreshed (${sessionID}) ${abs}`)
358
+ } catch (err) {
359
+ deps.log?.(`markdown refresh failed: ${String(err)}`)
360
+ }
361
+ })
362
+ return { dispose: () => registration.dispose() }
363
+ }
364
+
365
+ // ---- context injection (§5.5, aspect 10) -----------------------------------
366
+
367
+ const CONTEXT_MARKER = "[todo-supervisor] Active todo list"
368
+
369
+ /** Compact block appended to every model request while the session has a list. */
370
+ export function renderTodoContext(items: readonly TodoItem[]): string {
371
+ const open = openItems(items)
372
+ const inProgress = items.filter((item) => item.status === "in_progress").length
373
+ const status =
374
+ `${open.length} of ${items.length} todo${items.length === 1 ? "" : "s"} left` +
375
+ (inProgress > 0 ? `, ${inProgress} in progress` : "")
376
+ return (
377
+ `${CONTEXT_MARKER} — ${status}. Keep it updated with todo_write; ` +
378
+ `only end the turn once every item is completed or canceled.\n${renderList(items)}`
379
+ )
380
+ }
381
+
382
+ /**
383
+ * Build the `session.hook("context")` callback (§5.5): synchronous, cache-only.
384
+ * Idempotent — when two generations register on the same request (hot-reload
385
+ * overlap), the second sees the marker block and skips.
386
+ */
387
+ export function makeContextInjector(
388
+ store: TodoStore,
389
+ options: Options,
390
+ log: (...args: unknown[]) => void,
391
+ ): (event: { readonly sessionID: string; system: unknown[] }) => void {
392
+ return (event) => {
393
+ try {
394
+ if (!options.injectTodoContext) return
395
+ const items = store.getTodos(event.sessionID)?.items ?? []
396
+ if (items.length === 0) return
397
+ const system = event.system as Array<{ type?: unknown; text?: unknown }>
398
+ if (system.some((part) => typeof part?.text === "string" && part.text.startsWith(CONTEXT_MARKER))) return
399
+ system.push({ type: "text", text: renderTodoContext(items) })
400
+ if (options.debugEvents) log(`context injected (${event.sessionID}, ${items.length} items)`)
401
+ } catch (err) {
402
+ log(`context injection failed (${String(err)})`)
403
+ }
404
+ }
405
+ }