@encoredigitalgroup/opencode 0.2.5 → 0.2.7

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
@@ -32,9 +32,8 @@ The plugin resolves the mapped skills through OpenCode, so those skills can live
32
32
  ## Configuration
33
33
 
34
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.
35
+ any supported command's installed skill name. `enforceTodoTracker` defaults to `true`; set it to `false` to disable tracker-refresh enforcement. Unspecified values
36
+ use `docs/_planning` and the command's existing skill name.
38
37
 
39
38
  ```json
40
39
  {
@@ -42,16 +41,14 @@ and no custom tool classifications.
42
41
  "plugins": [
43
42
  {
44
43
  "package": "@encoredigitalgroup/opencode",
45
- "options": {
46
- "planDirectory": "plans",
47
- "enforceTodoTracker": false,
48
- "readOnlyTools": ["repository_status"],
49
- "skillMappings": {
50
- "plan-review": "custom-plan-review",
51
- "plan-execute": "custom-plan-execute"
52
- },
53
- "writeCapableTools": ["deploy"]
54
- }
44
+ "options": {
45
+ "planDirectory": "plans",
46
+ "enforceTodoTracker": false,
47
+ "skillMappings": {
48
+ "plan-review": "custom-plan-review",
49
+ "plan-execute": "custom-plan-execute"
50
+ }
51
+ }
55
52
  }
56
53
  ]
57
54
  }
@@ -70,17 +67,21 @@ Each todo requires:
70
67
  - `status`: `pending`, `in_progress`, `completed`, or `cancelled`.
71
68
  - `priority`: `high`, `medium`, or `low`.
72
69
 
70
+ Each tracker call replaces the complete ordered list. Include discovered work and current statuses in every replacement. Mark a todo completed only after
71
+ verifying its result. Mark a todo cancelled only when the user explicitly cancels that work. Never clear a list or change a status to avoid tracker updates,
72
+ tool restrictions, or difficult work.
73
+
73
74
  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
75
  message cache. That cache starts with the newest 20 messages, so older results are not shown until the host has loaded them.
75
76
 
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`.
77
+ Automatic refresh enforcement runs only while the latest valid list contains nonterminal work. After a session's first successful `todotracker` call, the plugin
78
+ requires another update at least once per minute while that work remains. When an update is due, it blocks every other tool until the tracker is updated. A valid
79
+ empty list or a list containing only `completed` and `cancelled` todos ends automatic refresh enforcement. A tracked subagent completing also requires its parent
80
+ session to update the tracker before continuing. Malformed tracker results remain enforcement-active because completion was not verified. Enforcement is disabled
81
+ for sessions that have never used `todotracker`, and can be disabled for all sessions with `"enforceTodoTracker": false`.
82
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.
83
+ When a root session is idle, the sidebar renders the final in-progress todo as completed only when every earlier todo is completed. It restores the original status
84
+ when work resumes.
84
85
 
85
86
  ## Development
86
87
 
@@ -4,15 +4,19 @@ export type SkillMappings = Record<SkillCommand, string>;
4
4
  export type PluginConfig = {
5
5
  enforceTodoTracker: boolean;
6
6
  planDirectory: string;
7
+ /** @deprecated Tool classifications no longer affect todo tracker enforcement. */
7
8
  readOnlyTools: string[];
8
9
  skillMappings: SkillMappings;
10
+ /** @deprecated Tool classifications no longer affect todo tracker enforcement. */
9
11
  writeCapableTools: string[];
10
12
  };
11
13
  export type UserPluginConfig = {
12
14
  enforceTodoTracker?: boolean;
13
15
  planDirectory?: string;
16
+ /** @deprecated Tool classifications no longer affect todo tracker enforcement. */
14
17
  readOnlyTools?: string[];
15
18
  skillMappings?: Partial<SkillMappings>;
19
+ /** @deprecated Tool classifications no longer affect todo tracker enforcement. */
16
20
  writeCapableTools?: string[];
17
21
  };
18
22
  /** Configuration type utilities. */
