@pi9/todo 0.1.0 → 0.2.0

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/src/settings.ts CHANGED
@@ -6,39 +6,50 @@ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  export type TodoWidgetPlacement = "aboveEditor" | "belowEditor" | "off";
7
7
  export type TodoToolVisibility = "all" | "set-only" | "none";
8
8
 
9
- export interface TodoUiSettings {
9
+ export interface TodoSettings {
10
10
  widgetPlacement: TodoWidgetPlacement;
11
11
  maxVisibleTasks: number;
12
12
  fallbackGlyphs: boolean;
13
13
  toolVisibility: TodoToolVisibility;
14
+ dynamicReminders: boolean;
15
+ reminderMinTurns: number;
16
+ reminderMaxTurns: number;
17
+ reminderOutputTokens: number;
18
+ reminderMaxPerRun: number;
14
19
  }
15
20
 
16
- /** The portion of Pi's session context needed to load project-specific settings. */
17
21
  export interface TodoSettingsContext {
18
22
  cwd: string;
19
23
  isProjectTrusted(): boolean;
20
24
  }
21
25
 
22
- export interface TodoUiSettingsLoadResult {
23
- settings: TodoUiSettings;
26
+ export interface TodoSettingsLoadResult {
27
+ settings: TodoSettings;
24
28
  warning?: string;
25
29
  }
26
30
 
27
- export interface TodoUiSettingsStoreOptions {
31
+ export interface TodoSettingsSourceOptions {
28
32
  globalSettingsPath?: string;
29
33
  projectSettingsPath?: (cwd: string) => string;
30
34
  }
31
35
 
32
- export const DEFAULT_TODO_UI_SETTINGS: TodoUiSettings = {
36
+ export const DEFAULT_TODO_SETTINGS: TodoSettings = {
33
37
  widgetPlacement: "aboveEditor",
34
38
  maxVisibleTasks: 5,
35
39
  fallbackGlyphs: false,
36
40
  toolVisibility: "set-only",
41
+ dynamicReminders: true,
42
+ reminderMinTurns: 4,
43
+ reminderMaxTurns: 8,
44
+ reminderOutputTokens: 16000,
45
+ reminderMaxPerRun: 2,
37
46
  };
38
47
 
39
48
  const WIDGET_PLACEMENTS = new Set<TodoWidgetPlacement>(["aboveEditor", "belowEditor", "off"]);
40
49
  const TOOL_VISIBILITIES = new Set<TodoToolVisibility>(["all", "set-only", "none"]);
41
50
 
51
+ type PositiveIntegerSetting = "reminderMinTurns" | "reminderMaxTurns" | "reminderOutputTokens" | "reminderMaxPerRun";
52
+
42
53
  export function getTodoGlobalSettingsPath(): string {
43
54
  return join(getAgentDir(), "todo", "settings.json");
44
55
  }
@@ -47,55 +58,37 @@ export function getTodoProjectSettingsPath(cwd: string): string {
47
58
  return join(cwd, CONFIG_DIR_NAME, "todo", "settings.json");
48
59
  }
49
60
 
50
- /**
51
- * Loads global settings and, only for a trusted project, project-local overrides.
52
- * Pass the event handler's `ctx` directly to `load` or `loadTodoUiSettings`.
53
- */
54
- export class TodoUiSettingsStore {
55
- private readonly globalSettingsPath: string;
56
- private readonly projectSettingsPath: (cwd: string) => string;
57
-
58
- constructor(options: TodoUiSettingsStoreOptions = {}) {
59
- this.globalSettingsPath = options.globalSettingsPath ?? getTodoGlobalSettingsPath();
60
- this.projectSettingsPath = options.projectSettingsPath ?? getTodoProjectSettingsPath;
61
- }
62
-
63
- async load(context?: TodoSettingsContext): Promise<TodoUiSettingsLoadResult> {
64
- const settings = cloneDefaults();
65
- const warnings: string[] = [];
66
-
67
- await applyFile(this.globalSettingsPath, settings, warnings);
68
-
69
- // Pi resolves trust before session handlers run. Never read project-controlled settings unless
70
- // that resolved context says the project is trusted.
71
- if (context && isTrusted(context)) {
72
- await applyFile(this.projectSettingsPath(context.cwd), settings, warnings);
73
- }
61
+ export async function loadTodoSettings(
62
+ context?: TodoSettingsContext,
63
+ options: TodoSettingsSourceOptions = {},
64
+ ): Promise<TodoSettingsLoadResult> {
65
+ const settings = defaultTodoSettings();
66
+ const warnings: string[] = [];
67
+ const globalPath = options.globalSettingsPath ?? getTodoGlobalSettingsPath();
74
68
 
75
- return { settings, ...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}) };
69
+ await applyFile(globalPath, settings, warnings);
70
+ if (context && isTrusted(context)) {
71
+ const projectPath = options.projectSettingsPath ?? getTodoProjectSettingsPath;
72
+ await applyFile(projectPath(context.cwd), settings, warnings);
76
73
  }
77
- }
78
74
 
79
- /** Convenience API for loading settings from a Pi session/event context. */
80
- export async function loadTodoUiSettings(context?: TodoSettingsContext): Promise<TodoUiSettingsLoadResult> {
81
- return new TodoUiSettingsStore().load(context);
75
+ return loadResult(settings, warnings);
82
76
  }
83
77
 
84
- /** Validates an object as a complete settings input, defaulting invalid or absent fields. */
85
- export function normalizeTodoUiSettings(value: unknown): TodoUiSettingsLoadResult {
86
- const settings = cloneDefaults();
78
+ export function normalizeTodoSettings(value: unknown): TodoSettingsLoadResult {
79
+ const settings = defaultTodoSettings();
87
80
  const warnings: string[] = [];
88
81
  applySettings(value, settings, warnings);
89
- return { settings, ...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}) };
82
+ return loadResult(settings, warnings);
90
83
  }
91
84
 
92
- async function applyFile(path: string, settings: TodoUiSettings, warnings: string[]): Promise<void> {
85
+ async function applyFile(path: string, settings: TodoSettings, warnings: string[]): Promise<void> {
93
86
  let raw: string;
94
87
  try {
95
88
  raw = await readFile(path, "utf8");
96
89
  } catch (error) {
97
90
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
98
- warnings.push(`Could not read todo settings at ${path}; using available defaults.`);
91
+ warnings.push(`Could not read todo settings at ${path}; keeping existing settings.`);
99
92
  }
100
93
  return;
101
94
  }
@@ -103,54 +96,80 @@ async function applyFile(path: string, settings: TodoUiSettings, warnings: strin
103
96
  try {
104
97
  applySettings(JSON.parse(raw) as unknown, settings, warnings);
105
98
  } catch {
106
- warnings.push(`Invalid todo settings at ${path}; using available defaults.`);
99
+ warnings.push(`Invalid todo settings at ${path}; keeping existing settings.`);
107
100
  }
108
101
  }
109
102
 
110
- function applySettings(value: unknown, settings: TodoUiSettings, warnings: string[]): void {
103
+ function applySettings(value: unknown, settings: TodoSettings, warnings: string[]): void {
111
104
  if (!isRecord(value)) {
112
- warnings.push("Invalid todo settings; using available defaults.");
105
+ warnings.push("Invalid todo settings; using defaults.");
113
106
  return;
114
107
  }
115
108
 
116
109
  if (value.widgetPlacement !== undefined) {
117
- if (WIDGET_PLACEMENTS.has(value.widgetPlacement as TodoWidgetPlacement)) {
118
- settings.widgetPlacement = value.widgetPlacement as TodoWidgetPlacement;
119
- } else {
120
- warnings.push("Invalid todo widgetPlacement; ignoring value.");
121
- }
110
+ if (isSetMember(WIDGET_PLACEMENTS, value.widgetPlacement)) settings.widgetPlacement = value.widgetPlacement;
111
+ else warnings.push("Invalid todo widgetPlacement; ignoring value.");
122
112
  }
123
113
  if (value.maxVisibleTasks !== undefined) {
124
- if (Number.isInteger(value.maxVisibleTasks) && (value.maxVisibleTasks as number) > 0) {
125
- settings.maxVisibleTasks = value.maxVisibleTasks as number;
126
- } else {
127
- warnings.push("Invalid todo maxVisibleTasks; ignoring value.");
128
- }
114
+ if (isPositiveInteger(value.maxVisibleTasks)) settings.maxVisibleTasks = value.maxVisibleTasks;
115
+ else warnings.push("Invalid todo maxVisibleTasks; ignoring value.");
129
116
  }
130
117
  if (value.fallbackGlyphs !== undefined) {
131
- if (typeof value.fallbackGlyphs === "boolean") {
132
- settings.fallbackGlyphs = value.fallbackGlyphs;
133
- } else {
134
- warnings.push("Invalid todo fallbackGlyphs; ignoring value.");
135
- }
118
+ if (typeof value.fallbackGlyphs === "boolean") settings.fallbackGlyphs = value.fallbackGlyphs;
119
+ else warnings.push("Invalid todo fallbackGlyphs; ignoring value.");
136
120
  }
137
121
  if (value.toolVisibility !== undefined) {
138
- if (TOOL_VISIBILITIES.has(value.toolVisibility as TodoToolVisibility)) {
139
- settings.toolVisibility = value.toolVisibility as TodoToolVisibility;
140
- } else {
141
- warnings.push("Invalid todo toolVisibility; ignoring value.");
142
- }
122
+ if (isSetMember(TOOL_VISIBILITIES, value.toolVisibility)) settings.toolVisibility = value.toolVisibility;
123
+ else warnings.push("Invalid todo toolVisibility; ignoring value.");
124
+ }
125
+ if (value.dynamicReminders !== undefined) {
126
+ if (typeof value.dynamicReminders === "boolean") settings.dynamicReminders = value.dynamicReminders;
127
+ else warnings.push("Invalid todo dynamicReminders; ignoring value.");
128
+ }
129
+
130
+ const priorTurnRange = [settings.reminderMinTurns, settings.reminderMaxTurns] as const;
131
+ applyPositiveInteger(value, settings, warnings, "reminderMinTurns");
132
+ applyPositiveInteger(value, settings, warnings, "reminderMaxTurns");
133
+ if (settings.reminderMaxTurns < settings.reminderMinTurns) {
134
+ [settings.reminderMinTurns, settings.reminderMaxTurns] = priorTurnRange;
135
+ warnings.push("Invalid todo reminderMaxTurns; must be at least reminderMinTurns; ignoring reminder turn range.");
143
136
  }
137
+ applyPositiveInteger(value, settings, warnings, "reminderOutputTokens");
138
+ applyPositiveInteger(value, settings, warnings, "reminderMaxPerRun");
144
139
  }
145
140
 
146
- function cloneDefaults(): TodoUiSettings {
147
- return { ...DEFAULT_TODO_UI_SETTINGS };
141
+ function applyPositiveInteger(
142
+ value: Record<string, unknown>,
143
+ settings: TodoSettings,
144
+ warnings: string[],
145
+ field: PositiveIntegerSetting,
146
+ ): void {
147
+ const candidate = value[field];
148
+ if (candidate === undefined) return;
149
+ if (isPositiveInteger(candidate)) settings[field] = candidate;
150
+ else warnings.push(`Invalid todo ${field}; ignoring value.`);
151
+ }
152
+
153
+ function defaultTodoSettings(): TodoSettings {
154
+ return { ...DEFAULT_TODO_SETTINGS };
155
+ }
156
+
157
+ function loadResult(settings: TodoSettings, warnings: string[]): TodoSettingsLoadResult {
158
+ return { settings, ...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}) };
148
159
  }
149
160
 
150
161
  function isRecord(value: unknown): value is Record<string, unknown> {
151
162
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
152
163
  }
153
164
 
165
+ function isPositiveInteger(value: unknown): value is number {
166
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
167
+ }
168
+
169
+ function isSetMember<T extends string>(values: ReadonlySet<T>, value: unknown): value is T {
170
+ return typeof value === "string" && values.has(value as T);
171
+ }
172
+
154
173
  function isTrusted(context: TodoSettingsContext): boolean {
155
174
  try {
156
175
  return context.isProjectTrusted();
package/src/state.ts CHANGED
@@ -1,4 +1,16 @@
1
- import { TODO_ACTIONS, TODO_STATUSES, type TodoAction, type TodoPhase, type TodoPhaseInput, type TodoState, type TodoStatus, type TodoTransitionInput } from "./types.js";
1
+ import {
2
+ TODO_ACTIONS,
3
+ TODO_STATUSES,
4
+ isTodoActionName,
5
+ isTodoStatus,
6
+ type TodoAction,
7
+ type TodoActionName,
8
+ type TodoPhase,
9
+ type TodoPhaseInput,
10
+ type TodoState,
11
+ type TodoStatus,
12
+ type TodoTransitionInput,
13
+ } from "./types.js";
2
14
 
3
15
  export function createTodoState(): TodoState {
4
16
  return { phases: [] };
@@ -14,78 +26,117 @@ export function cloneTodoState(state: TodoState): TodoState {
14
26
  }
15
27
 
16
28
  /** Applies an action atomically without mutating the supplied state or action. */
17
- export function transitionTodoState(state: TodoState, action: TodoAction | unknown): TodoState {
18
- assertState(state);
19
- const input = record(action, "Todo action");
20
- const actionName = input.action;
21
- if (typeof actionName !== "string" || !(TODO_ACTIONS as readonly string[]).includes(actionName)) {
22
- throw new Error(`Todo action must be one of: ${TODO_ACTIONS.join(", ")}.`);
23
- }
29
+ export function transitionTodoState(state: TodoState, value: unknown): TodoState {
30
+ assertTodoState(state);
31
+ const action = parseTodoAction(value);
24
32
 
25
- const next = cloneTodoState(state);
26
- switch (actionName) {
33
+ let next: TodoState;
34
+ switch (action.action) {
27
35
  case "set":
28
- assertOnlyFields(input, ["action", "phases"], "set");
29
- next.phases = parsePhases(input.phases).map(newPhase);
36
+ next = { phases: action.phases.map(newPhase) };
30
37
  break;
31
38
  case "add":
32
- assertOnlyFields(input, ["action", "phases"], "add");
33
- addPhases(next, parsePhases(input.phases));
39
+ next = addPhases(state, action.phases);
34
40
  break;
35
41
  case "transition":
36
- assertOnlyFields(input, ["action", "transitions"], "transition");
37
- applyTransitions(next, parseTransitions(input.transitions));
42
+ next = applyTransitions(state, action.transitions);
38
43
  break;
39
44
  case "view":
40
- assertOnlyFields(input, ["action", "phase"], "view");
41
- if (input.phase !== undefined) next.phases = [findPhase(next.phases, name(input.phase, "view phase"))];
45
+ next = action.phase === undefined
46
+ ? state
47
+ : { phases: [findPhase(state.phases, action.phase)] };
42
48
  break;
43
49
  }
44
50
 
45
- assertState(next);
51
+ assertTodoState(next);
46
52
  return next;
47
53
  }
48
54
 
49
- export const applyTodoAction = transitionTodoState;
50
-
51
55
  function newPhase(input: TodoPhaseInput): TodoPhase {
52
- return { name: input.name, tasks: input.tasks.map((task) => ({ name: task, status: "pending" })) };
56
+ return {
57
+ name: input.name,
58
+ tasks: input.tasks.map((task) => ({ name: task, status: "pending" })),
59
+ };
53
60
  }
54
61
 
55
- function addPhases(state: TodoState, inputs: TodoPhaseInput[]): void {
62
+ function addPhases(state: TodoState, inputs: readonly TodoPhaseInput[]): TodoState {
56
63
  if (!inputs.some((phase) => phase.tasks.length > 0)) {
57
64
  throw new Error("add requires at least one task.");
58
65
  }
59
66
 
67
+ const existingPhases = new Map(state.phases.map((phase) => [phase.name, phase]));
60
68
  for (const input of inputs) {
61
- let phase = state.phases.find((candidate) => candidate.name === input.name);
62
- if (!phase) {
63
- phase = { name: input.name, tasks: [] };
64
- state.phases.push(phase);
65
- }
66
- const names = new Set(phase.tasks.map((task) => task.name));
69
+ const phase = existingPhases.get(input.name);
70
+ if (!phase) continue;
71
+
72
+ const taskNames = new Set(phase.tasks.map((task) => task.name));
67
73
  for (const task of input.tasks) {
68
- if (names.has(task)) throw new Error(`Duplicate task name in phase ${input.name}: ${task}.`);
69
- names.add(task);
70
- phase.tasks.push({ name: task, status: "pending" });
74
+ if (taskNames.has(task)) throw new Error(`Duplicate task name in phase ${input.name}: ${task}.`);
75
+ taskNames.add(task);
71
76
  }
72
77
  }
78
+
79
+ const additions = new Map(inputs.map((phase) => [phase.name, phase.tasks]));
80
+ const phases = state.phases.map((phase) => {
81
+ const tasks = additions.get(phase.name);
82
+ return !tasks || tasks.length === 0
83
+ ? phase
84
+ : { ...phase, tasks: [...phase.tasks, ...tasks.map((name) => ({ name, status: "pending" as const }))] };
85
+ });
86
+
87
+ for (const input of inputs) {
88
+ if (!existingPhases.has(input.name)) phases.push(newPhase(input));
89
+ }
90
+ return { phases };
73
91
  }
74
92
 
75
- function applyTransitions(state: TodoState, transitions: TodoTransitionInput[]): void {
93
+ function applyTransitions(state: TodoState, transitions: readonly TodoTransitionInput[]): TodoState {
76
94
  if (transitions.length === 0) throw new Error("transition requires at least one status change.");
77
- const addressed = new Set<string>();
78
95
 
96
+ const statuses = new Map<string, TodoStatus>();
79
97
  for (const transition of transitions) {
80
- const key = addressKey(transition.phase, transition.task);
81
- if (addressed.has(key)) throw new Error(`Task may only be transitioned once per call: ${transition.phase} / ${transition.task}.`);
82
- addressed.add(key);
98
+ const key = todoAddressKey(transition.phase, transition.task);
99
+ if (statuses.has(key)) {
100
+ throw new Error(`Task may only be transitioned once per call: ${transition.phase} / ${transition.task}.`);
101
+ }
83
102
 
84
103
  const phase = state.phases.find((candidate) => candidate.name === transition.phase);
85
104
  if (!phase) throw new Error(phaseNotFoundMessage(state.phases, transition.phase));
86
- const task = phase.tasks.find((candidate) => candidate.name === transition.task);
87
- if (!task) throw new Error(taskNotFoundMessage(phase, transition.task));
88
- task.status = transition.status;
105
+ if (!phase.tasks.some((candidate) => candidate.name === transition.task)) {
106
+ throw new Error(taskNotFoundMessage(phase, transition.task));
107
+ }
108
+ statuses.set(key, transition.status);
109
+ }
110
+
111
+ return {
112
+ phases: state.phases.map((phase) => ({
113
+ ...phase,
114
+ tasks: phase.tasks.map((task) => {
115
+ const status = statuses.get(todoAddressKey(phase.name, task.name));
116
+ return status === undefined ? task : { ...task, status };
117
+ }),
118
+ })),
119
+ };
120
+ }
121
+
122
+ function parseTodoAction(value: unknown): TodoAction {
123
+ const input = record(value, "Todo action");
124
+ const action = actionName(input.action);
125
+
126
+ switch (action) {
127
+ case "set":
128
+ case "add":
129
+ assertOnlyFields(input, ["action", "phases"], action);
130
+ return { action, phases: parsePhases(input.phases) };
131
+ case "transition":
132
+ assertOnlyFields(input, ["action", "transitions"], action);
133
+ return { action, transitions: parseTransitions(input.transitions) };
134
+ case "view":
135
+ assertOnlyFields(input, ["action", "phase"], action);
136
+ return {
137
+ action,
138
+ ...(input.phase === undefined ? {} : { phase: name(input.phase, "view phase") }),
139
+ };
89
140
  }
90
141
  }
91
142
 
@@ -128,19 +179,22 @@ function name(value: unknown, label: string): string {
128
179
  return value;
129
180
  }
130
181
 
182
+ function actionName(value: unknown): TodoActionName {
183
+ if (!isTodoActionName(value)) throw new Error(`Todo action must be one of: ${TODO_ACTIONS.join(", ")}.`);
184
+ return value;
185
+ }
186
+
131
187
  function status(value: unknown, label: string): TodoStatus {
132
- if (typeof value !== "string" || !(TODO_STATUSES as readonly string[]).includes(value)) {
133
- throw new Error(`${label} must be one of: ${TODO_STATUSES.join(", ")}.`);
134
- }
135
- return value as TodoStatus;
188
+ if (!isTodoStatus(value)) throw new Error(`${label} must be one of: ${TODO_STATUSES.join(", ")}.`);
189
+ return value;
136
190
  }
137
191
 
138
- function assertOnlyFields(input: Record<string, unknown>, allowed: string[], label: string): void {
192
+ function assertOnlyFields(input: Record<string, unknown>, allowed: readonly string[], label: string): void {
139
193
  const unexpected = Object.keys(input).find((key) => !allowed.includes(key));
140
194
  if (unexpected) throw new Error(`${label} does not accept field: ${unexpected}.`);
141
195
  }
142
196
 
143
- function assertUnique(values: string[], message: (value: string) => string): void {
197
+ function assertUnique(values: readonly string[], message: (value: string) => string): void {
144
198
  const seen = new Set<string>();
145
199
  for (const value of values) {
146
200
  if (seen.has(value)) throw new Error(message(value));
@@ -148,13 +202,13 @@ function assertUnique(values: string[], message: (value: string) => string): voi
148
202
  }
149
203
  }
150
204
 
151
- function findPhase(phases: TodoPhase[], phaseName: string): TodoPhase {
205
+ function findPhase(phases: readonly TodoPhase[], phaseName: string): TodoPhase {
152
206
  const phase = phases.find((candidate) => candidate.name === phaseName);
153
207
  if (!phase) throw new Error(phaseNotFoundMessage(phases, phaseName));
154
208
  return phase;
155
209
  }
156
210
 
157
- function phaseNotFoundMessage(phases: TodoPhase[], phaseName: string): string {
211
+ function phaseNotFoundMessage(phases: readonly TodoPhase[], phaseName: string): string {
158
212
  const names = phases.map((phase) => `- ${phase.name}`).join("\n");
159
213
  return names ? `Phase not found: ${phaseName}.\n\nCurrent phases:\n${names}` : `Phase not found: ${phaseName}. The todo plan is empty.`;
160
214
  }
@@ -167,30 +221,50 @@ function taskNotFoundMessage(phase: TodoPhase, taskName: string): string {
167
221
  }
168
222
 
169
223
  export function todoAddressKey(phase: string, task: string): string {
170
- return addressKey(phase, task);
224
+ return `${phase}\0${task}`;
171
225
  }
172
226
 
173
- function addressKey(phase: string, task: string): string {
174
- return `${phase}\0${task}`;
227
+ export function currentTodoPhaseIndex(phases: readonly TodoPhase[]): number {
228
+ const active = phases.findIndex((phase) => phase.tasks.some((task) => task.status === "in_progress"));
229
+ return active >= 0
230
+ ? active
231
+ : phases.findIndex((phase) => phase.tasks.some((task) => task.status === "pending"));
175
232
  }
176
233
 
177
- function assertState(state: TodoState): void {
178
- if (!state || typeof state !== "object" || !Array.isArray(state.phases)) throw new Error("Invalid todo state.");
234
+ export function isTodoState(value: unknown): value is TodoState {
235
+ try {
236
+ assertTodoState(value);
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ function assertTodoState(value: unknown): asserts value is TodoState {
244
+ if (!value || typeof value !== "object" || !Array.isArray((value as { phases?: unknown }).phases)) {
245
+ throw new Error("Invalid todo state.");
246
+ }
247
+
248
+ const state = value as { phases: unknown[] };
179
249
  const phaseNames = new Set<string>();
180
250
  let activePhase: string | undefined;
181
251
 
182
- for (const phase of state.phases) {
183
- if (!phase || typeof phase !== "object" || !Array.isArray(phase.tasks)) throw new Error("Invalid todo state.");
252
+ for (const value of state.phases) {
253
+ if (!value || typeof value !== "object" || !Array.isArray((value as { tasks?: unknown }).tasks)) {
254
+ throw new Error("Invalid todo state.");
255
+ }
256
+ const phase = value as { name?: unknown; tasks: unknown[] };
184
257
  const phaseName = name(phase.name, "phase name");
185
258
  if (phaseNames.has(phaseName)) throw new Error("Invalid todo state: duplicate phase name.");
186
259
  phaseNames.add(phaseName);
187
260
 
188
261
  const taskNames = new Set<string>();
189
- for (const task of phase.tasks) {
190
- if (!task || typeof task !== "object") throw new Error("Invalid todo state.");
262
+ for (const value of phase.tasks) {
263
+ if (!value || typeof value !== "object") throw new Error("Invalid todo state.");
264
+ const task = value as { name?: unknown; status?: unknown };
191
265
  const taskName = name(task.name, "task name");
192
266
  if (taskNames.has(taskName)) throw new Error("Invalid todo state: duplicate task name.");
193
- if (!(TODO_STATUSES as readonly string[]).includes(task.status)) throw new Error("Invalid todo state.");
267
+ if (!isTodoStatus(task.status)) throw new Error("Invalid todo state.");
194
268
  if (task.status === "in_progress") {
195
269
  if (activePhase !== undefined && activePhase !== phaseName) {
196
270
  throw new Error("Invalid todo state: in_progress tasks must all belong to one phase.");