omp-onlyne 0.0.0-stage → 0.1.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.
@@ -0,0 +1,230 @@
1
+ import type { SessionEntry } from "@oh-my-pi/pi-coding-agent";
2
+ import { USER_TODO_EDIT_CUSTOM_TYPE } from "@oh-my-pi/pi-coding-agent/tools/todo";
3
+ import type { TodoState, TodoStateGoal, TodoStateItem, TodoStatePhase, TodoStatus } from "./todo.ts";
4
+
5
+ /**
6
+ * Branch records the structural reader understands. Kept as literals so this
7
+ * module never imports omp-todo-more; the rich adapter checks the installed
8
+ * module against them before it writes anything.
9
+ */
10
+ export const TODO_MORE_ENTRY = "mouriya.omp.todo-more";
11
+ export const TODO_MORE_RESULT_MARK = "todoMore";
12
+
13
+ /** What `session.setTodoPhases` and the native HUD take. */
14
+ export interface HostTodoTask {
15
+ content: string;
16
+ status: TodoStatus;
17
+ blocker?: string;
18
+ details?: string;
19
+ notes?: string[];
20
+ }
21
+
22
+ export interface HostTodoPhase {
23
+ name: string;
24
+ tasks: HostTodoTask[];
25
+ }
26
+
27
+ const TODO_STATUS: Record<TodoStatus, true> = {
28
+ pending: true,
29
+ in_progress: true,
30
+ completed: true,
31
+ abandoned: true,
32
+ blocked: true,
33
+ };
34
+
35
+ export function isTodoStatus(value: unknown): value is TodoStatus {
36
+ return typeof value === "string" && Object.hasOwn(TODO_STATUS, value);
37
+ }
38
+
39
+ interface NativeTask {
40
+ content: string;
41
+ status: TodoStatus;
42
+ blocker?: string;
43
+ details?: string;
44
+ notes?: string[];
45
+ }
46
+
47
+ interface NativePhase {
48
+ name: string;
49
+ tasks: NativeTask[];
50
+ }
51
+
52
+ /**
53
+ * The host's own todo list on a branch (oldest first): the newest native edit,
54
+ * joined onto the nearest earlier rich snapshot by title. Rich snapshots only
55
+ * contribute explanations; statuses, blockers and ordering are native.
56
+ */
57
+ export function readNativeState(entries: readonly SessionEntry[]): TodoState {
58
+ const pending: NativePhase[][] = [];
59
+ for (let index = entries.length - 1; index >= 0; index--) {
60
+ const entry = entries[index];
61
+ if (entry === undefined) continue;
62
+ const rich = richState(entry);
63
+ if (rich !== undefined) return joinNative(rich, pending);
64
+ const native = nativeRecord(entry);
65
+ if (native === undefined) continue;
66
+ pending.push(native);
67
+ // An empty native edit clears the list; older records must not resurrect it.
68
+ if (native.every(phase => phase.tasks.length === 0)) break;
69
+ }
70
+ return joinNative({ goal: null, phases: [] }, pending);
71
+ }
72
+
73
+ /** The titles one native record defines, for pruning Onlyne's origin roster. */
74
+ export function nativeTitles(entry: SessionEntry): Set<string> | undefined {
75
+ const phases = nativeRecord(entry);
76
+ if (phases === undefined) return undefined;
77
+ const titles = new Set<string>();
78
+ for (const phase of phases) {
79
+ for (const task of phase.tasks) titles.add(`${phase.name}\u0000${task.content}`);
80
+ }
81
+ return titles;
82
+ }
83
+
84
+ /** The host projection of a state: what `setTodoPhases` persists. */
85
+ export function nativePhases(state: TodoState): HostTodoPhase[] {
86
+ return state.phases.map(phase => ({
87
+ name: phase.name,
88
+ tasks: phase.items.map(item => ({
89
+ content: item.title,
90
+ status: item.status,
91
+ ...(item.blocker === undefined ? {} : { blocker: item.blocker }),
92
+ ...(item.details === undefined ? {} : { details: item.details }),
93
+ ...(item.notes === undefined ? {} : { notes: item.notes }),
94
+ })),
95
+ }));
96
+ }
97
+
98
+ function joinNative(base: TodoState, pendingNewestFirst: NativePhase[][]): TodoState {
99
+ return pendingNewestFirst.reduceRight((state, phases) => {
100
+ const known = new Map<string, TodoStateItem>();
101
+ for (const phase of state.phases) {
102
+ for (const item of phase.items) known.set(item.title, item);
103
+ }
104
+ const joined: TodoStatePhase[] = phases.map(phase => ({
105
+ name: phase.name,
106
+ items: phase.tasks.map(task => {
107
+ const previous = known.get(task.content);
108
+ const details = task.details ?? previous?.details;
109
+ const notes = task.notes ?? previous?.notes;
110
+ return {
111
+ title: task.content,
112
+ status: task.status,
113
+ ...(previous?.explanation === undefined ? {} : { explanation: previous.explanation }),
114
+ ...(task.blocker === undefined ? {} : { blocker: task.blocker }),
115
+ ...(details === undefined ? {} : { details }),
116
+ ...(notes === undefined ? {} : { notes }),
117
+ };
118
+ }),
119
+ }));
120
+ let count = 0;
121
+ for (const phase of joined) count += phase.items.length;
122
+ return count === 0 ? { goal: null, phases: [] } : { goal: state.goal, phases: joined };
123
+ }, base);
124
+ }
125
+
126
+ function richState(entry: SessionEntry): TodoState | undefined {
127
+ if (entry.type !== "custom" || entry.customType !== TODO_MORE_ENTRY) return undefined;
128
+ const phases = recordField(entry.data, "phases");
129
+ if (!Array.isArray(phases)) return undefined;
130
+ const parsed: TodoStatePhase[] = [];
131
+ for (const phase of phases) {
132
+ const name = stringField(phase, "name");
133
+ const items = recordField(phase, "items");
134
+ if (name === undefined || !Array.isArray(items)) return undefined;
135
+ const parsedItems: TodoStateItem[] = [];
136
+ for (const item of items) {
137
+ const title = stringField(item, "title");
138
+ const status = recordField(item, "status");
139
+ if (title === undefined || !isTodoStatus(status)) return undefined;
140
+ const explanation = stringField(item, "explanation");
141
+ const blocker = stringField(item, "blocker");
142
+ parsedItems.push({
143
+ title,
144
+ status,
145
+ ...(explanation === undefined ? {} : { explanation }),
146
+ ...(blocker === undefined ? {} : { blocker }),
147
+ });
148
+ }
149
+ parsed.push({ name, items: parsedItems });
150
+ }
151
+ const goal = richGoal(recordField(entry.data, "goal"));
152
+ if (goal === undefined) return undefined;
153
+ let count = 0;
154
+ for (const phase of parsed) count += phase.items.length;
155
+ return count === 0 ? { goal: null, phases: [] } : { goal, phases: parsed };
156
+ }
157
+
158
+ /** `null` means the snapshot carries no goal; `undefined` means it is malformed. */
159
+ function richGoal(value: unknown): TodoStateGoal | null | undefined {
160
+ if (value === null || value === undefined) return null;
161
+ const objective = stringField(value, "objective");
162
+ const measure = stringField(value, "measure");
163
+ const doneWhen = stringField(value, "doneWhen");
164
+ if (objective === undefined || measure === undefined || doneWhen === undefined) return undefined;
165
+ if (objective.trim().length === 0 || measure.trim().length === 0 || doneWhen.trim().length === 0) return undefined;
166
+ return { objective, measure, doneWhen };
167
+ }
168
+
169
+ function nativeRecord(entry: SessionEntry): NativePhase[] | undefined {
170
+ if (entry.type === "custom") {
171
+ if (entry.customType !== USER_TODO_EDIT_CUSTOM_TYPE) return undefined;
172
+ return parseNativePhases(recordField(entry.data, "phases"));
173
+ }
174
+ if (entry.type !== "message") return undefined;
175
+ const message = entry.message as unknown as {
176
+ role?: unknown;
177
+ toolName?: unknown;
178
+ isError?: unknown;
179
+ details?: { op?: unknown; phases?: unknown; [TODO_MORE_RESULT_MARK]?: unknown };
180
+ };
181
+ if (message.role !== "toolResult" || message.toolName !== "todo" || message.isError === true) return undefined;
182
+ if (message.details?.op === "view") return undefined;
183
+ // todo-more's own results follow the entry that records them.
184
+ if (message.details?.[TODO_MORE_RESULT_MARK] === true) return undefined;
185
+ return parseNativePhases(message.details?.phases);
186
+ }
187
+
188
+ function parseNativePhases(value: unknown): NativePhase[] | undefined {
189
+ if (!Array.isArray(value)) return undefined;
190
+ const phases: NativePhase[] = [];
191
+ for (const phase of value) {
192
+ const name = stringField(phase, "name");
193
+ const tasks = recordField(phase, "tasks");
194
+ if (name === undefined || !Array.isArray(tasks)) return undefined;
195
+ const parsed: NativeTask[] = [];
196
+ for (const task of tasks) {
197
+ const content = stringField(task, "content");
198
+ const status = recordField(task, "status");
199
+ if (content === undefined || !isTodoStatus(status)) return undefined;
200
+ const blocker = stringField(task, "blocker");
201
+ const details = stringField(task, "details");
202
+ const notes = stringListField(task, "notes");
203
+ parsed.push({
204
+ content,
205
+ status,
206
+ ...(blocker === undefined ? {} : { blocker }),
207
+ ...(details === undefined ? {} : { details }),
208
+ ...(notes === undefined ? {} : { notes }),
209
+ });
210
+ }
211
+ phases.push({ name, tasks: parsed });
212
+ }
213
+ return phases;
214
+ }
215
+
216
+ function stringListField(value: unknown, field: string): string[] | undefined {
217
+ const list = recordField(value, field);
218
+ if (!Array.isArray(list)) return undefined;
219
+ return list.every(entry => typeof entry === "string") ? (list as string[]) : undefined;
220
+ }
221
+
222
+ export function recordField(value: unknown, field: string): unknown {
223
+ if (value === null || typeof value !== "object") return undefined;
224
+ return (value as Record<string, unknown>)[field];
225
+ }
226
+
227
+ export function stringField(value: unknown, field: string): string | undefined {
228
+ const text = recordField(value, field);
229
+ return typeof text === "string" ? text : undefined;
230
+ }