@sigrun/todo 0.0.0-stage → 0.1.1

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/dist/state.js ADDED
@@ -0,0 +1,282 @@
1
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
2
+ import { detectCycle } from "./graph.js";
3
+ /**
4
+ * True when `op` changed the task state — the calls whose post-change snapshot
5
+ * must be persisted for replay. Reads, failures and a no-effect `update` leave
6
+ * state untouched.
7
+ */
8
+ export function changesState(op) {
9
+ switch (op.kind) {
10
+ case "create":
11
+ case "delete":
12
+ case "clear":
13
+ return true;
14
+ case "update":
15
+ return op.changed;
16
+ case "list":
17
+ case "get":
18
+ case "error":
19
+ return false;
20
+ }
21
+ }
22
+ /**
23
+ * Allowed forward transitions per source status. `completed` is one-way to
24
+ * `deleted` (never back to `in_progress`); `deleted` is terminal. Idempotent
25
+ * same→same is allowed separately, so this table only enumerates transitions.
26
+ */
27
+ const VALID_TRANSITIONS = {
28
+ pending: new Set(["in_progress", "completed", "deleted"]),
29
+ in_progress: new Set(["pending", "completed", "deleted"]),
30
+ completed: new Set(["deleted"]),
31
+ deleted: new Set(),
32
+ };
33
+ function isTransitionValid(from, to) {
34
+ return from === to || VALID_TRANSITIONS[from].has(to);
35
+ }
36
+ const CreateTaskItemSchema = Type.Object({
37
+ subject: Type.String({ description: "Task subject line" }),
38
+ description: Type.Optional(Type.String({ description: "Long-form task description" })),
39
+ activeForm: Type.Optional(Type.String({
40
+ description: "Present-continuous spinner label shown while status is in_progress (e.g. 'writing tests')",
41
+ })),
42
+ blockedBy: Type.Optional(Type.Array(Type.Number(), {
43
+ description: "Existing task ids this item waits on. Cannot name another item in this same batch; create the prerequisite first, then the dependent with blockedBy.",
44
+ })),
45
+ });
46
+ /**
47
+ * The tool's parameter schema. Every `description` doubles as LLM-facing prompt
48
+ * copy, so the wording is part of the tool's behaviour.
49
+ */
50
+ export const TodoParamsSchema = Type.Object({
51
+ action: StringEnum(["create", "update", "list", "get", "delete", "clear"]),
52
+ subject: Type.Optional(Type.String({ description: "Task subject line (required for create unless tasks[] is set)" })),
53
+ description: Type.Optional(Type.String({ description: "Long-form task description" })),
54
+ activeForm: Type.Optional(Type.String({
55
+ description: "Present-continuous spinner label shown while status is in_progress (e.g. 'writing tests')",
56
+ })),
57
+ status: Type.Optional(StringEnum(["pending", "in_progress", "completed", "deleted"], {
58
+ description: "Set this task's status (update): one of pending, in_progress, completed, deleted. When action is list, filters returned tasks by this status.",
59
+ })),
60
+ blockedBy: Type.Optional(Type.Array(Type.Number(), { description: "Initial blockedBy ids (create only)" })),
61
+ addBlockedBy: Type.Optional(Type.Array(Type.Number(), { description: "Task ids to add to blockedBy (update only, additive merge)" })),
62
+ removeBlockedBy: Type.Optional(Type.Array(Type.Number(), { description: "Task ids to remove from blockedBy (update only)" })),
63
+ id: Type.Optional(Type.Number({ description: "Task id (required for update, get, delete)" })),
64
+ note: Type.Optional(Type.String({
65
+ description: "Short note stored on the task (update, delete): why it was dropped, or why it stays open",
66
+ })),
67
+ tasks: Type.Optional(Type.Array(CreateTaskItemSchema, {
68
+ description: "Batch create: independent tasks in one call, one-of with subject. The whole list is validated then committed together. Items cannot blockedBy each other; create the prerequisite first, then the dependent with blockedBy.",
69
+ })),
70
+ });
71
+ /** Per-item create fields: on a batch they belong to each `tasks[]` item. */
72
+ const BATCH_ITEM_FIELDS = ["description", "activeForm", "blockedBy"];
73
+ function errorResult(state, message) {
74
+ return { state, op: { kind: "error", message } };
75
+ }
76
+ function buildCreatedTask(id, spec, subject) {
77
+ const task = { id, subject, status: "pending" };
78
+ if (spec.description)
79
+ task.description = spec.description;
80
+ if (spec.activeForm)
81
+ task.activeForm = spec.activeForm;
82
+ if (spec.blockedBy?.length)
83
+ task.blockedBy = [...spec.blockedBy];
84
+ return task;
85
+ }
86
+ function validateCreateBlockedBy(state, blockedBy, siblingIds) {
87
+ if (!blockedBy?.length)
88
+ return undefined;
89
+ for (const dep of blockedBy) {
90
+ if (siblingIds.has(dep)) {
91
+ return `blockedBy: #${dep} is another item in this batch; create the prerequisite first, then the dependent with blockedBy`;
92
+ }
93
+ const depTask = state.tasks.find((task) => task.id === dep);
94
+ if (!depTask)
95
+ return `blockedBy: #${dep} not found`;
96
+ if (depTask.status === "deleted")
97
+ return `blockedBy: #${dep} is deleted`;
98
+ }
99
+ return undefined;
100
+ }
101
+ function sameNumberList(a, b) {
102
+ const x = a ?? [];
103
+ const y = b ?? [];
104
+ return x.length === y.length && x.every((value, index) => value === y[index]);
105
+ }
106
+ /**
107
+ * Did this `update` change anything? A no-effect update — `status` set to its
108
+ * current value, or a field re-sent unchanged — reports false so the caller can
109
+ * say "no change" instead of "updated", which otherwise drives a model to
110
+ * re-issue the same call in a loop.
111
+ *
112
+ * blockedBy is order-sensitive (the reducer preserves insertion order).
113
+ */
114
+ function taskChanged(before, after) {
115
+ return (before.subject !== after.subject ||
116
+ before.status !== after.status ||
117
+ before.description !== after.description ||
118
+ before.activeForm !== after.activeForm ||
119
+ before.note !== after.note ||
120
+ !sameNumberList(before.blockedBy, after.blockedBy));
121
+ }
122
+ /**
123
+ * Pure reducer: (state, params) → (state, op). Formatting belongs to the tool's
124
+ * response envelope and persistence to the log; validation stays in-reducer so
125
+ * every invariant is enforced before any state is written.
126
+ */
127
+ export function applyTodo(state, params) {
128
+ switch (params.action) {
129
+ case "create": {
130
+ const batch = params.tasks;
131
+ if (Array.isArray(batch) && params.subject !== undefined) {
132
+ return errorResult(state, "create requires subject or tasks[], not both");
133
+ }
134
+ if (Array.isArray(batch)) {
135
+ const topLevel = BATCH_ITEM_FIELDS.filter((field) => params[field] !== undefined);
136
+ if (topLevel.length) {
137
+ return errorResult(state, `create with tasks[] takes ${topLevel.join(", ")} on each item, not at the top level`);
138
+ }
139
+ if (batch.length === 0)
140
+ return errorResult(state, "tasks[] must be non-empty");
141
+ for (const [index, item] of batch.entries()) {
142
+ if (!item.subject.trim())
143
+ return errorResult(state, `tasks[${index}]: subject required for create`);
144
+ }
145
+ const siblingIds = new Set(batch.map((_, index) => state.nextId + index));
146
+ for (const item of batch) {
147
+ const blockedError = validateCreateBlockedBy(state, item.blockedBy, siblingIds);
148
+ if (blockedError)
149
+ return errorResult(state, blockedError);
150
+ }
151
+ const created = batch.map((item, index) => buildCreatedTask(state.nextId + index, item, item.subject));
152
+ return {
153
+ state: { tasks: [...state.tasks, ...created], nextId: state.nextId + created.length },
154
+ op: { kind: "create", taskIds: created.map((task) => task.id) },
155
+ };
156
+ }
157
+ if (!params.subject?.trim())
158
+ return errorResult(state, "subject required for create");
159
+ const blockedError = validateCreateBlockedBy(state, params.blockedBy, new Set());
160
+ if (blockedError)
161
+ return errorResult(state, blockedError);
162
+ const newTask = buildCreatedTask(state.nextId, params, params.subject);
163
+ return {
164
+ state: { tasks: [...state.tasks, newTask], nextId: state.nextId + 1 },
165
+ op: { kind: "create", taskIds: [newTask.id] },
166
+ };
167
+ }
168
+ case "update": {
169
+ if (params.id === undefined)
170
+ return errorResult(state, "id required for update");
171
+ const index = state.tasks.findIndex((task) => task.id === params.id);
172
+ if (index === -1)
173
+ return errorResult(state, `#${params.id} not found`);
174
+ const current = state.tasks[index];
175
+ const hasMutation = params.subject !== undefined ||
176
+ params.description !== undefined ||
177
+ params.activeForm !== undefined ||
178
+ params.status !== undefined ||
179
+ params.note !== undefined ||
180
+ (params.addBlockedBy && params.addBlockedBy.length > 0) ||
181
+ (params.removeBlockedBy && params.removeBlockedBy.length > 0);
182
+ if (!hasMutation) {
183
+ return errorResult(state, "update requires at least one mutable field: subject, description, activeForm, status, note, addBlockedBy, or removeBlockedBy");
184
+ }
185
+ let newStatus = current.status;
186
+ if (params.status !== undefined) {
187
+ if (!isTransitionValid(current.status, params.status)) {
188
+ return errorResult(state, `illegal transition ${current.status} → ${params.status}`);
189
+ }
190
+ newStatus = params.status;
191
+ }
192
+ let newBlockedBy = current.blockedBy ? [...current.blockedBy] : [];
193
+ if (params.removeBlockedBy?.length) {
194
+ const toRemove = new Set(params.removeBlockedBy);
195
+ newBlockedBy = newBlockedBy.filter((dep) => !toRemove.has(dep));
196
+ }
197
+ if (params.addBlockedBy?.length) {
198
+ for (const dep of params.addBlockedBy) {
199
+ if (dep === current.id)
200
+ return errorResult(state, `cannot block #${current.id} on itself`);
201
+ const depTask = state.tasks.find((task) => task.id === dep);
202
+ if (!depTask)
203
+ return errorResult(state, `addBlockedBy: #${dep} not found`);
204
+ if (depTask.status === "deleted")
205
+ return errorResult(state, `addBlockedBy: #${dep} is deleted`);
206
+ if (!newBlockedBy.includes(dep))
207
+ newBlockedBy.push(dep);
208
+ }
209
+ if (detectCycle(state.tasks, current.id, newBlockedBy)) {
210
+ return errorResult(state, "addBlockedBy would create a cycle in the blockedBy graph");
211
+ }
212
+ }
213
+ const updated = { ...current, status: newStatus };
214
+ if (params.subject !== undefined)
215
+ updated.subject = params.subject;
216
+ if (params.description !== undefined)
217
+ updated.description = params.description;
218
+ if (params.activeForm !== undefined)
219
+ updated.activeForm = params.activeForm;
220
+ if (params.note !== undefined)
221
+ updated.note = params.note;
222
+ if (newBlockedBy.length)
223
+ updated.blockedBy = newBlockedBy;
224
+ else
225
+ delete updated.blockedBy;
226
+ const tasks = [...state.tasks];
227
+ tasks[index] = updated;
228
+ return {
229
+ state: { tasks, nextId: state.nextId },
230
+ op: {
231
+ kind: "update",
232
+ id: updated.id,
233
+ fromStatus: current.status,
234
+ toStatus: newStatus,
235
+ changed: taskChanged(current, updated),
236
+ },
237
+ };
238
+ }
239
+ case "list": {
240
+ return {
241
+ state,
242
+ op: { kind: "list", ...(params.status !== undefined ? { statusFilter: params.status } : {}) },
243
+ };
244
+ }
245
+ case "get": {
246
+ if (params.id === undefined)
247
+ return errorResult(state, "id required for get");
248
+ const task = state.tasks.find((candidate) => candidate.id === params.id);
249
+ if (!task)
250
+ return errorResult(state, `#${params.id} not found`);
251
+ return { state, op: { kind: "get", task } };
252
+ }
253
+ case "delete": {
254
+ if (params.id === undefined)
255
+ return errorResult(state, "id required for delete");
256
+ const index = state.tasks.findIndex((task) => task.id === params.id);
257
+ if (index === -1)
258
+ return errorResult(state, `#${params.id} not found`);
259
+ const current = state.tasks[index];
260
+ if (current.status === "deleted")
261
+ return errorResult(state, `#${current.id} is already deleted`);
262
+ const updated = { ...current, status: "deleted" };
263
+ if (params.note !== undefined)
264
+ updated.note = params.note;
265
+ const tasks = [...state.tasks];
266
+ tasks[index] = updated;
267
+ return {
268
+ state: { tasks, nextId: state.nextId },
269
+ op: { kind: "delete", id: updated.id, subject: updated.subject },
270
+ };
271
+ }
272
+ case "clear": {
273
+ // `nextId` is deliberately kept (rpiv-todo resets it to 1): ids must
274
+ // never be reused, because every snapshot kept in the transcript would
275
+ // then point two tasks at the same id.
276
+ return {
277
+ state: { tasks: [], nextId: state.nextId },
278
+ op: { kind: "clear", count: state.tasks.length },
279
+ };
280
+ }
281
+ }
282
+ }
package/dist/tool.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The `todo` tool: the only writer of a conversation's task list, and the
3
+ * source of the snapshots `replay` reads.
4
+ *
5
+ * One call applies one reducer step ({@link applyTodo}) to the conversation's
6
+ * {@link TodoLog}, and the reducer's op decides what the result carries:
7
+ *
8
+ * - `output` — the model's terse status line, always ending with the open-item
9
+ * count (open = `pending` + `in_progress`);
10
+ * - `details` — the full post-change snapshot, so the transcript can be
11
+ * replayed. STATE-CHANGING calls only: `replay` ignores a snapshot's
12
+ * `action`, so a read-only result carrying `{tasks, nextId}` (a filtered
13
+ * `list`, say) would silently become the conversation's list;
14
+ * - `onChanged` — the panel's cue, fired once per call that changed the list,
15
+ * and never for a read, a failure or a no-effect update.
16
+ *
17
+ * An error op carries `isError` and the reducer's message, with no details.
18
+ *
19
+ * `callers: ["model"]` keeps the tool out of code mode: a nested call's result
20
+ * is not recorded in the transcript, so a script-made change could never be
21
+ * replayed. `replay` stays at its `"unsafe"` default — an interrupted call is
22
+ * abandoned and the model re-issues it, where `"safe"` could apply a
23
+ * non-idempotent create twice.
24
+ */
25
+ import { type ConversationId, type ToolRegistration } from "@earendil-works/pi-durable";
26
+ import type { TodoLog } from "./log.js";
27
+ import { TodoParamsSchema } from "./state.js";
28
+ export interface TodoToolDeps {
29
+ /** The conversation's cache in front of the transcript. */
30
+ log: TodoLog;
31
+ /** Called once per call whose result changed the list. */
32
+ onChanged: (conversationId: ConversationId) => void;
33
+ }
34
+ /** The `todo` tool over one conversation-scoped {@link TodoLog}. */
35
+ export declare function createTodoTool(deps: TodoToolDeps): ToolRegistration<typeof TodoParamsSchema>;
package/dist/tool.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The `todo` tool: the only writer of a conversation's task list, and the
3
+ * source of the snapshots `replay` reads.
4
+ *
5
+ * One call applies one reducer step ({@link applyTodo}) to the conversation's
6
+ * {@link TodoLog}, and the reducer's op decides what the result carries:
7
+ *
8
+ * - `output` — the model's terse status line, always ending with the open-item
9
+ * count (open = `pending` + `in_progress`);
10
+ * - `details` — the full post-change snapshot, so the transcript can be
11
+ * replayed. STATE-CHANGING calls only: `replay` ignores a snapshot's
12
+ * `action`, so a read-only result carrying `{tasks, nextId}` (a filtered
13
+ * `list`, say) would silently become the conversation's list;
14
+ * - `onChanged` — the panel's cue, fired once per call that changed the list,
15
+ * and never for a read, a failure or a no-effect update.
16
+ *
17
+ * An error op carries `isError` and the reducer's message, with no details.
18
+ *
19
+ * `callers: ["model"]` keeps the tool out of code mode: a nested call's result
20
+ * is not recorded in the transcript, so a script-made change could never be
21
+ * replayed. `replay` stays at its `"unsafe"` default — an interrupted call is
22
+ * abandoned and the model re-issues it, where `"safe"` could apply a
23
+ * non-idempotent create twice.
24
+ */
25
+ import { defineTool } from "@earendil-works/pi-durable";
26
+ import { applyTodo, changesState, TodoParamsSchema } from "./state.js";
27
+ /** Statuses as they read inside a sentence: `in_progress` is two words. */
28
+ const STATUS_TEXT = {
29
+ pending: "pending",
30
+ in_progress: "in progress",
31
+ completed: "completed",
32
+ deleted: "deleted",
33
+ };
34
+ /** `1 task` / `2 tasks`, with an optional filter name: `1 pending task`. */
35
+ function countOf(count, noun, scope) {
36
+ const text = scope === undefined ? noun : `${STATUS_TEXT[scope]} ${noun}`;
37
+ return `${count} ${text}${count === 1 ? "" : "s"}`;
38
+ }
39
+ /**
40
+ * The count sentence that ends every successful result — the model's answer to
41
+ * "how much is left". A read's filter never narrows it: it always counts the
42
+ * whole list.
43
+ */
44
+ function openSummary(state) {
45
+ const open = state.tasks.filter((task) => task.status === "pending" || task.status === "in_progress");
46
+ const inProgress = open.filter((task) => task.status === "in_progress").length;
47
+ const suffix = inProgress === 0 ? "" : ` (${inProgress} in progress)`;
48
+ return `${open.length} open${suffix}`;
49
+ }
50
+ /** `#3 Wire the panel` — a task named by id and subject. */
51
+ function labelOf(id, subject) {
52
+ return `#${id} ${subject}`;
53
+ }
54
+ /** The subject of the task an `update` or `delete` addressed. */
55
+ function subjectOf(state, id) {
56
+ return state.tasks.find((task) => task.id === id)?.subject ?? "";
57
+ }
58
+ /**
59
+ * `#3 [in_progress] Wire the panel` — one entry of a list. The bracket repeats
60
+ * the parameter's own spelling, so the model reads back the token it passes.
61
+ */
62
+ function listEntry(task) {
63
+ return `#${task.id} [${task.status}] ${task.subject}`;
64
+ }
65
+ /**
66
+ * The `list` view: the matching tasks and the whole-list count. Deleted tasks
67
+ * are tombstones — kept in the snapshot so replay and ids stay stable, but left
68
+ * out of an unfiltered list.
69
+ */
70
+ function listText(state, statusFilter, summary) {
71
+ const shown = statusFilter === undefined
72
+ ? state.tasks.filter((task) => task.status !== "deleted")
73
+ : state.tasks.filter((task) => task.status === statusFilter);
74
+ if (shown.length === 0) {
75
+ const scope = statusFilter === undefined ? "" : ` ${STATUS_TEXT[statusFilter]}`;
76
+ return `No${scope} tasks. ${summary}.`;
77
+ }
78
+ const header = countOf(shown.length, "task", statusFilter);
79
+ return `${header}: ${shown.map(listEntry).join("; ")}. ${summary}.`;
80
+ }
81
+ /**
82
+ * The `get` view: the task with the fields the list view omits — the
83
+ * description, what it waits on, and its note.
84
+ */
85
+ function getText(task, summary) {
86
+ const extras = [];
87
+ if (task.blockedBy?.length) {
88
+ extras.push(`blocked by ${task.blockedBy.map((id) => `#${id}`).join(", ")}`);
89
+ }
90
+ if (task.note !== undefined)
91
+ extras.push(`note: ${task.note}`);
92
+ const description = task.description === undefined ? "" : ` — ${task.description}`;
93
+ const suffix = extras.length === 0 ? "" : ` (${extras.join("; ")})`;
94
+ return `${listEntry(task)}${description}${suffix}. ${summary}.`;
95
+ }
96
+ /**
97
+ * The model's view of one round — the text {@link createTodoTool} returns as
98
+ * `output`. A mutation is one sentence; a read is a list; an error is its own
99
+ * message with no count, because nothing changed and a count would only pad the
100
+ * reason it failed.
101
+ */
102
+ function formatOutput(state, op) {
103
+ const summary = openSummary(state);
104
+ switch (op.kind) {
105
+ case "create": {
106
+ const created = op.taskIds.map((id) => labelOf(id, subjectOf(state, id))).join(", ");
107
+ return `Created ${created}. ${summary}.`;
108
+ }
109
+ case "update": {
110
+ const label = labelOf(op.id, subjectOf(state, op.id));
111
+ // A no-effect update says so: a bare "updated" drives a model to
112
+ // re-issue the same call in a loop.
113
+ if (!op.changed)
114
+ return `No change to ${label}. ${summary}.`;
115
+ if (op.fromStatus === op.toStatus)
116
+ return `Updated ${label}. ${summary}.`;
117
+ const to = STATUS_TEXT[op.toStatus];
118
+ return `Updated ${label}: ${STATUS_TEXT[op.fromStatus]} → ${to}. ${summary}.`;
119
+ }
120
+ case "delete":
121
+ return `Deleted ${labelOf(op.id, op.subject)}. ${summary}.`;
122
+ case "clear":
123
+ return `Cleared ${countOf(op.count, "task")}. ${summary}.`;
124
+ case "get":
125
+ return getText(op.task, summary);
126
+ case "list":
127
+ return listText(state, op.statusFilter, summary);
128
+ case "error":
129
+ return op.message;
130
+ }
131
+ }
132
+ /**
133
+ * The `details` of a state-changing result: the whole post-change list,
134
+ * deleted tasks included. `TodoSnapshot` is an interface without an index
135
+ * signature, so it is not structurally a `JsonValue`; `satisfies` checks the
136
+ * fields and the cast only satisfies the tool-result typing.
137
+ */
138
+ function snapshotOf(action, state) {
139
+ const snapshot = {
140
+ action,
141
+ tasks: state.tasks,
142
+ nextId: state.nextId,
143
+ };
144
+ return snapshot;
145
+ }
146
+ /** The `todo` tool over one conversation-scoped {@link TodoLog}. */
147
+ export function createTodoTool(deps) {
148
+ const { log, onChanged } = deps;
149
+ return defineTool({
150
+ name: "todo",
151
+ description: "Maintain this conversation's task list. Use it to plan multi-step work and to keep the user's panel current: one entry per unit of work, `in_progress` when you start it, `completed` when it is done.",
152
+ callers: ["model"],
153
+ parameters: TodoParamsSchema,
154
+ execute: async (args, api) => {
155
+ const { state, op } = await log.mutate(api.conversationId, (current) => applyTodo(current, args));
156
+ const output = [{ type: "text", text: formatOutput(state, op) }];
157
+ if (op.kind === "error")
158
+ return { output, isError: true };
159
+ // Reads, and an update that re-sent the values already stored: nothing
160
+ // changed, so there is nothing to persist and nothing to refresh. A
161
+ // snapshot here would re-stamp the transcript with the state it holds.
162
+ if (!changesState(op))
163
+ return { output };
164
+ onChanged(api.conversationId);
165
+ return { output, details: snapshotOf(args.action, state) };
166
+ },
167
+ });
168
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Canonical todo state for `@sigrun/todo`. The shape is pinned: every
3
+ * state-changing `todo` result carries it as `details`, and replay reads the
4
+ * latest snapshot off the transcript, so field names and field order are a
5
+ * cross-version compatibility surface.
6
+ */
7
+ export type TaskStatus = "pending" | "in_progress" | "completed" | "deleted";
8
+ export interface Task {
9
+ id: number;
10
+ subject: string;
11
+ description?: string;
12
+ activeForm?: string;
13
+ status: TaskStatus;
14
+ blockedBy?: number[];
15
+ note?: string;
16
+ }
17
+ export interface TodoState {
18
+ tasks: Task[];
19
+ nextId: number;
20
+ }
21
+ export declare const EMPTY_STATE: TodoState;
22
+ export type TodoAction = "create" | "update" | "list" | "get" | "delete" | "clear";
23
+ /** The `details` of every state-changing result: the full post-change state. */
24
+ export interface TodoSnapshot extends TodoState {
25
+ action: TodoAction;
26
+ }
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Canonical todo state for `@sigrun/todo`. The shape is pinned: every
3
+ * state-changing `todo` result carries it as `details`, and replay reads the
4
+ * latest snapshot off the transcript, so field names and field order are a
5
+ * cross-version compatibility surface.
6
+ */
7
+ export const EMPTY_STATE = { tasks: [], nextId: 1 };
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
- "name": "@sigrun/todo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@sigrun/todo",
3
+ "version": "0.1.1",
4
+ "description": "Agent-kept task list for sigrun: a todo list the agent owns, rendered in a conversation panel",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "CHANGELOG.md",
20
+ "THIRD_PARTY_NOTICES.md"
21
+ ],
22
+ "scripts": {
23
+ "test": "node scripts/check-manifest.mjs --expect-name @sigrun/todo && vitest run",
24
+ "typecheck": "tsc --noEmit",
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/chord": "1.3.0 || 1.4.0",
30
+ "@earendil-works/pi-ai": "1.3.0 || 1.4.0",
31
+ "@earendil-works/pi-durable": "1.3.0 || 1.4.0"
32
+ },
33
+ "dependencies": {},
34
+ "devDependencies": {
35
+ "@earendil-works/chord": "1.4.0",
36
+ "@earendil-works/pi-ai": "1.4.0",
37
+ "@earendil-works/pi-durable": "1.4.0",
38
+ "@sigrun/sigrun": "file:../sigrun",
39
+ "@sigrun/shared": "file:../sigrun/shared",
40
+ "@types/node": "^26.6.4",
41
+ "tsx": "^4.23.15",
42
+ "typescript": "^7.0.2",
43
+ "vitest": "^5.0.3"
44
+ },
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "github",
48
+ "url": "git+https://github.com/sigrun-ai/sigrun-todo.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/sigrun-ai/sigrun-todo/issues"
52
+ },
53
+ "homepage": "https://github.com/sigrun-ai/sigrun-todo#readme",
54
+ "publishConfig": {
55
+ "access": "public",
56
+ "provenance": true
57
+ }
58
+ }