@gamflaz/opencode-todolist 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { getHintMarkerPath, readTodoData, TodoStore } from "../storage/todo-store";
3
+ import { TodoTree } from "./todo-tree";
4
+ export const DEFAULT_STALE_MS = 30 * 60 * 1000; // 30 minutes (prompt fallback)
5
+ export const CONTEXT_STALE_MS = 6 * 60 * 60 * 1000; // 6 hours
6
+ const UPCOMING_LIMIT = 3;
7
+ export const TODO_HINT = "<todo_hint>Active todos exist. If continuing previous work, call " +
8
+ "manage_todo_list [read] first. Ignore if the user is discussing something else.</todo_hint>";
9
+ function isOpen(status) {
10
+ return TodoTree.isOpenStatus(status);
11
+ }
12
+ function isTaskActive(task) {
13
+ if (isOpen(task.status))
14
+ return true;
15
+ return !!task.children && task.children.some((c) => isOpen(c.status));
16
+ }
17
+ export function isAnyTodoActive(todos) {
18
+ return todos.some(isTaskActive);
19
+ }
20
+ function atomicWrite(file, data) {
21
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
22
+ writeFileSync(tmp, data, "utf8");
23
+ renameSync(tmp, file);
24
+ }
25
+ export function consumeTodoHint(sessionID, options) {
26
+ try {
27
+ if (!sessionID)
28
+ return undefined;
29
+ const safeSessionID = TodoStore.normalizeSessionID(sessionID);
30
+ const state = readTodoData(safeSessionID);
31
+ if (!state)
32
+ return undefined;
33
+ const todos = state.todos;
34
+ if (todos.length === 0 || !isAnyTodoActive(todos))
35
+ return undefined;
36
+ const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;
37
+ const now = options?.now ?? Date.now();
38
+ const updatedAt = Date.parse(state.updated_at);
39
+ if (Number.isFinite(updatedAt) && now - updatedAt > staleMs) {
40
+ return undefined;
41
+ }
42
+ const markerFile = getHintMarkerPath(safeSessionID);
43
+ let hintedVersion = -1;
44
+ try {
45
+ const marker = JSON.parse(readFileSync(markerFile, "utf8"));
46
+ if (typeof marker?.version === "number")
47
+ hintedVersion = marker.version;
48
+ }
49
+ catch {
50
+ // Marker missing or corrupt
51
+ }
52
+ if (hintedVersion === state.version)
53
+ return undefined;
54
+ atomicWrite(markerFile, JSON.stringify({ version: state.version }));
55
+ return TODO_HINT;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ export function flattenLeaves(todos) {
62
+ return todos.flatMap((t) => (t.children?.length ? t.children : [t]));
63
+ }
64
+ export function findNextTodo(todos) {
65
+ // Strict order: only the first open root is relevant; later roots wait.
66
+ for (const root of todos) {
67
+ if (root.status === "completed" || root.status === "cancelled")
68
+ continue;
69
+ const children = root.children ?? [];
70
+ const errorChild = children.find((c) => c.status === "error");
71
+ if (root.status === "error")
72
+ return root;
73
+ if (errorChild)
74
+ return errorChild;
75
+ const activeChild = children.find((c) => c.status === "in_progress");
76
+ if (activeChild)
77
+ return activeChild;
78
+ if (root.status === "in_progress") {
79
+ return children.find((c) => c.status === "pending") ?? root;
80
+ }
81
+ return children.find((c) => c.status === "pending") ?? root;
82
+ }
83
+ return undefined;
84
+ }
85
+ export function findStuckParent(todos) {
86
+ return todos.find((t) => !!t.children?.length &&
87
+ isOpen(t.status) &&
88
+ !t.children.some((c) => isOpen(c.status)));
89
+ }
90
+ function renderCompactHint(todos) {
91
+ const leaves = flattenLeaves(todos);
92
+ const done = leaves.filter((t) => t.status === "completed").length;
93
+ const lines = [`Progress ${done}/${leaves.length} completed.`];
94
+ const openRoot = todos.find((root) => root.status !== "completed" && root.status !== "cancelled");
95
+ if (!openRoot) {
96
+ lines.push("Now: nothing is open.");
97
+ return lines.join("\n");
98
+ }
99
+ const children = openRoot.children ?? [];
100
+ const activeChild = children.find((c) => c.status === "in_progress");
101
+ const errorChild = children.find((c) => c.status === "error");
102
+ const nextPendingChild = children.find((c) => c.status === "pending");
103
+ let nowId;
104
+ if (openRoot.status === "error" || errorChild) {
105
+ const bad = openRoot.status === "error" ? openRoot : errorChild;
106
+ nowId = bad.id;
107
+ lines.push(`Now: ${bad.id} ${bad.content} is 'error'. Fix it or mark it cancelled via manage_todo_list [update], then continue.`);
108
+ }
109
+ else if (activeChild) {
110
+ nowId = activeChild.id;
111
+ lines.push(`Now: ${activeChild.id} ${activeChild.content}`);
112
+ }
113
+ else if (openRoot.status === "in_progress" && nextPendingChild) {
114
+ nowId = nextPendingChild.id;
115
+ lines.push(`Now: root ${openRoot.id} is in_progress. Start subtask ${nextPendingChild.id} "${nextPendingChild.content}" via manage_todo_list [update] and continue.`);
116
+ }
117
+ else if (openRoot.status === "in_progress" && children.length > 0) {
118
+ nowId = openRoot.id;
119
+ lines.push(`Now: all children of ${openRoot.id} are done. Set ${openRoot.id} to completed via manage_todo_list [update].`);
120
+ }
121
+ else if (openRoot.status === "pending" && nextPendingChild) {
122
+ nowId = nextPendingChild.id;
123
+ lines.push(`Now: start root ${openRoot.id} "${openRoot.content}" (or subtask ${nextPendingChild.id}) via manage_todo_list [update] and continue.`);
124
+ }
125
+ else if (openRoot.status === "pending") {
126
+ nowId = openRoot.id;
127
+ lines.push(`Now: start root ${openRoot.id} "${openRoot.content}" via manage_todo_list [update] and continue.`);
128
+ }
129
+ else {
130
+ nowId = openRoot.id;
131
+ lines.push(`Now: resolve ${openRoot.id} ${openRoot.content} via manage_todo_list [update].`);
132
+ }
133
+ // Next: pending items in root order, root by root, skipping the item named above.
134
+ const next = [];
135
+ for (const root of todos) {
136
+ if (root.status === "completed" || root.status === "cancelled")
137
+ continue;
138
+ const candidates = root.children?.length
139
+ ? (root.children ?? []).filter((c) => c.status === "pending")
140
+ : root.status === "pending"
141
+ ? [root]
142
+ : [];
143
+ for (const item of candidates) {
144
+ if (item.id === nowId)
145
+ continue;
146
+ next.push(`${item.id} ${item.content}`);
147
+ if (next.length === UPCOMING_LIMIT)
148
+ break;
149
+ }
150
+ if (next.length === UPCOMING_LIMIT)
151
+ break;
152
+ }
153
+ if (next.length > 0)
154
+ lines.push(`Next: ${next.join("; ")}`);
155
+ return lines.join("\n");
156
+ }
157
+ export function buildTodoHint(sessionID, options) {
158
+ try {
159
+ if (!sessionID)
160
+ return undefined;
161
+ const safeSessionID = TodoStore.normalizeSessionID(sessionID);
162
+ const state = readTodoData(safeSessionID);
163
+ if (!state)
164
+ return undefined;
165
+ const todos = state.todos;
166
+ if (todos.length === 0 || !isAnyTodoActive(todos))
167
+ return undefined;
168
+ const staleMs = options?.staleMs ?? CONTEXT_STALE_MS;
169
+ const now = options?.now ?? Date.now();
170
+ const updatedAt = Date.parse(state.updated_at);
171
+ if (Number.isFinite(updatedAt) && now - updatedAt > staleMs) {
172
+ return undefined;
173
+ }
174
+ return (`<todo_state>\n${renderCompactHint(todos)}\n</todo_state>\n` +
175
+ "<todo_rules>Open todos remain. Continue the in_progress item now. " +
176
+ "Progress moves forward only and roots run in order: never reopen a finished item, " +
177
+ "never start a later root while the current one is open. " +
178
+ "Do not end your turn or write a final summary while items are open, " +
179
+ "unless you are blocked or need user input. " +
180
+ "Call manage_todo_list [read] for the full list. " +
181
+ "Change items with manage_todo_list [update]; use [write] only to initialize an empty list or to clear it with []. " +
182
+ "Ignore only if the user clearly changed topic or asked to stop or pause.</todo_rules>");
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
@@ -0,0 +1,63 @@
1
+ import { type TodoStatus } from "../schemas/status.js";
2
+ import type { TodoChildern, TodoRootItem } from "../schemas/todo";
3
+ export interface TodoProgress {
4
+ completed: number;
5
+ total: number;
6
+ label: string;
7
+ }
8
+ export interface TodoStats {
9
+ totalSteps: number;
10
+ completedSteps: number;
11
+ totalSubTasks: number;
12
+ completedSubTasks: number;
13
+ percent: number;
14
+ }
15
+ export type TodoValidationResult = {
16
+ valid: true;
17
+ data: TodoRootItem[];
18
+ } | {
19
+ valid: false;
20
+ error: string;
21
+ };
22
+ /**
23
+ * A todo list (roots + children) with validation, statistics and rendering.
24
+ *
25
+ * Instance methods operate on the list passed to the constructor; helpers that
26
+ * do not depend on a list (status checks, progress bar, id formatting) are
27
+ * `static`.
28
+ */
29
+ export declare class TodoTree {
30
+ readonly todos: readonly TodoRootItem[];
31
+ private static readonly STATUS_ICONS;
32
+ private static readonly DEFAULT_ICON;
33
+ private static readonly BAR_FILLED;
34
+ private static readonly BAR_UNFILLED;
35
+ constructor(todos: readonly TodoRootItem[]);
36
+ static isOpenStatus(status: TodoStatus): boolean;
37
+ static calculateProgress(children?: readonly TodoChildern[]): TodoProgress | null;
38
+ static renderProgressBar(percent: number, width?: number): {
39
+ filled: string;
40
+ unfilled: string;
41
+ };
42
+ /**
43
+ * Display prefix for an id. Roots render as "1. ", children as "1.1 ".
44
+ * (All ids are numeric now, so every id is a "point id".)
45
+ */
46
+ static formatIdPrefix(id: number, depth: number): string;
47
+ private static nonCancelled;
48
+ private static contentIncludesId;
49
+ private static duplicateIdError;
50
+ calculateStats(): TodoStats;
51
+ validateAndNormalize(): TodoValidationResult;
52
+ /** Returns an error message, or null when the structure is valid. */
53
+ private validateStructure;
54
+ /**
55
+ * Rejects completed parents with unfinished children, and auto-completes a
56
+ * parent whose children are all completed.
57
+ */
58
+ private normalize;
59
+ render(): string;
60
+ private static icon;
61
+ /** Content with strike-through when completed, prefixed with its id unless it already starts with it. */
62
+ private static renderLabel;
63
+ }
@@ -0,0 +1,188 @@
1
+ import { OPEN_STATUSES } from "../schemas/status.js";
2
+ /**
3
+ * A todo list (roots + children) with validation, statistics and rendering.
4
+ *
5
+ * Instance methods operate on the list passed to the constructor; helpers that
6
+ * do not depend on a list (status checks, progress bar, id formatting) are
7
+ * `static`.
8
+ */
9
+ export class TodoTree {
10
+ todos;
11
+ static STATUS_ICONS = {
12
+ pending: "[ ]",
13
+ in_progress: "[~]",
14
+ completed: "[x]",
15
+ cancelled: "[-]",
16
+ error: "[!]",
17
+ };
18
+ static DEFAULT_ICON = "[ ]";
19
+ static BAR_FILLED = "█";
20
+ static BAR_UNFILLED = "░";
21
+ constructor(todos) {
22
+ this.todos = todos;
23
+ }
24
+ // Static helpers
25
+ static isOpenStatus(status) {
26
+ return OPEN_STATUSES.includes(status);
27
+ }
28
+ static calculateProgress(children) {
29
+ if (!children || children.length === 0)
30
+ return null;
31
+ const nonCancelled = TodoTree.nonCancelled(children);
32
+ const completed = nonCancelled.filter((c) => c.status === "completed").length;
33
+ const total = nonCancelled.length;
34
+ return { completed, total, label: `${completed}/${total}` };
35
+ }
36
+ static renderProgressBar(percent, width = 10) {
37
+ const clamped = Math.max(0, Math.min(100, percent));
38
+ const filledCount = Math.round((clamped / 100) * width);
39
+ const unfilledCount = width - filledCount;
40
+ return {
41
+ filled: TodoTree.BAR_FILLED.repeat(filledCount),
42
+ unfilled: TodoTree.BAR_UNFILLED.repeat(unfilledCount),
43
+ };
44
+ }
45
+ /**
46
+ * Display prefix for an id. Roots render as "1. ", children as "1.1 ".
47
+ * (All ids are numeric now, so every id is a "point id".)
48
+ */
49
+ static formatIdPrefix(id, depth) {
50
+ return depth > 0 ? `${id} ` : `${id}. `;
51
+ }
52
+ static nonCancelled(items) {
53
+ return items.filter((item) => item.status !== "cancelled");
54
+ }
55
+ static contentIncludesId(content, id) {
56
+ const key = String(id);
57
+ return (content === key ||
58
+ content.startsWith(`${key}. `) ||
59
+ content.startsWith(`${key}: `) ||
60
+ content.startsWith(`${key} `));
61
+ }
62
+ static duplicateIdError(id) {
63
+ return `Duplicate ID found: '${id}'. Every ID must be unique across the tree.`;
64
+ }
65
+ // Statistics
66
+ calculateStats() {
67
+ const totalSteps = this.todos.length;
68
+ const completedSteps = this.todos.filter((t) => t.status === "completed").length;
69
+ let totalSubTasks = 0;
70
+ let completedSubTasks = 0;
71
+ for (const item of this.todos) {
72
+ if (item.children && item.children.length > 0) {
73
+ const nonCancelled = TodoTree.nonCancelled(item.children);
74
+ totalSubTasks += nonCancelled.length;
75
+ completedSubTasks += nonCancelled.filter((c) => c.status === "completed").length;
76
+ }
77
+ else if (item.status !== "cancelled") {
78
+ // A root without children counts as a single unit of work.
79
+ totalSubTasks += 1;
80
+ if (item.status === "completed")
81
+ completedSubTasks += 1;
82
+ }
83
+ }
84
+ const percent = totalSubTasks > 0
85
+ ? Math.round((completedSubTasks / totalSubTasks) * 100)
86
+ : 0;
87
+ return {
88
+ totalSteps,
89
+ completedSteps,
90
+ totalSubTasks,
91
+ completedSubTasks,
92
+ percent,
93
+ };
94
+ }
95
+ // Validation
96
+ validateAndNormalize() {
97
+ const error = this.validateStructure();
98
+ if (error)
99
+ return { valid: false, error };
100
+ return this.normalize();
101
+ }
102
+ /** Returns an error message, or null when the structure is valid. */
103
+ validateStructure() {
104
+ const seenIds = new Set();
105
+ const activeParents = [];
106
+ for (const parent of this.todos) {
107
+ if (seenIds.has(parent.id))
108
+ return TodoTree.duplicateIdError(parent.id);
109
+ seenIds.add(parent.id);
110
+ if (parent.status === "in_progress")
111
+ activeParents.push(parent.id);
112
+ const activeChildren = [];
113
+ for (const child of parent.children ?? []) {
114
+ if (seenIds.has(child.id))
115
+ return TodoTree.duplicateIdError(child.id);
116
+ seenIds.add(child.id);
117
+ if (child.status === "in_progress")
118
+ activeChildren.push(child.id);
119
+ }
120
+ if (activeChildren.length > 1) {
121
+ return `Task '${parent.id}' has ${activeChildren.length} subtasks 'in_progress'. At most 1 is allowed.`;
122
+ }
123
+ if (activeChildren.length === 1 && parent.status !== "in_progress") {
124
+ return `Subtask '${activeChildren[0]}' is 'in_progress' but parent '${parent.id}' is '${parent.status}'. Set parent to 'in_progress' first.`;
125
+ }
126
+ }
127
+ if (activeParents.length > 1) {
128
+ return `Only 1 top-level task can be 'in_progress'. Found: ${activeParents.join(", ")}.`;
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * Rejects completed parents with unfinished children, and auto-completes a
134
+ * parent whose children are all completed.
135
+ */
136
+ normalize() {
137
+ const normalized = [];
138
+ for (const item of this.todos) {
139
+ const children = item.children;
140
+ if (!children || children.length === 0) {
141
+ normalized.push(item);
142
+ continue;
143
+ }
144
+ const hasUnfinishedChildren = children.some((c) => c.status === "pending" ||
145
+ c.status === "in_progress" ||
146
+ c.status === "error");
147
+ if (item.status === "completed" && hasUnfinishedChildren) {
148
+ return {
149
+ valid: false,
150
+ error: `Task '${item.id}' cannot be marked 'completed' while it still has pending, in-progress or errored subtasks.`,
151
+ };
152
+ }
153
+ const allChildrenCompleted = children.every((c) => c.status === "completed");
154
+ normalized.push({
155
+ ...item,
156
+ status: allChildrenCompleted ? "completed" : item.status,
157
+ });
158
+ }
159
+ return { valid: true, data: normalized };
160
+ }
161
+ // Rendering
162
+ render() {
163
+ const lines = [];
164
+ for (const parent of this.todos) {
165
+ const progress = TodoTree.calculateProgress(parent.children);
166
+ const progressText = progress ? ` (${progress.label})` : "";
167
+ lines.push(`${TodoTree.icon(parent.status)} ${TodoTree.renderLabel(parent, 0)}${progressText}`);
168
+ if (parent.note)
169
+ lines.push(` ↳ note: ${parent.note}`);
170
+ for (const child of parent.children ?? []) {
171
+ lines.push(` ${TodoTree.icon(child.status)} ${TodoTree.renderLabel(child, 1)}`);
172
+ if (child.note)
173
+ lines.push(` ↳ note: ${child.note}`);
174
+ }
175
+ }
176
+ return lines.join("\n");
177
+ }
178
+ static icon(status) {
179
+ return TodoTree.STATUS_ICONS[status] ?? TodoTree.DEFAULT_ICON;
180
+ }
181
+ /** Content with strike-through when completed, prefixed with its id unless it already starts with it. */
182
+ static renderLabel(item, depth) {
183
+ const content = item.status === "completed" ? `~~${item.content}~~` : item.content;
184
+ return TodoTree.contentIncludesId(item.content, item.id)
185
+ ? content
186
+ : `${TodoTree.formatIdPrefix(item.id, depth)}${content}`;
187
+ }
188
+ }
@@ -0,0 +1,35 @@
1
+ import type { Priority, TodoStatus } from "../schemas/status";
2
+ import type { TodoRootItem, TodoUpdateInput } from "../schemas/todo";
3
+ type UpdatableTodo = {
4
+ content: string;
5
+ status: TodoStatus;
6
+ priority?: Priority;
7
+ note?: string;
8
+ };
9
+ export interface UpdatedTodo {
10
+ id: number;
11
+ scope: "root" | "child";
12
+ content: string;
13
+ status: TodoStatus;
14
+ }
15
+ /**
16
+ * Order rules for one update:
17
+ *
18
+ * 1. Forward-only. A status never moves backward, and finished items are locked.
19
+ * 2. Strict order. Only the first unfinished root — and the children inside it —
20
+ * may change. Every later root waits until the previous ones are completed
21
+ * or cancelled.
22
+ * 3. Error exception. A root that holds `error` may be fixed early, but only
23
+ * that root and its own children; nothing else may be touched.
24
+ * 4. Single active root. Starting `in_progress` work is refused while another
25
+ * root is still `in_progress`, so a started root is finished before the next.
26
+ */
27
+ export declare function assertSequentialScope(todos: TodoRootItem[], input: TodoUpdateInput): {
28
+ targetRoot: TodoRootItem;
29
+ target: UpdatableTodo;
30
+ };
31
+ export declare function updateTodoById(todos: TodoRootItem[], input: TodoUpdateInput): {
32
+ todos: TodoRootItem[];
33
+ updated: UpdatedTodo;
34
+ };
35
+ export {};
@@ -0,0 +1,140 @@
1
+ const FINAL_STATUSES = ["completed", "cancelled"];
2
+ /**
3
+ * Forward-only progress: a status may advance or stay the same, never move
4
+ * backward. `error` is reportable from any open status and fixable back to
5
+ * `in_progress`/`completed`/`cancelled`.
6
+ */
7
+ const FORWARD_TRANSITIONS = {
8
+ pending: ["in_progress", "completed", "cancelled", "error"],
9
+ in_progress: ["completed", "cancelled", "error"],
10
+ error: ["in_progress", "completed", "cancelled"],
11
+ completed: [],
12
+ cancelled: [],
13
+ };
14
+ function isFinal(status) {
15
+ return FINAL_STATUSES.includes(status);
16
+ }
17
+ function applyUpdate(item, changes) {
18
+ const next = { ...item };
19
+ if (changes.content !== undefined)
20
+ next.content = changes.content;
21
+ if (changes.status !== undefined)
22
+ next.status = changes.status;
23
+ if (changes.priority !== undefined) {
24
+ if (changes.priority === null)
25
+ delete next.priority;
26
+ else
27
+ next.priority = changes.priority;
28
+ }
29
+ if (changes.note !== undefined) {
30
+ if (changes.note === null)
31
+ delete next.note;
32
+ else
33
+ next.note = changes.note;
34
+ }
35
+ return next;
36
+ }
37
+ function assertForwardTransition(from, to, id) {
38
+ if (from === to)
39
+ return;
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.`);
42
+ }
43
+ }
44
+ /** Any root still open, an `error` root included. */
45
+ function activeRoot(todos) {
46
+ return todos.find((root) => root.status === "in_progress");
47
+ }
48
+ function hasError(root) {
49
+ return (root.status === "error" ||
50
+ root.children?.some((child) => child.status === "error") === true);
51
+ }
52
+ /**
53
+ * Order rules for one update:
54
+ *
55
+ * 1. Forward-only. A status never moves backward, and finished items are locked.
56
+ * 2. Strict order. Only the first unfinished root — and the children inside it —
57
+ * may change. Every later root waits until the previous ones are completed
58
+ * or cancelled.
59
+ * 3. Error exception. A root that holds `error` may be fixed early, but only
60
+ * that root and its own children; nothing else may be touched.
61
+ * 4. Single active root. Starting `in_progress` work is refused while another
62
+ * root is still `in_progress`, so a started root is finished before the next.
63
+ */
64
+ export function assertSequentialScope(todos, input) {
65
+ const targetRoot = todos.find((root) => root.id === input.id ||
66
+ root.children?.some((child) => child.id === input.id));
67
+ if (!targetRoot) {
68
+ throw new Error(`[manage_todo_list] todo id ${input.id} was not found`);
69
+ }
70
+ const firstOpen = todos.findIndex((root) => !isFinal(root.status));
71
+ if (firstOpen === -1) {
72
+ throw new Error("[manage_todo_list] every todo is already finished; nothing can be updated.");
73
+ }
74
+ const targetRootIndex = todos.indexOf(targetRoot);
75
+ if (targetRootIndex !== firstOpen && !hasError(targetRoot)) {
76
+ const blocking = todos[firstOpen];
77
+ throw new Error(`[manage_todo_list] root ${targetRoot.id} is out of order: finish root ${blocking.id} "${blocking.content}" first (or cancel it). Only a root in 'error' may be fixed early.`);
78
+ }
79
+ const target = targetRoot.id === input.id
80
+ ? targetRoot
81
+ : targetRoot.children?.find((child) => child.id === input.id);
82
+ if (!target) {
83
+ throw new Error(`[manage_todo_list] todo id ${input.id} was not found`);
84
+ }
85
+ if (isFinal(target.status)) {
86
+ throw new Error(`[manage_todo_list] todo ${input.id} is '${target.status}' and final; it cannot be changed.`);
87
+ }
88
+ assertForwardTransition(target.status, input.status, input.id);
89
+ if (input.status === "in_progress") {
90
+ const running = activeRoot(todos);
91
+ if (running && running.id !== targetRoot.id) {
92
+ throw new Error(`[manage_todo_list] root ${running.id} is still in_progress. Finish it (or cancel it) before starting ${targetRoot.id}; an error root may be fixed early only when nothing else is running.`);
93
+ }
94
+ const busyChild = targetRoot.children?.find((child) => child.status === "in_progress" && child.id !== input.id);
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}.`);
97
+ }
98
+ }
99
+ return { targetRoot, target };
100
+ }
101
+ export function updateTodoById(todos, input) {
102
+ const { targetRoot } = assertSequentialScope(todos, input);
103
+ let updated;
104
+ const nextTodos = todos.map((root) => {
105
+ if (root.id === input.id) {
106
+ const item = applyUpdate(root, input);
107
+ updated = {
108
+ id: item.id,
109
+ scope: "root",
110
+ content: item.content,
111
+ status: item.status,
112
+ };
113
+ return item;
114
+ }
115
+ if (root.id !== targetRoot.id)
116
+ return root;
117
+ const children = (root.children ?? []).map((child) => {
118
+ if (child.id !== input.id)
119
+ return child;
120
+ const item = applyUpdate(child, input);
121
+ updated = {
122
+ id: item.id,
123
+ scope: "child",
124
+ content: item.content,
125
+ status: item.status,
126
+ };
127
+ return item;
128
+ });
129
+ // A running child implies a running parent. Finished or errored parents
130
+ // keep their status so the order rules stay visible.
131
+ const status = input.status === "in_progress" && root.status === "pending"
132
+ ? "in_progress"
133
+ : root.status;
134
+ return { ...root, status, children };
135
+ });
136
+ if (!updated) {
137
+ throw new Error(`[manage_todo_list] todo id ${input.id} was not found`);
138
+ }
139
+ return { todos: nextTodos, updated };
140
+ }
@@ -0,0 +1,18 @@
1
+ /** Rendered prefix of an error detail row. */
2
+ export declare const ERROR_PREFIX = "\u21B3 error: ";
3
+ /**
4
+ * Rendered width of a row: the indentation, the icon cell and the title.
5
+ *
6
+ * Rows must be sized explicitly, otherwise the scroll content never grows
7
+ * wider than the viewport and horizontal scrolling has nothing to move.
8
+ */
9
+ export declare function RowWidth(row: {
10
+ id?: number;
11
+ content: string;
12
+ depth: number;
13
+ }): number;
14
+ /** Rendered width of an expanded error line under its row. */
15
+ export declare function ErrorRowWidth(row: {
16
+ error: string;
17
+ depth: number;
18
+ }): number;
@@ -0,0 +1,22 @@
1
+ import { TodoTree } from "../todos/todo-tree";
2
+ /** Rendered prefix of an error detail row. */
3
+ export const ERROR_PREFIX = "↳ error: ";
4
+ /**
5
+ * Rendered width of a row: the indentation, the icon cell and the title.
6
+ *
7
+ * Rows must be sized explicitly, otherwise the scroll content never grows
8
+ * wider than the viewport and horizontal scrolling has nothing to move.
9
+ */
10
+ export function RowWidth(row) {
11
+ const indent = row.depth > 0 ? 2 : 0;
12
+ const label = row.id !== undefined
13
+ ? `${TodoTree.formatIdPrefix(row.id, row.depth)}${row.content}`
14
+ : row.content;
15
+ // icon glyph + 1 space, then the title.
16
+ return indent + 1 + 1 + [...label].length;
17
+ }
18
+ /** Rendered width of an expanded error line under its row. */
19
+ export function ErrorRowWidth(row) {
20
+ const indent = row.depth > 0 ? 4 : 2;
21
+ return indent + [...(ERROR_PREFIX + row.error)].length;
22
+ }
@@ -0,0 +1,2 @@
1
+ /** Shorten prompt text without cutting the trailing ellipsis. */
2
+ export declare function shorten(text: string, max: number): string;
@@ -0,0 +1,4 @@
1
+ /** Shorten prompt text without cutting the trailing ellipsis. */
2
+ export function shorten(text, max) {
3
+ return text.length <= max ? text : `${text.slice(0, max - 3).trimEnd()}...`;
4
+ }