@pi9/todo 0.1.0 → 0.3.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: [] };
@@ -10,94 +22,149 @@ export function cloneTodoState(state: TodoState): TodoState {
10
22
  name: phase.name,
11
23
  tasks: phase.tasks.map((task) => ({ ...task })),
12
24
  })),
25
+ ...(state.workingOn === undefined ? {} : { workingOn: state.workingOn }),
13
26
  };
14
27
  }
15
28
 
16
29
  /** 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
- }
30
+ export function transitionTodoState(state: TodoState, value: unknown): TodoState {
31
+ assertTodoState(state);
32
+ const action = parseTodoAction(value);
24
33
 
25
- const next = cloneTodoState(state);
26
- switch (actionName) {
34
+ let next: TodoState;
35
+ switch (action.action) {
27
36
  case "set":
28
- assertOnlyFields(input, ["action", "phases"], "set");
29
- next.phases = parsePhases(input.phases).map(newPhase);
37
+ next = { phases: action.phases.map(newPhase) };
30
38
  break;
31
39
  case "add":
32
- assertOnlyFields(input, ["action", "phases"], "add");
33
- addPhases(next, parsePhases(input.phases));
40
+ next = addPhases(state, action.phases);
34
41
  break;
35
42
  case "transition":
36
- assertOnlyFields(input, ["action", "transitions"], "transition");
37
- applyTransitions(next, parseTransitions(input.transitions));
43
+ next = applyTransitions(state, action.transitions, action.workingOn);
38
44
  break;
39
45
  case "view":
40
- assertOnlyFields(input, ["action", "phase"], "view");
41
- if (input.phase !== undefined) next.phases = [findPhase(next.phases, name(input.phase, "view phase"))];
46
+ next = state;
42
47
  break;
43
48
  }
44
49
 
45
- assertState(next);
50
+ assertTodoState(next);
46
51
  return next;
47
52
  }
48
53
 
49
- export const applyTodoAction = transitionTodoState;
50
-
51
54
  function newPhase(input: TodoPhaseInput): TodoPhase {
52
- return { name: input.name, tasks: input.tasks.map((task) => ({ name: task, status: "pending" })) };
55
+ return {
56
+ name: input.name,
57
+ tasks: input.tasks.map((task) => ({ ...task, status: "pending" })),
58
+ };
53
59
  }
54
60
 
55
- function addPhases(state: TodoState, inputs: TodoPhaseInput[]): void {
56
- if (!inputs.some((phase) => phase.tasks.length > 0)) {
57
- throw new Error("add requires at least one task.");
58
- }
59
-
61
+ function addPhases(state: TodoState, inputs: readonly TodoPhaseInput[]): TodoState {
62
+ const existingPhases = new Map(state.phases.map((phase) => [phase.name, phase]));
60
63
  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));
64
+ const phase = existingPhases.get(input.name);
65
+ if (!phase) continue;
66
+
67
+ const taskNames = new Set(phase.tasks.map((task) => task.name));
67
68
  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" });
69
+ if (taskNames.has(task.name)) throw new Error(`Duplicate task name in phase ${input.name}: ${task.name}.`);
70
+ taskNames.add(task.name);
71
71
  }
72
72
  }
73
+
74
+ const additions = new Map(inputs.map((phase) => [phase.name, phase.tasks]));
75
+ const phases = state.phases.map((phase) => {
76
+ const tasks = additions.get(phase.name);
77
+ return !tasks
78
+ ? phase
79
+ : { ...phase, tasks: [...phase.tasks, ...tasks.map((task) => ({ ...task, status: "pending" as const }))] };
80
+ });
81
+
82
+ for (const input of inputs) {
83
+ if (!existingPhases.has(input.name)) phases.push(newPhase(input));
84
+ }
85
+ return { ...state, phases };
73
86
  }
74
87
 
75
- function applyTransitions(state: TodoState, transitions: TodoTransitionInput[]): void {
88
+ function applyTransitions(
89
+ state: TodoState,
90
+ transitions: readonly TodoTransitionInput[],
91
+ workingOn: string | undefined,
92
+ ): TodoState {
76
93
  if (transitions.length === 0) throw new Error("transition requires at least one status change.");
77
- const addressed = new Set<string>();
78
94
 
95
+ const statuses = new Map<string, TodoStatus>();
79
96
  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);
97
+ const key = todoAddressKey(transition.phase, transition.task);
98
+ if (statuses.has(key)) {
99
+ throw new Error(`Task may only be transitioned once per call: ${transition.phase} / ${transition.task}.`);
100
+ }
83
101
 
84
102
  const phase = state.phases.find((candidate) => candidate.name === transition.phase);
85
103
  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;
104
+ if (!phase.tasks.some((candidate) => candidate.name === transition.task)) {
105
+ throw new Error(taskNotFoundMessage(phase, transition.task));
106
+ }
107
+ statuses.set(key, transition.status);
108
+ }
109
+
110
+ const phases = state.phases.map((phase) => ({
111
+ ...phase,
112
+ tasks: phase.tasks.map((task) => {
113
+ const status = statuses.get(todoAddressKey(phase.name, task.name));
114
+ return status === undefined ? task : { ...task, status };
115
+ }),
116
+ }));
117
+ const hasActiveTasks = phases.some((phase) => phase.tasks.some((task) => task.status === "in_progress"));
118
+ if (!hasActiveTasks) return { phases };
119
+ if (workingOn === undefined) {
120
+ throw new Error("transition requires workingOn when tasks remain in_progress.");
121
+ }
122
+ return { phases, workingOn };
123
+ }
124
+
125
+ function parseTodoAction(value: unknown): TodoAction {
126
+ const input = record(value, "Todo action");
127
+ const action = actionName(input.action);
128
+
129
+ switch (action) {
130
+ case "set":
131
+ case "add":
132
+ assertOnlyFields(input, ["action", "phases"], action);
133
+ return { action, phases: parsePhases(input.phases) };
134
+ case "transition":
135
+ assertOnlyFields(input, ["action", "transitions", "workingOn"], action);
136
+ return {
137
+ action,
138
+ transitions: parseTransitions(input.transitions),
139
+ ...(input.workingOn === undefined
140
+ ? {}
141
+ : { workingOn: name(input.workingOn, "transition workingOn") }),
142
+ };
143
+ case "view":
144
+ assertOnlyFields(input, ["action"], action);
145
+ return { action };
89
146
  }
90
147
  }
91
148
 
92
149
  function parsePhases(value: unknown): TodoPhaseInput[] {
93
150
  if (!Array.isArray(value)) throw new Error("phases must be an array.");
151
+ if (value.length === 0) throw new Error("phases must contain at least one phase.");
94
152
  const phases = value.map((item, phaseIndex) => {
95
153
  const input = record(item, `phases[${phaseIndex}]`);
96
154
  assertOnlyFields(input, ["name", "tasks"], `phases[${phaseIndex}]`);
97
155
  const phaseName = name(input.name, `phases[${phaseIndex}].name`);
98
156
  if (!Array.isArray(input.tasks)) throw new Error(`phases[${phaseIndex}].tasks must be an array.`);
99
- const tasks = input.tasks.map((task, taskIndex) => name(task, `phases[${phaseIndex}].tasks[${taskIndex}]`));
100
- assertUnique(tasks, (task) => `Duplicate task name in phase ${phaseName}: ${task}.`);
157
+ if (input.tasks.length === 0) throw new Error(`phases[${phaseIndex}].tasks must contain at least one task.`);
158
+ const tasks = input.tasks.map((task, taskIndex) => {
159
+ const label = `phases[${phaseIndex}].tasks[${taskIndex}]`;
160
+ const taskInput = record(task, label);
161
+ assertOnlyFields(taskInput, ["name", "description"], label);
162
+ return {
163
+ name: name(taskInput.name, `${label}.name`),
164
+ description: name(taskInput.description, `${label}.description`),
165
+ };
166
+ });
167
+ assertUnique(tasks.map((task) => task.name), (task) => `Duplicate task name in phase ${phaseName}: ${task}.`);
101
168
  return { name: phaseName, tasks };
102
169
  });
103
170
  assertUnique(phases.map((phase) => phase.name), (phase) => `Duplicate phase name: ${phase}.`);
@@ -128,19 +195,22 @@ function name(value: unknown, label: string): string {
128
195
  return value;
129
196
  }
130
197
 
198
+ function actionName(value: unknown): TodoActionName {
199
+ if (!isTodoActionName(value)) throw new Error(`Todo action must be one of: ${TODO_ACTIONS.join(", ")}.`);
200
+ return value;
201
+ }
202
+
131
203
  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;
204
+ if (!isTodoStatus(value)) throw new Error(`${label} must be one of: ${TODO_STATUSES.join(", ")}.`);
205
+ return value;
136
206
  }
137
207
 
138
- function assertOnlyFields(input: Record<string, unknown>, allowed: string[], label: string): void {
208
+ function assertOnlyFields(input: Record<string, unknown>, allowed: readonly string[], label: string): void {
139
209
  const unexpected = Object.keys(input).find((key) => !allowed.includes(key));
140
210
  if (unexpected) throw new Error(`${label} does not accept field: ${unexpected}.`);
141
211
  }
142
212
 
143
- function assertUnique(values: string[], message: (value: string) => string): void {
213
+ function assertUnique(values: readonly string[], message: (value: string) => string): void {
144
214
  const seen = new Set<string>();
145
215
  for (const value of values) {
146
216
  if (seen.has(value)) throw new Error(message(value));
@@ -148,13 +218,7 @@ function assertUnique(values: string[], message: (value: string) => string): voi
148
218
  }
149
219
  }
150
220
 
151
- function findPhase(phases: TodoPhase[], phaseName: string): TodoPhase {
152
- const phase = phases.find((candidate) => candidate.name === phaseName);
153
- if (!phase) throw new Error(phaseNotFoundMessage(phases, phaseName));
154
- return phase;
155
- }
156
-
157
- function phaseNotFoundMessage(phases: TodoPhase[], phaseName: string): string {
221
+ function phaseNotFoundMessage(phases: readonly TodoPhase[], phaseName: string): string {
158
222
  const names = phases.map((phase) => `- ${phase.name}`).join("\n");
159
223
  return names ? `Phase not found: ${phaseName}.\n\nCurrent phases:\n${names}` : `Phase not found: ${phaseName}. The todo plan is empty.`;
160
224
  }
@@ -167,30 +231,53 @@ function taskNotFoundMessage(phase: TodoPhase, taskName: string): string {
167
231
  }
168
232
 
169
233
  export function todoAddressKey(phase: string, task: string): string {
170
- return addressKey(phase, task);
234
+ return `${phase}\0${task}`;
171
235
  }
172
236
 
173
- function addressKey(phase: string, task: string): string {
174
- return `${phase}\0${task}`;
237
+ export function currentTodoPhaseIndex(phases: readonly TodoPhase[]): number {
238
+ const active = phases.findIndex((phase) => phase.tasks.some((task) => task.status === "in_progress"));
239
+ return active >= 0
240
+ ? active
241
+ : phases.findIndex((phase) => phase.tasks.some((task) => task.status === "pending"));
175
242
  }
176
243
 
177
- function assertState(state: TodoState): void {
178
- if (!state || typeof state !== "object" || !Array.isArray(state.phases)) throw new Error("Invalid todo state.");
244
+ export function isTodoState(value: unknown): value is TodoState {
245
+ try {
246
+ assertTodoState(value);
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
253
+ function assertTodoState(value: unknown): asserts value is TodoState {
254
+ if (!value || typeof value !== "object" || !Array.isArray((value as { phases?: unknown }).phases)) {
255
+ throw new Error("Invalid todo state.");
256
+ }
257
+
258
+ const state = value as { phases: unknown[]; workingOn?: unknown };
259
+ const workingOn = state.workingOn === undefined ? undefined : name(state.workingOn, "workingOn");
179
260
  const phaseNames = new Set<string>();
180
261
  let activePhase: string | undefined;
181
262
 
182
- for (const phase of state.phases) {
183
- if (!phase || typeof phase !== "object" || !Array.isArray(phase.tasks)) throw new Error("Invalid todo state.");
263
+ for (const value of state.phases) {
264
+ if (!value || typeof value !== "object" || !Array.isArray((value as { tasks?: unknown }).tasks)) {
265
+ throw new Error("Invalid todo state.");
266
+ }
267
+ const phase = value as { name?: unknown; tasks: unknown[] };
268
+ if (phase.tasks.length === 0) throw new Error("Invalid todo state: phases must contain at least one task.");
184
269
  const phaseName = name(phase.name, "phase name");
185
270
  if (phaseNames.has(phaseName)) throw new Error("Invalid todo state: duplicate phase name.");
186
271
  phaseNames.add(phaseName);
187
272
 
188
273
  const taskNames = new Set<string>();
189
- for (const task of phase.tasks) {
190
- if (!task || typeof task !== "object") throw new Error("Invalid todo state.");
274
+ for (const value of phase.tasks) {
275
+ if (!value || typeof value !== "object") throw new Error("Invalid todo state.");
276
+ const task = value as { name?: unknown; description?: unknown; status?: unknown };
191
277
  const taskName = name(task.name, "task name");
278
+ name(task.description, "task description");
192
279
  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.");
280
+ if (!isTodoStatus(task.status)) throw new Error("Invalid todo state.");
194
281
  if (task.status === "in_progress") {
195
282
  if (activePhase !== undefined && activePhase !== phaseName) {
196
283
  throw new Error("Invalid todo state: in_progress tasks must all belong to one phase.");
@@ -200,4 +287,11 @@ function assertState(state: TodoState): void {
200
287
  taskNames.add(taskName);
201
288
  }
202
289
  }
290
+
291
+ if (activePhase !== undefined && workingOn === undefined) {
292
+ throw new Error("Invalid todo state: workingOn is required while tasks are in_progress.");
293
+ }
294
+ if (activePhase === undefined && workingOn !== undefined) {
295
+ throw new Error("Invalid todo state: workingOn requires an in_progress task.");
296
+ }
203
297
  }