@encoredigitalgroup/opencode 0.2.3 → 0.2.5

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.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  `@encoredigitalgroup/opencode` adds file pickers for OpenCode planning commands.
4
4
 
5
- The TUI provides `/plan-review`, `/plan-resync`, `/plan-split`, and `/plan-execute` commands. Each command opens a picker for plans in `docs/_planning` and inserts the
6
- selected plan into the prompt. The server entry attaches the matching installed skill to the prompt.
5
+ The TUI provides `/plan-review`, `/plan-resync`, `/plan-split`, and `/plan-execute` commands. Each command opens a picker for plans in `docs/_planning` and
6
+ inserts the selected plan into the prompt. The server entry attaches the matching installed skill to the prompt.
7
7
 
8
8
  ## Install
9
9
 
@@ -24,15 +24,17 @@ OpenCode v2 loads this package from the `plugins` array. Add an object entry wit
24
24
  }
25
25
  ```
26
26
 
27
- Restart OpenCode after changing the configuration. OpenCode downloads missing npm plugins that appear in the `plugins` array, so you do not need to install this package
28
- with Bun or npm yourself. This package supports OpenCode v2 only; replace the legacy `plugin` configuration entry when migrating from OpenCode v1.
27
+ Restart OpenCode after changing the configuration. OpenCode downloads missing npm plugins that appear in the `plugins` array, so you do not need to install this
28
+ package with Bun or npm yourself. This package supports OpenCode v2 only; replace the legacy `plugin` configuration entry when migrating from OpenCode v1.
29
29
 
30
30
  The plugin resolves the mapped skills through OpenCode, so those skills can live in any location that OpenCode discovers.
31
31
 
32
32
  ## Configuration
33
33
 
34
- Set `planDirectory` and `skillMappings` in the object's `options` field. `planDirectory` is a non-empty path relative to the workspace. `skillMappings` can override any
35
- supported command's installed skill name. Unspecified values use the defaults: `docs/_planning` and the command's existing skill name.
34
+ Set `planDirectory` and `skillMappings` in the object's `options` field. `planDirectory` is a non-empty path relative to the workspace. `skillMappings` can override
35
+ any supported command's installed skill name. `enforceTodoTracker` defaults to `true`; set it to `false` to disable tracker-refresh enforcement. `readOnlyTools`
36
+ and `writeCapableTools` add custom tools to the built-in classifications. Unspecified values use the defaults: `docs/_planning`, the command's existing skill name,
37
+ and no custom tool classifications.
36
38
 
37
39
  ```json
38
40
  {
@@ -42,17 +44,43 @@ supported command's installed skill name. Unspecified values use the defaults: `
42
44
  "package": "@encoredigitalgroup/opencode",
43
45
  "options": {
44
46
  "planDirectory": "plans",
47
+ "enforceTodoTracker": false,
48
+ "readOnlyTools": ["repository_status"],
45
49
  "skillMappings": {
46
50
  "plan-review": "custom-plan-review",
47
51
  "plan-execute": "custom-plan-execute"
48
- }
52
+ },
53
+ "writeCapableTools": ["deploy"]
49
54
  }
50
55
  }
51
56
  ]
52
57
  }
