@gamflaz/opencode-todolist 1.1.0 → 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/README.md +61 -32
- package/dist/index.d.ts +1 -21
- package/dist/index.js +6 -48
- package/dist/storage/migrate-json.js +3 -3
- package/dist/storage/todo-store.js +2 -2
- package/dist/todos/read.d.ts +44 -0
- package/dist/todos/read.js +67 -0
- package/dist/todos/todo-tree.d.ts +18 -0
- package/dist/todos/todo-tree.js +36 -0
- package/dist/todos/update.js +7 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ plus a TUI widget above the composer that shows progress in real time.
|
|
|
8
8
|
- Hierarchical tasks: any number of root tasks, each with up to 9 subtasks.
|
|
9
9
|
- Numeric IDs only: root `1`, `2`, … and subtasks `1.1`, `1.2`, … (`1.9` max).
|
|
10
10
|
- Small partial updates: change one item without resending the whole list.
|
|
11
|
+
- Scoped reads: pull the whole list, one branch, or a single item.
|
|
11
12
|
- Strict forward-only progress with enforced order between root tasks.
|
|
12
13
|
- Real-time TUI widget with collapse, scroll, `↻ Continue`, and clear controls.
|
|
13
14
|
- Bundled skills that teach the agent how to drive the tool.
|
|
@@ -40,22 +41,36 @@ dependencies, so `bun install` is required before use.
|
|
|
40
41
|
|
|
41
42
|
#### Operation fields
|
|
42
43
|
|
|
43
|
-
| Operation | Required fields
|
|
44
|
-
| --------- |
|
|
45
|
-
| `read` | `operation`
|
|
46
|
-
| `write` | `operation`, `todos`
|
|
47
|
-
| `update` | `operation`, `id`, `status`
|
|
44
|
+
| Operation | Required fields | Optional fields |
|
|
45
|
+
| --------- | --------------------------- | ------------------------------ |
|
|
46
|
+
| `read` | `operation` | `scope`, `target` |
|
|
47
|
+
| `write` | `operation`, `todos` | None; use `todos: []` to clear |
|
|
48
|
+
| `update` | `operation`, `id`, `status` | `content`, `priority`, `note` |
|
|
48
49
|
|
|
49
50
|
For `update`, send `null` for `priority` or `note` to remove the existing value.
|
|
50
51
|
|
|
51
52
|
### Read
|
|
52
53
|
|
|
54
|
+
By default `read` returns the whole list. An optional `scope` narrows it to one
|
|
55
|
+
branch or one row, which keeps the response small on a long list.
|
|
56
|
+
|
|
57
|
+
| `scope` | `target` | Returns |
|
|
58
|
+
| ---------- | ------------------- | -------------------------------------------- |
|
|
59
|
+
| `all` | — | the whole list (default) |
|
|
60
|
+
| `root` | a root id, e.g. `1` | that root and all of its subtasks |
|
|
61
|
+
| `children` | a subtask id, `1.1` | that subtask, with its root shown as context |
|
|
62
|
+
| `item` | `1` or `1.1` | only that row |
|
|
63
|
+
|
|
64
|
+
`target` is only valid together with `scope: "root"`, `"children"`, or
|
|
65
|
+
`"item"` — passing it without one is rejected, so a scope can never be silently
|
|
66
|
+
ignored.
|
|
67
|
+
|
|
68
|
+
The whole list:
|
|
69
|
+
|
|
53
70
|
```json
|
|
54
71
|
{ "operation": "read" }
|
|
55
72
|
```
|
|
56
73
|
|
|
57
|
-
Returns the current list as a progress tree:
|
|
58
|
-
|
|
59
74
|
```text
|
|
60
75
|
[~] 1. Design architecture (1/2)
|
|
61
76
|
↳ note: Keep the hierarchy shallow
|
|
@@ -64,9 +79,35 @@ Returns the current list as a progress tree:
|
|
|
64
79
|
[ ] 2. Write tests
|
|
65
80
|
```
|
|
66
81
|
|
|
82
|
+
One branch (`root`):
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{ "operation": "read", "scope": "root", "target": 1 }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A single subtask, with its root as context (`children`):
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "operation": "read", "scope": "children", "target": 1.2 }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
[~] 1. Design architecture (1/2)
|
|
96
|
+
[~] 1.2 API endpoints
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
One row with no context (`item`):
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{ "operation": "read", "scope": "item", "target": 1.2 }
|
|
103
|
+
```
|
|
104
|
+
|
|
67
105
|
Markers: `[ ]` pending, `[~]` in progress, `[x]` completed, `[-]` cancelled,
|
|
68
106
|
`[!]` error. Notes render under their item as `↳ note: …`.
|
|
69
107
|
|
|
108
|
+
Even when a scope hides rows, the root keeps its real `(completed/total)` count —
|
|
109
|
+
only the display is narrowed.
|
|
110
|
+
|
|
70
111
|
### Write
|
|
71
112
|
|
|
72
113
|
`write` initializes a session or clears it. A non-empty list is only accepted
|
|
@@ -100,14 +141,14 @@ Clear the current list:
|
|
|
100
141
|
|
|
101
142
|
#### Todo item fields
|
|
102
143
|
|
|
103
|
-
| Field | Type
|
|
104
|
-
| ---------- |
|
|
105
|
-
| `id` | number
|
|
106
|
-
| `content` | string
|
|
107
|
-
| `status` | enum
|
|
108
|
-
| `priority` | `high` \| `medium` \| `low`
|
|
109
|
-
| `note` | string
|
|
110
|
-
| `children` | array of child items
|
|
144
|
+
| Field | Type | Required | Description |
|
|
145
|
+
| ---------- | --------------------------- | ------------- | --------------------------------------------------------------- |
|
|
146
|
+
| `id` | number | Yes | Root: positive integer. Child: `<root>.<n>`, such as `1.1`. |
|
|
147
|
+
| `content` | string | Yes | Task summary. |
|
|
148
|
+
| `status` | enum | Yes | `pending`, `in_progress`, `completed`, `cancelled`, or `error`. |
|
|
149
|
+
| `priority` | `high` \| `medium` \| `low` | No | Optional priority label. |
|
|
150
|
+
| `note` | string | No | Technical context or evidence. |
|
|
151
|
+
| `children` | array of child items | No, root only | Up to 9 subtasks. Child items cannot contain more children. |
|
|
111
152
|
|
|
112
153
|
Root IDs must be unique. A child ID must start with its parent root ID.
|
|
113
154
|
|
|
@@ -129,11 +170,11 @@ Root IDs must be unique. A child ID must start with its parent root ID.
|
|
|
129
170
|
|
|
130
171
|
| Field | Required | Notes |
|
|
131
172
|
| ---------- | -------- | ------------------------------------------------------------ |
|
|
132
|
-
| `id` | Yes | Root number or subtask number (`1`, `1.1`).
|
|
133
|
-
| `status` | Yes | `pending`, `in_progress`, `completed`, `cancelled`, `error`.
|
|
134
|
-
| `content` | No | One-line summary shown in the widget.
|
|
135
|
-
| `note` | No | Technical context or evidence. Pass `null` to remove.
|
|
136
|
-
| `priority` | No | `high`, `medium`, or `low`. Pass `null` to remove.
|
|
173
|
+
| `id` | Yes | Root number or subtask number (`1`, `1.1`). |
|
|
174
|
+
| `status` | Yes | `pending`, `in_progress`, `completed`, `cancelled`, `error`. |
|
|
175
|
+
| `content` | No | One-line summary shown in the widget. |
|
|
176
|
+
| `note` | No | Technical context or evidence. Pass `null` to remove. |
|
|
177
|
+
| `priority` | No | `high`, `medium`, or `low`. Pass `null` to remove. |
|
|
137
178
|
|
|
138
179
|
## Skills
|
|
139
180
|
|
|
@@ -152,15 +193,3 @@ bun run compile # rebuild dist/ (tsc + Solid JSX transform)
|
|
|
152
193
|
`bun run compile` must be used instead of `bun run build`: the raw `tsc` pass
|
|
153
194
|
alone leaves intermediate `.jsx` files in `dist/`, which Bun then prefers over
|
|
154
195
|
the compiled `.js` and reports as `No renderer found` / `ENOENT ... dist/tui.jsx`.
|
|
155
|
-
|
|
156
|
-
## Publishing notes
|
|
157
|
-
|
|
158
|
-
`dist/`, `index.js`, and `tui.js` are generated build output and are not
|
|
159
|
-
committed. They reach users through the npm tarball: npm runs `prepare`
|
|
160
|
-
(`bun run compile`) before packing, and `files` ships the built `dist/`
|
|
161
|
-
directory. The `opencode plugin add` installer skips lifecycle scripts
|
|
162
|
-
(`ignoreScripts: true`), so the tarball — not a post-install build — must
|
|
163
|
-
contain `dist/`.
|
|
164
|
-
|
|
165
|
-
Before publishing, run `bun run check && bun run compile` and confirm
|
|
166
|
-
`dist/` contains no `.jsx` files.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,30 +1,10 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin";
|
|
2
|
-
import { type
|
|
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
|
-
'
|
|
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(`
|
|
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
|
|
92
|
+
const result = runReadOperation(todos, parsed.data);
|
|
133
93
|
return {
|
|
134
|
-
output: { todos:
|
|
135
|
-
content:
|
|
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(`
|
|
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("
|
|
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(`
|
|
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("
|
|
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(
|
|
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
|
*
|
package/dist/todos/todo-tree.js
CHANGED
|
@@ -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.
|
package/dist/todos/update.js
CHANGED
|
@@ -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(`
|
|
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(`
|
|
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("
|
|
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(`
|
|
83
|
+
throw new Error(`todo id ${input.id} was not found`);
|
|
84
84
|
}
|
|
85
85
|
if (isFinal(target.status)) {
|
|
86
|
-
throw new Error(`
|
|
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(`
|
|
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(`
|
|
137
|
+
throw new Error(`todo id ${input.id} was not found`);
|
|
138
138
|
}
|
|
139
139
|
return { todos: nextTodos, updated };
|
|
140
140
|
}
|