omp-onlyne 0.0.0-stage → 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/AGENTS.sample.md +27 -0
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/index.ts +55 -0
- package/package.json +62 -5
- package/src/acquire.ts +182 -0
- package/src/binding.ts +132 -0
- package/src/bridge-text.ts +145 -0
- package/src/config.ts +332 -0
- package/src/decider-text.ts +438 -0
- package/src/decider.ts +2233 -0
- package/src/drafts.ts +57 -0
- package/src/handshake.ts +291 -0
- package/src/host-text.ts +92 -0
- package/src/messages.ts +427 -0
- package/src/node.ts +139 -0
- package/src/resources.ts +36 -0
- package/src/runtime.ts +676 -0
- package/src/store.ts +189 -0
- package/src/tern.ts +66 -0
- package/src/todo-native.ts +230 -0
- package/src/todo.ts +896 -0
- package/src/tool-text.ts +167 -0
- package/src/tool.ts +342 -0
- package/src/types.ts +150 -0
- package/src/ui/bootstrap-text.ts +125 -0
- package/src/ui/bootstrap.ts +363 -0
- package/src/ui/command-text.ts +38 -0
- package/src/ui/format.ts +56 -0
- package/src/ui/index.ts +156 -0
- package/src/ui/mail-message.ts +263 -0
- package/src/ui/status.ts +213 -0
- package/src/ui/viewer-text.ts +309 -0
- package/src/ui/viewer.ts +1403 -0
- package/src/urls-text.ts +304 -0
- package/src/urls.ts +1488 -0
package/src/todo.ts
ADDED
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import { OnlyneError, type Entry, type MessageRef, type ThreadRef } from "@onlyne/lib";
|
|
2
|
+
import type { AgentSession, ExtensionAPI, ExtensionContext, SessionEntry } from "@oh-my-pi/pi-coding-agent";
|
|
3
|
+
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
|
4
|
+
import { USER_TODO_EDIT_CUSTOM_TYPE } from "@oh-my-pi/pi-coding-agent/tools/todo";
|
|
5
|
+
import {
|
|
6
|
+
TODO_MORE_ENTRY,
|
|
7
|
+
TODO_MORE_RESULT_MARK,
|
|
8
|
+
nativePhases,
|
|
9
|
+
nativeTitles,
|
|
10
|
+
readNativeState,
|
|
11
|
+
recordField,
|
|
12
|
+
stringField,
|
|
13
|
+
type HostTodoPhase,
|
|
14
|
+
} from "./todo-native.ts";
|
|
15
|
+
import { bridgeText as t } from "./bridge-text.ts";
|
|
16
|
+
|
|
17
|
+
export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned" | "blocked";
|
|
18
|
+
|
|
19
|
+
/** What the work is, how its result is measured, and how far it must go to count as done. */
|
|
20
|
+
export interface TodoStateGoal {
|
|
21
|
+
objective: string;
|
|
22
|
+
measure: string;
|
|
23
|
+
doneWhen: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TodoStateItem {
|
|
27
|
+
title: string;
|
|
28
|
+
status: TodoStatus;
|
|
29
|
+
/** Present when the branch recorded one; the native host list has no such field. */
|
|
30
|
+
explanation?: string;
|
|
31
|
+
blocker?: string;
|
|
32
|
+
/** Native-only item fields; carried through so a rewrite does not drop them. */
|
|
33
|
+
details?: string;
|
|
34
|
+
notes?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TodoStatePhase {
|
|
38
|
+
name: string;
|
|
39
|
+
items: TodoStateItem[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the planner sees, whichever store backs the list. `goal` is null for a
|
|
44
|
+
* native OMP list: `user_todo_edit` carries phases only.
|
|
45
|
+
*/
|
|
46
|
+
export interface TodoState {
|
|
47
|
+
goal: TodoStateGoal | null;
|
|
48
|
+
phases: TodoStatePhase[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One inbound message's effect on the todo list, as decided by SystemOne.
|
|
53
|
+
* `append` carries the new item; `associate` names an existing item; `none`
|
|
54
|
+
* records that the message was considered and needs no todo change.
|
|
55
|
+
*/
|
|
56
|
+
export interface TodoDecision {
|
|
57
|
+
action: "none" | "associate" | "append";
|
|
58
|
+
/** append: target phase, default "Tasks"; associate: optional disambiguator. */
|
|
59
|
+
phase?: string;
|
|
60
|
+
/** append: the new item's title; associate: the existing item's title. */
|
|
61
|
+
title?: string;
|
|
62
|
+
/** append: the explanation body after the fixed Source line. */
|
|
63
|
+
explanation?: string;
|
|
64
|
+
/** append into an empty list: the goal the list needs. */
|
|
65
|
+
goal?: { objective: string; measure: string; doneWhen: string };
|
|
66
|
+
/** Model-owned estimate; absent leaves the item unestimated instead of inventing one. */
|
|
67
|
+
minutes?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The decision surface `applyMessageTodo` consumes. JudgeDecider wiring supplies it. */
|
|
71
|
+
export interface TodoPlanner {
|
|
72
|
+
decideTodo(entry: Entry, current: TodoState, context: string): Promise<TodoDecision>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A durable item identity. Onlyne records it in branch metadata; the host todo
|
|
77
|
+
* list keeps titles only, so this roster is the only thing that can tell a
|
|
78
|
+
* surviving item from a deleted-then-recreated title.
|
|
79
|
+
*/
|
|
80
|
+
export interface OnlyneTodoOrigin {
|
|
81
|
+
item_id: string;
|
|
82
|
+
phase: string;
|
|
83
|
+
title: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface OnlyneTodoLink {
|
|
87
|
+
/** Stable association identity; reused by replays and retries. */
|
|
88
|
+
link_id: string;
|
|
89
|
+
message_ref: MessageRef;
|
|
90
|
+
thread_ref: ThreadRef;
|
|
91
|
+
context: string;
|
|
92
|
+
seq: string;
|
|
93
|
+
item_id: string;
|
|
94
|
+
/** Display/back-reference snapshot, never the association key. */
|
|
95
|
+
phase: string;
|
|
96
|
+
title: string;
|
|
97
|
+
at: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface OnlyneTodoDecisionRecord {
|
|
101
|
+
/** context + message identity; the idempotence key. */
|
|
102
|
+
key: string;
|
|
103
|
+
context: string;
|
|
104
|
+
message_ref: MessageRef;
|
|
105
|
+
thread_ref: ThreadRef;
|
|
106
|
+
seq: string;
|
|
107
|
+
action: TodoDecision["action"];
|
|
108
|
+
link_id?: string;
|
|
109
|
+
/** Native fallback: the item explanation the host list cannot carry. */
|
|
110
|
+
explanation?: string;
|
|
111
|
+
at: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface OnlyneTodoMetadata {
|
|
115
|
+
version: 1;
|
|
116
|
+
commit_id: string;
|
|
117
|
+
/** The decision this commit records, when a message caused it. */
|
|
118
|
+
decision?: OnlyneTodoDecisionRecord;
|
|
119
|
+
/** Full origin roster after this commit. */
|
|
120
|
+
origins: OnlyneTodoOrigin[];
|
|
121
|
+
/** Associations established by this commit. Replay accumulates them. */
|
|
122
|
+
links: OnlyneTodoLink[];
|
|
123
|
+
/** Native fallback only: goal fields the host list cannot carry. */
|
|
124
|
+
goal_intent?: TodoStateGoal;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The branch-derived view the viewer reads; no SQLite index is involved. */
|
|
128
|
+
export interface OnlyneTodoBranch {
|
|
129
|
+
state: TodoState;
|
|
130
|
+
origins: readonly OnlyneTodoOrigin[];
|
|
131
|
+
links: readonly OnlyneTodoLink[];
|
|
132
|
+
/** Links whose item origin still exists under the current state. */
|
|
133
|
+
activeLinks: readonly OnlyneTodoLink[];
|
|
134
|
+
decisions: ReadonlyMap<string, OnlyneTodoDecisionRecord>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Native fallback's branch record: the decision and its associations, with no
|
|
139
|
+
* rich snapshot. It is the idempotence marker that keeps a message from being
|
|
140
|
+
* judged twice when omp-todo-more is not installed.
|
|
141
|
+
*/
|
|
142
|
+
export const ONLYNE_TODO_DECISION = "onlyne.todo-decision";
|
|
143
|
+
|
|
144
|
+
const TODO_MORE_PACKAGE = "omp-todo-more";
|
|
145
|
+
const TODO_MORE_STATE_MODULE = `${TODO_MORE_PACKAGE}/src/state.ts`;
|
|
146
|
+
|
|
147
|
+
type RichGoalState = { readonly kind: "set"; readonly goal: TodoStateGoal } | { readonly kind: "missing" };
|
|
148
|
+
type RichExplanation = { readonly kind: "explained"; readonly text: string } | { readonly kind: "missing" };
|
|
149
|
+
type RichTime =
|
|
150
|
+
| { readonly kind: "missing" }
|
|
151
|
+
| {
|
|
152
|
+
readonly kind: "estimated";
|
|
153
|
+
readonly budgetId: string;
|
|
154
|
+
readonly minutes: number;
|
|
155
|
+
readonly elapsedMs: number;
|
|
156
|
+
readonly runningSince: number | null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
interface RichItem {
|
|
160
|
+
readonly title: string;
|
|
161
|
+
readonly explanation: RichExplanation;
|
|
162
|
+
readonly status: TodoStatus;
|
|
163
|
+
readonly blocker?: string;
|
|
164
|
+
readonly time: RichTime;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface RichPhase {
|
|
168
|
+
readonly name: string;
|
|
169
|
+
readonly items: readonly RichItem[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type RichState =
|
|
173
|
+
| { readonly kind: "empty" }
|
|
174
|
+
| { readonly kind: "list"; readonly goal: RichGoalState; readonly phases: readonly RichPhase[] };
|
|
175
|
+
|
|
176
|
+
interface RichSnapshot {
|
|
177
|
+
goal: TodoStateGoal | null;
|
|
178
|
+
phases: Array<{
|
|
179
|
+
name: string;
|
|
180
|
+
items: Array<{
|
|
181
|
+
title: string;
|
|
182
|
+
explanation: string | null;
|
|
183
|
+
status: TodoStatus;
|
|
184
|
+
blocker?: string;
|
|
185
|
+
time: RichTime;
|
|
186
|
+
}>;
|
|
187
|
+
}>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The subset of `omp-todo-more/src/state.ts` the rich adapter drives. */
|
|
191
|
+
interface TodoMoreStateModule {
|
|
192
|
+
readonly TODO_MORE_ENTRY_TYPE: string;
|
|
193
|
+
readonly TODO_MORE_RESULT_MARK: string;
|
|
194
|
+
readTodoMore(entries: readonly SessionEntry[]): RichState;
|
|
195
|
+
hostPhases(state: RichState): HostTodoPhase[];
|
|
196
|
+
toSnapshot(state: RichState): RichSnapshot;
|
|
197
|
+
stateOf(goal: RichGoalState, phases: readonly RichPhase[]): RichState;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface TodoBackend {
|
|
201
|
+
read(entries: readonly SessionEntry[]): TodoState;
|
|
202
|
+
/** Runs inside `appendEntriesAtomically`; must not await. */
|
|
203
|
+
commit(session: AgentSession, plan: TodoDecision, entry: Entry, context: string): void;
|
|
204
|
+
project(session: AgentSession, entries: readonly SessionEntry[]): void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface PreparedRich {
|
|
208
|
+
next: RichState;
|
|
209
|
+
origins: OnlyneTodoOrigin[];
|
|
210
|
+
link?: OnlyneTodoLink;
|
|
211
|
+
decision: OnlyneTodoDecisionRecord;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface PreparedNative {
|
|
215
|
+
/** Undefined for `none` and `associate`: those leave the host list untouched. */
|
|
216
|
+
next?: TodoState;
|
|
217
|
+
origins: OnlyneTodoOrigin[];
|
|
218
|
+
link?: OnlyneTodoLink;
|
|
219
|
+
decision: OnlyneTodoDecisionRecord;
|
|
220
|
+
goalIntent?: TodoStateGoal;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let backendPromise: Promise<TodoBackend> | undefined;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Record SystemOne's todo decision for one inbound message.
|
|
227
|
+
*
|
|
228
|
+
* With omp-todo-more installed, the rich snapshot, the source association and
|
|
229
|
+
* the decision are one atomic branch commit; the native projection follows so
|
|
230
|
+
* OMP restores its HUD on resume. Without it, the decision marker and the
|
|
231
|
+
* `user_todo_edit` projection are that same atomic commit.
|
|
232
|
+
*/
|
|
233
|
+
export async function applyMessageTodo(entry: Entry, context: string, planner: TodoPlanner): Promise<void> {
|
|
234
|
+
const scope = context.replace(/\s+/g, " ").trim();
|
|
235
|
+
if (scope.length === 0) throw new OnlyneError("invalid_request", t("todoContextRequired"));
|
|
236
|
+
|
|
237
|
+
const session = liveSession();
|
|
238
|
+
const backend = await backendFor();
|
|
239
|
+
const key = decisionKey(scope, entry);
|
|
240
|
+
const replayed = readOnlyneTodo(session.sessionManager.getBranch());
|
|
241
|
+
if (replayed.decisions.has(key)) {
|
|
242
|
+
backend.project(session, session.sessionManager.getBranch());
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const plan = await planner.decideTodo(entry, backend.read(session.sessionManager.getBranch()), scope);
|
|
247
|
+
if (plan.action !== "none" && plan.action !== "associate" && plan.action !== "append") {
|
|
248
|
+
throw new OnlyneError("decision_unavailable", t("todoUnknownAction", { action: String(plan.action) }));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await session.sessionManager.appendEntriesAtomically(() => {
|
|
252
|
+
backend.commit(session, plan, entry, scope);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Re-read after the commit: a later queued commit may already have landed.
|
|
256
|
+
backend.project(session, session.sessionManager.getBranch());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Rebuild the host todo projection from the branch after startup, rewind and
|
|
261
|
+
* branch switches. The branch is authoritative; nothing is cached. The
|
|
262
|
+
* structural reader covers both record formats, so recovery never waits for
|
|
263
|
+
* the optional module to load.
|
|
264
|
+
*/
|
|
265
|
+
export function registerTodoRecovery(pi: ExtensionAPI): void {
|
|
266
|
+
const project = (ctx: ExtensionContext): void => {
|
|
267
|
+
const session = AgentRegistry.global().get(ctx.agent.id)?.session;
|
|
268
|
+
if (session === null || session === undefined) return;
|
|
269
|
+
session.setTodoPhases(nativePhases(readNativeState(session.sessionManager.getBranch())));
|
|
270
|
+
};
|
|
271
|
+
pi.on("session_start", (_event, ctx) => {
|
|
272
|
+
project(ctx);
|
|
273
|
+
});
|
|
274
|
+
pi.on("session_branch", (_event, ctx) => {
|
|
275
|
+
project(ctx);
|
|
276
|
+
});
|
|
277
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
278
|
+
project(ctx);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Read the todo state and every Onlyne association recorded on an active branch. */
|
|
283
|
+
export function readOnlyneTodo(entries: readonly SessionEntry[]): OnlyneTodoBranch {
|
|
284
|
+
let origins = new Map<string, OnlyneTodoOrigin>();
|
|
285
|
+
const links = new Map<string, OnlyneTodoLink>();
|
|
286
|
+
const decisions = new Map<string, OnlyneTodoDecisionRecord>();
|
|
287
|
+
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (entry.type === "custom" && entry.customType === TODO_MORE_ENTRY) {
|
|
290
|
+
const titles = snapshotTitles(entry.data);
|
|
291
|
+
if (titles === undefined) continue;
|
|
292
|
+
const metadata = onlyneMetadata(entry.data, entry.id);
|
|
293
|
+
if (metadata !== undefined) {
|
|
294
|
+
origins = new Map(
|
|
295
|
+
metadata.origins
|
|
296
|
+
.filter(origin => titles.has(`${origin.phase}\u0000${origin.title}`))
|
|
297
|
+
.map(origin => [origin.item_id, origin]),
|
|
298
|
+
);
|
|
299
|
+
for (const link of metadata.links) links.set(link.link_id, link);
|
|
300
|
+
if (metadata.decision !== undefined) decisions.set(metadata.decision.key, metadata.decision);
|
|
301
|
+
} else {
|
|
302
|
+
// A snapshot written by todo-more's own tool: titles keep their
|
|
303
|
+
// origins only while they remain present. A title absent here is
|
|
304
|
+
// retired, so a later same-title item starts a new identity.
|
|
305
|
+
origins = pruneOrigins(origins, titles);
|
|
306
|
+
}
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (entry.type === "custom" && entry.customType === ONLYNE_TODO_DECISION) {
|
|
310
|
+
const metadata = markerMetadata(entry.data, entry.id);
|
|
311
|
+
origins = new Map(metadata.origins.map(origin => [origin.item_id, origin]));
|
|
312
|
+
for (const link of metadata.links) links.set(link.link_id, link);
|
|
313
|
+
if (metadata.decision !== undefined) decisions.set(metadata.decision.key, metadata.decision);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const native = nativeTitles(entry);
|
|
317
|
+
if (native !== undefined) origins = pruneOrigins(origins, native);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const state = readNativeState(entries);
|
|
321
|
+
const present = stateTitles(state);
|
|
322
|
+
const finalOrigins = [...origins.values()].filter(origin => present.has(`${origin.phase}\u0000${origin.title}`));
|
|
323
|
+
const itemIds = new Set(finalOrigins.map(origin => origin.item_id));
|
|
324
|
+
return {
|
|
325
|
+
state,
|
|
326
|
+
origins: finalOrigins,
|
|
327
|
+
links: [...links.values()],
|
|
328
|
+
activeLinks: [...links.values()].filter(link => itemIds.has(link.item_id)),
|
|
329
|
+
decisions,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function backendFor(): Promise<TodoBackend> {
|
|
334
|
+
backendPromise ??= detectBackend();
|
|
335
|
+
return backendPromise;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function detectBackend(): Promise<TodoBackend> {
|
|
339
|
+
const module = await loadTodoMoreState();
|
|
340
|
+
return module === undefined ? nativeBackend : todoMoreBackend(module);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function loadTodoMoreState(): Promise<TodoMoreStateModule | undefined> {
|
|
344
|
+
const specifier = TODO_MORE_STATE_MODULE;
|
|
345
|
+
try {
|
|
346
|
+
// A variable specifier keeps TypeScript and the bundler from resolving the
|
|
347
|
+
// optional peer at build time; absence is decided at runtime.
|
|
348
|
+
return asTodoMoreStateModule(await import(specifier));
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (isMissingTodoMore(error)) return undefined;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isMissingTodoMore(error: unknown): boolean {
|
|
356
|
+
if (error === null || typeof error !== "object") return false;
|
|
357
|
+
const { code, message } = error as { code?: unknown; message?: unknown };
|
|
358
|
+
if (code !== "MODULE_NOT_FOUND" && code !== "ERR_MODULE_NOT_FOUND") return false;
|
|
359
|
+
if (typeof message !== "string") return false;
|
|
360
|
+
// Absent means the package itself does not resolve. A missing file inside an
|
|
361
|
+
// installed package is a broken install and must surface, not fall back.
|
|
362
|
+
return (
|
|
363
|
+
message.includes(`Cannot find package '${TODO_MORE_PACKAGE}'`) ||
|
|
364
|
+
message.includes(`Cannot find package "${TODO_MORE_PACKAGE}"`) ||
|
|
365
|
+
message.includes(`Cannot find module '${TODO_MORE_PACKAGE}'`) ||
|
|
366
|
+
message.includes(`Cannot find module "${TODO_MORE_PACKAGE}"`)
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function asTodoMoreStateModule(value: unknown): TodoMoreStateModule {
|
|
371
|
+
const module = value as Partial<TodoMoreStateModule> | null;
|
|
372
|
+
if (
|
|
373
|
+
module === null ||
|
|
374
|
+
typeof module !== "object" ||
|
|
375
|
+
typeof module.readTodoMore !== "function" ||
|
|
376
|
+
typeof module.hostPhases !== "function" ||
|
|
377
|
+
typeof module.toSnapshot !== "function" ||
|
|
378
|
+
typeof module.stateOf !== "function" ||
|
|
379
|
+
typeof module.TODO_MORE_ENTRY_TYPE !== "string" ||
|
|
380
|
+
typeof module.TODO_MORE_RESULT_MARK !== "string"
|
|
381
|
+
) {
|
|
382
|
+
throw new OnlyneError("unavailable", t("todoAdapterApi"));
|
|
383
|
+
}
|
|
384
|
+
if (module.TODO_MORE_ENTRY_TYPE !== TODO_MORE_ENTRY || module.TODO_MORE_RESULT_MARK !== TODO_MORE_RESULT_MARK) {
|
|
385
|
+
throw new OnlyneError("unavailable", t("todoRecordTypes"));
|
|
386
|
+
}
|
|
387
|
+
return module as TodoMoreStateModule;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function todoMoreBackend(module: TodoMoreStateModule): TodoBackend {
|
|
391
|
+
return {
|
|
392
|
+
read: entries => stateFromRich(module.readTodoMore(entries)),
|
|
393
|
+
commit(session, plan, entry, context) {
|
|
394
|
+
const branch = session.sessionManager.getBranch();
|
|
395
|
+
const latest = readOnlyneTodo(branch);
|
|
396
|
+
if (latest.decisions.has(decisionKey(context, entry))) return;
|
|
397
|
+
const prepared = prepareRichCommit(module, module.readTodoMore(branch), latest.origins, plan, entry, context);
|
|
398
|
+
const metadata: OnlyneTodoMetadata = {
|
|
399
|
+
version: 1,
|
|
400
|
+
commit_id: crypto.randomUUID(),
|
|
401
|
+
decision: prepared.decision,
|
|
402
|
+
origins: prepared.origins,
|
|
403
|
+
links: prepared.link === undefined ? [] : [prepared.link],
|
|
404
|
+
};
|
|
405
|
+
// Rich snapshot first, host projection second: todo-more joins the native
|
|
406
|
+
// record onto the nearest earlier rich snapshot, and OMP restores the HUD
|
|
407
|
+
// from the native record on resume.
|
|
408
|
+
session.sessionManager.appendCustomEntry(TODO_MORE_ENTRY, {
|
|
409
|
+
...module.toSnapshot(prepared.next),
|
|
410
|
+
onlyne: metadata,
|
|
411
|
+
});
|
|
412
|
+
session.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, {
|
|
413
|
+
phases: module.hostPhases(prepared.next),
|
|
414
|
+
});
|
|
415
|
+
},
|
|
416
|
+
project(session, entries) {
|
|
417
|
+
session.setTodoPhases(module.hostPhases(module.readTodoMore(entries)));
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const nativeBackend: TodoBackend = {
|
|
423
|
+
read: readNativeState,
|
|
424
|
+
commit(session, plan, entry, context) {
|
|
425
|
+
const branch = session.sessionManager.getBranch();
|
|
426
|
+
const latest = readOnlyneTodo(branch);
|
|
427
|
+
if (latest.decisions.has(decisionKey(context, entry))) return;
|
|
428
|
+
const prepared = prepareNativeCommit(latest.state, latest.origins, plan, entry, context);
|
|
429
|
+
const metadata: OnlyneTodoMetadata = {
|
|
430
|
+
version: 1,
|
|
431
|
+
commit_id: crypto.randomUUID(),
|
|
432
|
+
decision: prepared.decision,
|
|
433
|
+
origins: prepared.origins,
|
|
434
|
+
links: prepared.link === undefined ? [] : [prepared.link],
|
|
435
|
+
...(prepared.goalIntent === undefined ? {} : { goal_intent: prepared.goalIntent }),
|
|
436
|
+
};
|
|
437
|
+
session.sessionManager.appendCustomEntry(ONLYNE_TODO_DECISION, metadata);
|
|
438
|
+
if (prepared.next !== undefined) {
|
|
439
|
+
session.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, {
|
|
440
|
+
phases: nativePhases(prepared.next),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
project(session, entries) {
|
|
445
|
+
session.setTodoPhases(nativePhases(readNativeState(entries)));
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
function stateFromRich(state: RichState): TodoState {
|
|
450
|
+
if (state.kind === "empty") return { goal: null, phases: [] };
|
|
451
|
+
return {
|
|
452
|
+
goal: state.goal.kind === "set" ? state.goal.goal : null,
|
|
453
|
+
phases: state.phases.map(phase => ({
|
|
454
|
+
name: phase.name,
|
|
455
|
+
items: phase.items.map(item => ({
|
|
456
|
+
title: item.title,
|
|
457
|
+
status: item.status,
|
|
458
|
+
...(item.explanation.kind === "explained" ? { explanation: item.explanation.text } : {}),
|
|
459
|
+
...(item.blocker === undefined ? {} : { blocker: item.blocker }),
|
|
460
|
+
})),
|
|
461
|
+
})),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function prepareRichCommit(
|
|
466
|
+
module: TodoMoreStateModule,
|
|
467
|
+
state: RichState,
|
|
468
|
+
latestOrigins: readonly OnlyneTodoOrigin[],
|
|
469
|
+
plan: TodoDecision,
|
|
470
|
+
entry: Entry,
|
|
471
|
+
context: string,
|
|
472
|
+
): PreparedRich {
|
|
473
|
+
const at = new Date().toISOString();
|
|
474
|
+
const decision: OnlyneTodoDecisionRecord = {
|
|
475
|
+
key: decisionKey(context, entry),
|
|
476
|
+
context,
|
|
477
|
+
message_ref: { sender: entry.message.sender, id: entry.message.id },
|
|
478
|
+
thread_ref: entry.thread_ref,
|
|
479
|
+
seq: entry.seq,
|
|
480
|
+
action: plan.action,
|
|
481
|
+
at,
|
|
482
|
+
};
|
|
483
|
+
const origins = new Map(latestOrigins.map(origin => [origin.item_id, origin]));
|
|
484
|
+
const present = richTitles(state);
|
|
485
|
+
for (const [id, origin] of origins) {
|
|
486
|
+
if (!present.has(`${origin.phase}\u0000${origin.title}`)) origins.delete(id);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
switch (plan.action) {
|
|
490
|
+
case "none":
|
|
491
|
+
return { next: state, origins: [...origins.values()], decision };
|
|
492
|
+
case "associate": {
|
|
493
|
+
const title = requiredText(plan.title, "associate", "title");
|
|
494
|
+
const phase = plan.phase?.trim() || undefined;
|
|
495
|
+
if (state.kind === "empty") {
|
|
496
|
+
throw new OnlyneError("decision_unavailable", t("todoEmptyAssociate"));
|
|
497
|
+
}
|
|
498
|
+
const matches = state.phases
|
|
499
|
+
.filter(candidate => phase === undefined || candidate.name === phase)
|
|
500
|
+
.filter(candidate => candidate.items.some(item => item.title === title))
|
|
501
|
+
.map(candidate => candidate.name);
|
|
502
|
+
if (matches.length === 0) {
|
|
503
|
+
throw new OnlyneError("decision_unavailable", t("todoNoSuchItem", { title }));
|
|
504
|
+
}
|
|
505
|
+
if (matches.length > 1) {
|
|
506
|
+
throw new OnlyneError("ambiguous_target", t("todoAmbiguousTitle", { title }));
|
|
507
|
+
}
|
|
508
|
+
const phaseName = matches[0]!;
|
|
509
|
+
const origin = [...origins.values()].find(
|
|
510
|
+
candidate => candidate.phase === phaseName && candidate.title === title,
|
|
511
|
+
) ?? { item_id: crypto.randomUUID(), phase: phaseName, title };
|
|
512
|
+
origins.set(origin.item_id, origin);
|
|
513
|
+
const link = makeLink(entry, context, origin, at);
|
|
514
|
+
decision.link_id = link.link_id;
|
|
515
|
+
return { next: state, origins: [...origins.values()], link, decision };
|
|
516
|
+
}
|
|
517
|
+
case "append": {
|
|
518
|
+
const title = requiredText(plan.title, "append", "title");
|
|
519
|
+
const existing = state.kind === "list"
|
|
520
|
+
? state.phases.flatMap(candidate => candidate.items).some(item => item.title === title)
|
|
521
|
+
: false;
|
|
522
|
+
if (existing) {
|
|
523
|
+
throw new OnlyneError("id_conflict", t("todoDuplicateTitle", { title }));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const phaseName = plan.phase?.trim() ?? t("todoDefaultPhase");
|
|
527
|
+
if (phaseName.length === 0) throw new OnlyneError("invalid_request", t("todoPhaseRequired"));
|
|
528
|
+
const explanation = appendExplanation(plan, entry, context);
|
|
529
|
+
const item: RichItem = {
|
|
530
|
+
title,
|
|
531
|
+
explanation,
|
|
532
|
+
status: "pending",
|
|
533
|
+
time: itemTime(plan.minutes),
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
let goal: RichGoalState;
|
|
537
|
+
let phases: readonly RichPhase[];
|
|
538
|
+
if (state.kind === "empty") {
|
|
539
|
+
goal = { kind: "set", goal: requiredGoal(plan.goal) };
|
|
540
|
+
phases = [{ name: phaseName, items: [item] }];
|
|
541
|
+
} else {
|
|
542
|
+
goal =
|
|
543
|
+
state.goal.kind === "missing" && plan.goal !== undefined
|
|
544
|
+
? { kind: "set", goal: requiredGoal(plan.goal) }
|
|
545
|
+
: state.goal;
|
|
546
|
+
const index = state.phases.findIndex(candidate => candidate.name === phaseName);
|
|
547
|
+
phases =
|
|
548
|
+
index < 0
|
|
549
|
+
? [...state.phases, { name: phaseName, items: [item] }]
|
|
550
|
+
: state.phases.map((candidate, position) =>
|
|
551
|
+
position === index ? { name: candidate.name, items: [...candidate.items, item] } : candidate,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const next = module.stateOf(goal, phases);
|
|
556
|
+
const origin: OnlyneTodoOrigin = { item_id: crypto.randomUUID(), phase: phaseName, title };
|
|
557
|
+
origins.set(origin.item_id, origin);
|
|
558
|
+
const link = makeLink(entry, context, origin, at);
|
|
559
|
+
decision.link_id = link.link_id;
|
|
560
|
+
return { next, origins: [...origins.values()], link, decision };
|
|
561
|
+
}
|
|
562
|
+
default: {
|
|
563
|
+
const unreachable: never = plan.action;
|
|
564
|
+
throw new Error(t("todoUnexpectedAction", { action: String(unreachable) }));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function prepareNativeCommit(
|
|
570
|
+
state: TodoState,
|
|
571
|
+
latestOrigins: readonly OnlyneTodoOrigin[],
|
|
572
|
+
plan: TodoDecision,
|
|
573
|
+
entry: Entry,
|
|
574
|
+
context: string,
|
|
575
|
+
): PreparedNative {
|
|
576
|
+
const at = new Date().toISOString();
|
|
577
|
+
const decision: OnlyneTodoDecisionRecord = {
|
|
578
|
+
key: decisionKey(context, entry),
|
|
579
|
+
context,
|
|
580
|
+
message_ref: { sender: entry.message.sender, id: entry.message.id },
|
|
581
|
+
thread_ref: entry.thread_ref,
|
|
582
|
+
seq: entry.seq,
|
|
583
|
+
action: plan.action,
|
|
584
|
+
at,
|
|
585
|
+
};
|
|
586
|
+
const origins = new Map(latestOrigins.map(origin => [origin.item_id, origin]));
|
|
587
|
+
const present = stateTitles(state);
|
|
588
|
+
for (const [id, origin] of origins) {
|
|
589
|
+
if (!present.has(`${origin.phase}\u0000${origin.title}`)) origins.delete(id);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
switch (plan.action) {
|
|
593
|
+
case "none":
|
|
594
|
+
return { origins: [...origins.values()], decision };
|
|
595
|
+
case "associate": {
|
|
596
|
+
const title = requiredText(plan.title, "associate", "title");
|
|
597
|
+
const phase = plan.phase?.trim() || undefined;
|
|
598
|
+
const matches = state.phases
|
|
599
|
+
.filter(candidate => phase === undefined || candidate.name === phase)
|
|
600
|
+
.filter(candidate => candidate.items.some(item => item.title === title))
|
|
601
|
+
.map(candidate => candidate.name);
|
|
602
|
+
if (matches.length === 0) {
|
|
603
|
+
throw new OnlyneError("decision_unavailable", t("todoNoSuchItem", { title }));
|
|
604
|
+
}
|
|
605
|
+
if (matches.length > 1) {
|
|
606
|
+
throw new OnlyneError("ambiguous_target", t("todoAmbiguousTitle", { title }));
|
|
607
|
+
}
|
|
608
|
+
const phaseName = matches[0]!;
|
|
609
|
+
const origin = [...origins.values()].find(
|
|
610
|
+
candidate => candidate.phase === phaseName && candidate.title === title,
|
|
611
|
+
) ?? { item_id: crypto.randomUUID(), phase: phaseName, title };
|
|
612
|
+
origins.set(origin.item_id, origin);
|
|
613
|
+
const link = makeLink(entry, context, origin, at);
|
|
614
|
+
decision.link_id = link.link_id;
|
|
615
|
+
return { origins: [...origins.values()], link, decision };
|
|
616
|
+
}
|
|
617
|
+
case "append": {
|
|
618
|
+
const title = requiredText(plan.title, "append", "title");
|
|
619
|
+
const existing = state.phases.some(candidate => candidate.items.some(item => item.title === title));
|
|
620
|
+
if (existing) {
|
|
621
|
+
throw new OnlyneError("id_conflict", t("todoDuplicateTitle", { title }));
|
|
622
|
+
}
|
|
623
|
+
const phaseName = plan.phase?.trim() ?? t("todoDefaultPhase");
|
|
624
|
+
if (phaseName.length === 0) throw new OnlyneError("invalid_request", t("todoPhaseRequired"));
|
|
625
|
+
const item: TodoStateItem = { title, status: "pending" };
|
|
626
|
+
const index = state.phases.findIndex(candidate => candidate.name === phaseName);
|
|
627
|
+
const phases =
|
|
628
|
+
index < 0
|
|
629
|
+
? [...state.phases, { name: phaseName, items: [item] }]
|
|
630
|
+
: state.phases.map((candidate, position) =>
|
|
631
|
+
position === index ? { name: candidate.name, items: [...candidate.items, item] } : candidate,
|
|
632
|
+
);
|
|
633
|
+
const origin: OnlyneTodoOrigin = { item_id: crypto.randomUUID(), phase: phaseName, title };
|
|
634
|
+
origins.set(origin.item_id, origin);
|
|
635
|
+
const link = makeLink(entry, context, origin, at);
|
|
636
|
+
decision.link_id = link.link_id;
|
|
637
|
+
const explanation = explanationText(plan, entry, context);
|
|
638
|
+
if (explanation !== undefined) decision.explanation = explanation;
|
|
639
|
+
// Native OMP lists have no goal field; the intent survives on the marker
|
|
640
|
+
// instead of being invented into the host list.
|
|
641
|
+
const empty = state.phases.every(candidate => candidate.items.length === 0);
|
|
642
|
+
const goalIntent = empty ? goalIntentOf(plan.goal) : undefined;
|
|
643
|
+
return {
|
|
644
|
+
next: { goal: state.goal, phases },
|
|
645
|
+
origins: [...origins.values()],
|
|
646
|
+
link,
|
|
647
|
+
decision,
|
|
648
|
+
...(goalIntent === undefined ? {} : { goalIntent }),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
default: {
|
|
652
|
+
const unreachable: never = plan.action;
|
|
653
|
+
throw new Error(t("todoUnexpectedAction", { action: String(unreachable) }));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function decisionKey(context: string, entry: Entry): string {
|
|
659
|
+
return `${context}\u0000${entry.message.sender}\u0000${entry.message.id}`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function makeLink(entry: Entry, context: string, origin: OnlyneTodoOrigin, at: string): OnlyneTodoLink {
|
|
663
|
+
return {
|
|
664
|
+
link_id: crypto.randomUUID(),
|
|
665
|
+
message_ref: { sender: entry.message.sender, id: entry.message.id },
|
|
666
|
+
thread_ref: entry.thread_ref,
|
|
667
|
+
context,
|
|
668
|
+
seq: entry.seq,
|
|
669
|
+
item_id: origin.item_id,
|
|
670
|
+
phase: origin.phase,
|
|
671
|
+
title: origin.title,
|
|
672
|
+
at,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function explanationText(plan: TodoDecision, entry: Entry, context: string): string | undefined {
|
|
677
|
+
const body = plan.explanation?.trim() || entry.message.msg.replace(/\s+/g, " ").trim();
|
|
678
|
+
if (body.length === 0) return undefined;
|
|
679
|
+
return `${t("todoSourceLine", { source: context, seq: entry.seq, author: entry.authenticated_author })}\n\n${body}`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function appendExplanation(plan: TodoDecision, entry: Entry, context: string): RichExplanation {
|
|
683
|
+
const text = explanationText(plan, entry, context);
|
|
684
|
+
if (text === undefined) {
|
|
685
|
+
throw new OnlyneError("invalid_request", t("todoExplanationRequired"));
|
|
686
|
+
}
|
|
687
|
+
return { kind: "explained", text };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function itemTime(minutes: number | undefined): RichTime {
|
|
691
|
+
if (minutes === undefined) return { kind: "missing" };
|
|
692
|
+
const max = Math.floor(Number.MAX_SAFE_INTEGER / 60_000);
|
|
693
|
+
if (!Number.isInteger(minutes) || minutes <= 0 || minutes > max) {
|
|
694
|
+
throw new OnlyneError("invalid_request", t("todoEstimateInvalid"));
|
|
695
|
+
}
|
|
696
|
+
return { kind: "estimated", budgetId: crypto.randomUUID(), minutes, elapsedMs: 0, runningSince: null };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function requiredText(value: string | undefined, action: string, field: string): string {
|
|
700
|
+
const text = value?.trim();
|
|
701
|
+
if (text === undefined || text.length === 0) {
|
|
702
|
+
throw new OnlyneError("decision_unavailable", t("todoMissingField", { action, field }));
|
|
703
|
+
}
|
|
704
|
+
return text;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function requiredGoal(goal: TodoDecision["goal"]): TodoStateGoal {
|
|
708
|
+
const objective = goal?.objective.trim();
|
|
709
|
+
const measure = goal?.measure.trim();
|
|
710
|
+
const doneWhen = goal?.doneWhen.trim();
|
|
711
|
+
if (!objective || !measure || !doneWhen) {
|
|
712
|
+
throw new OnlyneError("decision_unavailable", t("todoGoalRequired"));
|
|
713
|
+
}
|
|
714
|
+
return { objective, measure, doneWhen };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Native lists cannot carry a goal; record a complete one, ignore a partial one. */
|
|
718
|
+
function goalIntentOf(goal: TodoDecision["goal"]): TodoStateGoal | undefined {
|
|
719
|
+
const objective = goal?.objective.trim();
|
|
720
|
+
const measure = goal?.measure.trim();
|
|
721
|
+
const doneWhen = goal?.doneWhen.trim();
|
|
722
|
+
if (!objective || !measure || !doneWhen) return undefined;
|
|
723
|
+
return { objective, measure, doneWhen };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function liveSession(): AgentSession {
|
|
727
|
+
const mains = AgentRegistry.global()
|
|
728
|
+
.list()
|
|
729
|
+
.filter(ref => ref.kind === "main" && ref.session !== null);
|
|
730
|
+
if (mains.length === 0) {
|
|
731
|
+
throw new OnlyneError("unavailable", t("todoNoSession"));
|
|
732
|
+
}
|
|
733
|
+
if (mains.length > 1) {
|
|
734
|
+
throw new OnlyneError("unavailable", t("todoMultipleSessions"));
|
|
735
|
+
}
|
|
736
|
+
return mains[0]!.session!;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function stateTitles(state: TodoState): Set<string> {
|
|
740
|
+
const titles = new Set<string>();
|
|
741
|
+
for (const phase of state.phases) {
|
|
742
|
+
for (const item of phase.items) titles.add(`${phase.name}\u0000${item.title}`);
|
|
743
|
+
}
|
|
744
|
+
return titles;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function richTitles(state: RichState): Set<string> {
|
|
748
|
+
const titles = new Set<string>();
|
|
749
|
+
if (state.kind === "list") {
|
|
750
|
+
for (const phase of state.phases) {
|
|
751
|
+
for (const item of phase.items) titles.add(`${phase.name}\u0000${item.title}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return titles;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function pruneOrigins(
|
|
758
|
+
origins: Map<string, OnlyneTodoOrigin>,
|
|
759
|
+
present: ReadonlySet<string>,
|
|
760
|
+
): Map<string, OnlyneTodoOrigin> {
|
|
761
|
+
const kept = new Map<string, OnlyneTodoOrigin>();
|
|
762
|
+
for (const origin of origins.values()) {
|
|
763
|
+
if (present.has(`${origin.phase}\u0000${origin.title}`)) kept.set(origin.item_id, origin);
|
|
764
|
+
}
|
|
765
|
+
return kept;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function snapshotTitles(data: unknown): Set<string> | undefined {
|
|
769
|
+
const phases = recordField(data, "phases");
|
|
770
|
+
if (!Array.isArray(phases)) return undefined;
|
|
771
|
+
const titles = new Set<string>();
|
|
772
|
+
for (const phase of phases) {
|
|
773
|
+
const name = stringField(phase, "name");
|
|
774
|
+
const items = recordField(phase, "items");
|
|
775
|
+
if (name === undefined || !Array.isArray(items)) return undefined;
|
|
776
|
+
for (const item of items) {
|
|
777
|
+
const title = stringField(item, "title");
|
|
778
|
+
if (title === undefined) return undefined;
|
|
779
|
+
titles.add(`${name}\u0000${title}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return titles;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function onlyneMetadata(data: unknown, entryId: string): OnlyneTodoMetadata | undefined {
|
|
786
|
+
const value = recordField(data, "onlyne");
|
|
787
|
+
if (value === undefined) return undefined;
|
|
788
|
+
return parseMetadata(value, entryId);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function markerMetadata(data: unknown, entryId: string): OnlyneTodoMetadata {
|
|
792
|
+
const metadata = parseMetadata(data, entryId);
|
|
793
|
+
if (metadata === undefined) {
|
|
794
|
+
throw new Error(t("todoBadMetadataVersion", { entry: entryId }));
|
|
795
|
+
}
|
|
796
|
+
if (metadata.decision === undefined) {
|
|
797
|
+
throw new Error(t("todoNoDecisionRecord", { entry: entryId }));
|
|
798
|
+
}
|
|
799
|
+
return metadata;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function parseMetadata(value: unknown, entryId: string): OnlyneTodoMetadata | undefined {
|
|
803
|
+
if (recordField(value, "version") !== 1) return undefined;
|
|
804
|
+
const origins = parseOrigins(recordField(value, "origins"), entryId);
|
|
805
|
+
const links = parseLinks(recordField(value, "links"), entryId);
|
|
806
|
+
const decision = parseDecision(recordField(value, "decision"), entryId);
|
|
807
|
+
const goalIntent = parseGoalIntent(recordField(value, "goal_intent"), entryId);
|
|
808
|
+
return {
|
|
809
|
+
version: 1,
|
|
810
|
+
commit_id: stringField(value, "commit_id") ?? "",
|
|
811
|
+
origins,
|
|
812
|
+
links,
|
|
813
|
+
...(decision === undefined ? {} : { decision }),
|
|
814
|
+
...(goalIntent === undefined ? {} : { goal_intent: goalIntent }),
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function parseGoalIntent(value: unknown, entryId: string): TodoStateGoal | undefined {
|
|
819
|
+
if (value === undefined) return undefined;
|
|
820
|
+
return {
|
|
821
|
+
objective: requiredString(value, "objective", entryId),
|
|
822
|
+
measure: requiredString(value, "measure", entryId),
|
|
823
|
+
doneWhen: requiredString(value, "doneWhen", entryId),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function parseOrigins(value: unknown, entryId: string): OnlyneTodoOrigin[] {
|
|
828
|
+
if (!Array.isArray(value)) {
|
|
829
|
+
throw new Error(t("todoNoOrigins", { entry: entryId }));
|
|
830
|
+
}
|
|
831
|
+
return value.map(origin => ({
|
|
832
|
+
item_id: requiredString(origin, "item_id", entryId),
|
|
833
|
+
phase: requiredString(origin, "phase", entryId),
|
|
834
|
+
title: requiredString(origin, "title", entryId),
|
|
835
|
+
}));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function parseLinks(value: unknown, entryId: string): OnlyneTodoLink[] {
|
|
839
|
+
if (!Array.isArray(value)) {
|
|
840
|
+
throw new Error(t("todoNoLinks", { entry: entryId }));
|
|
841
|
+
}
|
|
842
|
+
return value.map(link => ({
|
|
843
|
+
link_id: requiredString(link, "link_id", entryId),
|
|
844
|
+
message_ref: requiredMessageRef(recordField(link, "message_ref"), entryId),
|
|
845
|
+
thread_ref: requiredThreadRef(recordField(link, "thread_ref"), entryId),
|
|
846
|
+
context: requiredString(link, "context", entryId),
|
|
847
|
+
seq: requiredString(link, "seq", entryId),
|
|
848
|
+
item_id: requiredString(link, "item_id", entryId),
|
|
849
|
+
phase: requiredString(link, "phase", entryId),
|
|
850
|
+
title: requiredString(link, "title", entryId),
|
|
851
|
+
at: requiredString(link, "at", entryId),
|
|
852
|
+
}));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function parseDecision(value: unknown, entryId: string): OnlyneTodoDecisionRecord | undefined {
|
|
856
|
+
if (value === undefined) return undefined;
|
|
857
|
+
const action = stringField(value, "action");
|
|
858
|
+
if (action !== "none" && action !== "associate" && action !== "append") {
|
|
859
|
+
throw new Error(t("todoUnknownRecordedAction", { entry: entryId }));
|
|
860
|
+
}
|
|
861
|
+
const linkId = stringField(value, "link_id");
|
|
862
|
+
const explanation = stringField(value, "explanation");
|
|
863
|
+
return {
|
|
864
|
+
key: requiredString(value, "key", entryId),
|
|
865
|
+
context: requiredString(value, "context", entryId),
|
|
866
|
+
message_ref: requiredMessageRef(recordField(value, "message_ref"), entryId),
|
|
867
|
+
thread_ref: requiredThreadRef(recordField(value, "thread_ref"), entryId),
|
|
868
|
+
seq: requiredString(value, "seq", entryId),
|
|
869
|
+
action,
|
|
870
|
+
...(linkId === undefined ? {} : { link_id: linkId }),
|
|
871
|
+
...(explanation === undefined ? {} : { explanation }),
|
|
872
|
+
at: requiredString(value, "at", entryId),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function requiredMessageRef(value: unknown, entryId: string): MessageRef {
|
|
877
|
+
return {
|
|
878
|
+
sender: requiredString(value, "sender", entryId),
|
|
879
|
+
id: requiredString(value, "id", entryId),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function requiredThreadRef(value: unknown, entryId: string): ThreadRef {
|
|
884
|
+
return {
|
|
885
|
+
owner: requiredString(value, "owner", entryId),
|
|
886
|
+
id: requiredString(value, "id", entryId),
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function requiredString(value: unknown, field: string, entryId: string): string {
|
|
891
|
+
const text = stringField(value, field);
|
|
892
|
+
if (text === undefined) {
|
|
893
|
+
throw new Error(t("todoMissingString", { entry: entryId, field }));
|
|
894
|
+
}
|
|
895
|
+
return text;
|
|
896
|
+
}
|