53
58
  ```
54
59
 
55
- Launching a picker from an existing session submits to that session. Launching one from the home screen creates and displays a new session before submitting the selected plan.
60
+ Launching a picker from an existing session submits to that session. Launching one from the home screen creates and displays a new session before submitting the
61
+ selected plan.
62
+
63
+ ## Todo tracker
64
+
65
+ The plugin registers the direct `todotracker` tool. Each call replaces the current session's complete ordered todo list. Pass an empty `todos` array to clear the
66
+ list.
67
+ Each todo requires:
68
+
69
+ - `content`: a non-empty string containing at least one non-whitespace character.
70
+ - `status`: `pending`, `in_progress`, `completed`, or `cancelled`.
71
+ - `priority`: `high`, `medium`, or `low`.
72
+
73
+ The checklist renders only in the OpenCode terminal TUI sidebar. After a restart, it appears only when the completed `todotracker` result is in the host's loaded
74
+ message cache. That cache starts with the newest 20 messages, so older results are not shown until the host has loaded them.
75
+
76
+ After a session's first successful `todotracker` call, the plugin requires another call before a later write-capable tool runs. OpenCode's built-in write-capable
77
+ tools are `edit`, `patch`, `shell`, `subagent`, and `write`. Built-in read-only tools are `glob`, `grep`, `lsp`, `read`, `skill`, `webfetch`, and `websearch`; the
78
+ eleventh read-only call requires a tracker update. Session and user-interaction tools, such as `question` and `todo`, are not enforced. `readOnlyTools` and
79
+ `writeCapableTools` extend these lists for custom tools. If a tool appears in both lists, the write-capable classification takes precedence. The plugin also supports
80
+ the `apply_patch` and `bash` compatibility aliases as write-capable tools. Enforcement is disabled for sessions that have never used `todotracker`, and can be
81
+ disabled for all sessions with `"enforceTodoTracker": false`.
82
+
83
+ When a session is idle, the sidebar renders pending and in-progress todos as completed. It restores their original statuses when work resumes.
56
84
 
57
85
  ## Development
58
86
 
@@ -4,6 +4,8 @@ import { type PluginConfig } from "./ConfigTypes.js";
4
4
  export declare class ConfigLoader {
5
5
  private static stringOption;
6
6
  private static planDirectoryOption;
7
+ private static booleanOption;
8
+ private static toolNamesOption;
7
9
  /** Get valid skill mappings from plugin options. */
8
10
  private static skillMappingsOption;
9
11
  /** Get the configured option values that can override the defaults. */
@@ -2,12 +2,18 @@ export declare const skillCommands: readonly ["feature-planning", "plan-execute"
2
2
  export type SkillCommand = (typeof skillCommands)[number];
3
3
  export type SkillMappings = Record<SkillCommand, string>;
4
4
  export type PluginConfig = {
5
+ enforceTodoTracker: boolean;
5
6
  planDirectory: string;
7
+ readOnlyTools: string[];
6
8
  skillMappings: SkillMappings;
9
+ writeCapableTools: string[];
7
10
  };
8
11
  export type UserPluginConfig = {
12
+ enforceTodoTracker?: boolean;
9
13
  planDirectory?: string;
14
+ readOnlyTools?: string[];
10
15
  skillMappings?: Partial<SkillMappings>;
16
+ writeCapableTools?: string[];
11
17
  };
12
18
  /** Configuration type utilities. */
13
19
  export declare class ConfigTypes {
@@ -0,0 +1,54 @@
1
+ import type { SessionMessageInfo } from "@opencode/client";
2
+ import type { Plugin as ServerPlugin } from "@opencode/plugin";
3
+ import type { PluginConfig } from "../config";
4
+ export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
5
+ export type TodoPriority = "high" | "medium" | "low";
6
+ export type Todo = {
7
+ content: string;
8
+ status: TodoStatus;
9
+ priority: TodoPriority;
10
+ };
11
+ export type TodoTrackerInput = {
12
+ todos: Todo[];
13
+ };
14
+ export type TodoTrackerResultMetadata = {
15
+ todos: Todo[];
16
+ };
17
+ export declare const todoTrackerInput: {
18
+ readonly type: "object";
19
+ readonly additionalProperties: false;
20
+ readonly required: readonly ["todos"];
21
+ readonly properties: {
22
+ readonly todos: {
23
+ readonly type: "array";
24
+ readonly items: {
25
+ readonly type: "object";
26
+ readonly additionalProperties: false;
27
+ readonly required: readonly ["content", "status", "priority"];
28
+ readonly properties: {
29
+ readonly content: {
30
+ readonly type: "string";
31
+ readonly minLength: 1;
32
+ readonly pattern: "\\S";
33
+ };
34
+ readonly status: {
35
+ readonly enum: readonly ["pending", "in_progress", "completed", "cancelled"];
36
+ };
37
+ readonly priority: {
38
+ readonly enum: readonly ["high", "medium", "low"];
39
+ };
40
+ };
41
+ };
42
+ };
43
+ };
44
+ };
45
+ export declare const todoTrackerName = "todotracker";
46
+ export declare const todoTrackerDescription: string;
47
+ export declare function isTodoTrackerInput(value: unknown): value is TodoTrackerInput;
48
+ export declare function isTodoResultMetadata(value: unknown): value is TodoTrackerResultMetadata;
49
+ export declare function todosFromMessages(messages: readonly SessionMessageInfo[]): readonly Todo[];
50
+ export declare class TodoTracker {
51
+ static marker(status: TodoStatus): string;
52
+ static sidebar(messages: readonly SessionMessageInfo[], completed?: boolean): readonly Todo[];
53
+ static register(context: ServerPlugin.Context, config: PluginConfig): Promise<import("@opencode/plugin/promise/registration").Registration>;
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -15,8 +15,11 @@ class ConfigDefaults {
15
15
  }
16
16
  static getDefaultConfig() {
17
17
  return {
18
+ enforceTodoTracker: true,
18
19
  planDirectory: "docs/_planning",
19
- skillMappings: this.getDefaultSkillMappings()
20
+ readOnlyTools: [],
21
+ skillMappings: this.getDefaultSkillMappings(),
22
+ writeCapableTools: []
20
23
  };
21
24
  }
22
25
  }
@@ -39,14 +42,23 @@ class ConfigTypes {
39
42
  class ConfigMerger {
40
43
  static merge(userConfig) {
41
44
  const config = ConfigDefaults.getDefaultConfig();
45
+ if (userConfig.enforceTodoTracker !== undefined) {
46
+ config.enforceTodoTracker = userConfig.enforceTodoTracker;
47
+ }
42
48
  if (userConfig.planDirectory !== undefined) {
43
49
  config.planDirectory = userConfig.planDirectory;
44
50
  }
51
+ if (userConfig.readOnlyTools !== undefined) {
52
+ config.readOnlyTools = userConfig.readOnlyTools;
53
+ }
45
54
  if (userConfig.skillMappings !== undefined) {
46
55
  for (const command of ConfigTypes.getSkillCommands()) {
47
56
  config.skillMappings[command] = userConfig.skillMappings[command] ?? config.skillMappings[command];
48
57
  }
49
58
  }
59
+ if (userConfig.writeCapableTools !== undefined) {
60
+ config.writeCapableTools = userConfig.writeCapableTools;
61
+ }
50
62
  return config;
51
63
  }
52
64
  }
@@ -58,6 +70,11 @@ class ConfigValidator {
58
70
  if (config.planDirectory.trim() === "") {
59
71
  errors.push("Invalid planDirectory: must be a non-empty string.");
60
72
  }
73
+ for (const [option, tools] of [["readOnlyTools", config.readOnlyTools], ["writeCapableTools", config.writeCapableTools]]) {
74
+ if (tools.some((tool) => tool.trim() === "")) {
75
+ errors.push(`Invalid ${option}: tool names must be non-empty strings.`);
76
+ }
77
+ }
61
78
  for (const command of ConfigTypes.getSkillCommands()) {
62
79
  if (config.skillMappings[command].trim() === "") {
63
80
  errors.push(`Invalid skillMappings.${command}: must be a non-empty string.`);
@@ -84,6 +101,23 @@ class ConfigLoader {
84
101
  const directory = this.stringOption(value)?.replace(/\/+$/, "");
85
102
  return directory === "" ? undefined : directory;
86
103
  }
104
+ static booleanOption(value) {
105
+ return typeof value === "boolean" ? value : undefined;
106
+ }
107
+ static toolNamesOption(value) {
108
+ if (!Array.isArray(value)) {
109
+ return;
110
+ }
111
+ const toolNames = [];
112
+ for (const valueTool of value) {
113
+ const tool = this.stringOption(valueTool);
114
+ if (tool === undefined) {
115
+ return;
116
+ }
117
+ toolNames.push(tool);
118
+ }
119
+ return toolNames;
120
+ }
87
121
  static skillMappingsOption(value) {
88
122
  if (typeof value !== "object" || value === null) {
89
123
  return;
@@ -99,11 +133,17 @@ class ConfigLoader {
99
133
  return skillMappings;
100
134
  }
101
135
  static getUserConfig(options) {
136
+ const enforceTodoTracker = this.booleanOption(options?.enforceTodoTracker);
102
137
  const planDirectory = this.planDirectoryOption(options?.planDirectory);
138
+ const readOnlyTools = this.toolNamesOption(options?.readOnlyTools);
103
139
  const skillMappings = this.skillMappingsOption(options?.skillMappings);
140
+ const writeCapableTools = this.toolNamesOption(options?.writeCapableTools);
104
141
  return {
142
+ ...enforceTodoTracker === undefined ? {} : { enforceTodoTracker },
105
143
  ...planDirectory === undefined ? {} : { planDirectory },
106
- ...skillMappings === undefined ? {} : { skillMappings }
144
+ ...readOnlyTools === undefined ? {} : { readOnlyTools },
145
+ ...skillMappings === undefined ? {} : { skillMappings },
146
+ ...writeCapableTools === undefined ? {} : { writeCapableTools }
107
147
  };
108
148
  }
109
149
  static loadConfig(options) {
@@ -128,6 +168,297 @@ class SkillCommands {
128
168
  }
129
169
  }
130
170
 
171
+ // src/lib/TodoTracker.ts
172
+ var todoTrackerInput = {
173
+ type: "object",
174
+ additionalProperties: false,
175
+ required: ["todos"],
176
+ properties: {
177
+ todos: {
178
+ type: "array",
179
+ items: {
180
+ type: "object",
181
+ additionalProperties: false,
182
+ required: ["content", "status", "priority"],
183
+ properties: {
184
+ content: { type: "string", minLength: 1, pattern: "\\S" },
185
+ status: { enum: ["pending", "in_progress", "completed", "cancelled"] },
186
+ priority: { enum: ["high", "medium", "low"] }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ };
192
+ var todoTrackerName = "todotracker";
193
+ var todoTrackerDescription = "Replace the current session's complete ordered todo list. " + "Pass every remaining todo on each call; pass an empty array to clear the list.";
194
+ var trackerRefreshMessage = "Update todotracker with the complete current todo list, including newly discovered work and current statuses, before continuing tool work.";
195
+ var todoStatuses = ["pending", "in_progress", "completed", "cancelled"];
196
+ var todoPriorities = ["high", "medium", "low"];
197
+ function isRecord(value) {
198
+ return typeof value === "object" && value !== null && !Array.isArray(value);
199
+ }
200
+ function hasOnlyKeys(value, keys) {
201
+ const expected = new Set(keys);
202
+ return Object.keys(value).length === keys.length && Object.keys(value).every((key) => expected.has(key));
203
+ }
204
+ function isTodoStatus(value) {
205
+ return typeof value === "string" && todoStatuses.includes(value);
206
+ }
207
+ function isTodoPriority(value) {
208
+ return typeof value === "string" && todoPriorities.includes(value);
209
+ }
210
+ function isTodo(value) {
211
+ if (!isRecord(value) || !hasOnlyKeys(value, ["content", "status", "priority"])) {
212
+ return false;
213
+ }
214
+ return typeof value.content === "string" && value.content.length >= 1 && /\S/.test(value.content) && isTodoStatus(value.status) && isTodoPriority(value.priority);
215
+ }
216
+ function isTodoList(value) {
217
+ return Array.isArray(value) && value.every(isTodo);
218
+ }
219
+ function isTodoTrackerInput(value) {
220
+ return isRecord(value) && hasOnlyKeys(value, ["todos"]) && isTodoList(value.todos);
221
+ }
222
+ function isTodoResultMetadata(value) {
223
+ return isTodoTrackerInput(value);
224
+ }
225
+ function todosFromToolResult(metadata, content) {
226
+ if (isTodoResultMetadata(metadata)) {
227
+ return metadata.todos;
228
+ }
229
+ for (const item of content) {
230
+ if (item.type !== "text" || item.text === undefined) {
231
+ continue;
232
+ }
233
+ try {
234
+ const todos = JSON.parse(item.text);
235
+ if (isTodoList(todos)) {
236
+ return todos;
237
+ }
238
+ } catch {}
239
+ }
240
+ return [];
241
+ }
242
+ function todosFromMessages(messages) {
243
+ for (const message of messages.toReversed()) {
244
+ if (message.type !== "assistant")
245
+ continue;
246
+ for (const part of message.content.toReversed()) {
247
+ if (part.type === "tool" && part.name === todoTrackerName && part.state.status === "completed") {
248
+ return todosFromToolResult(part.state.metadata, part.state.content);
249
+ }
250
+ }
251
+ }
252
+ return [];
253
+ }
254
+ var builtInReadOnlyTools = new Set(["glob", "grep", "lsp", "read", "skill", "webfetch", "websearch"]);
255
+ var builtInWriteCapableTools = new Set(["edit", "patch", "shell", "subagent", "write"]);
256
+ var compatibilityWriteCapableTools = new Set(["apply_patch", "bash"]);
257
+ var readToolsBeforeRefresh = 10;
258
+ function refreshedTrackerState() {
259
+ return { kind: "tracked", readToolsSinceRefresh: 0, requiresRefreshBeforeWrite: false };
260
+ }
261
+ function isReadOnlyTool(tool, config) {
262
+ return builtInReadOnlyTools.has(tool) || config.readOnlyTools.includes(tool);
263
+ }
264
+ function isWriteCapableTool(tool, config) {
265
+ return builtInWriteCapableTools.has(tool) || compatibilityWriteCapableTools.has(tool) || config.writeCapableTools.includes(tool);
266
+ }
267
+ function requiresTrackerRefresh(state, tool, config) {
268
+ if (state.kind === "untracked" || tool === todoTrackerName) {
269
+ return false;
270
+ }
271
+ if (isWriteCapableTool(tool, config)) {
272
+ return state.requiresRefreshBeforeWrite;
273
+ }
274
+ if (isReadOnlyTool(tool, config)) {
275
+ return state.readToolsSinceRefresh >= readToolsBeforeRefresh;
276
+ }
277
+ return false;
278
+ }
279
+ function requiresTrackerRefreshBeforeMoreWork(state) {
280
+ return state.kind === "tracked" && (state.requiresRefreshBeforeWrite || state.readToolsSinceRefresh >= readToolsBeforeRefresh);
281
+ }
282
+ function trackerStateFromMessages(messages, config) {
283
+ let state = { kind: "untracked" };
284
+ for (const message of messages) {
285
+ if (message.type !== "assistant") {
286
+ continue;
287
+ }
288
+ for (const part of message.content) {
289
+ if (part.type !== "tool" || part.state.status !== "completed") {
290
+ continue;
291
+ }
292
+ if (part.name === todoTrackerName) {
293
+ state = refreshedTrackerState();
294
+ } else if (state.kind === "tracked") {
295
+ if (isWriteCapableTool(part.name, config)) {
296
+ state.requiresRefreshBeforeWrite = true;
297
+ } else if (isReadOnlyTool(part.name, config)) {
298
+ state.readToolsSinceRefresh += 1;
299
+ }
300
+ }
301
+ }
302
+ }
303
+ return state;
304
+ }
305
+
306
+ class TodoTrackerEnforcement {
307
+ context;
308
+ config;
309
+ states = new Map;
310
+ constructor(context, config) {
311
+ this.context = context;
312
+ this.config = config;
313
+ }
314
+ async stateFor(sessionID) {
315
+ const existing = this.states.get(sessionID);
316
+ if (existing !== undefined) {
317
+ return existing;
318
+ }
319
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }), this.config);
320
+ this.states.set(sessionID, state);
321
+ return state;
322
+ }
323
+ async markWriteRequiresRefresh(sessionID) {
324
+ const state = await this.stateFor(sessionID);
325
+ if (state.kind === "tracked") {
326
+ state.requiresRefreshBeforeWrite = true;
327
+ }
328
+ }
329
+ async register() {
330
+ let before;
331
+ let after;
332
+ let context;
333
+ try {
334
+ before = await this.context.tool.hook("execute.before", async (event) => {
335
+ const state = await this.stateFor(event.sessionID);
336
+ if (requiresTrackerRefresh(state, event.tool, this.config)) {
337
+ throw new Error(trackerRefreshMessage);
338
+ }
339
+ if (state.kind === "tracked") {
340
+ if (isWriteCapableTool(event.tool, this.config)) {
341
+ state.requiresRefreshBeforeWrite = true;
342
+ } else if (isReadOnlyTool(event.tool, this.config)) {
343
+ state.readToolsSinceRefresh += 1;
344
+ }
345
+ }
346
+ });
347
+ after = await this.context.tool.hook("execute.after", async (event) => {
348
+ if (event.status !== "completed") {
349
+ return;
350
+ }
351
+ if (event.tool === todoTrackerName) {
352
+ this.states.set(event.sessionID, refreshedTrackerState());
353
+ return;
354
+ }
355
+ const state = await this.stateFor(event.sessionID);
356
+ if (state.kind !== "tracked") {
357
+ return;
358
+ }
359
+ });
360
+ context = await this.context.session.hook("context", async (event) => {
361
+ const state = await this.stateFor(event.sessionID);
362
+ if (requiresTrackerRefreshBeforeMoreWork(state)) {
363
+ event.system.push({ type: "text", text: trackerRefreshMessage });
364
+ }
365
+ });
366
+ const beforeRegistration = before;
367
+ const afterRegistration = after;
368
+ const contextRegistration = context;
369
+ const controller = new AbortController;
370
+ const observer = this.observeSubagentCompletion(controller.signal);
371
+ return {
372
+ dispose: async () => {
373
+ controller.abort();
374
+ await Promise.all([
375
+ beforeRegistration.dispose(),
376
+ afterRegistration.dispose(),
377
+ contextRegistration.dispose(),
378
+ observer
379
+ ]);
380
+ }
381
+ };
382
+ } catch (error) {
383
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
384
+ throw error;
385
+ }
386
+ }
387
+ async observeSubagentCompletion(signal) {
388
+ try {
389
+ for await (const event of this.context.event.subscribe({ signal })) {
390
+ if (event.type !== "session.idle") {
391
+ continue;
392
+ }
393
+ const session = await this.context.session.get({ sessionID: event.data.sessionID });
394
+ if (session.parentID !== undefined) {
395
+ await this.markWriteRequiresRefresh(session.parentID);
396
+ }
397
+ }
398
+ } catch (error) {
399
+ if (!signal.aborted) {
400
+ console.error("Unable to watch subagent completion for todo enforcement.", error);
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ class TodoTracker {
407
+ static marker(status) {
408
+ switch (status) {
409
+ case "pending":
410
+ return "[ ]";
411
+ case "in_progress":
412
+ return "[~]";
413
+ case "completed":
414
+ return "[x]";
415
+ case "cancelled":
416
+ return "[-]";
417
+ }
418
+ }
419
+ static sidebar(messages, completed = false) {
420
+ const todos = todosFromMessages(messages);
421
+ if (!completed) {
422
+ return todos;
423
+ }
424
+ return todos.map((todo) => todo.status === "pending" || todo.status === "in_progress" ? { ...todo, status: "completed" } : todo);
425
+ }
426
+ static async register(context, config) {
427
+ const tool = await context.tool.transform((tools) => {
428
+ tools.add({
429
+ name: todoTrackerName,
430
+ description: todoTrackerDescription,
431
+ input: todoTrackerInput,
432
+ options: { codemode: false },
433
+ async execute(input) {
434
+ if (!isTodoTrackerInput(input)) {
435
+ throw new Error("Invalid todotracker input.");
436
+ }
437
+ return { content: JSON.stringify(input.todos), metadata: { todos: input.todos } };
438
+ }
439
+ });
440
+ });
441
+ if (!config.enforceTodoTracker) {
442
+ return tool;
443
+ }
444
+ try {
445
+ const enforcement = await new TodoTrackerEnforcement(context, config).register();
446
+ return {
447
+ dispose: async () => {
448
+ try {
449
+ await enforcement.dispose();
450
+ } finally {
451
+ await tool.dispose();
452
+ }
453
+ }
454
+ };
455
+ } catch (error) {
456
+ await tool.dispose();
457
+ throw error;
458
+ }
459
+ }
460
+ }
461
+
131
462
  // src/server/index.ts
132
463
  var server_default = Plugin.define({
133
464
  id: "@encoredigitalgroup/opencode",
@@ -135,7 +466,7 @@ var server_default = Plugin.define({
135
466
  const pluginConfig = ConfigLoader.loadConfig(ctx.options);
136
467
  const command = "feature-planning";
137
468
  const route = SkillCommands.getCommands(pluginConfig)[command];
138
- const registration = await ctx.command.transform((commands) => {
469
+ const commandRegistration = await ctx.command.transform((commands) => {
139
470
  commands.add({
140
471
  name: command,
141
472
  description: route.description,
@@ -154,7 +485,20 @@ var server_default = Plugin.define({
154
485
  }
155
486
  });
156
487
  });
157
- return registration.dispose;
488
+ let toolRegistration;
489
+ try {
490
+ toolRegistration = await TodoTracker.register(ctx, pluginConfig);
491
+ } catch (error) {
492
+ await commandRegistration.dispose();
493
+ throw error;
494
+ }
495
+ return async () => {
496
+ try {
497
+ await toolRegistration.dispose();
498
+ } finally {
499
+ await commandRegistration.dispose();
500
+ }
501
+ };
158
502
  }
159
503
  });
160
504
  export {