@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/README.md +11 -70
- package/package.json +2 -2
- package/src/format.ts +51 -17
- package/src/persistence.ts +30 -59
- package/src/reminder-cadence.ts +59 -0
- package/src/reminder.ts +24 -0
- package/src/renderer.ts +9 -55
- package/src/schema.ts +6 -16
- package/src/settings.ts +85 -66
- package/src/state.ts +132 -58
- package/src/tool.ts +100 -33
- package/src/types.ts +29 -22
- package/src/visibility.ts +1 -5
- package/src/widget-component.ts +13 -6
- package/src/widget-layout.ts +11 -52
- package/src/widget.ts +11 -18
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: [] };
|
|
@@ -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,
|
|
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
|
-
}
|
|
29
|
+
export function transitionTodoState(state: TodoState, value: unknown): TodoState {
|
|
30
|
+
assertTodoState(state);
|
|
31
|
+
const action = parseTodoAction(value);
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
switch (
|
|
33
|
+
let next: TodoState;
|
|
34
|
+
switch (action.action) {
|
|
27
35
|
case "set":
|
|
28
|
-
|
|
29
|
-
next.phases = parsePhases(input.phases).map(newPhase);
|
|
36
|
+
next = { phases: action.phases.map(newPhase) };
|
|
30
37
|
break;
|
|
31
38
|
case "add":
|
|
32
|
-
|
|
33
|
-
addPhases(next, parsePhases(input.phases));
|
|
39
|
+
next = addPhases(state, action.phases);
|
|
34
40
|
break;
|
|
35
41
|
case "transition":
|
|
36
|
-
|
|
37
|
-
applyTransitions(next, parseTransitions(input.transitions));
|
|
42
|
+
next = applyTransitions(state, action.transitions);
|
|
38
43
|
break;
|
|
39
44
|
case "view":
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
next = action.phase === undefined
|
|
46
|
+
? state
|
|
47
|
+
: { phases: [findPhase(state.phases, action.phase)] };
|
|
42
48
|
break;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
|
|
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 {
|
|
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[]):
|
|
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
|
-
|
|
62
|
-
if (!phase)
|
|
63
|
-
|
|
64
|
-
|
|
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 (
|
|
69
|
-
|
|
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[]):
|
|
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 =
|
|
81
|
-
if (
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 (
|
|
133
|
-
|
|
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
|
|
224
|
+
return `${phase}\0${task}`;
|
|
171
225
|
}
|
|
172
226
|
|
|
173
|
-
function
|
|
174
|
-
|
|
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
|
|
178
|
-
|
|
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
|
|
183
|
-
if (!
|
|
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
|
|
190
|
-
if (!
|
|
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 (!(
|
|
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.");
|