@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/README.md +21 -82
- package/media/todo-plan.png +0 -0
- package/package.json +3 -2
- package/src/format.ts +64 -27
- package/src/persistence.ts +30 -59
- package/src/reminder-cadence.ts +59 -0
- package/src/reminder.ts +24 -0
- package/src/renderer.ts +11 -67
- package/src/schema.ts +17 -21
- package/src/settings.ts +85 -66
- package/src/state.ts +163 -69
- package/src/tool.ts +100 -34
- package/src/types.ts +46 -22
- package/src/visibility.ts +1 -5
- package/src/widget-component.ts +17 -19
- package/src/widget-layout.ts +43 -74
- package/src/widget.ts +73 -28
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
|
|
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
|
|
23
|
-
settings:
|
|
26
|
+
export interface TodoSettingsLoadResult {
|
|
27
|
+
settings: TodoSettings;
|
|
24
28
|
warning?: string;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
export interface
|
|
31
|
+
export interface TodoSettingsSourceOptions {
|
|
28
32
|
globalSettingsPath?: string;
|
|
29
33
|
projectSettingsPath?: (cwd: string) => string;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
export const
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
82
|
+
return loadResult(settings, warnings);
|
|
90
83
|
}
|
|
91
84
|
|
|
92
|
-
async function applyFile(path: string, settings:
|
|
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};
|
|
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};
|
|
99
|
+
warnings.push(`Invalid todo settings at ${path}; keeping existing settings.`);
|
|
107
100
|
}
|
|
108
101
|
}
|
|
109
102
|
|
|
110
|
-
function applySettings(value: unknown, settings:
|
|
103
|
+
function applySettings(value: unknown, settings: TodoSettings, warnings: string[]): void {
|
|
111
104
|
if (!isRecord(value)) {
|
|
112
|
-
warnings.push("Invalid todo settings; using
|
|
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
|
|
118
|
-
|
|
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 (
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
147
|
-
|
|
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 {
|
|
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,
|
|
18
|
-
|
|
19
|
-
const
|
|
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
|
-
|
|
26
|
-
switch (
|
|
34
|
+
let next: TodoState;
|
|
35
|
+
switch (action.action) {
|
|
27
36
|
case "set":
|
|
28
|
-
|
|
29
|
-
next.phases = parsePhases(input.phases).map(newPhase);
|
|
37
|
+
next = { phases: action.phases.map(newPhase) };
|
|
30
38
|
break;
|
|
31
39
|
case "add":
|
|
32
|
-
|
|
33
|
-
addPhases(next, parsePhases(input.phases));
|
|
40
|
+
next = addPhases(state, action.phases);
|
|
34
41
|
break;
|
|
35
42
|
case "transition":
|
|
36
|
-
|
|
37
|
-
applyTransitions(next, parseTransitions(input.transitions));
|
|
43
|
+
next = applyTransitions(state, action.transitions, action.workingOn);
|
|
38
44
|
break;
|
|
39
45
|
case "view":
|
|
40
|
-
|
|
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
|
-
|
|
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 {
|
|
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[]):
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
if (!phase)
|
|
63
|
-
|
|
64
|
-
|
|
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 (
|
|
69
|
-
|
|
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(
|
|
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 =
|
|
81
|
-
if (
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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 (
|
|
133
|
-
|
|
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
|
|
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
|
|
234
|
+
return `${phase}\0${task}`;
|
|
171
235
|
}
|
|
172
236
|
|
|
173
|
-
function
|
|
174
|
-
|
|
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
|
|
178
|
-
|
|
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
|
|
183
|
-
if (!
|
|
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
|
|
190
|
-
if (!
|
|
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 (!(
|
|
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
|
}
|