@gamflaz/opencode-todolist 1.1.1 → 1.1.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.
package/dist/index.d.ts CHANGED
@@ -1,30 +1,10 @@
1
1
  import { Plugin } from "@opencode/plugin";
2
- import { type TodoReadInput, type TodoRootItem } from "./schemas/todo";
3
- export declare function formatReadOutput(todos: readonly TodoRootItem[], options?: {
4
- visibleIds?: readonly number[];
5
- }): string;
2
+ import { type TodoRootItem } from "./schemas/todo";
6
3
  export declare function formatTransitionLines(before: readonly TodoRootItem[], after: readonly TodoRootItem[]): string[];
7
4
  export declare function formatWriteOutput(todos: readonly TodoRootItem[], before?: readonly TodoRootItem[]): string;
8
5
  export declare function assertWriteIsInitialization(existing: readonly TodoRootItem[], incoming: readonly TodoRootItem[]): void;
9
6
  /** Update response: progress, next step, and transition lines without resending the list. */
10
7
  export declare function formatUpdateOutput(todos: readonly TodoRootItem[], before?: readonly TodoRootItem[]): string;
11
- /**
12
- * Narrows a list for a scoped read.
13
- *
14
- * - `scope: "root"` returns one root with every subtask, so the whole branch is
15
- * visible.
16
- * - `scope: "children"` prints the root line as context plus the targeted
17
- * subtask line, matching a "show me this subtask within its root" read.
18
- * - `scope: "item"` prints only the targeted row.
19
- *
20
- * Either way the root keeps every subtask in the *data*, because the root's
21
- * `(completed/total)` label is computed from them — dropping them would
22
- * silently turn `(2/2)` into `(1/1)`.
23
- */
24
- export declare function selectReadScope(todos: readonly TodoRootItem[], input: TodoReadInput): {
25
- todos: TodoRootItem[];
26
- visibleIds?: readonly number[];
27
- };
28
8
  export declare function registerTodoTools(ctx: Plugin.Context): Promise<void>;
29
9
  export declare function registerTodoPromptHook(ctx: Plugin.Context): Promise<void>;
30
10
  declare const _default: Plugin.Plugin;
package/dist/index.js CHANGED
@@ -3,17 +3,9 @@ import { TodoInputToolSchema, TodoOutputToolSchema, } from "./schemas/todo.js";
3
3
  import { registerTodoSkills } from "./skills/loader.js";
4
4
  import { readTodoData, readTodos, updateTodos } from "./storage/todo-store.js";
5
5
  import { buildTodoHint, consumeTodoHint, findNextTodo, findStuckParent, flattenLeaves, isAnyTodoActive, } from "./todos/hint.js";
6
+ import { runReadOperation } from "./todos/read.js";
6
7
  import { TodoTree } from "./todos/todo-tree.js";
7
8
  import { updateTodoById } from "./todos/update.js";
