@encoredigitalgroup/opencode 0.2.5 → 0.2.6

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
  }
@@ -73,14 +70,12 @@ Each todo requires:
73
70
  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
71
  message cache. That cache starts with the newest 20 messages, so older results are not shown until the host has loaded them.
75
72
 
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`.
73
+ After a session's first successful `todotracker` call, the plugin requires another update at least once per minute. When an update is due, it blocks every other
74
+ tool until the tracker is updated. A tracked subagent completing also requires its parent session to update the tracker before continuing. Enforcement is disabled
75
+ for sessions that have never used `todotracker`, and can be disabled for all sessions with `"enforceTodoTracker": false`.
82
76
 
83
- When a session is idle, the sidebar renders pending and in-progress todos as completed. It restores their original statuses when work resumes.
77
+ 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
78
+ when work resumes.
84
79
 
85
80
  ## Development
86
81
 
@@ -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. */
@@ -251,35 +251,17 @@ function todosFromMessages(messages) {
251
251
  }
252
252
  return [];
253
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 };
254
+ var trackerRefreshIntervalMs = 60000;
255
+ function refreshedTrackerState(refreshedAt = Date.now()) {
256
+ return { kind: "tracked", refreshedAt, refreshRequired: false };
260
257
  }
261
- function isReadOnlyTool(tool, config) {
262
- return builtInReadOnlyTools.has(tool) || config.readOnlyTools.includes(tool);
258
+ function trackerRefreshDue(state, now = Date.now()) {
259
+ return state.kind === "tracked" && (state.refreshRequired || now - state.refreshedAt >= trackerRefreshIntervalMs);
263
260
  }
264
- function isWriteCapableTool(tool, config) {
265
- return builtInWriteCapableTools.has(tool) || compatibilityWriteCapableTools.has(tool) || config.writeCapableTools.includes(tool);
261
+ function requiresTrackerRefresh(state, tool, now = Date.now()) {
262
+ return tool !== todoTrackerName && trackerRefreshDue(state, now);
266
263
  }
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) {
264
+ function trackerStateFromMessages(messages) {
283
265
  let state = { kind: "untracked" };
284
266
  for (const message of messages) {
285
267
  if (message.type !== "assistant") {
@@ -290,13 +272,7 @@ function trackerStateFromMessages(messages, config) {
290
272
  continue;
291
273
  }
292
274
  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
- }
275
+ state = refreshedTrackerState(part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
300
276
  }
301
277
  }
302
278
  }
@@ -305,25 +281,23 @@ function trackerStateFromMessages(messages, config) {
305
281
 
306
282
  class TodoTrackerEnforcement {
307
283
  context;
308
- config;
309
284
  states = new Map;
310
- constructor(context, config) {
285
+ constructor(context) {
311
286
  this.context = context;
312
- this.config = config;
313
287
  }
314
288
  async stateFor(sessionID) {
315
289
  const existing = this.states.get(sessionID);
316
290
  if (existing !== undefined) {
317
291
  return existing;
318
292
  }
319
- const state = trackerStateFromMessages(await this.context.session.context({ sessionID }), this.config);
293
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }));
320
294
  this.states.set(sessionID, state);
321
295
  return state;
322
296
  }
323
- async markWriteRequiresRefresh(sessionID) {
297
+ async requireTrackerRefresh(sessionID) {
324
298
  const state = await this.stateFor(sessionID);
325
299
  if (state.kind === "tracked") {
326
- state.requiresRefreshBeforeWrite = true;
300
+ state.refreshRequired = true;
327
301
  }
328
302
  }
329
303
  async register() {
@@ -333,16 +307,9 @@ class TodoTrackerEnforcement {
333
307
  try {
334
308
  before = await this.context.tool.hook("execute.before", async (event) => {
335
309
  const state = await this.stateFor(event.sessionID);
336
- if (requiresTrackerRefresh(state, event.tool, this.config)) {
310
+ if (requiresTrackerRefresh(state, event.tool)) {
337
311
  throw new Error(trackerRefreshMessage);
338
312
  }
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
313
  });
347
314
  after = await this.context.tool.hook("execute.after", async (event) => {
348
315
  if (event.status !== "completed") {
@@ -350,16 +317,11 @@ class TodoTrackerEnforcement {
350
317
  }
351
318
  if (event.tool === todoTrackerName) {
352
319
  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
320
  }
359
321
  });
360
322
  context = await this.context.session.hook("context", async (event) => {
361
323
  const state = await this.stateFor(event.sessionID);
362
- if (requiresTrackerRefreshBeforeMoreWork(state)) {
324
+ if (trackerRefreshDue(state)) {
363
325
  event.system.push({ type: "text", text: trackerRefreshMessage });
364
326
  }
365
327
  });
@@ -392,7 +354,7 @@ class TodoTrackerEnforcement {
392
354
  }
393
355
  const session = await this.context.session.get({ sessionID: event.data.sessionID });
394
356
  if (session.parentID !== undefined) {
395
- await this.markWriteRequiresRefresh(session.parentID);
357
+ await this.requireTrackerRefresh(session.parentID);
396
358
  }
397
359
  }
398
360
  } catch (error) {
@@ -418,10 +380,11 @@ class TodoTracker {
418
380
  }
419
381
  static sidebar(messages, completed = false) {
420
382
  const todos = todosFromMessages(messages);
421
- if (!completed) {
383
+ const finalTodo = todos.at(-1);
384
+ if (!completed || finalTodo?.status !== "in_progress" || todos.slice(0, -1).some((todo) => todo.status !== "completed")) {
422
385
  return todos;
423
386
  }
424
- return todos.map((todo) => todo.status === "pending" || todo.status === "in_progress" ? { ...todo, status: "completed" } : todo);
387
+ return [...todos.slice(0, -1), { ...finalTodo, status: "completed" }];
425
388
  }
426
389
  static async register(context, config) {
427
390
  const tool = await context.tool.transform((tools) => {
@@ -442,7 +405,7 @@ class TodoTracker {
442
405
  return tool;
443
406
  }
444
407
  try {
445
- const enforcement = await new TodoTrackerEnforcement(context, config).register();
408
+ const enforcement = await new TodoTrackerEnforcement(context).register();
446
409
  return {
447
410
  dispose: async () => {
448
411
  try {
package/dist/tui/index.js CHANGED
@@ -1036,35 +1036,17 @@ function todosFromMessages(messages) {
1036
1036
  }
1037
1037
  return [];
1038
1038
  }
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 };
1039
+ var trackerRefreshIntervalMs = 60000;
1040
+ function refreshedTrackerState(refreshedAt = Date.now()) {
1041
+ return { kind: "tracked", refreshedAt, refreshRequired: false };
1045
1042
  }
1046
- function isReadOnlyTool(tool, config) {
1047
- return builtInReadOnlyTools.has(tool) || config.readOnlyTools.includes(tool);
1043
+ function trackerRefreshDue(state, now = Date.now()) {
1044
+ return state.kind === "tracked" && (state.refreshRequired || now - state.refreshedAt >= trackerRefreshIntervalMs);
1048
1045
  }
1049
- function isWriteCapableTool(tool, config) {
1050
- return builtInWriteCapableTools.has(tool) || compatibilityWriteCapableTools.has(tool) || config.writeCapableTools.includes(tool);
1046
+ function requiresTrackerRefresh(state, tool, now = Date.now()) {
1047
+ return tool !== todoTrackerName && trackerRefreshDue(state, now);
1051
1048
  }
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;
1063
- }
1064
- function requiresTrackerRefreshBeforeMoreWork(state) {
1065
- return state.kind === "tracked" && (state.requiresRefreshBeforeWrite || state.readToolsSinceRefresh >= readToolsBeforeRefresh);
1066
- }
1067
- function trackerStateFromMessages(messages, config) {
1049
+ function trackerStateFromMessages(messages) {
1068
1050
  let state = { kind: "untracked" };
1069
1051
  for (const message of messages) {
1070
1052
  if (message.type !== "assistant") {
@@ -1075,13 +1057,7 @@ function trackerStateFromMessages(messages, config) {
1075
1057
  continue;
1076
1058
  }
1077
1059
  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
- }
1060
+ state = refreshedTrackerState(part.time?.completed ?? part.time?.ran ?? part.time?.created ?? message.time.completed ?? message.time.created);
1085
1061
  }
1086
1062
  }
1087
1063
  }
@@ -1090,25 +1066,23 @@ function trackerStateFromMessages(messages, config) {
1090
1066
 
1091
1067
  class TodoTrackerEnforcement {
1092
1068
  context;
1093
- config;
1094
1069
  states = new Map;
1095
- constructor(context, config) {
1070
+ constructor(context) {
1096
1071
  this.context = context;
1097
- this.config = config;
1098
1072
  }
1099
1073
  async stateFor(sessionID) {
1100
1074
  const existing = this.states.get(sessionID);
1101
1075
  if (existing !== undefined) {
1102
1076
  return existing;
1103
1077
  }
1104
- const state = trackerStateFromMessages(await this.context.session.context({ sessionID }), this.config);
1078
+ const state = trackerStateFromMessages(await this.context.session.context({ sessionID }));
1105
1079
  this.states.set(sessionID, state);
1106
1080
  return state;
1107
1081
  }
1108
- async markWriteRequiresRefresh(sessionID) {
1082
+ async requireTrackerRefresh(sessionID) {
1109
1083
  const state = await this.stateFor(sessionID);
1110
1084
  if (state.kind === "tracked") {
1111
- state.requiresRefreshBeforeWrite = true;
1085
+ state.refreshRequired = true;
1112
1086
  }
1113
1087
  }
1114
1088
  async register() {
@@ -1118,16 +1092,9 @@ class TodoTrackerEnforcement {
1118
1092
  try {
1119
1093
  before = await this.context.tool.hook("execute.before", async (event) => {
1120
1094
  const state = await this.stateFor(event.sessionID);
1121
- if (requiresTrackerRefresh(state, event.tool, this.config)) {
1095
+ if (requiresTrackerRefresh(state, event.tool)) {
1122
1096
  throw new Error(trackerRefreshMessage);
1123
1097
  }
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
1098
  });
1132
1099
  after = await this.context.tool.hook("execute.after", async (event) => {
1133
1100
  if (event.status !== "completed") {
@@ -1135,16 +1102,11 @@ class TodoTrackerEnforcement {
1135
1102
  }
1136
1103
  if (event.tool === todoTrackerName) {
1137
1104
  this.states.set(event.sessionID, refreshedTrackerState());
1138
- return;
1139
- }
1140
- const state = await this.stateFor(event.sessionID);
1141
- if (state.kind !== "tracked") {
1142
- return;
1143
1105
  }
1144
1106
  });
1145
1107
  context = await this.context.session.hook("context", async (event) => {
1146
1108
  const state = await this.stateFor(event.sessionID);
1147
- if (requiresTrackerRefreshBeforeMoreWork(state)) {
1109
+ if (trackerRefreshDue(state)) {
1148
1110
  event.system.push({ type: "text", text: trackerRefreshMessage });
1149
1111
  }
1150
1112
  });
@@ -1177,7 +1139,7 @@ class TodoTrackerEnforcement {
1177
1139
  }
1178
1140
  const session = await this.context.session.get({ sessionID: event.data.sessionID });
1179
1141
  if (session.parentID !== undefined) {
1180
- await this.markWriteRequiresRefresh(session.parentID);
1142
+ await this.requireTrackerRefresh(session.parentID);
1181
1143
  }
1182
1144
  }
1183
1145
  } catch (error) {
@@ -1203,10 +1165,11 @@ class TodoTracker {
1203
1165
  }
1204
1166
  static sidebar(messages, completed = false) {
1205
1167
  const todos = todosFromMessages(messages);
1206
- if (!completed) {
1168
+ const finalTodo = todos.at(-1);
1169
+ if (!completed || finalTodo?.status !== "in_progress" || todos.slice(0, -1).some((todo) => todo.status !== "completed")) {
1207
1170
  return todos;
1208
1171
  }
1209
- return todos.map((todo) => todo.status === "pending" || todo.status === "in_progress" ? { ...todo, status: "completed" } : todo);
1172
+ return [...todos.slice(0, -1), { ...finalTodo, status: "completed" }];
1210
1173
  }
1211
1174
  static async register(context, config) {
1212
1175
  const tool = await context.tool.transform((tools) => {
@@ -1227,7 +1190,7 @@ class TodoTracker {
1227
1190
  return tool;
1228
1191
  }
1229
1192
  try {
1230
- const enforcement = await new TodoTrackerEnforcement(context, config).register();
1193
+ const enforcement = await new TodoTrackerEnforcement(context).register();
1231
1194
  return {
1232
1195
  dispose: async () => {
1233
1196
  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.6",
5
5
  "description": "OpenCode file-picker commands for planning skills.",
6
6
  "publishConfig": {
7
7
  "access": "public"