@@ -50,5 +50,7 @@ export declare function todosFromMessages(messages: readonly SessionMessageInfo[
50
50
  export declare class TodoTracker {
51
51
  static marker(status: TodoStatus): string;
52
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>;
53
+ static register(context: ServerPlugin.Context, config: PluginConfig): Promise<{
54
+ dispose: () => Promise<void>;
55
+ }>;
54
56
  }
@@ -192,6 +192,10 @@ var todoTrackerInput = {
192
192
  var todoTrackerName = "todotracker";
193
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
194
  var trackerRefreshMessage = "Update todotracker with the complete current todo list, including newly discovered work and current statuses, before continuing tool work.";
195
+ var todoTrackerContract = "Todotracker rules: Each call replaces the complete ordered todo list. Include discovered work and current statuses in every replacement. Mark a todo completed only after verifying its result. Mark a todo cancelled only when the user explicitly cancels that work. Never clear a list or change a status to avoid tracker updates, tool restrictions, or difficult work.";
196
+ var compactionTrackerContract = `Retain this TodoTracker contract verbatim in the continuation summary:
197
+
198
+ ${todoTrackerContract}`;
195
199
  var todoStatuses = ["pending", "in_progress", "completed", "cancelled"];
196
200
  var todoPriorities = ["high", "medium", "low"];
197
201
  function isRecord(value) {
@@ -222,22 +226,34 @@ function isTodoTrackerInput(value) {
222
226
  function isTodoResultMetadata(value) {
223
227
  return isTodoTrackerInput(value);
224
228
  }
225
- function todosFromToolResult(metadata, content) {
229
+ function parseTodoList(metadata, content) {
226
230
  if (isTodoResultMetadata(metadata)) {
227
- return metadata.todos;
231
+ return { valid: true, todos: metadata.todos };
228
232
  }
229
- for (const item of content) {
233
+ const contentItems = typeof content === "string" ? [{ type: "text", text: content }] : content;
234
+ for (const item of contentItems) {
230
235
  if (item.type !== "text" || item.text === undefined) {
231
236
  continue;
232
237
  }
233
238
  try {
234
239
  const todos = JSON.parse(item.text);
235
240
  if (isTodoList(todos)) {
236
- return todos;
241
+ return { valid: true, todos };
237
242
  }
238
243
  } catch {}
239
244
  }
240
- return [];
245
+ return { valid: false };
246
+ }
247
+ function todosFromToolResult(metadata, content) {
248
+ const parsed = parseTodoList(metadata, content);
249
+ return parsed.valid ? parsed.todos : [];
250
+ }
251
+ function isTerminalTodoList(todos) {
252
+ return todos.every((todo) => todo.status === "completed" || todo.status === "cancelled");
253
+ }
254
+ function stateFromTodoResult(metadata, content, refreshedAt = Date.now()) {
255
+ const parsed = parseTodoList(metadata, content);
256
+ return parsed.valid && isTerminalTodoList(parsed.todos) ? { kind: "untracked" } : refreshedTrackerState(refreshedAt);
241
257
  }
242
258
  function todosFromMessages(messages) {
243
259
  for (const message of messages.toReversed()) {
@@ -251,35 +267,17 @@ function todosFromMessages(messages) {
251
267
  }
252
268
  return [];
253
269
  }
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 };
270
+ var trackerRefreshIntervalMs = 60000;
271
+ function refreshedTrackerState(refreshedAt = Date.now()) {
272
+ return { kind: "tracked", refreshedAt, refreshRequired: false };
260
273
  }
261
- function isReadOnlyTool(tool, config) {
262
- return builtInReadOnlyTools.has(tool) || config.readOnlyTools.includes(tool);
274
+ function trackerRefreshDue(state, now = Date.now()) {
275
+ return state.kind === "tracked" && (state.refreshRequired || now - state.refreshedAt >= trackerRefreshIntervalMs);
263
276
  }
264
- function isWriteCapableTool(tool, config) {
265
- return builtInWriteCapableTools.has(tool) || compatibilityWriteCapableTools.has(tool) || config.writeCapableTools.includes(tool);
277
+ function requiresTrackerRefresh(state, tool, now = Date.now()) {
278
+ return tool !== todoTrackerName && trackerRefreshDue(state, now);
266
279
  }
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) {
280
+ function trackerStateFromMessages(messages) {
283
281
  let state = { kind: "untracked" };
284
282
  for (const message of messages) {
285
283
  if (message.type !== "assistant") {
@@ -290,13 +288,7 @@ function trackerStateFromMessages(messages, config) {
290
288
  continue;
291
289
  }
292
290
  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
- }
291
+ state = stateFromTodoResult(part.state.metadata, part.state.content, part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
300
292
  }
301
293
  }
302
294
  }
@@ -305,82 +297,81 @@ function trackerStateFromMessages(messages, config) {
305
297
 
306
298
  class TodoTrackerEnforcement {
307
299
  context;
308
- config;
309
300
  states = new Map;
310
- constructor(context, config) {
301
+ instructedSessionIDs = new Set;
302
+ constructor(context) {
311
303
  this.context = context;
312
- this.config = config;
313
304
  }
314
305
  async stateFor(sessionID) {
315
306
  const existing = this.states.get(sessionID);
316
307
  if (existing !== undefined) {
317
308
  return existing;
318
309
  }
319
- const state = trackerStateFromMessages(await this.context.session.context({ sessionID }), this.config);
310
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }));
320
311
  this.states.set(sessionID, state);
321
312
  return state;
322
313
  }
323
- async markWriteRequiresRefresh(sessionID) {
314
+ async requireTrackerRefresh(sessionID) {
324
315
  const state = await this.stateFor(sessionID);
325
316
  if (state.kind === "tracked") {
326
- state.requiresRefreshBeforeWrite = true;
317
+ state.refreshRequired = true;
327
318
  }
328
319
  }
329
- async register() {
320
+ async register(enforceRefresh) {
330
321
  let before;
331
322
  let after;
332
323
  let context;
324
+ let compaction;
325
+ let observer;
326
+ const controller = new AbortController;
333
327
  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;
328
+ context = await this.context.session.hook("context", async (event) => {
329
+ if (!this.instructedSessionIDs.has(event.sessionID)) {
330
+ this.instructedSessionIDs.add(event.sessionID);
331
+ event.system.push({ type: "text", text: todoTrackerContract });
354
332
  }
355
- const state = await this.stateFor(event.sessionID);
356
- if (state.kind !== "tracked") {
333
+ if (!enforceRefresh) {
357
334
  return;
358
335
  }
359
- });
360
- context = await this.context.session.hook("context", async (event) => {
361
- const state = await this.stateFor(event.sessionID);
362
- if (requiresTrackerRefreshBeforeMoreWork(state)) {
336
+ if (trackerRefreshDue(await this.stateFor(event.sessionID))) {
363
337
  event.system.push({ type: "text", text: trackerRefreshMessage });
364
338
  }
365
339
  });
340
+ compaction = await this.context.session.hook("compaction", async (event) => {
341
+ event.system.push({ type: "text", text: compactionTrackerContract });
342
+ });
343
+ if (enforceRefresh) {
344
+ before = await this.context.tool.hook("execute.before", async (event) => {
345
+ if (requiresTrackerRefresh(await this.stateFor(event.sessionID), event.tool)) {
346
+ throw new Error(trackerRefreshMessage);
347
+ }
348
+ });
349
+ after = await this.context.tool.hook("execute.after", async (event) => {
350
+ if (event.status === "completed" && event.tool === todoTrackerName) {
351
+ this.states.set(event.sessionID, stateFromTodoResult(event.result.metadata, event.result.content ?? []));
352
+ }
353
+ });
354
+ observer = this.observeSubagentCompletion(controller.signal);
355
+ }
366
356
  const beforeRegistration = before;
367
357
  const afterRegistration = after;
368
358
  const contextRegistration = context;
369
- const controller = new AbortController;
370
- const observer = this.observeSubagentCompletion(controller.signal);
359
+ const compactionRegistration = compaction;
371
360
  return {
372
361
  dispose: async () => {
373
362
  controller.abort();
374
363
  await Promise.all([
375
- beforeRegistration.dispose(),
376
- afterRegistration.dispose(),
364
+ beforeRegistration?.dispose(),
365
+ afterRegistration?.dispose(),
377
366
  contextRegistration.dispose(),
367
+ compactionRegistration.dispose(),
378
368
  observer
379
369
  ]);
380
370
  }
381
371
  };
382
372
  } catch (error) {
383
- await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
373
+ controller.abort();
374
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose(), compaction?.dispose(), observer]);
384
375
  throw error;
385
376
  }
386
377
  }
@@ -392,7 +383,7 @@ class TodoTrackerEnforcement {
392
383
  }
393
384
  const session = await this.context.session.get({ sessionID: event.data.sessionID });
394
385
  if (session.parentID !== undefined) {
395
- await this.markWriteRequiresRefresh(session.parentID);
386
+ await this.requireTrackerRefresh(session.parentID);
396
387
  }
397
388
  }
398
389
  } catch (error) {
@@ -418,10 +409,11 @@ class TodoTracker {
418
409
  }
419
410
  static sidebar(messages, completed = false) {
420
411
  const todos = todosFromMessages(messages);
421
- if (!completed) {
412
+ const finalTodo = todos.at(-1);
413
+ if (!completed || finalTodo?.status !== "in_progress" || todos.slice(0, -1).some((todo) => todo.status !== "completed")) {
422
414
  return todos;
423
415
  }
424
- return todos.map((todo) => todo.status === "pending" || todo.status === "in_progress" ? { ...todo, status: "completed" } : todo);
416
+ return [...todos.slice(0, -1), { ...finalTodo, status: "completed" }];
425
417
  }
426
418
  static async register(context, config) {
427
419
  const tool = await context.tool.transform((tools) => {
@@ -438,11 +430,8 @@ class TodoTracker {
438
430
  }
439
431
  });
440
432
  });
441
- if (!config.enforceTodoTracker) {
442
- return tool;
443
- }
444
433
  try {
445
- const enforcement = await new TodoTrackerEnforcement(context, config).register();
434
+ const enforcement = await new TodoTrackerEnforcement(context).register(config.enforceTodoTracker);
446
435
  return {
447
436
  dispose: async () => {
448
437
  try {
package/dist/tui/index.js CHANGED
@@ -977,6 +977,10 @@ var todoTrackerInput = {
977
977
  var todoTrackerName = "todotracker";
978
978
  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.";
979
979
  var trackerRefreshMessage = "Update todotracker with the complete current todo list, including newly discovered work and current statuses, before continuing tool work.";
980
+ var todoTrackerContract = "Todotracker rules: Each call replaces the complete ordered todo list. Include discovered work and current statuses in every replacement. Mark a todo completed only after verifying its result. Mark a todo cancelled only when the user explicitly cancels that work. Never clear a list or change a status to avoid tracker updates, tool restrictions, or difficult work.";
981
+ var compactionTrackerContract = `Retain this TodoTracker contract verbatim in the continuation summary:
982
+
983
+ ${todoTrackerContract}`;
980
984
  var todoStatuses = ["pending", "in_progress", "completed", "cancelled"];
981
985
  var todoPriorities = ["high", "medium", "low"];
982
986
  function isRecord(value) {
@@ -1007,22 +1011,34 @@ function isTodoTrackerInput(value) {
1007
1011
  function isTodoResultMetadata(value) {
1008
1012
  return isTodoTrackerInput(value);
1009
1013
  }
1010
- function todosFromToolResult(metadata, content) {
1014
+ function parseTodoList(metadata, content) {
1011
1015
  if (isTodoResultMetadata(metadata)) {
1012
- return metadata.todos;
1016
+ return { valid: true, todos: metadata.todos };
1013
1017
  }
1014
- for (const item of content) {
1018
+ const contentItems = typeof content === "string" ? [{ type: "text", text: content }] : content;
1019
+ for (const item of contentItems) {
1015
1020
  if (item.type !== "text" || item.text === undefined) {
1016
1021
  continue;
1017
1022
  }
1018
1023
  try {
1019
1024
  const todos = JSON.parse(item.text);
1020
1025
  if (isTodoList(todos)) {
1021
- return todos;
1026
+ return { valid: true, todos };
1022
1027
  }
1023
1028
  } catch {}
1024
1029
  }
1025
- return [];
1030
+ return { valid: false };
1031
+ }
1032
+ function todosFromToolResult(metadata, content) {
1033
+ const parsed = parseTodoList(metadata, content);
1034
+ return parsed.valid ? parsed.todos : [];
1035
+ }
1036
+ function isTerminalTodoList(todos) {
1037
+ return todos.every((todo) => todo.status === "completed" || todo.status === "cancelled");
1038
+ }
1039
+ function stateFromTodoResult(metadata, content, refreshedAt = Date.now()) {
1040
+ const parsed = parseTodoList(metadata, content);
1041
+ return parsed.valid && isTerminalTodoList(parsed.todos) ? { kind: "untracked" } : refreshedTrackerState(refreshedAt);
1026
1042
  }
1027
1043
  function todosFromMessages(messages) {
1028
1044
  for (const message of messages.toReversed()) {
@@ -1036,35 +1052,17 @@ function todosFromMessages(messages) {
1036
1052
  }
1037
1053
  return [];
1038
1054
  }
1039
- var builtInReadOnlyTools = new Set(["glob", "grep", "lsp", "read", "skill", "webfetch", "websearch"]);
1040
- var builtInWriteCapableTools = new Set(["edit", "patch", "shell", "subagent", "write"]);
1041
- var compatibilityWriteCapableTools = new Set(["apply_patch", "bash"]);
1042
- var readToolsBeforeRefresh = 10;
1043
- function refreshedTrackerState() {
1044
- return { kind: "tracked", readToolsSinceRefresh: 0, requiresRefreshBeforeWrite: false };
1045
- }
1046
- function isReadOnlyTool(tool, config) {
1047
- return builtInReadOnlyTools.has(tool) || config.readOnlyTools.includes(tool);
1055
+ var trackerRefreshIntervalMs = 60000;
1056
+ function refreshedTrackerState(refreshedAt = Date.now()) {
1057
+ return { kind: "tracked", refreshedAt, refreshRequired: false };
1048
1058
  }
1049
- function isWriteCapableTool(tool, config) {
1050
- return builtInWriteCapableTools.has(tool) || compatibilityWriteCapableTools.has(tool) || config.writeCapableTools.includes(tool);
1059
+ function trackerRefreshDue(state, now = Date.now()) {
1060
+ return state.kind === "tracked" && (state.refreshRequired || now - state.refreshedAt >= trackerRefreshIntervalMs);
1051
1061
  }
1052
- function requiresTrackerRefresh(state, tool, config) {
1053
- if (state.kind === "untracked" || tool === todoTrackerName) {
1054
- return false;
1055
- }
1056
- if (isWriteCapableTool(tool, config)) {
1057
- return state.requiresRefreshBeforeWrite;
1058
- }
1059
- if (isReadOnlyTool(tool, config)) {
1060
- return state.readToolsSinceRefresh >= readToolsBeforeRefresh;
1061
- }
1062
- return false;
1062
+ function requiresTrackerRefresh(state, tool, now = Date.now()) {
1063
+ return tool !== todoTrackerName && trackerRefreshDue(state, now);
1063
1064
  }
1064
- function requiresTrackerRefreshBeforeMoreWork(state) {
1065
- return state.kind === "tracked" && (state.requiresRefreshBeforeWrite || state.readToolsSinceRefresh >= readToolsBeforeRefresh);
1066
- }
1067
- function trackerStateFromMessages(messages, config) {
1065
+ function trackerStateFromMessages(messages) {
1068
1066
  let state = { kind: "untracked" };
1069
1067
  for (const message of messages) {
1070
1068
  if (message.type !== "assistant") {
@@ -1075,13 +1073,7 @@ function trackerStateFromMessages(messages, config) {
1075
1073
  continue;
1076
1074
  }
1077
1075
  if (part.name === todoTrackerName) {
1078
- state = refreshedTrackerState();
1079
- } else if (state.kind === "tracked") {
1080
- if (isWriteCapableTool(part.name, config)) {
1081
- state.requiresRefreshBeforeWrite = true;
1082
- } else if (isReadOnlyTool(part.name, config)) {
1083
- state.readToolsSinceRefresh += 1;
1084
- }
1076
+ state = stateFromTodoResult(part.state.metadata, part.state.content, part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
1085
1077
  }
1086
1078
  }
1087
1079
  }
@@ -1090,82 +1082,81 @@ function trackerStateFromMessages(messages, config) {
1090
1082
 
1091
1083
  class TodoTrackerEnforcement {
1092
1084
  context;
1093
- config;
1094
1085
  states = new Map;
1095
- constructor(context, config) {
1086
+ instructedSessionIDs = new Set;
1087
+ constructor(context) {
1096
1088
  this.context = context;
1097
- this.config = config;
1098
1089
  }
1099
1090
  async stateFor(sessionID) {
1100
1091
  const existing = this.states.get(sessionID);
1101
1092
  if (existing !== undefined) {
1102
1093
  return existing;
1103
1094
  }
1104
- const state = trackerStateFromMessages(await this.context.session.context({ sessionID }), this.config);
1095
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }));
1105
1096
  this.states.set(sessionID, state);
1106
1097
  return state;
1107
1098
  }
1108
- async markWriteRequiresRefresh(sessionID) {
1099
+ async requireTrackerRefresh(sessionID) {
1109
1100
  const state = await this.stateFor(sessionID);
1110
1101
  if (state.kind === "tracked") {
1111
- state.requiresRefreshBeforeWrite = true;
1102
+ state.refreshRequired = true;
1112
1103
  }
1113
1104
  }
1114
- async register() {
1105
+ async register(enforceRefresh) {
1115
1106
  let before;
1116
1107
  let after;
1117
1108
  let context;
1109
+ let compaction;
1110
+ let observer;
1111
+ const controller = new AbortController;
1118
1112
  try {
1119
- before = await this.context.tool.hook("execute.before", async (event) => {
1120
- const state = await this.stateFor(event.sessionID);
1121
- if (requiresTrackerRefresh(state, event.tool, this.config)) {
1122
- throw new Error(trackerRefreshMessage);
1123
- }
1124
- if (state.kind === "tracked") {
1125
- if (isWriteCapableTool(event.tool, this.config)) {
1126
- state.requiresRefreshBeforeWrite = true;
1127
- } else if (isReadOnlyTool(event.tool, this.config)) {
1128
- state.readToolsSinceRefresh += 1;
1129
- }
1130
- }
1131
- });
1132
- after = await this.context.tool.hook("execute.after", async (event) => {
1133
- if (event.status !== "completed") {
1134
- return;
1135
- }
1136
- if (event.tool === todoTrackerName) {
1137
- this.states.set(event.sessionID, refreshedTrackerState());
1138
- return;
1113
+ context = await this.context.session.hook("context", async (event) => {
1114
+ if (!this.instructedSessionIDs.has(event.sessionID)) {
1115
+ this.instructedSessionIDs.add(event.sessionID);
1116
+ event.system.push({ type: "text", text: todoTrackerContract });
1139
1117
  }
1140
- const state = await this.stateFor(event.sessionID);
1141
- if (state.kind !== "tracked") {
1118
+ if (!enforceRefresh) {
1142
1119
  return;
1143
1120
  }
1144
- });
1145
- context = await this.context.session.hook("context", async (event) => {
1146
- const state = await this.stateFor(event.sessionID);
1147
- if (requiresTrackerRefreshBeforeMoreWork(state)) {
1121
+ if (trackerRefreshDue(await this.stateFor(event.sessionID))) {
1148
1122
  event.system.push({ type: "text", text: trackerRefreshMessage });
1149
1123
  }
1150
1124
  });
1125
+ compaction = await this.context.session.hook("compaction", async (event) => {
1126
+ event.system.push({ type: "text", text: compactionTrackerContract });
1127
+ });
1128
+ if (enforceRefresh) {
1129
+ before = await this.context.tool.hook("execute.before", async (event) => {
1130
+ if (requiresTrackerRefresh(await this.stateFor(event.sessionID), event.tool)) {
1131
+ throw new Error(trackerRefreshMessage);
1132
+ }
1133
+ });
1134
+ after = await this.context.tool.hook("execute.after", async (event) => {
1135
+ if (event.status === "completed" && event.tool === todoTrackerName) {
1136
+ this.states.set(event.sessionID, stateFromTodoResult(event.result.metadata, event.result.content ?? []));
1137
+ }
1138
+ });
1139
+ observer = this.observeSubagentCompletion(controller.signal);
1140
+ }
1151
1141
  const beforeRegistration = before;
1152
1142
  const afterRegistration = after;
1153
1143
  const contextRegistration = context;
1154
- const controller = new AbortController;
1155
- const observer = this.observeSubagentCompletion(controller.signal);
1144
+ const compactionRegistration = compaction;
1156
1145
  return {
1157
1146
  dispose: async () => {
1158
1147
  controller.abort();
1159
1148
  await Promise.all([
1160
- beforeRegistration.dispose(),
1161
- afterRegistration.dispose(),
1149
+ beforeRegistration?.dispose(),
1150
+ afterRegistration?.dispose(),
1162
1151
  contextRegistration.dispose(),
1152
+ compactionRegistration.dispose(),
1163
1153
  observer
1164
1154
  ]);
1165
1155
  }
1166
1156
  };
1167
1157
  } catch (error) {
1168
- await Promise.all([before?.dispose(), after?.dispose(), context?.dispose()]);
1158
+ controller.abort();
1159
+ await Promise.all([before?.dispose(), after?.dispose(), context?.dispose(), compaction?.dispose(), observer]);
1169
1160
  throw error;
1170
1161
  }
1171
1162
  }
@@ -1177,7 +1168,7 @@ class TodoTrackerEnforcement {
1177
1168
  }
1178
1169
  const session = await this.context.session.get({ sessionID: event.data.sessionID });
1179
1170
  if (session.parentID !== undefined) {
1180
- await this.markWriteRequiresRefresh(session.parentID);
1171
+ await this.requireTrackerRefresh(session.parentID);
1181
1172
  }
1182
1173
  }
1183
1174
  } catch (error) {
@@ -1203,10 +1194,11 @@ class TodoTracker {
1203
1194
  }
1204
1195
  static sidebar(messages, completed = false) {
1205
1196
  const todos = todosFromMessages(messages);
1206
- if (!completed) {
1197
+ const finalTodo = todos.at(-1);
1198
+ if (!completed || finalTodo?.status !== "in_progress" || todos.slice(0, -1).some((todo) => todo.status !== "completed")) {
1207
1199
  return todos;
1208
1200
  }
1209
- return todos.map((todo) => todo.status === "pending" || todo.status === "in_progress" ? { ...todo, status: "completed" } : todo);
1201
+ return [...todos.slice(0, -1), { ...finalTodo, status: "completed" }];
1210
1202
  }
1211
1203
  static async register(context, config) {
1212
1204
  const tool = await context.tool.transform((tools) => {
@@ -1223,11 +1215,8 @@ class TodoTracker {
1223
1215
  }
1224
1216
  });
1225
1217
  });
1226
- if (!config.enforceTodoTracker) {
1227
- return tool;
1228
- }
1229
1218
  try {
1230
- const enforcement = await new TodoTrackerEnforcement(context, config).register();
1219
+ const enforcement = await new TodoTrackerEnforcement(context).register(config.enforceTodoTracker);
1231
1220
  return {
1232
1221
  dispose: async () => {
1233
1222
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@encoredigitalgroup/opencode",
3
3
  "type": "module",
4
- "version": "0.2.5",
4
+ "version": "0.2.7",
5
5
  "description": "OpenCode file-picker commands for planning skills.",
6
6
  "publishConfig": {
7
7
  "access": "public"