8
- export function formatReadOutput(todos, options) {
9
- if (todos.length === 0)
10
- return "No todos recorded.";
11
- const tree = new TodoTree(todos).render(options);
12
- const check = new TodoTree(todos).validateAndNormalize();
13
- if (check.valid)
14
- return tree;
15
- return `Warning: the stored list needs fixing — ${check.error}\n${tree}`;
16
- }
17
9
  export function formatTransitionLines(before, after) {
18
10
  const previous = new Map(flattenLeaves(before).map((leaf) => [leaf.id, leaf.status]));
19
11
  const leaves = flattenLeaves(after);
@@ -60,7 +52,7 @@ export function formatWriteOutput(todos, before = []) {
60
52
  export function assertWriteIsInitialization(existing, incoming) {
61
53
  if (existing.length > 0 && incoming.length > 0) {
62
54
  const messages = [
63
- '[manage_todo_list] Invalid operation: "write".',
55
+ 'Invalid operation: "write".',
64
56
  "",
65
57
  'The "write" operation is only allowed during initialization.',
66
58
  'Use "update" to modify an existing todo list.',
@@ -75,38 +67,6 @@ export function formatUpdateOutput(todos, before = []) {
75
67
  const progress = `Todos (${stats.completedSubTasks}/${stats.totalSubTasks}).`;
76
68
  return formatWriteOutput(todos, before).replace(/^Todos \(\d+\/\d+\) updated\./, progress.slice(0, -1));
77
69
  }
78
- /**
79
- * Narrows a list for a scoped read.
80
- *
81
- * - `scope: "root"` returns one root with every subtask, so the whole branch is
82
- * visible.
83
- * - `scope: "children"` prints the root line as context plus the targeted
84
- * subtask line, matching a "show me this subtask within its root" read.
85
- * - `scope: "item"` prints only the targeted row.
86
- *
87
- * Either way the root keeps every subtask in the *data*, because the root's
88
- * `(completed/total)` label is computed from them — dropping them would
89
- * silently turn `(2/2)` into `(1/1)`.
90
- */
91
- export function selectReadScope(todos, input) {
92
- if (input.scope !== "item" && input.scope !== "children" && input.scope !== "root") {
93
- return { todos: [...todos] };
94
- }
95
- const target = input.target;
96
- const rootId = Number.isInteger(target) ? target : Math.floor(target);
97
- const root = todos.find((item) => item.id === rootId);
98
- const child = Number.isInteger(target)
99
- ? undefined
100
- : root?.children?.find((item) => item.id === target);
101
- if (!root || (!Number.isInteger(target) && !child)) {
102
- throw new Error(`[manage_todo_list] todo id ${target} was not found`);
103
- }
104
- // `root` shows the branch as-is; it needs no row filter at all.
105
- if (input.scope === "root")
106
- return { todos: [root] };
107
- const visibleIds = input.scope === "children" ? [rootId, target] : [target];
108
- return { todos: [root], visibleIds };
109
- }
110
70
  export async function registerTodoTools(ctx) {
111
71
  await ctx.tool.transform((tools) => {
112
72
  tools.add({
@@ -123,18 +83,16 @@ export async function registerTodoTools(ctx) {
123
83
  execute: async (input, toolContext) => {
124
84
  const parsed = TodoInputToolSchema.safeParse(input);
125
85
  if (!parsed.success) {
126
- throw new Error(`[manage_todo_list validation failed] ${parsed.error.issues
86
+ throw new Error(`Invalid manage_todo_list input: ${parsed.error.issues
127
87
  .map((i) => `${i.path.join(".") || "input"}: ${i.message}`)
128
88
  .join(", ")}`);
129
89
  }
130
90
  if (parsed.data.operation === "read") {
131
91
  const todos = await readTodos(toolContext.sessionID);
132
- const scoped = selectReadScope(todos, parsed.data);
92
+ const result = runReadOperation(todos, parsed.data);
133
93
  return {
134
- output: { todos: scoped.todos },
135
- content: formatReadOutput(scoped.todos, {
136
- visibleIds: scoped.visibleIds,
137
- }),
94
+ output: { todos: result.todos },
95
+ content: result.content,
138
96
  };
139
97
  }
140
98
  if (parsed.data.operation === "update") {
@@ -54,7 +54,7 @@ export function parseLegacyTodos(raw) {
54
54
  parsed = JSON.parse(raw);
55
55
  }
56
56
  catch (error) {
57
- throw new InvalidTodoDataError(`[todo store read failed] invalid todo data: ${error instanceof Error ? error.message : String(error)}`);
57
+ throw new InvalidTodoDataError(`invalid todo data: ${error instanceof Error ? error.message : String(error)}`);
58
58
  }
59
59
  const list = Array.isArray(parsed)
60
60
  ? parsed
@@ -62,11 +62,11 @@ export function parseLegacyTodos(raw) {
62
62
  ? parsed.todos
63
63
  : undefined;
64
64
  if (!Array.isArray(list)) {
65
- throw new InvalidTodoDataError("[todo store read failed] invalid todo data: todo list is missing");
65
+ throw new InvalidTodoDataError("invalid todo data: todo list is missing");
66
66
  }
67
67
  const result = TodoListSchema.safeParse(list);
68
68
  if (!result.success) {
69
- throw new InvalidTodoDataError(`[todo store read failed] invalid todo data: ${result.error.issues
69
+ throw new InvalidTodoDataError(`invalid todo data: ${result.error.issues
70
70
  .map((issue) => issue.message)
71
71
  .join(", ")}`);
72
72
  }
@@ -26,7 +26,7 @@ export class TodoStore {
26
26
  static normalizeSessionID(sessionID) {
27
27
  const normalized = sessionID.trim();
28
28
  if (!TodoStore.SESSION_ID_PATTERN.test(normalized)) {
29
- throw new Error("[todo store] invalid session ID");
29
+ throw new Error("invalid session ID");
30
30
  }
31
31
  return normalized;
32
32
  }
@@ -281,7 +281,7 @@ export class TodoStore {
281
281
  if (!result.valid) {
282
282
  // Throwing inside the transaction rolls it back, so an invalid list
283
283
  // never reaches disk and never bumps the revision.
284
- throw new Error(`[todo store validation failed] ${result.error}`);
284
+ throw new Error(result.error);
285
285
  }
286
286
  return result.data;
287
287
  }).todos;
@@ -0,0 +1,44 @@
1
+ import type { TodoReadInput, TodoRootItem } from "../schemas/todo";
2
+ /**
3
+ * Render a list as the tree the `read` operation answers with.
4
+ *
5
+ * A list that fails validation still renders — showing the rows is more useful
6
+ * than refusing — but the warning goes first so the caller fixes it before
7
+ * trusting the shape.
8
+ */
9
+ export declare function formatReadOutput(todos: readonly TodoRootItem[], options?: {
10
+ visibleIds?: readonly number[];
11
+ }): string;
12
+ /**
13
+ * Narrows a list for a scoped read.
14
+ *
15
+ * - `scope: "root"` returns one root with every subtask, so the whole branch is
16
+ * visible.
17
+ * - `scope: "children"` prints the root line as context plus the targeted
18
+ * subtask line, matching a "show me this subtask within its root" read.
19
+ * - `scope: "item"` prints only the targeted row.
20
+ *
21
+ * Either way the root keeps every subtask in the *data*, because the root's
22
+ * `(completed/total)` label is computed from them — dropping them would
23
+ * silently turn `(2/2)` into `(1/1)`.
24
+ */
25
+ export declare function selectReadScope(todos: readonly TodoRootItem[], input: TodoReadInput): {
26
+ todos: TodoRootItem[];
27
+ visibleIds?: readonly number[];
28
+ };
29
+ export interface ReadOperationResult {
30
+ /** Scope-narrowed list, also what the tool returns as structured output. */
31
+ todos: TodoRootItem[];
32
+ /** Rows to print for `children`/`item`; absent means "print everything". */
33
+ visibleIds?: readonly number[];
34
+ /** Rendered tree, ready to hand back as tool content. */
35
+ content: string;
36
+ }
37
+ /**
38
+ * Whole `read` operation, minus storage.
39
+ *
40
+ * Kept pure on purpose: the caller reads the session's list and passes it in,
41
+ * so this module never needs a session id and mirrors `updateTodoById` instead
42
+ * of the storage-facing wrappers.
43
+ */
44
+ export declare function runReadOperation(todos: readonly TodoRootItem[], input: TodoReadInput): ReadOperationResult;
@@ -0,0 +1,67 @@
1
+ import { TodoTree } from "./todo-tree.js";
2
+ /**
3
+ * Render a list as the tree the `read` operation answers with.
4
+ *
5
+ * A list that fails validation still renders — showing the rows is more useful
6
+ * than refusing — but the warning goes first so the caller fixes it before
7
+ * trusting the shape.
8
+ */
9
+ export function formatReadOutput(todos, options) {
10
+ if (todos.length === 0)
11
+ return "No todos recorded.";
12
+ const tree = new TodoTree(todos).render(options);
13
+ const check = new TodoTree(todos).validateAndNormalize();
14
+ if (check.valid)
15
+ return tree;
16
+ return `Warning: the stored list needs fixing — ${check.error}\n${tree}`;
17
+ }
18
+ /**
19
+ * Narrows a list for a scoped read.
20
+ *
21
+ * - `scope: "root"` returns one root with every subtask, so the whole branch is
22
+ * visible.
23
+ * - `scope: "children"` prints the root line as context plus the targeted
24
+ * subtask line, matching a "show me this subtask within its root" read.
25
+ * - `scope: "item"` prints only the targeted row.
26
+ *
27
+ * Either way the root keeps every subtask in the *data*, because the root's
28
+ * `(completed/total)` label is computed from them — dropping them would
29
+ * silently turn `(2/2)` into `(1/1)`.
30
+ */
31
+ export function selectReadScope(todos, input) {
32
+ if (input.scope !== "item" &&
33
+ input.scope !== "children" &&
34
+ input.scope !== "root") {
35
+ return { todos: [...todos] };
36
+ }
37
+ const target = input.target;
38
+ const rootId = Number.isInteger(target) ? target : Math.floor(target);
39
+ const root = todos.find((item) => item.id === rootId);
40
+ const child = Number.isInteger(target)
41
+ ? undefined
42
+ : root?.children?.find((item) => item.id === target);
43
+ if (!root || (!Number.isInteger(target) && !child)) {
44
+ throw new Error(`todo id ${target} was not found`);
45
+ }
46
+ // `root` shows the branch as-is; it needs no row filter at all.
47
+ if (input.scope === "root")
48
+ return { todos: [root] };
49
+ const visibleIds = input.scope === "children" ? [rootId, target] : [target];
50
+ return { todos: [root], visibleIds };
51
+ }
52
+ /**
53
+ * Whole `read` operation, minus storage.
54
+ *
55
+ * Kept pure on purpose: the caller reads the session's list and passes it in,
56
+ * so this module never needs a session id and mirrors `updateTodoById` instead
57
+ * of the storage-facing wrappers.
58
+ */
59
+ export function runReadOperation(todos, input) {
60
+ const scoped = selectReadScope(todos, input);
61
+ const content = formatReadOutput(scoped.todos, {
62
+ visibleIds: scoped.visibleIds,
63
+ });
64
+ return scoped.visibleIds === undefined
65
+ ? { todos: scoped.todos, content }
66
+ : { todos: scoped.todos, visibleIds: scoped.visibleIds, content };
67
+ }
@@ -54,8 +54,26 @@ export declare class TodoTree {
54
54
  /**
55
55
  * Rejects completed parents with unfinished children, and auto-completes a
56
56
  * parent whose children are all completed.
57
+ *
58
+ * The order check runs **after** the auto-complete above, never before: a
59
+ * parent whose children all finished is raised to `completed` here, and that
60
+ * promotion can itself create an out-of-order list. Checking first would let
61
+ * the promotion slip past the check.
57
62
  */
58
63
  private normalize;
64
+ /**
65
+ * Roots run in order, so nothing after the first unfinished root may be
66
+ * finished.
67
+ *
68
+ * Only `error` is allowed to follow an open root — a broken task has to be
69
+ * fixable even while earlier work is still open. Everything else
70
+ * (`completed`, `in_progress`, `cancelled`) has to wait its turn.
71
+ *
72
+ * This lives here rather than in `update.ts` because `write` and the legacy
73
+ * importer reach the database without passing the update rules at all. One
74
+ * check in the shared validator closes every path at once.
75
+ */
76
+ private static findOrderError;
59
77
  /**
60
78
  * Renders the tree.
61
79
  *
@@ -132,6 +132,11 @@ export class TodoTree {
132
132
  /**
133
133
  * Rejects completed parents with unfinished children, and auto-completes a
134
134
  * parent whose children are all completed.
135
+ *
136
+ * The order check runs **after** the auto-complete above, never before: a
137
+ * parent whose children all finished is raised to `completed` here, and that
138
+ * promotion can itself create an out-of-order list. Checking first would let
139
+ * the promotion slip past the check.
135
140
  */
136
141
  normalize() {
137
142
  const normalized = [];
@@ -156,8 +161,39 @@ export class TodoTree {
156
161
  status: allChildrenCompleted ? "completed" : item.status,
157
162
  });
158
163
  }
164
+ const orderError = TodoTree.findOrderError(normalized);
165
+ if (orderError)
166
+ return { valid: false, error: orderError };
159
167
  return { valid: true, data: normalized };
160
168
  }
169
+ /**
170
+ * Roots run in order, so nothing after the first unfinished root may be
171
+ * finished.
172
+ *
173
+ * Only `error` is allowed to follow an open root — a broken task has to be
174
+ * fixable even while earlier work is still open. Everything else
175
+ * (`completed`, `in_progress`, `cancelled`) has to wait its turn.
176
+ *
177
+ * This lives here rather than in `update.ts` because `write` and the legacy
178
+ * importer reach the database without passing the update rules at all. One
179
+ * check in the shared validator closes every path at once.
180
+ */
181
+ static findOrderError(todos) {
182
+ const isFinal = (status) => status === "completed" || status === "cancelled";
183
+ let firstOpen;
184
+ for (const root of todos) {
185
+ if (!firstOpen) {
186
+ if (!isFinal(root.status))
187
+ firstOpen = root;
188
+ continue;
189
+ }
190
+ // Past the first open root: a later root must still be untouched.
191
+ if (root.status === "pending" || root.status === "error")
192
+ continue;
193
+ return `Task '${root.id}' is '${root.status}' but earlier task '${firstOpen.id}' is still '${firstOpen.status}'. Roots run in order: finish or cancel '${firstOpen.id}' first.`;
194
+ }
195
+ return null;
196
+ }
161
197
  // Rendering
162
198
  /**
163
199
  * Renders the tree.
@@ -38,7 +38,7 @@ function assertForwardTransition(from, to, id) {
38
38
  if (from === to)
39
39
  return;
40
40
  if (!(FORWARD_TRANSITIONS[from] ?? []).includes(to)) {
41
- throw new Error(`[manage_todo_list] todo ${id} cannot move from '${from}' to '${to}'. Progress only moves forward; use 'error' to report a failure and fix it from there.`);
41
+ throw new Error(`todo ${id} cannot move from '${from}' to '${to}'. Progress only moves forward; use 'error' to report a failure and fix it from there.`);
42
42
  }
43
43
  }
44
44
  /** Any root still open, an `error` root included. */
@@ -65,11 +65,11 @@ export function assertSequentialScope(todos, input) {
65
65
  const targetRoot = todos.find((root) => root.id === input.id ||
66
66
  root.children?.some((child) => child.id === input.id));
67
67
  if (!targetRoot) {
68
- throw new Error(`[manage_todo_list] todo id ${input.id} was not found`);
68
+ throw new Error(`todo id ${input.id} was not found`);
69
69
  }
70
70
  const firstOpen = todos.findIndex((root) => !isFinal(root.status));
71
71
  if (firstOpen === -1) {
72
- throw new Error("[manage_todo_list] every todo is already finished; nothing can be updated.");
72
+ throw new Error("every todo is already finished; nothing can be updated.");
73
73
  }
74
74
  const targetRootIndex = todos.indexOf(targetRoot);
75
75
  if (targetRootIndex !== firstOpen && !hasError(targetRoot)) {
@@ -80,10 +80,10 @@ export function assertSequentialScope(todos, input) {
80
80
  ? targetRoot
81
81
  : targetRoot.children?.find((child) => child.id === input.id);
82
82
  if (!target) {
83
- throw new Error(`[manage_todo_list] todo id ${input.id} was not found`);
83
+ throw new Error(`todo id ${input.id} was not found`);
84
84
  }
85
85
  if (isFinal(target.status)) {
86
- throw new Error(`[manage_todo_list] todo ${input.id} is '${target.status}' and final; it cannot be changed.`);
86
+ throw new Error(`todo ${input.id} is '${target.status}' and final; it cannot be changed.`);
87
87
  }
88
88
  assertForwardTransition(target.status, input.status, input.id);
89
89
  if (input.status === "in_progress") {
@@ -93,7 +93,7 @@ export function assertSequentialScope(todos, input) {
93
93
  }
94
94
  const busyChild = targetRoot.children?.find((child) => child.status === "in_progress" && child.id !== input.id);
95
95
  if (busyChild) {
96
- throw new Error(`[manage_todo_list] subtask ${busyChild.id} is still in_progress. Finish it (or cancel it) before starting ${input.id}.`);
96
+ throw new Error(`subtask ${busyChild.id} is still in_progress. Finish it (or cancel it) before starting ${input.id}.`);
97
97
  }
98
98
  }
99
99
  return { targetRoot, target };
@@ -134,7 +134,7 @@ export function updateTodoById(todos, input) {
134
134
  return { ...root, status, children };
135
135
  });
136
136
  if (!updated) {
137
- throw new Error(`[manage_todo_list] todo id ${input.id} was not found`);
137
+ throw new Error(`todo id ${input.id} was not found`);
138
138
  }
139
139
  return { todos: nextTodos, updated };
140
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamflaz/opencode-todolist",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Hierarchical todo list plugin for OpenCode with a real-time TUI widget.",
5
5
  "type": "module",
6
6
  "files": [